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
@@ -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,
);
})),