1
#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
2
use super::*;
3

            
4
use std::collections::HashMap;
5
use azul_core::geom::{LogicalPosition, LogicalRect, LogicalSize};
6
use azul_core::resources::RendererResources;
7
use azul_css::props::basic::{ColorU, FontRef};
8
use azul_css::props::basic::pixel::DEFAULT_FONT_SIZE;
9
use azul_css::props::style::filter::StyleFilter;
10
use agg_rust::blur::stack_blur_rgba32;
11
use agg_rust::rendering_buffer::RowAccessor;
12
use agg_rust::trans_affine::TransAffine;
13
use crate::glyph_cache::GlyphCache;
14
use crate::solver3::display_list::{BorderRadius, DisplayList, DisplayListItem, LocalScrollId};
15
use crate::text3::cache::FontManager;
16

            
17
const IDENTITY_EPSILON: f32 = 0.0001;
18

            
19

            
20
// ============================================================================
21
// Retained-Mode Compositor — Layer Tree
22
// ============================================================================
23

            
24
/// Unique identifier for a compositing layer.
25
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26
pub struct LayerId(pub u64);
27

            
28
/// Persistent compositor state across frames.
29
///
30
/// Holds a tree of `Layer`s, each with its own pixbuf. On incremental updates
31
/// only damaged layers are re-rendered, and scroll is handled by pixel-shift.
32
#[derive(Debug)]
33
pub struct CompositorState {
34
    /// All layers keyed by ID.
35
    pub layers: HashMap<LayerId, Layer>,
36
    /// Root layer of the tree.
37
    pub root_layer: LayerId,
38
    /// Monotonic counter for generating unique `LayerIds`.
39
    next_layer_id: u64,
40
    /// Previous frame's per-node positions, used for damage computation.
41
    pub previous_positions: Vec<LogicalPosition>,
42
}
43

            
44
/// A single compositing layer with its own pixel buffer.
45
#[derive(Debug)]
46
pub struct Layer {
47
    pub id: LayerId,
48
    /// Persistent RGBA buffer for this layer's content.
49
    pub pixbuf: AzulPixmap,
50
    /// Position and size in parent layer coordinates.
51
    pub bounds: LogicalRect,
52
    /// Dirty regions that need re-rendering this frame.
53
    pub damage: Vec<LogicalRect>,
54
    /// Child layers in z-order (bottom to top).
55
    pub children: Vec<LayerId>,
56
    /// Current scroll offset (for scroll-frame layers).
57
    pub scroll_offset: (f32, f32),
58
    /// Layer opacity (1.0 = fully opaque).
59
    pub opacity: f32,
60
    /// CSS filters applied at composite time. For a normal filter layer these
61
    /// are applied to the layer's OWN content; for a `backdrop-filter` layer
62
    /// (see `is_backdrop_filter`) they are instead applied to the already-
63
    /// composited backdrop pixels under the layer's bounds.
64
    pub filters: Vec<StyleFilter>,
65
    /// If true, `filters` apply to the backdrop (parent + earlier siblings
66
    /// already in `output`), not to this layer's own content.
67
    pub is_backdrop_filter: bool,
68
    /// CSS transform for this layer.
69
    pub transform: TransAffine,
70
    /// Range of display list items [start, end) that render into this layer.
71
    pub display_list_range: (usize, usize),
72
    /// If this layer is a scroll frame, the scroll ID.
73
    pub scroll_id: Option<LocalScrollId>,
74
    /// Whether this layer needs re-compositing onto its parent.
75
    pub composite_dirty: bool,
76
}
77

            
78
/// Reason a layer was created.
79
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80
pub enum LayerReason {
81
    /// Root layer (always exists).
82
    Root,
83
    /// Created for a `PushScrollFrame`.
84
    ScrollFrame,
85
    /// Created for a `PushFilter` containing blur.
86
    BlurFilter,
87
    /// Created for a `PushOpacity` with opacity < 1.0.
88
    Opacity,
89
    /// Created for a `PushReferenceFrame` with non-identity transform.
90
    Transform,
91
}
92

            
93
impl CompositorState {
94
    /// Create a new compositor with a root layer sized to the viewport.
95
    #[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
96
107
    #[must_use] pub fn new(width: u32, height: u32) -> Self {
97
107
        let root_id = LayerId(0);
98
107
        let root_layer = Layer::new(
99
107
            root_id,
100
107
            LogicalRect {
101
107
                origin: LogicalPosition::zero(),
102
107
                size: LogicalSize {
103
107
                    width: width as f32,
104
107
                    height: height as f32,
105
107
                },
106
107
            },
107
107
            width,
108
107
            height,
109
        );
110
107
        let mut layers = HashMap::new();
111
107
        layers.insert(root_id, root_layer);
112
107
        Self {
113
107
            layers,
114
107
            root_layer: root_id,
115
107
            next_layer_id: 1,
116
107
            previous_positions: Vec::new(),
117
107
        }
118
107
    }
119

            
120
    /// Allocate a new unique layer ID.
121
1
    pub const fn alloc_layer_id(&mut self) -> LayerId {
122
1
        let id = LayerId(self.next_layer_id);
123
1
        self.next_layer_id += 1;
124
1
        id
125
1
    }
126

            
127
    /// Read-only peek at the next layer ID counter (for leak probes).
128
    #[must_use] pub const fn next_layer_id_peek(&self) -> u64 {
129
        self.next_layer_id
130
    }
131

            
132
    /// Walk the display list and create layers for scroll frames, filters, opacity, transforms.
133
    /// Returns a mapping from display-list item index to the `LayerId` it should render into.
134
    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
135
    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
136
    /// # Panics
137
    ///
138
    /// Panics if the internal layer stack underflows (a malformed display list).
139
    // single-pass walk over the full display-list opcode set; splitting it would
140
    // only scatter the shared layer-stack state across helpers
141
    #[allow(clippy::cognitive_complexity)]
