gioser

tawasuyu/brahman

Rust 519TOML 202Markdown 57JSON 197454 Definiciones

feat(tahuantinsuyu): fase 2 — CRUD UX sobre el tree (menú, modales, form natal)

Right-click sobre el explorador izquierdo abre menú contextual cuyas
opciones dependen del target (raíz, group, contact o chart). Modales
flotantes para crear/renombrar usando yahweh-widget-text-input; un
form más completo de 11 campos para la birth data al crear cartas
natales. Borrar pide confirmación por window.prompt nativo.

- tahuantinsuyu-store: rename_contact, rename_chart, move_group,
  move_contact (los `move_*` para fase posterior de drag-to-nest).
- tahuantinsuyu-tree: estado interno (Menu, Modal enum, ChartForm),
  handlers de ContextMenuRequested, render overlays.
  Soporta seis modales: rename de g/c/h, create group/contact, form
  natal completo con parseo + reporte de errores inline.
  Auto-expande el contact tras crear una carta.
  Nuevo evento TreeEvent::HierarchyChanged tras cada mutación.
- shell: maneja HierarchyChanged sin propagar selección.

`cargo check` y `cargo test` verdes. Fase 3 viene con engine real
contra eternal-astrology + pintado de la rueda.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

bcb92b537eaa9b18bc4db80237b93b1ed4e3f7e6 · por sergio · (hace 5 meses) · padre c48638fe8 · ver el árbol en este commit

Delta semántico · 38

crates/apps/tahuantinsuyu/src/shell.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs

5 archivos · +943 −18

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<GroupId>) -> 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<GroupId>) -> 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:<ulid>` → Group
 //! - `c:<ulid>` → Contact
 //! - `h:<ulid>` → 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<Pixels>,
+}
+
+/// 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<TextInput>,
+    },
+    RenameContact {
+        id: ContactId,
+        input: Entity<TextInput>,
+    },
+    RenameChart {
+        id: ChartId,
+        input: Entity<TextInput>,
+    },
+    CreateGroup {
+        parent: Option<GroupId>,
+        input: Entity<TextInput>,
+    },
+    CreateContact {
+        group: Option<GroupId>,
+        input: Entity<TextInput>,
+    },
+    CreateChart {
+        contact: ContactId,
+        form: ChartForm,
+        error: Option<SharedString>,
+    },
+}
+
+struct ChartForm {
+    name: Entity<TextInput>,
+    place: Entity<TextInput>,
+    year: Entity<TextInput>,
+    month: Entity<TextInput>,
+    day: Entity<TextInput>,
+    hour: Entity<TextInput>,
+    minute: Entity<TextInput>,
+    tz_offset_min: Entity<TextInput>,
+    lat: Entity<TextInput>,
+    lon: Entity<TextInput>,
+    alt: Entity<TextInput>,
 }
 
 // =====================================================================
@@ -50,12 +136,16 @@ pub struct TahuantinsuyuTree {
     store: Store,
     inner: Entity<TreeView>,
     expanded: HashSet<String>,
+    menu: Option<MenuState>,
+    modal: Option<Modal>,
 }
 
 impl EventEmitter<TreeEvent> for TahuantinsuyuTree {}
 
 impl TahuantinsuyuTree {
     pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
+        cx.observe_global::<Theme>(|_, 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<Self>) {
         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<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
@@ -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<Self>) {
+        if self.menu.take().is_some() {
+            cx.notify();
+        }
+    }
+
+    fn close_modal(&mut self, cx: &mut Context<Self>) {
+        if self.modal.take().is_some() {
+            cx.notify();
+        }
+    }
+
+    fn open_create_group(
+        &mut self,
+        parent: Option<GroupId>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        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<GroupId>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        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<Self>,
+    ) {
+        // 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::<String>
+            })
+            .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<Self>) {
+        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", &current, 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", &current, 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", &current, 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<Self>,
+    ) -> Entity<TextInput> {
+        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<Self>) {
+        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<Self>) {
+        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>) {
+        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<Self>,
+    ) {
+        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<TahuantinsuyuTree>,
+) -> 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<T: std::str::FromStr>(s: &str, field: &str) -> Result<T, String> {
+    s.trim()
+        .parse::<T>()
+        .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<String> {
+    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<String> {
+    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<String> {
+    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<TreeSelection> {
@@ -185,8 +713,355 @@ fn parse_row(id: &RowId) -> Option<TreeSelection> {
     None
 }
 
+// =====================================================================
+// Render
+// =====================================================================
+
+const MENU_WIDTH: f32 = 220.0;
+
 impl Render for TahuantinsuyuTree {
-    fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
-        self.inner.clone()
+    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> 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<Self>,
+    ) -> 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<Self>) -> 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<gpui::Div> {
+    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<TextInput>,
+    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<SharedString>,
+    cx: &mut Context<TahuantinsuyuTree>,
+) -> gpui::Div {
+    let labeled = |label: &'static str, input: Entity<TextInput>| -> 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
+}