1
//! CPU rendering for solver3 DisplayList
2
//!
3
//! This module renders a flat DisplayList (from solver3) to an AzulPixmap using agg-rust.
4
//! Unlike the old hierarchical CachedDisplayList, the new DisplayList is a simple
5
//! flat vector of rendering commands that can be executed sequentially.
6

            
7
use std::collections::HashMap;
8

            
9
use azul_core::{
10
    dom::ScrollbarOrientation,
11
    geom::{LogicalPosition, LogicalRect, LogicalSize},
12
    resources::{DecodedImage, FontInstanceKey, ImageRef, RendererResources},
13
    ui_solver::GlyphInstance,
14
};
15
use azul_css::props::basic::{pixel::DEFAULT_FONT_SIZE, ColorOrSystem, ColorU, FontRef};
16
use azul_css::props::style::filter::StyleFilter;
17

            
18
use agg_rust::{
19
    basics::{FillingRule, VertexSource, PATH_FLAGS_NONE},
20
    blur::stack_blur_rgba32,
21
    color::Rgba8,
22
    conv_stroke::ConvStroke,
23
    conv_transform::ConvTransform,
24
    gradient_lut::GradientLut,
25
    path_storage::PathStorage,
26
    pixfmt_rgba::{PixelFormat, PixfmtRgba32},
27
    rasterizer_scanline_aa::RasterizerScanlineAa,
28
    renderer_base::RendererBase,
29
    renderer_scanline::{render_scanlines_aa, render_scanlines_aa_solid},
30
    rendering_buffer::RowAccessor,
31
    rounded_rect::RoundedRect,
32
    scanline_u::ScanlineU8,
33
    span_allocator::SpanAllocator,
34
    span_gradient::{GradientConic, GradientFunction, GradientRadialD, GradientX, SpanGradient},
35
    span_interpolator_linear::SpanInterpolatorLinear,
36
    trans_affine::TransAffine,
37
};
38

            
39
use crate::{
40
    font::parsed::ParsedFont,
41
    glyph_cache::GlyphCache,
42
    solver3::display_list::{BorderRadius, DisplayList, DisplayListItem, LocalScrollId},
43
    text3::cache::{FontHash, FontManager},
44
};
45

            
46
const IDENTITY_EPSILON: f32 = 0.0001;
47
const IDENTITY_EPSILON_F64: f64 = 0.0001;
48
const MAX_SHADOW_PIXBUF_SIZE: u32 = 4096;
49

            
50
// ============================================================================
51
// Retained-Mode Compositor — Layer Tree
52
// ============================================================================
53

            
54
/// Unique identifier for a compositing layer.
55
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56
pub struct LayerId(pub u64);
57

            
58
/// Persistent compositor state across frames.
59
///
60
/// Holds a tree of `Layer`s, each with its own pixbuf. On incremental updates
61
/// only damaged layers are re-rendered, and scroll is handled by pixel-shift.
62
pub struct CompositorState {
63
    /// All layers keyed by ID.
64
    pub layers: HashMap<LayerId, Layer>,
65
    /// Root layer of the tree.
66
    pub root_layer: LayerId,
67
    /// Monotonic counter for generating unique LayerIds.
68
    next_layer_id: u64,
69
    /// Previous frame's per-node positions, used for damage computation.
70
    pub previous_positions: Vec<LogicalPosition>,
71
}
72

            
73
/// A single compositing layer with its own pixel buffer.
74
pub struct Layer {
75
    pub id: LayerId,
76
    /// Persistent RGBA buffer for this layer's content.
77
    pub pixbuf: AzulPixmap,
78
    /// Position and size in parent layer coordinates.
79
    pub bounds: LogicalRect,
80
    /// Dirty regions that need re-rendering this frame.
81
    pub damage: Vec<LogicalRect>,
82
    /// Child layers in z-order (bottom to top).
83
    pub children: Vec<LayerId>,
84
    /// Current scroll offset (for scroll-frame layers).
85
    pub scroll_offset: (f32, f32),
86
    /// Layer opacity (1.0 = fully opaque).
87
    pub opacity: f32,
88
    /// CSS filters applied at composite time.
89
    pub filters: Vec<StyleFilter>,
90
    /// CSS transform for this layer.
91
    pub transform: TransAffine,
92
    /// Range of display list items [start, end) that render into this layer.
93
    pub display_list_range: (usize, usize),
94
    /// If this layer is a scroll frame, the scroll ID.
95
    pub scroll_id: Option<LocalScrollId>,
96
    /// Whether this layer needs re-compositing onto its parent.
97
    pub composite_dirty: bool,
98
}
99

            
100
/// Reason a layer was created.
101
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102
pub enum LayerReason {
103
    /// Root layer (always exists).
104
    Root,
105
    /// Created for a `PushScrollFrame`.
106
    ScrollFrame,
107
    /// Created for a `PushFilter` containing blur.
108
    BlurFilter,
109
    /// Created for a `PushOpacity` with opacity < 1.0.
110
    Opacity,
111
    /// Created for a `PushReferenceFrame` with non-identity transform.
112
    Transform,
113
}
114

            
115
impl CompositorState {
116
    /// Create a new compositor with a root layer sized to the viewport.
117
88
    pub fn new(width: u32, height: u32) -> Self {
118
88
        let root_id = LayerId(0);
119
88
        let root_layer = Layer::new(
120
88
            root_id,
121
88
            LogicalRect {
122
88
                origin: LogicalPosition::zero(),
123
88
                size: LogicalSize {
124
88
                    width: width as f32,
125
88
                    height: height as f32,
126
88
                },
127
88
            },
128
88
            width,
129
88
            height,
130
        );
131
88
        let mut layers = HashMap::new();
132
88
        layers.insert(root_id, root_layer);
133
88
        CompositorState {
134
88
            layers,
135
88
            root_layer: root_id,
136
88
            next_layer_id: 1,
137
88
            previous_positions: Vec::new(),
138
88
        }
139
88
    }
140

            
141
    /// Allocate a new unique layer ID.
142
    pub fn alloc_layer_id(&mut self) -> LayerId {
143
        let id = LayerId(self.next_layer_id);
144
        self.next_layer_id += 1;
145
        id
146
    }
147

            
148
    /// Read-only peek at the next layer ID counter (for leak probes).
149
    pub fn next_layer_id_peek(&self) -> u64 {
150
        self.next_layer_id
151
    }
152

            
153
    /// Walk the display list and create layers for scroll frames, filters, opacity, transforms.
154
    /// Returns a mapping from display-list item index to the LayerId it should render into.
155
88
    pub fn allocate_layers_from_display_list(
156
88
        &mut self,
157
88
        display_list: &DisplayList,
158
88
        dpi_factor: f32,
159
88
    ) {
160
        // Remove all non-root layers from previous frame
161
88
        let root_id = self.root_layer;
162
88
        self.layers.retain(|id, _| *id == root_id);
163
88
        if let Some(root) = self.layers.get_mut(&root_id) {
164
88
            root.children.clear();
165
88
            root.damage.clear();
166
88
            root.display_list_range = (0, display_list.items.len());
167
88
            root.composite_dirty = true;
168
88
        }
169

            
170
88
        let mut layer_stack: Vec<LayerId> = vec![root_id];
171
88
        let mut i = 0;
172

            
173
3344
        while i < display_list.items.len() {
174
3256
            match &display_list.items[i] {
175
                DisplayListItem::PushScrollFrame {
176
                    clip_bounds,
177
                    content_size,
178
                    scroll_id,
179
                    ..
180
                } => {
181
                    let bounds = *clip_bounds.inner();
182
                    let pw = (bounds.size.width * dpi_factor).ceil() as u32;
183
                    let ph = (bounds.size.height * dpi_factor).ceil() as u32;
184
                    if pw > 0 && ph > 0 {
185
                        let new_id = self.alloc_layer_id();
186
                        let mut layer = Layer::new(new_id, bounds, pw, ph);
187
                        layer.scroll_id = Some(*scroll_id);
188
                        // Find the matching PopScrollFrame to set range
189
                        let end = find_matching_pop(&display_list.items, i, MatchKind::ScrollFrame);
190
                        layer.display_list_range = (i + 1, end);
191
                        self.layers.insert(new_id, layer);
192
                        // Add as child of current parent
193
                        let parent_id = *layer_stack.last().unwrap();
194
                        if let Some(parent) = self.layers.get_mut(&parent_id) {
195
                            parent.children.push(new_id);
196
                        }
197
                        layer_stack.push(new_id);
198
                    }
199
                }
200
                DisplayListItem::PopScrollFrame => {
201
                    if layer_stack.len() > 1 {
202
                        layer_stack.pop();
203
                    }
204
                }
205
                DisplayListItem::PushOpacity { bounds, opacity } => {
206
                    if *opacity < 1.0 {
207
                        let b = *bounds.inner();
208
                        let pw = (b.size.width * dpi_factor).ceil() as u32;
209
                        let ph = (b.size.height * dpi_factor).ceil() as u32;
210
                        if pw > 0 && ph > 0 {
211
                            let new_id = self.alloc_layer_id();
212
                            let mut layer = Layer::new(new_id, b, pw, ph);
213
                            layer.opacity = *opacity;
214
                            let end = find_matching_pop(&display_list.items, i, MatchKind::Opacity);
215
                            layer.display_list_range = (i + 1, end);
216
                            self.layers.insert(new_id, layer);
217
                            let parent_id = *layer_stack.last().unwrap();
218
                            if let Some(parent) = self.layers.get_mut(&parent_id) {
219
                                parent.children.push(new_id);
220
                            }
221
                            layer_stack.push(new_id);
222
                        }
223
                    }
224
                }
225
                DisplayListItem::PopOpacity => {
226
                    // Only pop if the top layer was an opacity layer
227
                    if layer_stack.len() > 1 {
228
                        let top_id = *layer_stack.last().unwrap();
229
                        if let Some(layer) = self.layers.get(&top_id) {
230
                            if layer.opacity < 1.0 && layer.scroll_id.is_none() {
231
                                layer_stack.pop();
232
                            }
233
                        }
234
                    }
235
                }
236
                DisplayListItem::PushFilter { bounds, filters } => {
237
                    let has_blur = filters.iter().any(|f| matches!(f, StyleFilter::Blur(_)));
238
                    if has_blur {
239
                        let b = *bounds.inner();
240
                        let pw = (b.size.width * dpi_factor).ceil() as u32;
241
                        let ph = (b.size.height * dpi_factor).ceil() as u32;
242
                        if pw > 0 && ph > 0 {
243
                            let new_id = self.alloc_layer_id();
244
                            let mut layer = Layer::new(new_id, b, pw, ph);
245
                            layer.filters = filters.clone();
246
                            let end = find_matching_pop(&display_list.items, i, MatchKind::Filter);
247
                            layer.display_list_range = (i + 1, end);
248
                            self.layers.insert(new_id, layer);
249
                            let parent_id = *layer_stack.last().unwrap();
250
                            if let Some(parent) = self.layers.get_mut(&parent_id) {
251
                                parent.children.push(new_id);
252
                            }
253
                            layer_stack.push(new_id);
254
                        }
255
                    }
256
                }
257
                DisplayListItem::PopFilter => {
258
                    if layer_stack.len() > 1 {
259
                        let top_id = *layer_stack.last().unwrap();
260
                        if let Some(layer) = self.layers.get(&top_id) {
261
                            if !layer.filters.is_empty() {
262
                                layer_stack.pop();
263
                            }
264
                        }
265
                    }
266
                }
267
                DisplayListItem::PushReferenceFrame {
268
                    initial_transform,
269
                    bounds,
270
                    ..
271
                } => {
272
                    let m = &initial_transform.m;
273
                    let is_identity = (m[0][0] - 1.0).abs() < IDENTITY_EPSILON
274
                        && m[0][1].abs() < IDENTITY_EPSILON
275
                        && m[1][0].abs() < IDENTITY_EPSILON
276
                        && (m[1][1] - 1.0).abs() < IDENTITY_EPSILON
277
                        && m[3][0].abs() < IDENTITY_EPSILON
278
                        && m[3][1].abs() < IDENTITY_EPSILON;
279
                    if !is_identity {
280
                        let b = *bounds.inner();
281
                        let pw = (b.size.width * dpi_factor).ceil().max(1.0) as u32;
282
                        let ph = (b.size.height * dpi_factor).ceil().max(1.0) as u32;
283
                        let new_id = self.alloc_layer_id();
284
                        let mut layer = Layer::new(new_id, b, pw, ph);
285
                        layer.transform = TransAffine::new_custom(
286
                            m[0][0] as f64,
287
                            m[0][1] as f64,
288
                            m[1][0] as f64,
289
                            m[1][1] as f64,
290
                            m[3][0] as f64,
291
                            m[3][1] as f64,
292
                        );
293
                        let end =
294
                            find_matching_pop(&display_list.items, i, MatchKind::ReferenceFrame);
295
                        layer.display_list_range = (i + 1, end);
296
                        self.layers.insert(new_id, layer);
297
                        let parent_id = *layer_stack.last().unwrap();
298
                        if let Some(parent) = self.layers.get_mut(&parent_id) {
299
                            parent.children.push(new_id);
300
                        }
301
                        layer_stack.push(new_id);
302
                    }
303
                }
304
                DisplayListItem::PopReferenceFrame => {
305
                    if layer_stack.len() > 1 {
306
                        let top_id = *layer_stack.last().unwrap();
307
                        if let Some(layer) = self.layers.get(&top_id) {
308
                            if !layer.transform.is_identity(IDENTITY_EPSILON_F64) {
309
                                layer_stack.pop();
310
                            }
311
                        }
312
                    }
313
                }
314
3256
                _ => {}
315
            }
316
3256
            i += 1;
317
        }
318
88
    }
319

            
320
    /// Compute damage rects from dirty node sets and old/new positions.
321
    pub fn compute_damage(
322
        &mut self,
323
        dirty_nodes: &std::collections::BTreeSet<usize>,
324
        old_positions: &[LogicalPosition],
325
        new_positions: &[LogicalPosition],
326
        calculated_rects: &[LogicalRect],
327
    ) {
328
        if dirty_nodes.is_empty() {
329
            return;
330
        }
331

            
332
        let mut damage_rects = Vec::new();
333
        for &node_idx in dirty_nodes {
334
            // Old bounds
335
            if node_idx < old_positions.len() && node_idx < calculated_rects.len() {
336
                let old_rect = LogicalRect {
337
                    origin: old_positions[node_idx],
338
                    size: calculated_rects[node_idx].size,
339
                };
340
                damage_rects.push(old_rect);
341
            }
342
            // New bounds
343
            if node_idx < new_positions.len() && node_idx < calculated_rects.len() {
344
                let new_rect = LogicalRect {
345
                    origin: new_positions[node_idx],
346
                    size: calculated_rects[node_idx].size,
347
                };
348
                damage_rects.push(new_rect);
349
            }
350
        }
351

            
352
        // Distribute damage rects to affected layers
353
        for (_, layer) in self.layers.iter_mut() {
354
            for damage in &damage_rects {
355
                if let Some(intersection) = rect_intersection(&layer.bounds, damage) {
356
                    layer.damage.push(intersection);
357
                    layer.composite_dirty = true;
358
                }
359
            }
360
        }
361
    }
362

            
363
    /// Render display list items into their respective layer pixbufs.
364
88
    pub fn render_layers(
365
88
        &mut self,
366
88
        display_list: &DisplayList,
367
88
        dpi_factor: f32,
368
88
        renderer_resources: &RendererResources,
369
88
        font_manager: Option<&FontManager<FontRef>>,
370
88
        glyph_cache: &mut GlyphCache,
371
88
        render_state: &CpuRenderState,
372
88
    ) -> Result<(), String> {
373
88
        let scroll_offsets = &render_state.scroll_offsets;
374
        // Collect layer IDs, ranges, bounds, scroll_id and child ranges.
375
88
        let layer_ranges: Vec<(
376
88
            LayerId,
377
88
            (usize, usize),
378
88
            LogicalRect,
379
88
            Option<LocalScrollId>,
380
88
            Vec<(usize, usize)>,
381
88
        )> = self
382
88
            .layers
383
88
            .iter()
384
88
            .map(|(id, layer)| {
385
                // Ranges of this layer's DIRECT children (nested scroll frames /
386
                // opacity / transform groups). They render into their own
387
                // pixbufs, so they must be skipped when rendering this layer's
388
                // range (which, for the root, spans the whole display list).
389
88
                let child_ranges: Vec<(usize, usize)> = layer
390
88
                    .children
391
88
                    .iter()
392
88
                    .filter_map(|cid| self.layers.get(cid).map(|c| c.display_list_range))
393
88
                    .collect();
394
88
                (*id, layer.display_list_range, layer.bounds, layer.scroll_id, child_ranges)
395
88
            })
396
88
            .collect();
397

            
398
        #[cfg(feature = "std")]
399
88
        if std::env::var("AZ_MAP_DEBUG").is_ok() {
400
            for (id, range, bounds, scroll_id, child_ranges) in &layer_ranges {
401
                std::eprintln!(
402
                    "[cpu-layer] render id={:?} range={:?} bounds={:?} scroll={:?} skip={:?} (dl_len={})",
403
                    id, range, bounds, scroll_id, child_ranges, display_list.items.len()
404
                );
405
            }
406
88
        }
407

            
408
176
        for (layer_id, range, layer_bounds, scroll_id, child_ranges) in &layer_ranges {
409
88
            let (start, end) = *range;
410
88
            if start >= end || start >= display_list.items.len() {
411
                continue;
412
88
            }
413

            
414
            // This layer's scroll offset (0 for non-scroll layers). Content inside
415
            // a scroll frame is at absolute coords; the renderer draws at
416
            // `pos - seed`, so folding the scroll offset into the seed shifts the
417
            // frame's content within its own pixbuf. composite_frame blits the
418
            // pixbuf back at `layer.bounds.origin` (NOT scroll_offset), so applying
419
            // it here is the single place — no double offset. (Without this, a full
420
            // repaint while scrolled drew content at offset 0.)
421
88
            let soff = scroll_id
422
88
                .and_then(|id| scroll_offsets.get(&id).copied())
423
88
                .unwrap_or((0.0, 0.0));
424

            
425
88
            let layer = self.layers.get_mut(layer_id).unwrap();
426
88
            layer.scroll_offset = soff;
427

            
428
            // Clear the layer pixbuf (transparent for non-root, white for root)
429
88
            if *layer_id == self.root_layer {
430
88
                layer.pixbuf.fill(255, 255, 255, 255);
431
88
            } else {
432
                layer.pixbuf.fill(0, 0, 0, 0);
433
            }
434

            
435
            // Seed = layer origin (for pixbuf-local placement) + scroll offset.
436
88
            let offset_x = layer_bounds.origin.x + soff.0;
437
88
            let offset_y = layer_bounds.origin.y + soff.1;
438
88
            render_display_list_range(
439
88
                display_list,
440
88
                &mut layer.pixbuf,
441
88
                start,
442
88
                end.min(display_list.items.len()),
443
88
                child_ranges,
444
88
                offset_x,
445
88
                offset_y,
446
88
                dpi_factor,
447
88
                renderer_resources,
448
88
                font_manager,
449
88
                glyph_cache,
450
88
                render_state,
451
            )?;
452
        }
453

            
454
88
        Ok(())
455
88
    }
456

            
457
    /// Composite all layers bottom-up into the final output pixmap.
458
88
    pub fn composite_frame(&self, output: &mut AzulPixmap, dpi_factor: f32) {
459
        // Start from root layer
460
88
        self.composite_layer_recursive(self.root_layer, output, 0.0, 0.0, dpi_factor);
461
88
    }
462

            
463
88
    fn composite_layer_recursive(
464
88
        &self,
465
88
        layer_id: LayerId,
466
88
        output: &mut AzulPixmap,
467
88
        parent_offset_x: f32,
468
88
        parent_offset_y: f32,
469
88
        dpi_factor: f32,
470
88
    ) {
471
88
        let layer = match self.layers.get(&layer_id) {
472
88
            Some(l) => l,
473
            None => return,
474
        };
475

            
476
88
        let abs_x = parent_offset_x + layer.bounds.origin.x;
477
88
        let abs_y = parent_offset_y + layer.bounds.origin.y;
478

            
479
        // For root layer, just blit directly
480
88
        if layer_id == self.root_layer {
481
88
            blit_pixmap(&layer.pixbuf, output, 0, 0, 1.0);
482
88
        } else {
483
            // Apply filters at composite time
484
            let src = if !layer.filters.is_empty() {
485
                let mut filtered = layer.pixbuf.clone_pixmap();
486
                apply_layer_filters(&mut filtered, &layer.filters, dpi_factor);
487
                Some(filtered)
488
            } else {
489
                None
490
            };
491

            
492
            let src_pixbuf = src.as_ref().unwrap_or(&layer.pixbuf);
493
            let px_x = (abs_x * dpi_factor) as i32;
494
            let px_y = (abs_y * dpi_factor) as i32;
495
            blit_pixmap(src_pixbuf, output, px_x, px_y, layer.opacity);
496
        }
497

            
498
        // Composite children in z-order
499
88
        let children: Vec<LayerId> = layer.children.clone();
500
88
        for child_id in &children {
501
            self.composite_layer_recursive(
502
                *child_id,
503
                output,
504
                if layer_id == self.root_layer {
505
                    0.0
506
                } else {
507
                    abs_x
508
                },
509
                if layer_id == self.root_layer {
510
                    0.0
511
                } else {
512
                    abs_y
513
                },
514
                dpi_factor,
515
            );
516
        }
517
88
    }
518

            
519
    /// Handle scroll by shifting pixels and re-rendering the exposed strip.
520
    pub fn scroll_layer(
521
        &mut self,
522
        scroll_id: LocalScrollId,
523
        new_offset: (f32, f32),
524
        display_list: &DisplayList,
525
        dpi_factor: f32,
526
        renderer_resources: &RendererResources,
527
        font_manager: Option<&FontManager<FontRef>>,
528
        glyph_cache: &mut GlyphCache,
529
    ) -> Result<(), String> {
530
        // Find the layer with this scroll_id
531
        let layer_id = self
532
            .layers
533
            .iter()
534
            .find(|(_, l)| l.scroll_id == Some(scroll_id))
535
            .map(|(id, _)| *id);
536

            
537
        let layer_id = match layer_id {
538
            Some(id) => id,
539
            None => return Ok(()), // No layer for this scroll ID
540
        };
541

            
542
        let layer = self.layers.get_mut(&layer_id).unwrap();
543
        let old_offset = layer.scroll_offset;
544
        let dx = new_offset.0 - old_offset.0;
545
        let dy = new_offset.1 - old_offset.1;
546

            
547
        if dx.abs() < 0.5 && dy.abs() < 0.5 {
548
            return Ok(());
549
        }
550

            
551
        // Shift pixels
552
        let px_dx = (dx * dpi_factor).round() as i32;
553
        let px_dy = (dy * dpi_factor).round() as i32;
554
        shift_pixbuf(&mut layer.pixbuf, px_dx, px_dy);
555

            
556
        // Compute exposed strips and re-render them.
557
        // Diagonal scroll produces 2 rects (one vertical strip + one horizontal strip).
558
        let exposed = compute_exposed_rects(&layer.bounds, dx, dy);
559
        for exposed_rect in exposed {
560
            layer.damage.push(exposed_rect);
561
        }
562

            
563
        layer.scroll_offset = new_offset;
564
        layer.composite_dirty = true;
565

            
566
        // Re-render damaged regions
567
        let range = layer.display_list_range;
568
        let bounds = layer.bounds;
569
        let offset_x = bounds.origin.x;
570
        let offset_y = bounds.origin.y;
571
        // Child-layer ranges to skip (rendered separately) — same as render_layers.
572
        let child_ranges: Vec<(usize, usize)> = self
573
            .layers
574
            .get(&layer_id)
575
            .map(|l| {
576
                l.children
577
                    .iter()
578
                    .filter_map(|cid| self.layers.get(cid).map(|c| c.display_list_range))
579
                    .collect()
580
            })
581
            .unwrap_or_default();
582
        // Scroll fast-path: VirtualView content (separate child DOMs) isn't
583
        // re-composited here — an empty state suffices (VirtualViews inside a
584
        // scrolling region are an edge case; the next full repaint composites them).
585
        let empty_rs = CpuRenderState::new(ScrollOffsetMap::new());
586
        render_display_list_range(
587
            display_list,
588
            &mut self.layers.get_mut(&layer_id).unwrap().pixbuf,
589
            range.0,
590
            range.1.min(display_list.items.len()),
591
            &child_ranges,
592
            offset_x,
593
            offset_y,
594
            dpi_factor,
595
            renderer_resources,
596
            font_manager,
597
            glyph_cache,
598
            &empty_rs,
599
        )?;
600

            
601
        Ok(())
602
    }
603
}
604

            
605
impl Layer {
606
88
    fn new(id: LayerId, bounds: LogicalRect, pixel_width: u32, pixel_height: u32) -> Self {
607
        Layer {
608
88
            id,
609
88
            pixbuf: AzulPixmap::new(pixel_width.max(1), pixel_height.max(1)).unwrap_or_else(|| {
610
                AzulPixmap {
611
                    data: vec![0; 4],
612
                    width: 1,
613
                    height: 1,
614
                }
615
            }),
616
88
            bounds,
617
88
            damage: Vec::new(),
618
88
            children: Vec::new(),
619
88
            scroll_offset: (0.0, 0.0),
620
            opacity: 1.0,
621
88
            filters: Vec::new(),
622
88
            transform: TransAffine::new(),
623
88
            display_list_range: (0, 0),
624
88
            scroll_id: None,
625
            composite_dirty: true,
626
        }
627
88
    }
628
}
629

            
630
// ============================================================================
631
// Layer helper types and functions
632
// ============================================================================
633

            
634
/// Which Push/Pop pair to match.
635
#[derive(Clone, Copy)]
636
enum MatchKind {
637
    ScrollFrame,
638
    Opacity,
639
    Filter,
640
    ReferenceFrame,
641
}
642

            
643
/// Find the matching Pop for a given Push at index `start`.
644
5
fn find_matching_pop(items: &[DisplayListItem], start: usize, kind: MatchKind) -> usize {
645
5
    let mut depth = 1u32;
646
10
    for i in (start + 1)..items.len() {
647
10
        match (&items[i], kind) {
648
            (DisplayListItem::PushScrollFrame { .. }, MatchKind::ScrollFrame) => depth += 1,
649
            (DisplayListItem::PopScrollFrame, MatchKind::ScrollFrame) => {
650
5
                depth -= 1;
651
5
                if depth == 0 {
652
5
                    return i;
653
                }
654
            }
655
            (DisplayListItem::PushOpacity { .. }, MatchKind::Opacity) => depth += 1,
656
            (DisplayListItem::PopOpacity, MatchKind::Opacity) => {
657
                depth -= 1;
658
                if depth == 0 {
659
                    return i;
660
                }
661
            }
662
            (DisplayListItem::PushFilter { .. }, MatchKind::Filter) => depth += 1,
663
            (DisplayListItem::PopFilter, MatchKind::Filter) => {
664
                depth -= 1;
665
                if depth == 0 {
666
                    return i;
667
                }
668
            }
669
            (DisplayListItem::PushReferenceFrame { .. }, MatchKind::ReferenceFrame) => depth += 1,
670
            (DisplayListItem::PopReferenceFrame, MatchKind::ReferenceFrame) => {
671
                depth -= 1;
672
                if depth == 0 {
673
                    return i;
674
                }
675
            }
676
5
            _ => {}
677
        }
678
    }
679
    items.len()
680
5
}
681

            
682
/// Compute the intersection of two logical rects.
683
fn rect_intersection(a: &LogicalRect, b: &LogicalRect) -> Option<LogicalRect> {
684
    let x1 = a.origin.x.max(b.origin.x);
685
    let y1 = a.origin.y.max(b.origin.y);
686
    let x2 = (a.origin.x + a.size.width).min(b.origin.x + b.size.width);
687
    let y2 = (a.origin.y + a.size.height).min(b.origin.y + b.size.height);
688
    if x2 > x1 && y2 > y1 {
689
        Some(LogicalRect {
690
            origin: LogicalPosition { x: x1, y: y1 },
691
            size: LogicalSize {
692
                width: x2 - x1,
693
                height: y2 - y1,
694
            },
695
        })
696
    } else {
697
        None
698
    }
699
}
700

            
701
/// Blit `src` onto `dst` at pixel position (px_x, px_y) with opacity.
702
88
fn blit_pixmap(src: &AzulPixmap, dst: &mut AzulPixmap, px_x: i32, px_y: i32, opacity: f32) {
703
88
    let sw = src.width as i32;
704
88
    let sh = src.height as i32;
705
88
    let dw = dst.width as i32;
706
88
    let dh = dst.height as i32;
707
88
    let op = (opacity * 255.0).clamp(0.0, 255.0) as u32;
708

            
709
42240
    for sy in 0..sh {
710
42240
        let dy = px_y + sy;
711
42240
        if dy < 0 || dy >= dh {
712
            continue;
713
42240
        }
714
27033600
        for sx in 0..sw {
715
27033600
            let dx = px_x + sx;
716
27033600
            if dx < 0 || dx >= dw {
717
                continue;
718
27033600
            }
719
27033600
            let si = ((sy * sw + sx) * 4) as usize;
720
27033600
            let di = ((dy * dw + dx) * 4) as usize;
721
27033600
            if si + 3 >= src.data.len() || di + 3 >= dst.data.len() {
722
                continue;
723
27033600
            }
724

            
725
27033600
            let sr = src.data[si] as u32;
726
27033600
            let sg = src.data[si + 1] as u32;
727
27033600
            let sb = src.data[si + 2] as u32;
728
27033600
            let sa = (src.data[si + 3] as u32 * op) / 255;
729

            
730
27033600
            if sa == 0 {
731
                continue;
732
27033600
            }
733
27033600
            if sa == 255 {
734
27033600
                dst.data[di] = sr as u8;
735
27033600
                dst.data[di + 1] = sg as u8;
736
27033600
                dst.data[di + 2] = sb as u8;
737
27033600
                dst.data[di + 3] = 255;
738
27033600
            } else {
739
                let inv_sa = 255 - sa;
740
                dst.data[di] = ((sr * sa + dst.data[di] as u32 * inv_sa) / 255) as u8;
741
                dst.data[di + 1] = ((sg * sa + dst.data[di + 1] as u32 * inv_sa) / 255) as u8;
742
                dst.data[di + 2] = ((sb * sa + dst.data[di + 2] as u32 * inv_sa) / 255) as u8;
743
                dst.data[di + 3] = ((sa + dst.data[di + 3] as u32 * inv_sa / 255).min(255)) as u8;
744
            }
745
        }
746
    }
747
88
}
748

            
749
/// Shift pixel data in a pixmap by (dx, dy) pixels, clearing exposed regions.
750
fn shift_pixbuf(pixmap: &mut AzulPixmap, dx: i32, dy: i32) {
751
    let w = pixmap.width as i32;
752
    let h = pixmap.height as i32;
753
    if dx.abs() >= w || dy.abs() >= h {
754
        // Entire buffer is exposed — just clear it
755
        pixmap.fill(0, 0, 0, 0);
756
        return;
757
    }
758

            
759
    let stride = (w * 4) as usize;
760
    let data = &mut pixmap.data;
761

            
762
    // Shift rows vertically
763
    if dy > 0 {
764
        // Shift down: copy from top to bottom
765
        for row in (0..h - dy).rev() {
766
            let src_start = (row * w * 4) as usize;
767
            let dst_start = ((row + dy) * w * 4) as usize;
768
            data.copy_within(src_start..src_start + stride, dst_start);
769
        }
770
        // Clear top rows
771
        for row in 0..dy {
772
            let start = (row * w * 4) as usize;
773
            data[start..start + stride].fill(0);
774
        }
775
    } else if dy < 0 {
776
        let ady = (-dy) as i32;
777
        // Shift up: copy from bottom to top
778
        for row in ady..h {
779
            let src_start = (row * w * 4) as usize;
780
            let dst_start = ((row - ady) * w * 4) as usize;
781
            data.copy_within(src_start..src_start + stride, dst_start);
782
        }
783
        // Clear bottom rows
784
        for row in (h - ady)..h {
785
            let start = (row * w * 4) as usize;
786
            data[start..start + stride].fill(0);
787
        }
788
    }
789

            
790
    // Shift columns horizontally
791
    if dx > 0 {
792
        for row in 0..h {
793
            let row_start = (row * w * 4) as usize;
794
            let shift = (dx * 4) as usize;
795
            // Shift right within the row
796
            data.copy_within(row_start..row_start + stride - shift, row_start + shift);
797
            // Clear left columns
798
            data[row_start..row_start + shift].fill(0);
799
        }
800
    } else if dx < 0 {
801
        let adx = (-dx * 4) as usize;
802
        for row in 0..h {
803
            let row_start = (row * w * 4) as usize;
804
            data.copy_within(row_start + adx..row_start + stride, row_start);
805
            // Clear right columns
806
            data[row_start + stride - adx..row_start + stride].fill(0);
807
        }
808
    }
809
}
810

            
811
/// Compute exposed rectangles after a scroll of (dx, dy) in logical coords.
812
/// Returns 0, 1, or 2 rects: a vertical strip (top/bottom) and/or a horizontal
813
/// strip (left/right). Diagonal scrolling produces both strips.
814
fn compute_exposed_rects(bounds: &LogicalRect, dx: f32, dy: f32) -> Vec<LogicalRect> {
815
    let w = bounds.size.width;
816
    let h = bounds.size.height;
817
    let mut rects = Vec::new();
818

            
819
    // Vertical exposed strip (full width, covers top or bottom edge)
820
    if dy.abs() > 0.5 {
821
        let strip = if dy > 0.0 {
822
            // Scrolled down — top strip exposed
823
            LogicalRect {
824
                origin: LogicalPosition {
825
                    x: bounds.origin.x,
826
                    y: bounds.origin.y,
827
                },
828
                size: LogicalSize {
829
                    width: w,
830
                    height: dy.min(h),
831
                },
832
            }
833
        } else {
834
            // Scrolled up — bottom strip exposed
835
            LogicalRect {
836
                origin: LogicalPosition {
837
                    x: bounds.origin.x,
838
                    y: bounds.origin.y + h + dy,
839
                },
840
                size: LogicalSize {
841
                    width: w,
842
                    height: (-dy).min(h),
843
                },
844
            }
845
        };
846
        rects.push(strip);
847
    }
848

            
849
    // Horizontal exposed strip (full height, covers left or right edge)
850
    if dx.abs() > 0.5 {
851
        let strip = if dx > 0.0 {
852
            LogicalRect {
853
                origin: LogicalPosition {
854
                    x: bounds.origin.x,
855
                    y: bounds.origin.y,
856
                },
857
                size: LogicalSize {
858
                    width: dx.min(w),
859
                    height: h,
860
                },
861
            }
862
        } else {
863
            LogicalRect {
864
                origin: LogicalPosition {
865
                    x: bounds.origin.x + w + dx,
866
                    y: bounds.origin.y,
867
                },
868
                size: LogicalSize {
869
                    width: (-dx).min(w),
870
                    height: h,
871
                },
872
            }
873
        };
874
        rects.push(strip);
875
    }
876

            
877
    rects
878
}
879

            
880
/// Scroll a frame's clip region by *moving the pixels already on screen* and
881
/// return the newly-exposed strip(s) (logical coords) that still need painting.
882
///
883
/// This is the thin-strip optimisation for scrolling: instead of repainting the
884
/// whole `clip_bounds` viewport every frame, we `memmove` the pixels that are
885
/// still visible and only re-rasterise the strip that scrolled into view. For a
886
/// 30px scroll of a 200×100 viewport that turns ~20k painted px into ~6k.
887
///
888
/// Sign convention is the renderer's, NOT the legacy `compute_exposed_rects`:
889
/// `render_single_item`/`scroll_rect` draw a content item at `position - offset`,
890
/// so a *positive* `delta` (the user scrolled further down/right) moves on-screen
891
/// content UP/LEFT. We therefore move the existing pixels UP/LEFT and expose a
892
/// strip at the trailing (bottom/right) edge. `compute_exposed_rects` assumed the
893
/// inverse and never matched the renderer — it and `scroll_layer` are dead code.
894
///
895
/// Only pixels strictly inside the (clamped) clip rectangle are moved, so the
896
/// scrollbar, the parent background and sibling content outside the frame are
897
/// left untouched. Diagonal scroll (both axes in one frame — mobile pan) is
898
/// handled as TWO strips: the vertical move + horizontal move are separable 1-D
899
/// passes, so the net effect is a 2-D translation and the exposed region is an
900
/// L-shape (a full-width top/bottom strip + a full-height left/right strip).
901
/// The two strips overlap in one corner; that corner is simply repainted twice,
902
/// which is correct (the caller clears then renders each item once).
903
///
904
/// Returns an empty vec when nothing moved, or `[clip_bounds]` when the shift is
905
/// large enough that the whole viewport is exposed (caller repaints in full).
906
///
907
/// NOTE: the move copies *composited* pixels, so a scroll frame whose content is
908
/// not opaque over its clip can drag whatever showed through. Real scroll
909
/// containers paint an opaque background or fully cover their box, so this is a
910
/// known, documented limitation rather than a correctness bug for the common case.
911
5
pub fn scroll_shift_region(
912
5
    pixmap: &mut AzulPixmap,
913
5
    clip_bounds: &LogicalRect,
914
5
    delta: (f32, f32),
915
5
    dpi_factor: f32,
916
5
) -> Vec<LogicalRect> {
917
5
    let px_dx = (delta.0 * dpi_factor).round() as i32;
918
5
    let px_dy = (delta.1 * dpi_factor).round() as i32;
919

            
920
    // Nothing actually moved (sub-pixel jitter rounds to zero).
921
5
    if px_dx == 0 && px_dy == 0 {
922
1
        return Vec::new();
923
4
    }
924

            
925
4
    let pw = pixmap.width() as i32;
926
4
    let ph = pixmap.height() as i32;
927

            
928
    // Clip rectangle in physical pixels, clamped to the pixmap.
929
4
    let cx0 = ((clip_bounds.origin.x * dpi_factor).floor() as i32).clamp(0, pw);
930
4
    let cy0 = ((clip_bounds.origin.y * dpi_factor).floor() as i32).clamp(0, ph);
931
4
    let cx1 = (((clip_bounds.origin.x + clip_bounds.size.width) * dpi_factor).ceil() as i32)
932
4
        .clamp(0, pw);
933
4
    let cy1 = (((clip_bounds.origin.y + clip_bounds.size.height) * dpi_factor).ceil() as i32)
934
4
        .clamp(0, ph);
935
4
    let region_w = cx1 - cx0;
936
4
    let region_h = cy1 - cy0;
937
4
    if region_w <= 0 || region_h <= 0 {
938
        return Vec::new();
939
4
    }
940

            
941
    // Shift exceeds the region — every pixel is exposed, so skip the memmove and
942
    // let the caller repaint the whole clip.
943
4
    if px_dx.abs() >= region_w || px_dy.abs() >= region_h {
944
1
        return vec![*clip_bounds];
945
3
    }
946

            
947
    // Dispatch to a specialised mover. The common single-axis cases get a tight
948
    // 1-D pass; diagonal pan gets a SINGLE-pass 2-D move (each row copied once
949
    // from its diagonally-offset source) instead of two sequential full passes —
950
    // half the memory traffic. (no-op is already handled by the early return.)
951
3
    let stride_px = pw;
952
3
    let data = pixmap.data_mut();
953
3
    match (px_dx != 0, px_dy != 0) {
954
2
        (false, true) => shift_vertical_1d(data, stride_px, cx0, cy0, cx1, cy1, px_dy),
955
        (true, false) => shift_horizontal_1d(data, stride_px, cx0, cy0, cx1, cy1, px_dx),
956
1
        (true, true) => shift_diagonal_2d(data, stride_px, cx0, cy0, cx1, cy1, px_dx, px_dy),
957
        (false, false) => {}
958
    }
959

            
960
    // Exposed strip(s) in LOGICAL coords. Over-cover the moving edge by one
961
    // physical pixel so dpi rounding never leaves a 1px white seam between the
962
    // moved block and the freshly-painted strip. One strip per moved axis, so
963
    // diagonal pan yields two (an L-shape, overlapping in one corner).
964
3
    let cb = clip_bounds;
965
3
    let mut exposed = Vec::new();
966
3
    if px_dy != 0 {
967
3
        let h_logical = (px_dy.abs() as f32 + 1.0) / dpi_factor;
968
3
        let h = h_logical.min(cb.size.height);
969
3
        let y = if px_dy > 0 {
970
            // bottom strip exposed
971
3
            cb.origin.y + cb.size.height - h
972
        } else {
973
            // top strip exposed
974
            cb.origin.y
975
        };
976
3
        exposed.push(LogicalRect {
977
3
            origin: LogicalPosition { x: cb.origin.x, y },
978
3
            size: LogicalSize { width: cb.size.width, height: h },
979
3
        });
980
    }
981
3
    if px_dx != 0 {
982
1
        let w_logical = (px_dx.abs() as f32 + 1.0) / dpi_factor;
983
1
        let w = w_logical.min(cb.size.width);
984
1
        let x = if px_dx > 0 {
985
            // right strip exposed
986
1
            cb.origin.x + cb.size.width - w
987
        } else {
988
            // left strip exposed
989
            cb.origin.x
990
        };
991
1
        exposed.push(LogicalRect {
992
1
            origin: LogicalPosition { x, y: cb.origin.y },
993
1
            size: LogicalSize { width: w, height: cb.size.height },
994
1
        });
995
2
    }
996
3
    exposed
997
5
}
998

            
999
// --- scroll_shift_region movers -------------------------------------------
// All three operate in PHYSICAL pixels on the raw RGBA buffer. `cx0..cx1` /
// `cy0..cy1` is the clamped clip region; `stride_px` is the buffer width in
// pixels. They only ever touch bytes inside the clip rectangle. Sign of the
// `px_*` deltas follows the renderer: positive = content moves up/left, so the
// exposed strip is the trailing (bottom/right) edge.
/// Single-axis VERTICAL move: shift whole rows up (px_dy>0) or down (px_dy<0).
/// Iteration order is chosen so a row read as a source is never already
/// overwritten (src and dst row SETS overlap, so order matters).
#[inline]
2
fn shift_vertical_1d(
2
    data: &mut [u8],
2
    stride_px: i32,
2
    cx0: i32,
2
    cy0: i32,
2
    cx1: i32,
2
    cy1: i32,
2
    px_dy: i32,
2
) {
2
    let col_bytes = ((cx1 - cx0) * 4) as usize;
240
    let row_off = |row: i32| ((row * stride_px + cx0) as usize) * 4;
2
    if px_dy > 0 {
        // Content up: dst = src - px_dy (dst < src) → iterate top→bottom.
120
        for dst in cy0..(cy1 - px_dy) {
120
            let s = row_off(dst + px_dy);
120
            data.copy_within(s..s + col_bytes, row_off(dst));
120
        }
    } else {
        let amt = -px_dy;
        // Content down: dst = src + amt (dst > src) → iterate bottom→top.
        for dst in ((cy0 + amt)..cy1).rev() {
            let s = row_off(dst - amt);
            data.copy_within(s..s + col_bytes, row_off(dst));
        }
    }
2
}
/// Single-axis HORIZONTAL move: shift each row's pixels left (px_dx>0) or right
/// (px_dx<0). Source and dest overlap WITHIN a row, so `copy_within`'s memmove
/// semantics handle it directly — no per-row ordering needed.
#[inline]
fn shift_horizontal_1d(
    data: &mut [u8],
    stride_px: i32,
    cx0: i32,
    cy0: i32,
    cx1: i32,
    cy1: i32,
    px_dx: i32,
) {
    let col_bytes = ((cx1 - cx0) * 4) as usize;
    let row_off = |row: i32| ((row * stride_px + cx0) as usize) * 4;
    if px_dx > 0 {
        let shift = (px_dx * 4) as usize;
        for row in cy0..cy1 {
            let left = row_off(row);
            data.copy_within(left + shift..left + col_bytes, left);
        }
    } else {
        let shift = ((-px_dx) * 4) as usize;
        for row in cy0..cy1 {
            let left = row_off(row);
            data.copy_within(left..left + col_bytes - shift, left + shift);
        }
    }
}
/// Diagonal (two-axis) pan in ONE pass: each destination row is copied directly
/// from its diagonally-offset source row, applying the column shift in the same
/// `copy_within`. Because |px_dy| ≥ 1, the source and dest rows are always
/// DIFFERENT rows ≥ one stride apart, so the per-copy byte ranges never overlap
/// regardless of the horizontal direction — only the row iteration order (by
/// `px_dy` sign) matters, exactly as in the vertical case. This does the work of
/// the two 1-D passes with half the memory traffic.
#[inline]
1
fn shift_diagonal_2d(
1
    data: &mut [u8],
1
    stride_px: i32,
1
    cx0: i32,
1
    cy0: i32,
1
    cx1: i32,
1
    cy1: i32,
1
    px_dx: i32,
1
    px_dy: i32,
1
) {
1
    let span_cols = (cx1 - cx0) - px_dx.abs();
1
    if span_cols <= 0 {
        return; // horizontal shift covers the whole region — nothing to keep
1
    }
1
    let len = (span_cols * 4) as usize;
    // Column starts for the kept span: content-left reads from the right, etc.
1
    let (src_col, dst_col) = if px_dx > 0 {
1
        (cx0 + px_dx, cx0)
    } else {
        (cx0, cx0 - px_dx)
    };
70
    let src_byte = |row: i32| ((row * stride_px + src_col) as usize) * 4;
70
    let dst_byte = |row: i32| ((row * stride_px + dst_col) as usize) * 4;
1
    if px_dy > 0 {
        // Content up: src row = dst + px_dy (below) → iterate top→bottom.
70
        for dst in cy0..(cy1 - px_dy) {
70
            let s = src_byte(dst + px_dy);
70
            data.copy_within(s..s + len, dst_byte(dst));
70
        }
    } else {
        let amt = -px_dy;
        // Content down: src row = dst - amt (above) → iterate bottom→top.
        for dst in ((cy0 + amt)..cy1).rev() {
            let s = src_byte(dst - amt);
            data.copy_within(s..s + len, dst_byte(dst));
        }
    }
1
}
/// Decide whether scroll frame `scroll_id` may use the [`scroll_shift_region`]
/// memmove fast path, or whether the caller must full-repaint the clip instead.
///
/// The memmove drags whatever is composited inside the clip. That is only WRONG
/// when transparent gaps in the SCROLLING content let static "backdrop" pixels
/// (painted *behind* the frame) show through and get dragged along. Per the
/// project's aggressive policy: take the fast path UNLESS that exact condition is
/// proven — i.e. fall back ONLY when (a) something is painted behind the frame
/// within the clip AND (b) the scrolling content does not opaquely cover the clip.
///
/// `scroll_offset` is the frame's current offset, used to project the content's
/// opaque fills (stored at content coords) into viewport space for the coverage
/// test. A scroll frame over nothing-but-the-clear-color is always eligible (no
/// backdrop to drag). Returns `true` when there is no such frame (nothing to do).
5
pub fn scroll_fast_path_eligible(
5
    display_list: &DisplayList,
5
    scroll_id: LocalScrollId,
5
    clip_bounds: &LogicalRect,
5
    scroll_offset: (f32, f32),
5
) -> bool {
    // Locate the frame's content range [start+1, end).
10
    let start = display_list.items.iter().position(|it| {
5
        matches!(it, DisplayListItem::PushScrollFrame { scroll_id: sid, .. } if *sid == scroll_id)
10
    });
5
    let start = match start {
5
        Some(s) => s,
        None => return true, // no frame for this id → nothing to shift
    };
5
    let end = find_matching_pop(&display_list.items, start, MatchKind::ScrollFrame)
5
        .min(display_list.items.len());
    // (a) Best case: the SCROLLING content opaquely covers the clip (projected
    // into viewport space by the scroll offset). Then nothing behind can ever
    // show through, so the shift is always safe.
5
    let content_opaque: Vec<LogicalRect> = display_list.items[start + 1..end]
5
        .iter()
5
        .filter_map(|it| opaque_fill_rect(it))
5
        .map(|r| LogicalRect {
1
            origin: LogicalPosition {
1
                x: r.origin.x - scroll_offset.0,
1
                y: r.origin.y - scroll_offset.1,
1
            },
1
            size: r.size,
1
        })
5
        .collect();
5
    if rect_covered_by(clip_bounds, &content_opaque) {
1
        return true;
4
    }
    // (b) Content has gaps. The drag is only VISIBLE if a NON-UNIFORM backdrop
    // shows through. Scan items behind the frame for SIGNIFICANT backdrop fills
    // (≥10% of the clip) — borders, text, shadows, thin/small decorations smear
    // imperceptibly and are ignored (aggressive policy: fall back only on a
    // proven artifact). Classify each significant backdrop item:
    //   - flat opaque Rect  → track its colour
    //   - Image / gradient  → non-uniform → not safe
    // Then: no significant backdrop → safe (only the clear behind); a single flat
    // colour that COVERS the clip → drags invisibly → safe; mixed colours, a
    // partial cover, or any non-uniform fill → full-repaint.
4
    let clip_area = (clip_bounds.size.width * clip_bounds.size.height).max(1.0);
4
    let mut backdrop_fills: Vec<LogicalRect> = Vec::new();
4
    let mut backdrop_color: Option<ColorU> = None;
4
    for it in display_list.items[..start].iter() {
4
        if it.is_state_management() {
            continue;
4
        }
4
        let b = match it.bounds() {
4
            Some(b) if rects_overlap_or_adjacent(&b, clip_bounds, 0.0) => b,
            _ => continue,
        };
        // Area of this item within the clip; ignore negligible coverage.
4
        let ix = b.origin.x.max(clip_bounds.origin.x);
4
        let iy = b.origin.y.max(clip_bounds.origin.y);
4
        let ix1 = (b.origin.x + b.size.width).min(clip_bounds.origin.x + clip_bounds.size.width);
4
        let iy1 = (b.origin.y + b.size.height).min(clip_bounds.origin.y + clip_bounds.size.height);
4
        let isect_area = ((ix1 - ix).max(0.0)) * ((iy1 - iy).max(0.0));
4
        if isect_area < clip_area * 0.10 {
            continue; // negligible — thin border / small decoration
4
        }
4
        match it {
4
            DisplayListItem::Rect { color, border_radius, .. }
4
                if color.a == 255 && border_radius.is_zero() =>
            {
1
                match backdrop_color {
3
                    None => backdrop_color = Some(*color),
1
                    Some(prev) if prev == *color => {}
1
                    Some(_) => return false, // ≥2 distinct backdrop colours → visible
                }
3
                backdrop_fills.push(b);
            }
            DisplayListItem::Rect { .. } => {} // translucent / rounded — let it drag
            DisplayListItem::Image { .. }
            | DisplayListItem::LinearGradient { .. }
            | DisplayListItem::RadialGradient { .. }
            | DisplayListItem::ConicGradient { .. } => return false, // non-uniform fill
            _ => {} // border/text/shadow/scrollbar etc. — negligible
        }
    }
3
    if backdrop_fills.is_empty() {
1
        return true; // only the clear (or negligible decoration) behind
2
    }
    // Single flat colour: safe only if it fills the whole clip (else its edge
    // against the clear would drag visibly).
2
    rect_covered_by(clip_bounds, &backdrop_fills)
5
}
/// If `it` is a fully-opaque, square-cornered rectangle fill, its bounds.
5
fn opaque_fill_rect(it: &DisplayListItem) -> Option<LogicalRect> {
5
    match it {
        DisplayListItem::Rect {
5
            bounds,
5
            color,
5
            border_radius,
5
        } if color.a == 255 && border_radius.is_zero() => Some(*bounds.inner()),
4
        _ => None,
    }
5
}
/// True if every ~4px sample of `target` lies inside some rect in `covers`.
/// Point-sampled so sub-4px gaps (imperceptible if dragged) don't force a full
/// repaint; empty `covers` → not covered.
11
fn rect_covered_by(target: &LogicalRect, covers: &[LogicalRect]) -> bool {
11
    if covers.is_empty() {
5
        return false;
6
    }
6
    let step = 4.0_f32;
6
    let x0 = target.origin.x;
6
    let y0 = target.origin.y;
6
    let x1 = x0 + target.size.width;
6
    let y1 = y0 + target.size.height;
6
    let mut y = y0 + step * 0.5;
126
    while y < y1 {
122
        let mut x = x0 + step * 0.5;
3122
        while x < x1 {
3328
            let inside = covers.iter().any(|r| {
3328
                x >= r.origin.x
3328
                    && x < r.origin.x + r.size.width
3328
                    && y >= r.origin.y
3327
                    && y < r.origin.y + r.size.height
3328
            });
3002
            if !inside {
2
                return false;
3000
            }
3000
            x += step;
        }
120
        y += step;
    }
4
    true
11
}
/// Apply CSS filters to a pixbuf at composite time.
fn apply_layer_filters(pixmap: &mut AzulPixmap, filters: &[StyleFilter], dpi_factor: f32) {
    for filter in filters {
        match filter {
            StyleFilter::Blur(blur) => {
                let rx = blur
                    .width
                    .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
                    * dpi_factor;
                let ry = blur
                    .height
                    .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
                    * dpi_factor;
                let radius = ((rx + ry) / 2.0).ceil() as u32;
                if radius > 0 {
                    let w = pixmap.width;
                    let h = pixmap.height;
                    let stride = (w * 4) as i32;
                    let mut ra = unsafe {
                        RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride)
                    };
                    stack_blur_rgba32(&mut ra, radius, radius);
                }
            }
            StyleFilter::Opacity(pct) => {
                let op = (pct.normalized() * 255.0).clamp(0.0, 255.0) as u32;
                for chunk in pixmap.data.chunks_exact_mut(4) {
                    chunk[3] = ((chunk[3] as u32 * op) / 255) as u8;
                }
            }
            StyleFilter::Grayscale(pct) => {
                let amount = pct.normalized().clamp(0.0, 1.0);
                for chunk in pixmap.data.chunks_exact_mut(4) {
                    let r = chunk[0] as f32;
                    let g = chunk[1] as f32;
                    let b = chunk[2] as f32;
                    let gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
                    chunk[0] = (r + (gray - r) * amount).clamp(0.0, 255.0) as u8;
                    chunk[1] = (g + (gray - g) * amount).clamp(0.0, 255.0) as u8;
                    chunk[2] = (b + (gray - b) * amount).clamp(0.0, 255.0) as u8;
                }
            }
            StyleFilter::Brightness(pct) => {
                let factor = pct.normalized().max(0.0);
                for chunk in pixmap.data.chunks_exact_mut(4) {
                    chunk[0] = (chunk[0] as f32 * factor).clamp(0.0, 255.0) as u8;
                    chunk[1] = (chunk[1] as f32 * factor).clamp(0.0, 255.0) as u8;
                    chunk[2] = (chunk[2] as f32 * factor).clamp(0.0, 255.0) as u8;
                }
            }
            StyleFilter::Contrast(pct) => {
                let factor = pct.normalized().max(0.0);
                for chunk in pixmap.data.chunks_exact_mut(4) {
                    chunk[0] = ((((chunk[0] as f32 / 255.0) - 0.5) * factor + 0.5) * 255.0)
                        .clamp(0.0, 255.0) as u8;
                    chunk[1] = ((((chunk[1] as f32 / 255.0) - 0.5) * factor + 0.5) * 255.0)
                        .clamp(0.0, 255.0) as u8;
                    chunk[2] = ((((chunk[2] as f32 / 255.0) - 0.5) * factor + 0.5) * 255.0)
                        .clamp(0.0, 255.0) as u8;
                }
            }
            StyleFilter::Invert(pct) => {
                let amount = pct.normalized().clamp(0.0, 1.0);
                for chunk in pixmap.data.chunks_exact_mut(4) {
                    chunk[0] = (chunk[0] as f32 + (255.0 - 2.0 * chunk[0] as f32) * amount)
                        .clamp(0.0, 255.0) as u8;
                    chunk[1] = (chunk[1] as f32 + (255.0 - 2.0 * chunk[1] as f32) * amount)
                        .clamp(0.0, 255.0) as u8;
                    chunk[2] = (chunk[2] as f32 + (255.0 - 2.0 * chunk[2] as f32) * amount)
                        .clamp(0.0, 255.0) as u8;
                }
            }
            StyleFilter::Sepia(pct) => {
                let amount = pct.normalized().clamp(0.0, 1.0);
                for chunk in pixmap.data.chunks_exact_mut(4) {
                    let r = chunk[0] as f32;
                    let g = chunk[1] as f32;
                    let b = chunk[2] as f32;
                    let sr = (0.393 * r + 0.769 * g + 0.189 * b).min(255.0);
                    let sg = (0.349 * r + 0.686 * g + 0.168 * b).min(255.0);
                    let sb = (0.272 * r + 0.534 * g + 0.131 * b).min(255.0);
                    chunk[0] = (r + (sr - r) * amount).clamp(0.0, 255.0) as u8;
                    chunk[1] = (g + (sg - g) * amount).clamp(0.0, 255.0) as u8;
                    chunk[2] = (b + (sb - b) * amount).clamp(0.0, 255.0) as u8;
                }
            }
            StyleFilter::Saturate(pct) => {
                let s = pct.normalized().max(0.0);
                for chunk in pixmap.data.chunks_exact_mut(4) {
                    let r = chunk[0] as f32;
                    let g = chunk[1] as f32;
                    let b = chunk[2] as f32;
                    let gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
                    chunk[0] = (gray + (r - gray) * s).clamp(0.0, 255.0) as u8;
                    chunk[1] = (gray + (g - gray) * s).clamp(0.0, 255.0) as u8;
                    chunk[2] = (gray + (b - gray) * s).clamp(0.0, 255.0) as u8;
                }
            }
            StyleFilter::HueRotate(angle) => {
                let rad = angle.to_degrees().to_radians();
                let cos_a = rad.cos();
                let sin_a = rad.sin();
                for chunk in pixmap.data.chunks_exact_mut(4) {
                    let r = chunk[0] as f32;
                    let g = chunk[1] as f32;
                    let b = chunk[2] as f32;
                    let nr = (0.213 + 0.787 * cos_a - 0.213 * sin_a) * r
                        + (0.715 - 0.715 * cos_a - 0.715 * sin_a) * g
                        + (0.072 - 0.072 * cos_a + 0.928 * sin_a) * b;
                    let ng = (0.213 - 0.213 * cos_a + 0.143 * sin_a) * r
                        + (0.715 + 0.285 * cos_a + 0.140 * sin_a) * g
                        + (0.072 - 0.072 * cos_a - 0.283 * sin_a) * b;
                    let nb = (0.213 - 0.213 * cos_a - 0.787 * sin_a) * r
                        + (0.715 - 0.715 * cos_a + 0.715 * sin_a) * g
                        + (0.072 + 0.928 * cos_a + 0.072 * sin_a) * b;
                    chunk[0] = nr.clamp(0.0, 255.0) as u8;
                    chunk[1] = ng.clamp(0.0, 255.0) as u8;
                    chunk[2] = nb.clamp(0.0, 255.0) as u8;
                }
            }
            _ => {} // Blend, Flood, ColorMatrix, DropShadow, ComponentTransfer, Offset, Composite not yet implemented
        }
    }
}
/// Render a range of display list items into a layer pixbuf,
/// offsetting coordinates by the layer's origin.
88
fn render_display_list_range(
88
    display_list: &DisplayList,
88
    pixmap: &mut AzulPixmap,
88
    start: usize,
88
    end: usize,
88
    // Index ranges (start..end) that belong to CHILD layers (nested scroll
88
    // frames / opacity / transform groups). Those items render into the child's
88
    // OWN pixbuf, so they must be skipped here — otherwise they're drawn twice
88
    // (once in this layer at absolute coords AND once in the child layer),
88
    // which produced overlapping / ghosted text in overflow:scroll content.
88
    skip_ranges: &[(usize, usize)],
88
    offset_x: f32,
88
    offset_y: f32,
88
    dpi_factor: f32,
88
    renderer_resources: &RendererResources,
88
    font_manager: Option<&FontManager<FontRef>>,
88
    glyph_cache: &mut GlyphCache,
88
    render_state: &CpuRenderState,
88
) -> Result<(), String> {
88
    let mut transform_stack = vec![TransAffine::new()];
88
    let mut clip_stack: Vec<Option<AzRect>> = vec![None];
88
    let mut mask_stack: Vec<MaskEntry> = Vec::new();
    // Apply the layer origin offset: content is translated by -(offset_x,offset_y)
    // so it's rendered RELATIVE to this layer's pixbuf origin (which is then
    // composited back at +layer_origin). The renderer translates positions by
    // `pos - scroll_offset`, so seeding the scroll-offset stack with the layer
    // origin achieves the relative placement. Previously offset_x/offset_y were
    // ignored, so child layers were double-offset (content drawn at absolute
    // coords then composited at +origin) — text fell to the bottom of the box.
88
    let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(offset_x, offset_y)];
