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>
d9e21fbedc434c01e97a35e8ad300ee1d3ad12d8 · by sergio · 2026-05-17T11:17:35Z (5 months ago) · parent 22e6ed6a7 · browse the tree at this commit
Semantic delta · 23
crates/apps/tahuantinsuyu/src/shell.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs
diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index fcdc296..6870627 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -33,7 +33,7 @@ use tahuantinsuyu_canvas::{
};
use tahuantinsuyu_engine::{LayerKind, PipelineRequest, compose};
use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection};
-use tahuantinsuyu_panel::{ControlPanel, PanelEvent};
+use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
use tahuantinsuyu_store::Store;
use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
use yahweh_bus::AppBus;
@@ -81,7 +81,7 @@ impl Shell {
})
.detach();
- Self {
+ let mut shell = Self {
store,
bus,
tree,
@@ -90,7 +90,25 @@ impl Shell {
current_chart: None,
current_offset_minutes: 0,
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>) {
@@ -98,6 +116,10 @@ impl Shell {
TreeEvent::Selected(s) => s,
TreeEvent::Opened(s) => s,
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();
return;
}
@@ -211,7 +233,7 @@ impl Shell {
});
}
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 {
partner_chart: Box::new(partner),
});
@@ -220,10 +242,22 @@ impl Shell {
requests
}
- /// Encuentra una carta hermana del contacto actual (cualquier otra
- /// carta con el mismo `contact_id` ≠ self). `None` si no hay
- /// hermana — el shell salta el request silenciosamente.
- fn find_synastry_partner(&self) -> Option<Chart> {
+ /// Resuelve la carta partner para sinastría: 1) si el picker tiene
+ /// un `partner_chart_id` válido en `module_configs`, lo usa; 2)
+ /// si no, cae al automático (primera carta hermana del contacto
+ /// 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 siblings = self.store.list_charts(current.contact_id).ok()?;
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
- /// la UI refleje el estado persistido al cargar una carta.
+ /// Pushea cada toggle/slider/picker del `module_configs` al panel
+ /// para que la UI refleje el estado persistido al cargar una carta.
fn sync_panel_from_configs(&mut self, cx: &mut Context<Self>) {
let snapshot: Vec<(String, serde_json::Value)> = self
.module_configs
@@ -288,6 +322,10 @@ impl Shell {
p.set_toggle(module_id, key, b, cx);
} else if let Some(f) = value.as_f64() {
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
// =====================================================================
+/// 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.
/// 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.
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
index 278cbd6..3ff3fe7 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
@@ -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()
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
index ef8793b..b97052b 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
@@ -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()
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs
index 439c1d7..557c638 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs
@@ -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(