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

            
20
use crate::solver3::layout_tree::LayoutNodeId;
21
use std::collections::BTreeMap;
22

            
23
use azul_core::{
24
    dom::{DomId, DomNodeId, NodeId},
25
    geom::{LogicalPosition, LogicalRect, LogicalSize},
26
    hit_test::FullHitTest,
27
    styled_dom::StyledDom,
28
};
29

            
30
use crate::solver3::{getters::{get_overflow_x, get_overflow_y}, layout_tree::LayoutNodeHot, PositionVec};
31
use crate::window::DomLayoutResult;
32

            
33
/// Large finite half-extent used in place of `f32::INFINITY` for clip axes that
34
/// are not constrained by any ancestor. Keeping it finite avoids `NaN` in
35
/// `point_in_rect` (`origin + size` would be `inf - inf = NaN`) while staying
36
/// far outside any realistic logical-pixel coordinate.
37
const CLIP_UNBOUNDED: f32 = 1.0e7;
38

            
39
/// CPU-based hit tester that works without `WebRender`.
40
///
41
/// In the GPU path, hit testing is done by `AsyncHitTester` which queries
42
/// `WebRender`'s spatial tree. In headless mode, we do hit testing directly
43
/// against the layout results (positioned rectangles).
44
///
45
/// This is actually simpler and faster than the `WebRender` path, since we
46
/// don't need to go through the compositor's spatial tree — we just walk
47
/// the layout result nodes and check point-in-rect.
48
#[derive(Debug)]
49
pub struct CpuHitTester {
50
    /// Cached hit test results from the last layout.
51
    /// Maps `DomId` -> list of (`NodeId`, positioned rect) sorted by paint order.
52
    node_rects: BTreeMap<DomId, Vec<HitTestEntry>>,
53
    /// Interned ancestor chains (scroll frames + reference frames). Index 0
54
    /// is always the empty chain. Entry / clip `chain` values index into
55
    /// this. A node's on-screen position is
56
    /// `T_total(static_pos − scroll_total)` — the same rule
57
    /// `cpurender::raster` paints with (accumulated scroll subtraction, then
58
    /// the composed reference-frame transform), so pixels and pointer
59
    /// targets cannot disagree.
60
    chains: Vec<Vec<HitChainLink>>,
61
    /// Every node that got a `PushScrollFrame` (from
62
    /// `DomLayoutResult::scroll_ids`, the same set the display list uses),
63
    /// translated into window space, with the chain of its STRICT scroll
64
    /// ancestors (a container's own viewport box does not move when it
65
    /// scrolls — only its content does).
66
    scroll_containers: Vec<ScrollContainerEntry>,
67
    /// `VirtualView` child-DOM placements in window space (static coords).
68
    dom_placements: BTreeMap<DomId, LogicalRect>,
69
}
70

            
71
/// A single entry in the CPU hit test acceleration structure.
72
#[derive(Debug, Clone)]
73
struct HitTestEntry {
74
    /// The DOM node that this entry corresponds to.
75
    node_id: NodeId,
76
    /// Static (unscrolled) position and size of this node in logical pixels,
77
    /// window space (`VirtualView` placement already applied).
78
    rect: LogicalRect,
79
    /// Ancestor scroll frames whose offsets shift this node on screen
80
    /// (index into [`CpuHitTester::chains`]).
81
    chain: u32,
82
    /// Clip boxes from `overflow`-clipping ancestors and the `VirtualView`
83
    /// composite bounds. Each clip carries the chain of ITS owner's strict
84
    /// scroll ancestors — a clip box inside a scrolled frame moves with that
85
    /// frame, while the clipping container's own scroll does not move its
86
    /// viewport. Axis-only clips (`overflow-x`/`overflow-y` independent) are
87
    /// stored with the unclipped axis widened to [`CLIP_UNBOUNDED`].
88
    clips: Vec<(LogicalRect, u32)>,
89
    /// Whether this node is pointer-events: none
90
    pointer_events_none: bool,
91
}
92

            
93
/// A scroll container (`PushScrollFrame` owner) for wheel-target resolution.
94
#[derive(Debug, Clone)]
95
struct ScrollContainerEntry {
96
    dom_id: DomId,
97
    node_id: NodeId,
98
    /// Index of this node in its DOM's layout tree (for content-size lookup).
99
    layout_idx: LayoutNodeId,
100
    scroll_id: u64,
101
    /// Static viewport box, window space (placement-translated).
102
    rect: LogicalRect,
103
    /// Chain of STRICT scroll ancestors (index into [`CpuHitTester::chains`]).
104
    chain: u32,
105
}
106

            
107
/// One link in a node's ancestor chain: something between the node and the
108
/// window root that moves the node's on-screen position at runtime.
109
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
110
pub enum HitChainLink {
111
    /// An ancestor scroll frame — shifts content by the CURRENT scroll
112
    /// offset (`ScrollManager::get_current_offset`), painted as
113
    /// `pos - offset`.
114
    Scroll(DomId, NodeId),
115
    /// An ancestor wrapped in a `PushReferenceFrame` (CSS transform / drag /
116
    /// animation) — the CURRENT matrix lives in the GPU value cache
117
    /// (`css_current_transform_values`), the same source the CPU raster
118
    /// reads at paint time.
119
    Transform(DomId, NodeId),
120
}
121

            
122
/// Minimal 2D affine mirroring `agg_rust::trans_affine::TransAffine`.
123
///
124
/// Row-vector convention: `x' = x·sx + y·shx + tx; y' = x·shy + y·sy + ty`.
125
/// Local copy because `agg-rust` is optional (svg/cpurender features) and
126
/// hit-testing must exist in every configuration. The multiply/invert bodies
127
/// are transcribed from agg so composition matches the raster EXACTLY.
128
#[derive(Debug, Clone, Copy)]
129
pub struct ScreenMapAffine {
130
    pub sx: f32,
131
    pub shy: f32,
132
    pub shx: f32,
133
    pub sy: f32,
134
    pub tx: f32,
135
    pub ty: f32,
136
}
137

            
138
impl ScreenMapAffine {
139
    pub const IDENTITY: Self = Self {
140
        sx: 1.0,
141
        shy: 0.0,
142
        shx: 0.0,
143
        sy: 1.0,
144
        tx: 0.0,
145
        ty: 0.0,
146
    };
147

            
148
    /// The 2D-affine slice of a `ComputedTransform3D`, exactly the elements
149
    /// the CPU raster feeds `TransAffine::new_custom`
150
    /// (`m[0][0], m[0][1], m[1][0], m[1][1], m[3][0], m[3][1]`).
151
6
    #[must_use] pub const fn from_transform_3d(t: &azul_core::transform::ComputedTransform3D) -> Self {
152
6
        Self {
153
6
            sx: t.m[0][0],
154
6
            shy: t.m[0][1],
155
6
            shx: t.m[1][0],
156
6
            sy: t.m[1][1],
157
6
            tx: t.m[3][0],
158
6
            ty: t.m[3][1],
159
6
        }
160
6
    }
161

            
162
    /// `self = self · m` (agg `multiply`).
163
6
    pub fn multiply(&mut self, m: &Self) {
164
6
        let t0 = self.sx.mul_add(m.sx, self.shy * m.shx);
165
6
        let t2 = self.shx.mul_add(m.sx, self.sy * m.shx);
166
6
        let t4 = self.tx.mul_add(m.sx, self.ty * m.shx) + m.tx;
167
6
        self.shy = self.sx.mul_add(m.shy, self.shy * m.sy);
168
6
        self.sy = self.shx.mul_add(m.shy, self.sy * m.sy);
169
6
        self.ty = self.tx.mul_add(m.shy, self.ty * m.sy) + m.ty;
170
6
        self.sx = t0;
171
6
        self.shx = t2;
172
6
        self.tx = t4;
173
6
    }
174

            
175
    /// `self = m · self` (agg `premultiply`) — the raster's per-push step is
176
    /// `composed = tf; composed.premultiply(&current)`.
177
6
    pub fn premultiply(&mut self, m: &Self) {
178
6
        let mut t = *m;
179
6
        t.multiply(self);
180
6
        *self = t;
181
6
    }
182

            
183
    /// In-place inverse (agg `invert`). Degenerate matrices (determinant 0)
184
    /// leave a non-finite result; callers treat non-finite mapped points as
185
    /// misses, which matches "a zero-scale transform is unclickable".
186
6
    pub fn invert(&mut self) {
187
6
        let d = 1.0 / self.sx.mul_add(self.sy, -(self.shy * self.shx));
188
6
        let t0 = self.sy * d;
189
6
        self.sy = self.sx * d;
190
6
        self.shy = -self.shy * d;
191
6
        self.shx = -self.shx * d;
192
6
        let t4 = (-self.tx).mul_add(t0, -(self.ty * self.shx));
193
6
        self.ty = (-self.tx).mul_add(self.shy, -(self.ty * self.sy));
194
6
        self.sx = t0;
195
6
        self.tx = t4;
196
6
    }
197

            
198
6
    #[must_use] pub fn apply(&self, p: LogicalPosition) -> LogicalPosition {
199
6
        LogicalPosition {
200
6
            x: p.x.mul_add(self.sx, p.y * self.shx) + self.tx,
201
6
            y: p.x.mul_add(self.shy, p.y * self.sy) + self.ty,
202
6
        }
203
6
    }
204

            
205
    #[allow(clippy::float_cmp)] // exact equality is correct here: identity is a fast-path gate; a near-identity matrix must still be applied
206
    #[must_use] pub fn is_identity(&self) -> bool {
207
        self.sx == 1.0
208
            && self.shy == 0.0
209
            && self.shx == 0.0
210
            && self.sy == 1.0
211
            && self.tx == 0.0
212
            && self.ty == 0.0
213
    }