3256
    for i in start..end {
        // Skip items rendered by a child layer (see skip_ranges doc above).
3256
        if skip_ranges.iter().any(|(s, e)| i >= *s && i < *e) {
            continue;
3256
        }
3256
        let item = &display_list.items[i];
3256
        render_single_item(
3256
            item,
3256
            pixmap,
3256
            dpi_factor,
3256
            renderer_resources,
3256
            font_manager,
3256
            glyph_cache,
3256
            &mut transform_stack,
3256
            &mut clip_stack,
3256
            &mut mask_stack,
3256
            &mut scroll_offset_stack,
3256
            render_state,
        )?;
    }
88
    Ok(())
88
}
// ============================================================================
// AzulPixmap — replacement for tiny_skia::Pixmap
// ============================================================================
/// A simple RGBA pixel buffer. Replaces tiny_skia::Pixmap.
pub struct AzulPixmap {
    data: Vec<u8>,
    width: u32,
    height: u32,
}
impl AzulPixmap {
    /// Create a new pixmap filled with opaque white.
2293
    pub fn new(width: u32, height: u32) -> Option<Self> {
2293
        if width == 0 || height == 0 {
            return None;
2293
        }
2293
        let len = (width as usize) * (height as usize) * 4;
2293
        let data = vec![255u8; len]; // opaque white
2293
        Some(Self {
2293
            data,
2293
            width,
2293
            height,
2293
        })
2293
    }
    /// Fill the entire pixmap with a single color.
2200
    pub fn fill(&mut self, r: u8, g: u8, b: u8, a: u8) {
186665776
        for chunk in self.data.chunks_exact_mut(4) {
186665776
            chunk[0] = r;
186665776
            chunk[1] = g;
186665776
            chunk[2] = b;
186665776
            chunk[3] = a;
186665776
        }
2200
    }
    /// Fill a rectangular region with a single color (pixel coordinates).
44
    pub fn fill_rect(&mut self, x: i32, y: i32, w: i32, h: i32, r: u8, g: u8, b: u8, a: u8) {
44
        let pw = self.width as i32;
44
        let ph = self.height as i32;
44
        let x0 = x.max(0).min(pw);
44
        let y0 = y.max(0).min(ph);
        // saturating: a non-finite/huge layout size casts to i32::MAX, and `x + w`
        // would then overflow (debug panic). Clamp instead.
44
        let x1 = x.saturating_add(w).max(0).min(pw);
44
        let y1 = y.saturating_add(h).max(0).min(ph);
17688
        for row in y0..y1 {
17688
            let start = (row * pw + x0) as usize * 4;
17688
            let end = (row * pw + x1) as usize * 4;
17688
            if end <= self.data.len() {
11037312
                for chunk in self.data[start..end].chunks_exact_mut(4) {
11037312
                    chunk[0] = r;
11037312
                    chunk[1] = g;
11037312
                    chunk[2] = b;
11037312
                    chunk[3] = a;
11037312
                }
            }
        }
44
    }
    /// Raw RGBA pixel data.
1946
    pub fn data(&self) -> &[u8] {
1946
        &self.data
1946
    }
    /// Mutable raw RGBA pixel data.
8
    pub fn data_mut(&mut self) -> &mut [u8] {
8
        &mut self.data
8
    }
    /// Width in pixels.
1290
    pub fn width(&self) -> u32 {
1290
        self.width
1290
    }
    /// Height in pixels.
1280
    pub fn height(&self) -> u32 {
1280
        self.height
1280
    }
    /// Create a clone of this pixmap (for filter application).
352
    pub fn clone_pixmap(&self) -> Self {
352
        Self {
352
            data: self.data.clone(),
352
            width: self.width,
352
            height: self.height,
352
        }
352
    }
    /// Resize the pixmap preserving existing content in the top-left corner.
    /// New right/bottom strips are filled with the specified color.
    /// Only grows — returns None if new dimensions are smaller (caller should realloc).
    pub fn resize_grow_only(
        &mut self,
        new_width: u32,
        new_height: u32,
        fill_r: u8,
        fill_g: u8,
        fill_b: u8,
        fill_a: u8,
    ) -> Option<()> {
        if new_width < self.width || new_height < self.height {
            return None;
        }
        if new_width == self.width && new_height == self.height {
            return Some(());
        }
        let old_w = self.width as usize;
        let old_h = self.height as usize;
        let new_w = new_width as usize;
        let new_h = new_height as usize;
        let mut new_data = vec![fill_a; new_w * new_h * 4];
        // Fill entire buffer with fill color first (covers right + bottom strips)
        for chunk in new_data.chunks_exact_mut(4) {
            chunk[0] = fill_r;
            chunk[1] = fill_g;
            chunk[2] = fill_b;
            chunk[3] = fill_a;
        }
        // Copy old rows into top-left corner
        let old_stride = old_w * 4;
        let new_stride = new_w * 4;
        for row in 0..old_h {
            let src = row * old_stride;
            let dst = row * new_stride;
            new_data[dst..dst + old_stride].copy_from_slice(&self.data[src..src + old_stride]);
        }
        self.data = new_data;
        self.width = new_width;
        self.height = new_height;
        Some(())
    }
    /// Resize the pixmap, reusing existing content for the overlapping region.
    /// Works for both growing and shrinking. New areas are filled with the given color.
    pub fn resize_reuse(
        &mut self,
        new_width: u32,
        new_height: u32,
        fill_r: u8,
        fill_g: u8,
        fill_b: u8,
        fill_a: u8,
    ) {
        if new_width == self.width && new_height == self.height {
            return;
        }
        let old_w = self.width as usize;
        let old_h = self.height as usize;
        let new_w = new_width as usize;
        let new_h = new_height as usize;
        let new_stride = new_w * 4;
        let old_stride = old_w * 4;
        let mut new_data = vec![0u8; new_w * new_h * 4];
        // Fill entire buffer with fill color
        for chunk in new_data.chunks_exact_mut(4) {
            chunk[0] = fill_r;
            chunk[1] = fill_g;
            chunk[2] = fill_b;
            chunk[3] = fill_a;
        }
        // Copy overlapping region from old to new
        let copy_rows = old_h.min(new_h);
        let copy_cols_bytes = old_stride.min(new_stride);
        for row in 0..copy_rows {
            let src = row * old_stride;
            let dst = row * new_stride;
            new_data[dst..dst + copy_cols_bytes]
                .copy_from_slice(&self.data[src..src + copy_cols_bytes]);
        }
        self.data = new_data;
        self.width = new_width;
        self.height = new_height;
    }
    /// Encode to PNG using the `png` crate.
1584
    pub fn encode_png(&self) -> Result<Vec<u8>, String> {
1584
        let mut buf = Vec::new();
        {
1584
            let mut encoder = png::Encoder::new(&mut buf, self.width, self.height);
1584
            encoder.set_color(png::ColorType::Rgba);
1584
            encoder.set_depth(png::BitDepth::Eight);
1584
            let mut writer = encoder
1584
                .write_header()
1584
                .map_err(|e| format!("PNG header error: {}", e))?;
1584
            writer
1584
                .write_image_data(&self.data)
1584
                .map_err(|e| format!("PNG write error: {}", e))?;
        }
1584
        Ok(buf)
1584
    }
    /// Decode a PNG byte slice into an AzulPixmap.
924
    pub fn decode_png(png_bytes: &[u8]) -> Result<Self, String> {
924
        let decoder = png::Decoder::new(std::io::Cursor::new(png_bytes));
924
        let mut reader = decoder
924
            .read_info()
924
            .map_err(|e| format!("PNG decode error: {}", e))?;
924
        let buf_size = reader
924
            .output_buffer_size()
924
            .ok_or_else(|| "PNG: unknown output buffer size".to_string())?;
924
        let mut buf = vec![0u8; buf_size];
924
        let info = reader
924
            .next_frame(&mut buf)
924
            .map_err(|e| format!("PNG frame error: {}", e))?;
924
        let width = info.width;
924
        let height = info.height;
        // Convert to RGBA if needed
924
        let data = match info.color_type {
924
            png::ColorType::Rgba => buf[..info.buffer_size()].to_vec(),
            png::ColorType::Rgb => {
                let mut rgba = Vec::with_capacity((width * height * 4) as usize);
                for chunk in buf[..info.buffer_size()].chunks_exact(3) {
                    rgba.push(chunk[0]);
                    rgba.push(chunk[1]);
                    rgba.push(chunk[2]);
                    rgba.push(255);
                }
                rgba
            }
            png::ColorType::Grayscale => {
                let mut rgba = Vec::with_capacity((width * height * 4) as usize);
                for &v in &buf[..info.buffer_size()] {
                    rgba.push(v);
                    rgba.push(v);
                    rgba.push(v);
                    rgba.push(255);
                }
                rgba
            }
            other => return Err(format!("Unsupported PNG color type: {:?}", other)),
        };
924
        Ok(Self {
924
            data,
924
            width,
924
            height,
924
        })
924
    }
}
// ============================================================================
// Pixel-diff comparison for regression testing
// ============================================================================
/// Result of comparing two pixmaps pixel-by-pixel.
#[derive(Debug, Clone)]
pub struct PixelDiffResult {
    /// Number of pixels that differ beyond the threshold.
    pub diff_count: u64,
    /// Total number of pixels compared.
    pub total_pixels: u64,
    /// Maximum per-channel delta found across all pixels.
    pub max_delta: u8,
    /// Whether dimensions matched.
    pub dimensions_match: bool,
    /// Width of the reference image.
    pub ref_width: u32,
    /// Height of the reference image.
    pub ref_height: u32,
    /// Width of the test image.
    pub test_width: u32,
    /// Height of the test image.
    pub test_height: u32,
}
impl PixelDiffResult {
    /// True if the images are identical within tolerance.
    pub fn is_match(&self) -> bool {
        self.dimensions_match && self.diff_count == 0
    }
    /// Fraction of pixels that differ (0.0 = identical, 1.0 = all different).
    pub fn diff_ratio(&self) -> f64 {
        if self.total_pixels == 0 {
            0.0
        } else {
            self.diff_count as f64 / self.total_pixels as f64
        }
    }
}
/// Compare two pixmaps pixel-by-pixel with a per-channel tolerance.
///
/// `threshold` is the maximum allowed per-channel difference (0 = exact match,
/// 2-3 = anti-aliasing tolerance, 10+ = loose match).
396
pub fn pixel_diff(reference: &AzulPixmap, test: &AzulPixmap, threshold: u8) -> PixelDiffResult {
396
    let dimensions_match = reference.width == test.width && reference.height == test.height;
396
    if !dimensions_match {
        return PixelDiffResult {
            diff_count: 0,
            total_pixels: 0,
            max_delta: 0,
            dimensions_match: false,
            ref_width: reference.width,
            ref_height: reference.height,
            test_width: test.width,
            test_height: test.height,
        };
396
    }
396
    let total_pixels = (reference.width as u64) * (reference.height as u64);
396
    let mut diff_count = 0u64;
396
    let mut max_delta = 0u8;
9785600
    for (ref_chunk, test_chunk) in reference
396
        .data
396
        .chunks_exact(4)
396
        .zip(test.data.chunks_exact(4))
    {
9785600
        let mut pixel_differs = false;
48928000
        for c in 0..4 {
39142400
            let delta = (ref_chunk[c] as i16 - test_chunk[c] as i16).unsigned_abs() as u8;
39142400
            if delta > threshold {
                pixel_differs = true;
39142400
            }
39142400
            if delta > max_delta {
                max_delta = delta;
39142400
            }
        }
9785600
        if pixel_differs {
            diff_count += 1;
9785600
        }
    }
396
    PixelDiffResult {
396
        diff_count,
396
        total_pixels,
396
        max_delta,
396
        dimensions_match: true,
396
        ref_width: reference.width,
396
        ref_height: reference.height,
396
        test_width: test.width,
396
        test_height: test.height,
396
    }
396
}
/// Compare a rendered pixmap against a reference PNG file.
///
/// Returns `Ok(result)` with the diff stats, or `Err` if the reference
/// file cannot be read/decoded.
pub fn compare_against_reference(
    rendered: &AzulPixmap,
    reference_png_path: &str,
    threshold: u8,
) -> Result<PixelDiffResult, String> {
    let ref_bytes = std::fs::read(reference_png_path)
        .map_err(|e| format!("Cannot read reference image {}: {}", reference_png_path, e))?;
    let reference = AzulPixmap::decode_png(&ref_bytes)?;
    Ok(pixel_diff(&reference, rendered, threshold))
}
// ============================================================================
// Simple rect type (replaces tiny_skia::Rect)
// ============================================================================
#[derive(Debug, Clone, Copy)]
struct AzRect {
    x: f32,
    y: f32,
    width: f32,
    height: f32,
}
/// Intersect a freshly-pushed clip with the currently-active one. `None`
/// means "no clip". An EMPTY intersection clips everything (zero-area rect) —
/// it must NOT degrade to `None`/unclipped, or nested clips could escape
/// their parents.
396
fn intersect_clips(current: Option<AzRect>, new: Option<AzRect>) -> Option<AzRect> {
396
    match (current, new) {
308
        (Some(cur), Some(new)) => {
308
            let x0 = cur.x.max(new.x);
308
            let y0 = cur.y.max(new.y);
308
            let x1 = (cur.x + cur.width).min(new.x + new.width);
308
            let y1 = (cur.y + cur.height).min(new.y + new.height);
308
            Some(AzRect {
308
                x: x0,
308
                y: y0,
308
                width: (x1 - x0).max(0.0),
308
                height: (y1 - y0).max(0.0),
308
            })
        }
        (Some(cur), None) => Some(cur),
88
        (None, new) => new,
    }
396
}
impl AzRect {
13904
    fn from_xywh(x: f32, y: f32, w: f32, h: f32) -> Option<Self> {
13904
        if w <= 0.0
13904
            || h <= 0.0
13904
            || !x.is_finite()
13904
            || !y.is_finite()
13904
            || !w.is_finite()
13904
            || !h.is_finite()
        {
            return None;
13904
        }
13904
        Some(Self {
13904
            x,
13904
            y,
13904
            width: w,
13904
            height: h,
13904
        })
13904
    }
    /// Intersect this rect with a clip rect. Returns None if fully clipped.
5412
    fn clip(&self, clip: &AzRect) -> Option<AzRect> {
5412
        let x1 = self.x.max(clip.x);
5412
        let y1 = self.y.max(clip.y);
5412
        let x2 = (self.x + self.width).min(clip.x + clip.width);
5412
        let y2 = (self.y + self.height).min(clip.y + clip.height);
5412
        if x2 > x1 && y2 > y1 {
880
            Some(AzRect {
880
                x: x1,
880
                y: y1,
880
                width: x2 - x1,
880
                height: y2 - y1,
880
            })
        } else {
4532
            None
        }
5412
    }
}
// ============================================================================
// AGG helper: fill a PathStorage with a solid color into an AzulPixmap
// ============================================================================
13772
fn agg_fill_path(
13772
    pixmap: &mut AzulPixmap,
13772
    path: &mut dyn VertexSource,
13772
    color: &Rgba8,
13772
    rule: FillingRule,
13772
) {
13772
    agg_fill_path_clipped(pixmap, path, color, rule, None);
13772
}
/// Fill a path with an optional pixel-level clip box.
///
/// When `clip` is `Some`, `RendererBase::clip_box_i()` restricts all
/// scanline output to the clip region.  This handles scroll-frame clips,
/// border-radius is TODO (would need a mask), transforms are handled by
/// transforming the clip box through the inverse transform before setting it.
14168
fn agg_fill_path_clipped(
14168
    pixmap: &mut AzulPixmap,
14168
    path: &mut dyn VertexSource,
14168
    color: &Rgba8,
14168
    rule: FillingRule,
14168
    clip: Option<AzRect>,
14168
) {
14168
    let w = pixmap.width;
14168
    let h = pixmap.height;
14168
    let stride = (w * 4) as i32;
14168
    let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
14168
    let mut pf = PixfmtRgba32::new(&mut ra);
14168
    let mut rb = RendererBase::new(pf);
14168
    if let Some(c) = clip {
        rb.clip_box_i(
            c.x as i32,
            c.y as i32,
            (c.x + c.width) as i32 - 1,
            (c.y + c.height) as i32 - 1,
        );
14168
    }
14168
    let mut ras = RasterizerScanlineAa::new();
14168
    ras.filling_rule(rule);
14168
    ras.add_path(path, 0);
14168
    let mut sl = ScanlineU8::new();
14168
    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, color);
