gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198629 Definitions

⎇ dd836522ab861a796d8ac54b01de05d2ef52ff27 crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs Rust · 2295 lines · 87.7 KB History Who touched what raw

feat(tahuantinsuyu): editor inline para cartas libres (F2) dd836522a · 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, FreeChartId, GroupId, StoredBirthData, StoredChartConfig,
34    TimeCertainty, 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/// Prefijo de IDs de filas que representan cartas libres.
45const PREFIX_FREE: &str = "f:";
46/// IDs sentinela para los nodos virtuales fijos del tree.
47const ROW_GENERAL: &str = "general";
48const ROW_FREE_ROOT: &str = "free-root";
49
50// =====================================================================
51// Eventos públicos
52// =====================================================================
53
54#[derive(Clone, Debug)]
55pub enum TreeEvent {
56    Selected(TreeSelection),
57    Opened(TreeSelection),
58    /// Una mutación de la jerarquía aconteció (crear, borrar, renombrar).
59    /// El host puede usarlo para invalidar caches en otros widgets.
60    HierarchyChanged,
61    /// El usuario pidió crear una carta libre nueva. El shell la
62    /// agrega a su mapa, le da un id efímero, y llama
63    /// `set_free_charts` con la lista actualizada.
64    NewFreeChartRequested,
65    /// El usuario pidió guardar una carta libre como `Chart`
66    /// persistido. El shell abre su propio modal con dropdown de
67    /// contacto + input de nombre y al confirmar invoca
68    /// `store.create_chart`.
69    SaveFreeChartRequested(FreeChartId),
70    /// Borrar una carta libre del mapa del shell. Si es `sky-now`,
71    /// el shell ignora (no se puede borrar el Cielo).
72    DeleteFreeChartRequested(FreeChartId),
73    /// Submit del modal "Guardar como" — el shell crea/usa el
74    /// contacto y persiste la carta. Si `contact` es `None`, el
75    /// shell crea uno nuevo con `new_contact_name`.
76    FreeChartSaveConfirmed {
77        source_id: FreeChartId,
78        chart_name: String,
79        contact: Option<ContactId>,
80        new_contact_name: Option<String>,
81    },
82    /// Submit del modal "Editar datos" para una carta libre. El
83    /// shell aplica al mapa `free_charts` y re-renderea el wheel.
84    FreeChartEditConfirmed {
85        source_id: FreeChartId,
86        birth_data: StoredBirthData,
87        label: String,
88    },
89}
90
91// =====================================================================
92// Estado interno
93// =====================================================================
94
95/// Target del menú contextual / acciones.
96#[derive(Clone, Debug)]
97enum MenuTarget {
98    Root,
99    Group(GroupId),
100    Contact(ContactId),
101    Chart(ChartId),
102    /// Branch "Cartas libres" — menú con "Nueva carta libre".
103    FreeChartsRoot,
104    /// Una carta libre concreta — menú con "Guardar como…",
105    /// "Renombrar" y "Borrar" (salvo `sky-now`, que no se borra).
106    FreeChart(FreeChartId),
107}
108
109impl MenuTarget {
110    fn from_selection(sel: &TreeSelection) -> Self {
111        match sel {
112            TreeSelection::Group(id) => MenuTarget::Group(*id),
113            TreeSelection::Contact(id) => MenuTarget::Contact(*id),
114            TreeSelection::Chart(id) => MenuTarget::Chart(*id),
115            // "General" comparte menu con Root — el target lógico es
116            // crear contactos sin grupo padre.
117            TreeSelection::GeneralRoot => MenuTarget::Root,
118            TreeSelection::FreeChart(id) => MenuTarget::FreeChart(id.clone()),
119            TreeSelection::FreeChartsRoot => MenuTarget::FreeChartsRoot,
120        }
121    }
122}
123
124#[derive(Clone, Debug)]
125struct MenuState {
126    target: MenuTarget,
127    position: Point<Pixels>,
128}
129
130/// Modal flotante. Una sola `Modal` activa a la vez — la app no
131/// soporta editar varias cosas en simultáneo.
132enum Modal {
133    RenameGroup {
134        id: GroupId,
135        input: Entity<TextInput>,
136    },
137    RenameContact {
138        id: ContactId,
139        input: Entity<TextInput>,
140    },
141    RenameChart {
142        id: ChartId,
143        input: Entity<TextInput>,
144    },
145    CreateGroup {
146        parent: Option<GroupId>,
147        input: Entity<TextInput>,
148    },
149    CreateContact {
150        group: Option<GroupId>,
151        input: Entity<TextInput>,
152    },
153    CreateChart {
154        contact: ContactId,
155        form: ChartForm,
156        error: Option<SharedString>,
157    },
158    /// Editar una carta existente — reusa `ChartForm` pre-cargada.
159    /// El submit llama `store.update_chart(id, ...)` preservando
160    /// `chart.contact_id`, `related_chart_id`, `module_state` y el
161    /// historial.
162    EditChart {
163        id: ChartId,
164        form: ChartForm,
165        error: Option<SharedString>,
166    },
167    /// Editar los datos (fecha/hora/lugar) de una carta libre.
168    /// Reusa el mismo `ChartForm` que `EditChart`. El submit emite
169    /// `FreeChartEditConfirmed` que el shell aplica al mapa
170    /// `free_charts` y re-renderea el wheel.
171    EditFreeChart {
172        source_id: FreeChartId,
173        form: ChartForm,
174        error: Option<SharedString>,
175    },
176    /// Guardar una carta libre como `Chart` persistido. El usuario
177    /// elige nombre + contacto destino (existente de la lista o
178    /// uno nuevo creado al vuelo). El shell escucha
179    /// `TreeEvent::FreeChartSaveConfirmed` y materializa.
180    SaveFreeChart {
181        source_id: FreeChartId,
182        name: Entity<TextInput>,
183        /// Nombre del contacto NUEVO (solo aplica si
184        /// `selected_contact == None`). Vacío para reusar uno
185        /// existente.
186        new_contact_name: Entity<TextInput>,
187        /// `Some(id)` = usar contacto existente; `None` = crear
188        /// contacto nuevo con `new_contact_name`.
189        selected_contact: Option<ContactId>,
190        /// Snapshot de contactos visibles al usuario en el momento
191        /// de abrir el modal. Incluye contact id + label (nombre).
192        all_contacts: Vec<(ContactId, String)>,
193        error: Option<SharedString>,
194    },
195}
196
197struct ChartForm {
198    name: Entity<TextInput>,
199    place: Entity<TextInput>,
200    year: Entity<TextInput>,
201    month: Entity<TextInput>,
202    day: Entity<TextInput>,
203    hour: Entity<TextInput>,
204    minute: Entity<TextInput>,
205    tz_offset_min: Entity<TextInput>,
206    lat: Entity<TextInput>,
207    lon: Entity<TextInput>,
208    alt: Entity<TextInput>,
209}
210
211// =====================================================================
212// Widget
213// =====================================================================
214
215pub struct TahuantinsuyuTree {
216    store: Store,
217    inner: Entity<TreeView>,
218    expanded: HashSet<String>,
219    menu: Option<MenuState>,
220    modal: Option<Modal>,
221    /// `true` cuando el dropdown de "ciudad rápida" en el ChartForm
222    /// está abierto. Vive en el tree (no en ChartForm) porque las
223    /// closures de los click handlers necesitan mutarlo via `cx.listener`.
224    city_picker_open: bool,
225    /// Atlas de ciudades para el dropdown del form. Se inicializa con
226    /// `default_city_presets()` (90 ciudades hardcoded). El host puede
227    /// llamar [`Self::set_city_atlas`] para reemplazar por uno custom
228    /// cargado desde disco (TSV).
229    city_atlas: Vec<CityPreset>,
230    /// Filtro de búsqueda activo. Vacío = sin filtro (jerarquía
231    /// completa). Cuando hay texto, refresh() solo incluye rows cuyo
232    /// nombre (group / contact / chart label) contenga el substring
233    /// case-insensitive, y auto-expande los ancestros para que el
234    /// match sea visible.
235    search_filter: String,
236    /// TextInput para el filtro — vive arriba del tree.
237    search_input: Entity<TextInput>,
238    /// Lista de cartas libres a mostrar bajo "Cartas libres". El shell
239    /// la actualiza vía [`Self::set_free_charts`] cada vez que crea,
240    /// renombra o borra una. El orden de inserción es el de display
241    /// (los nuevos van al final; "Cielo ahora" siempre va primero por
242    /// convención del shell).
243    free_charts: Vec<FreeChartEntry>,
244}
245
246/// Entrada de la sección "Cartas libres" — id + label visible +
247/// birth_data clonado (para pre-poblar el modal "Editar datos…").
248/// El Chart completo vive en el shell; el tree mantiene esta
249/// proyección compacta para no depender del shell en cada operación.
250#[derive(Clone, Debug)]
251pub struct FreeChartEntry {
252    pub id: FreeChartId,
253    pub label: String,
254    pub birth_data: StoredBirthData,
255}
256
257/// Preset de ciudad con datos canónicos para autocompletar lat/lon/tz
258/// al elegirlo en el form. TZ es la zona estándar **sin DST** — el
259/// usuario afina si necesita. `name` es `String` (no &'static) para
260/// permitir cargar atlas custom desde disco vía
261/// [`TahuantinsuyuTree::set_city_atlas`].
262#[derive(Clone, Debug)]
263pub struct CityPreset {
264    pub name: String,
265    pub lat: f64,
266    pub lon: f64,
267    pub tz_offset_minutes: i32,
268}
269
270/// Atlas hardcoded — 90 ciudades canónicas que cubren la mayoría de
271/// casos de uso. El usuario puede sobrescribirlas pasando un atlas
272/// custom vía [`TahuantinsuyuTree::set_city_atlas`] (típicamente
273/// cargado desde `$XDG_DATA_HOME/tahuantinsuyu/atlas.tsv`).
274pub fn default_city_presets() -> Vec<CityPreset> {
275    vec![
276        // Latinoamérica
277        CityPreset { name: "Buenos Aires, AR".into(), lat: -34.6037, lon: -58.3816, tz_offset_minutes: -180 },
278    CityPreset { name: "Córdoba, AR".into(),      lat: -31.4201, lon: -64.1888, tz_offset_minutes: -180 },
279    CityPreset { name: "Rosario, AR".into(),      lat: -32.9587, lon: -60.6930, tz_offset_minutes: -180 },
280    CityPreset { name: "Mendoza, AR".into(),      lat: -32.8908, lon: -68.8272, tz_offset_minutes: -180 },
281    CityPreset { name: "Caracas, VE".into(),      lat: 10.4806,  lon: -66.9036, tz_offset_minutes: -240 },
282    CityPreset { name: "Maracaibo, VE".into(),    lat: 10.6427,  lon: -71.6125, tz_offset_minutes: -240 },
283    CityPreset { name: "Valencia, VE".into(),     lat: 10.1620,  lon: -68.0078, tz_offset_minutes: -240 },
284    CityPreset { name: "Bogotá, CO".into(),       lat: 4.7110,   lon: -74.0721, tz_offset_minutes: -300 },
285    CityPreset { name: "Medellín, CO".into(),     lat: 6.2442,   lon: -75.5812, tz_offset_minutes: -300 },
286    CityPreset { name: "Cali, CO".into(),         lat: 3.4516,   lon: -76.5320, tz_offset_minutes: -300 },
287    CityPreset { name: "Lima, PE".into(),         lat: -12.0464, lon: -77.0428, tz_offset_minutes: -300 },
288    CityPreset { name: "Cusco, PE".into(),        lat: -13.5319, lon: -71.9675, tz_offset_minutes: -300 },
289    CityPreset { name: "Santiago, CL".into(),     lat: -33.4489, lon: -70.6693, tz_offset_minutes: -240 },
290    CityPreset { name: "Valparaíso, CL".into(),   lat: -33.0472, lon: -71.6127, tz_offset_minutes: -240 },
291    CityPreset { name: "Quito, EC".into(),        lat: -0.1807,  lon: -78.4678, tz_offset_minutes: -300 },
292    CityPreset { name: "Guayaquil, EC".into(),    lat: -2.1709,  lon: -79.9224, tz_offset_minutes: -300 },
293    CityPreset { name: "Montevideo, UY".into(),   lat: -34.9011, lon: -56.1645, tz_offset_minutes: -180 },
294    CityPreset { name: "Asunción, PY".into(),     lat: -25.2637, lon: -57.5759, tz_offset_minutes: -240 },
295    CityPreset { name: "La Paz, BO".into(),       lat: -16.4897, lon: -68.1193, tz_offset_minutes: -240 },
296    CityPreset { name: "Ciudad de México".into(), lat: 19.4326,  lon: -99.1332, tz_offset_minutes: -360 },
297    CityPreset { name: "Guadalajara, MX".into(),  lat: 20.6597,  lon: -103.3496, tz_offset_minutes: -360 },
298    CityPreset { name: "Monterrey, MX".into(),    lat: 25.6866,  lon: -100.3161, tz_offset_minutes: -360 },
299    CityPreset { name: "Habana, CU".into(),       lat: 23.1136,  lon: -82.3666, tz_offset_minutes: -300 },
300    CityPreset { name: "San Juan, PR".into(),     lat: 18.4655,  lon: -66.1057, tz_offset_minutes: -240 },
301    CityPreset { name: "San José, CR".into(),     lat: 9.9281,   lon: -84.0907, tz_offset_minutes: -360 },
302    CityPreset { name: "Panamá, PA".into(),       lat: 8.9824,   lon: -79.5199, tz_offset_minutes: -300 },
303    CityPreset { name: "San Salvador, SV".into(), lat: 13.6929,  lon: -89.2182, tz_offset_minutes: -360 },
304    CityPreset { name: "Guatemala, GT".into(),    lat: 14.6349,  lon: -90.5069, tz_offset_minutes: -360 },
305    CityPreset { name: "Tegucigalpa, HN".into(),  lat: 14.0723,  lon: -87.1921, tz_offset_minutes: -360 },
306    CityPreset { name: "Managua, NI".into(),      lat: 12.1149,  lon: -86.2362, tz_offset_minutes: -360 },
307    CityPreset { name: "Santo Domingo, DO".into(), lat: 18.4861, lon: -69.9312, tz_offset_minutes: -240 },
308    CityPreset { name: "São Paulo, BR".into(),    lat: -23.5505, lon: -46.6333, tz_offset_minutes: -180 },
309    CityPreset { name: "Rio de Janeiro, BR".into(), lat: -22.9068, lon: -43.1729, tz_offset_minutes: -180 },
310    CityPreset { name: "Brasília, BR".into(),     lat: -15.8267, lon: -47.9218, tz_offset_minutes: -180 },
311    CityPreset { name: "Salvador, BR".into(),     lat: -12.9777, lon: -38.5016, tz_offset_minutes: -180 },
312    // España
313    CityPreset { name: "Madrid, ES".into(),       lat: 40.4168,  lon: -3.7038,  tz_offset_minutes: 60 },
314    CityPreset { name: "Barcelona, ES".into(),    lat: 41.3851,  lon: 2.1734,   tz_offset_minutes: 60 },
315    CityPreset { name: "Sevilla, ES".into(),      lat: 37.3891,  lon: -5.9845,  tz_offset_minutes: 60 },
316    CityPreset { name: "Valencia, ES".into(),     lat: 39.4699,  lon: -0.3763,  tz_offset_minutes: 60 },
317    CityPreset { name: "Bilbao, ES".into(),       lat: 43.2630,  lon: -2.9350,  tz_offset_minutes: 60 },
318    // Europa
319    CityPreset { name: "London, UK".into(),       lat: 51.5074,  lon: -0.1278,  tz_offset_minutes: 0 },
320    CityPreset { name: "Paris, FR".into(),        lat: 48.8566,  lon: 2.3522,   tz_offset_minutes: 60 },
321    CityPreset { name: "Berlin, DE".into(),       lat: 52.5200,  lon: 13.4050,  tz_offset_minutes: 60 },
322    CityPreset { name: "München, DE".into(),      lat: 48.1351,  lon: 11.5820,  tz_offset_minutes: 60 },
323    CityPreset { name: "Roma, IT".into(),         lat: 41.9028,  lon: 12.4964,  tz_offset_minutes: 60 },
324    CityPreset { name: "Milano, IT".into(),       lat: 45.4642,  lon: 9.1900,   tz_offset_minutes: 60 },
325    CityPreset { name: "Amsterdam, NL".into(),    lat: 52.3676,  lon: 4.9041,   tz_offset_minutes: 60 },
326    CityPreset { name: "Bruxelles, BE".into(),    lat: 50.8503,  lon: 4.3517,   tz_offset_minutes: 60 },
327    CityPreset { name: "Wien, AT".into(),         lat: 48.2082,  lon: 16.3738,  tz_offset_minutes: 60 },
328    CityPreset { name: "Zürich, CH".into(),       lat: 47.3769,  lon: 8.5417,   tz_offset_minutes: 60 },
329    CityPreset { name: "Lisboa, PT".into(),       lat: 38.7223,  lon: -9.1393,  tz_offset_minutes: 0 },
330    CityPreset { name: "Dublin, IE".into(),       lat: 53.3498,  lon: -6.2603,  tz_offset_minutes: 0 },
331    CityPreset { name: "Stockholm, SE".into(),    lat: 59.3293,  lon: 18.0686,  tz_offset_minutes: 60 },
332    CityPreset { name: "Oslo, NO".into(),         lat: 59.9139,  lon: 10.7522,  tz_offset_minutes: 60 },
333    CityPreset { name: "København, DK".into(),    lat: 55.6761,  lon: 12.5683,  tz_offset_minutes: 60 },
334    CityPreset { name: "Helsinki, FI".into(),     lat: 60.1699,  lon: 24.9384,  tz_offset_minutes: 120 },
335    CityPreset { name: "Warszawa, PL".into(),     lat: 52.2297,  lon: 21.0122,  tz_offset_minutes: 60 },
336    CityPreset { name: "Praha, CZ".into(),        lat: 50.0755,  lon: 14.4378,  tz_offset_minutes: 60 },
337    CityPreset { name: "Budapest, HU".into(),     lat: 47.4979,  lon: 19.0402,  tz_offset_minutes: 60 },
338    CityPreset { name: "Athina, GR".into(),       lat: 37.9838,  lon: 23.7275,  tz_offset_minutes: 120 },
339    CityPreset { name: "İstanbul, TR".into(),     lat: 41.0082,  lon: 28.9784,  tz_offset_minutes: 180 },
340    CityPreset { name: "Moskva, RU".into(),       lat: 55.7558,  lon: 37.6173,  tz_offset_minutes: 180 },
341    // USA + Canada
342    CityPreset { name: "New York, US".into(),     lat: 40.7128,  lon: -74.0060, tz_offset_minutes: -300 },
343    CityPreset { name: "Los Angeles, US".into(),  lat: 34.0522,  lon: -118.2437, tz_offset_minutes: -480 },
344    CityPreset { name: "Chicago, US".into(),      lat: 41.8781,  lon: -87.6298, tz_offset_minutes: -360 },
345    CityPreset { name: "Miami, US".into(),        lat: 25.7617,  lon: -80.1918, tz_offset_minutes: -300 },
346    CityPreset { name: "Houston, US".into(),      lat: 29.7604,  lon: -95.3698, tz_offset_minutes: -360 },
347    CityPreset { name: "San Francisco, US".into(), lat: 37.7749, lon: -122.4194, tz_offset_minutes: -480 },
348    CityPreset { name: "Seattle, US".into(),      lat: 47.6062,  lon: -122.3321, tz_offset_minutes: -480 },
349    CityPreset { name: "Boston, US".into(),       lat: 42.3601,  lon: -71.0589, tz_offset_minutes: -300 },
350    CityPreset { name: "Washington DC".into(),    lat: 38.9072,  lon: -77.0369, tz_offset_minutes: -300 },
351    CityPreset { name: "Toronto, CA".into(),      lat: 43.6532,  lon: -79.3832, tz_offset_minutes: -300 },
352    CityPreset { name: "Montreal, CA".into(),     lat: 45.5017,  lon: -73.5673, tz_offset_minutes: -300 },
353    CityPreset { name: "Vancouver, CA".into(),    lat: 49.2827,  lon: -123.1207, tz_offset_minutes: -480 },
354    // Asia
355    CityPreset { name: "Tokyo, JP".into(),        lat: 35.6762,  lon: 139.6503, tz_offset_minutes: 540 },
356    CityPreset { name: "Beijing, CN".into(),      lat: 39.9042,  lon: 116.4074, tz_offset_minutes: 480 },
357    CityPreset { name: "Shanghai, CN".into(),     lat: 31.2304,  lon: 121.4737, tz_offset_minutes: 480 },
358    CityPreset { name: "Hong Kong".into(),        lat: 22.3193,  lon: 114.1694, tz_offset_minutes: 480 },
359    CityPreset { name: "Singapore".into(),        lat: 1.3521,   lon: 103.8198, tz_offset_minutes: 480 },
360    CityPreset { name: "Seoul, KR".into(),        lat: 37.5665,  lon: 126.9780, tz_offset_minutes: 540 },
361    CityPreset { name: "Bangkok, TH".into(),      lat: 13.7563,  lon: 100.5018, tz_offset_minutes: 420 },
362    CityPreset { name: "Jakarta, ID".into(),      lat: -6.2088,  lon: 106.8456, tz_offset_minutes: 420 },
363    CityPreset { name: "Manila, PH".into(),       lat: 14.5995,  lon: 120.9842, tz_offset_minutes: 480 },
364    CityPreset { name: "Mumbai, IN".into(),       lat: 19.0760,  lon: 72.8777,  tz_offset_minutes: 330 },
365    CityPreset { name: "Delhi, IN".into(),        lat: 28.7041,  lon: 77.1025,  tz_offset_minutes: 330 },
366    CityPreset { name: "Bangalore, IN".into(),    lat: 12.9716,  lon: 77.5946,  tz_offset_minutes: 330 },
367    CityPreset { name: "Karachi, PK".into(),      lat: 24.8607,  lon: 67.0011,  tz_offset_minutes: 300 },
368    CityPreset { name: "Tehran, IR".into(),       lat: 35.6892,  lon: 51.3890,  tz_offset_minutes: 210 },
369    CityPreset { name: "Dubai, AE".into(),        lat: 25.2048,  lon: 55.2708,  tz_offset_minutes: 240 },
370    CityPreset { name: "Tel Aviv, IL".into(),     lat: 32.0853,  lon: 34.7818,  tz_offset_minutes: 120 },
371    // África
372    CityPreset { name: "Cairo, EG".into(),        lat: 30.0444,  lon: 31.2357,  tz_offset_minutes: 120 },
373    CityPreset { name: "Lagos, NG".into(),        lat: 6.5244,   lon: 3.3792,   tz_offset_minutes: 60 },
374    CityPreset { name: "Nairobi, KE".into(),      lat: -1.2921,  lon: 36.8219,  tz_offset_minutes: 180 },
375    CityPreset { name: "Johannesburg, ZA".into(), lat: -26.2041, lon: 28.0473,  tz_offset_minutes: 120 },
376    CityPreset { name: "Cape Town, ZA".into(),    lat: -33.9249, lon: 18.4241,  tz_offset_minutes: 120 },
377    CityPreset { name: "Casablanca, MA".into(),   lat: 33.5731,  lon: -7.5898,  tz_offset_minutes: 60 },
378    // Oceanía
379    CityPreset { name: "Sydney, AU".into(),       lat: -33.8688, lon: 151.2093, tz_offset_minutes: 600 },
380    CityPreset { name: "Melbourne, AU".into(),    lat: -37.8136, lon: 144.9631, tz_offset_minutes: 600 },
381    CityPreset { name: "Auckland, NZ".into(),     lat: -36.8485, lon: 174.7633, tz_offset_minutes: 720 },
382    ]
383}
384
385/// Parsea un atlas TSV (tab-separated values): cada línea no vacía y
386/// no comentario es `name<TAB>lat<TAB>lon<TAB>tz_offset_minutes`.
387/// Devuelve solo las filas válidas — las inválidas se descartan en
388/// silencio (no abortamos la carga por una línea mal formada).
389pub fn parse_city_atlas_tsv(content: &str) -> Vec<CityPreset> {
390    let mut out = Vec::new();
391    for line in content.lines() {
392        let line = line.trim();
393        if line.is_empty() || line.starts_with('#') {
394            continue;
395        }
396        let parts: Vec<&str> = line.split('\t').collect();
397        if parts.len() < 4 {
398            continue;
399        }
400        let name = parts[0].trim().to_string();
401        let lat = parts[1].trim().parse::<f64>();
402        let lon = parts[2].trim().parse::<f64>();
403        let tz = parts[3].trim().parse::<i32>();
404        if let (Ok(lat), Ok(lon), Ok(tz)) = (lat, lon, tz) {
405            if !name.is_empty() {
406                out.push(CityPreset {
407                    name,
408                    lat,
409                    lon,
410                    tz_offset_minutes: tz,
411                });
412            }
413        }
414    }
415    out
416}
417
418impl EventEmitter<TreeEvent> for TahuantinsuyuTree {}
419
420impl TahuantinsuyuTree {
421    pub fn new(store: Store, cx: &mut Context<'_, Self>) -> Self {
422        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
423
424        let inner = cx.new(|cx| TreeView::new("tahuantinsuyu-tree", cx));
425        cx.subscribe(&inner, |this: &mut Self, _, ev, cx| {
426            this.on_inner(ev, cx);
427        })
428        .detach();
429
430        let search_input = cx.new(|cx| {
431            TextInput::new(String::new(), cx)
432                .with_placeholder(SharedString::from("Buscar nombre…"))
433        });
434        cx.subscribe(
435            &search_input,
436            |this: &mut Self, _, ev: &TextInputEvent, cx| match ev {
437                TextInputEvent::Confirmed(value) => {
438                    this.set_search_filter(value.clone(), cx);
439                }
440                TextInputEvent::Cancelled => {
441                    this.set_search_filter(String::new(), cx);
442                }
443            },
444        )
445        .detach();
446
447        let mut me = Self {
448            store,
449            inner,
450            expanded: HashSet::new(),
451            menu: None,
452            modal: None,
453            city_picker_open: false,
454            city_atlas: default_city_presets(),
455            search_filter: String::new(),
456            search_input,
457            free_charts: Vec::new(),
458        };
459        // "Cartas libres" expandida por default — el usuario espera ver
460        // "Cielo ahora" sin tener que hacer click en el chevron.
461        me.expanded.insert(ROW_FREE_ROOT.to_string());
462        me.refresh(cx);
463        me
464    }
465
466    /// Reemplaza la lista de cartas libres del tree. El shell la llama
467    /// cada vez que crea, renombra o borra una carta libre.
468    pub fn set_free_charts(&mut self, entries: Vec<FreeChartEntry>, cx: &mut Context<'_, Self>) {
469        self.free_charts = entries;
470        self.refresh(cx);
471    }
472
473    /// Reemplaza el atlas de ciudades del dropdown. La app llama esto
474    /// al boot si encuentra un archivo TSV custom en disco.
475    pub fn set_city_atlas(&mut self, atlas: Vec<CityPreset>, cx: &mut Context<'_, Self>) {
476        if !atlas.is_empty() {
477            self.city_atlas = atlas;
478            cx.notify();
479        }
480    }
481
482    pub fn refresh(&mut self, cx: &mut Context<'_, Self>) {
483        let mut rows = Vec::new();
484
485        // 1) General — branch fijo al top. Contiene los contactos sin
486        //    grupo padre (parent=None). Siempre presente.
487        let general_expanded = self.expanded.contains(ROW_GENERAL);
488        rows.push(TreeRow {
489            id: RowId::new(ROW_GENERAL.to_string()),
490            label: "General".to_string(),
491            depth: 0,
492            kind: RowKind::Branch,
493            expanded: general_expanded,
494            icon: Some("◇".into()),
495        });
496        if general_expanded {
497            self.append_contacts(None, 1, &mut rows);
498        }
499
500        // 2) Groups top-level con sus contenidos.
501        self.append_groups(None, 0, &mut rows);
502
503        // 3) Cartas libres — branch fijo al FONDO. Contiene "Cielo
504        //    ahora" + cualquier carta libre creada por el usuario.
505        //    Permanece visible aún sin entries (el usuario puede
506        //    crear nuevas desde su menu contextual).
507        let free_expanded = self.expanded.contains(ROW_FREE_ROOT);
508        rows.push(TreeRow {
509            id: RowId::new(ROW_FREE_ROOT.to_string()),
510            label: "Cartas libres".to_string(),
511            depth: 0,
512            kind: RowKind::Branch,
513            expanded: free_expanded,
514            icon: Some("🜨".into()),
515        });
516        if free_expanded {
517            for e in &self.free_charts {
518                let id_str = format!("{}{}", PREFIX_FREE, e.id.as_str());
519                let icon = if e.id.is_sky_now() { "⏱" } else { "✦" };
520                rows.push(TreeRow {
521                    id: RowId::new(id_str),
522                    label: e.label.clone(),
523                    depth: 1,
524                    kind: RowKind::Leaf,
525                    expanded: false,
526                    icon: Some(icon.into()),
527                });
528            }
529        }
530
531        self.inner.update(cx, |t, cx| t.set_rows(rows, cx));
532    }
533
534    /// Actualiza el filtro de búsqueda — texto vacío = sin filtro.
535    /// Cuando hay filtro, expande automáticamente los ancestros que
536    /// contienen matches para que el usuario vea los resultados sin
537    /// tener que clickear chevrons.
538    fn set_search_filter(&mut self, filter: String, cx: &mut Context<'_, Self>) {
539        self.search_filter = filter.trim().to_lowercase();
540        if !self.search_filter.is_empty() {
541            self.auto_expand_matches();
542        }
543        self.refresh(cx);
544    }
545
546    /// Pre-expande todos los groups + contacts que contienen al menos
547    /// un descendiente cuyo nombre matchee el filtro. Hace una pasada
548    /// recursiva agregando ids al `expanded` set.
549    fn auto_expand_matches(&mut self) {
550        fn walk_group(this: &mut TahuantinsuyuTree, group_id: GroupId) -> bool {
551            let mut any_match = false;
552            // Sub-groups recursivamente.
553            if let Ok(children) = this.store.list_groups(Some(group_id)) {
554                for g in children {
555                    let name_match = g.name.to_lowercase().contains(&this.search_filter);
556                    let child_match = walk_group(this, g.id);
557                    if name_match || child_match {
558                        this.expanded.insert(format!("{}{}", PREFIX_GROUP, g.id));
559                        any_match = true;
560                    }
561                }
562            }
563            // Contacts directos.
564            if let Ok(contacts) = this.store.list_contacts(Some(group_id)) {
565                for c in contacts {
566                    let name_match = c.name.to_lowercase().contains(&this.search_filter);
567                    let chart_match = contact_has_matching_chart(this, c.id);
568                    if name_match || chart_match {
569                        this.expanded.insert(format!("{}{}", PREFIX_CONTACT, c.id));
570                        any_match = true;
571                    }
572                }
573            }
574            any_match
575        }
576        fn contact_has_matching_chart(this: &TahuantinsuyuTree, contact_id: ContactId) -> bool {
577            this.store
578                .list_charts(contact_id)
579                .map(|charts| {
580                    charts
581                        .iter()
582                        .any(|h| h.label.to_lowercase().contains(&this.search_filter))
583                })
584                .unwrap_or(false)
585        }
586
587        // Top-level groups + contacts directos en raíz.
588        if let Ok(groups) = self.store.list_groups(None) {
589            for g in groups {
590                let name_match = g.name.to_lowercase().contains(&self.search_filter);
591                let child_match = walk_group(self, g.id);
592                if name_match || child_match {
593                    self.expanded.insert(format!("{}{}", PREFIX_GROUP, g.id));
594                }
595            }
596        }
597        if let Ok(contacts) = self.store.list_contacts(None) {
598            for c in contacts {
599                let name_match = c.name.to_lowercase().contains(&self.search_filter);
600                let chart_match = contact_has_matching_chart(self, c.id);
601                if name_match || chart_match {
602                    self.expanded.insert(format!("{}{}", PREFIX_CONTACT, c.id));
603                }
604            }
605        }
606    }
607
608    /// `true` si la jerarquía bajo `group_id` (recursivo) tiene
609    /// algún descendiente que matchee el filtro de búsqueda.
610    fn group_has_match(&self, group_id: GroupId) -> bool {
611        if let Ok(sub) = self.store.list_groups(Some(group_id)) {
612            for g in &sub {
613                if g.name.to_lowercase().contains(&self.search_filter) {
614                    return true;
615                }
616                if self.group_has_match(g.id) {
617                    return true;
618                }
619            }
620        }
621        if let Ok(contacts) = self.store.list_contacts(Some(group_id)) {
622            for c in &contacts {
623                if c.name.to_lowercase().contains(&self.search_filter) {
624                    return true;
625                }
626                if let Ok(charts) = self.store.list_charts(c.id) {
627                    if charts
628                        .iter()
629                        .any(|h| h.label.to_lowercase().contains(&self.search_filter))
630                    {
631                        return true;
632                    }
633                }
634            }
635        }
636        false
637    }
638
639    /// `true` si el contacto tiene una carta cuyo label matchee.
640    fn contact_has_match(&self, contact_id: ContactId) -> bool {
641        if let Ok(charts) = self.store.list_charts(contact_id) {
642            return charts
643                .iter()
644                .any(|h| h.label.to_lowercase().contains(&self.search_filter));
645        }
646        false
647    }
648
649    fn append_groups(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
650        let groups = match self.store.list_groups(parent) {
651            Ok(v) => v,
652            Err(_) => return,
653        };
654        for g in groups {
655            // Filtro: incluir si el group matchea por nombre o tiene
656            // algún descendiente matching.
657            if !self.search_filter.is_empty() {
658                let name_match = g.name.to_lowercase().contains(&self.search_filter);
659                if !name_match && !self.group_has_match(g.id) {
660                    continue;
661                }
662            }
663            let id_str = format!("{}{}", PREFIX_GROUP, g.id);
664            let expanded = self.expanded.contains(&id_str);
665            out.push(TreeRow {
666                id: RowId::new(id_str.clone()),
667                label: g.name.clone(),
668                depth,
669                kind: RowKind::Branch,
670                expanded,
671                icon: Some("📁".into()),
672            });
673            if expanded {
674                self.append_groups(Some(g.id), depth + 1, out);
675                self.append_contacts(Some(g.id), depth + 1, out);
676            }
677        }
678    }
679
680    fn append_contacts(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
681        let contacts = match self.store.list_contacts(parent) {
682            Ok(v) => v,
683            Err(_) => return,
684        };
685        for c in contacts {
686            if !self.search_filter.is_empty() {
687                let name_match = c.name.to_lowercase().contains(&self.search_filter);
688                if !name_match && !self.contact_has_match(c.id) {
689                    continue;
690                }
691            }
692            let id_str = format!("{}{}", PREFIX_CONTACT, c.id);
693            let expanded = self.expanded.contains(&id_str);
694            out.push(TreeRow {
695                id: RowId::new(id_str.clone()),
696                label: c.name.clone(),
697                depth,
698                kind: RowKind::Branch,
699                expanded,
700                icon: Some("🜨".into()),
701            });
702            if expanded {
703                self.append_charts(c.id, depth + 1, out);
704            }
705        }
706    }
707
708    fn append_charts(&self, contact: ContactId, depth: u32, out: &mut Vec<TreeRow>) {
709        let charts = match self.store.list_charts(contact) {
710            Ok(v) => v,
711            Err(_) => return,
712        };
713        for h in charts {
714            if !self.search_filter.is_empty()
715                && !h.label.to_lowercase().contains(&self.search_filter)
716            {
717                continue;
718            }
719            let id_str = format!("{}{}", PREFIX_CHART, h.id);
720            out.push(TreeRow {
721                id: RowId::new(id_str),
722                label: h.label.clone(),
723                depth,
724                kind: RowKind::Leaf,
725                expanded: false,
726                icon: Some("✦".into()),
727            });
728        }
729    }
730
731    fn on_inner(&mut self, ev: &InnerTreeEvent, cx: &mut Context<'_, Self>) {
732        match ev {
733            InnerTreeEvent::ChevronToggled(id) => {
734                let s = id.as_str().to_string();
735                if !self.expanded.remove(&s) {
736                    self.expanded.insert(s);
737                }
738                self.refresh(cx);
739            }
740            InnerTreeEvent::RowClicked(id) => {
741                if self.menu.is_some() {
742                    self.menu = None;
743                    cx.notify();
744                }
745                if let Some(sel) = parse_row(id) {
746                    cx.emit(TreeEvent::Selected(sel));
747                }
748            }
749            InnerTreeEvent::RowDoubleClicked(id) => {
750                if let Some(sel) = parse_row(id) {
751                    cx.emit(TreeEvent::Opened(sel));
752                }
753            }
754            InnerTreeEvent::ContextMenuRequested { id, position } => {
755                let target = match id.as_ref().and_then(parse_row) {
756                    Some(sel) => MenuTarget::from_selection(&sel),
757                    None => MenuTarget::Root,
758                };
759                self.menu = Some(MenuState {
760                    target,
761                    position: *position,
762                });
763                cx.notify();
764            }
765            InnerTreeEvent::ActiveChanged(_) => {}
766        }
767    }
768
769    // -----------------------------------------------------------------
770    // Acciones del menú
771    // -----------------------------------------------------------------
772
773    fn close_menu(&mut self, cx: &mut Context<'_, Self>) {
774        if self.menu.take().is_some() {
775            cx.notify();
776        }
777    }
778
779    fn close_modal(&mut self, cx: &mut Context<'_, Self>) {
780        if self.modal.take().is_some() {
781            self.city_picker_open = false;
782            cx.notify();
783        }
784    }
785
786    fn toggle_city_picker(&mut self, cx: &mut Context<'_, Self>) {
787        self.city_picker_open = !self.city_picker_open;
788        cx.notify();
789    }
790
791    /// Aplica un city preset al ChartForm activo (CreateChart o
792    /// EditChart). Setea place, lat, lon, tz_offset_min vía
793    /// `TextInput::set_text` y cierra el picker.
794    fn apply_city_preset(&mut self, preset: &CityPreset, cx: &mut Context<'_, Self>) {
795        let form = match self.modal.as_mut() {
796            Some(Modal::CreateChart { form, .. }) => form,
797            Some(Modal::EditChart { form, .. }) => form,
798            Some(Modal::EditFreeChart { form, .. }) => form,
799            _ => {
800                self.city_picker_open = false;
801                cx.notify();
802                return;
803            }
804        };
805        let place = form.place.clone();
806        let lat = form.lat.clone();
807        let lon = form.lon.clone();
808        let tz = form.tz_offset_min.clone();
809        let name = preset.name.clone();
810        let lat_val = preset.lat;
811        let lon_val = preset.lon;
812        let tz_val = preset.tz_offset_minutes;
813        place.update(cx, |i, cx| i.set_text(name, cx));
814        lat.update(cx, |i, cx| i.set_text(format!("{}", lat_val), cx));
815        lon.update(cx, |i, cx| i.set_text(format!("{}", lon_val), cx));
816        tz.update(cx, |i, cx| i.set_text(tz_val.to_string(), cx));
817        self.city_picker_open = false;
818        cx.notify();
819    }
820
821    fn open_create_group(
822        &mut self,
823        parent: Option<GroupId>,
824        window: &mut Window,
825        cx: &mut Context<'_, Self>,
826    ) {
827        let input = self.make_input("Nombre del grupo", "", window, cx);
828        self.modal = Some(Modal::CreateGroup { parent, input });
829        self.close_menu(cx);
830    }
831
832    fn open_create_contact(
833        &mut self,
834        group: Option<GroupId>,
835        window: &mut Window,
836        cx: &mut Context<'_, Self>,
837    ) {
838        let input = self.make_input("Nombre del contacto", "", window, cx);
839        self.modal = Some(Modal::CreateContact { group, input });
840        self.close_menu(cx);
841    }
842
843    fn open_edit_chart(
844        &mut self,
845        id: ChartId,
846        window: &mut Window,
847        cx: &mut Context<'_, Self>,
848    ) {
849        // Cargar la carta existente; si no se puede, fallamos en silencio.
850        let chart = match self.store.get_chart(id) {
851            Ok(c) => c,
852            Err(e) => {
853                eprintln!("[tree] open_edit_chart {}: {}", id, e);
854                return;
855            }
856        };
857        let bd = &chart.birth_data;
858        let form = ChartForm {
859            name: self.make_input("Etiqueta de la carta", &chart.label, window, cx),
860            place: self.make_input(
861                "Lugar (ciudad, país)",
862                bd.birthplace_label.as_deref().unwrap_or(""),
863                window,
864                cx,
865            ),
866            year: self.make_input("Año", &bd.year.to_string(), window, cx),
867            month: self.make_input("Mes", &bd.month.to_string(), window, cx),
868            day: self.make_input("Día", &bd.day.to_string(), window, cx),
869            hour: self.make_input("Hora (0-23)", &bd.hour.to_string(), window, cx),
870            minute: self.make_input("Minuto", &bd.minute.to_string(), window, cx),
871            tz_offset_min: self.make_input(
872                "TZ offset (min)",
873                &bd.tz_offset_minutes.to_string(),
874                window,
875                cx,
876            ),
877            lat: self.make_input("Latitud (°)", &format!("{}", bd.latitude_deg), window, cx),
878            lon: self.make_input(
879                "Longitud (°)",
880                &format!("{}", bd.longitude_deg),
881                window,
882                cx,
883            ),
884            alt: self.make_input("Altitud (m)", &format!("{}", bd.altitude_m), window, cx),
885        };
886        form.name.update(cx, |i, _| i.request_focus(window));
887        self.modal = Some(Modal::EditChart {
888            id,
889            form,
890            error: None,
891        });
892        self.close_menu(cx);
893    }
894
895    /// Abre el modal "Guardar como" para una carta libre. Pre-puebla
896    /// el `name` con el label actual de la entry. La lista de
897    /// contactos es un snapshot recursivo de toda la jerarquía
898    /// (no solo el nivel raíz). El usuario elige uno existente o
899    /// deja en "Nuevo contacto" para que se cree uno al confirmar.
900    /// Abre el modal "Editar datos" para una carta libre. Pre-puebla
901    /// `ChartForm` con `birth_data` actual de la entry. Submit emite
902    /// `FreeChartEditConfirmed` que el shell aplica al mapa de
903    /// `free_charts` y re-renderea.
904    fn open_edit_free_chart_modal(
905        &mut self,
906        source_id: FreeChartId,
907        window: &mut Window,
908        cx: &mut Context<'_, Self>,
909    ) {
910        let entry = match self.free_charts.iter().find(|e| e.id == source_id) {
911            Some(e) => e.clone(),
912            None => return,
913        };
914        let bd = &entry.birth_data;
915        let form = ChartForm {
916            name: self.make_input("Etiqueta de la carta", &entry.label, window, cx),
917            place: self.make_input(
918                "Lugar (ciudad, país)",
919                bd.birthplace_label.as_deref().unwrap_or(""),
920                window,
921                cx,
922            ),
923            year: self.make_input("Año", &bd.year.to_string(), window, cx),
924            month: self.make_input("Mes", &bd.month.to_string(), window, cx),
925            day: self.make_input("Día", &bd.day.to_string(), window, cx),
926            hour: self.make_input("Hora (0-23)", &bd.hour.to_string(), window, cx),
927            minute: self.make_input("Minuto", &bd.minute.to_string(), window, cx),
928            tz_offset_min: self.make_input(
929                "TZ offset (min)",
930                &bd.tz_offset_minutes.to_string(),
931                window,
932                cx,
933            ),
934            lat: self.make_input("Latitud (°)", &format!("{}", bd.latitude_deg), window, cx),
935            lon: self.make_input(
936                "Longitud (°)",
937                &format!("{}", bd.longitude_deg),
938                window,
939                cx,
940            ),
941            alt: self.make_input("Altitud (m)", &format!("{}", bd.altitude_m), window, cx),
942        };
943        form.name.update(cx, |i, _| i.request_focus(window));
944        self.modal = Some(Modal::EditFreeChart {
945            source_id,
946            form,
947            error: None,
948        });
949        self.close_menu(cx);
950    }
951
952    /// Cambia `selected_contact` del modal `SaveFreeChart` activo
953    /// sin recrear los inputs. Permite alternar entre los botones
954    /// radio "contacto existente" y "Nuevo contacto…".
955    fn set_save_modal_contact(
956        &mut self,
957        new_selection: Option<ContactId>,
958        expected_source: &FreeChartId,
959        cx: &mut Context<'_, Self>,
960    ) {
961        if let Some(Modal::SaveFreeChart {
962            source_id,
963            selected_contact,
964            ..
965        }) = self.modal.as_mut()
966        {
967            if source_id == expected_source {
968                *selected_contact = new_selection;
969                cx.notify();
970            }
971        }
972    }
973
974    fn open_save_free_chart_modal(
975        &mut self,
976        source_id: FreeChartId,
977        window: &mut Window,
978        cx: &mut Context<'_, Self>,
979    ) {
980        let default_label = self
981            .free_charts
982            .iter()
983            .find(|e| e.id == source_id)
984            .map(|e| e.label.clone())
985            .unwrap_or_else(|| "Carta libre".into());
986        let all_contacts = self.gather_all_contacts();
987        let name_input = self.make_input("Nombre de la carta", &default_label, window, cx);
988        let new_contact_input =
989            self.make_input("Nombre del contacto nuevo", "", window, cx);
990        // Default: primer contacto existente si lo hay; sino "Nuevo
991        // contacto" (None). Eso minimiza clicks cuando el usuario ya
992        // tiene contactos cargados.
993        let selected_contact = all_contacts.first().map(|(id, _)| *id);
994        self.modal = Some(Modal::SaveFreeChart {
995            source_id,
996            name: name_input,
997            new_contact_name: new_contact_input,
998            selected_contact,
999            all_contacts,
1000            error: None,
1001        });
1002        self.close_menu(cx);
1003    }
1004
1005    /// Snapshot recursivo de todos los contactos del árbol —
1006    /// `(id, label)`. Usado por el modal "Guardar como" para
1007    /// listar destinos. Las cartas se cuelgan del contacto que el
1008    /// usuario elija.
1009    fn gather_all_contacts(&self) -> Vec<(ContactId, String)> {
1010        fn walk(
1011            store: &Store,
1012            parent: Option<GroupId>,
1013            prefix: &str,
1014            out: &mut Vec<(ContactId, String)>,
1015        ) {
1016            if let Ok(contacts) = store.list_contacts(parent) {
1017                for c in contacts {
1018                    let label = if prefix.is_empty() {
1019                        c.name.clone()
1020                    } else {
1021                        format!("{}{}", prefix, c.name)
1022                    };
1023                    out.push((c.id, label));
1024                }
1025            }
1026            if let Ok(groups) = store.list_groups(parent) {
1027                for g in groups {
1028                    let new_prefix = if prefix.is_empty() {
1029                        format!("{} / ", g.name)
1030                    } else {
1031                        format!("{}{} / ", prefix, g.name)
1032                    };
1033                    walk(store, Some(g.id), &new_prefix, out);
1034                }
1035            }
1036        }
1037        let mut out = Vec::new();
1038        walk(&self.store, None, "", &mut out);
1039        out
1040    }
1041
1042    fn open_create_chart(
1043        &mut self,
1044        contact: ContactId,
1045        window: &mut Window,
1046        cx: &mut Context<'_, Self>,
1047    ) {
1048        // Pre-cargamos el nombre del contacto en el campo "Sujeto" del
1049        // form como conveniencia — la mayoría de las cartas se nombran
1050        // igual que la persona.
1051        let subject_name = self
1052            .store
1053            .list_contacts(None)
1054            .ok()
1055            .and_then(|all| {
1056                // Buscamos linealmente — list_contacts solo lista hijos
1057                // directos del group, no nos sirve para encontrar un
1058                // contact arbitrario. Para fase 2 nos quedamos con
1059                // "Carta natal" como label genérico si no podemos
1060                // resolver. Resolver-por-id viene si lo necesitamos.
1061                let _ = all;
1062                None::<String>
1063            })
1064            .unwrap_or_else(|| "Carta natal".into());
1065
1066        let form = ChartForm {
1067            name: self.make_input("Etiqueta de la carta", &subject_name, window, cx),
1068            place: self.make_input("Lugar (ciudad, país)", "", window, cx),
1069            year: self.make_input("Año", "1987", window, cx),
1070            month: self.make_input("Mes", "3", window, cx),
1071            day: self.make_input("Día", "14", window, cx),
1072            hour: self.make_input("Hora (0-23)", "5", window, cx),
1073            minute: self.make_input("Minuto", "22", window, cx),
1074            tz_offset_min: self.make_input("TZ offset (min)", "-240", window, cx),
1075            lat: self.make_input("Latitud (°)", "10.4806", window, cx),
1076            lon: self.make_input("Longitud (°)", "-66.9036", window, cx),
1077            alt: self.make_input("Altitud (m)", "900", window, cx),
1078        };
1079        // El primer field es el que recibe focus.
1080        form.name.update(cx, |i, _| i.request_focus(window));
1081
1082        self.modal = Some(Modal::CreateChart {
1083            contact,
1084            form,
1085            error: None,
1086        });
1087        self.close_menu(cx);
1088    }
1089
1090    fn open_rename(&mut self, target: MenuTarget, window: &mut Window, cx: &mut Context<'_, Self>) {
1091        let modal = match target {
1092            MenuTarget::Group(id) => {
1093                let current = self
1094                    .store
1095                    .list_groups(None)
1096                    .ok()
1097                    .and_then(|all| find_group_name(&all, &self.store, id))
1098                    .unwrap_or_default();
1099                Modal::RenameGroup {
1100                    id,
1101                    input: self.make_input("Nuevo nombre", &current, window, cx),
1102                }
1103            }
1104            MenuTarget::Contact(id) => {
1105                let current = self
1106                    .store
1107                    .list_contacts(None)
1108                    .ok()
1109                    .and_then(|all| find_contact_name(&all, &self.store, id))
1110                    .unwrap_or_default();
1111                Modal::RenameContact {
1112                    id,
1113                    input: self.make_input("Nuevo nombre", &current, window, cx),
1114                }
1115            }
1116            MenuTarget::Chart(id) => {
1117                let current = self
1118                    .store
1119                    .get_chart(id)
1120                    .ok()
1121                    .map(|c| c.label)
1122                    .unwrap_or_default();
1123                Modal::RenameChart {
1124                    id,
1125                    input: self.make_input("Nueva etiqueta", &current, window, cx),
1126                }
1127            }
1128            MenuTarget::Root | MenuTarget::FreeChartsRoot | MenuTarget::FreeChart(_) => return,
1129        };
1130        self.modal = Some(modal);
1131        self.close_menu(cx);
1132    }
1133
1134    /// Crea un `TextInput` con focus y suscripción a Confirmed/Cancelled.
1135    /// La closure decide qué hacer con cada evento — guardamos la subscripción
1136    /// detached para que viva mientras el modal exista.
1137    fn make_input(
1138        &self,
1139        placeholder: &str,
1140        initial: &str,
1141        window: &mut Window,
1142        cx: &mut Context<'_, Self>,
1143    ) -> Entity<TextInput> {
1144        let placeholder = placeholder.to_string();
1145        let input = cx.new(|cx| {
1146            TextInput::new(initial.to_string(), cx)
1147                .with_placeholder(SharedString::from(placeholder.clone()))
1148        });
1149        cx.subscribe(&input, |this: &mut Self, _, ev: &TextInputEvent, cx| {
1150            this.on_input_event(ev, cx);
1151        })
1152        .detach();
1153        input.update(cx, |i, _| i.request_focus(window));
1154        input
1155    }
1156
1157    fn on_input_event(&mut self, ev: &TextInputEvent, cx: &mut Context<'_, Self>) {
1158        match ev {
1159            TextInputEvent::Cancelled => self.close_modal(cx),
1160            TextInputEvent::Confirmed(value) => self.submit_modal(value.clone(), cx),
1161        }
1162    }
1163
1164    fn submit_modal(&mut self, value: String, cx: &mut Context<'_, Self>) {
1165        let trimmed = value.trim().to_string();
1166        // Tomamos ownership del modal — si el submit falla en mitad,
1167        // lo restablecemos. Esto evita un borrow-mut sobre self.modal.
1168        let modal = match self.modal.take() {
1169            Some(m) => m,
1170            None => return,
1171        };
1172        match modal {
1173            Modal::RenameGroup { id, input } => {
1174                if !trimmed.is_empty() {
1175                    let _ = self.store.rename_group(id, &trimmed);
1176                }
1177                drop(input);
1178                self.after_mutation(cx);
1179            }
1180            Modal::RenameContact { id, input } => {
1181                if !trimmed.is_empty() {
1182                    let _ = self.store.rename_contact(id, &trimmed);
1183                }
1184                drop(input);
1185                self.after_mutation(cx);
1186            }
1187            Modal::RenameChart { id, input } => {
1188                if !trimmed.is_empty() {
1189                    let _ = self.store.rename_chart(id, &trimmed);
1190                }
1191                drop(input);
1192                self.after_mutation(cx);
1193            }
1194            Modal::CreateGroup { parent, input } => {
1195                if !trimmed.is_empty() {
1196                    let _ = self.store.create_group(parent, &trimmed, None);
1197                }
1198                drop(input);
1199                self.after_mutation(cx);
1200            }
1201            Modal::CreateContact { group, input } => {
1202                if !trimmed.is_empty() {
1203                    let _ = self.store.create_contact(group, &trimmed, None);
1204                }
1205                drop(input);
1206                self.after_mutation(cx);
1207            }
1208            Modal::CreateChart {
1209                contact,
1210                form,
1211                error: _,
1212            } => {
1213                // `value` viene del campo que disparó Enter. Para el
1214                // form, ignoramos el value puntual y leemos todos los
1215                // campos del form.
1216                let _ = value;
1217                match build_chart_from_form(&form, cx) {
1218                    Ok((birth, label)) => {
1219                        match self.store.create_chart(
1220                            contact,
1221                            ChartKind::Natal,
1222                            &label,
1223                            &birth,
1224                            &StoredChartConfig::default(),
1225                            None,
1226                        ) {
1227                            Ok(_) => {
1228                                // Auto-expand del contact para que se vea
1229                                // la carta recién creada.
1230                                self.expanded
1231                                    .insert(format!("{}{}", PREFIX_CONTACT, contact));
1232                                self.after_mutation(cx);
1233                            }
1234                            Err(e) => {
1235                                self.modal = Some(Modal::CreateChart {
1236                                    contact,
1237                                    form,
1238                                    error: Some(SharedString::from(format!("Store: {}", e))),
1239                                });
1240                                cx.notify();
1241                            }
1242                        }
1243                    }
1244                    Err(msg) => {
1245                        self.modal = Some(Modal::CreateChart {
1246                            contact,
1247                            form,
1248                            error: Some(SharedString::from(msg)),
1249                        });
1250                        cx.notify();
1251                    }
1252                }
1253            }
1254            Modal::EditChart {
1255                id,
1256                form,
1257                error: _,
1258            } => {
1259                let _ = value;
1260                // Para preservar el ChartConfig original (zodiac, house
1261                // system, bodies, etc.) leemos la carta actual y solo
1262                // sobrescribimos label + birth_data. El editor no toca
1263                // config — eso se haría en un futuro panel de "Config
1264                // de carta".
1265                let existing = match self.store.get_chart(id) {
1266                    Ok(c) => c,
1267                    Err(e) => {
1268                        self.modal = Some(Modal::EditChart {
1269                            id,
1270                            form,
1271                            error: Some(SharedString::from(format!("Store: {}", e))),
1272                        });
1273                        cx.notify();
1274                        return;
1275                    }
1276                };
1277                match build_chart_from_form(&form, cx) {
1278                    Ok((birth, label)) => {
1279                        match self.store.update_chart(id, &label, &birth, &existing.config) {
1280                            Ok(_) => {
1281                                drop(form);
1282                                self.after_mutation(cx);
1283                            }
1284                            Err(e) => {
1285                                self.modal = Some(Modal::EditChart {
1286                                    id,
1287                                    form,
1288                                    error: Some(SharedString::from(format!("Store: {}", e))),
1289                                });
1290                                cx.notify();
1291                            }
1292                        }
1293                    }
1294                    Err(msg) => {
1295                        self.modal = Some(Modal::EditChart {
1296                            id,
1297                            form,
1298                            error: Some(SharedString::from(msg)),
1299                        });
1300                        cx.notify();
1301                    }
1302                }
1303            }
1304            Modal::EditFreeChart {
1305                source_id,
1306                form,
1307                error: _,
1308            } => {
1309                let _ = value;
1310                match build_chart_from_form(&form, cx) {
1311                    Ok((birth, label)) => {
1312                        cx.emit(TreeEvent::FreeChartEditConfirmed {
1313                            source_id,
1314                            birth_data: birth,
1315                            label,
1316                        });
1317                        self.modal = None;
1318                        cx.notify();
1319                    }
1320                    Err(msg) => {
1321                        self.modal = Some(Modal::EditFreeChart {
1322                            source_id,
1323                            form,
1324                            error: Some(SharedString::from(msg)),
1325                        });
1326                        cx.notify();
1327                    }
1328                }
1329            }
1330            Modal::SaveFreeChart {
1331                source_id,
1332                name,
1333                new_contact_name,
1334                selected_contact,
1335                all_contacts,
1336                error: _,
1337            } => {
1338                let _ = value;
1339                let chart_name = name.read(cx).text().to_string();
1340                let chart_name = chart_name.trim();
1341                if chart_name.is_empty() {
1342                    self.modal = Some(Modal::SaveFreeChart {
1343                        source_id,
1344                        name,
1345                        new_contact_name,
1346                        selected_contact,
1347                        all_contacts,
1348                        error: Some("El nombre de la carta no puede estar vacío".into()),
1349                    });
1350                    cx.notify();
1351                    return;
1352                }
1353                let new_contact = if selected_contact.is_none() {
1354                    let v = new_contact_name.read(cx).text().to_string();
1355                    let v = v.trim();
1356                    if v.is_empty() {
1357                        self.modal = Some(Modal::SaveFreeChart {
1358                            source_id,
1359                            name,
1360                            new_contact_name,
1361                            selected_contact,
1362                            all_contacts,
1363                            error: Some(
1364                                "Elegí un contacto existente o escribí un nombre para el nuevo"
1365                                    .into(),
1366                            ),
1367                        });
1368                        cx.notify();
1369                        return;
1370                    }
1371                    Some(v.to_string())
1372                } else {
1373                    None
1374                };
1375                cx.emit(TreeEvent::FreeChartSaveConfirmed {
1376                    source_id,
1377                    chart_name: chart_name.to_string(),
1378                    contact: selected_contact,
1379                    new_contact_name: new_contact,
1380                });
1381                drop(name);
1382                drop(new_contact_name);
1383                self.modal = None;
1384                cx.notify();
1385            }
1386        }
1387    }
1388
1389    fn after_mutation(&mut self, cx: &mut Context<'_, Self>) {
1390        self.modal = None;
1391        self.refresh(cx);
1392        cx.emit(TreeEvent::HierarchyChanged);
1393        cx.notify();
1394    }
1395
1396    fn confirm_and_delete(
1397        &mut self,
1398        target: MenuTarget,
1399        window: &mut Window,
1400        cx: &mut Context<'_, Self>,
1401    ) {
1402        let (label, kind) = match target {
1403            MenuTarget::Group(_) => ("este grupo (incluye sus subgrupos y contactos)", "group"),
1404            MenuTarget::Contact(_) => ("este contacto (incluye sus cartas)", "contact"),
1405            MenuTarget::Chart(_) => ("esta carta", "chart"),
1406            MenuTarget::Root | MenuTarget::FreeChartsRoot | MenuTarget::FreeChart(_) => return,
1407        };
1408        let answer = window.prompt(
1409            PromptLevel::Warning,
1410            &format!("¿Borrar {}?", label),
1411            None,
1412            &["Borrar", "Cancelar"],
1413            cx,
1414        );
1415        let target_clone = target.clone();
1416        cx.spawn(async move |this, cx| {
1417            let Ok(idx) = answer.await else { return };
1418            if idx != 0 {
1419                return;
1420            }
1421            let _ = this.update(cx, |this, cx| {
1422                match target_clone {
1423                    MenuTarget::FreeChartsRoot | MenuTarget::FreeChart(_) => {}
1424                    MenuTarget::Group(id) => {
1425                        let _ = this.store.delete_group(id);
1426                    }
1427                    MenuTarget::Contact(id) => {
1428                        let _ = this.store.delete_contact(id);
1429                    }
1430                    MenuTarget::Chart(id) => {
1431                        let _ = this.store.delete_chart(id);
1432                    }
1433                    MenuTarget::Root => {}
1434                }
1435                this.after_mutation(cx);
1436            });
1437        })
1438        .detach();
1439        let _ = kind;
1440        self.close_menu(cx);
1441    }
1442}
1443
1444// =====================================================================
1445// Form helpers
1446// =====================================================================
1447
1448fn build_chart_from_form(
1449    form: &ChartForm,
1450    cx: &mut Context<'_, TahuantinsuyuTree>,
1451) -> Result<(StoredBirthData, String), String> {
1452    let name = form.name.read(cx).text().trim().to_string();
1453    let place = form.place.read(cx).text().trim().to_string();
1454    let year: i32 = parse_field(form.year.read(cx).text(), "Año")?;
1455    let month: u32 = parse_field(form.month.read(cx).text(), "Mes")?;
1456    let day: u32 = parse_field(form.day.read(cx).text(), "Día")?;
1457    let hour: u32 = parse_field(form.hour.read(cx).text(), "Hora")?;
1458    let minute: u32 = parse_field(form.minute.read(cx).text(), "Minuto")?;
1459    let tz_offset_minutes: i32 = parse_field(form.tz_offset_min.read(cx).text(), "TZ offset")?;
1460    let latitude_deg: f64 = parse_field(form.lat.read(cx).text(), "Latitud")?;
1461    let longitude_deg: f64 = parse_field(form.lon.read(cx).text(), "Longitud")?;
1462    let altitude_m: f64 = parse_field(form.alt.read(cx).text(), "Altitud")?;
1463
1464    if !(1..=12).contains(&month) {
1465        return Err(format!("Mes fuera de rango: {}", month));
1466    }
1467    if !(1..=31).contains(&day) {
1468        return Err(format!("Día fuera de rango: {}", day));
1469    }
1470    if hour > 23 {
1471        return Err(format!("Hora fuera de rango: {}", hour));
1472    }
1473    if minute > 59 {
1474        return Err(format!("Minuto fuera de rango: {}", minute));
1475    }
1476
1477    let label = if name.is_empty() {
1478        "Carta natal".to_string()
1479    } else {
1480        name
1481    };
1482    let birth = StoredBirthData {
1483        year,
1484        month,
1485        day,
1486        hour,
1487        minute,
1488        second: 0.0,
1489        tz_offset_minutes,
1490        latitude_deg,
1491        longitude_deg,
1492        altitude_m,
1493        time_certainty: TimeCertainty::Exact,
1494        subject_name: None,
1495        birthplace_label: if place.is_empty() { None } else { Some(place) },
1496    };
1497    Ok((birth, label))
1498}
1499
1500fn parse_field<T: std::str::FromStr>(s: &str, field: &str) -> Result<T, String> {
1501    s.trim()
1502        .parse::<T>()
1503        .map_err(|_| format!("Campo \"{}\" inválido: {:?}", field, s))
1504}
1505
1506// =====================================================================
1507// Lookups auxiliares (DFS por la jerarquía)
1508// =====================================================================
1509
1510fn find_group_name(roots: &[tahuantinsuyu_model::Group], store: &Store, id: GroupId) -> Option<String> {
1511    for g in roots {
1512        if g.id == id {
1513            return Some(g.name.clone());
1514        }
1515        if let Ok(children) = store.list_groups(Some(g.id)) {
1516            if let Some(n) = find_group_name(&children, store, id) {
1517                return Some(n);
1518            }
1519        }
1520    }
1521    None
1522}
1523
1524fn find_contact_name(
1525    in_group: &[tahuantinsuyu_model::Contact],
1526    store: &Store,
1527    id: ContactId,
1528) -> Option<String> {
1529    for c in in_group {
1530        if c.id == id {
1531            return Some(c.name.clone());
1532        }
1533    }
1534    // Buscar también en todos los groups recursivamente.
1535    if let Ok(groups) = store.list_groups(None) {
1536        if let Some(n) = find_contact_in_groups(&groups, store, id) {
1537            return Some(n);
1538        }
1539    }
1540    None
1541}
1542
1543fn find_contact_in_groups(
1544    groups: &[tahuantinsuyu_model::Group],
1545    store: &Store,
1546    id: ContactId,
1547) -> Option<String> {
1548    for g in groups {
1549        if let Ok(cs) = store.list_contacts(Some(g.id)) {
1550            for c in &cs {
1551                if c.id == id {
1552                    return Some(c.name.clone());
1553                }
1554            }
1555        }
1556        if let Ok(children) = store.list_groups(Some(g.id)) {
1557            if let Some(n) = find_contact_in_groups(&children, store, id) {
1558                return Some(n);
1559            }
1560        }
1561    }
1562    None
1563}
1564
1565fn parse_row(id: &RowId) -> Option<TreeSelection> {
1566    let s = id.as_str();
1567    if s == ROW_GENERAL {
1568        return Some(TreeSelection::GeneralRoot);
1569    }
1570    if s == ROW_FREE_ROOT {
1571        return Some(TreeSelection::FreeChartsRoot);
1572    }
1573    if let Some(rest) = s.strip_prefix(PREFIX_FREE) {
1574        return Some(TreeSelection::FreeChart(FreeChartId(rest.to_string())));
1575    }
1576    if let Some(rest) = s.strip_prefix(PREFIX_GROUP) {
1577        return rest.parse().ok().map(TreeSelection::Group);
1578    }
1579    if let Some(rest) = s.strip_prefix(PREFIX_CONTACT) {
1580        return rest.parse().ok().map(TreeSelection::Contact);
1581    }
1582    if let Some(rest) = s.strip_prefix(PREFIX_CHART) {
1583        return rest.parse().ok().map(TreeSelection::Chart);
1584    }
1585    None
1586}
1587
1588// =====================================================================
1589// Render
1590// =====================================================================
1591
1592const MENU_WIDTH: f32 = 220.0;
1593
1594impl Render for TahuantinsuyuTree {
1595    fn render(&mut self, _w: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
1596        let theme = Theme::global(cx).clone();
1597        let search_bar = div()
1598            .px(px(6.0))
1599            .py(px(4.0))
1600            .border_b_1()
1601            .border_color(theme.border)
1602            .child(self.search_input.clone());
1603
1604        let mut root = div()
1605            .id("tahuantinsuyu-tree-root")
1606            .size_full()
1607            .relative()
1608            .bg(theme.bg_panel.clone())
1609            .flex()
1610            .flex_col()
1611            .child(search_bar)
1612            .child(div().flex_grow().min_h(px(0.0)).child(self.inner.clone()));
1613
1614        if let Some(menu) = self.menu.clone() {
1615            root = root.child(self.render_menu(&theme, menu, cx));
1616        }
1617        if self.modal.is_some() {
1618            root = root.child(self.render_modal(&theme, cx));
1619        }
1620        root
1621    }
1622}
1623
1624impl TahuantinsuyuTree {
1625    fn render_menu(
1626        &self,
1627        theme: &Theme,
1628        menu: MenuState,
1629        cx: &mut Context<'_, Self>,
1630    ) -> impl IntoElement {
1631        let mut items = div()
1632            .flex()
1633            .flex_col()
1634            .py(px(4.0))
1635            .min_w(px(MENU_WIDTH))
1636            .bg(theme.bg_panel_alt.clone())
1637            .border_1()
1638            .border_color(theme.border_strong)
1639            .rounded(px(6.0));
1640
1641        match menu.target.clone() {
1642            MenuTarget::Root => {
1643                items = items.child(menu_item("tts-menu-new-group", "Nuevo grupo", theme).on_click(
1644                    cx.listener(|this, _: &ClickEvent, w, cx| {
1645                        this.open_create_group(None, w, cx);
1646                    }),
1647                ));
1648                items = items.child(
1649                    menu_item("tts-menu-new-contact-root", "Nuevo contacto", theme).on_click(
1650                        cx.listener(|this, _: &ClickEvent, w, cx| {
1651                            this.open_create_contact(None, w, cx);
1652                        }),
1653                    ),
1654                );
1655            }
1656            MenuTarget::Group(id) => {
1657                items = items.child(
1658                    menu_item("tts-menu-new-subgroup", "Nuevo subgrupo", theme).on_click(
1659                        cx.listener(move |this, _: &ClickEvent, w, cx| {
1660                            this.open_create_group(Some(id), w, cx);
1661                        }),
1662                    ),
1663                );
1664                items = items.child(
1665                    menu_item("tts-menu-new-contact", "Nuevo contacto", theme).on_click(
1666                        cx.listener(move |this, _: &ClickEvent, w, cx| {
1667                            this.open_create_contact(Some(id), w, cx);
1668                        }),
1669                    ),
1670                );
1671                items = items.child(separator(theme));
1672                let t = menu.target.clone();
1673                items = items.child(menu_item("tts-menu-rename-g", "Renombrar…", theme).on_click(
1674                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1675                        this.open_rename(t.clone(), w, cx);
1676                    }),
1677                ));
1678                let t = menu.target.clone();
1679                items = items.child(menu_item("tts-menu-delete-g", "Borrar…", theme).on_click(
1680                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1681                        this.confirm_and_delete(t.clone(), w, cx);
1682                    }),
1683                ));
1684            }
1685            MenuTarget::Contact(id) => {
1686                items = items.child(menu_item("tts-menu-new-chart", "Nueva carta…", theme).on_click(
1687                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1688                        this.open_create_chart(id, w, cx);
1689                    }),
1690                ));
1691                items = items.child(separator(theme));
1692                let t = menu.target.clone();
1693                items = items.child(menu_item("tts-menu-rename-c", "Renombrar…", theme).on_click(
1694                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1695                        this.open_rename(t.clone(), w, cx);
1696                    }),
1697                ));
1698                let t = menu.target.clone();
1699                items = items.child(menu_item("tts-menu-delete-c", "Borrar…", theme).on_click(
1700                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1701                        this.confirm_and_delete(t.clone(), w, cx);
1702                    }),
1703                ));
1704            }
1705            MenuTarget::FreeChartsRoot => {
1706                items = items.child(
1707                    menu_item("tts-menu-new-free", "Nueva carta libre", theme).on_click(
1708                        cx.listener(|this, _: &ClickEvent, _w, cx| {
1709                            cx.emit(TreeEvent::NewFreeChartRequested);
1710                            this.close_menu(cx);
1711                        }),
1712                    ),
1713                );
1714            }
1715            MenuTarget::FreeChart(fid) => {
1716                let is_sky = fid.is_sky_now();
1717                let fid_edit = fid.clone();
1718                items = items.child(
1719                    menu_item("tts-menu-edit-free", "Editar datos…", theme).on_click(
1720                        cx.listener(move |this, _: &ClickEvent, w, cx| {
1721                            this.open_edit_free_chart_modal(fid_edit.clone(), w, cx);
1722                        }),
1723                    ),
1724                );
1725                let fid_save = fid.clone();
1726                items = items.child(
1727                    menu_item("tts-menu-save-free", "Guardar como…", theme).on_click(
1728                        cx.listener(move |this, _: &ClickEvent, w, cx| {
1729                            this.open_save_free_chart_modal(fid_save.clone(), w, cx);
1730                        }),
1731                    ),
1732                );
1733                if !is_sky {
1734                    items = items.child(separator(theme));
1735                    let fid_del = fid.clone();
1736                    items = items.child(
1737                        menu_item("tts-menu-delete-free", "Borrar", theme).on_click(
1738                            cx.listener(move |this, _: &ClickEvent, _w, cx| {
1739                                cx.emit(TreeEvent::DeleteFreeChartRequested(fid_del.clone()));
1740                                this.close_menu(cx);
1741                            }),
1742                        ),
1743                    );
1744                }
1745            }
1746            MenuTarget::Chart(id) => {
1747                items = items.child(menu_item("tts-menu-open-h", "Abrir", theme).on_click(
1748                    cx.listener(move |this, _: &ClickEvent, _w, cx| {
1749                        cx.emit(TreeEvent::Opened(TreeSelection::Chart(id)));
1750                        this.close_menu(cx);
1751                    }),
1752                ));
1753                items = items.child(menu_item("tts-menu-edit-h", "Editar…", theme).on_click(
1754                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1755                        this.open_edit_chart(id, w, cx);
1756                    }),
1757                ));
1758                items = items.child(separator(theme));
1759                let t = menu.target.clone();
1760                items = items.child(menu_item("tts-menu-rename-h", "Renombrar…", theme).on_click(
1761                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1762                        this.open_rename(t.clone(), w, cx);
1763                    }),
1764                ));
1765                let t = menu.target.clone();
1766                items = items.child(menu_item("tts-menu-delete-h", "Borrar…", theme).on_click(
1767                    cx.listener(move |this, _: &ClickEvent, w, cx| {
1768                        this.confirm_and_delete(t.clone(), w, cx);
1769                    }),
1770                ));
1771            }
1772        }
1773
1774        div()
1775            .absolute()
1776            .left(menu.position.x)
1777            .top(menu.position.y)
1778            .child(items)
1779    }
1780
1781    fn render_modal(&self, theme: &Theme, cx: &mut Context<'_, Self>) -> impl IntoElement {
1782        let modal = self.modal.as_ref().expect("render_modal sin modal activo");
1783        let inner = match modal {
1784            Modal::RenameGroup { input, .. }
1785            | Modal::RenameContact { input, .. }
1786            | Modal::RenameChart { input, .. } => {
1787                modal_box(theme, "Renombrar", input.clone(), "Enter = guardar — Escape = cancelar")
1788            }
1789            Modal::CreateGroup { input, .. } => {
1790                modal_box(theme, "Nuevo grupo", input.clone(), "Enter = crear — Escape = cancelar")
1791            }
1792            Modal::CreateContact { input, .. } => modal_box(
1793                theme,
1794                "Nuevo contacto",
1795                input.clone(),
1796                "Enter = crear — Escape = cancelar",
1797            ),
1798            Modal::CreateChart { form, error, .. } => render_chart_form(
1799                theme,
1800                "Nueva carta natal",
1801                form,
1802                error.clone(),
1803                self.city_picker_open,
1804                &self.city_atlas,
1805                cx,
1806            ),
1807            Modal::EditChart { form, error, .. } => render_chart_form(
1808                theme,
1809                "Editar carta natal",
1810                form,
1811                error.clone(),
1812                self.city_picker_open,
1813                &self.city_atlas,
1814                cx,
1815            ),
1816            Modal::EditFreeChart { form, error, .. } => render_chart_form(
1817                theme,
1818                "Editar carta libre",
1819                form,
1820                error.clone(),
1821                self.city_picker_open,
1822                &self.city_atlas,
1823                cx,
1824            ),
1825            Modal::SaveFreeChart {
1826                source_id,
1827                name,
1828                new_contact_name,
1829                selected_contact,
1830                all_contacts,
1831                error,
1832            } => render_save_free_chart(
1833                theme,
1834                source_id.clone(),
1835                name.clone(),
1836                new_contact_name.clone(),
1837                *selected_contact,
1838                all_contacts,
1839                error.clone(),
1840                cx,
1841            ),
1842        };
1843
1844        div()
1845            .absolute()
1846            .top(px(0.0))
1847            .left(px(0.0))
1848            .size_full()
1849            .flex()
1850            .items_center()
1851            .justify_center()
1852            .bg(hsla(0.0, 0.0, 0.0, 0.55))
1853            .child(inner)
1854    }
1855}
1856
1857// =====================================================================
1858// Helpers de UI
1859// =====================================================================
1860
1861fn menu_item(
1862    id: &'static str,
1863    label: &'static str,
1864    theme: &Theme,
1865) -> gpui::Stateful<gpui::Div> {
1866    div()
1867        .id(id)
1868        .px(px(12.0))
1869        .py(px(6.0))
1870        .text_size(px(12.0))
1871        .text_color(theme.fg_text)
1872        .hover(|s| s.bg(theme.bg_row_hover))
1873        .child(label)
1874}
1875
1876fn separator(theme: &Theme) -> gpui::Div {
1877    div()
1878        .my(px(3.0))
1879        .h(px(1.0))
1880        .w_full()
1881        .bg(theme.border)
1882}
1883
1884fn modal_box(
1885    theme: &Theme,
1886    title: &'static str,
1887    input: Entity<TextInput>,
1888    hint: &'static str,
1889) -> gpui::Div {
1890    div()
1891        .min_w(px(380.0))
1892        .p(px(16.0))
1893        .flex()
1894        .flex_col()
1895        .gap(px(10.0))
1896        .bg(theme.bg_panel_alt.clone())
1897        .border_1()
1898        .border_color(theme.border_strong)
1899        .rounded(px(8.0))
1900        .child(
1901            div()
1902                .text_size(px(13.0))
1903                .text_color(theme.fg_text)
1904                .child(title),
1905        )
1906        .child(input)
1907        .child(
1908            div()
1909                .text_size(px(10.0))
1910                .text_color(theme.fg_muted)
1911                .child(hint),
1912        )
1913}
1914
1915/// Modal "Guardar como" para una carta libre. Layout:
1916///
1917/// ```text
1918///   [Nombre de la carta] — TextInput pre-poblado con label
1919///   Contacto destino:
1920///     ○ Contacto A
1921///     ○ Contacto B
1922///     ● Nuevo contacto…  → [Nombre del contacto] TextInput
1923///   [Cancelar]  [Guardar]
1924/// ```
1925///
1926/// El submit emite `TreeEvent::FreeChartSaveConfirmed` que el shell
1927/// materializa contra la store.
1928#[allow(clippy::too_many_arguments)]
1929fn render_save_free_chart(
1930    theme: &Theme,
1931    source_id: FreeChartId,
1932    name: Entity<TextInput>,
1933    new_contact_name: Entity<TextInput>,
1934    selected_contact: Option<ContactId>,
1935    all_contacts: &[(ContactId, String)],
1936    error: Option<SharedString>,
1937    cx: &mut Context<'_, TahuantinsuyuTree>,
1938) -> gpui::Div {
1939    let title_row = div()
1940        .text_size(px(14.0))
1941        .text_color(theme.fg_text)
1942        .child(SharedString::from("Guardar carta libre"));
1943    let label_row =
1944        |label: &'static str| -> gpui::Div {
1945            div()
1946                .text_size(px(10.0))
1947                .text_color(theme.fg_muted)
1948                .child(label)
1949        };
1950    let name_block = div()
1951        .flex()
1952        .flex_col()
1953        .gap(px(2.0))
1954        .child(label_row("Nombre"))
1955        .child(name.clone());
1956
1957    // Lista de contactos como botones radio.
1958    let mut contact_list = div().flex().flex_col().gap(px(4.0));
1959    for (cid, label) in all_contacts.iter() {
1960        let selected = selected_contact == Some(*cid);
1961        let cid_for_click = *cid;
1962        let source_for_click = source_id.clone();
1963        let row_id: SharedString =
1964            SharedString::from(format!("tts-save-pick-{}", cid));
1965        let bullet = if selected { "●" } else { "○" };
1966        let row = div()
1967            .id(gpui::ElementId::from(row_id))
1968            .flex()
1969            .flex_row()
1970            .gap(px(8.0))
1971            .px(px(6.0))
1972            .py(px(3.0))
1973            .rounded(px(4.0))
1974            .text_size(px(11.0))
1975            .text_color(theme.fg_text)
1976            .hover(|s| s.bg(theme.bg_row_hover))
1977            .child(bullet.to_string())
1978            .child(label.clone())
1979            .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
1980                this.set_save_modal_contact(Some(cid_for_click), &source_for_click, cx);
1981            }));
1982        contact_list = contact_list.child(row);
1983    }
1984    // Opción "Nuevo contacto…" — bullet activo si selected_contact==None.
1985    let new_selected = selected_contact.is_none();
1986    let new_bullet = if new_selected { "●" } else { "○" };
1987    let source_for_new = source_id.clone();
1988    contact_list = contact_list.child(
1989        div()
1990            .id(gpui::ElementId::from(SharedString::from(
1991                "tts-save-pick-new",
1992            )))
1993            .flex()
1994            .flex_row()
1995            .gap(px(8.0))
1996            .px(px(6.0))
1997            .py(px(3.0))
1998            .rounded(px(4.0))
1999            .text_size(px(11.0))
2000            .text_color(theme.fg_text)
2001            .hover(|s| s.bg(theme.bg_row_hover))
2002            .child(new_bullet.to_string())
2003            .child("Nuevo contacto…")
2004            .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
2005                this.set_save_modal_contact(None, &source_for_new, cx);
2006            })),
2007    );
2008
2009    let mut contacts_block = div()
2010        .flex()
2011        .flex_col()
2012        .gap(px(2.0))
2013        .child(label_row("Contacto destino"))
2014        .child(contact_list);
2015
2016    // Si "Nuevo contacto" está activo, mostrar el TextInput debajo.
2017    if new_selected {
2018        contacts_block = contacts_block.child(
2019            div()
2020                .pt(px(4.0))
2021                .flex()
2022                .flex_col()
2023                .gap(px(2.0))
2024                .child(label_row("Nombre del contacto nuevo"))
2025                .child(new_contact_name.clone()),
2026        );
2027    }
2028
2029    let save_btn = div()
2030        .id("tts-save-free-confirm")
2031        .px(px(14.0))
2032        .py(px(8.0))
2033        .rounded(px(6.0))
2034        .bg(theme.bg_button())
2035        .hover(|s| s.bg(theme.bg_button_hover()))
2036        .text_size(px(12.0))
2037        .text_color(theme.fg_text)
2038        .child("Guardar")
2039        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2040            this.submit_modal(String::new(), cx);
2041        }));
2042    let cancel_btn = div()
2043        .id("tts-save-free-cancel")
2044        .px(px(14.0))
2045        .py(px(8.0))
2046        .rounded(px(6.0))
2047        .bg(theme.bg_panel.clone())
2048        .hover(|s| s.bg(theme.bg_row_hover))
2049        .text_size(px(12.0))
2050        .text_color(theme.fg_muted)
2051        .child("Cancelar")
2052        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2053            this.close_modal(cx);
2054        }));
2055
2056    let mut body = div()
2057        .min_w(px(420.0))
2058        .p(px(18.0))
2059        .flex()
2060        .flex_col()
2061        .gap(px(12.0))
2062        .bg(theme.bg_panel_alt.clone())
2063        .border_1()
2064        .border_color(theme.border_strong)
2065        .rounded(px(8.0))
2066        .child(title_row)
2067        .child(name_block)
2068        .child(contacts_block);
2069    if let Some(err) = error {
2070        body = body.child(
2071            div()
2072                .px(px(10.0))
2073                .py(px(6.0))
2074                .rounded(px(4.0))
2075                .bg(theme.bg_destructive_hover())
2076                .text_size(px(11.0))
2077                .text_color(theme.accent_destructive())
2078                .child(err),
2079        );
2080    }
2081    body = body.child(
2082        div()
2083            .flex()
2084            .flex_row()
2085            .gap(px(8.0))
2086            .justify_end()
2087            .child(cancel_btn)
2088            .child(save_btn),
2089    );
2090    body
2091}
2092
2093fn render_chart_form(
2094    theme: &Theme,
2095    title: &str,
2096    form: &ChartForm,
2097    error: Option<SharedString>,
2098    // Datos del tree que el form necesita renderizar — recibidos por
2099    // parámetro porque esta función se llama desde `render()` y la
2100    // entity del tree ya está leased; un `cx.entity().read(cx)`
2101    // adentro causa double_lease_panic en gpui.
2102    picker_open: bool,
2103    city_atlas: &[CityPreset],
2104    cx: &mut Context<'_, TahuantinsuyuTree>,
2105) -> gpui::Div {
2106    let labeled = |label: &'static str, input: Entity<TextInput>| -> gpui::Div {
2107        div()
2108            .flex()
2109            .flex_col()
2110            .gap(px(2.0))
2111            .child(
2112                div()
2113                    .text_size(px(10.0))
2114                    .text_color(theme.fg_muted)
2115                    .child(label),
2116            )
2117            .child(input)
2118    };
2119
2120    let date_row = div()
2121        .flex()
2122        .flex_row()
2123        .gap(px(8.0))
2124        .child(labeled("Año", form.year.clone()))
2125        .child(labeled("Mes", form.month.clone()))
2126        .child(labeled("Día", form.day.clone()))
2127        .child(labeled("Hora", form.hour.clone()))
2128        .child(labeled("Minuto", form.minute.clone()))
2129        .child(labeled("TZ (min)", form.tz_offset_min.clone()));
2130
2131    let loc_row = div()
2132        .flex()
2133        .flex_row()
2134        .gap(px(8.0))
2135        .child(labeled("Latitud", form.lat.clone()))
2136        .child(labeled("Longitud", form.lon.clone()))
2137        .child(labeled("Altitud (m)", form.alt.clone()));
2138
2139    let create_btn = div()
2140        .id("tts-chart-form-create")
2141        .px(px(14.0))
2142        .py(px(8.0))
2143        .rounded(px(6.0))
2144        .bg(theme.bg_button())
2145        .hover(|s| s.bg(theme.bg_button_hover()))
2146        .text_size(px(12.0))
2147        .text_color(theme.fg_text)
2148        .child(SharedString::from(if title.starts_with("Editar") {
2149            "Guardar cambios"
2150        } else {
2151            "Crear carta"
2152        }))
2153        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2154            // Disparamos un submit "vacío" — el handler de submit
2155            // re-lee todos los campos del form. El value que pasamos
2156            // se ignora dentro del branch CreateChart/EditChart.
2157            this.submit_modal(String::new(), cx);
2158        }));
2159
2160    let cancel_btn = div()
2161        .id("tts-chart-form-cancel")
2162        .px(px(14.0))
2163        .py(px(8.0))
2164        .rounded(px(6.0))
2165        .bg(theme.bg_panel.clone())
2166        .hover(|s| s.bg(theme.bg_row_hover))
2167        .text_size(px(12.0))
2168        .text_color(theme.fg_muted)
2169        .child("Cancelar")
2170        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2171            this.close_modal(cx);
2172        }));
2173
2174    // Header del form: title + botón "Ciudad rápida" con dropdown
2175    // que autocompleta place/lat/lon/tz al elegir un preset.
2176    let city_btn = div()
2177        .id("tts-form-city-btn")
2178        .px(px(10.0))
2179        .py(px(4.0))
2180        .rounded(px(4.0))
2181        .bg(theme.bg_button())
2182        .hover(|s| s.bg(theme.bg_button_hover()))
2183        .border_1()
2184        .border_color(if picker_open {
2185            theme.accent_strong
2186        } else {
2187            theme.border
2188        })
2189        .text_size(px(11.0))
2190        .text_color(theme.fg_text)
2191        .child("📍 Ciudad rápida ▾")
2192        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2193            this.toggle_city_picker(cx);
2194        }));
2195    let title_row = div()
2196        .relative()
2197        .flex()
2198        .flex_row()
2199        .items_center()
2200        .gap(px(12.0))
2201        .child(
2202            div()
2203                .text_size(px(14.0))
2204                .text_color(theme.fg_text)
2205                .child(SharedString::from(title.to_string())),
2206        )
2207        .child(div().flex_grow())
2208        .child(city_btn);
2209    let title_row = if picker_open {
2210        let popup_id: SharedString = SharedString::from("tts-form-city-popup");
2211        let mut popup = div()
2212            .id(gpui::ElementId::from(popup_id))
2213            .absolute()
2214            .top(px(36.0))
2215            .right(px(0.0))
2216            .min_w(px(240.0))
2217            .h(px(360.0))
2218            .py(px(4.0))
2219            .bg(theme.bg_panel_alt.clone())
2220            .border_1()
2221            .border_color(theme.border_strong)
2222            .rounded(px(6.0))
2223            .flex()
2224            .flex_col()
2225            .overflow_y_scroll();
2226        for preset in city_atlas.iter().cloned() {
2227            let row_id: SharedString =
2228                SharedString::from(format!("tts-city-{}", preset.name));
2229            let label = preset.name.clone();
2230            popup = popup.child(
2231                div()
2232                    .id(gpui::ElementId::from(row_id))
2233                    .px(px(10.0))
2234                    .py(px(4.0))
2235                    .text_size(px(11.0))
2236                    .text_color(theme.fg_text)
2237                    .hover(|s| s.bg(theme.bg_row_hover))
2238                    .child(SharedString::from(label))
2239                    .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
2240                        this.apply_city_preset(&preset, cx);
2241                    })),
2242            );
2243        }
2244        title_row.child(popup)
2245    } else {
2246        title_row
2247    };
2248
2249    let mut body = div()
2250        .min_w(px(640.0))
2251        .p(px(18.0))
2252        .flex()
2253        .flex_col()
2254        .gap(px(12.0))
2255        .bg(theme.bg_panel_alt.clone())
2256        .border_1()
2257        .border_color(theme.border_strong)
2258        .rounded(px(8.0))
2259        .child(title_row)
2260        .child(
2261            div()
2262                .flex()
2263                .flex_row()
2264                .gap(px(8.0))
2265                .child(labeled("Etiqueta", form.name.clone()))
2266                .child(labeled("Lugar (texto libre)", form.place.clone())),
2267        )
2268        .child(date_row)
2269        .child(loc_row);
2270
2271    if let Some(err) = error {
2272        body = body.child(
2273            div()
2274                .px(px(10.0))
2275                .py(px(6.0))
2276                .rounded(px(4.0))
2277                .bg(theme.bg_destructive_hover())
2278                .text_size(px(11.0))
2279                .text_color(theme.accent_destructive())
2280                .child(err),
2281        );
2282    }
2283
2284    body = body.child(
2285        div()
2286            .flex()
2287            .flex_row()
2288            .gap(px(8.0))
2289            .justify_end()
2290            .child(cancel_btn)
2291            .child(create_btn),
2292    );
2293
2294    body
2295}