Skip to main content

azul_layout/
headless.rs

1//! Headless backend for CPU-only rendering without a display server.
2//!
3//! This module provides the resource management and rendering pipeline for
4//! running Azul applications without any platform windowing APIs. It works
5//! in combination with `HeadlessWindow` (in `dll/src/desktop/shell2/headless/`) which
6//! provides the `PlatformWindow` trait implementation.
7//!
8//! # Architecture
9//!
10//! The headless path replaces the WebRender GPU pipeline with `cpurender`:
11//! `LayoutWindow → solver3 DisplayList → cpurender → PNG/Pixmap`. Compared to the
12//! GPU path there is no GL context, `webrender::Renderer`, or `RenderApi`; fonts
13//! and images are managed by `FontManager`/`ImageCache` and read directly by
14//! cpurender (no GPU texture atlas or upload), hit testing uses the layout-side
15//! `CpuHitTester` instead of WebRender's `AsyncHitTester`, and present/swap is a
16//! no-op.
17//!
18//! Activated with `AZUL_HEADLESS=1` (optionally `AZ_DEBUG=1` for the debug server).
19
20use std::collections::BTreeMap;
21
22use azul_core::{
23    dom::{DomId, NodeId},
24    geom::{LogicalPosition, LogicalRect, LogicalSize},
25    styled_dom::StyledDom,
26};
27
28use crate::solver3::{getters::{get_overflow_x, get_overflow_y}, layout_tree::LayoutNodeHot, PositionVec};
29
30/// Large finite half-extent used in place of `f32::INFINITY` for clip axes that
31/// are not constrained by any ancestor. Keeping it finite avoids `NaN` in
32/// `point_in_rect` (`origin + size` would be `inf - inf = NaN`) while staying
33/// far outside any realistic logical-pixel coordinate.
34const CLIP_UNBOUNDED: f32 = 1.0e7;
35
36/// CPU-based hit tester that works without `WebRender`.
37///
38/// In the GPU path, hit testing is done by `AsyncHitTester` which queries
39/// `WebRender`'s spatial tree. In headless mode, we do hit testing directly
40/// against the layout results (positioned rectangles).
41///
42/// This is actually simpler and faster than the `WebRender` path, since we
43/// don't need to go through the compositor's spatial tree — we just walk
44/// the layout result nodes and check point-in-rect.
45#[derive(Debug)]
46pub struct CpuHitTester {
47    /// Cached hit test results from the last layout.
48    /// Maps `DomId` -> list of (`NodeId`, positioned rect) sorted by paint order.
49    node_rects: BTreeMap<DomId, Vec<HitTestEntry>>,
50}
51
52/// A single entry in the CPU hit test acceleration structure.
53#[derive(Debug, Clone)]
54struct HitTestEntry {
55    /// The DOM node that this entry corresponds to.
56    node_id: NodeId,
57    /// Absolute position and size of this node in logical pixels.
58    rect: LogicalRect,
59    /// Clip rect (intersection of all ancestor overflow clips).
60    clip: Option<LogicalRect>,
61    /// Whether this node is pointer-events: none
62    pointer_events_none: bool,
63}
64
65impl Default for CpuHitTester {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl CpuHitTester {
72    /// Create a new empty hit tester.
73    #[must_use] pub const fn new() -> Self {
74        Self {
75            node_rects: BTreeMap::new(),
76        }
77    }
78
79    /// Sum of `HitTestEntry` counts across all `DomIds` (for leak probes).
80    #[must_use] pub fn node_rects_total(&self) -> usize {
81        self.node_rects.values().map(Vec::len).sum()
82    }
83
84    /// Rebuild the hit test structure from layout results.
85    ///
86    /// Called after each layout pass. Extracts positioned rectangles from
87    /// `LayoutWindow::layout_results` and builds a flat list for fast
88    /// point-in-rect testing.
89    pub fn rebuild_from_layout(
90        &mut self,
91        layout_results: &BTreeMap<DomId, crate::window::DomLayoutResult>,
92    ) {
93        self.node_rects.clear();
94
95        // VirtualView / iframe child DOMs lay out in CHILD-LOCAL coordinates
96        // (origin 0,0) but live on screen at the host VirtualView item's
97        // bounds. Hit entries must be TRANSLATED there and CLIPPED to the
98        // composite bounds — otherwise the child's nodes claim pointer events
99        // across the whole window (live bug: azul-maps' tile grid ate every
100        // click on the header toolbar, so the buttons never fired; the same
101        // escape the renderer had before intersect_clips()).
102        //
103        // Resolve placements iteratively so nested VirtualViews accumulate
104        // their host offsets (a child's own VirtualView item is in that
105        // child's local space).
106        let mut placements: BTreeMap<DomId, LogicalRect> = BTreeMap::new();
107        for _ in 0..4 {
108            // bounded depth; each pass resolves one nesting level
109            let mut changed = false;
110            for (host_dom, lr) in layout_results {
111                let host_offset = if host_dom.inner == 0 {
112                    Some(LogicalPosition::zero())
113                } else {
114                    placements.get(host_dom).map(|r| r.origin)
115                };
116                let Some(host_offset) = host_offset else { continue };
117                for item in &lr.display_list.items {
118                    if let crate::solver3::display_list::DisplayListItem::VirtualView {
119                        child_dom_id,
120                        bounds,
121                        ..
122                    } = item
123                    {
124                        let b = *bounds.inner();
125                        let absolute = LogicalRect {
126                            origin: LogicalPosition {
127                                x: b.origin.x + host_offset.x,
128                                y: b.origin.y + host_offset.y,
129                            },
130                            size: b.size,
131                        };
132                        if placements.get(child_dom_id) != Some(&absolute) {
133                            placements.insert(*child_dom_id, absolute);
134                            changed = true;
135                        }
136                    }
137                }
138            }
139            if !changed {
140                break;
141            }
142        }
143
144        for (dom_id, layout_result) in layout_results {
145            let mut entries = Vec::new();
146
147            let positions = &layout_result.calculated_positions;
148            let nodes = &layout_result.layout_tree.nodes;
149            let styled_dom = &layout_result.styled_dom;
150
151            // Child DOM: shift into window space + clip to the composite rect.
152            let (offset, dom_clip) = placements.get(dom_id).map_or_else(|| (LogicalPosition::zero(), None), |b| (b.origin, Some(*b)));
153
154            // Walk the layout nodes and their computed positions
155            for (idx, node) in nodes.iter().enumerate() {
156                // Only include nodes that map to a real DOM node
157                let Some(node_id) = node.dom_node_id else {
158                    continue; // skip anonymous boxes
159                };
160
161                // Get the position for this layout node
162                let pos = match positions.get(idx) {
163                    Some(p) => *p,
164                    None => continue,
165                };
166
167                // Get the computed size
168                let Some(size) = node.used_size else {
169                    continue;
170                };
171
172                let rect = LogicalRect {
173                    origin: LogicalPosition {
174                        x: pos.x + offset.x,
175                        y: pos.y + offset.y,
176                    },
177                    size,
178                };
179
180                // Clip this node to the intersection of the VirtualView composite
181                // bounds (`dom_clip`) and every `overflow: hidden | clip | scroll |
182                // auto` ancestor's box — otherwise a node that is scrolled/clipped
183                // out of its ancestor would still claim pointer events.
184                let clip = compute_node_clip(styled_dom, nodes, positions, idx, offset, dom_clip);
185
186                entries.push(HitTestEntry {
187                    node_id,
188                    rect,
189                    clip,
190                    // azul has no `pointer-events` CSS property yet, so every laid-out
191                    // node is hit-testable. Populate this from the styled DOM once such
192                    // a property is added to `azul_css`.
193                    pointer_events_none: false,
194                });
195            }
196
197            self.node_rects.insert(*dom_id, entries);
198        }
199    }
200
201    /// Perform a hit test at the given position.
202    ///
203    /// Returns nodes hit at (x, y) in reverse paint order (topmost first).
204    #[must_use] pub fn hit_test(
205        &self,
206        position: LogicalPosition,
207    ) -> Vec<(DomId, NodeId)> {
208        let mut results = Vec::new();
209
210        for (dom_id, entries) in &self.node_rects {
211            // Walk in reverse (last painted = topmost)
212            for entry in entries.iter().rev() {
213                if entry.pointer_events_none {
214                    continue;
215                }
216
217                // Check clip rect first (if any)
218                if let Some(ref clip) = entry.clip {
219                    if !point_in_rect(position, clip) {
220                        continue;
221                    }
222                }
223
224                // Check node rect
225                if point_in_rect(position, &entry.rect) {
226                    results.push((*dom_id, entry.node_id));
227                }
228            }
229        }
230
231        results
232    }
233}
234
235/// Simple point-in-rect test.
236fn point_in_rect(point: LogicalPosition, rect: &LogicalRect) -> bool {
237    point.x >= rect.origin.x
238        && point.x < rect.origin.x + rect.size.width
239        && point.y >= rect.origin.y
240        && point.y < rect.origin.y + rect.size.height
241}
242
243/// Compute the hit-test clip rect for a layout node: the intersection of the
244/// host `VirtualView` composite bounds (`dom_clip`) and every clipping ancestor's
245/// border box (any `overflow` other than `visible`).
246///
247/// Clipping is tracked per-axis because `overflow-x` / `overflow-y` are
248/// independent — an axis whose ancestors are all `overflow: visible` stays
249/// unbounded (stored as a large finite extent, see [`CLIP_UNBOUNDED`]). The
250/// ancestor box used is the border box (`used_size`); CSS clips at the padding
251/// edge, but the slightly larger border box is a safe over-inclusion for point
252/// hit-testing and avoids resolving padding/border here.
253#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
254fn compute_node_clip(
255    styled_dom: &StyledDom,
256    nodes: &[LayoutNodeHot],
257    positions: &PositionVec,
258    node_index: usize,
259    offset: LogicalPosition,
260    dom_clip: Option<LogicalRect>,
261) -> Option<LogicalRect> {
262    // Accumulate clip bounds per axis, seeded from the DOM-level composite clip.
263    let (mut min_x, mut min_y, mut max_x, mut max_y) = (
264        f32::NEG_INFINITY,
265        f32::NEG_INFINITY,
266        f32::INFINITY,
267        f32::INFINITY,
268    );
269    let mut has_clip = false;
270    if let Some(dc) = dom_clip {
271        min_x = dc.min_x();
272        min_y = dc.min_y();
273        max_x = dc.max_x();
274        max_y = dc.max_y();
275        has_clip = true;
276    }
277
278    // Walk ancestors. A node's own overflow clips its descendants, not itself, so
279    // we start at the parent. `guard` bounds the loop in case `parent` links ever
280    // form a cycle (they shouldn't, but a hit-test rebuild must never hang).
281    let styled_nodes = styled_dom.styled_nodes.as_container();
282    let mut cur = nodes.get(node_index).and_then(|n| n.parent);
283    let mut guard = 0usize;
284    while let Some(anc) = cur {
285        guard += 1;
286        if guard > nodes.len() {
287            break;
288        }
289        let Some(anc_node) = nodes.get(anc) else { break };
290        cur = anc_node.parent;
291
292        let Some(anc_dom_id) = anc_node.dom_node_id else {
293            continue;
294        };
295        let node_state = &styled_nodes[anc_dom_id].styled_node_state;
296        let clips_x = get_overflow_x(styled_dom, anc_dom_id, node_state).is_clipped();
297        let clips_y = get_overflow_y(styled_dom, anc_dom_id, node_state).is_clipped();
298        if !clips_x && !clips_y {
299            continue;
300        }
301        let (Some(pos), Some(size)) = (positions.get(anc), anc_node.used_size) else {
302            continue;
303        };
304        let (ax0, ay0) = (pos.x + offset.x, pos.y + offset.y);
305        if clips_x {
306            min_x = min_x.max(ax0);
307            max_x = max_x.min(ax0 + size.width);
308            has_clip = true;
309        }
310        if clips_y {
311            min_y = min_y.max(ay0);
312            max_y = max_y.min(ay0 + size.height);
313            has_clip = true;
314        }
315    }
316
317    if !has_clip {
318        return None;
319    }
320
321    // Replace any still-unbounded axis with a large finite extent so the stored
322    // rect's `origin + size` arithmetic stays finite (no `inf - inf = NaN`).
323    if !min_x.is_finite() {
324        min_x = -CLIP_UNBOUNDED;
325    }
326    if !min_y.is_finite() {
327        min_y = -CLIP_UNBOUNDED;
328    }
329    if !max_x.is_finite() {
330        max_x = CLIP_UNBOUNDED;
331    }
332    if !max_y.is_finite() {
333        max_y = CLIP_UNBOUNDED;
334    }
335
336    Some(LogicalRect {
337        origin: LogicalPosition { x: min_x, y: min_y },
338        size: LogicalSize {
339            width: (max_x - min_x).max(0.0),
340            height: (max_y - min_y).max(0.0),
341        },
342    })
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn test_cpu_hit_tester_empty() {
351        let tester = CpuHitTester::new();
352        let results = tester.hit_test(LogicalPosition { x: 100.0, y: 100.0 });
353        assert!(results.is_empty());
354    }
355
356    #[test]
357    fn test_point_in_rect() {
358        let rect = LogicalRect {
359            origin: LogicalPosition { x: 10.0, y: 10.0 },
360            size: LogicalSize {
361                width: 100.0,
362                height: 50.0,
363            },
364        };
365
366        // Inside
367        assert!(point_in_rect(LogicalPosition { x: 50.0, y: 30.0 }, &rect));
368        // On edge
369        assert!(point_in_rect(LogicalPosition { x: 10.0, y: 10.0 }, &rect));
370        // Outside
371        assert!(!point_in_rect(LogicalPosition { x: 5.0, y: 5.0 }, &rect));
372        assert!(!point_in_rect(LogicalPosition { x: 200.0, y: 30.0 }, &rect));
373    }
374}
375
376#[cfg(test)]
377#[allow(clippy::float_cmp)] // clip/hit geometry must round-trip bit-exactly, not "approximately"
378mod autotest_generated {
379    use std::collections::HashMap;
380
381    use azul_core::dom::{Dom, FormattingContext};
382
383    use super::*;
384    use crate::{
385        solver3::{
386            display_list::{DisplayList, DisplayListItem, WindowLogicalRect},
387            layout_tree::LayoutTree,
388        },
389        window::DomLayoutResult,
390    };
391
392    // -----------------------------------------------------------------------
393    // fixtures
394    // -----------------------------------------------------------------------
395
396    fn p(x: f32, y: f32) -> LogicalPosition {
397        LogicalPosition { x, y }
398    }
399
400    fn r(x: f32, y: f32, width: f32, height: f32) -> LogicalRect {
401        LogicalRect {
402            origin: p(x, y),
403            size: LogicalSize { width, height },
404        }
405    }
406
407    fn dom(inner: usize) -> DomId {
408        DomId { inner }
409    }
410
411    /// A layout node: `dom_node_id` as a raw index (`None` = anonymous box),
412    /// `size` as (w, h) (`None` = never laid out), `parent` as a node index.
413    fn hot(
414        dom_node_id: Option<usize>,
415        size: Option<(f32, f32)>,
416        parent: Option<usize>,
417    ) -> LayoutNodeHot {
418        LayoutNodeHot {
419            box_props: Default::default(),
420            dom_node_id: dom_node_id.map(NodeId::new),
421            used_size: size.map(|(width, height)| LogicalSize { width, height }),
422            formatting_context: FormattingContext::default(),
423            parent,
424        }
425    }
426
427    /// `body > div.clip > div` (`NodeId` 0, 1, 2), styled by `css_src`.
428    fn styled(css_src: &str) -> StyledDom {
429        let css = azul_css::parser2::new_from_str(css_src).0;
430        let mut d = Dom::create_body().with_children(
431            vec![Dom::create_div()
432                .with_class("clip".to_string().into())
433                .with_children(vec![Dom::create_div()].into())]
434            .into(),
435        );
436        StyledDom::create(&mut d, css)
437    }
438
439    fn layout_result(
440        styled_dom: StyledDom,
441        nodes: Vec<LayoutNodeHot>,
442        calculated_positions: PositionVec,
443        items: Vec<DisplayListItem>,
444    ) -> DomLayoutResult {
445        DomLayoutResult {
446            styled_dom,
447            layout_tree: LayoutTree {
448                nodes,
449                warm: Vec::new(),
450                cold: Vec::new(),
451                root: 0,
452                dom_to_layout: BTreeMap::new(),
453                children_arena: Vec::new(),
454                children_offsets: Vec::new(),
455                subtree_needs_intrinsic: Vec::new(),
456            },
457            calculated_positions,
458            viewport: LogicalRect::zero(),
459            display_list: DisplayList {
460                items,
461                ..Default::default()
462            },
463            scroll_ids: HashMap::new(),
464            scroll_id_to_node_id: HashMap::new(),
465        }
466    }
467
468    fn virtual_view(child: usize, bounds: LogicalRect) -> DisplayListItem {
469        DisplayListItem::VirtualView {
470            child_dom_id: dom(child),
471            bounds: WindowLogicalRect::new(bounds.origin, bounds.size),
472            clip_rect: WindowLogicalRect::new(bounds.origin, bounds.size),
473        }
474    }
475
476    /// Every f32 that can plausibly reach a hit test from a broken input event.
477    const HOSTILE_F32: [f32; 8] = [
478        0.0,
479        -0.0,
480        f32::NAN,
481        f32::INFINITY,
482        f32::NEG_INFINITY,
483        f32::MAX,
484        f32::MIN,
485        f32::MIN_POSITIVE,
486    ];
487
488    // -----------------------------------------------------------------------
489    // point_in_rect  (numeric)
490    // -----------------------------------------------------------------------
491
492    #[test]
493    fn point_in_rect_is_half_open_top_left_inclusive_bottom_right_exclusive() {
494        let rect = r(10.0, 10.0, 100.0, 50.0);
495
496        assert!(point_in_rect(p(10.0, 10.0), &rect), "top-left is inclusive");
497        assert!(point_in_rect(p(109.999, 59.999), &rect));
498        assert!(
499            !point_in_rect(p(110.0, 30.0), &rect),
500            "right edge is exclusive"
501        );
502        assert!(
503            !point_in_rect(p(50.0, 60.0), &rect),
504            "bottom edge is exclusive"
505        );
506        assert!(!point_in_rect(p(110.0, 60.0), &rect));
507    }
508
509    #[test]
510    fn point_in_rect_zero_sized_rect_contains_nothing_not_even_its_origin() {
511        let rect = r(0.0, 0.0, 0.0, 0.0);
512        assert!(!point_in_rect(p(0.0, 0.0), &rect));
513        assert!(!point_in_rect(p(-0.0, -0.0), &rect));
514
515        let elsewhere = r(7.0, 9.0, 0.0, 0.0);
516        assert!(!point_in_rect(p(7.0, 9.0), &elsewhere));
517    }
518
519    #[test]
520    fn point_in_rect_negative_size_rect_is_empty() {
521        // A rect whose size is negative has max < min on both axes: nothing is
522        // "inside" it, and in particular the test must not silently swap the
523        // edges and report a hit.
524        let rect = r(100.0, 100.0, -50.0, -50.0);
525        for x in [50.0_f32, 75.0, 99.0, 100.0, 125.0] {
526            for y in [50.0_f32, 75.0, 99.0, 100.0, 125.0] {
527                assert!(!point_in_rect(p(x, y), &rect), "({x}, {y}) must not hit");
528            }
529        }
530    }
531
532    #[test]
533    fn point_in_rect_negative_zero_origin_still_contains_zero() {
534        // -0.0 >= 0.0 and 0.0 >= -0.0 both hold: signed zero must not flip a hit.
535        let rect = r(-0.0, -0.0, 10.0, 10.0);
536        assert!(point_in_rect(p(0.0, 0.0), &rect));
537        assert!(point_in_rect(p(-0.0, -0.0), &rect));
538
539        let zero_origin = r(0.0, 0.0, 10.0, 10.0);
540        assert!(point_in_rect(p(-0.0, -0.0), &zero_origin));
541    }
542
543    #[test]
544    fn point_in_rect_nan_point_never_hits() {
545        let rect = r(-1000.0, -1000.0, 5000.0, 5000.0);
546        assert!(!point_in_rect(p(f32::NAN, 0.0), &rect));
547        assert!(!point_in_rect(p(0.0, f32::NAN), &rect));
548        assert!(!point_in_rect(p(f32::NAN, f32::NAN), &rect));
549    }
550
551    #[test]
552    fn point_in_rect_nan_rect_never_hits() {
553        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
554            let nan_origin = r(bad, 0.0, 10.0, 10.0);
555            let nan_size = r(0.0, 0.0, bad, 10.0);
556            // NaN origin/size makes every comparison false except the trivially
557            // true ones; the only thing that matters is that it doesn't panic and
558            // that a NaN box can't claim an arbitrary point.
559            let _ = point_in_rect(p(5.0, 5.0), &nan_origin);
560            let _ = point_in_rect(p(5.0, 5.0), &nan_size);
561        }
562        assert!(!point_in_rect(p(5.0, 5.0), &r(f32::NAN, 0.0, 10.0, 10.0)));
563        assert!(!point_in_rect(p(5.0, 5.0), &r(0.0, 0.0, f32::NAN, 10.0)));
564    }
565
566    #[test]
567    fn point_in_rect_infinite_extent_is_empty_which_is_why_clip_unbounded_exists() {
568        // origin = -inf, size = +inf  =>  origin + size = NaN  =>  `x < NaN` is
569        // false  =>  nothing is inside. This is exactly the trap CLIP_UNBOUNDED
570        // documents; the assertion pins the failure mode so nobody "optimizes"
571        // CLIP_UNBOUNDED back into f32::INFINITY.
572        let infinite = LogicalRect {
573            origin: p(f32::NEG_INFINITY, f32::NEG_INFINITY),
574            size: LogicalSize {
575                width: f32::INFINITY,
576                height: f32::INFINITY,
577            },
578        };
579        assert!(!point_in_rect(p(0.0, 0.0), &infinite));
580        assert!(!point_in_rect(p(-1.0e6, 1.0e6), &infinite));
581    }
582
583    #[test]
584    fn point_in_rect_clip_unbounded_extent_contains_every_realistic_coordinate() {
585        // The finite stand-in that compute_node_clip uses must behave like
586        // "unbounded" for any coordinate a real window can produce.
587        let unbounded = r(
588            -CLIP_UNBOUNDED,
589            -CLIP_UNBOUNDED,
590            2.0 * CLIP_UNBOUNDED,
591            2.0 * CLIP_UNBOUNDED,
592        );
593        for c in [0.0_f32, -0.0, 1.0, -1.0, 99_999.0, -99_999.0, 1.0e6, -1.0e6] {
594            assert!(point_in_rect(p(c, c), &unbounded), "{c} must be inside");
595        }
596        // ...but it is finite, so it does NOT swallow f32::MAX.
597        assert!(!point_in_rect(p(f32::MAX, 0.0), &unbounded));
598    }
599
600    #[test]
601    fn point_in_rect_saturates_at_f32_max_without_panicking() {
602        // origin + size overflows to +inf here; `x < inf` is true, so the point
603        // is reported inside. No debug-panic, no wraparound.
604        let huge = r(f32::MAX, f32::MAX, f32::MAX, f32::MAX);
605        assert!(point_in_rect(p(f32::MAX, f32::MAX), &huge));
606        assert!(!point_in_rect(p(0.0, 0.0), &huge));
607
608        let from_zero = r(0.0, 0.0, f32::MAX, f32::MAX);
609        assert!(point_in_rect(p(0.0, 0.0), &from_zero));
610        assert!(
611            !point_in_rect(p(f32::MAX, f32::MAX), &from_zero),
612            "the far edge stays exclusive even at f32::MAX"
613        );
614    }
615
616    #[test]
617    fn point_in_rect_never_panics_for_any_hostile_f32_combination() {
618        for &x in &HOSTILE_F32 {
619            for &y in &HOSTILE_F32 {
620                for &w in &HOSTILE_F32 {
621                    let rect = r(x, y, w, w);
622                    let _ = point_in_rect(p(y, x), &rect);
623                }
624            }
625        }
626    }
627
628    // -----------------------------------------------------------------------
629    // CpuHitTester::new / node_rects_total  (constructor + getter)
630    // -----------------------------------------------------------------------
631
632    #[test]
633    fn new_hit_tester_is_empty_and_matches_default() {
634        let tester = CpuHitTester::new();
635        assert_eq!(tester.node_rects_total(), 0);
636        assert!(tester.hit_test(p(0.0, 0.0)).is_empty());
637
638        let defaulted = CpuHitTester::default();
639        assert_eq!(defaulted.node_rects_total(), tester.node_rects_total());
640    }
641
642    #[test]
643    fn node_rects_total_sums_entries_across_doms_and_skips_unlaid_nodes() {
644        let mut results = BTreeMap::new();
645        // dom 0: 2 hit-testable nodes + 1 anonymous + 1 without a used_size
646        results.insert(
647            dom(0),
648            layout_result(
649                styled(""),
650                vec![
651                    hot(Some(0), Some((10.0, 10.0)), None),
652                    hot(Some(1), Some((10.0, 10.0)), None),
653                    hot(None, Some((10.0, 10.0)), None), // anonymous box
654                    hot(Some(2), None, None),            // never laid out
655                ],
656                vec![p(0.0, 0.0), p(0.0, 0.0), p(0.0, 0.0), p(0.0, 0.0)],
657                Vec::new(),
658            ),
659        );
660        // dom 1: 1 hit-testable node
661        results.insert(
662            dom(1),
663            layout_result(
664                styled(""),
665                vec![hot(Some(0), Some((10.0, 10.0)), None)],
666                vec![p(0.0, 0.0)],
667                Vec::new(),
668            ),
669        );
670
671        let mut tester = CpuHitTester::new();
672        tester.rebuild_from_layout(&results);
673        assert_eq!(tester.node_rects_total(), 3);
674    }
675
676    #[test]
677    fn node_rects_total_does_not_grow_when_the_same_layout_is_rebuilt() {
678        // Leak probe: rebuild_from_layout must clear, not append.
679        let mut results = BTreeMap::new();
680        results.insert(
681            dom(0),
682            layout_result(
683                styled(""),
684                vec![hot(Some(0), Some((10.0, 10.0)), None)],
685                vec![p(0.0, 0.0)],
686                Vec::new(),
687            ),
688        );
689
690        let mut tester = CpuHitTester::new();
691        for _ in 0..16 {
692            tester.rebuild_from_layout(&results);
693            assert_eq!(tester.node_rects_total(), 1);
694        }
695
696        tester.rebuild_from_layout(&BTreeMap::new());
697        assert_eq!(tester.node_rects_total(), 0);
698        assert!(tester.hit_test(p(1.0, 1.0)).is_empty());
699    }
700
701    // -----------------------------------------------------------------------
702    // CpuHitTester::hit_test  (numeric)
703    // -----------------------------------------------------------------------
704
705    #[test]
706    fn hit_test_on_empty_tester_never_panics_for_hostile_positions() {
707        let tester = CpuHitTester::new();
708        for &x in &HOSTILE_F32 {
709            for &y in &HOSTILE_F32 {
710                assert!(tester.hit_test(p(x, y)).is_empty());
711            }
712        }
713    }
714
715    #[test]
716    fn hit_test_with_hostile_positions_against_a_real_node_returns_no_spurious_hits() {
717        let mut results = BTreeMap::new();
718        results.insert(
719            dom(0),
720            layout_result(
721                styled(""),
722                vec![hot(Some(0), Some((100.0, 100.0)), None)],
723                vec![p(0.0, 0.0)],
724                Vec::new(),
725            ),
726        );
727        let mut tester = CpuHitTester::new();
728        tester.rebuild_from_layout(&results);
729
730        // Sanity: the node IS hittable at a normal coordinate.
731        assert_eq!(tester.hit_test(p(50.0, 50.0)).len(), 1);
732
733        for pos in [
734            p(f32::NAN, f32::NAN),
735            p(f32::NAN, 50.0),
736            p(50.0, f32::NAN),
737            p(f32::INFINITY, f32::INFINITY),
738            p(f32::NEG_INFINITY, f32::NEG_INFINITY),
739            p(f32::MAX, f32::MAX),
740            p(f32::MIN, f32::MIN),
741        ] {
742            assert!(
743                tester.hit_test(pos).is_empty(),
744                "({}, {}) must not hit a 0,0,100x100 node",
745                pos.x,
746                pos.y
747            );
748        }
749
750        // Zero and negative zero are inside (origin is inclusive).
751        assert_eq!(tester.hit_test(p(0.0, 0.0)).len(), 1);
752        assert_eq!(tester.hit_test(p(-0.0, -0.0)).len(), 1);
753        // The exclusive far edge.
754        assert!(tester.hit_test(p(100.0, 100.0)).is_empty());
755        assert_eq!(tester.hit_test(p(99.999, 99.999)).len(), 1);
756    }
757
758    #[test]
759    fn hit_test_returns_topmost_first() {
760        // Two fully overlapping siblings: the one that paints last (higher index)
761        // must come back first.
762        let mut results = BTreeMap::new();
763        results.insert(
764            dom(0),
765            layout_result(
766                styled(""),
767                vec![
768                    hot(Some(1), Some((100.0, 100.0)), None),
769                    hot(Some(2), Some((100.0, 100.0)), None),
770                ],
771                vec![p(0.0, 0.0), p(0.0, 0.0)],
772                Vec::new(),
773            ),
774        );
775        let mut tester = CpuHitTester::new();
776        tester.rebuild_from_layout(&results);
777
778        assert_eq!(
779            tester.hit_test(p(50.0, 50.0)),
780            vec![(dom(0), NodeId::new(2)), (dom(0), NodeId::new(1))]
781        );
782    }
783
784    #[test]
785    fn hit_test_skips_nodes_with_no_calculated_position() {
786        // `calculated_positions` shorter than `nodes` is a torn/partial layout:
787        // the extra nodes must be dropped, not indexed out of bounds.
788        let mut results = BTreeMap::new();
789        results.insert(
790            dom(0),
791            layout_result(
792                styled(""),
793                vec![
794                    hot(Some(0), Some((100.0, 100.0)), None),
795                    hot(Some(1), Some((100.0, 100.0)), None),
796                    hot(Some(2), Some((100.0, 100.0)), None),
797                ],
798                vec![p(0.0, 0.0)], // only node 0 has a position
799                Vec::new(),
800            ),
801        );
802        let mut tester = CpuHitTester::new();
803        tester.rebuild_from_layout(&results);
804
805        assert_eq!(tester.node_rects_total(), 1);
806        assert_eq!(tester.hit_test(p(50.0, 50.0)), vec![(dom(0), NodeId::ZERO)]);
807    }
808
809    #[test]
810    fn hit_test_respects_an_overflow_hidden_ancestor() {
811        // body(0) 500x500 > div.clip(1) 100x100 overflow:hidden > div(2) 400x400.
812        // A point at (200,200) is inside node 2's rect but scrolled/clipped out of
813        // its ancestor, so only the body may claim it.
814        let mut results = BTreeMap::new();
815        results.insert(
816            dom(0),
817            layout_result(
818                styled("div.clip { overflow: hidden; }"),
819                vec![
820                    hot(Some(0), Some((500.0, 500.0)), None),
821                    hot(Some(1), Some((100.0, 100.0)), Some(0)),
822                    hot(Some(2), Some((400.0, 400.0)), Some(1)),
823                ],
824                vec![p(0.0, 0.0), p(0.0, 0.0), p(0.0, 0.0)],
825                Vec::new(),
826            ),
827        );
828        let mut tester = CpuHitTester::new();
829        tester.rebuild_from_layout(&results);
830
831        assert_eq!(
832            tester.hit_test(p(50.0, 50.0)),
833            vec![
834                (dom(0), NodeId::new(2)),
835                (dom(0), NodeId::new(1)),
836                (dom(0), NodeId::new(0)),
837            ],
838            "inside the clip: all three nodes are hit, topmost first"
839        );
840        assert_eq!(
841            tester.hit_test(p(200.0, 200.0)),
842            vec![(dom(0), NodeId::new(0))],
843            "outside the clip: the clipped-out child must not eat the event"
844        );
845    }
846
847    // -----------------------------------------------------------------------
848    // CpuHitTester::rebuild_from_layout  (VirtualView placement)
849    // -----------------------------------------------------------------------
850
851    #[test]
852    fn rebuild_from_layout_with_no_doms_is_a_no_op() {
853        let mut tester = CpuHitTester::new();
854        tester.rebuild_from_layout(&BTreeMap::new());
855        assert_eq!(tester.node_rects_total(), 0);
856        assert!(tester.hit_test(p(0.0, 0.0)).is_empty());
857    }
858
859    #[test]
860    fn rebuild_translates_and_clips_virtual_view_child_doms() {
861        // Host dom 0 hosts child dom 1 at (100,100) 50x50. The child lays out in
862        // local coordinates with a 200x200 node at (0,0): it must be translated to
863        // (100,100) AND clipped to the 50x50 composite box, otherwise it claims
864        // pointer events across the whole window (the azul-maps tile-grid bug).
865        let mut results = BTreeMap::new();
866        results.insert(
867            dom(0),
868            layout_result(
869                styled(""),
870                Vec::new(),
871                Vec::new(),
872                vec![virtual_view(1, r(100.0, 100.0, 50.0, 50.0))],
873            ),
874        );
875        results.insert(
876            dom(1),
877            layout_result(
878                styled(""),
879                vec![hot(Some(1), Some((200.0, 200.0)), None)],
880                vec![p(0.0, 0.0)],
881                Vec::new(),
882            ),
883        );
884
885        let mut tester = CpuHitTester::new();
886        tester.rebuild_from_layout(&results);
887
888        assert!(
889            tester.hit_test(p(10.0, 10.0)).is_empty(),
890            "the child's local (10,10) is not its window position"
891        );
892        assert_eq!(
893            tester.hit_test(p(120.0, 120.0)),
894            vec![(dom(1), NodeId::new(1))],
895            "translated into the host's VirtualView bounds"
896        );
897        assert!(
898            tester.hit_test(p(180.0, 180.0)).is_empty(),
899            "inside the child's 200x200 rect but outside the 50x50 composite clip"
900        );
901    }
902
903    #[test]
904    fn rebuild_accumulates_offsets_through_nested_virtual_views() {
905        // dom0 --VV(10,10)--> dom1 --VV(5,5 local)--> dom2, whose node sits at
906        // local (0,0): absolute origin must be (15,15).
907        let mut results = BTreeMap::new();
908        results.insert(
909            dom(0),
910            layout_result(
911                styled(""),
912                Vec::new(),
913                Vec::new(),
914                vec![virtual_view(1, r(10.0, 10.0, 200.0, 200.0))],
915            ),
916        );
917        results.insert(
918            dom(1),
919            layout_result(
920                styled(""),
921                Vec::new(),
922                Vec::new(),
923                vec![virtual_view(2, r(5.0, 5.0, 100.0, 100.0))],
924            ),
925        );
926        results.insert(
927            dom(2),
928            layout_result(
929                styled(""),
930                vec![hot(Some(1), Some((20.0, 20.0)), None)],
931                vec![p(0.0, 0.0)],
932                Vec::new(),
933            ),
934        );
935
936        let mut tester = CpuHitTester::new();
937        tester.rebuild_from_layout(&results);
938
939        assert_eq!(
940            tester.hit_test(p(16.0, 16.0)),
941            vec![(dom(2), NodeId::new(1))]
942        );
943        assert!(
944            tester.hit_test(p(14.0, 14.0)).is_empty(),
945            "(14,14) is before the doubly-offset origin (15,15)"
946        );
947        assert!(tester.hit_test(p(36.0, 36.0)).is_empty());
948    }
949
950    #[test]
951    fn rebuild_ignores_virtual_views_pointing_at_a_missing_child_dom() {
952        let mut results = BTreeMap::new();
953        results.insert(
954            dom(0),
955            layout_result(
956                styled(""),
957                vec![hot(Some(0), Some((10.0, 10.0)), None)],
958                vec![p(0.0, 0.0)],
959                vec![virtual_view(42, r(0.0, 0.0, 10.0, 10.0))],
960            ),
961        );
962
963        let mut tester = CpuHitTester::new();
964        tester.rebuild_from_layout(&results);
965
966        assert_eq!(tester.node_rects_total(), 1);
967        assert_eq!(tester.hit_test(p(5.0, 5.0)), vec![(dom(0), NodeId::ZERO)]);
968    }
969
970    #[test]
971    fn rebuild_terminates_on_a_cyclic_virtual_view_graph() {
972        // dom1 hosts dom2 and dom2 hosts dom1: neither is reachable from the root
973        // dom, so neither gets placed. The placement loop is bounded, so this must
974        // terminate (a hang here would freeze every layout pass).
975        let mut results = BTreeMap::new();
976        results.insert(
977            dom(1),
978            layout_result(
979                styled(""),
980                vec![hot(Some(1), Some((10.0, 10.0)), None)],
981                vec![p(0.0, 0.0)],
982                vec![virtual_view(2, r(1.0, 1.0, 10.0, 10.0))],
983            ),
984        );
985        results.insert(
986            dom(2),
987            layout_result(
988                styled(""),
989                vec![hot(Some(1), Some((10.0, 10.0)), None)],
990                vec![p(0.0, 0.0)],
991                vec![virtual_view(1, r(2.0, 2.0, 10.0, 10.0))],
992            ),
993        );
994
995        let mut tester = CpuHitTester::new();
996        tester.rebuild_from_layout(&results);
997        assert_eq!(tester.node_rects_total(), 2);
998    }
999
1000    #[test]
1001    fn rebuild_handles_a_virtual_view_with_hostile_bounds() {
1002        // A NaN/infinite composite box must not produce a NaN clip that panics or
1003        // makes the child hit-testable everywhere.
1004        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX] {
1005            let mut results = BTreeMap::new();
1006            results.insert(
1007                dom(0),
1008                layout_result(
1009                    styled(""),
1010                    Vec::new(),
1011                    Vec::new(),
1012                    vec![virtual_view(1, r(bad, bad, bad, bad))],
1013                ),
1014            );
1015            results.insert(
1016                dom(1),
1017                layout_result(
1018                    styled(""),
1019                    vec![hot(Some(1), Some((20.0, 20.0)), None)],
1020                    vec![p(0.0, 0.0)],
1021                    Vec::new(),
1022                ),
1023            );
1024
1025            let mut tester = CpuHitTester::new();
1026            tester.rebuild_from_layout(&results);
1027            assert_eq!(tester.node_rects_total(), 1);
1028            // Whatever the clip degenerates to, hit testing must not panic.
1029            let _ = tester.hit_test(p(10.0, 10.0));
1030            let _ = tester.hit_test(p(f32::NAN, 0.0));
1031        }
1032    }
1033
1034    // -----------------------------------------------------------------------
1035    // compute_node_clip  (numeric)
1036    // -----------------------------------------------------------------------
1037
1038    #[test]
1039    fn compute_node_clip_without_ancestors_or_dom_clip_is_unclipped() {
1040        let styled_dom = styled("");
1041        let nodes = vec![hot(Some(0), Some((10.0, 10.0)), None)];
1042        let positions: PositionVec = vec![p(0.0, 0.0)];
1043
1044        assert_eq!(
1045            compute_node_clip(&styled_dom, &nodes, &positions, 0, p(0.0, 0.0), None),
1046            None
1047        );
1048    }
1049
1050    #[test]
1051    fn compute_node_clip_out_of_bounds_node_index_does_not_panic() {
1052        let styled_dom = styled("");
1053        let nodes: Vec<LayoutNodeHot> = Vec::new();
1054        let positions: PositionVec = Vec::new();
1055
1056        for idx in [0_usize, 1, 999, usize::MAX] {
1057            assert_eq!(
1058                compute_node_clip(&styled_dom, &nodes, &positions, idx, p(0.0, 0.0), None),
1059                None
1060            );
1061            // ...and with a DOM clip it still returns exactly that clip.
1062            let clip = compute_node_clip(
1063                &styled_dom,
1064                &nodes,
1065                &positions,
1066                idx,
1067                p(0.0, 0.0),
1068                Some(r(1.0, 2.0, 3.0, 4.0)),
1069            );
1070            assert_eq!(clip, Some(r(1.0, 2.0, 3.0, 4.0)));
1071        }
1072    }
1073
1074    #[test]
1075    fn compute_node_clip_round_trips_a_dom_clip_when_no_ancestor_clips() {
1076        // encode == decode: with no clipping ancestor the composite box must come
1077        // back byte-identical, offset included (the offset is already baked into
1078        // the placement, so it must NOT be applied twice).
1079        let styled_dom = styled("");
1080        let nodes = vec![hot(Some(0), Some((10.0, 10.0)), None)];
1081        let positions: PositionVec = vec![p(0.0, 0.0)];
1082        let dom_clip = r(100.0, 200.0, 50.0, 25.0);
1083
1084        let clip = compute_node_clip(
1085            &styled_dom,
1086            &nodes,
1087            &positions,
1088            0,
1089            p(100.0, 200.0),
1090            Some(dom_clip),
1091        )
1092        .expect("dom_clip must survive");
1093
1094        assert_eq!(clip.origin.x, dom_clip.origin.x);
1095        assert_eq!(clip.origin.y, dom_clip.origin.y);
1096        assert_eq!(clip.size.width, dom_clip.size.width);
1097        assert_eq!(clip.size.height, dom_clip.size.height);
1098    }
1099
1100    #[test]
1101    fn compute_node_clip_never_lets_nan_escape_into_the_clip_rect() {
1102        let styled_dom = styled("");
1103        let nodes = vec![hot(Some(0), Some((10.0, 10.0)), None)];
1104        let positions: PositionVec = vec![p(0.0, 0.0)];
1105
1106        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1107            for dom_clip in [
1108                r(bad, 0.0, 10.0, 10.0),
1109                r(0.0, bad, 10.0, 10.0),
1110                r(0.0, 0.0, bad, 10.0),
1111                r(0.0, 0.0, 10.0, bad),
1112                r(bad, bad, bad, bad),
1113            ] {
1114                let clip = compute_node_clip(
1115                    &styled_dom,
1116                    &nodes,
1117                    &positions,
1118                    0,
1119                    p(0.0, 0.0),
1120                    Some(dom_clip),
1121                )
1122                .expect("a dom_clip always yields a clip");
1123
1124                assert!(
1125                    clip.origin.x.is_finite()
1126                        && clip.origin.y.is_finite()
1127                        && clip.size.width.is_finite()
1128                        && clip.size.height.is_finite(),
1129                    "clip {clip:?} from dom_clip {dom_clip:?} must stay finite"
1130                );
1131                assert!(clip.size.width >= 0.0 && clip.size.height >= 0.0);
1132                assert!(
1133                    clip.max_x().is_finite() && clip.max_y().is_finite(),
1134                    "origin + size must not overflow to inf/NaN"
1135                );
1136                // point_in_rect over the result must be a real answer, not a NaN
1137                // comparison that silently drops every event.
1138                let _ = point_in_rect(p(0.0, 0.0), &clip);
1139            }
1140        }
1141    }
1142
1143    #[test]
1144    fn compute_node_clip_clamps_an_infinite_dom_clip_to_clip_unbounded() {
1145        let styled_dom = styled("");
1146        let nodes = vec![hot(Some(0), Some((10.0, 10.0)), None)];
1147        let positions: PositionVec = vec![p(0.0, 0.0)];
1148
1149        let clip = compute_node_clip(
1150            &styled_dom,
1151            &nodes,
1152            &positions,
1153            0,
1154            p(0.0, 0.0),
1155            Some(LogicalRect {
1156                origin: p(0.0, 0.0),
1157                size: LogicalSize {
1158                    width: f32::INFINITY,
1159                    height: f32::INFINITY,
1160                },
1161            }),
1162        )
1163        .expect("a dom_clip always yields a clip");
1164
1165        assert_eq!(clip.origin.x, 0.0);
1166        assert_eq!(clip.origin.y, 0.0);
1167        assert_eq!(clip.size.width, CLIP_UNBOUNDED);
1168        assert_eq!(clip.size.height, CLIP_UNBOUNDED);
1169        assert!(point_in_rect(p(1.0e6, 1.0e6), &clip));
1170    }
1171
1172    #[test]
1173    fn compute_node_clip_saturates_a_negative_sized_dom_clip_to_zero_not_negative() {
1174        let styled_dom = styled("");
1175        let nodes = vec![hot(Some(0), Some((10.0, 10.0)), None)];
1176        let positions: PositionVec = vec![p(0.0, 0.0)];
1177
1178        let clip = compute_node_clip(
1179            &styled_dom,
1180            &nodes,
1181            &positions,
1182            0,
1183            p(0.0, 0.0),
1184            Some(r(100.0, 100.0, -50.0, -50.0)),
1185        )
1186        .expect("a dom_clip always yields a clip");
1187
1188        assert_eq!(clip.size.width, 0.0);
1189        assert_eq!(clip.size.height, 0.0);
1190        assert!(!point_in_rect(p(100.0, 100.0), &clip));
1191        assert!(!point_in_rect(p(75.0, 75.0), &clip));
1192    }
1193
1194    #[test]
1195    fn compute_node_clip_intersects_a_clipping_ancestor_with_the_dom_clip() {
1196        // ancestor div.clip at (10,10) 100x50; dom_clip (0,0) 60x60
1197        // => intersection (10,10) 50x50
1198        let styled_dom = styled("div.clip { overflow: hidden; }");
1199        let nodes = vec![
1200            hot(Some(0), Some((500.0, 500.0)), None),
1201            hot(Some(1), Some((100.0, 50.0)), Some(0)),
1202            hot(Some(2), Some((400.0, 400.0)), Some(1)),
1203        ];
1204        let positions: PositionVec = vec![p(0.0, 0.0), p(10.0, 10.0), p(10.0, 10.0)];
1205
1206        let clip = compute_node_clip(
1207            &styled_dom,
1208            &nodes,
1209            &positions,
1210            2,
1211            p(0.0, 0.0),
1212            Some(r(0.0, 0.0, 60.0, 60.0)),
1213        )
1214        .expect("an overflow:hidden ancestor must clip");
1215
1216        assert_eq!(clip.origin.x, 10.0);
1217        assert_eq!(clip.origin.y, 10.0);
1218        assert_eq!(clip.size.width, 50.0);
1219        assert_eq!(clip.size.height, 50.0);
1220    }
1221
1222    #[test]
1223    fn compute_node_clip_applies_the_offset_to_the_ancestor_box() {
1224        let styled_dom = styled("div.clip { overflow: hidden; }");
1225        let nodes = vec![
1226            hot(Some(0), Some((500.0, 500.0)), None),
1227            hot(Some(1), Some((100.0, 50.0)), Some(0)),
1228            hot(Some(2), Some((400.0, 400.0)), Some(1)),
1229        ];
1230        let positions: PositionVec = vec![p(0.0, 0.0), p(10.0, 10.0), p(10.0, 10.0)];
1231
1232        let clip = compute_node_clip(&styled_dom, &nodes, &positions, 2, p(1000.0, 2000.0), None)
1233            .expect("an overflow:hidden ancestor must clip");
1234
1235        assert_eq!(clip.origin.x, 1010.0);
1236        assert_eq!(clip.origin.y, 2010.0);
1237        assert_eq!(clip.size.width, 100.0);
1238        assert_eq!(clip.size.height, 50.0);
1239    }
1240
1241    #[test]
1242    fn compute_node_clip_leaves_the_unclipped_axis_unbounded() {
1243        // overflow-x: hidden / overflow-y: visible — the y axis must stay
1244        // unbounded (finite stand-in), not collapse onto the ancestor's box.
1245        let styled_dom = styled("div.clip { overflow-x: hidden; }");
1246        let nodes = vec![
1247            hot(Some(0), Some((500.0, 500.0)), None),
1248            hot(Some(1), Some((100.0, 50.0)), Some(0)),
1249            hot(Some(2), Some((400.0, 400.0)), Some(1)),
1250        ];
1251        let positions: PositionVec = vec![p(0.0, 0.0), p(10.0, 10.0), p(10.0, 10.0)];
1252
1253        let clip = compute_node_clip(&styled_dom, &nodes, &positions, 2, p(0.0, 0.0), None)
1254            .expect("overflow-x: hidden must clip the x axis");
1255
1256        assert_eq!(clip.origin.x, 10.0);
1257        assert_eq!(clip.size.width, 100.0);
1258        assert_eq!(clip.origin.y, -CLIP_UNBOUNDED);
1259        assert_eq!(clip.size.height, 2.0 * CLIP_UNBOUNDED);
1260        assert!(clip.max_y().is_finite());
1261
1262        // A point far below the ancestor is still inside the clip (y unbounded),
1263        // but a point to the right of it is not.
1264        assert!(point_in_rect(p(50.0, 900_000.0), &clip));
1265        assert!(!point_in_rect(p(500.0, 20.0), &clip));
1266    }
1267
1268    #[test]
1269    fn compute_node_clip_skips_a_clipping_ancestor_that_was_never_laid_out() {
1270        // used_size: None on the clipping ancestor => nothing to intersect with;
1271        // it must be skipped rather than contributing a garbage/zero box.
1272        let styled_dom = styled("div.clip { overflow: hidden; }");
1273        let nodes = vec![
1274            hot(Some(0), Some((500.0, 500.0)), None),
1275            hot(Some(1), None, Some(0)), // clips, but has no used_size
1276            hot(Some(2), Some((400.0, 400.0)), Some(1)),
1277        ];
1278        let positions: PositionVec = vec![p(0.0, 0.0), p(10.0, 10.0), p(10.0, 10.0)];
1279
1280        assert_eq!(
1281            compute_node_clip(&styled_dom, &nodes, &positions, 2, p(0.0, 0.0), None),
1282            None
1283        );
1284    }
1285
1286    #[test]
1287    fn compute_node_clip_terminates_on_a_parent_cycle() {
1288        // Two anonymous boxes that are each other's parent. The `guard` counter is
1289        // the only thing standing between this and an infinite loop inside a
1290        // hit-test rebuild.
1291        let styled_dom = styled("");
1292        let nodes = vec![
1293            hot(None, Some((10.0, 10.0)), Some(1)),
1294            hot(None, Some((10.0, 10.0)), Some(0)),
1295        ];
1296        let positions: PositionVec = vec![p(0.0, 0.0), p(0.0, 0.0)];
1297
1298        assert_eq!(
1299            compute_node_clip(&styled_dom, &nodes, &positions, 0, p(0.0, 0.0), None),
1300            None
1301        );
1302        // The DOM clip still survives the bounded walk.
1303        assert_eq!(
1304            compute_node_clip(
1305                &styled_dom,
1306                &nodes,
1307                &positions,
1308                1,
1309                p(0.0, 0.0),
1310                Some(r(0.0, 0.0, 5.0, 5.0))
1311            ),
1312            Some(r(0.0, 0.0, 5.0, 5.0))
1313        );
1314    }
1315
1316    #[test]
1317    fn compute_node_clip_terminates_on_a_self_parent_cycle() {
1318        let styled_dom = styled("");
1319        let nodes = vec![hot(None, Some((10.0, 10.0)), Some(0))];
1320        let positions: PositionVec = vec![p(0.0, 0.0)];
1321
1322        assert_eq!(
1323            compute_node_clip(&styled_dom, &nodes, &positions, 0, p(0.0, 0.0), None),
1324            None
1325        );
1326    }
1327
1328    #[test]
1329    fn compute_node_clip_tolerates_a_parent_index_past_the_end_of_the_node_slice() {
1330        let styled_dom = styled("");
1331        let nodes = vec![hot(Some(0), Some((10.0, 10.0)), Some(usize::MAX))];
1332        let positions: PositionVec = vec![p(0.0, 0.0)];
1333
1334        assert_eq!(
1335            compute_node_clip(&styled_dom, &nodes, &positions, 0, p(0.0, 0.0), None),
1336            None
1337        );
1338    }
1339}