From d9e21fbedc434c01e97a35e8ad300ee1d3ad12d8 Mon Sep 17 00:00:00 2001 From: sergio Date: Sun, 17 May 2026 11:17:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(tahuantinsuyu):=20fase=2012=20=E2=80=94=20?= =?UTF-8?q?Control::ChartPicker=20para=20Synastry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- crates/apps/tahuantinsuyu/src/shell.rs | 68 ++++- .../tahuantinsuyu-modules/src/lib.rs | 27 +- .../tahuantinsuyu-panel/src/lib.rs | 241 ++++++++++++++++++ .../tahuantinsuyu-store/src/lib.rs | 18 ++ 4 files changed, 338 insertions(+), 16 deletions(-) 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) { + let charts = self.store.list_all_charts().unwrap_or_default(); + let options: Vec = 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) { @@ -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 { + /// 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 { + 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::().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 { 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) { 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 { - 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 { 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, + /// 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, + /// Valor actual por picker. `None` = "automático" (el módulo + /// elige). + chart_picker_value: HashMap<(String, String), Option>, + /// 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, cx: &mut Context) { + 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, + cx: &mut Context, + ) { + 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) { @@ -181,6 +234,38 @@ impl ControlPanel { } } + fn toggle_picker_open(&mut self, module_id: String, key: String, cx: &mut Context) { + 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, + cx: &mut Context, + ) { + 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, @@ -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, + ) -> 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, + ) -> 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::>>()) } + /// 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> { + 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 { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare(