Skip to main content

frus_widgets/
filters.rs

1//! The filter widgets: [`ColorFiltered`], [`ImageFiltered`] and [`ShaderMask`] apply
2//! a **pixel effect to their own subtree**; [`BackdropFilter`] applies one to whatever
3//! is painted **underneath** it, and [`BackdropGroup`] lets several of those share the
4//! work.
5//!
6//! All three are pass-throughs in layout — the box is the child's — and all three
7//! work the same way at paint time: the subtree is drained into a composited layer
8//! carrying the effect, which the renderer applies when it composites. That is the
9//! same machinery an opacity group and a shape clip already use, and the reason
10//! these three cost one layer between them rather than one each: two of them wrapped
11//! one inside the other are folded into a single layer, since a blur and a tint of a
12//! blur are two independent slots, not two pictures.
13//!
14//! They share an `enabled` flag, and it means *no layer at all* rather than a layer
15//! with a neutral effect. A blur of zero is still a blur — two full-surface passes
16//! and a texture — so a caller animating one to nothing wants a way to say so, and
17//! the flag is it.
18
19use frus_core::{
20    Backdrop, BlendMode, Color, ColorFilter, FractionalMask, ImageFilter, LayerFilter, Rect, Scene,
21    ShaderMask as MaskEffect,
22};
23use frus_layout::Style;
24
25use crate::interaction::Status;
26use crate::theme::Theme;
27use crate::widget::{FilterContext, Widget};
28
29/// Applies a [`ColorFilter`] to every pixel of its child: greyscale, a tint, a
30/// contrast curve, a silhouette.
31///
32/// Each pixel is transformed on its own, so nothing moves and nothing spreads — the
33/// subtree keeps exactly the shape it had. Use [`ImageFiltered`] when the effect
34/// needs a pixel's neighbours, and `BackdropFilter` when it should apply to what is
35/// painted *underneath* rather than to the child.
36///
37/// ```ignore
38/// ColorFiltered::new(ColorFilter::grayscale()).child(Image::asset("photo.png"))
39/// ColorFiltered::new(ColorFilter::Mode(theme.primary, BlendMode::SrcIn))
40///     .child(Icon::new(icons::STAR))   // the icon as a flat silhouette
41/// ```
42pub struct ColorFiltered<Msg = crate::callback::Callback> {
43    filter: ColorFilter,
44    enabled: bool,
45    children: Vec<Box<dyn Widget<Msg>>>,
46}
47
48impl<Msg> ColorFiltered<Msg> {
49    /// Applies `filter` to the child.
50    pub fn new(filter: ColorFilter) -> Self {
51        Self {
52            filter,
53            enabled: true,
54            children: Vec::new(),
55        }
56    }
57
58    /// Drains the colour out of the child.
59    pub fn grayscale() -> Self {
60        Self::new(ColorFilter::grayscale())
61    }
62
63    /// Blends `color` into every pixel with `blend`.
64    pub fn mode(color: Color, blend: BlendMode) -> Self {
65        Self::new(ColorFilter::Mode(color, blend))
66    }
67
68    /// Sets the filtered child.
69    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
70        self.children.clear();
71        self.children.push(Box::new(child));
72        self
73    }
74
75    /// Turns the filter off: the child is painted as if this widget were not there,
76    /// with no layer and no cost.
77    pub fn enabled(mut self, enabled: bool) -> Self {
78        self.enabled = enabled;
79        self
80    }
81}
82
83impl<Msg: Clone> Widget<Msg> for ColorFiltered<Msg> {
84    fn style(&self) -> Style {
85        // A pass-through: the box is the child's, and the effect is paint-time only.
86        Style::default()
87    }
88
89    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
90        &self.children
91    }
92
93    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
94
95    fn on_click(&self) -> Option<Msg> {
96        None
97    }
98
99    fn layer_filter(&self, _cx: FilterContext) -> Option<LayerFilter> {
100        self.enabled.then_some(LayerFilter {
101            color: Some(self.filter),
102            ..LayerFilter::NONE
103        })
104    }
105}
106
107/// Applies an [`ImageFilter`] to its child: a blur, a dilate, an erode.
108///
109/// The result depends on a pixel's neighbours, so the child is treated as an image —
110/// which is why the effect **spreads beyond the child's box**, and why a caller who
111/// wants it contained wraps this in a clip.
112///
113/// This is the cheap way to blur one thing. `BackdropFilter` blurs everything painted
114/// beneath it, which is a different and much more expensive question; when the answer
115/// wanted is "blur this widget", it is this one.
116///
117/// ```ignore
118/// ImageFiltered::blur(8.0).child(Image::asset("photo.png"))
119/// ImageFiltered::new(ImageFilter::Dilate { radius_x: 2.0, radius_y: 2.0 }).child(logo)
120/// ```
121pub struct ImageFiltered<Msg = crate::callback::Callback> {
122    filter: ImageFilter,
123    enabled: bool,
124    children: Vec<Box<dyn Widget<Msg>>>,
125}
126
127impl<Msg> ImageFiltered<Msg> {
128    /// Applies `filter` to the child.
129    pub fn new(filter: ImageFilter) -> Self {
130        Self {
131            filter,
132            enabled: true,
133            children: Vec::new(),
134        }
135    }
136
137    /// A Gaussian blur of `sigma` logical pixels, the same in both directions.
138    pub fn blur(sigma: f32) -> Self {
139        Self::new(ImageFilter::blur(sigma))
140    }
141
142    /// Sets the filtered child.
143    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
144        self.children.clear();
145        self.children.push(Box::new(child));
146        self
147    }
148
149    /// Turns the filter off: the child is painted as if this widget were not there.
150    ///
151    /// Prefer this over a filter that happens to be a no-op. A blur of zero still
152    /// costs a texture and two full-surface passes; this costs nothing.
153    pub fn enabled(mut self, enabled: bool) -> Self {
154        self.enabled = enabled;
155        self
156    }
157}
158
159impl<Msg: Clone> Widget<Msg> for ImageFiltered<Msg> {
160    fn style(&self) -> Style {
161        Style::default()
162    }
163
164    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
165        &self.children
166    }
167
168    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
169
170    fn on_click(&self) -> Option<Msg> {
171        None
172    }
173
174    fn layer_filter(&self, _cx: FilterContext) -> Option<LayerFilter> {
175        self.enabled.then_some(LayerFilter {
176            image: Some(self.filter),
177            ..LayerFilter::NONE
178        })
179    }
180}
181
182/// Blends a **two-stop fade** over its child: a list that fades out at its bottom
183/// edge, a headline filled with a gradient, a photograph dissolving into its page.
184///
185/// The fade is written in **fractions of the child's box** — `(0, 0)` its top-left
186/// corner, `(1, 1)` its bottom-right — and follows it through every resize with
187/// nothing to recompute. The default blend, [`BlendMode::Modulate`], multiplies, so
188/// the fade's *alpha* becomes the child's transparency: that is what makes a mask a
189/// mask, and it is why the colours in the examples below are white with only their
190/// alpha varying. Any other blend mode turns it into a tint, a highlight, or a
191/// silhouette instead.
192///
193/// ```ignore
194/// ShaderMask::fade_out_bottom().child(list)
195/// ShaderMask::linear((0.0, 0.0), (1.0, 0.0), theme.primary, theme.tertiary)
196///     .blend(BlendMode::SrcIn)
197///     .child(Text::new("gradient headline"))
198/// ```
199pub struct ShaderMask<Msg = crate::callback::Callback> {
200    mask: FractionalMask,
201    blend: BlendMode,
202    enabled: bool,
203    children: Vec<Box<dyn Widget<Msg>>>,
204}
205
206impl<Msg> ShaderMask<Msg> {
207    /// Blends `mask` over the child, multiplying it in.
208    pub fn new(mask: FractionalMask) -> Self {
209        Self {
210            mask,
211            blend: BlendMode::Modulate,
212            enabled: true,
213            children: Vec::new(),
214        }
215    }
216
217    /// A straight fade between two points of the box, from `from_color` to
218    /// `to_color`.
219    pub fn linear(from: (f32, f32), to: (f32, f32), from_color: Color, to_color: Color) -> Self {
220        Self::new(FractionalMask::Linear {
221            from,
222            to,
223            from_color,
224            to_color,
225        })
226    }
227
228    /// A fade outwards from `center`, reaching `to_color` at `radius` — a fraction of
229    /// the box's smaller side.
230    pub fn radial(center: (f32, f32), radius: f32, from_color: Color, to_color: Color) -> Self {
231        Self::new(FractionalMask::Radial {
232            center,
233            radius,
234            from_color,
235            to_color,
236        })
237    }
238
239    /// The child, opaque at its top and gone at its bottom.
240    pub fn fade_out_bottom() -> Self {
241        Self::new(FractionalMask::fade_out_bottom())
242    }
243
244    /// How the fade meets the child. The default multiplies it in.
245    pub fn blend(mut self, blend: BlendMode) -> Self {
246        self.blend = blend;
247        self
248    }
249
250    /// Sets the masked child.
251    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
252        self.children.clear();
253        self.children.push(Box::new(child));
254        self
255    }
256
257    /// Turns the mask off: the child is painted as if this widget were not there.
258    pub fn enabled(mut self, enabled: bool) -> Self {
259        self.enabled = enabled;
260        self
261    }
262}
263
264impl<Msg: Clone> Widget<Msg> for ShaderMask<Msg> {
265    fn style(&self) -> Style {
266        Style::default()
267    }
268
269    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
270        &self.children
271    }
272
273    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
274
275    fn on_click(&self) -> Option<Msg> {
276        None
277    }
278
279    fn layer_filter(&self, cx: FilterContext) -> Option<LayerFilter> {
280        self.enabled.then(|| LayerFilter {
281            mask: Some(MaskEffect {
282                // The fractions become pixels here, where the box is finally known.
283                shader: LayerFilter::resolve_mask(self.mask, cx.box_rect),
284                blend: self.blend,
285            }),
286            ..LayerFilter::NONE
287        })
288    }
289}
290
291/// Filters what is already painted **underneath** it, then paints its child over the
292/// result: the frosted glass a sheet, a bar or a dialog sits on.
293///
294/// The **region** is whatever clips this widget, not the widget's own box. Unclipped,
295/// that is the whole surface. That is the reference's rule and it is the useful one —
296/// a bar wants the blur to run edge to edge behind it — but it does mean a backdrop
297/// that should stop somewhere has to be told where, by a clip:
298///
299/// ```ignore
300/// ClipRRect::new(28.0).child(
301///     BackdropFilter::blur(24.0).child(
302///         Container::new().height(72.0).color(theme.surface.fade(0.6)),
303///     ),
304/// )
305/// ```
306///
307/// This is the expensive filter. [`ImageFiltered`] blurs *the widget*, from a texture
308/// the renderer was going to make anyway; a backdrop blurs *the frame so far*, so the
309/// renderer stops, copies what it has drawn, filters that, and carries on — once per
310/// backdrop. When the answer wanted is "blur this widget", the other one is both
311/// easier to reason about and dramatically cheaper.
312///
313/// A list of frosted rows is the case where that cost becomes a problem, and
314/// [`BackdropGroup`] with [`BackdropFilter::grouped`] is the answer: sixty rows,
315/// one blur.
316pub struct BackdropFilter<Msg = crate::callback::Callback> {
317    filter: ImageFilter,
318    blend: BlendMode,
319    enabled: bool,
320    /// An explicit sharing key, when the caller has one of its own.
321    key: Option<u64>,
322    /// `true` for the shared constructor: take the key from the enclosing group.
323    shared: bool,
324    children: Vec<Box<dyn Widget<Msg>>>,
325}
326
327impl<Msg> BackdropFilter<Msg> {
328    /// Applies `filter` to what is painted underneath.
329    pub fn new(filter: ImageFilter) -> Self {
330        Self {
331            filter,
332            blend: BlendMode::SrcOver,
333            enabled: true,
334            key: None,
335            shared: false,
336            children: Vec::new(),
337        }
338    }
339
340    /// A Gaussian blur of `sigma` logical pixels over what is underneath.
341    pub fn blur(sigma: f32) -> Self {
342        Self::new(ImageFilter::blur(sigma))
343    }
344
345    /// Shares the filtered copy with every other grouped backdrop under the same
346    /// [`BackdropGroup`]: they are filtered **once** between them.
347    ///
348    /// With no enclosing group this is an ordinary backdrop — the sharing simply has
349    /// nobody to share with, which is a quieter failure than an error and the same
350    /// thing the reference does.
351    ///
352    /// Two grouped backdrops that **overlap** will look wrong: the shared copy is
353    /// taken once, before the first of them, so the second cannot see the first.
354    pub fn grouped(mut self) -> Self {
355        self.shared = true;
356        self
357    }
358
359    /// Shares the filtered copy with every other backdrop given the same `key`,
360    /// without needing a group around them.
361    pub fn key(mut self, key: u64) -> Self {
362        self.key = Some(key);
363        self.shared = false;
364        self
365    }
366
367    /// How the filtered copy meets what it was copied from. The default paints it
368    /// over, which is what an opaque frame wants; [`BlendMode::Src`] replaces
369    /// instead, which is what a frame that is not opaque underneath wants.
370    pub fn blend(mut self, blend: BlendMode) -> Self {
371        self.blend = blend;
372        self
373    }
374
375    /// Sets the child painted over the filtered backdrop.
376    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
377        self.children.clear();
378        self.children.push(Box::new(child));
379        self
380    }
381
382    /// Turns the backdrop off: the child is painted as if this widget were not there,
383    /// and nothing is copied or filtered.
384    pub fn enabled(mut self, enabled: bool) -> Self {
385        self.enabled = enabled;
386        self
387    }
388}
389
390impl<Msg: Clone> Widget<Msg> for BackdropFilter<Msg> {
391    fn style(&self) -> Style {
392        // A pass-through, like the other three. What is filtered is the clip, not
393        // this box, so the box only has to be where the child wants to be.
394        Style::default()
395    }
396
397    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
398        &self.children
399    }
400
401    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
402
403    fn on_click(&self) -> Option<Msg> {
404        None
405    }
406
407    fn layer_filter(&self, cx: FilterContext) -> Option<LayerFilter> {
408        self.enabled.then_some(LayerFilter {
409            backdrop: Some(Backdrop {
410                filter: self.filter,
411                blend: self.blend,
412                // The enclosing group, or the caller's own key, or neither.
413                key: if self.shared {
414                    cx.backdrop_group
415                } else {
416                    self.key
417                },
418            }),
419            ..LayerFilter::NONE
420        })
421    }
422}
423
424/// Declares that the grouped backdrops inside it share **one** filtered copy of what
425/// is underneath, rather than one each.
426///
427/// A backdrop is expensive because it stops the renderer, copies the frame so far and
428/// filters it. Sixty frosted rows in a list is sixty of those. Under a group, and with
429/// [`BackdropFilter::grouped`], it is one: the copy is taken once and every row reads
430/// its own region out of it. The result is visually the same, because all sixty were
431/// filtering the same picture anyway.
432///
433/// ```ignore
434/// BackdropGroup::new().child(
435///     ListView::new().items(rows.iter().map(|row| {
436///         ClipRRect::new(16.0).child(
437///             BackdropFilter::blur(24.0).grouped().child(frosted_row(row)),
438///         )
439///     })),
440/// )
441/// ```
442///
443/// The catch is the one the sharing implies: two grouped backdrops that **overlap**
444/// will look wrong, because the copy they share was taken before either of them.
445///
446/// The group needs no key of its own. Its identity in the tree is the key, which is
447/// stable from frame to frame and unique without anything having to hand one out.
448pub struct BackdropGroup<Msg = crate::callback::Callback> {
449    children: Vec<Box<dyn Widget<Msg>>>,
450}
451
452impl<Msg> BackdropGroup<Msg> {
453    /// A group over the subtree below.
454    pub fn new() -> Self {
455        Self {
456            children: Vec::new(),
457        }
458    }
459
460    /// Sets the grouped subtree.
461    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
462        self.children.clear();
463        self.children.push(Box::new(child));
464        self
465    }
466}
467
468impl<Msg> Default for BackdropGroup<Msg> {
469    fn default() -> Self {
470        Self::new()
471    }
472}
473
474impl<Msg: Clone> Widget<Msg> for BackdropGroup<Msg> {
475    fn style(&self) -> Style {
476        Style::default()
477    }
478
479    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
480        &self.children
481    }
482
483    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
484
485    fn on_click(&self) -> Option<Msg> {
486        None
487    }
488
489    fn backdrop_group(&self) -> bool {
490        true
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::{Container, Flex, Theme};
498    use frus_core::{MaskShader, Primitive, Size};
499
500    fn scene_of(root: &impl Widget<()>) -> Vec<Primitive> {
501        let rt = crate::runtime::Runtime::default();
502        let theme = Theme::dark();
503        let ui = crate::ui::build_ui(root, Size::new(200.0, 200.0), &rt, &theme);
504        ui.scene().primitives().to_vec()
505    }
506
507    fn only_layer(primitives: &[Primitive]) -> LayerFilter {
508        let layers: Vec<&Primitive> = primitives
509            .iter()
510            .filter(|p| matches!(p, Primitive::Layer { .. }))
511            .collect();
512        assert_eq!(layers.len(), 1, "exactly one layer: {primitives:?}");
513        match layers[0] {
514            Primitive::Layer { filter, .. } => *filter,
515            _ => unreachable!(),
516        }
517    }
518
519    /// Every backdrop key in the scene, outermost first, layers walked in depth.
520    fn backdrop_keys(primitives: &[Primitive]) -> Vec<Option<u64>> {
521        let mut out = Vec::new();
522        fn walk(primitives: &[Primitive], out: &mut Vec<Option<u64>>) {
523            for p in primitives {
524                if let Primitive::Layer {
525                    filter, primitives, ..
526                } = p
527                {
528                    if let Some(b) = filter.backdrop {
529                        out.push(b.key);
530                    }
531                    walk(primitives, out);
532                }
533            }
534        }
535        walk(primitives, &mut out);
536        out
537    }
538
539    fn box_of(color: Color) -> Container<()> {
540        Container::new().width(100.0).height(50.0).color(color)
541    }
542
543    #[test]
544    fn a_colour_filter_reaches_the_layer() {
545        let root = ColorFiltered::grayscale().child(box_of(Color::rgb(1.0, 0.0, 0.0)));
546        let filter = only_layer(&scene_of(&root));
547        assert!(matches!(filter.color, Some(ColorFilter::Matrix(_))));
548        assert!(filter.image.is_none() && filter.mask.is_none());
549    }
550
551    /// The disabled flag means **no layer**, not a layer with nothing in it: a blur
552    /// of zero still costs a texture and two passes, and the point of the flag is to
553    /// cost nothing.
554    #[test]
555    fn disabled_paints_no_layer_at_all() {
556        let root = ImageFiltered::blur(8.0)
557            .enabled(false)
558            .child(box_of(Color::rgb(1.0, 0.0, 0.0)));
559        let primitives = scene_of(&root);
560        assert!(
561            !primitives
562                .iter()
563                .any(|p| matches!(p, Primitive::Layer { .. })),
564            "no layer: {primitives:?}"
565        );
566    }
567
568    /// Two filters one inside the other are **one** layer, not two — because
569    /// compositing does not re-composite a layer nested in another, and because a
570    /// blur and a tint are independent slots rather than two pictures.
571    #[test]
572    fn two_filters_share_one_layer() {
573        let root = ColorFiltered::grayscale()
574            .child(ImageFiltered::blur(4.0).child(box_of(Color::rgb(1.0, 0.0, 0.0))));
575        let filter = only_layer(&scene_of(&root));
576        assert!(filter.color.is_some(), "the outer greyscale survived");
577        assert!(filter.image.is_some(), "the inner blur survived");
578    }
579
580    /// Two filters asking for the **same** slot cannot share one, so they nest. The
581    /// outer one is a filter of the inner one's result, and folding them would
582    /// silently drop one.
583    #[test]
584    fn two_filters_of_the_same_kind_do_not_fold() {
585        let root = ColorFiltered::new(ColorFilter::invert())
586            .child(ColorFiltered::grayscale().child(box_of(Color::rgb(1.0, 0.0, 0.0))));
587        let primitives = scene_of(&root);
588        let outer = primitives
589            .iter()
590            .filter(|p| matches!(p, Primitive::Layer { .. }))
591            .count();
592        assert_eq!(outer, 1, "the outer layer");
593        match &primitives
594            .iter()
595            .find(|p| matches!(p, Primitive::Layer { .. }))
596        {
597            Some(Primitive::Layer { primitives, .. }) => assert!(
598                primitives
599                    .iter()
600                    .any(|p| matches!(p, Primitive::Layer { .. })),
601                "the inner filter kept a layer of its own"
602            ),
603            _ => panic!("a layer"),
604        }
605    }
606
607    /// The mask is written in fractions and resolved against the box the widget
608    /// actually got — which is the whole reason the resolution happens in the walk
609    /// and not at the call site.
610    #[test]
611    fn a_mask_is_resolved_against_the_box_on_screen() {
612        // The masked box is the second row of a column of two 100x50 boxes, so it
613        // starts at y = 50 and ends at y = 100.
614        let root = Flex::<()>::column()
615            .child(box_of(Color::rgb(0.0, 1.0, 0.0)))
616            .child(ShaderMask::fade_out_bottom().child(box_of(Color::rgb(1.0, 0.0, 0.0))));
617        let filter = only_layer(&scene_of(&root));
618        let mask = filter.mask.expect("a mask").shader;
619        match mask {
620            MaskShader::Linear { from, to, .. } => {
621                assert!(
622                    (from.y - 50.0).abs() < 0.5,
623                    "starts at the box top: {from:?}"
624                );
625                assert!((to.y - 100.0).abs() < 0.5, "ends at its bottom: {to:?}");
626            }
627            other => panic!("a linear fade: {other:?}"),
628        }
629    }
630
631    /// The default blend multiplies, which is what turns a fade into a mask rather
632    /// than into a tint painted over the child.
633    #[test]
634    fn a_mask_multiplies_unless_told_otherwise() {
635        let plain = ShaderMask::fade_out_bottom().child(box_of(Color::WHITE));
636        assert_eq!(
637            only_layer(&scene_of(&plain)).mask.expect("a mask").blend,
638            BlendMode::Modulate
639        );
640        let tinted = ShaderMask::fade_out_bottom()
641            .blend(BlendMode::SrcIn)
642            .child(box_of(Color::WHITE));
643        assert_eq!(
644            only_layer(&scene_of(&tinted)).mask.expect("a mask").blend,
645            BlendMode::SrcIn
646        );
647    }
648
649    /// A backdrop reaches the layer, and carries no sharing key when nothing asked
650    /// for one.
651    #[test]
652    fn a_backdrop_reaches_the_layer() {
653        let root = BackdropFilter::blur(12.0).child(box_of(Color::WHITE));
654        let filter = only_layer(&scene_of(&root));
655        let backdrop = filter.backdrop.expect("a backdrop");
656        assert_eq!(backdrop.filter, ImageFilter::blur(12.0));
657        assert_eq!(backdrop.blend, BlendMode::SrcOver);
658        assert!(backdrop.key.is_none(), "on its own unless told otherwise");
659    }
660
661    /// Two grouped backdrops under one group take the **same** key, which is what
662    /// tells the renderer to filter once for both.
663    #[test]
664    fn grouped_backdrops_share_one_key() {
665        let root = BackdropGroup::<()>::new().child(
666            Flex::column()
667                .child(
668                    BackdropFilter::blur(12.0)
669                        .grouped()
670                        .child(box_of(Color::WHITE)),
671                )
672                .child(
673                    BackdropFilter::blur(12.0)
674                        .grouped()
675                        .child(box_of(Color::WHITE)),
676                ),
677        );
678        let keys = backdrop_keys(&scene_of(&root));
679        assert_eq!(keys.len(), 2, "two backdrops");
680        assert!(keys[0].is_some(), "grouped, so keyed");
681        assert_eq!(keys[0], keys[1], "the same group, the same key");
682    }
683
684    /// Two groups are two keys: sharing is scoped to the group that declared it, so a
685    /// list inside a dialog does not share with the page behind it.
686    #[test]
687    fn two_groups_do_not_share_with_each_other() {
688        let grouped = || {
689            BackdropGroup::<()>::new().child(
690                BackdropFilter::blur(12.0)
691                    .grouped()
692                    .child(box_of(Color::WHITE)),
693            )
694        };
695        let root = Flex::<()>::column().child(grouped()).child(grouped());
696        let keys = backdrop_keys(&scene_of(&root));
697        assert_eq!(keys.len(), 2);
698        assert!(keys[0].is_some() && keys[1].is_some());
699        assert_ne!(keys[0], keys[1], "different groups, different keys");
700    }
701
702    /// Asking to be grouped with no group around is an ordinary backdrop, not an
703    /// error: the sharing simply has nobody to share with.
704    #[test]
705    fn grouped_with_no_group_is_an_ordinary_backdrop() {
706        let root = BackdropFilter::blur(12.0)
707            .grouped()
708            .child(box_of(Color::WHITE));
709        let keys = backdrop_keys(&scene_of(&root));
710        assert_eq!(keys, vec![None]);
711    }
712
713    /// A backdrop keeps its own layer whatever else is around it: it filters what is
714    /// *under* the layer, and a colour filter sharing that layer would be applied to
715    /// the content and not to the backdrop it sits on.
716    #[test]
717    fn a_backdrop_does_not_fold_into_a_colour_filter() {
718        let root = ColorFiltered::grayscale()
719            .child(BackdropFilter::blur(12.0).child(box_of(Color::WHITE)));
720        let primitives = scene_of(&root);
721        let outer = primitives
722            .iter()
723            .find(|p| matches!(p, Primitive::Layer { .. }))
724            .expect("the outer layer");
725        match outer {
726            Primitive::Layer {
727                filter, primitives, ..
728            } => {
729                assert!(filter.color.is_some(), "the greyscale is the outer one");
730                assert!(
731                    primitives
732                        .iter()
733                        .any(|p| matches!(p, Primitive::Layer { filter, .. } if filter.backdrop.is_some())),
734                    "the backdrop kept a layer of its own"
735                );
736            }
737            _ => unreachable!(),
738        }
739    }
740
741    /// A filter widget changes nothing about layout: its box is its child's, and the
742    /// sibling after it starts exactly where it would have without the filter.
743    #[test]
744    fn a_filter_does_not_move_anything() {
745        let bare = Flex::<()>::column()
746            .child(box_of(Color::rgb(1.0, 0.0, 0.0)))
747            .child(box_of(Color::rgb(0.0, 0.0, 1.0)));
748        let filtered = Flex::<()>::column()
749            .child(ImageFiltered::blur(6.0).child(box_of(Color::rgb(1.0, 0.0, 0.0))))
750            .child(box_of(Color::rgb(0.0, 0.0, 1.0)));
751        let blue_y = |primitives: &[Primitive]| {
752            primitives
753                .iter()
754                .find_map(|p| match p {
755                    Primitive::Rect { rect, color, .. } if color.b > 0.5 && color.r < 0.5 => {
756                        Some(rect.y)
757                    }
758                    _ => None,
759                })
760                .expect("the blue sibling")
761        };
762        assert_eq!(blue_y(&scene_of(&bare)), blue_y(&scene_of(&filtered)));
763    }
764}