1
//! Pure scroll state management — the single source of truth for scroll offsets.
2
//!
3
//! # Architecture
4
//!
5
//! `ScrollManager` is the exclusive owner of all scroll state. Other modules
6
//! interact with scrolling only through its public API:
7
//!
8
//! - **Platform shell** (macos/events.rs, etc.): Calls `record_scroll_from_hit_test()`
9
//!   to queue trackpad/mouse wheel input for the physics timer.
10
//! - **Scroll physics timer** (`scroll_timer.rs)`: Consumes inputs via `ScrollInputQueue`,
11
//!   applies physics, and pushes `CallbackChange::ScrollTo` for each updated node.
12
//! - **Event processing** (`event_v2.rs)`: Processes `ScrollTo` changes, sets scroll
13
//!   positions, and checks `VirtualView` re-invocation transparently.
14
//! - **Drag autoscroll** (`shell2/common/event.rs`): while a text-selection
15
//!   drag is held past a container's edge, a 60Hz timer pushes
16
//!   `CallbackChange::ScrollTo`. It does NOT go through the gesture manager —
17
//!   an earlier `AutoScrollDirection` design did, and was documented here
18
//!   long after it stopped being constructed anywhere.
19
//! - **Render loop**: Calls `tick()` every frame to advance easing animations.
20
//! - **`WebRender` sync** (`wr_translate2.rs)`: Reads offsets via
21
//!   `get_scroll_states_for_dom()` to synchronize scroll frames.
22
//! - **Layout** (cache.rs): Registers scroll nodes via
23
//!   `register_or_update_scroll_node()` after layout completes.
24
//!
25
//! # Scroll Flow
26
//!
27
//! ```text
28
//! Platform Event Handler
29
//!   → record_scroll_from_hit_test() → ScrollInputQueue
30
//!   → starts SCROLL_MOMENTUM_TIMER_ID if not running
31
//!
32
//! Timer fires (every ~16ms):
33
//!   → queue.take_all() → physics integration
34
//!   → push_change(CallbackChange::ScrollTo)
35
//!
36
//! ScrollTo processing (event_v2.rs):
37
//!   → scroll_manager.set_scroll_position()
38
//!   → virtual_view_manager.check_reinvoke() (transparent VirtualView support)
39
//!   → repaint
40
//! ```
41
//!
42
//! This module provides:
43
//! - Smooth scroll animations with easing
44
//! - Event source classification for scroll events
45
//! - Scrollbar geometry and hit-testing
46
//! - Virtual scroll bounds for `VirtualView` nodes
47

            
48
use alloc::collections::BTreeMap;
49
#[cfg(feature = "std")]
50
use alloc::vec::Vec;
51

            
52
use azul_core::{
53
    dom::{DomId, NodeId, ScrollbarOrientation},
54
    events::EasingFunction,
55
    geom::{LogicalPosition, LogicalRect, LogicalSize},
56
    hit_test::ScrollPosition,
57
    styled_dom::NodeHierarchyItemId,
58
    task::{Duration, Instant},
59
};
60

            
61
#[cfg(feature = "std")]
62
use std::sync::{Arc, Mutex};
63

            
64
use crate::managers::hover::InputPointId;
65
use crate::solver3::layout_tree::LayoutNodeId;
66
use crate::solver3::scrollbar::compute_scrollbar_geometry_with_button_size;
67

            
68
/// Minimum change in scroll offset (in logical pixels) to consider the position
69
/// "actually moved" and mark the scroll state dirty.
70
const SCROLL_CHANGE_EPSILON: f32 = 0.01;
71

            
72
// ============================================================================
73
// Scroll Input Types (for timer-based physics architecture)
74
// ============================================================================
75

            
76
/// Classifies the source of a scroll input event.
77
///
78
/// This determines how the scroll physics timer processes the input:
79
/// - `TrackpadContinuous`: The OS already applies momentum — set position directly
80
/// - `WheelDiscrete`: Mouse wheel clicks — apply as impulse with momentum decay
81
/// - `Programmatic`: API-driven scroll — apply with optional easing animation
82
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83
pub enum ScrollInputSource {
84
    /// Continuous trackpad gesture (macOS precise scrolling).
85
    /// Position is set directly — the OS handles momentum/physics.
86
    TrackpadContinuous,
87
    /// Trackpad gesture ended (fingers lifted off trackpad).
88
    /// Triggers spring-back if the scroll position is past the bounds
89
    /// (rubber-banding overshoot). The OS sends this when
90
    /// `NSEventPhaseEnded` or momentumPhaseEnded is detected.
91
    TrackpadEnd,
92
    /// Discrete mouse wheel steps (Windows/Linux mouse wheel).
93
    /// Applied as velocity impulse with momentum decay.
94
    WheelDiscrete,
95
    /// Programmatic scroll (scrollTo API, keyboard Page Up/Down).
96
    /// Applied with optional easing animation.
97
    Programmatic,
98
    /// Animated scroll toward an ABSOLUTE target offset (`delta` carries
99
    /// the target, not a delta): the physics timer seeks it with a
100
    /// critically-damped spring, so scroll-to-caret / scroll-to-page /
101
    /// find-result navigation glide instead of jumping, and a retarget
102
    /// mid-flight keeps the current velocity (no restart). Produced by
103
    /// `LayoutWindow::scroll_to_animated`.
104
    AnimateTo,
105
}
106

            
107
/// WHERE a scroll input physically came from - distinct from
108
/// [`ScrollInputSource`], which is the PROCESSING model. Different devices
109
/// deserve different curves (a wheel step animated with the trackpad's
110
/// long spring feels jarring), and accessibility drivers / test harnesses
111
/// need to identify themselves for correct treatment and diagnostics.
112
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
113
pub enum ScrollInputDevice {
114
    /// Discrete physical mouse wheel clicks.
115
    MouseWheel,
116
    /// Trackpad / precision touchpad gestures.
117
    Touchpad,
118
    /// Direct touchscreen panning.
119
    Touchscreen,
120
    /// Keyboard navigation (PageUp/PageDown/arrows/Home/End).
121
    Keyboard,
122
    /// Assistive technology (screen readers, switch access).
123
    Accessibility,
124
    /// An automated test driver (e2e harness, CI scenarios).
125
    TestDriver,
126
    /// API-driven (`scroll_to` / `scroll_to_animated` from app code).
127
    Programmatic,
128
    /// Origin unknown (legacy producers). Treated like `MouseWheel` for
129
    /// curve selection on discrete inputs.
130
    #[default]
131
    Unknown,
132
}
133

            
134
/// A single scroll input event to be processed by the physics timer.
135
///
136
/// Scroll inputs are recorded by the platform event handler and consumed
137
/// by the scroll physics timer callback. This decouples input recording
138
/// from physics simulation.
139
#[derive(Debug, Clone)]
140
pub struct ScrollInput {
141
    /// DOM containing the scrollable node
142
    pub dom_id: DomId,
143
    /// Target scroll node
144
    pub node_id: NodeId,
145
    /// Scroll delta (positive = scroll down/right)
146
    pub delta: LogicalPosition,
147
    /// When this input was recorded
148
    pub timestamp: Instant,
149
    /// How this input should be processed
150
    pub source: ScrollInputSource,
151
    /// Where this input physically came from (curve selection,
152
    /// accessibility, diagnostics).
153
    pub device: ScrollInputDevice,
154
}
155

            
156
/// Thread-safe queue for scroll inputs, shared between event handlers and timer callbacks.
157
///
158
/// Event handlers push inputs, the physics timer pops them. Protected by a Mutex
159
/// so that the timer callback (which only has `&CallbackInfo` / `*const LayoutWindow`)
160
/// can still consume pending inputs without needing `&mut`.
161
#[cfg(feature = "std")]
162
#[derive(Debug, Clone, Default)]
163
pub struct ScrollInputQueue {
164
    inner: Arc<Mutex<Vec<ScrollInput>>>,
165
}
166

            
167
#[cfg(feature = "std")]
168
impl ScrollInputQueue {
169
47
    #[must_use] pub fn new() -> Self {
170
47
        Self {
171
47
            inner: Arc::new(Mutex::new(Vec::new())),
172
47
        }
173
47
    }
174

            
175
    /// Push a new scroll input (called from platform event handler)
176
1603
    pub fn push(&self, input: ScrollInput) {
177
1603
        if let Ok(mut queue) = self.inner.lock() {
178
1603
            queue.push(input);
179
1603
        }
180
1603
    }
181

            
182
    /// Take all pending inputs (called from timer callback)
183
24
    #[must_use] pub fn take_all(&self) -> Vec<ScrollInput> {
184
24
        self.inner.lock().map_or_else(
185
            |_| Vec::new(),
186
24
            |mut queue| core::mem::take(&mut *queue),
187
        )
188
24
    }
189

            
190
    /// Take at most `max_events` recent inputs, sorted by timestamp (newest last).
191
    /// Any older events beyond `max_events` are discarded.
192
    /// This prevents the physics timer from processing an unbounded backlog.
193
558
    #[must_use] pub fn take_recent(&self, max_events: usize) -> Vec<ScrollInput> {
194
558
        self.inner.lock().map_or_else(
195
            |_| Vec::new(),
196
558
            |mut queue| {
197
558
                let mut events = core::mem::take(&mut *queue);
198
558
                if events.len() > max_events {
199
                    // Sort by timestamp ascending (oldest first), keep last N
200
1503
                    events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
201
4
                    events.drain(..events.len() - max_events);
202
554
                }
203
558
                events
204
558
            },
205
        )
206
558
    }
207

            
208
    /// Check if there are pending inputs without consuming them
209
682
    #[must_use] pub fn has_pending(&self) -> bool {
210
682
        self.inner
211
682
            .lock()
212
682
            .map(|q| !q.is_empty())
213
682
            .unwrap_or(false)
214
682
    }
215
}
216

            
217
// Scrollbar Component Types
218

            
219
/// Which component of a scrollbar was hit during hit-testing
220
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
221
pub enum ScrollbarComponent {
222
    /// The track (background) of the scrollbar
223
    Track,
224
    /// The draggable thumb (indicator of current scroll position)
225
    Thumb,
226
    /// Top/left button (scrolls by one page up/left)
227
    TopButton,
228
    /// Bottom/right button (scrolls by one page down/right)
229
    BottomButton,
230
}
231

            
232
/// Scrollbar geometry state (calculated per frame, used for hit-testing and rendering)
233
#[derive(Copy, Debug, Clone)]
234
pub struct ScrollbarState {
235
    /// Is this scrollbar visible? (content larger than container)
236
    pub visible: bool,
237
    /// Orientation
238
    pub orientation: ScrollbarOrientation,
239
    /// Base size (1:1 square, width = height). This is the unscaled size.
240
    pub base_size: f32,
241
    /// Scale transform to apply (calculated from container size)
242
    pub scale: LogicalPosition, // x = width scale, y = height scale
243
    /// Thumb position ratio (0.0 = top/left, 1.0 = bottom/right)
244
    pub thumb_position_ratio: f32,
245
    /// Thumb size ratio (0.0 = invisible, 1.0 = entire track)
246
    pub thumb_size_ratio: f32,
247
    /// Position of the scrollbar in the container (for hit-testing)
248
    pub track_rect: LogicalRect,
249
    /// Button size (square: `button_size` × `button_size`)
250
    pub button_size: f32,
251
    /// Usable track length after subtracting buttons
252
    pub usable_track_length: f32,
253
    /// Thumb length in pixels
254
    pub thumb_length: f32,
255
    /// Thumb offset from start of usable track region
256
    pub thumb_offset: f32,
257
}
258

            
259
impl ScrollbarState {
260
    /// Determine which component was hit at the given local position (relative to `track_rect`
261
    /// origin). Uses the shared geometry values (`button_size`, `usable_track_length`, `thumb_length`,
262
    /// `thumb_offset`) for consistent hit-testing.
263
37
    #[must_use] pub fn hit_test_component(&self, local_pos: LogicalPosition) -> ScrollbarComponent {
264
37
        match self.orientation {
265
            ScrollbarOrientation::Vertical => {
266
                // Top button
267
30
                if local_pos.y < self.button_size {
268
7
                    return ScrollbarComponent::TopButton;
269
23
                }
270

            
271
                // Bottom button
272
23
                let track_height = self.track_rect.size.height;
273
23
                if local_pos.y > track_height - self.button_size {
274
4
                    return ScrollbarComponent::BottomButton;
275
19
                }
276

            
277
                // Thumb region starts after top button
278
19
                let thumb_y_start = self.button_size + self.thumb_offset;
279
19
                let thumb_y_end = thumb_y_start + self.thumb_length;
280

            
281
19
                if local_pos.y >= thumb_y_start && local_pos.y <= thumb_y_end {
282
11
                    ScrollbarComponent::Thumb
283
                } else {
284
8
                    ScrollbarComponent::Track
285
                }
286
            }
287
            ScrollbarOrientation::Horizontal => {
288
                // Left button
289
7
                if local_pos.x < self.button_size {
290
                    return ScrollbarComponent::TopButton;
291
7
                }
292

            
293
                // Right button
294
7
                let track_width = self.track_rect.size.width;
295
7
                if local_pos.x > track_width - self.button_size {
296
                    return ScrollbarComponent::BottomButton;
297
7
                }
298

            
299
                // Thumb region starts after left button
300
7
                let thumb_x_start = self.button_size + self.thumb_offset;
301
7
                let thumb_x_end = thumb_x_start + self.thumb_length;
302

            
303
7
                if local_pos.x >= thumb_x_start && local_pos.x <= thumb_x_end {
304
6
                    ScrollbarComponent::Thumb
305
                } else {
306
1
                    ScrollbarComponent::Track
307
                }
308
            }
309
        }
310
37
    }
311
}
312

            
313
/// Result of a scrollbar hit-test
314
///
315
/// Contains information about which scrollbar component was hit
316
/// and the position relative to both the track and the window.
317
#[derive(Debug, Clone, Copy)]
318
pub struct ScrollbarHit {
319
    /// DOM containing the scrollable node
320
    pub dom_id: DomId,
321
    /// Node with the scrollbar
322
    pub node_id: NodeId,
323
    /// Whether this is a vertical or horizontal scrollbar
324
    pub orientation: ScrollbarOrientation,
325
    /// Which component was hit (track, thumb, buttons)
326
    pub component: ScrollbarComponent,
327
    /// Position relative to `track_rect` origin
328
    pub local_position: LogicalPosition,
329
    /// Original global window position
330
    pub global_position: LogicalPosition,
331
}
332

            
333
// Core Scroll Manager
334

            
335
/// Manages all scroll state and animations for a window
336
#[derive(Debug, Clone, Default)]
337
pub struct ScrollManager {
338
    /// Maps (`DomId`, `NodeId`) to their scroll state
339
    states: BTreeMap<(DomId, NodeId), AnimatedScrollState>,
340
    /// Scrollbar geometry states (calculated per frame)
341
    scrollbar_states: BTreeMap<(DomId, NodeId, ScrollbarOrientation), ScrollbarState>,
342
    /// Thread-safe queue for scroll inputs (shared with timer callbacks)
343
    #[cfg(feature = "std")]
344
    pub scroll_input_queue: ScrollInputQueue,
345
    /// Raw wheel/trackpad delta recorded *this input pass*, regardless of whether
346
    /// a scrollable node was under the cursor. The scroll input queue only carries
347
    /// deltas destined for scrollable containers (consumed by the physics timer);
348
    /// this field additionally lets `determine_all_events` synthesize a `Scroll`
349
    /// event aimed at the hovered node so non-scroll-container widgets (e.g. the
350
    /// map, which treats wheel = zoom) can react via a `HoverEventFilter::Scroll`
351
    /// callback + `CallbackInfo::get_scroll_delta`. Set in
352
    /// [`Self::record_scroll_from_hit_test`]; read during event determination and
353
    /// callback dispatch, then cleared at the end of the pass.
354
    pub pending_wheel_event: Option<LogicalPosition>,
355
    /// Set when a scroll position changes; cleared after the display list
356
    /// is regenerated.  Used by the CPU renderer path to detect when the
357
    /// display list must be rebuilt even though the DOM hasn't changed.
358
    scroll_dirty: bool,
359
    /// Scroll-direction preference, applied ONCE in [`Self::record_scroll_input`]
360
    /// (the single chokepoint every platform's wheel/axis event flows through).
361
    ///
362
    /// `false` (default) = traditional desktop wheel: a raw "scroll down" event
363
    /// increases the offset (content moves up). `true` = natural: inverted.
364
    /// Replaces the per-platform hardcoded `-delta` negations so the sign lives
365
    /// in one configurable place ([`Self::set_natural_scroll`]).
366
    ///
367
    /// CAVEAT: on macOS and on Linux touchpads via libinput the OS/driver ALREADY
368
    /// applies the user's natural-scroll preference before azul sees the delta, so
369
    /// this flag must stay at its default there (we preserve current behavior) and
370
    /// primarily controls mouse-wheel direction on platforms that don't pre-apply.
371
    natural_scroll: bool,
372
}
373

            
374
/// The complete scroll state for a single node (with animation support)
375
#[derive(Debug, Clone)]
376
pub struct AnimatedScrollState {
377
    /// Current scroll offset (live, may be animating)
378
    pub current_offset: LogicalPosition,
379
    /// Ongoing smooth scroll animation, if any
380
    pub animation: Option<ScrollAnimation>,
381
    /// Last time scroll activity occurred (for fading scrollbars)
382
    pub last_activity: Instant,
383
    /// Bounds of the scrollable container
384
    pub container_rect: LogicalRect,
385
    /// Bounds of the total content (for calculating scroll limits)
386
    pub content_rect: LogicalRect,
387
    /// Virtual scroll size from `VirtualView` callback (if this node hosts a `VirtualView`).
388
    /// When set, clamp logic uses this instead of `content_rect` for max scroll bounds.
389
    pub virtual_scroll_size: Option<LogicalSize>,
390
    /// Virtual scroll offset from `VirtualView` callback
391
    pub virtual_scroll_offset: Option<LogicalPosition>,
392
    /// Per-node overscroll behavior for X axis (from CSS `overscroll-behavior-x`)
393
    pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
394
    /// Per-node overscroll behavior for Y axis (from CSS `overscroll-behavior-y`)
395
    pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
396
    /// Per-node overflow scrolling mode (from CSS `-azul-overflow-scrolling`)
397
    pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
398
    /// CSS-resolved scrollbar thickness (from `scrollbar-width` property).
399
    /// Used for rendering and hit-testing. Defaults to 16.0 if not set.
400
    pub scrollbar_thickness: f32,
401
    /// Visual rendering width in CSS pixels (e.g. 8.0 for thin overlay).
402
    /// Non-zero even for overlay scrollbars. Falls back to `scrollbar_thickness` if 0.
403
    pub visual_width_px: f32,
404
    /// Whether this node also needs a horizontal scrollbar (affects vertical geometry)
405
    pub has_horizontal_scrollbar: bool,
406
    /// Whether this node also needs a vertical scrollbar (affects horizontal geometry)
407
    pub has_vertical_scrollbar: bool,
408
}
409

            
410
/// Details of an in-progress smooth scroll animation.
411
///
412
/// `pub` because `ScrollState::animation` is already a `pub` field holding it —
413
/// a private type behind a public field is only an error at the point somebody
414
/// outside this module reads it, which is why this compiled until the E2E
415
/// manager fingerprints needed to ask "is a scroll animation in flight?".
416
#[derive(Debug, Clone)]
417
pub struct ScrollAnimation {
418
    /// When the animation started
419
    start_time: Instant,
420
    /// Total duration of the animation
421
    duration: Duration,
422
    /// Scroll offset at animation start
423
    start_offset: LogicalPosition,
424
    /// Target scroll offset at animation end
425
    target_offset: LogicalPosition,
426
    /// Easing function for interpolation
427
    easing: EasingFunction,
428
}
429

            
430
/// Read-only snapshot of a scroll node's state, returned by `CallbackInfo` queries.
431
///
432
/// Provides all the information a timer callback needs to compute scroll physics
433
/// without requiring mutable access to the `ScrollManager`.
434
#[derive(Copy, Debug, Clone)]
435
pub struct ScrollNodeInfo {
436
    /// Current scroll offset
437
    pub current_offset: LogicalPosition,
438
    /// Container (viewport) bounds
439
    pub container_rect: LogicalRect,
440
    /// Content bounds (total scrollable area)
441
    pub content_rect: LogicalRect,
442
    /// Maximum scroll in X direction
443
    pub max_scroll_x: f32,
444
    /// Maximum scroll in Y direction
445
    pub max_scroll_y: f32,
446
    /// Per-node overscroll behavior for X axis
447
    pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
448
    /// Per-node overscroll behavior for Y axis
449
    pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
450
    /// Per-node overflow scrolling mode (auto vs touch)
451
    pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
452
}
453

            
454
/// Result of a scroll tick, indicating what actions are needed
455
#[derive(Debug, Default)]
456
pub struct ScrollTickResult {
457
    /// If true, a repaint is needed (scroll offset changed)
458
    pub needs_repaint: bool,
459
    /// Nodes whose scroll position was updated this tick
460
    pub updated_nodes: Vec<(DomId, NodeId)>,
461
}
462

            
463
// ScrollManager Implementation
464

            
465
impl ScrollManager {
466
    /// Creates a new empty `ScrollManager`
467
5850
    #[must_use] pub fn new() -> Self {
468
5850
        let mut m = Self::default();
469
        // Power-user / test override. Platform shells should call
470
        // `set_natural_scroll` from the OS preference; this env var wins so the
471
        // direction can be flipped without a rebuild and so tests are hermetic.
472
        #[cfg(feature = "std")]
473
5850
        if let Some(v) = std::env::var_os("AZ_NATURAL_SCROLL") {
474
            m.natural_scroll = matches!(v.to_str(), Some("1" | "true" | "TRUE"));
475
5850
        }
476
5850
        m
477
5850
    }
