Skip to main content

euv_ui/component/touch/hook/
impl.rs

1use super::*;
2
3/// Implementation of touch point extraction from DOM touch events.
4impl NativeTouchPoint {
5    /// Extracts all active touch points from a `TouchEvent`.
6    ///
7    /// Iterates over the `touches` list of the given `TouchEvent` and
8    /// builds a `Vec<NativeTouchPoint>` with each touch point's
9    /// identifier, viewport coordinates, screen coordinates, page
10    /// coordinates, and offset coordinates relative to the target element.
11    ///
12    /// The offset coordinates (`offset_x`, `offset_y`) are computed by
13    /// subtracting the target element's bounding rect from the touch's
14    /// client coordinates, since the browser `Touch` object does not
15    /// provide `offsetX`/`offsetY` directly.
16    ///
17    /// Uses web-sys typed getters (`TouchEvent::touches()`,
18    /// `TouchList::get`, `Touch::client_x()`) instead of
19    /// `Reflect::get(event, "clientX")`. The Reflect path allocates a
20    /// `JsValue::from_str` per field per touch (7 fields × N touches per
21    /// event) on every `touchmove` (60-120Hz); the typed getters skip
22    /// the string lookup and the per-field JS string allocation.
23    ///
24    /// # Arguments
25    ///
26    /// - `&Event` - The native DOM touch event.
27    ///
28    /// # Returns
29    ///
30    /// - `Vec<NativeTouchPoint>` - All currently active touch points.
31    pub fn extract_all(event: &Event) -> Vec<NativeTouchPoint> {
32        let touch_event: &TouchEvent = event.unchecked_ref::<TouchEvent>();
33        let touches: TouchList = touch_event.touches();
34        let target: JsValue = event
35            .target()
36            .map_or(JsValue::NULL, |event_target: EventTarget| {
37                event_target.into()
38            });
39        let element: Element = target.unchecked_into();
40        let rect: DomRect = element.get_bounding_client_rect();
41        let rect_left: f64 = rect.left();
42        let rect_top: f64 = rect.top();
43        let length: u32 = touches.length();
44        (0..length)
45            .filter_map(|index: u32| touches.get(index))
46            .map(|touch: Touch| {
47                let identifier: i32 = touch.identifier();
48                let client_x: i32 = touch.client_x();
49                let client_y: i32 = touch.client_y();
50                let screen_x: i32 = touch.screen_x();
51                let screen_y: i32 = touch.screen_y();
52                let page_x: i32 = touch.page_x();
53                let page_y: i32 = touch.page_y();
54                let offset_x: i32 = (client_x as f64 - rect_left).round() as i32;
55                let offset_y: i32 = (client_y as f64 - rect_top).round() as i32;
56                NativeTouchPoint {
57                    identifier,
58                    client_x,
59                    client_y,
60                    screen_x,
61                    screen_y,
62                    offset_x,
63                    offset_y,
64                    page_x,
65                    page_y,
66                }
67            })
68            .collect()
69    }
70
71    /// Extracts the changed touch points from a `TouchEvent`.
72    ///
73    /// The `changedTouches` list contains touch points that have changed
74    /// since the last touch event:
75    /// - For `touchstart` - newly added touch points.
76    /// - For `touchmove` - touch points that have moved.
77    /// - For `touchend` / `touchcancel` - removed touch points.
78    ///
79    /// This is useful for determining which specific fingers were lifted
80    /// in a `touchend` event, since the `touches` list no longer contains
81    /// them.
82    ///
83    /// Uses web-sys typed getters (`TouchEvent::changed_touches()`,
84    /// `TouchList::get`, `Touch::client_x()`) to avoid the per-field
85    /// `Reflect::get` + `JsValue::from_str` allocation cost on the hot
86    /// `touchmove` path.
87    ///
88    /// # Arguments
89    ///
90    /// - `&Event` - The native DOM touch event.
91    ///
92    /// # Returns
93    ///
94    /// - `Vec<NativeTouchPoint>` - The touch points that changed in this event.
95    pub fn extract_changed(event: &Event) -> Vec<NativeTouchPoint> {
96        let touch_event: &TouchEvent = event.unchecked_ref::<TouchEvent>();
97        let touches: TouchList = touch_event.changed_touches();
98        let target: JsValue = event
99            .target()
100            .map_or(JsValue::NULL, |event_target: EventTarget| {
101                event_target.into()
102            });
103        let element: Element = target.unchecked_into();
104        let rect: DomRect = element.get_bounding_client_rect();
105        let rect_left: f64 = rect.left();
106        let rect_top: f64 = rect.top();
107        let length: u32 = touches.length();
108        (0..length)
109            .filter_map(|index: u32| touches.get(index))
110            .map(|touch: Touch| {
111                let identifier: i32 = touch.identifier();
112                let client_x: i32 = touch.client_x();
113                let client_y: i32 = touch.client_y();
114                let screen_x: i32 = touch.screen_x();
115                let screen_y: i32 = touch.screen_y();
116                let page_x: i32 = touch.page_x();
117                let page_y: i32 = touch.page_y();
118                let offset_x: i32 = (client_x as f64 - rect_left).round() as i32;
119                let offset_y: i32 = (client_y as f64 - rect_top).round() as i32;
120                NativeTouchPoint {
121                    identifier,
122                    client_x,
123                    client_y,
124                    screen_x,
125                    screen_y,
126                    offset_x,
127                    offset_y,
128                    page_x,
129                    page_y,
130                }
131            })
132            .collect()
133    }
134}
135
136/// Implementation of high-precision touch point extraction from DOM touch events.
137impl NativeTouchPointF64 {
138    /// Extracts all active touch points with high-precision `f64` offset coordinates
139    /// from a `TouchEvent`.
140    ///
141    /// Similar to `NativeTouchPoint::extract_all`, but returns `f64` precision for
142    /// offset/client coordinates, which is essential for canvas drawing
143    /// and other pixel-precise interactions.
144    ///
145    /// Uses web-sys typed getters (`TouchEvent::touches()`,
146    /// `TouchList::get`, `Touch::client_x()`) instead of
147    /// `Reflect::get(event, "clientX")`. web-sys `Touch` exposes
148    /// `client_x`/`page_x`/etc. as `i32`; we widen to `f64` to preserve
149    /// the `NativeTouchPointF64` high-precision contract without losing
150    /// sub-pixel information on the offset computation.
151    ///
152    /// # Arguments
153    ///
154    /// - `&Event` - The native DOM touch event.
155    ///
156    /// # Returns
157    ///
158    /// - `Vec<NativeTouchPointF64>` - All currently active touch points with `f64` coordinates.
159    pub fn extract_all(event: &Event) -> Vec<NativeTouchPointF64> {
160        let touch_event: &TouchEvent = event.unchecked_ref::<TouchEvent>();
161        let touches: TouchList = touch_event.touches();
162        let target: JsValue = event
163            .target()
164            .map_or(JsValue::NULL, |event_target: EventTarget| {
165                event_target.into()
166            });
167        let element: Element = target.unchecked_into();
168        let rect: DomRect = element.get_bounding_client_rect();
169        let rect_left: f64 = rect.left();
170        let rect_top: f64 = rect.top();
171        let length: u32 = touches.length();
172        (0..length)
173            .filter_map(|index: u32| touches.get(index))
174            .map(|touch: Touch| {
175                let identifier: i32 = touch.identifier();
176                let client_x: f64 = touch.client_x() as f64;
177                let client_y: f64 = touch.client_y() as f64;
178                let screen_x: f64 = touch.screen_x() as f64;
179                let screen_y: f64 = touch.screen_y() as f64;
180                let page_x: f64 = touch.page_x() as f64;
181                let page_y: f64 = touch.page_y() as f64;
182                let offset_x: f64 = client_x - rect_left;
183                let offset_y: f64 = client_y - rect_top;
184                NativeTouchPointF64 {
185                    identifier,
186                    client_x,
187                    client_y,
188                    screen_x,
189                    screen_y,
190                    offset_x,
191                    offset_y,
192                    page_x,
193                    page_y,
194                }
195            })
196            .collect()
197    }
198}
199
200/// Implementation of gesture recognition over raw touch points.
201///
202/// The recognizer is a pure state machine: it is fed touch point lists and
203/// timestamps and reports what it decides. It never touches the DOM itself,
204/// which keeps the classification rules testable without a browser.
205impl EuvGestureRecognizer {
206    /// Creates a recognizer with the default thresholds.
207    ///
208    /// Equivalent to [`EuvGestureRecognizer::default`]; the inherent form
209    /// exists so callers read as `EuvGestureRecognizer::new()` rather than
210    /// reaching for the trait.
211    ///
212    /// # Returns
213    ///
214    /// - `EuvGestureRecognizer` - A recognizer using
215    ///   [`EuvGestureConfig::default`].
216    pub fn new() -> EuvGestureRecognizer {
217        Self::default()
218    }
219
220    /// Creates a recognizer with caller-supplied thresholds.
221    ///
222    /// # Arguments
223    ///
224    /// - `EuvGestureConfig` - The thresholds to classify against.
225    ///
226    /// # Returns
227    ///
228    /// - `EuvGestureRecognizer` - A recognizer using the given thresholds.
229    pub fn with_config(config: EuvGestureConfig) -> EuvGestureRecognizer {
230        EuvGestureRecognizer { config }
231    }
232
233    /// Mounts the reactive signals and returns the handlers that drive them.
234    ///
235    /// Wire the returned handlers to a single element in `html!`:
236    ///
237    /// ```ignore
238    /// let state: EuvGestureState = EuvGestureRecognizer::new().use_gesture();
239    /// div {
240    ///     ontouchstart: state.on_start
241    ///     ontouchmove: state.on_move
242    ///     ontouchend: state.on_end
243    ///     ontouchcancel: state.on_cancel
244    /// }
245    /// ```
246    ///
247    /// Per-move bookkeeping lives in a non-reactive cell so a 120Hz
248    /// `touchmove` does not allocate; only `last_gesture` is written when a
249    /// gesture completes.
250    ///
251    /// A long press is resolved on `touchend` from the measured elapsed time
252    /// rather than by a timer, so a press that is released after the
253    /// threshold still reports `LongPress` and a cancelled press reports
254    /// nothing.
255    ///
256    /// # Returns
257    ///
258    /// - `EuvGestureState` - The reactive signals plus the four handlers.
259    pub fn use_gesture(self) -> EuvGestureState {
260        let last_gesture: Signal<Option<EuvGesture>> = App::use_signal(|| None);
261        let drag: Signal<Option<EuvDrag>> = App::use_signal(|| None);
262        let pinch: Signal<Option<EuvPinch>> = App::use_signal(|| None);
263        let progress: Rc<RefCell<GestureProgress>> =
264            Rc::new(RefCell::new(GestureProgress::default()));
265        let recognizer: EuvGestureRecognizer = self;
266        let start_progress: Rc<RefCell<GestureProgress>> = Rc::clone(&progress);
267        let start_gesture: Signal<Option<EuvGesture>> = last_gesture;
268        let start_drag: Signal<Option<EuvDrag>> = drag;
269        let start_pinch: Signal<Option<EuvPinch>> = pinch;
270        let on_start: Option<Rc<dyn Fn(Event)>> = Some(Rc::new(move |event: Event| {
271            Self::suppress_page_scroll(&event);
272            let points: Vec<NativeTouchPoint> = NativeTouchPoint::extract_all(&event);
273            if points.is_empty() {
274                return;
275            }
276            // All four handlers share this one cell, and the browser dispatches
277            // touch events independently of one another, so a `touchstart` can
278            // land while a `touchmove` frame still holds the guard. A contended
279            // begin is skipped: the next `touchstart` re-seeds every field, so
280            // the only loss is the current sequence's origin.
281            if let Ok(mut slot) = start_progress.try_borrow_mut() {
282                slot.begin(&points);
283            }
284            start_gesture.set(None);
285            start_drag.set(None);
286            if let Some(reading) = recognizer.pinch_from(&points, recognizer.config.swipe_threshold)
287            {
288                start_pinch.set(Some(reading));
289            }
290        }));
291        let move_progress: Rc<RefCell<GestureProgress>> = Rc::clone(&progress);
292        let move_drag: Signal<Option<EuvDrag>> = drag;
293        let move_pinch: Signal<Option<EuvPinch>> = pinch;
294        let move_recognizer: EuvGestureRecognizer = self;
295        let on_move: Option<Rc<dyn Fn(Event)>> = Some(Rc::new(move |event: Event| {
296            Self::suppress_page_scroll(&event);
297            let points: Vec<NativeTouchPoint> = NativeTouchPoint::extract_all(&event);
298            if points.is_empty() {
299                return;
300            }
301            // Read everything out of the cell, drop the guard, and only then
302            // touch the signals: a `Signal::set` runs listeners synchronously and
303            // a listener may re-enter this handler, and borrowing across it would
304            // abort the WASM instance with an already-borrowed panic. A contended
305            // cell drops this frame's reading; the next `touchmove` at 60-120Hz
306            // recomputes it, so the drag and pinch signals are one frame stale
307            // rather than the app being aborted.
308            let Ok(mut slot) = move_progress.try_borrow_mut() else {
309                return;
310            };
311            slot.advance(&points);
312            let readings: (Option<EuvDrag>, Option<EuvPinch>) = {
313                let drag_reading: Option<EuvDrag> = slot.primary().map(|point: GesturePoint| {
314                    move_recognizer.drag_from(
315                        &point,
316                        slot.get_start_x(),
317                        slot.get_start_y(),
318                        *slot.get_travel(),
319                    )
320                });
321                let pinch_reading: Option<EuvPinch> = move_recognizer
322                    .pinch_from(&points, slot.get_pinch_start_distance())
323                    .filter(|candidate: &EuvPinch| {
324                        move_recognizer.pinch_is_significant(
325                            candidate.distance,
326                            slot.get_pinch_start_distance(),
327                        )
328                    });
329                (drag_reading, pinch_reading)
330            };
331            drop(slot);
332            if let Some(reading) = readings.0 {
333                move_drag.set(Some(reading));
334            }
335            if let Some(reading) = readings.1 {
336                move_pinch.set(Some(reading));
337            }
338        }));
339        let end_progress: Rc<RefCell<GestureProgress>> = Rc::clone(&progress);
340        let end_gesture: Signal<Option<EuvGesture>> = last_gesture;
341        let end_drag: Signal<Option<EuvDrag>> = drag;
342        let end_pinch: Signal<Option<EuvPinch>> = pinch;
343        let end_recognizer: EuvGestureRecognizer = self;
344        let on_end: Option<Rc<dyn Fn(Event)>> = Some(Rc::new(move |_: Event| {
345            // `finish` reads the clock through `now_millis`, so the guard must
346            // not outlive this block. A contended cell reports "no gesture" and
347            // the next `touchstart` re-seeds the cell.
348            let outcome: Option<EuvGesture> = match end_progress.try_borrow_mut() {
349                Ok(mut slot) => {
350                    let result: Option<EuvGesture> = slot.finish(&end_recognizer.config);
351                    slot.reset();
352                    result
353                }
354                Err(_) => None,
355            };
356            end_drag.set(None);
357            end_pinch.set(None);
358            if let Some(gesture) = outcome {
359                end_gesture.set(Some(gesture));
360            }
361        }));
362        let cancel_progress: Rc<RefCell<GestureProgress>> = Rc::clone(&progress);
363        let cancel_drag: Signal<Option<EuvDrag>> = drag;
364        let cancel_pinch: Signal<Option<EuvPinch>> = pinch;
365        let on_cancel: Option<Rc<dyn Fn(Event)>> = Some(Rc::new(move |_: Event| {
366            // Best-effort reset. A contended cell means a `touchmove` frame is
367            // mid-flight and will read the same cell on its way out; the reset
368            // is recomputed by the next `touchstart`, so skipping it costs one
369            // stale gesture. `borrow_mut` would abort the instance.
370            if let Ok(mut slot) = cancel_progress.try_borrow_mut() {
371                slot.reset();
372            }
373            cancel_drag.set(None);
374            cancel_pinch.set(None);
375        }));
376        EuvGestureState {
377            last_gesture,
378            drag,
379            pinch,
380            on_start,
381            on_move,
382            on_end,
383            on_cancel,
384        }
385    }
386
387    /// Calls `prevent_default` on a touch event so the browser does not also
388    /// scroll or zoom the page under a gesture.
389    ///
390    /// Guarded on `cancelable` because a passive or already-dispatched event
391    /// rejects the call.
392    ///
393    /// # Arguments
394    ///
395    /// - `&Event` - The touch event to cancel default handling on.
396    fn suppress_page_scroll(event: &Event) {
397        if event.cancelable() {
398            event.prevent_default();
399        }
400    }
401
402    /// Decides which single-finger gesture, if any, a finished touch
403    /// represents.
404    ///
405    /// A touch is a tap or long press only when the straight-line distance
406    /// from start to end is within `tap_slop`; that is deliberately separate
407    /// from the path length a drag accumulates, so a finger that wanders in
408    /// a small loop is not mistaken for a tap. Otherwise the dominant axis
409    /// of the displacement wins, and the gesture only fires past
410    /// `swipe_threshold`.
411    ///
412    /// # Arguments
413    ///
414    /// - `f64` - X displacement from start to end, in CSS pixels.
415    /// - `f64` - Y displacement from start to end, in CSS pixels.
416    /// - `f64` - Elapsed time from `touchstart` to `touchend`, in
417    ///   milliseconds.
418    /// - `f64` - Path length travelled, in CSS pixels. A finger that loops
419    ///   back to its origin has near-zero displacement but a long path, and
420    ///   must not be classified as a tap.
421    ///
422    /// # Returns
423    ///
424    /// - `Option<EuvGesture>` - The recognized gesture, or `None` when the
425    ///   movement is too small to be either a tap or a swipe.
426    pub fn classify(
427        &self,
428        dx: f64,
429        dy: f64,
430        elapsed_millis: f64,
431        travel: f64,
432    ) -> Option<EuvGesture> {
433        let distance: f64 = (dx * dx + dy * dy).sqrt();
434        if distance <= self.get_config().get_tap_slop()
435            && travel <= self.get_config().get_tap_slop()
436        {
437            if elapsed_millis >= self.get_config().get_long_press_millis() {
438                return Some(EuvGesture::LongPress);
439            }
440            return Some(EuvGesture::Tap);
441        }
442        if distance < self.get_config().get_swipe_threshold() {
443            return None;
444        }
445        if dx.abs() >= dy.abs() {
446            Some(if dx > 0.0 {
447                EuvGesture::Right
448            } else {
449                EuvGesture::Left
450            })
451        } else {
452            Some(if dy > 0.0 {
453                EuvGesture::Down
454            } else {
455                EuvGesture::Up
456            })
457        }
458    }
459
460    /// Builds the two-finger pinch description for the current touch points.
461    ///
462    /// Returns `None` unless exactly two points are supplied, so a
463    /// one-finger drag is never mistaken for a degenerate pinch.
464    ///
465    /// # Arguments
466    ///
467    /// - `&[NativeTouchPoint]` - The active touch points, which must hold
468    ///   exactly two entries.
469    /// - `f64` - The distance recorded when the pinch began, used as the
470    ///   baseline for `delta`.
471    ///
472    /// # Returns
473    ///
474    /// - `Option<EuvPinch>` - The pinch description, or `None` when the point
475    ///   count is not two.
476    pub fn pinch_from(&self, points: &[NativeTouchPoint], start_distance: f64) -> Option<EuvPinch> {
477        if points.len() != 2 {
478            return None;
479        }
480        let first: &NativeTouchPoint = &points[0];
481        let second: &NativeTouchPoint = &points[1];
482        let dx: f64 = f64::from(second.client_x) - f64::from(first.client_x);
483        let dy: f64 = f64::from(second.client_y) - f64::from(first.client_y);
484        let distance: f64 = (dx * dx + dy * dy).sqrt();
485        Some(EuvPinch {
486            distance,
487            start_distance,
488            center_x: f64::from(first.client_x + second.client_x) / 2.0,
489            center_y: f64::from(first.client_y + second.client_y) / 2.0,
490            delta: distance - start_distance,
491        })
492    }
493
494    /// Decides whether a pinch has grown or shrunk far enough to be worth
495    /// reporting.
496    ///
497    /// Comparing the current distance against the pinch baseline as a ratio
498    /// keeps the threshold meaningful at any zoom level, where an absolute
499    /// pixel delta would be noise when zoomed out and too small when zoomed
500    /// in.
501    ///
502    /// # Arguments
503    ///
504    /// - `f64` - Distance between the two fingers now.
505    /// - `f64` - Distance between the two fingers when the pinch began.
506    ///
507    /// # Returns
508    ///
509    /// - `bool` - `true` when the relative change exceeds
510    ///   `pinch_threshold`.
511    pub fn pinch_is_significant(&self, distance: f64, start_distance: f64) -> bool {
512        if start_distance <= 0.0 {
513            return false;
514        }
515        (distance - start_distance).abs() / start_distance
516            >= self.get_config().get_pinch_threshold()
517    }
518
519    /// Builds the single-finger drag description for the current point.
520    ///
521    /// # Arguments
522    ///
523    /// - `&GesturePoint` - The finger that is moving.
524    /// - `f64` - X of the point where this drag began.
525    /// - `f64` - Y of the point where this drag began.
526    /// - `f64` - Path length accumulated so far, in CSS pixels.
527    ///
528    /// # Returns
529    ///
530    /// - `EuvDrag` - The drag description for this move.
531    pub fn drag_from(
532        &self,
533        point: &GesturePoint,
534        start_x: f64,
535        start_y: f64,
536        travel: f64,
537    ) -> EuvDrag {
538        let x: f64 = point.client_x;
539        let y: f64 = point.client_y;
540        EuvDrag {
541            x,
542            y,
543            delta_x: x - start_x,
544            delta_y: y - start_y,
545            travel,
546        }
547    }
548}
549
550impl EuvGesture {
551    /// Returns the stable lowercase token for this gesture.
552    ///
553    /// Used for telemetry and `data-gesture` attributes, so the spelling is
554    /// part of the public contract and must not change between releases.
555    ///
556    /// # Returns
557    ///
558    /// - `&'static str` - The wire name: one of `left`, `right`, `up`,
559    ///   `down`, `tap`, or `long-press`.
560    pub fn name(self) -> &'static str {
561        let index: usize = match self {
562            Self::Left => 0,
563            Self::Right => 1,
564            Self::Up => 2,
565            Self::Down => 3,
566            Self::Tap => 4,
567            Self::LongPress => 5,
568        };
569        GESTURE_NAMES[index]
570    }
571}
572impl Default for EuvGestureConfig {
573    /// Returns the default thresholds, tuned for a finger on glass.
574    ///
575    /// 48px is roughly the width of an adult fingertip contact patch, so a
576    /// smaller travel is treated as jitter rather than intent. 500ms is the
577    /// conventional long-press boundary used by both iOS and Android.
578    ///
579    /// # Returns
580    ///
581    /// - `EuvGestureConfig` - The default threshold set.
582    fn default() -> Self {
583        Self {
584            swipe_threshold: 48.0,
585            tap_slop: 10.0,
586            long_press_millis: 500.0,
587            pinch_threshold: 0.01,
588        }
589    }
590}
591
592impl Default for EuvGestureRecognizer {
593    /// Returns a recognizer using the default thresholds.
594    ///
595    /// # Returns
596    ///
597    /// - `EuvGestureRecognizer` - A recognizer using
598    ///   [`EuvGestureConfig::default`].
599    fn default() -> Self {
600        Self {
601            config: EuvGestureConfig::default(),
602        }
603    }
604}
605
606impl GestureProgress {
607    /// Records the origin of a new touch sequence.
608    ///
609    /// When two or more fingers land together, the inter-finger distance is
610    /// captured as the pinch baseline, so a later spread is measured against
611    /// the initial separation rather than against zero.
612    ///
613    /// # Arguments
614    ///
615    /// - `&[NativeTouchPoint]` - The touch points active at `touchstart`.
616    pub fn begin(&mut self, points: &[NativeTouchPoint]) {
617        let Some(first) = points.first() else {
618            return;
619        };
620        self.set_start_x(f64::from(first.client_x));
621        self.set_start_y(f64::from(first.client_y));
622        self.set_last_x(self.get_start_x());
623        self.set_last_y(self.get_start_y());
624        self.set_travel(0.0);
625        self.set_started_at(now_millis());
626        self.set_active(true);
627        let baseline: f64 = match points {
628            [one, two, ..] => {
629                let dx: f64 = f64::from(two.client_x - one.client_x);
630                let dy: f64 = f64::from(two.client_y - one.client_y);
631                (dx * dx + dy * dy).sqrt()
632            }
633            _ => 0.0,
634        };
635        self.set_pinch_start_distance(baseline);
636    }
637
638    /// Folds a `touchmove` reading into the running totals.
639    ///
640    /// Travel is measured segment by segment rather than from the origin so
641    /// that a finger drawing a closed loop accumulates a large distance,
642    /// which is what separates a drag from a wandering tap.
643    ///
644    /// # Arguments
645    ///
646    /// - `&[NativeTouchPoint]` - The touch points active at `touchmove`.
647    pub fn advance(&mut self, points: &[NativeTouchPoint]) {
648        let Some(first) = points.first() else {
649            return;
650        };
651        let x: f64 = f64::from(first.client_x);
652        let y: f64 = f64::from(first.client_y);
653        let step_x: f64 = x - self.get_last_x();
654        let step_y: f64 = y - self.get_last_y();
655        self.set_travel(self.get_travel() + (step_x * step_x + step_y * step_y).sqrt());
656        self.set_last_x(x);
657        self.set_last_y(y);
658    }
659
660    /// Returns the first tracked finger, or `None` when no sequence is
661    /// active.
662    ///
663    ///
664    /// # Returns
665    ///
666    /// - `Option<GesturePoint>` - The live position of the tracked finger,
667    ///   or `None` when no sequence is in flight.
668    pub fn primary(&self) -> Option<GesturePoint> {
669        if !self.get_active() {
670            return None;
671        }
672        Some(GesturePoint {
673            client_x: self.get_last_x(),
674            client_y: self.get_last_y(),
675        })
676    }
677
678    /// Classifies the finished sequence and marks the cell inactive.
679    ///
680    /// A tap requires both a small straight-line displacement and a short
681    /// accumulated path. Checking only the displacement would misread a
682    /// finger that traces a closed loop and returns to its origin as a tap,
683    /// even though it travelled hundreds of pixels; the path length is what
684    /// separates the two cases.
685    ///
686    /// # Arguments
687    ///
688    /// - `&EuvGestureConfig` - The thresholds to classify against.
689    ///
690    /// # Returns
691    ///
692    /// - `Option<EuvGesture>` - The recognised gesture, or `None` when no
693    ///   sequence was in flight.
694    pub fn finish(&mut self, config: &EuvGestureConfig) -> Option<EuvGesture> {
695        if !self.get_active() {
696            return None;
697        }
698        self.set_active(false);
699        let dx: f64 = self.get_last_x() - self.get_start_x();
700        let dy: f64 = self.get_last_y() - self.get_start_y();
701        let elapsed: f64 = now_millis() - self.get_started_at();
702        let distance: f64 = (dx * dx + dy * dy).sqrt();
703        if distance <= config.tap_slop && *self.get_travel() <= config.tap_slop {
704            return Some(if elapsed >= config.long_press_millis {
705                EuvGesture::LongPress
706            } else {
707                EuvGesture::Tap
708            });
709        }
710        if distance < config.swipe_threshold {
711            return None;
712        }
713        Some(if dx.abs() >= dy.abs() {
714            if dx > 0.0 {
715                EuvGesture::Right
716            } else {
717                EuvGesture::Left
718            }
719        } else if dy > 0.0 {
720            EuvGesture::Down
721        } else {
722            EuvGesture::Up
723        })
724    }
725
726    /// Clears all progress, used when a sequence is cancelled.
727    ///
728    /// # Arguments
729    ///
730    pub fn reset(&mut self) {
731        *self = Self::default();
732    }
733}