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 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 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}