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