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>
This commit is contained in:
sergio
2026-05-17 11:30:21 +00:00
parent 8d95833c20
commit 6d572c81ca
6 changed files with 273 additions and 126 deletions
+25 -7
View File
@@ -144,7 +144,7 @@ impl Shell {
// edad actual. Estos quedan en module_configs como // edad actual. Estos quedan en module_configs como
// valor base si el usuario nunca tocó el slider. // valor base si el usuario nunca tocó el slider.
self.module_configs.clear(); 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 let entry = self
.module_configs .module_configs
.entry(module_id.into()) .entry(module_id.into())
@@ -153,6 +153,16 @@ impl Shell {
map.insert("target_age_years".into(), serde_json::json!(age)); 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 // 2) Sobreescribir con lo que el usuario persistió la
// última vez para esta carta (SQLite `module_state`). // última vez para esta carta (SQLite `module_state`).
self.load_persisted_module_states(chart.id); self.load_persisted_module_states(chart.id);
@@ -239,9 +249,17 @@ impl Shell {
}); });
} }
} }
if module_enabled(&self.module_configs, "solar_return") { if module_enabled(&self.module_configs, "planetary_return") {
let age = self.module_age_or_current("solar_return"); let age = self.module_age_or_current("planetary_return");
requests.push(PipelineRequest::SolarReturn { 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, target_age_years: age,
}); });
} }
@@ -329,9 +347,9 @@ impl Shell {
} else if let Some(f) = value.as_f64() { } else if let Some(f) = value.as_f64() {
p.set_slider(module_id, key, f, cx); p.set_slider(module_id, key, f, cx);
} else if let Some(s) = value.as_str() { } 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() { } 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 /// Módulos que pintan en el outer ring del canvas — mutuamente
/// excluyentes a nivel de UI. Al prender uno, los otros se apagan. /// 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: /// Etiqueta breve para mostrar al elegir una carta en el picker:
@@ -574,7 +574,7 @@ fn render_wheel(
if matches!(layer.kind, LayerKind::Outer) if matches!(layer.kind, LayerKind::Outer)
&& (layer.module_id == "transit" && (layer.module_id == "transit"
|| layer.module_id == "synastry" || layer.module_id == "synastry"
|| layer.module_id == "solar_return") || layer.module_id == "planetary_return")
{ {
for g in &layer.glyphs { for g in &layer.glyphs {
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.transits); 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 /// Resuelve qué radios corresponden a una capa de aspectos según el
/// `module_id`: natal-natal en `aspects`, cross con cada overlay /// `module_id`: natal-natal en `aspects`, cross con cada overlay
/// desde `bodies` (extremo natal) al ring del módulo. Synastry y /// desde `bodies` (extremo natal) al ring del módulo. Synastry y
/// Solar Return comparten el outer ring de tránsito (los tres son /// Planetary Return comparten el outer ring de tránsito (los tres
/// mutuamente excluyentes a nivel de Shell). /// son mutuamente excluyentes a nivel de Shell).
fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) { fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) {
match module_id { match module_id {
"transit" | "synastry" | "solar_return" => (self.bodies, self.transits), "transit" | "synastry" | "planetary_return" => (self.bodies, self.transits),
"progression" => (self.bodies, self.progression), "progression" => (self.bodies, self.progression),
"solar_arc" => (self.bodies, self.solar_arc), "solar_arc" => (self.bodies, self.solar_arc),
_ => (self.aspects, self.aspects), _ => (self.aspects, self.aspects),
@@ -949,7 +949,7 @@ fn paint_wheel(
matches!(l.kind, LayerKind::Outer) matches!(l.kind, LayerKind::Outer)
&& (l.module_id == "transit" && (l.module_id == "transit"
|| l.module_id == "synastry" || l.module_id == "synastry"
|| l.module_id == "solar_return") || l.module_id == "planetary_return")
}); });
if outer_active && show(LayerKind::Outer) { if outer_active && show(LayerKind::Outer) {
stroke_circle( stroke_circle(
@@ -974,7 +974,7 @@ fn paint_wheel(
if matches!(layer.kind, LayerKind::Outer) if matches!(layer.kind, LayerKind::Outer)
&& (layer.module_id == "transit" && (layer.module_id == "transit"
|| layer.module_id == "synastry" || layer.module_id == "synastry"
|| layer.module_id == "solar_return") || layer.module_id == "planetary_return")
{ {
for g in &layer.glyphs { for g in &layer.glyphs {
let color = with_alpha(planet_color(palette, &g.symbol), 0.85); let color = with_alpha(planet_color(palette, &g.symbol), 0.85);
@@ -264,11 +264,21 @@ pub fn compose(
crate::PipelineRequest::Synastry { partner_chart } => { crate::PipelineRequest::Synastry { partner_chart } => {
build_synastry_overlay(&natal, partner_chart, &mut render)?; build_synastry_overlay(&natal, partner_chart, &mut render)?;
} }
crate::PipelineRequest::SolarReturn { target_age_years } => { crate::PipelineRequest::PlanetaryReturn {
build_solar_return_overlay( 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, &natal,
&config_e, &config_e,
observer, observer,
body_e,
*target_age_years, *target_age_years,
&mut render, &mut render,
)?; )?;
@@ -540,29 +550,34 @@ fn build_synastry_overlay(
Ok(()) Ok(())
} }
/// Helper: agrega al `RenderModel` las capas del overlay de Solar /// Helper: agrega al `RenderModel` las capas del overlay de retorno
/// Return — la carta natal completa computada al instante en que el /// planetario — la carta natal completa computada al instante en que
/// Sol vuelve a su posición natal en el año pedido. Esa nueva carta /// el `body` vuelve a su posición natal cerca de la edad pedida.
/// va en el anillo externo (compartido con Transit/Synastry — /// Sun = retorno solar anual, Moon = mensual, Júpiter/Saturno =
/// mutuamente excluyentes a nivel de Shell). Cross aspects natal × /// generacionales. Esa nueva carta va en el anillo externo (compartido
/// return. /// con Transit/Synastry, mutuamente excluyentes a nivel de Shell).
fn build_solar_return_overlay( /// Cross aspects natal × return.
fn build_planetary_return_overlay(
natal: &NatalChart, natal: &NatalChart,
config_e: &ChartConfig, config_e: &ChartConfig,
observer: Observer, observer: Observer,
body: Body,
target_age_years: f64, target_age_years: f64,
render: &mut RenderModel, render: &mut RenderModel,
) -> Result<(), EngineError> { ) -> Result<(), EngineError> {
let session = session()?; let session = session()?;
let natal_sun = natal.placement(Body::Sun).ok_or_else(|| { let natal_p = natal.placement(body).ok_or_else(|| {
EngineError::Eternal("natal chart sin Sol — Solar Return imposible".into()) 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 // El offset desde el cumpleaños depende del período sinódico del
// cumpleaños del año target. Restamos 30 días para garantizar que // cuerpo: para Sun/planet lentos, ~30 días antes garantiza captar
// el retorno (que cae ~en la fecha de nacimiento) quede DENTRO de // el return; para Moon, ~15 días. Tomamos un margen amplio que
// la ventana de búsqueda. // sirve para todos.
const TROPICAL_YEAR_SECS: f64 = 365.242190 * 86400.0; const TROPICAL_YEAR_SECS: f64 = 365.242190 * 86400.0;
let after_seconds = (target_age_years * 365.242190 - 30.0) * 86400.0; let after_seconds = (target_age_years * 365.242190 - 30.0) * 86400.0;
let after_utc = natal let after_utc = natal
@@ -572,15 +587,20 @@ fn build_solar_return_overlay(
.add_seconds(after_seconds.max(-TROPICAL_YEAR_SECS * 2.0)); .add_seconds(after_seconds.max(-TROPICAL_YEAR_SECS * 2.0));
let after = ESInstant::from_utc(after_utc); let after = ESInstant::from_utc(after_utc);
let return_instant = next_return(session, Body::Sun, natal_sun_lon, after, None) let return_instant = next_return(session, body, natal_lon, after, None).map_err(|e| {
.map_err(|e| EngineError::Eternal(format!("next_return Sun: {:?}", e)))?; EngineError::Eternal(format!("next_return {}: {:?}", body.name(), e))
})?;
// La carta del retorno se computa al return_instant con el mismo // La carta del retorno se computa al return_instant con el mismo
// observer y config natales (convención clásica: solar return // observer y config natales (convención clásica: return tropical
// tropical en la ciudad de nacimiento). // en la ciudad de nacimiento).
let return_birth = BirthData::new(return_instant, observer); let return_birth = BirthData::new(return_instant, observer);
let return_chart = NatalChart::compute(&return_birth, config_e, session).map_err(|e| { 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 let glyphs: Vec<Glyph> = return_chart
@@ -595,7 +615,7 @@ fn build_solar_return_overlay(
}) })
.collect(); .collect();
render.layers.push(Layer { render.layers.push(Layer {
module_id: "solar_return".into(), module_id: "planetary_return".into(),
kind: LayerKind::Outer, kind: LayerKind::Outer,
ring: 0.82, ring: 0.82,
z: 12, z: 12,
@@ -624,7 +644,7 @@ fn build_solar_return_overlay(
}) })
.collect(); .collect();
render.layers.push(Layer { render.layers.push(Layer {
module_id: "solar_return".into(), module_id: "planetary_return".into(),
kind: LayerKind::Aspects, kind: LayerKind::Aspects,
ring: 0.0, ring: 0.0,
z: 13, z: 13,
@@ -191,11 +191,16 @@ pub enum PipelineRequest {
Synastry { Synastry {
partner_chart: Box<Chart>, partner_chart: Box<Chart>,
}, },
/// `module_id = "solar_return"` — carta natal fresca al instante /// `module_id = "planetary_return"` — carta natal fresca al
/// del próximo retorno solar para la edad pedida (Sun back to /// instante del próximo retorno del cuerpo elegido a su posición
/// natal Sun). Anillo externo compartido con Transit/Synastry /// natal, para la edad pedida. Sun = retorno solar anual, Moon =
/// — mutuamente excluyentes los tres a nivel de Shell. /// mensual, Júpiter/Saturno = generacionales. Anillo externo
SolarReturn { /// 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, target_age_years: f64,
}, },
} }
@@ -136,7 +136,7 @@ impl Registry {
r.register(Box::new(progression::ProgressionModule)); r.register(Box::new(progression::ProgressionModule));
r.register(Box::new(solar_arc::SolarArcModule)); r.register(Box::new(solar_arc::SolarArcModule));
r.register(Box::new(synastry::SynastryModule)); r.register(Box::new(synastry::SynastryModule));
r.register(Box::new(solar_return::SolarReturnModule)); r.register(Box::new(planetary_return::PlanetaryReturnModule));
r 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::*; use super::*;
/// Computa la carta natal completa al instante del próximo retorno /// Computa la carta natal completa al instante del próximo retorno
/// solar para la edad pedida. Comparte el outer ring con Transit y /// del cuerpo elegido. Sun = anual (cumpleaños), Moon = mensual,
/// Synastry — mutuamente excluyentes a nivel de Shell. /// Júpiter/Saturno = generacionales. Comparte el outer ring con
pub struct SolarReturnModule; /// 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 { fn id(&self) -> &'static str {
"solar_return" "planetary_return"
} }
fn label(&self) -> &'static str { fn label(&self) -> &'static str {
"Retorno solar" "Retornos planetarios"
} }
fn description(&self) -> &'static str { 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 { fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal) matches!(kind, ChartKind::Natal)
@@ -430,6 +431,23 @@ pub mod solar_return {
default: false, default: false,
hotkey: None, 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 { Control::Slider {
key: "target_age_years".into(), key: "target_age_years".into(),
label: "Edad del retorno".into(), label: "Edad del retorno".into(),
@@ -511,7 +529,7 @@ mod tests {
assert!(r.find("progression").is_some()); assert!(r.find("progression").is_some());
assert!(r.find("solar_arc").is_some()); assert!(r.find("solar_arc").is_some());
assert!(r.find("synastry").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. // Natal kind tiene 6 módulos aplicables.
assert_eq!(r.for_kind(ChartKind::Natal).len(), 6); assert_eq!(r.for_kind(ChartKind::Natal).len(), 6);
assert!(r.for_kind(ChartKind::Synastry).is_empty()); assert!(r.for_kind(ChartKind::Synastry).is_empty());
@@ -30,7 +30,7 @@ use gpui::{
}; };
use tahuantinsuyu_model::ChartKind; use tahuantinsuyu_model::ChartKind;
use tahuantinsuyu_modules::{Control, Module, Registry}; use tahuantinsuyu_modules::{Control, Module, Registry, SelectOption};
use yahweh_theme::Theme; use yahweh_theme::Theme;
// ===================================================================== // =====================================================================
@@ -82,12 +82,14 @@ pub struct ControlPanel {
/// los pickers porque típicamente representan "todas las cartas /// los pickers porque típicamente representan "todas las cartas
/// del DB" sin filtros por módulo. /// del DB" sin filtros por módulo.
chart_options: Vec<ChartOption>, chart_options: Vec<ChartOption>,
/// Valor actual por picker. `None` = "automático" (el módulo /// Valor actual de cualquier control basado en string (ChartPicker
/// elige). /// y Select comparten storage). `None` = sin selección — el render
chart_picker_value: HashMap<(String, String), Option<String>>, /// muestra placeholder ("automático" en picker, default-label en
/// Si hay un picker abierto, su (module_id, key). Mutuamente /// select).
/// excluyente: solo uno abierto a la vez. string_state: HashMap<(String, String), Option<String>>,
chart_picker_open: Option<(String, 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, registry: Registry,
} }
@@ -102,8 +104,8 @@ impl ControlPanel {
slider_state: HashMap::new(), slider_state: HashMap::new(),
slider_drag: None, slider_drag: None,
chart_options: Vec::new(), chart_options: Vec::new(),
chart_picker_value: HashMap::new(), string_state: HashMap::new(),
chart_picker_open: None, dropdown_open: None,
registry: Registry::with_builtins(), registry: Registry::with_builtins(),
} }
} }
@@ -125,10 +127,15 @@ impl ControlPanel {
.or_insert(default); .or_insert(default);
} }
Control::ChartPicker { key, .. } => { Control::ChartPicker { key, .. } => {
self.chart_picker_value self.string_state
.entry((m.id().to_string(), key)) .entry((m.id().to_string(), key))
.or_insert(None); .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; self.active_kind = kind;
// Cerrar cualquier picker abierto al cambiar de carta. // Cerrar cualquier dropdown abierto al cambiar de carta.
self.chart_picker_open = None; self.dropdown_open = None;
cx.notify(); cx.notify();
} }
@@ -167,8 +174,23 @@ impl ControlPanel {
cx.notify(); cx.notify();
} }
/// Setea el valor de un picker desde afuera (sin emitir). El shell /// Setea el valor de un control basado en string (ChartPicker o
/// la usa para restaurar el partner persistido al cargar una carta. /// 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( pub fn set_chart_picker(
&mut self, &mut self,
module_id: &str, module_id: &str,
@@ -176,9 +198,7 @@ impl ControlPanel {
chart_id: Option<String>, chart_id: Option<String>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.chart_picker_value self.set_string(module_id, key, chart_id, cx);
.insert((module_id.to_string(), key.to_string()), chart_id);
cx.notify();
} }
// ----- internos: handlers ----- // ----- 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 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(open) if open == &key_pair => None,
_ => Some(key_pair), _ => Some(key_pair),
}; };
self.chart_picker_open = new_state; self.dropdown_open = new_state;
cx.notify(); cx.notify();
} }
fn select_picker( fn select_string_value(
&mut self, &mut self,
module_id: String, module_id: String,
key: String, key: String,
chart_id: Option<String>, value: Option<String>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.chart_picker_value self.string_state
.insert((module_id.clone(), key.clone()), chart_id.clone()); .insert((module_id.clone(), key.clone()), value.clone());
self.chart_picker_open = None; self.dropdown_open = None;
let value = match chart_id { let json_value = match value {
Some(id) => serde_json::Value::String(id), Some(s) => serde_json::Value::String(s),
None => serde_json::Value::Null, None => serde_json::Value::Null,
}; };
cx.emit(PanelEvent::ControlChanged { cx.emit(PanelEvent::ControlChanged {
module_id, module_id,
key, key,
value, value: json_value,
}); });
cx.notify(); cx.notify();
} }
@@ -451,7 +471,12 @@ impl ControlPanel {
Control::ChartPicker { key, label } => { Control::ChartPicker { key, label } => {
self.render_chart_picker(theme, module_id, key, label, cx) 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), Control::TextInput { label, default, .. } => display_row(theme, label, default),
} }
} }
@@ -668,23 +693,69 @@ impl ControlPanel {
label: &str, label: &str,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> gpui::Div { ) -> gpui::Div {
let current_id = self let options: Vec<(String, String)> = self
.chart_picker_value .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())) .get(&(module_id.to_string(), key.to_string()))
.cloned() .cloned()
.flatten(); .flatten();
let current_label = current_id let current_label = current_value
.as_ref() .as_ref()
.and_then(|id| { .and_then(|v| options.iter().find(|(val, _)| val == v).map(|(_, l)| l.clone()))
self.chart_options .unwrap_or_else(|| placeholder.to_string());
.iter()
.find(|o| &o.id == id)
.map(|o| o.label.clone())
})
.unwrap_or_else(|| "(automático)".into());
let is_open = self let is_open = self
.chart_picker_open .dropdown_open
.as_ref() .as_ref()
.map(|(m, k)| m == module_id && k == key) .map(|(m, k)| m == module_id && k == key)
.unwrap_or(false); .unwrap_or(false);
@@ -692,7 +763,7 @@ impl ControlPanel {
let module_id_btn = module_id.to_string(); let module_id_btn = module_id.to_string();
let key_btn = key.to_string(); let key_btn = key.to_string();
let btn_id: SharedString = 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() let button = div()
.id(gpui::ElementId::from(btn_id)) .id(gpui::ElementId::from(btn_id))
.px(px(10.0)) .px(px(10.0))
@@ -710,7 +781,7 @@ impl ControlPanel {
.text_color(theme.fg_text) .text_color(theme.fg_text)
.child(SharedString::from(format!("{}", current_label))) .child(SharedString::from(format!("{}", current_label)))
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { .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() let mut wrapper = div()
@@ -727,17 +798,26 @@ impl ControlPanel {
.child(button); .child(button);
if is_open { 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) div().px(px(6.0)).py(px(3.0)).child(wrapper)
} }
fn render_picker_popup( fn render_dropdown_popup(
&self, &self,
theme: &Theme, theme: &Theme,
module_id: &str, module_id: &str,
key: &str, key: &str,
options: &[(String, String)],
include_auto: bool,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> gpui::Div { ) -> gpui::Div {
let mut popup = div() let mut popup = div()
@@ -753,41 +833,47 @@ impl ControlPanel {
.flex() .flex()
.flex_col(); .flex_col();
// Opción "automático" (limpia la selección manual). if include_auto {
let module_id_clear = module_id.to_string(); let module_id_clear = module_id.to_string();
let key_clear = key.to_string(); let key_clear = key.to_string();
let clear_id: SharedString = let clear_id: SharedString =
SharedString::from(format!("tts-picker-clear-{}-{}", module_id, key)); SharedString::from(format!("tts-dropdown-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() {
popup = popup.child( popup = popup.child(
div() div()
.my(px(3.0)) .id(gpui::ElementId::from(clear_id))
.h(px(1.0)) .px(px(12.0))
.w_full() .py(px(5.0))
.bg(theme.border), .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 module_id_pick = module_id.to_string();
let key_pick = key.to_string(); let key_pick = key.to_string();
let opt_id = opt.id.clone(); let opt_value = value.clone();
let row_id: SharedString = 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( popup = popup.child(
div() div()
.id(gpui::ElementId::from(row_id)) .id(gpui::ElementId::from(row_id))
@@ -796,12 +882,12 @@ impl ControlPanel {
.text_size(px(11.0)) .text_size(px(11.0))
.text_color(theme.fg_text) .text_color(theme.fg_text)
.hover(|s| s.bg(theme.bg_row_hover)) .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| { .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.select_picker( this.select_string_value(
module_id_pick.clone(), module_id_pick.clone(),
key_pick.clone(), key_pick.clone(),
Some(opt_id.clone()), Some(opt_value.clone()),
cx, cx,
); );
})), })),