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(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    pub 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    pub 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`.
132pub extern "C" fn scroll_physics_timer_callback(
133    mut data: RefAny,
134    mut timer_info: TimerCallbackInfo,
135) -> TimerCallbackReturn {
136    // Downcast the RefAny to our physics state
137    let mut physics = match data.downcast_mut::<ScrollPhysicsState>() {
138        Some(p) => p,
139        None => return TimerCallbackReturn::terminate_unchanged(),
140    };
141
142    // Extract physics config values
143    let sp = &physics.scroll_physics;
144    let dt = sp.timer_interval_ms.max(1) as f32 / 1000.0;
145    let friction_rate = friction_from_deceleration(sp.deceleration_rate);
146    let velocity_threshold = sp.min_velocity_threshold;
147    let wheel_multiplier = sp.wheel_multiplier;
148    let max_velocity = sp.max_velocity;
149    let overscroll_elasticity = sp.overscroll_elasticity;
150    let max_overscroll_distance = sp.max_overscroll_distance;
151    let bounce_back_duration_ms = sp.bounce_back_duration_ms;
152
153    // 1. Take at most MAX_SCROLL_EVENTS_PER_TICK recent inputs from the shared queue
154    let inputs = physics.input_queue.take_recent(MAX_SCROLL_EVENTS_PER_TICK);
155
156    for input in inputs {
157        let key = (input.dom_id, input.node_id);
158        match input.source {
159            ScrollInputSource::TrackpadContinuous => {
160                // Trackpad: OS handles momentum. Apply delta directly as position change.
161                let current = timer_info
162                    .get_scroll_node_info(input.dom_id, input.node_id)
163                    .map(|info| info.current_offset)
164                    .unwrap_or_default();
165
166                let new_pos = LogicalPosition {
167                    x: current.x + input.delta.x,
168                    y: current.y + input.delta.y,
169                };
170                physics.pending_trackpad_positions.insert(key, new_pos);
171
172                // Kill any existing velocity for this node (trackpad overrides momentum)
173                physics.node_velocities.remove(&key);
174            }
175            ScrollInputSource::WheelDiscrete => {
176                // Mouse wheel: Convert delta to velocity impulse
177                let node_physics = physics
178                    .node_velocities
179                    .entry(key)
180                    .or_insert_with(NodeScrollPhysics::default);
181
182                // Add impulse (delta is in pixels, convert to pixels/second)
183                node_physics.velocity.x += input.delta.x * wheel_multiplier * ASSUMED_FPS;
184                node_physics.velocity.y += input.delta.y * wheel_multiplier * ASSUMED_FPS;
185
186                // Clamp to max velocity
187                node_physics.velocity.x = node_physics.velocity.x.clamp(-max_velocity, max_velocity);
188                node_physics.velocity.y = node_physics.velocity.y.clamp(-max_velocity, max_velocity);
189            }
190            ScrollInputSource::Programmatic => {
191                // Programmatic: Set position directly
192                let current = timer_info
193                    .get_scroll_node_info(input.dom_id, input.node_id)
194                    .map(|info| info.current_offset)
195                    .unwrap_or_default();
196
197                let new_pos = LogicalPosition {
198                    x: current.x + input.delta.x,
199                    y: current.y + input.delta.y,
200                };
201                physics.pending_positions.insert(key, new_pos);
202            }
203            ScrollInputSource::TrackpadEnd => {
204                // Trackpad gesture ended (fingers lifted).
205                // If the scroll position is past the bounds (rubber-banding overshoot),
206                // start a spring-back animation to snap back to the boundary.
207                let pos = physics.pending_positions.remove(&key)
208                    .or_else(|| timer_info.get_scroll_node_info(input.dom_id, input.node_id)
209                        .map(|info| info.current_offset));
210
211                if let Some(pos) = pos {
212                    if let Some(info) = timer_info.get_scroll_node_info(input.dom_id, input.node_id) {
213                        let overshoot_x = calculate_overshoot(pos.x, 0.0, info.max_scroll_x);
214                        let overshoot_y = calculate_overshoot(pos.y, 0.0, info.max_scroll_y);
215
216                        if overshoot_x.abs() > 0.01 || overshoot_y.abs() > 0.01 {
217                            let node_phys = physics.node_velocities
218                                .entry(key)
219                                .or_insert_with(NodeScrollPhysics::default);
220                            // Zero out velocity — the spring-back force in the
221                            // velocity integration loop (step 2) will pull the
222                            // position back to the boundary.
223                            node_phys.velocity = LogicalPosition::zero();
224                            node_phys.is_rubber_banding = true;
225                        }
226
227                        // Preserve the overshot position for the spring-back animation.
228                        // Must use unclamped so the overshot position is NOT clamped to bounds.
229                        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(input.node_id));
230                        timer_info.scroll_to_unclamped(input.dom_id, hierarchy_id, pos);
231                    }
232                }
233            }
234        }
235    }
236
237    // 2. Integrate velocity physics for wheel-based momentum
238    let mut velocity_updates: Vec<((DomId, NodeId), LogicalPosition)> = Vec::new();
239
240    for ((dom_id, node_id), node_physics) in physics.node_velocities.iter_mut() {
241        // Get current scroll info for clamping and per-node CSS config
242        let info = match timer_info.get_scroll_node_info(*dom_id, *node_id) {
243            Some(i) => i,
244            None => continue,
245        };
246
247        // Determine if this node allows rubber-banding
248        let rubber_band_x = node_allows_rubber_band_x(&info, overscroll_elasticity);
249        let rubber_band_y = node_allows_rubber_band_y(&info, overscroll_elasticity);
250
251        // Calculate current overshoot amounts
252        let overshoot_x = calculate_overshoot(info.current_offset.x, 0.0, info.max_scroll_x);
253        let overshoot_y = calculate_overshoot(info.current_offset.y, 0.0, info.max_scroll_y);
254
255        let is_overshooting_x = overshoot_x.abs() > 0.01;
256        let is_overshooting_y = overshoot_y.abs() > 0.01;
257
258        // If we're in a rubber-band overshoot, apply critically-damped spring force.
259        // F = -k*x - c*v  where c = 2*sqrt(k) for critical damping (no oscillation).
260        if is_overshooting_x && rubber_band_x {
261            let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
262            let damping = 2.0 * spring_k.sqrt(); // critical damping coefficient
263            let spring_force_x = -spring_k * overshoot_x - damping * node_physics.velocity.x;
264            node_physics.velocity.x += spring_force_x * dt;
265            node_physics.is_rubber_banding = true;
266        }
267        if is_overshooting_y && rubber_band_y {
268            let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
269            let damping = 2.0 * spring_k.sqrt(); // critical damping coefficient
270            let spring_force_y = -spring_k * overshoot_y - damping * node_physics.velocity.y;
271            node_physics.velocity.y += spring_force_y * dt;
272            node_physics.is_rubber_banding = true;
273        }
274
275        // Skip if velocity is negligible and not rubber-banding
276        if !node_physics.is_rubber_banding
277            && node_physics.velocity.x.abs() < velocity_threshold
278            && node_physics.velocity.y.abs() < velocity_threshold
279        {
280            node_physics.velocity = LogicalPosition::zero();
281            continue;
282        }
283
284        // Apply velocity to position
285        let displacement = LogicalPosition {
286            x: node_physics.velocity.x * dt,
287            y: node_physics.velocity.y * dt,
288        };
289
290        let raw_new_x = info.current_offset.x + displacement.x;
291        let raw_new_y = info.current_offset.y + displacement.y;
292
293        // Clamp with or without rubber-banding
294        let new_x = if rubber_band_x && max_overscroll_distance > 0.0 {
295            // Allow overshoot with diminishing returns (elasticity)
296            rubber_band_clamp(raw_new_x, 0.0, info.max_scroll_x, max_overscroll_distance, overscroll_elasticity)
297        } else {
298            raw_new_x.clamp(0.0, info.max_scroll_x)
299        };
300
301        let new_y = if rubber_band_y && max_overscroll_distance > 0.0 {
302            rubber_band_clamp(raw_new_y, 0.0, info.max_scroll_y, max_overscroll_distance, overscroll_elasticity)
303        } else {
304            raw_new_y.clamp(0.0, info.max_scroll_y)
305        };
306
307        let new_pos = LogicalPosition { x: new_x, y: new_y };
308
309        // Apply exponential friction decay
310        let decay = (-friction_rate * dt * ASSUMED_FPS).exp();
311        node_physics.velocity.x *= decay;
312        node_physics.velocity.y *= decay;
313
314        // At edges without rubber-banding: kill velocity
315        if !rubber_band_x {
316            if new_pos.x <= 0.0 || new_pos.x >= info.max_scroll_x {
317                node_physics.velocity.x = 0.0;
318            }
319        }
320        if !rubber_band_y {
321            if new_pos.y <= 0.0 || new_pos.y >= info.max_scroll_y {
322                node_physics.velocity.y = 0.0;
323            }
324        }
325
326        // Check if rubber-banding spring-back is almost complete
327        let new_overshoot_x = calculate_overshoot(new_pos.x, 0.0, info.max_scroll_x);
328        let new_overshoot_y = calculate_overshoot(new_pos.y, 0.0, info.max_scroll_y);
329        if new_overshoot_x.abs() < 0.5 && new_overshoot_y.abs() < 0.5 {
330            node_physics.is_rubber_banding = false;
331        }
332
333        // Snap to zero if below threshold after decay
334        if node_physics.velocity.x.abs() < velocity_threshold {
335            node_physics.velocity.x = 0.0;
336        }
337        if node_physics.velocity.y.abs() < velocity_threshold {
338            node_physics.velocity.y = 0.0;
339        }
340
341        velocity_updates.push(((*dom_id, *node_id), new_pos));
342    }
343
344    // Clean up nodes with zero velocity and not rubber-banding
345    physics
346        .node_velocities
347        .retain(|_, v| v.velocity.x.abs() > 0.0 || v.velocity.y.abs() > 0.0 || v.is_rubber_banding);
348
349    // 3. Push ScrollTo changes for all updated positions
350    let mut any_changes = false;
351
352    // Apply programmatic position changes (hard-clamped to bounds)
353    let direct_positions: Vec<_> = physics.pending_positions.iter().map(|(k, v)| (*k, *v)).collect();
354    physics.pending_positions.clear();
355    for ((dom_id, node_id), position) in direct_positions {
356        let clamped = match timer_info.get_scroll_node_info(dom_id, node_id) {
357            Some(info) => LogicalPosition {
358                x: position.x.clamp(0.0, info.max_scroll_x),
359                y: position.y.clamp(0.0, info.max_scroll_y),
360            },
361            None => position,
362        };
363        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
364        timer_info.scroll_to(dom_id, hierarchy_id, clamped);
365        any_changes = true;
366    }
367
368    // Apply trackpad position changes (rubber-band clamped for elastic overshoot)
369    // Uses scroll_to_unclamped because the physics timer does its own rubber-band clamping.
370    let trackpad_positions: Vec<_> = physics.pending_trackpad_positions.iter().map(|(k, v)| (*k, *v)).collect();
371    physics.pending_trackpad_positions.clear();
372    for ((dom_id, node_id), position) in trackpad_positions {
373        let clamped = match timer_info.get_scroll_node_info(dom_id, node_id) {
374            Some(info) => {
375                let rubber_x = node_allows_rubber_band_x(&info, physics.scroll_physics.overscroll_elasticity);
376                let rubber_y = node_allows_rubber_band_y(&info, physics.scroll_physics.overscroll_elasticity);
377                let max_over = physics.scroll_physics.max_overscroll_distance;
378                let elasticity = physics.scroll_physics.overscroll_elasticity;
379                LogicalPosition {
380                    x: if rubber_x {
381                        rubber_band_clamp(position.x, 0.0, info.max_scroll_x, max_over, elasticity)
382                    } else {
383                        position.x.clamp(0.0, info.max_scroll_x)
384                    },
385                    y: if rubber_y {
386                        rubber_band_clamp(position.y, 0.0, info.max_scroll_y, max_over, elasticity)
387                    } else {
388                        position.y.clamp(0.0, info.max_scroll_y)
389                    },
390                }
391            },
392            None => position,
393        };
394        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
395        timer_info.scroll_to_unclamped(dom_id, hierarchy_id, clamped);
396        any_changes = true;
397    }
398
399    // Apply velocity-based position changes (uses unclamped: physics already handles rubber-band clamping)
400    for ((dom_id, node_id), position) in velocity_updates {
401        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
402        timer_info.scroll_to_unclamped(dom_id, hierarchy_id, position);
403        any_changes = true;
404    }
405
406    // 4. Decide whether to continue or terminate
407    if physics.is_active() || any_changes {
408        TimerCallbackReturn {
409            should_update: Update::DoNothing, // Scroll changes are handled via nodes_scrolled_in_callbacks, not DOM refresh
410            should_terminate: TerminateTimer::Continue,
411        }
412    } else {
413        // No more velocity, no pending inputs → terminate the timer
414        TimerCallbackReturn::terminate_unchanged()
415    }
416}
417
418// ============================================================================
419// Rubber-banding Helper Functions
420// ============================================================================
421
422/// Determines if a node allows rubber-banding on the X axis based on:
423/// 1. Whether the axis actually has overflow (max_scroll_x > 0)
424/// 2. Per-node `overflow_scrolling` CSS property (-azul-overflow-scrolling)
425/// 3. Per-node `overscroll_behavior_x` CSS property (overscroll-behavior-x)
426/// 4. Global `overscroll_elasticity` from ScrollPhysics
427fn node_allows_rubber_band_x(info: &ScrollNodeInfo, global_elasticity: f32) -> bool {
428    // No rubber-banding on an axis that doesn't scroll
429    if info.max_scroll_x <= 0.0 {
430        return false;
431    }
432    if info.overscroll_behavior_x == OverscrollBehavior::None {
433        return false;
434    }
435    if info.overflow_scrolling == OverflowScrolling::Touch {
436        return true;
437    }
438    global_elasticity > 0.0
439}
440
441/// Determines if a node allows rubber-banding on the Y axis
442fn node_allows_rubber_band_y(info: &ScrollNodeInfo, global_elasticity: f32) -> bool {
443    if info.max_scroll_y <= 0.0 {
444        return false;
445    }
446    if info.overscroll_behavior_y == OverscrollBehavior::None {
447        return false;
448    }
449    if info.overflow_scrolling == OverflowScrolling::Touch {
450        return true;
451    }
452    global_elasticity > 0.0
453}
454
455/// Calculate how far a position has overshot the valid scroll range.
456/// Returns positive for overshoot past max, negative for overshoot past min, 0 if in range.
457fn calculate_overshoot(pos: f32, min: f32, max: f32) -> f32 {
458    if pos < min {
459        pos - min // negative
460    } else if pos > max {
461        pos - max // positive
462    } else {
463        0.0
464    }
465}
466
467/// Rubber-band clamping: allows overshoot up to `max_overscroll`, with
468/// diminishing returns (elasticity) so it feels "springy".
469///
470/// The further you overshoot, the harder it becomes to scroll further.
471fn rubber_band_clamp(
472    raw_pos: f32,
473    min: f32,
474    max: f32,
475    max_overscroll: f32,
476    elasticity: f32,
477) -> f32 {
478    if raw_pos >= min && raw_pos <= max {
479        return raw_pos;
480    }
481
482    let (boundary, overshoot) = if raw_pos < min {
483        (min, min - raw_pos) // overshoot is positive distance past boundary
484    } else {
485        (max, raw_pos - max)
486    };
487
488    // Diminishing returns: as overshoot increases, actual displacement decreases
489    // Formula: actual = max_overscroll * (1 - e^(-elasticity * overshoot / max_overscroll))
490    let clamped_overscroll = if max_overscroll > 0.0 {
491        max_overscroll * (1.0 - (-elasticity * overshoot / max_overscroll).exp())
492    } else {
493        0.0
494    };
495
496    if raw_pos < min {
497        boundary - clamped_overscroll
498    } else {
499        boundary + clamped_overscroll
500    }
501}
502
503/// Convert deceleration_rate (0.0 - 1.0) to a friction constant for exponential decay.
504/// Higher deceleration_rate = less friction (slower deceleration).
505fn friction_from_deceleration(deceleration_rate: f32) -> f32 {
506    // deceleration_rate ~0.95 (fast) → friction ~0.05
507    // deceleration_rate ~0.998 (iOS-like) → friction ~0.002
508    (1.0 - deceleration_rate.clamp(0.0, 0.999)).max(0.001)
509}
510
511/// Calculate spring constant from bounce-back duration.
512/// Higher k = faster spring back. Approximate: k ≈ (2π / duration)²
513fn spring_constant_from_bounce_duration(duration_ms: u32) -> f32 {
514    let duration_s = duration_ms.max(50) as f32 / 1000.0;
515    let omega = core::f32::consts::TAU / duration_s;
516    omega * omega
517}