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