214
}
215

            
216
/// A chain resolved against the CURRENT scroll offsets and transform values.
217
///
218
/// The CPU raster paints `screen = T_total(pos − scroll_total)` — one
219
/// accumulated scroll translation and one composed transform stack, in that
220
/// order. The inverse mapping used for hit-testing is therefore
221
/// `local = T_total⁻¹(screen) + scroll_total`.
222
#[derive(Debug, Clone, Copy)]
223
struct ResolvedChain {
224
    scroll: LogicalPosition,
225
    /// FORWARD composed transform (identity when the chain has none).
226
    forward: ScreenMapAffine,
227
    has_transform: bool,
228
}
229

            
230
impl ResolvedChain {
231
237
    fn map_screen_to_local(&self, p: LogicalPosition) -> LogicalPosition {
232
237
        let p = if self.has_transform {
233
6
            let mut inv = self.forward;
234
6
            inv.invert();
235
6
            inv.apply(p)
236
        } else {
237
231
            p
238
        };
239
237
        LogicalPosition {
240
237
            x: p.x + self.scroll.x,
241
237
            y: p.y + self.scroll.y,
242
237
        }
243
237
    }
244

            
245
756
    fn map_local_to_screen(&self, p: LogicalPosition) -> LogicalPosition {
246
756
        let shifted = LogicalPosition {
247
756
            x: p.x - self.scroll.x,
248
756
            y: p.y - self.scroll.y,
249
756
        };
250
756
        if self.has_transform {
251
            self.forward.apply(shifted)
252
        } else {
253
756
            shifted
254
        }
255
756
    }
256
}
257

            
258
426
fn resolve_chain(
259
426
    chain: &[HitChainLink],
260
426
    resolve_scroll: &dyn Fn(DomId, NodeId) -> Option<LogicalPosition>,
261
426
    resolve_transform: &dyn Fn(DomId, NodeId) -> Option<azul_core::transform::ComputedTransform3D>,
262
426
) -> ResolvedChain {
263
426
    let mut scroll = LogicalPosition::zero();
264
426
    let mut forward = ScreenMapAffine::IDENTITY;
265
426
    let mut has_transform = false;
266
870
    for link in chain {
267
444
        match link {
268
438
            HitChainLink::Scroll(d, n) => {
269
438
                if let Some(o) = resolve_scroll(*d, *n) {
270
6
                    scroll.x += o.x;
271
6
                    scroll.y += o.y;
272
438
                }
273
            }
274
6
            HitChainLink::Transform(d, n) => {
275
6
                if let Some(t) = resolve_transform(*d, *n) {
276
6
                    // Mirror the raster's per-push composition:
277
6
                    // composed = tf.premultiply(current)
278
6
                    let mut tf = ScreenMapAffine::from_transform_3d(&t);
279
6
                    tf.premultiply(&forward);
280
6
                    forward = tf;
281
6
                    has_transform = true;
282
6
                }
283
            }
284
        }
285
    }
286
426
    ResolvedChain {
287
426
        scroll,
288
426
        forward,
289
426
        has_transform,
290
426
    }
291
426
}
292

            
293
/// Map a node's STATIC rect to its ON-SCREEN axis-aligned bounds.
294
///
295
/// Walks the node's ancestors, accumulates scroll offsets and reference-frame
296
/// transforms, and applies the raster's forward rule
297
/// `screen = T_total(corner − scroll_total)` to all four corners (result is
298
/// their AABB).
299
///
300
/// This is THE shared answer to "where is this node on screen right now" —
301
/// menu positioning (`LayoutWindow::get_node_hit_test_bounds`) and the a11y
302
/// snapshot both go through it, so what a screen reader is told and where a
303
/// context menu opens can never disagree with painted pixels.
304
///
305
/// Transform membership is decided by `resolve_transform` returning `Some`
306
/// — pass the same GPU-cache lookup the raster paints from.
307
639
pub fn node_rect_to_screen(
308
639
    layout_result: &DomLayoutResult,
309
639
    dom_id: DomId,
310
639
    layout_idx: usize,
311
639
    rect: LogicalRect,
312
639
    resolve_scroll: &dyn Fn(DomId, NodeId) -> Option<LogicalPosition>,
313
639
    resolve_transform: &dyn Fn(DomId, NodeId) -> Option<azul_core::transform::ComputedTransform3D>,
314
639
) -> LogicalRect {
315
639
    let nodes = &layout_result.layout_tree.nodes;
316

            
317
    // Collect links walking child→root; reversing yields outermost-first
318
    // with, per ancestor, Transform before Scroll (the builder nests the
319
    // reference frame OUTSIDE the scroll frame).
320
639
    let mut links_rev: Vec<HitChainLink> = Vec::new();
321
639
    let mut cur = nodes.get(layout_idx).and_then(|n| n.parent);
322
639
    let mut guard = 0usize;
323
4491
    while let Some(anc) = cur {
324
3852
        guard += 1;
325
3852
        if guard > nodes.len() {
326
            break;
327
3852
        }
328
3852
        let Some(anc_node) = nodes.get(anc) else { break };
329
3852
        if let Some(anid) = anc_node.dom_node_id {
330
3852
            if layout_result.scroll_ids.contains_key(&LayoutNodeId::new(anc)) {
331
333
                links_rev.push(HitChainLink::Scroll(dom_id, anid));
332
3519
            }
333
3852
            if resolve_transform(dom_id, anid).is_some() {
334
                links_rev.push(HitChainLink::Transform(dom_id, anid));
335
3852
            }
336
        }
337
3852
        cur = anc_node.parent;
338
    }
339
639
    if links_rev.is_empty() {
340
450
        return rect;
341
189
    }
342
189
    let chain: Vec<HitChainLink> = links_rev.into_iter().rev().collect();
343
189
    let resolved = resolve_chain(&chain, resolve_scroll, resolve_transform);
344

            
345
189
    let corners = [
346
189
        rect.origin,
347
189
        LogicalPosition {
348
189
            x: rect.origin.x + rect.size.width,
349
189
            y: rect.origin.y,
350
189
        },
351
189
        LogicalPosition {
352
189
            x: rect.origin.x,
353
189
            y: rect.origin.y + rect.size.height,
354
189
        },
355
189
        LogicalPosition {
356
189
            x: rect.origin.x + rect.size.width,
357
189
            y: rect.origin.y + rect.size.height,
358
189
        },
359
189
    ];
360
189
    let mut min_x = f32::INFINITY;
361
189
    let mut min_y = f32::INFINITY;
362
189
    let mut max_x = f32::NEG_INFINITY;
363
189
    let mut max_y = f32::NEG_INFINITY;
364
945
    for c in corners {
365
756
        let s = resolved.map_local_to_screen(c);
366
756
        min_x = min_x.min(s.x);
367
756
        min_y = min_y.min(s.y);
368
756
        max_x = max_x.max(s.x);
369
756
        max_y = max_y.max(s.y);
370
756
    }
371
189
    if !(min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()) {
372
        return rect;
373
189
    }
374
189
    LogicalRect {
375
189
        origin: LogicalPosition { x: min_x, y: min_y },
376
189
        size: LogicalSize {
377
189
            width: (max_x - min_x).max(0.0),
378
189
            height: (max_y - min_y).max(0.0),
379
189
        },
380
189
    }
381
639
}
382

            
383
/// Intern a chain into the table, returning its index.
384
2107
fn intern_chain(
385
2107
    chains: &mut Vec<Vec<HitChainLink>>,
386
2107
    lookup: &mut std::collections::HashMap<Vec<HitChainLink>, u32>,
387
2107
    v: Vec<HitChainLink>,
388
2107
) -> u32 {
389
2107
    if let Some(&i) = lookup.get(&v) {
390
1875
        return i;
391
232
    }
392
232
    let i = u32::try_from(chains.len()).unwrap_or(u32::MAX);
393
232
    chains.push(v.clone());
394
232
    lookup.insert(v, i);
395
232
    i
396
2107
}
397

            
398
impl Default for CpuHitTester {
399
1
    fn default() -> Self {
400
1
        Self::new()
401
1
    }
402
}
403

            
404
/// Resolve each layout node's ancestor chain index into `chains`.
405
///
406
/// `chain(n) = chain(parent) (+ parent's links)` — an ancestor shifts its
407
/// CONTENT, not itself. Scroll membership comes from `scroll_ids` (the exact
408
/// set the display-list builder emitted `PushScrollFrame` for); transform
409
/// membership from the GPU value cache's `css_transform_keys` via
410
/// `has_transform` (the exact set it wrapped in `PushReferenceFrame`). A node
411
/// with both nests the reference frame OUTSIDE the scroll frame, same as the
412
/// builder.
413
679
fn compute_node_chains(
414
679
    layout_result: &DomLayoutResult,
415
679
    dom_id: DomId,
416
679
    base_chain: u32,
417
679
    has_transform: &dyn Fn(NodeId) -> bool,
418
679
    chains: &mut Vec<Vec<HitChainLink>>,
419
679
    chain_lookup: &mut std::collections::HashMap<Vec<HitChainLink>, u32>,
420
679
) -> Vec<u32> {
421
679
    let nodes = &layout_result.layout_tree.nodes;
422
679
    let scroll_ids = &layout_result.scroll_ids;
423
679
    let mut chain_of: Vec<u32> = vec![u32::MAX; nodes.len()];
424
679
    let mut path: Vec<usize> = Vec::new();
425
6797
    for start in 0..nodes.len() {
426
6797
        if chain_of[start] != u32::MAX {
427
            continue;
428
6797
        }
429
6797
        path.clear();
430
6797
        path.push(start);
431
6797
        let mut cur = nodes[start].parent;
432
6797
        while let Some(p) = cur {
433
6119
            if chain_of[p] != u32::MAX || path.len() > nodes.len() {
434
6119
                break;
435
            }
436
            path.push(p);
437
            cur = nodes[p].parent;
438
        }
439
6797
        for &idx in path.iter().rev() {
440
6797
            let c = nodes[idx].parent.map_or(base_chain, |p| {
441
6119
                let pc = if chain_of[p] == u32::MAX {
442
                    base_chain // cycle guard tripped; degrade gracefully
443
                } else {
444
6119
                    chain_of[p]
445
                };
446
6119
                let is_scroll = scroll_ids.contains_key(&LayoutNodeId::new(p));
447
6119
                let pnid = nodes[p].dom_node_id;
448
6119
                let parent_transforms = pnid.is_some_and(has_transform);
449
6119
                match (pnid, is_scroll || parent_transforms) {
450
1428
                    (Some(pnid), true) => {
451
1428
                        let mut v = chains[pc as usize].clone();
452
1428
                        if parent_transforms {
453
10
                            v.push(HitChainLink::Transform(dom_id, pnid));
454
1418
                        }
455
1428
                        if is_scroll {
456
1418
                            v.push(HitChainLink::Scroll(dom_id, pnid));
457
1418
                        }
458
1428
                        intern_chain(chains, chain_lookup, v)
459
                    }
460
4691
                    _ => pc,
461
                }
462
6119
            });
463
6797
            chain_of[idx] = c;
464
        }
465
    }
466
679
    chain_of
467
679
}
468

            
469
/// A resolved `VirtualView` child-DOM placement.
470
///
471
/// The composite rect in window space plus the host-side chain (scroll
472
/// frames AND reference frames) active at the `VirtualView` item.
473
struct Placement {
474
    rect: LogicalRect,
475
    chain: Vec<HitChainLink>,
476
}
477

            
478
/// Resolve where each `VirtualView` / iframe child DOM lives on screen.
479
///
480
/// Child DOMs lay out in CHILD-LOCAL coordinates (origin 0,0) but live on
481
/// screen at the host `VirtualView` item's bounds. Hit entries must be
482
/// TRANSLATED there and CLIPPED to the composite bounds — otherwise the
483
/// child's nodes claim pointer events across the whole window (live bug:
484
/// azul-maps' tile grid ate every click on the header toolbar, so the
485
/// buttons never fired; the same escape the renderer had before
486
/// `intersect_clips()`).
487
///
488
/// Placements resolve iteratively so nested `VirtualView`s accumulate their
489
/// host offsets (a child's own `VirtualView` item is in that child's local
490
/// space). They also carry the host-side chain active at the `VirtualView`
491
/// item: if the host scrolls or transforms, the child viewport (and all of
492
/// the child's content) moves on screen with it. The chain is read off the
493
/// host display list by tracking `PushScrollFrame`/`PopScrollFrame` and
494
/// `PushReferenceFrame`/`PopReferenceFrame` nesting around the
495
/// `VirtualView` item — the same nesting the renderer applies when it
496
/// composites the child. Reference-frame owners come from
497
/// `DisplayList::node_mapping` (item index → source node).
498
729
fn resolve_virtual_view_placements(
499
729
    layout_results: &BTreeMap<DomId, DomLayoutResult>,
500
729
) -> BTreeMap<DomId, Placement> {
501
729
    let mut placements: BTreeMap<DomId, Placement> = BTreeMap::new();
502
736
    for _ in 0..4 {
503
        // bounded depth; each pass resolves one nesting level
504
736
        let mut changed = false;
505
1429
        for (host_dom, lr) in layout_results {
506
693
            let (host_offset, host_chain) = if host_dom.inner == 0 {
507
676
                (LogicalPosition::zero(), Vec::new())
508
17
            } else if let Some(p) = placements.get(host_dom) {
509
14
                (p.rect.origin, p.chain.clone())
510
            } else {
511
3
                continue;
512
            };
513
690
            let base_depth = host_chain.len();
514
690
            let mut stack = host_chain;
515
15878
            for (item_idx, item) in lr.display_list.items.iter().enumerate() {
516
                use crate::solver3::display_list::DisplayListItem as I;
517
15845
                match item {
518
150
                    I::PushScrollFrame { scroll_id, .. } => {
519
                        // `scroll_id` is the owning node's layout index by
520
                        // construction (`get_scroll_id`); the reverse map
521
                        // is authoritative, the index a safe fallback.
522
150
                        let nid = lr
523
150
                            .scroll_id_to_node_id
524
150
                            .get(scroll_id)
525
150
                            .copied()
526
150
                            .unwrap_or_else(|| {
527
                                NodeId::new(usize::try_from(*scroll_id).unwrap_or(usize::MAX))
528
                            });
529
150
                        stack.push(HitChainLink::Scroll(*host_dom, nid));
530
                    }
531
                    I::PopScrollFrame => {
532
                        // Never pop below the host's own chain.
533
150
                        if matches!(stack.last(), Some(HitChainLink::Scroll(..)))
534
150
                            && stack.len() > base_depth
535
150
                        {
536
150
                            stack.pop();
537
150
                        }
538
                    }
539
110
                    I::PushReferenceFrame { .. } => {
540
110
                        // The owner node comes from node_mapping; a frame
541
110
                        // with no source node (scrollbar thumbs) still
542
110
                        // needs a stack entry for pop symmetry — use a
543
110
                        // link that resolves to no transform.
544
110
                        let nid = lr
545
110
                            .display_list
546
110
                            .node_mapping
547
110
                            .get(item_idx)
548
110
                            .copied()
549
110
                            .flatten()
550
110
                            .unwrap_or(NodeId::ZERO);
551
110
                        stack.push(HitChainLink::Transform(*host_dom, nid));
552
110
                    }
553
                    I::PopReferenceFrame => {
554
110
                        if matches!(stack.last(), Some(HitChainLink::Transform(..)))
555
110
                            && stack.len() > base_depth
556
110
                        {
557
110
                            stack.pop();
558
110
                        }
559
                    }
560
                    I::VirtualView {
561
16
                        child_dom_id,
562
16
                        bounds,
563
                        ..
564
                    } => {
565
16
                        let b = *bounds.inner();
566
16
                        let absolute = LogicalRect {
567
16
                            origin: LogicalPosition {
568
16
                                x: b.origin.x + host_offset.x,
569
16
                                y: b.origin.y + host_offset.y,
570
16
                            },
571
16
                            size: b.size,
572
16
                        };
573
16
                        let differs = placements.get(child_dom_id).is_none_or(|p| {
574
8
                            p.rect != absolute || p.chain != stack
575
8
                        });
576
16
                        if differs {
577
8
                            placements.insert(
578
8
                                *child_dom_id,
579
8
                                Placement {
580
8
                                    rect: absolute,
581
8
                                    chain: stack.clone(),
582
8
                                },
583
8
                            );
584
8
                            changed = true;
585
8
                        }
586
                    }
587
15309
                    _ => {}
588
                }
589
            }
590
        }
591
736
        if !changed {
592
729
            break;
593
7
        }
594
    }
595
729
    placements
596
729
}
597

            
598
impl CpuHitTester {
599
    /// Create a new empty hit tester.
600
121
    #[must_use] pub fn new() -> Self {
601
121
        Self {
602
121
            node_rects: BTreeMap::new(),
603
121
            chains: vec![Vec::new()],
604
121
            scroll_containers: Vec::new(),
605
121
            dom_placements: BTreeMap::new(),
606
121
        }
607
121
    }
