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
        // VirtualView / iframe child DOMs lay out in CHILD-LOCAL coordinates
187
        // (origin 0,0) but live on screen at the host VirtualView item's
188
        // bounds. Hit entries must be TRANSLATED there and CLIPPED to the
189
        // composite bounds — otherwise the child's nodes claim pointer events
190
        // across the whole window (live bug: azul-maps' tile grid ate every
191
        // click on the header toolbar, so the buttons never fired; the same
192
        // escape the renderer had before intersect_clips()).
193
        //
194
        // Resolve placements iteratively so nested VirtualViews accumulate
195
        // their host offsets (a child's own VirtualView item is in that
196
        // child's local space).
197
        let mut placements: BTreeMap<DomId, LogicalRect> = BTreeMap::new();
198
        for _ in 0..4 {
199
            // bounded depth; each pass resolves one nesting level
200
            let mut changed = false;
201
            for (host_dom, lr) in layout_results {
202
                let host_offset = if host_dom.inner == 0 {
203
                    Some(LogicalPosition::zero())
204
                } else {
205
                    placements.get(host_dom).map(|r| r.origin)
206
                };
207
                let Some(host_offset) = host_offset else { continue };
208
                for item in lr.display_list.items.iter() {
209
                    if let crate::solver3::display_list::DisplayListItem::VirtualView {
210
                        child_dom_id,
211
                        bounds,
212
                        ..
213
                    } = item
214
                    {
215
                        let b = *bounds.inner();
216
                        let absolute = LogicalRect {
217
                            origin: LogicalPosition {
218
                                x: b.origin.x + host_offset.x,
219
                                y: b.origin.y + host_offset.y,
220
                            },
221
                            size: b.size,
222
                        };
223
                        if placements.get(child_dom_id) != Some(&absolute) {
224
                            placements.insert(*child_dom_id, absolute);
225
                            changed = true;
226
                        }
227
                    }
228
                }
229
            }
230
            if !changed {
231
                break;
232
            }
233
        }
234

            
235
        for (dom_id, layout_result) in layout_results {
236
            let mut entries = Vec::new();
237

            
238
            let positions = &layout_result.calculated_positions;
239
            let nodes = &layout_result.layout_tree.nodes;
240

            
241
            // Child DOM: shift into window space + clip to the composite rect.
242
            let (offset, dom_clip) = match placements.get(dom_id) {
243
                Some(b) => (b.origin, Some(*b)),
244
                None => (LogicalPosition::zero(), None),
245
            };
246

            
247
            // Walk the layout nodes and their computed positions
248
            for (idx, node) in nodes.iter().enumerate() {
249
                // Only include nodes that map to a real DOM node
250
                let node_id = match node.dom_node_id {
251
                    Some(id) => id,
252
                    None => continue, // skip anonymous boxes
253
                };
254

            
255
                // Get the position for this layout node
256
                let pos = match positions.get(idx) {
257
                    Some(p) => *p,
258
                    None => continue,
259
                };
260

            
261
                // Get the computed size
262
                let size = match node.used_size {
263
                    Some(s) => s,
264
                    None => continue,
265
                };
266

            
267
                let rect = LogicalRect {
268
                    origin: LogicalPosition {
269
                        x: pos.x + offset.x,
270
                        y: pos.y + offset.y,
271
                    },
272
                    size,
273
                };
274

            
275
                entries.push(HitTestEntry {
276
                    node_id,
277
                    rect,
278
                    clip: dom_clip,
279
                    pointer_events_none: false, // TODO: check CSS property
280
                });
281
            }
282

            
283
            self.node_rects.insert(*dom_id, entries);
284
        }
285
    }
286

            
287
    /// Perform a hit test at the given position.
288
    ///
289
    /// Returns nodes hit at (x, y) in reverse paint order (topmost first).
290
1
    pub fn hit_test(
291
1
        &self,
292
1
        position: LogicalPosition,
293
1
    ) -> Vec<(DomId, NodeId)> {
294
1
        let mut results = Vec::new();
295

            
296
1
        for (dom_id, entries) in &self.node_rects {
297
            // Walk in reverse (last painted = topmost)
298
            for entry in entries.iter().rev() {
299
                if entry.pointer_events_none {
300
                    continue;
301
                }
302

            
303
                // Check clip rect first (if any)
304
                if let Some(ref clip) = entry.clip {
305
                    if !point_in_rect(position, clip) {
306
                        continue;
307
                    }
308
                }
309

            
310
                // Check node rect
311
                if point_in_rect(position, &entry.rect) {
312
                    results.push((*dom_id, entry.node_id));
313
                }
314
            }
315
        }
316

            
317
1
        results
318
1
    }
