Skip to main content

azul_layout/managers/
scroll_state.rs

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//! - **Gesture manager** (gesture.rs): Tracks drag state and emits
15//!   `AutoScrollDirection` — does NOT modify scroll offsets directly.
16//! - **Render loop**: Calls `tick()` every frame to advance easing animations.
17//! - **`WebRender` sync** (`wr_translate2.rs)`: Reads offsets via
18//!   `get_scroll_states_for_dom()` to synchronize scroll frames.
19//! - **Layout** (cache.rs): Registers scroll nodes via
20//!   `register_or_update_scroll_node()` after layout completes.
21//!
22//! # Scroll Flow
23//!
24//! ```text
25//! Platform Event Handler
26//!   → record_scroll_from_hit_test() → ScrollInputQueue
27//!   → starts SCROLL_MOMENTUM_TIMER_ID if not running
28//!
29//! Timer fires (every ~16ms):
30//!   → queue.take_all() → physics integration
31//!   → push_change(CallbackChange::ScrollTo)
32//!
33//! ScrollTo processing (event_v2.rs):
34//!   → scroll_manager.set_scroll_position()
35//!   → virtual_view_manager.check_reinvoke() (transparent VirtualView support)
36//!   → repaint
37//! ```
38//!
39//! This module provides:
40//! - Smooth scroll animations with easing
41//! - Event source classification for scroll events
42//! - Scrollbar geometry and hit-testing
43//! - Virtual scroll bounds for `VirtualView` nodes
44
45use alloc::collections::BTreeMap;
46#[cfg(feature = "std")]
47use alloc::vec::Vec;
48
49use azul_core::{
50    dom::{DomId, NodeId, ScrollbarOrientation},
51    events::EasingFunction,
52    geom::{LogicalPosition, LogicalRect, LogicalSize},
53    hit_test::ScrollPosition,
54    styled_dom::NodeHierarchyItemId,
55    task::{Duration, Instant},
56};
57
58#[cfg(feature = "std")]
59use std::sync::{Arc, Mutex};
60
61use crate::managers::hover::InputPointId;
62use crate::solver3::scrollbar::compute_scrollbar_geometry_with_button_size;
63
64/// Minimum change in scroll offset (in logical pixels) to consider the position
65/// "actually moved" and mark the scroll state dirty.
66const SCROLL_CHANGE_EPSILON: f32 = 0.01;
67
68// ============================================================================
69// Scroll Input Types (for timer-based physics architecture)
70// ============================================================================
71
72/// Classifies the source of a scroll input event.
73///
74/// This determines how the scroll physics timer processes the input:
75/// - `TrackpadContinuous`: The OS already applies momentum — set position directly
76/// - `WheelDiscrete`: Mouse wheel clicks — apply as impulse with momentum decay
77/// - `Programmatic`: API-driven scroll — apply with optional easing animation
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ScrollInputSource {
80    /// Continuous trackpad gesture (macOS precise scrolling).
81    /// Position is set directly — the OS handles momentum/physics.
82    TrackpadContinuous,
83    /// Trackpad gesture ended (fingers lifted off trackpad).
84    /// Triggers spring-back if the scroll position is past the bounds
85    /// (rubber-banding overshoot). The OS sends this when
86    /// `NSEventPhaseEnded` or momentumPhaseEnded is detected.
87    TrackpadEnd,
88    /// Discrete mouse wheel steps (Windows/Linux mouse wheel).
89    /// Applied as velocity impulse with momentum decay.
90    WheelDiscrete,
91    /// Programmatic scroll (scrollTo API, keyboard Page Up/Down).
92    /// Applied with optional easing animation.
93    Programmatic,
94}
95
96/// A single scroll input event to be processed by the physics timer.
97///
98/// Scroll inputs are recorded by the platform event handler and consumed
99/// by the scroll physics timer callback. This decouples input recording
100/// from physics simulation.
101#[derive(Debug, Clone)]
102pub struct ScrollInput {
103    /// DOM containing the scrollable node
104    pub dom_id: DomId,
105    /// Target scroll node
106    pub node_id: NodeId,
107    /// Scroll delta (positive = scroll down/right)
108    pub delta: LogicalPosition,
109    /// When this input was recorded
110    pub timestamp: Instant,
111    /// How this input should be processed
112    pub source: ScrollInputSource,
113}
114
115/// Thread-safe queue for scroll inputs, shared between event handlers and timer callbacks.
116///
117/// Event handlers push inputs, the physics timer pops them. Protected by a Mutex
118/// so that the timer callback (which only has `&CallbackInfo` / `*const LayoutWindow`)
119/// can still consume pending inputs without needing `&mut`.
120#[cfg(feature = "std")]
121#[derive(Debug, Clone, Default)]
122pub struct ScrollInputQueue {
123    inner: Arc<Mutex<Vec<ScrollInput>>>,
124}
125
126#[cfg(feature = "std")]
127impl ScrollInputQueue {
128    #[must_use] pub fn new() -> Self {
129        Self {
130            inner: Arc::new(Mutex::new(Vec::new())),
131        }
132    }
133
134    /// Push a new scroll input (called from platform event handler)
135    pub fn push(&self, input: ScrollInput) {
136        if let Ok(mut queue) = self.inner.lock() {
137            queue.push(input);
138        }
139    }
140
141    /// Take all pending inputs (called from timer callback)
142    #[must_use] pub fn take_all(&self) -> Vec<ScrollInput> {
143        self.inner.lock().map_or_else(
144            |_| Vec::new(),
145            |mut queue| core::mem::take(&mut *queue),
146        )
147    }
148
149    /// Take at most `max_events` recent inputs, sorted by timestamp (newest last).
150    /// Any older events beyond `max_events` are discarded.
151    /// This prevents the physics timer from processing an unbounded backlog.
152    #[must_use] pub fn take_recent(&self, max_events: usize) -> Vec<ScrollInput> {
153        self.inner.lock().map_or_else(
154            |_| Vec::new(),
155            |mut queue| {
156                let mut events = core::mem::take(&mut *queue);
157                if events.len() > max_events {
158                    // Sort by timestamp ascending (oldest first), keep last N
159                    events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
160                    events.drain(..events.len() - max_events);
161                }
162                events
163            },
164        )
165    }
166
167    /// Check if there are pending inputs without consuming them
168    #[must_use] pub fn has_pending(&self) -> bool {
169        self.inner
170            .lock()
171            .map(|q| !q.is_empty())
172            .unwrap_or(false)
173    }
174}
175
176// Scrollbar Component Types
177
178/// Which component of a scrollbar was hit during hit-testing
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
180pub enum ScrollbarComponent {
181    /// The track (background) of the scrollbar
182    Track,
183    /// The draggable thumb (indicator of current scroll position)
184    Thumb,
185    /// Top/left button (scrolls by one page up/left)
186    TopButton,
187    /// Bottom/right button (scrolls by one page down/right)
188    BottomButton,
189}
190
191/// Scrollbar geometry state (calculated per frame, used for hit-testing and rendering)
192#[derive(Copy, Debug, Clone)]
193pub struct ScrollbarState {
194    /// Is this scrollbar visible? (content larger than container)
195    pub visible: bool,
196    /// Orientation
197    pub orientation: ScrollbarOrientation,
198    /// Base size (1:1 square, width = height). This is the unscaled size.
199    pub base_size: f32,
200    /// Scale transform to apply (calculated from container size)
201    pub scale: LogicalPosition, // x = width scale, y = height scale
202    /// Thumb position ratio (0.0 = top/left, 1.0 = bottom/right)
203    pub thumb_position_ratio: f32,
204    /// Thumb size ratio (0.0 = invisible, 1.0 = entire track)
205    pub thumb_size_ratio: f32,
206    /// Position of the scrollbar in the container (for hit-testing)
207    pub track_rect: LogicalRect,
208    /// Button size (square: `button_size` × `button_size`)
209    pub button_size: f32,
210    /// Usable track length after subtracting buttons
211    pub usable_track_length: f32,
212    /// Thumb length in pixels
213    pub thumb_length: f32,
214    /// Thumb offset from start of usable track region
215    pub thumb_offset: f32,
216}
217
218impl ScrollbarState {
219    /// Determine which component was hit at the given local position (relative to `track_rect`
220    /// origin). Uses the shared geometry values (`button_size`, `usable_track_length`, `thumb_length`,
221    /// `thumb_offset`) for consistent hit-testing.
222    #[must_use] pub fn hit_test_component(&self, local_pos: LogicalPosition) -> ScrollbarComponent {
223        match self.orientation {
224            ScrollbarOrientation::Vertical => {
225                // Top button
226                if local_pos.y < self.button_size {
227                    return ScrollbarComponent::TopButton;
228                }
229
230                // Bottom button
231                let track_height = self.track_rect.size.height;
232                if local_pos.y > track_height - self.button_size {
233                    return ScrollbarComponent::BottomButton;
234                }
235
236                // Thumb region starts after top button
237                let thumb_y_start = self.button_size + self.thumb_offset;
238                let thumb_y_end = thumb_y_start + self.thumb_length;
239
240                if local_pos.y >= thumb_y_start && local_pos.y <= thumb_y_end {
241                    ScrollbarComponent::Thumb
242                } else {
243                    ScrollbarComponent::Track
244                }
245            }
246            ScrollbarOrientation::Horizontal => {
247                // Left button
248                if local_pos.x < self.button_size {
249                    return ScrollbarComponent::TopButton;
250                }
251
252                // Right button
253                let track_width = self.track_rect.size.width;
254                if local_pos.x > track_width - self.button_size {
255                    return ScrollbarComponent::BottomButton;
256                }
257
258                // Thumb region starts after left button
259                let thumb_x_start = self.button_size + self.thumb_offset;
260                let thumb_x_end = thumb_x_start + self.thumb_length;
261
262                if local_pos.x >= thumb_x_start && local_pos.x <= thumb_x_end {
263                    ScrollbarComponent::Thumb
264                } else {
265                    ScrollbarComponent::Track
266                }
267            }
268        }
269    }
270}
271
272/// Result of a scrollbar hit-test
273///
274/// Contains information about which scrollbar component was hit
275/// and the position relative to both the track and the window.
276#[derive(Debug, Clone, Copy)]
277pub struct ScrollbarHit {
278    /// DOM containing the scrollable node
279    pub dom_id: DomId,
280    /// Node with the scrollbar
281    pub node_id: NodeId,
282    /// Whether this is a vertical or horizontal scrollbar
283    pub orientation: ScrollbarOrientation,
284    /// Which component was hit (track, thumb, buttons)
285    pub component: ScrollbarComponent,
286    /// Position relative to `track_rect` origin
287    pub local_position: LogicalPosition,
288    /// Original global window position
289    pub global_position: LogicalPosition,
290}
291
292// Core Scroll Manager
293
294/// Manages all scroll state and animations for a window
295#[derive(Debug, Clone, Default)]
296pub struct ScrollManager {
297    /// Maps (`DomId`, `NodeId`) to their scroll state
298    states: BTreeMap<(DomId, NodeId), AnimatedScrollState>,
299    /// Scrollbar geometry states (calculated per frame)
300    scrollbar_states: BTreeMap<(DomId, NodeId, ScrollbarOrientation), ScrollbarState>,
301    /// Thread-safe queue for scroll inputs (shared with timer callbacks)
302    #[cfg(feature = "std")]
303    pub scroll_input_queue: ScrollInputQueue,
304    /// Raw wheel/trackpad delta recorded *this input pass*, regardless of whether
305    /// a scrollable node was under the cursor. The scroll input queue only carries
306    /// deltas destined for scrollable containers (consumed by the physics timer);
307    /// this field additionally lets `determine_all_events` synthesize a `Scroll`
308    /// event aimed at the hovered node so non-scroll-container widgets (e.g. the
309    /// map, which treats wheel = zoom) can react via a `HoverEventFilter::Scroll`
310    /// callback + `CallbackInfo::get_scroll_delta`. Set in
311    /// [`Self::record_scroll_from_hit_test`]; read during event determination and
312    /// callback dispatch, then cleared at the end of the pass.
313    pub pending_wheel_event: Option<LogicalPosition>,
314    /// Set when a scroll position changes; cleared after the display list
315    /// is regenerated.  Used by the CPU renderer path to detect when the
316    /// display list must be rebuilt even though the DOM hasn't changed.
317    scroll_dirty: bool,
318    /// Scroll-direction preference, applied ONCE in [`Self::record_scroll_input`]
319    /// (the single chokepoint every platform's wheel/axis event flows through).
320    ///
321    /// `false` (default) = traditional desktop wheel: a raw "scroll down" event
322    /// increases the offset (content moves up). `true` = natural: inverted.
323    /// Replaces the per-platform hardcoded `-delta` negations so the sign lives
324    /// in one configurable place ([`Self::set_natural_scroll`]).
325    ///
326    /// CAVEAT: on macOS and on Linux touchpads via libinput the OS/driver ALREADY
327    /// applies the user's natural-scroll preference before azul sees the delta, so
328    /// this flag must stay at its default there (we preserve current behavior) and
329    /// primarily controls mouse-wheel direction on platforms that don't pre-apply.
330    natural_scroll: bool,
331}
332
333/// The complete scroll state for a single node (with animation support)
334#[derive(Debug, Clone)]
335pub struct AnimatedScrollState {
336    /// Current scroll offset (live, may be animating)
337    pub current_offset: LogicalPosition,
338    /// Ongoing smooth scroll animation, if any
339    pub animation: Option<ScrollAnimation>,
340    /// Last time scroll activity occurred (for fading scrollbars)
341    pub last_activity: Instant,
342    /// Bounds of the scrollable container
343    pub container_rect: LogicalRect,
344    /// Bounds of the total content (for calculating scroll limits)
345    pub content_rect: LogicalRect,
346    /// Virtual scroll size from `VirtualView` callback (if this node hosts a `VirtualView`).
347    /// When set, clamp logic uses this instead of `content_rect` for max scroll bounds.
348    pub virtual_scroll_size: Option<LogicalSize>,
349    /// Virtual scroll offset from `VirtualView` callback
350    pub virtual_scroll_offset: Option<LogicalPosition>,
351    /// Per-node overscroll behavior for X axis (from CSS `overscroll-behavior-x`)
352    pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
353    /// Per-node overscroll behavior for Y axis (from CSS `overscroll-behavior-y`)
354    pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
355    /// Per-node overflow scrolling mode (from CSS `-azul-overflow-scrolling`)
356    pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
357    /// CSS-resolved scrollbar thickness (from `scrollbar-width` property).
358    /// Used for rendering and hit-testing. Defaults to 16.0 if not set.
359    pub scrollbar_thickness: f32,
360    /// Visual rendering width in CSS pixels (e.g. 8.0 for thin overlay).
361    /// Non-zero even for overlay scrollbars. Falls back to `scrollbar_thickness` if 0.
362    pub visual_width_px: f32,
363    /// Whether this node also needs a horizontal scrollbar (affects vertical geometry)
364    pub has_horizontal_scrollbar: bool,
365    /// Whether this node also needs a vertical scrollbar (affects horizontal geometry)
366    pub has_vertical_scrollbar: bool,
367}
368
369/// Details of an in-progress smooth scroll animation.
370///
371/// `pub` because `ScrollState::animation` is already a `pub` field holding it —
372/// a private type behind a public field is only an error at the point somebody
373/// outside this module reads it, which is why this compiled until the E2E
374/// manager fingerprints needed to ask "is a scroll animation in flight?".
375#[derive(Debug, Clone)]
376pub struct ScrollAnimation {
377    /// When the animation started
378    start_time: Instant,
379    /// Total duration of the animation
380    duration: Duration,
381    /// Scroll offset at animation start
382    start_offset: LogicalPosition,
383    /// Target scroll offset at animation end
384    target_offset: LogicalPosition,
385    /// Easing function for interpolation
386    easing: EasingFunction,
387}
388
389/// Read-only snapshot of a scroll node's state, returned by `CallbackInfo` queries.
390///
391/// Provides all the information a timer callback needs to compute scroll physics
392/// without requiring mutable access to the `ScrollManager`.
393#[derive(Copy, Debug, Clone)]
394pub struct ScrollNodeInfo {
395    /// Current scroll offset
396    pub current_offset: LogicalPosition,
397    /// Container (viewport) bounds
398    pub container_rect: LogicalRect,
399    /// Content bounds (total scrollable area)
400    pub content_rect: LogicalRect,
401    /// Maximum scroll in X direction
402    pub max_scroll_x: f32,
403    /// Maximum scroll in Y direction
404    pub max_scroll_y: f32,
405    /// Per-node overscroll behavior for X axis
406    pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
407    /// Per-node overscroll behavior for Y axis
408    pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
409    /// Per-node overflow scrolling mode (auto vs touch)
410    pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
411}
412
413/// Result of a scroll tick, indicating what actions are needed
414#[derive(Debug, Default)]
415pub struct ScrollTickResult {
416    /// If true, a repaint is needed (scroll offset changed)
417    pub needs_repaint: bool,
418    /// Nodes whose scroll position was updated this tick
419    pub updated_nodes: Vec<(DomId, NodeId)>,
420}
421
422// ScrollManager Implementation
423
424impl ScrollManager {
425    /// Creates a new empty `ScrollManager`
426    #[must_use] pub fn new() -> Self {
427        let mut m = Self::default();
428        // Power-user / test override. Platform shells should call
429        // `set_natural_scroll` from the OS preference; this env var wins so the
430        // direction can be flipped without a rebuild and so tests are hermetic.
431        #[cfg(feature = "std")]
432        if let Some(v) = std::env::var_os("AZ_NATURAL_SCROLL") {
433            m.natural_scroll = matches!(v.to_str(), Some("1" | "true" | "TRUE"));
434        }
435        m
436    }
437
438    /// Set the scroll-direction preference. `true` = natural (content follows the
439    /// gesture / inverted from the traditional wheel). Platform shells call this
440    /// from the detected OS preference. See the `natural_scroll` field docs for the
441    /// macOS/libinput pre-application caveat.
442    pub const fn set_natural_scroll(&mut self, natural: bool) {
443        self.natural_scroll = natural;
444    }
445
446    /// Current scroll-direction preference (`true` = natural/inverted).
447    #[must_use] pub const fn is_natural_scroll(&self) -> bool {
448        self.natural_scroll
449    }
450
451    /// The sign applied to a raw input delta to get the offset delta:
452    /// `-1.0` traditional (default), `+1.0` natural. Centralises what used to be a
453    /// hardcoded `-delta` at every platform call site.
454    #[inline]
455    const fn scroll_sign(&self) -> f32 {
456        if self.natural_scroll {
457            1.0
458        } else {
459            -1.0
460        }
461    }
462
463    /// Sizes of the internal maps — used by `AZ_E2E_TEST` to watch for
464    /// unbounded growth across resize/tick iterations.
465    #[must_use] pub fn debug_counts(&self) -> (usize, usize) {
466        (self.states.len(), self.scrollbar_states.len())
467    }
468
469    /// Returns `true` if any scroll position changed since the last
470    /// `clear_scroll_dirty()` call.
471    pub(crate) const fn has_pending_scroll_changes(&self) -> bool {
472        self.scroll_dirty
473    }
474
475    /// Every `(DomId, NodeId)` this manager currently keys scroll state on.
476    ///
477    /// The E2E `assert_manager_invariants` op needs the KEY SET, not just the
478    /// count `debug_counts()` reports: a key naming a node that no longer exists
479    /// in `layout_results` is a dangling index (invariant X10), and a key set is
480    /// the only way to see it from outside.
481    #[must_use]
482    pub fn state_keys(&self) -> Vec<(DomId, NodeId)> {
483        self.states.keys().copied().collect()
484    }
485
486    /// Every `(DomId, NodeId)` whose `AnimatedScrollState` currently carries an
487    /// easing animation. `has_active_animations()` is exactly
488    /// `!animating_keys().is_empty()`; E2E invariant X2 asserts that identity.
489    #[must_use]
490    pub fn animating_keys(&self) -> Vec<(DomId, NodeId)> {
491        self.states
492            .iter()
493            .filter(|(_, s)| s.animation.is_some())
494            .map(|(k, _)| *k)
495            .collect()
496    }
497
498    /// Clear the dirty flag after the display list has been regenerated.
499    pub const fn clear_scroll_dirty(&mut self) {
500        self.scroll_dirty = false;
501    }
502
503    /// Build a map from `scroll_id` (`LocalScrollId`) to current scroll offset.
504    ///
505    /// Used by the CPU renderer to look up scroll positions at render time
506    /// without embedding them in the display list.
507    ///
508    /// `scroll_ids` maps layout-tree node index → `scroll_id`. We need to
509    /// convert our (`DomId`, `NodeId`) keys to `scroll_ids`.
510    #[must_use] pub fn build_scroll_offset_map(
511        &self,
512        dom_id: DomId,
513        scroll_ids: &std::collections::HashMap<usize, u64>,
514    ) -> std::collections::HashMap<u64, (f32, f32)> {
515        let mut map = std::collections::HashMap::new();
516        for ((d, node_id), state) in &self.states {
517            if *d != dom_id { continue; }
518            // Find the scroll_id for this node_id by searching scroll_ids
519            // (scroll_ids maps layout_index → scroll_id, and node_id.index() == layout_index
520            // for the root DOM)
521            let node_idx = node_id.index();
522            if let Some(&scroll_id) = scroll_ids.get(&node_idx) {
523                map.insert(scroll_id, (state.current_offset.x, state.current_offset.y));
524            }
525        }
526        map
527    }
528
529    // ========================================================================
530    // Input Recording API (timer-based architecture)
531    // ========================================================================
532
533    /// Records a scroll input event into the shared queue.
534    ///
535    /// This is the primary entry point for platform event handlers. Instead of
536    /// directly modifying scroll positions, the input is queued for the scroll
537    /// physics timer to process. This decouples input from physics simulation.
538    ///
539    /// The scroll-direction sign ([`Self::scroll_sign`]) is applied HERE — the
540    /// single chokepoint every wheel/axis event flows through — so platform shells
541    /// pass the RAW delta and no longer hardcode `-delta` at each call site.
542    ///
543    /// Returns `true` if the physics timer should be started (i.e., there are
544    /// now pending inputs and no timer is running yet).
545    #[cfg(feature = "std")]
546    pub fn record_scroll_input(&mut self, mut input: ScrollInput) -> bool {
547        let sign = self.scroll_sign();
548        input.delta.x *= sign;
549        input.delta.y *= sign;
550        let was_empty = !self.scroll_input_queue.has_pending();
551        self.scroll_input_queue.push(input);
552        was_empty // caller should start timer if this returns true
553    }
554
555    /// High-level entry point for platform event handlers: performs hit-test lookup
556    /// and queues the input for the physics timer, instead of directly modifying offsets.
557    ///
558    /// Returns `Some((dom_id, node_id, should_start_timer))` if a scrollable node was found.
559    /// The caller should start `SCROLL_MOMENTUM_TIMER_ID` when `should_start_timer` is true.
560    #[cfg(feature = "std")]
561    pub fn record_scroll_from_hit_test(
562        &mut self,
563        delta_x: f32,
564        delta_y: f32,
565        source: ScrollInputSource,
566        hover_manager: &crate::managers::hover::HoverManager,
567        input_point_id: &InputPointId,
568        now: Instant,
569    ) -> Option<(DomId, NodeId, bool)> {
570        // Record the raw wheel delta for this pass unconditionally — even when the
571        // cursor isn't over a scroll container — so a `Scroll` event can be aimed
572        // at the hovered node (wheel-as-zoom widgets like the map rely on this).
573        self.pending_wheel_event = Some(LogicalPosition { x: delta_x, y: delta_y });
574
575        let hit_test = hover_manager.get_current(input_point_id)?;
576
577        // MWA-B2: nested scroll containers — innermost-first with boundary
578        // handoff. The previous ascending iteration always picked the
579        // OUTERMOST scrollable ancestor (BTreeMap keys ascend; ancestors
580        // have lower arena NodeIds), so wheeling over a list inside a
581        // scrollable page scrolled the page instead of the list. We now
582        // walk innermost-first and give the event to the first candidate
583        // that can still move in the delta's direction (the web's default
584        // overscroll handoff); when every candidate is pinned, the
585        // innermost scrollable wins so the gesture still targets the node
586        // under the pointer.
587        let sign = self.scroll_sign();
588        let (eff_x, eff_y) = (delta_x * sign, delta_y * sign);
589        let target = self.select_scroll_target(
590            hit_test.hovered_nodes.iter().flat_map(|(dom_id, hit_node)| {
591                hit_node
592                    .scroll_hit_test_nodes
593                    .keys()
594                    .rev()
595                    .map(move |node_id| (*dom_id, *node_id))
596            }),
597            eff_x,
598            eff_y,
599        );
600        let (dom_id, node_id) = target?;
601        let input = ScrollInput {
602            dom_id,
603            node_id,
604            // Raw delta — record_scroll_input applies scroll_sign() itself.
605            delta: LogicalPosition { x: delta_x, y: delta_y },
606            timestamp: now,
607            source,
608        };
609        let should_start_timer = self.record_scroll_input(input);
610        Some((dom_id, node_id, should_start_timer))
611    }
612
613    /// MWA-B2: choose the scroll node a wheel/trackpad event should drive.
614    ///
615    /// `candidates` must be ordered innermost-first; `eff_x`/`eff_y` are the
616    /// direction-normalized deltas (post `scroll_sign()`: positive = offset
617    /// grows = view moves toward content's down/right). The first candidate
618    /// with remaining travel in a moved direction wins; if every candidate
619    /// is pinned, the innermost scrollable is returned so the gesture still
620    /// anchors under the pointer (matches CSS default overscroll behavior).
621    fn select_scroll_target<I>(
622        &self,
623        candidates: I,
624        eff_x: f32,
625        eff_y: f32,
626    ) -> Option<(DomId, NodeId)>
627    where
628        I: Iterator<Item = (DomId, NodeId)>,
629    {
630        let mut fallback = None;
631        for (dom_id, node_id) in candidates {
632            if !self.is_node_scrollable(dom_id, node_id) {
633                continue;
634            }
635            if fallback.is_none() {
636                fallback = Some((dom_id, node_id));
637            }
638            if self.can_consume_delta(dom_id, node_id, eff_x, eff_y) {
639                return Some((dom_id, node_id));
640            }
641        }
642        fallback
643    }
644
645    /// MWA-B10: the a11y tree's scroll surface for a node — current offset
646    /// plus max travel per axis, or `None` when the node isn't scrollable.
647    /// Screen readers use this (with the ScrollUp/Down/... actions) to
648    /// drive the same inbound handler mouse users exercise.
649    #[must_use] pub fn a11y_scroll_info(
650        &self,
651        dom_id: DomId,
652        node_id: NodeId,
653    ) -> Option<(LogicalPosition, f32, f32)> {
654        let state = self.states.get(&(dom_id, node_id))?;
655        let effective_width = state
656            .virtual_scroll_size
657            .map_or(state.content_rect.size.width, |s| s.width);
658        let effective_height = state
659            .virtual_scroll_size
660            .map_or(state.content_rect.size.height, |s| s.height);
661        let max_x = (effective_width - state.container_rect.size.width).max(0.0);
662        let max_y = (effective_height - state.container_rect.size.height).max(0.0);
663        if max_x <= 0.0 && max_y <= 0.0 {
664            return None;
665        }
666        Some((state.current_offset, max_x, max_y))
667    }
668
669    /// `true` when the node still has travel in the direction of the
670    /// normalized delta on at least one moved axis — the boundary-handoff
671    /// test for [`select_scroll_target`](Self::select_scroll_target).
672    fn can_consume_delta(
673        &self,
674        dom_id: DomId,
675        node_id: NodeId,
676        eff_x: f32,
677        eff_y: f32,
678    ) -> bool {
679        const EPS: f32 = 0.5;
680        let Some(state) = self.states.get(&(dom_id, node_id)) else {
681            return false;
682        };
683        let effective_width = state
684            .virtual_scroll_size
685            .map_or(state.content_rect.size.width, |s| s.width);
686        let effective_height = state
687            .virtual_scroll_size
688            .map_or(state.content_rect.size.height, |s| s.height);
689        let max_x = (effective_width - state.container_rect.size.width).max(0.0);
690        let max_y = (effective_height - state.container_rect.size.height).max(0.0);
691        let off = state.current_offset;
692
693        let x_ok = if eff_x > EPS {
694            off.x < max_x - EPS
695        } else if eff_x < -EPS {
696            off.x > EPS
697        } else {
698            false
699        };
700        let y_ok = if eff_y > EPS {
701            off.y < max_y - EPS
702        } else if eff_y < -EPS {
703            off.y > EPS
704        } else {
705            false
706        };
707        x_ok || y_ok
708    }
709
710    /// Get a clone of the scroll input queue (for sharing with timer callbacks).
711    ///
712    /// The timer callback stores this in its `RefAny` data and calls `take_all()`
713    /// each tick to consume pending inputs.
714    #[cfg(feature = "std")]
715    #[must_use] pub fn get_input_queue(&self) -> ScrollInputQueue {
716        self.scroll_input_queue.clone()
717    }
718
719    /// Advances scroll animations by one tick, returns repaint info
720    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
721    // Instant is a ref-counted FFI clock handle; called by every dll backend's event loop by value.
722    #[allow(clippy::needless_pass_by_value)]
723    pub fn tick(&mut self, now: Instant) -> ScrollTickResult {
724        let mut result = ScrollTickResult::default();
725        for ((dom_id, node_id), state) in &mut self.states {
726            if let Some(anim) = &state.animation {
727                let elapsed = now.duration_since(&anim.start_time);
728                let t = elapsed.div(&anim.duration).min(1.0);
729                let eased_t = apply_easing(t, anim.easing);
730
731                state.current_offset = LogicalPosition {
732                    x: anim.start_offset.x + (anim.target_offset.x - anim.start_offset.x) * eased_t,
733                    y: anim.start_offset.y + (anim.target_offset.y - anim.start_offset.y) * eased_t,
734                };
735                result.needs_repaint = true;
736                result.updated_nodes.push((*dom_id, *node_id));
737
738                if t >= 1.0 {
739                    state.animation = None;
740                }
741            }
742        }
743        result
744    }
745
746    /// Returns `true` if any scroll node has an active easing animation.
747    ///
748    /// Used by GPU render paths to skip rendering when the UI is completely
749    /// static (no scroll animations, no layout changes).
750    #[must_use] pub fn has_active_animations(&self) -> bool {
751        self.states.values().any(|s| s.animation.is_some())
752    }
753
754    /// Finds the closest scroll-container ancestor for a given node.
755    ///
756    /// Walks up the node hierarchy to find a node that is registered as a
757    /// scrollable node in this `ScrollManager`. Returns `None` if no scrollable
758    /// ancestor is found.
759    #[must_use] pub fn find_scroll_parent(
760        &self,
761        dom_id: DomId,
762        node_id: NodeId,
763        node_hierarchy: &[azul_core::styled_dom::NodeHierarchyItem],
764    ) -> Option<NodeId> {
765        let mut current = Some(node_id);
766        while let Some(nid) = current {
767            if self.states.contains_key(&(dom_id, nid)) && nid != node_id {
768                return Some(nid);
769            }
770            current = node_hierarchy
771                .get(nid.index())
772                .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
773        }
774        None
775    }
776
777    /// Check if a node is scrollable (has overflow:scroll/auto and overflowing content)
778    ///
779    /// Uses `virtual_scroll_size` (when set) instead of `content_rect` for the
780    /// overflow check, so `VirtualView` nodes with large virtual content are correctly
781    /// identified as scrollable even when only a small subset is rendered.
782    fn is_node_scrollable(&self, dom_id: DomId, node_id: NodeId) -> bool {
783        let result = self.states.get(&(dom_id, node_id)).is_some_and(|state| {
784            let effective_width = state.virtual_scroll_size
785                .map_or(state.content_rect.size.width, |s| s.width);
786            let effective_height = state.virtual_scroll_size
787                .map_or(state.content_rect.size.height, |s| s.height);
788            let has_horizontal = effective_width > state.container_rect.size.width;
789            let has_vertical = effective_height > state.container_rect.size.height;
790            has_horizontal || has_vertical
791        });
792        result
793    }
794
795    // +spec:overflow:4000a6 - scroll position as offset from scroll origin within scrollport
796    /// Sets scroll position immediately (no animation), clamped to valid bounds.
797    pub fn set_scroll_position(
798        &mut self,
799        dom_id: DomId,
800        node_id: NodeId,
801        position: LogicalPosition,
802        now: Instant,
803    ) {
804        let state = self
805            .states
806            .entry((dom_id, node_id))
807            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
808        let clamped = state.clamp(position);
809        if (clamped.x - state.current_offset.x).abs() > SCROLL_CHANGE_EPSILON
810            || (clamped.y - state.current_offset.y).abs() > SCROLL_CHANGE_EPSILON
811        {
812            self.scroll_dirty = true;
813        }
814        state.current_offset = clamped;
815        state.animation = None;
816        state.last_activity = now;
817    }
818
819    /// Sets scroll position immediately without clamping.
820    ///
821    /// Used by the scroll physics timer which does its own rubber-band clamping.
822    /// Allows the offset to go outside [0, `max_scroll`] for overscroll/rubber-banding.
823    pub fn set_scroll_position_unclamped(
824        &mut self,
825        dom_id: DomId,
826        node_id: NodeId,
827        position: LogicalPosition,
828        now: Instant,
829    ) {
830        let state = self
831            .states
832            .entry((dom_id, node_id))
833            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
834        if (position.x - state.current_offset.x).abs() > SCROLL_CHANGE_EPSILON
835            || (position.y - state.current_offset.y).abs() > SCROLL_CHANGE_EPSILON
836        {
837            self.scroll_dirty = true;
838        }
839        state.current_offset = position;
840        state.animation = None;
841        state.last_activity = now;
842    }
843
844    /// Scrolls by a delta amount with animation
845    pub fn scroll_by(
846        &mut self,
847        dom_id: DomId,
848        node_id: NodeId,
849        delta: LogicalPosition,
850        duration: Duration,
851        easing: EasingFunction,
852        now: Instant,
853    ) {
854        let current = self.get_current_offset(dom_id, node_id).unwrap_or_default();
855        let target = LogicalPosition {
856            x: current.x + delta.x,
857            y: current.y + delta.y,
858        };
859        self.scroll_to(dom_id, node_id, target, duration, easing, now);
860    }
861
862    /// Scrolls to an absolute position with animation
863    ///
864    /// If duration is zero, the position is set immediately without animation.
865    pub fn scroll_to(
866        &mut self,
867        dom_id: DomId,
868        node_id: NodeId,
869        target: LogicalPosition,
870        duration: Duration,
871        easing: EasingFunction,
872        now: Instant,
873    ) {
874        // For zero duration, set position immediately
875        let is_zero = match &duration {
876            Duration::System(s) => s.secs == 0 && s.nanos == 0,
877            Duration::Tick(t) => t.tick_diff == 0,
878        };
879
880        if is_zero {
881            self.set_scroll_position(dom_id, node_id, target, now);
882            return;
883        }
884
885        let state = self
886            .states
887            .entry((dom_id, node_id))
888            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
889        let clamped_target = state.clamp(target);
890        state.animation = Some(ScrollAnimation {
891            start_time: now.clone(),
892            duration,
893            start_offset: state.current_offset,
894            target_offset: clamped_target,
895            easing,
896        });
897        state.last_activity = now;
898    }
899
900    /// Updates the container and content bounds for a scrollable node
901    pub fn update_node_bounds(
902        &mut self,
903        dom_id: DomId,
904        node_id: NodeId,
905        container_rect: LogicalRect,
906        content_rect: LogicalRect,
907        now: Instant,
908    ) {
909        let state = self
910            .states
911            .entry((dom_id, node_id))
912            .or_insert_with(|| AnimatedScrollState::new(now));
913        state.container_rect = container_rect;
914        state.content_rect = content_rect;
915        state.current_offset = state.clamp(state.current_offset);
916    }
917
918    /// Updates virtual scroll bounds for a `VirtualView` node.
919    ///
920    /// Called after `VirtualView` callback returns to propagate the virtual content size
921    /// to the `ScrollManager`. Clamp logic then uses `virtual_scroll_size` (when set)
922    /// instead of `content_rect` for max scroll bounds.
923    ///
924    /// If no scroll state exists yet for this node (because `register_or_update_scroll_node`
925    /// hasn't been called yet), this creates a default state so the virtual size is preserved.
926    pub fn update_virtual_scroll_bounds(
927        &mut self,
928        dom_id: DomId,
929        node_id: NodeId,
930        virtual_scroll_size: LogicalSize,
931        virtual_scroll_offset: Option<LogicalPosition>,
932    ) {
933        let key = (dom_id, node_id);
934        let state = self.states.entry(key).or_insert_with(|| {
935            // AzInstant (System on std, safe Tick on no-clock targets) — not the
936            // WASM-panicking std::time::Instant::now(). (A refinement would thread
937            // the window's get_system_time_fn callback through for hookability.)
938            AnimatedScrollState::new(Instant::now())
939        });
940        state.virtual_scroll_size = Some(virtual_scroll_size);
941        state.virtual_scroll_offset = virtual_scroll_offset;
942        // Re-clamp with new virtual bounds
943        state.current_offset = state.clamp(state.current_offset);
944    }
945
946    /// Returns the current scroll offset for a node
947    #[must_use] pub fn get_current_offset(&self, dom_id: DomId, node_id: NodeId) -> Option<LogicalPosition> {
948        self.states
949            .get(&(dom_id, node_id))
950            .map(|s| s.current_offset)
951    }
952
953    /// Returns the timestamp of last scroll activity for a node
954    #[must_use] pub fn get_last_activity_time(&self, dom_id: DomId, node_id: NodeId) -> Option<Instant> {
955        self.states
956            .get(&(dom_id, node_id))
957            .map(|s| s.last_activity.clone())
958    }
959
960    /// Returns the internal scroll state for a node
961    #[must_use] pub fn get_scroll_state(&self, dom_id: DomId, node_id: NodeId) -> Option<&AnimatedScrollState> {
962        self.states.get(&(dom_id, node_id))
963    }
964
965    /// Returns a read-only snapshot of a scroll node's state.
966    ///
967    /// This is the preferred way for timer callbacks to query scroll state,
968    /// since they only have `&CallbackInfo` (read-only access).
969    ///
970    /// When `virtual_scroll_size` is set (for `VirtualView` nodes), the max scroll
971    /// bounds are computed from the virtual size instead of `content_rect`.
972    #[must_use] pub fn get_scroll_node_info(
973        &self,
974        dom_id: DomId,
975        node_id: NodeId,
976    ) -> Option<ScrollNodeInfo> {
977        let state = self.states.get(&(dom_id, node_id))?;
978        let effective_content_width = state.virtual_scroll_size
979            .map_or(state.content_rect.size.width, |s| s.width);
980        let effective_content_height = state.virtual_scroll_size
981            .map_or(state.content_rect.size.height, |s| s.height);
982        let max_x = (effective_content_width - state.container_rect.size.width).max(0.0);
983        let max_y = (effective_content_height - state.container_rect.size.height).max(0.0);
984        Some(ScrollNodeInfo {
985            current_offset: state.current_offset,
986            container_rect: state.container_rect,
987            content_rect: state.content_rect,
988            max_scroll_x: max_x,
989            max_scroll_y: max_y,
990            overscroll_behavior_x: state.overscroll_behavior_x,
991            overscroll_behavior_y: state.overscroll_behavior_y,
992            overflow_scrolling: state.overflow_scrolling,
993        })
994    }
995
996    /// Returns all scroll positions for nodes in a specific DOM
997    #[must_use] pub fn get_scroll_states_for_dom(&self, dom_id: DomId) -> BTreeMap<NodeId, ScrollPosition> {
998        // M12.7: iterating an EMPTY hashbrown map (RawIterRange) mis-lifts to
999        // wasm and loops forever (same class as the font-id / GPU-cache loops).
1000        // For the headless web path `states` is empty; guard it (len-based, no
1001        // iteration). Desktop unchanged.
1002        if self.states.is_empty() {
1003            return BTreeMap::new();
1004        }
1005        self.states
1006            .iter()
1007            .filter(|((d, _), _)| *d == dom_id)
1008            .map(|((_, node_id), state)| {
1009                // Use virtual_scroll_size (from VirtualView callback) when available,
1010                // otherwise fall back to content_rect.size from layout.
1011                let effective_content_size = state.virtual_scroll_size
1012                    .unwrap_or(state.content_rect.size);
1013                (
1014                    *node_id,
1015                    ScrollPosition {
1016                        parent_rect: state.container_rect,
1017                        children_rect: LogicalRect::new(
1018                            state.current_offset,
1019                            effective_content_size,
1020                        ),
1021                    },
1022                )
1023            })
1024            .collect()
1025    }
1026
1027    /// Registers or updates a scrollable node with its container and content sizes.
1028    /// This should be called after layout for each node that has overflow:scroll or overflow:auto
1029    /// with overflowing content.
1030    ///
1031    /// If the node already exists, updates container/content rects without changing scroll offset.
1032    /// If the node is new, initializes with zero scroll offset.
1033    pub fn register_or_update_scroll_node(
1034        &mut self,
1035        dom_id: DomId,
1036        node_id: NodeId,
1037        container_rect: LogicalRect,
1038        content_size: LogicalSize,
1039        now: Instant,
1040        scrollbar_thickness: f32,
1041        visual_width_px: f32,
1042        has_horizontal_scrollbar: bool,
1043        has_vertical_scrollbar: bool,
1044    ) {
1045        let key = (dom_id, node_id);
1046
1047        let content_rect = LogicalRect {
1048            origin: LogicalPosition::zero(),
1049            size: content_size,
1050        };
1051
1052        if let Some(existing) = self.states.get_mut(&key) {
1053            // Update rects, keep scroll offset
1054            existing.container_rect = container_rect;
1055            existing.content_rect = content_rect;
1056            existing.scrollbar_thickness = scrollbar_thickness;
1057            existing.visual_width_px = visual_width_px;
1058            existing.has_horizontal_scrollbar = has_horizontal_scrollbar;
1059            existing.has_vertical_scrollbar = has_vertical_scrollbar;
1060            // Re-clamp current offset to new bounds
1061            existing.current_offset = existing.clamp(existing.current_offset);
1062        } else {
1063            // +spec:overflow:8c7aa1 - initial scroll position is zero (scroll origin for LTR/TTB)
1064            self.states.insert(
1065                key,
1066                AnimatedScrollState {
1067                    current_offset: LogicalPosition::zero(),
1068                    animation: None,
1069                    last_activity: now,
1070                    container_rect,
1071                    content_rect,
1072                    virtual_scroll_size: None,
1073                    virtual_scroll_offset: None,
1074                    overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
1075                    overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
1076                    overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
1077                    scrollbar_thickness,
1078                    visual_width_px,
1079                    has_horizontal_scrollbar,
1080                    has_vertical_scrollbar,
1081                },
1082            );
1083        }
1084    }
1085
1086    // Scrollbar State Management
1087
1088    /// Calculate scrollbar states for all visible scrollbars.
1089    /// This should be called once per frame after layout is complete.
1090    /// Uses the shared `compute_scrollbar_geometry()` for consistent geometry.
1091    pub fn calculate_scrollbar_states(&mut self) {
1092        self.scrollbar_states.clear();
1093
1094        // Uses virtual_scroll_size (when set) for the overflow check and thumb ratio,
1095        // so VirtualView nodes with large virtual content show correct scrollbar geometry.
1096        for orientation in [ScrollbarOrientation::Vertical, ScrollbarOrientation::Horizontal] {
1097            let states: Vec<_> = self
1098                .states
1099                .iter()
1100                .filter(|(_, s)| {
1101                    let (effective, container) = match orientation {
1102                        ScrollbarOrientation::Vertical => (
1103                            s.virtual_scroll_size.map_or(s.content_rect.size.height, |vs| vs.height),
1104                            s.container_rect.size.height,
1105                        ),
1106                        ScrollbarOrientation::Horizontal => (
1107                            s.virtual_scroll_size.map_or(s.content_rect.size.width, |vs| vs.width),
1108                            s.container_rect.size.width,
1109                        ),
1110                    };
1111                    effective > container
1112                })
1113                .map(|((dom_id, node_id), scroll_state)| {
1114                    let state = Self::calculate_scrollbar_state_from_geometry(
1115                        scroll_state,
1116                        orientation,
1117                    );
1118                    ((*dom_id, *node_id, orientation), state)
1119                })
1120                .collect();
1121
1122            self.scrollbar_states.extend(states);
1123        }
1124    }
1125
1126    /// Calculate scrollbar state using the shared `compute_scrollbar_geometry()`.
1127    fn calculate_scrollbar_state_from_geometry(
1128        scroll_state: &AnimatedScrollState,
1129        orientation: ScrollbarOrientation,
1130    ) -> ScrollbarState {
1131        let scrollbar_thickness = if scroll_state.visual_width_px > 0.0 {
1132            scroll_state.visual_width_px
1133        } else if scroll_state.scrollbar_thickness > 0.0 {
1134            scroll_state.scrollbar_thickness
1135        } else {
1136            crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX
1137        };
1138
1139        let content_size = scroll_state.virtual_scroll_size
1140            .map_or(scroll_state.content_rect.size, |vs| vs);
1141
1142        let scroll_offset = match orientation {
1143            ScrollbarOrientation::Vertical => scroll_state.current_offset.y,
1144            ScrollbarOrientation::Horizontal => scroll_state.current_offset.x,
1145        };
1146
1147        let has_other_scrollbar = match orientation {
1148            ScrollbarOrientation::Vertical => scroll_state.has_horizontal_scrollbar,
1149            ScrollbarOrientation::Horizontal => scroll_state.has_vertical_scrollbar,
1150        };
1151
1152        // Overlay scrollbars (thickness == 0 from layout) have no arrow buttons
1153        let is_overlay = scroll_state.scrollbar_thickness == 0.0;
1154        let button_size = if is_overlay { 0.0 } else { scrollbar_thickness };
1155        let geom = compute_scrollbar_geometry_with_button_size(
1156            orientation,
1157            scroll_state.container_rect,
1158            content_size,
1159            scroll_offset,
1160            scrollbar_thickness,
1161            has_other_scrollbar,
1162            button_size,
1163        );
1164
1165        // Build ScrollbarState from the shared geometry
1166        let scale = match orientation {
1167            ScrollbarOrientation::Vertical => {
1168                LogicalPosition::new(1.0, geom.track_rect.size.height / scrollbar_thickness)
1169            }
1170            ScrollbarOrientation::Horizontal => {
1171                LogicalPosition::new(geom.track_rect.size.width / scrollbar_thickness, 1.0)
1172            }
1173        };
1174
1175        ScrollbarState {
1176            visible: true,
1177            orientation,
1178            base_size: scrollbar_thickness,
1179            scale,
1180            thumb_position_ratio: geom.scroll_ratio,
1181            thumb_size_ratio: geom.thumb_size_ratio,
1182            track_rect: geom.track_rect,
1183            button_size: geom.button_size,
1184            usable_track_length: geom.usable_track_length,
1185            thumb_length: geom.thumb_length,
1186            thumb_offset: geom.thumb_offset,
1187        }
1188    }
1189
1190    /// Get scrollbar state for hit-testing
1191    #[must_use] pub fn get_scrollbar_state(
1192        &self,
1193        dom_id: DomId,
1194        node_id: NodeId,
1195        orientation: ScrollbarOrientation,
1196    ) -> Option<&ScrollbarState> {
1197        self.scrollbar_states.get(&(dom_id, node_id, orientation))
1198    }
1199
1200    /// Iterate over all visible scrollbar states
1201    pub(crate) fn iter_scrollbar_states(
1202        &self,
1203    ) -> impl Iterator<Item = ((DomId, NodeId, ScrollbarOrientation), &ScrollbarState)> + '_ {
1204        self.scrollbar_states.iter().map(|(k, v)| (*k, v))
1205    }
1206
1207    // Scrollbar Hit-Testing
1208
1209    /// Hit-test scrollbars for a specific node at the given position.
1210    /// Returns Some if the position is inside a scrollbar for this node.
1211    pub(crate) fn hit_test_scrollbar(
1212        &self,
1213        dom_id: DomId,
1214        node_id: NodeId,
1215        global_pos: LogicalPosition,
1216    ) -> Option<ScrollbarHit> {
1217        // Check both vertical and horizontal scrollbars for this node
1218        for orientation in [
1219            ScrollbarOrientation::Vertical,
1220            ScrollbarOrientation::Horizontal,
1221        ] {
1222            let Some(scrollbar_state) = self.scrollbar_states.get(&(dom_id, node_id, orientation)) else {
1223                continue;
1224            };
1225
1226            if !scrollbar_state.visible {
1227                continue;
1228            }
1229
1230            // Check if position is inside scrollbar track using LogicalRect::contains
1231            if !scrollbar_state.track_rect.contains(global_pos) {
1232                continue;
1233            }
1234
1235            // Calculate local position relative to track origin
1236            let local_pos = LogicalPosition::new(
1237                global_pos.x - scrollbar_state.track_rect.origin.x,
1238                global_pos.y - scrollbar_state.track_rect.origin.y,
1239            );
1240
1241            // Determine which component was hit
1242            let component = scrollbar_state.hit_test_component(local_pos);
1243
1244            return Some(ScrollbarHit {
1245                dom_id,
1246                node_id,
1247                orientation,
1248                component,
1249                local_position: local_pos,
1250                global_position: global_pos,
1251            });
1252        }
1253
1254        None
1255    }
1256
1257    /// Perform hit-testing for all scrollbars at the given global position.
1258    ///
1259    /// This iterates through all visible scrollbars in reverse z-order (top to bottom)
1260    /// and returns the first hit. Use this when you don't know which node to check.
1261    ///
1262    /// For better performance, use `hit_test_scrollbar()` when you already have
1263    /// a hit-tested node from `WebRender`.
1264    #[must_use] pub fn hit_test_scrollbars(&self, global_pos: LogicalPosition) -> Option<ScrollbarHit> {
1265        // Iterate in reverse order to hit top-most scrollbars first
1266        for ((dom_id, node_id, orientation), scrollbar_state) in self.scrollbar_states.iter().rev()
1267        {
1268            if !scrollbar_state.visible {
1269                continue;
1270            }
1271
1272            // Check if position is inside scrollbar track
1273            if !scrollbar_state.track_rect.contains(global_pos) {
1274                continue;
1275            }
1276
1277            // Calculate local position relative to track origin
1278            let local_pos = LogicalPosition::new(
1279                global_pos.x - scrollbar_state.track_rect.origin.x,
1280                global_pos.y - scrollbar_state.track_rect.origin.y,
1281            );
1282
1283            // Determine which component was hit
1284            let component = scrollbar_state.hit_test_component(local_pos);
1285
1286            return Some(ScrollbarHit {
1287                dom_id: *dom_id,
1288                node_id: *node_id,
1289                orientation: *orientation,
1290                component,
1291                local_position: local_pos,
1292                global_position: global_pos,
1293            });
1294        }
1295
1296        None
1297    }
1298}
1299
1300// AnimatedScrollState Implementation
1301
1302impl AnimatedScrollState {
1303    // +spec:overflow:60f6a1 - scroll origin defaults to block-start inline-start corner (0,0)
1304    /// Create a new scroll state initialized at offset (0, 0).
1305    pub(crate) const fn new(now: Instant) -> Self {
1306        Self {
1307            current_offset: LogicalPosition::zero(),
1308            animation: None,
1309            last_activity: now,
1310            container_rect: LogicalRect::zero(),
1311            content_rect: LogicalRect::zero(),
1312            virtual_scroll_size: None,
1313            virtual_scroll_offset: None,
1314            overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
1315            overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
1316            overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
1317            scrollbar_thickness: crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX,
1318            visual_width_px: 0.0,
1319            has_horizontal_scrollbar: false,
1320            has_vertical_scrollbar: false,
1321        }
1322    }
1323
1324    /// Clamp a scroll position to valid bounds (0 to `max_scroll`).
1325    ///
1326    /// When `virtual_scroll_size` is set (for `VirtualView` nodes), the max bounds
1327    /// are computed from the virtual size instead of `content_rect`.
1328    pub(crate) fn clamp(&self, position: LogicalPosition) -> LogicalPosition {
1329        let effective_width = self.virtual_scroll_size
1330            .map_or(self.content_rect.size.width, |s| s.width);
1331        let effective_height = self.virtual_scroll_size
1332            .map_or(self.content_rect.size.height, |s| s.height);
1333        let max_x = (effective_width - self.container_rect.size.width).max(0.0);
1334        let max_y = (effective_height - self.container_rect.size.height).max(0.0);
1335        LogicalPosition {
1336            x: position.x.max(0.0).min(max_x),
1337            y: position.y.max(0.0).min(max_y),
1338        }
1339    }
1340}
1341
1342// Easing Functions
1343
1344/// Apply an easing function to a normalized time value (0.0 to 1.0).
1345/// Used by `ScrollAnimation::tick()` for smooth scroll animations.
1346#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
1347pub(crate) fn apply_easing(t: f32, easing: EasingFunction) -> f32 {
1348    match easing {
1349        EasingFunction::Linear => t,
1350        EasingFunction::EaseOut => 1.0 - (1.0 - t).powi(3),
1351        EasingFunction::EaseInOut => {
1352            if t < 0.5 {
1353                4.0 * t * t * t
1354            } else {
1355                1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
1356            }
1357        }
1358    }
1359}
1360
1361impl crate::managers::NodeIdRemap for ScrollManager {
1362    /// Rewrite every `(DomId, NodeId)` key for `dom` and DROP the scroll state of
1363    /// nodes that were unmounted.
1364    ///
1365    /// The previous implementation only rewrote keys whose id actually changed and
1366    /// *kept* everything else "conservatively" — which silently re-attached the
1367    /// scroll offset of a deleted node to whatever node inherited its index.
1368    /// `node_moves` contains an entry for every matched node, so "absent from the
1369    /// map" unambiguously means "unmounted".
1370    fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
1371        crate::managers::remap_dom_keys(&mut self.states, dom, map);
1372
1373        let old = core::mem::take(&mut self.scrollbar_states);
1374        for ((d, old_node_id, orientation), state) in old {
1375            if d != dom {
1376                self.scrollbar_states
1377                    .insert((d, old_node_id, orientation), state);
1378            } else if let Some(new_node_id) = map.resolve(old_node_id) {
1379                self.scrollbar_states
1380                    .insert((d, new_node_id, orientation), state);
1381            }
1382        }
1383    }
1384}
1385
1386// ============================================================================
1387// Natural-scroll direction — unit tests (#17)
1388// ============================================================================
1389#[cfg(all(test, feature = "std"))]
1390mod natural_scroll_tests {
1391    use super::*;
1392    use azul_core::dom::{DomId, NodeId};
1393    use azul_core::geom::LogicalPosition;
1394    use azul_core::task::Instant;
1395
1396    fn raw_input(dx: f32, dy: f32) -> ScrollInput {
1397        ScrollInput {
1398            dom_id: DomId::ROOT_ID,
1399            node_id: NodeId::new(0),
1400            delta: LogicalPosition::new(dx, dy),
1401            timestamp: Instant::now(),
1402            source: ScrollInputSource::WheelDiscrete,
1403        }
1404    }
1405
1406    #[test]
1407    #[allow(clippy::float_cmp)] // test asserts exact float equality on deterministic values
1408    fn default_is_traditional_and_inverts_raw_delta() {
1409        // With AZ_NATURAL_SCROLL unset, the default is traditional: the offset
1410        // delta is the NEGATION of the raw input — exactly what the per-platform
1411        // `-delta` hardcodes used to do, now centralised.
1412        let mut m = ScrollManager::new();
1413        assert!(!m.is_natural_scroll(), "default must be traditional");
1414        m.record_scroll_input(raw_input(3.0, 10.0));
1415        let q = m.get_input_queue().take_all();
1416        assert_eq!(q.len(), 1);
1417        assert_eq!(q[0].delta.x, -3.0, "x must be inverted by the default sign");
1418        assert_eq!(q[0].delta.y, -10.0, "y must be inverted by the default sign");
1419    }
1420
1421    #[test]
1422    #[allow(clippy::float_cmp)] // test asserts exact float equality on deterministic values
1423    fn natural_passes_raw_delta_through() {
1424        let mut m = ScrollManager::new();
1425        m.set_natural_scroll(true);
1426        assert!(m.is_natural_scroll());
1427        m.record_scroll_input(raw_input(3.0, 10.0));
1428        let q = m.get_input_queue().take_all();
1429        assert_eq!(q.len(), 1);
1430        assert_eq!(q[0].delta.x, 3.0, "natural mode must NOT invert x");
1431        assert_eq!(q[0].delta.y, 10.0, "natural mode must NOT invert y");
1432    }
1433
1434    #[test]
1435    #[allow(clippy::float_cmp)] // test asserts exact float equality on deterministic values
1436    fn toggling_flips_sign_for_subsequent_input() {
1437        // Same raw input, opposite directions before/after the toggle — proves the
1438        // single flag is the only thing controlling direction.
1439        let mut m = ScrollManager::new();
1440        m.record_scroll_input(raw_input(0.0, 5.0));
1441        m.set_natural_scroll(true);
1442        m.record_scroll_input(raw_input(0.0, 5.0));
1443        let q = m.get_input_queue().take_all();
1444        assert_eq!(q.len(), 2);
1445        assert_eq!(q[0].delta.y, -5.0, "traditional first");
1446        assert_eq!(q[1].delta.y, 5.0, "natural after toggle");
1447    }
1448
1449    // MWA-B2: nested-scroll target selection (innermost-first + handoff).
1450
1451    fn nested_setup() -> (ScrollManager, DomId, NodeId, NodeId) {
1452        use azul_core::geom::{LogicalRect, LogicalSize};
1453
1454        let now = Instant::now();
1455        let mut m = ScrollManager::new();
1456        let dom = DomId::ROOT_ID;
1457        // Ancestors have LOWER arena ids than descendants.
1458        let outer = NodeId::from_usize(1).unwrap();
1459        let inner = NodeId::from_usize(9).unwrap();
1460        // Outer: 200x200 viewport over 200x1000 content → max_y = 800.
1461        m.register_or_update_scroll_node(
1462            dom,
1463            outer,
1464            LogicalRect {
1465                origin: LogicalPosition::zero(),
1466                size: LogicalSize { width: 200.0, height: 200.0 },
1467            },
1468            LogicalSize { width: 200.0, height: 1000.0 },
1469            now.clone(),
1470            8.0,
1471            8.0,
1472            false,
1473            true,
1474        );
1475        // Inner: 100x100 viewport over 100x300 content → max_y = 200.
1476        m.register_or_update_scroll_node(
1477            dom,
1478            inner,
1479            LogicalRect {
1480                origin: LogicalPosition::zero(),
1481                size: LogicalSize { width: 100.0, height: 100.0 },
1482            },
1483            LogicalSize { width: 100.0, height: 300.0 },
1484            now,
1485            8.0,
1486            8.0,
1487            false,
1488            true,
1489        );
1490        (m, dom, outer, inner)
1491    }
1492
1493    #[test]
1494    fn nested_scroll_prefers_innermost_with_room() {
1495        let (m, dom, outer, inner) = nested_setup();
1496        // Innermost-first candidate order, scrolling "down" (eff +y).
1497        let picked = m.select_scroll_target(
1498            [(dom, inner), (dom, outer)].into_iter(),
1499            0.0,
1500            1.0,
1501        );
1502        assert_eq!(picked, Some((dom, inner)), "inner has room → inner wins");
1503    }
1504
1505    #[test]
1506    fn nested_scroll_hands_off_to_ancestor_at_boundary() {
1507        let (mut m, dom, outer, inner) = nested_setup();
1508        // Pin the inner container at its bottom edge (max_y = 200).
1509        m.states.get_mut(&(dom, inner)).unwrap().current_offset =
1510            LogicalPosition { x: 0.0, y: 200.0 };
1511
1512        let down = m.select_scroll_target(
1513            [(dom, inner), (dom, outer)].into_iter(),
1514            0.0,
1515            1.0,
1516        );
1517        assert_eq!(down, Some((dom, outer)), "inner pinned at bottom → handoff");
1518
1519        let up = m.select_scroll_target(
1520            [(dom, inner), (dom, outer)].into_iter(),
1521            0.0,
1522            -1.0,
1523        );
1524        assert_eq!(up, Some((dom, inner)), "inner has room upward → inner again");
1525    }
1526
1527    #[test]
1528    fn nested_scroll_falls_back_to_innermost_when_all_pinned() {
1529        let (mut m, dom, outer, inner) = nested_setup();
1530        m.states.get_mut(&(dom, inner)).unwrap().current_offset =
1531            LogicalPosition { x: 0.0, y: 200.0 };
1532        m.states.get_mut(&(dom, outer)).unwrap().current_offset =
1533            LogicalPosition { x: 0.0, y: 800.0 };
1534
1535        let picked = m.select_scroll_target(
1536            [(dom, inner), (dom, outer)].into_iter(),
1537            0.0,
1538            1.0,
1539        );
1540        assert_eq!(
1541            picked,
1542            Some((dom, inner)),
1543            "everything pinned → innermost fallback (gesture stays under pointer)"
1544        );
1545    }
1546}
1547
1548// ============================================================================
1549// Adversarial unit tests (autotest fleet)
1550//
1551// Hostile inputs for every category in the task file: numeric (NaN / ±inf /
1552// MIN / MAX / zero / saturation), predicates (invariants at the boundary),
1553// getters (defined value on a default/empty instance) and constructors.
1554// Every assertion below documents the *actual* behavior — nothing is weakened
1555// to make it pass.
1556// ============================================================================
1557#[cfg(all(test, feature = "std"))]
1558mod autotest_generated {
1559    #![allow(clippy::float_cmp)] // tests assert exact float results on deterministic inputs
1560
1561    use std::collections::HashMap;
1562
1563    use azul_core::{
1564        dom::{DomId, NodeId, ScrollbarOrientation},
1565        events::EasingFunction,
1566        geom::{LogicalPosition, LogicalRect, LogicalSize},
1567        hit_test::{FullHitTest, HitTest, OverflowingScrollNode, ScrollHitTestItem},
1568        styled_dom::NodeHierarchyItem,
1569        task::{Duration, Instant, SystemTick, SystemTickDiff, SystemTimeDiff},
1570    };
1571
1572    use super::*;
1573    use crate::managers::hover::HoverManager;
1574
1575    // ---------------------------------------------------------------- helpers
1576
1577    const DOM: DomId = DomId::ROOT_ID;
1578    const DOM1: DomId = DomId { inner: 1 };
1579
1580    fn node(i: usize) -> NodeId {
1581        NodeId::new(i)
1582    }
1583
1584    /// Deterministic tick-clock instant — no wall clock, no flakiness.
1585    fn at(t: u64) -> Instant {
1586        Instant::Tick(SystemTick::new(t))
1587    }
1588
1589    fn tick_dur(d: u64) -> Duration {
1590        Duration::Tick(SystemTickDiff { tick_diff: d })
1591    }
1592
1593    fn sys_dur(secs: u64, nanos: u32) -> Duration {
1594        Duration::System(SystemTimeDiff { secs, nanos })
1595    }
1596
1597    fn pos(x: f32, y: f32) -> LogicalPosition {
1598        LogicalPosition::new(x, y)
1599    }
1600
1601    fn size(w: f32, h: f32) -> LogicalSize {
1602        LogicalSize::new(w, h)
1603    }
1604
1605    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
1606        LogicalRect::new(pos(x, y), size(w, h))
1607    }
1608
1609    /// A manager with node 0 registered: `container` viewport over `content`.
1610    fn mgr(container: LogicalSize, content: LogicalSize) -> ScrollManager {
1611        let mut m = ScrollManager::new();
1612        m.register_or_update_scroll_node(
1613            DOM,
1614            node(0),
1615            LogicalRect::new(LogicalPosition::zero(), container),
1616            content,
1617            at(0),
1618            16.0,
1619            16.0,
1620            false,
1621            true,
1622        );
1623        m
1624    }
1625
1626    /// A bare `AnimatedScrollState` with the given container/content geometry.
1627    fn state(container: LogicalSize, content: LogicalSize) -> AnimatedScrollState {
1628        let mut s = AnimatedScrollState::new(at(0));
1629        s.container_rect = LogicalRect::new(LogicalPosition::zero(), container);
1630        s.content_rect = LogicalRect::new(LogicalPosition::zero(), content);
1631        s
1632    }
1633
1634    fn input(dx: f32, dy: f32, ts: u64) -> ScrollInput {
1635        ScrollInput {
1636            dom_id: DOM,
1637            node_id: node(0),
1638            delta: pos(dx, dy),
1639            timestamp: at(ts),
1640            source: ScrollInputSource::WheelDiscrete,
1641        }
1642    }
1643
1644    fn scrollbar(
1645        orientation: ScrollbarOrientation,
1646        track: LogicalRect,
1647        button_size: f32,
1648        thumb_offset: f32,
1649        thumb_length: f32,
1650    ) -> ScrollbarState {
1651        ScrollbarState {
1652            visible: true,
1653            orientation,
1654            base_size: 16.0,
1655            scale: LogicalPosition::new(1.0, 1.0),
1656            thumb_position_ratio: 0.0,
1657            thumb_size_ratio: 0.5,
1658            track_rect: track,
1659            button_size,
1660            usable_track_length: 0.0,
1661            thumb_length,
1662            thumb_offset,
1663        }
1664    }
1665
1666    /// A `HoverManager` whose current mouse hit-test reports `nodes` as scroll
1667    /// hit-test nodes in `DOM` (BTreeMap key order; `record_scroll_from_hit_test`
1668    /// walks them in reverse = innermost-first).
1669    fn hover_over(nodes: &[usize]) -> HoverManager {
1670        let mut ht = HitTest::empty();
1671        for n in nodes {
1672            ht.scroll_hit_test_nodes.insert(
1673                node(*n),
1674                ScrollHitTestItem {
1675                    point_in_viewport: LogicalPosition::zero(),
1676                    point_relative_to_item: LogicalPosition::zero(),
1677                    scroll_node: OverflowingScrollNode::default(),
1678                },
1679            );
1680        }
1681        let mut full = FullHitTest::empty(None);
1682        full.hovered_nodes.insert(DOM, ht);
1683        let mut hm = HoverManager::new();
1684        hm.push_hit_test(InputPointId::Mouse, full);
1685        hm
1686    }
1687
1688    // ============================================================ apply_easing
1689    // (numeric: zero / min_max / negative / overflow / nan_inf)
1690
1691    #[test]
1692    fn apply_easing_endpoints_are_exact_for_every_curve() {
1693        // The one invariant every easing curve must satisfy: f(0) == 0, f(1) == 1.
1694        // A violation here would make animations jump at their first/last tick.
1695        for e in [
1696            EasingFunction::Linear,
1697            EasingFunction::EaseOut,
1698            EasingFunction::EaseInOut,
1699        ] {
1700            assert_eq!(apply_easing(0.0, e), 0.0, "f(0) must be 0 for {e:?}");
1701            assert_eq!(apply_easing(1.0, e), 1.0, "f(1) must be 1 for {e:?}");
1702        }
1703    }
1704
1705    #[test]
1706    fn apply_easing_is_monotonic_and_bounded_on_the_unit_interval() {
1707        for e in [
1708            EasingFunction::Linear,
1709            EasingFunction::EaseOut,
1710            EasingFunction::EaseInOut,
1711        ] {
1712            let mut prev = f32::NEG_INFINITY;
1713            for i in 0..=100 {
1714                let t = i as f32 / 100.0;
1715                let v = apply_easing(t, e);
1716                assert!(v.is_finite(), "{e:?}({t}) must be finite, got {v}");
1717                assert!(
1718                    (-1e-6..=1.0 + 1e-6).contains(&v),
1719                    "{e:?}({t}) = {v} escaped [0, 1]"
1720                );
1721                assert!(v >= prev - 1e-6, "{e:?} must not go backwards at t={t}");
1722                prev = v;
1723            }
1724        }
1725    }
1726
1727    #[test]
1728    fn apply_easing_nan_propagates_without_panicking() {
1729        // NaN in => NaN out for every curve (no comparison panic, no unwrap).
1730        for e in [
1731            EasingFunction::Linear,
1732            EasingFunction::EaseOut,
1733            EasingFunction::EaseInOut,
1734        ] {
1735            assert!(
1736                apply_easing(f32::NAN, e).is_nan(),
1737                "{e:?}(NaN) must be NaN, not a silently-wrong number"
1738            );
1739        }
1740    }
1741
1742    #[test]
1743    fn apply_easing_infinities_saturate_to_infinity_not_panic() {
1744        assert_eq!(apply_easing(f32::INFINITY, EasingFunction::Linear), f32::INFINITY);
1745        assert_eq!(
1746            apply_easing(f32::NEG_INFINITY, EasingFunction::Linear),
1747            f32::NEG_INFINITY
1748        );
1749        // EaseOut: 1 - (1 - inf)^3 = 1 + inf
1750        assert_eq!(apply_easing(f32::INFINITY, EasingFunction::EaseOut), f32::INFINITY);
1751        assert_eq!(
1752            apply_easing(f32::NEG_INFINITY, EasingFunction::EaseOut),
1753            f32::NEG_INFINITY
1754        );
1755        // EaseInOut: t >= 0.5 branch for +inf, t < 0.5 branch for -inf
1756        assert_eq!(
1757            apply_easing(f32::INFINITY, EasingFunction::EaseInOut),
1758            f32::INFINITY
1759        );
1760        assert_eq!(
1761            apply_easing(f32::NEG_INFINITY, EasingFunction::EaseInOut),
1762            f32::NEG_INFINITY
1763        );
1764    }
1765
1766    #[test]
1767    fn apply_easing_f32_extremes_do_not_panic() {
1768        // powi(3) overflows f32 for MIN/MAX inputs — must saturate to +-inf,
1769        // never trap. (Callers clamp t to [0, 1]; this is the defense in depth.)
1770        for e in [
1771            EasingFunction::Linear,
1772            EasingFunction::EaseOut,
1773            EasingFunction::EaseInOut,
1774        ] {
1775            let hi = apply_easing(f32::MAX, e);
1776            let lo = apply_easing(f32::MIN, e);
1777            assert!(!hi.is_nan(), "{e:?}(f32::MAX) must not be NaN");
1778            assert!(!lo.is_nan(), "{e:?}(f32::MIN) must not be NaN");
1779        }
1780        // Subnormal / smallest positive: stays ~0, no denormal blowup.
1781        assert!(apply_easing(f32::MIN_POSITIVE, EasingFunction::EaseInOut).abs() < 1e-30);
1782    }
1783
1784    #[test]
1785    fn apply_easing_negative_t_is_deterministic_extrapolation() {
1786        // Out-of-range t is not clamped by apply_easing (the caller does that);
1787        // pin the exact extrapolated values so a silent change is caught.
1788        assert_eq!(apply_easing(-1.0, EasingFunction::Linear), -1.0);
1789        assert_eq!(apply_easing(-1.0, EasingFunction::EaseOut), -7.0);
1790        assert_eq!(apply_easing(-1.0, EasingFunction::EaseInOut), -4.0);
1791    }
1792
1793    #[test]
1794    fn apply_easing_ease_in_out_is_continuous_at_the_branch_boundary() {
1795        // t == 0.5 takes the `else` branch; both halves must meet at 0.5.
1796        assert_eq!(apply_easing(0.5, EasingFunction::EaseInOut), 0.5);
1797        let just_below = apply_easing(0.499_999, EasingFunction::EaseInOut);
1798        assert!(
1799            (just_below - 0.5).abs() < 1e-4,
1800            "discontinuity at the 0.5 branch: {just_below}"
1801        );
1802        assert_eq!(apply_easing(0.5, EasingFunction::EaseOut), 0.875);
1803    }
1804
1805    // ================================================ AnimatedScrollState::new
1806    // (constructor: no_panic / invariants_hold)
1807
1808    #[test]
1809    fn animated_scroll_state_new_starts_at_scroll_origin() {
1810        let s = AnimatedScrollState::new(at(0));
1811        assert_eq!(s.current_offset, LogicalPosition::zero());
1812        assert!(s.animation.is_none());
1813        assert_eq!(s.container_rect, LogicalRect::zero());
1814        assert_eq!(s.content_rect, LogicalRect::zero());
1815        assert!(s.virtual_scroll_size.is_none());
1816        assert!(s.virtual_scroll_offset.is_none());
1817        assert!(!s.has_horizontal_scrollbar);
1818        assert!(!s.has_vertical_scrollbar);
1819        // A zero-sized state has no travel: clamp must pin everything to origin.
1820        assert_eq!(s.clamp(pos(1e9, 1e9)), LogicalPosition::zero());
1821    }
1822
1823    // ============================================== AnimatedScrollState::clamp
1824    // (numeric: zero / min_max / negative / overflow)
1825
1826    #[test]
1827    fn clamp_pins_to_zero_and_max_travel() {
1828        let s = state(size(100.0, 100.0), size(100.0, 500.0));
1829        // max_x = 0 (no horizontal overflow), max_y = 400.
1830        assert_eq!(s.clamp(pos(0.0, 0.0)), pos(0.0, 0.0));
1831        assert_eq!(s.clamp(pos(50.0, 250.0)), pos(0.0, 250.0));
1832        assert_eq!(s.clamp(pos(-1.0, -1.0)), pos(0.0, 0.0));
1833        assert_eq!(s.clamp(pos(9999.0, 9999.0)), pos(0.0, 400.0));
1834    }
1835
1836    #[test]
1837    fn clamp_never_produces_negative_max_when_content_is_smaller_than_container() {
1838        // Content smaller than the viewport => max travel is 0, not negative.
1839        let s = state(size(500.0, 500.0), size(10.0, 10.0));
1840        assert_eq!(s.clamp(pos(100.0, 100.0)), LogicalPosition::zero());
1841        assert_eq!(s.clamp(pos(-100.0, -100.0)), LogicalPosition::zero());
1842    }
1843
1844    #[test]
1845    fn clamp_nan_position_collapses_to_origin_never_stores_nan() {
1846        // f32::max(NaN, 0.0) == 0.0, so a NaN offset is sanitized to the origin.
1847        // This is the property the whole scroll pipeline relies on to stay finite.
1848        let s = state(size(100.0, 100.0), size(100.0, 500.0));
1849        let c = s.clamp(pos(f32::NAN, f32::NAN));
1850        assert!(!c.x.is_nan() && !c.y.is_nan(), "clamp must not leak NaN");
1851        assert_eq!(c, LogicalPosition::zero());
1852    }
1853
1854    #[test]
1855    fn clamp_infinite_position_saturates_to_max_travel() {
1856        let s = state(size(100.0, 100.0), size(100.0, 500.0));
1857        assert_eq!(s.clamp(pos(f32::INFINITY, f32::INFINITY)), pos(0.0, 400.0));
1858        assert_eq!(
1859            s.clamp(pos(f32::NEG_INFINITY, f32::NEG_INFINITY)),
1860            LogicalPosition::zero()
1861        );
1862        assert_eq!(s.clamp(pos(f32::MAX, f32::MAX)), pos(0.0, 400.0));
1863        assert_eq!(s.clamp(pos(f32::MIN, f32::MIN)), LogicalPosition::zero());
1864    }
1865
1866    #[test]
1867    fn clamp_nan_geometry_degrades_to_zero_travel() {
1868        // A NaN content size must not poison the offset: (NaN - w).max(0.0) == 0.0.
1869        let s = state(size(100.0, 100.0), size(f32::NAN, f32::NAN));
1870        let c = s.clamp(pos(50.0, 50.0));
1871        assert!(!c.x.is_nan() && !c.y.is_nan());
1872        assert_eq!(c, LogicalPosition::zero());
1873    }
1874
1875    #[test]
1876    fn clamp_infinite_content_minus_infinite_container_is_zero_travel_not_nan() {
1877        // inf - inf = NaN; `.max(0.0)` rescues it to 0.
1878        let s = state(
1879            size(f32::INFINITY, f32::INFINITY),
1880            size(f32::INFINITY, f32::INFINITY),
1881        );
1882        let c = s.clamp(pos(10.0, 10.0));
1883        assert!(!c.x.is_nan() && !c.y.is_nan());
1884        assert_eq!(c, LogicalPosition::zero());
1885    }
1886
1887    #[test]
1888    fn clamp_prefers_virtual_scroll_size_over_content_rect() {
1889        let mut s = state(size(100.0, 100.0), size(100.0, 120.0));
1890        assert_eq!(s.clamp(pos(0.0, 1e9)), pos(0.0, 20.0), "content_rect bound");
1891        s.virtual_scroll_size = Some(size(100.0, 10_000.0));
1892        assert_eq!(
1893            s.clamp(pos(0.0, 1e9)),
1894            pos(0.0, 9900.0),
1895            "virtual size must override content_rect"
1896        );
1897    }
1898
1899    // ============================================== ScrollInputQueue (std only)
1900    // (constructor / getter / predicate / numeric)
1901
1902    #[test]
1903    fn input_queue_new_is_empty_and_default_matches() {
1904        let q = ScrollInputQueue::new();
1905        assert!(!q.has_pending());
1906        assert!(q.take_all().is_empty());
1907        assert!(q.take_recent(10).is_empty());
1908        assert!(!ScrollInputQueue::default().has_pending());
1909    }
1910
1911    #[test]
1912    fn input_queue_take_all_drains_and_preserves_push_order() {
1913        let q = ScrollInputQueue::new();
1914        q.push(input(1.0, 1.0, 1));
1915        q.push(input(2.0, 2.0, 2));
1916        assert!(q.has_pending());
1917        let taken = q.take_all();
1918        assert_eq!(taken.len(), 2);
1919        assert_eq!(taken[0].delta.x, 1.0);
1920        assert_eq!(taken[1].delta.x, 2.0);
1921        assert!(!q.has_pending(), "take_all must drain the queue");
1922        assert!(q.take_all().is_empty(), "second take_all is empty, not stale");
1923    }
1924
1925    #[test]
1926    fn input_queue_take_recent_zero_discards_everything() {
1927        // max_events = 0: `drain(..len - 0)` removes every event. Documented as
1928        // "older events beyond max_events are discarded" — with 0 that is all of
1929        // them, and the queue is left empty (the backlog is dropped, not kept).
1930        let q = ScrollInputQueue::new();
1931        q.push(input(1.0, 1.0, 1));
1932        q.push(input(2.0, 2.0, 2));
1933        let taken = q.take_recent(0);
1934        assert!(taken.is_empty(), "take_recent(0) must return nothing");
1935        assert!(!q.has_pending(), "take_recent(0) must still drain the queue");
1936    }
1937
1938    #[test]
1939    fn input_queue_take_recent_keeps_the_newest_events_sorted_oldest_first() {
1940        let q = ScrollInputQueue::new();
1941        // Pushed out of timestamp order on purpose.
1942        q.push(input(0.0, 0.0, 5));
1943        q.push(input(0.0, 0.0, 1));
1944        q.push(input(0.0, 0.0, 3));
1945        q.push(input(0.0, 0.0, 9));
1946        let taken = q.take_recent(2);
1947        assert_eq!(taken.len(), 2, "backlog must be truncated to max_events");
1948        assert_eq!(taken[0].timestamp, at(5));
1949        assert_eq!(taken[1].timestamp, at(9), "newest event must be last");
1950        assert!(!q.has_pending());
1951    }
1952
1953    #[test]
1954    fn input_queue_take_recent_below_limit_returns_push_order_not_sorted() {
1955        // NOTE: the doc says "sorted by timestamp (newest last)", but the sort
1956        // only runs on the overflow path (len > max_events). Below the limit the
1957        // events come back in PUSH order. Pinning the real behavior here.
1958        let q = ScrollInputQueue::new();
1959        q.push(input(0.0, 0.0, 5));
1960        q.push(input(0.0, 0.0, 1));
1961        q.push(input(0.0, 0.0, 3));
1962        let taken = q.take_recent(3);
1963        assert_eq!(taken.len(), 3);
1964        let stamps: Vec<_> = taken.iter().map(|e| e.timestamp.clone()).collect();
1965        assert_eq!(stamps, vec![at(5), at(1), at(3)]);
1966    }
1967
1968    #[test]
1969    fn input_queue_take_recent_usize_max_does_not_overflow() {
1970        // `events.len() - max_events` would underflow if the length guard were
1971        // wrong; usize::MAX must simply mean "take everything".
1972        let q = ScrollInputQueue::new();
1973        q.push(input(1.0, 0.0, 1));
1974        q.push(input(2.0, 0.0, 2));
1975        let taken = q.take_recent(usize::MAX);
1976        assert_eq!(taken.len(), 2);
1977        assert!(!q.has_pending());
1978        // Empty queue + usize::MAX: still no underflow, no panic.
1979        assert!(q.take_recent(usize::MAX).is_empty());
1980        assert!(q.take_recent(0).is_empty());
1981    }
1982
1983    #[test]
1984    fn input_queue_clone_shares_one_backing_store() {
1985        // The timer callback holds a clone; a push through either handle must be
1986        // visible to the other, otherwise inputs would silently vanish.
1987        let q = ScrollInputQueue::new();
1988        let c = q.clone();
1989        c.push(input(1.0, 2.0, 1));
1990        assert!(q.has_pending(), "clone must not deep-copy the queue");
1991        assert_eq!(q.take_all().len(), 1);
1992        assert!(!c.has_pending(), "draining one handle drains both");
1993    }
1994
1995    #[test]
1996    fn input_queue_accepts_non_finite_deltas_without_panicking() {
1997        let q = ScrollInputQueue::new();
1998        q.push(input(f32::NAN, f32::INFINITY, 1));
1999        q.push(input(f32::MAX, f32::MIN, 2));
2000        let taken = q.take_recent(usize::MAX);
2001        assert_eq!(taken.len(), 2);
2002        assert!(taken[0].delta.x.is_nan());
2003        assert_eq!(taken[0].delta.y, f32::INFINITY);
2004    }
2005
2006    // ======================================= ScrollbarState::hit_test_component
2007    // (numeric: zero / negative / nan_inf / boundary)
2008
2009    #[test]
2010    fn hit_test_component_vertical_maps_each_region() {
2011        let sb = scrollbar(
2012            ScrollbarOrientation::Vertical,
2013            rect(0.0, 0.0, 16.0, 100.0),
2014            16.0, // button_size
2015            10.0, // thumb_offset (from end of top button)
2016            30.0, // thumb_length
2017        );
2018        assert_eq!(sb.hit_test_component(pos(8.0, 0.0)), ScrollbarComponent::TopButton);
2019        assert_eq!(sb.hit_test_component(pos(8.0, 15.9)), ScrollbarComponent::TopButton);
2020        assert_eq!(
2021            sb.hit_test_component(pos(8.0, 99.0)),
2022            ScrollbarComponent::BottomButton
2023        );
2024        // Thumb spans [16 + 10, 16 + 10 + 30] = [26, 56].
2025        assert_eq!(sb.hit_test_component(pos(8.0, 26.0)), ScrollbarComponent::Thumb);
2026        assert_eq!(sb.hit_test_component(pos(8.0, 56.0)), ScrollbarComponent::Thumb);
2027        assert_eq!(sb.hit_test_component(pos(8.0, 20.0)), ScrollbarComponent::Track);
2028        assert_eq!(sb.hit_test_component(pos(8.0, 60.0)), ScrollbarComponent::Track);
2029    }
2030
2031    #[test]
2032    fn hit_test_component_boundaries_are_exact() {
2033        let sb = scrollbar(
2034            ScrollbarOrientation::Vertical,
2035            rect(0.0, 0.0, 16.0, 100.0),
2036            16.0,
2037            0.0,
2038            30.0,
2039        );
2040        // y == button_size is NOT the top button (strict <) — it is the thumb start.
2041        assert_eq!(sb.hit_test_component(pos(0.0, 16.0)), ScrollbarComponent::Thumb);
2042        // y == track_height - button_size is NOT the bottom button (strict >).
2043        assert_eq!(sb.hit_test_component(pos(0.0, 84.0)), ScrollbarComponent::Track);
2044        assert_eq!(
2045            sb.hit_test_component(pos(0.0, 84.001)),
2046            ScrollbarComponent::BottomButton
2047        );
2048    }
2049
2050    #[test]
2051    fn hit_test_component_overlay_zero_button_size_has_no_buttons() {
2052        // Overlay scrollbars get button_size == 0: y == 0 must NOT be a TopButton
2053        // (strict `<` means the button region is empty).
2054        let sb = scrollbar(
2055            ScrollbarOrientation::Vertical,
2056            rect(0.0, 0.0, 8.0, 100.0),
2057            0.0,
2058            0.0,
2059            50.0,
2060        );
2061        assert_eq!(sb.hit_test_component(pos(0.0, 0.0)), ScrollbarComponent::Thumb);
2062        assert_eq!(sb.hit_test_component(pos(0.0, 50.0)), ScrollbarComponent::Thumb);
2063        assert_eq!(sb.hit_test_component(pos(0.0, 60.0)), ScrollbarComponent::Track);
2064        // y == track_height is still not "> track_height - 0" ... it IS equal, so Track.
2065        assert_eq!(sb.hit_test_component(pos(0.0, 100.0)), ScrollbarComponent::Track);
2066    }
2067
2068    #[test]
2069    fn hit_test_component_nan_position_falls_through_to_track() {
2070        // Every float comparison against NaN is false, so NaN lands in the
2071        // final `else` — Track. Deterministic, no panic, no phantom button click.
2072        let sb = scrollbar(
2073            ScrollbarOrientation::Vertical,
2074            rect(0.0, 0.0, 16.0, 100.0),
2075            16.0,
2076            10.0,
2077            30.0,
2078        );
2079        assert_eq!(
2080            sb.hit_test_component(pos(f32::NAN, f32::NAN)),
2081            ScrollbarComponent::Track
2082        );
2083        let hb = scrollbar(
2084            ScrollbarOrientation::Horizontal,
2085            rect(0.0, 0.0, 100.0, 16.0),
2086            16.0,
2087            10.0,
2088            30.0,
2089        );
2090        assert_eq!(
2091            hb.hit_test_component(pos(f32::NAN, f32::NAN)),
2092            ScrollbarComponent::Track
2093        );
2094    }
2095
2096    #[test]
2097    fn hit_test_component_infinite_position_picks_an_end_button() {
2098        let sb = scrollbar(
2099            ScrollbarOrientation::Vertical,
2100            rect(0.0, 0.0, 16.0, 100.0),
2101            16.0,
2102            10.0,
2103            30.0,
2104        );
2105        assert_eq!(
2106            sb.hit_test_component(pos(0.0, f32::NEG_INFINITY)),
2107            ScrollbarComponent::TopButton
2108        );
2109        assert_eq!(
2110            sb.hit_test_component(pos(0.0, f32::INFINITY)),
2111            ScrollbarComponent::BottomButton
2112        );
2113        assert_eq!(
2114            sb.hit_test_component(pos(0.0, f32::MIN)),
2115            ScrollbarComponent::TopButton
2116        );
2117        assert_eq!(
2118            sb.hit_test_component(pos(0.0, f32::MAX)),
2119            ScrollbarComponent::BottomButton
2120        );
2121    }
2122
2123    #[test]
2124    fn hit_test_component_ignores_the_cross_axis() {
2125        // A vertical scrollbar must not care about x (and vice versa) — otherwise
2126        // a drag that leaves the bar sideways would change component mid-gesture.
2127        let v = scrollbar(
2128            ScrollbarOrientation::Vertical,
2129            rect(0.0, 0.0, 16.0, 100.0),
2130            16.0,
2131            10.0,
2132            30.0,
2133        );
2134        for x in [-1e9, -1.0, 0.0, 8.0, 1e9, f32::NAN] {
2135            assert_eq!(v.hit_test_component(pos(x, 30.0)), ScrollbarComponent::Thumb);
2136        }
2137        let h = scrollbar(
2138            ScrollbarOrientation::Horizontal,
2139            rect(0.0, 0.0, 100.0, 16.0),
2140            16.0,
2141            10.0,
2142            30.0,
2143        );
2144        for y in [-1e9, -1.0, 0.0, 8.0, 1e9, f32::NAN] {
2145            assert_eq!(h.hit_test_component(pos(30.0, y)), ScrollbarComponent::Thumb);
2146        }
2147    }
2148
2149    #[test]
2150    fn hit_test_component_degenerate_track_shorter_than_buttons_prefers_top() {
2151        // button_size > track length: the top/bottom regions overlap. First match
2152        // wins (TopButton) — no panic, no ambiguity.
2153        let sb = scrollbar(
2154            ScrollbarOrientation::Vertical,
2155            rect(0.0, 0.0, 16.0, 4.0),
2156            16.0,
2157            0.0,
2158            0.0,
2159        );
2160        assert_eq!(sb.hit_test_component(pos(0.0, 0.0)), ScrollbarComponent::TopButton);
2161        assert_eq!(sb.hit_test_component(pos(0.0, 3.0)), ScrollbarComponent::TopButton);
2162    }
2163
2164    // ========================================================= ScrollManager::new
2165    // (constructor / getters / predicates on an empty instance)
2166
2167    #[test]
2168    fn manager_new_is_empty_and_traditional_by_default() {
2169        let m = ScrollManager::new();
2170        assert_eq!(m.debug_counts(), (0, 0));
2171        assert!(!m.has_active_animations());
2172        assert!(!m.has_pending_scroll_changes());
2173        assert!(!m.is_natural_scroll());
2174        assert_eq!(m.scroll_sign(), -1.0);
2175        assert!(m.pending_wheel_event.is_none());
2176        assert!(!m.get_input_queue().has_pending());
2177        // Getters on an empty manager return None / empty, never panic.
2178        assert!(m.get_current_offset(DOM, node(0)).is_none());
2179        assert!(m.get_last_activity_time(DOM, node(0)).is_none());
2180        assert!(m.get_scroll_state(DOM, node(0)).is_none());
2181        assert!(m.get_scroll_node_info(DOM, node(0)).is_none());
2182        assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
2183        assert!(m.get_scroll_states_for_dom(DOM).is_empty());
2184        assert!(m
2185            .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
2186            .is_none());
2187        assert!(m.hit_test_scrollbars(pos(0.0, 0.0)).is_none());
2188        assert_eq!(m.iter_scrollbar_states().count(), 0);
2189        assert!(!m.is_node_scrollable(DOM, node(0)));
2190        assert!(!m.can_consume_delta(DOM, node(0), 10.0, 10.0));
2191    }
2192
2193    #[test]
2194    fn scroll_sign_flips_with_the_preference() {
2195        let mut m = ScrollManager::new();
2196        assert_eq!(m.scroll_sign(), -1.0);
2197        m.set_natural_scroll(true);
2198        assert!(m.is_natural_scroll());
2199        assert_eq!(m.scroll_sign(), 1.0);
2200        m.set_natural_scroll(false);
2201        assert_eq!(m.scroll_sign(), -1.0);
2202        // Idempotent: setting the same value twice must not toggle.
2203        m.set_natural_scroll(false);
2204        assert_eq!(m.scroll_sign(), -1.0);
2205    }
2206
2207    // ================================================= dirty-flag bookkeeping
2208    // (predicate: has_pending_scroll_changes / clear_scroll_dirty)
2209
2210    #[test]
2211    fn scroll_dirty_is_set_only_on_a_real_move_and_cleared_on_demand() {
2212        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2213        assert!(!m.has_pending_scroll_changes());
2214
2215        // Sub-epsilon move (< SCROLL_CHANGE_EPSILON = 0.01) must NOT dirty the
2216        // display list — otherwise every trackpad jitter forces a rebuild.
2217        m.set_scroll_position(DOM, node(0), pos(0.0, 0.005), at(1));
2218        assert!(!m.has_pending_scroll_changes(), "0.005px must not be 'moved'");
2219
2220        m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(2));
2221        assert!(m.has_pending_scroll_changes());
2222
2223        m.clear_scroll_dirty();
2224        assert!(!m.has_pending_scroll_changes());
2225        // Setting the SAME position again is a no-op move: stays clean.
2226        m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(3));
2227        assert!(!m.has_pending_scroll_changes());
2228    }
2229
2230    #[test]
2231    fn clear_scroll_dirty_on_a_clean_manager_is_a_noop() {
2232        let mut m = ScrollManager::new();
2233        m.clear_scroll_dirty();
2234        m.clear_scroll_dirty();
2235        assert!(!m.has_pending_scroll_changes());
2236    }
2237
2238    // ======================================= set_scroll_position (+unclamped)
2239    // (numeric: zero / min_max / negative / overflow / nan)
2240
2241    #[test]
2242    fn set_scroll_position_clamps_extremes_into_bounds() {
2243        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2244        m.set_scroll_position(DOM, node(0), pos(f32::MAX, f32::MAX), at(1));
2245        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2246
2247        m.set_scroll_position(DOM, node(0), pos(f32::MIN, f32::MIN), at(2));
2248        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 0.0)));
2249
2250        m.set_scroll_position(DOM, node(0), pos(f32::INFINITY, f32::INFINITY), at(3));
2251        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2252
2253        m.set_scroll_position(DOM, node(0), pos(f32::NAN, f32::NAN), at(4));
2254        let off = m.get_current_offset(DOM, node(0)).unwrap();
2255        assert!(!off.x.is_nan() && !off.y.is_nan(), "clamped path must kill NaN");
2256        assert_eq!(off, LogicalPosition::zero());
2257    }
2258
2259    #[test]
2260    fn set_scroll_position_cancels_a_running_animation() {
2261        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2262        m.scroll_to(DOM, node(0), pos(0.0, 300.0), tick_dur(100), EasingFunction::Linear, at(0));
2263        assert!(m.has_active_animations());
2264        m.set_scroll_position(DOM, node(0), pos(0.0, 10.0), at(1));
2265        assert!(!m.has_active_animations(), "an explicit set must win over easing");
2266        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 10.0)));
2267    }
2268
2269    #[test]
2270    fn set_scroll_position_on_an_unknown_node_creates_a_pinned_zero_state() {
2271        // The entry API inserts a zero-sized state, so the offset can only be 0 —
2272        // and the map grows by exactly one (no unbounded growth per call).
2273        let mut m = ScrollManager::new();
2274        m.set_scroll_position(DOM, node(42), pos(500.0, 500.0), at(1));
2275        assert_eq!(m.get_current_offset(DOM, node(42)), Some(LogicalPosition::zero()));
2276        assert_eq!(m.debug_counts(), (1, 0));
2277        m.set_scroll_position(DOM, node(42), pos(600.0, 600.0), at(2));
2278        assert_eq!(m.debug_counts(), (1, 0), "repeat set must not grow the map");
2279    }
2280
2281    #[test]
2282    fn set_scroll_position_unclamped_keeps_overscroll_values_verbatim() {
2283        // The physics timer relies on being able to push the offset OUTSIDE
2284        // [0, max] for rubber-banding — clamping here would kill the bounce.
2285        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2286        m.set_scroll_position_unclamped(DOM, node(0), pos(-50.0, -80.0), at(1));
2287        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(-50.0, -80.0)));
2288        m.set_scroll_position_unclamped(DOM, node(0), pos(0.0, 9999.0), at(2));
2289        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 9999.0)));
2290        assert!(m.has_pending_scroll_changes());
2291    }
2292
2293    #[test]
2294    fn set_scroll_position_unclamped_stores_non_finite_values_unfiltered() {
2295        // Documents a real hazard: the unclamped path performs NO sanitization,
2296        // so a NaN delta from a driver would be stored verbatim AND (because
2297        // `(NaN - x).abs() > EPS` is false) would not even mark the state dirty.
2298        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2299        m.set_scroll_position_unclamped(DOM, node(0), pos(f32::NAN, f32::NAN), at(1));
2300        let off = m.get_current_offset(DOM, node(0)).unwrap();
2301        assert!(off.x.is_nan() && off.y.is_nan(), "unclamped stores NaN as-is");
2302        assert!(
2303            !m.has_pending_scroll_changes(),
2304            "a NaN write does not trip the dirty flag (NaN comparisons are false)"
2305        );
2306        // But a later re-registration re-clamps it back to a finite value.
2307        m.register_or_update_scroll_node(
2308            DOM,
2309            node(0),
2310            rect(0.0, 0.0, 100.0, 100.0),
2311            size(100.0, 500.0),
2312            at(2),
2313            16.0,
2314            16.0,
2315            false,
2316            true,
2317        );
2318        let off = m.get_current_offset(DOM, node(0)).unwrap();
2319        assert!(!off.x.is_nan() && !off.y.is_nan(), "re-clamp must sanitize NaN");
2320    }
2321
2322    // ================================================== scroll_to / scroll_by
2323    // (numeric + animation lifecycle)
2324
2325    #[test]
2326    fn scroll_to_zero_duration_is_immediate_for_both_clock_kinds() {
2327        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2328        m.scroll_to(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(1));
2329        assert!(!m.has_active_animations(), "zero Tick duration must not animate");
2330        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 100.0)));
2331
2332        m.scroll_to(
2333            DOM,
2334            node(0),
2335            pos(0.0, 200.0),
2336            sys_dur(0, 0),
2337            EasingFunction::EaseOut,
2338            at(2),
2339        );
2340        assert!(!m.has_active_animations(), "zero System duration must not animate");
2341        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
2342    }
2343
2344    #[test]
2345    fn scroll_to_clamps_the_animation_target_not_just_the_final_offset() {
2346        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2347        m.scroll_to(DOM, node(0), pos(0.0, 1e9), tick_dur(100), EasingFunction::Linear, at(0));
2348        let anim_target = m
2349            .get_scroll_state(DOM, node(0))
2350            .and_then(|s| s.animation.as_ref())
2351            .map(|a| a.target_offset)
2352            .unwrap();
2353        assert_eq!(anim_target, pos(0.0, 400.0), "target must be pre-clamped");
2354        // Drive it to completion: the offset lands exactly on the clamped target.
2355        let r = m.tick(at(100));
2356        assert!(r.needs_repaint);
2357        assert_eq!(r.updated_nodes, vec![(DOM, node(0))]);
2358        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2359        assert!(!m.has_active_animations(), "animation must clear at t >= 1");
2360    }
2361
2362    #[test]
2363    fn scroll_to_nan_target_animates_to_the_origin_never_to_nan() {
2364        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2365        m.set_scroll_position(DOM, node(0), pos(0.0, 200.0), at(0));
2366        m.scroll_to(
2367            DOM,
2368            node(0),
2369            pos(f32::NAN, f32::NAN),
2370            tick_dur(10),
2371            EasingFunction::Linear,
2372            at(0),
2373        );
2374        m.tick(at(10));
2375        let off = m.get_current_offset(DOM, node(0)).unwrap();
2376        assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN target must be clamped away");
2377        assert_eq!(off, LogicalPosition::zero());
2378    }
2379
2380    #[test]
2381    fn scroll_by_accumulates_from_the_current_offset_and_saturates() {
2382        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2383        m.scroll_by(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(1));
2384        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 100.0)));
2385        m.scroll_by(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(2));
2386        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
2387        // A delta big enough to overflow f32 arithmetic: saturates at max travel.
2388        m.scroll_by(
2389            DOM,
2390            node(0),
2391            pos(f32::MAX, f32::MAX),
2392            tick_dur(0),
2393            EasingFunction::Linear,
2394            at(3),
2395        );
2396        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2397        // ...and back down past the origin.
2398        m.scroll_by(
2399            DOM,
2400            node(0),
2401            pos(f32::MIN, f32::MIN),
2402            tick_dur(0),
2403            EasingFunction::Linear,
2404            at(4),
2405        );
2406        assert_eq!(m.get_current_offset(DOM, node(0)), Some(LogicalPosition::zero()));
2407    }
2408
2409    #[test]
2410    fn scroll_by_on_an_unknown_node_defaults_to_origin_and_stays_pinned() {
2411        let mut m = ScrollManager::new();
2412        m.scroll_by(
2413            DOM,
2414            node(7),
2415            pos(1e9, 1e9),
2416            tick_dur(0),
2417            EasingFunction::Linear,
2418            at(1),
2419        );
2420        // No bounds registered => max travel 0 => still at the origin, no panic.
2421        assert_eq!(m.get_current_offset(DOM, node(7)), Some(LogicalPosition::zero()));
2422    }
2423
2424    #[test]
2425    fn scroll_by_nan_delta_does_not_poison_the_offset() {
2426        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2427        m.set_scroll_position(DOM, node(0), pos(0.0, 100.0), at(0));
2428        m.scroll_by(
2429            DOM,
2430            node(0),
2431            pos(f32::NAN, f32::NAN),
2432            tick_dur(0),
2433            EasingFunction::Linear,
2434            at(1),
2435        );
2436        let off = m.get_current_offset(DOM, node(0)).unwrap();
2437        assert!(!off.x.is_nan() && !off.y.is_nan());
2438        assert_eq!(off, LogicalPosition::zero(), "NaN target clamps to origin");
2439    }
2440
2441    // =============================================================== tick()
2442    // (other: no_panic_smoke + animation invariants)
2443
2444    #[test]
2445    fn tick_on_an_empty_manager_reports_no_work() {
2446        let mut m = ScrollManager::new();
2447        let r = m.tick(at(1));
2448        assert!(!r.needs_repaint);
2449        assert!(r.updated_nodes.is_empty());
2450    }
2451
2452    #[test]
2453    fn tick_interpolates_linearly_and_completes_exactly_once() {
2454        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2455        m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(100), EasingFunction::Linear, at(0));
2456
2457        let r = m.tick(at(50));
2458        assert!(r.needs_repaint);
2459        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
2460        assert!(m.has_active_animations(), "still mid-flight at t = 0.5");
2461
2462        let r = m.tick(at(100));
2463        assert!(r.needs_repaint);
2464        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2465        assert!(!m.has_active_animations());
2466
2467        // Ticking past the end must be a no-op, not a re-run.
2468        let r = m.tick(at(500));
2469        assert!(!r.needs_repaint);
2470        assert!(r.updated_nodes.is_empty());
2471    }
2472
2473    #[test]
2474    fn tick_before_the_animation_start_time_saturates_to_zero_progress() {
2475        // `now` earlier than `start_time` => duration_since saturates to 0 =>
2476        // t = 0 => offset stays at start. No negative-progress overshoot.
2477        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2478        m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(0));
2479        m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(100), EasingFunction::Linear, at(100));
2480        m.tick(at(0)); // clock went backwards
2481        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
2482        assert!(m.has_active_animations(), "no progress => still animating");
2483    }
2484
2485    #[test]
2486    fn tick_with_a_zero_duration_animation_completes_instead_of_producing_nan() {
2487        // 0/0 = NaN, but `NaN.min(1.0)` == 1.0 in Rust, so the animation snaps to
2488        // its target and is cleared — the offset never becomes NaN. (scroll_to
2489        // short-circuits zero durations; this covers a hand-built animation, e.g.
2490        // one whose duration was computed to zero.)
2491        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2492        m.states.get_mut(&(DOM, node(0))).unwrap().animation = Some(ScrollAnimation {
2493            start_time: at(0),
2494            duration: tick_dur(0),
2495            start_offset: pos(0.0, 0.0),
2496            target_offset: pos(0.0, 300.0),
2497            easing: EasingFunction::Linear,
2498        });
2499        let r = m.tick(at(0));
2500        assert!(r.needs_repaint);
2501        let off = m.get_current_offset(DOM, node(0)).unwrap();
2502        assert!(!off.y.is_nan(), "0/0 must not leak NaN into the offset");
2503        assert_eq!(off, pos(0.0, 300.0));
2504        assert!(!m.has_active_animations());
2505    }
2506
2507    #[test]
2508    fn tick_with_a_mismatched_clock_kind_stalls_at_zero_instead_of_panicking() {
2509        // Tick-clock animation ticked by a System INSTANT. A System instant and a
2510        // Tick instant have no common origin, so `duration_since` has no
2511        // meaningful span to report and saturates to zero => t = 0 forever. The
2512        // animation never advances and never completes — but it does not panic or
2513        // corrupt the offset.
2514        //
2515        // Note this is specifically an INSTANT mismatch. A mismatch between the
2516        // elapsed DURATION's unit and the animation duration's unit is a
2517        // different thing entirely and does convert: `Duration::div` puts both on
2518        // a canonical nanosecond scale.
2519        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2520        m.set_scroll_position(DOM, node(0), pos(0.0, 25.0), at(0));
2521        m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(10), EasingFunction::Linear, at(0));
2522        let r = m.tick(Instant::now()); // System clock vs Tick animation
2523        assert!(r.needs_repaint);
2524        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 25.0)));
2525        assert!(
2526            m.has_active_animations(),
2527            "mismatched clocks stall the animation (t stays 0) — it never completes"
2528        );
2529    }
2530
2531    #[test]
2532    fn tick_advances_every_animating_node_in_one_pass() {
2533        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2534        m.register_or_update_scroll_node(
2535            DOM,
2536            node(1),
2537            rect(0.0, 0.0, 100.0, 100.0),
2538            size(100.0, 300.0),
2539            at(0),
2540            16.0,
2541            16.0,
2542            false,
2543            true,
2544        );
2545        m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(10), EasingFunction::Linear, at(0));
2546        m.scroll_to(DOM, node(1), pos(0.0, 200.0), tick_dur(10), EasingFunction::Linear, at(0));
2547        let r = m.tick(at(10));
2548        assert_eq!(r.updated_nodes.len(), 2);
2549        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2550        assert_eq!(m.get_current_offset(DOM, node(1)), Some(pos(0.0, 200.0)));
2551    }
2552
2553    // ============================================== register_or_update_scroll_node
2554    // (numeric: nan_inf / zero / min_max + no unbounded growth)
2555
2556    #[test]
2557    fn register_twice_updates_in_place_and_keeps_the_offset() {
2558        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2559        m.set_scroll_position(DOM, node(0), pos(0.0, 300.0), at(1));
2560        m.register_or_update_scroll_node(
2561            DOM,
2562            node(0),
2563            rect(0.0, 0.0, 100.0, 100.0),
2564            size(100.0, 500.0),
2565            at(2),
2566            16.0,
2567            16.0,
2568            false,
2569            true,
2570        );
2571        assert_eq!(m.debug_counts(), (1, 0), "re-register must not grow the map");
2572        assert_eq!(
2573            m.get_current_offset(DOM, node(0)),
2574            Some(pos(0.0, 300.0)),
2575            "an existing node keeps its scroll offset across relayout"
2576        );
2577    }
2578
2579    #[test]
2580    fn re_registering_with_shrunken_content_re_clamps_the_offset() {
2581        // The classic resize bug: content shrinks under a scrolled-to-bottom node.
2582        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2583        m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
2584        m.register_or_update_scroll_node(
2585            DOM,
2586            node(0),
2587            rect(0.0, 0.0, 100.0, 100.0),
2588            size(100.0, 150.0), // content shrank: max_y is now 50
2589            at(2),
2590            16.0,
2591            16.0,
2592            false,
2593            true,
2594        );
2595        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
2596    }
2597
2598    #[test]
2599    fn register_with_non_finite_geometry_does_not_panic_or_leak_nan() {
2600        let mut m = ScrollManager::new();
2601        m.register_or_update_scroll_node(
2602            DOM,
2603            node(0),
2604            rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
2605            size(f32::NAN, f32::NAN),
2606            at(0),
2607            f32::NAN,
2608            f32::NAN,
2609            true,
2610            true,
2611        );
2612        let off = m.get_current_offset(DOM, node(0)).unwrap();
2613        assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN geometry must clamp to 0");
2614        assert_eq!(off, LogicalPosition::zero());
2615        assert!(!m.is_node_scrollable(DOM, node(0)), "NaN overflow check is false");
2616
2617        m.register_or_update_scroll_node(
2618            DOM,
2619            node(1),
2620            rect(0.0, 0.0, f32::INFINITY, f32::INFINITY),
2621            size(f32::INFINITY, f32::INFINITY),
2622            at(0),
2623            f32::MAX,
2624            f32::MAX,
2625            true,
2626            true,
2627        );
2628        let off = m.get_current_offset(DOM, node(1)).unwrap();
2629        assert!(!off.x.is_nan() && !off.y.is_nan());
2630        assert_eq!(m.debug_counts(), (2, 0));
2631    }
2632
2633    #[test]
2634    fn register_with_zero_sized_geometry_yields_a_non_scrollable_pinned_node() {
2635        let mut m = ScrollManager::new();
2636        m.register_or_update_scroll_node(
2637            DOM,
2638            node(0),
2639            LogicalRect::zero(),
2640            LogicalSize::zero(),
2641            at(0),
2642            0.0,
2643            0.0,
2644            false,
2645            false,
2646        );
2647        assert!(!m.is_node_scrollable(DOM, node(0)));
2648        assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
2649        let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
2650        assert_eq!(info.max_scroll_x, 0.0);
2651        assert_eq!(info.max_scroll_y, 0.0);
2652    }
2653
2654    // ===================================================== is_node_scrollable
2655    // (predicate: basic_true_false / edge_inputs)
2656
2657    #[test]
2658    fn is_node_scrollable_is_strict_overflow_not_equality() {
2659        let mut m = ScrollManager::new();
2660        // Content exactly equal to the container: NOT scrollable (strict `>`).
2661        m.register_or_update_scroll_node(
2662            DOM,
2663            node(0),
2664            rect(0.0, 0.0, 100.0, 100.0),
2665            size(100.0, 100.0),
2666            at(0),
2667            16.0,
2668            16.0,
2669            false,
2670            false,
2671        );
2672        assert!(!m.is_node_scrollable(DOM, node(0)));
2673        // One extra pixel of height => scrollable.
2674        m.register_or_update_scroll_node(
2675            DOM,
2676            node(1),
2677            rect(0.0, 0.0, 100.0, 100.0),
2678            size(100.0, 100.1),
2679            at(0),
2680            16.0,
2681            16.0,
2682            false,
2683            true,
2684        );
2685        assert!(m.is_node_scrollable(DOM, node(1)));
2686        // Unknown node / unknown DOM => false, never a panic.
2687        assert!(!m.is_node_scrollable(DOM, node(999)));
2688        assert!(!m.is_node_scrollable(DOM1, node(1)));
2689    }
2690
2691    #[test]
2692    fn is_node_scrollable_uses_the_virtual_size_when_present() {
2693        let mut m = ScrollManager::new();
2694        // Rendered content is tiny (only the visible slice), virtual content is huge.
2695        m.register_or_update_scroll_node(
2696            DOM,
2697            node(0),
2698            rect(0.0, 0.0, 100.0, 100.0),
2699            size(100.0, 50.0),
2700            at(0),
2701            16.0,
2702            16.0,
2703            false,
2704            true,
2705        );
2706        assert!(!m.is_node_scrollable(DOM, node(0)));
2707        m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 100_000.0), None);
2708        assert!(
2709            m.is_node_scrollable(DOM, node(0)),
2710            "a VirtualView with a large virtual size must be scrollable"
2711        );
2712    }
2713
2714    // ======================================================= can_consume_delta
2715    // (predicate: boundary / nan)
2716
2717    #[test]
2718    fn can_consume_delta_respects_the_half_pixel_deadzone() {
2719        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0)); // max_y = 400
2720        m.set_scroll_position(DOM, node(0), pos(0.0, 200.0), at(1));
2721        // |eff| <= EPS (0.5) is "not moved" on that axis.
2722        assert!(!m.can_consume_delta(DOM, node(0), 0.0, 0.0));
2723        assert!(!m.can_consume_delta(DOM, node(0), 0.5, 0.5), "exactly EPS is a no-move");
2724        assert!(!m.can_consume_delta(DOM, node(0), -0.5, -0.5));
2725        assert!(m.can_consume_delta(DOM, node(0), 0.0, 0.51));
2726        assert!(m.can_consume_delta(DOM, node(0), 0.0, -0.51));
2727        // X has no travel at all (content width == container width).
2728        assert!(!m.can_consume_delta(DOM, node(0), 100.0, 0.0));
2729    }
2730
2731    #[test]
2732    fn can_consume_delta_is_false_at_the_pinned_edges() {
2733        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2734        // Pinned at the top: cannot go further up, can go down.
2735        m.set_scroll_position(DOM, node(0), pos(0.0, 0.0), at(1));
2736        assert!(!m.can_consume_delta(DOM, node(0), 0.0, -10.0));
2737        assert!(m.can_consume_delta(DOM, node(0), 0.0, 10.0));
2738        // Pinned at the bottom: the mirror image.
2739        m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(2));
2740        assert!(!m.can_consume_delta(DOM, node(0), 0.0, 10.0));
2741        assert!(m.can_consume_delta(DOM, node(0), 0.0, -10.0));
2742    }
2743
2744    #[test]
2745    fn can_consume_delta_rejects_nan_and_accepts_infinite_deltas() {
2746        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2747        m.set_scroll_position(DOM, node(0), pos(0.0, 200.0), at(1));
2748        assert!(
2749            !m.can_consume_delta(DOM, node(0), f32::NAN, f32::NAN),
2750            "a NaN delta consumes nothing (every comparison is false)"
2751        );
2752        assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::INFINITY));
2753        assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::NEG_INFINITY));
2754        assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::MAX));
2755        assert!(!m.can_consume_delta(DOM, node(999), 0.0, f32::MAX), "unknown node");
2756    }
2757
2758    // ==================================================== select_scroll_target
2759    // (numeric: nan_inf / zero + fallback invariants)
2760
2761    #[test]
2762    fn select_scroll_target_on_no_candidates_is_none() {
2763        let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2764        assert!(m
2765            .select_scroll_target(core::iter::empty(), 0.0, 10.0)
2766            .is_none());
2767        // Candidates that are not scrollable are skipped entirely (no fallback).
2768        assert!(m
2769            .select_scroll_target([(DOM, node(50)), (DOM1, node(0))].into_iter(), 0.0, 10.0)
2770            .is_none());
2771    }
2772
2773    #[test]
2774    fn select_scroll_target_with_zero_or_nan_delta_falls_back_to_the_innermost() {
2775        // Nothing "can consume" a zero/NaN delta, so the gesture still anchors on
2776        // the innermost scrollable node rather than being dropped.
2777        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2778        m.register_or_update_scroll_node(
2779            DOM,
2780            node(9),
2781            rect(0.0, 0.0, 50.0, 50.0),
2782            size(50.0, 200.0),
2783            at(0),
2784            16.0,
2785            16.0,
2786            false,
2787            true,
2788        );
2789        let inner_first = [(DOM, node(9)), (DOM, node(0))];
2790        assert_eq!(
2791            m.select_scroll_target(inner_first.into_iter(), 0.0, 0.0),
2792            Some((DOM, node(9)))
2793        );
2794        assert_eq!(
2795            m.select_scroll_target(inner_first.into_iter(), f32::NAN, f32::NAN),
2796            Some((DOM, node(9)))
2797        );
2798        // An infinite delta IS consumable => also the innermost (it has room).
2799        assert_eq!(
2800            m.select_scroll_target(inner_first.into_iter(), 0.0, f32::INFINITY),
2801            Some((DOM, node(9)))
2802        );
2803    }
2804
2805    // ======================================================= a11y_scroll_info
2806    // (other: no_panic_smoke)
2807
2808    #[test]
2809    fn a11y_scroll_info_reports_travel_only_for_scrollable_nodes() {
2810        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2811        m.set_scroll_position(DOM, node(0), pos(0.0, 120.0), at(1));
2812        let (off, max_x, max_y) = m.a11y_scroll_info(DOM, node(0)).unwrap();
2813        assert_eq!(off, pos(0.0, 120.0));
2814        assert_eq!(max_x, 0.0);
2815        assert_eq!(max_y, 400.0);
2816
2817        // Non-scrollable node => None (screen readers must not offer scroll actions).
2818        m.register_or_update_scroll_node(
2819            DOM,
2820            node(1),
2821            rect(0.0, 0.0, 100.0, 100.0),
2822            size(10.0, 10.0),
2823            at(0),
2824            16.0,
2825            16.0,
2826            false,
2827            false,
2828        );
2829        assert!(m.a11y_scroll_info(DOM, node(1)).is_none());
2830        assert!(m.a11y_scroll_info(DOM, node(404)).is_none());
2831        assert!(m.a11y_scroll_info(DOM1, node(0)).is_none());
2832    }
2833
2834    #[test]
2835    fn a11y_scroll_info_uses_the_virtual_size() {
2836        let mut m = mgr(size(100.0, 100.0), size(100.0, 100.0));
2837        assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
2838        m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 1000.0), None);
2839        let (_, max_x, max_y) = m.a11y_scroll_info(DOM, node(0)).unwrap();
2840        assert_eq!(max_x, 0.0);
2841        assert_eq!(max_y, 900.0);
2842    }
2843
2844    // ================================================== get_scroll_node_info
2845    // (other: no_panic_smoke — max_scroll is never negative)
2846
2847    #[test]
2848    fn get_scroll_node_info_max_scroll_is_never_negative() {
2849        let m = mgr(size(500.0, 500.0), size(10.0, 10.0));
2850        let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
2851        assert_eq!(info.max_scroll_x, 0.0, "underflow must clamp to 0, not go negative");
2852        assert_eq!(info.max_scroll_y, 0.0);
2853        assert_eq!(info.current_offset, LogicalPosition::zero());
2854        assert!(m.get_scroll_node_info(DOM, node(1)).is_none());
2855    }
2856
2857    #[test]
2858    fn get_scroll_node_info_prefers_the_virtual_size_for_max_travel() {
2859        let mut m = mgr(size(100.0, 100.0), size(100.0, 200.0));
2860        assert_eq!(m.get_scroll_node_info(DOM, node(0)).unwrap().max_scroll_y, 100.0);
2861        m.update_virtual_scroll_bounds(DOM, node(0), size(600.0, 5000.0), Some(pos(1.0, 2.0)));
2862        let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
2863        assert_eq!(info.max_scroll_x, 500.0);
2864        assert_eq!(info.max_scroll_y, 4900.0);
2865        // content_rect is still the *rendered* rect — the virtual size only moves
2866        // the bounds, it does not rewrite the layout geometry.
2867        assert_eq!(info.content_rect.size, size(100.0, 200.0));
2868    }
2869
2870    // ============================================ update_virtual_scroll_bounds
2871    // (numeric: nan_inf / zero + implicit state creation)
2872
2873    #[test]
2874    fn update_virtual_scroll_bounds_creates_a_state_for_an_unknown_node() {
2875        let mut m = ScrollManager::new();
2876        m.update_virtual_scroll_bounds(DOM, node(3), size(100.0, 9000.0), Some(pos(0.0, 4.0)));
2877        assert_eq!(m.debug_counts(), (1, 0));
2878        let s = m.get_scroll_state(DOM, node(3)).unwrap();
2879        assert_eq!(s.virtual_scroll_size, Some(size(100.0, 9000.0)));
2880        assert_eq!(s.virtual_scroll_offset, Some(pos(0.0, 4.0)));
2881        assert_eq!(s.current_offset, LogicalPosition::zero());
2882        // Container is still zero-sized, so all 9000px are reachable.
2883        assert!(m.is_node_scrollable(DOM, node(3)));
2884    }
2885
2886    #[test]
2887    fn update_virtual_scroll_bounds_re_clamps_a_shrinking_virtual_size() {
2888        let mut m = mgr(size(100.0, 100.0), size(100.0, 100.0));
2889        m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 5000.0), None);
2890        m.set_scroll_position(DOM, node(0), pos(0.0, 4900.0), at(1));
2891        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 4900.0)));
2892        // The VirtualView shrinks (rows removed): the offset must follow it down.
2893        m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 300.0), None);
2894        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
2895    }
2896
2897    #[test]
2898    fn update_virtual_scroll_bounds_with_non_finite_size_does_not_panic() {
2899        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2900        m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
2901        m.update_virtual_scroll_bounds(DOM, node(0), size(f32::NAN, f32::NAN), None);
2902        let off = m.get_current_offset(DOM, node(0)).unwrap();
2903        assert!(!off.x.is_nan() && !off.y.is_nan());
2904        assert_eq!(off, LogicalPosition::zero(), "NaN virtual size => zero travel");
2905        assert!(!m.is_node_scrollable(DOM, node(0)));
2906
2907        m.update_virtual_scroll_bounds(DOM, node(0), size(0.0, f32::INFINITY), None);
2908        let off = m.get_current_offset(DOM, node(0)).unwrap();
2909        assert!(!off.y.is_nan(), "infinite virtual height must not produce NaN");
2910    }
2911
2912    // ====================================================== update_node_bounds
2913    // (numeric: zero / negative / overflow / nan)
2914
2915    #[test]
2916    fn update_node_bounds_creates_the_state_and_re_clamps_a_shrinking_content() {
2917        let mut m = ScrollManager::new();
2918        // Unknown node: the entry API materializes it at the scroll origin.
2919        m.update_node_bounds(
2920            DOM,
2921            node(0),
2922            rect(0.0, 0.0, 100.0, 100.0),
2923            rect(0.0, 0.0, 100.0, 500.0),
2924            at(0),
2925        );
2926        assert_eq!(m.debug_counts(), (1, 0));
2927        assert_eq!(m.get_current_offset(DOM, node(0)), Some(LogicalPosition::zero()));
2928
2929        m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
2930        m.clear_scroll_dirty();
2931
2932        // Content shrinks under a bottomed-out scroll: the offset must follow.
2933        m.update_node_bounds(
2934            DOM,
2935            node(0),
2936            rect(0.0, 0.0, 100.0, 100.0),
2937            rect(0.0, 0.0, 100.0, 150.0),
2938            at(2),
2939        );
2940        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
2941        // NOTE: the forced re-clamp moved the offset by 350px but did NOT set the
2942        // dirty flag (unlike set_scroll_position) — pinning the real behavior.
2943        assert!(!m.has_pending_scroll_changes());
2944    }
2945
2946    #[test]
2947    fn update_node_bounds_ignores_the_content_rect_origin() {
2948        // clamp() only reads `size`, so a content rect translated far away must
2949        // not shift the reachable travel.
2950        let mut m = ScrollManager::new();
2951        m.update_node_bounds(
2952            DOM,
2953            node(0),
2954            rect(0.0, 0.0, 100.0, 100.0),
2955            rect(999.0, 999.0, 100.0, 500.0),
2956            at(0),
2957        );
2958        m.set_scroll_position(DOM, node(0), pos(1e9, 1e9), at(1));
2959        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2960    }
2961
2962    #[test]
2963    fn update_node_bounds_with_non_finite_rects_does_not_leak_nan() {
2964        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2965        m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
2966        m.update_node_bounds(
2967            DOM,
2968            node(0),
2969            rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
2970            rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
2971            at(2),
2972        );
2973        let off = m.get_current_offset(DOM, node(0)).unwrap();
2974        assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN bounds must clamp to 0");
2975        assert_eq!(off, LogicalPosition::zero());
2976
2977        // Infinite content: the offset stays finite (clamped to the old value).
2978        m.update_node_bounds(
2979            DOM,
2980            node(0),
2981            rect(0.0, 0.0, 100.0, 100.0),
2982            rect(0.0, 0.0, f32::INFINITY, f32::INFINITY),
2983            at(3),
2984        );
2985        let off = m.get_current_offset(DOM, node(0)).unwrap();
2986        assert!(off.x.is_finite() && off.y.is_finite());
2987    }
2988
2989    // ============================================ get_scroll_states_for_dom /
2990    //                                              build_scroll_offset_map
2991    // (other: no_panic_smoke + DOM isolation)
2992
2993    #[test]
2994    fn get_scroll_states_for_dom_filters_by_dom_and_reports_the_live_offset() {
2995        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2996        m.set_scroll_position(DOM, node(0), pos(0.0, 42.0), at(1));
2997        m.register_or_update_scroll_node(
2998            DOM1,
2999            node(0),
3000            rect(0.0, 0.0, 10.0, 10.0),
3001            size(10.0, 100.0),
3002            at(0),
3003            16.0,
3004            16.0,
3005            false,
3006            true,
3007        );
3008
3009        let states = m.get_scroll_states_for_dom(DOM);
3010        assert_eq!(states.len(), 1, "other DOMs must not leak in");
3011        let sp = states.get(&node(0)).unwrap();
3012        assert_eq!(sp.parent_rect, rect(0.0, 0.0, 100.0, 100.0));
3013        assert_eq!(sp.children_rect.origin, pos(0.0, 42.0));
3014        assert_eq!(sp.children_rect.size, size(100.0, 500.0));
3015
3016        // A DOM with no registered nodes returns an empty map, not a panic.
3017        assert!(m.get_scroll_states_for_dom(DomId { inner: 99 }).is_empty());
3018    }
3019
3020    #[test]
3021    fn get_scroll_states_for_dom_uses_the_virtual_size_as_children_rect() {
3022        let mut m = mgr(size(100.0, 100.0), size(100.0, 120.0));
3023        m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 8000.0), None);
3024        let states = m.get_scroll_states_for_dom(DOM);
3025        assert_eq!(states.get(&node(0)).unwrap().children_rect.size, size(100.0, 8000.0));
3026    }
3027
3028    #[test]
3029    fn build_scroll_offset_map_only_emits_nodes_present_in_scroll_ids() {
3030        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3031        m.set_scroll_position(DOM, node(0), pos(0.0, 25.0), at(1));
3032        m.register_or_update_scroll_node(
3033            DOM,
3034            node(4),
3035            rect(0.0, 0.0, 100.0, 100.0),
3036            size(100.0, 500.0),
3037            at(0),
3038            16.0,
3039            16.0,
3040            false,
3041            true,
3042        );
3043        m.register_or_update_scroll_node(
3044            DOM1,
3045            node(0),
3046            rect(0.0, 0.0, 100.0, 100.0),
3047            size(100.0, 500.0),
3048            at(0),
3049            16.0,
3050            16.0,
3051            false,
3052            true,
3053        );
3054
3055        // Empty id map => empty offset map (and no panic).
3056        assert!(m.build_scroll_offset_map(DOM, &HashMap::new()).is_empty());
3057
3058        let mut ids: HashMap<usize, u64> = HashMap::new();
3059        ids.insert(0, 100); // node index 0 -> scroll id 100
3060        ids.insert(7, 700); // an id for a node that has no scroll state
3061        let map = m.build_scroll_offset_map(DOM, &ids);
3062        assert_eq!(map.len(), 1, "node 4 has no scroll_id; DOM1 is a different dom");
3063        assert_eq!(map.get(&100), Some(&(0.0, 25.0)));
3064        assert!(!map.contains_key(&700));
3065    }
3066
3067    // ====================================================== find_scroll_parent
3068    // (other: no_panic_smoke)
3069
3070    #[test]
3071    fn find_scroll_parent_walks_up_to_the_nearest_registered_ancestor() {
3072        // hierarchy: 0 (root) <- 1 <- 2  (parent field is 1-based encoded)
3073        let hierarchy = [
3074            NodeHierarchyItem { parent: 0, previous_sibling: 0, next_sibling: 0, last_child: 2 },
3075            NodeHierarchyItem { parent: 1, previous_sibling: 0, next_sibling: 0, last_child: 3 },
3076            NodeHierarchyItem { parent: 2, previous_sibling: 0, next_sibling: 0, last_child: 0 },
3077        ];
3078        let m = mgr(size(100.0, 100.0), size(100.0, 500.0)); // node 0 registered
3079        assert_eq!(
3080            m.find_scroll_parent(DOM, node(2), &hierarchy),
3081            Some(node(0)),
3082            "must skip the unregistered node 1 and find the root scroll container"
3083        );
3084        // The node itself is excluded even though it IS registered.
3085        assert_eq!(m.find_scroll_parent(DOM, node(0), &hierarchy), None);
3086        // No scroll container anywhere in this DOM.
3087        assert_eq!(m.find_scroll_parent(DOM1, node(2), &hierarchy), None);
3088    }
3089
3090    #[test]
3091    fn find_scroll_parent_handles_empty_and_out_of_range_hierarchies() {
3092        let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3093        // Empty slice: the very first `get()` misses => None, no index panic.
3094        assert_eq!(m.find_scroll_parent(DOM, node(0), &[]), None);
3095        assert_eq!(m.find_scroll_parent(DOM, node(9999), &[]), None);
3096        // Node id past the end of the hierarchy: still no panic.
3097        let hierarchy = [NodeHierarchyItem::zeroed()];
3098        assert_eq!(m.find_scroll_parent(DOM, node(9999), &hierarchy), None);
3099    }
3100
3101    // ============================================== calculate_scrollbar_states
3102    // (other: no_panic_smoke + no unbounded growth)
3103
3104    #[test]
3105    fn calculate_scrollbar_states_is_idempotent_and_only_for_overflowing_axes() {
3106        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3107        m.calculate_scrollbar_states();
3108        assert_eq!(m.debug_counts(), (1, 1), "only the vertical axis overflows");
3109        assert!(m
3110            .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
3111            .is_some());
3112        assert!(m
3113            .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Horizontal)
3114            .is_none());
3115
3116        // Re-running each frame must clear first — otherwise the map grows forever.
3117        for _ in 0..10 {
3118            m.calculate_scrollbar_states();
3119        }
3120        assert_eq!(m.debug_counts(), (1, 1), "per-frame recompute must not accumulate");
3121        assert_eq!(m.iter_scrollbar_states().count(), 1);
3122    }
3123
3124    #[test]
3125    fn calculate_scrollbar_states_drops_bars_once_the_content_fits() {
3126        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3127        m.calculate_scrollbar_states();
3128        assert_eq!(m.debug_counts().1, 1);
3129        // Relayout: content now fits => the scrollbar must disappear.
3130        m.register_or_update_scroll_node(
3131            DOM,
3132            node(0),
3133            rect(0.0, 0.0, 100.0, 100.0),
3134            size(100.0, 50.0),
3135            at(1),
3136            16.0,
3137            16.0,
3138            false,
3139            false,
3140        );
3141        m.calculate_scrollbar_states();
3142        assert_eq!(m.debug_counts().1, 0);
3143        assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
3144    }
3145
3146    #[test]
3147    fn calculate_scrollbar_states_produces_finite_geometry_for_both_axes() {
3148        let mut m = ScrollManager::new();
3149        m.register_or_update_scroll_node(
3150            DOM,
3151            node(0),
3152            rect(0.0, 0.0, 100.0, 100.0),
3153            size(1000.0, 1000.0),
3154            at(0),
3155            16.0,
3156            16.0,
3157            true,
3158            true,
3159        );
3160        m.calculate_scrollbar_states();
3161        assert_eq!(m.debug_counts(), (1, 2), "both axes overflow");
3162        for (_, sb) in m.iter_scrollbar_states() {
3163            assert!(sb.visible);
3164            assert!(sb.base_size.is_finite() && sb.base_size > 0.0);
3165            assert!(sb.scale.x.is_finite() && sb.scale.y.is_finite());
3166            assert!(sb.thumb_length.is_finite());
3167            assert!(sb.thumb_offset.is_finite());
3168            assert!(sb.usable_track_length.is_finite());
3169            assert!(sb.track_rect.size.width.is_finite());
3170            assert!(sb.track_rect.size.height.is_finite());
3171        }
3172    }
3173
3174    #[test]
3175    fn calculate_scrollbar_states_zero_thickness_falls_back_to_the_default_width() {
3176        // An overlay scrollbar reports thickness 0 from layout; the geometry must
3177        // still divide by a non-zero width (otherwise `scale` becomes inf/NaN).
3178        let mut m = ScrollManager::new();
3179        m.register_or_update_scroll_node(
3180            DOM,
3181            node(0),
3182            rect(0.0, 0.0, 100.0, 100.0),
3183            size(100.0, 400.0),
3184            at(0),
3185            0.0, // scrollbar_thickness (overlay)
3186            0.0, // visual_width_px
3187            false,
3188            true,
3189        );
3190        m.calculate_scrollbar_states();
3191        let sb = m
3192            .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
3193            .unwrap();
3194        assert_eq!(sb.base_size, crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX);
3195        assert_eq!(sb.button_size, 0.0, "overlay scrollbars have no arrow buttons");
3196        assert!(sb.scale.x.is_finite() && sb.scale.y.is_finite(), "no div-by-zero");
3197    }
3198
3199    #[test]
3200    fn calculate_scrollbar_state_from_geometry_survives_nan_input() {
3201        let mut s = state(size(f32::NAN, f32::NAN), size(f32::NAN, f32::NAN));
3202        s.scrollbar_thickness = f32::NAN;
3203        s.visual_width_px = f32::NAN;
3204        // `NaN > 0.0` is false for both width sources, so it falls back to the
3205        // default width instead of dividing by NaN.
3206        let sb = ScrollManager::calculate_scrollbar_state_from_geometry(
3207            &s,
3208            ScrollbarOrientation::Vertical,
3209        );
3210        assert!(sb.visible);
3211        assert_eq!(sb.base_size, crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX);
3212        // `.max(0.0)` rescues every length: NaN geometry degrades to a zero-length
3213        // thumb on a zero-length track rather than propagating NaN.
3214        assert_eq!(sb.usable_track_length, 0.0);
3215        assert_eq!(sb.thumb_length, 0.0);
3216        assert_eq!(sb.thumb_offset, 0.0);
3217        assert_eq!(sb.thumb_position_ratio, 0.0);
3218        // The lengths are safe, but `scale` divides the (NaN) track height by the
3219        // thickness with no rescue — a NaN scale reaches the render transform.
3220        assert!(
3221            sb.scale.y.is_nan(),
3222            "NaN container height still leaks into ScrollbarState::scale"
3223        );
3224        // Hit-testing such a bar is still total: y < button_size wins first.
3225        assert_eq!(
3226            sb.hit_test_component(pos(0.0, 5.0)),
3227            ScrollbarComponent::TopButton
3228        );
3229    }
3230
3231    // ================================== hit_test_scrollbar / hit_test_scrollbars
3232    // (numeric: zero / negative / nan_inf)
3233
3234    #[test]
3235    fn hit_test_scrollbars_finds_the_vertical_bar_and_reports_local_coords() {
3236        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3237        m.calculate_scrollbar_states();
3238        // Track is the right-hand 16px strip: origin.x = 100 - 16 = 84.
3239        let hit = m.hit_test_scrollbars(pos(90.0, 50.0)).expect("inside the track");
3240        assert_eq!(hit.dom_id, DOM);
3241        assert_eq!(hit.node_id, node(0));
3242        assert_eq!(hit.orientation, ScrollbarOrientation::Vertical);
3243        assert_eq!(hit.global_position, pos(90.0, 50.0));
3244        assert_eq!(hit.local_position, pos(6.0, 50.0), "local = global - track origin");
3245
3246        // Just outside the track (content area) => no hit.
3247        assert!(m.hit_test_scrollbars(pos(10.0, 50.0)).is_none());
3248        // Same answer through the node-targeted entry point.
3249        let hit2 = m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).unwrap();
3250        assert_eq!(hit2.local_position, hit.local_position);
3251        assert_eq!(hit2.component, hit.component);
3252        assert!(m.hit_test_scrollbar(DOM, node(1), pos(90.0, 50.0)).is_none());
3253    }
3254
3255    #[test]
3256    fn hit_test_scrollbars_rejects_non_finite_and_out_of_range_positions() {
3257        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3258        m.calculate_scrollbar_states();
3259        for p in [
3260            pos(f32::NAN, f32::NAN),
3261            pos(f32::INFINITY, f32::INFINITY),
3262            pos(f32::NEG_INFINITY, f32::NEG_INFINITY),
3263            pos(f32::MAX, f32::MAX),
3264            pos(f32::MIN, f32::MIN),
3265            pos(-1.0, -1.0),
3266            pos(0.0, 0.0),
3267        ] {
3268            assert!(
3269                m.hit_test_scrollbars(p).is_none(),
3270                "position {p:?} must not hit the 84..100 x 0..100 track"
3271            );
3272            assert!(m.hit_test_scrollbar(DOM, node(0), p).is_none());
3273        }
3274    }
3275
3276    #[test]
3277    fn hit_test_scrollbars_before_calculate_returns_none() {
3278        // The states map is only filled by calculate_scrollbar_states(); querying
3279        // first must be a clean miss, not a stale hit.
3280        let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3281        assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
3282        assert!(m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).is_none());
3283    }
3284
3285    #[test]
3286    fn hit_test_scrollbars_skips_invisible_bars() {
3287        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3288        m.calculate_scrollbar_states();
3289        m.scrollbar_states
3290            .get_mut(&(DOM, node(0), ScrollbarOrientation::Vertical))
3291            .unwrap()
3292            .visible = false;
3293        assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
3294        assert!(m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).is_none());
3295    }
3296
3297    // ========================================================= input recording
3298    // (record_scroll_input / record_scroll_from_hit_test)
3299
3300    #[test]
3301    fn record_scroll_input_reports_start_timer_only_on_the_first_pending_event() {
3302        let mut m = ScrollManager::new();
3303        assert!(m.record_scroll_input(input(0.0, 1.0, 1)), "queue was empty => start");
3304        assert!(!m.record_scroll_input(input(0.0, 1.0, 2)), "timer already running");
3305        let _ = m.get_input_queue().take_all();
3306        assert!(m.record_scroll_input(input(0.0, 1.0, 3)), "drained => start again");
3307    }
3308
3309    #[test]
3310    fn record_scroll_input_applies_the_sign_to_extreme_deltas_without_overflow() {
3311        let mut m = ScrollManager::new();
3312        m.record_scroll_input(input(f32::MAX, f32::INFINITY, 1));
3313        m.record_scroll_input(input(f32::NAN, f32::MIN, 2));
3314        let q = m.get_input_queue().take_all();
3315        assert_eq!(q[0].delta.x, -f32::MAX, "sign flip must not overflow");
3316        assert_eq!(q[0].delta.y, f32::NEG_INFINITY);
3317        assert!(q[1].delta.x.is_nan(), "NaN * -1 stays NaN, no panic");
3318        assert_eq!(q[1].delta.y, f32::MAX);
3319    }
3320
3321    #[test]
3322    fn record_scroll_from_hit_test_records_the_wheel_delta_even_with_no_hover() {
3323        // The wheel-as-zoom widgets (e.g. the map) depend on pending_wheel_event
3324        // being set unconditionally — before the hit-test lookup can bail out.
3325        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3326        let hover = HoverManager::new(); // no hit-test recorded at all
3327        let out = m.record_scroll_from_hit_test(
3328            3.0,
3329            -7.0,
3330            ScrollInputSource::WheelDiscrete,
3331            &hover,
3332            &InputPointId::Mouse,
3333            at(1),
3334        );
3335        assert!(out.is_none(), "no hover => no scroll target");
3336        assert_eq!(m.pending_wheel_event, Some(pos(3.0, -7.0)), "raw delta is recorded");
3337        assert!(!m.get_input_queue().has_pending(), "nothing queued for physics");
3338    }
3339
3340    #[test]
3341    fn record_scroll_from_hit_test_queues_the_raw_delta_and_signals_the_timer() {
3342        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3343        let hover = hover_over(&[0]);
3344        let (dom_id, node_id, start_timer) = m
3345            .record_scroll_from_hit_test(
3346                0.0,
3347                -10.0, // raw "wheel down" under the traditional sign
3348                ScrollInputSource::WheelDiscrete,
3349                &hover,
3350                &InputPointId::Mouse,
3351                at(1),
3352            )
3353            .expect("node 0 is scrollable and under the cursor");
3354        assert_eq!((dom_id, node_id), (DOM, node(0)));
3355        assert!(start_timer, "first queued input must start the physics timer");
3356        assert_eq!(m.pending_wheel_event, Some(pos(0.0, -10.0)));
3357
3358        // A second event while the queue is still pending must NOT re-start it.
3359        let (_, _, start_again) = m
3360            .record_scroll_from_hit_test(
3361                0.0,
3362                -10.0,
3363                ScrollInputSource::WheelDiscrete,
3364                &hover,
3365                &InputPointId::Mouse,
3366                at(2),
3367            )
3368            .unwrap();
3369        assert!(!start_again);
3370
3371        let q = m.get_input_queue().take_all();
3372        assert_eq!(q.len(), 2);
3373        // scroll_sign() is applied exactly once, in record_scroll_input.
3374        assert_eq!(q[0].delta.y, 10.0, "raw -10 * traditional sign (-1) = +10");
3375        assert_eq!(q[0].source, ScrollInputSource::WheelDiscrete);
3376        assert_eq!(q[0].timestamp, at(1));
3377    }
3378
3379    #[test]
3380    fn record_scroll_from_hit_test_ignores_hovered_nodes_that_cannot_scroll() {
3381        let mut m = ScrollManager::new();
3382        // Registered, but the content fits => not scrollable.
3383        m.register_or_update_scroll_node(
3384            DOM,
3385            node(0),
3386            rect(0.0, 0.0, 100.0, 100.0),
3387            size(100.0, 100.0),
3388            at(0),
3389            16.0,
3390            16.0,
3391            false,
3392            false,
3393        );
3394        let hover = hover_over(&[0]);
3395        let out = m.record_scroll_from_hit_test(
3396            0.0,
3397            -10.0,
3398            ScrollInputSource::WheelDiscrete,
3399            &hover,
3400            &InputPointId::Mouse,
3401            at(1),
3402        );
3403        assert!(out.is_none(), "a non-overflowing node must not swallow the wheel");
3404        assert_eq!(m.pending_wheel_event, Some(pos(0.0, -10.0)));
3405        assert!(!m.get_input_queue().has_pending());
3406    }
3407
3408    #[test]
3409    fn record_scroll_from_hit_test_with_non_finite_deltas_does_not_panic() {
3410        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3411        let hover = hover_over(&[0]);
3412        // NaN: nothing can consume it, so the innermost scrollable is the fallback.
3413        let out = m.record_scroll_from_hit_test(
3414            f32::NAN,
3415            f32::NAN,
3416            ScrollInputSource::TrackpadContinuous,
3417            &hover,
3418            &InputPointId::Mouse,
3419            at(1),
3420        );
3421        assert_eq!(out.map(|(d, n, _)| (d, n)), Some((DOM, node(0))));
3422        assert!(m.pending_wheel_event.unwrap().x.is_nan());
3423        let q = m.get_input_queue().take_all();
3424        assert_eq!(q.len(), 1);
3425        assert!(q[0].delta.x.is_nan(), "NaN is queued verbatim, no panic");
3426
3427        // Infinity: consumable (there is room), still queued safely.
3428        let out = m.record_scroll_from_hit_test(
3429            0.0,
3430            f32::NEG_INFINITY,
3431            ScrollInputSource::WheelDiscrete,
3432            &hover,
3433            &InputPointId::Mouse,
3434            at(2),
3435        );
3436        assert!(out.is_some());
3437        let q = m.get_input_queue().take_all();
3438        assert_eq!(q[0].delta.y, f32::INFINITY, "-inf * -1 = +inf");
3439    }
3440
3441    #[test]
3442    fn record_scroll_from_hit_test_picks_the_innermost_scrollable_under_the_cursor() {
3443        // Both nodes are hovered; scroll_hit_test_nodes is walked in reverse key
3444        // order, so the higher (deeper) NodeId wins when it can consume the delta.
3445        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3446        m.register_or_update_scroll_node(
3447            DOM,
3448            node(5),
3449            rect(0.0, 0.0, 50.0, 50.0),
3450            size(50.0, 200.0),
3451            at(0),
3452            16.0,
3453            16.0,
3454            false,
3455            true,
3456        );
3457        let hover = hover_over(&[0, 5]);
3458        let (_, node_id, _) = m
3459            .record_scroll_from_hit_test(
3460                0.0,
3461                -10.0,
3462                ScrollInputSource::WheelDiscrete,
3463                &hover,
3464                &InputPointId::Mouse,
3465                at(1),
3466            )
3467            .unwrap();
3468        assert_eq!(node_id, node(5), "innermost (deepest) scrollable wins");
3469    }
3470
3471    // ============================================================ getters
3472    // (get_current_offset / get_last_activity_time / get_scroll_state)
3473
3474    #[test]
3475    fn getters_agree_with_the_recorded_state() {
3476        let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3477        m.set_scroll_position(DOM, node(0), pos(0.0, 33.0), at(7));
3478        assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 33.0)));
3479        assert_eq!(m.get_last_activity_time(DOM, node(0)), Some(at(7)));
3480        let s = m.get_scroll_state(DOM, node(0)).unwrap();
3481        assert_eq!(s.current_offset, pos(0.0, 33.0));
3482        assert!(s.animation.is_none());
3483        // Unknown keys are a clean miss on every getter.
3484        assert!(m.get_current_offset(DOM1, node(0)).is_none());
3485        assert!(m.get_last_activity_time(DOM, node(1)).is_none());
3486        assert!(m.get_scroll_state(DOM1, node(1)).is_none());
3487    }
3488
3489    #[test]
3490    fn get_input_queue_hands_out_a_shared_handle() {
3491        let mut m = ScrollManager::new();
3492        let q = m.get_input_queue();
3493        assert!(!q.has_pending());
3494        m.record_scroll_input(input(0.0, 1.0, 1));
3495        assert!(q.has_pending(), "the handle must observe pushes made by the manager");
3496        assert_eq!(q.take_all().len(), 1);
3497        assert!(
3498            !m.get_input_queue().has_pending(),
3499            "draining the handle drains the manager's queue"
3500        );
3501    }
3502}