feat(tahuantinsuyu): modal "Guardar como…" real para cartas libres (F3)
Reemplaza el `save_free_chart_quick` MVP de la fase A por un
modal completo que el usuario controla:
Tree:
- Nuevo `Modal::SaveFreeChart { source_id, name, new_contact_name,
selected_contact, all_contacts, error }`.
- `open_save_free_chart_modal` abre el modal pre-poblando `name`
con el label de la carta libre y `selected_contact` con el
primer contacto existente (o `None` = nuevo contacto si no
hay ninguno).
- `gather_all_contacts` recorre la jerarquía recursivamente
devolviendo `(ContactId, "Grupo / Subgrupo / Contacto")` —
el usuario ve la ruta completa, no solo el nombre.
- `render_save_free_chart` pinta:
* Input "Nombre" pre-cargado
* Lista de contactos como botones radio (● / ○) + opción
"Nuevo contacto…" al final
* Si "Nuevo contacto…" seleccionado, aparece input
"Nombre del contacto nuevo"
* Botones Cancelar / Guardar
- `set_save_modal_contact` alterna el radio sin recrear inputs.
- Validaciones: nombre de carta no vacío; si `selected_contact`
es `None`, exigir `new_contact_name` no vacío. Errores se
muestran en una pill destructiva dentro del modal.
- Submit emite nuevo evento `TreeEvent::FreeChartSaveConfirmed
{ source_id, chart_name, contact, new_contact_name }`.
Shell:
- `persist_free_chart` resuelve el contacto destino (existente
o crea uno nuevo), llama `store.create_chart`, y al éxito
remueve la carta libre del mapa (salvo `sky-now`, que es
persistente). Si la carta libre estaba seleccionada, vuelve
al Cielo. Refresca opciones del picker para que el dropdown
ChartPicker incluya la carta recién guardada.
- El handler `SaveFreeChartRequested` queda como hook vacío;
el menú del tree abre el modal directamente con `window`.
10 tests verdes (no se afectaron los paths probados).
Próximo: F2 (editor inline de fecha/lugar/hora de la carta
libre) y F4 (botón "Guardar como…" en cada módulo overlay
con sufijo automático).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
a83b0396ce22d537b2630994af6deebe20d30c92 · by sergio · 2026-05-18T21:36:30Z (5 months ago) · parent 72da2934e · browse the tree at this commit
Semantic delta · 13
crates/apps/tahuantinsuyu/src/shell.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index 00dd533..c803645 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -481,12 +481,25 @@ impl Shell {
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());
+ TreeEvent::SaveFreeChartRequested(_id) => {
+ // El menú del tree abre el modal directamente; este
+ // evento queda como hook por si una tecla u otra
+ // UI quiere disparar el flujo sin pasar por el menú.
+ return;
+ }
+ TreeEvent::FreeChartSaveConfirmed {
+ source_id,
+ chart_name,
+ contact,
+ new_contact_name,
+ } => {
+ self.persist_free_chart(
+ source_id.clone(),
+ chart_name.clone(),
+ contact.clone(),
+ new_contact_name.clone(),
+ cx,
+ );
return;
}
TreeEvent::DeleteFreeChartRequested(id) => {
@@ -510,40 +523,70 @@ impl Shell {
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 {
+ /// Persiste una carta libre como `Chart` en la store. El usuario
+ /// eligió en el modal: nombre + contacto destino (existente o
+ /// uno nuevo creado al vuelo). La carta libre se REMUEVE del
+ /// mapa tras el persist exitoso — si quedaba seleccionada,
+ /// volvemos a "Cielo ahora". Si falla la persistencia, la carta
+ /// libre se conserva y logueamos.
+ fn persist_free_chart(
+ &mut self,
+ source_id: FreeChartId,
+ chart_name: String,
+ contact: Option<ContactId>,
+ new_contact_name: Option<String>,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(chart) = self.free_charts.get(&source_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);
+ // 1) Resolver el contact destino (existente o crear nuevo).
+ let contact_id = match (contact, new_contact_name) {
+ (Some(cid), _) => cid,
+ (None, Some(name)) => match self.store.create_contact(None, &name, None) {
+ Ok(c) => c.id,
+ Err(e) => {
+ eprintln!("[shell] create_contact al guardar libre: {}", e);
+ return;
+ }
+ },
+ (None, None) => {
+ eprintln!("[shell] persist_free_chart sin contacto ni nombre nuevo");
return;
}
};
- // 2) Crear la carta bajo ese contacto.
- if let Err(e) = self.store.create_chart(
- contact.id,
+ // 2) Crear la carta.
+ match self.store.create_chart(
+ contact_id,
chart.kind,
- &chart.label,
+ &chart_name,
&chart.birth_data,
&chart.config,
chart.related_chart_id,
) {
- eprintln!("[shell] create_chart al guardar libre: {}", e);
- return;
+ Ok(_) => {
+ eprintln!(
+ "[shell] carta libre {:?} guardada como '{}' bajo contacto {}",
+ source_id, chart_name, contact_id
+ );
+ }
+ Err(e) => {
+ eprintln!("[shell] create_chart al guardar libre: {}", e);
+ return;
+ }
}
- eprintln!(
- "[shell] carta libre '{}' guardada bajo contacto '{}' (id {})",
- chart.label, contact.name, contact.id
- );
+ // 3) Sky-now se conserva (siempre es); las demás se quitan
+ // del mapa libre. Si era la activa, volver al Cielo.
+ if !source_id.is_sky_now() {
+ self.free_charts.remove(&source_id);
+ self.push_free_charts_to_tree(cx);
+ // Si la activa era esta libre, regresar al Cielo.
+ self.apply_selection(
+ TreeSelection::FreeChart(FreeChartId::sky_now()),
+ cx,
+ );
+ }
+ self.refresh_chart_options(cx);
}
fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
index 4f8b839..821b8b6 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
@@ -70,6 +70,15 @@ pub enum TreeEvent {
/// Borrar una carta libre del mapa del shell. Si es `sky-now`,
/// el shell ignora (no se puede borrar el Cielo).
DeleteFreeChartRequested(FreeChartId),
+ /// Submit del modal "Guardar como" — el shell crea/usa el
+ /// contacto y persiste la carta. Si `contact` es `None`, el
+ /// shell crea uno nuevo con `new_contact_name`.
+ FreeChartSaveConfirmed {
+ source_id: FreeChartId,
+ chart_name: String,
+ contact: Option<ContactId>,
+ new_contact_name: Option<String>,
+ },
}
// =====================================================================
@@ -148,6 +157,25 @@ enum Modal {
form: ChartForm,
error: Option<SharedString>,
},
+ /// Guardar una carta libre como `Chart` persistido. El usuario
+ /// elige nombre + contacto destino (existente de la lista o
+ /// uno nuevo creado al vuelo). El shell escucha
+ /// `TreeEvent::FreeChartSaveConfirmed` y materializa.
+ SaveFreeChart {
+ source_id: FreeChartId,
+ name: Entity<TextInput>,
+ /// Nombre del contacto NUEVO (solo aplica si
+ /// `selected_contact == None`). Vacío para reusar uno
+ /// existente.
+ new_contact_name: Entity<TextInput>,
+ /// `Some(id)` = usar contacto existente; `None` = crear
+ /// contacto nuevo con `new_contact_name`.
+ selected_contact: Option<ContactId>,
+ /// Snapshot de contactos visibles al usuario en el momento
+ /// de abrir el modal. Incluye contact id + label (nombre).
+ all_contacts: Vec<(ContactId, String)>,
+ error: Option<SharedString>,
+ },
}
struct ChartForm {
@@ -845,6 +873,101 @@ impl TahuantinsuyuTree {
self.close_menu(cx);
}
+ /// Abre el modal "Guardar como" para una carta libre. Pre-puebla
+ /// el `name` con el label actual de la entry. La lista de
+ /// contactos es un snapshot recursivo de toda la jerarquía
+ /// (no solo el nivel raíz). El usuario elige uno existente o
+ /// deja en "Nuevo contacto" para que se cree uno al confirmar.
+ /// Cambia `selected_contact` del modal `SaveFreeChart` activo
+ /// sin recrear los inputs. Permite alternar entre los botones
+ /// radio "contacto existente" y "Nuevo contacto…".
+ fn set_save_modal_contact(
+ &mut self,
+ new_selection: Option<ContactId>,
+ expected_source: &FreeChartId,
+ cx: &mut Context<'_, Self>,
+ ) {
+ if let Some(Modal::SaveFreeChart {
+ source_id,
+ selected_contact,
+ ..
+ }) = self.modal.as_mut()
+ {
+ if source_id == expected_source {
+ *selected_contact = new_selection;
+ cx.notify();
+ }
+ }
+ }
+
+ fn open_save_free_chart_modal(
+ &mut self,
+ source_id: FreeChartId,
+ window: &mut Window,
+ cx: &mut Context<'_, Self>,
+ ) {
+ let default_label = self
+ .free_charts
+ .iter()
+ .find(|e| e.id == source_id)
+ .map(|e| e.label.clone())
+ .unwrap_or_else(|| "Carta libre".into());
+ let all_contacts = self.gather_all_contacts();
+ let name_input = self.make_input("Nombre de la carta", &default_label, window, cx);
+ let new_contact_input =
+ self.make_input("Nombre del contacto nuevo", "", window, cx);
+ // Default: primer contacto existente si lo hay; sino "Nuevo
+ // contacto" (None). Eso minimiza clicks cuando el usuario ya
+ // tiene contactos cargados.
+ let selected_contact = all_contacts.first().map(|(id, _)| *id);
+ self.modal = Some(Modal::SaveFreeChart {
+ source_id,
+ name: name_input,
+ new_contact_name: new_contact_input,
+ selected_contact,
+ all_contacts,
+ error: None,
+ });
+ self.close_menu(cx);
+ }
+
+ /// Snapshot recursivo de todos los contactos del árbol —
+ /// `(id, label)`. Usado por el modal "Guardar como" para
+ /// listar destinos. Las cartas se cuelgan del contacto que el
+ /// usuario elija.
+ fn gather_all_contacts(&self) -> Vec<(ContactId, String)> {
+ fn walk(
+ store: &Store,
+ parent: Option<GroupId>,
+ prefix: &str,
+ out: &mut Vec<(ContactId, String)>,
+ ) {
+ if let Ok(contacts) = store.list_contacts(parent) {
+ for c in contacts {
+ let label = if prefix.is_empty() {
+ c.name.clone()
+ } else {
+ format!("{}{}", prefix, c.name)
+ };
+ out.push((c.id, label));
+ }
+ }
+ if let Ok(groups) = store.list_groups(parent) {
+ for g in groups {
+ let new_prefix = if prefix.is_empty() {
+ format!("{} / ", g.name)
+ } else {
+ format!("{}{} / ", prefix, g.name)
+ };
+ walk(store, Some(g.id), &new_prefix, out);
+ }
+ }
+ }
+ let mut out = Vec::new();
+ walk(&self.store, None, "", &mut out);
+ out
+ }
+
fn open_create_chart(
&mut self,
contact: ContactId,
@@ -1107,6 +1230,62 @@ impl TahuantinsuyuTree {
}
}
}
+ Modal::SaveFreeChart {
+ source_id,
+ name,
+ new_contact_name,
+ selected_contact,
+ all_contacts,
+ error: _,
+ } => {
+ let _ = value;
+ let chart_name = name.read(cx).text().to_string();
+ let chart_name = chart_name.trim();
+ if chart_name.is_empty() {
+ self.modal = Some(Modal::SaveFreeChart {
+ source_id,
+ name,
+ new_contact_name,
+ selected_contact,
+ all_contacts,
+ error: Some("El nombre de la carta no puede estar vacío".into()),
+ });
+ cx.notify();
+ return;
+ }
+ let new_contact = if selected_contact.is_none() {
+ let v = new_contact_name.read(cx).text().to_string();
+ let v = v.trim();
+ if v.is_empty() {
+ self.modal = Some(Modal::SaveFreeChart {
+ source_id,
+ name,
+ new_contact_name,
+ selected_contact,
+ all_contacts,
+ error: Some(
+ "Elegí un contacto existente o escribí un nombre para el nuevo"
+ .into(),
+ ),
+ });
+ cx.notify();
+ return;
+ }
+ Some(v.to_string())
+ } else {
+ None
+ };
+ cx.emit(TreeEvent::FreeChartSaveConfirmed {
+ source_id,
+ chart_name: chart_name.to_string(),
+ contact: selected_contact,
+ new_contact_name: new_contact,
+ });
+ drop(name);
+ drop(new_contact_name);
+ self.modal = None;
+ cx.notify();
+ }
}
}
@@ -1441,9 +1620,8 @@ impl TahuantinsuyuTree {
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);
+ cx.listener(move |this, _: &ClickEvent, w, cx| {
+ this.open_save_free_chart_modal(fid_save.clone(), w, cx);
}),
),
);
@@ -1530,6 +1708,23 @@ impl TahuantinsuyuTree {
&self.city_atlas,
cx,
),
+ Modal::SaveFreeChart {
+ source_id,
+ name,
+ new_contact_name,
+ selected_contact,
+ all_contacts,
+ error,
+ } => render_save_free_chart(
+ theme,
+ source_id.clone(),
+ name.clone(),
+ new_contact_name.clone(),
+ *selected_contact,
+ all_contacts,
+ error.clone(),
+ cx,
+ ),
};
div()
@@ -1603,6 +1798,184 @@ fn modal_box(
)
}
+/// Modal "Guardar como" para una carta libre. Layout:
+///
+/// ```text
+/// [Nombre de la carta] — TextInput pre-poblado con label
+/// Contacto destino:
+/// ○ Contacto A
+/// ○ Contacto B
+/// ● Nuevo contacto… → [Nombre del contacto] TextInput
+/// [Cancelar] [Guardar]
+/// ```
+///
+/// El submit emite `TreeEvent::FreeChartSaveConfirmed` que el shell
+/// materializa contra la store.
+#[allow(clippy::too_many_arguments)]
+fn render_save_free_chart(
+ theme: &Theme,
+ source_id: FreeChartId,
+ name: Entity<TextInput>,
+ new_contact_name: Entity<TextInput>,
+ selected_contact: Option<ContactId>,
+ all_contacts: &[(ContactId, String)],
+ error: Option<SharedString>,
+ cx: &mut Context<'_, TahuantinsuyuTree>,
+) -> gpui::Div {
+ let title_row = div()
+ .text_size(px(14.0))
+ .text_color(theme.fg_text)
+ .child(SharedString::from("Guardar carta libre"));
+ let label_row =
+ |label: &'static str| -> gpui::Div {
+ div()
+ .text_size(px(10.0))
+ .text_color(theme.fg_muted)
+ .child(label)
+ };
+ let name_block = div()
+ .flex()
+ .flex_col()
+ .gap(px(2.0))
+ .child(label_row("Nombre"))
+ .child(name.clone());
+
+ // Lista de contactos como botones radio.
+ let mut contact_list = div().flex().flex_col().gap(px(4.0));
+ for (cid, label) in all_contacts.iter() {
+ let selected = selected_contact == Some(*cid);
+ let cid_for_click = *cid;
+ let source_for_click = source_id.clone();
+ let row_id: SharedString =
+ SharedString::from(format!("tts-save-pick-{}", cid));
+ let bullet = if selected { "●" } else { "○" };
+ let row = div()
+ .id(gpui::ElementId::from(row_id))
+ .flex()
+ .flex_row()
+ .gap(px(8.0))
+ .px(px(6.0))
+ .py(px(3.0))
+ .rounded(px(4.0))
+ .text_size(px(11.0))
+ .text_color(theme.fg_text)
+ .hover(|s| s.bg(theme.bg_row_hover))
+ .child(bullet.to_string())
+ .child(label.clone())
+ .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
+ this.set_save_modal_contact(Some(cid_for_click), &source_for_click, cx);
+ }));
+ contact_list = contact_list.child(row);
+ }
+ // Opción "Nuevo contacto…" — bullet activo si selected_contact==None.
+ let new_selected = selected_contact.is_none();
+ let new_bullet = if new_selected { "●" } else { "○" };
+ let source_for_new = source_id.clone();
+ contact_list = contact_list.child(
+ div()
+ .id(gpui::ElementId::from(SharedString::from(
+ "tts-save-pick-new",
+ )))
+ .flex()
+ .flex_row()
+ .gap(px(8.0))
+ .px(px(6.0))
+ .py(px(3.0))
+ .rounded(px(4.0))
+ .text_size(px(11.0))
+ .text_color(theme.fg_text)
+ .hover(|s| s.bg(theme.bg_row_hover))
+ .child(new_bullet.to_string())
+ .child("Nuevo contacto…")
+ .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
+ this.set_save_modal_contact(None, &source_for_new, cx);
+ })),
+ );
+
+ let mut contacts_block = div()
+ .flex()
+ .flex_col()
+ .gap(px(2.0))
+ .child(label_row("Contacto destino"))
+ .child(contact_list);
+
+ // Si "Nuevo contacto" está activo, mostrar el TextInput debajo.
+ if new_selected {
+ contacts_block = contacts_block.child(
+ div()
+ .pt(px(4.0))
+ .flex()
+ .flex_col()
+ .gap(px(2.0))
+ .child(label_row("Nombre del contacto nuevo"))
+ .child(new_contact_name.clone()),
+ );
+ }
+
+ let save_btn = div()
+ .id("tts-save-free-confirm")
+ .px(px(14.0))
+ .py(px(8.0))
+ .rounded(px(6.0))
+ .bg(theme.bg_button())
+ .hover(|s| s.bg(theme.bg_button_hover()))
+ .text_size(px(12.0))
+ .text_color(theme.fg_text)
+ .child("Guardar")
+ .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
+ this.submit_modal(String::new(), cx);
+ }));
+ let cancel_btn = div()
+ .id("tts-save-free-cancel")
+ .px(px(14.0))
+ .py(px(8.0))
+ .rounded(px(6.0))
+ .bg(theme.bg_panel.clone())
+ .hover(|s| s.bg(theme.bg_row_hover))
+ .text_size(px(12.0))
+ .text_color(theme.fg_muted)
+ .child("Cancelar")
+ .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
+ this.close_modal(cx);
+ }));
+
+ let mut body = div()
+ .min_w(px(420.0))
+ .p(px(18.0))
+ .flex()
+ .flex_col()
+ .gap(px(12.0))
+ .bg(theme.bg_panel_alt.clone())
+ .border_1()
+ .border_color(theme.border_strong)
+ .rounded(px(8.0))
+ .child(title_row)
+ .child(name_block)
+ .child(contacts_block);
+ if let Some(err) = error {
+ body = body.child(
+ div()
+ .px(px(10.0))
+ .py(px(6.0))
+ .rounded(px(4.0))
+ .bg(theme.bg_destructive_hover())
+ .text_size(px(11.0))
+ .text_color(theme.accent_destructive())
+ .child(err),
+ );
+ }
+ body = body.child(
+ div()
+ .flex()
+ .flex_row()
+ .gap(px(8.0))
+ .justify_end()
+ .child(cancel_btn)
+ .child(save_btn),
+ );
+ body
+}
+
fn render_chart_form(
theme: &Theme,
title: &str,