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