tawasuyu

tawasuyu/brahman

Rust 478TOML 190Markdown 50JSON 176650 Sutichaykuna

⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs Rust · 1709 siq'ikuna · 65.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

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