Skip to main content

frus_widgets/
navbar.rs

1//! [`NavigationBar`]: a persistent navigation bar — a centred title, an optional back
2//! button on the left. Placed at the head of a screen, it **slides and fades
3//! with it** during [`crate::Navigator`] transitions.
4
5#[cfg(test)]
6use frus_core::FontWeight;
7use frus_core::{Color, Insets, Point, Rect, Scene, TextStyle};
8use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
9
10use crate::interaction::Status;
11use crate::theme::Theme;
12use crate::widget::Widget;
13
14/// Bar height, in logical pixels.
15const HEIGHT: f32 = 56.0;
16/// Left margin: beyond the back-gesture zone, so the button stays clickable
17/// without triggering the swipe.
18const PAD_LEFT: f32 = 28.0;
19/// The bar's own padding: nothing above or below (see [`NavigationBar::style`]), 16 at
20/// the trailing edge, and the gesture-clearing inset at the leading one.
21const PADDING: Insets = Insets {
22    top: 0.0,
23    right: 16.0,
24    bottom: 0.0,
25    left: PAD_LEFT,
26};
27/// The hairline along the bottom edge.
28const DIVIDER_THICKNESS: f32 = 1.0;
29/// The title's type: what the caller said, else the step the reference gives an app bar's
30/// title — `titleLarge`. It used to be a private `20.0` at a medium weight, which had **both
31/// halves wrong**: the reference's is 22 and regular.
32fn title_style_of(over: Option<TextStyle>, theme: &Theme) -> TextStyle {
33    over.or(theme.widgets.nav_bar.title_style)
34        .unwrap_or(theme.text.title_large)
35}
36
37/// A navigation bar: a title + an optional back button.
38pub struct NavigationBar<Msg = crate::callback::Callback> {
39    title: String,
40    /// The caller's title style, if one was named. Unset, the theme's `titleLarge`.
41    title_style: Option<TextStyle>,
42    /// The caller's height, if one was named. Unset, the theme's, then [`HEIGHT`].
43    height: Option<f32>,
44    /// `[]` (root) or `[back button]`.
45    children: Vec<Box<dyn Widget<Msg>>>,
46}
47
48impl<Msg: Clone + 'static> NavigationBar<Msg> {
49    /// Creates a root bar: the title alone, with no back button.
50    pub fn new(title: impl Into<String>) -> Self {
51        Self {
52            title: title.into(),
53            title_style: None,
54            height: None,
55            children: Vec::new(),
56        }
57    }
58
59    /// Overrides the title style (size/weight/italic/color).
60    pub fn title_style(mut self, style: TextStyle) -> Self {
61        self.title_style = Some(style);
62        self
63    }
64
65    /// Overrides the bar height (the theme's, else 56 px).
66    pub fn height(mut self, height: f32) -> Self {
67        self.height = Some(height);
68        self
69    }
70
71    /// Adds a back button that emits `message`.
72    ///
73    /// It used to build `IconButton::glyph("←")` — a **character**, at the mercy of
74    /// whatever font is loaded, with no control over its weight and none over its size
75    /// beside the glyphs around it, and named by hand here rather than by the thing that
76    /// knows what it is. [`BackButton`](crate::BackButton) is that thing.
77    pub fn on_back(mut self, message: impl Into<Msg>) -> Self {
78        self.children = vec![Box::new(
79            crate::BackButton::new().icon_size(20.0).on_press(message),
80        )];
81        self
82    }
83}
84
85/// The resolvers, in an impl of their own: the builders above ask for `Msg: Clone +
86/// 'static` because they hold a message, and the `Widget` impl does not — a resolver
87/// declared beside the builders could not be called from the paint.
88impl<Msg> NavigationBar<Msg> {
89    /// `what the caller said ?? what the theme says ?? what the framework ships` — the
90    /// order every property here resolves in, and the one an added theme must not
91    /// disturb: a bar told to be 72 tall is 72 tall in an application whose theme says
92    /// 64.
93    fn bar_height(&self, theme: Option<&Theme>) -> f32 {
94        self.height
95            .or_else(|| theme.and_then(|t| t.widgets.nav_bar.height))
96            .unwrap_or(HEIGHT)
97    }
98
99    /// The bar's padding. There is no per-call override for it, so this is the theme's
100    /// answer or the framework's.
101    fn padding_of(theme: Option<&Theme>) -> Insets {
102        theme
103            .and_then(|t| t.widgets.nav_bar.padding)
104            .unwrap_or(PADDING)
105    }
106
107    /// What the system has taken at the top of the screen and beside it, which a bar at
108    /// the head of a screen is under — read **when it is asked for**, so a
109    /// [`Scaffold`](crate::Scaffold) that has already decided about the status bar, or a
110    /// [`SafeArea`](crate::SafeArea) that has consumed it, is believed.
111    ///
112    /// The bottom is not the bar's: something is under it by definition.
113    fn clearance() -> Insets {
114        let taken = crate::MediaQuery::of().padding;
115        Insets::new(taken.top, taken.right, 0.0, taken.left)
116    }
117
118    /// The bar's height and padding with the clearance added. The intrusion is **added to
119    /// the bar, not taken out of it** — the background runs up behind the status bar while
120    /// the title and the button keep their full height underneath, which is how the
121    /// reference's persistent navigation bar is sized (its height plus the top padding).
122    fn sizing(&self, theme: Option<&Theme>) -> (f32, Insets) {
123        let clear = Self::clearance();
124        let pad = Self::padding_of(theme);
125        (
126            self.bar_height(theme) + clear.top,
127            Insets::new(
128                pad.top + clear.top,
129                pad.right + clear.right,
130                pad.bottom,
131                pad.left + clear.left,
132            ),
133        )
134    }
135}
136
137impl<Msg: Clone> Widget<Msg> for NavigationBar<Msg> {
138    fn style(&self) -> Style {
139        Style {
140            // **The bar fills the width it is offered.** It used to say `Auto`, which in a
141            // row means *hug your children*: given no width by its parent it came out the
142            // size of the back button, and `paint` — which centres the title in the box it
143            // is given, the only thing it can do — put the title underneath the button.
144            //
145            // Every screen in the demo happens to hand it a width, so this never showed in
146            // the application; it showed the first time the widget was rendered on its own
147            // (milestone 296). A chrome that spans the head of a screen has no other
148            // sensible answer to "how wide would you like to be", and an app bar gives the
149            // same one.
150            width: Dimension::Percent(1.0),
151            height: Dimension::Length(self.sizing(None).0),
152            flex_direction: FlexDirection::Row,
153            justify: Justify::Start,
154            align: Align::Center,
155            // **No vertical padding**, and that is the reference's arrangement rather than
156            // a saving: a toolbar is 56 tall and holds a 48-pixel button centred in it
157            // (`constants.dart:27`, `constants.dart:30`), with the four pixels either side
158            // coming from the difference and not from a rule. Six pixels of padding left 44
159            // for the button, which is under the target it now reserves (milestone 442) —
160            // the bar squeezed the one control in it.
161            //
162            // The status bar, when the bar is under one, is added on top of that — see
163            // [`NavigationBar::sizing`].
164            padding: self.sizing(None).1,
165            ..Default::default()
166        }
167    }
168
169    /// The theme has a say in the **height and the padding**, not only the colours: a bar
170    /// that took its colours from the theme and its size from a constant would be themed
171    /// in the half that is easy to see and unthemed in the half that decides where
172    /// everything under it starts.
173    fn style_themed(&self, theme: &Theme) -> Style {
174        let (height, padding) = self.sizing(Some(theme));
175        Style {
176            height: Dimension::Length(height),
177            padding,
178            ..Widget::<Msg>::style(self)
179        }
180    }
181
182    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
183        &self.children
184    }
185
186    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
187        let o = status.opacity;
188        let t = &theme.widgets.nav_bar;
189        // Background + a thin bottom separator.
190        let background: Color = t.background.unwrap_or(theme.background);
191        scene.fill_rect(bounds, background.fade(o));
192        // The hairline never draws thicker than the bar it sits in, the way a divider's
193        // never outgrows its own box.
194        let thickness = t
195            .divider_thickness
196            .unwrap_or(DIVIDER_THICKNESS)
197            .min(bounds.height);
198        let rule: Color = t.divider_color.unwrap_or(theme.scheme.outline_variant);
199        scene.fill_rect(
200            Rect::new(
201                bounds.x,
202                bounds.y + bounds.height - thickness,
203                bounds.width,
204                thickness,
205            ),
206            rule.fade(o),
207        );
208
209        // The title is centred in the part of the bar **below the status bar and between
210        // the cutouts** — the background above runs behind the system's bar, the title
211        // does not — following `title_style` (the style's color is inherited from the
212        // theme when absent).
213        let clear = Self::clearance();
214        let room = Rect::new(
215            bounds.x + clear.left,
216            bounds.y + clear.top,
217            (bounds.width - clear.left - clear.right).max(0.0),
218            (bounds.height - clear.top).max(0.0),
219        );
220        let style = title_style_of(self.title_style, theme);
221        let measured = frus_text::measure_style(&self.title, style);
222        let tx = room.x + (room.width - measured.width) * 0.5;
223        let ty = room.y + (room.height - measured.height) * 0.5;
224        scene.text(
225            Point::new(tx, ty),
226            self.title.clone(),
227            &style.resolved(),
228            style.color.unwrap_or(theme.on_surface).fade(o),
229        );
230    }
231
232    fn on_click(&self) -> Option<Msg> {
233        None
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::{build_ui, Runtime, Size};
241    use frus_core::Color;
242    use frus_core::Primitive;
243
244    #[derive(Clone, Debug, PartialEq)]
245    enum Msg {
246        Back,
247    }
248
249    #[test]
250    fn root_bar_has_no_back_button() {
251        let bar: NavigationBar<Msg> = NavigationBar::new("Home");
252        assert!(Widget::children(&bar).is_empty());
253    }
254
255    #[test]
256    fn title_style_and_height_are_customizable() {
257        // Overridden title style and height (defaults: medium 20, 56 px).
258        let bar: NavigationBar<Msg> = NavigationBar::new("Title")
259            .title_style(TextStyle::new(24.0).weight(FontWeight::Bold).italic())
260            .height(72.0);
261        match Widget::style(&bar).height {
262            Dimension::Length(h) => assert_eq!(h, 72.0),
263            _ => panic!("an imposed height was expected"),
264        }
265        let ui = build_ui(
266            &bar,
267            Size::new(400.0, 72.0),
268            &Runtime::default(),
269            &Theme::default(),
270        );
271        let styled = ui.scene().primitives().iter().any(|p| {
272            matches!(
273                p,
274                Primitive::Text { text, size, weight, italic, .. }
275                    if text == "Title" && *size == 24.0 && *weight == FontWeight::Bold && *italic
276            )
277        });
278        assert!(styled, "the title must carry the overridden style");
279    }
280
281    #[test]
282    fn back_button_emits_message() {
283        let bar: NavigationBar<Msg> = NavigationBar::new("Settings").on_back(Msg::Back);
284        let ui = build_ui(
285            &bar,
286            Size::new(400.0, 56.0),
287            &Runtime::default(),
288            &Theme::default(),
289        );
290        // The back button is on the left; a click there returns the back message.
291        let id = ui.hit(Point::new(40.0, 28.0)).expect("back button");
292        assert_eq!(ui.msg_for(id), Some(Msg::Back));
293    }
294
295    /// **A bar given no width used to hug its back button**, and `paint` — which centres
296    /// the title in the box it is handed, the only thing it can do — put the title
297    /// underneath the button.
298    ///
299    /// A row is the arrangement that shows it: a row hands each child the width it asks
300    /// for, and `Auto` asks for the width of its contents. Every screen in the demo
301    /// happens to give the bar a width, which is why this survived 296 milestones without
302    /// being seen and then showed the first time the widget was rendered on its own.
303    #[test]
304    fn a_bar_given_no_width_still_spans_what_it_is_offered() {
305        const FRAME: f32 = 400.0;
306        let bar: NavigationBar<Msg> = NavigationBar::new("Settings").on_back(Msg::Back);
307        // A row of a known width whose child is asked for its own: the row spans the
308        // frame, and a child saying `Auto` there is handed exactly what it hugs. The
309        // width has to be definite for the bar's own answer to mean anything — a
310        // percentage of an undecided width is undecided too.
311        let root = crate::Flex::row().width(FRAME).child(bar);
312        let theme = Theme::default();
313        let ui = build_ui(&root, Size::new(FRAME, HEIGHT), &Runtime::default(), &theme);
314        let at = ui
315            .scene()
316            .primitives()
317            .iter()
318            .find_map(|p| match p {
319                Primitive::Text { text, position, .. } if text == "Settings" => Some(*position),
320                _ => None,
321            })
322            .expect("the title is painted");
323        let measured = frus_text::measure_style("Settings", title_style_of(None, &theme));
324        let centred = (FRAME - measured.width) * 0.5;
325        assert!(
326            (at.x - centred).abs() < 1.0,
327            "the title is centred in the frame: {} against {centred}",
328            at.x
329        );
330        // And the thing the bug actually looked like: the title sat on the button.
331        assert!(
332            at.x > PAD_LEFT + crate::ICON_BUTTON_SIZE,
333            "the title is clear of the back button: {} against {}",
334            at.x,
335            PAD_LEFT + crate::ICON_BUTTON_SIZE
336        );
337    }
338
339    /// Every rectangle the bar paints, in order: the background first, the hairline on
340    /// top of it.
341    fn rects(bar: &NavigationBar<Msg>, theme: &Theme, frame: Size) -> Vec<(Rect, Color)> {
342        let ui = build_ui(bar, frame, &Runtime::default(), theme);
343        ui.scene()
344            .primitives()
345            .iter()
346            .filter_map(|p| match p {
347                Primitive::Rect { rect, color, .. } => Some((*rect, *color)),
348                _ => None,
349            })
350            .collect()
351    }
352
353    /// **Every field of the theme reaches the painting** — and the two that are not
354    /// colours are the ones worth naming, because a bar themed in its colours and sized
355    /// from a constant is themed in the half that shows and unthemed in the half that
356    /// decides where everything under it starts.
357    ///
358    /// The colours are asserted on the scene rather than on a rendered pixel: this widget
359    /// hands an opaque colour straight to `fill_rect` and does no blending arithmetic of
360    /// its own, and that a colour asked for is the colour painted is pinned once for the
361    /// quad path in `frus-test`'s `painted_colours`, not once per widget.
362    #[test]
363    fn a_theme_reaches_every_property_the_bar_paints() {
364        const BACKGROUND: Color = Color::rgb(0.10, 0.20, 0.30);
365        const RULE: Color = Color::rgb(0.90, 0.10, 0.40);
366        let mut theme = Theme::default();
367        theme.widgets.nav_bar.height = Some(64.0);
368        theme.widgets.nav_bar.padding = Insets::new(0.0, 4.0, 0.0, 40.0).into();
369        theme.widgets.nav_bar.background = Some(BACKGROUND);
370        theme.widgets.nav_bar.divider_color = Some(RULE);
371        theme.widgets.nav_bar.divider_thickness = Some(3.0);
372        theme.widgets.nav_bar.title_style = Some(TextStyle::new(18.0).weight(FontWeight::Bold));
373
374        let bar: NavigationBar<Msg> = NavigationBar::new("Title").on_back(Msg::Back);
375        // The height and the padding are layout, so they have to come back through the
376        // themed style — the unthemed one is what a parent asks before a theme exists.
377        match Widget::<Msg>::style_themed(&bar, &theme).height {
378            Dimension::Length(h) => assert_eq!(h, 64.0, "the theme's height"),
379            other => panic!("a definite height was expected, got {other:?}"),
380        }
381        assert_eq!(
382            Widget::<Msg>::style_themed(&bar, &theme).padding,
383            Insets::new(0.0, 4.0, 0.0, 40.0),
384            "the theme's padding"
385        );
386
387        let painted = rects(&bar, &theme, Size::new(400.0, 64.0));
388        let (bg_rect, bg) = painted[0];
389        assert_eq!(bg, BACKGROUND, "the theme's background");
390        assert_eq!(bg_rect.height, 64.0, "and it fills the themed height");
391        let (rule_rect, rule) = painted[1];
392        assert_eq!(rule, RULE, "the theme's hairline colour");
393        assert_eq!(rule_rect.height, 3.0, "and its thickness");
394        assert_eq!(
395            rule_rect.y + rule_rect.height,
396            bg_rect.y + bg_rect.height,
397            "the hairline sits on the bottom edge whatever it weighs"
398        );
399
400        let ui = build_ui(&bar, Size::new(400.0, 64.0), &Runtime::default(), &theme);
401        let styled = ui.scene().primitives().iter().any(|p| {
402            matches!(p, Primitive::Text { text, size, weight, .. }
403                if text == "Title" && *size == 18.0 && *weight == FontWeight::Bold)
404        });
405        assert!(styled, "the theme's title style");
406    }
407
408    /// **What the caller said outranks what the theme says.** The middle term of
409    /// `caller ?? theme ?? framework` is the one an added theme can quietly promote, and
410    /// a bar told to be 72 tall in an application whose theme says 64 is 72 tall.
411    #[test]
412    fn a_call_sites_own_values_beat_the_theme() {
413        let mut theme = Theme::default();
414        theme.widgets.nav_bar.height = Some(64.0);
415        theme.widgets.nav_bar.title_style = Some(TextStyle::new(18.0));
416
417        let bar: NavigationBar<Msg> = NavigationBar::new("Title")
418            .height(72.0)
419            .title_style(TextStyle::new(30.0));
420        match Widget::<Msg>::style_themed(&bar, &theme).height {
421            Dimension::Length(h) => assert_eq!(h, 72.0, "the caller's height, not the theme's"),
422            other => panic!("a definite height was expected, got {other:?}"),
423        }
424        let ui = build_ui(&bar, Size::new(400.0, 72.0), &Runtime::default(), &theme);
425        let sized = ui.scene().primitives().iter().any(
426            |p| matches!(p, Primitive::Text { text, size, .. } if text == "Title" && *size == 30.0),
427        );
428        assert!(sized, "the caller's title style, not the theme's");
429
430        // And with nothing said at the call site, the theme is what answers — otherwise
431        // the test above would pass on a bar that ignores the theme entirely.
432        let plain: NavigationBar<Msg> = NavigationBar::new("Title");
433        match Widget::<Msg>::style_themed(&plain, &theme).height {
434            Dimension::Length(h) => assert_eq!(h, 64.0, "the theme's height"),
435            other => panic!("a definite height was expected, got {other:?}"),
436        }
437    }
438
439    /// The bar's own background, told apart from anything a shell around it paints.
440    const BAR_BG: Color = Color::rgb(0.12, 0.34, 0.56);
441    const STATUS: f32 = 48.0;
442    const SCREEN: Size = Size::new(400.0, 800.0);
443
444    /// A phone: a status bar of [`STATUS`] at the top, nothing else taken.
445    fn phone() -> crate::MediaQuery {
446        crate::MediaQuery::new(SCREEN).with_insets(frus_core::WindowInsets::bars(Insets::new(
447            STATUS, 0.0, 0.0, 0.0,
448        )))
449    }
450
451    /// Where the title is painted and where the bar's background is, for `root` built
452    /// and walked under `surface` — and the built frame, to click on.
453    fn painted_under(
454        surface: crate::MediaQuery,
455        root: &dyn Widget<Msg>,
456    ) -> (Point, Rect, crate::Ui<Msg>) {
457        let mut theme = Theme::default();
458        theme.widgets.nav_bar.background = Some(BAR_BG);
459        let ui = surface.scope(|| build_ui(root, SCREEN, &Runtime::default(), &theme));
460        let title = ui
461            .scene()
462            .primitives()
463            .iter()
464            .find_map(|p| match p {
465                Primitive::Text { text, position, .. } if text == "Task" => Some(*position),
466                _ => None,
467            })
468            .expect("the title is painted");
469        let background = ui
470            .scene()
471            .primitives()
472            .iter()
473            .find_map(|p| match p {
474                Primitive::Rect { rect, color, .. } if *color == BAR_BG => Some(*rect),
475                _ => None,
476            })
477            .expect("the bar's background is painted");
478        (title, background, ui)
479    }
480
481    fn screen_with(bar: NavigationBar<Msg>) -> crate::Flex<Msg> {
482        crate::Flex::column()
483            .width(SCREEN.width)
484            .height(SCREEN.height)
485            .child(bar)
486    }
487
488    /// **A bar at the head of a screen drew under the status bar** (milestone 504), and
489    /// always had: the title and the back button sat behind the clock. The scaffold told
490    /// its app-bar slot what the status bar took, `AppBar` read it, and this bar never
491    /// did. Found on a device while checking the colour of the system bars (#46).
492    ///
493    /// The intrusion is added to the bar, as a drawer header adds it: the background runs
494    /// up behind the status bar and the content keeps its full height below.
495    #[test]
496    fn a_bar_at_the_head_of_a_screen_clears_the_status_bar() {
497        let (flat, _, _) = painted_under(
498            crate::MediaQuery::new(SCREEN),
499            &screen_with(NavigationBar::new("Task")),
500        );
501        let (title, background, ui) = painted_under(
502            phone(),
503            &screen_with(NavigationBar::new("Task").on_back(Msg::Back)),
504        );
505        assert_eq!(
506            background.y, 0.0,
507            "the background runs behind the status bar"
508        );
509        assert_eq!(
510            background.height,
511            HEIGHT + STATUS,
512            "and the bar grows by it rather than giving it up"
513        );
514        assert!(
515            (title.y - (flat.y + STATUS)).abs() < 0.5,
516            "the title is centred below the status bar: {} against {}",
517            title.y,
518            flat.y + STATUS
519        );
520        // The button moved with it: pressed below the status bar it answers, and behind
521        // the clock there is nothing of it.
522        let below = ui
523            .hit(Point::new(40.0, STATUS + HEIGHT / 2.0))
524            .expect("the back button, below the status bar");
525        assert_eq!(ui.msg_for(below), Some(Msg::Back));
526        assert!(
527            ui.hit(Point::new(40.0, STATUS / 2.0))
528                .and_then(|id| ui.msg_for(id))
529                .is_none(),
530            "the back button is not under the status bar"
531        );
532    }
533
534    /// **A bar inside a `SafeArea` is not pushed down twice.** Most of the demo's screens
535    /// are a column in a `SafeArea` with this bar at the head; the safe area has taken the
536    /// status bar, so the bar must be told there is nothing left to clear.
537    #[test]
538    fn a_bar_in_a_safe_area_does_not_clear_the_status_bar_again() {
539        let (flat, _, _) = painted_under(
540            crate::MediaQuery::new(SCREEN),
541            &screen_with(NavigationBar::new("Task")),
542        );
543        let root = crate::Flex::column()
544            .width(SCREEN.width)
545            .height(SCREEN.height)
546            .child(crate::SafeArea::new(
547                crate::Flex::column().child(NavigationBar::new("Task")),
548            ));
549        let (title, background, _) = painted_under(phone(), &root);
550        assert_eq!(
551            background.y, STATUS,
552            "the safe area holds the bar below the notch"
553        );
554        assert_eq!(
555            background.height, HEIGHT,
556            "and the bar adds nothing of its own"
557        );
558        assert!(
559            (title.y - (flat.y + STATUS)).abs() < 0.5,
560            "the title is one status bar down, not two: {} against {}",
561            title.y,
562            flat.y + STATUS
563        );
564    }
565
566    /// In a scaffold's app-bar slot — the demo's task screen — the shell tells the bar what
567    /// the status bar takes, and the bar clears it as it does at the head of a column.
568    #[test]
569    fn a_bar_in_a_scaffolds_app_bar_slot_clears_the_status_bar() {
570        let (flat, _, _) = painted_under(
571            crate::MediaQuery::new(SCREEN),
572            &screen_with(NavigationBar::new("Task")),
573        );
574        // Built under the surface, as an application's view is: the scaffold reads its
575        // intrusions when it is constructed.
576        let scaffold = phone().scope(|| {
577            crate::Scaffold::new()
578                .size(SCREEN.width, SCREEN.height)
579                .app_bar(NavigationBar::new("Task"))
580                .body(crate::Container::new().flex(1.0))
581                .build()
582        });
583        let (title, background, _) = painted_under(phone(), &scaffold);
584        assert_eq!(background.y, 0.0);
585        assert_eq!(background.height, HEIGHT + STATUS);
586        assert!((title.y - (flat.y + STATUS)).abs() < 0.5, "{}", title.y);
587    }
588
589    #[test]
590    fn bar_paints_title_and_divider() {
591        let bar: NavigationBar<Msg> = NavigationBar::new("Title");
592        let ui = build_ui(
593            &bar,
594            Size::new(400.0, 56.0),
595            &Runtime::default(),
596            &Theme::default(),
597        );
598        let has_text = ui
599            .scene()
600            .primitives()
601            .iter()
602            .any(|p| matches!(p, Primitive::Text { text, .. } if text == "Title"));
603        assert!(has_text, "the title is painted");
604    }
605}