⎇ a4d1e0dc17b6fba7522adc49e007557321778fa0
crates/apps/tahuantinsuyu/src/shell.rs
History
Who touched what
raw
feat(tahuantinsuyu): fase 27 — Lots helenísticos + 9 fixed stars
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}