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