diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs index 744e99d..00dd533 100644 --- a/crates/apps/tahuantinsuyu/src/shell.rs +++ b/crates/apps/tahuantinsuyu/src/shell.rs @@ -36,12 +36,14 @@ use tahuantinsuyu_engine::{ svg_export, }; use tahuantinsuyu_model::{ - Chart, ChartId, ChartKind, ContactId, ModuleState, StoredBirthData, StoredChartConfig, - TreeSelection, + Chart, ChartId, ChartKind, ContactId, FreeChartId, ModuleState, StoredBirthData, + StoredChartConfig, TreeSelection, }; use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent}; use tahuantinsuyu_store::Store; -use tahuantinsuyu_tree::{parse_city_atlas_tsv, TahuantinsuyuTree, TreeEvent}; +use tahuantinsuyu_tree::{ + parse_city_atlas_tsv, FreeChartEntry, TahuantinsuyuTree, TreeEvent, +}; use yahweh_core::{LayoutDirection, NodeId}; use yahweh_theme::Theme; use yahweh_widget_container_core::ChildSlot; @@ -128,6 +130,15 @@ pub struct Shell { /// `render_current` lo incrementa y la closure async compara antes /// de aplicar el render al canvas. render_seq: u64, + /// Cartas "libres" — no persistidas en la store. Incluye la + /// especial `sky_now()` (Cielo ahora) + cualquier creada por el + /// usuario desde la sección "Cartas libres" del tree. Cada vez + /// que muta este mapa, llamamos `tree.set_free_charts`. + free_charts: HashMap, + /// Counter para id de cartas libres nuevas — el id se concatena + /// con el prefijo `free-` y el counter, así son únicos dentro de + /// la sesión sin pelearse con UUIDs reales. + next_free_id: u32, } impl Shell { @@ -204,17 +215,58 @@ impl Shell { current_offset_minutes: 0, module_configs: HashMap::new(), render_seq: 0, + free_charts: HashMap::new(), + next_free_id: 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); + // Inicializar "Cielo ahora" como carta libre fija y empujarla + // al tree. Queda seleccionada por default — el usuario abre + // la app y ya ve el firmamento actual. + shell.ensure_sky_now(cx); + shell.apply_selection( + TreeSelection::FreeChart(FreeChartId::sky_now()), + cx, + ); shell } + /// Garantiza que `sky-now` exista en `free_charts` y publica la + /// lista actualizada al tree. Recomputa la carta del cielo si ya + /// estaba (refresca al reloj actual). + fn ensure_sky_now(&mut self, cx: &mut Context) { + self.free_charts + .insert(FreeChartId::sky_now(), build_present_sky_chart()); + self.push_free_charts_to_tree(cx); + } + + fn push_free_charts_to_tree(&self, cx: &mut Context) { + // Orden de display: "Cielo ahora" primero, después el resto + // por id (los ids `free-N` quedan ordenados por creación). + let mut entries: Vec = Vec::new(); + if let Some(c) = self.free_charts.get(&FreeChartId::sky_now()) { + entries.push(FreeChartEntry { + id: FreeChartId::sky_now(), + label: c.label.clone(), + }); + } + let mut others: Vec<(&FreeChartId, &Chart)> = self + .free_charts + .iter() + .filter(|(k, _)| !k.is_sky_now()) + .collect(); + others.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str())); + for (id, c) in others { + entries.push(FreeChartEntry { + id: id.clone(), + label: c.label.clone(), + }); + } + self.tree + .update(cx, |t, cx| t.set_free_charts(entries, cx)); + } + /// 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`). @@ -416,10 +468,84 @@ impl Shell { cx.notify(); return; } + TreeEvent::NewFreeChartRequested => { + let id = FreeChartId(format!("free-{}", self.next_free_id)); + self.next_free_id += 1; + // Default: misma data que "Cielo ahora" pero con label + // distinto. El usuario edita después con el editor + // inline (fase B). + let mut chart = build_present_sky_chart(); + chart.label = format!("Carta libre #{}", self.next_free_id); + self.free_charts.insert(id.clone(), chart); + self.push_free_charts_to_tree(cx); + self.apply_selection(TreeSelection::FreeChart(id), cx); + return; + } + TreeEvent::SaveFreeChartRequested(id) => { + // Por ahora: log + persistencia simple en "General" + // (contact nuevo con el label de la carta). En la + // fase B se reemplaza por un modal con dropdown de + // contacto y custom name. + self.save_free_chart_quick(id.clone()); + return; + } + TreeEvent::DeleteFreeChartRequested(id) => { + if id.is_sky_now() { + return; // no se borra el Cielo + } + self.free_charts.remove(id); + self.push_free_charts_to_tree(cx); + // Si la carta borrada era la activa, vuelve al Cielo. + if let Some(current) = self.current_chart.as_ref() { + if current.label.starts_with(&format!("Carta libre")) { + self.apply_selection( + TreeSelection::FreeChart(FreeChartId::sky_now()), + cx, + ); + } + } + return; + } }; self.apply_selection(selection.clone(), cx); } + /// Guardado rápido de una carta libre como carta natal bajo un + /// contacto nuevo (auto-nombrado con el label de la carta libre). + /// Es la versión MVP — en la fase B se reemplaza por un modal + /// con dropdown de contacto + input de nombre custom. + fn save_free_chart_quick(&mut self, id: FreeChartId) { + let Some(chart) = self.free_charts.get(&id).cloned() else { + return; + }; + // 1) Crear o reusar un contacto. Por simplicidad, creamos uno + // nuevo con el label de la carta como nombre. La fase B le + // dará al usuario el dropdown. + let contact = match self.store.create_contact(None, &chart.label, None) { + Ok(c) => c, + Err(e) => { + eprintln!("[shell] create_contact al guardar libre: {}", e); + return; + } + }; + // 2) Crear la carta bajo ese contacto. + if let Err(e) = self.store.create_chart( + contact.id, + chart.kind, + &chart.label, + &chart.birth_data, + &chart.config, + chart.related_chart_id, + ) { + eprintln!("[shell] create_chart al guardar libre: {}", e); + return; + } + eprintln!( + "[shell] carta libre '{}' guardada bajo contacto '{}' (id {})", + chart.label, contact.name, contact.id + ); + } + fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context) { match sel { TreeSelection::Chart(id) => { @@ -514,6 +640,56 @@ impl Shell { }); self.panel.update(cx, |p, cx| p.set_active_kind(None, cx)); } + TreeSelection::FreeChart(id) => { + // Si es "Cielo ahora", refrescamos el reloj antes de + // renderizar — el usuario espera ver el momento actual, + // no el momento al que se cargó la carta. + if id.is_sky_now() { + self.free_charts + .insert(FreeChartId::sky_now(), build_present_sky_chart()); + self.push_free_charts_to_tree(cx); + } + let Some(chart) = self.free_charts.get(&id).cloned() else { + eprintln!("[shell] free chart {:?} no encontrada", id); + return; + }; + 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); + } + TreeSelection::FreeChartsRoot => { + // Grilla de thumbnails de las cartas libres. Como + // ThumbnailItem requiere ChartId, usamos `default()` + // para las libres — el canvas las muestra como + // entradas no-clickeables (eso está OK; el usuario + // hace click en el row del tree para seleccionar). + self.current_chart = None; + self.current_offset_minutes = 0; + let items: Vec = self + .free_charts + .values() + .map(|c| ThumbnailItem { + chart_id: c.id, + label: SharedString::from(c.label.clone()), + subtitle: Some(SharedString::from("libre".to_string())), + preview: None, + }) + .collect(); + 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::GeneralRoot => { // "General" agrupa los contactos sin grupo padre. El // canvas muestra thumbnails de TODAS las cartas de @@ -554,21 +730,6 @@ impl Shell { }); 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); - } } } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs index 3737d6a..1c90e2d 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs @@ -312,11 +312,35 @@ pub struct ModuleState { // Selección activa (qué muestra el canvas) // ===================================================================== +/// Identificador de una carta "libre" — efímera, no persistida en la +/// store. Llave de un `HashMap` en el shell. El valor `SKY_NOW_ID` +/// está reservado para la carta del instante actual; otros se +/// generan al vuelo como UUIDs string-encoded. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct FreeChartId(pub String); + +impl FreeChartId { + pub fn sky_now() -> Self { + Self(SKY_NOW_ID.into()) + } + pub fn is_sky_now(&self) -> bool { + self.0 == SKY_NOW_ID + } + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Sentinela del id de la carta "Cielo ahora" — siempre presente +/// como primer elemento de la sección "Cartas libres" del tree. +pub const SKY_NOW_ID: &str = "sky-now"; + /// 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. +/// - `FreeChart` → carta libre (no anclada a contacto). Incluye la +/// especial "Cielo ahora" + cualquier creada por el usuario. +/// - `FreeChartsRoot` → branch virtual de la sección "Cartas libres". /// - `GeneralRoot` → nodo branch virtual que agrupa los contactos /// sin grupo padre (contacts con parent=None). #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -324,7 +348,8 @@ pub enum TreeSelection { Group(GroupId), Contact(ContactId), Chart(ChartId), - PresentSky, + FreeChart(FreeChartId), + FreeChartsRoot, GeneralRoot, } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs index 3f29abe..4f8b839 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs @@ -30,8 +30,8 @@ use gpui::{ }; use tahuantinsuyu_model::{ - ChartId, ChartKind, ContactId, GroupId, StoredBirthData, StoredChartConfig, TimeCertainty, - TreeSelection, + ChartId, ChartKind, ContactId, FreeChartId, GroupId, StoredBirthData, StoredChartConfig, + TimeCertainty, TreeSelection, }; use tahuantinsuyu_store::Store; use yahweh_theme::Theme; @@ -41,9 +41,11 @@ 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"; +/// Prefijo de IDs de filas que representan cartas libres. +const PREFIX_FREE: &str = "f:"; +/// IDs sentinela para los nodos virtuales fijos del tree. const ROW_GENERAL: &str = "general"; +const ROW_FREE_ROOT: &str = "free-root"; // ===================================================================== // Eventos públicos @@ -56,6 +58,18 @@ pub enum TreeEvent { /// Una mutación de la jerarquía aconteció (crear, borrar, renombrar). /// El host puede usarlo para invalidar caches en otros widgets. HierarchyChanged, + /// El usuario pidió crear una carta libre nueva. El shell la + /// agrega a su mapa, le da un id efímero, y llama + /// `set_free_charts` con la lista actualizada. + NewFreeChartRequested, + /// El usuario pidió guardar una carta libre como `Chart` + /// persistido. El shell abre su propio modal con dropdown de + /// contacto + input de nombre y al confirmar invoca + /// `store.create_chart`. + SaveFreeChartRequested(FreeChartId), + /// Borrar una carta libre del mapa del shell. Si es `sky-now`, + /// el shell ignora (no se puede borrar el Cielo). + DeleteFreeChartRequested(FreeChartId), } // ===================================================================== @@ -69,6 +83,11 @@ enum MenuTarget { Group(GroupId), Contact(ContactId), Chart(ChartId), + /// Branch "Cartas libres" — menú con "Nueva carta libre". + FreeChartsRoot, + /// Una carta libre concreta — menú con "Guardar como…", + /// "Renombrar" y "Borrar" (salvo `sky-now`, que no se borra). + FreeChart(FreeChartId), } impl MenuTarget { @@ -80,9 +99,8 @@ impl MenuTarget { // "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, + TreeSelection::FreeChart(id) => MenuTarget::FreeChart(id.clone()), + TreeSelection::FreeChartsRoot => MenuTarget::FreeChartsRoot, } } } @@ -173,6 +191,21 @@ pub struct TahuantinsuyuTree { search_filter: String, /// TextInput para el filtro — vive arriba del tree. search_input: Entity, + /// Lista de cartas libres a mostrar bajo "Cartas libres". El shell + /// la actualiza vía [`Self::set_free_charts`] cada vez que crea, + /// renombra o borra una. El orden de inserción es el de display + /// (los nuevos van al final; "Cielo ahora" siempre va primero por + /// convención del shell). + free_charts: Vec, +} + +/// Entrada de la sección "Cartas libres" — id + label visible. La +/// estructura del Chart en sí vive en el shell (`free_charts` de +/// `Shell`), no en el tree. +#[derive(Clone, Debug)] +pub struct FreeChartEntry { + pub id: FreeChartId, + pub label: String, } /// Preset de ciudad con datos canónicos para autocompletar lat/lon/tz @@ -375,11 +408,22 @@ impl TahuantinsuyuTree { city_atlas: default_city_presets(), search_filter: String::new(), search_input, + free_charts: Vec::new(), }; + // "Cartas libres" expandida por default — el usuario espera ver + // "Cielo ahora" sin tener que hacer click en el chevron. + me.expanded.insert(ROW_FREE_ROOT.to_string()); me.refresh(cx); me } + /// Reemplaza la lista de cartas libres del tree. El shell la llama + /// cada vez que crea, renombra o borra una carta libre. + pub fn set_free_charts(&mut self, entries: Vec, cx: &mut Context<'_, Self>) { + self.free_charts = entries; + self.refresh(cx); + } + /// Reemplaza el atlas de ciudades del dropdown. La app llama esto /// al boot si encuentra un archivo TSV custom en disco. pub fn set_city_atlas(&mut self, atlas: Vec, cx: &mut Context<'_, Self>) { @@ -392,19 +436,8 @@ 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. + // 1) General — branch fijo al top. Contiene los contactos sin + // grupo padre (parent=None). Siempre presente. let general_expanded = self.expanded.contains(ROW_GENERAL); rows.push(TreeRow { id: RowId::new(ROW_GENERAL.to_string()), @@ -418,9 +451,37 @@ impl TahuantinsuyuTree { self.append_contacts(None, 1, &mut rows); } - // 3) Resto: groups top-level con sus contenidos. + // 2) Groups top-level con sus contenidos. self.append_groups(None, 0, &mut rows); + // 3) Cartas libres — branch fijo al FONDO. Contiene "Cielo + // ahora" + cualquier carta libre creada por el usuario. + // Permanece visible aún sin entries (el usuario puede + // crear nuevas desde su menu contextual). + let free_expanded = self.expanded.contains(ROW_FREE_ROOT); + rows.push(TreeRow { + id: RowId::new(ROW_FREE_ROOT.to_string()), + label: "Cartas libres".to_string(), + depth: 0, + kind: RowKind::Branch, + expanded: free_expanded, + icon: Some("🜨".into()), + }); + if free_expanded { + for e in &self.free_charts { + let id_str = format!("{}{}", PREFIX_FREE, e.id.as_str()); + let icon = if e.id.is_sky_now() { "⏱" } else { "✦" }; + rows.push(TreeRow { + id: RowId::new(id_str), + label: e.label.clone(), + depth: 1, + kind: RowKind::Leaf, + expanded: false, + icon: Some(icon.into()), + }); + } + } + self.inner.update(cx, |t, cx| t.set_rows(rows, cx)); } @@ -870,7 +931,7 @@ impl TahuantinsuyuTree { input: self.make_input("Nueva etiqueta", ¤t, window, cx), } } - MenuTarget::Root => return, + MenuTarget::Root | MenuTarget::FreeChartsRoot | MenuTarget::FreeChart(_) => return, }; self.modal = Some(modal); self.close_menu(cx); @@ -1066,7 +1127,7 @@ impl TahuantinsuyuTree { MenuTarget::Group(_) => ("este grupo (incluye sus subgrupos y contactos)", "group"), MenuTarget::Contact(_) => ("este contacto (incluye sus cartas)", "contact"), MenuTarget::Chart(_) => ("esta carta", "chart"), - MenuTarget::Root => return, + MenuTarget::Root | MenuTarget::FreeChartsRoot | MenuTarget::FreeChart(_) => return, }; let answer = window.prompt( PromptLevel::Warning, @@ -1083,6 +1144,7 @@ impl TahuantinsuyuTree { } let _ = this.update(cx, |this, cx| { match target_clone { + MenuTarget::FreeChartsRoot | MenuTarget::FreeChart(_) => {} MenuTarget::Group(id) => { let _ = this.store.delete_group(id); } @@ -1226,12 +1288,15 @@ 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 s == ROW_FREE_ROOT { + return Some(TreeSelection::FreeChartsRoot); + } + if let Some(rest) = s.strip_prefix(PREFIX_FREE) { + return Some(TreeSelection::FreeChart(FreeChartId(rest.to_string()))); + } if let Some(rest) = s.strip_prefix(PREFIX_GROUP) { return rest.parse().ok().map(TreeSelection::Group); } @@ -1361,6 +1426,40 @@ impl TahuantinsuyuTree { }), )); } + MenuTarget::FreeChartsRoot => { + items = items.child( + menu_item("tts-menu-new-free", "Nueva carta libre", theme).on_click( + cx.listener(|this, _: &ClickEvent, _w, cx| { + cx.emit(TreeEvent::NewFreeChartRequested); + this.close_menu(cx); + }), + ), + ); + } + MenuTarget::FreeChart(fid) => { + let is_sky = fid.is_sky_now(); + let fid_save = fid.clone(); + items = items.child( + menu_item("tts-menu-save-free", "Guardar como…", theme).on_click( + cx.listener(move |this, _: &ClickEvent, _w, cx| { + cx.emit(TreeEvent::SaveFreeChartRequested(fid_save.clone())); + this.close_menu(cx); + }), + ), + ); + if !is_sky { + items = items.child(separator(theme)); + let fid_del = fid.clone(); + items = items.child( + menu_item("tts-menu-delete-free", "Borrar", theme).on_click( + cx.listener(move |this, _: &ClickEvent, _w, cx| { + cx.emit(TreeEvent::DeleteFreeChartRequested(fid_del.clone())); + this.close_menu(cx); + }), + ), + ); + } + } MenuTarget::Chart(id) => { items = items.child(menu_item("tts-menu-open-h", "Abrir", theme).on_click( cx.listener(move |this, _: &ClickEvent, _w, cx| {