tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

⎇ a4d1e0dc17b6fba7522adc49e007557321778fa0 crates/apps/tahuantinsuyu/src/shell.rs Rust · 966 lines · 38.7 KB History Who touched what raw

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