1
use std::collections::HashMap;
2

            
3
use agg_rust::{blur::stack_blur_rgba32, rendering_buffer::RowAccessor, trans_affine::TransAffine};
4
use azul_core::{
5
    geom::{LogicalPosition, LogicalRect, LogicalSize},
6
    resources::RendererResources,
7
};
8
use azul_css::props::{
9
    basic::{pixel::DEFAULT_FONT_SIZE, ColorU, FontRef},
10
    style::filter::StyleFilter,
11
};
12

            
13
#[allow(clippy::wildcard_imports)]
14
// widget/render module pulls in the css property/value types it builds with
15
use super::*;
16
use crate::glyph_cache::GlyphCache;
17

            
18
/// A row-major 3x3 matrix over column vectors `[x; y; 1]`:
19
/// `x' = (m0 x + m1 y + m2) / w`, `y' = (m3 x + m4 y + m5) / w`,
20
/// `w = m6 x + m7 y + m8`. The compositor's per-layer mapping, so a
21
/// perspective (`w` depending on x / y) composes through the layer tree like
22
/// any translation does.
23
pub type Mat3 = [f64; 9];
24

            
25
/// The identity mapping.
26
pub const MAT3_IDENTITY: Mat3 = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
27

            
28
/// `a * b` (apply `b` first, then `a`).
29
#[must_use]
30
222
pub fn mat3_mul(a: &Mat3, b: &Mat3) -> Mat3 {
31
222
    let mut out = [0.0; 9];
32
888
    for r in 0..3 {
33
2664
        for c in 0..3 {
34
1998
            out[r * 3 + c] = a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c];
35
1998
        }
36
    }
37
222
    out
38
222
}
39

            
40
/// A pure translation.
41
#[must_use]
42
146
pub const fn mat3_translation(tx: f64, ty: f64) -> Mat3 {
43
146
    [1.0, 0.0, tx, 0.0, 1.0, ty, 0.0, 0.0, 1.0]
44
146
}
45

            
46
/// Is the third row the trivial `[0 0 1]` (no perspective)?
47
#[must_use]
48
320
pub const fn mat3_is_affine(m: &Mat3) -> bool {
49
320
    m[6] > -1e-12
50
320
        && m[6] < 1e-12
51
320
        && m[7] > -1e-12
52
320
        && m[7] < 1e-12
53
320
        && m[8] > 1.0 - 1e-9
54
320
        && m[8] < 1.0 + 1e-9
55
320
}
56

            
57
/// The affine part as agg's `TransAffine` (exact when [`mat3_is_affine`]).
58
#[must_use]
59
320
pub fn mat3_affine_part(m: &Mat3) -> TransAffine {
60
    // agg: x' = x*sx + y*shx + tx; y' = x*shy + y*sy + ty
61
320
    TransAffine::new_custom(m[0], m[3], m[1], m[4], m[2], m[5])
62
320
}
63
use crate::{
64
    solver3::display_list::{BorderRadius, DisplayList, DisplayListItem, LocalScrollId},
65
    text3::cache::FontManager,
66
};
67

            
68
const IDENTITY_EPSILON: f32 = 0.0001;
69

            
70
// ============================================================================
71
// Retained-Mode Compositor — Layer Tree
72
// ============================================================================
73

            
74
/// Unique identifier for a compositing layer.
75
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76
pub struct LayerId(pub u64);
77

            
78
/// Persistent compositor state across frames.
79
///
80
/// Holds a tree of `Layer`s, each with its own pixbuf. On incremental updates
81
/// only damaged layers are re-rendered, and scroll is handled by pixel-shift.
82
#[derive(Debug)]
83
pub struct CompositorState {
84
    /// What the ROOT layer is cleared to every frame: opaque white for an
85
    /// ordinary window, transparent black for a window whose background
86
    /// material is `Transparent` (the OS composites whatever the content
87
    /// leaves at alpha 0 - the desktop shows through a popup's rounded
88
    /// corners). Set through [`Self::set_clear_color`] before rendering.
89
    pub clear_color: [u8; 4],
90
    /// All layers keyed by ID.
91
    pub layers: HashMap<LayerId, Layer>,
92
    /// Root layer of the tree.
93
    pub root_layer: LayerId,
94
    /// Monotonic counter for generating unique `LayerIds`.
95
    next_layer_id: u64,
96
    /// Previous frame's per-node positions, used for damage computation.
97
    pub previous_positions: Vec<LogicalPosition>,
98
}
99

            
100
/// A single compositing layer with its own pixel buffer.
101
#[derive(Debug)]
102
pub struct Layer {
103
    pub id: LayerId,
104
    /// Persistent RGBA buffer for this layer's content.
105
    pub pixbuf: AzulPixmap,
106
    /// Position and size in parent layer coordinates.
107
    pub bounds: LogicalRect,
108
    /// Dirty regions that need re-rendering this frame.
109
    pub damage: Vec<LogicalRect>,
110
    /// Child layers in z-order (bottom to top).
111
    pub children: Vec<LayerId>,
112
    /// Current scroll offset (for scroll-frame layers).
113
    pub scroll_offset: (f32, f32),
114
    /// Intersection of the `PushClip` rects open around this layer's `Push*`
115
    /// in the display list — the parent-content-space window this layer must
116
    /// stay inside at composite time (an `overflow: hidden` or border-radius
117
    /// clip WRAPPING a nested layer; radii are ignored for layer clipping).
118
    /// `None` when nothing wraps the layer. Item-level clips inside one
119
    /// layer's own range are the rasterizer's clip stack, not this.
120
    pub static_clip: Option<LogicalRect>,
121
    /// Layer opacity (1.0 = fully opaque).
122
    pub opacity: f32,
123
    /// CSS filters applied at composite time. For a normal filter layer these
124
    /// are applied to the layer's OWN content; for a `backdrop-filter` layer
125
    /// (see `is_backdrop_filter`) they are instead applied to the already-
126
    /// composited backdrop pixels under the layer's bounds.
127
    pub filters: Vec<StyleFilter>,
128
    /// If true, `filters` apply to the backdrop (parent + earlier siblings
129
    /// already in `output`), not to this layer's own content.
130
    pub is_backdrop_filter: bool,
131
    /// CSS transform for this layer (the affine part, layer-local logical
132
    /// units; the translation is scaled to device pixels at composite time).
133
    pub transform: TransAffine,
134
    /// The PERSPECTIVE row of the layer's transform over its z = 0 plane
135
    /// (`[m03, m13, m33]` of the 4x4, logical units): `w = p0 x + p1 y + p2`.
136
    /// `[0, 0, 1]` for every affine transform. A `perspective() rotateX()`
137
    /// tilt lives here — the CPU path used to drop it and composite the
138
    /// affine part only (a tilted map rendered as a squashed rectangle).
139
    pub perspective_row: [f32; 3],
140
    /// Range of display list items [start, end) that render into this layer.
141
    pub display_list_range: (usize, usize),
142
    /// If this layer is a scroll frame, the scroll ID.
143
    pub scroll_id: Option<LocalScrollId>,
144
    /// Whether this layer needs re-compositing onto its parent.
145
    pub composite_dirty: bool,
146
}
147

            
148
/// Widest or tallest a compositor layer may be, in device pixels.
149
///
150
/// Nothing a real layout produces comes close — this exists purely so that a
151
/// nonsense extent cannot become an allocation. Chosen to match the usual
152
/// maximum GPU texture dimension, so the CPU path refuses the same sizes the
153
/// GPU path could never hold either.
154
const MAX_LAYER_DIM: u32 = 16_384;
155

            
156
/// The device-pixel size of a layer's backing pixmap, or `(0, 0)` when the
157
/// layout handed us a size nothing could be drawn at.
158
///
159
/// # Why this is not just `as u32`
160
///
161
/// `f32 as u32` SATURATES in Rust. An infinite (or merely absurd) extent
162
/// silently becomes `u32::MAX`, and the caller then asks `AzulPixmap::new` for
163
/// `u32::MAX * height * 4` bytes — a ~900 GB `vec![255u8; _]` that macOS
164
/// overcommits, touches, and is then killed over. The failure looks nothing
165
/// like its cause: no panic, no allocation error, just a `SIGKILL` seconds
166
/// later with the memory sitting in swap.
167
///
168
/// # Why it is loud
169
///
170
/// A non-finite layer size is ALWAYS a layout bug — there is no legitimate way
171
/// to ask for an infinitely wide box. Clamping quietly would leave that bug in
172
/// place and merely stop it from killing the process, so this panics in debug
173
/// builds and warns once in release, naming the node so the layout that
174
/// produced it can be found. The clamp is the seatbelt, not the fix.
175
/// The DOM node a display-list item came from, for diagnostics only.
176
116
fn node_of(display_list: &DisplayList, index: usize) -> Option<azul_core::dom::NodeId> {
177
116
    display_list.node_mapping.get(index).and_then(|n| *n)
178
116
}
179

            
180
126
fn layer_pixel_size(
181
126
    size: LogicalSize,
182
126
    dpi_factor: f32,
183
126
    node: Option<azul_core::dom::NodeId>,
184
126
) -> (u32, u32) {
185
126
    let dw = size.width * dpi_factor;
186
126
    let dh = size.height * dpi_factor;
187

            
188
126
    if !dw.is_finite() || !dh.is_finite() {
189
7
        report_impossible_layer_size(size, dpi_factor, node, "not finite");
190
7
        return (0, 0);
191
119
    }
192
119
    let over = dw.ceil() > MAX_LAYER_DIM as f32 || dh.ceil() > MAX_LAYER_DIM as f32;
193
119
    if over {
194
1
        report_impossible_layer_size(size, dpi_factor, node, "past MAX_LAYER_DIM");
195
118
    }
196
238
    let px = |v: f32| -> u32 {
197
238
        if v <= 0.0 {
198
17
            0
199
        } else {
200
221
            (v.ceil() as u32).min(MAX_LAYER_DIM)
201
        }
202
238
    };
203
119
    (px(dw), px(dh))
204
126
}
205

            
206
/// Say — once — that a layer was asked for at a size that cannot be real.
207
///
208
/// `debug_assert` rather than a hard panic so a release build still renders
209
/// (minus the offending layer) instead of dying on a bug the user cannot fix,
210
/// while every test and debug run stops exactly where the bad size was born.
211
8
fn report_impossible_layer_size(
212
8
    size: LogicalSize,
213
8
    dpi_factor: f32,
214
8
    node: Option<azul_core::dom::NodeId>,
215
8
    why: &str,
216
8
) {
217
    #[cfg(feature = "std")]
218
    {
219
        static ANNOUNCE: std::sync::Once = std::sync::Once::new();
220
8
        ANNOUNCE.call_once(|| {
221
1
            eprintln!(
222
1
                "[azul][compositor] layer for node {node:?} requested at {}x{} logical (dpi \
223
1
                 {dpi_factor}) — {why}. This is a LAYOUT bug: no box has an infinite used size. \
224
1
                 The layer is being skipped or clamped so the process survives; the wrong size is \
225
1
                 still wrong.",
226
                size.width, size.height,
227
            );
228
1
        });
229
    }
230
    // Not under `cfg(test)`: this module's own tests feed the guard infinities
231
    // on purpose to prove it holds, and they must assert on the RESULT rather
232
    // than die inside the thing they are testing.
233
    #[cfg(not(test))]
234
    debug_assert!(
235
        false,
236
        "compositor: layer for node {node:?} at {}x{} logical (dpi {dpi_factor}) is {why}",
237
        size.width, size.height,
238
    );
239
8
}
240

            
241
/// The `w × h` device-pixel window of `parent` whose top-left sits at
242
/// (`ox`, `oy`) in the parent's pixel space, as RGBA bytes; pixels outside
243
/// the parent are transparent black.
244
///
245
/// The backdrop a plain child layer is
246
/// seeded with (see `render_layers`).
247
#[allow(
248
    clippy::cast_sign_loss,
249
    clippy::cast_possible_wrap,
250
    clippy::cast_possible_truncation