478

            
479
    /// Set the scroll-direction preference. `true` = natural (content follows the
480
    /// gesture / inverted from the traditional wheel). Platform shells call this
481
    /// from the detected OS preference. See the `natural_scroll` field docs for the
482
    /// macOS/libinput pre-application caveat.
483
5
    pub const fn set_natural_scroll(&mut self, natural: bool) {
484
5
        self.natural_scroll = natural;
485
5
    }
486

            
487
    /// Current scroll-direction preference (`true` = natural/inverted).
488
4
    #[must_use] pub const fn is_natural_scroll(&self) -> bool {
489
4
        self.natural_scroll
490
4
    }
491

            
492
    /// The sign applied to a raw input delta to get the offset delta:
493
    /// `-1.0` traditional (default), `+1.0` natural. Centralises what used to be a
494
    /// hardcoded `-delta` at every platform call site.
495
    #[inline]
496
27
    const fn scroll_sign(&self) -> f32 {
497
27
        if self.natural_scroll {
498
3
            1.0
499
        } else {
500
24
            -1.0
501
        }
502
27
    }
503

            
504
    /// Sizes of the internal maps — used by `AZ_E2E_TEST` to watch for
505
    /// unbounded growth across resize/tick iterations.
506
12
    #[must_use] pub fn debug_counts(&self) -> (usize, usize) {
507
12
        (self.states.len(), self.scrollbar_states.len())
508
12
    }
509

            
510
    /// Returns `true` if any scroll position changed since the last
511
    /// `clear_scroll_dirty()` call.
512
36
    pub(crate) const fn has_pending_scroll_changes(&self) -> bool {
513
36
        self.scroll_dirty
514
36
    }
515

            
516
    /// Every `(DomId, NodeId)` this manager currently keys scroll state on.
517
    ///
518
    /// The E2E `assert_manager_invariants` op needs the KEY SET, not just the
519
    /// count `debug_counts()` reports: a key naming a node that no longer exists
520
    /// in `layout_results` is a dangling index (invariant X10), and a key set is
521
    /// the only way to see it from outside.
522
    #[must_use]
523
18901
    pub fn state_keys(&self) -> Vec<(DomId, NodeId)> {
524
18901
        self.states.keys().copied().collect()
525
18901
    }
526

            
527
    /// Every `(DomId, NodeId)` whose `AnimatedScrollState` currently carries an
528
    /// easing animation. `has_active_animations()` is exactly
529
    /// `!animating_keys().is_empty()`; E2E invariant X2 asserts that identity.
530
    #[must_use]
531
19
    pub fn animating_keys(&self) -> Vec<(DomId, NodeId)> {
532
19
        self.states
533
19
            .iter()
534
19
            .filter(|(_, s)| s.animation.is_some())
535
19
            .map(|(k, _)| *k)
536
19
            .collect()
537
19
    }
538

            
539
    /// Clear the dirty flag after the display list has been regenerated.
540
4569
    pub const fn clear_scroll_dirty(&mut self) {
541
4569
        self.scroll_dirty = false;
542
4569
    }
543

            
544
    /// Build a map from `scroll_id` (`LocalScrollId`) to current scroll offset.
545
    ///
546
    /// Used by the CPU renderer to look up scroll positions at render time
547
    /// without embedding them in the display list.
548
    ///
549
    /// Takes `DomLayoutResult::scroll_id_to_node_id` — the SAME table the
550
    /// `WebRender` path resolves through (`wr_translate2::scroll_all_nodes`) —
551
    /// because it is keyed the way this manager is keyed: by DOM `NodeId`.
552
    ///
553
    /// It used to take the sibling table `scroll_ids` (layout-tree index →
554
    /// `scroll_id`) and index it with `node_id.index()`, i.e. it assumed the
555
    /// layout tree and the DOM share an index space. They do not: anonymous
556
    /// boxes and box splits are inserted during layout-tree construction, so
557
    /// any scroll container with a text sibling ahead of it sits at a
558
    /// different index. The lookup then missed, the container never appeared
559
    /// in this map, the compositor resolved its layer offset as `(0.0, 0.0)`
560
    /// and the content stayed frozen — while the scrollbar thumb, driven by
561
    /// the `(DomId, NodeId)`-keyed GPU value cache, kept tracking the wheel.
562
16771
    #[must_use] pub fn build_scroll_offset_map(
563
16771
        &self,
564
16771
        dom_id: DomId,
565
16771
        scroll_id_to_node_id: &std::collections::HashMap<u64, NodeId>,
566
16771
    ) -> std::collections::HashMap<u64, (f32, f32)> {
567
16771
        let mut map = std::collections::HashMap::new();
568
33227
        for (&scroll_id, &node_id) in scroll_id_to_node_id {
569
16456
            if let Some(state) = self.states.get(&(dom_id, node_id)) {
570
16455
                map.insert(scroll_id, (state.current_offset.x, state.current_offset.y));
571
16455
            }
572
        }
573
16771
        map
574
16771
    }
575

            
576
    // ========================================================================
577
    // Input Recording API (timer-based architecture)
578
    // ========================================================================
579

            
580
    /// Records a scroll input event into the shared queue.
581
    ///
582
    /// This is the primary entry point for platform event handlers. Instead of
583
    /// directly modifying scroll positions, the input is queued for the scroll
584
    /// physics timer to process. This decouples input from physics simulation.
585
    ///
586
    /// The scroll-direction sign ([`Self::scroll_sign`]) is applied HERE — the
587
    /// single chokepoint every wheel/axis event flows through — so platform shells
588
    /// pass the RAW delta and no longer hardcode `-delta` at each call site.
589
    ///
590
    /// Returns `true` if the physics timer should be started (i.e., there are
591
    /// now pending inputs and no timer is running yet).
592
    #[cfg(feature = "std")]
593
16
    pub fn record_scroll_input(&mut self, mut input: ScrollInput) -> bool {
594
16
        let sign = self.scroll_sign();
595
16
        input.delta.x *= sign;
596
16
        input.delta.y *= sign;
597
16
        let was_empty = !self.scroll_input_queue.has_pending();
598
16
        self.scroll_input_queue.push(input);
599
16
        was_empty // caller should start timer if this returns true
600
16
    }
601

            
602
    /// High-level entry point for platform event handlers: performs hit-test lookup
603
    /// and queues the input for the physics timer, instead of directly modifying offsets.
604
    ///
605
    /// Returns `Some((dom_id, node_id, should_start_timer))` if a scrollable node was found.
606
    /// The caller should start `SCROLL_MOMENTUM_TIMER_ID` when `should_start_timer` is true.
607
    #[cfg(feature = "std")]
608
7
    pub fn record_scroll_from_hit_test(
609
7
        &mut self,
610
7
        delta_x: f32,
611
7
        delta_y: f32,
612
7
        source: ScrollInputSource,
613
7
        device: ScrollInputDevice,
614
7
        hover_manager: &crate::managers::hover::HoverManager,
615
7
        input_point_id: &InputPointId,
616
7
        now: Instant,
617
7
    ) -> Option<(DomId, NodeId, bool)> {
618
        // Record the raw wheel delta for this pass unconditionally — even when the
619
        // cursor isn't over a scroll container — so a `Scroll` event can be aimed
620
        // at the hovered node (wheel-as-zoom widgets like the map rely on this).
621
7
        self.pending_wheel_event = Some(LogicalPosition { x: delta_x, y: delta_y });
622

            
623
7
        let hit_test = hover_manager.get_current(input_point_id)?;
624

            
625
        // MWA-B2: nested scroll containers — innermost-first with boundary
626
        // handoff. The previous ascending iteration always picked the
627
        // OUTERMOST scrollable ancestor (BTreeMap keys ascend; ancestors
628
        // have lower arena NodeIds), so wheeling over a list inside a
629
        // scrollable page scrolled the page instead of the list. We now
630
        // walk innermost-first and give the event to the first candidate
631
        // that can still move in the delta's direction (the web's default
632
        // overscroll handoff); when every candidate is pinned, the
633
        // innermost scrollable wins so the gesture still targets the node
634
        // under the pointer.
635
6
        let sign = self.scroll_sign();
636
6
        let (eff_x, eff_y) = (delta_x * sign, delta_y * sign);
637
6
        let target = self.select_scroll_target(
638
6
            hit_test.hovered_nodes.iter().flat_map(|(dom_id, hit_node)| {
639
6
                hit_node
640
6
                    .scroll_hit_test_nodes
641
6
                    .keys()
642
6
                    .rev()
643
6
                    .map(move |node_id| (*dom_id, *node_id))
644
6
            }),
645
6
            eff_x,
646
6
            eff_y,
647
        );
648
6
        let (dom_id, node_id) = target?;
649
5
        let input = ScrollInput {
650
5
            dom_id,
651
5
            node_id,
652
5
            // Raw delta — record_scroll_input applies scroll_sign() itself.
653
5
            delta: LogicalPosition { x: delta_x, y: delta_y },
654
5
            timestamp: now,
655
5
            source,
656
5
            device,
657
5
        };
658
5
        let should_start_timer = self.record_scroll_input(input);
659
5
        Some((dom_id, node_id, should_start_timer))
660
7
    }
661

            
662
    /// MWA-B2: choose the scroll node a wheel/trackpad event should drive.
663
    ///
664
    /// `candidates` must be ordered innermost-first; `eff_x`/`eff_y` are the
665
    /// direction-normalized deltas (post `scroll_sign()`: positive = offset
666
    /// grows = view moves toward content's down/right). The first candidate
667
    /// with remaining travel in a moved direction wins; if every candidate
668
    /// is pinned, the innermost scrollable is returned so the gesture still
669
    /// anchors under the pointer (matches CSS default overscroll behavior).
670
15
    fn select_scroll_target<I>(
671
15
        &self,
672
15
        candidates: I,
673
15
        eff_x: f32,
674
15
        eff_y: f32,
675
15
    ) -> Option<(DomId, NodeId)>
676
15
    where
677
15
        I: Iterator<Item = (DomId, NodeId)>,
678
    {
679
15
        let mut fallback = None;
680
26
        for (dom_id, node_id) in candidates {
681
19
            if !self.is_node_scrollable(dom_id, node_id) {
682
3
                continue;
683
16
            }
684
16
            if fallback.is_none() {
685
12
                fallback = Some((dom_id, node_id));
686
12
            }
687
16
            if self.can_consume_delta(dom_id, node_id, eff_x, eff_y) {
688
8
                return Some((dom_id, node_id));
689
8
            }
690
        }
691
7
        fallback
692
15
    }
693

            
694
    /// MWA-B10: the a11y tree's scroll surface for a node — current offset
695
    /// plus max travel per axis, or `None` when the node isn't scrollable.
696
    /// Screen readers use this (with the ScrollUp/Down/... actions) to
697
    /// drive the same inbound handler mouse users exercise.
698
232848
    #[must_use] pub fn a11y_scroll_info(
699
232848
        &self,
700
232848
        dom_id: DomId,
701
232848
        node_id: NodeId,
702
232848
    ) -> Option<(LogicalPosition, f32, f32)> {
703
232848
        let state = self.states.get(&(dom_id, node_id))?;
704
20
        let effective_width = state
705
20
            .virtual_scroll_size
706
20
            .map_or(state.content_rect.size.width, |s| s.width);
707
20
        let effective_height = state
708
20
            .virtual_scroll_size
709
20
            .map_or(state.content_rect.size.height, |s| s.height);
710
20
        let max_x = (effective_width - state.container_rect.size.width).max(0.0);
711
20
        let max_y = (effective_height - state.container_rect.size.height).max(0.0);
712
20
        if max_x <= 0.0 && max_y <= 0.0 {
713
3
            return None;
714
17
        }
715
17
        Some((state.current_offset, max_x, max_y))
716
232848
    }
717

            
718
    /// `true` when the node still has travel in the direction of the
719
    /// normalized delta on at least one moved axis — the boundary-handoff
720
    /// test for [`select_scroll_target`](Self::select_scroll_target).
721
32
    fn can_consume_delta(
722
32
        &self,
723
32
        dom_id: DomId,
724
32
        node_id: NodeId,
725
32
        eff_x: f32,
726
32
        eff_y: f32,
727
32
    ) -> bool {
728
        const EPS: f32 = 0.5;
729
32
        let Some(state) = self.states.get(&(dom_id, node_id)) else {
730
2
            return false;
731
        };
732
30
        let effective_width = state
733
30
            .virtual_scroll_size
734
30
            .map_or(state.content_rect.size.width, |s| s.width);
735
30
        let effective_height = state
736
30
            .virtual_scroll_size
737
30
            .map_or(state.content_rect.size.height, |s| s.height);
738
30
        let max_x = (effective_width - state.container_rect.size.width).max(0.0);
739
30
        let max_y = (effective_height - state.container_rect.size.height).max(0.0);
740
30
        let off = state.current_offset;
741

            
742
30
        let x_ok = if eff_x > EPS {
743
1
            off.x < max_x - EPS
744
29
        } else if eff_x < -EPS {
745
            off.x > EPS
746
        } else {
747
29
            false
748
        };
749
30
        let y_ok = if eff_y > EPS {
750
15
            off.y < max_y - EPS
751
15
        } else if eff_y < -EPS {
752
5
            off.y > EPS
753
        } else {
754
10
            false
755
        };
756
30
        x_ok || y_ok
757
32
    }
758

            
759
    /// Get a clone of the scroll input queue (for sharing with timer callbacks).
760
    ///
761
    /// The timer callback stores this in its `RefAny` data and calls `take_all()`
762
    /// each tick to consume pending inputs.
763
    #[cfg(feature = "std")]
764
18
    #[must_use] pub fn get_input_queue(&self) -> ScrollInputQueue {
765
18
        self.scroll_input_queue.clone()
766
18
    }
767

            
768
    /// Advances scroll animations by one tick, returns repaint info
769
    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
770
    // Instant is a ref-counted FFI clock handle; called by every dll backend's event loop by value.
771
    #[allow(clippy::needless_pass_by_value)]
772
38
    pub fn tick(&mut self, now: Instant) -> ScrollTickResult {
773
38
        let mut result = ScrollTickResult::default();
774
76
        for ((dom_id, node_id), state) in &mut self.states {
775
38
            if let Some(anim) = &state.animation {
776
10
                let elapsed = now.duration_since(&anim.start_time);
777
10
                let t = elapsed.div(&anim.duration).min(1.0);
778
10
                let eased_t = apply_easing(t, anim.easing);
779

            
780
10
                state.current_offset = LogicalPosition {
781
10
                    x: anim.start_offset.x + (anim.target_offset.x - anim.start_offset.x) * eased_t,
782
10
                    y: anim.start_offset.y + (anim.target_offset.y - anim.start_offset.y) * eased_t,
783
10
                };
784
10
                result.needs_repaint = true;
785
10
                result.updated_nodes.push((*dom_id, *node_id));
786

            
787
10
                if t >= 1.0 {
788
6
                    state.animation = None;
789
6
                }
790
28
            }
791
        }
792
38
        result
793
38
    }
794

            
795
    /// Returns `true` if any scroll node has an active easing animation.
796
    ///
797
    /// Used by GPU render paths to skip rendering when the UI is completely
798
    /// static (no scroll animations, no layout changes).
799
1153
    #[must_use] pub fn has_active_animations(&self) -> bool {
800
1153
        self.states.values().any(|s| s.animation.is_some())
801
1153
    }
802

            
803
    /// Finds the closest scroll-container ancestor for a given node.
804
    ///
805
    /// Walks up the node hierarchy to find a node that is registered as a
806
    /// scrollable node in this `ScrollManager`. Returns `None` if no scrollable
807
    /// ancestor is found.
808
6
    #[must_use] pub fn find_scroll_parent(
809
6
        &self,
810
6
        dom_id: DomId,
811
6
        node_id: NodeId,
812
6
        node_hierarchy: &[azul_core::styled_dom::NodeHierarchyItem],
813
6
    ) -> Option<NodeId> {
814
6
        let mut current = Some(node_id);
815
15
        while let Some(nid) = current {
816
10
            if self.states.contains_key(&(dom_id, nid)) && nid != node_id {
817
1
                return Some(nid);
818
9
            }
819
9
            current = node_hierarchy
820
9
                .get(nid.index())
821
9
                .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
822
        }
823
5
        None
824
6
    }
825

            
826
    /// Check if a node is scrollable (has overflow:scroll/auto and overflowing content)
827
    ///
828
    /// Uses `virtual_scroll_size` (when set) instead of `content_rect` for the
