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