Files
brahman/crates/apps/tahuantinsuyu/src/shell.rs
T
sergio 72758e75ce feat(tahuantinsuyu): "Cielo ahora" + "General" como rows fijos al top del tree
Dos entradas siempre presentes en la cima del árbol:

1. **⏱ Cielo ahora** (leaf): selecciona una carta efímera del
   instante actual en Greenwich (UTC, lat 51.4769°, lon 0°,
   alt 47 m). NO se persiste en la store — `build_present_sky_chart`
   la construye al vuelo con `Chart { id: Default::default(), ... }`
   y birth_data tomado de `SystemTime::now()` via `unix_to_civil_utc`
   (algoritmo Howard Hinnant, exacto y proleptic-Gregoriano).

   La carta queda **seleccionada por default** al boot — el usuario
   abre la app y ya está viendo el firmamento actual, incluso si
   no tiene contactos cargados.

2. **◇ General** (branch): contenedor virtual para los contactos
   sin grupo asignado (parent=None). Antes esos contactos
   aparecían sueltos al nivel raíz; ahora viven dentro de
   "General" y se ofrece como destino claro para "Nuevo
   contacto" desde su menú. Click sobre General muestra
   thumbnails de TODAS las cartas de esos contactos en el canvas.

Soporte en `TreeSelection`: dos variantes nuevas `PresentSky` y
`GeneralRoot`. `parse_row` reconoce los IDs sentinela `sky:now`
y `general`. El shell maneja ambos casos en `apply_selection`:
- PresentSky → set `current_chart` + render
- GeneralRoot → grilla de thumbnails

`MenuTarget::from_selection` mapea PresentSky/GeneralRoot →
MenuTarget::Root (mismo menú "Nuevo grupo / Nuevo contacto").

`unix_to_civil_utc` con 4 tests cubre: epoch (1970-01-01),
2024-02-29 (año bisiesto), pre-epoch (-1 → 1969-12-31), y
year 2000.

Total 10 tests verdes (6 anteriores + 4 nuevos del calendario).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:11:43 +00:00

1637 lines
65 KiB
Rust

