Skip to main content

gestos/
gestos.rs

1//! Demo de la **arena de gestos** de Llimphi (Tier 4 de PARIDAD-FLUTTER).
2//!
3//! Un canvas pannable + zoomable que ejercita los tres gestos nuevos:
4//!
5//! - **Ctrl + rueda** → `on_scale`: zoom hacia el cursor (camino universal de
6//!   desktop; en macOS también responde al pinch del trackpad).
7//! - **Arrastrar** (botón izquierdo) → `draggable`: paneo. Mover cancela un
8//!   long-press en curso — esa desambiguación es la "arena".
9//! - **Doble-click** → `on_double_tap`: resetea zoom y paneo.
10//! - **Mantener apretado ~500 ms quieto** → `on_long_press_at`: deja una marca
11//!   en el punto (coordenadas de mundo, así sigue al zoom/paneo).
12//!
13//! La barra inferior muestra el zoom, la cantidad de marcas y el último gesto.
14//!
15//! Corre con: `cargo run -p llimphi-ui --example gestos --release`.
16
17use llimphi_ui::llimphi_layout::taffy::{
18    prelude::{length, percent, Dimension, FlexDirection, Size, Style},
19    AlignItems, JustifyContent,
20};
21use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle, Line, Stroke};
22use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
23use llimphi_ui::{App, DragPhase, GesturePhase, Handle, View};
24
25#[derive(Clone)]
26enum Msg {
27    /// Zoom incremental con factor multiplicativo + punto focal (local al canvas).
28    Zoom { factor: f32, fx: f32, fy: f32 },
29    /// Paneo por delta de arrastre.
30    Pan { dx: f32, dy: f32 },
31    /// Doble-tap: resetear la vista.
32    Reset,
33    /// Long-press: dejar una marca en el punto (local al canvas).
34    Mark { lx: f32, ly: f32 },
35}
36
37struct Model {
38    zoom: f32,
39    pan: (f32, f32),
40    /// Marcas en coordenadas de **mundo** (independientes del zoom/paneo).
41    marks: Vec<(f32, f32)>,
42    last: String,
43}
44
45struct Gestos;
46
47impl App for Gestos {
48    type Model = Model;
49    type Msg = Msg;
50
51    fn title() -> &'static str {
52        "llimphi · gestos (pinch-zoom · double-tap · long-press)"
53    }
54
55    fn initial_size() -> (u32, u32) {
56        (900, 640)
57    }
58
59    fn init(_: &Handle<Self::Msg>) -> Self::Model {
60        Model {
61            zoom: 1.0,
62            pan: (0.0, 0.0),
63            marks: Vec::new(),
64            last: "probá: Ctrl+rueda (zoom) · arrastrar (paneo) · doble-click (reset) · mantener (marca)".into(),
65        }
66    }
67
68    fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
69        match msg {
70            Msg::Zoom { factor, fx, fy } => {
71                // Zoom hacia el cursor: mantené fijo el punto de mundo bajo
72                // (fx, fy) reajustando el paneo. new_pan = focal - rf·(focal - pan).
73                let new_zoom = (model.zoom * factor).clamp(0.15, 12.0);
74                let rf = new_zoom / model.zoom; // factor real tras el clamp
75                model.pan.0 = fx - rf * (fx - model.pan.0);
76                model.pan.1 = fy - rf * (fy - model.pan.1);
77                model.zoom = new_zoom;
78                model.last = format!("zoom ×{:.2}", model.zoom);
79            }
80            Msg::Pan { dx, dy } => {
81                model.pan.0 += dx;
82                model.pan.1 += dy;
83                model.last = "paneo".into();
84            }
85            Msg::Reset => {
86                model.zoom = 1.0;
87                model.pan = (0.0, 0.0);
88                model.last = "doble-tap → reset".into();
89            }
90            Msg::Mark { lx, ly } => {
91                // Local del canvas → mundo: (local - pan) / zoom.
92                let wx = (lx - model.pan.0) / model.zoom;
93                let wy = (ly - model.pan.1) / model.zoom;
94                model.marks.push((wx, wy));
95                model.last = format!("long-press → marca #{} @ ({wx:.0}, {wy:.0})", model.marks.len());
96            }
97        }
98        model
99    }
100
101    fn view(model: &Self::Model) -> View<Self::Msg> {
102        let zoom = model.zoom;
103        let pan = model.pan;
104        let marks = model.marks.clone();
105
106        let canvas = View::new(Style {
107            size: Size { width: percent(1.0_f32), height: Dimension::auto() },
108            flex_grow: 1.0,
109            ..Default::default()
110        })
111        .fill(Color::from_rgba8(16, 18, 26, 255))
112        .clip(true)
113        .paint_with(move |scene, _ts, rect| {
114            // Grilla de mundo paso 40px, escalada por zoom y desplazada por pan.
115            let step = 40.0 * zoom as f64;
116            if step >= 4.0 {
117                let thin = Stroke::new(1.0);
118                let grid = Color::from_rgba8(40, 46, 60, 255);
119                // Offset del primer línea visible (pan módulo step).
120                let ox = (rect.x as f64) + (pan.0 as f64).rem_euclid(step);
121                let mut x = ox;
122                while x < (rect.x + rect.w) as f64 {
123                    scene.stroke(&thin, Affine::IDENTITY, grid, None,
124                        &Line::new((x, rect.y as f64), (x, (rect.y + rect.h) as f64)));
125                    x += step;
126                }
127                let oy = (rect.y as f64) + (pan.1 as f64).rem_euclid(step);
128                let mut y = oy;
129                while y < (rect.y + rect.h) as f64 {
130                    scene.stroke(&thin, Affine::IDENTITY, grid, None,
131                        &Line::new((rect.x as f64, y), ((rect.x + rect.w) as f64, y)));
132                    y += step;
133                }
134            }
135            // Marcas (coords de mundo → pantalla): pan + world·zoom.
136            let dot = Color::from_rgba8(90, 220, 150, 255);
137            let r = (6.0 * zoom as f64).clamp(3.0, 24.0);
138            for (wx, wy) in &marks {
139                let sx = rect.x as f64 + pan.0 as f64 + (*wx as f64) * zoom as f64;
140                let sy = rect.y as f64 + pan.1 as f64 + (*wy as f64) * zoom as f64;
141                scene.fill(Fill::NonZero, Affine::IDENTITY, dot, None, &Circle::new((sx, sy), r));
142            }
143        })
144        // Pinch-to-zoom (Ctrl+rueda / trackpad). El focal viene local al canvas.
145        .on_scale(|phase, factor, fx, fy| match phase {
146            GesturePhase::Update => Some(Msg::Zoom { factor, fx, fy }),
147            _ => None,
148        })
149        // Paneo por arrastre. El movimiento cancela un long-press en curso.
150        .draggable(|phase, dx, dy| match phase {
151            DragPhase::Move => Some(Msg::Pan { dx, dy }),
152            DragPhase::End => None,
153        })
154        // Doble-tap: reset. Long-press: marca en el punto.
155        .on_double_tap(Msg::Reset)
156        .on_long_press_at(|lx, ly, _w, _h| Some(Msg::Mark { lx, ly }));
157
158        let status = View::new(Style {
159            size: Size { width: percent(1.0_f32), height: length(40.0_f32) },
160            align_items: Some(AlignItems::Center),
161            justify_content: Some(JustifyContent::Center),
162            ..Default::default()
163        })
164        .fill(Color::from_rgba8(28, 32, 42, 255))
165        .text(
166            format!("{}   ·   ×{:.2}   ·   {} marcas", model.last, model.zoom, model.marks.len()),
167            18.0,
168            Color::from_rgba8(210, 220, 235, 255),
169        );
170
171        View::new(Style {
172            flex_direction: FlexDirection::Column,
173            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
174            ..Default::default()
175        })
176        .fill(Color::from_rgba8(16, 18, 26, 255))
177        .children(vec![canvas, status])
178    }
179}
180
181fn main() {
182    llimphi_ui::run::<Gestos>();
183}