Skip to main content

inset_embedder/
scene_builder.rs

1//! Flutter counterpart: dart:ui `SceneBuilder` / `Scene`.
2//!
3//! The engine's builder composites retained `EngineLayer`s; valo composites a display list, so
4//! every push opens a nested recording that `pop` closes into a list of its own: that list is
5//! the engine layer, embedded in the enclosing recording and handed back for the layer to keep,
6//! and `add_retained` embeds it again while the subtree has not changed (see the rendering
7//! crate's `PORTING.md`). A native surface the host composites is not in the list: the
8//! builder keeps the transform and the clip of its open scopes so that `add_surface` can place
9//! it in the view's coordinates, at the scale it is drawn and cut to what shows, and the scene
10//! carries those placements beside the picture.
11
12use std::fmt;
13use std::sync::Arc;
14
15use crate::composition::Surface;
16use crate::geometry::{
17    Clip, Color, Matrix4, Offset, RRect, Rect, rrect_radii_elliptical, transform3,
18};
19use crate::painting::{
20    Backdrop, BlendMode, Canvas, ClipOp, FillRule, ImageFilter, Paint, Path, Picture, valo,
21};
22
23/// Flutter `Scene` — what [`SceneBuilder::build`] produces and [`crate::View::present`] takes:
24/// the framework's picture, and the native surfaces this frame places, bottom-most first.
25/// A host without a compositor uses the picture alone.
26#[derive(Debug, PartialEq)]
27pub struct Scene {
28    pub picture: Arc<Picture>,
29    pub surfaces: Vec<SurfacePlacement>,
30}
31
32/// Where one frame puts one native surface. The surface is its producer's own handle, so a
33/// host reaches the native object behind it directly and moves it.
34#[derive(Clone)]
35pub struct SurfacePlacement {
36    pub surface: Arc<dyn Surface>,
37    /// In the view's physical pixels, with the transforms above the surface applied: the
38    /// scene's own space, whose root transform scales logical pixels by the device pixel ratio.
39    pub rect: Rect,
40    /// How many of the view's physical pixels one unit of the surface's own coordinates spans:
41    /// the scale of the transforms above it, the root's device pixel ratio among them. A
42    /// producer draws its buffers at the device pixel ratio, so a host shows a buffer at
43    /// `scale / device_pixel_ratio` of its size. Under a scale that differs between the axes,
44    /// or a rotation, it is the scale along the surface's own horizontal.
45    pub scale: f64,
46    /// The part of `rect` that shows, in the same pixels: `rect` cut down to the bounds of
47    /// every clip above the surface. A host shows nothing of the surface outside it. Where a
48    /// clip is not a rectangle, what lies between it and its bounds is the picture's to cover.
49    pub clip: Rect,
50}
51
52/// Two placements are equal when they put the same surface in the same place: what a host
53/// compares to tell a frame worth presenting from one it already shows.
54impl PartialEq for SurfacePlacement {
55    fn eq(&self, other: &SurfacePlacement) -> bool {
56        Arc::ptr_eq(&self.surface, &other.surface)
57            && self.rect == other.rect
58            && self.scale == other.scale
59            && self.clip == other.clip
60    }
61}
62
63impl fmt::Debug for SurfacePlacement {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.debug_struct("SurfacePlacement")
66            .field("surface", &Arc::as_ptr(&self.surface).cast::<()>())
67            .field("rect", &self.rect)
68            .field("scale", &self.scale)
69            .field("clip", &self.clip)
70            .finish()
71    }
72}
73
74/// Flutter `EngineLayer` — the display list a layer's subtree recorded when it was last added to
75/// a scene, which [`SceneBuilder::add_retained`] embeds again while nothing in it changed. The
76/// engine keeps a node of its layer tree; valo keeps a nested list.
77pub type EngineLayer = Arc<Picture>;
78
79/// Builds a [`Scene`] containing the given visuals.
80///
81/// A [`Scene`] can then be rendered using [`crate::View::present`].
82///
83/// To draw graphical operations onto a [`Scene`], first create a [`Canvas`], record into it, and
84/// add the resulting [`Picture`] with [`add_picture`](Self::add_picture). Each `push_*` opens a
85/// compositing scope that [`pop`](Self::pop) closes into an [`EngineLayer`]; unbalanced scopes
86/// are a bug the builder asserts on.
87pub struct SceneBuilder {
88    /// The recording of the innermost open scope, or of the scene itself outside any.
89    canvas: Canvas,
90    /// What the open scope's coordinates are in the view's: the transforms pushed so far.
91    transform: Matrix4,
92    /// The bounds of the clips pushed so far, in the view's coordinates; `None` while nothing
93    /// clips.
94    clip: Option<Rect>,
95    /// The scopes enclosing the open one, outermost first; every `push_*` adds one and every
96    /// `pop` takes one back. dart:ui asserts the same balance.
97    enclosing: Vec<EnclosingScope>,
98    /// The native surfaces placed so far, in paint order.
99    surfaces: Vec<SurfacePlacement>,
100}
101
102/// A scope as it was when a `push_*` opened the next one inside it.
103struct EnclosingScope {
104    canvas: Canvas,
105    transform: Matrix4,
106    clip: Option<Rect>,
107}
108
109impl fmt::Debug for SceneBuilder {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        f.debug_struct("SceneBuilder")
112            .field("open_scopes", &self.enclosing.len())
113            .finish_non_exhaustive()
114    }
115}
116
117impl Default for SceneBuilder {
118    fn default() -> SceneBuilder {
119        SceneBuilder::new()
120    }
121}
122
123impl SceneBuilder {
124    /// Creates an empty [`SceneBuilder`] object.
125    pub fn new() -> SceneBuilder {
126        SceneBuilder {
127            canvas: Canvas::new(),
128            transform: Matrix4::IDENTITY,
129            clip: None,
130            enclosing: Vec::new(),
131            surfaces: Vec::new(),
132        }
133    }
134
135    /// Pushes a transform operation onto the operation stack.
136    ///
137    /// The objects are transformed by the given matrix before rasterization.
138    pub fn push_transform(&mut self, matrix4: &Matrix4) {
139        self.open(|canvas| {
140            canvas.save();
141            canvas.concat(matrix4);
142        });
143        self.transformed_by(matrix4);
144    }
145
146    /// Pushes an offset operation onto the operation stack.
147    ///
148    /// This is equivalent to [`push_transform`](Self::push_transform) with a matrix with only
149    /// translation.
150    pub fn push_offset(&mut self, dx: f64, dy: f64) {
151        self.open(|canvas| {
152            canvas.save();
153            canvas.translate(dx as f32, dy as f32);
154        });
155        self.transformed_by(&Matrix4::translation(dx as f32, dy as f32));
156    }
157
158    /// Pushes a rectangular clip operation onto the operation stack.
159    ///
160    /// Rasterization outside the given rectangle is discarded.
161    pub fn push_clip_rect(&mut self, rect: Rect, clip_behavior: Clip) {
162        self.open(|canvas| {
163            open_clip(canvas, clip_behavior, rect.into());
164            canvas.clip_rect(rect, ClipOp::Intersect);
165        });
166        self.clipped_to(rect);
167    }
168
169    /// Pushes a rounded-rectangular clip operation onto the operation stack.
170    ///
171    /// Rasterization outside the given rounded rectangle is discarded.
172    pub fn push_clip_rrect(&mut self, rrect: RRect, clip_behavior: Clip) {
173        self.open(|canvas| {
174            open_clip(canvas, clip_behavior, rrect.outer_rect().into());
175            canvas.clip_rrect_radii_elliptical(
176                rrect.outer_rect(),
177                rrect_radii_elliptical(rrect),
178                ClipOp::Intersect,
179            );
180        });
181        self.clipped_to(rrect.outer_rect());
182    }
183
184    /// Pushes a path clip operation onto the operation stack.
185    ///
186    /// Rasterization outside the given path is discarded.
187    pub fn push_clip_path(&mut self, path: &Arc<Path>, clip_behavior: Clip) {
188        self.open(|canvas| {
189            open_clip(canvas, clip_behavior, path.bounds());
190            canvas.clip_path(path, FillRule::NonZero, ClipOp::Intersect);
191        });
192        self.clipped_to(bounds_of(path));
193    }
194
195    /// Pushes an opacity operation onto the operation stack.
196    ///
197    /// The given alpha value is blended into the alpha value of the objects' rasterization. An
198    /// alpha value of 0 makes the objects entirely invisible. An alpha value of 255 has no
199    /// effect (i.e., the objects retain the current opacity).
200    pub fn push_opacity(&mut self, alpha: i32, offset: Offset) {
201        self.open(|canvas| {
202            let paint = Paint {
203                color: Color::from_argb(alpha, 0, 0, 0).into(),
204                ..Paint::default()
205            };
206            canvas.save_layer(None, &paint);
207            canvas.translate(offset.dx() as f32, offset.dy() as f32);
208        });
209        self.transformed_by(&Matrix4::translation(
210            offset.dx() as f32,
211            offset.dy() as f32,
212        ));
213    }
214
215    /// Pushes a backdrop filter operation onto the operation stack.
216    ///
217    /// The given filter is applied to the current contents of the scene as far back as the most
218    /// recent save layer and rendered back to the scene using the indicated `blend_mode` prior to
219    /// rasterizing the child layers.
220    ///
221    /// Filters sharing a `backdrop_id` sample one backdrop, as Flutter's `BackdropKey` groups
222    /// them.
223    pub fn push_backdrop_filter(
224        &mut self,
225        filter: &ImageFilter,
226        blend_mode: BlendMode,
227        backdrop_id: Option<u64>,
228    ) {
229        let backdrop = valo_backdrop(filter, backdrop_id);
230        let paint = Paint {
231            blend_mode,
232            ..Paint::default()
233        };
234        self.open(|canvas| canvas.save_layer_backdrop(None, &paint, backdrop));
235    }
236
237    /// Ends the effect of the most recently pushed operation.
238    ///
239    /// The scope's recording becomes the [`EngineLayer`] returned, embedded in the enclosing
240    /// recording: the layer that pushed keeps it and hands it to
241    /// [`add_retained`](Self::add_retained) while its subtree stays unchanged. dart:ui returns
242    /// the engine layer from the push; a display list is only closed at its restore.
243    ///
244    /// # Panics
245    ///
246    /// If nothing is pushed.
247    pub fn pop(&mut self) -> EngineLayer {
248        let enclosing = self
249            .enclosing
250            .pop()
251            .expect("SceneBuilder::pop without a push");
252        self.canvas.restore();
253        let scope = std::mem::replace(&mut self.canvas, enclosing.canvas);
254        self.transform = enclosing.transform;
255        self.clip = enclosing.clip;
256        let layer = Arc::new(scope.build());
257        self.canvas.draw_display_list(&layer);
258        layer
259    }
260
261    /// Places a native surface the host composites, at `rect` in the open scope's
262    /// coordinates: Flutter's `addPlatformView`. Nothing is recorded in the picture; the
263    /// caller paints the rectangle clear itself, at the place in paint order it wants the
264    /// surface to show through.
265    pub fn add_surface(&mut self, surface: Arc<dyn Surface>, rect: Rect) {
266        let rect = self.in_view_coordinates(rect);
267        let clip = match self.clip {
268            Some(clip) => visible_part_of(rect, clip),
269            None => rect,
270        };
271        self.surfaces.push(SurfacePlacement {
272            surface,
273            rect,
274            scale: horizontal_scale_of(self.transform),
275            clip,
276        });
277    }
278
279    /// Adds a retained [`EngineLayer`] subtree from a previous frame.
280    ///
281    /// All the engine layers that are already in the subtree are kept as-is: the subtree is
282    /// embedded exactly as it was recorded, under the scopes open now.
283    pub fn add_retained(&mut self, retained_layer: &EngineLayer) {
284        self.canvas.draw_display_list(retained_layer);
285    }
286
287    /// Adds a [`Picture`] to the scene.
288    ///
289    /// The picture is rasterized at the given `offset`. The hints are for a raster cache valo
290    /// does not have; they are accepted so a layer can pass what Flutter passes.
291    pub fn add_picture(
292        &mut self,
293        offset: Offset,
294        picture: &Arc<Picture>,
295        is_complex_hint: bool,
296        will_change_hint: bool,
297    ) {
298        let _ = (is_complex_hint, will_change_hint);
299        self.canvas.save();
300        self.canvas
301            .translate(offset.dx() as f32, offset.dy() as f32);
302        self.canvas.draw_display_list(picture);
303        self.canvas.restore();
304    }
305
306    /// Finishes building the scene.
307    ///
308    /// # Panics
309    ///
310    /// In debug builds, if a pushed operation was not popped.
311    pub fn build(self) -> Scene {
312        debug_assert!(
313            self.enclosing.is_empty(),
314            "SceneBuilder::build with an open push"
315        );
316        Scene {
317            picture: Arc::new(self.canvas.build()),
318            surfaces: self.surfaces,
319        }
320    }
321
322    /// Opens a scope: a recording of its own, so that `pop` can hand the scope back as one
323    /// list, with `push` recording the scope's own effect at its start.
324    fn open(&mut self, push: impl FnOnce(&mut Canvas)) {
325        let enclosing = std::mem::replace(&mut self.canvas, Canvas::new());
326        self.enclosing.push(EnclosingScope {
327            canvas: enclosing,
328            transform: self.transform,
329            clip: self.clip,
330        });
331        push(&mut self.canvas);
332    }
333
334    /// The open scope's coordinates are now `matrix` inside what they were: a point is
335    /// taken through `matrix` first, then through the transforms pushed before it.
336    fn transformed_by(&mut self, matrix: &Matrix4) {
337        self.transform = self.transform.then(matrix);
338    }
339
340    /// The open scope shows nothing outside `bounds`, in its own coordinates, besides what the
341    /// scopes around it already cut.
342    fn clipped_to(&mut self, bounds: Rect) {
343        let bounds = self.in_view_coordinates(bounds);
344        self.clip = Some(match self.clip {
345            Some(clip) => visible_part_of(bounds, clip),
346            None => bounds,
347        });
348    }
349
350    /// `rect` in the view's coordinates: the box around its corners under the open scope's
351    /// transform.
352    fn in_view_coordinates(&self, rect: Rect) -> Rect {
353        let corners = [
354            Offset::new(rect.left, rect.top),
355            Offset::new(rect.right, rect.top),
356            Offset::new(rect.left, rect.bottom),
357            Offset::new(rect.right, rect.bottom),
358        ]
359        .map(|corner| transform3(self.transform, corner));
360        let xs = corners.iter().map(|corner| corner.dx());
361        let ys = corners.iter().map(|corner| corner.dy());
362        Rect::from_ltrb(
363            xs.clone().fold(f64::INFINITY, f64::min),
364            ys.clone().fold(f64::INFINITY, f64::min),
365            xs.fold(f64::NEG_INFINITY, f64::max),
366            ys.fold(f64::NEG_INFINITY, f64::max),
367        )
368    }
369}
370
371/// The save that starts a clip scope. `Clip::AntiAliasWithSaveLayer` opens a layer over the
372/// clip's bounds, as the engine's clip layers do.
373fn open_clip(canvas: &mut Canvas, clip_behavior: Clip, bounds: valo::Rect) {
374    match clip_behavior {
375        Clip::None | Clip::HardEdge | Clip::AntiAlias => canvas.save(),
376        Clip::AntiAliasWithSaveLayer => canvas.save_layer(Some(bounds), &Paint::default()),
377    }
378}
379
380/// The rectangle `path` lies in.
381fn bounds_of(path: &Path) -> Rect {
382    let bounds = path.bounds();
383    Rect::from_ltwh(
384        f64::from(bounds.x),
385        f64::from(bounds.y),
386        f64::from(bounds.width),
387        f64::from(bounds.height),
388    )
389}
390
391/// What of `rect` lies inside `clip`; an empty rectangle on `clip`'s nearest edge when
392/// nothing does.
393fn visible_part_of(rect: Rect, clip: Rect) -> Rect {
394    let overlap = rect.intersect(clip);
395    Rect::from_ltrb(
396        overlap.left,
397        overlap.top,
398        overlap.right.max(overlap.left),
399        overlap.bottom.max(overlap.top),
400    )
401}
402
403/// How long one unit along the horizontal becomes under `transform`.
404fn horizontal_scale_of(transform: Matrix4) -> f64 {
405    let m = transform.to_flutter_array();
406    f64::from(m[0]).hypot(f64::from(m[1]))
407}
408
409/// The valo [`Backdrop`] that samples the scene through `filter`, shared by every backdrop with
410/// the same `backdrop_id`.
411fn valo_backdrop(filter: &ImageFilter, backdrop_id: Option<u64>) -> Backdrop {
412    let backdrop = Backdrop::new(filter.to_valo());
413    match backdrop_id {
414        Some(id) => backdrop.shared(id),
415        None => backdrop,
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::painting::{ColorFilter, flattened_ops};
423
424    #[test]
425    fn pushes_and_pops_balance() {
426        let mut builder = SceneBuilder::new();
427        builder.push_offset(1.0, 2.0);
428        builder.push_opacity(128, Offset::ZERO);
429        builder.pop();
430        builder.pop();
431        let _scene = builder.build();
432    }
433
434    #[test]
435    fn a_popped_scope_is_the_engine_layer_embedded_in_its_parent() {
436        let mut canvas = Canvas::new();
437        canvas.draw_rect(Rect::from_ltwh(0.0, 0.0, 10.0, 10.0), &Paint::default());
438        let picture = Arc::new(canvas.build());
439        let mut builder = SceneBuilder::new();
440        builder.push_offset(1.0, 2.0);
441        builder.add_picture(Offset::ZERO, &picture, false, false);
442        let layer = builder.pop();
443        builder.add_retained(&layer);
444        let scene = builder.build();
445        let embedded = scene
446            .picture
447            .ops()
448            .iter()
449            .filter(|op| matches!(op, valo::Op::DrawDisplayList { list, .. } if Arc::ptr_eq(list, &layer)))
450            .count();
451        assert_eq!(
452            embedded,
453            2,
454            "once from the pop, once retained: {:?}",
455            scene.picture.ops()
456        );
457    }
458
459    /// A surface no producer feeds. The crates above the embedder use `test_support`'s,
460    /// which this crate's own tests are built without.
461    struct IdleSurface;
462
463    impl Surface for IdleSurface {
464        fn present(&self, _buffer: crate::Buffer, _released: Box<dyn FnOnce() + Send + Sync>) {}
465    }
466
467    fn idle_surface() -> Arc<dyn Surface> {
468        Arc::new(IdleSurface)
469    }
470
471    #[test]
472    fn a_surface_is_placed_under_the_open_transforms() {
473        let (inner, outer) = (idle_surface(), idle_surface());
474        let mut builder = SceneBuilder::new();
475        builder.push_transform(&Matrix4::scale(2.0, 2.0));
476        builder.push_offset(1.0, 1.0);
477        builder.add_surface(Arc::clone(&inner), Rect::from_ltwh(0.0, 0.0, 1.0, 1.0));
478        builder.pop();
479        builder.pop();
480        // Outside every scope: the view's own coordinates.
481        builder.add_surface(Arc::clone(&outer), Rect::from_ltwh(10.0, 20.0, 5.0, 5.0));
482        let scene = builder.build();
483        assert_eq!(
484            scene.surfaces,
485            [
486                unclipped(inner, Rect::from_ltrb(2.0, 2.0, 4.0, 4.0), 2.0),
487                unclipped(outer, Rect::from_ltwh(10.0, 20.0, 5.0, 5.0), 1.0),
488            ]
489        );
490    }
491
492    /// A page at half its size on a display of two pixels a point: each unit of the page's
493    /// own spans one of the view's pixels, so a buffer drawn at two pixels a point shows at
494    /// half its size.
495    #[test]
496    fn a_surface_carries_the_scale_of_the_transforms_above_it() {
497        let surface = idle_surface();
498        let mut builder = SceneBuilder::new();
499        builder.push_transform(&Matrix4::scale(2.0, 2.0));
500        builder.push_offset(10.0, 20.0);
501        builder.push_transform(&Matrix4::scale(0.5, 0.5));
502        builder.add_surface(
503            Arc::clone(&surface),
504            Rect::from_ltwh(0.0, 0.0, 390.0, 844.0),
505        );
506        builder.pop();
507        builder.pop();
508        builder.pop();
509        let placement = &builder.build().surfaces[0];
510        assert_eq!(placement.scale, 1.0);
511        assert_eq!(placement.rect, Rect::from_ltwh(20.0, 40.0, 390.0, 844.0));
512    }
513
514    #[test]
515    fn a_surface_shows_only_what_the_clips_above_it_leave() {
516        let (clipped, beside) = (idle_surface(), idle_surface());
517        let mut builder = SceneBuilder::new();
518        builder.push_transform(&Matrix4::scale(2.0, 2.0));
519        builder.push_clip_rect(Rect::from_ltwh(0.0, 0.0, 100.0, 50.0), Clip::HardEdge);
520        builder.push_offset(10.0, 10.0);
521        let path = {
522            let mut path = crate::painting::PathBuilder::new();
523            path.rrect(Rect::from_ltwh(0.0, 0.0, 200.0, 20.0).into(), 4.0);
524            path.build()
525        };
526        builder.push_clip_path(&path, Clip::AntiAlias);
527        builder.add_surface(
528            Arc::clone(&clipped),
529            Rect::from_ltwh(-5.0, -5.0, 300.0, 300.0),
530        );
531        builder.pop();
532        builder.pop();
533        builder.add_surface(Arc::clone(&beside), Rect::from_ltwh(0.0, 0.0, 300.0, 300.0));
534        builder.pop();
535        builder.pop();
536        let scene = builder.build();
537        // The path's bounds, (10, 10) to (210, 30), cut by the rectangle's, (0, 0) to (100, 50),
538        // at two pixels to a unit.
539        assert_eq!(
540            scene.surfaces[0].clip,
541            Rect::from_ltrb(20.0, 20.0, 200.0, 60.0)
542        );
543        assert_eq!(
544            scene.surfaces[0].rect,
545            Rect::from_ltwh(10.0, 10.0, 600.0, 600.0)
546        );
547        assert_eq!(
548            scene.surfaces[1].clip,
549            Rect::from_ltwh(0.0, 0.0, 200.0, 100.0),
550            "the rectangle's clip alone, once the path's scope is closed"
551        );
552    }
553
554    #[test]
555    fn a_surface_outside_its_clip_shows_nothing() {
556        let surface = idle_surface();
557        let mut builder = SceneBuilder::new();
558        builder.push_clip_rect(Rect::from_ltwh(0.0, 0.0, 100.0, 100.0), Clip::HardEdge);
559        builder.add_surface(surface, Rect::from_ltwh(200.0, 0.0, 50.0, 50.0));
560        builder.pop();
561        let clip = builder.build().surfaces[0].clip;
562        assert!(clip.is_empty(), "{clip:?}");
563    }
564
565    #[test]
566    fn a_placement_is_its_surface_its_rectangle_its_scale_and_what_shows() {
567        let surface = idle_surface();
568        let rect = Rect::from_ltwh(0.0, 0.0, 4.0, 4.0);
569        let placement = unclipped(Arc::clone(&surface), rect, 1.0);
570        assert_eq!(placement.clone(), placement);
571        let elsewhere = unclipped(
572            Arc::clone(&surface),
573            Rect::from_ltwh(1.0, 0.0, 4.0, 4.0),
574            1.0,
575        );
576        assert_ne!(elsewhere, placement);
577        let smaller = unclipped(Arc::clone(&surface), rect, 0.5);
578        assert_ne!(smaller, placement);
579        let cut = SurfacePlacement {
580            clip: Rect::from_ltwh(0.0, 0.0, 2.0, 4.0),
581            ..placement.clone()
582        };
583        assert_ne!(cut, placement);
584        let another_surface = unclipped(idle_surface(), rect, 1.0);
585        assert_ne!(
586            another_surface, placement,
587            "the same rectangle is not the same surface"
588        );
589    }
590
591    /// `surface` at `rect`, all of it showing, at `scale`.
592    fn unclipped(surface: Arc<dyn Surface>, rect: Rect, scale: f64) -> SurfacePlacement {
593        SurfacePlacement {
594            surface,
595            rect,
596            scale,
597            clip: rect,
598        }
599    }
600
601    #[test]
602    fn a_colour_filter_composed_over_a_blur_reaches_the_backdrop_in_its_order() {
603        let saturation = ColorFilter::saturation(1.8);
604        let filter =
605            ImageFilter::compose(ImageFilter::Color(saturation), ImageFilter::blur(4.0, 4.0));
606        let backdrop = recorded_backdrop(&filter, Some(7));
607        assert_eq!(
608            backdrop.filter,
609            Some(valo::ImageFilter::compose(
610                valo::ImageFilter::Color(saturation.to_valo()),
611                valo::ImageFilter::blur(4.0, 4.0),
612            ))
613        );
614        assert_eq!(backdrop.key, Some(7), "the backdrop is still shared");
615    }
616
617    #[test]
618    fn an_uneven_blur_reaches_the_backdrop_with_both_sigmas() {
619        let backdrop = recorded_backdrop(&ImageFilter::blur(2.0, 6.0), None);
620        assert_eq!(backdrop.filter, Some(valo::ImageFilter::blur(2.0, 6.0)));
621        assert_eq!(backdrop.key, None);
622    }
623
624    #[test]
625    fn a_bounded_blur_reaches_the_backdrop_with_its_bounds_and_leaves_the_layer_unconfined() {
626        let glass = Rect::from_ltwh(8.0, 8.0, 16.0, 16.0);
627        let bounded = recorded_backdrop(&ImageFilter::blur(2.0, 3.0).bounds(glass), None);
628        assert_eq!(
629            bounded.filter,
630            Some(valo::ImageFilter::bounded_blur(2.0, 3.0, glass.into()))
631        );
632        let unbounded = recorded_backdrop(&ImageFilter::blur(2.0, 3.0), None);
633        assert_eq!(
634            bounded.bounds, unbounded.bounds,
635            "the bounds limit what the blur reads, not the layer"
636        );
637    }
638
639    /// What a scene records of the one backdrop a push opened.
640    struct RecordedBackdrop {
641        filter: Option<valo::ImageFilter>,
642        /// What the backdrop's snapshot is shared under.
643        key: Option<u64>,
644        /// The bounds of the backdrop's layer.
645        bounds: valo::Bounds,
646    }
647
648    fn recorded_backdrop(filter: &ImageFilter, backdrop_id: Option<u64>) -> RecordedBackdrop {
649        let mut builder = SceneBuilder::new();
650        builder.push_backdrop_filter(filter, BlendMode::SrcOver, backdrop_id);
651        builder.pop();
652        flattened_ops(&builder.build().picture)
653            .into_iter()
654            .find_map(|op| match op {
655                valo::Op::SaveLayer {
656                    backdrop,
657                    scope_bounds,
658                    ..
659                } => Some(RecordedBackdrop {
660                    key: backdrop.as_ref().and_then(|backdrop| backdrop.shared_key),
661                    filter: backdrop.map(|backdrop| backdrop.filter),
662                    bounds: scope_bounds,
663                }),
664                _ => None,
665            })
666            .expect("a backdrop filter opens a layer")
667    }
668
669    /// Checks of what a scene shows once drawn. They need `test_support`'s GPU device, so they
670    /// run where the `test-support` feature is on, as it is in a test run of the workspace.
671    #[cfg(feature = "test-support")]
672    mod drawn {
673        use super::*;
674        use crate::test_support::drawn_pixels;
675
676        const SIZE: u32 = 32;
677
678        #[test]
679        fn a_colour_filter_composed_over_a_blur_recolours_the_backdrop() {
680            let swap_red_and_green = ColorFilter::matrix([
681                0.0, 1.0, 0.0, 0.0, 0.0, //
682                1.0, 0.0, 0.0, 0.0, 0.0, //
683                0.0, 0.0, 1.0, 0.0, 0.0, //
684                0.0, 0.0, 0.0, 1.0, 0.0, //
685            ]);
686            let glass = Rect::from_ltwh(8.0, 8.0, 16.0, 16.0);
687            let filter = ImageFilter::compose(
688                ImageFilter::Color(swap_red_and_green),
689                ImageFilter::blur(2.0, 2.0).bounds(glass),
690            );
691            let mut builder = SceneBuilder::new();
692            builder.add_picture(Offset::ZERO, &filled(Color::new(0xFFFF0000)), false, false);
693            builder.push_clip_rect(glass, Clip::HardEdge);
694            builder.push_backdrop_filter(&filter, BlendMode::SrcOver, None);
695            builder.pop();
696            builder.pop();
697            let pixels = drawn_pixels(&builder.build().picture, [SIZE, SIZE]);
698            assert_near(
699                pixel_at(&pixels, 16, 16),
700                [0, 255, 0, 255],
701                "inside the glass",
702            );
703            assert_near(
704                pixel_at(&pixels, 2, 2),
705                [255, 0, 0, 255],
706                "outside the glass",
707            );
708        }
709
710        /// A picture that covers the whole scene in `color`.
711        fn filled(color: Color) -> Arc<Picture> {
712            let mut canvas = Canvas::new();
713            let paint = Paint {
714                color: color.into(),
715                ..Paint::default()
716            };
717            canvas.draw_rect(Rect::from_ltwh(0.0, 0.0, SIZE.into(), SIZE.into()), &paint);
718            Arc::new(canvas.build())
719        }
720
721        fn pixel_at(pixels: &[u8], x: usize, y: usize) -> [u8; 4] {
722            let start = (y * SIZE as usize + x) * 4;
723            pixels[start..start + 4]
724                .try_into()
725                .expect("four bytes a pixel")
726        }
727
728        /// Within a step or two of rounding in each channel.
729        fn assert_near(actual: [u8; 4], expected: [u8; 4], place: &str) {
730            let near = actual
731                .iter()
732                .zip(expected)
733                .all(|(actual, expected)| actual.abs_diff(expected) <= 2);
734            assert!(near, "{place}: {actual:?}, expected {expected:?}");
735        }
736    }
737
738    #[test]
739    fn a_scene_without_surfaces_places_none() {
740        let scene = SceneBuilder::new().build();
741        assert!(scene.surfaces.is_empty());
742        assert!(scene.picture.ops().is_empty());
743    }
744
745    #[test]
746    #[should_panic(expected = "open push")]
747    fn build_with_an_open_push_panics() {
748        let mut builder = SceneBuilder::new();
749        builder.push_offset(0.0, 0.0);
750        let _scene = builder.build();
751    }
752
753    #[test]
754    #[should_panic(expected = "without a push")]
755    fn pop_without_a_push_panics() {
756        SceneBuilder::new().pop();
757    }
758}