142
107
    pub fn allocate_layers_from_display_list(
143
107
        &mut self,
144
107
        display_list: &DisplayList,
145
107
        dpi_factor: f32,
146
107
    ) {
147
        // Remove all non-root layers from previous frame
148
107
        let root_id = self.root_layer;
149
107
        self.layers.retain(|id, _| *id == root_id);
150
107
        if let Some(root) = self.layers.get_mut(&root_id) {
151
107
            root.children.clear();
152
107
            root.damage.clear();
153
107
            root.display_list_range = (0, display_list.items.len());
154
107
            root.composite_dirty = true;
155
107
        }
156

            
157
107
        let mut layer_stack: Vec<LayerId> = vec![root_id];
158
107
        let mut i = 0;
159

            
160
4032
        while i < display_list.items.len() {
161
3925
            match &display_list.items[i] {
162
                DisplayListItem::PushScrollFrame {
163
                    clip_bounds,
164
                    content_size,
165
                    scroll_id,
166
                    ..
167
                } => {
168
                    let bounds = *clip_bounds.inner();
169
                    let pw = (bounds.size.width * dpi_factor).ceil() as u32;
170
                    let ph = (bounds.size.height * dpi_factor).ceil() as u32;
171
                    if pw > 0 && ph > 0 {
172
                        let new_id = self.alloc_layer_id();
173
                        let mut layer = Layer::new(new_id, bounds, pw, ph);
174
                        layer.scroll_id = Some(*scroll_id);
175
                        // Find the matching PopScrollFrame to set range
176
                        let end = find_matching_pop(&display_list.items, i, MatchKind::ScrollFrame);
177
                        layer.display_list_range = (i + 1, end);
178
                        self.layers.insert(new_id, layer);
179
                        // Add as child of current parent
180
                        let parent_id = *layer_stack.last().unwrap();
181
                        if let Some(parent) = self.layers.get_mut(&parent_id) {
182
                            parent.children.push(new_id);
183
                        }
184
                        layer_stack.push(new_id);
185
                    }
186
                }
187
                DisplayListItem::PopScrollFrame => {
188
                    if layer_stack.len() > 1 {
189
                        layer_stack.pop();
190
                    }
191
                }
192
                DisplayListItem::PushOpacity { bounds, opacity } => {
193
                    if *opacity < 1.0 {
194
                        let b = *bounds.inner();
195
                        let pw = (b.size.width * dpi_factor).ceil() as u32;
196
                        let ph = (b.size.height * dpi_factor).ceil() as u32;
197
                        if pw > 0 && ph > 0 {
198
                            let new_id = self.alloc_layer_id();
199
                            let mut layer = Layer::new(new_id, b, pw, ph);
200
                            layer.opacity = *opacity;
201
                            let end = find_matching_pop(&display_list.items, i, MatchKind::Opacity);
202
                            layer.display_list_range = (i + 1, end);
203
                            self.layers.insert(new_id, layer);
204
                            let parent_id = *layer_stack.last().unwrap();
205
                            if let Some(parent) = self.layers.get_mut(&parent_id) {
206
                                parent.children.push(new_id);
207
                            }
208
                            layer_stack.push(new_id);
209
                        }
210
                    }
211
                }
212
                DisplayListItem::PopOpacity => {
213
                    // Only pop if the top layer was an opacity layer
214
                    if layer_stack.len() > 1 {
215
                        let top_id = *layer_stack.last().unwrap();
216
                        if let Some(layer) = self.layers.get(&top_id) {
217
                            if layer.opacity < 1.0 && layer.scroll_id.is_none() {
218
                                layer_stack.pop();
219
                            }
220
                        }
221
                    }
222
                }
223
                DisplayListItem::PushFilter { bounds, filters } => {
224
                    let has_blur = filters.iter().any(|f| matches!(f, StyleFilter::Blur(_)));
225
                    if has_blur {
226
                        let b = *bounds.inner();
227
                        let pw = (b.size.width * dpi_factor).ceil() as u32;
228
                        let ph = (b.size.height * dpi_factor).ceil() as u32;
229
                        if pw > 0 && ph > 0 {
230
                            let new_id = self.alloc_layer_id();
231
                            let mut layer = Layer::new(new_id, b, pw, ph);
232
                            layer.filters.clone_from(filters);
233
                            let end = find_matching_pop(&display_list.items, i, MatchKind::Filter);
234
                            layer.display_list_range = (i + 1, end);
235
                            self.layers.insert(new_id, layer);
236
                            let parent_id = *layer_stack.last().unwrap();
237
                            if let Some(parent) = self.layers.get_mut(&parent_id) {
238
                                parent.children.push(new_id);
239
                            }
240
                            layer_stack.push(new_id);
241
                        }
242
                    }
243
                }
244
                DisplayListItem::PopFilter => {
245
                    if layer_stack.len() > 1 {
246
                        let top_id = *layer_stack.last().unwrap();
247
                        if let Some(layer) = self.layers.get(&top_id) {
248
                            if !layer.filters.is_empty() {
249
                                layer_stack.pop();
250
                            }
251
                        }
252
                    }
253
                }
254
                DisplayListItem::PushReferenceFrame {
255
                    initial_transform,
256
                    bounds,
257
                    ..
258
                } => {
259
                    let m = &initial_transform.m;
260
                    let is_identity = (m[0][0] - 1.0).abs() < IDENTITY_EPSILON
261
                        && m[0][1].abs() < IDENTITY_EPSILON
262
                        && m[1][0].abs() < IDENTITY_EPSILON
263
                        && (m[1][1] - 1.0).abs() < IDENTITY_EPSILON
264
                        && m[3][0].abs() < IDENTITY_EPSILON
265
                        && m[3][1].abs() < IDENTITY_EPSILON;
266
                    if !is_identity {
267
                        let b = *bounds.inner();
268
                        let pw = (b.size.width * dpi_factor).ceil().max(1.0) as u32;
269
                        let ph = (b.size.height * dpi_factor).ceil().max(1.0) as u32;
270
                        let new_id = self.alloc_layer_id();
271
                        let mut layer = Layer::new(new_id, b, pw, ph);
272
                        layer.transform = TransAffine::new_custom(
273
                            f64::from(m[0][0]),
274
                            f64::from(m[0][1]),
275
                            f64::from(m[1][0]),
276
                            f64::from(m[1][1]),
277
                            f64::from(m[3][0]),
278
                            f64::from(m[3][1]),
279
                        );
280
                        let end =
281
                            find_matching_pop(&display_list.items, i, MatchKind::ReferenceFrame);
282
                        layer.display_list_range = (i + 1, end);
283
                        self.layers.insert(new_id, layer);
284
                        let parent_id = *layer_stack.last().unwrap();
285
                        if let Some(parent) = self.layers.get_mut(&parent_id) {
286
                            parent.children.push(new_id);
287
                        }
288
                        layer_stack.push(new_id);
289
                    }
290
                }
291
                DisplayListItem::PopReferenceFrame => {
292
                    if layer_stack.len() > 1 {
293
                        let top_id = *layer_stack.last().unwrap();
294
                        if let Some(layer) = self.layers.get(&top_id) {
295
                            if !layer.transform.is_identity(IDENTITY_EPSILON_F64) {
296
                                layer_stack.pop();
297
                            }
298
                        }
299
                    }
300
                }
301
                // `backdrop-filter` (superplan g4): allocate a layer mirroring
302
                // PushFilter, but tagged `is_backdrop_filter` so the compositor
303
                // applies the filter to the *backdrop* (parent + earlier siblings
304
                // already in `output`) rather than to the layer's own content.
305
                // The compositing side reads back the `output` region under the
306
                // layer bounds and runs `apply_layer_filters` on it before
307
                // blitting the content (see `composite_layer_recursive`).
308
1
                DisplayListItem::PushBackdropFilter { bounds, filters } => {
309
1
                    let b = *bounds.inner();
310
1
                    let pw = (b.size.width * dpi_factor).ceil() as u32;
311
1
                    let ph = (b.size.height * dpi_factor).ceil() as u32;
312
1
                    if pw > 0 && ph > 0 && !filters.is_empty() {
313
1
                        let new_id = self.alloc_layer_id();
314
1
                        let mut layer = Layer::new(new_id, b, pw, ph);
315
1
                        layer.filters.clone_from(filters);
316
1
                        layer.is_backdrop_filter = true;
317
                        // The layer's OWN content may be empty (e.g. an empty
318
                        // div with only `backdrop-filter`). render_layers skips
319
                        // empty display-list ranges, leaving the Layer::new
320
                        // opaque-white pixbuf, which would then be blitted over
321
                        // (and wipe) the filtered backdrop. Start transparent so
322
                        // an empty backdrop-filter element shows the backdrop.
323
1
                        layer.pixbuf.fill(0, 0, 0, 0);
324
1
                        let end =
325
1
                            find_matching_pop(&display_list.items, i, MatchKind::BackdropFilter);
326
1
                        layer.display_list_range = (i + 1, end);
327
1
                        self.layers.insert(new_id, layer);
328
1
                        let parent_id = *layer_stack.last().unwrap();
329
1
                        if let Some(parent) = self.layers.get_mut(&parent_id) {
330
1
                            parent.children.push(new_id);
331
1
                        }
332
1
                        layer_stack.push(new_id);
333
                    }
334
                }
335
                DisplayListItem::PopBackdropFilter => {
336
1
                    if layer_stack.len() > 1 {
337
1
                        let top_id = *layer_stack.last().unwrap();
338
1
                        if let Some(layer) = self.layers.get(&top_id) {
339
1
                            if layer.is_backdrop_filter {
340
1
                                layer_stack.pop();
341
1
                            }
342
                        }
343
                    }
344
                }
345
                // `text-shadow` (Push/PopTextShadow) is a text-rasterization
346
                // concern, not a layer boundary, so it is handled in
347
                // `render_single_item`, not here.
348
3923
                _ => {}
349
            }
350
3925
            i += 1;
351
        }
352
107
    }
353

            
354
    /// Compute damage rects from dirty node sets and old/new positions.
355
    pub fn compute_damage(
356
        &mut self,
357
        dirty_nodes: &std::collections::BTreeSet<usize>,
358
        old_positions: &[LogicalPosition],
359
        new_positions: &[LogicalPosition],
360
        calculated_rects: &[LogicalRect],
361
    ) {
362
        if dirty_nodes.is_empty() {
363
            return;
364
        }
365

            
366
        let mut damage_rects = Vec::new();
367
        for &node_idx in dirty_nodes {
368
            // Old bounds
369
            if node_idx < old_positions.len() && node_idx < calculated_rects.len() {
370
                let old_rect = LogicalRect {
371
                    origin: old_positions[node_idx],
372
                    size: calculated_rects[node_idx].size,
373
                };
374
                damage_rects.push(old_rect);
375
            }
376
            // New bounds
377
            if node_idx < new_positions.len() && node_idx < calculated_rects.len() {
378
                let new_rect = LogicalRect {
379
                    origin: new_positions[node_idx],
380
                    size: calculated_rects[node_idx].size,
381
                };
382
                damage_rects.push(new_rect);
383
            }
384
        }
385

            
386
        // Distribute damage rects to affected layers
387
        for layer in self.layers.values_mut() {
388
            for damage in &damage_rects {
389
                if let Some(intersection) = rect_intersection(&layer.bounds, damage) {
390
                    layer.damage.push(intersection);
391
                    layer.composite_dirty = true;
392
                }
393
            }
394
        }
395
    }
