tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Sutichaykuna

⎇ 32ab22f9542192dcf4d8f5b6fce4cefb7b1d0f7d crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs Rust · 1337 siq'ikuna · 46.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

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