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#[derive(Debug, Clone, Copy, PartialEq)]
51pub enum GestureEvent {
52    /// Pointer pressed.
53    Down { at: Point, pointer_id: i32 },
54    /// Pointer moved.
55    Move { at: Point, pointer_id: i32 },
56    /// Pointer released.
57    Up { at: Point, pointer_id: i32 },
58    /// The platform cancelled the gesture (`pointercancel`).
59    Cancel,
60}
61
62/// What the caller should do after a transition.
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub enum GestureEffect {
65    /// Nothing - including events from foreign pointer ids, which the
66    /// machine deliberately ignores.
67    None,
68    /// The threshold was crossed: begin the drag. `origin` is where the
69    /// press started (use it for the grab offset), `at` is the current
70    /// pointer position.
71    Begin { origin: Point, at: Point },
72    /// An active drag moved: track the pointer and update hover.
73    Track { at: Point },
74    /// An active drag released: attempt the drop at `at`.
75    Drop { at: Point },
76    /// The press resolved as a tap (released before the threshold).
77    Tap,
78    /// An active drag was aborted: clean up drag state.
79    Abort,
80}
81
82/// Advance the machine. Pure: same inputs, same outputs, no side effects.
83///
84/// `threshold` is the travel distance (CSS px) that promotes a press to a
85/// drag; releases inside it resolve as [`GestureEffect::Tap`].
86pub fn transition(
87    phase: GesturePhase,
88    event: GestureEvent,
89    threshold: f64,
90) -> (GesturePhase, GestureEffect) {
91    use GestureEffect as Fx;
92    use GesturePhase as P;
93
94    match (phase, event) {
95        // --- starting -----------------------------------------------------
96        (P::Idle, GestureEvent::Down { at, pointer_id }) => (
97            P::Pressed {
98                origin: at,
99                pointer_id,
100            },
101            Fx::None,
102        ),
103        // A second pointer pressing mid-gesture doesn't steal it.
104        (p @ (P::Pressed { .. } | P::Dragging { .. }), GestureEvent::Down { .. }) => (p, Fx::None),
105
106        // --- pressed: promote, tap, or wait --------------------------------
107        (
108            P::Pressed { origin, pointer_id },
109            GestureEvent::Move {
110                at,
111                pointer_id: pid,
112            },
113        ) if pid == pointer_id => {
114            let d = at - origin;
115            if (d.x * d.x + d.y * d.y).sqrt() >= threshold {
116                (P::Dragging { origin, pointer_id }, Fx::Begin { origin, at })
117            } else {
118                (P::Pressed { origin, pointer_id }, Fx::None)
119            }
120        }
121        (
122            P::Pressed { pointer_id, .. },
123            GestureEvent::Up {
124                pointer_id: pid, ..
125            },
126        ) if pid == pointer_id => (P::Idle, Fx::Tap),
127
128        // --- dragging: track, drop ----------------------------------------
129        (
130            P::Dragging { origin, pointer_id },
131            GestureEvent::Move {
132                at,
133                pointer_id: pid,
134            },
135        ) if pid == pointer_id => (P::Dragging { origin, pointer_id }, Fx::Track { at }),
136        (
137            P::Dragging { pointer_id, .. },
138            GestureEvent::Up {
139                at,
140                pointer_id: pid,
141            },
142        ) if pid == pointer_id => (P::Idle, Fx::Drop { at }),
143
144        // --- cancellation ---------------------------------------------------
145        (P::Dragging { .. }, GestureEvent::Cancel) => (P::Idle, Fx::Abort),
146        (P::Pressed { .. }, GestureEvent::Cancel) => (P::Idle, Fx::None),
147        (P::Idle, GestureEvent::Cancel) => (P::Idle, Fx::None),
148
149        // --- everything else is deliberately inert -------------------------
150        // Foreign pointer ids, moves/ups while idle: ignored, not errors -
151        // browsers deliver stray events and a UI library shrugs them off.
152        (p, _) => (p, Fx::None),
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    const T: f64 = 8.0;
161
162    fn down(x: f64, y: f64, pid: i32) -> GestureEvent {
163        GestureEvent::Down {
164            at: Point::new(x, y),
165            pointer_id: pid,
166        }
167    }
168    fn mv(x: f64, y: f64, pid: i32) -> GestureEvent {
169        GestureEvent::Move {
170            at: Point::new(x, y),
171            pointer_id: pid,
172        }
173    }
174    fn up(x: f64, y: f64, pid: i32) -> GestureEvent {
175        GestureEvent::Up {
176            at: Point::new(x, y),
177            pointer_id: pid,
178        }
179    }
180
181    #[test]
182    fn full_drag_lifecycle() {
183        let (p, fx) = transition(GesturePhase::Idle, down(0.0, 0.0, 1), T);
184        assert_eq!(fx, GestureEffect::None);
185
186        // sub-threshold wiggle stays pressed
187        let (p, fx) = transition(p, mv(3.0, 3.0, 1), T);
188        assert!(matches!(p, GesturePhase::Pressed { .. }));
189        assert_eq!(fx, GestureEffect::None);
190
191        // crossing the threshold begins the drag with the press origin
192        let (p, fx) = transition(p, mv(10.0, 0.0, 1), T);
193        assert_eq!(
194            fx,
195            GestureEffect::Begin {
196                origin: Point::new(0.0, 0.0),
197                at: Point::new(10.0, 0.0)
198            }
199        );
200
201        let (p, fx) = transition(p, mv(20.0, 5.0, 1), T);
202        assert_eq!(
203            fx,
204            GestureEffect::Track {
205                at: Point::new(20.0, 5.0)
206            }
207        );
208
209        let (p, fx) = transition(p, up(20.0, 5.0, 1), T);
210        assert_eq!(p, GesturePhase::Idle);
211        assert_eq!(
212            fx,
213            GestureEffect::Drop {
214                at: Point::new(20.0, 5.0)
215            }
216        );
217    }
218
219    #[test]
220    fn release_before_threshold_is_a_tap() {
221        let (p, _) = transition(GesturePhase::Idle, down(0.0, 0.0, 1), T);
222        let (p, fx) = transition(p, up(2.0, 2.0, 1), T);
223        assert_eq!(p, GesturePhase::Idle);
224        assert_eq!(fx, GestureEffect::Tap);
225    }
226
227    #[test]
228    fn foreign_pointer_ids_are_ignored() {
229        let (p, _) = transition(GesturePhase::Idle, down(0.0, 0.0, 1), T);
230        // a second finger moves and lifts: gesture unaffected
231        let (p, fx) = transition(p, mv(100.0, 100.0, 2), T);
232        assert!(matches!(p, GesturePhase::Pressed { .. }));
233        assert_eq!(fx, GestureEffect::None);
234        let (p, fx) = transition(p, up(100.0, 100.0, 2), T);
235        assert!(matches!(p, GesturePhase::Pressed { .. }));
236        assert_eq!(fx, GestureEffect::None);
237        // a second finger pressing doesn't steal ownership
238        let (p, fx) = transition(p, down(50.0, 50.0, 2), T);
239        assert!(matches!(p, GesturePhase::Pressed { pointer_id: 1, .. }));
240        assert_eq!(fx, GestureEffect::None);
241    }
242
243    #[test]
244    fn cancel_paths() {
245        // cancel mid-drag aborts
246        let (p, _) = transition(GesturePhase::Idle, down(0.0, 0.0, 1), T);
247        let (p, _) = transition(p, mv(20.0, 0.0, 1), T);
248        let (p, fx) = transition(p, GestureEvent::Cancel, T);
249        assert_eq!((p, fx), (GesturePhase::Idle, GestureEffect::Abort));
250        // cancel while merely pressed is silent
251        let (p, _) = transition(GesturePhase::Idle, down(0.0, 0.0, 1), T);
252        let (p, fx) = transition(p, GestureEvent::Cancel, T);
253        assert_eq!((p, fx), (GesturePhase::Idle, GestureEffect::None));
254    }
255
256    #[test]
257    fn stray_events_while_idle_are_inert() {
258        for ev in [mv(9.0, 9.0, 1), up(9.0, 9.0, 1)] {
259            let (p, fx) = transition(GesturePhase::Idle, ev, T);
260            assert_eq!((p, fx), (GesturePhase::Idle, GestureEffect::None));
261        }
262    }
263
264    #[test]
265    fn exact_threshold_promotes() {
266        let (p, _) = transition(GesturePhase::Idle, down(0.0, 0.0, 1), T);
267        let (_, fx) = transition(p, mv(8.0, 0.0, 1), T);
268        assert!(matches!(fx, GestureEffect::Begin { .. }));
269    }
270}