Skip to main content

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;