Skip to content

Instantly share code, notes, and snippets.

@jrmoulton
Created December 4, 2025 10:34
Show Gist options
  • Select an option

  • Save jrmoulton/9e2c8bd51b8e34c4ab31b16078722e8b to your computer and use it in GitHub Desktop.

Select an option

Save jrmoulton/9e2c8bd51b8e34c4ab31b16078722e8b to your computer and use it in GitHub Desktop.
Transform EnCoder
// 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