251
)] // bounded pixel/coord cast
252
28
fn backdrop_under(parent: &AzulPixmap, ox: i32, oy: i32, w: u32, h: u32) -> Vec<u8> {
253
28
    let mut out = vec![0u8; (w as usize) * (h as usize) * 4];
254
28
    let pw = parent.width as i32;
255
28
    let ph = parent.height as i32;
256
28
    let src = parent.data();
257
1770
    for y in 0..h as i32 {
258
1770
        let py = oy.saturating_add(y);
259
1770
        if py < 0 || py >= ph {
260
5
            continue;
261
1765
        }
262
        // Overlap of [ox, ox + w) with [0, pw).
263
1765
        let x0 = ox.max(0);
264
1765
        let x1 = ox.saturating_add(w as i32).min(pw);
265
1765
        if x1 <= x0 {
266
            continue;
267
1765
        }
268
1765
        let src_start = ((py * pw + x0) as usize) * 4;
269
1765
        let src_end = ((py * pw + x1) as usize) * 4;
270
1765
        let dst_start = ((y * w as i32) + (x0 - ox)) as usize * 4;
271
1765
        let len = src_end - src_start;
272
1765
        out[dst_start..dst_start + len].copy_from_slice(&src[src_start..src_end]);
273
    }
274
28
    out
275
28
}
276

            
277
/// Reason a layer was created.
278
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279
pub enum LayerReason {
280
    /// Root layer (always exists).
281
    Root,
282
    /// Created for a `PushScrollFrame`.
283
    ScrollFrame,
284
    /// Created for a `PushFilter` containing blur.
285
    BlurFilter,
286
    /// Created for a `PushOpacity` with opacity < 1.0.
287
    Opacity,
288
    /// Created for a `PushReferenceFrame` with non-identity transform.
289
    Transform,
290
}
291

            
292
/// Plain rect intersection; a disjoint pair yields a ZERO-SIZED rect (clip
293
/// to nothing).
294
3
fn intersect_logical_rects(a: LogicalRect, b: LogicalRect) -> LogicalRect {
295
3
    let x0 = a.origin.x.max(b.origin.x);
296
3
    let y0 = a.origin.y.max(b.origin.y);
297
3
    let x1 = (a.origin.x + a.size.width).min(b.origin.x + b.size.width);
298
3
    let y1 = (a.origin.y + a.size.height).min(b.origin.y + b.size.height);
299
3
    LogicalRect {
300
3
        origin: LogicalPosition { x: x0, y: y0 },
301
3
        size: LogicalSize {
302
3
            width: (x1 - x0).max(0.0),
303
3
            height: (y1 - y0).max(0.0),
304
3
        },
305
3
    }
306
3
}
307

            
308
impl CompositorState {
309
    /// Create a new compositor with a root layer sized to the viewport.
310
    #[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
311
    #[must_use]
312
262
    pub fn new(width: u32, height: u32) -> Self {
313
262
        let root_id = LayerId(0);
314
        // The ROOT layer is the canvas: start it OPAQUE WHITE (the base a
315
        // full repaint clears to), never zeroed transparent black. The
316
        // colorimetric LCD text blend reads destination pixels, so any
317
        // path that touches the root pixbuf before the first
318
        // render_layers (which re-fills it per frame) must find the same
319
        // base a fresh repaint would. Effect layers (opacity / filter /
320
        // transform) stay transparent — they composite; a PLAIN layer is
321
        // seeded with its parent's backdrop in `render_layers`.
322
262
        let mut root_layer = Layer::new(
323
262
            root_id,
324
262
            LogicalRect {
325
262
                origin: LogicalPosition::zero(),
326
262
                size: LogicalSize {
327
262
                    width: width as f32,
328
262
                    height: height as f32,
329
262
                },
330
262
            },
331
262
            width,
332
262
            height,
333
        );
334
262
        root_layer.pixbuf.fill(255, 255, 255, 255);
335
262
        let mut layers = HashMap::new();
336
262
        layers.insert(root_id, root_layer);
337
262
        Self {
338
262
            clear_color: [255, 255, 255, 255],
339
262
            layers,
340
262
            root_layer: root_id,
341
262
            next_layer_id: 1,
342
262
            previous_positions: Vec::new(),
343
262
        }
344
262
    }
345

            
346
    /// Allocate a new unique layer ID.
347
1101
    pub const fn alloc_layer_id(&mut self) -> LayerId {
348
1101
        let id = LayerId(self.next_layer_id);
349
1101
        self.next_layer_id += 1;
350
1101
        id
351
1101
    }
352

            
353
    /// Read-only peek at the next layer ID counter (for leak probes).
354
    #[must_use]
355
7
    pub const fn next_layer_id_peek(&self) -> u64 {
356
7
        self.next_layer_id
357
7
    }
358

            
359
    /// Walk the display list and create layers for scroll frames, filters, opacity, transforms.
360
    /// Returns a mapping from display-list item index to the `LayerId` it should render into.
361
    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
362
    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
363
    /// # Panics
364
    ///
365
    /// Panics if the internal layer stack underflows (a malformed display list).
366
    // single-pass walk over the full display-list opcode set; splitting it would
367
    // only scatter the shared layer-stack state across helpers
368
    #[allow(clippy::cognitive_complexity)]
369
255
    pub fn allocate_layers_from_display_list(
370
255
        &mut self,
371
255
        display_list: &DisplayList,
372
255
        dpi_factor: f32,
373
255
        live_transforms: &HashMap<usize, azul_core::transform::ComputedTransform3D>,
374
255
        live_opacities: &HashMap<usize, f32>,
375
255
    ) {
376
        // Remove all non-root layers from previous frame
377
255
        let root_id = self.root_layer;
378
260
        self.layers.retain(|id, _| *id == root_id);
379
255
        if let Some(root) = self.layers.get_mut(&root_id) {
380
255
            root.children.clear();
381
255
            root.damage.clear();
382
255
            root.display_list_range = (0, display_list.items.len());
383
255
            root.composite_dirty = true;
384
255
        }
385

            
386
255
        let mut layer_stack: Vec<LayerId> = vec![root_id];
387
        // The `PushClip`s open at each point of the walk. A layer created
388
        // inside them is windowed by their intersection at composite time —
389
        // the layer-boundary half of clip chaining (the rasterizer's clip
390
        // stack handles items inside one layer).
391
255
        let mut clip_stack: Vec<LogicalRect> = Vec::new();
392
        // One entry per open `PushReferenceFrame`, recording whether it actually
393
        // promoted a layer, so `PopReferenceFrame` pops exactly what was pushed.
394
255
        let mut ref_frame_promoted: Vec<bool> = Vec::new();
395
        // Same recorded pairing for opacity groups: the pop arm used to decide
396
        // by VALUE-testing the top layer (`opacity < 1.0`), which mispairs the
397
        // moment a group's effective opacity passes through exactly 1.0 —
398
        // reachable every time an enter fade completes.
399
255
        let mut opacity_promoted: Vec<bool> = Vec::new();
400
        // Same recorded pairing for scroll frames and filters: a push that
401
        // allocates no layer (zero-sized or EMPTY range) must not let its
402
        // pop remove the parent from the stack.
403
255
        let mut scroll_promoted: Vec<bool> = Vec::new();
404
255
        let mut filter_promoted: Vec<bool> = Vec::new();
405
255
        let mut i = 0;
406

            
407
3135
        while i < display_list.items.len() {
408
2880
            match &display_list.items[i] {
409
                DisplayListItem::PushScrollFrame {
410
43
                    clip_bounds,
411
43
                    content_size,
412
43
                    scroll_id,
413
                    ..
414
                } => {
415
43
                    let bounds = *clip_bounds.inner();
416
43
                    let (pw, ph) =
417
43
                        layer_pixel_size(bounds.size, dpi_factor, node_of(display_list, i));
418
                    // Find the matching PopScrollFrame FIRST: an EMPTY frame
419
                    // (every empty TextInput's value <p>) must not allocate a
420
                    // layer at all. Its pixbuf was never seeded, cleared or
421
                    // rendered (render_layers skips empty ranges), yet
422
                    // composite still blitted the never-initialized OPAQUE
423
                    // WHITE buffer over the parent - erasing the placeholder
424
                    // text under it on every fully-layered draw (the first
425
                    // frame; damage repaints are flat, which healed it).
426
43
                    let end = find_matching_pop(&display_list.items, i, MatchKind::ScrollFrame);
427
43
                    let created = pw > 0 && ph > 0 && end > i + 1;
428
43
                    scroll_promoted.push(created);
429
43
                    if created {
430
38
                        let new_id = self.alloc_layer_id();
431
38
                        let mut layer = Layer::new(new_id, bounds, pw, ph);
432
38
                        layer.static_clip =
433
38
                            clip_stack.iter().copied().reduce(intersect_logical_rects);
434
38
                        layer.scroll_id = Some(*scroll_id);
435
38
                        layer.display_list_range = (i + 1, end);
436
38
                        self.layers.insert(new_id, layer);
437
                        // Add as child of current parent
438
38
                        let parent_id = *layer_stack.last().unwrap();
439
38
                        if let Some(parent) = self.layers.get_mut(&parent_id) {
440
38
                            parent.children.push(new_id);
441
38
                        }
442
38
                        layer_stack.push(new_id);
443
5
                    }
444
                }
445
                DisplayListItem::PopScrollFrame => {
446
                    // Pair by recorded decision (see PopOpacity): a frame that
447
                    // allocated no layer must not pop its parent.
448
44
                    if scroll_promoted.pop() == Some(true) && layer_stack.len() > 1 {
449
37
                        layer_stack.pop();
450
37
                    }
451
                }
452
                DisplayListItem::PushOpacity {
453
14
                    bounds,
454
14
                    opacity,
455
14
                    opacity_key,
456
                } => {
457
                    // The LIVE value wins over the baked one — an enter/exit
458
                    // fade republishes its opacity every tick without the list
459
                    // being rebuilt, exactly like animated transforms. The
460
                    // baked value is only the fallback for an unkeyed (plain
461
                    // CSS opacity) group.
462
14
                    let effective = opacity_key
463
14
                        .and_then(|k| live_opacities.get(&k.id).copied())
464
14
                        .unwrap_or(*opacity);
465
14
                    let b = *bounds.inner();
466
14
                    let (pw, ph) = layer_pixel_size(b.size, dpi_factor, node_of(display_list, i));
467
14
                    let end = find_matching_pop(&display_list.items, i, MatchKind::Opacity);
468
14
                    let promote = effective < 1.0 && pw > 0 && ph > 0 && end > i + 1;
469
14
                    opacity_promoted.push(promote);
470
14
                    if promote {
471
6
                        let new_id = self.alloc_layer_id();
472
6
                        let mut layer = Layer::new(new_id, b, pw, ph);
473
6
                        layer.static_clip =
474
6
                            clip_stack.iter().copied().reduce(intersect_logical_rects);
475
6
                        layer.opacity = effective;
476
6
                        layer.display_list_range = (i + 1, end);
477
6
                        self.layers.insert(new_id, layer);
478
6
                        let parent_id = *layer_stack.last().unwrap();
479
6
                        if let Some(parent) = self.layers.get_mut(&parent_id) {
480
6
                            parent.children.push(new_id);
481
6
                        }
482
6
                        layer_stack.push(new_id);
483
8
                    }
484
                }
485
                DisplayListItem::PopOpacity => {
486
                    // Pair by RECORDED DECISION, never by value-testing the top
487
                    // layer — the same fix `PopReferenceFrame` got: an opacity
488
                    // group whose live value has reached exactly 1.0 (a settled
489
                    // fade) allocated nothing, and a value test would pop its
490
                    // PARENT instead.
491
15
                    if opacity_promoted.pop() == Some(true) && layer_stack.len() > 1 {
492
6
                        layer_stack.pop();
493
9
                    }
494
                }
495
6
                DisplayListItem::PushFilter { bounds, filters } => {
496
6
                    let has_blur = filters.iter().any(|f| matches!(f, StyleFilter::Blur(_)));
497
6
                    let (pw, ph) = if has_blur {
498
6
                        layer_pixel_size(bounds.inner().size, dpi_factor, node_of(display_list, i))
499
                    } else {
500
                        (0, 0)
501
                    };
502
6
                    let end = find_matching_pop(&display_list.items, i, MatchKind::Filter);
503
6
                    let promote = has_blur && pw > 0 && ph > 0 && end > i + 1;
504
6
                    filter_promoted.push(promote);
505
6
                    if promote {
506
3
                        let b = *bounds.inner();
507
3
                        let new_id = self.alloc_layer_id();
508
3
                        let mut layer = Layer::new(new_id, b, pw, ph);
509
3
                        layer.static_clip =
510
3
                            clip_stack.iter().copied().reduce(intersect_logical_rects);
511
3
                        layer.filters.clone_from(filters);
512
3
                        layer.display_list_range = (i + 1, end);
513
3
                        self.layers.insert(new_id, layer);
514
3
                        let parent_id = *layer_stack.last().unwrap();
515
3
                        if let Some(parent) = self.layers.get_mut(&parent_id) {
516
3
                            parent.children.push(new_id);
517
3
                        }
518
3
                        layer_stack.push(new_id);
519
3
                    }
520
                }
521
                DisplayListItem::PopFilter => {
522
                    // Recorded pairing (see PopOpacity) - the old value test
523
                    // on the top layer's filters mispaired nested groups.
524
7
                    if filter_promoted.pop() == Some(true) && layer_stack.len() > 1 {
525
3
                        layer_stack.pop();
526
4
                    }
527
                }
528
                DisplayListItem::PushReferenceFrame {
529
54
                    transform_key,
530
54
                    initial_transform,
531
54
                    bounds,
532
                } => {
533
                    // `initial_transform` is the matrix as of display-list BUILD
534
                    // time. A GPU-animated transform (drag, CSS transition, or a
535
                    // diff-driven FLIP) is republished every frame WITHOUT the
536
                    // list being rebuilt, so the live value is authoritative and
537
                    // the baked one is only the fallback for a key nothing has
538
                    // published. Reading the baked matrix here is what made
539
                    // engine-driven transitions invisible: a FLIP starts at
540
                    // identity, so no layer was promoted, and the per-item walk
541
                    // does not apply transforms at all — it only maintains a
542
                    // stack that the layer path consumes.
543
54
                    let m = live_transforms
544
54
                        .get(&transform_key.id)
545
54
                        .map_or(&initial_transform.m, |t| &t.m);
546
54
                    let is_identity = (m[0][0] - 1.0).abs() < IDENTITY_EPSILON
547
54
                        && m[0][1].abs() < IDENTITY_EPSILON
548
54
                        && m[1][0].abs() < IDENTITY_EPSILON
549
54
                        && (m[1][1] - 1.0).abs() < IDENTITY_EPSILON
550
54
                        && m[3][0].abs() < IDENTITY_EPSILON
551
8
                        && m[3][1].abs() < IDENTITY_EPSILON;
552
                    // Record the decision so the matching pop can be exact.
553
54
                    let end = find_matching_pop(&display_list.items, i, MatchKind::ReferenceFrame);
554
54
                    let promote = !is_identity && end > i + 1;
555
54
                    ref_frame_promoted.push(promote);
556
54
                    if promote {
557
50
                        let b = *bounds.inner();
558
50
                        let (pw, ph) =
559
50
                            layer_pixel_size(b.size, dpi_factor, node_of(display_list, i));
560
50
                        let (pw, ph) = (pw.max(1), ph.max(1));
561
50
                        let new_id = self.alloc_layer_id();
562
50
                        let mut layer = Layer::new(new_id, b, pw, ph);
563
50
                        layer.static_clip =
564
50
                            clip_stack.iter().copied().reduce(intersect_logical_rects);
565
50
                        layer.transform = TransAffine::new_custom(
566
50
                            f64::from(m[0][0]),
567
50
                            f64::from(m[0][1]),
568
50
                            f64::from(m[1][0]),
569
50
                            f64::from(m[1][1]),
570
50
                            f64::from(m[3][0]),
571
50
                            f64::from(m[3][1]),
572
50
                        );
573
50
                        layer.perspective_row = [m[0][3], m[1][3], m[3][3]];
574
50
                        layer.display_list_range = (i + 1, end);
575
50
                        self.layers.insert(new_id, layer);
576
50
                        let parent_id = *layer_stack.last().unwrap();
577
50
                        if let Some(parent) = self.layers.get_mut(&parent_id) {
578
50
                            parent.children.push(new_id);
579
50
                        }
580
50
                        layer_stack.push(new_id);
581
4
                    }
582
                }
583
                DisplayListItem::PopReferenceFrame => {
584
                    // Pair with the push by RECORDED DECISION, never by asking
585
                    // whether the top layer's transform looks non-identity: an
586
                    // identity frame nested inside a moved one allocates
587
                    // nothing, and a value test would then pop the PARENT and
588
                    // composite everything after it into the wrong layer. A FLIP
589
                    // sits at identity both at rest and at the instant it
590
                    // settles, so that mispairing is reachable in ordinary
591
                    // playback rather than only from a hand-built list.
592
55
                    if ref_frame_promoted.pop() == Some(true) && layer_stack.len() > 1 {
593
50
                        layer_stack.pop();
594
50
                    }
595
                }
596
                // `backdrop-filter` (superplan g4): allocate a layer mirroring
597
                // PushFilter, but tagged `is_backdrop_filter` so the compositor
598
                // applies the filter to the *backdrop* (parent + earlier siblings
599
                // already in `output`) rather than to the layer's own content.
600
                // The compositing side reads back the `output` region under the
601
                // layer bounds and runs `apply_layer_filters` on it before
602
                // blitting the content (see `composite_layer_recursive`).
603
3
                DisplayListItem::PushBackdropFilter { bounds, filters } => {
604
3
                    let b = *bounds.inner();
605
3
                    let (pw, ph) = layer_pixel_size(b.size, dpi_factor, node_of(display_list, i));
606
3
                    if pw > 0 && ph > 0 && !filters.is_empty() {
607
2
                        let new_id = self.alloc_layer_id();
608
2
                        let mut layer = Layer::new(new_id, b, pw, ph);
609
2
                        layer.static_clip =
610
2
                            clip_stack.iter().copied().reduce(intersect_logical_rects);
611
2
                        layer.filters.clone_from(filters);
612
2
                        layer.is_backdrop_filter = true;
613
                        // The layer's OWN content may be empty (e.g. an empty
614
                        // div with only `backdrop-filter`). render_layers skips
615
                        // empty display-list ranges, leaving the Layer::new
616
                        // opaque-white pixbuf, which would then be blitted over
617
                        // (and wipe) the filtered backdrop. Start transparent so
618
                        // an empty backdrop-filter element shows the backdrop.
619
2
                        layer.pixbuf.fill(0, 0, 0, 0);
620
2
                        let end =
621
2
                            find_matching_pop(&display_list.items, i, MatchKind::BackdropFilter);
622
2
                        layer.display_list_range = (i + 1, end);
623
2
                        self.layers.insert(new_id, layer);
624
2
                        let parent_id = *layer_stack.last().unwrap();
625
2
                        if let Some(parent) = self.layers.get_mut(&parent_id) {
626
2
                            parent.children.push(new_id);
627
2
                        }
628
2
                        layer_stack.push(new_id);
629
1
                    }
630
                }
631
                DisplayListItem::PopBackdropFilter => {
632
4
                    if layer_stack.len() > 1 {
633
2
                        let top_id = *layer_stack.last().unwrap();
634
2
                        if let Some(layer) = self.layers.get(&top_id) {
635
2
                            if layer.is_backdrop_filter {
636
2
                                layer_stack.pop();
637
2
                            }
638
                        }
639
2
                    }
640
                }
641
                // `text-shadow` (Push/PopTextShadow) is a text-rasterization
642
                // concern, not a layer boundary, so it is handled in
643
                // `render_single_item`, not here.
644
63
                DisplayListItem::PushClip { bounds, .. } => {
645
63
                    clip_stack.push(*bounds.inner());
646
63
                }
647
63
                DisplayListItem::PopClip => {
648
63
                    clip_stack.pop();
649
63
                }
650
2509
                _ => {}
651
            }
652
2880
            i += 1;
653
        }
654
255
    }
655

            
656
    /// Compute damage rects from dirty node sets and old/new positions.
657
5
    pub fn compute_damage(
658
5
        &mut self,
659
5
        dirty_nodes: &std::collections::BTreeSet<usize>,
660
5
        old_positions: &[LogicalPosition],
661
5
        new_positions: &[LogicalPosition],
662
5
        calculated_rects: &[LogicalRect],
663
5
    ) {
664
5
        if dirty_nodes.is_empty() {
665
1
            return;
666
4
        }
667

            
668
4
        let mut damage_rects = Vec::new();
669
10
        for &node_idx in dirty_nodes {
670
            // Old bounds
671
6
            if node_idx < old_positions.len() && node_idx < calculated_rects.len() {
672
3
                let old_rect = LogicalRect {
673
3
                    origin: old_positions[node_idx],
674
3
                    size: calculated_rects[node_idx].size,
675
3
                };
676
3
                damage_rects.push(old_rect);
677
3
            }
678
            // New bounds
679
6
            if node_idx < new_positions.len() && node_idx < calculated_rects.len() {
680
3
                let new_rect = LogicalRect {
681
3
                    origin: new_positions[node_idx],
682
3
                    size: calculated_rects[node_idx].size,
683
3
                };
684
3
                damage_rects.push(new_rect);
685
3
            }
686
        }
687

            
688
        // Distribute damage rects to affected layers
689
4
        for layer in self.layers.values_mut() {
690
10
            for damage in &damage_rects {
691
6
                if let Some(intersection) = rect_intersection(&layer.bounds, damage) {
692
6
                    layer.damage.push(intersection);
693
6
                    layer.composite_dirty = true;
694
6
                }
695
            }
696
        }
697
5
    }
698

            
699
    /// Render display list items into their respective layer pixbufs.
700
    /// # Panics
701
    ///
702
    /// Panics if a referenced layer id is not present in the layer map.
703
    /// # Errors
704
    ///
705
    /// Returns an error string if the layers cannot be composited.
706
225
    pub fn render_layers(
707
225
        &mut self,
708
225
        display_list: &DisplayList,
709
225
        dpi_factor: f32,
710
225
        renderer_resources: &RendererResources,
711
225
        font_manager: &FontManager<FontRef>,
712
225
        glyph_cache: &mut GlyphCache,
713
225
        render_state: &CpuRenderState,
714
225
    ) -> Result<(), String> {
715
225
        let scroll_offsets = &render_state.scroll_offsets;
716

            
717
        // PARENT-FIRST ORDER. A plain scroll-frame layer is seeded with its
718
        // parent's already-rendered pixels below, so the parent must have
719
        // rendered first; `self.layers` is a HashMap and iterates in no
720
        // order. Walk the tree from the root (children in z-order) and
721
        // remember each layer's parent.
722
225
        let mut order: Vec<LayerId> = Vec::with_capacity(self.layers.len());
723
225
        let mut parent_of: HashMap<LayerId, LayerId> = HashMap::new();
724
225
        let mut stack: Vec<LayerId> = vec![self.root_layer];
725
526
        while let Some(id) = stack.pop() {
726
301
            order.push(id);
727
301
            if let Some(layer) = self.layers.get(&id) {
728
301
                for child in layer.children.iter().rev() {
729
76
                    parent_of.insert(*child, id);
730
76
                    stack.push(*child);
731
76
                }
732
            }
733
        }
734
        // A layer unreachable from the root cannot exist after
735
        // `allocate_layers_from_display_list`, but render it rather than
736
        // drop it if it ever does.
737
301
        for id in self.layers.keys() {
738
301
            if !order.contains(id) {
739
                order.push(*id);
740
301
            }
741
        }
742

            
743
        // Collect layer IDs, ranges, bounds, scroll_id and child ranges.
744
225
        let layer_ranges: Vec<(
745
225
            LayerId,
746
225
            (usize, usize),
747
225
            LogicalRect,
748
225
            Option<LocalScrollId>,
749
225
            Vec<(usize, usize)>,
750
225
        )> = order
751
225
            .iter()
752
301
            .filter_map(|id| self.layers.get(id).map(|layer| (id, layer)))
753
301
            .map(|(id, layer)| {
754
                // Ranges of this layer's DIRECT children (nested scroll frames /
755
                // opacity / transform groups). They render into their own
756
                // pixbufs, so they must be skipped when rendering this layer's
757
                // range (which, for the root, spans the whole display list).
758
301
                let child_ranges: Vec<(usize, usize)> = layer
759
301
                    .children
760
301
                    .iter()
761
301
                    .filter_map(|cid| self.layers.get(cid).map(|c| c.display_list_range))
762
301
                    .collect();
763
301
                (
764
301
                    *id,
765
301
                    layer.display_list_range,
766
301
                    layer.bounds,
767
301
                    layer.scroll_id,
768
301
                    child_ranges,
769
301
                )
770
301
            })
771
225
            .collect();
772

            
773
        #[cfg(feature = "std")]
774
225
        if std::env::var("AZ_MAP_DEBUG").is_ok() {
775
            for (id, range, bounds, scroll_id, child_ranges) in &layer_ranges {
776
                std::eprintln!(
777
                    "[cpu-layer] render id={:?} range={:?} bounds={:?} scroll={:?} skip={:?} \
778
                     (dl_len={})",
779
                    id,
780
                    range,
781
                    bounds,
782
                    scroll_id,
783
                    child_ranges,
784
                    display_list.items.len()
785
                );
786
            }
787
225
        }
788

            
789
526
        for (layer_id, range, layer_bounds, scroll_id, child_ranges) in &layer_ranges {
790
301
            let (start, end) = *range;
791
            // An empty range still needs the CLEAR/SEED below: a pixbuf
792
            // starts opaque white and composite blits it regardless. The
793
            // allocator no longer creates empty-range layers, but any other
794
            // source of one must be an identity patch, not a white bar.
795
301
            let has_items = start < end && start < display_list.items.len();
796

            
797
            // This layer's scroll offset (0 for non-scroll layers). Content inside
798
            // a scroll frame is at absolute coords; the renderer draws at
799
            // `pos - seed`, so folding the scroll offset into the seed shifts the
800
            // frame's content within its own pixbuf. composite_frame blits the
801
            // pixbuf back at `layer.bounds.origin` (NOT scroll_offset), so applying
802
            // it here is the single place — no double offset. (Without this, a full
803
            // repaint while scrolled drew content at offset 0.)
804
301
            let soff = scroll_id
805
301
                .and_then(|id| scroll_offsets.get(&id).copied())
806
301
                .unwrap_or((0.0, 0.0));
807

            
808
            // THE BACKDROP A LAYER'S CONTENT IS DRAWN OVER.
809
            //
810
            // A plain layer — opacity 1, no filter, identity transform; in
811
            // practice every scroll frame — is composited by a verbatim
812
            // copy of its opaque pixels and a blend of the rest, so the
813
            // pixels it did not paint must already BE the parent's pixels.
814
            // They used to be transparent black, and the LCD text sweep
815
            // blends per stripe against whatever is in the destination
816
            // row, then forces the pixel opaque: every glyph inside a scroll
817
            // frame without a local background got dark RGB fringes blended
818
            // against black — the "ghosted subtitle on the first draw"
819
            // (the first frame is the only routinely layered render; later
820
            // repaints are flat and silently fixed it). Seed such a layer
821
            // with the parent's pixels under its bounds instead. Where the
822
            // layer paints nothing the composite is a no-op, where it
823
            // paints the blend sees the true backdrop — full == flat.
824
            //
825
            // Opacity / filter / transform layers keep the transparent clear:
826
            // their content must be composited through the effect.
827
301
            let seed_from_parent = *layer_id != self.root_layer
828
76
                && self.layers.get(layer_id).is_some_and(|l| {
829
76
                    l.opacity >= 1.0
830
75
                        && l.filters.is_empty()
831
74
                        && !l.is_backdrop_filter
832
74
                        && l.transform.is_identity(IDENTITY_EPSILON_F64)
833
76
                });
834
301
            let seed: Option<Vec<u8>> = if seed_from_parent {
835
28
                let (w, h) = self
836
28
                    .layers
837
28
                    .get(layer_id)
838
28
                    .map_or((0, 0), |l| (l.pixbuf.width, l.pixbuf.height));
839
                // Child-local (0, 0) lands at `bounds.origin` in the parent's
840
                // pixel space — the same placement `composite_layer_recursive`
841
                // uses for an untransformed layer.
842
                // The parent's pixbuf holds the parent's CONTENT: its (0,0)
843
                // is the parent's origin plus its scroll offset in window
844
                // space (items render at `pos - origin - soff`). Sample the
845
                // backdrop in THAT space — absolute coordinates grabbed the
846
                // wrong patch for any layer nested in a non-root layer.
847
28
                parent_of
848
28
                    .get(layer_id)
849
28
                    .and_then(|pid| self.layers.get(pid))
850
28
                    .map(|parent| {
851
28
                        let psoff = parent
852
28
                            .scroll_id
853
28
                            .and_then(|id| scroll_offsets.get(&id).copied())
854
28
                            .unwrap_or((0.0, 0.0));
855
28
                        let ox = ((layer_bounds.origin.x - parent.bounds.origin.x - psoff.0)
856
28
                            * dpi_factor)
857
28
                            .round() as i32;
858
28
                        let oy = ((layer_bounds.origin.y - parent.bounds.origin.y - psoff.1)
859
28
                            * dpi_factor)
860
28
                            .round() as i32;
861
28
                        backdrop_under(&parent.pixbuf, ox, oy, w, h)
862
28
                    })
863
            } else {
864
273
                None
865
            };
866

            
867
301
            let layer = self.layers.get_mut(layer_id).unwrap();
868
301
            layer.scroll_offset = soff;
869

            
870
            // Clear the layer pixbuf: the clear colour (white; transparent
871
            // for a transparent window) for the root, the parent's backdrop
872
            // for a plain layer, transparent for an effect layer.
873
301
            if *layer_id == self.root_layer {
874
225
                let [r, g, b, a] = self.clear_color;
875
225
                layer.pixbuf.fill(r, g, b, a);
876
225
            } else if let Some(seed) = seed.filter(|s| s.len() == layer.pixbuf.data().len()) {
877
28
                layer.pixbuf.data_mut().copy_from_slice(&seed);
878
52
            } else {
879
48
                layer.pixbuf.fill(0, 0, 0, 0);
880
48
            }
881

            
882
            // Seed = layer origin (for pixbuf-local placement) + scroll offset.
883
301
            let offset_x = layer_bounds.origin.x + soff.0;
884
301
            let offset_y = layer_bounds.origin.y + soff.1;
885
301
            if has_items {
886
297
                render_display_list_range(
887
297
                    display_list,
888
297
                    &mut layer.pixbuf,
889
297
                    start,
890
297
                    end.min(display_list.items.len()),
891
297
                    child_ranges,
892
297
                    offset_x,
893
297
                    offset_y,
894
297
                    dpi_factor,
895
297
                    renderer_resources,
896
297
                    font_manager,
897
297
                    glyph_cache,
898
297
                    render_state,
899
                )?;
900
4
            }
901
        }
902

            
903
225
        Ok(())
904
225
    }
905

            
906
    /// Composite all layers bottom-up into the final output pixmap.
907
    /// Clear the root layer to `color` from now on (see the field).
908
    pub const fn set_clear_color(&mut self, color: [u8; 4]) {
909
        self.clear_color = color;
910
    }
911

            
912
221
    pub fn composite_frame(&self, output: &mut AzulPixmap, dpi_factor: f32) {
913
        // Start from root layer, with an identity device transform, unclipped.
914
221
        self.composite_layer_recursive(self.root_layer, output, MAT3_IDENTITY, None, dpi_factor);
915
221
    }
916

            
917
    /// `parent_m` maps the PARENT's local device-pixel space to output
918
    /// device-pixel space — identity for children of the root (the root blits
919
    /// at the origin, untransformed, exactly as before).
920
    ///
921
    /// A layer's own mapping composes THREE pieces, applied to a layer-local
922
    /// pixel in this order: the layer's live transform (its translation is in
923
    /// LOGICAL units, so it is device-scaled here; the linear part is
924
    /// unit-free), then placement at `bounds.origin`, then the parent chain.
925
    /// Before this, the recursion carried plain offsets and `layer.transform`
926
    /// was NEVER READ at composite time — allocation promoted a transformed
927
    /// layer and stored its live matrix, rendering rasterised its content,
928
    /// and the final blit put the pixels back at the untransformed layout
929
    /// position. That silent drop is why a mid-animation frame with a
930
    /// provably-correct engine state (sampled tx=176, 143, 85, 27 across
931
    /// ticks) produced byte-identical screenshots: every layer of the
932
    /// pipeline agreed except the last one.
933
    ///
934
    /// Children inherit `this_m`, so a scrollbar or opacity layer INSIDE an
935
    /// animated subtree travels with it.
936
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
937
297
    fn composite_layer_recursive(
938
297
        &self,
939
297
        layer_id: LayerId,
940
297
        output: &mut AzulPixmap,
941
297
        parent_h: Mat3,
942
297
        clip: Option<(i32, i32, i32, i32)>,
943
297
        dpi_factor: f32,
944
297
    ) {
945
297
        let Some(layer) = self.layers.get(&layer_id) else {
946
            return;
947
        };
948

            
949
        // this_h: layer-local device pixels -> output device pixels, as a
950
        // 3x3 homography over column vectors `[x; y; 1]` so a perspective
951
        // parent carries its foreshortening into every descendant. Affine
952
        // layers (all of them outside a 3D transform) keep a trivial third
953
        // row and take the affine / integer-blit paths below unchanged.
954
297
        let this_h = if layer_id == self.root_layer {
955
            // The root blits at the origin, untransformed — same special case
956
            // the offset-based recursion had.
957
221
            parent_h
958
        } else {
959
76
            let t = &layer.transform;
960
76
            let dpi = f64::from(dpi_factor);
961
            // The layer's live transform with its logical translation scaled
962
            // to device pixels (linear part is unit-free; the perspective
963
            // coefficients multiply coordinates, so they scale by 1/dpi)...
964
76
            let layer_h: Mat3 = [
965
76
                t.sx,
966
76
                t.shx,
967
76
                t.tx * dpi,
968
76
                t.shy,
969
76
                t.sy,
970
76
                t.ty * dpi,
971
76
                f64::from(layer.perspective_row[0]) / dpi,
972
76
                f64::from(layer.perspective_row[1]) / dpi,
973
76
                f64::from(layer.perspective_row[2]),
974
76
            ];
975
            // ...then placed at bounds.origin, then through the parent chain
976
            // (column-vector matrices: the rightmost factor applies first).
977
76
            let place = mat3_translation(
978
76
                layout_offset_device_px(layer.bounds.origin.x, dpi),
979
76
                layout_offset_device_px(layer.bounds.origin.y, dpi),
980
            );
981
76
            mat3_mul(&parent_h, &mat3_mul(&place, &layer_h))
982
        };
983
297
        let this_m = mat3_affine_part(&this_h);
984
297
        let is_affine = mat3_is_affine(&this_h);
985

            
986
        // The clips WRAPPING this layer, recorded at allocation in the
987
        // parent's content space: `parent_h` (the rebased chain handed down)
988
        // is exactly the parent-content -> device mapping, so apply it and
989
        // join the chain. Only tightened when that mapping is a pure
990
        // translation — same policy as the scroll-frame window below.
991
297
        let clip = match layer.static_clip {
992
23
            Some(sc) if layer_id != self.root_layer => {
993
23
                let pm = mat3_affine_part(&parent_h);
994
23
                let translation_only = mat3_is_affine(&parent_h)
995
23
                    && (pm.sx - 1.0).abs() < IDENTITY_EPSILON_F64
996
23
                    && pm.shy.abs() < IDENTITY_EPSILON_F64
997
23
                    && pm.shx.abs() < IDENTITY_EPSILON_F64
998
23
                    && (pm.sy - 1.0).abs() < IDENTITY_EPSILON_F64;
999
23
                if translation_only {
23
                    let dpi = f64::from(dpi_factor);
23
                    let r = (
23
                        (f64::from(sc.origin.x) * dpi + pm.tx).round() as i32,
23
                        (f64::from(sc.origin.y) * dpi + pm.ty).round() as i32,
23
                        (f64::from(sc.origin.x + sc.size.width) * dpi + pm.tx).round() as i32,
23
                        (f64::from(sc.origin.y + sc.size.height) * dpi + pm.ty).round() as i32,
23
                    );
23
                    Some(clip.map_or(r, |c| {
                        (c.0.max(r.0), c.1.max(r.1), c.2.min(r.2), c.3.min(r.3))
                    }))
                } else {
                    clip
                }
            }
274
            _ => clip,
        };
        // Pure-integer-translation fast path — bit-identical to the old
        // offset blit for every untransformed layer (which is all of them,
        // outside an active animation / drag).
297
        let is_pure_translation = is_affine
297
            && (this_m.sx - 1.0).abs() < IDENTITY_EPSILON_F64
297
            && this_m.shy.abs() < IDENTITY_EPSILON_F64
297
            && this_m.shx.abs() < IDENTITY_EPSILON_F64
297
            && (this_m.sy - 1.0).abs() < IDENTITY_EPSILON_F64
297
            && (this_m.tx - this_m.tx.round()).abs() < 1e-6
259
            && (this_m.ty - this_m.ty.round()).abs() < 1e-6;
297
        let px_x = this_m.tx.round() as i32;
297
        let px_y = this_m.ty.round() as i32;
        // For root layer, just blit directly
297
        if layer_id == self.root_layer {
221
            blit_pixmap(&layer.pixbuf, output, 0, 0, 1.0);
221
        } else if layer.is_backdrop_filter && !layer.filters.is_empty() {
1
            // `backdrop-filter`: the backdrop (parent + earlier siblings) is
1
            // ALREADY composited into `output` at this point (bottom-up
1
            // order). Snapshot the region under the layer's bounds, run the
1
            // filter on that copy, write it back, THEN blit the layer's own
1
            // (unfiltered) content on top. Snapshot placement uses the
1
            // translation of the full mapping, so a backdrop-filter layer
1
            // inside a moved subtree filters the pixels actually under it.
1
            let w = layer.pixbuf.width;
1
            let h = layer.pixbuf.height;
1
            let snap = snapshot_region(output, px_x, px_y, w, h);
1
            let mut backdrop = AzulPixmap {
1
                data: snap.into(),
1
                width: w,
1
                height: h,
1
            };
1
            apply_layer_filters(&mut backdrop, &layer.filters, dpi_factor);
1
            write_region(output, &backdrop.data, w, h, px_x, px_y);
1
            blit_pixmap_clipped(&layer.pixbuf, output, px_x, px_y, layer.opacity, clip);
1
        } else {
            // Apply filters at composite time (to the layer's own content).
75
            let src = if layer.filters.is_empty() {
75
                None
            } else {
                let mut filtered = layer.pixbuf.clone_pixmap();
                apply_layer_filters(&mut filtered, &layer.filters, dpi_factor);
                Some(filtered)
            };
75
            let src_pixbuf = src.as_ref().unwrap_or(&layer.pixbuf);
75
            if is_pure_translation {
37
                blit_pixmap_clipped(src_pixbuf, output, px_x, px_y, layer.opacity, clip);
45
            } else if is_affine {
38
                blit_pixmap_affine_clipped(src_pixbuf, output, &this_m, layer.opacity, clip);
38
            } else {
                blit_pixmap_projective_clipped(src_pixbuf, output, &this_h, layer.opacity, clip);
            }
        }
        // Composite children in z-order. A child's `bounds.origin` is
        // WINDOW-absolute (every layer's is), so the mapping handed down
        // REBASE-s it into THIS layer's content space: translate by
        // -(this origin + this scroll offset). The root's origin and offset
        // are zero, so direct children of the root keep the absolute
        // placement they always had — but a layer nested in a NON-root layer
        // used to inherit `this_h` (which already places this layer at its
        // absolute origin) and then add its own absolute origin AGAIN: every
        // ancestor origin was double-counted, and a parent's scroll offset
        // never reached it at all. The TextArea widget was the visible case:
        // its inner scroll frame, nested in the demo's scrollable body,
        // painted its placeholder one body-clip-origin lower and to the
        // right of where layout (and hit-testing) put it.
297
        let children: Vec<LayerId> = layer.children.clone();
297
        if children.is_empty() {
227
            return;
70
        }
70
        let dpi = f64::from(dpi_factor);
        // Inverse of `place`, snapped the same way.
70
        let child_base = mat3_mul(
70
            &this_h,
70
            &mat3_translation(
70
                -(layout_offset_device_px(layer.bounds.origin.x, dpi)
70
                    + layout_offset_device_px(layer.scroll_offset.0, dpi)),
70
                -(layout_offset_device_px(layer.bounds.origin.y, dpi)
70
                    + layout_offset_device_px(layer.scroll_offset.1, dpi)),
70
            ),
        );
        // A scroll frame WINDOWS its children: pixels outside its device rect
        // belong to whatever surrounds the frame. Tighten only on the
        // pure-translation path — scroll frames are never transformed in
        // practice, and an approximate tightening under a live transform
        // would clip wrongly rather than loosely.
70
        let child_clip =
70
            if layer_id != self.root_layer && layer.scroll_id.is_some() && is_pure_translation {
2
                let r = (
2
                    px_x,
2
                    px_y,
2
                    px_x.saturating_add(layer.pixbuf.width as i32),
2
                    px_y.saturating_add(layer.pixbuf.height as i32),
2
                );
2
                Some(clip.map_or(r, |c| {
                    (c.0.max(r.0), c.1.max(r.1), c.2.min(r.2), c.3.min(r.3))
                }))
            } else {
68
                clip
            };
146
        for child_id in &children {
76
            self.composite_layer_recursive(*child_id, output, child_base, child_clip, dpi_factor);
76
        }
297
    }
    /// Handle scroll by shifting pixels and re-rendering the exposed strip.
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
    #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
    /// # Panics
    ///
    /// Panics if `layer_id` is not present in the layer map.
    /// # Errors
    ///
    /// Returns an error string if the layer cannot be scrolled.
3
    pub fn scroll_layer(
3
        &mut self,
3
        scroll_id: LocalScrollId,
3
        new_offset: (f32, f32),
3
        display_list: &DisplayList,
3
        dpi_factor: f32,
3
        renderer_resources: &RendererResources,
3
        font_manager: &FontManager<FontRef>,
3
        glyph_cache: &mut GlyphCache,
3
    ) -> Result<(), String> {
        // Find the layer with this scroll_id
3
        let layer_id = self
3
            .layers
3
            .iter()
5
            .find(|(_, l)| l.scroll_id == Some(scroll_id))
3
            .map(|(id, _)| *id);
3
        let Some(layer_id) = layer_id else {
1
            return Ok(()); // No layer for this scroll ID
        };
2
        let layer = self.layers.get_mut(&layer_id).unwrap();
2
        let old_offset = layer.scroll_offset;
2
        let dx = new_offset.0 - old_offset.0;
2
        let dy = new_offset.1 - old_offset.1;
2
        if dx.abs() < 0.5 && dy.abs() < 0.5 {
1
            return Ok(());
1
        }
        // Shift pixels
1
        let px_dx = (dx * dpi_factor).round() as i32;
1
        let px_dy = (dy * dpi_factor).round() as i32;
1
        shift_pixbuf(&mut layer.pixbuf, px_dx, px_dy);
        // Compute exposed strips and re-render them.
        // Diagonal scroll produces 2 rects (one vertical strip + one horizontal strip).
1
        let exposed = compute_exposed_rects(&layer.bounds, dx, dy);
2
        for exposed_rect in exposed {
1
            layer.damage.push(exposed_rect);
1
        }
1
        layer.scroll_offset = new_offset;
1
        layer.composite_dirty = true;
        // Re-render damaged regions
1
        let range = layer.display_list_range;
1
        let bounds = layer.bounds;
1
        let offset_x = bounds.origin.x;
1
        let offset_y = bounds.origin.y;
        // Child-layer ranges to skip (rendered separately) — same as render_layers.
1
        let child_ranges: Vec<(usize, usize)> = self
1
            .layers
1
            .get(&layer_id)
1
            .map(|l| {
1
                l.children
1
                    .iter()
1
                    .filter_map(|cid| self.layers.get(cid).map(|c| c.display_list_range))
1
                    .collect()
1
            })
1
            .unwrap_or_default();
        // Scroll fast-path: VirtualView content (separate child DOMs) isn't
        // re-composited here — an empty state suffices (VirtualViews inside a
        // scrolling region are an edge case; the next full repaint composites them).
1
        let empty_rs = CpuRenderState::new(ScrollOffsetMap::new());
1
        render_display_list_range(
1
            display_list,
1
            &mut self.layers.get_mut(&layer_id).unwrap().pixbuf,
1
            range.0,
1
            range.1.min(display_list.items.len()),
1
            &child_ranges,
1
            offset_x,
1
            offset_y,
1
            dpi_factor,
1
            renderer_resources,
1
            font_manager,
1
            glyph_cache,
1
            &empty_rs,
        )?;
1
        Ok(())
3
    }
}
impl Layer {
363
    fn new(id: LayerId, bounds: LogicalRect, pixel_width: u32, pixel_height: u32) -> Self {
        Self {
363
            id,
363
            pixbuf: AzulPixmap::new(pixel_width.max(1), pixel_height.max(1)).unwrap_or_else(|| {
                AzulPixmap {
                    data: vec![0; 4].into(),
                    width: 1,
                    height: 1,
                }
            }),
363
            bounds,
363
            damage: Vec::new(),
363
            children: Vec::new(),
363
            scroll_offset: (0.0, 0.0),
363
            static_clip: None,
            opacity: 1.0,
363
            filters: Vec::new(),
            is_backdrop_filter: false,
363
            transform: TransAffine::new(),
363
            perspective_row: [0.0, 0.0, 1.0],
363
            display_list_range: (0, 0),
363
            scroll_id: None,
            composite_dirty: true,
        }
363
    }
}
// ============================================================================
// Layer helper types and functions
// ============================================================================
/// Which Push/Pop pair to match.
#[derive(Clone, Copy)]
enum MatchKind {
    ScrollFrame,
    Opacity,
    Filter,
    BackdropFilter,
    ReferenceFrame,
}
/// Find the matching Pop for a given Push at index `start`.
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant
                                  // (or cross-type bindings that can't merge)
