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

            
36
use alloc::collections::BTreeMap;
37

            
38
use azul_core::{
39
    callbacks::{TimerCallbackReturn, Update},
40
    dom::DomId,
41
    geom::LogicalPosition,
42
    refany::RefAny,
43
    styled_dom::NodeHierarchyItemId,
44
    task::TerminateTimer,
45
};
46

            
47
use crate::{
48
    managers::scroll_state::{
49
        ScrollInput, ScrollInputDevice, ScrollInputQueue, ScrollInputSource, ScrollNodeInfo,
50
    },
51
    timer::TimerCallbackInfo,
52
};
53

            
54
use azul_css::props::style::scrollbar::{ScrollPhysics, OverflowScrolling, OverscrollBehavior};
55

            
56
/// Maximum number of scroll events processed per timer tick.
57
/// Older events beyond this limit are discarded to keep the physics
58
/// simulation bounded and testable.
59
const MAX_SCROLL_EVENTS_PER_TICK: usize = 100;
60

            
61
/// Assumed framerate for converting between per-frame and per-second quantities.
62
/// Used both in wheel impulse conversion and friction decay so the two stay coupled.
63
const ASSUMED_FPS: f32 = 60.0;
64

            
65
/// State stored in the timer's `RefAny` data.
66
///
67
/// Contains the shared input queue, per-node velocity state, and the global
68
/// scroll physics configuration from `SystemStyle`.
69
#[derive(Debug)]
70
pub struct ScrollPhysicsState {
71
    /// Shared input queue — same Arc as `ScrollManager.scroll_input_queue`
72
    pub input_queue: ScrollInputQueue,
73
    /// Per-node velocity tracking
74
    pub node_velocities: BTreeMap<(DomId, NodeId), NodeScrollPhysics>,
75
    /// Per-node "forced position" from programmatic scroll (hard-clamped)
76
    pub pending_positions: BTreeMap<(DomId, NodeId), LogicalPosition>,
77
    /// Per-node "forced position" from trackpad scroll (rubber-band clamped)
78
    pub pending_trackpad_positions: BTreeMap<(DomId, NodeId), LogicalPosition>,
79
    /// Absolute offsets that `AnimateTo` / wheel-glide inputs are seeking,
80
    /// per node, together with the DEVICE that asked for the seek (device
81
    /// picks the spring duration: physical wheel clicks get the short
82
    /// `wheel_animate_bounce_ms` glide, everything else
83
    /// `bounce_back_duration_ms`). The integration loop applies a
84
    /// critically-damped spring toward each and removes the entry on
85
    /// convergence (snap to the exact target).
86
    pub animate_targets: BTreeMap<(DomId, NodeId), (LogicalPosition, ScrollInputDevice)>,
87
    /// Global scroll physics configuration (from `SystemStyle`)
88
    pub scroll_physics: ScrollPhysics,
89
}
90

            
91
/// For convenience, re-export `NodeId`
92
use azul_core::id::NodeId;
93

            
94
/// Per-node scroll physics state
95
#[derive(Copy, Debug, Clone, Default)]
96
pub struct NodeScrollPhysics {
97
    /// Current velocity in pixels/second
98
    pub velocity: LogicalPosition,
99
    /// Whether this node is currently in a rubber-band overshoot state
100
    pub is_rubber_banding: bool,
101
}
102

            
103
impl ScrollPhysicsState {
104
    /// Create a new physics state with the shared input queue and global config
105
44
    #[must_use] pub const fn new(input_queue: ScrollInputQueue, scroll_physics: ScrollPhysics) -> Self {
106
44
        Self {
107
44
            input_queue,
108
44
            node_velocities: BTreeMap::new(),
109
44
            pending_positions: BTreeMap::new(),
110
44
            pending_trackpad_positions: BTreeMap::new(),
111
44
            animate_targets: BTreeMap::new(),
112
44
            scroll_physics,
113
44
        }
114
44
    }
115

            
116
    /// Returns true if any node has non-zero velocity or there are pending inputs
117
560
    fn is_active(&self) -> bool {
118
560
        let threshold = self.scroll_physics.min_velocity_threshold;
119
560
        !self.animate_targets.is_empty()
120
553
            || self.input_queue.has_pending()
121
552
            || self.node_velocities.values().any(|v| {
122
114
                v.velocity.x.abs() > threshold
123
111
                    || v.velocity.y.abs() > threshold
124
6
                    || v.is_rubber_banding
125
114
            })
126
441
            || !self.pending_positions.is_empty()
127
440
            || !self.pending_trackpad_positions.is_empty()
128
560
    }
