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 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 = DefaultHasher::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 pub fn min_scale(&self) -> f32 {
165 self.inner().min_scale.get()
166 }
167
168 pub fn max_scale(&self) -> f32 {
169 self.inner().max_scale.get()
170 }
171
172 /// Whether the content is currently transformed away from identity.
173 pub fn is_transformed(&self) -> bool {
174 let offset = self.offset_non_reactive();
175 (self.scale_non_reactive() - 1.0).abs() > SCALE_EPSILON
176 || offset.x != 0.0
177 || offset.y != 0.0
178 }
179
180 /// Whether the content is currently zoomed in beyond identity.
181 ///
182 /// Pan gestures only apply while this is `true`: an image at (or below)
183 /// its natural size has nothing to pan, and drags over it belong to
184 /// enclosing scrollables.
185 pub fn is_zoomed_in(&self) -> bool {
186 self.scale_non_reactive() > 1.0 + SCALE_EPSILON
187 }
188
189 /// Sets the scale directly (clamped to the configured range).
190 pub fn set_scale(&self, scale: f32) {
191 let clamped = scale.clamp(self.min_scale(), self.max_scale());
192 self.inner().scale.set(clamped);
193 }
194
195 /// Sets the pan offset directly.
196 pub fn set_offset(&self, offset: Point) {
197 let inner = self.inner();
198 inner.offset_x.set(offset.x);
199 inner.offset_y.set(offset.y);
200 }
201
202 /// Resets to identity (scale clamped into range, zero offset).
203 pub fn reset(&self) {
204 self.set_scale(1.0);
205 self.set_offset(Point { x: 0.0, y: 0.0 });
206 }
207
208 /// Applies one gesture step: zoom by `zoom` about `centroid`, then pan.
209 ///
210 /// `centroid` and `pan` are in the element's display frame; for finger
211 /// gestures the anchor must be the centroid of the pointers BEFORE the
212 /// step (as reported by `TransformGestureEvent::Transform`). With the
213 /// top-left-origin layer produced by [`ZoomState::layer`], a content
214 /// point `c` renders at `p = c * scale + offset`; this update applies
215 /// `p' = zoom * (p - centroid) + centroid + pan`, which keeps the
216 /// content glued to the fingers.
217 ///
218 /// Pan is inert while not zoomed in: whenever the resulting scale is at
219 /// (or below) identity the offset is clamped back to zero, so a pinch
220 /// that zooms out to `scale <= 1` — or a pure centroid pan at identity —
221 /// never leaves the content stranded at a stray offset.
222 pub fn apply_transform(&self, centroid: Point, pan: Point, zoom: f32) {
223 let old_scale = self.scale_non_reactive();
224 let new_scale = (old_scale * zoom).clamp(self.min_scale(), self.max_scale());
225 let effective_zoom = new_scale / old_scale;
226 let old_offset = self.offset_non_reactive();
227
228 let new_offset = if new_scale <= 1.0 + SCALE_EPSILON {
229 Point { x: 0.0, y: 0.0 }
230 } else {
231 Point {
232 x: centroid.x - (centroid.x - old_offset.x) * effective_zoom + pan.x,
233 y: centroid.y - (centroid.y - old_offset.y) * effective_zoom + pan.y,
234 }
235 };
236
237 if new_scale != old_scale {
238 self.inner().scale.set(new_scale);
239 }
240 if new_offset != old_offset {
241 self.set_offset(new_offset);
242 }
243 }
244
245 /// Builds the [`GraphicsLayer`] rendering this transform.
246 ///
247 /// Reads the state reactively, so it is meant for the lazy
248 /// `Modifier::graphics_layer(move || state.layer())` form.
249 pub fn layer(&self) -> GraphicsLayer {
250 let scale = self.scale();
251 let offset = self.offset();
252 GraphicsLayer {
253 scale_x: scale,
254 scale_y: scale,
255 translation_x: offset.x,
256 translation_y: offset.y,
257 transform_origin: TransformOrigin::new(0.0, 0.0),
258 ..Default::default()
259 }
260 }
261}
262
263struct ZoomGestureState {
264 tracker: TransformGesture,
265 active: bool,
266 travel: f32,
267 tap_down: Option<Point>,
268 last_tap: Option<(i64, Point)>,
269 fallback_epoch: Instant,
270}
271
272impl Default for ZoomGestureState {
273 fn default() -> Self {
274 Self {
275 tracker: TransformGesture::default(),
276 active: false,
277 travel: 0.0,
278 tap_down: None,
279 last_tap: None,
280 fallback_epoch: Instant::now(),
281 }
282 }
283}
284
285impl ZoomGestureState {
286 fn timestamp_ms(&self, time_ms: Option<i64>) -> i64 {
287 time_ms.unwrap_or_else(|| self.fallback_epoch.elapsed().as_millis() as i64)
288 }
289
290 fn abandon_tap(&mut self) {
291 self.tap_down = None;
292 self.last_tap = None;
293 }
294}
295
296impl Modifier {
297 /// Makes the element respond to transform gestures, updating `state`.
298 ///
299 /// Recognizes two-finger pinch/pan (touch), one-finger pan while the
300 /// content is zoomed in (`scale > 1`), a double-tap that resets a
301 /// transformed state to identity, and [`PointerEventKind::Zoom`] steps
302 /// (desktop ctrl+wheel, browser pinch). Pan — including the pinch
303 /// centroid — is inert while `scale <= 1`. Rendering is the app's
304 /// choice — typically `.graphics_layer(move || state.layer())` on the
305 /// same or a child element.
306 pub fn zoomable(self, state: ZoomState) -> Self {
307 let gesture_state = Rc::new(RefCell::new(ZoomGestureState::default()));
308 let key = state.id();
309
310 self.pointer_input(key, move |scope| {
311 let gesture_state = gesture_state.clone();
312
313 async move {
314 scope
315 .await_pointer_event_scope(|await_scope| async move {
316 loop {
317 let event = await_scope.await_pointer_event().await;
318
319 match event.kind {
320 PointerEventKind::Zoom => {
321 if !event.is_consumed() && event.zoom_delta != 1.0 {
322 state.apply_transform(
323 event.global_position,
324 Point { x: 0.0, y: 0.0 },
325 event.zoom_delta,
326 );
327 event.consume();
328 }
329 }
330 PointerEventKind::Cancel => {
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 gs.tracker.reset();
344 gs.active = false;
345 gs.travel = 0.0;
346 gs.abandon_tap();
347 continue;
348 }
349
350 let tracked =
351 event.copy_with_local_position(event.global_position);
352 let step = gs.tracker.handle_event(&tracked);
353
354 if event.kind == PointerEventKind::Down {
355 if gs.tracker.pointer_count() >= 2 {
356 gs.active = true;
357 gs.tap_down = None;
358 } else {
359 gs.travel = 0.0;
360 gs.tap_down = Some(event.global_position);
361 }
362 if event.id != 0 {
363 event.consume();
364 }
365 continue;
366 }
367
368 if event.kind == PointerEventKind::Up && event.id == 0 {
369 let now_ms = gs.timestamp_ms(event.time_ms);
370 let up_position = event.global_position;
371 let is_tap = !gs.active
372 && gs.tap_down.is_some_and(|down| {
373 distance(down, up_position) <= DRAG_THRESHOLD
374 });
375 gs.tap_down = None;
376 if is_tap {
377 let is_double_tap = gs.last_tap.is_some_and(
378 |(tap_ms, tap_position)| {
379 now_ms.saturating_sub(tap_ms)
380 <= DOUBLE_TAP_TIMEOUT_MS
381 && distance(tap_position, up_position)
382 <= DOUBLE_TAP_SLOP
383 },
384 );
385 if is_double_tap {
386 gs.last_tap = None;
387 if state.is_transformed() {
388 state.reset();
389 event.consume();
390 }
391 } else {
392 gs.last_tap = Some((now_ms, up_position));
393 }
394 } else {
395 gs.last_tap = None;
396 }
397 }
398
399 match step {
400 TransformGestureEvent::Transform {
401 pan,
402 zoom,
403 centroid,
404 pointer_count,
405 } => {
406 if pointer_count >= 2 {
407 gs.active = true;
408 } else if !gs.active && state.is_zoomed_in() {
409 gs.travel += (pan.x * pan.x + pan.y * pan.y).sqrt();
410 if gs.travel > DRAG_THRESHOLD {
411 gs.active = true;
412 }
413 }
414
415 if gs.active {
416 state.apply_transform(centroid, pan, zoom);
417 event.consume();
418 }
419 }
420 TransformGestureEvent::Ended => {
421 let was_active = gs.active;
422 gs.active = false;
423 gs.travel = 0.0;
424 if was_active {
425 event.consume();
426 }
427 }
428 TransformGestureEvent::None => {
429 if event.id != 0
430 || (gs.active
431 && matches!(
432 event.kind,
433 PointerEventKind::Up
434 | PointerEventKind::Cancel
435 ))
436 {
437 event.consume();
438 }
439 }
440 }
441 }
442 PointerEventKind::Scroll
443 | PointerEventKind::RotaryScrollPre
444 | PointerEventKind::RotaryScroll
445 | PointerEventKind::Enter
446 | PointerEventKind::Exit => {}
447 }
448 }
449 })
450 .await;
451 }
452 })
453 }
454}
455
456#[cfg(test)]
457#[path = "tests/zoom_tests.rs"]
458mod tests;