⎇ 42e09fd7cd429363a9867002c6ba1d77c04f1bf4
crates/apps/tahuantinsuyu/src/shell.rs
Historia
Quién tocó qué
crudo
feat(tahuantinsuyu): fase 7 — progresión secundaria como overlay (prueba de la arquitectura)
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 self.current_chart = Some(chart.clone());
119 self.current_offset_minutes = 0;
120 self.render_current(cx);
121 self.panel
122 .update(cx, |p, cx| p.set_active_kind(Some(chart.kind), cx));
123 }
124 TreeSelection::Contact(id) => {
125 self.current_chart = None;
126 self.current_offset_minutes = 0;
127 let charts = self.store.list_charts(id).unwrap_or_default();
128 let items: Vec<ThumbnailItem> = charts
129 .into_iter()
130 .map(|c| ThumbnailItem {
131 chart_id: c.id,
132 label: SharedString::from(c.label),
133 subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
134 preview: None,
135 })
136 .collect();
137 self.canvas.update(cx, |c, cx| {
138 c.set_mode(
139 CanvasMode::Thumbnails {
140 scope: ThumbnailScope::Contact(id),
141 items,
142 },
143 cx,
144 );
145 });
146 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
147 }
148 TreeSelection::Group(id) => {
149 self.current_chart = None;
150 self.current_offset_minutes = 0;
151 let charts = self.store.charts_under_group(id).unwrap_or_default();
152 let items: Vec<ThumbnailItem> = charts
153 .into_iter()
154 .map(|c| ThumbnailItem {
155 chart_id: c.id,
156 label: SharedString::from(c.label),
157 subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
158 preview: None,
159 })
160 .collect();
161 self.canvas.update(cx, |c, cx| {
162 c.set_mode(
163 CanvasMode::Thumbnails {
164 scope: ThumbnailScope::Group(id),
165 items,
166 },
167 cx,
168 );
169 });
170 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
171 }
172 }
173 }
174
175 /// Deriva los `PipelineRequest` activos a partir del `module_configs`.
176 fn build_requests(&self) -> Vec<PipelineRequest> {
177 let mut requests = Vec::new();
178 if module_enabled(&self.module_configs, "transit") {
179 requests.push(PipelineRequest::Transit);
180 }
181 if module_enabled(&self.module_configs, "progression") {
182 if let Some(chart) = self.current_chart.as_ref() {
183 let age = current_age_years(&chart.birth_data);
184 requests.push(PipelineRequest::SecondaryProgression {
185 target_age_years: age,
186 });
187 }
188 }
189 requests
190 }
191
192 fn render_current(&mut self, cx: &mut Context<Self>) {
193 let Some(chart) = self.current_chart.as_ref() else {
194 return;
195 };
196 let requests = self.build_requests();
197 let render = match compose(chart, self.current_offset_minutes, &requests) {
198 Ok(r) => r,
199 Err(e) => {
200 eprintln!(
201 "[shell] compose {} (+{}min, {} reqs): {}",
202 chart.id,
203 self.current_offset_minutes,
204 requests.len(),
205 e
206 );
207 return;
208 }
209 };
210 self.canvas.update(cx, |c, cx| {
211 c.set_mode(
212 CanvasMode::Wheel {
213 render: Box::new(render),
214 },
215 cx,
216 );
217 });
218 }
219
220 fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context<Self>) {
221 match ev {
222 CanvasEvent::TimeOffsetChanged(off) => {
223 self.current_offset_minutes = *off;
224 if self.current_chart.is_some() {
225 self.render_current(cx);
226 }
227 }
228 CanvasEvent::LayerVisibilityChanged { kind, visible } => {
229 // El toggle de Outer ([T]) no es visibility puro: dispara
230 // un pipeline distinto. Lo traducimos a un cambio en
231 // module_configs["transit"]["enabled"] + re-render.
232 if matches!(kind, LayerKind::Outer) {
233 set_module_enabled(&mut self.module_configs, "transit", *visible);
234 self.panel.update(cx, |p, cx| {
235 p.set_toggle("transit", "enabled", *visible, cx)
236 });
237 self.render_current(cx);
238 return;
239 }
240 // El resto son visibility puros sobre el canvas. Sync el
241 // panel para que el toggle visual coincida con la hotkey.
242 let key = match kind {
243 LayerKind::SignDial => "show_sign_dial",
244 LayerKind::Houses => "show_houses",
245 LayerKind::Aspects => "show_aspects",
246 LayerKind::Bodies => "show_bodies",
247 _ => return,
248 };
249 self.panel
250 .update(cx, |p, cx| p.set_toggle("natal", key, *visible, cx));
251 }
252 CanvasEvent::ChartRequested(_) => {
253 // Fase 7: doble click sobre un thumbnail abre la carta.
254 }
255 }
256 }
257
258 fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
259 match ev {
260 PanelEvent::ControlChanged {
261 module_id, key, value,
262 } => {
263 let bool_val = value.as_bool().unwrap_or(true);
264 if module_id == "natal" {
265 // Toggles puramente visuales — solo afectan visibility
266 // del render actual, sin recomponer.
267 let kind = match key.as_str() {
268 "show_sign_dial" => Some(LayerKind::SignDial),
269 "show_houses" => Some(LayerKind::Houses),
270 "show_aspects" => Some(LayerKind::Aspects),
271 "show_bodies" => Some(LayerKind::Bodies),
272 _ => None,
273 };
274 if let Some(k) = kind {
275 self.canvas
276 .update(cx, |c, cx| c.set_layer_visible(k, bool_val, cx));
277 }
278 } else {
279 // Cualquier otro módulo: actualizamos su config y
280 // recompomemos. La engine vuelve a llamarse con el
281 // PipelineRequest derivado del nuevo estado.
282 let entry = self
283 .module_configs
284 .entry(module_id.clone())
285 .or_insert_with(|| serde_json::json!({}));
286 if let serde_json::Value::Object(map) = entry {
287 map.insert(key.clone(), value.clone());
288 }
289 // Sincronizar visualmente el toggle [T] del canvas
290 // cuando el cambio fue al "enabled" del transit.
291 if module_id == "transit" && key == "enabled" {
292 self.canvas.update(cx, |c, cx| {
293 c.set_layer_visible(LayerKind::Outer, bool_val, cx)
294 });
295 }
296 self.render_current(cx);
297 }
298 }
299 PanelEvent::ModuleToggled { .. } => {
300 // Fase 7: encender/apagar módulos enteros desde un
301 // header con switch (vs. el toggle por-control de hoy).
302 }
303 }
304 let _ = (&self.store, &self.tree, &self.bus);
305 }
306}
307
308// =====================================================================
309// Helpers de module_configs
310// =====================================================================
311
312/// Edad en años decimales desde el nacimiento hasta el reloj actual.
313/// Aproximación: ignora la TZ de nacimiento (no afecta a resolución de
314/// año) y usa una fracción de año tropical sobre los segundos Unix.
315fn current_age_years(birth: &tahuantinsuyu_model::StoredBirthData) -> f64 {
316 use std::time::{SystemTime, UNIX_EPOCH};
317 let now_secs = SystemTime::now()
318 .duration_since(UNIX_EPOCH)
319 .map(|d| d.as_secs_f64())
320 .unwrap_or(0.0);
321 let birth_year_frac = birth.year as f64
322 + (birth.month.saturating_sub(1) as f64) / 12.0
323 + (birth.day.saturating_sub(1) as f64) / 365.25;
324 let now_year_frac = 1970.0 + now_secs / (365.2422 * 86400.0);
325 (now_year_frac - birth_year_frac).max(0.0)
326}
327
328fn module_enabled(cfgs: &HashMap<String, serde_json::Value>, id: &str) -> bool {
329 cfgs.get(id)
330 .and_then(|c| c.get("enabled"))
331 .and_then(|v| v.as_bool())
332 .unwrap_or(false)
333}
334
335fn set_module_enabled(
336 cfgs: &mut HashMap<String, serde_json::Value>,
337 id: &str,
338 enabled: bool,
339) {
340 let entry = cfgs
341 .entry(id.to_string())
342 .or_insert_with(|| serde_json::json!({}));
343 if let serde_json::Value::Object(map) = entry {
344 map.insert("enabled".into(), serde_json::Value::Bool(enabled));
345 }
346}
347
348impl Render for Shell {
349 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
350 let theme = Theme::global(cx).clone();
351
352 let header = div()
353 .h(px(34.0))
354 .px(px(12.0))
355 .flex()
356 .flex_row()
357 .items_center()
358 .gap(px(10.0))
359 .border_b_1()
360 .border_color(theme.border)
361 .child(
362 div()
363 .text_size(px(13.0))
364 .text_color(theme.fg_text)
365 .child("☉ Tahuantinsuyu"),
366 )
367 .child(
368 div()
369 .text_size(px(10.0))
370 .text_color(theme.fg_muted)
371 .child("estudio de astrología profesional"),
372 );
373
374 let tree_panel = div()
375 .w(px(TREE_WIDTH))
376 .min_w(px(TREE_WIDTH))
377 .h_full()
378 .border_r_1()
379 .border_color(theme.border)
380 .child(self.tree.clone());
381
382 let canvas_panel = div().flex_grow().h_full().child(self.canvas.clone());
383
384 let main_row = div()
385 .flex_grow()
386 .flex()
387 .flex_row()
388 .child(tree_panel)
389 .child(canvas_panel);
390
391 let bottom_panel = div()
392 .h(px(PANEL_HEIGHT))
393 .min_h(px(PANEL_HEIGHT))
394 .w_full()
395 .border_t_1()
396 .border_color(theme.border)
397 .child(self.panel.clone());
398
399 div()
400 .size_full()
401 .bg(theme.bg_app.clone())
402 .flex()
403 .flex_col()
404 .child(header)
405 .child(main_row)
406 .child(bottom_panel)
407 }
408}