tawasuyu

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Definitions

⎇ 295c9ba554fc693550cdec72b33f837cef714c6b crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs Rust · 1483 lines · 56.9 KB History Who touched what raw

feat(tahuantinsuyu): fase 22 — layout splitter + atlas loadable desde XDG 295c9ba55 · sergio ·

1//! `tahuantinsuyu-tree` — explorador jerárquico Groups → Contacts → Charts.
2//!
3//! Envuelve [`yahweh_widget_tree::TreeView`] con la lógica de dominio
4//! de Tahuantinsuyu. Los `RowId` codifican el tipo con prefijo:
5//!
6//! - `g:<ulid>` → Group
7//! - `c:<ulid>` → Contact
8//! - `h:<ulid>` → Chart
9//!
10//! ## Fase 2 — CRUD UX
11//!
12//! - **Right-click** abre un menú contextual cuyas opciones dependen
13//!   del target (raíz, group, contact o chart).
14//! - **Renombrar** y **crear** abren un modal con un `TextInput`.
15//! - **Crear carta** abre un formulario con los campos mínimos de
16//!   `StoredBirthData` (year/month/day/hour/min/tz/lat/lon).
17//! - **Borrar** pide confirmación con `window.prompt`.
18//!
19//! El host (la app) se suscribe a [`TreeEvent`] y traduce a `AppEvent`
20//! del bus de yahweh para que el canvas/panel reaccionen.
21
22#![forbid(unsafe_code)]
23#![warn(rust_2018_idioms)]
24
25use std::collections::HashSet;
26
27use gpui::{
28    ClickEvent, Context, Entity, EventEmitter, IntoElement, Pixels, Point, PromptLevel, Render,
29    SharedString, Window, div, hsla, prelude::*, px,
30};
31
32use tahuantinsuyu_model::{
33    ChartId, ChartKind, ContactId, GroupId, StoredBirthData, StoredChartConfig, TimeCertainty,
34    TreeSelection,
35};
36use tahuantinsuyu_store::Store;
37use yahweh_theme::Theme;
38use yahweh_widget_text_input::{TextInput, TextInputEvent};
39use yahweh_widget_tree::{RowId, RowKind, TreeEvent as InnerTreeEvent, TreeRow, TreeView};
40
41const PREFIX_GROUP: &str = "g:";
42const PREFIX_CONTACT: &str = "c:";
43const PREFIX_CHART: &str = "h:";
44
45// =====================================================================
46// Eventos públicos
47// =====================================================================
48
49#[derive(Clone, Debug)]
50pub enum TreeEvent {
51    Selected(TreeSelection),
52    Opened(TreeSelection),
53    /// Una mutación de la jerarquía aconteció (crear, borrar, renombrar).
54    /// El host puede usarlo para invalidar caches en otros widgets.
55    HierarchyChanged,
56}
57
58// =====================================================================
59// Estado interno
60// =====================================================================
61
62/// Target del menú contextual / acciones.
63#[derive(Clone, Debug)]
64enum MenuTarget {
65    Root,
66    Group(GroupId),
67    Contact(ContactId),
68    Chart(ChartId),
69}
70
71impl MenuTarget {
72    fn from_selection(sel: &TreeSelection) -> Self {
73        match sel {
74            TreeSelection::Group(id) => MenuTarget::Group(*id),
75            TreeSelection::Contact(id) => MenuTarget::Contact(*id),
76            TreeSelection::Chart(id) => MenuTarget::Chart(*id),
77        }
78    }
79}
80
81#[derive(Clone, Debug)]
82struct MenuState {
83    target: MenuTarget,
84    position: Point<Pixels>,
85}
86
87/// Modal flotante. Una sola `Modal` activa a la vez — la app no
88/// soporta editar varias cosas en simultáneo.
89enum Modal {
90    RenameGroup {
91        id: GroupId,
92        input: Entity<TextInput>,
93    },
94    RenameContact {
95        id: ContactId,
96        input: Entity<TextInput>,
97    },
98    RenameChart {
99        id: ChartId,
100        input: Entity<TextInput>,
101    },
102    CreateGroup {
103        parent: Option<GroupId>,
104        input: Entity<TextInput>,
105    },
106    CreateContact {
107        group: Option<GroupId>,
108        input: Entity<TextInput>,
109    },
110    CreateChart {
111        contact: ContactId,
112        form: ChartForm,
113        error: Option<SharedString>,
114    },
115    /// Editar una carta existente — reusa `ChartForm` pre-cargada.
116    /// El submit llama `store.update_chart(id, ...)` preservando
117    /// `chart.contact_id`, `related_chart_id`, `module_state` y el
118    /// historial.
119    EditChart {
120        id: ChartId,
121        form: ChartForm,
122        error: Option<SharedString>,
123    },
124}
125
126struct ChartForm {
127    name: Entity<TextInput>,
128    place: Entity<TextInput>,
129    year: Entity<TextInput>,
130    month: Entity<TextInput>,
131    day: Entity<TextInput>,
132    hour: Entity<TextInput>,
133    minute: Entity<TextInput>,
134    tz_offset_min: Entity<TextInput>,
135    lat: Entity<TextInput>,
136    lon: Entity<TextInput>,
137    alt: Entity<TextInput>,
138}
139
140// =====================================================================
141// Widget
142// =====================================================================
143
144pub struct TahuantinsuyuTree {
145    store: Store,
146    inner: Entity<TreeView>,
147    expanded: HashSet<String>,
148    menu: Option<MenuState>,
149    modal: Option<Modal>,
150    /// `true` cuando el dropdown de "ciudad rápida" en el ChartForm
151    /// está abierto. Vive en el tree (no en ChartForm) porque las
152    /// closures de los click handlers necesitan mutarlo via `cx.listener`.
153    city_picker_open: bool,
154    /// Atlas de ciudades para el dropdown del form. Se inicializa con
155    /// `default_city_presets()` (90 ciudades hardcoded). El host puede
156    /// llamar [`Self::set_city_atlas`] para reemplazar por uno custom
157    /// cargado desde disco (TSV).
158    city_atlas: Vec<CityPreset>,
159}
160
161/// Preset de ciudad con datos canónicos para autocompletar lat/lon/tz
162/// al elegirlo en el form. TZ es la zona estándar **sin DST** — el
163/// usuario afina si necesita. `name` es `String` (no &'static) para
164/// permitir cargar atlas custom desde disco vía
165/// [`TahuantinsuyuTree::set_city_atlas`].
166#[derive(Clone, Debug)]
167pub struct CityPreset {
168    pub name: String,
169    pub lat: f64,
170    pub lon: f64,
171    pub tz_offset_minutes: i32,
172}
173
174/// Atlas hardcoded — 90 ciudades canónicas que cubren la mayoría de
175/// casos de uso. El usuario puede sobrescribirlas pasando un atlas
176/// custom vía [`TahuantinsuyuTree::set_city_atlas`] (típicamente
177/// cargado desde `$XDG_DATA_HOME/tahuantinsuyu/atlas.tsv`).
178pub fn default_city_presets() -> Vec<CityPreset> {
179    vec![
180        // Latinoamérica
181        CityPreset { name: "Buenos Aires, AR".into(), lat: -34.6037, lon: -58.3816, tz_offset_minutes: -180 },
182    CityPreset { name: "Córdoba, AR".into(),      lat: -31.4201, lon: -64.1888, tz_offset_minutes: -180 },
183    CityPreset { name: "Rosario, AR".into(),      lat: -32.9587, lon: -60.6930, tz_offset_minutes: -180 },
184    CityPreset { name: "Mendoza, AR".into(),      lat: -32.8908, lon: -68.8272, tz_offset_minutes: -180 },
185    CityPreset { name: "Caracas, VE".into(),      lat: 10.4806,  lon: -66.9036, tz_offset_minutes: -240 },
186    CityPreset { name: "Maracaibo, VE".into(),    lat: 10.6427,  lon: -71.6125, tz_offset_minutes: -240 },
187    CityPreset { name: "Valencia, VE".into(),     lat: 10.1620,  lon: -68.0078, tz_offset_minutes: -240 },
188    CityPreset { name: "Bogotá, CO".into(),       lat: 4.7110,   lon: -74.0721, tz_offset_minutes: -300 },
189    CityPreset { name: "Medellín, CO".into(),     lat: 6.2442,   lon: -75.5812, tz_offset_minutes: -300 },
190    CityPreset { name: "Cali, CO".into(),         lat: 3.4516,   lon: -76.5320, tz_offset_minutes: -300 },
191    CityPreset { name: "Lima, PE".into(),         lat: -12.0464, lon: -77.0428, tz_offset_minutes: -300 },
192    CityPreset { name: "Cusco, PE".into(),        lat: -13.5319, lon: -71.9675, tz_offset_minutes: -300 },
193    CityPreset { name: "Santiago, CL".into(),     lat: -33.4489, lon: -70.6693, tz_offset_minutes: -240 },
194    CityPreset { name: "Valparaíso, CL".into(),   lat: -33.0472, lon: -71.6127, tz_offset_minutes: -240 },
195    CityPreset { name: "Quito, EC".into(),        lat: -0.1807,  lon: -78.4678, tz_offset_minutes: -300 },
196    CityPreset { name: "Guayaquil, EC".into(),    lat: -2.1709,  lon: -79.9224, tz_offset_minutes: -300 },
197    CityPreset { name: "Montevideo, UY".into(),   lat: -34.9011, lon: -56.1645, tz_offset_minutes: -180 },
198    CityPreset { name: "Asunción, PY".into(),     lat: -25.2637, lon: -57.5759, tz_offset_minutes: -240 },
199    CityPreset { name: "La Paz, BO".into(),       lat: -16.4897, lon: -68.1193, tz_offset_minutes: -240 },
200    CityPreset { name: "Ciudad de México".into(), lat: 19.4326,  lon: -99.1332, tz_offset_minutes: -360 },
201    CityPreset { name: "Guadalajara, MX".into(),  lat: 20.6597,  lon: -103.3496, tz_offset_minutes: -360 },
202    CityPreset { name: "Monterrey, MX".into(),    lat: 25.6866,  lon: -100.3161, tz_offset_minutes: -360 },
203    CityPreset { name: "Habana, CU".into(),       lat: 23.1136,  lon: -82.3666, tz_offset_minutes: -300 },
204    CityPreset { name: "San Juan, PR".into(),     lat: 18.4655,  lon: -66.1057, tz_offset_minutes: -240 },
205    CityPreset { name: "San José, CR".into(),     lat: 9.9281,   lon: -84.0907, tz_offset_minutes: -360 },
206    CityPreset { name: "Panamá, PA".into(),       lat: 8.9824,   lon: -79.5199, tz_offset_minutes: -300 },
207    CityPreset { name: "San Salvador, SV".into(), lat: 13.6929,  lon: -89.2182, tz_offset_minutes: -360 },
208    CityPreset { name: "Guatemala, GT".into(),    lat: 14.6349,  lon: -90.5069, tz_offset_minutes: -360 },
209    CityPreset { name: "Tegucigalpa, HN".into(),  lat: 14.0723,  lon: -87.1921, tz_offset_minutes: -360 },
210    CityPreset { name: "Managua, NI".into(),      lat: 12.1149,  lon: -86.2362, tz_offset_minutes: -360 },
211    CityPreset { name: "Santo Domingo, DO".into(), lat: 18.4861, lon: -69.9312, tz_offset_minutes: -240 },
212    CityPreset { name: "São Paulo, BR".into(),    lat: -23.5505, lon: -46.6333, tz_offset_minutes: -180 },
213    CityPreset { name: "Rio de Janeiro, BR".into(), lat: -22.9068, lon: -43.1729, tz_offset_minutes: -180 },
214    CityPreset { name: "Brasília, BR".into(),     lat: -15.8267, lon: -47.9218, tz_offset_minutes: -180 },
215    CityPreset { name: "Salvador, BR".into(),     lat: -12.9777, lon: -38.5016, tz_offset_minutes: -180 },
216    // España
217    CityPreset { name: "Madrid, ES".into(),       lat: 40.4168,  lon: -3.7038,  tz_offset_minutes: 60 },
218    CityPreset { name: "Barcelona, ES".into(),    lat: 41.3851,  lon: 2.1734,   tz_offset_minutes: 60 },
219    CityPreset { name: "Sevilla, ES".into(),      lat: 37.3891,  lon: -5.9845,  tz_offset_minutes: 60 },
220    CityPreset { name: "Valencia, ES".into(),     lat: 39.4699,  lon: -0.3763,  tz_offset_minutes: 60 },
221    CityPreset { name: "Bilbao, ES".into(),       lat: 43.2630,  lon: -2.9350,  tz_offset_minutes: 60 },
222    // Europa
223    CityPreset { name: "London, UK".into(),       lat: 51.5074,  lon: -0.1278,  tz_offset_minutes: 0 },
224    CityPreset { name: "Paris, FR".into(),        lat: 48.8566,  lon: 2.3522,   tz_offset_minutes: 60 },
225    CityPreset { name: "Berlin, DE".into(),       lat: 52.5200,  lon: 13.4050,  tz_offset_minutes: 60 },
226    CityPreset { name: "München, DE".into(),      lat: 48.1351,  lon: 11.5820,  tz_offset_minutes: 60 },
227    CityPreset { name: "Roma, IT".into(),         lat: 41.9028,  lon: 12.4964,  tz_offset_minutes: 60 },
228    CityPreset { name: "Milano, IT".into(),       lat: 45.4642,  lon: 9.1900,   tz_offset_minutes: 60 },
229    CityPreset { name: "Amsterdam, NL".into(),    lat: 52.3676,  lon: 4.9041,   tz_offset_minutes: 60 },
230    CityPreset { name: "Bruxelles, BE".into(),    lat: 50.8503,  lon: 4.3517,   tz_offset_minutes: 60 },
231    CityPreset { name: "Wien, AT".into(),         lat: 48.2082,  lon: 16.3738,  tz_offset_minutes: 60 },
232    CityPreset { name: "Zürich, CH".into(),       lat: 47.3769,  lon: 8.5417,   tz_offset_minutes: 60 },
233    CityPreset { name: "Lisboa, PT".into(),       lat: 38.7223,  lon: -9.1393,  tz_offset_minutes: 0 },
234    CityPreset { name: "Dublin, IE".into(),       lat: 53.3498,  lon: -6.2603,  tz_offset_minutes: 0 },
235    CityPreset { name: "Stockholm, SE".into(),    lat: 59.3293,  lon: 18.0686,  tz_offset_minutes: 60 },
236    CityPreset { name: "Oslo, NO".into(),         lat: 59.9139,  lon: 10.7522,  tz_offset_minutes: 60 },
237    CityPreset { name: "København, DK".into(),    lat: 55.6761,  lon: 12.5683,  tz_offset_minutes: 60 },
238    CityPreset { name: "Helsinki, FI".into(),     lat: 60.1699,  lon: 24.9384,  tz_offset_minutes: 120 },
239    CityPreset { name: "Warszawa, PL".into(),     lat: 52.2297,  lon: 21.0122,  tz_offset_minutes: 60 },
240    CityPreset { name: "Praha, CZ".into(),        lat: 50.0755,  lon: 14.4378,  tz_offset_minutes: 60 },
241    CityPreset { name: "Budapest, HU".into(),     lat: 47.4979,  lon: 19.0402,  tz_offset_minutes: 60 },
242    CityPreset { name: "Athina, GR".into(),       lat: 37.9838,  lon: 23.7275,  tz_offset_minutes: 120 },
243    CityPreset { name: "İstanbul, TR".into(),     lat: 41.0082,  lon: 28.9784,  tz_offset_minutes: 180 },
244    CityPreset { name: "Moskva, RU".into(),       lat: 55.7558,  lon: 37.6173,  tz_offset_minutes: 180 },
245    // USA + Canada
246    CityPreset { name: "New York, US".into(),     lat: 40.7128,  lon: -74.0060, tz_offset_minutes: -300 },
247    CityPreset { name: "Los Angeles, US".into(),  lat: 34.0522,  lon: -118.2437, tz_offset_minutes: -480 },
248    CityPreset { name: "Chicago, US".into(),      lat: 41.8781,  lon: -87.6298, tz_offset_minutes: -360 },
249    CityPreset { name: "Miami, US".into(),        lat: 25.7617,  lon: -80.1918, tz_offset_minutes: -300 },
250    CityPreset { name: "Houston, US".into(),      lat: 29.7604,  lon: -95.3698, tz_offset_minutes: -360 },
251    CityPreset { name: "San Francisco, US".into(), lat: 37.7749, lon: -122.4194, tz_offset_minutes: -480 },
252    CityPreset { name: "Seattle, US".into(),      lat: 47.6062,  lon: -122.3321, tz_offset_minutes: -480 },
253    CityPreset { name: "Boston, US".into(),       lat: 42.3601,  lon: -71.0589, tz_offset_minutes: -300 },
254    CityPreset { name: "Washington DC".into(),    lat: 38.9072,  lon: -77.0369, tz_offset_minutes: -300 },
255    CityPreset { name: "Toronto, CA".into(),      lat: 43.6532,  lon: -79.3832, tz_offset_minutes: -300 },
256    CityPreset { name: "Montreal, CA".into(),     lat: 45.5017,  lon: -73.5673, tz_offset_minutes: -300 },
257    CityPreset { name: "Vancouver, CA".into(),    lat: 49.2827,  lon: -123.1207, tz_offset_minutes: -480 },
258    // Asia
259    CityPreset { name: "Tokyo, JP".into(),        lat: 35.6762,  lon: 139.6503, tz_offset_minutes: 540 },
260    CityPreset { name: "Beijing, CN".into(),      lat: 39.9042,  lon: 116.4074, tz_offset_minutes: 480 },
261    CityPreset { name: "Shanghai, CN".into(),     lat: 31.2304,  lon: 121.4737, tz_offset_minutes: 480 },
262    CityPreset { name: "Hong Kong".into(),        lat: 22.3193,  lon: 114.1694, tz_offset_minutes: 480 },
263    CityPreset { name: "Singapore".into(),        lat: 1.3521,   lon: 103.8198, tz_offset_minutes: 480 },
264    CityPreset { name: "Seoul, KR".into(),        lat: 37.5665,  lon: 126.9780, tz_offset_minutes: 540 },
265    CityPreset { name: "Bangkok, TH".into(),      lat: 13.7563,  lon: 100.5018, tz_offset_minutes: 420 },
266    CityPreset { name: "Jakarta, ID".into(),      lat: -6.2088,  lon: 106.8456, tz_offset_minutes: 420 },
267    CityPreset { name: "Manila, PH".into(),       lat: 14.5995,  lon: 120.9842, tz_offset_minutes: 480 },
268    CityPreset { name: "Mumbai, IN".into(),       lat: 19.0760,  lon: 72.8777,  tz_offset_minutes: 330 },
269    CityPreset { name: "Delhi, IN".into(),        lat: 28.7041,  lon: 77.1025,  tz_offset_minutes: 330 },
270    CityPreset { name: "Bangalore, IN".into(),    lat: 12.9716,  lon: 77.5946,  tz_offset_minutes: 330 },
271    CityPreset { name: "Karachi, PK".into(),      lat: 24.8607,  lon: 67.0011,  tz_offset_minutes: 300 },
272    CityPreset { name: "Tehran, IR".into(),       lat: 35.6892,  lon: 51.3890,  tz_offset_minutes: 210 },
273    CityPreset { name: "Dubai, AE".into(),        lat: 25.2048,  lon: 55.2708,  tz_offset_minutes: 240 },
274    CityPreset { name: "Tel Aviv, IL".into(),     lat: 32.0853,  lon: 34.7818,  tz_offset_minutes: 120 },
275    // África
276    CityPreset { name: "Cairo, EG".into(),        lat: 30.0444,  lon: 31.2357,  tz_offset_minutes: 120 },
277    CityPreset { name: "Lagos, NG".into(),        lat: 6.5244,   lon: 3.3792,   tz_offset_minutes: 60 },
278    CityPreset { name: "Nairobi, KE".into(),      lat: -1.2921,  lon: 36.8219,  tz_offset_minutes: 180 },
279    CityPreset { name: "Johannesburg, ZA".into(), lat: -26.2041, lon: 28.0473,  tz_offset_minutes: 120 },
280    CityPreset { name: "Cape Town, ZA".into(),    lat: -33.9249, lon: 18.4241,  tz_offset_minutes: 120 },
281    CityPreset { name: "Casablanca, MA".into(),   lat: 33.5731,  lon: -7.5898,  tz_offset_minutes: 60 },
282    // Oceanía
283    CityPreset { name: "Sydney, AU".into(),       lat: -33.8688, lon: 151.2093, tz_offset_minutes: 600 },
284    CityPreset { name: "Melbourne, AU".into(),    lat: -37.8136, lon: 144.9631, tz_offset_minutes: 600 },
285    CityPreset { name: "Auckland, NZ".into(),     lat: -36.8485, lon: 174.7633, tz_offset_minutes: 720 },
286    ]
287}
288
289/// Parsea un atlas TSV (tab-separated values): cada línea no vacía y
290/// no comentario es `name<TAB>lat<TAB>lon<TAB>tz_offset_minutes`.
291/// Devuelve solo las filas válidas — las inválidas se descartan en
292/// silencio (no abortamos la carga por una línea mal formada).
293pub fn parse_city_atlas_tsv(content: &str) -> Vec<CityPreset> {
294    let mut out = Vec::new();
295    for line in content.lines() {
296        let line = line.trim();
297        if line.is_empty() || line.starts_with('#') {
298            continue;
299        }
300        let parts: Vec<&str> = line.split('\t').collect();
301        if parts.len() < 4 {
302            continue;
303        }
304        let name = parts[0].trim().to_string();
305        let lat = parts[1].trim().parse::<f64>();
306        let lon = parts[2].trim().parse::<f64>();
307        let tz = parts[3].trim().parse::<i32>();
308        if let (Ok(lat), Ok(lon), Ok(tz)) = (lat, lon, tz) {
309            if !name.is_empty() {
310                out.push(CityPreset {
311                    name,
312                    lat,
313                    lon,
314                    tz_offset_minutes: tz,
315                });
316            }
317        }
318    }
319    out
320}
321
322impl EventEmitter<TreeEvent> for TahuantinsuyuTree {}
323
324impl TahuantinsuyuTree {
325    pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
326        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
327
328        let inner = cx.new(|cx| TreeView::new("tahuantinsuyu-tree", cx));
329        cx.subscribe(&inner, |this: &mut Self, _, ev, cx| {
330            this.on_inner(ev, cx);
331        })
332        .detach();
333
334        let mut me = Self {
335            store,
336            inner,
337            expanded: HashSet::new(),
338            menu: None,
339            modal: None,
340            city_picker_open: false,
341            city_atlas: default_city_presets(),
342        };
343        me.refresh(cx);
344        me
345    }
346
347    /// Reemplaza el atlas de ciudades del dropdown. La app llama esto
348    /// al boot si encuentra un archivo TSV custom en disco.
349    pub fn set_city_atlas(&mut self, atlas: Vec<CityPreset>, cx: &mut Context<Self>) {
350        if !atlas.is_empty() {
351            self.city_atlas = atlas;
352            cx.notify();
353        }
354    }
355
356    pub fn refresh(&mut self, cx: &mut Context<Self>) {
357        let mut rows = Vec::new();
358        self.append_groups(None, 0, &mut rows);
359        self.append_contacts(None, 0, &mut rows);
360        self.inner.update(cx, |t, cx| t.set_rows(rows, cx));
361    }
362
363    fn append_groups(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
364        let groups = match self.store.list_groups(parent) {
365            Ok(v) => v,
366            Err(_) => return,
367        };
368        for g in groups {
369            let id_str = format!("{}{}", PREFIX_GROUP, g.id);
370            let expanded = self.expanded.contains(&id_str);
371            out.push(TreeRow {
372                id: RowId::new(id_str.clone()),
373                label: g.name.clone(),
374                depth,
375                kind: RowKind::Branch,
376                expanded,
377                icon: Some("📁".into()),
378            });
379            if expanded {
380                self.append_groups(Some(g.id), depth + 1, out);
381                self.append_contacts(Some(g.id), depth + 1, out);
382            }
383        }
384    }
385
386    fn append_contacts(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
387        let contacts = match self.store.list_contacts(parent) {
388            Ok(v) => v,
389            Err(_) => return,
390        };
391        for c in contacts {
392            let id_str = format!("{}{}", PREFIX_CONTACT, c.id);
393            let expanded = self.expanded.contains(&id_str);
394            out.push(TreeRow {
395                id: RowId::new(id_str.clone()),
396                label: c.name.clone(),
397                depth,
398                kind: RowKind::Branch,
399                expanded,
400                icon: Some("🜨".into()),
401            });
402            if expanded {
403                self.append_charts(c.id, depth + 1, out);
404            }
405        }
406    }
407
408    fn append_charts(&self, contact: ContactId, depth: u32, out: &mut Vec<TreeRow>) {
409        let charts = match self.store.list_charts(contact) {
410            Ok(v) => v,
411            Err(_) => return,
412        };
413        for h in charts {
414            let id_str = format!("{}{}", PREFIX_CHART, h.id);
415            out.push(TreeRow {
416                id: RowId::new(id_str),
417                label: h.label.clone(),
418                depth,
419                kind: RowKind::Leaf,
420                expanded: false,
421                icon: Some("✦".into()),
422            });
423        }
424    }
425
426    fn on_inner(&mut self, ev: &InnerTreeEvent, cx: &mut Context<Self>) {
427        match ev {
428            InnerTreeEvent::ChevronToggled(id) => {
429                let s = id.as_str().to_string();
430                if !self.expanded.remove(&s) {
431                    self.expanded.insert(s);
432                }
433                self.refresh(cx);
434            }
435            InnerTreeEvent::RowClicked(id) => {
436                if self.menu.is_some() {
437                    self.menu = None;
438                    cx.notify();
439                }
440                if let Some(sel) = parse_row(id) {
441                    cx.emit(TreeEvent::Selected(sel));
442                }
443            }
444            InnerTreeEvent::RowDoubleClicked(id) => {
445                if let Some(sel) = parse_row(id) {
446                    cx.emit(TreeEvent::Opened(sel));
447                }
448            }
449            InnerTreeEvent::ContextMenuRequested { id, position } => {
450                let target = match id.as_ref().and_then(parse_row) {
451                    Some(sel) => MenuTarget::from_selection(&sel),
452                    None => MenuTarget::Root,
453                };
454                self.menu = Some(MenuState {
455                    target,
456                    position: *position,
457                });
458                cx.notify();
459            }
460            InnerTreeEvent::ActiveChanged(_) => {}
461        }
462    }
463
464    // -----------------------------------------------------------------
465    // Acciones del menú
466    // -----------------------------------------------------------------
467
468    fn close_menu(&mut self, cx: &mut Context<Self>) {
469        if self.menu.take().is_some() {
470            cx.notify();
471        }
472    }
473
474    fn close_modal(&mut self, cx: &mut Context<Self>) {
475        if self.modal.take().is_some() {
476            self.city_picker_open = false;
477            cx.notify();
478        }
479    }
480
481    fn toggle_city_picker(&mut self, cx: &mut Context<Self>) {
482        self.city_picker_open = !self.city_picker_open;
483        cx.notify();
484    }
485
486    /// Aplica un city preset al ChartForm activo (CreateChart o
487    /// EditChart). Setea place, lat, lon, tz_offset_min vía
488    /// `TextInput::set_text` y cierra el picker.
489    fn apply_city_preset(&mut self, preset: &CityPreset, cx: &mut Context<Self>) {
490        let form = match self.modal.as_mut() {
491            Some(Modal::CreateChart { form, .. }) => form,
492            Some(Modal::EditChart { form, .. }) => form,
493            _ => {
494                self.city_picker_open = false;
495                cx.notify();
496                return;
497            }
498        };
499        let place = form.place.clone();
500        let lat = form.lat.clone();
501        let lon = form.lon.clone();
502        let tz = form.tz_offset_min.clone();
503        let name = preset.name.clone();
504        let lat_val = preset.lat;
505        let lon_val = preset.lon;
506        let tz_val = preset.tz_offset_minutes;
507        place.update(cx, |i, cx| i.set_text(name, cx));
508        lat.update(cx, |i, cx| i.set_text(format!("{}", lat_val), cx));
509        lon.update(cx, |i, cx| i.set_text(format!("{}", lon_val), cx));
510        tz.update(cx, |i, cx| i.set_text(tz_val.to_string(), cx));
511        self.city_picker_open = false;
512        cx.notify();
513    }
514
515    fn open_create_group(
516        &mut self,
517        parent: Option<GroupId>,
518        window: &mut Window,
519        cx: &mut Context<Self>,
520    ) {
521        let input = self.make_input("Nombre del grupo", "", window, cx);
522        self.modal = Some(Modal::CreateGroup { parent, input });
523        self.close_menu(cx);
524    }
525
526    fn open_create_contact(
527        &mut self,
528        group: Option<GroupId>,
529        window: &mut Window,
530        cx: &mut Context<Self>,
531    ) {
532        let input = self.make_input("Nombre del contacto", "", window, cx);
533        self.modal = Some(Modal::CreateContact { group, input });
534        self.close_menu(cx);
535    }
536
537    fn open_edit_chart(
538        &mut self,
539        id: ChartId,
540        window: &mut Window,
541        cx: &mut Context<Self>,
542    ) {
543        // Cargar la carta existente; si no se puede, fallamos en silencio.
544        let chart = match self.store.get_chart(id) {
545            Ok(c) => c,
546            Err(e) => {
547                eprintln!("[tree] open_edit_chart {}: {}", id, e);
548                return;
549            }
550        };
551        let bd = &chart.birth_data;
552        let form = ChartForm {
553            name: self.make_input("Etiqueta de la carta", &chart.label, window, cx),
554            place: self.make_input(
555                "Lugar (ciudad, país)",
556                bd.birthplace_label.as_deref().unwrap_or(""),
557                window,
558                cx,
559            ),
560            year: self.make_input("Año", &bd.year.to_string(), window, cx),
561            month: self.make_input("Mes", &bd.month.to_string(), window, cx),
562            day: self.make_input("Día", &bd.day.to_string(), window, cx),
563            hour: self.make_input("Hora (0-23)", &bd.hour.to_string(), window, cx),
564            minute: self.make_input("Minuto", &bd.minute.to_string(), window, cx),
565            tz_offset_min: self.make_input(
566                "TZ offset (min)",
567                &bd.tz_offset_minutes.to_string(),
568                window,
569                cx,
570            ),
571            lat: self.make_input("Latitud (°)", &format!("{}", bd.latitude_deg), window, cx),
572            lon: self.make_input(
573                "Longitud (°)",
574                &format!("{}", bd.longitude_deg),
575                window,
576                cx,
577            ),
578            alt: self.make_input("Altitud (m)", &format!("{}", bd.altitude_m), window, cx),
579        };
580        form.name.update(cx, |i, _| i.request_focus(window));
581        self.modal = Some(Modal::EditChart {
582            id,
583            form,
584            error: None,
585        });
586        self.close_menu(cx);
587    }
588
589    fn open_create_chart(
590        &mut self,
591        contact: ContactId,
592        window: &mut Window,
593        cx: &mut Context<Self>,
594    ) {
595        // Pre-cargamos el nombre del contacto en el campo "Sujeto" del
596        // form como conveniencia — la mayoría de las cartas se nombran
597        // igual que la persona.
598        let subject_name = self
599            .store
600            .list_contacts(None)
601            .ok()
602            .and_then(|all| {
603                // Buscamos linealmente — list_contacts solo lista hijos
604                // directos del group, no nos sirve para encontrar un
605                // contact arbitrario. Para fase 2 nos quedamos con
606                // "Carta natal" como label genérico si no podemos
607                // resolver. Resolver-por-id viene si lo necesitamos.
608                let _ = all;
609                None::<String>
610            })
611            .unwrap_or_else(|| "Carta natal".into());
612
613        let form = ChartForm {
614            name: self.make_input("Etiqueta de la carta", &subject_name, window, cx),
615            place: self.make_input("Lugar (ciudad, país)", "", window, cx),
616            year: self.make_input("Año", "1987", window, cx),
617            month: self.make_input("Mes", "3", window, cx),
618            day: self.make_input("Día", "14", window, cx),
619            hour: self.make_input("Hora (0-23)", "5", window, cx),
620            minute: self.make_input("Minuto", "22", window, cx),
621            tz_offset_min: self.make_input("TZ offset (min)", "-240", window, cx),
622            lat: self.make_input("Latitud (°)", "10.4806", window, cx),
623            lon: self.make_input("Longitud (°)", "-66.9036", window, cx),
624            alt: self.make_input("Altitud (m)", "900", window, cx),
625        };
626        // El primer field es el que recibe focus.
627        form.name.update(cx, |i, _| i.request_focus(window));
628
629        self.modal = Some(Modal::CreateChart {
630            contact,
631            form,
632            error: None,
633        });
634        self.close_menu(cx);
635    }
636
637    fn open_rename(&mut self, target: MenuTarget, window: &mut Window, cx: &mut Context<Self>) {
638        let modal = match target {
639            MenuTarget::Group(id) => {
640                let current = self
641                    .store
642                    .list_groups(None)
643                    .ok()
644                    .and_then(|all| find_group_name(&all, &self.store, id))
645                    .unwrap_or_default();
646                Modal::RenameGroup {
647                    id,
648                    input: self.make_input("Nuevo nombre", &current, window, cx),
649                }
650            }
651            MenuTarget::Contact(id) => {
652                let current = self
653                    .store
654                    .list_contacts(None)
655                    .ok()
656                    .and_then(|all| find_contact_name(&all, &self.store, id))
657                    .unwrap_or_default();
658                Modal::RenameContact {
659                    id,
660                    input: self.make_input("Nuevo nombre", &current, window, cx),
661                }
662            }
663            MenuTarget::Chart(id) => {
664                let current = self
665                    .store
666                    .get_chart(id)
667                    .ok()
668                    .map(|c| c.label)
669                    .unwrap_or_default();
670                Modal::RenameChart {
671                    id,
672                    input: self.make_input("Nueva etiqueta", &current, window, cx),
673                }
674            }
675            MenuTarget::Root => return,
676        };
677        self.modal = Some(modal);
678        self.close_menu(cx);
679    }
680
681    /// Crea un `TextInput` con focus y suscripción a Confirmed/Cancelled.
682    /// La closure decide qué hacer con cada evento — guardamos la subscripción
683    /// detached para que viva mientras el modal exista.
684    fn make_input(
685        &self,
686        placeholder: &str,
687        initial: &str,
688        window: &mut Window,
689        cx: &mut Context<Self>,
690    ) -> Entity<TextInput> {
691        let placeholder = placeholder.to_string();
692        let input = cx.new(|cx| {
693            TextInput::new(initial.to_string(), cx)
694                .with_placeholder(SharedString::from(placeholder.clone()))
695        });
696        cx.subscribe(&input, |this: &mut Self, _, ev: &TextInputEvent, cx| {
697            this.on_input_event(ev, cx);
698        })
699        .detach();
700        input.update(cx, |i, _| i.request_focus(window));
701        input
702    }
703
704    fn on_input_event(&mut self, ev: &TextInputEvent, cx: &mut Context<Self>) {
705        match ev {
706            TextInputEvent::Cancelled => self.close_modal(cx),
707            TextInputEvent::Confirmed(value) => self.submit_modal(value.clone(), cx),
708        }
709    }
710
711    fn submit_modal(&mut self, value: String, cx: &mut Context<Self>) {
712        let trimmed = value.trim().to_string();
713        // Tomamos ownership del modal — si el submit falla en mitad,
714        // lo restablecemos. Esto evita un borrow-mut sobre self.modal.
715        let modal = match self.modal.take() {
716            Some(m) => m,
717            None => return,
718        };
719        match modal {
720            Modal::RenameGroup { id, input } => {
721                if !trimmed.is_empty() {
722                    let _ = self.store.rename_group(id, &trimmed);
723                }
724                drop(input);
725                self.after_mutation(cx);
726            }
727            Modal::RenameContact { id, input } => {
728                if !trimmed.is_empty() {
729                    let _ = self.store.rename_contact(id, &trimmed);
730                }
731                drop(input);
732                self.after_mutation(cx);
733            }
734            Modal::RenameChart { id, input } => {
735                if !trimmed.is_empty() {
736                    let _ = self.store.rename_chart(id, &trimmed);
737                }
738                drop(input);
739                self.after_mutation(cx);
740            }
741            Modal::CreateGroup { parent, input } => {
742                if !trimmed.is_empty() {
743                    let _ = self.store.create_group(parent, &trimmed, None);
744                }
745                drop(input);
746                self.after_mutation(cx);
747            }
748            Modal::CreateContact { group, input } => {
749                if !trimmed.is_empty() {
750                    let _ = self.store.create_contact(group, &trimmed, None);
751                }
752                drop(input);
753                self.after_mutation(cx);
754            }
755            Modal::CreateChart {
756                contact,
757                form,
758                error: _,
759            } => {
760                // `value` viene del campo que disparó Enter. Para el
761                // form, ignoramos el value puntual y leemos todos los
762                // campos del form.
763                let _ = value;
764                match build_chart_from_form(&form, cx) {
765                    Ok((birth, label)) => {
766                        match self.store.create_chart(
767                            contact,
768                            ChartKind::Natal,
769                            &label,
770                            &birth,
771                            &StoredChartConfig::default(),
772                            None,
773                        ) {
774                            Ok(_) => {
775                                // Auto-expand del contact para que se vea
776                                // la carta recién creada.
777                                self.expanded
778                                    .insert(format!("{}{}", PREFIX_CONTACT, contact));
779                                self.after_mutation(cx);
780                            }
781                            Err(e) => {
782                                self.modal = Some(Modal::CreateChart {
783                                    contact,
784                                    form,
785                                    error: Some(SharedString::from(format!("Store: {}", e))),
786                                });
787                                cx.notify();
788                            }
789                        }
790                    }
791                    Err(msg) => {
792                        self.modal = Some(Modal::CreateChart {
793                            contact,
794                            form,
795                            error: Some(SharedString::from(msg)),
796                        });
797                        cx.notify();
798                    }
799                }
800            }
801            Modal::EditChart {
802                id,
803                form,
804                error: _,
805            } => {
806                let _ = value;
807                // Para preservar el ChartConfig original (zodiac, house
808                // system, bodies, etc.) leemos la carta actual y solo
809                // sobrescribimos label + birth_data. El editor no toca
810                // config — eso se haría en un futuro panel de "Config
811                // de carta".
812                let existing = match self.store.get_chart(id) {
813                    Ok(c) => c,
814                    Err(e) => {
815                        self.modal = Some(Modal::EditChart {
816                            id,
817                            form,
818                            error: Some(SharedString::from(format!("Store: {}", e))),
819                        });
820                        cx.notify();
821                        return;
822                    }
823                };
824                match build_chart_from_form(&form, cx) {
825                    Ok((birth, label)) => {
826                        match self.store.update_chart(id, &label, &birth, &existing.config) {
827                            Ok(_) => {
828                                drop(form);
829                                self.after_mutation(cx);
830                            }
831                            Err(e) => {
832                                self.modal = Some(Modal::EditChart {
833                                    id,
834                                    form,
835                                    error: Some(SharedString::from(format!("Store: {}", e))),
836                                });
837                                cx.notify();
838                            }
839                        }
840                    }
841                    Err(msg) => {
842                        self.modal = Some(Modal::EditChart {
843                            id,
844                            form,
845                            error: Some(SharedString::from(msg)),
846                        });
847                        cx.notify();
848                    }
849                }
850            }
851        }
852    }
853
854    fn after_mutation(&mut self, cx: &mut Context<Self>) {
855        self.modal = None;
856        self.refresh(cx);
857        cx.emit(TreeEvent::HierarchyChanged);
858        cx.notify();
859    }
860
861    fn confirm_and_delete(
862        &mut self,
863        target: MenuTarget,
864        window: &mut Window,
865        cx: &mut Context<Self>,
866    ) {
867        let (label, kind) = match target {
868            MenuTarget::Group(_) => ("este grupo (incluye sus subgrupos y contactos)", "group"),
869            MenuTarget::Contact(_) => ("este contacto (incluye sus cartas)", "contact"),
870            MenuTarget::Chart(_) => ("esta carta", "chart"),
871            MenuTarget::Root => return,
872        };
873        let answer = window.prompt(
874            PromptLevel::Warning,
875            &format!("¿Borrar {}?", label),
876            None,
877            &["Borrar", "Cancelar"],
878            cx,
879        );
880        let target_clone = target.clone();
881        cx.spawn(async move |this, cx| {
882            let Ok(idx) = answer.await else { return };
883            if idx != 0 {
884                return;
885            }
886            let _ = this.update(cx, |this, cx| {
887                match target_clone {
888                    MenuTarget::Group(id) => {
889                        let _ = this.store.delete_group(id);
890                    }
891                    MenuTarget::Contact(id) => {
892                        let _ = this.store.delete_contact(id);
893                    }
894                    MenuTarget::Chart(id) => {
895                        let _ = this.store.delete_chart(id);
896                    }
897                    MenuTarget::Root => {}
898                }
899                this.after_mutation(cx);
900            });
901        })
902        .detach();
903        let _ = kind;
904        self.close_menu(cx);
905    }
906}
907
908// =====================================================================
909// Form helpers
910// =====================================================================
911
912fn build_chart_from_form(
913    form: &ChartForm,
914    cx: &mut Context<TahuantinsuyuTree>,
915) -> Result<(StoredBirthData, String), String> {
916    let name = form.name.read(cx).text().trim().to_string();
917    let place = form.place.read(cx).text().trim().to_string();
918    let year: i32 = parse_field(form.year.read(cx).text(), "Año")?;
919    let month: u32 = parse_field(form.month.read(cx).text(), "Mes")?;
920    let day: u32 = parse_field(form.day.read(cx).text(), "Día")?;
921    let hour: u32 = parse_field(form.hour.read(cx).text(), "Hora")?;
922    let minute: u32 = parse_field(form.minute.read(cx).text(), "Minuto")?;
923    let tz_offset_minutes: i32 = parse_field(form.tz_offset_min.read(cx).text(), "TZ offset")?;
924    let latitude_deg: f64 = parse_field(form.lat.read(cx).text(), "Latitud")?;
925    let longitude_deg: f64 = parse_field(form.lon.read(cx).text(), "Longitud")?;
926    let altitude_m: f64 = parse_field(form.alt.read(cx).text(), "Altitud")?;
927
928    if !(1..=12).contains(&month) {
929        return Err(format!("Mes fuera de rango: {}", month));
930    }
931    if !(1..=31).contains(&day) {
932        return Err(format!("Día fuera de rango: {}", day));
933    }
934    if hour > 23 {
935        return Err(format!("Hora fuera de rango: {}", hour));
936    }
937    if minute > 59 {
938        return Err(format!("Minuto fuera de rango: {}", minute));
939    }
940
941    let label = if name.is_empty() {
942        "Carta natal".to_string()
943    } else {
944        name
945    };
946    let birth = StoredBirthData {
947        year,
948        month,
949        day,
950        hour,
951        minute,
952        second: 0.0,
953        tz_offset_minutes,
954        latitude_deg,
955        longitude_deg,
956        altitude_m,
957        time_certainty: TimeCertainty::Exact,
958        subject_name: None,
959        birthplace_label: if place.is_empty() { None } else { Some(place) },
960    };
961    Ok((birth, label))
962}
963
964fn parse_field<T: std::str::FromStr>(s: &str, field: &str) -> Result<T, String> {
965    s.trim()
966        .parse::<T>()
967        .map_err(|_| format!("Campo \"{}\" inválido: {:?}", field, s))
968}
969
970// =====================================================================
971// Lookups auxiliares (DFS por la jerarquía)
972// =====================================================================
973
974fn find_group_name(roots: &[tahuantinsuyu_model::Group], store: &Store, id: GroupId) -> Option<String> {
975    for g in roots {
976        if g.id == id {
977            return Some(g.name.clone());
978        }
979        if let Ok(children) = store.list_groups(Some(g.id)) {
980            if let Some(n) = find_group_name(&children, store, id) {
981                return Some(n);
982            }
983        }
984    }
985    None
986}
987
988fn find_contact_name(
989    in_group: &[tahuantinsuyu_model::Contact],
990    store: &Store,
991    id: ContactId,
992) -> Option<String> {
993    for c in in_group {
994        if c.id == id {
995            return Some(c.name.clone());
996        }
997    }
998    // Buscar también en todos los groups recursivamente.
999    if let Ok(groups) = store.list_groups(None) {
1000        if let Some(n) = find_contact_in_groups(&groups, store, id) {
1001            return Some(n);
1002        }
1003    }
1004    None
1005}
1006
1007fn find_contact_in_groups(
1008    groups: &[tahuantinsuyu_model::Group],
1009    store: &Store,
1010    id: ContactId,
1011) -> Option<String> {
1012    for g in groups {
1013        if let Ok(cs) = store.list_contacts(Some(g.id)) {
1014            for c in &cs {
1015                if c.id == id {
1016                    return Some(c.name.clone());
1017                }
1018            }
1019        }
1020        if let Ok(children) = store.list_groups(Some(g.id)) {
1021            if let Some(n) = find_contact_in_groups(&children, store, id) {
1022                return Some(n);
1023            }
1024        }
1025    }
1026    None
1027}
1028
1029fn parse_row(id: &RowId) -> Option<TreeSelection> {
1030    let s = id.as_str();
1031    if let Some(rest) = s.strip_prefix(PREFIX_GROUP) {
1032        return rest.parse().ok().map(TreeSelection::Group);
1033    }
1034    if let Some(rest) = s.strip_prefix(PREFIX_CONTACT) {
1035        return rest.parse().ok().map(TreeSelection::Contact);
1036    }
1037    if let Some(rest) = s.strip_prefix(PREFIX_CHART) {
1038        return rest.parse().ok().map(TreeSelection::Chart);
1039    }
1040    None
1041}
1042
1043// =====================================================================
1044// Render
1045// =====================================================================
1046
1047const MENU_WIDTH: f32 = 220.0;
1048
1049impl Render for TahuantinsuyuTree {
1050    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1051        let theme = Theme::global(cx).clone();
1052        let mut root = div()
1053            .id("tahuantinsuyu-tree-root")
1054            .size_full()
1055            .relative()
1056            .bg(theme.bg_panel.clone())
1057            .flex()
1058            .flex_col()
1059            .child(self.inner.clone());
1060
1061        if let Some(menu) = self.menu.clone() {
1062            root = root.child(self.render_menu(&theme, menu, cx));
1063        }
1064        if self.modal.is_some() {
1065            root = root.child(self.render_modal(&theme, cx));
1066        }
1067        root
1068    }
1069}
1070
1071impl TahuantinsuyuTree {
1072    fn render_menu(
1073        &self,
1074        theme: &Theme,
1075        menu: MenuState,
1076        cx: &mut Context<Self>,
1077    ) -> impl IntoElement {
1078        let mut items = div()
1079            .flex()
1080            .flex_col()
1081            .py(px(4.0))
1082            .min_w(px(MENU_WIDTH))
1083            .bg(theme.bg_panel_alt.clone())
1084            .border_1()
1085            .border_color(theme.border_strong)
1086            .rounded(px(6.0));
1087
1088        match menu.target.clone() {
1089            MenuTarget::Root => {
1090                items = items.child(menu_item("tts-menu-new-group", "Nuevo grupo", theme).on_click(
1091                    cx.listener(|this, _: &ClickEvent, w, cx| {
1092                        this.open_create_group(None, w, cx);
1093                    }),
1094                ));
1095                items = items.child(
1096                    menu_item("tts-menu-new-contact-root", "Nuevo contacto", theme).on_click(
1097                        cx.listener(|this, _: &ClickEvent, w, cx| {
1098                            this.open_create_contact(None, w, cx);
1099                        }),
1100                    ),
1101                );
1102            }
1103            MenuTarget::Group(id) => {
1104                items = items.child(
1105                    menu_item("tts-menu-new-subgroup", "Nuevo subgrupo", theme).on_click(
1106                        cx.listener(move |this, _: &ClickEvent, w, cx| {
1107                            this.open_create_group(Some(id), w, cx);
1108                        }),
1109                    ),
1110                );
1111                items = items.child(
1112                    menu_item("tts-menu-new-contact", "Nuevo contacto", theme).on_click(
1113                        cx.listener(move |this, _: &ClickEvent, w, cx| {
1114                            this.open_create_contact(Some(id), w, cx);
1115                        }),
1116                    ),
1117                );
1118                items = items.child(separator(theme));
1119                let t = menu.target.clone();
1120                items = items.child(menu_item("tts-menu-rename-g", "Renombrar…", theme).on_click(
1121                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1122                        this.open_rename(t.clone(), w, cx);
1123                    }),
1124                ));
1125                let t = menu.target.clone();
1126                items = items.child(menu_item("tts-menu-delete-g", "Borrar…", theme).on_click(
1127                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1128                        this.confirm_and_delete(t.clone(), w, cx);
1129                    }),
1130                ));
1131            }
1132            MenuTarget::Contact(id) => {
1133                items = items.child(menu_item("tts-menu-new-chart", "Nueva carta…", theme).on_click(
1134                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1135                        this.open_create_chart(id, w, cx);
1136                    }),
1137                ));
1138                items = items.child(separator(theme));
1139                let t = menu.target.clone();
1140                items = items.child(menu_item("tts-menu-rename-c", "Renombrar…", theme).on_click(
1141                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1142                        this.open_rename(t.clone(), w, cx);
1143                    }),
1144                ));
1145                let t = menu.target.clone();
1146                items = items.child(menu_item("tts-menu-delete-c", "Borrar…", theme).on_click(
1147                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1148                        this.confirm_and_delete(t.clone(), w, cx);
1149                    }),
1150                ));
1151            }
1152            MenuTarget::Chart(id) => {
1153                items = items.child(menu_item("tts-menu-open-h", "Abrir", theme).on_click(
1154                    cx.listener(move |this, _: &ClickEvent, _w, cx| {
1155                        cx.emit(TreeEvent::Opened(TreeSelection::Chart(id)));
1156                        this.close_menu(cx);
1157                    }),
1158                ));
1159                items = items.child(menu_item("tts-menu-edit-h", "Editar…", theme).on_click(
1160                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1161                        this.open_edit_chart(id, w, cx);
1162                    }),
1163                ));
1164                items = items.child(separator(theme));
1165                let t = menu.target.clone();
1166                items = items.child(menu_item("tts-menu-rename-h", "Renombrar…", theme).on_click(
1167                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1168                        this.open_rename(t.clone(), w, cx);
1169                    }),
1170                ));
1171                let t = menu.target.clone();
1172                items = items.child(menu_item("tts-menu-delete-h", "Borrar…", theme).on_click(
1173                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1174                        this.confirm_and_delete(t.clone(), w, cx);
1175                    }),
1176                ));
1177            }
1178        }
1179
1180        div()
1181            .absolute()
1182            .left(menu.position.x)
1183            .top(menu.position.y)
1184            .child(items)
1185    }
1186
1187    fn render_modal(&self, theme: &Theme, cx: &mut Context<Self>) -> impl IntoElement {
1188        let modal = self.modal.as_ref().expect("render_modal sin modal activo");
1189        let inner = match modal {
1190            Modal::RenameGroup { input, .. }
1191            | Modal::RenameContact { input, .. }
1192            | Modal::RenameChart { input, .. } => {
1193                modal_box(theme, "Renombrar", input.clone(), "Enter = guardar — Escape = cancelar")
1194            }
1195            Modal::CreateGroup { input, .. } => {
1196                modal_box(theme, "Nuevo grupo", input.clone(), "Enter = crear — Escape = cancelar")
1197            }
1198            Modal::CreateContact { input, .. } => modal_box(
1199                theme,
1200                "Nuevo contacto",
1201                input.clone(),
1202                "Enter = crear — Escape = cancelar",
1203            ),
1204            Modal::CreateChart { form, error, .. } => {
1205                render_chart_form(theme, "Nueva carta natal", form, error.clone(), cx)
1206            }
1207            Modal::EditChart { form, error, .. } => {
1208                render_chart_form(theme, "Editar carta natal", form, error.clone(), cx)
1209            }
1210        };
1211
1212        div()
1213            .absolute()
1214            .top(px(0.0))
1215            .left(px(0.0))
1216            .size_full()
1217            .flex()
1218            .items_center()
1219            .justify_center()
1220            .bg(hsla(0.0, 0.0, 0.0, 0.55))
1221            .child(inner)
1222    }
1223}
1224
1225// =====================================================================
1226// Helpers de UI
1227// =====================================================================
1228
1229fn menu_item(
1230    id: &'static str,
1231    label: &'static str,
1232    theme: &Theme,
1233) -> gpui::Stateful<gpui::Div> {
1234    div()
1235        .id(id)
1236        .px(px(12.0))
1237        .py(px(6.0))
1238        .text_size(px(12.0))
1239        .text_color(theme.fg_text)
1240        .hover(|s| s.bg(theme.bg_row_hover))
1241        .child(label)
1242}
1243
1244fn separator(theme: &Theme) -> gpui::Div {
1245    div()
1246        .my(px(3.0))
1247        .h(px(1.0))
1248        .w_full()
1249        .bg(theme.border)
1250}
1251
1252fn modal_box(
1253    theme: &Theme,
1254    title: &'static str,
1255    input: Entity<TextInput>,
1256    hint: &'static str,
1257) -> gpui::Div {
1258    div()
1259        .min_w(px(380.0))
1260        .p(px(16.0))
1261        .flex()
1262        .flex_col()
1263        .gap(px(10.0))
1264        .bg(theme.bg_panel_alt.clone())
1265        .border_1()
1266        .border_color(theme.border_strong)
1267        .rounded(px(8.0))
1268        .child(
1269            div()
1270                .text_size(px(13.0))
1271                .text_color(theme.fg_text)
1272                .child(title),
1273        )
1274        .child(input)
1275        .child(
1276            div()
1277                .text_size(px(10.0))
1278                .text_color(theme.fg_muted)
1279                .child(hint),
1280        )
1281}
1282
1283fn render_chart_form(
1284    theme: &Theme,
1285    title: &str,
1286    form: &ChartForm,
1287    error: Option<SharedString>,
1288    cx: &mut Context<TahuantinsuyuTree>,
1289) -> gpui::Div {
1290    let labeled = |label: &'static str, input: Entity<TextInput>| -> gpui::Div {
1291        div()
1292            .flex()
1293            .flex_col()
1294            .gap(px(2.0))
1295            .child(
1296                div()
1297                    .text_size(px(10.0))
1298                    .text_color(theme.fg_muted)
1299                    .child(label),
1300            )
1301            .child(input)
1302    };
1303
1304    let date_row = div()
1305        .flex()
1306        .flex_row()
1307        .gap(px(8.0))
1308        .child(labeled("Año", form.year.clone()))
1309        .child(labeled("Mes", form.month.clone()))
1310        .child(labeled("Día", form.day.clone()))
1311        .child(labeled("Hora", form.hour.clone()))
1312        .child(labeled("Minuto", form.minute.clone()))
1313        .child(labeled("TZ (min)", form.tz_offset_min.clone()));
1314
1315    let loc_row = div()
1316        .flex()
1317        .flex_row()
1318        .gap(px(8.0))
1319        .child(labeled("Latitud", form.lat.clone()))
1320        .child(labeled("Longitud", form.lon.clone()))
1321        .child(labeled("Altitud (m)", form.alt.clone()));
1322
1323    let create_btn = div()
1324        .id("tts-chart-form-create")
1325        .px(px(14.0))
1326        .py(px(8.0))
1327        .rounded(px(6.0))
1328        .bg(theme.bg_button())
1329        .hover(|s| s.bg(theme.bg_button_hover()))
1330        .text_size(px(12.0))
1331        .text_color(theme.fg_text)
1332        .child(SharedString::from(if title.starts_with("Editar") {
1333            "Guardar cambios"
1334        } else {
1335            "Crear carta"
1336        }))
1337        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
1338            // Disparamos un submit "vacío" — el handler de submit
1339            // re-lee todos los campos del form. El value que pasamos
1340            // se ignora dentro del branch CreateChart/EditChart.
1341            this.submit_modal(String::new(), cx);
1342        }));
1343
1344    let cancel_btn = div()
1345        .id("tts-chart-form-cancel")
1346        .px(px(14.0))
1347        .py(px(8.0))
1348        .rounded(px(6.0))
1349        .bg(theme.bg_panel.clone())
1350        .hover(|s| s.bg(theme.bg_row_hover))
1351        .text_size(px(12.0))
1352        .text_color(theme.fg_muted)
1353        .child("Cancelar")
1354        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
1355            this.close_modal(cx);
1356        }));
1357
1358    // Header del form: title + botón "Ciudad rápida" con dropdown
1359    // que autocompleta place/lat/lon/tz al elegir un preset.
1360    let (picker_open, atlas_snapshot) = {
1361        let me = cx.entity().read(cx);
1362        (me.city_picker_open, me.city_atlas.clone())
1363    };
1364    let city_btn = div()
1365        .id("tts-form-city-btn")
1366        .px(px(10.0))
1367        .py(px(4.0))
1368        .rounded(px(4.0))
1369        .bg(theme.bg_button())
1370        .hover(|s| s.bg(theme.bg_button_hover()))
1371        .border_1()
1372        .border_color(if picker_open {
1373            theme.accent_strong
1374        } else {
1375            theme.border
1376        })
1377        .text_size(px(11.0))
1378        .text_color(theme.fg_text)
1379        .child("📍 Ciudad rápida ▾")
1380        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
1381            this.toggle_city_picker(cx);
1382        }));
1383    let title_row = div()
1384        .relative()
1385        .flex()
1386        .flex_row()
1387        .items_center()
1388        .gap(px(12.0))
1389        .child(
1390            div()
1391                .text_size(px(14.0))
1392                .text_color(theme.fg_text)
1393                .child(SharedString::from(title.to_string())),
1394        )
1395        .child(div().flex_grow())
1396        .child(city_btn);
1397    let title_row = if picker_open {
1398        let popup_id: SharedString = SharedString::from("tts-form-city-popup");
1399        let mut popup = div()
1400            .id(gpui::ElementId::from(popup_id))
1401            .absolute()
1402            .top(px(36.0))
1403            .right(px(0.0))
1404            .min_w(px(240.0))
1405            .h(px(360.0))
1406            .py(px(4.0))
1407            .bg(theme.bg_panel_alt.clone())
1408            .border_1()
1409            .border_color(theme.border_strong)
1410            .rounded(px(6.0))
1411            .flex()
1412            .flex_col()
1413            .overflow_y_scroll();
1414        for preset in atlas_snapshot.iter().cloned() {
1415            let row_id: SharedString =
1416                SharedString::from(format!("tts-city-{}", preset.name));
1417            let label = preset.name.clone();
1418            popup = popup.child(
1419                div()
1420                    .id(gpui::ElementId::from(row_id))
1421                    .px(px(10.0))
1422                    .py(px(4.0))
1423                    .text_size(px(11.0))
1424                    .text_color(theme.fg_text)
1425                    .hover(|s| s.bg(theme.bg_row_hover))
1426                    .child(SharedString::from(label))
1427                    .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
1428                        this.apply_city_preset(&preset, cx);
1429                    })),
1430            );
1431        }
1432        title_row.child(popup)
1433    } else {
1434        title_row
1435    };
1436
1437    let mut body = div()
1438        .min_w(px(640.0))
1439        .p(px(18.0))
1440        .flex()
1441        .flex_col()
1442        .gap(px(12.0))
1443        .bg(theme.bg_panel_alt.clone())
1444        .border_1()
1445        .border_color(theme.border_strong)
1446        .rounded(px(8.0))
1447        .child(title_row)
1448        .child(
1449            div()
1450                .flex()
1451                .flex_row()
1452                .gap(px(8.0))
1453                .child(labeled("Etiqueta", form.name.clone()))
1454                .child(labeled("Lugar (texto libre)", form.place.clone())),
1455        )
1456        .child(date_row)
1457        .child(loc_row);
1458
1459    if let Some(err) = error {
1460        body = body.child(
1461            div()
1462                .px(px(10.0))
1463                .py(px(6.0))
1464                .rounded(px(4.0))
1465                .bg(theme.bg_destructive_hover())
1466                .text_size(px(11.0))
1467                .text_color(theme.accent_destructive())
1468                .child(err),
1469        );
1470    }
1471
1472    body = body.child(
1473        div()
1474            .flex()
1475            .flex_row()
1476            .gap(px(8.0))
1477            .justify_end()
1478            .child(cancel_btn)
1479            .child(create_btn),
1480    );
1481
1482    body
1483}