187
fn find_matching_pop(items: &[DisplayListItem], start: usize, kind: MatchKind) -> usize {
187
    let mut depth = 1u32;
1056
    for (i, item) in items.iter().enumerate().skip(start + 1) {
1056
        match (item, kind) {
6
            (DisplayListItem::PushScrollFrame { .. }, MatchKind::ScrollFrame) => depth += 1,
            (DisplayListItem::PopScrollFrame, MatchKind::ScrollFrame) => {
112
                depth -= 1;
112
                if depth == 0 {
106
                    return i;
6
                }
            }
            (DisplayListItem::PushOpacity { .. }, MatchKind::Opacity) => depth += 1,
            (DisplayListItem::PopOpacity, MatchKind::Opacity) => {
14
                depth -= 1;
14
                if depth == 0 {
14
                    return i;
                }
            }
            (DisplayListItem::PushFilter { .. }, MatchKind::Filter) => depth += 1,
            (DisplayListItem::PopFilter, MatchKind::Filter) => {
6
                depth -= 1;
6
                if depth == 0 {
6
                    return i;
                }
            }
            (DisplayListItem::PushBackdropFilter { .. }, MatchKind::BackdropFilter) => depth += 1,
            (DisplayListItem::PopBackdropFilter, MatchKind::BackdropFilter) => {
2
                depth -= 1;
2
                if depth == 0 {
2
                    return i;
                }
            }
21
            (DisplayListItem::PushReferenceFrame { .. }, MatchKind::ReferenceFrame) => depth += 1,
            (DisplayListItem::PopReferenceFrame, MatchKind::ReferenceFrame) => {
75
                depth -= 1;
75
                if depth == 0 {
54
                    return i;
21
                }
            }
820
            _ => {}
        }
    }
5
    items.len()
187
}
/// Compute exposed rectangles after a scroll of (dx, dy) in logical coords.
/// Returns 0, 1, or 2 rects: a vertical strip (top/bottom) and/or a horizontal
/// strip (left/right). Diagonal scrolling produces both strips.
8
fn compute_exposed_rects(bounds: &LogicalRect, dx: f32, dy: f32) -> Vec<LogicalRect> {
8
    let w = bounds.size.width;
8
    let h = bounds.size.height;
8
    let mut rects = Vec::new();
    // Vertical exposed strip (full width, covers top or bottom edge)
8
    if dy.abs() > 0.5 {
5
        let strip = if dy > 0.0 {
            // Scrolled down — top strip exposed
4
            LogicalRect {
4
                origin: LogicalPosition {
4
                    x: bounds.origin.x,
4
                    y: bounds.origin.y,
4
                },
4
                size: LogicalSize {
4
                    width: w,
4
                    height: dy.min(h),
4
                },
4
            }
        } else {
            // Scrolled up — bottom strip exposed
1
            LogicalRect {
1
                origin: LogicalPosition {
1
                    x: bounds.origin.x,
1
                    y: bounds.origin.y + h + dy,
1
                },
1
                size: LogicalSize {
1
                    width: w,
1
                    height: (-dy).min(h),
1
                },
1
            }
        };
5
        rects.push(strip);
3
    }
    // Horizontal exposed strip (full height, covers left or right edge)
8
    if dx.abs() > 0.5 {
1
        let strip = if dx > 0.0 {
            LogicalRect {
                origin: LogicalPosition {
                    x: bounds.origin.x,
                    y: bounds.origin.y,
                },
                size: LogicalSize {
                    width: dx.min(w),
                    height: h,
                },
            }
        } else {
1
            LogicalRect {
1
                origin: LogicalPosition {
1
                    x: bounds.origin.x + w + dx,
1
                    y: bounds.origin.y,
1
                },
1
                size: LogicalSize {
1
                    width: (-dx).min(w),
1
                    height: h,
1
                },
1
            }
        };
1
        rects.push(strip);
7
    }
8
    rects
8
}
/// Scroll a frame's clip region by *moving the pixels already on screen* and
/// return the newly-exposed strip(s) (logical coords) that still need painting.
///
/// This is the thin-strip optimisation for scrolling: instead of repainting the
/// whole `clip_bounds` viewport every frame, we `memmove` the pixels that are
/// still visible and only re-rasterise the strip that scrolled into view. For a
/// 30px scroll of a 200×100 viewport that turns ~20k painted px into ~6k.
///
/// Sign convention is the renderer's, NOT the legacy `compute_exposed_rects`:
/// `render_single_item`/`scroll_rect` draw a content item at `position - offset`,
/// so a *positive* `delta` (the user scrolled further down/right) moves on-screen
/// content UP/LEFT. We therefore move the existing pixels UP/LEFT and expose a
/// strip at the trailing (bottom/right) edge. `compute_exposed_rects` assumed the
/// inverse and never matched the renderer — it and `scroll_layer` are dead code.
///
/// Only pixels strictly inside the (clamped) clip rectangle are moved, so the
/// scrollbar, the parent background and sibling content outside the frame are
/// left untouched. Diagonal scroll (both axes in one frame — mobile pan) is
/// handled as TWO strips: the vertical move + horizontal move are separable 1-D
/// passes, so the net effect is a 2-D translation and the exposed region is an
/// L-shape (a full-width top/bottom strip + a full-height left/right strip).
/// The two strips overlap in one corner; that corner is simply repainted twice,
/// which is correct (the caller clears then renders each item once).
///
/// Returns an empty vec when nothing moved, or `[clip_bounds]` when the shift is
/// large enough that the whole viewport is exposed (caller repaints in full).
///
/// NOTE: the move copies *composited* pixels, so a scroll frame whose content is
/// not opaque over its clip can drag whatever showed through. Real scroll
/// containers paint an opaque background or fully cover their box, so this is a
/// known, documented limitation rather than a correctness bug for the common case.
#[allow(
    clippy::cast_possible_truncation,
    clippy::cast_possible_wrap,
    clippy::cast_precision_loss
)] // bounded pixel/coord/colour/glyph cast
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
38
pub fn scroll_shift_region(
38
    pixmap: &mut AzulPixmap,
38
    clip_bounds: &LogicalRect,
38
    delta: (f32, f32),
38
    new_offset: (f32, f32),
38
    dpi_factor: f32,
38
) -> Vec<LogicalRect> {
38
    scroll_shift_region_impl(
38
        pixmap,
38
        clip_bounds,
38
        delta,
38
        new_offset,
38
        dpi_factor,
        false,
        false,
    )
38
}
/// [`scroll_shift_region`] for a NATIVE target in POOL byte order (#32
/// ARGB8888 commit-swizzle pools): the moved pixels came from a COMMITTED
/// slot and are B,G,R,A; the commit swizzle converts the whole presented
/// clip, so the moved block is converted back to renderer order here —
/// otherwise moved pixels get double-swizzled and scrolled content paints
/// with R and B swapped on the glass.
pub fn scroll_shift_region_pool_order(
    pixmap: &mut AzulPixmap,
    clip_bounds: &LogicalRect,
    delta: (f32, f32),
    new_offset: (f32, f32),
    dpi_factor: f32,
) -> Vec<LogicalRect> {
    scroll_shift_region_impl(
        pixmap,
        clip_bounds,
        delta,
        new_offset,
        dpi_factor,
        false,
        true,
    )
}
/// [`scroll_shift_region`] with EXACT strips (no 1-px over-cover). The
/// over-cover guards fractional-dpi seams, but for the round-3 patch blit
/// (gated on INTEGRAL physical deltas) it reaches INTO the blit
/// destination — clearing and re-blending one column of carried LCD text,
/// which can never byte-match an unclipped render (the colorimetric blend
/// reads neighbours). Exact strips cover exactly the vacated pixels.
3
pub fn scroll_shift_region_exact(
3
    pixmap: &mut AzulPixmap,
3
    clip_bounds: &LogicalRect,
3
    delta: (f32, f32),
3
    new_offset: (f32, f32),
3
    dpi_factor: f32,
3
) -> Vec<LogicalRect> {
3
    scroll_shift_region_impl(
3
        pixmap,
3
        clip_bounds,
3
        delta,
3
        new_offset,
3
        dpi_factor,
        true,
        false,
    )
3
}
/// [`scroll_shift_region_exact`] for a pool-order native target — see
/// [`scroll_shift_region_pool_order`].
1
pub fn scroll_shift_region_exact_pool_order(
1
    pixmap: &mut AzulPixmap,
1
    clip_bounds: &LogicalRect,
1
    delta: (f32, f32),
1
    new_offset: (f32, f32),
1
    dpi_factor: f32,
1
) -> Vec<LogicalRect> {
1
    scroll_shift_region_impl(
1
        pixmap,
1
        clip_bounds,
1
        delta,
1
        new_offset,
1
        dpi_factor,
        true,
        true,
    )
1
}
#[allow(clippy::too_many_lines)]
42
fn scroll_shift_region_impl(
42
    pixmap: &mut AzulPixmap,
42
    clip_bounds: &LogicalRect,
42
    delta: (f32, f32),
42
    new_offset: (f32, f32),
42
    dpi_factor: f32,
42
    exact_strips: bool,
42
    unswizzle_rb_moved: bool,
42
) -> Vec<LogicalRect> {
    // The "just move the pixels" cost of a scroll frame, made visible as a
    // phase: this memmove inside OUR pixmap (plus the strip raster the
    // caller does after) IS the scroll fast path on the CPU backend - there
    // is no OS-compositor layer move below it.
42
    let _p = crate::probe::Probe::span("scroll_shift_memmove");
    // Physical shift = difference of the ROUNDED offsets, not the rounded
    // difference. Rounding each frame's delta independently accumulates up to
    // 0.5px of error per step at fractional dpi (the moved block drifts away
    // from the freshly-rasterised strips, showing internal seams); anchoring
    // both ends to the absolute offset keeps the cumulative error ≤ 1px
    // forever: round(new·dpi) − round(prev·dpi) telescopes across frames.
42
    let prev_offset = (new_offset.0 - delta.0, new_offset.1 - delta.1);
42
    let px_dx =
42
        (new_offset.0 * dpi_factor).round() as i32 - (prev_offset.0 * dpi_factor).round() as i32;
42
    let px_dy =
42
        (new_offset.1 * dpi_factor).round() as i32 - (prev_offset.1 * dpi_factor).round() as i32;
    // Nothing actually moved (sub-pixel jitter rounds to zero).
42
    if px_dx == 0 && px_dy == 0 {
4
        return Vec::new();
38
    }
38
    let pw = pixmap.width() as i32;
38
    let ph = pixmap.height() as i32;
    // Clip rectangle in physical pixels, clamped to the pixmap.
38
    let cx0 = ((clip_bounds.origin.x * dpi_factor).floor() as i32).clamp(0, pw);
38
    let cy0 = ((clip_bounds.origin.y * dpi_factor).floor() as i32).clamp(0, ph);
38
    let cx1 =
38
        (((clip_bounds.origin.x + clip_bounds.size.width) * dpi_factor).ceil() as i32).clamp(0, pw);
38
    let cy1 = (((clip_bounds.origin.y + clip_bounds.size.height) * dpi_factor).ceil() as i32)
38
        .clamp(0, ph);
38
    let region_w = cx1 - cx0;
38
    let region_h = cy1 - cy0;
38
    if region_w <= 0 || region_h <= 0 {
2
        return Vec::new();
36
    }
    // Shift exceeds the region — every pixel is exposed, so skip the memmove and
    // let the caller repaint the whole clip.
36
    if px_dx.abs() >= region_w || px_dy.abs() >= region_h {
16
        return vec![*clip_bounds];
20
    }
    // Dispatch to a specialised mover. The common single-axis cases get a tight
    // 1-D pass; diagonal pan gets a SINGLE-pass 2-D move (each row copied once
    // from its diagonally-offset source) instead of two sequential full passes —
    // half the memory traffic. (no-op is already handled by the early return.)
20
    let stride_px = pw;
20
    let data = pixmap.data_mut();
20
    match (px_dx != 0, px_dy != 0) {
9
        (false, true) => shift_vertical_1d(data, stride_px, cx0, cy0, cx1, cy1, px_dy),
10
        (true, false) => shift_horizontal_1d(data, stride_px, cx0, cy0, cx1, cy1, px_dx),
1
        (true, true) => shift_diagonal_2d(data, stride_px, cx0, cy0, cx1, cy1, px_dx, px_dy),
        (false, false) => {}
    }
    // #32 pool-order targets: convert the shifted region back to renderer
    // byte order (the moved pixels are committed B,G,R,A; the commit swizzle
    // will re-convert the whole presented clip). The exposed strips are
    // repainted fresh right after this returns, so including them here is
    // harmless — the swizzled bytes are overwritten.
20
    if unswizzle_rb_moved {
1
        if std::env::var("AZ_BB_DEBUG").is_ok() {
            eprintln!(
                "[bb] UNSWIZZLE clip px=({cx0},{cy0})..({cx1},{cy1}) delta=({px_dx},{px_dy})"
            );
1
        }
32
        for y in cy0..cy1 {
32
            let row = (y * stride_px) as usize;
1024
            for x in cx0..cx1 {
1024
                let o = (row + x as usize) * 4;
1024
                if o + 4 <= data.len() {
1024
                    data.swap(o, o + 2);
1024
                }
            }
        }
19
    }
    // Exposed strip(s) in LOGICAL coords. Over-cover the moving edge by one
    // physical pixel so dpi rounding never leaves a 1px white seam between the
    // moved block and the freshly-painted strip. One strip per moved axis, so
    // diagonal pan yields two (an L-shape, overlapping in one corner).
    //
    // Strips are derived from the CLAMPED region (`cx0..cx1`/`cy0..cy1`, the
    // pixels the memmove actually touched), not the raw `clip_bounds`. When
    // the clip extends past the pixmap (container taller than the window),
    // the raw clip's trailing edge is off-screen — a strip placed there
    // clamps to nothing, nothing repaints, and the rows at the WINDOW edge
    // keep their pre-shift content: a stale duplicated band that gets
    // re-dragged on every subsequent scroll.
20
    let cbx = cx0 as f32 / dpi_factor;
20
    let cby = cy0 as f32 / dpi_factor;
20
    let cbw = (cx1 - cx0) as f32 / dpi_factor;
20
    let cbh = (cy1 - cy0) as f32 / dpi_factor;
20
    let mut exposed = Vec::new();
20
    if px_dy != 0 {
10
        let over = if exact_strips { 0.0 } else { 1.0 };
10
        let h_logical = (px_dy.abs() as f32 + over) / dpi_factor;
10
        let h = h_logical.min(cbh);
10
        let y = if px_dy > 0 {
            // bottom strip exposed
10
            cby + cbh - h
        } else {
            // top strip exposed
            cby
        };
10
        exposed.push(LogicalRect {
10
            origin: LogicalPosition { x: cbx, y },
10
            size: LogicalSize {
10
                width: cbw,
10
                height: h,
10
            },
10
        });
10
    }
20
    if px_dx != 0 {
11
        let over = if exact_strips { 0.0 } else { 1.0 };
11
        let w_logical = (px_dx.abs() as f32 + over) / dpi_factor;
11
        let w = w_logical.min(cbw);
11
        let x = if px_dx > 0 {
            // right strip exposed
11
            cbx + cbw - w
        } else {
            // left strip exposed
            cbx
        };
11
        exposed.push(LogicalRect {
11
            origin: LogicalPosition { x, y: cby },
11
            size: LogicalSize {
11
                width: w,
11
                height: cbh,
11
            },
11
        });
9
    }
20
    exposed
42
}
// --- scroll_shift_region movers -------------------------------------------
// All three operate in PHYSICAL pixels on the raw RGBA buffer. `cx0..cx1` /
// `cy0..cy1` is the clamped clip region; `stride_px` is the buffer width in
// pixels. They only ever touch bytes inside the clip rectangle. Sign of the
// `px_*` deltas follows the renderer: positive = content moves up/left, so the
// exposed strip is the trailing (bottom/right) edge.
/// Single-axis VERTICAL move: shift whole rows up (`px_dy>0`) or down (`px_dy`<0).
/// Iteration order is chosen so a row read as a source is never already
/// overwritten (src and dst row SETS overlap, so order matters).
#[inline]
#[allow(clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
14
fn shift_vertical_1d(
14
    data: &mut [u8],
14
    stride_px: i32,
14
    cx0: i32,
14
    cy0: i32,
14
    cx1: i32,
14
    cy1: i32,
14
    px_dy: i32,
14
) {
14
    let col_bytes = ((cx1 - cx0) * 4) as usize;
824
    let row_off = |row: i32| ((row * stride_px + cx0) as usize) * 4;
14
    if px_dy > 0 {
        // Content up: dst = src - px_dy (dst < src) → iterate top→bottom.
399
        for dst in cy0..(cy1 - px_dy) {
399
            let s = row_off(dst + px_dy);
399
            data.copy_within(s..s + col_bytes, row_off(dst));
399
        }
    } else {
3
        let amt = -px_dy;
        // Content down: dst = src + amt (dst > src) → iterate bottom→top.
13
        for dst in ((cy0 + amt)..cy1).rev() {
13
            let s = row_off(dst - amt);
13
            data.copy_within(s..s + col_bytes, row_off(dst));
13
        }
    }
14
}
/// 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
14
fn shift_horizontal_1d(
14
    data: &mut [u8],
14
    stride_px: i32,
14
    cx0: i32,
14
    cy0: i32,
14
    cx1: i32,
14
    cy1: i32,
14
    px_dx: i32,
14
) {
14
    let col_bytes = ((cx1 - cx0) * 4) as usize;
1026
    let row_off = |row: i32| ((row * stride_px + cx0) as usize) * 4;
14
    if px_dx > 0 {
12
        let shift = (px_dx * 4) as usize;
1010
        for row in cy0..cy1 {
1010
            let left = row_off(row);
1010
            data.copy_within(left + shift..left + col_bytes, left);
1010
        }
    } else {
2
        let shift = ((-px_dx) * 4) as usize;
16
        for row in cy0..cy1 {
16
            let left = row_off(row);
16
            data.copy_within(left..left + col_bytes - shift, left + shift);
16
        }
    }
14
}
/// 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
4
fn shift_diagonal_2d(
4
    data: &mut [u8],
4
    stride_px: i32,
4
    cx0: i32,
4
    cy0: i32,
4
    cx1: i32,
4
    cy1: i32,
4
    px_dx: i32,
4
    px_dy: i32,
4
) {
4
    let span_cols = (cx1 - cx0) - px_dx.abs();
4
    if span_cols <= 0 {
1
        return; // horizontal shift covers the whole region — nothing to keep
3
    }
3
    let len = (span_cols * 4) as usize;
    // Column starts for the kept span: content-left reads from the right, etc.
3
    let (src_col, dst_col) = if px_dx > 0 {
2
        (cx0 + px_dx, cx0)
    } else {
1
        (cx0, cx0 - px_dx)
    };
80
    let src_byte = |row: i32| ((row * stride_px + src_col) as usize) * 4;
80
    let dst_byte = |row: i32| ((row * stride_px + dst_col) as usize) * 4;
3
    if px_dy > 0 {
        // Content up: src row = dst + px_dy (below) → iterate top→bottom.
75
        for dst in cy0..(cy1 - px_dy) {
75
            let s = src_byte(dst + px_dy);
75
            data.copy_within(s..s + len, dst_byte(dst));
75
        }
    } else {
1
        let amt = -px_dy;
        // Content down: src row = dst - amt (above) → iterate bottom→top.
5
        for dst in ((cy0 + amt)..cy1).rev() {
5
            let s = src_byte(dst - amt);
5
            data.copy_within(s..s + len, dst_byte(dst));
5
        }
    }
4
}
/// 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)
#[must_use]
36
pub fn scroll_fast_path_eligible(
36
    display_list: &DisplayList,
36
    scroll_id: LocalScrollId,
36
    clip_bounds: &LogicalRect,
36
    scroll_offset: (f32, f32),
36
    prev_offset: (f32, f32),
36
) -> bool {
36
    let _p = crate::probe::Probe::span("scroll_fastpath_check");
    // Locate the frame's content range [start+1, end).
117
    let start = display_list.items.iter().position(|it| {
36
        matches!(it, DisplayListItem::PushScrollFrame { scroll_id: sid, .. } if *sid == scroll_id)
117
    });
36
    let Some(start) = start else {
1
        return true; // no frame for this id → nothing to shift
    };
35
    let end = find_matching_pop(&display_list.items, start, MatchKind::ScrollFrame)
35
        .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.
35
    let mut depth = 0i32;
94
    for it in &display_list.items[..start] {
82
        match it {
1
            DisplayListItem::PushScrollFrame { .. } => depth += 1,
            DisplayListItem::PopScrollFrame => depth -= 1,
81
            _ => {}
        }
    }
35
    if depth > 0 {
1
        return false;
34
    }
    // 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.
62
    let covered_at = |off: (f32, f32)| {
62
        let fills: Vec<LogicalRect> = display_list.items[start + 1..end]
62
            .iter()
62
            .filter_map(opaque_fill_rect)
62
            .map(|r| LogicalRect {
262
                origin: LogicalPosition {
262
                    x: r.origin.x - off.0,
262
                    y: r.origin.y - off.1,
262
                },
262
                size: r.size,
262
            })
62
            .collect();
62
        rect_covered_by(clip_bounds, &fills)
62
    };
34
    if covered_at(scroll_offset) && covered_at(prev_offset) {
27
        return true;
7
    }
    // (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.
7
    let clip_area = (clip_bounds.size.width * clip_bounds.size.height).max(1.0);
7
    let mut backdrop_fills: Vec<LogicalRect> = Vec::new();
7
    let mut backdrop_color: Option<ColorU> = None;
7
    for it in &display_list.items[..start] {
6
        if it.is_state_management() {
            continue;
6
        }
6
        let b = match it.bounds() {
6
            Some(b) if rects_overlap_or_adjacent(&b, clip_bounds, 0.0) => b,
            _ => continue,
        };
        // Area of this item within the clip; ignore negligible coverage.
6
        let ix = b.origin.x.max(clip_bounds.origin.x);
6
        let iy = b.origin.y.max(clip_bounds.origin.y);
6
        let ix1 = (b.origin.x + b.size.width).min(clip_bounds.origin.x + clip_bounds.size.width);
6
        let iy1 = (b.origin.y + b.size.height).min(clip_bounds.origin.y + clip_bounds.size.height);
6
        let isect_area = ((ix1 - ix).max(0.0)) * ((iy1 - iy).max(0.0));
6
        if isect_area < clip_area * 0.10 {
            continue; // negligible — thin border / small decoration
6
        }
6
        match it {
            DisplayListItem::Rect {
6
                color,
6
                border_radius,
                ..
6
            } if color.a == 255 && border_radius.is_zero() => {
1
                match backdrop_color {
5
                    None => backdrop_color = Some(*color),
1
                    Some(prev) if prev == *color => {}
1
                    Some(_) => return false, // ≥2 distinct backdrop colours → visible
                }
5
                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
        }
    }
6
    if backdrop_fills.is_empty() {
2
        return true; // only the clear (or negligible decoration) behind
4
    }
    // Single flat colour: safe only if it fills the whole clip (else its edge
    // against the clear would drag visibly).
4
    rect_covered_by(clip_bounds, &backdrop_fills)
36
}
/// 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]
16769
pub fn gpu_value_damage(
16769
    display_list: &DisplayList,
16769
    old_transforms: &HashMap<usize, azul_core::transform::ComputedTransform3D>,
16769
    old_opacities: &HashMap<usize, f32>,
16769
    new_transforms: &HashMap<usize, azul_core::transform::ComputedTransform3D>,
16769
    new_opacities: &HashMap<usize, f32>,
16769
) -> GpuValueDamage {
    use std::collections::HashSet;
16769
    let mut changed_t: HashSet<usize> = HashSet::new();
33314
    for (k, v) in new_transforms {
16545
        if old_transforms.get(k) != Some(v) {
88
            changed_t.insert(*k);
16458
        }
    }
16769
    for k in old_transforms.keys() {
16520
        if !new_transforms.contains_key(k) {
10
            changed_t.insert(*k);
16510
        }
    }
16769
    let mut changed_o: HashSet<usize> = HashSet::new();
33304
    for (k, v) in new_opacities {
16535
        if old_opacities.get(k) != Some(v) {
46
            changed_o.insert(*k);
16490
        }
    }
16769
    for k in old_opacities.keys() {
16510
        if !new_opacities.contains_key(k) {
10
            changed_o.insert(*k);
16500
        }
    }
16769
    let mut out = GpuValueDamage::default();
16769
    if changed_t.is_empty() && changed_o.is_empty() {
16682
        return out;
87
    }
    // A moved reference frame damages its CONTENT at both the old and the
    // new position. The content extent is the union of the visual bounds of
    // everything down to the matching Pop (nested frames' pixels ride along),
    // transformed by each matrix ABOUT THE FRAME ORIGIN — the same convention
    // the compositor renders with. Only a non-affine matrix (perspective) is
    // genuinely unknowable and keeps the full-repaint fallback; the previous
    // blanket needs_full made EVERY spring/move tick a full-frame repaint.
124
    fn affine_rect_about(
124
        m: &azul_core::transform::ComputedTransform3D,
124
        origin: LogicalPosition,
124
        r: LogicalRect,
124
    ) -> Option<LogicalRect> {
124
        let mm = &m.m;
124
        let affine = mm[0][2] == 0.0
124
            && mm[0][3] == 0.0
124
            && mm[1][2] == 0.0
124
            && mm[1][3] == 0.0
124
            && (mm[3][3] - 1.0).abs() < f32::EPSILON;
124
        if !affine {
            return None;
124
        }
124
        let (mut min_x, mut min_y) = (f32::MAX, f32::MAX);
124
        let (mut max_x, mut max_y) = (f32::MIN, f32::MIN);
496
        for (cx, cy) in [
124
            (r.origin.x, r.origin.y),
124
            (r.origin.x + r.size.width, r.origin.y),
124
            (r.origin.x, r.origin.y + r.size.height),
124
            (r.origin.x + r.size.width, r.origin.y + r.size.height),
496
        ] {
496
            let (px, py) = (cx - origin.x, cy - origin.y);
496
            let x = px * mm[0][0] + py * mm[1][0] + mm[3][0] + origin.x;
496
            let y = px * mm[0][1] + py * mm[1][1] + mm[3][1] + origin.y;
496
            min_x = min_x.min(x);
496
            min_y = min_y.min(y);
496
            max_x = max_x.max(x);
496
            max_y = max_y.max(y);
496
        }
124
        Some(LogicalRect::new(
124
            LogicalPosition::new(min_x, min_y),
124
            LogicalSize::new(max_x - min_x, max_y - min_y),
124
        ))
124
    }
87
    let identity = azul_core::transform::ComputedTransform3D {
87
        m: [
87
            [1.0, 0.0, 0.0, 0.0],
87
            [0.0, 1.0, 0.0, 0.0],
87
            [0.0, 0.0, 1.0, 0.0],
87
            [0.0, 0.0, 0.0, 1.0],
87
        ],
87
    };
87
    let items = &display_list.items;
1369
    for (idx, item) in items.iter().enumerate() {
70
        match item {
            DisplayListItem::PushReferenceFrame {
70
                transform_key,
70
                bounds,
                ..
70
            } if changed_t.contains(&transform_key.id) => {
                // Content extent: union to the matching Pop.
62
                let mut depth = 0usize;
62
                let mut content: Option<LogicalRect> = None;
565
                for it in &items[idx..] {
565
                    match it {
83
                        DisplayListItem::PushReferenceFrame { .. } => depth += 1,
                        DisplayListItem::PopReferenceFrame => {
83
                            depth = depth.saturating_sub(1);
83
                            if depth == 0 {
62
                                break;
21
                            }
                        }
399
                        _ => {}
                    }
503
                    if let Some(b) = it.visual_bounds() {
481
                        content = Some(content.map_or(b, |c| {
419
                            let x0 = c.origin.x.min(b.origin.x);
419
                            let y0 = c.origin.y.min(b.origin.y);
419
                            let x1 = (c.origin.x + c.size.width).max(b.origin.x + b.size.width);
419
                            let y1 = (c.origin.y + c.size.height).max(b.origin.y + b.size.height);
419
                            LogicalRect::new(
419
                                LogicalPosition::new(x0, y0),
419
                                LogicalSize::new(x1 - x0, y1 - y0),
                            )
419
                        }));
22
                    }
                }
62
                let content = content.unwrap_or_else(|| *bounds.inner());
62
                let old_m = old_transforms.get(&transform_key.id).unwrap_or(&identity);
62
                let new_m = new_transforms.get(&transform_key.id).unwrap_or(&identity);
                match (
62
                    affine_rect_about(old_m, bounds.inner().origin, content),
62
                    affine_rect_about(new_m, bounds.inner().origin, content),
                ) {
62
                    (Some(a), Some(b)) => {
62
                        out.rects.push(a);
62
                        out.rects.push(b);
62
                    }
                    _ => out.needs_full = true,
                }
            }
35
            DisplayListItem::ScrollBarStyled { info } => {
35
                let thumb_moved = info
35
                    .thumb_transform_key
35
                    .is_some_and(|k| changed_t.contains(&k.id));
35
                let faded = info.opacity_key.is_some_and(|k| changed_o.contains(&k.id));
35
                if thumb_moved || faded {
35
                    // The whole bar bounds cover the thumb's old AND new
35
                    // position — precise and cheap.
35
                    out.rects.push(info.bounds.0);
35
                }
            }
            DisplayListItem::PushOpacity {
                bounds,
                opacity_key: Some(k),
                ..
            } => {
                // A faded group repaints inside its own bounds — precise,
                // unlike a moved reference frame whose content extent is
                // unknowable from the item.
                if changed_o.contains(&k.id) {
                    out.rects.push(*bounds.inner());
                }
            }
1272
            _ => {}
        }
    }
    // A changed key bound to nothing in THIS display list (another DOM's
    // scrollbar, a stale key) is ignored — it cannot affect these pixels.
87
    out
16769
}
/// Which scroll frames moved since the previous frame, with their clips
/// PROJECTED INTO VIEWPORT SPACE — the one shared collector for the shell
/// and the e2e twin (they used to carry identical copies).
///
/// `PushScrollFrame.clip_bounds` is the frame's clip in its PARENT's content
/// space (`get_paint_rect` applies no scroll). Damage rects are consumed in
/// viewport space, so a frame NESTED inside a scrolled ancestor — the
/// `TextInput`'s value `<p>` inside a scrolled page column — had its fallback
/// "repaint the whole clip" rect land `outer offset` pixels away from the
/// field: the field kept its old horizontal offset while a correctly placed
/// caret strip beside it rendered at the new one (the seam, 2026-08-31).
/// The walk keeps an offset stack of the enclosing frames' CURRENT offsets
/// and subtracts it, so top-level frames are unchanged and nested ones land
/// where they are on screen. Returns `(scroll_id, clip, delta, offset)`.
#[must_use]
16783
pub fn collect_scroll_shifts(
16783
    display_list: &DisplayList,
16783
    scroll_offsets: &ScrollOffsetMap,
16783
    previous_scroll_offsets: &ScrollOffsetMap,
16783
    dpi_factor: f32,
16783
) -> Vec<(LocalScrollId, LogicalRect, (f32, f32), (f32, f32))> {
16793
    let moved = |id: &LocalScrollId, offset: &(f32, f32)| -> Option<(f32, f32)> {
16484
        let prev = previous_scroll_offsets
16484
            .get(id)
16484
            .copied()
16484
            .unwrap_or((0.0, 0.0));
16484
        let delta = (offset.0 - prev.0, offset.1 - prev.1);
        // Threshold in PHYSICAL pixels: a delta that moves the content by at
        // least half a device pixel must repaint (at dpi=2 a 0.3-logical
        // wheel step is already a visible 0.6-device-px move).
16484
        ((delta.0 * dpi_factor).abs() > 0.5 || (delta.1 * dpi_factor).abs() > 0.5).then_some(delta)
16484
    };
16783
    let mut out = Vec::new();
16783
    let mut stack: Vec<(f32, f32)> = Vec::new();
16783
    let mut acc = (0.0f32, 0.0f32);
316825
    for item in &display_list.items {
300042
        match item {
            DisplayListItem::PushScrollFrame {
16516
                clip_bounds,
16516
                scroll_id,
                ..
            } => {
16516
                let offset = scroll_offsets.get(scroll_id).copied().unwrap_or((0.0, 0.0));
16516
                if let Some(delta) = scroll_offsets
16516
                    .get(scroll_id)
16516
                    .and_then(|o| moved(scroll_id, o))
34
                {
34
                    let mut clip = *clip_bounds.inner();
34
                    clip.origin.x -= acc.0;
34
                    clip.origin.y -= acc.1;
34
                    out.push((*scroll_id, clip, delta, offset));
16482
                }
16516
                stack.push(offset);
16516
                acc.0 += offset.0;
16516
                acc.1 += offset.1;
            }
            DisplayListItem::PopScrollFrame => {
16516
                if let Some(off) = stack.pop() {
16516
                    acc.0 -= off.0;
16516
                    acc.1 -= off.1;
16516
                }
            }
267010
            _ => {}
        }
    }
16783
    out
16783
}
/// The complete FAST-PATH damage recipe for one scrolled frame, shared by
/// the shell (`headless/mod.rs`) and the e2e twin (`e2e/cpu_backend.rs`) so a
/// damage rule can never land on one side only — the scrollbar snap-back
/// ghost trail (2026-08-29) was exactly such a one-sided fix: the twin
/// repainted the dragged overlay ghost, the shell did not.
///
/// Eligibility is checked at the PREVIOUS offset too (the pixels being
/// dragged were composited there). On the fast path the region is memmoved,
/// the exposed strips become damage, and every overlay composited over the
/// frame (its own scrollbar, an open dropdown) is repainted at BOTH its
/// correct position and where its dragged ghost landed (`origin - delta`) —
/// an overlay that does not span the scroll axis (the vertical scrollbar
/// during a horizontal pan) otherwise keeps a delta-wide stale copy per
/// frame. Ineligible frames repaint their whole clip.
#[derive(Debug)]
pub struct ScrollShiftOutcome {
    /// Rects to re-raster.
    pub damage: Vec<LogicalRect>,
    /// Rects to present even though they were not re-rastered (the whole
    /// moved clip on the fast path).
    pub present_extra: Vec<LogicalRect>,
}
/// See [`ScrollShiftOutcome`]. `pool_order` selects the commit-swizzle mover
/// for native ARGB pools ([`scroll_shift_region_pool_order`]).
#[allow(clippy::too_many_arguments)]
24
pub fn execute_scroll_shift(
24
    pixmap: &mut AzulPixmap,
24
    display_list: &DisplayList,
24
    scroll_id: LocalScrollId,
24
    clip: &LogicalRect,
24
    delta: (f32, f32),
24
    offset: (f32, f32),
24
    dpi_factor: f32,
24
    pool_order: bool,
24
) -> ScrollShiftOutcome {
24
    let mut damage = Vec::new();
24
    let mut present_extra = Vec::new();
24
    let prev_offset = (offset.0 - delta.0, offset.1 - delta.1);
24
    if scroll_fast_path_eligible(display_list, scroll_id, clip, offset, prev_offset) {
24
        let strips = if pool_order {
            scroll_shift_region_pool_order(pixmap, clip, delta, offset, dpi_factor)
        } else {
24
            scroll_shift_region(pixmap, clip, delta, offset, dpi_factor)
        };
        // Empty strips = the delta rounded to ZERO physical pixels: no
        // memmove ran and (pool-order targets) NOTHING was unswizzled. The
        // clip must then stay OUT of present_extra - on the in-place
        // commit-swizzle path an extra rect is not "harmless over-coverage"
        // but a byte swap of pixels nobody wrote: AzWriter's caret blink
        // carried a stationary scroll clip here every frame, and each
        // present TOGGLED the whole document area R<->B (blue UI one frame,
        // brown the next - KDE Wayland session, 2026-08-29). The swizzle
        // contract: present_extra covers exactly what the shifter
        // unswizzled, never more.
24
        let moved = !strips.is_empty();
24
        damage.extend(strips);
24
        if moved {
24
            for g in overlay_rects_after_frame(display_list, scroll_id, clip) {
10
                damage.push(g);
10
                let mut ghost = g;
10
                ghost.origin.x -= delta.0;
10
                ghost.origin.y -= delta.1;
10
                damage.push(ghost);
10
            }
24
            present_extra.push(*clip);
        }
    } else {
        damage.push(*clip);
    }
24
    ScrollShiftOutcome {
24
        damage,
24
        present_extra,
24
    }
24
}
/// 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]
26
pub fn overlay_rects_after_frame(
26
    display_list: &DisplayList,
26
    scroll_id: LocalScrollId,
26
    clip_bounds: &LogicalRect,
26
) -> Vec<LogicalRect> {
26
    let mut out = Vec::new();
99
    let Some(start) = display_list.items.iter().position(|it| {
25
        matches!(it, DisplayListItem::PushScrollFrame { scroll_id: sid, .. } if *sid == scroll_id)
99
    }) else {
1
        return out;
    };
25
    let end = find_matching_pop(&display_list.items, start, MatchKind::ScrollFrame)
25
        .min(display_list.items.len());
25
    let cx1 = clip_bounds.origin.x + clip_bounds.size.width;
25
    let cy1 = clip_bounds.origin.y + clip_bounds.size.height;
80
    for it in &display_list.items[end..] {
80
        if it.is_state_management() {
54
            continue;
26
        }
26
        let Some(b) = it.bounds() else { continue };
        // STRICT overlap: merely touching shares no pixels with the clip and
        // cannot be dragged.
26
        let ix = b.origin.x.max(clip_bounds.origin.x);
26
        let iy = b.origin.y.max(clip_bounds.origin.y);
26
        let ix1 = (b.origin.x + b.size.width).min(cx1);
26
        let iy1 = (b.origin.y + b.size.height).min(cy1);
26
        if ix1 > ix && iy1 > iy {
11
            out.push(LogicalRect {
11
                origin: LogicalPosition { x: ix, y: iy },
11
                size: LogicalSize {
11
                    width: ix1 - ix,
11
                    height: iy1 - iy,
11
                },
11
            });
25
        }
    }
25
    out
26
}
/// If `it` is a fully-opaque, square-cornered rectangle fill, its bounds.
293
fn opaque_fill_rect(it: &DisplayListItem) -> Option<LogicalRect> {
270
    match it {
        DisplayListItem::Rect {
270
            bounds,
270
            color,
270
            border_radius,
270
        } if color.a == 255 && border_radius.is_zero() => Some(*bounds.inner()),
29
        _ => None,
    }
293
}
/// 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
76
fn rect_covered_by(target: &LogicalRect, covers: &[LogicalRect]) -> bool {
76
    if covers.is_empty() {
6
        return false;
70
    }
70
    let step = 4.0_f32;
70
    let x0 = target.origin.x;
70
    let y0 = target.origin.y;
70
    let x1 = x0 + target.size.width;
70
    let y1 = y0 + target.size.height;
70
    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
1565
    while y < y1 {
1503
        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
66790
        while x < x1 {
135930
            let inside = covers.iter().any(|r| {
135930
                x >= r.origin.x
135930
                    && x < r.origin.x + r.size.width
135929
                    && y >= r.origin.y
135927
                    && y < r.origin.y + r.size.height
135930
            });
65295
            if !inside {
8
                return false;
65287
            }
65287
            x += step;
        }
1495
        y += step;
    }
62
    true
76
}
/// 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)
22
fn apply_layer_filters(pixmap: &mut AzulPixmap, filters: &[StyleFilter], dpi_factor: f32) {
44
    for filter in filters {
22
        match filter {
5
            StyleFilter::Blur(blur) => {
5
                let rx = blur
5
                    .width
5
                    .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
5
                    * dpi_factor;
5
                let ry = blur
5
                    .height
5
                    .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
5
                    * dpi_factor;
5
                let radius = f32::midpoint(rx, ry).ceil() as u32;
5
                if radius > 0 {
1
                    let w = pixmap.width;
1
                    let h = pixmap.height;
1
                    let stride = (w * 4) as i32;
1
                    let mut ra = unsafe {
1
                        RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride)
1
                    };
1
                    stack_blur_rgba32(&mut ra, radius, radius);
4
                }
            }
4
            StyleFilter::Opacity(pct) => {
4
                let op = (pct.normalized() * 255.0).clamp(0.0, 255.0) as u32;
16
                for chunk in pixmap.data.chunks_exact_mut(4) {
16
                    chunk[3] = ((u32::from(chunk[3]) * op) / 255) as u8;
16
                }
            }
4
            StyleFilter::Grayscale(pct) => {
4
                let amount = pct.normalized().clamp(0.0, 1.0);
16
                for chunk in pixmap.data.chunks_exact_mut(4) {
16
                    let r = f32::from(chunk[0]);
16
                    let g = f32::from(chunk[1]);
16
                    let b = f32::from(chunk[2]);
16
                    let gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
16
                    chunk[0] = (r + (gray - r) * amount).clamp(0.0, 255.0) as u8;
16
                    chunk[1] = (g + (gray - g) * amount).clamp(0.0, 255.0) as u8;
16
                    chunk[2] = (b + (gray - b) * amount).clamp(0.0, 255.0) as u8;
16
                }
            }
3
            StyleFilter::Brightness(pct) => {
3
                let factor = pct.normalized().max(0.0);
12
                for chunk in pixmap.data.chunks_exact_mut(4) {
12
                    chunk[0] = (f32::from(chunk[0]) * factor).clamp(0.0, 255.0) as u8;
12
                    chunk[1] = (f32::from(chunk[1]) * factor).clamp(0.0, 255.0) as u8;
12
                    chunk[2] = (f32::from(chunk[2]) * factor).clamp(0.0, 255.0) as u8;
12
                }
            }
            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;
                }
            }
3
            StyleFilter::Invert(pct) => {
3
                let amount = pct.normalized().clamp(0.0, 1.0);
5008
                for chunk in pixmap.data.chunks_exact_mut(4) {
5008
                    chunk[0] = (f32::from(chunk[0]) + (255.0 - 2.0 * f32::from(chunk[0])) * amount)
5008
                        .clamp(0.0, 255.0) as u8;
5008
                    chunk[1] = (f32::from(chunk[1]) + (255.0 - 2.0 * f32::from(chunk[1])) * amount)
5008
                        .clamp(0.0, 255.0) as u8;
5008
                    chunk[2] = (f32::from(chunk[2]) + (255.0 - 2.0 * f32::from(chunk[2])) * amount)
5008
                        .clamp(0.0, 255.0) as u8;
5008
                }
            }
            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;
                }
            }
1
            StyleFilter::Saturate(pct) => {
1
                let s = pct.normalized().max(0.0);
4
                for chunk in pixmap.data.chunks_exact_mut(4) {
4
                    let r = f32::from(chunk[0]);
4
                    let g = f32::from(chunk[1]);
4
                    let b = f32::from(chunk[2]);
4
                    let gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
4
                    chunk[0] = (gray + (r - gray) * s).clamp(0.0, 255.0) as u8;
4
                    chunk[1] = (gray + (g - gray) * s).clamp(0.0, 255.0) as u8;
4
                    chunk[2] = (gray + (b - gray) * s).clamp(0.0, 255.0) as u8;
4
                }
            }
