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)]
165#[path = "tests/transform_tests.rs"]
166mod tests;