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,
|
LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest, compose_with_options,
|
||||||
svg_export,
|
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_panel::{ChartOption, ControlPanel, PanelEvent};
|
||||||
use tahuantinsuyu_store::Store;
|
use tahuantinsuyu_store::Store;
|
||||||
use tahuantinsuyu_tree::{parse_city_atlas_tsv, TahuantinsuyuTree, TreeEvent};
|
use tahuantinsuyu_tree::{parse_city_atlas_tsv, TahuantinsuyuTree, TreeEvent};
|
||||||
@@ -205,6 +208,10 @@ impl Shell {
|
|||||||
shell.apply_dock(dock, cx);
|
shell.apply_dock(dock, cx);
|
||||||
shell.refresh_chart_options(cx);
|
shell.refresh_chart_options(cx);
|
||||||
shell.spawn_brahman_status_loop(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
|
shell
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,6 +514,61 @@ impl Shell {
|
|||||||
});
|
});
|
||||||
self.panel.update(cx, |p, cx| p.set_active_kind(None, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,6 +1102,76 @@ fn load_city_atlas_from_xdg() -> Option<Vec<tahuantinsuyu_tree::CityPreset>> {
|
|||||||
Some(atlas)
|
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:
|
/// Etiqueta breve para mostrar al elegir una carta en el picker:
|
||||||
/// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
|
/// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
|
||||||
fn format_birth_brief(birth: &tahuantinsuyu_model::StoredBirthData) -> String {
|
fn format_birth_brief(birth: &tahuantinsuyu_model::StoredBirthData) -> String {
|
||||||
@@ -1286,9 +1418,32 @@ impl Render for Shell {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use gpui::TestAppContext;
|
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) {
|
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:
|
/// Item activo del tree. El canvas reacciona a este tipo:
|
||||||
/// - `Chart` → abre la carta puntual.
|
/// - `Chart` → abre la carta puntual.
|
||||||
/// - `Contact` / `Group` → muestra thumbnails de las cartas descendientes.
|
/// - `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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
pub enum TreeSelection {
|
pub enum TreeSelection {
|
||||||
Group(GroupId),
|
Group(GroupId),
|
||||||
Contact(ContactId),
|
Contact(ContactId),
|
||||||
Chart(ChartId),
|
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_GROUP: &str = "g:";
|
||||||
const PREFIX_CONTACT: &str = "c:";
|
const PREFIX_CONTACT: &str = "c:";
|
||||||
const PREFIX_CHART: &str = "h:";
|
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
|
// Eventos públicos
|
||||||
@@ -74,6 +77,12 @@ impl MenuTarget {
|
|||||||
TreeSelection::Group(id) => MenuTarget::Group(*id),
|
TreeSelection::Group(id) => MenuTarget::Group(*id),
|
||||||
TreeSelection::Contact(id) => MenuTarget::Contact(*id),
|
TreeSelection::Contact(id) => MenuTarget::Contact(*id),
|
||||||
TreeSelection::Chart(id) => MenuTarget::Chart(*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>) {
|
pub fn refresh(&mut self, cx: &mut Context<'_, Self>) {
|
||||||
let mut rows = Vec::new();
|
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_groups(None, 0, &mut rows);
|
||||||
self.append_contacts(None, 0, &mut rows);
|
|
||||||
self.inner.update(cx, |t, cx| t.set_rows(rows, cx));
|
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> {
|
fn parse_row(id: &RowId) -> Option<TreeSelection> {
|
||||||
let s = id.as_str();
|
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) {
|
if let Some(rest) = s.strip_prefix(PREFIX_GROUP) {
|
||||||
return rest.parse().ok().map(TreeSelection::Group);
|
return rest.parse().ok().map(TreeSelection::Group);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user