From 72758e75ce515f355f27615671c95ecaf8c2a8bc Mon Sep 17 00:00:00 2001 From: sergio Date: Mon, 18 May 2026 20:11:43 +0000 Subject: [PATCH] feat(tahuantinsuyu): "Cielo ahora" + "General" como rows fijos al top del tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/apps/tahuantinsuyu/src/shell.rs | 163 +++++++++++++++++- .../tahuantinsuyu-model/src/lib.rs | 6 + .../tahuantinsuyu-tree/src/lib.rs | 45 ++++- 3 files changed, 209 insertions(+), 5 deletions(-) diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs index 5a5e8d3..744e99d 100644 --- a/crates/apps/tahuantinsuyu/src/shell.rs +++ b/crates/apps/tahuantinsuyu/src/shell.rs @@ -35,7 +35,10 @@ use tahuantinsuyu_engine::{ LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest, compose_with_options, svg_export, }; -use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection}; +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}; @@ -205,6 +208,10 @@ impl Shell { 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 } @@ -507,6 +514,61 @@ impl Shell { }); 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 = 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); + } } } @@ -1040,6 +1102,76 @@ fn load_city_atlas_from_xdg() -> Option> { 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 { @@ -1286,9 +1418,32 @@ impl Render for Shell { mod tests { use super::*; use gpui::TestAppContext; - use tahuantinsuyu_model::{ - ChartKind, ContactId, StoredBirthData, StoredChartConfig, - }; + + #[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) { ( diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs index 52a4604..3737d6a 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs @@ -315,11 +315,17 @@ pub struct ModuleState { /// Item activo del tree. El canvas reacciona a este tipo: /// - `Chart` → abre la carta puntual. /// - `Contact` / `Group` → muestra thumbnails de las cartas descendientes. +/// - `PresentSky` → carta efímera del instante presente (cielo ahora); +/// no vive en la store, se computa al vuelo cuando el host la pide. +/// - `GeneralRoot` → nodo branch virtual que agrupa los contactos +/// sin grupo padre (contacts con parent=None). #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum TreeSelection { Group(GroupId), Contact(ContactId), Chart(ChartId), + PresentSky, + GeneralRoot, } // ===================================================================== diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs index 380c066..3f29abe 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs @@ -41,6 +41,9 @@ use yahweh_widget_tree::{RowId, RowKind, TreeEvent as InnerTreeEvent, TreeRow, T const PREFIX_GROUP: &str = "g:"; const PREFIX_CONTACT: &str = "c:"; const PREFIX_CHART: &str = "h:"; +/// IDs sentinela para los dos rows virtuales fijos al top del tree. +const ROW_SKY_NOW: &str = "sky:now"; +const ROW_GENERAL: &str = "general"; // ===================================================================== // Eventos públicos @@ -74,6 +77,12 @@ impl MenuTarget { TreeSelection::Group(id) => MenuTarget::Group(*id), TreeSelection::Contact(id) => MenuTarget::Contact(*id), TreeSelection::Chart(id) => MenuTarget::Chart(*id), + // "General" comparte menu con Root — el target lógico es + // crear contactos sin grupo padre. + TreeSelection::GeneralRoot => MenuTarget::Root, + // "Cielo ahora" no admite operaciones de menu — es una + // carta efímera del momento, no se edita ni borra. + TreeSelection::PresentSky => MenuTarget::Root, } } } @@ -382,8 +391,36 @@ impl TahuantinsuyuTree { pub fn refresh(&mut self, cx: &mut Context<'_, Self>) { let mut rows = Vec::new(); + + // 1) Cielo ahora — siempre al top, leaf, ícono distintivo. + // No participa de search filter (es atemporal). + rows.push(TreeRow { + id: RowId::new(ROW_SKY_NOW.to_string()), + label: "Cielo ahora".to_string(), + depth: 0, + kind: RowKind::Leaf, + expanded: false, + icon: Some("⏱".into()), + }); + + // 2) General — branch fijo. Contiene los contactos sin grupo + // asignado (parent=None). Aparece siempre, sin filtro. + let general_expanded = self.expanded.contains(ROW_GENERAL); + rows.push(TreeRow { + id: RowId::new(ROW_GENERAL.to_string()), + label: "General".to_string(), + depth: 0, + kind: RowKind::Branch, + expanded: general_expanded, + icon: Some("◇".into()), + }); + if general_expanded { + self.append_contacts(None, 1, &mut rows); + } + + // 3) Resto: groups top-level con sus contenidos. self.append_groups(None, 0, &mut rows); - self.append_contacts(None, 0, &mut rows); + self.inner.update(cx, |t, cx| t.set_rows(rows, cx)); } @@ -1189,6 +1226,12 @@ fn find_contact_in_groups( fn parse_row(id: &RowId) -> Option { let s = id.as_str(); + if s == ROW_SKY_NOW { + return Some(TreeSelection::PresentSky); + } + if s == ROW_GENERAL { + return Some(TreeSelection::GeneralRoot); + } if let Some(rest) = s.strip_prefix(PREFIX_GROUP) { return rest.parse().ok().map(TreeSelection::Group); }