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 · 2026-05-13T02:52:51Z (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
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);
+ }
}