396

            
397
    /// Render display list items into their respective layer pixbufs.
398
    /// # Panics
399
    ///
400
    /// Panics if a referenced layer id is not present in the layer map.
401
    /// # Errors
402
    ///
403
    /// Returns an error string if the layers cannot be composited.
404
107
    pub fn render_layers(
405
107
        &mut self,
406
107
        display_list: &DisplayList,
407
107
        dpi_factor: f32,
408
107
        renderer_resources: &RendererResources,
409
107
        font_manager: Option<&FontManager<FontRef>>,
410
107
        glyph_cache: &mut GlyphCache,
411
107
        render_state: &CpuRenderState,
412
107
    ) -> Result<(), String> {
413
107
        let scroll_offsets = &render_state.scroll_offsets;
414
        // Collect layer IDs, ranges, bounds, scroll_id and child ranges.
415
107
        let layer_ranges: Vec<(
416
107
            LayerId,
417
107
            (usize, usize),
418
107
            LogicalRect,
419
107
            Option<LocalScrollId>,
420
107
            Vec<(usize, usize)>,
421
107
        )> = self
422
107
            .layers
423
107
            .iter()
424
108
            .map(|(id, layer)| {
425
                // Ranges of this layer's DIRECT children (nested scroll frames /
426
                // opacity / transform groups). They render into their own
427
                // pixbufs, so they must be skipped when rendering this layer's
428
                // range (which, for the root, spans the whole display list).
429
108
                let child_ranges: Vec<(usize, usize)> = layer
430
108
                    .children
431
108
                    .iter()
432
108
                    .filter_map(|cid| self.layers.get(cid).map(|c| c.display_list_range))
433
108
                    .collect();
434
108
                (*id, layer.display_list_range, layer.bounds, layer.scroll_id, child_ranges)
435
108
            })
436
107
            .collect();
437

            
438
        #[cfg(feature = "std")]
439
107
        if std::env::var("AZ_MAP_DEBUG").is_ok() {
440
            for (id, range, bounds, scroll_id, child_ranges) in &layer_ranges {
441
                std::eprintln!(
442
                    "[cpu-layer] render id={:?} range={:?} bounds={:?} scroll={:?} skip={:?} (dl_len={})",
443
                    id, range, bounds, scroll_id, child_ranges, display_list.items.len()
444
                );
445
            }
446
107
        }
447

            
448
215
        for (layer_id, range, layer_bounds, scroll_id, child_ranges) in &layer_ranges {
449
108
            let (start, end) = *range;
450
108
            if start >= end || start >= display_list.items.len() {
451
1
                continue;
452
107
            }
453

            
454
            // This layer's scroll offset (0 for non-scroll layers). Content inside
455
            // a scroll frame is at absolute coords; the renderer draws at
456
            // `pos - seed`, so folding the scroll offset into the seed shifts the
457
            // frame's content within its own pixbuf. composite_frame blits the
458
            // pixbuf back at `layer.bounds.origin` (NOT scroll_offset), so applying
459
            // it here is the single place — no double offset. (Without this, a full
460
            // repaint while scrolled drew content at offset 0.)
461
107
            let soff = scroll_id
462
107
                .and_then(|id| scroll_offsets.get(&id).copied())
463
107
                .unwrap_or((0.0, 0.0));
464

            
465
107
            let layer = self.layers.get_mut(layer_id).unwrap();
466
107
            layer.scroll_offset = soff;
467

            
468
            // Clear the layer pixbuf (transparent for non-root, white for root)
469
107
            if *layer_id == self.root_layer {
470
107
                layer.pixbuf.fill(255, 255, 255, 255);
471
107
            } else {
472
                layer.pixbuf.fill(0, 0, 0, 0);
473
            }
474

            
475
            // Seed = layer origin (for pixbuf-local placement) + scroll offset.
476
107
            let offset_x = layer_bounds.origin.x + soff.0;
477
107
            let offset_y = layer_bounds.origin.y + soff.1;
478
107
            render_display_list_range(
479
107
                display_list,
480
107
                &mut layer.pixbuf,
481
107
                start,
482
107
                end.min(display_list.items.len()),
483
107
                child_ranges,
484
107
                offset_x,
485
107
                offset_y,
486
107
                dpi_factor,
487
107
                renderer_resources,
488
107
                font_manager,
489
107
                glyph_cache,
490
107
                render_state,
491
            )?;
492
        }
493

            
494
107
        Ok(())
495
107
    }
496

            
497
    /// Composite all layers bottom-up into the final output pixmap.
498
107
    pub fn composite_frame(&self, output: &mut AzulPixmap, dpi_factor: f32) {
499
        // Start from root layer
500
107
        self.composite_layer_recursive(self.root_layer, output, 0.0, 0.0, dpi_factor);
501
107
    }
502

            
503
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
504
108
    fn composite_layer_recursive(
505
108
        &self,
506
108
        layer_id: LayerId,
507
108
        output: &mut AzulPixmap,
508
108
        parent_offset_x: f32,
509
108
        parent_offset_y: f32,
510
108
        dpi_factor: f32,
511
108
    ) {
512
108
        let Some(layer) = self.layers.get(&layer_id) else {
513
            return;
514
        };
515

            
516
108
        let abs_x = parent_offset_x + layer.bounds.origin.x;
517
108
        let abs_y = parent_offset_y + layer.bounds.origin.y;
518

            
519
        // For root layer, just blit directly
520
108
        if layer_id == self.root_layer {
521
107
            blit_pixmap(&layer.pixbuf, output, 0, 0, 1.0);
522
107
        } else {
523
1
            let px_x = (abs_x * dpi_factor) as i32;
524
1
            let px_y = (abs_y * dpi_factor) as i32;
525

            
526
1
            if layer.is_backdrop_filter && !layer.filters.is_empty() {
527
1
                // `backdrop-filter`: the backdrop (parent + earlier siblings) is
528
1
                // ALREADY composited into `output` at this point (bottom-up
529
1
                // order). Snapshot the region under the layer's bounds, run the
530
1
                // filter on that copy, write it back, THEN blit the layer's own
531
1
                // (unfiltered) content on top.
532
1
                let w = layer.pixbuf.width;
533
1
                let h = layer.pixbuf.height;
534
1
                let snap = snapshot_region(output, px_x, px_y, w, h);
535
1
                let mut backdrop = AzulPixmap {
536
1
                    data: snap,
537
1
                    width: w,
538
1
                    height: h,
539
1
                };
540
1
                apply_layer_filters(&mut backdrop, &layer.filters, dpi_factor);
541
1
                write_region(output, &backdrop.data, w, h, px_x, px_y);
542
1
                blit_pixmap(&layer.pixbuf, output, px_x, px_y, layer.opacity);
543
1
            } else {
544
                // Apply filters at composite time (to the layer's own content).
545
                let src = if layer.filters.is_empty() {
546
                    None
547
                } else {
548
                    let mut filtered = layer.pixbuf.clone_pixmap();
549
                    apply_layer_filters(&mut filtered, &layer.filters, dpi_factor);
550
                    Some(filtered)
551
                };
552

            
553
                let src_pixbuf = src.as_ref().unwrap_or(&layer.pixbuf);
554
                blit_pixmap(src_pixbuf, output, px_x, px_y, layer.opacity);
555
            }
556
        }
557

            
558
        // Composite children in z-order
559
108
        let children: Vec<LayerId> = layer.children.clone();
560
109
        for child_id in &children {
561
1
            self.composite_layer_recursive(
562
1
                *child_id,
563
1
                output,
564
1
                if layer_id == self.root_layer {
565
1
                    0.0
566
                } else {
567
                    abs_x
568
                },
569
1
                if layer_id == self.root_layer {
570
1
                    0.0
571
                } else {
572
                    abs_y
573
                },
574
1
                dpi_factor,
575
            );
576
        }
577
108
    }
