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}