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

            
23
use crate::solver3::layout_tree::LayoutNodeId;
24
use alloc::vec::Vec;
25

            
26
use azul_core::{
27
    dom::{DomId, DomNodeId, NodeId},
28
    geom::{LogicalPosition, LogicalRect, LogicalSize},
29
    task::{Duration, Instant},
30
};
31

            
32
use crate::{
33
    managers::scroll_state::ScrollManager,
34
    solver3::getters::{get_overflow_x, get_overflow_y},
35
    window::DomLayoutResult,
36
};
37

            
38
// Re-export types from core for public API
39
pub use azul_core::events::{ScrollIntoViewBehavior, ScrollIntoViewOptions, ScrollLogicalPosition};
40

            
41
/// Minimum scroll delta (in logical pixels) below which scrolling is skipped
42
const SCROLL_DELTA_THRESHOLD: f32 = 0.5;
43
/// Duration of smooth scroll animations in milliseconds
44
const SMOOTH_SCROLL_DURATION_MS: u64 = 300;
45

            
46
/// Calculated scroll adjustment for one scroll container
47
#[derive(Copy, Debug, Clone)]
48
pub struct ScrollAdjustment {
49
    /// The DOM containing the scroll container
50
    pub scroll_container_dom_id: DomId,
51
    /// The node ID of the scroll container within the DOM
52
    pub scroll_container_node_id: NodeId,
53
    /// The scroll delta to apply
54
    pub delta: LogicalPosition,
55
    /// The scroll behavior to use
56
    pub behavior: ScrollIntoViewBehavior,
57
}
58

            
59
/// Information about a scrollable ancestor
60
#[derive(Debug, Clone)]
61
struct ScrollableAncestor {
62
    dom_id: DomId,
63
    node_id: NodeId,
64
    /// What to ADD to the target rect to express it in this ancestor's dom.
65
    ///
66
    /// Zero within one dom, where everything already shares an absolute space.
67
    /// Non-zero once the walk has crossed out of a `VirtualView`'s nested dom,
68
    /// whose display list is 0-relative and composited at
69
    /// `host.origin + content_offset`.
70
    target_lift: LogicalPosition,
71
    /// The visible rect of the scroll container (content area)
72
    visible_rect: LogicalRect,
73
    /// Whether horizontal scroll is enabled
74
    scroll_x: bool,
75
    /// Whether vertical scroll is enabled
76
    scroll_y: bool,
77
}
78

            
79
/// Resolve a nested dom to the `VirtualView` that hosts it:
80
/// `(host's dom, host node, offset that converts nested geometry to the host's
81
/// space)`.
82
///
83
/// Passed in rather than reaching for `VirtualViewManager` so this module stays
84
/// independent of it — and so a test can hand in a fixture chain.
85
pub type NestedDomHop<'a> = &'a dyn Fn(DomId) -> Option<(DomId, NodeId, LogicalPosition)>;
86

            
87
// ============================================================================
88
// Core API: scroll_rect_into_view
89
// ============================================================================
90

            
91
/// Core function: scroll a rect into the visible area of its scroll containers
92
///
93
/// This is the ONLY scroll-into-view primitive. All higher-level APIs call this.
94
///
95
/// # Arguments
96
///
97
/// * `target_rect` - The rectangle to make visible (in absolute coordinates)
98
/// * `target_dom_id` - The DOM containing the target node
99
/// * `target_node_id` - The target node (used for finding scroll ancestors)
100
/// * `layout_results` - Layout data for all DOMs
101
/// * `scroll_manager` - Current scroll state
102
/// * `options` - How to scroll (alignment and animation)
103
/// * `now` - Current timestamp for animation
104
///
105
/// # Returns
106
///
107
/// A vector of scroll adjustments for each scroll container in the ancestry chain.
108
/// The adjustments are ordered from innermost (closest to target) to outermost.
109
// Instant is a ref-counted FFI clock handle threaded through the event loop by value;
110
// &-converting would cascade through the loop call chain.
111
#[allow(clippy::needless_pass_by_value)]
112
72
pub(crate) fn scroll_rect_into_view(
113
72
    target_rect: LogicalRect,
114
72
    target_dom_id: DomId,
115
72
    target_node_id: NodeId,
116
72
    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
117
72
    scroll_manager: &mut ScrollManager,
118
72
    options: ScrollIntoViewOptions,
119
72
    now: Instant,
120
72
    hop: NestedDomHop<'_>,
121
72
) -> Vec<ScrollAdjustment> {
122
72
    let mut adjustments = Vec::new();
123
    
124
    // Find scrollable ancestors from target to root
125
72
    let scroll_ancestors = find_scrollable_ancestors(
126
72
        target_dom_id,
127
72
        target_node_id,
128
72
        layout_results,
129
72
        scroll_manager,
130
72
        hop,
131
    );
132
    
133
72
    if scroll_ancestors.is_empty() {
134
32
        return adjustments;
135
40
    }
136
    
137
    // Transform target_rect relative to each scroll container and calculate deltas
138
40
    let mut current_rect = target_rect;
139
    
140
90
    for ancestor in scroll_ancestors {
141
        // Express the target in THIS ancestor's dom before comparing. Zero
142
        // within one dom; non-zero once the walk has left a nested one.
143
50
        let mut compare_rect = current_rect;
144
50
        compare_rect.origin.x += ancestor.target_lift.x;
145
50
        compare_rect.origin.y += ancestor.target_lift.y;
146

            
147
        // Calculate the scroll delta based on options
148
50
        let delta = calculate_scroll_delta(
149
50
            compare_rect,
150
50
            ancestor.visible_rect,
151
50
            options.block,
152
50
            options.inline_axis,
153
50
            ancestor.scroll_x,
154
50
            ancestor.scroll_y,
155
        );
156
        
157
        // Only add adjustment if there's actual scrolling to do
158
50
        if delta.x.abs() > SCROLL_DELTA_THRESHOLD || delta.y.abs() > SCROLL_DELTA_THRESHOLD {
159
43
            // Resolve scroll behavior
160
43
            let behavior = resolve_scroll_behavior(
161
43
                options.behavior,
162
43
                ancestor.dom_id,
163
43
                ancestor.node_id,
164
43
                layout_results,
165
43
            );
166
43
            
167
43
            // Apply the scroll adjustment
168
43
            apply_scroll_adjustment(
169
43
                scroll_manager,
170
43
                ancestor.dom_id,
171
43
                ancestor.node_id,
172
43
                delta,
173
43
                behavior,
174
43
                now.clone(),
175
43
            );
176
43
            
177
43
            adjustments.push(ScrollAdjustment {
178
43
                scroll_container_dom_id: ancestor.dom_id,
179
43
                scroll_container_node_id: ancestor.node_id,
180
43
                delta,
181
43
                behavior,
182
43
            });
183
43
            
184
43
            // Adjust current_rect for next iteration (relative to new scroll position)
185
43
            current_rect.origin.x -= delta.x;
186
43
            current_rect.origin.y -= delta.y;
187
43
        }
188
    }
189
    
190
40
    adjustments
191
72
}
192

            
193
// ============================================================================
194
// Higher-Level APIs
195
// ============================================================================
196

            
197
/// Scroll a DOM node's bounding rect into view
198
///
199
/// This is a convenience wrapper around `scroll_rect_into_view` that
200
/// automatically gets the node's bounding rect from layout results.
201
58
pub fn scroll_node_into_view(
202
58
    node_id: DomNodeId,
203
58
    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
204
58
    scroll_manager: &mut ScrollManager,
205
58
    options: ScrollIntoViewOptions,
206
58
    now: Instant,
207
58
    hop: NestedDomHop<'_>,
208
58
) -> Vec<ScrollAdjustment> {
209
    // Get node's bounding rect from layout
210
58
    let Some(target_rect) = get_node_rect(node_id, layout_results) else {
211
4
        return Vec::new();
212
    };
213
    
214
54
    let Some(internal_node_id) = node_id.node.into_crate_internal() else {
215
        return Vec::new();
216
    };
217

            
218
    // Call the core rect-based API
219
54
    scroll_rect_into_view(
220
54
        target_rect,
221
54
        node_id.dom,
222
54
        internal_node_id,
223
54
        layout_results,
224
54
        scroll_manager,
225
54
        options,
226
54
        now,
227
54
        hop,
228
    )
229
58
}
230

            
231
/// Scroll a text cursor position into view
232
///
233
/// Transforms the cursor's visual rect (in node-local coordinates) to absolute
234
/// coordinates before scrolling.
235
8
pub fn scroll_cursor_into_view(
236
8
    cursor_rect: LogicalRect,
237
8
    node_id: DomNodeId,
238
8
    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
239
8
    scroll_manager: &mut ScrollManager,
240
8
    options: ScrollIntoViewOptions,
241
8
    now: Instant,
242
8
    hop: NestedDomHop<'_>,
243
8
) -> Vec<ScrollAdjustment> {
244
    // Get node's position to transform cursor_rect to absolute coordinates
245
8
    let Some(node_rect) = get_node_rect(node_id, layout_results) else {
246
2
        return Vec::new();
247
    };
248
    
249
    // Transform cursor rect to absolute coordinates
250
6
    let absolute_cursor_rect = LogicalRect {
251
6
        origin: LogicalPosition {
252
6
            x: node_rect.origin.x + cursor_rect.origin.x,
253
6
            y: node_rect.origin.y + cursor_rect.origin.y,
254
6
        },
255
6
        size: cursor_rect.size,
256
6
    };
257
    
258
6
    let Some(internal_node_id) = node_id.node.into_crate_internal() else {
259
        return Vec::new();
260
    };
261

            
262
    // Call the core rect-based API
263
6
    scroll_rect_into_view(
264
6
        absolute_cursor_rect,
265
6
        node_id.dom,
266
6
        internal_node_id,
267
6
        layout_results,
268
6
        scroll_manager,
269
6
        options,
270
6
        now,
271
6
        hop,
272
    )
273
8
}
274

            
275
// ============================================================================
276
// Helper Functions
277
// ============================================================================
278

            
279
/// Find all scrollable ancestors from a node to the root
280
///
281
/// Returns ancestors ordered from innermost (closest to target) to outermost (root).
282
79
fn find_scrollable_ancestors(
283
79
    dom_id: DomId,
284
79
    node_id: NodeId,
285
79
    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
286
79
    scroll_manager: &ScrollManager,
287
79
    hop: NestedDomHop<'_>,
288
79
) -> Vec<ScrollableAncestor> {
289
79
    let mut ancestors = Vec::new();
290
79
    let mut current_dom = dom_id;
291
79
    let mut current = Some(node_id);
292
    // What to add to the target rect to express it in `current_dom`.
293
79
    let mut lift = LogicalPosition::zero();
294
79
    let mut crossings = 0usize;
295
79
    let mut entered_by_crossing = false;
296

            
297
    'walk: loop {
298
80
        let Some(layout_result) = layout_results.get(&current_dom) else {
299
2
            return ancestors;
300
        };
301
78
        let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_container();
302

            
303
        // In the target's OWN dom start above it: scrolling a container into
304
        // its own scrollport is not a thing. After a crossing, start AT the
305
        // host — the VirtualView clips the nested content, so it is a genuine
306
        // scroll ancestor of everything inside it.
307
78
        let mut node = if entered_by_crossing {
308
1
            current
309
        } else {
310
77
            current
311
77
                .and_then(|n| node_hierarchy.get(n))
312
77
                .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id)
313
        };
