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
+15 -2
View File
@@ -115,11 +115,24 @@ impl Shell {
return; return;
} }
}; };
let age = current_age_years(&chart.birth_data);
self.current_chart = Some(chart.clone()); self.current_chart = Some(chart.clone());
self.current_offset_minutes = 0; 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.render_current(cx);
self.panel self.panel.update(cx, |p, cx| {
.update(cx, |p, cx| p.set_active_kind(Some(chart.kind), cx)); p.set_active_kind(Some(chart.kind), cx);
p.set_slider("progression", "target_age_years", age, cx);
});
} }
TreeSelection::Contact(id) => { TreeSelection::Contact(id) => {
self.current_chart = None; self.current_chart = None;
@@ -305,15 +305,26 @@ pub mod progression {
false false
} }
fn controls(&self) -> Vec<Control> { fn controls(&self) -> Vec<Control> {
vec![Control::Toggle { vec![
Control::Toggle {
key: "enabled".into(), key: "enabled".into(),
label: "Activar".into(), label: "Activar".into(),
default: false, 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, 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> { fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new() Vec::new()
@@ -4,15 +4,19 @@
//! [`tahuantinsuyu_modules::Registry::for_kind`]) y pinta sus //! [`tahuantinsuyu_modules::Registry::for_kind`]) y pinta sus
//! [`Control`]s como toggles / sliders / selects. Cada cambio emite //! [`Control`]s como toggles / sliders / selects. Cada cambio emite
//! [`PanelEvent`] que la app traduce a mutaciones de visibilidad sobre //! [`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 //! ## Estado interno
//! //!
//! El panel mantiene un cache `toggle_state` con los valores actuales //! - `toggle_state: HashMap<(module_id, key), bool>` — valor actual de
//! de los toggles por (module_id, key). Inicializa desde los defaults //! cada toggle. Se inicializa lazy desde los defaults del módulo al
//! declarados por el módulo y se actualiza con cada click. Los sliders //! cambiar de `ChartKind`.
//! / selects todavía no son interactivos en fase 4 — quedan como //! - `slider_state: HashMap<(module_id, key), f64>` — valor actual de
//! display de "valor default". //! 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)] #![forbid(unsafe_code)]
#![warn(rust_2018_idioms)] #![warn(rust_2018_idioms)]
@@ -20,8 +24,9 @@
use std::collections::HashMap; use std::collections::HashMap;
use gpui::{ use gpui::{
ClickEvent, Context, EventEmitter, IntoElement, ParentElement, Render, SharedString, Styled, Bounds, ClickEvent, Context, EventEmitter, IntoElement, MouseButton, MouseDownEvent,
Window, div, prelude::*, px, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, Render, SharedString, Styled,
Window, canvas, div, prelude::*, px,
}; };
use tahuantinsuyu_model::ChartKind; 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 // Widget
// ===================================================================== // =====================================================================
pub struct ControlPanel { pub struct ControlPanel {
active_kind: Option<ChartKind>, 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>, toggle_state: HashMap<(String, String), bool>,
slider_state: HashMap<(String, String), f64>,
slider_drag: Option<SliderDrag>,
registry: Registry, registry: Registry,
} }
@@ -62,21 +79,30 @@ impl ControlPanel {
Self { Self {
active_kind: None, active_kind: None,
toggle_state: HashMap::new(), toggle_state: HashMap::new(),
slider_state: HashMap::new(),
slider_drag: None,
registry: Registry::with_builtins(), registry: Registry::with_builtins(),
} }
} }
pub fn set_active_kind(&mut self, kind: Option<ChartKind>, cx: &mut Context<Self>) { 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 self.active_kind != kind {
if let Some(k) = kind { if let Some(k) = kind {
for m in self.registry.for_kind(k) { for m in self.registry.for_kind(k) {
for c in m.controls() { for c in m.controls() {
if let Control::Toggle { key, default, .. } = c { match c {
Control::Toggle { key, default, .. } => {
self.toggle_state self.toggle_state
.entry((m.id().to_string(), key)) .entry((m.id().to_string(), key))
.or_insert(default); .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(); cx.notify();
} }
/// Setea un toggle desde afuera (sin emitir evento). Útil cuando el /// Setea un toggle desde afuera (sin emitir evento). Usado por el
/// canvas se autotoggleó via hotkey y queremos sincronizar el panel. /// 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>) { pub fn set_toggle(&mut self, module_id: &str, key: &str, value: bool, cx: &mut Context<Self>) {
self.toggle_state self.toggle_state
.insert((module_id.to_string(), key.to_string()), value); .insert((module_id.to_string(), key.to_string()), value);
cx.notify(); 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>) { fn on_toggle_click(&mut self, module_id: String, key: String, cx: &mut Context<Self>) {
let entry = self let entry = self
.toggle_state .toggle_state
@@ -107,13 +144,83 @@ impl ControlPanel {
}); });
cx.notify(); 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 { impl Render for ControlPanel {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone(); 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 { let modules: Vec<(String, String, String, Vec<Control>)> = match self.active_kind {
Some(k) => self Some(k) => self
.registry .registry
@@ -221,7 +328,7 @@ impl ControlPanel {
} }
div() div()
.min_w(px(240.0)) .min_w(px(260.0))
.p(px(8.0)) .p(px(8.0))
.rounded(px(6.0)) .rounded(px(6.0))
.bg(theme.bg_panel_alt.clone()) .bg(theme.bg_panel_alt.clone())
@@ -247,12 +354,35 @@ impl ControlPanel {
label, label,
default, default,
hotkey, 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::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 let active = self
.toggle_state .toggle_state
.get(&(module_id.to_string(), key.clone())) .get(&(module_id.to_string(), key.to_string()))
.copied() .copied()
.unwrap_or(*default); .unwrap_or(default);
let dot_color = if active { let dot_color = if active {
theme.accent theme.accent
} else { } else {
@@ -260,7 +390,10 @@ impl ControlPanel {
}; };
let id_str: SharedString = let id_str: SharedString =
SharedString::from(format!("tts-toggle-{}-{}", module_id, key)); SharedString::from(format!("tts-toggle-{}-{}", module_id, key));
let id_for_listener = (module_id.to_string(), key.clone()); 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() let row = div()
.id(gpui::ElementId::from(id_str)) .id(gpui::ElementId::from(id_str))
.flex() .flex()
@@ -276,94 +409,187 @@ impl ControlPanel {
div() div()
.text_size(px(11.0)) .text_size(px(11.0))
.text_color(theme.fg_text) .text_color(theme.fg_text)
.child(SharedString::from(label.clone())), .child(SharedString::from(label.to_string())),
) )
.child( .child(
div() div()
.ml_auto() .ml_auto()
.text_size(px(10.0)) .text_size(px(10.0))
.text_color(theme.fg_muted) .text_color(theme.fg_muted)
.child(SharedString::from( .child(SharedString::from(hotkey_str)),
hotkey
.clone()
.map(|h| format!("[{}]", h))
.unwrap_or_default(),
)),
) )
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
let (m, k) = id_for_listener.clone(); let (m, k) = id_for_listener.clone();
this.on_toggle_click(m, k, cx); this.on_toggle_click(m, k, cx);
})); }));
// `id()` devuelve `Stateful<Div>`; envolvemos para
// mantener uniforme el return type del match.
div().child(row) div().child(row)
} }
Control::Slider {
label, 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, min,
max, max,
default, bounds,
.. ev.position,
} => div() cx,
.flex() );
.flex_row() });
.items_center() });
.gap(px(8.0))
.px(px(6.0)) // MouseMove (durante drag) → continuar solo si ESTE
.py(px(3.0)) // slider es el que está bajo drag.
.child( let entity_m = entity.clone();
div() let mod_m = mod_for_mouse.clone();
.text_size(px(11.0)) let key_m = key_for_mouse.clone();
.text_color(theme.fg_text) window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
.child(SharedString::from(label.clone())), if !ev.dragging() {
) return;
.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())),
),
} }
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)
} }
} }
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())),
)
}