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