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