129
}
130

            
131
/// The scroll physics timer callback.
132
///
133
/// This is a normal timer callback registered with `SCROLL_MOMENTUM_TIMER_ID`.
134
/// It consumes pending scroll inputs, applies physics, and pushes `ScrollTo` changes.
135
///
136
/// Uses the `ScrollPhysics` configuration from `SystemStyle` for friction,
137
/// velocity thresholds, wheel multiplier, and rubber-banding parameters.
138
/// Per-node `OverflowScrolling` and `OverscrollBehavior` CSS properties are
139
/// respected to decide whether each node gets rubber-banding.
140
///
141
/// `AZ_SCROLL_DEBUG=1` turns on a per-event / per-tick trace of the scroll
142
/// pipeline.
143
///
144
/// It exists because the jitter reported on X11 and Wayland — a wheel scroll
145
/// that smooths, then jumps back and forward, damping toward the middle — could
146
/// not be reproduced from the code alone. Six candidate causes were eliminated
147
/// (spring stiffness, `dt`, device classification, X11 double ingress, the shm
148
/// slot count, and Wayland's slot catch-up), and the remaining ones need to
149
/// know what the platform actually DELIVERED, not what we think it delivers.
150
///
151
/// Off by default and checked once: this sits on the 16 ms tick and on every
152
/// scroll event, so it must cost nothing when unset.
153
#[cfg(feature = "std")]
154
#[must_use]
155
114
pub fn scroll_debug_enabled() -> bool {
156
    use std::sync::OnceLock;
157
    static ON: OnceLock<bool> = OnceLock::new();
158
114
    *ON.get_or_init(|| std::env::var("AZ_SCROLL_DEBUG").map(|v| v == "1").unwrap_or(false))
159
114
}
160

            
161
#[cfg(not(feature = "std"))]
162
#[must_use]
163
pub const fn scroll_debug_enabled() -> bool {
164
    false
165
}
166

            
167
/// One line per scroll event as the PLATFORM delivered it, before any physics.
168
///
169
/// Call this from the platform ingress (x11 `handle_scroll_input`, wayland
170
/// `axis`), so a log tells us the raw delta, whether the backend called it
171
/// continuous, and which source/device it was classified as. That is the piece
172
/// no amount of reading the code can supply.
173
#[cfg(feature = "std")]
174
pub fn trace_scroll_input(
175
    backend: &str,
176
    raw_dx: f32,
177
    raw_dy: f32,
178
    continuous: bool,
179
    source: &str,
180
    device: &str,
181
) {
182
    if !scroll_debug_enabled() {
183
        return;
184
    }
185
    std::eprintln!(
186
        "[az-scroll] IN  backend={backend} raw=({raw_dx:.4},{raw_dy:.4}) \
187
         continuous={continuous} source={source} device={device}"
188
    );
189
}
190

            
191
#[cfg(not(feature = "std"))]
192
pub fn trace_scroll_input(_: &str, _: f32, _: f32, _: bool, _: &str, _: &str) {}
193

            
194
/// # C API
195
///
196
/// This function has `extern "C"` ABI so it can be used as a `TimerCallbackType`.
197
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
198
#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/counter/fixed-point cast
199
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
200
550
pub extern "C" fn scroll_physics_timer_callback(
201
550
    mut data: RefAny,
202
550
    mut timer_info: TimerCallbackInfo,
203
550
) -> TimerCallbackReturn {
204
    // Downcast the RefAny to our physics state
205
550
    let Some(mut physics) = data.downcast_mut::<ScrollPhysicsState>() else {
206
1
        return TimerCallbackReturn::terminate_unchanged();
207
    };
208

            
209
    // Extract physics config values
210
549
    let sp = &physics.scroll_physics;
211
549
    let dt = sp.timer_interval_ms.max(1) as f32 / 1000.0;
212
549
    let friction_rate = friction_from_deceleration(sp.deceleration_rate);
213
549
    let velocity_threshold = sp.min_velocity_threshold;
214
549
    let wheel_multiplier = sp.wheel_multiplier;
215
    // Sanitize: `.max(0.0)` turns a NaN or negative max_velocity (a plain repr(C)
216
    // field with no validation, reachable from a bad SystemStyle) into 0.0, so the
217
    // `velocity.clamp(-max_velocity, max_velocity)` below never hits f32::clamp's
218
    // `min > max` panic — which in this extern "C" callback would ABORT the process.
219
549
    let max_velocity = sp.max_velocity.max(0.0);
220
549
    let overscroll_elasticity = sp.overscroll_elasticity;
221
549
    let max_overscroll_distance = sp.max_overscroll_distance;
222
549
    let bounce_back_duration_ms = sp.bounce_back_duration_ms;
223
549
    let wheel_animate_bounce_ms = sp.wheel_animate_bounce_ms;
224

            
225
    // 1. Take at most MAX_SCROLL_EVENTS_PER_TICK recent inputs from the shared queue
226
549
    let inputs = physics.input_queue.take_recent(MAX_SCROLL_EVENTS_PER_TICK);
227

            
228
791
    for input in inputs {
229
242
        let key = (input.dom_id, input.node_id);
230
242
        match input.source {
231
            ScrollInputSource::TrackpadContinuous => {
232
                // Trackpad: OS handles momentum. Apply delta directly as position change.
233
                //
234
                // ACCUMULATE onto whatever this tick already staged for the
235
                // node, falling back to the committed offset. `current_offset`
236
                // does NOT move while this callback runs — the ScrollTo changes
237
                // are applied after it returns — so basing every event of the
238
                // batch on it and then `insert`ing meant N events in one tick
239
                // collapsed to the LAST delta. A 120 Hz trackpad against the
240
                // 16 ms tick routinely queues two events, i.e. half the gesture
241
                // was silently dropped, which is what "scrolling fights itself"
242
                // feels like from the outside.
243
14
                let current = physics
244
14
                    .pending_trackpad_positions
245
14
                    .get(&key)
246
14
                    .copied()
247
14
                    .or_else(|| {
248
8
                        timer_info
249
8
                            .get_scroll_node_info(input.dom_id, input.node_id)
250
8
                            .map(|info| info.current_offset)
251
8
                    })
252
14
                    .unwrap_or_default();
253

            
254
14
                let new_pos = LogicalPosition {
255
14
                    x: current.x + input.delta.x,
256
14
                    y: current.y + input.delta.y,
257
14
                };
258
14
                physics.pending_trackpad_positions.insert(key, new_pos);
259

            
260
                // Kill any existing velocity for this node (trackpad overrides momentum)
261
14
                physics.node_velocities.remove(&key);
262
            }
263
            ScrollInputSource::WheelDiscrete => {
264
                // Input provenance decides the math. A PHYSICAL wheel click
265
                // glides toward an accumulating ABSOLUTE target (short
266
                // critically-damped spring, hard stop on arrival) — running
267
                // discrete clicks through the velocity+friction model gave
268
                // them the trackpad's floaty momentum tail, which feels
269
                // jarring on a ratcheting wheel. Consecutive clicks extend
270
                // the target (glide keeps going), they don't stack impulses.
271
                // Touchpads that surface wheel-style events (Windows
272
                // precision-touchpad fallback, X11 smooth scrolling) and
273
                // test drivers keep the velocity model: their deltas are
274
                // fine-grained and the momentum tail is correct for them.
275
112
                let mut wheel_glided = false;
276
107
                if matches!(
277
112
                    input.device,
278
                    ScrollInputDevice::MouseWheel | ScrollInputDevice::Unknown
279
                ) {
280
5
                    if let Some(info) =
281
5
                        timer_info.get_scroll_node_info(input.dom_id, input.node_id)
282
                    {
283
5
                        let base = physics
284
5
                            .animate_targets
285
5
                            .get(&key)
286
5
                            .map_or(info.current_offset, |(t, _)| *t);
287
                        // Clamp into the scrollable range: a wheel click at
288
                        // the boundary must not build up an off-range target
289
                        // (chaining to the parent scroller happens at
290
                        // hit-test time in the ScrollManager, per click).
291
5
                        let target = LogicalPosition {
292
5
                            x: (base.x + input.delta.x * wheel_multiplier)
293
5
                                .clamp(0.0, info.max_scroll_x.max(0.0)),
294
5
                            y: (base.y + input.delta.y * wheel_multiplier)
295
5
                                .clamp(0.0, info.max_scroll_y.max(0.0)),
296
5
                        };
297
5
                        physics.animate_targets.insert(key, (target, input.device));
298
                        // Ensure the seek loop visits this node; keep any
299
                        // in-flight velocity so a retarget stays continuous.
300
5
                        physics.node_velocities.entry(key).or_default();
301
5
                        physics.pending_trackpad_positions.remove(&key);
302
5
                        wheel_glided = true;
303
                    }
304
107
                }
305
112
                if !wheel_glided {
306
107
                    // Velocity impulse (trackpad-style wheel events, test
307
107
                    // drivers, or no scroll-node info registered yet).
308
107
                    let node_physics = physics
309
107
                        .node_velocities
310
107
                        .entry(key)
311
107
                        .or_insert_with(NodeScrollPhysics::default);
312
107

            
313
107
                    // Add impulse (delta is in pixels, convert to pixels/second)
314
107
                    node_physics.velocity.x += input.delta.x * wheel_multiplier * ASSUMED_FPS;
315
107
                    node_physics.velocity.y += input.delta.y * wheel_multiplier * ASSUMED_FPS;
316
107

            
317
107
                    // Clamp to max velocity
318
107
                    node_physics.velocity.x = node_physics.velocity.x.clamp(-max_velocity, max_velocity);
319
107
                    node_physics.velocity.y = node_physics.velocity.y.clamp(-max_velocity, max_velocity);
320
107
                }
321
            }
322
            ScrollInputSource::Programmatic => {
323
                // Programmatic: Set position directly. Accumulates within the
324
                // tick for the same reason as TrackpadContinuous above.
325
104
                let current = physics
326
104
                    .pending_positions
327
104
                    .get(&key)
328
104
                    .copied()
329
104
                    .or_else(|| {
330
104
                        timer_info
331
104
                            .get_scroll_node_info(input.dom_id, input.node_id)
332
104
                            .map(|info| info.current_offset)
333
104
                    })
334
104
                    .unwrap_or_default();
335

            
336
104
                let new_pos = LogicalPosition {
337
104
                    x: current.x + input.delta.x,
338
104
                    y: current.y + input.delta.y,
339
104
                };
340
104
                physics.pending_positions.insert(key, new_pos);
341
            }
342
            ScrollInputSource::AnimateTo => {
343
                // `delta` carries the ABSOLUTE target. Clamp into the node's
344
                // scrollable range; keep any existing velocity so a retarget
345
                // mid-flight stays continuous (the spring redirects it).
346
5
                if let Some(info) = timer_info.get_scroll_node_info(input.dom_id, input.node_id) {
347
5
                    let target = LogicalPosition {
348
5
                        x: input.delta.x.clamp(0.0, info.max_scroll_x.max(0.0)),
349
5
                        y: input.delta.y.clamp(0.0, info.max_scroll_y.max(0.0)),
350
5
                    };
351
5
                    physics.animate_targets.insert(key, (target, input.device));
352
5
                    physics.node_velocities.entry(key).or_default();
353
5
                    physics.pending_trackpad_positions.remove(&key);
354
5
                }
355
            }
356
            ScrollInputSource::TrackpadEnd => {
357
                // Trackpad gesture ended (fingers lifted).
358
                // If the scroll position is past the bounds (rubber-banding overshoot),
359
                // start a spring-back animation to snap back to the boundary.
360
                // Peek (do NOT remove) the position this tick has staged for
361
                // the node. It used to read `pending_positions`, the
362
                // PROGRAMMATIC map, which a trackpad gesture never writes — so
363
                // the overshoot decision was made from the stale, pre-tick
364
                // offset and the spring-back fought the finger's last delta.
365
                // Peeking rather than removing leaves step 3 to apply the
366
                // rubber-band clamp, which is the write that must win.
367
7
                let staged = physics
368
7
                    .pending_trackpad_positions
369
7
                    .get(&key)
370
7
                    .copied()
371
7
                    .or_else(|| physics.pending_positions.get(&key).copied());
372
7
                let already_staged = staged.is_some();
373
7
                let pos = staged
374
7
                    .or_else(|| timer_info.get_scroll_node_info(input.dom_id, input.node_id)
375
3
                        .map(|info| info.current_offset));
376

            
377
7
                if let Some(pos) = pos {
378
6
                    if let Some(info) = timer_info.get_scroll_node_info(input.dom_id, input.node_id) {
379
6
                        let overshoot_x = calculate_overshoot(pos.x, 0.0, info.max_scroll_x);
380
6
                        let overshoot_y = calculate_overshoot(pos.y, 0.0, info.max_scroll_y);
381

            
382
6
                        if overshoot_x.abs() > 0.01 || overshoot_y.abs() > 0.01 {
383
2
                            let node_phys = physics.node_velocities
384
2
                                .entry(key)
385
2
                                .or_insert_with(NodeScrollPhysics::default);
386
2
                            // Zero out velocity — the spring-back force in the
387
2
                            // velocity integration loop (step 2) will pull the
388
2
                            // position back to the boundary.
389
2
                            node_phys.velocity = LogicalPosition::zero();
390
2
                            node_phys.is_rubber_banding = true;
391
4
                        }
392

            
393
                        // Preserve the overshot position for the spring-back animation.
394
                        // Must use unclamped so the overshot position is NOT clamped to bounds.
395
                        //
396
                        // Skipped when step 3 is already going to write this
397
                        // node from a staged position: that write is the
398
                        // rubber-band-clamped one and must be the only one, or
399
                        // the node gets two conflicting offsets in one tick.
400
6
                        if !already_staged {
401
2
                            let hierarchy_id =
402
2
                                NodeHierarchyItemId::from_crate_internal(Some(input.node_id));
403
2
                            timer_info.scroll_to_unclamped(input.dom_id, hierarchy_id, pos);
404
4
                        }
405
                    }
406
1
                }
407
            }
408
        }
409
    }
410

            
411
    // 2. Integrate velocity physics for wheel-based momentum
412
549
    let mut velocity_updates: Vec<((DomId, NodeId), LogicalPosition)> = Vec::new();
413
    // Residual momentum from nodes that hit their boundary this tick, to be
414
    // transferred up the scroll chain after the iteration (can't mutate
415
    // node_velocities mid-loop).
416
549
    let mut momentum_handoffs: Vec<((DomId, NodeId), LogicalPosition)> = Vec::new();
417
    // AnimateTo targets, read-only during the iteration (the map itself is
418
    // mutated after the loop via `converged_targets`).
419
549
    let animate_targets = physics.animate_targets.clone();
420
549
    let mut converged_targets: Vec<(DomId, NodeId)> = Vec::new();
421
    // Nodes the finger moved THIS tick. Both writers below end in a
422
    // `scroll_to_unclamped` for the same node and the velocity one is applied
423
    // last, so without this the spring silently overwrote the gesture's delta
424
    // with a position integrated from the STALE pre-tick offset — the direct
425
    // "physics fighting the actual scroll" the user reported. The finger wins
426
    // while it is down; the spring resumes next tick from the committed offset.
427
549
    let moved_by_finger_this_tick: alloc::collections::BTreeSet<(DomId, NodeId)> =
428
549
        physics.pending_trackpad_positions.keys().copied().collect();
429

            
430
549
    for ((dom_id, node_id), node_physics) in &mut physics.node_velocities {
431
121
        if moved_by_finger_this_tick.contains(&(*dom_id, *node_id)) {
432
2
            continue;
433
119
        }
434
        // Get current scroll info for clamping and per-node CSS config
435
119
        let Some(info) = timer_info.get_scroll_node_info(*dom_id, *node_id) else {
436
4
            continue;
437
        };
438

            
439
        // Target-seeking spring (scroll_to_animated): a critically-damped
440
        // pull toward the absolute target — the same F = -k*x - c*v the
441
        // rubber-band uses, with x measured from the TARGET instead of the
442
        // boundary. Close enough + slow enough snaps EXACTLY onto the
443
        // target and retires it (no asymptotic crawl).
444
115
        let seek_target = animate_targets.get(&(*dom_id, *node_id)).copied();
445
115
        if let Some((target, seek_device)) = seek_target {
446
9
            let err_x = info.current_offset.x - target.x;
447
9
            let err_y = info.current_offset.y - target.y;
448
9
            if err_x.abs() < 0.5
449
9
                && err_y.abs() < 0.5
450
2
                && node_physics.velocity.x.abs() < velocity_threshold
451
2
                && node_physics.velocity.y.abs() < velocity_threshold
452
            {
453
1
                velocity_updates.push(((*dom_id, *node_id), target));
454
1
                node_physics.velocity = LogicalPosition::zero();
455
1
                converged_targets.push((*dom_id, *node_id));
456
1
                continue;
457
8
            }
458
            // Provenance picks the curve: wheel clicks want a short snappy
459
            // glide, programmatic/other seeks the platform bounce duration.
460
8
            let seek_duration_ms = match seek_device {
461
                ScrollInputDevice::MouseWheel | ScrollInputDevice::Unknown => {
462
4
                    wheel_animate_bounce_ms
463
                }
464
4
                _ => bounce_back_duration_ms,
465
            };
466
8
            let spring_k = spring_constant_from_bounce_duration(seek_duration_ms);
467
8
            let damping = 2.0 * spring_k.sqrt();
468
8
            node_physics.velocity.x += (-spring_k * err_x - damping * node_physics.velocity.x) * dt;
469
8
            node_physics.velocity.y += (-spring_k * err_y - damping * node_physics.velocity.y) * dt;
470
106
        }
471

            
472
        // Determine if this node allows rubber-banding
473
114
        let rubber_band_x = node_allows_rubber_band(info.max_scroll_x, info.overscroll_behavior_x, info.overflow_scrolling, overscroll_elasticity);
474
114
        let rubber_band_y = node_allows_rubber_band(info.max_scroll_y, info.overscroll_behavior_y, info.overflow_scrolling, overscroll_elasticity);
475

            
476
        // Calculate current overshoot amounts
477
114
        let overshoot_x = calculate_overshoot(info.current_offset.x, 0.0, info.max_scroll_x);
478
114
        let overshoot_y = calculate_overshoot(info.current_offset.y, 0.0, info.max_scroll_y);
479

            
480
114
        let is_overshooting_x = overshoot_x.abs() > 0.01;
481
114
        let is_overshooting_y = overshoot_y.abs() > 0.01;
482

            
483
        // If we're in a rubber-band overshoot, apply critically-damped spring force.
484
        // F = -k*x - c*v  where c = 2*sqrt(k) for critical damping (no oscillation).
485
114
        if is_overshooting_x && rubber_band_x {
486
            let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
487
            let damping = 2.0 * spring_k.sqrt(); // critical damping coefficient
488
            let spring_force_x = -spring_k * overshoot_x - damping * node_physics.velocity.x;
489
            node_physics.velocity.x += spring_force_x * dt;
490
            node_physics.is_rubber_banding = true;
491
114
        }
492
114
        if is_overshooting_y && rubber_band_y {
493
3
            let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
494
3
            let damping = 2.0 * spring_k.sqrt(); // critical damping coefficient
495
3
            let spring_force_y = -spring_k * overshoot_y - damping * node_physics.velocity.y;
496
3
            node_physics.velocity.y += spring_force_y * dt;
497
3
            node_physics.is_rubber_banding = true;
498
111
        }
499

            
500
        // Skip if velocity is negligible and not rubber-banding or seeking
501
114
        if !node_physics.is_rubber_banding
502
111
            && seek_target.is_none()
503
103
            && node_physics.velocity.x.abs() < velocity_threshold
504
102
            && node_physics.velocity.y.abs() < velocity_threshold
505
        {
506
            node_physics.velocity = LogicalPosition::zero();
507
            continue;
508
114
        }
509

            
510
        // Apply velocity to position
511
114
        let displacement = LogicalPosition {
512
114
            x: node_physics.velocity.x * dt,
513
114
            y: node_physics.velocity.y * dt,
514
114
        };
515

            
516
114
        let raw_new_x = info.current_offset.x + displacement.x;
517
114
        let raw_new_y = info.current_offset.y + displacement.y;
518

            
519
        // The whole jitter question in one line: what this tick READ, what the
520
        // writers wanted, and what it is about to COMMIT. If the offset a tick
521
        // reads is not the offset the previous tick wrote, the spring is
522
        // integrating from a stale base — and that is the oscillation.
523
        #[cfg(feature = "std")]
524
114
        if scroll_debug_enabled() {
525
            std::eprintln!(
526
                "[az-scroll] TICK node=({:?},{:?}) read=({:.3},{:.3}) vel=({:.3},{:.3}) \
527
                 disp=({:.3},{:.3}) -> commit=({:.3},{:.3}) target={:?} max=({:.1},{:.1})",
528
                dom_id, node_id,
529
                info.current_offset.x, info.current_offset.y,
530
                node_physics.velocity.x, node_physics.velocity.y,
531
                displacement.x, displacement.y,
532
                raw_new_x, raw_new_y,
533
                seek_target.map(|(t, _)| (t.x, t.y)),
534
                info.max_scroll_x, info.max_scroll_y,
535
            );
536
114
        }
537

            
538
        // Clamp with or without rubber-banding
539
114
        let new_x = if rubber_band_x && max_overscroll_distance > 0.0 {
540
            // Allow overshoot with diminishing returns (elasticity)
541
            rubber_band_clamp(raw_new_x, 0.0, info.max_scroll_x, max_overscroll_distance, overscroll_elasticity)
542
        } else {
543
114
            raw_new_x.clamp(0.0, info.max_scroll_x)
544
        };
545

            
546
114
        let new_y = if rubber_band_y && max_overscroll_distance > 0.0 {
547
3
            rubber_band_clamp(raw_new_y, 0.0, info.max_scroll_y, max_overscroll_distance, overscroll_elasticity)
548
        } else {
549
111
            raw_new_y.clamp(0.0, info.max_scroll_y)
550
        };
551

            
552
114
        let new_pos = LogicalPosition { x: new_x, y: new_y };
553

            
554
        // Apply exponential friction decay
555
114
        let decay = (-friction_rate * dt * ASSUMED_FPS).exp();
556
114
        node_physics.velocity.x *= decay;
557
114
        node_physics.velocity.y *= decay;
558

            
559
        // At edges without rubber-banding: hand the remaining momentum to a
560
        // scrollable ancestor, then kill this node's velocity (MWA-C-scroll:
561
        // a fling that exhausts the inner container mid-momentum continues
562
        // on the outer one, mirroring the input-time boundary handoff in
563
        // select_scroll_target). overscroll-behavior contain/none on this
564
        // node stops the chain, matching CSS scroll-chaining semantics.
565
114
        if !rubber_band_x && (new_pos.x <= 0.0 || new_pos.x >= info.max_scroll_x) {
566
113
            let into_edge = (new_pos.x <= 0.0 && node_physics.velocity.x < 0.0)
567
113
                || (new_pos.x >= info.max_scroll_x && node_physics.velocity.x > 0.0);
568
113
            if into_edge
569
                && info.overscroll_behavior_x == OverscrollBehavior::Auto
570
                && node_physics.velocity.x.abs() > velocity_threshold
571
            {
572
                momentum_handoffs.push((
573
                    (*dom_id, *node_id),
574
                    LogicalPosition { x: node_physics.velocity.x, y: 0.0 },
575
                ));
576
113
            }
577
113
            node_physics.velocity.x = 0.0;
578
1
        }
579
114
        if !rubber_band_y && (new_pos.y <= 0.0 || new_pos.y >= info.max_scroll_y) {
580
1
            let into_edge = (new_pos.y <= 0.0 && node_physics.velocity.y < 0.0)
581
1
                || (new_pos.y >= info.max_scroll_y && node_physics.velocity.y > 0.0);
582
1
            if into_edge
583
1
                && info.overscroll_behavior_y == OverscrollBehavior::Auto
584
1
                && node_physics.velocity.y.abs() > velocity_threshold
585
1
            {
586
1
                momentum_handoffs.push((
587
1
                    (*dom_id, *node_id),
588
1
                    LogicalPosition { x: 0.0, y: node_physics.velocity.y },
589
1
                ));
590
1
            }
591
1
            node_physics.velocity.y = 0.0;
592
113
        }
593

            
594
        // Check if rubber-banding spring-back is almost complete
595
114
        let new_overshoot_x = calculate_overshoot(new_pos.x, 0.0, info.max_scroll_x);
596
114
        let new_overshoot_y = calculate_overshoot(new_pos.y, 0.0, info.max_scroll_y);
597
114
        if new_overshoot_x.abs() < 0.5 && new_overshoot_y.abs() < 0.5 {
598
112
            node_physics.is_rubber_banding = false;
599
112
        }
600

            
601
        // Snap to zero if below threshold after decay
602
114
        if node_physics.velocity.x.abs() < velocity_threshold {
603
113
            node_physics.velocity.x = 0.0;
604
113
        }
605
114
        if node_physics.velocity.y.abs() < velocity_threshold {
606
4
            node_physics.velocity.y = 0.0;
607
110
        }
608

            
609
114
        velocity_updates.push(((*dom_id, *node_id), new_pos));
610
    }
611

            
612
    // Clean up nodes with zero velocity and not rubber-banding
613
549
    physics
614
549
        .node_velocities
615
549
        .retain(|_, v| v.velocity.x.abs() > 0.0 || v.velocity.y.abs() > 0.0 || v.is_rubber_banding);
616

            
617
    // MWA-C-scroll: transfer residual momentum up the scroll chain — walk the
618
    // scroll-parent chain to the nearest ancestor that can still consume in
619
    // the fling's direction and seed it with the leftover velocity (picked up
620
    // by the integration loop on the next tick; is_active() keeps the timer
621
    // alive because the entry lands in node_velocities).
622
    // Retire converged AnimateTo targets (snapped exactly this tick).
623
550
    for key in converged_targets {
624
1
        physics.animate_targets.remove(&key);
625
1
    }
626

            
627
550
    for ((dom_id, node_id), vel) in momentum_handoffs {
628
1
        let mut cur = node_id;
629
1
        for _ in 0..64 {
630
1
            let Some(parent) = timer_info.find_scroll_parent(dom_id, cur) else {
631
1
                break;
632
            };
633
            let Some(pinfo) = timer_info.get_scroll_node_info(dom_id, parent) else {
634
                break;
635
            };
636
            let can_x = vel.x != 0.0
637
                && ((vel.x > 0.0 && pinfo.current_offset.x < pinfo.max_scroll_x - 0.5)
638
                    || (vel.x < 0.0 && pinfo.current_offset.x > 0.5));
639
            let can_y = vel.y != 0.0
640
                && ((vel.y > 0.0 && pinfo.current_offset.y < pinfo.max_scroll_y - 0.5)
641
                    || (vel.y < 0.0 && pinfo.current_offset.y > 0.5));
642
            if can_x || can_y {
643
                let entry = physics
644
                    .node_velocities
645
                    .entry((dom_id, parent))
646
                    .or_insert_with(NodeScrollPhysics::default);
647
                if can_x {
648
                    entry.velocity.x += vel.x;
649
                }
650
                if can_y {
651
                    entry.velocity.y += vel.y;
652
                }
653
                break;
654
            }
655
            // This ancestor is itself exhausted in the fling's direction —
656
            // respect ITS overscroll-behavior before chaining past it.
657
            let stop_x = vel.x != 0.0 && pinfo.overscroll_behavior_x != OverscrollBehavior::Auto;
658
            let stop_y = vel.y != 0.0 && pinfo.overscroll_behavior_y != OverscrollBehavior::Auto;
659
            if stop_x || stop_y {
660
                break;
661
            }
662
            cur = parent;
663
        }
664
    }
665

            
666
    // 3. Push ScrollTo changes for all updated positions
667
549
    let mut any_changes = false;
668

            
669
    // Apply programmatic position changes (hard-clamped to bounds)
670
549
    let direct_positions: Vec<_> = physics.pending_positions.iter().map(|(k, v)| (*k, *v)).collect();
671
549
    physics.pending_positions.clear();
672
653
    for ((dom_id, node_id), position) in direct_positions {
673
104
        let clamped = timer_info.get_scroll_node_info(dom_id, node_id).map_or(position, |info| LogicalPosition {
674
3
                x: position.x.clamp(0.0, info.max_scroll_x),
675
3
                y: position.y.clamp(0.0, info.max_scroll_y),
676
3
            });
677
104
        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
678
104
        timer_info.scroll_to(dom_id, hierarchy_id, clamped);
679
104
        any_changes = true;
680
    }
681

            
682
    // Apply trackpad position changes (rubber-band clamped for elastic overshoot)
683
    // Uses scroll_to_unclamped because the physics timer does its own rubber-band clamping.
684
549
    let trackpad_positions: Vec<_> = physics.pending_trackpad_positions.iter().map(|(k, v)| (*k, *v)).collect();
685
549
    physics.pending_trackpad_positions.clear();
686
557
    for ((dom_id, node_id), position) in trackpad_positions {
687
8
        let clamped = timer_info.get_scroll_node_info(dom_id, node_id).map_or(position, |info| {
688
8
                let rubber_x = node_allows_rubber_band(info.max_scroll_x, info.overscroll_behavior_x, info.overflow_scrolling, physics.scroll_physics.overscroll_elasticity);
689
8
                let rubber_y = node_allows_rubber_band(info.max_scroll_y, info.overscroll_behavior_y, info.overflow_scrolling, physics.scroll_physics.overscroll_elasticity);
690
8
                let max_over = physics.scroll_physics.max_overscroll_distance;
691
8
                let elasticity = physics.scroll_physics.overscroll_elasticity;
692
                LogicalPosition {
693
8
                    x: if rubber_x {
694
                        rubber_band_clamp(position.x, 0.0, info.max_scroll_x, max_over, elasticity)
695
                    } else {
696
8
                        position.x.clamp(0.0, info.max_scroll_x)
697
                    },
698
8
                    y: if rubber_y {
699
3
                        rubber_band_clamp(position.y, 0.0, info.max_scroll_y, max_over, elasticity)
700
                    } else {
701
5
                        position.y.clamp(0.0, info.max_scroll_y)
702
                    },
703
                }
704
8
            });
705
8
        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
706
8
        timer_info.scroll_to_unclamped(dom_id, hierarchy_id, clamped);
707
8
        any_changes = true;
708
    }
709

            
710
    // Apply velocity-based position changes (uses unclamped: physics already handles rubber-band clamping)
711
664
    for ((dom_id, node_id), position) in velocity_updates {
712
115
        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
713
115
        timer_info.scroll_to_unclamped(dom_id, hierarchy_id, position);
714
115
        any_changes = true;
715
115
    }
716

            
717
    // 4. Decide whether to continue or terminate
718
549
    if physics.is_active() || any_changes {
719
127
        TimerCallbackReturn {
720
127
            should_update: Update::DoNothing, // Scroll changes are handled via nodes_scrolled_in_callbacks, not DOM refresh
721
127
            should_terminate: TerminateTimer::Continue,
722
127
        }
723
    } else {
724
        // No more velocity, no pending inputs → terminate the timer
725
422
        TimerCallbackReturn::terminate_unchanged()
726
    }
727
550
}
728

            
729
// ============================================================================
730
// Rubber-banding Helper Functions
731
// ============================================================================
732

            
733
/// Determines if a node allows rubber-banding on a given axis based on:
734
/// 1. Whether the axis actually has overflow (`max_scroll` > 0)
735
/// 2. Per-node `overflow_scrolling` CSS property (-azul-overflow-scrolling)
736
/// 3. Per-node `overscroll_behavior` CSS property (overscroll-behavior-x/y)
737
/// 4. Global `overscroll_elasticity` from `ScrollPhysics`
738
267
fn node_allows_rubber_band(
739
267
    max_scroll: f32,
740
267
    overscroll_behavior: OverscrollBehavior,
741
267
    overflow_scrolling: OverflowScrolling,
742
267
    global_elasticity: f32,
743
267
) -> bool {
744
267
    if max_scroll <= 0.0 {
745
128
        return false;
746
139
    }
747
139
    if overscroll_behavior == OverscrollBehavior::None {
748
3
        return false;
749
136
    }
750
136
    if overflow_scrolling == OverflowScrolling::Touch {
751
3
        return true;
752
133
    }
753
133
    global_elasticity > 0.0
754
267
}
755

            
756
/// Calculate how far a position has overshot the valid scroll range.
757
/// Returns positive for overshoot past max, negative for overshoot past min, 0 if in range.
758
487
fn calculate_overshoot(pos: f32, min: f32, max: f32) -> f32 {
759
487
    if pos < min {
760
5
        pos - min // negative
761
482
    } else if pos > max {
762
13
        pos - max // positive
763
    } else {
764
469
        0.0
765
    }
766
487
}
767

            
768
/// Rubber-band clamping: allows overshoot up to `max_overscroll`, with
769
/// diminishing returns (elasticity) so it feels "springy".
770
///
771
/// The further you overshoot, the harder it becomes to scroll further.
772
44
fn rubber_band_clamp(
773
44
    raw_pos: f32,
774
44
    min: f32,
775
44
    max: f32,
776
44
    max_overscroll: f32,
777
44
    elasticity: f32,
778
44
) -> f32 {
779
44
    if raw_pos >= min && raw_pos <= max {
780
4
        return raw_pos;
781
40
    }
782

            
783
40
    let (boundary, overshoot) = if raw_pos < min {
784
11
        (min, min - raw_pos) // overshoot is positive distance past boundary
785
    } else {
786
29
        (max, raw_pos - max)
787
    };
788

            
789
    // Diminishing returns: as overshoot increases, actual displacement decreases
790
    // Formula: actual = max_overscroll * (1 - e^(-elasticity * overshoot / max_overscroll))
791
40
    let clamped_overscroll = if max_overscroll > 0.0 {
792
35
        max_overscroll * (1.0 - (-elasticity * overshoot / max_overscroll).exp())
793
    } else {
794
5
        0.0
795
    };
796

            
797
40
    if raw_pos < min {
798
11
        boundary - clamped_overscroll
799
    } else {
800
29
        boundary + clamped_overscroll
801
    }
802
44
}
803

            
804
/// Convert `deceleration_rate` (0.0 - 1.0) to a friction constant for exponential decay.
805
/// Higher `deceleration_rate` = less friction (slower deceleration).
806
576
fn friction_from_deceleration(deceleration_rate: f32) -> f32 {
807
    // deceleration_rate ~0.95 (fast) → friction ~0.05
808
    // deceleration_rate ~0.998 (iOS-like) → friction ~0.002
809
576
    (1.0 - deceleration_rate.clamp(0.0, 0.999)).max(0.001)
810
576
}
811

            
812
/// Calculate spring constant from bounce-back duration.
813
/// Higher k = faster spring back. Approximate: k ≈ (2π / duration)²
814
#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/counter/fixed-point cast
815
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
816
32
fn spring_constant_from_bounce_duration(duration_ms: u32) -> f32 {
817
32
    let duration_s = duration_ms.max(50) as f32 / 1000.0;
818
32
    let omega = core::f32::consts::TAU / duration_s;
819
32
    omega * omega
820
32
}
821

            
822
// ============================================================================
823
// Generated adversarial tests
824
// ============================================================================
825

            
826
#[cfg(all(test, feature = "std"))]
827
#[allow(
828
    clippy::float_cmp,
