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    /// The bar's title is the **screen's heading**, what a screen reader's user moves
237    /// between, as an app bar's is. The bar paints it itself, so no text below says it, and
238    /// until milestone 700 the screen had no name at all.
239    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
240        (!self.title.is_empty()).then(|| {
241            frus_core::SemanticsProperties::new(frus_core::Role::Heading).label(self.title.clone())
242        })
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::{build_ui, Runtime, Size};
250    use frus_core::Color;
251    use frus_core::Primitive;
252
253    #[derive(Clone, Debug, PartialEq)]
254    enum Msg {
255        Back,
256    }
257
258    /// **The title is the screen's heading** in the accessibility tree; the back button is
259    /// a button of its own beside it.
260    #[test]
261    fn the_title_is_the_screens_heading() {
262        let bar: NavigationBar<Msg> = NavigationBar::new("Task").on_back(Msg::Back);
263        let ui = build_ui(
264            &bar,
265            Size::new(400.0, 56.0),
266            &Runtime::default(),
267            &Theme::default(),
268        );
269        let heading = ui
270            .semantics()
271            .iter()
272            .find(|(_, _, s)| s.role == frus_core::Role::Heading)
273            .map(|(_, _, s)| s.label.clone());
274        assert_eq!(heading, Some(Some("Task".to_string())));
275        assert!(Widget::semantics(&NavigationBar::<Msg>::new("")).is_none());
276    }
277
278    #[test]
279    fn root_bar_has_no_back_button() {
280        let bar: NavigationBar<Msg> = NavigationBar::new("Home");
281        assert!(Widget::children(&bar).is_empty());
282    }
283
284    #[test]
285    fn title_style_and_height_are_customizable() {
286        // Overridden title style and height (defaults: medium 20, 56 px).
287        let bar: NavigationBar<Msg> = NavigationBar::new("Title")
288            .title_style(TextStyle::new(24.0).weight(FontWeight::Bold).italic())
289            .height(72.0);
290        match Widget::style(&bar).height {
291            Dimension::Length(h) => assert_eq!(h, 72.0),
292            _ => panic!("an imposed height was expected"),
293        }
294        let ui = build_ui(
295            &bar,
296            Size::new(400.0, 72.0),
297            &Runtime::default(),
298            &Theme::default(),
299        );
300        let styled = ui.scene().primitives().iter().any(|p| {
301            matches!(
302                p,
303                Primitive::Text { text, size, weight, italic, .. }
304                    if text == "Title" && *size == 24.0 && *weight == FontWeight::Bold && *italic
305            )
306        });
307        assert!(styled, "the title must carry the overridden style");
308    }
309
310    #[test]
311    fn back_button_emits_message() {
312        let bar: NavigationBar<Msg> = NavigationBar::new("Settings").on_back(Msg::Back);
313        let ui = build_ui(
314            &bar,
315            Size::new(400.0, 56.0),
316            &Runtime::default(),
317            &Theme::default(),
318        );
319        // The back button is on the left; a click there returns the back message.
320        let id = ui.hit(Point::new(40.0, 28.0)).expect("back button");
321        assert_eq!(ui.msg_for(id), Some(Msg::Back));
322    }
323
324    /// **A bar given no width used to hug its back button**, and `paint` — which centres
325    /// the title in the box it is handed, the only thing it can do — put the title
326    /// underneath the button.
327    ///
328    /// A row is the arrangement that shows it: a row hands each child the width it asks
329    /// for, and `Auto` asks for the width of its contents. Every screen in the demo
330    /// happens to give the bar a width, which is why this survived 296 milestones without
331    /// being seen and then showed the first time the widget was rendered on its own.
332    #[test]
333    fn a_bar_given_no_width_still_spans_what_it_is_offered() {
334        const FRAME: f32 = 400.0;
335        let bar: NavigationBar<Msg> = NavigationBar::new("Settings").on_back(Msg::Back);
336        // A row of a known width whose child is asked for its own: the row spans the
337        // frame, and a child saying `Auto` there is handed exactly what it hugs. The
338        // width has to be definite for the bar's own answer to mean anything — a
339        // percentage of an undecided width is undecided too.
340        let root = crate::Flex::row().width(FRAME).child(bar);
341        let theme = Theme::default();
342        let ui = build_ui(&root, Size::new(FRAME, HEIGHT), &Runtime::default(), &theme);
343        let at = ui
344            .scene()
345            .primitives()
346            .iter()
347            .find_map(|p| match p {
348                Primitive::Text { text, position, .. } if text == "Settings" => Some(*position),
349                _ => None,
350            })
351            .expect("the title is painted");
352        let measured = frus_text::measure_style("Settings", title_style_of(None, &theme));
353        let centred = (FRAME - measured.width) * 0.5;
354        assert!(
355            (at.x - centred).abs() < 1.0,
356            "the title is centred in the frame: {} against {centred}",
357            at.x
358        );
359        // And the thing the bug actually looked like: the title sat on the button.
360        assert!(
361            at.x > PAD_LEFT + crate::ICON_BUTTON_SIZE,
362            "the title is clear of the back button: {} against {}",
363            at.x,
364            PAD_LEFT + crate::ICON_BUTTON_SIZE
365        );
366    }
367
368    /// Every rectangle the bar paints, in order: the background first, the hairline on
369    /// top of it.
370    fn rects(bar: &NavigationBar<Msg>, theme: &Theme, frame: Size) -> Vec<(Rect, Color)> {
371        let ui = build_ui(bar, frame, &Runtime::default(), theme);
372        ui.scene()
373            .primitives()
374            .iter()
375            .filter_map(|p| match p {
376                Primitive::Rect { rect, color, .. } => Some((*rect, *color)),
377                _ => None,
378            })
379            .collect()
380    }
381
382    /// **Every field of the theme reaches the painting** — and the two that are not
383    /// colours are the ones worth naming, because a bar themed in its colours and sized
384    /// from a constant is themed in the half that shows and unthemed in the half that
385    /// decides where everything under it starts.
386    ///
387    /// The colours are asserted on the scene rather than on a rendered pixel: this widget
388    /// hands an opaque colour straight to `fill_rect` and does no blending arithmetic of
389    /// its own, and that a colour asked for is the colour painted is pinned once for the
390    /// quad path in `frus-test`'s `painted_colours`, not once per widget.
391    #[test]
392    fn a_theme_reaches_every_property_the_bar_paints() {
393        const BACKGROUND: Color = Color::rgb(0.10, 0.20, 0.30);
394        const RULE: Color = Color::rgb(0.90, 0.10, 0.40);
395        let mut theme = Theme::default();
396        theme.widgets.nav_bar.height = Some(64.0);
397        theme.widgets.nav_bar.padding = Insets::new(0.0, 4.0, 0.0, 40.0).into();
398        theme.widgets.nav_bar.background = Some(BACKGROUND);
399        theme.widgets.nav_bar.divider_color = Some(RULE);
400        theme.widgets.nav_bar.divider_thickness = Some(3.0);
401        theme.widgets.nav_bar.title_style = Some(TextStyle::new(18.0).weight(FontWeight::Bold));
402
403        let bar: NavigationBar<Msg> = NavigationBar::new("Title").on_back(Msg::Back);
404        // The height and the padding are layout, so they have to come back through the
405        // themed style — the unthemed one is what a parent asks before a theme exists.
406        match Widget::<Msg>::style_themed(&bar, &theme).height {
407            Dimension::Length(h) => assert_eq!(h, 64.0, "the theme's height"),
408            other => panic!("a definite height was expected, got {other:?}"),
409        }
410        assert_eq!(
411            Widget::<Msg>::style_themed(&bar, &theme).padding,
412            Insets::new(0.0, 4.0, 0.0, 40.0),
413            "the theme's padding"
414        );
415
416        let painted = rects(&bar, &theme, Size::new(400.0, 64.0));
417        let (bg_rect, bg) = painted[0];
418        assert_eq!(bg, BACKGROUND, "the theme's background");
419        assert_eq!(bg_rect.height, 64.0, "and it fills the themed height");
420        let (rule_rect, rule) = painted[1];
421        assert_eq!(rule, RULE, "the theme's hairline colour");
422        assert_eq!(rule_rect.height, 3.0, "and its thickness");
423        assert_eq!(
424            rule_rect.y + rule_rect.height,
425            bg_rect.y + bg_rect.height,
426            "the hairline sits on the bottom edge whatever it weighs"
427        );
428
429        let ui = build_ui(&bar, Size::new(400.0, 64.0), &Runtime::default(), &theme);
430        let styled = ui.scene().primitives().iter().any(|p| {
431            matches!(p, Primitive::Text { text, size, weight, .. }
432                if text == "Title" && *size == 18.0 && *weight == FontWeight::Bold)
433        });
434        assert!(styled, "the theme's title style");
435    }
436
437    /// **What the caller said outranks what the theme says.** The middle term of
438    /// `caller ?? theme ?? framework` is the one an added theme can quietly promote, and
439    /// a bar told to be 72 tall in an application whose theme says 64 is 72 tall.
440    #[test]
441    fn a_call_sites_own_values_beat_the_theme() {
442        let mut theme = Theme::default();
443        theme.widgets.nav_bar.height = Some(64.0);
444        theme.widgets.nav_bar.title_style = Some(TextStyle::new(18.0));
445
446        let bar: NavigationBar<Msg> = NavigationBar::new("Title")
447            .height(72.0)
448            .title_style(TextStyle::new(30.0));
449        match Widget::<Msg>::style_themed(&bar, &theme).height {
450            Dimension::Length(h) => assert_eq!(h, 72.0, "the caller's height, not the theme's"),
451            other => panic!("a definite height was expected, got {other:?}"),
452        }
453        let ui = build_ui(&bar, Size::new(400.0, 72.0), &Runtime::default(), &theme);
454        let sized = ui.scene().primitives().iter().any(
455            |p| matches!(p, Primitive::Text { text, size, .. } if text == "Title" && *size == 30.0),
456        );
457        assert!(sized, "the caller's title style, not the theme's");
458
459        // And with nothing said at the call site, the theme is what answers — otherwise
460        // the test above would pass on a bar that ignores the theme entirely.
461        let plain: NavigationBar<Msg> = NavigationBar::new("Title");
462        match Widget::<Msg>::style_themed(&plain, &theme).height {
463            Dimension::Length(h) => assert_eq!(h, 64.0, "the theme's height"),
464            other => panic!("a definite height was expected, got {other:?}"),
465        }
466    }
467
468    /// The bar's own background, told apart from anything a shell around it paints.
469    const BAR_BG: Color = Color::rgb(0.12, 0.34, 0.56);
470    const STATUS: f32 = 48.0;
471    const SCREEN: Size = Size::new(400.0, 800.0);
472
473    /// A phone: a status bar of [`STATUS`] at the top, nothing else taken.
474    fn phone() -> crate::MediaQuery {
475        crate::MediaQuery::new(SCREEN).with_insets(frus_core::WindowInsets::bars(Insets::new(
476            STATUS, 0.0, 0.0, 0.0,
477        )))
478    }
479
480    /// Where the title is painted and where the bar's background is, for `root` built
481    /// and walked under `surface` — and the built frame, to click on.
482    fn painted_under(
483        surface: crate::MediaQuery,
484        root: &dyn Widget<Msg>,
485    ) -> (Point, Rect, crate::Ui<Msg>) {
486        let mut theme = Theme::default();
487        theme.widgets.nav_bar.background = Some(BAR_BG);
488        let ui = surface.scope(|| build_ui(root, SCREEN, &Runtime::default(), &theme));
489        let title = ui
490            .scene()
491            .primitives()
492            .iter()
493            .find_map(|p| match p {
494                Primitive::Text { text, position, .. } if text == "Task" => Some(*position),
495                _ => None,
496            })
497            .expect("the title is painted");
498        let background = ui
499            .scene()
500            .primitives()
501            .iter()
502            .find_map(|p| match p {
503                Primitive::Rect { rect, color, .. } if *color == BAR_BG => Some(*rect),
504                _ => None,
505            })
506            .expect("the bar's background is painted");
507        (title, background, ui)
508    }
509
510    fn screen_with(bar: NavigationBar<Msg>) -> crate::Flex<Msg> {
511        crate::Flex::column()
512            .width(SCREEN.width)
513            .height(SCREEN.height)
514            .child(bar)
515    }
516
517    /// **A bar at the head of a screen drew under the status bar** (milestone 504), and
518    /// always had: the title and the back button sat behind the clock. The scaffold told
519    /// its app-bar slot what the status bar took, `AppBar` read it, and this bar never
520    /// did. Found on a device while checking the colour of the system bars (#46).
521    ///
522    /// The intrusion is added to the bar, as a drawer header adds it: the background runs
523    /// up behind the status bar and the content keeps its full height below.
524    #[test]
525    fn a_bar_at_the_head_of_a_screen_clears_the_status_bar() {
526        let (flat, _, _) = painted_under(
527            crate::MediaQuery::new(SCREEN),
528            &screen_with(NavigationBar::new("Task")),
529        );
530        let (title, background, ui) = painted_under(
531            phone(),
532            &screen_with(NavigationBar::new("Task").on_back(Msg::Back)),
533        );
534        assert_eq!(
535            background.y, 0.0,
536            "the background runs behind the status bar"
537        );
538        assert_eq!(
539            background.height,
540            HEIGHT + STATUS,
541            "and the bar grows by it rather than giving it up"
542        );
543        assert!(
544            (title.y - (flat.y + STATUS)).abs() < 0.5,
545            "the title is centred below the status bar: {} against {}",
546            title.y,
547            flat.y + STATUS
548        );
549        // The button moved with it: pressed below the status bar it answers, and behind
550        // the clock there is nothing of it.
551        let below = ui
552            .hit(Point::new(40.0, STATUS + HEIGHT / 2.0))
553            .expect("the back button, below the status bar");
554        assert_eq!(ui.msg_for(below), Some(Msg::Back));
555        assert!(
556            ui.hit(Point::new(40.0, STATUS / 2.0))
557                .and_then(|id| ui.msg_for(id))
558                .is_none(),
559            "the back button is not under the status bar"
560        );
561    }
562
563    /// **A bar inside a `SafeArea` is not pushed down twice.** Most of the demo's screens
564    /// are a column in a `SafeArea` with this bar at the head; the safe area has taken the
565    /// status bar, so the bar must be told there is nothing left to clear.
566    #[test]
567    fn a_bar_in_a_safe_area_does_not_clear_the_status_bar_again() {
568        let (flat, _, _) = painted_under(
569            crate::MediaQuery::new(SCREEN),
570            &screen_with(NavigationBar::new("Task")),
571        );
572        let root = crate::Flex::column()
573            .width(SCREEN.width)
574            .height(SCREEN.height)
575            .child(crate::SafeArea::new(
576                crate::Flex::column().child(NavigationBar::new("Task")),
577            ));
578        let (title, background, _) = painted_under(phone(), &root);
579        assert_eq!(
580            background.y, STATUS,
581            "the safe area holds the bar below the notch"
582        );
583        assert_eq!(
584            background.height, HEIGHT,
585            "and the bar adds nothing of its own"
586        );
587        assert!(
588            (title.y - (flat.y + STATUS)).abs() < 0.5,
589            "the title is one status bar down, not two: {} against {}",
590            title.y,
591            flat.y + STATUS
592        );
593    }
594
595    /// In a scaffold's app-bar slot — the demo's task screen — the shell tells the bar what
596    /// the status bar takes, and the bar clears it as it does at the head of a column.
597    #[test]
598    fn a_bar_in_a_scaffolds_app_bar_slot_clears_the_status_bar() {
599        let (flat, _, _) = painted_under(
600            crate::MediaQuery::new(SCREEN),
601            &screen_with(NavigationBar::new("Task")),
602        );
603        // Built under the surface, as an application's view is: the scaffold reads its
604        // intrusions when it is constructed.
605        let scaffold = phone().scope(|| {
606            crate::Scaffold::new()
607                .size(SCREEN.width, SCREEN.height)
608                .app_bar(NavigationBar::new("Task"))
609                .body(crate::Container::new().flex(1.0))
610                .build()
611        });
612        let (title, background, _) = painted_under(phone(), &scaffold);
613        assert_eq!(background.y, 0.0);
614        assert_eq!(background.height, HEIGHT + STATUS);
615        assert!((title.y - (flat.y + STATUS)).abs() < 0.5, "{}", title.y);
616    }
617
618    #[test]
619    fn bar_paints_title_and_divider() {
620        let bar: NavigationBar<Msg> = NavigationBar::new("Title");
621        let ui = build_ui(
622            &bar,
623            Size::new(400.0, 56.0),
624            &Runtime::default(),
625            &Theme::default(),
626        );
627        let has_text = ui
628            .scene()
629            .primitives()
630            .iter()
631            .any(|p| matches!(p, Primitive::Text { text, .. } if text == "Title"));
632        assert!(has_text, "the title is painted");
633    }
634}