feat(tahuantinsuyu): fase 12 — Control::ChartPicker para Synastry

Cualquier carta del DB se puede elegir como partner de sinastría
(no solo hermanas del contacto actual). El módulo SynastryModule
declara un Control::ChartPicker; el panel renderea un dropdown que
abre un popup con todas las cartas; el shell inyecta las opciones y
resuelve el partner desde la selección persistida.

- modules: nueva variante Control::ChartPicker { key, label } sin
  default — las opciones las inyecta el host. SynastryModule.controls()
  agrega un picker con key="partner_chart_id".
- store: list_all_charts() — query sin filtros, ordenada por label
  case-insensitive. Pensada para selectores cross-contact.
- panel:
  - ChartOption struct público (id + label).
  - chart_options Vec + chart_picker_value HashMap + chart_picker_open
    Option (solo uno abierto a la vez).
  - APIs públicas set_chart_options / set_chart_picker para sync.
  - set_active_kind inicializa picker_value a None ("automático").
  - render_chart_picker: botón "▾ label" en bg_button; al click toggle
    un popup absolute con (automático) + separador + cada chart_option
    clickable. select_picker emite ControlChanged con Value::String(id)
    o Value::Null.
- shell:
  - new() llama refresh_chart_options al final para popular el panel
    desde el boot.
  - refresh_chart_options() builds Vec<ChartOption> desde
    list_all_charts (label + birth_brief "YYYY-MM-DD · Lugar"). Lo
    llamamos también desde TreeEvent::HierarchyChanged para que el
    dropdown refleje altas/bajas.
  - resolve_synastry_partner reemplaza find_synastry_partner: 1) lee
    module_configs["synastry"]["partner_chart_id"] y resuelve el chart;
    2) fallback al automático (primera hermana). El fallback significa
    que el módulo sigue funcionando sin elegir manualmente.
  - sync_panel_from_configs ahora maneja Value::String / Value::Null
    → set_chart_picker, así el picker se restaura al cargar una carta.

