feat(tahuantinsuyu): fase 8 — slider interactivo + slider de edad en progression

Los `Control::Slider` del panel ya no son display-only — son arrastrables
con el mismo patrón del splitter (canvas absoluto sobre el track + window
mouse handlers en cada frame). El `ProgressionModule` ahora expone un
slider de `target_age_years` (0..120) que el shell inicializa con la
edad actual del sujeto al cargar la carta.

- panel: SliderDrag struct + slider_state HashMap + slider_drag Option
  + métodos start/continue/end_slider_drag + apply_slider_position que
  calcula fraction desde la posición del mouse relativa al track y
  emite ControlChanged con el valor float. set_slider(module, key, val)
  para sincronización externa. set_active_kind ahora inicializa también
  los sliders desde sus defaults. render_slider pinta track + portion
  filled + thumb circular + canvas overlay con handlers de drag.
  Los Slider tienen un valor visible "X.X (min...max)" en el header.
- modules: ProgressionModule agrega Control::Slider target_age_years
  con range 0..120, step 0.25, default 30 (placeholder — el shell lo
  reescribe con la edad real al cargar la carta).
- shell: apply_selection(Chart) ahora calcula current_age, lo inserta
  en module_configs["progression"]["target_age_years"], y empuja al
  panel via set_slider. build_requests ya leía target_age_years desde
  el map (de fase 7), así que ahora el slider lo controla.

Mecánica: si activás "Progresión secundaria", el slider arranca en la
edad actual del sujeto. Arrastralo a la izquierda y la rueda recompone
la carta progresada para esa edad simbólica — vas viendo cómo el sujeto
"evoluciona" o "involuciona" a través de su línea temporal interna,
con los planetas progresados moviéndose por el anillo interno y los
cross aspects con la natal reorganizándose en tiempo real.

Same pattern aplica de aquí en más para cualquier slider futuro
(harmonic en NatalModule, target_year en SolarArc, orb_multiplier, …).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-17 10:54:06 +00:00
parent 42e09fd7cd
commit e0c5c02b8e
3 changed files with 385 additions and 135 deletions
@@ -305,15 +305,26 @@ pub mod progression {
false
}
fn controls(&self) -> Vec<Control> {
vec![Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
// Sin hotkey por ahora — el toggle vive en el panel.
// Fase 8 puede agregar [G] vía un canal genérico de
// ModuleToggleRequested.
hotkey: None,
}]
vec![
Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
},
// El default (30.0) es un placeholder — el shell empuja
// la edad actual del sujeto al cargar una carta vía
// panel.set_slider("progression", "target_age_years",
// current_age).
Control::Slider {
key: "target_age_years".into(),
label: "Edad objetivo (años)".into(),
min: 0.0,
max: 120.0,
step: 0.25,
default: 30.0,
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
@@ -4,15 +4,19 @@
//! [`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 (fase 4) y eventualmente a `ModuleState` en la store.
//! el canvas y al `module_configs` del shell.
//!
//! ## Estado interno
//!
//! El panel mantiene un cache `toggle_state` con los valores actuales
//! de los toggles por (module_id, key). Inicializa desde los defaults
//! declarados por el módulo y se actualiza con cada click. Los sliders
//! / selects todavía no son interactivos en fase 4 — quedan como
//! display de "valor default".
//! - `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)]
@@ -20,8 +24,9 @@
use std::collections::HashMap;
use gpui::{
ClickEvent, Context, EventEmitter, IntoElement, ParentElement, Render, SharedString, Styled,
Window, div, prelude::*, px,
Bounds, ClickEvent, Context, EventEmitter, IntoElement, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, Render, SharedString, Styled,
Window, canvas, div, prelude::*, px,
};
use tahuantinsuyu_model::ChartKind;
@@ -42,15 +47,27 @@ pub enum PanelEvent {
},
}
// =====================================================================
// Estado interno
// =====================================================================
#[derive(Clone, Debug)]
struct SliderDrag {
module_id: String,
key: String,
min: f64,
max: f64,
}
// =====================================================================
// Widget
// =====================================================================
pub struct ControlPanel {
active_kind: Option<ChartKind>,
/// Cache de toggles por (module_id, key). Se popula lazy desde los
/// defaults la primera vez que se renderea un kind.
toggle_state: HashMap<(String, String), bool>,
slider_state: HashMap<(String, String), f64>,
slider_drag: Option<SliderDrag>,
registry: Registry,
}
@@ -62,20 +79,29 @@ impl ControlPanel {
Self {
active_kind: None,
toggle_state: HashMap::new(),
slider_state: HashMap::new(),
slider_drag: None,
registry: Registry::with_builtins(),
}
}
pub fn set_active_kind(&mut self, kind: Option<ChartKind>, cx: &mut Context<Self>) {
// Si cambia el kind, inicializamos defaults para sus módulos.
if self.active_kind != kind {
if let Some(k) = kind {
for m in self.registry.for_kind(k) {
for c in m.controls() {
if let Control::Toggle { key, default, .. } = c {
self.toggle_state
.entry((m.id().to_string(), key))
.or_insert(default);
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);
}
_ => {}
}
}
}
@@ -85,14 +111,25 @@ impl ControlPanel {
cx.notify();
}
/// Setea un toggle desde afuera (sin emitir evento). Útil cuando el
/// canvas se autotoggleó via hotkey y queremos sincronizar el panel.
/// 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();
}
// ----- internos: handlers -----
fn on_toggle_click(&mut self, module_id: String, key: String, cx: &mut Context<Self>) {
let entry = self
.toggle_state
@@ -107,13 +144,83 @@ impl ControlPanel {
});
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 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();
// Snapshot de los módulos a renderear — borrowing isssues si
// dejáramos el iterador vivo mientras mutamos en el closure.
let modules: Vec<(String, String, String, Vec<Control>)> = match self.active_kind {
Some(k) => self
.registry
@@ -221,7 +328,7 @@ impl ControlPanel {
}
div()
.min_w(px(240.0))
.min_w(px(260.0))
.p(px(8.0))
.rounded(px(6.0))
.bg(theme.bg_panel_alt.clone())
@@ -247,123 +354,242 @@ impl ControlPanel {
label,
default,
hotkey,
} => {
let active = self
.toggle_state
.get(&(module_id.to_string(), key.clone()))
.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.clone());
let row = div()
.id(gpui::ElementId::from(id_str))
} => 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::Select { label, default, .. } => display_row(theme, label, default),
Control::TextInput { label, default, .. } => display_row(theme, label, default),
}
}
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))
.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.clone())),
.child(SharedString::from(label.to_string())),
)
.child(
div()
.ml_auto()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child(SharedString::from(
hotkey
.clone()
.map(|h| format!("[{}]", h))
.unwrap_or_default(),
)),
)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
let (m, k) = id_for_listener.clone();
this.on_toggle_click(m, k, cx);
}));
// `id()` devuelve `Stateful<Div>`; envolvemos para
// mantener uniforme el return type del match.
div().child(row)
}
Control::Slider {
label,
min,
max,
default,
..
} => 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.clone())),
)
.child(
div()
.ml_auto()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child(SharedString::from(format!("{} ({}{})", default, min, max))),
),
Control::Select { label, default, .. } => 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.clone())),
)
.child(
div()
.ml_auto()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child(SharedString::from(default.clone())),
),
Control::TextInput { label, default, .. } => 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.clone())),
)
.child(
div()
.ml_auto()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child(SharedString::from(default.clone())),
),
}
.child(SharedString::from(format!(
"{:.1} ({}{})",
value, min, max
))),
),
)
.child(track)
}
}
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())),
)
}