From 6d572c81ca5e6eaddb67bb8187f1ca1925950701 Mon Sep 17 00:00:00 2001 From: sergio Date: Sun, 17 May 2026 11:30:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(tahuantinsuyu):=20fase=2014=20=E2=80=94=20?= =?UTF-8?q?Return=20abstracto=20+=20Control::Select=20interactivo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El módulo SolarReturn se generaliza a PlanetaryReturn parametrizable por cuerpo (Sun/Moon/Mercury/Venus/Mars/Jupiter/Saturn/Uranus/Neptune/ Pluto). Validado contra `Control::Select`, ahora interactivo como tercer tipo de control draggable (después de Toggle/Slider/ChartPicker). Refactor estructural: el dropdown del ChartPicker pasa a ser infraestructura compartida — chart_picker_value/chart_picker_open desaparecen, reemplazados por string_state/dropdown_open que sirven a CUALQUIER control basado en string (picker + select). render_chart_picker y render_select ahora son thin wrappers sobre render_dropdown(options, include_auto). - engine: - PipelineRequest::SolarReturn → PipelineRequest::PlanetaryReturn { body: String, target_age_years }. Body como string agnóstico (sun/moon/jupiter/...) que el bridge mapea a eternal_sky::Body vía map_body — el mismo helper que ya usa StoredChartConfig. - build_solar_return_overlay → build_planetary_return_overlay con parameter `body: Body`. next_return acepta cualquier body, así que Moon return (mensual) y Saturn return (29 años) funcionan igual. Mensajes de error incluyen body.name() para diagnóstico. - modules: - SolarReturnModule → PlanetaryReturnModule (mod planetary_return). id "planetary_return". Controles: toggle "enabled" + Select "body" con 10 opciones de cuerpo (Sol → Plutón) + Slider edad. label "Retornos planetarios". - panel: - Refactor: chart_picker_value/chart_picker_open → string_state/ dropdown_open (compartido entre ChartPicker y Select). - set_string(module_id, key, value, cx) — API unificada. set_chart_picker queda como alias retrocompatible. - render_dropdown(options, include_auto, …) — helper común. picker pasa include_auto=true (muestra "(automático)" + separador); select pasa include_auto=false (las options son la única opción). - render_select implementado — el botón muestra la option's label (no value); click abre dropdown; click en opción emite ControlChanged con Value::String(option.value). - shell: - OUTER_RING_MODULES const: "solar_return" → "planetary_return". - build_requests para planetary_return: lee body string del module_configs (default "sun"), arma PipelineRequest::PlanetaryReturn. - apply_selection inicializa target_age + body=sun default para planetary_return. - sync_panel_from_configs strings → set_string (era set_chart_picker). Probarlo: en el panel del módulo "Retornos planetarios", click en el dropdown "Cuerpo" abre el popup; click en "Saturno" + slider en 29 años + toggle "Activar" = ves la carta del primer retorno de Saturno (cuando recién terminaba la primera vuelta) en el outer ring con cross aspects al natal. NOTE: La persistencia con id "solar_return" de fase 13 queda huérfana en la DB de los users que ya hayan probado. No es destructivo — simplemente esas rows quedan sin módulo que las lea. Pre-1.0. Co-Authored-By: Claude Opus 4.7 --- crates/apps/tahuantinsuyu/src/shell.rs | 32 ++- .../tahuantinsuyu-canvas/src/lib.rs | 12 +- .../tahuantinsuyu-engine/src/bridge.rs | 66 +++-- .../tahuantinsuyu-engine/src/lib.rs | 15 +- .../tahuantinsuyu-modules/src/lib.rs | 40 ++- .../tahuantinsuyu-panel/src/lib.rs | 234 ++++++++++++------ 6 files changed, 273 insertions(+), 126 deletions(-) diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs index c40914e..43d6960 100644 --- a/crates/apps/tahuantinsuyu/src/shell.rs +++ b/crates/apps/tahuantinsuyu/src/shell.rs @@ -144,7 +144,7 @@ impl Shell { // edad actual. Estos quedan en module_configs como // valor base si el usuario nunca tocó el slider. self.module_configs.clear(); - for module_id in ["progression", "solar_arc", "solar_return"] { + for module_id in ["progression", "solar_arc", "planetary_return"] { let entry = self .module_configs .entry(module_id.into()) @@ -153,6 +153,16 @@ impl Shell { map.insert("target_age_years".into(), serde_json::json!(age)); } } + // El módulo planetary_return además necesita un body + // por default — el shell elige "sun" si el usuario no + // tocó el Select. La persistencia luego puede pisar + // este valor. + if let Some(serde_json::Value::Object(map)) = + self.module_configs.get_mut("planetary_return") + { + map.entry(String::from("body")) + .or_insert(serde_json::json!("sun")); + } // 2) Sobreescribir con lo que el usuario persistió la // última vez para esta carta (SQLite `module_state`). self.load_persisted_module_states(chart.id); @@ -239,9 +249,17 @@ impl Shell { }); } } - if module_enabled(&self.module_configs, "solar_return") { - let age = self.module_age_or_current("solar_return"); - requests.push(PipelineRequest::SolarReturn { + if module_enabled(&self.module_configs, "planetary_return") { + let age = self.module_age_or_current("planetary_return"); + let body = self + .module_configs + .get("planetary_return") + .and_then(|c| c.get("body")) + .and_then(|v| v.as_str()) + .unwrap_or("sun") + .to_string(); + requests.push(PipelineRequest::PlanetaryReturn { + body, target_age_years: age, }); } @@ -329,9 +347,9 @@ impl Shell { } else if let Some(f) = value.as_f64() { p.set_slider(module_id, key, f, cx); } else if let Some(s) = value.as_str() { - p.set_chart_picker(module_id, key, Some(s.to_string()), cx); + p.set_string(module_id, key, Some(s.to_string()), cx); } else if value.is_null() { - p.set_chart_picker(module_id, key, None, cx); + p.set_string(module_id, key, None, cx); } } } @@ -524,7 +542,7 @@ impl Shell { /// Módulos que pintan en el outer ring del canvas — mutuamente /// excluyentes a nivel de UI. Al prender uno, los otros se apagan. -const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "solar_return"]; +const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "planetary_return"]; /// Etiqueta breve para mostrar al elegir una carta en el picker: diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs index 2e68ff4..c3ebef2 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs @@ -574,7 +574,7 @@ fn render_wheel( if matches!(layer.kind, LayerKind::Outer) && (layer.module_id == "transit" || layer.module_id == "synastry" - || layer.module_id == "solar_return") + || layer.module_id == "planetary_return") { for g in &layer.glyphs { let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.transits); @@ -730,11 +730,11 @@ impl Radii { /// Resuelve qué radios corresponden a una capa de aspectos según el /// `module_id`: natal-natal en `aspects`, cross con cada overlay /// desde `bodies` (extremo natal) al ring del módulo. Synastry y - /// Solar Return comparten el outer ring de tránsito (los tres son - /// mutuamente excluyentes a nivel de Shell). + /// Planetary Return comparten el outer ring de tránsito (los tres + /// son mutuamente excluyentes a nivel de Shell). fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) { match module_id { - "transit" | "synastry" | "solar_return" => (self.bodies, self.transits), + "transit" | "synastry" | "planetary_return" => (self.bodies, self.transits), "progression" => (self.bodies, self.progression), "solar_arc" => (self.bodies, self.solar_arc), _ => (self.aspects, self.aspects), @@ -949,7 +949,7 @@ fn paint_wheel( matches!(l.kind, LayerKind::Outer) && (l.module_id == "transit" || l.module_id == "synastry" - || l.module_id == "solar_return") + || l.module_id == "planetary_return") }); if outer_active && show(LayerKind::Outer) { stroke_circle( @@ -974,7 +974,7 @@ fn paint_wheel( if matches!(layer.kind, LayerKind::Outer) && (layer.module_id == "transit" || layer.module_id == "synastry" - || layer.module_id == "solar_return") + || layer.module_id == "planetary_return") { for g in &layer.glyphs { let color = with_alpha(planet_color(palette, &g.symbol), 0.85); diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs index a3ac4b0..aaf25fc 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs @@ -264,11 +264,21 @@ pub fn compose( crate::PipelineRequest::Synastry { partner_chart } => { build_synastry_overlay(&natal, partner_chart, &mut render)?; } - crate::PipelineRequest::SolarReturn { target_age_years } => { - build_solar_return_overlay( + crate::PipelineRequest::PlanetaryReturn { + body, + target_age_years, + } => { + let body_e = map_body(body).ok_or_else(|| { + EngineError::Eternal(format!( + "body desconocido para planetary return: {}", + body + )) + })?; + build_planetary_return_overlay( &natal, &config_e, observer, + body_e, *target_age_years, &mut render, )?; @@ -540,29 +550,34 @@ fn build_synastry_overlay( Ok(()) } -/// Helper: agrega al `RenderModel` las capas del overlay de Solar -/// Return — la carta natal completa computada al instante en que el -/// Sol vuelve a su posición natal en el año pedido. Esa nueva carta -/// va en el anillo externo (compartido con Transit/Synastry — -/// mutuamente excluyentes a nivel de Shell). Cross aspects natal × -/// return. -fn build_solar_return_overlay( +/// Helper: agrega al `RenderModel` las capas del overlay de retorno +/// planetario — la carta natal completa computada al instante en que +/// el `body` vuelve a su posición natal cerca de la edad pedida. +/// Sun = retorno solar anual, Moon = mensual, Júpiter/Saturno = +/// generacionales. Esa nueva carta va en el anillo externo (compartido +/// con Transit/Synastry, mutuamente excluyentes a nivel de Shell). +/// Cross aspects natal × return. +fn build_planetary_return_overlay( natal: &NatalChart, config_e: &ChartConfig, observer: Observer, + body: Body, target_age_years: f64, render: &mut RenderModel, ) -> Result<(), EngineError> { let session = session()?; - let natal_sun = natal.placement(Body::Sun).ok_or_else(|| { - EngineError::Eternal("natal chart sin Sol — Solar Return imposible".into()) + let natal_p = natal.placement(body).ok_or_else(|| { + EngineError::Eternal(format!( + "natal chart sin {} — return imposible", + body.name() + )) })?; - let natal_sun_lon = natal_sun.longitude.longitude_rad(); + let natal_lon = natal_p.longitude.longitude_rad(); - // Buscar el próximo retorno desde un punto razonable antes del - // cumpleaños del año target. Restamos 30 días para garantizar que - // el retorno (que cae ~en la fecha de nacimiento) quede DENTRO de - // la ventana de búsqueda. + // El offset desde el cumpleaños depende del período sinódico del + // cuerpo: para Sun/planet lentos, ~30 días antes garantiza captar + // el return; para Moon, ~15 días. Tomamos un margen amplio que + // sirve para todos. const TROPICAL_YEAR_SECS: f64 = 365.242190 * 86400.0; let after_seconds = (target_age_years * 365.242190 - 30.0) * 86400.0; let after_utc = natal @@ -572,15 +587,20 @@ fn build_solar_return_overlay( .add_seconds(after_seconds.max(-TROPICAL_YEAR_SECS * 2.0)); let after = ESInstant::from_utc(after_utc); - let return_instant = next_return(session, Body::Sun, natal_sun_lon, after, None) - .map_err(|e| EngineError::Eternal(format!("next_return Sun: {:?}", e)))?; + let return_instant = next_return(session, body, natal_lon, after, None).map_err(|e| { + EngineError::Eternal(format!("next_return {}: {:?}", body.name(), e)) + })?; // La carta del retorno se computa al return_instant con el mismo - // observer y config natales (convención clásica: solar return - // tropical en la ciudad de nacimiento). + // observer y config natales (convención clásica: return tropical + // en la ciudad de nacimiento). let return_birth = BirthData::new(return_instant, observer); let return_chart = NatalChart::compute(&return_birth, config_e, session).map_err(|e| { - EngineError::Eternal(format!("NatalChart::compute (solar return): {:?}", e)) + EngineError::Eternal(format!( + "NatalChart::compute ({} return): {:?}", + body.name(), + e + )) })?; let glyphs: Vec = return_chart @@ -595,7 +615,7 @@ fn build_solar_return_overlay( }) .collect(); render.layers.push(Layer { - module_id: "solar_return".into(), + module_id: "planetary_return".into(), kind: LayerKind::Outer, ring: 0.82, z: 12, @@ -624,7 +644,7 @@ fn build_solar_return_overlay( }) .collect(); render.layers.push(Layer { - module_id: "solar_return".into(), + module_id: "planetary_return".into(), kind: LayerKind::Aspects, ring: 0.0, z: 13, diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs index a24b564..c04c9e0 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs @@ -191,11 +191,16 @@ pub enum PipelineRequest { Synastry { partner_chart: Box, }, - /// `module_id = "solar_return"` — carta natal fresca al instante - /// del próximo retorno solar para la edad pedida (Sun back to - /// natal Sun). Anillo externo compartido con Transit/Synastry - /// — mutuamente excluyentes los tres a nivel de Shell. - SolarReturn { + /// `module_id = "planetary_return"` — carta natal fresca al + /// instante del próximo retorno del cuerpo elegido a su posición + /// natal, para la edad pedida. Sun = retorno solar anual, Moon = + /// mensual, Júpiter/Saturno = generacionales. Anillo externo + /// compartido con Transit/Synastry — mutuamente excluyentes a + /// nivel de Shell. + PlanetaryReturn { + /// Identificador agnóstico del cuerpo ("sun", "moon", + /// "jupiter", …). El bridge lo mapea a `eternal_sky::Body`. + body: String, target_age_years: f64, }, } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs index d3eb15a..4e67357 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs @@ -136,7 +136,7 @@ impl Registry { r.register(Box::new(progression::ProgressionModule)); r.register(Box::new(solar_arc::SolarArcModule)); r.register(Box::new(synastry::SynastryModule)); - r.register(Box::new(solar_return::SolarReturnModule)); + r.register(Box::new(planetary_return::PlanetaryReturnModule)); r } @@ -395,26 +395,27 @@ pub mod synastry { } // ===================================================================== -// SolarReturnModule — carta del año en curso (Sun back to natal Sun) +// PlanetaryReturnModule — retornos de cualquier cuerpo a su pos natal // ===================================================================== -pub mod solar_return { +pub mod planetary_return { use super::*; /// Computa la carta natal completa al instante del próximo retorno - /// solar para la edad pedida. Comparte el outer ring con Transit y - /// Synastry — mutuamente excluyentes a nivel de Shell. - pub struct SolarReturnModule; + /// del cuerpo elegido. Sun = anual (cumpleaños), Moon = mensual, + /// Júpiter/Saturno = generacionales. Comparte el outer ring con + /// Transit y Synastry — mutuamente excluyentes a nivel de Shell. + pub struct PlanetaryReturnModule; - impl Module for SolarReturnModule { + impl Module for PlanetaryReturnModule { fn id(&self) -> &'static str { - "solar_return" + "planetary_return" } fn label(&self) -> &'static str { - "Retorno solar" + "Retornos planetarios" } fn description(&self) -> &'static str { - "Carta del año — Sol de vuelta a su posición natal." + "Carta del próximo retorno (Sol, Luna, Júpiter, Saturno…)." } fn applies_to(&self, kind: ChartKind) -> bool { matches!(kind, ChartKind::Natal) @@ -430,6 +431,23 @@ pub mod solar_return { default: false, hotkey: None, }, + Control::Select { + key: "body".into(), + label: "Cuerpo".into(), + default: "sun".into(), + options: vec![ + SelectOption { value: "sun".into(), label: "Sol".into() }, + SelectOption { value: "moon".into(), label: "Luna".into() }, + SelectOption { value: "mercury".into(), label: "Mercurio".into() }, + SelectOption { value: "venus".into(), label: "Venus".into() }, + SelectOption { value: "mars".into(), label: "Marte".into() }, + SelectOption { value: "jupiter".into(), label: "Júpiter".into() }, + SelectOption { value: "saturn".into(), label: "Saturno".into() }, + SelectOption { value: "uranus".into(), label: "Urano".into() }, + SelectOption { value: "neptune".into(), label: "Neptuno".into() }, + SelectOption { value: "pluto".into(), label: "Plutón".into() }, + ], + }, Control::Slider { key: "target_age_years".into(), label: "Edad del retorno".into(), @@ -511,7 +529,7 @@ mod tests { assert!(r.find("progression").is_some()); assert!(r.find("solar_arc").is_some()); assert!(r.find("synastry").is_some()); - assert!(r.find("solar_return").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.for_kind(ChartKind::Synastry).is_empty()); diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs index b97052b..6a54457 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs @@ -30,7 +30,7 @@ use gpui::{ }; use tahuantinsuyu_model::ChartKind; -use tahuantinsuyu_modules::{Control, Module, Registry}; +use tahuantinsuyu_modules::{Control, Module, Registry, SelectOption}; use yahweh_theme::Theme; // ===================================================================== @@ -82,12 +82,14 @@ pub struct ControlPanel { /// los pickers porque típicamente representan "todas las cartas /// del DB" sin filtros por módulo. chart_options: Vec, - /// Valor actual por picker. `None` = "automático" (el módulo - /// elige). - chart_picker_value: HashMap<(String, String), Option>, - /// Si hay un picker abierto, su (module_id, key). Mutuamente - /// excluyente: solo uno abierto a la vez. - chart_picker_open: Option<(String, String)>, + /// Valor actual de cualquier control basado en string (ChartPicker + /// y Select comparten storage). `None` = sin selección — el render + /// muestra placeholder ("automático" en picker, default-label en + /// select). + string_state: HashMap<(String, String), Option>, + /// Si hay un dropdown abierto, su (module_id, key). Mutuamente + /// excluyente: solo uno abierto a la vez en todo el panel. + dropdown_open: Option<(String, String)>, registry: Registry, } @@ -102,8 +104,8 @@ impl ControlPanel { slider_state: HashMap::new(), slider_drag: None, chart_options: Vec::new(), - chart_picker_value: HashMap::new(), - chart_picker_open: None, + string_state: HashMap::new(), + dropdown_open: None, registry: Registry::with_builtins(), } } @@ -125,10 +127,15 @@ impl ControlPanel { .or_insert(default); } Control::ChartPicker { key, .. } => { - self.chart_picker_value + self.string_state .entry((m.id().to_string(), key)) .or_insert(None); } + Control::Select { key, default, .. } => { + self.string_state + .entry((m.id().to_string(), key)) + .or_insert(Some(default)); + } _ => {} } } @@ -136,8 +143,8 @@ impl ControlPanel { } } self.active_kind = kind; - // Cerrar cualquier picker abierto al cambiar de carta. - self.chart_picker_open = None; + // Cerrar cualquier dropdown abierto al cambiar de carta. + self.dropdown_open = None; cx.notify(); } @@ -167,8 +174,23 @@ impl ControlPanel { cx.notify(); } - /// Setea el valor de un picker desde afuera (sin emitir). El shell - /// la usa para restaurar el partner persistido al cargar una carta. + /// Setea el valor de un control basado en string (ChartPicker o + /// Select) desde afuera, sin emitir. El shell la usa para restaurar + /// el valor persistido al cargar una carta. + pub fn set_string( + &mut self, + module_id: &str, + key: &str, + value: Option, + cx: &mut Context, + ) { + self.string_state + .insert((module_id.to_string(), key.to_string()), value); + cx.notify(); + } + + /// Alias retrocompatible — los call-sites antiguos del shell usaban + /// `set_chart_picker`. Funcionalmente idéntico a [`Self::set_string`]. pub fn set_chart_picker( &mut self, module_id: &str, @@ -176,9 +198,7 @@ impl ControlPanel { chart_id: Option, cx: &mut Context, ) { - self.chart_picker_value - .insert((module_id.to_string(), key.to_string()), chart_id); - cx.notify(); + self.set_string(module_id, key, chart_id, cx); } // ----- internos: handlers ----- @@ -234,34 +254,34 @@ impl ControlPanel { } } - fn toggle_picker_open(&mut self, module_id: String, key: String, cx: &mut Context) { + fn toggle_dropdown_open(&mut self, module_id: String, key: String, cx: &mut Context) { let key_pair = (module_id, key); - let new_state = match self.chart_picker_open.as_ref() { + let new_state = match self.dropdown_open.as_ref() { Some(open) if open == &key_pair => None, _ => Some(key_pair), }; - self.chart_picker_open = new_state; + self.dropdown_open = new_state; cx.notify(); } - fn select_picker( + fn select_string_value( &mut self, module_id: String, key: String, - chart_id: Option, + value: Option, cx: &mut Context, ) { - self.chart_picker_value - .insert((module_id.clone(), key.clone()), chart_id.clone()); - self.chart_picker_open = None; - let value = match chart_id { - Some(id) => serde_json::Value::String(id), + self.string_state + .insert((module_id.clone(), key.clone()), value.clone()); + self.dropdown_open = None; + let json_value = match value { + Some(s) => serde_json::Value::String(s), None => serde_json::Value::Null, }; cx.emit(PanelEvent::ControlChanged { module_id, key, - value, + value: json_value, }); cx.notify(); } @@ -451,7 +471,12 @@ impl ControlPanel { Control::ChartPicker { key, label } => { self.render_chart_picker(theme, module_id, key, label, cx) } - Control::Select { label, default, .. } => display_row(theme, label, default), + Control::Select { + key, + label, + options, + default, + } => self.render_select(theme, module_id, key, label, options, default, cx), Control::TextInput { label, default, .. } => display_row(theme, label, default), } } @@ -668,23 +693,69 @@ impl ControlPanel { label: &str, cx: &mut Context, ) -> gpui::Div { - let current_id = self - .chart_picker_value + let options: Vec<(String, String)> = self + .chart_options + .iter() + .map(|o| (o.id.clone(), o.label.clone())) + .collect(); + self.render_dropdown( + theme, + module_id, + key, + label, + "(automático)", + &options, + true, // incluir opción "(automático)" en el popup + cx, + ) + } + + fn render_select( + &self, + theme: &Theme, + module_id: &str, + key: &str, + label: &str, + options: &[SelectOption], + default: &str, + cx: &mut Context, + ) -> gpui::Div { + let opts: Vec<(String, String)> = options + .iter() + .map(|o| (o.value.clone(), o.label.clone())) + .collect(); + let placeholder = options + .iter() + .find(|o| o.value == default) + .map(|o| o.label.clone()) + .unwrap_or_else(|| default.to_string()); + self.render_dropdown(theme, module_id, key, label, &placeholder, &opts, false, cx) + } + + #[allow(clippy::too_many_arguments)] + fn render_dropdown( + &self, + theme: &Theme, + module_id: &str, + key: &str, + label: &str, + placeholder: &str, + options: &[(String, String)], + include_auto: bool, + cx: &mut Context, + ) -> gpui::Div { + let current_value = self + .string_state .get(&(module_id.to_string(), key.to_string())) .cloned() .flatten(); - let current_label = current_id + let current_label = current_value .as_ref() - .and_then(|id| { - self.chart_options - .iter() - .find(|o| &o.id == id) - .map(|o| o.label.clone()) - }) - .unwrap_or_else(|| "(automático)".into()); + .and_then(|v| options.iter().find(|(val, _)| val == v).map(|(_, l)| l.clone())) + .unwrap_or_else(|| placeholder.to_string()); let is_open = self - .chart_picker_open + .dropdown_open .as_ref() .map(|(m, k)| m == module_id && k == key) .unwrap_or(false); @@ -692,7 +763,7 @@ impl ControlPanel { let module_id_btn = module_id.to_string(); let key_btn = key.to_string(); let btn_id: SharedString = - SharedString::from(format!("tts-picker-btn-{}-{}", module_id, key)); + SharedString::from(format!("tts-dropdown-btn-{}-{}", module_id, key)); let button = div() .id(gpui::ElementId::from(btn_id)) .px(px(10.0)) @@ -710,7 +781,7 @@ impl ControlPanel { .text_color(theme.fg_text) .child(SharedString::from(format!("▾ {}", current_label))) .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { - this.toggle_picker_open(module_id_btn.clone(), key_btn.clone(), cx); + this.toggle_dropdown_open(module_id_btn.clone(), key_btn.clone(), cx); })); let mut wrapper = div() @@ -727,17 +798,26 @@ impl ControlPanel { .child(button); if is_open { - wrapper = wrapper.child(self.render_picker_popup(theme, module_id, key, cx)); + wrapper = wrapper.child(self.render_dropdown_popup( + theme, + module_id, + key, + options, + include_auto, + cx, + )); } div().px(px(6.0)).py(px(3.0)).child(wrapper) } - fn render_picker_popup( + fn render_dropdown_popup( &self, theme: &Theme, module_id: &str, key: &str, + options: &[(String, String)], + include_auto: bool, cx: &mut Context, ) -> gpui::Div { let mut popup = div() @@ -753,41 +833,47 @@ impl ControlPanel { .flex() .flex_col(); - // Opción "automático" (limpia la selección manual). - let module_id_clear = module_id.to_string(); - let key_clear = key.to_string(); - let clear_id: SharedString = - SharedString::from(format!("tts-picker-clear-{}-{}", module_id, key)); - popup = popup.child( - div() - .id(gpui::ElementId::from(clear_id)) - .px(px(12.0)) - .py(px(5.0)) - .text_size(px(11.0)) - .text_color(theme.fg_muted) - .hover(|s| s.bg(theme.bg_row_hover)) - .child("(automático)") - .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { - this.select_picker(module_id_clear.clone(), key_clear.clone(), None, cx); - })), - ); - - if !self.chart_options.is_empty() { + if include_auto { + let module_id_clear = module_id.to_string(); + let key_clear = key.to_string(); + let clear_id: SharedString = + SharedString::from(format!("tts-dropdown-clear-{}-{}", module_id, key)); popup = popup.child( div() - .my(px(3.0)) - .h(px(1.0)) - .w_full() - .bg(theme.border), + .id(gpui::ElementId::from(clear_id)) + .px(px(12.0)) + .py(px(5.0)) + .text_size(px(11.0)) + .text_color(theme.fg_muted) + .hover(|s| s.bg(theme.bg_row_hover)) + .child("(automático)") + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.select_string_value( + module_id_clear.clone(), + key_clear.clone(), + None, + cx, + ); + })), ); + + if !options.is_empty() { + popup = popup.child( + div() + .my(px(3.0)) + .h(px(1.0)) + .w_full() + .bg(theme.border), + ); + } } - for opt in &self.chart_options { + for (value, opt_label) in options { let module_id_pick = module_id.to_string(); let key_pick = key.to_string(); - let opt_id = opt.id.clone(); + let opt_value = value.clone(); let row_id: SharedString = - SharedString::from(format!("tts-picker-opt-{}-{}-{}", module_id, key, opt.id)); + SharedString::from(format!("tts-dropdown-opt-{}-{}-{}", module_id, key, value)); popup = popup.child( div() .id(gpui::ElementId::from(row_id)) @@ -796,12 +882,12 @@ impl ControlPanel { .text_size(px(11.0)) .text_color(theme.fg_text) .hover(|s| s.bg(theme.bg_row_hover)) - .child(SharedString::from(opt.label.clone())) + .child(SharedString::from(opt_label.clone())) .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { - this.select_picker( + this.select_string_value( module_id_pick.clone(), key_pick.clone(), - Some(opt_id.clone()), + Some(opt_value.clone()), cx, ); })),