//! Shell — coordinador de los tres widgets.
//!
//! Es el "director de orquesta": dueño del tree, del canvas y del panel,
//! reenvía eventos entre ellos y aplica las mutaciones en la store.
//!
//! Flujo:
//!
//! ```text
//! Tree.Selected(Chart) → Shell → load chart + compose + set_mode(Wheel)
//! Tree.Selected(Group/Contact)→ Shell → charts_under_* + set_mode(Thumbnails)
//! Canvas.TimeOffsetChanged → Shell → compose(current_chart, off, requests)
//! Canvas.LayerVisibility[T] → Shell → flip module_configs[transit][enabled]
//! Panel.ControlChanged → Shell → update module_configs OR canvas visibility
//! ```
//!
//! ## module_configs
//!
//! Mapa `module_id → JSON` con la configuración persistente de cada
//! módulo (transit, progression, …). De ahí derivamos los
//! `PipelineRequest` que la engine consume. Los toggles "visuales"
//! del NatalModule (`show_sign_dial`, `show_houses`, …) NO viven acá
//! — afectan solo el render del canvas, no la composición.
use std::collections::HashMap;
use gpui::{
ClickEvent, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Window, div, prelude::*, px,
};
use tahuantinsuyu_canvas::{
AstrologyCanvas, CanvasEvent, CanvasMode, ThumbnailItem, ThumbnailScope,
};
use tahuantinsuyu_engine::{
LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest, compose_with_options,
svg_export,
};
use tahuantinsuyu_model::{
Chart, ChartId, ChartKind, ContactId, ModuleState, StoredBirthData, StoredChartConfig,
TreeSelection,
};
use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
use tahuantinsuyu_store::Store;
use tahuantinsuyu_tree::{parse_city_atlas_tsv, TahuantinsuyuTree, TreeEvent};
use yahweh_core::{LayoutDirection, NodeId};
use yahweh_theme::Theme;
use yahweh_widget_container_core::ChildSlot;
use yahweh_widget_splitter::{SplitContainer, SplitEvent};
use yahweh_widget_theme_switcher::theme_switcher;
/// Posición del panel de control dentro del shell. `Bottom` mantiene
/// el layout histórico (tree+canvas arriba, panel abajo); las variantes
/// laterales colapsan los splitters anidados en uno solo de 3 columnas.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PanelDock {
Bottom,
Right,
Left,
}
impl PanelDock {
fn as_setting(&self) -> &'static str {
match self {
PanelDock::Bottom => "bottom",
PanelDock::Right => "right",
PanelDock::Left => "left",
}
}
fn from_setting(s: &str) -> Option<Self> {
match s {
"bottom" => Some(PanelDock::Bottom),
"right" => Some(PanelDock::Right),
"left" => Some(PanelDock::Left),
_ => None,
}
}
}
/// Status del broker brahman tal como lo vimos en el último ping.
/// Se refresca cada 30 segundos desde un background task.
#[derive(Clone, Debug)]
pub enum BrahmanStatus {
/// Aún no probamos (boot, primer ciclo).
Pending,
/// Connect OK al broker, devolvió la lista de sessions activas.
Connected { session_count: usize },
/// Connect falló — broker no escucha en el socket o tomó timeout.
/// `reason` se incluye para diagnóstico en logs aunque la UI hoy
/// muestra solo "offline".
Offline {
#[allow(dead_code)]
reason: String,
},
}
pub struct Shell {
store: Store,
/// Los tres widgets viven como children de los splitters vía
/// AnyView clone; retenemos los Entity acá para que las
/// subscripciones sigan vivas y para poder rearmar el layout al
/// cambiar `dock` sin recrear los widgets.
tree: Entity<TahuantinsuyuTree>,
canvas: Entity<AstrologyCanvas>,
panel: Entity<ControlPanel>,
/// Splitter "exterior". En dock=Bottom es vertical con (main_split,
/// panel) como hijos; en dock=Right/Left es horizontal y agrupa
/// tree+canvas+panel en una sola tira.
outer_split: Entity<SplitContainer>,
/// Splitter horizontal interno con (tree, canvas). Solo se usa
/// cuando dock=Bottom; en docks laterales queda vivo pero sin ser
/// hijo del árbol activo.
main_split: Entity<SplitContainer>,
/// Dock activo del panel — determina cómo se arman los splitters
/// y cuáles flex se persisten.
dock: PanelDock,
/// Último estado conocido del broker brahman — refrescado cada
/// 30s desde el background task.
brahman_status: BrahmanStatus,
current_chart: Option<Chart>,
current_offset_minutes: i64,
/// Estado de los módulos overlay (transit, progression, …) por
/// `module_id`. Las claves dentro del JSON dependen del módulo (la
/// convención es `"enabled": bool` para el toggle principal).
module_configs: HashMap<String, serde_json::Value>,
/// Sequence counter para descartar resultados de cómputos
/// background que llegan después de uno más reciente. Cada
/// `render_current` lo incrementa y la closure async compara antes
/// de aplicar el render al canvas.
render_seq: u64,
}
impl Shell {
pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
let tree = cx.new(|cx| {
let mut t = TahuantinsuyuTree::new(store.clone(), cx);
// Si hay un atlas custom en $XDG_DATA_HOME/tahuantinsuyu/
// atlas.tsv, lo cargamos y reemplazamos el atlas hardcoded
// de 90 ciudades. Formato TSV: name<TAB>lat<TAB>lon<TAB>tz_min.
if let Some(atlas) = load_city_atlas_from_xdg() {
t.set_city_atlas(atlas, cx);
}
t
});
let canvas = cx.new(AstrologyCanvas::new);
let panel = cx.new(ControlPanel::new);
cx.subscribe(&tree, |this: &mut Self, _, ev: &TreeEvent, cx| {
this.on_tree_event(ev, cx);
})
.detach();
cx.subscribe(&panel, |this: &mut Self, _, ev: &PanelEvent, cx| {
this.on_panel_event(ev, cx);
})
.detach();
cx.subscribe(&canvas, |this: &mut Self, _, ev: &CanvasEvent, cx| {
this.on_canvas_event(ev, cx);
})
.detach();
// Splitters vacíos — `apply_dock` los puebla según el layout
// activo. Horizontal/Vertical son defaults; cada apply ajusta la
// dirección antes de setear children.
let main_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Horizontal, cx));
let outer_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Vertical, cx));
// Persistir flex en `DragEnd`. La key del setting depende del
// dock activo, así no se pisan los flexes de un layout con los
// de otro al mudarse. Se lee dentro del closure para tomar el
// dock actualizado, no el capturado en `new`.
let store_main = store.clone();
cx.subscribe(&main_split, move |this: &mut Self, sc, ev: &SplitEvent, cx| {
if matches!(ev, SplitEvent::DragEnd) {
let key = split_key_main(this.dock);
save_split_flex(&store_main, key, sc.read(cx));
}
})
.detach();
let store_outer = store.clone();
cx.subscribe(&outer_split, move |this: &mut Self, sc, ev: &SplitEvent, cx| {
if matches!(ev, SplitEvent::DragEnd) {
let key = split_key_outer(this.dock);
save_split_flex(&store_outer, key, sc.read(cx));
}
})
.detach();
let dock = load_dock(&store).unwrap_or(PanelDock::Bottom);
let mut shell = Self {
store,
tree,
canvas,
panel,
outer_split,
main_split,
dock,
brahman_status: BrahmanStatus::Pending,
current_chart: None,
current_offset_minutes: 0,
module_configs: HashMap::new(),
render_seq: 0,
};
shell.apply_dock(dock, cx);
shell.refresh_chart_options(cx);
shell.spawn_brahman_status_loop(cx);
// Carta "Cielo ahora" cargada por default al boot — el usuario
// siempre arranca viendo el estado del firmamento actual,
// incluso si la store está vacía.
shell.apply_selection(TreeSelection::PresentSky, cx);
shell
}
/// Arma el árbol de splitters según el dock pedido y persiste la
/// elección. Idempotente: llamar con el dock actual reconstruye los
/// children con flexes leídos del setting (útil tras `new`).
pub fn apply_dock(&mut self, dock: PanelDock, cx: &mut Context<Self>) {
self.dock = dock;
let tree_view = gpui::AnyView::from(self.tree.clone());
let canvas_view = gpui::AnyView::from(self.canvas.clone());
let panel_view = gpui::AnyView::from(self.panel.clone());
let main_view = gpui::AnyView::from(self.main_split.clone());
match dock {
PanelDock::Bottom => {
let flex_main = load_split_flex_n(
&self.store,
split_key_main(dock),
&[1.0, 4.0],
);
let flex_outer = load_split_flex_n(
&self.store,
split_key_outer(dock),
&[4.0, 1.0],
);
self.main_split.update(cx, |sc, cx| {
sc.set_direction(LayoutDirection::Horizontal, cx);
sc.set_children(
vec![
ChildSlot {
id: NodeId::new("tts-tree"),
flex: flex_main[0],
label: None,
view: tree_view.clone(),
},
ChildSlot {
id: NodeId::new("tts-canvas"),
flex: flex_main[1],
label: None,
view: canvas_view.clone(),
},
],
cx,
);
});
self.outer_split.update(cx, |sc, cx| {
sc.set_direction(LayoutDirection::Vertical, cx);
sc.set_children(
vec![
ChildSlot {
id: NodeId::new("tts-main"),
flex: flex_outer[0],
label: None,
view: main_view,
},
ChildSlot {
id: NodeId::new("tts-panel"),
flex: flex_outer[1],
label: None,
view: panel_view,
},
],
cx,
);
});
}
PanelDock::Right => {
let flex = load_split_flex_n(
&self.store,
split_key_outer(dock),
&[1.0, 4.0, 1.5],
);
self.outer_split.update(cx, |sc, cx| {
sc.set_direction(LayoutDirection::Horizontal, cx);
sc.set_children(
vec![
ChildSlot {
id: NodeId::new("tts-tree"),
flex: flex[0],
label: None,
view: tree_view,
},
ChildSlot {
id: NodeId::new("tts-canvas"),
flex: flex[1],
label: None,
view: canvas_view,
},
ChildSlot {
id: NodeId::new("tts-panel"),
flex: flex[2],
label: None,
view: panel_view,
},
],
cx,
);
});
}
PanelDock::Left => {
let flex = load_split_flex_n(
&self.store,
split_key_outer(dock),
&[1.5, 1.0, 4.0],
);
self.outer_split.update(cx, |sc, cx| {
sc.set_direction(LayoutDirection::Horizontal, cx);
sc.set_children(
vec![
ChildSlot {
id: NodeId::new("tts-panel"),
flex: flex[0],
label: None,
view: panel_view,
},
ChildSlot {
id: NodeId::new("tts-tree"),
flex: flex[1],
label: None,
view: tree_view,
},
ChildSlot {
id: NodeId::new("tts-canvas"),
flex: flex[2],
label: None,
view: canvas_view,
},
],
cx,
);
});
}
}
if let Err(e) = self.store.set_setting("layout.panel_dock", dock.as_setting()) {
eprintln!("[shell] persist panel_dock: {}", e);
}
cx.notify();
}
/// Loop que cada 30s pregunta al broker la lista de sessions
/// activas y actualiza `brahman_status`. El cómputo bloqueante
/// (list_sessions_blocking abre su propio tokio runtime) corre en
/// el background_executor — no bloquea el UI thread. Cuando llega
/// el resultado, el `this.update` dispara cx.notify para repintar
/// el badge del header.
fn spawn_brahman_status_loop(&self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
loop {
let result = cx
.background_executor()
.spawn(async {
brahman_sidecar::list_sessions_blocking("tahuantinsuyu-observer")
})
.await;
let _ = this.update(cx, |this, cx| {
this.brahman_status = match result {
Ok(list) => BrahmanStatus::Connected {
session_count: list.entries.len(),
},
Err(e) => BrahmanStatus::Offline {
reason: format!("{:?}", e),
},
};
cx.notify();
});
let timer = cx
.background_executor()
.timer(std::time::Duration::from_secs(30));
timer.await;
}
})
.detach();
}
/// Recarga la lista de opciones para los `Control::ChartPicker` y
/// la pushea al panel. Llamado al boot + tras cada
/// `TreeEvent::HierarchyChanged`.
fn refresh_chart_options(&self, cx: &mut Context<Self>) {
let charts = self.store.list_all_charts().unwrap_or_default();
let options: Vec<ChartOption> = charts
.into_iter()
.map(|c| ChartOption {
id: c.id.to_string(),
label: format!("{} — {}", c.label, format_birth_brief(&c.birth_data)),
})
.collect();
self.panel
.update(cx, |p, cx| p.set_chart_options(options, cx));
}
fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) {
let selection = match ev {
TreeEvent::Selected(s) => s,
TreeEvent::Opened(s) => s,
TreeEvent::HierarchyChanged => {
// La jerarquía cambió (alta/baja de cartas) — refrescar
// las opciones del picker para que aparezcan / desaparezcan
// en el dropdown.
self.refresh_chart_options(cx);
cx.notify();
return;
}
};
self.apply_selection(selection.clone(), cx);
}
fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
match sel {
TreeSelection::Chart(id) => {
let chart = match self.store.get_chart(id) {
Ok(c) => c,
Err(e) => {
eprintln!("[shell] get_chart {}: {}", id, e);
return;
}
};
let age = current_age_years(&chart.birth_data);
self.current_chart = Some(chart.clone());
self.current_offset_minutes = 0;
// 1) Defaults frescos para esta carta: edad objetivo =
// edad actual. Estos quedan en module_configs como
// valor base si el usuario nunca tocó el slider.
self.module_configs.clear();
for module_id in ["progression", "solar_arc", "planetary_return"] {
let entry = self
.module_configs
.entry(module_id.into())
.or_insert_with(|| serde_json::json!({}));
if let serde_json::Value::Object(map) = entry {
map.insert("target_age_years".into(), serde_json::json!(age));
}
}
// El módulo planetary_return además necesita un body
// por default — el shell elige "sun" si el usuario no
// tocó el Select. La persistencia luego puede pisar
// este valor.
if let Some(serde_json::Value::Object(map)) =
self.module_configs.get_mut("planetary_return")
{
map.entry(String::from("body"))
.or_insert(serde_json::json!("sun"));
}
// 2) Sobreescribir con lo que el usuario persistió la
// última vez para esta carta (SQLite `module_state`).
self.load_persisted_module_states(chart.id);
// 3) Sincronizar panel: active_kind + toggles/sliders.
self.panel.update(cx, |p, cx| {
p.set_active_kind(Some(chart.kind), cx);
});
self.sync_panel_from_configs(cx);
self.render_current(cx);
}
TreeSelection::Contact(id) => {
self.current_chart = None;
self.current_offset_minutes = 0;
let charts = self.store.list_charts(id).unwrap_or_default();
let items: Vec<ThumbnailItem> = charts
.into_iter()
.map(|c| ThumbnailItem {
chart_id: c.id,
label: SharedString::from(c.label),
subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
preview: None,
})
.collect();
self.canvas.update(cx, |c, cx| {
c.set_mode(
CanvasMode::Thumbnails {
scope: ThumbnailScope::Contact(id),
items,
},
cx,
);
});
self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
}
TreeSelection::Group(id) => {
self.current_chart = None;
self.current_offset_minutes = 0;
let charts = self.store.charts_under_group(id).unwrap_or_default();
let items: Vec<ThumbnailItem> = charts
.into_iter()
.map(|c| ThumbnailItem {
chart_id: c.id,
label: SharedString::from(c.label),
subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
preview: None,
})
.collect();
self.canvas.update(cx, |c, cx| {
c.set_mode(
CanvasMode::Thumbnails {
scope: ThumbnailScope::Group(id),
items,
},
cx,
);
});
self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
}
TreeSelection::GeneralRoot => {
// "General" agrupa los contactos sin grupo padre. El
// canvas muestra thumbnails de TODAS las cartas de
// esos contactos.
self.current_chart = None;
self.current_offset_minutes = 0;
let mut items: Vec<ThumbnailItem> = Vec::new();
if let Ok(contacts) = self.store.list_contacts(None) {
for ct in contacts {
if let Ok(charts) = self.store.list_charts(ct.id) {
for c in charts {
items.push(ThumbnailItem {
chart_id: c.id,
label: SharedString::from(c.label),
subtitle: Some(SharedString::from(format!(
"{} · {:?}",
ct.name, c.kind
))),
preview: None,
});
}
}
}
}
// Reusamos el scope Group con un id sentinela "vacío":
// como GeneralRoot no es un Group real, dejamos que el
// canvas pinte la grilla con el set de items y nada
// más — el `scope` no se usa para nada que requiera
// el id.
self.canvas.update(cx, |c, cx| {
c.set_mode(
CanvasMode::Thumbnails {
scope: ThumbnailScope::Group(Default::default()),
items,
},
cx,
);
});
self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
}
TreeSelection::PresentSky => {
// Carta efímera del momento: birth_data = ahora en
// Greenwich (UTC, lat=0, lon=0). Se construye al
// vuelo, no se persiste — el id sintético es
// `Default::default()`. Cada selección de PresentSky
// recomputa contra el reloj actual.
let chart = build_present_sky_chart();
self.current_chart = Some(chart);
self.current_offset_minutes = 0;
self.module_configs.clear();
self.panel
.update(cx, |p, cx| p.set_active_kind(Some(ChartKind::Natal), cx));
self.sync_panel_from_configs(cx);
self.render_current(cx);
}
}
}
/// Deriva los `PipelineRequest` activos a partir del `module_configs`.
fn build_requests(&self) -> Vec<PipelineRequest> {
let mut requests = Vec::new();
if module_enabled(&self.module_configs, "transit") {
requests.push(PipelineRequest::Transit);
}
if module_enabled(&self.module_configs, "progression") {
let age = self.module_age_or_current("progression");
requests.push(PipelineRequest::SecondaryProgression {
target_age_years: age,
});
}
if module_enabled(&self.module_configs, "solar_arc") {
let age = self.module_age_or_current("solar_arc");
requests.push(PipelineRequest::SolarArc {
target_age_years: age,
});
}
if module_enabled(&self.module_configs, "synastry") {
if let Some(partner) = self.resolve_synastry_partner() {
requests.push(PipelineRequest::Synastry {
partner_chart: Box::new(partner),
});
}
}
if module_enabled(&self.module_configs, "midpoints") {
requests.push(PipelineRequest::Midpoints);
}
if module_enabled(&self.module_configs, "uranian") {
requests.push(PipelineRequest::Uranian);
}
if module_enabled(&self.module_configs, "lots") {
requests.push(PipelineRequest::Lots);
}
if module_enabled(&self.module_configs, "fixed_stars") {
requests.push(PipelineRequest::FixedStars);
}
if module_enabled(&self.module_configs, "topocentric") {
requests.push(PipelineRequest::Topocentric);
}
if module_enabled(&self.module_configs, "primary_directions") {
let age = self.module_age_or_current("primary_directions");
let key = self
.module_configs
.get("primary_directions")
.and_then(|c| c.get("key"))
.and_then(|v| v.as_str())
.unwrap_or("naibod")
.to_string();
requests.push(PipelineRequest::PrimaryDirections {
target_age_years: age,
key,
});
}
if module_enabled(&self.module_configs, "composite") {
if let Some(partner) = self.resolve_composite_partner() {
requests.push(PipelineRequest::Composite {
partner_chart: Box::new(partner),
});
}
}
if module_enabled(&self.module_configs, "planetary_return") {
let age = self.module_age_or_current("planetary_return");
let body = self
.module_configs
.get("planetary_return")
.and_then(|c| c.get("body"))
.and_then(|v| v.as_str())
.unwrap_or("sun")
.to_string();
let shift_days = self
.module_configs
.get("planetary_return")
.and_then(|c| c.get("shift_days"))
.and_then(|v| v.as_f64())
.map(|v| v as i64)
.unwrap_or(0);
requests.push(PipelineRequest::PlanetaryReturn {
body,
target_age_years: age,
shift_days,
});
}
requests
}
/// Resuelve la carta partner para sinastría: 1) si el picker tiene
/// un `partner_chart_id` válido en `module_configs`, lo usa; 2)
/// si no, cae al automático (primera carta hermana del contacto
/// actual). `None` si nada matchea — el request se salta.
fn resolve_synastry_partner(&self) -> Option<Chart> {
let manual = self
.module_configs
.get("synastry")
.and_then(|c| c.get("partner_chart_id"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<tahuantinsuyu_model::ChartId>().ok())
.and_then(|id| self.store.get_chart(id).ok());
manual.or_else(|| self.find_synastry_partner_auto())
}
fn find_synastry_partner_auto(&self) -> Option<Chart> {
let current = self.current_chart.as_ref()?;
let siblings = self.store.list_charts(current.contact_id).ok()?;
siblings.into_iter().find(|c| c.id != current.id)
}
/// Resuelve el partner para Composite — mismo patrón que Synastry:
/// 1) lee module_configs["composite"]["partner_chart_id"] y resuelve
/// el chart; 2) fallback al primer hermano del contacto actual.
fn resolve_composite_partner(&self) -> Option<Chart> {
let manual = self
.module_configs
.get("composite")
.and_then(|c| c.get("partner_chart_id"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<tahuantinsuyu_model::ChartId>().ok())
.and_then(|id| self.store.get_chart(id).ok());
manual.or_else(|| self.find_synastry_partner_auto())
}
/// Deriva las `NatalOptions` activas a partir del `module_configs["natal"]`.
/// Si la entry no existe, devuelve defaults (majors=true, minors=false,
/// multiplier=1.0).
fn build_natal_options(&self) -> NatalOptions {
let cfg = self.module_configs.get("natal");
let read_bool = |key: &str, default: bool| -> bool {
cfg.and_then(|c| c.get(key))
.and_then(|v| v.as_bool())
.unwrap_or(default)
};
let read_f64 = |key: &str, default: f64| -> f64 {
cfg.and_then(|c| c.get(key))
.and_then(|v| v.as_f64())
.unwrap_or(default)
};
NatalOptions {
show_majors: read_bool("aspect_majors", true),
show_minors: read_bool("aspect_minors", false),
orb_multiplier: read_f64("orb_multiplier", 1.0),
show_dignities: read_bool("show_dignities", false),
}
}
/// Lee `module_state` desde SQLite para la carta dada y los mergea
/// con los defaults ya cargados en `module_configs`. Los valores
/// persistidos ganan sobre los defaults.
fn load_persisted_module_states(&mut self, chart_id: ChartId) {
let states = match self.store.list_module_states(chart_id) {
Ok(s) => s,
Err(e) => {
eprintln!("[shell] list_module_states {}: {}", chart_id, e);
return;
}
};
for st in states {
// Re-mergeamos `enabled` (columna separada en SQL) dentro
// del JSON config, así el resto del shell sigue leyendo
// todo desde una única estructura.
let mut combined = match st.config {
serde_json::Value::Object(m) => serde_json::Value::Object(m),
_ => serde_json::json!({}),
};
if let serde_json::Value::Object(map) = &mut combined {
map.insert("enabled".into(), serde_json::Value::Bool(st.enabled));
}
// Mergear sobre defaults previos (no sobreescribir si la
// entrada nueva no trae un campo).
match self.module_configs.entry(st.module_id) {
std::collections::hash_map::Entry::Vacant(v) => {
v.insert(combined);
}
std::collections::hash_map::Entry::Occupied(mut o) => {
if let (serde_json::Value::Object(dst), serde_json::Value::Object(src)) =
(o.get_mut(), &combined)
{
for (k, v) in src {
dst.insert(k.clone(), v.clone());
}
} else {
o.insert(combined);
}
}
}
}
}
/// Pushea cada toggle/slider/picker del `module_configs` al panel
/// para que la UI refleje el estado persistido al cargar una carta.
fn sync_panel_from_configs(&mut self, cx: &mut Context<Self>) {
let snapshot: Vec<(String, serde_json::Value)> = self
.module_configs
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
self.panel.update(cx, |p, cx| {
for (module_id, config) in &snapshot {
if let serde_json::Value::Object(map) = config {
for (key, value) in map {
if let Some(b) = value.as_bool() {
p.set_toggle(module_id, key, b, cx);
} else if let Some(f) = value.as_f64() {
p.set_slider(module_id, key, f, cx);
} else if let Some(s) = value.as_str() {
p.set_string(module_id, key, Some(s.to_string()), cx);
} else if value.is_null() {
p.set_string(module_id, key, None, cx);
}
}
}
}
});
}
/// Persiste el estado actual de un módulo a SQLite. Extrae
/// `enabled` del JSON y lo guarda en la columna dedicada; el resto
/// va al `config_json`.
fn persist_module(&self, module_id: &str) {
let Some(chart) = self.current_chart.as_ref() else {
return;
};
let Some(config) = self.module_configs.get(module_id) else {
return;
};
let enabled = config
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mut clean = config.clone();
if let serde_json::Value::Object(map) = &mut clean {
map.remove("enabled");
}
let state = ModuleState {
chart_id: chart.id,
module_id: module_id.to_string(),
enabled,
config: clean,
};
if let Err(e) = self.store.upsert_module_state(&state) {
eprintln!("[shell] upsert_module_state {}: {}", module_id, e);
}
}
/// Lee `target_age_years` del módulo o cae a la edad actual del
/// sujeto (calculada desde la fecha de nacimiento y el reloj).
fn module_age_or_current(&self, module_id: &str) -> f64 {
self.module_configs
.get(module_id)
.and_then(|c| c.get("target_age_years"))
.and_then(|v| v.as_f64())
.unwrap_or_else(|| {
self.current_chart
.as_ref()
.map(|c| current_age_years(&c.birth_data))
.unwrap_or(0.0)
})
}
fn render_current(&mut self, cx: &mut Context<Self>) {
let Some(chart) = self.current_chart.as_ref() else {
return;
};
// Snapshot de inputs para mover al background. La sesión
// VSOP2013 vive en un static `OnceLock` adentro del bridge, así
// que es compartible read-only entre threads sin que ningún
// dato cruce más allá del Chart clonado + requests/options.
let chart = chart.clone();
let offset = self.current_offset_minutes;
let requests = self.build_requests();
let natal_options = self.build_natal_options();
self.render_seq = self.render_seq.wrapping_add(1);
let my_seq = self.render_seq;
cx.spawn(async move |this, cx| {
// El compute corre en el background_executor — no bloquea
// el UI thread. Para una rueda completa con varios overlays
// puede tomar 100-200ms; sin esto, los drags del slider se
// sentirían atorados.
let chart_for_bg = chart.clone();
let requests_for_bg = requests.clone();
let opts_for_bg = natal_options.clone();
let result = cx
.background_executor()
.spawn(async move {
compose_with_options(&chart_for_bg, offset, &requests_for_bg, &opts_for_bg)
})
.await;
let _ = this.update(cx, |this, cx| {
// Descartar si llegó un render más nuevo en el medio.
// Sin este check, durante un drag rápido un compute
// viejo podría sobrescribir el más reciente.
if this.render_seq != my_seq {
return;
}
match result {
Ok(render) => {
this.canvas.update(cx, |c, cx| {
c.set_mode(
CanvasMode::Wheel {
render: Box::new(render),
},
cx,
);
});
}
Err(e) => {
eprintln!(
"[shell] compose {} (+{}min, {} reqs): {}",
chart.id,
offset,
requests.len(),
e
);
}
}
});
})
.detach();
}
fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context<Self>) {
match ev {
CanvasEvent::TimeOffsetChanged(off) => {
self.current_offset_minutes = *off;
if self.current_chart.is_some() {
self.render_current(cx);
}
}
CanvasEvent::LayerVisibilityChanged { kind, visible } => {
// El toggle de Outer ([T]) no es visibility puro: dispara
// un pipeline distinto. Lo traducimos a un cambio en
// module_configs["transit"]["enabled"] + re-render.
if matches!(kind, LayerKind::Outer) {
set_module_enabled(&mut self.module_configs, "transit", *visible);
self.persist_module("transit");
self.panel.update(cx, |p, cx| {
p.set_toggle("transit", "enabled", *visible, cx)
});
self.render_current(cx);
return;
}
// El resto son visibility puros sobre el canvas. Sync el
// panel para que el toggle visual coincida con la hotkey.
let key = match kind {
LayerKind::SignDial => "show_sign_dial",
LayerKind::Houses => "show_houses",
LayerKind::Aspects => "show_aspects",
LayerKind::Bodies => "show_bodies",
_ => return,
};
self.panel
.update(cx, |p, cx| p.set_toggle("natal", key, *visible, cx));
}
CanvasEvent::ShowCoordsChanged(visible) => {
// Sync el toggle del panel para que coincida con la
// hotkey C. No persist — los coord labels son una
// preferencia visual, no parte del module_state.
self.panel.update(cx, |p, cx| {
p.set_toggle("natal", "show_coords", *visible, cx)
});
}
CanvasEvent::ChartRequested(_) => {
// Fase 7: doble click sobre un thumbnail abre la carta.
}
CanvasEvent::ExportSvgRequested => {
self.export_current_to_svg();
}
}
}
/// Recompone la carta actual + escribe el SVG a un archivo en
/// `$XDG_DATA_HOME/tahuantinsuyu/exports/<label>_<short_id>.svg`.
/// Logea la ruta a stderr — futuro: file save dialog GPUI.
fn export_current_to_svg(&self) {
let Some(chart) = self.current_chart.as_ref() else {
eprintln!("[shell] export svg: sin carta activa");
return;
};
let requests = self.build_requests();
let natal_options = self.build_natal_options();
let render = match compose_with_options(
chart,
self.current_offset_minutes,
&requests,
&natal_options,
) {
Ok(r) => r,
Err(e) => {
eprintln!("[shell] export svg compose: {}", e);
return;
}
};
let svg = svg_export::render_to_svg(&render);
let dir = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu")
.map(|d| d.data_dir().join("exports"))
.unwrap_or_else(|| std::path::PathBuf::from("."));
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!("[shell] mkdir {:?}: {}", dir, e);
return;
}
let safe_label: String = chart
.label
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
let short = format!("{}", chart.id).chars().take(8).collect::<String>();
let path = dir.join(format!("{}_{}.svg", safe_label, short));
if let Err(e) = std::fs::write(&path, svg) {
eprintln!("[shell] write {:?}: {}", path, e);
} else {
eprintln!("[shell] SVG exportado → {}", path.display());
}
}
fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
match ev {
PanelEvent::ControlChanged {
module_id, key, value,
} => {
let bool_val = value.as_bool().unwrap_or(true);
if module_id == "natal" {
// Distinguimos: show_* = visibility (no recompose),
// aspect_*/orb_* = filtros de engine (recompose +
// persist).
let kind = match key.as_str() {
"show_sign_dial" => Some(LayerKind::SignDial),
"show_houses" => Some(LayerKind::Houses),
"show_aspects" => Some(LayerKind::Aspects),
"show_bodies" => Some(LayerKind::Bodies),
_ => None,
};
if let Some(k) = kind {
self.canvas
.update(cx, |c, cx| c.set_layer_visible(k, bool_val, cx));
} else if key == "show_coords" {
// Coord labels viven en el canvas (no son una
// capa pintada como otros show_*). Sync sin
// recompose ni persist en module_state.
self.canvas
.update(cx, |c, cx| c.set_show_coords(bool_val, cx));
} else {
// Filtros: actualizar module_configs + recompose.
let entry = self
.module_configs
.entry("natal".into())
.or_insert_with(|| serde_json::json!({}));
if let serde_json::Value::Object(map) = entry {
map.insert(key.clone(), value.clone());
}
self.persist_module("natal");
self.render_current(cx);
}
} else {
// Cualquier otro módulo: actualizamos su config y
// recompomemos. La engine vuelve a llamarse con el
// PipelineRequest derivado del nuevo estado.
let entry = self
.module_configs
.entry(module_id.clone())
.or_insert_with(|| serde_json::json!({}));
if let serde_json::Value::Object(map) = entry {
map.insert(key.clone(), value.clone());
}
// Transit, Synastry y Solar Return comparten el
// outer ring del canvas — son mutuamente excluyentes.
// Al prender uno, apagamos los otros + sync panel +
// persist.
if key == "enabled" && bool_val && OUTER_RING_MODULES.contains(&module_id.as_str()) {
for &other in OUTER_RING_MODULES.iter() {
if other != module_id && module_enabled(&self.module_configs, other) {
set_module_enabled(&mut self.module_configs, other, false);
let other_str = other.to_string();
self.panel.update(cx, |p, cx| {
p.set_toggle(&other_str, "enabled", false, cx)
});
self.persist_module(&other_str);
}
}
}
// Sincronizar visualmente el toggle [T] del canvas
// cuando el cambio afecta el outer ring (transit,
// synastry o solar_return).
if OUTER_RING_MODULES.contains(&module_id.as_str()) && key == "enabled" {
self.canvas.update(cx, |c, cx| {
c.set_layer_visible(LayerKind::Outer, bool_val, cx)
});
}
self.persist_module(module_id);
self.render_current(cx);
}
}
PanelEvent::ModuleToggled { .. } => {
// Fase 7: encender/apagar módulos enteros desde un
// header con switch (vs. el toggle por-control de hoy).
}
}
}
}
// =====================================================================
// Helpers de module_configs
// =====================================================================
// OUTER_RING_MODULES viene de tahuantinsuyu_engine — single source of
// truth. Shell y canvas leen del mismo slice.
/// Lee `$XDG_DATA_HOME/tahuantinsuyu/atlas.tsv` si existe y lo parsea
/// como atlas de ciudades. Devuelve `None` cuando no hay archivo o
/// quedó vacío después del parse — el tree cae al atlas hardcoded.
fn load_city_atlas_from_xdg() -> Option<Vec<tahuantinsuyu_tree::CityPreset>> {
let path = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu")
.map(|d| d.data_dir().join("atlas.tsv"))?;
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(&path).ok()?;
let atlas = parse_city_atlas_tsv(&content);
if atlas.is_empty() {
eprintln!(
"[shell] atlas.tsv encontrado en {:?} pero sin filas válidas — fallback a hardcoded",
path
);
return None;
}
eprintln!("[shell] atlas custom cargado: {} ciudades", atlas.len());
Some(atlas)
}
/// Carta efímera del "Cielo ahora": birth_data = momento actual en
/// Greenwich (UTC, lat 51.4769°, lon 0°). El `Chart` se construye al
/// vuelo, NO se persiste en la store, y los IDs son `Default` (todo
/// ceros) — la carta es un singleton conceptual de la vista, no un
/// registro. Los módulos que consultan `current_chart.id` deben
/// tolerar este ID sentinela.
fn build_present_sky_chart() -> Chart {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let (year, month, day, hour, minute, second) = unix_to_civil_utc(secs);
let birth = StoredBirthData {
year,
month,
day,
hour,
minute,
second: second as f64,
tz_offset_minutes: 0,
// Greenwich Royal Observatory — origen histórico del meridiano
// primario. Lat = 51°28'38"N, Lon = 0°.
latitude_deg: 51.4769,
longitude_deg: 0.0,
altitude_m: 47.0,
time_certainty: Default::default(),
subject_name: Some("Cielo".into()),
birthplace_label: Some("Greenwich (UTC)".into()),
};
Chart {
id: ChartId::default(),
contact_id: ContactId::default(),
kind: ChartKind::Natal,
label: format!(
"Cielo {:04}-{:02}-{:02} {:02}:{:02} UTC",
year, month, day, hour, minute
),
birth_data: birth,
config: StoredChartConfig::default(),
related_chart_id: None,
created_at_ms: 0,
}
}
/// Convierte un timestamp Unix (segundos UTC desde 1970-01-01) a
/// componentes calendario proleptic-Gregorianos `(year, month, day,
/// hour, minute, second)`. Algoritmo de Howard Hinnant
/// (`days_to_civil`), exacto en todo el rango representable por i64.
fn unix_to_civil_utc(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
let day_seconds: i64 = 86_400;
let z = secs.div_euclid(day_seconds);
let s = secs.rem_euclid(day_seconds);
// Hinnant: shift z para que el "era" empiece en 0000-03-01.
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u32; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if month <= 2 { (y + 1) as i32 } else { y as i32 };
let hour = (s / 3600) as u32;
let minute = ((s % 3600) / 60) as u32;
let second = (s % 60) as u32;
(year, month, day, hour, minute, second)
}
/// Etiqueta breve para mostrar al elegir una carta en el picker:
/// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
fn format_birth_brief(birth: &tahuantinsuyu_model::StoredBirthData) -> String {
let date = format!("{:04}-{:02}-{:02}", birth.year, birth.month, birth.day);
match &birth.birthplace_label {
Some(p) if !p.is_empty() => format!("{} · {}", date, p),
_ => date,
}
}
/// Edad en años decimales desde el nacimiento hasta el reloj actual.
/// Aproximación: ignora la TZ de nacimiento (no afecta a resolución de
/// año) y usa una fracción de año tropical sobre los segundos Unix.
fn current_age_years(birth: &tahuantinsuyu_model::StoredBirthData) -> f64 {
use std::time::{SystemTime, UNIX_EPOCH};
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
let birth_year_frac = birth.year as f64
+ (birth.month.saturating_sub(1) as f64) / 12.0
+ (birth.day.saturating_sub(1) as f64) / 365.25;
let now_year_frac = 1970.0 + now_secs / (365.2422 * 86400.0);
(now_year_frac - birth_year_frac).max(0.0)
}
fn module_enabled(cfgs: &HashMap<String, serde_json::Value>, id: &str) -> bool {
cfgs.get(id)
.and_then(|c| c.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
fn set_module_enabled(
cfgs: &mut HashMap<String, serde_json::Value>,
id: &str,
enabled: bool,
) {
let entry = cfgs
.entry(id.to_string())
.or_insert_with(|| serde_json::json!({}));
if let serde_json::Value::Object(map) = entry {
map.insert("enabled".into(), serde_json::Value::Bool(enabled));
}
}
/// Lee del `settings` el flex de un splitter en formato "f0,f1,..." y
/// lo devuelve como `Vec<f32>` con la misma longitud que `defaults`.
/// Si no hay nada persistido, faltan campos, o algún flex es ≤0, cae a
/// `defaults`. Validación estricta porque un flex 0 colapsa al panel.
fn load_split_flex_n(store: &Store, key: &str, defaults: &[f32]) -> Vec<f32> {
let Ok(Some(raw)) = store.get_setting(key) else {
return defaults.to_vec();
};
let parsed: Vec<f32> = raw
.split(',')
.filter_map(|s| s.trim().parse::<f32>().ok())
.collect();
if parsed.len() != defaults.len() || parsed.iter().any(|&f| f <= 0.0) {
return defaults.to_vec();
}
parsed
}
/// Persiste los flex actuales de un splitter — soporta N children.
fn save_split_flex(store: &Store, key: &str, sc: &SplitContainer) {
let children = sc.children();
if children.is_empty() {
return;
}
let payload: String = children
.iter()
.map(|c| format!("{:.4}", c.flex))
.collect::<Vec<_>>()
.join(",");
if let Err(e) = store.set_setting(key, &payload) {
eprintln!("[shell] save_split_flex {}: {}", key, e);
}
}
/// Key del setting donde se persiste el splitter "outer" (el de mayor
/// nivel del árbol). En dock=Bottom guarda (main,panel); en docks
/// laterales guarda los flex de las 3 columnas — usamos keys distintas
/// para no pisar valores entre layouts.
fn split_key_outer(dock: PanelDock) -> &'static str {
match dock {
PanelDock::Bottom => "layout.outer_split",
PanelDock::Right => "layout.dock_right",
PanelDock::Left => "layout.dock_left",
}
}
/// Key del setting del splitter horizontal interno. Solo se usa cuando
/// dock=Bottom (en docks laterales no hay main_split activo).
fn split_key_main(dock: PanelDock) -> &'static str {
match dock {
PanelDock::Bottom => "layout.main_split",
// En docks laterales el main_split está dormido — escribir acá
// no hace daño pero tampoco se usa al recargar.
PanelDock::Right => "layout.main_split_right",
PanelDock::Left => "layout.main_split_left",
}
}
fn load_dock(store: &Store) -> Option<PanelDock> {
let raw = store.get_setting("layout.panel_dock").ok().flatten()?;
PanelDock::from_setting(raw.trim())
}
impl Shell {
/// Tres botones compactos en el header — uno por dock disponible.
/// El dock activo se marca con `bg=accent`; los demás van planos.
/// Click llama a `apply_dock` que reorganiza splitters y persiste.
fn render_dock_switcher(
&self,
theme: &yahweh_theme::Theme,
cx: &mut Context<Self>,
) -> impl IntoElement {
let mut row = div()
.id("tts-dock-switcher")
.flex()
.flex_row()
.gap(px(2.0))
.px(px(2.0))
.py(px(2.0))
.rounded(px(6.0))
.bg(theme.bg_panel_alt.clone())
.border_1()
.border_color(theme.border);
for (dock, glyph) in [
(PanelDock::Left, ""),
(PanelDock::Bottom, ""),
(PanelDock::Right, ""),
] {
let active = self.dock == dock;
let fg = if active { theme.fg_text } else { theme.fg_muted };
let id: SharedString = SharedString::from(format!("tts-dock-{}", dock.as_setting()));
let mut btn = div()
.id(gpui::ElementId::from(id))
.w(px(22.0))
.h(px(20.0))
.flex()
.items_center()
.justify_center()
.rounded(px(4.0))
.text_size(px(12.0))
.text_color(fg)
.hover(|s| s.bg(theme.bg_row_hover))
.child(SharedString::from(glyph))
.on_click(cx.listener(move |this, _: &ClickEvent, _w, cx| {
if this.dock != dock {
this.apply_dock(dock, cx);
}
}));
if active {
btn = btn.bg(theme.accent);
}
row = row.child(btn);
}
row
}
}
impl Render for Shell {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
// Badge del estado del broker brahman — pequeña pill con
// color según el estado actual del ping cada-30s.
let (badge_text, badge_color) = match &self.brahman_status {
BrahmanStatus::Pending => ("Brahman · …".to_string(), theme.fg_muted),
BrahmanStatus::Connected { session_count } => (
format!("Brahman ✓ {} sessions", session_count),
theme.accent,
),
BrahmanStatus::Offline { .. } => {
("Brahman · offline".to_string(), theme.fg_disabled)
}
};
let brahman_badge = div()
.px(px(8.0))
.py(px(2.0))
.rounded(px(8.0))
.bg(theme.bg_panel_alt.clone())
.border_1()
.border_color(theme.border)
.text_size(px(10.0))
.text_color(badge_color)
.child(SharedString::from(badge_text));
let header = div()
.h(px(34.0))
.px(px(12.0))
.flex()
.flex_row()
.items_center()
.gap(px(10.0))
.border_b_1()
.border_color(theme.border)
.child(
div()
.text_size(px(13.0))
.text_color(theme.fg_text)
.child("☉ Tahuantinsuyu"),
)
.child(
div()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child("estudio de astrología profesional"),
)
.child(div().flex_grow())
.child(self.render_dock_switcher(&theme, cx))
.child(brahman_badge)
.child(theme_switcher(cx));
let body = div()
.flex_grow()
.w_full()
.child(self.outer_split.clone());
div()
.size_full()
.bg(theme.bg_app.clone())
.flex()
.flex_col()
.child(header)
.child(body)
}
}
// =====================================================================
// Tests de integración del Shell
// =====================================================================
//
// Cubren los caminos que combinan lógica del shell con persistencia y
// el bridge real de eternal. Los tests puramente unitarios de cada
// crate (engine, store, modules) viven en sus respectivos `tests`
// modules; acá testeamos los wiring points del binario.
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
#[test]
fn unix_to_civil_at_epoch() {
assert_eq!(unix_to_civil_utc(0), (1970, 1, 1, 0, 0, 0));
}
#[test]
fn unix_to_civil_known_dates() {
// 2024-01-01T00:00:00 UTC = 1704067200
assert_eq!(unix_to_civil_utc(1_704_067_200), (2024, 1, 1, 0, 0, 0));
// 2024-02-29T12:34:56 UTC = año bisiesto
let secs = 1_704_067_200 + (31 + 28) * 86_400 + 12 * 3600 + 34 * 60 + 56;
assert_eq!(unix_to_civil_utc(secs), (2024, 2, 29, 12, 34, 56));
}
#[test]
fn unix_to_civil_pre_epoch_wraps_correctly() {
// -1 segundo = 1969-12-31T23:59:59 UTC
assert_eq!(unix_to_civil_utc(-1), (1969, 12, 31, 23, 59, 59));
}
#[test]
fn unix_to_civil_year_2000() {
// 2000-01-01T00:00:00 UTC = 946684800
assert_eq!(unix_to_civil_utc(946_684_800), (2000, 1, 1, 0, 0, 0));
}
fn sample_chart_for(_contact_id: ContactId) -> (StoredBirthData, StoredChartConfig) {
(
StoredBirthData {
year: 1987,
month: 3,
day: 14,
hour: 5,
minute: 22,
second: 0.0,
tz_offset_minutes: -240,
latitude_deg: 10.4806,
longitude_deg: -66.9036,
altitude_m: 900.0,
time_certainty: Default::default(),
subject_name: Some("Sergio".into()),
birthplace_label: Some("Caracas".into()),
},
StoredChartConfig::default(),
)
}
/// Smoke test: el Shell se construye sin panic con una store
/// in-memory. Cubre que las suscripciones cross-widget (tree, panel,
/// canvas, ambos splitters) se cablean sin colisiones y que el
/// background loop del brahman status arranca limpio.
#[gpui::test]
fn shell_constructs_smoke(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::install_default(cx);
let store = Store::in_memory().expect("in-memory store");
let _shell = cx.new(|cx| Shell::new(store, cx));
// Si llegamos acá sin panic, el cableado funciona.
});
}
/// La selección de una carta vía `apply_selection` (mismo pathway
/// que dispara el TreeEvent) puebla `current_chart` y arranca un
/// compute. El render asíncrono se resuelve después; verificamos
/// solo los efectos sincrónicos: chart cargada y `render_seq`
/// avanzado.
#[gpui::test]
fn select_chart_updates_current(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::install_default(cx);
let store = Store::in_memory().expect("store");
let group = store.create_group(None, "Test", None).unwrap();
let contact = store
.create_contact(Some(group.id), "Subject", None)
.unwrap();
let (birth, config) = sample_chart_for(contact.id);
let chart = store
.create_chart(contact.id, ChartKind::Natal, "Natal", &birth, &config, None)
.unwrap();
let shell = cx.new(|cx| Shell::new(store, cx));
shell.update(cx, |s, cx| {
s.apply_selection(TreeSelection::Chart(chart.id), cx);
});
shell.read_with(cx, |s, _| {
let cur = s.current_chart.as_ref().expect("current_chart set");
assert_eq!(cur.id, chart.id);
assert_eq!(cur.label, "Natal");
assert!(s.render_seq >= 1, "render_seq debió avanzar al menos a 1");
});
});
}
/// Toggleando un módulo overlay vía `module_configs` directamente
/// (simulando el efecto de un `PanelEvent::ControlChanged`), la
/// función `build_requests` debe reflejar el cambio.
#[gpui::test]
fn module_toggles_produce_requests(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::install_default(cx);
let store = Store::in_memory().expect("store");
let shell = cx.new(|cx| Shell::new(store, cx));
shell.update(cx, |s, _cx| {
// Sin módulos activos → no hay requests.
assert!(s.build_requests().is_empty());
set_module_enabled(&mut s.module_configs, "transit", true);
set_module_enabled(&mut s.module_configs, "midpoints", true);
set_module_enabled(&mut s.module_configs, "uranian", true);
let reqs = s.build_requests();
assert_eq!(reqs.len(), 3);
assert!(matches!(reqs[0], PipelineRequest::Transit));
assert!(matches!(reqs[1], PipelineRequest::Midpoints));
assert!(matches!(reqs[2], PipelineRequest::Uranian));
set_module_enabled(&mut s.module_configs, "transit", false);
let reqs = s.build_requests();
assert_eq!(reqs.len(), 2);
assert!(!reqs
.iter()
.any(|r| matches!(r, PipelineRequest::Transit)));
});
});
}
/// `NatalOptions` derivados de `module_configs["natal"]` deben
/// respetar orb_multiplier, show_minors y show_dignities cuando los
/// hay, y caer a defaults razonables cuando no.
#[gpui::test]
fn natal_options_read_from_configs(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::install_default(cx);
let store = Store::in_memory().expect("store");
let shell = cx.new(|cx| Shell::new(store, cx));
shell.update(cx, |s, _cx| {
let opts = s.build_natal_options();
assert!(opts.show_majors);
assert!(!opts.show_minors);
assert_eq!(opts.orb_multiplier, 1.0);
assert!(!opts.show_dignities);
s.module_configs.insert(
"natal".into(),
serde_json::json!({
"aspect_majors": true,
"aspect_minors": true,
"orb_multiplier": 1.75,
"show_dignities": true,
}),
);
let opts = s.build_natal_options();
assert!(opts.show_minors);
assert_eq!(opts.orb_multiplier, 1.75);
assert!(opts.show_dignities);
});
});
}
/// El flex de los splitters persiste entre instancias de Shell que
/// comparten la misma store (in-memory): primera shell escribe via
/// `save_split_flex`, segunda shell lee via `load_split_flex_n` al
/// boot. Cubre 2 y 3 hijos (Bottom vs docks laterales).
#[test]
fn split_flex_round_trip_via_store() {
let store = Store::in_memory().expect("store");
let defaults_2 = vec![1.0_f32, 4.0];
let defaults_3 = vec![1.0_f32, 4.0, 1.5];
// Sin nada persistido → defaults.
assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
store.set_setting("layout.x", "2.5,3.5").unwrap();
assert_eq!(
load_split_flex_n(&store, "layout.x", &defaults_2),
vec![2.5_f32, 3.5]
);
store.set_setting("layout.x", "1.0,4.0,2.0").unwrap();
assert_eq!(
load_split_flex_n(&store, "layout.x", &defaults_3),
vec![1.0_f32, 4.0, 2.0]
);
// Valor corrupto → defaults.
store.set_setting("layout.x", "garbage").unwrap();
assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
// Cantidad incorrecta → defaults.
store.set_setting("layout.x", "2,3,4").unwrap();
assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
// Valor ≤0 → defaults.
store.set_setting("layout.x", "0,5").unwrap();
assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2);
}
/// PanelDock roundtrip via store.
#[test]
fn panel_dock_setting_roundtrip() {
assert_eq!(PanelDock::from_setting("bottom"), Some(PanelDock::Bottom));
assert_eq!(PanelDock::from_setting("right"), Some(PanelDock::Right));
assert_eq!(PanelDock::from_setting("left"), Some(PanelDock::Left));
assert_eq!(PanelDock::from_setting("nope"), None);
let store = Store::in_memory().expect("store");
assert_eq!(load_dock(&store), None);
store
.set_setting("layout.panel_dock", PanelDock::Right.as_setting())
.unwrap();
assert_eq!(load_dock(&store), Some(PanelDock::Right));
}
}