tawasuyu

tawasuyu/brahman

Rust 405TOML 156Markdown 40JSON 175449 Definiciones

⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc crates/apps/tahuantinsuyu/src/shell.rs Rust · 1636 líneas · 65.4 KB Historia Quién tocó qué crudo

feat(tahuantinsuyu): "Cielo ahora" + "General" como rows fijos al top del tree 72758e75c · sergio ·

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}