14168
}
fn agg_fill_transformed_path(
    pixmap: &mut AzulPixmap,
    path: &mut PathStorage,
    color: &Rgba8,
    rule: FillingRule,
    transform: &TransAffine,
) {
    agg_fill_transformed_path_clipped(pixmap, path, color, rule, transform, None);
}
fn agg_fill_transformed_path_clipped(
    pixmap: &mut AzulPixmap,
    path: &mut PathStorage,
    color: &Rgba8,
    rule: FillingRule,
    transform: &TransAffine,
    clip: Option<AzRect>,
) {
    if transform.is_identity(IDENTITY_EPSILON_F64) {
        agg_fill_path_clipped(pixmap, path, color, rule, clip);
    } else {
        let mut transformed = ConvTransform::new(path, transform.clone());
        agg_fill_path_clipped(pixmap, &mut transformed, color, rule, clip);
    }
}
// ============================================================================
// AGG helper: fill a path with a gradient into an AzulPixmap
// ============================================================================
fn agg_fill_gradient<G: GradientFunction>(
    pixmap: &mut AzulPixmap,
    path: &mut dyn VertexSource,
    lut: &GradientLut,
    gradient_fn: G,
    transform: TransAffine,
    d1: f64,
    d2: f64,
) {
    agg_fill_gradient_clipped(pixmap, path, lut, gradient_fn, transform, d1, d2, None);
}
44
fn agg_fill_gradient_clipped<G: GradientFunction>(
44
    pixmap: &mut AzulPixmap,
44
    path: &mut dyn VertexSource,
44
    lut: &GradientLut,
44
    gradient_fn: G,
44
    transform: TransAffine,
44
    d1: f64,
44
    d2: f64,
44
    clip: Option<AzRect>,
44
) {
44
    let w = pixmap.width;
44
    let h = pixmap.height;
44
    let stride = (w * 4) as i32;
44
    let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
44
    let mut pf = PixfmtRgba32::new(&mut ra);
44
    let mut rb = RendererBase::new(pf);
44
    if let Some(c) = clip {
        rb.clip_box_i(
            c.x as i32,
            c.y as i32,
            (c.x + c.width) as i32 - 1,
            (c.y + c.height) as i32 - 1,
        );
44
    }
44
    let mut ras = RasterizerScanlineAa::new();
44
    ras.filling_rule(FillingRule::NonZero);
44
    ras.add_path(path, 0);
44
    let mut sl = ScanlineU8::new();
44
    let interp = SpanInterpolatorLinear::new(transform);
44
    let mut sg = SpanGradient::new(interp, gradient_fn, lut, d1, d2);
44
    let mut alloc = SpanAllocator::<Rgba8>::new();
44
    render_scanlines_aa(&mut ras, &mut sl, &mut rb, &mut alloc, &mut sg);
44
}
// ============================================================================
// Gradient helpers
// ============================================================================
/// Fallback color used when a `system:*` keyword cannot be resolved
/// (for example because no `SystemStyle` is attached to the
/// [`CpuRenderState`], or because the requested key is unset on the
/// current platform). CSS Images Level 4 leaves the color undefined in
/// this case; transparent black means the stop simply contributes
/// nothing to the gradient instead of poisoning it with an arbitrary
/// visible color (the previous behaviour was hardcoded mid-gray, which
/// produced visibly wrong output).
const SYSTEM_COLOR_FALLBACK: ColorU = ColorU {
    r: 0,
    g: 0,
    b: 0,
    a: 0,
};
/// Resolve a `ColorOrSystem` against the optional system palette.
///
/// Concrete colors are returned verbatim. `system:*` keywords are
/// resolved against `system_colors` when available and fall back to
/// `SYSTEM_COLOR_FALLBACK` otherwise.
88
fn resolve_color(
88
    color: &ColorOrSystem,
88
    system_colors: Option<&azul_css::system::SystemColors>,
88
) -> ColorU {
88
    match (color, system_colors) {
88
        (ColorOrSystem::Color(c), _) => *c,
        (ColorOrSystem::System(_), Some(sc)) => color.resolve(sc, SYSTEM_COLOR_FALLBACK),
        (ColorOrSystem::System(_), None) => SYSTEM_COLOR_FALLBACK,
    }
88
}
/// Build a GradientLut from normalized linear color stops.
44
fn build_gradient_lut_linear(
44
    stops: &azul_css::props::style::background::NormalizedLinearColorStopVec,
44
    system_colors: Option<&azul_css::system::SystemColors>,
44
) -> GradientLut {
44
    let mut lut = GradientLut::new_default();
44
    let stops_slice = stops.as_ref();
44
    if stops_slice.len() < 2 {
        // Need at least 2 stops; fill with transparent
        lut.add_color(0.0, Rgba8::new(0, 0, 0, 0));
        lut.add_color(1.0, Rgba8::new(0, 0, 0, 0));
        lut.build_lut();
        return lut;
44
    }
132
    for stop in stops_slice {
88
        let offset = stop.offset.normalized() as f64; // 0.0..1.0
88
        let c = resolve_color(&stop.color, system_colors);
88
        lut.add_color(
88
            offset,
88
            Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32),
88
        );
88
    }
44
    lut.build_lut();
44
    lut
44
}
/// Build a GradientLut from normalized radial (conic) color stops.
fn build_gradient_lut_radial(
    stops: &azul_css::props::style::background::NormalizedRadialColorStopVec,
    system_colors: Option<&azul_css::system::SystemColors>,
) -> GradientLut {
    let mut lut = GradientLut::new_default();
    let stops_slice = stops.as_ref();
    if stops_slice.len() < 2 {
        lut.add_color(0.0, Rgba8::new(0, 0, 0, 0));
        lut.add_color(1.0, Rgba8::new(0, 0, 0, 0));
        lut.build_lut();
        return lut;
    }
    for stop in stops_slice {
        // Conic stops use angle — normalize to 0..1 fraction of full circle
        let offset = (stop.angle.to_degrees() / 360.0).clamp(0.0, 1.0) as f64;
        let c = resolve_color(&stop.color, system_colors);
        lut.add_color(
            offset,
            Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32),
        );
    }
    lut.build_lut();
    lut
}
/// Resolve a background position to (x_fraction, y_fraction) in 0..1 range.
fn resolve_background_position(
    pos: &azul_css::props::style::background::StyleBackgroundPosition,
    width: f32,
    height: f32,
) -> (f32, f32) {
    use azul_css::props::style::background::{
        BackgroundPositionHorizontal, BackgroundPositionVertical,
    };
    let x = match pos.horizontal {
        BackgroundPositionHorizontal::Left => 0.0,
        BackgroundPositionHorizontal::Center => 0.5,
        BackgroundPositionHorizontal::Right => 1.0,
        BackgroundPositionHorizontal::Exact(px) => {
            let val = px.to_pixels_internal(width, 16.0, 16.0);
            if width > 0.0 {
                val / width
            } else {
                0.5
            }
        }
    };
    let y = match pos.vertical {
        BackgroundPositionVertical::Top => 0.0,
        BackgroundPositionVertical::Center => 0.5,
        BackgroundPositionVertical::Bottom => 1.0,
        BackgroundPositionVertical::Exact(px) => {
            let val = px.to_pixels_internal(height, 16.0, 16.0);
            if height > 0.0 {
                val / height
            } else {
                0.5
            }
        }
    };
    (x, y)
}
44
fn render_linear_gradient(
44
    pixmap: &mut AzulPixmap,
44
    bounds: &LogicalRect,
44
    gradient: &azul_css::props::style::background::LinearGradient,
44
    border_radius: &BorderRadius,
44
    clip: Option<AzRect>,
44
    dpi_factor: f32,
44
    system_colors: Option<&azul_css::system::SystemColors>,
44
) -> Result<(), String> {
    use azul_css::props::basic::geometry::{LayoutRect, LayoutSize};
44
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
44
        Some(r) => r,
        None => return Ok(()),
    };
