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 std::{
45    cell::{Cell, RefCell},
46    hash::{Hash, Hasher},
47    rc::Rc,
48};
49
50use cranpose_core::{MutableState, OwnedMutableState};
51use cranpose_foundation::{
52    DRAG_THRESHOLD,
53    nodes::input::gestures::{TransformGesture, TransformGestureEvent},
54};
55use cranpose_ui_graphics::{GraphicsLayer, Point, TransformOrigin};
56use web_time::Instant;
57
58use crate::modifier::{Modifier, PointerEventKind};
59
60const SCALE_EPSILON: f32 = 1e-3;
61
62const DOUBLE_TAP_TIMEOUT_MS: i64 = 300;
63
64const DOUBLE_TAP_SLOP: f32 = 100.0;
65
66fn distance(a: Point, b: Point) -> f32 {
67    let dx = b.x - a.x;
68    let dy = b.y - a.y;
69    (dx * dx + dy * dy).sqrt()
70}
71
72/// Shared zoom/pan transform state for `Modifier::zoomable`.
73///
74/// Cloning shares the same underlying state (like `ScrollState`).
75#[derive(Clone, Copy)]
76pub struct ZoomState {
77    inner: MutableState<Rc<ZoomStateInner>>,
78}
79
80struct ZoomStateInner {
81    scale: OwnedMutableState<f32>,
82    offset_x: OwnedMutableState<f32>,
83    offset_y: OwnedMutableState<f32>,
84    min_scale: Cell<f32>,
85    max_scale: Cell<f32>,
86}
87
88impl Default for ZoomState {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl ZoomState {
95    /// Creates a state at scale 1.0 with a 1.0..=8.0 zoom range.
96    pub fn new() -> Self {
97        Self::with_scale_range(1.0, 8.0)
98    }
99
100    /// Creates a state at scale 1.0 clamping zoom to `min_scale..=max_scale`.
101    pub fn with_scale_range(min_scale: f32, max_scale: f32) -> Self {
102        assert!(
103            min_scale > 0.0 && max_scale >= min_scale,
104            "invalid zoom range {min_scale}..{max_scale}"
105        );
106        let runtime = cranpose_core::current_runtime_handle()
107            .expect("ZoomState::with_scale_range requires an active runtime");
108        Self {
109            inner: MutableState::with_runtime(
110                Rc::new(ZoomStateInner {
111                    scale: OwnedMutableState::with_runtime(
112                        1.0f32.clamp(min_scale, max_scale),
113                        runtime.clone(),
114                    ),
115                    offset_x: OwnedMutableState::with_runtime(0.0f32, runtime.clone()),
116                    offset_y: OwnedMutableState::with_runtime(0.0f32, runtime.clone()),
117                    min_scale: Cell::new(min_scale),
118                    max_scale: Cell::new(max_scale),
119                }),
120                runtime,
121            ),
122        }
123    }
124
125    fn inner(&self) -> Rc<ZoomStateInner> {
126        self.inner.get_non_reactive()
127    }
128
129    /// Stable identity of this state (shared across clones).
130    pub fn id(&self) -> u64 {
131        let mut hasher = cranpose_core::hash::default::new();
132        self.inner.runtime_state_id().hash(&mut hasher);
133        hasher.finish()
134    }
135
136    /// Current scale (reactive — subscribes the caller to changes).
137    pub fn scale(&self) -> f32 {
138        self.inner().scale.with(|s| *s)
139    }
140
141    /// Current scale without snapshot subscription.
142    pub fn scale_non_reactive(&self) -> f32 {
143        self.inner().scale.get_non_reactive()
144    }
145
146    /// Current pan offset in dp (reactive).
147    pub fn offset(&self) -> Point {
148        let inner = self.inner();
149        Point {
150            x: inner.offset_x.with(|v| *v),
151            y: inner.offset_y.with(|v| *v),
152        }
153    }
154
155    /// Current pan offset without snapshot subscription.
156    pub fn offset_non_reactive(&self) -> Point {
157        let inner = self.inner();
158        Point {
159            x: inner.offset_x.get_non_reactive(),
160            y: inner.offset_y.get_non_reactive(),
161        }
162    }
163
164    /// Returns the smallest scale this state allows.
165    pub fn min_scale(&self) -> f32 {
166        self.inner().min_scale.get()
167    }
168
169    /// Returns the largest scale this state allows.
170    pub fn max_scale(&self) -> f32 {
171        self.inner().max_scale.get()
172    }
173
174    /// Whether the content is currently transformed away from identity.
175    pub fn is_transformed(&self) -> bool {
176        let offset = self.offset_non_reactive();
177        (self.scale_non_reactive() - 1.0).abs() > SCALE_EPSILON
178            || offset.x != 0.0
179            || offset.y != 0.0
180    }
181
182    /// Whether the content is currently zoomed in beyond identity.
183    ///
184    /// Pan gestures only apply while this is `true`: an image at (or below)
185    /// its natural size has nothing to pan, and drags over it belong to
186    /// enclosing scrollables.
187    pub fn is_zoomed_in(&self) -> bool {
188        self.scale_non_reactive() > 1.0 + SCALE_EPSILON
189    }
190
191    /// Sets the scale directly (clamped to the configured range).
192    pub fn set_scale(&self, scale: f32) {
193        let clamped = scale.clamp(self.min_scale(), self.max_scale());
194        self.inner().scale.set(clamped);
195    }
196
197    /// Sets the pan offset directly.
198    pub fn set_offset(&self, offset: Point) {
199        let inner = self.inner();
200        inner.offset_x.set(offset.x);
201        inner.offset_y.set(offset.y);
202    }
203
204    /// Resets to identity (scale clamped into range, zero offset).
205    pub fn reset(&self) {
206        self.set_scale(1.0);
207        self.set_offset(Point { x: 0.0, y: 0.0 });
208    }
209
210    /// Applies one gesture step: zoom by `zoom` about `centroid`, then pan.
211    ///
212    /// `centroid` and `pan` are in the element's display frame; for finger
213    /// gestures the anchor must be the centroid of the pointers BEFORE the
214    /// step (as reported by `TransformGestureEvent::Transform`). With the
215    /// top-left-origin layer produced by [`ZoomState::layer`], a content
216    /// point `c` renders at `p = c * scale + offset`; this update applies
217    /// `p' = zoom * (p - centroid) + centroid + pan`, which keeps the
218    /// content glued to the fingers.
219    ///
220    /// Pan is inert while not zoomed in: whenever the resulting scale is at
221    /// (or below) identity the offset is clamped back to zero, so a pinch
222    /// that zooms out to `scale <= 1` — or a pure centroid pan at identity —
223    /// never leaves the content stranded at a stray offset.
224    pub fn apply_transform(&self, centroid: Point, pan: Point, zoom: f32) {
225        let old_scale = self.scale_non_reactive();
226        let new_scale = (old_scale * zoom).clamp(self.min_scale(), self.max_scale());
227        let effective_zoom = new_scale / old_scale;
228        let old_offset = self.offset_non_reactive();
229
230        let new_offset = if new_scale <= 1.0 + SCALE_EPSILON {
231            Point { x: 0.0, y: 0.0 }
232        } else {
233            Point {
234                x: centroid.x - (centroid.x - old_offset.x) * effective_zoom + pan.x,
235                y: centroid.y - (centroid.y - old_offset.y) * effective_zoom + pan.y,
236            }
237        };
238
239        if new_scale != old_scale {
240            self.inner().scale.set(new_scale);
241        }
242        if new_offset != old_offset {
243            self.set_offset(new_offset);
244        }
245    }
246
247    /// Builds the [`GraphicsLayer`] rendering this transform.
248    ///
249    /// Reads the state reactively, so it is meant for the lazy
250    /// `Modifier::graphics_layer(move || state.layer())` form.
251    pub fn layer(&self) -> GraphicsLayer {
252        let scale = self.scale();
253        let offset = self.offset();
254        GraphicsLayer {
255            scale_x: scale,
256            scale_y: scale,
257            translation_x: offset.x,
258            translation_y: offset.y,
259            transform_origin: TransformOrigin::new(0.0, 0.0),
260            ..Default::default()
261        }
262    }
263}
264
265struct ZoomGestureState {
266    tracker: TransformGesture,
267    active: bool,
268    travel: f32,
269    tap_down: Option<Point>,
270    last_tap: Option<(i64, Point)>,
271    fallback_epoch: Instant,
272}
273
274impl Default for ZoomGestureState {
275    fn default() -> Self {
276        Self {
277            tracker: TransformGesture::default(),
278            active: false,
279            travel: 0.0,
280            tap_down: None,
281            last_tap: None,
282            fallback_epoch: Instant::now(),
283        }
284    }
285}
286
287impl ZoomGestureState {
288    fn timestamp_ms(&self, time_ms: Option<i64>) -> i64 {
289        time_ms.unwrap_or_else(|| self.fallback_epoch.elapsed().as_millis() as i64)
290    }
291
292    fn abandon_tap(&mut self) {
293        self.tap_down = None;
294        self.last_tap = None;
295    }
296}
297
298impl Modifier {
299    /// Makes the element respond to transform gestures, updating `state`.
300    ///
301    /// Recognizes two-finger pinch/pan (touch), one-finger pan while the
302    /// content is zoomed in (`scale > 1`), a double-tap that resets a
303    /// transformed state to identity, and [`PointerEventKind::Zoom`] steps
304    /// (desktop ctrl+wheel, browser pinch). Pan — including the pinch
305    /// centroid — is inert while `scale <= 1`. Rendering is the app's
306    /// choice — typically `.graphics_layer(move || state.layer())` on the
307    /// same or a child element.
308    pub fn zoomable(self, state: ZoomState) -> Self {
309        let gesture_state = Rc::new(RefCell::new(ZoomGestureState::default()));
310        let key = state.id();
311
312        self.pointer_input(key, move |scope| {
313            let gesture_state = gesture_state.clone();
314
315            async move {
316                scope
317                    .await_pointer_event_scope(|await_scope| async move {
318                        loop {
319                            let event = await_scope.await_pointer_event().await;
320
321                            match event.kind {
322                                PointerEventKind::Zoom => {
323                                    if !event.is_consumed() && event.zoom_delta != 1.0 {
324                                        state.apply_transform(
325                                            event.global_position,
326                                            Point { x: 0.0, y: 0.0 },
327                                            event.zoom_delta,
328                                        );
329                                        event.consume();
330                                    }
331                                }
332                                PointerEventKind::Cancel => {
333                                    let mut gs = gesture_state.borrow_mut();
334                                    gs.tracker.reset();
335                                    gs.active = false;
336                                    gs.travel = 0.0;
337                                    gs.abandon_tap();
338                                }
339                                PointerEventKind::Down
340                                | PointerEventKind::Move
341                                | PointerEventKind::Up => {
342                                    let mut gs = gesture_state.borrow_mut();
343
344                                    if event.is_consumed() {
345                                        gs.tracker.reset();
346                                        gs.active = false;
347                                        gs.travel = 0.0;
348                                        gs.abandon_tap();
349                                        continue;
350                                    }
351
352                                    let tracked =
353                                        event.copy_with_local_position(event.global_position);
354                                    let step = gs.tracker.handle_event(&tracked);
355
356                                    if event.kind == PointerEventKind::Down {
357                                        if gs.tracker.pointer_count() >= 2 {
358                                            gs.active = true;
359                                            gs.tap_down = None;
360                                        } else {
361                                            gs.travel = 0.0;
362                                            gs.tap_down = Some(event.global_position);
363                                        }
364                                        if event.id != 0 {
365                                            event.consume();
366                                        }
367                                        continue;
368                                    }
369
370                                    if event.kind == PointerEventKind::Up && event.id == 0 {
371                                        let now_ms = gs.timestamp_ms(event.time_ms);
372                                        let up_position = event.global_position;
373                                        let is_tap = !gs.active
374                                            && gs.tap_down.is_some_and(|down| {
375                                                distance(down, up_position) <= DRAG_THRESHOLD
376                                            });
377                                        gs.tap_down = None;
378                                        if is_tap {
379                                            let is_double_tap = gs.last_tap.is_some_and(
380                                                |(tap_ms, tap_position)| {
381                                                    now_ms.saturating_sub(tap_ms)
382                                                        <= DOUBLE_TAP_TIMEOUT_MS
383                                                        && distance(tap_position, up_position)
384                                                            <= DOUBLE_TAP_SLOP
385                                                },
386                                            );
387                                            if is_double_tap {
388                                                gs.last_tap = None;
389                                                if state.is_transformed() {
390                                                    state.reset();
391                                                    event.consume();
392                                                }
393                                            } else {
394                                                gs.last_tap = Some((now_ms, up_position));
395                                            }
396                                        } else {
397                                            gs.last_tap = None;
398                                        }
399                                    }
400
401                                    match step {
402                                        TransformGestureEvent::Transform {
403                                            pan,
404                                            zoom,
405                                            centroid,
406                                            pointer_count,
407                                        } => {
408                                            if pointer_count >= 2 {
409                                                gs.active = true;
410                                            } else if !gs.active && state.is_zoomed_in() {
411                                                gs.travel += (pan.x * pan.x + pan.y * pan.y).sqrt();
412                                                if gs.travel > DRAG_THRESHOLD {
413                                                    gs.active = true;
414                                                }
415                                            }
416
417                                            if gs.active {
418                                                state.apply_transform(centroid, pan, zoom);
419                                                event.consume();
420                                            }
421                                        }
422                                        TransformGestureEvent::Ended => {
423                                            let was_active = gs.active;
424                                            gs.active = false;
425                                            gs.travel = 0.0;
426                                            if was_active {
427                                                event.consume();
428                                            }
429                                        }
430                                        TransformGestureEvent::None => {
431                                            if event.id != 0
432                                                || (gs.active
433                                                    && matches!(
434                                                        event.kind,
435                                                        PointerEventKind::Up
436                                                            | PointerEventKind::Cancel
437                                                    ))
438                                            {
439                                                event.consume();
440                                            }
441                                        }
442                                    }
443                                }
444                                PointerEventKind::Scroll
445                                | PointerEventKind::RotaryScrollPre
446                                | PointerEventKind::RotaryScroll
447                                | PointerEventKind::Enter
448                                | PointerEventKind::Exit => {}
449                            }
450                        }
451                    })
452                    .await;
453            }
454        })
455    }
456}
457
458#[cfg(test)]
459#[path = "tests/zoom_tests.rs"]
460mod tests;