tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definitions

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 · (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

2 files · +448 −32

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,