Skip to main content

valo_dl/
builder.rs

1use std::sync::Arc;
2
3use valo_geometry::{FillRule, Matrix, Path, PathBuilder, Rect};
4
5use crate::{ClipOp, DisplayList, Image, MaskKind, Op, Paint, Sampling};
6
7/// `DisplayListBuilder` records drawing commands into an immutable display list.
8///
9/// Recording is GPU-free and may run on any thread. The builder resolves bounds,
10/// clips, layer extents, and ordering metadata so rendering does not need to
11/// rediscover them.
12pub struct DisplayListBuilder {
13    ops: Vec<Op>,
14    scopes: Vec<Scope>,
15    /// Open save layers (innermost last). Layer-scoped oracle state lives
16    /// here; `Scope.is_layer` says which restore pops one.
17    layers: Vec<LayerScope>,
18    /// Shared backdrop keys seen so far, each with the union of the regions
19    /// of the backdrop layers carrying it (the replay blurs each union once).
20    backdrop_groups: Vec<crate::BackdropGroup>,
21    /// Ops indexes of clips awaiting their expiry, one bucket per open scope
22    /// (index 0 = the root scope, closed by `build`).
23    pending_clips: Vec<Vec<usize>>,
24    /// The depth-slot counter: ONE line for the whole list (Impeller's
25    /// `current_depth_`) — layer children continue it, never restart it.
26    slots: u32,
27    bounds: Option<Rect>,
28    draw_count: u32,
29    /// Backdrop reads in this list, nested lists included — shared or not.
30    /// Consumers that freeze pixels (the raster cache) must refuse any list
31    /// where this is nonzero.
32    backdrop_reads: u32,
33}
34
35/// `Backdrop` describes what a backdrop save layer samples from the scene
36/// beneath it.
37///
38/// Today that is a gaussian blur. Further seed-only stages (a color matrix
39/// for iOS glass saturation) join as fields here, never as effects on the
40/// layer paint — a paint effect would filter the children too.
41#[derive(Clone, Copy, Debug)]
42pub struct Backdrop {
43    /// Gaussian σ in local units at record; replay scales it into device
44    /// px. σ ≤ 0 records a plain save layer (nothing to blur — the scene
45    /// already shows through).
46    pub sigma: f32,
47    /// Tiles sharing a key reuse the FIRST tile's blur — and see the scene
48    /// as of that tile. Use one key only for tiles over the same
49    /// background.
50    pub shared_key: Option<u64>,
51}
52
53impl Backdrop {
54    /// `blur` is a gaussian backdrop blur of `sigma` local units.
55    pub fn blur(sigma: f32) -> Self {
56        Self {
57            sigma,
58            shared_key: None,
59        }
60    }
61
62    /// `shared` marks this backdrop as one tile of a keyed group.
63    pub fn shared(mut self, key: u64) -> Self {
64        self.shared_key = Some(key);
65        self
66    }
67}
68
69/// One save-scope's state: the transform and the device-space clip bounds
70/// (`None` = unclipped). Both restore on `restore()`.
71#[derive(Clone, Copy)]
72struct Scope {
73    transform: Matrix,
74    clip: Option<Rect>,
75    is_layer: bool,
76}
77
78/// Record-time state of an open `save_layer` scope.
79struct LayerScope {
80    /// The `Op::SaveLayer` to backpatch at restore.
81    op_index: usize,
82    /// Union of child draw bounds (list-root space, already clip∩hint-cropped).
83    bounds: Option<Rect>,
84    /// Children so far — for the pairwise-disjoint check (only consulted
85    /// while `compatible` still holds).
86    child_bounds: Vec<Rect>,
87    /// Alpha-linear + disjoint so far (Flutter's
88    /// can_distribute_opacity). Clips and nested lists falsify it.
89    compatible: bool,
90    /// ±3σ (device units) when the composite paint blurs.
91    blur_pad: f32,
92    /// `(sigma, shared_key)` when this layer opens pre-filled with a blur
93    /// of what's beneath it. The keyed group is noted at close, when the
94    /// layer's region is known.
95    backdrop: Option<(f32, Option<u64>)>,
96    /// A caller-supplied bounds hint is a CROP; eliding a hinted layer
97    /// would un-crop it. Conservative — Flutter tracks whether the bounds
98    /// actually clipped (`kMayClipContents`); valo vetoes on any hint until
99    /// a real caller needs the finer rule.
100    hinted: bool,
101}
102
103impl Default for DisplayListBuilder {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl DisplayListBuilder {
110    /// `new` creates an empty display-list builder.
111    pub fn new() -> Self {
112        Self {
113            ops: Vec::new(),
114            scopes: vec![Scope {
115                transform: Matrix::IDENTITY,
116                clip: None,
117                is_layer: false,
118            }],
119            layers: Vec::new(),
120            backdrop_groups: Vec::new(),
121            pending_clips: vec![Vec::new()],
122            slots: 0,
123            bounds: None,
124            draw_count: 0,
125            backdrop_reads: 0,
126        }
127    }
128
129    // ── transform stack (canvas semantics) ─────────────────────────────────
130
131    /// `save` preserves the current transform and clip until the matching `restore`.
132    pub fn save(&mut self) {
133        self.scopes.push(Scope {
134            is_layer: false,
135            ..*self.top()
136        });
137        self.pending_clips.push(Vec::new());
138        self.ops.push(Op::Save);
139    }
140
141    /// `save_count` returns the current canvas save-stack depth.
142    ///
143    /// A new builder starts at one. Each `save` or save-layer operation
144    /// increments the count, and each matched `restore` decrements it. Hosts
145    /// can use the value to verify that callbacks leave shared canvas state balanced.
146    pub fn save_count(&self) -> usize {
147        self.scopes.len()
148    }
149
150    /// `save_layer` begins an offscreen layer composited with `paint` at `restore`.
151    ///
152    /// `bounds_hint` is a local-space crop, not merely an allocation hint;
153    /// content outside it is discarded. Pass `None` to derive bounds from the
154    /// recorded children and active clip.
155    pub fn save_layer(&mut self, bounds_hint: Option<Rect>, paint: &Paint) {
156        self.save_layer_inner(bounds_hint, paint, None, None);
157    }
158
159    /// `save_layer_mask` begins a mask layer closed by `restore`.
160    ///
161    /// The layer's pixels become luminance or alpha coverage according to
162    /// `kind`, retaining enclosing content only where the mask has coverage.
163    /// `bounds_hint` crops the mask in local space.
164    pub fn save_layer_mask(&mut self, bounds_hint: Option<Rect>, kind: MaskKind) {
165        let paint = Paint {
166            blend_mode: crate::BlendMode::DstIn,
167            ..Paint::default()
168        };
169        self.save_layer_inner(bounds_hint, &paint, Some(kind), None);
170    }
171
172    /// `save_layer_backdrop` begins a layer that OPENS pre-filled with the
173    /// [`Backdrop`]-filtered scene beneath it (frosted glass). Children
174    /// paint over that glass, and `restore` composites glass + children as
175    /// one image with `paint` — so a group alpha fades them together
176    /// (Flutter's `saveLayer(bounds, paint, backdrop)`).
177    ///
178    /// Without `bounds_hint` the layer covers the active clip — a backdrop
179    /// reads everything beneath it, so a hint-less, clip-less list records
180    /// unbounded bounds; hint the layer when the list will be embedded.
181    pub fn save_layer_backdrop(
182        &mut self,
183        bounds_hint: Option<Rect>,
184        paint: &Paint,
185        backdrop: Backdrop,
186    ) {
187        // σ ≤ 0 has nothing to sample: keep the layer semantics, drop the
188        // read (and the raster-cache poison that rides every real read).
189        let backdrop = (backdrop.sigma > 0.0).then_some((backdrop.sigma, backdrop.shared_key));
190        self.save_layer_inner(bounds_hint, paint, None, backdrop);
191    }
192
193    fn save_layer_inner(
194        &mut self,
195        bounds_hint: Option<Rect>,
196        paint: &Paint,
197        mask_composite: Option<MaskKind>,
198        backdrop: Option<(f32, Option<u64>)>,
199    ) {
200        let device_hint = bounds_hint.map(|h| self.top().transform.map_rect(&h));
201        let mut scope = Scope {
202            is_layer: true,
203            ..*self.top()
204        };
205        // The hint crops children: fold it into the scope clip so child
206        // bounds (and everything derived) come pre-cropped.
207        if let Some(h) = device_hint {
208            scope.clip = Some(match scope.clip {
209                None => h,
210                Some(c) => c.intersect(&h).unwrap_or_default(),
211            });
212        }
213        // A filter that changes transparent black has output outside child
214        // ink. Its input coverage is therefore the explicit/active clip, or
215        // the renderer's eventual surface limit when no clip is known yet.
216        let floods_scope = paint.blend_mode.is_destructive()
217            || paint
218                .color_filter
219                .is_some_and(|filter| filter.modifies_transparent_black())
220            || paint
221                .image_filter
222                .as_ref()
223                .is_some_and(|filter| filter.modifies_transparent_black())
224            // A backdrop layer OPENS full of blurred parent, so it paints its
225            // whole region whether or not children add ink — and with no
226            // hint that region is everything beneath it. Deriving its bounds
227            // from children instead would leave a childless glass panel empty.
228            || backdrop.is_some();
229        let flooded_bounds = floods_scope.then(|| scope.clip.unwrap_or(Rect::EVERYTHING));
230        if backdrop.is_some() {
231            self.backdrop_reads += 1;
232        }
233        self.scopes.push(scope);
234        self.pending_clips.push(Vec::new());
235        self.layers.push(LayerScope {
236            op_index: self.ops.len(),
237            bounds: flooded_bounds,
238            child_bounds: Vec::new(),
239            compatible: true,
240            // Blurred layers spread ink past their children:
241            // pad the recorded bounds so the texture holds the falloff.
242            blur_pad: paint.device_effect_padding(&self.top().transform),
243            backdrop,
244            hinted: device_hint.is_some(),
245        });
246        // Children keep counting on the SAME depth line (Impeller's global
247        // numbering) — the layer's pass rebases against base_slot.
248        self.ops.push(Op::SaveLayer {
249            paint: paint.clone(),
250            mask_composite,
251            scope_bounds: Rect::default(), // backpatched at restore
252            base_slot: self.slots,
253            composite_slot: 0,
254            can_elide: false,
255            backdrop_sigma: backdrop.map(|(sigma, _)| sigma),
256            backdrop_key: backdrop.and_then(|(_, key)| key),
257        });
258    }
259
260    /// `restore` closes the most recent save, layer, or mask scope.
261    ///
262    /// An unmatched restore is ignored in release builds and triggers a debug assertion.
263    pub fn restore(&mut self) {
264        if self.scopes.len() == 1 {
265            debug_assert!(false, "restore() without matching save()");
266            return;
267        }
268        let scope = self.scopes.pop().expect("checked above");
269        self.expire_scope_clips(); // uses the CURRENT (possibly layer) counter
270        if scope.is_layer {
271            self.close_layer();
272        }
273        self.ops.push(Op::Restore);
274    }
275
276    /// `translate` offsets subsequent drawing and clipping operations.
277    pub fn translate(&mut self, tx: f32, ty: f32) {
278        self.concat(&Matrix::translation(tx, ty));
279    }
280
281    /// `scale` scales subsequent drawing and clipping operations.
282    pub fn scale(&mut self, sx: f32, sy: f32) {
283        self.concat(&Matrix::scale(sx, sy));
284    }
285
286    /// `rotate` rotates subsequent drawing and clipping operations clockwise.
287    ///
288    /// Positive angles rotate clockwise in Valo's y-down coordinate system.
289    pub fn rotate(&mut self, radians: f32) {
290        self.concat(&Matrix::rotation(radians));
291    }
292
293    /// `concat` appends a transform for subsequent drawing and clipping operations.
294    pub fn concat(&mut self, local: &Matrix) {
295        let top = self.top_mut();
296        top.transform = top.transform.then(local);
297        self.ops.push(Op::Transform(*local));
298    }
299
300    // ── clips (depth slots; expiry backpatched when the scope closes) ──────
301
302    /// `clip_rect` applies a rectangular clip until the current scope ends.
303    pub fn clip_rect(&mut self, rect: impl Into<Rect>, op: ClipOp) {
304        let rect = rect.into();
305        self.clip_path(&rect_path(rect), FillRule::NonZero, op);
306    }
307
308    /// `clip_rrect` applies a rounded-rectangle clip with one corner radius.
309    pub fn clip_rrect(&mut self, rect: impl Into<Rect>, radius: f32, op: ClipOp) {
310        let rect = rect.into();
311        self.clip_rrect_radii(rect, [radius; 4], op);
312    }
313
314    /// `clip_rrect_radii` applies a rounded-rectangle clip with per-corner radii.
315    ///
316    /// `radii` is ordered clockwise as `[top-left, top-right, bottom-right, bottom-left]`.
317    pub fn clip_rrect_radii(&mut self, rect: impl Into<Rect>, radii: [f32; 4], op: ClipOp) {
318        let rect = positive_rect(rect.into());
319        let mut p = PathBuilder::new();
320        p.rrect_radii(rect, radii);
321        self.clip_path(&p.build(), FillRule::NonZero, op);
322    }
323
324    /// `clip_rrect_radii_elliptical` applies per-corner elliptical radii.
325    ///
326    /// Each clockwise corner is `[x_radius, y_radius]`, starting at the top-left.
327    pub fn clip_rrect_radii_elliptical(
328        &mut self,
329        rect: impl Into<Rect>,
330        radii: [[f32; 2]; 4],
331        op: ClipOp,
332    ) {
333        let rect = positive_rect(rect.into());
334        if let Some(circular) = circular_radii(radii) {
335            return self.clip_rrect_radii(rect, circular, op);
336        }
337        let mut p = PathBuilder::new();
338        p.rrect_radii_elliptical(rect, radii);
339        self.clip_path(&p.build(), FillRule::NonZero, op);
340    }
341
342    /// `clip_path` applies a path clip until the current scope ends.
343    ///
344    /// Clips do NOT forfeit an enclosing layer's elision (Flutter's
345    /// opacity distribution ignores clips too): a depth clip records its
346    /// own expiry slot and works identically whether the group's children
347    /// draw in a layer or in the parent, and child bounds are already
348    /// clip-cropped when the disjointness check reads them. The Cupertino
349    /// dialog depends on this — fade → clip → backdrop must keep the fade
350    /// elidable or the glass snapshots a cleared offscreen.
351    pub fn clip_path(&mut self, path: &Arc<Path>, fill_rule: FillRule, op: ClipOp) {
352        let bounds = self.top().transform.map_rect(&path.bounds());
353        self.shrink_clip(op, bounds);
354        self.pending_clips
355            .last_mut()
356            .expect("root scope")
357            .push(self.ops.len());
358        self.ops.push(Op::ClipPath {
359            path: Arc::clone(path),
360            fill_rule,
361            op,
362            expiry_slot: 0, // backpatched by expire_scope_clips
363        });
364    }
365
366    // ── draws (one slot each; bounds pre-clipped for the culling oracle) ───
367
368    /// `draw_rect` records a filled or stroked rectangle.
369    pub fn draw_rect(&mut self, rect: impl Into<Rect>, paint: &Paint) {
370        let rect = rect.into();
371        if paint.is_nop() {
372            return;
373        }
374        if matches!(paint.style, crate::PaintStyle::Stroke(_)) {
375            // Stroked rects are stroked paths — one geometry pipeline.
376            // Zero-area rects still stroke: Skia draws them as a line.
377            return self.draw_path(&rect_path(rect), FillRule::NonZero, paint);
378        }
379        if rect.is_empty() {
380            return;
381        }
382        if is_analytic_blur(paint) {
383            self.record_rrect_blur(rect, [0.0; 4], paint);
384            return;
385        }
386        let Some(bounds) = self.clipped_device_bounds(&paint.effect_bounds(rect)) else {
387            return; // fully clipped at record time
388        };
389        let slot = self.take_draw_slot(bounds, supports_opacity(paint));
390        self.ops.push(Op::DrawRect {
391            rect,
392            paint: paint.clone(),
393            bounds,
394            slot,
395        });
396    }
397
398    /// `draw_path` records a filled or stroked path.
399    pub fn draw_path(&mut self, path: &Arc<Path>, fill_rule: FillRule, paint: &Paint) {
400        if path.is_empty() || paint.is_nop() {
401            return;
402        }
403        let scale = self.top().transform.max_scale();
404        let local = paint.effect_bounds(path.bounds().expand(paint.stroke_padding_at_scale(scale)));
405        let Some(bounds) = self.clipped_device_bounds(&local) else {
406            return;
407        };
408        let slot = self.take_draw_slot(bounds, supports_opacity(paint));
409        self.ops.push(Op::DrawPath {
410            path: Arc::clone(path),
411            fill_rule,
412            paint: paint.clone(),
413            bounds,
414            slot,
415        });
416    }
417
418    /// `draw_circle` records a filled or stroked circle.
419    pub fn draw_circle(
420        &mut self,
421        center: impl Into<valo_geometry::Point>,
422        radius: f32,
423        paint: &Paint,
424    ) {
425        let mut p = PathBuilder::new();
426        p.circle(center, radius);
427        self.draw_path(&p.build(), FillRule::NonZero, paint);
428    }
429
430    /// `draw_rrect` records a rounded rectangle with one corner radius.
431    pub fn draw_rrect(&mut self, rect: impl Into<Rect>, radius: f32, paint: &Paint) {
432        let rect = rect.into();
433        self.draw_rrect_radii(rect, [radius; 4], paint);
434    }
435
436    /// `draw_rrect_radii` records a rounded rectangle with per-corner radii.
437    ///
438    /// `radii` is ordered clockwise as `[top-left, top-right, bottom-right, bottom-left]`.
439    pub fn draw_rrect_radii(&mut self, rect: impl Into<Rect>, radii: [f32; 4], paint: &Paint) {
440        let rect = positive_rect(rect.into());
441        if rect.is_empty() || paint.is_nop() {
442            return;
443        }
444        if is_analytic_blur(paint) {
445            self.record_rrect_blur(rect, radii, paint);
446            return;
447        }
448        let mut p = PathBuilder::new();
449        p.rrect_radii(rect, radii);
450        self.draw_path(&p.build(), FillRule::NonZero, paint);
451    }
452
453    /// `draw_rrect_radii_elliptical` records per-corner elliptical radii.
454    ///
455    /// Each clockwise corner is `[x_radius, y_radius]`, starting at the top-left.
456    pub fn draw_rrect_radii_elliptical(
457        &mut self,
458        rect: impl Into<Rect>,
459        radii: [[f32; 2]; 4],
460        paint: &Paint,
461    ) {
462        let rect = positive_rect(rect.into());
463        if let Some(circular) = circular_radii(radii) {
464            return self.draw_rrect_radii(rect, circular, paint);
465        }
466        if rect.is_empty() || paint.is_nop() {
467            return;
468        }
469        let mut p = PathBuilder::new();
470        p.rrect_radii_elliptical(rect, radii);
471        self.draw_path(&p.build(), FillRule::NonZero, paint);
472    }
473
474    /// `draw_image` records the whole image into `dst`.
475    ///
476    /// It uses linear filtering and clamps at the image edges.
477    pub fn draw_image(&mut self, image: &Image, dst: Rect, paint: &Paint) {
478        let src = Rect::new(0.0, 0.0, image.width(), image.height());
479        self.draw_image_rect(image, src, dst, Sampling::default(), paint);
480    }
481
482    /// `draw_image_rect` records a source region into `dst` with explicit sampling.
483    ///
484    /// `src` is measured in source pixels. Tiling applies when `src` extends
485    /// beyond the image bounds.
486    pub fn draw_image_rect(
487        &mut self,
488        image: &Image,
489        src: Rect,
490        dst: Rect,
491        sampling: Sampling,
492        paint: &Paint,
493    ) {
494        if dst.is_empty() || src.is_empty() || paint.is_nop() {
495            return;
496        }
497        let Some(bounds) = self.clipped_device_bounds(&paint.effect_bounds(dst)) else {
498            return;
499        };
500        let slot = self.take_draw_slot(bounds, supports_opacity(paint));
501        self.ops.push(Op::DrawImage {
502            image: image.clone(),
503            src,
504            dst,
505            sampling,
506            paint: paint.clone(),
507            bounds,
508            slot,
509        });
510    }
511
512    /// `draw_glyph_run` records positioned glyphs from one font and size.
513    ///
514    /// `local_bounds` must enclose the glyph ink in local coordinates. Valo
515    /// retains the supplied font and glyph positions in the display list.
516    pub fn draw_glyph_run(
517        &mut self,
518        font: std::sync::Arc<valo_text::Font>,
519        size: f32,
520        paint: &Paint,
521        glyphs: Arc<Vec<crate::GlyphPos>>,
522        local_bounds: Rect,
523    ) {
524        if glyphs.is_empty() || paint.is_nop() {
525            return;
526        }
527        let scale = self.top().transform.max_scale();
528        let padded = paint.effect_bounds(local_bounds.expand(paint.stroke_padding_at_scale(scale)));
529        let Some(bounds) = self.clipped_device_bounds(&padded) else {
530            return;
531        };
532        // Shader text desugars into a two-draw layer at plan time; group
533        // opacity can't ride its children (it would apply twice).
534        let distributes = supports_opacity(paint) && paint.shader.is_none();
535        let slot = self.take_draw_slot(bounds, distributes);
536        self.ops.push(Op::GlyphRun {
537            font,
538            size,
539            paint: paint.clone(),
540            glyphs,
541            bounds,
542            slot,
543        });
544    }
545
546    /// `draw_display_list` records a nested display list by shared reference.
547    pub fn draw_display_list(&mut self, list: &Arc<DisplayList>) {
548        self.embed_display_list(list, false);
549    }
550
551    /// `draw_display_list_cached` records a nested list as a raster-cache candidate.
552    ///
553    /// Use it for stable, repeatedly drawn lists whose recording is expensive.
554    /// The renderer may still replay the list directly when caching is unsuitable.
555    pub fn draw_display_list_cached(&mut self, list: &Arc<DisplayList>) {
556        self.embed_display_list(list, true);
557    }
558
559    fn embed_display_list(&mut self, list: &Arc<DisplayList>, cache: bool) {
560        let Some(child_bounds) = list.bounds() else {
561            return; // draws nothing
562        };
563        let Some(bounds) = self.clipped_device_bounds(&child_bounds) else {
564            return;
565        };
566        let base_slot = self.slots;
567        self.slots += list.depth_slots();
568        self.draw_count += list.draw_count();
569        self.backdrop_reads += list.backdrop_reads();
570        self.union_bounds(bounds);
571        // Conservative: a nested list's internal structure is opaque here.
572        self.note_layer_child(bounds, false);
573        self.ops.push(Op::DrawDisplayList {
574            list: Arc::clone(list),
575            bounds,
576            base_slot,
577            cache,
578        });
579    }
580
581    // ── build ──────────────────────────────────────────────────────────────
582
583    /// `build` consumes the builder and returns its immutable display list.
584    ///
585    /// Any unmatched save scopes are closed before the list is finalized.
586    pub fn build(mut self) -> DisplayList {
587        // Unbalanced saves are a recording bug, but a recoverable one: close
588        // them so replay's stack discipline holds.
589        while self.scopes.len() > 1 {
590            self.restore();
591        }
592        self.expire_scope_clips(); // root-scope clips live to end-of-list
593        DisplayList::new(
594            self.ops,
595            self.bounds,
596            self.draw_count,
597            self.slots,
598            self.backdrop_groups,
599            self.backdrop_reads,
600        )
601    }
602
603    // ── internals ──────────────────────────────────────────────────────────
604
605    fn top(&self) -> &Scope {
606        self.scopes.last().expect("scope stack never empty")
607    }
608
609    fn top_mut(&mut self) -> &mut Scope {
610        self.scopes.last_mut().expect("scope stack never empty")
611    }
612
613    /// Backpatch the layer's oracle at its restore. Order matters: the
614    /// layer's clips expired first (caller did that), so their slots sit
615    /// inside the children's span; the composite takes the NEXT slot on the
616    /// same line.
617    fn close_layer(&mut self) {
618        let layer = self.layers.pop().expect("is_layer scope had a LayerScope");
619        self.slots += 1; // the composite's slot, next after the children's span
620        let mut scope_bounds = layer.bounds.unwrap_or_default();
621        if layer.blur_pad > 0.0 && !scope_bounds.is_empty() {
622            scope_bounds = scope_bounds.expand(layer.blur_pad);
623        }
624
625        let Op::SaveLayer {
626            paint,
627            mask_composite: _,
628            scope_bounds: sb,
629            base_slot: _,
630            composite_slot,
631            can_elide,
632            ..
633        } = &mut self.ops[layer.op_index]
634        else {
635            unreachable!("LayerScope.op_index always points at SaveLayer");
636        };
637        *sb = scope_bounds;
638        *composite_slot = self.slots;
639        // A backdrop layer never elides (its seed needs a texture); a hinted
640        // layer never elides (the hint is a crop that eliding would undo).
641        *can_elide = layer.compatible
642            && paint.is_opacity_only()
643            && layer.backdrop.is_none()
644            && !layer.hinted;
645
646        // One SrcOver composite quad — an ENCLOSING opacity group can still
647        // distribute its alpha onto it. This is what lets a fading group
648        // elide over a backdrop layer: the alpha lands once, on the glass
649        // and its children together.
650        let supports = paint.blend_mode == crate::BlendMode::SrcOver;
651        if let Some((sigma, Some(key))) = layer.backdrop {
652            self.note_backdrop_group(key, scope_bounds, sigma);
653        }
654        self.draw_count += 1; // the composite draws
655        self.union_bounds(scope_bounds);
656        self.note_layer_child(scope_bounds, supports);
657    }
658
659    /// One-quad closed-form blurred (r)rect; the quad spans the 3σ spread.
660    fn record_rrect_blur(&mut self, rect: Rect, radii: [f32; 4], paint: &Paint) {
661        let Some(bounds) = self.clipped_device_bounds(&rect.expand(paint.mask_padding())) else {
662            return;
663        };
664        let slot = self.take_draw_slot(bounds, supports_opacity(paint));
665        self.ops.push(Op::RRectBlur {
666            rect,
667            radii: valo_geometry::constrain_radii(&rect, radii),
668            paint: paint.clone(),
669            bounds,
670            slot,
671        });
672    }
673
674    fn note_backdrop_group(&mut self, key: u64, bounds: Rect, sigma: f32) {
675        match self.backdrop_groups.iter_mut().find(|g| g.key == key) {
676            Some(group) => {
677                group.union_bounds = group.union_bounds.union(&bounds);
678                if group.sigma != Some(sigma) {
679                    group.sigma = None; // mixed σ under one key: no sharing
680                }
681            }
682            None => self.backdrop_groups.push(crate::BackdropGroup {
683                key,
684                union_bounds: bounds,
685                sigma: Some(sigma),
686            }),
687        }
688    }
689
690    /// Draw bounds in list-root space, pre-intersected with the clip stack;
691    /// `None` = provably invisible, don't record.
692    fn clipped_device_bounds(&self, local: &Rect) -> Option<Rect> {
693        let device = self.top().transform.map_rect(local);
694        match self.top().clip {
695            None => Some(device),
696            Some(clip) => device.intersect(&clip),
697        }
698    }
699
700    /// Intersect clips shrink the recorded clip bounds; Difference is kept
701    /// conservative (bounds unchanged — correct, just not tighter).
702    fn shrink_clip(&mut self, op: ClipOp, shape_bounds: Rect) {
703        if op == ClipOp::Difference {
704            return;
705        }
706        let top = self.top_mut();
707        top.clip = Some(match top.clip {
708            None => shape_bounds,
709            Some(c) => c.intersect(&shape_bounds).unwrap_or_default(), // empty = all clipped
710        });
711    }
712
713    /// Closing a scope that recorded clips consumes ONE slot — that slot is
714    /// every pending clip's expiry: scope draws sit below it (ceilinged),
715    /// later draws above it (free). This is how expiry stays record-time.
716    fn expire_scope_clips(&mut self) {
717        let pending = self.pending_clips.pop().expect("scope stack never empty");
718        if !pending.is_empty() {
719            self.slots += 1;
720            for idx in pending {
721                let Op::ClipPath { expiry_slot, .. } = &mut self.ops[idx] else {
722                    unreachable!("pending_clips indexes only ClipPath ops");
723                };
724                *expiry_slot = self.slots;
725            }
726        }
727        if self.pending_clips.is_empty() {
728            self.pending_clips.push(Vec::new()); // keep the root bucket alive
729        }
730    }
731
732    fn take_draw_slot(&mut self, device_bounds: Rect, supports_opacity: bool) -> u32 {
733        self.slots += 1;
734        self.draw_count += 1;
735        self.union_bounds(device_bounds);
736        self.note_layer_child(device_bounds, supports_opacity);
737        self.slots
738    }
739
740    fn union_bounds(&mut self, b: Rect) {
741        self.bounds = Some(match self.bounds {
742            Some(cur) => cur.union(&b),
743            None => b,
744        });
745    }
746
747    /// Feed the innermost open layer's oracle: union its bounds; falsify
748    /// compatibility on an alpha-nonlinear child or the first overlap
749    /// (pairwise-disjoint is what makes shared-z elision legal).
750    fn note_layer_child(&mut self, bounds: Rect, supports_opacity: bool) {
751        let Some(layer) = self.layers.last_mut() else {
752            return;
753        };
754        layer.bounds = Some(match layer.bounds {
755            Some(cur) => cur.union(&bounds),
756            None => bounds,
757        });
758        if !layer.compatible {
759            return;
760        }
761        if !supports_opacity {
762            layer.compatible = false;
763            return;
764        }
765        if layer
766            .child_bounds
767            .iter()
768            .any(|prior| prior.intersects(&bounds))
769        {
770            layer.compatible = false;
771            return;
772        }
773        layer.child_bounds.push(bounds);
774    }
775}
776
777/// Group opacity distributes over a child iff scaling its src by α equals
778/// compositing the group at α: true for SrcOver and Plus (both linear in
779/// src), false for dst-multiplying and advanced modes.
780fn supports_opacity(paint: &Paint) -> bool {
781    // A colour filter is affine, not linear: distributing the group's alpha
782    // into the paint colour would filter the DIMMED colour, and
783    // `matrix(c · α) != matrix(c) · α` wherever the matrix translates or
784    // clamps. Filtered draws keep their own layer.
785    paint.color_filter.is_none()
786        && paint.effective_image_filter().is_none()
787        && matches!(
788            paint.blend_mode,
789            crate::BlendMode::SrcOver | crate::BlendMode::Plus
790        )
791}
792
793/// Solid + mask blur = the closed-form quad (Impeller's shadow gate,
794/// Canvas::IsShadowBlurDrawOperation). Shaders/images take the filter path.
795/// `Some(circular)` when every corner's rx equals its ry — the case the
796/// analytic rrect pipelines (blur shadows, uniform clips) can take.
797fn circular_radii(radii: [[f32; 2]; 4]) -> Option<[f32; 4]> {
798    radii
799        .iter()
800        .all(|[x, y]| x == y)
801        .then(|| radii.map(|[x, _]| x))
802}
803
804// Flutter's RRect bridge accepts inverted edges and normalizes them before
805// creating the engine round rect. CupertinoActivityIndicator relies on this.
806fn positive_rect(rect: Rect) -> Rect {
807    let x = if rect.width < 0.0 {
808        rect.x + rect.width
809    } else {
810        rect.x
811    };
812    let y = if rect.height < 0.0 {
813        rect.y + rect.height
814    } else {
815        rect.y
816    };
817    Rect::new(x, y, rect.width.abs(), rect.height.abs())
818}
819
820fn is_analytic_blur(paint: &Paint) -> bool {
821    paint.mask_blur.is_some()
822        && paint.shader.is_none()
823        // The closed-form quad has nowhere to run a colour filter, so a
824        // filtered shape takes the general layer path instead of silently
825        // rendering its unfiltered colour.
826        && paint.color_filter.is_none()
827        && paint.effective_image_filter().is_none()
828        && matches!(paint.style, crate::PaintStyle::Fill)
829}
830
831fn rect_path(r: Rect) -> Arc<Path> {
832    let mut p = PathBuilder::new();
833    p.rect(r);
834    p.build()
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840    use crate::BlendMode;
841    use valo_geometry::Color;
842
843    #[test]
844    fn save_count_tracks_saves_layers_and_restores() {
845        let mut builder = DisplayListBuilder::new();
846        assert_eq!(builder.save_count(), 1);
847
848        builder.save();
849        assert_eq!(builder.save_count(), 2);
850
851        builder.save_layer(None, &Paint::default());
852        assert_eq!(builder.save_count(), 3);
853
854        builder.restore();
855        assert_eq!(builder.save_count(), 2);
856        builder.restore();
857        assert_eq!(builder.save_count(), 1);
858    }
859
860    #[test]
861    fn rounded_rects_normalize_inverted_edges_like_flutter() {
862        let mut builder = DisplayListBuilder::new();
863        builder.draw_rrect(
864            Rect::from_ltrb(-1.0, -10.0 / 3.0, 1.0, -10.0),
865            1.0,
866            &Paint::from_color(Color::WHITE),
867        );
868
869        let list = builder.build();
870        let Op::DrawPath { path, .. } = &list.ops()[0] else {
871            panic!("rounded rectangle should record as a path");
872        };
873        assert_eq!(
874            path.bounds(),
875            Rect::from_ltrb(-1.0, -10.0, 1.0, -10.0 / 3.0)
876        );
877    }
878    fn red() -> Paint {
879        Paint::from_color(Color::rgb(1.0, 0.0, 0.0))
880    }
881
882    fn alpha_layer(a: f32) -> Paint {
883        Paint::from_color(Color::rgba(0.0, 0.0, 0.0, a))
884    }
885
886    fn find_clip(dl: &DisplayList) -> (&Op, u32) {
887        for op in dl.ops() {
888            if let Op::ClipPath { expiry_slot, .. } = op {
889                return (op, *expiry_slot);
890            }
891        }
892        panic!("no clip recorded");
893    }
894
895    /// Every recorded layer's `(scope_bounds, base_slot, composite_slot,
896    /// can_elide)`, in recording order — so an enclosing layer comes before
897    /// the layers nested inside it.
898    fn layer_facts(dl: &DisplayList) -> Vec<(Rect, u32, u32, bool)> {
899        dl.ops()
900            .iter()
901            .filter_map(|op| match op {
902                Op::SaveLayer {
903                    scope_bounds,
904                    base_slot,
905                    composite_slot,
906                    can_elide,
907                    ..
908                } => Some((*scope_bounds, *base_slot, *composite_slot, *can_elide)),
909                _ => None,
910            })
911            .collect()
912    }
913
914    fn find_layer(dl: &DisplayList) -> (Rect, u32, u32, bool) {
915        *layer_facts(dl).first().expect("no layer recorded")
916    }
917
918    #[test]
919    fn oracle_bounds_follow_transforms() {
920        let mut b = DisplayListBuilder::new();
921        b.save();
922        b.translate(100.0, 50.0);
923        b.draw_rect(Rect::new(0.0, 0.0, 10.0, 10.0), &red());
924        b.restore();
925        let dl = b.build();
926        assert_eq!(dl.bounds(), Some(Rect::new(100.0, 50.0, 10.0, 10.0)));
927        assert_eq!(dl.draw_count(), 1);
928        assert_eq!(dl.depth_slots(), 1);
929    }
930
931    #[test]
932    fn clip_shrinks_recorded_draw_bounds() {
933        let mut b = DisplayListBuilder::new();
934        b.save();
935        b.clip_rect(Rect::new(0.0, 0.0, 50.0, 50.0), ClipOp::Intersect);
936        b.draw_rect(Rect::new(25.0, 25.0, 100.0, 100.0), &red());
937        b.restore();
938        let dl = b.build();
939        assert_eq!(dl.bounds(), Some(Rect::new(25.0, 25.0, 25.0, 25.0)));
940    }
941
942    #[test]
943    fn fully_clipped_draw_is_dropped() {
944        let mut b = DisplayListBuilder::new();
945        b.save();
946        b.clip_rect(Rect::new(0.0, 0.0, 10.0, 10.0), ClipOp::Intersect);
947        b.draw_rect(Rect::new(500.0, 500.0, 10.0, 10.0), &red());
948        b.restore();
949        let dl = b.build();
950        assert_eq!(dl.draw_count(), 0);
951    }
952
953    #[test]
954    fn clip_expiry_is_the_restore_slot() {
955        let mut b = DisplayListBuilder::new();
956        b.draw_rect(Rect::new(0.0, 0.0, 10.0, 10.0), &red()); // slot 1
957        b.save();
958        b.clip_rect(Rect::new(0.0, 0.0, 50.0, 50.0), ClipOp::Intersect);
959        b.draw_rect(Rect::new(0.0, 0.0, 10.0, 10.0), &red()); // slot 2
960        b.restore(); // slot 3 = expiry
961        b.draw_rect(Rect::new(0.0, 0.0, 10.0, 10.0), &red()); // slot 4
962        let dl = b.build();
963        let (_, expiry) = find_clip(&dl);
964        assert_eq!(expiry, 3);
965        assert_eq!(dl.depth_slots(), 4);
966    }
967
968    #[test]
969    fn root_clip_expires_at_end_of_list() {
970        let mut b = DisplayListBuilder::new();
971        b.clip_rect(Rect::new(0.0, 0.0, 50.0, 50.0), ClipOp::Intersect);
972        b.draw_rect(Rect::new(0.0, 0.0, 10.0, 10.0), &red()); // slot 1
973        let dl = b.build();
974        let (_, expiry) = find_clip(&dl);
975        assert_eq!(expiry, 2, "root clips expire at the virtual end slot");
976        assert_eq!(dl.depth_slots(), 2);
977    }
978
979    #[test]
980    fn difference_clip_keeps_bounds_conservative() {
981        let mut b = DisplayListBuilder::new();
982        b.save();
983        b.clip_rect(Rect::new(0.0, 0.0, 50.0, 50.0), ClipOp::Difference);
984        b.draw_rect(Rect::new(0.0, 0.0, 100.0, 100.0), &red());
985        b.restore();
986        let dl = b.build();
987        assert_eq!(dl.bounds(), Some(Rect::new(0.0, 0.0, 100.0, 100.0)));
988    }
989
990    #[test]
991    fn nested_list_folds_oracle_and_offsets_slots() {
992        let mut inner = DisplayListBuilder::new();
993        inner.draw_rect(Rect::new(0.0, 0.0, 10.0, 10.0), &red());
994        inner.draw_rect(Rect::new(20.0, 0.0, 10.0, 10.0), &red());
995        let inner = Arc::new(inner.build());
996
997        let mut outer = DisplayListBuilder::new();
998        outer.draw_rect(Rect::new(0.0, 0.0, 5.0, 5.0), &red()); // slot 1
999        outer.translate(5.0, 5.0);
1000        outer.draw_display_list(&inner); // base_slot 1, child consumes 2
1001        outer.draw_rect(Rect::new(0.0, 0.0, 5.0, 5.0), &red()); // slot 4
1002        let outer = outer.build();
1003
1004        assert_eq!(outer.draw_count(), 4);
1005        assert_eq!(outer.depth_slots(), 4);
1006        let base = outer
1007            .ops()
1008            .iter()
1009            .find_map(|op| match op {
1010                Op::DrawDisplayList { base_slot, .. } => Some(*base_slot),
1011                _ => None,
1012            })
1013            .unwrap();
1014        assert_eq!(base, 1);
1015    }
1016
1017    #[test]
1018    fn nop_draws_are_dropped() {
1019        let mut b = DisplayListBuilder::new();
1020        b.draw_rect(Rect::new(0.0, 0.0, 0.0, 10.0), &red()); // empty rect
1021        b.draw_rect(
1022            Rect::new(0.0, 0.0, 10.0, 10.0),
1023            &Paint {
1024                color: Color::TRANSPARENT,
1025                blend_mode: BlendMode::SrcOver,
1026                ..Default::default()
1027            },
1028        );
1029        let dl = b.build();
1030        assert_eq!(dl.ops().len(), 0);
1031        assert_eq!(dl.bounds(), None);
1032    }
1033
1034    // ── save layers (M4) ────────────────────────────────────────────────────
1035
1036    #[test]
1037    fn layer_oracle_bounds_and_slots() {
1038        let mut b = DisplayListBuilder::new();
1039        b.draw_rect(Rect::new(0.0, 0.0, 10.0, 10.0), &red()); // slot 1
1040        b.save_layer(None, &alpha_layer(0.5)); // base_slot = 1
1041        b.draw_rect(Rect::new(20.0, 20.0, 30.0, 30.0), &red()); // slot 2
1042        b.draw_rect(Rect::new(60.0, 20.0, 30.0, 30.0), &red()); // slot 3
1043        b.restore(); // composite = slot 4, next on the same line
1044        b.draw_rect(Rect::new(0.0, 40.0, 10.0, 10.0), &red()); // slot 5
1045        let dl = b.build();
1046
1047        let (bounds, base_slot, composite_slot, can_elide) = find_layer(&dl);
1048        assert_eq!(bounds, Rect::new(20.0, 20.0, 70.0, 30.0));
1049        assert_eq!(base_slot, 1, "scope opened after one parent draw");
1050        assert_eq!(composite_slot, 4, "children keep the global line");
1051        assert!(
1052            can_elide,
1053            "disjoint SrcOver children + alpha-only composite"
1054        );
1055        assert_eq!(
1056            dl.depth_slots(),
1057            5,
1058            "one global depth line (Impeller's current_depth_)"
1059        );
1060        assert_eq!(dl.draw_count(), 5, "4 rects + the composite");
1061    }
1062
1063    #[test]
1064    fn overlapping_children_forfeit_elision() {
1065        let mut b = DisplayListBuilder::new();
1066        b.save_layer(None, &alpha_layer(0.5));
1067        b.draw_rect(Rect::new(0.0, 0.0, 30.0, 30.0), &red());
1068        b.draw_rect(Rect::new(10.0, 10.0, 30.0, 30.0), &red()); // overlaps
1069        b.restore();
1070        let (_, _, _, can_elide) = find_layer(&b.build());
1071        assert!(!can_elide);
1072    }
1073
1074    #[test]
1075    fn advanced_blend_composite_forfeits_elision() {
1076        let mut b = DisplayListBuilder::new();
1077        let paint = Paint {
1078            color: Color::rgba(0.0, 0.0, 0.0, 0.5),
1079            blend_mode: BlendMode::Multiply,
1080            ..Default::default()
1081        };
1082        b.save_layer(None, &paint);
1083        b.draw_rect(Rect::new(0.0, 0.0, 30.0, 30.0), &red());
1084        b.restore();
1085        let (_, _, _, can_elide) = find_layer(&b.build());
1086        assert!(!can_elide);
1087    }
1088
1089    #[test]
1090    fn destructive_layer_composite_floods_the_active_clip() {
1091        let mut b = DisplayListBuilder::new();
1092        b.clip_rect(Rect::new(4.0, 6.0, 80.0, 60.0), ClipOp::Intersect);
1093        b.save_layer(
1094            None,
1095            &Paint {
1096                blend_mode: BlendMode::SrcIn,
1097                ..Default::default()
1098            },
1099        );
1100        b.draw_rect(Rect::new(20.0, 20.0, 10.0, 10.0), &red());
1101        b.restore();
1102        let (bounds, ..) = find_layer(&b.build());
1103        assert_eq!(bounds, Rect::new(4.0, 6.0, 80.0, 60.0));
1104    }
1105
1106    #[test]
1107    fn clip_inside_layer_keeps_elision() {
1108        let mut b = DisplayListBuilder::new();
1109        b.save_layer(None, &alpha_layer(0.5));
1110        b.clip_rect(Rect::new(0.0, 0.0, 50.0, 50.0), ClipOp::Intersect);
1111        b.draw_rect(Rect::new(0.0, 0.0, 30.0, 30.0), &red());
1112        b.restore();
1113        let (_, _, _, can_elide) = find_layer(&b.build());
1114        // A depth clip expires on its own slot either way; Flutter's
1115        // opacity distribution ignores clips too.
1116        assert!(can_elide);
1117    }
1118
1119    #[test]
1120    fn bounds_hint_crops_the_scope() {
1121        let mut b = DisplayListBuilder::new();
1122        b.save_layer(Some(Rect::new(0.0, 0.0, 40.0, 40.0)), &alpha_layer(0.5));
1123        b.draw_rect(Rect::new(20.0, 20.0, 100.0, 100.0), &red());
1124        b.restore();
1125        let (bounds, ..) = find_layer(&b.build());
1126        assert_eq!(bounds, Rect::new(20.0, 20.0, 20.0, 20.0));
1127    }
1128
1129    #[test]
1130    fn clips_inside_layers_expire_within_the_scope_span() {
1131        let mut b = DisplayListBuilder::new();
1132        b.save_layer(None, &alpha_layer(0.5)); // base_slot = 0
1133        b.save();
1134        b.clip_rect(Rect::new(0.0, 0.0, 50.0, 50.0), ClipOp::Intersect);
1135        b.draw_rect(Rect::new(0.0, 0.0, 30.0, 30.0), &red()); // slot 1
1136        b.restore(); // slot 2 = expiry
1137        b.restore(); // composite = slot 3
1138        let dl = b.build();
1139        let (_, expiry) = find_clip(&dl);
1140        assert_eq!(expiry, 2, "expiry sits inside the layer's span");
1141        let (_, base_slot, composite_slot, _) = find_layer(&dl);
1142        assert_eq!((base_slot, composite_slot), (0, 3));
1143    }
1144
1145    // ── mask + backdrop blur (M5) ───────────────────────────────────────────
1146
1147    #[test]
1148    fn solid_mask_blur_records_the_analytic_op() {
1149        let mut b = DisplayListBuilder::new();
1150        let paint = Paint {
1151            mask_blur: Some(crate::MaskBlur::new(4.0)),
1152            ..red()
1153        };
1154        b.draw_rect(Rect::new(20.0, 20.0, 40.0, 40.0), &paint);
1155        b.draw_rrect(Rect::new(100.0, 20.0, 40.0, 40.0), 8.0, &paint);
1156        let dl = b.build();
1157        let blurs: Vec<_> = dl
1158            .ops()
1159            .iter()
1160            .filter_map(|op| match op {
1161                Op::RRectBlur { radii, bounds, .. } => Some((*radii, *bounds)),
1162                _ => None,
1163            })
1164            .collect();
1165        assert_eq!(blurs.len(), 2);
1166        assert_eq!(blurs[0].0, [0.0; 4]);
1167        assert_eq!(blurs[1].0, [8.0; 4]);
1168        // Bounds carry the ±3σ spread.
1169        assert_eq!(blurs[0].1, Rect::new(8.0, 8.0, 64.0, 64.0));
1170    }
1171
1172    #[test]
1173    fn shader_mask_blur_stays_general_but_pads_bounds() {
1174        let mut b = DisplayListBuilder::new();
1175        let paint = Paint {
1176            mask_blur: Some(crate::MaskBlur::new(2.0)),
1177            shader: Some(crate::Shader::linear(
1178                valo_geometry::Point::new(0.0, 0.0),
1179                valo_geometry::Point::new(10.0, 0.0),
1180                Color::BLACK,
1181                Color::WHITE,
1182            )),
1183            color: Color::WHITE,
1184            ..Default::default()
1185        };
1186        b.draw_rect(Rect::new(10.0, 10.0, 20.0, 20.0), &paint);
1187        let dl = b.build();
1188        let Op::DrawRect { bounds, .. } = &dl.ops()[0] else {
1189            panic!("shader paints keep the general op");
1190        };
1191        assert_eq!(*bounds, Rect::new(4.0, 4.0, 32.0, 32.0));
1192    }
1193
1194    #[test]
1195    fn hinted_layer_forfeits_elision() {
1196        let mut b = DisplayListBuilder::new();
1197        b.save_layer(Some(Rect::new(0.0, 0.0, 40.0, 40.0)), &alpha_layer(0.5));
1198        b.draw_rect(Rect::new(0.0, 0.0, 30.0, 30.0), &red());
1199        b.restore();
1200        let (_, _, _, can_elide) = find_layer(&b.build());
1201        assert!(!can_elide, "the hint is a crop; eliding would un-crop it");
1202    }
1203
1204    // ── backdrop layers ─────────────────────────────────────────────────────
1205
1206    /// A glass panel: a backdrop layer with nothing painted over it.
1207    fn glass(b: &mut DisplayListBuilder, rect: Rect, sigma: f32, key: Option<u64>) {
1208        b.save_layer_backdrop(
1209            Some(rect),
1210            &Paint::default(),
1211            Backdrop {
1212                sigma,
1213                shared_key: key,
1214            },
1215        );
1216        b.restore();
1217    }
1218
1219    #[test]
1220    fn shared_backdrops_group_by_key() {
1221        let mut b = DisplayListBuilder::new();
1222        glass(&mut b, Rect::new(0.0, 0.0, 50.0, 50.0), 8.0, Some(7));
1223        glass(&mut b, Rect::new(100.0, 0.0, 50.0, 50.0), 8.0, Some(7));
1224        glass(&mut b, Rect::new(0.0, 100.0, 50.0, 50.0), 8.0, None);
1225        let dl = b.build();
1226        let group = dl.backdrop_group(7).expect("key 7 recorded");
1227        // Each layer joins its group at close, contributing its scope bounds.
1228        assert_eq!(group.union_bounds, Rect::new(0.0, 0.0, 150.0, 50.0));
1229        assert_eq!(group.sigma, Some(8.0), "one σ across the key: shareable");
1230        assert_eq!(dl.draw_count(), 3, "each layer's composite is a draw");
1231        assert_eq!(dl.depth_slots(), 3);
1232    }
1233
1234    #[test]
1235    fn mixed_sigma_under_one_key_clears_the_shared_sigma() {
1236        let mut b = DisplayListBuilder::new();
1237        glass(&mut b, Rect::new(0.0, 0.0, 50.0, 50.0), 4.0, Some(7));
1238        glass(&mut b, Rect::new(100.0, 0.0, 50.0, 50.0), 12.0, Some(7));
1239        let dl = b.build();
1240        let group = dl.backdrop_group(7).expect("key 7 recorded");
1241        assert_eq!(group.sigma, None, "disagreeing σ cannot share one blur");
1242    }
1243
1244    #[test]
1245    fn opacity_group_elides_over_a_backdrop_layer() {
1246        let mut b = DisplayListBuilder::new();
1247        b.save_layer(None, &alpha_layer(0.5));
1248        glass(&mut b, Rect::new(0.0, 0.0, 50.0, 50.0), 4.0, None);
1249        b.restore();
1250        let layers = layer_facts(&b.build());
1251        assert_eq!(layers.len(), 2, "the opacity group and the glass inside it");
1252        assert!(
1253            layers[0].3,
1254            "the group's alpha lands on the glass composite — the whole point \
1255             of backdrop-as-a-layer-property: glass keeps blurring while the \
1256             group fades"
1257        );
1258        assert!(!layers[1].3, "the glass itself needs a texture to seed");
1259    }
1260
1261    /// The Cupertino dialog's exact recording shape: fade -> superellipse
1262    /// clip -> glass. The clip must NOT forfeit the fade's elision - a depth
1263    /// clip works identically whether the group's children draw in a layer
1264    /// or the parent, and eliding is what lets the glass snapshot the live
1265    /// scene instead of the fade's cleared offscreen.
1266    #[test]
1267    fn a_clip_does_not_forfeit_elision_around_glass() {
1268        let mut b = DisplayListBuilder::new();
1269        b.save_layer(None, &alpha_layer(0.5));
1270        b.save();
1271        let mut clip = PathBuilder::new();
1272        clip.rect(Rect::new(0.0, 0.0, 60.0, 60.0));
1273        b.clip_path(&clip.build(), FillRule::NonZero, ClipOp::Intersect);
1274        glass(&mut b, Rect::new(0.0, 0.0, 50.0, 50.0), 4.0, None);
1275        b.restore();
1276        b.restore();
1277        let layers = layer_facts(&b.build());
1278        assert_eq!(layers.len(), 2);
1279        assert!(layers[0].3, "the clipped fade still elides");
1280        assert!(!layers[1].3);
1281    }
1282
1283    #[test]
1284    fn backdrop_reads_count_unshared_and_nested() {
1285        let mut child = DisplayListBuilder::new();
1286        glass(&mut child, Rect::new(0.0, 0.0, 50.0, 50.0), 4.0, None);
1287        let child = Arc::new(child.build());
1288        assert_eq!(child.backdrop_reads(), 1, "unshared reads count too");
1289
1290        let mut parent = DisplayListBuilder::new();
1291        glass(&mut parent, Rect::new(0.0, 0.0, 50.0, 50.0), 8.0, Some(7));
1292        parent.draw_display_list(&child);
1293        let parent = parent.build();
1294        assert_eq!(parent.backdrop_reads(), 2, "own layer + the nested list's");
1295
1296        let mut clean = DisplayListBuilder::new();
1297        clean.draw_rect(Rect::new(0.0, 0.0, 10.0, 10.0), &red());
1298        assert_eq!(clean.build().backdrop_reads(), 0);
1299    }
1300
1301    #[cfg(feature = "serde")]
1302    #[test]
1303    fn serde_dump_is_readable_json() {
1304        // Dump-only by design (plan: diffs + bug reports, never persistence —
1305        // an Image can't be deserialized without a device).
1306        let mut b = DisplayListBuilder::new();
1307        b.translate(1.0, 2.0);
1308        b.draw_rect(Rect::new(0.0, 0.0, 10.0, 10.0), &red());
1309        let dl = b.build();
1310        let json: serde_json::Value = serde_json::to_value(&dl).unwrap();
1311        assert_eq!(json["ops"].as_array().unwrap().len(), dl.ops().len());
1312        assert!(json["ops"][1]["DrawRect"]["slot"].is_number());
1313    }
1314}