608

            
609
    /// Resolve an interned chain against the current scroll offsets and
610
    /// transform values, then map a screen point into the chain's local
611
    /// (static layout) space.
612
6
    fn map_point_through_chain(
613
6
        &self,
614
6
        chain: u32,
615
6
        p: LogicalPosition,
616
6
        resolve_scroll: &dyn Fn(DomId, NodeId) -> Option<LogicalPosition>,
617
6
        resolve_transform: &dyn Fn(
618
6
            DomId,
619
6
            NodeId,
620
6
        ) -> Option<azul_core::transform::ComputedTransform3D>,
621
6
    ) -> LogicalPosition {
622
6
        self.chains.get(chain as usize).map_or(p, |elems| {
623
6
            resolve_chain(elems, resolve_scroll, resolve_transform).map_screen_to_local(p)
624
6
        })
625
6
    }
626

            
627
    /// Sum of `HitTestEntry` counts across all `DomIds` (for leak probes).
628
29
    #[must_use] pub fn node_rects_total(&self) -> usize {
629
29
        self.node_rects.values().map(Vec::len).sum()
630
29
    }
631

            
632
    /// Rebuild the hit test structure from layout results.
633
    ///
634
    /// Called after each layout pass. Extracts positioned rectangles from
635
    /// `LayoutWindow::layout_results` and builds a flat list for fast
636
    /// point-in-rect testing.
637
67
    pub fn rebuild_from_layout(
638
67
        &mut self,
639
67
        layout_results: &BTreeMap<DomId, DomLayoutResult>,
640
67
    ) {
641
67
        self.rebuild_from_layout_with_gpu(layout_results, None);
642
67
    }
643

            
644
    /// Like [`Self::rebuild_from_layout`], but transform-aware: `gpu` is the
645
    /// window's [`GpuStateManager`](crate::managers::gpu_state::GpuStateManager),
646
    /// whose per-DOM `css_transform_keys` is the EXACT set of nodes the
647
    /// display list wrapped in `PushReferenceFrame` (the display-list builder
648
    /// reads the same cache) — so hit-test chains and painted frames cannot
649
    /// disagree about which nodes transform. Pass `None` only when no
650
    /// transforms can exist (unit tests, static popups).
651
    /// The DOM node ids currently registered as USER-wheel scroll targets.
652
    /// Test/introspection helper: programmatically-scrollable-only containers
653
    /// (`overflow: hidden`) must never appear here.
654
    #[must_use]
655
9
    pub fn debug_scroll_container_nodes(&self) -> Vec<NodeId> {
656
9
        self.scroll_containers.iter().map(|e| e.node_id).collect()
657
9
    }
658

            
659
729
    pub fn rebuild_from_layout_with_gpu(
660
729
        &mut self,
661
729
        layout_results: &BTreeMap<DomId, DomLayoutResult>,
662
729
        gpu: Option<&crate::managers::gpu_state::GpuStateManager>,
663
729
    ) {
664
729
        self.node_rects.clear();
665
729
        self.chains.clear();
666
729
        self.chains.push(Vec::new()); // chain 0 = empty
667
729
        self.scroll_containers.clear();
668
729
        self.dom_placements.clear();
669

            
670
729
        let placements = resolve_virtual_view_placements(layout_results);
671

            
672
729
        let mut chain_lookup: std::collections::HashMap<Vec<HitChainLink>, u32> =
673
729
            std::collections::HashMap::new();
674
729
        chain_lookup.insert(Vec::new(), 0);
675
737
        for (dom_id, p) in &placements {
676
8
            self.dom_placements.insert(*dom_id, p.rect);
677
8
        }
678

            
679
1408
        for (dom_id, layout_result) in layout_results {
680
679
            let mut entries = Vec::new();
681

            
682
679
            let positions = &layout_result.calculated_positions;
683
679
            let nodes = &layout_result.layout_tree.nodes;
684
679
            let styled_dom = &layout_result.styled_dom;
685

            
686
            // Child DOM: shift into window space + clip to the composite rect.
687
679
            let (offset, dom_clip, base_chain_vec) = placements.get(dom_id).map_or_else(
688
672
                || (LogicalPosition::zero(), None, Vec::new()),
689
7
                |p| (p.rect.origin, Some(p.rect), p.chain.clone()),
690
            );
691
679
            let base_chain = intern_chain(&mut self.chains, &mut chain_lookup, base_chain_vec);
692
679
            let dom_clip_entry = dom_clip.map(|r| (r, base_chain));
693

            
694
679
            let scroll_ids = &layout_result.scroll_ids;
695
679
            let transform_nodes = gpu
696
679
                .and_then(|g| g.caches.get(dom_id))
697
679
                .map(|c| &c.css_transform_keys);
698
679
            let chain_of = compute_node_chains(
699
679
                layout_result,
700
679
                *dom_id,
701
679
                base_chain,
702
6099
                &|n| transform_nodes.is_some_and(|t| t.contains_key(&n)),
703
679
                &mut self.chains,
704
679
                &mut chain_lookup,
705
            );
706

            
707
            // Scroll containers of this DOM, for wheel-target containment.
708
            // Only USER-scrollable containers become wheel targets:
709
            // overflow:hidden boxes carry scroll ids (programmatic
710
            // scrolling - scroll-into-view, callback offsets - reaches
711
            // them), but css-overflow-3 disables their user-triggered
712
            // scrolling, so the hit-tester must not route the wheel there.
713
901
            for (&layout_idx, &scroll_id) in scroll_ids {
714
222
                let Some(n) = nodes.get(layout_idx.index()) else { continue };
715
222
                let Some(node_id) = n.dom_node_id else { continue };
716
222
                let (Some(pos), Some(size)) = (positions.get(layout_idx.index()), n.used_size) else {
717
                    continue;
718
                };
719
222
                let user_scrollable = styled_dom
720
222
                    .styled_nodes
721
222
                    .as_container()
722
222
                    .get(node_id)
723
222
                    .is_some_and(|sn| {
724
222
                        let st = &sn.styled_node_state;
725
222
                        get_overflow_x(styled_dom, node_id, st)
726
222
                            .allows_user_scrolling()
727
222
                            || get_overflow_y(styled_dom, node_id, st)
728
222
                                .allows_user_scrolling()
729
222
                    });
730
222
                if !user_scrollable {
731
72
                    continue;
732
150
                }
733
150
                self.scroll_containers.push(ScrollContainerEntry {
734
150
                    dom_id: *dom_id,
735
150
                    node_id,
736
150
                    layout_idx,
737
150
                    scroll_id,
738
150
                    rect: LogicalRect {
739
150
                        origin: LogicalPosition {
740
150
                            x: pos.x + offset.x,
741
150
                            y: pos.y + offset.y,
742
150
                        },
743
150
                        size,
744
150
                    },
745
150
                    chain: chain_of[layout_idx.index()],
746
150
                });
747
            }
748

            
749
            // Walk the layout nodes and their computed positions
750
6797
            for (idx, node) in nodes.iter().enumerate() {
751
                // Only include nodes that map to a real DOM node
752
6797
                let Some(node_id) = node.dom_node_id else {
753
9
                    continue; // skip anonymous boxes
754
                };
755

            
756
                // Get the position for this layout node
757
6788
                let pos = match positions.get(idx) {
758
6786
                    Some(p) => *p,
759
2
                    None => continue,
760
                };
761

            
762
                // Get the computed size
763
6786
                let Some(size) = node.used_size else {
764
1124
                    continue;
765
                };
766

            
767
5662
                let rect = LogicalRect {
768
5662
                    origin: LogicalPosition {
769
5662
                        x: pos.x + offset.x,
770
5662
                        y: pos.y + offset.y,
771
5662
                    },
772
5662
                    size,
773
5662
                };
774

            
775
                // Clip this node to the VirtualView composite bounds
776
                // (`dom_clip`) and every `overflow: hidden | clip | scroll |
777
                // auto` ancestor's box — otherwise a node that is scrolled or
778
                // clipped out of its ancestor would still claim pointer events.
779
5662
                let clips = compute_node_clips(
780
5662
                    styled_dom,
781
5662
                    nodes,
782
5662
                    positions,
783
5662
                    idx,
784
5662
                    offset,
785
5662
                    dom_clip_entry,
786
5662
                    &chain_of,
787
                );
788

            
789
5662
                entries.push(HitTestEntry {
790
5662
                    node_id,
791
5662
                    rect,
792
5662
                    chain: chain_of[idx],
793
5662
                    clips,
794
5662
                    // azul has no `pointer-events` CSS property yet, so every laid-out
795
5662
                    // node is hit-testable. Populate this from the styled DOM once such
796
5662
                    // a property is added to `azul_css`.
797
5662
                    pointer_events_none: false,
798
5662
                });
799
            }
800

            
801
679
            self.node_rects.insert(*dom_id, entries);
802
        }
803
729
    }
