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, +} + +/// 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, + viewport_at_start: ChartViewport, } impl Demo { fn new(_cx: &mut Context) -> 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.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) { + 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) { + if self.drag.take().is_some() { + cx.notify(); + } + } + + fn on_scroll(&mut self, e: &ScrollWheelEvent, window: &mut Window, cx: &mut Context) { + 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) { + // 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) -> 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`; 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); + } }