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