feat(tahuantinsuyu): "Cartas libres" como sección + guardar a contacto (fase A)

Estructura de cartas no-persistidas con CRUD básico en la UI.

Modelo:
- `FreeChartId(String)` con sentinela `sky_now()` reservado para la
  carta del cielo. Otros ids se generan al vuelo como `free-N`.
- `TreeSelection::FreeChart(FreeChartId)` y `FreeChartsRoot`
  reemplazan al variante puntual `PresentSky` (que era un caso
  especial paralelo).

Tree:
- Sección **"🜨 Cartas libres"** branch fijo al FONDO del tree
  (al contrario de "◇ General" que va arriba). Contiene "Cielo
  ahora" como primera leaf + cualquier carta libre creada.
  Expandida por default.
- Menu contextual:
  * sobre la sección: "Nueva carta libre" → `NewFreeChartRequested`
  * sobre una carta libre: "Guardar como…" + "Borrar" (`sky-now`
    no admite borrar)
- Setter `set_free_charts(Vec<FreeChartEntry>)` actualizado por
  el shell tras cada mutación.

Shell:
- Nuevo state: `free_charts: HashMap<FreeChartId, Chart>` +
  `next_free_id: u32`.
- `ensure_sky_now` inserta/refresca "Cielo ahora" contra el
  reloj actual. Al boot se llama y la carta queda seleccionada.
- `push_free_charts_to_tree` publica la lista al tree
  (sky-now primero, después los `free-N` ordenados).
- Handlers de los 3 nuevos eventos:
  * `NewFreeChartRequested` → crea entry, selecciona
  * `SaveFreeChartRequested(id)` → `save_free_chart_quick`
    (MVP: crea contacto nuevo con el label de la carta + carta
    bajo él; la fase B reemplaza por modal con dropdown)
  * `DeleteFreeChartRequested(id)` → quita de free_charts;
    si era la activa, vuelve al Cielo

10 tests verdes (sin cambios — la lógica nueva afecta paths que
no están cubiertos en los smoke tests actuales).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-18 21:11:36 +00:00
parent 72758e75ce
commit 72da2934e8
3 changed files with 336 additions and 51 deletions
@@ -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<TextInput>,
/// 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<FreeChartEntry>,
}
/// 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<FreeChartEntry>, 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<CityPreset>, 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) Generalbranch 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", &current, 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<TreeSelection> {
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| {