gioser

tawasuyu/brahman

Rust 334TOML 129Markdown 39JSON 174745 Definitions

⎇ 295c9ba554fc693550cdec72b33f837cef714c6b crates/apps/tahuantinsuyu/src/shell.rs Rust · 884 lines · 35.4 KB History Who touched what raw

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