829
    clippy::cast_precision_loss,
830
    clippy::too_many_lines,
831
    clippy::unreadable_literal
832
)]
833
mod autotest_generated {
834
    use std::sync::{Arc, Mutex};
835

            
836
    use azul_core::{
837
        dom::{DomNodeId, OptionDomNodeId},
838
        geom::{LogicalRect, LogicalSize, OptionLogicalPosition},
839
        gl::OptionGlContextPtr,
840
        hit_test::ScrollPosition,
841
        refany::OptionRefAny,
842
        resources::RendererResources,
843
        task::Instant,
844
        window::{MonitorVec, RawWindowHandle},
845
    };
846
    use azul_css::system::SystemStyle;
847
    use rust_fontconfig::FcFontCache;
848

            
849
    use super::*;
850
    #[cfg(feature = "icu")]
851
    use crate::icu::IcuLocalizerHandle;
852
    use crate::{
853
        callbacks::{CallbackChange, CallbackInfo, CallbackInfoRefData, ExternalSystemCallbacks},
854
        window::LayoutWindow,
855
        window_state::FullWindowState,
856
    };
857

            
858
    // ------------------------------------------------------------------
859
    // Harness
860
    // ------------------------------------------------------------------
861

            
862
    /// A live callback environment: an otherwise-empty `LayoutWindow` (optionally
863
    /// carrying registered scroll nodes) plus the shared change log that
864
    /// `scroll_to` / `scroll_to_unclamped` push into. `tick()` runs one full
865
    /// timer callback against it, so the physics loop can be driven repeatedly.
866
    struct Env<'a> {
867
        ref_data: &'a CallbackInfoRefData<'a>,
868
        changes: &'a Arc<Mutex<Vec<CallbackChange>>>,
869
    }
