⎇ a539fab15ca0d46a1b6263c7af91303907802575
crates/apps/tahuantinsuyu/src/shell.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(tahuantinsuyu): fase 24 — observabilidad del broker brahman
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, "composite") {
387 if let Some(partner) = self.resolve_composite_partner() {
388 requests.push(PipelineRequest::Composite {
389 partner_chart: Box::new(partner),
390 });
391 }
392 }
393 if module_enabled(&self.module_configs, "planetary_return") {
394 let age = self.module_age_or_current("planetary_return");
395 let body = self
396 .module_configs
397 .get("planetary_return")
398 .and_then(|c| c.get("body"))
399 .and_then(|v| v.as_str())
400 .unwrap_or("sun")
401 .to_string();
402 let shift_days = self
403 .module_configs
404 .get("planetary_return")
405 .and_then(|c| c.get("shift_days"))
406 .and_then(|v| v.as_f64())
407 .map(|v| v as i64)
408 .unwrap_or(0);
409 requests.push(PipelineRequest::PlanetaryReturn {
410 body,
411 target_age_years: age,
412 shift_days,
413 });
414 }
415 requests
416 }
417
418 /// Resuelve la carta partner para sinastría: 1) si el picker tiene
419 /// un `partner_chart_id` válido en `module_configs`, lo usa; 2)
420 /// si no, cae al automático (primera carta hermana del contacto
421 /// actual). `None` si nada matchea — el request se salta.
422 fn resolve_synastry_partner(&self) -> Option<Chart> {
423 let manual = self
424 .module_configs
425 .get("synastry")
426 .and_then(|c| c.get("partner_chart_id"))
427 .and_then(|v| v.as_str())
428 .and_then(|s| s.parse::<tahuantinsuyu_model::ChartId>().ok())
429 .and_then(|id| self.store.get_chart(id).ok());
430 manual.or_else(|| self.find_synastry_partner_auto())
431 }
432
433 fn find_synastry_partner_auto(&self) -> Option<Chart> {
434 let current = self.current_chart.as_ref()?;
435 let siblings = self.store.list_charts(current.contact_id).ok()?;
436 siblings.into_iter().find(|c| c.id != current.id)
437 }
438
439 /// Resuelve el partner para Composite — mismo patrón que Synastry:
440 /// 1) lee module_configs["composite"]["partner_chart_id"] y resuelve
441 /// el chart; 2) fallback al primer hermano del contacto actual.
442 fn resolve_composite_partner(&self) -> Option<Chart> {
443 let manual = self
444 .module_configs
445 .get("composite")
446 .and_then(|c| c.get("partner_chart_id"))
447 .and_then(|v| v.as_str())
448 .and_then(|s| s.parse::<tahuantinsuyu_model::ChartId>().ok())
449 .and_then(|id| self.store.get_chart(id).ok());
450 manual.or_else(|| self.find_synastry_partner_auto())
451 }
452
453 /// Deriva las `NatalOptions` activas a partir del `module_configs["natal"]`.
454 /// Si la entry no existe, devuelve defaults (majors=true, minors=false,
455 /// multiplier=1.0).
456 fn build_natal_options(&self) -> NatalOptions {
457 let cfg = self.module_configs.get("natal");
458 let read_bool = |key: &str, default: bool| -> bool {
459 cfg.and_then(|c| c.get(key))
460 .and_then(|v| v.as_bool())
461 .unwrap_or(default)
462 };
463 let read_f64 = |key: &str, default: f64| -> f64 {
464 cfg.and_then(|c| c.get(key))
465 .and_then(|v| v.as_f64())
466 .unwrap_or(default)
467 };
468 NatalOptions {
469 show_majors: read_bool("aspect_majors", true),
470 show_minors: read_bool("aspect_minors", false),
471 orb_multiplier: read_f64("orb_multiplier", 1.0),
472 show_dignities: read_bool("show_dignities", false),
473 }
474 }
475
476 /// Lee `module_state` desde SQLite para la carta dada y los mergea
477 /// con los defaults ya cargados en `module_configs`. Los valores
478 /// persistidos ganan sobre los defaults.
479 fn load_persisted_module_states(&mut self, chart_id: ChartId) {
480 let states = match self.store.list_module_states(chart_id) {
481 Ok(s) => s,
482 Err(e) => {
483 eprintln!("[shell] list_module_states {}: {}", chart_id, e);
484 return;
485 }
486 };
487 for st in states {
488 // Re-mergeamos `enabled` (columna separada en SQL) dentro
489 // del JSON config, así el resto del shell sigue leyendo
490 // todo desde una única estructura.
491 let mut combined = match st.config {
492 serde_json::Value::Object(m) => serde_json::Value::Object(m),
493 _ => serde_json::json!({}),
494 };
495 if let serde_json::Value::Object(map) = &mut combined {
496 map.insert("enabled".into(), serde_json::Value::Bool(st.enabled));
497 }
498 // Mergear sobre defaults previos (no sobreescribir si la
499 // entrada nueva no trae un campo).
500 match self.module_configs.entry(st.module_id) {
501 std::collections::hash_map::Entry::Vacant(v) => {
502 v.insert(combined);
503 }
504 std::collections::hash_map::Entry::Occupied(mut o) => {
505 if let (serde_json::Value::Object(dst), serde_json::Value::Object(src)) =
506 (o.get_mut(), &combined)
507 {
508 for (k, v) in src {
509 dst.insert(k.clone(), v.clone());
510 }
511 } else {
512 o.insert(combined);
513 }
514 }
515 }
516 }
517 }
518
519 /// Pushea cada toggle/slider/picker del `module_configs` al panel
520 /// para que la UI refleje el estado persistido al cargar una carta.
521 fn sync_panel_from_configs(&mut self, cx: &mut Context<Self>) {
522 let snapshot: Vec<(String, serde_json::Value)> = self
523 .module_configs
524 .iter()
525 .map(|(k, v)| (k.clone(), v.clone()))
526 .collect();
527 self.panel.update(cx, |p, cx| {
528 for (module_id, config) in &snapshot {
529 if let serde_json::Value::Object(map) = config {
530 for (key, value) in map {
531 if let Some(b) = value.as_bool() {
532 p.set_toggle(module_id, key, b, cx);
533 } else if let Some(f) = value.as_f64() {
534 p.set_slider(module_id, key, f, cx);
535 } else if let Some(s) = value.as_str() {
536 p.set_string(module_id, key, Some(s.to_string()), cx);
537 } else if value.is_null() {
538 p.set_string(module_id, key, None, cx);
539 }
540 }
541 }
542 }
543 });
544 }
545
546 /// Persiste el estado actual de un módulo a SQLite. Extrae
547 /// `enabled` del JSON y lo guarda en la columna dedicada; el resto
548 /// va al `config_json`.
549 fn persist_module(&self, module_id: &str) {
550 let Some(chart) = self.current_chart.as_ref() else {
551 return;
552 };
553 let Some(config) = self.module_configs.get(module_id) else {
554 return;
555 };
556 let enabled = config
557 .get("enabled")
558 .and_then(|v| v.as_bool())
559 .unwrap_or(false);
560 let mut clean = config.clone();
561 if let serde_json::Value::Object(map) = &mut clean {
562 map.remove("enabled");
563 }
564 let state = ModuleState {
565 chart_id: chart.id,
566 module_id: module_id.to_string(),
567 enabled,
568 config: clean,
569 };
570 if let Err(e) = self.store.upsert_module_state(&state) {
571 eprintln!("[shell] upsert_module_state {}: {}", module_id, e);
572 }
573 }
574
575 /// Lee `target_age_years` del módulo o cae a la edad actual del
576 /// sujeto (calculada desde la fecha de nacimiento y el reloj).
577 fn module_age_or_current(&self, module_id: &str) -> f64 {
578 self.module_configs
579 .get(module_id)
580 .and_then(|c| c.get("target_age_years"))
581 .and_then(|v| v.as_f64())
582 .unwrap_or_else(|| {
583 self.current_chart
584 .as_ref()
585 .map(|c| current_age_years(&c.birth_data))
586 .unwrap_or(0.0)
587 })
588 }
589
590 fn render_current(&mut self, cx: &mut Context<Self>) {
591 let Some(chart) = self.current_chart.as_ref() else {
592 return;
593 };
594 // Snapshot de inputs para mover al background. La sesión
595 // VSOP2013 vive en un static `OnceLock` adentro del bridge, así
596 // que es compartible read-only entre threads sin que ningún
597 // dato cruce más allá del Chart clonado + requests/options.
598 let chart = chart.clone();
599 let offset = self.current_offset_minutes;
600 let requests = self.build_requests();
601 let natal_options = self.build_natal_options();
602 self.render_seq = self.render_seq.wrapping_add(1);
603 let my_seq = self.render_seq;
604
605 cx.spawn(async move |this, cx| {
606 // El compute corre en el background_executor — no bloquea
607 // el UI thread. Para una rueda completa con varios overlays
608 // puede tomar 100-200ms; sin esto, los drags del slider se
609 // sentirían atorados.
610 let chart_for_bg = chart.clone();
611 let requests_for_bg = requests.clone();
612 let opts_for_bg = natal_options.clone();
613 let result = cx
614 .background_executor()
615 .spawn(async move {
616 compose_with_options(&chart_for_bg, offset, &requests_for_bg, &opts_for_bg)
617 })
618 .await;
619
620 let _ = this.update(cx, |this, cx| {
621 // Descartar si llegó un render más nuevo en el medio.
622 // Sin este check, durante un drag rápido un compute
623 // viejo podría sobrescribir el más reciente.
624 if this.render_seq != my_seq {
625 return;
626 }
627 match result {
628 Ok(render) => {
629 this.canvas.update(cx, |c, cx| {
630 c.set_mode(
631 CanvasMode::Wheel {
632 render: Box::new(render),
633 },
634 cx,
635 );
636 });
637 }
638 Err(e) => {
639 eprintln!(
640 "[shell] compose {} (+{}min, {} reqs): {}",
641 chart.id,
642 offset,
643 requests.len(),
644 e
645 );
646 }
647 }
648 });
649 })
650 .detach();
651 }
652
653 fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context<Self>) {
654 match ev {
655 CanvasEvent::TimeOffsetChanged(off) => {
656 self.current_offset_minutes = *off;
657 if self.current_chart.is_some() {
658 self.render_current(cx);
659 }
660 }
661 CanvasEvent::LayerVisibilityChanged { kind, visible } => {
662 // El toggle de Outer ([T]) no es visibility puro: dispara
663 // un pipeline distinto. Lo traducimos a un cambio en
664 // module_configs["transit"]["enabled"] + re-render.
665 if matches!(kind, LayerKind::Outer) {
666 set_module_enabled(&mut self.module_configs, "transit", *visible);
667 self.persist_module("transit");
668 self.panel.update(cx, |p, cx| {
669 p.set_toggle("transit", "enabled", *visible, cx)
670 });
671 self.render_current(cx);
672 return;
673 }
674 // El resto son visibility puros sobre el canvas. Sync el
675 // panel para que el toggle visual coincida con la hotkey.
676 let key = match kind {
677 LayerKind::SignDial => "show_sign_dial",
678 LayerKind::Houses => "show_houses",
679 LayerKind::Aspects => "show_aspects",
680 LayerKind::Bodies => "show_bodies",
681 _ => return,
682 };
683 self.panel
684 .update(cx, |p, cx| p.set_toggle("natal", key, *visible, cx));
685 }
686 CanvasEvent::ChartRequested(_) => {
687 // Fase 7: doble click sobre un thumbnail abre la carta.
688 }
689 CanvasEvent::ExportSvgRequested => {
690 self.export_current_to_svg();
691 }
692 }
693 }
694
695 /// Recompone la carta actual + escribe el SVG a un archivo en
696 /// `$XDG_DATA_HOME/tahuantinsuyu/exports/<label>_<short_id>.svg`.
697 /// Logea la ruta a stderr — futuro: file save dialog GPUI.
698 fn export_current_to_svg(&self) {
699 let Some(chart) = self.current_chart.as_ref() else {
700 eprintln!("[shell] export svg: sin carta activa");
701 return;
702 };
703 let requests = self.build_requests();
704 let natal_options = self.build_natal_options();
705 let render = match compose_with_options(
706 chart,
707 self.current_offset_minutes,
708 &requests,
709 &natal_options,
710 ) {
711 Ok(r) => r,
712 Err(e) => {
713 eprintln!("[shell] export svg compose: {}", e);
714 return;
715 }
716 };
717 let svg = svg_export::render_to_svg(&render);
718 let dir = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu")
719 .map(|d| d.data_dir().join("exports"))
720 .unwrap_or_else(|| std::path::PathBuf::from("."));
721 if let Err(e) = std::fs::create_dir_all(&dir) {
722 eprintln!("[shell] mkdir {:?}: {}", dir, e);
723 return;
724 }
725 let safe_label: String = chart
726 .label
727 .chars()
728 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
729 .collect();
730 let short = format!("{}", chart.id).chars().take(8).collect::<String>();
731 let path = dir.join(format!("{}_{}.svg", safe_label, short));
732 if let Err(e) = std::fs::write(&path, svg) {
733 eprintln!("[shell] write {:?}: {}", path, e);
734 } else {
735 eprintln!("[shell] SVG exportado → {}", path.display());
736 }
737 }
738
739 fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
740 match ev {
741 PanelEvent::ControlChanged {
742 module_id, key, value,
743 } => {
744 let bool_val = value.as_bool().unwrap_or(true);
745 if module_id == "natal" {
746 // Distinguimos: show_* = visibility (no recompose),
747 // aspect_*/orb_* = filtros de engine (recompose +
748 // persist).
749 let kind = match key.as_str() {
750 "show_sign_dial" => Some(LayerKind::SignDial),
751 "show_houses" => Some(LayerKind::Houses),
752 "show_aspects" => Some(LayerKind::Aspects),
753 "show_bodies" => Some(LayerKind::Bodies),
754 _ => None,
755 };
756 if let Some(k) = kind {
757 self.canvas
758 .update(cx, |c, cx| c.set_layer_visible(k, bool_val, cx));
759 } else {
760 // Filtros: actualizar module_configs + recompose.
761 let entry = self
762 .module_configs
763 .entry("natal".into())
764 .or_insert_with(|| serde_json::json!({}));
765 if let serde_json::Value::Object(map) = entry {
766 map.insert(key.clone(), value.clone());
767 }
768 self.persist_module("natal");
769 self.render_current(cx);
770 }
771 } else {
772 // Cualquier otro módulo: actualizamos su config y
773 // recompomemos. La engine vuelve a llamarse con el
774 // PipelineRequest derivado del nuevo estado.
775 let entry = self
776 .module_configs
777 .entry(module_id.clone())
778 .or_insert_with(|| serde_json::json!({}));
779 if let serde_json::Value::Object(map) = entry {
780 map.insert(key.clone(), value.clone());
781 }
782 // Transit, Synastry y Solar Return comparten el
783 // outer ring del canvas — son mutuamente excluyentes.
784 // Al prender uno, apagamos los otros + sync panel +
785 // persist.
786 if key == "enabled" && bool_val && OUTER_RING_MODULES.contains(&module_id.as_str()) {
787 for &other in OUTER_RING_MODULES.iter() {
788 if other != module_id && module_enabled(&self.module_configs, other) {
789 set_module_enabled(&mut self.module_configs, other, false);
790 let other_str = other.to_string();
791 self.panel.update(cx, |p, cx| {
792 p.set_toggle(&other_str, "enabled", false, cx)
793 });
794 self.persist_module(&other_str);
795 }
796 }
797 }
798 // Sincronizar visualmente el toggle [T] del canvas
799 // cuando el cambio afecta el outer ring (transit,
800 // synastry o solar_return).
801 if OUTER_RING_MODULES.contains(&module_id.as_str()) && key == "enabled" {
802 self.canvas.update(cx, |c, cx| {
803 c.set_layer_visible(LayerKind::Outer, bool_val, cx)
804 });
805 }
806 self.persist_module(module_id);
807 self.render_current(cx);
808 }
809 }
810 PanelEvent::ModuleToggled { .. } => {
811 // Fase 7: encender/apagar módulos enteros desde un
812 // header con switch (vs. el toggle por-control de hoy).
813 }
814 }
815 let _ = (&self.store, &self.tree, &self.bus);
816 }
817}
818
819// =====================================================================
820// Helpers de module_configs
821// =====================================================================
822
823// OUTER_RING_MODULES viene de tahuantinsuyu_engine — single source of
824// truth. Shell y canvas leen del mismo slice.
825
826
827/// Lee `$XDG_DATA_HOME/tahuantinsuyu/atlas.tsv` si existe y lo parsea
828/// como atlas de ciudades. Devuelve `None` cuando no hay archivo o
829/// quedó vacío después del parse — el tree cae al atlas hardcoded.
830fn load_city_atlas_from_xdg() -> Option<Vec<tahuantinsuyu_tree::CityPreset>> {
831 let path = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu")
832 .map(|d| d.data_dir().join("atlas.tsv"))?;
833 if !path.exists() {
834 return None;
835 }
836 let content = std::fs::read_to_string(&path).ok()?;
837 let atlas = parse_city_atlas_tsv(&content);
838 if atlas.is_empty() {
839 eprintln!(
840 "[shell] atlas.tsv encontrado en {:?} pero sin filas válidas — fallback a hardcoded",
841 path
842 );
843 return None;
844 }
845 eprintln!("[shell] atlas custom cargado: {} ciudades", atlas.len());
846 Some(atlas)
847}
848
849/// Etiqueta breve para mostrar al elegir una carta en el picker:
850/// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
851fn format_birth_brief(birth: &tahuantinsuyu_model::StoredBirthData) -> String {
852 let date = format!("{:04}-{:02}-{:02}", birth.year, birth.month, birth.day);
853 match &birth.birthplace_label {
854 Some(p) if !p.is_empty() => format!("{} · {}", date, p),
855 _ => date,
856 }
857}
858
859/// Edad en años decimales desde el nacimiento hasta el reloj actual.
860/// Aproximación: ignora la TZ de nacimiento (no afecta a resolución de
861/// año) y usa una fracción de año tropical sobre los segundos Unix.
862fn current_age_years(birth: &tahuantinsuyu_model::StoredBirthData) -> f64 {
863 use std::time::{SystemTime, UNIX_EPOCH};
864 let now_secs = SystemTime::now()
865 .duration_since(UNIX_EPOCH)
866 .map(|d| d.as_secs_f64())
867 .unwrap_or(0.0);
868 let birth_year_frac = birth.year as f64
869 + (birth.month.saturating_sub(1) as f64) / 12.0
870 + (birth.day.saturating_sub(1) as f64) / 365.25;
871 let now_year_frac = 1970.0 + now_secs / (365.2422 * 86400.0);
872 (now_year_frac - birth_year_frac).max(0.0)
873}
874
875fn module_enabled(cfgs: &HashMap<String, serde_json::Value>, id: &str) -> bool {
876 cfgs.get(id)
877 .and_then(|c| c.get("enabled"))
878 .and_then(|v| v.as_bool())
879 .unwrap_or(false)
880}
881
882fn set_module_enabled(
883 cfgs: &mut HashMap<String, serde_json::Value>,
884 id: &str,
885 enabled: bool,
886) {
887 let entry = cfgs
888 .entry(id.to_string())
889 .or_insert_with(|| serde_json::json!({}));
890 if let serde_json::Value::Object(map) = entry {
891 map.insert("enabled".into(), serde_json::Value::Bool(enabled));
892 }
893}
894
895impl Render for Shell {
896 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
897 let theme = Theme::global(cx).clone();
898
899 // Badge del estado del broker brahman — pequeña pill con
900 // color según el estado actual del ping cada-30s.
901 let (badge_text, badge_color) = match &self.brahman_status {
902 BrahmanStatus::Pending => ("Brahman · …".to_string(), theme.fg_muted),
903 BrahmanStatus::Connected { session_count } => (
904 format!("Brahman ✓ {} sessions", session_count),
905 theme.accent,
906 ),
907 BrahmanStatus::Offline { .. } => {
908 ("Brahman · offline".to_string(), theme.fg_disabled)
909 }
910 };
911 let brahman_badge = div()
912 .px(px(8.0))
913 .py(px(2.0))
914 .rounded(px(8.0))
915 .bg(theme.bg_panel_alt.clone())
916 .border_1()
917 .border_color(theme.border)
918 .text_size(px(10.0))
919 .text_color(badge_color)
920 .child(SharedString::from(badge_text));
921
922 let header = div()
923 .h(px(34.0))
924 .px(px(12.0))
925 .flex()
926 .flex_row()
927 .items_center()
928 .gap(px(10.0))
929 .border_b_1()
930 .border_color(theme.border)
931 .child(
932 div()
933 .text_size(px(13.0))
934 .text_color(theme.fg_text)
935 .child("☉ Tahuantinsuyu"),
936 )
937 .child(
938 div()
939 .text_size(px(10.0))
940 .text_color(theme.fg_muted)
941 .child("estudio de astrología profesional"),
942 )
943 .child(div().flex_grow())
944 .child(brahman_badge)
945 .child(theme_switcher(cx));
946
947 let body = div()
948 .flex_grow()
949 .w_full()
950 .child(self.outer_split.clone());
951
952 div()
953 .size_full()
954 .bg(theme.bg_app.clone())
955 .flex()
956 .flex_col()
957 .child(header)
958 .child(body)
959 }
960}