Skip to main content

azul_layout/
headless.rs

1//! Headless backend for CPU-only rendering without a display server.
2//!
3//! This module provides the resource management and rendering pipeline for
4//! running Azul applications without any platform windowing APIs. It works
5//! in combination with `HeadlessWindow` (in `dll/src/desktop/shell2/headless/`) which
6//! provides the `PlatformWindow` trait implementation.
7//!
8//! # Architecture
9//!
10//! The headless path replaces the WebRender GPU pipeline with `cpurender`:
11//! `LayoutWindow → solver3 DisplayList → cpurender → PNG/Pixmap`. Compared to the
12//! GPU path there is no GL context, `webrender::Renderer`, or `RenderApi`; fonts
13//! and images are managed by `FontManager`/`ImageCache` and read directly by
14//! cpurender (no GPU texture atlas or upload), hit testing uses the layout-side
15//! `CpuHitTester` instead of WebRender's `AsyncHitTester`, and present/swap is a
16//! no-op.
17//!
18//! Activated with `AZUL_HEADLESS=1` (optionally `AZ_DEBUG=1` for the debug server).
19
20use std::collections::BTreeMap;
21
22use azul_core::{
23    dom::{DomId, NodeId},
24    geom::{LogicalPosition, LogicalRect, LogicalSize},
25    styled_dom::StyledDom,
26};
27
28use crate::solver3::{getters::{get_overflow_x, get_overflow_y}, layout_tree::LayoutNodeHot, PositionVec};
29
30/// Large finite half-extent used in place of `f32::INFINITY` for clip axes that
31/// are not constrained by any ancestor. Keeping it finite avoids `NaN` in
32/// `point_in_rect` (`origin + size` would be `inf - inf = NaN`) while staying
33/// far outside any realistic logical-pixel coordinate.
34const CLIP_UNBOUNDED: f32 = 1.0e7;
35
36/// CPU-based hit tester that works without `WebRender`.
37///
38/// In the GPU path, hit testing is done by `AsyncHitTester` which queries
39/// `WebRender`'s spatial tree. In headless mode, we do hit testing directly
40/// against the layout results (positioned rectangles).
41///
42/// This is actually simpler and faster than the `WebRender` path, since we
43/// don't need to go through the compositor's spatial tree — we just walk
44/// the layout result nodes and check point-in-rect.
45#[derive(Debug)]
46pub struct CpuHitTester {
47    /// Cached hit test results from the last layout.
48    /// Maps `DomId` -> list of (`NodeId`, positioned rect) sorted by paint order.
49    node_rects: BTreeMap<DomId, Vec<HitTestEntry>>,
50}
51
52/// A single entry in the CPU hit test acceleration structure.
53#[derive(Debug, Clone)]
54struct HitTestEntry {
55    /// The DOM node that this entry corresponds to.
56    node_id: NodeId,
57    /// Absolute position and size of this node in logical pixels.
58    rect: LogicalRect,
59    /// Clip rect (intersection of all ancestor overflow clips).
60    clip: Option<LogicalRect>,
61    /// Whether this node is pointer-events: none
62    pointer_events_none: bool,
63}
64
65impl Default for CpuHitTester {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl CpuHitTester {
72    /// Create a new empty hit tester.
73    #[must_use] pub const fn new() -> Self {
74        Self {
75            node_rects: BTreeMap::new(),
76        }
77    }
78
79    /// Sum of `HitTestEntry` counts across all `DomIds` (for leak probes).
80    #[must_use] pub fn node_rects_total(&self) -> usize {
81        self.node_rects.values().map(Vec::len).sum()
82    }
83
84    /// Rebuild the hit test structure from layout results.
85    ///
86    /// Called after each layout pass. Extracts positioned rectangles from
87    /// `LayoutWindow::layout_results` and builds a flat list for fast
88    /// point-in-rect testing.
89    pub fn rebuild_from_layout(
90        &mut self,
91        layout_results: &BTreeMap<DomId, crate::window::DomLayoutResult>,
92    ) {
93        self.node_rects.clear();
94
95        // VirtualView / iframe child DOMs lay out in CHILD-LOCAL coordinates
96        // (origin 0,0) but live on screen at the host VirtualView item's
97        // bounds. Hit entries must be TRANSLATED there and CLIPPED to the
98        // composite bounds — otherwise the child's nodes claim pointer events
99        // across the whole window (live bug: azul-maps' tile grid ate every
100        // click on the header toolbar, so the buttons never fired; the same
101        // escape the renderer had before intersect_clips()).
102        //
103        // Resolve placements iteratively so nested VirtualViews accumulate
104        // their host offsets (a child's own VirtualView item is in that
105        // child's local space).
106        let mut placements: BTreeMap<DomId, LogicalRect> = BTreeMap::new();
107        for _ in 0..4 {
108            // bounded depth; each pass resolves one nesting level
109            let mut changed = false;
110            for (host_dom, lr) in layout_results {
111                let host_offset = if host_dom.inner == 0 {
112                    Some(LogicalPosition::zero())
113                } else {
114                    placements.get(host_dom).map(|r| r.origin)
115                };
116                let Some(host_offset) = host_offset else { continue };
117                for item in &lr.display_list.items {
118                    if let crate::solver3::display_list::DisplayListItem::VirtualView {
119                        child_dom_id,
120                        bounds,
121                        ..
122                    } = item
123                    {
124                        let b = *bounds.inner();
125                        let absolute = LogicalRect {
126                            origin: LogicalPosition {
127                                x: b.origin.x + host_offset.x,
128                                y: b.origin.y + host_offset.y,
129                            },
130                            size: b.size,
131                        };
132                        if placements.get(child_dom_id) != Some(&absolute) {
133                            placements.insert(*child_dom_id, absolute);
134                            changed = true;
135                        }
136                    }
137                }
138            }
139            if !changed {
140                break;
141            }
142        }
143
144        for (dom_id, layout_result) in layout_results {
145            let mut entries = Vec::new();
146
147            let positions = &layout_result.calculated_positions;
148            let nodes = &layout_result.layout_tree.nodes;
149            let styled_dom = &layout_result.styled_dom;
150
151            // Child DOM: shift into window space + clip to the composite rect.
152            let (offset, dom_clip) = placements.get(dom_id).map_or_else(|| (LogicalPosition::zero(), None), |b| (b.origin, Some(*b)));
153
154            // Walk the layout nodes and their computed positions
155            for (idx, node) in nodes.iter().enumerate() {
156                // Only include nodes that map to a real DOM node
157                let Some(node_id) = node.dom_node_id else {
158                    continue; // skip anonymous boxes
159                };
160
161                // Get the position for this layout node
162                let pos = match positions.get(idx) {
163                    Some(p) => *p,
164                    None => continue,
165                };
166
167                // Get the computed size
168                let Some(size) = node.used_size else {
169                    continue;
170                };
171
172                let rect = LogicalRect {
173                    origin: LogicalPosition {
174                        x: pos.x + offset.x,
175                        y: pos.y + offset.y,
176                    },
177                    size,
178                };
179
180                // Clip this node to the intersection of the VirtualView composite
181                // bounds (`dom_clip`) and every `overflow: hidden | clip | scroll |
182                // auto` ancestor's box — otherwise a node that is scrolled/clipped
183                // out of its ancestor would still claim pointer events.
184                let clip = compute_node_clip(styled_dom, nodes, positions, idx, offset, dom_clip);
185
186                entries.push(HitTestEntry {
187                    node_id,
188                    rect,
189                    clip,
190                    // azul has no `pointer-events` CSS property yet, so every laid-out
191                    // node is hit-testable. Populate this from the styled DOM once such
192                    // a property is added to `azul_css`.
193                    pointer_events_none: false,
194                });
195            }
196
197            self.node_rects.insert(*dom_id, entries);
198        }
199    }
200
201    /// Perform a hit test at the given position.
202    ///
203    /// Returns nodes hit at (x, y) in reverse paint order (topmost first).
204    #[must_use] pub fn hit_test(
205        &self,
206        position: LogicalPosition,
207    ) -> Vec<(DomId, NodeId)> {
208        let mut results = Vec::new();
209
210        for (dom_id, entries) in &self.node_rects {
211            // Walk in reverse (last painted = topmost)
212            for entry in entries.iter().rev() {
213                if entry.pointer_events_none {
214                    continue;
215                }
216
217                // Check clip rect first (if any)
218                if let Some(ref clip) = entry.clip {
219                    if !point_in_rect(position, clip) {
220                        continue;
221                    }
222                }
223
224                // Check node rect
225                if point_in_rect(position, &entry.rect) {
226                    results.push((*dom_id, entry.node_id));
227                }
228            }
229        }
230
231        results
232    }
233}
234
235/// Simple point-in-rect test.
236fn point_in_rect(point: LogicalPosition, rect: &LogicalRect) -> bool {
237    point.x >= rect.origin.x
238        && point.x < rect.origin.x + rect.size.width
239        && point.y >= rect.origin.y
240        && point.y < rect.origin.y + rect.size.height
241}
242
243/// Compute the hit-test clip rect for a layout node: the intersection of the
244/// host `VirtualView` composite bounds (`dom_clip`) and every clipping ancestor's
245/// border box (any `overflow` other than `visible`).
246///
247/// Clipping is tracked per-axis because `overflow-x` / `overflow-y` are
248/// independent — an axis whose ancestors are all `overflow: visible` stays
249/// unbounded (stored as a large finite extent, see [`CLIP_UNBOUNDED`]). The
250/// ancestor box used is the border box (`used_size`); CSS clips at the padding
251/// edge, but the slightly larger border box is a safe over-inclusion for point
252/// hit-testing and avoids resolving padding/border here.
253#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
254fn compute_node_clip(
255    styled_dom: &StyledDom,
256    nodes: &[LayoutNodeHot],
257    positions: &PositionVec,
258    node_index: usize,
259    offset: LogicalPosition,
260    dom_clip: Option<LogicalRect>,
261) -> Option<LogicalRect> {
262    // Accumulate clip bounds per axis, seeded from the DOM-level composite clip.
263    let (mut min_x, mut min_y, mut max_x, mut max_y) = (
264        f32::NEG_INFINITY,
265        f32::NEG_INFINITY,
266        f32::INFINITY,
267        f32::INFINITY,
268    );
269    let mut has_clip = false;
270    if let Some(dc) = dom_clip {
271        min_x = dc.min_x();
272        min_y = dc.min_y();
273        max_x = dc.max_x();
274        max_y = dc.max_y();
275        has_clip = true;
276    }
277
278    // Walk ancestors. A node's own overflow clips its descendants, not itself, so
279    // we start at the parent. `guard` bounds the loop in case `parent` links ever
280    // form a cycle (they shouldn't, but a hit-test rebuild must never hang).
281    let styled_nodes = styled_dom.styled_nodes.as_container();
282    let mut cur = nodes.get(node_index).and_then(|n| n.parent);
283    let mut guard = 0usize;
284    while let Some(anc) = cur {
285        guard += 1;
286        if guard > nodes.len() {
287            break;
288        }
289        let Some(anc_node) = nodes.get(anc) else { break };
290        cur = anc_node.parent;
291
292        let Some(anc_dom_id) = anc_node.dom_node_id else {
293            continue;
294        };
295        let node_state = &styled_nodes[anc_dom_id].styled_node_state;
296        let clips_x = get_overflow_x(styled_dom, anc_dom_id, node_state).is_clipped();
297        let clips_y = get_overflow_y(styled_dom, anc_dom_id, node_state).is_clipped();
298        if !clips_x && !clips_y {
299            continue;
300        }
301        let (Some(pos), Some(size)) = (positions.get(anc), anc_node.used_size) else {
302            continue;
303        };
304        let (ax0, ay0) = (pos.x + offset.x, pos.y + offset.y);
305        if clips_x {
306            min_x = min_x.max(ax0);
307            max_x = max_x.min(ax0 + size.width);
308            has_clip = true;
309        }
310        if clips_y {
311            min_y = min_y.max(ay0);
312            max_y = max_y.min(ay0 + size.height);
313            has_clip = true;
314        }
315    }
316
317    if !has_clip {
318        return None;
319    }
320
321    // Replace any still-unbounded axis with a large finite extent so the stored
322    // rect's `origin + size` arithmetic stays finite (no `inf - inf = NaN`).
323    if !min_x.is_finite() {
324        min_x = -CLIP_UNBOUNDED;
325    }
326    if !min_y.is_finite() {
327        min_y = -CLIP_UNBOUNDED;
328    }
329    if !max_x.is_finite() {
330        max_x = CLIP_UNBOUNDED;
331    }
332    if !max_y.is_finite() {
333        max_y = CLIP_UNBOUNDED;
334    }
335
336    Some(LogicalRect {
337        origin: LogicalPosition { x: min_x, y: min_y },
338        size: LogicalSize {
339            width: (max_x - min_x).max(0.0),
340            height: (max_y - min_y).max(0.0),
341        },
342    })
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn test_cpu_hit_tester_empty() {
351        let tester = CpuHitTester::new();
352        let results = tester.hit_test(LogicalPosition { x: 100.0, y: 100.0 });
353        assert!(results.is_empty());
354    }
355
356    #[test]
357    fn test_point_in_rect() {
358        let rect = LogicalRect {
359            origin: LogicalPosition { x: 10.0, y: 10.0 },
360            size: LogicalSize {
361                width: 100.0,
362                height: 50.0,
363            },
364        };
365
366        // Inside
367        assert!(point_in_rect(LogicalPosition { x: 50.0, y: 30.0 }, &rect));
368        // On edge
369        assert!(point_in_rect(LogicalPosition { x: 10.0, y: 10.0 }, &rect));
370        // Outside
371        assert!(!point_in_rect(LogicalPosition { x: 5.0, y: 5.0 }, &rect));
372        assert!(!point_in_rect(LogicalPosition { x: 200.0, y: 30.0 }, &rect));
373    }
374}