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, DomNodeId},
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/// State stored in the timer's RefAny data.
60///
61/// Contains the shared input queue, per-node velocity state, and the global
62/// scroll physics configuration from `SystemStyle`.
63#[derive(Debug)]
64pub struct ScrollPhysicsState {
65    /// Shared input queue — same Arc as ScrollManager.scroll_input_queue
66    pub input_queue: ScrollInputQueue,
67    /// Per-node velocity tracking
68    pub node_velocities: BTreeMap<(DomId, NodeId), NodeScrollPhysics>,
69    /// Per-node "forced position" from trackpad or programmatic scroll
70    pub pending_positions: BTreeMap<(DomId, NodeId), LogicalPosition>,
71    /// Global scroll physics configuration (from SystemStyle)
72    pub scroll_physics: ScrollPhysics,
73}
74
75/// For convenience, re-export NodeId
76use azul_core::id::NodeId;
77
78/// Per-node scroll physics state
79#[derive(Debug, Clone, Default)]
80pub struct NodeScrollPhysics {
81    /// Current velocity in pixels/second
82    pub velocity: LogicalPosition,
83    /// Whether this node is currently in a rubber-band overshoot state
84    pub is_rubber_banding: bool,
85}
86
87impl ScrollPhysicsState {
88    /// Create a new physics state with the shared input queue and global config
89    pub fn new(input_queue: ScrollInputQueue, scroll_physics: ScrollPhysics) -> Self {
90        Self {
91            input_queue,
92            node_velocities: BTreeMap::new(),
93            pending_positions: BTreeMap::new(),
94            scroll_physics,
95        }
96    }
97
98    /// Returns true if any node has non-zero velocity or there are pending inputs
99    pub fn is_active(&self) -> bool {
100        let threshold = self.scroll_physics.min_velocity_threshold;
101        self.input_queue.has_pending()
102            || self.node_velocities.values().any(|v| {
103                v.velocity.x.abs() > threshold
104                    || v.velocity.y.abs() > threshold
105                    || v.is_rubber_banding
106            })
107            || !self.pending_positions.is_empty()
108    }
109}
110
111// Destructor for RefAny
112fn scroll_physics_state_destructor(data: &mut RefAny) {
113    // RefAny handles Drop automatically, nothing special needed
114    let _ = data;
115}
116
117/// The scroll physics timer callback.
118///
119/// This is a normal timer callback registered with `SCROLL_MOMENTUM_TIMER_ID`.
120/// It consumes pending scroll inputs, applies physics, and pushes ScrollTo changes.
121///
122/// Uses the `ScrollPhysics` configuration from `SystemStyle` for friction,
123/// velocity thresholds, wheel multiplier, and rubber-banding parameters.
124/// Per-node `OverflowScrolling` and `OverscrollBehavior` CSS properties are
125/// respected to decide whether each node gets rubber-banding.
126///
127/// # C API
128///
129/// This function has `extern "C"` ABI so it can be used as a `TimerCallbackType`.
130pub extern "C" fn scroll_physics_timer_callback(
131    mut data: RefAny,
132    mut timer_info: TimerCallbackInfo,
133) -> TimerCallbackReturn {
134    // Downcast the RefAny to our physics state
135    let mut physics = match data.downcast_mut::<ScrollPhysicsState>() {
136        Some(p) => p,
137        None => return TimerCallbackReturn::terminate_unchanged(),
138    };
139
140    // Extract physics config values
141    let sp = &physics.scroll_physics;
142    let dt = sp.timer_interval_ms.max(1) as f32 / 1000.0;
143    let friction_rate = friction_from_deceleration(sp.deceleration_rate);
144    let velocity_threshold = sp.min_velocity_threshold;
145    let wheel_multiplier = sp.wheel_multiplier;
146    let max_velocity = sp.max_velocity;
147    let overscroll_elasticity = sp.overscroll_elasticity;
148    let max_overscroll_distance = sp.max_overscroll_distance;
149    let bounce_back_duration_ms = sp.bounce_back_duration_ms;
150
151    // 1. Take at most MAX_SCROLL_EVENTS_PER_TICK recent inputs from the shared queue
152    let inputs = physics.input_queue.take_recent(MAX_SCROLL_EVENTS_PER_TICK);
153
154    for input in inputs {
155        let key = (input.dom_id, input.node_id);
156        match input.source {
157            ScrollInputSource::TrackpadContinuous => {
158                // Trackpad: OS handles momentum. Apply delta directly as position change.
159                let current = timer_info
160                    .get_scroll_node_info(input.dom_id, input.node_id)
161                    .map(|info| info.current_offset)
162                    .unwrap_or_default();
163
164                let new_pos = LogicalPosition {
165                    x: current.x + input.delta.x,
166                    y: current.y + input.delta.y,
167                };
168                physics.pending_positions.insert(key, new_pos);
169
170                // Kill any existing velocity for this node (trackpad overrides momentum)
171                physics.node_velocities.remove(&key);
172            }
173            ScrollInputSource::WheelDiscrete => {
174                // Mouse wheel: Convert delta to velocity impulse
175                let node_physics = physics
176                    .node_velocities
177                    .entry(key)
178                    .or_insert_with(NodeScrollPhysics::default);
179
180                // Add impulse (delta is in pixels, convert to pixels/second at ~60fps)
181                node_physics.velocity.x += input.delta.x * wheel_multiplier * 60.0;
182                node_physics.velocity.y += input.delta.y * wheel_multiplier * 60.0;
183
184                // Clamp to max velocity
185                node_physics.velocity.x = node_physics.velocity.x.clamp(-max_velocity, max_velocity);
186                node_physics.velocity.y = node_physics.velocity.y.clamp(-max_velocity, max_velocity);
187            }
188            ScrollInputSource::Programmatic => {
189                // Programmatic: Set position directly
190                let current = timer_info
191                    .get_scroll_node_info(input.dom_id, input.node_id)
192                    .map(|info| info.current_offset)
193                    .unwrap_or_default();
194
195                let new_pos = LogicalPosition {
196                    x: current.x + input.delta.x,
197                    y: current.y + input.delta.y,
198                };
199                physics.pending_positions.insert(key, new_pos);
200            }
201        }
202    }
203
204    // 2. Integrate velocity physics for wheel-based momentum
205    let mut velocity_updates: Vec<((DomId, NodeId), LogicalPosition)> = Vec::new();
206
207    for ((dom_id, node_id), node_physics) in physics.node_velocities.iter_mut() {
208        // Get current scroll info for clamping and per-node CSS config
209        let info = match timer_info.get_scroll_node_info(*dom_id, *node_id) {
210            Some(i) => i,
211            None => continue,
212        };
213
214        // Determine if this node allows rubber-banding
215        let rubber_band_x = node_allows_rubber_band_x(&info, overscroll_elasticity);
216        let rubber_band_y = node_allows_rubber_band_y(&info, overscroll_elasticity);
217
218        // Calculate current overshoot amounts
219        let overshoot_x = calculate_overshoot(info.current_offset.x, 0.0, info.max_scroll_x);
220        let overshoot_y = calculate_overshoot(info.current_offset.y, 0.0, info.max_scroll_y);
221
222        let is_overshooting_x = overshoot_x.abs() > 0.01;
223        let is_overshooting_y = overshoot_y.abs() > 0.01;
224
225        // If we're in a rubber-band overshoot, apply spring-back force
226        if is_overshooting_x && rubber_band_x {
227            // Spring-back: accelerate towards the boundary
228            let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
229            let spring_force_x = -spring_k * overshoot_x;
230            node_physics.velocity.x += spring_force_x * dt;
231            node_physics.is_rubber_banding = true;
232        }
233        if is_overshooting_y && rubber_band_y {
234            let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
235            let spring_force_y = -spring_k * overshoot_y;
236            node_physics.velocity.y += spring_force_y * dt;
237            node_physics.is_rubber_banding = true;
238        }
239
240        // Skip if velocity is negligible and not rubber-banding
241        if !node_physics.is_rubber_banding
242            && node_physics.velocity.x.abs() < velocity_threshold
243            && node_physics.velocity.y.abs() < velocity_threshold
244        {
245            node_physics.velocity = LogicalPosition::zero();
246            continue;
247        }
248
249        // Apply velocity to position
250        let displacement = LogicalPosition {
251            x: node_physics.velocity.x * dt,
252            y: node_physics.velocity.y * dt,
253        };
254
255        let raw_new_x = info.current_offset.x + displacement.x;
256        let raw_new_y = info.current_offset.y + displacement.y;
257
258        // Clamp with or without rubber-banding
259        let new_x = if rubber_band_x && max_overscroll_distance > 0.0 {
260            // Allow overshoot with diminishing returns (elasticity)
261            rubber_band_clamp(raw_new_x, 0.0, info.max_scroll_x, max_overscroll_distance, overscroll_elasticity)
262        } else {
263            raw_new_x.max(0.0).min(info.max_scroll_x)
264        };
265
266        let new_y = if rubber_band_y && max_overscroll_distance > 0.0 {
267            rubber_band_clamp(raw_new_y, 0.0, info.max_scroll_y, max_overscroll_distance, overscroll_elasticity)
268        } else {
269            raw_new_y.max(0.0).min(info.max_scroll_y)
270        };
271
272        let new_pos = LogicalPosition { x: new_x, y: new_y };
273
274        // Apply exponential friction decay
275        let decay = (-friction_rate * dt * 60.0).exp();
276        node_physics.velocity.x *= decay;
277        node_physics.velocity.y *= decay;
278
279        // At edges without rubber-banding: kill velocity
280        if !rubber_band_x {
281            if new_pos.x <= 0.0 || new_pos.x >= info.max_scroll_x {
282                node_physics.velocity.x = 0.0;
283            }
284        }
285        if !rubber_band_y {
286            if new_pos.y <= 0.0 || new_pos.y >= info.max_scroll_y {
287                node_physics.velocity.y = 0.0;
288            }
289        }
290
291        // Check if rubber-banding spring-back is almost complete
292        let new_overshoot_x = calculate_overshoot(new_pos.x, 0.0, info.max_scroll_x);
293        let new_overshoot_y = calculate_overshoot(new_pos.y, 0.0, info.max_scroll_y);
294        if new_overshoot_x.abs() < 0.5 && new_overshoot_y.abs() < 0.5 {
295            node_physics.is_rubber_banding = false;
296        }
297
298        // Snap to zero if below threshold after decay
299        if node_physics.velocity.x.abs() < velocity_threshold {
300            node_physics.velocity.x = 0.0;
301        }
302        if node_physics.velocity.y.abs() < velocity_threshold {
303            node_physics.velocity.y = 0.0;
304        }
305
306        velocity_updates.push(((*dom_id, *node_id), new_pos));
307    }
308
309    // Clean up nodes with zero velocity and not rubber-banding
310    physics
311        .node_velocities
312        .retain(|_, v| v.velocity.x.abs() > 0.0 || v.velocity.y.abs() > 0.0 || v.is_rubber_banding);
313
314    // 3. Push ScrollTo changes for all updated positions
315    let mut any_changes = false;
316
317    // Apply direct position changes (trackpad/programmatic)
318    let direct_positions: Vec<_> = physics.pending_positions.iter().map(|(k, v)| (*k, *v)).collect();
319    physics.pending_positions.clear();
320    for ((dom_id, node_id), position) in direct_positions {
321        // Clamp to valid bounds (no rubber-band for direct positioning)
322        let clamped = match timer_info.get_scroll_node_info(dom_id, node_id) {
323            Some(info) => LogicalPosition {
324                x: position.x.max(0.0).min(info.max_scroll_x),
325                y: position.y.max(0.0).min(info.max_scroll_y),
326            },
327            None => position,
328        };
329        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
330        timer_info.scroll_to(dom_id, hierarchy_id, clamped);
331        any_changes = true;
332    }
333
334    // Apply velocity-based position changes
335    for ((dom_id, node_id), position) in velocity_updates {
336        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
337        timer_info.scroll_to(dom_id, hierarchy_id, position);
338        any_changes = true;
339    }
340
341    // 4. Decide whether to continue or terminate
342    if physics.is_active() || any_changes {
343        TimerCallbackReturn {
344            should_update: if any_changes {
345                Update::RefreshDom
346            } else {
347                Update::DoNothing
348            },
349            should_terminate: TerminateTimer::Continue,
350        }
351    } else {
352        // No more velocity, no pending inputs → terminate the timer
353        TimerCallbackReturn::terminate_unchanged()
354    }
355}
356
357// ============================================================================
358// Rubber-banding Helper Functions
359// ============================================================================
360
361/// Determines if a node allows rubber-banding on the X axis based on:
362/// 1. Per-node `overflow_scrolling` CSS property (-azul-overflow-scrolling)
363/// 2. Per-node `overscroll_behavior_x` CSS property (overscroll-behavior-x)
364/// 3. Global `overscroll_elasticity` from ScrollPhysics
365fn node_allows_rubber_band_x(info: &ScrollNodeInfo, global_elasticity: f32) -> bool {
366    // If overscroll-behavior-x is None, no rubber-band regardless
367    if info.overscroll_behavior_x == OverscrollBehavior::None {
368        return false;
369    }
370    // If -azul-overflow-scrolling: touch, force rubber-banding on
371    if info.overflow_scrolling == OverflowScrolling::Touch {
372        return true;
373    }
374    // Otherwise (Auto): use global config
375    global_elasticity > 0.0
376}
377
378/// Determines if a node allows rubber-banding on the Y axis
379fn node_allows_rubber_band_y(info: &ScrollNodeInfo, global_elasticity: f32) -> bool {
380    if info.overscroll_behavior_y == OverscrollBehavior::None {
381        return false;
382    }
383    if info.overflow_scrolling == OverflowScrolling::Touch {
384        return true;
385    }
386    global_elasticity > 0.0
387}
388
389/// Calculate how far a position has overshot the valid scroll range.
390/// Returns positive for overshoot past max, negative for overshoot past min, 0 if in range.
391fn calculate_overshoot(pos: f32, min: f32, max: f32) -> f32 {
392    if pos < min {
393        pos - min // negative
394    } else if pos > max {
395        pos - max // positive
396    } else {
397        0.0
398    }
399}
400
401/// Rubber-band clamping: allows overshoot up to `max_overscroll`, with
402/// diminishing returns (elasticity) so it feels "springy".
403///
404/// The further you overshoot, the harder it becomes to scroll further.
405fn rubber_band_clamp(
406    raw_pos: f32,
407    min: f32,
408    max: f32,
409    max_overscroll: f32,
410    elasticity: f32,
411) -> f32 {
412    if raw_pos >= min && raw_pos <= max {
413        return raw_pos;
414    }
415
416    let (boundary, overshoot) = if raw_pos < min {
417        (min, min - raw_pos) // overshoot is positive distance past boundary
418    } else {
419        (max, raw_pos - max)
420    };
421
422    // Diminishing returns: as overshoot increases, actual displacement decreases
423    // Formula: actual = max_overscroll * (1 - e^(-elasticity * overshoot / max_overscroll))
424    let clamped_overscroll = if max_overscroll > 0.0 {
425        max_overscroll * (1.0 - (-elasticity * overshoot / max_overscroll).exp())
426    } else {
427        0.0
428    };
429
430    if raw_pos < min {
431        boundary - clamped_overscroll
432    } else {
433        boundary + clamped_overscroll
434    }
435}
436
437/// Convert deceleration_rate (0.0 - 1.0) to a friction constant for exponential decay.
438/// Higher deceleration_rate = less friction (slower deceleration).
439fn friction_from_deceleration(deceleration_rate: f32) -> f32 {
440    // deceleration_rate ~0.95 (fast) → friction ~0.05
441    // deceleration_rate ~0.998 (iOS-like) → friction ~0.002
442    (1.0 - deceleration_rate.clamp(0.0, 0.999)).max(0.001)
443}
444
445/// Calculate spring constant from bounce-back duration.
446/// Higher k = faster spring back. Approximate: k ≈ (2π / duration)²
447fn spring_constant_from_bounce_duration(duration_ms: u32) -> f32 {
448    let duration_s = duration_ms.max(50) as f32 / 1000.0;
449    let omega = core::f32::consts::TAU / duration_s;
450    omega * omega
451}