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