Skip to main content

azul_layout/
scroll_timer.rs

1//! Scroll physics timer callback — the core of the timer-based scroll architecture.
2//!
3//! This module implements the scroll physics as a regular timer callback, using
4//! the same transactional `push_change(CallbackChange::ScrollTo)` pattern as all
5//! other state modifications. There is nothing special about the scroll timer —
6//! it is a normal user-space timer that happens to be started by the framework.
7//!
8//! # Architecture
9//!
10//! ```text
11//! Platform Event Handler
12//!   → ScrollManager.record_scroll_input(ScrollInput)
13//!   → starts SCROLL_MOMENTUM_TIMER if not running
14//!
15//! Timer fires (every timer_interval_ms from ScrollPhysics):
16//!   1. queue.take_recent(100) — consume up to 100 most recent inputs
17//!   2. For each input:
18//!      - TrackpadContinuous → set offset directly (OS handles momentum)
19//!      - WheelDiscrete → add impulse to velocity
20//!      - Programmatic → set target position
21//!   3. Integrate physics: velocity decay, clamping
22//!   4. push_change(CallbackChange::ScrollTo) for each updated node
23//!   5. Return continue_and_update() or terminate_unchanged()
24//! ```
25//!
26//! # Key Design Decisions
27//!
28//! - **No mutable access to LayoutWindow needed**: Uses `CallbackChange::ScrollTo`
29//!   (the same transactional pattern as all other callbacks).
30//! - **Shared queue via Arc<Mutex>**: The `ScrollInputQueue` is cloned into the
31//!   timer's `RefAny` data. Event handlers push, timer pops.
32//! - **Platform-independent**: Works on macOS, Windows, Linux — anywhere timers work.
33//! - **Self-terminating**: When all velocities are below threshold and no inputs
34//!   pending, the timer returns `TerminateTimer::Terminate`.
35
36use alloc::collections::BTreeMap;
37
38use azul_core::{
39    callbacks::{TimerCallbackReturn, Update},
40    dom::DomId,
41    geom::LogicalPosition,
42    refany::RefAny,
43    styled_dom::NodeHierarchyItemId,
44    task::TerminateTimer,
45};
46
47use crate::{
48    managers::scroll_state::{ScrollInput, ScrollInputQueue, ScrollInputSource, ScrollNodeInfo},
49    timer::TimerCallbackInfo,
50};
51
52use azul_css::props::style::scrollbar::{ScrollPhysics, OverflowScrolling, OverscrollBehavior};
53
54/// Maximum number of scroll events processed per timer tick.
55/// Older events beyond this limit are discarded to keep the physics
56/// simulation bounded and testable.
57const MAX_SCROLL_EVENTS_PER_TICK: usize = 100;
58
59/// Assumed framerate for converting between per-frame and per-second quantities.
60/// Used both in wheel impulse conversion and friction decay so the two stay coupled.
61const ASSUMED_FPS: f32 = 60.0;
62
63/// State stored in the timer's `RefAny` data.
64///
65/// Contains the shared input queue, per-node velocity state, and the global
66/// scroll physics configuration from `SystemStyle`.
67#[derive(Debug)]
68pub struct ScrollPhysicsState {
69    /// Shared input queue — same Arc as `ScrollManager.scroll_input_queue`
70    pub input_queue: ScrollInputQueue,
71    /// Per-node velocity tracking
72    pub node_velocities: BTreeMap<(DomId, NodeId), NodeScrollPhysics>,
73    /// Per-node "forced position" from programmatic scroll (hard-clamped)
74    pub pending_positions: BTreeMap<(DomId, NodeId), LogicalPosition>,
75    /// Per-node "forced position" from trackpad scroll (rubber-band clamped)
76    pub pending_trackpad_positions: BTreeMap<(DomId, NodeId), LogicalPosition>,
77    /// Global scroll physics configuration (from `SystemStyle`)
78    pub scroll_physics: ScrollPhysics,
79}
80
81/// For convenience, re-export `NodeId`
82use azul_core::id::NodeId;
83
84/// Per-node scroll physics state
85#[derive(Copy, Debug, Clone, Default)]
86pub struct NodeScrollPhysics {
87    /// Current velocity in pixels/second
88    pub velocity: LogicalPosition,
89    /// Whether this node is currently in a rubber-band overshoot state
90    pub is_rubber_banding: bool,
91}
92
93impl ScrollPhysicsState {
94    /// Create a new physics state with the shared input queue and global config
95    #[must_use] pub const fn new(input_queue: ScrollInputQueue, scroll_physics: ScrollPhysics) -> Self {
96        Self {
97            input_queue,
98            node_velocities: BTreeMap::new(),
99            pending_positions: BTreeMap::new(),
100            pending_trackpad_positions: BTreeMap::new(),
101            scroll_physics,
102        }
103    }
104
105    /// Returns true if any node has non-zero velocity or there are pending inputs
106    fn is_active(&self) -> bool {
107        let threshold = self.scroll_physics.min_velocity_threshold;
108        self.input_queue.has_pending()
109            || self.node_velocities.values().any(|v| {
110                v.velocity.x.abs() > threshold
111                    || v.velocity.y.abs() > threshold
112                    || v.is_rubber_banding
113            })
114            || !self.pending_positions.is_empty()
115            || !self.pending_trackpad_positions.is_empty()
116    }
117}
118
119/// The scroll physics timer callback.
120///
121/// This is a normal timer callback registered with `SCROLL_MOMENTUM_TIMER_ID`.
122/// It consumes pending scroll inputs, applies physics, and pushes `ScrollTo` changes.
123///
124/// Uses the `ScrollPhysics` configuration from `SystemStyle` for friction,
125/// velocity thresholds, wheel multiplier, and rubber-banding parameters.
126/// Per-node `OverflowScrolling` and `OverscrollBehavior` CSS properties are
127/// respected to decide whether each node gets rubber-banding.
128///
129/// # C API
130///
131/// This function has `extern "C"` ABI so it can be used as a `TimerCallbackType`.
132#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
133#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/counter/fixed-point cast
134#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
135pub extern "C" fn scroll_physics_timer_callback(
136    mut data: RefAny,
137    mut timer_info: TimerCallbackInfo,
138) -> TimerCallbackReturn {
139    // Downcast the RefAny to our physics state
140    let Some(mut physics) = data.downcast_mut::<ScrollPhysicsState>() else {
141        return TimerCallbackReturn::terminate_unchanged();
142    };
143
144    // Extract physics config values
145    let sp = &physics.scroll_physics;
146    let dt = sp.timer_interval_ms.max(1) as f32 / 1000.0;
147    let friction_rate = friction_from_deceleration(sp.deceleration_rate);
148    let velocity_threshold = sp.min_velocity_threshold;
149    let wheel_multiplier = sp.wheel_multiplier;
150    let max_velocity = sp.max_velocity;
151    let overscroll_elasticity = sp.overscroll_elasticity;
152    let max_overscroll_distance = sp.max_overscroll_distance;
153    let bounce_back_duration_ms = sp.bounce_back_duration_ms;
154
155    // 1. Take at most MAX_SCROLL_EVENTS_PER_TICK recent inputs from the shared queue
156    let inputs = physics.input_queue.take_recent(MAX_SCROLL_EVENTS_PER_TICK);
157
158    for input in inputs {
159        let key = (input.dom_id, input.node_id);
160        match input.source {
161            ScrollInputSource::TrackpadContinuous => {
162                // Trackpad: OS handles momentum. Apply delta directly as position change.
163                let current = timer_info
164                    .get_scroll_node_info(input.dom_id, input.node_id)
165                    .map(|info| info.current_offset)
166                    .unwrap_or_default();
167
168                let new_pos = LogicalPosition {
169                    x: current.x + input.delta.x,
170                    y: current.y + input.delta.y,
171                };
172                physics.pending_trackpad_positions.insert(key, new_pos);
173
174                // Kill any existing velocity for this node (trackpad overrides momentum)
175                physics.node_velocities.remove(&key);
176            }
177            ScrollInputSource::WheelDiscrete => {
178                // Mouse wheel: Convert delta to velocity impulse
179                let node_physics = physics
180                    .node_velocities
181                    .entry(key)
182                    .or_insert_with(NodeScrollPhysics::default);
183
184                // Add impulse (delta is in pixels, convert to pixels/second)
185                node_physics.velocity.x += input.delta.x * wheel_multiplier * ASSUMED_FPS;
186                node_physics.velocity.y += input.delta.y * wheel_multiplier * ASSUMED_FPS;
187
188                // Clamp to max velocity
189                node_physics.velocity.x = node_physics.velocity.x.clamp(-max_velocity, max_velocity);
190                node_physics.velocity.y = node_physics.velocity.y.clamp(-max_velocity, max_velocity);
191            }
192            ScrollInputSource::Programmatic => {
193                // Programmatic: Set position directly
194                let current = timer_info
195                    .get_scroll_node_info(input.dom_id, input.node_id)
196                    .map(|info| info.current_offset)
197                    .unwrap_or_default();
198
199                let new_pos = LogicalPosition {
200                    x: current.x + input.delta.x,
201                    y: current.y + input.delta.y,
202                };
203                physics.pending_positions.insert(key, new_pos);
204            }
205            ScrollInputSource::TrackpadEnd => {
206                // Trackpad gesture ended (fingers lifted).
207                // If the scroll position is past the bounds (rubber-banding overshoot),
208                // start a spring-back animation to snap back to the boundary.
209                let pos = physics.pending_positions.remove(&key)
210                    .or_else(|| timer_info.get_scroll_node_info(input.dom_id, input.node_id)
211                        .map(|info| info.current_offset));
212
213                if let Some(pos) = pos {
214                    if let Some(info) = timer_info.get_scroll_node_info(input.dom_id, input.node_id) {
215                        let overshoot_x = calculate_overshoot(pos.x, 0.0, info.max_scroll_x);
216                        let overshoot_y = calculate_overshoot(pos.y, 0.0, info.max_scroll_y);
217
218                        if overshoot_x.abs() > 0.01 || overshoot_y.abs() > 0.01 {
219                            let node_phys = physics.node_velocities
220                                .entry(key)
221                                .or_insert_with(NodeScrollPhysics::default);
222                            // Zero out velocity — the spring-back force in the
223                            // velocity integration loop (step 2) will pull the
224                            // position back to the boundary.
225                            node_phys.velocity = LogicalPosition::zero();
226                            node_phys.is_rubber_banding = true;
227                        }
228
229                        // Preserve the overshot position for the spring-back animation.
230                        // Must use unclamped so the overshot position is NOT clamped to bounds.
231                        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(input.node_id));
232                        timer_info.scroll_to_unclamped(input.dom_id, hierarchy_id, pos);
233                    }
234                }
235            }
236        }
237    }
238
239    // 2. Integrate velocity physics for wheel-based momentum
240    let mut velocity_updates: Vec<((DomId, NodeId), LogicalPosition)> = Vec::new();
241    // Residual momentum from nodes that hit their boundary this tick, to be
242    // transferred up the scroll chain after the iteration (can't mutate
243    // node_velocities mid-loop).
244    let mut momentum_handoffs: Vec<((DomId, NodeId), LogicalPosition)> = Vec::new();
245
246    for ((dom_id, node_id), node_physics) in &mut physics.node_velocities {
247        // Get current scroll info for clamping and per-node CSS config
248        let Some(info) = timer_info.get_scroll_node_info(*dom_id, *node_id) else {
249            continue;
250        };
251
252        // Determine if this node allows rubber-banding
253        let rubber_band_x = node_allows_rubber_band(info.max_scroll_x, info.overscroll_behavior_x, info.overflow_scrolling, overscroll_elasticity);
254        let rubber_band_y = node_allows_rubber_band(info.max_scroll_y, info.overscroll_behavior_y, info.overflow_scrolling, overscroll_elasticity);
255
256        // Calculate current overshoot amounts
257        let overshoot_x = calculate_overshoot(info.current_offset.x, 0.0, info.max_scroll_x);
258        let overshoot_y = calculate_overshoot(info.current_offset.y, 0.0, info.max_scroll_y);
259
260        let is_overshooting_x = overshoot_x.abs() > 0.01;
261        let is_overshooting_y = overshoot_y.abs() > 0.01;
262
263        // If we're in a rubber-band overshoot, apply critically-damped spring force.
264        // F = -k*x - c*v  where c = 2*sqrt(k) for critical damping (no oscillation).
265        if is_overshooting_x && rubber_band_x {
266            let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
267            let damping = 2.0 * spring_k.sqrt(); // critical damping coefficient
268            let spring_force_x = -spring_k * overshoot_x - damping * node_physics.velocity.x;
269            node_physics.velocity.x += spring_force_x * dt;
270            node_physics.is_rubber_banding = true;
271        }
272        if is_overshooting_y && rubber_band_y {
273            let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
274            let damping = 2.0 * spring_k.sqrt(); // critical damping coefficient
275            let spring_force_y = -spring_k * overshoot_y - damping * node_physics.velocity.y;
276            node_physics.velocity.y += spring_force_y * dt;
277            node_physics.is_rubber_banding = true;
278        }
279
280        // Skip if velocity is negligible and not rubber-banding
281        if !node_physics.is_rubber_banding
282            && node_physics.velocity.x.abs() < velocity_threshold
283            && node_physics.velocity.y.abs() < velocity_threshold
284        {
285            node_physics.velocity = LogicalPosition::zero();
286            continue;
287        }
288
289        // Apply velocity to position
290        let displacement = LogicalPosition {
291            x: node_physics.velocity.x * dt,
292            y: node_physics.velocity.y * dt,
293        };
294
295        let raw_new_x = info.current_offset.x + displacement.x;
296        let raw_new_y = info.current_offset.y + displacement.y;
297
298        // Clamp with or without rubber-banding
299        let new_x = if rubber_band_x && max_overscroll_distance > 0.0 {
300            // Allow overshoot with diminishing returns (elasticity)
301            rubber_band_clamp(raw_new_x, 0.0, info.max_scroll_x, max_overscroll_distance, overscroll_elasticity)
302        } else {
303            raw_new_x.clamp(0.0, info.max_scroll_x)
304        };
305
306        let new_y = if rubber_band_y && max_overscroll_distance > 0.0 {
307            rubber_band_clamp(raw_new_y, 0.0, info.max_scroll_y, max_overscroll_distance, overscroll_elasticity)
308        } else {
309            raw_new_y.clamp(0.0, info.max_scroll_y)
310        };
311
312        let new_pos = LogicalPosition { x: new_x, y: new_y };
313
314        // Apply exponential friction decay
315        let decay = (-friction_rate * dt * ASSUMED_FPS).exp();
316        node_physics.velocity.x *= decay;
317        node_physics.velocity.y *= decay;
318
319        // At edges without rubber-banding: hand the remaining momentum to a
320        // scrollable ancestor, then kill this node's velocity (MWA-C-scroll:
321        // a fling that exhausts the inner container mid-momentum continues
322        // on the outer one, mirroring the input-time boundary handoff in
323        // select_scroll_target). overscroll-behavior contain/none on this
324        // node stops the chain, matching CSS scroll-chaining semantics.
325        if !rubber_band_x && (new_pos.x <= 0.0 || new_pos.x >= info.max_scroll_x) {
326            let into_edge = (new_pos.x <= 0.0 && node_physics.velocity.x < 0.0)
327                || (new_pos.x >= info.max_scroll_x && node_physics.velocity.x > 0.0);
328            if into_edge
329                && info.overscroll_behavior_x == OverscrollBehavior::Auto
330                && node_physics.velocity.x.abs() > velocity_threshold
331            {
332                momentum_handoffs.push((
333                    (*dom_id, *node_id),
334                    LogicalPosition { x: node_physics.velocity.x, y: 0.0 },
335                ));
336            }
337            node_physics.velocity.x = 0.0;
338        }
339        if !rubber_band_y && (new_pos.y <= 0.0 || new_pos.y >= info.max_scroll_y) {
340            let into_edge = (new_pos.y <= 0.0 && node_physics.velocity.y < 0.0)
341                || (new_pos.y >= info.max_scroll_y && node_physics.velocity.y > 0.0);
342            if into_edge
343                && info.overscroll_behavior_y == OverscrollBehavior::Auto
344                && node_physics.velocity.y.abs() > velocity_threshold
345            {
346                momentum_handoffs.push((
347                    (*dom_id, *node_id),
348                    LogicalPosition { x: 0.0, y: node_physics.velocity.y },
349                ));
350            }
351            node_physics.velocity.y = 0.0;
352        }
353
354        // Check if rubber-banding spring-back is almost complete
355        let new_overshoot_x = calculate_overshoot(new_pos.x, 0.0, info.max_scroll_x);
356        let new_overshoot_y = calculate_overshoot(new_pos.y, 0.0, info.max_scroll_y);
357        if new_overshoot_x.abs() < 0.5 && new_overshoot_y.abs() < 0.5 {
358            node_physics.is_rubber_banding = false;
359        }
360
361        // Snap to zero if below threshold after decay
362        if node_physics.velocity.x.abs() < velocity_threshold {
363            node_physics.velocity.x = 0.0;
364        }
365        if node_physics.velocity.y.abs() < velocity_threshold {
366            node_physics.velocity.y = 0.0;
367        }
368
369        velocity_updates.push(((*dom_id, *node_id), new_pos));
370    }
371
372    // Clean up nodes with zero velocity and not rubber-banding
373    physics
374        .node_velocities
375        .retain(|_, v| v.velocity.x.abs() > 0.0 || v.velocity.y.abs() > 0.0 || v.is_rubber_banding);
376
377    // MWA-C-scroll: transfer residual momentum up the scroll chain — walk the
378    // scroll-parent chain to the nearest ancestor that can still consume in
379    // the fling's direction and seed it with the leftover velocity (picked up
380    // by the integration loop on the next tick; is_active() keeps the timer
381    // alive because the entry lands in node_velocities).
382    for ((dom_id, node_id), vel) in momentum_handoffs {
383        let mut cur = node_id;
384        for _ in 0..64 {
385            let Some(parent) = timer_info.find_scroll_parent(dom_id, cur) else {
386                break;
387            };
388            let Some(pinfo) = timer_info.get_scroll_node_info(dom_id, parent) else {
389                break;
390            };
391            let can_x = vel.x != 0.0
392                && ((vel.x > 0.0 && pinfo.current_offset.x < pinfo.max_scroll_x - 0.5)
393                    || (vel.x < 0.0 && pinfo.current_offset.x > 0.5));
394            let can_y = vel.y != 0.0
395                && ((vel.y > 0.0 && pinfo.current_offset.y < pinfo.max_scroll_y - 0.5)
396                    || (vel.y < 0.0 && pinfo.current_offset.y > 0.5));
397            if can_x || can_y {
398                let entry = physics
399                    .node_velocities
400                    .entry((dom_id, parent))
401                    .or_insert_with(NodeScrollPhysics::default);
402                if can_x {
403                    entry.velocity.x += vel.x;
404                }
405                if can_y {
406                    entry.velocity.y += vel.y;
407                }
408                break;
409            }
410            // This ancestor is itself exhausted in the fling's direction —
411            // respect ITS overscroll-behavior before chaining past it.
412            let stop_x = vel.x != 0.0 && pinfo.overscroll_behavior_x != OverscrollBehavior::Auto;
413            let stop_y = vel.y != 0.0 && pinfo.overscroll_behavior_y != OverscrollBehavior::Auto;
414            if stop_x || stop_y {
415                break;
416            }
417            cur = parent;
418        }
419    }
420
421    // 3. Push ScrollTo changes for all updated positions
422    let mut any_changes = false;
423
424    // Apply programmatic position changes (hard-clamped to bounds)
425    let direct_positions: Vec<_> = physics.pending_positions.iter().map(|(k, v)| (*k, *v)).collect();
426    physics.pending_positions.clear();
427    for ((dom_id, node_id), position) in direct_positions {
428        let clamped = timer_info.get_scroll_node_info(dom_id, node_id).map_or(position, |info| LogicalPosition {
429                x: position.x.clamp(0.0, info.max_scroll_x),
430                y: position.y.clamp(0.0, info.max_scroll_y),
431            });
432        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
433        timer_info.scroll_to(dom_id, hierarchy_id, clamped);
434        any_changes = true;
435    }
436
437    // Apply trackpad position changes (rubber-band clamped for elastic overshoot)
438    // Uses scroll_to_unclamped because the physics timer does its own rubber-band clamping.
439    let trackpad_positions: Vec<_> = physics.pending_trackpad_positions.iter().map(|(k, v)| (*k, *v)).collect();
440    physics.pending_trackpad_positions.clear();
441    for ((dom_id, node_id), position) in trackpad_positions {
442        let clamped = timer_info.get_scroll_node_info(dom_id, node_id).map_or(position, |info| {
443                let rubber_x = node_allows_rubber_band(info.max_scroll_x, info.overscroll_behavior_x, info.overflow_scrolling, physics.scroll_physics.overscroll_elasticity);
444                let rubber_y = node_allows_rubber_band(info.max_scroll_y, info.overscroll_behavior_y, info.overflow_scrolling, physics.scroll_physics.overscroll_elasticity);
445                let max_over = physics.scroll_physics.max_overscroll_distance;
446                let elasticity = physics.scroll_physics.overscroll_elasticity;
447                LogicalPosition {
448                    x: if rubber_x {
449                        rubber_band_clamp(position.x, 0.0, info.max_scroll_x, max_over, elasticity)
450                    } else {
451                        position.x.clamp(0.0, info.max_scroll_x)
452                    },
453                    y: if rubber_y {
454                        rubber_band_clamp(position.y, 0.0, info.max_scroll_y, max_over, elasticity)
455                    } else {
456                        position.y.clamp(0.0, info.max_scroll_y)
457                    },
458                }
459            });
460        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
461        timer_info.scroll_to_unclamped(dom_id, hierarchy_id, clamped);
462        any_changes = true;
463    }
464
465    // Apply velocity-based position changes (uses unclamped: physics already handles rubber-band clamping)
466    for ((dom_id, node_id), position) in velocity_updates {
467        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
468        timer_info.scroll_to_unclamped(dom_id, hierarchy_id, position);
469        any_changes = true;
470    }
471
472    // 4. Decide whether to continue or terminate
473    if physics.is_active() || any_changes {
474        TimerCallbackReturn {
475            should_update: Update::DoNothing, // Scroll changes are handled via nodes_scrolled_in_callbacks, not DOM refresh
476            should_terminate: TerminateTimer::Continue,
477        }
478    } else {
479        // No more velocity, no pending inputs → terminate the timer
480        TimerCallbackReturn::terminate_unchanged()
481    }
482}
483
484// ============================================================================
485// Rubber-banding Helper Functions
486// ============================================================================
487
488/// Determines if a node allows rubber-banding on a given axis based on:
489/// 1. Whether the axis actually has overflow (`max_scroll` > 0)
490/// 2. Per-node `overflow_scrolling` CSS property (-azul-overflow-scrolling)
491/// 3. Per-node `overscroll_behavior` CSS property (overscroll-behavior-x/y)
492/// 4. Global `overscroll_elasticity` from `ScrollPhysics`
493fn node_allows_rubber_band(
494    max_scroll: f32,
495    overscroll_behavior: OverscrollBehavior,
496    overflow_scrolling: OverflowScrolling,
497    global_elasticity: f32,
498) -> bool {
499    if max_scroll <= 0.0 {
500        return false;
501    }
502    if overscroll_behavior == OverscrollBehavior::None {
503        return false;
504    }
505    if overflow_scrolling == OverflowScrolling::Touch {
506        return true;
507    }
508    global_elasticity > 0.0
509}
510
511/// Calculate how far a position has overshot the valid scroll range.
512/// Returns positive for overshoot past max, negative for overshoot past min, 0 if in range.
513fn calculate_overshoot(pos: f32, min: f32, max: f32) -> f32 {
514    if pos < min {
515        pos - min // negative
516    } else if pos > max {
517        pos - max // positive
518    } else {
519        0.0
520    }
521}
522
523/// Rubber-band clamping: allows overshoot up to `max_overscroll`, with
524/// diminishing returns (elasticity) so it feels "springy".
525///
526/// The further you overshoot, the harder it becomes to scroll further.
527fn rubber_band_clamp(
528    raw_pos: f32,
529    min: f32,
530    max: f32,
531    max_overscroll: f32,
532    elasticity: f32,
533) -> f32 {
534    if raw_pos >= min && raw_pos <= max {
535        return raw_pos;
536    }
537
538    let (boundary, overshoot) = if raw_pos < min {
539        (min, min - raw_pos) // overshoot is positive distance past boundary
540    } else {
541        (max, raw_pos - max)
542    };
543
544    // Diminishing returns: as overshoot increases, actual displacement decreases
545    // Formula: actual = max_overscroll * (1 - e^(-elasticity * overshoot / max_overscroll))
546    let clamped_overscroll = if max_overscroll > 0.0 {
547        max_overscroll * (1.0 - (-elasticity * overshoot / max_overscroll).exp())
548    } else {
549        0.0
550    };
551
552    if raw_pos < min {
553        boundary - clamped_overscroll
554    } else {
555        boundary + clamped_overscroll
556    }
557}
558
559/// Convert `deceleration_rate` (0.0 - 1.0) to a friction constant for exponential decay.
560/// Higher `deceleration_rate` = less friction (slower deceleration).
561fn friction_from_deceleration(deceleration_rate: f32) -> f32 {
562    // deceleration_rate ~0.95 (fast) → friction ~0.05
563    // deceleration_rate ~0.998 (iOS-like) → friction ~0.002
564    (1.0 - deceleration_rate.clamp(0.0, 0.999)).max(0.001)
565}
566
567/// Calculate spring constant from bounce-back duration.
568/// Higher k = faster spring back. Approximate: k ≈ (2π / duration)²
569#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/counter/fixed-point cast
570#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
571fn spring_constant_from_bounce_duration(duration_ms: u32) -> f32 {
572    let duration_s = duration_ms.max(50) as f32 / 1000.0;
573    let omega = core::f32::consts::TAU / duration_s;
574    omega * omega
575}