use crate::input::{InputEvent, TouchContact, TouchPhase};
use std::collections::HashMap;
const ZOOM_THRESHOLD: f64 = 0.1;
const ROTATION_THRESHOLD_PX: f64 = 25.0;
const PITCH_DEGREES_PER_PX: f64 = -0.5;
pub struct GestureRecognizer {
fingers: HashMap<u64, FingerState>,
two_finger_ids: Option<(u64, u64)>,
start_distance: f64,
last_distance: f64,
start_vector: Option<[f64; 2]>,
last_vector: Option<[f64; 2]>,
min_diameter: f64,
zoom_active: bool,
rotate_active: bool,
pitch_valid: Option<bool>,
pitch_last_points: Option<([f64; 2], [f64; 2])>,
}
#[derive(Debug, Clone, Copy)]
struct FingerState {
x: f64,
y: f64,
}
impl Default for GestureRecognizer {
fn default() -> Self {
Self::new()
}
}
impl GestureRecognizer {
pub fn new() -> Self {
Self {
fingers: HashMap::new(),
two_finger_ids: None,
start_distance: 0.0,
last_distance: 0.0,
start_vector: None,
last_vector: None,
min_diameter: 0.0,
zoom_active: false,
rotate_active: false,
pitch_valid: None,
pitch_last_points: None,
}
}
pub fn reset(&mut self) {
self.fingers.clear();
self.reset_two_finger();
}
#[inline]
pub fn finger_count(&self) -> usize {
self.fingers.len()
}
pub fn process(&mut self, contact: TouchContact) -> Vec<InputEvent> {
match contact.phase {
TouchPhase::Started => self.on_start(contact),
TouchPhase::Moved => self.on_move(contact),
TouchPhase::Ended | TouchPhase::Cancelled => self.on_end(contact),
}
}
fn on_start(&mut self, c: TouchContact) -> Vec<InputEvent> {
self.fingers.insert(c.id, FingerState { x: c.x, y: c.y });
if self.two_finger_ids.is_none() && self.fingers.len() >= 2 {
let mut ids: Vec<u64> = self.fingers.keys().copied().collect();
ids.sort_unstable();
let (id_a, id_b) = (ids[0], ids[1]);
let a = self.fingers[&id_a];
let b = self.fingers[&id_b];
let dist = distance(a.x, a.y, b.x, b.y);
let vec = [b.x - a.x, b.y - a.y];
self.two_finger_ids = Some((id_a, id_b));
self.start_distance = dist;
self.last_distance = dist;
self.start_vector = Some(vec);
self.last_vector = Some(vec);
self.min_diameter = dist;
self.zoom_active = false;
self.rotate_active = false;
self.pitch_valid = None;
self.pitch_last_points = Some(([a.x, a.y], [b.x, b.y]));
}
Vec::new()
}
fn on_move(&mut self, c: TouchContact) -> Vec<InputEvent> {
let prev = match self.fingers.get(&c.id) {
Some(f) => *f,
None => return Vec::new(), };
let dx = c.x - prev.x;
let dy = c.y - prev.y;
self.fingers.insert(c.id, FingerState { x: c.x, y: c.y });
let mut events = Vec::new();
if let Some((id_a, id_b)) = self.two_finger_ids {
if let (Some(a), Some(b)) = (self.fingers.get(&id_a), self.fingers.get(&id_b)) {
let a = *a;
let b = *b;
events.extend(self.check_zoom(a, b));
events.extend(self.check_rotation(a, b));
events.extend(self.check_pitch(a, b, c.id));
}
}
if dx.abs() > f64::EPSILON || dy.abs() > f64::EPSILON {
let (anchor_x, anchor_y) = self.pan_anchor();
events.push(InputEvent::Pan {
dx,
dy,
x: Some(anchor_x),
y: Some(anchor_y),
});
}
events
}
fn on_end(&mut self, c: TouchContact) -> Vec<InputEvent> {
self.fingers.remove(&c.id);
if let Some((id_a, id_b)) = self.two_finger_ids {
if c.id == id_a || c.id == id_b {
self.reset_two_finger();
}
}
Vec::new()
}
fn check_zoom(&mut self, a: FingerState, b: FingerState) -> Option<InputEvent> {
let dist = distance(a.x, a.y, b.x, b.y);
if dist < 1.0 {
return None; }
if !self.zoom_active {
let delta = (dist / self.start_distance).ln() / std::f64::consts::LN_2;
if delta.abs() < ZOOM_THRESHOLD {
return None;
}
self.zoom_active = true;
}
let zoom_delta = (dist / self.last_distance).ln() / std::f64::consts::LN_2;
self.last_distance = dist;
let factor = 2.0_f64.powf(zoom_delta);
let mx = (a.x + b.x) * 0.5;
let my = (a.y + b.y) * 0.5;
Some(InputEvent::Zoom {
factor,
x: Some(mx),
y: Some(my),
})
}
fn check_rotation(&mut self, a: FingerState, b: FingerState) -> Option<InputEvent> {
let vec = [b.x - a.x, b.y - a.y];
let mag = (vec[0] * vec[0] + vec[1] * vec[1]).sqrt();
if mag < 1.0 {
return None;
}
self.min_diameter = self.min_diameter.min(mag);
if !self.rotate_active {
if let Some(start) = self.start_vector {
let bearing = angle_between(vec, start);
let circumference = std::f64::consts::PI * self.min_diameter;
let threshold_deg = if circumference > 0.0 {
ROTATION_THRESHOLD_PX / circumference * 360.0
} else {
360.0
};
if bearing.abs() < threshold_deg {
self.last_vector = Some(vec);
return None;
}
}
self.rotate_active = true;
}
let bearing_delta = if let Some(last) = self.last_vector {
angle_between(vec, last)
} else {
0.0
};
self.last_vector = Some(vec);
if bearing_delta.abs() < f64::EPSILON {
return None;
}
let delta_yaw = bearing_delta.to_radians();
Some(InputEvent::Rotate {
delta_yaw,
delta_pitch: 0.0,
})
}
fn check_pitch(&mut self, a: FingerState, b: FingerState, moved_id: u64) -> Option<InputEvent> {
let (id_a, id_b) = self.two_finger_ids?;
if moved_id != id_a && moved_id != id_b {
return None;
}
let last_points = self.pitch_last_points?;
let vec_a = [a.x - last_points.0[0], a.y - last_points.0[1]];
let vec_b = [b.x - last_points.1[0], b.y - last_points.1[1]];
if self.pitch_valid.is_none() {
let a_mag = (vec_a[0] * vec_a[0] + vec_a[1] * vec_a[1]).sqrt();
let b_mag = (vec_b[0] * vec_b[0] + vec_b[1] * vec_b[1]).sqrt();
if a_mag > 2.0 && b_mag > 2.0 {
let a_vert = vec_a[1].abs() > vec_a[0].abs();
let b_vert = vec_b[1].abs() > vec_b[0].abs();
let same_dir = vec_a[1] * vec_b[1] > 0.0;
self.pitch_valid = Some(a_vert && b_vert && same_dir);
}
}
if self.pitch_valid != Some(true) {
return None;
}
self.pitch_last_points = Some(([a.x, a.y], [b.x, b.y]));
let y_avg = (vec_a[1] + vec_b[1]) * 0.5;
if y_avg.abs() < f64::EPSILON {
return None;
}
let pitch_delta_rad = (y_avg * PITCH_DEGREES_PER_PX).to_radians();
Some(InputEvent::Rotate {
delta_yaw: 0.0,
delta_pitch: pitch_delta_rad,
})
}
fn pan_anchor(&self) -> (f64, f64) {
if self.fingers.is_empty() {
return (0.0, 0.0);
}
let (mut sx, mut sy) = (0.0, 0.0);
for f in self.fingers.values() {
sx += f.x;
sy += f.y;
}
let n = self.fingers.len() as f64;
(sx / n, sy / n)
}
fn reset_two_finger(&mut self) {
self.two_finger_ids = None;
self.start_distance = 0.0;
self.last_distance = 0.0;
self.start_vector = None;
self.last_vector = None;
self.min_diameter = 0.0;
self.zoom_active = false;
self.rotate_active = false;
self.pitch_valid = None;
self.pitch_last_points = None;
}
}
#[inline]
fn distance(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
let dx = x2 - x1;
let dy = y2 - y1;
(dx * dx + dy * dy).sqrt()
}
fn angle_between(a: [f64; 2], b: [f64; 2]) -> f64 {
let cross = b[0] * a[1] - b[1] * a[0];
let dot = a[0] * b[0] + a[1] * b[1];
cross.atan2(dot).to_degrees()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::TouchPhase;
fn tc(id: u64, phase: TouchPhase, x: f64, y: f64) -> TouchContact {
TouchContact { id, phase, x, y }
}
#[test]
fn single_finger_pan() {
let mut g = GestureRecognizer::new();
let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
let events = g.process(tc(0, TouchPhase::Moved, 110.0, 205.0));
assert_eq!(events.len(), 1);
match events[0] {
InputEvent::Pan { dx, dy, .. } => {
assert!((dx - 10.0).abs() < 1e-9);
assert!((dy - 5.0).abs() < 1e-9);
}
_ => panic!("expected Pan"),
}
}
#[test]
fn finger_end_clears_state() {
let mut g = GestureRecognizer::new();
let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
let _ = g.process(tc(0, TouchPhase::Ended, 100.0, 200.0));
assert_eq!(g.finger_count(), 0);
}
#[test]
fn pinch_zoom_produces_zoom_event() {
let mut g = GestureRecognizer::new();
let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
let _ = g.process(tc(0, TouchPhase::Moved, 50.0, 200.0));
let events = g.process(tc(1, TouchPhase::Moved, 250.0, 200.0));
let has_zoom = events.iter().any(|e| e.is_zoom());
assert!(has_zoom, "expected zoom event from pinch: {events:?}");
}
#[test]
fn pinch_zoom_below_threshold_does_not_activate() {
let mut g = GestureRecognizer::new();
let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
let events = g.process(tc(1, TouchPhase::Moved, 201.0, 200.0));
let has_zoom = events.iter().any(|e| e.is_zoom());
assert!(!has_zoom, "should not zoom below threshold");
}
#[test]
fn rotation_produces_rotate_event() {
let mut g = GestureRecognizer::new();
let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
let _ = g.process(tc(0, TouchPhase::Moved, 100.0, 250.0));
let events = g.process(tc(1, TouchPhase::Moved, 200.0, 150.0));
let has_rotate = events.iter().any(|e| {
matches!(e,
InputEvent::Rotate { delta_yaw, .. } if delta_yaw.abs() > 1e-6
)
});
assert!(has_rotate, "expected rotation event: {events:?}");
}
#[test]
fn vertical_drag_produces_pitch() {
let mut g = GestureRecognizer::new();
let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
let _ = g.process(tc(0, TouchPhase::Moved, 100.0, 230.0));
let events = g.process(tc(1, TouchPhase::Moved, 200.0, 230.0));
let has_pitch = events.iter().any(|e| {
matches!(e,
InputEvent::Rotate { delta_pitch, .. } if delta_pitch.abs() > 1e-6
)
});
assert!(has_pitch, "expected pitch event: {events:?}");
}
#[test]
fn lifting_one_finger_resets_two_finger_state() {
let mut g = GestureRecognizer::new();
let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
assert!(g.two_finger_ids.is_some());
let _ = g.process(tc(1, TouchPhase::Ended, 200.0, 200.0));
assert!(g.two_finger_ids.is_none());
}
#[test]
fn cancel_resets_everything() {
let mut g = GestureRecognizer::new();
let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
g.reset();
assert_eq!(g.finger_count(), 0);
assert!(g.two_finger_ids.is_none());
}
#[test]
fn third_finger_ignored_for_two_finger_gesture() {
let mut g = GestureRecognizer::new();
let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
let ids_before = g.two_finger_ids;
let _ = g.process(tc(2, TouchPhase::Started, 300.0, 200.0));
assert_eq!(g.two_finger_ids, ids_before);
}
#[test]
fn pan_anchor_is_centroid() {
let mut g = GestureRecognizer::new();
let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
let (ax, ay) = g.pan_anchor();
assert!((ax - 150.0).abs() < 1e-9);
assert!((ay - 200.0).abs() < 1e-9);
}
#[test]
fn angle_between_90_degrees() {
let a = [1.0, 0.0];
let b = [0.0, 1.0];
let angle = angle_between(a, b);
assert!((angle.abs() - 90.0).abs() < 0.1, "got {angle}");
}
#[test]
fn angle_between_opposite_is_180() {
let a = [1.0, 0.0];
let b = [-1.0, 0.0];
let angle = angle_between(a, b);
assert!((angle.abs() - 180.0).abs() < 0.1, "got {angle}");
}
#[test]
fn pinch_out_zooms_in() {
let mut g = GestureRecognizer::new();
let _ = g.process(tc(0, TouchPhase::Started, 100.0, 200.0));
let _ = g.process(tc(1, TouchPhase::Started, 200.0, 200.0));
let _ = g.process(tc(0, TouchPhase::Moved, 0.0, 200.0));
let events = g.process(tc(1, TouchPhase::Moved, 300.0, 200.0));
let zoom_event = events.iter().find(|e| e.is_zoom());
if let Some(InputEvent::Zoom { factor, .. }) = zoom_event {
assert!(
*factor > 1.0,
"spreading fingers should zoom in, got factor={factor}"
);
} else {
panic!("expected zoom event: {events:?}");
}
}
#[test]
fn pinch_in_zooms_out() {
let mut g = GestureRecognizer::new();
let _ = g.process(tc(0, TouchPhase::Started, 0.0, 200.0));
let _ = g.process(tc(1, TouchPhase::Started, 300.0, 200.0));
let _ = g.process(tc(0, TouchPhase::Moved, 100.0, 200.0));
let events = g.process(tc(1, TouchPhase::Moved, 200.0, 200.0));
let zoom_event = events.iter().find(|e| e.is_zoom());
if let Some(InputEvent::Zoom { factor, .. }) = zoom_event {
assert!(
*factor < 1.0,
"squeezing fingers should zoom out, got factor={factor}"
);
} else {
panic!("expected zoom event: {events:?}");
}
}
}