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    ///   back to its origin has near-zero displacement but a long path, and
416    ///   must not be classified as a tap.
417    ///
418    /// # Returns
419    ///
420    /// - `Option<EuvGesture>` - The recognized gesture, or `None` when the
421    ///   movement is too small to be either a tap or a swipe.
422    pub fn classify(
423        &self,
424        dx: f64,
425        dy: f64,
426        elapsed_millis: f64,
427        travel: f64,
428    ) -> Option<EuvGesture> {
429        let distance: f64 = (dx * dx + dy * dy).sqrt();
430        if distance <= self.get_config().get_tap_slop()
431            && travel <= self.get_config().get_tap_slop()
432        {
433            if elapsed_millis >= self.get_config().get_long_press_millis() {
434                return Some(EuvGesture::LongPress);
435            }
436            return Some(EuvGesture::Tap);
437        }
438        if distance < self.get_config().get_swipe_threshold() {
439            return None;
440        }
441        if dx.abs() >= dy.abs() {
442            Some(if dx > 0.0 {
443                EuvGesture::Right
444            } else {
445                EuvGesture::Left
446            })
447        } else {
448            Some(if dy > 0.0 {
449                EuvGesture::Down
450            } else {
451                EuvGesture::Up
452            })
453        }
454    }
455
456    /// Builds the two-finger pinch description for the current touch points.
457    ///
458    /// Returns `None` unless exactly two points are supplied, so a
459    /// one-finger drag is never mistaken for a degenerate pinch.
460    ///
461    /// # Arguments
462    ///
463    /// - `&[NativeTouchPoint]` - The active touch points, which must hold
464    ///   exactly two entries.
465    /// - `f64` - The distance recorded when the pinch began, used as the
466    ///   baseline for `delta`.
467    ///
468    /// # Returns
469    ///
470    /// - `Option<EuvPinch>` - The pinch description, or `None` when the point
471    ///   count is not two.
472    pub fn pinch_from(&self, points: &[NativeTouchPoint], start_distance: f64) -> Option<EuvPinch> {
473        if points.len() != 2 {
474            return None;
475        }
476        let first: &NativeTouchPoint = &points[0];
477        let second: &NativeTouchPoint = &points[1];
478        let dx: f64 = f64::from(second.client_x) - f64::from(first.client_x);
479        let dy: f64 = f64::from(second.client_y) - f64::from(first.client_y);
480        let distance: f64 = (dx * dx + dy * dy).sqrt();
481        Some(EuvPinch {
482            distance,
483            start_distance,
484            center_x: f64::from(first.client_x + second.client_x) / 2.0,
485            center_y: f64::from(first.client_y + second.client_y) / 2.0,
486            delta: distance - start_distance,
487        })
488    }
489
490    /// Decides whether a pinch has grown or shrunk far enough to be worth
491    /// reporting.
492    ///
493    /// Comparing the current distance against the pinch baseline as a ratio
494    /// keeps the threshold meaningful at any zoom level, where an absolute
495    /// pixel delta would be noise when zoomed out and too small when zoomed
496    /// in.
497    ///
498    /// # Arguments
499    ///
500    /// - `f64` - Distance between the two fingers now.
501    ///
502    /// # Returns
503    ///
504    /// - `bool` - `true` when the relative change exceeds
505    ///   `pinch_threshold`.
506    pub fn pinch_is_significant(&self, distance: f64, start_distance: f64) -> bool {
507        if start_distance <= 0.0 {
508            return false;
509        }
510        (distance - start_distance).abs() / start_distance
511            >= self.get_config().get_pinch_threshold()
512    }
513
514    /// Builds the single-finger drag description for the current point.
515    ///
516    /// # Arguments
517    ///
518    /// - `&GesturePoint` - The finger that is moving.
519    /// - `f64` - X of the point where this drag began.
520    ///
521    /// # Returns
522    ///
523    /// - `EuvDrag` - The drag description for this move.
524    pub fn drag_from(
525        &self,
526        point: &GesturePoint,
527        start_x: f64,
528        start_y: f64,
529        travel: f64,
530    ) -> EuvDrag {
531        let x: f64 = point.client_x;
532        let y: f64 = point.client_y;
533        EuvDrag {
534            x,
535            y,
536            delta_x: x - start_x,
537            delta_y: y - start_y,
538            travel,
539        }
540    }
541}
542
543/// Inherent implementation of [`EuvGesture`].
544impl EuvGesture {
545    /// Returns the stable lowercase token for this gesture.
546    ///
547    /// Used for telemetry and `data-gesture` attributes, so the spelling is
548    /// part of the public contract and must not change between releases.
549    ///
550    /// # Returns
551    ///
552    /// - `&'static str` - The wire name: one of `left`, `right`, `up`,
553    ///   `down`, `tap`, or `long-press`.
554    pub fn name(self) -> &'static str {
555        let index: usize = match self {
556            Self::Left => 0,
557            Self::Right => 1,
558            Self::Up => 2,
559            Self::Down => 3,
560            Self::Tap => 4,
561            Self::LongPress => 5,
562        };
563        GESTURE_NAMES[index]
564    }
565}
566/// Default construction for [`EuvGestureConfig`].
567impl Default for EuvGestureConfig {
568    /// Returns the default thresholds, tuned for a finger on glass.
569    ///
570    /// 48px is roughly the width of an adult fingertip contact patch, so a
571    /// smaller travel is treated as jitter rather than intent. 500ms is the
572    /// conventional long-press boundary used by both iOS and Android.
573    ///
574    /// # Returns
575    ///
576    /// - `EuvGestureConfig` - The default threshold set.
577    fn default() -> Self {
578        Self {
579            swipe_threshold: 48.0,
580            tap_slop: 10.0,
581            long_press_millis: 500.0,
582            pinch_threshold: 0.01,
583        }
584    }
585}
586
587/// Default construction for [`EuvGestureRecognizer`].
588impl Default for EuvGestureRecognizer {
589    /// Returns a recognizer using the default thresholds.
590    ///
591    /// # Returns
592    ///
593    /// - `EuvGestureRecognizer` - A recognizer using
594    ///   [`EuvGestureConfig::default`].
595    fn default() -> Self {
596        Self {
597            config: EuvGestureConfig::default(),
598        }
599    }
600}
601
602/// Inherent implementation of [`GestureProgress`].
603impl GestureProgress {
604    /// Records the origin of a new touch sequence.
605    ///
606    /// When two or more fingers land together, the inter-finger distance is
607    /// captured as the pinch baseline, so a later spread is measured against
608    /// the initial separation rather than against zero.
609    ///
610    /// # Arguments
611    ///
612    /// - `&[NativeTouchPoint]` - The touch points active at `touchstart`.
613    pub fn begin(&mut self, points: &[NativeTouchPoint]) {
614        let Some(first) = points.first() else {
615            return;
616        };
617        self.set_start_x(f64::from(first.client_x));
618        self.set_start_y(f64::from(first.client_y));
619        self.set_last_x(self.get_start_x());
620        self.set_last_y(self.get_start_y());
621        self.set_travel(0.0);
622        self.set_started_at(now_millis());
623        self.set_active(true);
624        let baseline: f64 = match points {
625            [one, two, ..] => {
626                let dx: f64 = f64::from(two.client_x - one.client_x);
627                let dy: f64 = f64::from(two.client_y - one.client_y);
628                (dx * dx + dy * dy).sqrt()
629            }
630            _ => 0.0,
631        };
632        self.set_pinch_start_distance(baseline);
633    }
634
635    /// Folds a `touchmove` reading into the running totals.
636    ///
637    /// Travel is measured segment by segment rather than from the origin so
638    /// that a finger drawing a closed loop accumulates a large distance,
639    /// which is what separates a drag from a wandering tap.
640    ///
641    /// # Arguments
642    ///
643    /// - `&[NativeTouchPoint]` - The touch points active at `touchmove`.
644    pub fn advance(&mut self, points: &[NativeTouchPoint]) {
645        let Some(first) = points.first() else {
646            return;
647        };
648        let x: f64 = f64::from(first.client_x);
649        let y: f64 = f64::from(first.client_y);
650        let step_x: f64 = x - self.get_last_x();
651        let step_y: f64 = y - self.get_last_y();
652        self.set_travel(self.get_travel() + (step_x * step_x + step_y * step_y).sqrt());
653        self.set_last_x(x);
654        self.set_last_y(y);
655    }
656
657    /// Returns the first tracked finger, or `None` when no sequence is
658    /// active.
659    ///
660    ///
661    /// # Returns
662    ///
663    /// - `Option<GesturePoint>` - The live position of the tracked finger,
664    ///   or `None` when no sequence is in flight.
665    pub fn primary(&self) -> Option<GesturePoint> {
666        if !self.get_active() {
667            return None;
668        }
669        Some(GesturePoint {
670            client_x: self.get_last_x(),
671            client_y: self.get_last_y(),
672        })
673    }
674
675    /// Classifies the finished sequence and marks the cell inactive.
676    ///
677    /// A tap requires both a small straight-line displacement and a short
678    /// accumulated path. Checking only the displacement would misread a
679    /// finger that traces a closed loop and returns to its origin as a tap,
680    /// even though it travelled hundreds of pixels; the path length is what
681    /// separates the two cases.
682    ///
683    /// # Arguments
684    ///
685    /// - `&EuvGestureConfig` - The thresholds to classify against.
686    ///
687    /// # Returns
688    ///
689    /// - `Option<EuvGesture>` - The recognised gesture, or `None` when no
690    ///   sequence was in flight.
691    pub fn finish(&mut self, config: &EuvGestureConfig) -> Option<EuvGesture> {
692        if !self.get_active() {
693            return None;
694        }
695        self.set_active(false);
696        let dx: f64 = self.get_last_x() - self.get_start_x();
697        let dy: f64 = self.get_last_y() - self.get_start_y();
698        let elapsed: f64 = now_millis() - self.get_started_at();
699        let distance: f64 = (dx * dx + dy * dy).sqrt();
700        if distance <= config.tap_slop && self.get_travel() <= config.tap_slop {
701            return Some(if elapsed >= config.long_press_millis {
702                EuvGesture::LongPress
703            } else {
704                EuvGesture::Tap
705            });
706        }
707        if distance < config.swipe_threshold {
708            return None;
709        }
710        Some(if dx.abs() >= dy.abs() {
711            if dx > 0.0 {
712                EuvGesture::Right
713            } else {
714                EuvGesture::Left
715            }
716        } else if dy > 0.0 {
717            EuvGesture::Down
718        } else {
719            EuvGesture::Up
720        })
721    }
722
723    /// Clears all progress, used when a sequence is cancelled.
724    ///
725    /// # Arguments
726    ///
727    pub fn reset(&mut self) {
728        *self = Self::default();
729    }
730}