Files
brahman/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
T
sergio 9db0591f28 feat(tahuantinsuyu): "Guardar como…" en módulo Retorno planetario (F4)
Cierra la fase B con el botón pedido por el usuario: tener una
carta natal abierta, activar el módulo Retorno planetario con
edad N + cuerpo (ej. Sol, 34 años), y al click guardar la carta
resultante con sufijo automático `rs-34` en el mismo contacto.

Infraestructura nueva (extensible a otros overlays):
- `Control::Action { key, label }` en tahuantinsuyu-modules —
  un botón sin estado que el panel pinta como pill clickeable.
- `PanelEvent::Action { module_id, key }` que el panel emite
  al click y el shell despacha.
- `render_action` en tahuantinsuyu-panel: pill con bg_button
  + hover + border. Wrap en Div plano para tipo coherente.

Backend (eternal-bridge):
- Nueva función pública `compute_planetary_return_chart(chart,
  body, target_age_years, shift_days) -> (StoredBirthData,
  instant_label)` en `tahuantinsuyu-engine`. Reusa el cómputo
  ya existente del overlay: `next_return` + parser ISO-8601
  para extraer year/mm/dd/hh:mm:ss del instant del retorno.
  Hereda lat/lon/alt/TZ del natal — convención clásica del
  Solar return en la ciudad de nacimiento.

Flujo en el shell:
- Handler `on_panel_action` despacha por `(module_id, key)`. Hoy
  solo `planetary_return.save_as_free` está cableado; otros
  módulos overlay (progression, solar_arc, primary_directions,
  transit) son extensión natural — TODO.
- `save_planetary_return_as_free`:
  1) lee config (body, age, shift_days) del module_configs
  2) llama `compute_planetary_return_chart`
  3) construye un `Chart` clonando el natal con birth_data
     nuevo + label `{contacto} rs-34 · 2024-08-12 14:23 UTC`
     (sufijo según cuerpo: `rs` para Sol, `lunar` para Luna,
     nombre directo para los demás)
  4) inserta como FreeChart con id `free-{N}` y la
     selecciona para que el usuario la vea
- El usuario después puede usar el menú contextual de la
  free chart para "Guardar como…" → modal F3 → persiste
  bajo el contacto que elija (típicamente el del natal).

UX completa:
1. Tener natal abierta
2. Panel: módulo "Retorno planetario" → Activar + elegir
   cuerpo + slider edad
3. Click "💾 Guardar retorno como carta libre"
4. La nueva carta aparece en "Cartas libres" seleccionada
5. Click derecho → "Guardar como…" → elegir contacto +
   confirmar nombre

10 tests verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 00:01:49 +00:00

1038 lines
34 KiB
Rust

