Skip to main content

azul_layout/managers/
scroll_into_view.rs

1//! Scroll-into-view implementation
2//!
3//! Provides W3C CSSOM View Module compliant scroll-into-view functionality.
4//! This module contains the core primitive `scroll_rect_into_view` which all
5//! higher-level scroll-into-view APIs build upon.
6//!
7//! # Architecture
8//!
9//! The core principle is that all scroll-into-view operations reduce to scrolling
10//! a rectangle into the visible area of its scroll container ancestry:
11//!
12//! - `scroll_rect_into_view`: Core primitive - scroll any rect into view
13//! - `scroll_node_into_view`: Scroll a DOM node's bounding rect into view
14//! - `scroll_cursor_into_view`: Scroll a text cursor position into view
15//!
16//! # W3C Compliance
17//!
18//! This implementation follows the W3C CSSOM View Module specification:
19//! - `ScrollLogicalPosition`: start, center, end, nearest
20//! - `ScrollBehavior`: auto, instant, smooth
21//! - Proper scroll ancestor chain traversal
22
23use alloc::vec::Vec;
24
25use azul_core::{
26    dom::{DomId, DomNodeId, NodeId},
27    geom::{LogicalPosition, LogicalRect, LogicalSize},
28    task::{Duration, Instant},
29};
30
31use crate::{
32    managers::scroll_state::ScrollManager,
33    solver3::getters::{get_overflow_x, get_overflow_y},
34    window::DomLayoutResult,
35};
36
37// Re-export types from core for public API
38pub use azul_core::events::{ScrollIntoViewBehavior, ScrollIntoViewOptions, ScrollLogicalPosition};
39
40/// Minimum scroll delta (in logical pixels) below which scrolling is skipped
41const SCROLL_DELTA_THRESHOLD: f32 = 0.5;
42/// Duration of smooth scroll animations in milliseconds
43const SMOOTH_SCROLL_DURATION_MS: u64 = 300;
44
45/// Calculated scroll adjustment for one scroll container
46#[derive(Copy, Debug, Clone)]
47pub struct ScrollAdjustment {
48    /// The DOM containing the scroll container
49    pub scroll_container_dom_id: DomId,
50    /// The node ID of the scroll container within the DOM
51    pub scroll_container_node_id: NodeId,
52    /// The scroll delta to apply
53    pub delta: LogicalPosition,
54    /// The scroll behavior to use
55    pub behavior: ScrollIntoViewBehavior,
56}
57
58/// Information about a scrollable ancestor
59#[derive(Debug, Clone)]
60struct ScrollableAncestor {
61    dom_id: DomId,
62    node_id: NodeId,
63    /// The visible rect of the scroll container (content area)
64    visible_rect: LogicalRect,
65    /// Whether horizontal scroll is enabled
66    scroll_x: bool,
67    /// Whether vertical scroll is enabled
68    scroll_y: bool,
69}
70
71// ============================================================================
72// Core API: scroll_rect_into_view
73// ============================================================================
74
75/// Core function: scroll a rect into the visible area of its scroll containers
76///
77/// This is the ONLY scroll-into-view primitive. All higher-level APIs call this.
78///
79/// # Arguments
80///
81/// * `target_rect` - The rectangle to make visible (in absolute coordinates)
82/// * `target_dom_id` - The DOM containing the target node
83/// * `target_node_id` - The target node (used for finding scroll ancestors)
84/// * `layout_results` - Layout data for all DOMs
85/// * `scroll_manager` - Current scroll state
86/// * `options` - How to scroll (alignment and animation)
87/// * `now` - Current timestamp for animation
88///
89/// # Returns
90///
91/// A vector of scroll adjustments for each scroll container in the ancestry chain.
92/// The adjustments are ordered from innermost (closest to target) to outermost.
93// Instant is a ref-counted FFI clock handle threaded through the event loop by value;
94// &-converting would cascade through the loop call chain.
95#[allow(clippy::needless_pass_by_value)]
96pub(crate) fn scroll_rect_into_view(
97    target_rect: LogicalRect,
98    target_dom_id: DomId,
99    target_node_id: NodeId,
100    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
101    scroll_manager: &mut ScrollManager,
102    options: ScrollIntoViewOptions,
103    now: Instant,
104) -> Vec<ScrollAdjustment> {
105    let mut adjustments = Vec::new();
106    
107    // Find scrollable ancestors from target to root
108    let scroll_ancestors = find_scrollable_ancestors(
109        target_dom_id,
110        target_node_id,
111        layout_results,
112        scroll_manager,
113    );
114    
115    if scroll_ancestors.is_empty() {
116        return adjustments;
117    }
118    
119    // Transform target_rect relative to each scroll container and calculate deltas
120    let mut current_rect = target_rect;
121    
122    for ancestor in scroll_ancestors {
123        // Calculate the scroll delta based on options
124        let delta = calculate_scroll_delta(
125            current_rect,
126            ancestor.visible_rect,
127            options.block,
128            options.inline_axis,
129            ancestor.scroll_x,
130            ancestor.scroll_y,
131        );
132        
133        // Only add adjustment if there's actual scrolling to do
134        if delta.x.abs() > SCROLL_DELTA_THRESHOLD || delta.y.abs() > SCROLL_DELTA_THRESHOLD {
135            // Resolve scroll behavior
136            let behavior = resolve_scroll_behavior(
137                options.behavior,
138                ancestor.dom_id,
139                ancestor.node_id,
140                layout_results,
141            );
142            
143            // Apply the scroll adjustment
144            apply_scroll_adjustment(
145                scroll_manager,
146                ancestor.dom_id,
147                ancestor.node_id,
148                delta,
149                behavior,
150                now.clone(),
151            );
152            
153            adjustments.push(ScrollAdjustment {
154                scroll_container_dom_id: ancestor.dom_id,
155                scroll_container_node_id: ancestor.node_id,
156                delta,
157                behavior,
158            });
159            
160            // Adjust current_rect for next iteration (relative to new scroll position)
161            current_rect.origin.x -= delta.x;
162            current_rect.origin.y -= delta.y;
163        }
164    }
165    
166    adjustments
167}
168
169// ============================================================================
170// Higher-Level APIs
171// ============================================================================
172
173/// Scroll a DOM node's bounding rect into view
174///
175/// This is a convenience wrapper around `scroll_rect_into_view` that
176/// automatically gets the node's bounding rect from layout results.
177pub fn scroll_node_into_view(
178    node_id: DomNodeId,
179    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
180    scroll_manager: &mut ScrollManager,
181    options: ScrollIntoViewOptions,
182    now: Instant,
183) -> Vec<ScrollAdjustment> {
184    // Get node's bounding rect from layout
185    let Some(target_rect) = get_node_rect(node_id, layout_results) else {
186        return Vec::new();
187    };
188    
189    let Some(internal_node_id) = node_id.node.into_crate_internal() else {
190        return Vec::new();
191    };
192
193    // Call the core rect-based API
194    scroll_rect_into_view(
195        target_rect,
196        node_id.dom,
197        internal_node_id,
198        layout_results,
199        scroll_manager,
200        options,
201        now,
202    )
203}
204
205/// Scroll a text cursor position into view
206///
207/// Transforms the cursor's visual rect (in node-local coordinates) to absolute
208/// coordinates before scrolling.
209pub fn scroll_cursor_into_view(
210    cursor_rect: LogicalRect,
211    node_id: DomNodeId,
212    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
213    scroll_manager: &mut ScrollManager,
214    options: ScrollIntoViewOptions,
215    now: Instant,
216) -> Vec<ScrollAdjustment> {
217    // Get node's position to transform cursor_rect to absolute coordinates
218    let Some(node_rect) = get_node_rect(node_id, layout_results) else {
219        return Vec::new();
220    };
221    
222    // Transform cursor rect to absolute coordinates
223    let absolute_cursor_rect = LogicalRect {
224        origin: LogicalPosition {
225            x: node_rect.origin.x + cursor_rect.origin.x,
226            y: node_rect.origin.y + cursor_rect.origin.y,
227        },
228        size: cursor_rect.size,
229    };
230    
231    let Some(internal_node_id) = node_id.node.into_crate_internal() else {
232        return Vec::new();
233    };
234
235    // Call the core rect-based API
236    scroll_rect_into_view(
237        absolute_cursor_rect,
238        node_id.dom,
239        internal_node_id,
240        layout_results,
241        scroll_manager,
242        options,
243        now,
244    )
245}
246
247// ============================================================================
248// Helper Functions
249// ============================================================================
250
251/// Find all scrollable ancestors from a node to the root
252///
253/// Returns ancestors ordered from innermost (closest to target) to outermost (root).
254fn find_scrollable_ancestors(
255    dom_id: DomId,
256    node_id: NodeId,
257    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
258    scroll_manager: &ScrollManager,
259) -> Vec<ScrollableAncestor> {
260    let mut ancestors = Vec::new();
261    
262    let Some(layout_result) = layout_results.get(&dom_id) else {
263        return ancestors;
264    };
265    
266    let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_container();
267
268    // Walk up the DOM tree from parent of target node
269    let mut current = node_hierarchy.get(node_id).and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
270    
271    while let Some(current_node_id) = current {
272        // Check if this node is scrollable
273        if let Some(ancestor) = check_if_scrollable(
274            dom_id,
275            current_node_id,
276            layout_result,
277            scroll_manager,
278        ) {
279            ancestors.push(ancestor);
280        }
281        
282        // Move to parent
283        current = node_hierarchy.get(current_node_id).and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
284    }
285    
286    ancestors
287}
288
289/// Check if a node is scrollable and return its scroll info
290fn check_if_scrollable(
291    dom_id: DomId,
292    node_id: NodeId,
293    layout_result: &DomLayoutResult,
294    scroll_manager: &ScrollManager,
295) -> Option<ScrollableAncestor> {
296    let styled_nodes = layout_result.styled_dom.styled_nodes.as_container();
297    let styled_node = styled_nodes.get(node_id)?;
298    
299    let overflow_x = get_overflow_x(
300        &layout_result.styled_dom,
301        node_id,
302        &styled_node.styled_node_state,
303    );
304    let overflow_y = get_overflow_y(
305        &layout_result.styled_dom,
306        node_id,
307        &styled_node.styled_node_state,
308    );
309    
310    let scroll_x = overflow_x.is_scroll();
311    let scroll_y = overflow_y.is_scroll();
312    
313    // If neither axis is scrollable, skip this node
314    if !scroll_x && !scroll_y {
315        return None;
316    }
317    
318    // Check if the scroll manager has scroll state for this node
319    // (which means it actually has overflowing content)
320    let scroll_state = scroll_manager.get_scroll_state(dom_id, node_id)?;
321    
322    // Check if content actually overflows (use virtual_scroll_size when set, e.g. for VirtualView)
323    let effective_width = scroll_state.virtual_scroll_size.map_or(scroll_state.content_rect.size.width, |s| s.width);
324    let effective_height = scroll_state.virtual_scroll_size.map_or(scroll_state.content_rect.size.height, |s| s.height);
325    let has_overflow_x = effective_width > scroll_state.container_rect.size.width;
326    let has_overflow_y = effective_height > scroll_state.container_rect.size.height;
327    
328    if !has_overflow_x && !has_overflow_y {
329        return None;
330    }
331    
332    // Get the visible rect (container rect minus current scroll offset)
333    let visible_rect = LogicalRect {
334        origin: LogicalPosition {
335            x: scroll_state.container_rect.origin.x + scroll_state.current_offset.x,
336            y: scroll_state.container_rect.origin.y + scroll_state.current_offset.y,
337        },
338        size: scroll_state.container_rect.size,
339    };
340    
341    Some(ScrollableAncestor {
342        dom_id,
343        node_id,
344        visible_rect,
345        scroll_x: scroll_x && has_overflow_x,
346        scroll_y: scroll_y && has_overflow_y,
347    })
348}
349
350/// Calculate the scroll delta needed to bring target into view within container
351#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
352fn calculate_scroll_delta(
353    target: LogicalRect,
354    container: LogicalRect,
355    block: ScrollLogicalPosition,
356    inline: ScrollLogicalPosition,
357    scroll_x_enabled: bool,
358    scroll_y_enabled: bool,
359) -> LogicalPosition {
360    LogicalPosition {
361        x: if scroll_x_enabled {
362            calculate_axis_delta(
363                target.origin.x,
364                target.size.width,
365                container.origin.x,
366                container.size.width,
367                inline,
368            )
369        } else {
370            0.0
371        },
372        y: if scroll_y_enabled {
373            calculate_axis_delta(
374                target.origin.y,
375                target.size.height,
376                container.origin.y,
377                container.size.height,
378                block,
379            )
380        } else {
381            0.0
382        },
383    }
384}
385
386/// Calculate scroll delta for a single axis
387#[must_use] pub fn calculate_axis_delta(
388    target_start: f32,
389    target_size: f32,
390    container_start: f32,
391    container_size: f32,
392    position: ScrollLogicalPosition,
393) -> f32 {
394    let target_end = target_start + target_size;
395    let container_end = container_start + container_size;
396    
397    match position {
398        ScrollLogicalPosition::Start => {
399            // Align target start with container start
400            target_start - container_start
401        }
402        ScrollLogicalPosition::End => {
403            // Align target end with container end
404            target_end - container_end
405        }
406        ScrollLogicalPosition::Center => {
407            // Center target in container
408            let target_center = target_start + target_size / 2.0;
409            let container_center = container_start + container_size / 2.0;
410            target_center - container_center
411        }
412        ScrollLogicalPosition::Nearest => {
413            // Minimum scroll to make target fully visible
414            if target_start < container_start {
415                // Target is above/left of visible area - scroll up/left
416                target_start - container_start
417            } else if target_end > container_end {
418                // Target is below/right of visible area
419                if target_size <= container_size {
420                    // Target fits, align end with container end
421                    target_end - container_end
422                } else {
423                    // Target doesn't fit, align start with container start
424                    target_start - container_start
425                }
426            } else {
427                // Target is already fully visible
428                0.0
429            }
430        }
431    }
432}
433
434/// Resolve scroll behavior based on options and CSS properties
435// +spec:containing-block:03528c - scroll-behavior on root element applies to viewport
436const fn resolve_scroll_behavior(
437    requested: ScrollIntoViewBehavior,
438    _dom_id: DomId,
439    _node_id: NodeId,
440    _layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
441) -> ScrollIntoViewBehavior {
442    match requested {
443        ScrollIntoViewBehavior::Auto => {
444            // TODO: Check CSS scroll-behavior property on the scroll container
445            // For now, default to instant
446            ScrollIntoViewBehavior::Instant
447        }
448        other => other,
449    }
450}
451
452/// Apply a scroll adjustment to the scroll manager
453fn apply_scroll_adjustment(
454    scroll_manager: &mut ScrollManager,
455    dom_id: DomId,
456    node_id: NodeId,
457    delta: LogicalPosition,
458    behavior: ScrollIntoViewBehavior,
459    now: Instant,
460) {
461    use azul_core::events::EasingFunction;
462    use azul_core::task::SystemTimeDiff;
463    
464    let current = scroll_manager
465        .get_current_offset(dom_id, node_id)
466        .unwrap_or_default();
467    
468    let new_position = LogicalPosition {
469        x: current.x + delta.x,
470        y: current.y + delta.y,
471    };
472    
473    match behavior {
474        ScrollIntoViewBehavior::Instant | ScrollIntoViewBehavior::Auto => {
475            scroll_manager.set_scroll_position(dom_id, node_id, new_position, now);
476        }
477        ScrollIntoViewBehavior::Smooth => {
478            // Use smooth scroll with 300ms duration
479            let duration = Duration::System(SystemTimeDiff::from_millis(SMOOTH_SCROLL_DURATION_MS));
480            scroll_manager.scroll_to(
481                dom_id,
482                node_id,
483                new_position,
484                duration,
485                EasingFunction::EaseOut,
486                now,
487            );
488        }
489    }
490}
491
492/// Get a node's bounding rect from layout results
493fn get_node_rect(
494    node_id: DomNodeId,
495    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
496) -> Option<LogicalRect> {
497    let layout_result = layout_results.get(&node_id.dom)?;
498    let nid = node_id.node.into_crate_internal()?;
499    
500    // Get position
501    let layout_indices = layout_result.layout_tree.dom_to_layout.get(&nid)?;
502    let layout_index = *layout_indices.first()?;
503    let position = *layout_result.calculated_positions.get(layout_index)?;
504    
505    // Get size
506    let layout_node = layout_result.layout_tree.get(layout_index)?;
507    let size = layout_node.used_size?;
508    
509    Some(LogicalRect::new(position, size))
510}
511
512#[cfg(test)]
513mod autotest_generated {
514    use alloc::collections::BTreeMap;
515    use std::collections::HashMap;
516
517    use azul_core::{
518        dom::{Dom, FormattingContext, IdOrClass},
519        styled_dom::{NodeHierarchyItemId, StyledDom},
520    };
521
522    use super::*;
523    use crate::solver3::{
524        display_list::DisplayList,
525        geometry::PackedBoxProps,
526        layout_tree::{LayoutNodeHot, LayoutTree},
527    };
528
529    // ------------------------------------------------------------------
530    // Fixtures
531    // ------------------------------------------------------------------
532
533    /// Flat node indices of [`chain_dom`]. The fixture is a *linear* chain, so
534    /// these indices hold under any tree-flattening order.
535    const OUTER: usize = 1;
536    const INNER: usize = 2;
537    const TARGET: usize = 3;
538    /// A node index far past the end of the fixture DOM.
539    const OUT_OF_RANGE: usize = 9999;
540
541    const SCROLL_CSS: &str = ".outer { overflow-x: scroll; overflow-y: scroll; } .inner { \
542                              overflow-x: scroll; overflow-y: scroll; }";
543    const X_ONLY_CSS: &str = ".inner { overflow-x: scroll; }";
544    const NO_CSS: &str = "";
545
546    fn dom_id(inner: usize) -> DomId {
547        DomId { inner }
548    }
549
550    fn nid(index: usize) -> NodeId {
551        NodeId::new(index)
552    }
553
554    fn dnid(dom: usize, index: usize) -> DomNodeId {
555        DomNodeId {
556            dom: dom_id(dom),
557            node: NodeHierarchyItemId::from_crate_internal(Some(nid(index))),
558        }
559    }
560
561    /// A `DomNodeId` whose node slot is the "no node" sentinel.
562    fn null_dnid(dom: usize) -> DomNodeId {
563        DomNodeId {
564            dom: dom_id(dom),
565            node: NodeHierarchyItemId::NONE,
566        }
567    }
568
569    fn pos(x: f32, y: f32) -> LogicalPosition {
570        LogicalPosition::new(x, y)
571    }
572
573    fn size(width: f32, height: f32) -> LogicalSize {
574        LogicalSize::new(width, height)
575    }
576
577    fn rect(x: f32, y: f32, width: f32, height: f32) -> LogicalRect {
578        LogicalRect::new(pos(x, y), size(width, height))
579    }
580
581    fn close(a: f32, b: f32) -> bool {
582        (a - b).abs() < 1e-3
583    }
584
585    fn now() -> Instant {
586        Instant::now()
587    }
588
589    fn opts(
590        block: ScrollLogicalPosition,
591        inline_axis: ScrollLogicalPosition,
592        behavior: ScrollIntoViewBehavior,
593    ) -> ScrollIntoViewOptions {
594        ScrollIntoViewOptions {
595            block,
596            inline_axis,
597            behavior,
598        }
599    }
600
601    fn div_with_class(class: &str) -> Dom {
602        Dom::create_div().with_ids_and_classes(vec![IdOrClass::Class(class.into())].into())
603    }
604
605    /// `body(0) > .outer(1) > .inner(2) > .target(3)` — a strictly linear chain,
606    /// styled through a real class stylesheet (not `Dom::with_css`, whose scoped
607    /// `*` rule would also match the descendants and blur which node is styled).
608    fn chain_dom(css_str: &str) -> StyledDom {
609        let mut dom = Dom::create_body().with_child(
610            div_with_class("outer")
611                .with_child(div_with_class("inner").with_child(div_with_class("target"))),
612        );
613        let (css, _warnings) = azul_css::parser2::new_from_str(css_str);
614        StyledDom::create(&mut dom, css)
615    }
616
617    fn empty_layout_tree() -> LayoutTree {
618        LayoutTree {
619            nodes: Vec::new(),
620            warm: Vec::new(),
621            cold: Vec::new(),
622            root: 0,
623            dom_to_layout: BTreeMap::new(),
624            children_arena: Vec::new(),
625            children_offsets: Vec::new(),
626            subtree_needs_intrinsic: Vec::new(),
627        }
628    }
629
630    /// A `DomLayoutResult` with an *empty* layout tree. Everything except
631    /// `get_node_rect` reads only `styled_dom`, so no real layout is needed.
632    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
633        DomLayoutResult {
634            styled_dom,
635            layout_tree: empty_layout_tree(),
636            calculated_positions: Vec::new(),
637            viewport: LogicalRect::zero(),
638            display_list: DisplayList::default(),
639            scroll_ids: HashMap::new(),
640            scroll_id_to_node_id: HashMap::new(),
641        }
642    }
643
644    /// One layout box per entry, in order: DOM node `n` maps to layout index `i`,
645    /// laid out at `p` with used size `s`.
646    fn layout_result_with_boxes(
647        styled_dom: StyledDom,
648        boxes: &[(usize, LogicalPosition, Option<LogicalSize>)],
649    ) -> DomLayoutResult {
650        let mut lr = layout_result(styled_dom);
651        for (layout_index, (node_index, position, used_size)) in boxes.iter().enumerate() {
652            lr.layout_tree
653                .dom_to_layout
654                .insert(nid(*node_index), vec![layout_index]);
655            lr.layout_tree.nodes.push(LayoutNodeHot {
656                box_props: PackedBoxProps::default(),
657                dom_node_id: Some(nid(*node_index)),
658                used_size: *used_size,
659                formatting_context: FormattingContext::Block {
660                    establishes_new_context: false,
661                },
662                parent: None,
663            });
664            lr.calculated_positions.push(*position);
665        }
666        lr
667    }
668
669    fn window(lr: DomLayoutResult) -> BTreeMap<DomId, DomLayoutResult> {
670        let mut map = BTreeMap::new();
671        map.insert(dom_id(0), lr);
672        map
673    }
674
675    fn register(sm: &mut ScrollManager, node: usize, container: LogicalRect, content: LogicalSize) {
676        sm.register_or_update_scroll_node(
677            dom_id(0),
678            nid(node),
679            container,
680            content,
681            now(),
682            16.0,
683            8.0,
684            false,
685            false,
686        );
687    }
688
689    /// `.inner` (node 2) is a 100×100 scroll container at the origin holding
690    /// 100×1000 of content: vertical overflow only, max scroll y = 900.
691    /// `.outer` (node 1) is styled scrollable but never registered with the scroll
692    /// manager, so it is *not* a live scroll container.
693    fn inner_only() -> (BTreeMap<DomId, DomLayoutResult>, ScrollManager) {
694        let layout_results = window(layout_result(chain_dom(SCROLL_CSS)));
695        let mut sm = ScrollManager::new();
696        register(
697            &mut sm,
698            INNER,
699            rect(0.0, 0.0, 100.0, 100.0),
700            size(100.0, 1000.0),
701        );
702        (layout_results, sm)
703    }
704
705    // ==================================================================
706    // calculate_axis_delta — numeric: zero / min_max / negative / nan_inf
707    // ==================================================================
708
709    #[test]
710    fn axis_delta_all_zero_inputs_are_zero_for_every_position() {
711        for position in [
712            ScrollLogicalPosition::Start,
713            ScrollLogicalPosition::Center,
714            ScrollLogicalPosition::End,
715            ScrollLogicalPosition::Nearest,
716        ] {
717            let delta = calculate_axis_delta(0.0, 0.0, 0.0, 0.0, position);
718            assert!(close(delta, 0.0), "{position:?} on all-zero input");
719        }
720    }
721
722    #[test]
723    fn axis_delta_start_aligns_target_start_with_container_start() {
724        assert!(close(
725            calculate_axis_delta(100.0, 50.0, 20.0, 30.0, ScrollLogicalPosition::Start),
726            80.0
727        ));
728    }
729
730    #[test]
731    fn axis_delta_end_aligns_target_end_with_container_end() {
732        // target 100..150, container 0..30 => 150 - 30
733        assert!(close(
734            calculate_axis_delta(100.0, 50.0, 0.0, 30.0, ScrollLogicalPosition::End),
735            120.0
736        ));
737    }
738
739    #[test]
740    fn axis_delta_center_aligns_midpoints() {
741        // target center 120, container center 50
742        assert!(close(
743            calculate_axis_delta(100.0, 40.0, 0.0, 100.0, ScrollLogicalPosition::Center),
744            70.0
745        ));
746    }
747
748    #[test]
749    fn axis_delta_center_of_zero_sized_target_is_offset_of_container_center() {
750        assert!(close(
751            calculate_axis_delta(0.0, 0.0, 0.0, 100.0, ScrollLogicalPosition::Center),
752            -50.0
753        ));
754    }
755
756    #[test]
757    fn axis_delta_nearest_leaves_fully_visible_target_alone() {
758        // target 10..30 inside container 0..100
759        assert!(close(
760            calculate_axis_delta(10.0, 20.0, 0.0, 100.0, ScrollLogicalPosition::Nearest),
761            0.0
762        ));
763    }
764
765    #[test]
766    fn axis_delta_nearest_scrolls_back_for_target_before_container() {
767        assert!(close(
768            calculate_axis_delta(-40.0, 20.0, 0.0, 100.0, ScrollLogicalPosition::Nearest),
769            -40.0
770        ));
771    }
772
773    #[test]
774    fn axis_delta_nearest_aligns_end_when_target_fits() {
775        // target 150..170 (size 20) below container 0..100 => end-align
776        assert!(close(
777            calculate_axis_delta(150.0, 20.0, 0.0, 100.0, ScrollLogicalPosition::Nearest),
778            70.0
779        ));
780    }
781
782    #[test]
783    fn axis_delta_nearest_aligns_start_when_target_does_not_fit() {
784        // target 150..450 (size 300) is bigger than the 100px container => start-align
785        assert!(close(
786            calculate_axis_delta(150.0, 300.0, 0.0, 100.0, ScrollLogicalPosition::Nearest),
787            150.0
788        ));
789    }
790
791    #[test]
792    fn axis_delta_handles_negative_coordinates_deterministically() {
793        // container -100..-50, target -200..-190
794        assert!(close(
795            calculate_axis_delta(-200.0, 10.0, -100.0, 50.0, ScrollLogicalPosition::Start),
796            -100.0
797        ));
798        assert!(close(
799            calculate_axis_delta(-200.0, 10.0, -100.0, 50.0, ScrollLogicalPosition::End),
800            -140.0
801        ));
802        assert!(close(
803            calculate_axis_delta(-200.0, 10.0, -100.0, 50.0, ScrollLogicalPosition::Nearest),
804            -100.0
805        ));
806    }
807
808    #[test]
809    fn axis_delta_nearest_never_scrolls_the_wrong_way() {
810        let (container_start, container_size) = (100.0f32, 50.0f32);
811        for target_start in [-1000.0f32, 0.0, 99.0, 100.0, 120.0, 149.0, 150.0, 1000.0] {
812            for target_size in [0.0f32, 10.0, 50.0, 60.0] {
813                let delta = calculate_axis_delta(
814                    target_start,
815                    target_size,
816                    container_start,
817                    container_size,
818                    ScrollLogicalPosition::Nearest,
819                );
820                let start_align = target_start - container_start;
821                let end_align = (target_start + target_size) - (container_start + container_size);
822                let fully_visible = target_start >= container_start
823                    && (target_start + target_size) <= (container_start + container_size);
824
825                // The delta is always one of {0, start-align, end-align} — never an
826                // arbitrary value, and never an overshoot past both alignments.
827                assert!(
828                    close(delta, 0.0) || close(delta, start_align) || close(delta, end_align),
829                    "delta {delta} for target {target_start}+{target_size}"
830                );
831                if fully_visible {
832                    assert!(
833                        close(delta, 0.0),
834                        "visible target {target_start}+{target_size} must not scroll"
835                    );
836                }
837                if target_start < container_start {
838                    // Target starts before the viewport: only ever scroll backwards.
839                    assert!(delta <= 0.0, "delta {delta} scrolled the wrong way");
840                }
841            }
842        }
843    }
844
845    #[test]
846    fn axis_delta_nan_target_start_does_not_panic() {
847        // Start/End/Center are pure arithmetic: NaN propagates (a defined f32 result).
848        for position in [
849            ScrollLogicalPosition::Start,
850            ScrollLogicalPosition::End,
851            ScrollLogicalPosition::Center,
852        ] {
853            let delta = calculate_axis_delta(f32::NAN, 10.0, 0.0, 100.0, position);
854            assert!(delta.is_nan(), "{position:?} should propagate NaN, got {delta}");
855        }
856    }
857
858    #[test]
859    fn axis_delta_nan_target_start_is_zero_for_nearest() {
860        // Every NaN comparison is false, so `Nearest` falls through to the
861        // "already fully visible" branch and returns exactly 0.0 — the safe
862        // choice (no scroll) rather than a NaN leaking into the scroll offset.
863        let delta = calculate_axis_delta(f32::NAN, 10.0, 0.0, 100.0, ScrollLogicalPosition::Nearest);
864        assert_eq!(delta, 0.0);
865    }
866
867    #[test]
868    fn axis_delta_nan_container_does_not_panic() {
869        for position in [
870            ScrollLogicalPosition::Start,
871            ScrollLogicalPosition::End,
872            ScrollLogicalPosition::Center,
873            ScrollLogicalPosition::Nearest,
874        ] {
875            let delta = calculate_axis_delta(0.0, 10.0, f32::NAN, f32::NAN, position);
876            assert!(
877                delta.is_nan() || delta == 0.0,
878                "{position:?} gave {delta} for a NaN container"
879            );
880        }
881    }
882
883    #[test]
884    fn axis_delta_infinite_target_start_saturates_to_infinity() {
885        let delta =
886            calculate_axis_delta(f32::INFINITY, 10.0, 0.0, 100.0, ScrollLogicalPosition::Start);
887        assert!(delta.is_infinite() && delta.is_sign_positive());
888
889        let delta = calculate_axis_delta(
890            f32::NEG_INFINITY,
891            10.0,
892            0.0,
893            100.0,
894            ScrollLogicalPosition::Start,
895        );
896        assert!(delta.is_infinite() && delta.is_sign_negative());
897    }
898
899    #[test]
900    fn axis_delta_infinity_minus_infinity_is_nan_not_a_panic() {
901        let delta = calculate_axis_delta(
902            f32::INFINITY,
903            10.0,
904            f32::INFINITY,
905            100.0,
906            ScrollLogicalPosition::Start,
907        );
908        assert!(delta.is_nan());
909    }
910
911    #[test]
912    fn axis_delta_infinite_sizes_do_not_panic() {
913        for position in [
914            ScrollLogicalPosition::Start,
915            ScrollLogicalPosition::End,
916            ScrollLogicalPosition::Center,
917            ScrollLogicalPosition::Nearest,
918        ] {
919            let delta = calculate_axis_delta(0.0, f32::INFINITY, 0.0, f32::INFINITY, position);
920            assert!(
921                delta.is_nan() || delta.is_infinite() || delta.is_finite(),
922                "{position:?} produced a non-f32 value"
923            );
924        }
925    }
926
927    #[test]
928    fn axis_delta_f32_max_overflow_saturates_instead_of_panicking() {
929        // target_start + target_size overflows f32 => +inf (IEEE saturation, no panic)
930        let delta =
931            calculate_axis_delta(f32::MAX, f32::MAX, 0.0, 100.0, ScrollLogicalPosition::End);
932        assert!(delta.is_infinite() && delta.is_sign_positive());
933
934        // Nearest sees target_end == +inf > container_end, and the (infinite)
935        // target does not fit, so it start-aligns to a finite f32::MAX.
936        let delta =
937            calculate_axis_delta(f32::MAX, f32::MAX, 0.0, 100.0, ScrollLogicalPosition::Nearest);
938        assert_eq!(delta, f32::MAX);
939    }
940
941    #[test]
942    fn axis_delta_f32_min_does_not_panic() {
943        for position in [
944            ScrollLogicalPosition::Start,
945            ScrollLogicalPosition::End,
946            ScrollLogicalPosition::Center,
947            ScrollLogicalPosition::Nearest,
948        ] {
949            let delta = calculate_axis_delta(f32::MIN, 1.0, f32::MAX, 1.0, position);
950            assert!(!delta.is_nan(), "{position:?} produced NaN from finite input");
951        }
952    }
953
954    // ==================================================================
955    // calculate_scroll_delta — numeric
956    // ==================================================================
957
958    #[test]
959    fn scroll_delta_zero_rects_are_zero() {
960        let delta = calculate_scroll_delta(
961            LogicalRect::zero(),
962            LogicalRect::zero(),
963            ScrollLogicalPosition::Nearest,
964            ScrollLogicalPosition::Nearest,
965            true,
966            true,
967        );
968        assert_eq!((delta.x, delta.y), (0.0, 0.0));
969    }
970
971    #[test]
972    fn scroll_delta_disabled_axes_are_exactly_zero_even_for_nan_and_infinite_rects() {
973        let poison = LogicalRect::new(
974            pos(f32::NAN, f32::INFINITY),
975            size(f32::NAN, f32::NEG_INFINITY),
976        );
977        let delta = calculate_scroll_delta(
978            poison,
979            poison,
980            ScrollLogicalPosition::Start,
981            ScrollLogicalPosition::Start,
982            false,
983            false,
984        );
985        // Disabled axes short-circuit to 0.0 before any arithmetic runs, so no
986        // NaN can reach the scroll offset.
987        assert_eq!((delta.x, delta.y), (0.0, 0.0));
988        assert!(delta.x.is_finite() && delta.y.is_finite());
989    }
990
991    #[test]
992    fn scroll_delta_does_not_swap_the_axes() {
993        // x must use `inline` + width, y must use `block` + height.
994        let delta = calculate_scroll_delta(
995            rect(10.0, 200.0, 5.0, 5.0),
996            rect(0.0, 0.0, 100.0, 50.0),
997            ScrollLogicalPosition::Start, // block  -> y
998            ScrollLogicalPosition::End,   // inline -> x
999            true,
1000            true,
1001        );
1002        assert!(close(delta.x, -85.0), "x used the wrong axis/position: {}", delta.x);
1003        assert!(close(delta.y, 200.0), "y used the wrong axis/position: {}", delta.y);
1004    }
1005
1006    #[test]
1007    fn scroll_delta_only_enabled_axis_moves() {
1008        let target = rect(500.0, 500.0, 10.0, 10.0);
1009        let container = rect(0.0, 0.0, 100.0, 100.0);
1010
1011        let x_only = calculate_scroll_delta(
1012            target,
1013            container,
1014            ScrollLogicalPosition::Start,
1015            ScrollLogicalPosition::Start,
1016            true,
1017            false,
1018        );
1019        assert!(close(x_only.x, 500.0));
1020        assert_eq!(x_only.y, 0.0);
1021
1022        let y_only = calculate_scroll_delta(
1023            target,
1024            container,
1025            ScrollLogicalPosition::Start,
1026            ScrollLogicalPosition::Start,
1027            false,
1028            true,
1029        );
1030        assert_eq!(y_only.x, 0.0);
1031        assert!(close(y_only.y, 500.0));
1032    }
1033
1034    // ==================================================================
1035    // resolve_scroll_behavior — predicate / invariant
1036    // ==================================================================
1037
1038    #[test]
1039    fn resolve_behavior_maps_auto_to_instant_and_passes_the_rest_through() {
1040        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
1041        assert_eq!(
1042            resolve_scroll_behavior(
1043                ScrollIntoViewBehavior::Auto,
1044                dom_id(0),
1045                nid(TARGET),
1046                &empty
1047            ),
1048            ScrollIntoViewBehavior::Instant
1049        );
1050        assert_eq!(
1051            resolve_scroll_behavior(
1052                ScrollIntoViewBehavior::Instant,
1053                dom_id(0),
1054                nid(TARGET),
1055                &empty
1056            ),
1057            ScrollIntoViewBehavior::Instant
1058        );
1059        assert_eq!(
1060            resolve_scroll_behavior(
1061                ScrollIntoViewBehavior::Smooth,
1062                dom_id(OUT_OF_RANGE),
1063                nid(OUT_OF_RANGE),
1064                &empty
1065            ),
1066            ScrollIntoViewBehavior::Smooth
1067        );
1068    }
1069
1070    #[test]
1071    fn resolve_behavior_is_idempotent() {
1072        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
1073        for behavior in [
1074            ScrollIntoViewBehavior::Auto,
1075            ScrollIntoViewBehavior::Instant,
1076            ScrollIntoViewBehavior::Smooth,
1077        ] {
1078            let once = resolve_scroll_behavior(behavior, dom_id(0), nid(0), &empty);
1079            let twice = resolve_scroll_behavior(once, dom_id(0), nid(0), &empty);
1080            assert_eq!(once, twice, "resolving {behavior:?} twice changed the result");
1081        }
1082    }
1083
1084    // ==================================================================
1085    // apply_scroll_adjustment — numeric: zero / min_max / negative / overflow
1086    // ==================================================================
1087
1088    /// 100×100 container, 500×500 content => max scroll (400, 400).
1089    fn registered_manager() -> ScrollManager {
1090        let mut sm = ScrollManager::new();
1091        register(
1092            &mut sm,
1093            INNER,
1094            rect(0.0, 0.0, 100.0, 100.0),
1095            size(500.0, 500.0),
1096        );
1097        sm
1098    }
1099
1100    #[test]
1101    fn apply_zero_delta_leaves_the_offset_at_zero() {
1102        let mut sm = registered_manager();
1103        apply_scroll_adjustment(
1104            &mut sm,
1105            dom_id(0),
1106            nid(INNER),
1107            pos(0.0, 0.0),
1108            ScrollIntoViewBehavior::Instant,
1109            now(),
1110        );
1111        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1112        assert_eq!((offset.x, offset.y), (0.0, 0.0));
1113    }
1114
1115    #[test]
1116    fn apply_on_an_unregistered_node_creates_a_zero_bounded_state() {
1117        // No bounds are known for the node, so max scroll is 0 and the delta is
1118        // clamped away entirely — it must not panic or store an unbounded offset.
1119        let mut sm = ScrollManager::new();
1120        apply_scroll_adjustment(
1121            &mut sm,
1122            dom_id(0),
1123            nid(OUT_OF_RANGE),
1124            pos(1234.0, 5678.0),
1125            ScrollIntoViewBehavior::Instant,
1126            now(),
1127        );
1128        let offset = sm.get_current_offset(dom_id(0), nid(OUT_OF_RANGE)).unwrap();
1129        assert_eq!((offset.x, offset.y), (0.0, 0.0));
1130    }
1131
1132    #[test]
1133    fn apply_instant_delta_moves_the_offset() {
1134        let mut sm = registered_manager();
1135        apply_scroll_adjustment(
1136            &mut sm,
1137            dom_id(0),
1138            nid(INNER),
1139            pos(50.0, 60.0),
1140            ScrollIntoViewBehavior::Instant,
1141            now(),
1142        );
1143        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1144        assert!(close(offset.x, 50.0) && close(offset.y, 60.0));
1145        assert!(!sm.has_active_animations());
1146    }
1147
1148    #[test]
1149    fn apply_negative_delta_clamps_to_zero() {
1150        let mut sm = registered_manager();
1151        apply_scroll_adjustment(
1152            &mut sm,
1153            dom_id(0),
1154            nid(INNER),
1155            pos(-1000.0, -1000.0),
1156            ScrollIntoViewBehavior::Instant,
1157            now(),
1158        );
1159        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1160        assert_eq!((offset.x, offset.y), (0.0, 0.0));
1161    }
1162
1163    #[test]
1164    fn apply_f32_max_delta_clamps_to_max_scroll() {
1165        let mut sm = registered_manager();
1166        apply_scroll_adjustment(
1167            &mut sm,
1168            dom_id(0),
1169            nid(INNER),
1170            pos(f32::MAX, f32::MAX),
1171            ScrollIntoViewBehavior::Instant,
1172            now(),
1173        );
1174        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1175        assert!(close(offset.x, 400.0) && close(offset.y, 400.0), "{offset:?}");
1176    }
1177
1178    #[test]
1179    fn apply_infinite_delta_clamps_to_the_scroll_bounds() {
1180        let mut sm = registered_manager();
1181        apply_scroll_adjustment(
1182            &mut sm,
1183            dom_id(0),
1184            nid(INNER),
1185            pos(f32::INFINITY, f32::NEG_INFINITY),
1186            ScrollIntoViewBehavior::Instant,
1187            now(),
1188        );
1189        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1190        assert!(offset.x.is_finite() && offset.y.is_finite(), "{offset:?}");
1191        assert!(close(offset.x, 400.0) && close(offset.y, 0.0), "{offset:?}");
1192    }
1193
1194    #[test]
1195    fn apply_nan_delta_cannot_poison_the_scroll_offset() {
1196        let mut sm = registered_manager();
1197        apply_scroll_adjustment(
1198            &mut sm,
1199            dom_id(0),
1200            nid(INNER),
1201            pos(f32::NAN, f32::NAN),
1202            ScrollIntoViewBehavior::Instant,
1203            now(),
1204        );
1205        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1206        // f32::max(NaN, 0.0) == 0.0, so the clamp scrubs the NaN.
1207        assert!(offset.x.is_finite() && offset.y.is_finite(), "{offset:?}");
1208        assert_eq!((offset.x, offset.y), (0.0, 0.0));
1209    }
1210
1211    #[test]
1212    fn apply_auto_behaves_like_instant() {
1213        let mut sm = registered_manager();
1214        apply_scroll_adjustment(
1215            &mut sm,
1216            dom_id(0),
1217            nid(INNER),
1218            pos(25.0, 25.0),
1219            ScrollIntoViewBehavior::Auto,
1220            now(),
1221        );
1222        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1223        assert!(close(offset.x, 25.0) && close(offset.y, 25.0));
1224        assert!(!sm.has_active_animations());
1225    }
1226
1227    #[test]
1228    fn apply_smooth_animates_instead_of_jumping() {
1229        let mut sm = registered_manager();
1230        apply_scroll_adjustment(
1231            &mut sm,
1232            dom_id(0),
1233            nid(INNER),
1234            pos(50.0, 50.0),
1235            ScrollIntoViewBehavior::Smooth,
1236            now(),
1237        );
1238        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1239        assert_eq!((offset.x, offset.y), (0.0, 0.0), "smooth must not jump");
1240        assert!(sm.has_active_animations(), "smooth must arm an animation");
1241    }
1242
1243    #[test]
1244    fn apply_deltas_accumulate_onto_the_current_offset() {
1245        let mut sm = registered_manager();
1246        for _ in 0..3 {
1247            apply_scroll_adjustment(
1248                &mut sm,
1249                dom_id(0),
1250                nid(INNER),
1251                pos(100.0, 100.0),
1252                ScrollIntoViewBehavior::Instant,
1253                now(),
1254            );
1255        }
1256        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1257        assert!(close(offset.x, 300.0) && close(offset.y, 300.0), "{offset:?}");
1258    }
1259
1260    // ==================================================================
1261    // get_node_rect — missing / stale / corrupt layout data
1262    // ==================================================================
1263
1264    #[test]
1265    fn get_node_rect_missing_dom_is_none() {
1266        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
1267        assert!(get_node_rect(dnid(0, TARGET), &empty).is_none());
1268    }
1269
1270    #[test]
1271    fn get_node_rect_wrong_dom_id_is_none() {
1272        let lrs = window(layout_result_with_boxes(
1273            chain_dom(SCROLL_CSS),
1274            &[(TARGET, pos(10.0, 20.0), Some(size(30.0, 40.0)))],
1275        ));
1276        assert!(get_node_rect(dnid(7, TARGET), &lrs).is_none());
1277    }
1278
1279    #[test]
1280    fn get_node_rect_null_node_id_is_none() {
1281        let lrs = window(layout_result_with_boxes(
1282            chain_dom(SCROLL_CSS),
1283            &[(TARGET, pos(10.0, 20.0), Some(size(30.0, 40.0)))],
1284        ));
1285        assert!(get_node_rect(null_dnid(0), &lrs).is_none());
1286    }
1287
1288    #[test]
1289    fn get_node_rect_unmapped_node_is_none() {
1290        let lrs = window(layout_result_with_boxes(
1291            chain_dom(SCROLL_CSS),
1292            &[(TARGET, pos(10.0, 20.0), Some(size(30.0, 40.0)))],
1293        ));
1294        assert!(get_node_rect(dnid(0, OUTER), &lrs).is_none());
1295        assert!(get_node_rect(dnid(0, OUT_OF_RANGE), &lrs).is_none());
1296    }
1297
1298    #[test]
1299    fn get_node_rect_empty_layout_index_list_is_none() {
1300        let mut lr = layout_result_with_boxes(
1301            chain_dom(SCROLL_CSS),
1302            &[(TARGET, pos(10.0, 20.0), Some(size(30.0, 40.0)))],
1303        );
1304        // A DOM node mapped to *no* layout box (can happen for display:none).
1305        lr.layout_tree.dom_to_layout.insert(nid(TARGET), Vec::new());
1306        assert!(get_node_rect(dnid(0, TARGET), &window(lr)).is_none());
1307    }
1308
1309    #[test]
1310    fn get_node_rect_dangling_layout_index_is_none_not_a_panic() {
1311        let mut lr = layout_result_with_boxes(
1312            chain_dom(SCROLL_CSS),
1313            &[(TARGET, pos(10.0, 20.0), Some(size(30.0, 40.0)))],
1314        );
1315        // Stale mapping pointing past the end of both `calculated_positions` and
1316        // `layout_tree.nodes` — must be a None, not an out-of-bounds index panic.
1317        lr.layout_tree.dom_to_layout.insert(nid(TARGET), vec![7]);
1318        assert!(get_node_rect(dnid(0, TARGET), &window(lr)).is_none());
1319    }
1320
1321    #[test]
1322    fn get_node_rect_unsized_node_is_none() {
1323        let lrs = window(layout_result_with_boxes(
1324            chain_dom(SCROLL_CSS),
1325            &[(TARGET, pos(10.0, 20.0), None)],
1326        ));
1327        assert!(get_node_rect(dnid(0, TARGET), &lrs).is_none());
1328    }
1329
1330    #[test]
1331    fn get_node_rect_returns_position_and_used_size() {
1332        let lrs = window(layout_result_with_boxes(
1333            chain_dom(SCROLL_CSS),
1334            &[(TARGET, pos(10.0, 20.0), Some(size(30.0, 40.0)))],
1335        ));
1336        let r = get_node_rect(dnid(0, TARGET), &lrs).expect("target has a layout box");
1337        assert!(close(r.origin.x, 10.0) && close(r.origin.y, 20.0));
1338        assert!(close(r.size.width, 30.0) && close(r.size.height, 40.0));
1339    }
1340
1341    // ==================================================================
1342    // check_if_scrollable — predicate invariants
1343    // ==================================================================
1344
1345    #[test]
1346    fn check_if_scrollable_out_of_range_node_is_none() {
1347        let lr = layout_result(chain_dom(SCROLL_CSS));
1348        let sm = ScrollManager::new();
1349        assert!(check_if_scrollable(dom_id(0), nid(OUT_OF_RANGE), &lr, &sm).is_none());
1350    }
1351
1352    #[test]
1353    fn check_if_scrollable_without_overflow_css_is_none() {
1354        // Registered *and* overflowing, but the CSS says the node does not scroll.
1355        let lr = layout_result(chain_dom(NO_CSS));
1356        let mut sm = ScrollManager::new();
1357        register(
1358            &mut sm,
1359            INNER,
1360            rect(0.0, 0.0, 100.0, 100.0),
1361            size(500.0, 500.0),
1362        );
1363        assert!(check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm).is_none());
1364    }
1365
1366    #[test]
1367    fn check_if_scrollable_without_scroll_state_is_none() {
1368        // CSS says scrollable, but the scroll manager has never seen the node.
1369        let lr = layout_result(chain_dom(SCROLL_CSS));
1370        let sm = ScrollManager::new();
1371        assert!(check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm).is_none());
1372    }
1373
1374    #[test]
1375    fn check_if_scrollable_with_content_that_fits_is_none() {
1376        let lr = layout_result(chain_dom(SCROLL_CSS));
1377        let mut sm = ScrollManager::new();
1378        register(
1379            &mut sm,
1380            INNER,
1381            rect(0.0, 0.0, 100.0, 100.0),
1382            size(100.0, 100.0),
1383        );
1384        assert!(check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm).is_none());
1385    }
1386
1387    #[test]
1388    fn check_if_scrollable_reports_the_overflowing_axes_and_visible_rect() {
1389        let lr = layout_result(chain_dom(SCROLL_CSS));
1390        let mut sm = ScrollManager::new();
1391        // Container at (5, 7), overflowing vertically only.
1392        register(
1393            &mut sm,
1394            INNER,
1395            rect(5.0, 7.0, 100.0, 100.0),
1396            size(100.0, 1000.0),
1397        );
1398        sm.set_scroll_position(dom_id(0), nid(INNER), pos(0.0, 300.0), now());
1399
1400        let ancestor = check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm)
1401            .expect("an overflowing scroll container");
1402        assert_eq!(ancestor.node_id, nid(INNER));
1403        assert_eq!(ancestor.dom_id, dom_id(0));
1404        assert!(!ancestor.scroll_x, "x does not overflow, so it is not scrollable");
1405        assert!(ancestor.scroll_y);
1406        // visible_rect = container origin + current scroll offset, container size.
1407        assert!(close(ancestor.visible_rect.origin.x, 5.0));
1408        assert!(close(ancestor.visible_rect.origin.y, 307.0));
1409        assert!(close(ancestor.visible_rect.size.width, 100.0));
1410        assert!(close(ancestor.visible_rect.size.height, 100.0));
1411    }
1412
1413    #[test]
1414    fn check_if_scrollable_uses_virtual_scroll_size_over_content_rect() {
1415        let lr = layout_result(chain_dom(SCROLL_CSS));
1416        let mut sm = ScrollManager::new();
1417        // Content fits the container exactly => no overflow from `content_rect`...
1418        register(
1419            &mut sm,
1420            INNER,
1421            rect(0.0, 0.0, 100.0, 100.0),
1422            size(100.0, 100.0),
1423        );
1424        assert!(check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm).is_none());
1425
1426        // ...but a VirtualView reports a much larger virtual size, which wins.
1427        sm.update_virtual_scroll_bounds(dom_id(0), nid(INNER), size(100.0, 10_000.0), None);
1428        let ancestor = check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm)
1429            .expect("virtual_scroll_size must drive the overflow check");
1430        assert!(ancestor.scroll_y);
1431        assert!(!ancestor.scroll_x);
1432    }
1433
1434    #[test]
1435    fn check_if_scrollable_zero_sized_container_with_content_overflows() {
1436        let lr = layout_result(chain_dom(SCROLL_CSS));
1437        let mut sm = ScrollManager::new();
1438        register(&mut sm, INNER, LogicalRect::zero(), size(1.0, 1.0));
1439        let ancestor =
1440            check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm).expect("1px > 0px overflows");
1441        assert!(ancestor.scroll_x && ancestor.scroll_y);
1442    }
1443
1444    #[test]
1445    fn check_if_scrollable_x_only_css_does_not_enable_the_y_axis() {
1446        // `.inner` declares only `overflow-x: scroll`. Per CSS Overflow 3 § 3.1 the
1447        // computed `overflow-y` of such a box becomes `auto` (i.e. scrollable), and
1448        // `MultiValue::<LayoutOverflow>::resolve_computed` implements exactly that —
1449        // but `check_if_scrollable` reads the *specified* values, so the y axis stays
1450        // non-scrollable here even though the content overflows it.
1451        let lr = layout_result(chain_dom(X_ONLY_CSS));
1452        let mut sm = ScrollManager::new();
1453        register(
1454            &mut sm,
1455            INNER,
1456            rect(0.0, 0.0, 100.0, 100.0),
1457            size(500.0, 500.0),
1458        );
1459        let ancestor = check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm)
1460            .expect("overflow-x: scroll + overflowing content");
1461        assert!(ancestor.scroll_x);
1462        assert!(!ancestor.scroll_y);
1463    }
1464
1465    // ==================================================================
1466    // find_scrollable_ancestors
1467    // ==================================================================
1468
1469    #[test]
1470    fn find_ancestors_missing_dom_is_empty() {
1471        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
1472        let sm = ScrollManager::new();
1473        assert!(find_scrollable_ancestors(dom_id(0), nid(TARGET), &empty, &sm).is_empty());
1474    }
1475
1476    #[test]
1477    fn find_ancestors_out_of_range_node_is_empty() {
1478        let (lrs, sm) = inner_only();
1479        assert!(find_scrollable_ancestors(dom_id(0), nid(OUT_OF_RANGE), &lrs, &sm).is_empty());
1480    }
1481
1482    #[test]
1483    fn find_ancestors_of_the_root_is_empty() {
1484        // The root has no parent — the walk must terminate immediately.
1485        let (lrs, sm) = inner_only();
1486        assert!(find_scrollable_ancestors(dom_id(0), nid(0), &lrs, &sm).is_empty());
1487    }
1488
1489    #[test]
1490    fn find_ancestors_excludes_the_target_itself() {
1491        // `.inner` is a live scroll container, but scrolling *itself* into view is
1492        // not the job of its own scrollport: the walk starts at the parent.
1493        let (lrs, sm) = inner_only();
1494        let ancestors = find_scrollable_ancestors(dom_id(0), nid(INNER), &lrs, &sm);
1495        assert!(ancestors.iter().all(|a| a.node_id != nid(INNER)));
1496    }
1497
1498    #[test]
1499    fn find_ancestors_skips_styled_but_non_overflowing_containers() {
1500        // `.outer` is styled `overflow: scroll` but was never registered.
1501        let (lrs, sm) = inner_only();
1502        let ancestors = find_scrollable_ancestors(dom_id(0), nid(TARGET), &lrs, &sm);
1503        assert_eq!(ancestors.len(), 1);
1504        assert_eq!(ancestors[0].node_id, nid(INNER));
1505    }
1506
1507    #[test]
1508    fn find_ancestors_orders_innermost_first() {
1509        let lrs = window(layout_result(chain_dom(SCROLL_CSS)));
1510        let mut sm = ScrollManager::new();
1511        register(
1512            &mut sm,
1513            INNER,
1514            rect(0.0, 400.0, 100.0, 100.0),
1515            size(100.0, 1000.0),
1516        );
1517        register(
1518            &mut sm,
1519            OUTER,
1520            rect(0.0, 0.0, 200.0, 200.0),
1521            size(200.0, 2000.0),
1522        );
1523
1524        let ancestors = find_scrollable_ancestors(dom_id(0), nid(TARGET), &lrs, &sm);
1525        assert_eq!(ancestors.len(), 2);
1526        assert_eq!(ancestors[0].node_id, nid(INNER), "innermost must come first");
1527        assert_eq!(ancestors[1].node_id, nid(OUTER));
1528    }
1529
1530    // ==================================================================
1531    // scroll_rect_into_view — the core primitive
1532    // ==================================================================
1533
1534    #[test]
1535    fn rect_into_view_without_scroll_containers_is_a_no_op() {
1536        let lrs = window(layout_result(chain_dom(SCROLL_CSS)));
1537        let mut sm = ScrollManager::new();
1538        let adjustments = scroll_rect_into_view(
1539            rect(0.0, 5000.0, 10.0, 10.0),
1540            dom_id(0),
1541            nid(TARGET),
1542            &lrs,
1543            &mut sm,
1544            opts(
1545                ScrollLogicalPosition::Start,
1546                ScrollLogicalPosition::Start,
1547                ScrollIntoViewBehavior::Instant,
1548            ),
1549            now(),
1550        );
1551        assert!(adjustments.is_empty());
1552        assert!(sm.get_current_offset(dom_id(0), nid(INNER)).is_none());
1553    }
1554
1555    #[test]
1556    fn rect_into_view_missing_dom_is_a_no_op() {
1557        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
1558        let mut sm = ScrollManager::new();
1559        let adjustments = scroll_rect_into_view(
1560            rect(0.0, 5000.0, 10.0, 10.0),
1561            dom_id(0),
1562            nid(TARGET),
1563            &empty,
1564            &mut sm,
1565            ScrollIntoViewOptions::nearest(),
1566            now(),
1567        );
1568        assert!(adjustments.is_empty());
1569    }
1570
1571    #[test]
1572    fn rect_into_view_already_visible_target_does_not_scroll() {
1573        let (lrs, mut sm) = inner_only();
1574        let adjustments = scroll_rect_into_view(
1575            rect(0.0, 10.0, 50.0, 20.0),
1576            dom_id(0),
1577            nid(TARGET),
1578            &lrs,
1579            &mut sm,
1580            ScrollIntoViewOptions::nearest(),
1581            now(),
1582        );
1583        assert!(adjustments.is_empty());
1584        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1585        assert_eq!((offset.x, offset.y), (0.0, 0.0));
1586    }
1587
1588    #[test]
1589    fn rect_into_view_scrolls_and_reports_the_delta() {
1590        let (lrs, mut sm) = inner_only();
1591        let adjustments = scroll_rect_into_view(
1592            rect(0.0, 500.0, 50.0, 20.0),
1593            dom_id(0),
1594            nid(TARGET),
1595            &lrs,
1596            &mut sm,
1597            opts(
1598                ScrollLogicalPosition::Start,
1599                ScrollLogicalPosition::Start,
1600                ScrollIntoViewBehavior::Instant,
1601            ),
1602            now(),
1603        );
1604        assert_eq!(adjustments.len(), 1);
1605        assert_eq!(adjustments[0].scroll_container_node_id, nid(INNER));
1606        assert!(close(adjustments[0].delta.y, 500.0));
1607        assert_eq!(adjustments[0].delta.x, 0.0, "x does not overflow");
1608        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1609        assert!(close(offset.y, 500.0), "{offset:?}");
1610    }
1611
1612    #[test]
1613    fn rect_into_view_ignores_a_delta_at_exactly_the_threshold() {
1614        // The guard is `abs() > SCROLL_DELTA_THRESHOLD`, so a delta of exactly
1615        // 0.5px is *not* applied. 0.5 and 0.75 are both exact in binary f32.
1616        assert!(close(SCROLL_DELTA_THRESHOLD, 0.5));
1617
1618        let (lrs, mut sm) = inner_only();
1619        let at_threshold = scroll_rect_into_view(
1620            rect(0.0, 0.5, 50.0, 20.0),
1621            dom_id(0),
1622            nid(TARGET),
1623            &lrs,
1624            &mut sm,
1625            opts(
1626                ScrollLogicalPosition::Start,
1627                ScrollLogicalPosition::Start,
1628                ScrollIntoViewBehavior::Instant,
1629            ),
1630            now(),
1631        );
1632        assert!(at_threshold.is_empty(), "0.5px must be below the threshold");
1633        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1634        assert_eq!(offset.y, 0.0);
1635
1636        let above_threshold = scroll_rect_into_view(
1637            rect(0.0, 0.75, 50.0, 20.0),
1638            dom_id(0),
1639            nid(TARGET),
1640            &lrs,
1641            &mut sm,
1642            opts(
1643                ScrollLogicalPosition::Start,
1644                ScrollLogicalPosition::Start,
1645                ScrollIntoViewBehavior::Instant,
1646            ),
1647            now(),
1648        );
1649        assert_eq!(above_threshold.len(), 1, "0.75px must scroll");
1650        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1651        assert!(close(offset.y, 0.75), "{offset:?}");
1652    }
1653
1654    #[test]
1655    fn rect_into_view_f32_max_rect_clamps_to_max_scroll() {
1656        let (lrs, mut sm) = inner_only();
1657        let adjustments = scroll_rect_into_view(
1658            rect(f32::MAX, f32::MAX, f32::MAX, f32::MAX),
1659            dom_id(0),
1660            nid(TARGET),
1661            &lrs,
1662            &mut sm,
1663            opts(
1664                ScrollLogicalPosition::Start,
1665                ScrollLogicalPosition::Start,
1666                ScrollIntoViewBehavior::Instant,
1667            ),
1668            now(),
1669        );
1670        assert_eq!(adjustments.len(), 1);
1671        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1672        assert!(offset.x.is_finite() && offset.y.is_finite(), "{offset:?}");
1673        // max scroll y = content 1000 - container 100
1674        assert!(close(offset.y, 900.0), "{offset:?}");
1675    }
1676
1677    #[test]
1678    fn rect_into_view_nan_rect_does_not_scroll_or_poison_the_offset() {
1679        let (lrs, mut sm) = inner_only();
1680        let adjustments = scroll_rect_into_view(
1681            LogicalRect::new(pos(f32::NAN, f32::NAN), size(f32::NAN, f32::NAN)),
1682            dom_id(0),
1683            nid(TARGET),
1684            &lrs,
1685            &mut sm,
1686            opts(
1687                ScrollLogicalPosition::Start,
1688                ScrollLogicalPosition::Start,
1689                ScrollIntoViewBehavior::Instant,
1690            ),
1691            now(),
1692        );
1693        // `NaN.abs() > threshold` is false, so the adjustment is skipped entirely.
1694        assert!(adjustments.is_empty());
1695        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1696        assert!(offset.x.is_finite() && offset.y.is_finite(), "{offset:?}");
1697        assert_eq!((offset.x, offset.y), (0.0, 0.0));
1698    }
1699
1700    #[test]
1701    fn rect_into_view_negative_rect_scrolls_back_and_clamps_at_zero() {
1702        let (lrs, mut sm) = inner_only();
1703        sm.set_scroll_position(dom_id(0), nid(INNER), pos(0.0, 300.0), now());
1704
1705        let adjustments = scroll_rect_into_view(
1706            rect(0.0, -500.0, 10.0, 10.0),
1707            dom_id(0),
1708            nid(TARGET),
1709            &lrs,
1710            &mut sm,
1711            opts(
1712                ScrollLogicalPosition::Start,
1713                ScrollLogicalPosition::Start,
1714                ScrollIntoViewBehavior::Instant,
1715            ),
1716            now(),
1717        );
1718        assert_eq!(adjustments.len(), 1);
1719        // visible rect starts at y = 0 + 300, so the reported delta is unclamped...
1720        assert!(close(adjustments[0].delta.y, -800.0), "{:?}", adjustments[0]);
1721        // ...while the stored offset is clamped into [0, max_scroll].
1722        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1723        assert_eq!(offset.y, 0.0);
1724    }
1725
1726    #[test]
1727    fn rect_into_view_auto_behavior_is_resolved_to_instant() {
1728        let (lrs, mut sm) = inner_only();
1729        let adjustments = scroll_rect_into_view(
1730            rect(0.0, 500.0, 50.0, 20.0),
1731            dom_id(0),
1732            nid(TARGET),
1733            &lrs,
1734            &mut sm,
1735            opts(
1736                ScrollLogicalPosition::Start,
1737                ScrollLogicalPosition::Start,
1738                ScrollIntoViewBehavior::Auto,
1739            ),
1740            now(),
1741        );
1742        assert_eq!(adjustments.len(), 1);
1743        assert_eq!(adjustments[0].behavior, ScrollIntoViewBehavior::Instant);
1744        assert!(!sm.has_active_animations());
1745    }
1746
1747    #[test]
1748    fn rect_into_view_smooth_behavior_animates() {
1749        let (lrs, mut sm) = inner_only();
1750        let adjustments = scroll_rect_into_view(
1751            rect(0.0, 500.0, 50.0, 20.0),
1752            dom_id(0),
1753            nid(TARGET),
1754            &lrs,
1755            &mut sm,
1756            opts(
1757                ScrollLogicalPosition::Start,
1758                ScrollLogicalPosition::Start,
1759                ScrollIntoViewBehavior::Smooth,
1760            ),
1761            now(),
1762        );
1763        assert_eq!(adjustments.len(), 1);
1764        assert_eq!(adjustments[0].behavior, ScrollIntoViewBehavior::Smooth);
1765        assert!(sm.has_active_animations());
1766        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1767        assert_eq!(offset.y, 0.0, "a smooth scroll must not jump");
1768    }
1769
1770    #[test]
1771    fn rect_into_view_walks_the_whole_scroll_chain_innermost_first() {
1772        let lrs = window(layout_result(chain_dom(SCROLL_CSS)));
1773        let mut sm = ScrollManager::new();
1774        // `.inner` sits at absolute y=400 inside `.outer`, which is itself scrolled
1775        // to the top. The target is deep inside `.inner`'s content at y=900.
1776        register(
1777            &mut sm,
1778            INNER,
1779            rect(0.0, 400.0, 100.0, 100.0),
1780            size(100.0, 1000.0),
1781        );
1782        register(
1783            &mut sm,
1784            OUTER,
1785            rect(0.0, 0.0, 200.0, 200.0),
1786            size(200.0, 2000.0),
1787        );
1788
1789        let adjustments = scroll_rect_into_view(
1790            rect(0.0, 900.0, 50.0, 20.0),
1791            dom_id(0),
1792            nid(TARGET),
1793            &lrs,
1794            &mut sm,
1795            opts(
1796                ScrollLogicalPosition::Start,
1797                ScrollLogicalPosition::Start,
1798                ScrollIntoViewBehavior::Instant,
1799            ),
1800            now(),
1801        );
1802
1803        assert_eq!(adjustments.len(), 2);
1804        assert_eq!(adjustments[0].scroll_container_node_id, nid(INNER));
1805        assert_eq!(adjustments[1].scroll_container_node_id, nid(OUTER));
1806        // inner: target 900 - visible 400 => 500
1807        assert!(close(adjustments[0].delta.y, 500.0), "{:?}", adjustments[0]);
1808        // outer: the rect is re-based by the inner scroll (900 - 500 = 400), so the
1809        // outer container only has to scroll the remaining 400.
1810        assert!(close(adjustments[1].delta.y, 400.0), "{:?}", adjustments[1]);
1811
1812        let inner_offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1813        let outer_offset = sm.get_current_offset(dom_id(0), nid(OUTER)).unwrap();
1814        assert!(close(inner_offset.y, 500.0), "{inner_offset:?}");
1815        assert!(close(outer_offset.y, 400.0), "{outer_offset:?}");
1816    }
1817
1818    // ==================================================================
1819    // scroll_node_into_view
1820    // ==================================================================
1821
1822    #[test]
1823    fn node_into_view_missing_layout_results_is_empty() {
1824        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
1825        let mut sm = ScrollManager::new();
1826        assert!(scroll_node_into_view(
1827            dnid(0, TARGET),
1828            &empty,
1829            &mut sm,
1830            ScrollIntoViewOptions::nearest(),
1831            now(),
1832        )
1833        .is_empty());
1834    }
1835
1836    #[test]
1837    fn node_into_view_null_node_is_empty() {
1838        let (lrs, mut sm) = inner_only();
1839        assert!(scroll_node_into_view(
1840            null_dnid(0),
1841            &lrs,
1842            &mut sm,
1843            ScrollIntoViewOptions::nearest(),
1844            now(),
1845        )
1846        .is_empty());
1847    }
1848
1849    #[test]
1850    fn node_into_view_without_a_layout_box_is_empty() {
1851        // `inner_only()` has an empty layout tree, so `get_node_rect` finds nothing.
1852        let (lrs, mut sm) = inner_only();
1853        assert!(scroll_node_into_view(
1854            dnid(0, TARGET),
1855            &lrs,
1856            &mut sm,
1857            ScrollIntoViewOptions::center(),
1858            now(),
1859        )
1860        .is_empty());
1861    }
1862
1863    #[test]
1864    fn node_into_view_scrolls_the_nodes_bounding_rect() {
1865        let lrs = window(layout_result_with_boxes(
1866            chain_dom(SCROLL_CSS),
1867            &[(TARGET, pos(0.0, 500.0), Some(size(50.0, 20.0)))],
1868        ));
1869        let mut sm = ScrollManager::new();
1870        register(
1871            &mut sm,
1872            INNER,
1873            rect(0.0, 0.0, 100.0, 100.0),
1874            size(100.0, 1000.0),
1875        );
1876
1877        let adjustments = scroll_node_into_view(
1878            dnid(0, TARGET),
1879            &lrs,
1880            &mut sm,
1881            opts(
1882                ScrollLogicalPosition::Start,
1883                ScrollLogicalPosition::Start,
1884                ScrollIntoViewBehavior::Instant,
1885            ),
1886            now(),
1887        );
1888        assert_eq!(adjustments.len(), 1);
1889        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1890        assert!(close(offset.y, 500.0), "{offset:?}");
1891    }
1892
1893    #[test]
1894    fn node_into_view_center_alignment_centers_the_node() {
1895        let lrs = window(layout_result_with_boxes(
1896            chain_dom(SCROLL_CSS),
1897            &[(TARGET, pos(0.0, 500.0), Some(size(50.0, 20.0)))],
1898        ));
1899        let mut sm = ScrollManager::new();
1900        register(
1901            &mut sm,
1902            INNER,
1903            rect(0.0, 0.0, 100.0, 100.0),
1904            size(100.0, 1000.0),
1905        );
1906
1907        let adjustments = scroll_node_into_view(
1908            dnid(0, TARGET),
1909            &lrs,
1910            &mut sm,
1911            opts(
1912                ScrollLogicalPosition::Center,
1913                ScrollLogicalPosition::Center,
1914                ScrollIntoViewBehavior::Instant,
1915            ),
1916            now(),
1917        );
1918        assert_eq!(adjustments.len(), 1);
1919        // target center 510, container center 50 => 460
1920        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1921        assert!(close(offset.y, 460.0), "{offset:?}");
1922    }
1923
1924    // ==================================================================
1925    // scroll_cursor_into_view — numeric
1926    // ==================================================================
1927
1928    /// `.target` is a 100×1000 text box filling `.inner`'s content area.
1929    fn cursor_fixture(content: LogicalSize) -> (BTreeMap<DomId, DomLayoutResult>, ScrollManager) {
1930        let lrs = window(layout_result_with_boxes(
1931            chain_dom(SCROLL_CSS),
1932            &[(TARGET, pos(0.0, 0.0), Some(size(100.0, 1000.0)))],
1933        ));
1934        let mut sm = ScrollManager::new();
1935        register(&mut sm, INNER, rect(0.0, 0.0, 100.0, 100.0), content);
1936        (lrs, sm)
1937    }
1938
1939    #[test]
1940    fn cursor_into_view_missing_node_is_empty() {
1941        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
1942        let mut sm = ScrollManager::new();
1943        assert!(scroll_cursor_into_view(
1944            rect(0.0, 800.0, 2.0, 16.0),
1945            dnid(0, TARGET),
1946            &empty,
1947            &mut sm,
1948            ScrollIntoViewOptions::nearest(),
1949            now(),
1950        )
1951        .is_empty());
1952    }
1953
1954    #[test]
1955    fn cursor_into_view_null_node_is_empty() {
1956        let (lrs, mut sm) = cursor_fixture(size(100.0, 1000.0));
1957        assert!(scroll_cursor_into_view(
1958            rect(0.0, 800.0, 2.0, 16.0),
1959            null_dnid(0),
1960            &lrs,
1961            &mut sm,
1962            ScrollIntoViewOptions::nearest(),
1963            now(),
1964        )
1965        .is_empty());
1966    }
1967
1968    #[test]
1969    fn cursor_into_view_zero_rect_maps_to_the_node_origin() {
1970        // The node origin is the container origin, so a zero cursor rect there is
1971        // already visible and nothing scrolls.
1972        let (lrs, mut sm) = cursor_fixture(size(100.0, 1000.0));
1973        let adjustments = scroll_cursor_into_view(
1974            LogicalRect::zero(),
1975            dnid(0, TARGET),
1976            &lrs,
1977            &mut sm,
1978            ScrollIntoViewOptions::nearest(),
1979            now(),
1980        );
1981        assert!(adjustments.is_empty());
1982        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
1983        assert_eq!((offset.x, offset.y), (0.0, 0.0));
1984    }
1985
1986    #[test]
1987    fn cursor_into_view_transforms_local_coordinates_to_absolute() {
1988        let (lrs, mut sm) = cursor_fixture(size(100.0, 1000.0));
1989        // Cursor at node-local (0, 800) => absolute (0, 800).
1990        let adjustments = scroll_cursor_into_view(
1991            rect(0.0, 800.0, 2.0, 16.0),
1992            dnid(0, TARGET),
1993            &lrs,
1994            &mut sm,
1995            opts(
1996                ScrollLogicalPosition::Start,
1997                ScrollLogicalPosition::Start,
1998                ScrollIntoViewBehavior::Instant,
1999            ),
2000            now(),
2001        );
2002        assert_eq!(adjustments.len(), 1);
2003        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
2004        assert!(close(offset.y, 800.0), "{offset:?}");
2005    }
2006
2007    #[test]
2008    fn cursor_into_view_scrolls_back_up_to_a_cursor_above_the_viewport() {
2009        let (lrs, mut sm) = cursor_fixture(size(100.0, 1000.0));
2010        sm.set_scroll_position(dom_id(0), nid(INNER), pos(0.0, 300.0), now());
2011
2012        let adjustments = scroll_cursor_into_view(
2013            rect(0.0, 50.0, 2.0, 16.0),
2014            dnid(0, TARGET),
2015            &lrs,
2016            &mut sm,
2017            opts(
2018                ScrollLogicalPosition::Start,
2019                ScrollLogicalPosition::Start,
2020                ScrollIntoViewBehavior::Instant,
2021            ),
2022            now(),
2023        );
2024        assert_eq!(adjustments.len(), 1);
2025        // visible rect starts at 300, cursor at 50 => delta -250 => offset 50
2026        assert!(close(adjustments[0].delta.y, -250.0), "{:?}", adjustments[0]);
2027        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
2028        assert!(close(offset.y, 50.0), "{offset:?}");
2029    }
2030
2031    #[test]
2032    fn cursor_into_view_nan_cursor_rect_does_not_scroll_or_panic() {
2033        let (lrs, mut sm) = cursor_fixture(size(100.0, 1000.0));
2034        let adjustments = scroll_cursor_into_view(
2035            LogicalRect::new(pos(f32::NAN, f32::NAN), size(2.0, 16.0)),
2036            dnid(0, TARGET),
2037            &lrs,
2038            &mut sm,
2039            opts(
2040                ScrollLogicalPosition::Start,
2041                ScrollLogicalPosition::Start,
2042                ScrollIntoViewBehavior::Instant,
2043            ),
2044            now(),
2045        );
2046        assert!(adjustments.is_empty());
2047        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
2048        assert!(offset.x.is_finite() && offset.y.is_finite(), "{offset:?}");
2049        assert_eq!((offset.x, offset.y), (0.0, 0.0));
2050    }
2051
2052    #[test]
2053    fn cursor_into_view_f32_max_cursor_clamps_to_max_scroll_on_both_axes() {
2054        // 500×1000 of content in a 100×100 container => max scroll (400, 900).
2055        let (lrs, mut sm) = cursor_fixture(size(500.0, 1000.0));
2056        let adjustments = scroll_cursor_into_view(
2057            LogicalRect::new(pos(f32::MAX, f32::MAX), size(2.0, 16.0)),
2058            dnid(0, TARGET),
2059            &lrs,
2060            &mut sm,
2061            opts(
2062                ScrollLogicalPosition::Start,
2063                ScrollLogicalPosition::Start,
2064                ScrollIntoViewBehavior::Instant,
2065            ),
2066            now(),
2067        );
2068        assert_eq!(adjustments.len(), 1);
2069        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
2070        assert!(offset.x.is_finite() && offset.y.is_finite(), "{offset:?}");
2071        assert!(close(offset.x, 400.0) && close(offset.y, 900.0), "{offset:?}");
2072    }
2073
2074    #[test]
2075    fn cursor_into_view_infinite_cursor_rect_does_not_panic() {
2076        let (lrs, mut sm) = cursor_fixture(size(500.0, 1000.0));
2077        let adjustments = scroll_cursor_into_view(
2078            LogicalRect::new(
2079                pos(f32::NEG_INFINITY, f32::INFINITY),
2080                size(f32::INFINITY, f32::INFINITY),
2081            ),
2082            dnid(0, TARGET),
2083            &lrs,
2084            &mut sm,
2085            opts(
2086                ScrollLogicalPosition::Nearest,
2087                ScrollLogicalPosition::Nearest,
2088                ScrollIntoViewBehavior::Instant,
2089            ),
2090            now(),
2091        );
2092        // Whatever it decides, the stored offset must stay inside the bounds.
2093        let _ = adjustments;
2094        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
2095        assert!(offset.x.is_finite() && offset.y.is_finite(), "{offset:?}");
2096        assert!(offset.x >= 0.0 && offset.x <= 400.0, "{offset:?}");
2097        assert!(offset.y >= 0.0 && offset.y <= 900.0, "{offset:?}");
2098    }
2099}