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 transformed (`scale != 1` or panned) so an untransformed
27//!   zoomable never steals drags from an enclosing scrollable.
28//! - **Ctrl+wheel / trackpad pinch** (desktop, web): discrete
29//!   [`PointerEventKind::Zoom`] steps about the cursor.
30//!
31//! # Coordinate space
32//! Gesture math runs in window (global) coordinates, which stay stable while
33//! the element's own graphics layer is being transformed. The focal-point
34//! anchoring is therefore exact for zoomable surfaces positioned at the
35//! window origin (the fullscreen viewer case) and approximate otherwise.
36
37use crate::modifier::{Modifier, PointerEventKind};
38use cranpose_core::{ownedMutableStateOf, OwnedMutableState};
39use cranpose_foundation::nodes::input::gestures::{TransformGesture, TransformGestureEvent};
40use cranpose_foundation::DRAG_THRESHOLD;
41use cranpose_ui_graphics::{GraphicsLayer, Point, TransformOrigin};
42use std::cell::{Cell, RefCell};
43use std::rc::Rc;
44
45/// Scale factors closer to 1.0 than this are treated as "not zoomed".
46const SCALE_EPSILON: f32 = 1e-3;
47
48/// Shared zoom/pan transform state for `Modifier::zoomable`.
49///
50/// Cloning shares the same underlying state (like `ScrollState`).
51#[derive(Clone)]
52pub struct ZoomState {
53    inner: Rc<ZoomStateInner>,
54}
55
56struct ZoomStateInner {
57    /// Uniform content scale factor. Reactive so composables and lazy
58    /// graphics-layer closures re-evaluate when it changes.
59    scale: OwnedMutableState<f32>,
60    /// Content translation in dp, applied after scaling about the top-left
61    /// origin: a content point `c` is displayed at `c * scale + offset`.
62    offset_x: OwnedMutableState<f32>,
63    offset_y: OwnedMutableState<f32>,
64    min_scale: Cell<f32>,
65    max_scale: Cell<f32>,
66}
67
68impl Default for ZoomState {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl ZoomState {
75    /// Creates a state at scale 1.0 with a 1.0..=8.0 zoom range.
76    pub fn new() -> Self {
77        Self::with_scale_range(1.0, 8.0)
78    }
79
80    /// Creates a state at scale 1.0 clamping zoom to `min_scale..=max_scale`.
81    pub fn with_scale_range(min_scale: f32, max_scale: f32) -> Self {
82        assert!(
83            min_scale > 0.0 && max_scale >= min_scale,
84            "invalid zoom range {min_scale}..{max_scale}"
85        );
86        Self {
87            inner: Rc::new(ZoomStateInner {
88                scale: ownedMutableStateOf(1.0f32.clamp(min_scale, max_scale)),
89                offset_x: ownedMutableStateOf(0.0f32),
90                offset_y: ownedMutableStateOf(0.0f32),
91                min_scale: Cell::new(min_scale),
92                max_scale: Cell::new(max_scale),
93            }),
94        }
95    }
96
97    /// Stable identity of this state (shared across clones).
98    pub fn id(&self) -> u64 {
99        Rc::as_ptr(&self.inner) as usize as u64
100    }
101
102    /// Current scale (reactive — subscribes the caller to changes).
103    pub fn scale(&self) -> f32 {
104        self.inner.scale.with(|s| *s)
105    }
106
107    /// Current scale without snapshot subscription.
108    pub fn scale_non_reactive(&self) -> f32 {
109        self.inner.scale.get_non_reactive()
110    }
111
112    /// Current pan offset in dp (reactive).
113    pub fn offset(&self) -> Point {
114        Point {
115            x: self.inner.offset_x.with(|v| *v),
116            y: self.inner.offset_y.with(|v| *v),
117        }
118    }
119
120    /// Current pan offset without snapshot subscription.
121    pub fn offset_non_reactive(&self) -> Point {
122        Point {
123            x: self.inner.offset_x.get_non_reactive(),
124            y: self.inner.offset_y.get_non_reactive(),
125        }
126    }
127
128    pub fn min_scale(&self) -> f32 {
129        self.inner.min_scale.get()
130    }
131
132    pub fn max_scale(&self) -> f32 {
133        self.inner.max_scale.get()
134    }
135
136    /// Whether the content is currently transformed away from identity.
137    pub fn is_transformed(&self) -> bool {
138        let offset = self.offset_non_reactive();
139        (self.scale_non_reactive() - 1.0).abs() > SCALE_EPSILON
140            || offset.x != 0.0
141            || offset.y != 0.0
142    }
143
144    /// Sets the scale directly (clamped to the configured range).
145    pub fn set_scale(&self, scale: f32) {
146        let clamped = scale.clamp(self.min_scale(), self.max_scale());
147        self.inner.scale.set(clamped);
148    }
149
150    /// Sets the pan offset directly.
151    pub fn set_offset(&self, offset: Point) {
152        self.inner.offset_x.set(offset.x);
153        self.inner.offset_y.set(offset.y);
154    }
155
156    /// Resets to identity (scale clamped into range, zero offset).
157    pub fn reset(&self) {
158        self.set_scale(1.0);
159        self.set_offset(Point { x: 0.0, y: 0.0 });
160    }
161
162    /// Applies one gesture step: zoom by `zoom` about `centroid`, then pan.
163    ///
164    /// `centroid` and `pan` are in the element's display frame; for finger
165    /// gestures the anchor must be the centroid of the pointers BEFORE the
166    /// step (as reported by `TransformGestureEvent::Transform`). With the
167    /// top-left-origin layer produced by [`ZoomState::layer`], a content
168    /// point `c` renders at `p = c * scale + offset`; this update applies
169    /// `p' = zoom * (p - centroid) + centroid + pan`, which keeps the
170    /// content glued to the fingers.
171    pub fn apply_transform(&self, centroid: Point, pan: Point, zoom: f32) {
172        let old_scale = self.scale_non_reactive();
173        let new_scale = (old_scale * zoom).clamp(self.min_scale(), self.max_scale());
174        let effective_zoom = new_scale / old_scale;
175        let old_offset = self.offset_non_reactive();
176
177        let new_offset = Point {
178            x: centroid.x - (centroid.x - old_offset.x) * effective_zoom + pan.x,
179            y: centroid.y - (centroid.y - old_offset.y) * effective_zoom + pan.y,
180        };
181
182        if new_scale != old_scale {
183            self.inner.scale.set(new_scale);
184        }
185        if new_offset != old_offset {
186            self.set_offset(new_offset);
187        }
188    }
189
190    /// Builds the [`GraphicsLayer`] rendering this transform.
191    ///
192    /// Reads the state reactively, so it is meant for the lazy
193    /// `Modifier::graphics_layer(move || state.layer())` form.
194    pub fn layer(&self) -> GraphicsLayer {
195        let scale = self.scale();
196        let offset = self.offset();
197        GraphicsLayer {
198            scale_x: scale,
199            scale_y: scale,
200            translation_x: offset.x,
201            translation_y: offset.y,
202            transform_origin: TransformOrigin::new(0.0, 0.0),
203            ..Default::default()
204        }
205    }
206}
207
208/// Per-modifier gesture bookkeeping for `zoomable`.
209#[derive(Default)]
210struct ZoomGestureState {
211    tracker: TransformGesture,
212    /// Whether the gesture crossed into actively transforming (consuming).
213    active: bool,
214    /// Accumulated single-finger travel for the drag threshold.
215    travel: f32,
216}
217
218impl Modifier {
219    /// Makes the element respond to transform gestures, updating `state`.
220    ///
221    /// Recognizes two-finger pinch/pan (touch), one-finger pan while the
222    /// content is transformed, and [`PointerEventKind::Zoom`] steps
223    /// (desktop ctrl+wheel, browser pinch). Rendering is the app's choice —
224    /// typically `.graphics_layer(move || state.layer())` on the same or a
225    /// child element.
226    pub fn zoomable(self, state: ZoomState) -> Self {
227        let gesture_state = Rc::new(RefCell::new(ZoomGestureState::default()));
228        let key = state.id();
229
230        self.pointer_input(key, move |scope| {
231            let state = state.clone();
232            let gesture_state = gesture_state.clone();
233
234            async move {
235                scope
236                    .await_pointer_event_scope(|await_scope| async move {
237                        loop {
238                            let event = await_scope.await_pointer_event().await;
239
240                            match event.kind {
241                                PointerEventKind::Zoom => {
242                                    if !event.is_consumed() && event.zoom_delta != 1.0 {
243                                        state.apply_transform(
244                                            event.global_position,
245                                            Point { x: 0.0, y: 0.0 },
246                                            event.zoom_delta,
247                                        );
248                                        event.consume();
249                                    }
250                                }
251                                PointerEventKind::Cancel => {
252                                    // Platforms cancel whole gestures, not
253                                    // individual pointers.
254                                    let mut gs = gesture_state.borrow_mut();
255                                    gs.tracker.reset();
256                                    gs.active = false;
257                                    gs.travel = 0.0;
258                                }
259                                PointerEventKind::Down
260                                | PointerEventKind::Move
261                                | PointerEventKind::Up => {
262                                    let mut gs = gesture_state.borrow_mut();
263
264                                    if event.is_consumed() {
265                                        // Another handler owns this pointer
266                                        // sequence; abandon the gesture.
267                                        gs.tracker.reset();
268                                        gs.active = false;
269                                        gs.travel = 0.0;
270                                        continue;
271                                    }
272
273                                    // Track in window coordinates: they stay
274                                    // stable while our own layer transform
275                                    // changes mid-gesture.
276                                    let tracked =
277                                        event.copy_with_local_position(event.global_position);
278                                    let step = gs.tracker.handle_event(&tracked);
279
280                                    if event.kind == PointerEventKind::Down {
281                                        if gs.tracker.pointer_count() >= 2 {
282                                            // Pinch begins: transform gestures
283                                            // own the sequence immediately.
284                                            gs.active = true;
285                                        } else {
286                                            gs.travel = 0.0;
287                                        }
288                                        // Secondary fingers are meaningless to
289                                        // single-pointer handlers; keep them.
290                                        if event.id != 0 {
291                                            event.consume();
292                                        }
293                                        continue;
294                                    }
295
296                                    match step {
297                                        TransformGestureEvent::Transform {
298                                            pan,
299                                            zoom,
300                                            centroid,
301                                            pointer_count,
302                                        } => {
303                                            if pointer_count >= 2 {
304                                                gs.active = true;
305                                            } else if !gs.active && state.is_transformed() {
306                                                // One-finger pan only grabs the
307                                                // gesture when there is a
308                                                // transform to pan, so identity
309                                                // zoomables never steal scrolls.
310                                                gs.travel += (pan.x * pan.x + pan.y * pan.y).sqrt();
311                                                if gs.travel > DRAG_THRESHOLD {
312                                                    gs.active = true;
313                                                }
314                                            }
315
316                                            if gs.active {
317                                                state.apply_transform(centroid, pan, zoom);
318                                                event.consume();
319                                            }
320                                        }
321                                        TransformGestureEvent::Ended => {
322                                            let was_active = gs.active;
323                                            gs.active = false;
324                                            gs.travel = 0.0;
325                                            if was_active {
326                                                event.consume();
327                                            }
328                                        }
329                                        TransformGestureEvent::None => {
330                                            if event.id != 0
331                                                || (gs.active
332                                                    && matches!(
333                                                        event.kind,
334                                                        PointerEventKind::Up
335                                                            | PointerEventKind::Cancel
336                                                    ))
337                                            {
338                                                event.consume();
339                                            }
340                                        }
341                                    }
342                                }
343                                PointerEventKind::Scroll
344                                | PointerEventKind::Enter
345                                | PointerEventKind::Exit => {}
346                            }
347                        }
348                    })
349                    .await;
350            }
351        })
352    }
353}
354
355#[cfg(test)]
356#[path = "tests/zoom_tests.rs"]
357mod tests;