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