44
    let stops = gradient.stops.as_ref();
44
    if stops.is_empty() {
        return Ok(());
44
    }
44
    let lut = build_gradient_lut_linear(&gradient.stops, system_colors);
    // Convert Direction to start/end points using the existing to_points method
44
    let layout_rect = LayoutRect {
44
        origin: azul_css::props::basic::geometry::LayoutPoint::new(0, 0),
44
        size: LayoutSize {
44
            width: (rect.width as isize),
44
            height: (rect.height as isize),
44
        },
44
    };
44
    let (from_pt, to_pt) = gradient.direction.to_points(&layout_rect);
    // Pixel-space start/end
44
    let x1 = rect.x as f64 + from_pt.x as f64;
44
    let y1 = rect.y as f64 + from_pt.y as f64;
44
    let x2 = rect.x as f64 + to_pt.x as f64;
44
    let y2 = rect.y as f64 + to_pt.y as f64;
44
    let dx = x2 - x1;
44
    let dy = y2 - y1;
44
    let len = (dx * dx + dy * dy).sqrt();
44
    if len < 0.001 {
        return Ok(());
44
    }
    // gradient-space (0..100, 0) → pixel-space line (x1,y1)→(x2,y2). Use agg's
    // helper so the composition order is T * R * S — hand-rolling it via
    // new_translation().rotate().scale() pre-multiplies and ends up as
    // S * R * T, which rotates the translation and yields out-of-range gx.
44
    let mut transform = TransAffine::new_line_segment(x1, y1, x2, y2, 100.0);
44
    transform.invert();
44
    let mut path = if border_radius.is_zero() {
44
        build_rect_path(&rect)
    } else {
        build_rounded_rect_path(&rect, border_radius, dpi_factor)
    };
44
    agg_fill_gradient_clipped(
44
        pixmap, &mut path, &lut, GradientX, transform, 0.0, 100.0, clip,
    );
44
    Ok(())
44
}
fn render_radial_gradient(
    pixmap: &mut AzulPixmap,
    bounds: &LogicalRect,
    gradient: &azul_css::props::style::background::RadialGradient,
    border_radius: &BorderRadius,
    clip: Option<AzRect>,
    dpi_factor: f32,
    system_colors: Option<&azul_css::system::SystemColors>,
) -> Result<(), String> {
    use azul_css::props::style::background::{RadialGradientSize, Shape};
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
        Some(r) => r,
        None => return Ok(()),
    };
    let stops = gradient.stops.as_ref();
    if stops.is_empty() {
        return Ok(());
    }
    let lut = build_gradient_lut_linear(&gradient.stops, system_colors);
    let w = rect.width as f64;
    let h = rect.height as f64;
    // Compute center from position
    let (cx_frac, cy_frac) =
        resolve_background_position(&gradient.position, rect.width, rect.height);
    let cx = rect.x as f64 + cx_frac as f64 * w;
    let cy = rect.y as f64 + cy_frac as f64 * h;
    // Compute radius based on shape and size
    let radius = match gradient.size {
        RadialGradientSize::ClosestSide => {
            let dx = (cx_frac as f64 * w).min((1.0 - cx_frac as f64) * w);
            let dy = (cy_frac as f64 * h).min((1.0 - cy_frac as f64) * h);
            match gradient.shape {
                Shape::Circle => dx.min(dy),
                Shape::Ellipse => dx.min(dy), // simplified
            }
        }
        RadialGradientSize::FarthestSide => {
            let dx = (cx_frac as f64 * w).max((1.0 - cx_frac as f64) * w);
            let dy = (cy_frac as f64 * h).max((1.0 - cy_frac as f64) * h);
            match gradient.shape {
                Shape::Circle => dx.max(dy),
                Shape::Ellipse => dx.max(dy),
            }
        }
        RadialGradientSize::ClosestCorner => {
            let dx = (cx_frac as f64 * w).min((1.0 - cx_frac as f64) * w);
            let dy = (cy_frac as f64 * h).min((1.0 - cy_frac as f64) * h);
            (dx * dx + dy * dy).sqrt()
        }
        RadialGradientSize::FarthestCorner => {
            let dx = (cx_frac as f64 * w).max((1.0 - cx_frac as f64) * w);
            let dy = (cy_frac as f64 * h).max((1.0 - cy_frac as f64) * h);
            (dx * dx + dy * dy).sqrt()
        }
    };
    if radius < 0.001 {
        return Ok(());
    }
    // Gradient-space (radius=100 at distance=100) → pixel-space around (cx, cy).
    // Build as T * S (scale first, then translate) so S only affects the radius.
    // scale() pre-multiplies so we must start from scaling matrix.
    let mut transform = TransAffine::new_scaling_uniform(radius / 100.0);
    transform.translate(cx, cy);
    transform.invert();
    let mut path = if border_radius.is_zero() {
        build_rect_path(&rect)
    } else {
        build_rounded_rect_path(&rect, border_radius, dpi_factor)
    };
    agg_fill_gradient_clipped(
        pixmap,
        &mut path,
        &lut,
        GradientRadialD,
        transform,
        0.0,
        100.0,
        clip,
    );
    Ok(())
}
fn render_conic_gradient(
    pixmap: &mut AzulPixmap,
    bounds: &LogicalRect,
    gradient: &azul_css::props::style::background::ConicGradient,
    border_radius: &BorderRadius,
    clip: Option<AzRect>,
    dpi_factor: f32,
    system_colors: Option<&azul_css::system::SystemColors>,
) -> Result<(), String> {
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
        Some(r) => r,
        None => return Ok(()),
    };
    let stops = gradient.stops.as_ref();
    if stops.is_empty() {
        return Ok(());
    }
    let lut = build_gradient_lut_radial(&gradient.stops, system_colors);
    let w = rect.width as f64;
    let h = rect.height as f64;
    // Compute center
    let (cx_frac, cy_frac) = resolve_background_position(&gradient.center, rect.width, rect.height);
    let cx = rect.x as f64 + cx_frac as f64 * w;
    let cy = rect.y as f64 + cy_frac as f64 * h;
    // Start angle (CSS conic gradients start at 12 o'clock = -90deg in math coords)
    let start_angle_deg = gradient.angle.to_degrees();
    let start_angle_rad = ((start_angle_deg - 90.0) as f64).to_radians();
    // Forward: gradient angle θ → pixel rotated by start_angle around (cx, cy).
    // Build as T * R so rotation is applied before translation (rotate() pre-multiplies,
    // so start from rotation matrix and translate last).
    let mut transform = TransAffine::new_rotation(start_angle_rad);
    transform.translate(cx, cy);
    transform.invert();
    // GradientConic maps atan2(y,x) * d / pi, covering [0, d] for the half-circle.
    // We use d2 = 100 as the range; the LUT maps 0..1 over that.
    let d2 = 100.0;
    let mut path = if border_radius.is_zero() {
        build_rect_path(&rect)
    } else {
        build_rounded_rect_path(&rect, border_radius, dpi_factor)
    };
    agg_fill_gradient_clipped(
        pixmap,
        &mut path,
        &lut,
        GradientConic,
        transform,
        0.0,
        d2,
        clip,
    );
    Ok(())
}
// ============================================================================
// Box shadow rendering
// ============================================================================
176
fn render_box_shadow(
176
    pixmap: &mut AzulPixmap,
176
    bounds: &LogicalRect,
176
    shadow: &azul_css::props::style::box_shadow::StyleBoxShadow,
176
    border_radius: &BorderRadius,
176
    dpi_factor: f32,
176
) -> Result<(), String> {
    use azul_css::props::style::box_shadow::BoxShadowClipMode;
176
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
176
        Some(r) => r,
        None => return Ok(()),
    };
176
    let offset_x =
176
        shadow
176
            .offset_x
176
            .inner
176
            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
176
            * dpi_factor;
176
    let offset_y =
176
        shadow
176
            .offset_y
176
            .inner
176
            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
176
            * dpi_factor;
176
    let blur_r =
176
        (shadow
176
            .blur_radius
176
            .inner
176
            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
176
            * dpi_factor)
176
            .max(0.0);
176
    let spread =
176
        shadow
176
            .spread_radius
176
            .inner
176
            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
176
            * dpi_factor;
176
    let color = shadow.color;
176
    if color.a == 0 {
        return Ok(());
176
    }
    // Compute shadow rect (expanded by spread, padded by blur)
176
    let padding = blur_r.ceil();
176
    let shadow_x = rect.x + offset_x - spread - padding;
176
    let shadow_y = rect.y + offset_y - spread - padding;
176
    let shadow_w = rect.width + 2.0 * spread + 2.0 * padding;
176
    let shadow_h = rect.height + 2.0 * spread + 2.0 * padding;
176
    if shadow_w <= 0.0 || shadow_h <= 0.0 {
        return Ok(());
176
    }
176
    let sw = shadow_w.ceil() as u32;
176
    let sh = shadow_h.ceil() as u32;
176
    if sw == 0 || sh == 0 || sw > MAX_SHADOW_PIXBUF_SIZE || sh > MAX_SHADOW_PIXBUF_SIZE {
        return Ok(());
176
    }
    // Create temp buffer and draw the shadow shape into it
176
    let mut tmp = AzulPixmap::new(sw, sh).ok_or("cannot create shadow pixmap")?;
176
    tmp.fill(0, 0, 0, 0); // transparent
    // The shape origin within the temp buffer
176
    let shape_x = padding + spread;
176
    let shape_y = padding + spread;
176
    let shape_rect = match AzRect::from_xywh(shape_x, shape_y, rect.width, rect.height) {
176
        Some(r) => r,
        None => return Ok(()),
    };
176
    let agg_color = Rgba8::new(
176
        color.r as u32,
176
        color.g as u32,
176
        color.b as u32,
176
        color.a as u32,
    );
176
    if border_radius.is_zero() {
176
        let mut path = build_rect_path(&shape_rect);
176
        agg_fill_path(&mut tmp, &mut path, &agg_color, FillingRule::NonZero);
176
    } else {
        let mut path = build_rounded_rect_path(&shape_rect, border_radius, dpi_factor);
        agg_fill_path(&mut tmp, &mut path, &agg_color, FillingRule::NonZero);
    }
    // Apply blur
176
    if blur_r > 0.5 {
176
        let blur_radius = (blur_r.ceil() as u32).min(254);
176
        let stride = (sw * 4) as i32;
176
        let mut ra = unsafe { RowAccessor::new_with_buf(tmp.data.as_mut_ptr(), sw, sh, stride) };
176
        stack_blur_rgba32(&mut ra, blur_radius, blur_radius);
176
    }
    // Blit the shadow buffer onto the main pixmap
176
    let dst_x = shadow_x as i32;
176
    let dst_y = shadow_y as i32;
176
    blit_buffer(pixmap, &tmp.data, sw, sh, dst_x, dst_y);
176
    Ok(())
176
}
/// Alpha-blend one premultiplied-alpha RGBA buffer onto another at (dx, dy).
176
fn blit_buffer(dst: &mut AzulPixmap, src: &[u8], src_w: u32, src_h: u32, dx: i32, dy: i32) {
176
    let dw = dst.width as i32;
176
    let dh = dst.height as i32;
13376
    for py in 0..src_h as i32 {
13376
        let ty = dy + py;
13376
        if ty < 0 || ty >= dh {
            continue;
13376
        }
1016576
        for px in 0..src_w as i32 {
1016576
            let tx = dx + px;
1016576
            if tx < 0 || tx >= dw {
                continue;
1016576
            }
1016576
            let si = ((py as u32 * src_w + px as u32) * 4) as usize;
1016576
            let di = ((ty as u32 * dst.width + tx as u32) * 4) as usize;
1016576
            if si + 3 >= src.len() || di + 3 >= dst.data.len() {
                continue;
1016576
            }
1016576
            let sa = src[si + 3] as u32;
1016576
            if sa == 0 {
22528
                continue;
994048
            }
994048
            if sa == 255 {
                dst.data[di] = src[si];
                dst.data[di + 1] = src[si + 1];
                dst.data[di + 2] = src[si + 2];
                dst.data[di + 3] = 255;
994048
            } else {
994048
                // Premultiplied-alpha compositing: src RGB already premultiplied by AGG
994048
                let inv_sa = 255 - sa;
994048
                dst.data[di] =
994048
                    ((src[si] as u32 + dst.data[di] as u32 * inv_sa / 255).min(255)) as u8;
994048
                dst.data[di + 1] =
994048
                    ((src[si + 1] as u32 + dst.data[di + 1] as u32 * inv_sa / 255).min(255)) as u8;
994048
                dst.data[di + 2] =
994048
                    ((src[si + 2] as u32 + dst.data[di + 2] as u32 * inv_sa / 255).min(255)) as u8;
994048
                dst.data[di + 3] = ((sa + dst.data[di + 3] as u32 * inv_sa / 255).min(255)) as u8;
994048
            }
        }
    }
176
}
// ============================================================================
// Image mask clipping
// ============================================================================
/// Entry on the mask/opacity stack.
enum MaskEntry {
    /// Image mask clip (R8 mask).
    ImageMask {
        snapshot: Vec<u8>,
        mask_data: Vec<u8>,
        origin_x: i32,
        origin_y: i32,
        width: u32,
        height: u32,
    },
    /// Opacity layer.
    Opacity {
        snapshot: Vec<u8>,
        rect: AzRect,
        opacity: f32,
    },
}
/// Take a snapshot of a rectangular region of the pixmap.
220
fn snapshot_region(pixmap: &AzulPixmap, x: i32, y: i32, w: u32, h: u32) -> Vec<u8> {
220
    let pw = pixmap.width as i32;
220
    let ph = pixmap.height as i32;
220
    let mut snap = vec![0u8; (w as usize) * (h as usize) * 4];
22000
    for py in 0..h as i32 {
22000
        let sy = y + py;
22000
        if sy < 0 || sy >= ph {
            continue;
22000
        }
2640000
        for px in 0..w as i32 {
2640000
            let sx = x + px;
2640000
            if sx < 0 || sx >= pw {
                continue;
2640000
            }
2640000
            let si = ((sy as u32 * pixmap.width + sx as u32) * 4) as usize;
2640000
            let di = ((py as u32 * w + px as u32) * 4) as usize;
2640000
            if si + 3 < pixmap.data.len() && di + 3 < snap.len() {
2640000
                snap[di] = pixmap.data[si];
2640000
                snap[di + 1] = pixmap.data[si + 1];
2640000
                snap[di + 2] = pixmap.data[si + 2];
2640000
                snap[di + 3] = pixmap.data[si + 3];
2640000
            }
        }
    }
220
    snap
220
}
/// Extract and scale mask image data (R8) to target dimensions.
220
fn extract_mask_data(mask_image: &ImageRef, target_w: u32, target_h: u32) -> Option<Vec<u8>> {
220
    let image_data = mask_image.get_data();
220
    let (mask_bytes, src_w, src_h) = match &*image_data {
220
        DecodedImage::Raw((descriptor, data)) => {
220
            let w = descriptor.width as u32;
220
            let h = descriptor.height as u32;
220
            if w == 0 || h == 0 {
                return None;
220
            }
220
            let bytes = match data {
220
                azul_core::resources::ImageData::Raw(shared) => shared.as_ref(),
                _ => return None,
            };
220
            match descriptor.format {
220
                azul_core::resources::RawImageFormat::R8 => (bytes.to_vec(), w, h),
                azul_core::resources::RawImageFormat::BGRA8 => {
                    // Use alpha channel as mask
                    let mut r8 = Vec::with_capacity((w * h) as usize);
                    for chunk in bytes.chunks_exact(4) {
                        r8.push(chunk[3]); // alpha
                    }
                    (r8, w, h)
                }
                _ => {
                    // Use first channel as grayscale mask
                    let chan_count = bytes.len() / (w * h) as usize;
                    if chan_count == 0 {
                        return None;
                    }
                    let mut r8 = Vec::with_capacity((w * h) as usize);
                    for i in 0..(w * h) as usize {
                        r8.push(bytes[i * chan_count]);
                    }
                    (r8, w, h)
                }
            }
        }
        _ => return None,
    };
220
    if target_w == 0 || target_h == 0 {
        return None;
220
    }
    // Scale mask to target dimensions via nearest-neighbor
220
    let mut scaled = vec![0u8; (target_w * target_h) as usize];
220
    let sx = src_w as f32 / target_w as f32;
220
    let sy = src_h as f32 / target_h as f32;
22000
    for py in 0..target_h {
2640000
        for px in 0..target_w {
2640000
            let mx = ((px as f32 * sx) as u32).min(src_w - 1);
2640000
            let my = ((py as f32 * sy) as u32).min(src_h - 1);
2640000
            scaled[(py * target_w + px) as usize] = mask_bytes[(my * src_w + mx) as usize];
2640000
        }
    }
220
    Some(scaled)
220
}
/// Apply a mask: for each pixel in the mask region, blend between the snapshot
/// (pre-mask state) and the current pixmap state using the mask value.
220
fn apply_mask(pixmap: &mut AzulPixmap, entry: &MaskEntry) {
220
    let (snapshot, mask_data, origin_x, origin_y, width, height) = match entry {
        MaskEntry::ImageMask {
220
            snapshot,
220
            mask_data,
220
            origin_x,
220
            origin_y,
220
            width,
220
            height,
220
        } => (
220
            snapshot,
220
            mask_data.as_slice(),
220
            *origin_x,
220
            *origin_y,
220
            *width,
220
            *height,
220
        ),
        _ => return,
    };
220
    let pw = pixmap.width as i32;
220
    let ph = pixmap.height as i32;
22000
    for py in 0..height as i32 {
22000
        let dy = origin_y + py;
22000
        if dy < 0 || dy >= ph {
            continue;
22000
        }
2640000
        for px in 0..width as i32 {
2640000
            let dx = origin_x + px;
2640000
            if dx < 0 || dx >= pw {
                continue;
2640000
            }
2640000
            let mi = (py as u32 * width + px as u32) as usize;
2640000
            let mask_val = mask_data.get(mi).copied().unwrap_or(0) as u32;
2640000
            let pi = ((dy as u32 * pixmap.width + dx as u32) * 4) as usize;
2640000
            let si = ((py as u32 * width + px as u32) * 4) as usize;
2640000
            if pi + 3 >= pixmap.data.len() || si + 3 >= snapshot.len() {
                continue;
2640000
            }
            // Blend: result = snapshot * (255 - mask) + current * mask
            // mask_val 255 = fully visible (keep current), 0 = fully clipped (restore snapshot)
2640000
            let inv_mask = 255 - mask_val;
13200000
            for c in 0..4 {
10560000
                let snap_c = snapshot[si + c] as u32;
10560000
                let cur_c = pixmap.data[pi + c] as u32;
10560000
                pixmap.data[pi + c] = ((cur_c * mask_val + snap_c * inv_mask) / 255) as u8;
10560000
            }
        }
    }
220
}
// ============================================================================
// Public API
// ============================================================================
pub struct RenderOptions {
    pub width: f32,
    pub height: f32,
    pub dpi_factor: f32,
}
/// Reuse `retained` pixmap if it matches the target dimensions, otherwise allocate new.
1100
fn acquire_pixmap(retained: Option<AzulPixmap>, w: u32, h: u32) -> Result<AzulPixmap, String> {
1100
    if let Some(p) = retained {
        if p.width == w && p.height == h {
            return Ok(p);
        }
1100
    }
1100
    AzulPixmap::new(w, h).ok_or_else(|| "cannot create pixmap".to_string())
1100
}
pub fn render(
    dl: &DisplayList,
    res: &RendererResources,
    opts: RenderOptions,
    glyph_cache: &mut GlyphCache,
) -> Result<AzulPixmap, String> {
    let RenderOptions {
        width,
        height,
        dpi_factor,
    } = opts;
    let mut pixmap = acquire_pixmap(
        None,
        (width * dpi_factor) as u32,
        (height * dpi_factor) as u32,
    )?;
    pixmap.fill(255, 255, 255, 255);
    render_display_list(dl, &mut pixmap, dpi_factor, res, None, glyph_cache)?;
    Ok(pixmap)
}
/// Render a display list using fonts from FontManager directly.
/// This is used in reftest scenarios where RendererResources doesn't have fonts registered.
1100
pub fn render_with_font_manager(
1100
    dl: &DisplayList,
1100
    res: &RendererResources,
1100
    font_manager: &FontManager<FontRef>,
1100
    opts: RenderOptions,
1100
    glyph_cache: &mut GlyphCache,
1100
) -> Result<AzulPixmap, String> {
1100
    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
1100
    render_with_font_manager_and_scroll(dl, res, font_manager, opts, glyph_cache, &empty_state)
1100
}
/// Render with FontManager and explicit render state (scroll offsets + GPU values).
/// Used by `take_screenshot` to render with the current scroll/transform/opacity state.
1100
pub fn render_with_font_manager_and_scroll(
1100
    dl: &DisplayList,
1100
    res: &RendererResources,
1100
    font_manager: &FontManager<FontRef>,
1100
    opts: RenderOptions,
1100
    glyph_cache: &mut GlyphCache,
1100
    render_state: &CpuRenderState,
1100
) -> Result<AzulPixmap, String> {
1100
    render_with_font_manager_and_scroll_retained(
1100
        dl,
1100
        res,
1100
        font_manager,
1100
        opts,
1100
        glyph_cache,
1100
        render_state,
1100
        None,
    )
1100
}
/// Render with optional retained pixmap. If `retained` is Some and matches
/// the target dimensions, it is reused (cleared to white) instead of
/// allocating a fresh buffer. The pixmap is returned regardless.
1100
pub fn render_with_font_manager_and_scroll_retained(
1100
    dl: &DisplayList,
1100
    res: &RendererResources,
1100
    font_manager: &FontManager<FontRef>,
1100
    opts: RenderOptions,
1100
    glyph_cache: &mut GlyphCache,
1100
    render_state: &CpuRenderState,
1100
    retained: Option<AzulPixmap>,
1100
) -> Result<AzulPixmap, String> {
    let RenderOptions {
1100
        width,
1100
        height,
1100
        dpi_factor,
1100
    } = opts;
1100
    let pw = (width * dpi_factor) as u32;
1100
    let ph = (height * dpi_factor) as u32;
1100
    let mut pixmap = acquire_pixmap(retained, pw, ph)?;
1100
    pixmap.fill(255, 255, 255, 255);
1100
    render_display_list_with_state(
1100
        dl,
1100
        &mut pixmap,
1100
        dpi_factor,
1100
        res,
1100
        Some(font_manager),
1100
        glyph_cache,
1100
        render_state,
    )?;
1100
    Ok(pixmap)
1100
}
/// Scroll offsets keyed by scroll_id (LocalScrollId).
/// Passed to the renderer so it can look up the current scroll position
/// for each PushScrollFrame without embedding it in the display list.
pub type ScrollOffsetMap = HashMap<LocalScrollId, (f32, f32)>;
/// Compute damage rects by comparing two display lists item by item.
///
/// Returns a list of bounding rects that need repainting, or `None` if a
/// full repaint is required (structural change, different item count, etc.).
///
/// The comparison is conservative: any item whose bounds or content changed
/// produces a damage rect covering both the old and new bounds.
132
pub fn compute_display_list_damage(
132
    old: &DisplayList,
132
    new: &DisplayList,
132
) -> Option<Vec<LogicalRect>> {
    // Different item counts → structural change → full repaint
132
    if old.items.len() != new.items.len() {
132
        return None;
    }
    let mut damage = Vec::new();
    for (old_item, new_item) in old.items.iter().zip(new.items.iter()) {
        // Compare discriminant first (cheap)
        if std::mem::discriminant(old_item) != std::mem::discriminant(new_item) {
            return None; // structural change
        }
        // Compare full visual content, not just bounds — a color or text
        // change within the same bounds must still produce a damage rect.
        // Use visual_bounds() to include effects like box-shadow extent.
        if !old_item.is_visually_equal(new_item) {
            let old_bounds = old_item.visual_bounds();
            let new_bounds = new_item.visual_bounds();
            if let Some(ob) = old_bounds {
                damage.push(ob);
            }
            if let Some(nb) = new_bounds {
                damage.push(nb);
            }
        }
    }
    // Coalesce overlapping rects
    coalesce_damage_rects(&mut damage);
    Some(damage)
132
}
/// Are two display lists visually identical? (same length, same item
/// discriminants, every item `is_visually_equal`). Cheaper proxy than a
/// structural hash, reusing the same per-item comparison the damage diff uses.
pub fn display_lists_visually_equal(a: &DisplayList, b: &DisplayList) -> bool {
    if a.items.len() != b.items.len() {
        return false;
    }
    a.items.iter().zip(b.items.iter()).all(|(x, y)| {
        std::mem::discriminant(x) == std::mem::discriminant(y) && x.is_visually_equal(y)
    })
}
/// Damage rects for `VirtualView` child DOMs whose content changed since the
/// previous frame.
///
/// The parent display list only carries a `VirtualView { child_dom_id, bounds }`
/// item that stays byte-identical when the *child* DOM re-renders (e.g. a
/// MapWidget tile arriving on a worker thread and re-invoking the VirtualView
/// in place). So `compute_display_list_damage` — which only diffs the parent —
/// reports "nothing changed", and `render_frame` would skip the frame, freezing
/// the child content. This compares each VirtualView's child DL against the
/// previous frame's and returns the on-screen bounds of every one that differs,
/// so the caller can damage exactly those regions.
///
/// `current` / `previous` are keyed by the child `DomId` (the non-root entries
/// of `layout_results`). A child that is newly present or newly absent counts
/// as changed.
pub fn compute_virtual_view_damage(
    parent: &DisplayList,
    current: &std::collections::BTreeMap<azul_core::dom::DomId, std::sync::Arc<DisplayList>>,
    previous: &std::collections::BTreeMap<azul_core::dom::DomId, std::sync::Arc<DisplayList>>,
) -> Vec<LogicalRect> {
    let mut damage = Vec::new();
    for item in parent.items.iter() {
        if let DisplayListItem::VirtualView { child_dom_id, bounds, .. } = item {
            let changed = match (current.get(child_dom_id), previous.get(child_dom_id)) {
                (Some(c), Some(p)) => {
                    // Same Arc → definitely unchanged (cheap fast-path).
                    !std::sync::Arc::ptr_eq(c, p) && !display_lists_visually_equal(c, p)
                }
                (Some(_), None) | (None, Some(_)) => true,
                (None, None) => false,
            };
            if changed {
                damage.push(*bounds.inner());
            }
        }
    }
    damage
}
/// Merge overlapping or adjacent damage rects to reduce overdraw.
fn coalesce_damage_rects(rects: &mut Vec<LogicalRect>) {
    if rects.len() <= 1 {
        return;
    }
    // Simple O(n^2) merge — fine for typical damage counts (<20 rects)
    let mut changed = true;
    while changed {
        changed = false;
        let mut i = 0;
        while i < rects.len() {
            let mut j = i + 1;
            while j < rects.len() {
                // 8 logical pixels: merge rects that are close enough to avoid
                // many tiny damage regions that would cause redundant repaints —
                // BUT only when the merged box doesn't balloon the repaint. Two
                // PERPENDICULAR thin strips (e.g. a vertical + a horizontal
                // scrollbar meeting at a corner) are "adjacent" yet their bounding
                // box is the whole viewport: merging them turns ~3k px of overdraw
                // into ~20k. Reject a merge whose union is much larger than the two
                // rects combined; keep them separate instead.
                if rects_overlap_or_adjacent(&rects[i], &rects[j], 8.0) {
                    let u = union_rect(&rects[i], &rects[j]);
                    let area_u = (u.size.width * u.size.height).max(0.0);
                    let area_i = (rects[i].size.width * rects[i].size.height).max(0.0);
                    let area_j = (rects[j].size.width * rects[j].size.height).max(0.0);
                    // 1.5× slack covers genuine overlap (union < sum) and small-gap
                    // tiling (union ≈ sum) while rejecting perpendicular-strip bboxes.
                    if area_u <= (area_i + area_j) * 1.5 + 64.0 {
                        rects[i] = u;
                        rects.swap_remove(j);
                        changed = true;
                    } else {
                        j += 1;
                    }
                } else {
                    j += 1;
                }
            }
            i += 1;
        }
    }
}
136
fn rects_overlap_or_adjacent(a: &LogicalRect, b: &LogicalRect, gap: f32) -> bool {
136
    a.origin.x - gap <= b.origin.x + b.size.width
136
        && b.origin.x - gap <= a.origin.x + a.size.width
136
        && a.origin.y - gap <= b.origin.y + b.size.height
136
        && b.origin.y - gap <= a.origin.y + a.size.height
136
}
pub fn union_rect(a: &LogicalRect, b: &LogicalRect) -> LogicalRect {
    let x = a.origin.x.min(b.origin.x);
    let y = a.origin.y.min(b.origin.y);
    let right = (a.origin.x + a.size.width).max(b.origin.x + b.size.width);
    let bottom = (a.origin.y + a.size.height).max(b.origin.y + b.size.height);
    LogicalRect {
        origin: LogicalPosition { x, y },
        size: LogicalSize {
            width: right - x,
            height: bottom - y,
        },
    }
}
/// Compute damage rects for a grow-only window resize.
/// Returns the right strip and bottom strip that need rendering.
pub fn compute_resize_damage(
    old_width: f32,
    old_height: f32,
    new_width: f32,
    new_height: f32,
) -> Vec<LogicalRect> {
    let mut rects = Vec::new();
    if new_width > old_width {
        rects.push(LogicalRect {
            origin: LogicalPosition {
                x: old_width,
                y: 0.0,
            },
            size: LogicalSize {
                width: new_width - old_width,
                height: new_height,
            },
        });
    }
    if new_height > old_height {
        rects.push(LogicalRect {
            origin: LogicalPosition {
                x: 0.0,
                y: old_height,
            },
            size: LogicalSize {
                width: old_width.min(new_width),
                height: new_height - old_height,
            },
        });
    }
    rects
}
/// Compare a rectangular sub-region of two pixmaps pixel-by-pixel.
/// Returns the number of pixels that differ by more than `threshold` per channel.
44
pub fn compare_region(
44
    a: &AzulPixmap,
44
    b: &AzulPixmap,
44
    x: u32,
44
    y: u32,
44
    w: u32,
44
    h: u32,
44
    threshold: u8,
44
) -> usize {
44
    let mut diff_count = 0;
8800
    for row in y..(y + h).min(a.height).min(b.height) {
1760000
        for col in x..(x + w).min(a.width).min(b.width) {
1760000
            let ai = (row * a.width + col) as usize * 4;
1760000
            let bi = (row * b.width + col) as usize * 4;
1760000
            if ai + 3 >= a.data.len() || bi + 3 >= b.data.len() {
                continue;
1760000
            }
1760000
            let dr = (a.data[ai] as i16 - b.data[bi] as i16).unsigned_abs() as u8;
1760000
            let dg = (a.data[ai + 1] as i16 - b.data[bi + 1] as i16).unsigned_abs() as u8;
1760000
            let db = (a.data[ai + 2] as i16 - b.data[bi + 2] as i16).unsigned_abs() as u8;
1760000
            if dr > threshold || dg > threshold || db > threshold {
                diff_count += 1;
1760000
            }
        }
    }
44
    diff_count
44
}
/// Consolidated render-time state for CPU rendering.
///
/// Bundles scroll offsets and GPU-animated values (transforms, opacities)
/// that WebRender would normally manage internally. In cpurender these
/// are looked up from the `GpuValueCache` at screenshot time.
pub struct CpuRenderState {
    /// Scroll offsets by scroll_id
    pub scroll_offsets: ScrollOffsetMap,
    /// Transform values keyed by TransformKey.id — scrollbar thumb positions
    /// and CSS transforms that are GPU-animated in WebRender.
    pub transforms: HashMap<usize, azul_core::transform::ComputedTransform3D>,
    /// Opacity values keyed by OpacityKey.id — scrollbar fade-in/out.
    /// For WhenScrolling mode, opacity is 1.0 when recently scrolled,
    /// fades to 0.0 after idle. For Always mode, opacity is always 1.0.
    pub opacities: HashMap<usize, f32>,
    /// System style for resolving system color references inside gradient
    /// stops (e.g. `system:accent` in macOS button backgrounds). When None,
    /// system color stops fall back to a transparent color.
    pub system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
    /// Display lists of nested `VirtualView` child DOMs, keyed by their
    /// `child_dom_id`. The WebRender path composites these via separate pipelines;
    /// the CPU path has no pipelines, so the `DisplayListItem::VirtualView` arm
    /// recursively rasterises the child's display list from here (translated to the
    /// item's `bounds.origin`, clipped to `bounds`). Empty for non-window renders.
    pub virtual_view_display_lists:
        std::collections::BTreeMap<azul_core::dom::DomId, std::sync::Arc<DisplayList>>,
    /// Resolved images for `DecodedImage::Callback` `<img>` nodes, keyed by the
    /// callback image's hash. The CPU renderer can't invoke `RenderImageCallback`s
    /// itself (it would draw a grey placeholder); the backend pre-invokes them
    /// via [`crate::window::LayoutWindow::invoke_cpu_image_callbacks`] and passes
    /// the produced images here, where the `DisplayListItem::Image` arm looks
    /// them up by hash. Empty when there are no callback images.
    pub image_callback_results:
        std::collections::BTreeMap<azul_core::resources::ImageRefHash, azul_core::resources::ImageRef>,
}
impl CpuRenderState {
1936
    pub fn new(scroll_offsets: ScrollOffsetMap) -> Self {
1936
        Self {
1936
            scroll_offsets,
1936
            transforms: HashMap::new(),
1936
            opacities: HashMap::new(),
1936
            system_style: None,
1936
            virtual_view_display_lists: std::collections::BTreeMap::new(),
1936
            image_callback_results: std::collections::BTreeMap::new(),
1936
        }
1936
    }
    /// Provide the resolved `RenderImageCallback` images (see the field doc).
    pub fn with_image_callback_results(
        mut self,
        results: std::collections::BTreeMap<
            azul_core::resources::ImageRefHash,
            azul_core::resources::ImageRef,
        >,
    ) -> Self {
        self.image_callback_results = results;
        self
    }
    /// Provide the nested `VirtualView` child DOM display lists so the CPU
    /// renderer can composite them (see the field doc).
88
    pub fn with_virtual_view_display_lists(
88
        mut self,
88
        lists: std::collections::BTreeMap<azul_core::dom::DomId, std::sync::Arc<DisplayList>>,
88
    ) -> Self {
88
        self.virtual_view_display_lists = lists;
88
        self
88
    }
    /// Attach a `SystemStyle` so the renderer can resolve `system:*` color
    /// keywords (e.g. in gradient stops) against the live OS palette.
748
    pub fn with_system_style(
748
        mut self,
748
        system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
748
    ) -> Self {
748
        self.system_style = system_style;
748
        self
748
    }
    /// Build from a GpuValueCache snapshot.
    pub fn from_gpu_cache(
        gpu_cache: Option<&azul_core::gpu::GpuValueCache>,
        dom_id: azul_core::dom::DomId,
        scroll_offsets: &ScrollOffsetMap,
    ) -> Self {
        let mut transforms = HashMap::new();
        let mut opacities = HashMap::new();
        if let Some(cache) = gpu_cache {
            // Scrollbar thumb transforms (vertical)
            for (node_id, key) in &cache.transform_keys {
                if let Some(value) = cache.current_transform_values.get(node_id) {
                    transforms.insert(key.id, value.clone());
                }
            }
            // Scrollbar thumb transforms (horizontal)
            for (node_id, key) in &cache.h_transform_keys {
                if let Some(value) = cache.h_current_transform_values.get(node_id) {
                    transforms.insert(key.id, value.clone());
                }
            }
            // CSS transforms
            for (node_id, key) in &cache.css_transform_keys {
                if let Some(value) = cache.css_current_transform_values.get(node_id) {
                    transforms.insert(key.id, value.clone());
                }
            }
            // Scrollbar opacity (vertical)
            for ((d, node_id), key) in &cache.scrollbar_v_opacity_keys {
                if *d == dom_id {
                    if let Some(&value) = cache.scrollbar_v_opacity_values.get(&(*d, *node_id)) {
                        opacities.insert(key.id, value);
                    }
                }
            }
            // Scrollbar opacity (horizontal)
            for ((d, node_id), key) in &cache.scrollbar_h_opacity_keys {
                if *d == dom_id {
                    if let Some(&value) = cache.scrollbar_h_opacity_values.get(&(*d, *node_id)) {
                        opacities.insert(key.id, value);
                    }
                }
            }
            // CSS opacity
            for (node_id, key) in &cache.opacity_keys {
                if let Some(&value) = cache.current_opacity_values.get(node_id) {
                    opacities.insert(key.id, value);
                }
            }
        }
        Self {
            scroll_offsets: scroll_offsets.clone(),
            transforms,
            opacities,
            system_style: None,
            virtual_view_display_lists: std::collections::BTreeMap::new(),
            image_callback_results: std::collections::BTreeMap::new(),
        }
    }
}
fn render_display_list(
    display_list: &DisplayList,
    pixmap: &mut AzulPixmap,
    dpi_factor: f32,
    renderer_resources: &RendererResources,
    font_manager: Option<&FontManager<FontRef>>,
    glyph_cache: &mut GlyphCache,
) -> Result<(), String> {
    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
    render_display_list_with_state(
        display_list,
        pixmap,
        dpi_factor,
        renderer_resources,
        font_manager,
        glyph_cache,
        &empty_state,
    )
}
1848
fn render_display_list_with_state(
1848
    display_list: &DisplayList,
1848
    pixmap: &mut AzulPixmap,
1848
    dpi_factor: f32,
1848
    renderer_resources: &RendererResources,
1848
    font_manager: Option<&FontManager<FontRef>>,
1848
    glyph_cache: &mut GlyphCache,
1848
    render_state: &CpuRenderState,
1848
) -> Result<(), String> {
1848
    let mut transform_stack = vec![TransAffine::new()]; // identity
1848
    let mut clip_stack: Vec<Option<AzRect>> = vec![None];
1848
    let mut mask_stack: Vec<MaskEntry> = Vec::new();
    // Accumulated scroll offset stack. Each PushScrollFrame pushes
    // (parent_offset_x + scroll_x, parent_offset_y + scroll_y).
    // Items inside a scroll frame have their bounds shifted by the
    // accumulated offset before rendering.
1848
    let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
1848
    let _p_loop = crate::probe::Probe::span("raster_loop");
18788
    for item in &display_list.items {
16940
        let _p_item = crate::probe::Probe::span(probe_label_for_item(item));
16940
        render_single_item(
16940
            item,
16940
            pixmap,
16940
            dpi_factor,
16940
            renderer_resources,
16940
            font_manager,
16940
            glyph_cache,
16940
            &mut transform_stack,
16940
            &mut clip_stack,
16940
            &mut mask_stack,
16940
            &mut scroll_offset_stack,
16940
            render_state,
        )?;
    }
1848
    Ok(())
1848
}
/// Compact item-kind label for [`crate::probe`]. Names must be `'static`
/// strings (probe events store `&'static str` for cheap aggregation),
/// hence the closed match instead of formatting `Debug`.
#[inline]
16940
fn probe_label_for_item(item: &DisplayListItem) -> &'static str {
    use crate::solver3::display_list::DisplayListItem as I;
