gioser

tawasuyu/brahman

Rust 459TOML 177Markdown 49JSON 176207 Definiciones

feat(tahuantinsuyu): fase 14 — Return abstracto + Control::Select interactivo

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 <noreply@anthropic.com>

6d572c81ca5e6eaddb67bb8187f1ca1925950701 · por sergio · (hace 5 meses) · padre 8d95833c2 · ver el árbol en este commit

Delta semántico · 36

crates/apps/tahuantinsuyu/src/shell.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs

6 archivos · +273 −126

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<Glyph> = 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<Chart>,
     },
-    /// `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<ChartOption>,
-    /// Valor actual por picker. `None` = "automático" (el módulo
-    /// elige).
-    chart_picker_value: HashMap<(String, String), Option<String>>,
-    /// 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<String>>,
+    /// 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<String>,
+        cx: &mut Context<Self>,
+    ) {
+        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<String>,
         cx: &mut Context<Self>,
     ) {
-        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<Self>) {
+    fn toggle_dropdown_open(&mut self, module_id: String, key: String, cx: &mut Context<Self>) {
         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<String>,
+        value: Option<String>,
         cx: &mut Context<Self>,
     ) {
-        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<Self>,
     ) -> 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<Self>,
+    ) -> 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<Self>,
+    ) -> 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<Self>,
     ) -> 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,
                         );
                     })),