314

            
315
275
        while let Some(current_node_id) = node {
316
197
            if let Some(mut ancestor) = check_if_scrollable(
317
197
                current_dom,
318
197
                current_node_id,
319
197
                layout_result,
320
197
                scroll_manager,
321
197
            ) {
322
54
                ancestor.target_lift = lift;
323
54
                ancestors.push(ancestor);
324
143
            }
325
197
            node = node_hierarchy
326
197
                .get(current_node_id)
327
197
                .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
328
        }
329

            
330
        // Reached this dom's root. A nested dom is a WINDOW inside a
331
        // VirtualView, so its scroll ancestry continues in the host's dom —
332
        // stopping here meant a caret inside a virtualized view could never be
333
        // revealed through the containers that actually clip it.
334
78
        crossings += 1;
335
78
        if crossings > NESTED_DOM_CROSSING_LIMIT {
336
            break 'walk;
337
78
        }
338
78
        match hop(current_dom) {
339
1
            Some((parent_dom, host_node, offset)) => {
340
1
                lift.x += offset.x;
341
1
                lift.y += offset.y;
342
1
                current_dom = parent_dom;
343
1
                current = Some(host_node);
344
1
                entered_by_crossing = true;
345
1
            }
346
77
            None => break 'walk,
347
        }
348
    }
349

            
350
77
    ancestors
351
79
}
352

            
353
/// How many `VirtualView` boundaries [`find_scrollable_ancestors`] will cross.
354
/// Nesting is shallow; the bound only stops a cycle.
355
const NESTED_DOM_CROSSING_LIMIT: usize = 32;
356

            
357
/// Check if a node is scrollable and return its scroll info
358
206
fn check_if_scrollable(
359
206
    dom_id: DomId,
360
206
    node_id: NodeId,
361
206
    layout_result: &DomLayoutResult,
362
206
    scroll_manager: &ScrollManager,
363
206
) -> Option<ScrollableAncestor> {
364
206
    let styled_nodes = layout_result.styled_dom.styled_nodes.as_container();
365
206
    let styled_node = styled_nodes.get(node_id)?;
366
    
367
205
    let overflow_x = get_overflow_x(
368
205
        &layout_result.styled_dom,
369
205
        node_id,
370
205
        &styled_node.styled_node_state,
371
    );
372
205
    let overflow_y = get_overflow_y(
373
205
        &layout_result.styled_dom,
374
205
        node_id,
375
205
        &styled_node.styled_node_state,
376
    );
377
    
378
    // Programmatic scrolling reaches EVERY scroll container - including
379
    // overflow:hidden, whose user scrolling is disabled but which css
380
    // overflow-3 §3.1 keeps programmatically scrollable (scrollIntoView
381
    // through a hidden clipper is the canonical carousel/tab-panel case).
382
205
    let scroll_x = overflow_x.is_scroll_container();
383
205
    let scroll_y = overflow_y.is_scroll_container();
384

            
385
    // If neither axis is scrollable, skip this node
386
205
    if !scroll_x && !scroll_y {
387
120
        return None;
388
85
    }
389
    
390
    // Check if the scroll manager has scroll state for this node
391
    // (which means it actually has overflowing content)
392
85
    let scroll_state = scroll_manager.get_scroll_state(dom_id, node_id)?;
393
    
394
    // Check if content actually overflows (use virtual_scroll_size when set, e.g. for VirtualView)
395
60
    let effective_width = scroll_state.virtual_scroll_size.map_or(scroll_state.content_rect.size.width, |s| s.width);
396
60
    let effective_height = scroll_state.virtual_scroll_size.map_or(scroll_state.content_rect.size.height, |s| s.height);
397
60
    let has_overflow_x = effective_width > scroll_state.container_rect.size.width;
398
60
    let has_overflow_y = effective_height > scroll_state.container_rect.size.height;
399
    
400
60
    if !has_overflow_x && !has_overflow_y {
401
2
        return None;
402
58
    }
403
    
404
    // Get the visible rect (container rect minus current scroll offset)
405
58
    let visible_rect = LogicalRect {
406
58
        origin: LogicalPosition {
407
58
            x: scroll_state.container_rect.origin.x + scroll_state.current_offset.x,
408
58
            y: scroll_state.container_rect.origin.y + scroll_state.current_offset.y,
409
58
        },
410
58
        size: scroll_state.container_rect.size,
411
58
    };
412
    
413
    Some(ScrollableAncestor {
414
58
        dom_id,
415
58
        node_id,
416
        // Filled in by the walk, which is what knows how many VirtualView
417
        // boundaries were crossed to reach this ancestor.
418
58
        target_lift: LogicalPosition::zero(),
419
58
        visible_rect,
420
58
        scroll_x: scroll_x && has_overflow_x,
421
58
        scroll_y: scroll_y && has_overflow_y,
422
    })
423
206
}
424

            
425
/// Calculate the scroll delta needed to bring target into view within container
426
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
427
55
fn calculate_scroll_delta(
428
55
    target: LogicalRect,
429
55
    container: LogicalRect,
430
55
    block: ScrollLogicalPosition,
431
55
    inline: ScrollLogicalPosition,
432
55
    scroll_x_enabled: bool,
433
55
    scroll_y_enabled: bool,
434
55
) -> LogicalPosition {
435
    LogicalPosition {
436
55
        x: if scroll_x_enabled {
437
5
            calculate_axis_delta(
438
5
                target.origin.x,
439
5
                target.size.width,
440
5
                container.origin.x,
441
5
                container.size.width,
442
5
                inline,
443
            )
444
        } else {
445
50
            0.0
446
        },
447
55
        y: if scroll_y_enabled {
448
53
            calculate_axis_delta(
449
53
                target.origin.y,
450
53
                target.size.height,
451
53
                container.origin.y,
452
53
                container.size.height,
453
53
                block,
454
            )
455
        } else {
456
2
            0.0
457
        },
458
    }
459
55
}
460

            
461
/// Calculate scroll delta for a single axis
462
180
#[must_use] pub fn calculate_axis_delta(
463
180
    target_start: f32,
464
180
    target_size: f32,
465
180
    container_start: f32,
466
180
    container_size: f32,
467
180
    position: ScrollLogicalPosition,
