⎇ dd836522ab861a796d8ac54b01de05d2ef52ff27
crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
History
Who touched what
raw
feat(tahuantinsuyu): editor inline para cartas libres (F2)
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", ¤t, 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", ¤t, 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", ¤t, 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}