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