Skip to main content

animato_dioxus/
gesture.rs

1//! Pointer, drag, pinch, and swipe helpers.
2
3use animato_core::Update;
4use animato_physics::{DragState, InertiaConfig, InertiaN, PointerData};
5use dioxus::prelude::{Signal, use_signal};
6use std::fmt;
7use std::sync::{Arc, Mutex};
8
9pub use animato_physics::{DragAxis, DragConstraints, Gesture, GestureConfig, SwipeDirection};
10
11/// Draggable element configuration.
12#[derive(Clone, Debug, PartialEq)]
13pub struct DragConfig {
14    /// Drag axis.
15    pub axis: DragAxis,
16    /// Optional drag constraints.
17    pub constraints: Option<DragConstraints>,
18    /// Enable inertia after pointer release.
19    pub inertia: bool,
20    /// Inertia configuration.
21    pub inertia_config: InertiaConfig<[f32; 2]>,
22    /// Snap-to points after release.
23    pub snap_points: Vec<[f32; 2]>,
24    /// Allow elastic edge behavior at constraints.
25    pub elastic_edges: bool,
26}
27
28impl Default for DragConfig {
29    fn default() -> Self {
30        Self {
31            axis: DragAxis::Both,
32            constraints: None,
33            inertia: true,
34            inertia_config: InertiaConfig::new(1400.0, 2.0),
35            snap_points: Vec::new(),
36            elastic_edges: false,
37        }
38    }
39}
40
41/// Handle returned by [`use_drag`].
42#[derive(Clone)]
43pub struct DragHandle {
44    state: Arc<Mutex<DragState>>,
45    inertia: Arc<Mutex<Option<InertiaN<[f32; 2]>>>>,
46    position: Signal<[f32; 2]>,
47    config: DragConfig,
48}
49
50impl fmt::Debug for DragHandle {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        f.debug_struct("DragHandle")
53            .field("config", &self.config)
54            .finish_non_exhaustive()
55    }
56}
57
58impl DragHandle {
59    /// Feed a pointer-down sample into the drag tracker.
60    pub fn pointer_down(&self, x: f32, y: f32, pointer_id: u64) {
61        crate::with_lock(&self.inertia, |inertia| *inertia = None);
62        crate::with_lock(&self.state, |state| {
63            state.on_pointer_down(PointerData::new(x, y, pointer_id));
64            crate::set_signal(self.position, state.position());
65        });
66    }
67
68    /// Feed a pointer-move sample into the drag tracker.
69    pub fn pointer_move(&self, x: f32, y: f32, pointer_id: u64, dt: f32) {
70        crate::with_lock(&self.state, |state| {
71            state.on_pointer_move(PointerData::new(x, y, pointer_id), dt.max(0.0));
72            crate::set_signal(self.position, state.position());
73        });
74    }
75
76    /// Feed a pointer-up sample into the drag tracker.
77    pub fn pointer_up(&self, x: f32, y: f32, pointer_id: u64) {
78        let inertia = crate::with_lock(&self.state, |state| {
79            let inertia = if self.config.inertia {
80                state.on_pointer_up(PointerData::new(x, y, pointer_id))
81            } else {
82                let _ = state.on_pointer_up(PointerData::new(x, y, pointer_id));
83                None
84            };
85            if inertia.is_none()
86                && let Some(snapped) = nearest_snap(state.position(), &self.config.snap_points)
87            {
88                state.snap_to(snapped);
89            }
90            crate::set_signal(self.position, state.position());
91            inertia
92        });
93        crate::with_lock(&self.inertia, |slot| *slot = inertia);
94    }
95
96    /// Replace the active drag constraints and clamp the current position.
97    pub fn set_constraints(&self, constraints: Option<DragConstraints>) {
98        crate::with_lock(&self.state, |state| {
99            state.set_constraints(constraints.unwrap_or_else(DragConstraints::unbounded));
100            crate::set_signal(self.position, state.position());
101        });
102    }
103
104    /// Move instantly to a position, applying the current constraints.
105    pub fn snap_to(&self, position: [f32; 2]) {
106        crate::with_lock(&self.inertia, |inertia| *inertia = None);
107        crate::with_lock(&self.state, |state| {
108            state.snap_to(position);
109            crate::set_signal(self.position, state.position());
110        });
111    }
112
113    /// Position signal.
114    pub fn position(&self) -> Signal<[f32; 2]> {
115        self.position
116    }
117
118    /// Advance any post-release inertia by `dt` seconds.
119    pub fn tick(&self, dt: f32) -> bool {
120        crate::with_lock(&self.inertia, |inertia| {
121            if let Some(active) = inertia.as_mut() {
122                let running = active.update(dt.max(0.0));
123                crate::set_signal(self.position, active.position());
124                if !running {
125                    *inertia = None;
126                }
127                running
128            } else {
129                false
130            }
131        })
132    }
133}
134
135/// Create a draggable element hook.
136pub fn use_drag<T: 'static>(target: T, config: DragConfig) -> (Signal<[f32; 2]>, DragHandle) {
137    let _ = target;
138    let initial = [0.0, 0.0];
139    let mut state = DragState::new(initial).axis(config.axis);
140    if let Some(constraints) = config.constraints {
141        state = state.constraints(constraints);
142    }
143    state = state.inertia_config(config.inertia_config.clone());
144
145    let position = use_signal(|| initial);
146    let handle = DragHandle {
147        state: Arc::new(Mutex::new(state)),
148        inertia: Arc::new(Mutex::new(None)),
149        position,
150        config,
151    };
152
153    let loop_handle = handle.clone();
154    crate::spawn_animation_loop(move |dt| {
155        loop_handle.tick(dt);
156        true
157    });
158
159    (position, handle)
160}
161
162/// Listen for recognized pointer gestures on a target.
163pub fn use_gesture<T: 'static>(target: T, config: GestureConfig) -> Signal<Option<Gesture>> {
164    let _ = (target, config);
165    use_signal(|| None)
166}
167
168/// Handle returned by [`use_pinch`].
169#[derive(Clone)]
170pub struct PinchHandle {
171    scale: Signal<f32>,
172}
173
174impl fmt::Debug for PinchHandle {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        f.debug_struct("PinchHandle").finish_non_exhaustive()
177    }
178}
179
180impl PinchHandle {
181    /// Set the pinch scale.
182    pub fn set_scale(&self, scale: f32) {
183        crate::set_signal(self.scale, crate::finite_or(scale, 1.0).max(0.0));
184    }
185
186    /// Reset the pinch scale to `1.0`.
187    pub fn reset(&self) {
188        crate::set_signal(self.scale, 1.0);
189    }
190
191    /// Scale signal.
192    pub fn scale(&self) -> Signal<f32> {
193        self.scale
194    }
195}
196
197/// Create a pinch-zoom hook.
198pub fn use_pinch<T: 'static>(target: T) -> (Signal<f32>, PinchHandle) {
199    let _ = target;
200    let scale = use_signal(|| 1.0);
201    (scale, PinchHandle { scale })
202}
203
204/// Swipe detection configuration.
205#[derive(Clone, Copy, Debug, PartialEq)]
206pub struct SwipeConfig {
207    /// Minimum distance required to emit a swipe.
208    pub min_distance: f32,
209    /// Minimum velocity required to emit a swipe.
210    pub min_velocity: f32,
211}
212
213impl Default for SwipeConfig {
214    fn default() -> Self {
215        Self {
216            min_distance: 40.0,
217            min_velocity: 100.0,
218        }
219    }
220}
221
222/// Swipe event emitted by [`use_swipe`].
223#[derive(Clone, Copy, Debug, PartialEq)]
224pub struct SwipeEvent {
225    /// Swipe direction.
226    pub direction: SwipeDirection,
227    /// Swipe velocity in pixels per second.
228    pub velocity: f32,
229    /// Swipe distance in pixels.
230    pub distance: f32,
231}
232
233/// Listen for swipe gestures on a target.
234pub fn use_swipe<T: 'static>(target: T, config: SwipeConfig) -> Signal<Option<SwipeEvent>> {
235    let _ = (target, config);
236    use_signal(|| None)
237}
238
239fn nearest_snap(position: [f32; 2], snap_points: &[[f32; 2]]) -> Option<[f32; 2]> {
240    snap_points.iter().copied().min_by(|a, b| {
241        distance_sq(position, *a)
242            .partial_cmp(&distance_sq(position, *b))
243            .unwrap_or(std::cmp::Ordering::Equal)
244    })
245}
246
247fn distance_sq(a: [f32; 2], b: [f32; 2]) -> f32 {
248    let dx = a[0] - b[0];
249    let dy = a[1] - b[1];
250    dx * dx + dy * dy
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use animato_physics::{GestureRecognizer, PointerData as PhysicsPointerData};
257    use dioxus::prelude::*;
258    use std::cell::RefCell;
259
260    thread_local! {
261        static DRAG_CAPTURE: RefCell<Option<(Signal<[f32; 2]>, DragHandle)>> = const { RefCell::new(None) };
262        static INERTIA_DRAG_CAPTURE: RefCell<Option<(Signal<[f32; 2]>, DragHandle)>> = const { RefCell::new(None) };
263        static PINCH_CAPTURE: RefCell<Option<(Signal<f32>, PinchHandle)>> = const { RefCell::new(None) };
264        static GESTURE_CAPTURE: RefCell<Option<Signal<Option<Gesture>>>> = const { RefCell::new(None) };
265        static SWIPE_CAPTURE: RefCell<Option<Signal<Option<SwipeEvent>>>> = const { RefCell::new(None) };
266    }
267
268    #[allow(non_snake_case)]
269    fn DragHookApp() -> Element {
270        let pair = use_drag(
271            "node",
272            DragConfig {
273                axis: DragAxis::X,
274                constraints: Some(DragConstraints::bounded(0.0, 100.0, 0.0, 100.0)),
275                inertia: false,
276                snap_points: vec![[0.0, 0.0], [100.0, 0.0]],
277                ..DragConfig::default()
278            },
279        );
280        DRAG_CAPTURE.with(|slot| *slot.borrow_mut() = Some(pair));
281
282        rsx! { div {} }
283    }
284
285    #[allow(non_snake_case)]
286    fn InertiaDragHookApp() -> Element {
287        let pair = use_drag(
288            "node",
289            DragConfig {
290                constraints: Some(DragConstraints::bounded(-500.0, 500.0, -500.0, 500.0)),
291                inertia: true,
292                inertia_config: InertiaConfig::new(500.0, 1.0),
293                ..DragConfig::default()
294            },
295        );
296        INERTIA_DRAG_CAPTURE.with(|slot| *slot.borrow_mut() = Some(pair));
297
298        rsx! { div {} }
299    }
300
301    #[allow(non_snake_case)]
302    fn GestureHookApp() -> Element {
303        let gesture = use_gesture("node", GestureConfig::default());
304        let pinch = use_pinch("node");
305        let swipe = use_swipe("node", SwipeConfig::default());
306        GESTURE_CAPTURE.with(|slot| *slot.borrow_mut() = Some(gesture));
307        PINCH_CAPTURE.with(|slot| *slot.borrow_mut() = Some(pinch));
308        SWIPE_CAPTURE.with(|slot| *slot.borrow_mut() = Some(swipe));
309
310        rsx! { div {} }
311    }
312
313    #[test]
314    fn nearest_snap_selects_closest_point() {
315        let points = [[0.0, 0.0], [10.0, 0.0], [20.0, 0.0]];
316        assert_eq!(nearest_snap([12.0, 0.0], &points), Some([10.0, 0.0]));
317    }
318
319    #[test]
320    fn swipe_config_has_useful_defaults() {
321        let config = SwipeConfig::default();
322        assert!(config.min_distance > 0.0);
323        assert!(config.min_velocity > 0.0);
324    }
325
326    #[test]
327    fn gesture_recognizer_detects_swipe() {
328        let mut recognizer = GestureRecognizer::new(GestureConfig::default());
329        recognizer.on_pointer_down(PhysicsPointerData::new(0.0, 0.0, 1), 0.0);
330        recognizer.on_pointer_move(PhysicsPointerData::new(100.0, 0.0, 1), 0.1);
331        let gesture = recognizer.on_pointer_up(PhysicsPointerData::new(100.0, 0.0, 1), 0.1);
332
333        assert!(matches!(
334            gesture,
335            Some(Gesture::Swipe {
336                direction: SwipeDirection::Right,
337                ..
338            })
339        ));
340    }
341
342    #[test]
343    fn drag_hook_updates_snaps_clamps_and_stops_without_inertia() {
344        DRAG_CAPTURE.with(|slot| *slot.borrow_mut() = None);
345        let mut dom = VirtualDom::new(DragHookApp);
346        dom.rebuild_in_place();
347        let (position, handle) =
348            DRAG_CAPTURE.with(|slot| slot.borrow().as_ref().cloned().expect("drag hook captured"));
349
350        assert_eq!(crate::read_signal(position), [0.0, 0.0]);
351        handle.pointer_down(0.0, 0.0, 1);
352        handle.pointer_move(80.0, 40.0, 99, 0.1);
353        assert_eq!(crate::read_signal(position), [0.0, 0.0]);
354
355        handle.pointer_move(80.0, 40.0, 1, 0.1);
356        assert_eq!(crate::read_signal(handle.position()), [80.0, 0.0]);
357        handle.pointer_up(80.0, 40.0, 1);
358        assert_eq!(crate::read_signal(position), [100.0, 0.0]);
359        assert!(!handle.tick(0.016));
360
361        handle.set_constraints(Some(DragConstraints::bounded(-10.0, 40.0, -10.0, 40.0)));
362        assert_eq!(crate::read_signal(position), [40.0, 0.0]);
363        handle.snap_to([5.0, 20.0]);
364        assert_eq!(crate::read_signal(position), [5.0, 0.0]);
365        handle.set_constraints(None);
366        handle.snap_to([f32::INFINITY, f32::NAN]);
367        assert_eq!(crate::read_signal(position), [0.0, 0.0]);
368    }
369
370    #[test]
371    fn drag_hook_runs_release_inertia_until_settled_or_cancelled() {
372        INERTIA_DRAG_CAPTURE.with(|slot| *slot.borrow_mut() = None);
373        let mut dom = VirtualDom::new(InertiaDragHookApp);
374        dom.rebuild_in_place();
375        let (position, handle) = INERTIA_DRAG_CAPTURE.with(|slot| {
376            slot.borrow()
377                .as_ref()
378                .cloned()
379                .expect("inertia drag hook captured")
380        });
381
382        handle.pointer_down(0.0, 0.0, 1);
383        handle.pointer_move(100.0, 0.0, 1, 0.01);
384        let release_position = crate::read_signal(position);
385        handle.pointer_up(100.0, 0.0, 1);
386        assert!(handle.tick(0.016));
387        assert!(crate::read_signal(position)[0] >= release_position[0]);
388
389        handle.snap_to([12.0, 24.0]);
390        assert_eq!(crate::read_signal(position), [12.0, 24.0]);
391        assert!(!handle.tick(0.016));
392    }
393
394    #[test]
395    fn gesture_pinch_and_swipe_hooks_return_stable_signals() {
396        GESTURE_CAPTURE.with(|slot| *slot.borrow_mut() = None);
397        PINCH_CAPTURE.with(|slot| *slot.borrow_mut() = None);
398        SWIPE_CAPTURE.with(|slot| *slot.borrow_mut() = None);
399        let mut dom = VirtualDom::new(GestureHookApp);
400        dom.rebuild_in_place();
401
402        let gesture = GESTURE_CAPTURE.with(|slot| {
403            slot.borrow()
404                .as_ref()
405                .copied()
406                .expect("gesture signal captured")
407        });
408        let (scale, pinch) = PINCH_CAPTURE.with(|slot| {
409            slot.borrow()
410                .as_ref()
411                .cloned()
412                .expect("pinch hook captured")
413        });
414        let swipe = SWIPE_CAPTURE.with(|slot| {
415            slot.borrow()
416                .as_ref()
417                .copied()
418                .expect("swipe signal captured")
419        });
420
421        assert_eq!(crate::read_signal(gesture), None);
422        assert_eq!(crate::read_signal(swipe), None);
423        assert_eq!(crate::read_signal(scale), 1.0);
424
425        pinch.set_scale(f32::NAN);
426        assert_eq!(crate::read_signal(pinch.scale()), 1.0);
427        pinch.set_scale(-2.0);
428        assert_eq!(crate::read_signal(scale), 0.0);
429        pinch.reset();
430        assert_eq!(crate::read_signal(scale), 1.0);
431    }
432}