Created
December 4, 2025 10:34
-
-
Save jrmoulton/9e2c8bd51b8e34c4ab31b16078722e8b to your computer and use it in GitHub Desktop.
Transform EnCoder
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Copyright 2025 the Transform Encoder Authors | |
| // SPDX-License-Identifier: Apache-2.0 OR MIT | |
| //! # Transform Encoder | |
| //! | |
| //! This crate provides a comprehensive system for handling input events and converting them | |
| //! into affine transformations for 2D graphics applications. It supports mouse, touch, and | |
| //! gesture inputs with configurable behavior patterns. | |
| //! | |
| //! ## Features | |
| //! | |
| //! - **Multi-input Support**: Handles mouse, touch, and gesture events from ui_events | |
| //! - **Configurable Behaviors**: Flexible mapping of input combinations to transform actions | |
| //! - **Gesture Recognition**: Support for pinch, rotate, and drag gestures | |
| //! - **Overscroll Handling**: Bounce-back effects when content exceeds boundaries | |
| //! - **Snapping**: Configurable snap-to targets with dead zones | |
| //! - **Cumulative Tracking**: State tracking for complex gesture sequences | |
| //! | |
| //! ## Basic Usage | |
| //! | |
| //! ```rust | |
| //! use transform_encoder::{Encoder, Behavior, Action}; | |
| //! use kurbo::{Affine, Vec2, Axis}; | |
| //! use ui_events::keyboard::Modifiers; | |
| //! | |
| //! // Create a behavior configuration | |
| //! let behavior = Behavior::new() | |
| //! .mouse_scroll_vertical(|m| m.is_empty(), Action::ZoomXY(Vec2::new(0.1, 0.1))) | |
| //! .drag(|m| m.is_empty(), Action::Pan(Vec2::new(1.0, 1.0))); | |
| //! | |
| //! // Create an event state handler | |
| //! let mut encoder = Encoder::new() | |
| //! .with_behavior(behavior) | |
| //! .with_transform(Affine::IDENTITY); | |
| //! | |
| //! // Process events (in your event loop) | |
| //! // let new_transform = encoder.encode(&pointer_event, || None, None, None, None); | |
| //! ``` | |
| //! | |
| //! ## Transform Actions | |
| //! | |
| //! The library supports various transformation actions: | |
| //! | |
| //! - **Pan**: Translate content based on drag or scroll input | |
| //! - **Zoom**: Scale content uniformly (ZoomXY) or along specific axes (ZoomX, ZoomY) | |
| //! - **Rotate**: Rotate content around a center point | |
| //! - **Scroll**: Discrete scrolling for paginated content | |
| //! - **Custom**: User-defined transformation callbacks | |
| //! - **Overscroll**: Elastic boundary effects | |
| //! | |
| //! ## Event Handling Flow | |
| //! | |
| //! 1. **Input Detection**: Raw pointer events are received | |
| //! 2. **Behavior Matching**: Events are matched against configured behavior patterns | |
| //! 3. **Action Execution**: Matching actions are applied to the transform | |
| //! 4. **State Update**: Internal tracking state is updated | |
| //! 5. **Result Return**: Updated transform is returned if event was handled | |
| //! # Affine Transform Event Handler | |
| //! | |
| //! A library for handling pointer events (mouse, touch, trackpad) and converting them into | |
| //! affine transformations like pan, zoom, and rotate. Built on top of the `ui-events` crate | |
| //! for cross-platform event handling and `kurbo` for mathematical primitives. | |
| //! | |
| //! ## Basic Usage | |
| //! | |
| //! ```rust | |
| //! use transform_encoder::{Encoder, Behavior, Action}; | |
| //! use kurbo::{Vec2, Axis}; | |
| //! use ui_events::keyboard::Modifiers; | |
| //! | |
| //! // Create a behavior configuration | |
| //! let behavior = Behavior::new() | |
| //! .mouse_scroll_vertical(|m| m.is_empty(), Action::ZoomXY(Vec2::new(0.1, 0.1))) | |
| //! .drag(|m| m.is_empty(), Action::Pan(Vec2::new(1.0, 1.0))); | |
| //! | |
| //! // Create event state | |
| //! let mut state = Encoder::new().with_behavior(behavior); | |
| //! | |
| //! // Handle events by calling state.encode(event, center_fn, min_scale, max_scale, bounds) | |
| //! ``` | |
| //! | |
| //! ## Architecture | |
| //! | |
| //! - [`Encoder`] - Main state tracker that processes events and manages transforms | |
| //! - [`Behavior`] - Configurable behavior that maps input patterns to actions | |
| //! - [`Action`] - Actions that can be performed (pan, zoom, rotate, scroll, custom) | |
| //! - [`ScrollInput`] - Specification for wheel/scroll input (device type + axis) | |
| //! | |
| //! The library uses a functional approach where you configure behavior patterns upfront, | |
| //! then process events through the state machine which returns an optional transform if handled. | |
| use std::rc::Rc; | |
| use kurbo::*; | |
| use ui_events::{ScrollDelta, keyboard::*, pointer::*}; | |
| /// Tracks cumulative rotation across multiple gesture events. | |
| /// | |
| /// This struct maintains state for rotation gestures that span multiple events, | |
| /// allowing for smooth and continuous rotation tracking. It's particularly useful | |
| /// for multi-touch rotation gestures where you need to accumulate rotation | |
| /// across multiple pointer events. | |
| /// | |
| /// # Example | |
| /// | |
| /// ```rust | |
| /// use transform_encoder::RotationTracker; | |
| /// | |
| /// let mut tracker = RotationTracker::new(); | |
| /// | |
| /// // Simulate rotation events | |
| /// tracker.update(0.1); // 0.1 radians | |
| /// tracker.update(0.2); // additional 0.2 radians | |
| /// | |
| /// assert!((tracker.cumulative_rotation - 0.3).abs() < f64::EPSILON); | |
| /// ``` | |
| #[derive(Debug, Clone)] | |
| pub struct RotationTracker { | |
| /// The total accumulated rotation in radians | |
| pub cumulative_rotation: f64, | |
| } | |
| impl Default for RotationTracker { | |
| fn default() -> Self { | |
| Self::new() | |
| } | |
| } | |
| impl RotationTracker { | |
| /// Create a new rotation tracker with zero cumulative rotation. | |
| #[must_use] | |
| pub fn new() -> Self { | |
| Self { | |
| cumulative_rotation: 0.0, | |
| } | |
| } | |
| /// Add a rotation delta to the cumulative rotation. | |
| /// | |
| /// # Arguments | |
| /// | |
| /// * `th_rad` - The rotation delta to add, in radians | |
| pub fn update(&mut self, th_rad: f64) { | |
| self.cumulative_rotation += th_rad; | |
| } | |
| } | |
| /// Extension trait for Vec2 to add component-wise multiplication. | |
| /// | |
| /// This trait adds the `component_mul` method to `Vec2`, which performs | |
| /// element-wise multiplication of two vectors. This is useful for applying | |
| /// different sensitivity settings to X and Y axes. | |
| trait Vec2Ext { | |
| /// Multiplies each component of this vector by the corresponding component of another vector. | |
| /// | |
| /// # Arguments | |
| /// * `other` - The vector to multiply with | |
| /// | |
| /// # Returns | |
| /// A new vector with components `(self.x * other.x, self.y * other.y)` | |
| fn component_mul(self, other: Vec2) -> Vec2; | |
| } | |
| impl Vec2Ext for Vec2 { | |
| fn component_mul(self, other: Vec2) -> Vec2 { | |
| Vec2::new(self.x * other.x, self.y * other.y) | |
| } | |
| } | |
| /// Type alias for custom transform callback functions. | |
| /// | |
| /// Custom transform actions receive the pointer event and can directly | |
| /// modify the transform. This provides maximum flexibility for specialized | |
| /// transform behaviors that don't fit the predefined action types. | |
| /// | |
| /// # Parameters | |
| /// * `&PointerEvent` - The input event that triggered this action | |
| /// * `&mut Affine` - The transform to modify directly | |
| pub type CustomAction = dyn Fn(&PointerEvent, &mut Affine); | |
| /// Type alias for custom rotation callback functions with rotation tracking. | |
| /// | |
| /// Similar to `CustomAction` but also provides access to the | |
| /// rotation tracker for cumulative rotation calculations. Useful for | |
| /// complex rotation gestures that need to track accumulated rotation | |
| /// over multiple events. | |
| /// | |
| /// # Parameters | |
| /// * `&PointerEvent` - The input event that triggered this action | |
| /// * `&mut Affine` - The transform to modify directly | |
| /// * `&mut RotationTracker` - Tracker for cumulative rotation state | |
| pub type CustomRotationAction = dyn Fn(&PointerEvent, &mut Affine, &mut RotationTracker); | |
| /// Specification for scroll/wheel input events. | |
| /// | |
| /// This struct combines device type (mouse, touch) with the axis of movement | |
| /// to allow fine-grained control over how different input combinations are | |
| /// handled. For example, you might want mouse scroll on Y-axis to zoom while | |
| /// trackpad scroll on Y-axis to pan. | |
| /// | |
| /// # Examples | |
| /// | |
| /// ```rust | |
| /// # use transform_encoder::ScrollInput; | |
| /// # use ui_events::pointer::PointerType; | |
| /// # use kurbo::Axis; | |
| /// // Mouse wheel vertical scrolling | |
| /// let mouse_scroll_y = ScrollInput::mouse_y(); | |
| /// | |
| /// // Touch/trackpad horizontal scrolling | |
| /// let trackpad_x = ScrollInput::touch_x(); | |
| /// ``` | |
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| pub struct ScrollInput { | |
| /// The type of pointer device (Mouse, Touch, etc.) | |
| pub device: PointerType, | |
| /// The axis of movement (Horizontal or Vertical) | |
| pub axis: Axis, | |
| } | |
| impl ScrollInput { | |
| /// Creates a mouse horizontal scroll input specification. | |
| pub fn mouse_x() -> Self { | |
| Self { | |
| device: PointerType::Mouse, | |
| axis: Axis::Horizontal, | |
| } | |
| } | |
| /// Creates a mouse vertical scroll input specification. | |
| pub fn mouse_y() -> Self { | |
| Self { | |
| device: PointerType::Mouse, | |
| axis: Axis::Vertical, | |
| } | |
| } | |
| /// Creates a touch/trackpad horizontal scroll input specification. | |
| pub fn touch_x() -> Self { | |
| Self { | |
| device: PointerType::Touch, | |
| axis: Axis::Horizontal, | |
| } | |
| } | |
| /// Creates a touch/trackpad vertical scroll input specification. | |
| pub fn touch_y() -> Self { | |
| Self { | |
| device: PointerType::Touch, | |
| axis: Axis::Vertical, | |
| } | |
| } | |
| /// Creates a pen/stylus horizontal scroll input specification. | |
| pub fn pen_x() -> Self { | |
| Self { | |
| device: PointerType::Pen, | |
| axis: Axis::Horizontal, | |
| } | |
| } | |
| /// Creates a pen/stylus vertical scroll input specification. | |
| pub fn pen_y() -> Self { | |
| Self { | |
| device: PointerType::Pen, | |
| axis: Axis::Vertical, | |
| } | |
| } | |
| } | |
| /// Actions that can be performed during transform operations. | |
| /// | |
| /// Each variant represents a different type of transformation that can be applied | |
| /// in response to user input. Multiple actions can be associated with the same | |
| /// input event, allowing complex transformation behaviors. | |
| /// | |
| /// # Examples | |
| /// | |
| /// ```rust | |
| /// # use transform_encoder::Action; | |
| /// # use kurbo::Vec2; | |
| /// // Pan with different X/Y sensitivity | |
| /// let pan_action = Action::Pan(Vec2::new(1.0, 0.5)); | |
| /// | |
| /// // Uniform zoom | |
| /// let zoom_action = Action::ZoomXY(Vec2::new(0.1, 0.1)); | |
| /// | |
| /// // Horizontal-only zoom | |
| /// let zoom_x_action = Action::ZoomX(0.05); | |
| /// ``` | |
| #[derive(Clone)] | |
| pub enum Action { | |
| /// Translates content by the input delta scaled by the sensitivity vector. | |
| /// The Vec2 parameter specifies X and Y sensitivity multipliers. | |
| Pan(Vec2), | |
| /// Scales content uniformly with different X/Y sensitivity factors. | |
| /// The Vec2 parameter specifies X and Y scale sensitivity. | |
| ZoomXY(Vec2), | |
| /// Scales content only along the X axis. | |
| /// The f64 parameter specifies the scale sensitivity. | |
| ZoomX(f64), | |
| /// Scales content only along the Y axis. | |
| /// The f64 parameter specifies the scale sensitivity. | |
| ZoomY(f64), | |
| /// Rotates content around a center point. | |
| /// The f64 parameter specifies the rotation sensitivity in radians per input unit. | |
| Rotate(f64), | |
| /// Scrolls content horizontally (discrete scrolling). | |
| /// The f64 parameter specifies the scroll sensitivity. | |
| HorizontalScroll(f64), | |
| /// Scrolls content vertically (discrete scrolling). | |
| /// The f64 parameter specifies the scroll sensitivity. | |
| VerticalScroll(f64), | |
| /// Elastic pan behavior when content exceeds boundaries. | |
| /// The Vec2 parameter specifies X and Y overscroll sensitivity. | |
| OverscrollPan(Vec2), | |
| /// Custom transformation defined by a callback function. | |
| Custom(Rc<CustomAction>), | |
| /// Custom rotation transformation with rotation tracking. | |
| CustomRotation(Rc<CustomRotationAction>), | |
| /// No-op action that consumes the event without transforming. | |
| /// Useful for disabling default behaviors on specific input combinations. | |
| None, | |
| } | |
| impl PartialEq for Action { | |
| fn eq(&self, other: &Self) -> bool { | |
| match (self, other) { | |
| (Self::Pan(a), Self::Pan(b)) | |
| | (Self::ZoomXY(a), Self::ZoomXY(b)) | |
| | (Self::OverscrollPan(a), Self::OverscrollPan(b)) => a == b, | |
| (Self::ZoomX(a), Self::ZoomX(b)) | |
| | (Self::ZoomY(a), Self::ZoomY(b)) | |
| | (Self::Rotate(a), Self::Rotate(b)) | |
| | (Self::HorizontalScroll(a), Self::HorizontalScroll(b)) | |
| | (Self::VerticalScroll(a), Self::VerticalScroll(b)) => a == b, | |
| (Self::None, Self::None) => true, | |
| // Can't compare function pointers | |
| _ => false, | |
| } | |
| } | |
| } | |
| impl std::fmt::Debug for Action { | |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| match self { | |
| Self::Pan(v) => f.debug_tuple("Pan").field(v).finish(), | |
| Self::ZoomXY(v) => f.debug_tuple("ZoomXY").field(v).finish(), | |
| Self::ZoomX(v) => f.debug_tuple("ZoomX").field(v).finish(), | |
| Self::ZoomY(v) => f.debug_tuple("ZoomY").field(v).finish(), | |
| Self::Rotate(v) => f.debug_tuple("Rotate").field(v).finish(), | |
| Self::HorizontalScroll(v) => f.debug_tuple("HorizontalScroll").field(v).finish(), | |
| Self::VerticalScroll(v) => f.debug_tuple("VerticalScroll").field(v).finish(), | |
| Self::OverscrollPan(v) => f.debug_tuple("OverscrollPan").field(v).finish(), | |
| Self::Custom(_) => f.debug_tuple("Custom").field(&"<function>").finish(), | |
| Self::CustomRotation(_) => f | |
| .debug_tuple("CustomRotation") | |
| .field(&"<function>") | |
| .finish(), | |
| Self::None => write!(f, "None"), | |
| } | |
| } | |
| } | |
| type FilterFn = dyn Fn(Modifiers) -> bool; | |
| /// Configuration for how different input combinations map to actions | |
| #[derive(Clone)] | |
| #[allow(clippy::struct_excessive_bools)] | |
| pub struct Behavior { | |
| /// List of (`filter`, `wheel_input`, `action`) for wheel events - multiple | |
| /// actions can match | |
| pub scroll_actions: Vec<(Rc<FilterFn>, ScrollInput, Action)>, | |
| /// List of (filter, action) for drag events - multiple actions can match | |
| pub drag_actions: Vec<(Rc<FilterFn>, Action)>, | |
| /// List of (filter, action) for pinch gestures - multiple actions can match | |
| pub pinch_actions: Vec<(Rc<FilterFn>, Action)>, | |
| /// List of (filter, action) for rotation gestures - multiple actions can | |
| /// match | |
| pub rotation_actions: Vec<(Rc<FilterFn>, Action)>, | |
| pub zoom_suppress_y_translation: bool, | |
| pub zoom_suppress_x_translation: bool, | |
| pub x_axis_only_when_dominant: bool, | |
| pub y_axis_only_when_dominant: bool, | |
| } | |
| impl Default for Behavior { | |
| fn default() -> Self { | |
| let wheel_actions: Vec<(Rc<FilterFn>, ScrollInput, Action)> = Vec::new(); | |
| let drag_actions: Vec<(Rc<FilterFn>, Action)> = Vec::new(); | |
| let pinch_actions: Vec<(Rc<FilterFn>, Action)> = Vec::new(); | |
| let rotation_actions: Vec<(Rc<FilterFn>, Action)> = Vec::new(); | |
| Self { | |
| scroll_actions: wheel_actions, | |
| drag_actions, | |
| pinch_actions, | |
| rotation_actions, | |
| zoom_suppress_y_translation: false, | |
| zoom_suppress_x_translation: false, | |
| x_axis_only_when_dominant: false, | |
| y_axis_only_when_dominant: false, | |
| } | |
| } | |
| } | |
| impl Behavior { | |
| /// Builder pattern for easy configuration | |
| pub fn new() -> Self { | |
| Self::default() | |
| } | |
| /// Add action for wheel events with custom filter and wheel input | |
| #[must_use] | |
| pub fn scroll<F>(mut self, filter: F, wheel_input: ScrollInput, action: Action) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| { | |
| self.scroll_actions | |
| .push((Rc::new(filter), wheel_input, action)); | |
| self | |
| } | |
| /// Add action for mouse wheel events | |
| #[must_use] | |
| pub fn mouse_scroll<F>(self, filter: F, axis: Axis, action: Action) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| { | |
| let wheel_input = ScrollInput { | |
| device: PointerType::Mouse, | |
| axis, | |
| }; | |
| self.scroll(filter, wheel_input, action) | |
| } | |
| /// Add action for trackpad wheel events | |
| #[must_use] | |
| pub fn touch_scroll<F>(self, filter: F, axis: Axis, action: Action) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| { | |
| let wheel_input = ScrollInput { | |
| device: PointerType::Touch, | |
| axis, | |
| }; | |
| self.scroll(filter, wheel_input, action) | |
| } | |
| /// Convenience methods for mouse wheel | |
| #[must_use] | |
| pub fn mouse_scroll_vertical<F>(self, filter: F, action: Action) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| { | |
| self.mouse_scroll(filter, Axis::Vertical, action) | |
| } | |
| #[must_use] | |
| pub fn mouse_scroll_horizontal<F>(self, filter: F, action: Action) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| { | |
| self.mouse_scroll(filter, Axis::Horizontal, action) | |
| } | |
| /// Convenience methods for trackpad | |
| #[must_use] | |
| pub fn touch_vertical<F>(self, filter: F, action: Action) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| { | |
| self.touch_scroll(filter, Axis::Vertical, action) | |
| } | |
| #[must_use] | |
| pub fn touch_horizontal<F>(self, filter: F, action: Action) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| { | |
| self.touch_scroll(filter, Axis::Horizontal, action) | |
| } | |
| /// Add action for pen scroll events | |
| #[must_use] | |
| pub fn pen_scroll<F>(self, filter: F, axis: Axis, action: Action) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| { | |
| let wheel_input = ScrollInput { | |
| device: PointerType::Pen, | |
| axis, | |
| }; | |
| self.scroll(filter, wheel_input, action) | |
| } | |
| /// Convenience methods for pen/stylus | |
| #[must_use] | |
| pub fn pen_vertical<F>(self, filter: F, action: Action) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| { | |
| self.pen_scroll(filter, Axis::Vertical, action) | |
| } | |
| #[must_use] | |
| pub fn pen_horizontal<F>(self, filter: F, action: Action) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| { | |
| self.pen_scroll(filter, Axis::Horizontal, action) | |
| } | |
| /// Add action for drag events with custom filter | |
| #[must_use] | |
| pub fn drag<F>(mut self, filter: F, action: Action) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| { | |
| self.drag_actions.push((Rc::new(filter), action)); | |
| self | |
| } | |
| /// Add overscroll action for drag events with custom filter | |
| #[must_use] | |
| pub fn overscroll<F>(mut self, filter: F, sensitivity: Vec2) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| { | |
| self.drag_actions | |
| .push((Rc::new(filter), Action::OverscrollPan(sensitivity))); | |
| self | |
| } | |
| /// Add a custom callback for drag events with custom filter | |
| #[must_use] | |
| pub fn drag_custom<F, C>(mut self, filter: F, callback: C) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| C: Fn(&PointerEvent, &mut Affine) + 'static, | |
| { | |
| self.drag_actions | |
| .push((Rc::new(filter), Action::Custom(Rc::new(callback)))); | |
| self | |
| } | |
| /// Add a custom callback for wheel events with custom filter and wheel | |
| /// input | |
| #[must_use] | |
| pub fn scroll_custom<F, C>(mut self, filter: F, scroll_input: ScrollInput, callback: C) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| C: Fn(&PointerEvent, &mut Affine) + 'static, | |
| { | |
| self.scroll_actions.push(( | |
| Rc::new(filter), | |
| scroll_input, | |
| Action::Custom(Rc::new(callback)), | |
| )); | |
| self | |
| } | |
| /// Add action for pinch gestures with custom filter | |
| #[must_use] | |
| pub fn pinch<F>(mut self, filter: F, action: Action) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| { | |
| self.pinch_actions.push((Rc::new(filter), action)); | |
| self | |
| } | |
| /// Add action for rotation gestures with custom filter | |
| #[must_use] | |
| pub fn rotation<F>(mut self, filter: F, action: Action) -> Self | |
| where | |
| F: Fn(Modifiers) -> bool + 'static, | |
| { | |
| self.rotation_actions.push((Rc::new(filter), action)); | |
| self | |
| } | |
| /// Set whether y translation should be supressed during zoom | |
| #[must_use] | |
| pub fn zoom_suppress_y_translation(mut self, suppress: bool) -> Self { | |
| self.zoom_suppress_y_translation = suppress; | |
| self | |
| } | |
| /// Set whether x translation should be supressed during zoom | |
| #[must_use] | |
| pub fn zoom_suppress_x_translation(mut self, suppress: bool) -> Self { | |
| self.zoom_suppress_x_translation = suppress; | |
| self | |
| } | |
| /// Set whether only the y axis should be processed when it is greater than | |
| /// the x | |
| #[must_use] | |
| pub fn y_axis_only_when_dominant(mut self, y_when_dominant: bool) -> Self { | |
| self.y_axis_only_when_dominant = y_when_dominant; | |
| self | |
| } | |
| /// Set whether only the x axis should be processed when it is greater than | |
| /// the y | |
| #[must_use] | |
| pub fn x_axis_only_when_dominant(mut self, x_when_dominant: bool) -> Self { | |
| self.x_axis_only_when_dominant = x_when_dominant; | |
| self | |
| } | |
| } | |
| /// Enhanced state tracker with behavior configuration | |
| #[derive(Clone)] | |
| pub struct Encoder { | |
| pub pointer_pos: Point, | |
| pub is_dragging: bool, | |
| pub last_drag_pos: Option<Point>, | |
| pub current_drag_pos: Option<Point>, | |
| pub behavior: Behavior, | |
| pub transform: Affine, // Transform available to custom callbacks | |
| pub rotation_tracker: RotationTracker, // For cumulative rotation tracking | |
| pub page_size: Option<Size>, | |
| pub line_size: Option<Size>, | |
| filter: Option<fn(&PointerEvent) -> bool>, | |
| } | |
| impl Default for Encoder { | |
| fn default() -> Self { | |
| Self { | |
| pointer_pos: Point::ZERO, | |
| is_dragging: false, | |
| last_drag_pos: None, | |
| current_drag_pos: None, | |
| behavior: Behavior::default(), | |
| transform: Affine::IDENTITY, | |
| rotation_tracker: RotationTracker::new(), | |
| line_size: None, | |
| page_size: None, | |
| filter: None, | |
| } | |
| } | |
| } | |
| impl Encoder { | |
| #[must_use] | |
| pub fn new() -> Self { | |
| Self::default() | |
| } | |
| /// Configure behavior with a fluent API | |
| #[must_use] | |
| pub fn with_behavior(mut self, behavior: Behavior) -> Self { | |
| self.behavior = behavior; | |
| self | |
| } | |
| /// Set the transform for custom callbacks to use | |
| #[must_use] | |
| pub fn with_transform(mut self, transform: Affine) -> Self { | |
| self.transform = transform; | |
| self | |
| } | |
| /// Update the internal transform (call this when the main transform | |
| /// changes) | |
| pub fn update_transform(&mut self, transform: Affine) { | |
| self.transform = transform; | |
| } | |
| /// Set an event filter | |
| #[must_use] | |
| pub fn with_filter(mut self, filter: fn(&PointerEvent) -> bool) -> Self { | |
| self.filter = Some(filter); | |
| self | |
| } | |
| /// Get all matching wheel actions for the given modifiers and wheel input | |
| fn get_wheel_actions(&self, modifiers: Modifiers, wheel_input: ScrollInput) -> Vec<Action> { | |
| self.behavior | |
| .scroll_actions | |
| .iter() | |
| .filter_map(|(filter, input, action)| { | |
| if *input == wheel_input && filter(modifiers) { | |
| Some(action.clone()) | |
| } else { | |
| None | |
| } | |
| }) | |
| .collect() | |
| } | |
| /// Get all matching drag actions for the given modifiers | |
| fn get_drag_actions(&self, modifiers: Modifiers) -> Vec<Action> { | |
| self.behavior | |
| .drag_actions | |
| .iter() | |
| .filter_map(|(filter, action)| { | |
| if filter(modifiers) { | |
| Some(action.clone()) | |
| } else { | |
| None | |
| } | |
| }) | |
| .collect() | |
| } | |
| /// Get all matching pinch actions for the given modifiers | |
| fn get_pinch_actions(&self, modifiers: Modifiers) -> Vec<Action> { | |
| self.behavior | |
| .pinch_actions | |
| .iter() | |
| .filter_map(|(filter, action)| { | |
| if filter(modifiers) { | |
| Some(action.clone()) | |
| } else { | |
| None | |
| } | |
| }) | |
| .collect() | |
| } | |
| /// Get all matching rotation actions for the given modifiers | |
| fn get_rotation_actions(&self, modifiers: Modifiers) -> Vec<Action> { | |
| self.behavior | |
| .rotation_actions | |
| .iter() | |
| .filter_map(|(filter, action)| { | |
| if filter(modifiers) { | |
| Some(action.clone()) | |
| } else { | |
| None | |
| } | |
| }) | |
| .collect() | |
| } | |
| /// Check if point is outside bounds and return overscroll amount | |
| fn calculate_overscroll(point: Point, bounds: Rect) -> Option<Vec2> { | |
| if bounds.contains(point) { | |
| return None; | |
| } | |
| let overscroll_x = if point.x < bounds.x0 { | |
| point.x - bounds.x0 | |
| } else if point.x > bounds.x1 { | |
| point.x - bounds.x1 | |
| } else { | |
| 0.0 | |
| }; | |
| let overscroll_y = if point.y < bounds.y0 { | |
| point.y - bounds.y0 | |
| } else if point.y > bounds.y1 { | |
| point.y - bounds.y1 | |
| } else { | |
| 0.0 | |
| }; | |
| Some(Vec2::new(overscroll_x, overscroll_y)) | |
| } | |
| /// Trigger overscroll at a specific point without requiring an event | |
| pub fn trigger_overscroll_at_point( | |
| &mut self, | |
| point: Point, | |
| bounds: Rect, | |
| sensitivity: Vec2, | |
| ) -> bool { | |
| if let Some(overscroll) = Self::calculate_overscroll(point, bounds) { | |
| // Find and apply all overscroll actions directly | |
| // Use the provided modifiers | |
| let pan_amount = overscroll.component_mul(sensitivity); | |
| self.transform *= Affine::translate(pan_amount); | |
| return true; | |
| } | |
| false | |
| } | |
| /// Check if an event passes the filter | |
| fn passes_filter(&self, event: &PointerEvent) -> bool { | |
| match self.filter { | |
| Some(filter) => filter(event), | |
| None => true, | |
| } | |
| } | |
| /// Helper to clamp scale factor while preserving existing transform properties. | |
| /// | |
| /// This method ensures that zoom operations respect min/max scale limits without | |
| /// affecting other transform properties like translation or rotation. When a scale | |
| /// operation would exceed the limits, only the scale factor is adjusted - the | |
| /// translation component remains unchanged to prevent unwanted content jumps. | |
| /// | |
| /// # Parameters | |
| /// | |
| /// * `scale_factor` - The desired scale factor to apply | |
| /// * `min_scale` - Optional minimum scale limit | |
| /// * `max_scale` - Optional maximum scale limit | |
| /// | |
| /// # Returns | |
| /// | |
| /// A tuple of (clamped_scale_factor, was_clamped_to_no_op) where: | |
| /// - `clamped_scale_factor` is the scale factor that can be safely applied without exceeding limits | |
| /// - `was_clamped_to_no_op` is true if the scale was clamped to effectively no scaling (scale factor ≈ 1.0) | |
| /// because the current scale is already at the limit. When this is true, the caller should | |
| /// avoid applying any transform to preserve the current translation. | |
| /// | |
| /// # Invariants | |
| /// | |
| /// - Translation is never modified by scale clamping | |
| /// - Rotation is never modified by scale clamping | |
| /// - Only the uniform scale factor is adjusted | |
| fn clamp_scale_factor( | |
| &self, | |
| mut scale_factor: f64, | |
| min_scale: Option<f64>, | |
| max_scale: Option<f64>, | |
| ) -> (f64, bool) { | |
| let current_scale = self.transform.as_coeffs()[0]; | |
| let proposed_scale = current_scale * scale_factor; | |
| let original_scale_factor = scale_factor; | |
| if let Some(max_scale) = max_scale | |
| && proposed_scale > max_scale | |
| { | |
| scale_factor = max_scale / current_scale; | |
| } | |
| if let Some(min_scale) = min_scale | |
| && proposed_scale < min_scale | |
| { | |
| scale_factor = min_scale / current_scale; | |
| } | |
| // Check if clamping actually prevented any scaling (i.e., we're already at the limit) | |
| let would_scale = (scale_factor - 1.0).abs() > f64::EPSILON; | |
| let was_clamped_to_no_op = | |
| !would_scale && (original_scale_factor - 1.0).abs() > f64::EPSILON; | |
| (scale_factor, was_clamped_to_no_op) | |
| } | |
| /// Process a pointer event and apply any matching transform actions. | |
| /// | |
| /// This is the core method of the event system that takes raw pointer events | |
| /// and converts them into affine transformations based on the configured behavior. | |
| /// The method updates internal state and returns the new transform if any actions | |
| /// were triggered, or `None` if the event was ignored. | |
| /// | |
| /// # Parameters | |
| /// | |
| /// * `pe` - The pointer event to process (Down, Move, Up, Scroll, Gesture, etc.) | |
| /// * `center_x_fn` - **Lazy** callback that computes the X center point for zoom/rotation operations. | |
| /// This is called as a closure because the center point calculation may be expensive | |
| /// (e.g., computing content bounds, querying UI layout) and should only be computed | |
| /// when actually needed for zoom/rotate actions. For other actions like pan or scroll, | |
| /// this function is never called. | |
| /// * `min_scale` - Optional minimum scale limit. When zooming would go below this value, | |
| /// the zoom is clamped to this minimum. **Important**: If already at the minimum scale, | |
| /// further zoom-out attempts will not modify the translation component to avoid unwanted | |
| /// content jumps. However, normal zoom operations within limits will zoom around the | |
| /// specified center point and may change translation. | |
| /// * `max_scale` - Optional maximum scale limit. When zooming would exceed this value, | |
| /// the zoom is clamped to this maximum. Like min_scale, if already at the maximum scale, | |
| /// further zoom-in attempts will not modify translation. | |
| /// * `bounds` - Optional content bounds rectangle used for overscroll detection. | |
| /// When provided, actions that support overscroll (like `OverscrollPan`) will | |
| /// check if the pointer is outside these bounds and apply elastic effects. | |
| /// Bounds should be in the same coordinate space as the pointer events. | |
| /// | |
| /// # Returns | |
| /// | |
| /// * `Some(transform)` - The updated transform if the event triggered any actions | |
| /// * `None` - If the event was not handled by any configured behavior | |
| /// | |
| /// # Behavior Matching | |
| /// | |
| /// The method matches events against configured behaviors in this order: | |
| /// 1. Event type (Down/Move/Up/Scroll/Gesture) | |
| /// 2. Modifier keys (Ctrl, Shift, Meta, etc.) | |
| /// 3. Device type (Mouse, Touch, Pen) for scroll events | |
| /// 4. Axis (Horizontal/Vertical) for scroll events | |
| /// | |
| /// Multiple actions can match the same event and will all be applied. | |
| /// | |
| /// # State Management | |
| /// | |
| /// The method maintains several pieces of internal state: | |
| /// - Drag tracking (start position, current position, dragging flag) | |
| /// - Transform state (for custom callbacks) | |
| /// - Rotation accumulation (for gesture sequences) | |
| /// - Pointer position and button state | |
| /// | |
| /// # Examples | |
| /// | |
| /// ## Basic Usage | |
| /// ```rust | |
| /// # use transform_encoder::{Encoder, Behavior, Action}; | |
| /// # use kurbo::{Affine, Vec2}; | |
| /// # use ui_events::pointer::*; | |
| /// # use ui_events::keyboard::Modifiers; | |
| /// # use dpi::PhysicalPosition; | |
| /// let behavior = Behavior::new() | |
| /// .drag(|m| m.is_empty(), Action::Pan(Vec2::new(1.0, 1.0))); | |
| /// | |
| /// let mut state = Encoder::new().with_behavior(behavior); | |
| /// | |
| /// // Create a mouse drag event | |
| /// let event = PointerEvent::Down(PointerButtonEvent { | |
| /// state: PointerState { | |
| /// position: PhysicalPosition::new(100.0, 50.0), | |
| /// modifiers: Modifiers::empty(), | |
| /// scale_factor: 1.0, | |
| /// count: 1, | |
| /// ..Default::default() | |
| /// }, | |
| /// button: Some(PointerButton::Primary), | |
| /// pointer: PointerInfo { | |
| /// pointer_type: PointerType::Mouse, | |
| /// pointer_id: None, | |
| /// persistent_device_id: None, | |
| /// }, | |
| /// }); | |
| /// | |
| /// // Process the event | |
| /// if let Some(new_transform) = state.encode( | |
| /// &event, | |
| /// || Some(400.0), // Center X for zoom operations | |
| /// Some(0.1), // Min scale | |
| /// Some(10.0), // Max scale | |
| /// None, // No bounds checking | |
| /// ) { | |
| /// // Event was handled, apply the new transform | |
| /// println!("New transform: {:?}", new_transform); | |
| /// } | |
| /// ``` | |
| /// | |
| /// ## With Lazy Center Calculation | |
| /// ```rust | |
| /// # use transform_encoder::{Encoder, Behavior, Action}; | |
| /// # use kurbo::{Vec2, Rect}; | |
| /// let mut state = Encoder::new(); | |
| /// # let event = ui_events::pointer::PointerEvent::Up(ui_events::pointer::PointerButtonEvent { | |
| /// # state: ui_events::pointer::PointerState::default(), | |
| /// # button: Some(ui_events::pointer::PointerButton::Primary), | |
| /// # pointer: ui_events::pointer::PointerInfo { | |
| /// # pointer_type: ui_events::pointer::PointerType::Mouse, | |
| /// # pointer_id: None, | |
| /// # persistent_device_id: None, | |
| /// # }, | |
| /// # }); | |
| /// | |
| /// // Expensive center calculation only called when needed | |
| /// let transform = state.encode( | |
| /// &event, | |
| /// || { | |
| /// // This closure only executes for zoom/rotate operations | |
| /// let content_bounds = expensive_layout_calculation(); | |
| /// Some(content_bounds.center().x) | |
| /// }, | |
| /// None, | |
| /// None, | |
| /// None, | |
| /// ); | |
| /// | |
| /// # fn expensive_layout_calculation() -> Rect { Rect::new(0.0, 0.0, 800.0, 600.0) } | |
| /// ``` | |
| pub fn encode( | |
| &mut self, | |
| pe: &PointerEvent, | |
| mut center_x_fn: impl FnMut() -> Option<f64>, | |
| min_scale: Option<f64>, | |
| max_scale: Option<f64>, | |
| bounds: Option<Rect>, | |
| ) -> Option<Affine> { | |
| if !self.passes_filter(pe) { | |
| return None; | |
| } | |
| use ui_events::pointer::PointerEvent as PE; | |
| match pe { | |
| PE::Down(pbe) => { | |
| let modifiers = pbe.state.modifiers; | |
| let logical_point = pbe.state.logical_point(); | |
| // Always track dragging state first | |
| self.pointer_pos = logical_point; | |
| self.is_dragging = true; | |
| self.last_drag_pos = Some(logical_point); | |
| self.current_drag_pos = Some(logical_point); | |
| let actions = self.get_drag_actions(modifiers); | |
| let mut stop = false; | |
| for action in actions { | |
| match action { | |
| Action::Custom(callback) => { | |
| callback(pe, &mut self.transform); | |
| stop = true; | |
| } | |
| Action::CustomRotation(callback) => { | |
| callback(pe, &mut self.transform, &mut self.rotation_tracker); | |
| stop = true; | |
| } | |
| Action::None => {} // Track but don't consume | |
| _ => stop = true, // Consume for other actions | |
| } | |
| } | |
| if stop { Some(self.transform) } else { None } | |
| } | |
| PE::Move(pu) => { | |
| let logical_point = pu.current.logical_point(); | |
| let modifiers = pu.current.modifiers; | |
| self.pointer_pos = logical_point; | |
| if self.is_dragging { | |
| self.last_drag_pos = self.current_drag_pos; | |
| self.current_drag_pos = Some(logical_point); | |
| let actions = self.get_drag_actions(modifiers); | |
| let mut consumed = false; | |
| for action in actions { | |
| match action { | |
| Action::Pan(sensitivity) => { | |
| // Handle panning directly | |
| if let Some(last_pos) = self.last_drag_pos { | |
| let delta = | |
| (logical_point - last_pos).component_mul(sensitivity); | |
| self.transform = Affine::translate(delta) * self.transform; | |
| } | |
| consumed = true; | |
| } | |
| Action::OverscrollPan(sensitivity) => { | |
| // Handle overscroll if bounds provided | |
| if let Some(bounds) = bounds | |
| && let Some(overscroll) = | |
| Self::calculate_overscroll(logical_point, bounds) | |
| { | |
| let pan_amount = overscroll.component_mul(sensitivity); | |
| self.transform = | |
| Affine::translate(-pan_amount) * self.transform; | |
| consumed = true; | |
| } | |
| } | |
| Action::Custom(callback) => { | |
| callback(pe, &mut self.transform); | |
| consumed = true; | |
| } | |
| Action::CustomRotation(callback) => { | |
| callback(pe, &mut self.transform, &mut self.rotation_tracker); | |
| consumed = true; | |
| } | |
| Action::None => consumed = true, // Track for manual handling | |
| _ => {} | |
| } | |
| } | |
| return if consumed { Some(self.transform) } else { None }; | |
| } | |
| None | |
| } | |
| PE::Up(_) => { | |
| self.is_dragging = false; | |
| self.current_drag_pos = None; | |
| self.last_drag_pos = None; | |
| None | |
| } | |
| PE::Scroll(wheel_event) => { | |
| // Update touch state for device detection | |
| let delta: Vec2 = self.resolve_scroll_delta(wheel_event); | |
| // Determine which axes to process based on behavior flags | |
| let should_process_y = if self.behavior.x_axis_only_when_dominant { | |
| delta.y.abs() >= delta.x.abs() && delta.y != 0.0 | |
| } else { | |
| delta.y != 0.0 | |
| }; | |
| let should_process_x = if self.behavior.y_axis_only_when_dominant { | |
| delta.x.abs() > delta.y.abs() && delta.x != 0.0 | |
| } else { | |
| delta.x != 0.0 | |
| }; | |
| // Handle Y axis | |
| let mut y_consumed = false; | |
| if should_process_y { | |
| let wheel_input = ScrollInput { | |
| device: wheel_event.pointer.pointer_type, | |
| axis: Axis::Vertical, | |
| }; | |
| let actions = self.get_wheel_actions(wheel_event.state.modifiers, wheel_input); | |
| for action in actions { | |
| match action { | |
| Action::Pan(sensitivity) => { | |
| let pan_amount = (-delta).component_mul(sensitivity); | |
| self.transform = Affine::translate(pan_amount) * self.transform; | |
| y_consumed = true; | |
| } | |
| Action::VerticalScroll(sensitivity) => { | |
| let scroll_delta = Vec2::new(0.0, -delta.y * sensitivity); | |
| self.transform = Affine::translate(scroll_delta) * self.transform; | |
| y_consumed = true; | |
| } | |
| Action::HorizontalScroll(sensitivity) => { | |
| let scroll_delta = Vec2::new(-delta.y * sensitivity, 0.0); | |
| self.transform = Affine::translate(scroll_delta) * self.transform; | |
| y_consumed = true; | |
| } | |
| Action::ZoomXY(sensitivity) => { | |
| let center = center_x_fn() | |
| .map_or(wheel_event.state.logical_point(), |cx| { | |
| Point::new(cx, wheel_event.state.logical_point().y) | |
| }); | |
| let wheel_factor = -delta.y * sensitivity.y; | |
| let scale_factor = if wheel_factor > 0.0 { | |
| 1.0 + wheel_factor.min(0.5) | |
| } else { | |
| 1.0 / (1.0 + (-wheel_factor).min(0.5)) | |
| }; | |
| let old_translation = self.transform.translation(); | |
| let (scale_factor, was_clamped_to_no_op) = | |
| self.clamp_scale_factor(scale_factor, min_scale, max_scale); | |
| if !was_clamped_to_no_op { | |
| self.transform = | |
| Affine::scale_about(scale_factor, center) * self.transform; | |
| } | |
| if self.behavior.zoom_suppress_y_translation { | |
| let new_translation = self.transform.translation(); | |
| self.transform = self.transform.with_translation(Vec2::new( | |
| new_translation.x, | |
| old_translation.y, | |
| )); | |
| } | |
| if self.behavior.zoom_suppress_x_translation { | |
| let new_translation = self.transform.translation(); | |
| self.transform = self.transform.with_translation(Vec2::new( | |
| old_translation.x, | |
| new_translation.y, | |
| )); | |
| } | |
| y_consumed = true; | |
| } | |
| Action::ZoomX(sensitivity) => { | |
| let center = center_x_fn() | |
| .map_or(wheel_event.state.logical_point(), |cx| { | |
| Point::new(cx, wheel_event.state.logical_point().y) | |
| }); | |
| let wheel_factor = -delta.y * sensitivity; | |
| let scale_factor = if wheel_factor > 0.0 { | |
| 1.0 + wheel_factor.min(0.5) | |
| } else { | |
| 1.0 / (1.0 + (-wheel_factor).min(0.5)) | |
| }; | |
| let old_translation = self.transform.translation(); | |
| let (scale_factor, was_clamped_to_no_op) = | |
| self.clamp_scale_factor(scale_factor, min_scale, max_scale); | |
| if !was_clamped_to_no_op { | |
| let scale_transform = | |
| Affine::scale_non_uniform(scale_factor, 1.0); | |
| self.transform = Affine::translate(center.to_vec2()) | |
| * scale_transform | |
| * Affine::translate(-center.to_vec2()) | |
| * self.transform; | |
| } | |
| if self.behavior.zoom_suppress_x_translation { | |
| let new_translation = self.transform.translation(); | |
| self.transform = self.transform.with_translation(Vec2::new( | |
| old_translation.x, | |
| new_translation.y, | |
| )); | |
| } | |
| y_consumed = true; | |
| } | |
| Action::ZoomY(sensitivity) => { | |
| let center = center_x_fn() | |
| .map_or(wheel_event.state.logical_point(), |cx| { | |
| Point::new(cx, wheel_event.state.logical_point().y) | |
| }); | |
| let wheel_factor = -delta.y * sensitivity; | |
| let scale_factor = if wheel_factor > 0.0 { | |
| 1.0 + wheel_factor.min(0.5) | |
| } else { | |
| 1.0 / (1.0 + (-wheel_factor).min(0.5)) | |
| }; | |
| let old_translation = self.transform.translation(); | |
| let (scale_factor, was_clamped_to_no_op) = | |
| self.clamp_scale_factor(scale_factor, min_scale, max_scale); | |
| if !was_clamped_to_no_op { | |
| let scale_transform = | |
| Affine::scale_non_uniform(1.0, scale_factor); | |
| self.transform = Affine::translate(center.to_vec2()) | |
| * scale_transform | |
| * Affine::translate(-center.to_vec2()) | |
| * self.transform; | |
| } | |
| if self.behavior.zoom_suppress_y_translation { | |
| let new_translation = self.transform.translation(); | |
| self.transform = self.transform.with_translation(Vec2::new( | |
| new_translation.x, | |
| old_translation.y, | |
| )); | |
| } | |
| y_consumed = true; | |
| } | |
| Action::Custom(callback) => { | |
| callback(pe, &mut self.transform); | |
| y_consumed = true; | |
| } | |
| Action::CustomRotation(callback) => { | |
| callback(pe, &mut self.transform, &mut self.rotation_tracker); | |
| y_consumed = true; | |
| } | |
| _ => {} | |
| } | |
| } | |
| } | |
| // Handle X axis | |
| let mut x_consumed = false; | |
| if should_process_x { | |
| let wheel_input = ScrollInput { | |
| device: wheel_event.pointer.pointer_type, | |
| axis: Axis::Horizontal, | |
| }; | |
| let actions = self.get_wheel_actions(wheel_event.state.modifiers, wheel_input); | |
| for action in actions { | |
| match action { | |
| Action::Pan(sensitivity) => { | |
| let scroll_delta = Vec2::new(-delta.x * sensitivity.x, 0.0); | |
| self.transform = Affine::translate(scroll_delta) * self.transform; | |
| x_consumed = true; | |
| } | |
| Action::HorizontalScroll(sensitivity) => { | |
| let scroll_delta = Vec2::new(-delta.x * sensitivity, 0.0); | |
| self.transform = Affine::translate(scroll_delta) * self.transform; | |
| x_consumed = true; | |
| } | |
| Action::Custom(callback) => { | |
| callback(pe, &mut self.transform); | |
| x_consumed = true; | |
| } | |
| Action::CustomRotation(callback) => { | |
| callback(pe, &mut self.transform, &mut self.rotation_tracker); | |
| x_consumed = true; | |
| } | |
| _ => {} | |
| } | |
| } | |
| } | |
| if x_consumed || y_consumed { | |
| Some(self.transform) | |
| } else { | |
| None | |
| } | |
| } | |
| PE::Gesture(gesture_event) => { | |
| match &gesture_event.gesture { | |
| PointerGesture::Pinch(delta) => { | |
| if *delta != 0.0 { | |
| let actions = self.get_pinch_actions(gesture_event.state.modifiers); | |
| let mut consumed = false; | |
| for action in actions { | |
| match action { | |
| Action::ZoomXY(sensitivity) => { | |
| let scale_factor = 1.0 + f64::from(*delta) * sensitivity.y; | |
| let (scale_factor, was_clamped_to_no_op) = self | |
| .clamp_scale_factor(scale_factor, min_scale, max_scale); | |
| let center = center_x_fn().map_or( | |
| gesture_event.state.logical_point(), | |
| |cx| { | |
| Point::new( | |
| cx, | |
| gesture_event.state.logical_point().y, | |
| ) | |
| }, | |
| ); | |
| let old_translation = self.transform.translation(); | |
| if !was_clamped_to_no_op { | |
| self.transform = | |
| Affine::scale_about(scale_factor, center) | |
| * self.transform; | |
| } | |
| if self.behavior.zoom_suppress_y_translation { | |
| let new_translation = self.transform.translation(); | |
| self.transform = self.transform.with_translation( | |
| Vec2::new(new_translation.x, old_translation.y), | |
| ); | |
| } | |
| if self.behavior.zoom_suppress_x_translation { | |
| let new_translation = self.transform.translation(); | |
| self.transform = self.transform.with_translation( | |
| Vec2::new(old_translation.x, new_translation.y), | |
| ); | |
| } | |
| consumed = true; | |
| } | |
| Action::ZoomX(sensitivity) => { | |
| let scale_factor = 1.0 + f64::from(*delta) * sensitivity; | |
| let (scale_factor, was_clamped_to_no_op) = self | |
| .clamp_scale_factor(scale_factor, min_scale, max_scale); | |
| let center = center_x_fn().map_or( | |
| gesture_event.state.logical_point(), | |
| |cx| { | |
| Point::new( | |
| cx, | |
| gesture_event.state.logical_point().y, | |
| ) | |
| }, | |
| ); | |
| let old_translation = self.transform.translation(); | |
| if !was_clamped_to_no_op { | |
| let scale_transform = | |
| Affine::scale_non_uniform(scale_factor, 1.0); | |
| self.transform = Affine::translate(center.to_vec2()) | |
| * scale_transform | |
| * Affine::translate(-center.to_vec2()) | |
| * self.transform; | |
| } | |
| if self.behavior.zoom_suppress_x_translation { | |
| let new_translation = self.transform.translation(); | |
| self.transform = self.transform.with_translation( | |
| Vec2::new(old_translation.x, new_translation.y), | |
| ); | |
| } | |
| consumed = true; | |
| } | |
| Action::ZoomY(sensitivity) => { | |
| let scale_factor = 1.0 + f64::from(*delta) * sensitivity; | |
| let (scale_factor, was_clamped_to_no_op) = self | |
| .clamp_scale_factor(scale_factor, min_scale, max_scale); | |
| let center = center_x_fn().map_or( | |
| gesture_event.state.logical_point(), | |
| |cx| { | |
| Point::new( | |
| cx, | |
| gesture_event.state.logical_point().y, | |
| ) | |
| }, | |
| ); | |
| let old_translation = self.transform.translation(); | |
| if !was_clamped_to_no_op { | |
| let scale_transform = | |
| Affine::scale_non_uniform(1.0, scale_factor); | |
| self.transform = Affine::translate(center.to_vec2()) | |
| * scale_transform | |
| * Affine::translate(-center.to_vec2()) | |
| * self.transform; | |
| } | |
| if self.behavior.zoom_suppress_y_translation { | |
| let new_translation = self.transform.translation(); | |
| self.transform = self.transform.with_translation( | |
| Vec2::new(new_translation.x, old_translation.y), | |
| ); | |
| } | |
| consumed = true; | |
| } | |
| Action::Pan(sensitivity) => { | |
| let pan_amount = | |
| Vec2::new(f64::from(*delta), f64::from(*delta)) | |
| .component_mul(sensitivity); | |
| self.transform = | |
| Affine::translate(pan_amount) * self.transform; | |
| consumed = true; | |
| } | |
| Action::Custom(callback) => { | |
| callback(pe, &mut self.transform); | |
| consumed = true; | |
| } | |
| Action::CustomRotation(callback) => { | |
| callback( | |
| pe, | |
| &mut self.transform, | |
| &mut self.rotation_tracker, | |
| ); | |
| consumed = true; | |
| } | |
| Action::None => { | |
| consumed = true; | |
| } | |
| _ => {} | |
| } | |
| } | |
| if consumed { | |
| return Some(self.transform); | |
| } | |
| } | |
| } | |
| PointerGesture::Rotate(delta) => { | |
| let actions = self.get_rotation_actions(gesture_event.state.modifiers); | |
| let mut consumed = false; | |
| for action in actions { | |
| match action { | |
| Action::Rotate(sensitivity) => { | |
| let rotation_radians = f64::from(-delta) * sensitivity; | |
| let center = center_x_fn() | |
| .map_or(gesture_event.state.logical_point(), |cx| { | |
| Point::new(cx, gesture_event.state.logical_point().y) | |
| }); | |
| let rotate = Affine::rotate_about(rotation_radians, center); | |
| self.transform = rotate * self.transform; | |
| consumed = true; | |
| } | |
| Action::Custom(callback) => { | |
| callback(pe, &mut self.transform); | |
| consumed = true; | |
| } | |
| Action::CustomRotation(callback) => { | |
| callback(pe, &mut self.transform, &mut self.rotation_tracker); | |
| consumed = true; | |
| } | |
| Action::None => { | |
| consumed = true; | |
| } | |
| _ => {} | |
| } | |
| } | |
| if consumed { | |
| return Some(self.transform); | |
| } | |
| } | |
| } | |
| None | |
| } | |
| _ => None, | |
| } | |
| } | |
| fn resolve_scroll_delta(&self, scroll_event: &PointerScrollEvent) -> Vec2 { | |
| match &scroll_event.delta { | |
| ScrollDelta::PageDelta(x, y) => match self.page_size { | |
| Some(Size { width, height }) => { | |
| Vec2::new(f64::from(*x) * width, f64::from(*y) * height) | |
| } | |
| None => Vec2::new(f64::from(*x) * 800.0, f64::from(*y) * 600.0), // fallback page size | |
| }, | |
| ScrollDelta::LineDelta(x, y) => match self.line_size { | |
| Some(Size { width, height }) => { | |
| Vec2::new(f64::from(*x) * width, f64::from(*y) * height) | |
| } | |
| None => Vec2::new(f64::from(*x) * 20.0, f64::from(*y) * 20.0), // fallback line height | |
| }, | |
| ScrollDelta::PixelDelta(physical_position) => { | |
| let logical = physical_position.to_logical(scroll_event.state.scale_factor); | |
| Vec2::new(logical.x, logical.y) | |
| } | |
| } | |
| } | |
| } | |
| /// Configuration for value snapping with dead zone support. | |
| /// | |
| /// A snap target defines a value that other values can "snap" to if they are within | |
| /// a certain threshold distance. This is commonly used for alignment, grid snapping, | |
| /// or magnetic behavior in UI interactions. | |
| /// | |
| /// # Example | |
| /// | |
| /// ```rust | |
| /// use transform_encoder::{SnapTarget, apply_snapping}; | |
| /// | |
| /// let targets = vec![ | |
| /// SnapTarget::new(0.0, 0.1), // Snap to 0 if within 0.1 | |
| /// SnapTarget::new(1.0, 0.1), // Snap to 1 if within 0.1 | |
| /// SnapTarget::new(2.0, 0.15), // Snap to 2 if within 0.15 | |
| /// ]; | |
| /// | |
| /// assert_eq!(apply_snapping(0.05, targets.clone()), 0.0); // Snaps to 0 | |
| /// assert_eq!(apply_snapping(0.5, targets.clone()), 0.5); // No snap | |
| /// assert_eq!(apply_snapping(1.08, targets.clone()), 1.0); // Snaps to 1 | |
| /// ``` | |
| #[derive(Debug, Clone)] | |
| pub struct SnapTarget { | |
| /// The value to snap to | |
| pub target: f64, | |
| /// The maximum distance from target where snapping occurs | |
| pub threshold: f64, | |
| } | |
| impl SnapTarget { | |
| /// Create a new snap target with the specified value and threshold. | |
| /// | |
| /// # Arguments | |
| /// | |
| /// * `target` - The value that other values can snap to | |
| /// * `threshold` - The maximum distance from target where snapping occurs | |
| pub fn new(target: f64, threshold: f64) -> Self { | |
| Self { target, threshold } | |
| } | |
| } | |
| /// Apply snapping logic to a value using the provided snap targets. | |
| /// | |
| /// This function evaluates the given value against all snap targets and returns the target | |
| /// value of the closest snap target if the input value falls within that target's threshold. | |
| /// If multiple targets are within range, the closest one is selected. If no targets are | |
| /// within range, the original value is returned unchanged. | |
| /// | |
| /// # Arguments | |
| /// | |
| /// * `value` - The input value to potentially snap | |
| /// * `snap_targets` - An iterable collection of snap target configurations | |
| /// | |
| /// # Returns | |
| /// | |
| /// Either the snapped target value or the original value if no snapping occurred. | |
| /// | |
| /// # Example | |
| /// | |
| /// ```rust | |
| /// use transform_encoder::{SnapTarget, apply_snapping}; | |
| /// | |
| /// let targets = vec![ | |
| /// SnapTarget::new(0.0, 0.1), | |
| /// SnapTarget::new(1.0, 0.1), | |
| /// ]; | |
| /// | |
| /// assert_eq!(apply_snapping(0.05, targets.iter().cloned()), 0.0); | |
| /// assert_eq!(apply_snapping(0.5, targets.iter().cloned()), 0.5); | |
| /// ``` | |
| pub fn apply_snapping(value: f64, snap_targets: impl IntoIterator<Item = SnapTarget>) -> f64 { | |
| let mut best_target = None; | |
| let mut best_distance = f64::INFINITY; | |
| for snap_target in snap_targets { | |
| let distance = (value - snap_target.target).abs(); | |
| if distance <= snap_target.threshold && distance < best_distance { | |
| best_target = Some(snap_target.target); | |
| best_distance = distance; | |
| } | |
| } | |
| best_target.unwrap_or(value) | |
| } | |
| /// Apply snapping logic using pre-sorted snap targets for improved performance. | |
| /// | |
| /// This is an optimized version of [`apply_snapping`] that uses binary search to efficiently | |
| /// find the closest snap targets when dealing with large numbers of snap targets. The input | |
| /// slice must be sorted by the `target` field in ascending order. | |
| /// | |
| /// # Arguments | |
| /// | |
| /// * `value` - The input value to potentially snap | |
| /// * `snap_targets` - A slice of snap targets sorted by target value (ascending) | |
| /// | |
| /// # Returns | |
| /// | |
| /// Either the snapped target value or the original value if no snapping occurred. | |
| /// | |
| /// # Panics | |
| /// | |
| /// This function will not panic, but if the input is not properly sorted, the results | |
| /// may be incorrect. | |
| /// | |
| /// # Example | |
| /// | |
| /// ```rust | |
| /// use transform_encoder::{SnapTarget, apply_snapping_sorted}; | |
| /// | |
| /// let mut targets = vec![ | |
| /// SnapTarget::new(2.0, 0.1), | |
| /// SnapTarget::new(0.0, 0.1), | |
| /// SnapTarget::new(1.0, 0.1), | |
| /// ]; | |
| /// targets.sort_by(|a, b| a.target.partial_cmp(&b.target).unwrap()); | |
| /// | |
| /// assert_eq!(apply_snapping_sorted(0.05, &targets), 0.0); | |
| /// assert_eq!(apply_snapping_sorted(0.5, &targets), 0.5); | |
| /// ``` | |
| pub fn apply_snapping_sorted(value: f64, snap_targets: &[SnapTarget]) -> f64 { | |
| if snap_targets.is_empty() { | |
| return value; | |
| } | |
| // Binary search by target value | |
| let idx = match snap_targets.binary_search_by(|t| { | |
| t.target | |
| .partial_cmp(&value) | |
| .unwrap_or(std::cmp::Ordering::Equal) | |
| }) { | |
| Ok(exact_idx) => exact_idx, | |
| Err(insert_idx) => insert_idx, | |
| }; | |
| let mut best_target = None; | |
| let mut best_distance = f64::INFINITY; | |
| // Check the target at the found index | |
| if idx < snap_targets.len() { | |
| let distance = (value - snap_targets[idx].target).abs(); | |
| if distance <= snap_targets[idx].threshold { | |
| best_target = Some(snap_targets[idx].target); | |
| best_distance = distance; | |
| } | |
| } | |
| // Check the target before | |
| if idx > 0 { | |
| let distance = (value - snap_targets[idx - 1].target).abs(); | |
| if distance <= snap_targets[idx - 1].threshold && distance < best_distance { | |
| best_target = Some(snap_targets[idx - 1].target); | |
| } | |
| } | |
| best_target.unwrap_or(value) | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| use dpi::PhysicalPosition; | |
| use ui_events::pointer::{ | |
| PointerButton, PointerButtonEvent, PointerEvent, PointerGesture, PointerGestureEvent, | |
| PointerInfo, PointerScrollEvent, PointerState, PointerType, PointerUpdate, | |
| }; | |
| fn create_pointer_down(x: f64, y: f64) -> PointerEvent { | |
| PointerEvent::Down(PointerButtonEvent { | |
| state: PointerState { | |
| position: PhysicalPosition::new(x, y), | |
| scale_factor: 1.0, | |
| modifiers: Modifiers::empty(), | |
| count: 1, | |
| ..Default::default() | |
| }, | |
| button: Some(PointerButton::Primary), | |
| pointer: PointerInfo { | |
| pointer_id: None, | |
| persistent_device_id: None, | |
| pointer_type: PointerType::Mouse, | |
| }, | |
| }) | |
| } | |
| fn create_pointer_move(x: f64, y: f64) -> PointerEvent { | |
| PointerEvent::Move(PointerUpdate { | |
| pointer: PointerInfo { | |
| pointer_id: None, | |
| persistent_device_id: None, | |
| pointer_type: PointerType::Mouse, | |
| }, | |
| current: PointerState { | |
| position: PhysicalPosition::new(x, y), | |
| scale_factor: 1.0, | |
| modifiers: Modifiers::empty(), | |
| count: 1, | |
| ..Default::default() | |
| }, | |
| coalesced: Vec::new(), | |
| predicted: Vec::new(), | |
| }) | |
| } | |
| fn create_pointer_up(x: f64, y: f64) -> PointerEvent { | |
| PointerEvent::Up(PointerButtonEvent { | |
| state: PointerState { | |
| position: PhysicalPosition::new(x, y), | |
| scale_factor: 1.0, | |
| modifiers: Modifiers::empty(), | |
| count: 1, | |
| ..Default::default() | |
| }, | |
| button: Some(PointerButton::Primary), | |
| pointer: PointerInfo { | |
| pointer_id: None, | |
| persistent_device_id: None, | |
| pointer_type: PointerType::Mouse, | |
| }, | |
| }) | |
| } | |
| fn create_scroll_event(delta_x: f64, delta_y: f64, device: PointerType) -> PointerEvent { | |
| PointerEvent::Scroll(PointerScrollEvent { | |
| state: PointerState { | |
| position: PhysicalPosition::new(100.0, 100.0), | |
| scale_factor: 1.0, | |
| modifiers: Modifiers::empty(), | |
| count: 1, | |
| ..Default::default() | |
| }, | |
| delta: ui_events::ScrollDelta::PixelDelta(PhysicalPosition::new(delta_x, delta_y)), | |
| pointer: PointerInfo { | |
| pointer_id: None, | |
| persistent_device_id: None, | |
| pointer_type: device, | |
| }, | |
| }) | |
| } | |
| fn create_pinch_event(delta: f32) -> PointerEvent { | |
| PointerEvent::Gesture(PointerGestureEvent { | |
| state: PointerState { | |
| position: PhysicalPosition::new(100.0, 100.0), | |
| scale_factor: 1.0, | |
| modifiers: Modifiers::empty(), | |
| count: 2, | |
| ..Default::default() | |
| }, | |
| gesture: PointerGesture::Pinch(delta), | |
| pointer: PointerInfo { | |
| pointer_id: None, | |
| persistent_device_id: None, | |
| pointer_type: PointerType::Touch, | |
| }, | |
| }) | |
| } | |
| #[test] | |
| fn test_mouse_drag_pan_sequence() { | |
| let behavior = Behavior::new().drag(|m| m.is_empty(), Action::Pan(Vec2::new(1.0, 1.0))); | |
| let mut state = Encoder::new() | |
| .with_behavior(behavior) | |
| .with_transform(Affine::IDENTITY); | |
| // Pointer down at (10, 10) | |
| let down_event = create_pointer_down(10.0, 10.0); | |
| let transform = state.encode(&down_event, || None, None, None, None); | |
| assert_eq!(transform, Some(Affine::IDENTITY)); // No movement yet | |
| // Move to (30, 40) - should pan by (20, 30) | |
| let move_event = create_pointer_move(30.0, 40.0); | |
| let transform = state.encode(&move_event, || None, None, None, None); | |
| let expected = Affine::translate(Vec2::new(20.0, 30.0)); | |
| assert_eq!(transform, Some(expected)); | |
| // Move to (50, 60) - should pan by additional (20, 20) | |
| let move_event = create_pointer_move(50.0, 60.0); | |
| let transform = state.encode(&move_event, || None, None, None, None); | |
| let expected = Affine::translate(Vec2::new(40.0, 50.0)); | |
| assert_eq!(transform, Some(expected)); | |
| // Pointer up - should stop dragging | |
| let up_event = create_pointer_up(50.0, 60.0); | |
| let transform = state.encode(&up_event, || None, None, None, None); | |
| assert_eq!(transform, None); | |
| assert!(!state.is_dragging); | |
| } | |
| #[test] | |
| fn test_mouse_scroll_zoom_sequence() { | |
| let behavior = Behavior::new() | |
| .mouse_scroll_vertical(|m| m.is_empty(), Action::ZoomXY(Vec2::new(0.1, 0.1))); | |
| let mut state = Encoder::new() | |
| .with_behavior(behavior) | |
| .with_transform(Affine::IDENTITY); | |
| // Scroll up (negative delta for zoom in) | |
| let scroll_event = create_scroll_event(0.0, -10.0, PointerType::Mouse); | |
| let transform = state.encode(&scroll_event, || Some(100.0), None, None, None); | |
| // Check that we got a scale transform around the center point | |
| let transform = transform.unwrap(); | |
| let scale = transform.as_coeffs()[0]; // Get X scale factor | |
| assert!(scale > 1.0); // Should be zoomed in | |
| assert!(scale < 2.0); // But not too much | |
| // Scroll down (positive delta for zoom out) | |
| let scroll_event = create_scroll_event(0.0, 10.0, PointerType::Mouse); | |
| let new_transform = state.encode(&scroll_event, || Some(100.0), None, None, None); | |
| let new_transform = new_transform.unwrap(); | |
| let new_scale = new_transform.as_coeffs()[0]; | |
| assert!(new_scale < scale); // Should be zoomed out from previous | |
| } | |
| #[test] | |
| fn test_trackpad_pan_vs_mouse_zoom() { | |
| let behavior = Behavior::new() | |
| .mouse_scroll_vertical(|m| m.is_empty(), Action::ZoomXY(Vec2::new(0.1, 0.1))) | |
| .touch_vertical(|m| m.is_empty(), Action::Pan(Vec2::new(1.0, 1.0))); | |
| let mut state = Encoder::new() | |
| .with_behavior(behavior) | |
| .with_transform(Affine::IDENTITY); | |
| // Mouse wheel should zoom (negative delta for zoom in) | |
| let mouse_scroll = create_scroll_event(0.0, -10.0, PointerType::Mouse); | |
| let mouse_transform = state.encode(&mouse_scroll, || Some(100.0), None, None, None); | |
| let mouse_scale = mouse_transform.unwrap().as_coeffs()[0]; | |
| assert!(mouse_scale > 1.0); // Zoomed in | |
| // Reset state | |
| state.transform = Affine::IDENTITY; | |
| // Trackpad scroll should pan | |
| let trackpad_scroll = create_scroll_event(0.0, 10.0, PointerType::Touch); | |
| let trackpad_transform = state.encode(&trackpad_scroll, || Some(100.0), None, None, None); | |
| let trackpad_translation = trackpad_transform.unwrap().translation(); | |
| assert_eq!(trackpad_translation.y, -10.0); // Panned up | |
| assert_eq!(trackpad_transform.unwrap().as_coeffs()[0], 1.0); // No scale change | |
| } | |
| #[test] | |
| fn test_pinch_zoom_gesture() { | |
| let behavior = Behavior::new().pinch(|_| true, Action::ZoomXY(Vec2::new(2.0, 2.0))); | |
| let mut state = Encoder::new() | |
| .with_behavior(behavior) | |
| .with_transform(Affine::IDENTITY); | |
| // Pinch in (zoom out) | |
| let pinch_event = create_pinch_event(-0.1); | |
| let transform = state.encode(&pinch_event, || Some(100.0), None, None, None); | |
| let transform = transform.unwrap(); | |
| let scale = transform.as_coeffs()[0]; | |
| assert!(scale < 1.0); // Should be zoomed out | |
| // Pinch out (zoom in) | |
| let pinch_event = create_pinch_event(0.2); | |
| let new_transform = state.encode(&pinch_event, || Some(100.0), None, None, None); | |
| let new_transform = new_transform.unwrap(); | |
| let new_scale = new_transform.as_coeffs()[0]; | |
| assert!(new_scale > scale); // Should be more zoomed in | |
| } | |
| #[test] | |
| fn test_modifier_key_behavior() { | |
| let behavior = Behavior::new() | |
| .drag(|m| m.is_empty(), Action::Pan(Vec2::new(1.0, 1.0))) | |
| .drag( | |
| |m| m.contains(Modifiers::CONTROL), | |
| Action::ZoomXY(Vec2::new(0.1, 0.1)), | |
| ); | |
| let mut state = Encoder::new() | |
| .with_behavior(behavior) | |
| .with_transform(Affine::IDENTITY); | |
| // Normal drag without modifiers should pan | |
| let down_event = PointerEvent::Down(PointerButtonEvent { | |
| state: PointerState { | |
| position: PhysicalPosition::new(10.0, 10.0), | |
| scale_factor: 1.0, | |
| modifiers: Modifiers::empty(), | |
| count: 1, | |
| ..Default::default() | |
| }, | |
| button: Some(PointerButton::Primary), | |
| pointer: PointerInfo { | |
| pointer_id: None, | |
| persistent_device_id: None, | |
| pointer_type: PointerType::Mouse, | |
| }, | |
| }); | |
| let move_event = PointerEvent::Move(PointerUpdate { | |
| pointer: PointerInfo { | |
| pointer_id: None, | |
| persistent_device_id: None, | |
| pointer_type: PointerType::Mouse, | |
| }, | |
| current: PointerState { | |
| position: PhysicalPosition::new(30.0, 30.0), | |
| scale_factor: 1.0, | |
| modifiers: Modifiers::empty(), | |
| count: 1, | |
| ..Default::default() | |
| }, | |
| coalesced: Vec::new(), | |
| predicted: Vec::new(), | |
| }); | |
| state.encode(&down_event, || None, None, None, None); | |
| let transform = state.encode(&move_event, || None, None, None, None); | |
| let translation = transform.unwrap().translation(); | |
| assert_eq!(translation, Vec2::new(20.0, 20.0)); | |
| assert_eq!(transform.unwrap().as_coeffs()[0], 1.0); // No scale change | |
| } | |
| #[test] | |
| fn test_pen_input_handling() { | |
| let behavior = | |
| Behavior::new().pen_vertical(|m| m.is_empty(), Action::Pan(Vec2::new(1.0, 1.0))); | |
| let mut state = Encoder::new() | |
| .with_behavior(behavior) | |
| .with_transform(Affine::IDENTITY); | |
| // Pen scroll should trigger pan | |
| let pen_scroll = create_scroll_event(0.0, 15.0, PointerType::Pen); | |
| let transform = state.encode(&pen_scroll, || None, None, None, None); | |
| let translation = transform.unwrap().translation(); | |
| assert_eq!(translation.y, -15.0); // Panned | |
| } | |
| #[test] | |
| fn test_scale_clamping() { | |
| let behavior = Behavior::new() | |
| .mouse_scroll_vertical(|m| m.is_empty(), Action::ZoomXY(Vec2::new(1.0, 1.0))); | |
| let mut state = Encoder::new() | |
| .with_behavior(behavior) | |
| .with_transform(Affine::IDENTITY); | |
| // Try to zoom out beyond minimum scale | |
| let scroll_event = create_scroll_event(0.0, -100.0, PointerType::Mouse); | |
| let transform = state.encode(&scroll_event, || Some(100.0), Some(0.5), None, None); | |
| let scale = transform.unwrap().as_coeffs()[0]; | |
| assert!(scale >= 0.5); // Should be clamped to minimum | |
| // Reset and try to zoom in beyond maximum scale | |
| state.transform = Affine::scale(2.0); // Start at 2x scale | |
| let scroll_event = create_scroll_event(0.0, 100.0, PointerType::Mouse); | |
| let transform = state.encode(&scroll_event, || Some(100.0), None, Some(3.0), None); | |
| let scale = transform.unwrap().as_coeffs()[0]; | |
| assert!(scale <= 3.0); // Should be clamped to maximum | |
| } | |
| #[test] | |
| fn test_overscroll_behavior() { | |
| let mut state = Encoder::new(); | |
| let bounds = Rect::new(0.0, 0.0, 100.0, 100.0); | |
| let outside_point = Point::new(150.0, 150.0); | |
| let sensitivity = Vec2::new(0.1, 0.1); | |
| let original_transform = state.transform; | |
| let result = state.trigger_overscroll_at_point(outside_point, bounds, sensitivity); | |
| assert!(result); // Should return true for overscroll applied | |
| assert_ne!(state.transform, original_transform); // Transform should change | |
| // Verify the direction of overscroll translation | |
| let translation = state.transform.translation(); | |
| assert!(translation.x > 0.0); // Should translate right | |
| assert!(translation.y > 0.0); // Should translate down | |
| } | |
| #[test] | |
| fn test_scale_clamping_preserves_translation() { | |
| let behavior = Behavior::new() | |
| .mouse_scroll_vertical(|m| m.is_empty(), Action::ZoomXY(Vec2::new(1.0, 1.0))); | |
| let mut state = Encoder::new() | |
| .with_behavior(behavior) | |
| .with_transform(Affine::IDENTITY); | |
| // Start already at the maximum scale with some translation to preserve | |
| state.transform = Affine::translate(Vec2::new(100.0, 50.0)) * Affine::scale(3.0); | |
| let original_translation = state.transform.translation(); | |
| // Try to zoom in beyond maximum scale (should be clamped to no-op) | |
| let large_zoom_event = create_scroll_event(0.0, -10.0, PointerType::Mouse); // Try to zoom in | |
| let transform = state.encode(&large_zoom_event, || Some(200.0), None, Some(3.0), None); | |
| if let Some(clamped_transform) = transform { | |
| let new_translation = clamped_transform.translation(); | |
| let new_scale = clamped_transform.as_coeffs()[0]; | |
| // Scale should remain at maximum | |
| assert!((new_scale - 3.0).abs() < f64::EPSILON); | |
| // Translation should be preserved since we were already at the limit | |
| assert!((new_translation.x - original_translation.x).abs() < f64::EPSILON); | |
| assert!((new_translation.y - original_translation.y).abs() < f64::EPSILON); | |
| } else { | |
| // If no transform is returned, that's also acceptable - the zoom was ignored | |
| } | |
| // Reset and test minimum scale clamping - start already at minimum scale | |
| state.transform = Affine::translate(Vec2::new(-75.0, 200.0)) * Affine::scale(0.5); | |
| let original_translation = state.transform.translation(); | |
| // Try to zoom out beyond minimum scale (should be clamped to no-op) | |
| let large_zoom_out_event = create_scroll_event(0.0, 10.0, PointerType::Mouse); // Try to zoom out | |
| let transform = state.encode(&large_zoom_out_event, || Some(200.0), Some(0.5), None, None); | |
| if let Some(clamped_transform) = transform { | |
| let new_translation = clamped_transform.translation(); | |
| let new_scale = clamped_transform.as_coeffs()[0]; | |
| // Scale should remain at minimum | |
| assert!((new_scale - 0.5).abs() < f64::EPSILON); | |
| // Translation should be preserved since we were already at the limit | |
| assert!((new_translation.x - original_translation.x).abs() < f64::EPSILON); | |
| assert!((new_translation.y - original_translation.y).abs() < f64::EPSILON); | |
| } else { | |
| // If no transform is returned, that's also acceptable - the zoom was ignored | |
| } | |
| } | |
| #[test] | |
| fn test_lazy_center_calculation() { | |
| let behavior = Behavior::new() | |
| .mouse_scroll_vertical(|m| m.is_empty(), Action::ZoomXY(Vec2::new(0.1, 0.1))) | |
| .drag(|m| m.is_empty(), Action::Pan(Vec2::new(1.0, 1.0))); | |
| let mut state = Encoder::new() | |
| .with_behavior(behavior) | |
| .with_transform(Affine::IDENTITY); | |
| let mut center_called = false; | |
| // Test that center function is NOT called for pan operations | |
| let drag_event = create_pointer_down(10.0, 10.0); | |
| state.encode( | |
| &drag_event, | |
| || { | |
| center_called = true; | |
| Some(100.0) | |
| }, | |
| None, | |
| None, | |
| None, | |
| ); | |
| assert!( | |
| !center_called, | |
| "Center function should not be called for pan operations" | |
| ); | |
| // Test that center function IS called for zoom operations | |
| center_called = false; | |
| let zoom_event = create_scroll_event(0.0, -10.0, PointerType::Mouse); | |
| state.encode( | |
| &zoom_event, | |
| || { | |
| center_called = true; | |
| Some(100.0) | |
| }, | |
| None, | |
| None, | |
| None, | |
| ); | |
| assert!( | |
| center_called, | |
| "Center function should be called for zoom operations" | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment