cranpose-ui 0.1.30

UI primitives for Cranpose
Documentation
//! Pinch-to-zoom / pan state and the `Modifier::zoomable` gesture modifier.
//!
//! # Overview
//! [`ZoomState`] is a pure transform model (scale + offset) mirroring the
//! [`ScrollState`](crate::ScrollState) pattern: reactive `MutableState`
//! internals so composables and lazy `graphics_layer` closures observe
//! changes, plus non-reactive accessors for gesture handlers.
//!
//! The app renders the transform with the existing graphics-layer pipeline:
//!
//! ```text
//! let zoom = ZoomState::new();
//! Image(
//!     Modifier::empty()
//!         .fill_max_size()
//!         .zoomable(zoom.clone())
//!         .graphics_layer(move || zoom.layer()),
//!     ...
//! );
//! ```
//!
//! # Gestures
//! - **Two-finger pinch** (Android/touch): scale about the finger centroid,
//!   plus centroid pan. Activates immediately when the second finger lands.
//! - **One-finger pan**: activates after the drag threshold, and only while
//!   the content is transformed (`scale != 1` or panned) so an untransformed
//!   zoomable never steals drags from an enclosing scrollable.
//! - **Ctrl+wheel / trackpad pinch** (desktop, web): discrete
//!   [`PointerEventKind::Zoom`] steps about the cursor.
//!
//! # Coordinate space
//! Gesture math runs in window (global) coordinates, which stay stable while
//! the element's own graphics layer is being transformed. The focal-point
//! anchoring is therefore exact for zoomable surfaces positioned at the
//! window origin (the fullscreen viewer case) and approximate otherwise.

use crate::modifier::{Modifier, PointerEventKind};
use cranpose_core::{ownedMutableStateOf, OwnedMutableState};
use cranpose_foundation::nodes::input::gestures::{TransformGesture, TransformGestureEvent};
use cranpose_foundation::DRAG_THRESHOLD;
use cranpose_ui_graphics::{GraphicsLayer, Point, TransformOrigin};
use std::cell::{Cell, RefCell};
use std::rc::Rc;

/// Scale factors closer to 1.0 than this are treated as "not zoomed".
const SCALE_EPSILON: f32 = 1e-3;

/// Shared zoom/pan transform state for `Modifier::zoomable`.
///
/// Cloning shares the same underlying state (like `ScrollState`).
#[derive(Clone)]
pub struct ZoomState {
    inner: Rc<ZoomStateInner>,
}

struct ZoomStateInner {
    /// Uniform content scale factor. Reactive so composables and lazy
    /// graphics-layer closures re-evaluate when it changes.
    scale: OwnedMutableState<f32>,
    /// Content translation in dp, applied after scaling about the top-left
    /// origin: a content point `c` is displayed at `c * scale + offset`.
    offset_x: OwnedMutableState<f32>,
    offset_y: OwnedMutableState<f32>,
    min_scale: Cell<f32>,
    max_scale: Cell<f32>,
}

impl Default for ZoomState {
    fn default() -> Self {
        Self::new()
    }
}

impl ZoomState {
    /// Creates a state at scale 1.0 with a 1.0..=8.0 zoom range.
    pub fn new() -> Self {
        Self::with_scale_range(1.0, 8.0)
    }

    /// Creates a state at scale 1.0 clamping zoom to `min_scale..=max_scale`.
    pub fn with_scale_range(min_scale: f32, max_scale: f32) -> Self {
        assert!(
            min_scale > 0.0 && max_scale >= min_scale,
            "invalid zoom range {min_scale}..{max_scale}"
        );
        Self {
            inner: Rc::new(ZoomStateInner {
                scale: ownedMutableStateOf(1.0f32.clamp(min_scale, max_scale)),
                offset_x: ownedMutableStateOf(0.0f32),
                offset_y: ownedMutableStateOf(0.0f32),
                min_scale: Cell::new(min_scale),
                max_scale: Cell::new(max_scale),
            }),
        }
    }

    /// Stable identity of this state (shared across clones).
    pub fn id(&self) -> u64 {
        Rc::as_ptr(&self.inner) as usize as u64
    }

