diff --git a/Cargo.lock b/Cargo.lock index 010c816..fff98f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10918,6 +10918,7 @@ dependencies = [ "tahuantinsuyu-tree", "yahweh-bus", "yahweh-theme", + "yahweh-widget-theme-switcher", ] [[package]] diff --git a/crates/apps/tahuantinsuyu/Cargo.toml b/crates/apps/tahuantinsuyu/Cargo.toml index 82a8ad5..0cd08cb 100644 --- a/crates/apps/tahuantinsuyu/Cargo.toml +++ b/crates/apps/tahuantinsuyu/Cargo.toml @@ -18,6 +18,7 @@ tahuantinsuyu-tree = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-tree" } yahweh-bus = { workspace = true } yahweh-theme = { workspace = true } +yahweh-widget-theme-switcher = { path = "../../modules/ui_engine/widgets/theme-switcher" } gpui = { workspace = true } directories = { workspace = true } serde_json = { workspace = true } diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs index a0fc6b2..020b3ad 100644 --- a/crates/apps/tahuantinsuyu/src/shell.rs +++ b/crates/apps/tahuantinsuyu/src/shell.rs @@ -41,6 +41,7 @@ use tahuantinsuyu_store::Store; use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent}; use yahweh_bus::AppBus; use yahweh_theme::Theme; +use yahweh_widget_theme_switcher::theme_switcher; const TREE_WIDTH: f32 = 280.0; const PANEL_HEIGHT: f32 = 180.0; @@ -252,6 +253,9 @@ impl Shell { }); } } + if module_enabled(&self.module_configs, "midpoints") { + requests.push(PipelineRequest::Midpoints); + } if module_enabled(&self.module_configs, "planetary_return") { let age = self.module_age_or_current("planetary_return"); let body = self @@ -705,7 +709,9 @@ impl Render for Shell { .text_size(px(10.0)) .text_color(theme.fg_muted) .child("estudio de astrología profesional"), - ); + ) + .child(div().flex_grow()) + .child(theme_switcher(cx)); let tree_panel = div() .w(px(TREE_WIDTH)) diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs index 21e0de5..c2aac9b 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs @@ -119,20 +119,47 @@ pub struct CanvasState { drag_jog: Option, } -/// Info del cuerpo bajo el cursor — usado por el render para mostrar -/// un tooltip flotante con detalles. +/// Info del elemento bajo el cursor — usado por el render para mostrar +/// un tooltip flotante con detalles. Cubre body glyphs (módulo + +/// símbolo + grado + casa + retro + dignidad) y cusps de casa (número +/// de la casa + grado del cusp + signo). #[derive(Clone, Debug)] -pub struct HoverInfo { - pub module_id: String, - pub symbol: String, - pub deg: f32, - pub house: Option, - pub retrograde: bool, - pub dignity_marker: Option, - pub annotation: Option, - /// Posición relativa al wheel (en píxeles desde su top-left). - pub local_x: f32, - pub local_y: f32, +pub enum HoverInfo { + Body { + module_id: String, + symbol: String, + deg: f32, + house: Option, + retrograde: bool, + dignity_marker: Option, + annotation: Option, + local_x: f32, + local_y: f32, + }, + HouseCusp { + house_number: u8, + deg: f32, + local_x: f32, + local_y: f32, + }, +} + +impl HoverInfo { + fn local(&self) -> (f32, f32) { + match self { + HoverInfo::Body { local_x, local_y, .. } => (*local_x, *local_y), + HoverInfo::HouseCusp { local_x, local_y, .. } => (*local_x, *local_y), + } + } + + fn key(&self) -> String { + match self { + HoverInfo::Body { + module_id, symbol, .. + } => format!("body:{}:{}", module_id, symbol), + HoverInfo::HouseCusp { house_number, .. } => format!("cusp:{}", house_number), + } + } } impl Default for CanvasState { @@ -273,11 +300,10 @@ impl AstrologyCanvas { cx.notify(); } - /// Hit-test sobre los body glyphs activos. Recibe la posición del - /// mouse en window coords y los bounds del canvas (wheel). Para - /// cada Glyph con LayerKind == Bodies u Outer en el RenderModel - /// actual, calcula su posición pintada y mide distancia. Si está - /// dentro de `threshold_px`, actualiza `state.hover`. + /// Hit-test sobre body glyphs + house cusps. Para bodies: distancia + /// al centro del glyph dentro de threshold. Para cusps: el mouse + /// debe estar cerca del ring de casas Y angularmente cerca del + /// cusp (proximidad a la línea radial). fn on_hover_check( &mut self, position: Point, @@ -293,16 +319,21 @@ impl AstrologyCanvas { let (cx_px, cy_px) = bounds_center(bounds); let mx: f32 = position.x.into(); let my: f32 = position.y.into(); + let ox: f32 = bounds.origin.x.into(); + let oy: f32 = bounds.origin.y.into(); let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0; let radii = Radii::from_outer(r_outer); let asc = render.ascendant_deg; let rot = self.state.view_rotation_deg; - let threshold = 14.0_f32; + let body_threshold = 14.0_f32; let mut best: Option<(f32, HoverInfo)> = None; + + // 1) Body glyphs (incluye natal, overlays, midpoints). for layer in &render.layers { let ring = match layer.kind { LayerKind::Bodies => radii.body_ring(&layer.module_id), + LayerKind::Midpoints => radii.midpoints, LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => { radii.transits } @@ -313,15 +344,13 @@ impl AstrologyCanvas { let dx = mx - (cx_px + gx); let dy = my - (cy_px + gy); let dist = (dx * dx + dy * dy).sqrt(); - if dist > threshold { + if dist > body_threshold { continue; } if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) { - let ox: f32 = bounds.origin.x.into(); - let oy: f32 = bounds.origin.y.into(); best = Some(( dist, - HoverInfo { + HoverInfo::Body { module_id: layer.module_id.clone(), symbol: g.symbol.clone(), deg: g.deg, @@ -336,9 +365,62 @@ impl AstrologyCanvas { } } } + + // 2) House cusps — solo si el mouse está cerca del anillo de + // casas (radio entre houses_inner y houses_outer + margen) y + // ningún body ganó. Las cusps son líneas radiales — la + // distancia angular al cusp más cercano determina el hit. + if best.is_none() { + let dx = mx - cx_px; + let dy = my - cy_px; + let mouse_r = (dx * dx + dy * dy).sqrt(); + let r_in = radii.houses_inner - 6.0; + let r_out = radii.houses_outer + 6.0; + if mouse_r >= r_in && mouse_r <= r_out { + // Calcular la longitud zodiacal que corresponde a este + // ángulo de pantalla (inversa de polar_to_screen). + let screen_angle_deg = dy.atan2(dx).to_degrees(); // (-180, 180] + // polar_to_screen: deg = 180 - (lon - asc + rot) + // → lon = asc + 180 - screen_angle_deg - rot + let lon = ((asc + 180.0 - screen_angle_deg - rot) as f32).rem_euclid(360.0); + // Buscar cusp más cercano (con wraparound). + for layer in &render.layers { + if matches!(layer.kind, LayerKind::Houses) { + if let Geometry::Ring { cusps_deg } = &layer.geometry { + for (i, c) in cusps_deg.iter().enumerate() { + let mut diff = (lon - c).abs(); + if diff > 180.0 { + diff = 360.0 - diff; + } + if diff < 2.5 { + // Mouse cerca de ESTE cusp. + let (gx, gy) = polar_to_screen( + *c, + asc, + rot, + (radii.houses_inner + radii.houses_outer) / 2.0, + ); + best = Some(( + diff, + HoverInfo::HouseCusp { + house_number: (i as u8) + 1, + deg: *c, + local_x: cx_px + gx - ox, + local_y: cy_px + gy - oy, + }, + )); + break; + } + } + } + } + } + } + } + let new_hover = best.map(|(_, h)| h); let changed = match (&self.state.hover, &new_hover) { - (Some(a), Some(b)) => a.symbol != b.symbol || a.module_id != b.module_id, + (Some(a), Some(b)) => a.key() != b.key(), (None, None) => false, _ => true, }; @@ -718,33 +800,67 @@ fn render_wheel( } } - // Tooltip absoluto sobre el cuerpo hovered. Aparece arriba-derecha - // del planeta, con offset pequeño para no taparlo. Texto: - // " ° · Casa N · módulo · retrógrado?" + // Tooltip absoluto sobre el elemento hovered (cuerpo o cusp). if let Some(hov) = hover { - let sign_idx = ((hov.deg / 30.0).floor() as usize) % 12; - let sign_name = SIGN_NAMES_ES[sign_idx]; - let deg_in_sign = hov.deg - (sign_idx as f32) * 30.0; - let mut text = format!( - "{} {} · {:.1}°", - planet_unicode(&hov.symbol), - sign_name, - deg_in_sign, - ); - if let Some(h) = hov.house { - text.push_str(&format!(" · Casa {}", h)); - } - if hov.retrograde { - text.push_str(" · ℞"); - } - if let Some(m) = &hov.dignity_marker { - text.push_str(&format!(" · {}", m)); - } - if hov.module_id != "natal" { - text.push_str(&format!(" · {}", hov.module_id)); - } - let tip_x = (hov.local_x + 14.0).min(WHEEL_SIZE - 220.0).max(8.0); - let tip_y = (hov.local_y - 28.0).max(8.0); + let text = match hov { + HoverInfo::Body { + module_id, + symbol, + deg, + house, + retrograde, + dignity_marker, + annotation, + .. + } => { + let sign_idx = ((deg / 30.0).floor() as usize) % 12; + let sign_name = SIGN_NAMES_ES[sign_idx]; + let deg_in_sign = deg - (sign_idx as f32) * 30.0; + let display_symbol = if module_id == "midpoints" { + // El symbol del midpoint es "a/b" — para el header + // del tooltip usamos los unicodes individuales. + if let Some((a, b)) = symbol.split_once('/') { + format!("{}/{}", planet_unicode(a), planet_unicode(b)) + } else { + symbol.clone() + } + } else { + planet_unicode(symbol).to_string() + }; + let mut t = format!("{} {} · {:.1}°", display_symbol, sign_name, deg_in_sign); + if let Some(h) = house { + t.push_str(&format!(" · Casa {}", h)); + } + if *retrograde { + t.push_str(" · ℞"); + } + if let Some(m) = dignity_marker { + t.push_str(&format!(" · {}", m)); + } + if module_id == "midpoints" { + if let Some(a) = annotation { + t.push_str(&format!(" · {}", a)); + } + } else if module_id != "natal" { + t.push_str(&format!(" · {}", module_id)); + } + t + } + HoverInfo::HouseCusp { + house_number, deg, .. + } => { + let sign_idx = ((deg / 30.0).floor() as usize) % 12; + let sign_name = SIGN_NAMES_ES[sign_idx]; + let deg_in_sign = deg - (sign_idx as f32) * 30.0; + format!( + "Cusp Casa {} · {} {:.1}°", + house_number, sign_name, deg_in_sign + ) + } + }; + let (lx, ly) = hov.local(); + let tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0); + let tip_y = (ly - 28.0).max(8.0); wheel = wheel.child( div() .absolute() @@ -998,13 +1114,12 @@ struct Radii { transits: f32, houses_outer: f32, houses_inner: f32, + /// Anillo de midpoints — entre bodies natales y houses_inner. + midpoints: f32, bodies: f32, /// Anillo interno con cuerpos progresados (overlay opcional). progression: f32, - /// Anillo más interno con cuerpos dirigidos por Solar Arc (overlay - /// opcional). Si tanto progression como solar_arc están activos, - /// progression va afuera (más cerca de bodies natales) y solar_arc - /// adentro (entre progression y aspects). + /// Anillo más interno con cuerpos dirigidos por Solar Arc. solar_arc: f32, aspects: f32, } @@ -1017,6 +1132,7 @@ impl Radii { transits: r * 0.82, houses_outer: r * 0.78, houses_inner: r * 0.66, + midpoints: r * 0.62, bodies: r * 0.58, progression: r * 0.48, solar_arc: r * 0.40, @@ -1029,6 +1145,7 @@ impl Radii { match module_id { "progression" => self.progression, "solar_arc" => self.solar_arc, + "midpoints" => self.midpoints, _ => self.bodies, } } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs index 9e5a5a9..17657fe 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs @@ -299,6 +299,10 @@ pub fn compose( format!("Sinastría · {}", partner_label), ); } + crate::PipelineRequest::Midpoints => { + build_midpoints_overlay(&natal, &mut render); + push_overlay_meta(&mut render, "midpoints", "Midpoints ☉/☽".into()); + } crate::PipelineRequest::PlanetaryReturn { body, target_age_years, @@ -467,6 +471,57 @@ fn build_progression_overlay( Ok(()) } +/// Helper: agrega al `RenderModel` los midpoints entre pares de +/// cuerpos natales. Filtra para mostrar solo los que involucran al +/// Sol o a la Luna (~10 puntos) — son los más significativos +/// astrológicamente y mantiene la rueda legible. +/// +/// El midpoint de dos longitudes es la menor distancia angular entre +/// ellas. Si `|a - b| > 180`, hay que sumar 180 al promedio para +/// obtener el midpoint "corto". +fn build_midpoints_overlay(natal: &NatalChart, render: &mut RenderModel) { + let mut glyphs: Vec = Vec::new(); + let placements = &natal.placements; + + for i in 0..placements.len() { + for j in (i + 1)..placements.len() { + let pa = &placements[i]; + let pb = &placements[j]; + // Solo midpoints que involucren Sol o Luna. + let involves_luminary = matches!(pa.body, Body::Sun | Body::Moon) + || matches!(pb.body, Body::Sun | Body::Moon); + if !involves_luminary { + continue; + } + let a = pa.longitude.longitude_deg() as f32; + let b = pb.longitude.longitude_deg() as f32; + let diff = (a - b).abs(); + let mid = if diff > 180.0 { + ((a + b) / 2.0 + 180.0).rem_euclid(360.0) + } else { + ((a + b) / 2.0).rem_euclid(360.0) + }; + glyphs.push(Glyph { + deg: mid, + symbol: format!("{}/{}", body_symbol(pa.body), body_symbol(pb.body)), + annotation: Some(format!("{}/{}", pa.body.name(), pb.body.name())), + retrograde: false, + house: None, + dignity_marker: None, + }); + } + } + + render.layers.push(Layer { + module_id: "midpoints".into(), + kind: LayerKind::Midpoints, + ring: 0.62, + z: 14, + geometry: Geometry::GlyphsOnly, + glyphs, + }); +} + /// Helper: agrega al `RenderModel` las capas del overlay de Solar Arc /// (método true-progressed-Sun por default). Cada cuerpo natal se /// desplaza por el mismo arco — preserva las relaciones angulares y diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs index 823127f..39c75f2 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs @@ -258,6 +258,10 @@ pub enum PipelineRequest { body: String, target_age_years: f64, }, + /// `module_id = "midpoints"` — anillo de puntos medios entre pares + /// de cuerpos natales. Por simplicidad filtramos a los que + /// involucran al Sol o a la Luna (~10 puntos). + Midpoints, } /// Opciones que afectan la pasada natal (qué aspectos pintar, qué diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs index 04050b3..ff3a811 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs @@ -137,6 +137,7 @@ impl Registry { r.register(Box::new(solar_arc::SolarArcModule)); r.register(Box::new(synastry::SynastryModule)); r.register(Box::new(planetary_return::PlanetaryReturnModule)); + r.register(Box::new(midpoints::MidpointsModule)); r } @@ -545,6 +546,49 @@ pub mod solar_arc { } } +// ===================================================================== +// MidpointsModule — puntos medios entre cuerpos natales (Sol/Luna) +// ===================================================================== + +pub mod midpoints { + use super::*; + + /// Computa midpoints entre los cuerpos natales (filtrado a los que + /// involucran Sol o Luna, ~10 puntos) y los renderea como pequeños + /// puntos en un anillo interior. Hovering muestra los dos cuerpos + /// que originan el midpoint. + pub struct MidpointsModule; + + impl Module for MidpointsModule { + fn id(&self) -> &'static str { + "midpoints" + } + fn label(&self) -> &'static str { + "Midpoints" + } + fn description(&self) -> &'static str { + "Puntos medios que involucran al Sol o a la Luna." + } + fn applies_to(&self, kind: ChartKind) -> bool { + matches!(kind, ChartKind::Natal) + } + fn enabled_by_default(&self) -> bool { + false + } + fn controls(&self) -> Vec { + vec![Control::Toggle { + key: "enabled".into(), + label: "Activar".into(), + default: false, + hotkey: None, + }] + } + fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { + Vec::new() + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -558,8 +602,9 @@ mod tests { assert!(r.find("solar_arc").is_some()); assert!(r.find("synastry").is_some()); assert!(r.find("planetary_return").is_some()); - // Natal kind tiene 6 módulos aplicables. - assert_eq!(r.for_kind(ChartKind::Natal).len(), 6); + assert!(r.find("midpoints").is_some()); + // Natal kind tiene 7 módulos aplicables. + assert_eq!(r.for_kind(ChartKind::Natal).len(), 7); assert!(r.for_kind(ChartKind::Synastry).is_empty()); } } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs index d22ccea..2e07a6c 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs @@ -147,8 +147,51 @@ pub struct TahuantinsuyuTree { expanded: HashSet, menu: Option, modal: Option, + /// `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, } +/// 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. +#[derive(Clone, Copy)] +struct CityPreset { + name: &'static str, + lat: f64, + lon: f64, + tz_offset_minutes: i32, +} + +const CITY_PRESETS: &[CityPreset] = &[ + CityPreset { name: "Buenos Aires, AR", lat: -34.6037, lon: -58.3816, tz_offset_minutes: -180 }, + CityPreset { name: "Caracas, VE", lat: 10.4806, lon: -66.9036, tz_offset_minutes: -240 }, + CityPreset { name: "Bogotá, CO", lat: 4.7110, lon: -74.0721, tz_offset_minutes: -300 }, + CityPreset { name: "Lima, PE", lat: -12.0464, lon: -77.0428, tz_offset_minutes: -300 }, + CityPreset { name: "Santiago, CL", lat: -33.4489, lon: -70.6693, tz_offset_minutes: -240 }, + CityPreset { name: "Quito, EC", lat: -0.1807, lon: -78.4678, tz_offset_minutes: -300 }, + CityPreset { name: "Montevideo, UY", lat: -34.9011, lon: -56.1645, tz_offset_minutes: -180 }, + CityPreset { name: "Asunción, PY", lat: -25.2637, lon: -57.5759, tz_offset_minutes: -240 }, + CityPreset { name: "La Paz, BO", lat: -16.4897, lon: -68.1193, tz_offset_minutes: -240 }, + CityPreset { name: "Ciudad de México", lat: 19.4326, lon: -99.1332, tz_offset_minutes: -360 }, + CityPreset { name: "Madrid, ES", lat: 40.4168, lon: -3.7038, tz_offset_minutes: 60 }, + CityPreset { name: "Barcelona, ES", lat: 41.3851, lon: 2.1734, tz_offset_minutes: 60 }, + CityPreset { name: "London, UK", lat: 51.5074, lon: -0.1278, tz_offset_minutes: 0 }, + CityPreset { name: "Paris, FR", lat: 48.8566, lon: 2.3522, tz_offset_minutes: 60 }, + CityPreset { name: "Berlin, DE", lat: 52.5200, lon: 13.4050, tz_offset_minutes: 60 }, + CityPreset { name: "Roma, IT", lat: 41.9028, lon: 12.4964, tz_offset_minutes: 60 }, + CityPreset { name: "New York, US", lat: 40.7128, lon: -74.0060, tz_offset_minutes: -300 }, + CityPreset { name: "Los Angeles, US", lat: 34.0522, lon: -118.2437, tz_offset_minutes: -480 }, + CityPreset { name: "Chicago, US", lat: 41.8781, lon: -87.6298, tz_offset_minutes: -360 }, + CityPreset { name: "São Paulo, BR", lat: -23.5505, lon: -46.6333, tz_offset_minutes: -180 }, + CityPreset { name: "Rio de Janeiro, BR", lat: -22.9068, lon: -43.1729, tz_offset_minutes: -180 }, + CityPreset { name: "Tokyo, JP", lat: 35.6762, lon: 139.6503, tz_offset_minutes: 540 }, + CityPreset { name: "Sydney, AU", lat: -33.8688, lon: 151.2093, tz_offset_minutes: 600 }, + CityPreset { name: "Mumbai, IN", lat: 19.0760, lon: 72.8777, tz_offset_minutes: 330 }, + CityPreset { name: "Cairo, EG", lat: 30.0444, lon: 31.2357, tz_offset_minutes: 120 }, +]; + impl EventEmitter for TahuantinsuyuTree {} impl TahuantinsuyuTree { @@ -167,6 +210,7 @@ impl TahuantinsuyuTree { expanded: HashSet::new(), menu: None, modal: None, + city_picker_open: false, }; me.refresh(cx); me @@ -292,10 +336,43 @@ impl TahuantinsuyuTree { fn close_modal(&mut self, cx: &mut Context) { if self.modal.take().is_some() { + self.city_picker_open = false; cx.notify(); } } + fn toggle_city_picker(&mut self, cx: &mut Context) { + 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) { + 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(); + place.update(cx, |i, cx| i.set_text(preset.name.to_string(), cx)); + lat.update(cx, |i, cx| i.set_text(format!("{}", preset.lat), cx)); + lon.update(cx, |i, cx| i.set_text(format!("{}", preset.lon), cx)); + tz.update(cx, |i, cx| { + i.set_text(preset.tz_offset_minutes.to_string(), cx) + }); + self.city_picker_open = false; + cx.notify(); + } + fn open_create_group( &mut self, parent: Option, @@ -1139,6 +1216,78 @@ fn render_chart_form( 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 picker_open = cx.entity().read(cx).city_picker_open; + 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 mut popup = div() + .absolute() + .top(px(36.0)) + .right(px(0.0)) + .min_w(px(220.0)) + .max_h(px(320.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(); + for preset in CITY_PRESETS.iter().copied() { + let row_id: SharedString = + SharedString::from(format!("tts-city-{}", preset.name)); + 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(preset.name.to_string())) + .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)) @@ -1149,12 +1298,7 @@ fn render_chart_form( .border_1() .border_color(theme.border_strong) .rounded(px(8.0)) - .child( - div() - .text_size(px(14.0)) - .text_color(theme.fg_text) - .child(SharedString::from(title.to_string())), - ) + .child(title_row) .child( div() .flex()