⎇ d2b6b8b12e2739473e9454b9107161c5907fba93
crates/apps/tahuantinsuyu/src/shell.rs
History
Who touched what
raw
test(tahuantinsuyu): tests de integración del Shell con TestAppContext
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::{
35 LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest, compose_with_options,
36 svg_export,
37};
38use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection};
39use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
40use tahuantinsuyu_store::Store;
41use tahuantinsuyu_tree::{parse_city_atlas_tsv, TahuantinsuyuTree, TreeEvent};
42use yahweh_core::{LayoutDirection, NodeId};
43use yahweh_theme::Theme;
44use yahweh_widget_container_core::ChildSlot;
45use yahweh_widget_splitter::{SplitContainer, SplitEvent};
46use yahweh_widget_theme_switcher::theme_switcher;
47
48/// Status del broker brahman tal como lo vimos en el último ping.
49/// Se refresca cada 30 segundos desde un background task.
50#[derive(Clone, Debug)]
51pub enum BrahmanStatus {
52 /// Aún no probamos (boot, primer ciclo).
53 Pending,
54 /// Connect OK al broker, devolvió la lista de sessions activas.
55 Connected { session_count: usize },
56 /// Connect falló — broker no escucha en el socket o tomó timeout.
57 /// `reason` se incluye para diagnóstico en logs aunque la UI hoy
58 /// muestra solo "offline".
59 Offline {
60 #[allow(dead_code)]
61 reason: String,
62 },
63}
64
65pub struct Shell {
66 store: Store,
67 /// El árbol vive como child de `outer_split` (vía AnyView clone),
68 /// pero retenemos el Entity acá para que las subscripciones
69 /// registradas en `new` sigan vivas — al droppear el último handle,
70 /// gpui cancela los suscriptores.
71 #[allow(dead_code)]
72 tree: Entity<TahuantinsuyuTree>,
73 canvas: Entity<AstrologyCanvas>,
74 panel: Entity<ControlPanel>,
75 /// Splitter vertical entre el main_row (arriba — tree + canvas) y
76 /// el panel de control (abajo). El splitter horizontal interno se
77 /// arma en `new` y queda referenciado vía `outer_split` (es uno de
78 /// sus children), sin necesidad de retenerlo aparte.
79 outer_split: Entity<SplitContainer>,
80 /// Último estado conocido del broker brahman — refrescado cada
81 /// 30s desde el background task.
82 brahman_status: BrahmanStatus,
83 current_chart: Option<Chart>,
84 current_offset_minutes: i64,
85 /// Estado de los módulos overlay (transit, progression, …) por
86 /// `module_id`. Las claves dentro del JSON dependen del módulo (la
87 /// convención es `"enabled": bool` para el toggle principal).
88 module_configs: HashMap<String, serde_json::Value>,
89 /// Sequence counter para descartar resultados de cómputos
90 /// background que llegan después de uno más reciente. Cada
91 /// `render_current` lo incrementa y la closure async compara antes
92 /// de aplicar el render al canvas.
93 render_seq: u64,
94}
95
96impl Shell {
97 pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
98 cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
99
100 let tree = cx.new(|cx| {
101 let mut t = TahuantinsuyuTree::new(store.clone(), cx);
102 // Si hay un atlas custom en $XDG_DATA_HOME/tahuantinsuyu/
103 // atlas.tsv, lo cargamos y reemplazamos el atlas hardcoded
104 // de 90 ciudades. Formato TSV: name<TAB>lat<TAB>lon<TAB>tz_min.
105 if let Some(atlas) = load_city_atlas_from_xdg() {
106 t.set_city_atlas(atlas, cx);
107 }
108 t
109 });
110 let canvas = cx.new(AstrologyCanvas::new);
111 let panel = cx.new(ControlPanel::new);
112
113 cx.subscribe(&tree, |this: &mut Self, _, ev: &TreeEvent, cx| {
114 this.on_tree_event(ev, cx);
115 })
116 .detach();
117
118 cx.subscribe(&panel, |this: &mut Self, _, ev: &PanelEvent, cx| {
119 this.on_panel_event(ev, cx);
120 })
121 .detach();
122
123 cx.subscribe(&canvas, |this: &mut Self, _, ev: &CanvasEvent, cx| {
124 this.on_canvas_event(ev, cx);
125 })
126 .detach();
127
128 // Splitter horizontal: tree + canvas. Defaults (1.0, 4.0) salvo
129 // que tengamos un flex persistido en `settings`.
130 let (main_left, main_right) =
131 load_split_flex(&store, "layout.main_split", 1.0, 4.0);
132 let main_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Horizontal, cx));
133 main_split.update(cx, |sc, cx| {
134 sc.set_children(
135 vec![
136 ChildSlot {
137 id: NodeId::new("tts-tree"),
138 flex: main_left,
139 label: None,
140 view: gpui::AnyView::from(tree.clone()),
141 },
142 ChildSlot {
143 id: NodeId::new("tts-canvas"),
144 flex: main_right,
145 label: None,
146 view: gpui::AnyView::from(canvas.clone()),
147 },
148 ],
149 cx,
150 );
151 });
152
153 // Splitter vertical: main arriba, panel abajo. Defaults (4.0, 1.0).
154 let (outer_top, outer_bottom) =
155 load_split_flex(&store, "layout.outer_split", 4.0, 1.0);
156 let outer_split = cx.new(|cx| {
157 let mut sc = SplitContainer::new(LayoutDirection::Vertical, cx);
158 sc.set_children(
159 vec![
160 ChildSlot {
161 id: NodeId::new("tts-main"),
162 flex: outer_top,
163 label: None,
164 view: gpui::AnyView::from(main_split.clone()),
165 },
166 ChildSlot {
167 id: NodeId::new("tts-panel"),
168 flex: outer_bottom,
169 label: None,
170 view: gpui::AnyView::from(panel.clone()),
171 },
172 ],
173 cx,
174 );
175 sc
176 });
177
178 // Persistir flex en `DragEnd`. Capturamos el store por valor
179 // (Store es Clone — comparte el Arc<Mutex<Connection>>).
180 let store_main = store.clone();
181 cx.subscribe(&main_split, move |_, sc, ev: &SplitEvent, cx| {
182 if matches!(ev, SplitEvent::DragEnd) {
183 save_split_flex(&store_main, "layout.main_split", sc.read(cx));
184 }
185 })
186 .detach();
187 let store_outer = store.clone();
188 cx.subscribe(&outer_split, move |_, sc, ev: &SplitEvent, cx| {
189 if matches!(ev, SplitEvent::DragEnd) {
190 save_split_flex(&store_outer, "layout.outer_split", sc.read(cx));
191 }
192 })
193 .detach();
194
195 let shell = Self {
196 store,
197 tree,
198 canvas,
199 panel,
200 outer_split,
201 brahman_status: BrahmanStatus::Pending,
202 current_chart: None,
203 current_offset_minutes: 0,
204 module_configs: HashMap::new(),
205 render_seq: 0,
206 };
207 shell.refresh_chart_options(cx);
208 shell.spawn_brahman_status_loop(cx);
209 shell
210 }
211
212 /// Loop que cada 30s pregunta al broker la lista de sessions
213 /// activas y actualiza `brahman_status`. El cómputo bloqueante
214 /// (list_sessions_blocking abre su propio tokio runtime) corre en
215 /// el background_executor — no bloquea el UI thread. Cuando llega
216 /// el resultado, el `this.update` dispara cx.notify para repintar
217 /// el badge del header.
218 fn spawn_brahman_status_loop(&self, cx: &mut Context<Self>) {
219 cx.spawn(async move |this, cx| {
220 loop {
221 let result = cx
222 .background_executor()
223 .spawn(async {
224 brahman_sidecar::list_sessions_blocking("tahuantinsuyu-observer")
225 })
226 .await;
227 let _ = this.update(cx, |this, cx| {
228 this.brahman_status = match result {
229 Ok(list) => BrahmanStatus::Connected {
230 session_count: list.entries.len(),
231 },
232 Err(e) => BrahmanStatus::Offline {
233 reason: format!("{:?}", e),
234 },
235 };
236 cx.notify();
237 });
238 let timer = cx
239 .background_executor()
240 .timer(std::time::Duration::from_secs(30));
241 timer.await;
242 }
243 })
244 .detach();
245 }
246
247 /// Recarga la lista de opciones para los `Control::ChartPicker` y
248 /// la pushea al panel. Llamado al boot + tras cada
249 /// `TreeEvent::HierarchyChanged`.
250 fn refresh_chart_options(&self, cx: &mut Context<Self>) {
251 let charts = self.store.list_all_charts().unwrap_or_default();
252 let options: Vec<ChartOption> = charts
253 .into_iter()
254 .map(|c| ChartOption {
255 id: c.id.to_string(),
256 label: format!("{} — {}", c.label, format_birth_brief(&c.birth_data)),
257 })
258 .collect();
259 self.panel
260 .update(cx, |p, cx| p.set_chart_options(options, cx));
261 }
262
263 fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) {
264 let selection = match ev {
265 TreeEvent::Selected(s) => s,
266 TreeEvent::Opened(s) => s,
267 TreeEvent::HierarchyChanged => {
268 // La jerarquía cambió (alta/baja de cartas) — refrescar
269 // las opciones del picker para que aparezcan / desaparezcan
270 // en el dropdown.
271 self.refresh_chart_options(cx);
272 cx.notify();
273 return;
274 }
275 };
276 self.apply_selection(selection.clone(), cx);
277 }
278
279 fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
280 match sel {
281 TreeSelection::Chart(id) => {
282 let chart = match self.store.get_chart(id) {
283 Ok(c) => c,
284 Err(e) => {
285 eprintln!("[shell] get_chart {}: {}", id, e);
286 return;
287 }
288 };
289 let age = current_age_years(&chart.birth_data);
290 self.current_chart = Some(chart.clone());
291 self.current_offset_minutes = 0;
292 // 1) Defaults frescos para esta carta: edad objetivo =
293 // edad actual. Estos quedan en module_configs como
294 // valor base si el usuario nunca tocó el slider.
295 self.module_configs.clear();
296 for module_id in ["progression", "solar_arc", "planetary_return"] {
297 let entry = self
298 .module_configs
299 .entry(module_id.into())
300 .or_insert_with(|| serde_json::json!({}));
301 if let serde_json::Value::Object(map) = entry {
302 map.insert("target_age_years".into(), serde_json::json!(age));
303 }
304 }
305 // El módulo planetary_return además necesita un body
306 // por default — el shell elige "sun" si el usuario no
307 // tocó el Select. La persistencia luego puede pisar
308 // este valor.
309 if let Some(serde_json::Value::Object(map)) =
310 self.module_configs.get_mut("planetary_return")
311 {
312 map.entry(String::from("body"))
313 .or_insert(serde_json::json!("sun"));
314 }
315 // 2) Sobreescribir con lo que el usuario persistió la
316 // última vez para esta carta (SQLite `module_state`).
317 self.load_persisted_module_states(chart.id);
318 // 3) Sincronizar panel: active_kind + toggles/sliders.
319 self.panel.update(cx, |p, cx| {
320 p.set_active_kind(Some(chart.kind), cx);
321 });
322 self.sync_panel_from_configs(cx);
323 self.render_current(cx);
324 }
325 TreeSelection::Contact(id) => {
326 self.current_chart = None;
327 self.current_offset_minutes = 0;
328 let charts = self.store.list_charts(id).unwrap_or_default();
329 let items: Vec<ThumbnailItem> = charts
330 .into_iter()
331 .map(|c| ThumbnailItem {
332 chart_id: c.id,
333 label: SharedString::from(c.label),
334 subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
335 preview: None,
336 })
337 .collect();
338 self.canvas.update(cx, |c, cx| {
339 c.set_mode(
340 CanvasMode::Thumbnails {
341 scope: ThumbnailScope::Contact(id),
342 items,
343 },
344 cx,
345 );
346 });
347 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
348 }
349 TreeSelection::Group(id) => {
350 self.current_chart = None;
351 self.current_offset_minutes = 0;
352 let charts = self.store.charts_under_group(id).unwrap_or_default();
353 let items: Vec<ThumbnailItem> = charts
354 .into_iter()
355 .map(|c| ThumbnailItem {
356 chart_id: c.id,
357 label: SharedString::from(c.label),
358 subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
359 preview: None,
360 })
361 .collect();
362 self.canvas.update(cx, |c, cx| {
363 c.set_mode(
364 CanvasMode::Thumbnails {
365 scope: ThumbnailScope::Group(id),
366 items,
367 },
368 cx,
369 );
370 });
371 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
372 }
373 }
374 }
375
376 /// Deriva los `PipelineRequest` activos a partir del `module_configs`.
377 fn build_requests(&self) -> Vec<PipelineRequest> {
378 let mut requests = Vec::new();
379 if module_enabled(&self.module_configs, "transit") {
380 requests.push(PipelineRequest::Transit);
381 }
382 if module_enabled(&self.module_configs, "progression") {
383 let age = self.module_age_or_current("progression");
384 requests.push(PipelineRequest::SecondaryProgression {
385 target_age_years: age,
386 });
387 }
388 if module_enabled(&self.module_configs, "solar_arc") {
389 let age = self.module_age_or_current("solar_arc");
390 requests.push(PipelineRequest::SolarArc {
391 target_age_years: age,
392 });
393 }
394 if module_enabled(&self.module_configs, "synastry") {
395 if let Some(partner) = self.resolve_synastry_partner() {
396 requests.push(PipelineRequest::Synastry {
397 partner_chart: Box::new(partner),
398 });
399 }
400 }
401 if module_enabled(&self.module_configs, "midpoints") {
402 requests.push(PipelineRequest::Midpoints);
403 }
404 if module_enabled(&self.module_configs, "uranian") {
405 requests.push(PipelineRequest::Uranian);
406 }
407 if module_enabled(&self.module_configs, "lots") {
408 requests.push(PipelineRequest::Lots);
409 }
410 if module_enabled(&self.module_configs, "fixed_stars") {
411 requests.push(PipelineRequest::FixedStars);
412 }
413 if module_enabled(&self.module_configs, "composite") {
414 if let Some(partner) = self.resolve_composite_partner() {
415 requests.push(PipelineRequest::Composite {
416 partner_chart: Box::new(partner),
417 });
418 }
419 }
420 if module_enabled(&self.module_configs, "planetary_return") {
421 let age = self.module_age_or_current("planetary_return");
422 let body = self
423 .module_configs
424 .get("planetary_return")
425 .and_then(|c| c.get("body"))
426 .and_then(|v| v.as_str())
427 .unwrap_or("sun")
428 .to_string();
429 let shift_days = self
430 .module_configs
431 .get("planetary_return")
432 .and_then(|c| c.get("shift_days"))
433 .and_then(|v| v.as_f64())
434 .map(|v| v as i64)
435 .unwrap_or(0);
436 requests.push(PipelineRequest::PlanetaryReturn {
437 body,
438 target_age_years: age,
439 shift_days,
440 });
441 }
442 requests
443 }
444
445 /// Resuelve la carta partner para sinastría: 1) si el picker tiene
446 /// un `partner_chart_id` válido en `module_configs`, lo usa; 2)
447 /// si no, cae al automático (primera carta hermana del contacto
448 /// actual). `None` si nada matchea — el request se salta.
449 fn resolve_synastry_partner(&self) -> Option<Chart> {
450 let manual = self
451 .module_configs
452 .get("synastry")
453 .and_then(|c| c.get("partner_chart_id"))
454 .and_then(|v| v.as_str())
455 .and_then(|s| s.parse::<tahuantinsuyu_model::ChartId>().ok())
456 .and_then(|id| self.store.get_chart(id).ok());
457 manual.or_else(|| self.find_synastry_partner_auto())
458 }
459
460 fn find_synastry_partner_auto(&self) -> Option<Chart> {
461 let current = self.current_chart.as_ref()?;
462 let siblings = self.store.list_charts(current.contact_id).ok()?;
463 siblings.into_iter().find(|c| c.id != current.id)
464 }
465
466 /// Resuelve el partner para Composite — mismo patrón que Synastry:
467 /// 1) lee module_configs["composite"]["partner_chart_id"] y resuelve
468 /// el chart; 2) fallback al primer hermano del contacto actual.
469 fn resolve_composite_partner(&self) -> Option<Chart> {
470 let manual = self
471 .module_configs
472 .get("composite")
473 .and_then(|c| c.get("partner_chart_id"))
474 .and_then(|v| v.as_str())
475 .and_then(|s| s.parse::<tahuantinsuyu_model::ChartId>().ok())
476 .and_then(|id| self.store.get_chart(id).ok());
477 manual.or_else(|| self.find_synastry_partner_auto())
478 }
479
480 /// Deriva las `NatalOptions` activas a partir del `module_configs["natal"]`.
481 /// Si la entry no existe, devuelve defaults (majors=true, minors=false,
482 /// multiplier=1.0).
483 fn build_natal_options(&self) -> NatalOptions {
484 let cfg = self.module_configs.get("natal");
485 let read_bool = |key: &str, default: bool| -> bool {
486 cfg.and_then(|c| c.get(key))
487 .and_then(|v| v.as_bool())
488 .unwrap_or(default)
489 };
490 let read_f64 = |key: &str, default: f64| -> f64 {
491 cfg.and_then(|c| c.get(key))
492 .and_then(|v| v.as_f64())
493 .unwrap_or(default)
494 };
495 NatalOptions {
496 show_majors: read_bool("aspect_majors", true),
497 show_minors: read_bool("aspect_minors", false),
498 orb_multiplier: read_f64("orb_multiplier", 1.0),
499 show_dignities: read_bool("show_dignities", false),
500 }
501 }
502
503 /// Lee `module_state` desde SQLite para la carta dada y los mergea
504 /// con los defaults ya cargados en `module_configs`. Los valores
505 /// persistidos ganan sobre los defaults.
506 fn load_persisted_module_states(&mut self, chart_id: ChartId) {
507 let states = match self.store.list_module_states(chart_id) {
508 Ok(s) => s,
509 Err(e) => {
510 eprintln!("[shell] list_module_states {}: {}", chart_id, e);
511 return;
512 }
513 };
514 for st in states {
515 // Re-mergeamos `enabled` (columna separada en SQL) dentro
516 // del JSON config, así el resto del shell sigue leyendo
517 // todo desde una única estructura.
518 let mut combined = match st.config {
519 serde_json::Value::Object(m) => serde_json::Value::Object(m),
520 _ => serde_json::json!({}),
521 };
522 if let serde_json::Value::Object(map) = &mut combined {
523 map.insert("enabled".into(), serde_json::Value::Bool(st.enabled));
524 }
525 // Mergear sobre defaults previos (no sobreescribir si la
526 // entrada nueva no trae un campo).
527 match self.module_configs.entry(st.module_id) {
528 std::collections::hash_map::Entry::Vacant(v) => {
529 v.insert(combined);
530 }
531 std::collections::hash_map::Entry::Occupied(mut o) => {
532 if let (serde_json::Value::Object(dst), serde_json::Value::Object(src)) =
533 (o.get_mut(), &combined)
534 {
535 for (k, v) in src {
536 dst.insert(k.clone(), v.clone());
537 }
538 } else {
539 o.insert(combined);
540 }
541 }
542 }
543 }
544 }
545
546 /// Pushea cada toggle/slider/picker del `module_configs` al panel
547 /// para que la UI refleje el estado persistido al cargar una carta.
548 fn sync_panel_from_configs(&mut self, cx: &mut Context<Self>) {
549 let snapshot: Vec<(String, serde_json::Value)> = self
550 .module_configs
551 .iter()
552 .map(|(k, v)| (k.clone(), v.clone()))
553 .collect();
554 self.panel.update(cx, |p, cx| {
555 for (module_id, config) in &snapshot {
556 if let serde_json::Value::Object(map) = config {
557 for (key, value) in map {
558 if let Some(b) = value.as_bool() {
559 p.set_toggle(module_id, key, b, cx);
560 } else if let Some(f) = value.as_f64() {
561 p.set_slider(module_id, key, f, cx);
562 } else if let Some(s) = value.as_str() {
563 p.set_string(module_id, key, Some(s.to_string()), cx);
564 } else if value.is_null() {
565 p.set_string(module_id, key, None, cx);
566 }
567 }
568 }
569 }
570 });
571 }
572
573 /// Persiste el estado actual de un módulo a SQLite. Extrae
574 /// `enabled` del JSON y lo guarda en la columna dedicada; el resto
575 /// va al `config_json`.
576 fn persist_module(&self, module_id: &str) {
577 let Some(chart) = self.current_chart.as_ref() else {
578 return;
579 };
580 let Some(config) = self.module_configs.get(module_id) else {
581 return;
582 };
583 let enabled = config
584 .get("enabled")
585 .and_then(|v| v.as_bool())
586 .unwrap_or(false);
587 let mut clean = config.clone();
588 if let serde_json::Value::Object(map) = &mut clean {
589 map.remove("enabled");
590 }
591 let state = ModuleState {
592 chart_id: chart.id,
593 module_id: module_id.to_string(),
594 enabled,
595 config: clean,
596 };
597 if let Err(e) = self.store.upsert_module_state(&state) {
598 eprintln!("[shell] upsert_module_state {}: {}", module_id, e);
599 }
600 }
601
602 /// Lee `target_age_years` del módulo o cae a la edad actual del
603 /// sujeto (calculada desde la fecha de nacimiento y el reloj).
604 fn module_age_or_current(&self, module_id: &str) -> f64 {
605 self.module_configs
606 .get(module_id)
607 .and_then(|c| c.get("target_age_years"))
608 .and_then(|v| v.as_f64())
609 .unwrap_or_else(|| {
610 self.current_chart
611 .as_ref()
612 .map(|c| current_age_years(&c.birth_data))
613 .unwrap_or(0.0)
614 })
615 }
616
617 fn render_current(&mut self, cx: &mut Context<Self>) {
618 let Some(chart) = self.current_chart.as_ref() else {
619 return;
620 };
621 // Snapshot de inputs para mover al background. La sesión
622 // VSOP2013 vive en un static `OnceLock` adentro del bridge, así
623 // que es compartible read-only entre threads sin que ningún
624 // dato cruce más allá del Chart clonado + requests/options.
625 let chart = chart.clone();
626 let offset = self.current_offset_minutes;
627 let requests = self.build_requests();
628 let natal_options = self.build_natal_options();
629 self.render_seq = self.render_seq.wrapping_add(1);
630 let my_seq = self.render_seq;
631
632 cx.spawn(async move |this, cx| {
633 // El compute corre en el background_executor — no bloquea
634 // el UI thread. Para una rueda completa con varios overlays
635 // puede tomar 100-200ms; sin esto, los drags del slider se
636 // sentirían atorados.
637 let chart_for_bg = chart.clone();
638 let requests_for_bg = requests.clone();
639 let opts_for_bg = natal_options.clone();
640 let result = cx
641 .background_executor()
642 .spawn(async move {
643 compose_with_options(&chart_for_bg, offset, &requests_for_bg, &opts_for_bg)
644 })
645 .await;
646
647 let _ = this.update(cx, |this, cx| {
648 // Descartar si llegó un render más nuevo en el medio.
649 // Sin este check, durante un drag rápido un compute
650 // viejo podría sobrescribir el más reciente.
651 if this.render_seq != my_seq {
652 return;
653 }
654 match result {
655 Ok(render) => {
656 this.canvas.update(cx, |c, cx| {
657 c.set_mode(
658 CanvasMode::Wheel {
659 render: Box::new(render),
660 },
661 cx,
662 );
663 });
664 }
665 Err(e) => {
666 eprintln!(
667 "[shell] compose {} (+{}min, {} reqs): {}",
668 chart.id,
669 offset,
670 requests.len(),
671 e
672 );
673 }
674 }
675 });
676 })
677 .detach();
678 }
679
680 fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context<Self>) {
681 match ev {
682 CanvasEvent::TimeOffsetChanged(off) => {
683 self.current_offset_minutes = *off;
684 if self.current_chart.is_some() {
685 self.render_current(cx);
686 }
687 }
688 CanvasEvent::LayerVisibilityChanged { kind, visible } => {
689 // El toggle de Outer ([T]) no es visibility puro: dispara
690 // un pipeline distinto. Lo traducimos a un cambio en
691 // module_configs["transit"]["enabled"] + re-render.
692 if matches!(kind, LayerKind::Outer) {
693 set_module_enabled(&mut self.module_configs, "transit", *visible);
694 self.persist_module("transit");
695 self.panel.update(cx, |p, cx| {
696 p.set_toggle("transit", "enabled", *visible, cx)
697 });
698 self.render_current(cx);
699 return;
700 }
701 // El resto son visibility puros sobre el canvas. Sync el
702 // panel para que el toggle visual coincida con la hotkey.
703 let key = match kind {
704 LayerKind::SignDial => "show_sign_dial",
705 LayerKind::Houses => "show_houses",
706 LayerKind::Aspects => "show_aspects",
707 LayerKind::Bodies => "show_bodies",
708 _ => return,
709 };
710 self.panel
711 .update(cx, |p, cx| p.set_toggle("natal", key, *visible, cx));
712 }
713 CanvasEvent::ChartRequested(_) => {
714 // Fase 7: doble click sobre un thumbnail abre la carta.
715 }
716 CanvasEvent::ExportSvgRequested => {
717 self.export_current_to_svg();
718 }
719 }
720 }
721
722 /// Recompone la carta actual + escribe el SVG a un archivo en
723 /// `$XDG_DATA_HOME/tahuantinsuyu/exports/<label>_<short_id>.svg`.
724 /// Logea la ruta a stderr — futuro: file save dialog GPUI.
725 fn export_current_to_svg(&self) {
726 let Some(chart) = self.current_chart.as_ref() else {
727 eprintln!("[shell] export svg: sin carta activa");
728 return;
729 };
730 let requests = self.build_requests();
731 let natal_options = self.build_natal_options();
732 let render = match compose_with_options(
733 chart,
734 self.current_offset_minutes,
735 &requests,
736 &natal_options,
737 ) {
738 Ok(r) => r,
739 Err(e) => {
740 eprintln!("[shell] export svg compose: {}", e);
741 return;
742 }
743 };
744 let svg = svg_export::render_to_svg(&render);
745 let dir = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu")
746 .map(|d| d.data_dir().join("exports"))
747 .unwrap_or_else(|| std::path::PathBuf::from("."));
748 if let Err(e) = std::fs::create_dir_all(&dir) {
749 eprintln!("[shell] mkdir {:?}: {}", dir, e);
750 return;
751 }
752 let safe_label: String = chart
753 .label
754 .chars()
755 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
756 .collect();
757 let short = format!("{}", chart.id).chars().take(8).collect::<String>();
758 let path = dir.join(format!("{}_{}.svg", safe_label, short));
759 if let Err(e) = std::fs::write(&path, svg) {
760 eprintln!("[shell] write {:?}: {}", path, e);
761 } else {
762 eprintln!("[shell] SVG exportado → {}", path.display());
763 }
764 }
765
766 fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
767 match ev {
768 PanelEvent::ControlChanged {
769 module_id, key, value,
770 } => {
771 let bool_val = value.as_bool().unwrap_or(true);
772 if module_id == "natal" {
773 // Distinguimos: show_* = visibility (no recompose),
774 // aspect_*/orb_* = filtros de engine (recompose +
775 // persist).
776 let kind = match key.as_str() {
777 "show_sign_dial" => Some(LayerKind::SignDial),
778 "show_houses" => Some(LayerKind::Houses),
779 "show_aspects" => Some(LayerKind::Aspects),
780 "show_bodies" => Some(LayerKind::Bodies),
781 _ => None,
782 };
783 if let Some(k) = kind {
784 self.canvas
785 .update(cx, |c, cx| c.set_layer_visible(k, bool_val, cx));
786 } else {
787 // Filtros: actualizar module_configs + recompose.
788 let entry = self
789 .module_configs
790 .entry("natal".into())
791 .or_insert_with(|| serde_json::json!({}));
792 if let serde_json::Value::Object(map) = entry {
793 map.insert(key.clone(), value.clone());
794 }
795 self.persist_module("natal");
796 self.render_current(cx);
797 }
798 } else {
799 // Cualquier otro módulo: actualizamos su config y
800 // recompomemos. La engine vuelve a llamarse con el
801 // PipelineRequest derivado del nuevo estado.
802 let entry = self
803 .module_configs
804 .entry(module_id.clone())
805 .or_insert_with(|| serde_json::json!({}));
806 if let serde_json::Value::Object(map) = entry {
807 map.insert(key.clone(), value.clone());
808 }
809 // Transit, Synastry y Solar Return comparten el
810 // outer ring del canvas — son mutuamente excluyentes.
811 // Al prender uno, apagamos los otros + sync panel +
812 // persist.
813 if key == "enabled" && bool_val && OUTER_RING_MODULES.contains(&module_id.as_str()) {
814 for &other in OUTER_RING_MODULES.iter() {
815 if other != module_id && module_enabled(&self.module_configs, other) {
816 set_module_enabled(&mut self.module_configs, other, false);
817 let other_str = other.to_string();
818 self.panel.update(cx, |p, cx| {
819 p.set_toggle(&other_str, "enabled", false, cx)
820 });
821 self.persist_module(&other_str);
822 }
823 }
824 }
825 // Sincronizar visualmente el toggle [T] del canvas
826 // cuando el cambio afecta el outer ring (transit,
827 // synastry o solar_return).
828 if OUTER_RING_MODULES.contains(&module_id.as_str()) && key == "enabled" {
829 self.canvas.update(cx, |c, cx| {
830 c.set_layer_visible(LayerKind::Outer, bool_val, cx)
831 });
832 }
833 self.persist_module(module_id);
834 self.render_current(cx);
835 }
836 }
837 PanelEvent::ModuleToggled { .. } => {
838 // Fase 7: encender/apagar módulos enteros desde un
839 // header con switch (vs. el toggle por-control de hoy).
840 }
841 }
842 }
843}
844
845// =====================================================================
846// Helpers de module_configs
847// =====================================================================
848
849// OUTER_RING_MODULES viene de tahuantinsuyu_engine — single source of
850// truth. Shell y canvas leen del mismo slice.
851
852
853/// Lee `$XDG_DATA_HOME/tahuantinsuyu/atlas.tsv` si existe y lo parsea
854/// como atlas de ciudades. Devuelve `None` cuando no hay archivo o
855/// quedó vacío después del parse — el tree cae al atlas hardcoded.
856fn load_city_atlas_from_xdg() -> Option<Vec<tahuantinsuyu_tree::CityPreset>> {
857 let path = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu")
858 .map(|d| d.data_dir().join("atlas.tsv"))?;
859 if !path.exists() {
860 return None;
861 }
862 let content = std::fs::read_to_string(&path).ok()?;
863 let atlas = parse_city_atlas_tsv(&content);
864 if atlas.is_empty() {
865 eprintln!(
866 "[shell] atlas.tsv encontrado en {:?} pero sin filas válidas — fallback a hardcoded",
867 path
868 );
869 return None;
870 }
871 eprintln!("[shell] atlas custom cargado: {} ciudades", atlas.len());
872 Some(atlas)
873}
874
875/// Etiqueta breve para mostrar al elegir una carta en el picker:
876/// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
877fn format_birth_brief(birth: &tahuantinsuyu_model::StoredBirthData) -> String {
878 let date = format!("{:04}-{:02}-{:02}", birth.year, birth.month, birth.day);
879 match &birth.birthplace_label {
880 Some(p) if !p.is_empty() => format!("{} · {}", date, p),
881 _ => date,
882 }
883}
884
885/// Edad en años decimales desde el nacimiento hasta el reloj actual.
886/// Aproximación: ignora la TZ de nacimiento (no afecta a resolución de
887/// año) y usa una fracción de año tropical sobre los segundos Unix.
888fn current_age_years(birth: &tahuantinsuyu_model::StoredBirthData) -> f64 {
889 use std::time::{SystemTime, UNIX_EPOCH};
890 let now_secs = SystemTime::now()
891 .duration_since(UNIX_EPOCH)
892 .map(|d| d.as_secs_f64())
893 .unwrap_or(0.0);
894 let birth_year_frac = birth.year as f64
895 + (birth.month.saturating_sub(1) as f64) / 12.0
896 + (birth.day.saturating_sub(1) as f64) / 365.25;
897 let now_year_frac = 1970.0 + now_secs / (365.2422 * 86400.0);
898 (now_year_frac - birth_year_frac).max(0.0)
899}
900
901fn module_enabled(cfgs: &HashMap<String, serde_json::Value>, id: &str) -> bool {
902 cfgs.get(id)
903 .and_then(|c| c.get("enabled"))
904 .and_then(|v| v.as_bool())
905 .unwrap_or(false)
906}
907
908fn set_module_enabled(
909 cfgs: &mut HashMap<String, serde_json::Value>,
910 id: &str,
911 enabled: bool,
912) {
913 let entry = cfgs
914 .entry(id.to_string())
915 .or_insert_with(|| serde_json::json!({}));
916 if let serde_json::Value::Object(map) = entry {
917 map.insert("enabled".into(), serde_json::Value::Bool(enabled));
918 }
919}
920
921/// Lee del `settings` el flex de un splitter (formato "left,right"). Si
922/// no hay nada persistido o está corrupto, devuelve los defaults.
923fn load_split_flex(store: &Store, key: &str, default_a: f32, default_b: f32) -> (f32, f32) {
924 let Ok(Some(raw)) = store.get_setting(key) else {
925 return (default_a, default_b);
926 };
927 let mut parts = raw.split(',');
928 let a = parts.next().and_then(|s| s.trim().parse::<f32>().ok());
929 let b = parts.next().and_then(|s| s.trim().parse::<f32>().ok());
930 match (a, b) {
931 (Some(a), Some(b)) if a > 0.0 && b > 0.0 => (a, b),
932 _ => (default_a, default_b),
933 }
934}
935
936/// Persiste los flex actuales de un splitter de 2 children. Si tiene
937/// más children (en el futuro) sólo guarda los dos primeros — ajustar
938/// el formato si se necesita más.
939fn save_split_flex(store: &Store, key: &str, sc: &SplitContainer) {
940 let children = sc.children();
941 let Some((first, rest)) = children.split_first() else {
942 return;
943 };
944 let Some(second) = rest.first() else {
945 return;
946 };
947 let payload = format!("{:.4},{:.4}", first.flex, second.flex);
948 if let Err(e) = store.set_setting(key, &payload) {
949 eprintln!("[shell] save_split_flex {}: {}", key, e);
950 }
951}
952
953impl Render for Shell {
954 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
955 let theme = Theme::global(cx).clone();
956
957 // Badge del estado del broker brahman — pequeña pill con
958 // color según el estado actual del ping cada-30s.
959 let (badge_text, badge_color) = match &self.brahman_status {
960 BrahmanStatus::Pending => ("Brahman · …".to_string(), theme.fg_muted),
961 BrahmanStatus::Connected { session_count } => (
962 format!("Brahman ✓ {} sessions", session_count),
963 theme.accent,
964 ),
965 BrahmanStatus::Offline { .. } => {
966 ("Brahman · offline".to_string(), theme.fg_disabled)
967 }
968 };
969 let brahman_badge = div()
970 .px(px(8.0))
971 .py(px(2.0))
972 .rounded(px(8.0))
973 .bg(theme.bg_panel_alt.clone())
974 .border_1()
975 .border_color(theme.border)
976 .text_size(px(10.0))
977 .text_color(badge_color)
978 .child(SharedString::from(badge_text));
979
980 let header = div()
981 .h(px(34.0))
982 .px(px(12.0))
983 .flex()
984 .flex_row()
985 .items_center()
986 .gap(px(10.0))
987 .border_b_1()
988 .border_color(theme.border)
989 .child(
990 div()
991 .text_size(px(13.0))
992 .text_color(theme.fg_text)
993 .child("☉ Tahuantinsuyu"),
994 )
995 .child(
996 div()
997 .text_size(px(10.0))
998 .text_color(theme.fg_muted)
999 .child("estudio de astrología profesional"),
1000 )
1001 .child(div().flex_grow())
1002 .child(brahman_badge)
1003 .child(theme_switcher(cx));
1004
1005 let body = div()
1006 .flex_grow()
1007 .w_full()
1008 .child(self.outer_split.clone());
1009
1010 div()
1011 .size_full()
1012 .bg(theme.bg_app.clone())
1013 .flex()
1014 .flex_col()
1015 .child(header)
1016 .child(body)
1017 }
1018}
1019
1020// =====================================================================
1021// Tests de integración del Shell
1022// =====================================================================
1023//
1024// Cubren los caminos que combinan lógica del shell con persistencia y
1025// el bridge real de eternal. Los tests puramente unitarios de cada
1026// crate (engine, store, modules) viven en sus respectivos `tests`
1027// modules; acá testeamos los wiring points del binario.
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::*;
1032 use gpui::TestAppContext;
1033 use tahuantinsuyu_model::{
1034 ChartKind, ContactId, StoredBirthData, StoredChartConfig,
1035 };
1036
1037 fn sample_chart_for(_contact_id: ContactId) -> (StoredBirthData, StoredChartConfig) {
1038 (
1039 StoredBirthData {
1040 year: 1987,
1041 month: 3,
1042 day: 14,
1043 hour: 5,
1044 minute: 22,
1045 second: 0.0,
1046 tz_offset_minutes: -240,
1047 latitude_deg: 10.4806,
1048 longitude_deg: -66.9036,
1049 altitude_m: 900.0,
1050 time_certainty: Default::default(),
1051 subject_name: Some("Sergio".into()),
1052 birthplace_label: Some("Caracas".into()),
1053 },
1054 StoredChartConfig::default(),
1055 )
1056 }
1057
1058 /// Smoke test: el Shell se construye sin panic con una store
1059 /// in-memory. Cubre que las suscripciones cross-widget (tree, panel,
1060 /// canvas, ambos splitters) se cablean sin colisiones y que el
1061 /// background loop del brahman status arranca limpio.
1062 #[gpui::test]
1063 fn shell_constructs_smoke(cx: &mut TestAppContext) {
1064 cx.update(|cx| {
1065 Theme::install_default(cx);
1066 let store = Store::in_memory().expect("in-memory store");
1067 let _shell = cx.new(|cx| Shell::new(store, cx));
1068 // Si llegamos acá sin panic, el cableado funciona.
1069 });
1070 }
1071
1072 /// La selección de una carta vía `apply_selection` (mismo pathway
1073 /// que dispara el TreeEvent) puebla `current_chart` y arranca un
1074 /// compute. El render asíncrono se resuelve después; verificamos
1075 /// solo los efectos sincrónicos: chart cargada y `render_seq`
1076 /// avanzado.
1077 #[gpui::test]
1078 fn select_chart_updates_current(cx: &mut TestAppContext) {
1079 cx.update(|cx| {
1080 Theme::install_default(cx);
1081 let store = Store::in_memory().expect("store");
1082 let group = store.create_group(None, "Test", None).unwrap();
1083 let contact = store
1084 .create_contact(Some(group.id), "Subject", None)
1085 .unwrap();
1086 let (birth, config) = sample_chart_for(contact.id);
1087 let chart = store
1088 .create_chart(contact.id, ChartKind::Natal, "Natal", &birth, &config, None)
1089 .unwrap();
1090
1091 let shell = cx.new(|cx| Shell::new(store, cx));
1092 shell.update(cx, |s, cx| {
1093 s.apply_selection(TreeSelection::Chart(chart.id), cx);
1094 });
1095 shell.read_with(cx, |s, _| {
1096 let cur = s.current_chart.as_ref().expect("current_chart set");
1097 assert_eq!(cur.id, chart.id);
1098 assert_eq!(cur.label, "Natal");
1099 assert!(s.render_seq >= 1, "render_seq debió avanzar al menos a 1");
1100 });
1101 });
1102 }
1103
1104 /// Toggleando un módulo overlay vía `module_configs` directamente
1105 /// (simulando el efecto de un `PanelEvent::ControlChanged`), la
1106 /// función `build_requests` debe reflejar el cambio.
1107 #[gpui::test]
1108 fn module_toggles_produce_requests(cx: &mut TestAppContext) {
1109 cx.update(|cx| {
1110 Theme::install_default(cx);
1111 let store = Store::in_memory().expect("store");
1112 let shell = cx.new(|cx| Shell::new(store, cx));
1113
1114 shell.update(cx, |s, _cx| {
1115 // Sin módulos activos → no hay requests.
1116 assert!(s.build_requests().is_empty());
1117
1118 set_module_enabled(&mut s.module_configs, "transit", true);
1119 set_module_enabled(&mut s.module_configs, "midpoints", true);
1120 set_module_enabled(&mut s.module_configs, "uranian", true);
1121
1122 let reqs = s.build_requests();
1123 assert_eq!(reqs.len(), 3);
1124 assert!(matches!(reqs[0], PipelineRequest::Transit));
1125 assert!(matches!(reqs[1], PipelineRequest::Midpoints));
1126 assert!(matches!(reqs[2], PipelineRequest::Uranian));
1127
1128 set_module_enabled(&mut s.module_configs, "transit", false);
1129 let reqs = s.build_requests();
1130 assert_eq!(reqs.len(), 2);
1131 assert!(!reqs
1132 .iter()
1133 .any(|r| matches!(r, PipelineRequest::Transit)));
1134 });
1135 });
1136 }
1137
1138 /// `NatalOptions` derivados de `module_configs["natal"]` deben
1139 /// respetar orb_multiplier, show_minors y show_dignities cuando los
1140 /// hay, y caer a defaults razonables cuando no.
1141 #[gpui::test]
1142 fn natal_options_read_from_configs(cx: &mut TestAppContext) {
1143 cx.update(|cx| {
1144 Theme::install_default(cx);
1145 let store = Store::in_memory().expect("store");
1146 let shell = cx.new(|cx| Shell::new(store, cx));
1147
1148 shell.update(cx, |s, _cx| {
1149 let opts = s.build_natal_options();
1150 assert!(opts.show_majors);
1151 assert!(!opts.show_minors);
1152 assert_eq!(opts.orb_multiplier, 1.0);
1153 assert!(!opts.show_dignities);
1154
1155 s.module_configs.insert(
1156 "natal".into(),
1157 serde_json::json!({
1158 "aspect_majors": true,
1159 "aspect_minors": true,
1160 "orb_multiplier": 1.75,
1161 "show_dignities": true,
1162 }),
1163 );
1164 let opts = s.build_natal_options();
1165 assert!(opts.show_minors);
1166 assert_eq!(opts.orb_multiplier, 1.75);
1167 assert!(opts.show_dignities);
1168 });
1169 });
1170 }
1171
1172 /// El flex de los splitters persiste entre instancias de Shell que
1173 /// comparten la misma store (in-memory): primera shell escribe via
1174 /// `save_split_flex`, segunda shell lee via `load_split_flex` al
1175 /// boot.
1176 #[test]
1177 fn split_flex_round_trip_via_store() {
1178 let store = Store::in_memory().expect("store");
1179 // No hay nada persistido todavía: defaults.
1180 assert_eq!(load_split_flex(&store, "layout.x", 1.0, 4.0), (1.0, 4.0));
1181
1182 store.set_setting("layout.x", "2.5,3.5").unwrap();
1183 assert_eq!(load_split_flex(&store, "layout.x", 1.0, 4.0), (2.5, 3.5));
1184
1185 // Valor corrupto → defaults.
1186 store.set_setting("layout.x", "garbage").unwrap();
1187 assert_eq!(load_split_flex(&store, "layout.x", 1.0, 4.0), (1.0, 4.0));
1188
1189 // Valores ≤ 0 → defaults (los splitters tratan 0 como hidden).
1190 store.set_setting("layout.x", "0,5").unwrap();
1191 assert_eq!(load_split_flex(&store, "layout.x", 1.0, 4.0), (1.0, 4.0));
1192 }
1193}