tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definiciones

⎇ e044d475167b0b210a79df049541c22d61edd4e2 crates/apps/tahuantinsuyu/src/shell.rs Rust · 1018 líneas · 41.0 KB Historia Quién tocó qué crudo

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