804

            
805
    /// Perform a hit test at the given position, ignoring scroll offsets.
806
    ///
807
    /// Only correct for content that cannot scroll (e.g. menu popups) and for
808
    /// unit tests. Interactive windows must use [`Self::hit_test_scrolled`] —
809
    /// this wrapper tests the STATIC layout geometry, which is exactly the
810
    /// "clicks land on pre-scroll targets" bug for anything inside a scroll
811
    /// frame.
812
135
    #[must_use] pub fn hit_test(
813
135
        &self,
814
135
        position: LogicalPosition,
815
135
    ) -> Vec<(DomId, NodeId)> {
816
135
        self.hit_test_scrolled(position, &|_, _| None, &|_, _| None)
817
135
            .into_iter()
818
306
            .map(|(d, n, _)| (d, n))
819
135
            .collect()
820
135
    }
821

            
822
    /// Perform a hit test at the given position with live scroll offsets and
823
    /// transform values.
824
    ///
825
    /// `resolve_scroll` returns the CURRENT scroll offset of a scroll
826
    /// container (`ScrollManager::get_current_offset`); `resolve_transform`
827
    /// the CURRENT matrix of a reference-frame owner
828
    /// (`GpuValueCache::css_current_transform_values` — the same map the CPU
829
    /// raster reads at paint time). Content painted at
830
    /// `T_total(static_pos − scroll_total)` is hit at the same place: a
831
    /// point `p` hits a node iff `T⁻¹(p) + scroll_total` lands in the node's
832
    /// static rect. Clip boxes are shifted by the clip OWNER's chain — a
833
    /// scroller's viewport clips where the viewport IS, not where its
834
    /// content went.
835
    ///
836
    /// Returns `(dom, node, local_point)` triples in reverse paint order
837
    /// (topmost first), where `local_point` is the query point mapped into
838
    /// that node's STATIC layout space — callers use it directly for
839
    /// node-relative points (caret placement, `point_relative_to_item`).
840
147
    #[must_use] pub fn hit_test_scrolled(
841
147
        &self,
842
147
        position: LogicalPosition,
843
147
        resolve_scroll: &dyn Fn(DomId, NodeId) -> Option<LogicalPosition>,
844
147
        resolve_transform: &dyn Fn(
845
147
            DomId,
846
147
            NodeId,
847
147
        ) -> Option<azul_core::transform::ComputedTransform3D>,
848
147
    ) -> Vec<(DomId, NodeId, LogicalPosition)> {
849
147
        let mut results = Vec::new();
850

            
851
        // Resolve every chain once per query, then map the point through it.
852
147
        let mapped: Vec<LogicalPosition> = self
853
147
            .chains
854
147
            .iter()
855
231
            .map(|chain| {
856
231
                resolve_chain(chain, resolve_scroll, resolve_transform)
857
231
                    .map_screen_to_local(position)
858
231
            })
859
147
            .collect();
860
2130
        let local = |chain: u32| -> LogicalPosition {
861
2074
            mapped.get(chain as usize).copied().unwrap_or(position)
862
2074
        };
863

            
864
243
        for (dom_id, entries) in &self.node_rects {
865
            // Walk in reverse (last painted = topmost)
866
1857
            for entry in entries.iter().rev() {
867
1845
                if entry.pointer_events_none {
868
                    continue;
869
1845
                }
870

            
871
                // Every clip box must contain the point (each in its owner's
872
                // space).
873
1845
                if !entry
874
1845
                    .clips
875
1845
                    .iter()
876
1845
                    .all(|(clip, chain)| point_in_rect(local(*chain), clip))
877
                {
878
567
                    continue;
879
1278
                }
880

            
881
                // Check node rect in the node's local (static) space.
882
1278
                let p_local = local(entry.chain);
883
1278
                if point_in_rect(p_local, &entry.rect) {
884
260
                    results.push((*dom_id, entry.node_id, p_local));
885
1019
                }
886
            }
887
        }
888

            
889
147
        results
890
147
    }
