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:
@@ -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,
|
||||
);
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user