Skip to main content

repose_app/
touch_gesture.rs

1use std::collections::BTreeMap;
2
3use repose_core::Vec2;
4use repose_core::input::PointerButton;
5
6use crate::runtime::ReposeRuntime;
7use crate::runtime::TAP_SLOP_DP;
8
9#[derive(Clone, Copy, Debug)]
10struct DynGestureState {
11    avg_distance: f32,
12    avg_pos: Vec2,
13    heading: f32,
14}
15
16#[derive(Clone, Debug)]
17struct GestureState {
18    previous: Option<DynGestureState>,
19    current: DynGestureState,
20}
21
22pub struct MultiTouchDelta {
23    pub zoom: f32,
24    pub translation: Vec2,
25    pub rotation: f32,
26    pub center: Vec2,
27    pub num_touches: usize,
28}
29
30pub struct TouchGestureState {
31    active_touches: BTreeMap<u64, (f32, f32)>,
32    primary_touch_id: Option<u64>,
33    prev_touch_px: Option<(f32, f32)>,
34    touch_start: Option<(web_time::Instant, (f32, f32))>,
35    touch_scroll_accum_x_px: f32,
36    touch_scroll_accum_y_px: f32,
37    touch_scrolled: bool,
38    scroll_capture_id: Option<u64>,
39    gesture_state: Option<GestureState>,
40    past_touch_slop: bool,
41    gesture_kind: Option<GestureKind>,
42    accum_pan: Vec2,
43    accum_zoom: f32,
44    accum_rotation: f32,
45    primary_press_focus: Option<Option<u64>>,
46    pending_primary: Option<(Vec2, u64)>,
47}
48
49/// Which motion a multi-finger gesture committed to. Set once, when the gesture
50/// first travels past slop, so a pinch never also pans.
51#[derive(Clone, Copy, PartialEq, Eq)]
52enum GestureKind {
53    Pan,
54    Zoom,
55    Rotate,
56}
57
58impl Default for TouchGestureState {
59    fn default() -> Self {
60        Self {
61            active_touches: BTreeMap::new(),
62            primary_touch_id: None,
63            prev_touch_px: None,
64            touch_start: None,
65            touch_scroll_accum_x_px: 0.0,
66            touch_scroll_accum_y_px: 0.0,
67            touch_scrolled: false,
68            scroll_capture_id: None,
69            gesture_state: None,
70            past_touch_slop: false,
71            gesture_kind: None,
72            accum_pan: Vec2 { x: 0.0, y: 0.0 },
73            accum_zoom: 1.0,
74            accum_rotation: 0.0,
75            primary_press_focus: None,
76            pending_primary: None,
77        }
78    }
79}
80
81pub struct TouchEnded {
82    pub swipe_right: Option<bool>,
83    /// Focus result of a press dispatched on release. `None` when no press
84    /// ran (scroll/pinch release, cancel); `Some` carries the press's
85    /// focused id (`None` inside means the tap explicitly defocused).
86    pub press: Option<Option<u64>>,
87}
88
89impl TouchGestureState {
90    fn calc_dynamic_state(&self) -> Option<DynGestureState> {
91        let n = self.active_touches.len();
92        if n < 2 {
93            return None;
94        }
95        let n_recip = 1.0 / n as f32;
96        let mut avg_pos = Vec2 { x: 0.0, y: 0.0 };
97        for (x, y) in self.active_touches.values() {
98            avg_pos.x += *x;
99            avg_pos.y += *y;
100        }
101        avg_pos.x *= n_recip;
102        avg_pos.y *= n_recip;
103        let mut avg_distance = 0.0;
104        for (x, y) in self.active_touches.values() {
105            let dx = avg_pos.x - *x;
106            let dy = avg_pos.y - *y;
107            avg_distance += (dx * dx + dy * dy).sqrt();
108        }
109        avg_distance *= n_recip;
110        let first = self.active_touches.values().next().copied()?;
111        let heading = (avg_pos.x - first.0).atan2(avg_pos.y - first.1);
112        Some(DynGestureState {
113            avg_distance: avg_distance.max(1.0),
114            avg_pos,
115            heading,
116        })
117    }
118
119    fn update_gesture(&mut self, pointer_pos: Option<Vec2>, added_or_removed: bool) {
120        if let Some(dyn_state) = self.calc_dynamic_state() {
121            if let Some(state) = &mut self.gesture_state {
122                state.previous = Some(state.current);
123                state.current = dyn_state;
124                if added_or_removed {
125                    state.previous = None;
126                    self.past_touch_slop = false;
127                    self.gesture_kind = None;
128                    self.accum_pan = Vec2 { x: 0.0, y: 0.0 };
129                    self.accum_zoom = 1.0;
130                    self.accum_rotation = 0.0;
131                }
132            } else if pointer_pos.is_some() {
133                self.gesture_state = Some(GestureState {
134                    previous: None,
135                    current: dyn_state,
136                });
137                self.past_touch_slop = false;
138                self.gesture_kind = None;
139                self.accum_pan = Vec2 { x: 0.0, y: 0.0 };
140                self.accum_zoom = 1.0;
141                self.accum_rotation = 0.0;
142                if added_or_removed && let Some(s) = &mut self.gesture_state {
143                    s.previous = None;
144                }
145            }
146        } else {
147            self.gesture_state = None;
148            self.past_touch_slop = false;
149            self.gesture_kind = None;
150            self.accum_pan = Vec2 { x: 0.0, y: 0.0 };
151            self.accum_zoom = 1.0;
152            self.accum_rotation = 0.0;
153        }
154    }
155
156    fn multi_touch_delta(&self) -> Option<(Vec2, f32, f32, Vec2)> {
157        let state = self.gesture_state.as_ref()?;
158        let prev = state.previous.unwrap_or(state.current);
159        let curr = state.current;
160        let zoom = curr.avg_distance / prev.avg_distance;
161        let pan = Vec2 {
162            x: curr.avg_pos.x - prev.avg_pos.x,
163            y: curr.avg_pos.y - prev.avg_pos.y,
164        };
165        let rotation = curr.heading - prev.heading;
166        let rotation = rotation.sin().atan2(rotation.cos());
167        Some((pan, zoom, rotation, curr.avg_pos))
168    }
169
170    pub fn touch_started(
171        &mut self,
172        rt: &mut ReposeRuntime,
173        tid: u64,
174        pos_px: (f32, f32),
175    ) -> Option<u64> {
176        rt.mouse_pos_px = pos_px;
177        let pos = Vec2 {
178            x: pos_px.0,
179            y: pos_px.1,
180        };
181        self.active_touches.insert(tid, pos_px);
182        // Every finger dispatches its own press immediately: games stage
183        // per-finger contacts off these events (GML `device_mouse_*`
184        // parity).
185        let press = rt.handle_touch_press(Self::touch_finger(tid), pos, PointerButton::Primary);
186
187        let is_primary = self.primary_touch_id.is_none();
188        if is_primary {
189            self.primary_touch_id = Some(tid);
190            self.touch_start = Some((web_time::Instant::now(), pos_px));
191            self.touch_scrolled = false;
192            self.scroll_capture_id = None;
193            self.touch_scroll_accum_x_px = 0.0;
194            self.touch_scroll_accum_y_px = 0.0;
195            self.prev_touch_px = Some(pos_px);
196            self.pending_primary = Some((pos, tid));
197            self.primary_press_focus = Some(press.focused);
198            if self.active_touches.len() >= 2 {
199                self.update_gesture(Some(pos), true);
200            }
201            return press.focused;
202        }
203        if let Some(primary_tid) = self.primary_touch_id {
204            rt.suppress_touch_click(primary_tid);
205        }
206        self.pending_primary = None;
207        if self.active_touches.len() >= 2 {
208            let pointer_pos = self
209                .primary_touch_id
210                .and_then(|pid| self.active_touches.get(&pid).copied())
211                .map(|(x, y)| Vec2 { x, y });
212            self.update_gesture(pointer_pos, true);
213        }
214        None
215    }
216
217    pub fn touch_moved(
218        &mut self,
219        rt: &mut ReposeRuntime,
220        tid: u64,
221        pos_px: (f32, f32),
222        scale: f32,
223    ) -> (
224        bool,
225        Option<(f32, Vec2)>,
226        Option<(Vec2, Vec2)>,
227        Option<(f32, Vec2)>,
228    ) {
229        rt.mouse_pos_px = pos_px;
230        let pos = Vec2 {
231            x: pos_px.0,
232            y: pos_px.1,
233        };
234        let mut dirty;
235        let mut pinch: Option<(f32, Vec2)> = None;
236        let mut pan: Option<(Vec2, Vec2)> = None;
237        let mut rotation: Option<(f32, Vec2)> = None;
238        self.active_touches.insert(tid, pos_px);
239        rt.handle_touch_move(Self::touch_finger(tid), pos);
240        dirty = true;
241
242        if self.active_touches.len() >= 2 {
243            let pointer_pos = self
244                .primary_touch_id
245                .and_then(|pid| self.active_touches.get(&pid).copied())
246                .map(|(x, y)| Vec2 { x, y });
247            self.update_gesture(pointer_pos, false);
248            if let Some((raw_pan, raw_zoom, raw_rot, center)) = self.multi_touch_delta() {
249                let centroid_size = self
250                    .gesture_state
251                    .as_ref()
252                    .map(|s| s.current.avg_distance)
253                    .unwrap_or(1.0);
254                let touch_slop = 18.0 * scale;
255                if !self.past_touch_slop {
256                    self.accum_pan.x += raw_pan.x;
257                    self.accum_pan.y += raw_pan.y;
258                    self.accum_zoom *= raw_zoom;
259                    self.accum_rotation += raw_rot;
260                    let zoom_motion = (self.accum_zoom - 1.0).abs() * centroid_size;
261                    let rotation_motion = self.accum_rotation.abs() * centroid_size;
262                    let pan_motion = (self.accum_pan.x * self.accum_pan.x
263                        + self.accum_pan.y * self.accum_pan.y)
264                        .sqrt();
265                    if zoom_motion > touch_slop
266                        || rotation_motion > touch_slop
267                        || pan_motion > touch_slop
268                    {
269                        self.past_touch_slop = true;
270                        // Whichever motion travelled furthest commits the
271                        // gesture. A real pinch also moves its centroid, so
272                        // without this a pinch pans and zooms at once - the
273                        // view lurches while it scales.
274                        self.gesture_kind = Some(
275                            if zoom_motion >= rotation_motion && zoom_motion >= pan_motion {
276                                GestureKind::Zoom
277                            } else if rotation_motion >= pan_motion {
278                                GestureKind::Rotate
279                            } else {
280                                GestureKind::Pan
281                            },
282                        );
283                    }
284                }
285                match self.gesture_kind {
286                    Some(GestureKind::Zoom) => pinch = Some((raw_zoom, center)),
287                    Some(GestureKind::Rotate) => {
288                        if raw_rot != 0.0 {
289                            rotation = Some((raw_rot, center));
290                        }
291                    }
292                    _ => pan = Some((raw_pan, center)),
293                }
294                if self.past_touch_slop {
295                    self.touch_scrolled = true;
296                    dirty = true;
297                } else {
298                    dirty = false;
299                }
300            }
301            if self.primary_touch_id == Some(tid) {
302                self.prev_touch_px = Some(pos_px);
303            }
304            return (dirty, pinch, pan, rotation);
305        }
306
307        if self.primary_touch_id != Some(tid) {
308            return (dirty, None, None, None);
309        }
310
311        // Tap-slop grace period: while the finger stays within slop of the
312        // press point, swallow the move so a tap cannot jitter the page and
313        // the click survives the release. Only travel past slop turns the
314        // gesture into a drag/scroll, matching Compose `detectTapGestures`
315        // (a dwell time alone never cancels a tap).
316        if let Some((pending_pos, pending_tid)) = self.pending_primary {
317            let slop = TAP_SLOP_DP * scale;
318            let dx = pos_px.0 - pending_pos.x;
319            let dy = pos_px.1 - pending_pos.y;
320            if dx * dx + dy * dy <= slop * slop {
321                self.prev_touch_px = Some(pos_px);
322                return (dirty, None, None, None);
323            }
324            rt.suppress_touch_click(pending_tid);
325            self.pending_primary = None;
326        }
327
328        if let Some(prev) = self.prev_touch_px {
329            let dx_px = pos_px.0 - prev.0;
330            let dy_px = pos_px.1 - prev.1;
331
332            if (dx_px.abs() > 0.0 || dy_px.abs() > 0.0) && !rt.touch_owns_drag(tid) {
333                self.touch_scroll_accum_x_px += dx_px;
334                self.touch_scroll_accum_y_px += dy_px;
335
336                let is_scroll = self.touch_scrolled
337                    || self.touch_scroll_accum_x_px.abs() > 6.0 * scale
338                    || self.touch_scroll_accum_y_px.abs() > 6.0 * scale;
339
340                if is_scroll {
341                    let (consumed, cap) = rt.handle_scroll_at(
342                        pos,
343                        Vec2 {
344                            x: -dx_px,
345                            y: -dy_px,
346                        },
347                        self.scroll_capture_id,
348                    );
349                    self.scroll_capture_id = cap;
350
351                    if consumed {
352                        self.touch_scrolled = true;
353                    }
354                }
355            }
356            dirty = true;
357        }
358
359        self.prev_touch_px = Some(pos_px);
360        (dirty, None, None, None)
361    }
362
363    pub fn touch_ended(
364        &mut self,
365        rt: &mut ReposeRuntime,
366        tid: u64,
367        pos_px: (f32, f32),
368        cancelled: bool,
369    ) -> TouchEnded {
370        rt.mouse_pos_px = pos_px;
371        let pos = Vec2 {
372            x: pos_px.0,
373            y: pos_px.1,
374        };
375
376        let is_primary = self.primary_touch_id == Some(tid);
377        if cancelled {
378            rt.handle_touch_cancel_at(tid, pos);
379        } else {
380            rt.handle_touch_release(Self::touch_finger(tid), pos, PointerButton::Primary);
381        }
382        let press = if is_primary && !cancelled {
383            self.pending_primary.take();
384            self.primary_press_focus.take()
385        } else {
386            if is_primary && cancelled {
387                self.pending_primary = None;
388                self.primary_press_focus = None;
389            }
390            None
391        };
392
393        self.active_touches.remove(&tid);
394        if self.active_touches.len() >= 2 {
395            let pointer_pos = self
396                .primary_touch_id
397                .and_then(|pid| self.active_touches.get(&pid).copied())
398                .map(|(x, y)| Vec2 { x, y });
399            self.update_gesture(pointer_pos, true);
400        } else {
401            self.gesture_state = None;
402            self.past_touch_slop = false;
403            self.gesture_kind = None;
404            self.accum_pan = Vec2 { x: 0.0, y: 0.0 };
405            self.accum_zoom = 1.0;
406            self.accum_rotation = 0.0;
407        }
408
409        let mut swipe_right = None;
410        if is_primary {
411            self.primary_touch_id = None;
412            if let Some((t0, p0)) = self.touch_start.take() {
413                let dt = (web_time::Instant::now() - t0).as_secs_f32();
414                let dx = pos_px.0 - p0.0;
415                let dy = pos_px.1 - p0.1;
416
417                if dt < 0.35 && dy.abs() < 40.0 && dx.abs() > 80.0 && !self.touch_scrolled {
418                    swipe_right = Some(dx > 0.0);
419                }
420            }
421            self.scroll_capture_id = None;
422            self.prev_touch_px = None;
423        }
424        TouchEnded { swipe_right, press }
425    }
426
427    /// Live touch contacts in physical px, keyed by winit touch id.
428    /// Single source for game touch zones (unlike the press
429    /// edge, which fires once and is gone).
430    /// Finger id carried on gesture-dispatched pointer events, so
431    /// game viewports can attribute each touch to its finger.
432    fn touch_finger(tid: u64) -> Option<u64> {
433        Some(tid)
434    }
435
436    pub fn active_touches(&self) -> &BTreeMap<u64, (f32, f32)> {
437        &self.active_touches
438    }
439
440    /// Record a live contact (Started/Moved). Called from
441    /// `handle_touch_raw` before the gesture layer runs.
442    pub fn contact_down(&mut self, tid: u64, pos_px: (f32, f32)) {
443        self.active_touches.insert(tid, pos_px);
444    }
445
446    /// Drop a live contact (Ended/Cancelled). Called from
447    /// `handle_touch_raw` before the gesture layer runs.
448    pub fn contact_up(&mut self, tid: u64) {
449        self.active_touches.remove(&tid);
450    }
451
452    pub fn multi_touch_info(&self) -> Option<MultiTouchDelta> {
453        let state = self.gesture_state.as_ref()?;
454        let prev = state.previous.unwrap_or(state.current);
455        let curr = state.current;
456        let zoom = curr.avg_distance / prev.avg_distance;
457        let translation = Vec2 {
458            x: curr.avg_pos.x - prev.avg_pos.x,
459            y: curr.avg_pos.y - prev.avg_pos.y,
460        };
461        let rotation = curr.heading - prev.heading;
462        let rotation = rotation.sin().atan2(rotation.cos());
463        Some(MultiTouchDelta {
464            zoom,
465            translation,
466            rotation,
467            center: curr.avg_pos,
468            num_touches: self.active_touches.len(),
469        })
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use std::cell::RefCell;
477    use std::rc::Rc;
478
479    /// A twist travels furthest around the centroid, so it must commit the
480    /// gesture to rotation and report nothing else. When the channels were
481    /// independent a twist also zoomed and panned, so the view lurched while
482    /// it turned.
483    #[test]
484    fn twist_reports_rotation_once_past_slop() {
485        let mut rt = ReposeRuntime::new();
486        let mut g = TouchGestureState::default();
487        g.touch_started(&mut rt, 0, (0.0, 0.0));
488        g.touch_started(&mut rt, 1, (100.0, 0.0));
489        let (_, _, _, rotation) = g.touch_moved(&mut rt, 1, (100.0, 20.0), 1.0);
490        let _ = rotation;
491        let (_, _, _, _) = g.touch_moved(&mut rt, 1, (50.0, 80.0), 1.0);
492        let (dirty, pinch, pan, rotation) = g.touch_moved(&mut rt, 1, (0.0, 100.0), 1.0);
493        assert!(dirty, "twist past slop must mark dirty");
494        assert!(pinch.is_none(), "a twist must not also zoom");
495        assert!(pan.is_none(), "a twist must not also pan");
496        let (delta_rot, center) = rotation.expect("rotation must be propagated, not dropped");
497        assert!(
498            delta_rot.abs() > 0.1,
499            "expected non-trivial twist, got {delta_rot}"
500        );
501        assert!(center.x.is_finite() && center.y.is_finite());
502        let info = g.multi_touch_info().expect("info mirrors deltas");
503        assert!(info.rotation.abs() > 0.0);
504    }
505
506    /// The mirror image: a pure pinch (fingers moving apart without the
507    /// centroid following) must commit to zoom and not slide the view.
508    #[test]
509    fn pinch_reports_zoom_once_past_slop() {
510        let mut rt = ReposeRuntime::new();
511        let mut g = TouchGestureState::default();
512        g.touch_started(&mut rt, 0, (40.0, 50.0));
513        g.touch_started(&mut rt, 1, (60.0, 50.0));
514        // Spread the fingers to 3x their separation while the centroid holds.
515        let (dirty, pinch, pan, _) = g.touch_moved(&mut rt, 1, (70.0, 50.0), 1.0);
516        g.touch_moved(&mut rt, 0, (30.0, 50.0), 1.0);
517        let (dirty, pinch, pan, _) = g.touch_moved(&mut rt, 1, (90.0, 50.0), 1.0);
518        assert!(dirty, "pinch past slop must mark dirty");
519        assert!(pinch.is_some(), "pinch must report zoom");
520        assert!(pan.is_none(), "a pinch must not also pan");
521    }
522
523    #[test]
524    fn pure_pinch_reports_no_rotation_spam() {
525        let mut rt = ReposeRuntime::new();
526        let mut g = TouchGestureState::default();
527        g.touch_started(&mut rt, 0, (0.0, 0.0));
528        g.touch_started(&mut rt, 1, (100.0, 0.0));
529        let (_, _, _, _) = g.touch_moved(&mut rt, 1, (140.0, 0.0), 1.0);
530        let (dirty, pinch, _, rotation) = g.touch_moved(&mut rt, 1, (180.0, 0.0), 1.0);
531        assert!(dirty);
532        assert!(pinch.is_some());
533        assert!(
534            rotation.is_none(),
535            "exact-zero rotation must not spam Rotate gestures"
536        );
537    }
538
539    #[test]
540    fn second_finger_down_registers_before_any_move() {
541        let mut rt = ReposeRuntime::new();
542        let mut g = TouchGestureState::default();
543        g.contact_down(1, (10.0, 10.0));
544        g.touch_started(&mut rt, 1, (10.0, 10.0));
545        g.contact_down(2, (200.0, 200.0));
546        g.touch_started(&mut rt, 2, (200.0, 200.0));
547        assert_eq!(
548            g.active_touches().len(),
549            2,
550            "both fingers visible while held still"
551        );
552        assert!(
553            g.active_touches().contains_key(&1) && g.active_touches().contains_key(&2),
554            "no finger dropped before its first move"
555        );
556    }
557
558    #[test]
559    fn every_finger_dispatches_its_own_press_move_release() {
560        use repose_core::input::{PointerEvent, PointerEventKind, PointerKind};
561        use std::collections::HashMap;
562        let mut rt = ReposeRuntime::new();
563        let seen: Rc<RefCell<HashMap<u64, Vec<PointerEventKind>>>> =
564            Rc::new(RefCell::new(HashMap::new()));
565        let seen_down = seen.clone();
566        let mk = move |_kind: PointerKind| {
567            let seen_down = seen_down.clone();
568            Rc::new(move |ev: PointerEvent| {
569                seen_down
570                    .borrow_mut()
571                    .entry(ev.id.0)
572                    .or_default()
573                    .push(ev.event);
574            })
575        };
576        rt.cache_frame(repose_core::runtime::Frame {
577            scene: Default::default(),
578            hit_regions: vec![repose_core::HitRegion {
579                id: 1,
580                rect: repose_core::Rect {
581                    x: 0.0,
582                    y: 0.0,
583                    w: 400.0,
584                    h: 400.0,
585                },
586                on_pointer_down: Some(mk(PointerKind::Touch)),
587                on_pointer_move: Some(mk(PointerKind::Touch)),
588                on_pointer_up: Some(mk(PointerKind::Touch)),
589                ..Default::default()
590            }],
591            semantics_nodes: Vec::new(),
592            focus_chain: Vec::new(),
593        });
594        let mut g = TouchGestureState::default();
595        g.touch_started(&mut rt, 1, (10.0, 10.0));
596        g.touch_started(&mut rt, 2, (200.0, 200.0));
597        g.touch_moved(&mut rt, 1, (12.0, 10.0), 1.0);
598        g.touch_moved(&mut rt, 2, (200.0, 202.0), 1.0);
599        g.touch_ended(&mut rt, 1, (12.0, 10.0), false);
600        g.touch_ended(&mut rt, 2, (200.0, 202.0), false);
601        let seen = seen.borrow();
602        for fid in [1u64, 2u64] {
603            let kinds: Vec<_> = seen.get(&fid).cloned().unwrap_or_default();
604            assert!(
605                kinds.iter().any(|k| matches!(k, PointerEventKind::Down(_))),
606                "finger {fid} must dispatch its own press, got {kinds:?}"
607            );
608            assert!(
609                kinds.iter().any(|k| matches!(k, PointerEventKind::Move)),
610                "finger {fid} must dispatch its own move, got {kinds:?}"
611            );
612            assert!(
613                kinds.iter().any(|k| matches!(k, PointerEventKind::Up(_))),
614                "finger {fid} must dispatch its own release, got {kinds:?}"
615            );
616        }
617        assert!(
618            rt.touch_paths.is_empty(),
619            "both finger paths must drop on release"
620        );
621    }
622}