578

            
579
    /// Handle scroll by shifting pixels and re-rendering the exposed strip.
580
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
581
    #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
582
    /// # Panics
583
    ///
584
    /// Panics if `layer_id` is not present in the layer map.
585
    /// # Errors
586
    ///
587
    /// Returns an error string if the layer cannot be scrolled.
588
    pub fn scroll_layer(
589
        &mut self,
590
        scroll_id: LocalScrollId,
591
        new_offset: (f32, f32),
592
        display_list: &DisplayList,
593
        dpi_factor: f32,
594
        renderer_resources: &RendererResources,
595
        font_manager: Option<&FontManager<FontRef>>,
596
        glyph_cache: &mut GlyphCache,
597
    ) -> Result<(), String> {
598
        // Find the layer with this scroll_id
599
        let layer_id = self
600
            .layers
601
            .iter()
602
            .find(|(_, l)| l.scroll_id == Some(scroll_id))
603
            .map(|(id, _)| *id);
604

            
605
        let Some(layer_id) = layer_id else {
606
            return Ok(()); // No layer for this scroll ID
607
        };
608

            
609
        let layer = self.layers.get_mut(&layer_id).unwrap();
610
        let old_offset = layer.scroll_offset;
611
        let dx = new_offset.0 - old_offset.0;
612
        let dy = new_offset.1 - old_offset.1;
613

            
614
        if dx.abs() < 0.5 && dy.abs() < 0.5 {
615
            return Ok(());
616
        }
617

            
618
        // Shift pixels
619
        let px_dx = (dx * dpi_factor).round() as i32;
620
        let px_dy = (dy * dpi_factor).round() as i32;
621
        shift_pixbuf(&mut layer.pixbuf, px_dx, px_dy);
622

            
623
        // Compute exposed strips and re-render them.
624
        // Diagonal scroll produces 2 rects (one vertical strip + one horizontal strip).
625
        let exposed = compute_exposed_rects(&layer.bounds, dx, dy);
626
        for exposed_rect in exposed {
627
            layer.damage.push(exposed_rect);
628
        }
629

            
630
        layer.scroll_offset = new_offset;
631
        layer.composite_dirty = true;
632

            
633
        // Re-render damaged regions
634
        let range = layer.display_list_range;
635
        let bounds = layer.bounds;
636
        let offset_x = bounds.origin.x;
637
        let offset_y = bounds.origin.y;
638
        // Child-layer ranges to skip (rendered separately) — same as render_layers.
639
        let child_ranges: Vec<(usize, usize)> = self
640
            .layers
641
            .get(&layer_id)
642
            .map(|l| {
643
                l.children
644
                    .iter()
645
                    .filter_map(|cid| self.layers.get(cid).map(|c| c.display_list_range))
646
                    .collect()
647
            })
648
            .unwrap_or_default();
649
        // Scroll fast-path: VirtualView content (separate child DOMs) isn't
650
        // re-composited here — an empty state suffices (VirtualViews inside a
651
        // scrolling region are an edge case; the next full repaint composites them).
652
        let empty_rs = CpuRenderState::new(ScrollOffsetMap::new());
653
        render_display_list_range(
654
            display_list,
655
            &mut self.layers.get_mut(&layer_id).unwrap().pixbuf,
656
            range.0,
657
            range.1.min(display_list.items.len()),
658
            &child_ranges,
659
            offset_x,
660
            offset_y,
661
            dpi_factor,
662
            renderer_resources,
663
            font_manager,
664
            glyph_cache,
665
            &empty_rs,
666
        )?;
667

            
668
        Ok(())
669
    }
