//! 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::{ 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, ModuleState, 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; /// 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, /// El árbol vive como child de `outer_split` (vía AnyView clone), /// pero retenemos el Entity acá para que las subscripciones /// registradas en `new` sigan vivas — al droppear el último handle, /// gpui cancela los suscriptores. #[allow(dead_code)] tree: Entity, canvas: Entity, panel: Entity, /// Splitter vertical entre el main_row (arriba — tree + canvas) y /// el panel de control (abajo). El splitter horizontal interno se /// arma en `new` y queda referenciado vía `outer_split` (es uno de /// sus children), sin necesidad de retenerlo aparte. outer_split: Entity, /// Ú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(); // Splitter horizontal: tree + canvas. Defaults (1.0, 4.0) salvo // que tengamos un flex persistido en `settings`. let (main_left, main_right) = load_split_flex(&store, "layout.main_split", 1.0, 4.0); let main_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Horizontal, cx)); main_split.update(cx, |sc, cx| { sc.set_children( vec![ ChildSlot { id: NodeId::new("tts-tree"), flex: main_left, label: None, view: gpui::AnyView::from(tree.clone()), }, ChildSlot { id: NodeId::new("tts-canvas"), flex: main_right, label: None, view: gpui::AnyView::from(canvas.clone()), }, ], cx, ); }); // Splitter vertical: main arriba, panel abajo. Defaults (4.0, 1.0). let (outer_top, outer_bottom) = load_split_flex(&store, "layout.outer_split", 4.0, 1.0); let outer_split = cx.new(|cx| { let mut sc = SplitContainer::new(LayoutDirection::Vertical, cx); sc.set_children( vec![ ChildSlot { id: NodeId::new("tts-main"), flex: outer_top, label: None, view: gpui::AnyView::from(main_split.clone()), }, ChildSlot { id: NodeId::new("tts-panel"), flex: outer_bottom, label: None, view: gpui::AnyView::from(panel.clone()), }, ], cx, ); sc }); // Persistir flex en `DragEnd`. Capturamos el store por valor // (Store es Clone — comparte el Arc>). let store_main = store.clone(); cx.subscribe(&main_split, move |_, sc, ev: &SplitEvent, cx| { if matches!(ev, SplitEvent::DragEnd) { save_split_flex(&store_main, "layout.main_split", sc.read(cx)); } }) .detach(); let store_outer = store.clone(); cx.subscribe(&outer_split, move |_, sc, ev: &SplitEvent, cx| { if matches!(ev, SplitEvent::DragEnd) { save_split_flex(&store_outer, "layout.outer_split", sc.read(cx)); } }) .detach(); let shell = Self { store, tree, canvas, panel, outer_split, brahman_status: BrahmanStatus::Pending, current_chart: None, current_offset_minutes: 0, module_configs: HashMap::new(), render_seq: 0, }; shell.refresh_chart_options(cx); shell.spawn_brahman_status_loop(cx); shell } /// 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)); } } } /// 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, "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::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/