891
}
892

            
893
/// Simple point-in-rect test.
894
2672
fn point_in_rect(point: LogicalPosition, rect: &LogicalRect) -> bool {
895
2672
    point.x >= rect.origin.x
896
2227
        && point.x < rect.origin.x + rect.size.width
897
725
        && point.y >= rect.origin.y
898
634
        && point.y < rect.origin.y + rect.size.height
899
2672
}
900

            
901
/// Convert CPU hit test results to `FullHitTest` format.
902
///
903
/// Maps `(DomId, NodeId)` pairs from [`CpuHitTester::hit_test`] into the same
904
/// `FullHitTest` structure that `WebRender`'s `fullhittest_new_webrender`
905
/// produces, so the event dispatch code works identically for both backends.
906
///
907
/// This lives HERE (next to the tester that produces its input) rather than in
908
/// the DLL, because two hosts consume it: the desktop shells
909
/// (`wr_translate2::convert_cpu_hit_test_to_full`, which now delegates) and the
910
/// headless E2E runner (`crate::e2e::runner`). Two copies of "which node did
911
/// the pointer land on" is exactly the divergence that makes a scenario pass in
912
/// one host and fail in the other.
913
#[allow(clippy::cast_possible_truncation)] // bounded: DomId/NodeId indices, hit depth
914
#[allow(clippy::too_many_lines)] // moved verbatim from the DLL; one pass per hit-test kind
915
#[must_use]
916
12
pub fn convert_cpu_hit_test_to_full(
917
12
    tester: &CpuHitTester,
918
12
    hits: &[(DomId, NodeId, LogicalPosition)],
919
12
    old_focus_node: Option<DomNodeId>,
920
12
    layout_results: &BTreeMap<DomId, DomLayoutResult>,
921
12
    cursor_position: LogicalPosition,
922
12
    resolve_scroll: &dyn Fn(DomId, NodeId) -> Option<LogicalPosition>,
923
12
    resolve_transform: &dyn Fn(DomId, NodeId) -> Option<azul_core::transform::ComputedTransform3D>,
924
12
) -> FullHitTest {
925
    use azul_core::{
926
        dom::OptionDomNodeId,
927
        hit_test::{HitTest, HitTestItem, OverflowingScrollNode, ScrollHitTestItem},
928
    };
929

            
930
12
    let focused_node = old_focus_node.map_or(OptionDomNodeId::None, OptionDomNodeId::Some);
931

            
932
12
    let mut hovered_nodes: BTreeMap<DomId, HitTest> = BTreeMap::new();
933

            
934
39
    for (depth, (dom_id, node_id, local_point)) in hits.iter().enumerate() {
935
        // Compute point_relative_to_item in content-box coordinates.
936
        // `local_point` is the cursor already mapped into this node's STATIC
937
        // layout space (ancestor scroll offsets added back, ancestor
938
        // transforms inverted) — the same space the entry rects live in,
939
        // which is the node's static position translated by its VirtualView
940
        // placement. Subtract the node's static border-box position (plus
941
        // placement) AND padding+border to get content-box-local coordinates
942
        // that match the text layout coordinate space.
943
39
        let placement = tester
944
39
            .dom_placements
945
39
            .get(dom_id)
946
39
            .map_or_else(LogicalPosition::zero, |r| r.origin);
947
39
        let point_relative = layout_results
948
39
            .get(dom_id)
949
39
            .and_then(|lr| {
950
39
                lr.layout_tree
951
39
                    .dom_to_layout
952
39
                    .get(node_id)
953
39
                    .and_then(|indices| indices.first())
954
39
                    .and_then(|&idx| {
955
39
                        let node_pos = lr.calculated_positions.get(idx.index())?;
956
39
                        let node = lr.layout_tree.get(idx)?;
957
39
                        let bp = node.box_props.unpack();
958
39
                        let content_x =
959
39
                            node_pos.x + placement.x + bp.padding.left + bp.border.left;
960
39
                        let content_y =
961
39
                            node_pos.y + placement.y + bp.padding.top + bp.border.top;
962
39
                        Some(LogicalPosition::new(
963
39
                            local_point.x - content_x,
964
39
                            local_point.y - content_y,
965
39
                        ))
966
39
                    })
967
39
            })
968
39
            .unwrap_or_else(LogicalPosition::zero);
969

            
970
39
        let hit_test = hovered_nodes.entry(*dom_id).or_insert_with(|| HitTest {
971
12
            regular_hit_test_nodes: BTreeMap::new(),
972
12
            scroll_hit_test_nodes: BTreeMap::new(),
973
12
            scrollbar_hit_test_nodes: BTreeMap::new(),
974
12
            cursor_hit_test_nodes: BTreeMap::new(),
975
12
        });
976

            
977
39
        hit_test.regular_hit_test_nodes.insert(
978
39
            *node_id,
979
39
            HitTestItem {
980
39
                point_in_viewport: cursor_position,
981
39
                point_relative_to_item: point_relative,
982
39
                is_focusable: false,
983
39
                is_virtual_view_hit: None,
984
39
                hit_depth: depth as u32,
985
39
            },
986
        );
987
    }
988

            
989
    // Scroll containers: the CPU hit tester reports only regular DOM nodes,
990
    // so mirror the WR converter's TAG_TYPE_SCROLL_CONTAINER pass by rect
991
    // containment. Without this, scroll_hit_test_nodes stays empty on the
992
    // CPU-render path and wheel/trackpad scrolling never finds a target
993
    // (a11y scrolling still worked - it targets nodes directly - which is
994
    // how this stayed unnoticed).
995
    //
996
    // Containment tests the container's ON-SCREEN viewport box: the static
997
    // box from the hit tester (already placement-translated for VirtualView
998
    // child DOMs), shifted by the container's OWN ancestors' current scroll
999
    // offsets — a scroller nested in a scrolled frame moves with that frame,
    // while its own scrolling never moves its viewport. `parent_rect` /
    // `child_rect` stay in static layout coordinates: downstream only uses
    // their relative geometry (scroll ranges), which translation cannot
    // change.
18
    for sc in &tester.scroll_containers {
6
        let dom_id = &sc.dom_id;
6
        let node_id = sc.node_id;
6
        let scroll_id = sc.scroll_id;
6
        let Some(lr) = layout_results.get(dom_id) else {
            continue;
        };
6
        let layout_idx = sc.layout_idx;
6
        let p_local = tester.map_point_through_chain(
6
            sc.chain,
6
            cursor_position,
6
            resolve_scroll,
6
            resolve_transform,
        );
6
        let adj_x = p_local.x;
6
        let adj_y = p_local.y;
        {
6
            let node_pos = sc.rect.origin;
6
            let node_size = sc.rect.size;
6
            let inside = adj_x >= node_pos.x
6
                && adj_x <= node_pos.x + node_size.width
6
                && adj_y >= node_pos.y
6
                && adj_y <= node_pos.y + node_size.height;
6
            if !inside {
                continue;
6
            }
6
            let parent_rect = LogicalRect::new(node_pos, node_size);
6
            let child_rect = compute_scroll_child_rect(lr, layout_idx.index(), parent_rect);
6
            let scroll_node = OverflowingScrollNode {
6
                parent_rect,
6
                child_rect,
6
                virtual_child_rect: child_rect,
6
                // CPU path has no WebRender document; the pipeline half of the
6
                // external id is only used for WR scroll-layer sync.
6
                parent_external_scroll_id: azul_core::hit_test::ExternalScrollId(
6
                    scroll_id,
6
                    azul_core::hit_test::PipelineId(dom_id.inner as u32, 0),
6
                ),
6
                parent_dom_hash: azul_core::dom::DomNodeHash {
6
                    inner: node_id.index() as u64,
6
                },
6
                scroll_tag_id: azul_core::dom::ScrollTagId {
6
                    inner: azul_core::dom::TagId {
6
                        inner: node_id.index() as u64,
6
                    },
6
                },
6
            };
6
            hovered_nodes
6
                .entry(*dom_id)
6
                .or_insert_with(HitTest::empty)
6
                .scroll_hit_test_nodes
6
                .insert(
6
                    node_id,
6
                    ScrollHitTestItem {
6
                        point_in_viewport: cursor_position,
6
                        // Relative to the container's ON-SCREEN viewport box
6
                        // (static box shifted by the container's ancestors).
6
                        point_relative_to_item: LogicalPosition::new(
6
                            adj_x - node_pos.x,
6
                            adj_y - node_pos.y,
6
                        ),
6
                        scroll_node,
6
                    },
                );
        }
    }
12
    FullHitTest {
12
        hovered_nodes,
12
        focused_node,
12
    }
12
}
/// Compute the `child_rect` (scrollable content bounds) of an overflowing scroll
/// node from the layout tree.
///
/// The content rect is anchored at the node's own border-box origin and sized to
/// the node's overflow content size (`LayoutTree::get_content_size`, which honors
/// `overflow_content_size` / inline text overflow). It is clamped to be at least
/// as large as the viewport (`parent_rect`), so a node whose content does *not*
/// overflow yields `child_rect == parent_rect` and the `ScrollState` clamping
/// produces a zero scroll range (the prior, always-no-scroll behavior).
#[must_use]
6
pub fn compute_scroll_child_rect(
6
    layout_result: &DomLayoutResult,
6
    layout_idx: usize,
6
    parent_rect: LogicalRect,
6
) -> LogicalRect {
6
    let content_size = layout_result.layout_tree.get_content_size(LayoutNodeId::new(layout_idx));
6
    LogicalRect::new(
6
        parent_rect.origin,
6
        LogicalSize::new(
6
            content_size.width.max(parent_rect.size.width),
6
            content_size.height.max(parent_rect.size.height),
        ),
    )
6
}
/// Compute the hit-test clip boxes for a layout node: the host `VirtualView`
/// composite bounds (`dom_clip_entry`) plus every clipping ancestor's border
/// box (any `overflow` other than `visible`), each tagged with the chain of
/// the clip OWNER's strict scroll ancestors so the query can shift each box
/// into its own scrolled space.
///
/// Clipping is tracked per-axis because `overflow-x` / `overflow-y` are
/// independent — an axis the ancestor does not clip is widened to
/// [`CLIP_UNBOUNDED`] (kept finite so `origin + size` arithmetic never
/// produces `inf - inf = NaN`). The ancestor box used is the border box
/// (`used_size`); CSS clips at the padding edge, but the slightly larger
/// border box is a safe over-inclusion for point hit-testing and avoids
/// resolving padding/border here.
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
5697
fn compute_node_clips(
5697
    styled_dom: &StyledDom,
5697
    nodes: &[LayoutNodeHot],
5697
    positions: &PositionVec,
5697
    node_index: usize,
5697
    offset: LogicalPosition,
5697
    dom_clip_entry: Option<(LogicalRect, u32)>,
5697
    chain_of: &[u32],
5697
) -> Vec<(LogicalRect, u32)> {
    // A non-finite edge must degrade to "unclipped on that side", never be
    // stored: `point_in_rect` against a NaN rect is always false, which would
    // make every node under a corrupt clip silently unhittable.
2343
    fn sanitize_clip_rect(r: LogicalRect) -> LogicalRect {
2343
        let min_x = if r.min_x().is_finite() { r.min_x() } else { -CLIP_UNBOUNDED };
2343
        let min_y = if r.min_y().is_finite() { r.min_y() } else { -CLIP_UNBOUNDED };
2343
        let max_x = if r.max_x().is_finite() { r.max_x() } else { CLIP_UNBOUNDED };
2343
        let max_y = if r.max_y().is_finite() { r.max_y() } else { CLIP_UNBOUNDED };
2343
        LogicalRect {
2343
            origin: LogicalPosition { x: min_x, y: min_y },
2343
            size: LogicalSize {
2343
                width: (max_x - min_x).max(0.0),
2343
                height: (max_y - min_y).max(0.0),
2343
            },
2343
        }
2343
    }
5697
    let mut clips = Vec::new();
5697
    if let Some((dc, chain)) = dom_clip_entry {
30
        clips.push((sanitize_clip_rect(dc), chain));
5667
    }
    // Walk ancestors. A node's own overflow clips its descendants, not itself, so
    // we start at the parent. `guard` bounds the loop in case `parent` links ever
    // form a cycle (they shouldn't, but a hit-test rebuild must never hang).
5697
    let styled_nodes = styled_dom.styled_nodes.as_container();
5697
    let mut cur = nodes.get(node_index).and_then(|n| n.parent);
5697
    let mut guard = 0usize;
23865
    while let Some(anc) = cur {
18172
        guard += 1;
18172
        if guard > nodes.len() {
3
            break;
18169
        }
18169
        let Some(anc_node) = nodes.get(anc) else { break };
18168
        cur = anc_node.parent;
18168
        let Some(anc_dom_id) = anc_node.dom_node_id else {
5
            continue;
        };
18163
        let node_state = &styled_nodes[anc_dom_id].styled_node_state;
18163
        let clips_x = get_overflow_x(styled_dom, anc_dom_id, node_state).is_clipped();
18163
        let clips_y = get_overflow_y(styled_dom, anc_dom_id, node_state).is_clipped();
18163
        if !clips_x && !clips_y {
15849
            continue;
2314
        }
2314
        let (Some(pos), Some(size)) = (positions.get(anc), anc_node.used_size) else {
1
            continue;
        };
2313
        let (ax0, ay0) = (pos.x + offset.x, pos.y + offset.y);
2313
        let (min_x, max_x) = if clips_x {
1219
            (ax0, ax0 + size.width)
        } else {
1094
            (-CLIP_UNBOUNDED, CLIP_UNBOUNDED)
        };
2313
        let (min_y, max_y) = if clips_y {
2312
            (ay0, ay0 + size.height)
        } else {
1
            (-CLIP_UNBOUNDED, CLIP_UNBOUNDED)
        };
2313
        clips.push((
2313
            sanitize_clip_rect(LogicalRect {
2313
                origin: LogicalPosition { x: min_x, y: min_y },
2313
                size: LogicalSize {
2313
                    width: (max_x - min_x).max(0.0),
2313
                    height: (max_y - min_y).max(0.0),
2313
                },
2313
            }),
2313
            chain_of.get(anc).copied().unwrap_or(0),
2313
        ));
    }
5697
    clips
5697
}
/// Test-compat shim for the pre-scroll-aware single-rect clip API: the static
/// intersection of every clip box from [`compute_node_clips`] — exactly what
/// the query evaluates when nothing is scrolled. Kept so the generated clip
/// tests keep asserting the per-axis clip semantics they were written for.
#[cfg(test)]
35
fn compute_node_clip(
35
    styled_dom: &StyledDom,
35
    nodes: &[LayoutNodeHot],
35
    positions: &PositionVec,
35
    node_index: usize,
35
    offset: LogicalPosition,
35
    dom_clip: Option<LogicalRect>,
35
) -> Option<LogicalRect> {
35
    let chain_of = vec![0u32; nodes.len()];
35
    let clips = compute_node_clips(
35
        styled_dom,
35
        nodes,
35
        positions,
35
        node_index,
35
        offset,
35
        dom_clip.map(|r| (r, 0)),
35
        &chain_of,
    );
35
    if clips.is_empty() {
9
        return None;
26
    }
26
    let (mut min_x, mut min_y, mut max_x, mut max_y) = (
26
        -CLIP_UNBOUNDED,
26
        -CLIP_UNBOUNDED,
26
        CLIP_UNBOUNDED,
26
        CLIP_UNBOUNDED,
26
    );
53
    for (r, _) in &clips {
27
        min_x = min_x.max(r.min_x());
27
        min_y = min_y.max(r.min_y());
27
        max_x = max_x.min(r.max_x());
27
        max_y = max_y.min(r.max_y());
27
    }
26
    Some(LogicalRect {
26
        origin: LogicalPosition { x: min_x, y: min_y },
26
        size: LogicalSize {
26
            width: (max_x - min_x).max(0.0),
26
            height: (max_y - min_y).max(0.0),
26
        },
26
    })
35
}
#[cfg(test)]
mod tests {
    use super::*;
    #[test]
1
    fn test_cpu_hit_tester_empty() {
1
        let tester = CpuHitTester::new();
1
        let results = tester.hit_test(LogicalPosition { x: 100.0, y: 100.0 });
1
        assert!(results.is_empty());
1
    }
    #[test]
1
    fn test_point_in_rect() {
1
        let rect = LogicalRect {
1
            origin: LogicalPosition { x: 10.0, y: 10.0 },
1
            size: LogicalSize {
1
                width: 100.0,
1
                height: 50.0,
1
            },
1
        };
        // Inside
1
        assert!(point_in_rect(LogicalPosition { x: 50.0, y: 30.0 }, &rect));
        // On edge
1
        assert!(point_in_rect(LogicalPosition { x: 10.0, y: 10.0 }, &rect));
        // Outside
1
        assert!(!point_in_rect(LogicalPosition { x: 5.0, y: 5.0 }, &rect));
1
        assert!(!point_in_rect(LogicalPosition { x: 200.0, y: 30.0 }, &rect));
1
    }
}
#[cfg(test)]
#[allow(clippy::float_cmp)] // clip/hit geometry must round-trip bit-exactly, not "approximately"
mod autotest_generated {
    use std::collections::HashMap;
    use azul_core::dom::{Dom, FormattingContext};
    use super::*;
    use crate::{
        solver3::{
            display_list::{DisplayList, DisplayListItem, WindowLogicalRect},
            layout_tree::LayoutTree,
        },
        window::DomLayoutResult,
    };
    // -----------------------------------------------------------------------
    // fixtures
    // -----------------------------------------------------------------------
    fn p(x: f32, y: f32) -> LogicalPosition {
        LogicalPosition { x, y }
    }
    fn r(x: f32, y: f32, width: f32, height: f32) -> LogicalRect {
        LogicalRect {
            origin: p(x, y),
            size: LogicalSize { width, height },
        }
    }
    fn dom(inner: usize) -> DomId {
        DomId { inner }
    }
    /// A layout node: `dom_node_id` as a raw index (`None` = anonymous box),
    /// `size` as (w, h) (`None` = never laid out), `parent` as a node index.
    fn hot(
        dom_node_id: Option<usize>,
        size: Option<(f32, f32)>,
        parent: Option<usize>,
    ) -> LayoutNodeHot {
        LayoutNodeHot {
            box_props: Default::default(),
            dom_node_id: dom_node_id.map(NodeId::new),
            used_size: size.map(|(width, height)| LogicalSize { width, height }),
            formatting_context: FormattingContext::default(),
            parent,
        }
    }
    /// `body > div.clip > div` (`NodeId` 0, 1, 2), styled by `css_src`.
    fn styled(css_src: &str) -> StyledDom {
        let css = azul_css::parser2::new_from_str(css_src).0;
        let mut d = Dom::create_body().with_children(
            vec![Dom::create_div()
                .with_class("clip".to_string().into())
                .with_children(vec![Dom::create_div()].into())]
            .into(),
        );
        StyledDom::create(&mut d, css)
    }
    fn layout_result(
        styled_dom: StyledDom,
        nodes: Vec<LayoutNodeHot>,
        calculated_positions: PositionVec,
        items: Vec<DisplayListItem>,
    ) -> DomLayoutResult {
        DomLayoutResult {
            styled_dom,
            layout_tree: LayoutTree {
                nodes,
                warm: Vec::new(),
                cold: Vec::new(),
                root: 0,
                dom_to_layout: BTreeMap::new(),
                children_arena: Vec::new(),
                children_offsets: Vec::new(),
                subtree_needs_intrinsic: Vec::new(),
            },
            calculated_positions,
            viewport: LogicalRect::zero(),
            display_list: std::sync::Arc::new(DisplayList {
                items,
                ..Default::default()
            }),
            scroll_ids: HashMap::new(),
            scroll_id_to_node_id: HashMap::new(),
        }
    }
    fn virtual_view(child: usize, bounds: LogicalRect) -> DisplayListItem {
        DisplayListItem::VirtualView {
            child_dom_id: dom(child),
            bounds: WindowLogicalRect::new(bounds.origin, bounds.size),
            clip_rect: WindowLogicalRect::new(bounds.origin, bounds.size),
            content_offset: Default::default(),
        }
    }
    /// Every f32 that can plausibly reach a hit test from a broken input event.
    const HOSTILE_F32: [f32; 8] = [
        0.0,
        -0.0,
        f32::NAN,
        f32::INFINITY,
        f32::NEG_INFINITY,
        f32::MAX,
        f32::MIN,
        f32::MIN_POSITIVE,
    ];
    // -----------------------------------------------------------------------
    // point_in_rect  (numeric)
    // -----------------------------------------------------------------------
    #[test]
    fn point_in_rect_is_half_open_top_left_inclusive_bottom_right_exclusive() {
        let rect = r(10.0, 10.0, 100.0, 50.0);
        assert!(point_in_rect(p(10.0, 10.0), &rect), "top-left is inclusive");
        assert!(point_in_rect(p(109.999, 59.999), &rect));
        assert!(
            !point_in_rect(p(110.0, 30.0), &rect),
            "right edge is exclusive"
        );
        assert!(
            !point_in_rect(p(50.0, 60.0), &rect),
            "bottom edge is exclusive"
        );
        assert!(!point_in_rect(p(110.0, 60.0), &rect));
    }
    #[test]
    fn point_in_rect_zero_sized_rect_contains_nothing_not_even_its_origin() {
        let rect = r(0.0, 0.0, 0.0, 0.0);
        assert!(!point_in_rect(p(0.0, 0.0), &rect));
        assert!(!point_in_rect(p(-0.0, -0.0), &rect));
        let elsewhere = r(7.0, 9.0, 0.0, 0.0);
        assert!(!point_in_rect(p(7.0, 9.0), &elsewhere));
    }
    #[test]
    fn point_in_rect_negative_size_rect_is_empty() {
        // A rect whose size is negative has max < min on both axes: nothing is
        // "inside" it, and in particular the test must not silently swap the
        // edges and report a hit.
        let rect = r(100.0, 100.0, -50.0, -50.0);
        for x in [50.0_f32, 75.0, 99.0, 100.0, 125.0] {
            for y in [50.0_f32, 75.0, 99.0, 100.0, 125.0] {
                assert!(!point_in_rect(p(x, y), &rect), "({x}, {y}) must not hit");
            }
        }
    }
    #[test]
    fn point_in_rect_negative_zero_origin_still_contains_zero() {
        // -0.0 >= 0.0 and 0.0 >= -0.0 both hold: signed zero must not flip a hit.
        let rect = r(-0.0, -0.0, 10.0, 10.0);
        assert!(point_in_rect(p(0.0, 0.0), &rect));
        assert!(point_in_rect(p(-0.0, -0.0), &rect));
        let zero_origin = r(0.0, 0.0, 10.0, 10.0);
        assert!(point_in_rect(p(-0.0, -0.0), &zero_origin));
    }
    #[test]
    fn point_in_rect_nan_point_never_hits() {
        let rect = r(-1000.0, -1000.0, 5000.0, 5000.0);
        assert!(!point_in_rect(p(f32::NAN, 0.0), &rect));
        assert!(!point_in_rect(p(0.0, f32::NAN), &rect));
        assert!(!point_in_rect(p(f32::NAN, f32::NAN), &rect));
    }
    #[test]
    fn point_in_rect_nan_rect_never_hits() {
        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
            let nan_origin = r(bad, 0.0, 10.0, 10.0);
            let nan_size = r(0.0, 0.0, bad, 10.0);
            // NaN origin/size makes every comparison false except the trivially
            // true ones; the only thing that matters is that it doesn't panic and
            // that a NaN box can't claim an arbitrary point.
            let _ = point_in_rect(p(5.0, 5.0), &nan_origin);
            let _ = point_in_rect(p(5.0, 5.0), &nan_size);
        }
        assert!(!point_in_rect(p(5.0, 5.0), &r(f32::NAN, 0.0, 10.0, 10.0)));
        assert!(!point_in_rect(p(5.0, 5.0), &r(0.0, 0.0, f32::NAN, 10.0)));
    }
    #[test]
    fn point_in_rect_infinite_extent_is_empty_which_is_why_clip_unbounded_exists() {
        // origin = -inf, size = +inf  =>  origin + size = NaN  =>  `x < NaN` is
        // false  =>  nothing is inside. This is exactly the trap CLIP_UNBOUNDED
        // documents; the assertion pins the failure mode so nobody "optimizes"
        // CLIP_UNBOUNDED back into f32::INFINITY.
        let infinite = LogicalRect {
            origin: p(f32::NEG_INFINITY, f32::NEG_INFINITY),
            size: LogicalSize {
                width: f32::INFINITY,
                height: f32::INFINITY,
            },
        };
        assert!(!point_in_rect(p(0.0, 0.0), &infinite));
        assert!(!point_in_rect(p(-1.0e6, 1.0e6), &infinite));
    }
    #[test]
    fn point_in_rect_clip_unbounded_extent_contains_every_realistic_coordinate() {
        // The finite stand-in that compute_node_clip uses must behave like
        // "unbounded" for any coordinate a real window can produce.
        let unbounded = r(
            -CLIP_UNBOUNDED,
            -CLIP_UNBOUNDED,
            2.0 * CLIP_UNBOUNDED,
            2.0 * CLIP_UNBOUNDED,
        );
        for c in [0.0_f32, -0.0, 1.0, -1.0, 99_999.0, -99_999.0, 1.0e6, -1.0e6] {
            assert!(point_in_rect(p(c, c), &unbounded), "{c} must be inside");
        }
        // ...but it is finite, so it does NOT swallow f32::MAX.
        assert!(!point_in_rect(p(f32::MAX, 0.0), &unbounded));
    }
    #[test]
    fn point_in_rect_saturates_at_f32_max_without_panicking() {
        // origin + size overflows to +inf here; `x < inf` is true, so the point
        // is reported inside. No debug-panic, no wraparound.
        let huge = r(f32::MAX, f32::MAX, f32::MAX, f32::MAX);
        assert!(point_in_rect(p(f32::MAX, f32::MAX), &huge));
        assert!(!point_in_rect(p(0.0, 0.0), &huge));
        let from_zero = r(0.0, 0.0, f32::MAX, f32::MAX);
        assert!(point_in_rect(p(0.0, 0.0), &from_zero));
        assert!(
            !point_in_rect(p(f32::MAX, f32::MAX), &from_zero),
            "the far edge stays exclusive even at f32::MAX"
        );
    }
    #[test]
    fn point_in_rect_never_panics_for_any_hostile_f32_combination() {
        for &x in &HOSTILE_F32 {
            for &y in &HOSTILE_F32 {
                for &w in &HOSTILE_F32 {
                    let rect = r(x, y, w, w);
                    let _ = point_in_rect(p(y, x), &rect);
                }
            }
        }
    }
    // -----------------------------------------------------------------------
    // CpuHitTester::new / node_rects_total  (constructor + getter)
    // -----------------------------------------------------------------------
    #[test]
    fn new_hit_tester_is_empty_and_matches_default() {
        let tester = CpuHitTester::new();
        assert_eq!(tester.node_rects_total(), 0);
        assert!(tester.hit_test(p(0.0, 0.0)).is_empty());
        let defaulted = CpuHitTester::default();
        assert_eq!(defaulted.node_rects_total(), tester.node_rects_total());
    }
    #[test]
    fn node_rects_total_sums_entries_across_doms_and_skips_unlaid_nodes() {
        let mut results = BTreeMap::new();
        // dom 0: 2 hit-testable nodes + 1 anonymous + 1 without a used_size
        results.insert(
            dom(0),
            layout_result(
                styled(""),
                vec![
                    hot(Some(0), Some((10.0, 10.0)), None),
                    hot(Some(1), Some((10.0, 10.0)), None),
                    hot(None, Some((10.0, 10.0)), None), // anonymous box
                    hot(Some(2), None, None),            // never laid out
                ],
                vec![p(0.0, 0.0), p(0.0, 0.0), p(0.0, 0.0), p(0.0, 0.0)],
                Vec::new(),
            ),
        );
        // dom 1: 1 hit-testable node
        results.insert(
            dom(1),
            layout_result(
                styled(""),
                vec![hot(Some(0), Some((10.0, 10.0)), None)],
                vec![p(0.0, 0.0)],
                Vec::new(),
            ),
        );
        let mut tester = CpuHitTester::new();
        tester.rebuild_from_layout(&results);
        assert_eq!(tester.node_rects_total(), 3);
    }
    #[test]
    fn node_rects_total_does_not_grow_when_the_same_layout_is_rebuilt() {
        // Leak probe: rebuild_from_layout must clear, not append.
        let mut results = BTreeMap::new();
        results.insert(
            dom(0),
            layout_result(
                styled(""),
                vec![hot(Some(0), Some((10.0, 10.0)), None)],
                vec![p(0.0, 0.0)],
                Vec::new(),
            ),
        );
        let mut tester = CpuHitTester::new();
        for _ in 0..16 {
            tester.rebuild_from_layout(&results);
            assert_eq!(tester.node_rects_total(), 1);
        }
        tester.rebuild_from_layout(&BTreeMap::new());
        assert_eq!(tester.node_rects_total(), 0);
        assert!(tester.hit_test(p(1.0, 1.0)).is_empty());
    }
    // -----------------------------------------------------------------------
    // CpuHitTester::hit_test  (numeric)
    // -----------------------------------------------------------------------
    #[test]
    fn hit_test_on_empty_tester_never_panics_for_hostile_positions() {
        let tester = CpuHitTester::new();
        for &x in &HOSTILE_F32 {
            for &y in &HOSTILE_F32 {
                assert!(tester.hit_test(p(x, y)).is_empty());
            }
        }
    }
    #[test]
    fn hit_test_with_hostile_positions_against_a_real_node_returns_no_spurious_hits() {
        let mut results = BTreeMap::new();
        results.insert(
            dom(0),
            layout_result(
                styled(""),
                vec![hot(Some(0), Some((100.0, 100.0)), None)],
                vec![p(0.0, 0.0)],
                Vec::new(),
            ),
        );
        let mut tester = CpuHitTester::new();
        tester.rebuild_from_layout(&results);
        // Sanity: the node IS hittable at a normal coordinate.
        assert_eq!(tester.hit_test(p(50.0, 50.0)).len(), 1);
        for pos in [
            p(f32::NAN, f32::NAN),
            p(f32::NAN, 50.0),
            p(50.0, f32::NAN),
            p(f32::INFINITY, f32::INFINITY),
            p(f32::NEG_INFINITY, f32::NEG_INFINITY),
            p(f32::MAX, f32::MAX),
            p(f32::MIN, f32::MIN),
        ] {
            assert!(
                tester.hit_test(pos).is_empty(),
                "({}, {}) must not hit a 0,0,100x100 node",
                pos.x,
                pos.y
            );
        }
        // Zero and negative zero are inside (origin is inclusive).
        assert_eq!(tester.hit_test(p(0.0, 0.0)).len(), 1);
        assert_eq!(tester.hit_test(p(-0.0, -0.0)).len(), 1);
        // The exclusive far edge.
        assert!(tester.hit_test(p(100.0, 100.0)).is_empty());
        assert_eq!(tester.hit_test(p(99.999, 99.999)).len(), 1);
    }
    #[test]
    fn hit_test_returns_topmost_first() {
        // Two fully overlapping siblings: the one that paints last (higher index)
        // must come back first.
        let mut results = BTreeMap::new();
        results.insert(
            dom(0),
            layout_result(
                styled(""),
                vec![
                    hot(Some(1), Some((100.0, 100.0)), None),
                    hot(Some(2), Some((100.0, 100.0)), None),
                ],
                vec![p(0.0, 0.0), p(0.0, 0.0)],
                Vec::new(),
            ),
        );
        let mut tester = CpuHitTester::new();
        tester.rebuild_from_layout(&results);
        assert_eq!(
            tester.hit_test(p(50.0, 50.0)),
            vec![(dom(0), NodeId::new(2)), (dom(0), NodeId::new(1))]
        );
    }
    #[test]
    fn hit_test_skips_nodes_with_no_calculated_position() {
        // `calculated_positions` shorter than `nodes` is a torn/partial layout:
        // the extra nodes must be dropped, not indexed out of bounds.
        let mut results = BTreeMap::new();
        results.insert(
            dom(0),
            layout_result(
                styled(""),
                vec![
                    hot(Some(0), Some((100.0, 100.0)), None),
                    hot(Some(1), Some((100.0, 100.0)), None),
                    hot(Some(2), Some((100.0, 100.0)), None),
                ],
                vec![p(0.0, 0.0)], // only node 0 has a position
                Vec::new(),
            ),
        );
        let mut tester = CpuHitTester::new();
        tester.rebuild_from_layout(&results);
        assert_eq!(tester.node_rects_total(), 1);
        assert_eq!(tester.hit_test(p(50.0, 50.0)), vec![(dom(0), NodeId::ZERO)]);
    }
    #[test]
    fn hit_test_respects_an_overflow_hidden_ancestor() {
        // body(0) 500x500 > div.clip(1) 100x100 overflow:hidden > div(2) 400x400.
        // A point at (200,200) is inside node 2's rect but scrolled/clipped out of
        // its ancestor, so only the body may claim it.
        let mut results = BTreeMap::new();
        results.insert(
            dom(0),
            layout_result(
                styled("div.clip { overflow: hidden; }"),
                vec![
                    hot(Some(0), Some((500.0, 500.0)), None),
                    hot(Some(1), Some((100.0, 100.0)), Some(0)),
                    hot(Some(2), Some((400.0, 400.0)), Some(1)),
                ],
                vec![p(0.0, 0.0), p(0.0, 0.0), p(0.0, 0.0)],
                Vec::new(),
            ),
        );
        let mut tester = CpuHitTester::new();
        tester.rebuild_from_layout(&results);
        assert_eq!(
            tester.hit_test(p(50.0, 50.0)),
            vec![
                (dom(0), NodeId::new(2)),
                (dom(0), NodeId::new(1)),
                (dom(0), NodeId::new(0)),
            ],
            "inside the clip: all three nodes are hit, topmost first"
        );
        assert_eq!(
            tester.hit_test(p(200.0, 200.0)),
            vec![(dom(0), NodeId::new(0))],
            "outside the clip: the clipped-out child must not eat the event"
        );
    }
    // -----------------------------------------------------------------------
    // CpuHitTester::rebuild_from_layout  (VirtualView placement)
    // -----------------------------------------------------------------------
    #[test]
    fn rebuild_from_layout_with_no_doms_is_a_no_op() {
        let mut tester = CpuHitTester::new();
        tester.rebuild_from_layout(&BTreeMap::new());
        assert_eq!(tester.node_rects_total(), 0);
        assert!(tester.hit_test(p(0.0, 0.0)).is_empty());
    }
    #[test]
    fn rebuild_translates_and_clips_virtual_view_child_doms() {
        // Host dom 0 hosts child dom 1 at (100,100) 50x50. The child lays out in
        // local coordinates with a 200x200 node at (0,0): it must be translated to
        // (100,100) AND clipped to the 50x50 composite box, otherwise it claims
        // pointer events across the whole window (the azul-maps tile-grid bug).
        let mut results = BTreeMap::new();
        results.insert(
            dom(0),
            layout_result(
                styled(""),
                Vec::new(),
                Vec::new(),
                vec![virtual_view(1, r(100.0, 100.0, 50.0, 50.0))],
            ),
        );
        results.insert(
            dom(1),
            layout_result(
                styled(""),
                vec![hot(Some(1), Some((200.0, 200.0)), None)],
                vec![p(0.0, 0.0)],
                Vec::new(),
            ),
        );
        let mut tester = CpuHitTester::new();
        tester.rebuild_from_layout(&results);
        assert!(
            tester.hit_test(p(10.0, 10.0)).is_empty(),
            "the child's local (10,10) is not its window position"
        );
        assert_eq!(
            tester.hit_test(p(120.0, 120.0)),
            vec![(dom(1), NodeId::new(1))],
            "translated into the host's VirtualView bounds"
        );
        assert!(
            tester.hit_test(p(180.0, 180.0)).is_empty(),
            "inside the child's 200x200 rect but outside the 50x50 composite clip"
        );
    }
    #[test]
    fn rebuild_accumulates_offsets_through_nested_virtual_views() {
        // dom0 --VV(10,10)--> dom1 --VV(5,5 local)--> dom2, whose node sits at
        // local (0,0): absolute origin must be (15,15).
        let mut results = BTreeMap::new();
        results.insert(
            dom(0),
            layout_result(
                styled(""),
                Vec::new(),
                Vec::new(),
                vec![virtual_view(1, r(10.0, 10.0, 200.0, 200.0))],
            ),
        );
        results.insert(
            dom(1),
            layout_result(
                styled(""),
                Vec::new(),
                Vec::new(),
                vec![virtual_view(2, r(5.0, 5.0, 100.0, 100.0))],
            ),
        );
        results.insert(
            dom(2),
            layout_result(
                styled(""),
                vec![hot(Some(1), Some((20.0, 20.0)), None)],
                vec![p(0.0, 0.0)],
                Vec::new(),
            ),
        );
        let mut tester = CpuHitTester::new();
        tester.rebuild_from_layout(&results);
        assert_eq!(
            tester.hit_test(p(16.0, 16.0)),
            vec![(dom(2), NodeId::new(1))]
        );
        assert!(
            tester.hit_test(p(14.0, 14.0)).is_empty(),
            "(14,14) is before the doubly-offset origin (15,15)"
        );
        assert!(tester.hit_test(p(36.0, 36.0)).is_empty());
    }
    #[test]
    fn rebuild_ignores_virtual_views_pointing_at_a_missing_child_dom() {
        let mut results = BTreeMap::new();
        results.insert(
            dom(0),
            layout_result(
                styled(""),
                vec![hot(Some(0), Some((10.0, 10.0)), None)],
                vec![p(0.0, 0.0)],
                vec![virtual_view(42, r(0.0, 0.0, 10.0, 10.0))],
            ),
        );
        let mut tester = CpuHitTester::new();
        tester.rebuild_from_layout(&results);
        assert_eq!(tester.node_rects_total(), 1);
        assert_eq!(tester.hit_test(p(5.0, 5.0)), vec![(dom(0), NodeId::ZERO)]);
    }
    #[test]
    fn rebuild_terminates_on_a_cyclic_virtual_view_graph() {
        // dom1 hosts dom2 and dom2 hosts dom1: neither is reachable from the root
        // dom, so neither gets placed. The placement loop is bounded, so this must
        // terminate (a hang here would freeze every layout pass).
        let mut results = BTreeMap::new();
        results.insert(
            dom(1),
            layout_result(
                styled(""),
                vec![hot(Some(1), Some((10.0, 10.0)), None)],
                vec![p(0.0, 0.0)],
                vec![virtual_view(2, r(1.0, 1.0, 10.0, 10.0))],
            ),
        );
        results.insert(
            dom(2),
            layout_result(
                styled(""),
                vec![hot(Some(1), Some((10.0, 10.0)), None)],
                vec![p(0.0, 0.0)],
                vec![virtual_view(1, r(2.0, 2.0, 10.0, 10.0))],
            ),
        );
        let mut tester = CpuHitTester::new();
        tester.rebuild_from_layout(&results);
        assert_eq!(tester.node_rects_total(), 2);
    }
    #[test]
    fn rebuild_handles_a_virtual_view_with_hostile_bounds() {
        // A NaN/infinite composite box must not produce a NaN clip that panics or
        // makes the child hit-testable everywhere.
        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX] {
            let mut results = BTreeMap::new();
            results.insert(
                dom(0),
                layout_result(
                    styled(""),
                    Vec::new(),
                    Vec::new(),
                    vec![virtual_view(1, r(bad, bad, bad, bad))],
                ),
            );
            results.insert(
                dom(1),
                layout_result(
                    styled(""),
                    vec![hot(Some(1), Some((20.0, 20.0)), None)],
                    vec![p(0.0, 0.0)],
                    Vec::new(),
                ),
            );
            let mut tester = CpuHitTester::new();
            tester.rebuild_from_layout(&results);
            assert_eq!(tester.node_rects_total(), 1);
            // Whatever the clip degenerates to, hit testing must not panic.
            let _ = tester.hit_test(p(10.0, 10.0));
            let _ = tester.hit_test(p(f32::NAN, 0.0));
        }
    }
    // -----------------------------------------------------------------------
    // compute_node_clip  (numeric)
    // -----------------------------------------------------------------------
    #[test]
    fn compute_node_clip_without_ancestors_or_dom_clip_is_unclipped() {
        let styled_dom = styled("");
        let nodes = vec![hot(Some(0), Some((10.0, 10.0)), None)];
        let positions: PositionVec = vec![p(0.0, 0.0)];
        assert_eq!(
            compute_node_clip(&styled_dom, &nodes, &positions, 0, p(0.0, 0.0), None),
            None
        );
    }
    #[test]
    fn compute_node_clip_out_of_bounds_node_index_does_not_panic() {
        let styled_dom = styled("");
        let nodes: Vec<LayoutNodeHot> = Vec::new();
        let positions: PositionVec = Vec::new();
        for idx in [0_usize, 1, 999, usize::MAX] {
            assert_eq!(
                compute_node_clip(&styled_dom, &nodes, &positions, idx, p(0.0, 0.0), None),
                None
            );
            // ...and with a DOM clip it still returns exactly that clip.
            let clip = compute_node_clip(
                &styled_dom,
                &nodes,
                &positions,
                idx,
                p(0.0, 0.0),
                Some(r(1.0, 2.0, 3.0, 4.0)),
            );
            assert_eq!(clip, Some(r(1.0, 2.0, 3.0, 4.0)));
        }
    }
    #[test]
    fn compute_node_clip_round_trips_a_dom_clip_when_no_ancestor_clips() {
        // encode == decode: with no clipping ancestor the composite box must come
        // back byte-identical, offset included (the offset is already baked into
        // the placement, so it must NOT be applied twice).
        let styled_dom = styled("");
        let nodes = vec![hot(Some(0), Some((10.0, 10.0)), None)];
        let positions: PositionVec = vec![p(0.0, 0.0)];
        let dom_clip = r(100.0, 200.0, 50.0, 25.0);
        let clip = compute_node_clip(
            &styled_dom,
            &nodes,
            &positions,
            0,
            p(100.0, 200.0),
            Some(dom_clip),
        )
        .expect("dom_clip must survive");
        assert_eq!(clip.origin.x, dom_clip.origin.x);
        assert_eq!(clip.origin.y, dom_clip.origin.y);
        assert_eq!(clip.size.width, dom_clip.size.width);
        assert_eq!(clip.size.height, dom_clip.size.height);
    }
    #[test]
    fn compute_node_clip_never_lets_nan_escape_into_the_clip_rect() {
        let styled_dom = styled("");
        let nodes = vec![hot(Some(0), Some((10.0, 10.0)), None)];
        let positions: PositionVec = vec![p(0.0, 0.0)];
        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
            for dom_clip in [
                r(bad, 0.0, 10.0, 10.0),
                r(0.0, bad, 10.0, 10.0),
                r(0.0, 0.0, bad, 10.0),
                r(0.0, 0.0, 10.0, bad),
                r(bad, bad, bad, bad),
            ] {
                let clip = compute_node_clip(
                    &styled_dom,
                    &nodes,
                    &positions,
                    0,
                    p(0.0, 0.0),
                    Some(dom_clip),
                )
                .expect("a dom_clip always yields a clip");
                assert!(
                    clip.origin.x.is_finite()
                        && clip.origin.y.is_finite()
                        && clip.size.width.is_finite()
                        && clip.size.height.is_finite(),
                    "clip {clip:?} from dom_clip {dom_clip:?} must stay finite"
                );
                assert!(clip.size.width >= 0.0 && clip.size.height >= 0.0);
                assert!(
                    clip.max_x().is_finite() && clip.max_y().is_finite(),
                    "origin + size must not overflow to inf/NaN"
                );
                // point_in_rect over the result must be a real answer, not a NaN
                // comparison that silently drops every event.
                let _ = point_in_rect(p(0.0, 0.0), &clip);
            }
        }
    }
    #[test]
    fn compute_node_clip_clamps_an_infinite_dom_clip_to_clip_unbounded() {
        let styled_dom = styled("");
        let nodes = vec![hot(Some(0), Some((10.0, 10.0)), None)];
        let positions: PositionVec = vec![p(0.0, 0.0)];
        let clip = compute_node_clip(
            &styled_dom,
            &nodes,
            &positions,
            0,
            p(0.0, 0.0),
            Some(LogicalRect {
                origin: p(0.0, 0.0),
                size: LogicalSize {
                    width: f32::INFINITY,
                    height: f32::INFINITY,
                },
            }),
        )
        .expect("a dom_clip always yields a clip");
        assert_eq!(clip.origin.x, 0.0);
        assert_eq!(clip.origin.y, 0.0);
        assert_eq!(clip.size.width, CLIP_UNBOUNDED);
        assert_eq!(clip.size.height, CLIP_UNBOUNDED);
        assert!(point_in_rect(p(1.0e6, 1.0e6), &clip));
    }
    #[test]
    fn compute_node_clip_saturates_a_negative_sized_dom_clip_to_zero_not_negative() {
        let styled_dom = styled("");
        let nodes = vec![hot(Some(0), Some((10.0, 10.0)), None)];
        let positions: PositionVec = vec![p(0.0, 0.0)];
        let clip = compute_node_clip(
            &styled_dom,
            &nodes,
            &positions,
            0,
            p(0.0, 0.0),
            Some(r(100.0, 100.0, -50.0, -50.0)),
        )
        .expect("a dom_clip always yields a clip");
        assert_eq!(clip.size.width, 0.0);
        assert_eq!(clip.size.height, 0.0);
        assert!(!point_in_rect(p(100.0, 100.0), &clip));
        assert!(!point_in_rect(p(75.0, 75.0), &clip));
    }
    #[test]
    fn compute_node_clip_intersects_a_clipping_ancestor_with_the_dom_clip() {
        // ancestor div.clip at (10,10) 100x50; dom_clip (0,0) 60x60
        // => intersection (10,10) 50x50
        let styled_dom = styled("div.clip { overflow: hidden; }");
        let nodes = vec![
            hot(Some(0), Some((500.0, 500.0)), None),
            hot(Some(1), Some((100.0, 50.0)), Some(0)),
            hot(Some(2), Some((400.0, 400.0)), Some(1)),
        ];
        let positions: PositionVec = vec![p(0.0, 0.0), p(10.0, 10.0), p(10.0, 10.0)];
        let clip = compute_node_clip(
            &styled_dom,
            &nodes,
            &positions,
            2,
            p(0.0, 0.0),
            Some(r(0.0, 0.0, 60.0, 60.0)),
        )
        .expect("an overflow:hidden ancestor must clip");
        assert_eq!(clip.origin.x, 10.0);
        assert_eq!(clip.origin.y, 10.0);
        assert_eq!(clip.size.width, 50.0);
        assert_eq!(clip.size.height, 50.0);
    }
    #[test]
    fn compute_node_clip_applies_the_offset_to_the_ancestor_box() {
        let styled_dom = styled("div.clip { overflow: hidden; }");
        let nodes = vec![
            hot(Some(0), Some((500.0, 500.0)), None),
            hot(Some(1), Some((100.0, 50.0)), Some(0)),
            hot(Some(2), Some((400.0, 400.0)), Some(1)),
        ];
        let positions: PositionVec = vec![p(0.0, 0.0), p(10.0, 10.0), p(10.0, 10.0)];
        let clip = compute_node_clip(&styled_dom, &nodes, &positions, 2, p(1000.0, 2000.0), None)
            .expect("an overflow:hidden ancestor must clip");
        assert_eq!(clip.origin.x, 1010.0);
        assert_eq!(clip.origin.y, 2010.0);
        assert_eq!(clip.size.width, 100.0);
        assert_eq!(clip.size.height, 50.0);
    }
    #[test]
    fn compute_node_clip_leaves_the_unclipped_axis_unbounded() {
        // overflow-x: hidden / overflow-y: visible — the y axis must stay
        // unbounded (finite stand-in), not collapse onto the ancestor's box.
        let styled_dom = styled("div.clip { overflow-x: hidden; }");
        let nodes = vec![
            hot(Some(0), Some((500.0, 500.0)), None),
            hot(Some(1), Some((100.0, 50.0)), Some(0)),
            hot(Some(2), Some((400.0, 400.0)), Some(1)),
        ];
        let positions: PositionVec = vec![p(0.0, 0.0), p(10.0, 10.0), p(10.0, 10.0)];
        let clip = compute_node_clip(&styled_dom, &nodes, &positions, 2, p(0.0, 0.0), None)
            .expect("overflow-x: hidden must clip the x axis");
        assert_eq!(clip.origin.x, 10.0);
        assert_eq!(clip.size.width, 100.0);
        assert_eq!(clip.origin.y, -CLIP_UNBOUNDED);
        assert_eq!(clip.size.height, 2.0 * CLIP_UNBOUNDED);
        assert!(clip.max_y().is_finite());
        // A point far below the ancestor is still inside the clip (y unbounded),
        // but a point to the right of it is not.
        assert!(point_in_rect(p(50.0, 900_000.0), &clip));
        assert!(!point_in_rect(p(500.0, 20.0), &clip));
    }
    #[test]
    fn compute_node_clip_skips_a_clipping_ancestor_that_was_never_laid_out() {
        // used_size: None on the clipping ancestor => nothing to intersect with;
        // it must be skipped rather than contributing a garbage/zero box.
        let styled_dom = styled("div.clip { overflow: hidden; }");
        let nodes = vec![
            hot(Some(0), Some((500.0, 500.0)), None),
            hot(Some(1), None, Some(0)), // clips, but has no used_size
            hot(Some(2), Some((400.0, 400.0)), Some(1)),
        ];
        let positions: PositionVec = vec![p(0.0, 0.0), p(10.0, 10.0), p(10.0, 10.0)];
        assert_eq!(
            compute_node_clip(&styled_dom, &nodes, &positions, 2, p(0.0, 0.0), None),
            None
        );
    }
    #[test]
    fn compute_node_clip_terminates_on_a_parent_cycle() {
        // Two anonymous boxes that are each other's parent. The `guard` counter is
        // the only thing standing between this and an infinite loop inside a
        // hit-test rebuild.
        let styled_dom = styled("");
        let nodes = vec![
            hot(None, Some((10.0, 10.0)), Some(1)),
            hot(None, Some((10.0, 10.0)), Some(0)),
        ];
        let positions: PositionVec = vec![p(0.0, 0.0), p(0.0, 0.0)];
        assert_eq!(
            compute_node_clip(&styled_dom, &nodes, &positions, 0, p(0.0, 0.0), None),
            None
        );
        // The DOM clip still survives the bounded walk.
        assert_eq!(
            compute_node_clip(
                &styled_dom,
                &nodes,
                &positions,
                1,
                p(0.0, 0.0),
                Some(r(0.0, 0.0, 5.0, 5.0))
            ),
            Some(r(0.0, 0.0, 5.0, 5.0))
        );
    }
    #[test]
    fn compute_node_clip_terminates_on_a_self_parent_cycle() {
        let styled_dom = styled("");
        let nodes = vec![hot(None, Some((10.0, 10.0)), Some(0))];
        let positions: PositionVec = vec![p(0.0, 0.0)];
        assert_eq!(
            compute_node_clip(&styled_dom, &nodes, &positions, 0, p(0.0, 0.0), None),
            None
        );
    }
    #[test]
    fn compute_node_clip_tolerates_a_parent_index_past_the_end_of_the_node_slice() {
        let styled_dom = styled("");
        let nodes = vec![hot(Some(0), Some((10.0, 10.0)), Some(usize::MAX))];
        let positions: PositionVec = vec![p(0.0, 0.0)];
        assert_eq!(
            compute_node_clip(&styled_dom, &nodes, &positions, 0, p(0.0, 0.0), None),
            None
        );
    }
}