gioser

tawasuyu/brahman

Rust 463TOML 180Markdown 49JSON 176353 Definiciones

feat(lapaloma-demo): pan + zoom + double-click reset interactivos

- viewport.rs: `pan_fraction(fx, fy)` — pan en fracción del viewport
  independiente del plot_rect. Útil cuando el handler GPUI trabaja
  en coords de window y no conoce el rect interno del chart.
- lapaloma-demo: state machine de drag (DragAnchor con snapshot del
  viewport al click) + handlers on_mouse_down/move/up para pan,
  on_scroll_wheel con sensitivity exponencial 0.0015 para zoom
  anchor-preserving al cursor, on_click con click_count >= 2 para
  reset al viewport inicial. El header muestra estado dragging.
- Maneja ScrollDelta::Pixels (trackpad) y ::Lines (mouse wheel
  tradicional) unificando con line-height 16px.

46 tests verdes en lapaloma-{core,cartesian,render}.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

138307c2ba723db9cb120d6d096a647a744c53ed · por sergio · (hace 5 meses) · padre fd2536971 · ver el árbol en este commit

Delta semántico · 13

crates/apps/lapaloma-demo/src/main.rs

crates/modules/ui_engine/widgets/lapaloma-cartesian/src/viewport.rs

2 archivos · +150 −23

diff --git a/crates/apps/lapaloma-demo/src/main.rs b/crates/apps/lapaloma-demo/src/main.rs
index 3c6ce14..90810d1 100644
--- a/crates/apps/lapaloma-demo/src/main.rs
+++ b/crates/apps/lapaloma-demo/src/main.rs
@@ -1,8 +1,17 @@
 //! `lapaloma-demo` — demo visual mínimo de Lapaloma sobre yahweh.
 //!
-//! Levanta una ventana de 900×560 con un único chart cartesiano
-//! pre-llenado con `sin(x · 0.04)` sobre 1024 muestras. Sirve
-//! como smoke test de la cadena completa:
+//! Levanta una ventana 900×560 con un único chart cartesiano
+//! pre-llenado con `sin(x · 0.04)` sobre 1024 muestras. Pan + zoom
+//! interactivos:
+//!
+//! - **Click + drag**: pan en X/Y normalizado al tamaño de la
+//!   ventana (vía `ChartViewport::pan_fraction`, sin requerir el
+//!   plot rect exacto).
+//! - **Mouse wheel**: zoom exponencial anchor-preserving en la
+//!   posición del cursor (sección 5.3 / 5.4 del ARCHITECTURE.md).
+//! - **Doble-click**: reset al viewport inicial.
+//!
+//! Cadena viva:
 //!
 //! ```text
 //! DataBuffer        (lapaloma-core)
@@ -12,13 +21,14 @@
 //! LineSeries        (lapaloma-cartesian)
 //!   ↓ canvas.stroke_polyline (una sola draw call)
 //! WindowCanvas      (lapaloma-render::gpui_backend)
-//!   ↓ paint_path
+//!   ↓ paint_path + paint_glyph
 //! gpui::Window
 //! ```
-//!
-//! Correr con `cargo run -p lapaloma-demo`. Requiere DISPLAY/WAYLAND.
 
-use gpui::{div, prelude::*, px, Context, IntoElement, Render, Window};
+use gpui::{
+    div, prelude::*, px, ClickEvent, Context, IntoElement, MouseButton, MouseDownEvent,
+    MouseMoveEvent, MouseUpEvent, Point, Render, ScrollDelta, ScrollWheelEvent, Window,
+};
 
 use lapaloma_cartesian::{lapaloma_chart, ChartViewport};
 use lapaloma_core::buffer::DataBuffer;
@@ -29,24 +39,116 @@ use yahweh_theme::Theme;
 const N_SAMPLES: usize = 1024;
 const FREQ: f32 = 0.04;
 
