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

            
86
use std::collections::BTreeMap;
87

            
88
use 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)]
96
pub 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.
112
const DEFAULT_MAX_ITERATIONS: usize = 1000;
113

            
114
impl Default for HeadlessConfig {
115
1
    fn default() -> Self {
116
1
        Self {
117
1
            width: 800.0,
118
1
            height: 600.0,
119
1
            dpi_factor: 1.0,
120
1
            enable_rendering: false,
121
1
            max_iterations: Some(DEFAULT_MAX_ITERATIONS),
122
1
        }
123
1
    }
124
}
125

            
126
impl 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.
143
pub 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)]
151
struct 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

            
162
impl CpuHitTester {
163
    /// Create a new empty hit tester.
164
1
    pub fn new() -> Self {
165
1
        Self {
166
1
            node_rects: BTreeMap::new(),
167
1
        }
168
1
    }
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
1
    pub fn hit_test(
233
1
        &self,
234
1
        position: LogicalPosition,
235
1
    ) -> Vec<(DomId, NodeId)> {
236
1
        let mut results = Vec::new();
237

            
238
1
        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
1
        results
260
1
    }
261
}
262

            
263
/// Simple point-in-rect test.
264
4
fn point_in_rect(point: LogicalPosition, rect: &LogicalRect) -> bool {
265
4
    point.x >= rect.origin.x
266
3
        && point.x < rect.origin.x + rect.size.width
267
2
        && point.y >= rect.origin.y
268
2
        && point.y < rect.origin.y + rect.size.height
269
4
}
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")]
278
pub struct HeadlessRenderer {
279
    pub width: f32,
280
    pub height: f32,
281
    pub dpi_factor: f32,
282
}
283

            
284
#[cfg(feature = "cpurender")]
285
impl 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)]
318
mod tests {
319
    use super::*;
320

            
321
    #[test]
322
1
    fn test_headless_config_default() {
323
1
        let config = HeadlessConfig::default();
324
1
        assert_eq!(config.width, 800.0);
325
1
        assert_eq!(config.height, 600.0);
326
1
        assert_eq!(config.dpi_factor, 1.0);
327
1
        assert!(!config.enable_rendering);
328
1
        assert_eq!(config.max_iterations, Some(DEFAULT_MAX_ITERATIONS));
329
1
    }
330

            
331
    #[test]
332
1
    fn test_cpu_hit_tester_empty() {
333
1
        let tester = CpuHitTester::new();
334
1
        let results = tester.hit_test(LogicalPosition { x: 100.0, y: 100.0 });
335
1
        assert!(results.is_empty());
336
1
    }
337

            
338
    #[test]
339
1
    fn test_point_in_rect() {
340
1
        let rect = LogicalRect {
341
1
            origin: LogicalPosition { x: 10.0, y: 10.0 },
342
1
            size: LogicalSize {
343
1
                width: 100.0,
344
1
                height: 50.0,
345
1
            },
346
1
        };
347

            
348
        // Inside
349
1
        assert!(point_in_rect(LogicalPosition { x: 50.0, y: 30.0 }, &rect));
350
        // On edge
351
1
        assert!(point_in_rect(LogicalPosition { x: 10.0, y: 10.0 }, &rect));
352
        // Outside
353
1
        assert!(!point_in_rect(LogicalPosition { x: 5.0, y: 5.0 }, &rect));
354
1
        assert!(!point_in_rect(LogicalPosition { x: 200.0, y: 30.0 }, &rect));
355
1
    }
356
}