Skip to main content

frus_widgets/
gridtile.rs

1//! [`GridTile`] and [`GridTileBar`]: a cell of an image list, and the strip of words
2//! **over** it.
3//!
4//! ```ignore
5//! GridTile::new(Image::new(photo))
6//!     .footer(GridTileBar::new().title(Text::new("Cliffs")).subtitle(Text::new("Ada")))
7//! ```
8//!
9//! The whole idea is in the word *over*. A caption under a picture is a column, and
10//! nobody needs a widget for a column. A grid of pictures with their names on them is a
11//! different thing: the tile is the picture's size, the strip is laid on top of it, and
12//! the grid's rows stay even because the words took no room. That is why the header and
13//! the footer are layers and not siblings (`grid_tile.dart:49`), and why a tile with
14//! neither is simply its child — there is nothing to stack.
15//!
16//! ## The bar reads light on dark, whatever the application is
17//!
18//! A [`GridTileBar`] stands on a photograph, and a photograph has no brightness the theme
19//! knows about. So the strip takes a **dark scheme for its own subtree** and white content
20//! over it (`grid_tile_bar.dart:75`), which is the one place in this framework where a
21//! widget overrules the application's colours rather than resolving against them. The
22//! alternative is a caption that is legible until someone switches to the light theme.
23//!
24//! It is still overridable, and by more than the reference allows:
25//! [`GridTileBarTheme`](crate::GridTileBarTheme) and the builders answer the colours, the
26//! type and the two heights, and a caller who wants their own scheme wraps the bar in a
27//! [`Themed`](crate::Themed) of their own.
28
29use std::cell::{OnceCell, RefCell};
30
31use frus_core::{Color, Rect, Scene, TextOverflow, TextStyle};
32use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
33
34use crate::flex::Flex;
35use crate::interaction::Status;
36use crate::theme::Theme;
37use crate::themed::Themed;
38use crate::widget::Widget;
39
40/// A bar carrying one line (`grid_tile_bar.dart:79`).
41pub const GRID_TILE_BAR_HEIGHT: f32 = 48.0;
42
43/// A bar carrying a title **and** a subtitle.
44pub const GRID_TILE_BAR_TWO_LINE_HEIGHT: f32 = 68.0;
45
46/// The room at an end with something in it (`grid_tile_bar.dart:70`).
47const TIGHT_PADDING: f32 = 8.0;
48
49/// The room at an end with nothing in it. A glyph carries its own margin; a word does not.
50const LOOSE_PADDING: f32 = 16.0;
51
52/// Between the slots.
53const GAP: f32 = 8.0;
54
55/// A cell of an image list: a child, with a strip laid over its top or its bottom.
56pub struct GridTile<Msg = crate::callback::Callback> {
57    child: RefCell<Option<Box<dyn Widget<Msg>>>>,
58    header: RefCell<Option<Box<dyn Widget<Msg>>>>,
59    footer: RefCell<Option<Box<dyn Widget<Msg>>>>,
60    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
61}
62
63impl<Msg: Clone + 'static> GridTile<Msg> {
64    /// A tile filled by `child` — typically a picture (`grid_tile.dart:41`).
65    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
66        Self::new_boxed(Box::new(child))
67    }
68
69    /// The same, for a child that is already boxed.
70    pub fn new_boxed(child: Box<dyn Widget<Msg>>) -> Self {
71        Self {
72            child: RefCell::new(Some(child)),
73            header: RefCell::new(None),
74            footer: RefCell::new(None),
75            built: OnceCell::new(),
76        }
77    }
78
79    /// A strip across the **top** of the tile, over the child (`grid_tile.dart:31`).
80    /// Typically a [`GridTileBar`].
81    #[must_use]
82    pub fn header(self, header: impl Widget<Msg> + 'static) -> Self {
83        *self.header.borrow_mut() = Some(Box::new(header));
84        self
85    }
86
87    /// A strip across the **bottom** (`grid_tile.dart:36`).
88    ///
89    /// The reference notes that a tile does not usually have both this and a header, and
90    /// that is a note rather than a rule: nothing here stops it, because a picture with a
91    /// name at the top and a date at the bottom is a thing people build.
92    #[must_use]
93    pub fn footer(self, footer: impl Widget<Msg> + 'static) -> Self {
94        *self.footer.borrow_mut() = Some(Box::new(footer));
95        self
96    }
97
98    /// The layers, built once: the child, then whichever strips were given.
99    ///
100    /// The child is **not** positioned, so it is the child that decides how big the tile
101    /// is; the strips are, so they take none of that decision and no room in it. That is
102    /// the difference between this and a column, and it is the whole widget.
103    fn layers(&self) -> &[Box<dyn Widget<Msg>>] {
104        self.built.get_or_init(|| {
105            let mut layers: Vec<Box<dyn Widget<Msg>>> = Vec::with_capacity(3);
106            if let Some(child) = self.child.borrow_mut().take() {
107                layers.push(child);
108            }
109            if let Some(header) = self.header.borrow_mut().take() {
110                layers.push(Box::new(
111                    crate::Positioned::new(header).top(0.0).left(0.0).right(0.0),
112                ));
113            }
114            if let Some(footer) = self.footer.borrow_mut().take() {
115                layers.push(Box::new(
116                    crate::Positioned::new(footer)
117                        .bottom(0.0)
118                        .left(0.0)
119                        .right(0.0),
120                ));
121            }
122            layers
123        })
124    }
125
126    /// Whether anything is laid over the child. With nothing, the tile **is** the child
127    /// and is not a stack at all — which is the reference's early return
128    /// (`grid_tile.dart:45`) and matters here for more than a spare node: a stack answers
129    /// the walk's structural questions differently, and a tile that claimed to be one
130    /// while holding a single child would lay that child out loose in a box of its own.
131    fn stacked(&self) -> bool {
132        self.layers().len() > 1
133    }
134}
135
136impl<Msg: Clone + 'static> Widget<Msg> for GridTile<Msg> {
137    /// The child's box. A tile is the size of what fills it; the strips are over it.
138    fn style(&self) -> Style {
139        match self.layers().first() {
140            Some(child) => Widget::<Msg>::style(child),
141            None => Style::default(),
142        }
143    }
144
145    fn style_themed(&self, theme: &Theme) -> Style {
146        match self.layers().first() {
147            Some(child) => Widget::<Msg>::style_themed(child, theme),
148            None => Style::default(),
149        }
150    }
151
152    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
153        self.layers()
154    }
155
156    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
157
158    fn on_click(&self) -> Option<Msg> {
159        None
160    }
161
162    fn stack(&self) -> bool {
163        self.stacked()
164    }
165
166    /// Loose: a layer that was not told where to go keeps its own size. The child is that
167    /// layer, and it is the one that sized the tile.
168    fn stack_fit(&self) -> crate::StackFit {
169        crate::StackFit::Loose
170    }
171}
172
173/// The strip of words laid over a [`GridTile`]: up to two lines, with a slot at each end.
174pub struct GridTileBar<Msg = crate::callback::Callback> {
175    background_color: Option<Color>,
176    foreground_color: Option<Color>,
177    title_style: Option<TextStyle>,
178    subtitle_style: Option<TextStyle>,
179    height: Option<f32>,
180    leading: RefCell<Option<Box<dyn Widget<Msg>>>>,
181    title: RefCell<Option<Box<dyn Widget<Msg>>>>,
182    subtitle: RefCell<Option<Box<dyn Widget<Msg>>>>,
183    trailing: RefCell<Option<Box<dyn Widget<Msg>>>>,
184    has_title: bool,
185    has_subtitle: bool,
186    has_leading: bool,
187    has_trailing: bool,
188    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
189}
190
191impl<Msg: Clone + 'static> Default for GridTileBar<Msg> {
192    fn default() -> Self {
193        Self::new()
194    }
195}
196
197impl<Msg: Clone + 'static> GridTileBar<Msg> {
198    /// An empty bar. Every slot is optional, the reference's included — a strip carrying
199    /// only a trailing glyph is a legitimate thing to lay over a picture.
200    pub fn new() -> Self {
201        Self {
202            background_color: None,
203            foreground_color: None,
204            title_style: None,
205            subtitle_style: None,
206            height: None,
207            leading: RefCell::new(None),
208            title: RefCell::new(None),
209            subtitle: RefCell::new(None),
210            trailing: RefCell::new(None),
211            has_title: false,
212            has_subtitle: false,
213            has_leading: false,
214            has_trailing: false,
215            built: OnceCell::new(),
216        }
217    }
218
219    /// What is drawn behind the words (`grid_tile_bar.dart:41`). Untold, **nothing** — the
220    /// picture shows through, which is the arrangement the widget is for.
221    #[must_use]
222    pub fn background_color(mut self, color: Color) -> Self {
223        self.background_color = Some(color);
224        self
225    }
226
227    /// The colour of the words and glyphs in the bar. Untold, white
228    /// (`grid_tile_bar.dart:83`).
229    ///
230    /// Not in the reference, which hardcodes it; here because a bar over a pale photograph
231    /// wants the opposite and there was nowhere to say so.
232    #[must_use]
233    pub fn foreground_color(mut self, color: Color) -> Self {
234        self.foreground_color = Some(color);
235        self
236    }
237
238    /// A widget before the title — a glyph, usually (`grid_tile_bar.dart:46`).
239    #[must_use]
240    pub fn leading(mut self, leading: impl Widget<Msg> + 'static) -> Self {
241        *self.leading.borrow_mut() = Some(Box::new(leading));
242        self.has_leading = true;
243        self
244    }
245
246    /// The bar's main line (`grid_tile_bar.dart:51`). Typically a
247    /// [`Text`](crate::Text), which takes the bar's type and colour without being told.
248    #[must_use]
249    pub fn title(mut self, title: impl Widget<Msg> + 'static) -> Self {
250        *self.title.borrow_mut() = Some(Box::new(title));
251        self.has_title = true;
252        self
253    }
254
255    /// A second line under the title (`grid_tile_bar.dart:56`). **Its presence is what
256    /// makes the bar 68 rather than 48**, so this is a size decision as much as a content
257    /// one.
258    #[must_use]
259    pub fn subtitle(mut self, subtitle: impl Widget<Msg> + 'static) -> Self {
260        *self.subtitle.borrow_mut() = Some(Box::new(subtitle));
261        self.has_subtitle = true;
262        self
263    }
264
265    /// A widget after the title (`grid_tile_bar.dart:61`).
266    #[must_use]
267    pub fn trailing(mut self, trailing: impl Widget<Msg> + 'static) -> Self {
268        *self.trailing.borrow_mut() = Some(Box::new(trailing));
269        self.has_trailing = true;
270        self
271    }
272
273    /// The bar's height, over the two the content would have chosen.
274    #[must_use]
275    pub fn height(mut self, height: f32) -> Self {
276        self.height = Some(height);
277        self
278    }
279
280    /// The title's type. Untold, the scale's `title_medium` (`grid_tile_bar.dart:95`).
281    #[must_use]
282    pub fn title_style(mut self, style: TextStyle) -> Self {
283        self.title_style = Some(style);
284        self
285    }
286
287    /// The subtitle's. Untold, `body_small` (`grid_tile_bar.dart:101`).
288    #[must_use]
289    pub fn subtitle_style(mut self, style: TextStyle) -> Self {
290        self.subtitle_style = Some(style);
291        self
292    }
293
294    /// How tall the bar is: two lines make it 68, anything else 48.
295    fn resolved_height(&self, theme: &Theme) -> f32 {
296        if let Some(told) = self.height {
297            return told;
298        }
299        let t = &theme.widgets.grid_tile_bar;
300        if self.has_title && self.has_subtitle {
301            t.two_line_height.unwrap_or(GRID_TILE_BAR_TWO_LINE_HEIGHT)
302        } else {
303            t.height.unwrap_or(GRID_TILE_BAR_HEIGHT)
304        }
305    }
306
307    /// The room at each end. An end with a glyph in it takes less, because a glyph is
308    /// already smaller than the box it is drawn in (`grid_tile_bar.dart:70`).
309    fn room(&self) -> (f32, f32) {
310        (
311            if self.has_leading {
312                TIGHT_PADDING
313            } else {
314                LOOSE_PADDING
315            },
316            if self.has_trailing {
317                TIGHT_PADDING
318            } else {
319                LOOSE_PADDING
320            },
321        )
322    }
323
324    /// One line of the bar, wrapped in the type it takes.
325    fn line(
326        slot: Box<dyn Widget<Msg>>,
327        own: Option<TextStyle>,
328        pick: fn(&Theme) -> TextStyle,
329        themed: fn(&Theme) -> Option<TextStyle>,
330    ) -> Box<dyn Widget<Msg>> {
331        Box::new(Themed::tweak(
332            move |theme| {
333                // The colour comes from the bar's own tweak, which has already run: this
334                // one answers the *type* and leaves the ink where it found it, so the two
335                // decisions stay apart and either can be overridden alone.
336                let ink = theme.widgets.text.style.color;
337                let mut style = own.or_else(|| themed(theme)).unwrap_or_else(|| pick(theme));
338                style.color = style.color.or(ink);
339                theme.widgets.text.style = style;
340            },
341            slot,
342        ))
343    }
344
345    fn assemble(&self) -> Vec<Box<dyn Widget<Msg>>> {
346        let mut row = Flex::<Msg>::row().flex(1.0).align(Align::Center).gap(GAP);
347        if let Some(leading) = self.leading.borrow_mut().take() {
348            row = row.child_boxed(leading);
349        }
350        let title = self.title.borrow_mut().take();
351        let subtitle = self.subtitle.borrow_mut().take();
352        let own_title = self.title_style;
353        let own_subtitle = self.subtitle_style;
354        let lines: Option<Box<dyn Widget<Msg>>> = match (title, subtitle) {
355            (Some(title), Some(subtitle)) => Some(Box::new(
356                Flex::<Msg>::column()
357                    .justify(Justify::Center)
358                    .align(Align::Start)
359                    .child_boxed(Self::line(
360                        title,
361                        own_title,
362                        |theme| crate::theme::type_scale(Some(theme)).title_medium,
363                        |theme| theme.widgets.grid_tile_bar.title_style,
364                    ))
365                    .child_boxed(Self::line(
366                        subtitle,
367                        own_subtitle,
368                        |theme| crate::theme::type_scale(Some(theme)).body_small,
369                        |theme| theme.widgets.grid_tile_bar.subtitle_style,
370                    )),
371            )),
372            // One line on its own takes the **title**'s type whichever slot it came from
373            // (`grid_tile_bar.dart:115`): a subtitle with no title above it is not a
374            // second line, it is the only line.
375            (Some(only), None) | (None, Some(only)) => Some(Self::line(
376                only,
377                own_title,
378                |theme| crate::theme::type_scale(Some(theme)).title_medium,
379                |theme| theme.widgets.grid_tile_bar.title_style,
380            )),
381            (None, None) => None,
382        };
383        if let Some(lines) = lines {
384            row = row.child(crate::Expanded::new(lines));
385        }
386        if let Some(trailing) = self.trailing.borrow_mut().take() {
387            row = row.child_boxed(trailing);
388        }
389
390        let own_ink = self.foreground_color;
391        vec![Box::new(Themed::tweak(
392            move |theme| {
393                // **A dark scheme for this subtree, whatever the application is.** The bar
394                // stands on a photograph and the theme cannot know how bright one is; the
395                // reference installs `ThemeData.dark()` for the same reason
396                // (`grid_tile_bar.dart:75`). What is kept is everything that is not a
397                // colour — the typographic scale, the reading direction, the spacing, and
398                // the application's per-widget defaults — so a bar in a themed application
399                // still reads as part of it.
400                let mut dark = Theme::dark();
401                dark.direction = theme.direction;
402                dark.text = theme.text;
403                dark.radius = theme.radius;
404                dark.spacing = theme.spacing;
405                dark.tap_target = theme.tap_target;
406                dark.widgets = theme.widgets.clone();
407                *theme = dark;
408
409                let ink = own_ink
410                    .or(theme.widgets.grid_tile_bar.foreground_color)
411                    .unwrap_or(Color::WHITE);
412                theme.widgets.text.style.color = Some(ink);
413                theme.widgets.icon.color = Some(ink);
414                // A caption over a picture does not wrap and does not grow: it cuts.
415                // Two lines of a name would cover the face the tile is showing.
416                theme.widgets.text.soft_wrap = Some(false);
417                theme.widgets.text.overflow = Some(TextOverflow::Ellipsis);
418                theme.widgets.text.max_lines = Some(1);
419            },
420            row,
421        )) as Box<dyn Widget<Msg>>]
422    }
423}
424
425impl<Msg: Clone + 'static> Widget<Msg> for GridTileBar<Msg> {
426    fn style(&self) -> Style {
427        Widget::<Msg>::style_themed(self, &Theme::default())
428    }
429
430    fn style_themed(&self, theme: &Theme) -> Style {
431        let (start, end) = self.room();
432        Style {
433            height: Dimension::Length(self.resolved_height(theme)),
434            width: Dimension::Percent(1.0),
435            flex_direction: FlexDirection::Row,
436            align: Align::Stretch,
437            padding: frus_core::Insets::new(0.0, end, 0.0, start),
438            ..Default::default()
439        }
440    }
441
442    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
443        self.built.get_or_init(|| self.assemble())
444    }
445
446    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
447        let fill = self
448            .background_color
449            .or(theme.widgets.grid_tile_bar.background_color);
450        // Untold, nothing at all — not a transparent rectangle, which would still be a
451        // primitive and would still be a decision. The picture is the background.
452        if let Some(fill) = fill.filter(|c| c.a > 0.0) {
453            scene.fill_rect(bounds, fill.fade(status.opacity));
454        }
455    }
456
457    fn on_click(&self) -> Option<Msg> {
458        None
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use crate::{build_ui, Container, Icon, Icons, Runtime, Size, Text};
466    use frus_core::Primitive;
467
468    fn picture() -> Container<()> {
469        Container::<()>::new()
470            .width(120.0)
471            .height(120.0)
472            .color(Color::rgb(0.2, 0.4, 0.8))
473    }
474
475    fn scene_of(tile: impl Widget<()> + 'static) -> Vec<Primitive> {
476        let root = crate::flex::Flex::<()>::column()
477            .width(200.0)
478            .height(200.0)
479            .child(tile);
480        build_ui(
481            &root,
482            Size::new(200.0, 200.0),
483            &Runtime::default(),
484            &Theme::default(),
485        )
486        .scene()
487        .primitives()
488        .to_vec()
489    }
490
491    /// A tile with nothing over it is its child, and is **not** a stack: a stack lays its
492    /// layers out loose in a box of their own, which is not what one child in flow does.
493    #[test]
494    fn a_tile_with_no_strips_is_its_child() {
495        let bare = GridTile::new(picture());
496        assert!(!Widget::<()>::stack(&bare));
497        assert_eq!(Widget::<()>::children(&bare).len(), 1);
498        let with_footer = GridTile::new(picture()).footer(GridTileBar::new().title(Text::new("A")));
499        assert!(Widget::<()>::stack(&with_footer));
500        assert_eq!(Widget::<()>::children(&with_footer).len(), 2);
501    }
502
503    /// The tile is the size of what fills it, whatever is laid over it: the strips are
504    /// positioned, so they take no part in the box.
505    #[test]
506    fn the_strips_do_not_change_the_tile() {
507        let bare = Widget::<()>::style(&GridTile::new(picture()));
508        let dressed = Widget::<()>::style(
509            &GridTile::new(picture())
510                .header(GridTileBar::new().title(Text::new("A")))
511                .footer(GridTileBar::new().title(Text::new("B"))),
512        );
513        assert_eq!(bare.width, dressed.width);
514        assert_eq!(bare.height, dressed.height);
515        assert_eq!(bare.width, Dimension::Length(120.0));
516    }
517
518    /// A header sits at the top of the tile and a footer at the bottom — both over the
519    /// child, both the full width.
520    #[test]
521    fn a_header_is_at_the_top_and_a_footer_at_the_bottom() {
522        let ink = Color::rgb(1.0, 0.0, 0.0);
523        let paper = Color::rgb(0.0, 1.0, 0.0);
524        let tile = || {
525            GridTile::new(picture())
526                .header(
527                    GridTileBar::new()
528                        .background_color(ink)
529                        .title(Text::new("H")),
530                )
531                .footer(
532                    GridTileBar::new()
533                        .background_color(paper)
534                        .title(Text::new("F")),
535                )
536        };
537        let strips = scene_of(tile());
538        let rect_of = |c: Color| {
539            strips
540                .iter()
541                .find_map(|p| match p {
542                    Primitive::Rect { color, rect, .. } if *color == c => Some(*rect),
543                    _ => None,
544                })
545                .expect("the strip is drawn")
546        };
547        let header = rect_of(ink);
548        let footer = rect_of(paper);
549        assert_eq!(header.y, 0.0, "the header is at the top");
550        assert_eq!(header.height, GRID_TILE_BAR_HEIGHT);
551        assert_eq!(
552            footer.y + footer.height,
553            120.0,
554            "and the footer at the bottom of the tile, not of the page"
555        );
556        assert_eq!(header.width, 120.0, "both run the tile's full width");
557        assert_eq!(footer.width, 120.0);
558    }
559
560    /// Two lines make the bar 68; anything else 48. It is the subtitle that decides,
561    /// which makes adding one a size decision.
562    #[test]
563    fn a_subtitle_makes_the_bar_taller() {
564        let theme = Theme::default();
565        let height = |bar: GridTileBar<()>| match Widget::<()>::style_themed(&bar, &theme).height {
566            Dimension::Length(h) => h,
567            other => panic!("a bar has a height, not {other:?}"),
568        };
569        assert_eq!(height(GridTileBar::new().title(Text::new("A"))), 48.0);
570        assert_eq!(
571            height(
572                GridTileBar::new()
573                    .title(Text::new("A"))
574                    .subtitle(Text::new("B"))
575            ),
576            68.0
577        );
578        // A subtitle on its own is the only line, so it is one line tall.
579        assert_eq!(height(GridTileBar::new().subtitle(Text::new("B"))), 48.0);
580        assert_eq!(height(GridTileBar::new().height(90.0)), 90.0);
581    }
582
583    /// An end with a glyph in it takes less room than an end with nothing.
584    #[test]
585    fn a_slot_at_an_end_tightens_it() {
586        let theme = Theme::default();
587        let padding = |bar: GridTileBar<()>| Widget::<()>::style_themed(&bar, &theme).padding;
588        let plain = padding(GridTileBar::new().title(Text::new("A")));
589        assert_eq!((plain.left, plain.right), (LOOSE_PADDING, LOOSE_PADDING));
590        let dressed = padding(
591            GridTileBar::new()
592                .leading(Icon::new(Icons::CHECK))
593                .title(Text::new("A")),
594        );
595        assert_eq!(
596            (dressed.left, dressed.right),
597            (TIGHT_PADDING, LOOSE_PADDING),
598            "only the end with something in it"
599        );
600    }
601
602    /// The bar reads light on dark whatever the application is, because it stands on a
603    /// picture and the theme cannot know how bright one is.
604    #[test]
605    fn the_bar_is_light_on_the_picture() {
606        let colours: Vec<Color> = scene_of(
607            GridTile::new(picture()).footer(GridTileBar::new().title(Text::new("Cliffs"))),
608        )
609        .into_iter()
610        .filter_map(|p| match p {
611            Primitive::Text { color, .. } => Some(color),
612            _ => None,
613        })
614        .collect();
615        assert_eq!(colours, vec![Color::WHITE]);
616        // And a caller who wants the other way round has somewhere to say so.
617        let told = Color::rgb(0.1, 0.1, 0.1);
618        let mine: Vec<Color> = scene_of(
619            GridTile::new(picture()).footer(
620                GridTileBar::new()
621                    .foreground_color(told)
622                    .title(Text::new("Cliffs")),
623            ),
624        )
625        .into_iter()
626        .filter_map(|p| match p {
627            Primitive::Text { color, .. } => Some(color),
628            _ => None,
629        })
630        .collect();
631        assert_eq!(mine, vec![told]);
632    }
633
634    /// Untold, the bar draws **nothing** behind the words: the picture is the background,
635    /// and a transparent rectangle would still be a primitive and still be a decision.
636    #[test]
637    fn a_bar_with_no_colour_paints_nothing() {
638        let backgrounds = |bar: GridTileBar<()>| {
639            scene_of(GridTile::new(picture()).footer(bar))
640                .into_iter()
641                .filter(|p| matches!(p, Primitive::Rect { rect, .. } if rect.height == 48.0))
642                .count()
643        };
644        assert_eq!(backgrounds(GridTileBar::new().title(Text::new("A"))), 0);
645        let told = Color::rgb(0.0, 0.0, 0.0);
646        assert_eq!(
647            backgrounds(
648                GridTileBar::new()
649                    .background_color(told)
650                    .title(Text::new("A"))
651            ),
652            1
653        );
654    }
655
656    /// The theme answers what the caller did not, for the heights as for the colours.
657    #[test]
658    fn the_theme_answers_what_the_caller_did_not() {
659        let mut theme = Theme::default();
660        theme.widgets.grid_tile_bar.height = Some(40.0);
661        theme.widgets.grid_tile_bar.two_line_height = Some(80.0);
662        let one = GridTileBar::<()>::new().title(Text::new("A"));
663        assert_eq!(
664            Widget::<()>::style_themed(&one, &theme).height,
665            Dimension::Length(40.0)
666        );
667        let two = GridTileBar::<()>::new()
668            .title(Text::new("A"))
669            .subtitle(Text::new("B"));
670        assert_eq!(
671            Widget::<()>::style_themed(&two, &theme).height,
672            Dimension::Length(80.0)
673        );
674    }
675}