From e0c5c02b8e2345247740805fdbb1fb9547ccb6b2 Mon Sep 17 00:00:00 2001 From: sergio Date: Sun, 17 May 2026 10:54:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(tahuantinsuyu):=20fase=208=20=E2=80=94=20s?= =?UTF-8?q?lider=20interactivo=20+=20slider=20de=20edad=20en=20progression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/apps/tahuantinsuyu/src/shell.rs | 17 +- .../tahuantinsuyu-modules/src/lib.rs | 29 +- .../tahuantinsuyu-panel/src/lib.rs | 474 +++++++++++++----- 3 files changed, 385 insertions(+), 135 deletions(-) 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 { - 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 { 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` — 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, - /// 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, 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, cx: &mut Context) { - // 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.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.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) { 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, + position: Point, + cx: &mut Context, + ) { + 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, + position: Point, + cx: &mut Context, + ) { + if self.slider_drag.is_some() { + self.apply_slider_position(bounds, position, cx); + } + } + + fn end_slider_drag(&mut self, cx: &mut Context) { + if self.slider_drag.take().is_some() { + cx.notify(); + } + } + + fn apply_slider_position( + &mut self, + bounds: Bounds, + position: Point, + cx: &mut Context, + ) { + 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) -> 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)> = 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, + ) -> 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, + ) -> 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, _w, _cx| (), + move |bounds: Bounds, _, 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
`; 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())), + ) +}