use teksilo_canvas::Point;
use crate::pointer::{CancelReason, PointerId};
use super::{GestureEvent, GestureRecognizer, GestureResult, RawPointerEvent, RecognizerContext};
const MIN_SPAN: f32 = 1.0;
#[derive(Copy, Clone, Debug, PartialEq)]
struct Contact {
id: PointerId,
position: Point,
}
#[derive(Clone, Debug, Default)]
pub struct TouchPinchRecognizer {
contacts: [Option<Contact>; 2],
start_span: f32,
start_angle: f32,
last_span: f32,
cumulative_scale: f32,
cumulative_rotation: f32,
last_angle: f32,
active: bool,
}
impl TouchPinchRecognizer {
pub fn new() -> Self {
Self {
contacts: [None; 2],
start_span: 0.0,
start_angle: 0.0,
last_span: 0.0,
cumulative_scale: 1.0,
cumulative_rotation: 0.0,
last_angle: 0.0,
active: false,
}
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn contact_ids(&self) -> Vec<PointerId> {
self.contacts.iter().flatten().map(|c| c.id).collect()
}
pub fn center(&self) -> Option<Point> {
let (a, b) = self.pair()?;
Some(Point::new(
(a.position.x + b.position.x) / 2.0,
(a.position.y + b.position.y) / 2.0,
))
}
pub fn cumulative_scale(&self) -> f32 {
self.cumulative_scale
}
pub fn cumulative_rotation(&self) -> f32 {
self.cumulative_rotation
}
pub fn contact_down(&mut self, id: PointerId, position: Point) -> Option<GestureEvent> {
if self.contacts.iter().flatten().any(|c| c.id == id) {
return self.contact_moved(id, position);
}
let slot = self.contacts.iter().position(Option::is_none)?;
self.contacts[slot] = Some(Contact { id, position });
let (a, b) = self.pair()?;
let span = distance(a.position, b.position);
if span < MIN_SPAN {
return None;
}
self.start_span = span;
self.last_span = span;
self.start_angle = angle(a.position, b.position);
self.last_angle = self.start_angle;
self.cumulative_scale = 1.0;
self.cumulative_rotation = 0.0;
self.active = true;
Some(GestureEvent::PinchStarted {
center: self.center()?,
})
}
pub fn contact_moved(&mut self, id: PointerId, position: Point) -> Option<GestureEvent> {
let slot = self
.contacts
.iter()
.position(|c| c.is_some_and(|c| c.id == id))?;
self.contacts[slot] = Some(Contact { id, position });
if !self.active {
return None;
}
let (a, b) = self.pair()?;
let span = distance(a.position, b.position);
let scale_step = span / self.last_span.max(MIN_SPAN);
self.last_span = span;
if self.start_span >= MIN_SPAN {
self.cumulative_scale = span / self.start_span;
}
let now = angle(a.position, b.position);
let rotation_step = shortest_arc(now - self.last_angle);
self.cumulative_rotation += rotation_step;
self.last_angle = now;
Some(GestureEvent::PinchChanged {
center: self.center()?,
scale: scale_step,
rotation: rotation_step,
})
}
pub fn contact_up(&mut self, id: PointerId) -> Option<GestureEvent> {
let slot = self
.contacts
.iter()
.position(|c| c.is_some_and(|c| c.id == id))?;
self.contacts[slot] = None;
self.active.then(|| {
self.active = false;
GestureEvent::PinchEnded
})
}
pub fn cancel(&mut self, reason: CancelReason) -> Option<GestureEvent> {
let was_active = self.active;
self.contacts = [None; 2];
self.active = false;
was_active.then_some(GestureEvent::PinchCancelled { reason })
}
fn pair(&self) -> Option<(Contact, Contact)> {
Some((self.contacts[0]?, self.contacts[1]?))
}
}
impl GestureRecognizer for TouchPinchRecognizer {
fn process(&mut self, event: &RawPointerEvent, _cx: &RecognizerContext) -> GestureResult {
let id = event.pointer().id;
let recognized = match event {
RawPointerEvent::Down { position, .. } => self.contact_down(id, *position),
RawPointerEvent::Move { position, .. } => self.contact_moved(id, *position),
RawPointerEvent::Up { .. } => self.contact_up(id),
RawPointerEvent::Cancel { reason, .. } => self.cancel(*reason),
};
match recognized {
Some(gesture) => GestureResult::Recognized(gesture),
None => GestureResult::Pending,
}
}
fn reset(&mut self) {
*self = Self::new();
}
fn priority(&self) -> u32 {
40
}
fn wants_all_pointers(&self) -> bool {
true
}
}
fn distance(a: Point, b: Point) -> f32 {
let dx = b.x - a.x;
let dy = b.y - a.y;
(dx * dx + dy * dy).sqrt()
}
fn angle(a: Point, b: Point) -> f32 {
(b.y - a.y).atan2(b.x - a.x)
}
fn shortest_arc(mut d: f32) -> f32 {
while d > std::f32::consts::PI {
d -= std::f32::consts::TAU;
}
while d <= -std::f32::consts::PI {
d += std::f32::consts::TAU;
}
d
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pointer::{BackendDeviceKey, PointerIdAllocator};
fn ids(n: u64) -> Vec<PointerId> {
(0..n)
.map(|i| PointerIdAllocator::global().begin(BackendDeviceKey::DEFAULT, i))
.collect()
}
#[test]
fn one_contact_starts_nothing() {
let p = ids(1);
let mut pinch = TouchPinchRecognizer::new();
assert!(pinch.contact_down(p[0], Point::new(0.0, 0.0)).is_none());
assert!(!pinch.is_active());
assert!(pinch.center().is_none());
}
#[test]
fn two_contacts_start_and_the_scale_is_the_span_ratio() {
let p = ids(2);
let mut pinch = TouchPinchRecognizer::new();
assert!(pinch.contact_down(p[0], Point::new(0.0, 0.0)).is_none());
let started = pinch
.contact_down(p[1], Point::new(100.0, 0.0))
.expect("the second contact starts the pinch");
match started {
GestureEvent::PinchStarted { center } => {
assert_eq!(center, Point::new(50.0, 0.0));
}
other => panic!("expected PinchStarted, got {other:?}"),
}
let changed = pinch
.contact_moved(p[1], Point::new(200.0, 0.0))
.expect("a move on a tracked contact reports a change");
match changed {
GestureEvent::PinchChanged { scale, center, .. } => {
assert!((scale - 2.0).abs() < 1e-5, "span doubled, scale is {scale}");
assert_eq!(center, Point::new(100.0, 0.0));
}
other => panic!("expected PinchChanged, got {other:?}"),
}
let changed = pinch
.contact_moved(p[1], Point::new(300.0, 0.0))
.expect("a second move reports a second change");
match changed {
GestureEvent::PinchChanged { scale, .. } => {
assert!(
(scale - 1.5).abs() < 1e-5,
"the second sample is the step since the first (300/200 = 1.5), \
not the ratio to the start span (300/100 = 3.0); got {scale}"
);
}
other => panic!("expected PinchChanged, got {other:?}"),
}
assert!(
(pinch.cumulative_scale() - 3.0).abs() < 1e-5,
"the whole gesture spread the span threefold, got {}",
pinch.cumulative_scale()
);
}
#[test]
fn the_steps_of_a_spread_multiply_back_to_the_spread() {
let p = ids(2);
let mut pinch = TouchPinchRecognizer::new();
pinch.contact_down(p[0], Point::new(0.0, 0.0));
pinch.contact_down(p[1], Point::new(100.0, 0.0));
let mut folded = 1.0f32;
for span in [115.0f32, 132.0, 152.0, 174.0, 200.0] {
let changed = pinch
.contact_moved(p[1], Point::new(span, 0.0))
.expect("each move reports a change");
let GestureEvent::PinchChanged { scale, .. } = changed else {
panic!("expected PinchChanged, got {changed:?}");
};
folded *= scale;
}
assert!(
(folded - 2.0).abs() < 1e-4,
"folding every step in must reach the ×2 spread, got {folded}"
);
assert!(
(pinch.cumulative_scale() - 2.0).abs() < 1e-4,
"and the retained baseline agrees, got {}",
pinch.cumulative_scale()
);
}
#[test]
fn a_collapsed_span_does_not_divide_the_next_step_by_zero() {
let p = ids(2);
let mut pinch = TouchPinchRecognizer::new();
pinch.contact_down(p[0], Point::new(0.0, 0.0));
pinch.contact_down(p[1], Point::new(100.0, 0.0));
pinch.contact_moved(p[1], Point::new(0.0, 0.0));
let changed = pinch
.contact_moved(p[1], Point::new(50.0, 0.0))
.expect("the pinch is still running");
let GestureEvent::PinchChanged { scale, .. } = changed else {
panic!("expected PinchChanged, got {changed:?}");
};
assert!(
scale.is_finite() && scale > 0.0,
"a step out of a collapsed span must stay finite and positive, got {scale}"
);
}
#[test]
fn rotation_accumulates_and_unwraps_past_pi() {
let p = ids(2);
let mut pinch = TouchPinchRecognizer::new();
pinch.contact_down(p[0], Point::new(0.0, 0.0));
pinch.contact_down(p[1], Point::new(100.0, 0.0));
let mut emitted = Vec::new();
for step in 1..=8 {
let theta = std::f32::consts::FRAC_PI_4 * step as f32;
let changed = pinch
.contact_moved(p[1], Point::new(100.0 * theta.cos(), 100.0 * theta.sin()))
.expect("each move reports a change");
let GestureEvent::PinchChanged { rotation, .. } = changed else {
panic!("expected PinchChanged, got {changed:?}");
};
emitted.push(rotation);
}
for (i, step) in emitted.iter().enumerate() {
assert!(
(step - std::f32::consts::FRAC_PI_4).abs() < 1e-3,
"sample {i} is one 45° step (0.7854 rad), got {step}"
);
}
let summed = emitted.iter().sum::<f32>() / std::f32::consts::TAU;
assert!(
(summed - 1.0).abs() < 1e-3,
"the steps add up to one turn, got {summed}"
);
let turns = pinch.cumulative_rotation() / std::f32::consts::TAU;
assert!(
(turns - 1.0).abs() < 1e-3,
"a full turn should read as one turn, got {turns}"
);
}
#[test]
fn a_third_contact_is_ignored() {
let p = ids(3);
let mut pinch = TouchPinchRecognizer::new();
pinch.contact_down(p[0], Point::new(0.0, 0.0));
pinch.contact_down(p[1], Point::new(100.0, 0.0));
assert_eq!(pinch.contact_ids(), vec![p[0], p[1]]);
assert!(
pinch.contact_down(p[2], Point::new(0.0, 500.0)).is_none(),
"the third contact produces nothing"
);
assert_eq!(
pinch.contact_ids(),
vec![p[0], p[1]],
"the pinch still follows the two earliest contacts"
);
assert!(
pinch.contact_moved(p[2], Point::new(0.0, 900.0)).is_none(),
"moving the ignored contact never disturbs the pinch"
);
assert!(pinch.contact_moved(p[1], Point::new(200.0, 0.0)).is_some());
}
#[test]
fn a_contact_leaving_mid_pinch_ends_the_gesture() {
let p = ids(2);
let mut pinch = TouchPinchRecognizer::new();
pinch.contact_down(p[0], Point::new(0.0, 0.0));
pinch.contact_down(p[1], Point::new(100.0, 0.0));
assert!(pinch.is_active());
assert!(matches!(
pinch.contact_up(p[0]),
Some(GestureEvent::PinchEnded)
));
assert!(!pinch.is_active());
assert_eq!(
pinch.contact_ids(),
vec![p[1]],
"the remaining finger is kept so a new second finger restarts cleanly"
);
assert!(
pinch.contact_up(p[1]).is_none(),
"the second lift ends nothing — the gesture was already over"
);
}
#[test]
fn a_cancel_revokes_the_whole_gesture() {
let p = ids(2);
let mut pinch = TouchPinchRecognizer::new();
pinch.contact_down(p[0], Point::new(0.0, 0.0));
pinch.contact_down(p[1], Point::new(100.0, 0.0));
match pinch.cancel(CancelReason::Platform) {
Some(GestureEvent::PinchCancelled { reason }) => {
assert_eq!(reason, CancelReason::Platform);
}
other => panic!("expected PinchCancelled, got {other:?}"),
}
assert!(pinch.contact_ids().is_empty(), "both slots are cleared");
assert!(pinch.cancel(CancelReason::Platform).is_none());
}
#[test]
fn coincident_contacts_do_not_start_a_pinch() {
let p = ids(2);
let mut pinch = TouchPinchRecognizer::new();
pinch.contact_down(p[0], Point::new(10.0, 10.0));
assert!(
pinch.contact_down(p[1], Point::new(10.2, 10.0)).is_none(),
"a sub-pixel span would divide the scale by near-zero"
);
assert!(!pinch.is_active());
}
#[test]
fn the_recognizer_declares_that_it_wants_every_contact() {
let pinch = TouchPinchRecognizer::new();
assert!(pinch.wants_all_pointers());
assert!(
!pinch.competes_for_sequence(),
"a pinch is not a press claimant — it is arbitrated by contact count"
);
}
#[test]
fn shortest_arc_folds_into_the_half_open_turn() {
assert!((shortest_arc(0.0)).abs() < 1e-6);
assert!((shortest_arc(std::f32::consts::TAU)).abs() < 1e-5);
assert!(
(shortest_arc(std::f32::consts::PI + 0.1) + std::f32::consts::PI - 0.1).abs() < 1e-5
);
}
}