tawasuyu

tawasuyu/brahman

Rust 329TOML 125JSON 17Markdown 144672 Definitions

⎇ 7eb620aa170f825b5dec6ff454baac07c48bd9f1 crates/apps/tahuantinsuyu/src/shell.rs Rust · 1473 lines · 58.7 KB History Who touched what raw

feat(tahuantinsuyu): GR dual-ring + topo ascensional pegado al dial + coords 7eb620aa1 · sergio ·

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