cranpose_foundation/nodes/input/gestures/
transform.rs1use cranpose_ui_graphics::Point;
20
21use crate::nodes::input::{PointerEvent, PointerEventKind, PointerId};
22
23const MIN_ZOOM_SPREAD: f32 = 1.0;
24
25#[derive(Clone, Copy, Debug, PartialEq)]
27pub enum TransformGestureEvent {
28 None,
30 Transform {
32 pan: Point,
34 zoom: f32,
37 centroid: Point,
43 pointer_count: usize,
45 },
46 Ended,
48}
49
50#[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 pub fn pointer_count(&self) -> usize {
63 self.pointers.len()
64 }
65
66 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 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 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}