829
    /// overflow check, so `VirtualView` nodes with large virtual content are correctly
830
    /// identified as scrollable even when only a small subset is rendered.
831
30
    fn is_node_scrollable(&self, dom_id: DomId, node_id: NodeId) -> bool {
832
30
        let result = self.states.get(&(dom_id, node_id)).is_some_and(|state| {
833
25
            let effective_width = state.virtual_scroll_size
834
25
                .map_or(state.content_rect.size.width, |s| s.width);
835
25
            let effective_height = state.virtual_scroll_size
836
25
                .map_or(state.content_rect.size.height, |s| s.height);
837
25
            let has_horizontal = effective_width > state.container_rect.size.width;
838
25
            let has_vertical = effective_height > state.container_rect.size.height;
839
25
            has_horizontal || has_vertical
840
25
        });
841
30
        result
842
30
    }
843

            
844
    // +spec:overflow:4000a6 - scroll position as offset from scroll origin within scrollport
845
    /// Sets scroll position immediately (no animation), clamped to valid bounds.
846
221
    pub fn set_scroll_position(
847
221
        &mut self,
848
221
        dom_id: DomId,
849
221
        node_id: NodeId,
850
221
        position: LogicalPosition,
851
221
        now: Instant,
852
221
    ) {
853
221
        let state = self
854
221
            .states
855
221
            .entry((dom_id, node_id))
856
221
            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
857
221
        let clamped = state.clamp(position);
858
221
        if (clamped.x - state.current_offset.x).abs() > SCROLL_CHANGE_EPSILON
859
197
            || (clamped.y - state.current_offset.y).abs() > SCROLL_CHANGE_EPSILON
860
169
        {
861
169
            self.scroll_dirty = true;
862
169
        }
863
221
        state.current_offset = clamped;
864
221
        state.animation = None;
865
221
        state.last_activity = now;
866
221
    }
867

            
868
    /// Sets scroll position immediately without clamping.
869
    ///
870
    /// Used by the scroll physics timer which does its own rubber-band clamping.
871
    /// Allows the offset to go outside [0, `max_scroll`] for overscroll/rubber-banding.
872
157
    pub fn set_scroll_position_unclamped(
873
157
        &mut self,
874
157
        dom_id: DomId,
875
157
        node_id: NodeId,
876
157
        position: LogicalPosition,
877
157
        now: Instant,
878
157
    ) {
879
157
        let state = self
880
157
            .states
881
157
            .entry((dom_id, node_id))
882
157
            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
883
157
        if (position.x - state.current_offset.x).abs() > SCROLL_CHANGE_EPSILON
884
153
            || (position.y - state.current_offset.y).abs() > SCROLL_CHANGE_EPSILON
885
92
        {
886
92
            self.scroll_dirty = true;
887
92
        }
888
157
        state.current_offset = position;
889
157
        state.animation = None;
890
157
        state.last_activity = now;
891
157
    }
892

            
893
    /// Scrolls by a delta amount with animation
894
6
    pub fn scroll_by(
895
6
        &mut self,
896
6
        dom_id: DomId,
897
6
        node_id: NodeId,
898
6
        delta: LogicalPosition,
899
6
        duration: Duration,
900
6
        easing: EasingFunction,
901
6
        now: Instant,
902
6
    ) {
903
6
        let current = self.get_current_offset(dom_id, node_id).unwrap_or_default();
904
6
        let target = LogicalPosition {
905
6
            x: current.x + delta.x,
906
6
            y: current.y + delta.y,
907
6
        };
908
6
        self.scroll_to(dom_id, node_id, target, duration, easing, now);
909
6
    }
910

            
911
    /// Scrolls to an absolute position with animation
912
    ///
913
    /// If duration is zero, the position is set immediately without animation.
914
85
    pub fn scroll_to(
915
85
        &mut self,
916
85
        dom_id: DomId,
917
85
        node_id: NodeId,
918
85
        target: LogicalPosition,
919
85
        duration: Duration,
920
85
        easing: EasingFunction,
921
85
        now: Instant,
922
85
    ) {
923
        // For zero duration, set position immediately
924
85
        let is_zero = match &duration {
925
70
            Duration::System(s) => s.secs == 0 && s.nanos == 0,
926
15
            Duration::Tick(t) => t.tick_diff == 0,
927
        };
928

            
929
85
        if is_zero {
930
74
            self.set_scroll_position(dom_id, node_id, target, now);
931
74
            return;
932
11
        }
933

            
934
11
        let state = self
935
11
            .states
936
11
            .entry((dom_id, node_id))
937
11
            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
938
11
        let clamped_target = state.clamp(target);
939
11
        state.animation = Some(ScrollAnimation {
940
11
            start_time: now.clone(),
941
11
            duration,
942
11
            start_offset: state.current_offset,
943
11
            target_offset: clamped_target,
944
11
            easing,
945
11
        });
946
11
        state.last_activity = now;
947
85
    }
948

            
949
    /// Updates the container and content bounds for a scrollable node
950
358
    pub fn update_node_bounds(
951
358
        &mut self,
952
358
        dom_id: DomId,
953
358
        node_id: NodeId,
954
358
        container_rect: LogicalRect,
955
358
        content_rect: LogicalRect,
956
358
        now: Instant,
957
358
    ) {
958
358
        let state = self
959
358
            .states
960
358
            .entry((dom_id, node_id))
961
358
            .or_insert_with(|| AnimatedScrollState::new(now));
962
358
        state.container_rect = container_rect;
963
358
        state.content_rect = content_rect;
964
358
        state.current_offset = state.clamp(state.current_offset);
965
358
    }
966

            
967
    /// Updates virtual scroll bounds for a `VirtualView` node.
968
    ///
969
    /// Called after `VirtualView` callback returns to propagate the virtual content size
970
    /// to the `ScrollManager`. Clamp logic then uses `virtual_scroll_size` (when set)
971
    /// instead of `content_rect` for max scroll bounds.
972
    ///
973
    /// If no scroll state exists yet for this node (because `register_or_update_scroll_node`
974
    /// hasn't been called yet), this creates a default state so the virtual size is preserved.
975
237
    pub fn update_virtual_scroll_bounds(
976
237
        &mut self,
977
237
        dom_id: DomId,
978
237
        node_id: NodeId,
979
237
        virtual_scroll_size: LogicalSize,
980
237
        virtual_scroll_offset: Option<LogicalPosition>,
981
237
    ) {
982
237
        let key = (dom_id, node_id);
983
237
        let state = self.states.entry(key).or_insert_with(|| {
984
            // AzInstant (System on std, safe Tick on no-clock targets) — not the
985
            // WASM-panicking std::time::Instant::now(). (A refinement would thread
986
            // the window's get_system_time_fn callback through for hookability.)
987
2
            AnimatedScrollState::new(Instant::now())
988
2
        });
989
237
        state.virtual_scroll_size = Some(virtual_scroll_size);
990
237
        state.virtual_scroll_offset = virtual_scroll_offset;
991
        // Re-clamp with new virtual bounds
992
237
        state.current_offset = state.clamp(state.current_offset);
993
237
    }
994

            
995
    /// Returns the current scroll offset for a node
996
352253
    #[must_use] pub fn get_current_offset(&self, dom_id: DomId, node_id: NodeId) -> Option<LogicalPosition> {
997
352253
        self.states
998
352253
            .get(&(dom_id, node_id))
999
352253
            .map(|s| s.current_offset)
352253
    }
    /// Returns the timestamp of last scroll activity for a node
16455
    #[must_use] pub fn get_last_activity_time(&self, dom_id: DomId, node_id: NodeId) -> Option<Instant> {
16455
        self.states
16455
            .get(&(dom_id, node_id))
16455
            .map(|s| s.last_activity.clone())
16455
    }
    /// Returns the internal scroll state for a node
329900
    #[must_use] pub fn get_scroll_state(&self, dom_id: DomId, node_id: NodeId) -> Option<&AnimatedScrollState> {
329900
        self.states.get(&(dom_id, node_id))
329900
    }
    /// Returns a read-only snapshot of a scroll node's state.
    ///
    /// This is the preferred way for timer callbacks to query scroll state,
    /// since they only have `&CallbackInfo` (read-only access).
    ///
    /// When `virtual_scroll_size` is set (for `VirtualView` nodes), the max scroll
    /// bounds are computed from the virtual size instead of `content_rect`.
371
    #[must_use] pub fn get_scroll_node_info(
371
        &self,
371
        dom_id: DomId,
371
        node_id: NodeId,
371
    ) -> Option<ScrollNodeInfo> {
371
        let state = self.states.get(&(dom_id, node_id))?;
160
        let effective_content_width = state.virtual_scroll_size
160
            .map_or(state.content_rect.size.width, |s| s.width);
160
        let effective_content_height = state.virtual_scroll_size
160
            .map_or(state.content_rect.size.height, |s| s.height);
160
        let max_x = (effective_content_width - state.container_rect.size.width).max(0.0);
160
        let max_y = (effective_content_height - state.container_rect.size.height).max(0.0);
160
        Some(ScrollNodeInfo {
160
            current_offset: state.current_offset,
160
            container_rect: state.container_rect,
160
            content_rect: state.content_rect,
160
            max_scroll_x: max_x,
160
            max_scroll_y: max_y,
160
            overscroll_behavior_x: state.overscroll_behavior_x,
160
            overscroll_behavior_y: state.overscroll_behavior_y,
160
            overflow_scrolling: state.overflow_scrolling,
160
        })
371
    }
    /// Returns all scroll positions for nodes in a specific DOM
    ///
    /// # `ScrollPosition` coordinate convention (MIXED — read before consuming)
    ///
    /// The two rects of the emitted [`ScrollPosition`] do NOT live in the same
    /// space, and a consumer that subtracts one origin from the other gets
    /// garbage:
    ///
    /// - `parent_rect` — the container's border box in **absolute window
    ///   coordinates** (`calculated_positions[node]`, see
    ///   `shell2::common::layout::register_scroll_nodes`). Only `size` is
    ///   meaningful to most consumers; the origin exists for `scroll_into_view`.
    /// - `children_rect.origin` — the **scroll offset itself**, i.e.
    ///   `current_offset`: the distance already scrolled, measured from the
    ///   scroll origin, clamped to `[0, content − container]`. It is NOT the
    ///   absolute position of the scrolled content, and it is NOT relative to
    ///   `parent_rect.origin`. Content is painted at `position − offset`
    ///   (`cpurender::raster`), so a positive value means "scrolled down/right".
    /// - `children_rect.size` — the scrollable content size (the `VirtualView`
    ///   virtual size when one was reported, else the laid-out content size).
    ///
    /// `content_rect.origin` is never carried out of here because it is always
    /// zero in the state (`register_or_update_scroll_node` builds it that way,
    /// and `clamp` reads only its size) — the absolute-content-rect reading of
    /// `children_rect` is not even representable.
    ///
    /// The round trip is the identity: `LayoutWindow::set_scroll_position`
    /// feeds `children_rect.origin` straight back into `set_scroll_position`.
7568
    #[must_use] pub fn get_scroll_states_for_dom(&self, dom_id: DomId) -> BTreeMap<NodeId, ScrollPosition> {
        // M12.7: iterating an EMPTY hashbrown map (RawIterRange) mis-lifts to
        // wasm and loops forever (same class as the font-id / GPU-cache loops).
        // For the headless web path `states` is empty; guard it (len-based, no
        // iteration). Desktop unchanged.
7568
        if self.states.is_empty() {
7156
            return BTreeMap::new();
412
        }
412
        self.states
412
            .iter()
415
            .filter(|((d, _), _)| *d == dom_id)
412
            .map(|((_, node_id), state)| {
                // Use virtual_scroll_size (from VirtualView callback) when available,
                // otherwise fall back to content_rect.size from layout.
203
                let effective_content_size = state.virtual_scroll_size
203
                    .unwrap_or(state.content_rect.size);
203
                (
203
                    *node_id,
203
                    ScrollPosition {
203
                        parent_rect: state.container_rect,
203
                        children_rect: LogicalRect::new(
203
                            state.current_offset,
203
                            effective_content_size,
203
                        ),
203
                    },
203
                )
203
            })
412
            .collect()
7568
    }
    /// Registers or updates a scrollable node with its container and content sizes.
    /// This should be called after layout for each node that has overflow:scroll or overflow:auto
    /// with overflowing content.
    ///
    /// If the node already exists, updates container/content rects without changing scroll offset.
    /// If the node is new, initializes with zero scroll offset.
189
    pub fn register_or_update_scroll_node(
189
        &mut self,
189
        dom_id: DomId,
189
        node_id: NodeId,
189
        container_rect: LogicalRect,
189
        content_size: LogicalSize,
189
        now: Instant,
189
        scrollbar_thickness: f32,
189
        visual_width_px: f32,
189
        has_horizontal_scrollbar: bool,
189
        has_vertical_scrollbar: bool,
189
    ) {
189
        let key = (dom_id, node_id);
189
        let content_rect = LogicalRect {
189
            origin: LogicalPosition::zero(),
189
            size: content_size,
189
        };
189
        if let Some(existing) = self.states.get_mut(&key) {
20
            // Update rects, keep scroll offset
20
            existing.container_rect = container_rect;
20
            existing.content_rect = content_rect;
20
            existing.scrollbar_thickness = scrollbar_thickness;
20
            existing.visual_width_px = visual_width_px;
20
            existing.has_horizontal_scrollbar = has_horizontal_scrollbar;
20
            existing.has_vertical_scrollbar = has_vertical_scrollbar;
20
            // Re-clamp current offset to new bounds
20
            existing.current_offset = existing.clamp(existing.current_offset);
172
        } else {
169
            // +spec:overflow:8c7aa1 - initial scroll position is zero (scroll origin for LTR/TTB)
169
            self.states.insert(
169
                key,
169
                AnimatedScrollState {
169
                    current_offset: LogicalPosition::zero(),
169
                    animation: None,
169
                    last_activity: now,
169
                    container_rect,
169
                    content_rect,
169
                    virtual_scroll_size: None,
169
                    virtual_scroll_offset: None,
169
                    overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
169
                    overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
169
                    overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
169
                    scrollbar_thickness,
169
                    visual_width_px,
169
                    has_horizontal_scrollbar,
169
                    has_vertical_scrollbar,
169
                },
169
            );
169
        }
189
    }
    // Scrollbar State Management
    /// Calculate scrollbar states for all visible scrollbars.
    /// This should be called once per frame after layout is complete.
    /// Uses the shared `compute_scrollbar_geometry()` for consistent geometry.
186
    pub fn calculate_scrollbar_states(&mut self) {
186
        self.scrollbar_states.clear();
        // Uses virtual_scroll_size (when set) for the overflow check and thumb ratio,
        // so VirtualView nodes with large virtual content show correct scrollbar geometry.
372
        for orientation in [ScrollbarOrientation::Vertical, ScrollbarOrientation::Horizontal] {
372
            let states: Vec<_> = self
372
                .states
372
                .iter()
372
                .filter(|(_, s)| {
186
                    let (effective, container) = match orientation {
                        ScrollbarOrientation::Vertical => (
93
                            s.virtual_scroll_size.map_or(s.content_rect.size.height, |vs| vs.height),
93
                            s.container_rect.size.height,
                        ),
                        ScrollbarOrientation::Horizontal => (
93
                            s.virtual_scroll_size.map_or(s.content_rect.size.width, |vs| vs.width),
93
                            s.container_rect.size.width,
                        ),
                    };
186
                    effective > container
186
                })
372
                .map(|((dom_id, node_id), scroll_state)| {
111
                    let state = Self::calculate_scrollbar_state_from_geometry(
111
                        scroll_state,
111
                        orientation,
                    );
111
                    ((*dom_id, *node_id, orientation), state)
111
                })
372
                .collect();
372
            self.scrollbar_states.extend(states);
        }
186
    }
    /// Calculate scrollbar state using the shared `compute_scrollbar_geometry()`.
112
    fn calculate_scrollbar_state_from_geometry(
112
        scroll_state: &AnimatedScrollState,
112
        orientation: ScrollbarOrientation,
112
    ) -> ScrollbarState {
112
        let scrollbar_thickness = if scroll_state.visual_width_px > 0.0 {
74
            scroll_state.visual_width_px
38
        } else if scroll_state.scrollbar_thickness > 0.0 {
36
            scroll_state.scrollbar_thickness
        } else {
2
            crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX
        };
112
        let content_size = scroll_state.virtual_scroll_size
112
            .map_or(scroll_state.content_rect.size, |vs| vs);
112
        let scroll_offset = match orientation {
93
            ScrollbarOrientation::Vertical => scroll_state.current_offset.y,
19
            ScrollbarOrientation::Horizontal => scroll_state.current_offset.x,
        };
112
        let has_other_scrollbar = match orientation {
93
            ScrollbarOrientation::Vertical => scroll_state.has_horizontal_scrollbar,
19
            ScrollbarOrientation::Horizontal => scroll_state.has_vertical_scrollbar,
        };
        // Overlay scrollbars (thickness == 0 from layout) have no arrow buttons
112
        let is_overlay = scroll_state.scrollbar_thickness == 0.0;
112
        let button_size = if is_overlay { 0.0 } else { scrollbar_thickness };
112
        let geom = compute_scrollbar_geometry_with_button_size(
112
            orientation,
112
            scroll_state.container_rect,
112
            content_size,
112
            scroll_offset,
112
            scrollbar_thickness,
112
            has_other_scrollbar,
112
            button_size,
        );
        // Build ScrollbarState from the shared geometry
112
        let scale = match orientation {
            ScrollbarOrientation::Vertical => {
93
                LogicalPosition::new(1.0, geom.track_rect.size.height / scrollbar_thickness)
            }
            ScrollbarOrientation::Horizontal => {
19
                LogicalPosition::new(geom.track_rect.size.width / scrollbar_thickness, 1.0)
            }
        };
112
        ScrollbarState {
112
            visible: true,
112
            orientation,
112
            base_size: scrollbar_thickness,
112
            scale,
112
            thumb_position_ratio: geom.scroll_ratio,
112
            thumb_size_ratio: geom.thumb_size_ratio,
112
            track_rect: geom.track_rect,
112
            button_size: geom.button_size,
112
            usable_track_length: geom.usable_track_length,
112
            thumb_length: geom.thumb_length,
112
            thumb_offset: geom.thumb_offset,
112
        }
112
    }
    /// Get scrollbar state for hit-testing
4
    #[must_use] pub fn get_scrollbar_state(
4
        &self,
4
        dom_id: DomId,
4
        node_id: NodeId,
4
        orientation: ScrollbarOrientation,
4
    ) -> Option<&ScrollbarState> {
4
        self.scrollbar_states.get(&(dom_id, node_id, orientation))
4
    }
    /// Iterate over all visible scrollbar states
3
    pub(crate) fn iter_scrollbar_states(
3
        &self,
3
    ) -> impl Iterator<Item = ((DomId, NodeId, ScrollbarOrientation), &ScrollbarState)> + '_ {
3
        self.scrollbar_states.iter().map(|(k, v)| (*k, v))
3
    }
    // Scrollbar Hit-Testing
    /// Hit-test scrollbars for a specific node at the given position.
    /// Returns Some if the position is inside a scrollbar for this node.
