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>
This commit is contained in:
@@ -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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1102,76 @@ fn load_city_atlas_from_xdg() -> Option<Vec<tahuantinsuyu_tree::CityPreset>> {
|
||||
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) {
|
||||
(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
|
||||
@@ -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<TreeSelection> {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user