16940
    match item {
4048
        I::Rect { .. } => "dl:rect",
        I::SelectionRect { .. } => "dl:sel_rect",
880
        I::CursorRect { .. } => "dl:cursor",
2948
        I::Border { .. } => "dl:border",
1144
        I::Text { .. } => "dl:text",
1144
        I::TextLayout { .. } => "dl:text_layout",
132
        I::Image { .. } => "dl:image",
        I::ScrollBar { .. } => "dl:scrollbar_raw",
        I::ScrollBarStyled { .. } => "dl:scrollbar",
        I::PushClip { .. } => "dl:push_clip",
        I::PopClip => "dl:pop_clip",
        I::PushScrollFrame { .. } => "dl:push_scroll",
        I::PopScrollFrame => "dl:pop_scroll",
1848
        I::PushStackingContext { .. } => "dl:push_stack",
1848
        I::PopStackingContext => "dl:pop_stack",
        I::PushReferenceFrame { .. } => "dl:push_ref",
        I::PopReferenceFrame => "dl:pop_ref",
        I::PushOpacity { .. } => "dl:push_opacity",
        I::PopOpacity => "dl:pop_opacity",
        I::PushFilter { .. } => "dl:push_filter",
        I::PopFilter => "dl:pop_filter",
        I::PushBackdropFilter { .. } => "dl:push_bdfilter",
        I::PopBackdropFilter => "dl:pop_bdfilter",
        I::PushTextShadow { .. } => "dl:push_tshadow",
        I::PopTextShadow => "dl:pop_tshadow",
220
        I::PushImageMaskClip { .. } => "dl:push_imask",
220
        I::PopImageMaskClip => "dl:pop_imask",
44
        I::LinearGradient { .. } => "dl:linear_grad",
        I::RadialGradient { .. } => "dl:radial_grad",
        I::ConicGradient { .. } => "dl:conic_grad",
176
        I::BoxShadow { .. } => "dl:box_shadow",
        I::Underline { .. } => "dl:underline",
        I::Strikethrough { .. } => "dl:strike",
        I::Overline { .. } => "dl:overline",
2288
        I::HitTestArea { .. } => "dl:hit",
        I::VirtualView { .. } => "dl:vview",
        I::VirtualViewPlaceholder { .. } => "dl:vview_ph",
    }
