feat(tahuantinsuyu): editor inline para cartas libres (F2)

Las cartas libres ahora se pueden editar en su totalidad (fecha,
hora, lugar, lat/lon/alt, TZ, label) desde el menú contextual.
La edición es **in-memory** — la carta se queda como libre tras
el cambio; para persistirla hay que usar "Guardar como…".

Tree:
- Nuevo `Modal::EditFreeChart { source_id, form, error }`
  paralelo al `Modal::EditChart` existente. Reusa la misma
  `ChartForm` (11 TextInputs: name + place + date + time + TZ
  + lat/lon/alt) y la misma función `render_chart_form` para
  pintarlo. El title cambia a "Editar carta libre".
- `open_edit_free_chart_modal(source_id, w, cx)`: lee el entry
  de `self.free_charts` (que ahora trae `birth_data` además
  de id+label), pre-puebla el form, y abre el modal.
- Submit: `build_chart_from_form` parsea + valida; al éxito
  emite nuevo evento `TreeEvent::FreeChartEditConfirmed
  { source_id, birth_data, label }`. Al error, conserva el
  modal con la pill destructiva.
- City picker funciona como antes — el branch de
  `apply_city_preset` se extendió para que reconozca
  `Modal::EditFreeChart` además de Create/Edit.

Modelo:
- `FreeChartEntry` ahora incluye `birth_data: StoredBirthData`
  además de id+label. El shell se lo pasa al setter; el tree
  lo usa para pre-poblar el form sin tener que pedirlo al
  shell.

Shell:
- `push_free_charts_to_tree` clona `birth_data` en cada entry.
- Handler `FreeChartEditConfirmed`: actualiza
  `free_charts[id]` con los nuevos datos + label, re-publica
  al tree, y si la carta editada era la activa, re-renderea
  el wheel.

Menú contextual de "Cartas libres" / `<carta libre>` ahora:
- Editar datos…
- Guardar como…
- Borrar  (no se ofrece sobre sky-now)

10 tests verdes (sin afectar lo testeado).

