tawasuyu

tawasuyu/brahman

Rust 307TOML 110JSON 14Markdown 113965 Definiciones

⎇ d890bd4b3a7aa8af918e854c1ae6754ca08673cd crates/apps/tahuantinsuyu/src/shell.rs Rust · 817 líneas · 32.6 KB Historia Quién tocó qué crudo

feat(tahuantinsuyu): fase 21 — background compute + UranianModule d890bd4b3 · 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    Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Window, div,
28    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::{TahuantinsuyuTree, TreeEvent};
42use yahweh_bus::AppBus;
43use yahweh_theme::Theme;
44use yahweh_widget_theme_switcher::theme_switcher;
45
46const TREE_WIDTH: f32 = 280.0;
47const PANEL_HEIGHT: f32 = 180.0;
48
49pub struct Shell {
50    store: Store,
51    #[allow(dead_code)]
52    bus: Entity<AppBus>,
53    tree: Entity<TahuantinsuyuTree>,
54    canvas: Entity<AstrologyCanvas>,
55    panel: Entity<ControlPanel>,
56    current_chart: Option<Chart>,
57    current_offset_minutes: i64,
58    /// Estado de los módulos overlay (transit, progression, …) por
59    /// `module_id`. Las claves dentro del JSON dependen del módulo (la
60    /// convención es `"enabled": bool` para el toggle principal).
61    module_configs: HashMap<String, serde_json::Value>,
62    /// Sequence counter para descartar resultados de cómputos
63    /// background que llegan después de uno más reciente. Cada
64    /// `render_current` lo incrementa y la closure async compara antes
65    /// de aplicar el render al canvas.
66    render_seq: u64,
67}
68
69impl Shell {
70    pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
71        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
72
73        let bus = cx.new(|_| AppBus);
74        let tree = cx.new(|cx| TahuantinsuyuTree::new(store.clone(), cx));
75        let canvas = cx.new(AstrologyCanvas::new);
76        let panel = cx.new(ControlPanel::new);
77
78        cx.subscribe(&tree, |this: &mut Self, _, ev: &TreeEvent, cx| {
79            this.on_tree_event(ev, cx);
80        })
81        .detach();
82
83        cx.subscribe(&panel, |this: &mut Self, _, ev: &PanelEvent, cx| {
84            this.on_panel_event(ev, cx);
85        })
86        .detach();
87
88        cx.subscribe(&canvas, |this: &mut Self, _, ev: &CanvasEvent, cx| {
89            this.on_canvas_event(ev, cx);
90        })
91        .detach();
92
93        let mut shell = Self {
94            store,
95            bus,
96            tree,
97            canvas,
98            panel,
99            current_chart: None,
100            current_offset_minutes: 0,
101            module_configs: HashMap::new(),
102            render_seq: 0,
103        };
104        shell.refresh_chart_options(cx);
105        shell
106    }
107
108    /// Recarga la lista de opciones para los `Control::ChartPicker` y
109    /// la pushea al panel. Llamado al boot + tras cada
110    /// `TreeEvent::HierarchyChanged`.
111    fn refresh_chart_options(&self, cx: &mut Context<Self>) {
112        let charts = self.store.list_all_charts().unwrap_or_default();
113        let options: Vec<ChartOption> = charts
114            .into_iter()
115            .map(|c| ChartOption {
116                id: c.id.to_string(),
117                label: format!("{} — {}", c.label, format_birth_brief(&c.birth_data)),
118            })
119            .collect();
120        self.panel
121            .update(cx, |p, cx| p.set_chart_options(options, cx));
122    }
123
124    fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) {
125        let selection = match ev {
126            TreeEvent::Selected(s) => s,
127            TreeEvent::Opened(s) => s,
128            TreeEvent::HierarchyChanged => {
129                // La jerarquía cambió (alta/baja de cartas) — refrescar
130                // las opciones del picker para que aparezcan / desaparezcan
131                // en el dropdown.
132                self.refresh_chart_options(cx);
133                cx.notify();
134                return;
135            }
136        };
137        self.apply_selection(selection.clone(), cx);
138    }
139
140    fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
141        match sel {
142            TreeSelection::Chart(id) => {
143                let chart = match self.store.get_chart(id) {
144                    Ok(c) => c,
145                    Err(e) => {
146                        eprintln!("[shell] get_chart {}: {}", id, e);
147                        return;
148                    }
149                };
150                let age = current_age_years(&chart.birth_data);
151                self.current_chart = Some(chart.clone());
152                self.current_offset_minutes = 0;
153                // 1) Defaults frescos para esta carta: edad objetivo =
154                //    edad actual. Estos quedan en module_configs como
155                //    valor base si el usuario nunca tocó el slider.
156                self.module_configs.clear();
157                for module_id in ["progression", "solar_arc", "planetary_return"] {
158                    let entry = self
159                        .module_configs
160                        .entry(module_id.into())
161                        .or_insert_with(|| serde_json::json!({}));
162                    if let serde_json::Value::Object(map) = entry {
163                        map.insert("target_age_years".into(), serde_json::json!(age));
164                    }
165                }
166                // El módulo planetary_return además necesita un body
167                // por default — el shell elige "sun" si el usuario no
168                // tocó el Select. La persistencia luego puede pisar
169                // este valor.
170                if let Some(serde_json::Value::Object(map)) =
171                    self.module_configs.get_mut("planetary_return")
172                {
173                    map.entry(String::from("body"))
174                        .or_insert(serde_json::json!("sun"));
175                }
176                // 2) Sobreescribir con lo que el usuario persistió la
177                //    última vez para esta carta (SQLite `module_state`).
178                self.load_persisted_module_states(chart.id);
179                // 3) Sincronizar panel: active_kind + toggles/sliders.
180                self.panel.update(cx, |p, cx| {
181                    p.set_active_kind(Some(chart.kind), cx);
182                });
183                self.sync_panel_from_configs(cx);
184                self.render_current(cx);
185            }
186            TreeSelection::Contact(id) => {
187                self.current_chart = None;
188                self.current_offset_minutes = 0;
189                let charts = self.store.list_charts(id).unwrap_or_default();
190                let items: Vec<ThumbnailItem> = charts
191                    .into_iter()
192                    .map(|c| ThumbnailItem {
193                        chart_id: c.id,
194                        label: SharedString::from(c.label),
195                        subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
196                        preview: None,
197                    })
198                    .collect();
199                self.canvas.update(cx, |c, cx| {
200                    c.set_mode(
201                        CanvasMode::Thumbnails {
202                            scope: ThumbnailScope::Contact(id),
203                            items,
204                        },
205                        cx,
206                    );
207                });
208                self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
209            }
210            TreeSelection::Group(id) => {
211                self.current_chart = None;
212                self.current_offset_minutes = 0;
213                let charts = self.store.charts_under_group(id).unwrap_or_default();
214                let items: Vec<ThumbnailItem> = charts
215                    .into_iter()
216                    .map(|c| ThumbnailItem {
217                        chart_id: c.id,
218                        label: SharedString::from(c.label),
219                        subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
220                        preview: None,
221                    })
222                    .collect();
223                self.canvas.update(cx, |c, cx| {
224                    c.set_mode(
225                        CanvasMode::Thumbnails {
226                            scope: ThumbnailScope::Group(id),
227                            items,
228                        },
229                        cx,
230                    );
231                });
232                self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
233            }
234        }
235    }
236
237    /// Deriva los `PipelineRequest` activos a partir del `module_configs`.
238    fn build_requests(&self) -> Vec<PipelineRequest> {
239        let mut requests = Vec::new();
240        if module_enabled(&self.module_configs, "transit") {
241            requests.push(PipelineRequest::Transit);
242        }
243        if module_enabled(&self.module_configs, "progression") {
244            let age = self.module_age_or_current("progression");
245            requests.push(PipelineRequest::SecondaryProgression {
246                target_age_years: age,
247            });
248        }
249        if module_enabled(&self.module_configs, "solar_arc") {
250            let age = self.module_age_or_current("solar_arc");
251            requests.push(PipelineRequest::SolarArc {
252                target_age_years: age,
253            });
254        }
255        if module_enabled(&self.module_configs, "synastry") {
256            if let Some(partner) = self.resolve_synastry_partner() {
257                requests.push(PipelineRequest::Synastry {
258                    partner_chart: Box::new(partner),
259                });
260            }
261        }
262        if module_enabled(&self.module_configs, "midpoints") {
263            requests.push(PipelineRequest::Midpoints);
264        }
265        if module_enabled(&self.module_configs, "uranian") {
266            requests.push(PipelineRequest::Uranian);
267        }
268        if module_enabled(&self.module_configs, "composite") {
269            if let Some(partner) = self.resolve_composite_partner() {
270                requests.push(PipelineRequest::Composite {
271                    partner_chart: Box::new(partner),
272                });
273            }
274        }
275        if module_enabled(&self.module_configs, "planetary_return") {
276            let age = self.module_age_or_current("planetary_return");
277            let body = self
278                .module_configs
279                .get("planetary_return")
280                .and_then(|c| c.get("body"))
281                .and_then(|v| v.as_str())
282                .unwrap_or("sun")
283                .to_string();
284            let shift_days = self
285                .module_configs
286                .get("planetary_return")
287                .and_then(|c| c.get("shift_days"))
288                .and_then(|v| v.as_f64())
289                .map(|v| v as i64)
290                .unwrap_or(0);
291            requests.push(PipelineRequest::PlanetaryReturn {
292                body,
293                target_age_years: age,
294                shift_days,
295            });
296        }
297        requests
298    }
299
300    /// Resuelve la carta partner para sinastría: 1) si el picker tiene
301    /// un `partner_chart_id` válido en `module_configs`, lo usa; 2)
302    /// si no, cae al automático (primera carta hermana del contacto
303    /// actual). `None` si nada matchea — el request se salta.
304    fn resolve_synastry_partner(&self) -> Option<Chart> {
305        let manual = self
306            .module_configs
307            .get("synastry")
308            .and_then(|c| c.get("partner_chart_id"))
309            .and_then(|v| v.as_str())
310            .and_then(|s| s.parse::<tahuantinsuyu_model::ChartId>().ok())
311            .and_then(|id| self.store.get_chart(id).ok());
312        manual.or_else(|| self.find_synastry_partner_auto())
313    }
314
315    fn find_synastry_partner_auto(&self) -> Option<Chart> {
316        let current = self.current_chart.as_ref()?;
317        let siblings = self.store.list_charts(current.contact_id).ok()?;
318        siblings.into_iter().find(|c| c.id != current.id)
319    }
320
321    /// Resuelve el partner para Composite — mismo patrón que Synastry:
322    /// 1) lee module_configs["composite"]["partner_chart_id"] y resuelve
323    /// el chart; 2) fallback al primer hermano del contacto actual.
324    fn resolve_composite_partner(&self) -> Option<Chart> {
325        let manual = self
326            .module_configs
327            .get("composite")
328            .and_then(|c| c.get("partner_chart_id"))
329            .and_then(|v| v.as_str())
330            .and_then(|s| s.parse::<tahuantinsuyu_model::ChartId>().ok())
331            .and_then(|id| self.store.get_chart(id).ok());
332        manual.or_else(|| self.find_synastry_partner_auto())
333    }
334
335    /// Deriva las `NatalOptions` activas a partir del `module_configs["natal"]`.
336    /// Si la entry no existe, devuelve defaults (majors=true, minors=false,
337    /// multiplier=1.0).
338    fn build_natal_options(&self) -> NatalOptions {
339        let cfg = self.module_configs.get("natal");
340        let read_bool = |key: &str, default: bool| -> bool {
341            cfg.and_then(|c| c.get(key))
342                .and_then(|v| v.as_bool())
343                .unwrap_or(default)
344        };
345        let read_f64 = |key: &str, default: f64| -> f64 {
346            cfg.and_then(|c| c.get(key))
347                .and_then(|v| v.as_f64())
348                .unwrap_or(default)
349        };
350        NatalOptions {
351            show_majors: read_bool("aspect_majors", true),
352            show_minors: read_bool("aspect_minors", false),
353            orb_multiplier: read_f64("orb_multiplier", 1.0),
354            show_dignities: read_bool("show_dignities", false),
355        }
356    }
357
358    /// Lee `module_state` desde SQLite para la carta dada y los mergea
359    /// con los defaults ya cargados en `module_configs`. Los valores
360    /// persistidos ganan sobre los defaults.
361    fn load_persisted_module_states(&mut self, chart_id: ChartId) {
362        let states = match self.store.list_module_states(chart_id) {
363            Ok(s) => s,
364            Err(e) => {
365                eprintln!("[shell] list_module_states {}: {}", chart_id, e);
366                return;
367            }
368        };
369        for st in states {
370            // Re-mergeamos `enabled` (columna separada en SQL) dentro
371            // del JSON config, así el resto del shell sigue leyendo
372            // todo desde una única estructura.
373            let mut combined = match st.config {
374                serde_json::Value::Object(m) => serde_json::Value::Object(m),
375                _ => serde_json::json!({}),
376            };
377            if let serde_json::Value::Object(map) = &mut combined {
378                map.insert("enabled".into(), serde_json::Value::Bool(st.enabled));
379            }
380            // Mergear sobre defaults previos (no sobreescribir si la
381            // entrada nueva no trae un campo).
382            match self.module_configs.entry(st.module_id) {
383                std::collections::hash_map::Entry::Vacant(v) => {
384                    v.insert(combined);
385                }
386                std::collections::hash_map::Entry::Occupied(mut o) => {
387                    if let (serde_json::Value::Object(dst), serde_json::Value::Object(src)) =
388                        (o.get_mut(), &combined)
389                    {
390                        for (k, v) in src {
391                            dst.insert(k.clone(), v.clone());
392                        }
393                    } else {
394                        o.insert(combined);
395                    }
396                }
397            }
398        }
399    }
400
401    /// Pushea cada toggle/slider/picker del `module_configs` al panel
402    /// para que la UI refleje el estado persistido al cargar una carta.
403    fn sync_panel_from_configs(&mut self, cx: &mut Context<Self>) {
404        let snapshot: Vec<(String, serde_json::Value)> = self
405            .module_configs
406            .iter()
407            .map(|(k, v)| (k.clone(), v.clone()))
408            .collect();
409        self.panel.update(cx, |p, cx| {
410            for (module_id, config) in &snapshot {
411                if let serde_json::Value::Object(map) = config {
412                    for (key, value) in map {
413                        if let Some(b) = value.as_bool() {
414                            p.set_toggle(module_id, key, b, cx);
415                        } else if let Some(f) = value.as_f64() {
416                            p.set_slider(module_id, key, f, cx);
417                        } else if let Some(s) = value.as_str() {
418                            p.set_string(module_id, key, Some(s.to_string()), cx);
419                        } else if value.is_null() {
420                            p.set_string(module_id, key, None, cx);
421                        }
422                    }
423                }
424            }
425        });
426    }
427
428    /// Persiste el estado actual de un módulo a SQLite. Extrae
429    /// `enabled` del JSON y lo guarda en la columna dedicada; el resto
430    /// va al `config_json`.
431    fn persist_module(&self, module_id: &str) {
432        let Some(chart) = self.current_chart.as_ref() else {
433            return;
434        };
435        let Some(config) = self.module_configs.get(module_id) else {
436            return;
437        };
438        let enabled = config
439            .get("enabled")
440            .and_then(|v| v.as_bool())
441            .unwrap_or(false);
442        let mut clean = config.clone();
443        if let serde_json::Value::Object(map) = &mut clean {
444            map.remove("enabled");
445        }
446        let state = ModuleState {
447            chart_id: chart.id,
448            module_id: module_id.to_string(),
449            enabled,
450            config: clean,
451        };
452        if let Err(e) = self.store.upsert_module_state(&state) {
453            eprintln!("[shell] upsert_module_state {}: {}", module_id, e);
454        }
455    }
456
457    /// Lee `target_age_years` del módulo o cae a la edad actual del
458    /// sujeto (calculada desde la fecha de nacimiento y el reloj).
459    fn module_age_or_current(&self, module_id: &str) -> f64 {
460        self.module_configs
461            .get(module_id)
462            .and_then(|c| c.get("target_age_years"))
463            .and_then(|v| v.as_f64())
464            .unwrap_or_else(|| {
465                self.current_chart
466                    .as_ref()
467                    .map(|c| current_age_years(&c.birth_data))
468                    .unwrap_or(0.0)
469            })
470    }
471
472    fn render_current(&mut self, cx: &mut Context<Self>) {
473        let Some(chart) = self.current_chart.as_ref() else {
474            return;
475        };
476        // Snapshot de inputs para mover al background. La sesión
477        // VSOP2013 vive en un static `OnceLock` adentro del bridge, así
478        // que es compartible read-only entre threads sin que ningún
479        // dato cruce más allá del Chart clonado + requests/options.
480        let chart = chart.clone();
481        let offset = self.current_offset_minutes;
482        let requests = self.build_requests();
483        let natal_options = self.build_natal_options();
484        self.render_seq = self.render_seq.wrapping_add(1);
485        let my_seq = self.render_seq;
486
487        cx.spawn(async move |this, cx| {
488            // El compute corre en el background_executor — no bloquea
489            // el UI thread. Para una rueda completa con varios overlays
490            // puede tomar 100-200ms; sin esto, los drags del slider se
491            // sentirían atorados.
492            let chart_for_bg = chart.clone();
493            let requests_for_bg = requests.clone();
494            let opts_for_bg = natal_options.clone();
495            let result = cx
496                .background_executor()
497                .spawn(async move {
498                    compose_with_options(&chart_for_bg, offset, &requests_for_bg, &opts_for_bg)
499                })
500                .await;
501
502            let _ = this.update(cx, |this, cx| {
503                // Descartar si llegó un render más nuevo en el medio.
504                // Sin este check, durante un drag rápido un compute
505                // viejo podría sobrescribir el más reciente.
506                if this.render_seq != my_seq {
507                    return;
508                }
509                match result {
510                    Ok(render) => {
511                        this.canvas.update(cx, |c, cx| {
512                            c.set_mode(
513                                CanvasMode::Wheel {
514                                    render: Box::new(render),
515                                },
516                                cx,
517                            );
518                        });
519                    }
520                    Err(e) => {
521                        eprintln!(
522                            "[shell] compose {} (+{}min, {} reqs): {}",
523                            chart.id,
524                            offset,
525                            requests.len(),
526                            e
527                        );
528                    }
529                }
530            });
531        })
532        .detach();
533    }
534
535    fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context<Self>) {
536        match ev {
537            CanvasEvent::TimeOffsetChanged(off) => {
538                self.current_offset_minutes = *off;
539                if self.current_chart.is_some() {
540                    self.render_current(cx);
541                }
542            }
543            CanvasEvent::LayerVisibilityChanged { kind, visible } => {
544                // El toggle de Outer ([T]) no es visibility puro: dispara
545                // un pipeline distinto. Lo traducimos a un cambio en
546                // module_configs["transit"]["enabled"] + re-render.
547                if matches!(kind, LayerKind::Outer) {
548                    set_module_enabled(&mut self.module_configs, "transit", *visible);
549                    self.persist_module("transit");
550                    self.panel.update(cx, |p, cx| {
551                        p.set_toggle("transit", "enabled", *visible, cx)
552                    });
553                    self.render_current(cx);
554                    return;
555                }
556                // El resto son visibility puros sobre el canvas. Sync el
557                // panel para que el toggle visual coincida con la hotkey.
558                let key = match kind {
559                    LayerKind::SignDial => "show_sign_dial",
560                    LayerKind::Houses => "show_houses",
561                    LayerKind::Aspects => "show_aspects",
562                    LayerKind::Bodies => "show_bodies",
563                    _ => return,
564                };
565                self.panel
566                    .update(cx, |p, cx| p.set_toggle("natal", key, *visible, cx));
567            }
568            CanvasEvent::ChartRequested(_) => {
569                // Fase 7: doble click sobre un thumbnail abre la carta.
570            }
571            CanvasEvent::ExportSvgRequested => {
572                self.export_current_to_svg();
573            }
574        }
575    }
576
577    /// Recompone la carta actual + escribe el SVG a un archivo en
578    /// `$XDG_DATA_HOME/tahuantinsuyu/exports/<label>_<short_id>.svg`.
579    /// Logea la ruta a stderr — futuro: file save dialog GPUI.
580    fn export_current_to_svg(&self) {
581        let Some(chart) = self.current_chart.as_ref() else {
582            eprintln!("[shell] export svg: sin carta activa");
583            return;
584        };
585        let requests = self.build_requests();
586        let natal_options = self.build_natal_options();
587        let render = match compose_with_options(
588            chart,
589            self.current_offset_minutes,
590            &requests,
591            &natal_options,
592        ) {
593            Ok(r) => r,
594            Err(e) => {
595                eprintln!("[shell] export svg compose: {}", e);
596                return;
597            }
598        };
599        let svg = svg_export::render_to_svg(&render);
600        let dir = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu")
601            .map(|d| d.data_dir().join("exports"))
602            .unwrap_or_else(|| std::path::PathBuf::from("."));
603        if let Err(e) = std::fs::create_dir_all(&dir) {
604            eprintln!("[shell] mkdir {:?}: {}", dir, e);
605            return;
606        }
607        let safe_label: String = chart
608            .label
609            .chars()
610            .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
611            .collect();
612        let short = format!("{}", chart.id).chars().take(8).collect::<String>();
613        let path = dir.join(format!("{}_{}.svg", safe_label, short));
614        if let Err(e) = std::fs::write(&path, svg) {
615            eprintln!("[shell] write {:?}: {}", path, e);
616        } else {
617            eprintln!("[shell] SVG exportado → {}", path.display());
618        }
619    }
620
621    fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
622        match ev {
623            PanelEvent::ControlChanged {
624                module_id, key, value,
625            } => {
626                let bool_val = value.as_bool().unwrap_or(true);
627                if module_id == "natal" {
628                    // Distinguimos: show_* = visibility (no recompose),
629                    // aspect_*/orb_* = filtros de engine (recompose +
630                    // persist).
631                    let kind = match key.as_str() {
632                        "show_sign_dial" => Some(LayerKind::SignDial),
633                        "show_houses" => Some(LayerKind::Houses),
634                        "show_aspects" => Some(LayerKind::Aspects),
635                        "show_bodies" => Some(LayerKind::Bodies),
636                        _ => None,
637                    };
638                    if let Some(k) = kind {
639                        self.canvas
640                            .update(cx, |c, cx| c.set_layer_visible(k, bool_val, cx));
641                    } else {
642                        // Filtros: actualizar module_configs + recompose.
643                        let entry = self
644                            .module_configs
645                            .entry("natal".into())
646                            .or_insert_with(|| serde_json::json!({}));
647                        if let serde_json::Value::Object(map) = entry {
648                            map.insert(key.clone(), value.clone());
649                        }
650                        self.persist_module("natal");
651                        self.render_current(cx);
652                    }
653                } else {
654                    // Cualquier otro módulo: actualizamos su config y
655                    // recompomemos. La engine vuelve a llamarse con el
656                    // PipelineRequest derivado del nuevo estado.
657                    let entry = self
658                        .module_configs
659                        .entry(module_id.clone())
660                        .or_insert_with(|| serde_json::json!({}));
661                    if let serde_json::Value::Object(map) = entry {
662                        map.insert(key.clone(), value.clone());
663                    }
664                    // Transit, Synastry y Solar Return comparten el
665                    // outer ring del canvas — son mutuamente excluyentes.
666                    // Al prender uno, apagamos los otros + sync panel +
667                    // persist.
668                    if key == "enabled" && bool_val && OUTER_RING_MODULES.contains(&module_id.as_str()) {
669                        for &other in OUTER_RING_MODULES.iter() {
670                            if other != module_id && module_enabled(&self.module_configs, other) {
671                                set_module_enabled(&mut self.module_configs, other, false);
672                                let other_str = other.to_string();
673                                self.panel.update(cx, |p, cx| {
674                                    p.set_toggle(&other_str, "enabled", false, cx)
675                                });
676                                self.persist_module(&other_str);
677                            }
678                        }
679                    }
680                    // Sincronizar visualmente el toggle [T] del canvas
681                    // cuando el cambio afecta el outer ring (transit,
682                    // synastry o solar_return).
683                    if OUTER_RING_MODULES.contains(&module_id.as_str()) && key == "enabled" {
684                        self.canvas.update(cx, |c, cx| {
685                            c.set_layer_visible(LayerKind::Outer, bool_val, cx)
686                        });
687                    }
688                    self.persist_module(module_id);
689                    self.render_current(cx);
690                }
691            }
692            PanelEvent::ModuleToggled { .. } => {
693                // Fase 7: encender/apagar módulos enteros desde un
694                // header con switch (vs. el toggle por-control de hoy).
695            }
696        }
697        let _ = (&self.store, &self.tree, &self.bus);
698    }
699}
700
701// =====================================================================
702// Helpers de module_configs
703// =====================================================================
704
705// OUTER_RING_MODULES viene de tahuantinsuyu_engine — single source of
706// truth. Shell y canvas leen del mismo slice.
707
708
709/// Etiqueta breve para mostrar al elegir una carta en el picker:
710/// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
711fn format_birth_brief(birth: &tahuantinsuyu_model::StoredBirthData) -> String {
712    let date = format!("{:04}-{:02}-{:02}", birth.year, birth.month, birth.day);
713    match &birth.birthplace_label {
714        Some(p) if !p.is_empty() => format!("{} · {}", date, p),
715        _ => date,
716    }
717}
718
719/// Edad en años decimales desde el nacimiento hasta el reloj actual.
720/// Aproximación: ignora la TZ de nacimiento (no afecta a resolución de
721/// año) y usa una fracción de año tropical sobre los segundos Unix.
722fn current_age_years(birth: &tahuantinsuyu_model::StoredBirthData) -> f64 {
723    use std::time::{SystemTime, UNIX_EPOCH};
724    let now_secs = SystemTime::now()
725        .duration_since(UNIX_EPOCH)
726        .map(|d| d.as_secs_f64())
727        .unwrap_or(0.0);
728    let birth_year_frac = birth.year as f64
729        + (birth.month.saturating_sub(1) as f64) / 12.0
730        + (birth.day.saturating_sub(1) as f64) / 365.25;
731    let now_year_frac = 1970.0 + now_secs / (365.2422 * 86400.0);
732    (now_year_frac - birth_year_frac).max(0.0)
733}
734
735fn module_enabled(cfgs: &HashMap<String, serde_json::Value>, id: &str) -> bool {
736    cfgs.get(id)
737        .and_then(|c| c.get("enabled"))
738        .and_then(|v| v.as_bool())
739        .unwrap_or(false)
740}
741
742fn set_module_enabled(
743    cfgs: &mut HashMap<String, serde_json::Value>,
744    id: &str,
745    enabled: bool,
746) {
747    let entry = cfgs
748        .entry(id.to_string())
749        .or_insert_with(|| serde_json::json!({}));
750    if let serde_json::Value::Object(map) = entry {
751        map.insert("enabled".into(), serde_json::Value::Bool(enabled));
752    }
753}
754
755impl Render for Shell {
756    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
757        let theme = Theme::global(cx).clone();
758
759        let header = div()
760            .h(px(34.0))
761            .px(px(12.0))
762            .flex()
763            .flex_row()
764            .items_center()
765            .gap(px(10.0))
766            .border_b_1()
767            .border_color(theme.border)
768            .child(
769                div()
770                    .text_size(px(13.0))
771                    .text_color(theme.fg_text)
772                    .child("☉ Tahuantinsuyu"),
773            )
774            .child(
775                div()
776                    .text_size(px(10.0))
777                    .text_color(theme.fg_muted)
778                    .child("estudio de astrología profesional"),
779            )
780            .child(div().flex_grow())
781            .child(theme_switcher(cx));
782
783        let tree_panel = div()
784            .w(px(TREE_WIDTH))
785            .min_w(px(TREE_WIDTH))
786            .h_full()
787            .border_r_1()
788            .border_color(theme.border)
789            .child(self.tree.clone());
790
791        let canvas_panel = div().flex_grow().h_full().child(self.canvas.clone());
792
793        let main_row = div()
794            .flex_grow()
795            .flex()
796            .flex_row()
797            .child(tree_panel)
798            .child(canvas_panel);
799
800        let bottom_panel = div()
801            .h(px(PANEL_HEIGHT))
802            .min_h(px(PANEL_HEIGHT))
803            .w_full()
804            .border_t_1()
805            .border_color(theme.border)
806            .child(self.panel.clone());
807
808        div()
809            .size_full()
810            .bg(theme.bg_app.clone())
811            .flex()
812            .flex_col()
813            .child(header)
814            .child(main_row)
815            .child(bottom_panel)
816    }
817}