tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definiciones

⎇ 97a6aab883ed5467e1e8c631ae766b265b36fc63 crates/apps/tahuantinsuyu/src/shell.rs Rust · 479 líneas · 18.3 KB Historia Quién tocó qué crudo

feat(tahuantinsuyu): fase 10 — Sinastría como overlay (bi-wheel con carta hermana) 97a6aab88 · sergio ·

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}