319
}
320

            
321
/// Simple point-in-rect test.
322
4
fn point_in_rect(point: LogicalPosition, rect: &LogicalRect) -> bool {
323
4
    point.x >= rect.origin.x
324
3
        && point.x < rect.origin.x + rect.size.width
325
2
        && point.y >= rect.origin.y
326
2
        && point.y < rect.origin.y + rect.size.height
327
4
}
328

            
329
/// Headless renderer for CPU-based screenshot capture.
330
///
331
/// Wraps `cpurender::render()` with headless-specific configuration.
332
/// This is separate from `CpuCompositor` (which implements the `Compositor`
333
/// trait for the WebRender software fallback path). The headless renderer
334
/// operates entirely without WebRender.
335
#[cfg(feature = "cpurender")]
336
pub struct HeadlessRenderer {
337
    pub width: f32,
338
    pub height: f32,
339
    pub dpi_factor: f32,
340
}
341

            
342
#[cfg(feature = "cpurender")]
343
impl HeadlessRenderer {
344
    /// Create a new headless renderer with the given dimensions.
345
    pub fn new(width: f32, height: f32, dpi_factor: f32) -> Self {
346
        Self {
347
            width,
348
            height,
349
            dpi_factor,
350
        }
351
    }
352

            
353
    /// Render a display list to a pixel buffer.
354
    ///
355
    /// Returns an `AzulPixmap` that can be saved as PNG.
356
    pub fn render_frame(
357
        &self,
358
        display_list: &crate::solver3::display_list::DisplayList,
359
        renderer_resources: &RendererResources,
360
    ) -> Result<crate::cpurender::AzulPixmap, String> {
361
        let mut glyph_cache = crate::glyph_cache::GlyphCache::new();
362
        crate::cpurender::render(
363
            display_list,
364
            renderer_resources,
365
            crate::cpurender::RenderOptions {
366
                width: self.width,
367
                height: self.height,
368
                dpi_factor: self.dpi_factor,
369
            },
370
            &mut glyph_cache,
371
        )
372
    }
373
}
374

            
375
#[cfg(test)]
376
mod tests {
377
    use super::*;
378

            
379
    #[test]
380
1
    fn test_headless_config_default() {
381
1
        let config = HeadlessConfig::default();
382
1
        assert_eq!(config.width, 800.0);
383
1
        assert_eq!(config.height, 600.0);
384
1
        assert_eq!(config.dpi_factor, 1.0);
385
1
        assert!(!config.enable_rendering);
386
1
        assert_eq!(config.max_iterations, Some(DEFAULT_MAX_ITERATIONS));
387
1
    }
388

            
389
    #[test]
390
1
    fn test_cpu_hit_tester_empty() {
391
1
        let tester = CpuHitTester::new();
392
1
        let results = tester.hit_test(LogicalPosition { x: 100.0, y: 100.0 });
393
1
        assert!(results.is_empty());
394
1
    }
395

            
396
    #[test]
397
1
    fn test_point_in_rect() {
398
1
        let rect = LogicalRect {
399
1
            origin: LogicalPosition { x: 10.0, y: 10.0 },
400
1
            size: LogicalSize {
401
1
                width: 100.0,
402
1
                height: 50.0,
403
1
            },
404
1
        };
405

            
406
        // Inside
407
1
        assert!(point_in_rect(LogicalPosition { x: 50.0, y: 30.0 }, &rect));
408
        // On edge
409
1
        assert!(point_in_rect(LogicalPosition { x: 10.0, y: 10.0 }, &rect));
410
        // Outside
411
1
        assert!(!point_in_rect(LogicalPosition { x: 5.0, y: 5.0 }, &rect));
412
1
        assert!(!point_in_rect(LogicalPosition { x: 200.0, y: 30.0 }, &rect));
413
1
    }
414
}