gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definiciones

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 · por sergio · (hace 5 meses) · padre 42e09fd7c · ver el árbol en este commit

Delta semántico · 19

crates/apps/tahuantinsuyu/src/shell.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs

3 archivos · +385 −135

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())),
+        )
+}