    /// Current scale (reactive — subscribes the caller to changes).
    pub fn scale(&self) -> f32 {
        self.inner.scale.with(|s| *s)
    }

    /// Current scale without snapshot subscription.
    pub fn scale_non_reactive(&self) -> f32 {
        self.inner.scale.get_non_reactive()
    }

    /// Current pan offset in dp (reactive).
    pub fn offset(&self) -> Point {
        Point {
            x: self.inner.offset_x.with(|v| *v),
            y: self.inner.offset_y.with(|v| *v),
        }
    }

    /// Current pan offset without snapshot subscription.
    pub fn offset_non_reactive(&self) -> Point {
        Point {
            x: self.inner.offset_x.get_non_reactive(),
            y: self.inner.offset_y.get_non_reactive(),
        }
    }

    pub fn min_scale(&self) -> f32 {
        self.inner.min_scale.get()
    }

    pub fn max_scale(&self) -> f32 {
        self.inner.max_scale.get()
    }

    /// Whether the content is currently transformed away from identity.
    pub fn is_transformed(&self) -> bool {
        let offset = self.offset_non_reactive();
        (self.scale_non_reactive() - 1.0).abs() > SCALE_EPSILON
            || offset.x != 0.0
            || offset.y != 0.0
    }

    /// Sets the scale directly (clamped to the configured range).
    pub fn set_scale(&self, scale: f32) {
        let clamped = scale.clamp(self.min_scale(), self.max_scale());
        self.inner.scale.set(clamped);
    }

    /// Sets the pan offset directly.
    pub fn set_offset(&self, offset: Point) {
        self.inner.offset_x.set(offset.x);
        self.inner.offset_y.set(offset.y);
    }

    /// Resets to identity (scale clamped into range, zero offset).
    pub fn reset(&self) {
        self.set_scale(1.0);
        self.set_offset(Point { x: 0.0, y: 0.0 });
    }

    /// Applies one gesture step: zoom by `zoom` about `centroid`, then pan.
    ///
    /// `centroid` and `pan` are in the element's display frame; for finger
    /// gestures the anchor must be the centroid of the pointers BEFORE the
    /// step (as reported by `TransformGestureEvent::Transform`). With the
    /// top-left-origin layer produced by [`ZoomState::layer`], a content
    /// point `c` renders at `p = c * scale + offset`; this update applies
    /// `p' = zoom * (p - centroid) + centroid + pan`, which keeps the
    /// content glued to the fingers.
    pub fn apply_transform(&self, centroid: Point, pan: Point, zoom: f32) {
        let old_scale = self.scale_non_reactive();
        let new_scale = (old_scale * zoom).clamp(self.min_scale(), self.max_scale());
        let effective_zoom = new_scale / old_scale;
        let old_offset = self.offset_non_reactive();

        let new_offset = Point {
            x: centroid.x - (centroid.x - old_offset.x) * effective_zoom + pan.x,
            y: centroid.y - (centroid.y - old_offset.y) * effective_zoom + pan.y,
        };

        if new_scale != old_scale {
            self.inner.scale.set(new_scale);
        }
        if new_offset != old_offset {
            self.set_offset(new_offset);
        }
    }

    /// Builds the [`GraphicsLayer`] rendering this transform.
    ///
    /// Reads the state reactively, so it is meant for the lazy
    /// `Modifier::graphics_layer(move || state.layer())` form.
    pub fn layer(&self) -> GraphicsLayer {
        let scale = self.scale();
        let offset = self.offset();
        GraphicsLayer {
            scale_x: scale,
            scale_y: scale,
            translation_x: offset.x,
            translation_y: offset.y,
            transform_origin: TransformOrigin::new(0.0, 0.0),
            ..Default::default()
        }
    }
}

/// Per-modifier gesture bookkeeping for `zoomable`.
#[derive(Default)]
struct ZoomGestureState {
    tracker: TransformGesture,
    /// Whether the gesture crossed into actively transforming (consuming).
    active: bool,
    /// Accumulated single-finger travel for the drag threshold.
    travel: f32,
}