670
}
671

            
672
impl Layer {
673
108
    fn new(id: LayerId, bounds: LogicalRect, pixel_width: u32, pixel_height: u32) -> Self {
674
        Self {
675
108
            id,
676
108
            pixbuf: AzulPixmap::new(pixel_width.max(1), pixel_height.max(1)).unwrap_or_else(|| {
677
                AzulPixmap {
678
                    data: vec![0; 4],
679
                    width: 1,
680
                    height: 1,
681
                }
682
            }),
683
108
            bounds,
684
108
            damage: Vec::new(),
685
108
            children: Vec::new(),
686
108
            scroll_offset: (0.0, 0.0),
687
            opacity: 1.0,
688
108
            filters: Vec::new(),
689
            is_backdrop_filter: false,
690
108
            transform: TransAffine::new(),
691
108
            display_list_range: (0, 0),
692
108
            scroll_id: None,
693
            composite_dirty: true,
694
        }
695
108
    }
696
}
697

            
698
// ============================================================================
699
// Layer helper types and functions
700
// ============================================================================
701

            
702
/// Which Push/Pop pair to match.
703
#[derive(Clone, Copy)]
704
enum MatchKind {
705
    ScrollFrame,
706
    Opacity,
707
    Filter,
708
    BackdropFilter,
709
    ReferenceFrame,
710
}
711

            
712
/// Find the matching Pop for a given Push at index `start`.
713
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
714
6
fn find_matching_pop(items: &[DisplayListItem], start: usize, kind: MatchKind) -> usize {
715
6
    let mut depth = 1u32;
716
11
    for (i, item) in items.iter().enumerate().skip(start + 1) {
717
11
        match (item, kind) {
718
            (DisplayListItem::PushScrollFrame { .. }, MatchKind::ScrollFrame) => depth += 1,
719
            (DisplayListItem::PopScrollFrame, MatchKind::ScrollFrame) => {
720
5
                depth -= 1;
721
5
                if depth == 0 {
722
5
                    return i;
723
                }
724
            }
725
            (DisplayListItem::PushOpacity { .. }, MatchKind::Opacity) => depth += 1,
726
            (DisplayListItem::PopOpacity, MatchKind::Opacity) => {
727
                depth -= 1;
728
                if depth == 0 {
729
                    return i;
730
                }
731
            }
732
            (DisplayListItem::PushFilter { .. }, MatchKind::Filter) => depth += 1,
733
            (DisplayListItem::PopFilter, MatchKind::Filter) => {
734
                depth -= 1;
735
                if depth == 0 {
736
                    return i;
737
                }
738
            }
739
            (DisplayListItem::PushBackdropFilter { .. }, MatchKind::BackdropFilter) => depth += 1,
740
            (DisplayListItem::PopBackdropFilter, MatchKind::BackdropFilter) => {
741
1
                depth -= 1;
742
1
                if depth == 0 {
743
1
                    return i;
744
                }
745
            }
746
            (DisplayListItem::PushReferenceFrame { .. }, MatchKind::ReferenceFrame) => depth += 1,
747
            (DisplayListItem::PopReferenceFrame, MatchKind::ReferenceFrame) => {
748
                depth -= 1;
749
                if depth == 0 {
750
                    return i;
751
                }
752
            }
753
5
            _ => {}
754
        }
755
    }
756
    items.len()
757
6
}
758

            
759
/// Compute exposed rectangles after a scroll of (dx, dy) in logical coords.
760
/// Returns 0, 1, or 2 rects: a vertical strip (top/bottom) and/or a horizontal
761
/// strip (left/right). Diagonal scrolling produces both strips.
762
fn compute_exposed_rects(bounds: &LogicalRect, dx: f32, dy: f32) -> Vec<LogicalRect> {
763
    let w = bounds.size.width;
764
    let h = bounds.size.height;
765
    let mut rects = Vec::new();
766

            
767
    // Vertical exposed strip (full width, covers top or bottom edge)
768
    if dy.abs() > 0.5 {
769
        let strip = if dy > 0.0 {
770
            // Scrolled down — top strip exposed
771
            LogicalRect {
772
                origin: LogicalPosition {
773
                    x: bounds.origin.x,
774
                    y: bounds.origin.y,
775
                },
776
                size: LogicalSize {
777
                    width: w,
778
                    height: dy.min(h),
779
                },
780
            }
781
        } else {
782
            // Scrolled up — bottom strip exposed
783
            LogicalRect {
784
                origin: LogicalPosition {
785
                    x: bounds.origin.x,
786
                    y: bounds.origin.y + h + dy,
787
                },
788
                size: LogicalSize {
789
                    width: w,
790
                    height: (-dy).min(h),
791
                },
792
            }
793
        };
794
        rects.push(strip);
795
    }
796

            
797
    // Horizontal exposed strip (full height, covers left or right edge)
798
    if dx.abs() > 0.5 {
799
        let strip = if dx > 0.0 {
800
            LogicalRect {
801
                origin: LogicalPosition {
802
                    x: bounds.origin.x,
803
                    y: bounds.origin.y,
804
                },
805
                size: LogicalSize {
806
                    width: dx.min(w),
807
                    height: h,
808
                },
809
            }
810
        } else {
811
            LogicalRect {
812
                origin: LogicalPosition {
813
                    x: bounds.origin.x + w + dx,
814
                    y: bounds.origin.y,
815
                },
816
                size: LogicalSize {
817
                    width: (-dx).min(w),
818
                    height: h,
819
                },
820
            }
821
        };
822
        rects.push(strip);
823
    }
824

            
825
    rects
826
}
827

            
828
/// Scroll a frame's clip region by *moving the pixels already on screen* and
829
/// return the newly-exposed strip(s) (logical coords) that still need painting.
830
///
831
/// This is the thin-strip optimisation for scrolling: instead of repainting the
832
/// whole `clip_bounds` viewport every frame, we `memmove` the pixels that are
833
/// still visible and only re-rasterise the strip that scrolled into view. For a
834
/// 30px scroll of a 200×100 viewport that turns ~20k painted px into ~6k.
835
///
836
/// Sign convention is the renderer's, NOT the legacy `compute_exposed_rects`:
837
/// `render_single_item`/`scroll_rect` draw a content item at `position - offset`,
838
/// so a *positive* `delta` (the user scrolled further down/right) moves on-screen
839
/// content UP/LEFT. We therefore move the existing pixels UP/LEFT and expose a
840
/// strip at the trailing (bottom/right) edge. `compute_exposed_rects` assumed the
841
/// inverse and never matched the renderer — it and `scroll_layer` are dead code.
842
///
843
/// Only pixels strictly inside the (clamped) clip rectangle are moved, so the
844
/// scrollbar, the parent background and sibling content outside the frame are
845
/// left untouched. Diagonal scroll (both axes in one frame — mobile pan) is
846
/// handled as TWO strips: the vertical move + horizontal move are separable 1-D
847
/// passes, so the net effect is a 2-D translation and the exposed region is an
848
/// L-shape (a full-width top/bottom strip + a full-height left/right strip).
849
/// The two strips overlap in one corner; that corner is simply repainted twice,
850
/// which is correct (the caller clears then renders each item once).
851
///
852
/// Returns an empty vec when nothing moved, or `[clip_bounds]` when the shift is
853
/// large enough that the whole viewport is exposed (caller repaints in full).
854
///
855
/// NOTE: the move copies *composited* pixels, so a scroll frame whose content is
856
/// not opaque over its clip can drag whatever showed through. Real scroll
857
/// containers paint an opaque background or fully cover their box, so this is a
858
/// known, documented limitation rather than a correctness bug for the common case.
859
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
860
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
861
5
pub fn scroll_shift_region(
862
5
    pixmap: &mut AzulPixmap,
863
5
    clip_bounds: &LogicalRect,
864
5
    delta: (f32, f32),
865
5
    new_offset: (f32, f32),
866
5
    dpi_factor: f32,
867
5
) -> Vec<LogicalRect> {
868
    // Physical shift = difference of the ROUNDED offsets, not the rounded
869
    // difference. Rounding each frame's delta independently accumulates up to
870
    // 0.5px of error per step at fractional dpi (the moved block drifts away
871
    // from the freshly-rasterised strips, showing internal seams); anchoring
872
    // both ends to the absolute offset keeps the cumulative error ≤ 1px
873
    // forever: round(new·dpi) − round(prev·dpi) telescopes across frames.
874
5
    let prev_offset = (new_offset.0 - delta.0, new_offset.1 - delta.1);
875
5
    let px_dx =
876
5
        (new_offset.0 * dpi_factor).round() as i32 - (prev_offset.0 * dpi_factor).round() as i32;
877
5
    let px_dy =
878
5
        (new_offset.1 * dpi_factor).round() as i32 - (prev_offset.1 * dpi_factor).round() as i32;
879

            
880
    // Nothing actually moved (sub-pixel jitter rounds to zero).
881
5
    if px_dx == 0 && px_dy == 0 {
882
1
        return Vec::new();
883
4
    }
884

            
885
4
    let pw = pixmap.width() as i32;
886
4
    let ph = pixmap.height() as i32;
887

            
888
    // Clip rectangle in physical pixels, clamped to the pixmap.
889
4
    let cx0 = ((clip_bounds.origin.x * dpi_factor).floor() as i32).clamp(0, pw);
890
4
    let cy0 = ((clip_bounds.origin.y * dpi_factor).floor() as i32).clamp(0, ph);
891
4
    let cx1 = (((clip_bounds.origin.x + clip_bounds.size.width) * dpi_factor).ceil() as i32)
892
4
        .clamp(0, pw);
893
4
    let cy1 = (((clip_bounds.origin.y + clip_bounds.size.height) * dpi_factor).ceil() as i32)
894
4
        .clamp(0, ph);
895
4
    let region_w = cx1 - cx0;
896
4
    let region_h = cy1 - cy0;
897
4
    if region_w <= 0 || region_h <= 0 {
898
        return Vec::new();
899
4
    }
900

            
901
    // Shift exceeds the region — every pixel is exposed, so skip the memmove and
902
    // let the caller repaint the whole clip.
903
4
    if px_dx.abs() >= region_w || px_dy.abs() >= region_h {
904
1
        return vec![*clip_bounds];
905
3
    }
906

            
907
    // Dispatch to a specialised mover. The common single-axis cases get a tight
908
    // 1-D pass; diagonal pan gets a SINGLE-pass 2-D move (each row copied once
909
    // from its diagonally-offset source) instead of two sequential full passes —
910
    // half the memory traffic. (no-op is already handled by the early return.)
911
3
    let stride_px = pw;
912
3
    let data = pixmap.data_mut();
913
3
    match (px_dx != 0, px_dy != 0) {
914
2
        (false, true) => shift_vertical_1d(data, stride_px, cx0, cy0, cx1, cy1, px_dy),
915
        (true, false) => shift_horizontal_1d(data, stride_px, cx0, cy0, cx1, cy1, px_dx),
916
1
        (true, true) => shift_diagonal_2d(data, stride_px, cx0, cy0, cx1, cy1, px_dx, px_dy),
917
        (false, false) => {}
918
    }
919

            
920
    // Exposed strip(s) in LOGICAL coords. Over-cover the moving edge by one
921
    // physical pixel so dpi rounding never leaves a 1px white seam between the
922
    // moved block and the freshly-painted strip. One strip per moved axis, so
923
    // diagonal pan yields two (an L-shape, overlapping in one corner).
924
    //
925
    // Strips are derived from the CLAMPED region (`cx0..cx1`/`cy0..cy1`, the
926
    // pixels the memmove actually touched), not the raw `clip_bounds`. When
927
    // the clip extends past the pixmap (container taller than the window),
928
    // the raw clip's trailing edge is off-screen — a strip placed there
929
    // clamps to nothing, nothing repaints, and the rows at the WINDOW edge
930
    // keep their pre-shift content: a stale duplicated band that gets
931
    // re-dragged on every subsequent scroll.
932
3
    let cbx = cx0 as f32 / dpi_factor;
933
3
    let cby = cy0 as f32 / dpi_factor;
934
3
    let cbw = (cx1 - cx0) as f32 / dpi_factor;
935
3
    let cbh = (cy1 - cy0) as f32 / dpi_factor;
936
3
    let mut exposed = Vec::new();
937
3
    if px_dy != 0 {
938
3
        let h_logical = (px_dy.abs() as f32 + 1.0) / dpi_factor;
939
3
        let h = h_logical.min(cbh);
940
3
        let y = if px_dy > 0 {
941
            // bottom strip exposed
942
3
            cby + cbh - h
943
        } else {
944
            // top strip exposed
945
            cby
946
        };
947
3
        exposed.push(LogicalRect {
948
3
            origin: LogicalPosition { x: cbx, y },
949
3
            size: LogicalSize { width: cbw, height: h },
950
3
        });
951
    }
952
3
    if px_dx != 0 {
953
1
        let w_logical = (px_dx.abs() as f32 + 1.0) / dpi_factor;
954
1
        let w = w_logical.min(cbw);
955
1
        let x = if px_dx > 0 {
956
            // right strip exposed
957
1
            cbx + cbw - w
958
        } else {
959
            // left strip exposed
960
            cbx
961
        };
962
1
        exposed.push(LogicalRect {
963
1
            origin: LogicalPosition { x, y: cby },
964
1
            size: LogicalSize { width: w, height: cbh },
965
1
        });
966
2
    }
967
3
    exposed
968
5
}
969

            
970
// --- scroll_shift_region movers -------------------------------------------
971
// All three operate in PHYSICAL pixels on the raw RGBA buffer. `cx0..cx1` /
972
// `cy0..cy1` is the clamped clip region; `stride_px` is the buffer width in
973
// pixels. They only ever touch bytes inside the clip rectangle. Sign of the
974
// `px_*` deltas follows the renderer: positive = content moves up/left, so the
975
// exposed strip is the trailing (bottom/right) edge.
976

            
977
/// Single-axis VERTICAL move: shift whole rows up (`px_dy>0`) or down (`px_dy`<0).
978
/// Iteration order is chosen so a row read as a source is never already
979
/// overwritten (src and dst row SETS overlap, so order matters).
980
#[inline]
981
#[allow(clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
982
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
983
2
fn shift_vertical_1d(
984
2
    data: &mut [u8],
985
2
    stride_px: i32,
986
2
    cx0: i32,
987
2
    cy0: i32,
988
2
    cx1: i32,
989
2
    cy1: i32,
990
2
    px_dy: i32,
991
2
) {
992
2
    let col_bytes = ((cx1 - cx0) * 4) as usize;
993
240
    let row_off = |row: i32| ((row * stride_px + cx0) as usize) * 4;
994
2
    if px_dy > 0 {
995
        // Content up: dst = src - px_dy (dst < src) → iterate top→bottom.
996
120
        for dst in cy0..(cy1 - px_dy) {
997
120
            let s = row_off(dst + px_dy);
998
120
            data.copy_within(s..s + col_bytes, row_off(dst));
999
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]
#[allow(clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
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]
#[allow(clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
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 and `prev_offset` the offset
/// the pixels being moved were rendered at; both are used to project the
/// content's opaque fills (stored at content coords) into viewport space for
/// the coverage test — coverage must hold at BOTH offsets, since the memmove
/// drags pixels that were composited at the OLD offset. 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).
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
5
#[must_use] pub fn scroll_fast_path_eligible(
5
    display_list: &DisplayList,
5
    scroll_id: LocalScrollId,
5
    clip_bounds: &LogicalRect,
5
    scroll_offset: (f32, f32),
5
    prev_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 Some(start) = start else {
        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());
    // NESTED frame → ineligible. An inner frame's clip_bounds are the OUTER
    // frame's content coords: with the outer frame scrolled, the memmove
    // would shift a region displaced from the real on-screen clip by the
    // outer offset. Conservative full-clip repaint instead.
5
    let mut depth = 0i32;
5
    for it in &display_list.items[..start] {
5
        match it {
            DisplayListItem::PushScrollFrame { .. } => depth += 1,
            DisplayListItem::PopScrollFrame => depth -= 1,
5
            _ => {}
        }
    }
5
    if depth > 0 {
        return false;
5
    }
    // NOTE on overlays: anything painted AFTER the frame that overlaps the
    // clip (the frame's own scrollbar, an open dropdown, a tooltip) gets
    // dragged by the memmove. That does NOT make the frame ineligible — the
    // caller repaints those regions after the shift via
    // [`overlay_rects_after_frame`] (a scrollbar would otherwise disable the
    // fast path for every scroll container).
    // (a) Best case: the SCROLLING content opaquely covers the clip (projected
    // into viewport space by the scroll offset — at BOTH the old offset, where
    // the dragged pixels were rendered, and the new one). Then nothing behind
    // can ever show through, so the shift is always safe.
6
    let covered_at = |off: (f32, f32)| {
6
        let fills: Vec<LogicalRect> = display_list.items[start + 1..end]
6
            .iter()
6
            .filter_map(opaque_fill_rect)
6
            .map(|r| LogicalRect {
2
                origin: LogicalPosition {
2
                    x: r.origin.x - off.0,
2
                    y: r.origin.y - off.1,
2
                },
2
                size: r.size,
2
            })
6
            .collect();
6
        rect_covered_by(clip_bounds, &fills)
6
    };
5
    if covered_at(scroll_offset) && covered_at(prev_offset) {
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] {
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
}
/// Result of diffing the GPU-animated values between two frames.
#[derive(Debug, Default)]
pub struct GpuValueDamage {
    /// Regions to repaint (scrollbar bounds whose thumb/opacity value changed).
    pub rects: Vec<LogicalRect>,
    /// A changed transform is bound to a `PushReferenceFrame` (drag / CSS
    /// transform animation): the moved CONTENT's extent isn't derivable from
    /// the item alone, so the caller must full-repaint.
    pub needs_full: bool,
}
/// Diff the GPU value maps of two frames and damage the items BOUND to the
/// changed keys.
///
/// Scrollbar thumb position, scrollbar fade opacity, and drag/CSS transforms
/// live in the GPU value cache; display-list items only carry the KEYS, so
/// they compare `is_visually_equal` while the pixels must change. Without
/// this channel, the `ScrollBarStyled` equality arm would freeze the thumb
/// (missed damage); with it, an idle window reaches `FrameDamage::None` even
/// with scrollbars present.
#[allow(clippy::implicit_hasher)] // internal call sites all use std hasher
#[must_use] pub fn gpu_value_damage(
    display_list: &DisplayList,
    old_transforms: &HashMap<usize, azul_core::transform::ComputedTransform3D>,
    old_opacities: &HashMap<usize, f32>,
    new_transforms: &HashMap<usize, azul_core::transform::ComputedTransform3D>,
    new_opacities: &HashMap<usize, f32>,
) -> GpuValueDamage {
    use std::collections::HashSet;
    let mut changed_t: HashSet<usize> = HashSet::new();
    for (k, v) in new_transforms {
        if old_transforms.get(k) != Some(v) {
            changed_t.insert(*k);
        }
    }
    for k in old_transforms.keys() {
        if !new_transforms.contains_key(k) {
            changed_t.insert(*k);
        }
    }
    let mut changed_o: HashSet<usize> = HashSet::new();
    for (k, v) in new_opacities {
        if old_opacities.get(k) != Some(v) {
            changed_o.insert(*k);
        }
    }
    for k in old_opacities.keys() {
        if !new_opacities.contains_key(k) {
            changed_o.insert(*k);
        }
    }
    let mut out = GpuValueDamage::default();
    if changed_t.is_empty() && changed_o.is_empty() {
        return out;
    }
    for item in &display_list.items {
        match item {
            DisplayListItem::ScrollBarStyled { info } => {
                let thumb_moved = info
                    .thumb_transform_key
                    .is_some_and(|k| changed_t.contains(&k.id));
                let faded = info.opacity_key.is_some_and(|k| changed_o.contains(&k.id));
                if thumb_moved || faded {
                    // The whole bar bounds cover the thumb's old AND new
                    // position — precise and cheap.
                    out.rects.push(info.bounds.0);
                }
            }
            DisplayListItem::PushReferenceFrame { transform_key, .. } => {
                if changed_t.contains(&transform_key.id) {
                    out.needs_full = true;
                }
            }
            _ => {}
        }
    }
    // A changed key bound to nothing in THIS display list (another DOM's
    // scrollbar, a stale key) is ignored — it cannot affect these pixels.
    out
}
/// Clip-intersected bounds of every item painted AFTER scroll frame
/// `scroll_id`'s `PopScrollFrame` that STRICTLY overlaps `clip_bounds`.
///
/// Anything composited over the frame inside its clip (the frame's own
/// scrollbar, an open dropdown/context menu/tooltip, a sibling's box-shadow)
/// gets DRAGGED by the `scroll_shift_region` memmove. Rather than making such
/// frames ineligible for the fast path (a scrollbar would disable it for
/// every scroll container), the caller adds these rects to the damage set so
/// the dragged pixels are simply repainted after the shift.
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
#[must_use] pub fn overlay_rects_after_frame(
    display_list: &DisplayList,
    scroll_id: LocalScrollId,
    clip_bounds: &LogicalRect,
) -> Vec<LogicalRect> {
    let mut out = Vec::new();
    let Some(start) = display_list.items.iter().position(|it| {
        matches!(it, DisplayListItem::PushScrollFrame { scroll_id: sid, .. } if *sid == scroll_id)
    }) else {
        return out;
    };
    let end = find_matching_pop(&display_list.items, start, MatchKind::ScrollFrame)
        .min(display_list.items.len());
    let cx1 = clip_bounds.origin.x + clip_bounds.size.width;
    let cy1 = clip_bounds.origin.y + clip_bounds.size.height;
    for it in &display_list.items[end..] {
        if it.is_state_management() {
            continue;
        }
        let Some(b) = it.bounds() else { continue };
        // STRICT overlap: merely touching shares no pixels with the clip and
        // cannot be dragged.
        let ix = b.origin.x.max(clip_bounds.origin.x);
        let iy = b.origin.y.max(clip_bounds.origin.y);
        let ix1 = (b.origin.x + b.size.width).min(cx1);
        let iy1 = (b.origin.y + b.size.height).min(cy1);
        if ix1 > ix && iy1 > iy {
            out.push(LogicalRect {
                origin: LogicalPosition { x: ix, y: iy },
                size: LogicalSize {
                    width: ix1 - ix,
                    height: iy1 - iy,
                },
            });
        }
    }
    out
}
/// If `it` is a fully-opaque, square-cornered rectangle fill, its bounds.
6
fn opaque_fill_rect(it: &DisplayListItem) -> Option<LogicalRect> {
6
    match it {
        DisplayListItem::Rect {
6
            bounds,
6
            color,
6
            border_radius,
6
        } if color.a == 255 && border_radius.is_zero() => Some(*bounds.inner()),
4
        _ => None,
    }
6
}
/// 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.
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
12
fn rect_covered_by(target: &LogicalRect, covers: &[LogicalRect]) -> bool {
12
    if covers.is_empty() {
5
        return false;
7
    }
7
    let step = 4.0_f32;
7
    let x0 = target.origin.x;
7
    let y0 = target.origin.y;
7
    let x1 = x0 + target.size.width;
7
    let y1 = y0 + target.size.height;
7
    let mut y = y0 + step * 0.5;
    #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
152
    while y < y1 {
147
        let mut x = x0 + step * 0.5;
        #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
3772
        while x < x1 {
3953
            let inside = covers.iter().any(|r| {
3953
                x >= r.origin.x
3953
                    && x < r.origin.x + r.size.width
3953
                    && y >= r.origin.y
3952
                    && y < r.origin.y + r.size.height
3953
            });
3627
            if !inside {
2
                return false;
3625
            }
3625
            x += step;
        }
145
        y += step;
    }
5
    true
12
}
/// Apply CSS filters to a pixbuf at composite time.
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
1
fn apply_layer_filters(pixmap: &mut AzulPixmap, filters: &[StyleFilter], dpi_factor: f32) {
2
    for filter in filters {
1
        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 = f32::midpoint(rx, ry).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] = ((u32::from(chunk[3]) * 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 = f32::from(chunk[0]);
                    let g = f32::from(chunk[1]);
                    let b = f32::from(chunk[2]);
                    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] = (f32::from(chunk[0]) * factor).clamp(0.0, 255.0) as u8;
                    chunk[1] = (f32::from(chunk[1]) * factor).clamp(0.0, 255.0) as u8;
                    chunk[2] = (f32::from(chunk[2]) * 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] = ((((f32::from(chunk[0]) / 255.0) - 0.5) * factor + 0.5) * 255.0)
                        .clamp(0.0, 255.0) as u8;
                    chunk[1] = ((((f32::from(chunk[1]) / 255.0) - 0.5) * factor + 0.5) * 255.0)
                        .clamp(0.0, 255.0) as u8;
                    chunk[2] = ((((f32::from(chunk[2]) / 255.0) - 0.5) * factor + 0.5) * 255.0)
                        .clamp(0.0, 255.0) as u8;
                }
            }