//! `tahuantinsuyu-panel` — control panel inferior de la app.
//!
//! Lee los módulos disponibles para la carta activa (vía
//! [`tahuantinsuyu_modules::Registry::for_kind`]) y pinta sus
//! [`Control`]s como toggles / sliders / selects. Cada cambio emite
//! [`PanelEvent`] que la app traduce a mutaciones de visibilidad sobre
//! el canvas y al `module_configs` del shell.
//!
//! ## Estado interno
//!
//! - `toggle_state: HashMap<(module_id, key), bool>` — valor actual de
//! cada toggle. Se inicializa lazy desde los defaults del módulo al
//! cambiar de `ChartKind`.
//! - `slider_state: HashMap<(module_id, key), f64>` — valor actual de
//! cada slider. El shell puede sobreescribirlo via [`Self::set_slider`]
//! cuando cambia la carta activa (ej. inicializar `target_age_years`
//! con la edad actual del sujeto).
//! - `slider_drag: Option<SliderDrag>` — slider que está bajo drag
//! activo. Mutuamente excluyente: solo se arrastra un slider a la vez.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::collections::HashMap;
use gpui::{
Bounds, ClickEvent, Context, EventEmitter, IntoElement, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, Render, SharedString, Styled,
Window, canvas, div, prelude::*, px,
};
use tahuantinsuyu_model::ChartKind;
use tahuantinsuyu_modules::{Control, Registry, SelectOption};
use yahweh_theme::Theme;
// =====================================================================
// Eventos
// =====================================================================
#[derive(Clone, Debug)]
pub enum PanelEvent {
ModuleToggled { module_id: String, enabled: bool },
ControlChanged {
module_id: String,
key: String,
value: serde_json::Value,
},
/// Click sobre un `Control::Action`. El shell decide qué hacer
/// (típicamente: capturar la carta derivada del overlay como
/// `FreeChart`).
Action {
module_id: String,
key: String,
},
}
/// 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
// =====================================================================
#[derive(Clone, Debug)]
struct SliderDrag {
module_id: String,
key: String,
min: f64,
max: f64,
}
// =====================================================================
// Widget
// =====================================================================
pub struct ControlPanel {
active_kind: Option<ChartKind>,
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 de cualquier control basado en string (ChartPicker
/// y Select comparten storage). `None` = sin selección — el render
/// muestra placeholder ("automático" en picker, default-label en
/// select).
string_state: HashMap<(String, String), Option<String>>,
/// Si hay un dropdown abierto, su (module_id, key). Mutuamente
/// excluyente: solo uno abierto a la vez en todo el panel.
dropdown_open: Option<(String, String)>,
/// Overrides explícitos del estado expanded/collapsed por módulo.
/// La semántica del default (sin override) está en
/// [`Self::is_collapsed`]: natal y módulos enabled = expanded;
/// el resto collapsed.
collapse_overrides: HashMap<String, bool>,
registry: Registry,
}
impl EventEmitter<PanelEvent> for ControlPanel {}
impl ControlPanel {
pub fn new(cx: &mut Context<'_, Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
Self {
active_kind: None,
toggle_state: HashMap::new(),
slider_state: HashMap::new(),
slider_drag: None,
chart_options: Vec::new(),
string_state: HashMap::new(),
dropdown_open: None,
collapse_overrides: HashMap::new(),
registry: Registry::with_builtins(),
}
}
/// Decide si el card de un módulo debe pintarse collapsed (solo
/// header) o expanded (header + controles). La regla: si el usuario
/// puso un override explícito lo respetamos; sino, natal va
/// expanded siempre y el resto solo si su toggle "enabled" es true.
fn is_collapsed(&self, module_id: &str) -> bool {
if let Some(v) = self.collapse_overrides.get(module_id) {
return *v;
}
if module_id == "natal" {
return false;
}
!self
.toggle_state
.get(&(module_id.to_string(), "enabled".to_string()))
.copied()
.unwrap_or(false)
}
fn toggle_collapsed(&mut self, module_id: String, cx: &mut Context<'_, Self>) {
let current = self.is_collapsed(&module_id);
self.collapse_overrides.insert(module_id, !current);
cx.notify();
}
pub fn set_active_kind(&mut self, kind: Option<ChartKind>, cx: &mut Context<'_, Self>) {
if self.active_kind != kind {
if let Some(k) = kind {
for m in self.registry.for_kind(k) {
for c in m.controls() {
match c {
Control::Toggle { key, default, .. } => {
self.toggle_state
.entry((m.id().to_string(), key))
.or_insert(default);
}
Control::Slider { key, default, .. } => {
self.slider_state
.entry((m.id().to_string(), key))
.or_insert(default);
}
Control::ChartPicker { key, .. } => {
self.string_state
.entry((m.id().to_string(), key))
.or_insert(None);
}
Control::Select { key, default, .. } => {
self.string_state
.entry((m.id().to_string(), key))
.or_insert(Some(default));
}
_ => {}
}
}
}
}
}
self.active_kind = kind;
// Cerrar cualquier dropdown abierto al cambiar de carta.
self.dropdown_open = None;
cx.notify();
}
/// Setea un toggle desde afuera (sin emitir evento). Usado por el
/// shell para sincronizar cuando el canvas se autotoggleó via hotkey.
pub fn set_toggle(&mut self, module_id: &str, key: &str, value: bool, cx: &mut Context<'_, Self>) {
self.toggle_state
.insert((module_id.to_string(), key.to_string()), value);
cx.notify();
}
/// Setea un slider desde afuera (sin emitir evento). El shell la
/// usa, por ejemplo, para inicializar `progression.target_age_years`
/// con la edad actual del sujeto al cargar una carta nueva.
pub fn set_slider(&mut self, module_id: &str, key: &str, value: f64, cx: &mut Context<'_, Self>) {
self.slider_state
.insert((module_id.to_string(), key.to_string()), value);
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 control basado en string (ChartPicker o
/// Select) desde afuera, sin emitir. El shell la usa para restaurar
/// el valor persistido al cargar una carta.
pub fn set_string(
&mut self,
module_id: &str,
key: &str,
value: Option<String>,
cx: &mut Context<'_, Self>,
) {
self.string_state
.insert((module_id.to_string(), key.to_string()), value);
cx.notify();
}
/// Alias retrocompatible — los call-sites antiguos del shell usaban
/// `set_chart_picker`. Funcionalmente idéntico a [`Self::set_string`].
pub fn set_chart_picker(
&mut self,
module_id: &str,
key: &str,
chart_id: Option<String>,
cx: &mut Context<'_, Self>,
) {
self.set_string(module_id, key, chart_id, cx);
}
// ----- internos: handlers -----
fn on_toggle_click(&mut self, module_id: String, key: String, cx: &mut Context<'_, Self>) {
let entry = self
.toggle_state
.entry((module_id.clone(), key.clone()))
.or_insert(true);
*entry = !*entry;
let new_val = *entry;
cx.emit(PanelEvent::ControlChanged {
module_id,
key,
value: serde_json::Value::Bool(new_val),
});
cx.notify();
}
fn start_slider_drag(
&mut self,
module_id: String,
key: String,
min: f64,
max: f64,
bounds: Bounds<Pixels>,
position: Point<Pixels>,
cx: &mut Context<'_, Self>,
) {
self.slider_drag = Some(SliderDrag {
module_id: module_id.clone(),
key: key.clone(),
min,
max,
});
self.apply_slider_position(bounds, position, cx);
}
fn continue_slider_drag(
&mut self,
bounds: Bounds<Pixels>,
position: Point<Pixels>,
cx: &mut Context<'_, Self>,
) {
if self.slider_drag.is_some() {
self.apply_slider_position(bounds, position, cx);
}
}
fn end_slider_drag(&mut self, cx: &mut Context<'_, Self>) {
if self.slider_drag.take().is_some() {
cx.notify();
}
}
fn toggle_dropdown_open(&mut self, module_id: String, key: String, cx: &mut Context<'_, Self>) {
let key_pair = (module_id, key);
let new_state = match self.dropdown_open.as_ref() {
Some(open) if open == &key_pair => None,
_ => Some(key_pair),
};
self.dropdown_open = new_state;
cx.notify();
}
fn select_string_value(
&mut self,
module_id: String,
key: String,
value: Option<String>,
cx: &mut Context<'_, Self>,
) {
self.string_state
.insert((module_id.clone(), key.clone()), value.clone());
self.dropdown_open = None;
let json_value = match value {
Some(s) => serde_json::Value::String(s),
None => serde_json::Value::Null,
};
cx.emit(PanelEvent::ControlChanged {
module_id,
key,
value: json_value,
});
cx.notify();
}
fn apply_slider_position(
&mut self,
bounds: Bounds<Pixels>,
position: Point<Pixels>,
cx: &mut Context<'_, Self>,
) {
let Some(drag) = self.slider_drag.as_ref().cloned() else {
return;
};
let track_x: f32 = bounds.origin.x.into();
let track_w: f32 = bounds.size.width.into();
let mouse_x: f32 = position.x.into();
let fraction = if track_w > 0.0 {
((mouse_x - track_x) / track_w).clamp(0.0, 1.0) as f64
} else {
0.0
};
let value = drag.min + fraction * (drag.max - drag.min);
self.slider_state
.insert((drag.module_id.clone(), drag.key.clone()), value);
cx.emit(PanelEvent::ControlChanged {
module_id: drag.module_id,
key: drag.key,
value: serde_json::json!(value),
});
cx.notify();
}
}
// =====================================================================
// Render
// =====================================================================
const SLIDER_TRACK_W: f32 = 140.0;
const SLIDER_TRACK_H: f32 = 8.0;
const SLIDER_THUMB: f32 = 12.0;
impl Render for ControlPanel {
fn render(&mut self, _w: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let modules: Vec<(String, String, String, Vec<Control>)> = match self.active_kind {
Some(k) => self
.registry
.for_kind(k)
.iter()
.map(|m| {
(
m.id().to_string(),
m.label().to_string(),
m.description().to_string(),
m.controls(),
)
})
.collect(),
None => Vec::new(),
};
let header = div()
.h(px(28.0))
.px(px(12.0))
.flex()
.flex_row()
.items_center()
.gap(px(8.0))
.border_b_1()
.border_color(theme.border)
.child(
div()
.text_size(px(11.0))
.text_color(theme.fg_muted)
.child("Panel de control"),
)
.child(
div()
.ml_auto()
.text_size(px(10.0))
.text_color(theme.fg_disabled)
.child(match self.active_kind {
Some(k) => SharedString::from(format!("{:?}", k)),
None => SharedString::from("sin carta activa"),
}),
);
let mut body = div()
.id("tts-panel-body")
.flex_grow()
// `min_h(0)` libera al body de la altura intrínseca de su
// contenido — sin esto el flex_col padre lo expandiría hasta
// fit-content y el scroll nunca aparecería.
.min_h(px(0.0))
.overflow_y_scroll()
.flex()
.flex_row()
.flex_wrap()
.gap(px(16.0))
.px(px(12.0))
.py(px(8.0));
if modules.is_empty() {
body = body.child(
div()
.text_size(px(11.0))
.text_color(theme.fg_disabled)
.child("Seleccioná una carta para ver sus controles."),
);
} else {
for (id, label, desc, controls) in &modules {
body = body.child(self.render_module(&theme, id, label, desc, controls, cx));
}
}
div()
.size_full()
.bg(theme.bg_panel.clone())
.flex()
.flex_col()
.child(header)
.child(body)
}
}
impl ControlPanel {
fn render_module(
&self,
theme: &Theme,
module_id: &str,
label: &str,
description: &str,
controls: &[Control],
cx: &mut Context<'_, Self>,
) -> gpui::Div {
let collapsed = self.is_collapsed(module_id);
let chevron = if collapsed { "" } else { "" };
let header_id: SharedString =
SharedString::from(format!("tts-module-header-{}", module_id));
let module_id_for_listener = module_id.to_string();
let header = div()
.id(gpui::ElementId::from(header_id))
.flex()
.flex_row()
.items_center()
.gap(px(8.0))
.hover(|s| s.bg(theme.bg_row_hover))
.rounded(px(4.0))
.px(px(4.0))
.py(px(2.0))
.child(
div()
.text_size(px(11.0))
.text_color(theme.fg_muted)
.child(chevron),
)
.child(
div()
.flex()
.flex_col()
.flex_grow()
.gap(px(2.0))
.child(
div()
.text_size(px(12.0))
.text_color(theme.fg_text)
.child(SharedString::from(label.to_string())),
)
.child(
div()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child(SharedString::from(description.to_string())),
),
)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.toggle_collapsed(module_id_for_listener.clone(), cx);
}));
let mut card = div()
.min_w(px(260.0))
.p(px(8.0))
.rounded(px(6.0))
.bg(theme.bg_panel_alt.clone())
.border_1()
.border_color(theme.border)
.flex()
.flex_col()
.gap(px(6.0))
.child(header);
if !collapsed {
let mut body = div().flex().flex_col().gap(px(4.0));
for c in controls {
body = body.child(self.render_control(theme, module_id, c, cx));
}
card = card.child(body);
}
card
}
fn render_control(
&self,
theme: &Theme,
module_id: &str,
c: &Control,
cx: &mut Context<'_, Self>,
) -> gpui::Div {
match c {
Control::Toggle {
key,
label,
default,
hotkey,
} => self.render_toggle(theme, module_id, key, label, *default, hotkey.as_deref(), cx),
Control::Slider {
key,
label,
min,
max,
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 {
key,
label,
options,
default,
} => self.render_select(theme, module_id, key, label, options, default, cx),
Control::TextInput { label, default, .. } => display_row(theme, label, default),
Control::Action { key, label } => {
self.render_action(theme, module_id, key, label, cx)
}
}
}
fn render_action(
&self,
theme: &Theme,
module_id: &str,
key: &str,
label: &str,
cx: &mut Context<'_, Self>,
) -> gpui::Div {
let id_str: SharedString =
SharedString::from(format!("tts-action-{}-{}", module_id, key));
let id_for_listener = (module_id.to_string(), key.to_string());
let btn = div()
.id(gpui::ElementId::from(id_str))
.flex()
.flex_row()
.items_center()
.justify_center()
.px(px(10.0))
.py(px(5.0))
.rounded(px(6.0))
.bg(theme.bg_button())
.hover(|s| s.bg(theme.bg_button_hover()))
.border_1()
.border_color(theme.border)
.text_size(px(11.0))
.text_color(theme.fg_text)
.child(SharedString::from(label.to_string()))
.on_click(cx.listener(move |_this, _: &ClickEvent, _, cx| {
let (m, k) = id_for_listener.clone();
cx.emit(PanelEvent::Action {
module_id: m,
key: k,
});
}));
// Wrap en Div plano para que el tipo coincida con el resto
// de los renderers (`render_control` espera `gpui::Div`).
div().child(btn)
}
fn render_toggle(
&self,
theme: &Theme,
module_id: &str,
key: &str,
label: &str,
default: bool,
hotkey: Option<&str>,
cx: &mut Context<'_, Self>,
) -> gpui::Div {
let active = self
.toggle_state
.get(&(module_id.to_string(), key.to_string()))
.copied()
.unwrap_or(default);
let dot_color = if active {
theme.accent
} else {
theme.fg_disabled
};
let id_str: SharedString =
SharedString::from(format!("tts-toggle-{}-{}", module_id, key));
let id_for_listener = (module_id.to_string(), key.to_string());
let hotkey_str = hotkey
.map(|h| format!("[{}]", h))
.unwrap_or_default();
let row = div()
.id(gpui::ElementId::from(id_str))
.flex()
.flex_row()
.items_center()
.gap(px(8.0))
.px(px(6.0))
.py(px(3.0))
.rounded(px(4.0))
.hover(|s| s.bg(theme.bg_row_hover))
.child(div().size(px(8.0)).rounded(px(4.0)).bg(dot_color))
.child(
div()
.text_size(px(11.0))
.text_color(theme.fg_text)
.child(SharedString::from(label.to_string())),
)
.child(
div()
.ml_auto()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child(SharedString::from(hotkey_str)),
)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
let (m, k) = id_for_listener.clone();
this.on_toggle_click(m, k, cx);
}));
div().child(row)
}
fn render_slider(
&self,
theme: &Theme,
module_id: &str,
key: &str,
label: &str,
min: f64,
max: f64,
default: f64,
cx: &mut Context<'_, Self>,
) -> gpui::Div {
let value = self
.slider_state
.get(&(module_id.to_string(), key.to_string()))
.copied()
.unwrap_or(default);
let range = (max - min).max(f64::EPSILON);
let fraction = ((value - min) / range).clamp(0.0, 1.0) as f32;
let filled_w = fraction * SLIDER_TRACK_W;
let thumb_x = (fraction * SLIDER_TRACK_W) - SLIDER_THUMB / 2.0;
let entity = cx.entity();
let mod_for_mouse = module_id.to_string();
let key_for_mouse = key.to_string();
let canvas_overlay = canvas(
move |_bounds: Bounds<Pixels>, _w, _cx| (),
move |bounds: Bounds<Pixels>, _, window, _| {
// MouseDown sobre el track → start drag + valor inmediato.
let entity_d = entity.clone();
let mod_d = mod_for_mouse.clone();
let key_d = key_for_mouse.clone();
window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
if ev.button != MouseButton::Left {
return;
}
if !bounds.contains(&ev.position) {
return;
}
entity_d.update(cx, |this, cx| {
this.start_slider_drag(
mod_d.clone(),
key_d.clone(),
min,
max,
bounds,
ev.position,
cx,
);
});
});
// MouseMove (durante drag) → continuar solo si ESTE
// slider es el que está bajo drag.
let entity_m = entity.clone();
let mod_m = mod_for_mouse.clone();
let key_m = key_for_mouse.clone();
window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
if !ev.dragging() {
return;
}
entity_m.update(cx, |this, cx| {
let is_mine = this
.slider_drag
.as_ref()
.map(|d| d.module_id == mod_m && d.key == key_m)
.unwrap_or(false);
if is_mine {
this.continue_slider_drag(bounds, ev.position, cx);
}
});
});
// MouseUp anywhere → terminar drag.
let entity_u = entity.clone();
window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
entity_u.update(cx, |this, cx| this.end_slider_drag(cx));
});
},
)
.absolute()
.w(px(SLIDER_TRACK_W))
.h(px(SLIDER_TRACK_H));
let track = div()
.relative()
.w(px(SLIDER_TRACK_W))
.h(px(SLIDER_TRACK_H))
.bg(theme.bg_input())
.rounded(px(SLIDER_TRACK_H / 2.0))
.child(
div()
.absolute()
.left(px(0.0))
.top(px(0.0))
.h(px(SLIDER_TRACK_H))
.w(px(filled_w))
.bg(theme.accent)
.rounded(px(SLIDER_TRACK_H / 2.0)),
)
.child(
div()
.absolute()
.left(px(thumb_x))
.top(px(-2.0))
.w(px(SLIDER_THUMB))
.h(px(SLIDER_THUMB))
.rounded(px(SLIDER_THUMB / 2.0))
.bg(theme.fg_text)
.border_1()
.border_color(theme.border_strong),
)
.child(canvas_overlay);
div()
.flex()
.flex_col()
.gap(px(4.0))
.px(px(6.0))
.py(px(3.0))
.child(
div()
.flex()
.flex_row()
.items_center()
.gap(px(8.0))
.child(
div()
.text_size(px(11.0))
.text_color(theme.fg_text)
.child(SharedString::from(label.to_string())),
)
.child(
div()
.ml_auto()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child(SharedString::from(format!(
"{:.1} ({}{})",
value, min, max
))),
),
)
.child(track)
}
}
impl ControlPanel {
fn render_chart_picker(
&self,
theme: &Theme,
module_id: &str,
key: &str,
label: &str,
cx: &mut Context<'_, Self>,
) -> gpui::Div {
let options: Vec<(String, String)> = self
.chart_options
.iter()
.map(|o| (o.id.clone(), o.label.clone()))
.collect();
self.render_dropdown(
theme,
module_id,
key,
label,
"(automático)",
&options,
true, // incluir opción "(automático)" en el popup
cx,
)
}
fn render_select(
&self,
theme: &Theme,
module_id: &str,
key: &str,
label: &str,
options: &[SelectOption],
default: &str,
cx: &mut Context<'_, Self>,
) -> gpui::Div {
let opts: Vec<(String, String)> = options
.iter()
.map(|o| (o.value.clone(), o.label.clone()))
.collect();
let placeholder = options
.iter()
.find(|o| o.value == default)
.map(|o| o.label.clone())
.unwrap_or_else(|| default.to_string());
self.render_dropdown(theme, module_id, key, label, &placeholder, &opts, false, cx)
}
#[allow(clippy::too_many_arguments)]
fn render_dropdown(
&self,
theme: &Theme,
module_id: &str,
key: &str,
label: &str,
placeholder: &str,
options: &[(String, String)],
include_auto: bool,
cx: &mut Context<'_, Self>,
) -> gpui::Div {
let current_value = self
.string_state
.get(&(module_id.to_string(), key.to_string()))
.cloned()
.flatten();
let current_label = current_value
.as_ref()
.and_then(|v| options.iter().find(|(val, _)| val == v).map(|(_, l)| l.clone()))
.unwrap_or_else(|| placeholder.to_string());
let is_open = self
.dropdown_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-dropdown-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_dropdown_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_dropdown_popup(
theme,
module_id,
key,
options,
include_auto,
cx,
));
}
div().px(px(6.0)).py(px(3.0)).child(wrapper)
}
fn render_dropdown_popup(
&self,
theme: &Theme,
module_id: &str,
key: &str,
options: &[(String, String)],
include_auto: bool,
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();
if include_auto {
let module_id_clear = module_id.to_string();
let key_clear = key.to_string();
let clear_id: SharedString =
SharedString::from(format!("tts-dropdown-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_string_value(
module_id_clear.clone(),
key_clear.clone(),
None,
cx,
);
})),
);
if !options.is_empty() {
popup = popup.child(
div()
.my(px(3.0))
.h(px(1.0))
.w_full()
.bg(theme.border),
);
}
}
for (value, opt_label) in options {
let module_id_pick = module_id.to_string();
let key_pick = key.to_string();
let opt_value = value.clone();
let row_id: SharedString =
SharedString::from(format!("tts-dropdown-opt-{}-{}-{}", module_id, key, value));
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_string_value(
module_id_pick.clone(),
key_pick.clone(),
Some(opt_value.clone()),
cx,
);
})),
);
}
popup
}
}
fn display_row(theme: &Theme, label: &str, value: &str) -> gpui::Div {
div()
.flex()
.flex_row()
.items_center()
.gap(px(8.0))
.px(px(6.0))
.py(px(3.0))
.child(
div()
.text_size(px(11.0))
.text_color(theme.fg_text)
.child(SharedString::from(label.to_string())),
)
.child(
div()
.ml_auto()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child(SharedString::from(value.to_string())),
)
}