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