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>
e0c5c02b8e2345247740805fdbb1fb9547ccb6b2 · by sergio · 2026-05-17T10:54:06Z (5 months ago) · parent 42e09fd7c · browse the tree at this commit
Semantic delta · 19
crates/apps/tahuantinsuyu/src/shell.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index e1f3342..7fccb5e 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -115,11 +115,24 @@ impl Shell {
return;
}
};
+ let age = current_age_years(&chart.birth_data);
self.current_chart = Some(chart.clone());
self.current_offset_minutes = 0;
+ // Inicializar la edad objetivo del módulo de progresión
+ // con la edad actual del sujeto, así el slider arranca
+ // "donde corresponde" si el usuario lo activa.
+ let prog_entry = self
+ .module_configs
+ .entry("progression".into())
+ .or_insert_with(|| serde_json::json!({}));
+ if let serde_json::Value::Object(map) = prog_entry {
+ map.insert("target_age_years".into(), serde_json::json!(age));
+ }
self.render_current(cx);
- self.panel
- .update(cx, |p, cx| p.set_active_kind(Some(chart.kind), cx));
+ self.panel.update(cx, |p, cx| {
+ p.set_active_kind(Some(chart.kind), cx);
+ p.set_slider("progression", "target_age_years", age, cx);
+ });
}
TreeSelection::Contact(id) => {
self.current_chart = None;
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
index 8ae243d..a857895 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
@@ -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()
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
index b6e11fe..ef8793b 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
@@ -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())),
+ )
+}