tawasuyu

tawasuyu/brahman

Rust 405TOML 156Markdown 40JSON 175449 Sutichaykuna

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/apps/tahuantinsuyu/src/shell.rs Rust · 779 siq'ikuna · 30.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

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