cranpose_foundation/nodes/input/gestures/transform.rs
1//! Multi-pointer transform (pinch/pan) gesture recognition.
2//!
3//! [`TransformGesture`] tracks the positions of every active pointer and
4//! reports incremental pan and zoom steps as individual pointer samples
5//! arrive. Unlike Jetpack Compose — where each `PointerEvent` carries a full
6//! frame of all pointers — cranpose delivers one event per pointer sample, so
7//! the tracker keeps the last known position of every pointer and computes
8//! each step against that stored snapshot:
9//!
10//! - **pan** is the movement of the centroid of all active pointers,
11//! - **zoom** is the ratio of the mean pointer distance from the centroid
12//! after vs. before the sample (only meaningful with 2+ pointers).
13//!
14//! Individual steps of a two-finger pan wobble the zoom slightly (moving one
15//! finger changes the spread), but consecutive steps compose to the exact
16//! frame result: applying all `zoom` factors multiplicatively and all `pan`
17//! deltas additively reproduces the true gesture.
18
19use cranpose_ui_graphics::Point;
20
21use crate::nodes::input::{PointerEvent, PointerEventKind, PointerId};
22
23const MIN_ZOOM_SPREAD: f32 = 1.0;
24
25/// A transform step reported by [`TransformGesture::handle_event`].
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub enum TransformGestureEvent {
28 /// Nothing to report for this event.
29 None,
30 /// The tracked pointer set moved.
31 Transform {
32 /// Centroid movement since the previous sample.
33 pan: Point,
34 /// Multiplicative spread change since the previous sample
35 /// (`1.0` when fewer than two pointers are down).
36 zoom: f32,
37 /// Centroid of all active pointers BEFORE this sample, in the same
38 /// coordinates as the pointer events. This is the anchor to zoom
39 /// about: applying `display' = zoom * (display - centroid) +
40 /// centroid + pan` keeps the content glued to the fingers (it
41 /// matches Compose's `calculateCentroid(useCurrent = false)`).
42 centroid: Point,
43 /// Number of pointers taking part in the gesture.
44 pointer_count: usize,
45 },
46 /// The last tracked pointer lifted; the gesture is over.
47 Ended,
48}
49
50/// Tracks active pointers and recognizes pinch/pan transform steps.
51#[derive(Clone, Debug, Default)]
52pub struct TransformGesture {
53 pointers: Vec<(PointerId, Point)>,
54}
55
56impl TransformGesture {
57 pub fn new() -> Self {
58 Self::default()
59 }
60
61 /// Number of pointers currently tracked.
62 pub fn pointer_count(&self) -> usize {
63 self.pointers.len()
64 }
65
66 /// Centroid of the currently tracked pointers, if any.
67 pub fn centroid(&self) -> Option<Point> {
68 if self.pointers.is_empty() {
69 None
70 } else {
71 Some(centroid_of(&self.pointers))
72 }
73 }
74
75 /// Feeds one pointer event; returns the recognized transform step.
76 pub fn handle_event(&mut self, event: &PointerEvent) -> TransformGestureEvent {
77 match event.kind {
78 PointerEventKind::Down => {
79 match self.pointers.iter_mut().find(|(id, _)| *id == event.id) {
80 Some(entry) => entry.1 = event.position,
81 None => self.pointers.push((event.id, event.position)),
82 }
83 TransformGestureEvent::None
84 }
85 PointerEventKind::Move => {
86 let Some(index) = self.pointers.iter().position(|(id, _)| *id == event.id) else {
87 return TransformGestureEvent::None;
88 };
89
90 let old_centroid = centroid_of(&self.pointers);
91 let old_spread = mean_spread(&self.pointers, old_centroid);
92
93 self.pointers[index].1 = event.position;
94
95 let new_centroid = centroid_of(&self.pointers);
96 let new_spread = mean_spread(&self.pointers, new_centroid);
97
98 let pan = Point {
99 x: new_centroid.x - old_centroid.x,
100 y: new_centroid.y - old_centroid.y,
101 };
102 let zoom = if self.pointers.len() >= 2
103 && old_spread > MIN_ZOOM_SPREAD
104 && new_spread > MIN_ZOOM_SPREAD
105 {
106 new_spread / old_spread
107 } else {
108 1.0
109 };
110
111 if pan.x == 0.0 && pan.y == 0.0 && zoom == 1.0 {
112 TransformGestureEvent::None
113 } else {
114 TransformGestureEvent::Transform {
115 pan,
116 zoom,
117 centroid: old_centroid,
118 pointer_count: self.pointers.len(),
119 }
120 }
121 }
122 PointerEventKind::Up | PointerEventKind::Cancel => {
123 self.pointers.retain(|(id, _)| *id != event.id);
124 if self.pointers.is_empty() {
125 TransformGestureEvent::Ended
126 } else {
127 TransformGestureEvent::None
128 }
129 }
130 _ => TransformGestureEvent::None,
131 }
132 }
133
134 /// Forgets all tracked pointers.
135 pub fn reset(&mut self) {
136 self.pointers.clear();
137 }
138}
139
140fn centroid_of(pointers: &[(PointerId, Point)]) -> Point {
141 let count = pointers.len() as f32;
142 let mut sum = Point { x: 0.0, y: 0.0 };
143 for (_, position) in pointers {
144 sum.x += position.x;
145 sum.y += position.y;
146 }
147 Point {
148 x: sum.x / count,
149 y: sum.y / count,
150 }
151}
152
153fn mean_spread(pointers: &[(PointerId, Point)], centroid: Point) -> f32 {
154 let count = pointers.len() as f32;
155 let mut sum = 0.0;
156 for (_, position) in pointers {
157 let dx = position.x - centroid.x;
158 let dy = position.y - centroid.y;
159 sum += (dx * dx + dy * dy).sqrt();
160 }
161 sum / count
162}
163
164#[cfg(test)]
165#[path = "tests/transform_tests.rs"]
166mod tests;