16940
}
/// Render only the damaged regions of a display list into a retained pixmap.
///
/// For each damage rect:
/// 1. Clear that region in the pixmap (fill with background color).
/// 2. Iterate all display list items, skip those entirely outside the damage rect.
/// 3. Render intersecting items clipped to the damage rect.
///
/// Push/Pop state commands are always processed (they maintain clip/scroll stacks).
44
pub fn render_display_list_damaged(
44
    display_list: &DisplayList,
44
    pixmap: &mut AzulPixmap,
44
    dpi_factor: f32,
44
    renderer_resources: &RendererResources,
44
    font_manager: Option<&FontManager<FontRef>>,
44
    glyph_cache: &mut GlyphCache,
44
    render_state: &CpuRenderState,
44
    damage_rects: &[LogicalRect],
44
) -> Result<(), String> {
44
    if damage_rects.is_empty() {
        return Ok(()); // nothing changed
44
    }
    // Clear damaged regions to white
88
    for dr in damage_rects {
44
        let px = (dr.origin.x * dpi_factor) as i32;
44
        let py = (dr.origin.y * dpi_factor) as i32;
44
        let pw = (dr.size.width * dpi_factor) as i32;
44
        let ph = (dr.size.height * dpi_factor) as i32;
44
        pixmap.fill_rect(px, py, pw, ph, 255, 255, 255, 255);
44
    }
    // Items are individually tested against each damage rect below
    // (line-by-line). We iterate items ONCE (not per-rect) to avoid
    // double-rendering items that span multiple rects (alpha-blending artifacts).
    //
    // The BASE CLIP is the union of the damage rects: an item that only
    // PARTIALLY intersects the damage must not repaint its full bounds —
    // otherwise it overpaints neighbours that don't intersect the damage and
    // therefore never repaint. (Live bug: the maps header background TOUCHES
    // the VirtualView damage band below it, so it repainted all 70px of
    // header — wiping the toolbar buttons, which lie outside the damage and
    // were skipped. The toolbar vanished on the first incremental frame.)
44
    let union = {
44
        let mut it = damage_rects.iter();
44
        let first = *it.next().unwrap();
44
        it.fold(first, |acc, r| union_rect(&acc, r))
    };
44
    let base_clip = logical_rect_to_az_rect(&union, dpi_factor);
44
    let mut transform_stack = vec![TransAffine::new()];
44
    let mut clip_stack: Vec<Option<AzRect>> = vec![base_clip];
44
    let mut mask_stack: Vec<MaskEntry> = Vec::new();
44
    let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
220
    for item in display_list.items.iter() {
        // Always process state-management items (Push/Pop) regardless of bounds,
        // because skipping a Push while processing its matching Pop corrupts stacks.
220
        if !item.is_state_management() {
132
            if let Some(item_bounds) = item.bounds() {
                // Items inside a scroll frame are stored at CONTENT coords but
                // RENDER at `pos - scroll_offset`. The damage rects are in viewport
                // space, so we must apply the current scroll offset to the bounds
                // before the intersection test — otherwise scrolled content is
                // filtered against the wrong position and rows that actually fall
                // in a damage strip get dropped (visible as a missing band).
132
                let (sdx, sdy) = *scroll_offset_stack.last().unwrap_or(&(0.0, 0.0));
132
                let test_bounds = if sdx == 0.0 && sdy == 0.0 {
132
                    item_bounds
                } else {
                    LogicalRect {
                        origin: LogicalPosition {
                            x: item_bounds.origin.x - sdx,
                            y: item_bounds.origin.y - sdy,
                        },
                        size: item_bounds.size,
                    }
                };
                // Check if item intersects ANY damage rect (not just the union)
132
                let hits_damage = damage_rects
132
                    .iter()
132
                    .any(|dr| rects_overlap_or_adjacent(&test_bounds, dr, 0.0));
132
                if !hits_damage {
44
                    continue;
88
                }
            }
88
        }
176
        render_single_item(
176
            item,
176
            pixmap,
176
            dpi_factor,
176
            renderer_resources,
176
            font_manager,
176
            glyph_cache,
176
            &mut transform_stack,
176
            &mut clip_stack,
176
            &mut mask_stack,
176
            &mut scroll_offset_stack,
176
            render_state,
        )?;
    }
44
    Ok(())
44
}
29700
fn render_single_item(
29700
    item: &DisplayListItem,
29700
    pixmap: &mut AzulPixmap,
29700
    dpi_factor: f32,
29700
    renderer_resources: &RendererResources,
29700
    font_manager: Option<&FontManager<FontRef>>,
29700
    glyph_cache: &mut GlyphCache,
29700
    transform_stack: &mut Vec<TransAffine>,
29700
    clip_stack: &mut Vec<Option<AzRect>>,
29700
    mask_stack: &mut Vec<MaskEntry>,
29700
    scroll_offset_stack: &mut Vec<(f32, f32)>,
29700
    render_state: &CpuRenderState,
29700
) -> Result<(), String> {
    // Current accumulated scroll offset — applied to all item bounds.
    // Negative because scrolling down (positive offset) moves content up.
29700
    let (scroll_dx, scroll_dy) = *scroll_offset_stack.last().unwrap_or(&(0.0, 0.0));
    // Helper: apply scroll offset to a LogicalRect.
    // Items inside scroll frames have absolute window coordinates;
    // the scroll offset shifts them so the visible portion aligns
    // with the clip region.
29700
    let scroll_rect = |r: &LogicalRect| -> LogicalRect {
17248
        if scroll_dx == 0.0 && scroll_dy == 0.0 {
11704
            return *r;
5544
        }
5544
        LogicalRect {
5544
            origin: LogicalPosition {
5544
                x: r.origin.x - scroll_dx,
5544
                y: r.origin.y - scroll_dy,
5544
            },
5544
            size: r.size,
5544
        }
17248
    };
29700
    match item {
        DisplayListItem::Rect {
5632
            bounds,
5632
            color,
5632
            border_radius,
        } => {
5632
            let clip = *clip_stack.last().unwrap();
5632
            render_rect(
5632
                pixmap,
5632
                &scroll_rect(bounds.inner()),
5632
                *color,
5632
                border_radius,
5632
                clip,
5632
                dpi_factor,
            )?;
        }
        DisplayListItem::SelectionRect {
            bounds,
            color,
            border_radius,
        } => {
            let clip = *clip_stack.last().unwrap();
            render_rect(
                pixmap,
                &scroll_rect(bounds.inner()),
                *color,
                border_radius,
                clip,
                dpi_factor,
            )?;
        }
880
        DisplayListItem::CursorRect { bounds, color } => {
880
            let clip = *clip_stack.last().unwrap();
880
            render_rect(
880
                pixmap,
880
                &scroll_rect(bounds.inner()),
880
                *color,
880
                &BorderRadius::default(),
880
                clip,
880
                dpi_factor,
            )?;
        }
        DisplayListItem::Border {
4576
            bounds,
4576
            widths,
4576
            colors,
4576
            styles,
4576
            border_radius,
        } => {
4576
            let default_color = ColorU {
4576
                r: 0,
4576
                g: 0,
4576
                b: 0,
4576
                a: 255,
4576
            };
4576
            let w_top = widths
4576
                .top
4576
                .and_then(|w| w.get_property().cloned())
4576
                .map(|w| {
4576
                    w.inner
4576
                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
4576
                })
4576
                .unwrap_or(0.0);
4576
            let w_right = widths
4576
                .right
4576
                .and_then(|w| w.get_property().cloned())
4576
                .map(|w| {
4576
                    w.inner
4576
                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
4576
                })
4576
                .unwrap_or(0.0);
4576
            let w_bottom = widths
4576
                .bottom
4576
                .and_then(|w| w.get_property().cloned())
4576
                .map(|w| {
4576
                    w.inner
4576
                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
4576
                })
4576
                .unwrap_or(0.0);
4576
            let w_left = widths
4576
                .left
4576
                .and_then(|w| w.get_property().cloned())
4576
                .map(|w| {
4576
                    w.inner
4576
                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
4576
                })
4576
                .unwrap_or(0.0);
4576
            let c_top = colors
4576
                .top
4576
                .and_then(|c| c.get_property().cloned())
4576
                .map(|c| c.inner)
4576
                .unwrap_or(default_color);
4576
            let c_right = colors
4576
                .right
4576
                .and_then(|c| c.get_property().cloned())
4576
                .map(|c| c.inner)
4576
                .unwrap_or(default_color);
4576
            let c_bottom = colors
4576
                .bottom
4576
                .and_then(|c| c.get_property().cloned())
4576
                .map(|c| c.inner)
4576
                .unwrap_or(default_color);
4576
            let c_left = colors
4576
                .left
4576
                .and_then(|c| c.get_property().cloned())
4576
                .map(|c| c.inner)
4576
                .unwrap_or(default_color);
            use azul_css::props::style::border::BorderStyle;
4576
            let s_top = styles
4576
                .top
4576
                .and_then(|s| s.get_property().cloned())
4576
                .map(|s| s.inner)
4576
                .unwrap_or(BorderStyle::Solid);
4576
            let s_right = styles
4576
                .right
4576
                .and_then(|s| s.get_property().cloned())
4576
                .map(|s| s.inner)
4576
                .unwrap_or(BorderStyle::Solid);
4576
            let s_bottom = styles
4576
                .bottom
4576
                .and_then(|s| s.get_property().cloned())
4576
                .map(|s| s.inner)
4576
                .unwrap_or(BorderStyle::Solid);
4576
            let s_left = styles
4576
                .left
4576
                .and_then(|s| s.get_property().cloned())
4576
                .map(|s| s.inner)
4576
                .unwrap_or(BorderStyle::Solid);
4576
            let simple_radius = BorderRadius {
4576
                top_left: border_radius.top_left.to_pixels_internal(
4576
                    bounds.0.size.width,
4576
                    DEFAULT_FONT_SIZE,
4576
                    DEFAULT_FONT_SIZE,
4576
                ),
4576
                top_right: border_radius.top_right.to_pixels_internal(
4576
                    bounds.0.size.width,
4576
                    DEFAULT_FONT_SIZE,
4576
                    DEFAULT_FONT_SIZE,
4576
                ),
4576
                bottom_left: border_radius.bottom_left.to_pixels_internal(
4576
                    bounds.0.size.width,
4576
                    DEFAULT_FONT_SIZE,
4576
                    DEFAULT_FONT_SIZE,
4576
                ),
4576
                bottom_right: border_radius.bottom_right.to_pixels_internal(
4576
                    bounds.0.size.width,
4576
                    DEFAULT_FONT_SIZE,
4576
                    DEFAULT_FONT_SIZE,
4576
                ),
4576
            };
4576
            let clip = *clip_stack.last().unwrap();
4576
            let b = scroll_rect(bounds.inner());
            // If all sides same color/width/style, use single render_border call
4576
            let all_same = c_top == c_right
4576
                && c_top == c_bottom
4576
                && c_top == c_left
4576
                && w_top == w_right
4576
                && w_top == w_bottom
4576
                && w_top == w_left
4576
                && s_top == s_right
4576
                && s_top == s_bottom
4576
                && s_top == s_left;
4576
            if all_same {
4576
                render_border(
4576
                    pixmap,
4576
                    &b,
4576
                    c_top,
4576
                    w_top,
4576
                    s_top,
4576
                    &simple_radius,
4576
                    clip,
4576
                    dpi_factor,
                )?;
            } else {
                // Per-side rendering: render each side separately
                render_border_sides(
                    pixmap,
                    &b,
                    [c_top, c_right, c_bottom, c_left],
                    [w_top, w_right, w_bottom, w_left],
                    [s_top, s_right, s_bottom, s_left],
                    &simple_radius,
                    clip,
                    dpi_factor,
                )?;
            }
        }
        DisplayListItem::Underline {
            bounds,
            color,
            thickness: _,
        } => {
            let clip = *clip_stack.last().unwrap();
            render_rect(
                pixmap,
                &scroll_rect(bounds.inner()),
                *color,
                &BorderRadius::default(),
                clip,
                dpi_factor,
            )?;
        }
        DisplayListItem::Strikethrough {
            bounds,
            color,
            thickness: _,
        } => {
            let clip = *clip_stack.last().unwrap();
            render_rect(
                pixmap,
                &scroll_rect(bounds.inner()),
                *color,
                &BorderRadius::default(),
                clip,
                dpi_factor,
            )?;
        }
        DisplayListItem::Overline {
            bounds,
            color,
            thickness: _,
        } => {
            let clip = *clip_stack.last().unwrap();
            render_rect(
                pixmap,
                &scroll_rect(bounds.inner()),
                *color,
                &BorderRadius::default(),
                clip,
                dpi_factor,
            )?;
        }
        DisplayListItem::Text {
5192
            glyphs,
5192
            font_size_px,
5192
            font_hash,
5192
            color,
5192
            clip_rect,
            ..
        } => {
5192
            let clip = *clip_stack.last().unwrap();
5192
            render_text(
5192
                glyphs,
5192
                *font_hash,
5192
                *font_size_px,
5192
                *color,
5192
                pixmap,
5192
                &scroll_rect(clip_rect.inner()),
5192
                clip,
5192
                renderer_resources,
5192
                font_manager,
5192
                dpi_factor,
5192
                glyph_cache,
5192
                (scroll_dx, scroll_dy),
            )?;
        }
        DisplayListItem::TextLayout {
3344
            layout,
3344
            bounds,
3344
            font_hash,
3344
            font_size_px,
3344
            color,
3344
        } => {
3344
            // TextLayout is metadata for PDF/accessibility - skip in CPU rendering
3344
        }
132
        DisplayListItem::Image { bounds, image, .. } => {
132
            let clip = *clip_stack.last().unwrap();
            // A `DecodedImage::Callback` `<img>` (e.g. the AzulPaint canvas) can't
            // be rasterised here — the renderer can't run the callback. The backend
            // pre-invoked it into `image_callback_results`; swap in the produced
            // image (keyed by the callback image's hash). Falls back to `image`
            // (→ grey placeholder) only if no result was produced.
132
            let resolved = render_state.image_callback_results.get(&image.get_hash());
132
            render_image(
132
                pixmap,
132
                &scroll_rect(bounds.inner()),
132
                resolved.unwrap_or(image),
132
                clip,
132
                dpi_factor,
            )?;
        }
        DisplayListItem::ScrollBar {
            bounds,
            color,
            orientation,
            opacity_key: _,
            hit_id: _,
        } => {
            let clip = *clip_stack.last().unwrap();
            render_rect(
                pixmap,
                &scroll_rect(bounds.inner()),
                *color,
                &BorderRadius::default(),
                clip,
                dpi_factor,
            )?;
        }
        DisplayListItem::ScrollBarStyled { info } => {
            let clip = *clip_stack.last().unwrap();
            // Resolve scrollbar opacity from the GPU value cache.
            // WhenScrolling mode starts at 0.0 and fades to 1.0 on scroll.
            // In cpurender we read the current value; if none is cached
            // (e.g. headless mode never ran synchronize_scrollbar_opacity)
            // default to 1.0 so the scrollbar is always visible.
            let scrollbar_opacity = info
                .opacity_key
                .and_then(|key| render_state.opacities.get(&key.id).copied())
                .unwrap_or(1.0);
            if scrollbar_opacity > 0.001 {
                // Render track
                if info.track_color.a > 0 {
                    render_rect(
                        pixmap,
                        &scroll_rect(info.track_bounds.inner()),
                        info.track_color,
                        &BorderRadius::default(),
                        clip,
                        dpi_factor,
                    )?;
                }
                // Render decrement button
                if let Some(btn_bounds) = &info.button_decrement_bounds {
                    if info.button_color.a > 0 {
                        render_rect(
                            pixmap,
                            &scroll_rect(btn_bounds.inner()),
                            info.button_color,
                            &BorderRadius::default(),
                            clip,
                            dpi_factor,
                        )?;
                    }
                }
                // Render increment button
                if let Some(btn_bounds) = &info.button_increment_bounds {
                    if info.button_color.a > 0 {
                        render_rect(
                            pixmap,
                            &scroll_rect(btn_bounds.inner()),
                            info.button_color,
                            &BorderRadius::default(),
                            clip,
                            dpi_factor,
                        )?;
                    }
                }
                // Render thumb — the thumb is wrapped in PushReferenceFrame
                // with a thumb_transform_key, so the GPU cache lookup handles
                // positioning dynamically. Here we just apply the initial
                // transform embedded in the display list item as a fallback.
                if info.thumb_color.a > 0 {
                    let thumb_rect = info.thumb_bounds.inner();
                    // Look up live transform from render_state if available
                    let transform = info
                        .thumb_transform_key
                        .and_then(|key| render_state.transforms.get(&key.id))
                        .unwrap_or(&info.thumb_initial_transform);
                    let tx = transform.m[3][0];
                    let ty = transform.m[3][1];
                    let transformed_thumb = LogicalRect {
                        origin: LogicalPosition {
                            x: thumb_rect.origin.x + tx,
                            y: thumb_rect.origin.y + ty,
                        },
                        size: thumb_rect.size,
                    };
                    render_rect(
                        pixmap,
                        &scroll_rect(&transformed_thumb),
                        info.thumb_color,
                        &info.thumb_border_radius,
                        clip,
                        dpi_factor,
                    )?;
                }
            } // end scrollbar_opacity > 0
        }
        DisplayListItem::PushClip {
264
            bounds,
264
            border_radius,
264
        } => {
264
            // Two fixes (the invisible-maps-header bug):
264
            // 1. The clip must live in the same coordinate space items draw in
264
            //    (`pos - accumulated_scroll`) — shift it via scroll_rect() like
264
            //    every drawing arm. A VirtualView child's PushClip otherwise
264
            //    lands at raw child-local coordinates on the window.
264
            // 2. A nested clip can only NARROW the active one. Pushing the rect
264
            //    verbatim let a child DL's own PushClip REPLACE the VirtualView
264
            //    composite clip, so the child painted over the whole window
264
            //    (the maps header/toolbar disappeared under the tile grid).
264
            let new_clip = logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor);
264
            let merged = intersect_clips(clip_stack.last().copied().flatten(), new_clip);
264
            clip_stack.push(merged);
264
        }
        DisplayListItem::PopClip => {
            // Never pop the base clip (the window rect pushed at init). An
            // unbalanced PopClip — e.g. a display-list bookkeeping mismatch in
            // the titlebar/stacking-context emit path — must NOT abort the whole
            // layer render. Previously this returned Err, the caller logged
            // "render_layers error: Clip stack underflow" and DROPPED THE ENTIRE
            // FRAME, leaving a blank window with no body/button. Clamp to the base
            // instead so the frame still presents; the only effect of an over-pop
            // is that trailing items fall back to the base (window) clip, which is
            // harmless for well-formed DOMs.
264
            if clip_stack.len() > 1 {
264
                clip_stack.pop();
264
            } else {
                #[cfg(feature = "std")]
                if std::env::var("AZ_CLIP_DEBUG").is_ok() {
                    eprintln!(
                        "[CpuBackend] PopClip with no matching PushClip — clamping to base clip"
                    );
                }
            }
        }
        DisplayListItem::PushScrollFrame { scroll_id, .. } => {
            // Scroll frame = scroll offset only.
            // The display list generator always emits PushClip before
            // PushScrollFrame with the same clip bounds, so we don't
            // need to push another clip here — that would double-clip.
            transform_stack.push(
                transform_stack
                    .last()
                    .cloned()
                    .unwrap_or_else(TransAffine::new),
            );
            let frame_offset = render_state
                .scroll_offsets
                .get(scroll_id)
                .copied()
                .unwrap_or((0.0, 0.0));
            let new_scroll = (scroll_dx + frame_offset.0, scroll_dy + frame_offset.1);
            scroll_offset_stack.push(new_scroll);
        }
        DisplayListItem::PopScrollFrame => {
            // Only pop transform and scroll offset — the clip was pushed
            // by a separate PushClip and will be popped by PopClip.
            if transform_stack.len() > 1 {
                transform_stack.pop();
            }
            if scroll_offset_stack.len() > 1 {
                scroll_offset_stack.pop();
            }
        }
4752
        DisplayListItem::HitTestArea { bounds, tag } => {
4752
            // Hit test areas don't render anything
4752
        }
1936
        DisplayListItem::PushStackingContext { z_index, bounds } => {
1936
            // For CPU rendering, stacking contexts are already handled by display list order
1936
        }
1936
        DisplayListItem::PopStackingContext => {}
        DisplayListItem::VirtualView {
132
            child_dom_id,
132
            bounds,
132
            clip_rect,
        } => {
132
            let _ = clip_rect;
            // Composite the VirtualView's child DOM (a separate LayoutResult the
            // normal layout loop produced — e.g. the MapWidget's tile grid). Its
            // display list is 0-relative, so we (1) clip to the VirtualView's
            // on-screen rect and (2) push a scroll offset of -bounds.origin so the
            // renderer (which draws at `pos - accumulated_scroll`) places the child
            // content at the VirtualView origin. Then recursively rasterise it.
            // (Was: a debug-blue overlay that never drew the child — the reason the
            // CPU backend showed a blank map.)
132
            let child_dl = render_state.virtual_view_display_lists.get(child_dom_id).cloned();
            #[cfg(feature = "std")]
132
            if std::env::var("AZ_MAP_DEBUG").is_ok() {
                eprintln!(
                    "[cpu-vview] VirtualView item: child_dom_id={} found={} items={} bounds={:?} avail_ids={:?}",
                    child_dom_id.inner,
                    child_dl.is_some(),
                    child_dl.as_ref().map(|d| d.items.len()).unwrap_or(0),
                    bounds.inner(),
                    render_state.virtual_view_display_lists.keys().map(|k| k.inner).collect::<alloc::vec::Vec<_>>(),
                );
132
            }
132
            if let Some(child_dl) = child_dl {
132
                let vv_origin = bounds.inner().origin;
                // Intersect with the active clip (the VirtualView may itself sit
                // inside a clipped/scrolled container) — same rule as PushClip.
132
                let vv_clip = intersect_clips(
132
                    clip_stack.last().copied().flatten(),
132
                    logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor),
                );
132
                clip_stack.push(vv_clip);
132
                scroll_offset_stack.push((scroll_dx - vv_origin.x, scroll_dy - vv_origin.y));
9328
                for child_item in child_dl.items.iter() {
9328
                    render_single_item(
9328
                        child_item,
9328
                        pixmap,
9328
                        dpi_factor,
9328
                        renderer_resources,
9328
                        font_manager,
9328
                        glyph_cache,
9328
                        transform_stack,
9328
                        clip_stack,
9328
                        mask_stack,
9328
                        scroll_offset_stack,
9328
                        render_state,
                    )?;
                }
132
                scroll_offset_stack.pop();
132
                clip_stack.pop();
            }
        }
        DisplayListItem::VirtualViewPlaceholder { .. } => {
            #[cfg(feature = "std")]
            if std::env::var("AZ_MAP_DEBUG").is_ok() {
                eprintln!("[cpu-vview] VirtualViewPlaceholder hit (NOT swapped to a VirtualView item — nothing composites)");
            }
        }
        // Gradient rendering
        DisplayListItem::LinearGradient {
44
            bounds,
44
            gradient,
44
            border_radius,
        } => {
44
            let clip = *clip_stack.last().unwrap();
44
            render_linear_gradient(
44
                pixmap,
44
                &scroll_rect(bounds.inner()),
44
                gradient,
44
                border_radius,
44
                clip,
44
                dpi_factor,
44
                render_state.system_style.as_deref().map(|s| &s.colors),
            )?;
        }
        DisplayListItem::RadialGradient {
            bounds,
            gradient,
            border_radius,
        } => {
            let clip = *clip_stack.last().unwrap();
            render_radial_gradient(
                pixmap,
                &scroll_rect(bounds.inner()),
                gradient,
                border_radius,
                clip,
                dpi_factor,
                render_state.system_style.as_deref().map(|s| &s.colors),
            )?;
        }
        DisplayListItem::ConicGradient {
            bounds,
            gradient,
            border_radius,
        } => {
            let clip = *clip_stack.last().unwrap();
            render_conic_gradient(
                pixmap,
                &scroll_rect(bounds.inner()),
                gradient,
                border_radius,
                clip,
                dpi_factor,
                render_state.system_style.as_deref().map(|s| &s.colors),
            )?;
        }
        // BoxShadow
        DisplayListItem::BoxShadow {
176
            bounds,
176
            shadow,
176
            border_radius,
        } => {
176
            render_box_shadow(
176
                pixmap,
176
                &scroll_rect(bounds.inner()),
176
                shadow,
176
                border_radius,
176
                dpi_factor,
            )?;
        }
        // --- Opacity layers ---
        DisplayListItem::PushOpacity { bounds, opacity } => {
            let rect = logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor);
            if let Some(r) = rect {
                let snap = snapshot_region(
                    pixmap,
                    r.x as i32,
                    r.y as i32,
                    r.width as u32,
                    r.height as u32,
                );
                mask_stack.push(MaskEntry::Opacity {
                    snapshot: snap,
                    rect: r,
                    opacity: *opacity,
                });
            }
        }
        DisplayListItem::PopOpacity => {
            if let Some(MaskEntry::Opacity {
                snapshot,
                rect,
                opacity,
            }) = mask_stack.pop()
            {
                let x = rect.x as i32;
                let y = rect.y as i32;
                let w = rect.width as u32;
                let h = rect.height as u32;
                let pw = pixmap.width as i32;
                let ph = pixmap.height as i32;
                // Blend: result = snapshot + (current - snapshot) * opacity
                for py in 0..h as i32 {
                    let dy = y + py;
                    if dy < 0 || dy >= ph {
                        continue;
                    }
                    for px in 0..w as i32 {
                        let dx = x + px;
                        if dx < 0 || dx >= pw {
                            continue;
                        }
                        let pi = ((dy as u32 * pixmap.width + dx as u32) * 4) as usize;
                        let si = ((py as u32 * w + px as u32) * 4) as usize;
                        if pi + 3 >= pixmap.data.len() || si + 3 >= snapshot.len() {
                            continue;
                        }
                        let op = (opacity * 255.0).clamp(0.0, 255.0) as u32;
                        let inv_op = 255 - op;
                        for c in 0..4 {
                            let snap_c = snapshot[si + c] as u32;
                            let cur_c = pixmap.data[pi + c] as u32;
                            pixmap.data[pi + c] = ((cur_c * op + snap_c * inv_op) / 255) as u8;
                        }
                    }
                }
            }
        }
        // --- Reference frames (CSS transforms) ---
        DisplayListItem::PushReferenceFrame {
            transform_key,
            initial_transform,
            bounds,
        } => {
            // Look up the current GPU-cached transform value for this key.
            // For scrollbar thumbs, the GpuValueCache stores the up-to-date
            // thumb translation. For CSS transforms, it stores the computed
            // matrix. Falls back to the initial_transform baked in the DL.
            let live_transform = render_state.transforms.get(&transform_key.id);
            let m = match live_transform {
                Some(t) => &t.m,
                None => &initial_transform.m,
            };
            let tf = TransAffine::new_custom(
                m[0][0] as f64,
                m[0][1] as f64, // sx, shy
                m[1][0] as f64,
                m[1][1] as f64, // shx, sy
                m[3][0] as f64,
                m[3][1] as f64, // tx, ty
            );
            let current = transform_stack
                .last()
                .cloned()
                .unwrap_or_else(TransAffine::new);
            let mut composed = tf;
            composed.premultiply(&current);
            transform_stack.push(composed);
        }
        DisplayListItem::PopReferenceFrame => {
            if transform_stack.len() > 1 {
                transform_stack.pop();
            }
        }
        // --- Filter effects ---
        // TODO: proper compositing architecture with per-layer pixbufs
        DisplayListItem::PushFilter { .. } => {}
        DisplayListItem::PopFilter => {}
        DisplayListItem::PushBackdropFilter { .. } => {}
        DisplayListItem::PopBackdropFilter => {}
        DisplayListItem::PushTextShadow { .. } => {}
        DisplayListItem::PopTextShadow => {}
        DisplayListItem::PushImageMaskClip {
220
            bounds,
220
            mask_image,
220
            mask_rect,
        } => {
220
            let mr = &scroll_rect(mask_rect.inner());
220
            let px_x = (mr.origin.x * dpi_factor) as i32;
220
            let px_y = (mr.origin.y * dpi_factor) as i32;
220
            let px_w = (mr.size.width * dpi_factor).ceil() as u32;
220
            let px_h = (mr.size.height * dpi_factor).ceil() as u32;
220
            if px_w > 0 && px_h > 0 {
220
                let snapshot = snapshot_region(pixmap, px_x, px_y, px_w, px_h);
220
                let mask_data = extract_mask_data(mask_image, px_w, px_h)
220
                    .unwrap_or_else(|| vec![255u8; (px_w * px_h) as usize]);
220
                mask_stack.push(MaskEntry::ImageMask {
220
                    snapshot,
220
                    mask_data,
220
                    origin_x: px_x,
220
                    origin_y: px_y,
220
                    width: px_w,
220
                    height: px_h,
220
                });
            }
        }
        DisplayListItem::PopImageMaskClip => {
220
            if let Some(entry) = mask_stack.pop() {
220
                apply_mask(pixmap, &entry);
220
            }
        }
    }
29700
    Ok(())
29700
}
6512
fn render_rect(
6512
    pixmap: &mut AzulPixmap,
6512
    bounds: &LogicalRect,
6512
    color: ColorU,
6512
    border_radius: &BorderRadius,
6512
    clip: Option<AzRect>,
6512
    dpi_factor: f32,
6512
) -> Result<(), String> {
6512
    if color.a == 0 {
        return Ok(());
6512
    }
6512
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
6512
        Some(r) => r,
        None => return Ok(()),
    };
    // Early-out if fully outside clip
6512
    if let Some(ref c) = clip {
1012
        if rect.clip(c).is_none() {
748
            return Ok(());
264
        }
5500
    }
5764
    let agg_color = Rgba8::new(
5764
        color.r as u32,
5764
        color.g as u32,
5764
        color.b as u32,
5764
        color.a as u32,
    );
5764
    if border_radius.is_zero() {
        // Fast path: axis-aligned rectangle — use direct RendererBase::blend_bar
        // instead of the full rasterizer pipeline. This avoids path construction,
        // cell generation, sorting, and scanline rendering for simple rectangles.
5368
        let w = pixmap.width;
5368
        let h = pixmap.height;
5368
        let stride = (w * 4) as i32;
5368
        let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
5368
        let mut pf = PixfmtRgba32::new(&mut ra);
5368
        let mut rb = RendererBase::new(pf);
5368
        if let Some(c) = clip {
264
            rb.clip_box_i(
264
                c.x as i32,
264
                c.y as i32,
264
                (c.x + c.width) as i32 - 1,
264
                (c.y + c.height) as i32 - 1,
264
            );
5104
        }
5368
        rb.blend_bar(
5368
            rect.x as i32,
5368
            rect.y as i32,
5368
            (rect.x + rect.width) as i32 - 1,
5368
            (rect.y + rect.height) as i32 - 1,
5368
            &agg_color,
            255, // cover=255: alpha is already in the color
        );
396
    } else {
396
        // Rounded rect: needs the full rasterizer for curved corners
396
        let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
396
        agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::NonZero, clip);
396
    }
5764
    Ok(())
6512
}
5192
fn render_text(
5192
    glyphs: &[GlyphInstance],
5192
    font_hash: FontHash,
5192
    font_size_px: f32,
5192
    color: ColorU,
5192
    pixmap: &mut AzulPixmap,
5192
    clip_rect: &LogicalRect,
5192
    clip: Option<AzRect>,
5192
    renderer_resources: &RendererResources,
5192
    font_manager: Option<&FontManager<FontRef>>,
5192
    dpi_factor: f32,
5192
    glyph_cache: &mut GlyphCache,
5192
    scroll_offset: (f32, f32),
5192
) -> Result<(), String> {
5192
    if color.a == 0 || glyphs.is_empty() {
        return Ok(());
5192
    }
    // Skip text entirely if its clip_rect is outside the active clip region
5192
    if let Some(ref c) = clip {
3520
        let text_rect = match logical_rect_to_az_rect(clip_rect, dpi_factor) {
3520
            Some(r) => r,
            None => return Ok(()),
        };
3520
        if text_rect.clip(c).is_none() {
3080
            return Ok(()); // fully clipped
440
        }
1672
    }
2112
    let agg_color = Rgba8::new(
2112
        color.r as u32,
2112
        color.g as u32,
2112
        color.b as u32,
2112
        color.a as u32,
    );
    // Try to get the parsed font
2112
    let parsed_font: &ParsedFont = if let Some(fm) = font_manager {
2112
        match fm.get_font_by_hash(font_hash.font_hash) {
2112
            Some(font_ref) => unsafe { &*(font_ref.get_parsed() as *const ParsedFont) },
            None => {
                eprintln!(
                    "[cpurender] Font hash {} not found in FontManager",
                    font_hash.font_hash
                );
                return Ok(());
            }
        }
    } else {
        let font_key = match renderer_resources.font_hash_map.get(&font_hash.font_hash) {
            Some(k) => k,
            None => {
                eprintln!(
                    "[cpurender] Font hash {} not found in font_hash_map (available: {:?})",
                    font_hash.font_hash,
                    renderer_resources.font_hash_map.keys().collect::<Vec<_>>()
                );
                return Ok(());
            }
        };
        let font_ref = match renderer_resources.currently_registered_fonts.get(font_key) {
            Some((font_ref, _instances)) => font_ref,
            None => {
                eprintln!(
                    "[cpurender] FontKey {:?} not found in currently_registered_fonts",
                    font_key
                );
                return Ok(());
            }
        };
        unsafe { &*(font_ref.get_parsed() as *const ParsedFont) }
    };
2112
    let units_per_em = parsed_font.font_metrics.units_per_em as f32;
2112
    if units_per_em <= 0.0 {
        return Ok(());
2112
    }
2112
    let scale = (font_size_px * dpi_factor) / units_per_em;
2112
    let ppem = (font_size_px * dpi_factor).round() as u16;
    // Set up the rasterizer pipeline once, reuse for all glyphs
2112
    let w = pixmap.width;
2112
    let h = pixmap.height;
2112
    let stride = (w * 4) as i32;
    // Create renderer infrastructure once, reuse for all glyphs in this text run.
    // Batches all glyph cells into a single rasterizer pass when possible.
2112
    let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
2112
    let mut pf = PixfmtRgba32::new(&mut ra);
2112
    let mut rb = RendererBase::new(pf);
2112
    if let Some(c) = clip {
440
        rb.clip_box_i(
440
            c.x as i32,
440
            c.y as i32,
440
            (c.x + c.width) as i32 - 1,
440
            (c.y + c.height) as i32 - 1,
440
        );
1672
    }
2112
    let mut ras = RasterizerScanlineAa::new();
2112
    ras.filling_rule(FillingRule::NonZero);
    // Accumulate all glyph cells into one rasterizer, then render once.
    // This amortizes sort_cells cost across all glyphs in the run.
14960
    for glyph in glyphs {
12848
        let glyph_index = glyph.index as u16;
        // Lazy decode: first access to a given gid for this face does
        // the allsorts glyf walk + OwnedGlyph conversion; subsequent
        // accesses are an Arc bump + BTreeMap lookup.
12848
        let glyph_data = match parsed_font.get_or_decode_glyph(glyph_index) {
12848
            Some(d) => d,
            None => continue,
        };
12848
        let is_hinted = glyph_cache
12848
            .get_or_build(
12848
                font_hash.font_hash,
12848
                glyph_index,
12848
                &glyph_data,
12848
                parsed_font,
12848
                ppem,
            )
12848
            .map(|c| c.is_hinted)
12848
            .unwrap_or(false);
12848
        let glyph_x = (glyph.point.x - scroll_offset.0) * dpi_factor;
12848
        let glyph_baseline_y = (glyph.point.y - scroll_offset.1) * dpi_factor;
12848
        let (cells, int_x, int_y) = match glyph_cache.get_or_build_cells(
12848
            font_hash.font_hash,
12848
            glyph_index,
12848
            ppem,
12848
            glyph_x,
12848
            glyph_baseline_y,
12848
            scale,
12848
            is_hinted,
12848
        ) {
11968
            Some(c) => c,
880
            None => continue,
        };
11968
        ras.add_cells_offset(cells, int_x, int_y);
    }
    // Single render pass for all glyphs in this text run
2112
    let mut sl = ScanlineU8::new();
2112
    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &agg_color);
2112
    Ok(())
5192
}
4576
fn render_border(
4576
    pixmap: &mut AzulPixmap,
4576
    bounds: &LogicalRect,
4576
    color: ColorU,
4576
    width: f32,
4576
    border_style: azul_css::props::style::border::BorderStyle,
4576
    border_radius: &BorderRadius,
4576
    clip: Option<AzRect>,
4576
    dpi_factor: f32,
4576
) -> Result<(), String> {
    use azul_css::props::style::border::BorderStyle;
4576
    if color.a == 0 || width <= 0.0 {
2772
        return Ok(());
1804
    }
1804
    match border_style {
        BorderStyle::None | BorderStyle::Hidden => return Ok(()),
1804
        _ => {}
    }
1804
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
1804
        Some(r) => r,
        None => return Ok(()),
    };
    // Skip if fully outside clip
1804
    if let Some(ref c) = clip {
880
        if rect.clip(c).is_none() {
704
            return Ok(());
176
        }
924
    }
1100
    let scaled_width = width * dpi_factor;
1100
    let agg_color = Rgba8::new(
1100
        color.r as u32,
1100
        color.g as u32,
1100
        color.b as u32,
1100
        color.a as u32,
    );
    // 1. Build outer path (rounded rect at the nominal border radii)
1100
    let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
1100
    let x = rect.x as f64;
1100
    let y = rect.y as f64;
1100
    let w = rect.width as f64;
1100
    let h = rect.height as f64;
1100
    let sw = scaled_width as f64;
    // 2. Add inner path with shrunk radii so EvenOdd fill carves the stroke
1100
    let ir = AzRect::from_xywh(
1100
        rect.x + scaled_width,
1100
        rect.y + scaled_width,
1100
        rect.width - 2.0 * scaled_width,
1100
        rect.height - 2.0 * scaled_width,
    );
1100
    if let Some(ir) = ir {
1100
        let inner_radius = BorderRadius {
1100
            top_left: (border_radius.top_left - width).max(0.0),
1100
            top_right: (border_radius.top_right - width).max(0.0),
1100
            bottom_right: (border_radius.bottom_right - width).max(0.0),
1100
            bottom_left: (border_radius.bottom_left - width).max(0.0),
1100
        };
1100
        let mut inner = build_rounded_rect_path(&ir, &inner_radius, dpi_factor);
1100
        path.concat_path(&mut inner, 0);
1100
    }
    // 3. Render based on border style
1100
    match border_style {
        BorderStyle::Dashed | BorderStyle::Dotted => {
            // For dashed/dotted: stroke the border path with dash pattern
            use agg_rust::conv_dash::ConvDash;
            use agg_rust::conv_stroke::ConvStroke;
            let half = sw / 2.0;
            let mut stroke_path = PathStorage::new();
            let (cx, cy, cw, ch) = (x + half, y + half, w - sw, h - sw);
            stroke_path.move_to(cx, cy);
            stroke_path.line_to(cx + cw, cy);
            stroke_path.line_to(cx + cw, cy + ch);
            stroke_path.line_to(cx, cy + ch);
            stroke_path.close_polygon(PATH_FLAGS_NONE);
            let mut dashed = ConvDash::new(stroke_path);
            if border_style == BorderStyle::Dashed {
                dashed.add_dash(sw * 3.0, sw);
            } else {
                dashed.add_dash(sw, sw);
            }
            let mut stroked = ConvStroke::new(dashed);
            stroked.set_width(sw);
            agg_fill_path_clipped(pixmap, &mut stroked, &agg_color, FillingRule::NonZero, clip);
        }
1100
        _ if border_radius.is_zero() => {
            // Fast path: solid border without rounding — use blend_bar strips
1100
            let pw = pixmap.width;
1100
            let ph = pixmap.height;
1100
            let stride = (pw * 4) as i32;
1100
            let mut ra =
1100
                unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), pw, ph, stride) };
1100
            let mut pf = PixfmtRgba32::new(&mut ra);
1100
            let mut rb = RendererBase::new(pf);
1100
            if let Some(c) = clip {
176
                rb.clip_box_i(
176
                    c.x as i32,
176
                    c.y as i32,
176
                    (c.x + c.width) as i32 - 1,
176
                    (c.y + c.height) as i32 - 1,
176
                );
924
            }
1100
            let (xi, yi) = (x as i32, y as i32);
1100
            let (x2i, y2i) = ((x + w) as i32 - 1, (y + h) as i32 - 1);
1100
            let swi = sw as i32;
            // Top strip
1100
            rb.blend_bar(xi, yi, x2i, yi + swi - 1, &agg_color, 255);
            // Bottom strip
1100
            rb.blend_bar(xi, y2i - swi + 1, x2i, y2i, &agg_color, 255);
            // Left strip (between top and bottom)
1100
            rb.blend_bar(xi, yi + swi, xi + swi - 1, y2i - swi, &agg_color, 255);
            // Right strip
1100
            rb.blend_bar(x2i - swi + 1, yi + swi, x2i, y2i - swi, &agg_color, 255);
        }
        _ => {
            // Rounded solid border: fill double-path with EvenOdd
            agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::EvenOdd, clip);
        }
    }
1100
    Ok(())
