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