Próximo y último: F4 — botón "Guardar como…" en cada módulo
overlay (RS, prog, sa, gr) que captura la carta derivada con
un sufijo automático en el contacto original.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-18 21:51:51 +00:00
parent a83b0396ce
commit dd836522ab
2 changed files with 145 additions and 3 deletions
+28
View File
@@ -249,6 +249,7 @@ impl Shell {
entries.push(FreeChartEntry { entries.push(FreeChartEntry {
id: FreeChartId::sky_now(), id: FreeChartId::sky_now(),
label: c.label.clone(), label: c.label.clone(),
birth_data: c.birth_data.clone(),
}); });
} }
let mut others: Vec<(&FreeChartId, &Chart)> = self let mut others: Vec<(&FreeChartId, &Chart)> = self
@@ -261,6 +262,7 @@ impl Shell {
entries.push(FreeChartEntry { entries.push(FreeChartEntry {
id: id.clone(), id: id.clone(),
label: c.label.clone(), label: c.label.clone(),
birth_data: c.birth_data.clone(),
}); });
} }
self.tree self.tree
@@ -502,6 +504,32 @@ impl Shell {
); );
return; return;
} }
TreeEvent::FreeChartEditConfirmed {
source_id,
birth_data,
label,
} => {
if let Some(chart) = self.free_charts.get_mut(source_id) {
chart.birth_data = birth_data.clone();
chart.label = label.clone();
}
self.push_free_charts_to_tree(cx);
// Si la carta editada era la activa, re-render.
if let Some(current) = self.current_chart.as_mut() {
// Heurística: comparamos por label (ya cambiado al
// que pidió el usuario). Si el label de la activa
// coincide, era esta carta.
if current.label == label.clone()
|| current.birth_data.subject_name.as_deref() == Some("Cielo")
{
if let Some(updated) = self.free_charts.get(source_id) {
*current = updated.clone();
self.render_current(cx);
}
}
}
return;
}
TreeEvent::DeleteFreeChartRequested(id) => { TreeEvent::DeleteFreeChartRequested(id) => {
if id.is_sky_now() { if id.is_sky_now() {
return; // no se borra el Cielo return; // no se borra el Cielo
@@ -79,6 +79,13 @@ pub enum TreeEvent {
contact: Option<ContactId>, contact: Option<ContactId>,
new_contact_name: Option<String>, new_contact_name: Option<String>,
}, },
/// Submit del modal "Editar datos" para una carta libre. El
/// shell aplica al mapa `free_charts` y re-renderea el wheel.
FreeChartEditConfirmed {
source_id: FreeChartId,
birth_data: StoredBirthData,
label: String,
},
} }
// ===================================================================== // =====================================================================
@@ -157,6 +164,15 @@ enum Modal {
form: ChartForm, form: ChartForm,
error: Option<SharedString>, error: Option<SharedString>,
}, },
/// Editar los datos (fecha/hora/lugar) de una carta libre.
/// Reusa el mismo `ChartForm` que `EditChart`. El submit emite
/// `FreeChartEditConfirmed` que el shell aplica al mapa
/// `free_charts` y re-renderea el wheel.
EditFreeChart {
source_id: FreeChartId,
form: ChartForm,
error: Option<SharedString>,
},
/// Guardar una carta libre como `Chart` persistido. El usuario /// Guardar una carta libre como `Chart` persistido. El usuario
/// elige nombre + contacto destino (existente de la lista o /// elige nombre + contacto destino (existente de la lista o
/// uno nuevo creado al vuelo). El shell escucha /// uno nuevo creado al vuelo). El shell escucha
@@ -227,13 +243,15 @@ pub struct TahuantinsuyuTree {
free_charts: Vec<FreeChartEntry>, free_charts: Vec<FreeChartEntry>,
} }
/// Entrada de la sección "Cartas libres" — id + label visible. La /// Entrada de la sección "Cartas libres" — id + label visible +
/// estructura del Chart en sí vive en el shell (`free_charts` de /// birth_data clonado (para pre-poblar el modal "Editar datos…").
/// `Shell`), no en el tree. /// El Chart completo vive en el shell; el tree mantiene esta
/// proyección compacta para no depender del shell en cada operación.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct FreeChartEntry { pub struct FreeChartEntry {
pub id: FreeChartId, pub id: FreeChartId,
pub label: String, pub label: String,
pub birth_data: StoredBirthData,
} }
/// Preset de ciudad con datos canónicos para autocompletar lat/lon/tz /// Preset de ciudad con datos canónicos para autocompletar lat/lon/tz
@@ -777,6 +795,7 @@ impl TahuantinsuyuTree {
let form = match self.modal.as_mut() { let form = match self.modal.as_mut() {
Some(Modal::CreateChart { form, .. }) => form, Some(Modal::CreateChart { form, .. }) => form,
Some(Modal::EditChart { form, .. }) => form, Some(Modal::EditChart { form, .. }) => form,
Some(Modal::EditFreeChart { form, .. }) => form,
_ => { _ => {
self.city_picker_open = false; self.city_picker_open = false;
cx.notify(); cx.notify();
@@ -878,6 +897,58 @@ impl TahuantinsuyuTree {
/// contactos es un snapshot recursivo de toda la jerarquía /// contactos es un snapshot recursivo de toda la jerarquía
/// (no solo el nivel raíz). El usuario elige uno existente o /// (no solo el nivel raíz). El usuario elige uno existente o
/// deja en "Nuevo contacto" para que se cree uno al confirmar. /// deja en "Nuevo contacto" para que se cree uno al confirmar.
/// Abre el modal "Editar datos" para una carta libre. Pre-puebla
/// `ChartForm` con `birth_data` actual de la entry. Submit emite
/// `FreeChartEditConfirmed` que el shell aplica al mapa de
/// `free_charts` y re-renderea.
fn open_edit_free_chart_modal(
&mut self,
source_id: FreeChartId,
window: &mut Window,
cx: &mut Context<'_, Self>,
) {
let entry = match self.free_charts.iter().find(|e| e.id == source_id) {
Some(e) => e.clone(),
None => return,
};
let bd = &entry.birth_data;
let form = ChartForm {
name: self.make_input("Etiqueta de la carta", &entry.label, window, cx),
place: self.make_input(
"Lugar (ciudad, país)",
bd.birthplace_label.as_deref().unwrap_or(""),
window,
cx,
),
year: self.make_input("Año", &bd.year.to_string(), window, cx),
month: self.make_input("Mes", &bd.month.to_string(), window, cx),
day: self.make_input("Día", &bd.day.to_string(), window, cx),
hour: self.make_input("Hora (0-23)", &bd.hour.to_string(), window, cx),
minute: self.make_input("Minuto", &bd.minute.to_string(), window, cx),
tz_offset_min: self.make_input(
"TZ offset (min)",
&bd.tz_offset_minutes.to_string(),
window,
cx,
),
lat: self.make_input("Latitud (°)", &format!("{}", bd.latitude_deg), window, cx),
lon: self.make_input(
"Longitud (°)",
&format!("{}", bd.longitude_deg),
window,
cx,
),
alt: self.make_input("Altitud (m)", &format!("{}", bd.altitude_m), window, cx),
};
form.name.update(cx, |i, _| i.request_focus(window));
self.modal = Some(Modal::EditFreeChart {
source_id,
form,
error: None,
});
self.close_menu(cx);
}
/// Cambia `selected_contact` del modal `SaveFreeChart` activo /// Cambia `selected_contact` del modal `SaveFreeChart` activo
/// sin recrear los inputs. Permite alternar entre los botones /// sin recrear los inputs. Permite alternar entre los botones
/// radio "contacto existente" y "Nuevo contacto…". /// radio "contacto existente" y "Nuevo contacto…".
@@ -1230,6 +1301,32 @@ impl TahuantinsuyuTree {
} }
} }
} }
Modal::EditFreeChart {
source_id,
form,
error: _,
} => {
let _ = value;
match build_chart_from_form(&form, cx) {
Ok((birth, label)) => {
cx.emit(TreeEvent::FreeChartEditConfirmed {
source_id,
birth_data: birth,
label,
});
self.modal = None;
cx.notify();
}
Err(msg) => {
self.modal = Some(Modal::EditFreeChart {
source_id,
form,
error: Some(SharedString::from(msg)),
});
cx.notify();
}
}
}
Modal::SaveFreeChart { Modal::SaveFreeChart {
source_id, source_id,
name, name,
@@ -1617,6 +1714,14 @@ impl TahuantinsuyuTree {
} }
MenuTarget::FreeChart(fid) => { MenuTarget::FreeChart(fid) => {
let is_sky = fid.is_sky_now(); let is_sky = fid.is_sky_now();
let fid_edit = fid.clone();
items = items.child(
menu_item("tts-menu-edit-free", "Editar datos…", theme).on_click(
cx.listener(move |this, _: &ClickEvent, w, cx| {
this.open_edit_free_chart_modal(fid_edit.clone(), w, cx);
}),
),
);
let fid_save = fid.clone(); let fid_save = fid.clone();
items = items.child( items = items.child(
menu_item("tts-menu-save-free", "Guardar como…", theme).on_click( menu_item("tts-menu-save-free", "Guardar como…", theme).on_click(
@@ -1708,6 +1813,15 @@ impl TahuantinsuyuTree {
&self.city_atlas, &self.city_atlas,
cx, cx,
), ),
Modal::EditFreeChart { form, error, .. } => render_chart_form(
theme,
"Editar carta libre",
form,
error.clone(),
self.city_picker_open,
&self.city_atlas,
cx,
),
Modal::SaveFreeChart { Modal::SaveFreeChart {
source_id, source_id,
name, name,