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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user