1
            StyleFilter::HueRotate(angle) => {
1
                let rad = angle.to_degrees().to_radians();
1
                let cos_a = rad.cos();
1
                let sin_a = rad.sin();
4
                for chunk in pixmap.data.chunks_exact_mut(4) {
4
                    let r = f32::from(chunk[0]);
4
                    let g = f32::from(chunk[1]);
4
                    let b = f32::from(chunk[2]);
4
                    let nr = (0.213 + 0.787 * cos_a - 0.213 * sin_a) * r
4
                        + (0.715 - 0.715 * cos_a - 0.715 * sin_a) * g
4
                        + (0.072 - 0.072 * cos_a + 0.928 * sin_a) * b;
4
                    let ng = (0.213 - 0.213 * cos_a + 0.143 * sin_a) * r
4
                        + (0.715 + 0.285 * cos_a + 0.140 * sin_a) * g
4
                        + (0.072 - 0.072 * cos_a - 0.283 * sin_a) * b;
4
                    let nb = (0.213 - 0.213 * cos_a - 0.787 * sin_a) * r
4
                        + (0.715 - 0.715 * cos_a + 0.715 * sin_a) * g
4
                        + (0.072 + 0.928 * cos_a + 0.072 * sin_a) * b;
4
                    chunk[0] = nr.clamp(0.0, 255.0) as u8;
4
                    chunk[1] = ng.clamp(0.0, 255.0) as u8;
4
                    chunk[2] = nb.clamp(0.0, 255.0) as u8;
4
                }
            }
1
            _ => {} /* Blend, Flood, ColorMatrix, DropShadow, ComponentTransfer, Offset,
                     * Composite not yet implemented */
        }
    }
22
}
/// Render a range of display list items into a layer pixbuf,
/// offsetting coordinates by the layer's origin.
300
fn render_display_list_range(
300
    display_list: &DisplayList,
300
    pixmap: &mut AzulPixmap,
300
    start: usize,
300
    end: usize,
300
    // Index ranges (start..end) that belong to CHILD layers (nested scroll
300
    // frames / opacity / transform groups). Those items render into the child's
300
    // OWN pixbuf, so they must be skipped here — otherwise they're drawn twice
300
    // (once in this layer at absolute coords AND once in the child layer),
300
    // which produced overlapping / ghosted text in overflow:scroll content.
300
    skip_ranges: &[(usize, usize)],
300
    offset_x: f32,
300
    offset_y: f32,
300
    dpi_factor: f32,
300
    renderer_resources: &RendererResources,
300
    font_manager: &FontManager<FontRef>,
300
    glyph_cache: &mut GlyphCache,
300
    render_state: &CpuRenderState,
300
) -> Result<(), String> {
300
    let mut transform_stack = vec![TransAffine::new()];
300
    let mut clip_stack: Vec<Option<AzRect>> = vec![None];
300
    let mut real_clip_stack: Vec<Option<AzRect>> = vec![None];
300
    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.
300
    let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(offset_x, offset_y)];
300
    let mut text_shadow_stack: Vec<azul_css::props::style::box_shadow::StyleBoxShadow> = Vec::new();
3291
    for i in start..end {
        // Skip items rendered by a child layer (see skip_ranges doc above).
3291
        if skip_ranges.iter().any(|(s, e)| i >= *s && i < *e) {
535
            continue;
2756
        }
2756
        let item = &display_list.items[i];
2756
        render_single_item(
2756
            item,
2756
            display_list.uniform_text_bgs.get(i).copied().flatten(),
2756
            pixmap,
2756
            dpi_factor,
2756
            renderer_resources,
2756
            font_manager,
2756
            glyph_cache,
2756
            &mut transform_stack,
2756
            &mut clip_stack,
2756
            &mut real_clip_stack,
2756
            &mut mask_stack,
2756
            &mut scroll_offset_stack,
2756
            &mut text_shadow_stack,
2756
            render_state,
        )?;
    }
300
    Ok(())
300
}
// ============================================================================
// 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.
/// Runtime toggle for the DAMAGE REFINEMENTS (skip non-painting items,
/// symmetric-difference strips for same-origin solid rects / clips).
/// Default ON. They were landed default-OFF because honest (smaller)
/// damage un-masked what looked like a cross-path LCD divergence; the
/// real defect was twofold and is FIXED: (1) the FIR spread in the LCD
/// pixfmts wrote 2 stripes outside the renderer clip, double-blending
/// retained fringe on damage-rect repaints (agg-rust `set_stripe_clip`),
/// and (2) a changed Text item's damage did not cover the 1-device-px
/// fringe overhang (inflated in the diff below). The corpus damage-
/// soundness gate is green with the refinements on; `AZ_DL_DIFF_REFINE=0`
/// remains as the kill-switch.
static DL_DIFF_REFINEMENTS: core::sync::atomic::AtomicU8 = core::sync::atomic::AtomicU8::new(0);
#[must_use]
2992
pub fn dl_diff_refinements_enabled() -> bool {
    use core::sync::atomic::Ordering;
2992
    match DL_DIFF_REFINEMENTS.load(Ordering::Relaxed) {
2970
        1 => true,
        2 => false,
        _ => {
            // Default ON; the env var forces either way ("0"/"off" = off).
22
            let on = match std::env::var("AZ_DL_DIFF_REFINE") {
                Ok(v) => !(v == "0" || v.eq_ignore_ascii_case("off")),
22
                Err(_) => true,
            };
22
            DL_DIFF_REFINEMENTS.store(if on { 1 } else { 2 }, Ordering::Relaxed);
22
            on
        }
    }
2992
}
/// Force the refinements on/off (tests; overrides the env).
pub fn set_dl_diff_refinements(on: bool) {
    use core::sync::atomic::Ordering;
    DL_DIFF_REFINEMENTS.store(if on { 1 } else { 2 }, Ordering::Relaxed);
}
/// Round-3 translate hint: a mismatching item pair whose OLD geometry
/// translated by `delta` equals the NEW one, and whose old bounds sit
/// inside `region_old`, is a MOVE — collected into the returned moved
/// union instead of damage. The caller blits that union by `delta` and
/// repaints only the rest. See `items_equal_translated`.
#[derive(Debug, Clone, Copy)]
pub struct TranslateHint {
    pub delta: (f32, f32),
    pub region_old: LogicalRect,
}
/// Decide whether a layout patch's move may be presented as a BLIT.
///
/// Extracted so the real backend and the e2e harness make the same decision
/// from the same inputs. They did not: `dll/.../headless/mod.rs` had this logic
/// inline and `layout/src/e2e/cpu_backend.rs` had none of it — zero mentions of
/// `TranslateHint`, `dominant_delta` or the already-shifted guard. The harness
/// therefore could not execute the blit path AT ALL, which is where the
/// "typing does not repaint" bug lives: a stale hint blits the previous frame
/// by `dominant_delta`, so the damage rect is right and the pixels under it are
/// the old ones, translated.
///
/// The conditions, all of which must hold:
/// - the dominant delta is INTEGRAL in physical pixels; a fractional blit would change every
///   subpixel phase, so those frames must re-render
/// - it actually moved at least one physical pixel
/// - the previous frame's pixels are trustworthy (`can_reuse_previous_frame`)
/// - this display list has not already been shifted, which is what `already_shifted` carries (a
///   buffers-held retry would shift twice)
#[must_use]
16771
pub fn translate_hint_for_patch(
16771
    last_patch_move: Option<&crate::solver3::display_list::PatchMoveSummary>,
16771
    dpi_factor: f32,
16771
    can_reuse_previous_frame: bool,
16771
    already_shifted: bool,
16771
    any_scroll_nonzero: bool,
16771
) -> Option<(TranslateHint, Vec<LogicalRect>)> {
    // The mover rects/regions come from `compute_patch_move_summary` in
    // CONTENT space - no scroll projection exists on that producer. At zero
    // scroll the spaces coincide; with any active offset a blit would memmove
    // the wrong pixels (garbled bands during a scrolled resize). Until the
    // producer projects through the scroll chain, a scrolled window falls
    // back to plain damage.
16771
    if any_scroll_nonzero {
16429
        return None;
342
    }
342
    let m = last_patch_move?;
6
    let px = m.dominant_delta.x * dpi_factor;
6
    let py = m.dominant_delta.y * dpi_factor;
6
    let integral = (px - px.round()).abs() < 0.001 && (py - py.round()).abs() < 0.001;
6
    let moved_any = px.round().abs() >= 1.0 || py.round().abs() >= 1.0;
6
    if integral && moved_any && can_reuse_previous_frame && !already_shifted {
2
        Some((
2
            TranslateHint {
2
                delta: (m.dominant_delta.x, m.dominant_delta.y),
2
                region_old: m.moved_region_old,
2
            },
2
            m.exceptions.clone(),
2
        ))
    } else {
4
        None
    }
16771
}
/// What [`execute_translate_blit`] produced: paint damage to merge, and
/// present-only regions (whole mover clips — every pixel in them changed on
/// screen even though only strips were rasterised).
#[derive(Debug, Default)]
pub struct TranslateBlitResult {
    pub damage: Vec<LogicalRect>,
    pub present_extra: Vec<LogicalRect>,
}
/// ROUND 3 of the incremental present — the layout-translation blit. The
/// translated diff classified the dominant movers as MOVES (no damage);
/// shift their pixels here and repaint only the exceptions + exposed strips.
/// Sign map: the shifter takes SCROLL deltas (offset +d moves content by
/// −d), so a layout move BY +d passes delta −d anchored at offset (0,0).
///
/// Extracted from the dll headless present so BOTH backends (the shells'
/// `render_frame` and the e2e twin) execute the identical blit — before
/// this, the twin had no notion of a `TranslateHint` at all, so every
/// mover-blit bug was structurally untestable in the harness.
///
/// One blit PER MOVER RECT (never the union — the gaps between movers are
/// static backdrop that must not be dragged). Clip per mover =
/// old∪(old+delta) so both the vacated source and the destination lie
/// inside the memmove region; exposed strips repaint per mover.
#[must_use]
pub fn execute_translate_blit(
    output: &mut AzulPixmap,
    hint: &TranslateHint,
    exceptions: &[LogicalRect],
    mover_rects: &[LogicalRect],
    new_display_list: &DisplayList,
    dpi_factor: f32,
    pool_order: bool,
) -> TranslateBlitResult {
    let mut res = TranslateBlitResult::default();
    let d = hint.delta;
    if std::env::var_os("AZ_BLIT_DEBUG").is_some() {
        eprintln!(
            "[blit] delta={:?} movers={} exceptions={}",
            d,
            mover_rects.len(),
            exceptions.len()
        );
        for m in mover_rects {
            eprintln!("[blit]   mover {m:?}");
        }
        for e in exceptions {
            eprintln!("[blit]   exception {e:?}");
        }
    }
    for mr in mover_rects {
        let dest = LogicalRect {
            origin: LogicalPosition {
                x: mr.origin.x + d.0,
                y: mr.origin.y + d.1,
            },
            size: mr.size,
        };
        let x0 = mr.origin.x.min(dest.origin.x);
        let y0 = mr.origin.y.min(dest.origin.y);
        let x1 = (mr.origin.x + mr.size.width).max(dest.origin.x + dest.size.width);
        let y1 = (mr.origin.y + mr.size.height).max(dest.origin.y + dest.size.height);
        let clip = LogicalRect {
            origin: LogicalPosition { x: x0, y: y0 },
            size: LogicalSize {
                width: x1 - x0,
                height: y1 - y0,
            },
        };
        let shift_exact = if pool_order {
            scroll_shift_region_exact_pool_order
        } else {
            scroll_shift_region_exact
        };
        let strips = shift_exact(output, &clip, (-d.0, -d.1), (0.0, 0.0), dpi_factor);
        // Inflate the vacated strips by 1px: LCD fringe of a run hugging the
        // mover's edge hangs one device pixel OUTSIDE the mover rect, so the
        // un-inflated vacated region leaves that column stale after the move
        // (the full-repaint control clears it via the diff's own text
        // inflation — the gate diverged by exactly that column). A wider
        // strip that now cuts a destination run is already handled by the
        // fringe-touching-runs-damaged-whole rule below.
        let strips: Vec<LogicalRect> = strips
            .iter()
            .map(|r| LogicalRect {
                origin: LogicalPosition {
                    x: r.origin.x - 1.0,
                    y: r.origin.y - 1.0,
                },
                size: LogicalSize {
                    width: r.size.width + 2.0,
                    height: r.size.height + 2.0,
                },
            })
            .collect();
        res.damage.extend(strips.iter().copied());
        // LCD text is FIR-fringed: a run STARTING at the blit destination
        // hangs 1px of fringe INTO the vacated strip. A strip-clipped repaint
        // cannot reproduce that fringe (the colorimetric blend reads
        // neighbours), so any text run whose 1px-inflated bounds touch a
        // strip is damaged WHOLE — cleared and re-rendered exactly like the
        // full-repaint control.
        for strip in &strips {
            for item in &new_display_list.items {
                if let DisplayListItem::Text { .. } = item {
                    if let Some(b) = item.bounds() {
                        let inflated = LogicalRect {
                            origin: LogicalPosition {
                                x: b.origin.x - 1.0,
                                y: b.origin.y - 1.0,
                            },
                            size: LogicalSize {
                                width: b.size.width + 2.0,
                                height: b.size.height + 2.0,
                            },
                        };
                        let ix = inflated.origin.x < strip.origin.x + strip.size.width
                            && strip.origin.x < inflated.origin.x + inflated.size.width
                            && inflated.origin.y < strip.origin.y + strip.size.height
                            && strip.origin.y < inflated.origin.y + inflated.size.height;
                        if ix {
                            res.damage.push(inflated);
                        }
                    }
                }
            }
        }
        res.present_extra.push(clip);
    }
    res.damage.extend(exceptions.iter().copied());
    res
}
/// [`compute_display_list_damage`] + the translate hint. Returns
/// `(damage, moved_union)`; `moved_union` is `None` when nothing matched
/// the hint (caller falls back to plain damage).
/// Damage for a STRUCTURALLY changed display list (item count or item kinds
/// differ): the visually-equal PREFIX and SUFFIX are unchanged, so everything
/// that changed lies in the middle window — damage is the union of the OLD
/// and NEW middle windows' bounds, mapped to visual space through the scroll
/// stack. Sound for any localized insertion / removal / replacement; a change
/// that touches everything degrades to a full-window rect, which is what the
/// bail produced anyway.
///
/// `None` (keep the full repaint) when any scroll offset changed between the
/// frames — a scrolled frame moves items the equality scan would misjudge.
136
fn windowed_structural_damage(
136
    old: &DisplayList,
136
    new: &DisplayList,
136
    old_offsets: &ScrollOffsetMap,
136
    new_offsets: &ScrollOffsetMap,
136
) -> Option<Vec<LogicalRect>> {
136
    if old_offsets != new_offsets {
3
        return None;
133
    }
133
    let o = &old.items;
133
    let n = &new.items;
133
    let min_len = o.len().min(n.len());
133
    let mut prefix = 0;
1060
    while prefix < min_len && o[prefix].is_visually_equal(&n[prefix]) {
927
        prefix += 1;
927
    }
133
    let mut suffix = 0;
390
    while suffix < min_len - prefix
353
        && o[o.len() - 1 - suffix].is_visually_equal(&n[n.len() - 1 - suffix])
257
    {
257
        suffix += 1;
257
    }
    // The accumulated scroll offset at the prefix boundary (offsets are equal
    // between the frames, so one walk over the new list serves both).
133
    let mut stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
929
    for item in &n[..prefix] {
927
        match item {
54
            DisplayListItem::PushScrollFrame { scroll_id, .. } => {
54
                let (ax, ay) = *stack.last().unwrap_or(&(0.0, 0.0));
54
                let (sx, sy) = new_offsets.get(scroll_id).copied().unwrap_or((0.0, 0.0));
54
                stack.push((ax + sx, ay + sy));
54
            }
            DisplayListItem::PopScrollFrame => {
20
                if stack.len() > 1 {
20
                    stack.pop();
20
                }
            }
853
            _ => {}
        }
    }
    // A text-shadow's extent is not representable per item (the shadow
    // paints around every glyph run until the Pop) — if EITHER changed
    // window contains one, bail to the full-repaint path rather than
    // under-damage the fringe. This must abort the WHOLE fallback: bailing
    // from just one window's union would return the other window's rect as
    // if it were complete coverage.
133
    let shadow_in_window = o[prefix..o.len() - suffix]
133
        .iter()
133
        .chain(n[prefix..n.len() - suffix].iter())
1116
        .any(|i| matches!(i, DisplayListItem::PushTextShadow { .. }));
133
    if shadow_in_window {
        return None;
133
    }
    // PER-ITEM rects for the middle window of one list, tracking Push/Pop
    // inside it. This used to union the whole window into ONE rect per side —
    // sound, but as coarse as the window: an Enter in a TextArea unioned the
    // full-width HitTestArea in with the ~60px-wide moved lines and damaged
    // 375×48 where ~60×48 changed. Per-item rects (coalesced below) cover the
    // same changed pixels exactly: every pixel a window item painted, before
    // or after, lies in its own item's rect, and every pixel BETWEEN window
    // items belongs to visually-equal prefix/suffix items, which by
    // definition did not change.
133
    let refine = dl_diff_refinements_enabled();
133
    let mut damage: Vec<LogicalRect> = Vec::new();
133
    let mut skipped_nonpainting = false;
266
    let mut collect_window = |items: &[DisplayListItem], offsets: &ScrollOffsetMap| {
266
        let mut stack = stack.clone();
1382
        for item in items {
1116
            match item {
                DisplayListItem::PushScrollFrame { scroll_id, .. } => {
                    let (ax, ay) = *stack.last().unwrap_or(&(0.0, 0.0));
                    let (sx, sy) = offsets.get(scroll_id).copied().unwrap_or((0.0, 0.0));
                    stack.push((ax + sx, ay + sy));
                }
                DisplayListItem::PopScrollFrame => {
1
                    if stack.len() > 1 {
                        stack.pop();
1
                    }
                }
1115
                _ => {}
            }
            // Items that never RASTERIZE never produce paint damage — the
            // same rule as the paired path's refinement: hit testing and
            // PDF/a11y metadata read the NEW display list, no pixels are
            // involved. In the union days their (viewport-sized) rects were
            // absorbed anyway; per-item they WOULD dominate the result.
1116
            if refine
667
                && matches!(
1116
                    item,
                    DisplayListItem::HitTestArea { .. } | DisplayListItem::TextLayout { .. }
                )
            {
449
                skipped_nonpainting = true;
449
                continue;
667
            }
            // INK bounds, not box bounds: a box-shadow's blur/spread and a
            // glyph run's overhang paint OUTSIDE `bounds()`. Unioning the
            // un-expanded rects left the moved shadow's fringe stale — the
            // Switch toggle's 2830 stale px — and, for Text, the LCD
            // subpixel fringe one column outside the box.
667
            let Some(b) = item.visual_bounds().or_else(|| item.bounds()) else {
33
                continue;
            };
634
            let (ox, oy) = *stack.last().unwrap_or(&(0.0, 0.0));
634
            let mut visual = LogicalRect {
634
                origin: LogicalPosition {
634
                    x: b.origin.x - ox,
634
                    y: b.origin.y - oy,
634
                },
634
                size: b.size,
634
            };
            // Mirror the paired path's rule: LCD subpixel AA (the shipping
            // default) bleeds 1 logical px past the glyph ink.
634
            if matches!(item, DisplayListItem::Text { .. }) {
365
                visual.origin.x -= 1.0;
365
                visual.origin.y -= 1.0;
365
                visual.size.width += 2.0;
365
                visual.size.height += 2.0;
449
            }
634
            damage.push(visual);
        }
266
    };
133
    collect_window(&o[prefix..o.len() - suffix], old_offsets);
133
    collect_window(&n[prefix..n.len() - suffix], new_offsets);
133
    if damage.is_empty() {
        // Every window item was a non-painting one (hit areas, metadata):
        // nothing rasterizes differently, so there is genuinely no paint
        // damage — the same `Some(empty)` the paired path produces for a
        // HitTestArea-only change.
1
        if skipped_nonpainting {
            return Some(damage);
1
        }
        // A window of only Push/Pop pairs (no bounds-carrying item) still
        // changed structure somewhere — be conservative rather than claim
        // "no damage".
1
        return None;
132
    }
132
    coalesce_damage_rects(&mut damage);
132
    Some(damage)
136
}
#[must_use]
124
pub fn compute_display_list_damage_translated(
124
    old: &DisplayList,
124
    new: &DisplayList,
124
    old_offsets: &ScrollOffsetMap,
124
    new_offsets: &ScrollOffsetMap,
124
    hint: Option<&TranslateHint>,
124
) -> Option<(Vec<LogicalRect>, Option<LogicalRect>)> {
124
    compute_display_list_damage_impl(old, new, old_offsets, new_offsets, hint)
124
}
#[must_use]
967
pub fn compute_display_list_damage(
967
    old: &DisplayList,
967
    new: &DisplayList,
967
    old_offsets: &ScrollOffsetMap,
967
    new_offsets: &ScrollOffsetMap,
967
) -> Option<Vec<LogicalRect>> {
967
    compute_display_list_damage_impl(old, new, old_offsets, new_offsets, None)
967
        .map(|(damage, _moved)| damage)
967
}
#[allow(clippy::too_many_lines)]
1091
fn compute_display_list_damage_impl(
1091
    old: &DisplayList,
1091
    new: &DisplayList,
1091
    old_offsets: &ScrollOffsetMap,
1091
    new_offsets: &ScrollOffsetMap,
1091
    hint: Option<&TranslateHint>,
1091
) -> Option<(Vec<LogicalRect>, Option<LogicalRect>)> {
    // Different item counts: a LOCALIZED structural change (a paragraph that
    // now wraps to one more line emits one more Text item, an inserted node,
    // a removed one). This used to bail to a full-window repaint — which is
    // how retyping a paragraph's text repainted the whole window whenever
    // the new text changed the LINE COUNT (font-metrics dependent: DejaVu
    // wraps where Helvetica does not, so the same scenario was green on
    // macOS and red on ubuntu). The windowed fallback below damages the
    // changed middle instead.
1091
    if old.items.len() != new.items.len() {
133
        return windowed_structural_damage(old, new, old_offsets, new_offsets)
133
            .map(|damage| (damage, None));
958
    }
958
    let mut damage = Vec::new();
958
    let mut moved_union: Option<LogicalRect> = None;
    // 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.
958
    let mut offset_stack: Vec<((f32, f32), (f32, f32))> = vec![((0.0, 0.0), (0.0, 0.0))];
612369
    for (old_item, new_item) in old.items.iter().zip(new.items.iter()) {
        // Compare discriminant first (cheap)
612369
        if std::mem::discriminant(old_item) != std::mem::discriminant(new_item) {
            // Structural change with equal counts — same windowed fallback as
            // the count-mismatch case above.
3
            return windowed_structural_damage(old, new, old_offsets, new_offsets)
3
                .map(|damage| (damage, None));
612366
        }
612366
        match new_item {
81
            DisplayListItem::PushScrollFrame { scroll_id, .. } => {
81
                let (acc_old, acc_new) = *offset_stack.last().unwrap_or(&((0.0, 0.0), (0.0, 0.0)));
81
                let o = old_offsets.get(scroll_id).copied().unwrap_or((0.0, 0.0));
81
                let n = new_offsets.get(scroll_id).copied().unwrap_or((0.0, 0.0));
81
                offset_stack.push((
81
                    (acc_old.0 + o.0, acc_old.1 + o.1),
81
                    (acc_new.0 + n.0, acc_new.1 + n.1),
81
                ));
81
            }
            DisplayListItem::PopScrollFrame => {
81
                if offset_stack.len() > 1 {
81
                    offset_stack.pop();
81
                }
            }
612204
            _ => {}
        }
        // 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.
612366
        if !old_item.is_visually_equal(new_item) {
2859
            let refine = dl_diff_refinements_enabled();
            // Items that never RASTERIZE must never produce PAINT damage.
            // The root's HitTestArea resizes with the window (640→680 =
            // a full-viewport "change") and TextLayout is PDF/a11y
            // metadata — both were forcing 100% repaints on every resize.
            // Hit testing and metadata consumers read the NEW display list
            // directly; no pixels are involved.
2859
            if refine
2859
                && matches!(
2859
                    new_item,
                    DisplayListItem::HitTestArea { .. } | DisplayListItem::TextLayout { .. }
                )
            {
                continue;
2859
            }
            // A Border whose widths are all None/absent paints NOTHING —
            // the generator emits one per node regardless, and a resize of
            // a borderless node's Border item was damaging its whole rect
            // (the viewport, for the root).
            if let (
                DisplayListItem::Border { widths: ow, .. },
                DisplayListItem::Border { widths: nw, .. },
2859
            ) = (old_item, new_item)
            {
                if refine {
                    // Sides have distinct wrapper types; small duplication
                    // (same inner PixelValue) beats a generic bound here.
                    let paints = |w: &crate::solver3::display_list::StyleBorderWidths| {
                        w.top.as_ref().map_or(0.0, |v| {
                            v.get_property()
                                .map_or(0.0, |x| x.inner.to_pixels_internal(0.0, 16.0, 16.0))
                        }) > 0.0
                            || w.right.as_ref().map_or(0.0, |v| {
                                v.get_property()
                                    .map_or(0.0, |x| x.inner.to_pixels_internal(0.0, 16.0, 16.0))
                            }) > 0.0
                            || w.bottom.as_ref().map_or(0.0, |v| {
                                v.get_property()
                                    .map_or(0.0, |x| x.inner.to_pixels_internal(0.0, 16.0, 16.0))
                            }) > 0.0
                            || w.left.as_ref().map_or(0.0, |v| {
                                v.get_property()
                                    .map_or(0.0, |x| x.inner.to_pixels_internal(0.0, 16.0, 16.0))
                            }) > 0.0
                    };
                    if !paints(ow) && !paints(nw) {
                        continue;
                    }
                } else {
                    // fall through to plain damage below
                }
2859
            }
            // PushStackingContext carries region metadata (bounds + z);
            // its geometry change does not itself paint — children carry
            // their own damage. (Same class as HitTestArea.)
2859
            if refine && matches!(new_item, DisplayListItem::PushStackingContext { .. }) {
6
                continue;
2853
            }
2853
            let (acc_old, acc_new) = *offset_stack.last().unwrap_or(&((0.0, 0.0), (0.0, 0.0)));
            // A SOLID rect that only changed SIZE (same origin, color,
            // radius) — the width:100% root background on every resize —
            // damages only its SYMMETRIC DIFFERENCE: pixels inside both old
            // and new are identical (uniform fill), so old∪new (the whole
            // viewport, forcing a full repaint per resize) over-damages by
            // exactly the intersection. Emit the exposed right/bottom (or
            // shrunk) strips instead.
            // Same-origin size-only change of a CLIP: the intersection kept
            // its clipping; only the delta strips can newly show or hide
            // content. (A moved clip falls through to full old∪new.)
            if let (
                DisplayListItem::PushClip {
                    bounds: ob,
                    border_radius: obr,
                },
                DisplayListItem::PushClip {
                    bounds: nb,
                    border_radius: nbr,
                },
2853
            ) = (old_item, new_item)
            {
                if refine {
                    let same_origin = (ob.0.origin.x - nb.0.origin.x).abs() < 0.01
                        && (ob.0.origin.y - nb.0.origin.y).abs() < 0.01;
                    if same_origin && obr == nbr {
                        let o = ob.0;
                        let nr = nb.0;
                        let (wx0, wx1) = (
                            o.size.width.min(nr.size.width),
                            o.size.width.max(nr.size.width),
                        );
                        if wx1 - wx0 > 0.01 {
                            damage.push(LogicalRect {
                                origin: LogicalPosition {
                                    x: nr.origin.x + wx0,
                                    y: nr.origin.y,
                                },
                                size: LogicalSize {
                                    width: wx1 - wx0,
                                    height: o.size.height.max(nr.size.height),
                                },
                            });
                        }
                        let (hy0, hy1) = (
                            o.size.height.min(nr.size.height),
                            o.size.height.max(nr.size.height),
                        );
                        if hy1 - hy0 > 0.01 {
                            damage.push(LogicalRect {
                                origin: LogicalPosition {
                                    x: nr.origin.x,
                                    y: nr.origin.y + hy0,
                                },
                                size: LogicalSize {
                                    width: o.size.width.max(nr.size.width),
                                    height: hy1 - hy0,
                                },
                            });
                        }
                        continue;
                    }
                } else {
                    // plain damage below
                }
2853
            }
            if let (
                DisplayListItem::Rect {
30
                    bounds: ob,
30
                    color: oc,
30
                    border_radius: obr,
                },
                DisplayListItem::Rect {
30
                    bounds: nb,
30
                    color: nc,
30
                    border_radius: nbr,
                },
2853
            ) = (old_item, new_item)
            {
30
                if refine {
30
                    let same_origin = (ob.0.origin.x - nb.0.origin.x).abs() < 0.01
20
                        && (ob.0.origin.y - nb.0.origin.y).abs() < 0.01;
30
                    if same_origin && oc == nc && obr == nbr && obr.is_zero() {
9
                        let (acc_o, acc_n) = (acc_old, acc_new);
9
                        let scrolled =
                            |r: &crate::solver3::display_list::WindowLogicalRect,
                             acc: (f32, f32)| LogicalRect {
18
                                origin: LogicalPosition {
18
                                    x: r.0.origin.x - acc.0,
18
                                    y: r.0.origin.y - acc.1,
18
                                },
18
                                size: r.0.size,
18
                            };
9
                        let o = scrolled(ob, acc_o);
9
                        let nr = scrolled(nb, acc_n);
                        // Horizontal strip (width delta), full height of the taller.
9
                        let (wx0, wx1) = (
9
                            o.size.width.min(nr.size.width),
9
                            o.size.width.max(nr.size.width),
9
                        );
9
                        if wx1 - wx0 > 0.01 {
9
                            damage.push(LogicalRect {
9
                                origin: LogicalPosition {
9
                                    x: nr.origin.x + wx0,
9
                                    y: nr.origin.y,
9
                                },
9
                                size: LogicalSize {
9
                                    width: wx1 - wx0,
9
                                    height: o.size.height.max(nr.size.height),
9
                                },
9
                            });
9
                        }
                        // Vertical strip (height delta), full width of the wider.
9
                        let (hy0, hy1) = (
9
                            o.size.height.min(nr.size.height),
9
                            o.size.height.max(nr.size.height),
9
                        );
9
                        if hy1 - hy0 > 0.01 {
2
                            damage.push(LogicalRect {
2
                                origin: LogicalPosition {
2
                                    x: nr.origin.x,
2
                                    y: nr.origin.y + hy0,
2
                                },
2
                                size: LogicalSize {
2
                                    width: o.size.width.max(nr.size.width),
2
                                    height: hy1 - hy0,
2
                                },
2
                            });
7
                        }
9
                        continue;
21
                    }
                } else {
                    // plain damage below
                }
2823
            }
            // Round-3: a pure translation by the hint's delta, inside its
            // region, is a MOVE — the caller blits it; no damage here.
2844
            if let Some(h) = hint {
                if let Some(ob) = old_item.visual_bounds() {
                    let inside = ob.origin.x >= h.region_old.origin.x - 0.5
                        && ob.origin.y >= h.region_old.origin.y - 0.5
                        && ob.origin.x + ob.size.width
                            <= h.region_old.origin.x + h.region_old.size.width + 0.5
                        && ob.origin.y + ob.size.height
                            <= h.region_old.origin.y + h.region_old.size.height + 0.5;
                    if inside && items_equal_translated(old_item, new_item, h.delta) {
                        let vr = LogicalRect {
                            origin: LogicalPosition {
                                x: ob.origin.x - acc_old.0,
                                y: ob.origin.y - acc_old.1,
                            },
                            size: ob.size,
                        };
                        moved_union = Some(match moved_union {
                            None => vr,
                            Some(m) => union_rects(m, vr),
                        });
                        continue;
                    }
                }
2844
            }
2844
            if std::env::var_os("AZ_BLIT_DEBUG").is_some() {
                eprintln!(
                    "[dldiff] mismatch {:?} old_bounds={:?}",
                    std::mem::discriminant(new_item),
                    old_item.visual_bounds()
                );
2844
            }
            // LCD text ink hangs ~2/3 of a device pixel outside the run's
            // bounds on each side (the 5-tap FIR fringe). A MOVED run leaves
            // stale fringe one pixel outside old∪new unless the damage covers
            // it, and the repaint must CLEAR that pixel so the re-blend lands
            // on base, not on retained ink. 1 LOGICAL px covers the fringe
            // for every dpi ≥ 0.67 (0.67 × 1 px = the 2-stripe overhang).
2844
            let fringe = if matches!(new_item, DisplayListItem::Text { .. }) {
2696
                1.0
            } else {
148
                0.0
            };
2844
            let inflate = |r: LogicalRect| LogicalRect {
5688
                origin: LogicalPosition {
5688
                    x: r.origin.x - fringe,
5688
                    y: r.origin.y - fringe,
5688
                },
5688
                size: LogicalSize {
5688
                    width: 2.0f32.mul_add(fringe, r.size.width),
5688
                    height: 2.0f32.mul_add(fringe, r.size.height),
5688
                },
5688
            };
2844
            if let Some(ob) = old_item.visual_bounds() {
2844
                damage.push(inflate(LogicalRect {
2844
                    origin: LogicalPosition {
2844
                        x: ob.origin.x - acc_old.0,
2844
                        y: ob.origin.y - acc_old.1,
2844
                    },
2844
                    size: ob.size,
2844
                }));
2844
            }
2844
            if let Some(nb) = new_item.visual_bounds() {
2844
                damage.push(inflate(LogicalRect {
2844
                    origin: LogicalPosition {
2844
                        x: nb.origin.x - acc_new.0,
2844
                        y: nb.origin.y - acc_new.1,
2844
                    },
2844
                    size: nb.size,
2844
                }));
2844
            }
609507
        }
    }
    // Coalesce overlapping rects
955
    coalesce_damage_rects(&mut damage);
955
    Some((damage, moved_union))
1091
}
fn union_rects(a: LogicalRect, b: LogicalRect) -> LogicalRect {
    let x0 = a.origin.x.min(b.origin.x);
    let y0 = a.origin.y.min(b.origin.y);
    let x1 = (a.origin.x + a.size.width).max(b.origin.x + b.size.width);
    let y1 = (a.origin.y + a.size.height).max(b.origin.y + b.size.height);
    LogicalRect {
        origin: LogicalPosition { x: x0, y: y0 },
        size: LogicalSize {
            width: x1 - x0,
            height: y1 - y0,
        },
    }
}
/// Is `new` exactly `old` translated by `delta`? Uses `translate_item` on a
/// clone + `is_visually_equal` — runs ONLY for mismatching pairs inside the
/// hint region (the steady-state pair equals and never reaches this).
#[must_use]
pub fn items_equal_translated(
    old: &DisplayListItem,
    new: &DisplayListItem,
    delta: (f32, f32),
) -> bool {
    use crate::solver3::display_list::translate_item;
    let translated = translate_item(
        old.clone(),
        LogicalPosition {
            x: delta.0,
            y: delta.1,
        },
    );
    translated.is_visually_equal(new)
}
/// 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]
9
pub fn display_lists_visually_equal(a: &DisplayList, b: &DisplayList) -> bool {
9
    if a.items.len() != b.items.len() {
1
        return false;
8
    }
9
    a.items.iter().zip(b.items.iter()).all(|(x, y)| {
9
        std::mem::discriminant(x) == std::mem::discriminant(y) && x.is_visually_equal(y)
9
    })
9
}
/// 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]
16772
pub fn compute_virtual_view_damage(
16772
    parent: &DisplayList,
16772
    current: &std::collections::BTreeMap<azul_core::dom::DomId, std::sync::Arc<DisplayList>>,
16772
    previous: &std::collections::BTreeMap<azul_core::dom::DomId, std::sync::Arc<DisplayList>>,
16772
) -> Vec<LogicalRect> {
16772
    let mut damage = Vec::new();
316743
    for item in &parent.items {
        if let DisplayListItem::VirtualView {
8
            child_dom_id,
8
            bounds,
8
            content_offset,
            ..
299971
        } = item
        {
8
            let view = *bounds.inner();
8
            match (current.get(child_dom_id), previous.get(child_dom_id)) {
5
                (Some(c), Some(p)) => {
                    // Same Arc → definitely unchanged (cheap fast-path).
5
                    if std::sync::Arc::ptr_eq(c, p) || display_lists_visually_equal(c, p) {
2
                        continue;
3
                    }
                    // SOMETHING changed - but WHAT. Damaging the whole view
                    // here is what made a blinking caret cost a full-window
                    // re-raster plus a full-window blit: AzWriter's document
                    // body is a VirtualView, the caret lives in its child DOM,
                    // and every 1200 ms tick reported `render=30ms blit=15ms`
                    // on an otherwise idle X11/CPU window. The parent list has
                    // had an item diff all along; the child never got one.
                    //
                    // Child lists are 0-relative (see the VirtualView arm in
                    // raster.rs: the renderer draws at `pos - scroll`, with the
                    // view origin and content offset pushed as the scroll), so
                    // a diff rect at content (x, y) lands on screen at
                    // `origin + (x, y) - content_offset`, clipped to the view.
3
                    let empty = ScrollOffsetMap::default();
3
                    match compute_display_list_damage(p, c, &empty, &empty) {
                        // The diff paired every item and found the ones that
                        // moved: repaint exactly those.
3
                        Some(rects) if !rects.is_empty() => {
3
                            let dx = view.origin.x - content_offset.x;
3
                            let dy = view.origin.y - content_offset.y;
6
                            for r in rects {
3
                                let on_screen = LogicalRect {
3
                                    origin: LogicalPosition {
3
                                        x: r.origin.x + dx,
3
                                        y: r.origin.y + dy,
3
                                    },
3
                                    size: r.size,
3
                                };
3
                                let clipped = intersect_logical_rects(on_screen, view);
                                // Scrolled out of sight: behind the view's clip,
                                // so those pixels are a neighbour's, not ours.
3
                                if clipped.size.width > 0.0 && clipped.size.height > 0.0 {
2
                                    damage.push(clipped);
2
                                }
                            }
                        }
                        // The two answers disagree (visually different, but the
                        // diff found nothing to repaint) or the diff gave up on
                        // a structural change. Precision is an optimisation;
                        // correctness is not, so fall back to the whole view.
                        _ => damage.push(view),
                    }
                }
                // Appeared or disappeared this frame - nothing to diff against.
2
                (Some(_), None) | (None, Some(_)) => damage.push(view),
1
                (None, None) => {}
            }
299963
        }
    }
16772
    damage
16772
}
/// 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
1217
pub fn coalesce_damage_rects(rects: &mut Vec<LogicalRect>) {
1217
    if rects.len() <= 1 {
102
        return;
1115
    }
    // Simple O(n^2) merge — fine for typical damage counts (<20 rects)
1115
    let mut changed = true;
3526
    while changed {
2411
        changed = false;
2411
        let mut i = 0;
6980
        while i < rects.len() {
4569
            let mut j = i + 1;
13431
            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.
8862
                if rects_overlap_or_adjacent(&rects[i], &rects[j], 8.0) {
6906
                    let u = union_rect(&rects[i], &rects[j]);
6906
                    let area_u = (u.size.width * u.size.height).max(0.0);
6906
                    let area_i = (rects[i].size.width * rects[i].size.height).max(0.0);
6906
                    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.
6906
                    if area_u <= (area_i + area_j) * 1.5 + 64.0 {
4634
                        rects[i] = u;
4634
                        rects.swap_remove(j);
4634
                        changed = true;
4634
                    } else {
2272
                        j += 1;
2272
                    }
1956
                } else {
1956
                    j += 1;
1956
                }
            }
4569
            i += 1;
        }
    }
