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 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}