11
    pub(crate) fn hit_test_scrollbar(
11
        &self,
11
        dom_id: DomId,
11
        node_id: NodeId,
11
        global_pos: LogicalPosition,
11
    ) -> Option<ScrollbarHit> {
        // Check both vertical and horizontal scrollbars for this node
21
        for orientation in [
11
            ScrollbarOrientation::Vertical,
11
            ScrollbarOrientation::Horizontal,
        ] {
21
            let Some(scrollbar_state) = self.scrollbar_states.get(&(dom_id, node_id, orientation)) else {
12
                continue;
            };
9
            if !scrollbar_state.visible {
1
                continue;
8
            }
            // Check if position is inside scrollbar track using LogicalRect::contains
8
            if !scrollbar_state.track_rect.contains(global_pos) {
7
                continue;
1
            }
            // Calculate local position relative to track origin
1
            let local_pos = LogicalPosition::new(
1
                global_pos.x - scrollbar_state.track_rect.origin.x,
1
                global_pos.y - scrollbar_state.track_rect.origin.y,
            );
            // Determine which component was hit
1
            let component = scrollbar_state.hit_test_component(local_pos);
1
            return Some(ScrollbarHit {
1
                dom_id,
1
                node_id,
1
                orientation,
1
                component,
1
                local_position: local_pos,
1
                global_position: global_pos,
1
            });
        }
10
        None
11
    }
    /// Perform hit-testing for all scrollbars at the given global position.
    ///
    /// This iterates through all visible scrollbars in reverse z-order (top to bottom)
    /// and returns the first hit. Use this when you don't know which node to check.
    ///
    /// For better performance, use `hit_test_scrollbar()` when you already have
    /// a hit-tested node from `WebRender`.
13
    #[must_use] pub fn hit_test_scrollbars(&self, global_pos: LogicalPosition) -> Option<ScrollbarHit> {
        // Iterate in reverse order to hit top-most scrollbars first
13
        for ((dom_id, node_id, orientation), scrollbar_state) in self.scrollbar_states.iter().rev()
        {
10
            if !scrollbar_state.visible {
1
                continue;
9
            }
            // Check if position is inside scrollbar track
9
            if !scrollbar_state.track_rect.contains(global_pos) {
8
                continue;
1
            }
            // Calculate local position relative to track origin
1
            let local_pos = LogicalPosition::new(
1
                global_pos.x - scrollbar_state.track_rect.origin.x,
1
                global_pos.y - scrollbar_state.track_rect.origin.y,
            );
            // Determine which component was hit
1
            let component = scrollbar_state.hit_test_component(local_pos);
1
            return Some(ScrollbarHit {
1
                dom_id: *dom_id,
1
                node_id: *node_id,
1
                orientation: *orientation,
1
                component,
1
                local_position: local_pos,
1
                global_position: global_pos,
1
            });
        }
12
        None
