Skip to main content

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 crate::nodes::input::{PointerEvent, PointerEventKind, PointerId};
20use cranpose_ui_graphics::Point;
21
22/// Minimum mean pointer spread (in dp) for a zoom ratio to be trusted.
23/// Below this the fingers are effectively on top of each other and the
24/// ratio explodes numerically.
25const MIN_ZOOM_SPREAD: f32 = 1.0;
26
27/// A transform step reported by [`TransformGesture::handle_event`].
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub enum TransformGestureEvent {
30    /// Nothing to report for this event.
31    None,
32    /// The tracked pointer set moved.
33    Transform {
34        /// Centroid movement since the previous sample.
35        pan: Point,
36        /// Multiplicative spread change since the previous sample
37        /// (`1.0` when fewer than two pointers are down).
38        zoom: f32,
39        /// Centroid of all active pointers BEFORE this sample, in the same
40        /// coordinates as the pointer events. This is the anchor to zoom
41        /// about: applying `display' = zoom * (display - centroid) +
42        /// centroid + pan` keeps the content glued to the fingers (it
43        /// matches Compose's `calculateCentroid(useCurrent = false)`).
44        centroid: Point,
45        /// Number of pointers taking part in the gesture.
46        pointer_count: usize,
47    },
48    /// The last tracked pointer lifted; the gesture is over.
49    Ended,
50}
51
52/// Tracks active pointers and recognizes pinch/pan transform steps.
53#[derive(Clone, Debug, Default)]
54pub struct TransformGesture {
55    pointers: Vec<(PointerId, Point)>,
56}
57
58impl TransformGesture {
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Number of pointers currently tracked.
64    pub fn pointer_count(&self) -> usize {
65        self.pointers.len()
66    }
67
68    /// Centroid of the currently tracked pointers, if any.
69    pub fn centroid(&self) -> Option<Point> {
70        if self.pointers.is_empty() {
71            None
72        } else {
73            Some(centroid_of(&self.pointers))
74        }
75    }
76
77    /// Feeds one pointer event; returns the recognized transform step.
78    pub fn handle_event(&mut self, event: &PointerEvent) -> TransformGestureEvent {
79        match event.kind {
80            PointerEventKind::Down => {
81                match self.pointers.iter_mut().find(|(id, _)| *id == event.id) {
82                    Some(entry) => entry.1 = event.position,
83                    None => self.pointers.push((event.id, event.position)),
84                }
85                TransformGestureEvent::None
86            }
87            PointerEventKind::Move => {
88                let Some(index) = self.pointers.iter().position(|(id, _)| *id == event.id) else {
89                    return TransformGestureEvent::None;
90                };
91
92                let old_centroid = centroid_of(&self.pointers);
93                let old_spread = mean_spread(&self.pointers, old_centroid);
94
95                self.pointers[index].1 = event.position;
96
97                let new_centroid = centroid_of(&self.pointers);
98                let new_spread = mean_spread(&self.pointers, new_centroid);
99
100                let pan = Point {
101                    x: new_centroid.x - old_centroid.x,
102                    y: new_centroid.y - old_centroid.y,
103                };
104                let zoom = if self.pointers.len() >= 2
105                    && old_spread > MIN_ZOOM_SPREAD
106                    && new_spread > MIN_ZOOM_SPREAD
107                {
108                    new_spread / old_spread
109                } else {
110                    1.0
111                };
112
113                if pan.x == 0.0 && pan.y == 0.0 && zoom == 1.0 {
114                    TransformGestureEvent::None
115                } else {
116                    TransformGestureEvent::Transform {
117                        pan,
118                        zoom,
119                        centroid: old_centroid,
120                        pointer_count: self.pointers.len(),
121                    }
122                }
123            }
124            PointerEventKind::Up | PointerEventKind::Cancel => {
125                self.pointers.retain(|(id, _)| *id != event.id);
126                if self.pointers.is_empty() {
127                    TransformGestureEvent::Ended
128                } else {
129                    TransformGestureEvent::None
130                }
131            }
132            _ => TransformGestureEvent::None,
133        }
134    }
135
136    /// Forgets all tracked pointers.
137    pub fn reset(&mut self) {
138        self.pointers.clear();
139    }
140}
141
142fn centroid_of(pointers: &[(PointerId, Point)]) -> Point {
143    let count = pointers.len() as f32;
144    let mut sum = Point { x: 0.0, y: 0.0 };
145    for (_, position) in pointers {
146        sum.x += position.x;
147        sum.y += position.y;
148    }
149    Point {
150        x: sum.x / count,
151        y: sum.y / count,
152    }
153}
154
155fn mean_spread(pointers: &[(PointerId, Point)], centroid: Point) -> f32 {
156    let count = pointers.len() as f32;
157    let mut sum = 0.0;
158    for (_, position) in pointers {
159        let dx = position.x - centroid.x;
160        let dy = position.y - centroid.y;
161        sum += (dx * dx + dy * dy).sqrt();
162    }
163    sum / count
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    fn event(kind: PointerEventKind, id: u64, x: f32, y: f32) -> PointerEvent {
171        let mut event = PointerEvent::new(kind, Point { x, y }, Point { x, y });
172        event.id = id;
173        event
174    }
175
176    #[test]
177    fn pinch_out_reports_zoom_and_focal_centroid() {
178        let mut gesture = TransformGesture::new();
179        gesture.handle_event(&event(PointerEventKind::Down, 0, 100.0, 100.0));
180        gesture.handle_event(&event(PointerEventKind::Down, 1, 200.0, 100.0));
181
182        // Second finger moves from (200,100) to (300,100): spread 50 -> 100.
183        let step = gesture.handle_event(&event(PointerEventKind::Move, 1, 300.0, 100.0));
184        match step {
185            TransformGestureEvent::Transform {
186                pan,
187                zoom,
188                centroid,
189                pointer_count,
190            } => {
191                assert!((zoom - 2.0).abs() < 1e-5, "spread doubled, got zoom={zoom}");
192                assert!((pan.x - 50.0).abs() < 1e-5 && pan.y.abs() < 1e-5, "{pan:?}");
193                // Anchor centroid: pointer positions BEFORE the sample.
194                assert_eq!(centroid, Point { x: 150.0, y: 100.0 });
195                assert_eq!(pointer_count, 2);
196            }
197            other => panic!("expected Transform, got {other:?}"),
198        }
199    }
200
201    #[test]
202    fn pinch_in_reports_zoom_below_one() {
203        let mut gesture = TransformGesture::new();
204        gesture.handle_event(&event(PointerEventKind::Down, 0, 0.0, 0.0));
205        gesture.handle_event(&event(PointerEventKind::Down, 1, 0.0, 200.0));
206
207        let step = gesture.handle_event(&event(PointerEventKind::Move, 1, 0.0, 100.0));
208        match step {
209            TransformGestureEvent::Transform { zoom, .. } => {
210                assert!((zoom - 0.5).abs() < 1e-5, "spread halved, got zoom={zoom}");
211            }
212            other => panic!("expected Transform, got {other:?}"),
213        }
214    }
215
216    #[test]
217    fn two_finger_pan_steps_compose_to_pure_pan() {
218        let mut gesture = TransformGesture::new();
219        gesture.handle_event(&event(PointerEventKind::Down, 0, 100.0, 100.0));
220        gesture.handle_event(&event(PointerEventKind::Down, 1, 200.0, 100.0));
221
222        // Both fingers move +10 in x, one sample after the other.
223        let mut total_pan = Point { x: 0.0, y: 0.0 };
224        let mut total_zoom = 1.0;
225        for step in [
226            gesture.handle_event(&event(PointerEventKind::Move, 0, 110.0, 100.0)),
227            gesture.handle_event(&event(PointerEventKind::Move, 1, 210.0, 100.0)),
228        ] {
229            if let TransformGestureEvent::Transform { pan, zoom, .. } = step {
230                total_pan.x += pan.x;
231                total_pan.y += pan.y;
232                total_zoom *= zoom;
233            }
234        }
235
236        assert!(
237            (total_pan.x - 10.0).abs() < 1e-4 && total_pan.y.abs() < 1e-4,
238            "steps must compose to the +10 centroid pan, got {total_pan:?}"
239        );
240        assert!(
241            (total_zoom - 1.0).abs() < 1e-4,
242            "pure pan must compose to zoom 1.0, got {total_zoom}"
243        );
244    }
245
246    #[test]
247    fn single_finger_move_is_pan_only() {
248        let mut gesture = TransformGesture::new();
249        gesture.handle_event(&event(PointerEventKind::Down, 0, 50.0, 50.0));
250
251        let step = gesture.handle_event(&event(PointerEventKind::Move, 0, 62.0, 45.0));
252        assert_eq!(
253            step,
254            TransformGestureEvent::Transform {
255                pan: Point { x: 12.0, y: -5.0 },
256                zoom: 1.0,
257                centroid: Point { x: 50.0, y: 50.0 },
258                pointer_count: 1,
259            }
260        );
261    }
262
263    #[test]
264    fn untracked_pointer_moves_are_ignored() {
265        let mut gesture = TransformGesture::new();
266        gesture.handle_event(&event(PointerEventKind::Down, 0, 50.0, 50.0));
267
268        let step = gesture.handle_event(&event(PointerEventKind::Move, 7, 500.0, 500.0));
269        assert_eq!(step, TransformGestureEvent::None);
270    }
271
272    #[test]
273    fn gesture_ends_when_last_pointer_lifts() {
274        let mut gesture = TransformGesture::new();
275        gesture.handle_event(&event(PointerEventKind::Down, 0, 0.0, 0.0));
276        gesture.handle_event(&event(PointerEventKind::Down, 1, 100.0, 0.0));
277
278        assert_eq!(
279            gesture.handle_event(&event(PointerEventKind::Up, 1, 100.0, 0.0)),
280            TransformGestureEvent::None
281        );
282        assert_eq!(gesture.pointer_count(), 1);
283        assert_eq!(
284            gesture.handle_event(&event(PointerEventKind::Up, 0, 0.0, 0.0)),
285            TransformGestureEvent::Ended
286        );
287        assert_eq!(gesture.pointer_count(), 0);
288    }
289
290    #[test]
291    fn lifting_one_finger_does_not_jump_the_pan() {
292        let mut gesture = TransformGesture::new();
293        gesture.handle_event(&event(PointerEventKind::Down, 0, 0.0, 0.0));
294        gesture.handle_event(&event(PointerEventKind::Down, 1, 100.0, 0.0));
295        gesture.handle_event(&event(PointerEventKind::Up, 1, 100.0, 0.0));
296
297        // The next move of the remaining finger reports only its own delta,
298        // not the centroid jump caused by the lifted finger.
299        let step = gesture.handle_event(&event(PointerEventKind::Move, 0, 5.0, 0.0));
300        match step {
301            TransformGestureEvent::Transform { pan, zoom, .. } => {
302                assert!((pan.x - 5.0).abs() < 1e-5 && pan.y.abs() < 1e-5, "{pan:?}");
303                assert_eq!(zoom, 1.0);
304            }
305            other => panic!("expected Transform, got {other:?}"),
306        }
307    }
308}