⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc
crates/apps/tahuantinsuyu/src/shell.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(tahuantinsuyu): "Cielo ahora" + "General" como rows fijos al top del tree
1//! Shell — coordinador de los tres widgets.
2//!
3//! Es el "director de orquesta": dueño del tree, del canvas y del panel,
4//! reenvía eventos entre ellos y aplica las mutaciones en la store.
5//!
6//! Flujo:
7//!
8//! ```text
9//! Tree.Selected(Chart) → Shell → load chart + compose + set_mode(Wheel)
10//! Tree.Selected(Group/Contact)→ Shell → charts_under_* + set_mode(Thumbnails)
11//! Canvas.TimeOffsetChanged → Shell → compose(current_chart, off, requests)
12//! Canvas.LayerVisibility[T] → Shell → flip module_configs[transit][enabled]
13//! Panel.ControlChanged → Shell → update module_configs OR canvas visibility
14//! ```
15//!
16//! ## module_configs
17//!
18//! Mapa `module_id → JSON` con la configuración persistente de cada
19//! módulo (transit, progression, …). De ahí derivamos los
20//! `PipelineRequest` que la engine consume. Los toggles "visuales"
21//! del NatalModule (`show_sign_dial`, `show_houses`, …) NO viven acá
22//! — afectan solo el render del canvas, no la composición.
23
24use std::collections::HashMap;
25
26use gpui::{
27 ClickEvent, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
28 Window, div, prelude::*, px,
29};
30
31use tahuantinsuyu_canvas::{
32 AstrologyCanvas, CanvasEvent, CanvasMode, ThumbnailItem, ThumbnailScope,
33};
34use tahuantinsuyu_engine::{
35 LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest, compose_with_options,
36 svg_export,
37};
38use tahuantinsuyu_model::{
39 Chart, ChartId, ChartKind, ContactId, ModuleState, StoredBirthData, StoredChartConfig,
40 TreeSelection,
41};
42use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
43use tahuantinsuyu_store::Store;
44use tahuantinsuyu_tree::{parse_city_atlas_tsv, TahuantinsuyuTree, TreeEvent};
45use yahweh_core::{LayoutDirection, NodeId};
46use yahweh_theme::Theme;
47use yahweh_widget_container_core::ChildSlot;
48use yahweh_widget_splitter::{SplitContainer, SplitEvent};
49use yahweh_widget_theme_switcher::theme_switcher;
50
51/// Posición del panel de control dentro del shell. `Bottom` mantiene
52/// el layout histórico (tree+canvas arriba, panel abajo); las variantes
53/// laterales colapsan los splitters anidados en uno solo de 3 columnas.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum PanelDock {
56 Bottom,
57 Right,
58 Left,
59}
60
61impl PanelDock {
62 fn as_setting(&self) -> &'static str {
63 match self {
64 PanelDock::Bottom => "bottom",
65 PanelDock::Right => "right",
66 PanelDock::Left => "left",
67 }
68 }
69
70 fn from_setting(s: &str) -> Option<Self> {
71 match s {
72 "bottom" => Some(PanelDock::Bottom),
73 "right" => Some(PanelDock::Right),
74 "left" => Some(PanelDock::Left),
75 _ => None,
76 }
77 }
78}
79
80/// Status del broker brahman tal como lo vimos en el último ping.
81/// Se refresca cada 30 segundos desde un background task.
82#[derive(Clone, Debug)]
83pub enum BrahmanStatus {
84 /// Aún no probamos (boot, primer ciclo).
85 Pending,
86 /// Connect OK al broker, devolvió la lista de sessions activas.
87 Connected { session_count: usize },
88 /// Connect falló — broker no escucha en el socket o tomó timeout.
89 /// `reason` se incluye para diagnóstico en logs aunque la UI hoy
90 /// muestra solo "offline".
91 Offline {
92 #[allow(dead_code)]
93 reason: String,
94 },
95}
96
97pub struct Shell {
98 store: Store,
99 /// Los tres widgets viven como children de los splitters vía
100 /// AnyView clone; retenemos los Entity acá para que las
101 /// subscripciones sigan vivas y para poder rearmar el layout al
102 /// cambiar `dock` sin recrear los widgets.
103 tree: Entity<TahuantinsuyuTree>,
104 canvas: Entity<AstrologyCanvas>,
105 panel: Entity<ControlPanel>,
106 /// Splitter "exterior". En dock=Bottom es vertical con (main_split,
107 /// panel) como hijos; en dock=Right/Left es horizontal y agrupa
108 /// tree+canvas+panel en una sola tira.
109 outer_split: Entity<SplitContainer>,
110 /// Splitter horizontal interno con (tree, canvas). Solo se usa
111 /// cuando dock=Bottom; en docks laterales queda vivo pero sin ser
112 /// hijo del árbol activo.
113 main_split: Entity<SplitContainer>,
114 /// Dock activo del panel — determina cómo se arman los splitters
115 /// y cuáles flex se persisten.
116 dock: PanelDock,
117 /// Último estado conocido del broker brahman — refrescado cada
118 /// 30s desde el background task.
119 brahman_status: BrahmanStatus,
120 current_chart: Option<Chart>,
121 current_offset_minutes: i64,
122 /// Estado de los módulos overlay (transit, progression, …) por
123 /// `module_id`. Las claves dentro del JSON dependen del módulo (la
124 /// convención es `"enabled": bool` para el toggle principal).
125 module_configs: HashMap<String, serde_json::Value>,
126 /// Sequence counter para descartar resultados de cómputos
127 /// background que llegan después de uno más reciente. Cada
128 /// `render_current` lo incrementa y la closure async compara antes
129 /// de aplicar el render al canvas.
130 render_seq: u64,
131}
132
133impl Shell {
134 pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
135 cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
136
137 let tree = cx.new(|cx| {
138 let mut t = TahuantinsuyuTree::new(store.clone(), cx);
139 // Si hay un atlas custom en $XDG_DATA_HOME/tahuantinsuyu/
140 // atlas.tsv, lo cargamos y reemplazamos el atlas hardcoded
141 // de 90 ciudades. Formato TSV: name<TAB>lat<TAB>lon<TAB>tz_min.
142 if let Some(atlas) = load_city_atlas_from_xdg() {
143 t.set_city_atlas(atlas, cx);
144 }
145 t
146 });
147 let canvas = cx.new(AstrologyCanvas::new);
148 let panel = cx.new(ControlPanel::new);
149
150 cx.subscribe(&tree, |this: &mut Self, _, ev: &TreeEvent, cx| {
151 this.on_tree_event(ev, cx);
152 })
153 .detach();
154
155 cx.subscribe(&panel, |this: &mut Self, _, ev: &PanelEvent, cx| {
156 this.on_panel_event(ev, cx);
157 })
158 .detach();
159
160 cx.subscribe(&canvas, |this: &mut Self, _, ev: &CanvasEvent, cx| {
161 this.on_canvas_event(ev, cx);
162 })
163 .detach();
164
165 // Splitters vacíos — `apply_dock` los puebla según el layout
166 // activo. Horizontal/Vertical son defaults; cada apply ajusta la
167 // dirección antes de setear children.
168 let main_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Horizontal, cx));
169 let outer_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Vertical, cx));
170
171 // Persistir flex en `DragEnd`. La key del setting depende del
172 // dock activo, así no se pisan los flexes de un layout con los
173 // de otro al mudarse. Se lee dentro del closure para tomar el
174 // dock actualizado, no el capturado en `new`.
175 let store_main = store.clone();
176 cx.subscribe(&main_split, move |this: &mut Self, sc, ev: &SplitEvent, cx| {
177 if matches!(ev, SplitEvent::DragEnd) {
178 let key = split_key_main(this.dock);
179 save_split_flex(&store_main, key, sc.read(cx));
180 }
181 })
182 .detach();
183 let store_outer = store.clone();
184 cx.subscribe(&outer_split, move |this: &mut Self, sc, ev: &SplitEvent, cx| {
185 if matches!(ev, SplitEvent::DragEnd) {
186 let key = split_key_outer(this.dock);
187 save_split_flex(&store_outer, key, sc.read(cx));
188 }
189 })
190 .detach();
191
192 let dock = load_dock(&store).unwrap_or(PanelDock::Bottom);
193
194 let mut shell = Self {
195 store,
196 tree,
197 canvas,
198 panel,
199 outer_split,
200 main_split,
201 dock,
202 brahman_status: BrahmanStatus::Pending,
203 current_chart: None,
204 current_offset_minutes: 0,
205 module_configs: HashMap::new(),
206 render_seq: 0,
207 };
208 shell.apply_dock(dock, cx);
209 shell.refresh_chart_options(cx);
210 shell.spawn_brahman_status_loop(cx);
211 // Carta "Cielo ahora" cargada por default al boot — el usuario
212 // siempre arranca viendo el estado del firmamento actual,
213 // incluso si la store está vacía.
214 shell.apply_selection(TreeSelection::PresentSky, cx);
215 shell
216 }
217
218 /// Arma el árbol de splitters según el dock pedido y persiste la
219 /// elección. Idempotente: llamar con el dock actual reconstruye los
220 /// children con flexes leídos del setting (útil tras `new`).
221 pub fn apply_dock(&mut self, dock: PanelDock, cx: &mut Context<Self>) {
222 self.dock = dock;
223
224 let tree_view = gpui::AnyView::from(self.tree.clone());
225 let canvas_view = gpui::AnyView::from(self.canvas.clone());
226 let panel_view = gpui::AnyView::from(self.panel.clone());
227 let main_view = gpui::AnyView::from(self.main_split.clone());
228
229 match dock {
230 PanelDock::Bottom => {
231 let flex_main = load_split_flex_n(
232 &self.store,
233 split_key_main(dock),
234 &[1.0, 4.0],
235 );
236 let flex_outer = load_split_flex_n(
237 &self.store,
238 split_key_outer(dock),
239 &[4.0, 1.0],
240 );
241 self.main_split.update(cx, |sc, cx| {
242 sc.set_direction(LayoutDirection::Horizontal, cx);
243 sc.set_children(
244 vec![
245 ChildSlot {
246 id: NodeId::new("tts-tree"),
247 flex: flex_main[0],
248 label: None,
249 view: tree_view.clone(),
250 },
251 ChildSlot {
252 id: NodeId::new("tts-canvas"),
253 flex: flex_main[1],
254 label: None,
255 view: canvas_view.clone(),
256 },
257 ],
258 cx,
259 );
260 });
261 self.outer_split.update(cx, |sc, cx| {
262 sc.set_direction(LayoutDirection::Vertical, cx);
263 sc.set_children(
264 vec![
265 ChildSlot {
266 id: NodeId::new("tts-main"),
267 flex: flex_outer[0],
268 label: None,
269 view: main_view,
270 },
271 ChildSlot {
272 id: NodeId::new("tts-panel"),
273 flex: flex_outer[1],
274 label: None,
275 view: panel_view,
276 },
277 ],
278 cx,
279 );
280 });
281 }
282 PanelDock::Right => {
283 let flex = load_split_flex_n(
284 &self.store,
285 split_key_outer(dock),
286 &[1.0, 4.0, 1.5],
287 );
288 self.outer_split.update(cx, |sc, cx| {
289 sc.set_direction(LayoutDirection::Horizontal, cx);
290 sc.set_children(
291 vec![
292 ChildSlot {
293 id: NodeId::new("tts-tree"),
294 flex: flex[0],
295 label: None,
296 view: tree_view,
297 },
298 ChildSlot {
299 id: NodeId::new("tts-canvas"),
300 flex: flex[1],
301 label: None,
302 view: canvas_view,
303 },
304 ChildSlot {
305 id: NodeId::new("tts-panel"),
306 flex: flex[2],
307 label: None,
308 view: panel_view,
309 },
310 ],
311 cx,
312 );
313 });
314 }
315 PanelDock::Left => {
316 let flex = load_split_flex_n(
317 &self.store,
318 split_key_outer(dock),
319 &[1.5, 1.0, 4.0],
320 );
321 self.outer_split.update(cx, |sc, cx| {
322 sc.set_direction(LayoutDirection::Horizontal, cx);
323 sc.set_children(
324 vec![
325 ChildSlot {
326 id: NodeId::new("tts-panel"),
327 flex: flex[0],
328 label: None,
329 view: panel_view,
330 },
331 ChildSlot {
332 id: NodeId::new("tts-tree"),
333 flex: flex[1],
334 label: None,
335 view: tree_view,
336 },
337 ChildSlot {
338 id: NodeId::new("tts-canvas"),
339 flex: flex[2],
340 label: None,
341 view: canvas_view,
342 },
343 ],
344 cx,
345 );
346 });
347 }
348 }
349
350 if let Err(e) = self.store.set_setting("layout.panel_dock", dock.as_setting()) {
351 eprintln!("[shell] persist panel_dock: {}", e);
352 }
353 cx.notify();
354 }
355
356 /// Loop que cada 30s pregunta al broker la lista de sessions
357 /// activas y actualiza `brahman_status`. El cómputo bloqueante
358 /// (list_sessions_blocking abre su propio tokio runtime) corre en
359 /// el background_executor — no bloquea el UI thread. Cuando llega
360 /// el resultado, el `this.update` dispara cx.notify para repintar
361 /// el badge del header.
362 fn spawn_brahman_status_loop(&self, cx: &mut Context<Self>) {
363 cx.spawn(async move |this, cx| {
364 loop {
365 let result = cx
366 .background_executor()
367 .spawn(async {
368 brahman_sidecar::list_sessions_blocking("tahuantinsuyu-observer")
369 })
370 .await;
371 let _ = this.update(cx, |this, cx| {
372 this.brahman_status = match result {
373 Ok(list) => BrahmanStatus::Connected {
374 session_count: list.entries.len(),
375 },
376 Err(e) => BrahmanStatus::Offline {
377 reason: format!("{:?}", e),
378 },
379 };
380 cx.notify();
381 });
382 let timer = cx
383 .background_executor()
384 .timer(std::time::Duration::from_secs(30));
385 timer.await;
386 }
387 })
388 .detach();
389 }
390
391 /// Recarga la lista de opciones para los `Control::ChartPicker` y
392 /// la pushea al panel. Llamado al boot + tras cada
393 /// `TreeEvent::HierarchyChanged`.
394 fn refresh_chart_options(&self, cx: &mut Context<Self>) {
395 let charts = self.store.list_all_charts().unwrap_or_default();
396 let options: Vec<ChartOption> = charts
397 .into_iter()
398 .map(|c| ChartOption {
399 id: c.id.to_string(),
400 label: format!("{} — {}", c.label, format_birth_brief(&c.birth_data)),
401 })
402 .collect();
403 self.panel
404 .update(cx, |p, cx| p.set_chart_options(options, cx));
405 }
406
407 fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) {
408 let selection = match ev {
409 TreeEvent::Selected(s) => s,
410 TreeEvent::Opened(s) => s,
411 TreeEvent::HierarchyChanged => {
412 // La jerarquía cambió (alta/baja de cartas) — refrescar
413 // las opciones del picker para que aparezcan / desaparezcan
414 // en el dropdown.
415 self.refresh_chart_options(cx);
416 cx.notify();
417 return;
418 }
419 };
420 self.apply_selection(selection.clone(), cx);
421 }
422
423 fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
424 match sel {
425 TreeSelection::Chart(id) => {
426 let chart = match self.store.get_chart(id) {
427 Ok(c) => c,
428 Err(e) => {
429 eprintln!("[shell] get_chart {}: {}", id, e);
430 return;
431 }
432 };
433 let age = current_age_years(&chart.birth_data);
434 self.current_chart = Some(chart.clone());
435 self.current_offset_minutes = 0;
436 // 1) Defaults frescos para esta carta: edad objetivo =
437 // edad actual. Estos quedan en module_configs como
438 // valor base si el usuario nunca tocó el slider.
439 self.module_configs.clear();
440 for module_id in ["progression", "solar_arc", "planetary_return"] {
441 let entry = self
442 .module_configs
443 .entry(module_id.into())
444 .or_insert_with(|| serde_json::json!({}));
445 if let serde_json::Value::Object(map) = entry {
446 map.insert("target_age_years".into(), serde_json::json!(age));
447 }
448 }
449 // El módulo planetary_return además necesita un body
450 // por default — el shell elige "sun" si el usuario no
451 // tocó el Select. La persistencia luego puede pisar
452 // este valor.
453 if let Some(serde_json::Value::Object(map)) =
454 self.module_configs.get_mut("planetary_return")
455 {
456 map.entry(String::from("body"))
457 .or_insert(serde_json::json!("sun"));
458 }
459 // 2) Sobreescribir con lo que el usuario persistió la
460 // última vez para esta carta (SQLite `module_state`).
461 self.load_persisted_module_states(chart.id);
462 // 3) Sincronizar panel: active_kind + toggles/sliders.
463 self.panel.update(cx, |p, cx| {
464 p.set_active_kind(Some(chart.kind), cx);
465 });
466 self.sync_panel_from_configs(cx);
467 self.render_current(cx);
468 }
469 TreeSelection::Contact(id) => {
470 self.current_chart = None;
471 self.current_offset_minutes = 0;
472 let charts = self.store.list_charts(id).unwrap_or_default();
473 let items: Vec<ThumbnailItem> = charts
474 .into_iter()
475 .map(|c| ThumbnailItem {
476 chart_id: c.id,
477 label: SharedString::from(c.label),
478 subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
479 preview: None,
480 })
481 .collect();
482 self.canvas.update(cx, |c, cx| {
483 c.set_mode(
484 CanvasMode::Thumbnails {
485 scope: ThumbnailScope::Contact(id),
486 items,
487 },
488 cx,
489 );
490 });
491 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
492 }
493 TreeSelection::Group(id) => {
494 self.current_chart = None;
495 self.current_offset_minutes = 0;
496 let charts = self.store.charts_under_group(id).unwrap_or_default();
497 let items: Vec<ThumbnailItem> = charts
498 .into_iter()
499 .map(|c| ThumbnailItem {
500 chart_id: c.id,
501 label: SharedString::from(c.label),
502 subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
503 preview: None,
504 })
505 .collect();
506 self.canvas.update(cx, |c, cx| {
507 c.set_mode(
508 CanvasMode::Thumbnails {
509 scope: ThumbnailScope::Group(id),
510 items,
511 },
512 cx,
513 );
514 });
515 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
516 }
517 TreeSelection::GeneralRoot => {
518 // "General" agrupa los contactos sin grupo padre. El
519 // canvas muestra thumbnails de TODAS las cartas de
520 // esos contactos.
521 self.current_chart = None;
522 self.current_offset_minutes = 0;
523 let mut items: Vec<ThumbnailItem> = Vec::new();
524 if let Ok(contacts) = self.store.list_contacts(None) {
525 for ct in contacts {
526 if let Ok(charts) = self.store.list_charts(ct.id) {
527 for c in charts {
528 items.push(ThumbnailItem {
529 chart_id: c.id,
530 label: SharedString::from(c.label),
531 subtitle: Some(SharedString::from(format!(
532 "{} · {:?}",
533 ct.name, c.kind
534 ))),
535 preview: None,
536 });
537 }
538 }
539 }
540 }
541 // Reusamos el scope Group con un id sentinela "vacío":
542 // como GeneralRoot no es un Group real, dejamos que el
543 // canvas pinte la grilla con el set de items y nada
544 // más — el `scope` no se usa para nada que requiera
545 // el id.
546 self.canvas.update(cx, |c, cx| {
547 c.set_mode(
548 CanvasMode::Thumbnails {
549 scope: ThumbnailScope::Group(Default::default()),
550 items,
551 },
552 cx,
553 );
554 });
555 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
556 }
557 TreeSelection::PresentSky => {
558 // Carta efímera del momento: birth_data = ahora en
559 // Greenwich (UTC, lat=0, lon=0). Se construye al
560 // vuelo, no se persiste — el id sintético es
561 // `Default::default()`. Cada selección de PresentSky
562 // recomputa contra el reloj actual.
563 let chart = build_present_sky_chart();
564 self.current_chart = Some(chart);
565 self.current_offset_minutes = 0;
566 self.module_configs.clear();
567 self.panel
568 .update(cx, |p, cx| p.set_active_kind(Some(ChartKind::Natal), cx));
569 self.sync_panel_from_configs(cx);
570 self.render_current(cx);
571 }
572 }
573 }
574
575 /// Deriva los `PipelineRequest` activos a partir del `module_configs`.
576 fn build_requests(&self) -> Vec<PipelineRequest> {
577 let mut requests = Vec::new();
578 if module_enabled(&self.module_configs, "transit") {
579 requests.push(PipelineRequest::Transit);
580 }
581 if module_enabled(&self.module_configs, "progression") {
582 let age = self.module_age_or_current("progression");
583 requests.push(PipelineRequest::SecondaryProgression {
584 target_age_years: age,
585 });
586 }
587 if module_enabled(&self.module_configs, "solar_arc") {
588 let age = self.module_age_or_current("solar_arc");
589 requests.push(PipelineRequest::SolarArc {
590 target_age_years: age,
591 });
592 }
593 if module_enabled(&self.module_configs, "synastry") {
594 if let Some(partner) = self.resolve_synastry_partner() {
595 requests.push(PipelineRequest::Synastry {
596 partner_chart: Box::new(partner),
597 });
598 }
599 }
600 if module_enabled(&self.module_configs, "midpoints") {
601 requests.push(PipelineRequest::Midpoints);
602 }
603 if module_enabled(&self.module_configs, "uranian") {
604 requests.push(PipelineRequest::Uranian);
605 }
606 if module_enabled(&self.module_configs, "lots") {
607 requests.push(PipelineRequest::Lots);
608 }
609 if module_enabled(&self.module_configs, "fixed_stars") {
610 requests.push(PipelineRequest::FixedStars);
611 }
612 if module_enabled(&self.module_configs, "topocentric") {
613 requests.push(PipelineRequest::Topocentric);
614 }
615 if module_enabled(&self.module_configs, "primary_directions") {
616 let age = self.module_age_or_current("primary_directions");
617 let key = self
618 .module_configs
619 .get("primary_directions")
620 .and_then(|c| c.get("key"))
621 .and_then(|v| v.as_str())
622 .unwrap_or("naibod")
623 .to_string();
624 requests.push(PipelineRequest::PrimaryDirections {
625 target_age_years: age,
626 key,
627 });
628 }
629 if module_enabled(&self.module_configs, "composite") {
630 if let Some(partner) = self.resolve_composite_partner() {
631 requests.push(PipelineRequest::Composite {
632 partner_chart: Box::new(partner),
633 });
634 }
635 }
636 if module_enabled(&self.module_configs, "planetary_return") {
637 let age = self.module_age_or_current("planetary_return");
638 let body = self
639 .module_configs
640 .get("planetary_return")
641 .and_then(|c| c.get("body"))
642 .and_then(|v| v.as_str())
643 .unwrap_or("sun")
644 .to_string();
645 let shift_days = self
646 .module_configs
647 .get("planetary_return")
648 .and_then(|c| c.get("shift_days"))
649 .and_then(|v| v.as_f64())
650 .map(|v| v as i64)
651 .unwrap_or(0);
652 requests.push(PipelineRequest::PlanetaryReturn {
653 body,
654 target_age_years: age,
655 shift_days,
656 });
657 }
658 requests
659 }
660
661 /// Resuelve la carta partner para sinastría: 1) si el picker tiene
662 /// un `partner_chart_id` válido en `module_configs`, lo usa; 2)
663 /// si no, cae al automático (primera carta hermana del contacto
664 /// actual). `None` si nada matchea — el request se salta.
665 fn resolve_synastry_partner(&self) -> Option<Chart> {
666 let manual = self
667 .module_configs
668 .get("synastry")
669 .and_then(|c| c.get("partner_chart_id"))
670 .and_then(|v| v.as_str())
671 .and_then(|s| s.parse::<tahuantinsuyu_model::ChartId>().ok())
672 .and_then(|id| self.store.get_chart(id).ok());
673 manual.or_else(|| self.find_synastry_partner_auto())
674 }
675
676 fn find_synastry_partner_auto(&self) -> Option<Chart> {
677 let current = self.current_chart.as_ref()?;
678 let siblings = self.store.list_charts(current.contact_id).ok()?;
679 siblings.into_iter().find(|c| c.id != current.id)
680 }
681
682 /// Resuelve el partner para Composite — mismo patrón que Synastry:
683 /// 1) lee module_configs["composite"]["partner_chart_id"] y resuelve
684 /// el chart; 2) fallback al primer hermano del contacto actual.
685 fn resolve_composite_partner(&self) -> Option<Chart> {
686 let manual = self
687 .module_configs
688 .get("composite")
689 .and_then(|c| c.get("partner_chart_id"))
690 .and_then(|v| v.as_str())
691 .and_then(|s| s.parse::<tahuantinsuyu_model::ChartId>().ok())
692 .and_then(|id| self.store.get_chart(id).ok());
693 manual.or_else(|| self.find_synastry_partner_auto())
694 }
695
696 /// Deriva las `NatalOptions` activas a partir del `module_configs["natal"]`.
697 /// Si la entry no existe, devuelve defaults (majors=true, minors=false,
698 /// multiplier=1.0).
699 fn build_natal_options(&self) -> NatalOptions {
700 let cfg = self.module_configs.get("natal");
701 let read_bool = |key: &str, default: bool| -> bool {
702 cfg.and_then(|c| c.get(key))
703 .and_then(|v| v.as_bool())
704 .unwrap_or(default)
705 };
706 let read_f64 = |key: &str, default: f64| -> f64 {
707 cfg.and_then(|c| c.get(key))
708 .and_then(|v| v.as_f64())
709 .unwrap_or(default)
710 };
711 NatalOptions {
712 show_majors: read_bool("aspect_majors", true),
713 show_minors: read_bool("aspect_minors", false),
714 orb_multiplier: read_f64("orb_multiplier", 1.0),
715 show_dignities: read_bool("show_dignities", false),
716 }
717 }
718
719 /// Lee `module_state` desde SQLite para la carta dada y los mergea
720 /// con los defaults ya cargados en `module_configs`. Los valores
721 /// persistidos ganan sobre los defaults.
722 fn load_persisted_module_states(&mut self, chart_id: ChartId) {
723 let states = match self.store.list_module_states(chart_id) {
724 Ok(s) => s,
725 Err(e) => {
726 eprintln!("[shell] list_module_states {}: {}", chart_id, e);
727 return;
728 }
729 };
730 for st in states {
731 // Re-mergeamos `enabled` (columna separada en SQL) dentro
732 // del JSON config, así el resto del shell sigue leyendo
733 // todo desde una única estructura.
734 let mut combined = match st.config {
735 serde_json::Value::Object(m) => serde_json::Value::Object(m),
736 _ => serde_json::json!({}),
737 };
738 if let serde_json::Value::Object(map) = &mut combined {
739 map.insert("enabled".into(), serde_json::Value::Bool(st.enabled));
740 }
741 // Mergear sobre defaults previos (no sobreescribir si la
742 // entrada nueva no trae un campo).
743 match self.module_configs.entry(st.module_id) {
744 std::collections::hash_map::Entry::Vacant(v) => {
745 v.insert(combined);
746 }
747 std::collections::hash_map::Entry::Occupied(mut o) => {
748 if let (serde_json::Value::Object(dst), serde_json::Value::Object(src)) =
749 (o.get_mut(), &combined)
750 {
751 for (k, v) in src {
752 dst.insert(k.clone(), v.clone());
753 }
754 } else {
755 o.insert(combined);
756 }
757 }
758 }
759 }
760 }
761
762 /// Pushea cada toggle/slider/picker del `module_configs` al panel
763 /// para que la UI refleje el estado persistido al cargar una carta.
764 fn sync_panel_from_configs(&mut self, cx: &mut Context<Self>) {
765 let snapshot: Vec<(String, serde_json::Value)> = self
766 .module_configs
767 .iter()
768 .map(|(k, v)| (k.clone(), v.clone()))
769 .collect();
770 self.panel.update(cx, |p, cx| {
771 for (module_id, config) in &snapshot {
772 if let serde_json::Value::Object(map) = config {
773 for (key, value) in map {
774 if let Some(b) = value.as_bool() {
775 p.set_toggle(module_id, key, b, cx);
776 } else if let Some(f) = value.as_f64() {
777 p.set_slider(module_id, key, f, cx);
778 } else if let Some(s) = value.as_str() {
779 p.set_string(module_id, key, Some(s.to_string()), cx);
780 } else if value.is_null() {
781 p.set_string(module_id, key, None, cx);
782 }
783 }
784 }
785 }
786 });
787 }
788
789 /// Persiste el estado actual de un módulo a SQLite. Extrae
790 /// `enabled` del JSON y lo guarda en la columna dedicada; el resto
791 /// va al `config_json`.
792 fn persist_module(&self, module_id: &str) {
793 let Some(chart) = self.current_chart.as_ref() else {
794 return;
795 };
796 let Some(config) = self.module_configs.get(module_id) else {
797 return;
798 };
799 let enabled = config
800 .get("enabled")
801 .and_then(|v| v.as_bool())
802 .unwrap_or(false);
803 let mut clean = config.clone();
804 if let serde_json::Value::Object(map) = &mut clean {
805 map.remove("enabled");
806 }
807 let state = ModuleState {
808 chart_id: chart.id,
809 module_id: module_id.to_string(),
810 enabled,
811 config: clean,
812 };
813 if let Err(e) = self.store.upsert_module_state(&state) {
814 eprintln!("[shell] upsert_module_state {}: {}", module_id, e);
815 }
816 }
817
818 /// Lee `target_age_years` del módulo o cae a la edad actual del
819 /// sujeto (calculada desde la fecha de nacimiento y el reloj).
820 fn module_age_or_current(&self, module_id: &str) -> f64 {
821 self.module_configs
822 .get(module_id)
823 .and_then(|c| c.get("target_age_years"))
824 .and_then(|v| v.as_f64())
825 .unwrap_or_else(|| {
826 self.current_chart
827 .as_ref()
828 .map(|c| current_age_years(&c.birth_data))
829 .unwrap_or(0.0)
830 })
831 }
832
833 fn render_current(&mut self, cx: &mut Context<Self>) {
834 let Some(chart) = self.current_chart.as_ref() else {
835 return;
836 };
837 // Snapshot de inputs para mover al background. La sesión
838 // VSOP2013 vive en un static `OnceLock` adentro del bridge, así
839 // que es compartible read-only entre threads sin que ningún
840 // dato cruce más allá del Chart clonado + requests/options.
841 let chart = chart.clone();
842 let offset = self.current_offset_minutes;
843 let requests = self.build_requests();
844 let natal_options = self.build_natal_options();
845 self.render_seq = self.render_seq.wrapping_add(1);
846 let my_seq = self.render_seq;
847
848 cx.spawn(async move |this, cx| {
849 // El compute corre en el background_executor — no bloquea
850 // el UI thread. Para una rueda completa con varios overlays
851 // puede tomar 100-200ms; sin esto, los drags del slider se
852 // sentirían atorados.
853 let chart_for_bg = chart.clone();
854 let requests_for_bg = requests.clone();
855 let opts_for_bg = natal_options.clone();
856 let result = cx
857 .background_executor()
858 .spawn(async move {
859 compose_with_options(&chart_for_bg, offset, &requests_for_bg, &opts_for_bg)
860 })
861 .await;
862
863 let _ = this.update(cx, |this, cx| {
864 // Descartar si llegó un render más nuevo en el medio.
865 // Sin este check, durante un drag rápido un compute
866 // viejo podría sobrescribir el más reciente.
867 if this.render_seq != my_seq {
868 return;
869 }
870 match result {
871 Ok(render) => {
872 this.canvas.update(cx, |c, cx| {
873 c.set_mode(
874 CanvasMode::Wheel {
875 render: Box::new(render),
876 },
877 cx,
878 );
879 });
880 }
881 Err(e) => {
882 eprintln!(
883 "[shell] compose {} (+{}min, {} reqs): {}",
884 chart.id,
885 offset,
886 requests.len(),
887 e
888 );
889 }
890 }
891 });
892 })
893 .detach();
894 }
895
896 fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context<Self>) {
897 match ev {
898 CanvasEvent::TimeOffsetChanged(off) => {
899 self.current_offset_minutes = *off;
900 if self.current_chart.is_some() {
901 self.render_current(cx);
902 }
903 }
904 CanvasEvent::LayerVisibilityChanged { kind, visible } => {
905 // El toggle de Outer ([T]) no es visibility puro: dispara
906 // un pipeline distinto. Lo traducimos a un cambio en
907 // module_configs["transit"]["enabled"] + re-render.
908 if matches!(kind, LayerKind::Outer) {
909 set_module_enabled(&mut self.module_configs, "transit", *visible);
910 self.persist_module("transit");
911 self.panel.update(cx, |p, cx| {
912 p.set_toggle("transit", "enabled", *visible, cx)
913 });
914 self.render_current(cx);
915 return;
916 }
917 // El resto son visibility puros sobre el canvas. Sync el
918 // panel para que el toggle visual coincida con la hotkey.
919 let key = match kind {
920 LayerKind::SignDial => "show_sign_dial",
921 LayerKind::Houses => "show_houses",
922 LayerKind::Aspects => "show_aspects",
923 LayerKind::Bodies => "show_bodies",
924 _ => return,
925 };
926 self.panel
927 .update(cx, |p, cx| p.set_toggle("natal", key, *visible, cx));
928 }
929 CanvasEvent::ShowCoordsChanged(visible) => {
930 // Sync el toggle del panel para que coincida con la
931 // hotkey C. No persist — los coord labels son una
932 // preferencia visual, no parte del module_state.
933 self.panel.update(cx, |p, cx| {
934 p.set_toggle("natal", "show_coords", *visible, cx)
935 });
936 }
937 CanvasEvent::ChartRequested(_) => {
938 // Fase 7: doble click sobre un thumbnail abre la carta.
939 }
940 CanvasEvent::ExportSvgRequested => {
941 self.export_current_to_svg();
942 }
943 }
944 }
945
946 /// Recompone la carta actual + escribe el SVG a un archivo en
947 /// `$XDG_DATA_HOME/tahuantinsuyu/exports/<label>_<short_id>.svg`.
948 /// Logea la ruta a stderr — futuro: file save dialog GPUI.
949 fn export_current_to_svg(&self) {
950 let Some(chart) = self.current_chart.as_ref() else {
951 eprintln!("[shell] export svg: sin carta activa");
952 return;
953 };
954 let requests = self.build_requests();
955 let natal_options = self.build_natal_options();
956 let render = match compose_with_options(
957 chart,
958 self.current_offset_minutes,
959 &requests,
960 &natal_options,
961 ) {
962 Ok(r) => r,
963 Err(e) => {
964 eprintln!("[shell] export svg compose: {}", e);
965 return;
966 }
967 };
968 let svg = svg_export::render_to_svg(&render);
969 let dir = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu")
970 .map(|d| d.data_dir().join("exports"))
971 .unwrap_or_else(|| std::path::PathBuf::from("."));
972 if let Err(e) = std::fs::create_dir_all(&dir) {
973 eprintln!("[shell] mkdir {:?}: {}", dir, e);
974 return;
975 }
976 let safe_label: String = chart
977 .label
978 .chars()
979 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
980 .collect();
981 let short = format!("{}", chart.id).chars().take(8).collect::<String>();
982 let path = dir.join(format!("{}_{}.svg", safe_label, short));
983 if let Err(e) = std::fs::write(&path, svg) {
984 eprintln!("[shell] write {:?}: {}", path, e);
985 } else {
986 eprintln!("[shell] SVG exportado → {}", path.display());
987 }
988 }
989
990 fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
991 match ev {
992 PanelEvent::ControlChanged {
993 module_id, key, value,
994 } => {
995 let bool_val = value.as_bool().unwrap_or(true);
996 if module_id == "natal" {
997 // Distinguimos: show_* = visibility (no recompose),
998 // aspect_*/orb_* = filtros de engine (recompose +
999 // persist).
1000 let kind = match key.as_str() {
1001 "show_sign_dial" => Some(LayerKind::SignDial),
1002 "show_houses" => Some(LayerKind::Houses),
1003 "show_aspects" => Some(LayerKind::Aspects),
1004 "show_bodies" => Some(LayerKind::Bodies),
1005 _ => None,
1006 };
1007 if let Some(k) = kind {
1008 self.canvas
1009 .update(cx, |c, cx| c.set_layer_visible(k, bool_val, cx));
1010 } else if key == "show_coords" {
1011 // Coord labels viven en el canvas (no son una
1012 // capa pintada como otros show_*). Sync sin
1013 // recompose ni persist en module_state.
1014 self.canvas
1015 .update(cx, |c, cx| c.set_show_coords(bool_val, cx));
1016 } else {
1017 // Filtros: actualizar module_configs + recompose.
1018 let entry = self
1019 .module_configs
1020 .entry("natal".into())
1021 .or_insert_with(|| serde_json::json!({}));
1022 if let serde_json::Value::Object(map) = entry {
1023 map.insert(key.clone(), value.clone());
1024 }
1025 self.persist_module("natal");
1026 self.render_current(cx);
1027 }
1028 } else {
1029 // Cualquier otro módulo: actualizamos su config y
1030 // recompomemos. La engine vuelve a llamarse con el
1031 // PipelineRequest derivado del nuevo estado.
1032 let entry = self
1033 .module_configs
1034 .entry(module_id.clone())
1035 .or_insert_with(|| serde_json::json!({}));
1036 if let serde_json::Value::Object(map) = entry {
1037 map.insert(key.clone(), value.clone());
1038 }
1039 // Transit, Synastry y Solar Return comparten el
1040 // outer ring del canvas — son mutuamente excluyentes.
1041 // Al prender uno, apagamos los otros + sync panel +
1042 // persist.
1043 if key == "enabled" && bool_val && OUTER_RING_MODULES.contains(&module_id.as_str()) {
1044 for &other in OUTER_RING_MODULES.iter() {
1045 if other != module_id && module_enabled(&self.module_configs, other) {
1046 set_module_enabled(&mut self.module_configs, other, false);
1047 let other_str = other.to_string();
1048 self.panel.update(cx, |p, cx| {
1049 p.set_toggle(&other_str, "enabled", false, cx)
1050 });
1051 self.persist_module(&other_str);
1052 }
1053 }
1054 }
1055 // Sincronizar visualmente el toggle [T] del canvas
1056 // cuando el cambio afecta el outer ring (transit,
1057 // synastry o solar_return).
1058 if OUTER_RING_MODULES.contains(&module_id.as_str()) && key == "enabled" {
1059 self.canvas.update(cx, |c, cx| {
1060 c.set_layer_visible(LayerKind::Outer, bool_val, cx)
1061 });
1062 }
1063 self.persist_module(module_id);
1064 self.render_current(cx);
1065 }
1066 }
1067 PanelEvent::ModuleToggled { .. } => {
1068 // Fase 7: encender/apagar módulos enteros desde un
1069 // header con switch (vs. el toggle por-control de hoy).
1070 }
1071 }
1072 }
1073}
1074
1075// =====================================================================
1076// Helpers de module_configs
1077// =====================================================================
1078
1079// OUTER_RING_MODULES viene de tahuantinsuyu_engine — single source of
1080// truth. Shell y canvas leen del mismo slice.
1081
1082
1083/// Lee `$XDG_DATA_HOME/tahuantinsuyu/atlas.tsv` si existe y lo parsea
1084/// como atlas de ciudades. Devuelve `None` cuando no hay archivo o
1085/// quedó vacío después del parse — el tree cae al atlas hardcoded.
1086fn load_city_atlas_from_xdg() -> Option<Vec<tahuantinsuyu_tree::CityPreset>> {
1087 let path = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu")
1088 .map(|d| d.data_dir().join("atlas.tsv"))?;
1089 if !path.exists() {
1090 return None;
1091 }
1092 let content = std::fs::read_to_string(&path).ok()?;
1093 let atlas = parse_city_atlas_tsv(&content);
1094 if atlas.is_empty() {
1095 eprintln!(
1096 "[shell] atlas.tsv encontrado en {:?} pero sin filas válidas — fallback a hardcoded",
1097 path
1098 );
1099 return None;
1100 }
1101 eprintln!("[shell] atlas custom cargado: {} ciudades", atlas.len());
1102 Some(atlas)
1103}
1104
1105/// Carta efímera del "Cielo ahora": birth_data = momento actual en
1106/// Greenwich (UTC, lat 51.4769°, lon 0°). El `Chart` se construye al
1107/// vuelo, NO se persiste en la store, y los IDs son `Default` (todo
1108/// ceros) — la carta es un singleton conceptual de la vista, no un
1109/// registro. Los módulos que consultan `current_chart.id` deben
1110/// tolerar este ID sentinela.
1111fn build_present_sky_chart() -> Chart {
1112 use std::time::{SystemTime, UNIX_EPOCH};
1113 let secs = SystemTime::now()
1114 .duration_since(UNIX_EPOCH)
1115 .map(|d| d.as_secs() as i64)
1116 .unwrap_or(0);
1117 let (year, month, day, hour, minute, second) = unix_to_civil_utc(secs);
1118 let birth = StoredBirthData {
1119 year,
1120 month,
1121 day,
1122 hour,
1123 minute,
1124 second: second as f64,
1125 tz_offset_minutes: 0,
1126 // Greenwich Royal Observatory — origen histórico del meridiano
1127 // primario. Lat = 51°28'38"N, Lon = 0°.
1128 latitude_deg: 51.4769,
1129 longitude_deg: 0.0,
1130 altitude_m: 47.0,
1131 time_certainty: Default::default(),
1132 subject_name: Some("Cielo".into()),
1133 birthplace_label: Some("Greenwich (UTC)".into()),
1134 };
1135 Chart {
1136 id: ChartId::default(),
1137 contact_id: ContactId::default(),
1138 kind: ChartKind::Natal,
1139 label: format!(
1140 "Cielo {:04}-{:02}-{:02} {:02}:{:02} UTC",
1141 year, month, day, hour, minute
1142 ),
1143 birth_data: birth,
1144 config: StoredChartConfig::default(),
1145 related_chart_id: None,
1146 created_at_ms: 0,
1147 }
1148}
1149
1150/// Convierte un timestamp Unix (segundos UTC desde 1970-01-01) a
1151/// componentes calendario proleptic-Gregorianos `(year, month, day,
1152/// hour, minute, second)`. Algoritmo de Howard Hinnant
1153/// (`days_to_civil`), exacto en todo el rango representable por i64.
1154fn unix_to_civil_utc(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
1155 let day_seconds: i64 = 86_400;
1156 let z = secs.div_euclid(day_seconds);
1157 let s = secs.rem_euclid(day_seconds);
1158 // Hinnant: shift z para que el "era" empiece en 0000-03-01.
1159 let z = z + 719_468;
1160 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1161 let doe = (z - era * 146_097) as u32; // [0, 146096]
1162 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1163 let y = yoe as i64 + era * 400;
1164 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
1165 let mp = (5 * doy + 2) / 153; // [0, 11]
1166 let day = doy - (153 * mp + 2) / 5 + 1;
1167 let month = if mp < 10 { mp + 3 } else { mp - 9 };
1168 let year = if month <= 2 { (y + 1) as i32 } else { y as i32 };
1169 let hour = (s / 3600) as u32;
1170 let minute = ((s % 3600) / 60) as u32;
1171 let second = (s % 60) as u32;
1172 (year, month, day, hour, minute, second)
1173}
1174
1175/// Etiqueta breve para mostrar al elegir una carta en el picker:
1176/// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
1177fn format_birth_brief(birth: &tahuantinsuyu_model::StoredBirthData) -> String {
1178 let date = format!("{:04}-{:02}-{:02}", birth.year, birth.month, birth.day);
1179 match &birth.birthplace_label {
1180 Some(p) if !p.is_empty() => format!("{} · {}", date, p),
1181 _ => date,
1182 }
1183}
1184
1185/// Edad en años decimales desde el nacimiento hasta el reloj actual.
1186/// Aproximación: ignora la TZ de nacimiento (no afecta a resolución de
1187/// año) y usa una fracción de año tropical sobre los segundos Unix.
1188fn current_age_years(birth: &tahuantinsuyu_model::StoredBirthData) -> f64 {
1189 use std::time::{SystemTime, UNIX_EPOCH};
1190 let now_secs = SystemTime::now()
1191 .duration_since(UNIX_EPOCH)
1192 .map(|d| d.as_secs_f64())
1193 .unwrap_or(0.0);
1194 let birth_year_frac = birth.year as f64
1195 + (birth.month.saturating_sub(1) as f64) / 12.0
1196 + (birth.day.saturating_sub(1) as f64) / 365.25;
1197 let now_year_frac = 1970.0 + now_secs / (365.2422 * 86400.0);
1198 (now_year_frac - birth_year_frac).max(0.0)
1199}
1200
1201fn module_enabled(cfgs: &HashMap<String, serde_json::Value>, id: &str) -> bool {
1202 cfgs.get(id)
1203 .and_then(|c| c.get("enabled"))
1204 .and_then(|v| v.as_bool())
1205 .unwrap_or(false)
1206}
1207
1208fn set_module_enabled(
1209 cfgs: &mut HashMap<String, serde_json::Value>,
1210 id: &str,
1211 enabled: bool,
1212) {
1213 let entry = cfgs
1214 .entry(id.to_string())
1215 .or_insert_with(|| serde_json::json!({}));
1216 if let serde_json::Value::Object(map) = entry {
1217 map.insert("enabled".into(), serde_json::Value::Bool(enabled));
1218 }
1219}
1220
1221/// Lee del `settings` el flex de un splitter en formato "f0,f1,..." y
1222/// lo devuelve como `Vec<f32>` con la misma longitud que `defaults`.
1223/// Si no hay nada persistido, faltan campos, o algún flex es ≤0, cae a
1224/// `defaults`. Validación estricta porque un flex 0 colapsa al panel.
1225fn load_split_flex_n(store: &Store, key: &str, defaults: &[f32]) -> Vec<f32> {
1226 let Ok(Some(raw)) = store.get_setting(key) else {
1227 return defaults.to_vec();
1228 };
1229 let parsed: Vec<f32> = raw
1230 .split(',')
1231 .filter_map(|s| s.trim().parse::<f32>().ok())
1232 .collect();
1233 if parsed.len() != defaults.len() || parsed.iter().any(|&f| f <= 0.0) {
1234 return defaults.to_vec();
1235 }
1236 parsed
1237}
1238
1239/// Persiste los flex actuales de un splitter — soporta N children.
1240fn save_split_flex(store: &Store, key: &str, sc: &SplitContainer) {
1241 let children = sc.children();
1242 if children.is_empty() {
1243 return;
1244 }
1245 let payload: String = children
1246 .iter()
1247 .map(|c| format!("{:.4}", c.flex))
1248 .collect::<Vec<_>>()
1249 .join(",");
1250 if let Err(e) = store.set_setting(key, &payload) {
1251 eprintln!("[shell] save_split_flex {}: {}", key, e);
1252 }
1253}
1254
1255/// Key del setting donde se persiste el splitter "outer" (el de mayor
1256/// nivel del árbol). En dock=Bottom guarda (main,panel); en docks
1257/// laterales guarda los flex de las 3 columnas — usamos keys distintas
1258/// para no pisar valores entre layouts.
1259fn split_key_outer(dock: PanelDock) -> &'static str {
1260 match dock {
1261 PanelDock::Bottom => "layout.outer_split",
1262 PanelDock::Right => "layout.dock_right",
1263 PanelDock::Left => "layout.dock_left",
1264 }
1265}
1266
1267/// Key del setting del splitter horizontal interno. Solo se usa cuando
1268/// dock=Bottom (en docks laterales no hay main_split activo).
1269fn split_key_main(dock: PanelDock) -> &'static str {
1270 match dock {
1271 PanelDock::Bottom => "layout.main_split",
1272 // En docks laterales el main_split está dormido — escribir acá
1273 // no hace daño pero tampoco se usa al recargar.
1274 PanelDock::Right => "layout.main_split_right",
1275 PanelDock::Left => "layout.main_split_left",
1276 }
1277}
1278
1279fn load_dock(store: &Store) -> Option<PanelDock> {
1280 let raw = store.get_setting("layout.panel_dock").ok().flatten()?;
1281 PanelDock::from_setting(raw.trim())
1282}
1283
1284impl Shell {
1285 /// Tres botones compactos en el header — uno por dock disponible.
1286 /// El dock activo se marca con `bg=accent`; los demás van planos.
1287 /// Click llama a `apply_dock` que reorganiza splitters y persiste.
1288 fn render_dock_switcher(
1289 &self,
1290 theme: &yahweh_theme::Theme,
1291 cx: &mut Context<Self>,
1292 ) -> impl IntoElement {
1293 let mut row = div()
1294 .id("tts-dock-switcher")
1295 .flex()
1296 .flex_row()
1297 .gap(px(2.0))
1298 .px(px(2.0))
1299 .py(px(2.0))
1300 .rounded(px(6.0))
1301 .bg(theme.bg_panel_alt.clone())
1302 .border_1()
1303 .border_color(theme.border);
1304
1305 for (dock, glyph) in [
1306 (PanelDock::Left, "◧"),
1307 (PanelDock::Bottom, "▭"),
1308 (PanelDock::Right, "◨"),
1309 ] {
1310 let active = self.dock == dock;
1311 let fg = if active { theme.fg_text } else { theme.fg_muted };
1312 let id: SharedString = SharedString::from(format!("tts-dock-{}", dock.as_setting()));
1313 let mut btn = div()
1314 .id(gpui::ElementId::from(id))
1315 .w(px(22.0))
1316 .h(px(20.0))
1317 .flex()
1318 .items_center()
1319 .justify_center()
1320 .rounded(px(4.0))
1321 .text_size(px(12.0))
1322 .text_color(fg)
1323 .hover(|s| s.bg(theme.bg_row_hover))
1324 .child(SharedString::from(glyph))
1325 .on_click(cx.listener(move |this, _: &ClickEvent, _w, cx| {
1326 if this.dock != dock {
1327 this.apply_dock(dock, cx);
1328 }
1329 }));
1330 if active {
1331 btn = btn.bg(theme.accent);
1332 }
1333 row = row.child(btn);
1334 }
1335
1336 row
1337 }
1338}
1339
1340impl Render for Shell {
1341 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1342 let theme = Theme::global(cx).clone();
1343
1344 // Badge del estado del broker brahman — pequeña pill con
1345 // color según el estado actual del ping cada-30s.
1346 let (badge_text, badge_color) = match &self.brahman_status {
1347 BrahmanStatus::Pending => ("Brahman · …".to_string(), theme.fg_muted),
1348 BrahmanStatus::Connected { session_count } => (
1349 format!("Brahman ✓ {} sessions", session_count),
1350 theme.accent,
1351 ),
1352 BrahmanStatus::Offline { .. } => {
1353 ("Brahman · offline".to_string(), theme.fg_disabled)
1354 }
1355 };
1356 let brahman_badge = div()
1357 .px(px(8.0))
1358 .py(px(2.0))
1359 .rounded(px(8.0))
1360 .bg(theme.bg_panel_alt.clone())
1361 .border_1()
1362 .border_color(theme.border)
1363 .text_size(px(10.0))
1364 .text_color(badge_color)
1365 .child(SharedString::from(badge_text));
1366
1367 let header = div()
1368 .h(px(34.0))
1369 .px(px(12.0))
1370 .flex()
1371 .flex_row()
1372 .items_center()
1373 .gap(px(10.0))
1374 .border_b_1()
1375 .border_color(theme.border)
1376 .child(
1377 div()
1378 .text_size(px(13.0))
1379 .text_color(theme.fg_text)
1380 .child("☉ Tahuantinsuyu"),
1381 )
1382 .child(
1383 div()
1384 .text_size(px(10.0))
1385 .text_color(theme.fg_muted)
1386 .child("estudio de astrología profesional"),
1387 )
1388 .child(div().flex_grow())
1389 .child(self.render_dock_switcher(&theme, cx))
1390 .child(brahman_badge)
1391 .child(theme_switcher(cx));
1392
1393 let body = div()
1394 .flex_grow()
1395 .w_full()
1396 .child(self.outer_split.clone());
1397
1398 div()
1399 .size_full()
1400 .bg(theme.bg_app.clone())
1401 .flex()
1402 .flex_col()
1403 .child(header)
1404 .child(body)
1405 }
1406}
1407
1408// =====================================================================
1409// Tests de integración del Shell
1410// =====================================================================
1411//
1412// Cubren los caminos que combinan lógica del shell con persistencia y
1413// el bridge real de eternal. Los tests puramente unitarios de cada
1414// crate (engine, store, modules) viven en sus respectivos `tests`
1415// modules; acá testeamos los wiring points del binario.
1416
1417#[cfg(test)]
1418mod tests {
1419 use super::*;
1420 use gpui::TestAppContext;
1421
1422 #[test]
1423 fn unix_to_civil_at_epoch() {
1424 assert_eq!(unix_to_civil_utc(0), (1970, 1, 1, 0, 0, 0));
1425 }
1426
1427 #[test]
1428 fn unix_to_civil_known_dates() {
1429 // 2024-01-01T00:00:00 UTC = 1704067200
1430 assert_eq!(unix_to_civil_utc(1_704_067_200), (2024, 1, 1, 0, 0, 0));
1431 // 2024-02-29T12:34:56 UTC = año bisiesto
1432 let secs = 1_704_067_200 + (31 + 28) * 86_400 + 12 * 3600 + 34 * 60 + 56;
1433 assert_eq!(unix_to_civil_utc(secs), (2024, 2, 29, 12, 34, 56));
1434 }
1435
1436 #[test]
1437 fn unix_to_civil_pre_epoch_wraps_correctly() {
1438 // -1 segundo = 1969-12-31T23:59:59 UTC
1439 assert_eq!(unix_to_civil_utc(-1), (1969, 12, 31, 23, 59, 59));
1440 }
1441
1442 #[test]
1443 fn unix_to_civil_year_2000() {
1444 // 2000-01-01T00:00:00 UTC = 946684800
1445 assert_eq!(unix_to_civil_utc(946_684_800), (2000, 1, 1, 0, 0, 0));
1446 }
1447
1448 fn sample_chart_for(_contact_id: ContactId) -> (StoredBirthData, StoredChartConfig) {
1449 (
1450 StoredBirthData {
1451 year: 1987,
1452 month: 3,
1453 day: 14,
1454 hour: 5,
1455 minute: 22,
1456 second: 0.0,
1457 tz_offset_minutes: -240,
1458 latitude_deg: 10.4806,
1459 longitude_deg: -66.9036,
1460 altitude_m: 900.0,
1461 time_certainty: Default::default(),
1462 subject_name: Some("Sergio".into()),
1463 birthplace_label: Some("Caracas".into()),
1464 },
1465 StoredChartConfig::default(),
1466 )
1467 }
1468
1469 /// Smoke test: el Shell se construye sin panic con una store
1470 /// in-memory. Cubre que las suscripciones cross-widget (tree, panel,
1471 /// canvas, ambos splitters) se cablean sin colisiones y que el
1472 /// background loop del brahman status arranca limpio.
1473 #[gpui::test]
1474 fn shell_constructs_smoke(cx: &mut TestAppContext) {
1475 cx.update(|cx| {
1476 Theme::install_default(cx);
1477 let store = Store::in_memory().expect("in-memory store");
1478 let _shell = cx.new(|cx| Shell::new(store, cx));
1479 // Si llegamos acá sin panic, el cableado funciona.
1480 });
1481 }
1482
1483 /// La selección de una carta vía `apply_selection` (mismo pathway
1484 /// que dispara el TreeEvent) puebla `current_chart` y arranca un
1485 /// compute. El render asíncrono se resuelve después; verificamos
1486 /// solo los efectos sincrónicos: chart cargada y `render_seq`
1487 /// avanzado.
1488 #[gpui::test]
1489 fn select_chart_updates_current(cx: &mut TestAppContext) {
1490 cx.update(|cx| {
1491 Theme::install_default(cx);
1492 let store = Store::in_memory().expect("store");
1493 let group = store.create_group(None, "Test", None).unwrap();
1494 let contact = store
1495 .create_contact(Some(group.id), "Subject", None)
1496 .unwrap();
1497 let (birth, config) = sample_chart_for(contact.id);
1498 let chart = store
1499 .create_chart(contact.id, ChartKind::Natal, "Natal", &birth, &config, None)
1500 .unwrap();
1501
1502 let shell = cx.new(|cx| Shell::new(store, cx));
1503 shell.update(cx, |s, cx| {
1504 s.apply_selection(TreeSelection::Chart(chart.id), cx);
1505 });
1506 shell.read_with(cx, |s, _| {
1507 let cur = s.current_chart.as_ref().expect("current_chart set");
1508 assert_eq!(cur.id, chart.id);
1509 assert_eq!(cur.label, "Natal");
1510 assert!(s.render_seq >= 1, "render_seq debió avanzar al menos a 1");
1511 });
1512 });
1513 }
1514
1515 /// Toggleando un módulo overlay vía `module_configs` directamente
1516 /// (simulando el efecto de un `PanelEvent::ControlChanged`), la
1517 /// función `build_requests` debe reflejar el cambio.
1518 #[gpui::test]
1519 fn module_toggles_produce_requests(cx: &mut TestAppContext) {
1520 cx.update(|cx| {
1521 Theme::install_default(cx);
1522 let store = Store::in_memory().expect("store");
1523 let shell = cx.new(|cx| Shell::new(store, cx));
1524
1525 shell.update(cx, |s, _cx| {
1526 // Sin módulos activos → no hay requests.
1527 assert!(s.build_requests().is_empty());
1528
1529 set_module_enabled(&mut s.module_configs, "transit", true);
1530 set_module_enabled(&mut s.module_configs, "midpoints", true);
1531 set_module_enabled(&mut s.module_configs, "uranian", true);
1532
1533 let reqs = s.build_requests();
1534 assert_eq!(reqs.len(), 3);
1535 assert!(matches!(reqs[0], PipelineRequest::Transit));
1536 assert!(matches!(reqs[1], PipelineRequest::Midpoints));
1537 assert!(matches!(reqs[2], PipelineRequest::Uranian));
1538
1539 set_module_enabled(&mut s.module_configs, "transit", false);
1540 let reqs = s.build_requests();
1541 assert_eq!(reqs.len(), 2);
1542 assert!(!reqs
1543 .iter()
1544 .any(|r| matches!(r, PipelineRequest::Transit)));
1545 });
1546 });
1547 }
1548
1549 /// `NatalOptions` derivados de `module_configs["natal"]` deben
1550 /// respetar orb_multiplier, show_minors y show_dignities cuando los
1551 /// hay, y caer a defaults razonables cuando no.
1552 #[gpui::test]
1553 fn natal_options_read_from_configs(cx: &mut TestAppContext) {
1554 cx.update(|cx| {
1555 Theme::install_default(cx);
1556 let store = Store::in_memory().expect("store");
1557 let shell = cx.new(|cx| Shell::new(store, cx));
1558
1559 shell.update(cx, |s, _cx| {
1560 let opts = s.build_natal_options();
1561 assert!(opts.show_majors);
1562 assert!(!opts.show_minors);
1563 assert_eq!(opts.orb_multiplier, 1.0);
1564 assert!(!opts.show_dignities);
1565
1566 s.module_configs.insert(
1567 "natal".into(),
1568 serde_json::json!({
1569 "aspect_majors": true,
1570 "aspect_minors": true,
1571 "orb_multiplier": 1.75,
1572 "show_dignities": true,
1573 }),
1574 );
1575 let opts = s.build_natal_options();
1576 assert!(opts.show_minors);
1577 assert_eq!(opts.orb_multiplier, 1.75);
1578 assert!(opts.show_dignities);
1579 });
1580 });
1581 }
1582
1583 /// El flex de los splitters persiste entre instancias de Shell que
1584 /// comparten la misma store (in-memory): primera shell escribe via
1585 /// `save_split_flex`, segunda shell lee via `load_split_flex_n` al
1586 /// boot. Cubre 2 y 3 hijos (Bottom vs docks laterales).
1587 #[test]
1588 fn split_flex_round_trip_via_store() {
1589 let store = Store::in_memory().expect("store");
1590 let defaults_2 = vec![1.0_f32, 4.0];
1591 let defaults_3 = vec![1.0_f32, 4.0, 1.5];
1592
1593 // Sin nada persistido → defaults.
1594 assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
1595
1596 store.set_setting("layout.x", "2.5,3.5").unwrap();
1597 assert_eq!(
1598 load_split_flex_n(&store, "layout.x", &defaults_2),
1599 vec![2.5_f32, 3.5]
1600 );
1601
1602 store.set_setting("layout.x", "1.0,4.0,2.0").unwrap();
1603 assert_eq!(
1604 load_split_flex_n(&store, "layout.x", &defaults_3),
1605 vec![1.0_f32, 4.0, 2.0]
1606 );
1607
1608 // Valor corrupto → defaults.
1609 store.set_setting("layout.x", "garbage").unwrap();
1610 assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
1611
1612 // Cantidad incorrecta → defaults.
1613 store.set_setting("layout.x", "2,3,4").unwrap();
1614 assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
1615
1616 // Valor ≤0 → defaults.
1617 store.set_setting("layout.x", "0,5").unwrap();
1618 assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
1619 }
1620
1621 /// PanelDock roundtrip via store.
1622 #[test]
1623 fn panel_dock_setting_roundtrip() {
1624 assert_eq!(PanelDock::from_setting("bottom"), Some(PanelDock::Bottom));
1625 assert_eq!(PanelDock::from_setting("right"), Some(PanelDock::Right));
1626 assert_eq!(PanelDock::from_setting("left"), Some(PanelDock::Left));
1627 assert_eq!(PanelDock::from_setting("nope"), None);
1628
1629 let store = Store::in_memory().expect("store");
1630 assert_eq!(load_dock(&store), None);
1631 store
1632 .set_setting("layout.panel_dock", PanelDock::Right.as_setting())
1633 .unwrap();
1634 assert_eq!(load_dock(&store), Some(PanelDock::Right));
1635 }
1636}