870

            
871
    impl Env<'_> {
872
        /// Run one `scroll_physics_timer_callback` tick against this environment.
873
        fn tick(&mut self, data: &RefAny) -> TimerCallbackReturn {
874
            let info = CallbackInfo::new(
875
                self.ref_data,
876
                self.changes,
877
                DomNodeId {
878
                    dom: DomId::ROOT_ID,
879
                    node: NodeHierarchyItemId::NONE,
880
                },
881
                OptionLogicalPosition::None,
882
                OptionLogicalPosition::None,
883
            );
884
            let timer_info =
885
                TimerCallbackInfo::create(info, OptionDomNodeId::None, Instant::now(), 0, false);
886
            scroll_physics_timer_callback(data.clone(), timer_info)
887
        }
888

            
889
        /// Drain the `CallbackChange`s pushed so far.
890
        fn take_changes(&self) -> Vec<CallbackChange> {
891
            self.changes
892
                .lock()
893
                .map(|mut c| core::mem::take(&mut *c))
894
                .unwrap_or_default()
895
        }
896

            
897
        /// Drain the change log, asserting every entry is a `ScrollTo`, and
898
        /// return `(node index, position, unclamped)` for each.
899
        fn take_scroll_tos(&self) -> Vec<(usize, LogicalPosition, bool)> {
900
            self.take_changes()
901
                .iter()
902
                .map(|change| {
903
                    let CallbackChange::ScrollTo {
904
                        node_id,
905
                        position,
906
                        unclamped,
907
                        ..
908
                    } = change
909
                    else {
910
                        panic!("expected only ScrollTo changes, got {change:?}");
911
                    };
912
                    let idx = node_id
913
                        .into_crate_internal()
914
                        .expect("ScrollTo must name a concrete node")
915
                        .index();
916
                    (idx, *position, *unclamped)
917
                })
918
                .collect()
919
        }
920
    }
921

            
922
    /// Builds a callback environment. `setup` may register scroll nodes on the
923
    /// `LayoutWindow` before it is frozen behind the shared reference.