1217
}
#[must_use]
637126
pub fn rects_overlap_or_adjacent(a: &LogicalRect, b: &LogicalRect, gap: f32) -> bool {
637126
    a.origin.x - gap <= b.origin.x + b.size.width
635048
        && b.origin.x - gap <= a.origin.x + a.size.width
634432
        && a.origin.y - gap <= b.origin.y + b.size.height
21194
        && b.origin.y - gap <= a.origin.y + a.size.height
637126
}
/// Compute damage rects for a grow-only window resize.
/// Returns the right strip and bottom strip that need rendering.
#[must_use]
15
pub fn compute_resize_damage(
15
    old_width: f32,
15
    old_height: f32,
15
    new_width: f32,
15
    new_height: f32,
15
) -> Vec<LogicalRect> {
15
    let mut rects = Vec::new();
15
    if new_width > old_width {
10
        rects.push(LogicalRect {
10
            origin: LogicalPosition {
10
                x: old_width,
10
                y: 0.0,
10
            },
10
            size: LogicalSize {
10
                width: new_width - old_width,
10
                height: new_height,
10
            },
10
        });
12
    }
15
    if new_height > old_height {
6
        rects.push(LogicalRect {
6
            origin: LogicalPosition {
6
                x: 0.0,
6
                y: old_height,
6
            },
6
            size: LogicalSize {
6
                width: old_width.min(new_width),
6
                height: new_height - old_height,
6
            },
6
        });
9
    }
15
    rects
15
}
/// 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
#[must_use]
19
pub fn compare_region(
19
    a: &AzulPixmap,
19
    b: &AzulPixmap,
19
    x: u32,
19
    y: u32,
19
    w: u32,
19
    h: u32,
19
    threshold: u8,
19
) -> usize {
19
    let mut diff_count = 0;
2026
    for row in y..(y + h).min(a.height).min(b.height) {
400100
        for col in x..(x + w).min(a.width).min(b.width) {
400100
            let ai = (row * a.width + col) as usize * 4;
400100
            let bi = (row * b.width + col) as usize * 4;
400100
            if ai + 3 >= a.data.len() || bi + 3 >= b.data.len() {
                continue;
400100
            }
400100
            let dr = (i16::from(a.data[ai]) - i16::from(b.data[bi])).unsigned_abs() as u8;
400100
            let dg = (i16::from(a.data[ai + 1]) - i16::from(b.data[bi + 1])).unsigned_abs() as u8;
400100
            let db = (i16::from(a.data[ai + 2]) - i16::from(b.data[bi + 2])).unsigned_abs() as u8;
400100
            if dr > threshold || dg > threshold || db > threshold {
52
                diff_count += 1;
400052
            }
        }
    }
19
    diff_count
19
}
/// Layout-driven layer offsets snap to whole device pixels so layers take the integer blit instead of being resampled.
#[inline]
432
fn layout_offset_device_px(logical: f32, dpi: f64) -> f64 {
432
    (f64::from(logical) * dpi).round()
432
}
// ============================================================================
// scroll_shift_region — unit tests (#14 single-axis, #16 diagonal pan)
// ============================================================================
#[cfg(test)]
mod layer_size_is_never_an_allocation_bomb {
    use azul_core::geom::LogicalSize;
    use super::*;
10
    fn sz(w: f32, h: f32) -> LogicalSize {
10
        LogicalSize::new(w, h)
10
    }
    #[test]
1
    fn an_infinite_size_is_not_a_size() {
        // `as u32` saturates, so this used to be u32::MAX — and the caller then
        // asked for u32::MAX * height * 4 bytes.
1
        assert_eq!(layer_pixel_size(sz(f32::INFINITY, 27.0), 2.0, None), (0, 0));
1
        assert_eq!(
1
            layer_pixel_size(sz(100.0, f32::INFINITY), 2.0, None),
            (0, 0)
        );
1
        assert_eq!(layer_pixel_size(sz(f32::NAN, 27.0), 2.0, None), (0, 0));
1
        assert_eq!(layer_pixel_size(sz(100.0, 27.0), f32::NAN, None), (0, 0));
1
    }
    #[test]
1
    fn a_finite_but_absurd_size_is_clamped_rather_than_saturated() {
        // Finite overflow is the same bomb with an extra step: 1e30 * 2 still
        // saturates the cast.
1
        assert_eq!(
1
            layer_pixel_size(sz(1e30, 27.0), 2.0, None),
            (MAX_LAYER_DIM, 54),
        );
1
    }
    #[test]
1
    fn a_non_positive_extent_is_zero_not_a_wrapped_huge_number() {
        // `-5.0 as u32` is 0 today, but only because the cast saturates at the
        // bottom too — state it, so a future refactor cannot turn it negative.
1
        assert_eq!(layer_pixel_size(sz(-5.0, 27.0), 2.0, None), (0, 54));
1
        assert_eq!(layer_pixel_size(sz(0.0, 0.0), 2.0, None), (0, 0));
1
    }
    #[test]
1
    fn ordinary_sizes_are_unchanged_by_the_guard() {
        // The guard must be invisible to every layout that was already fine,
        // rounding up exactly as the old cast did.
1
        assert_eq!(layer_pixel_size(sz(1000.0, 730.0), 2.0, None), (2000, 1460));
1
        assert_eq!(layer_pixel_size(sz(27.5, 13.5), 2.0, None), (55, 27));
1
        assert_eq!(layer_pixel_size(sz(1.0, 1.0), 1.0, None), (1, 1));
1
    }
}
#[cfg(test)]
mod translate_hint_contract {
    use azul_core::geom::{LogicalPosition, LogicalRect, LogicalSize};
    use super::*;
    use crate::solver3::display_list::PatchMoveSummary;
7
    fn mv(dx: f32, dy: f32) -> PatchMoveSummary {
7
        let r = LogicalRect::new(
7
            LogicalPosition::new(0.0, 0.0),
7
            LogicalSize::new(100.0, 100.0),
        );
7
        PatchMoveSummary {
7
            dominant_delta: LogicalPosition::new(dx, dy),
7
            mover_rects_old: vec![r],
7
            moved_region_old: r,
7
            exceptions: Vec::new(),
7
        }
7
    }
    /// A fractional physical delta must NOT blit: it would change every
    /// subpixel phase, so the frame has to re-render.
    #[test]
1
    fn a_fractional_delta_is_not_blittable() {
1
        assert!(translate_hint_for_patch(Some(&mv(0.0, 10.5)), 1.0, true, false, false).is_none());
        // ... but the SAME logical delta at 2x dpi is integral in physical px.
1
        assert!(translate_hint_for_patch(Some(&mv(0.0, 10.5)), 2.0, true, false, false).is_some());
1
    }
    /// A sub-pixel move is not a move.
    #[test]
1
    fn a_delta_that_moves_no_physical_pixel_is_not_blittable() {
1
        assert!(translate_hint_for_patch(Some(&mv(0.0, 0.0)), 1.0, true, false, false).is_none());
1
    }
    /// The two guards that make a blit safe at all.
    #[test]
1
    fn an_untrustworthy_previous_frame_or_a_second_shift_is_refused() {
1
        assert!(translate_hint_for_patch(Some(&mv(0.0, 12.0)), 1.0, true, false, false).is_some());
        // previous frame cannot be reused -> no source pixels to blit
1
        assert!(translate_hint_for_patch(Some(&mv(0.0, 12.0)), 1.0, false, false, false).is_none());
        // this display list was already shifted -> shifting twice doubles it
1
        assert!(translate_hint_for_patch(Some(&mv(0.0, 12.0)), 1.0, true, true, false).is_none());
1
    }
    /// No patch, no hint.
    #[test]
1
    fn no_patch_move_means_no_hint() {
1
        assert!(translate_hint_for_patch(None, 1.0, true, false, false).is_none());
        // Content-space movers are only valid at zero scroll: any active
        // offset must kill the blit (the memmove would garble scrolled
        // content) and fall back to plain damage.
1
        assert!(translate_hint_for_patch(Some(&mv(0.0, 12.0)), 1.0, true, false, true).is_none());
1
    }
}
#[cfg(test)]
mod scroll_shift_tests {
    use azul_core::geom::{LogicalPosition, LogicalRect, LogicalSize};
    use super::*;
    /// 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
    }
33
    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
33
        LogicalRect {
33
            origin: LogicalPosition::new(x, y),
33
            size: LogicalSize::new(w, h),
33
        }
33
    }
    /// #32 LAW: on a pool-order (B,G,R,A) target, shift + commit-swizzle must
    /// leave the moved pixels byte-identical to a PLAIN byte-move of the slot
    /// — a pure move never changes displayed colors. The pool-order variant
    /// un-swizzles the moved block so the commit swizzle re-converts it; the
    /// shipped bug (plain variant + commit swizzle) double-converts and paints
    /// scrolled content with R and B swapped (see the NC below).
    #[test]
1
    fn pool_order_shift_then_commit_swizzle_is_a_pure_byte_move() {
1
        let (w, h) = (32u32, 32u32);
1
        let clip = rect(0.0, 0.0, 32.0, 32.0);
2
        let mk = || {
2
            let mut p = AzulPixmap::new(w, h).unwrap();
2
            let d = p.data_mut();
64
            for y in 0..h as usize {
2048
                for x in 0..w as usize {
2048
                    let o = (y * w as usize + x) * 4;
2048
                    d[o] = (10 + x) as u8; // R
2048
                    d[o + 1] = (100 + y) as u8; // G
2048
                    d[o + 2] = (200 - x) as u8; // B (never equals R)
2048
                    d[o + 3] = 255;
2048
                }
            }
2
            p
2
        };
3
        let swizzle_all = |p: &mut AzulPixmap| {
3
            let d = p.data_mut();
3072
            for o in (0..d.len()).step_by(4) {
3072
                d.swap(o, o + 2);
3072
            }
3
        };
        // Simulated committed slot: pattern in POOL order.
1
        let mut slot = mk();
1
        swizzle_all(&mut slot);
        // Reference: a plain byte-move of the same slot (what the compositor
        // must end up displaying — a move never recolors).
1
        let mut reference = mk();
1
        swizzle_all(&mut reference);
1
        let ref_strips =
1
            scroll_shift_region_exact(&mut reference, &clip, (0.0, 8.0), (0.0, 8.0), 1.0);
        // Production path: pool-order shift, then the commit swizzle over the
        // whole presented clip.
1
        let strips =
1
            scroll_shift_region_exact_pool_order(&mut slot, &clip, (0.0, 8.0), (0.0, 8.0), 1.0);
1
        assert_eq!(
            strips, ref_strips,
            "both variants must expose the same strips"
        );
1
        swizzle_all(&mut slot); // the commit swizzle (full clip = full pixmap here)
1024
        let in_strip = |x: usize, y: usize| {
1024
            strips.iter().any(|r| {
1024
                (x as f32) >= r.origin.x
1024
                    && (x as f32) < r.origin.x + r.size.width
1024
                    && (y as f32) >= r.origin.y
256
                    && (y as f32) < r.origin.y + r.size.height
1024
            })
1024
        };
1
        let (a, b) = (slot.data(), reference.data());
32
        for y in 0..h as usize {
1024
            for x in 0..w as usize {
1024
                if in_strip(x, y) {
256
                    continue; // strips are repainted fresh in production
768
                }
768
                let o = (y * w as usize + x) * 4;
768
                assert_eq!(
768
                    &a[o..o + 4],
768
                    &b[o..o + 4],
                    "moved pixel recolored at {x},{y} — the double-swizzle bug"
                );
            }
        }
1
    }
    /// NEGATIVE CONTROL for the law above: the SHIPPED-BUG combination (plain
    /// shift + commit swizzle) must DIFFER from the pure byte-move in the
    /// moved region — proving the law's comparison can fail. If this ever
    /// passes with equality, the fixture can no longer express the bug and
    /// the law is vacuous.
    #[test]
1
    fn nc_plain_shift_then_commit_swizzle_recolors_moved_pixels() {
1
        let (w, h) = (32u32, 32u32);
1
        let clip = rect(0.0, 0.0, 32.0, 32.0);
3
        let mk = || {
3
            let mut p = AzulPixmap::new(w, h).unwrap();
3
            let d = p.data_mut();
96
            for y in 0..h as usize {
3072
                for x in 0..w as usize {
3072
                    let o = (y * w as usize + x) * 4;
3072
                    d[o] = (10 + x) as u8;
3072
                    d[o + 1] = (100 + y) as u8;
3072
                    d[o + 2] = (200 - x) as u8;
3072
                    d[o + 3] = 255;
3072
                }
            }
3
            p
3
        };
4
        let swizzle_all = |p: &mut AzulPixmap| {
4
            let d = p.data_mut();
4096
            for o in (0..d.len()).step_by(4) {
4096
                d.swap(o, o + 2);
4096
            }
4
        };
1
        let mut slot = mk();
1
        swizzle_all(&mut slot);
1
        let mut reference = mk();
1
        swizzle_all(&mut reference);
1
        let strips = scroll_shift_region_exact(&mut reference, &clip, (0.0, 8.0), (0.0, 8.0), 1.0);
1
        let mut buggy = mk();
1
        swizzle_all(&mut buggy);
1
        let _ = scroll_shift_region_exact(&mut buggy, &clip, (0.0, 8.0), (0.0, 8.0), 1.0);
1
        swizzle_all(&mut buggy); // commit swizzle double-converts the moved block
1024
        let in_strip = |x: usize, y: usize| {
1024
            strips.iter().any(|r| {
1024
                (x as f32) >= r.origin.x
1024
                    && (x as f32) < r.origin.x + r.size.width
1024
                    && (y as f32) >= r.origin.y
256
                    && (y as f32) < r.origin.y + r.size.height
1024
            })
1024
        };
1
        let (a, b) = (buggy.data(), reference.data());
1
        let mut differs = 0usize;
32
        for y in 0..h as usize {
1024
            for x in 0..w as usize {
1024
                if in_strip(x, y) {
256
                    continue;
768
                }
768
                let o = (y * w as usize + x) * 4;
768
                if a[o..o + 4] != b[o..o + 4] {
768
                    differs += 1;
768
                }
            }
        }
1
        assert!(
1
            differs > 0,
            "the buggy combination did not recolor anything — the law above is vacuous"
        );
1
    }
    #[test]
