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}