13
    }
}
// AnimatedScrollState Implementation
impl AnimatedScrollState {
    // +spec:overflow:60f6a1 - scroll origin defaults to block-start inline-start corner (0,0)
    /// Create a new scroll state initialized at offset (0, 0).
314
    pub(crate) const fn new(now: Instant) -> Self {
314
        Self {
314
            current_offset: LogicalPosition::zero(),
314
            animation: None,
314
            last_activity: now,
314
            container_rect: LogicalRect::zero(),
314
            content_rect: LogicalRect::zero(),
314
            virtual_scroll_size: None,
314
            virtual_scroll_offset: None,
314
            overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
314
            overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
314
            overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
314
            scrollbar_thickness: crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX,
314
            visual_width_px: 0.0,
314
            has_horizontal_scrollbar: false,
314
            has_vertical_scrollbar: false,
314
        }
314
    }
    /// Clamp a scroll position to valid bounds (0 to `max_scroll`).
    ///
    /// When `virtual_scroll_size` is set (for `VirtualView` nodes), the max bounds
    /// are computed from the virtual size instead of `content_rect`.
863
    pub(crate) fn clamp(&self, position: LogicalPosition) -> LogicalPosition {
863
        let effective_width = self.virtual_scroll_size
863
            .map_or(self.content_rect.size.width, |s| s.width);
863
        let effective_height = self.virtual_scroll_size
863
            .map_or(self.content_rect.size.height, |s| s.height);
863
        let max_x = (effective_width - self.container_rect.size.width).max(0.0);
863
        let max_y = (effective_height - self.container_rect.size.height).max(0.0);
863
        LogicalPosition {
863
            x: position.x.max(0.0).min(max_x),
863
            y: position.y.max(0.0).min(max_y),
863
        }
863
    }
}
// Easing Functions
/// Apply an easing function to a normalized time value (0.0 to 1.0).
/// Used by `ScrollAnimation::tick()` for smooth scroll animations.
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
445
pub(crate) fn apply_easing(t: f32, easing: EasingFunction) -> f32 {
445
    match easing {
119
        EasingFunction::Linear => t,
111
        EasingFunction::EaseOut => 1.0 - (1.0 - t).powi(3),
        EasingFunction::EaseInOut => {
112
            if t < 0.5 {
56
                4.0 * t * t * t
            } else {
56
                1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
            }
        }
        // Critically-damped spring released at the target (x(t) settles as
        // (1+ωt)·e^(−ωt)): position = 1 − (1+ωt)e^(−ωt), ω chosen so the
        // curve is within ~0.6% of the target at t = 1 (ω = 7). The exact
        // landing is guaranteed by the clamp below — no asymptotic crawl.
        // Same curve family the scroll physics integrates numerically
        // (spring_constant_from_bounce_duration), evaluated analytically.
        EasingFunction::Spring => {
            const OMEGA: f32 = 7.0;
103
            let settle = 1.0 - (1.0 + OMEGA * t) * (-OMEGA * t).exp();
            // Normalize so t = 1 maps EXACTLY to 1.0: `end` goes through
            // the SAME arithmetic as settle(1), so the division is 1.0 in
            // f32 bit-for-bit (a hand-typed constant differed in the last
            // ulp and t=1 landed at 0.99999994).
103
            let end = 1.0 - (1.0 + OMEGA) * (-OMEGA).exp();
103
            (settle / end).clamp(0.0, 1.0)
        }
    }
445
}
#[cfg(test)]
mod spring_easing_laws {
    use super::*;
    use azul_core::events::EasingFunction;
    #[test]
1
    fn spring_hits_both_endpoints_exactly_and_is_monotone() {
1
        assert_eq!(apply_easing(0.0, EasingFunction::Spring), 0.0);
1
        assert_eq!(apply_easing(1.0, EasingFunction::Spring), 1.0);
1
        let mut prev = 0.0f32;
101
        for i in 1..=100 {
100
            let v = apply_easing(i as f32 / 100.0, EasingFunction::Spring);
100
            assert!(v >= prev, "monotone: {prev} -> {v} at step {i}");
100
            assert!((0.0..=1.0).contains(&v));
100
            prev = v;
        }
1
    }
    #[test]
1
    fn spring_front_loads_motion_like_a_released_spring() {
        // More than 60% of the distance covered by half time (fast pull,
        // gentle landing) — distinguishes it from Linear and EaseInOut.
1
        let half = apply_easing(0.5, EasingFunction::Spring);
1
        assert!(half > 0.6, "front-loaded: {half}");
1
        assert!(half > apply_easing(0.5, EasingFunction::Linear));
1
    }
}
impl crate::managers::NodeIdRemap for ScrollManager {
    /// Rewrite every `(DomId, NodeId)` key for `dom` and DROP the scroll state of
    /// nodes that were unmounted.
    ///
    /// The previous implementation only rewrote keys whose id actually changed and
    /// *kept* everything else "conservatively" — which silently re-attached the
    /// scroll offset of a deleted node to whatever node inherited its index.
    /// `node_moves` contains an entry for every matched node, so "absent from the
    /// map" unambiguously means "unmounted".
26
    fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
26
        crate::managers::remap_dom_keys(&mut self.states, dom, map);
26
        let old = core::mem::take(&mut self.scrollbar_states);
27
        for ((d, old_node_id, orientation), state) in old {
1
            if d != dom {
                self.scrollbar_states
                    .insert((d, old_node_id, orientation), state);
1
            } else if let Some(new_node_id) = map.resolve(old_node_id) {
                self.scrollbar_states
                    .insert((d, new_node_id, orientation), state);
1
            }
        }
26
    }
}
// ============================================================================
// Natural-scroll direction — unit tests (#17)
// ============================================================================
#[cfg(all(test, feature = "std"))]
mod natural_scroll_tests {
    use super::*;
    use azul_core::dom::{DomId, NodeId};
    use azul_core::geom::LogicalPosition;
    use azul_core::task::Instant;
4
    fn raw_input(dx: f32, dy: f32) -> ScrollInput {
4
        ScrollInput {
4
            dom_id: DomId::ROOT_ID,
4
            node_id: NodeId::new(0),
4
            delta: LogicalPosition::new(dx, dy),
4
            timestamp: Instant::now(),
4
            source: ScrollInputSource::WheelDiscrete,
4
            device: ScrollInputDevice::TestDriver,
4
        }
4
    }
    #[test]
    #[allow(clippy::float_cmp)] // test asserts exact float equality on deterministic values
1
    fn default_is_traditional_and_inverts_raw_delta() {
        // With AZ_NATURAL_SCROLL unset, the default is traditional: the offset
        // delta is the NEGATION of the raw input — exactly what the per-platform
        // `-delta` hardcodes used to do, now centralised.
1
        let mut m = ScrollManager::new();
1
        assert!(!m.is_natural_scroll(), "default must be traditional");
1
        m.record_scroll_input(raw_input(3.0, 10.0));
1
        let q = m.get_input_queue().take_all();
1
        assert_eq!(q.len(), 1);
1
        assert_eq!(q[0].delta.x, -3.0, "x must be inverted by the default sign");
1
        assert_eq!(q[0].delta.y, -10.0, "y must be inverted by the default sign");
1
    }
    #[test]
    #[allow(clippy::float_cmp)] // test asserts exact float equality on deterministic values
1
    fn natural_passes_raw_delta_through() {
1
        let mut m = ScrollManager::new();
1
        m.set_natural_scroll(true);
1
        assert!(m.is_natural_scroll());
1
        m.record_scroll_input(raw_input(3.0, 10.0));
1
        let q = m.get_input_queue().take_all();
1
        assert_eq!(q.len(), 1);
1
        assert_eq!(q[0].delta.x, 3.0, "natural mode must NOT invert x");
1
        assert_eq!(q[0].delta.y, 10.0, "natural mode must NOT invert y");
1
    }
    #[test]
    #[allow(clippy::float_cmp)] // test asserts exact float equality on deterministic values
1
    fn toggling_flips_sign_for_subsequent_input() {
        // Same raw input, opposite directions before/after the toggle — proves the
        // single flag is the only thing controlling direction.
1
        let mut m = ScrollManager::new();
1
        m.record_scroll_input(raw_input(0.0, 5.0));
1
        m.set_natural_scroll(true);
1
        m.record_scroll_input(raw_input(0.0, 5.0));
1
        let q = m.get_input_queue().take_all();
1
        assert_eq!(q.len(), 2);
1
        assert_eq!(q[0].delta.y, -5.0, "traditional first");
1
        assert_eq!(q[1].delta.y, 5.0, "natural after toggle");
1
    }
    // MWA-B2: nested-scroll target selection (innermost-first + handoff).
3
    fn nested_setup() -> (ScrollManager, DomId, NodeId, NodeId) {
        use azul_core::geom::{LogicalRect, LogicalSize};
3
        let now = Instant::now();
3
        let mut m = ScrollManager::new();
3
        let dom = DomId::ROOT_ID;
        // Ancestors have LOWER arena ids than descendants.
3
        let outer = NodeId::from_usize(1).unwrap();
3
        let inner = NodeId::from_usize(9).unwrap();
        // Outer: 200x200 viewport over 200x1000 content → max_y = 800.
3
        m.register_or_update_scroll_node(
3
            dom,
3
            outer,
3
            LogicalRect {
3
                origin: LogicalPosition::zero(),
3
                size: LogicalSize { width: 200.0, height: 200.0 },
3
            },
3
            LogicalSize { width: 200.0, height: 1000.0 },
3
            now.clone(),
            8.0,
            8.0,
            false,
            true,
        );
        // Inner: 100x100 viewport over 100x300 content → max_y = 200.
3
        m.register_or_update_scroll_node(
3
            dom,
3
            inner,
3
            LogicalRect {
3
                origin: LogicalPosition::zero(),
3
                size: LogicalSize { width: 100.0, height: 100.0 },
3
            },
3
            LogicalSize { width: 100.0, height: 300.0 },
3
            now,
            8.0,
            8.0,
            false,
            true,
        );
3
        (m, dom, outer, inner)
3
    }
    #[test]
1
    fn nested_scroll_prefers_innermost_with_room() {
1
        let (m, dom, outer, inner) = nested_setup();
        // Innermost-first candidate order, scrolling "down" (eff +y).
1
        let picked = m.select_scroll_target(
1
            [(dom, inner), (dom, outer)].into_iter(),
            0.0,
            1.0,
        );
1
        assert_eq!(picked, Some((dom, inner)), "inner has room → inner wins");
1
    }
    #[test]
1
    fn nested_scroll_hands_off_to_ancestor_at_boundary() {
1
        let (mut m, dom, outer, inner) = nested_setup();
        // Pin the inner container at its bottom edge (max_y = 200).
1
        m.states.get_mut(&(dom, inner)).unwrap().current_offset =
1
            LogicalPosition { x: 0.0, y: 200.0 };
1
        let down = m.select_scroll_target(
1
            [(dom, inner), (dom, outer)].into_iter(),
            0.0,
            1.0,
        );
1
        assert_eq!(down, Some((dom, outer)), "inner pinned at bottom → handoff");
1
        let up = m.select_scroll_target(
1
            [(dom, inner), (dom, outer)].into_iter(),
            0.0,
            -1.0,
        );
1
        assert_eq!(up, Some((dom, inner)), "inner has room upward → inner again");
1
    }
    #[test]
1
    fn nested_scroll_falls_back_to_innermost_when_all_pinned() {
1
        let (mut m, dom, outer, inner) = nested_setup();
1
        m.states.get_mut(&(dom, inner)).unwrap().current_offset =
1
            LogicalPosition { x: 0.0, y: 200.0 };
1
        m.states.get_mut(&(dom, outer)).unwrap().current_offset =
1
            LogicalPosition { x: 0.0, y: 800.0 };
1
        let picked = m.select_scroll_target(
1
            [(dom, inner), (dom, outer)].into_iter(),
            0.0,
            1.0,
        );
1
        assert_eq!(
            picked,
1
            Some((dom, inner)),
            "everything pinned → innermost fallback (gesture stays under pointer)"
        );
1
    }
}
// ============================================================================
// Adversarial unit tests (autotest fleet)
//
// Hostile inputs for every category in the task file: numeric (NaN / ±inf /
// MIN / MAX / zero / saturation), predicates (invariants at the boundary),
// getters (defined value on a default/empty instance) and constructors.
// Every assertion below documents the *actual* behavior — nothing is weakened
// to make it pass.
// ============================================================================
#[cfg(all(test, feature = "std"))]
mod autotest_generated {
    #![allow(clippy::float_cmp)] // tests assert exact float results on deterministic inputs
    use std::collections::HashMap;
    use azul_core::{
        dom::{DomId, NodeId, ScrollbarOrientation},
        events::EasingFunction,
        geom::{LogicalPosition, LogicalRect, LogicalSize},
        hit_test::{FullHitTest, HitTest, OverflowingScrollNode, ScrollHitTestItem},
        styled_dom::NodeHierarchyItem,
        task::{Duration, Instant, SystemTick, SystemTickDiff, SystemTimeDiff},
    };
    use super::*;
    use crate::managers::hover::HoverManager;
    // ---------------------------------------------------------------- helpers
    const DOM: DomId = DomId::ROOT_ID;
    const DOM1: DomId = DomId { inner: 1 };
    fn node(i: usize) -> NodeId {
        NodeId::new(i)
    }
    /// Deterministic tick-clock instant — no wall clock, no flakiness.
    fn at(t: u64) -> Instant {
        Instant::Tick(SystemTick::new(t))
    }
    fn tick_dur(d: u64) -> Duration {
        Duration::Tick(SystemTickDiff { tick_diff: d })
    }
    fn sys_dur(secs: u64, nanos: u32) -> Duration {
        Duration::System(SystemTimeDiff { secs, nanos })
    }
    fn pos(x: f32, y: f32) -> LogicalPosition {
        LogicalPosition::new(x, y)
    }
    fn size(w: f32, h: f32) -> LogicalSize {
        LogicalSize::new(w, h)
    }
    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
        LogicalRect::new(pos(x, y), size(w, h))
    }
    /// A manager with node 0 registered: `container` viewport over `content`.
    fn mgr(container: LogicalSize, content: LogicalSize) -> ScrollManager {
        let mut m = ScrollManager::new();
        m.register_or_update_scroll_node(
            DOM,
            node(0),
            LogicalRect::new(LogicalPosition::zero(), container),
            content,
            at(0),
            16.0,
            16.0,
            false,
            true,
        );
        m
    }
    /// A bare `AnimatedScrollState` with the given container/content geometry.
    fn state(container: LogicalSize, content: LogicalSize) -> AnimatedScrollState {
        let mut s = AnimatedScrollState::new(at(0));
        s.container_rect = LogicalRect::new(LogicalPosition::zero(), container);
        s.content_rect = LogicalRect::new(LogicalPosition::zero(), content);
        s
    }
    fn input(dx: f32, dy: f32, ts: u64) -> ScrollInput {
        ScrollInput {
            dom_id: DOM,
            node_id: node(0),
            delta: pos(dx, dy),
            timestamp: at(ts),
            source: ScrollInputSource::WheelDiscrete,
            device: ScrollInputDevice::TestDriver,
        }
    }
    fn scrollbar(
        orientation: ScrollbarOrientation,
        track: LogicalRect,
        button_size: f32,
        thumb_offset: f32,
        thumb_length: f32,
    ) -> ScrollbarState {
        ScrollbarState {
            visible: true,
            orientation,
            base_size: 16.0,
            scale: LogicalPosition::new(1.0, 1.0),
            thumb_position_ratio: 0.0,
            thumb_size_ratio: 0.5,
            track_rect: track,
            button_size,
            usable_track_length: 0.0,
            thumb_length,
            thumb_offset,
        }
    }
    /// A `HoverManager` whose current mouse hit-test reports `nodes` as scroll
    /// hit-test nodes in `DOM` (BTreeMap key order; `record_scroll_from_hit_test`
    /// walks them in reverse = innermost-first).
    fn hover_over(nodes: &[usize]) -> HoverManager {
        let mut ht = HitTest::empty();
        for n in nodes {
            ht.scroll_hit_test_nodes.insert(
                node(*n),
                ScrollHitTestItem {
                    point_in_viewport: LogicalPosition::zero(),
                    point_relative_to_item: LogicalPosition::zero(),
                    scroll_node: OverflowingScrollNode::default(),
                },
            );
        }
        let mut full = FullHitTest::empty(None);
        full.hovered_nodes.insert(DOM, ht);
        let mut hm = HoverManager::new();
        hm.push_hit_test(InputPointId::Mouse, full);
        hm
    }
    // ============================================================ apply_easing
    // (numeric: zero / min_max / negative / overflow / nan_inf)
    #[test]
    fn apply_easing_endpoints_are_exact_for_every_curve() {
        // The one invariant every easing curve must satisfy: f(0) == 0, f(1) == 1.
        // A violation here would make animations jump at their first/last tick.
        for e in [
            EasingFunction::Linear,
            EasingFunction::EaseOut,
            EasingFunction::EaseInOut,
        ] {
            assert_eq!(apply_easing(0.0, e), 0.0, "f(0) must be 0 for {e:?}");
            assert_eq!(apply_easing(1.0, e), 1.0, "f(1) must be 1 for {e:?}");
        }
    }
    #[test]
    fn apply_easing_is_monotonic_and_bounded_on_the_unit_interval() {
        for e in [
            EasingFunction::Linear,
            EasingFunction::EaseOut,
            EasingFunction::EaseInOut,
        ] {
            let mut prev = f32::NEG_INFINITY;
            for i in 0..=100 {
                let t = i as f32 / 100.0;
                let v = apply_easing(t, e);
                assert!(v.is_finite(), "{e:?}({t}) must be finite, got {v}");
                assert!(
                    (-1e-6..=1.0 + 1e-6).contains(&v),
                    "{e:?}({t}) = {v} escaped [0, 1]"
                );
                assert!(v >= prev - 1e-6, "{e:?} must not go backwards at t={t}");
                prev = v;
            }
        }
    }
    #[test]
    fn apply_easing_nan_propagates_without_panicking() {
        // NaN in => NaN out for every curve (no comparison panic, no unwrap).
        for e in [
            EasingFunction::Linear,
            EasingFunction::EaseOut,
            EasingFunction::EaseInOut,
        ] {
            assert!(
                apply_easing(f32::NAN, e).is_nan(),
                "{e:?}(NaN) must be NaN, not a silently-wrong number"
            );
        }
    }
    #[test]
    fn apply_easing_infinities_saturate_to_infinity_not_panic() {
        assert_eq!(apply_easing(f32::INFINITY, EasingFunction::Linear), f32::INFINITY);
        assert_eq!(
            apply_easing(f32::NEG_INFINITY, EasingFunction::Linear),
            f32::NEG_INFINITY
        );
        // EaseOut: 1 - (1 - inf)^3 = 1 + inf
        assert_eq!(apply_easing(f32::INFINITY, EasingFunction::EaseOut), f32::INFINITY);
        assert_eq!(
            apply_easing(f32::NEG_INFINITY, EasingFunction::EaseOut),
            f32::NEG_INFINITY
        );
        // EaseInOut: t >= 0.5 branch for +inf, t < 0.5 branch for -inf
        assert_eq!(
            apply_easing(f32::INFINITY, EasingFunction::EaseInOut),
            f32::INFINITY
        );
        assert_eq!(
            apply_easing(f32::NEG_INFINITY, EasingFunction::EaseInOut),
            f32::NEG_INFINITY
        );
    }
    #[test]
    fn apply_easing_f32_extremes_do_not_panic() {
        // powi(3) overflows f32 for MIN/MAX inputs — must saturate to +-inf,
        // never trap. (Callers clamp t to [0, 1]; this is the defense in depth.)
        for e in [
            EasingFunction::Linear,
            EasingFunction::EaseOut,
            EasingFunction::EaseInOut,
        ] {
            let hi = apply_easing(f32::MAX, e);
            let lo = apply_easing(f32::MIN, e);
            assert!(!hi.is_nan(), "{e:?}(f32::MAX) must not be NaN");
            assert!(!lo.is_nan(), "{e:?}(f32::MIN) must not be NaN");
        }
        // Subnormal / smallest positive: stays ~0, no denormal blowup.
        assert!(apply_easing(f32::MIN_POSITIVE, EasingFunction::EaseInOut).abs() < 1e-30);
    }
    #[test]
    fn apply_easing_negative_t_is_deterministic_extrapolation() {
        // Out-of-range t is not clamped by apply_easing (the caller does that);
        // pin the exact extrapolated values so a silent change is caught.
        assert_eq!(apply_easing(-1.0, EasingFunction::Linear), -1.0);
        assert_eq!(apply_easing(-1.0, EasingFunction::EaseOut), -7.0);
        assert_eq!(apply_easing(-1.0, EasingFunction::EaseInOut), -4.0);
    }
    #[test]
    fn apply_easing_ease_in_out_is_continuous_at_the_branch_boundary() {
        // t == 0.5 takes the `else` branch; both halves must meet at 0.5.
        assert_eq!(apply_easing(0.5, EasingFunction::EaseInOut), 0.5);
        let just_below = apply_easing(0.499_999, EasingFunction::EaseInOut);
        assert!(
            (just_below - 0.5).abs() < 1e-4,
            "discontinuity at the 0.5 branch: {just_below}"
        );
        assert_eq!(apply_easing(0.5, EasingFunction::EaseOut), 0.875);
    }
    // ================================================ AnimatedScrollState::new
    // (constructor: no_panic / invariants_hold)
    #[test]
    fn animated_scroll_state_new_starts_at_scroll_origin() {
        let s = AnimatedScrollState::new(at(0));
        assert_eq!(s.current_offset, LogicalPosition::zero());
        assert!(s.animation.is_none());
        assert_eq!(s.container_rect, LogicalRect::zero());
        assert_eq!(s.content_rect, LogicalRect::zero());
        assert!(s.virtual_scroll_size.is_none());
        assert!(s.virtual_scroll_offset.is_none());
        assert!(!s.has_horizontal_scrollbar);
        assert!(!s.has_vertical_scrollbar);
        // A zero-sized state has no travel: clamp must pin everything to origin.
        assert_eq!(s.clamp(pos(1e9, 1e9)), LogicalPosition::zero());
    }
    // ============================================== AnimatedScrollState::clamp
    // (numeric: zero / min_max / negative / overflow)
    #[test]
    fn clamp_pins_to_zero_and_max_travel() {
        let s = state(size(100.0, 100.0), size(100.0, 500.0));
        // max_x = 0 (no horizontal overflow), max_y = 400.
        assert_eq!(s.clamp(pos(0.0, 0.0)), pos(0.0, 0.0));
        assert_eq!(s.clamp(pos(50.0, 250.0)), pos(0.0, 250.0));
        assert_eq!(s.clamp(pos(-1.0, -1.0)), pos(0.0, 0.0));
        assert_eq!(s.clamp(pos(9999.0, 9999.0)), pos(0.0, 400.0));
    }
    #[test]
    fn clamp_never_produces_negative_max_when_content_is_smaller_than_container() {
        // Content smaller than the viewport => max travel is 0, not negative.
        let s = state(size(500.0, 500.0), size(10.0, 10.0));
        assert_eq!(s.clamp(pos(100.0, 100.0)), LogicalPosition::zero());
        assert_eq!(s.clamp(pos(-100.0, -100.0)), LogicalPosition::zero());
    }
    #[test]
    fn clamp_nan_position_collapses_to_origin_never_stores_nan() {
        // f32::max(NaN, 0.0) == 0.0, so a NaN offset is sanitized to the origin.
        // This is the property the whole scroll pipeline relies on to stay finite.
        let s = state(size(100.0, 100.0), size(100.0, 500.0));
        let c = s.clamp(pos(f32::NAN, f32::NAN));
        assert!(!c.x.is_nan() && !c.y.is_nan(), "clamp must not leak NaN");
        assert_eq!(c, LogicalPosition::zero());
    }
    #[test]
    fn clamp_infinite_position_saturates_to_max_travel() {
        let s = state(size(100.0, 100.0), size(100.0, 500.0));
        assert_eq!(s.clamp(pos(f32::INFINITY, f32::INFINITY)), pos(0.0, 400.0));
        assert_eq!(
            s.clamp(pos(f32::NEG_INFINITY, f32::NEG_INFINITY)),
            LogicalPosition::zero()
        );
        assert_eq!(s.clamp(pos(f32::MAX, f32::MAX)), pos(0.0, 400.0));
        assert_eq!(s.clamp(pos(f32::MIN, f32::MIN)), LogicalPosition::zero());
    }
    #[test]
    fn clamp_nan_geometry_degrades_to_zero_travel() {
        // A NaN content size must not poison the offset: (NaN - w).max(0.0) == 0.0.
        let s = state(size(100.0, 100.0), size(f32::NAN, f32::NAN));
        let c = s.clamp(pos(50.0, 50.0));
        assert!(!c.x.is_nan() && !c.y.is_nan());
        assert_eq!(c, LogicalPosition::zero());
    }
    #[test]
    fn clamp_infinite_content_minus_infinite_container_is_zero_travel_not_nan() {
        // inf - inf = NaN; `.max(0.0)` rescues it to 0.
        let s = state(
            size(f32::INFINITY, f32::INFINITY),
            size(f32::INFINITY, f32::INFINITY),
        );
        let c = s.clamp(pos(10.0, 10.0));
        assert!(!c.x.is_nan() && !c.y.is_nan());
        assert_eq!(c, LogicalPosition::zero());
    }
    #[test]
    fn clamp_prefers_virtual_scroll_size_over_content_rect() {
        let mut s = state(size(100.0, 100.0), size(100.0, 120.0));
        assert_eq!(s.clamp(pos(0.0, 1e9)), pos(0.0, 20.0), "content_rect bound");
        s.virtual_scroll_size = Some(size(100.0, 10_000.0));
        assert_eq!(
            s.clamp(pos(0.0, 1e9)),
            pos(0.0, 9900.0),
            "virtual size must override content_rect"
        );
    }
    // ============================================== ScrollInputQueue (std only)
    // (constructor / getter / predicate / numeric)
    #[test]
    fn input_queue_new_is_empty_and_default_matches() {
        let q = ScrollInputQueue::new();
        assert!(!q.has_pending());
        assert!(q.take_all().is_empty());
        assert!(q.take_recent(10).is_empty());
        assert!(!ScrollInputQueue::default().has_pending());
    }
    #[test]
    fn input_queue_take_all_drains_and_preserves_push_order() {
        let q = ScrollInputQueue::new();
        q.push(input(1.0, 1.0, 1));
        q.push(input(2.0, 2.0, 2));
        assert!(q.has_pending());
        let taken = q.take_all();
        assert_eq!(taken.len(), 2);
        assert_eq!(taken[0].delta.x, 1.0);
        assert_eq!(taken[1].delta.x, 2.0);
        assert!(!q.has_pending(), "take_all must drain the queue");
        assert!(q.take_all().is_empty(), "second take_all is empty, not stale");
    }
    #[test]
    fn input_queue_take_recent_zero_discards_everything() {
        // max_events = 0: `drain(..len - 0)` removes every event. Documented as
        // "older events beyond max_events are discarded" — with 0 that is all of
        // them, and the queue is left empty (the backlog is dropped, not kept).
        let q = ScrollInputQueue::new();
        q.push(input(1.0, 1.0, 1));
        q.push(input(2.0, 2.0, 2));
        let taken = q.take_recent(0);
        assert!(taken.is_empty(), "take_recent(0) must return nothing");
        assert!(!q.has_pending(), "take_recent(0) must still drain the queue");
    }
    #[test]
    fn input_queue_take_recent_keeps_the_newest_events_sorted_oldest_first() {
        let q = ScrollInputQueue::new();
        // Pushed out of timestamp order on purpose.
        q.push(input(0.0, 0.0, 5));
        q.push(input(0.0, 0.0, 1));
        q.push(input(0.0, 0.0, 3));
        q.push(input(0.0, 0.0, 9));
        let taken = q.take_recent(2);
        assert_eq!(taken.len(), 2, "backlog must be truncated to max_events");
        assert_eq!(taken[0].timestamp, at(5));
        assert_eq!(taken[1].timestamp, at(9), "newest event must be last");
        assert!(!q.has_pending());
    }
    #[test]
    fn input_queue_take_recent_below_limit_returns_push_order_not_sorted() {
        // NOTE: the doc says "sorted by timestamp (newest last)", but the sort
        // only runs on the overflow path (len > max_events). Below the limit the
        // events come back in PUSH order. Pinning the real behavior here.
        let q = ScrollInputQueue::new();
        q.push(input(0.0, 0.0, 5));
        q.push(input(0.0, 0.0, 1));
        q.push(input(0.0, 0.0, 3));
        let taken = q.take_recent(3);
        assert_eq!(taken.len(), 3);
        let stamps: Vec<_> = taken.iter().map(|e| e.timestamp.clone()).collect();
        assert_eq!(stamps, vec![at(5), at(1), at(3)]);
    }
    #[test]
    fn input_queue_take_recent_usize_max_does_not_overflow() {
        // `events.len() - max_events` would underflow if the length guard were
        // wrong; usize::MAX must simply mean "take everything".
        let q = ScrollInputQueue::new();
        q.push(input(1.0, 0.0, 1));
        q.push(input(2.0, 0.0, 2));
        let taken = q.take_recent(usize::MAX);
        assert_eq!(taken.len(), 2);
        assert!(!q.has_pending());
        // Empty queue + usize::MAX: still no underflow, no panic.
        assert!(q.take_recent(usize::MAX).is_empty());
        assert!(q.take_recent(0).is_empty());
    }
    #[test]
    fn input_queue_clone_shares_one_backing_store() {
        // The timer callback holds a clone; a push through either handle must be
        // visible to the other, otherwise inputs would silently vanish.
        let q = ScrollInputQueue::new();
        let c = q.clone();
        c.push(input(1.0, 2.0, 1));
        assert!(q.has_pending(), "clone must not deep-copy the queue");
        assert_eq!(q.take_all().len(), 1);
        assert!(!c.has_pending(), "draining one handle drains both");
    }
    #[test]
    fn input_queue_accepts_non_finite_deltas_without_panicking() {
        let q = ScrollInputQueue::new();
        q.push(input(f32::NAN, f32::INFINITY, 1));
        q.push(input(f32::MAX, f32::MIN, 2));
        let taken = q.take_recent(usize::MAX);
        assert_eq!(taken.len(), 2);
        assert!(taken[0].delta.x.is_nan());
        assert_eq!(taken[0].delta.y, f32::INFINITY);
    }
    // ======================================= ScrollbarState::hit_test_component
    // (numeric: zero / negative / nan_inf / boundary)
    #[test]
    fn hit_test_component_vertical_maps_each_region() {
        let sb = scrollbar(
            ScrollbarOrientation::Vertical,
            rect(0.0, 0.0, 16.0, 100.0),
            16.0, // button_size
            10.0, // thumb_offset (from end of top button)
            30.0, // thumb_length
        );
        assert_eq!(sb.hit_test_component(pos(8.0, 0.0)), ScrollbarComponent::TopButton);
        assert_eq!(sb.hit_test_component(pos(8.0, 15.9)), ScrollbarComponent::TopButton);
        assert_eq!(
            sb.hit_test_component(pos(8.0, 99.0)),
            ScrollbarComponent::BottomButton
        );
        // Thumb spans [16 + 10, 16 + 10 + 30] = [26, 56].
        assert_eq!(sb.hit_test_component(pos(8.0, 26.0)), ScrollbarComponent::Thumb);
        assert_eq!(sb.hit_test_component(pos(8.0, 56.0)), ScrollbarComponent::Thumb);
        assert_eq!(sb.hit_test_component(pos(8.0, 20.0)), ScrollbarComponent::Track);
        assert_eq!(sb.hit_test_component(pos(8.0, 60.0)), ScrollbarComponent::Track);
    }
    #[test]
    fn hit_test_component_boundaries_are_exact() {
        let sb = scrollbar(
            ScrollbarOrientation::Vertical,
            rect(0.0, 0.0, 16.0, 100.0),
            16.0,
            0.0,
            30.0,
        );
        // y == button_size is NOT the top button (strict <) — it is the thumb start.
        assert_eq!(sb.hit_test_component(pos(0.0, 16.0)), ScrollbarComponent::Thumb);
        // y == track_height - button_size is NOT the bottom button (strict >).
        assert_eq!(sb.hit_test_component(pos(0.0, 84.0)), ScrollbarComponent::Track);
        assert_eq!(
            sb.hit_test_component(pos(0.0, 84.001)),
            ScrollbarComponent::BottomButton
        );
    }
    #[test]
    fn hit_test_component_overlay_zero_button_size_has_no_buttons() {
        // Overlay scrollbars get button_size == 0: y == 0 must NOT be a TopButton
        // (strict `<` means the button region is empty).
        let sb = scrollbar(
            ScrollbarOrientation::Vertical,
            rect(0.0, 0.0, 8.0, 100.0),
            0.0,
            0.0,
            50.0,
        );
        assert_eq!(sb.hit_test_component(pos(0.0, 0.0)), ScrollbarComponent::Thumb);
        assert_eq!(sb.hit_test_component(pos(0.0, 50.0)), ScrollbarComponent::Thumb);
        assert_eq!(sb.hit_test_component(pos(0.0, 60.0)), ScrollbarComponent::Track);
        // y == track_height is still not "> track_height - 0" ... it IS equal, so Track.
        assert_eq!(sb.hit_test_component(pos(0.0, 100.0)), ScrollbarComponent::Track);
    }
    #[test]
    fn hit_test_component_nan_position_falls_through_to_track() {
        // Every float comparison against NaN is false, so NaN lands in the
        // final `else` — Track. Deterministic, no panic, no phantom button click.
        let sb = scrollbar(
            ScrollbarOrientation::Vertical,
            rect(0.0, 0.0, 16.0, 100.0),
            16.0,
            10.0,
            30.0,
        );
        assert_eq!(
            sb.hit_test_component(pos(f32::NAN, f32::NAN)),
            ScrollbarComponent::Track
        );
        let hb = scrollbar(
            ScrollbarOrientation::Horizontal,
            rect(0.0, 0.0, 100.0, 16.0),
            16.0,
            10.0,
            30.0,
        );
        assert_eq!(
            hb.hit_test_component(pos(f32::NAN, f32::NAN)),
            ScrollbarComponent::Track
        );
    }
    #[test]
    fn hit_test_component_infinite_position_picks_an_end_button() {
        let sb = scrollbar(
            ScrollbarOrientation::Vertical,
            rect(0.0, 0.0, 16.0, 100.0),
            16.0,
            10.0,
            30.0,
        );
        assert_eq!(
            sb.hit_test_component(pos(0.0, f32::NEG_INFINITY)),
            ScrollbarComponent::TopButton
        );
        assert_eq!(
            sb.hit_test_component(pos(0.0, f32::INFINITY)),
            ScrollbarComponent::BottomButton
        );
        assert_eq!(
            sb.hit_test_component(pos(0.0, f32::MIN)),
            ScrollbarComponent::TopButton
        );
        assert_eq!(
            sb.hit_test_component(pos(0.0, f32::MAX)),
            ScrollbarComponent::BottomButton
        );
    }
    #[test]
    fn hit_test_component_ignores_the_cross_axis() {
        // A vertical scrollbar must not care about x (and vice versa) — otherwise
        // a drag that leaves the bar sideways would change component mid-gesture.
        let v = scrollbar(
            ScrollbarOrientation::Vertical,
            rect(0.0, 0.0, 16.0, 100.0),
            16.0,
            10.0,
            30.0,
        );
        for x in [-1e9, -1.0, 0.0, 8.0, 1e9, f32::NAN] {
            assert_eq!(v.hit_test_component(pos(x, 30.0)), ScrollbarComponent::Thumb);
        }
        let h = scrollbar(
            ScrollbarOrientation::Horizontal,
            rect(0.0, 0.0, 100.0, 16.0),
            16.0,
            10.0,
            30.0,
        );
        for y in [-1e9, -1.0, 0.0, 8.0, 1e9, f32::NAN] {
            assert_eq!(h.hit_test_component(pos(30.0, y)), ScrollbarComponent::Thumb);
        }
    }
    #[test]
    fn hit_test_component_degenerate_track_shorter_than_buttons_prefers_top() {
        // button_size > track length: the top/bottom regions overlap. First match
        // wins (TopButton) — no panic, no ambiguity.
        let sb = scrollbar(
            ScrollbarOrientation::Vertical,
            rect(0.0, 0.0, 16.0, 4.0),
            16.0,
            0.0,
            0.0,
        );
        assert_eq!(sb.hit_test_component(pos(0.0, 0.0)), ScrollbarComponent::TopButton);
        assert_eq!(sb.hit_test_component(pos(0.0, 3.0)), ScrollbarComponent::TopButton);
    }
    // ========================================================= ScrollManager::new
    // (constructor / getters / predicates on an empty instance)
    #[test]
    fn manager_new_is_empty_and_traditional_by_default() {
        let m = ScrollManager::new();
        assert_eq!(m.debug_counts(), (0, 0));
        assert!(!m.has_active_animations());
        assert!(!m.has_pending_scroll_changes());
        assert!(!m.is_natural_scroll());
        assert_eq!(m.scroll_sign(), -1.0);
        assert!(m.pending_wheel_event.is_none());
        assert!(!m.get_input_queue().has_pending());
        // Getters on an empty manager return None / empty, never panic.
        assert!(m.get_current_offset(DOM, node(0)).is_none());
        assert!(m.get_last_activity_time(DOM, node(0)).is_none());
        assert!(m.get_scroll_state(DOM, node(0)).is_none());
        assert!(m.get_scroll_node_info(DOM, node(0)).is_none());
        assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
        assert!(m.get_scroll_states_for_dom(DOM).is_empty());
        assert!(m
            .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
            .is_none());
        assert!(m.hit_test_scrollbars(pos(0.0, 0.0)).is_none());
        assert_eq!(m.iter_scrollbar_states().count(), 0);
        assert!(!m.is_node_scrollable(DOM, node(0)));
        assert!(!m.can_consume_delta(DOM, node(0), 10.0, 10.0));
    }
    #[test]
    fn scroll_sign_flips_with_the_preference() {
        let mut m = ScrollManager::new();
        assert_eq!(m.scroll_sign(), -1.0);
        m.set_natural_scroll(true);
        assert!(m.is_natural_scroll());
        assert_eq!(m.scroll_sign(), 1.0);
        m.set_natural_scroll(false);
        assert_eq!(m.scroll_sign(), -1.0);
        // Idempotent: setting the same value twice must not toggle.
        m.set_natural_scroll(false);
        assert_eq!(m.scroll_sign(), -1.0);
    }
    // ================================================= dirty-flag bookkeeping
    // (predicate: has_pending_scroll_changes / clear_scroll_dirty)
    #[test]
    fn scroll_dirty_is_set_only_on_a_real_move_and_cleared_on_demand() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        assert!(!m.has_pending_scroll_changes());
        // Sub-epsilon move (< SCROLL_CHANGE_EPSILON = 0.01) must NOT dirty the
        // display list — otherwise every trackpad jitter forces a rebuild.
        m.set_scroll_position(DOM, node(0), pos(0.0, 0.005), at(1));
        assert!(!m.has_pending_scroll_changes(), "0.005px must not be 'moved'");
        m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(2));
        assert!(m.has_pending_scroll_changes());
        m.clear_scroll_dirty();
        assert!(!m.has_pending_scroll_changes());
        // Setting the SAME position again is a no-op move: stays clean.
        m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(3));
        assert!(!m.has_pending_scroll_changes());
    }
    #[test]
    fn clear_scroll_dirty_on_a_clean_manager_is_a_noop() {
        let mut m = ScrollManager::new();
        m.clear_scroll_dirty();
        m.clear_scroll_dirty();
        assert!(!m.has_pending_scroll_changes());
    }
    // ======================================= set_scroll_position (+unclamped)
    // (numeric: zero / min_max / negative / overflow / nan)
    #[test]
    fn set_scroll_position_clamps_extremes_into_bounds() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position(DOM, node(0), pos(f32::MAX, f32::MAX), at(1));
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
        m.set_scroll_position(DOM, node(0), pos(f32::MIN, f32::MIN), at(2));
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 0.0)));
        m.set_scroll_position(DOM, node(0), pos(f32::INFINITY, f32::INFINITY), at(3));
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
        m.set_scroll_position(DOM, node(0), pos(f32::NAN, f32::NAN), at(4));
        let off = m.get_current_offset(DOM, node(0)).unwrap();
        assert!(!off.x.is_nan() && !off.y.is_nan(), "clamped path must kill NaN");
        assert_eq!(off, LogicalPosition::zero());
    }
    #[test]
    fn set_scroll_position_cancels_a_running_animation() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.scroll_to(DOM, node(0), pos(0.0, 300.0), tick_dur(100), EasingFunction::Linear, at(0));
        assert!(m.has_active_animations());
        m.set_scroll_position(DOM, node(0), pos(0.0, 10.0), at(1));
        assert!(!m.has_active_animations(), "an explicit set must win over easing");
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 10.0)));
    }
    #[test]
    fn set_scroll_position_on_an_unknown_node_creates_a_pinned_zero_state() {
        // The entry API inserts a zero-sized state, so the offset can only be 0 —
        // and the map grows by exactly one (no unbounded growth per call).
        let mut m = ScrollManager::new();
        m.set_scroll_position(DOM, node(42), pos(500.0, 500.0), at(1));
        assert_eq!(m.get_current_offset(DOM, node(42)), Some(LogicalPosition::zero()));
        assert_eq!(m.debug_counts(), (1, 0));
        m.set_scroll_position(DOM, node(42), pos(600.0, 600.0), at(2));
        assert_eq!(m.debug_counts(), (1, 0), "repeat set must not grow the map");
    }
    #[test]
    fn set_scroll_position_unclamped_keeps_overscroll_values_verbatim() {
        // The physics timer relies on being able to push the offset OUTSIDE
        // [0, max] for rubber-banding — clamping here would kill the bounce.
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position_unclamped(DOM, node(0), pos(-50.0, -80.0), at(1));
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(-50.0, -80.0)));
        m.set_scroll_position_unclamped(DOM, node(0), pos(0.0, 9999.0), at(2));
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 9999.0)));
        assert!(m.has_pending_scroll_changes());
    }
    #[test]
    fn set_scroll_position_unclamped_stores_non_finite_values_unfiltered() {
        // Documents a real hazard: the unclamped path performs NO sanitization,
        // so a NaN delta from a driver would be stored verbatim AND (because
        // `(NaN - x).abs() > EPS` is false) would not even mark the state dirty.
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position_unclamped(DOM, node(0), pos(f32::NAN, f32::NAN), at(1));
        let off = m.get_current_offset(DOM, node(0)).unwrap();
        assert!(off.x.is_nan() && off.y.is_nan(), "unclamped stores NaN as-is");
        assert!(
            !m.has_pending_scroll_changes(),
            "a NaN write does not trip the dirty flag (NaN comparisons are false)"
        );
        // But a later re-registration re-clamps it back to a finite value.
        m.register_or_update_scroll_node(
            DOM,
            node(0),
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 500.0),
            at(2),
            16.0,
            16.0,
            false,
            true,
        );
        let off = m.get_current_offset(DOM, node(0)).unwrap();
        assert!(!off.x.is_nan() && !off.y.is_nan(), "re-clamp must sanitize NaN");
    }
    // ================================================== scroll_to / scroll_by
    // (numeric + animation lifecycle)
    #[test]
    fn scroll_to_zero_duration_is_immediate_for_both_clock_kinds() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.scroll_to(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(1));
        assert!(!m.has_active_animations(), "zero Tick duration must not animate");
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 100.0)));
        m.scroll_to(
            DOM,
            node(0),
            pos(0.0, 200.0),
            sys_dur(0, 0),
            EasingFunction::EaseOut,
            at(2),
        );
        assert!(!m.has_active_animations(), "zero System duration must not animate");
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
    }
    #[test]
    fn scroll_to_clamps_the_animation_target_not_just_the_final_offset() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.scroll_to(DOM, node(0), pos(0.0, 1e9), tick_dur(100), EasingFunction::Linear, at(0));
        let anim_target = m
            .get_scroll_state(DOM, node(0))
            .and_then(|s| s.animation.as_ref())
            .map(|a| a.target_offset)
            .unwrap();
        assert_eq!(anim_target, pos(0.0, 400.0), "target must be pre-clamped");
        // Drive it to completion: the offset lands exactly on the clamped target.
        let r = m.tick(at(100));
        assert!(r.needs_repaint);
        assert_eq!(r.updated_nodes, vec![(DOM, node(0))]);
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
        assert!(!m.has_active_animations(), "animation must clear at t >= 1");
    }
    #[test]
    fn scroll_to_nan_target_animates_to_the_origin_never_to_nan() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position(DOM, node(0), pos(0.0, 200.0), at(0));
        m.scroll_to(
            DOM,
            node(0),
            pos(f32::NAN, f32::NAN),
            tick_dur(10),
            EasingFunction::Linear,
            at(0),
        );
        m.tick(at(10));
        let off = m.get_current_offset(DOM, node(0)).unwrap();
        assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN target must be clamped away");
        assert_eq!(off, LogicalPosition::zero());
    }
    #[test]
    fn scroll_by_accumulates_from_the_current_offset_and_saturates() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.scroll_by(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(1));
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 100.0)));
        m.scroll_by(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(2));
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
        // A delta big enough to overflow f32 arithmetic: saturates at max travel.
        m.scroll_by(
            DOM,
            node(0),
            pos(f32::MAX, f32::MAX),
            tick_dur(0),
            EasingFunction::Linear,
            at(3),
        );
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
        // ...and back down past the origin.
        m.scroll_by(
            DOM,
            node(0),
            pos(f32::MIN, f32::MIN),
            tick_dur(0),
            EasingFunction::Linear,
            at(4),
        );
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(LogicalPosition::zero()));
    }
    #[test]
    fn scroll_by_on_an_unknown_node_defaults_to_origin_and_stays_pinned() {
        let mut m = ScrollManager::new();
        m.scroll_by(
            DOM,
            node(7),
            pos(1e9, 1e9),
            tick_dur(0),
            EasingFunction::Linear,
            at(1),
        );
        // No bounds registered => max travel 0 => still at the origin, no panic.
        assert_eq!(m.get_current_offset(DOM, node(7)), Some(LogicalPosition::zero()));
    }
    #[test]
    fn scroll_by_nan_delta_does_not_poison_the_offset() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position(DOM, node(0), pos(0.0, 100.0), at(0));
        m.scroll_by(
            DOM,
            node(0),
            pos(f32::NAN, f32::NAN),
            tick_dur(0),
            EasingFunction::Linear,
            at(1),
        );
        let off = m.get_current_offset(DOM, node(0)).unwrap();
        assert!(!off.x.is_nan() && !off.y.is_nan());
        assert_eq!(off, LogicalPosition::zero(), "NaN target clamps to origin");
    }
    // =============================================================== tick()
    // (other: no_panic_smoke + animation invariants)
    #[test]
    fn tick_on_an_empty_manager_reports_no_work() {
        let mut m = ScrollManager::new();
        let r = m.tick(at(1));
        assert!(!r.needs_repaint);
        assert!(r.updated_nodes.is_empty());
    }
    #[test]
    fn tick_interpolates_linearly_and_completes_exactly_once() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(100), EasingFunction::Linear, at(0));
        let r = m.tick(at(50));
        assert!(r.needs_repaint);
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
        assert!(m.has_active_animations(), "still mid-flight at t = 0.5");
        let r = m.tick(at(100));
        assert!(r.needs_repaint);
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
        assert!(!m.has_active_animations());
        // Ticking past the end must be a no-op, not a re-run.
        let r = m.tick(at(500));
        assert!(!r.needs_repaint);
        assert!(r.updated_nodes.is_empty());
    }
    #[test]
    fn tick_before_the_animation_start_time_saturates_to_zero_progress() {
        // `now` earlier than `start_time` => duration_since saturates to 0 =>
        // t = 0 => offset stays at start. No negative-progress overshoot.
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(0));
        m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(100), EasingFunction::Linear, at(100));
        m.tick(at(0)); // clock went backwards
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
        assert!(m.has_active_animations(), "no progress => still animating");
    }
    #[test]
    fn tick_with_a_zero_duration_animation_completes_instead_of_producing_nan() {
        // 0/0 = NaN, but `NaN.min(1.0)` == 1.0 in Rust, so the animation snaps to
        // its target and is cleared — the offset never becomes NaN. (scroll_to
        // short-circuits zero durations; this covers a hand-built animation, e.g.
        // one whose duration was computed to zero.)
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.states.get_mut(&(DOM, node(0))).unwrap().animation = Some(ScrollAnimation {
            start_time: at(0),
            duration: tick_dur(0),
            start_offset: pos(0.0, 0.0),
            target_offset: pos(0.0, 300.0),
            easing: EasingFunction::Linear,
        });
        let r = m.tick(at(0));
        assert!(r.needs_repaint);
        let off = m.get_current_offset(DOM, node(0)).unwrap();
        assert!(!off.y.is_nan(), "0/0 must not leak NaN into the offset");
        assert_eq!(off, pos(0.0, 300.0));
        assert!(!m.has_active_animations());
    }
    #[test]
    fn tick_with_a_mismatched_clock_kind_stalls_at_zero_instead_of_panicking() {
        // Tick-clock animation ticked by a System INSTANT. A System instant and a
        // Tick instant have no common origin, so `duration_since` has no
        // meaningful span to report and saturates to zero => t = 0 forever. The
        // animation never advances and never completes — but it does not panic or
        // corrupt the offset.
        //
        // Note this is specifically an INSTANT mismatch. A mismatch between the
        // elapsed DURATION's unit and the animation duration's unit is a
        // different thing entirely and does convert: `Duration::div` puts both on
        // a canonical nanosecond scale.
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position(DOM, node(0), pos(0.0, 25.0), at(0));
        m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(10), EasingFunction::Linear, at(0));
        let r = m.tick(Instant::now()); // System clock vs Tick animation
        assert!(r.needs_repaint);
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 25.0)));
        assert!(
            m.has_active_animations(),
            "mismatched clocks stall the animation (t stays 0) — it never completes"
        );
    }
    #[test]
    fn tick_advances_every_animating_node_in_one_pass() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.register_or_update_scroll_node(
            DOM,
            node(1),
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 300.0),
            at(0),
            16.0,
            16.0,
            false,
            true,
        );
        m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(10), EasingFunction::Linear, at(0));
        m.scroll_to(DOM, node(1), pos(0.0, 200.0), tick_dur(10), EasingFunction::Linear, at(0));
        let r = m.tick(at(10));
        assert_eq!(r.updated_nodes.len(), 2);
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
        assert_eq!(m.get_current_offset(DOM, node(1)), Some(pos(0.0, 200.0)));
    }
    // ============================================== register_or_update_scroll_node
    // (numeric: nan_inf / zero / min_max + no unbounded growth)
    #[test]
    fn register_twice_updates_in_place_and_keeps_the_offset() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position(DOM, node(0), pos(0.0, 300.0), at(1));
        m.register_or_update_scroll_node(
            DOM,
            node(0),
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 500.0),
            at(2),
            16.0,
            16.0,
            false,
            true,
        );
        assert_eq!(m.debug_counts(), (1, 0), "re-register must not grow the map");
        assert_eq!(
            m.get_current_offset(DOM, node(0)),
            Some(pos(0.0, 300.0)),
            "an existing node keeps its scroll offset across relayout"
        );
    }
    #[test]
    fn re_registering_with_shrunken_content_re_clamps_the_offset() {
        // The classic resize bug: content shrinks under a scrolled-to-bottom node.
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
        m.register_or_update_scroll_node(
            DOM,
            node(0),
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 150.0), // content shrank: max_y is now 50
            at(2),
            16.0,
            16.0,
            false,
            true,
        );
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
    }
    #[test]
    fn register_with_non_finite_geometry_does_not_panic_or_leak_nan() {
        let mut m = ScrollManager::new();
        m.register_or_update_scroll_node(
            DOM,
            node(0),
            rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
            size(f32::NAN, f32::NAN),
            at(0),
            f32::NAN,
            f32::NAN,
            true,
            true,
        );
        let off = m.get_current_offset(DOM, node(0)).unwrap();
        assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN geometry must clamp to 0");
        assert_eq!(off, LogicalPosition::zero());
        assert!(!m.is_node_scrollable(DOM, node(0)), "NaN overflow check is false");
        m.register_or_update_scroll_node(
            DOM,
            node(1),
            rect(0.0, 0.0, f32::INFINITY, f32::INFINITY),
            size(f32::INFINITY, f32::INFINITY),
            at(0),
            f32::MAX,
            f32::MAX,
            true,
            true,
        );
        let off = m.get_current_offset(DOM, node(1)).unwrap();
        assert!(!off.x.is_nan() && !off.y.is_nan());
        assert_eq!(m.debug_counts(), (2, 0));
    }
    #[test]
    fn register_with_zero_sized_geometry_yields_a_non_scrollable_pinned_node() {
        let mut m = ScrollManager::new();
        m.register_or_update_scroll_node(
            DOM,
            node(0),
            LogicalRect::zero(),
            LogicalSize::zero(),
            at(0),
            0.0,
            0.0,
            false,
            false,
        );
        assert!(!m.is_node_scrollable(DOM, node(0)));
        assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
        let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
        assert_eq!(info.max_scroll_x, 0.0);
        assert_eq!(info.max_scroll_y, 0.0);
    }
    // ===================================================== is_node_scrollable
    // (predicate: basic_true_false / edge_inputs)
    #[test]
    fn is_node_scrollable_is_strict_overflow_not_equality() {
        let mut m = ScrollManager::new();
        // Content exactly equal to the container: NOT scrollable (strict `>`).
        m.register_or_update_scroll_node(
            DOM,
            node(0),
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 100.0),
            at(0),
            16.0,
            16.0,
            false,
            false,
        );
        assert!(!m.is_node_scrollable(DOM, node(0)));
        // One extra pixel of height => scrollable.
        m.register_or_update_scroll_node(
            DOM,
            node(1),
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 100.1),
            at(0),
            16.0,
            16.0,
            false,
            true,
        );
        assert!(m.is_node_scrollable(DOM, node(1)));
        // Unknown node / unknown DOM => false, never a panic.
        assert!(!m.is_node_scrollable(DOM, node(999)));
        assert!(!m.is_node_scrollable(DOM1, node(1)));
    }
    #[test]
    fn is_node_scrollable_uses_the_virtual_size_when_present() {
        let mut m = ScrollManager::new();
        // Rendered content is tiny (only the visible slice), virtual content is huge.
        m.register_or_update_scroll_node(
            DOM,
            node(0),
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 50.0),
            at(0),
            16.0,
            16.0,
            false,
            true,
        );
        assert!(!m.is_node_scrollable(DOM, node(0)));
        m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 100_000.0), None);
        assert!(
            m.is_node_scrollable(DOM, node(0)),
            "a VirtualView with a large virtual size must be scrollable"
        );
    }
    // ======================================================= can_consume_delta
    // (predicate: boundary / nan)
    #[test]
    fn can_consume_delta_respects_the_half_pixel_deadzone() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0)); // max_y = 400
        m.set_scroll_position(DOM, node(0), pos(0.0, 200.0), at(1));
        // |eff| <= EPS (0.5) is "not moved" on that axis.
        assert!(!m.can_consume_delta(DOM, node(0), 0.0, 0.0));
        assert!(!m.can_consume_delta(DOM, node(0), 0.5, 0.5), "exactly EPS is a no-move");
        assert!(!m.can_consume_delta(DOM, node(0), -0.5, -0.5));
        assert!(m.can_consume_delta(DOM, node(0), 0.0, 0.51));
        assert!(m.can_consume_delta(DOM, node(0), 0.0, -0.51));
        // X has no travel at all (content width == container width).
        assert!(!m.can_consume_delta(DOM, node(0), 100.0, 0.0));
    }
    #[test]
    fn can_consume_delta_is_false_at_the_pinned_edges() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        // Pinned at the top: cannot go further up, can go down.
        m.set_scroll_position(DOM, node(0), pos(0.0, 0.0), at(1));
        assert!(!m.can_consume_delta(DOM, node(0), 0.0, -10.0));
        assert!(m.can_consume_delta(DOM, node(0), 0.0, 10.0));
        // Pinned at the bottom: the mirror image.
        m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(2));
        assert!(!m.can_consume_delta(DOM, node(0), 0.0, 10.0));
        assert!(m.can_consume_delta(DOM, node(0), 0.0, -10.0));
    }
    #[test]
    fn can_consume_delta_rejects_nan_and_accepts_infinite_deltas() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position(DOM, node(0), pos(0.0, 200.0), at(1));
        assert!(
            !m.can_consume_delta(DOM, node(0), f32::NAN, f32::NAN),
            "a NaN delta consumes nothing (every comparison is false)"
        );
        assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::INFINITY));
        assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::NEG_INFINITY));
        assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::MAX));
        assert!(!m.can_consume_delta(DOM, node(999), 0.0, f32::MAX), "unknown node");
    }
    // ==================================================== select_scroll_target
    // (numeric: nan_inf / zero + fallback invariants)
    #[test]
    fn select_scroll_target_on_no_candidates_is_none() {
        let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        assert!(m
            .select_scroll_target(core::iter::empty(), 0.0, 10.0)
            .is_none());
        // Candidates that are not scrollable are skipped entirely (no fallback).
        assert!(m
            .select_scroll_target([(DOM, node(50)), (DOM1, node(0))].into_iter(), 0.0, 10.0)
            .is_none());
    }
    #[test]
    fn select_scroll_target_with_zero_or_nan_delta_falls_back_to_the_innermost() {
        // Nothing "can consume" a zero/NaN delta, so the gesture still anchors on
        // the innermost scrollable node rather than being dropped.
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.register_or_update_scroll_node(
            DOM,
            node(9),
            rect(0.0, 0.0, 50.0, 50.0),
            size(50.0, 200.0),
            at(0),
            16.0,
            16.0,
            false,
            true,
        );
        let inner_first = [(DOM, node(9)), (DOM, node(0))];
        assert_eq!(
            m.select_scroll_target(inner_first.into_iter(), 0.0, 0.0),
            Some((DOM, node(9)))
        );
        assert_eq!(
            m.select_scroll_target(inner_first.into_iter(), f32::NAN, f32::NAN),
            Some((DOM, node(9)))
        );
        // An infinite delta IS consumable => also the innermost (it has room).
        assert_eq!(
            m.select_scroll_target(inner_first.into_iter(), 0.0, f32::INFINITY),
            Some((DOM, node(9)))
        );
    }
    // ======================================================= a11y_scroll_info
    // (other: no_panic_smoke)
    #[test]
    fn a11y_scroll_info_reports_travel_only_for_scrollable_nodes() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position(DOM, node(0), pos(0.0, 120.0), at(1));
        let (off, max_x, max_y) = m.a11y_scroll_info(DOM, node(0)).unwrap();
        assert_eq!(off, pos(0.0, 120.0));
        assert_eq!(max_x, 0.0);
        assert_eq!(max_y, 400.0);
        // Non-scrollable node => None (screen readers must not offer scroll actions).
        m.register_or_update_scroll_node(
            DOM,
            node(1),
            rect(0.0, 0.0, 100.0, 100.0),
            size(10.0, 10.0),
            at(0),
            16.0,
            16.0,
            false,
            false,
        );
        assert!(m.a11y_scroll_info(DOM, node(1)).is_none());
        assert!(m.a11y_scroll_info(DOM, node(404)).is_none());
        assert!(m.a11y_scroll_info(DOM1, node(0)).is_none());
    }
    #[test]
    fn a11y_scroll_info_uses_the_virtual_size() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 100.0));
        assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
        m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 1000.0), None);
        let (_, max_x, max_y) = m.a11y_scroll_info(DOM, node(0)).unwrap();
        assert_eq!(max_x, 0.0);
        assert_eq!(max_y, 900.0);
    }
    // ================================================== get_scroll_node_info
    // (other: no_panic_smoke — max_scroll is never negative)
    #[test]
    fn get_scroll_node_info_max_scroll_is_never_negative() {
        let m = mgr(size(500.0, 500.0), size(10.0, 10.0));
        let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
        assert_eq!(info.max_scroll_x, 0.0, "underflow must clamp to 0, not go negative");
        assert_eq!(info.max_scroll_y, 0.0);
        assert_eq!(info.current_offset, LogicalPosition::zero());
        assert!(m.get_scroll_node_info(DOM, node(1)).is_none());
    }
    #[test]
    fn get_scroll_node_info_prefers_the_virtual_size_for_max_travel() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 200.0));
        assert_eq!(m.get_scroll_node_info(DOM, node(0)).unwrap().max_scroll_y, 100.0);
        m.update_virtual_scroll_bounds(DOM, node(0), size(600.0, 5000.0), Some(pos(1.0, 2.0)));
        let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
        assert_eq!(info.max_scroll_x, 500.0);
        assert_eq!(info.max_scroll_y, 4900.0);
        // content_rect is still the *rendered* rect — the virtual size only moves
        // the bounds, it does not rewrite the layout geometry.
        assert_eq!(info.content_rect.size, size(100.0, 200.0));
    }
    // ============================================ update_virtual_scroll_bounds
    // (numeric: nan_inf / zero + implicit state creation)
    #[test]
    fn update_virtual_scroll_bounds_creates_a_state_for_an_unknown_node() {
        let mut m = ScrollManager::new();
        m.update_virtual_scroll_bounds(DOM, node(3), size(100.0, 9000.0), Some(pos(0.0, 4.0)));
        assert_eq!(m.debug_counts(), (1, 0));
        let s = m.get_scroll_state(DOM, node(3)).unwrap();
        assert_eq!(s.virtual_scroll_size, Some(size(100.0, 9000.0)));
        assert_eq!(s.virtual_scroll_offset, Some(pos(0.0, 4.0)));
        assert_eq!(s.current_offset, LogicalPosition::zero());
        // Container is still zero-sized, so all 9000px are reachable.
        assert!(m.is_node_scrollable(DOM, node(3)));
    }
    #[test]
    fn update_virtual_scroll_bounds_re_clamps_a_shrinking_virtual_size() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 100.0));
        m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 5000.0), None);
        m.set_scroll_position(DOM, node(0), pos(0.0, 4900.0), at(1));
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 4900.0)));
        // The VirtualView shrinks (rows removed): the offset must follow it down.
        m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 300.0), None);
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
    }
    #[test]
    fn update_virtual_scroll_bounds_with_non_finite_size_does_not_panic() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
        m.update_virtual_scroll_bounds(DOM, node(0), size(f32::NAN, f32::NAN), None);
        let off = m.get_current_offset(DOM, node(0)).unwrap();
        assert!(!off.x.is_nan() && !off.y.is_nan());
        assert_eq!(off, LogicalPosition::zero(), "NaN virtual size => zero travel");
        assert!(!m.is_node_scrollable(DOM, node(0)));
        m.update_virtual_scroll_bounds(DOM, node(0), size(0.0, f32::INFINITY), None);
        let off = m.get_current_offset(DOM, node(0)).unwrap();
        assert!(!off.y.is_nan(), "infinite virtual height must not produce NaN");
    }
    // ====================================================== update_node_bounds
    // (numeric: zero / negative / overflow / nan)
    #[test]
    fn update_node_bounds_creates_the_state_and_re_clamps_a_shrinking_content() {
        let mut m = ScrollManager::new();
        // Unknown node: the entry API materializes it at the scroll origin.
        m.update_node_bounds(
            DOM,
            node(0),
            rect(0.0, 0.0, 100.0, 100.0),
            rect(0.0, 0.0, 100.0, 500.0),
            at(0),
        );
        assert_eq!(m.debug_counts(), (1, 0));
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(LogicalPosition::zero()));
        m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
        m.clear_scroll_dirty();
        // Content shrinks under a bottomed-out scroll: the offset must follow.
        m.update_node_bounds(
            DOM,
            node(0),
            rect(0.0, 0.0, 100.0, 100.0),
            rect(0.0, 0.0, 100.0, 150.0),
            at(2),
        );
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
        // NOTE: the forced re-clamp moved the offset by 350px but did NOT set the
        // dirty flag (unlike set_scroll_position) — pinning the real behavior.
        assert!(!m.has_pending_scroll_changes());
    }
    #[test]
    fn update_node_bounds_ignores_the_content_rect_origin() {
        // clamp() only reads `size`, so a content rect translated far away must
        // not shift the reachable travel.
        let mut m = ScrollManager::new();
        m.update_node_bounds(
            DOM,
            node(0),
            rect(0.0, 0.0, 100.0, 100.0),
            rect(999.0, 999.0, 100.0, 500.0),
            at(0),
        );
        m.set_scroll_position(DOM, node(0), pos(1e9, 1e9), at(1));
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
    }
    #[test]
    fn update_node_bounds_with_non_finite_rects_does_not_leak_nan() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
        m.update_node_bounds(
            DOM,
            node(0),
            rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
            rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
            at(2),
        );
        let off = m.get_current_offset(DOM, node(0)).unwrap();
        assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN bounds must clamp to 0");
        assert_eq!(off, LogicalPosition::zero());
        // Infinite content: the offset stays finite (clamped to the old value).
        m.update_node_bounds(
            DOM,
            node(0),
            rect(0.0, 0.0, 100.0, 100.0),
            rect(0.0, 0.0, f32::INFINITY, f32::INFINITY),
            at(3),
        );
        let off = m.get_current_offset(DOM, node(0)).unwrap();
        assert!(off.x.is_finite() && off.y.is_finite());
    }
    // ============================================ get_scroll_states_for_dom /
    //                                              build_scroll_offset_map
    // (other: no_panic_smoke + DOM isolation)
    #[test]
    fn get_scroll_states_for_dom_filters_by_dom_and_reports_the_live_offset() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position(DOM, node(0), pos(0.0, 42.0), at(1));
        m.register_or_update_scroll_node(
            DOM1,
            node(0),
            rect(0.0, 0.0, 10.0, 10.0),
            size(10.0, 100.0),
            at(0),
            16.0,
            16.0,
            false,
            true,
        );
        let states = m.get_scroll_states_for_dom(DOM);
        assert_eq!(states.len(), 1, "other DOMs must not leak in");
        let sp = states.get(&node(0)).unwrap();
        assert_eq!(sp.parent_rect, rect(0.0, 0.0, 100.0, 100.0));
        assert_eq!(sp.children_rect.origin, pos(0.0, 42.0));
        assert_eq!(sp.children_rect.size, size(100.0, 500.0));
        // A DOM with no registered nodes returns an empty map, not a panic.
        assert!(m.get_scroll_states_for_dom(DomId { inner: 99 }).is_empty());
    }
    #[test]
    fn get_scroll_states_for_dom_uses_the_virtual_size_as_children_rect() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 120.0));
        m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 8000.0), None);
        let states = m.get_scroll_states_for_dom(DOM);
        assert_eq!(states.get(&node(0)).unwrap().children_rect.size, size(100.0, 8000.0));
    }
    /// CONVENTION PIN, end to end: `children_rect.origin` is the raw scroll
    /// offset in a space of its own, and `parent_rect.origin` is an absolute
    /// window coordinate. Every fixture above registers its container at
    /// (0, 0), where the two candidate conventions are indistinguishable —
    /// this one puts the container where the AzWriter document view actually
    /// sits (below the ribbon, indented from the left) and drives the reported
    /// offset through the SAME geometry function the scrollbar painter and the
    /// GPU-only scroll path use.
    ///
    /// The consumers used to compute `children_rect.origin - parent_rect.origin`.
    /// For the vertical leg below that made an UNSCROLLED container report
    /// -120, which `compute_thumb_geometry` then took by absolute value (an
    /// `.abs()` since removed — see
    /// `a_negative_overscroll_offset_pins_the_thumb_to_the_start_of_the_track`)
    /// — +120 is 15% of the 800px scroll range, so the thumb opened 38.4px
    /// down its own track instead of at the top.
    #[test]
    fn scroll_states_of_a_container_below_the_window_origin_drive_the_thumb_from_zero() {
        use crate::solver3::scrollbar::{
            compute_scrollbar_geometry_with_button_size, ScrollbarGeometry,
        };
        const THICKNESS: f32 = 16.0;
        // A 300x400 viewport at absolute (250, 120) over 900x1200 of content:
        // max scroll is (600, 800) on the two axes.
        let mut m = ScrollManager::new();
        m.register_or_update_scroll_node(
            DOM,
            node(0),
            rect(250.0, 120.0, 300.0, 400.0),
            size(900.0, 1200.0),
            at(0),
            THICKNESS,
            THICKNESS,
            true,
            true,
        );
        // Exactly what `paint_scrollbars` does: read the state, take the offset
        // straight out of `children_rect.origin`, feed it to the shared geometry.
        fn geom(
            m: &ScrollManager,
            expected_offset: LogicalPosition,
            orientation: ScrollbarOrientation,
        ) -> ScrollbarGeometry {
            let sp = *m.get_scroll_states_for_dom(DOM).get(&node(0)).unwrap();
            assert_eq!(
                sp.parent_rect.origin,
                pos(250.0, 120.0),
                "parent_rect keeps the ABSOLUTE container position"
            );
            assert_eq!(
                sp.children_rect.origin, expected_offset,
                "children_rect.origin is the raw offset, never container-relative"
            );
            compute_scrollbar_geometry_with_button_size(
                orientation,
                sp.parent_rect,
                sp.children_rect.size,
                match orientation {
                    ScrollbarOrientation::Vertical => sp.children_rect.origin.y,
                    ScrollbarOrientation::Horizontal => sp.children_rect.origin.x,
                },
                THICKNESS,
                true,
                0.0,
            )
        }
        // --- unscrolled: the thumb sits at the very top / far left ----------
        let v = geom(&m, pos(0.0, 0.0), ScrollbarOrientation::Vertical);
        assert_eq!(v.thumb_offset, 0.0, "was 38.4 with the mixed-origin subtraction");
        let h = geom(&m, pos(0.0, 0.0), ScrollbarOrientation::Horizontal);
        assert_eq!(h.thumb_offset, 0.0, "was ~78.9 with the mixed-origin subtraction");
        // --- half way: half of the thumb's travel ---------------------------
        m.set_scroll_position(DOM, node(0), pos(300.0, 400.0), at(1));
        let v = geom(&m, pos(300.0, 400.0), ScrollbarOrientation::Vertical);
        let expected = (v.usable_track_length - v.thumb_length) * 0.5;
        assert!((v.thumb_offset - expected).abs() < 1e-3, "{v:?}");
        let h = geom(&m, pos(300.0, 400.0), ScrollbarOrientation::Horizontal);
        let expected = (h.usable_track_length - h.thumb_length) * 0.5;
        assert!((h.thumb_offset - expected).abs() < 1e-3, "{h:?}");
        // --- bottomed out: the thumb ends flush with the track --------------
        m.set_scroll_position(DOM, node(0), pos(9_999.0, 9_999.0), at(2));
        let v = geom(&m, pos(600.0, 800.0), ScrollbarOrientation::Vertical);
        assert!(
            (v.thumb_offset - (v.usable_track_length - v.thumb_length)).abs() < 1e-3,
            "{v:?}"
        );
        let h = geom(&m, pos(600.0, 800.0), ScrollbarOrientation::Horizontal);
        assert!(
            (h.thumb_offset - (h.usable_track_length - h.thumb_length)).abs() < 1e-3,
            "{h:?}"
        );
    }
    #[test]
    fn build_scroll_offset_map_only_emits_nodes_present_in_scroll_ids() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position(DOM, node(0), pos(0.0, 25.0), at(1));
        m.register_or_update_scroll_node(
            DOM,
            node(4),
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 500.0),
            at(0),
            16.0,
            16.0,
            false,
            true,
        );
        m.register_or_update_scroll_node(
            DOM1,
            node(0),
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 500.0),
            at(0),
            16.0,
            16.0,
            false,
            true,
        );
        // Empty id map => empty offset map (and no panic).
        assert!(m.build_scroll_offset_map(DOM, &HashMap::new()).is_empty());
        let mut ids: HashMap<u64, NodeId> = HashMap::new();
        ids.insert(100, node(0)); // scroll id 100 -> DOM node 0
        ids.insert(700, node(7)); // an id for a node that has no scroll state
        let map = m.build_scroll_offset_map(DOM, &ids);
        assert_eq!(map.len(), 1, "node 4 has no scroll_id; DOM1 is a different dom");
        assert_eq!(map.get(&100), Some(&(0.0, 25.0)));
        assert!(!map.contains_key(&700));
    }
    #[test]
    fn build_scroll_offset_map_resolves_a_scroller_whose_layout_index_diverges() {
        // REGRESSION, CPU present path: "the wheel moves the thumb but the
        // content stays frozen".
        //
        // The offset map used to be built by indexing the layout-index-keyed
        // `scroll_ids` table with `node_id.index()`. That holds only for DOMs
        // whose layout tree happens to be index-identical to the DOM — which
        // is exactly what the minimal fixtures above are, and why nothing
        // caught it. A text run ahead of the scroll container makes layout
        // insert anonymous boxes, the indices diverge, the lookup misses, the
        // compositor resolves the layer offset as (0,0) and the scroll-shift
        // machinery sees no delta. The thumb keeps moving because it rides the
        // (DomId, NodeId)-keyed GPU value cache, which never had the bug.
        //
        // The tables below are shaped exactly as `LayoutWindow::compute_scroll_ids`
        // emits them for such a tree: the root scroller is DOM node 0 at layout
        // index 0, the content scroller is DOM node 2 sitting at layout index 5.
        let mut m = mgr(size(200.0, 200.0), size(200.0, 400.0));
        m.register_or_update_scroll_node(
            DOM,
            node(2),
            rect(0.0, 0.0, 200.0, 200.0),
            size(200.0, 2000.0),
            at(0),
            16.0,
            16.0,
            false,
            true,
        );
        m.set_scroll_position(DOM, node(2), pos(0.0, 300.0), at(1));
        let mut scroll_ids: HashMap<LayoutNodeId, u64> = HashMap::new();
        scroll_ids.insert(LayoutNodeId::new(0), 100);
        scroll_ids.insert(LayoutNodeId::new(5), 500);
        let mut scroll_id_to_node_id: HashMap<u64, NodeId> = HashMap::new();
        scroll_id_to_node_id.insert(100, node(0));
        scroll_id_to_node_id.insert(500, node(2));
        // The premise, asserted rather than assumed: for this tree the DOM
        // NodeId is NOT a valid key into the layout-index table. Note the
        // deliberate cross-space cast: since `LayoutNodeId` landed, this
        // probe is the ONLY way to even ask the question — the conflation
        // this test guards against no longer type-checks in production code.
        assert!(
            !scroll_ids.contains_key(&LayoutNodeId::new(node(2).index())),
            "fixture must actually diverge, otherwise it re-tests the coinciding case"
        );
        let map = m.build_scroll_offset_map(DOM, &scroll_id_to_node_id);
        assert_eq!(
            map.get(&500),
            Some(&(0.0, 300.0)),
            "the scrolled container must reach the renderer, or its content freezes"
        );
        assert_eq!(map.get(&100), Some(&(0.0, 0.0)), "the unscrolled root still reports (0,0)");
    }
    // ====================================================== find_scroll_parent
    // (other: no_panic_smoke)
    #[test]
    fn find_scroll_parent_walks_up_to_the_nearest_registered_ancestor() {
        // hierarchy: 0 (root) <- 1 <- 2  (parent field is 1-based encoded)
        let hierarchy = [
            NodeHierarchyItem { parent: 0, previous_sibling: 0, next_sibling: 0, last_child: 2 },
            NodeHierarchyItem { parent: 1, previous_sibling: 0, next_sibling: 0, last_child: 3 },
            NodeHierarchyItem { parent: 2, previous_sibling: 0, next_sibling: 0, last_child: 0 },
        ];
        let m = mgr(size(100.0, 100.0), size(100.0, 500.0)); // node 0 registered
        assert_eq!(
            m.find_scroll_parent(DOM, node(2), &hierarchy),
            Some(node(0)),
            "must skip the unregistered node 1 and find the root scroll container"
        );
        // The node itself is excluded even though it IS registered.
        assert_eq!(m.find_scroll_parent(DOM, node(0), &hierarchy), None);
        // No scroll container anywhere in this DOM.
        assert_eq!(m.find_scroll_parent(DOM1, node(2), &hierarchy), None);
    }
    #[test]
    fn find_scroll_parent_handles_empty_and_out_of_range_hierarchies() {
        let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        // Empty slice: the very first `get()` misses => None, no index panic.
        assert_eq!(m.find_scroll_parent(DOM, node(0), &[]), None);
        assert_eq!(m.find_scroll_parent(DOM, node(9999), &[]), None);
        // Node id past the end of the hierarchy: still no panic.
        let hierarchy = [NodeHierarchyItem::zeroed()];
        assert_eq!(m.find_scroll_parent(DOM, node(9999), &hierarchy), None);
    }
    // ============================================== calculate_scrollbar_states
    // (other: no_panic_smoke + no unbounded growth)
    #[test]
    fn calculate_scrollbar_states_is_idempotent_and_only_for_overflowing_axes() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.calculate_scrollbar_states();
        assert_eq!(m.debug_counts(), (1, 1), "only the vertical axis overflows");
        assert!(m
            .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
            .is_some());
        assert!(m
            .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Horizontal)
            .is_none());
        // Re-running each frame must clear first — otherwise the map grows forever.
        for _ in 0..10 {
            m.calculate_scrollbar_states();
        }
        assert_eq!(m.debug_counts(), (1, 1), "per-frame recompute must not accumulate");
        assert_eq!(m.iter_scrollbar_states().count(), 1);
    }
    #[test]
    fn calculate_scrollbar_states_drops_bars_once_the_content_fits() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.calculate_scrollbar_states();
        assert_eq!(m.debug_counts().1, 1);
        // Relayout: content now fits => the scrollbar must disappear.
        m.register_or_update_scroll_node(
            DOM,
            node(0),
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 50.0),
            at(1),
            16.0,
            16.0,
            false,
            false,
        );
        m.calculate_scrollbar_states();
        assert_eq!(m.debug_counts().1, 0);
        assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
    }
    #[test]
    fn calculate_scrollbar_states_produces_finite_geometry_for_both_axes() {
        let mut m = ScrollManager::new();
        m.register_or_update_scroll_node(
            DOM,
            node(0),
            rect(0.0, 0.0, 100.0, 100.0),
            size(1000.0, 1000.0),
            at(0),
            16.0,
            16.0,
            true,
            true,
        );
        m.calculate_scrollbar_states();
        assert_eq!(m.debug_counts(), (1, 2), "both axes overflow");
        for (_, sb) in m.iter_scrollbar_states() {
            assert!(sb.visible);
            assert!(sb.base_size.is_finite() && sb.base_size > 0.0);
            assert!(sb.scale.x.is_finite() && sb.scale.y.is_finite());
            assert!(sb.thumb_length.is_finite());
            assert!(sb.thumb_offset.is_finite());
            assert!(sb.usable_track_length.is_finite());
            assert!(sb.track_rect.size.width.is_finite());
            assert!(sb.track_rect.size.height.is_finite());
        }
    }
    #[test]
    fn calculate_scrollbar_states_zero_thickness_falls_back_to_the_default_width() {
        // An overlay scrollbar reports thickness 0 from layout; the geometry must
        // still divide by a non-zero width (otherwise `scale` becomes inf/NaN).
        let mut m = ScrollManager::new();
        m.register_or_update_scroll_node(
            DOM,
            node(0),
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 400.0),
            at(0),
            0.0, // scrollbar_thickness (overlay)
            0.0, // visual_width_px
            false,
            true,
        );
        m.calculate_scrollbar_states();
        let sb = m
            .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
            .unwrap();
        assert_eq!(sb.base_size, crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX);
        assert_eq!(sb.button_size, 0.0, "overlay scrollbars have no arrow buttons");
        assert!(sb.scale.x.is_finite() && sb.scale.y.is_finite(), "no div-by-zero");
    }
    #[test]
    fn calculate_scrollbar_state_from_geometry_survives_nan_input() {
        let mut s = state(size(f32::NAN, f32::NAN), size(f32::NAN, f32::NAN));
        s.scrollbar_thickness = f32::NAN;
        s.visual_width_px = f32::NAN;
        // `NaN > 0.0` is false for both width sources, so it falls back to the
        // default width instead of dividing by NaN.
        let sb = ScrollManager::calculate_scrollbar_state_from_geometry(
            &s,
            ScrollbarOrientation::Vertical,
        );
        assert!(sb.visible);
        assert_eq!(sb.base_size, crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX);
        // `.max(0.0)` rescues every length: NaN geometry degrades to a zero-length
        // thumb on a zero-length track rather than propagating NaN.
        assert_eq!(sb.usable_track_length, 0.0);
        assert_eq!(sb.thumb_length, 0.0);
        assert_eq!(sb.thumb_offset, 0.0);
        assert_eq!(sb.thumb_position_ratio, 0.0);
        // The lengths are safe, but `scale` divides the (NaN) track height by the
        // thickness with no rescue — a NaN scale reaches the render transform.
        assert!(
            sb.scale.y.is_nan(),
            "NaN container height still leaks into ScrollbarState::scale"
        );
        // Hit-testing such a bar is still total: y < button_size wins first.
        assert_eq!(
            sb.hit_test_component(pos(0.0, 5.0)),
            ScrollbarComponent::TopButton
        );
    }
    // ================================== hit_test_scrollbar / hit_test_scrollbars
    // (numeric: zero / negative / nan_inf)
    #[test]
    fn hit_test_scrollbars_finds_the_vertical_bar_and_reports_local_coords() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.calculate_scrollbar_states();
        // Track is the right-hand 16px strip: origin.x = 100 - 16 = 84.
        let hit = m.hit_test_scrollbars(pos(90.0, 50.0)).expect("inside the track");
        assert_eq!(hit.dom_id, DOM);
        assert_eq!(hit.node_id, node(0));
        assert_eq!(hit.orientation, ScrollbarOrientation::Vertical);
        assert_eq!(hit.global_position, pos(90.0, 50.0));
        assert_eq!(hit.local_position, pos(6.0, 50.0), "local = global - track origin");
        // Just outside the track (content area) => no hit.
        assert!(m.hit_test_scrollbars(pos(10.0, 50.0)).is_none());
        // Same answer through the node-targeted entry point.
        let hit2 = m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).unwrap();
        assert_eq!(hit2.local_position, hit.local_position);
        assert_eq!(hit2.component, hit.component);
        assert!(m.hit_test_scrollbar(DOM, node(1), pos(90.0, 50.0)).is_none());
    }
    #[test]
    fn hit_test_scrollbars_rejects_non_finite_and_out_of_range_positions() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.calculate_scrollbar_states();
        for p in [
            pos(f32::NAN, f32::NAN),
            pos(f32::INFINITY, f32::INFINITY),
            pos(f32::NEG_INFINITY, f32::NEG_INFINITY),
            pos(f32::MAX, f32::MAX),
            pos(f32::MIN, f32::MIN),
            pos(-1.0, -1.0),
            pos(0.0, 0.0),
        ] {
            assert!(
                m.hit_test_scrollbars(p).is_none(),
                "position {p:?} must not hit the 84..100 x 0..100 track"
            );
            assert!(m.hit_test_scrollbar(DOM, node(0), p).is_none());
        }
    }
    #[test]
    fn hit_test_scrollbars_before_calculate_returns_none() {
        // The states map is only filled by calculate_scrollbar_states(); querying
        // first must be a clean miss, not a stale hit.
        let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
        assert!(m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).is_none());
    }
    #[test]
    fn hit_test_scrollbars_skips_invisible_bars() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.calculate_scrollbar_states();
        m.scrollbar_states
            .get_mut(&(DOM, node(0), ScrollbarOrientation::Vertical))
            .unwrap()
            .visible = false;
        assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
        assert!(m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).is_none());
    }
    // ========================================================= input recording
    // (record_scroll_input / record_scroll_from_hit_test)
    #[test]
    fn record_scroll_input_reports_start_timer_only_on_the_first_pending_event() {
        let mut m = ScrollManager::new();
        assert!(m.record_scroll_input(input(0.0, 1.0, 1)), "queue was empty => start");
        assert!(!m.record_scroll_input(input(0.0, 1.0, 2)), "timer already running");
        let _ = m.get_input_queue().take_all();
        assert!(m.record_scroll_input(input(0.0, 1.0, 3)), "drained => start again");
    }
    #[test]
    fn record_scroll_input_applies_the_sign_to_extreme_deltas_without_overflow() {
        let mut m = ScrollManager::new();
        m.record_scroll_input(input(f32::MAX, f32::INFINITY, 1));
        m.record_scroll_input(input(f32::NAN, f32::MIN, 2));
        let q = m.get_input_queue().take_all();
        assert_eq!(q[0].delta.x, -f32::MAX, "sign flip must not overflow");
        assert_eq!(q[0].delta.y, f32::NEG_INFINITY);
        assert!(q[1].delta.x.is_nan(), "NaN * -1 stays NaN, no panic");
        assert_eq!(q[1].delta.y, f32::MAX);
    }
    #[test]
    fn record_scroll_from_hit_test_records_the_wheel_delta_even_with_no_hover() {
        // The wheel-as-zoom widgets (e.g. the map) depend on pending_wheel_event
        // being set unconditionally — before the hit-test lookup can bail out.
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        let hover = HoverManager::new(); // no hit-test recorded at all
        let out = m.record_scroll_from_hit_test_test_shim(
            3.0,
            -7.0,
            ScrollInputSource::WheelDiscrete,
            &hover,
            &InputPointId::Mouse,
            at(1),
        );
        assert!(out.is_none(), "no hover => no scroll target");
        assert_eq!(m.pending_wheel_event, Some(pos(3.0, -7.0)), "raw delta is recorded");
        assert!(!m.get_input_queue().has_pending(), "nothing queued for physics");
    }
    #[test]
    fn record_scroll_from_hit_test_queues_the_raw_delta_and_signals_the_timer() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        let hover = hover_over(&[0]);
        let (dom_id, node_id, start_timer) = m
            .record_scroll_from_hit_test_test_shim(
                0.0,
                -10.0, // raw "wheel down" under the traditional sign
                ScrollInputSource::WheelDiscrete,
                &hover,
                &InputPointId::Mouse,
                at(1),
            )
            .expect("node 0 is scrollable and under the cursor");
        assert_eq!((dom_id, node_id), (DOM, node(0)));
        assert!(start_timer, "first queued input must start the physics timer");
        assert_eq!(m.pending_wheel_event, Some(pos(0.0, -10.0)));
        // A second event while the queue is still pending must NOT re-start it.
        let (_, _, start_again) = m
            .record_scroll_from_hit_test_test_shim(
                0.0,
                -10.0,
                ScrollInputSource::WheelDiscrete,
                &hover,
                &InputPointId::Mouse,
                at(2),
            )
            .unwrap();
        assert!(!start_again);
        let q = m.get_input_queue().take_all();
        assert_eq!(q.len(), 2);
        // scroll_sign() is applied exactly once, in record_scroll_input.
        assert_eq!(q[0].delta.y, 10.0, "raw -10 * traditional sign (-1) = +10");
        assert_eq!(q[0].source, ScrollInputSource::WheelDiscrete);
        assert_eq!(q[0].timestamp, at(1));
    }
    #[test]
    fn record_scroll_from_hit_test_ignores_hovered_nodes_that_cannot_scroll() {
        let mut m = ScrollManager::new();
        // Registered, but the content fits => not scrollable.
        m.register_or_update_scroll_node(
            DOM,
            node(0),
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 100.0),
            at(0),
            16.0,
            16.0,
            false,
            false,
        );
        let hover = hover_over(&[0]);
        let out = m.record_scroll_from_hit_test_test_shim(
            0.0,
            -10.0,
            ScrollInputSource::WheelDiscrete,
            &hover,
            &InputPointId::Mouse,
            at(1),
        );
        assert!(out.is_none(), "a non-overflowing node must not swallow the wheel");
        assert_eq!(m.pending_wheel_event, Some(pos(0.0, -10.0)));
        assert!(!m.get_input_queue().has_pending());
    }
    #[test]
    fn record_scroll_from_hit_test_with_non_finite_deltas_does_not_panic() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        let hover = hover_over(&[0]);
        // NaN: nothing can consume it, so the innermost scrollable is the fallback.
        let out = m.record_scroll_from_hit_test_test_shim(
            f32::NAN,
            f32::NAN,
            ScrollInputSource::TrackpadContinuous,
            &hover,
            &InputPointId::Mouse,
            at(1),
        );
        assert_eq!(out.map(|(d, n, _)| (d, n)), Some((DOM, node(0))));
        assert!(m.pending_wheel_event.unwrap().x.is_nan());
        let q = m.get_input_queue().take_all();
        assert_eq!(q.len(), 1);
        assert!(q[0].delta.x.is_nan(), "NaN is queued verbatim, no panic");
        // Infinity: consumable (there is room), still queued safely.
        let out = m.record_scroll_from_hit_test_test_shim(
            0.0,
            f32::NEG_INFINITY,
            ScrollInputSource::WheelDiscrete,
            &hover,
            &InputPointId::Mouse,
            at(2),
        );
        assert!(out.is_some());
        let q = m.get_input_queue().take_all();
        assert_eq!(q[0].delta.y, f32::INFINITY, "-inf * -1 = +inf");
    }
    #[test]
    fn record_scroll_from_hit_test_picks_the_innermost_scrollable_under_the_cursor() {
        // Both nodes are hovered; scroll_hit_test_nodes is walked in reverse key
        // order, so the higher (deeper) NodeId wins when it can consume the delta.
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.register_or_update_scroll_node(
            DOM,
            node(5),
            rect(0.0, 0.0, 50.0, 50.0),
            size(50.0, 200.0),
            at(0),
            16.0,
            16.0,
            false,
            true,
        );
        let hover = hover_over(&[0, 5]);
        let (_, node_id, _) = m
            .record_scroll_from_hit_test_test_shim(
                0.0,
                -10.0,
                ScrollInputSource::WheelDiscrete,
                &hover,
                &InputPointId::Mouse,
                at(1),
            )
            .unwrap();
        assert_eq!(node_id, node(5), "innermost (deepest) scrollable wins");
    }
    // ============================================================ getters
    // (get_current_offset / get_last_activity_time / get_scroll_state)
    #[test]
    fn getters_agree_with_the_recorded_state() {
        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
        m.set_scroll_position(DOM, node(0), pos(0.0, 33.0), at(7));
        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 33.0)));
        assert_eq!(m.get_last_activity_time(DOM, node(0)), Some(at(7)));
        let s = m.get_scroll_state(DOM, node(0)).unwrap();
        assert_eq!(s.current_offset, pos(0.0, 33.0));
        assert!(s.animation.is_none());
        // Unknown keys are a clean miss on every getter.
        assert!(m.get_current_offset(DOM1, node(0)).is_none());
        assert!(m.get_last_activity_time(DOM, node(1)).is_none());
        assert!(m.get_scroll_state(DOM1, node(1)).is_none());
    }
    #[test]
    fn get_input_queue_hands_out_a_shared_handle() {
        let mut m = ScrollManager::new();
        let q = m.get_input_queue();
        assert!(!q.has_pending());
        m.record_scroll_input(input(0.0, 1.0, 1));
        assert!(q.has_pending(), "the handle must observe pushes made by the manager");
        assert_eq!(q.take_all().len(), 1);
        assert!(
            !m.get_input_queue().has_pending(),
            "draining the handle drains the manager's queue"
        );
    }
}
#[cfg(all(test, feature = "std"))]
impl ScrollManager {
    /// Test shim: the old 6-arg call shape with `device = TestDriver`.
7
    pub(crate) fn record_scroll_from_hit_test_test_shim(
7
        &mut self,
7
        delta_x: f32,
7
        delta_y: f32,
7
        source: ScrollInputSource,
7
        hover_manager: &crate::managers::hover::HoverManager,
7
        input_point_id: &InputPointId,
7
        now: Instant,
7
    ) -> Option<(DomId, NodeId, bool)> {
7
        self.record_scroll_from_hit_test(
7
            delta_x,
7
            delta_y,
7
            source,
7
            ScrollInputDevice::TestDriver,
7
            hover_manager,
7
            input_point_id,
7
            now,
        )
7
    }
}