cranpose_ui/zoom.rs
1//! Pinch-to-zoom / pan state and the `Modifier::zoomable` gesture modifier.
2//!
3//! # Overview
4//! [`ZoomState`] is a pure transform model (scale + offset) mirroring the
5//! [`ScrollState`](crate::ScrollState) pattern: reactive `MutableState`
6//! internals so composables and lazy `graphics_layer` closures observe
7//! changes, plus non-reactive accessors for gesture handlers.
8//!
9//! The app renders the transform with the existing graphics-layer pipeline:
10//!
11//! ```text
12//! let zoom = ZoomState::new();
13//! Image(
14//! Modifier::empty()
15//! .fill_max_size()
16//! .zoomable(zoom.clone())
17//! .graphics_layer(move || zoom.layer()),
18//! ...
19//! );
20//! ```
21//!
22//! # Gestures
23//! - **Two-finger pinch** (Android/touch): scale about the finger centroid,
24//! plus centroid pan. Activates immediately when the second finger lands.
25//! - **One-finger pan**: activates after the drag threshold, and only while
26//! the content is zoomed in (`scale > 1`) so an unzoomed zoomable never
27//! steals drags from an enclosing scrollable.
28//! - **Double tap**: resets a transformed state back to identity.
29//! - **Ctrl+wheel / trackpad pinch** (desktop, web): discrete
30//! [`PointerEventKind::Zoom`] steps about the cursor.
31//!
32//! # Pan gating
33//! ALL pan — one-finger drags and the two-finger pinch centroid — is inert
34//! while `scale <= 1`: [`ZoomState::apply_transform`] clamps the offset back
35//! to zero whenever the resulting scale is at (or below) identity, so a pinch
36//! that zooms back out never strands the content at a stray offset.
37//!
38//! # Coordinate space
39//! Gesture math runs in window (global) coordinates, which stay stable while
40//! the element's own graphics layer is being transformed. The focal-point
41//! anchoring is therefore exact for zoomable surfaces positioned at the
42//! window origin (the fullscreen viewer case) and approximate otherwise.
43
44use crate::modifier::{Modifier, PointerEventKind};
45use cranpose_core::{ownedMutableStateOf, OwnedMutableState};
46use cranpose_foundation::nodes::input::gestures::{TransformGesture, TransformGestureEvent};
47use cranpose_foundation::DRAG_THRESHOLD;
48use cranpose_ui_graphics::{GraphicsLayer, Point, TransformOrigin};
49use std::cell::{Cell, RefCell};
50use std::rc::Rc;
51use web_time::Instant;
52
53/// Scale factors closer to 1.0 than this are treated as "not zoomed".
54const SCALE_EPSILON: f32 = 1e-3;
55
56/// Maximum time between the taps of a double-tap, in milliseconds
57/// (matches Android's `ViewConfiguration` DOUBLE_TAP_TIMEOUT).
58const DOUBLE_TAP_TIMEOUT_MS: i64 = 300;
59
60/// Maximum distance between the taps of a double-tap, in dp
61/// (matches Android's `ViewConfiguration` doubleTapSlop).
62const DOUBLE_TAP_SLOP: f32 = 100.0;
63
64fn distance(a: Point, b: Point) -> f32 {
65 let dx = b.x - a.x;
66 let dy = b.y - a.y;
67 (dx * dx + dy * dy).sqrt()
68}
69
70/// Shared zoom/pan transform state for `Modifier::zoomable`.
71///
72/// Cloning shares the same underlying state (like `ScrollState`).
73#[derive(Clone)]
74pub struct ZoomState {
75 inner: Rc<ZoomStateInner>,
76}
77
78struct ZoomStateInner {
79 /// Uniform content scale factor. Reactive so composables and lazy
80 /// graphics-layer closures re-evaluate when it changes.
81 scale: OwnedMutableState<f32>,
82 /// Content translation in dp, applied after scaling about the top-left
83 /// origin: a content point `c` is displayed at `c * scale + offset`.
84 offset_x: OwnedMutableState<f32>,
85 offset_y: OwnedMutableState<f32>,
86 min_scale: Cell<f32>,
87 max_scale: Cell<f32>,
88}
89
90impl Default for ZoomState {
91 fn default() -> Self {
92 Self::new()
93 }
94}
95
96impl ZoomState {
97 /// Creates a state at scale 1.0 with a 1.0..=8.0 zoom range.
98 pub fn new() -> Self {
99 Self::with_scale_range(1.0, 8.0)
100 }
101
102 /// Creates a state at scale 1.0 clamping zoom to `min_scale..=max_scale`.
103 pub fn with_scale_range(min_scale: f32, max_scale: f32) -> Self {
104 assert!(
105 min_scale > 0.0 && max_scale >= min_scale,
106 "invalid zoom range {min_scale}..{max_scale}"
107 );
108 Self {
109 inner: Rc::new(ZoomStateInner {
110 scale: ownedMutableStateOf(1.0f32.clamp(min_scale, max_scale)),
111 offset_x: ownedMutableStateOf(0.0f32),
112 offset_y: ownedMutableStateOf(0.0f32),
113 min_scale: Cell::new(min_scale),
114 max_scale: Cell::new(max_scale),
115 }),
116 }
117 }
118
119 /// Stable identity of this state (shared across clones).
120 pub fn id(&self) -> u64 {
121 Rc::as_ptr(&self.inner) as usize as u64
122 }
123
124 /// Current scale (reactive — subscribes the caller to changes).
125 pub fn scale(&self) -> f32 {
126 self.inner.scale.with(|s| *s)
127 }
128
129 /// Current scale without snapshot subscription.
130 pub fn scale_non_reactive(&self) -> f32 {
131 self.inner.scale.get_non_reactive()
132 }
133
134 /// Current pan offset in dp (reactive).
135 pub fn offset(&self) -> Point {
136 Point {
137 x: self.inner.offset_x.with(|v| *v),
138 y: self.inner.offset_y.with(|v| *v),
139 }
140 }
141
142 /// Current pan offset without snapshot subscription.
143 pub fn offset_non_reactive(&self) -> Point {
144 Point {
145 x: self.inner.offset_x.get_non_reactive(),
146 y: self.inner.offset_y.get_non_reactive(),
147 }
148 }
149
150 pub fn min_scale(&self) -> f32 {
151 self.inner.min_scale.get()
152 }
153
154 pub fn max_scale(&self) -> f32 {
155 self.inner.max_scale.get()
156 }
157
158 /// Whether the content is currently transformed away from identity.
159 pub fn is_transformed(&self) -> bool {
160 let offset = self.offset_non_reactive();
161 (self.scale_non_reactive() - 1.0).abs() > SCALE_EPSILON
162 || offset.x != 0.0
163 || offset.y != 0.0
164 }
165
166 /// Whether the content is currently zoomed in beyond identity.
167 ///
168 /// Pan gestures only apply while this is `true`: an image at (or below)
169 /// its natural size has nothing to pan, and drags over it belong to
170 /// enclosing scrollables.
171 pub fn is_zoomed_in(&self) -> bool {
172 self.scale_non_reactive() > 1.0 + SCALE_EPSILON
173 }
174
175 /// Sets the scale directly (clamped to the configured range).
176 pub fn set_scale(&self, scale: f32) {
177 let clamped = scale.clamp(self.min_scale(), self.max_scale());
178 self.inner.scale.set(clamped);
179 }
180
181 /// Sets the pan offset directly.
182 pub fn set_offset(&self, offset: Point) {
183 self.inner.offset_x.set(offset.x);
184 self.inner.offset_y.set(offset.y);
185 }
186
187 /// Resets to identity (scale clamped into range, zero offset).
188 pub fn reset(&self) {
189 self.set_scale(1.0);
190 self.set_offset(Point { x: 0.0, y: 0.0 });
191 }
192
193 /// Applies one gesture step: zoom by `zoom` about `centroid`, then pan.
194 ///
195 /// `centroid` and `pan` are in the element's display frame; for finger
196 /// gestures the anchor must be the centroid of the pointers BEFORE the
197 /// step (as reported by `TransformGestureEvent::Transform`). With the
198 /// top-left-origin layer produced by [`ZoomState::layer`], a content
199 /// point `c` renders at `p = c * scale + offset`; this update applies
200 /// `p' = zoom * (p - centroid) + centroid + pan`, which keeps the
201 /// content glued to the fingers.
202 ///
203 /// Pan is inert while not zoomed in: whenever the resulting scale is at
204 /// (or below) identity the offset is clamped back to zero, so a pinch
205 /// that zooms out to `scale <= 1` — or a pure centroid pan at identity —
206 /// never leaves the content stranded at a stray offset.
207 pub fn apply_transform(&self, centroid: Point, pan: Point, zoom: f32) {
208 let old_scale = self.scale_non_reactive();
209 let new_scale = (old_scale * zoom).clamp(self.min_scale(), self.max_scale());
210 let effective_zoom = new_scale / old_scale;
211 let old_offset = self.offset_non_reactive();
212
213 let new_offset = if new_scale <= 1.0 + SCALE_EPSILON {
214 // Not zoomed in: pan (finger delta AND focal-point correction)
215 // must not displace the content.
216 Point { x: 0.0, y: 0.0 }
217 } else {
218 Point {
219 x: centroid.x - (centroid.x - old_offset.x) * effective_zoom + pan.x,
220 y: centroid.y - (centroid.y - old_offset.y) * effective_zoom + pan.y,
221 }
222 };
223
224 if new_scale != old_scale {
225 self.inner.scale.set(new_scale);
226 }
227 if new_offset != old_offset {
228 self.set_offset(new_offset);
229 }
230 }
231
232 /// Builds the [`GraphicsLayer`] rendering this transform.
233 ///
234 /// Reads the state reactively, so it is meant for the lazy
235 /// `Modifier::graphics_layer(move || state.layer())` form.
236 pub fn layer(&self) -> GraphicsLayer {
237 let scale = self.scale();
238 let offset = self.offset();
239 GraphicsLayer {
240 scale_x: scale,
241 scale_y: scale,
242 translation_x: offset.x,
243 translation_y: offset.y,
244 transform_origin: TransformOrigin::new(0.0, 0.0),
245 ..Default::default()
246 }
247 }
248}
249
250/// Per-modifier gesture bookkeeping for `zoomable`.
251struct ZoomGestureState {
252 tracker: TransformGesture,
253 /// Whether the gesture crossed into actively transforming (consuming).
254 active: bool,
255 /// Accumulated single-finger travel for the drag threshold.
256 travel: f32,
257 /// Down position of a potential tap (single finger, in window coords).
258 tap_down: Option<Point>,
259 /// Time and position of the previous completed tap, for double-tap
260 /// detection.
261 last_tap: Option<(i64, Point)>,
262 /// Timestamp epoch for platforms whose events carry no input timestamps.
263 fallback_epoch: Instant,
264}
265
266impl Default for ZoomGestureState {
267 fn default() -> Self {
268 Self {
269 tracker: TransformGesture::default(),
270 active: false,
271 travel: 0.0,
272 tap_down: None,
273 last_tap: None,
274 fallback_epoch: Instant::now(),
275 }
276 }
277}
278
279impl ZoomGestureState {
280 /// The event's own timestamp when the platform provides one (Android),
281 /// falling back to delivery time (desktop mouse, web).
282 fn timestamp_ms(&self, time_ms: Option<i64>) -> i64 {
283 time_ms.unwrap_or_else(|| self.fallback_epoch.elapsed().as_millis() as i64)
284 }
285
286 /// Abandons any tap tracking (drag, pinch, cancel, foreign consumption).
287 fn abandon_tap(&mut self) {
288 self.tap_down = None;
289 self.last_tap = None;
290 }
291}
292
293impl Modifier {
294 /// Makes the element respond to transform gestures, updating `state`.
295 ///
296 /// Recognizes two-finger pinch/pan (touch), one-finger pan while the
297 /// content is zoomed in (`scale > 1`), a double-tap that resets a
298 /// transformed state to identity, and [`PointerEventKind::Zoom`] steps
299 /// (desktop ctrl+wheel, browser pinch). Pan — including the pinch
300 /// centroid — is inert while `scale <= 1`. Rendering is the app's
301 /// choice — typically `.graphics_layer(move || state.layer())` on the
302 /// same or a child element.
303 pub fn zoomable(self, state: ZoomState) -> Self {
304 let gesture_state = Rc::new(RefCell::new(ZoomGestureState::default()));
305 let key = state.id();
306
307 self.pointer_input(key, move |scope| {
308 let state = state.clone();
309 let gesture_state = gesture_state.clone();
310
311 async move {
312 scope
313 .await_pointer_event_scope(|await_scope| async move {
314 loop {
315 let event = await_scope.await_pointer_event().await;
316
317 match event.kind {
318 PointerEventKind::Zoom => {
319 if !event.is_consumed() && event.zoom_delta != 1.0 {
320 state.apply_transform(
321 event.global_position,
322 Point { x: 0.0, y: 0.0 },
323 event.zoom_delta,
324 );
325 event.consume();
326 }
327 }
328 PointerEventKind::Cancel => {
329 // Platforms cancel whole gestures, not
330 // individual pointers.
331 let mut gs = gesture_state.borrow_mut();
332 gs.tracker.reset();
333 gs.active = false;
334 gs.travel = 0.0;
335 gs.abandon_tap();
336 }
337 PointerEventKind::Down
338 | PointerEventKind::Move
339 | PointerEventKind::Up => {
340 let mut gs = gesture_state.borrow_mut();
341
342 if event.is_consumed() {
343 // Another handler owns this pointer
344 // sequence; abandon the gesture.
345 gs.tracker.reset();
346 gs.active = false;
347 gs.travel = 0.0;
348 gs.abandon_tap();
349 continue;
350 }
351
352 // Track in window coordinates: they stay
353 // stable while our own layer transform
354 // changes mid-gesture.
355 let tracked =
356 event.copy_with_local_position(event.global_position);
357 let step = gs.tracker.handle_event(&tracked);
358
359 if event.kind == PointerEventKind::Down {
360 if gs.tracker.pointer_count() >= 2 {
361 // Pinch begins: transform gestures
362 // own the sequence immediately.
363 gs.active = true;
364 // A multi-finger gesture is never
365 // a tap.
366 gs.tap_down = None;
367 } else {
368 gs.travel = 0.0;
369 gs.tap_down = Some(event.global_position);
370 }
371 // Secondary fingers are meaningless to
372 // single-pointer handlers; keep them.
373 if event.id != 0 {
374 event.consume();
375 }
376 continue;
377 }
378
379 if event.kind == PointerEventKind::Up && event.id == 0 {
380 // Double-tap resets a transformed
381 // state back to identity.
382 let now_ms = gs.timestamp_ms(event.time_ms);
383 let up_position = event.global_position;
384 let is_tap = !gs.active
385 && gs.tap_down.is_some_and(|down| {
386 distance(down, up_position) <= DRAG_THRESHOLD
387 });
388 gs.tap_down = None;
389 if is_tap {
390 let is_double_tap = gs.last_tap.is_some_and(
391 |(tap_ms, tap_position)| {
392 now_ms.saturating_sub(tap_ms)
393 <= DOUBLE_TAP_TIMEOUT_MS
394 && distance(tap_position, up_position)
395 <= DOUBLE_TAP_SLOP
396 },
397 );
398 if is_double_tap {
399 gs.last_tap = None;
400 if state.is_transformed() {
401 state.reset();
402 event.consume();
403 }
404 } else {
405 gs.last_tap = Some((now_ms, up_position));
406 }
407 } else {
408 gs.last_tap = None;
409 }
410 }
411
412 match step {
413 TransformGestureEvent::Transform {
414 pan,
415 zoom,
416 centroid,
417 pointer_count,
418 } => {
419 if pointer_count >= 2 {
420 gs.active = true;
421 } else if !gs.active && state.is_zoomed_in() {
422 // One-finger pan only grabs the
423 // gesture while zoomed in
424 // (scale > 1), so unzoomed
425 // zoomables never steal scrolls.
426 gs.travel += (pan.x * pan.x + pan.y * pan.y).sqrt();
427 if gs.travel > DRAG_THRESHOLD {
428 gs.active = true;
429 }
430 }
431
432 if gs.active {
433 state.apply_transform(centroid, pan, zoom);
434 event.consume();
435 }
436 }
437 TransformGestureEvent::Ended => {
438 let was_active = gs.active;
439 gs.active = false;
440 gs.travel = 0.0;
441 if was_active {
442 event.consume();
443 }
444 }
445 TransformGestureEvent::None => {
446 if event.id != 0
447 || (gs.active
448 && matches!(
449 event.kind,
450 PointerEventKind::Up
451 | PointerEventKind::Cancel
452 ))
453 {
454 event.consume();
455 }
456 }
457 }
458 }
459 PointerEventKind::Scroll
460 | PointerEventKind::Enter
461 | PointerEventKind::Exit => {}
462 }
463 }
464 })
465 .await;
466 }
467 })
468 }
469}
470
471#[cfg(test)]
472#[path = "tests/zoom_tests.rs"]
473mod tests;