468
180
) -> f32 {
469
180
    let target_end = target_start + target_size;
470
180
    let container_end = container_start + container_size;
471
    
472
180
    match position {
473
        ScrollLogicalPosition::Start => {
474
            // Align target start with container start
475
39
            target_start - container_start
476
        }
477
        ScrollLogicalPosition::End => {
478
            // Align target end with container end
479
18
            target_end - container_end
480
        }
481
        ScrollLogicalPosition::Center => {
482
            // Center target in container
483
17
            let target_center = target_start + target_size / 2.0;
484
17
            let container_center = container_start + container_size / 2.0;
485
17
            target_center - container_center
486
        }
487
        ScrollLogicalPosition::Nearest => {
488
            // Minimum scroll to make target fully visible
489
106
            if target_start < container_start {
490
                // Target is above/left of visible area - scroll up/left
491
34
                target_start - container_start
492
72
            } else if target_end > container_end {
493
                // Target is below/right of visible area
494
45
                if target_size <= container_size {
495
                    // Target fits, align end with container end
496
37
                    target_end - container_end
497
                } else {
498
                    // Target doesn't fit, align start with container start
499
8
                    target_start - container_start
500
                }
501
            } else {
502
                // Target is already fully visible
503
27
                0.0
504
            }
505
        }
506
    }
507
180
}
508

            
509
/// Resolve scroll behavior based on options and CSS properties
510
// +spec:containing-block:03528c - scroll-behavior on root element applies to viewport
511
52
const fn resolve_scroll_behavior(
512
52
    requested: ScrollIntoViewBehavior,
513
52
    _dom_id: DomId,
514
52
    _node_id: NodeId,
515
52
    _layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
516
52
) -> ScrollIntoViewBehavior {
517
52
    match requested {
518
        ScrollIntoViewBehavior::Auto => {
519
            // TODO: Check CSS scroll-behavior property on the scroll container
520
            // For now, default to instant
521
31
            ScrollIntoViewBehavior::Instant
522
        }
523
21
        other => other,
524
    }
525
52
}
526

            
527
/// Apply a scroll adjustment to the scroll manager
528
55
fn apply_scroll_adjustment(
529
55
    scroll_manager: &mut ScrollManager,
530
55
    dom_id: DomId,
531
55
    node_id: NodeId,
532
55
    delta: LogicalPosition,
533
55
    behavior: ScrollIntoViewBehavior,
534
55
    now: Instant,
535
55
) {
536
    use azul_core::events::EasingFunction;
537
    use azul_core::task::SystemTimeDiff;
538
    
539
55
    let current = scroll_manager
540
55
        .get_current_offset(dom_id, node_id)
541
55
        .unwrap_or_default();
542
    
543
55
    let new_position = LogicalPosition {
544
55
        x: current.x + delta.x,
545
55
        y: current.y + delta.y,
546
55
    };
547
    
548
55
    match behavior {
549
53
        ScrollIntoViewBehavior::Instant | ScrollIntoViewBehavior::Auto => {
550
53
            scroll_manager.set_scroll_position(dom_id, node_id, new_position, now);
551
53
        }
552
2
        ScrollIntoViewBehavior::Smooth => {
553
2
            // Use smooth scroll with 300ms duration
554
2
            let duration = Duration::System(SystemTimeDiff::from_millis(SMOOTH_SCROLL_DURATION_MS));
555
2
            scroll_manager.scroll_to(
556
2
                dom_id,
557
2
                node_id,
558
2
                new_position,
559
2
                duration,
560
2
                EasingFunction::EaseOut,
561
2
                now,
562
2
            );
563
2
        }
564
    }
565
55
}
566

            
567
/// Get a node's bounding rect from layout results
568
75
fn get_node_rect(
569
75
    node_id: DomNodeId,
570
75
    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
571
75
) -> Option<LogicalRect> {
572
75
    let layout_result = layout_results.get(&node_id.dom)?;
573
70
    let nid = node_id.node.into_crate_internal()?;
574
    
575
    // Get position
576
67
    let layout_indices = layout_result.layout_tree.dom_to_layout.get(&nid)?;
577
64
    let layout_index = *layout_indices.first()?;
578
63
    let position = *layout_result.calculated_positions.get(layout_index.index())?;
579
    
580
    // Get size
581
62
    let layout_node = layout_result.layout_tree.get(layout_index)?;
582
62
    let size = layout_node.used_size?;
583
    
584
61
    Some(LogicalRect::new(position, size))
585
75
}
586

            
587
#[cfg(test)]
588
mod autotest_generated {
589
    /// The fixtures here are single-dom: there is no `VirtualView` boundary to
590
    /// cross, so the walk stops at the root exactly as it always did.
591
    fn no_hop(_: DomId) -> Option<(DomId, NodeId, LogicalPosition)> {
592
        None
593
    }
594

            
595
    use alloc::collections::BTreeMap;
596
    use std::collections::HashMap;
597

            
598
    use azul_core::{
599
        dom::{Dom, FormattingContext, IdOrClass},
600
        styled_dom::{NodeHierarchyItemId, StyledDom},
601
    };
602

            
603
    use super::*;
604
    use crate::solver3::{
605
        display_list::DisplayList,
606
        geometry::PackedBoxProps,
607
        layout_tree::{LayoutNodeHot, LayoutTree},
608
    };
609

            
610
    // ------------------------------------------------------------------
611
    // Fixtures
612
    // ------------------------------------------------------------------
613

            
614
    /// Flat node indices of [`chain_dom`]. The fixture is a *linear* chain, so
615
    /// these indices hold under any tree-flattening order.
616
    const OUTER: usize = 1;
617
    const INNER: usize = 2;
618
    const TARGET: usize = 3;
619
    /// A node index far past the end of the fixture DOM.
620
    const OUT_OF_RANGE: usize = 9999;
621

            
622
    const SCROLL_CSS: &str = ".outer { overflow-x: scroll; overflow-y: scroll; } .inner { \
623
                              overflow-x: scroll; overflow-y: scroll; }";
624
    const X_ONLY_CSS: &str = ".inner { overflow-x: scroll; }";
625
    const NO_CSS: &str = "";
626

            
627
    fn dom_id(inner: usize) -> DomId {
628
        DomId { inner }
629
    }
630

            
631
    fn nid(index: usize) -> NodeId {
632
        NodeId::new(index)
633
    }
634

            
635
    fn dnid(dom: usize, index: usize) -> DomNodeId {
636
        DomNodeId {
637
            dom: dom_id(dom),
638
            node: NodeHierarchyItemId::from_crate_internal(Some(nid(index))),
639
        }
640
    }
641

            
642
    /// A `DomNodeId` whose node slot is the "no node" sentinel.
643
    fn null_dnid(dom: usize) -> DomNodeId {
644
        DomNodeId {
645
            dom: dom_id(dom),
646
            node: NodeHierarchyItemId::NONE,
647
        }
648
    }
649

            
650
    fn pos(x: f32, y: f32) -> LogicalPosition {
651
        LogicalPosition::new(x, y)
652
    }
653

            
654
    fn size(width: f32, height: f32) -> LogicalSize {
655
        LogicalSize::new(width, height)
656
    }
657

            
658
    fn rect(x: f32, y: f32, width: f32, height: f32) -> LogicalRect {
659
        LogicalRect::new(pos(x, y), size(width, height))
660
    }
661

            
662
    fn close(a: f32, b: f32) -> bool {
663
        (a - b).abs() < 1e-3
664
    }
665

            
666
    fn now() -> Instant {
667
        Instant::now()
668
    }
669

            
670
    fn opts(
671
        block: ScrollLogicalPosition,
672
        inline_axis: ScrollLogicalPosition,
673
        behavior: ScrollIntoViewBehavior,
674
    ) -> ScrollIntoViewOptions {
675
        ScrollIntoViewOptions {
676
            block,
677
            inline_axis,
678
            behavior,
679
        }
680
    }
681

            
682
    fn div_with_class(class: &str) -> Dom {
683
        Dom::create_div().with_ids_and_classes(vec![IdOrClass::Class(class.into())].into())
684
    }
685

            
686
    /// `body(0) > .outer(1) > .inner(2) > .target(3)` — a strictly linear chain,
687
    /// styled through a real class stylesheet (not `Dom::with_css`, whose scoped
688
    /// `*` rule would also match the descendants and blur which node is styled).
689
    fn chain_dom(css_str: &str) -> StyledDom {
690
        let mut dom = Dom::create_body().with_child(
691
            div_with_class("outer")
692
                .with_child(div_with_class("inner").with_child(div_with_class("target"))),
693
        );
694
        let (css, _warnings) = azul_css::parser2::new_from_str(css_str);
695
        StyledDom::create(&mut dom, css)
696
    }
697

            
698
    fn empty_layout_tree() -> LayoutTree {
699
        LayoutTree {
700
            nodes: Vec::new(),
701
            warm: Vec::new(),
702
            cold: Vec::new(),
703
            root: 0,
704
            dom_to_layout: BTreeMap::new(),
705
            children_arena: Vec::new(),
706
            children_offsets: Vec::new(),
707
            subtree_needs_intrinsic: Vec::new(),
708
        }
709
    }
710

            
711
    /// A `DomLayoutResult` with an *empty* layout tree. Everything except
712
    /// `get_node_rect` reads only `styled_dom`, so no real layout is needed.
713
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
714
        DomLayoutResult {
715
            styled_dom,
716
            layout_tree: empty_layout_tree(),
717
            calculated_positions: Vec::new(),
718
            viewport: LogicalRect::zero(),
719
            display_list: std::sync::Arc::new(DisplayList::default()),
720
            scroll_ids: HashMap::new(),
721
            scroll_id_to_node_id: HashMap::new(),
722
        }
723
    }
724

            
725
    /// One layout box per entry, in order: DOM node `n` maps to layout index `i`,
726
    /// laid out at `p` with used size `s`.
727
    fn layout_result_with_boxes(
728
        styled_dom: StyledDom,
729
        boxes: &[(usize, LogicalPosition, Option<LogicalSize>)],
730
    ) -> DomLayoutResult {
731
        let mut lr = layout_result(styled_dom);
732
        for (layout_index, (node_index, position, used_size)) in boxes.iter().enumerate() {
733
            lr.layout_tree
734
                .dom_to_layout
735
                .insert(nid(*node_index), vec![LayoutNodeId::new(layout_index)]);
736
            lr.layout_tree.nodes.push(LayoutNodeHot {
737
                box_props: PackedBoxProps::default(),
738
                dom_node_id: Some(nid(*node_index)),
739
                used_size: *used_size,
740
                formatting_context: FormattingContext::Block {
741
                    establishes_new_context: false,
742
                },
743
                parent: None,
744
            });
745
            lr.calculated_positions.push(*position);
746
        }
747
        lr
748
    }
749

            
750
    fn window(lr: DomLayoutResult) -> BTreeMap<DomId, DomLayoutResult> {
751
        let mut map = BTreeMap::new();
752
        map.insert(dom_id(0), lr);
753
        map
754
    }
755

            
756
    fn register(sm: &mut ScrollManager, node: usize, container: LogicalRect, content: LogicalSize) {
757
        sm.register_or_update_scroll_node(
758
            dom_id(0),
759
            nid(node),
760
            container,
761
            content,
762
            now(),
763
            16.0,
764
            8.0,
765
            false,
766
            false,
767
        );
768
    }
769

            
770
    /// `.inner` (node 2) is a 100×100 scroll container at the origin holding
771
    /// 100×1000 of content: vertical overflow only, max scroll y = 900.
