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
+57 -9
View File
@@ -33,7 +33,7 @@ use tahuantinsuyu_canvas::{
}; };
use tahuantinsuyu_engine::{LayerKind, PipelineRequest, compose}; use tahuantinsuyu_engine::{LayerKind, PipelineRequest, compose};
use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection}; use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection};
use tahuantinsuyu_panel::{ControlPanel, PanelEvent}; use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
use tahuantinsuyu_store::Store; use tahuantinsuyu_store::Store;
use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent}; use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
use yahweh_bus::AppBus; use yahweh_bus::AppBus;
@@ -81,7 +81,7 @@ impl Shell {
}) })
.detach(); .detach();
Self { let mut shell = Self {
store, store,
bus, bus,
tree, tree,
@@ -90,7 +90,25 @@ impl Shell {
current_chart: None, current_chart: None,
current_offset_minutes: 0, current_offset_minutes: 0,
module_configs: HashMap::new(), module_configs: HashMap::new(),
};
shell.refresh_chart_options(cx);
shell
} }
/// Recarga la lista de opciones para los `Control::ChartPicker` y
/// la pushea al panel. Llamado al boot + tras cada
/// `TreeEvent::HierarchyChanged`.
fn refresh_chart_options(&self, cx: &mut Context<Self>) {
let charts = self.store.list_all_charts().unwrap_or_default();
let options: Vec<ChartOption> = charts
.into_iter()
.map(|c| ChartOption {
id: c.id.to_string(),
label: format!("{} — {}", c.label, format_birth_brief(&c.birth_data)),
})
.collect();
self.panel
.update(cx, |p, cx| p.set_chart_options(options, cx));
} }
fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) { fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) {
@@ -98,6 +116,10 @@ impl Shell {
TreeEvent::Selected(s) => s, TreeEvent::Selected(s) => s,
TreeEvent::Opened(s) => s, TreeEvent::Opened(s) => s,
TreeEvent::HierarchyChanged => { TreeEvent::HierarchyChanged => {
// La jerarquía cambió (alta/baja de cartas) — refrescar
// las opciones del picker para que aparezcan / desaparezcan
// en el dropdown.
self.refresh_chart_options(cx);
cx.notify(); cx.notify();
return; return;
} }
@@ -211,7 +233,7 @@ impl Shell {
}); });
} }
if module_enabled(&self.module_configs, "synastry") { if module_enabled(&self.module_configs, "synastry") {
if let Some(partner) = self.find_synastry_partner() { if let Some(partner) = self.resolve_synastry_partner() {
requests.push(PipelineRequest::Synastry { requests.push(PipelineRequest::Synastry {
partner_chart: Box::new(partner), partner_chart: Box::new(partner),
}); });
@@ -220,10 +242,22 @@ impl Shell {
requests requests
} }
/// Encuentra una carta hermana del contacto actual (cualquier otra /// Resuelve la carta partner para sinastría: 1) si el picker tiene
/// carta con el mismo `contact_id` ≠ self). `None` si no hay /// un `partner_chart_id` válido en `module_configs`, lo usa; 2)
/// hermana — el shell salta el request silenciosamente. /// si no, cae al automático (primera carta hermana del contacto
fn find_synastry_partner(&self) -> Option<Chart> { /// actual). `None` si nada matchea — el request se salta.
fn resolve_synastry_partner(&self) -> Option<Chart> {
let manual = self
.module_configs
.get("synastry")
.and_then(|c| c.get("partner_chart_id"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<tahuantinsuyu_model::ChartId>().ok())
.and_then(|id| self.store.get_chart(id).ok());
manual.or_else(|| self.find_synastry_partner_auto())
}
fn find_synastry_partner_auto(&self) -> Option<Chart> {
let current = self.current_chart.as_ref()?; let current = self.current_chart.as_ref()?;
let siblings = self.store.list_charts(current.contact_id).ok()?; let siblings = self.store.list_charts(current.contact_id).ok()?;
siblings.into_iter().find(|c| c.id != current.id) siblings.into_iter().find(|c| c.id != current.id)
@@ -272,8 +306,8 @@ impl Shell {
} }
} }
/// Pushea cada toggle/slider del `module_configs` al panel para que /// Pushea cada toggle/slider/picker del `module_configs` al panel
/// la UI refleje el estado persistido al cargar una carta. /// para que la UI refleje el estado persistido al cargar una carta.
fn sync_panel_from_configs(&mut self, cx: &mut Context<Self>) { fn sync_panel_from_configs(&mut self, cx: &mut Context<Self>) {
let snapshot: Vec<(String, serde_json::Value)> = self let snapshot: Vec<(String, serde_json::Value)> = self
.module_configs .module_configs
@@ -288,6 +322,10 @@ impl Shell {
p.set_toggle(module_id, key, b, cx); p.set_toggle(module_id, key, b, cx);
} 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() {
p.set_chart_picker(module_id, key, Some(s.to_string()), cx);
} else if value.is_null() {
p.set_chart_picker(module_id, key, None, cx);
} }
} }
} }
@@ -482,6 +520,16 @@ impl Shell {
// Helpers de module_configs // Helpers de module_configs
// ===================================================================== // =====================================================================
/// Etiqueta breve para mostrar al elegir una carta en el picker:
/// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
fn format_birth_brief(birth: &tahuantinsuyu_model::StoredBirthData) -> String {
let date = format!("{:04}-{:02}-{:02}", birth.year, birth.month, birth.day);
match &birth.birthplace_label {
Some(p) if !p.is_empty() => format!("{} · {}", date, p),
_ => date,
}
}
/// Edad en años decimales desde el nacimiento hasta el reloj actual. /// Edad en años decimales desde el nacimiento hasta el reloj actual.
/// Aproximación: ignora la TZ de nacimiento (no afecta a resolución de /// Aproximación: ignora la TZ de nacimiento (no afecta a resolución de
/// año) y usa una fracción de año tropical sobre los segundos Unix. /// año) y usa una fracción de año tropical sobre los segundos Unix.
@@ -100,6 +100,15 @@ pub enum Control {
label: String, label: String,
default: 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -365,12 +374,18 @@ pub mod synastry {
false false
} }
fn controls(&self) -> Vec<Control> { fn controls(&self) -> Vec<Control> {
vec![Control::Toggle { vec![
Control::Toggle {
key: "enabled".into(), key: "enabled".into(),
label: "Activar".into(), label: "Activar".into(),
default: false, default: false,
hotkey: None, hotkey: None,
}] },
Control::ChartPicker {
key: "partner_chart_id".into(),
label: "Partner".into(),
},
]
} }
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> { fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new() 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 // Estado interno
// ===================================================================== // =====================================================================
@@ -68,6 +77,17 @@ pub struct ControlPanel {
toggle_state: HashMap<(String, String), bool>, toggle_state: HashMap<(String, String), bool>,
slider_state: HashMap<(String, String), f64>, slider_state: HashMap<(String, String), f64>,
slider_drag: Option<SliderDrag>, 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, registry: Registry,
} }
@@ -81,6 +101,9 @@ impl ControlPanel {
toggle_state: HashMap::new(), toggle_state: HashMap::new(),
slider_state: HashMap::new(), slider_state: HashMap::new(),
slider_drag: None, slider_drag: None,
chart_options: Vec::new(),
chart_picker_value: HashMap::new(),
chart_picker_open: None,
registry: Registry::with_builtins(), registry: Registry::with_builtins(),
} }
} }
@@ -101,6 +124,11 @@ impl ControlPanel {
.entry((m.id().to_string(), key)) .entry((m.id().to_string(), key))
.or_insert(default); .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; self.active_kind = kind;
// Cerrar cualquier picker abierto al cambiar de carta.
self.chart_picker_open = None;
cx.notify(); cx.notify();
} }
@@ -128,6 +158,29 @@ impl ControlPanel {
cx.notify(); 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 ----- // ----- internos: handlers -----
fn on_toggle_click(&mut self, module_id: String, key: String, cx: &mut Context<Self>) { 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( fn apply_slider_position(
&mut self, &mut self,
bounds: Bounds<Pixels>, bounds: Bounds<Pixels>,
@@ -363,6 +448,9 @@ impl ControlPanel {
default, default,
.. ..
} => self.render_slider(theme, module_id, key, label, *min, *max, *default, cx), } => 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::Select { label, default, .. } => display_row(theme, label, default),
Control::TextInput { 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 { fn display_row(theme: &Theme, label: &str, value: &str) -> gpui::Div {
div() div()
.flex() .flex()
@@ -293,6 +293,24 @@ impl Store {
.and_then(|v| v.into_iter().collect::<StoreResult<Vec<_>>>()) .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> { pub fn get_chart(&self, id: ChartId) -> StoreResult<Chart> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(