Skip to main content

dioxus_dnd/core/
machine.rs

1//! A formal state machine for pointer-driven drag gestures.
2//!
3//! The lifecycle every synthesized drag goes through - press, threshold
4//! promotion, tracking, release or abort - is modeled here as a pure
5//! transition function over explicit states and events, so every edge
6//! (stray pointer ids, release before the threshold, cancellation mid-drag)
7//! is an exhaustive match arm with a test, not an ad-hoc `if`.
8//!
9//! [`crate::core::Draggable`] drives this machine; you can drive it yourself
10//! to build custom pointer interactions with the same rigor:
11//!
12//! ```rust
13//! use dioxus_dnd::core::{transition, GestureEffect, GesturePhase, GestureEvent, Point};
14//!
15//! let mut phase = GesturePhase::Idle;
16//! let (next, fx) = transition(phase, GestureEvent::Down { at: Point::new(10.0, 10.0), pointer_id: 1 }, 8.0);
17//! phase = next;
18//! assert_eq!(fx, GestureEffect::None); // pressed, not yet a drag
19//!
20//! let (next, fx) = transition(phase, GestureEvent::Move { at: Point::new(30.0, 10.0), pointer_id: 1 }, 8.0);
21//! assert!(matches!(fx, GestureEffect::Begin { .. })); // crossed the threshold
22//! # let _ = next;
23//! ```
24
25use super::types::Point;
26
27/// Where a pointer gesture currently stands.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub enum GesturePhase {
30    /// No interaction in progress.
31    Idle,
32    /// Pointer is down on a draggable but hasn't traveled past the
33    /// threshold - could still resolve as a tap.
34    Pressed {
35        /// Where the press started (client coordinates).
36        origin: Point,
37        /// The pointer that owns this gesture.
38        pointer_id: i32,
39    },
40    /// An active drag.
41    Dragging {
42        /// Where the press started.
43        origin: Point,
44        /// The pointer that owns this gesture.
45        pointer_id: i32,
46    },
47}
48
49/// An input to the machine.
50///
51/// Non-exhaustive because the gesture vocabulary provably grows (`Hold`
52/// arrived in 2.5); feed events in freely, but never match this
53/// exhaustively - treat unknown inputs as inert, like the machine does.
54#[derive(Debug, Clone, Copy, PartialEq)]
55#[non_exhaustive]
56pub enum GestureEvent {
57    /// Pointer pressed.
58    Down { at: Point, pointer_id: i32 },
59    /// Pointer moved.
60    Move { at: Point, pointer_id: i32 },
61    /// Pointer released.
62    Up { at: Point, pointer_id: i32 },
63    /// The press's hold timer elapsed while the pointer stayed put: a
64    /// long-press. Promotes a matching [`GesturePhase::Pressed`] straight to
65    /// a drag; inert in every other phase, so a stale timer firing after the
66    /// gesture already resolved is harmless.
67    Hold { pointer_id: i32 },
68    /// The platform cancelled the gesture (`pointercancel`).
69    Cancel,
70}
71
72/// How a press gets promoted to a drag - the policy half of the touch
73/// auto-sensor.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75pub enum Promotion {
76    /// Travel in any direction past the threshold begins the drag. The right
77    /// policy for mouse and pen, and for touch surfaces that own every
78    /// gesture (`touch-action: none`).
79    #[default]
80    Distance,
81    /// Touch sharing the viewport with native vertical scrolling
82    /// (`touch-action: pan-y`): a [`GestureEvent::Hold`] or a
83    /// sideways-dominant pull (|dx| > |dy|) past the threshold begins the
84    /// drag, while a vertical-dominant pull resolves the press as scroll
85    /// intent - the machine returns to `Idle` and the browser's pan takes
86    /// the gesture (an exact diagonal counts as scroll).
87    HoldOrSideways,
88}
89
90/// What the caller should do after a transition.
91#[derive(Debug, Clone, Copy, PartialEq)]
92pub enum GestureEffect {
93    /// Nothing - including events from foreign pointer ids, which the
94    /// machine deliberately ignores.
95    None,
96    /// The threshold was crossed: begin the drag. `origin` is where the
97    /// press started (use it for the grab offset), `at` is the current
98    /// pointer position.
99    Begin { origin: Point, at: Point },
100    /// An active drag moved: track the pointer and update hover.
101    Track { at: Point },
102    /// An active drag released: attempt the drop at `at`.
103    Drop { at: Point },
104    /// The press resolved as a tap (released before the threshold).
105    Tap,
106    /// An active drag was aborted: clean up drag state.
107    Abort,
108}
109
110/// Advance the machine with the default [`Promotion::Distance`] policy.
111/// Pure: same inputs, same outputs, no side effects.
112///
113/// `threshold` is the travel distance (CSS px) that promotes a press to a
114/// drag; releases inside it resolve as [`GestureEffect::Tap`].
115pub fn transition(
116    phase: GesturePhase,
117    event: GestureEvent,
118    threshold: f64,
119) -> (GesturePhase, GestureEffect) {
120    transition_with(phase, event, threshold, Promotion::Distance)
121}
122
123/// Advance the machine under an explicit [`Promotion`] policy. Pure: same
124/// inputs, same outputs, no side effects.
125///
126/// The policy only shapes how a [`GesturePhase::Pressed`] press becomes a
127/// drag; everything after `Begin` is policy-independent.
128pub fn transition_with(
129    phase: GesturePhase,
130    event: GestureEvent,
131    threshold: f64,
132    promotion: Promotion,
133) -> (GesturePhase, GestureEffect) {
134    use GestureEffect as Fx;
135    use GesturePhase as P;
136
137    match (phase, event) {
138        // --- starting -----------------------------------------------------
139        (P::Idle, GestureEvent::Down { at, pointer_id }) => (
140            P::Pressed {
141                origin: at,
142                pointer_id,
143            },
144            Fx::None,
145        ),
146        // A second pointer pressing mid-gesture doesn't steal it.
147        (p @ (P::Pressed { .. } | P::Dragging { .. }), GestureEvent::Down { .. }) => (p, Fx::None),
148
149        // --- pressed: promote, tap, or wait --------------------------------
150        (
151            P::Pressed { origin, pointer_id },
152            GestureEvent::Move {
153                at,
154                pointer_id: pid,
155            },
156        ) if pid == pointer_id => {
157            let d = at - origin;
158            if (d.x * d.x + d.y * d.y).sqrt() >= threshold {
159                match promotion {
160                    Promotion::Distance => {
161                        (P::Dragging { origin, pointer_id }, Fx::Begin { origin, at })
162                    }
163                    Promotion::HoldOrSideways if d.x.abs() > d.y.abs() => {
164                        (P::Dragging { origin, pointer_id }, Fx::Begin { origin, at })
165                    }
166                    // Vertical-dominant travel is scroll intent: yield the
167                    // gesture. The browser pan (when the surface can scroll)
168                    // arrives as `pointercancel`, which finds Idle and stays
169                    // silent.
170                    Promotion::HoldOrSideways => (P::Idle, Fx::None),
171                }
172            } else {
173                (P::Pressed { origin, pointer_id }, Fx::None)
174            }
175        }
176        // A long-press is a promotion regardless of policy: drivers that
177        // never arm a timer simply never feed `Hold`. The drag begins at the
178        // press origin, so the grab offset is exactly where the finger sat.
179        (P::Pressed { origin, pointer_id }, GestureEvent::Hold { pointer_id: pid })
180            if pid == pointer_id =>
181        {
182            (
183                P::Dragging { origin, pointer_id },
184                Fx::Begin { origin, at: origin },
185            )
186        }
187        (
188            P::Pressed { pointer_id, .. },
189            GestureEvent::Up {
190                pointer_id: pid, ..
191            },
192        ) if pid == pointer_id => (P::Idle, Fx::Tap),
193
194        // --- dragging: track, drop ----------------------------------------
195        (
196            P::Dragging { origin, pointer_id },
197            GestureEvent::Move {
198                at,
199                pointer_id: pid,
200            },
201        ) if pid == pointer_id => (P::Dragging { origin, pointer_id }, Fx::Track { at }),
202        (
203            P::Dragging { pointer_id, .. },
204            GestureEvent::Up {
205                at,
206                pointer_id: pid,
207            },
208        ) if pid == pointer_id => (P::Idle, Fx::Drop { at }),
209
210        // --- cancellation ---------------------------------------------------
211        (P::Dragging { .. }, GestureEvent::Cancel) => (P::Idle, Fx::Abort),
212        (P::Pressed { .. }, GestureEvent::Cancel) => (P::Idle, Fx::None),
213        (P::Idle, GestureEvent::Cancel) => (P::Idle, Fx::None),
214
215        // --- everything else is deliberately inert -------------------------
216        // Foreign pointer ids, moves/ups while idle: ignored, not errors -
217        // browsers deliver stray events and a UI library shrugs them off.
218        (p, _) => (p, Fx::None),
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    const T: f64 = 8.0;
227
228    fn down(x: f64, y: f64, pid: i32) -> GestureEvent {
229        GestureEvent::Down {
230            at: Point::new(x, y),
231            pointer_id: pid,
232        }
233    }
234    fn mv(x: f64, y: f64, pid: i32) -> GestureEvent {
235        GestureEvent::Move {
236            at: Point::new(x, y),
237            pointer_id: pid,
238        }
239    }
240    fn up(x: f64, y: f64, pid: i32) -> GestureEvent {
241        GestureEvent::Up {
242            at: Point::new(x, y),
243            pointer_id: pid,
244        }
245    }
246
247    #[test]
248    fn full_drag_lifecycle() {
249        let (p, fx) = transition(GesturePhase::Idle, down(0.0, 0.0, 1), T);
250        assert_eq!(fx, GestureEffect::None);
251
252        // sub-threshold wiggle stays pressed
253        let (p, fx) = transition(p, mv(3.0, 3.0, 1), T);
254        assert!(matches!(p, GesturePhase::Pressed { .. }));
255        assert_eq!(fx, GestureEffect::None);
256
257        // crossing the threshold begins the drag with the press origin
258        let (p, fx) = transition(p, mv(10.0, 0.0, 1), T);
259        assert_eq!(
260            fx,
261            GestureEffect::Begin {
262                origin: Point::new(0.0, 0.0),
263                at: Point::new(10.0, 0.0)
264            }
265        );
266
267        let (p, fx) = transition(p, mv(20.0, 5.0, 1), T);
268        assert_eq!(
269            fx,
270            GestureEffect::Track {
271                at: Point::new(20.0, 5.0)
272            }
273        );
274
275        let (p, fx) = transition(p, up(20.0, 5.0, 1), T);
276        assert_eq!(p, GesturePhase::Idle);
277        assert_eq!(
278            fx,
279            GestureEffect::Drop {
280                at: Point::new(20.0, 5.0)
281            }
282        );
283    }
284
285    #[test]
286    fn release_before_threshold_is_a_tap() {
287        let (p, _) = transition(GesturePhase::Idle, down(0.0, 0.0, 1), T);
288        let (p, fx) = transition(p, up(2.0, 2.0, 1), T);
289        assert_eq!(p, GesturePhase::Idle);
290        assert_eq!(fx, GestureEffect::Tap);
291    }
292
293    #[test]
294    fn foreign_pointer_ids_are_ignored() {
295        let (p, _) = transition(GesturePhase::Idle, down(0.0, 0.0, 1), T);
296        // a second finger moves and lifts: gesture unaffected
297        let (p, fx) = transition(p, mv(100.0, 100.0, 2), T);
298        assert!(matches!(p, GesturePhase::Pressed { .. }));
299        assert_eq!(fx, GestureEffect::None);
300        let (p, fx) = transition(p, up(100.0, 100.0, 2), T);
301        assert!(matches!(p, GesturePhase::Pressed { .. }));
302        assert_eq!(fx, GestureEffect::None);
303        // a second finger pressing doesn't steal ownership
304        let (p, fx) = transition(p, down(50.0, 50.0, 2), T);
305        assert!(matches!(p, GesturePhase::Pressed { pointer_id: 1, .. }));
306        assert_eq!(fx, GestureEffect::None);
307    }
308
309    #[test]
310    fn cancel_paths() {
311        // cancel mid-drag aborts
312        let (p, _) = transition(GesturePhase::Idle, down(0.0, 0.0, 1), T);
313        let (p, _) = transition(p, mv(20.0, 0.0, 1), T);
314        let (p, fx) = transition(p, GestureEvent::Cancel, T);
315        assert_eq!((p, fx), (GesturePhase::Idle, GestureEffect::Abort));
316        // cancel while merely pressed is silent
317        let (p, _) = transition(GesturePhase::Idle, down(0.0, 0.0, 1), T);
318        let (p, fx) = transition(p, GestureEvent::Cancel, T);
319        assert_eq!((p, fx), (GesturePhase::Idle, GestureEffect::None));
320    }
321
322    #[test]
323    fn stray_events_while_idle_are_inert() {
324        for ev in [mv(9.0, 9.0, 1), up(9.0, 9.0, 1)] {
325            let (p, fx) = transition(GesturePhase::Idle, ev, T);
326            assert_eq!((p, fx), (GesturePhase::Idle, GestureEffect::None));
327        }
328    }
329
330    #[test]
331    fn exact_threshold_promotes() {
332        let (p, _) = transition(GesturePhase::Idle, down(0.0, 0.0, 1), T);
333        let (_, fx) = transition(p, mv(8.0, 0.0, 1), T);
334        assert!(matches!(fx, GestureEffect::Begin { .. }));
335    }
336
337    fn hold(pid: i32) -> GestureEvent {
338        GestureEvent::Hold { pointer_id: pid }
339    }
340
341    fn step_auto(p: GesturePhase, ev: GestureEvent) -> (GesturePhase, GestureEffect) {
342        transition_with(p, ev, T, Promotion::HoldOrSideways)
343    }
344
345    #[test]
346    fn hold_promotes_at_the_press_origin() {
347        let (p, _) = step_auto(GesturePhase::Idle, down(5.0, 5.0, 1));
348        let (p, fx) = step_auto(p, hold(1));
349        assert!(matches!(p, GesturePhase::Dragging { .. }));
350        assert_eq!(
351            fx,
352            GestureEffect::Begin {
353                origin: Point::new(5.0, 5.0),
354                at: Point::new(5.0, 5.0)
355            }
356        );
357        // and the drag then tracks normally
358        let (_, fx) = step_auto(p, mv(5.0, 40.0, 1));
359        assert_eq!(
360            fx,
361            GestureEffect::Track {
362                at: Point::new(5.0, 40.0)
363            }
364        );
365    }
366
367    #[test]
368    fn stale_or_foreign_hold_is_inert() {
369        // foreign pointer id while pressed
370        let (p, _) = step_auto(GesturePhase::Idle, down(0.0, 0.0, 1));
371        let (p2, fx) = step_auto(p, hold(2));
372        assert_eq!((p2, fx), (p, GestureEffect::None));
373        // firing while idle (gesture already resolved)
374        let (p, fx) = step_auto(GesturePhase::Idle, hold(1));
375        assert_eq!((p, fx), (GesturePhase::Idle, GestureEffect::None));
376        // firing mid-drag changes nothing
377        let (p, _) = step_auto(GesturePhase::Idle, down(0.0, 0.0, 1));
378        let (p, _) = step_auto(p, hold(1));
379        let (p2, fx) = step_auto(p, hold(1));
380        assert_eq!((p2, fx), (p, GestureEffect::None));
381        // hold under the Distance policy still promotes - the policy shapes
382        // Move promotion only, and Distance drivers never feed Hold anyway
383        let (p, _) = transition(GesturePhase::Idle, down(0.0, 0.0, 1), T);
384        let (_, fx) = transition(p, hold(1), T);
385        assert!(matches!(fx, GestureEffect::Begin { .. }));
386    }
387
388    #[test]
389    fn sideways_pull_promotes_under_auto() {
390        let (p, _) = step_auto(GesturePhase::Idle, down(0.0, 0.0, 1));
391        let (_, fx) = step_auto(p, mv(9.0, 4.0, 1));
392        assert_eq!(
393            fx,
394            GestureEffect::Begin {
395                origin: Point::new(0.0, 0.0),
396                at: Point::new(9.0, 4.0)
397            }
398        );
399    }
400
401    #[test]
402    fn vertical_pull_yields_to_scroll_under_auto() {
403        let (p, _) = step_auto(GesturePhase::Idle, down(0.0, 0.0, 1));
404        // sub-threshold drift keeps waiting
405        let (p, fx) = step_auto(p, mv(1.0, 5.0, 1));
406        assert!(matches!(p, GesturePhase::Pressed { .. }));
407        assert_eq!(fx, GestureEffect::None);
408        // vertical-dominant travel past the threshold resolves as scroll
409        let (p, fx) = step_auto(p, mv(2.0, 12.0, 1));
410        assert_eq!((p, fx), (GesturePhase::Idle, GestureEffect::None));
411        // an exact diagonal counts as scroll, not drag
412        let (p, _) = step_auto(GesturePhase::Idle, down(0.0, 0.0, 1));
413        let (p, fx) = step_auto(p, mv(10.0, 10.0, 1));
414        assert_eq!((p, fx), (GesturePhase::Idle, GestureEffect::None));
415    }
416
417    #[test]
418    fn distance_policy_promotes_vertical_pulls_unchanged() {
419        let (p, _) = transition(GesturePhase::Idle, down(0.0, 0.0, 1), T);
420        let (_, fx) = transition(p, mv(0.0, 12.0, 1), T);
421        assert!(matches!(fx, GestureEffect::Begin { .. }));
422    }
423}