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 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)]
165mod tests {
166    use super::*;
167
168    fn event(kind: PointerEventKind, id: u64, x: f32, y: f32) -> PointerEvent {
169        let mut event = PointerEvent::new(kind, Point { x, y }, Point { x, y });
170        event.id = id;
171        event
172    }
173
174    #[test]
175    fn pinch_out_reports_zoom_and_focal_centroid() {
176        let mut gesture = TransformGesture::new();
177        gesture.handle_event(&event(PointerEventKind::Down, 0, 100.0, 100.0));
178        gesture.handle_event(&event(PointerEventKind::Down, 1, 200.0, 100.0));
179
180        let step = gesture.handle_event(&event(PointerEventKind::Move, 1, 300.0, 100.0));
181        match step {
182            TransformGestureEvent::Transform {
183                pan,
184                zoom,
185                centroid,
186                pointer_count,
187            } => {
188                assert!((zoom - 2.0).abs() < 1e-5, "spread doubled, got zoom={zoom}");
189                assert!((pan.x - 50.0).abs() < 1e-5 && pan.y.abs() < 1e-5, "{pan:?}");
190                assert_eq!(centroid, Point { x: 150.0, y: 100.0 });
191                assert_eq!(pointer_count, 2);
192            }
193            other => panic!("expected Transform, got {other:?}"),
194        }
195    }
196
197    #[test]
198    fn pinch_in_reports_zoom_below_one() {
199        let mut gesture = TransformGesture::new();
200        gesture.handle_event(&event(PointerEventKind::Down, 0, 0.0, 0.0));
201        gesture.handle_event(&event(PointerEventKind::Down, 1, 0.0, 200.0));
202
203        let step = gesture.handle_event(&event(PointerEventKind::Move, 1, 0.0, 100.0));
204        match step {
205            TransformGestureEvent::Transform { zoom, .. } => {
206                assert!((zoom - 0.5).abs() < 1e-5, "spread halved, got zoom={zoom}");
207            }
208            other => panic!("expected Transform, got {other:?}"),
209        }
210    }
211
212    #[test]
213    fn two_finger_pan_steps_compose_to_pure_pan() {
214        let mut gesture = TransformGesture::new();
215        gesture.handle_event(&event(PointerEventKind::Down, 0, 100.0, 100.0));
216        gesture.handle_event(&event(PointerEventKind::Down, 1, 200.0, 100.0));
217
218        let mut total_pan = Point { x: 0.0, y: 0.0 };
219        let mut total_zoom = 1.0;
220        for step in [
221            gesture.handle_event(&event(PointerEventKind::Move, 0, 110.0, 100.0)),
222            gesture.handle_event(&event(PointerEventKind::Move, 1, 210.0, 100.0)),
223        ] {
224            if let TransformGestureEvent::Transform { pan, zoom, .. } = step {
225                total_pan.x += pan.x;
226                total_pan.y += pan.y;
227                total_zoom *= zoom;
228            }
229        }
230
231        assert!(
232            (total_pan.x - 10.0).abs() < 1e-4 && total_pan.y.abs() < 1e-4,
233            "steps must compose to the +10 centroid pan, got {total_pan:?}"
234        );
235        assert!(
236            (total_zoom - 1.0).abs() < 1e-4,
237            "pure pan must compose to zoom 1.0, got {total_zoom}"
238        );
239    }
240
241    #[test]
242    fn single_finger_move_is_pan_only() {
243        let mut gesture = TransformGesture::new();
244        gesture.handle_event(&event(PointerEventKind::Down, 0, 50.0, 50.0));
245
246        let step = gesture.handle_event(&event(PointerEventKind::Move, 0, 62.0, 45.0));
247        assert_eq!(
248            step,
249            TransformGestureEvent::Transform {
250                pan: Point { x: 12.0, y: -5.0 },
251                zoom: 1.0,
252                centroid: Point { x: 50.0, y: 50.0 },
253                pointer_count: 1,
254            }
255        );
256    }
257
258    #[test]
259    fn untracked_pointer_moves_are_ignored() {
260        let mut gesture = TransformGesture::new();
261        gesture.handle_event(&event(PointerEventKind::Down, 0, 50.0, 50.0));
262
263        let step = gesture.handle_event(&event(PointerEventKind::Move, 7, 500.0, 500.0));
264        assert_eq!(step, TransformGestureEvent::None);
265    }
266
267    #[test]
268    fn gesture_ends_when_last_pointer_lifts() {
269        let mut gesture = TransformGesture::new();
270        gesture.handle_event(&event(PointerEventKind::Down, 0, 0.0, 0.0));
271        gesture.handle_event(&event(PointerEventKind::Down, 1, 100.0, 0.0));
272
273        assert_eq!(
274            gesture.handle_event(&event(PointerEventKind::Up, 1, 100.0, 0.0)),
275            TransformGestureEvent::None
276        );
277        assert_eq!(gesture.pointer_count(), 1);
278        assert_eq!(
279            gesture.handle_event(&event(PointerEventKind::Up, 0, 0.0, 0.0)),
280            TransformGestureEvent::Ended
281        );
282        assert_eq!(gesture.pointer_count(), 0);
283    }
284
285    #[test]
286    fn lifting_one_finger_does_not_jump_the_pan() {
287        let mut gesture = TransformGesture::new();
288        gesture.handle_event(&event(PointerEventKind::Down, 0, 0.0, 0.0));
289        gesture.handle_event(&event(PointerEventKind::Down, 1, 100.0, 0.0));
290        gesture.handle_event(&event(PointerEventKind::Up, 1, 100.0, 0.0));
291
292        let step = gesture.handle_event(&event(PointerEventKind::Move, 0, 5.0, 0.0));
293        match step {
294            TransformGestureEvent::Transform { pan, zoom, .. } => {
295                assert!((pan.x - 5.0).abs() < 1e-5 && pan.y.abs() < 1e-5, "{pan:?}");
296                assert_eq!(zoom, 1.0);
297            }
298            other => panic!("expected Transform, got {other:?}"),
299        }
300    }
301}