1
    fn noop_when_delta_zero() {
1
        let mut p = xy_pixmap(64, 64);
1
        let strips = scroll_shift_region(
1
            &mut p,
1
            &rect(0.0, 0.0, 64.0, 64.0),
1
            (0.0, 0.0),
1
            (0.0, 0.0),
            1.0,
        );
1
        assert!(
1
            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(
1
            &mut p,
1
            &rect(0.0, 0.0, 200.0, 100.0),
1
            (0.0, 30.0),
1
            (0.0, 30.0),
            1.0,
        );
1
        assert_eq!(
1
            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!(
1
            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 = scroll_shift_region(
1
            &mut p,
1
            &rect(0.0, 0.0, 200.0, 100.0),
1
            (20.0, 30.0),
1
            (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(
1
            &mut p,
1
            &clip,
1
            (0.0, 10.0),
1
            (0.0, 10.0),
            1.0,
        ));
7
        for &(x, y) in &[
7
            (0u32, 0u32),
7
            (199, 99),
7
            (100, 5),
7
            (100, 90),
7
            (2, 50),
7
            (190, 50),
7
        ] {
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!(
1
            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 azul_css::props::basic::color::ColorU;
    use crate::solver3::display_list::{
        BorderRadius, DisplayList, DisplayListItem, WindowLogicalRect,
    };
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
            layout_node_mapping: Vec::new(),
5
            uniform_text_bgs: Vec::new(),
5
            text_selection_colors: 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 {
10
                r: 10,
10
                g: 20,
10
                b: 30,
10
                a,
10
            },
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(
1
            &list,
            7,
1
            &rect(0.0, 0.0, 100.0, 100.0),
1
            (0.0, 0.0),
1
            (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(
1
            &list,
            7,
1
            &rect(0.0, 0.0, 100.0, 100.0),
1
            (0.0, 0.0),
1
            (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 {
1
                r: 200,
1
                g: 0,
1
                b: 0,
1
                a: 255,
1
            };
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 {
1
                r: 0,
1
                g: 0,
1
                b: 200,
1
                a: 255,
1
            };
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(
1
            &list,
1
            7,
1
            &rect(0.0, 0.0, 100.0, 100.0),
1
            (0.0, 0.0),
1
            (0.0, 0.0)
1
        ));
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(
1
            &list,
1
            7,
1
            &rect(0.0, 0.0, 100.0, 100.0),
1
            (0.0, 0.0),
1
            (0.0, 0.0)
1
        ));
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(
1
            &list,
            7,
1
            &rect(0.0, 0.0, 100.0, 100.0),
1
            (0.0, 0.0),
1
            (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::*;
    /// The CPU renderer resolves every glyph run through a `FontManager` — there is
    /// no second font table. These display lists carry no text, so an empty manager
    /// is the honest input; a text-bearing test must register its face here.
1
    fn test_font_manager() -> FontManager<FontRef> {
1
        FontManager::new(rust_fontconfig::FcFontCache::default()).expect("FontManager::new")
1
    }
    use azul_core::resources::RendererResources;
    use azul_css::props::{
        basic::{length::PercentageValue, ColorU},
        style::filter::StyleFilter,
    };
    use crate::{
        cpurender::{CpuRenderState, ScrollOffsetMap},
        solver3::display_list::DisplayList,
    };
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 {
1
            r: 0,
1
            g: 0,
1
            b: 255,
1
            a: 255,
1
        };
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, &HashMap::new(), &HashMap::new());
        // 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, &test_font_manager(), &mut gc, &state)
1
            .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
    }
}
// ============================================================================
// Adversarial unit tests (autotest fleet)
//
// Focus: the compositor's numeric edges — NaN/inf/negative/zero `dpi_factor`,
// saturating float→int casts, clip/region clamping, damage-rect arithmetic and
// the malformed-display-list paths that the doc comments claim panic.
// ============================================================================
#[cfg(test)]
#[allow(clippy::float_cmp)] // deterministic float values: exact compare is the assertion
#[allow(clippy::many_single_char_names)] // domain-standard coordinate/pixel short names
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry short names
#[allow(
    clippy::cast_possible_truncation,
    clippy::cast_precision_loss,
    clippy::cast_sign_loss
)]
mod autotest_generated {
    /// The CPU renderer resolves every glyph run through a `FontManager` — there is
    /// no second font table. These display lists carry no text, so an empty manager
    /// is the honest input; a text-bearing test must register its face here.
    fn test_font_manager() -> FontManager<FontRef> {
        FontManager::new(rust_fontconfig::FcFontCache::default()).expect("FontManager::new")
    }
    use std::{
        collections::{BTreeMap, BTreeSet, HashMap},
        sync::Arc,
    };
    use azul_core::{
        dom::DomId,
        resources::{RendererResources, TransformKey},
        transform::ComputedTransform3D,
    };
    use azul_css::props::{
        basic::{angle::AngleValue, color::ColorU, length::PercentageValue, pixel::PixelValue},
        style::filter::{StyleBlur, StyleFilter},
    };
    use super::*;
    use crate::{
        cpurender::{CpuRenderState, ScrollOffsetMap},
        solver3::display_list::{BorderRadius, DisplayList, DisplayListItem, WindowLogicalRect},
    };
    // ---------------------------------------------------------------- helpers
    fn lr(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
        LogicalRect {
            origin: LogicalPosition::new(x, y),
            size: LogicalSize::new(w, h),
        }
    }
    fn wlr(x: f32, y: f32, w: f32, h: f32) -> WindowLogicalRect {
        lr(x, y, w, h).into()
    }
    fn dlist(items: Vec<DisplayListItem>) -> DisplayList {
        DisplayList {
            items,
            ..Default::default()
        }
    }
    fn rect_item(x: f32, y: f32, w: f32, h: f32, color: ColorU) -> DisplayListItem {
        DisplayListItem::Rect {
            bounds: wlr(x, y, w, h),
            color,
            border_radius: BorderRadius::default(),
        }
    }
    fn opaque_rect(x: f32, y: f32, w: f32, h: f32) -> DisplayListItem {
        rect_item(
            x,
            y,
            w,
            h,
            ColorU {
                r: 10,
                g: 20,
                b: 30,
                a: 255,
            },
        )
    }
    fn push_scroll(id: u64, x: f32, y: f32, w: f32, h: f32) -> DisplayListItem {
        DisplayListItem::PushScrollFrame {
            clip_bounds: wlr(x, y, w, h),
            content_size: LogicalSize::new(w, h * 10.0),
            scroll_id: id,
        }
    }
    /// Pixmap where each pixel encodes its own coordinates (R = x, G = y), so a
    /// shift can be checked as an exact translation.
    fn xy_map(w: u32, h: u32) -> AzulPixmap {
        let mut p = AzulPixmap::new(w, h).unwrap();
        let d = p.data_mut();
        for y in 0..h {
            for x in 0..w {
                let i = ((y * w + x) * 4) as usize;
                d[i] = (x & 0xFF) as u8;
                d[i + 1] = (y & 0xFF) as u8;
                d[i + 2] = 0;
                d[i + 3] = 255;
            }
        }
        p
    }
    fn solid(w: u32, h: u32, c: [u8; 4]) -> AzulPixmap {
        let mut p = AzulPixmap::new(w, h).unwrap();
        p.fill(c[0], c[1], c[2], c[3]);
        p
    }
    fn at(p: &AzulPixmap, x: u32, y: u32) -> [u8; 4] {
        let w = p.width();
        let d = p.data();
        let i = ((y * w + x) * 4) as usize;
        [d[i], d[i + 1], d[i + 2], d[i + 3]]
    }
    fn render_deps() -> (RendererResources, GlyphCache, CpuRenderState) {
        (
            RendererResources::default(),
            GlyphCache::new(),
            CpuRenderState::new(ScrollOffsetMap::new()),
        )
    }
    // ============================== CompositorState::new (constructor) =======
    #[test]
    fn compositor_new_zero_viewport_does_not_panic() {
        // AzulPixmap::new(0, 0) returns None — Layer::new must clamp to 1×1
        // instead of unwrapping a None.
        let c = CompositorState::new(0, 0);
        assert_eq!(c.layers.len(), 1, "only the root layer exists after new()");
        assert_eq!(c.root_layer, LayerId(0));
        let root = c.layers.get(&c.root_layer).unwrap();
        assert_eq!(
            (root.pixbuf.width(), root.pixbuf.height()),
            (1, 1),
            "a 0×0 viewport must degrade to a 1×1 pixbuf, not an empty/absent one"
        );
        assert_eq!(root.bounds.size.width, 0.0);
        assert_eq!(root.bounds.size.height, 0.0);
    }
    #[test]
    fn compositor_new_invariants_hold() {
        let c = CompositorState::new(800, 600);
        assert_eq!(c.layers.len(), 1);
        assert_eq!(c.next_layer_id_peek(), 1, "root consumes id 0");
        assert!(c.previous_positions.is_empty());
        let root = c.layers.get(&c.root_layer).unwrap();
        assert_eq!(root.id, LayerId(0));
        assert_eq!(root.bounds.size.width, 800.0);
        assert_eq!(root.bounds.size.height, 600.0);
        assert_eq!((root.pixbuf.width(), root.pixbuf.height()), (800, 600));
        assert_eq!(root.pixbuf.data().len(), 800 * 600 * 4);
        assert_eq!(
            at(&root.pixbuf, 0, 0),
            [255, 255, 255, 255],
            "root starts opaque white"
        );
        assert_eq!(root.opacity, 1.0);
        assert!(root.children.is_empty());
        assert!(root.damage.is_empty());
    }
    // ============================== alloc_layer_id / next_layer_id_peek ======
    #[test]
    fn alloc_layer_id_is_unique_and_monotonic() {
        let mut c = CompositorState::new(4, 4);
        let ids: Vec<LayerId> = (0..1000).map(|_| c.alloc_layer_id()).collect();
        for (i, id) in ids.iter().enumerate() {
            assert_eq!(*id, LayerId(i as u64 + 1), "ids must be dense + monotonic");
        }
        assert_eq!(c.next_layer_id_peek(), 1001);
    }
    #[test]
    fn next_layer_id_peek_is_side_effect_free() {
        let mut c = CompositorState::new(4, 4);
        let before = c.next_layer_id_peek();
        assert_eq!(
            before,
            c.next_layer_id_peek(),
            "peek must not mutate the counter"
        );
        let _ = c.alloc_layer_id();
        assert_eq!(c.next_layer_id_peek(), before + 1);
    }
    #[test]
    fn alloc_layer_id_at_u64_max_boundary() {
        // The last id that can be handed out without overflowing the counter.
        let mut c = CompositorState::new(4, 4);
        c.next_layer_id = u64::MAX - 1;
        assert_eq!(c.alloc_layer_id(), LayerId(u64::MAX - 1));
        assert_eq!(c.next_layer_id_peek(), u64::MAX);
    }
    // ============================== Layer::new (constructor) =================
    #[test]
    fn layer_new_zero_pixels_clamps_to_1x1_and_sets_defaults() {
        let l = Layer::new(LayerId(9), lr(1.0, 2.0, 3.0, 4.0), 0, 0);
        assert_eq!(l.id, LayerId(9));
        assert_eq!((l.pixbuf.width(), l.pixbuf.height()), (1, 1));
        assert_eq!(l.bounds.origin.x, 1.0);
        assert_eq!(l.bounds.size.height, 4.0);
        assert_eq!(l.opacity, 1.0);
        assert_eq!(l.scroll_offset, (0.0, 0.0));
        assert_eq!(l.display_list_range, (0, 0));
        assert!(l.damage.is_empty());
        assert!(l.children.is_empty());
        assert!(l.filters.is_empty());
        assert!(!l.is_backdrop_filter);
        assert!(l.scroll_id.is_none());
        assert!(l.composite_dirty);
        assert!(l.transform.is_identity(IDENTITY_EPSILON_F64));
    }
    #[test]
    fn layer_new_with_nan_bounds_does_not_panic() {
        let nan_bounds = lr(f32::NAN, f32::NAN, f32::NAN, f32::NAN);
        let l = Layer::new(LayerId(1), nan_bounds, 2, 3);
        assert_eq!((l.pixbuf.width(), l.pixbuf.height()), (2, 3));
        assert!(l.bounds.size.width.is_nan(), "bounds are stored verbatim");
    }
    // ============================== find_matching_pop =======================
    #[test]
    fn find_matching_pop_on_empty_items_returns_len() {
        let items: Vec<DisplayListItem> = Vec::new();
        assert_eq!(find_matching_pop(&items, 0, MatchKind::ScrollFrame), 0);
    }
    #[test]
    fn find_matching_pop_start_past_end_returns_len() {
        // `skip(start + 1)` past the end must yield an empty iterator, not panic.
        let items = vec![
            push_scroll(1, 0.0, 0.0, 10.0, 10.0),
            DisplayListItem::PopScrollFrame,
        ];
        assert_eq!(find_matching_pop(&items, 10, MatchKind::ScrollFrame), 2);
        assert_eq!(find_matching_pop(&items, 1_000_000, MatchKind::Opacity), 2);
    }
    #[test]
    fn find_matching_pop_unmatched_push_returns_len() {
        let items = vec![
            push_scroll(1, 0.0, 0.0, 10.0, 10.0),
            opaque_rect(0.0, 0.0, 5.0, 5.0),
        ];
        assert_eq!(
            find_matching_pop(&items, 0, MatchKind::ScrollFrame),
            2,
            "a Push with no Pop must clamp to items.len()"
        );
    }
    #[test]
    fn find_matching_pop_respects_nesting() {
        let items = vec![
            push_scroll(1, 0.0, 0.0, 10.0, 10.0),
            push_scroll(2, 0.0, 0.0, 5.0, 5.0),
            DisplayListItem::PopScrollFrame,
            DisplayListItem::PopScrollFrame,
        ];
        assert_eq!(
            find_matching_pop(&items, 0, MatchKind::ScrollFrame),
            3,
            "outer pop"
        );
        assert_eq!(
            find_matching_pop(&items, 1, MatchKind::ScrollFrame),
            2,
            "inner pop"
        );
    }
    #[test]
    fn find_matching_pop_ignores_other_kinds() {
        let items = vec![
            push_scroll(1, 0.0, 0.0, 10.0, 10.0),
            DisplayListItem::PopOpacity,
            DisplayListItem::PopFilter,
            DisplayListItem::PopScrollFrame,
        ];
        assert_eq!(find_matching_pop(&items, 0, MatchKind::ScrollFrame), 3);
    }
    #[test]
    fn find_matching_pop_extra_pops_do_not_underflow_depth() {
        // depth is a u32: a stream of stray Pops must not wrap it below zero.
        let items = vec![
            DisplayListItem::PopScrollFrame,
            DisplayListItem::PopScrollFrame,
            DisplayListItem::PopScrollFrame,
        ];
        assert_eq!(find_matching_pop(&items, 0, MatchKind::ScrollFrame), 1);
    }
    // ============================== compute_exposed_rects ===================
    #[test]
    fn compute_exposed_rects_zero_and_subpixel_delta_expose_nothing() {
        let b = lr(0.0, 0.0, 200.0, 100.0);
        assert!(compute_exposed_rects(&b, 0.0, 0.0).is_empty());
        assert!(
            compute_exposed_rects(&b, 0.49, -0.49).is_empty(),
            "|d| <= 0.5 is a no-op"
        );
    }
    #[test]
    fn compute_exposed_rects_nan_delta_exposes_nothing() {
        let b = lr(0.0, 0.0, 200.0, 100.0);
        // NaN.abs() > 0.5 is false — no strip, no panic.
        assert!(compute_exposed_rects(&b, f32::NAN, f32::NAN).is_empty());
    }
    #[test]
    fn compute_exposed_rects_clamps_strip_to_bounds() {
        let b = lr(0.0, 0.0, 200.0, 100.0);
        let r = compute_exposed_rects(&b, 0.0, 1000.0);
        assert_eq!(r.len(), 1);
        assert_eq!(
            r[0].size.height, 100.0,
            "strip cannot exceed the frame height"
        );
        assert_eq!(r[0].size.width, 200.0);
    }
    #[test]
    fn compute_exposed_rects_infinite_delta_saturates_to_bounds() {
        let b = lr(0.0, 0.0, 200.0, 100.0);
        let pos = compute_exposed_rects(&b, 0.0, f32::INFINITY);
        assert_eq!(pos.len(), 1);
        assert_eq!(
            pos[0].size.height, 100.0,
            "+inf must clamp via min(h), not stay inf"
        );
        assert!(pos[0].origin.y.is_finite());
        let neg = compute_exposed_rects(&b, 0.0, f32::NEG_INFINITY);
        assert_eq!(neg.len(), 1);
        assert_eq!(neg[0].size.height, 100.0);
        assert!(!neg[0].size.height.is_nan());
    }
    #[test]
    fn compute_exposed_rects_diagonal_yields_two_strips() {
        let b = lr(0.0, 0.0, 200.0, 100.0);
        let r = compute_exposed_rects(&b, -10.0, 10.0);
        assert_eq!(
            r.len(),
            2,
            "diagonal scroll = vertical strip + horizontal strip"
        );
        assert!(r.iter().any(|s| s.size.width == 200.0), "full-width strip");
        assert!(
            r.iter().any(|s| s.size.height == 100.0),
            "full-height strip"
        );
    }
    // ============================== scroll_shift_region =====================
    #[test]
    fn scroll_shift_zero_dpi_is_a_noop() {
        let mut p = xy_map(64, 64);
        let strips = scroll_shift_region(
            &mut p,
            &lr(0.0, 0.0, 64.0, 64.0),
            (0.0, 30.0),
            (0.0, 30.0),
            0.0,
        );
        assert!(
            strips.is_empty(),
            "dpi 0 → 0 physical px moved → nothing exposed"
        );
        assert_eq!(at(&p, 10, 20), [10, 20, 0, 255], "buffer must be untouched");
    }
    #[test]
    fn scroll_shift_nan_dpi_is_a_noop() {
        let mut p = xy_map(64, 64);
        let strips = scroll_shift_region(
            &mut p,
            &lr(0.0, 0.0, 64.0, 64.0),
            (0.0, 30.0),
            (0.0, 30.0),
            f32::NAN,
        );
        assert!(
            strips.is_empty(),
            "NaN casts to 0 px — no move, no exposure"
        );
        assert_eq!(at(&p, 10, 20), [10, 20, 0, 255]);
    }
    #[test]
    fn scroll_shift_nan_offsets_are_a_noop() {
        let mut p = xy_map(64, 64);
        let strips = scroll_shift_region(
            &mut p,
            &lr(0.0, 0.0, 64.0, 64.0),
            (f32::NAN, f32::NAN),
            (f32::NAN, f32::NAN),
            1.0,
        );
        assert!(strips.is_empty());
        assert_eq!(at(&p, 10, 20), [10, 20, 0, 255]);
    }
    #[test]
    fn scroll_shift_negative_dpi_is_a_noop() {
        // A negative scale collapses the clamped clip region to zero width.
        let mut p = xy_map(64, 64);
        let strips = scroll_shift_region(
            &mut p,
            &lr(0.0, 0.0, 64.0, 64.0),
            (0.0, 10.0),
            (0.0, 10.0),
            -1.0,
        );
        assert!(strips.is_empty(), "negative dpi → empty region → no move");
        assert_eq!(at(&p, 10, 20), [10, 20, 0, 255]);
    }
    #[test]
    fn scroll_shift_clip_entirely_outside_pixmap_is_a_noop() {
        let mut p = xy_map(64, 64);
        let strips = scroll_shift_region(
            &mut p,
            &lr(500.0, 500.0, 10.0, 10.0),
            (0.0, 10.0),
            (0.0, 10.0),
            1.0,
        );
        assert!(
            strips.is_empty(),
            "off-screen clip clamps to an empty region"
        );
        assert_eq!(at(&p, 63, 63), [63, 63, 0, 255]);
    }
    #[test]
    fn scroll_shift_huge_offset_returns_whole_clip() {
        let mut p = xy_map(64, 64);
        let clip = lr(0.0, 0.0, 64.0, 64.0);
        let strips = scroll_shift_region(&mut p, &clip, (0.0, 1.0e9), (0.0, 1.0e9), 1.0);
        assert_eq!(
            strips.len(),
            1,
            "shift ≥ region → caller repaints the whole clip"
        );
        assert_eq!(strips[0].size.width, 64.0);
        assert_eq!(strips[0].size.height, 64.0);
        assert_eq!(
            at(&p, 10, 20),
            [10, 20, 0, 255],
            "memmove is skipped entirely"
        );
    }
    #[test]
    fn scroll_shift_infinite_dpi_returns_whole_clip() {
        let mut p = xy_map(64, 64);
        let clip = lr(0.0, 0.0, 64.0, 64.0);
        // inf saturates the px delta to i32::MAX → "exceeds region" branch.
        let strips = scroll_shift_region(&mut p, &clip, (0.0, 10.0), (0.0, 10.0), f32::INFINITY);
        assert_eq!(strips.len(), 1);
        assert_eq!(strips[0].size.height, 64.0);
    }
    #[test]
    fn scroll_shift_clip_larger_than_pixmap_keeps_strip_on_screen() {
        // Regression guard for the documented "stale duplicated band": the strip
        // must come from the CLAMPED region, so it always lands inside the pixmap.
        let mut p = xy_map(64, 64);
        let clip = lr(-100.0, -100.0, 400.0, 400.0);
        let strips = scroll_shift_region(&mut p, &clip, (0.0, 10.0), (0.0, 10.0), 1.0);
        assert_eq!(strips.len(), 1);
        let s = &strips[0];
        assert_eq!(s.origin.x, 0.0);
        assert_eq!(s.size.width, 64.0);
        assert!(
            s.origin.y >= 0.0 && s.origin.y + s.size.height <= 64.0,
            "exposed strip must stay inside the pixmap, got {s:?}"
        );
        assert_eq!(
            at(&p, 50, 10),
            [50, 20, 0, 255],
            "content translated up by 10"
        );
    }
    #[test]
    fn scroll_shift_rounds_absolute_offsets_not_the_delta() {
        // The doc promises round(new·dpi) − round(prev·dpi): at dpi 2 a +0.25
        // logical step is 0.5 physical px, which must NOT round to 1 px each frame.
        let mut p = xy_map(64, 64);
        let clip = lr(0.0, 0.0, 32.0, 32.0);
        let strips = scroll_shift_region(&mut p, &clip, (0.0, 0.25), (0.0, 10.25), 2.0);
        // round(10.25*2)=21 (round-half-away-from-zero on .5), round(10.0*2)=20 → 1px.
        assert_eq!(strips.len(), 1, "a 1px move exposes exactly one strip");
        assert_eq!(
            at(&p, 5, 0),
            [5, 1, 0, 255],
            "moved up by exactly 1 physical px"
        );
    }
    // ============================== shift_*_1d / shift_diagonal_2d ==========
    #[test]
    fn shift_vertical_1d_zero_delta_is_a_noop() {
        let mut p = xy_map(8, 8);
        let before = p.data().to_vec();
        shift_vertical_1d(p.data_mut(), 8, 0, 0, 8, 8, 0);
        assert_eq!(
            p.data(),
            &before[..],
            "px_dy = 0 must not touch a single byte"
        );
    }
    #[test]
    fn shift_vertical_1d_delta_larger_than_region_is_a_noop() {
        let mut p = xy_map(8, 8);
        let before = p.data().to_vec();
        shift_vertical_1d(p.data_mut(), 8, 0, 0, 8, 8, 100);
        assert_eq!(
            p.data(),
            &before[..],
            "|px_dy| ≥ height → empty row range, no panic"
        );
        shift_vertical_1d(p.data_mut(), 8, 0, 0, 8, 8, -100);
        assert_eq!(p.data(), &before[..]);
    }
    #[test]
    fn shift_vertical_1d_moves_content_up_and_down() {
        let mut p = xy_map(8, 8);
        shift_vertical_1d(p.data_mut(), 8, 0, 0, 8, 8, 2); // content up by 2
        assert_eq!(
            at(&p, 3, 0),
            [3, 2, 0, 255],
            "row 0 now holds original row 2"
        );
        assert_eq!(
            at(&p, 3, 5),
            [3, 7, 0, 255],
            "row 5 now holds original row 7"
        );
        let mut q = xy_map(8, 8);
        shift_vertical_1d(q.data_mut(), 8, 0, 0, 8, 8, -3); // content down by 3
        assert_eq!(
            at(&q, 3, 7),
            [3, 4, 0, 255],
            "row 7 now holds original row 4"
        );
        assert_eq!(
            at(&q, 3, 3),
            [3, 0, 0, 255],
            "row 3 now holds original row 0"
        );
    }
    #[test]
    fn shift_horizontal_1d_zero_delta_is_a_noop() {
        let mut p = xy_map(8, 8);
        let before = p.data().to_vec();
        shift_horizontal_1d(p.data_mut(), 8, 0, 0, 8, 8, 0);
        assert_eq!(p.data(), &before[..]);
    }
    #[test]
    fn shift_horizontal_1d_max_valid_shift_keeps_one_column() {
        // px_dx = region_w - 1 is the largest shift scroll_shift_region can pass
        // through (it early-returns at >= region_w): the copy must not underflow.
        let mut p = xy_map(8, 8);
        shift_horizontal_1d(p.data_mut(), 8, 0, 0, 8, 8, 7);
        assert_eq!(
            at(&p, 0, 4),
            [7, 4, 0, 255],
            "col 0 now holds original col 7"
        );
        let mut q = xy_map(8, 8);
        shift_horizontal_1d(q.data_mut(), 8, 0, 0, 8, 8, -7);
        assert_eq!(
            at(&q, 7, 4),
            [0, 4, 0, 255],
            "col 7 now holds original col 0"
        );
    }
    #[test]
    fn shift_horizontal_1d_only_touches_the_clip_columns() {
        let mut p = xy_map(8, 8);
        shift_horizontal_1d(p.data_mut(), 8, 2, 1, 6, 3, 1);
        // Outside the clip rows/cols: untouched.
        assert_eq!(at(&p, 0, 0), [0, 0, 0, 255]);
        assert_eq!(at(&p, 7, 2), [7, 2, 0, 255], "col 7 is outside cx0..cx1");
        assert_eq!(at(&p, 3, 7), [3, 7, 0, 255], "row 7 is outside cy0..cy1");
        // Inside: shifted left by 1.
        assert_eq!(at(&p, 2, 1), [3, 1, 0, 255]);
    }
    #[test]
    fn shift_diagonal_2d_full_width_shift_is_a_noop() {
        let mut p = xy_map(8, 8);
        let before = p.data().to_vec();
        shift_diagonal_2d(p.data_mut(), 8, 0, 0, 8, 8, 8, 1); // span_cols == 0
        assert_eq!(p.data(), &before[..], "nothing left to keep → early return");
    }
    #[test]
    fn shift_diagonal_2d_translates_both_axes_in_one_pass() {
        let mut p = xy_map(8, 8);
        shift_diagonal_2d(p.data_mut(), 8, 0, 0, 8, 8, 2, 3); // content up-left
        assert_eq!(at(&p, 0, 0), [2, 3, 0, 255], "(0,0) holds original (2,3)");
        assert_eq!(at(&p, 5, 4), [7, 7, 0, 255], "(5,4) holds original (7,7)");
        let mut q = xy_map(8, 8);
        shift_diagonal_2d(q.data_mut(), 8, 0, 0, 8, 8, -2, -3); // content down-right
        assert_eq!(at(&q, 7, 7), [5, 4, 0, 255], "(7,7) holds original (5,4)");
    }
    // ============================== rect_covered_by =========================
    #[test]
    fn rect_covered_by_empty_covers_is_false() {
        assert!(!rect_covered_by(&lr(0.0, 0.0, 10.0, 10.0), &[]));
    }
    #[test]
    fn rect_covered_by_degenerate_target_is_vacuously_covered() {
        let cover = [lr(0.0, 0.0, 10.0, 10.0)];
        // No sample points → the "every sample is inside" predicate holds.
        assert!(
            rect_covered_by(&lr(0.0, 0.0, 0.0, 0.0), &cover),
            "zero-size target"
        );
        assert!(
            rect_covered_by(&lr(0.0, 0.0, -50.0, -50.0), &cover),
            "negative-size target"
        );
        assert!(
            rect_covered_by(&lr(0.0, 0.0, f32::NAN, f32::NAN), &cover),
            "NaN target must terminate (no infinite while-float loop) and be defined"
        );
    }
    #[test]
    fn rect_covered_by_tolerates_sub_4px_gaps_but_not_wide_ones() {
        let target = lr(0.0, 0.0, 100.0, 100.0);
        // 1px gap at y ∈ [49, 50) — no 4px sample lands in it → still "covered".
        assert!(rect_covered_by(
            &target,
            &[lr(0.0, 0.0, 100.0, 49.0), lr(0.0, 50.0, 100.0, 50.0)]
        ));
        // 20px gap → sampled → not covered.
        assert!(!rect_covered_by(
            &target,
            &[lr(0.0, 0.0, 100.0, 40.0), lr(0.0, 60.0, 100.0, 40.0)]
        ));
    }
    // ============================== rects_overlap_or_adjacent ===============
    #[test]
    fn rects_touching_with_zero_gap_are_adjacent() {
        let a = lr(0.0, 0.0, 10.0, 10.0);
        let b = lr(10.0, 0.0, 10.0, 10.0);
        assert!(
            rects_overlap_or_adjacent(&a, &b, 0.0),
            "shared edge counts as adjacent"
        );
        assert!(!rects_overlap_or_adjacent(
            &a,
            &lr(11.0, 0.0, 10.0, 10.0),
            0.0
        ));
    }
    #[test]
    fn rects_overlap_negative_gap_requires_real_overlap() {
        let a = lr(0.0, 0.0, 10.0, 10.0);
        let b = lr(10.0, 0.0, 10.0, 10.0);
        assert!(
            !rects_overlap_or_adjacent(&a, &b, -1.0),
            "a negative gap must SHRINK the test, not widen it"
        );
        assert!(rects_overlap_or_adjacent(
            &a,
            &lr(5.0, 0.0, 10.0, 10.0),
            -1.0
        ));
    }
    #[test]
    fn rects_overlap_nan_inputs_are_false_not_panics() {
        let a = lr(0.0, 0.0, 10.0, 10.0);
        let nan = lr(f32::NAN, f32::NAN, f32::NAN, f32::NAN);
        assert!(
            !rects_overlap_or_adjacent(&a, &nan, 0.0),
            "NaN compares false everywhere"
        );
        assert!(
            !rects_overlap_or_adjacent(&a, &a, f32::NAN),
            "NaN gap → false"
        );
    }
    #[test]
    fn rects_overlap_infinite_gap_swallows_everything() {
        let a = lr(0.0, 0.0, 1.0, 1.0);
        let b = lr(1.0e9, 1.0e9, 1.0, 1.0);
        assert!(rects_overlap_or_adjacent(&a, &b, f32::INFINITY));
    }
    // ============================== coalesce_damage_rects ===================
    #[test]
    fn coalesce_empty_and_single_are_untouched() {
        let mut v: Vec<LogicalRect> = Vec::new();
        coalesce_damage_rects(&mut v);
        assert!(v.is_empty());
        let mut one = vec![lr(1.0, 2.0, 3.0, 4.0)];
        coalesce_damage_rects(&mut one);
        assert_eq!(one.len(), 1);
        assert_eq!(one[0].origin.x, 1.0);
    }
    #[test]
    fn coalesce_merges_identical_and_chained_rects() {
        let mut v = vec![lr(0.0, 0.0, 10.0, 10.0), lr(0.0, 0.0, 10.0, 10.0)];
        coalesce_damage_rects(&mut v);
        assert_eq!(v.len(), 1, "duplicates must collapse");
        let mut chain = vec![
            lr(0.0, 0.0, 10.0, 10.0),
            lr(5.0, 0.0, 10.0, 10.0),
            lr(10.0, 0.0, 10.0, 10.0),
        ];
        coalesce_damage_rects(&mut chain);
        assert_eq!(
            chain.len(),
            1,
            "an overlapping chain collapses transitively"
        );
        assert_eq!(chain[0].size.width, 20.0);
    }
    #[test]
    fn coalesce_keeps_distant_rects_separate() {
        let mut v = vec![lr(0.0, 0.0, 10.0, 10.0), lr(500.0, 500.0, 10.0, 10.0)];
        coalesce_damage_rects(&mut v);
        assert_eq!(v.len(), 2);
    }
    #[test]
    fn coalesce_rejects_perpendicular_strip_union() {
        // The documented anti-ballooning guard: a vertical + a horizontal
        // scrollbar strip touch at a corner but their union is the viewport.
        let mut v = vec![lr(990.0, 0.0, 10.0, 1000.0), lr(0.0, 990.0, 1000.0, 10.0)];
        coalesce_damage_rects(&mut v);
        assert_eq!(
            v.len(),
            2,
            "union would be 100× the painted area — must stay split"
        );
    }
    #[test]
    fn coalesce_with_nan_rects_terminates() {
        // NaN comparisons are always false → no merge, and the fixpoint loop
        // must still terminate rather than spin.
        let mut v = vec![
            lr(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
            lr(0.0, 0.0, 10.0, 10.0),
            lr(f32::NAN, 0.0, 10.0, 10.0),
        ];
        coalesce_damage_rects(&mut v);
        assert_eq!(v.len(), 3);
    }
    // ============================== compute_resize_damage ===================
    #[test]
    fn resize_damage_shrink_or_equal_is_empty() {
        assert!(compute_resize_damage(100.0, 100.0, 100.0, 100.0).is_empty());
        assert!(
            compute_resize_damage(100.0, 100.0, 50.0, 50.0).is_empty(),
            "grow-only"
        );
    }
    #[test]
    fn resize_damage_grow_both_axes_gives_right_and_bottom_strips() {
        let r = compute_resize_damage(100.0, 100.0, 200.0, 150.0);
        assert_eq!(r.len(), 2);
        assert_eq!(r[0].origin.x, 100.0, "right strip starts at the old width");
        assert_eq!(r[0].size.width, 100.0);
        assert_eq!(r[0].size.height, 150.0, "right strip spans the NEW height");
        assert_eq!(
            r[1].origin.y, 100.0,
            "bottom strip starts at the old height"
        );
        assert_eq!(
            r[1].size.width, 100.0,
            "bottom strip is min(old, new) wide — no overdraw"
        );
        assert_eq!(r[1].size.height, 50.0);
    }
    #[test]
    fn resize_damage_zero_and_nan_are_defined() {
        assert!(compute_resize_damage(0.0, 0.0, 0.0, 0.0).is_empty());
        assert_eq!(compute_resize_damage(0.0, 0.0, 10.0, 10.0).len(), 2);
        assert!(
            compute_resize_damage(f32::NAN, f32::NAN, f32::NAN, f32::NAN).is_empty(),
            "NaN > NaN is false → no damage, no panic"
        );
        assert!(compute_resize_damage(f32::NAN, f32::NAN, 10.0, 10.0).is_empty());
    }
    #[test]
    fn resize_damage_infinite_new_size_does_not_panic() {
        let r = compute_resize_damage(0.0, 0.0, f32::INFINITY, f32::INFINITY);
        assert_eq!(r.len(), 2);
        assert!(
            r[0].size.width.is_infinite(),
            "inf propagates, but nothing panics"
        );
    }
    // ============================== compare_region ==========================
    #[test]
    fn compare_region_identical_pixmaps_report_zero() {
        let a = solid(4, 4, [1, 2, 3, 255]);
        let b = solid(4, 4, [1, 2, 3, 255]);
        assert_eq!(compare_region(&a, &b, 0, 0, 4, 4, 0), 0);
    }
    #[test]
    fn compare_region_threshold_255_never_counts_a_pixel() {
        // Max per-channel distance is 255, and the test is strictly `>`.
        let a = solid(4, 4, [0, 0, 0, 255]);
        let b = solid(4, 4, [255, 255, 255, 255]);
        assert_eq!(
            compare_region(&a, &b, 0, 0, 4, 4, 255),
            0,
            "saturated threshold = blind"
        );
        assert_eq!(
            compare_region(&a, &b, 0, 0, 4, 4, 254),
            16,
            "one below → every pixel"
        );
        assert_eq!(compare_region(&a, &b, 0, 0, 4, 4, 0), 16);
    }
    #[test]
    fn compare_region_ignores_the_alpha_channel() {
        let a = solid(4, 4, [9, 9, 9, 255]);
        let b = solid(4, 4, [9, 9, 9, 0]);
        assert_eq!(
            compare_region(&a, &b, 0, 0, 4, 4, 0),
            0,
            "only RGB is compared"
        );
    }
    #[test]
    fn compare_region_clamps_oversized_and_empty_regions() {
        let a = solid(4, 4, [0, 0, 0, 255]);
        let b = solid(4, 4, [255, 255, 255, 255]);
        assert_eq!(
            compare_region(&a, &b, 0, 0, u32::MAX, u32::MAX, 0),
            16,
            "w/h are clamped to the pixmaps — no OOB, no overflow"
        );
        assert_eq!(compare_region(&a, &b, 0, 0, 0, 0, 0), 0, "empty region");
        assert_eq!(
            compare_region(&a, &b, u32::MAX, u32::MAX, 0, 0, 0),
            0,
            "origin at u32::MAX with a zero extent must not overflow x + w"
        );
    }
    #[test]
    fn compare_region_with_mismatched_pixmap_sizes_uses_the_overlap() {
        let a = solid(4, 4, [0, 0, 0, 255]);
        let b = solid(2, 2, [255, 255, 255, 255]);
        assert_eq!(
            compare_region(&a, &b, 0, 0, 4, 4, 0),
            4,
            "iteration clamps to min(a, b) — the 2×2 overlap"
        );
    }
    // ============================== opaque_fill_rect ========================
    #[test]
    fn opaque_fill_rect_accepts_only_opaque_square_rects() {
        assert!(opaque_fill_rect(&opaque_rect(1.0, 2.0, 3.0, 4.0)).is_some());
        assert!(
            opaque_fill_rect(&rect_item(
                0.0,
                0.0,
                1.0,
                1.0,
                ColorU {
                    r: 0,
                    g: 0,
                    b: 0,
                    a: 254
                }
            ))
            .is_none(),
            "a = 254 is not fully opaque"
        );
        let rounded = DisplayListItem::Rect {
            bounds: wlr(0.0, 0.0, 10.0, 10.0),
            color: ColorU {
                r: 0,
                g: 0,
                b: 0,
                a: 255,
            },
            border_radius: BorderRadius {
                top_left: 0.1,
                top_right: 0.0,
                bottom_left: 0.0,
                bottom_right: 0.0,
            },
        };
        assert!(
            opaque_fill_rect(&rounded).is_none(),
            "any corner radius disqualifies"
        );
        assert!(opaque_fill_rect(&DisplayListItem::PopScrollFrame).is_none());
        let b = opaque_fill_rect(&opaque_rect(1.0, 2.0, 3.0, 4.0)).unwrap();
        assert_eq!((b.origin.x, b.size.height), (1.0, 4.0));
    }
    // ============================== scroll_fast_path_eligible ===============
    #[test]
    fn fast_path_eligible_when_no_such_frame() {
        assert!(scroll_fast_path_eligible(
            &dlist(vec![]),
            3,
            &lr(0.0, 0.0, 10.0, 10.0),
            (0.0, 0.0),
            (0.0, 0.0)
        ));
    }
    #[test]
    fn fast_path_ineligible_for_a_nested_frame() {
        // Inner clip_bounds are in the OUTER frame's content space → memmove
        // would shift the wrong region.
        let list = dlist(vec![
            push_scroll(1, 0.0, 0.0, 100.0, 100.0),
            push_scroll(2, 0.0, 0.0, 50.0, 50.0),
            opaque_rect(0.0, 0.0, 50.0, 500.0),
            DisplayListItem::PopScrollFrame,
            DisplayListItem::PopScrollFrame,
        ]);
        assert!(
            !scroll_fast_path_eligible(&list, 2, &lr(0.0, 0.0, 50.0, 50.0), (0.0, 0.0), (0.0, 0.0)),
            "a nested frame must fall back to a full repaint"
        );
        assert!(
            scroll_fast_path_eligible(
                &list,
                1,
                &lr(0.0, 0.0, 100.0, 100.0),
                (0.0, 0.0),
                (0.0, 0.0)
            ),
            "the outer frame is still eligible (nothing painted behind it)"
        );
    }
    #[test]
    fn fast_path_zero_area_clip_does_not_divide_by_zero() {
        let list = dlist(vec![
            push_scroll(1, 0.0, 0.0, 0.0, 0.0),
            opaque_rect(0.0, 0.0, 10.0, 10.0),
            DisplayListItem::PopScrollFrame,
        ]);
        // clip_area is max(1.0)-guarded; must return a bool, not panic.
        assert!(scroll_fast_path_eligible(
            &list,
            1,
            &lr(0.0, 0.0, 0.0, 0.0),
            (0.0, 0.0),
            (0.0, 0.0)
        ));
    }
    #[test]
    fn fast_path_checks_coverage_at_both_offsets() {
        // Content covers the clip only while the frame is at offset 0; scrolled
        // by 60 it uncovers the bottom half, exposing a partial backdrop.
        let list = dlist(vec![
            opaque_rect(0.0, 0.0, 100.0, 40.0), // partial backdrop (≥10% of clip)
            push_scroll(7, 0.0, 0.0, 100.0, 100.0),
            opaque_rect(0.0, 0.0, 100.0, 100.0), // covers the clip at offset 0 only
            DisplayListItem::PopScrollFrame,
        ]);
        let clip = lr(0.0, 0.0, 100.0, 100.0);
        assert!(scroll_fast_path_eligible(
            &list,
            7,
            &clip,
            (0.0, 0.0),
            (0.0, 0.0)
        ));
        assert!(
            !scroll_fast_path_eligible(&list, 7, &clip, (0.0, 60.0), (0.0, 0.0)),
            "coverage must hold at the NEW offset too, not just the old one"
        );
        assert!(
            !scroll_fast_path_eligible(&list, 7, &clip, (0.0, 0.0), (0.0, 60.0)),
            "…and at the OLD offset, where the dragged pixels were rendered"
        );
    }
    // ============================== overlay_rects_after_frame ================
    #[test]
    fn overlay_rects_empty_without_a_matching_frame() {
        let list = dlist(vec![opaque_rect(0.0, 0.0, 10.0, 10.0)]);
        assert!(overlay_rects_after_frame(&list, 99, &lr(0.0, 0.0, 10.0, 10.0)).is_empty());
    }
    #[test]
    fn overlay_rects_require_strict_overlap_and_are_clipped() {
        let clip = lr(0.0, 0.0, 100.0, 100.0);
        let list = dlist(vec![
            push_scroll(1, 0.0, 0.0, 100.0, 100.0),
            opaque_rect(0.0, 0.0, 100.0, 500.0), // inside the frame — not an overlay
            DisplayListItem::PopScrollFrame,
            opaque_rect(100.0, 0.0, 10.0, 10.0), // merely TOUCHES the clip edge
            opaque_rect(90.0, 90.0, 40.0, 40.0), // genuinely overlaps
            DisplayListItem::PushClip {
                // state-management → skipped
                bounds: wlr(0.0, 0.0, 100.0, 100.0),
                border_radius: BorderRadius::default(),
            },
        ]);
        let r = overlay_rects_after_frame(&list, 1, &clip);
        assert_eq!(r.len(), 1, "only the strictly-overlapping item, got {r:?}");
        assert_eq!(r[0].origin.x, 90.0);
        assert_eq!(
            r[0].size.width, 10.0,
            "intersection is clipped to the frame"
        );
        assert_eq!(r[0].size.height, 10.0);
    }
    // ============================== gpu_value_damage =========================
    fn translate(tx: f32, ty: f32) -> ComputedTransform3D {
        ComputedTransform3D {
            m: [
                [1.0, 0.0, 0.0, 0.0],
                [0.0, 1.0, 0.0, 0.0],
                [0.0, 0.0, 1.0, 0.0],
                [tx, ty, 0.0, 1.0],
            ],
        }
    }
    fn ref_frame(key: usize) -> DisplayListItem {
        DisplayListItem::PushReferenceFrame {
            transform_key: TransformKey { id: key },
            initial_transform: ComputedTransform3D::IDENTITY,
            bounds: wlr(0.0, 0.0, 50.0, 50.0),
        }
    }
    #[test]
    fn gpu_value_damage_unchanged_maps_report_nothing() {
        let list = dlist(vec![ref_frame(3), DisplayListItem::PopReferenceFrame]);
        let mut t: HashMap<usize, ComputedTransform3D> = HashMap::new();
        t.insert(3, translate(1.0, 1.0));
        let mut o: HashMap<usize, f32> = HashMap::new();
        o.insert(4, 0.5);
        let d = gpu_value_damage(&list, &t, &o, &t.clone(), &o.clone());
        assert!(d.rects.is_empty());
        assert!(!d.needs_full);
        let empty_t: HashMap<usize, ComputedTransform3D> = HashMap::new();
        let empty_o: HashMap<usize, f32> = HashMap::new();
        let d2 = gpu_value_damage(&list, &empty_t, &empty_o, &empty_t, &empty_o);
        assert!(
            d2.rects.is_empty() && !d2.needs_full,
            "empty maps → no damage"
        );
    }
    #[test]
    fn gpu_value_damage_changed_transform_on_a_reference_frame_damages_both_positions() {
        let list = dlist(vec![ref_frame(3), DisplayListItem::PopReferenceFrame]);
        let mut old_t: HashMap<usize, ComputedTransform3D> = HashMap::new();
        old_t.insert(3, ComputedTransform3D::IDENTITY);
        let mut new_t: HashMap<usize, ComputedTransform3D> = HashMap::new();
        new_t.insert(3, translate(20.0, 0.0));
        let o: HashMap<usize, f32> = HashMap::new();
        let d = gpu_value_damage(&list, &old_t, &o, &new_t, &o);
        // The content extent IS knowable (the frame's items + its own
        // bounds), so a moved frame damages its content at the OLD and the
        // NEW matrix — the previous blanket needs_full made every spring
        // tick a full-frame repaint.
        assert!(!d.needs_full, "affine moves must not full-repaint");
        assert_eq!(d.rects.len(), 2, "old position + new position");
        let dx = (d.rects[1].origin.x - d.rects[0].origin.x).abs();
        assert!(
            (dx - 20.0).abs() < 0.01,
            "the two rects are 20px apart, got {dx}"
        );
    }
    #[test]
    fn gpu_value_damage_removed_key_counts_as_changed() {
        let list = dlist(vec![ref_frame(3), DisplayListItem::PopReferenceFrame]);
        let mut old_t: HashMap<usize, ComputedTransform3D> = HashMap::new();
        old_t.insert(3, translate(5.0, 5.0));
        let new_t: HashMap<usize, ComputedTransform3D> = HashMap::new();
        let o: HashMap<usize, f32> = HashMap::new();
        let d = gpu_value_damage(&list, &old_t, &o, &new_t, &o);
        assert!(
            !d.rects.is_empty() || d.needs_full,
            "a key present in old but absent in new is a change (the frame settles to identity, \
             damaging its old offset position)"
        );
        assert!(
            !d.needs_full,
            "the settle is affine — rect damage, not full"
        );
    }
    #[test]
    fn gpu_value_damage_ignores_keys_bound_to_nothing() {
        let list = dlist(vec![opaque_rect(0.0, 0.0, 10.0, 10.0)]);
        let t: HashMap<usize, ComputedTransform3D> = HashMap::new();
        let mut new_t: HashMap<usize, ComputedTransform3D> = HashMap::new();
        new_t.insert(77, translate(1.0, 0.0));
        let old_o: HashMap<usize, f32> = HashMap::new();
        let mut new_o: HashMap<usize, f32> = HashMap::new();
        new_o.insert(88, 0.25);
        let d = gpu_value_damage(&list, &t, &old_o, &new_t, &new_o);
        assert!(
            d.rects.is_empty() && !d.needs_full,
            "a key bound to no item cannot damage"
        );
    }
    #[test]
    fn gpu_value_damage_nan_opacity_is_change_but_still_unbound() {
        let list = dlist(vec![opaque_rect(0.0, 0.0, 10.0, 10.0)]);
        let t: HashMap<usize, ComputedTransform3D> = HashMap::new();
        let mut o: HashMap<usize, f32> = HashMap::new();
        o.insert(1, f32::NAN);
        // NaN != NaN → the key reads as "changed"; nothing binds it, so no damage.
        let d = gpu_value_damage(&list, &t, &o.clone(), &t, &o);
        assert!(d.rects.is_empty() && !d.needs_full);
    }
    // ============================== display list diffing =====================
    #[test]
    fn display_lists_visually_equal_basics() {
        let a = dlist(vec![opaque_rect(0.0, 0.0, 10.0, 10.0)]);
        let b = dlist(vec![opaque_rect(0.0, 0.0, 10.0, 10.0)]);
        assert!(
            display_lists_visually_equal(&dlist(vec![]), &dlist(vec![])),
            "empty == empty"
        );
        assert!(display_lists_visually_equal(&a, &b));
        assert!(
            !display_lists_visually_equal(&a, &dlist(vec![])),
            "length differs"
        );
        let c = dlist(vec![rect_item(
            0.0,
            0.0,
            10.0,
            10.0,
            ColorU {
                r: 9,
                g: 9,
                b: 9,
                a: 255,
            },
        )]);
        assert!(!display_lists_visually_equal(&a, &c), "colour differs");
        let d = dlist(vec![DisplayListItem::PopClip]);
        assert!(
            !display_lists_visually_equal(&a, &d),
            "discriminant differs"
        );
    }
    /// DETECTOR for a bug class, not for one widget.
    ///
    /// Reported against the slider: dragging it left a trail of blue thumbs on
    /// the grey track, each the thumb's previous position, never repainted. The
    /// slider is not special — anything that repositions a child by a layout
    /// property is exposed the same way. The diff can notice an item at its NEW
    /// rect and forget the OLD one, and what you see is stale paint that reads
    /// as a rendering glitch rather than a damage bug.
    ///
    /// The invariant, stated once so every future mover inherits it:
    ///
    ///   when an item moves, the damage must cover BOTH the rect it left and
    ///   the rect it arrived at.
    ///
    /// Covering only the new rect is the trail. Covering only the old one is an
    /// element that never appears. Same defect, two symptoms, one assertion.
    #[test]
    fn a_moved_item_damages_the_pixels_it_vacated() {
        let off = ScrollOffsetMap::new();
        // Sweep a range of distances: a mover that overlaps its old position
        // can be covered by a single sloppy rect and hide the bug, so include
        // clearly disjoint moves too.
        for delta in [4.0_f32, 16.0, 64.0, 150.0] {
            let a = dlist(vec![opaque_rect(0.0, 0.0, 16.0, 16.0)]);
            let b = dlist(vec![opaque_rect(delta, 0.0, 16.0, 16.0)]);
            let d = compute_display_list_damage(&a, &b, &off, &off)
                .expect("a pure move is not a structural change");
            let covers = |x: f32, y: f32, w: f32, h: f32| {
                let (x1, y1) = (x + w, y + h);
                d.iter().any(|r| {
                    r.origin.x <= x + 0.01
                        && r.origin.y <= y + 0.01
                        && r.origin.x + r.size.width + 0.01 >= x1
                        && r.origin.y + r.size.height + 0.01 >= y1
                })
            };
            assert!(
                covers(delta, 0.0, 16.0, 16.0),
                "delta={delta}: damage misses the NEW rect, so the item would never appear there. \
                 damage={d:?}"
            );
            assert!(
                covers(0.0, 0.0, 16.0, 16.0),
                "delta={delta}: damage misses the pixels the item VACATED. The old paint stays on \
                 screen — this is the slider's trail of blue thumbs on the grey track, and it is \
                 a property of the damage diff, not of the slider. damage={d:?}"
            );
        }
    }
    #[test]
    fn damage_diff_windows_a_structural_change() {
        // A structural change used to bail to a FULL repaint; it now damages
        // the changed window (here: the removed rect's own area).
        let off = ScrollOffsetMap::new();
        let a = dlist(vec![opaque_rect(0.0, 0.0, 10.0, 10.0)]);
        let d = compute_display_list_damage(&a, &dlist(vec![]), &off, &off)
            .expect("a removed item damages its area, not the full frame");
        assert_eq!(d.len(), 1);
        assert!((d[0].size.width - 10.0).abs() < 0.5 && (d[0].size.height - 10.0).abs() < 0.5);
        let b = dlist(vec![DisplayListItem::PopClip]);
        let d = compute_display_list_damage(&a, &b, &off, &off)
            .expect("a replaced item damages its area");
        assert_eq!(
            d.len(),
            1,
            "the boundless PopClip contributes no rect of its own"
        );
        // A change with NO bounds-carrying item anywhere in the window stays
        // a full repaint — conservative, never "no damage".
        let c = dlist(vec![DisplayListItem::PopClip]);
        let e = dlist(vec![DisplayListItem::PopScrollFrame]);
        assert!(compute_display_list_damage(&c, &e, &off, &off).is_none());
    }
    #[test]
    fn damage_diff_of_identical_lists_is_empty() {
        let off = ScrollOffsetMap::new();
        let a = dlist(vec![opaque_rect(0.0, 0.0, 10.0, 10.0)]);
        let b = dlist(vec![opaque_rect(0.0, 0.0, 10.0, 10.0)]);
        let d = compute_display_list_damage(&a, &b, &off, &off).expect("no structural change");
        assert!(d.is_empty(), "identical frames damage nothing");
        let e = compute_display_list_damage(&dlist(vec![]), &dlist(vec![]), &off, &off);
        assert_eq!(
            e.map(|v| v.len()),
            Some(0),
            "two empty lists are comparable"
        );
    }
    #[test]
    fn damage_diff_covers_a_colour_change() {
        let off = ScrollOffsetMap::new();
        let a = dlist(vec![opaque_rect(10.0, 10.0, 20.0, 20.0)]);
        let b = dlist(vec![rect_item(
            10.0,
            10.0,
            20.0,
            20.0,
            ColorU {
                r: 1,
                g: 1,
                b: 1,
                a: 255,
            },
        )]);
        let d = compute_display_list_damage(&a, &b, &off, &off).unwrap();
        assert_eq!(d.len(), 1, "old + new bounds coincide → one coalesced rect");
        assert_eq!(d[0].origin.x, 10.0);
        assert_eq!(d[0].size.width, 20.0);
    }
    #[test]
    fn damage_diff_projects_scroll_offsets_into_viewport_space() {
        // The item lives at content y = 100 inside frame 1. Old offset 0, new
        // offset 50 → its pixels were at y=100 and will be at y=50.
        let mut old_off = ScrollOffsetMap::new();
        old_off.insert(1, (0.0, 0.0));
        let mut new_off = ScrollOffsetMap::new();
        new_off.insert(1, (0.0, 50.0));
        let old = dlist(vec![
            push_scroll(1, 0.0, 0.0, 100.0, 100.0),
            opaque_rect(0.0, 100.0, 10.0, 10.0),
            DisplayListItem::PopScrollFrame,
        ]);
        let new = dlist(vec![
            push_scroll(1, 0.0, 0.0, 100.0, 100.0),
            rect_item(
                0.0,
                100.0,
                10.0,
                10.0,
                ColorU {
                    r: 7,
                    g: 7,
                    b: 7,
                    a: 255,
                },
            ),
            DisplayListItem::PopScrollFrame,
        ]);
        let d = compute_display_list_damage(&old, &new, &old_off, &new_off).unwrap();
        assert_eq!(
            d.len(),
            2,
            "old and new positions are 40px apart → no merge, got {d:?}"
        );
        let ys: Vec<f32> = d.iter().map(|r| r.origin.y).collect();
        assert!(
            ys.contains(&100.0),
            "old pixels at y=100 (offset 0), got {ys:?}"
        );
        assert!(
            ys.contains(&50.0),
            "new pixels at y=50 (offset 50), got {ys:?}"
        );
    }
    // ============================== compute_virtual_view_damage ==============
    #[test]
    fn virtual_view_damage_without_virtual_views_is_empty() {
        let parent = dlist(vec![opaque_rect(0.0, 0.0, 10.0, 10.0)]);
        let cur: BTreeMap<DomId, Arc<DisplayList>> = BTreeMap::new();
        let prev: BTreeMap<DomId, Arc<DisplayList>> = BTreeMap::new();
        assert!(compute_virtual_view_damage(&parent, &cur, &prev).is_empty());
    }
    #[test]
    fn virtual_view_damage_tracks_child_dom_changes() {
        let dom = DomId { inner: 1 };
        let parent = dlist(vec![DisplayListItem::VirtualView {
            child_dom_id: dom,
            bounds: wlr(5.0, 6.0, 40.0, 30.0),
            clip_rect: wlr(5.0, 6.0, 40.0, 30.0),
            content_offset: Default::default(),
        }]);
        let shared = Arc::new(dlist(vec![opaque_rect(0.0, 0.0, 10.0, 10.0)]));
        let equal_but_distinct = Arc::new(dlist(vec![opaque_rect(0.0, 0.0, 10.0, 10.0)]));
        let different = Arc::new(dlist(vec![opaque_rect(0.0, 0.0, 99.0, 99.0)]));
        let mut cur: BTreeMap<DomId, Arc<DisplayList>> = BTreeMap::new();
        let mut prev: BTreeMap<DomId, Arc<DisplayList>> = BTreeMap::new();
        // Same Arc → cheap pointer fast-path → no damage.
        cur.insert(dom, Arc::clone(&shared));
        prev.insert(dom, Arc::clone(&shared));
        assert!(compute_virtual_view_damage(&parent, &cur, &prev).is_empty());
        // Distinct Arcs, identical content → still no damage.
        cur.insert(dom, Arc::clone(&equal_but_distinct));
        assert!(compute_virtual_view_damage(&parent, &cur, &prev).is_empty());
        // Content actually changed → damage the VirtualView's on-screen bounds.
        cur.insert(dom, Arc::clone(&different));
        let d = compute_virtual_view_damage(&parent, &cur, &prev);
        assert_eq!(d.len(), 1);
        assert_eq!(d[0].origin.x, 5.0);
        assert_eq!(d[0].size.width, 40.0);
        // Newly present child (absent last frame) counts as changed.
        prev.remove(&dom);
        assert_eq!(compute_virtual_view_damage(&parent, &cur, &prev).len(), 1);
        // Absent in both → nothing to draw, nothing to damage.
        cur.remove(&dom);
        assert!(compute_virtual_view_damage(&parent, &cur, &prev).is_empty());
    }
    /// A BLINKING CARET MUST NOT REPAINT THE DOCUMENT.
    ///
    /// AzWriter's document body is a `VirtualView`, and the caret lives inside
    /// that child DOM. Measured on X11/CPU before this rule existed, with the
    /// window otherwise completely idle:
    ///
    ///     [X11 cpu present] total=45.72ms | render=29.90ms blit=15.65ms ...
    ///
    /// every 1200 ms - the caret-blink interval - because ANY difference in the
    /// child display list damaged the VirtualView's whole on-screen box. The
    /// item diff the parent list already gets was never run on the child, so a
    /// 2x18 caret cost a full-window re-raster AND a full-window swizzle +
    /// XPutImage.
    ///
    /// The rule: damage what CHANGED inside the view, translated into parent
    /// space and clipped to the view - never the whole view because something
    /// in it moved.
    #[test]
    fn a_caret_blink_inside_a_virtual_view_damages_the_caret_not_the_view() {
        let dom = DomId { inner: 1 };
        // A document viewport the size of a real window body.
        let parent = dlist(vec![DisplayListItem::VirtualView {
            child_dom_id: dom,
            bounds: wlr(10.0, 20.0, 1200.0, 900.0),
            clip_rect: wlr(10.0, 20.0, 1200.0, 900.0),
            content_offset: Default::default(),
        }]);
        // Two frames of the child: identical except the caret, which is on in
        // one and off in the other. Same item count - this is a blink, not a
        // structural change.
        let caret_on = Arc::new(dlist(vec![
            opaque_rect(0.0, 0.0, 1200.0, 40.0),
            opaque_rect(300.0, 400.0, 2.0, 18.0),
        ]));
        let caret_off = Arc::new(dlist(vec![
            opaque_rect(0.0, 0.0, 1200.0, 40.0),
            rect_item(
                300.0,
                400.0,
                2.0,
                18.0,
                ColorU {
                    r: 255,
                    g: 255,
                    b: 255,
                    a: 255,
                },
            ),
        ]));
        let mut cur: BTreeMap<DomId, Arc<DisplayList>> = BTreeMap::new();
        let mut prev: BTreeMap<DomId, Arc<DisplayList>> = BTreeMap::new();
        prev.insert(dom, Arc::clone(&caret_on));
        cur.insert(dom, Arc::clone(&caret_off));
        let d = compute_virtual_view_damage(&parent, &cur, &prev);
        assert!(
            !d.is_empty(),
            "the caret DID change - something must repaint"
        );
        let view_area = 1200.0 * 900.0;
        let damaged: f32 = d.iter().map(|r| r.size.width * r.size.height).sum();
        assert!(
            damaged < view_area * 0.01,
            "a 2x18 caret must not damage the document: {damaged}px of {view_area}px in {d:?}"
        );
        // ...and it must be damaged where it actually IS on screen: the child
        // list is 0-relative, the view sits at (10, 20).
        let caret = d[0];
        assert!(
            (caret.origin.x - 310.0).abs() < 2.0 && (caret.origin.y - 420.0).abs() < 2.0,
            "caret damage must be translated into parent space, got {caret:?}"
        );
    }
    /// The precise path must not leak damage outside the view. A child item
    /// that changed while scrolled out of sight is behind the view's clip, so
    /// repainting its position would dirty a neighbour's pixels.
    #[test]
    fn virtual_view_damage_is_clipped_to_the_view() {
        let dom = DomId { inner: 1 };
        let parent = dlist(vec![DisplayListItem::VirtualView {
            child_dom_id: dom,
            bounds: wlr(0.0, 0.0, 100.0, 50.0),
            clip_rect: wlr(0.0, 0.0, 100.0, 50.0),
            content_offset: Default::default(),
        }]);
        let a = Arc::new(dlist(vec![
            opaque_rect(0.0, 0.0, 10.0, 10.0),
            opaque_rect(0.0, 400.0, 10.0, 10.0),
        ]));
        let b = Arc::new(dlist(vec![
            opaque_rect(0.0, 0.0, 10.0, 10.0),
            opaque_rect(0.0, 400.0, 99.0, 10.0),
        ]));
        let mut cur: BTreeMap<DomId, Arc<DisplayList>> = BTreeMap::new();
        let mut prev: BTreeMap<DomId, Arc<DisplayList>> = BTreeMap::new();
        prev.insert(dom, Arc::clone(&a));
        cur.insert(dom, Arc::clone(&b));
        for r in compute_virtual_view_damage(&parent, &cur, &prev) {
            assert!(
                r.origin.x >= 0.0
                    && r.origin.y >= 0.0
                    && r.origin.x + r.size.width <= 100.0
                    && r.origin.y + r.size.height <= 50.0,
                "damage {r:?} escapes the view box 0,0 100x50"
            );
        }
    }
    /// A structural change in the child (the diff cannot pair items) still
    /// falls back to the whole view. Precision is an optimisation; correctness
    /// is not negotiable.
    #[test]
    fn a_structural_child_change_still_damages_the_whole_view() {
        let dom = DomId { inner: 1 };
        let parent = dlist(vec![DisplayListItem::VirtualView {
            child_dom_id: dom,
            bounds: wlr(5.0, 6.0, 40.0, 30.0),
            clip_rect: wlr(5.0, 6.0, 40.0, 30.0),
            content_offset: Default::default(),
        }]);
        // Present last frame, gone this frame: nothing to diff against.
        let only = Arc::new(dlist(vec![opaque_rect(0.0, 0.0, 10.0, 10.0)]));
        let mut cur: BTreeMap<DomId, Arc<DisplayList>> = BTreeMap::new();
        let prev: BTreeMap<DomId, Arc<DisplayList>> = BTreeMap::new();
        cur.insert(dom, Arc::clone(&only));
        let d = compute_virtual_view_damage(&parent, &cur, &prev);
        assert_eq!(d.len(), 1);
        assert_eq!(d[0].size.width, 40.0);
        assert_eq!(d[0].size.height, 30.0);
    }
    // ============================== apply_layer_filters ======================
    #[test]
    fn filters_empty_list_is_a_noop() {
        let mut p = solid(2, 2, [10, 20, 30, 40]);
        apply_layer_filters(&mut p, &[], 1.0);
        assert_eq!(at(&p, 0, 0), [10, 20, 30, 40]);
    }
    #[test]
    fn filter_opacity_saturates_at_both_ends() {
        let mut zero = solid(2, 2, [10, 20, 30, 255]);
        apply_layer_filters(
            &mut zero,
            &[StyleFilter::Opacity(PercentageValue::new(0.0))],
            1.0,
        );
        assert_eq!(at(&zero, 0, 0)[3], 0, "0% → fully transparent");
        let mut half = solid(2, 2, [10, 20, 30, 255]);
        apply_layer_filters(
            &mut half,
            &[StyleFilter::Opacity(PercentageValue::new(50.0))],
            1.0,
        );
        assert_eq!(at(&half, 0, 0)[3], 127, "50% → 127 (127.5 truncated)");
        let mut over = solid(2, 2, [10, 20, 30, 255]);
        apply_layer_filters(
            &mut over,
            &[StyleFilter::Opacity(PercentageValue::new(500.0))],
            1.0,
        );
        assert_eq!(at(&over, 0, 0)[3], 255, "500% must clamp, not wrap");
        let mut neg = solid(2, 2, [10, 20, 30, 255]);
        apply_layer_filters(
            &mut neg,
            &[StyleFilter::Opacity(PercentageValue::new(-100.0))],
            1.0,
        );
        assert_eq!(
            at(&neg, 0, 0)[3],
            0,
            "a negative percentage clamps to 0, not to 255"
        );
        assert_eq!(at(&neg, 0, 0)[0], 10, "RGB is untouched by opacity");
    }
    #[test]
    fn filter_nan_percentage_quantizes_to_zero_and_does_not_panic() {
        // PercentageValue is fixed-point (isize ×1000): `NaN as isize` saturates
        // to 0, so a NaN filter amount degrades to 0% rather than poisoning the
        // pixels with NaN.
        let mut p = solid(2, 2, [200, 100, 50, 255]);
        apply_layer_filters(
            &mut p,
            &[StyleFilter::Grayscale(PercentageValue::new(f32::NAN))],
            1.0,
        );
        assert_eq!(
            at(&p, 0, 0),
            [200, 100, 50, 255],
            "NaN amount ⇒ 0% ⇒ identity"
        );
    }
    #[test]
    fn filter_brightness_clamps_below_zero_and_above_white() {
        let mut dark = solid(2, 2, [200, 100, 50, 255]);
        apply_layer_filters(
            &mut dark,
            &[StyleFilter::Brightness(PercentageValue::new(-500.0))],
            1.0,
        );
        assert_eq!(
            at(&dark, 0, 0),
            [0, 0, 0, 255],
            "negative brightness floors at black"
        );
        let mut bright = solid(2, 2, [100, 100, 100, 255]);
        apply_layer_filters(
            &mut bright,
            &[StyleFilter::Brightness(PercentageValue::new(100_000.0))],
            1.0,
        );
        assert_eq!(
            at(&bright, 0, 0),
            [255, 255, 255, 255],
            "huge brightness saturates to white"
        );
    }
    #[test]
    fn filter_grayscale_and_saturate_agree_at_their_extremes() {
        // luma(255, 0, 0) = 0.2126 * 255 ≈ 54
        let mut gray = solid(2, 2, [255, 0, 0, 255]);
        apply_layer_filters(
            &mut gray,
            &[StyleFilter::Grayscale(PercentageValue::new(100.0))],
            1.0,
        );
        let g = at(&gray, 0, 0);
        assert_eq!(
            (g[0], g[1], g[2]),
            (54, 54, 54),
            "full grayscale → luma on every channel"
        );
        assert_eq!(g[3], 255, "alpha untouched");
        let mut desat = solid(2, 2, [255, 0, 0, 255]);
        apply_layer_filters(
            &mut desat,
            &[StyleFilter::Saturate(PercentageValue::new(0.0))],
            1.0,
        );
        assert_eq!(at(&desat, 0, 0), g, "saturate(0) == grayscale(1)");
    }
    #[test]
    fn filter_grayscale_amount_over_100_percent_is_clamped() {
        let mut a = solid(2, 2, [255, 0, 0, 255]);
        apply_layer_filters(
            &mut a,
            &[StyleFilter::Grayscale(PercentageValue::new(100.0))],
            1.0,
        );
        let mut b = solid(2, 2, [255, 0, 0, 255]);
        apply_layer_filters(
            &mut b,
            &[StyleFilter::Grayscale(PercentageValue::new(9999.0))],
            1.0,
        );
        assert_eq!(
            at(&a, 0, 0),
            at(&b, 0, 0),
            "amount is clamped to 1.0 — no overshoot"
        );
    }
    #[test]
    fn filter_invert_full_inverts_rgb_only() {
        let mut p = solid(2, 2, [0, 0, 255, 200]);
        apply_layer_filters(
            &mut p,
            &[StyleFilter::Invert(PercentageValue::new(100.0))],
            1.0,
        );
        assert_eq!(at(&p, 0, 0), [255, 255, 0, 200]);
    }
    #[test]
    fn filter_hue_rotate_by_zero_preserves_the_colour() {
        let mut p = solid(2, 2, [200, 100, 50, 255]);
        apply_layer_filters(&mut p, &[StyleFilter::HueRotate(AngleValue::deg(0.0))], 1.0);
        let g = at(&p, 0, 0);
        for (got, want) in g.iter().zip([200u8, 100, 50, 255].iter()) {
            assert!(
                (i32::from(*got) - i32::from(*want)).abs() <= 2,
                "identity hue rotation must round-trip (±2 for f32 matrix error), got {g:?}"
            );
        }
    }
    #[test]
    fn filter_blur_with_no_effective_radius_is_a_noop() {
        let base = solid(8, 8, [10, 20, 30, 255]);
        let blur = |px: f32| {
            StyleFilter::Blur(StyleBlur {
                width: PixelValue::px(px),
                height: PixelValue::px(px),
            })
        };
        let mut zero = solid(8, 8, [10, 20, 30, 255]);
        apply_layer_filters(&mut zero, &[blur(0.0)], 1.0);
        assert_eq!(zero.data(), base.data(), "0px radius → skipped");
        let mut neg = solid(8, 8, [10, 20, 30, 255]);
        apply_layer_filters(&mut neg, &[blur(-8.0)], 1.0);
        assert_eq!(
            neg.data(),
            base.data(),
            "a negative radius casts to 0, it must not wrap"
        );
        let mut nan_dpi = solid(8, 8, [10, 20, 30, 255]);
        apply_layer_filters(&mut nan_dpi, &[blur(4.0)], f32::NAN);
        assert_eq!(nan_dpi.data(), base.data(), "NaN dpi → radius 0 → skipped");
        let mut zero_dpi = solid(8, 8, [10, 20, 30, 255]);
        apply_layer_filters(&mut zero_dpi, &[blur(4.0)], 0.0);
        assert_eq!(zero_dpi.data(), base.data(), "dpi 0 → radius 0 → skipped");
    }
    #[test]
    fn filter_blur_softens_a_hard_edge() {
        let mut p = AzulPixmap::new(16, 16).unwrap();
        p.fill(0, 0, 0, 255);
        p.fill_rect(8, 0, 8, 16, 255, 255, 255, 255); // right half white
        let before = p.data().to_vec();
        apply_layer_filters(
            &mut p,
            &[StyleFilter::Blur(StyleBlur {
                width: PixelValue::px(2.0),
                height: PixelValue::px(2.0),
            })],
            1.0,
        );
        assert_ne!(
            p.data(),
            &before[..],
            "a 2px blur must actually change pixels"
        );
        assert_eq!(
            p.data().len(),
            before.len(),
            "the buffer must not be reallocated"
        );
    }
    #[test]
    fn filter_unimplemented_variants_are_noops() {
        let mut p = solid(2, 2, [10, 20, 30, 255]);
        apply_layer_filters(&mut p, &[StyleFilter::ComponentTransfer], 1.0);
        assert_eq!(at(&p, 0, 0), [10, 20, 30, 255]);
    }
    #[test]
    fn filter_chain_applies_in_order() {
        let mut p = solid(2, 2, [255, 0, 0, 255]);
        apply_layer_filters(
            &mut p,
            &[
                StyleFilter::Brightness(PercentageValue::new(0.0)), // → black
                StyleFilter::Invert(PercentageValue::new(100.0)),   // → white
            ],
            1.0,
        );
        assert_eq!(
            at(&p, 0, 0),
            [255, 255, 255, 255],
            "filters must compose left-to-right"
        );
    }
    // ============================== allocate_layers_from_display_list ========
    #[test]
    fn allocate_layers_on_empty_display_list_keeps_only_the_root() {
        let mut c = CompositorState::new(64, 64);
        c.allocate_layers_from_display_list(&dlist(vec![]), 1.0, &HashMap::new(), &HashMap::new());
        assert_eq!(c.layers.len(), 1);
        let root = c.layers.get(&c.root_layer).unwrap();
        assert_eq!(root.display_list_range, (0, 0));
        assert!(root.children.is_empty());
    }
    /// A display list that wants one layer of every kind.
    fn layer_soup() -> DisplayList {
        dlist(vec![
            push_scroll(1, 0.0, 0.0, 20.0, 20.0),
            rect_item(
                0.0,
                0.0,
                16.0,
                16.0,
                ColorU {
                    r: 0,
                    g: 0,
                    b: 255,
                    a: 255,
                },
            ),
            DisplayListItem::PopScrollFrame,
            DisplayListItem::PushOpacity {
                bounds: wlr(0.0, 0.0, 20.0, 20.0),
                opacity: 0.5,
                opacity_key: None,
            },
            rect_item(
                0.0,
                0.0,
                16.0,
                16.0,
                ColorU {
                    r: 0,
                    g: 0,
                    b: 255,
                    a: 255,
                },
            ),
            DisplayListItem::PopOpacity,
            DisplayListItem::PushFilter {
                bounds: wlr(0.0, 0.0, 20.0, 20.0),
                filters: vec![StyleFilter::Blur(StyleBlur {
                    width: PixelValue::px(2.0),
                    height: PixelValue::px(2.0),
                })],
            },
            rect_item(
                0.0,
                0.0,
                16.0,
                16.0,
                ColorU {
                    r: 0,
                    g: 0,
                    b: 255,
                    a: 255,
                },
            ),
            DisplayListItem::PopFilter,
        ])
    }
    #[test]
    fn allocate_layers_skips_an_empty_scroll_frame() {
        // THE first-draw placeholder-strip bug: an EMPTY scroll frame (every
        // empty TextInput's value <p>) allocated a layer whose pixbuf was
        // never seeded, cleared or rendered - but still composited its
        // never-initialized OPAQUE WHITE pixels over the parent, erasing
        // the sibling placeholder text under it on the first (fully
        // layered) frame. Empty groups must allocate NOTHING, and the pop
        // must not disturb the stack pairing of surrounding frames.
        let mut c = CompositorState::new(64, 64);
        let list = dlist(vec![
            push_scroll(1, 0.0, 0.0, 40.0, 40.0),
            rect_item(
                0.0,
                0.0,
                16.0,
                16.0,
                ColorU {
                    r: 0,
                    g: 0,
                    b: 255,
                    a: 255,
                },
            ),
            push_scroll(2, 2.0, 2.0, 10.0, 10.0),
            DisplayListItem::PopScrollFrame,
            rect_item(
                0.0,
                0.0,
                16.0,
                16.0,
                ColorU {
                    r: 0,
                    g: 0,
                    b: 255,
                    a: 255,
                },
            ),
            DisplayListItem::PopScrollFrame,
        ]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        assert_eq!(
            c.layers.len(),
            2,
            "root + outer only; the empty inner frame allocates nothing"
        );
        let root = c.layers.get(&c.root_layer).unwrap();
        assert_eq!(root.children.len(), 1);
        let outer = c.layers.get(&root.children[0]).unwrap();
        assert_eq!(outer.scroll_id, Some(1));
        assert!(
            outer.children.is_empty(),
            "the empty inner frame must not appear as a child"
        );
        assert_eq!(
            outer.display_list_range,
            (1, 5),
            "outer range runs to ITS OWN pop, not the inner one"
        );
    }
    #[test]
    fn allocate_layers_at_dpi_one_creates_one_layer_per_group() {
        let mut c = CompositorState::new(64, 64);
        c.allocate_layers_from_display_list(&layer_soup(), 1.0, &HashMap::new(), &HashMap::new());
        assert_eq!(c.layers.len(), 4, "root + scroll + opacity + blur");
        let root = c.layers.get(&c.root_layer).unwrap();
        assert_eq!(
            root.children.len(),
            3,
            "all three are direct children of the root"
        );
    }
    #[test]
    fn allocate_layers_at_degenerate_dpi_creates_no_layers() {
        for dpi in [0.0_f32, -2.0, f32::NAN] {
            let mut c = CompositorState::new(64, 64);
            c.allocate_layers_from_display_list(
                &layer_soup(),
                dpi,
                &HashMap::new(),
                &HashMap::new(),
            );
            assert_eq!(
                c.layers.len(),
                1,
                "dpi {dpi} yields a 0-pixel pixbuf — the layer must be skipped, not allocated"
            );
        }
    }
    #[test]
    fn allocate_layers_zero_sized_scroll_frame_is_skipped() {
        let mut c = CompositorState::new(64, 64);
        let list = dlist(vec![
            push_scroll(1, 0.0, 0.0, 0.0, 0.0),
            DisplayListItem::PopScrollFrame,
        ]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        assert_eq!(c.layers.len(), 1, "a 0×0 clip cannot get a pixbuf");
    }
    #[test]
    fn allocate_layers_scroll_frame_records_id_and_range() {
        let mut c = CompositorState::new(64, 64);
        let list = dlist(vec![
            push_scroll(7, 5.0, 6.0, 20.0, 20.0),
            opaque_rect(0.0, 0.0, 10.0, 10.0),
            DisplayListItem::PopScrollFrame,
        ]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        assert_eq!(c.layers.len(), 2);
        let l = c
            .layers
            .values()
            .find(|l| l.scroll_id == Some(7))
            .expect("scroll layer");
        assert_eq!(
            l.display_list_range,
            (1, 2),
            "range is (push+1, matching pop)"
        );
        assert_eq!(l.bounds.origin.x, 5.0);
        assert_eq!((l.pixbuf.width(), l.pixbuf.height()), (20, 20));
    }
    #[test]
    fn allocate_layers_unbalanced_pops_do_not_underflow_the_stack() {
        // The doc claims this panics on stack underflow — it must not.
        let mut c = CompositorState::new(64, 64);
        let list = dlist(vec![
            DisplayListItem::PopScrollFrame,
            DisplayListItem::PopOpacity,
            DisplayListItem::PopFilter,
            DisplayListItem::PopReferenceFrame,
            DisplayListItem::PopBackdropFilter,
            DisplayListItem::PopScrollFrame,
        ]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        assert_eq!(
            c.layers.len(),
            1,
            "stray pops are ignored, the root survives"
        );
    }
    #[test]
    fn allocate_layers_unmatched_push_runs_to_the_end_of_the_list() {
        let mut c = CompositorState::new(64, 64);
        let list = dlist(vec![
            push_scroll(1, 0.0, 0.0, 20.0, 20.0),
            opaque_rect(0.0, 0.0, 5.0, 5.0),
        ]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        let l = c.layers.values().find(|l| l.scroll_id == Some(1)).unwrap();
        assert_eq!(
            l.display_list_range,
            (1, 2),
            "an unmatched push clamps to items.len()"
        );
    }
    #[test]
    fn allocate_layers_opacity_edge_values() {
        // opacity >= 1.0 and NaN must NOT allocate a layer (`*opacity < 1.0`).
        for op in [1.0_f32, 2.0, f32::NAN] {
            let mut c = CompositorState::new(64, 64);
            let list = dlist(vec![
                DisplayListItem::PushOpacity {
                    bounds: wlr(0.0, 0.0, 20.0, 20.0),
                    opacity: op,
                    opacity_key: None,
                },
                DisplayListItem::PopOpacity,
            ]);
            c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
            assert_eq!(c.layers.len(), 1, "opacity {op} needs no layer");
        }
        let mut c = CompositorState::new(64, 64);
        let list = dlist(vec![
            DisplayListItem::PushOpacity {
                bounds: wlr(0.0, 0.0, 20.0, 20.0),
                opacity: -3.0,
                opacity_key: None,
            },
            rect_item(
                0.0,
                0.0,
                16.0,
                16.0,
                ColorU {
                    r: 0,
                    g: 0,
                    b: 255,
                    a: 255,
                },
            ),
            DisplayListItem::PopOpacity,
        ]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        assert_eq!(
            c.layers.len(),
            2,
            "a negative opacity still needs its own layer"
        );
    }
    #[test]
    fn allocate_layers_identity_reference_frame_is_not_promoted() {
        let mut c = CompositorState::new(64, 64);
        let ident = dlist(vec![ref_frame(1), DisplayListItem::PopReferenceFrame]);
        c.allocate_layers_from_display_list(&ident, 1.0, &HashMap::new(), &HashMap::new());
        assert_eq!(c.layers.len(), 1, "an identity transform needs no layer");
        let mut c2 = CompositorState::new(64, 64);
        let moved = dlist(vec![
            DisplayListItem::PushReferenceFrame {
                transform_key: TransformKey { id: 1 },
                initial_transform: translate(20.0, 10.0),
                bounds: wlr(0.0, 0.0, 20.0, 20.0),
            },
            rect_item(
                0.0,
                0.0,
                16.0,
                16.0,
                ColorU {
                    r: 0,
                    g: 0,
                    b: 255,
                    a: 255,
                },
            ),
            DisplayListItem::PopReferenceFrame,
        ]);
        c2.allocate_layers_from_display_list(&moved, 1.0, &HashMap::new(), &HashMap::new());
        assert_eq!(
            c2.layers.len(),
            2,
            "a non-identity transform gets its own layer"
        );
        let l = c2.layers.values().find(|l| l.id != c2.root_layer).unwrap();
        assert!(!l.transform.is_identity(IDENTITY_EPSILON_F64));
    }
    /// The matrix baked into a `PushReferenceFrame` is the value at display-list
    /// BUILD time. An animated transform changes every frame without the list
    /// being rebuilt, so layer allocation must read the LIVE value.
    ///
    /// This is the failure that made engine-driven transitions invisible: the
    /// FLIP seeds a node at identity on the frame the DOM changes, so the baked
    /// matrix is identity, so no layer was promoted — and every later sample
    /// landed in a `transform_stack` that `render_single_item` never reads,
    /// because transforms are realised by layers, not by the item walk. The node
    /// jumped from its old rect to its new one with nothing in between.
    #[test]
    fn allocate_layers_reference_frame_reads_the_live_transform_not_the_baked_one() {
        let mut live: HashMap<usize, ComputedTransform3D> = HashMap::new();
        live.insert(1, translate(120.0, 0.0));
        // Baked identity, live moved: the node IS displaced this frame.
        let mut c = CompositorState::new(64, 64);
        c.allocate_layers_from_display_list(
            &dlist(vec![
                ref_frame(1),
                rect_item(
                    0.0,
                    0.0,
                    16.0,
                    16.0,
                    ColorU {
                        r: 0,
                        g: 0,
                        b: 255,
                        a: 255,
                    },
                ),
                DisplayListItem::PopReferenceFrame,
            ]),
            1.0,
            &live,
            &HashMap::new(),
        );
        assert_eq!(
            c.layers.len(),
            2,
            "a live-animated frame must be promoted to a layer"
        );
        let l = c.layers.values().find(|l| l.id != c.root_layer).unwrap();
        assert!(
            (l.transform.tx - 120.0).abs() < 0.001,
            "layer must carry the LIVE matrix, got tx={}",
            l.transform.tx
        );
    }
    /// The reverse direction, and the one that leaves visible damage: a settled
    /// animation publishes identity, and if allocation still trusted the baked
    /// matrix the node would stay permanently offset by a stale sample.
    #[test]
    fn allocate_layers_live_identity_retires_a_baked_transform() {
        let mut live: HashMap<usize, ComputedTransform3D> = HashMap::new();
        live.insert(1, ComputedTransform3D::IDENTITY);
        let mut c = CompositorState::new(64, 64);
        c.allocate_layers_from_display_list(
            &dlist(vec![
                DisplayListItem::PushReferenceFrame {
                    transform_key: TransformKey { id: 1 },
                    initial_transform: translate(20.0, 10.0),
                    bounds: wlr(0.0, 0.0, 20.0, 20.0),
                },
                DisplayListItem::PopReferenceFrame,
            ]),
            1.0,
            &live,
            &HashMap::new(),
        );
        assert_eq!(
            c.layers.len(),
            1,
            "a settled animation must not keep its layer"
        );
    }
    /// Push/pop pairing must not be inferred from the transform VALUE.
    ///
    /// Allocation skips identity frames, and the pop arm used to decide whether
    /// to pop by asking whether the top layer's transform was non-identity — so
    /// an identity frame nested inside a moved one popped the PARENT, and every
    /// item after it composited into the wrong layer. A FLIP passes through
    /// identity exactly (at rest, and at the instant it settles), so this is
    /// reachable in normal playback rather than only by a hand-built list.
    /// The opacity twin of the live-transform pins: a keyed group's LIVE
    /// value decides promotion, in both directions, and pop pairing is by
    /// recorded decision.
    ///
    /// An enter/exit fade binds `opacity_key` while the BAKED value is 1.0 —
    /// so without the live lookup a mid-fade group allocated no layer at all
    /// (the fade was invisible), and once the fade settles at exactly 1.0 the
    /// old value-testing pop arm popped the PARENT instead of the group.
    #[test]
    fn allocate_layers_live_opacity_decides_promotion_both_directions() {
        use azul_core::resources::OpacityKey;
        let key = OpacityKey { id: 5 };
        let group = |baked: f32| {
            dlist(vec![
                DisplayListItem::PushOpacity {
                    bounds: wlr(0.0, 0.0, 20.0, 20.0),
                    opacity: baked,
                    opacity_key: Some(key),
                },
                opaque_rect(0.0, 0.0, 5.0, 5.0),
                DisplayListItem::PopOpacity,
            ])
        };
        let no_t: HashMap<usize, ComputedTransform3D> = HashMap::new();
        // Mid-fade: baked 1.0, live 0.4 — the group MUST get a layer at the
        // live value.
        let mut live_o: HashMap<usize, f32> = HashMap::new();
        live_o.insert(5, 0.4);
        let mut c = CompositorState::new(64, 64);
        c.allocate_layers_from_display_list(&group(1.0), 1.0, &no_t, &live_o);
        assert_eq!(c.layers.len(), 2, "a mid-fade keyed group needs a layer");
        let l = c.layers.values().find(|l| l.id != c.root_layer).unwrap();
        assert!(
            (l.opacity - 0.4).abs() < 0.001,
            "layer must carry the LIVE opacity"
        );
        // Settled fade: live 1.0 beats baked 0.5 — no layer.
        let mut live_one: HashMap<usize, f32> = HashMap::new();
        live_one.insert(5, 1.0);
        let mut c2 = CompositorState::new(64, 64);
        c2.allocate_layers_from_display_list(&group(0.5), 1.0, &no_t, &live_one);
        assert_eq!(c2.layers.len(), 1, "a settled fade must not keep its layer");
    }
    /// Pop pairing for opacity groups is by recorded decision: a keyed group
    /// whose live value is exactly 1.0 (a completed fade) allocates nothing,
    /// and its `PopOpacity` must NOT pop the enclosing moved reference frame.
    #[test]
    fn allocate_layers_completed_fade_does_not_pop_its_moved_parent() {
        use azul_core::resources::OpacityKey;
        let mut live_t: HashMap<usize, ComputedTransform3D> = HashMap::new();
        live_t.insert(1, translate(30.0, 0.0));
        let mut live_o: HashMap<usize, f32> = HashMap::new();
        live_o.insert(9, 1.0); // the fade has completed
        let mut c = CompositorState::new(64, 64);
        c.allocate_layers_from_display_list(
            &dlist(vec![
                ref_frame(1),
                DisplayListItem::PushOpacity {
                    bounds: wlr(0.0, 0.0, 10.0, 10.0),
                    opacity: 1.0,
                    opacity_key: Some(OpacityKey { id: 9 }),
                },
                DisplayListItem::PopOpacity,     // allocated nothing
                opaque_rect(0.0, 0.0, 5.0, 5.0), // still inside the FRAME
                DisplayListItem::PopReferenceFrame,
            ]),
            1.0,
            &live_t,
            &live_o,
        );
        assert_eq!(c.layers.len(), 2, "only the moved frame gets a layer");
        let frame_id = *c
            .layers
            .get(&c.root_layer)
            .unwrap()
            .children
            .first()
            .unwrap();
        let frame = c.layers.get(&frame_id).unwrap();
        let (start, end) = frame.display_list_range;
        assert!(
            start <= 3 && end > 3,
            "the rect at index 3 sits inside the frame, but its layer spans {start}..{end}"
        );
    }
    #[test]
    fn allocate_layers_identity_child_frame_does_not_pop_its_moved_parent() {
        let mut live: HashMap<usize, ComputedTransform3D> = HashMap::new();
        live.insert(1, translate(30.0, 0.0)); // outer: moving
        live.insert(2, ComputedTransform3D::IDENTITY); // inner: at rest
        let mut c = CompositorState::new(64, 64);
        c.allocate_layers_from_display_list(
            &dlist(vec![
                ref_frame(1),
                ref_frame(2),
                DisplayListItem::PopReferenceFrame, // inner — allocated nothing
                opaque_rect(0.0, 0.0, 5.0, 5.0),    // still inside the OUTER frame
                DisplayListItem::PopReferenceFrame, // outer
                opaque_rect(0.0, 0.0, 5.0, 5.0),    // back at the root
            ]),
            1.0,
            &live,
            &HashMap::new(),
        );
        assert_eq!(c.layers.len(), 2, "only the moved outer frame gets a layer");
        let outer_id = *c
            .layers
            .get(&c.root_layer)
            .unwrap()
            .children
            .first()
            .unwrap();
        let outer = c.layers.get(&outer_id).unwrap();
        let (start, end) = outer.display_list_range;
        assert!(
            start <= 3 && end > 3,
            "the rect at index 3 sits inside the outer frame, but its layer spans {start}..{end}"
        );
    }
    #[test]
    fn allocate_layers_nests_children_under_their_parent() {
        let mut c = CompositorState::new(64, 64);
        let list = dlist(vec![
            push_scroll(1, 0.0, 0.0, 40.0, 40.0),
            push_scroll(2, 0.0, 0.0, 20.0, 20.0),
            opaque_rect(0.0, 0.0, 5.0, 5.0),
            DisplayListItem::PopScrollFrame,
            DisplayListItem::PopScrollFrame,
        ]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        assert_eq!(c.layers.len(), 3);
        let root = c.layers.get(&c.root_layer).unwrap();
        assert_eq!(
            root.children.len(),
            1,
            "only the outer frame hangs off the root"
        );
        let outer_id = root.children[0];
        let outer = c.layers.get(&outer_id).unwrap();
        assert_eq!(outer.scroll_id, Some(1));
        assert_eq!(
            outer.children.len(),
            1,
            "the inner frame is a child of the outer one"
        );
        let inner = c.layers.get(&outer.children[0]).unwrap();
        assert_eq!(inner.scroll_id, Some(2));
        assert_eq!(inner.display_list_range, (2, 3));
    }
    #[test]
    fn allocate_layers_is_idempotent_across_frames() {
        let mut c = CompositorState::new(64, 64);
        c.allocate_layers_from_display_list(&layer_soup(), 1.0, &HashMap::new(), &HashMap::new());
        let first = c.layers.len();
        c.allocate_layers_from_display_list(&layer_soup(), 1.0, &HashMap::new(), &HashMap::new());
        assert_eq!(
            c.layers.len(),
            first,
            "re-allocating must not leak last frame's layers"
        );
        let root = c.layers.get(&c.root_layer).unwrap();
        assert_eq!(
            root.children.len(),
            3,
            "root children are rebuilt, not appended to"
        );
        assert!(
            c.next_layer_id_peek() > first as u64,
            "ids stay monotonic across frames"
        );
    }
    #[test]
    fn allocate_layers_backdrop_filter_starts_transparent() {
        let mut c = CompositorState::new(64, 64);
        let list = dlist(vec![
            DisplayListItem::PushBackdropFilter {
                bounds: wlr(0.0, 0.0, 20.0, 20.0),
                filters: vec![StyleFilter::Invert(PercentageValue::new(100.0))],
            },
            DisplayListItem::PopBackdropFilter,
        ]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        let l = c
            .layers
            .values()
            .find(|l| l.is_backdrop_filter)
            .expect("backdrop layer");
        assert_eq!(
            at(&l.pixbuf, 0, 0),
            [0, 0, 0, 0],
            "an empty backdrop-filter box must not blit opaque white over the backdrop"
        );
        // With no filters at all it is NOT a backdrop layer.
        let mut c2 = CompositorState::new(64, 64);
        let empty = dlist(vec![
            DisplayListItem::PushBackdropFilter {
                bounds: wlr(0.0, 0.0, 20.0, 20.0),
                filters: Vec::new(),
            },
            DisplayListItem::PopBackdropFilter,
        ]);
        c2.allocate_layers_from_display_list(&empty, 1.0, &HashMap::new(), &HashMap::new());
        assert_eq!(c2.layers.len(), 1, "no filters → no layer");
    }
    // ============================== compute_damage ===========================
    #[test]
    fn compute_damage_with_no_dirty_nodes_is_a_noop() {
        let mut c = CompositorState::new(64, 64);
        c.compute_damage(&BTreeSet::new(), &[], &[], &[]);
        assert!(c.layers.get(&c.root_layer).unwrap().damage.is_empty());
    }
    #[test]
    fn compute_damage_ignores_out_of_range_node_indices() {
        let mut c = CompositorState::new(64, 64);
        let dirty: BTreeSet<usize> = [0usize, 5, usize::MAX].into_iter().collect();
        // Every slice is empty → every index is out of range → guarded, no panic.
        c.compute_damage(&dirty, &[], &[], &[]);
        assert!(c.layers.get(&c.root_layer).unwrap().damage.is_empty());
    }
    #[test]
    fn compute_damage_covers_the_old_and_the_new_position() {
        let mut c = CompositorState::new(64, 64);
        let dirty: BTreeSet<usize> = [0usize].into_iter().collect();
        let old = [LogicalPosition::new(0.0, 0.0)];
        let new = [LogicalPosition::new(20.0, 20.0)];
        let rects = [lr(0.0, 0.0, 10.0, 10.0)];
        c.compute_damage(&dirty, &old, &new, &rects);
        let root = c.layers.get(&c.root_layer).unwrap();
        assert_eq!(
            root.damage.len(),
            2,
            "a moved node damages where it was AND where it is"
        );
        assert!(root.composite_dirty);
        assert!(root.damage.iter().any(|d| d.origin.x == 0.0));
        assert!(root.damage.iter().any(|d| d.origin.x == 20.0));
    }
    #[test]
    fn compute_damage_with_nan_positions_does_not_leak_nan() {
        // `rect_intersection` uses f32::max/min, which IGNORE NaN — so a NaN
        // node degrades to whole-layer damage (conservative) rather than to
        // `None`. Either way, no NaN may reach a damage rect: a NaN rect would
        // silently rasterise to nothing and the node would never repaint.
        let mut c = CompositorState::new(64, 64);
        let dirty: BTreeSet<usize> = [0usize].into_iter().collect();
        let nan = [LogicalPosition::new(f32::NAN, f32::NAN)];
        let rects = [lr(0.0, 0.0, f32::NAN, f32::NAN)];
        c.compute_damage(&dirty, &nan, &nan, &rects);
        let root = c.layers.get(&c.root_layer).unwrap();
        for d in &root.damage {
            assert!(
                d.origin.x.is_finite()
                    && d.origin.y.is_finite()
                    && d.size.width.is_finite()
                    && d.size.height.is_finite(),
                "NaN must not leak into a damage rect, got {d:?}"
            );
        }
    }
    #[test]
    fn compute_damage_clips_to_the_layer_bounds() {
        let mut c = CompositorState::new(64, 64);
        let dirty: BTreeSet<usize> = [0usize].into_iter().collect();
        let pos = [LogicalPosition::new(60.0, 60.0)];
        let rects = [lr(0.0, 0.0, 100.0, 100.0)];
        c.compute_damage(&dirty, &pos, &pos, &rects);
        let root = c.layers.get(&c.root_layer).unwrap();
        for d in &root.damage {
            assert!(
                d.origin.x + d.size.width <= 64.0 && d.origin.y + d.size.height <= 64.0,
                "damage must be clipped to the layer, got {d:?}"
            );
        }
    }
    // ============================== render_layers / composite_frame ==========
    #[test]
    fn render_layers_on_an_empty_display_list_is_ok() {
        let mut c = CompositorState::new(8, 8);
        let list = dlist(vec![]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        let (rr, mut gc, st) = render_deps();
        assert!(c
            .render_layers(&list, 1.0, &rr, &test_font_manager(), &mut gc, &st)
            .is_ok());
    }
    #[test]
    fn render_layers_paints_a_rect_into_the_root_pixbuf() {
        let mut c = CompositorState::new(16, 16);
        let list = dlist(vec![rect_item(
            0.0,
            0.0,
            16.0,
            16.0,
            ColorU {
                r: 0,
                g: 0,
                b: 255,
                a: 255,
            },
        )]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        let (rr, mut gc, st) = render_deps();
        c.render_layers(&list, 1.0, &rr, &test_font_manager(), &mut gc, &st)
            .unwrap();
        let root = c.layers.get(&c.root_layer).unwrap();
        assert_eq!(at(&root.pixbuf, 8, 8), [0, 0, 255, 255]);
        let mut out = AzulPixmap::new(16, 16).unwrap();
        out.fill(0, 0, 0, 255);
        c.composite_frame(&mut out, 1.0);
        assert_eq!(
            at(&out, 8, 8),
            [0, 0, 255, 255],
            "the root layer is blitted 1:1"
        );
    }
    #[test]
    fn render_layers_survives_degenerate_dpi_factors() {
        // A non-finite / non-positive scale makes every rect un-rasterisable;
        // the renderer must skip it and still return Ok with a cleared root.
        for dpi in [0.0_f32, -1.0, f32::NAN, f32::INFINITY] {
            let mut c = CompositorState::new(8, 8);
            let list = dlist(vec![rect_item(
                0.0,
                0.0,
                8.0,
                8.0,
                ColorU {
                    r: 0,
                    g: 0,
                    b: 255,
                    a: 255,
                },
            )]);
            c.allocate_layers_from_display_list(&list, dpi, &HashMap::new(), &HashMap::new());
            let (rr, mut gc, st) = render_deps();
            assert!(
                c.render_layers(&list, dpi, &rr, &test_font_manager(), &mut gc, &st)
                    .is_ok(),
                "dpi {dpi} must not error or panic"
            );
            let root = c.layers.get(&c.root_layer).unwrap();
            assert_eq!(
                at(&root.pixbuf, 4, 4),
                [255, 255, 255, 255],
                "dpi {dpi} rasterises nothing — the root stays cleared to white"
            );
        }
    }
    #[test]
    fn render_layers_skips_a_layer_range_past_the_end_of_the_list() {
        let mut c = CompositorState::new(8, 8);
        let list = dlist(vec![opaque_rect(0.0, 0.0, 8.0, 8.0)]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        // Simulate a stale range left over from a longer display list.
        let root_id = c.root_layer;
        c.layers.get_mut(&root_id).unwrap().display_list_range = (999, 1000);
        let (rr, mut gc, st) = render_deps();
        assert!(
            c.render_layers(&list, 1.0, &rr, &test_font_manager(), &mut gc, &st)
                .is_ok(),
            "an out-of-range range must be skipped, not indexed"
        );
    }
    #[test]
    fn render_layers_clamps_a_range_that_overruns_the_list() {
        let mut c = CompositorState::new(8, 8);
        let list = dlist(vec![rect_item(
            0.0,
            0.0,
            8.0,
            8.0,
            ColorU {
                r: 0,
                g: 255,
                b: 0,
                a: 255,
            },
        )]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        let root_id = c.root_layer;
        c.layers.get_mut(&root_id).unwrap().display_list_range = (0, 9999);
        let (rr, mut gc, st) = render_deps();
        c.render_layers(&list, 1.0, &rr, &test_font_manager(), &mut gc, &st)
            .unwrap();
        let root = c.layers.get(&c.root_layer).unwrap();
        assert_eq!(
            at(&root.pixbuf, 4, 4),
            [0, 255, 0, 255],
            "end is clamped to items.len()"
        );
    }
    #[test]
    fn composite_frame_handles_degenerate_dpi_and_undersized_output() {
        let mut c = CompositorState::new(16, 16);
        let list = dlist(vec![]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        let (rr, mut gc, st) = render_deps();
        c.render_layers(&list, 1.0, &rr, &test_font_manager(), &mut gc, &st)
            .unwrap();
        for dpi in [0.0_f32, -1.0, f32::NAN] {
            let mut out = AzulPixmap::new(16, 16).unwrap();
            out.fill(0, 0, 0, 255);
            c.composite_frame(&mut out, dpi);
            assert_eq!(
                at(&out, 0, 0),
                [255, 255, 255, 255],
                "root blit ignores dpi {dpi}"
            );
        }
        // Output smaller than the root layer: the blit must clip, not panic.
        let mut small = AzulPixmap::new(4, 4).unwrap();
        small.fill(0, 0, 0, 255);
        c.composite_frame(&mut small, 1.0);
        assert_eq!(at(&small, 3, 3), [255, 255, 255, 255]);
    }
    #[test]
    fn composite_frame_applies_layer_opacity() {
        let mut c = CompositorState::new(16, 16);
        let list = dlist(vec![
            DisplayListItem::PushOpacity {
                bounds: wlr(0.0, 0.0, 16.0, 16.0),
                opacity: 0.0, // fully transparent group
                opacity_key: None,
            },
            rect_item(
                0.0,
                0.0,
                16.0,
                16.0,
                ColorU {
                    r: 255,
                    g: 0,
                    b: 0,
                    a: 255,
                },
            ),
            DisplayListItem::PopOpacity,
        ]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        let (rr, mut gc, st) = render_deps();
        c.render_layers(&list, 1.0, &rr, &test_font_manager(), &mut gc, &st)
            .unwrap();
        let mut out = AzulPixmap::new(16, 16).unwrap();
        out.fill(0, 0, 0, 255);
        c.composite_frame(&mut out, 1.0);
        assert_eq!(
            at(&out, 8, 8),
            [255, 255, 255, 255],
            "an opacity-0 layer must contribute nothing over the white root"
        );
    }
    // ================= windowed structural display-list damage ===============
    /// THE CLASS (dl_text_patch on ubuntu, 2026-08-24): retyping a
    /// paragraph's text so it wraps to one more line emits one more Text
    /// item; the item-count check bailed the whole diff to a FULL repaint —
    /// font-metrics dependent, so green on macOS and red on DejaVu. A
    /// localized structural change now damages the changed middle window.
    #[test]
    fn a_changed_item_count_damages_the_changed_window_not_the_full_frame() {
        let red = ColorU {
            r: 255,
            g: 0,
            b: 0,
            a: 255,
        };
        let blue = ColorU {
            r: 0,
            g: 0,
            b: 255,
            a: 255,
        };
        let old = dlist(vec![
            rect_item(0.0, 0.0, 100.0, 10.0, red),
            rect_item(0.0, 20.0, 100.0, 10.0, red),
            rect_item(0.0, 500.0, 100.0, 10.0, red),
        ]);
        let new = dlist(vec![
            rect_item(0.0, 0.0, 100.0, 10.0, red),
            rect_item(0.0, 20.0, 100.0, 10.0, blue),
            rect_item(0.0, 32.0, 100.0, 10.0, blue), // the inserted "new line"
            rect_item(0.0, 500.0, 100.0, 10.0, red),
        ]);
        let off = ScrollOffsetMap::new();
        let damage = compute_display_list_damage(&old, &new, &off, &off)
            .expect("a localized insertion must yield rect damage, not a full repaint");
        assert!(!damage.is_empty());
        for r in &damage {
            assert!(
                r.origin.y >= 19.0 && r.origin.y + r.size.height <= 43.0,
                "damage stays in the changed window (the shared prefix/suffix are untouched): \
                 {r:?}"
            );
        }
    }
    /// The fallback maps the changed window through the scroll stack, and
    /// keeps the full-repaint bail when the offsets themselves changed.
    #[test]
    fn windowed_damage_respects_scroll_offsets() {
        let red = ColorU {
            r: 255,
            g: 0,
            b: 0,
            a: 255,
        };
        let old = dlist(vec![
            push_scroll(1, 0.0, 0.0, 100.0, 50.0),
            rect_item(0.0, 100.0, 100.0, 10.0, red),
            DisplayListItem::PopScrollFrame,
        ]);
        let new = dlist(vec![
            push_scroll(1, 0.0, 0.0, 100.0, 50.0),
            rect_item(0.0, 100.0, 100.0, 10.0, red),
            rect_item(0.0, 112.0, 100.0, 10.0, red),
            DisplayListItem::PopScrollFrame,
        ]);
        let mut off = ScrollOffsetMap::new();
        off.insert(1, (0.0, 90.0));
        let damage = compute_display_list_damage(&old, &new, &off, &off)
            .expect("insertion inside an (unchanged) scrolled frame yields rects");
        assert!(
            damage.iter().any(|r| (r.origin.y - 22.0).abs() < 1.0),
            "content coords minus the 90px scroll offset: {damage:?}"
        );
        let mut moved = ScrollOffsetMap::new();
        moved.insert(1, (0.0, 40.0));
        assert!(
            compute_display_list_damage(&old, &new, &off, &moved).is_none(),
            "a structural change WHILE the offsets changed keeps the full-repaint bail"
        );
    }
    // ===================== nested scroll-frame compositing ===================
    /// A `PushClip` WRAPPING a nested layer joins the chain: the layer's
    /// pixels stay inside the clip at composite time (item-level clipping
    /// inside one layer was always handled by the rasterizer; the layer
    /// boundary was the hole).
    #[test]
    fn a_push_clip_wrapping_a_nested_layer_clips_it_at_composite() {
        let mut c = CompositorState::new(64, 64);
        let red = ColorU {
            r: 255,
            g: 0,
            b: 0,
            a: 255,
        };
        let list = dlist(vec![
            DisplayListItem::PushClip {
                bounds: wlr(10.0, 10.0, 20.0, 20.0),
                border_radius: BorderRadius::default(),
            },
            push_scroll(1, 10.0, 10.0, 40.0, 40.0),
            rect_item(10.0, 10.0, 40.0, 40.0, red),
            DisplayListItem::PopScrollFrame,
            DisplayListItem::PopClip,
        ]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        let (rr, mut gc, st) = render_deps();
        c.render_layers(&list, 1.0, &rr, &test_font_manager(), &mut gc, &st)
            .unwrap();
        let mut out = AzulPixmap::new(64, 64).unwrap();
        out.fill(255, 255, 255, 255);
        c.composite_frame(&mut out, 1.0);
        assert_eq!(
            at(&out, 25, 25),
            [255, 0, 0, 255],
            "inside the wrapping clip"
        );
        assert_eq!(
            at(&out, 35, 15),
            [255, 255, 255, 255],
            "the scroll layer's pixels outside the WRAPPING PushClip are clipped"
        );
        assert_eq!(at(&out, 15, 35), [255, 255, 255, 255], "both axes");
    }
    /// THE CLASS (AzWidgets TextArea, 2026-08-24): a layer nested inside a
    /// NON-root layer composited at parent origin + its own absolute origin —
    /// every ancestor origin double-counted — so its pixels landed below and
    /// right of where layout and hit-testing put them.
    #[test]
    fn a_nested_scroll_frame_composites_parent_relative_not_double_offset() {
        let mut c = CompositorState::new(64, 64);
        let red = ColorU {
            r: 255,
            g: 0,
            b: 0,
            a: 255,
        };
        let list = dlist(vec![
            push_scroll(1, 10.0, 10.0, 40.0, 40.0),
            push_scroll(2, 20.0, 20.0, 20.0, 20.0),
            rect_item(20.0, 20.0, 20.0, 20.0, red),
            DisplayListItem::PopScrollFrame,
            DisplayListItem::PopScrollFrame,
        ]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        let (rr, mut gc, st) = render_deps();
        c.render_layers(&list, 1.0, &rr, &test_font_manager(), &mut gc, &st)
            .unwrap();
        let mut out = AzulPixmap::new(64, 64).unwrap();
        out.fill(255, 255, 255, 255);
        c.composite_frame(&mut out, 1.0);
        assert_eq!(
            at(&out, 22, 22),
            [255, 0, 0, 255],
            "the nested frame's rect paints at its LAYOUT position (20..40)"
        );
        assert_eq!(
            at(&out, 48, 48),
            [255, 255, 255, 255],
            "…and NOT at the double-offset position (30..50) the old absolute placement produced"
        );
    }
    /// Scrolling the OUTER frame must move a nested frame's pixels with the
    /// content, and clip them at the outer frame's edge — the nested layer's
    /// pixels do not live in the outer layer's backing, so both effects come
    /// from the composite step alone.
    #[test]
    fn scrolling_the_outer_frame_moves_and_clips_the_nested_frame() {
        let mut c = CompositorState::new(64, 64);
        let red = ColorU {
            r: 255,
            g: 0,
            b: 0,
            a: 255,
        };
        let list = dlist(vec![
            push_scroll(1, 10.0, 10.0, 40.0, 40.0),
            push_scroll(2, 20.0, 20.0, 20.0, 20.0),
            rect_item(20.0, 20.0, 20.0, 20.0, red),
            DisplayListItem::PopScrollFrame,
            DisplayListItem::PopScrollFrame,
        ]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        let (rr, mut gc, _st) = render_deps();
        let mut offsets = ScrollOffsetMap::new();
        offsets.insert(1, (0.0, 15.0));
        let st = CpuRenderState::new(offsets);
        c.render_layers(&list, 1.0, &rr, &test_font_manager(), &mut gc, &st)
            .unwrap();
        let mut out = AzulPixmap::new(64, 64).unwrap();
        out.fill(255, 255, 255, 255);
        c.composite_frame(&mut out, 1.0);
        // The rect's visual band moves from y 20..40 to y 5..25, clipped by
        // the outer frame's rect to y 10..25.
        assert_eq!(
            at(&out, 22, 12),
            [255, 0, 0, 255],
            "scrolling the outer frame moves the nested frame's pixels up"
        );
        assert_eq!(
            at(&out, 22, 8),
            [255, 255, 255, 255],
            "…and the part scrolled past the outer frame's top edge is CLIPPED"
        );
        assert_eq!(
            at(&out, 22, 30),
            [255, 255, 255, 255],
            "…and the vacated band below is no longer red"
        );
    }
    // ============================== scroll_layer =============================
    #[test]
    fn scroll_layer_with_an_unknown_id_is_ok() {
        let mut c = CompositorState::new(32, 32);
        let list = dlist(vec![]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        let (rr, mut gc, _st) = render_deps();
        assert!(
            c.scroll_layer(
                4242,
                (0.0, 10.0),
                &list,
                1.0,
                &rr,
                &test_font_manager(),
                &mut gc
            )
            .is_ok(),
            "scrolling a frame that has no layer is a no-op, not a panic"
        );
    }
    #[test]
    fn scroll_layer_ignores_subpixel_deltas() {
        let mut c = CompositorState::new(32, 32);
        let list = dlist(vec![
            push_scroll(1, 0.0, 0.0, 32.0, 32.0),
            opaque_rect(0.0, 0.0, 32.0, 200.0),
            DisplayListItem::PopScrollFrame,
        ]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        let (rr, mut gc, _st) = render_deps();
        c.scroll_layer(
            1,
            (0.0, 0.4),
            &list,
            1.0,
            &rr,
            &test_font_manager(),
            &mut gc,
        )
        .unwrap();
        let l = c.layers.values().find(|l| l.scroll_id == Some(1)).unwrap();
        assert_eq!(
            l.scroll_offset,
            (0.0, 0.0),
            "|dy| < 0.5 must not move anything"
        );
        assert!(l.damage.is_empty());
    }
    #[test]
    fn scroll_layer_updates_offset_and_records_the_exposed_strip() {
        let mut c = CompositorState::new(32, 32);
        let list = dlist(vec![
            push_scroll(1, 0.0, 0.0, 32.0, 32.0),
            opaque_rect(0.0, 0.0, 32.0, 200.0),
            DisplayListItem::PopScrollFrame,
        ]);
        c.allocate_layers_from_display_list(&list, 1.0, &HashMap::new(), &HashMap::new());
        let (rr, mut gc, _st) = render_deps();
        c.scroll_layer(
            1,
            (0.0, 10.0),
            &list,
            1.0,
            &rr,
            &test_font_manager(),
            &mut gc,
        )
        .unwrap();
        let l = c.layers.values().find(|l| l.scroll_id == Some(1)).unwrap();
        assert_eq!(l.scroll_offset, (0.0, 10.0));
        assert_eq!(l.damage.len(), 1, "a single-axis scroll exposes one strip");
        assert!(l.composite_dirty);
    }
    // ============================== render_display_list_range ================
    #[test]
    fn render_range_with_start_after_end_is_ok() {
        let list = dlist(vec![opaque_rect(0.0, 0.0, 4.0, 4.0)]);
        let mut p = solid(4, 4, [255, 255, 255, 255]);
        let (rr, mut gc, st) = render_deps();
        let r = render_display_list_range(
            &list,
            &mut p,
            5,
            2,
            &[],
            0.0,
            0.0,
            1.0,
            &rr,
            &test_font_manager(),
            &mut gc,
            &st,
        );
        assert!(
            r.is_ok(),
            "an inverted range is an empty range, not a panic"
        );
        assert_eq!(at(&p, 0, 0), [255, 255, 255, 255], "nothing was drawn");
    }
    #[test]
    fn render_range_honours_skip_ranges() {
        let list = dlist(vec![rect_item(
            0.0,
            0.0,
            4.0,
            4.0,
            ColorU {
                r: 255,
                g: 0,
                b: 0,
                a: 255,
            },
        )]);
        let mut p = solid(4, 4, [255, 255, 255, 255]);
        let (rr, mut gc, st) = render_deps();
        render_display_list_range(
            &list,
            &mut p,
            0,
            1,
            &[(0, 1)],
            0.0,
            0.0,
            1.0,
            &rr,
            &test_font_manager(),
            &mut gc,
            &st,
        )
        .unwrap();
        assert_eq!(
            at(&p, 2, 2),
            [255, 255, 255, 255],
            "an item claimed by a child layer must not be drawn twice"
        );
    }
}