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, ImageFilter, 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` filters the sampled scene before a save layer's children paint.
36///
37/// Compose image filters to control the order of backdrop effects. The layer's
38/// paint separately controls how the filtered backdrop and children composite.
39#[derive(Clone, Debug)]
40pub struct Backdrop {
41    /// `filter` transforms the sampled scene in local coordinates.
42    pub filter: ImageFilter,
43    /// `shared_key` lets tiles reuse the first matching filtered snapshot.
44    /// Use one key only for tiles over the same background with the same filter.
45    pub shared_key: Option<u64>,
46}
47
48impl Backdrop {
49    /// `new` filters the backdrop with `filter` before foreground content paints.
50    pub fn new(filter: ImageFilter) -> Self {
51        Self {
52            filter,
53            shared_key: None,
54        }
55    }
56
57    /// `blur` creates an isotropic Gaussian backdrop blur in local units.
58    pub fn blur(sigma: f32) -> Self {
59        Self::new(ImageFilter::blur(sigma, sigma))
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    /// The sampled-scene filter and optional shared group, recorded at save time.
93    backdrop: Option<Backdrop>,
94    /// A caller-supplied bounds hint is a CROP; eliding a hinted layer
95    /// would un-crop it. Conservative — Flutter tracks whether the bounds
96    /// actually clipped (`kMayClipContents`); valo vetoes on any hint until
97    /// a real caller needs the finer rule.
98    hinted: bool,
99}
100
101impl Default for DisplayListBuilder {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107impl DisplayListBuilder {
108    /// `new` creates an empty display-list builder.
109    pub fn new() -> Self {
110        Self {
111            ops: Vec::new(),
112            scopes: vec![Scope {
113                transform: Matrix::IDENTITY,
114                clip: None,
115                is_layer: false,
116            }],
117            layers: Vec::new(),
118            backdrop_groups: Vec::new(),
119            pending_clips: vec![Vec::new()],
120            slots: 0,
121            bounds: None,
122            draw_count: 0,
123            backdrop_reads: 0,
124        }
125    }
126
127    // ── transform stack (canvas semantics) ─────────────────────────────────
128
129    /// `save` preserves the current transform and clip until the matching `restore`.
130    pub fn save(&mut self) {
131        self.scopes.push(Scope {
132            is_layer: false,
133            ..*self.top()
134        });
135        self.pending_clips.push(Vec::new());
136        self.ops.push(Op::Save);
137    }
138
139    /// `save_count` returns the current canvas save-stack depth.
140    ///
141    /// A new builder starts at one. Each `save` or save-layer operation
142    /// increments the count, and each matched `restore` decrements it. Hosts
143    /// can use the value to verify that callbacks leave shared canvas state balanced.
144    pub fn save_count(&self) -> usize {
145        self.scopes.len()
146    }
147
148    /// `save_layer` begins an offscreen layer composited with `paint` at `restore`.
149    ///
150    /// `bounds_hint` is a local-space crop, not merely an allocation hint;
151    /// content outside it is discarded. Pass `None` to derive bounds from the
152    /// recorded children and active clip.
153    pub fn save_layer(&mut self, bounds_hint: Option<Rect>, paint: &Paint) {
154        self.save_layer_inner(bounds_hint, paint, None, None);
155    }
156
157    /// `save_layer_mask` begins a mask layer closed by `restore`.
158    ///
159    /// The layer's pixels become luminance or alpha coverage according to
160    /// `kind`, retaining enclosing content only where the mask has coverage.
161    /// `bounds_hint` crops the mask in local space.
162    pub fn save_layer_mask(&mut self, bounds_hint: Option<Rect>, kind: MaskKind) {
163        let paint = Paint {
164            blend_mode: crate::BlendMode::DstIn,
165            ..Paint::default()
166        };
167        self.save_layer_inner(bounds_hint, &paint, Some(kind), None);
168    }
169
170    /// `save_layer_backdrop` begins a layer that OPENS pre-filled with the
171    /// [`Backdrop`]-filtered scene beneath it (frosted glass). Children
172    /// paint over that glass, and `restore` composites glass + children as
173    /// one image with `paint` — so a group alpha fades them together
174    /// (Flutter's `saveLayer(bounds, paint, backdrop)`).
175    ///
176    /// Without `bounds_hint` the layer covers the active clip — a backdrop
177    /// reads everything beneath it, so a hint-less, clip-less list records
178    /// unbounded bounds; hint the layer when the list will be embedded.
179    pub fn save_layer_backdrop(
180        &mut self,
181        bounds_hint: Option<Rect>,
182        paint: &Paint,
183        backdrop: Backdrop,
184    ) {
185        let backdrop = (!backdrop.filter.is_nop()).then_some(backdrop);
186        self.save_layer_inner(bounds_hint, paint, None, backdrop);
187    }
188
189    fn save_layer_inner(
190        &mut self,
191        bounds_hint: Option<Rect>,
192        paint: &Paint,
193        mask_composite: Option<MaskKind>,
194        backdrop: Option<Backdrop>,
195    ) {
196        let device_hint = bounds_hint.map(|h| self.top().transform.map_rect(&h));
197        let mut scope = Scope {
198            is_layer: true,
199            ..*self.top()
200        };
201        // The hint crops children: fold it into the scope clip so child
202        // bounds (and everything derived) come pre-cropped.
203        if let Some(h) = device_hint {
204            scope.clip = Some(match scope.clip {
205                None => h,
206                Some(c) => c.intersect(&h).unwrap_or_default(),
207            });
208        }
209        // A filter that changes transparent black has output outside child
210        // ink. Its input coverage is therefore the explicit/active clip, or
211        // the renderer's eventual surface limit when no clip is known yet.
212        let floods_scope = paint.blend_mode.is_destructive()
213            || paint
214                .color_filter
215                .is_some_and(|filter| filter.modifies_transparent_black())
216            || paint
217                .image_filter
218                .as_ref()
219                .is_some_and(|filter| filter.modifies_transparent_black())
220            // A backdrop layer OPENS full of blurred parent, so it paints its
221            // whole region whether or not children add ink — and with no
222            // hint that region is everything beneath it. Deriving its bounds
223            // from children instead would leave a childless glass panel empty.
224            || backdrop.is_some();
225        let flooded_bounds = floods_scope.then(|| scope.clip.unwrap_or(Rect::EVERYTHING));
226        if backdrop.is_some() {
227            self.backdrop_reads += 1;
228        }
229        self.scopes.push(scope);
230        self.pending_clips.push(Vec::new());
231        self.layers.push(LayerScope {
232            op_index: self.ops.len(),
233            bounds: flooded_bounds,
234            child_bounds: Vec::new(),
235            compatible: true,
236            // Blurred layers spread ink past their children:
237            // pad the recorded bounds so the texture holds the falloff.
238            blur_pad: paint.device_effect_padding(&self.top().transform),
239            backdrop: backdrop.clone(),
240            hinted: device_hint.is_some(),
241        });
242        // Children keep counting on the SAME depth line (Impeller's global
243        // numbering) — the layer's pass rebases against base_slot.
244        self.ops.push(Op::SaveLayer {
245            paint: paint.clone(),
246            mask_composite,
247            scope_bounds: Rect::default(), // backpatched at restore
248            base_slot: self.slots,
249            composite_slot: 0,
250            can_elide: false,
251            backdrop_key: backdrop.as_ref().and_then(|b| b.shared_key),
252            backdrop_filter: backdrop.map(|b| b.filter),
253        });
254    }
255
256    /// `restore` closes the most recent save, layer, or mask scope.
257    ///
258    /// An unmatched restore is ignored in release builds and triggers a debug assertion.
259    pub fn restore(&mut self) {
260        if self.scopes.len() == 1 {
261            debug_assert!(false, "restore() without matching save()");
262            return;
263        }
264        let scope = self.scopes.pop().expect("checked above");
265        self.expire_scope_clips(); // uses the CURRENT (possibly layer) counter
266        if scope.is_layer {
267            self.close_layer();
268        }
269        self.ops.push(Op::Restore);
270    }
271
272    /// `translate` offsets subsequent drawing and clipping operations.
273    pub fn translate(&mut self, tx: f32, ty: f32) {
274        self.concat(&Matrix::translation(tx, ty));
275    }
276
277    /// `scale` scales subsequent drawing and clipping operations.
278    pub fn scale(&mut self, sx: f32, sy: f32) {
279        self.concat(&Matrix::scale(sx, sy));
280    }
281
282    /// `rotate` rotates subsequent drawing and clipping operations clockwise.
283    ///
284    /// Positive angles rotate clockwise in Valo's y-down coordinate system.
285    pub fn rotate(&mut self, radians: f32) {
286        self.concat(&Matrix::rotation(radians));
287    }
288
289    /// `concat` appends a transform for subsequent drawing and clipping operations.
290    pub fn concat(&mut self, local: &Matrix) {
291        let top = self.top_mut();
292        top.transform = top.transform.then(local);
293        self.ops.push(Op::Transform(*local));
294    }
295
296    // ── clips (depth slots; expiry backpatched when the scope closes) ──────
297
298    /// `clip_rect` applies a rectangular clip until the current scope ends.
299    pub fn clip_rect(&mut self, rect: impl Into<Rect>, op: ClipOp) {
300        let rect = rect.into();
301        self.clip_path(&rect_path(rect), FillRule::NonZero, op);
302    }
303
304    /// `clip_rrect` applies a rounded-rectangle clip with one corner radius.
305    pub fn clip_rrect(&mut self, rect: impl Into<Rect>, radius: f32, op: ClipOp) {
306        let rect = rect.into();
307        self.clip_rrect_radii(rect, [radius; 4], op);
308    }
309
310    /// `clip_rrect_radii` applies a rounded-rectangle clip with per-corner radii.
311    ///
312    /// `radii` is ordered clockwise as `[top-left, top-right, bottom-right, bottom-left]`.
313    pub fn clip_rrect_radii(&mut self, rect: impl Into<Rect>, radii: [f32; 4], op: ClipOp) {
314        let rect = positive_rect(rect.into());
315        let mut p = PathBuilder::new();
316        p.rrect_radii(rect, radii);
317        self.clip_path(&p.build(), FillRule::NonZero, op);
318    }
319
320    /// `clip_rrect_radii_elliptical` applies per-corner elliptical radii.
321    ///
322    /// Each clockwise corner is `[x_radius, y_radius]`, starting at the top-left.
323    pub fn clip_rrect_radii_elliptical(
324        &mut self,
325        rect: impl Into<Rect>,
326        radii: [[f32; 2]; 4],
327        op: ClipOp,
328    ) {
329        let rect = positive_rect(rect.into());
330        if let Some(circular) = circular_radii(radii) {
331            return self.clip_rrect_radii(rect, circular, op);
332        }
333        let mut p = PathBuilder::new();
334        p.rrect_radii_elliptical(rect, radii);
335        self.clip_path(&p.build(), FillRule::NonZero, op);
336    }
337
338    /// `clip_path` applies a path clip until the current scope ends.
339    ///
340    /// Clips do NOT forfeit an enclosing layer's elision (Flutter's
341    /// opacity distribution ignores clips too): a depth clip records its
342    /// own expiry slot and works identically whether the group's children
343    /// draw in a layer or in the parent, and child bounds are already
344    /// clip-cropped when the disjointness check reads them. The Cupertino
345    /// dialog depends on this — fade → clip → backdrop must keep the fade
346    /// elidable or the glass snapshots a cleared offscreen.
347    pub fn clip_path(&mut self, path: &Arc<Path>, fill_rule: FillRule, op: ClipOp) {
348        let bounds = self.top().transform.map_rect(&path.bounds());
349        self.shrink_clip(op, bounds);
350        self.pending_clips
351            .last_mut()
352            .expect("root scope")
353            .push(self.ops.len());
354        self.ops.push(Op::ClipPath {
355            path: Arc::clone(path),
356            fill_rule,
357            op,
358            expiry_slot: 0, // backpatched by expire_scope_clips
359        });
360    }
361
362    // ── draws (one slot each; bounds pre-clipped for the culling oracle) ───
363
364    /// `draw_rect` records a filled or stroked rectangle.
365    pub fn draw_rect(&mut self, rect: impl Into<Rect>, paint: &Paint) {
366        let rect = rect.into();
367        if paint.is_nop() {
368            return;
369        }
370        if matches!(paint.style, crate::PaintStyle::Stroke(_)) {
371            // Stroked rects are stroked paths — one geometry pipeline.
372            // Zero-area rects still stroke: Skia draws them as a line.
373            return self.draw_path(&rect_path(rect), FillRule::NonZero, paint);
374        }
375        if rect.is_empty() {
376            return;
377        }
378        if is_analytic_blur(paint) {
379            self.record_rrect_blur(rect, [0.0; 4], paint);
380            return;
381        }
382        let Some(bounds) = self.clipped_device_bounds(&paint.effect_bounds(rect)) else {
383            return; // fully clipped at record time
384        };
385        let slot = self.take_draw_slot(bounds, supports_opacity(paint));
386        self.ops.push(Op::DrawRect {
387            rect,
388            paint: paint.clone(),
389            bounds,
390            slot,
391        });
392    }
393
394    /// `draw_path` records a filled or stroked path.
395    pub fn draw_path(&mut self, path: &Arc<Path>, fill_rule: FillRule, paint: &Paint) {
396        if path.is_empty() || paint.is_nop() {
397            return;
398        }
399        let scale = self.top().transform.max_scale();
400        let local = paint.effect_bounds(path.bounds().expand(paint.stroke_padding_at_scale(scale)));
401        let Some(bounds) = self.clipped_device_bounds(&local) else {
402            return;
403        };
404        let slot = self.take_draw_slot(bounds, supports_opacity(paint));
405        self.ops.push(Op::DrawPath {
406            path: Arc::clone(path),
407            fill_rule,
408            paint: paint.clone(),
409            bounds,
410            slot,
411        });
412    }
413
414    /// `draw_circle` records a filled or stroked circle.
415    pub fn draw_circle(
416        &mut self,
417        center: impl Into<valo_geometry::Point>,
418        radius: f32,
419        paint: &Paint,
420    ) {
421        let mut p = PathBuilder::new();
422        p.circle(center, radius);
423        self.draw_path(&p.build(), FillRule::NonZero, paint);
424    }
425
426    /// `draw_rrect` records a rounded rectangle with one corner radius.
427    pub fn draw_rrect(&mut self, rect: impl Into<Rect>, radius: f32, paint: &Paint) {
428        let rect = rect.into();
429        self.draw_rrect_radii(rect, [radius; 4], paint);
430    }
431
432    /// `draw_rrect_radii` records a rounded rectangle with per-corner radii.
433    ///
434    /// `radii` is ordered clockwise as `[top-left, top-right, bottom-right, bottom-left]`.
435    pub fn draw_rrect_radii(&mut self, rect: impl Into<Rect>, radii: [f32; 4], paint: &Paint) {
436        let rect = positive_rect(rect.into());
437        if rect.is_empty() || paint.is_nop() {
438            return;
439        }
440        if is_analytic_blur(paint) {
441            self.record_rrect_blur(rect, radii, paint);
442            return;
443        }
444        let mut p = PathBuilder::new();
445        p.rrect_radii(rect, radii);
446        self.draw_path(&p.build(), FillRule::NonZero, paint);
447    }
448
449    /// `draw_rrect_radii_elliptical` records per-corner elliptical radii.
450    ///
451    /// Each clockwise corner is `[x_radius, y_radius]`, starting at the top-left.
452    pub fn draw_rrect_radii_elliptical(
453        &mut self,
454        rect: impl Into<Rect>,
455        radii: [[f32; 2]; 4],
456        paint: &Paint,
457    ) {
458        let rect = positive_rect(rect.into());
459        if let Some(circular) = circular_radii(radii) {
460            return self.draw_rrect_radii(rect, circular, paint);
461        }
462        if rect.is_empty() || paint.is_nop() {
463            return;
464        }
465        let mut p = PathBuilder::new();
466        p.rrect_radii_elliptical(rect, radii);
467        self.draw_path(&p.build(), FillRule::NonZero, paint);
468    }
469
470    /// `draw_image` records the whole image into `dst`.
471    ///
472    /// It uses linear filtering and clamps at the image edges.
473    pub fn draw_image(&mut self, image: &Image, dst: Rect, paint: &Paint) {
474        let src = Rect::new(0.0, 0.0, image.width(), image.height());
475        self.draw_image_rect(image, src, dst, Sampling::default(), paint);
476    }
477
478    /// `draw_image_rect` records a source region into `dst` with explicit sampling.
479    ///
480    /// `src` is measured in source pixels. Tiling applies when `src` extends
481    /// beyond the image bounds.
482    pub fn draw_image_rect(
483        &mut self,
484        image: &Image,
485        src: Rect,
486        dst: Rect,
487        sampling: Sampling,
488        paint: &Paint,
489    ) {
490        if dst.is_empty() || src.is_empty() || paint.is_nop() {
491            return;
492        }
493        let Some(bounds) = self.clipped_device_bounds(&paint.effect_bounds(dst)) else {
494            return;
495        };
496        let slot = self.take_draw_slot(bounds, supports_opacity(paint));
497        self.ops.push(Op::DrawImage {
498            image: image.clone(),
499            src,
500            dst,
501            sampling,
502            paint: paint.clone(),
503            bounds,
504            slot,
505        });
506    }
507
508    /// `draw_glyph_run` records positioned glyphs from one font and size.
509    ///
510    /// `local_bounds` must enclose the glyph ink in local coordinates. Valo
511    /// retains the supplied font and glyph positions in the display list.
512    pub fn draw_glyph_run(
513        &mut self,
514        font: std::sync::Arc<valo_text::Font>,
515        size: f32,
516        paint: &Paint,
517        glyphs: Arc<Vec<crate::GlyphPos>>,
518        local_bounds: Rect,
519    ) {
520        if glyphs.is_empty() || paint.is_nop() {
521            return;
522        }
523        let scale = self.top().transform.max_scale();
524        let padded = paint.effect_bounds(local_bounds.expand(paint.stroke_padding_at_scale(scale)));
525        let Some(bounds) = self.clipped_device_bounds(&padded) else {
526            return;
527        };
528        // Shader text desugars into a two-draw layer at plan time; group
529        // opacity can't ride its children (it would apply twice).
530        let distributes = supports_opacity(paint) && paint.shader.is_none();
531        let slot = self.take_draw_slot(bounds, distributes);
532        self.ops.push(Op::GlyphRun {
533            font,
534            size,
535            paint: paint.clone(),
536            glyphs,
537            bounds,
538            slot,
539        });
540    }
541
542    /// `draw_display_list` records a nested display list by shared reference.
543    pub fn draw_display_list(&mut self, list: &Arc<DisplayList>) {
544        self.embed_display_list(list, false);
545    }
546
547    /// `draw_display_list_cached` records a nested list as a raster-cache candidate.
548    ///
549    /// Use it for stable, repeatedly drawn lists whose recording is expensive.
550    /// The renderer may still replay the list directly when caching is unsuitable.
551    pub fn draw_display_list_cached(&mut self, list: &Arc<DisplayList>) {
552        self.embed_display_list(list, true);
553    }
554
555    fn embed_display_list(&mut self, list: &Arc<DisplayList>, cache: bool) {
556        let Some(child_bounds) = list.bounds() else {
557            return; // draws nothing
558        };
559        let Some(bounds) = self.clipped_device_bounds(&child_bounds) else {
560            return;
561        };
562        let base_slot = self.slots;
563        self.slots += list.depth_slots();
564        self.draw_count += list.draw_count();
565        self.backdrop_reads += list.backdrop_reads();
566        self.union_bounds(bounds);
567        // Conservative: a nested list's internal structure is opaque here.
568        self.note_layer_child(bounds, false);
569        self.ops.push(Op::DrawDisplayList {
570            list: Arc::clone(list),
571            bounds,
572            base_slot,
573            cache,
574        });
575    }
576
577    // ── build ──────────────────────────────────────────────────────────────
578
579    /// `build` consumes the builder and returns its immutable display list.
580    ///
581    /// Any unmatched save scopes are closed before the list is finalized.
582    pub fn build(mut self) -> DisplayList {
583        // Unbalanced saves are a recording bug, but a recoverable one: close
584        // them so replay's stack discipline holds.
585        while self.scopes.len() > 1 {
586            self.restore();
587        }
588        self.expire_scope_clips(); // root-scope clips live to end-of-list
589        DisplayList::new(
590            self.ops,
591            self.bounds,
592            self.draw_count,
593            self.slots,
594            self.backdrop_groups,
595            self.backdrop_reads,
596        )
597    }
598
599    // ── internals ──────────────────────────────────────────────────────────
600
601    fn top(&self) -> &Scope {
602        self.scopes.last().expect("scope stack never empty")
603    }
604
605    fn top_mut(&mut self) -> &mut Scope {
606        self.scopes.last_mut().expect("scope stack never empty")
607    }
608
609    /// Backpatch the layer's oracle at its restore. Order matters: the
610    /// layer's clips expired first (caller did that), so their slots sit
611    /// inside the children's span; the composite takes the NEXT slot on the
612    /// same line.
613    fn close_layer(&mut self) {
614        let layer = self.layers.pop().expect("is_layer scope had a LayerScope");
615        self.slots += 1; // the composite's slot, next after the children's span
616        let mut scope_bounds = layer.bounds.unwrap_or_default();
617        if layer.blur_pad > 0.0 && !scope_bounds.is_empty() {
618            scope_bounds = scope_bounds.expand(layer.blur_pad);
619        }
620
621        let Op::SaveLayer {
622            paint,
623            mask_composite: _,
624            scope_bounds: sb,
625            base_slot: _,
626            composite_slot,
627            can_elide,
628            ..
629        } = &mut self.ops[layer.op_index]
630        else {
631            unreachable!("LayerScope.op_index always points at SaveLayer");
632        };
633        *sb = scope_bounds;
634        *composite_slot = self.slots;
635        // A backdrop layer never elides (its seed needs a texture); a hinted
636        // layer never elides (the hint is a crop that eliding would undo).
637        *can_elide = layer.compatible
638            && paint.is_opacity_only()
639            && layer.backdrop.is_none()
640            && !layer.hinted;
641
642        // One SrcOver composite quad — an ENCLOSING opacity group can still
643        // distribute its alpha onto it. This is what lets a fading group
644        // elide over a backdrop layer: the alpha lands once, on the glass
645        // and its children together.
646        let supports = paint.blend_mode == crate::BlendMode::SrcOver;
647        if let Some(Backdrop {
648            filter,
649            shared_key: Some(key),
650        }) = layer.backdrop
651        {
652            self.note_backdrop_group(key, scope_bounds, filter);
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, filter: ImageFilter) {
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.filter.as_ref() != Some(&filter) {
679                    group.filter = None; // Different filters cannot share one snapshot.
680                }
681            }
682            None => self.backdrop_groups.push(crate::BackdropGroup {
683                key,
684                union_bounds: bounds,
685                filter: Some(filter),
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                filter: ImageFilter::blur(sigma, 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!(
1230            group.filter,
1231            Some(ImageFilter::blur(8.0, 8.0)),
1232            "one σ across the key: shareable"
1233        );
1234        assert_eq!(dl.draw_count(), 3, "each layer's composite is a draw");
1235        assert_eq!(dl.depth_slots(), 3);
1236    }
1237
1238    #[test]
1239    fn mixed_sigma_under_one_key_clears_the_shared_sigma() {
1240        let mut b = DisplayListBuilder::new();
1241        glass(&mut b, Rect::new(0.0, 0.0, 50.0, 50.0), 4.0, Some(7));
1242        glass(&mut b, Rect::new(100.0, 0.0, 50.0, 50.0), 12.0, Some(7));
1243        let dl = b.build();
1244        let group = dl.backdrop_group(7).expect("key 7 recorded");
1245        assert_eq!(group.filter, None, "disagreeing σ cannot share one blur");
1246    }
1247
1248    #[test]
1249    fn opacity_group_elides_over_a_backdrop_layer() {
1250        let mut b = DisplayListBuilder::new();
1251        b.save_layer(None, &alpha_layer(0.5));
1252        glass(&mut b, Rect::new(0.0, 0.0, 50.0, 50.0), 4.0, None);
1253        b.restore();
1254        let layers = layer_facts(&b.build());
1255        assert_eq!(layers.len(), 2, "the opacity group and the glass inside it");
1256        assert!(
1257            layers[0].3,
1258            "the group's alpha lands on the glass composite — the whole point \
1259             of backdrop-as-a-layer-property: glass keeps blurring while the \
1260             group fades"
1261        );
1262        assert!(!layers[1].3, "the glass itself needs a texture to seed");
1263    }
1264
1265    /// The Cupertino dialog's exact recording shape: fade -> superellipse
1266    /// clip -> glass. The clip must NOT forfeit the fade's elision - a depth
1267    /// clip works identically whether the group's children draw in a layer
1268    /// or the parent, and eliding is what lets the glass snapshot the live
1269    /// scene instead of the fade's cleared offscreen.
1270    #[test]
1271    fn a_clip_does_not_forfeit_elision_around_glass() {
1272        let mut b = DisplayListBuilder::new();
1273        b.save_layer(None, &alpha_layer(0.5));
1274        b.save();
1275        let mut clip = PathBuilder::new();
1276        clip.rect(Rect::new(0.0, 0.0, 60.0, 60.0));
1277        b.clip_path(&clip.build(), FillRule::NonZero, ClipOp::Intersect);
1278        glass(&mut b, Rect::new(0.0, 0.0, 50.0, 50.0), 4.0, None);
1279        b.restore();
1280        b.restore();
1281        let layers = layer_facts(&b.build());
1282        assert_eq!(layers.len(), 2);
1283        assert!(layers[0].3, "the clipped fade still elides");
1284        assert!(!layers[1].3);
1285    }
1286
1287    #[test]
1288    fn backdrop_reads_count_unshared_and_nested() {
1289        let mut child = DisplayListBuilder::new();
1290        glass(&mut child, Rect::new(0.0, 0.0, 50.0, 50.0), 4.0, None);
1291        let child = Arc::new(child.build());
1292        assert_eq!(child.backdrop_reads(), 1, "unshared reads count too");
1293
1294        let mut parent = DisplayListBuilder::new();
1295        glass(&mut parent, Rect::new(0.0, 0.0, 50.0, 50.0), 8.0, Some(7));
1296        parent.draw_display_list(&child);
1297        let parent = parent.build();
1298        assert_eq!(parent.backdrop_reads(), 2, "own layer + the nested list's");
1299
1300        let mut clean = DisplayListBuilder::new();
1301        clean.draw_rect(Rect::new(0.0, 0.0, 10.0, 10.0), &red());
1302        assert_eq!(clean.build().backdrop_reads(), 0);
1303    }
1304
1305    #[cfg(feature = "serde")]
1306    #[test]
1307    fn serde_dump_is_readable_json() {
1308        // Dump-only by design (plan: diffs + bug reports, never persistence —
1309        // an Image can't be deserialized without a device).
1310        let mut b = DisplayListBuilder::new();
1311        b.translate(1.0, 2.0);
1312        b.draw_rect(Rect::new(0.0, 0.0, 10.0, 10.0), &red());
1313        let dl = b.build();
1314        let json: serde_json::Value = serde_json::to_value(&dl).unwrap();
1315        assert_eq!(json["ops"].as_array().unwrap().len(), dl.ops().len());
1316        assert!(json["ops"][1]["DrawRect"]["slot"].is_number());
1317    }
1318}