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,
|
svg_export,
|
||||||
};
|
};
|
||||||
use tahuantinsuyu_model::{
|
use tahuantinsuyu_model::{
|
||||||
Chart, ChartId, ChartKind, ContactId, ModuleState, StoredBirthData, StoredChartConfig,
|
Chart, ChartId, ChartKind, ContactId, FreeChartId, ModuleState, StoredBirthData,
|
||||||
TreeSelection,
|
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, FreeChartEntry, TahuantinsuyuTree, TreeEvent,
|
||||||
|
};
|
||||||
use yahweh_core::{LayoutDirection, NodeId};
|
use yahweh_core::{LayoutDirection, NodeId};
|
||||||
use yahweh_theme::Theme;
|
use yahweh_theme::Theme;
|
||||||
use yahweh_widget_container_core::ChildSlot;
|
use yahweh_widget_container_core::ChildSlot;
|
||||||
@@ -128,6 +130,15 @@ pub struct Shell {
|
|||||||
/// `render_current` lo incrementa y la closure async compara antes
|
/// `render_current` lo incrementa y la closure async compara antes
|
||||||
/// de aplicar el render al canvas.
|
/// de aplicar el render al canvas.
|
||||||
render_seq: u64,
|
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 {
|
impl Shell {
|
||||||
@@ -204,17 +215,58 @@ impl Shell {
|
|||||||
current_offset_minutes: 0,
|
current_offset_minutes: 0,
|
||||||
module_configs: HashMap::new(),
|
module_configs: HashMap::new(),
|
||||||
render_seq: 0,
|
render_seq: 0,
|
||||||
|
free_charts: HashMap::new(),
|
||||||
|
next_free_id: 0,
|
||||||
};
|
};
|
||||||
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
|
// Inicializar "Cielo ahora" como carta libre fija y empujarla
|
||||||
// siempre arranca viendo el estado del firmamento actual,
|
// al tree. Queda seleccionada por default — el usuario abre
|
||||||
// incluso si la store está vacía.
|
// la app y ya ve el firmamento actual.
|
||||||
shell.apply_selection(TreeSelection::PresentSky, cx);
|
shell.ensure_sky_now(cx);
|
||||||
|
shell.apply_selection(
|
||||||
|
TreeSelection::FreeChart(FreeChartId::sky_now()),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
shell
|
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
|
/// Arma el árbol de splitters según el dock pedido y persiste la
|
||||||
/// elección. Idempotente: llamar con el dock actual reconstruye los
|
/// elección. Idempotente: llamar con el dock actual reconstruye los
|
||||||
/// children con flexes leídos del setting (útil tras `new`).
|
/// children con flexes leídos del setting (útil tras `new`).
|
||||||
@@ -416,10 +468,84 @@ impl Shell {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
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);
|
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>) {
|
fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
|
||||||
match sel {
|
match sel {
|
||||||
TreeSelection::Chart(id) => {
|
TreeSelection::Chart(id) => {
|
||||||
@@ -514,6 +640,56 @@ 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::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 => {
|
TreeSelection::GeneralRoot => {
|
||||||
// "General" agrupa los contactos sin grupo padre. El
|
// "General" agrupa los contactos sin grupo padre. El
|
||||||
// canvas muestra thumbnails de TODAS las cartas de
|
// 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));
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -312,11 +312,35 @@ pub struct ModuleState {
|
|||||||
// Selección activa (qué muestra el canvas)
|
// 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:
|
/// 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);
|
/// - `FreeChart` → carta libre (no anclada a contacto). Incluye la
|
||||||
/// no vive en la store, se computa al vuelo cuando el host la pide.
|
/// 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
|
/// - `GeneralRoot` → nodo branch virtual que agrupa los contactos
|
||||||
/// sin grupo padre (contacts con parent=None).
|
/// sin grupo padre (contacts con parent=None).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
@@ -324,7 +348,8 @@ pub enum TreeSelection {
|
|||||||
Group(GroupId),
|
Group(GroupId),
|
||||||
Contact(ContactId),
|
Contact(ContactId),
|
||||||
Chart(ChartId),
|
Chart(ChartId),
|
||||||
PresentSky,
|
FreeChart(FreeChartId),
|
||||||
|
FreeChartsRoot,
|
||||||
GeneralRoot,
|
GeneralRoot,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use tahuantinsuyu_model::{
|
use tahuantinsuyu_model::{
|
||||||
ChartId, ChartKind, ContactId, GroupId, StoredBirthData, StoredChartConfig, TimeCertainty,
|
ChartId, ChartKind, ContactId, FreeChartId, GroupId, StoredBirthData, StoredChartConfig,
|
||||||
TreeSelection,
|
TimeCertainty, TreeSelection,
|
||||||
};
|
};
|
||||||
use tahuantinsuyu_store::Store;
|
use tahuantinsuyu_store::Store;
|
||||||
use yahweh_theme::Theme;
|
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_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.
|
/// Prefijo de IDs de filas que representan cartas libres.
|
||||||
const ROW_SKY_NOW: &str = "sky:now";
|
const PREFIX_FREE: &str = "f:";
|
||||||
|
/// IDs sentinela para los nodos virtuales fijos del tree.
|
||||||
const ROW_GENERAL: &str = "general";
|
const ROW_GENERAL: &str = "general";
|
||||||
|
const ROW_FREE_ROOT: &str = "free-root";
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Eventos públicos
|
// Eventos públicos
|
||||||
@@ -56,6 +58,18 @@ pub enum TreeEvent {
|
|||||||
/// Una mutación de la jerarquía aconteció (crear, borrar, renombrar).
|
/// Una mutación de la jerarquía aconteció (crear, borrar, renombrar).
|
||||||
/// El host puede usarlo para invalidar caches en otros widgets.
|
/// El host puede usarlo para invalidar caches en otros widgets.
|
||||||
HierarchyChanged,
|
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),
|
Group(GroupId),
|
||||||
Contact(ContactId),
|
Contact(ContactId),
|
||||||
Chart(ChartId),
|
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 {
|
impl MenuTarget {
|
||||||
@@ -80,9 +99,8 @@ impl MenuTarget {
|
|||||||
// "General" comparte menu con Root — el target lógico es
|
// "General" comparte menu con Root — el target lógico es
|
||||||
// crear contactos sin grupo padre.
|
// crear contactos sin grupo padre.
|
||||||
TreeSelection::GeneralRoot => MenuTarget::Root,
|
TreeSelection::GeneralRoot => MenuTarget::Root,
|
||||||
// "Cielo ahora" no admite operaciones de menu — es una
|
TreeSelection::FreeChart(id) => MenuTarget::FreeChart(id.clone()),
|
||||||
// carta efímera del momento, no se edita ni borra.
|
TreeSelection::FreeChartsRoot => MenuTarget::FreeChartsRoot,
|
||||||
TreeSelection::PresentSky => MenuTarget::Root,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,6 +191,21 @@ pub struct TahuantinsuyuTree {
|
|||||||
search_filter: String,
|
search_filter: String,
|
||||||
/// TextInput para el filtro — vive arriba del tree.
|
/// TextInput para el filtro — vive arriba del tree.
|
||||||
search_input: Entity<TextInput>,
|
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
|
/// Preset de ciudad con datos canónicos para autocompletar lat/lon/tz
|
||||||
@@ -375,11 +408,22 @@ impl TahuantinsuyuTree {
|
|||||||
city_atlas: default_city_presets(),
|
city_atlas: default_city_presets(),
|
||||||
search_filter: String::new(),
|
search_filter: String::new(),
|
||||||
search_input,
|
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.refresh(cx);
|
||||||
me
|
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
|
/// Reemplaza el atlas de ciudades del dropdown. La app llama esto
|
||||||
/// al boot si encuentra un archivo TSV custom en disco.
|
/// al boot si encuentra un archivo TSV custom en disco.
|
||||||
pub fn set_city_atlas(&mut self, atlas: Vec<CityPreset>, cx: &mut Context<'_, Self>) {
|
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>) {
|
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.
|
// 1) General — branch fijo al top. Contiene los contactos sin
|
||||||
// No participa de search filter (es atemporal).
|
// grupo padre (parent=None). Siempre presente.
|
||||||
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);
|
let general_expanded = self.expanded.contains(ROW_GENERAL);
|
||||||
rows.push(TreeRow {
|
rows.push(TreeRow {
|
||||||
id: RowId::new(ROW_GENERAL.to_string()),
|
id: RowId::new(ROW_GENERAL.to_string()),
|
||||||
@@ -418,9 +451,37 @@ impl TahuantinsuyuTree {
|
|||||||
self.append_contacts(None, 1, &mut rows);
|
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);
|
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));
|
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),
|
input: self.make_input("Nueva etiqueta", ¤t, window, cx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuTarget::Root => return,
|
MenuTarget::Root | MenuTarget::FreeChartsRoot | MenuTarget::FreeChart(_) => return,
|
||||||
};
|
};
|
||||||
self.modal = Some(modal);
|
self.modal = Some(modal);
|
||||||
self.close_menu(cx);
|
self.close_menu(cx);
|
||||||
@@ -1066,7 +1127,7 @@ impl TahuantinsuyuTree {
|
|||||||
MenuTarget::Group(_) => ("este grupo (incluye sus subgrupos y contactos)", "group"),
|
MenuTarget::Group(_) => ("este grupo (incluye sus subgrupos y contactos)", "group"),
|
||||||
MenuTarget::Contact(_) => ("este contacto (incluye sus cartas)", "contact"),
|
MenuTarget::Contact(_) => ("este contacto (incluye sus cartas)", "contact"),
|
||||||
MenuTarget::Chart(_) => ("esta carta", "chart"),
|
MenuTarget::Chart(_) => ("esta carta", "chart"),
|
||||||
MenuTarget::Root => return,
|
MenuTarget::Root | MenuTarget::FreeChartsRoot | MenuTarget::FreeChart(_) => return,
|
||||||
};
|
};
|
||||||
let answer = window.prompt(
|
let answer = window.prompt(
|
||||||
PromptLevel::Warning,
|
PromptLevel::Warning,
|
||||||
@@ -1083,6 +1144,7 @@ impl TahuantinsuyuTree {
|
|||||||
}
|
}
|
||||||
let _ = this.update(cx, |this, cx| {
|
let _ = this.update(cx, |this, cx| {
|
||||||
match target_clone {
|
match target_clone {
|
||||||
|
MenuTarget::FreeChartsRoot | MenuTarget::FreeChart(_) => {}
|
||||||
MenuTarget::Group(id) => {
|
MenuTarget::Group(id) => {
|
||||||
let _ = this.store.delete_group(id);
|
let _ = this.store.delete_group(id);
|
||||||
}
|
}
|
||||||
@@ -1226,12 +1288,15 @@ 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 {
|
if s == ROW_GENERAL {
|
||||||
return Some(TreeSelection::GeneralRoot);
|
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) {
|
if let Some(rest) = s.strip_prefix(PREFIX_GROUP) {
|
||||||
return rest.parse().ok().map(TreeSelection::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) => {
|
MenuTarget::Chart(id) => {
|
||||||
items = items.child(menu_item("tts-menu-open-h", "Abrir", theme).on_click(
|
items = items.child(menu_item("tts-menu-open-h", "Abrir", theme).on_click(
|
||||||
cx.listener(move |this, _: &ClickEvent, _w, cx| {
|
cx.listener(move |this, _: &ClickEvent, _w, cx| {
|
||||||
|
|||||||
Reference in New Issue
Block a user