//! Shell — coordinador de los tres widgets. //! //! Es el "director de orquesta": dueño del tree, del canvas y del panel, //! reenvía eventos entre ellos y aplica las mutaciones en la store. //! //! Flujo: //! //! ```text //! Tree.Selected(Chart) → Shell → load chart + compose + set_mode(Wheel) //! Tree.Selected(Group/Contact)→ Shell → charts_under_* + set_mode(Thumbnails) //! Canvas.TimeOffsetChanged → Shell → compose(current_chart, off, requests) //! Canvas.LayerVisibility[T] → Shell → flip module_configs[transit][enabled] //! Panel.ControlChanged → Shell → update module_configs OR canvas visibility //! ``` //! //! ## module_configs //! //! Mapa `module_id → JSON` con la configuración persistente de cada //! módulo (transit, progression, …). De ahí derivamos los //! `PipelineRequest` que la engine consume. Los toggles "visuales" //! del NatalModule (`show_sign_dial`, `show_houses`, …) NO viven acá //! — afectan solo el render del canvas, no la composición. use std::collections::HashMap; use gpui::{ ClickEvent, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Window, div, prelude::*, px, }; use tahuantinsuyu_canvas::{ AstrologyCanvas, CanvasEvent, CanvasMode, ThumbnailItem, ThumbnailScope, }; use tahuantinsuyu_engine::{ LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest, compose_with_options, svg_export, }; use tahuantinsuyu_model::{ Chart, ChartId, ChartKind, ContactId, ModuleState, StoredBirthData, StoredChartConfig, TreeSelection, }; use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent}; use tahuantinsuyu_store::Store; use tahuantinsuyu_tree::{parse_city_atlas_tsv, TahuantinsuyuTree, TreeEvent}; use yahweh_core::{LayoutDirection, NodeId}; use yahweh_theme::Theme; use yahweh_widget_container_core::ChildSlot; use yahweh_widget_splitter::{SplitContainer, SplitEvent}; use yahweh_widget_theme_switcher::theme_switcher; /// Posición del panel de control dentro del shell. `Bottom` mantiene /// el layout histórico (tree+canvas arriba, panel abajo); las variantes /// laterales colapsan los splitters anidados en uno solo de 3 columnas. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PanelDock { Bottom, Right, Left, } impl PanelDock { fn as_setting(&self) -> &'static str { match self { PanelDock::Bottom => "bottom", PanelDock::Right => "right", PanelDock::Left => "left", } } fn from_setting(s: &str) -> Option { match s { "bottom" => Some(PanelDock::Bottom), "right" => Some(PanelDock::Right), "left" => Some(PanelDock::Left), _ => None, } } } /// Status del broker brahman tal como lo vimos en el último ping. /// Se refresca cada 30 segundos desde un background task. #[derive(Clone, Debug)] pub enum BrahmanStatus { /// Aún no probamos (boot, primer ciclo). Pending, /// Connect OK al broker, devolvió la lista de sessions activas. Connected { session_count: usize }, /// Connect falló — broker no escucha en el socket o tomó timeout. /// `reason` se incluye para diagnóstico en logs aunque la UI hoy /// muestra solo "offline". Offline { #[allow(dead_code)] reason: String, }, } pub struct Shell { store: Store, /// Los tres widgets viven como children de los splitters vía /// AnyView clone; retenemos los Entity acá para que las /// subscripciones sigan vivas y para poder rearmar el layout al /// cambiar `dock` sin recrear los widgets. tree: Entity, canvas: Entity, panel: Entity, /// Splitter "exterior". En dock=Bottom es vertical con (main_split, /// panel) como hijos; en dock=Right/Left es horizontal y agrupa /// tree+canvas+panel en una sola tira. outer_split: Entity, /// Splitter horizontal interno con (tree, canvas). Solo se usa /// cuando dock=Bottom; en docks laterales queda vivo pero sin ser /// hijo del árbol activo. main_split: Entity, /// Dock activo del panel — determina cómo se arman los splitters /// y cuáles flex se persisten. dock: PanelDock, /// Último estado conocido del broker brahman — refrescado cada /// 30s desde el background task. brahman_status: BrahmanStatus, current_chart: Option, current_offset_minutes: i64, /// Estado de los módulos overlay (transit, progression, …) por /// `module_id`. Las claves dentro del JSON dependen del módulo (la /// convención es `"enabled": bool` para el toggle principal). module_configs: HashMap, /// Sequence counter para descartar resultados de cómputos /// background que llegan después de uno más reciente. Cada /// `render_current` lo incrementa y la closure async compara antes /// de aplicar el render al canvas. render_seq: u64, } impl Shell { pub fn new(store: Store, cx: &mut Context) -> Self { cx.observe_global::(|_, cx| cx.notify()).detach(); let tree = cx.new(|cx| { let mut t = TahuantinsuyuTree::new(store.clone(), cx); // Si hay un atlas custom en $XDG_DATA_HOME/tahuantinsuyu/ // atlas.tsv, lo cargamos y reemplazamos el atlas hardcoded // de 90 ciudades. Formato TSV: namelatlontz_min. if let Some(atlas) = load_city_atlas_from_xdg() { t.set_city_atlas(atlas, cx); } t }); let canvas = cx.new(AstrologyCanvas::new); let panel = cx.new(ControlPanel::new); cx.subscribe(&tree, |this: &mut Self, _, ev: &TreeEvent, cx| { this.on_tree_event(ev, cx); }) .detach(); cx.subscribe(&panel, |this: &mut Self, _, ev: &PanelEvent, cx| { this.on_panel_event(ev, cx); }) .detach(); cx.subscribe(&canvas, |this: &mut Self, _, ev: &CanvasEvent, cx| { this.on_canvas_event(ev, cx); }) .detach(); // Splitters vacíos — `apply_dock` los puebla según el layout // activo. Horizontal/Vertical son defaults; cada apply ajusta la // dirección antes de setear children. let main_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Horizontal, cx)); let outer_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Vertical, cx)); // Persistir flex en `DragEnd`. La key del setting depende del // dock activo, así no se pisan los flexes de un layout con los // de otro al mudarse. Se lee dentro del closure para tomar el // dock actualizado, no el capturado en `new`. let store_main = store.clone(); cx.subscribe(&main_split, move |this: &mut Self, sc, ev: &SplitEvent, cx| { if matches!(ev, SplitEvent::DragEnd) { let key = split_key_main(this.dock); save_split_flex(&store_main, key, sc.read(cx)); } }) .detach(); let store_outer = store.clone(); cx.subscribe(&outer_split, move |this: &mut Self, sc, ev: &SplitEvent, cx| { if matches!(ev, SplitEvent::DragEnd) { let key = split_key_outer(this.dock); save_split_flex(&store_outer, key, sc.read(cx)); } }) .detach(); let dock = load_dock(&store).unwrap_or(PanelDock::Bottom); let mut shell = Self { store, tree, canvas, panel, outer_split, main_split, dock, brahman_status: BrahmanStatus::Pending, current_chart: None, current_offset_minutes: 0, module_configs: HashMap::new(), render_seq: 0, }; shell.apply_dock(dock, cx); shell.refresh_chart_options(cx); shell.spawn_brahman_status_loop(cx); // Carta "Cielo ahora" cargada por default al boot — el usuario // siempre arranca viendo el estado del firmamento actual, // incluso si la store está vacía. shell.apply_selection(TreeSelection::PresentSky, cx); shell } /// Arma el árbol de splitters según el dock pedido y persiste la /// elección. Idempotente: llamar con el dock actual reconstruye los /// children con flexes leídos del setting (útil tras `new`). pub fn apply_dock(&mut self, dock: PanelDock, cx: &mut Context) { self.dock = dock; let tree_view = gpui::AnyView::from(self.tree.clone()); let canvas_view = gpui::AnyView::from(self.canvas.clone()); let panel_view = gpui::AnyView::from(self.panel.clone()); let main_view = gpui::AnyView::from(self.main_split.clone()); match dock { PanelDock::Bottom => { let flex_main = load_split_flex_n( &self.store, split_key_main(dock), &[1.0, 4.0], ); let flex_outer = load_split_flex_n( &self.store, split_key_outer(dock), &[4.0, 1.0], ); self.main_split.update(cx, |sc, cx| { sc.set_direction(LayoutDirection::Horizontal, cx); sc.set_children( vec![ ChildSlot { id: NodeId::new("tts-tree"), flex: flex_main[0], label: None, view: tree_view.clone(), }, ChildSlot { id: NodeId::new("tts-canvas"), flex: flex_main[1], label: None, view: canvas_view.clone(), }, ], cx, ); }); self.outer_split.update(cx, |sc, cx| { sc.set_direction(LayoutDirection::Vertical, cx); sc.set_children( vec![ ChildSlot { id: NodeId::new("tts-main"), flex: flex_outer[0], label: None, view: main_view, }, ChildSlot { id: NodeId::new("tts-panel"), flex: flex_outer[1], label: None, view: panel_view, }, ], cx, ); }); } PanelDock::Right => { let flex = load_split_flex_n( &self.store, split_key_outer(dock), &[1.0, 4.0, 1.5], ); self.outer_split.update(cx, |sc, cx| { sc.set_direction(LayoutDirection::Horizontal, cx); sc.set_children( vec![ ChildSlot { id: NodeId::new("tts-tree"), flex: flex[0], label: None, view: tree_view, }, ChildSlot { id: NodeId::new("tts-canvas"), flex: flex[1], label: None, view: canvas_view, }, ChildSlot { id: NodeId::new("tts-panel"), flex: flex[2], label: None, view: panel_view, }, ], cx, ); }); } PanelDock::Left => { let flex = load_split_flex_n( &self.store, split_key_outer(dock), &[1.5, 1.0, 4.0], ); self.outer_split.update(cx, |sc, cx| { sc.set_direction(LayoutDirection::Horizontal, cx); sc.set_children( vec![ ChildSlot { id: NodeId::new("tts-panel"), flex: flex[0], label: None, view: panel_view, }, ChildSlot { id: NodeId::new("tts-tree"), flex: flex[1], label: None, view: tree_view, }, ChildSlot { id: NodeId::new("tts-canvas"), flex: flex[2], label: None, view: canvas_view, }, ], cx, ); }); } } if let Err(e) = self.store.set_setting("layout.panel_dock", dock.as_setting()) { eprintln!("[shell] persist panel_dock: {}", e); } cx.notify(); } /// Loop que cada 30s pregunta al broker la lista de sessions /// activas y actualiza `brahman_status`. El cómputo bloqueante /// (list_sessions_blocking abre su propio tokio runtime) corre en /// el background_executor — no bloquea el UI thread. Cuando llega /// el resultado, el `this.update` dispara cx.notify para repintar /// el badge del header. fn spawn_brahman_status_loop(&self, cx: &mut Context) { cx.spawn(async move |this, cx| { loop { let result = cx .background_executor() .spawn(async { brahman_sidecar::list_sessions_blocking("tahuantinsuyu-observer") }) .await; let _ = this.update(cx, |this, cx| { this.brahman_status = match result { Ok(list) => BrahmanStatus::Connected { session_count: list.entries.len(), }, Err(e) => BrahmanStatus::Offline { reason: format!("{:?}", e), }, }; cx.notify(); }); let timer = cx .background_executor() .timer(std::time::Duration::from_secs(30)); timer.await; } }) .detach(); } /// Recarga la lista de opciones para los `Control::ChartPicker` y /// la pushea al panel. Llamado al boot + tras cada /// `TreeEvent::HierarchyChanged`. fn refresh_chart_options(&self, cx: &mut Context) { let charts = self.store.list_all_charts().unwrap_or_default(); let options: Vec = charts .into_iter() .map(|c| ChartOption { id: c.id.to_string(), label: format!("{} — {}", c.label, format_birth_brief(&c.birth_data)), }) .collect(); self.panel .update(cx, |p, cx| p.set_chart_options(options, cx)); } fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context) { let selection = match ev { TreeEvent::Selected(s) => s, TreeEvent::Opened(s) => s, TreeEvent::HierarchyChanged => { // La jerarquía cambió (alta/baja de cartas) — refrescar // las opciones del picker para que aparezcan / desaparezcan // en el dropdown. self.refresh_chart_options(cx); cx.notify(); return; } }; self.apply_selection(selection.clone(), cx); } fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context) { match sel { TreeSelection::Chart(id) => { let chart = match self.store.get_chart(id) { Ok(c) => c, Err(e) => { eprintln!("[shell] get_chart {}: {}", id, e); return; } }; let age = current_age_years(&chart.birth_data); self.current_chart = Some(chart.clone()); self.current_offset_minutes = 0; // 1) Defaults frescos para esta carta: edad objetivo = // edad actual. Estos quedan en module_configs como // valor base si el usuario nunca tocó el slider. self.module_configs.clear(); for module_id in ["progression", "solar_arc", "planetary_return"] { let entry = self .module_configs .entry(module_id.into()) .or_insert_with(|| serde_json::json!({})); if let serde_json::Value::Object(map) = entry { map.insert("target_age_years".into(), serde_json::json!(age)); } } // El módulo planetary_return además necesita un body // por default — el shell elige "sun" si el usuario no // tocó el Select. La persistencia luego puede pisar // este valor. if let Some(serde_json::Value::Object(map)) = self.module_configs.get_mut("planetary_return") { map.entry(String::from("body")) .or_insert(serde_json::json!("sun")); } // 2) Sobreescribir con lo que el usuario persistió la // última vez para esta carta (SQLite `module_state`). self.load_persisted_module_states(chart.id); // 3) Sincronizar panel: active_kind + toggles/sliders. self.panel.update(cx, |p, cx| { p.set_active_kind(Some(chart.kind), cx); }); self.sync_panel_from_configs(cx); self.render_current(cx); } TreeSelection::Contact(id) => { self.current_chart = None; self.current_offset_minutes = 0; let charts = self.store.list_charts(id).unwrap_or_default(); let items: Vec = charts .into_iter() .map(|c| ThumbnailItem { chart_id: c.id, label: SharedString::from(c.label), subtitle: Some(SharedString::from(format!("{:?}", c.kind))), preview: None, }) .collect(); self.canvas.update(cx, |c, cx| { c.set_mode( CanvasMode::Thumbnails { scope: ThumbnailScope::Contact(id), items, }, cx, ); }); self.panel.update(cx, |p, cx| p.set_active_kind(None, cx)); } TreeSelection::Group(id) => { self.current_chart = None; self.current_offset_minutes = 0; let charts = self.store.charts_under_group(id).unwrap_or_default(); let items: Vec = charts .into_iter() .map(|c| ThumbnailItem { chart_id: c.id, label: SharedString::from(c.label), subtitle: Some(SharedString::from(format!("{:?}", c.kind))), preview: None, }) .collect(); self.canvas.update(cx, |c, cx| { c.set_mode( CanvasMode::Thumbnails { scope: ThumbnailScope::Group(id), items, }, cx, ); }); self.panel.update(cx, |p, cx| p.set_active_kind(None, cx)); } TreeSelection::GeneralRoot => { // "General" agrupa los contactos sin grupo padre. El // canvas muestra thumbnails de TODAS las cartas de // esos contactos. self.current_chart = None; self.current_offset_minutes = 0; let mut items: Vec = Vec::new(); if let Ok(contacts) = self.store.list_contacts(None) { for ct in contacts { if let Ok(charts) = self.store.list_charts(ct.id) { for c in charts { items.push(ThumbnailItem { chart_id: c.id, label: SharedString::from(c.label), subtitle: Some(SharedString::from(format!( "{} · {:?}", ct.name, c.kind ))), preview: None, }); } } } } // Reusamos el scope Group con un id sentinela "vacío": // como GeneralRoot no es un Group real, dejamos que el // canvas pinte la grilla con el set de items y nada // más — el `scope` no se usa para nada que requiera // el id. self.canvas.update(cx, |c, cx| { c.set_mode( CanvasMode::Thumbnails { scope: ThumbnailScope::Group(Default::default()), items, }, cx, ); }); self.panel.update(cx, |p, cx| p.set_active_kind(None, cx)); } TreeSelection::PresentSky => { // Carta efímera del momento: birth_data = ahora en // Greenwich (UTC, lat=0, lon=0). Se construye al // vuelo, no se persiste — el id sintético es // `Default::default()`. Cada selección de PresentSky // recomputa contra el reloj actual. let chart = build_present_sky_chart(); self.current_chart = Some(chart); self.current_offset_minutes = 0; self.module_configs.clear(); self.panel .update(cx, |p, cx| p.set_active_kind(Some(ChartKind::Natal), cx)); self.sync_panel_from_configs(cx); self.render_current(cx); } } } /// Deriva los `PipelineRequest` activos a partir del `module_configs`. fn build_requests(&self) -> Vec { let mut requests = Vec::new(); if module_enabled(&self.module_configs, "transit") { requests.push(PipelineRequest::Transit); } if module_enabled(&self.module_configs, "progression") { let age = self.module_age_or_current("progression"); requests.push(PipelineRequest::SecondaryProgression { target_age_years: age, }); } if module_enabled(&self.module_configs, "solar_arc") { let age = self.module_age_or_current("solar_arc"); requests.push(PipelineRequest::SolarArc { target_age_years: age, }); } if module_enabled(&self.module_configs, "synastry") { if let Some(partner) = self.resolve_synastry_partner() { requests.push(PipelineRequest::Synastry { partner_chart: Box::new(partner), }); } } if module_enabled(&self.module_configs, "midpoints") { requests.push(PipelineRequest::Midpoints); } if module_enabled(&self.module_configs, "uranian") { requests.push(PipelineRequest::Uranian); } if module_enabled(&self.module_configs, "lots") { requests.push(PipelineRequest::Lots); } if module_enabled(&self.module_configs, "fixed_stars") { requests.push(PipelineRequest::FixedStars); } if module_enabled(&self.module_configs, "topocentric") { requests.push(PipelineRequest::Topocentric); } if module_enabled(&self.module_configs, "primary_directions") { let age = self.module_age_or_current("primary_directions"); let key = self .module_configs .get("primary_directions") .and_then(|c| c.get("key")) .and_then(|v| v.as_str()) .unwrap_or("naibod") .to_string(); requests.push(PipelineRequest::PrimaryDirections { target_age_years: age, key, }); } if module_enabled(&self.module_configs, "composite") { if let Some(partner) = self.resolve_composite_partner() { requests.push(PipelineRequest::Composite { partner_chart: Box::new(partner), }); } } if module_enabled(&self.module_configs, "planetary_return") { let age = self.module_age_or_current("planetary_return"); let body = self .module_configs .get("planetary_return") .and_then(|c| c.get("body")) .and_then(|v| v.as_str()) .unwrap_or("sun") .to_string(); let shift_days = self .module_configs .get("planetary_return") .and_then(|c| c.get("shift_days")) .and_then(|v| v.as_f64()) .map(|v| v as i64) .unwrap_or(0); requests.push(PipelineRequest::PlanetaryReturn { body, target_age_years: age, shift_days, }); } requests } /// Resuelve la carta partner para sinastría: 1) si el picker tiene /// un `partner_chart_id` válido en `module_configs`, lo usa; 2) /// si no, cae al automático (primera carta hermana del contacto /// actual). `None` si nada matchea — el request se salta. fn resolve_synastry_partner(&self) -> Option { let manual = self .module_configs .get("synastry") .and_then(|c| c.get("partner_chart_id")) .and_then(|v| v.as_str()) .and_then(|s| s.parse::().ok()) .and_then(|id| self.store.get_chart(id).ok()); manual.or_else(|| self.find_synastry_partner_auto()) } fn find_synastry_partner_auto(&self) -> Option { let current = self.current_chart.as_ref()?; let siblings = self.store.list_charts(current.contact_id).ok()?; siblings.into_iter().find(|c| c.id != current.id) } /// Resuelve el partner para Composite — mismo patrón que Synastry: /// 1) lee module_configs["composite"]["partner_chart_id"] y resuelve /// el chart; 2) fallback al primer hermano del contacto actual. fn resolve_composite_partner(&self) -> Option { let manual = self .module_configs .get("composite") .and_then(|c| c.get("partner_chart_id")) .and_then(|v| v.as_str()) .and_then(|s| s.parse::().ok()) .and_then(|id| self.store.get_chart(id).ok()); manual.or_else(|| self.find_synastry_partner_auto()) } /// Deriva las `NatalOptions` activas a partir del `module_configs["natal"]`. /// Si la entry no existe, devuelve defaults (majors=true, minors=false, /// multiplier=1.0). fn build_natal_options(&self) -> NatalOptions { let cfg = self.module_configs.get("natal"); let read_bool = |key: &str, default: bool| -> bool { cfg.and_then(|c| c.get(key)) .and_then(|v| v.as_bool()) .unwrap_or(default) }; let read_f64 = |key: &str, default: f64| -> f64 { cfg.and_then(|c| c.get(key)) .and_then(|v| v.as_f64()) .unwrap_or(default) }; NatalOptions { show_majors: read_bool("aspect_majors", true), show_minors: read_bool("aspect_minors", false), orb_multiplier: read_f64("orb_multiplier", 1.0), show_dignities: read_bool("show_dignities", false), } } /// Lee `module_state` desde SQLite para la carta dada y los mergea /// con los defaults ya cargados en `module_configs`. Los valores /// persistidos ganan sobre los defaults. fn load_persisted_module_states(&mut self, chart_id: ChartId) { let states = match self.store.list_module_states(chart_id) { Ok(s) => s, Err(e) => { eprintln!("[shell] list_module_states {}: {}", chart_id, e); return; } }; for st in states { // Re-mergeamos `enabled` (columna separada en SQL) dentro // del JSON config, así el resto del shell sigue leyendo // todo desde una única estructura. let mut combined = match st.config { serde_json::Value::Object(m) => serde_json::Value::Object(m), _ => serde_json::json!({}), }; if let serde_json::Value::Object(map) = &mut combined { map.insert("enabled".into(), serde_json::Value::Bool(st.enabled)); } // Mergear sobre defaults previos (no sobreescribir si la // entrada nueva no trae un campo). match self.module_configs.entry(st.module_id) { std::collections::hash_map::Entry::Vacant(v) => { v.insert(combined); } std::collections::hash_map::Entry::Occupied(mut o) => { if let (serde_json::Value::Object(dst), serde_json::Value::Object(src)) = (o.get_mut(), &combined) { for (k, v) in src { dst.insert(k.clone(), v.clone()); } } else { o.insert(combined); } } } } } /// Pushea cada toggle/slider/picker del `module_configs` al panel /// para que la UI refleje el estado persistido al cargar una carta. fn sync_panel_from_configs(&mut self, cx: &mut Context) { let snapshot: Vec<(String, serde_json::Value)> = self .module_configs .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(); self.panel.update(cx, |p, cx| { for (module_id, config) in &snapshot { if let serde_json::Value::Object(map) = config { for (key, value) in map { if let Some(b) = value.as_bool() { p.set_toggle(module_id, key, b, cx); } else if let Some(f) = value.as_f64() { p.set_slider(module_id, key, f, cx); } else if let Some(s) = value.as_str() { p.set_string(module_id, key, Some(s.to_string()), cx); } else if value.is_null() { p.set_string(module_id, key, None, cx); } } } } }); } /// Persiste el estado actual de un módulo a SQLite. Extrae /// `enabled` del JSON y lo guarda en la columna dedicada; el resto /// va al `config_json`. fn persist_module(&self, module_id: &str) { let Some(chart) = self.current_chart.as_ref() else { return; }; let Some(config) = self.module_configs.get(module_id) else { return; }; let enabled = config .get("enabled") .and_then(|v| v.as_bool()) .unwrap_or(false); let mut clean = config.clone(); if let serde_json::Value::Object(map) = &mut clean { map.remove("enabled"); } let state = ModuleState { chart_id: chart.id, module_id: module_id.to_string(), enabled, config: clean, }; if let Err(e) = self.store.upsert_module_state(&state) { eprintln!("[shell] upsert_module_state {}: {}", module_id, e); } } /// Lee `target_age_years` del módulo o cae a la edad actual del /// sujeto (calculada desde la fecha de nacimiento y el reloj). fn module_age_or_current(&self, module_id: &str) -> f64 { self.module_configs .get(module_id) .and_then(|c| c.get("target_age_years")) .and_then(|v| v.as_f64()) .unwrap_or_else(|| { self.current_chart .as_ref() .map(|c| current_age_years(&c.birth_data)) .unwrap_or(0.0) }) } fn render_current(&mut self, cx: &mut Context) { let Some(chart) = self.current_chart.as_ref() else { return; }; // Snapshot de inputs para mover al background. La sesión // VSOP2013 vive en un static `OnceLock` adentro del bridge, así // que es compartible read-only entre threads sin que ningún // dato cruce más allá del Chart clonado + requests/options. let chart = chart.clone(); let offset = self.current_offset_minutes; let requests = self.build_requests(); let natal_options = self.build_natal_options(); self.render_seq = self.render_seq.wrapping_add(1); let my_seq = self.render_seq; cx.spawn(async move |this, cx| { // El compute corre en el background_executor — no bloquea // el UI thread. Para una rueda completa con varios overlays // puede tomar 100-200ms; sin esto, los drags del slider se // sentirían atorados. let chart_for_bg = chart.clone(); let requests_for_bg = requests.clone(); let opts_for_bg = natal_options.clone(); let result = cx .background_executor() .spawn(async move { compose_with_options(&chart_for_bg, offset, &requests_for_bg, &opts_for_bg) }) .await; let _ = this.update(cx, |this, cx| { // Descartar si llegó un render más nuevo en el medio. // Sin este check, durante un drag rápido un compute // viejo podría sobrescribir el más reciente. if this.render_seq != my_seq { return; } match result { Ok(render) => { this.canvas.update(cx, |c, cx| { c.set_mode( CanvasMode::Wheel { render: Box::new(render), }, cx, ); }); } Err(e) => { eprintln!( "[shell] compose {} (+{}min, {} reqs): {}", chart.id, offset, requests.len(), e ); } } }); }) .detach(); } fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context) { match ev { CanvasEvent::TimeOffsetChanged(off) => { self.current_offset_minutes = *off; if self.current_chart.is_some() { self.render_current(cx); } } CanvasEvent::LayerVisibilityChanged { kind, visible } => { // El toggle de Outer ([T]) no es visibility puro: dispara // un pipeline distinto. Lo traducimos a un cambio en // module_configs["transit"]["enabled"] + re-render. if matches!(kind, LayerKind::Outer) { set_module_enabled(&mut self.module_configs, "transit", *visible); self.persist_module("transit"); self.panel.update(cx, |p, cx| { p.set_toggle("transit", "enabled", *visible, cx) }); self.render_current(cx); return; } // El resto son visibility puros sobre el canvas. Sync el // panel para que el toggle visual coincida con la hotkey. let key = match kind { LayerKind::SignDial => "show_sign_dial", LayerKind::Houses => "show_houses", LayerKind::Aspects => "show_aspects", LayerKind::Bodies => "show_bodies", _ => return, }; self.panel .update(cx, |p, cx| p.set_toggle("natal", key, *visible, cx)); } CanvasEvent::ShowCoordsChanged(visible) => { // Sync el toggle del panel para que coincida con la // hotkey C. No persist — los coord labels son una // preferencia visual, no parte del module_state. self.panel.update(cx, |p, cx| { p.set_toggle("natal", "show_coords", *visible, cx) }); } CanvasEvent::ChartRequested(_) => { // Fase 7: doble click sobre un thumbnail abre la carta. } CanvasEvent::ExportSvgRequested => { self.export_current_to_svg(); } } } /// Recompone la carta actual + escribe el SVG a un archivo en /// `$XDG_DATA_HOME/tahuantinsuyu/exports/