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:
@@ -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<FreeChartId, Chart>,
|
||||
/// 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>) {
|
||||
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<Self>) {
|
||||
// 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<FreeChartEntry> = 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<Self>) {
|
||||
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<ThumbnailItem> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user