impl Modifier {
    /// Makes the element respond to transform gestures, updating `state`.
    ///
    /// Recognizes two-finger pinch/pan (touch), one-finger pan while the
    /// content is transformed, and [`PointerEventKind::Zoom`] steps
    /// (desktop ctrl+wheel, browser pinch). Rendering is the app's choice —
    /// typically `.graphics_layer(move || state.layer())` on the same or a
    /// child element.
    pub fn zoomable(self, state: ZoomState) -> Self {
        let gesture_state = Rc::new(RefCell::new(ZoomGestureState::default()));
        let key = state.id();

        self.pointer_input(key, move |scope| {
            let state = state.clone();
            let gesture_state = gesture_state.clone();

            async move {
                scope
                    .await_pointer_event_scope(|await_scope| async move {
                        loop {
                            let event = await_scope.await_pointer_event().await;

                            match event.kind {
                                PointerEventKind::Zoom => {
                                    if !event.is_consumed() && event.zoom_delta != 1.0 {
                                        state.apply_transform(
                                            event.global_position,
                                            Point { x: 0.0, y: 0.0 },
                                            event.zoom_delta,
                                        );
                                        event.consume();
                                    }
                                }
                                PointerEventKind::Cancel => {
                                    // Platforms cancel whole gestures, not
                                    // individual pointers.
                                    let mut gs = gesture_state.borrow_mut();
                                    gs.tracker.reset();
                                    gs.active = false;
                                    gs.travel = 0.0;
                                }
                                PointerEventKind::Down
                                | PointerEventKind::Move
                                | PointerEventKind::Up => {
                                    let mut gs = gesture_state.borrow_mut();

                                    if event.is_consumed() {
                                        // Another handler owns this pointer
                                        // sequence; abandon the gesture.
                                        gs.tracker.reset();
                                        gs.active = false;
                                        gs.travel = 0.0;
                                        continue;
                                    }

                                    // Track in window coordinates: they stay
                                    // stable while our own layer transform
                                    // changes mid-gesture.
                                    let tracked =
                                        event.copy_with_local_position(event.global_position);
                                    let step = gs.tracker.handle_event(&tracked);

                                    if event.kind == PointerEventKind::Down {
                                        if gs.tracker.pointer_count() >= 2 {
                                            // Pinch begins: transform gestures
                                            // own the sequence immediately.
                                            gs.active = true;
                                        } else {
                                            gs.travel = 0.0;
                                        }
                                        // Secondary fingers are meaningless to
                                        // single-pointer handlers; keep them.
                                        if event.id != 0 {
                                            event.consume();
                                        }
                                        continue;
                                    }

                                    match step {
                                        TransformGestureEvent::Transform {
                                            pan,
                                            zoom,
                                            centroid,
                                            pointer_count,
                                        } => {
                                            if pointer_count >= 2 {
                                                gs.active = true;
                                            } else if !gs.active && state.is_transformed() {
                                                // One-finger pan only grabs the
                                                // gesture when there is a
                                                // transform to pan, so identity
                                                // zoomables never steal scrolls.
                                                gs.travel += (pan.x * pan.x + pan.y * pan.y).sqrt();
                                                if gs.travel > DRAG_THRESHOLD {
                                                    gs.active = true;
                                                }
                                            }

                                            if gs.active {
                                                state.apply_transform(centroid, pan, zoom);
                                                event.consume();
                                            }
                                        }
                                        TransformGestureEvent::Ended => {
                                            let was_active = gs.active;
                                            gs.active = false;
                                            gs.travel = 0.0;
                                            if was_active {
                                                event.consume();
                                            }
                                        }
                                        TransformGestureEvent::None => {
                                            if event.id != 0
                                                || (gs.active
                                                    && matches!(
                                                        event.kind,
                                                        PointerEventKind::Up
                                                            | PointerEventKind::Cancel
                                                    ))
                                            {
                                                event.consume();
                                            }
                                        }
                                    }
                                }
                                PointerEventKind::Scroll
                                | PointerEventKind::Enter
                                | PointerEventKind::Exit => {}
                            }
                        }
                    })
                    .await;
            }
        })
    }
}

#[cfg(test)]
#[path = "tests/zoom_tests.rs"]
mod tests;