e9369371db
- Crash fix (panic en gpui entity_map.rs:138 / double_lease): `render_chart_form` hacía `cx.entity().read(cx)` mientras estaba dentro del `render()` del tree — la entity ya estaba leased como `&mut self` y un read concurrente disparaba el double_lease_panic. Se cambió la firma para recibir `picker_open` y `city_atlas` como parámetros desde `render_modal` (que sí tiene `&self`). - Simplificación de anillos: el carril de planetas se acerca (bodies 0.60·r / bodies_inner 0.57·r) — antes 0.05 de separación, ahora 0.03, se ve como "carril" en lugar de dos anillos sueltos. El stroke visible del círculo de aspectos se elimina — `radii.aspects` queda solo como punto de anclaje para las líneas. El `bodies_inner` cambia a stroke plano más sutil (no 3D) para no competir con `bodies`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1667 lines
64 KiB
Rust
1667 lines
64 KiB
Rust
//! `tahuantinsuyu-tree` — explorador jerárquico Groups → Contacts → Charts.
|
|
//!
|
|
//! Envuelve [`yahweh_widget_tree::TreeView`] con la lógica de dominio
|
|
//! de Tahuantinsuyu. Los `RowId` codifican el tipo con prefijo:
|
|
//!
|
|
//! - `g:<ulid>` → Group
|
|
//! - `c:<ulid>` → Contact
|
|
//! - `h:<ulid>` → Chart
|
|
//!
|
|
//! ## Fase 2 — CRUD UX
|
|
//!
|
|
//! - **Right-click** abre un menú contextual cuyas opciones dependen
|
|
//! del target (raíz, group, contact o chart).
|
|
//! - **Renombrar** y **crear** abren un modal con un `TextInput`.
|
|
//! - **Crear carta** abre un formulario con los campos mínimos de
|
|
//! `StoredBirthData` (year/month/day/hour/min/tz/lat/lon).
|
|
//! - **Borrar** pide confirmación con `window.prompt`.
|
|
//!
|
|
//! El host (la app) se suscribe a [`TreeEvent`] y traduce a `AppEvent`
|
|
//! del bus de yahweh para que el canvas/panel reaccionen.
|
|
|
|
#![forbid(unsafe_code)]
|
|
#![warn(rust_2018_idioms)]
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use gpui::{
|
|
ClickEvent, Context, Entity, EventEmitter, IntoElement, Pixels, Point, PromptLevel, Render,
|
|
SharedString, Window, div, hsla, prelude::*, px,
|
|
};
|
|
|
|
use tahuantinsuyu_model::{
|
|
ChartId, ChartKind, ContactId, GroupId, StoredBirthData, StoredChartConfig, TimeCertainty,
|
|
TreeSelection,
|
|
};
|
|
use tahuantinsuyu_store::Store;
|
|
use yahweh_theme::Theme;
|
|
use yahweh_widget_text_input::{TextInput, TextInputEvent};
|
|
use yahweh_widget_tree::{RowId, RowKind, TreeEvent as InnerTreeEvent, TreeRow, TreeView};
|
|
|
|
const PREFIX_GROUP: &str = "g:";
|
|
const PREFIX_CONTACT: &str = "c:";
|
|
const PREFIX_CHART: &str = "h:";
|
|
|
|
// =====================================================================
|
|
// Eventos públicos
|
|
// =====================================================================
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum TreeEvent {
|
|
Selected(TreeSelection),
|
|
Opened(TreeSelection),
|
|
/// Una mutación de la jerarquía aconteció (crear, borrar, renombrar).
|
|
/// El host puede usarlo para invalidar caches en otros widgets.
|
|
HierarchyChanged,
|
|
}
|
|
|
|
// =====================================================================
|
|
// Estado interno
|
|
// =====================================================================
|
|
|
|
/// Target del menú contextual / acciones.
|
|
#[derive(Clone, Debug)]
|
|
enum MenuTarget {
|
|
Root,
|
|
Group(GroupId),
|
|
Contact(ContactId),
|
|
Chart(ChartId),
|
|
}
|
|
|
|
impl MenuTarget {
|
|
fn from_selection(sel: &TreeSelection) -> Self {
|
|
match sel {
|
|
TreeSelection::Group(id) => MenuTarget::Group(*id),
|
|
TreeSelection::Contact(id) => MenuTarget::Contact(*id),
|
|
TreeSelection::Chart(id) => MenuTarget::Chart(*id),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct MenuState {
|
|
target: MenuTarget,
|
|
position: Point<Pixels>,
|
|
}
|
|
|
|
/// Modal flotante. Una sola `Modal` activa a la vez — la app no
|
|
/// soporta editar varias cosas en simultáneo.
|
|
enum Modal {
|
|
RenameGroup {
|
|
id: GroupId,
|
|
input: Entity<TextInput>,
|
|
},
|
|
RenameContact {
|
|
id: ContactId,
|
|
input: Entity<TextInput>,
|
|
},
|
|
RenameChart {
|
|
id: ChartId,
|
|
input: Entity<TextInput>,
|
|
},
|
|
CreateGroup {
|
|
parent: Option<GroupId>,
|
|
input: Entity<TextInput>,
|
|
},
|
|
CreateContact {
|
|
group: Option<GroupId>,
|
|
input: Entity<TextInput>,
|
|
},
|
|
CreateChart {
|
|
contact: ContactId,
|
|
form: ChartForm,
|
|
error: Option<SharedString>,
|
|
},
|
|
/// Editar una carta existente — reusa `ChartForm` pre-cargada.
|
|
/// El submit llama `store.update_chart(id, ...)` preservando
|
|
/// `chart.contact_id`, `related_chart_id`, `module_state` y el
|
|
/// historial.
|
|
EditChart {
|
|
id: ChartId,
|
|
form: ChartForm,
|
|
error: Option<SharedString>,
|
|
},
|
|
}
|
|
|
|
struct ChartForm {
|
|
name: Entity<TextInput>,
|
|
place: Entity<TextInput>,
|
|
year: Entity<TextInput>,
|
|
month: Entity<TextInput>,
|
|
day: Entity<TextInput>,
|
|
hour: Entity<TextInput>,
|
|
minute: Entity<TextInput>,
|
|
tz_offset_min: Entity<TextInput>,
|
|
lat: Entity<TextInput>,
|
|
lon: Entity<TextInput>,
|
|
alt: Entity<TextInput>,
|
|
}
|
|
|
|
// =====================================================================
|
|
// Widget
|
|
// =====================================================================
|
|
|
|
pub struct TahuantinsuyuTree {
|
|
store: Store,
|
|
inner: Entity<TreeView>,
|
|
expanded: HashSet<String>,
|
|
menu: Option<MenuState>,
|
|
modal: Option<Modal>,
|
|
/// `true` cuando el dropdown de "ciudad rápida" en el ChartForm
|
|
/// está abierto. Vive en el tree (no en ChartForm) porque las
|
|
/// closures de los click handlers necesitan mutarlo via `cx.listener`.
|
|
city_picker_open: bool,
|
|
/// Atlas de ciudades para el dropdown del form. Se inicializa con
|
|
/// `default_city_presets()` (90 ciudades hardcoded). El host puede
|
|
/// llamar [`Self::set_city_atlas`] para reemplazar por uno custom
|
|
/// cargado desde disco (TSV).
|
|
city_atlas: Vec<CityPreset>,
|
|
/// Filtro de búsqueda activo. Vacío = sin filtro (jerarquía
|
|
/// completa). Cuando hay texto, refresh() solo incluye rows cuyo
|
|
/// nombre (group / contact / chart label) contenga el substring
|
|
/// case-insensitive, y auto-expande los ancestros para que el
|
|
/// match sea visible.
|
|
search_filter: String,
|
|
/// TextInput para el filtro — vive arriba del tree.
|
|
search_input: Entity<TextInput>,
|
|
}
|
|
|
|
/// Preset de ciudad con datos canónicos para autocompletar lat/lon/tz
|
|
/// al elegirlo en el form. TZ es la zona estándar **sin DST** — el
|
|
/// usuario afina si necesita. `name` es `String` (no &'static) para
|
|
/// permitir cargar atlas custom desde disco vía
|
|
/// [`TahuantinsuyuTree::set_city_atlas`].
|
|
#[derive(Clone, Debug)]
|
|
pub struct CityPreset {
|
|
pub name: String,
|
|
pub lat: f64,
|
|
pub lon: f64,
|
|
pub tz_offset_minutes: i32,
|
|
}
|
|
|
|
/// Atlas hardcoded — 90 ciudades canónicas que cubren la mayoría de
|
|
/// casos de uso. El usuario puede sobrescribirlas pasando un atlas
|
|
/// custom vía [`TahuantinsuyuTree::set_city_atlas`] (típicamente
|
|
/// cargado desde `$XDG_DATA_HOME/tahuantinsuyu/atlas.tsv`).
|
|
pub fn default_city_presets() -> Vec<CityPreset> {
|
|
vec![
|
|
// Latinoamérica
|
|
CityPreset { name: "Buenos Aires, AR".into(), lat: -34.6037, lon: -58.3816, tz_offset_minutes: -180 },
|
|
CityPreset { name: "Córdoba, AR".into(), lat: -31.4201, lon: -64.1888, tz_offset_minutes: -180 },
|
|
CityPreset { name: "Rosario, AR".into(), lat: -32.9587, lon: -60.6930, tz_offset_minutes: -180 },
|
|
CityPreset { name: "Mendoza, AR".into(), lat: -32.8908, lon: -68.8272, tz_offset_minutes: -180 },
|
|
CityPreset { name: "Caracas, VE".into(), lat: 10.4806, lon: -66.9036, tz_offset_minutes: -240 },
|
|
CityPreset { name: "Maracaibo, VE".into(), lat: 10.6427, lon: -71.6125, tz_offset_minutes: -240 },
|
|
CityPreset { name: "Valencia, VE".into(), lat: 10.1620, lon: -68.0078, tz_offset_minutes: -240 },
|
|
CityPreset { name: "Bogotá, CO".into(), lat: 4.7110, lon: -74.0721, tz_offset_minutes: -300 },
|
|
CityPreset { name: "Medellín, CO".into(), lat: 6.2442, lon: -75.5812, tz_offset_minutes: -300 },
|
|
CityPreset { name: "Cali, CO".into(), lat: 3.4516, lon: -76.5320, tz_offset_minutes: -300 },
|
|
CityPreset { name: "Lima, PE".into(), lat: -12.0464, lon: -77.0428, tz_offset_minutes: -300 },
|
|
CityPreset { name: "Cusco, PE".into(), lat: -13.5319, lon: -71.9675, tz_offset_minutes: -300 },
|
|
CityPreset { name: "Santiago, CL".into(), lat: -33.4489, lon: -70.6693, tz_offset_minutes: -240 },
|
|
CityPreset { name: "Valparaíso, CL".into(), lat: -33.0472, lon: -71.6127, tz_offset_minutes: -240 },
|
|
CityPreset { name: "Quito, EC".into(), lat: -0.1807, lon: -78.4678, tz_offset_minutes: -300 },
|
|
CityPreset { name: "Guayaquil, EC".into(), lat: -2.1709, lon: -79.9224, tz_offset_minutes: -300 },
|
|
CityPreset { name: "Montevideo, UY".into(), lat: -34.9011, lon: -56.1645, tz_offset_minutes: -180 },
|
|
CityPreset { name: "Asunción, PY".into(), lat: -25.2637, lon: -57.5759, tz_offset_minutes: -240 },
|
|
CityPreset { name: "La Paz, BO".into(), lat: -16.4897, lon: -68.1193, tz_offset_minutes: -240 },
|
|
CityPreset { name: "Ciudad de México".into(), lat: 19.4326, lon: -99.1332, tz_offset_minutes: -360 },
|
|
CityPreset { name: "Guadalajara, MX".into(), lat: 20.6597, lon: -103.3496, tz_offset_minutes: -360 },
|
|
CityPreset { name: "Monterrey, MX".into(), lat: 25.6866, lon: -100.3161, tz_offset_minutes: -360 },
|
|
CityPreset { name: "Habana, CU".into(), lat: 23.1136, lon: -82.3666, tz_offset_minutes: -300 },
|
|
CityPreset { name: "San Juan, PR".into(), lat: 18.4655, lon: -66.1057, tz_offset_minutes: -240 },
|
|
CityPreset { name: "San José, CR".into(), lat: 9.9281, lon: -84.0907, tz_offset_minutes: -360 },
|
|
CityPreset { name: "Panamá, PA".into(), lat: 8.9824, lon: -79.5199, tz_offset_minutes: -300 },
|
|
CityPreset { name: "San Salvador, SV".into(), lat: 13.6929, lon: -89.2182, tz_offset_minutes: -360 },
|
|
CityPreset { name: "Guatemala, GT".into(), lat: 14.6349, lon: -90.5069, tz_offset_minutes: -360 },
|
|
CityPreset { name: "Tegucigalpa, HN".into(), lat: 14.0723, lon: -87.1921, tz_offset_minutes: -360 },
|
|
CityPreset { name: "Managua, NI".into(), lat: 12.1149, lon: -86.2362, tz_offset_minutes: -360 },
|
|
CityPreset { name: "Santo Domingo, DO".into(), lat: 18.4861, lon: -69.9312, tz_offset_minutes: -240 },
|
|
CityPreset { name: "São Paulo, BR".into(), lat: -23.5505, lon: -46.6333, tz_offset_minutes: -180 },
|
|
CityPreset { name: "Rio de Janeiro, BR".into(), lat: -22.9068, lon: -43.1729, tz_offset_minutes: -180 },
|
|
CityPreset { name: "Brasília, BR".into(), lat: -15.8267, lon: -47.9218, tz_offset_minutes: -180 },
|
|
CityPreset { name: "Salvador, BR".into(), lat: -12.9777, lon: -38.5016, tz_offset_minutes: -180 },
|
|
// España
|
|
CityPreset { name: "Madrid, ES".into(), lat: 40.4168, lon: -3.7038, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Barcelona, ES".into(), lat: 41.3851, lon: 2.1734, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Sevilla, ES".into(), lat: 37.3891, lon: -5.9845, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Valencia, ES".into(), lat: 39.4699, lon: -0.3763, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Bilbao, ES".into(), lat: 43.2630, lon: -2.9350, tz_offset_minutes: 60 },
|
|
// Europa
|
|
CityPreset { name: "London, UK".into(), lat: 51.5074, lon: -0.1278, tz_offset_minutes: 0 },
|
|
CityPreset { name: "Paris, FR".into(), lat: 48.8566, lon: 2.3522, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Berlin, DE".into(), lat: 52.5200, lon: 13.4050, tz_offset_minutes: 60 },
|
|
CityPreset { name: "München, DE".into(), lat: 48.1351, lon: 11.5820, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Roma, IT".into(), lat: 41.9028, lon: 12.4964, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Milano, IT".into(), lat: 45.4642, lon: 9.1900, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Amsterdam, NL".into(), lat: 52.3676, lon: 4.9041, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Bruxelles, BE".into(), lat: 50.8503, lon: 4.3517, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Wien, AT".into(), lat: 48.2082, lon: 16.3738, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Zürich, CH".into(), lat: 47.3769, lon: 8.5417, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Lisboa, PT".into(), lat: 38.7223, lon: -9.1393, tz_offset_minutes: 0 },
|
|
CityPreset { name: "Dublin, IE".into(), lat: 53.3498, lon: -6.2603, tz_offset_minutes: 0 },
|
|
CityPreset { name: "Stockholm, SE".into(), lat: 59.3293, lon: 18.0686, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Oslo, NO".into(), lat: 59.9139, lon: 10.7522, tz_offset_minutes: 60 },
|
|
CityPreset { name: "København, DK".into(), lat: 55.6761, lon: 12.5683, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Helsinki, FI".into(), lat: 60.1699, lon: 24.9384, tz_offset_minutes: 120 },
|
|
CityPreset { name: "Warszawa, PL".into(), lat: 52.2297, lon: 21.0122, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Praha, CZ".into(), lat: 50.0755, lon: 14.4378, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Budapest, HU".into(), lat: 47.4979, lon: 19.0402, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Athina, GR".into(), lat: 37.9838, lon: 23.7275, tz_offset_minutes: 120 },
|
|
CityPreset { name: "İstanbul, TR".into(), lat: 41.0082, lon: 28.9784, tz_offset_minutes: 180 },
|
|
CityPreset { name: "Moskva, RU".into(), lat: 55.7558, lon: 37.6173, tz_offset_minutes: 180 },
|
|
// USA + Canada
|
|
CityPreset { name: "New York, US".into(), lat: 40.7128, lon: -74.0060, tz_offset_minutes: -300 },
|
|
CityPreset { name: "Los Angeles, US".into(), lat: 34.0522, lon: -118.2437, tz_offset_minutes: -480 },
|
|
CityPreset { name: "Chicago, US".into(), lat: 41.8781, lon: -87.6298, tz_offset_minutes: -360 },
|
|
CityPreset { name: "Miami, US".into(), lat: 25.7617, lon: -80.1918, tz_offset_minutes: -300 },
|
|
CityPreset { name: "Houston, US".into(), lat: 29.7604, lon: -95.3698, tz_offset_minutes: -360 },
|
|
CityPreset { name: "San Francisco, US".into(), lat: 37.7749, lon: -122.4194, tz_offset_minutes: -480 },
|
|
CityPreset { name: "Seattle, US".into(), lat: 47.6062, lon: -122.3321, tz_offset_minutes: -480 },
|
|
CityPreset { name: "Boston, US".into(), lat: 42.3601, lon: -71.0589, tz_offset_minutes: -300 },
|
|
CityPreset { name: "Washington DC".into(), lat: 38.9072, lon: -77.0369, tz_offset_minutes: -300 },
|
|
CityPreset { name: "Toronto, CA".into(), lat: 43.6532, lon: -79.3832, tz_offset_minutes: -300 },
|
|
CityPreset { name: "Montreal, CA".into(), lat: 45.5017, lon: -73.5673, tz_offset_minutes: -300 },
|
|
CityPreset { name: "Vancouver, CA".into(), lat: 49.2827, lon: -123.1207, tz_offset_minutes: -480 },
|
|
// Asia
|
|
CityPreset { name: "Tokyo, JP".into(), lat: 35.6762, lon: 139.6503, tz_offset_minutes: 540 },
|
|
CityPreset { name: "Beijing, CN".into(), lat: 39.9042, lon: 116.4074, tz_offset_minutes: 480 },
|
|
CityPreset { name: "Shanghai, CN".into(), lat: 31.2304, lon: 121.4737, tz_offset_minutes: 480 },
|
|
CityPreset { name: "Hong Kong".into(), lat: 22.3193, lon: 114.1694, tz_offset_minutes: 480 },
|
|
CityPreset { name: "Singapore".into(), lat: 1.3521, lon: 103.8198, tz_offset_minutes: 480 },
|
|
CityPreset { name: "Seoul, KR".into(), lat: 37.5665, lon: 126.9780, tz_offset_minutes: 540 },
|
|
CityPreset { name: "Bangkok, TH".into(), lat: 13.7563, lon: 100.5018, tz_offset_minutes: 420 },
|
|
CityPreset { name: "Jakarta, ID".into(), lat: -6.2088, lon: 106.8456, tz_offset_minutes: 420 },
|
|
CityPreset { name: "Manila, PH".into(), lat: 14.5995, lon: 120.9842, tz_offset_minutes: 480 },
|
|
CityPreset { name: "Mumbai, IN".into(), lat: 19.0760, lon: 72.8777, tz_offset_minutes: 330 },
|
|
CityPreset { name: "Delhi, IN".into(), lat: 28.7041, lon: 77.1025, tz_offset_minutes: 330 },
|
|
CityPreset { name: "Bangalore, IN".into(), lat: 12.9716, lon: 77.5946, tz_offset_minutes: 330 },
|
|
CityPreset { name: "Karachi, PK".into(), lat: 24.8607, lon: 67.0011, tz_offset_minutes: 300 },
|
|
CityPreset { name: "Tehran, IR".into(), lat: 35.6892, lon: 51.3890, tz_offset_minutes: 210 },
|
|
CityPreset { name: "Dubai, AE".into(), lat: 25.2048, lon: 55.2708, tz_offset_minutes: 240 },
|
|
CityPreset { name: "Tel Aviv, IL".into(), lat: 32.0853, lon: 34.7818, tz_offset_minutes: 120 },
|
|
// África
|
|
CityPreset { name: "Cairo, EG".into(), lat: 30.0444, lon: 31.2357, tz_offset_minutes: 120 },
|
|
CityPreset { name: "Lagos, NG".into(), lat: 6.5244, lon: 3.3792, tz_offset_minutes: 60 },
|
|
CityPreset { name: "Nairobi, KE".into(), lat: -1.2921, lon: 36.8219, tz_offset_minutes: 180 },
|
|
CityPreset { name: "Johannesburg, ZA".into(), lat: -26.2041, lon: 28.0473, tz_offset_minutes: 120 },
|
|
CityPreset { name: "Cape Town, ZA".into(), lat: -33.9249, lon: 18.4241, tz_offset_minutes: 120 },
|
|
CityPreset { name: "Casablanca, MA".into(), lat: 33.5731, lon: -7.5898, tz_offset_minutes: 60 },
|
|
// Oceanía
|
|
CityPreset { name: "Sydney, AU".into(), lat: -33.8688, lon: 151.2093, tz_offset_minutes: 600 },
|
|
CityPreset { name: "Melbourne, AU".into(), lat: -37.8136, lon: 144.9631, tz_offset_minutes: 600 },
|
|
CityPreset { name: "Auckland, NZ".into(), lat: -36.8485, lon: 174.7633, tz_offset_minutes: 720 },
|
|
]
|
|
}
|
|
|
|
/// Parsea un atlas TSV (tab-separated values): cada línea no vacía y
|
|
/// no comentario es `name<TAB>lat<TAB>lon<TAB>tz_offset_minutes`.
|
|
/// Devuelve solo las filas válidas — las inválidas se descartan en
|
|
/// silencio (no abortamos la carga por una línea mal formada).
|
|
pub fn parse_city_atlas_tsv(content: &str) -> Vec<CityPreset> {
|
|
let mut out = Vec::new();
|
|
for line in content.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
let parts: Vec<&str> = line.split('\t').collect();
|
|
if parts.len() < 4 {
|
|
continue;
|
|
}
|
|
let name = parts[0].trim().to_string();
|
|
let lat = parts[1].trim().parse::<f64>();
|
|
let lon = parts[2].trim().parse::<f64>();
|
|
let tz = parts[3].trim().parse::<i32>();
|
|
if let (Ok(lat), Ok(lon), Ok(tz)) = (lat, lon, tz) {
|
|
if !name.is_empty() {
|
|
out.push(CityPreset {
|
|
name,
|
|
lat,
|
|
lon,
|
|
tz_offset_minutes: tz,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
impl EventEmitter<TreeEvent> for TahuantinsuyuTree {}
|
|
|
|
impl TahuantinsuyuTree {
|
|
pub fn new(store: Store, cx: &mut Context<'_, Self>) -> Self {
|
|
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
|
|
|
let inner = cx.new(|cx| TreeView::new("tahuantinsuyu-tree", cx));
|
|
cx.subscribe(&inner, |this: &mut Self, _, ev, cx| {
|
|
this.on_inner(ev, cx);
|
|
})
|
|
.detach();
|
|
|
|
let search_input = cx.new(|cx| {
|
|
TextInput::new(String::new(), cx)
|
|
.with_placeholder(SharedString::from("Buscar nombre…"))
|
|
});
|
|
cx.subscribe(
|
|
&search_input,
|
|
|this: &mut Self, _, ev: &TextInputEvent, cx| match ev {
|
|
TextInputEvent::Confirmed(value) => {
|
|
this.set_search_filter(value.clone(), cx);
|
|
}
|
|
TextInputEvent::Cancelled => {
|
|
this.set_search_filter(String::new(), cx);
|
|
}
|
|
},
|
|
)
|
|
.detach();
|
|
|
|
let mut me = Self {
|
|
store,
|
|
inner,
|
|
expanded: HashSet::new(),
|
|
menu: None,
|
|
modal: None,
|
|
city_picker_open: false,
|
|
city_atlas: default_city_presets(),
|
|
search_filter: String::new(),
|
|
search_input,
|
|
};
|
|
me.refresh(cx);
|
|
me
|
|
}
|
|
|
|
/// Reemplaza el atlas de ciudades del dropdown. La app llama esto
|
|
/// al boot si encuentra un archivo TSV custom en disco.
|
|
pub fn set_city_atlas(&mut self, atlas: Vec<CityPreset>, cx: &mut Context<'_, Self>) {
|
|
if !atlas.is_empty() {
|
|
self.city_atlas = atlas;
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
pub fn refresh(&mut self, cx: &mut Context<'_, Self>) {
|
|
let mut rows = Vec::new();
|
|
self.append_groups(None, 0, &mut rows);
|
|
self.append_contacts(None, 0, &mut rows);
|
|
self.inner.update(cx, |t, cx| t.set_rows(rows, cx));
|
|
}
|
|
|
|
/// Actualiza el filtro de búsqueda — texto vacío = sin filtro.
|
|
/// Cuando hay filtro, expande automáticamente los ancestros que
|
|
/// contienen matches para que el usuario vea los resultados sin
|
|
/// tener que clickear chevrons.
|
|
fn set_search_filter(&mut self, filter: String, cx: &mut Context<'_, Self>) {
|
|
self.search_filter = filter.trim().to_lowercase();
|
|
if !self.search_filter.is_empty() {
|
|
self.auto_expand_matches();
|
|
}
|
|
self.refresh(cx);
|
|
}
|
|
|
|
/// Pre-expande todos los groups + contacts que contienen al menos
|
|
/// un descendiente cuyo nombre matchee el filtro. Hace una pasada
|
|
/// recursiva agregando ids al `expanded` set.
|
|
fn auto_expand_matches(&mut self) {
|
|
fn walk_group(this: &mut TahuantinsuyuTree, group_id: GroupId) -> bool {
|
|
let mut any_match = false;
|
|
// Sub-groups recursivamente.
|
|
if let Ok(children) = this.store.list_groups(Some(group_id)) {
|
|
for g in children {
|
|
let name_match = g.name.to_lowercase().contains(&this.search_filter);
|
|
let child_match = walk_group(this, g.id);
|
|
if name_match || child_match {
|
|
this.expanded.insert(format!("{}{}", PREFIX_GROUP, g.id));
|
|
any_match = true;
|
|
}
|
|
}
|
|
}
|
|
// Contacts directos.
|
|
if let Ok(contacts) = this.store.list_contacts(Some(group_id)) {
|
|
for c in contacts {
|
|
let name_match = c.name.to_lowercase().contains(&this.search_filter);
|
|
let chart_match = contact_has_matching_chart(this, c.id);
|
|
if name_match || chart_match {
|
|
this.expanded.insert(format!("{}{}", PREFIX_CONTACT, c.id));
|
|
any_match = true;
|
|
}
|
|
}
|
|
}
|
|
any_match
|
|
}
|
|
fn contact_has_matching_chart(this: &TahuantinsuyuTree, contact_id: ContactId) -> bool {
|
|
this.store
|
|
.list_charts(contact_id)
|
|
.map(|charts| {
|
|
charts
|
|
.iter()
|
|
.any(|h| h.label.to_lowercase().contains(&this.search_filter))
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
// Top-level groups + contacts directos en raíz.
|
|
if let Ok(groups) = self.store.list_groups(None) {
|
|
for g in groups {
|
|
let name_match = g.name.to_lowercase().contains(&self.search_filter);
|
|
let child_match = walk_group(self, g.id);
|
|
if name_match || child_match {
|
|
self.expanded.insert(format!("{}{}", PREFIX_GROUP, g.id));
|
|
}
|
|
}
|
|
}
|
|
if let Ok(contacts) = self.store.list_contacts(None) {
|
|
for c in contacts {
|
|
let name_match = c.name.to_lowercase().contains(&self.search_filter);
|
|
let chart_match = contact_has_matching_chart(self, c.id);
|
|
if name_match || chart_match {
|
|
self.expanded.insert(format!("{}{}", PREFIX_CONTACT, c.id));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `true` si la jerarquía bajo `group_id` (recursivo) tiene
|
|
/// algún descendiente que matchee el filtro de búsqueda.
|
|
fn group_has_match(&self, group_id: GroupId) -> bool {
|
|
if let Ok(sub) = self.store.list_groups(Some(group_id)) {
|
|
for g in &sub {
|
|
if g.name.to_lowercase().contains(&self.search_filter) {
|
|
return true;
|
|
}
|
|
if self.group_has_match(g.id) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
if let Ok(contacts) = self.store.list_contacts(Some(group_id)) {
|
|
for c in &contacts {
|
|
if c.name.to_lowercase().contains(&self.search_filter) {
|
|
return true;
|
|
}
|
|
if let Ok(charts) = self.store.list_charts(c.id) {
|
|
if charts
|
|
.iter()
|
|
.any(|h| h.label.to_lowercase().contains(&self.search_filter))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// `true` si el contacto tiene una carta cuyo label matchee.
|
|
fn contact_has_match(&self, contact_id: ContactId) -> bool {
|
|
if let Ok(charts) = self.store.list_charts(contact_id) {
|
|
return charts
|
|
.iter()
|
|
.any(|h| h.label.to_lowercase().contains(&self.search_filter));
|
|
}
|
|
false
|
|
}
|
|
|
|
fn append_groups(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
|
|
let groups = match self.store.list_groups(parent) {
|
|
Ok(v) => v,
|
|
Err(_) => return,
|
|
};
|
|
for g in groups {
|
|
// Filtro: incluir si el group matchea por nombre o tiene
|
|
// algún descendiente matching.
|
|
if !self.search_filter.is_empty() {
|
|
let name_match = g.name.to_lowercase().contains(&self.search_filter);
|
|
if !name_match && !self.group_has_match(g.id) {
|
|
continue;
|
|
}
|
|
}
|
|
let id_str = format!("{}{}", PREFIX_GROUP, g.id);
|
|
let expanded = self.expanded.contains(&id_str);
|
|
out.push(TreeRow {
|
|
id: RowId::new(id_str.clone()),
|
|
label: g.name.clone(),
|
|
depth,
|
|
kind: RowKind::Branch,
|
|
expanded,
|
|
icon: Some("📁".into()),
|
|
});
|
|
if expanded {
|
|
self.append_groups(Some(g.id), depth + 1, out);
|
|
self.append_contacts(Some(g.id), depth + 1, out);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn append_contacts(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
|
|
let contacts = match self.store.list_contacts(parent) {
|
|
Ok(v) => v,
|
|
Err(_) => return,
|
|
};
|
|
for c in contacts {
|
|
if !self.search_filter.is_empty() {
|
|
let name_match = c.name.to_lowercase().contains(&self.search_filter);
|
|
if !name_match && !self.contact_has_match(c.id) {
|
|
continue;
|
|
}
|
|
}
|
|
let id_str = format!("{}{}", PREFIX_CONTACT, c.id);
|
|
let expanded = self.expanded.contains(&id_str);
|
|
out.push(TreeRow {
|
|
id: RowId::new(id_str.clone()),
|
|
label: c.name.clone(),
|
|
depth,
|
|
kind: RowKind::Branch,
|
|
expanded,
|
|
icon: Some("🜨".into()),
|
|
});
|
|
if expanded {
|
|
self.append_charts(c.id, depth + 1, out);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn append_charts(&self, contact: ContactId, depth: u32, out: &mut Vec<TreeRow>) {
|
|
let charts = match self.store.list_charts(contact) {
|
|
Ok(v) => v,
|
|
Err(_) => return,
|
|
};
|
|
for h in charts {
|
|
if !self.search_filter.is_empty()
|
|
&& !h.label.to_lowercase().contains(&self.search_filter)
|
|
{
|
|
continue;
|
|
}
|
|
let id_str = format!("{}{}", PREFIX_CHART, h.id);
|
|
out.push(TreeRow {
|
|
id: RowId::new(id_str),
|
|
label: h.label.clone(),
|
|
depth,
|
|
kind: RowKind::Leaf,
|
|
expanded: false,
|
|
icon: Some("✦".into()),
|
|
});
|
|
}
|
|
}
|
|
|
|
fn on_inner(&mut self, ev: &InnerTreeEvent, cx: &mut Context<'_, Self>) {
|
|
match ev {
|
|
InnerTreeEvent::ChevronToggled(id) => {
|
|
let s = id.as_str().to_string();
|
|
if !self.expanded.remove(&s) {
|
|
self.expanded.insert(s);
|
|
}
|
|
self.refresh(cx);
|
|
}
|
|
InnerTreeEvent::RowClicked(id) => {
|
|
if self.menu.is_some() {
|
|
self.menu = None;
|
|
cx.notify();
|
|
}
|
|
if let Some(sel) = parse_row(id) {
|
|
cx.emit(TreeEvent::Selected(sel));
|
|
}
|
|
}
|
|
InnerTreeEvent::RowDoubleClicked(id) => {
|
|
if let Some(sel) = parse_row(id) {
|
|
cx.emit(TreeEvent::Opened(sel));
|
|
}
|
|
}
|
|
InnerTreeEvent::ContextMenuRequested { id, position } => {
|
|
let target = match id.as_ref().and_then(parse_row) {
|
|
Some(sel) => MenuTarget::from_selection(&sel),
|
|
None => MenuTarget::Root,
|
|
};
|
|
self.menu = Some(MenuState {
|
|
target,
|
|
position: *position,
|
|
});
|
|
cx.notify();
|
|
}
|
|
InnerTreeEvent::ActiveChanged(_) => {}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Acciones del menú
|
|
// -----------------------------------------------------------------
|
|
|
|
fn close_menu(&mut self, cx: &mut Context<'_, Self>) {
|
|
if self.menu.take().is_some() {
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn close_modal(&mut self, cx: &mut Context<'_, Self>) {
|
|
if self.modal.take().is_some() {
|
|
self.city_picker_open = false;
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn toggle_city_picker(&mut self, cx: &mut Context<'_, Self>) {
|
|
self.city_picker_open = !self.city_picker_open;
|
|
cx.notify();
|
|
}
|
|
|
|
/// Aplica un city preset al ChartForm activo (CreateChart o
|
|
/// EditChart). Setea place, lat, lon, tz_offset_min vía
|
|
/// `TextInput::set_text` y cierra el picker.
|
|
fn apply_city_preset(&mut self, preset: &CityPreset, cx: &mut Context<'_, Self>) {
|
|
let form = match self.modal.as_mut() {
|
|
Some(Modal::CreateChart { form, .. }) => form,
|
|
Some(Modal::EditChart { form, .. }) => form,
|
|
_ => {
|
|
self.city_picker_open = false;
|
|
cx.notify();
|
|
return;
|
|
}
|
|
};
|
|
let place = form.place.clone();
|
|
let lat = form.lat.clone();
|
|
let lon = form.lon.clone();
|
|
let tz = form.tz_offset_min.clone();
|
|
let name = preset.name.clone();
|
|
let lat_val = preset.lat;
|
|
let lon_val = preset.lon;
|
|
let tz_val = preset.tz_offset_minutes;
|
|
place.update(cx, |i, cx| i.set_text(name, cx));
|
|
lat.update(cx, |i, cx| i.set_text(format!("{}", lat_val), cx));
|
|
lon.update(cx, |i, cx| i.set_text(format!("{}", lon_val), cx));
|
|
tz.update(cx, |i, cx| i.set_text(tz_val.to_string(), cx));
|
|
self.city_picker_open = false;
|
|
cx.notify();
|
|
}
|
|
|
|
fn open_create_group(
|
|
&mut self,
|
|
parent: Option<GroupId>,
|
|
window: &mut Window,
|
|
cx: &mut Context<'_, Self>,
|
|
) {
|
|
let input = self.make_input("Nombre del grupo", "", window, cx);
|
|
self.modal = Some(Modal::CreateGroup { parent, input });
|
|
self.close_menu(cx);
|
|
}
|
|
|
|
fn open_create_contact(
|
|
&mut self,
|
|
group: Option<GroupId>,
|
|
window: &mut Window,
|
|
cx: &mut Context<'_, Self>,
|
|
) {
|
|
let input = self.make_input("Nombre del contacto", "", window, cx);
|
|
self.modal = Some(Modal::CreateContact { group, input });
|
|
self.close_menu(cx);
|
|
}
|
|
|
|
fn open_edit_chart(
|
|
&mut self,
|
|
id: ChartId,
|
|
window: &mut Window,
|
|
cx: &mut Context<'_, Self>,
|
|
) {
|
|
// Cargar la carta existente; si no se puede, fallamos en silencio.
|
|
let chart = match self.store.get_chart(id) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
eprintln!("[tree] open_edit_chart {}: {}", id, e);
|
|
return;
|
|
}
|
|
};
|
|
let bd = &chart.birth_data;
|
|
let form = ChartForm {
|
|
name: self.make_input("Etiqueta de la carta", &chart.label, window, cx),
|
|
place: self.make_input(
|
|
"Lugar (ciudad, país)",
|
|
bd.birthplace_label.as_deref().unwrap_or(""),
|
|
window,
|
|
cx,
|
|
),
|
|
year: self.make_input("Año", &bd.year.to_string(), window, cx),
|
|
month: self.make_input("Mes", &bd.month.to_string(), window, cx),
|
|
day: self.make_input("Día", &bd.day.to_string(), window, cx),
|
|
hour: self.make_input("Hora (0-23)", &bd.hour.to_string(), window, cx),
|
|
minute: self.make_input("Minuto", &bd.minute.to_string(), window, cx),
|
|
tz_offset_min: self.make_input(
|
|
"TZ offset (min)",
|
|
&bd.tz_offset_minutes.to_string(),
|
|
window,
|
|
cx,
|
|
),
|
|
lat: self.make_input("Latitud (°)", &format!("{}", bd.latitude_deg), window, cx),
|
|
lon: self.make_input(
|
|
"Longitud (°)",
|
|
&format!("{}", bd.longitude_deg),
|
|
window,
|
|
cx,
|
|
),
|
|
alt: self.make_input("Altitud (m)", &format!("{}", bd.altitude_m), window, cx),
|
|
};
|
|
form.name.update(cx, |i, _| i.request_focus(window));
|
|
self.modal = Some(Modal::EditChart {
|
|
id,
|
|
form,
|
|
error: None,
|
|
});
|
|
self.close_menu(cx);
|
|
}
|
|
|
|
fn open_create_chart(
|
|
&mut self,
|
|
contact: ContactId,
|
|
window: &mut Window,
|
|
cx: &mut Context<'_, Self>,
|
|
) {
|
|
// Pre-cargamos el nombre del contacto en el campo "Sujeto" del
|
|
// form como conveniencia — la mayoría de las cartas se nombran
|
|
// igual que la persona.
|
|
let subject_name = self
|
|
.store
|
|
.list_contacts(None)
|
|
.ok()
|
|
.and_then(|all| {
|
|
// Buscamos linealmente — list_contacts solo lista hijos
|
|
// directos del group, no nos sirve para encontrar un
|
|
// contact arbitrario. Para fase 2 nos quedamos con
|
|
// "Carta natal" como label genérico si no podemos
|
|
// resolver. Resolver-por-id viene si lo necesitamos.
|
|
let _ = all;
|
|
None::<String>
|
|
})
|
|
.unwrap_or_else(|| "Carta natal".into());
|
|
|
|
let form = ChartForm {
|
|
name: self.make_input("Etiqueta de la carta", &subject_name, window, cx),
|
|
place: self.make_input("Lugar (ciudad, país)", "", window, cx),
|
|
year: self.make_input("Año", "1987", window, cx),
|
|
month: self.make_input("Mes", "3", window, cx),
|
|
day: self.make_input("Día", "14", window, cx),
|
|
hour: self.make_input("Hora (0-23)", "5", window, cx),
|
|
minute: self.make_input("Minuto", "22", window, cx),
|
|
tz_offset_min: self.make_input("TZ offset (min)", "-240", window, cx),
|
|
lat: self.make_input("Latitud (°)", "10.4806", window, cx),
|
|
lon: self.make_input("Longitud (°)", "-66.9036", window, cx),
|
|
alt: self.make_input("Altitud (m)", "900", window, cx),
|
|
};
|
|
// El primer field es el que recibe focus.
|
|
form.name.update(cx, |i, _| i.request_focus(window));
|
|
|
|
self.modal = Some(Modal::CreateChart {
|
|
contact,
|
|
form,
|
|
error: None,
|
|
});
|
|
self.close_menu(cx);
|
|
}
|
|
|
|
fn open_rename(&mut self, target: MenuTarget, window: &mut Window, cx: &mut Context<'_, Self>) {
|
|
let modal = match target {
|
|
MenuTarget::Group(id) => {
|
|
let current = self
|
|
.store
|
|
.list_groups(None)
|
|
.ok()
|
|
.and_then(|all| find_group_name(&all, &self.store, id))
|
|
.unwrap_or_default();
|
|
Modal::RenameGroup {
|
|
id,
|
|
input: self.make_input("Nuevo nombre", ¤t, window, cx),
|
|
}
|
|
}
|
|
MenuTarget::Contact(id) => {
|
|
let current = self
|
|
.store
|
|
.list_contacts(None)
|
|
.ok()
|
|
.and_then(|all| find_contact_name(&all, &self.store, id))
|
|
.unwrap_or_default();
|
|
Modal::RenameContact {
|
|
id,
|
|
input: self.make_input("Nuevo nombre", ¤t, window, cx),
|
|
}
|
|
}
|
|
MenuTarget::Chart(id) => {
|
|
let current = self
|
|
.store
|
|
.get_chart(id)
|
|
.ok()
|
|
.map(|c| c.label)
|
|
.unwrap_or_default();
|
|
Modal::RenameChart {
|
|
id,
|
|
input: self.make_input("Nueva etiqueta", ¤t, window, cx),
|
|
}
|
|
}
|
|
MenuTarget::Root => return,
|
|
};
|
|
self.modal = Some(modal);
|
|
self.close_menu(cx);
|
|
}
|
|
|
|
/// Crea un `TextInput` con focus y suscripción a Confirmed/Cancelled.
|
|
/// La closure decide qué hacer con cada evento — guardamos la subscripción
|
|
/// detached para que viva mientras el modal exista.
|
|
fn make_input(
|
|
&self,
|
|
placeholder: &str,
|
|
initial: &str,
|
|
window: &mut Window,
|
|
cx: &mut Context<'_, Self>,
|
|
) -> Entity<TextInput> {
|
|
let placeholder = placeholder.to_string();
|
|
let input = cx.new(|cx| {
|
|
TextInput::new(initial.to_string(), cx)
|
|
.with_placeholder(SharedString::from(placeholder.clone()))
|
|
});
|
|
cx.subscribe(&input, |this: &mut Self, _, ev: &TextInputEvent, cx| {
|
|
this.on_input_event(ev, cx);
|
|
})
|
|
.detach();
|
|
input.update(cx, |i, _| i.request_focus(window));
|
|
input
|
|
}
|
|
|
|
fn on_input_event(&mut self, ev: &TextInputEvent, cx: &mut Context<'_, Self>) {
|
|
match ev {
|
|
TextInputEvent::Cancelled => self.close_modal(cx),
|
|
TextInputEvent::Confirmed(value) => self.submit_modal(value.clone(), cx),
|
|
}
|
|
}
|
|
|
|
fn submit_modal(&mut self, value: String, cx: &mut Context<'_, Self>) {
|
|
let trimmed = value.trim().to_string();
|
|
// Tomamos ownership del modal — si el submit falla en mitad,
|
|
// lo restablecemos. Esto evita un borrow-mut sobre self.modal.
|
|
let modal = match self.modal.take() {
|
|
Some(m) => m,
|
|
None => return,
|
|
};
|
|
match modal {
|
|
Modal::RenameGroup { id, input } => {
|
|
if !trimmed.is_empty() {
|
|
let _ = self.store.rename_group(id, &trimmed);
|
|
}
|
|
drop(input);
|
|
self.after_mutation(cx);
|
|
}
|
|
Modal::RenameContact { id, input } => {
|
|
if !trimmed.is_empty() {
|
|
let _ = self.store.rename_contact(id, &trimmed);
|
|
}
|
|
drop(input);
|
|
self.after_mutation(cx);
|
|
}
|
|
Modal::RenameChart { id, input } => {
|
|
if !trimmed.is_empty() {
|
|
let _ = self.store.rename_chart(id, &trimmed);
|
|
}
|
|
drop(input);
|
|
self.after_mutation(cx);
|
|
}
|
|
Modal::CreateGroup { parent, input } => {
|
|
if !trimmed.is_empty() {
|
|
let _ = self.store.create_group(parent, &trimmed, None);
|
|
}
|
|
drop(input);
|
|
self.after_mutation(cx);
|
|
}
|
|
Modal::CreateContact { group, input } => {
|
|
if !trimmed.is_empty() {
|
|
let _ = self.store.create_contact(group, &trimmed, None);
|
|
}
|
|
drop(input);
|
|
self.after_mutation(cx);
|
|
}
|
|
Modal::CreateChart {
|
|
contact,
|
|
form,
|
|
error: _,
|
|
} => {
|
|
// `value` viene del campo que disparó Enter. Para el
|
|
// form, ignoramos el value puntual y leemos todos los
|
|
// campos del form.
|
|
let _ = value;
|
|
match build_chart_from_form(&form, cx) {
|
|
Ok((birth, label)) => {
|
|
match self.store.create_chart(
|
|
contact,
|
|
ChartKind::Natal,
|
|
&label,
|
|
&birth,
|
|
&StoredChartConfig::default(),
|
|
None,
|
|
) {
|
|
Ok(_) => {
|
|
// Auto-expand del contact para que se vea
|
|
// la carta recién creada.
|
|
self.expanded
|
|
.insert(format!("{}{}", PREFIX_CONTACT, contact));
|
|
self.after_mutation(cx);
|
|
}
|
|
Err(e) => {
|
|
self.modal = Some(Modal::CreateChart {
|
|
contact,
|
|
form,
|
|
error: Some(SharedString::from(format!("Store: {}", e))),
|
|
});
|
|
cx.notify();
|
|
}
|
|
}
|
|
}
|
|
Err(msg) => {
|
|
self.modal = Some(Modal::CreateChart {
|
|
contact,
|
|
form,
|
|
error: Some(SharedString::from(msg)),
|
|
});
|
|
cx.notify();
|
|
}
|
|
}
|
|
}
|
|
Modal::EditChart {
|
|
id,
|
|
form,
|
|
error: _,
|
|
} => {
|
|
let _ = value;
|
|
// Para preservar el ChartConfig original (zodiac, house
|
|
// system, bodies, etc.) leemos la carta actual y solo
|
|
// sobrescribimos label + birth_data. El editor no toca
|
|
// config — eso se haría en un futuro panel de "Config
|
|
// de carta".
|
|
let existing = match self.store.get_chart(id) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
self.modal = Some(Modal::EditChart {
|
|
id,
|
|
form,
|
|
error: Some(SharedString::from(format!("Store: {}", e))),
|
|
});
|
|
cx.notify();
|
|
return;
|
|
}
|
|
};
|
|
match build_chart_from_form(&form, cx) {
|
|
Ok((birth, label)) => {
|
|
match self.store.update_chart(id, &label, &birth, &existing.config) {
|
|
Ok(_) => {
|
|
drop(form);
|
|
self.after_mutation(cx);
|
|
}
|
|
Err(e) => {
|
|
self.modal = Some(Modal::EditChart {
|
|
id,
|
|
form,
|
|
error: Some(SharedString::from(format!("Store: {}", e))),
|
|
});
|
|
cx.notify();
|
|
}
|
|
}
|
|
}
|
|
Err(msg) => {
|
|
self.modal = Some(Modal::EditChart {
|
|
id,
|
|
form,
|
|
error: Some(SharedString::from(msg)),
|
|
});
|
|
cx.notify();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn after_mutation(&mut self, cx: &mut Context<'_, Self>) {
|
|
self.modal = None;
|
|
self.refresh(cx);
|
|
cx.emit(TreeEvent::HierarchyChanged);
|
|
cx.notify();
|
|
}
|
|
|
|
fn confirm_and_delete(
|
|
&mut self,
|
|
target: MenuTarget,
|
|
window: &mut Window,
|
|
cx: &mut Context<'_, Self>,
|
|
) {
|
|
let (label, kind) = match target {
|
|
MenuTarget::Group(_) => ("este grupo (incluye sus subgrupos y contactos)", "group"),
|
|
MenuTarget::Contact(_) => ("este contacto (incluye sus cartas)", "contact"),
|
|
MenuTarget::Chart(_) => ("esta carta", "chart"),
|
|
MenuTarget::Root => return,
|
|
};
|
|
let answer = window.prompt(
|
|
PromptLevel::Warning,
|
|
&format!("¿Borrar {}?", label),
|
|
None,
|
|
&["Borrar", "Cancelar"],
|
|
cx,
|
|
);
|
|
let target_clone = target.clone();
|
|
cx.spawn(async move |this, cx| {
|
|
let Ok(idx) = answer.await else { return };
|
|
if idx != 0 {
|
|
return;
|
|
}
|
|
let _ = this.update(cx, |this, cx| {
|
|
match target_clone {
|
|
MenuTarget::Group(id) => {
|
|
let _ = this.store.delete_group(id);
|
|
}
|
|
MenuTarget::Contact(id) => {
|
|
let _ = this.store.delete_contact(id);
|
|
}
|
|
MenuTarget::Chart(id) => {
|
|
let _ = this.store.delete_chart(id);
|
|
}
|
|
MenuTarget::Root => {}
|
|
}
|
|
this.after_mutation(cx);
|
|
});
|
|
})
|
|
.detach();
|
|
let _ = kind;
|
|
self.close_menu(cx);
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Form helpers
|
|
// =====================================================================
|
|
|
|
fn build_chart_from_form(
|
|
form: &ChartForm,
|
|
cx: &mut Context<'_, TahuantinsuyuTree>,
|
|
) -> Result<(StoredBirthData, String), String> {
|
|
let name = form.name.read(cx).text().trim().to_string();
|
|
let place = form.place.read(cx).text().trim().to_string();
|
|
let year: i32 = parse_field(form.year.read(cx).text(), "Año")?;
|
|
let month: u32 = parse_field(form.month.read(cx).text(), "Mes")?;
|
|
let day: u32 = parse_field(form.day.read(cx).text(), "Día")?;
|
|
let hour: u32 = parse_field(form.hour.read(cx).text(), "Hora")?;
|
|
let minute: u32 = parse_field(form.minute.read(cx).text(), "Minuto")?;
|
|
let tz_offset_minutes: i32 = parse_field(form.tz_offset_min.read(cx).text(), "TZ offset")?;
|
|
let latitude_deg: f64 = parse_field(form.lat.read(cx).text(), "Latitud")?;
|
|
let longitude_deg: f64 = parse_field(form.lon.read(cx).text(), "Longitud")?;
|
|
let altitude_m: f64 = parse_field(form.alt.read(cx).text(), "Altitud")?;
|
|
|
|
if !(1..=12).contains(&month) {
|
|
return Err(format!("Mes fuera de rango: {}", month));
|
|
}
|
|
if !(1..=31).contains(&day) {
|
|
return Err(format!("Día fuera de rango: {}", day));
|
|
}
|
|
if hour > 23 {
|
|
return Err(format!("Hora fuera de rango: {}", hour));
|
|
}
|
|
if minute > 59 {
|
|
return Err(format!("Minuto fuera de rango: {}", minute));
|
|
}
|
|
|
|
let label = if name.is_empty() {
|
|
"Carta natal".to_string()
|
|
} else {
|
|
name
|
|
};
|
|
let birth = StoredBirthData {
|
|
year,
|
|
month,
|
|
day,
|
|
hour,
|
|
minute,
|
|
second: 0.0,
|
|
tz_offset_minutes,
|
|
latitude_deg,
|
|
longitude_deg,
|
|
altitude_m,
|
|
time_certainty: TimeCertainty::Exact,
|
|
subject_name: None,
|
|
birthplace_label: if place.is_empty() { None } else { Some(place) },
|
|
};
|
|
Ok((birth, label))
|
|
}
|
|
|
|
fn parse_field<T: std::str::FromStr>(s: &str, field: &str) -> Result<T, String> {
|
|
s.trim()
|
|
.parse::<T>()
|
|
.map_err(|_| format!("Campo \"{}\" inválido: {:?}", field, s))
|
|
}
|
|
|
|
// =====================================================================
|
|
// Lookups auxiliares (DFS por la jerarquía)
|
|
// =====================================================================
|
|
|
|
fn find_group_name(roots: &[tahuantinsuyu_model::Group], store: &Store, id: GroupId) -> Option<String> {
|
|
for g in roots {
|
|
if g.id == id {
|
|
return Some(g.name.clone());
|
|
}
|
|
if let Ok(children) = store.list_groups(Some(g.id)) {
|
|
if let Some(n) = find_group_name(&children, store, id) {
|
|
return Some(n);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn find_contact_name(
|
|
in_group: &[tahuantinsuyu_model::Contact],
|
|
store: &Store,
|
|
id: ContactId,
|
|
) -> Option<String> {
|
|
for c in in_group {
|
|
if c.id == id {
|
|
return Some(c.name.clone());
|
|
}
|
|
}
|
|
// Buscar también en todos los groups recursivamente.
|
|
if let Ok(groups) = store.list_groups(None) {
|
|
if let Some(n) = find_contact_in_groups(&groups, store, id) {
|
|
return Some(n);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn find_contact_in_groups(
|
|
groups: &[tahuantinsuyu_model::Group],
|
|
store: &Store,
|
|
id: ContactId,
|
|
) -> Option<String> {
|
|
for g in groups {
|
|
if let Ok(cs) = store.list_contacts(Some(g.id)) {
|
|
for c in &cs {
|
|
if c.id == id {
|
|
return Some(c.name.clone());
|
|
}
|
|
}
|
|
}
|
|
if let Ok(children) = store.list_groups(Some(g.id)) {
|
|
if let Some(n) = find_contact_in_groups(&children, store, id) {
|
|
return Some(n);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn parse_row(id: &RowId) -> Option<TreeSelection> {
|
|
let s = id.as_str();
|
|
if let Some(rest) = s.strip_prefix(PREFIX_GROUP) {
|
|
return rest.parse().ok().map(TreeSelection::Group);
|
|
}
|
|
if let Some(rest) = s.strip_prefix(PREFIX_CONTACT) {
|
|
return rest.parse().ok().map(TreeSelection::Contact);
|
|
}
|
|
if let Some(rest) = s.strip_prefix(PREFIX_CHART) {
|
|
return rest.parse().ok().map(TreeSelection::Chart);
|
|
}
|
|
None
|
|
}
|
|
|
|
// =====================================================================
|
|
// Render
|
|
// =====================================================================
|
|
|
|
const MENU_WIDTH: f32 = 220.0;
|
|
|
|
impl Render for TahuantinsuyuTree {
|
|
fn render(&mut self, _w: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
|
|
let theme = Theme::global(cx).clone();
|
|
let search_bar = div()
|
|
.px(px(6.0))
|
|
.py(px(4.0))
|
|
.border_b_1()
|
|
.border_color(theme.border)
|
|
.child(self.search_input.clone());
|
|
|
|
let mut root = div()
|
|
.id("tahuantinsuyu-tree-root")
|
|
.size_full()
|
|
.relative()
|
|
.bg(theme.bg_panel.clone())
|
|
.flex()
|
|
.flex_col()
|
|
.child(search_bar)
|
|
.child(div().flex_grow().min_h(px(0.0)).child(self.inner.clone()));
|
|
|
|
if let Some(menu) = self.menu.clone() {
|
|
root = root.child(self.render_menu(&theme, menu, cx));
|
|
}
|
|
if self.modal.is_some() {
|
|
root = root.child(self.render_modal(&theme, cx));
|
|
}
|
|
root
|
|
}
|
|
}
|
|
|
|
impl TahuantinsuyuTree {
|
|
fn render_menu(
|
|
&self,
|
|
theme: &Theme,
|
|
menu: MenuState,
|
|
cx: &mut Context<'_, Self>,
|
|
) -> impl IntoElement {
|
|
let mut items = div()
|
|
.flex()
|
|
.flex_col()
|
|
.py(px(4.0))
|
|
.min_w(px(MENU_WIDTH))
|
|
.bg(theme.bg_panel_alt.clone())
|
|
.border_1()
|
|
.border_color(theme.border_strong)
|
|
.rounded(px(6.0));
|
|
|
|
match menu.target.clone() {
|
|
MenuTarget::Root => {
|
|
items = items.child(menu_item("tts-menu-new-group", "Nuevo grupo", theme).on_click(
|
|
cx.listener(|this, _: &ClickEvent, w, cx| {
|
|
this.open_create_group(None, w, cx);
|
|
}),
|
|
));
|
|
items = items.child(
|
|
menu_item("tts-menu-new-contact-root", "Nuevo contacto", theme).on_click(
|
|
cx.listener(|this, _: &ClickEvent, w, cx| {
|
|
this.open_create_contact(None, w, cx);
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
MenuTarget::Group(id) => {
|
|
items = items.child(
|
|
menu_item("tts-menu-new-subgroup", "Nuevo subgrupo", theme).on_click(
|
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
|
this.open_create_group(Some(id), w, cx);
|
|
}),
|
|
),
|
|
);
|
|
items = items.child(
|
|
menu_item("tts-menu-new-contact", "Nuevo contacto", theme).on_click(
|
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
|
this.open_create_contact(Some(id), w, cx);
|
|
}),
|
|
),
|
|
);
|
|
items = items.child(separator(theme));
|
|
let t = menu.target.clone();
|
|
items = items.child(menu_item("tts-menu-rename-g", "Renombrar…", theme).on_click(
|
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
|
this.open_rename(t.clone(), w, cx);
|
|
}),
|
|
));
|
|
let t = menu.target.clone();
|
|
items = items.child(menu_item("tts-menu-delete-g", "Borrar…", theme).on_click(
|
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
|
this.confirm_and_delete(t.clone(), w, cx);
|
|
}),
|
|
));
|
|
}
|
|
MenuTarget::Contact(id) => {
|
|
items = items.child(menu_item("tts-menu-new-chart", "Nueva carta…", theme).on_click(
|
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
|
this.open_create_chart(id, w, cx);
|
|
}),
|
|
));
|
|
items = items.child(separator(theme));
|
|
let t = menu.target.clone();
|
|
items = items.child(menu_item("tts-menu-rename-c", "Renombrar…", theme).on_click(
|
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
|
this.open_rename(t.clone(), w, cx);
|
|
}),
|
|
));
|
|
let t = menu.target.clone();
|
|
items = items.child(menu_item("tts-menu-delete-c", "Borrar…", theme).on_click(
|
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
|
this.confirm_and_delete(t.clone(), w, cx);
|
|
}),
|
|
));
|
|
}
|
|
MenuTarget::Chart(id) => {
|
|
items = items.child(menu_item("tts-menu-open-h", "Abrir", theme).on_click(
|
|
cx.listener(move |this, _: &ClickEvent, _w, cx| {
|
|
cx.emit(TreeEvent::Opened(TreeSelection::Chart(id)));
|
|
this.close_menu(cx);
|
|
}),
|
|
));
|
|
items = items.child(menu_item("tts-menu-edit-h", "Editar…", theme).on_click(
|
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
|
this.open_edit_chart(id, w, cx);
|
|
}),
|
|
));
|
|
items = items.child(separator(theme));
|
|
let t = menu.target.clone();
|
|
items = items.child(menu_item("tts-menu-rename-h", "Renombrar…", theme).on_click(
|
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
|
this.open_rename(t.clone(), w, cx);
|
|
}),
|
|
));
|
|
let t = menu.target.clone();
|
|
items = items.child(menu_item("tts-menu-delete-h", "Borrar…", theme).on_click(
|
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
|
this.confirm_and_delete(t.clone(), w, cx);
|
|
}),
|
|
));
|
|
}
|
|
}
|
|
|
|
div()
|
|
.absolute()
|
|
.left(menu.position.x)
|
|
.top(menu.position.y)
|
|
.child(items)
|
|
}
|
|
|
|
fn render_modal(&self, theme: &Theme, cx: &mut Context<'_, Self>) -> impl IntoElement {
|
|
let modal = self.modal.as_ref().expect("render_modal sin modal activo");
|
|
let inner = match modal {
|
|
Modal::RenameGroup { input, .. }
|
|
| Modal::RenameContact { input, .. }
|
|
| Modal::RenameChart { input, .. } => {
|
|
modal_box(theme, "Renombrar", input.clone(), "Enter = guardar — Escape = cancelar")
|
|
}
|
|
Modal::CreateGroup { input, .. } => {
|
|
modal_box(theme, "Nuevo grupo", input.clone(), "Enter = crear — Escape = cancelar")
|
|
}
|
|
Modal::CreateContact { input, .. } => modal_box(
|
|
theme,
|
|
"Nuevo contacto",
|
|
input.clone(),
|
|
"Enter = crear — Escape = cancelar",
|
|
),
|
|
Modal::CreateChart { form, error, .. } => render_chart_form(
|
|
theme,
|
|
"Nueva carta natal",
|
|
form,
|
|
error.clone(),
|
|
self.city_picker_open,
|
|
&self.city_atlas,
|
|
cx,
|
|
),
|
|
Modal::EditChart { form, error, .. } => render_chart_form(
|
|
theme,
|
|
"Editar carta natal",
|
|
form,
|
|
error.clone(),
|
|
self.city_picker_open,
|
|
&self.city_atlas,
|
|
cx,
|
|
),
|
|
};
|
|
|
|
div()
|
|
.absolute()
|
|
.top(px(0.0))
|
|
.left(px(0.0))
|
|
.size_full()
|
|
.flex()
|
|
.items_center()
|
|
.justify_center()
|
|
.bg(hsla(0.0, 0.0, 0.0, 0.55))
|
|
.child(inner)
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Helpers de UI
|
|
// =====================================================================
|
|
|
|
fn menu_item(
|
|
id: &'static str,
|
|
label: &'static str,
|
|
theme: &Theme,
|
|
) -> gpui::Stateful<gpui::Div> {
|
|
div()
|
|
.id(id)
|
|
.px(px(12.0))
|
|
.py(px(6.0))
|
|
.text_size(px(12.0))
|
|
.text_color(theme.fg_text)
|
|
.hover(|s| s.bg(theme.bg_row_hover))
|
|
.child(label)
|
|
}
|
|
|
|
fn separator(theme: &Theme) -> gpui::Div {
|
|
div()
|
|
.my(px(3.0))
|
|
.h(px(1.0))
|
|
.w_full()
|
|
.bg(theme.border)
|
|
}
|
|
|
|
fn modal_box(
|
|
theme: &Theme,
|
|
title: &'static str,
|
|
input: Entity<TextInput>,
|
|
hint: &'static str,
|
|
) -> gpui::Div {
|
|
div()
|
|
.min_w(px(380.0))
|
|
.p(px(16.0))
|
|
.flex()
|
|
.flex_col()
|
|
.gap(px(10.0))
|
|
.bg(theme.bg_panel_alt.clone())
|
|
.border_1()
|
|
.border_color(theme.border_strong)
|
|
.rounded(px(8.0))
|
|
.child(
|
|
div()
|
|
.text_size(px(13.0))
|
|
.text_color(theme.fg_text)
|
|
.child(title),
|
|
)
|
|
.child(input)
|
|
.child(
|
|
div()
|
|
.text_size(px(10.0))
|
|
.text_color(theme.fg_muted)
|
|
.child(hint),
|
|
)
|
|
}
|
|
|
|
fn render_chart_form(
|
|
theme: &Theme,
|
|
title: &str,
|
|
form: &ChartForm,
|
|
error: Option<SharedString>,
|
|
// Datos del tree que el form necesita renderizar — recibidos por
|
|
// parámetro porque esta función se llama desde `render()` y la
|
|
// entity del tree ya está leased; un `cx.entity().read(cx)`
|
|
// adentro causa double_lease_panic en gpui.
|
|
picker_open: bool,
|
|
city_atlas: &[CityPreset],
|
|
cx: &mut Context<'_, TahuantinsuyuTree>,
|
|
) -> gpui::Div {
|
|
let labeled = |label: &'static str, input: Entity<TextInput>| -> gpui::Div {
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap(px(2.0))
|
|
.child(
|
|
div()
|
|
.text_size(px(10.0))
|
|
.text_color(theme.fg_muted)
|
|
.child(label),
|
|
)
|
|
.child(input)
|
|
};
|
|
|
|
let date_row = div()
|
|
.flex()
|
|
.flex_row()
|
|
.gap(px(8.0))
|
|
.child(labeled("Año", form.year.clone()))
|
|
.child(labeled("Mes", form.month.clone()))
|
|
.child(labeled("Día", form.day.clone()))
|
|
.child(labeled("Hora", form.hour.clone()))
|
|
.child(labeled("Minuto", form.minute.clone()))
|
|
.child(labeled("TZ (min)", form.tz_offset_min.clone()));
|
|
|
|
let loc_row = div()
|
|
.flex()
|
|
.flex_row()
|
|
.gap(px(8.0))
|
|
.child(labeled("Latitud", form.lat.clone()))
|
|
.child(labeled("Longitud", form.lon.clone()))
|
|
.child(labeled("Altitud (m)", form.alt.clone()));
|
|
|
|
let create_btn = div()
|
|
.id("tts-chart-form-create")
|
|
.px(px(14.0))
|
|
.py(px(8.0))
|
|
.rounded(px(6.0))
|
|
.bg(theme.bg_button())
|
|
.hover(|s| s.bg(theme.bg_button_hover()))
|
|
.text_size(px(12.0))
|
|
.text_color(theme.fg_text)
|
|
.child(SharedString::from(if title.starts_with("Editar") {
|
|
"Guardar cambios"
|
|
} else {
|
|
"Crear carta"
|
|
}))
|
|
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
|
// Disparamos un submit "vacío" — el handler de submit
|
|
// re-lee todos los campos del form. El value que pasamos
|
|
// se ignora dentro del branch CreateChart/EditChart.
|
|
this.submit_modal(String::new(), cx);
|
|
}));
|
|
|
|
let cancel_btn = div()
|
|
.id("tts-chart-form-cancel")
|
|
.px(px(14.0))
|
|
.py(px(8.0))
|
|
.rounded(px(6.0))
|
|
.bg(theme.bg_panel.clone())
|
|
.hover(|s| s.bg(theme.bg_row_hover))
|
|
.text_size(px(12.0))
|
|
.text_color(theme.fg_muted)
|
|
.child("Cancelar")
|
|
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
|
this.close_modal(cx);
|
|
}));
|
|
|
|
// Header del form: title + botón "Ciudad rápida" con dropdown
|
|
// que autocompleta place/lat/lon/tz al elegir un preset.
|
|
let city_btn = div()
|
|
.id("tts-form-city-btn")
|
|
.px(px(10.0))
|
|
.py(px(4.0))
|
|
.rounded(px(4.0))
|
|
.bg(theme.bg_button())
|
|
.hover(|s| s.bg(theme.bg_button_hover()))
|
|
.border_1()
|
|
.border_color(if picker_open {
|
|
theme.accent_strong
|
|
} else {
|
|
theme.border
|
|
})
|
|
.text_size(px(11.0))
|
|
.text_color(theme.fg_text)
|
|
.child("📍 Ciudad rápida ▾")
|
|
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
|
this.toggle_city_picker(cx);
|
|
}));
|
|
let title_row = div()
|
|
.relative()
|
|
.flex()
|
|
.flex_row()
|
|
.items_center()
|
|
.gap(px(12.0))
|
|
.child(
|
|
div()
|
|
.text_size(px(14.0))
|
|
.text_color(theme.fg_text)
|
|
.child(SharedString::from(title.to_string())),
|
|
)
|
|
.child(div().flex_grow())
|
|
.child(city_btn);
|
|
let title_row = if picker_open {
|
|
let popup_id: SharedString = SharedString::from("tts-form-city-popup");
|
|
let mut popup = div()
|
|
.id(gpui::ElementId::from(popup_id))
|
|
.absolute()
|
|
.top(px(36.0))
|
|
.right(px(0.0))
|
|
.min_w(px(240.0))
|
|
.h(px(360.0))
|
|
.py(px(4.0))
|
|
.bg(theme.bg_panel_alt.clone())
|
|
.border_1()
|
|
.border_color(theme.border_strong)
|
|
.rounded(px(6.0))
|
|
.flex()
|
|
.flex_col()
|
|
.overflow_y_scroll();
|
|
for preset in city_atlas.iter().cloned() {
|
|
let row_id: SharedString =
|
|
SharedString::from(format!("tts-city-{}", preset.name));
|
|
let label = preset.name.clone();
|
|
popup = popup.child(
|
|
div()
|
|
.id(gpui::ElementId::from(row_id))
|
|
.px(px(10.0))
|
|
.py(px(4.0))
|
|
.text_size(px(11.0))
|
|
.text_color(theme.fg_text)
|
|
.hover(|s| s.bg(theme.bg_row_hover))
|
|
.child(SharedString::from(label))
|
|
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
|
this.apply_city_preset(&preset, cx);
|
|
})),
|
|
);
|
|
}
|
|
title_row.child(popup)
|
|
} else {
|
|
title_row
|
|
};
|
|
|
|
let mut body = div()
|
|
.min_w(px(640.0))
|
|
.p(px(18.0))
|
|
.flex()
|
|
.flex_col()
|
|
.gap(px(12.0))
|
|
.bg(theme.bg_panel_alt.clone())
|
|
.border_1()
|
|
.border_color(theme.border_strong)
|
|
.rounded(px(8.0))
|
|
.child(title_row)
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.flex_row()
|
|
.gap(px(8.0))
|
|
.child(labeled("Etiqueta", form.name.clone()))
|
|
.child(labeled("Lugar (texto libre)", form.place.clone())),
|
|
)
|
|
.child(date_row)
|
|
.child(loc_row);
|
|
|
|
if let Some(err) = error {
|
|
body = body.child(
|
|
div()
|
|
.px(px(10.0))
|
|
.py(px(6.0))
|
|
.rounded(px(4.0))
|
|
.bg(theme.bg_destructive_hover())
|
|
.text_size(px(11.0))
|
|
.text_color(theme.accent_destructive())
|
|
.child(err),
|
|
);
|
|
}
|
|
|
|
body = body.child(
|
|
div()
|
|
.flex()
|
|
.flex_row()
|
|
.gap(px(8.0))
|
|
.justify_end()
|
|
.child(cancel_btn)
|
|
.child(create_btn),
|
|
);
|
|
|
|
body
|
|
}
|