+/// Sensibilidad exponencial del wheel zoom (sección 5.4 del doc).
+const WHEEL_SENSITIVITY: f64 = 0.0015;
+
 fn main() {
-    launch_app("Lapaloma — sin(x) demo", (900., 560.), Demo::new);
+    launch_app("Lapaloma — sin(x) demo (drag = pan, wheel = zoom)", (900., 560.), Demo::new);
 }
 
 struct Demo {
     data: DataBuffer,
+    viewport: ChartViewport,
+    initial_viewport: ChartViewport,
+    drag: Option<DragAnchor>,
+}
+
+/// Snapshot del estado al inicio del drag. Mantener una copia del
+/// viewport "al click" evita acumular errores frame a frame.
+#[derive(Clone, Copy)]
+struct DragAnchor {
+    start_position: Point<gpui::Pixels>,
+    viewport_at_start: ChartViewport,
 }
 
 impl Demo {
     fn new(_cx: &mut Context<Self>) -> Self {
-        // Buffer canónico interleaved: x va 0..N-1, y = sin(x · FREQ).
         let mut data = DataBuffer::with_capacity(N_SAMPLES);
         for i in 0..N_SAMPLES {
             let x = i as f32;
-            let y = (x * FREQ).sin();
-            data.push(x, y);
+            data.push(x, (x * FREQ).sin());
+        }
+        let viewport = ChartViewport::new(0.0, (N_SAMPLES - 1) as f64, -1.1, 1.1);
+        Self {
+            data,
+            viewport,
+            initial_viewport: viewport,
+            drag: None,
+        }
+    }
+
+    fn on_mouse_down(&mut self, e: &MouseDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
+        self.drag = Some(DragAnchor {
+            start_position: e.position,
+            viewport_at_start: self.viewport,
+        });
+        cx.notify();
+    }
+
+    fn on_mouse_move(&mut self, e: &MouseMoveEvent, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(anchor) = self.drag else { return };
+        let win = window.viewport_size();
+        let w: f32 = win.width.into();
+        let h: f32 = win.height.into();
+        if w <= 0.0 || h <= 0.0 {
+            return;
+        }
+        let sx: f32 = e.position.x.into();
+        let sy: f32 = e.position.y.into();
+        let ax: f32 = anchor.start_position.x.into();
+        let ay: f32 = anchor.start_position.y.into();
+        let dfx = ((sx - ax) / w) as f64;
+        let dfy = ((sy - ay) / h) as f64;
+        let mut vp = anchor.viewport_at_start;
+        vp.pan_fraction(dfx, dfy);
+        self.viewport = vp;
+        cx.notify();
+    }
+
+    fn on_mouse_up(&mut self, _e: &MouseUpEvent, _w: &mut Window, cx: &mut Context<Self>) {
+        if self.drag.take().is_some() {
+            cx.notify();
+        }
+    }
+
+    fn on_scroll(&mut self, e: &ScrollWheelEvent, window: &mut Window, cx: &mut Context<Self>) {
+        let win = window.viewport_size();
+        let w: f32 = win.width.into();
+        let h: f32 = win.height.into();
+        if w <= 0.0 || h <= 0.0 {
+            return;
+        }
+        // dy normalizado a píxeles. ScrollDelta puede venir en
+        // líneas (mouse wheel discreto) o píxeles (trackpad).
+        let dy_px: f32 = match e.delta {
+            ScrollDelta::Pixels(p) => p.y.into(),
+            ScrollDelta::Lines(p) => {
+                let y: f32 = p.y.into();
+                y * 16.0 // line-height aproximada
+            }
+        };
+        let factor = (-dy_px as f64 * WHEEL_SENSITIVITY).exp();
+
+        let sx: f32 = e.position.x.into();
+        let sy: f32 = e.position.y.into();
+        let ax = (sx / w).clamp(0.0, 1.0) as f64;
+        // Invertimos Y: dominio crece para arriba, pantalla crece para abajo.
+        let ay = (1.0 - sy / h).clamp(0.0, 1.0) as f64;
+
+        self.viewport.zoom_uniform(factor, (ax, ay));
+        cx.notify();
+    }
+
+    fn on_click(&mut self, e: &ClickEvent, _w: &mut Window, cx: &mut Context<Self>) {
+        // Reset sólo si es doble (o triple) click de mouse. Click
+        // simple no hace nada; el drag se maneja por
+        // mouse_down/move/up. Clicks de teclado no aplican acá.
+        if let ClickEvent::Mouse(m) = e {
+            if m.up.click_count >= 2 {
+                self.viewport = self.initial_viewport;
+                cx.notify();
+            }
         }
-        Self { data }
     }
 }
 
