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    // Sanitize: `.max(0.0)` turns a NaN or negative max_velocity (a plain repr(C)
151    // field with no validation, reachable from a bad SystemStyle) into 0.0, so the
152    // `velocity.clamp(-max_velocity, max_velocity)` below never hits f32::clamp's
153    // `min > max` panic — which in this extern "C" callback would ABORT the process.
154    let max_velocity = sp.max_velocity.max(0.0);
155    let overscroll_elasticity = sp.overscroll_elasticity;
156    let max_overscroll_distance = sp.max_overscroll_distance;
157    let bounce_back_duration_ms = sp.bounce_back_duration_ms;
158
159    // 1. Take at most MAX_SCROLL_EVENTS_PER_TICK recent inputs from the shared queue
160    let inputs = physics.input_queue.take_recent(MAX_SCROLL_EVENTS_PER_TICK);
161
162    for input in inputs {
163        let key = (input.dom_id, input.node_id);
164        match input.source {
165            ScrollInputSource::TrackpadContinuous => {
166                // Trackpad: OS handles momentum. Apply delta directly as position change.
167                let current = timer_info
168                    .get_scroll_node_info(input.dom_id, input.node_id)
169                    .map(|info| info.current_offset)
170                    .unwrap_or_default();
171
172                let new_pos = LogicalPosition {
173                    x: current.x + input.delta.x,
174                    y: current.y + input.delta.y,
175                };
176                physics.pending_trackpad_positions.insert(key, new_pos);
177
178                // Kill any existing velocity for this node (trackpad overrides momentum)
179                physics.node_velocities.remove(&key);
180            }
181            ScrollInputSource::WheelDiscrete => {
182                // Mouse wheel: Convert delta to velocity impulse
183                let node_physics = physics
184                    .node_velocities
185                    .entry(key)
186                    .or_insert_with(NodeScrollPhysics::default);
187
188                // Add impulse (delta is in pixels, convert to pixels/second)
189                node_physics.velocity.x += input.delta.x * wheel_multiplier * ASSUMED_FPS;
190                node_physics.velocity.y += input.delta.y * wheel_multiplier * ASSUMED_FPS;
191
192                // Clamp to max velocity
193                node_physics.velocity.x = node_physics.velocity.x.clamp(-max_velocity, max_velocity);
194                node_physics.velocity.y = node_physics.velocity.y.clamp(-max_velocity, max_velocity);
195            }
196            ScrollInputSource::Programmatic => {
197                // Programmatic: Set position directly
198                let current = timer_info
199                    .get_scroll_node_info(input.dom_id, input.node_id)
200                    .map(|info| info.current_offset)
201                    .unwrap_or_default();
202
203                let new_pos = LogicalPosition {
204                    x: current.x + input.delta.x,
205                    y: current.y + input.delta.y,
206                };
207                physics.pending_positions.insert(key, new_pos);
208            }
209            ScrollInputSource::TrackpadEnd => {
210                // Trackpad gesture ended (fingers lifted).
211                // If the scroll position is past the bounds (rubber-banding overshoot),
212                // start a spring-back animation to snap back to the boundary.
213                let pos = physics.pending_positions.remove(&key)
214                    .or_else(|| timer_info.get_scroll_node_info(input.dom_id, input.node_id)
215                        .map(|info| info.current_offset));
216
217                if let Some(pos) = pos {
218                    if let Some(info) = timer_info.get_scroll_node_info(input.dom_id, input.node_id) {
219                        let overshoot_x = calculate_overshoot(pos.x, 0.0, info.max_scroll_x);
220                        let overshoot_y = calculate_overshoot(pos.y, 0.0, info.max_scroll_y);
221
222                        if overshoot_x.abs() > 0.01 || overshoot_y.abs() > 0.01 {
223                            let node_phys = physics.node_velocities
224                                .entry(key)
225                                .or_insert_with(NodeScrollPhysics::default);
226                            // Zero out velocity — the spring-back force in the
227                            // velocity integration loop (step 2) will pull the
228                            // position back to the boundary.
229                            node_phys.velocity = LogicalPosition::zero();
230                            node_phys.is_rubber_banding = true;
231                        }
232
233                        // Preserve the overshot position for the spring-back animation.
234                        // Must use unclamped so the overshot position is NOT clamped to bounds.
235                        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(input.node_id));
236                        timer_info.scroll_to_unclamped(input.dom_id, hierarchy_id, pos);
237                    }
238                }
239            }
240        }
241    }
242
243    // 2. Integrate velocity physics for wheel-based momentum
244    let mut velocity_updates: Vec<((DomId, NodeId), LogicalPosition)> = Vec::new();
245    // Residual momentum from nodes that hit their boundary this tick, to be
246    // transferred up the scroll chain after the iteration (can't mutate
247    // node_velocities mid-loop).
248    let mut momentum_handoffs: Vec<((DomId, NodeId), LogicalPosition)> = Vec::new();
249
250    for ((dom_id, node_id), node_physics) in &mut physics.node_velocities {
251        // Get current scroll info for clamping and per-node CSS config
252        let Some(info) = timer_info.get_scroll_node_info(*dom_id, *node_id) else {
253            continue;
254        };
255
256        // Determine if this node allows rubber-banding
257        let rubber_band_x = node_allows_rubber_band(info.max_scroll_x, info.overscroll_behavior_x, info.overflow_scrolling, overscroll_elasticity);
258        let rubber_band_y = node_allows_rubber_band(info.max_scroll_y, info.overscroll_behavior_y, info.overflow_scrolling, overscroll_elasticity);
259
260        // Calculate current overshoot amounts
261        let overshoot_x = calculate_overshoot(info.current_offset.x, 0.0, info.max_scroll_x);
262        let overshoot_y = calculate_overshoot(info.current_offset.y, 0.0, info.max_scroll_y);
263
264        let is_overshooting_x = overshoot_x.abs() > 0.01;
265        let is_overshooting_y = overshoot_y.abs() > 0.01;
266
267        // If we're in a rubber-band overshoot, apply critically-damped spring force.
268        // F = -k*x - c*v  where c = 2*sqrt(k) for critical damping (no oscillation).
269        if is_overshooting_x && rubber_band_x {
270            let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
271            let damping = 2.0 * spring_k.sqrt(); // critical damping coefficient
272            let spring_force_x = -spring_k * overshoot_x - damping * node_physics.velocity.x;
273            node_physics.velocity.x += spring_force_x * dt;
274            node_physics.is_rubber_banding = true;
275        }
276        if is_overshooting_y && rubber_band_y {
277            let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
278            let damping = 2.0 * spring_k.sqrt(); // critical damping coefficient
279            let spring_force_y = -spring_k * overshoot_y - damping * node_physics.velocity.y;
280            node_physics.velocity.y += spring_force_y * dt;
281            node_physics.is_rubber_banding = true;
282        }
283
284        // Skip if velocity is negligible and not rubber-banding
285        if !node_physics.is_rubber_banding
286            && node_physics.velocity.x.abs() < velocity_threshold
287            && node_physics.velocity.y.abs() < velocity_threshold
288        {
289            node_physics.velocity = LogicalPosition::zero();
290            continue;
291        }
292
293        // Apply velocity to position
294        let displacement = LogicalPosition {
295            x: node_physics.velocity.x * dt,
296            y: node_physics.velocity.y * dt,
297        };
298
299        let raw_new_x = info.current_offset.x + displacement.x;
300        let raw_new_y = info.current_offset.y + displacement.y;
301
302        // Clamp with or without rubber-banding
303        let new_x = if rubber_band_x && max_overscroll_distance > 0.0 {
304            // Allow overshoot with diminishing returns (elasticity)
305            rubber_band_clamp(raw_new_x, 0.0, info.max_scroll_x, max_overscroll_distance, overscroll_elasticity)
306        } else {
307            raw_new_x.clamp(0.0, info.max_scroll_x)
308        };
309
310        let new_y = if rubber_band_y && max_overscroll_distance > 0.0 {
311            rubber_band_clamp(raw_new_y, 0.0, info.max_scroll_y, max_overscroll_distance, overscroll_elasticity)
312        } else {
313            raw_new_y.clamp(0.0, info.max_scroll_y)
314        };
315
316        let new_pos = LogicalPosition { x: new_x, y: new_y };
317
318        // Apply exponential friction decay
319        let decay = (-friction_rate * dt * ASSUMED_FPS).exp();
320        node_physics.velocity.x *= decay;
321        node_physics.velocity.y *= decay;
322
323        // At edges without rubber-banding: hand the remaining momentum to a
324        // scrollable ancestor, then kill this node's velocity (MWA-C-scroll:
325        // a fling that exhausts the inner container mid-momentum continues
326        // on the outer one, mirroring the input-time boundary handoff in
327        // select_scroll_target). overscroll-behavior contain/none on this
328        // node stops the chain, matching CSS scroll-chaining semantics.
329        if !rubber_band_x && (new_pos.x <= 0.0 || new_pos.x >= info.max_scroll_x) {
330            let into_edge = (new_pos.x <= 0.0 && node_physics.velocity.x < 0.0)
331                || (new_pos.x >= info.max_scroll_x && node_physics.velocity.x > 0.0);
332            if into_edge
333                && info.overscroll_behavior_x == OverscrollBehavior::Auto
334                && node_physics.velocity.x.abs() > velocity_threshold
335            {
336                momentum_handoffs.push((
337                    (*dom_id, *node_id),
338                    LogicalPosition { x: node_physics.velocity.x, y: 0.0 },
339                ));
340            }
341            node_physics.velocity.x = 0.0;
342        }
343        if !rubber_band_y && (new_pos.y <= 0.0 || new_pos.y >= info.max_scroll_y) {
344            let into_edge = (new_pos.y <= 0.0 && node_physics.velocity.y < 0.0)
345                || (new_pos.y >= info.max_scroll_y && node_physics.velocity.y > 0.0);
346            if into_edge
347                && info.overscroll_behavior_y == OverscrollBehavior::Auto
348                && node_physics.velocity.y.abs() > velocity_threshold
349            {
350                momentum_handoffs.push((
351                    (*dom_id, *node_id),
352                    LogicalPosition { x: 0.0, y: node_physics.velocity.y },
353                ));
354            }
355            node_physics.velocity.y = 0.0;
356        }
357
358        // Check if rubber-banding spring-back is almost complete
359        let new_overshoot_x = calculate_overshoot(new_pos.x, 0.0, info.max_scroll_x);
360        let new_overshoot_y = calculate_overshoot(new_pos.y, 0.0, info.max_scroll_y);
361        if new_overshoot_x.abs() < 0.5 && new_overshoot_y.abs() < 0.5 {
362            node_physics.is_rubber_banding = false;
363        }
364
365        // Snap to zero if below threshold after decay
366        if node_physics.velocity.x.abs() < velocity_threshold {
367            node_physics.velocity.x = 0.0;
368        }
369        if node_physics.velocity.y.abs() < velocity_threshold {
370            node_physics.velocity.y = 0.0;
371        }
372
373        velocity_updates.push(((*dom_id, *node_id), new_pos));
374    }
375
376    // Clean up nodes with zero velocity and not rubber-banding
377    physics
378        .node_velocities
379        .retain(|_, v| v.velocity.x.abs() > 0.0 || v.velocity.y.abs() > 0.0 || v.is_rubber_banding);
380
381    // MWA-C-scroll: transfer residual momentum up the scroll chain — walk the
382    // scroll-parent chain to the nearest ancestor that can still consume in
383    // the fling's direction and seed it with the leftover velocity (picked up
384    // by the integration loop on the next tick; is_active() keeps the timer
385    // alive because the entry lands in node_velocities).
386    for ((dom_id, node_id), vel) in momentum_handoffs {
387        let mut cur = node_id;
388        for _ in 0..64 {
389            let Some(parent) = timer_info.find_scroll_parent(dom_id, cur) else {
390                break;
391            };
392            let Some(pinfo) = timer_info.get_scroll_node_info(dom_id, parent) else {
393                break;
394            };
395            let can_x = vel.x != 0.0
396                && ((vel.x > 0.0 && pinfo.current_offset.x < pinfo.max_scroll_x - 0.5)
397                    || (vel.x < 0.0 && pinfo.current_offset.x > 0.5));
398            let can_y = vel.y != 0.0
399                && ((vel.y > 0.0 && pinfo.current_offset.y < pinfo.max_scroll_y - 0.5)
400                    || (vel.y < 0.0 && pinfo.current_offset.y > 0.5));
401            if can_x || can_y {
402                let entry = physics
403                    .node_velocities
404                    .entry((dom_id, parent))
405                    .or_insert_with(NodeScrollPhysics::default);
406                if can_x {
407                    entry.velocity.x += vel.x;
408                }
409                if can_y {
410                    entry.velocity.y += vel.y;
411                }
412                break;
413            }
414            // This ancestor is itself exhausted in the fling's direction —
415            // respect ITS overscroll-behavior before chaining past it.
416            let stop_x = vel.x != 0.0 && pinfo.overscroll_behavior_x != OverscrollBehavior::Auto;
417            let stop_y = vel.y != 0.0 && pinfo.overscroll_behavior_y != OverscrollBehavior::Auto;
418            if stop_x || stop_y {
419                break;
420            }
421            cur = parent;
422        }
423    }
424
425    // 3. Push ScrollTo changes for all updated positions
426    let mut any_changes = false;
427
428    // Apply programmatic position changes (hard-clamped to bounds)
429    let direct_positions: Vec<_> = physics.pending_positions.iter().map(|(k, v)| (*k, *v)).collect();
430    physics.pending_positions.clear();
431    for ((dom_id, node_id), position) in direct_positions {
432        let clamped = timer_info.get_scroll_node_info(dom_id, node_id).map_or(position, |info| LogicalPosition {
433                x: position.x.clamp(0.0, info.max_scroll_x),
434                y: position.y.clamp(0.0, info.max_scroll_y),
435            });
436        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
437        timer_info.scroll_to(dom_id, hierarchy_id, clamped);
438        any_changes = true;
439    }
440
441    // Apply trackpad position changes (rubber-band clamped for elastic overshoot)
442    // Uses scroll_to_unclamped because the physics timer does its own rubber-band clamping.
443    let trackpad_positions: Vec<_> = physics.pending_trackpad_positions.iter().map(|(k, v)| (*k, *v)).collect();
444    physics.pending_trackpad_positions.clear();
445    for ((dom_id, node_id), position) in trackpad_positions {
446        let clamped = timer_info.get_scroll_node_info(dom_id, node_id).map_or(position, |info| {
447                let rubber_x = node_allows_rubber_band(info.max_scroll_x, info.overscroll_behavior_x, info.overflow_scrolling, physics.scroll_physics.overscroll_elasticity);
448                let rubber_y = node_allows_rubber_band(info.max_scroll_y, info.overscroll_behavior_y, info.overflow_scrolling, physics.scroll_physics.overscroll_elasticity);
449                let max_over = physics.scroll_physics.max_overscroll_distance;
450                let elasticity = physics.scroll_physics.overscroll_elasticity;
451                LogicalPosition {
452                    x: if rubber_x {
453                        rubber_band_clamp(position.x, 0.0, info.max_scroll_x, max_over, elasticity)
454                    } else {
455                        position.x.clamp(0.0, info.max_scroll_x)
456                    },
457                    y: if rubber_y {
458                        rubber_band_clamp(position.y, 0.0, info.max_scroll_y, max_over, elasticity)
459                    } else {
460                        position.y.clamp(0.0, info.max_scroll_y)
461                    },
462                }
463            });
464        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
465        timer_info.scroll_to_unclamped(dom_id, hierarchy_id, clamped);
466        any_changes = true;
467    }
468
469    // Apply velocity-based position changes (uses unclamped: physics already handles rubber-band clamping)
470    for ((dom_id, node_id), position) in velocity_updates {
471        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
472        timer_info.scroll_to_unclamped(dom_id, hierarchy_id, position);
473        any_changes = true;
474    }
475
476    // 4. Decide whether to continue or terminate
477    if physics.is_active() || any_changes {
478        TimerCallbackReturn {
479            should_update: Update::DoNothing, // Scroll changes are handled via nodes_scrolled_in_callbacks, not DOM refresh
480            should_terminate: TerminateTimer::Continue,
481        }
482    } else {
483        // No more velocity, no pending inputs → terminate the timer
484        TimerCallbackReturn::terminate_unchanged()
485    }
486}
487
488// ============================================================================
489// Rubber-banding Helper Functions
490// ============================================================================
491
492/// Determines if a node allows rubber-banding on a given axis based on:
493/// 1. Whether the axis actually has overflow (`max_scroll` > 0)
494/// 2. Per-node `overflow_scrolling` CSS property (-azul-overflow-scrolling)
495/// 3. Per-node `overscroll_behavior` CSS property (overscroll-behavior-x/y)
496/// 4. Global `overscroll_elasticity` from `ScrollPhysics`
497fn node_allows_rubber_band(
498    max_scroll: f32,
499    overscroll_behavior: OverscrollBehavior,
500    overflow_scrolling: OverflowScrolling,
501    global_elasticity: f32,
502) -> bool {
503    if max_scroll <= 0.0 {
504        return false;
505    }
506    if overscroll_behavior == OverscrollBehavior::None {
507        return false;
508    }
509    if overflow_scrolling == OverflowScrolling::Touch {
510        return true;
511    }
512    global_elasticity > 0.0
513}
514
515/// Calculate how far a position has overshot the valid scroll range.
516/// Returns positive for overshoot past max, negative for overshoot past min, 0 if in range.
517fn calculate_overshoot(pos: f32, min: f32, max: f32) -> f32 {
518    if pos < min {
519        pos - min // negative
520    } else if pos > max {
521        pos - max // positive
522    } else {
523        0.0
524    }
525}
526
527/// Rubber-band clamping: allows overshoot up to `max_overscroll`, with
528/// diminishing returns (elasticity) so it feels "springy".
529///
530/// The further you overshoot, the harder it becomes to scroll further.
531fn rubber_band_clamp(
532    raw_pos: f32,
533    min: f32,
534    max: f32,
535    max_overscroll: f32,
536    elasticity: f32,
537) -> f32 {
538    if raw_pos >= min && raw_pos <= max {
539        return raw_pos;
540    }
541
542    let (boundary, overshoot) = if raw_pos < min {
543        (min, min - raw_pos) // overshoot is positive distance past boundary
544    } else {
545        (max, raw_pos - max)
546    };
547
548    // Diminishing returns: as overshoot increases, actual displacement decreases
549    // Formula: actual = max_overscroll * (1 - e^(-elasticity * overshoot / max_overscroll))
550    let clamped_overscroll = if max_overscroll > 0.0 {
551        max_overscroll * (1.0 - (-elasticity * overshoot / max_overscroll).exp())
552    } else {
553        0.0
554    };
555
556    if raw_pos < min {
557        boundary - clamped_overscroll
558    } else {
559        boundary + clamped_overscroll
560    }
561}
562
563/// Convert `deceleration_rate` (0.0 - 1.0) to a friction constant for exponential decay.
564/// Higher `deceleration_rate` = less friction (slower deceleration).
565fn friction_from_deceleration(deceleration_rate: f32) -> f32 {
566    // deceleration_rate ~0.95 (fast) → friction ~0.05
567    // deceleration_rate ~0.998 (iOS-like) → friction ~0.002
568    (1.0 - deceleration_rate.clamp(0.0, 0.999)).max(0.001)
569}
570
571/// Calculate spring constant from bounce-back duration.
572/// Higher k = faster spring back. Approximate: k ≈ (2π / duration)²
573#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/counter/fixed-point cast
574#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
575fn spring_constant_from_bounce_duration(duration_ms: u32) -> f32 {
576    let duration_s = duration_ms.max(50) as f32 / 1000.0;
577    let omega = core::f32::consts::TAU / duration_s;
578    omega * omega
579}
580
581// ============================================================================
582// Generated adversarial tests
583// ============================================================================
584
585#[cfg(all(test, feature = "std"))]
586#[allow(
587    clippy::float_cmp,
588    clippy::cast_precision_loss,
589    clippy::too_many_lines,
590    clippy::unreadable_literal
591)]
592mod autotest_generated {
593    use std::sync::{Arc, Mutex};
594
595    use azul_core::{
596        dom::{DomNodeId, OptionDomNodeId},
597        geom::{LogicalRect, LogicalSize, OptionLogicalPosition},
598        gl::OptionGlContextPtr,
599        hit_test::ScrollPosition,
600        refany::OptionRefAny,
601        resources::RendererResources,
602        task::Instant,
603        window::{MonitorVec, RawWindowHandle},
604    };
605    use azul_css::system::SystemStyle;
606    use rust_fontconfig::FcFontCache;
607
608    use super::*;
609    #[cfg(feature = "icu")]
610    use crate::icu::IcuLocalizerHandle;
611    use crate::{
612        callbacks::{CallbackChange, CallbackInfo, CallbackInfoRefData, ExternalSystemCallbacks},
613        window::LayoutWindow,
614        window_state::FullWindowState,
615    };
616
617    // ------------------------------------------------------------------
618    // Harness
619    // ------------------------------------------------------------------
620
621    /// A live callback environment: an otherwise-empty `LayoutWindow` (optionally
622    /// carrying registered scroll nodes) plus the shared change log that
623    /// `scroll_to` / `scroll_to_unclamped` push into. `tick()` runs one full
624    /// timer callback against it, so the physics loop can be driven repeatedly.
625    struct Env<'a> {
626        ref_data: &'a CallbackInfoRefData<'a>,
627        changes: &'a Arc<Mutex<Vec<CallbackChange>>>,
628    }
629
630    impl Env<'_> {
631        /// Run one `scroll_physics_timer_callback` tick against this environment.
632        fn tick(&mut self, data: &RefAny) -> TimerCallbackReturn {
633            let info = CallbackInfo::new(
634                self.ref_data,
635                self.changes,
636                DomNodeId {
637                    dom: DomId::ROOT_ID,
638                    node: NodeHierarchyItemId::NONE,
639                },
640                OptionLogicalPosition::None,
641                OptionLogicalPosition::None,
642            );
643            let timer_info =
644                TimerCallbackInfo::create(info, OptionDomNodeId::None, Instant::now(), 0, false);
645            scroll_physics_timer_callback(data.clone(), timer_info)
646        }
647
648        /// Drain the `CallbackChange`s pushed so far.
649        fn take_changes(&self) -> Vec<CallbackChange> {
650            self.changes
651                .lock()
652                .map(|mut c| core::mem::take(&mut *c))
653                .unwrap_or_default()
654        }
655
656        /// Drain the change log, asserting every entry is a `ScrollTo`, and
657        /// return `(node index, position, unclamped)` for each.
658        fn take_scroll_tos(&self) -> Vec<(usize, LogicalPosition, bool)> {
659            self.take_changes()
660                .iter()
661                .map(|change| {
662                    let CallbackChange::ScrollTo {
663                        node_id,
664                        position,
665                        unclamped,
666                        ..
667                    } = change
668                    else {
669                        panic!("expected only ScrollTo changes, got {change:?}");
670                    };
671                    let idx = node_id
672                        .into_crate_internal()
673                        .expect("ScrollTo must name a concrete node")
674                        .index();
675                    (idx, *position, *unclamped)
676                })
677                .collect()
678        }
679    }
680
681    /// Builds a callback environment. `setup` may register scroll nodes on the
682    /// `LayoutWindow` before it is frozen behind the shared reference.
683    fn with_env<R>(setup: impl FnOnce(&mut LayoutWindow), f: impl FnOnce(&mut Env<'_>) -> R) -> R {
684        let mut layout_window =
685            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
686        setup(&mut layout_window);
687
688        let renderer_resources = RendererResources::default();
689        let previous_window_state: Option<FullWindowState> = None;
690        let current_window_state = FullWindowState::default();
691        let gl_context = OptionGlContextPtr::None;
692        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
693            BTreeMap::new();
694        let window_handle = RawWindowHandle::Unsupported;
695        let system_callbacks = ExternalSystemCallbacks::rust_internal();
696
697        let ref_data = CallbackInfoRefData {
698            layout_window: &layout_window,
699            renderer_resources: &renderer_resources,
700            previous_window_state: &previous_window_state,
701            current_window_state: &current_window_state,
702            gl_context: &gl_context,
703            current_scroll_manager: &scroll_states,
704            current_window_handle: &window_handle,
705            system_callbacks: &system_callbacks,
706            system_style: Arc::new(SystemStyle::default()),
707            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
708            #[cfg(feature = "icu")]
709            icu_localizer: IcuLocalizerHandle::default(),
710            ctx: OptionRefAny::None,
711        };
712
713        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
714        let mut env = Env {
715            ref_data: &ref_data,
716            changes: &changes,
717        };
718        f(&mut env)
719    }
720
721    /// Registers node `idx` of the root DOM as a scrollable node with a
722    /// `container_w x container_h` viewport over `content_w x content_h` content
723    /// (so `max_scroll_x = content_w - container_w`, clamped at 0).
724    fn register_node(
725        window: &mut LayoutWindow,
726        idx: usize,
727        container: (f32, f32),
728        content: (f32, f32),
729    ) {
730        window.scroll_manager.register_or_update_scroll_node(
731            DomId::ROOT_ID,
732            NodeId::new(idx),
733            LogicalRect::new(
734                LogicalPosition::zero(),
735                LogicalSize::new(container.0, container.1),
736            ),
737            LogicalSize::new(content.0, content.1),
738            Instant::now(),
739            0.0,
740            0.0,
741            false,
742            false,
743        );
744    }
745
746    /// A scroll input for node `idx` of the root DOM.
747    fn input(idx: usize, delta: (f32, f32), source: ScrollInputSource) -> ScrollInput {
748        ScrollInput {
749            dom_id: DomId::ROOT_ID,
750            node_id: NodeId::new(idx),
751            delta: LogicalPosition::new(delta.0, delta.1),
752            timestamp: Instant::now(),
753            source,
754        }
755    }
756
757    /// A `ScrollPhysicsState` wrapped in a `RefAny`, plus the queue that feeds it.
758    fn state_with(physics: ScrollPhysics) -> (RefAny, ScrollInputQueue) {
759        let queue = ScrollInputQueue::new();
760        let state = ScrollPhysicsState::new(queue.clone(), physics);
761        (RefAny::new(state), queue)
762    }
763
764    fn key(idx: usize) -> (DomId, NodeId) {
765        (DomId::ROOT_ID, NodeId::new(idx))
766    }
767
768    /// Reads the physics state back out of the `RefAny` after a tick.
769    fn with_state<R>(data: &mut RefAny, f: impl FnOnce(&ScrollPhysicsState) -> R) -> R {
770        let state = data
771            .downcast_ref::<ScrollPhysicsState>()
772            .expect("RefAny must still hold a ScrollPhysicsState");
773        f(&state)
774    }
775
776    /// A `ScrollPhysics` whose every float field is `NaN` and every integer field
777    /// is degenerate — except `max_velocity`, which must stay non-NaN and
778    /// non-negative or `f32::clamp` panics (see the `known_hazard` tests below).
779    fn nan_physics() -> ScrollPhysics {
780        ScrollPhysics {
781            smooth_scroll_duration_ms: 0,
782            deceleration_rate: f32::NAN,
783            min_velocity_threshold: f32::NAN,
784            max_velocity: 0.0,
785            wheel_multiplier: f32::NAN,
786            invert_direction: false,
787            overscroll_elasticity: f32::NAN,
788            max_overscroll_distance: f32::NAN,
789            bounce_back_duration_ms: 0,
790            timer_interval_ms: 0,
791        }
792    }
793
794    // ==================================================================
795    // calculate_overshoot — numeric
796    // ==================================================================
797
798    #[test]
799    fn calculate_overshoot_returns_zero_inside_the_range_and_on_both_boundaries() {
800        assert_eq!(calculate_overshoot(0.0, 0.0, 100.0), 0.0);
801        assert_eq!(calculate_overshoot(100.0, 0.0, 100.0), 0.0);
802        assert_eq!(calculate_overshoot(50.0, 0.0, 100.0), 0.0);
803        // Degenerate range (min == max): only that single point is in range.
804        assert_eq!(calculate_overshoot(0.0, 0.0, 0.0), 0.0);
805        // -0.0 is neither < 0.0 nor > 0.0, so it counts as in-range.
806        assert_eq!(calculate_overshoot(-0.0, 0.0, 100.0), 0.0);
807    }
808
809    #[test]
810    fn calculate_overshoot_is_signed_by_which_boundary_was_crossed() {
811        assert_eq!(calculate_overshoot(-10.0, 0.0, 100.0), -10.0);
812        assert_eq!(calculate_overshoot(110.0, 0.0, 100.0), 10.0);
813        // Negative range: overshoot is still measured relative to the boundary.
814        assert_eq!(calculate_overshoot(-30.0, -20.0, -10.0), -10.0);
815        assert_eq!(calculate_overshoot(0.0, -20.0, -10.0), 10.0);
816    }
817
818    #[test]
819    fn calculate_overshoot_nan_position_reports_no_overshoot() {
820        // Both `NaN < min` and `NaN > max` are false, so the in-range branch wins
821        // and a NaN position is reported as "not overshooting" rather than
822        // propagating NaN into the spring force.
823        let out = calculate_overshoot(f32::NAN, 0.0, 100.0);
824        assert!(!out.is_nan(), "NaN must not leak out of calculate_overshoot");
825        assert_eq!(out, 0.0);
826        // A NaN bound, however, does make every position look "in range".
827        assert_eq!(calculate_overshoot(1e9, 0.0, f32::NAN), 0.0);
828        assert_eq!(calculate_overshoot(-1e9, f32::NAN, 100.0), 0.0);
829    }
830
831    #[test]
832    fn calculate_overshoot_infinite_position_saturates_without_panicking() {
833        assert_eq!(calculate_overshoot(f32::INFINITY, 0.0, 100.0), f32::INFINITY);
834        assert_eq!(
835            calculate_overshoot(f32::NEG_INFINITY, 0.0, 100.0),
836            f32::NEG_INFINITY
837        );
838        // inf - inf would be NaN; the boundary check keeps us in-range instead.
839        assert_eq!(
840            calculate_overshoot(f32::INFINITY, f32::NEG_INFINITY, f32::INFINITY),
841            0.0
842        );
843    }
844
845    #[test]
846    fn calculate_overshoot_extreme_finite_range_overflows_to_infinity_not_a_panic() {
847        // f32::MAX - f32::MIN is not representable -> +inf. Defined, no panic.
848        let out = calculate_overshoot(f32::MAX, f32::MIN, f32::MIN);
849        assert!(out.is_infinite() && out.is_sign_positive());
850        let out = calculate_overshoot(f32::MIN, f32::MAX, f32::MAX);
851        assert!(out.is_infinite() && out.is_sign_negative());
852    }
853
854    #[test]
855    fn calculate_overshoot_inverted_range_is_deterministic() {
856        // min > max: the `pos < min` branch is checked first, so everything below
857        // `min` reads as a negative overshoot. No panic, no assertion inside.
858        assert_eq!(calculate_overshoot(5.0, 10.0, 0.0), -5.0);
859        assert_eq!(calculate_overshoot(20.0, 10.0, 0.0), 20.0);
860    }
861
862    // ==================================================================
863    // rubber_band_clamp — numeric
864    // ==================================================================
865
866    #[test]
867    fn rubber_band_clamp_is_the_identity_inside_the_range() {
868        assert_eq!(rubber_band_clamp(0.0, 0.0, 100.0, 50.0, 0.5), 0.0);
869        assert_eq!(rubber_band_clamp(50.0, 0.0, 100.0, 50.0, 0.5), 50.0);
870        assert_eq!(rubber_band_clamp(100.0, 0.0, 100.0, 50.0, 0.5), 100.0);
871    }
872
873    #[test]
874    fn rubber_band_clamp_with_zero_max_overscroll_hard_clamps_to_the_boundary() {
875        assert_eq!(rubber_band_clamp(1000.0, 0.0, 100.0, 0.0, 0.5), 100.0);
876        assert_eq!(rubber_band_clamp(-1000.0, 0.0, 100.0, 0.0, 0.5), 0.0);
877        // A negative max_overscroll takes the same `else` branch (no bounce).
878        assert_eq!(rubber_band_clamp(1000.0, 0.0, 100.0, -50.0, 0.5), 100.0);
879        assert_eq!(rubber_band_clamp(-1000.0, 0.0, 100.0, -50.0, 0.5), 0.0);
880    }
881
882    #[test]
883    fn rubber_band_clamp_with_zero_elasticity_pins_to_the_boundary() {
884        // 1 - e^0 == 0, so no overshoot displacement at all.
885        assert_eq!(rubber_band_clamp(1000.0, 0.0, 100.0, 120.0, 0.0), 100.0);
886        assert_eq!(rubber_band_clamp(-1000.0, 0.0, 100.0, 120.0, 0.0), 0.0);
887    }
888
889    #[test]
890    fn rubber_band_clamp_never_exceeds_max_overscroll_even_for_absurd_input() {
891        let (min, max, max_over, elast) = (0.0, 100.0, 120.0, 0.5);
892        for raw in [101.0_f32, 500.0, 1e6, 1e30, f32::MAX, f32::INFINITY] {
893            let out = rubber_band_clamp(raw, min, max, max_over, elast);
894            assert!(out.is_finite(), "raw={raw} produced {out}");
895            assert!(
896                out >= max && out <= max + max_over,
897                "raw={raw} escaped the overscroll band: {out}"
898            );
899        }
900        for raw in [-1.0_f32, -500.0, -1e6, -1e30, f32::MIN, f32::NEG_INFINITY] {
901            let out = rubber_band_clamp(raw, min, max, max_over, elast);
902            assert!(out.is_finite(), "raw={raw} produced {out}");
903            assert!(
904                out <= min && out >= min - max_over,
905                "raw={raw} escaped the overscroll band: {out}"
906            );
907        }
908        // The band is approached asymptotically: an infinite pull lands exactly on it.
909        assert_eq!(
910            rubber_band_clamp(f32::INFINITY, min, max, max_over, elast),
911            max + max_over
912        );
913        assert_eq!(
914            rubber_band_clamp(f32::NEG_INFINITY, min, max, max_over, elast),
915            min - max_over
916        );
917    }
918
919    #[test]
920    fn rubber_band_clamp_has_diminishing_returns_and_stays_monotonic() {
921        let (min, max, max_over, elast) = (0.0, 100.0, 100.0, 0.5);
922        let mut previous = max;
923        for raw in [110.0_f32, 120.0, 200.0, 400.0, 800.0] {
924            let out = rubber_band_clamp(raw, min, max, max_over, elast);
925            // Monotonically increasing...
926            assert!(out > previous, "not monotonic at raw={raw}: {out} <= {previous}");
927            // ...but always giving back less than the raw pull (springy resistance).
928            assert!(
929                out < raw,
930                "raw={raw} was not resisted at all (got {out})"
931            );
932            previous = out;
933        }
934    }
935
936    #[test]
937    fn rubber_band_clamp_nan_inputs_are_defined_and_do_not_panic() {
938        // NaN fails both in-range comparisons, falls into the `raw_pos >= max`
939        // branch, and NaN propagates to the result. The caller
940        // (`scroll_to_unclamped` -> change processor) sanitises it later.
941        assert!(rubber_band_clamp(f32::NAN, 0.0, 100.0, 120.0, 0.5).is_nan());
942        // A NaN elasticity / max_overscroll must not panic either.
943        assert!(rubber_band_clamp(500.0, 0.0, 100.0, 120.0, f32::NAN).is_nan());
944        // NaN max_overscroll is not > 0.0, so the no-bounce branch pins the boundary.
945        assert_eq!(rubber_band_clamp(500.0, 0.0, 100.0, f32::NAN, 0.5), 100.0);
946    }
947
948    #[test]
949    fn rubber_band_clamp_negative_elasticity_stays_non_nan() {
950        // A negative elasticity inverts the exponential (e^+x): the "resistance"
951        // becomes an amplification. It is nonsense physically, but it must not
952        // panic and must not produce NaN.
953        for raw in [110.0_f32, 1e6, f32::MAX] {
954            let out = rubber_band_clamp(raw, 0.0, 100.0, 100.0, -1.0);
955            assert!(!out.is_nan(), "raw={raw} produced NaN");
956        }
957        // Small overshoot with negative elasticity: still finite and defined.
958        let out = rubber_band_clamp(110.0, 0.0, 100.0, 100.0, -1.0);
959        assert!(out.is_finite());
960        assert!(out < 100.0, "negative elasticity flips the sign: {out}");
961    }
962
963    #[test]
964    fn rubber_band_clamp_degenerate_range_still_returns_a_boundary() {
965        // min == max: everything except that point overshoots.
966        assert_eq!(rubber_band_clamp(0.0, 0.0, 0.0, 100.0, 0.5), 0.0);
967        let out = rubber_band_clamp(10.0, 0.0, 0.0, 100.0, 0.5);
968        assert!(out > 0.0 && out <= 100.0, "{out}");
969        let out = rubber_band_clamp(-10.0, 0.0, 0.0, 100.0, 0.5);
970        assert!((-100.0..0.0).contains(&out), "{out}");
971    }
972
973    // ==================================================================
974    // friction_from_deceleration — numeric
975    // ==================================================================
976
977    #[test]
978    fn friction_from_deceleration_matches_the_documented_values() {
979        assert!((friction_from_deceleration(0.95) - 0.05).abs() < 1e-6);
980        assert!((friction_from_deceleration(0.998) - 0.002).abs() < 1e-6);
981        assert!((friction_from_deceleration(0.0) - 1.0).abs() < 1e-6);
982    }
983
984    #[test]
985    fn friction_from_deceleration_clamps_both_ends_and_never_returns_zero() {
986        // Anything >= 0.999 collapses onto the 0.001 friction floor: a
987        // deceleration_rate of exactly 1.0 ("never stops") must NOT produce a
988        // zero friction, or momentum would run forever.
989        assert_eq!(friction_from_deceleration(1.0), 0.001);
990        assert_eq!(friction_from_deceleration(0.999), 0.001);
991        assert_eq!(friction_from_deceleration(f32::MAX), 0.001);
992        assert_eq!(friction_from_deceleration(f32::INFINITY), 0.001);
993        // Anything <= 0.0 saturates to full friction.
994        assert_eq!(friction_from_deceleration(-0.0), 1.0);
995        assert_eq!(friction_from_deceleration(-5.0), 1.0);
996        assert_eq!(friction_from_deceleration(f32::MIN), 1.0);
997        assert_eq!(friction_from_deceleration(f32::NEG_INFINITY), 1.0);
998    }
999
1000    #[test]
1001    fn friction_from_deceleration_nan_falls_back_to_the_floor() {
1002        // f32::clamp(NaN) == NaN, but `NaN.max(0.001)` == 0.001 (f32::max ignores
1003        // NaN), so the friction floor rescues the whole physics integration.
1004        let out = friction_from_deceleration(f32::NAN);
1005        assert!(!out.is_nan(), "NaN deceleration must not poison friction");
1006        assert_eq!(out, 0.001);
1007    }
1008
1009    #[test]
1010    fn friction_from_deceleration_always_yields_a_usable_decay_factor() {
1011        let dt = 16.0 / 1000.0;
1012        for rate in [
1013            0.0_f32,
1014            0.5,
1015            0.9,
1016            0.95,
1017            0.996,
1018            0.998,
1019            0.999,
1020            1.0,
1021            -1.0,
1022            f32::NAN,
1023            f32::INFINITY,
1024            f32::NEG_INFINITY,
1025            f32::MAX,
1026            f32::MIN,
1027            f32::MIN_POSITIVE,
1028        ] {
1029            let friction = friction_from_deceleration(rate);
1030            assert!(friction.is_finite(), "rate={rate} -> {friction}");
1031            assert!(
1032                (0.001..=1.0).contains(&friction),
1033                "rate={rate} -> friction {friction} outside [0.001, 1.0]"
1034            );
1035            // This is exactly how the callback uses it: exp(-friction*dt*60).
1036            let decay = (-friction * dt * ASSUMED_FPS).exp();
1037            assert!(decay.is_finite() && decay > 0.0 && decay < 1.0, "rate={rate} -> decay {decay}");
1038        }
1039    }
1040
1041    // ==================================================================
1042    // spring_constant_from_bounce_duration — numeric
1043    // ==================================================================
1044
1045    #[test]
1046    fn spring_constant_clamps_short_durations_to_50ms() {
1047        let floor = spring_constant_from_bounce_duration(50);
1048        assert_eq!(spring_constant_from_bounce_duration(0), floor);
1049        assert_eq!(spring_constant_from_bounce_duration(1), floor);
1050        assert_eq!(spring_constant_from_bounce_duration(49), floor);
1051        // (2*pi / 0.05)^2 ~= 15791.4
1052        assert!((floor - 15791.37).abs() < 1.0, "{floor}");
1053    }
1054
1055    #[test]
1056    fn spring_constant_decreases_monotonically_with_duration() {
1057        let mut previous = f32::INFINITY;
1058        for ms in [50_u32, 100, 200, 300, 400, 500, 1000, 10_000] {
1059            let k = spring_constant_from_bounce_duration(ms);
1060            assert!(k.is_finite() && k > 0.0, "ms={ms} -> {k}");
1061            assert!(k < previous, "ms={ms}: {k} did not decrease below {previous}");
1062            previous = k;
1063        }
1064    }
1065
1066    #[test]
1067    fn spring_constant_stays_finite_and_positive_at_u32_max() {
1068        // duration_ms = u32::MAX -> ~4295 seconds -> a vanishingly small k.
1069        // It must stay > 0 so that `2 * k.sqrt()` (the damping term) is not NaN.
1070        let k = spring_constant_from_bounce_duration(u32::MAX);
1071        assert!(k.is_finite(), "{k}");
1072        assert!(k > 0.0, "{k}");
1073        assert!(k < 1e-6, "{k}");
1074    }
1075
1076    #[test]
1077    fn spring_constant_damping_term_is_always_finite() {
1078        // The callback computes `2.0 * spring_k.sqrt()`; a negative or NaN k
1079        // would make the critical-damping coefficient NaN.
1080        for ms in [0_u32, 1, 50, 400, 1000, u32::MAX / 2, u32::MAX - 1, u32::MAX] {
1081            let k = spring_constant_from_bounce_duration(ms);
1082            let damping = 2.0 * k.sqrt();
1083            assert!(damping.is_finite() && damping > 0.0, "ms={ms} -> damping {damping}");
1084        }
1085    }
1086
1087    // ==================================================================
1088    // node_allows_rubber_band — predicate / numeric
1089    // ==================================================================
1090
1091    #[test]
1092    fn node_allows_rubber_band_requires_actual_overflow_on_the_axis() {
1093        // An axis with no overflow can never rubber-band, whatever the config.
1094        for max_scroll in [0.0_f32, -0.0, -1.0, -1e9, f32::MIN, f32::NEG_INFINITY] {
1095            assert!(
1096                !node_allows_rubber_band(
1097                    max_scroll,
1098                    OverscrollBehavior::Auto,
1099                    OverflowScrolling::Touch,
1100                    1.0
1101                ),
1102                "max_scroll={max_scroll} must not rubber-band"
1103            );
1104        }
1105    }
1106
1107    #[test]
1108    fn node_allows_rubber_band_is_vetoed_by_overscroll_behavior_none() {
1109        // `overscroll-behavior: none` wins over -azul-overflow-scrolling: touch
1110        // and over a fully elastic global config.
1111        assert!(!node_allows_rubber_band(
1112            400.0,
1113            OverscrollBehavior::None,
1114            OverflowScrolling::Touch,
1115            1.0
1116        ));
1117        assert!(!node_allows_rubber_band(
1118            f32::MAX,
1119            OverscrollBehavior::None,
1120            OverflowScrolling::Auto,
1121            1.0
1122        ));
1123    }
1124
1125    #[test]
1126    fn node_allows_rubber_band_touch_overrides_a_zero_global_elasticity() {
1127        // -azul-overflow-scrolling: touch opts in even on a Windows-like
1128        // (elasticity 0.0) global config.
1129        assert!(node_allows_rubber_band(
1130            400.0,
1131            OverscrollBehavior::Auto,
1132            OverflowScrolling::Touch,
1133            0.0
1134        ));
1135        // `contain` blocks chaining but still permits the local bounce.
1136        assert!(node_allows_rubber_band(
1137            400.0,
1138            OverscrollBehavior::Contain,
1139            OverflowScrolling::Touch,
1140            0.0
1141        ));
1142    }
1143
1144    #[test]
1145    fn node_allows_rubber_band_otherwise_follows_the_global_elasticity() {
1146        let ask = |elasticity: f32| {
1147            node_allows_rubber_band(
1148                400.0,
1149                OverscrollBehavior::Auto,
1150                OverflowScrolling::Auto,
1151                elasticity,
1152            )
1153        };
1154        assert!(!ask(0.0));
1155        assert!(!ask(-0.0));
1156        assert!(!ask(-1.0));
1157        assert!(!ask(f32::NEG_INFINITY));
1158        // NaN > 0.0 is false -> no bounce. Defined, no panic.
1159        assert!(!ask(f32::NAN));
1160        assert!(ask(f32::MIN_POSITIVE));
1161        assert!(ask(0.3));
1162        assert!(ask(f32::INFINITY));
1163    }
1164
1165    #[test]
1166    fn node_allows_rubber_band_contain_still_bounces_locally() {
1167        // CSS: `contain` stops scroll *chaining*, not the local overscroll effect.
1168        assert!(node_allows_rubber_band(
1169            400.0,
1170            OverscrollBehavior::Contain,
1171            OverflowScrolling::Auto,
1172            0.5
1173        ));
1174        assert!(!node_allows_rubber_band(
1175            400.0,
1176            OverscrollBehavior::Contain,
1177            OverflowScrolling::Auto,
1178            0.0
1179        ));
1180    }
1181
1182    #[test]
1183    fn node_allows_rubber_band_nan_max_scroll_is_treated_as_overflowing() {
1184        // NOTE (quirk, asserted so a change is noticed): `NaN <= 0.0` is false,
1185        // so a NaN max_scroll slips past the "has overflow" gate and the node is
1186        // allowed to rubber-band against a NaN boundary. Not reachable from a
1187        // sane layout (max_scroll comes from `(content - container).max(0.0)`),
1188        // but it is not defended against here either.
1189        assert!(node_allows_rubber_band(
1190            f32::NAN,
1191            OverscrollBehavior::Auto,
1192            OverflowScrolling::Auto,
1193            0.5
1194        ));
1195        assert!(node_allows_rubber_band(
1196            f32::NAN,
1197            OverscrollBehavior::Auto,
1198            OverflowScrolling::Touch,
1199            0.0
1200        ));
1201        // The other two vetoes still apply, NaN or not.
1202        assert!(!node_allows_rubber_band(
1203            f32::NAN,
1204            OverscrollBehavior::None,
1205            OverflowScrolling::Touch,
1206            1.0
1207        ));
1208    }
1209
1210    // ==================================================================
1211    // ScrollPhysicsState::new — constructor
1212    // ==================================================================
1213
1214    #[test]
1215    fn new_starts_empty_and_keeps_the_config_verbatim() {
1216        for physics in [
1217            ScrollPhysics::default(),
1218            ScrollPhysics::ios(),
1219            ScrollPhysics::macos(),
1220            ScrollPhysics::windows(),
1221            ScrollPhysics::android(),
1222            nan_physics(),
1223        ] {
1224            let state = ScrollPhysicsState::new(ScrollInputQueue::new(), physics);
1225            assert!(state.node_velocities.is_empty());
1226            assert!(state.pending_positions.is_empty());
1227            assert!(state.pending_trackpad_positions.is_empty());
1228            assert!(!state.input_queue.has_pending());
1229            // Config is stored verbatim (compare a field that is not NaN).
1230            assert_eq!(
1231                state.scroll_physics.timer_interval_ms,
1232                physics.timer_interval_ms
1233            );
1234            assert_eq!(state.scroll_physics.max_velocity, physics.max_velocity);
1235        }
1236    }
1237
1238    #[test]
1239    fn new_shares_the_input_queue_rather_than_copying_it() {
1240        // The whole architecture depends on this: the event handler pushes into
1241        // its clone of the queue and the timer must see it.
1242        let queue = ScrollInputQueue::new();
1243        let state = ScrollPhysicsState::new(queue.clone(), ScrollPhysics::default());
1244        assert!(!state.input_queue.has_pending());
1245
1246        queue.push(input(0, (0.0, 10.0), ScrollInputSource::WheelDiscrete));
1247        assert!(
1248            state.input_queue.has_pending(),
1249            "the queue must be shared (Arc), not deep-copied"
1250        );
1251
1252        let taken = state.input_queue.take_recent(MAX_SCROLL_EVENTS_PER_TICK);
1253        assert_eq!(taken.len(), 1);
1254        assert!(!queue.has_pending(), "draining the timer side drains both");
1255    }
1256
1257    // ==================================================================
1258    // ScrollPhysicsState::is_active — predicate
1259    // ==================================================================
1260
1261    #[test]
1262    fn is_active_is_false_for_a_fresh_state() {
1263        let state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
1264        assert!(!state.is_active());
1265    }
1266
1267    #[test]
1268    fn is_active_is_true_while_inputs_are_pending() {
1269        let queue = ScrollInputQueue::new();
1270        let state = ScrollPhysicsState::new(queue.clone(), ScrollPhysics::default());
1271        queue.push(input(0, (0.0, 1.0), ScrollInputSource::WheelDiscrete));
1272        assert!(state.is_active());
1273    }
1274
1275    #[test]
1276    fn is_active_uses_a_strict_greater_than_against_the_threshold() {
1277        let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
1278        let threshold = state.scroll_physics.min_velocity_threshold; // 50.0
1279        let at = |velocity: LogicalPosition| NodeScrollPhysics {
1280            velocity,
1281            is_rubber_banding: false,
1282        };
1283
1284        // Exactly at the threshold is NOT active (strict `>`).
1285        state
1286            .node_velocities
1287            .insert(key(0), at(LogicalPosition::new(0.0, threshold)));
1288        assert!(!state.is_active(), "velocity == threshold must not be active");
1289
1290        // A hair above it is.
1291        state
1292            .node_velocities
1293            .insert(key(0), at(LogicalPosition::new(0.0, threshold * 1.0001)));
1294        assert!(state.is_active());
1295
1296        // Either axis is enough, and the sign does not matter.
1297        state
1298            .node_velocities
1299            .insert(key(0), at(LogicalPosition::new(-threshold * 2.0, 0.0)));
1300        assert!(state.is_active(), "|velocity| is what counts, not the sign");
1301    }
1302
1303    #[test]
1304    fn is_active_is_true_while_rubber_banding_even_at_zero_velocity() {
1305        let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
1306        state.node_velocities.insert(
1307            key(0),
1308            NodeScrollPhysics {
1309                velocity: LogicalPosition::zero(),
1310                is_rubber_banding: true,
1311            },
1312        );
1313        assert!(
1314            state.is_active(),
1315            "the spring-back animation must keep the timer alive"
1316        );
1317    }
1318
1319    #[test]
1320    fn is_active_is_true_while_positions_are_pending() {
1321        let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
1322        state
1323            .pending_positions
1324            .insert(key(0), LogicalPosition::zero());
1325        assert!(state.is_active());
1326
1327        let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
1328        state
1329            .pending_trackpad_positions
1330            .insert(key(0), LogicalPosition::zero());
1331        assert!(state.is_active());
1332    }
1333
1334    #[test]
1335    fn is_active_treats_nan_velocity_as_inactive_without_panicking() {
1336        // NaN.abs() > threshold is false -> the node reads as at rest. The
1337        // important part is that this is deterministic and does not panic.
1338        let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
1339        state.node_velocities.insert(
1340            key(0),
1341            NodeScrollPhysics {
1342                velocity: LogicalPosition::new(f32::NAN, f32::NAN),
1343                is_rubber_banding: false,
1344            },
1345        );
1346        assert!(!state.is_active());
1347
1348        // A NaN *threshold* likewise never reports active.
1349        let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), nan_physics());
1350        state.node_velocities.insert(
1351            key(0),
1352            NodeScrollPhysics {
1353                velocity: LogicalPosition::new(1e9, 1e9),
1354                is_rubber_banding: false,
1355            },
1356        );
1357        assert!(!state.is_active());
1358    }
1359
1360    #[test]
1361    fn is_active_with_infinite_velocity_is_true() {
1362        let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
1363        state.node_velocities.insert(
1364            key(0),
1365            NodeScrollPhysics {
1366                velocity: LogicalPosition::new(f32::INFINITY, f32::NEG_INFINITY),
1367                is_rubber_banding: false,
1368            },
1369        );
1370        assert!(state.is_active());
1371    }
1372
1373    // ==================================================================
1374    // scroll_physics_timer_callback — smoke / integration
1375    // ==================================================================
1376
1377    #[test]
1378    fn callback_with_a_foreign_refany_terminates_instead_of_panicking() {
1379        let data = RefAny::new(42_u32);
1380        with_env(|_| {}, |env| {
1381            let ret = env.tick(&data);
1382            assert_eq!(ret.should_terminate, TerminateTimer::Terminate);
1383            assert_eq!(ret.should_update, Update::DoNothing);
1384            assert!(env.take_changes().is_empty());
1385        });
1386    }
1387
1388    #[test]
1389    fn callback_with_nothing_to_do_terminates_the_timer() {
1390        let (data, _queue) = state_with(ScrollPhysics::default());
1391        with_env(|_| {}, |env| {
1392            let ret = env.tick(&data);
1393            assert_eq!(
1394                ret.should_terminate,
1395                TerminateTimer::Terminate,
1396                "an idle physics timer must not keep spinning"
1397            );
1398            assert!(env.take_changes().is_empty());
1399        });
1400    }
1401
1402    #[test]
1403    fn callback_programmatic_input_pushes_a_hard_clamped_scroll_to() {
1404        let (data, queue) = state_with(ScrollPhysics::default());
1405        // Viewport 100x100 over 100x500 content -> max_scroll = (0, 400).
1406        queue.push(input(3, (0.0, 10_000.0), ScrollInputSource::Programmatic));
1407
1408        with_env(
1409            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
1410            |env| {
1411                let ret = env.tick(&data);
1412                assert_eq!(ret.should_terminate, TerminateTimer::Continue);
1413                // Scroll is applied via nodes_scrolled_in_callbacks, not a relayout.
1414                assert_eq!(ret.should_update, Update::DoNothing);
1415
1416                let scrolls = env.take_scroll_tos();
1417                assert_eq!(scrolls.len(), 1);
1418                let (idx, pos, unclamped) = scrolls[0];
1419                assert_eq!(idx, 3);
1420                assert!(!unclamped, "programmatic scroll must be hard-clamped");
1421                assert_eq!(pos.x, 0.0);
1422                assert_eq!(pos.y, 400.0, "a 10000px jump must clamp to max_scroll_y");
1423            },
1424        );
1425    }
1426
1427    #[test]
1428    fn callback_programmatic_negative_input_clamps_to_zero() {
1429        let (data, queue) = state_with(ScrollPhysics::default());
1430        queue.push(input(3, (-1e9, -1e9), ScrollInputSource::Programmatic));
1431
1432        with_env(
1433            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
1434            |env| {
1435                let _ = env.tick(&data);
1436                let scrolls = env.take_scroll_tos();
1437                assert_eq!(scrolls.len(), 1);
1438                assert_eq!(scrolls[0].1, LogicalPosition::zero());
1439            },
1440        );
1441    }
1442
1443    #[test]
1444    fn callback_trackpad_overshoot_is_bounded_by_max_overscroll_distance() {
1445        // iOS physics: elasticity 0.5, max_overscroll_distance 120.
1446        let physics = ScrollPhysics::ios();
1447        let (data, queue) = state_with(physics);
1448        queue.push(input(3, (0.0, 1e9), ScrollInputSource::TrackpadContinuous));
1449
1450        with_env(
1451            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
1452            |env| {
1453                let ret = env.tick(&data);
1454                assert_eq!(ret.should_terminate, TerminateTimer::Continue);
1455
1456                let scrolls = env.take_scroll_tos();
1457                assert_eq!(scrolls.len(), 1);
1458                let (idx, pos, unclamped) = scrolls[0];
1459                assert_eq!(idx, 3);
1460                assert!(unclamped, "the timer does its own rubber-band clamping");
1461                assert!(pos.y.is_finite());
1462                // max_scroll_y (400) + max_overscroll_distance (120) is the ceiling.
1463                assert!(
1464                    pos.y > 400.0 && pos.y <= 400.0 + physics.max_overscroll_distance + 1e-3,
1465                    "a 1e9 px flick escaped the overscroll band: {}",
1466                    pos.y
1467                );
1468                // The x axis has no overflow -> no bounce, hard 0.
1469                assert_eq!(pos.x, 0.0);
1470            },
1471        );
1472    }
1473
1474    #[test]
1475    fn callback_trackpad_without_elasticity_hard_clamps() {
1476        // Windows physics: elasticity 0.0, max_overscroll_distance 0.0.
1477        let (data, queue) = state_with(ScrollPhysics::windows());
1478        queue.push(input(3, (0.0, 1e9), ScrollInputSource::TrackpadContinuous));
1479
1480        with_env(
1481            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
1482            |env| {
1483                let _ = env.tick(&data);
1484                let scrolls = env.take_scroll_tos();
1485                assert_eq!(scrolls.len(), 1);
1486                assert_eq!(scrolls[0].1.y, 400.0, "no bounce -> pinned to max_scroll_y");
1487            },
1488        );
1489    }
1490
1491    #[test]
1492    fn callback_wheel_impulse_is_clamped_to_max_velocity() {
1493        let physics = ScrollPhysics::default(); // max_velocity 8000, wheel_multiplier 1.0
1494        let (mut data, queue) = state_with(physics);
1495        // delta * wheel_multiplier * 60 would be 6e10 / -inf without the clamp.
1496        queue.push(input(0, (1e9, -1e9), ScrollInputSource::WheelDiscrete));
1497        queue.push(input(1, (f32::INFINITY, f32::NEG_INFINITY), ScrollInputSource::WheelDiscrete));
1498
1499        with_env(|_| {}, |env| {
1500            let ret = env.tick(&data);
1501            assert_eq!(ret.should_terminate, TerminateTimer::Continue);
1502        });
1503
1504        with_state(&mut data, |state| {
1505            for idx in [0_usize, 1] {
1506                let node = state
1507                    .node_velocities
1508                    .get(&key(idx))
1509                    .unwrap_or_else(|| panic!("node {idx} lost its velocity"));
1510                assert_eq!(node.velocity.x, physics.max_velocity, "node {idx}");
1511                assert_eq!(node.velocity.y, -physics.max_velocity, "node {idx}");
1512            }
1513        });
1514    }
1515
1516    #[test]
1517    fn callback_wheel_momentum_decays_and_never_leaves_the_scroll_bounds() {
1518        let (mut data, queue) = state_with(ScrollPhysics::default());
1519        queue.push(input(3, (0.0, 100.0), ScrollInputSource::WheelDiscrete));
1520
1521        with_env(
1522            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
1523            |env| {
1524                let mut ticks = 0;
1525                // The offset in the (immutable) LayoutWindow never advances, so
1526                // this isolates the decay: the timer MUST still wind down.
1527                loop {
1528                    let ret = env.tick(&data);
1529                    for (_, pos, _) in env.take_scroll_tos() {
1530                        assert!(pos.x.is_finite() && pos.y.is_finite());
1531                        assert!(
1532                            (0.0..=400.0).contains(&pos.y),
1533                            "tick {ticks}: y={} left [0, max_scroll_y]",
1534                            pos.y
1535                        );
1536                        assert_eq!(pos.x, 0.0);
1537                    }
1538                    ticks += 1;
1539                    if ret.should_terminate == TerminateTimer::Terminate {
1540                        break;
1541                    }
1542                    assert!(
1543                        ticks < 1000,
1544                        "momentum never decayed below the velocity threshold"
1545                    );
1546                }
1547                assert!(ticks > 1, "the fling should survive at least one tick");
1548            },
1549        );
1550
1551        with_state(&mut data, |state| {
1552            assert!(
1553                state.node_velocities.is_empty(),
1554                "a terminated timer must not leave live velocities behind"
1555            );
1556        });
1557    }
1558
1559    #[test]
1560    fn callback_caps_the_events_processed_per_tick() {
1561        let (data, queue) = state_with(ScrollPhysics::default());
1562        // 5x the cap, each targeting a distinct node so they cannot coalesce.
1563        let total = MAX_SCROLL_EVENTS_PER_TICK * 5;
1564        for i in 0..total {
1565            queue.push(input(i, (0.0, 1.0), ScrollInputSource::Programmatic));
1566        }
1567
1568        with_env(|_| {}, |env| {
1569            let ret = env.tick(&data);
1570            assert_eq!(ret.should_terminate, TerminateTimer::Continue);
1571
1572            let scrolls = env.take_scroll_tos();
1573            assert_eq!(
1574                scrolls.len(),
1575                MAX_SCROLL_EVENTS_PER_TICK,
1576                "the per-tick event budget must be enforced"
1577            );
1578            // take_recent keeps the NEWEST events, so the surviving nodes are the
1579            // last MAX_SCROLL_EVENTS_PER_TICK that were pushed.
1580            for (idx, _, _) in &scrolls {
1581                assert!(
1582                    *idx >= total - MAX_SCROLL_EVENTS_PER_TICK,
1583                    "node {idx} is a stale event that should have been dropped"
1584                );
1585            }
1586        });
1587
1588        assert!(
1589            !queue.has_pending(),
1590            "the backlog must be drained, not left to grow unboundedly"
1591        );
1592    }
1593
1594    #[test]
1595    fn callback_nan_delta_does_not_panic() {
1596        // Programmatic: the NaN reaches the change log (the change processor
1597        // sanitises it via AnimatedScrollState::clamp) but nothing panics.
1598        let (data, queue) = state_with(ScrollPhysics::default());
1599        queue.push(input(0, (f32::NAN, f32::NAN), ScrollInputSource::Programmatic));
1600
1601        with_env(|_| {}, |env| {
1602            let ret = env.tick(&data);
1603            assert_eq!(ret.should_terminate, TerminateTimer::Continue);
1604            let scrolls = env.take_scroll_tos();
1605            assert_eq!(scrolls.len(), 1);
1606            assert!(scrolls[0].1.x.is_nan() && scrolls[0].1.y.is_nan());
1607        });
1608    }
1609
1610    #[test]
1611    fn callback_nan_wheel_delta_drops_the_node_instead_of_spinning_forever() {
1612        // A NaN velocity survives the clamp, but `retain` uses `> 0.0` (false for
1613        // NaN) so the node is dropped and the timer terminates. Asserted so that a
1614        // regression into an un-killable NaN velocity loop is caught.
1615        let (mut data, queue) = state_with(ScrollPhysics::default());
1616        queue.push(input(0, (f32::NAN, f32::NAN), ScrollInputSource::WheelDiscrete));
1617
1618        with_env(|_| {}, |env| {
1619            let ret = env.tick(&data);
1620            assert_eq!(ret.should_terminate, TerminateTimer::Terminate);
1621            assert!(env.take_changes().is_empty());
1622        });
1623
1624        with_state(&mut data, |state| {
1625            assert!(state.node_velocities.is_empty());
1626        });
1627    }
1628
1629    #[test]
1630    fn callback_trackpad_end_on_an_unknown_node_is_a_no_op() {
1631        let (data, queue) = state_with(ScrollPhysics::ios());
1632        queue.push(input(7, (0.0, 0.0), ScrollInputSource::TrackpadEnd));
1633
1634        with_env(|_| {}, |env| {
1635            let ret = env.tick(&data);
1636            assert_eq!(ret.should_terminate, TerminateTimer::Terminate);
1637            assert!(env.take_changes().is_empty());
1638        });
1639    }
1640
1641    #[test]
1642    fn callback_trackpad_end_inside_the_bounds_does_not_start_a_spring_back() {
1643        let (mut data, queue) = state_with(ScrollPhysics::ios());
1644        queue.push(input(3, (0.0, 0.0), ScrollInputSource::TrackpadEnd));
1645
1646        with_env(
1647            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
1648            |env| {
1649                // offset is 0 (in range) -> no overshoot -> no rubber-banding.
1650                let _ = env.tick(&data);
1651                let scrolls = env.take_scroll_tos();
1652                assert_eq!(scrolls.len(), 1, "the position is re-pushed unclamped");
1653                assert!(scrolls[0].2, "TrackpadEnd re-pushes the raw position");
1654                assert_eq!(scrolls[0].1, LogicalPosition::zero());
1655            },
1656        );
1657
1658        with_state(&mut data, |state| {
1659            assert!(
1660                state.node_velocities.is_empty(),
1661                "no overshoot must not arm the spring"
1662            );
1663        });
1664    }
1665
1666    #[test]
1667    fn callback_degenerate_physics_config_does_not_panic() {
1668        // Every float NaN, every duration 0 (max_velocity stays 0.0: see the
1669        // `known_bug` tests for why a NaN/negative max_velocity panics).
1670        let (data, queue) = state_with(nan_physics());
1671        queue.push(input(0, (10.0, 10.0), ScrollInputSource::WheelDiscrete));
1672        queue.push(input(1, (10.0, 10.0), ScrollInputSource::TrackpadContinuous));
1673        queue.push(input(2, (10.0, 10.0), ScrollInputSource::Programmatic));
1674        queue.push(input(3, (10.0, 10.0), ScrollInputSource::TrackpadEnd));
1675
1676        with_env(
1677            |w| {
1678                register_node(w, 0, (100.0, 100.0), (100.0, 500.0));
1679                register_node(w, 1, (100.0, 100.0), (100.0, 500.0));
1680                register_node(w, 2, (100.0, 100.0), (100.0, 500.0));
1681                register_node(w, 3, (100.0, 100.0), (100.0, 500.0));
1682            },
1683            |env| {
1684                let ret = env.tick(&data);
1685                // Whatever it decides, it must decide *something* and not panic.
1686                assert!(matches!(
1687                    ret.should_terminate,
1688                    TerminateTimer::Continue | TerminateTimer::Terminate
1689                ));
1690                // A second tick over the resulting state must survive too.
1691                let _ = env.tick(&data);
1692            },
1693        );
1694    }
1695
1696    #[test]
1697    fn callback_zero_timer_interval_still_advances_time() {
1698        // dt = max(1) / 1000 -> a 0ms interval must not produce dt == 0 (which
1699        // would freeze the integration) nor a division by zero.
1700        let physics = ScrollPhysics {
1701            timer_interval_ms: 0,
1702            ..ScrollPhysics::default()
1703        };
1704        let (mut data, queue) = state_with(physics);
1705        queue.push(input(3, (0.0, 100.0), ScrollInputSource::WheelDiscrete));
1706
1707        with_env(
1708            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
1709            |env| {
1710                let ret = env.tick(&data);
1711                assert_eq!(ret.should_terminate, TerminateTimer::Continue);
1712                let scrolls = env.take_scroll_tos();
1713                assert_eq!(scrolls.len(), 1);
1714                let y = scrolls[0].1.y;
1715                assert!(y.is_finite() && y > 0.0 && y <= 400.0, "y={y}");
1716            },
1717        );
1718
1719        with_state(&mut data, |state| {
1720            let node = state.node_velocities.get(&key(3)).expect("velocity kept");
1721            assert!(node.velocity.y.is_finite());
1722        });
1723    }
1724
1725    #[test]
1726    fn callback_survives_a_huge_backlog_on_a_single_node() {
1727        // All events coalesce onto one node: the velocity impulses accumulate but
1728        // must stay clamped, and the queue must be fully drained.
1729        let physics = ScrollPhysics::default();
1730        let (mut data, queue) = state_with(physics);
1731        for _ in 0..(MAX_SCROLL_EVENTS_PER_TICK * 10) {
1732            queue.push(input(0, (0.0, 1e6), ScrollInputSource::WheelDiscrete));
1733        }
1734
1735        with_env(|_| {}, |env| {
1736            let ret = env.tick(&data);
1737            assert_eq!(ret.should_terminate, TerminateTimer::Continue);
1738        });
1739
1740        assert!(!queue.has_pending());
1741        with_state(&mut data, |state| {
1742            let node = state.node_velocities.get(&key(0)).expect("velocity kept");
1743            assert_eq!(
1744                node.velocity.y, physics.max_velocity,
1745                "1000 stacked impulses must not exceed max_velocity"
1746            );
1747        });
1748    }
1749
1750    // ------------------------------------------------------------------
1751    // A NaN or negative `max_velocity` (ScrollPhysics is a plain repr(C) struct
1752    // with no validation, so a bad SystemStyle can supply one) used to reach
1753    // `velocity.clamp(-max_velocity, max_velocity)` with min > max and panic
1754    // inside f32::clamp — which, in the extern "C" `scroll_physics_timer_callback`,
1755    // ABORTS the process. The branch now sanitizes with `.max(0.0)`; these tests
1756    // pin that the sanitized bound yields a safe clamp.
1757    // ------------------------------------------------------------------
1758
1759    #[test]
1760    fn nan_max_velocity_is_sanitized_to_a_safe_clamp() {
1761        let max_velocity = ScrollPhysics {
1762            max_velocity: f32::NAN,
1763            ..ScrollPhysics::default()
1764        }
1765        .max_velocity
1766        .max(0.0);
1767        assert_eq!(max_velocity, 0.0);
1768        assert_eq!((600.0_f32).clamp(-max_velocity, max_velocity), 0.0);
1769    }
1770
1771    #[test]
1772    fn negative_max_velocity_is_sanitized_to_a_safe_clamp() {
1773        let max_velocity = ScrollPhysics {
1774            max_velocity: -1.0,
1775            ..ScrollPhysics::default()
1776        }
1777        .max_velocity
1778        .max(0.0);
1779        assert_eq!(max_velocity, 0.0);
1780        assert_eq!((600.0_f32).clamp(-max_velocity, max_velocity), 0.0);
1781    }
1782
1783    #[test]
1784    fn zero_max_velocity_is_the_only_safe_degenerate_config() {
1785        // -0.0 <= 0.0, so a zero max_velocity does NOT panic: it pins every
1786        // wheel impulse to zero. This is the boundary the two tests above sit on.
1787        assert_eq!((600.0_f32).clamp(-0.0, 0.0), 0.0);
1788        assert_eq!((-600.0_f32).clamp(-0.0, 0.0), -0.0);
1789        // ...and NaN passes straight through clamp without panicking.
1790        assert!(f32::NAN.clamp(-8000.0, 8000.0).is_nan());
1791    }
1792}