gioser

tawasuyu/brahman

Rust 326TOML 123JSON 17Markdown 144606 Sutichaykuna

⎇ 36d6645e7fe3fbb68f45352948429d20eaa4c86d crates/apps/cosmobiologia/src/shell.rs Rust · 2132 siq'ikuna · 85.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(cosmobiologia): rectificador per-segundo + direcciones primarias reales 36d6645e7 · 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 cosmobiologia_canvas::{
32    AstrologyCanvas, CanvasEvent, CanvasMode, ThumbnailItem, ThumbnailScope,
33};
34use cosmobiologia_engine::{
35    EventoConocido, LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest,
36    compose_with_options, svg_export,
37};
38use cosmobiologia_model::{
39    Chart, ChartId, ChartKind, ContactId, FreeChartId, ModuleState, StoredBirthData,
40    StoredChartConfig, TreeSelection,
41};
42use cosmobiologia_panel::{ChartOption, ControlPanel, PanelEvent};
43use cosmobiologia_store::Store;
44use cosmobiologia_tree::{
45    parse_city_atlas_tsv, FreeChartEntry, TahuantinsuyuTree, TreeEvent,
46};
47use nahual_core::{LayoutDirection, NodeId};
48use nahual_theme::Theme;
49use nahual_widget_container_core::ChildSlot;
50use nahual_widget_splitter::{SplitContainer, SplitEvent};
51use nahual_widget_theme_switcher::theme_switcher;
52
53/// Posición del panel de control dentro del shell. `Bottom` mantiene
54/// el layout histórico (tree+canvas arriba, panel abajo); las variantes
55/// laterales colapsan los splitters anidados en uno solo de 3 columnas.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum PanelDock {
58    Bottom,
59    Right,
60    Left,
61}
62
63impl PanelDock {
64    fn as_setting(&self) -> &'static str {
65        match self {
66            PanelDock::Bottom => "bottom",
67            PanelDock::Right => "right",
68            PanelDock::Left => "left",
69        }
70    }
71
72    fn from_setting(s: &str) -> Option<Self> {
73        match s {
74            "bottom" => Some(PanelDock::Bottom),
75            "right" => Some(PanelDock::Right),
76            "left" => Some(PanelDock::Left),
77            _ => None,
78        }
79    }
80}
81
82/// Status del broker brahman tal como lo vimos en el último ping.
83/// Se refresca cada 30 segundos desde un background task.
84#[derive(Clone, Debug)]
85pub enum BrahmanStatus {
86    /// Aún no probamos (boot, primer ciclo).
87    Pending,
88    /// Connect OK al broker, devolvió la lista de sessions activas.
89    Connected { session_count: usize },
90    /// Connect falló — broker no escucha en el socket o tomó timeout.
91    /// `reason` se incluye para diagnóstico en logs aunque la UI hoy
92    /// muestra solo "offline".
93    Offline {
94        #[allow(dead_code)]
95        reason: String,
96    },
97}
98
99pub struct Shell {
100    store: Store,
101    /// Los tres widgets viven como children de los splitters vía
102    /// AnyView clone; retenemos los Entity acá para que las
103    /// subscripciones sigan vivas y para poder rearmar el layout al
104    /// cambiar `dock` sin recrear los widgets.
105    tree: Entity<TahuantinsuyuTree>,
106    canvas: Entity<AstrologyCanvas>,
107    panel: Entity<ControlPanel>,
108    /// Splitter "exterior". En dock=Bottom es vertical con (main_split,
109    /// panel) como hijos; en dock=Right/Left es horizontal y agrupa
110    /// tree+canvas+panel en una sola tira.
111    outer_split: Entity<SplitContainer>,
112    /// Splitter horizontal interno con (tree, canvas). Solo se usa
113    /// cuando dock=Bottom; en docks laterales queda vivo pero sin ser
114    /// hijo del árbol activo.
115    main_split: Entity<SplitContainer>,
116    /// Dock activo del panel — determina cómo se arman los splitters
117    /// y cuáles flex se persisten.
118    dock: PanelDock,
119    /// Último estado conocido del broker brahman — refrescado cada
120    /// 30s desde el background task.
121    brahman_status: BrahmanStatus,
122    current_chart: Option<Chart>,
123    current_offset_minutes: i64,
124    /// Estado de los módulos overlay (transit, progression, …) por
125    /// `module_id`. Las claves dentro del JSON dependen del módulo (la
126    /// convención es `"enabled": bool` para el toggle principal).
127    module_configs: HashMap<String, serde_json::Value>,
128    /// Sequence counter para descartar resultados de cómputos
129    /// background que llegan después de uno más reciente. Cada
130    /// `render_current` lo incrementa y la closure async compara antes
131    /// de aplicar el render al canvas.
132    render_seq: u64,
133    /// Cartas "libres" — no persistidas en la store. Incluye la
134    /// especial `sky_now()` (Cielo ahora) + cualquier creada por el
135    /// usuario desde la sección "Cartas libres" del tree. Cada vez
136    /// que muta este mapa, llamamos `tree.set_free_charts`.
137    free_charts: HashMap<FreeChartId, Chart>,
138    /// Counter para id de cartas libres nuevas — el id se concatena
139    /// con el prefijo `free-` y el counter, así son únicos dentro de
140    /// la sesión sin pelearse con UUIDs reales.
141    next_free_id: u32,
142}
143
144impl Shell {
145    pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
146        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
147
148        let tree = cx.new(|cx| {
149            let mut t = TahuantinsuyuTree::new(store.clone(), cx);
150            // Si hay un atlas custom en $XDG_DATA_HOME/cosmobiologia/
151            // atlas.tsv, lo cargamos y reemplazamos el atlas hardcoded
152            // de 90 ciudades. Formato TSV: name<TAB>lat<TAB>lon<TAB>tz_min.
153            if let Some(atlas) = load_city_atlas_from_xdg() {
154                t.set_city_atlas(atlas, cx);
155            }
156            t
157        });
158        let canvas = cx.new(AstrologyCanvas::new);
159        let panel = cx.new(ControlPanel::new);
160
161        cx.subscribe(&tree, |this: &mut Self, _, ev: &TreeEvent, cx| {
162            this.on_tree_event(ev, cx);
163        })
164        .detach();
165
166        cx.subscribe(&panel, |this: &mut Self, _, ev: &PanelEvent, cx| {
167            this.on_panel_event(ev, cx);
168        })
169        .detach();
170
171        cx.subscribe(&canvas, |this: &mut Self, _, ev: &CanvasEvent, cx| {
172            this.on_canvas_event(ev, cx);
173        })
174        .detach();
175
176        // Splitters vacíos — `apply_dock` los puebla según el layout
177        // activo. Horizontal/Vertical son defaults; cada apply ajusta la
178        // dirección antes de setear children.
179        let main_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Horizontal, cx));
180        let outer_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Vertical, cx));
181
182        // Persistir flex en `DragEnd`. La key del setting depende del
183        // dock activo, así no se pisan los flexes de un layout con los
184        // de otro al mudarse. Se lee dentro del closure para tomar el
185        // dock actualizado, no el capturado en `new`.
186        let store_main = store.clone();
187        cx.subscribe(&main_split, move |this: &mut Self, sc, ev: &SplitEvent, cx| {
188            if matches!(ev, SplitEvent::DragEnd) {
189                let key = split_key_main(this.dock);
190                save_split_flex(&store_main, key, sc.read(cx));
191            }
192        })
193        .detach();
194        let store_outer = store.clone();
195        cx.subscribe(&outer_split, move |this: &mut Self, sc, ev: &SplitEvent, cx| {
196            if matches!(ev, SplitEvent::DragEnd) {
197                let key = split_key_outer(this.dock);
198                save_split_flex(&store_outer, key, sc.read(cx));
199            }
200        })
201        .detach();
202
203        let dock = load_dock(&store).unwrap_or(PanelDock::Bottom);
204
205        let mut shell = Self {
206            store,
207            tree,
208            canvas,
209            panel,
210            outer_split,
211            main_split,
212            dock,
213            brahman_status: BrahmanStatus::Pending,
214            current_chart: None,
215            current_offset_minutes: 0,
216            module_configs: HashMap::new(),
217            render_seq: 0,
218            free_charts: HashMap::new(),
219            next_free_id: 0,
220        };
221        shell.apply_dock(dock, cx);
222        shell.refresh_chart_options(cx);
223        shell.spawn_brahman_status_loop(cx);
224        // Inicializar "Cielo ahora" como carta libre fija y empujarla
225        // al tree. Queda seleccionada por default — el usuario abre
226        // la app y ya ve el firmamento actual.
227        shell.ensure_sky_now(cx);
228        shell.apply_selection(
229            TreeSelection::FreeChart(FreeChartId::sky_now()),
230            cx,
231        );
232        shell
233    }
234
235    /// Garantiza que `sky-now` exista en `free_charts` y publica la
236    /// lista actualizada al tree. Recomputa la carta del cielo si ya
237    /// estaba (refresca al reloj actual).
238    fn ensure_sky_now(&mut self, cx: &mut Context<Self>) {
239        self.free_charts
240            .insert(FreeChartId::sky_now(), build_present_sky_chart());
241        self.push_free_charts_to_tree(cx);
242    }
243
244    fn push_free_charts_to_tree(&self, cx: &mut Context<Self>) {
245        // Orden de display: "Cielo ahora" primero, después el resto
246        // por id (los ids `free-N` quedan ordenados por creación).
247        let mut entries: Vec<FreeChartEntry> = Vec::new();
248        if let Some(c) = self.free_charts.get(&FreeChartId::sky_now()) {
249            entries.push(FreeChartEntry {
250                id: FreeChartId::sky_now(),
251                label: c.label.clone(),
252                birth_data: c.birth_data.clone(),
253            });
254        }
255        let mut others: Vec<(&FreeChartId, &Chart)> = self
256            .free_charts
257            .iter()
258            .filter(|(k, _)| !k.is_sky_now())
259            .collect();
260        others.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
261        for (id, c) in others {
262            entries.push(FreeChartEntry {
263                id: id.clone(),
264                label: c.label.clone(),
265                birth_data: c.birth_data.clone(),
266            });
267        }
268        self.tree
269            .update(cx, |t, cx| t.set_free_charts(entries, cx));
270    }
271
272    /// Arma el árbol de splitters según el dock pedido y persiste la
273    /// elección. Idempotente: llamar con el dock actual reconstruye los
274    /// children con flexes leídos del setting (útil tras `new`).
275    pub fn apply_dock(&mut self, dock: PanelDock, cx: &mut Context<Self>) {
276        self.dock = dock;
277
278        let tree_view = gpui::AnyView::from(self.tree.clone());
279        let canvas_view = gpui::AnyView::from(self.canvas.clone());
280        let panel_view = gpui::AnyView::from(self.panel.clone());
281        let main_view = gpui::AnyView::from(self.main_split.clone());
282
283        match dock {
284            PanelDock::Bottom => {
285                let flex_main = load_split_flex_n(
286                    &self.store,
287                    split_key_main(dock),
288                    &[1.0, 4.0],
289                );
290                let flex_outer = load_split_flex_n(
291                    &self.store,
292                    split_key_outer(dock),
293                    &[4.0, 1.0],
294                );
295                self.main_split.update(cx, |sc, cx| {
296                    sc.set_direction(LayoutDirection::Horizontal, cx);
297                    sc.set_children(
298                        vec![
299                            ChildSlot {
300                                id: NodeId::new("tts-tree"),
301                                flex: flex_main[0],
302                                label: None,
303                                view: tree_view.clone(),
304                            },
305                            ChildSlot {
306                                id: NodeId::new("tts-canvas"),
307                                flex: flex_main[1],
308                                label: None,
309                                view: canvas_view.clone(),
310                            },
311                        ],
312                        cx,
313                    );
314                });
315                self.outer_split.update(cx, |sc, cx| {
316                    sc.set_direction(LayoutDirection::Vertical, cx);
317                    sc.set_children(
318                        vec![
319                            ChildSlot {
320                                id: NodeId::new("tts-main"),
321                                flex: flex_outer[0],
322                                label: None,
323                                view: main_view,
324                            },
325                            ChildSlot {
326                                id: NodeId::new("tts-panel"),
327                                flex: flex_outer[1],
328                                label: None,
329                                view: panel_view,
330                            },
331                        ],
332                        cx,
333                    );
334                });
335            }
336            PanelDock::Right => {
337                let flex = load_split_flex_n(
338                    &self.store,
339                    split_key_outer(dock),
340                    &[1.0, 4.0, 1.5],
341                );
342                self.outer_split.update(cx, |sc, cx| {
343                    sc.set_direction(LayoutDirection::Horizontal, cx);
344                    sc.set_children(
345                        vec![
346                            ChildSlot {
347                                id: NodeId::new("tts-tree"),
348                                flex: flex[0],
349                                label: None,
350                                view: tree_view,
351                            },
352                            ChildSlot {
353                                id: NodeId::new("tts-canvas"),
354                                flex: flex[1],
355                                label: None,
356                                view: canvas_view,
357                            },
358                            ChildSlot {
359                                id: NodeId::new("tts-panel"),
360                                flex: flex[2],
361                                label: None,
362                                view: panel_view,
363                            },
364                        ],
365                        cx,
366                    );
367                });
368            }
369            PanelDock::Left => {
370                let flex = load_split_flex_n(
371                    &self.store,
372                    split_key_outer(dock),
373                    &[1.5, 1.0, 4.0],
374                );
375                self.outer_split.update(cx, |sc, cx| {
376                    sc.set_direction(LayoutDirection::Horizontal, cx);
377                    sc.set_children(
378                        vec![
379                            ChildSlot {
380                                id: NodeId::new("tts-panel"),
381                                flex: flex[0],
382                                label: None,
383                                view: panel_view,
384                            },
385                            ChildSlot {
386                                id: NodeId::new("tts-tree"),
387                                flex: flex[1],
388                                label: None,
389                                view: tree_view,
390                            },
391                            ChildSlot {
392                                id: NodeId::new("tts-canvas"),
393                                flex: flex[2],
394                                label: None,
395                                view: canvas_view,
396                            },
397                        ],
398                        cx,
399                    );
400                });
401            }
402        }
403
404        if let Err(e) = self.store.set_setting("layout.panel_dock", dock.as_setting()) {
405            eprintln!("[shell] persist panel_dock: {}", e);
406        }
407        cx.notify();
408    }
409
410    /// Loop que cada 30s pregunta al broker la lista de sessions
411    /// activas y actualiza `brahman_status`. El cómputo bloqueante
412    /// (list_sessions_blocking abre su propio tokio runtime) corre en
413    /// el background_executor — no bloquea el UI thread. Cuando llega
414    /// el resultado, el `this.update` dispara cx.notify para repintar
415    /// el badge del header.
416    fn spawn_brahman_status_loop(&self, cx: &mut Context<Self>) {
417        cx.spawn(async move |this, cx| {
418            loop {
419                let result = cx
420                    .background_executor()
421                    .spawn(async {
422                        brahman_sidecar::list_sessions_blocking("cosmobiologia-observer")
423                    })
424                    .await;
425                let _ = this.update(cx, |this, cx| {
426                    this.brahman_status = match result {
427                        Ok(list) => BrahmanStatus::Connected {
428                            session_count: list.entries.len(),
429                        },
430                        Err(e) => BrahmanStatus::Offline {
431                            reason: format!("{:?}", e),
432                        },
433                    };
434                    cx.notify();
435                });
436                let timer = cx
437                    .background_executor()
438                    .timer(std::time::Duration::from_secs(30));
439                timer.await;
440            }
441        })
442        .detach();
443    }
444
445    /// Recarga la lista de opciones para los `Control::ChartPicker` y
446    /// la pushea al panel. Llamado al boot + tras cada
447    /// `TreeEvent::HierarchyChanged`.
448    fn refresh_chart_options(&self, cx: &mut Context<Self>) {
449        let charts = self.store.list_all_charts().unwrap_or_default();
450        let options: Vec<ChartOption> = charts
451            .into_iter()
452            .map(|c| ChartOption {
453                id: c.id.to_string(),
454                label: format!("{} — {}", c.label, format_birth_brief(&c.birth_data)),
455            })
456            .collect();
457        self.panel
458            .update(cx, |p, cx| p.set_chart_options(options, cx));
459    }
460
461    fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) {
462        let selection = match ev {
463            TreeEvent::Selected(s) => s,
464            TreeEvent::Opened(s) => s,
465            TreeEvent::HierarchyChanged => {
466                // La jerarquía cambió (alta/baja de cartas) — refrescar
467                // las opciones del picker para que aparezcan / desaparezcan
468                // en el dropdown.
469                self.refresh_chart_options(cx);
470                cx.notify();
471                return;
472            }
473            TreeEvent::NewFreeChartRequested => {
474                let id = FreeChartId(format!("free-{}", self.next_free_id));
475                self.next_free_id += 1;
476                // Default: misma data que "Cielo ahora" pero con label
477                // distinto. El usuario edita después con el editor
478                // inline (fase B).
479                let mut chart = build_present_sky_chart();
480                chart.label = format!("Carta libre #{}", self.next_free_id);
481                self.free_charts.insert(id.clone(), chart);
482                self.push_free_charts_to_tree(cx);
483                self.apply_selection(TreeSelection::FreeChart(id), cx);
484                return;
485            }
486            TreeEvent::SaveFreeChartRequested(_id) => {
487                // El menú del tree abre el modal directamente; este
488                // evento queda como hook por si una tecla u otra
489                // UI quiere disparar el flujo sin pasar por el menú.
490                return;
491            }
492            TreeEvent::FreeChartSaveConfirmed {
493                source_id,
494                chart_name,
495                contact,
496                new_contact_name,
497            } => {
498                self.persist_free_chart(
499                    source_id.clone(),
500                    chart_name.clone(),
501                    contact.clone(),
502                    new_contact_name.clone(),
503                    cx,
504                );
505                return;
506            }
507            TreeEvent::FreeChartEditConfirmed {
508                source_id,
509                birth_data,
510                label,
511            } => {
512                if let Some(chart) = self.free_charts.get_mut(source_id) {
513                    chart.birth_data = birth_data.clone();
514                    chart.label = label.clone();
515                }
516                self.push_free_charts_to_tree(cx);
517                // Si la carta editada era la activa, re-render.
518                if let Some(current) = self.current_chart.as_mut() {
519                    // Heurística: comparamos por label (ya cambiado al
520                    // que pidió el usuario). Si el label de la activa
521                    // coincide, era esta carta.
522                    if current.label == label.clone()
523                        || current.birth_data.subject_name.as_deref() == Some("Cielo")
524                    {
525                        if let Some(updated) = self.free_charts.get(source_id) {
526                            *current = updated.clone();
527                            self.render_current(cx);
528                        }
529                    }
530                }
531                return;
532            }
533            TreeEvent::DeleteFreeChartRequested(id) => {
534                if id.is_sky_now() {
535                    return; // no se borra el Cielo
536                }
537                self.free_charts.remove(id);
538                self.push_free_charts_to_tree(cx);
539                // Si la carta borrada era la activa, vuelve al Cielo.
540                if let Some(current) = self.current_chart.as_ref() {
541                    if current.label.starts_with(&format!("Carta libre")) {
542                        self.apply_selection(
543                            TreeSelection::FreeChart(FreeChartId::sky_now()),
544                            cx,
545                        );
546                    }
547                }
548                return;
549            }
550        };
551        self.apply_selection(selection.clone(), cx);
552    }
553
554    /// Persiste una carta libre como `Chart` en la store. El usuario
555    /// eligió en el modal: nombre + contacto destino (existente o
556    /// uno nuevo creado al vuelo). La carta libre se REMUEVE del
557    /// mapa tras el persist exitoso — si quedaba seleccionada,
558    /// volvemos a "Cielo ahora". Si falla la persistencia, la carta
559    /// libre se conserva y logueamos.
560    fn persist_free_chart(
561        &mut self,
562        source_id: FreeChartId,
563        chart_name: String,
564        contact: Option<ContactId>,
565        new_contact_name: Option<String>,
566        cx: &mut Context<Self>,
567    ) {
568        let Some(chart) = self.free_charts.get(&source_id).cloned() else {
569            return;
570        };
571        // 1) Resolver el contact destino (existente o crear nuevo).
572        let contact_id = match (contact, new_contact_name) {
573            (Some(cid), _) => cid,
574            (None, Some(name)) => match self.store.create_contact(None, &name, None) {
575                Ok(c) => c.id,
576                Err(e) => {
577                    eprintln!("[shell] create_contact al guardar libre: {}", e);
578                    return;
579                }
580            },
581            (None, None) => {
582                eprintln!("[shell] persist_free_chart sin contacto ni nombre nuevo");
583                return;
584            }
585        };
586        // 2) Crear la carta.
587        match self.store.create_chart(
588            contact_id,
589            chart.kind,
590            &chart_name,
591            &chart.birth_data,
592            &chart.config,
593            chart.related_chart_id,
594        ) {
595            Ok(_) => {
596                eprintln!(
597                    "[shell] carta libre {:?} guardada como '{}' bajo contacto {}",
598                    source_id, chart_name, contact_id
599                );
600            }
601            Err(e) => {
602                eprintln!("[shell] create_chart al guardar libre: {}", e);
603                return;
604            }
605        }
606        // 3) Sky-now se conserva (siempre es); las demás se quitan
607        // del mapa libre. Si era la activa, volver al Cielo.
608        if !source_id.is_sky_now() {
609            self.free_charts.remove(&source_id);
610            self.push_free_charts_to_tree(cx);
611            // Si la activa era esta libre, regresar al Cielo.
612            self.apply_selection(
613                TreeSelection::FreeChart(FreeChartId::sky_now()),
614                cx,
615            );
616        }
617        self.refresh_chart_options(cx);
618    }
619
620    fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
621        match sel {
622            TreeSelection::Chart(id) => {
623                let chart = match self.store.get_chart(id) {
624                    Ok(c) => c,
625                    Err(e) => {
626                        eprintln!("[shell] get_chart {}: {}", id, e);
627                        return;
628                    }
629                };
630                let age = current_age_years(&chart.birth_data);
631                self.current_chart = Some(chart.clone());
632                self.current_offset_minutes = 0;
633                // 1) Defaults frescos para esta carta: edad objetivo =
634                //    edad actual. Estos quedan en module_configs como
635                //    valor base si el usuario nunca tocó el slider.
636                self.module_configs.clear();
637                for module_id in ["progression", "solar_arc", "planetary_return"] {
638                    let entry = self
639                        .module_configs
640                        .entry(module_id.into())
641                        .or_insert_with(|| serde_json::json!({}));
642                    if let serde_json::Value::Object(map) = entry {
643                        map.insert("target_age_years".into(), serde_json::json!(age));
644                    }
645                }
646                // El módulo planetary_return además necesita un body
647                // por default — el shell elige "sun" si el usuario no
648                // tocó el Select. La persistencia luego puede pisar
649                // este valor.
650                if let Some(serde_json::Value::Object(map)) =
651                    self.module_configs.get_mut("planetary_return")
652                {
653                    map.entry(String::from("body"))
654                        .or_insert(serde_json::json!("sun"));
655                }
656                // 2) Sobreescribir con lo que el usuario persistió la
657                //    última vez para esta carta (SQLite `module_state`).
658                self.load_persisted_module_states(chart.id);
659                // 3) Sincronizar panel: active_kind + toggles/sliders.
660                self.panel.update(cx, |p, cx| {
661                    p.set_active_kind(Some(chart.kind), cx);
662                });
663                self.sync_panel_from_configs(cx);
664                self.render_current(cx);
665            }
666            TreeSelection::Contact(id) => {
667                self.current_chart = None;
668                self.current_offset_minutes = 0;
669                let charts = self.store.list_charts(id).unwrap_or_default();
670                let items: Vec<ThumbnailItem> = charts
671                    .into_iter()
672                    .map(|c| ThumbnailItem {
673                        chart_id: c.id,
674                        label: SharedString::from(c.label),
675                        subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
676                        preview: None,
677                    })
678                    .collect();
679                self.canvas.update(cx, |c, cx| {
680                    c.set_mode(
681                        CanvasMode::Thumbnails {
682                            scope: ThumbnailScope::Contact(id),
683                            items,
684                        },
685                        cx,
686                    );
687                });
688                self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
689            }
690            TreeSelection::Group(id) => {
691                self.current_chart = None;
692                self.current_offset_minutes = 0;
693                let charts = self.store.charts_under_group(id).unwrap_or_default();
694                let items: Vec<ThumbnailItem> = charts
695                    .into_iter()
696                    .map(|c| ThumbnailItem {
697                        chart_id: c.id,
698                        label: SharedString::from(c.label),
699                        subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
700                        preview: None,
701                    })
702                    .collect();
703                self.canvas.update(cx, |c, cx| {
704                    c.set_mode(
705                        CanvasMode::Thumbnails {
706                            scope: ThumbnailScope::Group(id),
707                            items,
708                        },
709                        cx,
710                    );
711                });
712                self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
713            }
714            TreeSelection::FreeChart(id) => {
715                // Si es "Cielo ahora", refrescamos el reloj antes de
716                // renderizar — el usuario espera ver el momento actual,
717                // no el momento al que se cargó la carta.
718                if id.is_sky_now() {
719                    self.free_charts
720                        .insert(FreeChartId::sky_now(), build_present_sky_chart());
721                    self.push_free_charts_to_tree(cx);
722                }
723                let Some(chart) = self.free_charts.get(&id).cloned() else {
724                    eprintln!("[shell] free chart {:?} no encontrada", id);
725                    return;
726                };
727                self.current_chart = Some(chart);
728                self.current_offset_minutes = 0;
729                self.module_configs.clear();
730                self.panel
731                    .update(cx, |p, cx| p.set_active_kind(Some(ChartKind::Natal), cx));
732                self.sync_panel_from_configs(cx);
733                self.render_current(cx);
734            }
735            TreeSelection::FreeChartsRoot => {
736                // Grilla de thumbnails de las cartas libres. Como
737                // ThumbnailItem requiere ChartId, usamos `default()`
738                // para las libres — el canvas las muestra como
739                // entradas no-clickeables (eso está OK; el usuario
740                // hace click en el row del tree para seleccionar).
741                self.current_chart = None;
742                self.current_offset_minutes = 0;
743                let items: Vec<ThumbnailItem> = self
744                    .free_charts
745                    .values()
746                    .map(|c| ThumbnailItem {
747                        chart_id: c.id,
748                        label: SharedString::from(c.label.clone()),
749                        subtitle: Some(SharedString::from("libre".to_string())),
750                        preview: None,
751                    })
752                    .collect();
753                self.canvas.update(cx, |c, cx| {
754                    c.set_mode(
755                        CanvasMode::Thumbnails {
756                            scope: ThumbnailScope::Group(Default::default()),
757                            items,
758                        },
759                        cx,
760                    );
761                });
762                self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
763            }
764            TreeSelection::GeneralRoot => {
765                // "General" agrupa los contactos sin grupo padre. El
766                // canvas muestra thumbnails de TODAS las cartas de
767                // esos contactos.
768                self.current_chart = None;
769                self.current_offset_minutes = 0;
770                let mut items: Vec<ThumbnailItem> = Vec::new();
771                if let Ok(contacts) = self.store.list_contacts(None) {
772                    for ct in contacts {
773                        if let Ok(charts) = self.store.list_charts(ct.id) {
774                            for c in charts {
775                                items.push(ThumbnailItem {
776                                    chart_id: c.id,
777                                    label: SharedString::from(c.label),
778                                    subtitle: Some(SharedString::from(format!(
779                                        "{} · {:?}",
780                                        ct.name, c.kind
781                                    ))),
782                                    preview: None,
783                                });
784                            }
785                        }
786                    }
787                }
788                // Reusamos el scope Group con un id sentinela "vacío":
789                // como GeneralRoot no es un Group real, dejamos que el
790                // canvas pinte la grilla con el set de items y nada
791                // más — el `scope` no se usa para nada que requiera
792                // el id.
793                self.canvas.update(cx, |c, cx| {
794                    c.set_mode(
795                        CanvasMode::Thumbnails {
796                            scope: ThumbnailScope::Group(Default::default()),
797                            items,
798                        },
799                        cx,
800                    );
801                });
802                self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
803            }
804        }
805    }
806
807    /// Deriva los `PipelineRequest` activos a partir del `module_configs`.
808    fn build_requests(&self) -> Vec<PipelineRequest> {
809        let mut requests = Vec::new();
810        if module_enabled(&self.module_configs, "transit") {
811            requests.push(PipelineRequest::Transit);
812        }
813        if module_enabled(&self.module_configs, "progression") {
814            let age = self.module_age_or_current("progression");
815            requests.push(PipelineRequest::SecondaryProgression {
816                target_age_years: age,
817            });
818        }
819        if module_enabled(&self.module_configs, "solar_arc") {
820            let age = self.module_age_or_current("solar_arc");
821            requests.push(PipelineRequest::SolarArc {
822                target_age_years: age,
823            });
824        }
825        if module_enabled(&self.module_configs, "synastry") {
826            if let Some(partner) = self.resolve_synastry_partner() {
827                requests.push(PipelineRequest::Synastry {
828                    partner_chart: Box::new(partner),
829                });
830            }
831        }
832        if module_enabled(&self.module_configs, "midpoints") {
833            requests.push(PipelineRequest::Midpoints);
834        }
835        if module_enabled(&self.module_configs, "uranian") {
836            requests.push(PipelineRequest::Uranian);
837        }
838        if module_enabled(&self.module_configs, "lots") {
839            requests.push(PipelineRequest::Lots);
840        }
841        if module_enabled(&self.module_configs, "fixed_stars") {
842            requests.push(PipelineRequest::FixedStars);
843        }
844        if module_enabled(&self.module_configs, "topocentric") {
845            requests.push(PipelineRequest::Topocentric);
846        }
847        if module_enabled(&self.module_configs, "primary_directions") {
848            let age = self.module_age_or_current("primary_directions");
849            let key = self
850                .module_configs
851                .get("primary_directions")
852                .and_then(|c| c.get("key"))
853                .and_then(|v| v.as_str())
854                .unwrap_or("naibod")
855                .to_string();
856            requests.push(PipelineRequest::PrimaryDirections {
857                target_age_years: age,
858                key,
859            });
860        }
861        if module_enabled(&self.module_configs, "composite") {
862            if let Some(partner) = self.resolve_composite_partner() {
863                requests.push(PipelineRequest::Composite {
864                    partner_chart: Box::new(partner),
865                });
866            }
867        }
868        if module_enabled(&self.module_configs, "planetary_return") {
869            let age = self.module_age_or_current("planetary_return");
870            let body = self
871                .module_configs
872                .get("planetary_return")
873                .and_then(|c| c.get("body"))
874                .and_then(|v| v.as_str())
875                .unwrap_or("sun")
876                .to_string();
877            let shift_days = self
878                .module_configs
879                .get("planetary_return")
880                .and_then(|c| c.get("shift_days"))
881                .and_then(|v| v.as_f64())
882                .map(|v| v as i64)
883                .unwrap_or(0);
884            requests.push(PipelineRequest::PlanetaryReturn {
885                body,
886                target_age_years: age,
887                shift_days,
888            });
889        }
890        requests
891    }
892
893    /// Resuelve la carta partner para sinastría: 1) si el picker tiene
894    /// un `partner_chart_id` válido en `module_configs`, lo usa; 2)
895    /// si no, cae al automático (primera carta hermana del contacto
896    /// actual). `None` si nada matchea — el request se salta.
897    fn resolve_synastry_partner(&self) -> Option<Chart> {
898        let manual = self
899            .module_configs
900            .get("synastry")
901            .and_then(|c| c.get("partner_chart_id"))
902            .and_then(|v| v.as_str())
903            .and_then(|s| s.parse::<cosmobiologia_model::ChartId>().ok())
904            .and_then(|id| self.store.get_chart(id).ok());
905        manual.or_else(|| self.find_synastry_partner_auto())
906    }
907
908    fn find_synastry_partner_auto(&self) -> Option<Chart> {
909        let current = self.current_chart.as_ref()?;
910        let siblings = self.store.list_charts(current.contact_id).ok()?;
911        siblings.into_iter().find(|c| c.id != current.id)
912    }
913
914    /// Resuelve el partner para Composite — mismo patrón que Synastry:
915    /// 1) lee module_configs["composite"]["partner_chart_id"] y resuelve
916    /// el chart; 2) fallback al primer hermano del contacto actual.
917    fn resolve_composite_partner(&self) -> Option<Chart> {
918        let manual = self
919            .module_configs
920            .get("composite")
921            .and_then(|c| c.get("partner_chart_id"))
922            .and_then(|v| v.as_str())
923            .and_then(|s| s.parse::<cosmobiologia_model::ChartId>().ok())
924            .and_then(|id| self.store.get_chart(id).ok());
925        manual.or_else(|| self.find_synastry_partner_auto())
926    }
927
928    /// Deriva las `NatalOptions` activas a partir del `module_configs["natal"]`.
929    /// Si la entry no existe, devuelve defaults (majors=true, minors=false,
930    /// multiplier=1.0).
931    fn build_natal_options(&self) -> NatalOptions {
932        let cfg = self.module_configs.get("natal");
933        let read_bool = |key: &str, default: bool| -> bool {
934            cfg.and_then(|c| c.get(key))
935                .and_then(|v| v.as_bool())
936                .unwrap_or(default)
937        };
938        let read_f64 = |key: &str, default: f64| -> f64 {
939            cfg.and_then(|c| c.get(key))
940                .and_then(|v| v.as_f64())
941                .unwrap_or(default)
942        };
943        NatalOptions {
944            show_majors: read_bool("aspect_majors", true),
945            show_minors: read_bool("aspect_minors", false),
946            orb_multiplier: read_f64("orb_multiplier", 1.0),
947            show_dignities: read_bool("show_dignities", false),
948            harmonic: read_f64("harmonic", 1.0).round().clamp(1.0, 64.0) as u32,
949        }
950    }
951
952    /// Lee `module_state` desde SQLite para la carta dada y los mergea
953    /// con los defaults ya cargados en `module_configs`. Los valores
954    /// persistidos ganan sobre los defaults.
955    fn load_persisted_module_states(&mut self, chart_id: ChartId) {
956        let states = match self.store.list_module_states(chart_id) {
957            Ok(s) => s,
958            Err(e) => {
959                eprintln!("[shell] list_module_states {}: {}", chart_id, e);
960                return;
961            }
962        };
963        for st in states {
964            // Re-mergeamos `enabled` (columna separada en SQL) dentro
965            // del JSON config, así el resto del shell sigue leyendo
966            // todo desde una única estructura.
967            let mut combined = match st.config {
968                serde_json::Value::Object(m) => serde_json::Value::Object(m),
969                _ => serde_json::json!({}),
970            };
971            if let serde_json::Value::Object(map) = &mut combined {
972                map.insert("enabled".into(), serde_json::Value::Bool(st.enabled));
973            }
974            // Mergear sobre defaults previos (no sobreescribir si la
975            // entrada nueva no trae un campo).
976            match self.module_configs.entry(st.module_id) {
977                std::collections::hash_map::Entry::Vacant(v) => {
978                    v.insert(combined);
979                }
980                std::collections::hash_map::Entry::Occupied(mut o) => {
981                    if let (serde_json::Value::Object(dst), serde_json::Value::Object(src)) =
982                        (o.get_mut(), &combined)
983                    {
984                        for (k, v) in src {
985                            dst.insert(k.clone(), v.clone());
986                        }
987                    } else {
988                        o.insert(combined);
989                    }
990                }
991            }
992        }
993    }
994
995    /// Pushea cada toggle/slider/picker del `module_configs` al panel
996    /// para que la UI refleje el estado persistido al cargar una carta.
997    fn sync_panel_from_configs(&mut self, cx: &mut Context<Self>) {
998        let snapshot: Vec<(String, serde_json::Value)> = self
999            .module_configs
1000            .iter()
1001            .map(|(k, v)| (k.clone(), v.clone()))
1002            .collect();
1003        self.panel.update(cx, |p, cx| {
1004            for (module_id, config) in &snapshot {
1005                if let serde_json::Value::Object(map) = config {
1006                    for (key, value) in map {
1007                        if let Some(b) = value.as_bool() {
1008                            p.set_toggle(module_id, key, b, cx);
1009                        } else if let Some(f) = value.as_f64() {
1010                            p.set_slider(module_id, key, f, cx);
1011                        } else if let Some(s) = value.as_str() {
1012                            p.set_string(module_id, key, Some(s.to_string()), cx);
1013                        } else if value.is_null() {
1014                            p.set_string(module_id, key, None, cx);
1015                        }
1016                    }
1017                }
1018            }
1019        });
1020    }
1021
1022    /// Persiste el estado actual de un módulo a SQLite. Extrae
1023    /// `enabled` del JSON y lo guarda en la columna dedicada; el resto
1024    /// va al `config_json`.
1025    fn persist_module(&self, module_id: &str) {
1026        let Some(chart) = self.current_chart.as_ref() else {
1027            return;
1028        };
1029        let Some(config) = self.module_configs.get(module_id) else {
1030            return;
1031        };
1032        let enabled = config
1033            .get("enabled")
1034            .and_then(|v| v.as_bool())
1035            .unwrap_or(false);
1036        let mut clean = config.clone();
1037        if let serde_json::Value::Object(map) = &mut clean {
1038            map.remove("enabled");
1039        }
1040        let state = ModuleState {
1041            chart_id: chart.id,
1042            module_id: module_id.to_string(),
1043            enabled,
1044            config: clean,
1045        };
1046        if let Err(e) = self.store.upsert_module_state(&state) {
1047            eprintln!("[shell] upsert_module_state {}: {}", module_id, e);
1048        }
1049    }
1050
1051    /// Lee `target_age_years` del módulo o cae a la edad actual del
1052    /// sujeto (calculada desde la fecha de nacimiento y el reloj).
1053    fn module_age_or_current(&self, module_id: &str) -> f64 {
1054        self.module_configs
1055            .get(module_id)
1056            .and_then(|c| c.get("target_age_years"))
1057            .and_then(|v| v.as_f64())
1058            .unwrap_or_else(|| {
1059                self.current_chart
1060                    .as_ref()
1061                    .map(|c| current_age_years(&c.birth_data))
1062                    .unwrap_or(0.0)
1063            })
1064    }
1065
1066    fn render_current(&mut self, cx: &mut Context<Self>) {
1067        let Some(chart) = self.current_chart.as_ref() else {
1068            return;
1069        };
1070        // Snapshot de inputs para mover al background. La sesión
1071        // VSOP2013 vive en un static `OnceLock` adentro del bridge, así
1072        // que es compartible read-only entre threads sin que ningún
1073        // dato cruce más allá del Chart clonado + requests/options.
1074        let chart = chart.clone();
1075        let offset = self.current_offset_minutes;
1076        let requests = self.build_requests();
1077        let natal_options = self.build_natal_options();
1078        self.render_seq = self.render_seq.wrapping_add(1);
1079        let my_seq = self.render_seq;
1080
1081        cx.spawn(async move |this, cx| {
1082            // El compute corre en el background_executor — no bloquea
1083            // el UI thread. Para una rueda completa con varios overlays
1084            // puede tomar 100-200ms; sin esto, los drags del slider se
1085            // sentirían atorados.
1086            let chart_for_bg = chart.clone();
1087            let requests_for_bg = requests.clone();
1088            let opts_for_bg = natal_options.clone();
1089            let result = cx
1090                .background_executor()
1091                .spawn(async move {
1092                    compose_with_options(&chart_for_bg, offset, &requests_for_bg, &opts_for_bg)
1093                })
1094                .await;
1095
1096            let _ = this.update(cx, |this, cx| {
1097                // Descartar si llegó un render más nuevo en el medio.
1098                // Sin este check, durante un drag rápido un compute
1099                // viejo podría sobrescribir el más reciente.
1100                if this.render_seq != my_seq {
1101                    return;
1102                }
1103                match result {
1104                    Ok(render) => {
1105                        this.canvas.update(cx, |c, cx| {
1106                            c.set_mode(
1107                                CanvasMode::Wheel {
1108                                    render: Box::new(render),
1109                                },
1110                                cx,
1111                            );
1112                        });
1113                    }
1114                    Err(e) => {
1115                        eprintln!(
1116                            "[shell] compose {} (+{}min, {} reqs): {}",
1117                            chart.id,
1118                            offset,
1119                            requests.len(),
1120                            e
1121                        );
1122                    }
1123                }
1124            });
1125        })
1126        .detach();
1127    }
1128
1129    fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context<Self>) {
1130        match ev {
1131            CanvasEvent::TimeOffsetChanged(off) => {
1132                self.current_offset_minutes = *off;
1133                if self.current_chart.is_some() {
1134                    self.render_current(cx);
1135                }
1136            }
1137            CanvasEvent::LayerVisibilityChanged { kind, visible } => {
1138                // El toggle de Outer ([T]) no es visibility puro: dispara
1139                // un pipeline distinto. Lo traducimos a un cambio en
1140                // module_configs["transit"]["enabled"] + re-render.
1141                if matches!(kind, LayerKind::Outer) {
1142                    set_module_enabled(&mut self.module_configs, "transit", *visible);
1143                    self.persist_module("transit");
1144                    self.panel.update(cx, |p, cx| {
1145                        p.set_toggle("transit", "enabled", *visible, cx)
1146                    });
1147                    self.render_current(cx);
1148                    return;
1149                }
1150                // El resto son visibility puros sobre el canvas. Sync el
1151                // panel para que el toggle visual coincida con la hotkey.
1152                let key = match kind {
1153                    LayerKind::SignDial => "show_sign_dial",
1154                    LayerKind::Houses => "show_houses",
1155                    LayerKind::Aspects => "show_aspects",
1156                    LayerKind::Bodies => "show_bodies",
1157                    _ => return,
1158                };
1159                self.panel
1160                    .update(cx, |p, cx| p.set_toggle("natal", key, *visible, cx));
1161            }
1162            CanvasEvent::ShowCoordsChanged(visible) => {
1163                // Sync el toggle del panel para que coincida con la
1164                // hotkey C. No persist — los coord labels son una
1165                // preferencia visual, no parte del module_state.
1166                self.panel.update(cx, |p, cx| {
1167                    p.set_toggle("natal", "show_coords", *visible, cx)
1168                });
1169            }
1170            CanvasEvent::ChartRequested(_) => {
1171                // Fase 7: doble click sobre un thumbnail abre la carta.
1172            }
1173            CanvasEvent::ExportSvgRequested => {
1174                self.export_current_to_svg();
1175            }
1176            CanvasEvent::GrAgeDelta(delta) => {
1177                self.scrub_gr_age(*delta, cx);
1178            }
1179            CanvasEvent::HarmonicSelected(n) => {
1180                self.select_harmonic(*n, cx);
1181            }
1182        }
1183    }
1184
1185    /// Fija el armónico de la carta natal (clic en una barra del
1186    /// espectro): escribe `harmonic` en `module_configs["natal"]`,
1187    /// sincroniza el slider del panel y recompone.
1188    fn select_harmonic(&mut self, n: u32, cx: &mut Context<Self>) {
1189        let entry = self
1190            .module_configs
1191            .entry("natal".into())
1192            .or_insert_with(|| serde_json::json!({}));
1193        if let serde_json::Value::Object(map) = entry {
1194            map.insert("harmonic".into(), serde_json::json!(n));
1195        }
1196        self.panel.update(cx, |p, cx| {
1197            p.set_slider("natal", "harmonic", n as f64, cx)
1198        });
1199        self.persist_module("natal");
1200        self.render_current(cx);
1201    }
1202
1203    /// Scrubbing en vivo de la edad GR vía jog-dial. Acumula `delta`
1204    /// sobre `target_age_years` del módulo `primary_directions`,
1205    /// clampa a [0,120], sincroniza el slider del panel y recompone.
1206    fn scrub_gr_age(&mut self, delta_years: f64, cx: &mut Context<Self>) {
1207        if !module_enabled(&self.module_configs, "primary_directions") {
1208            return;
1209        }
1210        let current = self.module_age_or_current("primary_directions");
1211        let next = (current + delta_years).clamp(0.0, 120.0);
1212        if (next - current).abs() < 1e-6 {
1213            return;
1214        }
1215        let entry = self
1216            .module_configs
1217            .entry("primary_directions".into())
1218            .or_insert_with(|| serde_json::json!({}));
1219        if let serde_json::Value::Object(map) = entry {
1220            map.insert("target_age_years".into(), serde_json::json!(next));
1221        }
1222        self.panel.update(cx, |p, cx| {
1223            p.set_slider("primary_directions", "target_age_years", next, cx)
1224        });
1225        self.persist_module("primary_directions");
1226        self.render_current(cx);
1227    }
1228
1229    /// Recompone la carta actual + escribe el SVG a un archivo en
1230    /// `$XDG_DATA_HOME/cosmobiologia/exports/<label>_<short_id>.svg`.
1231    /// Logea la ruta a stderr — futuro: file save dialog GPUI.
1232    fn export_current_to_svg(&self) {
1233        let Some(chart) = self.current_chart.as_ref() else {
1234            eprintln!("[shell] export svg: sin carta activa");
1235            return;
1236        };
1237        let requests = self.build_requests();
1238        let natal_options = self.build_natal_options();
1239        let render = match compose_with_options(
1240            chart,
1241            self.current_offset_minutes,
1242            &requests,
1243            &natal_options,
1244        ) {
1245            Ok(r) => r,
1246            Err(e) => {
1247                eprintln!("[shell] export svg compose: {}", e);
1248                return;
1249            }
1250        };
1251        let svg = svg_export::render_to_svg(&render);
1252        let dir = directories::ProjectDirs::from("net", "gioser", "cosmobiologia")
1253            .map(|d| d.data_dir().join("exports"))
1254            .unwrap_or_else(|| std::path::PathBuf::from("."));
1255        if let Err(e) = std::fs::create_dir_all(&dir) {
1256            eprintln!("[shell] mkdir {:?}: {}", dir, e);
1257            return;
1258        }
1259        let safe_label: String = chart
1260            .label
1261            .chars()
1262            .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1263            .collect();
1264        let short = format!("{}", chart.id).chars().take(8).collect::<String>();
1265        let path = dir.join(format!("{}_{}.svg", safe_label, short));
1266        if let Err(e) = std::fs::write(&path, svg) {
1267            eprintln!("[shell] write {:?}: {}", path, e);
1268        } else {
1269            eprintln!("[shell] SVG exportado → {}", path.display());
1270        }
1271    }
1272
1273    fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
1274        match ev {
1275            PanelEvent::ControlChanged {
1276                module_id, key, value,
1277            } => {
1278                let bool_val = value.as_bool().unwrap_or(true);
1279                if module_id == "natal" {
1280                    // Distinguimos: show_* = visibility (no recompose),
1281                    // aspect_*/orb_* = filtros de engine (recompose +
1282                    // persist).
1283                    let kind = match key.as_str() {
1284                        "show_sign_dial" => Some(LayerKind::SignDial),
1285                        "show_houses" => Some(LayerKind::Houses),
1286                        "show_aspects" => Some(LayerKind::Aspects),
1287                        "show_bodies" => Some(LayerKind::Bodies),
1288                        _ => None,
1289                    };
1290                    if let Some(k) = kind {
1291                        self.canvas
1292                            .update(cx, |c, cx| c.set_layer_visible(k, bool_val, cx));
1293                    } else if key == "show_coords" {
1294                        // Coord labels viven en el canvas (no son una
1295                        // capa pintada como otros show_*). Sync sin
1296                        // recompose ni persist en module_state.
1297                        self.canvas
1298                            .update(cx, |c, cx| c.set_show_coords(bool_val, cx));
1299                    } else {
1300                        // Filtros: actualizar module_configs + recompose.
1301                        let entry = self
1302                            .module_configs
1303                            .entry("natal".into())
1304                            .or_insert_with(|| serde_json::json!({}));
1305                        if let serde_json::Value::Object(map) = entry {
1306                            map.insert(key.clone(), value.clone());
1307                        }
1308                        self.persist_module("natal");
1309                        self.render_current(cx);
1310                    }
1311                } else {
1312                    // Cualquier otro módulo: actualizamos su config y
1313                    // recompomemos. La engine vuelve a llamarse con el
1314                    // PipelineRequest derivado del nuevo estado.
1315                    let entry = self
1316                        .module_configs
1317                        .entry(module_id.clone())
1318                        .or_insert_with(|| serde_json::json!({}));
1319                    if let serde_json::Value::Object(map) = entry {
1320                        map.insert(key.clone(), value.clone());
1321                    }
1322                    // Transit, Synastry y Solar Return comparten el
1323                    // outer ring del canvas — son mutuamente excluyentes.
1324                    // Al prender uno, apagamos los otros + sync panel +
1325                    // persist.
1326                    if key == "enabled" && bool_val && OUTER_RING_MODULES.contains(&module_id.as_str()) {
1327                        for &other in OUTER_RING_MODULES.iter() {
1328                            if other != module_id && module_enabled(&self.module_configs, other) {
1329                                set_module_enabled(&mut self.module_configs, other, false);
1330                                let other_str = other.to_string();
1331                                self.panel.update(cx, |p, cx| {
1332                                    p.set_toggle(&other_str, "enabled", false, cx)
1333                                });
1334                                self.persist_module(&other_str);
1335                            }
1336                        }
1337                    }
1338                    // Sincronizar visualmente el toggle [T] del canvas
1339                    // cuando el cambio afecta el outer ring (transit,
1340                    // synastry o solar_return).
1341                    if OUTER_RING_MODULES.contains(&module_id.as_str()) && key == "enabled" {
1342                        self.canvas.update(cx, |c, cx| {
1343                            c.set_layer_visible(LayerKind::Outer, bool_val, cx)
1344                        });
1345                    }
1346                    self.persist_module(module_id);
1347                    self.render_current(cx);
1348                }
1349            }
1350            PanelEvent::ModuleToggled { .. } => {
1351                // Fase 7: encender/apagar módulos enteros desde un
1352                // header con switch (vs. el toggle por-control de hoy).
1353            }
1354            PanelEvent::Action { module_id, key } => {
1355                self.on_panel_action(module_id.clone(), key.clone(), cx);
1356            }
1357        }
1358    }
1359
1360    /// Click sobre un `Control::Action` del panel. Por ahora maneja:
1361    /// - planetary_return.save_as_free → captura la carta del
1362    ///   retorno actual (cuerpo + edad) como FreeChart con sufijo
1363    ///   `rs-{N}` / `lunar-{N}` / etc. según el cuerpo elegido.
1364    ///
1365    /// Otros módulos overlay (progression, solar_arc, primary_directions)
1366    /// son extensión natural — TODO.
1367    fn on_panel_action(&mut self, module_id: String, key: String, cx: &mut Context<Self>) {
1368        match key.as_str() {
1369            "save_as_free" => match module_id.as_str() {
1370                "planetary_return" => self.save_planetary_return_as_free(cx),
1371                "transit" => self.save_transit_as_free(cx),
1372                "progression" => self.save_progression_as_free(cx),
1373                // Solar arc y direcciones primarias son transformaciones
1374                // matemáticas puras (no tienen un birth_data real
1375                // equivalente). Guardarlas exigiría un `ChartKind`
1376                // `Derived { source, transform, params }`. TODO.
1377                _ => {}
1378            },
1379            "rectificar" => self.run_rectificacion(cx),
1380            _ => {}
1381        }
1382    }
1383
1384    /// Lanza el rectificador automático (Sistema GR): lee las edades de
1385    /// los eventos conocidos de los sliders del módulo, barre las horas
1386    /// candidatas y escribe el resultado en el campo «Resultado» del
1387    /// panel. El barrido es síncrono — para ±15 min son ~31 cartas.
1388    fn run_rectificacion(&mut self, cx: &mut Context<Self>) {
1389        // Clonamos la carta: `rectificar` necesita `&Chart` y luego
1390        // `panel.update` toma `&mut self` — no pueden solaparse.
1391        let Some(chart) = self.current_chart.clone() else {
1392            return;
1393        };
1394        let cfg = self.module_configs.get("primary_directions");
1395        let read_age = |key: &str| -> f64 {
1396            cfg.and_then(|c| c.get(key))
1397                .and_then(|v| v.as_f64())
1398                .unwrap_or(0.0)
1399        };
1400        // Edades > 0 — una ranura en 0 es "sin usar".
1401        let eventos: Vec<EventoConocido> = ["evento_1", "evento_2", "evento_3"]
1402            .iter()
1403            .map(|k| read_age(k))
1404            .filter(|edad| *edad > 0.5)
1405            .map(|edad| EventoConocido { edad_years: edad })
1406            .collect();
1407        let key_gr = cfg
1408            .and_then(|c| c.get("key"))
1409            .and_then(|v| v.as_str())
1410            .unwrap_or("naibod")
1411            .to_string();
1412
1413        // Ventana ±15 min — dos pasadas (minuto grueso, segundo fino).
1414        match cosmobiologia_engine::rectificar(&chart, &eventos, 15, &key_gr) {
1415            Ok(r) => {
1416                // Offset en segundos → texto «±Xm Ys».
1417                let seg = r.mejor_offset_segundos;
1418                let signo = if seg < 0 { "-" } else { "+" };
1419                let abs = seg.abs();
1420                let resumen = format!(
1421                    "{signo}{}m {:02}s · error {:.2}a",
1422                    abs / 60,
1423                    abs % 60,
1424                    r.mejor_puntaje
1425                );
1426                self.panel.update(cx, |p, cx| {
1427                    p.set_string("primary_directions", "resultado", Some(resumen), cx)
1428                });
1429                // Publicar el perfil al canvas: dibuja la curva del
1430                // barrido, cuyo valle marca la hora rectificada.
1431                self.canvas
1432                    .update(cx, |c, cx| c.set_rectificacion(Some(r), cx));
1433            }
1434            Err(_) => {
1435                self.panel.update(cx, |p, cx| {
1436                    p.set_string(
1437                        "primary_directions",
1438                        "resultado",
1439                        Some("define al menos un evento (edad > 0)".to_string()),
1440                        cx,
1441                    )
1442                });
1443            }
1444        }
1445    }
1446
1447    /// Snapshot del cielo en este instante anclado al lugar del
1448    /// natal. Sufijo `transito-{fecha}`. Útil para guardar "qué
1449    /// estaba pasando ahora en la carta de Pedro".
1450    fn save_transit_as_free(&mut self, cx: &mut Context<Self>) {
1451        let Some(natal) = self.current_chart.as_ref() else {
1452            eprintln!("[shell] save_transit: sin carta activa");
1453            return;
1454        };
1455        if natal.id == ChartId::default() {
1456            eprintln!("[shell] save_transit: la carta activa es libre");
1457            return;
1458        }
1459        match cosmobiologia_engine::compute_transit_chart(natal) {
1460            Ok((birth, instant_label)) => {
1461                let label = format!("{} transito · {}", natal.label, instant_label);
1462                self.insert_derived_free_chart(natal.clone(), birth, label, cx);
1463            }
1464            Err(e) => eprintln!("[shell] compute_transit_chart: {}", e),
1465        }
1466    }
1467
1468    /// Carta progresada secundaria a la edad del slider. La
1469    /// progresada es natal + N días simbólicos; el Chart resultante
1470    /// tiene un birth_data REAL (no simbólico) — cuando se computa
1471    /// como natal de nuevo, da las posiciones progresadas correctas.
1472    /// Sufijo `prog-{N}a`.
1473    fn save_progression_as_free(&mut self, cx: &mut Context<Self>) {
1474        let Some(natal) = self.current_chart.as_ref() else {
1475            eprintln!("[shell] save_progression: sin carta activa");
1476            return;
1477        };
1478        if natal.id == ChartId::default() {
1479            eprintln!("[shell] save_progression: la carta activa es libre");
1480            return;
1481        }
1482        let age = self.module_age_or_current("progression");
1483        match cosmobiologia_engine::compute_progression_chart(natal, age) {
1484            Ok((birth, instant_label)) => {
1485                let label = format!(
1486                    "{} prog-{:.0}a · {}",
1487                    natal.label, age, instant_label
1488                );
1489                self.insert_derived_free_chart(natal.clone(), birth, label, cx);
1490            }
1491            Err(e) => eprintln!("[shell] compute_progression_chart: {}", e),
1492        }
1493    }
1494
1495    /// Inserta un Chart derivado (transit/progression/PR) como
1496    /// FreeChart conservando contact/kind/related/config del natal
1497    /// original. El id es sintético; el usuario puede después
1498    /// "Guardar como…" para persistirlo bajo un contacto.
1499    fn insert_derived_free_chart(
1500        &mut self,
1501        source_natal: Chart,
1502        new_birth: StoredBirthData,
1503        new_label: String,
1504        cx: &mut Context<Self>,
1505    ) {
1506        let id = FreeChartId(format!("free-{}", self.next_free_id));
1507        self.next_free_id += 1;
1508        let mut chart = source_natal;
1509        chart.id = ChartId::default();
1510        chart.label = new_label;
1511        chart.birth_data = new_birth;
1512        self.free_charts.insert(id.clone(), chart);
1513        self.push_free_charts_to_tree(cx);
1514        self.apply_selection(TreeSelection::FreeChart(id), cx);
1515    }
1516
1517    /// Computa la carta del retorno planetario actual (con cuerpo +
1518    /// edad del módulo) y la inserta como FreeChart. El usuario
1519    /// puede después "Guardar como…" para persistirla bajo un
1520    /// contacto (típicamente el mismo del natal).
1521    fn save_planetary_return_as_free(&mut self, cx: &mut Context<Self>) {
1522        let Some(natal) = self.current_chart.as_ref() else {
1523            eprintln!("[shell] save_planetary_return: sin carta activa");
1524            return;
1525        };
1526        if natal.id == ChartId::default() {
1527            eprintln!("[shell] save_planetary_return: la carta activa es libre, no natal");
1528            return;
1529        }
1530        let cfg = self
1531            .module_configs
1532            .get("planetary_return")
1533            .cloned()
1534            .unwrap_or(serde_json::json!({}));
1535        let body = cfg
1536            .get("body")
1537            .and_then(|v| v.as_str())
1538            .unwrap_or("sun")
1539            .to_string();
1540        let age = self.module_age_or_current("planetary_return");
1541        let shift_days = cfg
1542            .get("shift_days")
1543            .and_then(|v| v.as_f64())
1544            .unwrap_or(0.0) as i64;
1545
1546        // Pedimos al engine la fecha exacta del retorno. La engine
1547        // expone `compute_planetary_return_chart` que devuelve un
1548        // `StoredBirthData` listo para reusar como carta natal.
1549        match cosmobiologia_engine::compute_planetary_return_chart(
1550            natal, &body, age, shift_days,
1551        ) {
1552            Ok((birth, instant_label)) => {
1553                let suffix = match body.as_str() {
1554                    "sun" => "rs",
1555                    "moon" => "lunar",
1556                    other => other,
1557                };
1558                let label = format!(
1559                    "{} {}-{:.0}a · {}",
1560                    natal.label, suffix, age, instant_label
1561                );
1562                self.insert_derived_free_chart(natal.clone(), birth, label, cx);
1563            }
1564            Err(e) => {
1565                eprintln!("[shell] compute_planetary_return_chart: {}", e);
1566            }
1567        }
1568    }
1569}
1570
1571// =====================================================================
1572// Helpers de module_configs
1573// =====================================================================
1574
1575// OUTER_RING_MODULES viene de cosmobiologia_engine — single source of
1576// truth. Shell y canvas leen del mismo slice.
1577
1578
1579/// Lee `$XDG_DATA_HOME/cosmobiologia/atlas.tsv` si existe y lo parsea
1580/// como atlas de ciudades. Devuelve `None` cuando no hay archivo o
1581/// quedó vacío después del parse — el tree cae al atlas hardcoded.
1582fn load_city_atlas_from_xdg() -> Option<Vec<cosmobiologia_tree::CityPreset>> {
1583    let path = directories::ProjectDirs::from("net", "gioser", "cosmobiologia")
1584        .map(|d| d.data_dir().join("atlas.tsv"))?;
1585    if !path.exists() {
1586        return None;
1587    }
1588    let content = std::fs::read_to_string(&path).ok()?;
1589    let atlas = parse_city_atlas_tsv(&content);
1590    if atlas.is_empty() {
1591        eprintln!(
1592            "[shell] atlas.tsv encontrado en {:?} pero sin filas válidas — fallback a hardcoded",
1593            path
1594        );
1595        return None;
1596    }
1597    eprintln!("[shell] atlas custom cargado: {} ciudades", atlas.len());
1598    Some(atlas)
1599}
1600
1601/// Carta efímera del "Cielo ahora": birth_data = momento actual en
1602/// Greenwich (UTC, lat 51.4769°, lon 0°). El `Chart` se construye al
1603/// vuelo, NO se persiste en la store, y los IDs son `Default` (todo
1604/// ceros) — la carta es un singleton conceptual de la vista, no un
1605/// registro. Los módulos que consultan `current_chart.id` deben
1606/// tolerar este ID sentinela.
1607fn build_present_sky_chart() -> Chart {
1608    use std::time::{SystemTime, UNIX_EPOCH};
1609    let secs = SystemTime::now()
1610        .duration_since(UNIX_EPOCH)
1611        .map(|d| d.as_secs() as i64)
1612        .unwrap_or(0);
1613    let (year, month, day, hour, minute, second) = unix_to_civil_utc(secs);
1614    let birth = StoredBirthData {
1615        year,
1616        month,
1617        day,
1618        hour,
1619        minute,
1620        second: second as f64,
1621        tz_offset_minutes: 0,
1622        // Greenwich Royal Observatory — origen histórico del meridiano
1623        // primario. Lat = 51°28'38"N, Lon = 0°.
1624        latitude_deg: 51.4769,
1625        longitude_deg: 0.0,
1626        altitude_m: 47.0,
1627        time_certainty: Default::default(),
1628        subject_name: Some("Cielo".into()),
1629        birthplace_label: Some("Greenwich (UTC)".into()),
1630    };
1631    Chart {
1632        id: ChartId::default(),
1633        contact_id: ContactId::default(),
1634        kind: ChartKind::Natal,
1635        label: format!(
1636            "Cielo {:04}-{:02}-{:02} {:02}:{:02} UTC",
1637            year, month, day, hour, minute
1638        ),
1639        birth_data: birth,
1640        config: StoredChartConfig::default(),
1641        related_chart_id: None,
1642        created_at_ms: 0,
1643    }
1644}
1645
1646/// Convierte un timestamp Unix (segundos UTC desde 1970-01-01) a
1647/// componentes calendario proleptic-Gregorianos `(year, month, day,
1648/// hour, minute, second)`. Algoritmo de Howard Hinnant
1649/// (`days_to_civil`), exacto en todo el rango representable por i64.
1650fn unix_to_civil_utc(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
1651    let day_seconds: i64 = 86_400;
1652    let z = secs.div_euclid(day_seconds);
1653    let s = secs.rem_euclid(day_seconds);
1654    // Hinnant: shift z para que el "era" empiece en 0000-03-01.
1655    let z = z + 719_468;
1656    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1657    let doe = (z - era * 146_097) as u32; // [0, 146096]
1658    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1659    let y = yoe as i64 + era * 400;
1660    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
1661    let mp = (5 * doy + 2) / 153; // [0, 11]
1662    let day = doy - (153 * mp + 2) / 5 + 1;
1663    let month = if mp < 10 { mp + 3 } else { mp - 9 };
1664    let year = if month <= 2 { (y + 1) as i32 } else { y as i32 };
1665    let hour = (s / 3600) as u32;
1666    let minute = ((s % 3600) / 60) as u32;
1667    let second = (s % 60) as u32;
1668    (year, month, day, hour, minute, second)
1669}
1670
1671/// Etiqueta breve para mostrar al elegir una carta en el picker:
1672/// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
1673fn format_birth_brief(birth: &cosmobiologia_model::StoredBirthData) -> String {
1674    let date = format!("{:04}-{:02}-{:02}", birth.year, birth.month, birth.day);
1675    match &birth.birthplace_label {
1676        Some(p) if !p.is_empty() => format!("{} · {}", date, p),
1677        _ => date,
1678    }
1679}
1680
1681/// Edad en años decimales desde el nacimiento hasta el reloj actual.
1682/// Aproximación: ignora la TZ de nacimiento (no afecta a resolución de
1683/// año) y usa una fracción de año tropical sobre los segundos Unix.
1684fn current_age_years(birth: &cosmobiologia_model::StoredBirthData) -> f64 {
1685    use std::time::{SystemTime, UNIX_EPOCH};
1686    let now_secs = SystemTime::now()
1687        .duration_since(UNIX_EPOCH)
1688        .map(|d| d.as_secs_f64())
1689        .unwrap_or(0.0);
1690    let birth_year_frac = birth.year as f64
1691        + (birth.month.saturating_sub(1) as f64) / 12.0
1692        + (birth.day.saturating_sub(1) as f64) / 365.25;
1693    let now_year_frac = 1970.0 + now_secs / (365.2422 * 86400.0);
1694    (now_year_frac - birth_year_frac).max(0.0)
1695}
1696
1697fn module_enabled(cfgs: &HashMap<String, serde_json::Value>, id: &str) -> bool {
1698    cfgs.get(id)
1699        .and_then(|c| c.get("enabled"))
1700        .and_then(|v| v.as_bool())
1701        .unwrap_or(false)
1702}
1703
1704fn set_module_enabled(
1705    cfgs: &mut HashMap<String, serde_json::Value>,
1706    id: &str,
1707    enabled: bool,
1708) {
1709    let entry = cfgs
1710        .entry(id.to_string())
1711        .or_insert_with(|| serde_json::json!({}));
1712    if let serde_json::Value::Object(map) = entry {
1713        map.insert("enabled".into(), serde_json::Value::Bool(enabled));
1714    }
1715}
1716
1717/// Lee del `settings` el flex de un splitter en formato "f0,f1,..." y
1718/// lo devuelve como `Vec<f32>` con la misma longitud que `defaults`.
1719/// Si no hay nada persistido, faltan campos, o algún flex es ≤0, cae a
1720/// `defaults`. Validación estricta porque un flex 0 colapsa al panel.
1721fn load_split_flex_n(store: &Store, key: &str, defaults: &[f32]) -> Vec<f32> {
1722    let Ok(Some(raw)) = store.get_setting(key) else {
1723        return defaults.to_vec();
1724    };
1725    let parsed: Vec<f32> = raw
1726        .split(',')
1727        .filter_map(|s| s.trim().parse::<f32>().ok())
1728        .collect();
1729    if parsed.len() != defaults.len() || parsed.iter().any(|&f| f <= 0.0) {
1730        return defaults.to_vec();
1731    }
1732    parsed
1733}
1734
1735/// Persiste los flex actuales de un splitter — soporta N children.
1736fn save_split_flex(store: &Store, key: &str, sc: &SplitContainer) {
1737    let children = sc.children();
1738    if children.is_empty() {
1739        return;
1740    }
1741    let payload: String = children
1742        .iter()
1743        .map(|c| format!("{:.4}", c.flex))
1744        .collect::<Vec<_>>()
1745        .join(",");
1746    if let Err(e) = store.set_setting(key, &payload) {
1747        eprintln!("[shell] save_split_flex {}: {}", key, e);
1748    }
1749}
1750
1751/// Key del setting donde se persiste el splitter "outer" (el de mayor
1752/// nivel del árbol). En dock=Bottom guarda (main,panel); en docks
1753/// laterales guarda los flex de las 3 columnas — usamos keys distintas
1754/// para no pisar valores entre layouts.
1755fn split_key_outer(dock: PanelDock) -> &'static str {
1756    match dock {
1757        PanelDock::Bottom => "layout.outer_split",
1758        PanelDock::Right => "layout.dock_right",
1759        PanelDock::Left => "layout.dock_left",
1760    }
1761}
1762
1763/// Key del setting del splitter horizontal interno. Solo se usa cuando
1764/// dock=Bottom (en docks laterales no hay main_split activo).
1765fn split_key_main(dock: PanelDock) -> &'static str {
1766    match dock {
1767        PanelDock::Bottom => "layout.main_split",
1768        // En docks laterales el main_split está dormido — escribir acá
1769        // no hace daño pero tampoco se usa al recargar.
1770        PanelDock::Right => "layout.main_split_right",
1771        PanelDock::Left => "layout.main_split_left",
1772    }
1773}
1774
1775fn load_dock(store: &Store) -> Option<PanelDock> {
1776    let raw = store.get_setting("layout.panel_dock").ok().flatten()?;
1777    PanelDock::from_setting(raw.trim())
1778}
1779
1780impl Shell {
1781    /// Tres botones compactos en el header — uno por dock disponible.
1782    /// El dock activo se marca con `bg=accent`; los demás van planos.
1783    /// Click llama a `apply_dock` que reorganiza splitters y persiste.
1784    fn render_dock_switcher(
1785        &self,
1786        theme: &nahual_theme::Theme,
1787        cx: &mut Context<Self>,
1788    ) -> impl IntoElement {
1789        let mut row = div()
1790            .id("tts-dock-switcher")
1791            .flex()
1792            .flex_row()
1793            .gap(px(2.0))
1794            .px(px(2.0))
1795            .py(px(2.0))
1796            .rounded(px(6.0))
1797            .bg(theme.bg_panel_alt.clone())
1798            .border_1()
1799            .border_color(theme.border);
1800
1801        for (dock, glyph) in [
1802            (PanelDock::Left, "◧"),
1803            (PanelDock::Bottom, "▭"),
1804            (PanelDock::Right, "◨"),
1805        ] {
1806            let active = self.dock == dock;
1807            let fg = if active { theme.fg_text } else { theme.fg_muted };
1808            let id: SharedString = SharedString::from(format!("tts-dock-{}", dock.as_setting()));
1809            let mut btn = div()
1810                .id(gpui::ElementId::from(id))
1811                .w(px(22.0))
1812                .h(px(20.0))
1813                .flex()
1814                .items_center()
1815                .justify_center()
1816                .rounded(px(4.0))
1817                .text_size(px(12.0))
1818                .text_color(fg)
1819                .hover(|s| s.bg(theme.bg_row_hover))
1820                .child(SharedString::from(glyph))
1821                .on_click(cx.listener(move |this, _: &ClickEvent, _w, cx| {
1822                    if this.dock != dock {
1823                        this.apply_dock(dock, cx);
1824                    }
1825                }));
1826            if active {
1827                btn = btn.bg(theme.accent);
1828            }
1829            row = row.child(btn);
1830        }
1831
1832        row
1833    }
1834}
1835
1836impl Render for Shell {
1837    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1838        let theme = Theme::global(cx).clone();
1839
1840        // Badge del estado del broker brahman — pequeña pill con
1841        // color según el estado actual del ping cada-30s.
1842        let (badge_text, badge_color) = match &self.brahman_status {
1843            BrahmanStatus::Pending => ("Brahman · …".to_string(), theme.fg_muted),
1844            BrahmanStatus::Connected { session_count } => (
1845                format!("Brahman ✓ {} sessions", session_count),
1846                theme.accent,
1847            ),
1848            BrahmanStatus::Offline { .. } => {
1849                ("Brahman · offline".to_string(), theme.fg_disabled)
1850            }
1851        };
1852        let brahman_badge = div()
1853            .px(px(8.0))
1854            .py(px(2.0))
1855            .rounded(px(8.0))
1856            .bg(theme.bg_panel_alt.clone())
1857            .border_1()
1858            .border_color(theme.border)
1859            .text_size(px(10.0))
1860            .text_color(badge_color)
1861            .child(SharedString::from(badge_text));
1862
1863        let header = div()
1864            .h(px(34.0))
1865            .px(px(12.0))
1866            .flex()
1867            .flex_row()
1868            .items_center()
1869            .gap(px(10.0))
1870            .border_b_1()
1871            .border_color(theme.border)
1872            .child(
1873                div()
1874                    .text_size(px(13.0))
1875                    .text_color(theme.fg_text)
1876                    .child("☉ Tahuantinsuyu"),
1877            )
1878            .child(
1879                div()
1880                    .text_size(px(10.0))
1881                    .text_color(theme.fg_muted)
1882                    .child("estudio de astrología profesional"),
1883            )
1884            .child(div().flex_grow())
1885            .child(self.render_dock_switcher(&theme, cx))
1886            .child(brahman_badge)
1887            .child(theme_switcher(cx));
1888
1889        let body = div()
1890            .flex_grow()
1891            .w_full()
1892            .child(self.outer_split.clone());
1893
1894        div()
1895            .size_full()
1896            .bg(theme.bg_app.clone())
1897            .flex()
1898            .flex_col()
1899            .child(header)
1900            .child(body)
1901    }
1902}
1903
1904// =====================================================================
1905// Tests de integración del Shell
1906// =====================================================================
1907//
1908// Cubren los caminos que combinan lógica del shell con persistencia y
1909// el bridge real de eternal. Los tests puramente unitarios de cada
1910// crate (engine, store, modules) viven en sus respectivos `tests`
1911// modules; acá testeamos los wiring points del binario.
1912
1913#[cfg(test)]
1914mod tests {
1915    use super::*;
1916    use gpui::TestAppContext;
1917
1918    #[test]
1919    fn unix_to_civil_at_epoch() {
1920        assert_eq!(unix_to_civil_utc(0), (1970, 1, 1, 0, 0, 0));
1921    }
1922
1923    #[test]
1924    fn unix_to_civil_known_dates() {
1925        // 2024-01-01T00:00:00 UTC = 1704067200
1926        assert_eq!(unix_to_civil_utc(1_704_067_200), (2024, 1, 1, 0, 0, 0));
1927        // 2024-02-29T12:34:56 UTC = año bisiesto
1928        let secs = 1_704_067_200 + (31 + 28) * 86_400 + 12 * 3600 + 34 * 60 + 56;
1929        assert_eq!(unix_to_civil_utc(secs), (2024, 2, 29, 12, 34, 56));
1930    }
1931
1932    #[test]
1933    fn unix_to_civil_pre_epoch_wraps_correctly() {
1934        // -1 segundo = 1969-12-31T23:59:59 UTC
1935        assert_eq!(unix_to_civil_utc(-1), (1969, 12, 31, 23, 59, 59));
1936    }
1937
1938    #[test]
1939    fn unix_to_civil_year_2000() {
1940        // 2000-01-01T00:00:00 UTC = 946684800
1941        assert_eq!(unix_to_civil_utc(946_684_800), (2000, 1, 1, 0, 0, 0));
1942    }
1943
1944    fn sample_chart_for(_contact_id: ContactId) -> (StoredBirthData, StoredChartConfig) {
1945        (
1946            StoredBirthData {
1947                year: 1987,
1948                month: 3,
1949                day: 14,
1950                hour: 5,
1951                minute: 22,
1952                second: 0.0,
1953                tz_offset_minutes: -240,
1954                latitude_deg: 10.4806,
1955                longitude_deg: -66.9036,
1956                altitude_m: 900.0,
1957                time_certainty: Default::default(),
1958                subject_name: Some("Sergio".into()),
1959                birthplace_label: Some("Caracas".into()),
1960            },
1961            StoredChartConfig::default(),
1962        )
1963    }
1964
1965    /// Smoke test: el Shell se construye sin panic con una store
1966    /// in-memory. Cubre que las suscripciones cross-widget (tree, panel,
1967    /// canvas, ambos splitters) se cablean sin colisiones y que el
1968    /// background loop del brahman status arranca limpio.
1969    #[gpui::test]
1970    fn shell_constructs_smoke(cx: &mut TestAppContext) {
1971        cx.update(|cx| {
1972            Theme::install_default(cx);
1973            let store = Store::in_memory().expect("in-memory store");
1974            let _shell = cx.new(|cx| Shell::new(store, cx));
1975            // Si llegamos acá sin panic, el cableado funciona.
1976        });
1977    }
1978
1979    /// La selección de una carta vía `apply_selection` (mismo pathway
1980    /// que dispara el TreeEvent) puebla `current_chart` y arranca un
1981    /// compute. El render asíncrono se resuelve después; verificamos
1982    /// solo los efectos sincrónicos: chart cargada y `render_seq`
1983    /// avanzado.
1984    #[gpui::test]
1985    fn select_chart_updates_current(cx: &mut TestAppContext) {
1986        cx.update(|cx| {
1987            Theme::install_default(cx);
1988            let store = Store::in_memory().expect("store");
1989            let group = store.create_group(None, "Test", None).unwrap();
1990            let contact = store
1991                .create_contact(Some(group.id), "Subject", None)
1992                .unwrap();
1993            let (birth, config) = sample_chart_for(contact.id);
1994            let chart = store
1995                .create_chart(contact.id, ChartKind::Natal, "Natal", &birth, &config, None)
1996                .unwrap();
1997
1998            let shell = cx.new(|cx| Shell::new(store, cx));
1999            shell.update(cx, |s, cx| {
2000                s.apply_selection(TreeSelection::Chart(chart.id), cx);
2001            });
2002            shell.read_with(cx, |s, _| {
2003                let cur = s.current_chart.as_ref().expect("current_chart set");
2004                assert_eq!(cur.id, chart.id);
2005                assert_eq!(cur.label, "Natal");
2006                assert!(s.render_seq >= 1, "render_seq debió avanzar al menos a 1");
2007            });
2008        });
2009    }
2010
2011    /// Toggleando un módulo overlay vía `module_configs` directamente
2012    /// (simulando el efecto de un `PanelEvent::ControlChanged`), la
2013    /// función `build_requests` debe reflejar el cambio.
2014    #[gpui::test]
2015    fn module_toggles_produce_requests(cx: &mut TestAppContext) {
2016        cx.update(|cx| {
2017            Theme::install_default(cx);
2018            let store = Store::in_memory().expect("store");
2019            let shell = cx.new(|cx| Shell::new(store, cx));
2020
2021            shell.update(cx, |s, _cx| {
2022                // Sin módulos activos → no hay requests.
2023                assert!(s.build_requests().is_empty());
2024
2025                set_module_enabled(&mut s.module_configs, "transit", true);
2026                set_module_enabled(&mut s.module_configs, "midpoints", true);
2027                set_module_enabled(&mut s.module_configs, "uranian", true);
2028
2029                let reqs = s.build_requests();
2030                assert_eq!(reqs.len(), 3);
2031                assert!(matches!(reqs[0], PipelineRequest::Transit));
2032                assert!(matches!(reqs[1], PipelineRequest::Midpoints));
2033                assert!(matches!(reqs[2], PipelineRequest::Uranian));
2034
2035                set_module_enabled(&mut s.module_configs, "transit", false);
2036                let reqs = s.build_requests();
2037                assert_eq!(reqs.len(), 2);
2038                assert!(!reqs
2039                    .iter()
2040                    .any(|r| matches!(r, PipelineRequest::Transit)));
2041            });
2042        });
2043    }
2044
2045    /// `NatalOptions` derivados de `module_configs["natal"]` deben
2046    /// respetar orb_multiplier, show_minors y show_dignities cuando los
2047    /// hay, y caer a defaults razonables cuando no.
2048    #[gpui::test]
2049    fn natal_options_read_from_configs(cx: &mut TestAppContext) {
2050        cx.update(|cx| {
2051            Theme::install_default(cx);
2052            let store = Store::in_memory().expect("store");
2053            let shell = cx.new(|cx| Shell::new(store, cx));
2054
2055            shell.update(cx, |s, _cx| {
2056                let opts = s.build_natal_options();
2057                assert!(opts.show_majors);
2058                assert!(!opts.show_minors);
2059                assert_eq!(opts.orb_multiplier, 1.0);
2060                assert!(!opts.show_dignities);
2061
2062                s.module_configs.insert(
2063                    "natal".into(),
2064                    serde_json::json!({
2065                        "aspect_majors": true,
2066                        "aspect_minors": true,
2067                        "orb_multiplier": 1.75,
2068                        "show_dignities": true,
2069                    }),
2070                );
2071                let opts = s.build_natal_options();
2072                assert!(opts.show_minors);
2073                assert_eq!(opts.orb_multiplier, 1.75);
2074                assert!(opts.show_dignities);
2075            });
2076        });
2077    }
2078
2079    /// El flex de los splitters persiste entre instancias de Shell que
2080    /// comparten la misma store (in-memory): primera shell escribe via
2081    /// `save_split_flex`, segunda shell lee via `load_split_flex_n` al
2082    /// boot. Cubre 2 y 3 hijos (Bottom vs docks laterales).
2083    #[test]
2084    fn split_flex_round_trip_via_store() {
2085        let store = Store::in_memory().expect("store");
2086        let defaults_2 = vec![1.0_f32, 4.0];
2087        let defaults_3 = vec![1.0_f32, 4.0, 1.5];
2088
2089        // Sin nada persistido → defaults.
2090        assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
2091
2092        store.set_setting("layout.x", "2.5,3.5").unwrap();
2093        assert_eq!(
2094            load_split_flex_n(&store, "layout.x", &defaults_2),
2095            vec![2.5_f32, 3.5]
2096        );
2097
2098        store.set_setting("layout.x", "1.0,4.0,2.0").unwrap();
2099        assert_eq!(
2100            load_split_flex_n(&store, "layout.x", &defaults_3),
2101            vec![1.0_f32, 4.0, 2.0]
2102        );
2103
2104        // Valor corrupto → defaults.
2105        store.set_setting("layout.x", "garbage").unwrap();
2106        assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
2107
2108        // Cantidad incorrecta → defaults.
2109        store.set_setting("layout.x", "2,3,4").unwrap();
2110        assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
2111
2112        // Valor ≤0 → defaults.
2113        store.set_setting("layout.x", "0,5").unwrap();
2114        assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
2115    }
2116
2117    /// PanelDock roundtrip via store.
2118    #[test]
2119    fn panel_dock_setting_roundtrip() {
2120        assert_eq!(PanelDock::from_setting("bottom"), Some(PanelDock::Bottom));
2121        assert_eq!(PanelDock::from_setting("right"), Some(PanelDock::Right));
2122        assert_eq!(PanelDock::from_setting("left"), Some(PanelDock::Left));
2123        assert_eq!(PanelDock::from_setting("nope"), None);
2124
2125        let store = Store::in_memory().expect("store");
2126        assert_eq!(load_dock(&store), None);
2127        store
2128            .set_setting("layout.panel_dock", PanelDock::Right.as_setting())
2129            .unwrap();
2130        assert_eq!(load_dock(&store), Some(PanelDock::Right));
2131    }
2132}