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;
7
8#[derive(Clone, Copy, Debug)]
9struct DynGestureState {
10    avg_distance: f32,
11    avg_pos: Vec2,
12    heading: f32,
13}
14
15#[derive(Clone, Debug)]
16struct GestureState {
17    previous: Option<DynGestureState>,
18    current: DynGestureState,
19}
20
21pub struct MultiTouchDelta {
22    pub zoom: f32,
23    pub translation: Vec2,
24    pub rotation: f32,
25    pub center: Vec2,
26    pub num_touches: usize,
27}
28
29pub struct TouchGestureState {
30    active_touches: BTreeMap<u64, (f32, f32)>,
31    primary_touch_id: Option<u64>,
32    prev_touch_px: Option<(f32, f32)>,
33    touch_start: Option<(web_time::Instant, (f32, f32))>,
34    touch_scroll_accum_x_px: f32,
35    touch_scroll_accum_y_px: f32,
36    touch_scrolled: bool,
37    scroll_capture_id: Option<u64>,
38    gesture_state: Option<GestureState>,
39    past_touch_slop: bool,
40    accum_pan: Vec2,
41    accum_zoom: f32,
42    accum_rotation: f32,
43    // single-finger pending (deferred press to allow 2nd finger to cancel)
44    primary_press_dispatched: bool,
45    pending_primary: Option<(Vec2, web_time::Instant, u64)>,
46}
47
48impl Default for TouchGestureState {
49    fn default() -> Self {
50        Self {
51            active_touches: BTreeMap::new(),
52            primary_touch_id: None,
53            prev_touch_px: None,
54            touch_start: None,
55            touch_scroll_accum_x_px: 0.0,
56            touch_scroll_accum_y_px: 0.0,
57            touch_scrolled: false,
58            scroll_capture_id: None,
59            gesture_state: None,
60            past_touch_slop: false,
61            accum_pan: Vec2 { x: 0.0, y: 0.0 },
62            accum_zoom: 1.0,
63            accum_rotation: 0.0,
64            primary_press_dispatched: false,
65            pending_primary: None,
66        }
67    }
68}
69
70pub struct TouchEnded {
71    pub swipe_right: Option<bool>,
72    /// Focus result of a press dispatched on release. `None` when no press
73    /// ran (scroll/pinch release, cancel); `Some` carries the press's
74    /// focused id (`None` inside means the tap explicitly defocused).
75    pub press: Option<Option<u64>>,
76}
77
78impl TouchGestureState {
79    fn calc_dynamic_state(&self) -> Option<DynGestureState> {
80        let n = self.active_touches.len();
81        if n < 2 {
82            return None;
83        }
84        let n_recip = 1.0 / n as f32;
85        let mut avg_pos = Vec2 { x: 0.0, y: 0.0 };
86        for (x, y) in self.active_touches.values() {
87            avg_pos.x += *x;
88            avg_pos.y += *y;
89        }
90        avg_pos.x *= n_recip;
91        avg_pos.y *= n_recip;
92        let mut avg_distance = 0.0;
93        for (x, y) in self.active_touches.values() {
94            let dx = avg_pos.x - *x;
95            let dy = avg_pos.y - *y;
96            avg_distance += (dx * dx + dy * dy).sqrt();
97        }
98        avg_distance *= n_recip;
99        let first = self.active_touches.values().next().copied()?;
100        let heading = (avg_pos.x - first.0).atan2(avg_pos.y - first.1);
101        Some(DynGestureState {
102            avg_distance: avg_distance.max(1.0),
103            avg_pos,
104            heading,
105        })
106    }
107
108    fn update_gesture(&mut self, pointer_pos: Option<Vec2>, added_or_removed: bool) {
109        if let Some(dyn_state) = self.calc_dynamic_state() {
110            if let Some(state) = &mut self.gesture_state {
111                state.previous = Some(state.current);
112                state.current = dyn_state;
113                if added_or_removed {
114                    state.previous = None;
115                    self.past_touch_slop = false;
116                    self.accum_pan = Vec2 { x: 0.0, y: 0.0 };
117                    self.accum_zoom = 1.0;
118                    self.accum_rotation = 0.0;
119                }
120            } else if pointer_pos.is_some() {
121                self.gesture_state = Some(GestureState {
122                    previous: None,
123                    current: dyn_state,
124                });
125                self.past_touch_slop = false;
126                self.accum_pan = Vec2 { x: 0.0, y: 0.0 };
127                self.accum_zoom = 1.0;
128                self.accum_rotation = 0.0;
129                if added_or_removed && let Some(s) = &mut self.gesture_state {
130                    s.previous = None;
131                }
132            }
133        } else {
134            self.gesture_state = None;
135            self.past_touch_slop = false;
136            self.accum_pan = Vec2 { x: 0.0, y: 0.0 };
137            self.accum_zoom = 1.0;
138            self.accum_rotation = 0.0;
139        }
140    }
141
142    fn multi_touch_delta(&self) -> Option<(Vec2, f32, f32, Vec2)> {
143        let state = self.gesture_state.as_ref()?;
144        let prev = state.previous.unwrap_or(state.current);
145        let curr = state.current;
146        let zoom = curr.avg_distance / prev.avg_distance;
147        let pan = Vec2 {
148            x: curr.avg_pos.x - prev.avg_pos.x,
149            y: curr.avg_pos.y - prev.avg_pos.y,
150        };
151        let rotation = curr.heading - prev.heading;
152        let rotation = rotation.sin().atan2(rotation.cos());
153        Some((pan, zoom, rotation, curr.avg_pos))
154    }
155
156    pub fn touch_started(
157        &mut self,
158        rt: &mut ReposeRuntime,
159        tid: u64,
160        pos_px: (f32, f32),
161    ) -> Option<u64> {
162        rt.mouse_pos_px = pos_px;
163        let pos = Vec2 {
164            x: pos_px.0,
165            y: pos_px.1,
166        };
167        let was_empty = self.active_touches.is_empty();
168        let _ = was_empty;
169        self.active_touches.insert(tid, pos_px);
170
171        let is_primary = self.primary_touch_id.is_none();
172        if is_primary {
173            self.primary_touch_id = Some(tid);
174            self.touch_start = Some((web_time::Instant::now(), pos_px));
175            self.touch_scrolled = false;
176            self.scroll_capture_id = None;
177            self.touch_scroll_accum_x_px = 0.0;
178            self.touch_scroll_accum_y_px = 0.0;
179            self.prev_touch_px = Some(pos_px);
180            self.pending_primary = Some((pos, web_time::Instant::now(), tid));
181            self.primary_press_dispatched = false;
182            if self.active_touches.len() >= 2 {
183                self.update_gesture(Some(pos), true);
184            }
185            return None;
186        }
187        if self.pending_primary.is_some() {
188            self.pending_primary = None;
189        }
190        if self.primary_press_dispatched {
191            rt.handle_pointer_cancel();
192            self.primary_press_dispatched = false;
193        }
194        if self.active_touches.len() >= 2 {
195            let pointer_pos = self
196                .primary_touch_id
197                .and_then(|pid| self.active_touches.get(&pid).copied())
198                .map(|(x, y)| Vec2 { x, y });
199            self.update_gesture(pointer_pos, true);
200        }
201        None
202    }
203
204    pub fn touch_moved(
205        &mut self,
206        rt: &mut ReposeRuntime,
207        tid: u64,
208        pos_px: (f32, f32),
209        scale: f32,
210    ) -> (
211        bool,
212        Option<(f32, Vec2)>,
213        Option<(Vec2, Vec2)>,
214        Option<(f32, Vec2)>,
215    ) {
216        rt.mouse_pos_px = pos_px;
217        let pos = Vec2 {
218            x: pos_px.0,
219            y: pos_px.1,
220        };
221        let mut dirty = false;
222        let mut pinch: Option<(f32, Vec2)> = None;
223        let mut pan: Option<(Vec2, Vec2)> = None;
224        let mut rotation: Option<(f32, Vec2)> = None;
225        self.active_touches.insert(tid, pos_px);
226
227        if self.active_touches.len() >= 2 {
228            let pointer_pos = self
229                .primary_touch_id
230                .and_then(|pid| self.active_touches.get(&pid).copied())
231                .map(|(x, y)| Vec2 { x, y });
232            self.update_gesture(pointer_pos, false);
233            if let Some((raw_pan, raw_zoom, raw_rot, center)) = self.multi_touch_delta() {
234                let centroid_size = self
235                    .gesture_state
236                    .as_ref()
237                    .map(|s| s.current.avg_distance)
238                    .unwrap_or(1.0);
239                let touch_slop = 18.0 * scale;
240                if !self.past_touch_slop {
241                    self.accum_pan.x += raw_pan.x;
242                    self.accum_pan.y += raw_pan.y;
243                    self.accum_zoom *= raw_zoom;
244                    self.accum_rotation += raw_rot;
245                    let zoom_motion = (self.accum_zoom - 1.0).abs() * centroid_size;
246                    let rotation_motion = self.accum_rotation.abs() * centroid_size;
247                    let pan_motion = (self.accum_pan.x * self.accum_pan.x
248                        + self.accum_pan.y * self.accum_pan.y)
249                        .sqrt();
250                    if zoom_motion > touch_slop
251                        || rotation_motion > touch_slop
252                        || pan_motion > touch_slop
253                    {
254                        self.past_touch_slop = true;
255                    }
256                }
257                if self.past_touch_slop {
258                    pinch = Some((raw_zoom, center));
259                    pan = Some((raw_pan, center));
260                    if raw_rot != 0.0 {
261                        rotation = Some((raw_rot, center));
262                    }
263                    self.touch_scrolled = true;
264                    dirty = true;
265                } else {
266                    dirty = false;
267                }
268            }
269            if self.primary_touch_id == Some(tid) {
270                self.prev_touch_px = Some(pos_px);
271            }
272            return (dirty, pinch, pan, rotation);
273        }
274
275        if self.primary_touch_id != Some(tid) {
276            return (dirty, None, None, None);
277        }
278
279        if let Some((pending_pos, pending_instant, _pending_tid)) = self.pending_primary {
280            let dt = (web_time::Instant::now() - pending_instant).as_secs_f32();
281            let dx = pos_px.0 - pending_pos.x;
282            let dy = pos_px.1 - pending_pos.y;
283            let dist = (dx * dx + dy * dy).sqrt();
284            if dt > 0.03 || dist > 6.0 * scale {
285                let _focused = rt
286                    .handle_pointer_press(pending_pos, PointerButton::Primary)
287                    .focused;
288                self.primary_press_dispatched = true;
289                self.pending_primary = None;
290            } else {
291                self.prev_touch_px = Some(pos_px);
292                return (dirty, None, None, None);
293            }
294        }
295
296        if let Some(prev) = self.prev_touch_px {
297            let dx_px = pos_px.0 - prev.0;
298            let dy_px = pos_px.1 - prev.1;
299
300            if dx_px.abs() > 0.0 || dy_px.abs() > 0.0 {
301                self.touch_scroll_accum_x_px += dx_px;
302                self.touch_scroll_accum_y_px += dy_px;
303
304                let is_scroll = self.touch_scrolled
305                    || self.touch_scroll_accum_x_px.abs() > 6.0 * scale
306                    || self.touch_scroll_accum_y_px.abs() > 6.0 * scale;
307
308                if is_scroll {
309                    let (consumed, cap) = rt.handle_scroll_at(
310                        pos,
311                        Vec2 {
312                            x: -dx_px,
313                            y: -dy_px,
314                        },
315                        self.scroll_capture_id,
316                    );
317                    self.scroll_capture_id = cap;
318
319                    if consumed {
320                        self.touch_scrolled = true;
321                    }
322                }
323            }
324
325            if self.primary_press_dispatched {
326                rt.handle_pointer_move(pos);
327            }
328            dirty = true;
329        }
330
331        self.prev_touch_px = Some(pos_px);
332        (dirty, None, None, None)
333    }
334
335    pub fn touch_ended(
336        &mut self,
337        rt: &mut ReposeRuntime,
338        tid: u64,
339        pos_px: (f32, f32),
340        cancelled: bool,
341    ) -> TouchEnded {
342        rt.mouse_pos_px = pos_px;
343        let pos = Vec2 {
344            x: pos_px.0,
345            y: pos_px.1,
346        };
347
348        let is_primary = self.primary_touch_id == Some(tid);
349        let was_multi = self.active_touches.len() >= 2;
350
351        let mut press = None;
352        if is_primary {
353            if let Some((pending_pos, _, _)) = self.pending_primary.take() {
354                if !cancelled && !was_multi && self.active_touches.len() < 2 {
355                    press = Some(
356                        rt.handle_pointer_press(pending_pos, PointerButton::Primary)
357                            .focused,
358                    );
359                    rt.handle_pointer_release(pos, PointerButton::Primary);
360                }
361                self.primary_press_dispatched = false;
362            } else if self.primary_press_dispatched {
363                if cancelled || was_multi {
364                    rt.handle_pointer_cancel();
365                } else {
366                    rt.handle_pointer_release(pos, PointerButton::Primary);
367                }
368                self.primary_press_dispatched = false;
369            } else if cancelled || was_multi {
370                rt.handle_pointer_cancel();
371            }
372        }
373
374        self.active_touches.remove(&tid);
375        if self.active_touches.len() >= 2 {
376            let pointer_pos = self
377                .primary_touch_id
378                .and_then(|pid| self.active_touches.get(&pid).copied())
379                .map(|(x, y)| Vec2 { x, y });
380            self.update_gesture(pointer_pos, true);
381        } else {
382            self.gesture_state = None;
383            self.past_touch_slop = false;
384            self.accum_pan = Vec2 { x: 0.0, y: 0.0 };
385            self.accum_zoom = 1.0;
386            self.accum_rotation = 0.0;
387        }
388
389        let mut swipe_right = None;
390        if is_primary {
391            self.primary_touch_id = None;
392            if let Some((t0, p0)) = self.touch_start.take() {
393                let dt = (web_time::Instant::now() - t0).as_secs_f32();
394                let dx = pos_px.0 - p0.0;
395                let dy = pos_px.1 - p0.1;
396
397                if dt < 0.35 && dy.abs() < 40.0 && dx.abs() > 80.0 && !self.touch_scrolled {
398                    swipe_right = Some(dx > 0.0);
399                }
400            }
401            self.scroll_capture_id = None;
402            self.prev_touch_px = None;
403        }
404        TouchEnded {
405            swipe_right,
406            press,
407        }
408    }
409
410    pub fn multi_touch_info(&self) -> Option<MultiTouchDelta> {
411        let state = self.gesture_state.as_ref()?;
412        let prev = state.previous.unwrap_or(state.current);
413        let curr = state.current;
414        let zoom = curr.avg_distance / prev.avg_distance;
415        let translation = Vec2 {
416            x: curr.avg_pos.x - prev.avg_pos.x,
417            y: curr.avg_pos.y - prev.avg_pos.y,
418        };
419        let rotation = curr.heading - prev.heading;
420        let rotation = rotation.sin().atan2(rotation.cos());
421        Some(MultiTouchDelta {
422            zoom,
423            translation,
424            rotation,
425            center: curr.avg_pos,
426            num_touches: self.active_touches.len(),
427        })
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn twist_reports_rotation_once_past_slop() {
437        let mut rt = ReposeRuntime::new();
438        let mut g = TouchGestureState::default();
439        g.touch_started(&mut rt, 0, (0.0, 0.0));
440        g.touch_started(&mut rt, 1, (100.0, 0.0));
441        let (_, _, _, rotation) = g.touch_moved(&mut rt, 1, (100.0, 20.0), 1.0);
442        let _ = rotation;
443        let (_, _, _, _) = g.touch_moved(&mut rt, 1, (50.0, 80.0), 1.0);
444        let (dirty, pinch, pan, rotation) = g.touch_moved(&mut rt, 1, (0.0, 100.0), 1.0);
445        assert!(dirty, "twist past slop must mark dirty");
446        assert!(pinch.is_some(), "zoom channel still reported");
447        assert!(pan.is_some(), "pan channel still reported");
448        let (delta_rot, center) = rotation.expect("rotation must be propagated, not dropped");
449        assert!(
450            delta_rot.abs() > 0.1,
451            "expected non-trivial twist, got {delta_rot}"
452        );
453        assert!(center.x.is_finite() && center.y.is_finite());
454        let info = g.multi_touch_info().expect("info mirrors deltas");
455        assert!(info.rotation.abs() > 0.0);
456    }
457
458    #[test]
459    fn pure_pinch_reports_no_rotation_spam() {
460        let mut rt = ReposeRuntime::new();
461        let mut g = TouchGestureState::default();
462        g.touch_started(&mut rt, 0, (0.0, 0.0));
463        g.touch_started(&mut rt, 1, (100.0, 0.0));
464        let (_, _, _, _) = g.touch_moved(&mut rt, 1, (140.0, 0.0), 1.0);
465        let (dirty, pinch, _, rotation) = g.touch_moved(&mut rt, 1, (180.0, 0.0), 1.0);
466        assert!(dirty);
467        assert!(pinch.is_some());
468        assert!(
469            rotation.is_none(),
470            "exact-zero rotation must not spam Rotate gestures"
471        );
472    }
473}