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 backend replaces the WebRender GPU pipeline with a purely
11//! CPU-based approach. Here's how each resource type is managed:
12//!
13//! ```text
14//! ┌──────────────────────────────────────────────────────────┐
15//! │ Normal (GPU) Path │
16//! │ │
17//! │ LayoutWindow ──→ DisplayList ──→ WebRender ──→ GL │
18//! │ │ │ │
19//! │ │ RenderApi ←─── Renderer │
20//! │ │ (font/image │ │
21//! │ │ registration) AsyncHitTester │
22//! │ │ │
23//! └──────────────────────────────────────────────────────────┘
24//!
25//! ┌──────────────────────────────────────────────────────────┐
26//! │ Headless (CPU) Path │
27//! │ │
28//! │ LayoutWindow ──→ DisplayList ──→ cpurender ──→ PNG│
29//! │ │ │ │
30//! │ │ HeadlessResources (agg-rust │
31//! │ │ (font/image Pixmap) │
32//! │ │ management) │
33//! │ │ CpuHitTester │
34//! │ │ │
35//! └──────────────────────────────────────────────────────────┘
36//! ```
37//!
38//! ## Key Differences from GPU Path
39//!
40//! | Concern | GPU Path | Headless Path |
41//! |---------------------|-------------------------|------------------------|
42//! | Window | NSWindow / HWND / X11 | HeadlessWindow (no-op) |
43//! | OpenGL | GlContextPtr | None |
44//! | Renderer | webrender::Renderer | None (skip) |
45//! | RenderApi | WrRenderApi | None (skip) |
46//! | Hit Testing | AsyncHitTester (WR) | CpuHitTester (layout) |
47//! | Font Registration | RenderApi::add_font() | FontManager only |
48//! | Image Registration | RenderApi::add_image() | ImageCache only |
49//! | Frame Generation | generate_frame() + WR | generate_frame() only |
50//! | Screenshot | glReadPixels | cpurender → Pixmap |
51//! | Display List | WR DisplayList | solver3 DisplayList |
52//! | Present/Swap | swapBuffers | no-op |
53//!
54//! ## Resource Lifecycle (Headless)
55//!
56//! Fonts and images are managed entirely through `LayoutWindow`:
57//!
58//! ```text
59//! Font Loading:
60//! 1. FcFontCache discovers system fonts (same as GPU path)
61//! 2. FontManager loads + caches parsed fonts
62//! 3. TextLayoutCache shapes text and caches glyph positions
63//! 4. cpurender reads glyph outlines directly from ParsedFont
64//! (no GPU texture atlas needed)
65//!
66//! Image Loading:
67//! 1. ImageCache stores decoded images (same as GPU path)
68//! 2. cpurender blits pixels directly from DecodedImage
69//! (no GPU texture upload needed)
70//! ```
71//!
72//! ## Usage
73//!
74//! The headless backend is activated by setting `AZUL_HEADLESS=1`:
75//!
76//! ```bash
77//! AZUL_HEADLESS=1 ./my_azul_app
78//! ```
79//!
80//! Or combined with the debug server for remote inspection:
81//!
82//! ```bash
83//! AZUL_HEADLESS=1 AZ_DEBUG=1 ./my_azul_app
84//! ```
85
86use std::collections::BTreeMap;
87
88use azul_core::{
89 dom::{DomId, NodeId},
90 geom::{LogicalPosition, LogicalRect, LogicalSize},
91 resources::RendererResources,
92};
93
94/// Configuration for headless rendering.
95#[derive(Debug, Clone)]
96pub struct HeadlessConfig {
97 /// Logical window width in CSS pixels
98 pub width: f32,
99 /// Logical window height in CSS pixels
100 pub height: f32,
101 /// DPI scale factor (1.0 = 96 DPI, 2.0 = Retina)
102 pub dpi_factor: f32,
103 /// Whether to enable CPU rendering for screenshots
104 /// (false = layout-only mode, no pixel output)
105 pub enable_rendering: bool,
106 /// Maximum number of event loop iterations before auto-close
107 /// (prevents infinite loops in tests)
108 pub max_iterations: Option<usize>,
109}
110
111/// Default safety limit for event loop iterations in headless/test mode.
112const DEFAULT_MAX_ITERATIONS: usize = 1000;
113
114impl Default for HeadlessConfig {
115 fn default() -> Self {
116 Self {
117 width: 800.0,
118 height: 600.0,
119 dpi_factor: 1.0,
120 enable_rendering: false,
121 max_iterations: Some(DEFAULT_MAX_ITERATIONS),
122 }
123 }
124}
125
126impl HeadlessConfig {
127 /// Create with defaults. Viewport can be changed at runtime via
128 /// the debug server's `resize` command or E2E test JSON steps.
129 pub fn new() -> Self {
130 Self::default()
131 }
132}
133
134/// CPU-based hit tester that works without WebRender.
135///
136/// In the GPU path, hit testing is done by `AsyncHitTester` which queries
137/// WebRender's spatial tree. In headless mode, we do hit testing directly
138/// against the layout results (positioned rectangles).
139///
140/// This is actually simpler and faster than the WebRender path, since we
141/// don't need to go through the compositor's spatial tree — we just walk
142/// the layout result nodes and check point-in-rect.
143pub struct CpuHitTester {
144 /// Cached hit test results from the last layout.
145 /// Maps DomId -> list of (NodeId, positioned rect) sorted by paint order.
146 node_rects: BTreeMap<DomId, Vec<HitTestEntry>>,
147}
148
149/// A single entry in the CPU hit test acceleration structure.
150#[derive(Debug, Clone)]
151struct HitTestEntry {
152 /// The DOM node that this entry corresponds to.
153 node_id: NodeId,
154 /// Absolute position and size of this node in logical pixels.
155 rect: LogicalRect,
156 /// Clip rect (intersection of all ancestor overflow clips).
157 clip: Option<LogicalRect>,
158 /// Whether this node is pointer-events: none
159 pointer_events_none: bool,
160}
161
162impl CpuHitTester {
163 /// Create a new empty hit tester.
164 pub fn new() -> Self {
165 Self {
166 node_rects: BTreeMap::new(),
167 }
168 }
169
170 /// Sum of HitTestEntry counts across all DomIds (for leak probes).
171 pub fn node_rects_total(&self) -> usize {
172 self.node_rects.values().map(|v| v.len()).sum()
173 }
174
175 /// Rebuild the hit test structure from layout results.
176 ///
177 /// Called after each layout pass. Extracts positioned rectangles from
178 /// `LayoutWindow::layout_results` and builds a flat list for fast
179 /// point-in-rect testing.
180 pub fn rebuild_from_layout(
181 &mut self,
182 layout_results: &BTreeMap<DomId, crate::window::DomLayoutResult>,
183 ) {
184 self.node_rects.clear();
185
186 for (dom_id, layout_result) in layout_results {
187 let mut entries = Vec::new();
188
189 let positions = &layout_result.calculated_positions;
190 let nodes = &layout_result.layout_tree.nodes;
191
192 // Walk the layout nodes and their computed positions
193 for (idx, node) in nodes.iter().enumerate() {
194 // Only include nodes that map to a real DOM node
195 let node_id = match node.dom_node_id {
196 Some(id) => id,
197 None => continue, // skip anonymous boxes
198 };
199
200 // Get the position for this layout node
201 let pos = match positions.get(idx) {
202 Some(p) => *p,
203 None => continue,
204 };
205
206 // Get the computed size
207 let size = match node.used_size {
208 Some(s) => s,
209 None => continue,
210 };
211
212 let rect = LogicalRect {
213 origin: pos,
214 size,
215 };
216
217 entries.push(HitTestEntry {
218 node_id,
219 rect,
220 clip: None, // TODO: compute clip chains
221 pointer_events_none: false, // TODO: check CSS property
222 });
223 }
224
225 self.node_rects.insert(*dom_id, entries);
226 }
227 }
228
229 /// Perform a hit test at the given position.
230 ///
231 /// Returns nodes hit at (x, y) in reverse paint order (topmost first).
232 pub fn hit_test(
233 &self,
234 position: LogicalPosition,
235 ) -> Vec<(DomId, NodeId)> {
236 let mut results = Vec::new();
237
238 for (dom_id, entries) in &self.node_rects {
239 // Walk in reverse (last painted = topmost)
240 for entry in entries.iter().rev() {
241 if entry.pointer_events_none {
242 continue;
243 }
244
245 // Check clip rect first (if any)
246 if let Some(ref clip) = entry.clip {
247 if !point_in_rect(position, clip) {
248 continue;
249 }
250 }
251
252 // Check node rect
253 if point_in_rect(position, &entry.rect) {
254 results.push((*dom_id, entry.node_id));
255 }
256 }
257 }
258
259 results
260 }
261}
262
263/// Simple point-in-rect test.
264fn point_in_rect(point: LogicalPosition, rect: &LogicalRect) -> bool {
265 point.x >= rect.origin.x
266 && point.x < rect.origin.x + rect.size.width
267 && point.y >= rect.origin.y
268 && point.y < rect.origin.y + rect.size.height
269}
270
271/// Headless renderer for CPU-based screenshot capture.
272///
273/// Wraps `cpurender::render()` with headless-specific configuration.
274/// This is separate from `CpuCompositor` (which implements the `Compositor`
275/// trait for the WebRender software fallback path). The headless renderer
276/// operates entirely without WebRender.
277#[cfg(feature = "cpurender")]
278pub struct HeadlessRenderer {
279 pub width: f32,
280 pub height: f32,
281 pub dpi_factor: f32,
282}
283
284#[cfg(feature = "cpurender")]
285impl HeadlessRenderer {
286 /// Create a new headless renderer with the given dimensions.
287 pub fn new(width: f32, height: f32, dpi_factor: f32) -> Self {
288 Self {
289 width,
290 height,
291 dpi_factor,
292 }
293 }
294
295 /// Render a display list to a pixel buffer.
296 ///
297 /// Returns an `AzulPixmap` that can be saved as PNG.
298 pub fn render_frame(
299 &self,
300 display_list: &crate::solver3::display_list::DisplayList,
301 renderer_resources: &RendererResources,
302 ) -> Result<crate::cpurender::AzulPixmap, String> {
303 let mut glyph_cache = crate::glyph_cache::GlyphCache::new();
304 crate::cpurender::render(
305 display_list,
306 renderer_resources,
307 crate::cpurender::RenderOptions {
308 width: self.width,
309 height: self.height,
310 dpi_factor: self.dpi_factor,
311 },
312 &mut glyph_cache,
313 )
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn test_headless_config_default() {
323 let config = HeadlessConfig::default();
324 assert_eq!(config.width, 800.0);
325 assert_eq!(config.height, 600.0);
326 assert_eq!(config.dpi_factor, 1.0);
327 assert!(!config.enable_rendering);
328 assert_eq!(config.max_iterations, Some(DEFAULT_MAX_ITERATIONS));
329 }
330
331 #[test]
332 fn test_cpu_hit_tester_empty() {
333 let tester = CpuHitTester::new();
334 let results = tester.hit_test(LogicalPosition { x: 100.0, y: 100.0 });
335 assert!(results.is_empty());
336 }
337
338 #[test]
339 fn test_point_in_rect() {
340 let rect = LogicalRect {
341 origin: LogicalPosition { x: 10.0, y: 10.0 },
342 size: LogicalSize {
343 width: 100.0,
344 height: 50.0,
345 },
346 };
347
348 // Inside
349 assert!(point_in_rect(LogicalPosition { x: 50.0, y: 30.0 }, &rect));
350 // On edge
351 assert!(point_in_rect(LogicalPosition { x: 10.0, y: 10.0 }, &rect));
352 // Outside
353 assert!(!point_in_rect(LogicalPosition { x: 5.0, y: 5.0 }, &rect));
354 assert!(!point_in_rect(LogicalPosition { x: 200.0, y: 30.0 }, &rect));
355 }
356}