4576
}
/// Render border with per-side colors/widths/styles using CSS trapezoid model.
/// Each side is a trapezoid: outer edge → inner edge with 45° miters at corners.
fn render_border_sides(
    pixmap: &mut AzulPixmap,
    bounds: &LogicalRect,
    colors: [ColorU; 4], // top, right, bottom, left
    widths: [f32; 4],    // top, right, bottom, left
    _styles: [azul_css::props::style::border::BorderStyle; 4],
    _border_radius: &BorderRadius,
    clip: Option<AzRect>,
    dpi_factor: f32,
) -> Result<(), String> {
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
        Some(r) => r,
        None => return Ok(()),
    };
    // Outer corners
    let ox = rect.x as f64;
    let oy = rect.y as f64;
    let ow = rect.width as f64;
    let oh = rect.height as f64;
    // Inner corners (inset by per-side widths)
    let wt = (widths[0] * dpi_factor) as f64;
    let wr = (widths[1] * dpi_factor) as f64;
    let wb = (widths[2] * dpi_factor) as f64;
    let wl = (widths[3] * dpi_factor) as f64;
    let ix = ox + wl;
    let iy = oy + wt;
    let iw = ow - wl - wr;
    let ih = oh - wt - wb;
    // Each side is a trapezoid with 4 vertices:
    // Top:    (ox, oy) → (ox+ow, oy) → (ix+iw, iy) → (ix, iy)
    // Right:  (ox+ow, oy) → (ox+ow, oy+oh) → (ix+iw, iy+ih) → (ix+iw, iy)
    // Bottom: (ox+ow, oy+oh) → (ox, oy+oh) → (ix, iy+ih) → (ix+iw, iy+ih)
    // Left:   (ox, oy+oh) → (ox, oy) → (ix, iy) → (ix, iy+ih)
    let sides: [(f64, f64, f64, f64, f64, f64, f64, f64, ColorU, f32); 4] = [
        // Top trapezoid
        (
            ox,
            oy,
            ox + ow,
            oy,
            ix + iw,
            iy,
            ix,
            iy,
            colors[0],
            widths[0],
        ),
        // Right trapezoid
        (
            ox + ow,
            oy,
            ox + ow,
            oy + oh,
            ix + iw,
            iy + ih,
            ix + iw,
            iy,
            colors[1],
            widths[1],
        ),
        // Bottom trapezoid
        (
            ox + ow,
            oy + oh,
            ox,
            oy + oh,
            ix,
            iy + ih,
            ix + iw,
            iy + ih,
            colors[2],
            widths[2],
        ),
        // Left trapezoid
        (
            ox,
            oy + oh,
            ox,
            oy,
            ix,
            iy,
            ix,
            iy + ih,
            colors[3],
            widths[3],
        ),
    ];
    if _border_radius.is_zero() {
        // Fast path: axis-aligned border strips — no rasterizer needed
        let pw = pixmap.width;
        let ph = pixmap.height;
        let stride = (pw * 4) as i32;
        let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), pw, ph, stride) };
        let mut pf = PixfmtRgba32::new(&mut ra);
        let mut rb = RendererBase::new(pf);
        if let Some(c) = clip {
            rb.clip_box_i(
                c.x as i32,
                c.y as i32,
                (c.x + c.width) as i32 - 1,
                (c.y + c.height) as i32 - 1,
            );
        }
        // Top: full width, height = wt
        if widths[0] > 0.0 && colors[0].a > 0 {
            let c = colors[0];
            let ac = Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32);
            rb.blend_bar(
                ox as i32,
                oy as i32,
                (ox + ow) as i32 - 1,
                iy as i32 - 1,
                &ac,
                255,
            );
        }
        // Bottom
        if widths[2] > 0.0 && colors[2].a > 0 {
            let c = colors[2];
            let ac = Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32);
            rb.blend_bar(
                ox as i32,
                (iy + ih) as i32,
                (ox + ow) as i32 - 1,
                (oy + oh) as i32 - 1,
                &ac,
                255,
            );
        }
        // Left: between top and bottom
        if widths[3] > 0.0 && colors[3].a > 0 {
            let c = colors[3];
            let ac = Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32);
            rb.blend_bar(
                ox as i32,
                iy as i32,
                ix as i32 - 1,
                (iy + ih) as i32 - 1,
                &ac,
                255,
            );
        }
        // Right
        if widths[1] > 0.0 && colors[1].a > 0 {
            let c = colors[1];
            let ac = Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32);
            rb.blend_bar(
                (ix + iw) as i32,
                iy as i32,
                (ox + ow) as i32 - 1,
                (iy + ih) as i32 - 1,
                &ac,
                255,
            );
        }
    } else {
        // Rounded borders: use trapezoid rasterizer
        for &(x0, y0, x1, y1, x2, y2, x3, y3, color, width) in &sides {
            if width <= 0.0 || color.a == 0 {
                continue;
            }
            let mut path = PathStorage::new();
            path.move_to(x0, y0);
            path.line_to(x1, y1);
            path.line_to(x2, y2);
            path.line_to(x3, y3);
            path.close_polygon(PATH_FLAGS_NONE);
            let agg_color = Rgba8::new(
                color.r as u32,
                color.g as u32,
                color.b as u32,
                color.a as u32,
            );
            agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::NonZero, clip);
        }
    }
    Ok(())
}
12628
fn logical_rect_to_az_rect(bounds: &LogicalRect, dpi_factor: f32) -> Option<AzRect> {
12628
    let x = bounds.origin.x * dpi_factor;
12628
    let y = bounds.origin.y * dpi_factor;
12628
    let width = bounds.size.width * dpi_factor;
12628
    let height = bounds.size.height * dpi_factor;
12628
    AzRect::from_xywh(x, y, width, height)
12628
}
132
fn render_image(
132
    pixmap: &mut AzulPixmap,
132
    bounds: &LogicalRect,
132
    image: &ImageRef,
132
    clip: Option<AzRect>,
132
    dpi_factor: f32,
132
) -> Result<(), String> {
132
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
132
        Some(r) => r,
        None => return Ok(()),
    };
    // Skip if fully outside clip
132
    if let Some(ref c) = clip {
        if rect.clip(c).is_none() {
            return Ok(());
        }
132
    }
132
    let image_data = image.get_data();
132
    let (src_rgba, src_w, src_h) = match &*image_data {
132
        DecodedImage::Raw((descriptor, data)) => {
132
            let w = descriptor.width as u32;
132
            let h = descriptor.height as u32;
132
            if w == 0 || h == 0 {
                return Ok(());
132
            }
132
            let bytes = match data {
132
                azul_core::resources::ImageData::Raw(shared) => shared.as_ref(),
                _ => return Ok(()),
            };
132
            let rgba = match descriptor.format {
                azul_core::resources::RawImageFormat::BGRA8 => {
132
                    let mut out = Vec::with_capacity(bytes.len());
1320000
                    for chunk in bytes.chunks_exact(4) {
1320000
                        let b = chunk[0];
1320000
                        let g = chunk[1];
1320000
                        let r = chunk[2];
1320000
                        let a = chunk[3];
1320000
                        out.push(r);
1320000
                        out.push(g);
1320000
                        out.push(b);
1320000
                        out.push(a);
1320000
                    }
132
                    out
                }
                azul_core::resources::RawImageFormat::R8 => {
                    let mut out = Vec::with_capacity(bytes.len() * 4);
                    for &v in bytes {
                        out.push(v);
                        out.push(v);
                        out.push(v);
                        out.push(v);
                    }
                    out
                }
                _ => {
                    // Unsupported format — render gray placeholder
                    let gray = Rgba8::new(200, 200, 200, 255);
                    let mut path = build_rect_path(&rect);
                    agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
                    return Ok(());
                }
            };
132
            (rgba, w, h)
        }
        DecodedImage::NullImage { .. } | DecodedImage::Callback(_) => {
            let gray = Rgba8::new(200, 200, 200, 255);
            let mut path = build_rect_path(&rect);
            agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
            return Ok(());
        }
        _ => return Ok(()),
    };
    // Simple nearest-neighbor blit with scaling
132
    let dst_x = rect.x as i32;
132
    let dst_y = rect.y as i32;
132
    let dst_w = rect.width as u32;
132
    let dst_h = rect.height as u32;
132
    let pw = pixmap.width;
132
    let ph = pixmap.height;
132
    let sx = src_w as f32 / dst_w.max(1) as f32;
132
    let sy = src_h as f32 / dst_h.max(1) as f32;
    // Compute pixel-level clip bounds for the blit loop
132
    let (clip_x1, clip_y1, clip_x2, clip_y2) = if let Some(ref c) = clip {
        (
            c.x as i32,
            c.y as i32,
            (c.x + c.width) as i32,
            (c.y + c.height) as i32,
        )
    } else {
132
        (0, 0, pw as i32, ph as i32)
    };
11440
    for py in 0..dst_h {
1038400
        for px in 0..dst_w {
1038400
            let tx = dst_x + px as i32;
1038400
            let ty = dst_y + py as i32;
1038400
            if tx < 0 || ty < 0 || tx >= pw as i32 || ty >= ph as i32 {
                continue;
1038400
            }
            // Clip check
1038400
            if tx < clip_x1 || ty < clip_y1 || tx >= clip_x2 || ty >= clip_y2 {
                continue;
1038400
            }
1038400
            let src_x = ((px as f32 * sx) as u32).min(src_w - 1);
1038400
            let src_y = ((py as f32 * sy) as u32).min(src_h - 1);
1038400
            let si = ((src_y * src_w + src_x) * 4) as usize;
1038400
            let di = ((ty as u32 * pw + tx as u32) * 4) as usize;
1038400
            if si + 3 < src_rgba.len() && di + 3 < pixmap.data.len() {
1038400
                let sa = src_rgba[si + 3] as u32;
1038400
                if sa == 255 {
1038400
                    pixmap.data[di] = src_rgba[si];
1038400
                    pixmap.data[di + 1] = src_rgba[si + 1];
1038400
                    pixmap.data[di + 2] = src_rgba[si + 2];
1038400
                    pixmap.data[di + 3] = 255;
1038400
                } else if sa > 0 {
                    // Alpha blend: dst = src * sa + dst * (255 - sa)
                    let da = 255 - sa;
                    pixmap.data[di] =
                        ((src_rgba[si] as u32 * sa + pixmap.data[di] as u32 * da) / 255) as u8;
                    pixmap.data[di + 1] = ((src_rgba[si + 1] as u32 * sa
                        + pixmap.data[di + 1] as u32 * da)
                        / 255) as u8;
                    pixmap.data[di + 2] = ((src_rgba[si + 2] as u32 * sa
                        + pixmap.data[di + 2] as u32 * da)
                        / 255) as u8;
                    pixmap.data[di + 3] =
                        ((sa + pixmap.data[di + 3] as u32 * da / 255).min(255)) as u8;
                }
            }
        }
    }
132
    Ok(())
