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