gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198629 Definitions

⎇ d890bd4b3a7aa8af918e854c1ae6754ca08673cd crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs Rust · 1421 lines · 53.9 KB History Who touched what raw

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