132
}
220
fn build_rect_path(rect: &AzRect) -> PathStorage {
220
    let mut path = PathStorage::new();
220
    let x = rect.x as f64;
220
    let y = rect.y as f64;
220
    let w = rect.width as f64;
220
    let h = rect.height as f64;
220
    path.move_to(x, y);
220
    path.line_to(x + w, y);
220
    path.line_to(x + w, y + h);
220
    path.line_to(x, y + h);
220
    path.close_polygon(PATH_FLAGS_NONE);
220
    path
220
}
2596
fn build_rounded_rect_path(
2596
    rect: &AzRect,
2596
    border_radius: &BorderRadius,
2596
    dpi_factor: f32,
2596
) -> PathStorage {
2596
    let mut path = PathStorage::new();
2596
    let x = rect.x as f64;
2596
    let y = rect.y as f64;
2596
    let w = rect.width as f64;
2596
    let h = rect.height as f64;
2596
    let tl = (border_radius.top_left * dpi_factor) as f64;
2596
    let tr = (border_radius.top_right * dpi_factor) as f64;
2596
    let br = (border_radius.bottom_right * dpi_factor) as f64;
2596
    let bl = (border_radius.bottom_left * dpi_factor) as f64;
2596
    if tl <= 0.0 && tr <= 0.0 && br <= 0.0 && bl <= 0.0 {
2200
        path.move_to(x, y);
2200
        path.line_to(x + w, y);
2200
        path.line_to(x + w, y + h);
2200
        path.line_to(x, y + h);
2200
        path.close_polygon(PATH_FLAGS_NONE);
2200
        return path;
396
    }
    // agg::RoundedRect emits real arc vertices (MOVE_TO + LINE_TO segments)
    // via its embedded Arc generator, which the scanline rasterizer consumes
    // directly. curve3() control points are silently flattened to straight
    // lines by the rasterizer, which is why the hand-rolled path produced
    // square corners — Arc-based flattening produces smooth corners.
    //
    // agg's corner slots (rx1/ry1 .. rx4/ry4) map to screen corners as:
    //   slot 1 → top-left    (center at x1+rx1, y1+ry1)
    //   slot 2 → top-right   (center at x2-rx2, y1+ry2)
    //   slot 3 → bottom-right (center at x2-rx3, y2-ry3)
    //   slot 4 → bottom-left (center at x1+rx4, y2-ry4)
396
    let mut rr = RoundedRect::default_new();
396
    rr.rect(x, y, x + w, y + h);
396
    rr.radius_all(tl, tl, tr, tr, br, br, bl, bl);
396
    rr.normalize_radius();
396
    rr.set_approximation_scale(dpi_factor.max(1.0) as f64);
396
    path.concat_path(&mut rr, 0);
396
    path
2596
}
// ============================================================================
// Component Preview Rendering
// ============================================================================
/// Options for rendering a component preview.
pub struct ComponentPreviewOptions {
    /// Optional width constraint. If None, size to content (uses 4096px max).
    pub width: Option<f32>,
    /// Optional height constraint. If None, size to content (uses 4096px max).
    pub height: Option<f32>,
    /// DPI scale factor. Default 1.0.
    pub dpi_factor: f32,
    /// Background color. Default white.
    pub background_color: ColorU,
}
impl Default for ComponentPreviewOptions {
    fn default() -> Self {
        Self {
            width: None,
            height: None,
            dpi_factor: 1.0,
            background_color: ColorU {
                r: 255,
                g: 255,
                b: 255,
                a: 255,
            },
        }
    }
}
/// Result of a component preview render.
pub struct ComponentPreviewResult {
    /// PNG-encoded image data.
    pub png_data: Vec<u8>,
    /// Actual content width (logical pixels).
    pub content_width: f32,
    /// Actual content height (logical pixels).
    pub content_height: f32,
}
/// Compute the tight bounding box of all display list items.
fn compute_content_bounds(dl: &DisplayList) -> Option<(f32, f32, f32, f32)> {
    let mut min_x = f32::MAX;
    let mut min_y = f32::MAX;
    let mut max_x = f32::MIN;
    let mut max_y = f32::MIN;
    let mut has_items = false;
    for item in &dl.items {
        let bounds = match item {
            DisplayListItem::Rect { bounds, .. } => Some(*bounds),
            DisplayListItem::SelectionRect { bounds, .. } => Some(*bounds),
            DisplayListItem::Border { bounds, .. } => Some(*bounds),
            DisplayListItem::Text { clip_rect, .. } => Some(*clip_rect),
            DisplayListItem::Image { bounds, .. } => Some(*bounds),
            DisplayListItem::BoxShadow { bounds, .. } => Some(*bounds),
            DisplayListItem::PushClip { bounds, .. } => Some(*bounds),
            DisplayListItem::LinearGradient { bounds, .. } => Some(*bounds),
            DisplayListItem::RadialGradient { bounds, .. } => Some(*bounds),
            DisplayListItem::ConicGradient { bounds, .. } => Some(*bounds),
            DisplayListItem::VirtualView { bounds, .. } => Some(*bounds),
            DisplayListItem::ScrollBar { bounds, .. } => Some(*bounds),
            _ => None,
        };
        if let Some(b) = bounds {
            has_items = true;
            min_x = min_x.min(b.0.origin.x);
            min_y = min_y.min(b.0.origin.y);
            max_x = max_x.max(b.0.origin.x + b.0.size.width);
            max_y = max_y.max(b.0.origin.y + b.0.size.height);
        }
    }
    if has_items {
        Some((min_x, min_y, max_x, max_y))
    } else {
        None
    }
}
/// Render a `StyledDom` to a PNG image for component preview.
#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
748
pub fn render_component_preview(
748
    styled_dom: azul_core::styled_dom::StyledDom,
748
    font_manager: &FontManager<azul_css::props::basic::FontRef>,
748
    opts: ComponentPreviewOptions,
748
    system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
748
) -> Result<ComponentPreviewResult, String> {
    use crate::{
        font_traits::TextLayoutCache,
        solver3::{self, cache::LayoutCache, display_list::DisplayList},
    };
    use azul_core::{
        dom::DomId,
        geom::{LogicalPosition, LogicalRect, LogicalSize},
        resources::{IdNamespace, RendererResources},
        selection::{SelectionState, TextSelection},
    };
    use std::collections::{BTreeMap, HashMap};
    const MAX_SIZE: f32 = 4096.0;
748
    let layout_width = opts.width.unwrap_or(MAX_SIZE);
748
    let layout_height = opts.height.unwrap_or(MAX_SIZE);
748
    let viewport = LogicalRect {
748
        origin: LogicalPosition::zero(),
748
        size: LogicalSize {
748
            width: layout_width,
748
            height: layout_height,
748
        },
748
    };
748
    let mut preview_font_manager = FontManager::from_arc_shared(
748
        font_manager.fc_cache.clone(),
748
        font_manager.parsed_fonts.clone(),
    )
748
    .map_err(|e| format!("Failed to create preview font manager: {:?}", e))?;
    // --- Font resolution ---
    {
        use crate::solver3::getters::collect_and_resolve_font_chains_with_registration;
        use crate::text3::default::PathLoader;
748
        let platform = azul_css::system::Platform::current();
748
        let chains = collect_and_resolve_font_chains_with_registration(
748
            &styled_dom,
748
            &preview_font_manager.fc_cache,
748
            &preview_font_manager,
748
            &platform,
        );
748
        let loader = PathLoader::new();
748
        let _failed = preview_font_manager.load_missing_for_chains(&chains, |bytes, index| {
            loader.load_font_shared(bytes, index)
        });
748
        preview_font_manager.set_font_chain_cache(chains.into_fontconfig_chains());
    }
    // --- Layout ---
748
    let mut layout_cache = LayoutCache {
748
        tree: None,
748
        calculated_positions: Vec::new(),
748
        viewport: None,
748
        scroll_ids: HashMap::new(),
748
        scroll_id_to_node_id: HashMap::new(),
748
        counters: HashMap::new(),
748
        float_cache: HashMap::new(),
748
        cache_map: Default::default(),
748
        previous_positions: Vec::new(),
748
        cached_display_list: None,
748
        prev_dom_ptr: 0,
748
        prev_viewport: LogicalRect::zero(),
748
    };
748
    let mut text_cache = TextLayoutCache::new();
748
    let empty_scroll_offsets = BTreeMap::new();
748
    let empty_text_selections = BTreeMap::new();
748
    let renderer_resources = RendererResources::default();
748
    let id_namespace = IdNamespace(0xFFFF);
748
    let dom_id = DomId::ROOT_ID;
748
    let mut debug_messages = None;
748
    let get_system_time_fn = azul_core::task::GetSystemTimeCallback {
748
        cb: azul_core::task::get_system_time_libstd,
748
    };
748
    let display_list = solver3::layout_document(
748
        &mut layout_cache,
748
        &mut text_cache,
748
        &styled_dom,
748
        viewport,
748
        &preview_font_manager,
748
        &empty_scroll_offsets,
748
        &empty_text_selections,
748
        &mut debug_messages,
748
        None,
748
        &renderer_resources,
748
        id_namespace,
748
        dom_id,
        false,
748
        Vec::new(),
748
        None, // preedit_text: not needed for headless preview rendering
748
        &azul_core::resources::ImageCache::default(),
748
        system_style.clone(),
748
        get_system_time_fn,
    )
748
    .map_err(|e| format!("Layout failed: {:?}", e))?;
    // --- Determine actual render size ---
748
    let (render_width, render_height) = if opts.width.is_some() && opts.height.is_some() {
748
        (opts.width.unwrap(), opts.height.unwrap())
    } else {
        match compute_content_bounds(&display_list) {
            Some((_min_x, _min_y, max_x, max_y)) => {
                let w = if opts.width.is_some() {
                    opts.width.unwrap()
                } else {
                    max_x.max(1.0).ceil()
                };
                let h = if opts.height.is_some() {
                    opts.height.unwrap()
                } else {
                    max_y.max(1.0).ceil()
                };
                (w, h)
            }
            None => {
                return Ok(ComponentPreviewResult {
                    png_data: Vec::new(),
                    content_width: 0.0,
                    content_height: 0.0,
                });
            }
        }
    };
748
    let render_width = render_width.min(MAX_SIZE);
748
    let render_height = render_height.min(MAX_SIZE);
    // --- Render ---
748
    let dpi = opts.dpi_factor;
748
    let pixel_w = ((render_width * dpi) as u32).max(1);
748
    let pixel_h = ((render_height * dpi) as u32).max(1);
748
    let mut pixmap = AzulPixmap::new(pixel_w, pixel_h)
748
        .ok_or_else(|| format!("Cannot create pixmap {}x{}", pixel_w, pixel_h))?;
748
    let bg = opts.background_color;
748
    pixmap.fill(bg.r, bg.g, bg.b, bg.a);
748
    let mut preview_glyph_cache = GlyphCache::new();
748
    let preview_render_state =
748
        CpuRenderState::new(ScrollOffsetMap::new()).with_system_style(system_style);
748
    render_display_list_with_state(
748
        &display_list,
748
        &mut pixmap,
748
        dpi,
748
        &renderer_resources,
748
        Some(&preview_font_manager),
748
        &mut preview_glyph_cache,
748
        &preview_render_state,
    )?;
748
    let png_data = pixmap
748
        .encode_png()
748
        .map_err(|e| format!("PNG encoding failed: {}", e))?;
748
    Ok(ComponentPreviewResult {
748
        png_data,
748
        content_width: render_width,
748
        content_height: render_height,
748
    })
748
}
/// Render a `Dom` + `Css` to a PNG image at the given dimensions.
///
/// This is a convenience API that creates a `StyledDom`, lays it out,
/// and rasterizes via the CPU renderer.
#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
748
pub fn render_dom_to_image(
748
    mut dom: azul_core::dom::Dom,
748
    css: azul_css::css::Css,
748
    width: f32,
748
    height: f32,
748
    dpi: f32,
748
) -> Result<Vec<u8>, String> {
    use crate::font_traits::FontManager;
    use azul_core::styled_dom::StyledDom;
748
    let styled_dom = StyledDom::create(&mut dom, css);
748
    let fc_cache = crate::font::loading::build_font_cache();
748
    let font_manager = FontManager::new(fc_cache)
748
        .map_err(|e| format!("Failed to create font manager: {:?}", e))?;
748
    let opts = ComponentPreviewOptions {
748
        width: Some(width),
748
        height: Some(height),
748
        dpi_factor: dpi,
748
        background_color: azul_css::props::basic::ColorU {
748
            r: 255,
748
            g: 255,
748
            b: 255,
748
            a: 255,
748
        },
748
    };
748
    let result = render_component_preview(styled_dom, &font_manager, opts, None)?;
748
    Ok(result.png_data)
748
}
// ============================================================================
// Direct SVG-to-image renderer (bypasses CSS layout)
// ============================================================================
/// Render raw SVG bytes to a PNG image.
///
/// Parses the SVG XML, walks the element tree, extracts path geometry +
/// fill/stroke attributes, and rasterizes via agg-rust directly (no CSS
/// layout involved).
#[cfg(all(feature = "std", feature = "xml"))]
88
pub fn render_svg_to_png(
88
    svg_data: &[u8],
88
    target_width: u32,
88
    target_height: u32,
88
) -> Result<Vec<u8>, String> {
88
    let svg_str =
88
        core::str::from_utf8(svg_data).map_err(|e| format!("SVG is not valid UTF-8: {e}"))?;
88
    let nodes =
88
        crate::xml::parse_xml_string(svg_str).map_err(|e| format!("XML parse error: {e}"))?;
    // Find the <svg> root
88
    let node_slice: &[azul_core::xml::XmlNodeChild] = nodes.as_ref();
88
    let svg_node = node_slice
88
        .iter()
88
        .find_map(|n| {
88
            if let azul_core::xml::XmlNodeChild::Element(e) = n {
88
                let tag = e.node_type.as_str().to_lowercase();
88
                if tag == "svg" {
88
                    Some(e)
                } else {
                    None
                }
            } else {
                None
            }
88
        })
88
        .ok_or_else(|| "No <svg> root element found".to_string())?;
    // Parse viewBox for coordinate mapping
88
    let vb = parse_viewbox(svg_node);
88
    let (vb_x, vb_y, vb_w, vb_h) =
88
        vb.unwrap_or((0.0, 0.0, target_width as f64, target_height as f64));
88
    let sx = target_width as f64 / vb_w;
88
    let sy = target_height as f64 / vb_h;
88
    let scale = sx.min(sy);
88
    let root_transform =
88
        TransAffine::new_custom(scale, 0.0, 0.0, scale, -vb_x * scale, -vb_y * scale);
88
    let mut pixmap = AzulPixmap::new(target_width, target_height)
88
        .ok_or_else(|| "Failed to create pixmap".to_string())?;
88
    pixmap.fill(255, 255, 255, 255);
88
    render_svg_group(svg_node, &mut pixmap, &root_transform);
88
    pixmap
88
        .encode_png()
88
        .map_err(|e| format!("PNG encode error: {e}"))
88
}
/// Like [`render_svg_to_png`] but returns the rendered pixmap as an [`ImageRef`]
/// (RGBA8) directly — no PNG round-trip. The MapWidget uses this to render each
/// decoded tile SVG to a colour image node: `SvgNodeData::Path` in the DOM only
/// produces a clip mask (not a filled shape), so reuse the same `render_svg_group`
/// rasteriser the tiger uses (which reads SVG fill/stroke attrs) and embed the
/// result as an image.
pub fn render_svg_to_imageref(
    svg_data: &[u8],
    target_width: u32,
    target_height: u32,
) -> Result<ImageRef, String> {
    let svg_str =
        core::str::from_utf8(svg_data).map_err(|e| format!("SVG is not valid UTF-8: {e}"))?;
    let nodes =
        crate::xml::parse_xml_string(svg_str).map_err(|e| format!("XML parse error: {e}"))?;
    let node_slice: &[azul_core::xml::XmlNodeChild] = nodes.as_ref();
    let svg_node = node_slice
        .iter()
        .find_map(|n| {
            if let azul_core::xml::XmlNodeChild::Element(e) = n {
                if e.node_type.as_str().to_lowercase() == "svg" {
                    Some(e)
                } else {
                    None
                }
            } else {
                None
            }
        })
        .ok_or_else(|| "No <svg> root element found".to_string())?;
    let vb = parse_viewbox(svg_node);
    let (vb_x, vb_y, vb_w, vb_h) =
        vb.unwrap_or((0.0, 0.0, target_width as f64, target_height as f64));
    let scale = (target_width as f64 / vb_w).min(target_height as f64 / vb_h);
    let root_transform =
        TransAffine::new_custom(scale, 0.0, 0.0, scale, -vb_x * scale, -vb_y * scale);
    let mut pixmap = AzulPixmap::new(target_width, target_height)
        .ok_or_else(|| "Failed to create pixmap".to_string())?;
    // Transparent background so the tile container shows through any gaps.
    pixmap.fill(0, 0, 0, 0);
    render_svg_group(svg_node, &mut pixmap, &root_transform);
    let rgba = pixmap.data().to_vec();
    let raw = azul_core::resources::RawImage {
        pixels: azul_core::resources::RawImageData::U8(rgba.into()),
        width: target_width as usize,
        height: target_height as usize,
        premultiplied_alpha: false,
        data_format: azul_core::resources::RawImageFormat::RGBA8,
        tag: alloc::vec::Vec::new().into(),
    };
    ImageRef::new_rawimage(raw).ok_or_else(|| "Failed to build ImageRef from pixmap".to_string())
}
#[cfg(all(feature = "std", feature = "xml"))]
88
fn parse_viewbox(node: &azul_core::xml::XmlNode) -> Option<(f64, f64, f64, f64)> {
88
    let vb = node
88
        .attributes
88
        .get_key("viewbox")
88
        .or_else(|| node.attributes.get_key("viewBox"))?;
88
    let nums: Vec<f64> = vb
88
        .as_str()
1364
        .split(|c: char| c == ',' || c.is_ascii_whitespace())
352
        .filter(|s| !s.is_empty())
352
        .filter_map(|s| s.parse().ok())
88
        .collect();
88
    if nums.len() == 4 {
88
        Some((nums[0], nums[1], nums[2], nums[3]))
    } else {
        None
    }
88
}
/// Inherited SVG style (fill, stroke, stroke-width) that cascades from parent groups.
#[cfg(all(feature = "std", feature = "xml"))]
#[derive(Clone)]
struct SvgInheritedStyle {
    fill: Option<String>,   // None = not set (inherit default black)
    stroke: Option<String>, // None = not set (inherit default none)
    stroke_width: Option<f64>,
}
#[cfg(all(feature = "std", feature = "xml"))]
impl Default for SvgInheritedStyle {
88
    fn default() -> Self {
88
        Self {
88
            fill: None,
88
            stroke: None,
88
            stroke_width: None,
88
        }
88
    }
}
#[cfg(all(feature = "std", feature = "xml"))]
88
fn render_svg_group(
88
    node: &azul_core::xml::XmlNode,
88
    pixmap: &mut AzulPixmap,
88
    parent_transform: &TransAffine,
88
) {
88
    render_svg_group_with_style(
88
        node,
88
        pixmap,
88
        parent_transform,
88
        &SvgInheritedStyle::default(),
    );
88
}
#[cfg(all(feature = "std", feature = "xml"))]
11220
fn render_svg_group_with_style(
11220
    node: &azul_core::xml::XmlNode,
11220
    pixmap: &mut AzulPixmap,
11220
    parent_transform: &TransAffine,
11220
    parent_style: &SvgInheritedStyle,
11220
) {
    use agg_rust::math_stroke::{LineCap, LineJoin};
    use azul_core::xml::{XmlNode, XmlNodeChild};
11220
    let group_transform = if let Some(t) = node.attributes.get_key("transform") {
88
        let mut tf = parse_svg_transform(t.as_str());
88
        tf.premultiply(parent_transform);
88
        tf
    } else {
11132
        parent_transform.clone()
    };
    // Inherit style from this group's attributes
11220
    let group_style = SvgInheritedStyle {
11220
        fill: node
11220
            .attributes
11220
            .get_key("fill")
11220
            .map(|s| s.as_str().to_string())
11220
            .or_else(|| parent_style.fill.clone()),
11220
        stroke: node
11220
            .attributes
11220
            .get_key("stroke")
11220
            .map(|s| s.as_str().to_string())
11220
            .or_else(|| parent_style.stroke.clone()),
11220
        stroke_width: node
11220
            .attributes
11220
            .get_key("stroke-width")
11220
            .and_then(|s| s.as_str().parse().ok())
11220
            .or(parent_style.stroke_width),
    };
54604
    for child in node.children.as_ref().iter() {
54604
        let child_node = match child {
21868
            XmlNodeChild::Element(e) => e,
32736
            _ => continue,
        };
21868
        let tag = child_node.node_type.as_str().to_lowercase();
21868
        match tag.as_str() {
21868
            "g" | "svg" => {
10648
                render_svg_group_with_style(child_node, pixmap, &group_transform, &group_style);
10648
            }
11220
            "path" | "circle" | "rect" | "ellipse" | "line" | "polygon" | "polyline" => {
10736
                let path_storage = match build_agg_path(child_node) {
10736
                    Some(p) => p,
                    None => continue,
                };
                // Flatten bezier curves into line segments for the rasterizer
10736
                let mut curved = agg_rust::conv_curve::ConvCurve::new(path_storage);
                // Per-element transform
10736
                let elem_transform = if let Some(t) = child_node.attributes.get_key("transform") {
                    let mut tf = parse_svg_transform(t.as_str());
                    tf.premultiply(&group_transform);
                    tf
                } else {
10736
                    group_transform.clone()
                };
                // Fill: element overrides group
10736
                let fill_attr = child_node
10736
                    .attributes
10736
                    .get_key("fill")
10736
                    .map(|s| s.as_str().to_string())
10736
                    .or_else(|| group_style.fill.clone());
10736
                let fill_color = match fill_attr.as_deref() {
10560
                    Some("none") => None,
9988
                    Some(c) => parse_svg_color(c),
176
                    None => Some(Rgba8 {
176
                        r: 0,
176
                        g: 0,
176
                        b: 0,
176
                        a: 255,
176
                    }), // SVG default
                };
10736
                let fill_opacity = child_node
10736
                    .attributes
10736
                    .get_key("fill-opacity")
10736
                    .and_then(|s| s.as_str().parse::<f64>().ok())
10736
                    .unwrap_or(1.0);
10736
                let opacity = child_node
10736
                    .attributes
10736
                    .get_key("opacity")
10736
                    .and_then(|s| s.as_str().parse::<f64>().ok())
10736
                    .unwrap_or(1.0);
10736
                if let Some(mut color) = fill_color {
10164
                    color.a = ((color.a as f64) * fill_opacity * opacity).min(255.0) as u8;
10164
                    let fill_rule_str = child_node
10164
                        .attributes
10164
                        .get_key("fill-rule")
10164
                        .map(|s| s.as_str().to_string());
10164
                    let rule = match fill_rule_str.as_deref() {
                        Some("evenodd") => FillingRule::EvenOdd,
10164
                        _ => FillingRule::NonZero,
                    };
10164
                    let mut transformed = ConvTransform::new(&mut curved, elem_transform.clone());
10164
                    agg_fill_path(pixmap, &mut transformed, &color, rule);
572
                }
                // Stroke: element overrides group
10736
                let stroke_attr = child_node
10736
                    .attributes
10736
                    .get_key("stroke")
10736
                    .map(|s| s.as_str().to_string())
10736
                    .or_else(|| group_style.stroke.clone());
10736
                let stroke_color = match stroke_attr.as_deref() {
7304
                    Some("none") | None => None,
3432
                    Some(c) => parse_svg_color(c),
                };
10736
                if let Some(mut color) = stroke_color {
3432
                    let stroke_opacity = child_node
3432
                        .attributes
3432
                        .get_key("stroke-opacity")
3432
                        .and_then(|s| s.as_str().parse::<f64>().ok())
3432
                        .unwrap_or(1.0);
3432
                    color.a = ((color.a as f64) * stroke_opacity * opacity).min(255.0) as u8;
3432
                    let stroke_width = child_node
3432
                        .attributes
3432
                        .get_key("stroke-width")
3432
                        .and_then(|s| s.as_str().parse::<f64>().ok())
3432
                        .or(group_style.stroke_width)
3432
                        .unwrap_or(1.0);
3432
                    let mut conv_stroke = ConvStroke::new(&mut curved);
3432
                    conv_stroke.set_width(stroke_width);
3432
                    conv_stroke.set_line_cap(LineCap::Round);
3432
                    conv_stroke.set_line_join(LineJoin::Round);
3432
                    let mut transformed =
3432
                        ConvTransform::new(&mut conv_stroke, elem_transform.clone());
3432
                    agg_fill_path(pixmap, &mut transformed, &color, FillingRule::NonZero);
7304
                }
            }
484
            _ => {
484
                // Recurse into unknown containers (defs, symbol, etc.)
484
                render_svg_group_with_style(child_node, pixmap, &group_transform, &group_style);
484
            }
        }
    }
11220
}
/// Build an agg PathStorage from an SVG shape element's attributes.
#[cfg(all(feature = "std", feature = "xml"))]
10736
fn build_agg_path(node: &azul_core::xml::XmlNode) -> Option<PathStorage> {
10736
    let tag = node.node_type.as_str().to_lowercase();
10736
    match tag.as_str() {
10736
        "path" => {
10648
            let d = node.attributes.get_key("d")?;
10648
            let mp = azul_core::path_parser::parse_svg_path_d(d.as_str()).ok()?;
10648
            Some(svg_multi_polygon_to_path_storage(&mp))
        }
88
        "circle" => {
44
            let cx = attr_f64(node, "cx");
44
            let cy = attr_f64(node, "cy");
44
            let r = attr_f64(node, "r");
44
            if r <= 0.0 {
                return None;
44
            }
44
            let mp = azul_core::path_parser::svg_circle_to_paths(cx as f32, cy as f32, r as f32);
44
            let multi = azul_core::svg::SvgMultiPolygon {
44
                rings: azul_core::svg::SvgPathVec::from_vec(vec![mp]),
44
            };
44
            Some(svg_multi_polygon_to_path_storage(&multi))
        }
44
        "rect" => {
44
            let x = attr_f64(node, "x");
44
            let y = attr_f64(node, "y");
44
            let w = attr_f64(node, "width");
44
            let h = attr_f64(node, "height");
44
            let rx = attr_f64(node, "rx");
44
            let ry = if let Some(v) = node.attributes.get_key("ry") {
44
                v.as_str().parse().unwrap_or(rx)
            } else {
                rx
            };
44
            if w <= 0.0 || h <= 0.0 {
                return None;
44
            }
44
            let mp = azul_core::path_parser::svg_rect_to_path(
44
                x as f32, y as f32, w as f32, h as f32, rx as f32, ry as f32,
            );
44
            let multi = azul_core::svg::SvgMultiPolygon {
44
                rings: azul_core::svg::SvgPathVec::from_vec(vec![mp]),
44
            };
44
            Some(svg_multi_polygon_to_path_storage(&multi))
        }
        "ellipse" => {
            let cx = attr_f64(node, "cx");
            let cy = attr_f64(node, "cy");
            let rx = attr_f64(node, "rx");
            let ry = attr_f64(node, "ry");
            if rx <= 0.0 || ry <= 0.0 {
                return None;
            }
            // Use circle path with scaling
            let mp = azul_core::path_parser::svg_circle_to_paths(cx as f32, cy as f32, 1.0);
            let multi = azul_core::svg::SvgMultiPolygon {
                rings: azul_core::svg::SvgPathVec::from_vec(vec![mp]),
            };
            let mut ps = svg_multi_polygon_to_path_storage(&multi);
            // Scale ellipse: we'll just build it directly instead
            let mut path = PathStorage::new();
            const KAPPA: f64 = 0.5522847498;
            let kx = rx * KAPPA;
            let ky = ry * KAPPA;
            path.move_to(cx, cy - ry);
            path.curve4(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy);
            path.curve4(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry);
            path.curve4(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy);
            path.curve4(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry);
            path.close_polygon(PATH_FLAGS_NONE);
            Some(path)
        }
        "line" => {
            let x1 = attr_f64(node, "x1");
            let y1 = attr_f64(node, "y1");
            let x2 = attr_f64(node, "x2");
            let y2 = attr_f64(node, "y2");
            let mut path = PathStorage::new();
            path.move_to(x1, y1);
            path.line_to(x2, y2);
            Some(path)
        }
        "polygon" | "polyline" => {
            let pts_str = node.attributes.get_key("points")?;
            let nums: Vec<f64> = pts_str
                .as_str()
                .split(|c: char| c == ',' || c.is_ascii_whitespace())
                .filter(|s| !s.is_empty())
                .filter_map(|s| s.parse().ok())
                .collect();
            if nums.len() < 4 {
                return None;
            }
            let mut path = PathStorage::new();
            path.move_to(nums[0], nums[1]);
            for chunk in nums[2..].chunks_exact(2) {
                path.line_to(chunk[0], chunk[1]);
            }
            if tag == "polygon" {
                path.close_polygon(PATH_FLAGS_NONE);
            }
            Some(path)
        }
        _ => None,
    }
10736
}
#[cfg(all(feature = "std", feature = "xml"))]
352
fn attr_f64(node: &azul_core::xml::XmlNode, key: &str) -> f64 {
352
    node.attributes
352
        .get_key(key)
352
        .and_then(|s| s.as_str().parse().ok())
352
        .unwrap_or(0.0)
352
}
/// Convert SvgMultiPolygon to agg PathStorage.
#[cfg(all(feature = "std", feature = "xml"))]
10736
fn svg_multi_polygon_to_path_storage(mp: &azul_core::svg::SvgMultiPolygon) -> PathStorage {
10736
    let mut path = PathStorage::new();
10736
    for ring in mp.rings.as_ref().iter() {
10692
        let mut first = true;
92532
        for item in ring.items.as_ref().iter() {
92532
            match item {
9328
                azul_core::svg::SvgPathElement::Line(l) => {
9328
                    if first {
528
                        path.move_to(l.start.x as f64, l.start.y as f64);
528
                        first = false;
8800
                    }
9328
                    path.line_to(l.end.x as f64, l.end.y as f64);
                }
                azul_core::svg::SvgPathElement::QuadraticCurve(q) => {
                    if first {
                        path.move_to(q.start.x as f64, q.start.y as f64);
                        first = false;
                    }
                    path.curve3(
                        q.ctrl.x as f64,
                        q.ctrl.y as f64,
                        q.end.x as f64,
                        q.end.y as f64,
                    );
                }
83204
                azul_core::svg::SvgPathElement::CubicCurve(c) => {
83204
                    if first {
10164
                        path.move_to(c.start.x as f64, c.start.y as f64);
10164
                        first = false;
73040
                    }
83204
                    path.curve4(
83204
                        c.ctrl_1.x as f64,
83204
                        c.ctrl_1.y as f64,
83204
                        c.ctrl_2.x as f64,
83204
                        c.ctrl_2.y as f64,
83204
                        c.end.x as f64,
83204
                        c.end.y as f64,
                    );
                }
            }
        }
10692
        path.close_polygon(PATH_FLAGS_NONE);
    }
10736
    path
10736
}
/// Parse SVG transform attribute (supports matrix, translate, scale, rotate).
#[cfg(all(feature = "std", feature = "xml"))]
88
fn parse_svg_transform(s: &str) -> TransAffine {
88
    let s = s.trim();
88
    let parse_nums = |inner: &str| -> Vec<f64> {
88
        inner
2816
            .split(|c: char| c == ',' || c.is_ascii_whitespace())
352
            .filter(|s| !s.is_empty())
352
            .filter_map(|s| s.parse().ok())
88
            .collect()
88
    };
88
    if let Some(inner) = s.strip_prefix("matrix(").and_then(|s| s.strip_suffix(')')) {
44
        let nums = parse_nums(inner);
44
        if nums.len() == 6 {
44
            return TransAffine::new_custom(nums[0], nums[1], nums[2], nums[3], nums[4], nums[5]);
        }
44
    } else if let Some(inner) = s
44
        .strip_prefix("translate(")
44
        .and_then(|s| s.strip_suffix(')'))
    {
44
        let nums = parse_nums(inner);
44
        let tx = nums.first().copied().unwrap_or(0.0);
44
        let ty = nums.get(1).copied().unwrap_or(0.0);
44
        return TransAffine::new_custom(1.0, 0.0, 0.0, 1.0, tx, ty);
    } else if let Some(inner) = s.strip_prefix("scale(").and_then(|s| s.strip_suffix(')')) {
        let nums = parse_nums(inner);
        let sx = nums.first().copied().unwrap_or(1.0);
        let sy = nums.get(1).copied().unwrap_or(sx);
        return TransAffine::new_custom(sx, 0.0, 0.0, sy, 0.0, 0.0);
    } else if let Some(inner) = s.strip_prefix("rotate(").and_then(|s| s.strip_suffix(')')) {
        let nums = parse_nums(inner);
        let angle = nums.first().copied().unwrap_or(0.0).to_radians();
        let cos_a = angle.cos();
        let sin_a = angle.sin();
        return TransAffine::new_custom(cos_a, sin_a, -sin_a, cos_a, 0.0, 0.0);
    }
    TransAffine::new()
88
}
/// Parse SVG color string (#RRGGBB, #RGB, named colors).
#[cfg(all(feature = "std", feature = "xml"))]
13420
fn parse_svg_color(s: &str) -> Option<Rgba8> {
13420
    let s = s.trim();
13420
    if s.starts_with('#') {
13420
        let hex = &s[1..];
13420
        return match hex.len() {
            6 => {
2552
                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
2552
                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
2552
                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
2552
                Some(Rgba8 { r, g, b, a: 255 })
            }
            3 => {
10868
                let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
10868
                let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
10868
                let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
10868
                Some(Rgba8 { r, g, b, a: 255 })
            }
            _ => None,
        };
    }
    match s.to_lowercase().as_str() {
        "black" => Some(Rgba8 {
            r: 0,
            g: 0,
            b: 0,
            a: 255,
        }),
        "white" => Some(Rgba8 {
            r: 255,
            g: 255,
            b: 255,
            a: 255,
        }),
        "red" => Some(Rgba8 {
            r: 255,
            g: 0,
            b: 0,
            a: 255,
        }),
        "green" => Some(Rgba8 {
            r: 0,
            g: 128,
            b: 0,
            a: 255,
        }),
        "blue" => Some(Rgba8 {
            r: 0,
            g: 0,
            b: 255,
            a: 255,
        }),
        "yellow" => Some(Rgba8 {
            r: 255,
            g: 255,
            b: 0,
            a: 255,
        }),
        "orange" => Some(Rgba8 {
            r: 255,
            g: 165,
            b: 0,
            a: 255,
        }),
        "gold" => Some(Rgba8 {
            r: 255,
            g: 215,
            b: 0,
            a: 255,
        }),
        _ => None,
    }
13420
}
// ============================================================================
// scroll_shift_region — unit tests (#14 single-axis, #16 diagonal pan)
// ============================================================================
#[cfg(test)]
mod scroll_shift_tests {
    use super::*;
    use azul_core::geom::{LogicalPosition, LogicalRect, LogicalSize};
    /// Pixmap where every pixel encodes its own coords: R = x&0xFF, G = y&0xFF.
    /// After a shift, a pixel's (R,G) tells you which source pixel landed there,
    /// so we can assert the move is an exact translation.
5
    fn xy_pixmap(w: u32, h: u32) -> AzulPixmap {
5
        let mut p = AzulPixmap::new(w, h).unwrap();
5
        let d = p.data_mut();
428
        for y in 0..h {
68192
            for x in 0..w {
68192
                let i = ((y * w + x) * 4) as usize;
68192
                d[i] = (x & 0xFF) as u8;
68192
                d[i + 1] = (y & 0xFF) as u8;
68192
                d[i + 2] = 0;
68192
                d[i + 3] = 255;
68192
            }
        }
5
        p
5
    }
10
    fn at(p: &AzulPixmap, x: u32, y: u32) -> [u8; 4] {
10
        let w = p.width();
10
        let d = p.data();
10
        let i = ((y * w + x) * 4) as usize;
10
        [d[i], d[i + 1], d[i + 2], d[i + 3]]
10
    }
31
    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
31
        LogicalRect {
31
            origin: LogicalPosition::new(x, y),
31
            size: LogicalSize::new(w, h),
31
        }
31
    }
    #[test]
1
    fn noop_when_delta_zero() {
1
        let mut p = xy_pixmap(64, 64);
1
        let strips = scroll_shift_region(&mut p, &rect(0.0, 0.0, 64.0, 64.0), (0.0, 0.0), 1.0);
1
        assert!(strips.is_empty(), "zero delta must not shift or expose anything");
        // Buffer untouched.
1
        assert_eq!(at(&p, 10, 20), [10, 20, 0, 255]);
1
    }
    #[test]
1
    fn vertical_scroll_one_strip_and_translates() {
1
        let mut p = xy_pixmap(200, 100);
        // Scroll DOWN by 30 → content moves UP → bottom strip exposed.
1
        let strips = scroll_shift_region(&mut p, &rect(0.0, 0.0, 200.0, 100.0), (0.0, 30.0), 1.0);
1
        assert_eq!(strips.len(), 1, "single-axis scroll = one strip, got {:?}", strips);
1
        let s = &strips[0];
1
        assert!(
1
            (s.origin.y - (100.0 - s.size.height)).abs() < 0.01 && s.size.width == 200.0,
            "vertical scroll-down must expose a full-width BOTTOM strip, got {:?}",
            s
        );
        // Kept region (top): (x, y) now holds original (x, y+30).
1
        assert_eq!(at(&p, 50, 10), [50, 40, 0, 255], "content not translated up by 30");
1
    }
    #[test]
1
    fn diagonal_pan_two_strips_and_translates() {
1
        let mut p = xy_pixmap(200, 100);
        // Diagonal scroll down-right by (20, 30): content moves up-left.
1
        let strips =
1
            scroll_shift_region(&mut p, &rect(0.0, 0.0, 200.0, 100.0), (20.0, 30.0), 1.0);
1
        assert_eq!(
1
            strips.len(),
            2,
            "diagonal pan must expose TWO strips (L-shape), got {:?}",
            strips
        );
        // One full-width strip (the vertical move) + one full-height strip (horizontal).
1
        let has_h_strip = strips.iter().any(|s| s.size.width == 200.0);
2
        let has_v_strip = strips.iter().any(|s| s.size.height == 100.0);
1
        assert!(
1
            has_h_strip && has_v_strip,
            "expected a full-width AND a full-height strip, got {:?}",
            strips
        );
        // Kept top-left region: (sx,sy) now holds original (sx+20, sy+30).
        // (50,40) is inside the kept block (bottom strip y>=69, right strip x>=179).
1
        let got = at(&p, 50, 40);
1
        assert_eq!(got[0], 70, "x not translated left by 20 (R channel)");
1
        assert_eq!(got[1], 70, "y not translated up by 30 (G channel)");
1
    }
    #[test]
1
    fn shift_only_touches_inside_clip() {
1
        let mut p = xy_pixmap(200, 100);
        // Clip is a sub-region; everything OUTSIDE must be byte-identical after.
1
        let clip = rect(8.0, 16.0, 180.0, 60.0); // phys [8,188) x [16,76)
1
        let _ = scroll_shift_region(&mut p, &clip, (0.0, 10.0), 1.0);
7
        for &(x, y) in &[(0u32, 0u32), (199, 99), (100, 5), (100, 90), (2, 50), (190, 50)] {
6
            assert_eq!(
6
                at(&p, x, y),
6
                [(x & 0xFF) as u8, (y & 0xFF) as u8, 0, 255],
                "pixel ({},{}) OUTSIDE the clip was modified — scroll leaked past its frame",
                x,
                y
            );
        }
        // Inside the kept region it DID move: (50,40) holds original (50,50).
1
        assert_eq!(at(&p, 50, 40), [50, 50, 0, 255], "inside-clip content not shifted");
1
    }
    #[test]
1
    fn shift_larger_than_region_returns_full_clip() {
1
        let mut p = xy_pixmap(64, 64);
1
        let clip = rect(0.0, 0.0, 64.0, 64.0);
        // Shift exceeds the region height → whole clip exposed (no partial strip).
1
        let strips = scroll_shift_region(&mut p, &clip, (0.0, 100.0), 1.0);
1
        assert_eq!(strips.len(), 1);
1
        assert_eq!(strips[0].size.width, 64.0);
1
        assert_eq!(strips[0].size.height, 64.0);
1
    }
    // --- #20 fast-path eligibility ---
    use crate::solver3::display_list::{
        BorderRadius, DisplayList, DisplayListItem, WindowLogicalRect,
    };
    use azul_css::props::basic::color::ColorU;
5
    fn dl(items: Vec<DisplayListItem>) -> DisplayList {
5
        DisplayList {
5
            items,
5
            node_mapping: Vec::new(),
5
            forced_page_breaks: Vec::new(),
5
            fixed_position_item_ranges: Vec::new(),
5
        }
5
    }
15
    fn wr(x: f32, y: f32, w: f32, h: f32) -> WindowLogicalRect {
15
        rect(x, y, w, h).into()
15
    }
10
    fn fill(x: f32, y: f32, w: f32, h: f32, a: u8) -> DisplayListItem {
10
        DisplayListItem::Rect {
10
            bounds: wr(x, y, w, h),
10
            color: ColorU { r: 10, g: 20, b: 30, a },
10
            border_radius: BorderRadius::default(),
10
        }
10
    }
5
    fn scroll_frame(id: u64) -> DisplayListItem {
5
        DisplayListItem::PushScrollFrame {
5
            clip_bounds: wr(0.0, 0.0, 100.0, 100.0),
5
            content_size: LogicalSize::new(100.0, 1000.0),
5
            scroll_id: id,
5
        }
5
    }
    #[test]
1
    fn eligible_when_no_backdrop_even_if_transparent() {
        // Transparent content, but nothing painted behind the frame → safe.
1
        let list = dl(vec![
1
            scroll_frame(7),
1
            fill(0.0, 0.0, 100.0, 30.0, 0), // transparent row
1
            DisplayListItem::PopScrollFrame,
        ]);
1
        assert!(scroll_fast_path_eligible(&list, 7, &rect(0.0, 0.0, 100.0, 100.0), (0.0, 0.0)));
1
    }
    #[test]
1
    fn eligible_when_backdrop_is_single_uniform_colour() {
        // A SINGLE flat colour covering the whole clip behind transparent content
        // drags invisibly (same colour everywhere) → aggressive policy keeps the
        // fast path. (This is the common body/container background case.)
1
        let list = dl(vec![
1
            fill(0.0, 0.0, 100.0, 100.0, 255), // one flat colour covering the clip
1
            scroll_frame(7),
1
            fill(0.0, 0.0, 100.0, 30.0, 0), // transparent content
1
            DisplayListItem::PopScrollFrame,
        ]);
1
        assert!(scroll_fast_path_eligible(&list, 7, &rect(0.0, 0.0, 100.0, 100.0), (0.0, 0.0)));
1
    }
    #[test]
1
    fn ineligible_when_backdrop_is_non_uniform() {
        // Two DIFFERENT colours behind transparent content → dragging them is
        // visible → must full-repaint.
1
        let mut left = fill(0.0, 0.0, 50.0, 100.0, 255);
1
        if let DisplayListItem::Rect { color, .. } = &mut left {
1
            *color = ColorU { r: 200, g: 0, b: 0, a: 255 };
1
        }
1
        let mut right = fill(50.0, 0.0, 50.0, 100.0, 255);
1
        if let DisplayListItem::Rect { color, .. } = &mut right {
1
            *color = ColorU { r: 0, g: 0, b: 200, a: 255 };
1
        }
1
        let list = dl(vec![
1
            left,
1
            right,
1
            scroll_frame(7),
1
            fill(0.0, 0.0, 100.0, 30.0, 0), // transparent content
1
            DisplayListItem::PopScrollFrame,
        ]);
1
        assert!(!scroll_fast_path_eligible(&list, 7, &rect(0.0, 0.0, 100.0, 100.0), (0.0, 0.0)));
1
    }
    #[test]
1
    fn ineligible_when_single_colour_only_partly_covers() {
        // One flat colour that covers only PART of the clip (rest is clear): its
        // edge against the clear would drag visibly → full-repaint.
1
        let list = dl(vec![
1
            fill(0.0, 0.0, 100.0, 40.0, 255), // covers only the top 40px
1
            scroll_frame(7),
1
            fill(0.0, 0.0, 100.0, 30.0, 0), // transparent content
1
            DisplayListItem::PopScrollFrame,
        ]);
1
        assert!(!scroll_fast_path_eligible(&list, 7, &rect(0.0, 0.0, 100.0, 100.0), (0.0, 0.0)));
1
    }
    #[test]
1
    fn eligible_when_backdrop_but_opaque_content_covers() {
        // Backdrop behind, but the scrolling content opaquely covers the clip →
        // nothing behind ever shows through → fast path is safe.
1
        let list = dl(vec![
1
            fill(0.0, 0.0, 100.0, 100.0, 255), // backdrop
1
            scroll_frame(7),
1
            fill(0.0, 0.0, 100.0, 1000.0, 255), // opaque full-content cover
1
            DisplayListItem::PopScrollFrame,
        ]);
1
        assert!(scroll_fast_path_eligible(&list, 7, &rect(0.0, 0.0, 100.0, 100.0), (0.0, 0.0)));
1
    }
    #[test]
1
    fn rect_covered_by_detects_gap() {
1
        let target = rect(0.0, 0.0, 100.0, 100.0);
        // Single full cover.
1
        assert!(rect_covered_by(&target, &[rect(0.0, 0.0, 100.0, 100.0)]));
        // Two halves tile it.
1
        assert!(rect_covered_by(
1
            &target,
1
            &[rect(0.0, 0.0, 100.0, 50.0), rect(0.0, 50.0, 100.0, 50.0)]
        ));
        // A gap in the middle is NOT covered.
1
        assert!(!rect_covered_by(
1
            &target,
1
            &[rect(0.0, 0.0, 100.0, 40.0), rect(0.0, 60.0, 100.0, 40.0)]
1
        ));
        // Empty → not covered.
1
        assert!(!rect_covered_by(&target, &[]));
1
    }
}