1
            StyleFilter::Invert(pct) => {
1
                let amount = pct.normalized().clamp(0.0, 1.0);
5000
                for chunk in pixmap.data.chunks_exact_mut(4) {
5000
                    chunk[0] = (f32::from(chunk[0]) + (255.0 - 2.0 * f32::from(chunk[0])) * amount)
5000
                        .clamp(0.0, 255.0) as u8;
5000
                    chunk[1] = (f32::from(chunk[1]) + (255.0 - 2.0 * f32::from(chunk[1])) * amount)
5000
                        .clamp(0.0, 255.0) as u8;
5000
                    chunk[2] = (f32::from(chunk[2]) + (255.0 - 2.0 * f32::from(chunk[2])) * amount)
5000
                        .clamp(0.0, 255.0) as u8;
5000
                }
            }
            StyleFilter::Sepia(pct) => {
                let amount = pct.normalized().clamp(0.0, 1.0);
                for chunk in pixmap.data.chunks_exact_mut(4) {
                    let r = f32::from(chunk[0]);
                    let g = f32::from(chunk[1]);
                    let b = f32::from(chunk[2]);
                    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 = f32::from(chunk[0]);
                    let g = f32::from(chunk[1]);
                    let b = f32::from(chunk[2]);
                    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 = f32::from(chunk[0]);
                    let g = f32::from(chunk[1]);
                    let b = f32::from(chunk[2]);
                    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
        }
    }
