⎇ 36d6645e7fe3fbb68f45352948429d20eaa4c86d
crates/apps/cosmobiologia/src/shell.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(cosmobiologia): rectificador per-segundo + direcciones primarias reales
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 cosmobiologia_canvas::{
32 AstrologyCanvas, CanvasEvent, CanvasMode, ThumbnailItem, ThumbnailScope,
33};
34use cosmobiologia_engine::{
35 EventoConocido, LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest,
36 compose_with_options, svg_export,
37};
38use cosmobiologia_model::{
39 Chart, ChartId, ChartKind, ContactId, FreeChartId, ModuleState, StoredBirthData,
40 StoredChartConfig, TreeSelection,
41};
42use cosmobiologia_panel::{ChartOption, ControlPanel, PanelEvent};
43use cosmobiologia_store::Store;
44use cosmobiologia_tree::{
45 parse_city_atlas_tsv, FreeChartEntry, TahuantinsuyuTree, TreeEvent,
46};
47use nahual_core::{LayoutDirection, NodeId};
48use nahual_theme::Theme;
49use nahual_widget_container_core::ChildSlot;
50use nahual_widget_splitter::{SplitContainer, SplitEvent};
51use nahual_widget_theme_switcher::theme_switcher;
52
53/// Posición del panel de control dentro del shell. `Bottom` mantiene
54/// el layout histórico (tree+canvas arriba, panel abajo); las variantes
55/// laterales colapsan los splitters anidados en uno solo de 3 columnas.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum PanelDock {
58 Bottom,
59 Right,
60 Left,
61}
62
63impl PanelDock {
64 fn as_setting(&self) -> &'static str {
65 match self {
66 PanelDock::Bottom => "bottom",
67 PanelDock::Right => "right",
68 PanelDock::Left => "left",
69 }
70 }
71
72 fn from_setting(s: &str) -> Option<Self> {
73 match s {
74 "bottom" => Some(PanelDock::Bottom),
75 "right" => Some(PanelDock::Right),
76 "left" => Some(PanelDock::Left),
77 _ => None,
78 }
79 }
80}
81
82/// Status del broker brahman tal como lo vimos en el último ping.
83/// Se refresca cada 30 segundos desde un background task.
84#[derive(Clone, Debug)]
85pub enum BrahmanStatus {
86 /// Aún no probamos (boot, primer ciclo).
87 Pending,
88 /// Connect OK al broker, devolvió la lista de sessions activas.
89 Connected { session_count: usize },
90 /// Connect falló — broker no escucha en el socket o tomó timeout.
91 /// `reason` se incluye para diagnóstico en logs aunque la UI hoy
92 /// muestra solo "offline".
93 Offline {
94 #[allow(dead_code)]
95 reason: String,
96 },
97}
98
99pub struct Shell {
100 store: Store,
101 /// Los tres widgets viven como children de los splitters vía
102 /// AnyView clone; retenemos los Entity acá para que las
103 /// subscripciones sigan vivas y para poder rearmar el layout al
104 /// cambiar `dock` sin recrear los widgets.
105 tree: Entity<TahuantinsuyuTree>,
106 canvas: Entity<AstrologyCanvas>,
107 panel: Entity<ControlPanel>,
108 /// Splitter "exterior". En dock=Bottom es vertical con (main_split,
109 /// panel) como hijos; en dock=Right/Left es horizontal y agrupa
110 /// tree+canvas+panel en una sola tira.
111 outer_split: Entity<SplitContainer>,
112 /// Splitter horizontal interno con (tree, canvas). Solo se usa
113 /// cuando dock=Bottom; en docks laterales queda vivo pero sin ser
114 /// hijo del árbol activo.
115 main_split: Entity<SplitContainer>,
116 /// Dock activo del panel — determina cómo se arman los splitters
117 /// y cuáles flex se persisten.
118 dock: PanelDock,
119 /// Último estado conocido del broker brahman — refrescado cada
120 /// 30s desde el background task.
121 brahman_status: BrahmanStatus,
122 current_chart: Option<Chart>,
123 current_offset_minutes: i64,
124 /// Estado de los módulos overlay (transit, progression, …) por
125 /// `module_id`. Las claves dentro del JSON dependen del módulo (la
126 /// convención es `"enabled": bool` para el toggle principal).
127 module_configs: HashMap<String, serde_json::Value>,
128 /// Sequence counter para descartar resultados de cómputos
129 /// background que llegan después de uno más reciente. Cada
130 /// `render_current` lo incrementa y la closure async compara antes
131 /// de aplicar el render al canvas.
132 render_seq: u64,
133 /// Cartas "libres" — no persistidas en la store. Incluye la
134 /// especial `sky_now()` (Cielo ahora) + cualquier creada por el
135 /// usuario desde la sección "Cartas libres" del tree. Cada vez
136 /// que muta este mapa, llamamos `tree.set_free_charts`.
137 free_charts: HashMap<FreeChartId, Chart>,
138 /// Counter para id de cartas libres nuevas — el id se concatena
139 /// con el prefijo `free-` y el counter, así son únicos dentro de
140 /// la sesión sin pelearse con UUIDs reales.
141 next_free_id: u32,
142}
143
144impl Shell {
145 pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
146 cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
147
148 let tree = cx.new(|cx| {
149 let mut t = TahuantinsuyuTree::new(store.clone(), cx);
150 // Si hay un atlas custom en $XDG_DATA_HOME/cosmobiologia/
151 // atlas.tsv, lo cargamos y reemplazamos el atlas hardcoded
152 // de 90 ciudades. Formato TSV: name<TAB>lat<TAB>lon<TAB>tz_min.
153 if let Some(atlas) = load_city_atlas_from_xdg() {
154 t.set_city_atlas(atlas, cx);
155 }
156 t
157 });
158 let canvas = cx.new(AstrologyCanvas::new);
159 let panel = cx.new(ControlPanel::new);
160
161 cx.subscribe(&tree, |this: &mut Self, _, ev: &TreeEvent, cx| {
162 this.on_tree_event(ev, cx);
163 })
164 .detach();
165
166 cx.subscribe(&panel, |this: &mut Self, _, ev: &PanelEvent, cx| {
167 this.on_panel_event(ev, cx);
168 })
169 .detach();
170
171 cx.subscribe(&canvas, |this: &mut Self, _, ev: &CanvasEvent, cx| {
172 this.on_canvas_event(ev, cx);
173 })
174 .detach();
175
176 // Splitters vacíos — `apply_dock` los puebla según el layout
177 // activo. Horizontal/Vertical son defaults; cada apply ajusta la
178 // dirección antes de setear children.
179 let main_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Horizontal, cx));
180 let outer_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Vertical, cx));
181
182 // Persistir flex en `DragEnd`. La key del setting depende del
183 // dock activo, así no se pisan los flexes de un layout con los
184 // de otro al mudarse. Se lee dentro del closure para tomar el
185 // dock actualizado, no el capturado en `new`.
186 let store_main = store.clone();
187 cx.subscribe(&main_split, move |this: &mut Self, sc, ev: &SplitEvent, cx| {
188 if matches!(ev, SplitEvent::DragEnd) {
189 let key = split_key_main(this.dock);
190 save_split_flex(&store_main, key, sc.read(cx));
191 }
192 })
193 .detach();
194 let store_outer = store.clone();
195 cx.subscribe(&outer_split, move |this: &mut Self, sc, ev: &SplitEvent, cx| {
196 if matches!(ev, SplitEvent::DragEnd) {
197 let key = split_key_outer(this.dock);
198 save_split_flex(&store_outer, key, sc.read(cx));
199 }
200 })
201 .detach();
202
203 let dock = load_dock(&store).unwrap_or(PanelDock::Bottom);
204
205 let mut shell = Self {
206 store,
207 tree,
208 canvas,
209 panel,
210 outer_split,
211 main_split,
212 dock,
213 brahman_status: BrahmanStatus::Pending,
214 current_chart: None,
215 current_offset_minutes: 0,
216 module_configs: HashMap::new(),
217 render_seq: 0,
218 free_charts: HashMap::new(),
219 next_free_id: 0,
220 };
221 shell.apply_dock(dock, cx);
222 shell.refresh_chart_options(cx);
223 shell.spawn_brahman_status_loop(cx);
224 // Inicializar "Cielo ahora" como carta libre fija y empujarla
225 // al tree. Queda seleccionada por default — el usuario abre
226 // la app y ya ve el firmamento actual.
227 shell.ensure_sky_now(cx);
228 shell.apply_selection(
229 TreeSelection::FreeChart(FreeChartId::sky_now()),
230 cx,
231 );
232 shell
233 }
234
235 /// Garantiza que `sky-now` exista en `free_charts` y publica la
236 /// lista actualizada al tree. Recomputa la carta del cielo si ya
237 /// estaba (refresca al reloj actual).
238 fn ensure_sky_now(&mut self, cx: &mut Context<Self>) {
239 self.free_charts
240 .insert(FreeChartId::sky_now(), build_present_sky_chart());
241 self.push_free_charts_to_tree(cx);
242 }
243
244 fn push_free_charts_to_tree(&self, cx: &mut Context<Self>) {
245 // Orden de display: "Cielo ahora" primero, después el resto
246 // por id (los ids `free-N` quedan ordenados por creación).
247 let mut entries: Vec<FreeChartEntry> = Vec::new();
248 if let Some(c) = self.free_charts.get(&FreeChartId::sky_now()) {
249 entries.push(FreeChartEntry {
250 id: FreeChartId::sky_now(),
251 label: c.label.clone(),
252 birth_data: c.birth_data.clone(),
253 });
254 }
255 let mut others: Vec<(&FreeChartId, &Chart)> = self
256 .free_charts
257 .iter()
258 .filter(|(k, _)| !k.is_sky_now())
259 .collect();
260 others.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
261 for (id, c) in others {
262 entries.push(FreeChartEntry {
263 id: id.clone(),
264 label: c.label.clone(),
265 birth_data: c.birth_data.clone(),
266 });
267 }
268 self.tree
269 .update(cx, |t, cx| t.set_free_charts(entries, cx));
270 }
271
272 /// Arma el árbol de splitters según el dock pedido y persiste la
273 /// elección. Idempotente: llamar con el dock actual reconstruye los
274 /// children con flexes leídos del setting (útil tras `new`).
275 pub fn apply_dock(&mut self, dock: PanelDock, cx: &mut Context<Self>) {
276 self.dock = dock;
277
278 let tree_view = gpui::AnyView::from(self.tree.clone());
279 let canvas_view = gpui::AnyView::from(self.canvas.clone());
280 let panel_view = gpui::AnyView::from(self.panel.clone());
281 let main_view = gpui::AnyView::from(self.main_split.clone());
282
283 match dock {
284 PanelDock::Bottom => {
285 let flex_main = load_split_flex_n(
286 &self.store,
287 split_key_main(dock),
288 &[1.0, 4.0],
289 );
290 let flex_outer = load_split_flex_n(
291 &self.store,
292 split_key_outer(dock),
293 &[4.0, 1.0],
294 );
295 self.main_split.update(cx, |sc, cx| {
296 sc.set_direction(LayoutDirection::Horizontal, cx);
297 sc.set_children(
298 vec![
299 ChildSlot {
300 id: NodeId::new("tts-tree"),
301 flex: flex_main[0],
302 label: None,
303 view: tree_view.clone(),
304 },
305 ChildSlot {
306 id: NodeId::new("tts-canvas"),
307 flex: flex_main[1],
308 label: None,
309 view: canvas_view.clone(),
310 },
311 ],
312 cx,
313 );
314 });
315 self.outer_split.update(cx, |sc, cx| {
316 sc.set_direction(LayoutDirection::Vertical, cx);
317 sc.set_children(
318 vec![
319 ChildSlot {
320 id: NodeId::new("tts-main"),
321 flex: flex_outer[0],
322 label: None,
323 view: main_view,
324 },
325 ChildSlot {
326 id: NodeId::new("tts-panel"),
327 flex: flex_outer[1],
328 label: None,
329 view: panel_view,
330 },
331 ],
332 cx,
333 );
334 });
335 }
336 PanelDock::Right => {
337 let flex = load_split_flex_n(
338 &self.store,
339 split_key_outer(dock),
340 &[1.0, 4.0, 1.5],
341 );
342 self.outer_split.update(cx, |sc, cx| {
343 sc.set_direction(LayoutDirection::Horizontal, cx);
344 sc.set_children(
345 vec![
346 ChildSlot {
347 id: NodeId::new("tts-tree"),
348 flex: flex[0],
349 label: None,
350 view: tree_view,
351 },
352 ChildSlot {
353 id: NodeId::new("tts-canvas"),
354 flex: flex[1],
355 label: None,
356 view: canvas_view,
357 },
358 ChildSlot {
359 id: NodeId::new("tts-panel"),
360 flex: flex[2],
361 label: None,
362 view: panel_view,
363 },
364 ],
365 cx,
366 );
367 });
368 }
369 PanelDock::Left => {
370 let flex = load_split_flex_n(
371 &self.store,
372 split_key_outer(dock),
373 &[1.5, 1.0, 4.0],
374 );
375 self.outer_split.update(cx, |sc, cx| {
376 sc.set_direction(LayoutDirection::Horizontal, cx);
377 sc.set_children(
378 vec![
379 ChildSlot {
380 id: NodeId::new("tts-panel"),
381 flex: flex[0],
382 label: None,
383 view: panel_view,
384 },
385 ChildSlot {
386 id: NodeId::new("tts-tree"),
387 flex: flex[1],
388 label: None,
389 view: tree_view,
390 },
391 ChildSlot {
392 id: NodeId::new("tts-canvas"),
393 flex: flex[2],
394 label: None,
395 view: canvas_view,
396 },
397 ],
398 cx,
399 );
400 });
401 }
402 }
403
404 if let Err(e) = self.store.set_setting("layout.panel_dock", dock.as_setting()) {
405 eprintln!("[shell] persist panel_dock: {}", e);
406 }
407 cx.notify();
408 }
409
410 /// Loop que cada 30s pregunta al broker la lista de sessions
411 /// activas y actualiza `brahman_status`. El cómputo bloqueante
412 /// (list_sessions_blocking abre su propio tokio runtime) corre en
413 /// el background_executor — no bloquea el UI thread. Cuando llega
414 /// el resultado, el `this.update` dispara cx.notify para repintar
415 /// el badge del header.
416 fn spawn_brahman_status_loop(&self, cx: &mut Context<Self>) {
417 cx.spawn(async move |this, cx| {
418 loop {
419 let result = cx
420 .background_executor()
421 .spawn(async {
422 brahman_sidecar::list_sessions_blocking("cosmobiologia-observer")
423 })
424 .await;
425 let _ = this.update(cx, |this, cx| {
426 this.brahman_status = match result {
427 Ok(list) => BrahmanStatus::Connected {
428 session_count: list.entries.len(),
429 },
430 Err(e) => BrahmanStatus::Offline {
431 reason: format!("{:?}", e),
432 },
433 };
434 cx.notify();
435 });
436 let timer = cx
437 .background_executor()
438 .timer(std::time::Duration::from_secs(30));
439 timer.await;
440 }
441 })
442 .detach();
443 }
444
445 /// Recarga la lista de opciones para los `Control::ChartPicker` y
446 /// la pushea al panel. Llamado al boot + tras cada
447 /// `TreeEvent::HierarchyChanged`.
448 fn refresh_chart_options(&self, cx: &mut Context<Self>) {
449 let charts = self.store.list_all_charts().unwrap_or_default();
450 let options: Vec<ChartOption> = charts
451 .into_iter()
452 .map(|c| ChartOption {
453 id: c.id.to_string(),
454 label: format!("{} — {}", c.label, format_birth_brief(&c.birth_data)),
455 })
456 .collect();
457 self.panel
458 .update(cx, |p, cx| p.set_chart_options(options, cx));
459 }
460
461 fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) {
462 let selection = match ev {
463 TreeEvent::Selected(s) => s,
464 TreeEvent::Opened(s) => s,
465 TreeEvent::HierarchyChanged => {
466 // La jerarquía cambió (alta/baja de cartas) — refrescar
467 // las opciones del picker para que aparezcan / desaparezcan
468 // en el dropdown.
469 self.refresh_chart_options(cx);
470 cx.notify();
471 return;
472 }
473 TreeEvent::NewFreeChartRequested => {
474 let id = FreeChartId(format!("free-{}", self.next_free_id));
475 self.next_free_id += 1;
476 // Default: misma data que "Cielo ahora" pero con label
477 // distinto. El usuario edita después con el editor
478 // inline (fase B).
479 let mut chart = build_present_sky_chart();
480 chart.label = format!("Carta libre #{}", self.next_free_id);
481 self.free_charts.insert(id.clone(), chart);
482 self.push_free_charts_to_tree(cx);
483 self.apply_selection(TreeSelection::FreeChart(id), cx);
484 return;
485 }
486 TreeEvent::SaveFreeChartRequested(_id) => {
487 // El menú del tree abre el modal directamente; este
488 // evento queda como hook por si una tecla u otra
489 // UI quiere disparar el flujo sin pasar por el menú.
490 return;
491 }
492 TreeEvent::FreeChartSaveConfirmed {
493 source_id,
494 chart_name,
495 contact,
496 new_contact_name,
497 } => {
498 self.persist_free_chart(
499 source_id.clone(),
500 chart_name.clone(),
501 contact.clone(),
502 new_contact_name.clone(),
503 cx,
504 );
505 return;
506 }
507 TreeEvent::FreeChartEditConfirmed {
508 source_id,
509 birth_data,
510 label,
511 } => {
512 if let Some(chart) = self.free_charts.get_mut(source_id) {
513 chart.birth_data = birth_data.clone();
514 chart.label = label.clone();
515 }
516 self.push_free_charts_to_tree(cx);
517 // Si la carta editada era la activa, re-render.
518 if let Some(current) = self.current_chart.as_mut() {
519 // Heurística: comparamos por label (ya cambiado al
520 // que pidió el usuario). Si el label de la activa
521 // coincide, era esta carta.
522 if current.label == label.clone()
523 || current.birth_data.subject_name.as_deref() == Some("Cielo")
524 {
525 if let Some(updated) = self.free_charts.get(source_id) {
526 *current = updated.clone();
527 self.render_current(cx);
528 }
529 }
530 }
531 return;
532 }
533 TreeEvent::DeleteFreeChartRequested(id) => {
534 if id.is_sky_now() {
535 return; // no se borra el Cielo
536 }
537 self.free_charts.remove(id);
538 self.push_free_charts_to_tree(cx);
539 // Si la carta borrada era la activa, vuelve al Cielo.
540 if let Some(current) = self.current_chart.as_ref() {
541 if current.label.starts_with(&format!("Carta libre")) {
542 self.apply_selection(
543 TreeSelection::FreeChart(FreeChartId::sky_now()),
544 cx,
545 );
546 }
547 }
548 return;
549 }
550 };
551 self.apply_selection(selection.clone(), cx);
552 }
553
554 /// Persiste una carta libre como `Chart` en la store. El usuario
555 /// eligió en el modal: nombre + contacto destino (existente o
556 /// uno nuevo creado al vuelo). La carta libre se REMUEVE del
557 /// mapa tras el persist exitoso — si quedaba seleccionada,
558 /// volvemos a "Cielo ahora". Si falla la persistencia, la carta
559 /// libre se conserva y logueamos.
560 fn persist_free_chart(
561 &mut self,
562 source_id: FreeChartId,
563 chart_name: String,
564 contact: Option<ContactId>,
565 new_contact_name: Option<String>,
566 cx: &mut Context<Self>,
567 ) {
568 let Some(chart) = self.free_charts.get(&source_id).cloned() else {
569 return;
570 };
571 // 1) Resolver el contact destino (existente o crear nuevo).
572 let contact_id = match (contact, new_contact_name) {
573 (Some(cid), _) => cid,
574 (None, Some(name)) => match self.store.create_contact(None, &name, None) {
575 Ok(c) => c.id,
576 Err(e) => {
577 eprintln!("[shell] create_contact al guardar libre: {}", e);
578 return;
579 }
580 },
581 (None, None) => {
582 eprintln!("[shell] persist_free_chart sin contacto ni nombre nuevo");
583 return;
584 }
585 };
586 // 2) Crear la carta.
587 match self.store.create_chart(
588 contact_id,
589 chart.kind,
590 &chart_name,
591 &chart.birth_data,
592 &chart.config,
593 chart.related_chart_id,
594 ) {
595 Ok(_) => {
596 eprintln!(
597 "[shell] carta libre {:?} guardada como '{}' bajo contacto {}",
598 source_id, chart_name, contact_id
599 );
600 }
601 Err(e) => {
602 eprintln!("[shell] create_chart al guardar libre: {}", e);
603 return;
604 }
605 }
606 // 3) Sky-now se conserva (siempre es); las demás se quitan
607 // del mapa libre. Si era la activa, volver al Cielo.
608 if !source_id.is_sky_now() {
609 self.free_charts.remove(&source_id);
610 self.push_free_charts_to_tree(cx);
611 // Si la activa era esta libre, regresar al Cielo.
612 self.apply_selection(
613 TreeSelection::FreeChart(FreeChartId::sky_now()),
614 cx,
615 );
616 }
617 self.refresh_chart_options(cx);
618 }
619
620 fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
621 match sel {
622 TreeSelection::Chart(id) => {
623 let chart = match self.store.get_chart(id) {
624 Ok(c) => c,
625 Err(e) => {
626 eprintln!("[shell] get_chart {}: {}", id, e);
627 return;
628 }
629 };
630 let age = current_age_years(&chart.birth_data);
631 self.current_chart = Some(chart.clone());
632 self.current_offset_minutes = 0;
633 // 1) Defaults frescos para esta carta: edad objetivo =
634 // edad actual. Estos quedan en module_configs como
635 // valor base si el usuario nunca tocó el slider.
636 self.module_configs.clear();
637 for module_id in ["progression", "solar_arc", "planetary_return"] {
638 let entry = self
639 .module_configs
640 .entry(module_id.into())
641 .or_insert_with(|| serde_json::json!({}));
642 if let serde_json::Value::Object(map) = entry {
643 map.insert("target_age_years".into(), serde_json::json!(age));
644 }
645 }
646 // El módulo planetary_return además necesita un body
647 // por default — el shell elige "sun" si el usuario no
648 // tocó el Select. La persistencia luego puede pisar
649 // este valor.
650 if let Some(serde_json::Value::Object(map)) =
651 self.module_configs.get_mut("planetary_return")
652 {
653 map.entry(String::from("body"))
654 .or_insert(serde_json::json!("sun"));
655 }
656 // 2) Sobreescribir con lo que el usuario persistió la
657 // última vez para esta carta (SQLite `module_state`).
658 self.load_persisted_module_states(chart.id);
659 // 3) Sincronizar panel: active_kind + toggles/sliders.
660 self.panel.update(cx, |p, cx| {
661 p.set_active_kind(Some(chart.kind), cx);
662 });
663 self.sync_panel_from_configs(cx);
664 self.render_current(cx);
665 }
666 TreeSelection::Contact(id) => {
667 self.current_chart = None;
668 self.current_offset_minutes = 0;
669 let charts = self.store.list_charts(id).unwrap_or_default();
670 let items: Vec<ThumbnailItem> = charts
671 .into_iter()
672 .map(|c| ThumbnailItem {
673 chart_id: c.id,
674 label: SharedString::from(c.label),
675 subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
676 preview: None,
677 })
678 .collect();
679 self.canvas.update(cx, |c, cx| {
680 c.set_mode(
681 CanvasMode::Thumbnails {
682 scope: ThumbnailScope::Contact(id),
683 items,
684 },
685 cx,
686 );
687 });
688 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
689 }
690 TreeSelection::Group(id) => {
691 self.current_chart = None;
692 self.current_offset_minutes = 0;
693 let charts = self.store.charts_under_group(id).unwrap_or_default();
694 let items: Vec<ThumbnailItem> = charts
695 .into_iter()
696 .map(|c| ThumbnailItem {
697 chart_id: c.id,
698 label: SharedString::from(c.label),
699 subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
700 preview: None,
701 })
702 .collect();
703 self.canvas.update(cx, |c, cx| {
704 c.set_mode(
705 CanvasMode::Thumbnails {
706 scope: ThumbnailScope::Group(id),
707 items,
708 },
709 cx,
710 );
711 });
712 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
713 }
714 TreeSelection::FreeChart(id) => {
715 // Si es "Cielo ahora", refrescamos el reloj antes de
716 // renderizar — el usuario espera ver el momento actual,
717 // no el momento al que se cargó la carta.
718 if id.is_sky_now() {
719 self.free_charts
720 .insert(FreeChartId::sky_now(), build_present_sky_chart());
721 self.push_free_charts_to_tree(cx);
722 }
723 let Some(chart) = self.free_charts.get(&id).cloned() else {
724 eprintln!("[shell] free chart {:?} no encontrada", id);
725 return;
726 };
727 self.current_chart = Some(chart);
728 self.current_offset_minutes = 0;
729 self.module_configs.clear();
730 self.panel
731 .update(cx, |p, cx| p.set_active_kind(Some(ChartKind::Natal), cx));
732 self.sync_panel_from_configs(cx);
733 self.render_current(cx);
734 }
735 TreeSelection::FreeChartsRoot => {
736 // Grilla de thumbnails de las cartas libres. Como
737 // ThumbnailItem requiere ChartId, usamos `default()`
738 // para las libres — el canvas las muestra como
739 // entradas no-clickeables (eso está OK; el usuario
740 // hace click en el row del tree para seleccionar).
741 self.current_chart = None;
742 self.current_offset_minutes = 0;
743 let items: Vec<ThumbnailItem> = self
744 .free_charts
745 .values()
746 .map(|c| ThumbnailItem {
747 chart_id: c.id,
748 label: SharedString::from(c.label.clone()),
749 subtitle: Some(SharedString::from("libre".to_string())),
750 preview: None,
751 })
752 .collect();
753 self.canvas.update(cx, |c, cx| {
754 c.set_mode(
755 CanvasMode::Thumbnails {
756 scope: ThumbnailScope::Group(Default::default()),
757 items,
758 },
759 cx,
760 );
761 });
762 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
763 }
764 TreeSelection::GeneralRoot => {
765 // "General" agrupa los contactos sin grupo padre. El
766 // canvas muestra thumbnails de TODAS las cartas de
767 // esos contactos.
768 self.current_chart = None;
769 self.current_offset_minutes = 0;
770 let mut items: Vec<ThumbnailItem> = Vec::new();
771 if let Ok(contacts) = self.store.list_contacts(None) {
772 for ct in contacts {
773 if let Ok(charts) = self.store.list_charts(ct.id) {
774 for c in charts {
775 items.push(ThumbnailItem {
776 chart_id: c.id,
777 label: SharedString::from(c.label),
778 subtitle: Some(SharedString::from(format!(
779 "{} · {:?}",
780 ct.name, c.kind
781 ))),
782 preview: None,
783 });
784 }
785 }
786 }
787 }
788 // Reusamos el scope Group con un id sentinela "vacío":
789 // como GeneralRoot no es un Group real, dejamos que el
790 // canvas pinte la grilla con el set de items y nada
791 // más — el `scope` no se usa para nada que requiera
792 // el id.
793 self.canvas.update(cx, |c, cx| {
794 c.set_mode(
795 CanvasMode::Thumbnails {
796 scope: ThumbnailScope::Group(Default::default()),
797 items,
798 },
799 cx,
800 );
801 });
802 self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
803 }
804 }
805 }
806
807 /// Deriva los `PipelineRequest` activos a partir del `module_configs`.
808 fn build_requests(&self) -> Vec<PipelineRequest> {
809 let mut requests = Vec::new();
810 if module_enabled(&self.module_configs, "transit") {
811 requests.push(PipelineRequest::Transit);
812 }
813 if module_enabled(&self.module_configs, "progression") {
814 let age = self.module_age_or_current("progression");
815 requests.push(PipelineRequest::SecondaryProgression {
816 target_age_years: age,
817 });
818 }
819 if module_enabled(&self.module_configs, "solar_arc") {
820 let age = self.module_age_or_current("solar_arc");
821 requests.push(PipelineRequest::SolarArc {
822 target_age_years: age,
823 });
824 }
825 if module_enabled(&self.module_configs, "synastry") {
826 if let Some(partner) = self.resolve_synastry_partner() {
827 requests.push(PipelineRequest::Synastry {
828 partner_chart: Box::new(partner),
829 });
830 }
831 }
832 if module_enabled(&self.module_configs, "midpoints") {
833 requests.push(PipelineRequest::Midpoints);
834 }
835 if module_enabled(&self.module_configs, "uranian") {
836 requests.push(PipelineRequest::Uranian);
837 }
838 if module_enabled(&self.module_configs, "lots") {
839 requests.push(PipelineRequest::Lots);
840 }
841 if module_enabled(&self.module_configs, "fixed_stars") {
842 requests.push(PipelineRequest::FixedStars);
843 }
844 if module_enabled(&self.module_configs, "topocentric") {
845 requests.push(PipelineRequest::Topocentric);
846 }
847 if module_enabled(&self.module_configs, "primary_directions") {
848 let age = self.module_age_or_current("primary_directions");
849 let key = self
850 .module_configs
851 .get("primary_directions")
852 .and_then(|c| c.get("key"))
853 .and_then(|v| v.as_str())
854 .unwrap_or("naibod")
855 .to_string();
856 requests.push(PipelineRequest::PrimaryDirections {
857 target_age_years: age,
858 key,
859 });
860 }
861 if module_enabled(&self.module_configs, "composite") {
862 if let Some(partner) = self.resolve_composite_partner() {
863 requests.push(PipelineRequest::Composite {
864 partner_chart: Box::new(partner),
865 });
866 }
867 }
868 if module_enabled(&self.module_configs, "planetary_return") {
869 let age = self.module_age_or_current("planetary_return");
870 let body = self
871 .module_configs
872 .get("planetary_return")
873 .and_then(|c| c.get("body"))
874 .and_then(|v| v.as_str())
875 .unwrap_or("sun")
876 .to_string();
877 let shift_days = self
878 .module_configs
879 .get("planetary_return")
880 .and_then(|c| c.get("shift_days"))
881 .and_then(|v| v.as_f64())
882 .map(|v| v as i64)
883 .unwrap_or(0);
884 requests.push(PipelineRequest::PlanetaryReturn {
885 body,
886 target_age_years: age,
887 shift_days,
888 });
889 }
890 requests
891 }
892
893 /// Resuelve la carta partner para sinastría: 1) si el picker tiene
894 /// un `partner_chart_id` válido en `module_configs`, lo usa; 2)
895 /// si no, cae al automático (primera carta hermana del contacto
896 /// actual). `None` si nada matchea — el request se salta.
897 fn resolve_synastry_partner(&self) -> Option<Chart> {
898 let manual = self
899 .module_configs
900 .get("synastry")
901 .and_then(|c| c.get("partner_chart_id"))
902 .and_then(|v| v.as_str())
903 .and_then(|s| s.parse::<cosmobiologia_model::ChartId>().ok())
904 .and_then(|id| self.store.get_chart(id).ok());
905 manual.or_else(|| self.find_synastry_partner_auto())
906 }
907
908 fn find_synastry_partner_auto(&self) -> Option<Chart> {
909 let current = self.current_chart.as_ref()?;
910 let siblings = self.store.list_charts(current.contact_id).ok()?;
911 siblings.into_iter().find(|c| c.id != current.id)
912 }
913
914 /// Resuelve el partner para Composite — mismo patrón que Synastry:
915 /// 1) lee module_configs["composite"]["partner_chart_id"] y resuelve
916 /// el chart; 2) fallback al primer hermano del contacto actual.
917 fn resolve_composite_partner(&self) -> Option<Chart> {
918 let manual = self
919 .module_configs
920 .get("composite")
921 .and_then(|c| c.get("partner_chart_id"))
922 .and_then(|v| v.as_str())
923 .and_then(|s| s.parse::<cosmobiologia_model::ChartId>().ok())
924 .and_then(|id| self.store.get_chart(id).ok());
925 manual.or_else(|| self.find_synastry_partner_auto())
926 }
927
928 /// Deriva las `NatalOptions` activas a partir del `module_configs["natal"]`.
929 /// Si la entry no existe, devuelve defaults (majors=true, minors=false,
930 /// multiplier=1.0).
931 fn build_natal_options(&self) -> NatalOptions {
932 let cfg = self.module_configs.get("natal");
933 let read_bool = |key: &str, default: bool| -> bool {
934 cfg.and_then(|c| c.get(key))
935 .and_then(|v| v.as_bool())
936 .unwrap_or(default)
937 };
938 let read_f64 = |key: &str, default: f64| -> f64 {
939 cfg.and_then(|c| c.get(key))
940 .and_then(|v| v.as_f64())
941 .unwrap_or(default)
942 };
943 NatalOptions {
944 show_majors: read_bool("aspect_majors", true),
945 show_minors: read_bool("aspect_minors", false),
946 orb_multiplier: read_f64("orb_multiplier", 1.0),
947 show_dignities: read_bool("show_dignities", false),
948 harmonic: read_f64("harmonic", 1.0).round().clamp(1.0, 64.0) as u32,
949 }
950 }
951
952 /// Lee `module_state` desde SQLite para la carta dada y los mergea
953 /// con los defaults ya cargados en `module_configs`. Los valores
954 /// persistidos ganan sobre los defaults.
955 fn load_persisted_module_states(&mut self, chart_id: ChartId) {
956 let states = match self.store.list_module_states(chart_id) {
957 Ok(s) => s,
958 Err(e) => {
959 eprintln!("[shell] list_module_states {}: {}", chart_id, e);
960 return;
961 }
962 };
963 for st in states {
964 // Re-mergeamos `enabled` (columna separada en SQL) dentro
965 // del JSON config, así el resto del shell sigue leyendo
966 // todo desde una única estructura.
967 let mut combined = match st.config {
968 serde_json::Value::Object(m) => serde_json::Value::Object(m),
969 _ => serde_json::json!({}),
970 };
971 if let serde_json::Value::Object(map) = &mut combined {
972 map.insert("enabled".into(), serde_json::Value::Bool(st.enabled));
973 }
974 // Mergear sobre defaults previos (no sobreescribir si la
975 // entrada nueva no trae un campo).
976 match self.module_configs.entry(st.module_id) {
977 std::collections::hash_map::Entry::Vacant(v) => {
978 v.insert(combined);
979 }
980 std::collections::hash_map::Entry::Occupied(mut o) => {
981 if let (serde_json::Value::Object(dst), serde_json::Value::Object(src)) =
982 (o.get_mut(), &combined)
983 {
984 for (k, v) in src {
985 dst.insert(k.clone(), v.clone());
986 }
987 } else {
988 o.insert(combined);
989 }
990 }
991 }
992 }
993 }
994
995 /// Pushea cada toggle/slider/picker del `module_configs` al panel
996 /// para que la UI refleje el estado persistido al cargar una carta.
997 fn sync_panel_from_configs(&mut self, cx: &mut Context<Self>) {
998 let snapshot: Vec<(String, serde_json::Value)> = self
999 .module_configs
1000 .iter()
1001 .map(|(k, v)| (k.clone(), v.clone()))
1002 .collect();
1003 self.panel.update(cx, |p, cx| {
1004 for (module_id, config) in &snapshot {
1005 if let serde_json::Value::Object(map) = config {
1006 for (key, value) in map {
1007 if let Some(b) = value.as_bool() {
1008 p.set_toggle(module_id, key, b, cx);
1009 } else if let Some(f) = value.as_f64() {
1010 p.set_slider(module_id, key, f, cx);
1011 } else if let Some(s) = value.as_str() {
1012 p.set_string(module_id, key, Some(s.to_string()), cx);
1013 } else if value.is_null() {
1014 p.set_string(module_id, key, None, cx);
1015 }
1016 }
1017 }
1018 }
1019 });
1020 }
1021
1022 /// Persiste el estado actual de un módulo a SQLite. Extrae
1023 /// `enabled` del JSON y lo guarda en la columna dedicada; el resto
1024 /// va al `config_json`.
1025 fn persist_module(&self, module_id: &str) {
1026 let Some(chart) = self.current_chart.as_ref() else {
1027 return;
1028 };
1029 let Some(config) = self.module_configs.get(module_id) else {
1030 return;
1031 };
1032 let enabled = config
1033 .get("enabled")
1034 .and_then(|v| v.as_bool())
1035 .unwrap_or(false);
1036 let mut clean = config.clone();
1037 if let serde_json::Value::Object(map) = &mut clean {
1038 map.remove("enabled");
1039 }
1040 let state = ModuleState {
1041 chart_id: chart.id,
1042 module_id: module_id.to_string(),
1043 enabled,
1044 config: clean,
1045 };
1046 if let Err(e) = self.store.upsert_module_state(&state) {
1047 eprintln!("[shell] upsert_module_state {}: {}", module_id, e);
1048 }
1049 }
1050
1051 /// Lee `target_age_years` del módulo o cae a la edad actual del
1052 /// sujeto (calculada desde la fecha de nacimiento y el reloj).
1053 fn module_age_or_current(&self, module_id: &str) -> f64 {
1054 self.module_configs
1055 .get(module_id)
1056 .and_then(|c| c.get("target_age_years"))
1057 .and_then(|v| v.as_f64())
1058 .unwrap_or_else(|| {
1059 self.current_chart
1060 .as_ref()
1061 .map(|c| current_age_years(&c.birth_data))
1062 .unwrap_or(0.0)
1063 })
1064 }
1065
1066 fn render_current(&mut self, cx: &mut Context<Self>) {
1067 let Some(chart) = self.current_chart.as_ref() else {
1068 return;
1069 };
1070 // Snapshot de inputs para mover al background. La sesión
1071 // VSOP2013 vive en un static `OnceLock` adentro del bridge, así
1072 // que es compartible read-only entre threads sin que ningún
1073 // dato cruce más allá del Chart clonado + requests/options.
1074 let chart = chart.clone();
1075 let offset = self.current_offset_minutes;
1076 let requests = self.build_requests();
1077 let natal_options = self.build_natal_options();
1078 self.render_seq = self.render_seq.wrapping_add(1);
1079 let my_seq = self.render_seq;
1080
1081 cx.spawn(async move |this, cx| {
1082 // El compute corre en el background_executor — no bloquea
1083 // el UI thread. Para una rueda completa con varios overlays
1084 // puede tomar 100-200ms; sin esto, los drags del slider se
1085 // sentirían atorados.
1086 let chart_for_bg = chart.clone();
1087 let requests_for_bg = requests.clone();
1088 let opts_for_bg = natal_options.clone();
1089 let result = cx
1090 .background_executor()
1091 .spawn(async move {
1092 compose_with_options(&chart_for_bg, offset, &requests_for_bg, &opts_for_bg)
1093 })
1094 .await;
1095
1096 let _ = this.update(cx, |this, cx| {
1097 // Descartar si llegó un render más nuevo en el medio.
1098 // Sin este check, durante un drag rápido un compute
1099 // viejo podría sobrescribir el más reciente.
1100 if this.render_seq != my_seq {
1101 return;
1102 }
1103 match result {
1104 Ok(render) => {
1105 this.canvas.update(cx, |c, cx| {
1106 c.set_mode(
1107 CanvasMode::Wheel {
1108 render: Box::new(render),
1109 },
1110 cx,
1111 );
1112 });
1113 }
1114 Err(e) => {
1115 eprintln!(
1116 "[shell] compose {} (+{}min, {} reqs): {}",
1117 chart.id,
1118 offset,
1119 requests.len(),
1120 e
1121 );
1122 }
1123 }
1124 });
1125 })
1126 .detach();
1127 }
1128
1129 fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context<Self>) {
1130 match ev {
1131 CanvasEvent::TimeOffsetChanged(off) => {
1132 self.current_offset_minutes = *off;
1133 if self.current_chart.is_some() {
1134 self.render_current(cx);
1135 }
1136 }
1137 CanvasEvent::LayerVisibilityChanged { kind, visible } => {
1138 // El toggle de Outer ([T]) no es visibility puro: dispara
1139 // un pipeline distinto. Lo traducimos a un cambio en
1140 // module_configs["transit"]["enabled"] + re-render.
1141 if matches!(kind, LayerKind::Outer) {
1142 set_module_enabled(&mut self.module_configs, "transit", *visible);
1143 self.persist_module("transit");
1144 self.panel.update(cx, |p, cx| {
1145 p.set_toggle("transit", "enabled", *visible, cx)
1146 });
1147 self.render_current(cx);
1148 return;
1149 }
1150 // El resto son visibility puros sobre el canvas. Sync el
1151 // panel para que el toggle visual coincida con la hotkey.
1152 let key = match kind {
1153 LayerKind::SignDial => "show_sign_dial",
1154 LayerKind::Houses => "show_houses",
1155 LayerKind::Aspects => "show_aspects",
1156 LayerKind::Bodies => "show_bodies",
1157 _ => return,
1158 };
1159 self.panel
1160 .update(cx, |p, cx| p.set_toggle("natal", key, *visible, cx));
1161 }
1162 CanvasEvent::ShowCoordsChanged(visible) => {
1163 // Sync el toggle del panel para que coincida con la
1164 // hotkey C. No persist — los coord labels son una
1165 // preferencia visual, no parte del module_state.
1166 self.panel.update(cx, |p, cx| {
1167 p.set_toggle("natal", "show_coords", *visible, cx)
1168 });
1169 }
1170 CanvasEvent::ChartRequested(_) => {
1171 // Fase 7: doble click sobre un thumbnail abre la carta.
1172 }
1173 CanvasEvent::ExportSvgRequested => {
1174 self.export_current_to_svg();
1175 }
1176 CanvasEvent::GrAgeDelta(delta) => {
1177 self.scrub_gr_age(*delta, cx);
1178 }
1179 CanvasEvent::HarmonicSelected(n) => {
1180 self.select_harmonic(*n, cx);
1181 }
1182 }
1183 }
1184
1185 /// Fija el armónico de la carta natal (clic en una barra del
1186 /// espectro): escribe `harmonic` en `module_configs["natal"]`,
1187 /// sincroniza el slider del panel y recompone.
1188 fn select_harmonic(&mut self, n: u32, cx: &mut Context<Self>) {
1189 let entry = self
1190 .module_configs
1191 .entry("natal".into())
1192 .or_insert_with(|| serde_json::json!({}));
1193 if let serde_json::Value::Object(map) = entry {
1194 map.insert("harmonic".into(), serde_json::json!(n));
1195 }
1196 self.panel.update(cx, |p, cx| {
1197 p.set_slider("natal", "harmonic", n as f64, cx)
1198 });
1199 self.persist_module("natal");
1200 self.render_current(cx);
1201 }
1202
1203 /// Scrubbing en vivo de la edad GR vía jog-dial. Acumula `delta`
1204 /// sobre `target_age_years` del módulo `primary_directions`,
1205 /// clampa a [0,120], sincroniza el slider del panel y recompone.
1206 fn scrub_gr_age(&mut self, delta_years: f64, cx: &mut Context<Self>) {
1207 if !module_enabled(&self.module_configs, "primary_directions") {
1208 return;
1209 }
1210 let current = self.module_age_or_current("primary_directions");
1211 let next = (current + delta_years).clamp(0.0, 120.0);
1212 if (next - current).abs() < 1e-6 {
1213 return;
1214 }
1215 let entry = self
1216 .module_configs
1217 .entry("primary_directions".into())
1218 .or_insert_with(|| serde_json::json!({}));
1219 if let serde_json::Value::Object(map) = entry {
1220 map.insert("target_age_years".into(), serde_json::json!(next));
1221 }
1222 self.panel.update(cx, |p, cx| {
1223 p.set_slider("primary_directions", "target_age_years", next, cx)
1224 });
1225 self.persist_module("primary_directions");
1226 self.render_current(cx);
1227 }
1228
1229 /// Recompone la carta actual + escribe el SVG a un archivo en
1230 /// `$XDG_DATA_HOME/cosmobiologia/exports/<label>_<short_id>.svg`.
1231 /// Logea la ruta a stderr — futuro: file save dialog GPUI.
1232 fn export_current_to_svg(&self) {
1233 let Some(chart) = self.current_chart.as_ref() else {
1234 eprintln!("[shell] export svg: sin carta activa");
1235 return;
1236 };
1237 let requests = self.build_requests();
1238 let natal_options = self.build_natal_options();
1239 let render = match compose_with_options(
1240 chart,
1241 self.current_offset_minutes,
1242 &requests,
1243 &natal_options,
1244 ) {
1245 Ok(r) => r,
1246 Err(e) => {
1247 eprintln!("[shell] export svg compose: {}", e);
1248 return;
1249 }
1250 };
1251 let svg = svg_export::render_to_svg(&render);
1252 let dir = directories::ProjectDirs::from("net", "gioser", "cosmobiologia")
1253 .map(|d| d.data_dir().join("exports"))
1254 .unwrap_or_else(|| std::path::PathBuf::from("."));
1255 if let Err(e) = std::fs::create_dir_all(&dir) {
1256 eprintln!("[shell] mkdir {:?}: {}", dir, e);
1257 return;
1258 }
1259 let safe_label: String = chart
1260 .label
1261 .chars()
1262 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1263 .collect();
1264 let short = format!("{}", chart.id).chars().take(8).collect::<String>();
1265 let path = dir.join(format!("{}_{}.svg", safe_label, short));
1266 if let Err(e) = std::fs::write(&path, svg) {
1267 eprintln!("[shell] write {:?}: {}", path, e);
1268 } else {
1269 eprintln!("[shell] SVG exportado → {}", path.display());
1270 }
1271 }
1272
1273 fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
1274 match ev {
1275 PanelEvent::ControlChanged {
1276 module_id, key, value,
1277 } => {
1278 let bool_val = value.as_bool().unwrap_or(true);
1279 if module_id == "natal" {
1280 // Distinguimos: show_* = visibility (no recompose),
1281 // aspect_*/orb_* = filtros de engine (recompose +
1282 // persist).
1283 let kind = match key.as_str() {
1284 "show_sign_dial" => Some(LayerKind::SignDial),
1285 "show_houses" => Some(LayerKind::Houses),
1286 "show_aspects" => Some(LayerKind::Aspects),
1287 "show_bodies" => Some(LayerKind::Bodies),
1288 _ => None,
1289 };
1290 if let Some(k) = kind {
1291 self.canvas
1292 .update(cx, |c, cx| c.set_layer_visible(k, bool_val, cx));
1293 } else if key == "show_coords" {
1294 // Coord labels viven en el canvas (no son una
1295 // capa pintada como otros show_*). Sync sin
1296 // recompose ni persist en module_state.
1297 self.canvas
1298 .update(cx, |c, cx| c.set_show_coords(bool_val, cx));
1299 } else {
1300 // Filtros: actualizar module_configs + recompose.
1301 let entry = self
1302 .module_configs
1303 .entry("natal".into())
1304 .or_insert_with(|| serde_json::json!({}));
1305 if let serde_json::Value::Object(map) = entry {
1306 map.insert(key.clone(), value.clone());
1307 }
1308 self.persist_module("natal");
1309 self.render_current(cx);
1310 }
1311 } else {
1312 // Cualquier otro módulo: actualizamos su config y
1313 // recompomemos. La engine vuelve a llamarse con el
1314 // PipelineRequest derivado del nuevo estado.
1315 let entry = self
1316 .module_configs
1317 .entry(module_id.clone())
1318 .or_insert_with(|| serde_json::json!({}));
1319 if let serde_json::Value::Object(map) = entry {
1320 map.insert(key.clone(), value.clone());
1321 }
1322 // Transit, Synastry y Solar Return comparten el
1323 // outer ring del canvas — son mutuamente excluyentes.
1324 // Al prender uno, apagamos los otros + sync panel +
1325 // persist.
1326 if key == "enabled" && bool_val && OUTER_RING_MODULES.contains(&module_id.as_str()) {
1327 for &other in OUTER_RING_MODULES.iter() {
1328 if other != module_id && module_enabled(&self.module_configs, other) {
1329 set_module_enabled(&mut self.module_configs, other, false);
1330 let other_str = other.to_string();
1331 self.panel.update(cx, |p, cx| {
1332 p.set_toggle(&other_str, "enabled", false, cx)
1333 });
1334 self.persist_module(&other_str);
1335 }
1336 }
1337 }
1338 // Sincronizar visualmente el toggle [T] del canvas
1339 // cuando el cambio afecta el outer ring (transit,
1340 // synastry o solar_return).
1341 if OUTER_RING_MODULES.contains(&module_id.as_str()) && key == "enabled" {
1342 self.canvas.update(cx, |c, cx| {
1343 c.set_layer_visible(LayerKind::Outer, bool_val, cx)
1344 });
1345 }
1346 self.persist_module(module_id);
1347 self.render_current(cx);
1348 }
1349 }
1350 PanelEvent::ModuleToggled { .. } => {
1351 // Fase 7: encender/apagar módulos enteros desde un
1352 // header con switch (vs. el toggle por-control de hoy).
1353 }
1354 PanelEvent::Action { module_id, key } => {
1355 self.on_panel_action(module_id.clone(), key.clone(), cx);
1356 }
1357 }
1358 }
1359
1360 /// Click sobre un `Control::Action` del panel. Por ahora maneja:
1361 /// - planetary_return.save_as_free → captura la carta del
1362 /// retorno actual (cuerpo + edad) como FreeChart con sufijo
1363 /// `rs-{N}` / `lunar-{N}` / etc. según el cuerpo elegido.
1364 ///
1365 /// Otros módulos overlay (progression, solar_arc, primary_directions)
1366 /// son extensión natural — TODO.
1367 fn on_panel_action(&mut self, module_id: String, key: String, cx: &mut Context<Self>) {
1368 match key.as_str() {
1369 "save_as_free" => match module_id.as_str() {
1370 "planetary_return" => self.save_planetary_return_as_free(cx),
1371 "transit" => self.save_transit_as_free(cx),
1372 "progression" => self.save_progression_as_free(cx),
1373 // Solar arc y direcciones primarias son transformaciones
1374 // matemáticas puras (no tienen un birth_data real
1375 // equivalente). Guardarlas exigiría un `ChartKind`
1376 // `Derived { source, transform, params }`. TODO.
1377 _ => {}
1378 },
1379 "rectificar" => self.run_rectificacion(cx),
1380 _ => {}
1381 }
1382 }
1383
1384 /// Lanza el rectificador automático (Sistema GR): lee las edades de
1385 /// los eventos conocidos de los sliders del módulo, barre las horas
1386 /// candidatas y escribe el resultado en el campo «Resultado» del
1387 /// panel. El barrido es síncrono — para ±15 min son ~31 cartas.
1388 fn run_rectificacion(&mut self, cx: &mut Context<Self>) {
1389 // Clonamos la carta: `rectificar` necesita `&Chart` y luego
1390 // `panel.update` toma `&mut self` — no pueden solaparse.
1391 let Some(chart) = self.current_chart.clone() else {
1392 return;
1393 };
1394 let cfg = self.module_configs.get("primary_directions");
1395 let read_age = |key: &str| -> f64 {
1396 cfg.and_then(|c| c.get(key))
1397 .and_then(|v| v.as_f64())
1398 .unwrap_or(0.0)
1399 };
1400 // Edades > 0 — una ranura en 0 es "sin usar".
1401 let eventos: Vec<EventoConocido> = ["evento_1", "evento_2", "evento_3"]
1402 .iter()
1403 .map(|k| read_age(k))
1404 .filter(|edad| *edad > 0.5)
1405 .map(|edad| EventoConocido { edad_years: edad })
1406 .collect();
1407 let key_gr = cfg
1408 .and_then(|c| c.get("key"))
1409 .and_then(|v| v.as_str())
1410 .unwrap_or("naibod")
1411 .to_string();
1412
1413 // Ventana ±15 min — dos pasadas (minuto grueso, segundo fino).
1414 match cosmobiologia_engine::rectificar(&chart, &eventos, 15, &key_gr) {
1415 Ok(r) => {
1416 // Offset en segundos → texto «±Xm Ys».
1417 let seg = r.mejor_offset_segundos;
1418 let signo = if seg < 0 { "-" } else { "+" };
1419 let abs = seg.abs();
1420 let resumen = format!(
1421 "{signo}{}m {:02}s · error {:.2}a",
1422 abs / 60,
1423 abs % 60,
1424 r.mejor_puntaje
1425 );
1426 self.panel.update(cx, |p, cx| {
1427 p.set_string("primary_directions", "resultado", Some(resumen), cx)
1428 });
1429 // Publicar el perfil al canvas: dibuja la curva del
1430 // barrido, cuyo valle marca la hora rectificada.
1431 self.canvas
1432 .update(cx, |c, cx| c.set_rectificacion(Some(r), cx));
1433 }
1434 Err(_) => {
1435 self.panel.update(cx, |p, cx| {
1436 p.set_string(
1437 "primary_directions",
1438 "resultado",
1439 Some("define al menos un evento (edad > 0)".to_string()),
1440 cx,
1441 )
1442 });
1443 }
1444 }
1445 }
1446
1447 /// Snapshot del cielo en este instante anclado al lugar del
1448 /// natal. Sufijo `transito-{fecha}`. Útil para guardar "qué
1449 /// estaba pasando ahora en la carta de Pedro".
1450 fn save_transit_as_free(&mut self, cx: &mut Context<Self>) {
1451 let Some(natal) = self.current_chart.as_ref() else {
1452 eprintln!("[shell] save_transit: sin carta activa");
1453 return;
1454 };
1455 if natal.id == ChartId::default() {
1456 eprintln!("[shell] save_transit: la carta activa es libre");
1457 return;
1458 }
1459 match cosmobiologia_engine::compute_transit_chart(natal) {
1460 Ok((birth, instant_label)) => {
1461 let label = format!("{} transito · {}", natal.label, instant_label);
1462 self.insert_derived_free_chart(natal.clone(), birth, label, cx);
1463 }
1464 Err(e) => eprintln!("[shell] compute_transit_chart: {}", e),
1465 }
1466 }
1467
1468 /// Carta progresada secundaria a la edad del slider. La
1469 /// progresada es natal + N días simbólicos; el Chart resultante
1470 /// tiene un birth_data REAL (no simbólico) — cuando se computa
1471 /// como natal de nuevo, da las posiciones progresadas correctas.
1472 /// Sufijo `prog-{N}a`.
1473 fn save_progression_as_free(&mut self, cx: &mut Context<Self>) {
1474 let Some(natal) = self.current_chart.as_ref() else {
1475 eprintln!("[shell] save_progression: sin carta activa");
1476 return;
1477 };
1478 if natal.id == ChartId::default() {
1479 eprintln!("[shell] save_progression: la carta activa es libre");
1480 return;
1481 }
1482 let age = self.module_age_or_current("progression");
1483 match cosmobiologia_engine::compute_progression_chart(natal, age) {
1484 Ok((birth, instant_label)) => {
1485 let label = format!(
1486 "{} prog-{:.0}a · {}",
1487 natal.label, age, instant_label
1488 );
1489 self.insert_derived_free_chart(natal.clone(), birth, label, cx);
1490 }
1491 Err(e) => eprintln!("[shell] compute_progression_chart: {}", e),
1492 }
1493 }
1494
1495 /// Inserta un Chart derivado (transit/progression/PR) como
1496 /// FreeChart conservando contact/kind/related/config del natal
1497 /// original. El id es sintético; el usuario puede después
1498 /// "Guardar como…" para persistirlo bajo un contacto.
1499 fn insert_derived_free_chart(
1500 &mut self,
1501 source_natal: Chart,
1502 new_birth: StoredBirthData,
1503 new_label: String,
1504 cx: &mut Context<Self>,
1505 ) {
1506 let id = FreeChartId(format!("free-{}", self.next_free_id));
1507 self.next_free_id += 1;
1508 let mut chart = source_natal;
1509 chart.id = ChartId::default();
1510 chart.label = new_label;
1511 chart.birth_data = new_birth;
1512 self.free_charts.insert(id.clone(), chart);
1513 self.push_free_charts_to_tree(cx);
1514 self.apply_selection(TreeSelection::FreeChart(id), cx);
1515 }
1516
1517 /// Computa la carta del retorno planetario actual (con cuerpo +
1518 /// edad del módulo) y la inserta como FreeChart. El usuario
1519 /// puede después "Guardar como…" para persistirla bajo un
1520 /// contacto (típicamente el mismo del natal).
1521 fn save_planetary_return_as_free(&mut self, cx: &mut Context<Self>) {
1522 let Some(natal) = self.current_chart.as_ref() else {
1523 eprintln!("[shell] save_planetary_return: sin carta activa");
1524 return;
1525 };
1526 if natal.id == ChartId::default() {
1527 eprintln!("[shell] save_planetary_return: la carta activa es libre, no natal");
1528 return;
1529 }
1530 let cfg = self
1531 .module_configs
1532 .get("planetary_return")
1533 .cloned()
1534 .unwrap_or(serde_json::json!({}));
1535 let body = cfg
1536 .get("body")
1537 .and_then(|v| v.as_str())
1538 .unwrap_or("sun")
1539 .to_string();
1540 let age = self.module_age_or_current("planetary_return");
1541 let shift_days = cfg
1542 .get("shift_days")
1543 .and_then(|v| v.as_f64())
1544 .unwrap_or(0.0) as i64;
1545
1546 // Pedimos al engine la fecha exacta del retorno. La engine
1547 // expone `compute_planetary_return_chart` que devuelve un
1548 // `StoredBirthData` listo para reusar como carta natal.
1549 match cosmobiologia_engine::compute_planetary_return_chart(
1550 natal, &body, age, shift_days,
1551 ) {
1552 Ok((birth, instant_label)) => {
1553 let suffix = match body.as_str() {
1554 "sun" => "rs",
1555 "moon" => "lunar",
1556 other => other,
1557 };
1558 let label = format!(
1559 "{} {}-{:.0}a · {}",
1560 natal.label, suffix, age, instant_label
1561 );
1562 self.insert_derived_free_chart(natal.clone(), birth, label, cx);
1563 }
1564 Err(e) => {
1565 eprintln!("[shell] compute_planetary_return_chart: {}", e);
1566 }
1567 }
1568 }
1569}
1570
1571// =====================================================================
1572// Helpers de module_configs
1573// =====================================================================
1574
1575// OUTER_RING_MODULES viene de cosmobiologia_engine — single source of
1576// truth. Shell y canvas leen del mismo slice.
1577
1578
1579/// Lee `$XDG_DATA_HOME/cosmobiologia/atlas.tsv` si existe y lo parsea
1580/// como atlas de ciudades. Devuelve `None` cuando no hay archivo o
1581/// quedó vacío después del parse — el tree cae al atlas hardcoded.
1582fn load_city_atlas_from_xdg() -> Option<Vec<cosmobiologia_tree::CityPreset>> {
1583 let path = directories::ProjectDirs::from("net", "gioser", "cosmobiologia")
1584 .map(|d| d.data_dir().join("atlas.tsv"))?;
1585 if !path.exists() {
1586 return None;
1587 }
1588 let content = std::fs::read_to_string(&path).ok()?;
1589 let atlas = parse_city_atlas_tsv(&content);
1590 if atlas.is_empty() {
1591 eprintln!(
1592 "[shell] atlas.tsv encontrado en {:?} pero sin filas válidas — fallback a hardcoded",
1593 path
1594 );
1595 return None;
1596 }
1597 eprintln!("[shell] atlas custom cargado: {} ciudades", atlas.len());
1598 Some(atlas)
1599}
1600
1601/// Carta efímera del "Cielo ahora": birth_data = momento actual en
1602/// Greenwich (UTC, lat 51.4769°, lon 0°). El `Chart` se construye al
1603/// vuelo, NO se persiste en la store, y los IDs son `Default` (todo
1604/// ceros) — la carta es un singleton conceptual de la vista, no un
1605/// registro. Los módulos que consultan `current_chart.id` deben
1606/// tolerar este ID sentinela.
1607fn build_present_sky_chart() -> Chart {
1608 use std::time::{SystemTime, UNIX_EPOCH};
1609 let secs = SystemTime::now()
1610 .duration_since(UNIX_EPOCH)
1611 .map(|d| d.as_secs() as i64)
1612 .unwrap_or(0);
1613 let (year, month, day, hour, minute, second) = unix_to_civil_utc(secs);
1614 let birth = StoredBirthData {
1615 year,
1616 month,
1617 day,
1618 hour,
1619 minute,
1620 second: second as f64,
1621 tz_offset_minutes: 0,
1622 // Greenwich Royal Observatory — origen histórico del meridiano
1623 // primario. Lat = 51°28'38"N, Lon = 0°.
1624 latitude_deg: 51.4769,
1625 longitude_deg: 0.0,
1626 altitude_m: 47.0,
1627 time_certainty: Default::default(),
1628 subject_name: Some("Cielo".into()),
1629 birthplace_label: Some("Greenwich (UTC)".into()),
1630 };
1631 Chart {
1632 id: ChartId::default(),
1633 contact_id: ContactId::default(),
1634 kind: ChartKind::Natal,
1635 label: format!(
1636 "Cielo {:04}-{:02}-{:02} {:02}:{:02} UTC",
1637 year, month, day, hour, minute
1638 ),
1639 birth_data: birth,
1640 config: StoredChartConfig::default(),
1641 related_chart_id: None,
1642 created_at_ms: 0,
1643 }
1644}
1645
1646/// Convierte un timestamp Unix (segundos UTC desde 1970-01-01) a
1647/// componentes calendario proleptic-Gregorianos `(year, month, day,
1648/// hour, minute, second)`. Algoritmo de Howard Hinnant
1649/// (`days_to_civil`), exacto en todo el rango representable por i64.
1650fn unix_to_civil_utc(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
1651 let day_seconds: i64 = 86_400;
1652 let z = secs.div_euclid(day_seconds);
1653 let s = secs.rem_euclid(day_seconds);
1654 // Hinnant: shift z para que el "era" empiece en 0000-03-01.
1655 let z = z + 719_468;
1656 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1657 let doe = (z - era * 146_097) as u32; // [0, 146096]
1658 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1659 let y = yoe as i64 + era * 400;
1660 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
1661 let mp = (5 * doy + 2) / 153; // [0, 11]
1662 let day = doy - (153 * mp + 2) / 5 + 1;
1663 let month = if mp < 10 { mp + 3 } else { mp - 9 };
1664 let year = if month <= 2 { (y + 1) as i32 } else { y as i32 };
1665 let hour = (s / 3600) as u32;
1666 let minute = ((s % 3600) / 60) as u32;
1667 let second = (s % 60) as u32;
1668 (year, month, day, hour, minute, second)
1669}
1670
1671/// Etiqueta breve para mostrar al elegir una carta en el picker:
1672/// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
1673fn format_birth_brief(birth: &cosmobiologia_model::StoredBirthData) -> String {
1674 let date = format!("{:04}-{:02}-{:02}", birth.year, birth.month, birth.day);
1675 match &birth.birthplace_label {
1676 Some(p) if !p.is_empty() => format!("{} · {}", date, p),
1677 _ => date,
1678 }
1679}
1680
1681/// Edad en años decimales desde el nacimiento hasta el reloj actual.
1682/// Aproximación: ignora la TZ de nacimiento (no afecta a resolución de
1683/// año) y usa una fracción de año tropical sobre los segundos Unix.
1684fn current_age_years(birth: &cosmobiologia_model::StoredBirthData) -> f64 {
1685 use std::time::{SystemTime, UNIX_EPOCH};
1686 let now_secs = SystemTime::now()
1687 .duration_since(UNIX_EPOCH)
1688 .map(|d| d.as_secs_f64())
1689 .unwrap_or(0.0);
1690 let birth_year_frac = birth.year as f64
1691 + (birth.month.saturating_sub(1) as f64) / 12.0
1692 + (birth.day.saturating_sub(1) as f64) / 365.25;
1693 let now_year_frac = 1970.0 + now_secs / (365.2422 * 86400.0);
1694 (now_year_frac - birth_year_frac).max(0.0)
1695}
1696
1697fn module_enabled(cfgs: &HashMap<String, serde_json::Value>, id: &str) -> bool {
1698 cfgs.get(id)
1699 .and_then(|c| c.get("enabled"))
1700 .and_then(|v| v.as_bool())
1701 .unwrap_or(false)
1702}
1703
1704fn set_module_enabled(
1705 cfgs: &mut HashMap<String, serde_json::Value>,
1706 id: &str,
1707 enabled: bool,
1708) {
1709 let entry = cfgs
1710 .entry(id.to_string())
1711 .or_insert_with(|| serde_json::json!({}));
1712 if let serde_json::Value::Object(map) = entry {
1713 map.insert("enabled".into(), serde_json::Value::Bool(enabled));
1714 }
1715}
1716
1717/// Lee del `settings` el flex de un splitter en formato "f0,f1,..." y
1718/// lo devuelve como `Vec<f32>` con la misma longitud que `defaults`.
1719/// Si no hay nada persistido, faltan campos, o algún flex es ≤0, cae a
1720/// `defaults`. Validación estricta porque un flex 0 colapsa al panel.
1721fn load_split_flex_n(store: &Store, key: &str, defaults: &[f32]) -> Vec<f32> {
1722 let Ok(Some(raw)) = store.get_setting(key) else {
1723 return defaults.to_vec();
1724 };
1725 let parsed: Vec<f32> = raw
1726 .split(',')
1727 .filter_map(|s| s.trim().parse::<f32>().ok())
1728 .collect();
1729 if parsed.len() != defaults.len() || parsed.iter().any(|&f| f <= 0.0) {
1730 return defaults.to_vec();
1731 }
1732 parsed
1733}
1734
1735/// Persiste los flex actuales de un splitter — soporta N children.
1736fn save_split_flex(store: &Store, key: &str, sc: &SplitContainer) {
1737 let children = sc.children();
1738 if children.is_empty() {
1739 return;
1740 }
1741 let payload: String = children
1742 .iter()
1743 .map(|c| format!("{:.4}", c.flex))
1744 .collect::<Vec<_>>()
1745 .join(",");
1746 if let Err(e) = store.set_setting(key, &payload) {
1747 eprintln!("[shell] save_split_flex {}: {}", key, e);
1748 }
1749}
1750
1751/// Key del setting donde se persiste el splitter "outer" (el de mayor
1752/// nivel del árbol). En dock=Bottom guarda (main,panel); en docks
1753/// laterales guarda los flex de las 3 columnas — usamos keys distintas
1754/// para no pisar valores entre layouts.
1755fn split_key_outer(dock: PanelDock) -> &'static str {
1756 match dock {
1757 PanelDock::Bottom => "layout.outer_split",
1758 PanelDock::Right => "layout.dock_right",
1759 PanelDock::Left => "layout.dock_left",
1760 }
1761}
1762
1763/// Key del setting del splitter horizontal interno. Solo se usa cuando
1764/// dock=Bottom (en docks laterales no hay main_split activo).
1765fn split_key_main(dock: PanelDock) -> &'static str {
1766 match dock {
1767 PanelDock::Bottom => "layout.main_split",
1768 // En docks laterales el main_split está dormido — escribir acá
1769 // no hace daño pero tampoco se usa al recargar.
1770 PanelDock::Right => "layout.main_split_right",
1771 PanelDock::Left => "layout.main_split_left",
1772 }
1773}
1774
1775fn load_dock(store: &Store) -> Option<PanelDock> {
1776 let raw = store.get_setting("layout.panel_dock").ok().flatten()?;
1777 PanelDock::from_setting(raw.trim())
1778}
1779
1780impl Shell {
1781 /// Tres botones compactos en el header — uno por dock disponible.
1782 /// El dock activo se marca con `bg=accent`; los demás van planos.
1783 /// Click llama a `apply_dock` que reorganiza splitters y persiste.
1784 fn render_dock_switcher(
1785 &self,
1786 theme: &nahual_theme::Theme,
1787 cx: &mut Context<Self>,
1788 ) -> impl IntoElement {
1789 let mut row = div()
1790 .id("tts-dock-switcher")
1791 .flex()
1792 .flex_row()
1793 .gap(px(2.0))
1794 .px(px(2.0))
1795 .py(px(2.0))
1796 .rounded(px(6.0))
1797 .bg(theme.bg_panel_alt.clone())
1798 .border_1()
1799 .border_color(theme.border);
1800
1801 for (dock, glyph) in [
1802 (PanelDock::Left, "◧"),
1803 (PanelDock::Bottom, "▭"),
1804 (PanelDock::Right, "◨"),
1805 ] {
1806 let active = self.dock == dock;
1807 let fg = if active { theme.fg_text } else { theme.fg_muted };
1808 let id: SharedString = SharedString::from(format!("tts-dock-{}", dock.as_setting()));
1809 let mut btn = div()
1810 .id(gpui::ElementId::from(id))
1811 .w(px(22.0))
1812 .h(px(20.0))
1813 .flex()
1814 .items_center()
1815 .justify_center()
1816 .rounded(px(4.0))
1817 .text_size(px(12.0))
1818 .text_color(fg)
1819 .hover(|s| s.bg(theme.bg_row_hover))
1820 .child(SharedString::from(glyph))
1821 .on_click(cx.listener(move |this, _: &ClickEvent, _w, cx| {
1822 if this.dock != dock {
1823 this.apply_dock(dock, cx);
1824 }
1825 }));
1826 if active {
1827 btn = btn.bg(theme.accent);
1828 }
1829 row = row.child(btn);
1830 }
1831
1832 row
1833 }
1834}
1835
1836impl Render for Shell {
1837 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1838 let theme = Theme::global(cx).clone();
1839
1840 // Badge del estado del broker brahman — pequeña pill con
1841 // color según el estado actual del ping cada-30s.
1842 let (badge_text, badge_color) = match &self.brahman_status {
1843 BrahmanStatus::Pending => ("Brahman · …".to_string(), theme.fg_muted),
1844 BrahmanStatus::Connected { session_count } => (
1845 format!("Brahman ✓ {} sessions", session_count),
1846 theme.accent,
1847 ),
1848 BrahmanStatus::Offline { .. } => {
1849 ("Brahman · offline".to_string(), theme.fg_disabled)
1850 }
1851 };
1852 let brahman_badge = div()
1853 .px(px(8.0))
1854 .py(px(2.0))
1855 .rounded(px(8.0))
1856 .bg(theme.bg_panel_alt.clone())
1857 .border_1()
1858 .border_color(theme.border)
1859 .text_size(px(10.0))
1860 .text_color(badge_color)
1861 .child(SharedString::from(badge_text));
1862
1863 let header = div()
1864 .h(px(34.0))
1865 .px(px(12.0))
1866 .flex()
1867 .flex_row()
1868 .items_center()
1869 .gap(px(10.0))
1870 .border_b_1()
1871 .border_color(theme.border)
1872 .child(
1873 div()
1874 .text_size(px(13.0))
1875 .text_color(theme.fg_text)
1876 .child("☉ Tahuantinsuyu"),
1877 )
1878 .child(
1879 div()
1880 .text_size(px(10.0))
1881 .text_color(theme.fg_muted)
1882 .child("estudio de astrología profesional"),
1883 )
1884 .child(div().flex_grow())
1885 .child(self.render_dock_switcher(&theme, cx))
1886 .child(brahman_badge)
1887 .child(theme_switcher(cx));
1888
1889 let body = div()
1890 .flex_grow()
1891 .w_full()
1892 .child(self.outer_split.clone());
1893
1894 div()
1895 .size_full()
1896 .bg(theme.bg_app.clone())
1897 .flex()
1898 .flex_col()
1899 .child(header)
1900 .child(body)
1901 }
1902}
1903
1904// =====================================================================
1905// Tests de integración del Shell
1906// =====================================================================
1907//
1908// Cubren los caminos que combinan lógica del shell con persistencia y
1909// el bridge real de eternal. Los tests puramente unitarios de cada
1910// crate (engine, store, modules) viven en sus respectivos `tests`
1911// modules; acá testeamos los wiring points del binario.
1912
1913#[cfg(test)]
1914mod tests {
1915 use super::*;
1916 use gpui::TestAppContext;
1917
1918 #[test]
1919 fn unix_to_civil_at_epoch() {
1920 assert_eq!(unix_to_civil_utc(0), (1970, 1, 1, 0, 0, 0));
1921 }
1922
1923 #[test]
1924 fn unix_to_civil_known_dates() {
1925 // 2024-01-01T00:00:00 UTC = 1704067200
1926 assert_eq!(unix_to_civil_utc(1_704_067_200), (2024, 1, 1, 0, 0, 0));
1927 // 2024-02-29T12:34:56 UTC = año bisiesto
1928 let secs = 1_704_067_200 + (31 + 28) * 86_400 + 12 * 3600 + 34 * 60 + 56;
1929 assert_eq!(unix_to_civil_utc(secs), (2024, 2, 29, 12, 34, 56));
1930 }
1931
1932 #[test]
1933 fn unix_to_civil_pre_epoch_wraps_correctly() {
1934 // -1 segundo = 1969-12-31T23:59:59 UTC
1935 assert_eq!(unix_to_civil_utc(-1), (1969, 12, 31, 23, 59, 59));
1936 }
1937
1938 #[test]
1939 fn unix_to_civil_year_2000() {
1940 // 2000-01-01T00:00:00 UTC = 946684800
1941 assert_eq!(unix_to_civil_utc(946_684_800), (2000, 1, 1, 0, 0, 0));
1942 }
1943
1944 fn sample_chart_for(_contact_id: ContactId) -> (StoredBirthData, StoredChartConfig) {
1945 (
1946 StoredBirthData {
1947 year: 1987,
1948 month: 3,
1949 day: 14,
1950 hour: 5,
1951 minute: 22,
1952 second: 0.0,
1953 tz_offset_minutes: -240,
1954 latitude_deg: 10.4806,
1955 longitude_deg: -66.9036,
1956 altitude_m: 900.0,
1957 time_certainty: Default::default(),
1958 subject_name: Some("Sergio".into()),
1959 birthplace_label: Some("Caracas".into()),
1960 },
1961 StoredChartConfig::default(),
1962 )
1963 }
1964
1965 /// Smoke test: el Shell se construye sin panic con una store
1966 /// in-memory. Cubre que las suscripciones cross-widget (tree, panel,
1967 /// canvas, ambos splitters) se cablean sin colisiones y que el
1968 /// background loop del brahman status arranca limpio.
1969 #[gpui::test]
1970 fn shell_constructs_smoke(cx: &mut TestAppContext) {
1971 cx.update(|cx| {
1972 Theme::install_default(cx);
1973 let store = Store::in_memory().expect("in-memory store");
1974 let _shell = cx.new(|cx| Shell::new(store, cx));
1975 // Si llegamos acá sin panic, el cableado funciona.
1976 });
1977 }
1978
1979 /// La selección de una carta vía `apply_selection` (mismo pathway
1980 /// que dispara el TreeEvent) puebla `current_chart` y arranca un
1981 /// compute. El render asíncrono se resuelve después; verificamos
1982 /// solo los efectos sincrónicos: chart cargada y `render_seq`
1983 /// avanzado.
1984 #[gpui::test]
1985 fn select_chart_updates_current(cx: &mut TestAppContext) {
1986 cx.update(|cx| {
1987 Theme::install_default(cx);
1988 let store = Store::in_memory().expect("store");
1989 let group = store.create_group(None, "Test", None).unwrap();
1990 let contact = store
1991 .create_contact(Some(group.id), "Subject", None)
1992 .unwrap();
1993 let (birth, config) = sample_chart_for(contact.id);
1994 let chart = store
1995 .create_chart(contact.id, ChartKind::Natal, "Natal", &birth, &config, None)
1996 .unwrap();
1997
1998 let shell = cx.new(|cx| Shell::new(store, cx));
1999 shell.update(cx, |s, cx| {
2000 s.apply_selection(TreeSelection::Chart(chart.id), cx);
2001 });
2002 shell.read_with(cx, |s, _| {
2003 let cur = s.current_chart.as_ref().expect("current_chart set");
2004 assert_eq!(cur.id, chart.id);
2005 assert_eq!(cur.label, "Natal");
2006 assert!(s.render_seq >= 1, "render_seq debió avanzar al menos a 1");
2007 });
2008 });
2009 }
2010
2011 /// Toggleando un módulo overlay vía `module_configs` directamente
2012 /// (simulando el efecto de un `PanelEvent::ControlChanged`), la
2013 /// función `build_requests` debe reflejar el cambio.
2014 #[gpui::test]
2015 fn module_toggles_produce_requests(cx: &mut TestAppContext) {
2016 cx.update(|cx| {
2017 Theme::install_default(cx);
2018 let store = Store::in_memory().expect("store");
2019 let shell = cx.new(|cx| Shell::new(store, cx));
2020
2021 shell.update(cx, |s, _cx| {
2022 // Sin módulos activos → no hay requests.
2023 assert!(s.build_requests().is_empty());
2024
2025 set_module_enabled(&mut s.module_configs, "transit", true);
2026 set_module_enabled(&mut s.module_configs, "midpoints", true);
2027 set_module_enabled(&mut s.module_configs, "uranian", true);
2028
2029 let reqs = s.build_requests();
2030 assert_eq!(reqs.len(), 3);
2031 assert!(matches!(reqs[0], PipelineRequest::Transit));
2032 assert!(matches!(reqs[1], PipelineRequest::Midpoints));
2033 assert!(matches!(reqs[2], PipelineRequest::Uranian));
2034
2035 set_module_enabled(&mut s.module_configs, "transit", false);
2036 let reqs = s.build_requests();
2037 assert_eq!(reqs.len(), 2);
2038 assert!(!reqs
2039 .iter()
2040 .any(|r| matches!(r, PipelineRequest::Transit)));
2041 });
2042 });
2043 }
2044
2045 /// `NatalOptions` derivados de `module_configs["natal"]` deben
2046 /// respetar orb_multiplier, show_minors y show_dignities cuando los
2047 /// hay, y caer a defaults razonables cuando no.
2048 #[gpui::test]
2049 fn natal_options_read_from_configs(cx: &mut TestAppContext) {
2050 cx.update(|cx| {
2051 Theme::install_default(cx);
2052 let store = Store::in_memory().expect("store");
2053 let shell = cx.new(|cx| Shell::new(store, cx));
2054
2055 shell.update(cx, |s, _cx| {
2056 let opts = s.build_natal_options();
2057 assert!(opts.show_majors);
2058 assert!(!opts.show_minors);
2059 assert_eq!(opts.orb_multiplier, 1.0);
2060 assert!(!opts.show_dignities);
2061
2062 s.module_configs.insert(
2063 "natal".into(),
2064 serde_json::json!({
2065 "aspect_majors": true,
2066 "aspect_minors": true,
2067 "orb_multiplier": 1.75,
2068 "show_dignities": true,
2069 }),
2070 );
2071 let opts = s.build_natal_options();
2072 assert!(opts.show_minors);
2073 assert_eq!(opts.orb_multiplier, 1.75);
2074 assert!(opts.show_dignities);
2075 });
2076 });
2077 }
2078
2079 /// El flex de los splitters persiste entre instancias de Shell que
2080 /// comparten la misma store (in-memory): primera shell escribe via
2081 /// `save_split_flex`, segunda shell lee via `load_split_flex_n` al
2082 /// boot. Cubre 2 y 3 hijos (Bottom vs docks laterales).
2083 #[test]
2084 fn split_flex_round_trip_via_store() {
2085 let store = Store::in_memory().expect("store");
2086 let defaults_2 = vec![1.0_f32, 4.0];
2087 let defaults_3 = vec![1.0_f32, 4.0, 1.5];
2088
2089 // Sin nada persistido → defaults.
2090 assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
2091
2092 store.set_setting("layout.x", "2.5,3.5").unwrap();
2093 assert_eq!(
2094 load_split_flex_n(&store, "layout.x", &defaults_2),
2095 vec![2.5_f32, 3.5]
2096 );
2097
2098 store.set_setting("layout.x", "1.0,4.0,2.0").unwrap();
2099 assert_eq!(
2100 load_split_flex_n(&store, "layout.x", &defaults_3),
2101 vec![1.0_f32, 4.0, 2.0]
2102 );
2103
2104 // Valor corrupto → defaults.
2105 store.set_setting("layout.x", "garbage").unwrap();
2106 assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
2107
2108 // Cantidad incorrecta → defaults.
2109 store.set_setting("layout.x", "2,3,4").unwrap();
2110 assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
2111
2112 // Valor ≤0 → defaults.
2113 store.set_setting("layout.x", "0,5").unwrap();
2114 assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
2115 }
2116
2117 /// PanelDock roundtrip via store.
2118 #[test]
2119 fn panel_dock_setting_roundtrip() {
2120 assert_eq!(PanelDock::from_setting("bottom"), Some(PanelDock::Bottom));
2121 assert_eq!(PanelDock::from_setting("right"), Some(PanelDock::Right));
2122 assert_eq!(PanelDock::from_setting("left"), Some(PanelDock::Left));
2123 assert_eq!(PanelDock::from_setting("nope"), None);
2124
2125 let store = Store::in_memory().expect("store");
2126 assert_eq!(load_dock(&store), None);
2127 store
2128 .set_setting("layout.panel_dock", PanelDock::Right.as_setting())
2129 .unwrap();
2130 assert_eq!(load_dock(&store), Some(PanelDock::Right));
2131 }
2132}