9db0591f28
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>
1038 lines
34 KiB
Rust
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())),
|
|
)
|
|
}
|