1
}
/// Render a range of display list items into a layer pixbuf,
/// offsetting coordinates by the layer's origin.
107
fn render_display_list_range(
107
    display_list: &DisplayList,
107
    pixmap: &mut AzulPixmap,
107
    start: usize,
107
    end: usize,
107
    // Index ranges (start..end) that belong to CHILD layers (nested scroll
107
    // frames / opacity / transform groups). Those items render into the child's
107
    // OWN pixbuf, so they must be skipped here — otherwise they're drawn twice
107
    // (once in this layer at absolute coords AND once in the child layer),
107
    // which produced overlapping / ghosted text in overflow:scroll content.
107
    skip_ranges: &[(usize, usize)],
107
    offset_x: f32,
107
    offset_y: f32,
107
    dpi_factor: f32,
107
    renderer_resources: &RendererResources,
107
    font_manager: Option<&FontManager<FontRef>>,
107
    glyph_cache: &mut GlyphCache,
107
    render_state: &CpuRenderState,
107
) -> Result<(), String> {
107
    let mut transform_stack = vec![TransAffine::new()];
107
    let mut clip_stack: Vec<Option<AzRect>> = vec![None];
107
    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.
107
    let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(offset_x, offset_y)];
107
    let mut text_shadow_stack: Vec<azul_css::props::style::box_shadow::StyleBoxShadow> = Vec::new();
3925
    for i in start..end {
        // Skip items rendered by a child layer (see skip_ranges doc above).
3925
        if skip_ranges.iter().any(|(s, e)| i >= *s && i < *e) {
            continue;
3925
        }
3925
        let item = &display_list.items[i];
3925
        render_single_item(
3925
            item,
3925
            pixmap,
3925
            dpi_factor,
3925
            renderer_resources,
3925
            font_manager,
3925
            glyph_cache,
3925
            &mut transform_stack,
3925
            &mut clip_stack,
3925
            &mut mask_stack,
3925
            &mut scroll_offset_stack,
3925
            &mut text_shadow_stack,
3925
            render_state,
        )?;
    }
107
    Ok(())
107
}
// ============================================================================
// AzulPixmap — replacement for tiny_skia::Pixmap
// ============================================================================
/// 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.
///
/// The returned rects are in VIEWPORT space. Items inside a scroll frame are
/// stored at CONTENT coords but render at `pos - scroll_offset`, so a changed
/// item's bounds are projected through the accumulated scroll offset of its
/// enclosing frame(s) — the OLD item through `old_offsets` (where its pixels
/// were on screen), the NEW item through `new_offsets` (where they will be).
/// Without this projection, damage for items inside a scrolled frame lands at
/// the content-space position (off by exactly the scroll offset), so the
/// consumer repaints the wrong band and the changed item stays visually stale.
159
#[must_use] pub fn compute_display_list_damage(
159
    old: &DisplayList,
159
    new: &DisplayList,
159
    old_offsets: &ScrollOffsetMap,
159
    new_offsets: &ScrollOffsetMap,
