⎇ cabdb2927ee6c466b9c338ccd75818f0c436d7f6
crates/apps/tahuantinsuyu/src/shell.rs
History
Who touched what
raw
feat(tahuantinsuyu): fase 14 — Return abstracto + Control::Select interactivo
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::{LayerKind, PipelineRequest, compose};
35use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection};
36use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
37use tahuantinsuyu_store::Store;
38use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
39use yahweh_bus::AppBus;
40use yahweh_theme::Theme;
41
42const TREE_WIDTH: f32 = 280.0;
43const PANEL_HEIGHT: f32 = 180.0;
44
45pub struct Shell {
46 store: Store,
47 #[allow(dead_code)]
48 bus: Entity<AppBus>,
49 tree: Entity<TahuantinsuyuTree>,
50 canvas: Entity<AstrologyCanvas>,
51 panel: Entity<ControlPanel>,
52 current_chart: Option<Chart>,
53 current_offset_minutes: i64,
54 /// Estado de los módulos overlay (transit, progression, …) por
55 /// `module_id`. Las claves dentro del JSON dependen del módulo (la
56 /// convención es `"enabled": bool` para el toggle principal).
57 module_configs: HashMap<String, serde_json::Value>,
58}
59
60impl Shell {
61 pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
62 cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
63
64 let bus = cx.new(|_| AppBus);
65 let tree = cx.new(|cx| TahuantinsuyuTree::new(store.clone(), cx));
66 let canvas = cx.new(AstrologyCanvas::new);
67 let panel = cx.new(ControlPanel::new);
68
69 cx.subscribe(&tree, |this: &mut Self, _, ev: &TreeEvent, cx| {
70 this.on_tree_event(ev, cx);
71 })
72 .detach();
73
74 cx.subscribe(&panel, |this: &mut Self, _, ev: &PanelEvent, cx| {
75 this.on_panel_event(ev, cx);
76 })
77 .detach();
78
79 cx.subscribe(&canvas, |this: &mut Self, _, ev: &CanvasEvent, cx| {
80 this.on_canvas_event(ev, cx);
81 })
82 .detach();
83
84 let mut shell = Self {
85 store,
86 bus,
87 tree,
88 canvas,
89 panel,
90 current_chart: None,
91 current_offset_minutes: 0,
92 module_configs: HashMap::new(),
93 };
94 shell.refresh_chart_options(cx);
95 shell
96 }
97
98 /// Recarga la lista de opciones para los `Control::ChartPicker` y
99 /// la pushea al panel. Llamado al boot + tras cada
100 /// `TreeEvent::HierarchyChanged`.
101 fn refresh_chart_options(&self, cx: &mut Context<Self>) {
102 let charts = self.store.list_all_charts().unwrap_or_default();
103 let options: Vec<ChartOption> = charts
104 .into_iter()
105 .map(|c| ChartOption {
106 id: c.id.to_string(),
107 label: format!("{} — {}", c.label, format_birth_brief(&c.birth_data)),
108 })
109 .collect();
110 self.panel
111 .update(cx, |p, cx| p.set_chart_options(options, cx));
112 }
113
114 fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) {
115 let selection = match ev {
116 TreeEvent::Selected(s) => s,
117 TreeEvent::Opened(s) => s,
118 TreeEvent::HierarchyChanged => {
119 // La jerarquía cambió (alta/baja de cartas) — refrescar
120 // las opciones del picker para que aparezcan / desaparezcan
121 // en el dropdown.
122 self.refresh_chart_options(cx);
123 cx.notify();
124 return;
125 }
126 };
127 self.apply_selection(selection.clone(), cx);
128 }
129
130 fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
131 match sel {
132 TreeSelection::Chart(id) => {
133 let chart = match self.store.get_chart(id) {
134 Ok(c) => c,
135 Err(e) => {
136 eprintln!("[shell] get_chart {}: {}", id, e);
137 return;
138 }
139 };
140 let age = current_age_years(&chart.birth_data);
141 self.current_chart = Some(chart.clone());
142 self.current_offset_minutes = 0;
143 // 1) Defaults frescos para esta carta: edad objetivo =
144 // edad actual. Estos quedan en module_configs como
145 // valor base si el usuario nunca tocó el slider.
146 self.module_configs.clear();
147 for module_id in ["progression", "solar_arc", "planetary_return"] {
148 let entry = self
149 .module_configs
150 .entry(module_id.into())
151 .or_insert_with(|| serde_json::json!({}));
152 if let serde_json::Value::Object(map) = entry {
153 map.insert("target_age_years".into(), serde_json::json!(age));
154 }
155 }
156 // El módulo planetary_return además necesita un body
157 // por default — el shell elige "sun" si el usuario no
158 // tocó el Select. La persistencia luego puede pisar
159 // este valor.
160 if let Some(serde_json::Value::Object(map)) =
161 self.module_configs.get_mut("planetary_return")
162 {
163 map.entry(String::from("body"))
164 .or_insert(serde_json::json!("sun"));
165 }
166 // 2) Sobreescribir con lo que el usuario persistió la
167 // última vez para esta carta (SQLite `module_state`).
168 self.load_persisted_module_states(chart.id);
169 // 3) Sincronizar panel: active_kind + toggles/sliders.
170 self.panel.update(cx, |p, cx| {
171 p.set_active_kind(Some(chart.kind), cx);
172 });
173 self.sync_panel_from_configs(cx);
174 self.render_current(cx);
175 }
176 TreeSelection::Contact(id) => {
177 self.current_chart = None;
178 self.current_offset_minutes = 0;
179 let charts = self.store.list_charts(id).unwrap_or_default();
180 let items: Vec<ThumbnailItem> = charts
181 .into_iter()
182 .map(|c| ThumbnailItem {
183 chart_id: c.id,
184 label: SharedString::from(c.label),
185 subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
186 preview: None,
187 })
188 .collect();
189 self.canvas.update(cx, |c, cx| {
190 c.set_mode(
191 CanvasMode::Thumbnails {
192 scope: ThumbnailScope::Contact(id),
193 items,
194 },
195 cx,
196 );
197 });
198 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
199 }
200 TreeSelection::Group(id) => {
201 self.current_chart = None;
202 self.current_offset_minutes = 0;
203 let charts = self.store.charts_under_group(id).unwrap_or_default();
204 let items: Vec<ThumbnailItem> = charts
205 .into_iter()
206 .map(|c| ThumbnailItem {
207 chart_id: c.id,
208 label: SharedString::from(c.label),
209 subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
210 preview: None,
211 })
212 .collect();
213 self.canvas.update(cx, |c, cx| {
214 c.set_mode(
215 CanvasMode::Thumbnails {
216 scope: ThumbnailScope::Group(id),
217 items,
218 },
219 cx,
220 );
221 });
222 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
223 }
224 }
225 }
226
227 /// Deriva los `PipelineRequest` activos a partir del `module_configs`.
228 fn build_requests(&self) -> Vec<PipelineRequest> {
229 let mut requests = Vec::new();
230 if module_enabled(&self.module_configs, "transit") {
231 requests.push(PipelineRequest::Transit);
232 }
233 if module_enabled(&self.module_configs, "progression") {
234 let age = self.module_age_or_current("progression");
235 requests.push(PipelineRequest::SecondaryProgression {
236 target_age_years: age,
237 });
238 }
239 if module_enabled(&self.module_configs, "solar_arc") {
240 let age = self.module_age_or_current("solar_arc");
241 requests.push(PipelineRequest::SolarArc {
242 target_age_years: age,
243 });
244 }
245 if module_enabled(&self.module_configs, "synastry") {
246 if let Some(partner) = self.resolve_synastry_partner() {
247 requests.push(PipelineRequest::Synastry {
248 partner_chart: Box::new(partner),
249 });
250 }
251 }
252 if module_enabled(&self.module_configs, "planetary_return") {
253 let age = self.module_age_or_current("planetary_return");
254 let body = self
255 .module_configs
256 .get("planetary_return")
257 .and_then(|c| c.get("body"))
258 .and_then(|v| v.as_str())
259 .unwrap_or("sun")
260 .to_string();
261 requests.push(PipelineRequest::PlanetaryReturn {
262 body,
263 target_age_years: age,
264 });
265 }
266 requests
267 }
268
269 /// Resuelve la carta partner para sinastría: 1) si el picker tiene
270 /// un `partner_chart_id` válido en `module_configs`, lo usa; 2)
271 /// si no, cae al automático (primera carta hermana del contacto
272 /// actual). `None` si nada matchea — el request se salta.
273 fn resolve_synastry_partner(&self) -> Option<Chart> {
274 let manual = self
275 .module_configs
276 .get("synastry")
277 .and_then(|c| c.get("partner_chart_id"))
278 .and_then(|v| v.as_str())
279 .and_then(|s| s.parse::<tahuantinsuyu_model::ChartId>().ok())
280 .and_then(|id| self.store.get_chart(id).ok());
281 manual.or_else(|| self.find_synastry_partner_auto())
282 }
283
284 fn find_synastry_partner_auto(&self) -> Option<Chart> {
285 let current = self.current_chart.as_ref()?;
286 let siblings = self.store.list_charts(current.contact_id).ok()?;
287 siblings.into_iter().find(|c| c.id != current.id)
288 }
289
290 /// Lee `module_state` desde SQLite para la carta dada y los mergea
291 /// con los defaults ya cargados en `module_configs`. Los valores
292 /// persistidos ganan sobre los defaults.
293 fn load_persisted_module_states(&mut self, chart_id: ChartId) {
294 let states = match self.store.list_module_states(chart_id) {
295 Ok(s) => s,
296 Err(e) => {
297 eprintln!("[shell] list_module_states {}: {}", chart_id, e);
298 return;
299 }
300 };
301 for st in states {
302 // Re-mergeamos `enabled` (columna separada en SQL) dentro
303 // del JSON config, así el resto del shell sigue leyendo
304 // todo desde una única estructura.
305 let mut combined = match st.config {
306 serde_json::Value::Object(m) => serde_json::Value::Object(m),
307 _ => serde_json::json!({}),
308 };
309 if let serde_json::Value::Object(map) = &mut combined {
310 map.insert("enabled".into(), serde_json::Value::Bool(st.enabled));
311 }
312 // Mergear sobre defaults previos (no sobreescribir si la
313 // entrada nueva no trae un campo).
314 match self.module_configs.entry(st.module_id) {
315 std::collections::hash_map::Entry::Vacant(v) => {
316 v.insert(combined);
317 }
318 std::collections::hash_map::Entry::Occupied(mut o) => {
319 if let (serde_json::Value::Object(dst), serde_json::Value::Object(src)) =
320 (o.get_mut(), &combined)
321 {
322 for (k, v) in src {
323 dst.insert(k.clone(), v.clone());
324 }
325 } else {
326 o.insert(combined);
327 }
328 }
329 }
330 }
331 }
332
333 /// Pushea cada toggle/slider/picker del `module_configs` al panel
334 /// para que la UI refleje el estado persistido al cargar una carta.
335 fn sync_panel_from_configs(&mut self, cx: &mut Context<Self>) {
336 let snapshot: Vec<(String, serde_json::Value)> = self
337 .module_configs
338 .iter()
339 .map(|(k, v)| (k.clone(), v.clone()))
340 .collect();
341 self.panel.update(cx, |p, cx| {
342 for (module_id, config) in &snapshot {
343 if let serde_json::Value::Object(map) = config {
344 for (key, value) in map {
345 if let Some(b) = value.as_bool() {
346 p.set_toggle(module_id, key, b, cx);
347 } else if let Some(f) = value.as_f64() {
348 p.set_slider(module_id, key, f, cx);
349 } else if let Some(s) = value.as_str() {
350 p.set_string(module_id, key, Some(s.to_string()), cx);
351 } else if value.is_null() {
352 p.set_string(module_id, key, None, cx);
353 }
354 }
355 }
356 }
357 });
358 }
359
360 /// Persiste el estado actual de un módulo a SQLite. Extrae
361 /// `enabled` del JSON y lo guarda en la columna dedicada; el resto
362 /// va al `config_json`.
363 fn persist_module(&self, module_id: &str) {
364 let Some(chart) = self.current_chart.as_ref() else {
365 return;
366 };
367 let Some(config) = self.module_configs.get(module_id) else {
368 return;
369 };
370 let enabled = config
371 .get("enabled")
372 .and_then(|v| v.as_bool())
373 .unwrap_or(false);
374 let mut clean = config.clone();
375 if let serde_json::Value::Object(map) = &mut clean {
376 map.remove("enabled");
377 }
378 let state = ModuleState {
379 chart_id: chart.id,
380 module_id: module_id.to_string(),
381 enabled,
382 config: clean,
383 };
384 if let Err(e) = self.store.upsert_module_state(&state) {
385 eprintln!("[shell] upsert_module_state {}: {}", module_id, e);
386 }
387 }
388
389 /// Lee `target_age_years` del módulo o cae a la edad actual del
390 /// sujeto (calculada desde la fecha de nacimiento y el reloj).
391 fn module_age_or_current(&self, module_id: &str) -> f64 {
392 self.module_configs
393 .get(module_id)
394 .and_then(|c| c.get("target_age_years"))
395 .and_then(|v| v.as_f64())
396 .unwrap_or_else(|| {
397 self.current_chart
398 .as_ref()
399 .map(|c| current_age_years(&c.birth_data))
400 .unwrap_or(0.0)
401 })
402 }
403
404 fn render_current(&mut self, cx: &mut Context<Self>) {
405 let Some(chart) = self.current_chart.as_ref() else {
406 return;
407 };
408 let requests = self.build_requests();
409 let render = match compose(chart, self.current_offset_minutes, &requests) {
410 Ok(r) => r,
411 Err(e) => {
412 eprintln!(
413 "[shell] compose {} (+{}min, {} reqs): {}",
414 chart.id,
415 self.current_offset_minutes,
416 requests.len(),
417 e
418 );
419 return;
420 }
421 };
422 self.canvas.update(cx, |c, cx| {
423 c.set_mode(
424 CanvasMode::Wheel {
425 render: Box::new(render),
426 },
427 cx,
428 );
429 });
430 }
431
432 fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context<Self>) {
433 match ev {
434 CanvasEvent::TimeOffsetChanged(off) => {
435 self.current_offset_minutes = *off;
436 if self.current_chart.is_some() {
437 self.render_current(cx);
438 }
439 }
440 CanvasEvent::LayerVisibilityChanged { kind, visible } => {
441 // El toggle de Outer ([T]) no es visibility puro: dispara
442 // un pipeline distinto. Lo traducimos a un cambio en
443 // module_configs["transit"]["enabled"] + re-render.
444 if matches!(kind, LayerKind::Outer) {
445 set_module_enabled(&mut self.module_configs, "transit", *visible);
446 self.persist_module("transit");
447 self.panel.update(cx, |p, cx| {
448 p.set_toggle("transit", "enabled", *visible, cx)
449 });
450 self.render_current(cx);
451 return;
452 }
453 // El resto son visibility puros sobre el canvas. Sync el
454 // panel para que el toggle visual coincida con la hotkey.
455 let key = match kind {
456 LayerKind::SignDial => "show_sign_dial",
457 LayerKind::Houses => "show_houses",
458 LayerKind::Aspects => "show_aspects",
459 LayerKind::Bodies => "show_bodies",
460 _ => return,
461 };
462 self.panel
463 .update(cx, |p, cx| p.set_toggle("natal", key, *visible, cx));
464 }
465 CanvasEvent::ChartRequested(_) => {
466 // Fase 7: doble click sobre un thumbnail abre la carta.
467 }
468 }
469 }
470
471 fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
472 match ev {
473 PanelEvent::ControlChanged {
474 module_id, key, value,
475 } => {
476 let bool_val = value.as_bool().unwrap_or(true);
477 if module_id == "natal" {
478 // Toggles puramente visuales — solo afectan visibility
479 // del render actual, sin recomponer.
480 let kind = match key.as_str() {
481 "show_sign_dial" => Some(LayerKind::SignDial),
482 "show_houses" => Some(LayerKind::Houses),
483 "show_aspects" => Some(LayerKind::Aspects),
484 "show_bodies" => Some(LayerKind::Bodies),
485 _ => None,
486 };
487 if let Some(k) = kind {
488 self.canvas
489 .update(cx, |c, cx| c.set_layer_visible(k, bool_val, cx));
490 }
491 } else {
492 // Cualquier otro módulo: actualizamos su config y
493 // recompomemos. La engine vuelve a llamarse con el
494 // PipelineRequest derivado del nuevo estado.
495 let entry = self
496 .module_configs
497 .entry(module_id.clone())
498 .or_insert_with(|| serde_json::json!({}));
499 if let serde_json::Value::Object(map) = entry {
500 map.insert(key.clone(), value.clone());
501 }
502 // Transit, Synastry y Solar Return comparten el
503 // outer ring del canvas — son mutuamente excluyentes.
504 // Al prender uno, apagamos los otros + sync panel +
505 // persist.
506 if key == "enabled" && bool_val && OUTER_RING_MODULES.contains(&module_id.as_str()) {
507 for &other in OUTER_RING_MODULES.iter() {
508 if other != module_id && module_enabled(&self.module_configs, other) {
509 set_module_enabled(&mut self.module_configs, other, false);
510 let other_str = other.to_string();
511 self.panel.update(cx, |p, cx| {
512 p.set_toggle(&other_str, "enabled", false, cx)
513 });
514 self.persist_module(&other_str);
515 }
516 }
517 }
518 // Sincronizar visualmente el toggle [T] del canvas
519 // cuando el cambio afecta el outer ring (transit,
520 // synastry o solar_return).
521 if OUTER_RING_MODULES.contains(&module_id.as_str()) && key == "enabled" {
522 self.canvas.update(cx, |c, cx| {
523 c.set_layer_visible(LayerKind::Outer, bool_val, cx)
524 });
525 }
526 self.persist_module(module_id);
527 self.render_current(cx);
528 }
529 }
530 PanelEvent::ModuleToggled { .. } => {
531 // Fase 7: encender/apagar módulos enteros desde un
532 // header con switch (vs. el toggle por-control de hoy).
533 }
534 }
535 let _ = (&self.store, &self.tree, &self.bus);
536 }
537}
538
539// =====================================================================
540// Helpers de module_configs
541// =====================================================================
542
543/// Módulos que pintan en el outer ring del canvas — mutuamente
544/// excluyentes a nivel de UI. Al prender uno, los otros se apagan.
545const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "planetary_return"];
546
547
548/// Etiqueta breve para mostrar al elegir una carta en el picker:
549/// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
550fn format_birth_brief(birth: &tahuantinsuyu_model::StoredBirthData) -> String {
551 let date = format!("{:04}-{:02}-{:02}", birth.year, birth.month, birth.day);
552 match &birth.birthplace_label {
553 Some(p) if !p.is_empty() => format!("{} · {}", date, p),
554 _ => date,
555 }
556}
557
558/// Edad en años decimales desde el nacimiento hasta el reloj actual.
559/// Aproximación: ignora la TZ de nacimiento (no afecta a resolución de
560/// año) y usa una fracción de año tropical sobre los segundos Unix.
561fn current_age_years(birth: &tahuantinsuyu_model::StoredBirthData) -> f64 {
562 use std::time::{SystemTime, UNIX_EPOCH};
563 let now_secs = SystemTime::now()
564 .duration_since(UNIX_EPOCH)
565 .map(|d| d.as_secs_f64())
566 .unwrap_or(0.0);
567 let birth_year_frac = birth.year as f64
568 + (birth.month.saturating_sub(1) as f64) / 12.0
569 + (birth.day.saturating_sub(1) as f64) / 365.25;
570 let now_year_frac = 1970.0 + now_secs / (365.2422 * 86400.0);
571 (now_year_frac - birth_year_frac).max(0.0)
572}
573
574fn module_enabled(cfgs: &HashMap<String, serde_json::Value>, id: &str) -> bool {
575 cfgs.get(id)
576 .and_then(|c| c.get("enabled"))
577 .and_then(|v| v.as_bool())
578 .unwrap_or(false)
579}
580
581fn set_module_enabled(
582 cfgs: &mut HashMap<String, serde_json::Value>,
583 id: &str,
584 enabled: bool,
585) {
586 let entry = cfgs
587 .entry(id.to_string())
588 .or_insert_with(|| serde_json::json!({}));
589 if let serde_json::Value::Object(map) = entry {
590 map.insert("enabled".into(), serde_json::Value::Bool(enabled));
591 }
592}
593
594impl Render for Shell {
595 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
596 let theme = Theme::global(cx).clone();
597
598 let header = div()
599 .h(px(34.0))
600 .px(px(12.0))
601 .flex()
602 .flex_row()
603 .items_center()
604 .gap(px(10.0))
605 .border_b_1()
606 .border_color(theme.border)
607 .child(
608 div()
609 .text_size(px(13.0))
610 .text_color(theme.fg_text)
611 .child("☉ Tahuantinsuyu"),
612 )
613 .child(
614 div()
615 .text_size(px(10.0))
616 .text_color(theme.fg_muted)
617 .child("estudio de astrología profesional"),
618 );
619
620 let tree_panel = div()
621 .w(px(TREE_WIDTH))
622 .min_w(px(TREE_WIDTH))
623 .h_full()
624 .border_r_1()
625 .border_color(theme.border)
626 .child(self.tree.clone());
627
628 let canvas_panel = div().flex_grow().h_full().child(self.canvas.clone());
629
630 let main_row = div()
631 .flex_grow()
632 .flex()
633 .flex_row()
634 .child(tree_panel)
635 .child(canvas_panel);
636
637 let bottom_panel = div()
638 .h(px(PANEL_HEIGHT))
639 .min_h(px(PANEL_HEIGHT))
640 .w_full()
641 .border_t_1()
642 .border_color(theme.border)
643 .child(self.panel.clone());
644
645 div()
646 .size_full()
647 .bg(theme.bg_app.clone())
648 .flex()
649 .flex_col()
650 .child(header)
651 .child(main_row)
652 .child(bottom_panel)
653 }
654}