@@ -54,19 +156,14 @@ impl Render for Demo {
     fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let theme = Theme::global(cx).clone();
 
-        // Viewport: ve toda la X, Y con un poco de margen.
-        let viewport = ChartViewport::new(0.0, (N_SAMPLES - 1) as f64, -1.1, 1.1);
-
-        // Estilo: stroke nórdico azul claro sobre fondo del theme.
         let stroke = StrokeStyle::new(2.0, Color::from_hex(0x88c0d0));
         let plot_bg = Color::rgba(0.10, 0.12, 0.16, 1.0);
+        let chart = lapaloma_chart(self.data.clone(), self.viewport, stroke).background(plot_bg);
 
-        // DataBuffer es `Clone`; el Element toma ownership del clone
-        // por frame. Para datasets enormes el siguiente paso es
-        // pasar a `Arc<DataBuffer>`; con 1k samples es trivial.
-        let chart = lapaloma_chart(self.data.clone(), viewport, stroke).background(plot_bg);
+        let drag_active = self.drag.is_some();
 
         div()
+            .id("lapaloma-demo-root")
             .size_full()
             .bg(theme.bg_app.clone())
             .p(px(16.))
@@ -84,10 +181,23 @@ impl Render for Demo {
                     .text_color(theme.fg_muted)
                     .text_size(px(12.))
                     .child(format!(
-                        "{} muestras de sin(x · {}). LineSeries · LTTB-on-density · 1 draw call.",
-                        N_SAMPLES, FREQ
+                        "{} muestras de sin(x · {}). drag = pan · wheel = zoom · double-click = reset · {}",
+                        N_SAMPLES,
+                        FREQ,
+                        if drag_active { "dragging" } else { "idle" },
                     )),
             )
-            .child(div().w_full().flex_grow().child(chart))
+            .child(
+                div()
+                    .id("lapaloma-chart-host")
+                    .w_full()
+                    .flex_grow()
+                    .child(chart)
+                    .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
+                    .on_mouse_move(cx.listener(Self::on_mouse_move))
+                    .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
+                    .on_scroll_wheel(cx.listener(Self::on_scroll))
+                    .on_click(cx.listener(Self::on_click)),
+            )
     }
 }
diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/viewport.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/viewport.rs
index a30451f..16052c3 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/viewport.rs
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/viewport.rs
@@ -58,6 +58,14 @@ impl ChartViewport {
         self.pan(dx, dy);
     }
 
+    /// Pan en **fracción del viewport**. `fx = 0.5` arrastra medio
+    /// span hacia la izquierda. Útil cuando el caller no conoce el
+    /// `plot_rect` exacto y trabaja con coords normalizadas
+    /// (drag dividido por el ancho de la window).
+    pub fn pan_fraction(&mut self, fx: f64, fy: f64) {
+        self.pan(-fx * self.x_span(), fy * self.y_span());
+    }
+
     /// Zoom anchor-preserving (sección 5.3 del ARCHITECTURE.md).
     /// `anchor_norm` es la posición del ancla **normalizada al
     /// viewport** en `[0, 1]` por eje (típicamente: la posición
@@ -127,4 +135,13 @@ mod tests {
         assert!((v.x_min - (-5.0)).abs() < 1e-9);
         assert!((v.x_max - 5.0).abs() < 1e-9);
     }
+
+    #[test]
+    fn pan_fraction_es_independiente_de_plot() {
+        let mut v = ChartViewport::new(0.0, 10.0, 0.0, 10.0);
+        // 50% del span hacia la derecha = viewport se mueve -5 en X.
+        v.pan_fraction(0.5, 0.0);
+        assert!((v.x_min - (-5.0)).abs() < 1e-9);
+        assert!((v.x_max - 5.0).abs() < 1e-9);
+    }
 }