⎇ 97a6aab883ed5467e1e8c631ae766b265b36fc63
crates/apps/tahuantinsuyu/src/shell.rs
Historia
Quién tocó qué
crudo
feat(tahuantinsuyu): fase 10 — Sinastría como overlay (bi-wheel con carta hermana)
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, TreeSelection};
36use tahuantinsuyu_panel::{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 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 }
95
96 fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) {
97 let selection = match ev {
98 TreeEvent::Selected(s) => s,
99 TreeEvent::Opened(s) => s,
100 TreeEvent::HierarchyChanged => {
101 cx.notify();
102 return;
103 }
104 };
105 self.apply_selection(selection.clone(), cx);
106 }
107
108 fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
109 match sel {
110 TreeSelection::Chart(id) => {
111 let chart = match self.store.get_chart(id) {
112 Ok(c) => c,
113 Err(e) => {
114 eprintln!("[shell] get_chart {}: {}", id, e);
115 return;
116 }
117 };
118 let age = current_age_years(&chart.birth_data);
119 self.current_chart = Some(chart.clone());
120 self.current_offset_minutes = 0;
121 // Inicializar la edad objetivo de los módulos basados
122 // en edad con la edad actual del sujeto, así sus
123 // sliders arrancan donde corresponde al activarse.
124 for module_id in ["progression", "solar_arc"] {
125 let entry = self
126 .module_configs
127 .entry(module_id.into())
128 .or_insert_with(|| serde_json::json!({}));
129 if let serde_json::Value::Object(map) = entry {
130 map.insert("target_age_years".into(), serde_json::json!(age));
131 }
132 }
133 self.render_current(cx);
134 self.panel.update(cx, |p, cx| {
135 p.set_active_kind(Some(chart.kind), cx);
136 p.set_slider("progression", "target_age_years", age, cx);
137 p.set_slider("solar_arc", "target_age_years", age, cx);
138 });
139 }
140 TreeSelection::Contact(id) => {
141 self.current_chart = None;
142 self.current_offset_minutes = 0;
143 let charts = self.store.list_charts(id).unwrap_or_default();
144 let items: Vec<ThumbnailItem> = charts
145 .into_iter()
146 .map(|c| ThumbnailItem {
147 chart_id: c.id,
148 label: SharedString::from(c.label),
149 subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
150 preview: None,
151 })
152 .collect();
153 self.canvas.update(cx, |c, cx| {
154 c.set_mode(
155 CanvasMode::Thumbnails {
156 scope: ThumbnailScope::Contact(id),
157 items,
158 },
159 cx,
160 );
161 });
162 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
163 }
164 TreeSelection::Group(id) => {
165 self.current_chart = None;
166 self.current_offset_minutes = 0;
167 let charts = self.store.charts_under_group(id).unwrap_or_default();
168 let items: Vec<ThumbnailItem> = charts
169 .into_iter()
170 .map(|c| ThumbnailItem {
171 chart_id: c.id,
172 label: SharedString::from(c.label),
173 subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
174 preview: None,
175 })
176 .collect();
177 self.canvas.update(cx, |c, cx| {
178 c.set_mode(
179 CanvasMode::Thumbnails {
180 scope: ThumbnailScope::Group(id),
181 items,
182 },
183 cx,
184 );
185 });
186 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
187 }
188 }
189 }
190
191 /// Deriva los `PipelineRequest` activos a partir del `module_configs`.
192 fn build_requests(&self) -> Vec<PipelineRequest> {
193 let mut requests = Vec::new();
194 if module_enabled(&self.module_configs, "transit") {
195 requests.push(PipelineRequest::Transit);
196 }
197 if module_enabled(&self.module_configs, "progression") {
198 let age = self.module_age_or_current("progression");
199 requests.push(PipelineRequest::SecondaryProgression {
200 target_age_years: age,
201 });
202 }
203 if module_enabled(&self.module_configs, "solar_arc") {
204 let age = self.module_age_or_current("solar_arc");
205 requests.push(PipelineRequest::SolarArc {
206 target_age_years: age,
207 });
208 }
209 if module_enabled(&self.module_configs, "synastry") {
210 if let Some(partner) = self.find_synastry_partner() {
211 requests.push(PipelineRequest::Synastry {
212 partner_chart: Box::new(partner),
213 });
214 }
215 }
216 requests
217 }
218
219 /// Encuentra una carta hermana del contacto actual (cualquier otra
220 /// carta con el mismo `contact_id` ≠ self). `None` si no hay
221 /// hermana — el shell salta el request silenciosamente.
222 fn find_synastry_partner(&self) -> Option<Chart> {
223 let current = self.current_chart.as_ref()?;
224 let siblings = self.store.list_charts(current.contact_id).ok()?;
225 siblings.into_iter().find(|c| c.id != current.id)
226 }
227
228 /// Lee `target_age_years` del módulo o cae a la edad actual del
229 /// sujeto (calculada desde la fecha de nacimiento y el reloj).
230 fn module_age_or_current(&self, module_id: &str) -> f64 {
231 self.module_configs
232 .get(module_id)
233 .and_then(|c| c.get("target_age_years"))
234 .and_then(|v| v.as_f64())
235 .unwrap_or_else(|| {
236 self.current_chart
237 .as_ref()
238 .map(|c| current_age_years(&c.birth_data))
239 .unwrap_or(0.0)
240 })
241 }
242
243 fn render_current(&mut self, cx: &mut Context<Self>) {
244 let Some(chart) = self.current_chart.as_ref() else {
245 return;
246 };
247 let requests = self.build_requests();
248 let render = match compose(chart, self.current_offset_minutes, &requests) {
249 Ok(r) => r,
250 Err(e) => {
251 eprintln!(
252 "[shell] compose {} (+{}min, {} reqs): {}",
253 chart.id,
254 self.current_offset_minutes,
255 requests.len(),
256 e
257 );
258 return;
259 }
260 };
261 self.canvas.update(cx, |c, cx| {
262 c.set_mode(
263 CanvasMode::Wheel {
264 render: Box::new(render),
265 },
266 cx,
267 );
268 });
269 }
270
271 fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context<Self>) {
272 match ev {
273 CanvasEvent::TimeOffsetChanged(off) => {
274 self.current_offset_minutes = *off;
275 if self.current_chart.is_some() {
276 self.render_current(cx);
277 }
278 }
279 CanvasEvent::LayerVisibilityChanged { kind, visible } => {
280 // El toggle de Outer ([T]) no es visibility puro: dispara
281 // un pipeline distinto. Lo traducimos a un cambio en
282 // module_configs["transit"]["enabled"] + re-render.
283 if matches!(kind, LayerKind::Outer) {
284 set_module_enabled(&mut self.module_configs, "transit", *visible);
285 self.panel.update(cx, |p, cx| {
286 p.set_toggle("transit", "enabled", *visible, cx)
287 });
288 self.render_current(cx);
289 return;
290 }
291 // El resto son visibility puros sobre el canvas. Sync el
292 // panel para que el toggle visual coincida con la hotkey.
293 let key = match kind {
294 LayerKind::SignDial => "show_sign_dial",
295 LayerKind::Houses => "show_houses",
296 LayerKind::Aspects => "show_aspects",
297 LayerKind::Bodies => "show_bodies",
298 _ => return,
299 };
300 self.panel
301 .update(cx, |p, cx| p.set_toggle("natal", key, *visible, cx));
302 }
303 CanvasEvent::ChartRequested(_) => {
304 // Fase 7: doble click sobre un thumbnail abre la carta.
305 }
306 }
307 }
308
309 fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
310 match ev {
311 PanelEvent::ControlChanged {
312 module_id, key, value,
313 } => {
314 let bool_val = value.as_bool().unwrap_or(true);
315 if module_id == "natal" {
316 // Toggles puramente visuales — solo afectan visibility
317 // del render actual, sin recomponer.
318 let kind = match key.as_str() {
319 "show_sign_dial" => Some(LayerKind::SignDial),
320 "show_houses" => Some(LayerKind::Houses),
321 "show_aspects" => Some(LayerKind::Aspects),
322 "show_bodies" => Some(LayerKind::Bodies),
323 _ => None,
324 };
325 if let Some(k) = kind {
326 self.canvas
327 .update(cx, |c, cx| c.set_layer_visible(k, bool_val, cx));
328 }
329 } else {
330 // Cualquier otro módulo: actualizamos su config y
331 // recompomemos. La engine vuelve a llamarse con el
332 // PipelineRequest derivado del nuevo estado.
333 let entry = self
334 .module_configs
335 .entry(module_id.clone())
336 .or_insert_with(|| serde_json::json!({}));
337 if let serde_json::Value::Object(map) = entry {
338 map.insert(key.clone(), value.clone());
339 }
340 // Transit y Synastry comparten el outer ring del
341 // canvas — son mutuamente excluyentes. Al prender
342 // uno, apagamos el otro y sincronizamos el panel.
343 if key == "enabled" && bool_val {
344 let conflicting = match module_id.as_str() {
345 "transit" => Some("synastry"),
346 "synastry" => Some("transit"),
347 _ => None,
348 };
349 if let Some(other) = conflicting {
350 if module_enabled(&self.module_configs, other) {
351 set_module_enabled(&mut self.module_configs, other, false);
352 let other_str = other.to_string();
353 self.panel.update(cx, |p, cx| {
354 p.set_toggle(&other_str, "enabled", false, cx)
355 });
356 }
357 }
358 }
359 // Sincronizar visualmente el toggle [T] del canvas
360 // cuando el cambio afecta el outer ring (transit o
361 // synastry — ambos lo usan).
362 if (module_id == "transit" || module_id == "synastry") && key == "enabled" {
363 self.canvas.update(cx, |c, cx| {
364 c.set_layer_visible(LayerKind::Outer, bool_val, cx)
365 });
366 }
367 self.render_current(cx);
368 }
369 }
370 PanelEvent::ModuleToggled { .. } => {
371 // Fase 7: encender/apagar módulos enteros desde un
372 // header con switch (vs. el toggle por-control de hoy).
373 }
374 }
375 let _ = (&self.store, &self.tree, &self.bus);
376 }
377}
378
379// =====================================================================
380// Helpers de module_configs
381// =====================================================================
382
383/// Edad en años decimales desde el nacimiento hasta el reloj actual.
384/// Aproximación: ignora la TZ de nacimiento (no afecta a resolución de
385/// año) y usa una fracción de año tropical sobre los segundos Unix.
386fn current_age_years(birth: &tahuantinsuyu_model::StoredBirthData) -> f64 {
387 use std::time::{SystemTime, UNIX_EPOCH};
388 let now_secs = SystemTime::now()
389 .duration_since(UNIX_EPOCH)
390 .map(|d| d.as_secs_f64())
391 .unwrap_or(0.0);
392 let birth_year_frac = birth.year as f64
393 + (birth.month.saturating_sub(1) as f64) / 12.0
394 + (birth.day.saturating_sub(1) as f64) / 365.25;
395 let now_year_frac = 1970.0 + now_secs / (365.2422 * 86400.0);
396 (now_year_frac - birth_year_frac).max(0.0)
397}
398
399fn module_enabled(cfgs: &HashMap<String, serde_json::Value>, id: &str) -> bool {
400 cfgs.get(id)
401 .and_then(|c| c.get("enabled"))
402 .and_then(|v| v.as_bool())
403 .unwrap_or(false)
404}
405
406fn set_module_enabled(
407 cfgs: &mut HashMap<String, serde_json::Value>,
408 id: &str,
409 enabled: bool,
410) {
411 let entry = cfgs
412 .entry(id.to_string())
413 .or_insert_with(|| serde_json::json!({}));
414 if let serde_json::Value::Object(map) = entry {
415 map.insert("enabled".into(), serde_json::Value::Bool(enabled));
416 }
417}
418
419impl Render for Shell {
420 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
421 let theme = Theme::global(cx).clone();
422
423 let header = div()
424 .h(px(34.0))
425 .px(px(12.0))
426 .flex()
427 .flex_row()
428 .items_center()
429 .gap(px(10.0))
430 .border_b_1()
431 .border_color(theme.border)
432 .child(
433 div()
434 .text_size(px(13.0))
435 .text_color(theme.fg_text)
436 .child("☉ Tahuantinsuyu"),
437 )
438 .child(
439 div()
440 .text_size(px(10.0))
441 .text_color(theme.fg_muted)
442 .child("estudio de astrología profesional"),
443 );
444
445 let tree_panel = div()
446 .w(px(TREE_WIDTH))
447 .min_w(px(TREE_WIDTH))
448 .h_full()
449 .border_r_1()
450 .border_color(theme.border)
451 .child(self.tree.clone());
452
453 let canvas_panel = div().flex_grow().h_full().child(self.canvas.clone());
454
455 let main_row = div()
456 .flex_grow()
457 .flex()
458 .flex_row()
459 .child(tree_panel)
460 .child(canvas_panel);
461
462 let bottom_panel = div()
463 .h(px(PANEL_HEIGHT))
464 .min_h(px(PANEL_HEIGHT))
465 .w_full()
466 .border_t_1()
467 .border_color(theme.border)
468 .child(self.panel.clone());
469
470 div()
471 .size_full()
472 .bg(theme.bg_app.clone())
473 .flex()
474 .flex_col()
475 .child(header)
476 .child(main_row)
477 .child(bottom_panel)
478 }
479}