924
    fn with_env<R>(setup: impl FnOnce(&mut LayoutWindow), f: impl FnOnce(&mut Env<'_>) -> R) -> R {
925
        let mut layout_window =
926
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
927
        setup(&mut layout_window);
928

            
929
        let renderer_resources = RendererResources::default();
930
        let previous_window_state: Option<FullWindowState> = None;
931
        let current_window_state = FullWindowState::default();
932
        let gl_context = OptionGlContextPtr::None;
933
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
934
            BTreeMap::new();
935
        let window_handle = RawWindowHandle::Unsupported;
936
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
937

            
938
        let ref_data = CallbackInfoRefData {
939
            layout_window: &layout_window,
940
            renderer_resources: &renderer_resources,
941
            previous_window_state: &previous_window_state,
942
            current_window_state: &current_window_state,
943
            gl_context: &gl_context,
944
            current_scroll_manager: &scroll_states,
945
            current_window_handle: &window_handle,
946
            system_callbacks: &system_callbacks,
947
            system_style: Arc::new(SystemStyle::default()),
948
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
949
            #[cfg(feature = "icu")]
950
            icu_localizer: IcuLocalizerHandle::default(),
951
            ctx: OptionRefAny::None,
952
        };
953

            
954
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
955
        let mut env = Env {
956
            ref_data: &ref_data,
957
            changes: &changes,
958
        };
959
        f(&mut env)
960
    }
961

            
962
    /// Registers node `idx` of the root DOM as a scrollable node with a
963
    /// `container_w x container_h` viewport over `content_w x content_h` content
964
    /// (so `max_scroll_x = content_w - container_w`, clamped at 0).
965
    fn register_node(
966
        window: &mut LayoutWindow,
967
        idx: usize,
968
        container: (f32, f32),
969
        content: (f32, f32),
970
    ) {
971
        window.scroll_manager.register_or_update_scroll_node(
972
            DomId::ROOT_ID,
973
            NodeId::new(idx),
974
            LogicalRect::new(
975
                LogicalPosition::zero(),
976
                LogicalSize::new(container.0, container.1),
977
            ),
978
            LogicalSize::new(content.0, content.1),
979
            Instant::now(),
980
            0.0,
981
            0.0,
982
            false,
983
            false,
984
        );
985
    }
986

            
987
    /// A scroll input for node `idx` of the root DOM (device = TestDriver,
988
    /// which keeps the legacy velocity model on WheelDiscrete).
989
    fn input(idx: usize, delta: (f32, f32), source: ScrollInputSource) -> ScrollInput {
990
        input_dev(idx, delta, source, ScrollInputDevice::TestDriver)
991
    }
992

            
993
    /// A scroll input with an explicit device provenance.
994
    fn input_dev(
995
        idx: usize,
996
        delta: (f32, f32),
997
        source: ScrollInputSource,
998
        device: ScrollInputDevice,
999
    ) -> ScrollInput {
        ScrollInput {
            dom_id: DomId::ROOT_ID,
            node_id: NodeId::new(idx),
            delta: LogicalPosition::new(delta.0, delta.1),
            timestamp: Instant::now(),
            source,
            device,
        }
    }
    /// A `ScrollPhysicsState` wrapped in a `RefAny`, plus the queue that feeds it.
    fn state_with(physics: ScrollPhysics) -> (RefAny, ScrollInputQueue) {
        let queue = ScrollInputQueue::new();
        let state = ScrollPhysicsState::new(queue.clone(), physics);
        (RefAny::new(state), queue)
    }
    fn key(idx: usize) -> (DomId, NodeId) {
        (DomId::ROOT_ID, NodeId::new(idx))
    }
    /// Reads the physics state back out of the `RefAny` after a tick.
    fn with_state<R>(data: &mut RefAny, f: impl FnOnce(&ScrollPhysicsState) -> R) -> R {
        let state = data
            .downcast_ref::<ScrollPhysicsState>()
            .expect("RefAny must still hold a ScrollPhysicsState");
        f(&state)
    }
    /// A `ScrollPhysics` whose every float field is `NaN` and every integer field
    /// is degenerate — except `max_velocity`, which must stay non-NaN and
    /// non-negative or `f32::clamp` panics (see the `known_hazard` tests below).
    fn nan_physics() -> ScrollPhysics {
        ScrollPhysics {
            smooth_scroll_duration_ms: 0,
            deceleration_rate: f32::NAN,
            min_velocity_threshold: f32::NAN,
            max_velocity: 0.0,
            wheel_multiplier: f32::NAN,
            invert_direction: false,
            overscroll_elasticity: f32::NAN,
            max_overscroll_distance: f32::NAN,
            bounce_back_duration_ms: 0,
            timer_interval_ms: 0,
            wheel_animate_bounce_ms: 0,
        }
    }
    // ==================================================================
    // calculate_overshoot — numeric
    // ==================================================================
    #[test]
    fn calculate_overshoot_returns_zero_inside_the_range_and_on_both_boundaries() {
        assert_eq!(calculate_overshoot(0.0, 0.0, 100.0), 0.0);
        assert_eq!(calculate_overshoot(100.0, 0.0, 100.0), 0.0);
        assert_eq!(calculate_overshoot(50.0, 0.0, 100.0), 0.0);
        // Degenerate range (min == max): only that single point is in range.
        assert_eq!(calculate_overshoot(0.0, 0.0, 0.0), 0.0);
        // -0.0 is neither < 0.0 nor > 0.0, so it counts as in-range.
        assert_eq!(calculate_overshoot(-0.0, 0.0, 100.0), 0.0);
    }
    #[test]
    fn calculate_overshoot_is_signed_by_which_boundary_was_crossed() {
        assert_eq!(calculate_overshoot(-10.0, 0.0, 100.0), -10.0);
        assert_eq!(calculate_overshoot(110.0, 0.0, 100.0), 10.0);
        // Negative range: overshoot is still measured relative to the boundary.
        assert_eq!(calculate_overshoot(-30.0, -20.0, -10.0), -10.0);
        assert_eq!(calculate_overshoot(0.0, -20.0, -10.0), 10.0);
    }
    #[test]
    fn calculate_overshoot_nan_position_reports_no_overshoot() {
        // Both `NaN < min` and `NaN > max` are false, so the in-range branch wins
        // and a NaN position is reported as "not overshooting" rather than
        // propagating NaN into the spring force.
        let out = calculate_overshoot(f32::NAN, 0.0, 100.0);
        assert!(!out.is_nan(), "NaN must not leak out of calculate_overshoot");
        assert_eq!(out, 0.0);
        // A NaN bound, however, does make every position look "in range".
        assert_eq!(calculate_overshoot(1e9, 0.0, f32::NAN), 0.0);
        assert_eq!(calculate_overshoot(-1e9, f32::NAN, 100.0), 0.0);
    }
    #[test]
    fn calculate_overshoot_infinite_position_saturates_without_panicking() {
        assert_eq!(calculate_overshoot(f32::INFINITY, 0.0, 100.0), f32::INFINITY);
        assert_eq!(
            calculate_overshoot(f32::NEG_INFINITY, 0.0, 100.0),
            f32::NEG_INFINITY
        );
        // inf - inf would be NaN; the boundary check keeps us in-range instead.
        assert_eq!(
            calculate_overshoot(f32::INFINITY, f32::NEG_INFINITY, f32::INFINITY),
            0.0
        );
    }
    #[test]
    fn calculate_overshoot_extreme_finite_range_overflows_to_infinity_not_a_panic() {
        // f32::MAX - f32::MIN is not representable -> +inf. Defined, no panic.
        let out = calculate_overshoot(f32::MAX, f32::MIN, f32::MIN);
        assert!(out.is_infinite() && out.is_sign_positive());
        let out = calculate_overshoot(f32::MIN, f32::MAX, f32::MAX);
        assert!(out.is_infinite() && out.is_sign_negative());
    }
    #[test]
    fn calculate_overshoot_inverted_range_is_deterministic() {
        // min > max: the `pos < min` branch is checked first, so everything below
        // `min` reads as a negative overshoot. No panic, no assertion inside.
        assert_eq!(calculate_overshoot(5.0, 10.0, 0.0), -5.0);
        assert_eq!(calculate_overshoot(20.0, 10.0, 0.0), 20.0);
    }
    // ==================================================================
    // rubber_band_clamp — numeric
    // ==================================================================
    #[test]
    fn rubber_band_clamp_is_the_identity_inside_the_range() {
        assert_eq!(rubber_band_clamp(0.0, 0.0, 100.0, 50.0, 0.5), 0.0);
        assert_eq!(rubber_band_clamp(50.0, 0.0, 100.0, 50.0, 0.5), 50.0);
        assert_eq!(rubber_band_clamp(100.0, 0.0, 100.0, 50.0, 0.5), 100.0);
    }
    #[test]
    fn rubber_band_clamp_with_zero_max_overscroll_hard_clamps_to_the_boundary() {
        assert_eq!(rubber_band_clamp(1000.0, 0.0, 100.0, 0.0, 0.5), 100.0);
        assert_eq!(rubber_band_clamp(-1000.0, 0.0, 100.0, 0.0, 0.5), 0.0);
        // A negative max_overscroll takes the same `else` branch (no bounce).
        assert_eq!(rubber_band_clamp(1000.0, 0.0, 100.0, -50.0, 0.5), 100.0);
        assert_eq!(rubber_band_clamp(-1000.0, 0.0, 100.0, -50.0, 0.5), 0.0);
    }
    #[test]
    fn rubber_band_clamp_with_zero_elasticity_pins_to_the_boundary() {
        // 1 - e^0 == 0, so no overshoot displacement at all.
        assert_eq!(rubber_band_clamp(1000.0, 0.0, 100.0, 120.0, 0.0), 100.0);
        assert_eq!(rubber_band_clamp(-1000.0, 0.0, 100.0, 120.0, 0.0), 0.0);
    }
    #[test]
    fn rubber_band_clamp_never_exceeds_max_overscroll_even_for_absurd_input() {
        let (min, max, max_over, elast) = (0.0, 100.0, 120.0, 0.5);
        for raw in [101.0_f32, 500.0, 1e6, 1e30, f32::MAX, f32::INFINITY] {
            let out = rubber_band_clamp(raw, min, max, max_over, elast);
            assert!(out.is_finite(), "raw={raw} produced {out}");
            assert!(
                out >= max && out <= max + max_over,
                "raw={raw} escaped the overscroll band: {out}"
            );
        }
        for raw in [-1.0_f32, -500.0, -1e6, -1e30, f32::MIN, f32::NEG_INFINITY] {
            let out = rubber_band_clamp(raw, min, max, max_over, elast);
            assert!(out.is_finite(), "raw={raw} produced {out}");
            assert!(
                out <= min && out >= min - max_over,
                "raw={raw} escaped the overscroll band: {out}"
            );
        }
        // The band is approached asymptotically: an infinite pull lands exactly on it.
        assert_eq!(
            rubber_band_clamp(f32::INFINITY, min, max, max_over, elast),
            max + max_over
        );
        assert_eq!(
            rubber_band_clamp(f32::NEG_INFINITY, min, max, max_over, elast),
            min - max_over
        );
    }
    #[test]
    fn rubber_band_clamp_has_diminishing_returns_and_stays_monotonic() {
        let (min, max, max_over, elast) = (0.0, 100.0, 100.0, 0.5);
        let mut previous = max;
        for raw in [110.0_f32, 120.0, 200.0, 400.0, 800.0] {
            let out = rubber_band_clamp(raw, min, max, max_over, elast);
            // Monotonically increasing...
            assert!(out > previous, "not monotonic at raw={raw}: {out} <= {previous}");
            // ...but always giving back less than the raw pull (springy resistance).
            assert!(
                out < raw,
                "raw={raw} was not resisted at all (got {out})"
            );
            previous = out;
        }
    }
    #[test]
    fn rubber_band_clamp_nan_inputs_are_defined_and_do_not_panic() {
        // NaN fails both in-range comparisons, falls into the `raw_pos >= max`
        // branch, and NaN propagates to the result. The caller
        // (`scroll_to_unclamped` -> change processor) sanitises it later.
        assert!(rubber_band_clamp(f32::NAN, 0.0, 100.0, 120.0, 0.5).is_nan());
        // A NaN elasticity / max_overscroll must not panic either.
        assert!(rubber_band_clamp(500.0, 0.0, 100.0, 120.0, f32::NAN).is_nan());
        // NaN max_overscroll is not > 0.0, so the no-bounce branch pins the boundary.
        assert_eq!(rubber_band_clamp(500.0, 0.0, 100.0, f32::NAN, 0.5), 100.0);
    }
    #[test]
    fn rubber_band_clamp_negative_elasticity_stays_non_nan() {
        // A negative elasticity inverts the exponential (e^+x): the "resistance"
        // becomes an amplification. It is nonsense physically, but it must not
        // panic and must not produce NaN.
        for raw in [110.0_f32, 1e6, f32::MAX] {
            let out = rubber_band_clamp(raw, 0.0, 100.0, 100.0, -1.0);
            assert!(!out.is_nan(), "raw={raw} produced NaN");
        }
        // Small overshoot with negative elasticity: still finite and defined.
        let out = rubber_band_clamp(110.0, 0.0, 100.0, 100.0, -1.0);
        assert!(out.is_finite());
        assert!(out < 100.0, "negative elasticity flips the sign: {out}");
    }
    #[test]
    fn rubber_band_clamp_degenerate_range_still_returns_a_boundary() {
        // min == max: everything except that point overshoots.
        assert_eq!(rubber_band_clamp(0.0, 0.0, 0.0, 100.0, 0.5), 0.0);
        let out = rubber_band_clamp(10.0, 0.0, 0.0, 100.0, 0.5);
        assert!(out > 0.0 && out <= 100.0, "{out}");
        let out = rubber_band_clamp(-10.0, 0.0, 0.0, 100.0, 0.5);
        assert!((-100.0..0.0).contains(&out), "{out}");
    }
    // ==================================================================
    // friction_from_deceleration — numeric
    // ==================================================================
    #[test]
    fn friction_from_deceleration_matches_the_documented_values() {
        assert!((friction_from_deceleration(0.95) - 0.05).abs() < 1e-6);
        assert!((friction_from_deceleration(0.998) - 0.002).abs() < 1e-6);
        assert!((friction_from_deceleration(0.0) - 1.0).abs() < 1e-6);
    }
    #[test]
    fn friction_from_deceleration_clamps_both_ends_and_never_returns_zero() {
        // Anything >= 0.999 collapses onto the 0.001 friction floor: a
        // deceleration_rate of exactly 1.0 ("never stops") must NOT produce a
        // zero friction, or momentum would run forever.
        assert_eq!(friction_from_deceleration(1.0), 0.001);
        assert_eq!(friction_from_deceleration(0.999), 0.001);
        assert_eq!(friction_from_deceleration(f32::MAX), 0.001);
        assert_eq!(friction_from_deceleration(f32::INFINITY), 0.001);
        // Anything <= 0.0 saturates to full friction.
        assert_eq!(friction_from_deceleration(-0.0), 1.0);
        assert_eq!(friction_from_deceleration(-5.0), 1.0);
        assert_eq!(friction_from_deceleration(f32::MIN), 1.0);
        assert_eq!(friction_from_deceleration(f32::NEG_INFINITY), 1.0);
    }
    #[test]
    fn friction_from_deceleration_nan_falls_back_to_the_floor() {
        // f32::clamp(NaN) == NaN, but `NaN.max(0.001)` == 0.001 (f32::max ignores
        // NaN), so the friction floor rescues the whole physics integration.
        let out = friction_from_deceleration(f32::NAN);
        assert!(!out.is_nan(), "NaN deceleration must not poison friction");
        assert_eq!(out, 0.001);
    }
    #[test]
    fn friction_from_deceleration_always_yields_a_usable_decay_factor() {
        let dt = 16.0 / 1000.0;
        for rate in [
            0.0_f32,
            0.5,
            0.9,
            0.95,
            0.996,
            0.998,
            0.999,
            1.0,
            -1.0,
            f32::NAN,
            f32::INFINITY,
            f32::NEG_INFINITY,
            f32::MAX,
            f32::MIN,
            f32::MIN_POSITIVE,
        ] {
            let friction = friction_from_deceleration(rate);
            assert!(friction.is_finite(), "rate={rate} -> {friction}");
            assert!(
                (0.001..=1.0).contains(&friction),
                "rate={rate} -> friction {friction} outside [0.001, 1.0]"
            );
            // This is exactly how the callback uses it: exp(-friction*dt*60).
            let decay = (-friction * dt * ASSUMED_FPS).exp();
            assert!(decay.is_finite() && decay > 0.0 && decay < 1.0, "rate={rate} -> decay {decay}");
        }
    }
    // ==================================================================
    // spring_constant_from_bounce_duration — numeric
    // ==================================================================
    #[test]
    fn spring_constant_clamps_short_durations_to_50ms() {
        let floor = spring_constant_from_bounce_duration(50);
        assert_eq!(spring_constant_from_bounce_duration(0), floor);
        assert_eq!(spring_constant_from_bounce_duration(1), floor);
        assert_eq!(spring_constant_from_bounce_duration(49), floor);
        // (2*pi / 0.05)^2 ~= 15791.4
        assert!((floor - 15791.37).abs() < 1.0, "{floor}");
    }
    #[test]
    fn spring_constant_decreases_monotonically_with_duration() {
        let mut previous = f32::INFINITY;
        for ms in [50_u32, 100, 200, 300, 400, 500, 1000, 10_000] {
            let k = spring_constant_from_bounce_duration(ms);
            assert!(k.is_finite() && k > 0.0, "ms={ms} -> {k}");
            assert!(k < previous, "ms={ms}: {k} did not decrease below {previous}");
            previous = k;
        }
    }
    #[test]
    fn spring_constant_stays_finite_and_positive_at_u32_max() {
        // duration_ms = u32::MAX -> ~4295 seconds -> a vanishingly small k.
        // It must stay > 0 so that `2 * k.sqrt()` (the damping term) is not NaN.
        let k = spring_constant_from_bounce_duration(u32::MAX);
        assert!(k.is_finite(), "{k}");
        assert!(k > 0.0, "{k}");
        assert!(k < 1e-6, "{k}");
    }
    #[test]
    fn spring_constant_damping_term_is_always_finite() {
        // The callback computes `2.0 * spring_k.sqrt()`; a negative or NaN k
        // would make the critical-damping coefficient NaN.
        for ms in [0_u32, 1, 50, 400, 1000, u32::MAX / 2, u32::MAX - 1, u32::MAX] {
            let k = spring_constant_from_bounce_duration(ms);
            let damping = 2.0 * k.sqrt();
            assert!(damping.is_finite() && damping > 0.0, "ms={ms} -> damping {damping}");
        }
    }
    // ==================================================================
    // node_allows_rubber_band — predicate / numeric
    // ==================================================================
    #[test]
    fn node_allows_rubber_band_requires_actual_overflow_on_the_axis() {
        // An axis with no overflow can never rubber-band, whatever the config.
        for max_scroll in [0.0_f32, -0.0, -1.0, -1e9, f32::MIN, f32::NEG_INFINITY] {
            assert!(
                !node_allows_rubber_band(
                    max_scroll,
                    OverscrollBehavior::Auto,
                    OverflowScrolling::Touch,
                    1.0
                ),
                "max_scroll={max_scroll} must not rubber-band"
            );
        }
    }
    #[test]
    fn node_allows_rubber_band_is_vetoed_by_overscroll_behavior_none() {
        // `overscroll-behavior: none` wins over -azul-overflow-scrolling: touch
        // and over a fully elastic global config.
        assert!(!node_allows_rubber_band(
            400.0,
            OverscrollBehavior::None,
            OverflowScrolling::Touch,
            1.0
        ));
        assert!(!node_allows_rubber_band(
            f32::MAX,
            OverscrollBehavior::None,
            OverflowScrolling::Auto,
            1.0
        ));
    }
    #[test]
    fn node_allows_rubber_band_touch_overrides_a_zero_global_elasticity() {
        // -azul-overflow-scrolling: touch opts in even on a Windows-like
        // (elasticity 0.0) global config.
        assert!(node_allows_rubber_band(
            400.0,
            OverscrollBehavior::Auto,
            OverflowScrolling::Touch,
            0.0
        ));
        // `contain` blocks chaining but still permits the local bounce.
        assert!(node_allows_rubber_band(
            400.0,
            OverscrollBehavior::Contain,
            OverflowScrolling::Touch,
            0.0
        ));
    }
    #[test]
    fn node_allows_rubber_band_otherwise_follows_the_global_elasticity() {
        let ask = |elasticity: f32| {
            node_allows_rubber_band(
                400.0,
                OverscrollBehavior::Auto,
                OverflowScrolling::Auto,
                elasticity,
            )
        };
        assert!(!ask(0.0));
        assert!(!ask(-0.0));
        assert!(!ask(-1.0));
        assert!(!ask(f32::NEG_INFINITY));
        // NaN > 0.0 is false -> no bounce. Defined, no panic.
        assert!(!ask(f32::NAN));
        assert!(ask(f32::MIN_POSITIVE));
        assert!(ask(0.3));
        assert!(ask(f32::INFINITY));
    }
    #[test]
    fn node_allows_rubber_band_contain_still_bounces_locally() {
        // CSS: `contain` stops scroll *chaining*, not the local overscroll effect.
        assert!(node_allows_rubber_band(
            400.0,
            OverscrollBehavior::Contain,
            OverflowScrolling::Auto,
            0.5
        ));
        assert!(!node_allows_rubber_band(
            400.0,
            OverscrollBehavior::Contain,
            OverflowScrolling::Auto,
            0.0
        ));
    }
    #[test]
    fn node_allows_rubber_band_nan_max_scroll_is_treated_as_overflowing() {
        // NOTE (quirk, asserted so a change is noticed): `NaN <= 0.0` is false,
        // so a NaN max_scroll slips past the "has overflow" gate and the node is
        // allowed to rubber-band against a NaN boundary. Not reachable from a
        // sane layout (max_scroll comes from `(content - container).max(0.0)`),
        // but it is not defended against here either.
        assert!(node_allows_rubber_band(
            f32::NAN,
            OverscrollBehavior::Auto,
            OverflowScrolling::Auto,
            0.5
        ));
        assert!(node_allows_rubber_band(
            f32::NAN,
            OverscrollBehavior::Auto,
            OverflowScrolling::Touch,
            0.0
        ));
        // The other two vetoes still apply, NaN or not.
        assert!(!node_allows_rubber_band(
            f32::NAN,
            OverscrollBehavior::None,
            OverflowScrolling::Touch,
            1.0
        ));
    }
    // ==================================================================
    // ScrollPhysicsState::new — constructor
    // ==================================================================
    #[test]
    fn new_starts_empty_and_keeps_the_config_verbatim() {
        for physics in [
            ScrollPhysics::default(),
            ScrollPhysics::ios(),
            ScrollPhysics::macos(),
            ScrollPhysics::windows(),
            ScrollPhysics::android(),
            nan_physics(),
        ] {
            let state = ScrollPhysicsState::new(ScrollInputQueue::new(), physics);
            assert!(state.node_velocities.is_empty());
            assert!(state.pending_positions.is_empty());
            assert!(state.pending_trackpad_positions.is_empty());
            assert!(!state.input_queue.has_pending());
            // Config is stored verbatim (compare a field that is not NaN).
            assert_eq!(
                state.scroll_physics.timer_interval_ms,
                physics.timer_interval_ms
            );
            assert_eq!(state.scroll_physics.max_velocity, physics.max_velocity);
        }
    }
    #[test]
    fn new_shares_the_input_queue_rather_than_copying_it() {
        // The whole architecture depends on this: the event handler pushes into
        // its clone of the queue and the timer must see it.
        let queue = ScrollInputQueue::new();
        let state = ScrollPhysicsState::new(queue.clone(), ScrollPhysics::default());
        assert!(!state.input_queue.has_pending());
        queue.push(input(0, (0.0, 10.0), ScrollInputSource::WheelDiscrete));
        assert!(
            state.input_queue.has_pending(),
            "the queue must be shared (Arc), not deep-copied"
        );
        let taken = state.input_queue.take_recent(MAX_SCROLL_EVENTS_PER_TICK);
        assert_eq!(taken.len(), 1);
        assert!(!queue.has_pending(), "draining the timer side drains both");
    }
    // ==================================================================
    // ScrollPhysicsState::is_active — predicate
    // ==================================================================
    #[test]
    fn is_active_is_false_for_a_fresh_state() {
        let state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
        assert!(!state.is_active());
    }
    #[test]
    fn is_active_is_true_while_inputs_are_pending() {
        let queue = ScrollInputQueue::new();
        let state = ScrollPhysicsState::new(queue.clone(), ScrollPhysics::default());
        queue.push(input(0, (0.0, 1.0), ScrollInputSource::WheelDiscrete));
        assert!(state.is_active());
    }
    #[test]
    fn is_active_uses_a_strict_greater_than_against_the_threshold() {
        let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
        let threshold = state.scroll_physics.min_velocity_threshold; // 50.0
        let at = |velocity: LogicalPosition| NodeScrollPhysics {
            velocity,
            is_rubber_banding: false,
        };
        // Exactly at the threshold is NOT active (strict `>`).
        state
            .node_velocities
            .insert(key(0), at(LogicalPosition::new(0.0, threshold)));
        assert!(!state.is_active(), "velocity == threshold must not be active");
        // A hair above it is.
        state
            .node_velocities
            .insert(key(0), at(LogicalPosition::new(0.0, threshold * 1.0001)));
        assert!(state.is_active());
        // Either axis is enough, and the sign does not matter.
        state
            .node_velocities
            .insert(key(0), at(LogicalPosition::new(-threshold * 2.0, 0.0)));
        assert!(state.is_active(), "|velocity| is what counts, not the sign");
    }
    #[test]
    fn is_active_is_true_while_rubber_banding_even_at_zero_velocity() {
        let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
        state.node_velocities.insert(
            key(0),
            NodeScrollPhysics {
                velocity: LogicalPosition::zero(),
                is_rubber_banding: true,
            },
        );
        assert!(
            state.is_active(),
            "the spring-back animation must keep the timer alive"
        );
    }
    #[test]
    fn is_active_is_true_while_positions_are_pending() {
        let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
        state
            .pending_positions
            .insert(key(0), LogicalPosition::zero());
        assert!(state.is_active());
        let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
        state
            .pending_trackpad_positions
            .insert(key(0), LogicalPosition::zero());
        assert!(state.is_active());
    }
    #[test]
    fn is_active_treats_nan_velocity_as_inactive_without_panicking() {
        // NaN.abs() > threshold is false -> the node reads as at rest. The
        // important part is that this is deterministic and does not panic.
        let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
        state.node_velocities.insert(
            key(0),
            NodeScrollPhysics {
                velocity: LogicalPosition::new(f32::NAN, f32::NAN),
                is_rubber_banding: false,
            },
        );
        assert!(!state.is_active());
        // A NaN *threshold* likewise never reports active.
        let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), nan_physics());
        state.node_velocities.insert(
            key(0),
            NodeScrollPhysics {
                velocity: LogicalPosition::new(1e9, 1e9),
                is_rubber_banding: false,
            },
        );
        assert!(!state.is_active());
    }
    #[test]
    fn is_active_with_infinite_velocity_is_true() {
        let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
        state.node_velocities.insert(
            key(0),
            NodeScrollPhysics {
                velocity: LogicalPosition::new(f32::INFINITY, f32::NEG_INFINITY),
                is_rubber_banding: false,
            },
        );
        assert!(state.is_active());
    }
    // ==================================================================
    // scroll_physics_timer_callback — smoke / integration
    // ==================================================================
    #[test]
    fn callback_with_a_foreign_refany_terminates_instead_of_panicking() {
        let data = RefAny::new(42_u32);
        with_env(|_| {}, |env| {
            let ret = env.tick(&data);
            assert_eq!(ret.should_terminate, TerminateTimer::Terminate);
            assert_eq!(ret.should_update, Update::DoNothing);
            assert!(env.take_changes().is_empty());
        });
    }
    #[test]
    fn callback_with_nothing_to_do_terminates_the_timer() {
        let (data, _queue) = state_with(ScrollPhysics::default());
        with_env(|_| {}, |env| {
            let ret = env.tick(&data);
            assert_eq!(
                ret.should_terminate,
                TerminateTimer::Terminate,
                "an idle physics timer must not keep spinning"
            );
            assert!(env.take_changes().is_empty());
        });
    }
    #[test]
    fn callback_programmatic_input_pushes_a_hard_clamped_scroll_to() {
        let (data, queue) = state_with(ScrollPhysics::default());
        // Viewport 100x100 over 100x500 content -> max_scroll = (0, 400).
        queue.push(input(3, (0.0, 10_000.0), ScrollInputSource::Programmatic));
        with_env(
            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
            |env| {
                let ret = env.tick(&data);
                assert_eq!(ret.should_terminate, TerminateTimer::Continue);
                // Scroll is applied via nodes_scrolled_in_callbacks, not a relayout.
                assert_eq!(ret.should_update, Update::DoNothing);
                let scrolls = env.take_scroll_tos();
                assert_eq!(scrolls.len(), 1);
                let (idx, pos, unclamped) = scrolls[0];
                assert_eq!(idx, 3);
                assert!(!unclamped, "programmatic scroll must be hard-clamped");
                assert_eq!(pos.x, 0.0);
                assert_eq!(pos.y, 400.0, "a 10000px jump must clamp to max_scroll_y");
            },
        );
    }
    #[test]
    fn callback_programmatic_negative_input_clamps_to_zero() {
        let (data, queue) = state_with(ScrollPhysics::default());
        queue.push(input(3, (-1e9, -1e9), ScrollInputSource::Programmatic));
        with_env(
            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
            |env| {
                let _ = env.tick(&data);
                let scrolls = env.take_scroll_tos();
                assert_eq!(scrolls.len(), 1);
                assert_eq!(scrolls[0].1, LogicalPosition::zero());
            },
        );
    }
    #[test]
    fn callback_trackpad_overshoot_is_bounded_by_max_overscroll_distance() {
        // iOS physics: elasticity 0.5, max_overscroll_distance 120.
        let physics = ScrollPhysics::ios();
        let (data, queue) = state_with(physics);
        queue.push(input(3, (0.0, 1e9), ScrollInputSource::TrackpadContinuous));
        with_env(
            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
            |env| {
                let ret = env.tick(&data);
                assert_eq!(ret.should_terminate, TerminateTimer::Continue);
                let scrolls = env.take_scroll_tos();
                assert_eq!(scrolls.len(), 1);
                let (idx, pos, unclamped) = scrolls[0];
                assert_eq!(idx, 3);
                assert!(unclamped, "the timer does its own rubber-band clamping");
                assert!(pos.y.is_finite());
                // max_scroll_y (400) + max_overscroll_distance (120) is the ceiling.
                assert!(
                    pos.y > 400.0 && pos.y <= 400.0 + physics.max_overscroll_distance + 1e-3,
                    "a 1e9 px flick escaped the overscroll band: {}",
                    pos.y
                );
                // The x axis has no overflow -> no bounce, hard 0.
                assert_eq!(pos.x, 0.0);
            },
        );
    }
    #[test]
    fn callback_trackpad_without_elasticity_hard_clamps() {
        // Windows physics: elasticity 0.0, max_overscroll_distance 0.0.
        let (data, queue) = state_with(ScrollPhysics::windows());
        queue.push(input(3, (0.0, 1e9), ScrollInputSource::TrackpadContinuous));
        with_env(
            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
            |env| {
                let _ = env.tick(&data);
                let scrolls = env.take_scroll_tos();
                assert_eq!(scrolls.len(), 1);
                assert_eq!(scrolls[0].1.y, 400.0, "no bounce -> pinned to max_scroll_y");
            },
        );
    }
    #[test]
    fn callback_wheel_impulse_is_clamped_to_max_velocity() {
        let physics = ScrollPhysics::default(); // max_velocity 8000, wheel_multiplier 1.0
        let (mut data, queue) = state_with(physics);
        // delta * wheel_multiplier * 60 would be 6e10 / -inf without the clamp.
        queue.push(input(0, (1e9, -1e9), ScrollInputSource::WheelDiscrete));
        queue.push(input(1, (f32::INFINITY, f32::NEG_INFINITY), ScrollInputSource::WheelDiscrete));
        with_env(|_| {}, |env| {
            let ret = env.tick(&data);
            assert_eq!(ret.should_terminate, TerminateTimer::Continue);
        });
        with_state(&mut data, |state| {
            for idx in [0_usize, 1] {
                let node = state
                    .node_velocities
                    .get(&key(idx))
                    .unwrap_or_else(|| panic!("node {idx} lost its velocity"));
                assert_eq!(node.velocity.x, physics.max_velocity, "node {idx}");
                assert_eq!(node.velocity.y, -physics.max_velocity, "node {idx}");
            }
        });
    }
    #[test]
    fn callback_wheel_momentum_decays_and_never_leaves_the_scroll_bounds() {
        let (mut data, queue) = state_with(ScrollPhysics::default());
        queue.push(input(3, (0.0, 100.0), ScrollInputSource::WheelDiscrete));
        with_env(
            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
            |env| {
                let mut ticks = 0;
                // The offset in the (immutable) LayoutWindow never advances, so
                // this isolates the decay: the timer MUST still wind down.
                loop {
                    let ret = env.tick(&data);
                    for (_, pos, _) in env.take_scroll_tos() {
                        assert!(pos.x.is_finite() && pos.y.is_finite());
                        assert!(
                            (0.0..=400.0).contains(&pos.y),
                            "tick {ticks}: y={} left [0, max_scroll_y]",
                            pos.y
                        );
                        assert_eq!(pos.x, 0.0);
                    }
                    ticks += 1;
                    if ret.should_terminate == TerminateTimer::Terminate {
                        break;
                    }
                    assert!(
                        ticks < 1000,
                        "momentum never decayed below the velocity threshold"
                    );
                }
                assert!(ticks > 1, "the fling should survive at least one tick");
            },
        );
        with_state(&mut data, |state| {
            assert!(
                state.node_velocities.is_empty(),
                "a terminated timer must not leave live velocities behind"
            );
        });
    }
    #[test]
    fn callback_caps_the_events_processed_per_tick() {
        let (data, queue) = state_with(ScrollPhysics::default());
        // 5x the cap, each targeting a distinct node so they cannot coalesce.
        let total = MAX_SCROLL_EVENTS_PER_TICK * 5;
        for i in 0..total {
            queue.push(input(i, (0.0, 1.0), ScrollInputSource::Programmatic));
        }
        with_env(|_| {}, |env| {
            let ret = env.tick(&data);
            assert_eq!(ret.should_terminate, TerminateTimer::Continue);
            let scrolls = env.take_scroll_tos();
            assert_eq!(
                scrolls.len(),
                MAX_SCROLL_EVENTS_PER_TICK,
                "the per-tick event budget must be enforced"
            );
            // take_recent keeps the NEWEST events, so the surviving nodes are the
            // last MAX_SCROLL_EVENTS_PER_TICK that were pushed.
            for (idx, _, _) in &scrolls {
                assert!(
                    *idx >= total - MAX_SCROLL_EVENTS_PER_TICK,
                    "node {idx} is a stale event that should have been dropped"
                );
            }
        });
        assert!(
            !queue.has_pending(),
            "the backlog must be drained, not left to grow unboundedly"
        );
    }
    #[test]
    fn callback_nan_delta_does_not_panic() {
        // Programmatic: the NaN reaches the change log (the change processor
        // sanitises it via AnimatedScrollState::clamp) but nothing panics.
        let (data, queue) = state_with(ScrollPhysics::default());
        queue.push(input(0, (f32::NAN, f32::NAN), ScrollInputSource::Programmatic));
        with_env(|_| {}, |env| {
            let ret = env.tick(&data);
            assert_eq!(ret.should_terminate, TerminateTimer::Continue);
            let scrolls = env.take_scroll_tos();
            assert_eq!(scrolls.len(), 1);
            assert!(scrolls[0].1.x.is_nan() && scrolls[0].1.y.is_nan());
        });
    }
    #[test]
    fn callback_nan_wheel_delta_drops_the_node_instead_of_spinning_forever() {
        // A NaN velocity survives the clamp, but `retain` uses `> 0.0` (false for
        // NaN) so the node is dropped and the timer terminates. Asserted so that a
        // regression into an un-killable NaN velocity loop is caught.
        let (mut data, queue) = state_with(ScrollPhysics::default());
        queue.push(input(0, (f32::NAN, f32::NAN), ScrollInputSource::WheelDiscrete));
        with_env(|_| {}, |env| {
            let ret = env.tick(&data);
            assert_eq!(ret.should_terminate, TerminateTimer::Terminate);
            assert!(env.take_changes().is_empty());
        });
        with_state(&mut data, |state| {
            assert!(state.node_velocities.is_empty());
        });
    }
    #[test]
    fn callback_trackpad_end_on_an_unknown_node_is_a_no_op() {
        let (data, queue) = state_with(ScrollPhysics::ios());
        queue.push(input(7, (0.0, 0.0), ScrollInputSource::TrackpadEnd));
        with_env(|_| {}, |env| {
            let ret = env.tick(&data);
            assert_eq!(ret.should_terminate, TerminateTimer::Terminate);
            assert!(env.take_changes().is_empty());
        });
    }
    #[test]
    fn callback_trackpad_end_inside_the_bounds_does_not_start_a_spring_back() {
        let (mut data, queue) = state_with(ScrollPhysics::ios());
        queue.push(input(3, (0.0, 0.0), ScrollInputSource::TrackpadEnd));
        with_env(
            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
            |env| {
                // offset is 0 (in range) -> no overshoot -> no rubber-banding.
                let _ = env.tick(&data);
                let scrolls = env.take_scroll_tos();
                assert_eq!(scrolls.len(), 1, "the position is re-pushed unclamped");
                assert!(scrolls[0].2, "TrackpadEnd re-pushes the raw position");
                assert_eq!(scrolls[0].1, LogicalPosition::zero());
            },
        );
        with_state(&mut data, |state| {
            assert!(
                state.node_velocities.is_empty(),
                "no overshoot must not arm the spring"
            );
        });
    }
    #[test]
    fn callback_degenerate_physics_config_does_not_panic() {
        // Every float NaN, every duration 0 (max_velocity stays 0.0: see the
        // `known_bug` tests for why a NaN/negative max_velocity panics).
        let (data, queue) = state_with(nan_physics());
        queue.push(input(0, (10.0, 10.0), ScrollInputSource::WheelDiscrete));
        queue.push(input(1, (10.0, 10.0), ScrollInputSource::TrackpadContinuous));
        queue.push(input(2, (10.0, 10.0), ScrollInputSource::Programmatic));
        queue.push(input(3, (10.0, 10.0), ScrollInputSource::TrackpadEnd));
        with_env(
            |w| {
                register_node(w, 0, (100.0, 100.0), (100.0, 500.0));
                register_node(w, 1, (100.0, 100.0), (100.0, 500.0));
                register_node(w, 2, (100.0, 100.0), (100.0, 500.0));
                register_node(w, 3, (100.0, 100.0), (100.0, 500.0));
            },
            |env| {
                let ret = env.tick(&data);
                // Whatever it decides, it must decide *something* and not panic.
                assert!(matches!(
                    ret.should_terminate,
                    TerminateTimer::Continue | TerminateTimer::Terminate
                ));
                // A second tick over the resulting state must survive too.
                let _ = env.tick(&data);
            },
        );
    }
    #[test]
    fn callback_zero_timer_interval_still_advances_time() {
        // dt = max(1) / 1000 -> a 0ms interval must not produce dt == 0 (which
        // would freeze the integration) nor a division by zero.
        let physics = ScrollPhysics {
            timer_interval_ms: 0,
            ..ScrollPhysics::default()
        };
        let (mut data, queue) = state_with(physics);
        queue.push(input(3, (0.0, 100.0), ScrollInputSource::WheelDiscrete));
        with_env(
            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
            |env| {
                let ret = env.tick(&data);
                assert_eq!(ret.should_terminate, TerminateTimer::Continue);
                let scrolls = env.take_scroll_tos();
                assert_eq!(scrolls.len(), 1);
                let y = scrolls[0].1.y;
                assert!(y.is_finite() && y > 0.0 && y <= 400.0, "y={y}");
            },
        );
        with_state(&mut data, |state| {
            let node = state.node_velocities.get(&key(3)).expect("velocity kept");
            assert!(node.velocity.y.is_finite());
        });
    }
    #[test]
    fn callback_survives_a_huge_backlog_on_a_single_node() {
        // All events coalesce onto one node: the velocity impulses accumulate but
        // must stay clamped, and the queue must be fully drained.
        let physics = ScrollPhysics::default();
        let (mut data, queue) = state_with(physics);
        for _ in 0..(MAX_SCROLL_EVENTS_PER_TICK * 10) {
            queue.push(input(0, (0.0, 1e6), ScrollInputSource::WheelDiscrete));
        }
        with_env(|_| {}, |env| {
            let ret = env.tick(&data);
            assert_eq!(ret.should_terminate, TerminateTimer::Continue);
        });
        assert!(!queue.has_pending());
        with_state(&mut data, |state| {
            let node = state.node_velocities.get(&key(0)).expect("velocity kept");
            assert_eq!(
                node.velocity.y, physics.max_velocity,
                "1000 stacked impulses must not exceed max_velocity"
            );
        });
    }
    // ------------------------------------------------------------------
    // A NaN or negative `max_velocity` (ScrollPhysics is a plain repr(C) struct
    // with no validation, so a bad SystemStyle can supply one) used to reach
    // `velocity.clamp(-max_velocity, max_velocity)` with min > max and panic
    // inside f32::clamp — which, in the extern "C" `scroll_physics_timer_callback`,
    // ABORTS the process. The branch now sanitizes with `.max(0.0)`; these tests
    // pin that the sanitized bound yields a safe clamp.
    // ------------------------------------------------------------------
    #[test]
    fn nan_max_velocity_is_sanitized_to_a_safe_clamp() {
        let max_velocity = ScrollPhysics {
            max_velocity: f32::NAN,
            ..ScrollPhysics::default()
        }
        .max_velocity
        .max(0.0);
        assert_eq!(max_velocity, 0.0);
        assert_eq!((600.0_f32).clamp(-max_velocity, max_velocity), 0.0);
    }
    #[test]
    fn negative_max_velocity_is_sanitized_to_a_safe_clamp() {
        let max_velocity = ScrollPhysics {
            max_velocity: -1.0,
            ..ScrollPhysics::default()
        }
        .max_velocity
        .max(0.0);
        assert_eq!(max_velocity, 0.0);
        assert_eq!((600.0_f32).clamp(-max_velocity, max_velocity), 0.0);
    }
    #[test]
    fn zero_max_velocity_is_the_only_safe_degenerate_config() {
        // -0.0 <= 0.0, so a zero max_velocity does NOT panic: it pins every
        // wheel impulse to zero. This is the boundary the two tests above sit on.
        assert_eq!((600.0_f32).clamp(-0.0, 0.0), 0.0);
        assert_eq!((-600.0_f32).clamp(-0.0, 0.0), -0.0);
        // ...and NaN passes straight through clamp without panicking.
        assert!(f32::NAN.clamp(-8000.0, 8000.0).is_nan());
    }
    // ==================================================================
    // AnimateTo — target-seeking spring (scroll_to_animated)
    // ==================================================================
    //
    // NOTE: the window's scroll offset is NOT advanced between ticks in
    // this harness (ScrollTo changes are applied by the event loop in
    // production), so multi-tick assertions here are about VELOCITY
    // continuity and single-tick outputs, both offset-independent.
    #[test]
    fn animate_to_first_tick_glides_instead_of_jumping() {
        let (mut data, queue) = state_with(ScrollPhysics::default());
        // Viewport 100x100 over 100x500 -> max_scroll_y = 400.
        queue.push(input(3, (0.0, 400.0), ScrollInputSource::AnimateTo));
        with_env(
            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
            |env| {
                let ret = env.tick(&data);
                assert_eq!(ret.should_terminate, TerminateTimer::Continue);
                let tos = env.take_scroll_tos();
                assert_eq!(tos.len(), 1, "one node moved: {tos:?}");
                let (idx, pos, _unclamped) = tos[0];
                assert_eq!(idx, 3);
                assert!(
                    pos.y > 0.0 && pos.y < 400.0,
                    "an animated scroll SEEKS the target across ticks instead of \
                     teleporting (Programmatic behavior): first tick landed at {pos:?}"
                );
            },
        );
        with_state(&mut data, |st| {
            assert!(
                st.animate_targets.contains_key(&key(3)),
                "the target stays armed until convergence"
            );
        });
    }
    #[test]
    fn animate_to_snaps_exactly_onto_the_target_and_retires_it() {
        let (mut data, queue) = state_with(ScrollPhysics::default());
        queue.push(input(3, (0.0, 400.0), ScrollInputSource::AnimateTo));
        with_env(
            |w| {
                register_node(w, 3, (100.0, 100.0), (100.0, 500.0));
                // Start 0.2px short of the target with no velocity: the
                // convergence branch must snap to EXACTLY 400 (no
                // asymptotic crawl) and retire the target.
                w.scroll_manager.set_scroll_position(
                    DomId::ROOT_ID,
                    NodeId::new(3),
                    LogicalPosition::new(0.0, 399.8),
                    Instant::now(),
                );
            },
            |env| {
                let _ = env.tick(&data);
                let tos = env.take_scroll_tos();
                assert_eq!(tos.len(), 1, "{tos:?}");
                let (_, pos, _) = tos[0];
                assert!(
                    (pos.y - 400.0).abs() < f32::EPSILON,
                    "convergence snaps to the exact target, got {pos:?}"
                );
            },
        );
        with_state(&mut data, |st| {
            assert!(
                st.animate_targets.is_empty(),
                "a converged target is retired"
            );
        });
    }
    #[test]
    fn animate_to_retarget_keeps_the_current_velocity() {
        let (data, queue) = state_with(ScrollPhysics::default());
        queue.push(input(3, (0.0, 400.0), ScrollInputSource::AnimateTo));
        with_env(
            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
            |env| {
                let _ = env.tick(&data);
                let first = env.take_scroll_tos();
                let (_, p1, _) = first[0];
                // Retarget mid-flight to the opposite direction.
                queue.push(input(3, (0.0, 0.0), ScrollInputSource::AnimateTo));
                let _ = env.tick(&data);
                let second = env.take_scroll_tos();
                let (_, p2, _) = second[0];
                // The spring must REDIRECT the existing velocity, not
                // restart from rest: the second position stays continuous
                // with the first (still near/above it), it does not
                // teleport toward the new target.
                assert!(
                    p2.y > 0.0 && (p2.y - p1.y).abs() < p1.y.max(1.0) * 4.0,
                    "retarget must stay continuous: first {p1:?}, second {p2:?}"
                );
            },
        );
    }
    // ==================================================================
    // WheelDiscrete provenance — physical wheel = target glide,
    // everything else keeps the velocity model
    // ==================================================================
    #[test]
    fn physical_wheel_click_arms_an_absolute_target_glide() {
        let (mut data, queue) = state_with(ScrollPhysics::default());
        // Viewport 100x100 over 100x500 -> max_scroll_y = 400.
        queue.push(input_dev(
            3,
            (0.0, 30.0),
            ScrollInputSource::WheelDiscrete,
            ScrollInputDevice::MouseWheel,
        ));
        with_env(
            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
            |env| {
                let ret = env.tick(&data);
                assert_eq!(ret.should_terminate, TerminateTimer::Continue);
                let tos = env.take_scroll_tos();
                assert_eq!(tos.len(), 1, "one node moved: {tos:?}");
                let (_, pos, _) = tos[0];
                assert!(
                    pos.y > 0.0 && pos.y < 30.0,
                    "a wheel click GLIDES toward its target (default \
                     wheel_multiplier = 1.0 -> target y = 30), it neither \
                     teleports nor overshoots on the first tick: {pos:?}"
                );
            },
        );
        with_state(&mut data, |st| {
            let (target, device) = st.animate_targets[&key(3)];
            assert_eq!(
                target.y, 30.0,
                "target = current offset + delta * wheel_multiplier"
            );
            assert_eq!(device, ScrollInputDevice::MouseWheel);
        });
    }
    #[test]
    fn consecutive_wheel_clicks_extend_the_target_instead_of_stacking_impulses() {
        let (mut data, queue) = state_with(ScrollPhysics::default());
        queue.push(input_dev(
            3,
            (0.0, 30.0),
            ScrollInputSource::WheelDiscrete,
            ScrollInputDevice::MouseWheel,
        ));
        queue.push(input_dev(
            3,
            (0.0, 30.0),
            ScrollInputSource::WheelDiscrete,
            ScrollInputDevice::MouseWheel,
        ));
        with_env(
            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
            |env| {
                let _ = env.tick(&data);
            },
        );
        with_state(&mut data, |st| {
            let (target, _) = st.animate_targets[&key(3)];
            assert_eq!(
                target.y, 60.0,
                "the second click extends the FIRST click's target (30 + 30), \
                 it does not restart from the current offset"
            );
        });
    }
    #[test]
    fn wheel_click_target_is_clamped_to_the_scrollable_range() {
        let (mut data, queue) = state_with(ScrollPhysics::default());
        // max_scroll_y = 400; one huge click must not build an off-range target.
        queue.push(input_dev(
            3,
            (0.0, 10_000.0),
            ScrollInputSource::WheelDiscrete,
            ScrollInputDevice::MouseWheel,
        ));
        with_env(
            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
            |env| {
                let _ = env.tick(&data);
            },
        );
        with_state(&mut data, |st| {
            let (target, _) = st.animate_targets[&key(3)];
            assert_eq!(target.y, 400.0, "target clamps to max_scroll_y");
        });
    }
    #[test]
    fn test_driver_wheel_keeps_the_velocity_model() {
        let (mut data, queue) = state_with(ScrollPhysics::default());
        queue.push(input(3, (0.0, 30.0), ScrollInputSource::WheelDiscrete));
        with_env(
            |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
            |env| {
                let _ = env.tick(&data);
            },
        );
        with_state(&mut data, |st| {
            assert!(
                st.animate_targets.is_empty(),
                "TestDriver wheel events must stay on the deterministic \
                 velocity model (e2e harness contract), not the glide"
            );
            let v = st.node_velocities[&key(3)].velocity;
            assert!(v.y > 0.0, "impulse recorded as velocity: {v:?}");
        });
    }
    #[test]
    fn wheel_glide_uses_the_short_wheel_spring_not_the_bounce_spring() {
        // Same geometry, same 400px seek: node 3 via physical-wheel glide
        // (wheel_animate_bounce_ms = 60, stiff), node 4 via AnimateTo from a
        // test driver (bounce_back_duration_ms = 5000, soft). The stiffer
        // wheel spring must pull farther on the first tick.
        let physics = ScrollPhysics {
            wheel_animate_bounce_ms: 60,
            bounce_back_duration_ms: 5000,
            ..ScrollPhysics::default()
        };
        let (data, queue) = state_with(physics);
        queue.push(input_dev(
            3,
            (0.0, 400.0),
            ScrollInputSource::WheelDiscrete,
            ScrollInputDevice::MouseWheel,
        ));
        queue.push(input(4, (0.0, 400.0), ScrollInputSource::AnimateTo));
        with_env(
            |w| {
                register_node(w, 3, (100.0, 100.0), (100.0, 500.0));
                register_node(w, 4, (100.0, 100.0), (100.0, 500.0));
            },
            |env| {
                let _ = env.tick(&data);
                let tos = env.take_scroll_tos();
                let wheel_y = tos.iter().find(|(i, ..)| *i == 3).map(|(_, p, _)| p.y);
                let bounce_y = tos.iter().find(|(i, ..)| *i == 4).map(|(_, p, _)| p.y);
                let (Some(wheel_y), Some(bounce_y)) = (wheel_y, bounce_y) else {
                    panic!("both nodes must move on the first tick: {tos:?}");
                };
                assert!(
                    wheel_y > bounce_y,
                    "provenance picks the spring: wheel glide (60ms) must be \
                     snappier than the bounce-duration seek (5000ms); \
                     wheel {wheel_y} vs bounce {bounce_y}"
                );
            },
        );
    }
    // ==================================================================
    // REGRESSION (B1): the physics must not fight the real scroll
    // ==================================================================
    //
    // Reported on macOS: "physics based scrolling probably leading to external
    // scroll events and constantly fighting with the actual scroll".
    //
    // The harness above deliberately freezes the `LayoutWindow` behind a shared
    // reference, so it can only inspect the `ScrollTo` changes a tick EMITS —
    // it never applies them back. That is exactly why the bugs below were
    // invisible to the suite: they only show up once the loop is CLOSED, i.e.
    // once tick N+1 reads the offset tick N wrote. `closed_loop` does that.
    /// One tick with the loop closed: run the callback, then apply every
    /// emitted `ScrollTo` back into the `ScrollManager`, exactly as
    /// `dll/src/desktop/shell2/common/event.rs` does after a callback returns.
    /// Returns the `(node index, position, unclamped)` triples of that tick.
    fn closed_loop_tick(
        layout_window: &mut LayoutWindow,
        data: &RefAny,
    ) -> Vec<(usize, LogicalPosition, bool)> {
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
        {
            let renderer_resources = RendererResources::default();
            let previous_window_state: Option<FullWindowState> = None;
            let current_window_state = FullWindowState::default();
            let gl_context = OptionGlContextPtr::None;
            let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
                BTreeMap::new();
            let window_handle = RawWindowHandle::Unsupported;
            let system_callbacks = ExternalSystemCallbacks::rust_internal();
            let ref_data = CallbackInfoRefData {
                layout_window: &*layout_window,
                renderer_resources: &renderer_resources,
                previous_window_state: &previous_window_state,
                current_window_state: &current_window_state,
                gl_context: &gl_context,
                current_scroll_manager: &scroll_states,
                current_window_handle: &window_handle,
                system_callbacks: &system_callbacks,
                system_style: Arc::new(SystemStyle::default()),
                monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
                #[cfg(feature = "icu")]
                icu_localizer: IcuLocalizerHandle::default(),
                ctx: OptionRefAny::None,
            };
            let info = CallbackInfo::new(
                &ref_data,
                &changes,
                DomNodeId {
                    dom: DomId::ROOT_ID,
                    node: NodeHierarchyItemId::NONE,
                },
                OptionLogicalPosition::None,
                OptionLogicalPosition::None,
            );
            let timer_info =
                TimerCallbackInfo::create(info, OptionDomNodeId::None, Instant::now(), 0, false);
            let _ = scroll_physics_timer_callback(data.clone(), timer_info);
        }
        let emitted: Vec<(usize, LogicalPosition, bool)> = changes
            .lock()
            .map(|c| {
                c.iter()
                    .map(|change| {
                        let CallbackChange::ScrollTo {
                            node_id,
                            position,
                            unclamped,
                            ..
                        } = change
                        else {
                            panic!("expected only ScrollTo changes, got {change:?}");
                        };
                        (
                            node_id
                                .into_crate_internal()
                                .expect("ScrollTo must name a concrete node")
                                .index(),
                            *position,
                            *unclamped,
                        )
                    })
                    .collect()
            })
            .unwrap_or_default();
        for (idx, position, unclamped) in &emitted {
            let node = NodeId::new(*idx);
            if *unclamped {
                layout_window.scroll_manager.set_scroll_position_unclamped(
                    DomId::ROOT_ID,
                    node,
                    *position,
                    Instant::now(),
                );
            } else {
                layout_window.scroll_manager.set_scroll_position(
                    DomId::ROOT_ID,
                    node,
                    *position,
                    Instant::now(),
                );
            }
        }
        emitted
    }
    fn offset_of(layout_window: &LayoutWindow, idx: usize) -> LogicalPosition {
        layout_window
            .scroll_manager
            .get_current_offset(DomId::ROOT_ID, NodeId::new(idx))
            .unwrap_or_default()
    }
    /// REGRESSION (B1): one finger gesture must land on a STABLE offset.
    ///
    /// Drives five 10px trackpad deltas plus the gesture end, then lets the
    /// physics run 120 ticks with the loop closed. The offset must settle and
    /// stay settled — no oscillation, no drift.
    #[test]
    fn a_single_trackpad_gesture_converges_to_a_stable_offset() {
        let mut layout_window =
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
        register_node(&mut layout_window, 1, (100.0, 100.0), (100.0, 1000.0));
        let queue = layout_window.scroll_manager.get_input_queue();
        let data = RefAny::new(ScrollPhysicsState::new(
            queue.clone(),
            ScrollPhysics::default(),
        ));
        for _ in 0..5 {
            queue.push(input_dev(
                1,
                (0.0, 10.0),
                ScrollInputSource::TrackpadContinuous,
                ScrollInputDevice::Touchpad,
            ));
        }
        queue.push(input_dev(
            1,
            (0.0, 0.0),
            ScrollInputSource::TrackpadEnd,
            ScrollInputDevice::Touchpad,
        ));
        let mut trace = Vec::new();
        for _ in 0..120 {
            let _ = closed_loop_tick(&mut layout_window, &data);
            trace.push(offset_of(&layout_window, 1).y);
        }
        let settled = trace[trace.len() - 1];
        // Five 10px deltas, well inside the 900px range: the gesture must land
        // on their SUM. Losing deltas (they used to overwrite each other inside
        // a tick) shows up here as a smaller number.
        assert!(
            (settled - 50.0).abs() < 0.5,
            "a 5 x 10px gesture must land on 50px, landed on {settled} (trace tail: {:?})",
            &trace[trace.len().saturating_sub(6)..]
        );
        // Stable: the last 30 ticks must not move it at all.
        for (i, y) in trace.iter().enumerate().skip(trace.len() - 30) {
            assert!(
                (*y - settled).abs() < 0.01,
                "offset still moving at tick {i}: {y} vs settled {settled}"
            );
        }
    }
    /// REGRESSION (B1): a physics-produced update must NEVER come back as a
    /// fresh scroll input. If it did, every tick would re-integrate its own
    /// output and the gesture could never converge.
    #[test]
    fn physics_output_is_not_re_consumed_as_fresh_input() {
        let mut layout_window =
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
        register_node(&mut layout_window, 1, (100.0, 100.0), (100.0, 1000.0));
        let queue = layout_window.scroll_manager.get_input_queue();
        let data = RefAny::new(ScrollPhysicsState::new(
            queue.clone(),
            ScrollPhysics::default(),
        ));
        queue.push(input_dev(
            1,
            (0.0, 40.0),
            ScrollInputSource::TrackpadContinuous,
            ScrollInputDevice::Touchpad,
        ));
        queue.push(input_dev(
            1,
            (0.0, 0.0),
            ScrollInputSource::TrackpadEnd,
            ScrollInputDevice::Touchpad,
        ));
        // First tick drains the user's gesture...
        let _ = closed_loop_tick(&mut layout_window, &data);
        // ...and from then on nothing may re-appear in the input queue, however
        // many offsets the physics writes.
        for tick in 0..60 {
            let _ = closed_loop_tick(&mut layout_window, &data);
            assert!(
                !queue.has_pending(),
                "tick {tick}: applying a physics ScrollTo put input back on the \
                 scroll input queue — that is the feedback loop"
            );
        }
    }
    /// REGRESSION (B1): two trackpad events inside ONE 16ms tick must add up.
    ///
    /// `current_offset` does not move while the callback runs, so computing
    /// `current + delta` per event and `insert`ing collapsed the batch to the
    /// LAST delta. A 120Hz trackpad against the 16ms tick puts two events in a
    /// tick routinely, i.e. half the gesture was dropped.
    #[test]
    fn two_trackpad_events_in_one_tick_accumulate_instead_of_overwriting() {
        let mut layout_window =
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
        register_node(&mut layout_window, 1, (100.0, 100.0), (100.0, 1000.0));
        let queue = layout_window.scroll_manager.get_input_queue();
        let data = RefAny::new(ScrollPhysicsState::new(
            queue.clone(),
            ScrollPhysics::default(),
        ));
        for _ in 0..3 {
            queue.push(input_dev(
                1,
                (0.0, 10.0),
                ScrollInputSource::TrackpadContinuous,
                ScrollInputDevice::Touchpad,
            ));
        }
        let _ = closed_loop_tick(&mut layout_window, &data);
        let y = offset_of(&layout_window, 1).y;
        assert!(
            (y - 30.0).abs() < 0.01,
            "three 10px deltas in one tick must move 30px, moved {y}"
        );
    }
    /// REGRESSION (B1): only ONE writer may claim a node's offset in a tick.
    ///
    /// The trackpad staging position and the velocity/spring position are both
    /// applied as `scroll_to_unclamped` for the same node, velocity LAST — so
    /// on any tick where the finger moved AND the spring was armed, the
    /// gesture's delta was silently overwritten by a position integrated from
    /// the stale, pre-tick offset. That is the "constantly fighting" symptom.
    #[test]
    fn the_spring_does_not_also_write_a_node_the_finger_moved_this_tick() {
        let mut layout_window =
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
        // max_scroll_y = 100, so a 150px flick overshoots and arms the spring.
        register_node(&mut layout_window, 1, (100.0, 100.0), (100.0, 200.0));
        let queue = layout_window.scroll_manager.get_input_queue();
        let data = RefAny::new(ScrollPhysicsState::new(
            queue.clone(),
            ScrollPhysics::macos(),
        ));
        queue.push(input_dev(
            1,
            (0.0, 150.0),
            ScrollInputSource::TrackpadContinuous,
            ScrollInputDevice::Touchpad,
        ));
        queue.push(input_dev(
            1,
            (0.0, 0.0),
            ScrollInputSource::TrackpadEnd,
            ScrollInputDevice::Touchpad,
        ));
        let emitted = closed_loop_tick(&mut layout_window, &data);
        let writes_for_node_1 = emitted.iter().filter(|(idx, ..)| *idx == 1).count();
        assert_eq!(
            writes_for_node_1, 1,
            "the finger moved node 1 this tick, so exactly one writer may claim \
             it; got {writes_for_node_1} ScrollTos: {emitted:?}"
        );
    }
    /// REGRESSION (B1): after an overscroll flick the offset must spring back
    /// to the boundary.
    ///
    /// `TrackpadEnd` used to look for the gesture's position in
    /// `pending_positions` — the PROGRAMMATIC map, which a trackpad gesture
    /// never writes — and so decided "no overshoot" from the stale pre-tick
    /// offset. The rubber band was never armed and the view stayed parked
    /// outside its own bounds.
    #[test]
    fn an_overscrolled_gesture_springs_back_to_the_boundary() {
        let mut layout_window =
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
        register_node(&mut layout_window, 1, (100.0, 100.0), (100.0, 200.0));
        let queue = layout_window.scroll_manager.get_input_queue();
        let data = RefAny::new(ScrollPhysicsState::new(
            queue.clone(),
            ScrollPhysics::macos(),
        ));
        queue.push(input_dev(
            1,
            (0.0, 150.0),
            ScrollInputSource::TrackpadContinuous,
            ScrollInputDevice::Touchpad,
        ));
        queue.push(input_dev(
            1,
            (0.0, 0.0),
            ScrollInputSource::TrackpadEnd,
            ScrollInputDevice::Touchpad,
        ));
        let after_gesture = {
            let _ = closed_loop_tick(&mut layout_window, &data);
            offset_of(&layout_window, 1).y
        };
        assert!(
            after_gesture > 100.5,
            "the flick must overshoot past max_scroll_y=100 first, got {after_gesture}"
        );
        for _ in 0..240 {
            let _ = closed_loop_tick(&mut layout_window, &data);
        }
        let settled = offset_of(&layout_window, 1).y;
        assert!(
            (settled - 100.0).abs() < 1.0,
            "the rubber band must pull the view back to max_scroll_y=100, \
             it stayed at {settled}"
        );
    }
}