772
    /// `.outer` (node 1) is styled scrollable but never registered with the scroll
773
    /// manager, so it is *not* a live scroll container.
774
    fn inner_only() -> (BTreeMap<DomId, DomLayoutResult>, ScrollManager) {
775
        let layout_results = window(layout_result(chain_dom(SCROLL_CSS)));
776
        let mut sm = ScrollManager::new();
777
        register(
778
            &mut sm,
779
            INNER,
780
            rect(0.0, 0.0, 100.0, 100.0),
781
            size(100.0, 1000.0),
782
        );
783
        (layout_results, sm)
784
    }
785

            
786
    // ==================================================================
787
    // calculate_axis_delta — numeric: zero / min_max / negative / nan_inf
788
    // ==================================================================
789

            
790
    #[test]
791
    fn axis_delta_all_zero_inputs_are_zero_for_every_position() {
792
        for position in [
793
            ScrollLogicalPosition::Start,
794
            ScrollLogicalPosition::Center,
795
            ScrollLogicalPosition::End,
796
            ScrollLogicalPosition::Nearest,
797
        ] {
798
            let delta = calculate_axis_delta(0.0, 0.0, 0.0, 0.0, position);
799
            assert!(close(delta, 0.0), "{position:?} on all-zero input");
800
        }
801
    }
802

            
803
    #[test]
804
    fn axis_delta_start_aligns_target_start_with_container_start() {
805
        assert!(close(
806
            calculate_axis_delta(100.0, 50.0, 20.0, 30.0, ScrollLogicalPosition::Start),
807
            80.0
808
        ));
809
    }
810

            
811
    #[test]
812
    fn axis_delta_end_aligns_target_end_with_container_end() {
813
        // target 100..150, container 0..30 => 150 - 30
814
        assert!(close(
815
            calculate_axis_delta(100.0, 50.0, 0.0, 30.0, ScrollLogicalPosition::End),
816
            120.0
817
        ));
818
    }
819

            
820
    #[test]
821
    fn axis_delta_center_aligns_midpoints() {
822
        // target center 120, container center 50
823
        assert!(close(
824
            calculate_axis_delta(100.0, 40.0, 0.0, 100.0, ScrollLogicalPosition::Center),
825
            70.0
826
        ));
827
    }
828

            
829
    #[test]
830
    fn axis_delta_center_of_zero_sized_target_is_offset_of_container_center() {
831
        assert!(close(
832
            calculate_axis_delta(0.0, 0.0, 0.0, 100.0, ScrollLogicalPosition::Center),
833
            -50.0
834
        ));
835
    }
836

            
837
    #[test]
838
    fn axis_delta_nearest_leaves_fully_visible_target_alone() {
839
        // target 10..30 inside container 0..100
840
        assert!(close(
841
            calculate_axis_delta(10.0, 20.0, 0.0, 100.0, ScrollLogicalPosition::Nearest),
842
            0.0
843
        ));
844
    }
845

            
846
    #[test]
847
    fn axis_delta_nearest_scrolls_back_for_target_before_container() {
848
        assert!(close(
849
            calculate_axis_delta(-40.0, 20.0, 0.0, 100.0, ScrollLogicalPosition::Nearest),
850
            -40.0
851
        ));
852
    }
853

            
854
    #[test]
855
    fn axis_delta_nearest_aligns_end_when_target_fits() {
856
        // target 150..170 (size 20) below container 0..100 => end-align
857
        assert!(close(
858
            calculate_axis_delta(150.0, 20.0, 0.0, 100.0, ScrollLogicalPosition::Nearest),
859
            70.0
860
        ));
861
    }
862

            
863
    #[test]
864
    fn axis_delta_nearest_aligns_start_when_target_does_not_fit() {
865
        // target 150..450 (size 300) is bigger than the 100px container => start-align
866
        assert!(close(
867
            calculate_axis_delta(150.0, 300.0, 0.0, 100.0, ScrollLogicalPosition::Nearest),
868
            150.0
869
        ));
870
    }
871

            
872
    #[test]
873
    fn axis_delta_handles_negative_coordinates_deterministically() {
874
        // container -100..-50, target -200..-190
875
        assert!(close(
876
            calculate_axis_delta(-200.0, 10.0, -100.0, 50.0, ScrollLogicalPosition::Start),
877
            -100.0
878
        ));
879
        assert!(close(
880
            calculate_axis_delta(-200.0, 10.0, -100.0, 50.0, ScrollLogicalPosition::End),
881
            -140.0
882
        ));
883
        assert!(close(
884
            calculate_axis_delta(-200.0, 10.0, -100.0, 50.0, ScrollLogicalPosition::Nearest),
885
            -100.0
886
        ));
887
    }
888

            
889
    #[test]
890
    fn axis_delta_nearest_never_scrolls_the_wrong_way() {
891
        let (container_start, container_size) = (100.0f32, 50.0f32);
892
        for target_start in [-1000.0f32, 0.0, 99.0, 100.0, 120.0, 149.0, 150.0, 1000.0] {
893
            for target_size in [0.0f32, 10.0, 50.0, 60.0] {
894
                let delta = calculate_axis_delta(
895
                    target_start,
896
                    target_size,
897
                    container_start,
898
                    container_size,
899
                    ScrollLogicalPosition::Nearest,
900
                );
901
                let start_align = target_start - container_start;
902
                let end_align = (target_start + target_size) - (container_start + container_size);
903
                let fully_visible = target_start >= container_start
904
                    && (target_start + target_size) <= (container_start + container_size);
905

            
906
                // The delta is always one of {0, start-align, end-align} — never an
907
                // arbitrary value, and never an overshoot past both alignments.
908
                assert!(
909
                    close(delta, 0.0) || close(delta, start_align) || close(delta, end_align),
910
                    "delta {delta} for target {target_start}+{target_size}"
911
                );
912
                if fully_visible {
913
                    assert!(
914
                        close(delta, 0.0),
915
                        "visible target {target_start}+{target_size} must not scroll"
916
                    );
917
                }
918
                if target_start < container_start {
919
                    // Target starts before the viewport: only ever scroll backwards.
920
                    assert!(delta <= 0.0, "delta {delta} scrolled the wrong way");
921
                }
922
            }
923
        }
924
    }
925

            
926
    #[test]
927
    fn axis_delta_nan_target_start_does_not_panic() {
928
        // Start/End/Center are pure arithmetic: NaN propagates (a defined f32 result).
929
        for position in [
930
            ScrollLogicalPosition::Start,
931
            ScrollLogicalPosition::End,
932
            ScrollLogicalPosition::Center,
933
        ] {
934
            let delta = calculate_axis_delta(f32::NAN, 10.0, 0.0, 100.0, position);
935
            assert!(delta.is_nan(), "{position:?} should propagate NaN, got {delta}");
936
        }
937
    }
938

            
939
    #[test]
940
    fn axis_delta_nan_target_start_is_zero_for_nearest() {
941
        // Every NaN comparison is false, so `Nearest` falls through to the
942
        // "already fully visible" branch and returns exactly 0.0 — the safe
943
        // choice (no scroll) rather than a NaN leaking into the scroll offset.
944
        let delta = calculate_axis_delta(f32::NAN, 10.0, 0.0, 100.0, ScrollLogicalPosition::Nearest);
945
        assert_eq!(delta, 0.0);
946
    }
947

            
948
    #[test]
949
    fn axis_delta_nan_container_does_not_panic() {
950
        for position in [
951
            ScrollLogicalPosition::Start,
952
            ScrollLogicalPosition::End,
953
            ScrollLogicalPosition::Center,
954
            ScrollLogicalPosition::Nearest,
955
        ] {
956
            let delta = calculate_axis_delta(0.0, 10.0, f32::NAN, f32::NAN, position);
957
            assert!(
958
                delta.is_nan() || delta == 0.0,
959
                "{position:?} gave {delta} for a NaN container"
960
            );
961
        }
962
    }
963

            
964
    #[test]
965
    fn axis_delta_infinite_target_start_saturates_to_infinity() {
966
        let delta =
967
            calculate_axis_delta(f32::INFINITY, 10.0, 0.0, 100.0, ScrollLogicalPosition::Start);
968
        assert!(delta.is_infinite() && delta.is_sign_positive());
969

            
970
        let delta = calculate_axis_delta(
971
            f32::NEG_INFINITY,
972
            10.0,
973
            0.0,
974
            100.0,
975
            ScrollLogicalPosition::Start,
976
        );
977
        assert!(delta.is_infinite() && delta.is_sign_negative());
978
    }
979

            
980
    #[test]
981
    fn axis_delta_infinity_minus_infinity_is_nan_not_a_panic() {
982
        let delta = calculate_axis_delta(
983
            f32::INFINITY,
984
            10.0,
985
            f32::INFINITY,
986
            100.0,
987
            ScrollLogicalPosition::Start,
988
        );
989
        assert!(delta.is_nan());
990
    }
991

            
992
    #[test]
