gioser

tawasuyu/brahman

Rust 332TOML 127JSON 17Markdown 154710 Definitions

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 · (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

4 files · +338 −16

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(