159
) -> Option<Vec<LogicalRect>> {
    // Different item counts → structural change → full repaint
159
    if old.items.len() != new.items.len() {
159
        return None;
    }
    let mut damage = Vec::new();
    // Accumulated (old, new) scroll offsets of the enclosing frames. The two
    // lists are structurally identical (discriminants checked below), so one
    // stack driven by the new list tracks both.
    let mut offset_stack: Vec<((f32, f32), (f32, f32))> = vec![((0.0, 0.0), (0.0, 0.0))];
    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
        }
        match new_item {
            DisplayListItem::PushScrollFrame { scroll_id, .. } => {
                let (acc_old, acc_new) = *offset_stack.last().unwrap_or(&((0.0, 0.0), (0.0, 0.0)));
                let o = old_offsets.get(scroll_id).copied().unwrap_or((0.0, 0.0));
                let n = new_offsets.get(scroll_id).copied().unwrap_or((0.0, 0.0));
                offset_stack.push((
                    (acc_old.0 + o.0, acc_old.1 + o.1),
                    (acc_new.0 + n.0, acc_new.1 + n.1),
                ));
            }
            DisplayListItem::PopScrollFrame => {
                if offset_stack.len() > 1 {
                    offset_stack.pop();
                }
            }
            _ => {}
        }
        // 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 (acc_old, acc_new) = *offset_stack.last().unwrap_or(&((0.0, 0.0), (0.0, 0.0)));
            if let Some(ob) = old_item.visual_bounds() {
                damage.push(LogicalRect {
                    origin: LogicalPosition {
                        x: ob.origin.x - acc_old.0,
                        y: ob.origin.y - acc_old.1,
                    },
                    size: ob.size,
                });
            }
            if let Some(nb) = new_item.visual_bounds() {
                damage.push(LogicalRect {
                    origin: LogicalPosition {
                        x: nb.origin.x - acc_new.0,
                        y: nb.origin.y - acc_new.1,
                    },
                    size: nb.size,
                });
            }
        }
    }
    // Coalesce overlapping rects
    coalesce_damage_rects(&mut damage);
    Some(damage)
159
}
/// 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.
#[must_use] 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.
#[must_use] 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 {
        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.
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
pub 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;
        }
    }
}
163
#[must_use] pub fn rects_overlap_or_adjacent(a: &LogicalRect, b: &LogicalRect, gap: f32) -> bool {
163
    a.origin.x - gap <= b.origin.x + b.size.width
163
        && b.origin.x - gap <= a.origin.x + a.size.width
163
        && a.origin.y - gap <= b.origin.y + b.size.height
163
        && b.origin.y - gap <= a.origin.y + a.size.height
163
}
/// Compute damage rects for a grow-only window resize.
/// Returns the right strip and bottom strip that need rendering.
#[must_use] 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.
#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
#[allow(clippy::many_single_char_names)] // domain-standard coordinate/geometry/short-lived names
53
#[must_use] pub fn compare_region(
53
    a: &AzulPixmap,
53
    b: &AzulPixmap,
53
    x: u32,
53
    y: u32,
53
    w: u32,
53
    h: u32,
53
    threshold: u8,
53
) -> usize {
53
    let mut diff_count = 0;
10600
    for row in y..(y + h).min(a.height).min(b.height) {
2120000
        for col in x..(x + w).min(a.width).min(b.width) {
2120000
            let ai = (row * a.width + col) as usize * 4;
2120000
            let bi = (row * b.width + col) as usize * 4;
2120000
            if ai + 3 >= a.data.len() || bi + 3 >= b.data.len() {
                continue;
2120000
            }
2120000
            let dr = (i16::from(a.data[ai]) - i16::from(b.data[bi])).unsigned_abs() as u8;
2120000
            let dg = (i16::from(a.data[ai + 1]) - i16::from(b.data[bi + 1])).unsigned_abs() as u8;
2120000
            let db = (i16::from(a.data[ai + 2]) - i16::from(b.data[bi + 2])).unsigned_abs() as u8;
2120000
            if dr > threshold || dg > threshold || db > threshold {
                diff_count += 1;
2120000
            }
        }
    }
53
    diff_count
53
}
// ============================================================================
// 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.
    #[allow(clippy::many_single_char_names)] // domain-standard coordinate/geometry/short-lived names
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
    }
    #[allow(clippy::many_single_char_names)] // domain-standard coordinate/geometry/short-lived names
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), (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]
    #[allow(clippy::float_cmp)] // intentional exact compare: change-detection / identity fast-path / cache-key match
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), (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]
    #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
    #[allow(clippy::float_cmp)] // intentional exact compare: change-detection / identity fast-path / cache-key match
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), (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
        drop(scroll_shift_region(&mut p, &clip, (0.0, 10.0), (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 ({x},{y}) OUTSIDE the clip was modified — scroll leaked past its frame"
            );
        }
        // 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]
    #[allow(clippy::float_cmp)] // test asserts exact float equality on deterministic values
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), (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
    }
    #[allow(clippy::many_single_char_names)] // domain-standard coordinate/geometry/short-lived names
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), (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), (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), (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), (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), (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
    }
}
#[cfg(test)]
mod backdrop_filter_tests {
    use super::*;
    use azul_core::resources::RendererResources;
    use azul_css::props::basic::ColorU;
    use azul_css::props::style::filter::StyleFilter;
    use azul_css::props::basic::length::PercentageValue;
    use crate::solver3::display_list::DisplayList;
    use crate::cpurender::{CpuRenderState, ScrollOffsetMap};
2
    fn lrect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
2
        LogicalRect {
2
            origin: LogicalPosition::new(x, y),
2
            size: LogicalSize::new(w, h),
2
        }
2
    }
    // p/x/y/w/d/i are the conventional pixel-access short names
    #[allow(clippy::many_single_char_names)]
2
    fn px(p: &AzulPixmap, x: u32, y: u32) -> [u8; 4] {
2
        let w = p.width();
2
        let d = p.data();
2
        let i = ((y * w + x) * 4) as usize;
2
        [d[i], d[i + 1], d[i + 2], d[i + 3]]
2
    }
    /// A `backdrop-filter: invert(100%)` must invert the already-composited
    /// backdrop under the element, while leaving pixels outside the element box
    /// untouched.
    #[test]
1
    fn backdrop_filter_inverts_backdrop_region() {
1
        let w = 100u32;
1
        let h = 100u32;
        // Background: a solid blue rect over the whole canvas (root layer).
        // Then a backdrop-filter:invert region over the right half (no own
        // content), so its backdrop (blue) becomes inverted (yellow).
1
        let blue = ColorU { r: 0, g: 0, b: 255, a: 255 };
1
        let dl = DisplayList {
1
            items: vec![
1
                DisplayListItem::Rect {
1
                    bounds: lrect(0.0, 0.0, 100.0, 100.0).into(),
1
                    color: blue,
1
                    border_radius: BorderRadius::default(),
1
                },
1
                DisplayListItem::PushBackdropFilter {
1
                    bounds: lrect(50.0, 0.0, 50.0, 100.0).into(),
1
                    filters: vec![StyleFilter::Invert(PercentageValue::new(100.0))],
1
                },
1
                DisplayListItem::PopBackdropFilter,
1
            ],
1
            ..Default::default()
1
        };
1
        let mut comp = CompositorState::new(w, h);
1
        comp.allocate_layers_from_display_list(&dl, 1.0);
        // A backdrop-filter layer must have been allocated.
1
        assert!(
1
            comp.layers.values().any(|l| l.is_backdrop_filter),
            "no backdrop-filter layer allocated"
        );
1
        let rr = RendererResources::default();
1
        let mut gc = GlyphCache::new();
1
        let state = CpuRenderState::new(ScrollOffsetMap::new());
1
        comp.render_layers(&dl, 1.0, &rr, None, &mut gc, &state).unwrap();
1
        let mut out = AzulPixmap::new(w, h).unwrap();
1
        out.fill(0, 0, 0, 255);
1
        comp.composite_frame(&mut out, 1.0);
        // Left half: untouched blue backdrop.
1
        let left = px(&out, 10, 50);
1
        assert_eq!(left, [0, 0, 255, 255], "left half should stay blue");
        // Right half: blue inverted -> (255,255,0).
1
        let right = px(&out, 75, 50);
1
        assert!(
1
            right[0] > 200 && right[1] > 200 && right[2] < 60,
            "right half backdrop should be inverted to yellow, got {right:?}"
        );
1
    }
}