diff --git a/Cargo.lock b/Cargo.lock index bdda8b9..b04183e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11011,6 +11011,7 @@ dependencies = [ "tahuantinsuyu-model", "tahuantinsuyu-store", "yahweh-theme", + "yahweh-widget-text-input", "yahweh-widget-tree", ] diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs index 476f8cd..4df2918 100644 --- a/crates/apps/tahuantinsuyu/src/shell.rs +++ b/crates/apps/tahuantinsuyu/src/shell.rs @@ -82,6 +82,14 @@ impl Shell { let selection = match ev { TreeEvent::Selected(s) => s, TreeEvent::Opened(s) => s, + TreeEvent::HierarchyChanged => { + // El tree ya hizo refresh internamente; el canvas/panel + // se enteran cuando llegue una nueva Selección. Fase 3 + // podría re-disparar la última selección para que el + // thumbnail grid se actualice si era una vista de grupo. + cx.notify(); + return; + } }; self.apply_selection(selection.clone(), cx); } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs index d5fa5ba..bb345c4 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs @@ -154,6 +154,19 @@ impl Store { Ok(()) } + /// Cambia el `parent_id` de un Group. Pasar `None` para mover a raíz. + /// **No** valida ciclos — el caller debe garantizar que el nuevo + /// padre no sea descendiente del que mueve (sino la DB queda con un + /// ciclo que el list_groups no rompe pero hace al CTE infinito). + pub fn move_group(&self, id: GroupId, new_parent: Option) -> StoreResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE groups SET parent_id = ?2 WHERE id = ?1", + params![id.to_string(), new_parent.map(|g| g.to_string())], + )?; + Ok(()) + } + // ----------------------------------------------------------------- // Contacts // ----------------------------------------------------------------- @@ -204,6 +217,24 @@ impl Store { Ok(()) } + pub fn rename_contact(&self, id: ContactId, name: &str) -> StoreResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE contacts SET name = ?2 WHERE id = ?1", + params![id.to_string(), name], + )?; + Ok(()) + } + + pub fn move_contact(&self, id: ContactId, new_group: Option) -> StoreResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE contacts SET group_id = ?2 WHERE id = ?1", + params![id.to_string(), new_group.map(|g| g.to_string())], + )?; + Ok(()) + } + // ----------------------------------------------------------------- // Charts // ----------------------------------------------------------------- @@ -285,6 +316,15 @@ impl Store { Ok(()) } + pub fn rename_chart(&self, id: ChartId, label: &str) -> StoreResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE charts SET label = ?2 WHERE id = ?1", + params![id.to_string(), label], + )?; + Ok(()) + } + // ----------------------------------------------------------------- // Module state // ----------------------------------------------------------------- diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/Cargo.toml index 99fe9ac..8819220 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/Cargo.toml +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/Cargo.toml @@ -10,4 +10,5 @@ tahuantinsuyu-model = { path = "../tahuantinsuyu-model" } tahuantinsuyu-store = { path = "../tahuantinsuyu-store" } yahweh-theme = { workspace = true } yahweh-widget-tree = { workspace = true } +yahweh-widget-text-input = { path = "../../ui_engine/widgets/text_input" } gpui = { workspace = true } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs index bb915bb..410436e 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs @@ -1,28 +1,41 @@ //! `tahuantinsuyu-tree` — explorador jerárquico Groups → Contacts → Charts. //! //! Envuelve [`yahweh_widget_tree::TreeView`] con la lógica de dominio -//! propia de Tahuantinsuyu. Los `RowId` codifican el tipo del item con -//! prefijo: +//! de Tahuantinsuyu. Los `RowId` codifican el tipo con prefijo: //! //! - `g:` → Group //! - `c:` → Contact //! - `h:` → Chart //! +//! ## Fase 2 — CRUD UX +//! +//! - **Right-click** abre un menú contextual cuyas opciones dependen +//! del target (raíz, group, contact o chart). +//! - **Renombrar** y **crear** abren un modal con un `TextInput`. +//! - **Crear carta** abre un formulario con los campos mínimos de +//! `StoredBirthData` (year/month/day/hour/min/tz/lat/lon). +//! - **Borrar** pide confirmación con `window.prompt`. +//! //! El host (la app) se suscribe a [`TreeEvent`] y traduce a `AppEvent` //! del bus de yahweh para que el canvas/panel reaccionen. -//! -//! Esta fase 1 trae el wrapper + el armado de filas; el CRUD UX -//! (drag-to-nest, rename inline, menú contextual) llega con la fase 2. #![forbid(unsafe_code)] #![warn(rust_2018_idioms)] use std::collections::HashSet; -use gpui::{Context, Entity, EventEmitter, IntoElement, Render, Window, prelude::*}; +use gpui::{ + ClickEvent, Context, Entity, EventEmitter, IntoElement, Pixels, Point, PromptLevel, Render, + SharedString, Window, div, hsla, prelude::*, px, +}; -use tahuantinsuyu_model::{ContactId, GroupId, TreeSelection}; +use tahuantinsuyu_model::{ + ChartId, ChartKind, ContactId, GroupId, StoredBirthData, StoredChartConfig, TimeCertainty, + TreeSelection, +}; use tahuantinsuyu_store::Store; +use yahweh_theme::Theme; +use yahweh_widget_text_input::{TextInput, TextInputEvent}; use yahweh_widget_tree::{RowId, RowKind, TreeEvent as InnerTreeEvent, TreeRow, TreeView}; const PREFIX_GROUP: &str = "g:"; @@ -35,11 +48,84 @@ const PREFIX_CHART: &str = "h:"; #[derive(Clone, Debug)] pub enum TreeEvent { - /// El usuario activó (single click) un item. Selected(TreeSelection), - /// El usuario abrió (doble click) un item — la app decide qué hacer - /// (en general, abrir la carta en el canvas). Opened(TreeSelection), + /// Una mutación de la jerarquía aconteció (crear, borrar, renombrar). + /// El host puede usarlo para invalidar caches en otros widgets. + HierarchyChanged, +} + +// ===================================================================== +// Estado interno +// ===================================================================== + +/// Target del menú contextual / acciones. +#[derive(Clone, Debug)] +enum MenuTarget { + Root, + Group(GroupId), + Contact(ContactId), + Chart(ChartId), +} + +impl MenuTarget { + fn from_selection(sel: &TreeSelection) -> Self { + match sel { + TreeSelection::Group(id) => MenuTarget::Group(*id), + TreeSelection::Contact(id) => MenuTarget::Contact(*id), + TreeSelection::Chart(id) => MenuTarget::Chart(*id), + } + } +} + +#[derive(Clone, Debug)] +struct MenuState { + target: MenuTarget, + position: Point, +} + +/// Modal flotante. Una sola `Modal` activa a la vez — la app no +/// soporta editar varias cosas en simultáneo. +enum Modal { + RenameGroup { + id: GroupId, + input: Entity, + }, + RenameContact { + id: ContactId, + input: Entity, + }, + RenameChart { + id: ChartId, + input: Entity, + }, + CreateGroup { + parent: Option, + input: Entity, + }, + CreateContact { + group: Option, + input: Entity, + }, + CreateChart { + contact: ContactId, + form: ChartForm, + error: Option, + }, +} + +struct ChartForm { + name: Entity, + place: Entity, + year: Entity, + month: Entity, + day: Entity, + hour: Entity, + minute: Entity, + tz_offset_min: Entity, + lat: Entity, + lon: Entity, + alt: Entity, } // ===================================================================== @@ -50,12 +136,16 @@ pub struct TahuantinsuyuTree { store: Store, inner: Entity, expanded: HashSet, + menu: Option, + modal: Option, } impl EventEmitter for TahuantinsuyuTree {} impl TahuantinsuyuTree { pub fn new(store: Store, cx: &mut Context) -> Self { + cx.observe_global::(|_, cx| cx.notify()).detach(); + let inner = cx.new(|cx| TreeView::new("tahuantinsuyu-tree", cx)); cx.subscribe(&inner, |this: &mut Self, _, ev, cx| { this.on_inner(ev, cx); @@ -66,19 +156,18 @@ impl TahuantinsuyuTree { store, inner, expanded: HashSet::new(), + menu: None, + modal: None, }; me.refresh(cx); me } - /// Re-lee la jerarquía desde la store y empuja al TreeView. Llamar - /// después de crear/borrar items. pub fn refresh(&mut self, cx: &mut Context) { let mut rows = Vec::new(); self.append_groups(None, 0, &mut rows); self.append_contacts(None, 0, &mut rows); - self.inner - .update(cx, |t, cx| t.set_rows(rows, cx)); + self.inner.update(cx, |t, cx| t.set_rows(rows, cx)); } fn append_groups(&self, parent: Option, depth: u32, out: &mut Vec) { @@ -154,6 +243,10 @@ impl TahuantinsuyuTree { self.refresh(cx); } InnerTreeEvent::RowClicked(id) => { + if self.menu.is_some() { + self.menu = None; + cx.notify(); + } if let Some(sel) = parse_row(id) { cx.emit(TreeEvent::Selected(sel)); } @@ -163,12 +256,447 @@ impl TahuantinsuyuTree { cx.emit(TreeEvent::Opened(sel)); } } - InnerTreeEvent::ContextMenuRequested { .. } => { - // Fase 2: menú contextual para crear/renombrar/borrar. + InnerTreeEvent::ContextMenuRequested { id, position } => { + let target = match id.as_ref().and_then(parse_row) { + Some(sel) => MenuTarget::from_selection(&sel), + None => MenuTarget::Root, + }; + self.menu = Some(MenuState { + target, + position: *position, + }); + cx.notify(); } InnerTreeEvent::ActiveChanged(_) => {} } } + + // ----------------------------------------------------------------- + // Acciones del menú + // ----------------------------------------------------------------- + + fn close_menu(&mut self, cx: &mut Context) { + if self.menu.take().is_some() { + cx.notify(); + } + } + + fn close_modal(&mut self, cx: &mut Context) { + if self.modal.take().is_some() { + cx.notify(); + } + } + + fn open_create_group( + &mut self, + parent: Option, + window: &mut Window, + cx: &mut Context, + ) { + let input = self.make_input("Nombre del grupo", "", window, cx); + self.modal = Some(Modal::CreateGroup { parent, input }); + self.close_menu(cx); + } + + fn open_create_contact( + &mut self, + group: Option, + window: &mut Window, + cx: &mut Context, + ) { + let input = self.make_input("Nombre del contacto", "", window, cx); + self.modal = Some(Modal::CreateContact { group, input }); + self.close_menu(cx); + } + + fn open_create_chart( + &mut self, + contact: ContactId, + window: &mut Window, + cx: &mut Context, + ) { + // Pre-cargamos el nombre del contacto en el campo "Sujeto" del + // form como conveniencia — la mayoría de las cartas se nombran + // igual que la persona. + let subject_name = self + .store + .list_contacts(None) + .ok() + .and_then(|all| { + // Buscamos linealmente — list_contacts solo lista hijos + // directos del group, no nos sirve para encontrar un + // contact arbitrario. Para fase 2 nos quedamos con + // "Carta natal" como label genérico si no podemos + // resolver. Resolver-por-id viene si lo necesitamos. + let _ = all; + None:: + }) + .unwrap_or_else(|| "Carta natal".into()); + + let form = ChartForm { + name: self.make_input("Etiqueta de la carta", &subject_name, window, cx), + place: self.make_input("Lugar (ciudad, país)", "", window, cx), + year: self.make_input("Año", "1987", window, cx), + month: self.make_input("Mes", "3", window, cx), + day: self.make_input("Día", "14", window, cx), + hour: self.make_input("Hora (0-23)", "5", window, cx), + minute: self.make_input("Minuto", "22", window, cx), + tz_offset_min: self.make_input("TZ offset (min)", "-240", window, cx), + lat: self.make_input("Latitud (°)", "10.4806", window, cx), + lon: self.make_input("Longitud (°)", "-66.9036", window, cx), + alt: self.make_input("Altitud (m)", "900", window, cx), + }; + // El primer field es el que recibe focus. + form.name.update(cx, |i, _| i.request_focus(window)); + + self.modal = Some(Modal::CreateChart { + contact, + form, + error: None, + }); + self.close_menu(cx); + } + + fn open_rename(&mut self, target: MenuTarget, window: &mut Window, cx: &mut Context) { + let modal = match target { + MenuTarget::Group(id) => { + let current = self + .store + .list_groups(None) + .ok() + .and_then(|all| find_group_name(&all, &self.store, id)) + .unwrap_or_default(); + Modal::RenameGroup { + id, + input: self.make_input("Nuevo nombre", ¤t, window, cx), + } + } + MenuTarget::Contact(id) => { + let current = self + .store + .list_contacts(None) + .ok() + .and_then(|all| find_contact_name(&all, &self.store, id)) + .unwrap_or_default(); + Modal::RenameContact { + id, + input: self.make_input("Nuevo nombre", ¤t, window, cx), + } + } + MenuTarget::Chart(id) => { + let current = self + .store + .get_chart(id) + .ok() + .map(|c| c.label) + .unwrap_or_default(); + Modal::RenameChart { + id, + input: self.make_input("Nueva etiqueta", ¤t, window, cx), + } + } + MenuTarget::Root => return, + }; + self.modal = Some(modal); + self.close_menu(cx); + } + + /// Crea un `TextInput` con focus y suscripción a Confirmed/Cancelled. + /// La closure decide qué hacer con cada evento — guardamos la subscripción + /// detached para que viva mientras el modal exista. + fn make_input( + &self, + placeholder: &str, + initial: &str, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let placeholder = placeholder.to_string(); + let input = cx.new(|cx| { + TextInput::new(initial.to_string(), cx) + .with_placeholder(SharedString::from(placeholder.clone())) + }); + cx.subscribe(&input, |this: &mut Self, _, ev: &TextInputEvent, cx| { + this.on_input_event(ev, cx); + }) + .detach(); + input.update(cx, |i, _| i.request_focus(window)); + input + } + + fn on_input_event(&mut self, ev: &TextInputEvent, cx: &mut Context) { + match ev { + TextInputEvent::Cancelled => self.close_modal(cx), + TextInputEvent::Confirmed(value) => self.submit_modal(value.clone(), cx), + } + } + + fn submit_modal(&mut self, value: String, cx: &mut Context) { + let trimmed = value.trim().to_string(); + // Tomamos ownership del modal — si el submit falla en mitad, + // lo restablecemos. Esto evita un borrow-mut sobre self.modal. + let modal = match self.modal.take() { + Some(m) => m, + None => return, + }; + match modal { + Modal::RenameGroup { id, input } => { + if !trimmed.is_empty() { + let _ = self.store.rename_group(id, &trimmed); + } + drop(input); + self.after_mutation(cx); + } + Modal::RenameContact { id, input } => { + if !trimmed.is_empty() { + let _ = self.store.rename_contact(id, &trimmed); + } + drop(input); + self.after_mutation(cx); + } + Modal::RenameChart { id, input } => { + if !trimmed.is_empty() { + let _ = self.store.rename_chart(id, &trimmed); + } + drop(input); + self.after_mutation(cx); + } + Modal::CreateGroup { parent, input } => { + if !trimmed.is_empty() { + let _ = self.store.create_group(parent, &trimmed, None); + } + drop(input); + self.after_mutation(cx); + } + Modal::CreateContact { group, input } => { + if !trimmed.is_empty() { + let _ = self.store.create_contact(group, &trimmed, None); + } + drop(input); + self.after_mutation(cx); + } + Modal::CreateChart { + contact, + form, + error: _, + } => { + // `value` viene del campo que disparó Enter. Para el + // form, ignoramos el value puntual y leemos todos los + // campos del form. + let _ = value; + match build_chart_from_form(&form, cx) { + Ok((birth, label)) => { + match self.store.create_chart( + contact, + ChartKind::Natal, + &label, + &birth, + &StoredChartConfig::default(), + None, + ) { + Ok(_) => { + // Auto-expand del contact para que se vea + // la carta recién creada. + self.expanded + .insert(format!("{}{}", PREFIX_CONTACT, contact)); + self.after_mutation(cx); + } + Err(e) => { + self.modal = Some(Modal::CreateChart { + contact, + form, + error: Some(SharedString::from(format!("Store: {}", e))), + }); + cx.notify(); + } + } + } + Err(msg) => { + self.modal = Some(Modal::CreateChart { + contact, + form, + error: Some(SharedString::from(msg)), + }); + cx.notify(); + } + } + } + } + } + + fn after_mutation(&mut self, cx: &mut Context) { + self.modal = None; + self.refresh(cx); + cx.emit(TreeEvent::HierarchyChanged); + cx.notify(); + } + + fn confirm_and_delete( + &mut self, + target: MenuTarget, + window: &mut Window, + cx: &mut Context, + ) { + let (label, kind) = match target { + MenuTarget::Group(_) => ("este grupo (incluye sus subgrupos y contactos)", "group"), + MenuTarget::Contact(_) => ("este contacto (incluye sus cartas)", "contact"), + MenuTarget::Chart(_) => ("esta carta", "chart"), + MenuTarget::Root => return, + }; + let answer = window.prompt( + PromptLevel::Warning, + &format!("¿Borrar {}?", label), + None, + &["Borrar", "Cancelar"], + cx, + ); + let target_clone = target.clone(); + cx.spawn(async move |this, cx| { + let Ok(idx) = answer.await else { return }; + if idx != 0 { + return; + } + let _ = this.update(cx, |this, cx| { + match target_clone { + MenuTarget::Group(id) => { + let _ = this.store.delete_group(id); + } + MenuTarget::Contact(id) => { + let _ = this.store.delete_contact(id); + } + MenuTarget::Chart(id) => { + let _ = this.store.delete_chart(id); + } + MenuTarget::Root => {} + } + this.after_mutation(cx); + }); + }) + .detach(); + let _ = kind; + self.close_menu(cx); + } +} + +// ===================================================================== +// Form helpers +// ===================================================================== + +fn build_chart_from_form( + form: &ChartForm, + cx: &mut Context, +) -> Result<(StoredBirthData, String), String> { + let name = form.name.read(cx).text().trim().to_string(); + let place = form.place.read(cx).text().trim().to_string(); + let year: i32 = parse_field(form.year.read(cx).text(), "Año")?; + let month: u32 = parse_field(form.month.read(cx).text(), "Mes")?; + let day: u32 = parse_field(form.day.read(cx).text(), "Día")?; + let hour: u32 = parse_field(form.hour.read(cx).text(), "Hora")?; + let minute: u32 = parse_field(form.minute.read(cx).text(), "Minuto")?; + let tz_offset_minutes: i32 = parse_field(form.tz_offset_min.read(cx).text(), "TZ offset")?; + let latitude_deg: f64 = parse_field(form.lat.read(cx).text(), "Latitud")?; + let longitude_deg: f64 = parse_field(form.lon.read(cx).text(), "Longitud")?; + let altitude_m: f64 = parse_field(form.alt.read(cx).text(), "Altitud")?; + + if !(1..=12).contains(&month) { + return Err(format!("Mes fuera de rango: {}", month)); + } + if !(1..=31).contains(&day) { + return Err(format!("Día fuera de rango: {}", day)); + } + if hour > 23 { + return Err(format!("Hora fuera de rango: {}", hour)); + } + if minute > 59 { + return Err(format!("Minuto fuera de rango: {}", minute)); + } + + let label = if name.is_empty() { + "Carta natal".to_string() + } else { + name + }; + let birth = StoredBirthData { + year, + month, + day, + hour, + minute, + second: 0.0, + tz_offset_minutes, + latitude_deg, + longitude_deg, + altitude_m, + time_certainty: TimeCertainty::Exact, + subject_name: None, + birthplace_label: if place.is_empty() { None } else { Some(place) }, + }; + Ok((birth, label)) +} + +fn parse_field(s: &str, field: &str) -> Result { + s.trim() + .parse::() + .map_err(|_| format!("Campo \"{}\" inválido: {:?}", field, s)) +} + +// ===================================================================== +// Lookups auxiliares (DFS por la jerarquía) +// ===================================================================== + +fn find_group_name(roots: &[tahuantinsuyu_model::Group], store: &Store, id: GroupId) -> Option { + for g in roots { + if g.id == id { + return Some(g.name.clone()); + } + if let Ok(children) = store.list_groups(Some(g.id)) { + if let Some(n) = find_group_name(&children, store, id) { + return Some(n); + } + } + } + None +} + +fn find_contact_name( + in_group: &[tahuantinsuyu_model::Contact], + store: &Store, + id: ContactId, +) -> Option { + for c in in_group { + if c.id == id { + return Some(c.name.clone()); + } + } + // Buscar también en todos los groups recursivamente. + if let Ok(groups) = store.list_groups(None) { + if let Some(n) = find_contact_in_groups(&groups, store, id) { + return Some(n); + } + } + None +} + +fn find_contact_in_groups( + groups: &[tahuantinsuyu_model::Group], + store: &Store, + id: ContactId, +) -> Option { + for g in groups { + if let Ok(cs) = store.list_contacts(Some(g.id)) { + for c in &cs { + if c.id == id { + return Some(c.name.clone()); + } + } + } + if let Ok(children) = store.list_groups(Some(g.id)) { + if let Some(n) = find_contact_in_groups(&children, store, id) { + return Some(n); + } + } + } + None } fn parse_row(id: &RowId) -> Option { @@ -185,8 +713,355 @@ fn parse_row(id: &RowId) -> Option { None } +// ===================================================================== +// Render +// ===================================================================== + +const MENU_WIDTH: f32 = 220.0; + impl Render for TahuantinsuyuTree { - fn render(&mut self, _w: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.inner.clone() + fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::global(cx).clone(); + let mut root = div() + .id("tahuantinsuyu-tree-root") + .size_full() + .relative() + .bg(theme.bg_panel.clone()) + .flex() + .flex_col() + .child(self.inner.clone()); + + if let Some(menu) = self.menu.clone() { + root = root.child(self.render_menu(&theme, menu, cx)); + } + if self.modal.is_some() { + root = root.child(self.render_modal(&theme, cx)); + } + root } } + +impl TahuantinsuyuTree { + fn render_menu( + &self, + theme: &Theme, + menu: MenuState, + cx: &mut Context, + ) -> impl IntoElement { + let mut items = div() + .flex() + .flex_col() + .py(px(4.0)) + .min_w(px(MENU_WIDTH)) + .bg(theme.bg_panel_alt.clone()) + .border_1() + .border_color(theme.border_strong) + .rounded(px(6.0)); + + match menu.target.clone() { + MenuTarget::Root => { + items = items.child(menu_item("tts-menu-new-group", "Nuevo grupo", theme).on_click( + cx.listener(|this, _: &ClickEvent, w, cx| { + this.open_create_group(None, w, cx); + }), + )); + items = items.child( + menu_item("tts-menu-new-contact-root", "Nuevo contacto", theme).on_click( + cx.listener(|this, _: &ClickEvent, w, cx| { + this.open_create_contact(None, w, cx); + }), + ), + ); + } + MenuTarget::Group(id) => { + items = items.child( + menu_item("tts-menu-new-subgroup", "Nuevo subgrupo", theme).on_click( + cx.listener(move |this, _: &ClickEvent, w, cx| { + this.open_create_group(Some(id), w, cx); + }), + ), + ); + items = items.child( + menu_item("tts-menu-new-contact", "Nuevo contacto", theme).on_click( + cx.listener(move |this, _: &ClickEvent, w, cx| { + this.open_create_contact(Some(id), w, cx); + }), + ), + ); + items = items.child(separator(theme)); + let t = menu.target.clone(); + items = items.child(menu_item("tts-menu-rename-g", "Renombrar…", theme).on_click( + cx.listener(move |this, _: &ClickEvent, w, cx| { + this.open_rename(t.clone(), w, cx); + }), + )); + let t = menu.target.clone(); + items = items.child(menu_item("tts-menu-delete-g", "Borrar…", theme).on_click( + cx.listener(move |this, _: &ClickEvent, w, cx| { + this.confirm_and_delete(t.clone(), w, cx); + }), + )); + } + MenuTarget::Contact(id) => { + items = items.child(menu_item("tts-menu-new-chart", "Nueva carta…", theme).on_click( + cx.listener(move |this, _: &ClickEvent, w, cx| { + this.open_create_chart(id, w, cx); + }), + )); + items = items.child(separator(theme)); + let t = menu.target.clone(); + items = items.child(menu_item("tts-menu-rename-c", "Renombrar…", theme).on_click( + cx.listener(move |this, _: &ClickEvent, w, cx| { + this.open_rename(t.clone(), w, cx); + }), + )); + let t = menu.target.clone(); + items = items.child(menu_item("tts-menu-delete-c", "Borrar…", theme).on_click( + cx.listener(move |this, _: &ClickEvent, w, cx| { + this.confirm_and_delete(t.clone(), w, cx); + }), + )); + } + MenuTarget::Chart(id) => { + items = items.child(menu_item("tts-menu-open-h", "Abrir", theme).on_click( + cx.listener(move |this, _: &ClickEvent, _w, cx| { + cx.emit(TreeEvent::Opened(TreeSelection::Chart(id))); + this.close_menu(cx); + }), + )); + items = items.child(separator(theme)); + let t = menu.target.clone(); + items = items.child(menu_item("tts-menu-rename-h", "Renombrar…", theme).on_click( + cx.listener(move |this, _: &ClickEvent, w, cx| { + this.open_rename(t.clone(), w, cx); + }), + )); + let t = menu.target.clone(); + items = items.child(menu_item("tts-menu-delete-h", "Borrar…", theme).on_click( + cx.listener(move |this, _: &ClickEvent, w, cx| { + this.confirm_and_delete(t.clone(), w, cx); + }), + )); + } + } + + div() + .absolute() + .left(menu.position.x) + .top(menu.position.y) + .child(items) + } + + fn render_modal(&self, theme: &Theme, cx: &mut Context) -> impl IntoElement { + let modal = self.modal.as_ref().expect("render_modal sin modal activo"); + let inner = match modal { + Modal::RenameGroup { input, .. } + | Modal::RenameContact { input, .. } + | Modal::RenameChart { input, .. } => { + modal_box(theme, "Renombrar", input.clone(), "Enter = guardar — Escape = cancelar") + } + Modal::CreateGroup { input, .. } => { + modal_box(theme, "Nuevo grupo", input.clone(), "Enter = crear — Escape = cancelar") + } + Modal::CreateContact { input, .. } => modal_box( + theme, + "Nuevo contacto", + input.clone(), + "Enter = crear — Escape = cancelar", + ), + Modal::CreateChart { form, error, .. } => render_chart_form(theme, form, error.clone(), cx), + }; + + div() + .absolute() + .top(px(0.0)) + .left(px(0.0)) + .size_full() + .flex() + .items_center() + .justify_center() + .bg(hsla(0.0, 0.0, 0.0, 0.55)) + .child(inner) + } +} + +// ===================================================================== +// Helpers de UI +// ===================================================================== + +fn menu_item( + id: &'static str, + label: &'static str, + theme: &Theme, +) -> gpui::Stateful { + div() + .id(id) + .px(px(12.0)) + .py(px(6.0)) + .text_size(px(12.0)) + .text_color(theme.fg_text) + .hover(|s| s.bg(theme.bg_row_hover)) + .child(label) +} + +fn separator(theme: &Theme) -> gpui::Div { + div() + .my(px(3.0)) + .h(px(1.0)) + .w_full() + .bg(theme.border) +} + +fn modal_box( + theme: &Theme, + title: &'static str, + input: Entity, + hint: &'static str, +) -> gpui::Div { + div() + .min_w(px(380.0)) + .p(px(16.0)) + .flex() + .flex_col() + .gap(px(10.0)) + .bg(theme.bg_panel_alt.clone()) + .border_1() + .border_color(theme.border_strong) + .rounded(px(8.0)) + .child( + div() + .text_size(px(13.0)) + .text_color(theme.fg_text) + .child(title), + ) + .child(input) + .child( + div() + .text_size(px(10.0)) + .text_color(theme.fg_muted) + .child(hint), + ) +} + +fn render_chart_form( + theme: &Theme, + form: &ChartForm, + error: Option, + cx: &mut Context, +) -> gpui::Div { + let labeled = |label: &'static str, input: Entity| -> gpui::Div { + div() + .flex() + .flex_col() + .gap(px(2.0)) + .child( + div() + .text_size(px(10.0)) + .text_color(theme.fg_muted) + .child(label), + ) + .child(input) + }; + + let date_row = div() + .flex() + .flex_row() + .gap(px(8.0)) + .child(labeled("Año", form.year.clone())) + .child(labeled("Mes", form.month.clone())) + .child(labeled("Día", form.day.clone())) + .child(labeled("Hora", form.hour.clone())) + .child(labeled("Minuto", form.minute.clone())) + .child(labeled("TZ (min)", form.tz_offset_min.clone())); + + let loc_row = div() + .flex() + .flex_row() + .gap(px(8.0)) + .child(labeled("Latitud", form.lat.clone())) + .child(labeled("Longitud", form.lon.clone())) + .child(labeled("Altitud (m)", form.alt.clone())); + + let create_btn = div() + .id("tts-chart-form-create") + .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("Crear carta") + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + // Disparamos un submit "vacío" — el handler de submit + // re-lee todos los campos del form. El value que pasamos + // se ignora dentro del branch CreateChart. + this.submit_modal(String::new(), cx); + })); + + let cancel_btn = div() + .id("tts-chart-form-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(640.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( + div() + .text_size(px(14.0)) + .text_color(theme.fg_text) + .child("Nueva carta natal"), + ) + .child( + div() + .flex() + .flex_row() + .gap(px(8.0)) + .child(labeled("Etiqueta", form.name.clone())) + .child(labeled("Lugar (texto libre)", form.place.clone())), + ) + .child(date_row) + .child(loc_row); + + 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(create_btn), + ); + + body +}