From a83b0396ce22d537b2630994af6deebe20d30c92 Mon Sep 17 00:00:00 2001 From: sergio Date: Mon, 18 May 2026 21:36:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(tahuantinsuyu):=20modal=20"Guardar=20como?= =?UTF-8?q?=E2=80=A6"=20real=20para=20cartas=20libres=20(F3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/apps/tahuantinsuyu/src/shell.rs | 101 +++-- .../tahuantinsuyu-tree/src/lib.rs | 379 +++++++++++++++++- 2 files changed, 448 insertions(+), 32 deletions(-) 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, + new_contact_name: Option, + cx: &mut Context, + ) { + 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) { 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, + new_contact_name: Option, + }, } // ===================================================================== @@ -148,6 +157,25 @@ enum Modal { form: ChartForm, error: Option, }, + /// 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, + /// Nombre del contacto NUEVO (solo aplica si + /// `selected_contact == None`). Vacío para reusar uno + /// existente. + new_contact_name: Entity, + /// `Some(id)` = usar contacto existente; `None` = crear + /// contacto nuevo con `new_contact_name`. + selected_contact: Option, + /// 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, + }, } 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, + 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, + 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, + new_contact_name: Entity, + selected_contact: Option, + all_contacts: &[(ContactId, String)], + error: Option, + 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,