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#[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 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 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 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 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 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 pub fn contact_down(&mut self, tid: u64, pos_px: (f32, f32)) {
443 self.active_touches.insert(tid, pos_px);
444 }
445
446 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 #[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 #[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 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}