Persistencia: el partner_chart_id va al config_json (fase 11), así
que cada carta recuerda con quién hizo sinastría la última vez.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-17 11:17:35 +00:00
parent 22e6ed6a71
commit d9e21fbedc
4 changed files with 338 additions and 16 deletions
@@ -100,6 +100,15 @@ pub enum Control {
label: String,
default: String,
},
/// Picker dinámico de una carta de la DB. Las opciones las inyecta
/// el host (Shell) en el panel — el módulo solo declara la
/// existencia del control. Valor emitido en `ControlChanged` =
/// `Value::String(chart_id)` cuando se selecciona, `Value::Null`
/// cuando se vuelve a "automático".
ChartPicker {
key: String,
label: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -365,12 +374,18 @@ pub mod synastry {
false
}
fn controls(&self) -> Vec<Control> {
vec![Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
}]
vec![
Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
},
Control::ChartPicker {
key: "partner_chart_id".into(),
label: "Partner".into(),
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
@@ -47,6 +47,15 @@ pub enum PanelEvent {
},
}
/// Opción que el host inyecta al panel para que los `Control::ChartPicker`
/// puedan mostrar el dropdown. El `id` es el ULID stringificado de la
/// carta; el `label` es lo que se muestra en el dropdown.
#[derive(Clone, Debug)]
pub struct ChartOption {
pub id: String,
pub label: String,
}
// =====================================================================
// Estado interno
// =====================================================================
@@ -68,6 +77,17 @@ pub struct ControlPanel {
toggle_state: HashMap<(String, String), bool>,
slider_state: HashMap<(String, String), f64>,
slider_drag: Option<SliderDrag>,
/// Opciones globales para todos los `ChartPicker` — las inyecta el
/// shell vía [`Self::set_chart_options`]. Compartido entre todos
/// 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)>,
registry: Registry,
}
@@ -81,6 +101,9 @@ impl ControlPanel {
toggle_state: HashMap::new(),
slider_state: HashMap::new(),
slider_drag: None,
chart_options: Vec::new(),
chart_picker_value: HashMap::new(),
chart_picker_open: None,
registry: Registry::with_builtins(),
}
}
@@ -101,6 +124,11 @@ impl ControlPanel {
.entry((m.id().to_string(), key))
.or_insert(default);
}
Control::ChartPicker { key, .. } => {
self.chart_picker_value
.entry((m.id().to_string(), key))
.or_insert(None);
}
_ => {}
}
}
@@ -108,6 +136,8 @@ impl ControlPanel {
}
}
self.active_kind = kind;
// Cerrar cualquier picker abierto al cambiar de carta.
self.chart_picker_open = None;
cx.notify();
}
@@ -128,6 +158,29 @@ impl ControlPanel {
cx.notify();
}
/// Reemplaza el catálogo de opciones que muestran los
/// `Control::ChartPicker`. El shell la llama cada vez que la
/// jerarquía de cartas cambia (crear/borrar) para que el dropdown
/// quede al día sin necesidad de re-instanciar el panel.
pub fn set_chart_options(&mut self, options: Vec<ChartOption>, cx: &mut Context<Self>) {
self.chart_options = options;
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.
pub fn set_chart_picker(
&mut self,
module_id: &str,
key: &str,
chart_id: Option<String>,
cx: &mut Context<Self>,
) {
self.chart_picker_value
.insert((module_id.to_string(), key.to_string()), chart_id);
cx.notify();
}
// ----- internos: handlers -----
fn on_toggle_click(&mut self, module_id: String, key: String, cx: &mut Context<Self>) {
@@ -181,6 +234,38 @@ impl ControlPanel {
}
}
fn toggle_picker_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() {
Some(open) if open == &key_pair => None,
_ => Some(key_pair),
};
self.chart_picker_open = new_state;
cx.notify();
}
fn select_picker(
&mut self,
module_id: String,
key: String,
chart_id: 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),
None => serde_json::Value::Null,
};
cx.emit(PanelEvent::ControlChanged {
module_id,
key,
value,
});
cx.notify();
}
fn apply_slider_position(
&mut self,
bounds: Bounds<Pixels>,
@@ -363,6 +448,9 @@ impl ControlPanel {
default,
..
} => self.render_slider(theme, module_id, key, label, *min, *max, *default, cx),
Control::ChartPicker { key, label } => {
self.render_chart_picker(theme, module_id, key, label, cx)
}
Control::Select { label, default, .. } => display_row(theme, label, default),
Control::TextInput { label, default, .. } => display_row(theme, label, default),
}
@@ -571,6 +659,159 @@ impl ControlPanel {
}
}
impl ControlPanel {
fn render_chart_picker(
&self,
theme: &Theme,
module_id: &str,
key: &str,
label: &str,
cx: &mut Context<Self>,
) -> gpui::Div {
let current_id = self
.chart_picker_value
.get(&(module_id.to_string(), key.to_string()))
.cloned()
.flatten();
let current_label = current_id
.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());
let is_open = self
.chart_picker_open
.as_ref()
.map(|(m, k)| m == module_id && k == key)
.unwrap_or(false);
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));
let button = div()
.id(gpui::ElementId::from(btn_id))
.px(px(10.0))
.py(px(5.0))
.rounded(px(4.0))
.bg(theme.bg_button())
.hover(|s| s.bg(theme.bg_button_hover()))
.border_1()
.border_color(if is_open {
theme.accent_strong
} else {
theme.border
})
.text_size(px(11.0))
.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);
}));
let mut wrapper = div()
.relative()
.flex()
.flex_col()
.gap(px(2.0))
.child(
div()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child(SharedString::from(label.to_string())),
)
.child(button);
if is_open {
wrapper = wrapper.child(self.render_picker_popup(theme, module_id, key, cx));
}
div().px(px(6.0)).py(px(3.0)).child(wrapper)
}
fn render_picker_popup(
&self,
theme: &Theme,
module_id: &str,
key: &str,
cx: &mut Context<Self>,
) -> gpui::Div {
let mut popup = div()
.absolute()
.top(px(48.0))
.left(px(0.0))
.min_w(px(240.0))
.py(px(4.0))
.bg(theme.bg_panel_alt.clone())
.border_1()
.border_color(theme.border_strong)
.rounded(px(6.0))
.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() {
popup = popup.child(
div()
.my(px(3.0))
.h(px(1.0))
.w_full()
.bg(theme.border),
);
}
for opt in &self.chart_options {
let module_id_pick = module_id.to_string();
let key_pick = key.to_string();
let opt_id = opt.id.clone();
let row_id: SharedString =
SharedString::from(format!("tts-picker-opt-{}-{}-{}", module_id, key, opt.id));
popup = popup.child(
div()
.id(gpui::ElementId::from(row_id))
.px(px(12.0))
.py(px(5.0))
.text_size(px(11.0))
.text_color(theme.fg_text)
.hover(|s| s.bg(theme.bg_row_hover))
.child(SharedString::from(opt.label.clone()))
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.select_picker(
module_id_pick.clone(),
key_pick.clone(),
Some(opt_id.clone()),
cx,
);
})),
);
}
popup
}
}
fn display_row(theme: &Theme, label: &str, value: &str) -> gpui::Div {
div()
.flex()
@@ -293,6 +293,24 @@ impl Store {
.and_then(|v| v.into_iter().collect::<StoreResult<Vec<_>>>())
}
/// Lista todas las cartas del DB ordenadas por label (case-insensitive).
/// Pensado para pickers / selectores cross-contact (ej. elegir un
/// partner de sinastría desde cualquier contacto).
pub fn list_all_charts(&self) -> StoreResult<Vec<Chart>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, contact_id, kind, label, birth_data_json, config_json, \
related_chart_id, created_at_ms \
FROM charts ORDER BY label COLLATE NOCASE ASC",
)?;
let rows = stmt.query_map([], row_to_chart)?;
let mut out = Vec::new();
for r in rows {
out.push(r??);
}
Ok(out)
}
pub fn get_chart(&self, id: ChartId) -> StoreResult<Chart> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(