993
    fn axis_delta_infinite_sizes_do_not_panic() {
994
        for position in [
995
            ScrollLogicalPosition::Start,
996
            ScrollLogicalPosition::End,
997
            ScrollLogicalPosition::Center,
998
            ScrollLogicalPosition::Nearest,
999
        ] {
            let delta = calculate_axis_delta(0.0, f32::INFINITY, 0.0, f32::INFINITY, position);
            assert!(
                delta.is_nan() || delta.is_infinite() || delta.is_finite(),
                "{position:?} produced a non-f32 value"
            );
        }
    }
    #[test]
    fn axis_delta_f32_max_overflow_saturates_instead_of_panicking() {
        // target_start + target_size overflows f32 => +inf (IEEE saturation, no panic)
        let delta =
            calculate_axis_delta(f32::MAX, f32::MAX, 0.0, 100.0, ScrollLogicalPosition::End);
        assert!(delta.is_infinite() && delta.is_sign_positive());
        // Nearest sees target_end == +inf > container_end, and the (infinite)
        // target does not fit, so it start-aligns to a finite f32::MAX.
        let delta =
            calculate_axis_delta(f32::MAX, f32::MAX, 0.0, 100.0, ScrollLogicalPosition::Nearest);
        assert_eq!(delta, f32::MAX);
    }
    #[test]
    fn axis_delta_f32_min_does_not_panic() {
        for position in [
            ScrollLogicalPosition::Start,
            ScrollLogicalPosition::End,
            ScrollLogicalPosition::Center,
            ScrollLogicalPosition::Nearest,
        ] {
            let delta = calculate_axis_delta(f32::MIN, 1.0, f32::MAX, 1.0, position);
            assert!(!delta.is_nan(), "{position:?} produced NaN from finite input");
        }
    }
    // ==================================================================
    // calculate_scroll_delta — numeric
    // ==================================================================
    #[test]
    fn scroll_delta_zero_rects_are_zero() {
        let delta = calculate_scroll_delta(
            LogicalRect::zero(),
            LogicalRect::zero(),
            ScrollLogicalPosition::Nearest,
            ScrollLogicalPosition::Nearest,
            true,
            true,
        );
        assert_eq!((delta.x, delta.y), (0.0, 0.0));
    }
    #[test]
    fn scroll_delta_disabled_axes_are_exactly_zero_even_for_nan_and_infinite_rects() {
        let poison = LogicalRect::new(
            pos(f32::NAN, f32::INFINITY),
            size(f32::NAN, f32::NEG_INFINITY),
        );
        let delta = calculate_scroll_delta(
            poison,
            poison,
            ScrollLogicalPosition::Start,
            ScrollLogicalPosition::Start,
            false,
            false,
        );
        // Disabled axes short-circuit to 0.0 before any arithmetic runs, so no
        // NaN can reach the scroll offset.
        assert_eq!((delta.x, delta.y), (0.0, 0.0));
        assert!(delta.x.is_finite() && delta.y.is_finite());
    }
    #[test]
    fn scroll_delta_does_not_swap_the_axes() {
        // x must use `inline` + width, y must use `block` + height.
        let delta = calculate_scroll_delta(
            rect(10.0, 200.0, 5.0, 5.0),
            rect(0.0, 0.0, 100.0, 50.0),
            ScrollLogicalPosition::Start, // block  -> y
            ScrollLogicalPosition::End,   // inline -> x
            true,
            true,
        );
        assert!(close(delta.x, -85.0), "x used the wrong axis/position: {}", delta.x);
        assert!(close(delta.y, 200.0), "y used the wrong axis/position: {}", delta.y);
    }
    #[test]
    fn scroll_delta_only_enabled_axis_moves() {
        let target = rect(500.0, 500.0, 10.0, 10.0);
        let container = rect(0.0, 0.0, 100.0, 100.0);
        let x_only = calculate_scroll_delta(
            target,
            container,
            ScrollLogicalPosition::Start,
            ScrollLogicalPosition::Start,
            true,
            false,
        );
        assert!(close(x_only.x, 500.0));
        assert_eq!(x_only.y, 0.0);
        let y_only = calculate_scroll_delta(
            target,
            container,
            ScrollLogicalPosition::Start,
            ScrollLogicalPosition::Start,
            false,
            true,
        );
        assert_eq!(y_only.x, 0.0);
        assert!(close(y_only.y, 500.0));
    }
    // ==================================================================
    // resolve_scroll_behavior — predicate / invariant
    // ==================================================================
    #[test]
    fn resolve_behavior_maps_auto_to_instant_and_passes_the_rest_through() {
        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
        assert_eq!(
            resolve_scroll_behavior(
                ScrollIntoViewBehavior::Auto,
                dom_id(0),
                nid(TARGET),
                &empty
            ),
            ScrollIntoViewBehavior::Instant
        );
        assert_eq!(
            resolve_scroll_behavior(
                ScrollIntoViewBehavior::Instant,
                dom_id(0),
                nid(TARGET),
                &empty
            ),
            ScrollIntoViewBehavior::Instant
        );
        assert_eq!(
            resolve_scroll_behavior(
                ScrollIntoViewBehavior::Smooth,
                dom_id(OUT_OF_RANGE),
                nid(OUT_OF_RANGE),
                &empty
            ),
            ScrollIntoViewBehavior::Smooth
        );
    }
    #[test]
    fn resolve_behavior_is_idempotent() {
        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
        for behavior in [
            ScrollIntoViewBehavior::Auto,
            ScrollIntoViewBehavior::Instant,
            ScrollIntoViewBehavior::Smooth,
        ] {
            let once = resolve_scroll_behavior(behavior, dom_id(0), nid(0), &empty);
            let twice = resolve_scroll_behavior(once, dom_id(0), nid(0), &empty);
            assert_eq!(once, twice, "resolving {behavior:?} twice changed the result");
        }
    }
    // ==================================================================
    // apply_scroll_adjustment — numeric: zero / min_max / negative / overflow
    // ==================================================================
    /// 100×100 container, 500×500 content => max scroll (400, 400).
    fn registered_manager() -> ScrollManager {
        let mut sm = ScrollManager::new();
        register(
            &mut sm,
            INNER,
            rect(0.0, 0.0, 100.0, 100.0),
            size(500.0, 500.0),
        );
        sm
    }
    #[test]
    fn apply_zero_delta_leaves_the_offset_at_zero() {
        let mut sm = registered_manager();
        apply_scroll_adjustment(
            &mut sm,
            dom_id(0),
            nid(INNER),
            pos(0.0, 0.0),
            ScrollIntoViewBehavior::Instant,
            now(),
        );
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert_eq!((offset.x, offset.y), (0.0, 0.0));
    }
    #[test]
    fn apply_on_an_unregistered_node_creates_a_zero_bounded_state() {
        // No bounds are known for the node, so max scroll is 0 and the delta is
        // clamped away entirely — it must not panic or store an unbounded offset.
        let mut sm = ScrollManager::new();
        apply_scroll_adjustment(
            &mut sm,
            dom_id(0),
            nid(OUT_OF_RANGE),
            pos(1234.0, 5678.0),
            ScrollIntoViewBehavior::Instant,
            now(),
        );
        let offset = sm.get_current_offset(dom_id(0), nid(OUT_OF_RANGE)).unwrap();
        assert_eq!((offset.x, offset.y), (0.0, 0.0));
    }
    #[test]
    fn apply_instant_delta_moves_the_offset() {
        let mut sm = registered_manager();
        apply_scroll_adjustment(
            &mut sm,
            dom_id(0),
            nid(INNER),
            pos(50.0, 60.0),
            ScrollIntoViewBehavior::Instant,
            now(),
        );
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(close(offset.x, 50.0) && close(offset.y, 60.0));
        assert!(!sm.has_active_animations());
    }
    #[test]
    fn apply_negative_delta_clamps_to_zero() {
        let mut sm = registered_manager();
        apply_scroll_adjustment(
            &mut sm,
            dom_id(0),
            nid(INNER),
            pos(-1000.0, -1000.0),
            ScrollIntoViewBehavior::Instant,
            now(),
        );
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert_eq!((offset.x, offset.y), (0.0, 0.0));
    }
    #[test]
    fn apply_f32_max_delta_clamps_to_max_scroll() {
        let mut sm = registered_manager();
        apply_scroll_adjustment(
            &mut sm,
            dom_id(0),
            nid(INNER),
            pos(f32::MAX, f32::MAX),
            ScrollIntoViewBehavior::Instant,
            now(),
        );
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(close(offset.x, 400.0) && close(offset.y, 400.0), "{offset:?}");
    }
    #[test]
    fn apply_infinite_delta_clamps_to_the_scroll_bounds() {
        let mut sm = registered_manager();
        apply_scroll_adjustment(
            &mut sm,
            dom_id(0),
            nid(INNER),
            pos(f32::INFINITY, f32::NEG_INFINITY),
            ScrollIntoViewBehavior::Instant,
            now(),
        );
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(offset.x.is_finite() && offset.y.is_finite(), "{offset:?}");
        assert!(close(offset.x, 400.0) && close(offset.y, 0.0), "{offset:?}");
    }
    #[test]
    fn apply_nan_delta_cannot_poison_the_scroll_offset() {
        let mut sm = registered_manager();
        apply_scroll_adjustment(
            &mut sm,
            dom_id(0),
            nid(INNER),
            pos(f32::NAN, f32::NAN),
            ScrollIntoViewBehavior::Instant,
            now(),
        );
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        // f32::max(NaN, 0.0) == 0.0, so the clamp scrubs the NaN.
        assert!(offset.x.is_finite() && offset.y.is_finite(), "{offset:?}");
        assert_eq!((offset.x, offset.y), (0.0, 0.0));
    }
    #[test]
    fn apply_auto_behaves_like_instant() {
        let mut sm = registered_manager();
        apply_scroll_adjustment(
            &mut sm,
            dom_id(0),
            nid(INNER),
            pos(25.0, 25.0),
            ScrollIntoViewBehavior::Auto,
            now(),
        );
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(close(offset.x, 25.0) && close(offset.y, 25.0));
        assert!(!sm.has_active_animations());
    }
    #[test]
    fn apply_smooth_animates_instead_of_jumping() {
        let mut sm = registered_manager();
        apply_scroll_adjustment(
            &mut sm,
            dom_id(0),
            nid(INNER),
            pos(50.0, 50.0),
            ScrollIntoViewBehavior::Smooth,
            now(),
        );
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert_eq!((offset.x, offset.y), (0.0, 0.0), "smooth must not jump");
        assert!(sm.has_active_animations(), "smooth must arm an animation");
    }
    #[test]
    fn apply_deltas_accumulate_onto_the_current_offset() {
        let mut sm = registered_manager();
        for _ in 0..3 {
            apply_scroll_adjustment(
                &mut sm,
                dom_id(0),
                nid(INNER),
                pos(100.0, 100.0),
                ScrollIntoViewBehavior::Instant,
                now(),
            );
        }
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(close(offset.x, 300.0) && close(offset.y, 300.0), "{offset:?}");
    }
    // ==================================================================
    // get_node_rect — missing / stale / corrupt layout data
    // ==================================================================
    #[test]
    fn get_node_rect_missing_dom_is_none() {
        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
        assert!(get_node_rect(dnid(0, TARGET), &empty).is_none());
    }
    #[test]
    fn get_node_rect_wrong_dom_id_is_none() {
        let lrs = window(layout_result_with_boxes(
            chain_dom(SCROLL_CSS),
            &[(TARGET, pos(10.0, 20.0), Some(size(30.0, 40.0)))],
        ));
        assert!(get_node_rect(dnid(7, TARGET), &lrs).is_none());
    }
    #[test]
    fn get_node_rect_null_node_id_is_none() {
        let lrs = window(layout_result_with_boxes(
            chain_dom(SCROLL_CSS),
            &[(TARGET, pos(10.0, 20.0), Some(size(30.0, 40.0)))],
        ));
        assert!(get_node_rect(null_dnid(0), &lrs).is_none());
    }
    #[test]
    fn get_node_rect_unmapped_node_is_none() {
        let lrs = window(layout_result_with_boxes(
            chain_dom(SCROLL_CSS),
            &[(TARGET, pos(10.0, 20.0), Some(size(30.0, 40.0)))],
        ));
        assert!(get_node_rect(dnid(0, OUTER), &lrs).is_none());
        assert!(get_node_rect(dnid(0, OUT_OF_RANGE), &lrs).is_none());
    }
    #[test]
    fn get_node_rect_empty_layout_index_list_is_none() {
        let mut lr = layout_result_with_boxes(
            chain_dom(SCROLL_CSS),
            &[(TARGET, pos(10.0, 20.0), Some(size(30.0, 40.0)))],
        );
        // A DOM node mapped to *no* layout box (can happen for display:none).
        lr.layout_tree.dom_to_layout.insert(nid(TARGET), Vec::new());
        assert!(get_node_rect(dnid(0, TARGET), &window(lr)).is_none());
    }
    #[test]
    fn get_node_rect_dangling_layout_index_is_none_not_a_panic() {
        let mut lr = layout_result_with_boxes(
            chain_dom(SCROLL_CSS),
            &[(TARGET, pos(10.0, 20.0), Some(size(30.0, 40.0)))],
        );
        // Stale mapping pointing past the end of both `calculated_positions` and
        // `layout_tree.nodes` — must be a None, not an out-of-bounds index panic.
        lr.layout_tree.dom_to_layout.insert(nid(TARGET), vec![LayoutNodeId::new(7)]);
        assert!(get_node_rect(dnid(0, TARGET), &window(lr)).is_none());
    }
    #[test]
    fn get_node_rect_unsized_node_is_none() {
        let lrs = window(layout_result_with_boxes(
            chain_dom(SCROLL_CSS),
            &[(TARGET, pos(10.0, 20.0), None)],
        ));
        assert!(get_node_rect(dnid(0, TARGET), &lrs).is_none());
    }
    #[test]
    fn get_node_rect_returns_position_and_used_size() {
        let lrs = window(layout_result_with_boxes(
            chain_dom(SCROLL_CSS),
            &[(TARGET, pos(10.0, 20.0), Some(size(30.0, 40.0)))],
        ));
        let r = get_node_rect(dnid(0, TARGET), &lrs).expect("target has a layout box");
        assert!(close(r.origin.x, 10.0) && close(r.origin.y, 20.0));
        assert!(close(r.size.width, 30.0) && close(r.size.height, 40.0));
    }
    // ==================================================================
    // check_if_scrollable — predicate invariants
    // ==================================================================
    #[test]
    fn check_if_scrollable_out_of_range_node_is_none() {
        let lr = layout_result(chain_dom(SCROLL_CSS));
        let sm = ScrollManager::new();
        assert!(check_if_scrollable(dom_id(0), nid(OUT_OF_RANGE), &lr, &sm).is_none());
    }
    #[test]
    fn check_if_scrollable_without_overflow_css_is_none() {
        // Registered *and* overflowing, but the CSS says the node does not scroll.
        let lr = layout_result(chain_dom(NO_CSS));
        let mut sm = ScrollManager::new();
        register(
            &mut sm,
            INNER,
            rect(0.0, 0.0, 100.0, 100.0),
            size(500.0, 500.0),
        );
        assert!(check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm).is_none());
    }
    #[test]
    fn check_if_scrollable_without_scroll_state_is_none() {
        // CSS says scrollable, but the scroll manager has never seen the node.
        let lr = layout_result(chain_dom(SCROLL_CSS));
        let sm = ScrollManager::new();
        assert!(check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm).is_none());
    }
    #[test]
    fn check_if_scrollable_with_content_that_fits_is_none() {
        let lr = layout_result(chain_dom(SCROLL_CSS));
        let mut sm = ScrollManager::new();
        register(
            &mut sm,
            INNER,
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 100.0),
        );
        assert!(check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm).is_none());
    }
    #[test]
    fn check_if_scrollable_reports_the_overflowing_axes_and_visible_rect() {
        let lr = layout_result(chain_dom(SCROLL_CSS));
        let mut sm = ScrollManager::new();
        // Container at (5, 7), overflowing vertically only.
        register(
            &mut sm,
            INNER,
            rect(5.0, 7.0, 100.0, 100.0),
            size(100.0, 1000.0),
        );
        sm.set_scroll_position(dom_id(0), nid(INNER), pos(0.0, 300.0), now());
        let ancestor = check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm)
            .expect("an overflowing scroll container");
        assert_eq!(ancestor.node_id, nid(INNER));
        assert_eq!(ancestor.dom_id, dom_id(0));
        assert!(!ancestor.scroll_x, "x does not overflow, so it is not scrollable");
        assert!(ancestor.scroll_y);
        // visible_rect = container origin + current scroll offset, container size.
        assert!(close(ancestor.visible_rect.origin.x, 5.0));
        assert!(close(ancestor.visible_rect.origin.y, 307.0));
        assert!(close(ancestor.visible_rect.size.width, 100.0));
        assert!(close(ancestor.visible_rect.size.height, 100.0));
    }
    #[test]
    fn check_if_scrollable_uses_virtual_scroll_size_over_content_rect() {
        let lr = layout_result(chain_dom(SCROLL_CSS));
        let mut sm = ScrollManager::new();
        // Content fits the container exactly => no overflow from `content_rect`...
        register(
            &mut sm,
            INNER,
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 100.0),
        );
        assert!(check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm).is_none());
        // ...but a VirtualView reports a much larger virtual size, which wins.
        sm.update_virtual_scroll_bounds(dom_id(0), nid(INNER), size(100.0, 10_000.0), None);
        let ancestor = check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm)
            .expect("virtual_scroll_size must drive the overflow check");
        assert!(ancestor.scroll_y);
        assert!(!ancestor.scroll_x);
    }
    #[test]
    fn check_if_scrollable_zero_sized_container_with_content_overflows() {
        let lr = layout_result(chain_dom(SCROLL_CSS));
        let mut sm = ScrollManager::new();
        register(&mut sm, INNER, LogicalRect::zero(), size(1.0, 1.0));
        let ancestor =
            check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm).expect("1px > 0px overflows");
        assert!(ancestor.scroll_x && ancestor.scroll_y);
    }
    #[test]
    fn check_if_scrollable_x_only_css_does_not_enable_the_y_axis() {
        // `.inner` declares only `overflow-x: scroll`. Per CSS Overflow 3 § 3.1 the
        // computed `overflow-y` of such a box becomes `auto` (i.e. scrollable), and
        // `MultiValue::<LayoutOverflow>::resolve_computed` implements exactly that —
        // but `check_if_scrollable` reads the *specified* values, so the y axis stays
        // non-scrollable here even though the content overflows it.
        let lr = layout_result(chain_dom(X_ONLY_CSS));
        let mut sm = ScrollManager::new();
        register(
            &mut sm,
            INNER,
            rect(0.0, 0.0, 100.0, 100.0),
            size(500.0, 500.0),
        );
        let ancestor = check_if_scrollable(dom_id(0), nid(INNER), &lr, &sm)
            .expect("overflow-x: scroll + overflowing content");
        assert!(ancestor.scroll_x);
        assert!(!ancestor.scroll_y);
    }
    // ==================================================================
    // find_scrollable_ancestors
    // ==================================================================
    #[test]
    fn find_ancestors_missing_dom_is_empty() {
        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
        let sm = ScrollManager::new();
        assert!(find_scrollable_ancestors(dom_id(0), nid(TARGET), &empty, &sm, &no_hop).is_empty());
    }
    #[test]
    fn find_ancestors_out_of_range_node_is_empty() {
        let (lrs, sm) = inner_only();
        assert!(find_scrollable_ancestors(dom_id(0), nid(OUT_OF_RANGE), &lrs, &sm, &no_hop).is_empty());
    }
    #[test]
    fn find_ancestors_of_the_root_is_empty() {
        // The root has no parent — the walk must terminate immediately.
        let (lrs, sm) = inner_only();
        assert!(find_scrollable_ancestors(dom_id(0), nid(0), &lrs, &sm, &no_hop).is_empty());
    }
    /// A node inside a `VirtualView`'s nested dom must be revealed through the
    /// containers that clip the HOST, not just the ones in its own dom.
    ///
    /// The walk used to stop at the nested dom's root, so a caret inside a
    /// virtualized view could never be scrolled into view through the outer
    /// page — and the geometry it did find was compared against a target rect
    /// still expressed in the nested dom's 0-relative space.
    #[test]
    fn the_walk_crosses_into_the_host_dom_and_carries_the_lift() {
        let (mut lrs, sm) = inner_only();
        // A second dom standing in for the VirtualView's nested content, hosted
        // by INNER (a live scroll container) in dom 0.
        let nested = dom_id(1);
        let nested_lr = layout_result(chain_dom(SCROLL_CSS));
        lrs.insert(nested, nested_lr);
        let lift = LogicalPosition::new(11.0, 220.0);
        let hop = move |d: DomId| {
            if d == nested {
                Some((dom_id(0), nid(INNER), lift))
            } else {
                None
            }
        };
        let ancestors = find_scrollable_ancestors(nested, nid(TARGET), &lrs, &sm, &hop);
        let crossed = ancestors
            .iter()
            .find(|a| a.dom_id == dom_id(0) && a.node_id == nid(INNER))
            .expect("the host's scroll container must be reached from the nested dom");
        assert_eq!(
            crossed.target_lift, lift,
            "the target must be lifted into the host's space before it is compared"
        );
        // And nothing found INSIDE the nested dom is lifted.
        assert!(
            ancestors
                .iter()
                .filter(|a| a.dom_id == nested)
                .all(|a| a.target_lift == LogicalPosition::zero()),
            "geometry in the target's own dom needs no lift"
        );
    }
    #[test]
    fn find_ancestors_excludes_the_target_itself() {
        // `.inner` is a live scroll container, but scrolling *itself* into view is
        // not the job of its own scrollport: the walk starts at the parent.
        let (lrs, sm) = inner_only();
        let ancestors = find_scrollable_ancestors(dom_id(0), nid(INNER), &lrs, &sm, &no_hop);
        assert!(ancestors.iter().all(|a| a.node_id != nid(INNER)));
    }
    #[test]
    fn find_ancestors_skips_styled_but_non_overflowing_containers() {
        // `.outer` is styled `overflow: scroll` but was never registered.
        let (lrs, sm) = inner_only();
        let ancestors = find_scrollable_ancestors(dom_id(0), nid(TARGET), &lrs, &sm, &no_hop);
        assert_eq!(ancestors.len(), 1);
        assert_eq!(ancestors[0].node_id, nid(INNER));
    }
    #[test]
    fn find_ancestors_orders_innermost_first() {
        let lrs = window(layout_result(chain_dom(SCROLL_CSS)));
        let mut sm = ScrollManager::new();
        register(
            &mut sm,
            INNER,
            rect(0.0, 400.0, 100.0, 100.0),
            size(100.0, 1000.0),
        );
        register(
            &mut sm,
            OUTER,
            rect(0.0, 0.0, 200.0, 200.0),
            size(200.0, 2000.0),
        );
        let ancestors = find_scrollable_ancestors(dom_id(0), nid(TARGET), &lrs, &sm, &no_hop);
        assert_eq!(ancestors.len(), 2);
        assert_eq!(ancestors[0].node_id, nid(INNER), "innermost must come first");
        assert_eq!(ancestors[1].node_id, nid(OUTER));
    }
    // ==================================================================
    // scroll_rect_into_view — the core primitive
    // ==================================================================
    #[test]
    fn rect_into_view_without_scroll_containers_is_a_no_op() {
        let lrs = window(layout_result(chain_dom(SCROLL_CSS)));
        let mut sm = ScrollManager::new();
        let adjustments = scroll_rect_into_view(
            rect(0.0, 5000.0, 10.0, 10.0),
            dom_id(0),
            nid(TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        assert!(adjustments.is_empty());
        assert!(sm.get_current_offset(dom_id(0), nid(INNER)).is_none());
    }
    #[test]
    fn rect_into_view_missing_dom_is_a_no_op() {
        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
        let mut sm = ScrollManager::new();
        let adjustments = scroll_rect_into_view(
            rect(0.0, 5000.0, 10.0, 10.0),
            dom_id(0),
            nid(TARGET),
            &empty,
            &mut sm,
            ScrollIntoViewOptions::nearest(),
            now(),
            &no_hop,
        );
        assert!(adjustments.is_empty());
    }
    #[test]
    fn rect_into_view_already_visible_target_does_not_scroll() {
        let (lrs, mut sm) = inner_only();
        let adjustments = scroll_rect_into_view(
            rect(0.0, 10.0, 50.0, 20.0),
            dom_id(0),
            nid(TARGET),
            &lrs,
            &mut sm,
            ScrollIntoViewOptions::nearest(),
            now(),
            &no_hop,
        );
        assert!(adjustments.is_empty());
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert_eq!((offset.x, offset.y), (0.0, 0.0));
    }
    #[test]
    fn rect_into_view_scrolls_and_reports_the_delta() {
        let (lrs, mut sm) = inner_only();
        let adjustments = scroll_rect_into_view(
            rect(0.0, 500.0, 50.0, 20.0),
            dom_id(0),
            nid(TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        assert_eq!(adjustments.len(), 1);
        assert_eq!(adjustments[0].scroll_container_node_id, nid(INNER));
        assert!(close(adjustments[0].delta.y, 500.0));
        assert_eq!(adjustments[0].delta.x, 0.0, "x does not overflow");
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(close(offset.y, 500.0), "{offset:?}");
    }
    #[test]
    fn rect_into_view_ignores_a_delta_at_exactly_the_threshold() {
        // The guard is `abs() > SCROLL_DELTA_THRESHOLD`, so a delta of exactly
        // 0.5px is *not* applied. 0.5 and 0.75 are both exact in binary f32.
        assert!(close(SCROLL_DELTA_THRESHOLD, 0.5));
        let (lrs, mut sm) = inner_only();
        let at_threshold = scroll_rect_into_view(
            rect(0.0, 0.5, 50.0, 20.0),
            dom_id(0),
            nid(TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        assert!(at_threshold.is_empty(), "0.5px must be below the threshold");
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert_eq!(offset.y, 0.0);
        let above_threshold = scroll_rect_into_view(
            rect(0.0, 0.75, 50.0, 20.0),
            dom_id(0),
            nid(TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        assert_eq!(above_threshold.len(), 1, "0.75px must scroll");
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(close(offset.y, 0.75), "{offset:?}");
    }
    #[test]
    fn rect_into_view_f32_max_rect_clamps_to_max_scroll() {
        let (lrs, mut sm) = inner_only();
        let adjustments = scroll_rect_into_view(
            rect(f32::MAX, f32::MAX, f32::MAX, f32::MAX),
            dom_id(0),
            nid(TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        assert_eq!(adjustments.len(), 1);
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(offset.x.is_finite() && offset.y.is_finite(), "{offset:?}");
        // max scroll y = content 1000 - container 100
        assert!(close(offset.y, 900.0), "{offset:?}");
    }
    #[test]
    fn rect_into_view_nan_rect_does_not_scroll_or_poison_the_offset() {
        let (lrs, mut sm) = inner_only();
        let adjustments = scroll_rect_into_view(
            LogicalRect::new(pos(f32::NAN, f32::NAN), size(f32::NAN, f32::NAN)),
            dom_id(0),
            nid(TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        // `NaN.abs() > threshold` is false, so the adjustment is skipped entirely.
        assert!(adjustments.is_empty());
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(offset.x.is_finite() && offset.y.is_finite(), "{offset:?}");
        assert_eq!((offset.x, offset.y), (0.0, 0.0));
    }
    #[test]
    fn rect_into_view_negative_rect_scrolls_back_and_clamps_at_zero() {
        let (lrs, mut sm) = inner_only();
        sm.set_scroll_position(dom_id(0), nid(INNER), pos(0.0, 300.0), now());
        let adjustments = scroll_rect_into_view(
            rect(0.0, -500.0, 10.0, 10.0),
            dom_id(0),
            nid(TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        assert_eq!(adjustments.len(), 1);
        // visible rect starts at y = 0 + 300, so the reported delta is unclamped...
        assert!(close(adjustments[0].delta.y, -800.0), "{:?}", adjustments[0]);
        // ...while the stored offset is clamped into [0, max_scroll].
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert_eq!(offset.y, 0.0);
    }
    #[test]
    fn rect_into_view_auto_behavior_is_resolved_to_instant() {
        let (lrs, mut sm) = inner_only();
        let adjustments = scroll_rect_into_view(
            rect(0.0, 500.0, 50.0, 20.0),
            dom_id(0),
            nid(TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Auto,
            ),
            now(),
            &no_hop,
        );
        assert_eq!(adjustments.len(), 1);
        assert_eq!(adjustments[0].behavior, ScrollIntoViewBehavior::Instant);
        assert!(!sm.has_active_animations());
    }
    #[test]
    fn rect_into_view_smooth_behavior_animates() {
        let (lrs, mut sm) = inner_only();
        let adjustments = scroll_rect_into_view(
            rect(0.0, 500.0, 50.0, 20.0),
            dom_id(0),
            nid(TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Smooth,
            ),
            now(),
            &no_hop,
        );
        assert_eq!(adjustments.len(), 1);
        assert_eq!(adjustments[0].behavior, ScrollIntoViewBehavior::Smooth);
        assert!(sm.has_active_animations());
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert_eq!(offset.y, 0.0, "a smooth scroll must not jump");
    }
    #[test]
    fn rect_into_view_walks_the_whole_scroll_chain_innermost_first() {
        let lrs = window(layout_result(chain_dom(SCROLL_CSS)));
        let mut sm = ScrollManager::new();
        // `.inner` sits at absolute y=400 inside `.outer`, which is itself scrolled
        // to the top. The target is deep inside `.inner`'s content at y=900.
        register(
            &mut sm,
            INNER,
            rect(0.0, 400.0, 100.0, 100.0),
            size(100.0, 1000.0),
        );
        register(
            &mut sm,
            OUTER,
            rect(0.0, 0.0, 200.0, 200.0),
            size(200.0, 2000.0),
        );
        let adjustments = scroll_rect_into_view(
            rect(0.0, 900.0, 50.0, 20.0),
            dom_id(0),
            nid(TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        assert_eq!(adjustments.len(), 2);
        assert_eq!(adjustments[0].scroll_container_node_id, nid(INNER));
        assert_eq!(adjustments[1].scroll_container_node_id, nid(OUTER));
        // inner: target 900 - visible 400 => 500
        assert!(close(adjustments[0].delta.y, 500.0), "{:?}", adjustments[0]);
        // outer: the rect is re-based by the inner scroll (900 - 500 = 400), so the
        // outer container only has to scroll the remaining 400.
        assert!(close(adjustments[1].delta.y, 400.0), "{:?}", adjustments[1]);
        let inner_offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        let outer_offset = sm.get_current_offset(dom_id(0), nid(OUTER)).unwrap();
        assert!(close(inner_offset.y, 500.0), "{inner_offset:?}");
        assert!(close(outer_offset.y, 400.0), "{outer_offset:?}");
    }
    // ==================================================================
    // scroll_node_into_view
    // ==================================================================
    #[test]
    fn node_into_view_missing_layout_results_is_empty() {
        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
        let mut sm = ScrollManager::new();
        assert!(scroll_node_into_view(
            dnid(0, TARGET),
            &empty,
            &mut sm,
            ScrollIntoViewOptions::nearest(),
            now(),
            &no_hop,
        )
        .is_empty());
    }
    #[test]
    fn node_into_view_null_node_is_empty() {
        let (lrs, mut sm) = inner_only();
        assert!(scroll_node_into_view(
            null_dnid(0),
            &lrs,
            &mut sm,
            ScrollIntoViewOptions::nearest(),
            now(),
            &no_hop,
        )
        .is_empty());
    }
    #[test]
    fn node_into_view_without_a_layout_box_is_empty() {
        // `inner_only()` has an empty layout tree, so `get_node_rect` finds nothing.
        let (lrs, mut sm) = inner_only();
        assert!(scroll_node_into_view(
            dnid(0, TARGET),
            &lrs,
            &mut sm,
            ScrollIntoViewOptions::center(),
            now(),
            &no_hop,
        )
        .is_empty());
    }
    #[test]
    fn node_into_view_scrolls_the_nodes_bounding_rect() {
        let lrs = window(layout_result_with_boxes(
            chain_dom(SCROLL_CSS),
            &[(TARGET, pos(0.0, 500.0), Some(size(50.0, 20.0)))],
        ));
        let mut sm = ScrollManager::new();
        register(
            &mut sm,
            INNER,
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 1000.0),
        );
        let adjustments = scroll_node_into_view(
            dnid(0, TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        assert_eq!(adjustments.len(), 1);
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(close(offset.y, 500.0), "{offset:?}");
    }
    #[test]
    fn node_into_view_center_alignment_centers_the_node() {
        let lrs = window(layout_result_with_boxes(
            chain_dom(SCROLL_CSS),
            &[(TARGET, pos(0.0, 500.0), Some(size(50.0, 20.0)))],
        ));
        let mut sm = ScrollManager::new();
        register(
            &mut sm,
            INNER,
            rect(0.0, 0.0, 100.0, 100.0),
            size(100.0, 1000.0),
        );
        let adjustments = scroll_node_into_view(
            dnid(0, TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Center,
                ScrollLogicalPosition::Center,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        assert_eq!(adjustments.len(), 1);
        // target center 510, container center 50 => 460
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(close(offset.y, 460.0), "{offset:?}");
    }
    // ==================================================================
    // scroll_cursor_into_view — numeric
    // ==================================================================
    /// `.target` is a 100×1000 text box filling `.inner`'s content area.
    fn cursor_fixture(content: LogicalSize) -> (BTreeMap<DomId, DomLayoutResult>, ScrollManager) {
        let lrs = window(layout_result_with_boxes(
            chain_dom(SCROLL_CSS),
            &[(TARGET, pos(0.0, 0.0), Some(size(100.0, 1000.0)))],
        ));
        let mut sm = ScrollManager::new();
        register(&mut sm, INNER, rect(0.0, 0.0, 100.0, 100.0), content);
        (lrs, sm)
    }
    #[test]
    fn cursor_into_view_missing_node_is_empty() {
        let empty: BTreeMap<DomId, DomLayoutResult> = BTreeMap::new();
        let mut sm = ScrollManager::new();
        assert!(scroll_cursor_into_view(
            rect(0.0, 800.0, 2.0, 16.0),
            dnid(0, TARGET),
            &empty,
            &mut sm,
            ScrollIntoViewOptions::nearest(),
            now(),
            &no_hop,
        )
        .is_empty());
    }
    #[test]
    fn cursor_into_view_null_node_is_empty() {
        let (lrs, mut sm) = cursor_fixture(size(100.0, 1000.0));
        assert!(scroll_cursor_into_view(
            rect(0.0, 800.0, 2.0, 16.0),
            null_dnid(0),
            &lrs,
            &mut sm,
            ScrollIntoViewOptions::nearest(),
            now(),
            &no_hop,
        )
        .is_empty());
    }
    #[test]
    fn cursor_into_view_zero_rect_maps_to_the_node_origin() {
        // The node origin is the container origin, so a zero cursor rect there is
        // already visible and nothing scrolls.
        let (lrs, mut sm) = cursor_fixture(size(100.0, 1000.0));
        let adjustments = scroll_cursor_into_view(
            LogicalRect::zero(),
            dnid(0, TARGET),
            &lrs,
            &mut sm,
            ScrollIntoViewOptions::nearest(),
            now(),
            &no_hop,
        );
        assert!(adjustments.is_empty());
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert_eq!((offset.x, offset.y), (0.0, 0.0));
    }
    #[test]
    fn cursor_into_view_transforms_local_coordinates_to_absolute() {
        let (lrs, mut sm) = cursor_fixture(size(100.0, 1000.0));
        // Cursor at node-local (0, 800) => absolute (0, 800).
        let adjustments = scroll_cursor_into_view(
            rect(0.0, 800.0, 2.0, 16.0),
            dnid(0, TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        assert_eq!(adjustments.len(), 1);
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(close(offset.y, 800.0), "{offset:?}");
    }
    #[test]
    fn cursor_into_view_scrolls_back_up_to_a_cursor_above_the_viewport() {
        let (lrs, mut sm) = cursor_fixture(size(100.0, 1000.0));
        sm.set_scroll_position(dom_id(0), nid(INNER), pos(0.0, 300.0), now());
        let adjustments = scroll_cursor_into_view(
            rect(0.0, 50.0, 2.0, 16.0),
            dnid(0, TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        assert_eq!(adjustments.len(), 1);
        // visible rect starts at 300, cursor at 50 => delta -250 => offset 50
        assert!(close(adjustments[0].delta.y, -250.0), "{:?}", adjustments[0]);
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(close(offset.y, 50.0), "{offset:?}");
    }
    #[test]
    fn cursor_into_view_nan_cursor_rect_does_not_scroll_or_panic() {
        let (lrs, mut sm) = cursor_fixture(size(100.0, 1000.0));
        let adjustments = scroll_cursor_into_view(
            LogicalRect::new(pos(f32::NAN, f32::NAN), size(2.0, 16.0)),
            dnid(0, TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        assert!(adjustments.is_empty());
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(offset.x.is_finite() && offset.y.is_finite(), "{offset:?}");
        assert_eq!((offset.x, offset.y), (0.0, 0.0));
    }
    #[test]
    fn cursor_into_view_f32_max_cursor_clamps_to_max_scroll_on_both_axes() {
        // 500×1000 of content in a 100×100 container => max scroll (400, 900).
        let (lrs, mut sm) = cursor_fixture(size(500.0, 1000.0));
        let adjustments = scroll_cursor_into_view(
            LogicalRect::new(pos(f32::MAX, f32::MAX), size(2.0, 16.0)),
            dnid(0, TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Start,
                ScrollLogicalPosition::Start,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        assert_eq!(adjustments.len(), 1);
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(offset.x.is_finite() && offset.y.is_finite(), "{offset:?}");
        assert!(close(offset.x, 400.0) && close(offset.y, 900.0), "{offset:?}");
    }
    #[test]
    fn cursor_into_view_infinite_cursor_rect_does_not_panic() {
        let (lrs, mut sm) = cursor_fixture(size(500.0, 1000.0));
        let adjustments = scroll_cursor_into_view(
            LogicalRect::new(
                pos(f32::NEG_INFINITY, f32::INFINITY),
                size(f32::INFINITY, f32::INFINITY),
            ),
            dnid(0, TARGET),
            &lrs,
            &mut sm,
            opts(
                ScrollLogicalPosition::Nearest,
                ScrollLogicalPosition::Nearest,
                ScrollIntoViewBehavior::Instant,
            ),
            now(),
            &no_hop,
        );
        // Whatever it decides, the stored offset must stay inside the bounds.
        let _ = adjustments;
        let offset = sm.get_current_offset(dom_id(0), nid(INNER)).unwrap();
        assert!(offset.x.is_finite() && offset.y.is_finite(), "{offset:?}");
        assert!(offset.x >= 0.0 && offset.x <= 400.0, "{offset:?}");
        assert!(offset.y >= 0.0 && offset.y <= 900.0, "{offset:?}");
    }
}