Skip to main content

frus_widgets/
appbar.rs

1//! [`AppBar`]: an **adaptive application bar**, Material style.
2//!
3//! The developer declares an optional title, an optional `leading` and a list of
4//! **actions**, each any widget — typically icon buttons. Beside them, a bar may have
5//! **foldable actions**: text buttons the bar shows inline while they fit and **folds
6//! into an overflow menu** when the **available width** runs out, never asking whether
7//! this is mobile or desktop. A wide screen → everything inline; a narrow phone →
8//! overflow.
9//!
10//! **Everything is customisable** (themed defaults, never imposed): the title is any
11//! widget (`title`) with a themed type (`title_style`), every action is any widget, and
12//! the spacing, the action size, the background and the height can all be overridden.
13//!
14//! ```ignore
15//! AppBar::new()
16//!     .title(Text::new("My Tasks"))           // any widget; a bar may have none
17//!     .leading(IconButton::new(Icons::MENU).on_press(Msg::ToggleMenu))
18//!     .action(IconButton::new(Icons::SEARCH).on_press(Msg::Search))  // any widget
19//!     .action(Badge::new("3"))
20//!     .overflow(app.menu_open, Msg::ToggleMenu)
21//!     .foldable_action("Pause", Msg::ToggleTimer) // folds when the bar is narrow
22//!     .foldable_action("Settings", Msg::OpenSettings)
23//!     .build()
24//! ```
25
26#[cfg(test)]
27use frus_core::FontWeight;
28use frus_core::{BorderRadius, Color, Rect, ShapeBorder, TextStyle};
29use frus_layout::{Align, Dimension};
30
31use crate::button::Variant;
32use crate::constraints::ConstrainedBox;
33use crate::container::Container;
34use crate::dsl::button;
35use crate::flex::Flex;
36use crate::media::MediaQuery;
37use crate::menu::PopupMenuButton;
38#[cfg(test)]
39use crate::text::Text;
40use crate::theme::Theme;
41use crate::widget::Widget;
42use crate::widgettheme::{DefaultTextStyle, IconTheme};
43
44/// Does a bar following `platform`'s conventions centre its title by default?
45///
46/// Where the system centres its own — iOS and macOS — so does a bar that has not been
47/// told otherwise, and **only while there is room to read it that way**: past one action
48/// the title goes back to being flush after the leading, because a centred title
49/// squeezed between a leading and three buttons is neither centred nor readable. Every
50/// other platform starts the title after the leading and leaves it there.
51///
52/// The reference's rule (`app_bar.dart:805`), read from the **theme's** platform, so a
53/// theme set to iOS centres the title on an Android phone (milestone 615).
54/// [`AppBar::center_title`] overrides it either way.
55pub const fn platform_centers_title(platform: frus_core::TargetPlatform, actions: usize) -> bool {
56    use frus_core::TargetPlatform as P;
57    match platform {
58        P::Ios | P::MacOs => actions < 2,
59        P::Android | P::Fuchsia | P::Linux | P::Windows => false,
60    }
61}
62
63/// The bar's height, the reference's Material 3 `toolbarHeight`.
64///
65/// A **fixed** height, not the content's: chrome that changes height with what happens
66/// to be in it makes every screen a slightly different shape, and the page below it move
67/// when an action appears. [`AppBar::height`] overrides it.
68pub const APP_BAR_HEIGHT: f32 = 64.0;
69/// The title's type: what the caller said, else what the theme's `app_bar` says, else the
70/// step of the type scale the reference names — `titleLarge`.
71///
72/// It used to be a private `TextStyle::new(22.0).weight(Medium)`, which had the size right
73/// and **the weight wrong**: `titleLarge` is regular. A number written beside the scale is a
74/// number that can drift from it without anybody seeing, which is milestone 413's lesson and
75/// this is the same constant one file over.
76fn title_style_of(over: Option<TextStyle>, theme: &Theme) -> TextStyle {
77    over.or(theme.widgets.app_bar.title_style)
78        .unwrap_or(theme.text.title_large)
79}
80/// How far the reader's font setting may enlarge the **title**, and no further.
81///
82/// A bar is chrome: it keeps [`APP_BAR_HEIGHT`] whatever the reader asked for, because a
83/// toolbar that grew with the type would push every screen down. So the reference caps the
84/// title's scaler rather than the bar's height — the same 1.34 — "to keep the visual
85/// hierarchy the same even with larger font sizes". A caller who wants the whole scale
86/// gives the title its own [`AppBar::title_style`] and their own height.
87pub const APP_BAR_MAX_TITLE_SCALE: f32 = 1.34;
88/// The actions' size: what the caller said, else the step the reference gives them.
89///
90/// A bar's actions **are text buttons**, so they take `labelLarge` — the same step
91/// [`crate::Button`] already reads — rather than the `bodyMedium` the reference gives the
92/// toolbar's other text.
93fn action_size_of(over: Option<f32>, theme: &Theme) -> f32 {
94    over.or(theme.text.label_large.size)
95        .unwrap_or(frus_core::DEFAULT_TEXT_SIZE)
96}
97/// Whether an untold bar sits at the top of the screen. The reference's default, and the
98/// one that makes a bar used **outside** a shell behave: it consumes the status bar itself
99/// rather than waiting for something to inset it.
100const PRIMARY: bool = true;
101
102/// The space between the bar's elements (the default, overridden by [`AppBar::gap`]): none,
103/// as the reference's toolbar puts its actions side by side, each an icon button whose own
104/// 48 px box is the room around its glyph (milestone 611). It was 8 px.
105const GAP: f32 = 0.0;
106/// The space between the leading and the title, and between the start edge and a title
107/// with no leading: the reference's `NavigationToolbar.kMiddleSpacing`.
108const TITLE_SPACING: f32 = 16.0;
109/// The width reserved for the `leading` slot (a leading icon, Material style).
110const LEADING_SLOT: f32 = 56.0;
111/// The bar's horizontal margin: none. The leading's 56 px slot starts at the edge and the
112/// actions end there, as the reference's do; the glyphs' distance from the edge is their
113/// buttons' own (milestone 611). It was 8 px on each side. Counted in the folding budget.
114const H_PAD: f32 = 0.0;
115/// The width the title is never squeezed below, in px. A title cut to nothing tells a
116/// reader less than a title cut to two characters and an ellipsis.
117const TITLE_MIN: f32 = 64.0;
118/// An action: any widget (always inline — an arbitrary widget cannot become a text menu
119/// row), or labelled and foldable into the overflow.
120enum Action<Msg> {
121    Labeled { label: String, message: Msg },
122    Custom(Box<dyn Widget<Msg>>),
123}
124
125/// An adaptive application bar. A fluent builder finished by [`AppBar::build`].
126pub struct AppBar<Msg = crate::callback::Callback> {
127    title: Option<Box<dyn Widget<Msg>>>,
128    title_style: Option<TextStyle>,
129    /// Was the title's style left at the framework's default? Only then may the theme
130    /// have its say — a caller who set one outranks it.
131    width: f32,
132    leading: Option<Box<dyn Widget<Msg>>>,
133    /// Whether a bar with no `leading` of its own may take one from the shell it stands in.
134    automatically_imply_leading: bool,
135    /// The same for the trailing end.
136    automatically_imply_actions: bool,
137    overflow: Option<(bool, Msg)>,
138    actions: Vec<Action<Msg>>,
139    action_size: Option<f32>,
140    primary: bool,
141    /// What the surface's side intrusions take, left and right, read with the width: a
142    /// primary bar keeps its row clear of them (milestone 728).
143    side_insets: f32,
144    gap: f32,
145    background: Option<Color>,
146    height: Option<f32>,
147    /// `None` = the platform's convention (see [`platform_centers_title`]).
148    center_title: Option<bool>,
149    bottom: Option<Box<dyn Widget<Msg>>>,
150    leading_width: Option<f32>,
151    title_spacing: f32,
152    foreground: Option<Color>,
153    elevation: f32,
154    shape: Option<ShapeBorder>,
155    shadow_color: Option<Color>,
156    surface_tint: Option<Color>,
157    force_material_transparency: bool,
158    toolbar_opacity: f32,
159    bottom_opacity: f32,
160    actions_padding: Option<f32>,
161    flexible_space: Option<Box<dyn Widget<Msg>>>,
162    icon_theme: Option<IconTheme>,
163    actions_icon_theme: Option<IconTheme>,
164    toolbar_text_style: Option<TextStyle>,
165    exclude_header_semantics: bool,
166}
167
168impl<Msg: Clone + 'static> Default for AppBar<Msg> {
169    /// The same as [`AppBar::new`]: a bar with no title, as wide as the surface.
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175impl<Msg: Clone + 'static> AppBar<Msg> {
176    /// Creates a bar with **no title**, as wide as **the surface it is being built
177    /// for** — which is what decides how many actions fit on the line and how many
178    /// fold into the overflow menu.
179    ///
180    /// The title is [`AppBar::title`], and it is any widget. A bar that has none keeps its
181    /// leading and its actions where they would be and puts nothing between them.
182    ///
183    /// The width is read from [`MediaQuery::of`], so no caller passes it. Outside any
184    /// surface description — a unit test that builds a bar on its own — there is no
185    /// width to fold against and nothing folds, which is what this did before the
186    /// surface was ambient. A bar that is **not** the full width of the screen (one
187    /// beside a rail, say) still says so with [`AppBar::width`].
188    ///
189    /// **Breaking**: this took the title as a string until milestone 522.
190    /// `AppBar::new().title(Text::new("Inbox"))` is `AppBar::new().title(Text::new("Inbox"))`.
191    pub fn new() -> Self {
192        let surface = MediaQuery::of();
193        Self {
194            // **A widget, never a string — and not always one.** The reference's `title`
195            // is a `Widget?` and nothing else (`app_bar.dart:1067`). A string constructor
196            // that took a second path through the bar is what made a bar's accessibility
197            // depend on which constructor the caller had reached for (milestone 397), and
198            // a constructor that demanded a string is what made "no title" an empty text,
199            // laid out and painted where the title would have been (milestone 522).
200            title: None,
201            title_style: None,
202            width: if surface.is_described() {
203                surface.size.width
204            } else {
205                f32::MAX
206            },
207            leading: None,
208            automatically_imply_leading: true,
209            automatically_imply_actions: true,
210            overflow: None,
211            actions: Vec::new(),
212            action_size: None,
213            primary: PRIMARY,
214            side_insets: if surface.is_described() {
215                surface.padding.left + surface.padding.right
216            } else {
217                0.0
218            },
219            gap: GAP,
220            background: None,
221            height: None,
222            center_title: None,
223            bottom: None,
224            leading_width: None,
225            title_spacing: TITLE_SPACING,
226            foreground: None,
227            elevation: 0.0,
228            shape: None,
229            shadow_color: None,
230            surface_tint: None,
231            force_material_transparency: false,
232            toolbar_opacity: 1.0,
233            bottom_opacity: 1.0,
234            actions_padding: None,
235            flexible_space: None,
236            icon_theme: None,
237            actions_icon_theme: None,
238            toolbar_text_style: None,
239            exclude_header_semantics: false,
240        }
241    }
242
243    /// The **available width** for the bar, in logical pixels: what drives the
244    /// folding. It is a *size*, not a platform indicator.
245    ///
246    /// An override. [`AppBar::new`] already takes the surface's width from
247    /// [`MediaQuery::of`]; this is for a bar that does not get the whole of it.
248    pub fn width(mut self, width: f32) -> Self {
249        self.width = width;
250        self
251    }
252
253    /// The text title's style (size/weight/italic/color). Default: the reference's
254    /// `title_large` at medium weight, in the theme's colour.
255    pub fn title_style(mut self, style: TextStyle) -> Self {
256        self.title_style = Some(style);
257        self
258    }
259
260    /// The title, as **any widget** — a text, a logo, a composed row.
261    ///
262    /// This is the title in the reference, where `title` is a `Widget?` and there is no
263    /// string form at all. Unset, the bar has none: nothing is laid out, painted or
264    /// announced between the leading and the actions.
265    ///
266    /// No style is put on it. It takes the resolved title type from the
267    /// `DefaultTextStyle` the bar hands down, which is what the reference does at
268    /// `app_bar.dart:1084`, so a `Text` that chose nothing wears the bar's type and one
269    /// that chose its own size keeps it. And it is announced as the screen's heading
270    /// unless [`AppBar::exclude_header_semantics`] says otherwise.
271    ///
272    /// It was called `title_widget` while the bar had two kinds of title, and
273    /// [`AppBar::new`] took a string until milestone 522. There is one kind, and it is
274    /// optional.
275    pub fn title(mut self, widget: impl Widget<Msg> + 'static) -> Self {
276        self.title = Some(Box::new(widget));
277        self
278    }
279
280    /// The leading element (a menu or back button…), optional.
281    pub fn leading(mut self, widget: impl Widget<Msg> + 'static) -> Self {
282        self.leading = Some(Box::new(widget));
283        self
284    }
285
286    /// Whether a bar with **no `leading` of its own** may take one from the shell it
287    /// stands in. `true` unless said otherwise, as in the reference.
288    ///
289    /// A bar on a screen that has a drawer then grows the button that opens it, and a bar
290    /// on a screen that has none stays as it was — the shell is what knows, and until
291    /// milestone 422 it had no way to say so to a slot handed to it already built.
292    ///
293    /// The reference's other branch has no counterpart here. There a bar also implies a
294    /// **back button** when the route it is in can be popped; frus's
295    /// [`Navigator`](crate::Navigator) is controlled — the application holds the stack — so
296    /// the framework does not know a depth to imply anything from. A screen that can be
297    /// left says so with [`AppBar::leading`].
298    ///
299    /// `false` is for a bar that wants its leading slot empty on a screen that has a
300    /// drawer: a logo where the button would be, and some other way in.
301    pub fn automatically_imply_leading(mut self, imply: bool) -> Self {
302        self.automatically_imply_leading = imply;
303        self
304    }
305
306    /// Whether a bar with **no actions of its own** may take one from the shell it stands
307    /// in. `true` unless said otherwise, as in the reference (`app_bar.dart:1113`).
308    ///
309    /// A bar with nothing at its trailing end, on a screen that has an end drawer, grows
310    /// the button that opens it. One action of its own and it does not: the reference's
311    /// test is the same — the implied button is what fills an **empty** end, never
312    /// something added beside what the caller put there.
313    pub fn automatically_imply_actions(mut self, imply: bool) -> Self {
314        self.automatically_imply_actions = imply;
315        self
316    }
317
318    /// Enables the overflow menu: its open state (controlled by the app) and the
319    /// toggle message (emitted by the `⋯` button and on an outside click).
320    pub fn overflow(mut self, open: bool, toggle: Msg) -> Self {
321        self.overflow = Some((open, toggle));
322        self
323    }
324
325    /// Adds an action: **any widget**, at the bar's trailing end, after those already
326    /// added. Typically an [`IconButton`](crate::IconButton), or a
327    /// [`PopupMenuButton`] for a menu of its own.
328    ///
329    /// The reference's `actions` is a list of widgets, and this is one more of them. The
330    /// actions sit side by side, centred across the bar, inset by
331    /// [`AppBar::actions_padding`], their glyphs in [`AppBar::actions_icon_theme`]. A widget
332    /// is never folded into the overflow menu: an arbitrary widget cannot become a menu
333    /// row. An action that may fold is a [`AppBar::foldable_action`].
334    ///
335    /// **Breaking**: until milestone 613 this took a label and a message and made the
336    /// foldable button; that is now `foldable_action`, and this is what `action_widget` was.
337    pub fn action(mut self, widget: impl Widget<Msg> + 'static) -> Self {
338        self.actions.push(Action::Custom(Box::new(widget)));
339        self
340    }
341
342    /// Adds every action of an iterator, **already boxed**, for actions built in a loop:
343    /// the reference's `actions: [...]` in one call. The same as [`AppBar::action`] for
344    /// each, in order.
345    pub fn actions(mut self, actions: impl IntoIterator<Item = Box<dyn Widget<Msg>>>) -> Self {
346        self.actions.extend(actions.into_iter().map(Action::Custom));
347        self
348    }
349
350    /// Adds an action that **folds**: a text button with `label`, shown inline while it
351    /// fits and moved into the overflow menu ([`AppBar::overflow`]) when the bar is too
352    /// narrow for it. The last ones fold first.
353    ///
354    /// This is frus's own, beside the reference's list of widgets: a bar that adapts to
355    /// its width without the application measuring anything. It was `action` until
356    /// milestone 613.
357    pub fn foldable_action(mut self, label: impl Into<String>, message: Msg) -> Self {
358        self.actions.push(Action::Labeled {
359            label: label.into(),
360            message,
361        });
362        self
363    }
364
365    /// Adds a widget action: [`AppBar::action`], under its former name.
366    #[deprecated(
367        since = "0.3.0",
368        note = "an action is any widget: use `AppBar::action`"
369    )]
370    pub fn action_widget(self, widget: impl Widget<Msg> + 'static) -> Self {
371        self.action(widget)
372    }
373
374    /// The foldable actions' font size (the theme's `label_large` by default).
375    pub fn action_size(mut self, size: f32) -> Self {
376        self.action_size = Some(size);
377        self
378    }
379
380    /// Whether this bar sits at the **top of the screen**, and so has to keep its content
381    /// out of the status bar. `true` unless said otherwise, as in the reference.
382    ///
383    /// The bar pads **itself**: its surface still runs behind the status bar — that is what
384    /// a Material bar looks like — and only its toolbar and its `bottom` are held clear.
385    /// The two things this separates are the shell's job and the bar's: a
386    /// [`Scaffold`](crate::Scaffold) makes the slot tall enough, and the bar decides
387    /// whether to use the room. Until milestone 417 the shell insetted the bar from
388    /// outside, which is one switch where the reference has two — and why a bar used
389    /// *without* a shell drew under the status bar.
390    ///
391    /// Turn it off for a bar that is not at the top: one nested in a page, or a second bar
392    /// under the first.
393    #[must_use]
394    pub fn primary(mut self, primary: bool) -> Self {
395        self.primary = primary;
396        self
397    }
398
399    /// The space between the bar's elements (8 px by default).
400    pub fn gap(mut self, gap: f32) -> Self {
401        self.gap = gap;
402        self
403    }
404
405    /// The bar's background color (transparent by default: the parent decides).
406    pub fn background(mut self, color: Color) -> Self {
407        self.background = Some(color);
408        self
409    }
410
411    /// An imposed bar height (by default, the content's natural height).
412    pub fn height(mut self, height: f32) -> Self {
413        self.height = Some(height);
414        self
415    }
416
417    /// Centres the title in the space left between the leading and the actions,
418    /// rather than starting it flush after the leading.
419    ///
420    /// **Overrides the platform's convention**, which is what a bar that has not been
421    /// told anything follows: centred where the system centres its own titles, flush
422    /// after the leading everywhere else. See [`platform_centers_title`].
423    ///
424    /// Centred, the title still yields to the actions first: it is centred in what is
425    /// left, not in the window.
426    pub fn center_title(mut self, centered: bool) -> Self {
427        self.center_title = Some(centered);
428        self
429    }
430
431    /// A widget **under** the bar, spanning its width — a row of tabs, a search field,
432    /// a progress bar. It belongs to the bar, so it sits inside the background.
433    pub fn bottom(mut self, widget: impl Widget<Msg> + 'static) -> Self {
434        self.bottom = Some(Box::new(widget));
435        self
436    }
437
438    /// The width reserved for the leading slot. Unset, it is the Material slot width;
439    /// a leading wider than that would otherwise push the title without the folding
440    /// budget knowing, and the actions would run off the edge.
441    pub fn leading_width(mut self, width: f32) -> Self {
442        self.leading_width = Some(width);
443        self
444    }
445
446    /// The gap between the leading and the title.
447    pub fn title_spacing(mut self, spacing: f32) -> Self {
448        self.title_spacing = spacing;
449        self
450    }
451
452    /// The colour of the bar's own text — the title, and the labelled actions. Unset,
453    /// each follows the theme.
454    pub fn foreground(mut self, color: Color) -> Self {
455        self.foreground = Some(color);
456        self
457    }
458
459    /// How far off the page the bar sits. `0` — the default — is flat, which is what a bar
460    /// sitting on a surface of the same colour wants.
461    ///
462    /// A height casts a shadow only where a [`shadow_color`](Self::shadow_color) is named,
463    /// and tints only where a [`surface_tint`](Self::surface_tint) is: the reference's
464    /// Material 3 bar makes both transparent (its app bar defaults, lines 2542 and 2545).
465    pub fn elevation(mut self, elevation: f32) -> Self {
466        self.elevation = elevation.max(0.0);
467        self
468    }
469
470    /// **What shape the bar is** — the reference's `shape` (`app_bar.dart:209`), and a
471    /// whole [`ShapeBorder`] since milestone 455. Square by default, as the reference's is.
472    ///
473    /// The bar clips to it, so a coloured surface and anything drawn on it stop at the
474    /// curve rather than squaring off the corner the shadow already rounded.
475    ///
476    /// A bar is a **rectangle**, so what a shape contributes here is the corners it
477    /// resolves to in the bar's own box — a stadium rounds to half the bar's height,
478    /// which is what a stadium means on a wide, short box. A shape with no rounded form
479    /// at all leaves the bar square rather than clipping it to something it is not.
480    ///
481    /// **Breaking**: this took a `BorderRadius` under the same name until milestone 455.
482    /// A caller passing a number says [`radius`](Self::radius) instead.
483    pub fn shape(mut self, shape: ShapeBorder) -> Self {
484        self.shape = Some(shape);
485        self
486    }
487
488    /// The corner radii — the shorthand for a rounded rectangle, writing into the same
489    /// one field [`shape`](Self::shape) does. The last of the two called is the one that
490    /// counts.
491    pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
492        self.shape = Some(ShapeBorder::rounded(radius));
493        self
494    }
495
496    /// The colour of the shadow the bar casts. Only visible with an
497    /// [`elevation`](Self::elevation).
498    ///
499    /// The reference's `shadowColor`, **alpha included**. Left unset it is transparent, as
500    /// the reference's Material 3 bar is (its app bar defaults, line 2542), and an
501    /// elevated bar casts nothing.
502    pub fn shadow_color(mut self, color: Color) -> Self {
503        self.shadow_color = Some(color);
504        self
505    }
506
507    /// The colour laid over the bar's surface **in proportion to its elevation** — the
508    /// reference's `surfaceTintColor`, and Material 3's way of showing height.
509    ///
510    /// A shadow says a thing is above the page; the tint says how far, and it is what
511    /// still reads on a dark background where a shadow shows nothing. The strength comes
512    /// from the specification's table (see
513    /// [`surface_tint_opacity`](frus_core::surface_tint_opacity)), so an elevation of 3
514    /// tints at 8% whoever asks.
515    ///
516    /// Nothing is tinted at elevation zero, which is the default: a flat bar is the
517    /// surface it was given.
518    pub fn surface_tint(mut self, color: Color) -> Self {
519        self.surface_tint = Some(color);
520        self
521    }
522
523    /// Draws the bar with **no surface at all**: no background, no tint, no shadow —
524    /// only what it holds.
525    ///
526    /// The reference's `forceMaterialTransparency`, and it exists for the same case: a
527    /// bar over an image or a video, where the chrome should be the controls and nothing
528    /// else. It overrides the background, the tint and the elevation rather than
529    /// arguing with them, because a caller asking for transparency has already decided.
530    pub fn force_material_transparency(mut self, force: bool) -> Self {
531        self.force_material_transparency = force;
532        self
533    }
534
535    /// The opacity of the toolbar row — the leading, the title and the actions — without
536    /// touching the bar's surface. `1.0` by default.
537    ///
538    /// The reference's `toolbarOpacity`. It is what fades a collapsing header's contents
539    /// out while its background stays, and it is a **group** opacity: overlapping
540    /// children do not darken where they overlap.
541    pub fn toolbar_opacity(mut self, opacity: f32) -> Self {
542        self.toolbar_opacity = opacity.clamp(0.0, 1.0);
543        self
544    }
545
546    /// The opacity of the [`bottom`](Self::bottom) slot, independently of the toolbar.
547    /// `1.0` by default. The reference's `bottomOpacity`.
548    pub fn bottom_opacity(mut self, opacity: f32) -> Self {
549        self.bottom_opacity = opacity.clamp(0.0, 1.0);
550        self
551    }
552
553    /// Padding **across** the **actions** as a group, at its two ends, on top of the gap
554    /// between them.
555    ///
556    /// The reference's `actionsPadding`, added in its 3.27 line for the same reason: an
557    /// icon button's own hit area already reaches the bar's edge, so a design that wants
558    /// the *glyphs* inset has nowhere else to say so.
559    pub fn actions_padding(mut self, padding: f32) -> Self {
560        self.actions_padding = Some(padding);
561        self
562    }
563    /// Stops the title being announced as a **heading**. `false` by default, as the
564    /// reference's `excludeHeaderSemantics` is.
565    ///
566    /// A screen reader's user moves through a screen by its headings, and the bar's title
567    /// is the one every screen has. Excluding it is for a bar whose title is decorative,
568    /// or one of two bars on a page where only the outer one names it — announcing both
569    /// as headings gives the user two landmarks where there is one screen.
570    pub fn exclude_header_semantics(mut self, exclude: bool) -> Self {
571        self.exclude_header_semantics = exclude;
572        self
573    }
574
575    /// A widget stacked **behind** the toolbar and the [`bottom`](Self::bottom), filling
576    /// the bar's whole box.
577    ///
578    /// The reference's `flexibleSpace`, and it is what makes a collapsing header possible
579    /// at all: an image, a gradient, a photograph that the title sits on. The bar's height
580    /// is unchanged — this is a layer, not a slot — so the space is exactly as tall as
581    /// the bar however tall the widget would rather be.
582    ///
583    /// The bar's own background is drawn first and this over it, so a translucent surface
584    /// still tints what is behind, and a caller who wants only the image says
585    /// [`force_material_transparency`](Self::force_material_transparency).
586    pub fn flexible_space(mut self, widget: impl Widget<Msg> + 'static) -> Self {
587        self.flexible_space = Some(Box::new(widget));
588        self
589    }
590
591    /// The colour and the size of the **glyphs in the bar** — the reference's `iconTheme`.
592    ///
593    /// It reaches every icon in the leading slot and, unless
594    /// [`actions_icon_theme`](Self::actions_icon_theme) says otherwise, in the actions
595    /// too. It is delivered as a theme for that subtree rather than as an argument to
596    /// each widget, so it reaches an icon nested inside a button the bar never sees —
597    /// which is what the reference's inherited `IconTheme` does and why it is one.
598    ///
599    /// An icon that names its own colour still wins: `caller ?? this ?? the scheme`.
600    pub fn icon_theme(mut self, icons: IconTheme) -> Self {
601        self.icon_theme = Some(icons);
602        self
603    }
604
605    /// The same, for the **actions** alone — the reference's `actionsIconTheme`.
606    ///
607    /// Unset, the actions follow [`icon_theme`](Self::icon_theme). Set, they part company
608    /// with the leading slot, which is the case it exists for: a back arrow in the
609    /// foreground colour beside actions in a muted one.
610    pub fn actions_icon_theme(mut self, icons: IconTheme) -> Self {
611        self.actions_icon_theme = Some(icons);
612        self
613    }
614
615    /// The type worn by the **words in the bar that are not the title** — a label beside
616    /// the back arrow, a "Save" in the actions, anything a caller handed over already
617    /// assembled.
618    ///
619    /// Not the title: the title has [`title_style`](Self::title_style), and the reference
620    /// keeps the same two apart for the same reason. A bar's title is one line the bar
621    /// itself lays out and can measure; the rest is other people's widgets, and the only
622    /// way to reach a `Text` nested three levels inside one of them is to hand the style
623    /// *down* rather than pass it *in*.
624    ///
625    /// It is handed down **field by field**: setting a size leaves the colours alone, and
626    /// a `Text` that chose its own size keeps it. See
627    /// [`DefaultTextStyle`](crate::DefaultTextStyle) for the rule in full.
628    pub fn toolbar_text_style(mut self, style: TextStyle) -> Self {
629        self.toolbar_text_style = Some(style);
630        self
631    }
632
633    /// The button a labelled action is drawn as — made in this one place, so that the
634    /// width the fold is decided on is the width of what is then drawn. The bar used to
635    /// estimate it with a padding and no minimum of its own, and the two drifted apart.
636    ///
637    /// A **text button**, as an action with words is in the reference: no outline, the bar
638    /// is its surface (milestone 611). It was an outlined pill.
639    fn action_button(label: String, message: Msg, size: f32) -> impl Widget<Msg> + 'static {
640        button(label, message).variant(Variant::Text).size(size)
641    }
642
643    /// The button that opens the overflow menu: the reference's three dots, upright, in an
644    /// icon button (milestone 611). It was a `⋯` in an outlined pill.
645    fn overflow_button(message: Msg) -> impl Widget<Msg> + 'static {
646        crate::IconButton::new(crate::Icons::MORE_VERT)
647            .label("More")
648            .on_press(message)
649    }
650
651    /// A widget's declared width (0 if it depends on layout).
652    fn widget_width(widget: &dyn Widget<Msg>, theme: &Theme) -> f32 {
653        match widget.style_themed(theme).width {
654            Dimension::Length(v) => v,
655            _ => 0.0,
656        }
657    }
658
659    /// Assembles the bar into a widget ready to display (the row `leading . title .
660    /// spring . inline actions . overflow`, over an optional `bottom`).
661    ///
662    /// The order in which space is handed out is worth stating, because it is a
663    /// decision and not an accident: **the actions are served first and the title
664    /// yields.** A title cut short is still a title; an action pushed off the edge is
665    /// gone. So the folding budget is computed against the title's *floor*, and
666    /// whatever is left afterwards is what the title gets - with an ellipsis if it
667    /// does not fit.
668    /// Finishes the bar.
669    ///
670    /// The composition is **deferred until the theme is known** ([`ThemeBuilder`]):
671    /// `center_title` decides which children exist and in what order, so it cannot be
672    /// resolved by a hook that runs after the row has been assembled. Everything the
673    /// theme has a say in — the title's centring, its type, the bar's colours — is
674    /// resolved inside, `caller ?? theme ?? framework`.
675    ///
676    /// [`ThemeBuilder`]: crate::ThemeBuilder
677    pub fn build(self) -> Box<dyn Widget<Msg>> {
678        Box::new(crate::themebuilder::ThemeBuilder::boxed(move |theme| {
679            self.build_with(theme)
680        }))
681    }
682
683    /// The composition proper, against a known theme.
684    fn build_with(self, theme: &Theme) -> Box<dyn Widget<Msg>> {
685        let t = theme.widgets.app_bar;
686        let AppBar {
687            title,
688            title_style,
689            width,
690            leading,
691            automatically_imply_leading,
692            automatically_imply_actions,
693            overflow,
694            actions,
695            action_size,
696            gap,
697            background,
698            height,
699            center_title,
700            bottom,
701            leading_width,
702            title_spacing,
703            foreground,
704            elevation,
705            shape,
706            shadow_color,
707            surface_tint,
708            force_material_transparency,
709            toolbar_opacity,
710            bottom_opacity,
711            actions_padding,
712            primary,
713            side_insets,
714            flexible_space,
715            icon_theme,
716            actions_icon_theme,
717            toolbar_text_style,
718            exclude_header_semantics,
719        } = self;
720
721        // **Is that width a measurement or a sentinel?** `AppBar::new` takes the surface's
722        // width from the ambient description, and falls back to `f32::MAX` when nothing
723        // described it — a bar built outside a frame, which is every bar a test builds by
724        // hand. `f32::MAX` is finite, so every arithmetic below happily produced a number:
725        // a row `f32::MAX` wide, a title ceiling just under it, and a leading spring that
726        // pushed a centred title to `x = 1.7e38`, off any screen there will ever be. The
727        // sentinel is asked about once, here, and the two places that need a real width
728        // fill the space they are offered instead.
729        let known_width = width.is_finite() && width < f32::MAX;
730
731        // **What the shell knows and the bar does not** (milestone 422). This bar was
732        // handed to its `Scaffold` already built, so it cannot see the screen it stands on;
733        // the shell tells it, through the ambient the walk installs on the way down, and
734        // this composition runs under that ambient because it is deferred.
735        //
736        // A bar with no `leading` of its own, on a screen with a drawer, grows the button
737        // that opens it — the reference's first branch at `app_bar.dart:1010`. The second,
738        // a back button for a route that can be popped, has no counterpart: frus's
739        // `Navigator` is controlled, so there is no stack depth for the framework to read.
740        let shell = crate::ScaffoldInfo::of();
741        let leading = leading.or_else(|| {
742            if !automatically_imply_leading {
743                return None;
744            }
745            let toggle = shell.drawer_toggle::<Msg>()?;
746            Some(
747                Box::new(crate::IconButton::new(crate::Icons::MENU).on_press(toggle))
748                    as Box<dyn Widget<Msg>>,
749            )
750        });
751        // And the trailing end, on the same terms: **an empty end**, never a button added
752        // beside what the caller put there (`app_bar.dart:1113`). An overflow toggle counts
753        // as something at that end, since it is what the bar puts there itself.
754        let mut actions = actions;
755        if actions.is_empty() && overflow.is_none() && automatically_imply_actions {
756            if let Some(toggle) = shell.end_drawer_toggle::<Msg>() {
757                actions.push(Action::Custom(Box::new(
758                    crate::IconButton::new(crate::Icons::MENU).on_press(toggle),
759                )));
760            }
761        }
762
763        // `caller ?? theme ?? platform`. The platform is the last word rather than the
764        // framework's own taste, because where a title sits is a system convention before
765        // it is a design one — see `platform_centers_title`.
766        let center_title = center_title
767            .or(t.center_title)
768            .unwrap_or_else(|| platform_centers_title(theme.platform, actions.len()));
769
770        // The title's type, likewise: the caller's style, else the theme's, else the
771        // reference's `titleLarge`.
772        let mut title_style = title_style_of(title_style, theme);
773        let action_size = action_size_of(action_size, theme);
774        let foreground = foreground
775            .or(t.foreground)
776            .unwrap_or(theme.scheme.on_surface);
777        title_style.color = Some(foreground);
778        // The bar keeps its height, so the title is capped rather than let out. See
779        // [`APP_BAR_MAX_TITLE_SCALE`]: this is the reference's answer for chrome, and the
780        // opposite of the one its components give (there the height is a floor and the
781        // content wins).
782        let title_style = title_style.clamp_scale(APP_BAR_MAX_TITLE_SCALE);
783        // The title's type as something a subtree can **hand down**, plus the two settings
784        // the reference pairs with it: one line, cut with an ellipsis. A `Text` that never
785        // chose a size, a wrap or an overflow takes all three; one that did keeps its own.
786        let title_words = DefaultTextStyle {
787            soft_wrap: Some(false),
788            overflow: Some(frus_core::TextOverflow::Ellipsis),
789            ..DefaultTextStyle::from_text_style(title_style)
790        };
791        // The same thing as a whole theme, for asking a title how wide it naturally is.
792        let dressed_title_theme = {
793            let mut dressed = theme.clone();
794            dressed.widgets.text = dressed.widgets.text.merge(title_words);
795            dressed
796        };
797        let background = background.or(t.background);
798        let elevation = if elevation > 0.0 {
799            elevation
800        } else {
801            t.elevation.unwrap_or(0.0)
802        };
803        let height = height.or(t.height);
804        let leading_w = match (&leading, leading_width) {
805            (None, _) => 0.0,
806            (Some(_), Some(w)) => w,
807            (Some(widget), None) => Self::widget_width(widget.as_ref(), theme).max(LEADING_SLOT),
808        };
809        // The title's natural width, asked of the widget **under the type it will
810        // actually wear**. Asking it bare would measure a string title at the framework's
811        // 16 px and reserve room for a bar that draws it at 22.
812        // Nought for a bar with no title: it reserves nothing, and holds no floor.
813        let natural_title = title.as_ref().map_or(0.0, |title| {
814            Self::widget_width(title.as_ref(), &dressed_title_theme)
815        });
816
817        // The room the actions may claim: everything except the margins, the leading,
818        // the spacing, and what the title is **reserved**.
819        //
820        // The order matters and it is a decision. A bar that folds actions into an
821        // overflow already has a way of making room, and that is the one to use first:
822        // the title keeps its natural width — up to half the bar, so a long one cannot
823        // starve the actions — and the actions fold to fit what is left. Truncating
824        // the title is the *last* resort, for when even one action and the overflow
825        // button will not fit beside it.
826        //
827        // The row's own children before the actions are counted as the row below will
828        // have them: the leading, the spacer that widens its gap to `title_spacing`, a
829        // spring before a centred title, the title if there is one, and the spring after
830        // it — each joined to the next by a gap. The gap joining the last of them to the
831        // actions is each action's own, below, so a bar with no actions still owes it here.
832        // With a leading, the spacing sits between it and the title; without one, between
833        // the start edge and the title, as the reference's toolbar puts it. A centred title
834        // has no start to keep it from.
835        let spacer_w =
836            if title.is_some() && (leading.is_some() || !center_title) && title_spacing > gap {
837                title_spacing - gap
838            } else {
839                0.0
840            };
841        let before_actions = usize::from(leading.is_some())
842            + usize::from(spacer_w > 0.0)
843            + usize::from(center_title)
844            + usize::from(title.is_some())
845            + 1;
846        let joins = before_actions - 1 + usize::from(actions.is_empty());
847        // A primary bar keeps its row clear of the side intrusions (the `SafeArea` below):
848        // a phone on its side has its cutout and corners there. What it takes is not room
849        // for actions, or they are counted into a row that is narrower than the bar, and the
850        // last ones run off its end (milestone 728).
851        let sides = if primary { side_insets } else { 0.0 };
852        let fixed = H_PAD * 2.0
853            + sides
854            + leading_w
855            + spacer_w
856            + gap * joins as f32
857            + actions_padding.map_or(0.0, |pad| pad * 2.0);
858        let room = (width - fixed).max(0.0);
859        let title_reserve = natural_title
860            .min(room * 0.5)
861            .max(TITLE_MIN.min(natural_title));
862        let budget = room - title_reserve;
863        // The `⋯` is made as the actions are, and measured as they are.
864        let overflow_btn_w = match &overflow {
865            Some((_, toggle)) => {
866                let glyph = Self::overflow_button(toggle.clone());
867                Self::widget_width(&glyph, theme) + gap
868            }
869            None => 0.0,
870        };
871
872        // Each action's width; free widgets are **always** inline.
873        let widths: Vec<f32> = actions
874            .iter()
875            .map(|action| match action {
876                Action::Labeled { label, message } => {
877                    let drawn = Self::action_button(label.clone(), message.clone(), action_size);
878                    Self::widget_width(&drawn, theme) + gap
879                }
880                Action::Custom(widget) => Self::widget_width(widget.as_ref(), theme) + gap,
881            })
882            .collect();
883        let total: f32 = widths.iter().sum();
884        let custom_total: f32 = actions
885            .iter()
886            .zip(&widths)
887            .filter(|(action, _)| matches!(action, Action::Custom(_)))
888            .map(|(_, w)| *w)
889            .sum();
890
891        // How many **labelled** actions fit inline? If everything fits, no overflow;
892        // otherwise reserve the overflow button and the free widgets and keep as many
893        // labelled ones as possible, in order - so it is the last actions that fold,
894        // which is where a reader has learnt to go looking for them.
895        let (kept_labeled, actions_w) = if total <= budget {
896            (usize::MAX, total)
897        } else {
898            let mut used = overflow_btn_w + custom_total;
899            let mut kept = 0;
900            for (action, w) in actions.iter().zip(&widths) {
901                if matches!(action, Action::Custom(_)) {
902                    continue;
903                }
904                if used + w <= budget {
905                    used += w;
906                    kept += 1;
907                } else {
908                    break;
909                }
910            }
911            (kept, used)
912        };
913
914        // What is left for the title, once the actions have taken theirs. Unknown when
915        // the width is: a ceiling computed from the sentinel is `f32::MAX` less a few
916        // pixels, which is not a ceiling.
917        let title_room = if known_width {
918            (width - fixed - actions_w).max(TITLE_MIN.min(natural_title))
919        } else {
920            f32::INFINITY
921        };
922
923        // An icon theme is delivered as a **theme for the subtree**, not as an argument
924        // to each widget: that is the only way it reaches a glyph nested inside a button
925        // the bar never sees. The reference's `IconTheme` is an inherited widget for the
926        // same reason.
927        //
928        // A toolbar text style travels the same road, and for the same reason: the words
929        // it is meant for are inside widgets the bar was handed, not ones it built.
930        let dress = |widget: Box<dyn Widget<Msg>>, icons: Option<IconTheme>| {
931            if icons.is_none() && toolbar_text_style.is_none() {
932                return widget;
933            }
934            let words = toolbar_text_style.map(DefaultTextStyle::from_text_style);
935            Box::new(crate::Themed::tweak(
936                move |t| {
937                    if let Some(icons) = icons {
938                        if let Some(color) = icons.color {
939                            t.widgets.icon.color = Some(color);
940                            t.widgets.icon_button.icon_color = Some(color);
941                        }
942                        if let Some(size) = icons.size {
943                            t.widgets.icon.size = Some(size);
944                            t.widgets.icon_button.icon_size = Some(size);
945                        }
946                    }
947                    // **Merged onto** whatever an enclosing subtree already handed down,
948                    // rather than replacing it: two nested subtrees each setting one field
949                    // must leave a text wearing both.
950                    if let Some(words) = words {
951                        t.widgets.text = t.widgets.text.merge(words);
952                    }
953                },
954                widget,
955            )) as Box<dyn Widget<Msg>>
956        };
957
958        let mut row = Flex::row().align(Align::Center).gap(gap);
959        // The leading **in its slot**, centred: the reference gives it a box of exactly the
960        // leading width, so an icon button's glyph sits 16 px from the edge and the title
961        // starts at 72. The slot used to be counted in the budget and not given, and the
962        // glyph touched the edge (milestone 611).
963        if let Some(leading) = leading {
964            row = row.child(
965                Container::new()
966                    .width(leading_w)
967                    .alignment(crate::Alignment::CENTER)
968                    .child(dress(leading, icon_theme)),
969            );
970        }
971        if spacer_w > 0.0 {
972            row = row.child(Container::new().width(spacer_w));
973        }
974        // Centred: a spring on either side of the title. Otherwise one spring after it,
975        // which is what pushes the actions to the right.
976        if center_title {
977            row = row.child(Container::new().flex(1.0));
978        }
979        // The bar's title is the **heading** of the screen it names — the landmark a
980        // screen reader's user jumps to — and now that is true of *every* title rather
981        // than only of one written as a string. The reference wraps the same way
982        // (`app_bar.dart:1071`), and milestone 401's `Semantics` wrapper is what lets a
983        // container state a role for a child it was handed already assembled.
984        let title = title.map(|title| -> Box<dyn Widget<Msg>> {
985            if exclude_header_semantics {
986                title
987            } else {
988                Box::new(crate::Semantics::heading(title))
989            }
990        });
991        // **The type, handed down rather than applied.** The words are cut by the box they
992        // are given instead of by arithmetic here: `soft_wrap: false` and an ellipsis, on a
993        // `Text` that never chose either, exactly as the reference sets them
994        // (`app_bar.dart:1084`). Cutting the string by hand needed a width computed before
995        // the layout ran, which is the class of mistake milestone 392 was.
996        // And a **ceiling**, the reference's `_AppBarTitleBox` (`app_bar.dart:1069`). The
997        // ellipsis alone would very nearly do it — a text handed an overflow mode grants
998        // the squeeze, so flexbox may shrink it — but "very nearly" is how an over-long
999        // task name evicted its own delete button in milestone 333. What is left after the
1000        // actions have taken theirs is a number the bar knows; saying it outright is one
1001        // line, and it is the line that makes the guarantee testable.
1002        // A bar with no title puts nothing here: the reference's toolbar is handed a `null`
1003        // middle and builds none.
1004        if let Some(title) = title {
1005            row = row.child(
1006                ConstrainedBox::new(crate::Themed::tweak(
1007                    move |t| t.widgets.text = t.widgets.text.merge(title_words),
1008                    title,
1009                ))
1010                .max_width(title_room),
1011            );
1012        }
1013        row = row.child(Container::new().flex(1.0));
1014
1015        let mut labeled_seen = 0;
1016        let mut folded: Vec<(String, Msg)> = Vec::new();
1017        // The actions as a **group**, so that `actions_padding` insets the glyphs rather
1018        // than each button's own hit area. Without a padding the group is the row itself
1019        // and nothing is nested that was not nested before.
1020        let mut group = Flex::row().align(Align::Center).gap(gap);
1021        for action in actions {
1022            match action {
1023                Action::Custom(widget) => group = group.child_boxed(widget),
1024                Action::Labeled { label, message } => {
1025                    if labeled_seen < kept_labeled {
1026                        group = group.child(Self::action_button(label, message, action_size));
1027                    } else {
1028                        folded.push((label, message));
1029                    }
1030                    labeled_seen += 1;
1031                }
1032            }
1033        }
1034
1035        if !folded.is_empty() {
1036            match overflow {
1037                // A controlled overflow menu: the glyph opens it, the items emit the
1038                // actions.
1039                Some((open, toggle)) => {
1040                    let mut menu =
1041                        PopupMenuButton::new(Self::overflow_button(toggle.clone()), open, toggle);
1042                    for (label, message) in folded {
1043                        menu = menu.item(label, message);
1044                    }
1045                    group = group.child(menu);
1046                }
1047                // No overflow configured: show everything inline (it may overflow).
1048                None => {
1049                    for (label, message) in folded {
1050                        group = group.child(Self::action_button(label, message, action_size));
1051                    }
1052                }
1053            }
1054        }
1055        // Unset, the actions follow the bar's icon theme; set, they part company with
1056        // the leading slot — a back arrow in the foreground colour beside actions in a
1057        // muted one, which is the case the reference's `actionsIconTheme` exists for.
1058        // Untold, the actions' glyphs are the scheme's `on_surface_variant`, a step quieter
1059        // than the leading's `on_surface`: the reference's default `actionsIconTheme`
1060        // (milestone 611).
1061        let actions_icons = actions_icon_theme.or(icon_theme).or(Some(IconTheme {
1062            color: Some(theme.scheme.on_surface_variant),
1063            size: None,
1064        }));
1065        let group = dress(Box::new(group), actions_icons);
1066        row = match actions_padding {
1067            // Across, at both ends of the group: what insets the glyphs from the bar's edge.
1068            // Not above and below, where a bar of a fixed height has no room to give — an
1069            // icon button is already 48 of its 64 (milestone 611).
1070            Some(pad) => row.child(
1071                Container::new()
1072                    .padding_each(0.0, pad, 0.0, pad)
1073                    .child(group),
1074            ),
1075            None => row.child_boxed(group),
1076        };
1077
1078        // The toolbar proper: the row, with its horizontal margin and any imposed
1079        // height. The `bottom` sits under it inside the same background - it belongs to
1080        // the bar, rather than being something the application places beneath one.
1081        // The row is given the bar's width less its margins, so that the springs
1082        // inside it have something to share: a row that hugged its content would leave
1083        // a centred title with no space to be centred in.
1084        let row = if known_width {
1085            row.width((width - H_PAD * 2.0).max(0.0))
1086        } else {
1087            // Nobody described the surface, so `width` is the sentinel rather than a
1088            // number. Fill what the parent offers instead of laying the row out across
1089            // `f32::MAX`: a spring in a row that wide puts a centred title at infinity,
1090            // which is where every Apple-platform title went until milestone 474.
1091            row.width_fraction(1.0)
1092        };
1093        // The bar is a **fixed** height, the caller's or the reference's. It used to hug
1094        // its content, which made the chrome a different shape on every screen and moved
1095        // the page below it the moment an action appeared or a title wrapped.
1096        let mut toolbar = Container::new().padding_each(0.0, H_PAD, 0.0, H_PAD);
1097        toolbar = toolbar.height(height.unwrap_or(APP_BAR_HEIGHT));
1098        let toolbar = toolbar.child(row);
1099
1100        // The two opacities are **group** opacities and independent of one another: a
1101        // collapsing header fades its contents out while its surface stays, and the
1102        // `bottom` — a tab strip, usually — fades on its own schedule.
1103        let toolbar: Box<dyn Widget<Msg>> = if toolbar_opacity < 1.0 {
1104            Box::new(Container::new().opacity(toolbar_opacity).child(toolbar))
1105        } else {
1106            Box::new(toolbar)
1107        };
1108        let bottom = bottom.map(|widget| -> Box<dyn Widget<Msg>> {
1109            if bottom_opacity < 1.0 {
1110                Box::new(Container::new().opacity(bottom_opacity).child(widget))
1111            } else {
1112                widget
1113            }
1114        });
1115        let content: Box<dyn Widget<Msg>> = match bottom {
1116            Some(bottom) => Box::new(Flex::column().child_boxed(toolbar).child_boxed(bottom)),
1117            None => toolbar,
1118        };
1119        // **The padding applies to the toolbar and the bottom, not to the surface** — the
1120        // reference says so in as many words (`app_bar.dart:1189`), and it is what makes a
1121        // Material bar look the way it does: the colour runs behind the status bar while
1122        // the title sits below it. The bottom edge is left alone; a bar has something under
1123        // it by definition.
1124        let content: Box<dyn Widget<Msg>> = if primary {
1125            Box::new(crate::SafeArea::new(content).edges(crate::Edges::ALL.without_bottom()))
1126        } else {
1127            content
1128        };
1129
1130        // The bar **occupies** the width it was told about rather than hugging its
1131        // content. Hugging made two things quietly wrong: a background painted only
1132        // behind the text instead of across the bar, and a centred title with no free
1133        // space to be centred in.
1134        let mut chrome = Container::new();
1135        if known_width {
1136            chrome = chrome.width(width);
1137        } else {
1138            chrome = chrome.width_fraction(1.0);
1139        }
1140        // **Transparency wins outright.** A caller asking for a bar over an image has
1141        // already decided; arguing with the background and the elevation it inherited
1142        // from a theme would only make that decision conditional on the theme.
1143        if !force_material_transparency {
1144            // Material 3 shows height by **tinting** the surface, not only by casting a
1145            // shadow: at elevation 3 the tint is 8%, and it is what still reads on a dark
1146            // background where a shadow shows nothing at all.
1147            let surface = match (background, surface_tint) {
1148                (Some(base), Some(tint)) => Some(base.surface_tint(tint, elevation)),
1149                (base, _) => base,
1150            };
1151            if let Some(color) = surface {
1152                chrome = chrome.color(color);
1153            }
1154            // A shadow only where a colour is named: the reference's is transparent.
1155            if let Some(shadow) = shadow_color
1156                .or(t.shadow_color)
1157                .filter(|shadow| elevation > 0.0 && shadow.a > 0.0)
1158            {
1159                chrome = chrome.shadow(0.0, elevation * 0.25, elevation, shadow);
1160            }
1161        }
1162        // The shape clips as well as rounds: a surface that stopped short of its own
1163        // corner would square off the one the shadow had already curved.
1164        //
1165        // The corners a shape *resolves to* in the bar's own box, not the shape itself:
1166        // the chrome is a rectangle, and a shape that is not one has no corners to give.
1167        if let Some(shape) = shape.or(t.shape) {
1168            let box_ = Rect::new(0.0, 0.0, width, height.unwrap_or(APP_BAR_HEIGHT));
1169            if let Some((_, radius)) = shape.as_rounded(box_) {
1170                chrome = chrome.radius(radius).clip();
1171            }
1172        }
1173        // **Behind the toolbar, filling the bar's box.** A layer, not a slot: the bar is
1174        // exactly as tall as it was, however tall the widget would rather be. The bar's
1175        // own surface is painted by `chrome` underneath, so a translucent flexible space
1176        // still tints what the surface put there.
1177        let content: Box<dyn Widget<Msg>> = match flexible_space {
1178            Some(space) => Box::new(crate::Stack::new().layer(space).layer(content)),
1179            None => content,
1180        };
1181        Box::new(chrome.child(content))
1182    }
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187    use super::*;
1188    use crate::{build_ui, Runtime, Size, Theme};
1189
1190    #[derive(Clone, Debug, PartialEq)]
1191    enum Msg {
1192        PopupMenuButton,
1193        A,
1194        B,
1195        C,
1196    }
1197
1198    /// **A bar folds against the surface it was built for, with no caller saying how
1199    /// wide that is.**
1200    ///
1201    /// Milestone 393. The width used to be an argument, and an application that got it
1202    /// wrong got a bar that folded too early or ran off the edge. `AppBar::new` reads
1203    /// [`MediaQuery::of`] instead: the same three actions fit on a wide surface and
1204    /// fold into the overflow menu on a narrow one, and neither build says a number.
1205    #[test]
1206    fn a_bar_folds_against_the_surface_it_was_built_for() {
1207        let built = |width: f32| {
1208            let size = Size::new(width, 80.0);
1209            let bar = MediaQuery::new(size).scope(|| {
1210                AppBar::new()
1211                    .title(Text::new("Title"))
1212                    .overflow(false, Msg::PopupMenuButton)
1213                    .foldable_action("Action One", Msg::A)
1214                    .foldable_action("Action Two", Msg::B)
1215                    .foldable_action("Action Three", Msg::C)
1216                    .build()
1217            });
1218            // The actions still on the bar, by their words: a folded one leaves, and the
1219            // overflow button that takes its place says no words of its own.
1220            texts_of(bar.as_ref(), width)
1221                .into_iter()
1222                .filter(|t| t.starts_with("Action"))
1223                .count()
1224        };
1225        assert_eq!(built(1000.0), 3, "a wide surface shows them all");
1226        assert!(
1227            built(1000.0) > built(300.0),
1228            "a narrow surface folds what a wide one shows"
1229        );
1230    }
1231
1232    /// **A bar beside a cutout folds against the room it is left** (milestone 728): a
1233    /// phone on its side keeps its cutout and corners at the sides, the bar's row is kept
1234    /// clear of them, and the actions are counted into that row — not into the whole width,
1235    /// where the last ones ran off its end.
1236    #[test]
1237    fn a_bar_beside_a_cutout_folds_against_what_is_left() {
1238        let built = |width: f32, sides: f32| {
1239            let surface = MediaQuery::new(Size::new(width, 80.0)).with_insets(
1240                frus_core::WindowInsets::bars(frus_core::Insets::new(0.0, sides, 0.0, sides)),
1241            );
1242            let bar = surface.scope(|| {
1243                AppBar::new()
1244                    .title(Text::new("Title"))
1245                    .overflow(false, Msg::PopupMenuButton)
1246                    .foldable_action("Action One", Msg::A)
1247                    .foldable_action("Action Two", Msg::B)
1248                    .foldable_action("Action Three", Msg::C)
1249                    .build()
1250            });
1251            texts_of(bar.as_ref(), width)
1252                .into_iter()
1253                .filter(|t| t.starts_with("Action"))
1254                .count()
1255        };
1256        // The narrowest surface on which all three fit with nothing at the sides…
1257        let snug = (300..1000)
1258            .step_by(4)
1259            .map(|w| w as f32)
1260            .find(|&w| built(w, 0.0) == 3)
1261            .expect("a width on which all three fit");
1262        // …folds one once 59 a side is taken.
1263        assert!(
1264            built(snug, 59.0) < 3,
1265            "at {snug}, the cutouts leave no room for all three"
1266        );
1267        assert_eq!(
1268            built(snug + 2.0 * 59.0, 59.0),
1269            3,
1270            "given the room back, they fit"
1271        );
1272    }
1273
1274    /// The bar's surface as it is actually painted: the first filled rectangle as wide
1275    /// as the bar.
1276    fn surface(bar: &dyn Widget<Msg>, width: f32) -> Option<(Color, f32)> {
1277        let ui = build_ui(
1278            bar,
1279            Size::new(width, 200.0),
1280            &Runtime::default(),
1281            &Theme::default(),
1282        );
1283        // **Into the layers, not only across the top.** A clipped subtree is drained
1284        // into a composited `Layer`, so a bar with a shape paints nothing at the scene's
1285        // top level at all — which is what the first version of this helper concluded.
1286        fn find(primitives: &[crate::Primitive], width: f32) -> Option<(Color, f32)> {
1287            for p in primitives {
1288                match p {
1289                    // `blur > 0` is a **shadow**: drawn as wide as the thing casting it,
1290                    // and otherwise mistaken for the surface. This helper was, and
1291                    // reported a black at 22% alpha.
1292                    crate::Primitive::Rect {
1293                        rect,
1294                        color,
1295                        radius,
1296                        blur,
1297                        ..
1298                    } if rect.width >= width - 0.5 && *blur == 0.0 => {
1299                        return Some((*color, radius.top_left))
1300                    }
1301                    crate::Primitive::Layer { primitives, .. } => {
1302                        if let Some(found) = find(primitives, width) {
1303                            return Some(found);
1304                        }
1305                    }
1306                    _ => {}
1307                }
1308            }
1309            None
1310        }
1311        find(ui.scene().primitives(), width)
1312    }
1313
1314    /// **Material 3 shows height by tinting, not only by shadowing.**
1315    ///
1316    /// A shadow says a thing is above the page; the tint says how far, and on a dark
1317    /// background a shadow says nothing at all. The strength is the specification's
1318    /// table — 8% at elevation 3 — so the check is that the painted surface is the
1319    /// background moved **exactly** that far towards the tint, not merely that it moved.
1320    #[test]
1321    fn an_elevated_bar_is_tinted_by_its_elevation() {
1322        const W: f32 = 400.0;
1323        let base = Color::rgb(0.10, 0.10, 0.12);
1324        let tint = Color::rgb(0.60, 0.40, 1.00);
1325        let flat = AppBar::<Msg>::new()
1326            .title(Text::new("Title"))
1327            .width(W)
1328            .background(base)
1329            .build();
1330        let raised = AppBar::<Msg>::new()
1331            .title(Text::new("Title"))
1332            .width(W)
1333            .background(base)
1334            .elevation(3.0)
1335            .surface_tint(tint)
1336            .build();
1337        let (flat_color, _) = surface(flat.as_ref(), W).expect("a flat surface");
1338        let (raised_color, _) = surface(raised.as_ref(), W).expect("a raised surface");
1339        assert_eq!(flat_color, base, "nothing is tinted without a tint");
1340        let expected = base.lerp(tint, 0.08);
1341        assert!(
1342            (raised_color.r - expected.r).abs() < 1e-4
1343                && (raised_color.g - expected.g).abs() < 1e-4
1344                && (raised_color.b - expected.b).abs() < 1e-4,
1345            "elevation 3 should tint at 8%: {raised_color:?} against {expected:?}"
1346        );
1347    }
1348
1349    /// **An elevated bar casts nothing until it is given a shadow colour**
1350    /// (the reference's app bar defaults, line 2542). Its Material 3 bar keeps its height for the
1351    /// tint and makes the shadow transparent; this one drew a near-black of its own at 22 %
1352    /// under any bar with a height.
1353    #[test]
1354    fn an_elevated_bar_casts_a_shadow_only_in_a_colour_it_was_given() {
1355        const W: f32 = 400.0;
1356        // A shadow is a blurred rectangle — or, from a bar with no height, an unblurred one in
1357        // the shadow's colour, a hard box that a blur test alone never sees.
1358        fn shadows(primitives: &[crate::Primitive], named: Color, out: &mut Vec<Color>) {
1359            for p in primitives {
1360                match p {
1361                    crate::Primitive::Rect { color, blur, .. }
1362                        if *blur > 0.0 || *color == named =>
1363                    {
1364                        out.push(*color)
1365                    }
1366                    crate::Primitive::Layer { primitives, .. } => shadows(primitives, named, out),
1367                    _ => {}
1368                }
1369            }
1370        }
1371        let named = Color::rgba(0.0, 0.0, 0.0, 0.3);
1372        let cast = |bar: AppBar<Msg>, theme: &Theme| {
1373            let bar = bar.build();
1374            let ui = build_ui(
1375                bar.as_ref(),
1376                Size::new(W, 200.0),
1377                &Runtime::default(),
1378                theme,
1379            );
1380            let mut out = Vec::new();
1381            shadows(ui.scene().primitives(), named, &mut out);
1382            out
1383        };
1384        let bar = || AppBar::<Msg>::new().title(Text::new("Title")).width(W);
1385        let plain = Theme::default();
1386        assert!(
1387            cast(bar().elevation(3.0), &plain).is_empty(),
1388            "a height alone is no shadow"
1389        );
1390
1391        assert_eq!(
1392            cast(bar().elevation(3.0).shadow_color(named), &plain),
1393            vec![named]
1394        );
1395        let mut themed = Theme::default();
1396        themed.widgets.app_bar.shadow_color = Some(named);
1397        assert_eq!(
1398            cast(bar().elevation(3.0), &themed),
1399            vec![named],
1400            "or a colour the theme names"
1401        );
1402        assert!(
1403            cast(bar().shadow_color(named), &plain).is_empty(),
1404            "and a flat bar has no height to cast it from"
1405        );
1406    }
1407
1408    /// **A bar told to be transparent has no surface at all** — no background, no tint,
1409    /// no shadow — whatever a theme handed it.
1410    ///
1411    /// The reference's `forceMaterialTransparency`, for a bar over an image where the
1412    /// chrome should be the controls and nothing else. It overrides rather than argues,
1413    /// because a caller asking for transparency has already decided.
1414    #[test]
1415    fn a_transparent_bar_paints_no_surface() {
1416        const W: f32 = 400.0;
1417        let bar = AppBar::<Msg>::new()
1418            .title(Text::new("Title"))
1419            .width(W)
1420            .background(Color::rgb(0.9, 0.1, 0.1))
1421            .elevation(6.0)
1422            .surface_tint(Color::rgb(0.0, 1.0, 0.0))
1423            .force_material_transparency(true)
1424            .build();
1425        assert!(
1426            surface(bar.as_ref(), W).is_none(),
1427            "the bar painted a surface it was told not to"
1428        );
1429    }
1430
1431    /// **A bar takes a whole shape, not only a corner** (`app_bar.dart:209`).
1432    ///
1433    /// A bar is a rectangle, so what a shape contributes is the corners it resolves to in
1434    /// the bar's **own** box — and a stadium's box is a wide, short one, so it rounds to
1435    /// half the bar's height. That is the number the old `BorderRadius`-only property
1436    /// could never have been given, because it would have had to be written out at the
1437    /// call site and would then have been wrong for a bar of any other height.
1438    #[test]
1439    fn a_bar_takes_a_whole_shape_and_not_only_a_corner() {
1440        // A background, because `surface` looks for the widest opaque rectangle and a
1441        // bar the theme gives no colour paints none.
1442        let corner = |bar: AppBar<Msg>| {
1443            let built = bar
1444                .width(400.0)
1445                .background(Color::rgb(0.2, 0.2, 0.2))
1446                .build();
1447            surface(built.as_ref(), 400.0).map(|(_, radius)| radius)
1448        };
1449
1450        // A stadium: half the short side of a 400x64 bar (`APP_BAR_HEIGHT`), so 32.
1451        assert_eq!(
1452            corner(
1453                AppBar::new()
1454                    .title(Text::new("Title"))
1455                    .shape(ShapeBorder::stadium())
1456            ),
1457            Some(APP_BAR_HEIGHT / 2.0)
1458        );
1459
1460        // And half of a taller one, without the caller doing the arithmetic.
1461        assert_eq!(
1462            corner(
1463                AppBar::new()
1464                    .title(Text::new("Title"))
1465                    .height(80.0)
1466                    .shape(ShapeBorder::stadium())
1467            ),
1468            Some(40.0)
1469        );
1470
1471        // The shorthand still says what it always said.
1472        assert_eq!(
1473            corner(AppBar::new().title(Text::new("Title")).radius(18.0)),
1474            Some(18.0)
1475        );
1476
1477        // A shape with no rounded form leaves the bar square rather than clipping it to
1478        // something a bar is not.
1479        assert_eq!(
1480            corner(
1481                AppBar::new()
1482                    .title(Text::new("Title"))
1483                    .shape(ShapeBorder::beveled(6.0))
1484            ),
1485            Some(0.0)
1486        );
1487    }
1488
1489    /// **And a theme can name one**, which it could not: `AppBarTheme::shape` was the last
1490    /// theme field left carrying the reference's word with a corner radius behind it.
1491    #[test]
1492    fn a_theme_names_the_bar_s_shape() {
1493        let mut theme = Theme::dark();
1494        theme.widgets.app_bar.shape = Some(ShapeBorder::stadium());
1495        theme.widgets.app_bar.background = Some(Color::rgb(0.2, 0.2, 0.2));
1496        let built = AppBar::<Msg>::new()
1497            .title(Text::new("Title"))
1498            .width(400.0)
1499            .build();
1500        let ui = build_ui(
1501            built.as_ref(),
1502            Size::new(400.0, 200.0),
1503            &Runtime::default(),
1504            &theme,
1505        );
1506        fn find(primitives: &[crate::Primitive]) -> Option<f32> {
1507            for p in primitives {
1508                match p {
1509                    crate::Primitive::Rect {
1510                        rect, radius, blur, ..
1511                    } if rect.width >= 399.5 && *blur == 0.0 => return Some(radius.top_left),
1512                    crate::Primitive::Layer { primitives, .. } => {
1513                        if let Some(found) = find(primitives) {
1514                            return Some(found);
1515                        }
1516                    }
1517                    _ => {}
1518                }
1519            }
1520            None
1521        }
1522        assert_eq!(find(ui.scene().primitives()), Some(APP_BAR_HEIGHT / 2.0));
1523    }
1524
1525    /// **The shape rounds the bar's corners**, and the caller's outranks the theme's.
1526    #[test]
1527    fn the_bar_takes_the_shape_it_was_given() {
1528        const W: f32 = 400.0;
1529        let square = AppBar::<Msg>::new()
1530            .title(Text::new("Title"))
1531            .width(W)
1532            .background(Color::rgb(0.2, 0.2, 0.2))
1533            .build();
1534        let rounded = AppBar::<Msg>::new()
1535            .title(Text::new("Title"))
1536            .width(W)
1537            .background(Color::rgb(0.2, 0.2, 0.2))
1538            .radius(18.0)
1539            .build();
1540        assert_eq!(surface(square.as_ref(), W).expect("square").1, 0.0);
1541        assert_eq!(surface(rounded.as_ref(), W).expect("rounded").1, 18.0);
1542    }
1543
1544    /// **The bar's title is the screen's heading, and it was announced as a label.**
1545    ///
1546    /// A screen reader's user moves through a screen by its headings. The one every
1547    /// screen has is the bar's title, and ours carried `Role::Label` — one more piece of
1548    /// text among the rest, with nothing to jump to. The roadmap had this marked *done*
1549    /// since before milestone 397 and nothing in the framework emitted `Role::Heading` at
1550    /// all.
1551    #[test]
1552    fn the_bars_title_is_the_screens_heading() {
1553        const W: f32 = 400.0;
1554        let role_of_title = |exclude: bool| {
1555            let bar = AppBar::<Msg>::new()
1556                .title(Text::new("Inbox"))
1557                .width(W)
1558                .exclude_header_semantics(exclude)
1559                .build();
1560            let ui = build_ui(
1561                bar.as_ref(),
1562                Size::new(W, 200.0),
1563                &Runtime::default(),
1564                &Theme::default(),
1565            );
1566            ui.semantics()
1567                .iter()
1568                .find(|(_, _, s)| s.label.as_deref() == Some("Inbox"))
1569                .map(|(_, _, s)| s.role)
1570                .expect("the title is described")
1571        };
1572        assert_eq!(role_of_title(false), frus_core::Role::Heading);
1573        // And a bar that says its title is not the screen's name gets a plain label back.
1574        assert_eq!(role_of_title(true), frus_core::Role::Label);
1575    }
1576
1577    /// **A widget title is the screen's heading too**, which it was not until now.
1578    ///
1579    /// Milestone 397 could mark a *text* title as a landmark and not a widget one, because
1580    /// by then a widget title is a `Box<dyn Widget>` and the bar had no way in. So the
1581    /// accessibility of a bar depended on which constructor the caller had reached for —
1582    /// not a distinction anybody using assistive technology should be able to feel, and not
1583    /// one the reference has: its `title` is a `Widget?` and nothing else, wrapped once in
1584    /// `Semantics(header: true)` (`app_bar.dart:1071`).
1585    ///
1586    /// The two constructors are now the same path, and this asserts they answer the same.
1587    #[test]
1588    fn a_widget_title_is_a_heading_exactly_as_a_string_one_is() {
1589        const W: f32 = 400.0;
1590        let role_of = |bar: Box<dyn Widget<Msg>>| {
1591            let ui = build_ui(
1592                bar.as_ref(),
1593                Size::new(W, 200.0),
1594                &Runtime::default(),
1595                &Theme::default(),
1596            );
1597            ui.semantics()
1598                .iter()
1599                .find(|(_, _, s)| s.label.as_deref() == Some("Inbox"))
1600                .map(|(_, _, s)| s.role)
1601                .expect("the title is described")
1602        };
1603        let from_string = role_of(
1604            AppBar::<Msg>::new()
1605                .title(Text::new("Inbox"))
1606                .width(W)
1607                .build(),
1608        );
1609        let from_widget = role_of(
1610            AppBar::<Msg>::new()
1611                .title(crate::Text::new("Inbox"))
1612                .width(W)
1613                .build(),
1614        );
1615        assert_eq!(from_string, frus_core::Role::Heading);
1616        assert_eq!(
1617            from_widget, from_string,
1618            "the same bar, described differently by which constructor was used"
1619        );
1620    }
1621
1622    /// **A widget title wears the bar's type**, without the bar reaching into it.
1623    ///
1624    /// The reference hands the title style down rather than applying it
1625    /// (`app_bar.dart:1084`), which is the only thing that works when the title is a
1626    /// caller's widget with a `Text` somewhere inside it. A text that chose its own size
1627    /// keeps it — that is milestone 400's rule, and it is what makes the handover safe to
1628    /// apply to a widget the bar never looked into.
1629    #[test]
1630    fn the_bar_hands_its_type_to_a_title_it_did_not_build() {
1631        const W: f32 = 500.0;
1632        let size_of = |bar: Box<dyn Widget<Msg>>| {
1633            let ui = build_ui(
1634                bar.as_ref(),
1635                Size::new(W, 200.0),
1636                &Runtime::default(),
1637                &Theme::default(),
1638            );
1639            let mut found = None;
1640            fn walk(prims: &[crate::Primitive], out: &mut Option<f32>) {
1641                for p in prims {
1642                    match p {
1643                        crate::Primitive::Text { text, size, .. } if text == "Inbox" => {
1644                            *out = Some(*size)
1645                        }
1646                        crate::Primitive::Layer { primitives, .. } => walk(primitives, out),
1647                        _ => {}
1648                    }
1649                }
1650            }
1651            walk(ui.scene().primitives(), &mut found);
1652            found.expect("the title is drawn")
1653        };
1654        let inherited = size_of(
1655            AppBar::<Msg>::new()
1656                .title(crate::Text::new("Inbox"))
1657                .width(W)
1658                .build(),
1659        );
1660        assert_eq!(
1661            inherited,
1662            Theme::default().text.title_large.size.unwrap(),
1663            "a title that chose nothing wears the bar's type"
1664        );
1665        // And one that chose keeps its own, which is what makes the handover safe.
1666        let chosen = size_of(
1667            AppBar::<Msg>::new()
1668                .title(crate::Text::new("Inbox").size(11.0))
1669                .width(W)
1670                .build(),
1671        );
1672        assert_eq!(chosen, 11.0);
1673    }
1674
1675    /// **A bar with no title has none**, rather than an empty one.
1676    ///
1677    /// Milestone 522. `AppBar::new` took the title as a string, so a bar with nothing
1678    /// between its leading and its actions — a back button and a Save — was written
1679    /// `new("")`, and an empty text was laid out and painted where a title would have
1680    /// been. The reference's title is a `Widget?`: absent, nothing is built there.
1681    #[test]
1682    fn a_bar_with_no_title_builds_none() {
1683        const W: f32 = 400.0;
1684        let bar = AppBar::<Msg>::new()
1685            .width(W)
1686            .leading(button("Back", Msg::A))
1687            .foldable_action("Save", Msg::B)
1688            .build();
1689        assert_eq!(
1690            texts_of(bar.as_ref(), W),
1691            ["Back", "Save"],
1692            "nothing is drawn between the leading and the action"
1693        );
1694        let ui = build_ui(
1695            bar.as_ref(),
1696            Size::new(W, 200.0),
1697            &Runtime::default(),
1698            &Theme::default(),
1699        );
1700        assert!(
1701            ui.semantics()
1702                .iter()
1703                .all(|(_, _, s)| s.role != frus_core::Role::Heading),
1704            "and nothing is announced as the screen's heading"
1705        );
1706        let save_x = ui
1707            .scene()
1708            .primitives()
1709            .iter()
1710            .find_map(|p| match p {
1711                frus_core::Primitive::Text { text, position, .. } if text == "Save" => {
1712                    Some(position.x)
1713                }
1714                _ => None,
1715            })
1716            .expect("the action is drawn");
1717        assert!(save_x > W / 2.0, "the action keeps the end: x = {save_x}");
1718        assert_eq!(bar_height(bar.as_ref()), Some(APP_BAR_HEIGHT));
1719    }
1720
1721    /// **A bar with no title owes the fold no room for one**, nor a spacing before it.
1722    ///
1723    /// The budget counts the row's children before the actions, and the title was always
1724    /// one of them. At exactly the width one action takes, an untitled bar shows the action;
1725    /// charged a spacing or a join for a title it has not got, it would fold it away.
1726    ///
1727    /// Told where its title goes rather than left to the platform: Apple's platforms centre
1728    /// a bar with one action, and a centred title has a spring of its own. Neither costs room
1729    /// now that the reference's toolbar has no gaps (milestone 611).
1730    #[test]
1731    fn an_untitled_bar_gives_its_actions_the_room() {
1732        let theme = Theme::default();
1733        let size = action_size_of(None, &theme);
1734        let save = AppBar::<Msg>::widget_width(
1735            &AppBar::<Msg>::action_button("Save".into(), Msg::B, size),
1736            &theme,
1737        );
1738        let shows = |centered: bool, width: f32| {
1739            let bar = AppBar::<Msg>::new()
1740                .width(width)
1741                .center_title(centered)
1742                .overflow(false, Msg::PopupMenuButton)
1743                .foldable_action("Save", Msg::B)
1744                .build();
1745            texts_of(bar.as_ref(), width).iter().any(|t| t == "Save")
1746        };
1747        let width = H_PAD * 2.0 + save + 0.25;
1748        assert!(
1749            shows(false, width),
1750            "flush, the action fits a bar {width} px wide"
1751        );
1752        assert!(shows(true, width), "centred, too");
1753        assert!(!shows(false, width - 1.0), "and a pixel less folds it");
1754    }
1755
1756    /// Every path the bar paints, layers included.
1757    fn glyph_colors(bar: &dyn Widget<Msg>, width: f32) -> Vec<Color> {
1758        fn walk(primitives: &[crate::Primitive], out: &mut Vec<Color>) {
1759            for p in primitives {
1760                match p {
1761                    crate::Primitive::Path {
1762                        fill: Some(color), ..
1763                    } => out.push(*color),
1764                    crate::Primitive::Layer { primitives, .. } => walk(primitives, out),
1765                    _ => {}
1766                }
1767            }
1768        }
1769        let ui = build_ui(
1770            bar,
1771            Size::new(width, 200.0),
1772            &Runtime::default(),
1773            &Theme::default(),
1774        );
1775        let mut out = Vec::new();
1776        walk(ui.scene().primitives(), &mut out);
1777        out
1778    }
1779
1780    /// **The bar's icon theme reaches a glyph it never sees.**
1781    ///
1782    /// The reference's `IconTheme` is an *inherited* widget, and that is the whole point:
1783    /// an app bar cannot restyle an icon nested inside a button it was handed. Delivering
1784    /// the colour as a theme for the subtree is what reaches it. The check is a leading
1785    /// slot holding a plain `Icon`, recoloured without the bar touching the widget.
1786    #[test]
1787    fn the_bars_icon_theme_reaches_a_glyph_it_never_sees() {
1788        const W: f32 = 400.0;
1789        let wanted = Color::rgb(0.9, 0.2, 0.4);
1790        let plain = AppBar::<Msg>::new()
1791            .title(Text::new("Title"))
1792            .width(W)
1793            .leading(crate::Icon::new(crate::Icons::STAR))
1794            .build();
1795        let themed = AppBar::<Msg>::new()
1796            .title(Text::new("Title"))
1797            .width(W)
1798            .leading(crate::Icon::new(crate::Icons::STAR))
1799            .icon_theme(crate::widgettheme::IconTheme {
1800                color: Some(wanted),
1801                size: None,
1802            })
1803            .build();
1804        assert!(
1805            !glyph_colors(plain.as_ref(), W).contains(&wanted),
1806            "the plain bar should not already be that colour"
1807        );
1808        assert!(
1809            glyph_colors(themed.as_ref(), W).contains(&wanted),
1810            "the icon theme never reached the glyph"
1811        );
1812    }
1813
1814    /// Every text in the built bar, as `(the words, their size)`. Composited layers are
1815    /// walked into: a clipped subtree is drained into one, and a bar with a shape clips.
1816    fn toolbar_texts(bar: &dyn Widget<Msg>, width: f32) -> Vec<(String, f32)> {
1817        fn walk(prims: &[crate::Primitive], out: &mut Vec<(String, f32)>) {
1818            for p in prims {
1819                match p {
1820                    crate::Primitive::Text { text, size, .. } => out.push((text.clone(), *size)),
1821                    crate::Primitive::Layer { primitives, .. } => walk(primitives, out),
1822                    _ => {}
1823                }
1824            }
1825        }
1826        let ui = build_ui(
1827            bar,
1828            Size::new(width, 200.0),
1829            &Runtime::default(),
1830            &Theme::default(),
1831        );
1832        let mut out = Vec::new();
1833        walk(ui.scene().primitives(), &mut out);
1834        out
1835    }
1836
1837    /// **The bar's toolbar style reaches a text it never sees, and not its title.**
1838    ///
1839    /// Same reasoning as the icon theme, and the same delivery: an app bar cannot restyle
1840    /// a run of words nested inside a widget it was handed, so the style goes down as a
1841    /// theme for the subtree rather than in as an argument.
1842    ///
1843    /// The second half is the one worth a test of its own. The reference keeps
1844    /// `toolbarTextStyle` and `titleTextStyle` apart, and a bar that let the first reach
1845    /// the title would quietly resize it — the one line in the bar that already had an
1846    /// answer, and the one whose width decides how many actions still fit.
1847    #[test]
1848    fn the_toolbar_style_reaches_the_words_around_the_title() {
1849        const W: f32 = 600.0;
1850        let size_of = |texts: &[(String, f32)], words: &str| {
1851            texts
1852                .iter()
1853                .find(|(t, _)| t == words)
1854                .map(|(_, s)| *s)
1855                .unwrap_or_else(|| panic!("no {words:?} in {texts:?}"))
1856        };
1857        let bar = |style: Option<TextStyle>| {
1858            let mut bar = AppBar::<Msg>::new()
1859                .title(Text::new("Title"))
1860                .width(W)
1861                .leading(crate::Text::new("Back"));
1862            if let Some(style) = style {
1863                bar = bar.toolbar_text_style(style);
1864            }
1865            toolbar_texts(bar.build().as_ref(), W)
1866        };
1867        let plain = bar(None);
1868        let dressed = bar(Some(TextStyle::new(11.0)));
1869        assert!(
1870            size_of(&plain, "Back") > 11.0,
1871            "the plain bar should not already be at 11 px"
1872        );
1873        assert_eq!(size_of(&dressed, "Back"), 11.0, "the style never arrived");
1874        assert_eq!(
1875            size_of(&dressed, "Title"),
1876            size_of(&plain, "Title"),
1877            "and it must not have touched the title"
1878        );
1879    }
1880
1881    /// **The actions may part company with the leading slot** — the reference's
1882    /// `actionsIconTheme`, for a back arrow in the foreground colour beside actions in a
1883    /// muted one. Unset, they follow the bar's own.
1884    #[test]
1885    fn the_actions_may_wear_their_own_icon_theme() {
1886        const W: f32 = 600.0;
1887        let lead = Color::rgb(0.1, 0.8, 0.2);
1888        let acts = Color::rgb(0.2, 0.1, 0.9);
1889        let bar = AppBar::<Msg>::new()
1890            .title(Text::new("Title"))
1891            .width(W)
1892            .leading(crate::Icon::new(crate::Icons::STAR))
1893            .action(crate::Icon::new(crate::Icons::FAVORITE))
1894            .icon_theme(crate::widgettheme::IconTheme {
1895                color: Some(lead),
1896                size: None,
1897            })
1898            .actions_icon_theme(crate::widgettheme::IconTheme {
1899                color: Some(acts),
1900                size: None,
1901            })
1902            .build();
1903        let colors = glyph_colors(bar.as_ref(), W);
1904        assert!(colors.contains(&lead), "the leading glyph kept its colour");
1905        assert!(colors.contains(&acts), "the action glyph took its own");
1906    }
1907
1908    /// **An action is any widget**, as the reference's `actions` is a list of widgets
1909    /// (milestone 613): added one at a time or as a list, they keep the order they were
1910    /// given in, and none of them folds, however narrow the bar.
1911    #[test]
1912    fn actions_are_widgets_kept_in_order() {
1913        let bar = |width: f32| {
1914            AppBar::<Msg>::new()
1915                .title(Text::new("Title"))
1916                .width(width)
1917                .overflow(false, Msg::A)
1918                .action(Text::new("First"))
1919                .actions([
1920                    Box::new(Text::new("Second")) as Box<dyn Widget<Msg>>,
1921                    Box::new(Text::new("Third")),
1922                ])
1923                .action(crate::IconButton::new(crate::Icons::SEARCH).on_press(Msg::B))
1924                .action(Text::new("Fourth"))
1925                .build()
1926        };
1927        for width in [1000.0, 200.0] {
1928            let texts: Vec<String> = texts_of(bar(width).as_ref(), width)
1929                .into_iter()
1930                .filter(|t| t != "Title")
1931                .collect();
1932            assert_eq!(
1933                texts,
1934                ["First", "Second", "Third", "Fourth"],
1935                "at {width} px"
1936            );
1937        }
1938    }
1939
1940    /// **A widget action is a press away**: the bar hands it its message untouched.
1941    #[test]
1942    fn a_widget_action_emits_its_own_message() {
1943        const W: f32 = 600.0;
1944        let bar = AppBar::<Msg>::new()
1945            .title(Text::new("Title"))
1946            .width(W)
1947            .action(button("Go", Msg::C))
1948            .build();
1949        crate::build_deferred(bar.as_ref(), &Theme::default(), &Runtime::default());
1950        fn walk(widget: &dyn Widget<Msg>, out: &mut Vec<Msg>) {
1951            if let Some(message) = widget.on_click() {
1952                out.push(message);
1953            }
1954            for child in widget.children() {
1955                walk(child.as_ref(), out);
1956            }
1957        }
1958        let mut messages = Vec::new();
1959        walk(bar.as_ref(), &mut messages);
1960        assert_eq!(messages, vec![Msg::C]);
1961    }
1962
1963    /// **The flexible space is a layer, not a slot**: it fills the bar's box and leaves
1964    /// its height alone, however tall the widget would rather be.
1965    #[test]
1966    fn the_flexible_space_does_not_make_the_bar_taller() {
1967        const W: f32 = 400.0;
1968        let height = |with_space: bool| {
1969            let mut bar = AppBar::<Msg>::new().title(Text::new("Title")).width(W);
1970            if with_space {
1971                bar = bar.flexible_space(Container::new().height(400.0));
1972            }
1973            let built = bar.build();
1974            let ui = build_ui(
1975                built.as_ref(),
1976                Size::new(W, 800.0),
1977                &Runtime::default(),
1978                &Theme::default(),
1979            );
1980            ui.scene()
1981                .primitives()
1982                .iter()
1983                .filter_map(|p| match p {
1984                    crate::Primitive::Text { position, .. } => Some(position.y),
1985                    _ => None,
1986                })
1987                .fold(0.0_f32, f32::max)
1988        };
1989        // The title sits where it sat: a 400 px flexible space did not push it down.
1990        assert!(
1991            (height(true) - height(false)).abs() < 0.5,
1992            "the flexible space moved the title by {}",
1993            height(true) - height(false)
1994        );
1995    }
1996
1997    /// Counts the buttons (rectangles with a shadow), excluding floating menu items.
1998    /// **The actions a bar keeps inline fit in the bar**, at every width and whatever
1999    /// its labels are.
2000    ///
2001    /// The fold was decided on an estimate of each button that the buttons did not
2002    /// share — 20 px either side of the label where a button takes 24, and no minimum
2003    /// where a button is never narrower than 64 — and on a row with a gap fewer than it
2004    /// had. Each slip was a few pixels, and they add up: the demo's own bar ran 13 px
2005    /// past its edge on a desktop the day one action was taken out of it.
2006    #[test]
2007    fn the_actions_a_bar_keeps_fit_in_it_at_every_width() {
2008        const LABELS: [&str; 11] = [
2009            "Light",
2010            "Seed: default",
2011            "RTL",
2012            "Français",
2013            "A+",
2014            "A−",
2015            "Log →",
2016            "Settings →",
2017            "Quick actions",
2018            "Save",
2019            "Clear completed",
2020        ];
2021        let mut worst: Vec<String> = Vec::new();
2022        // The leading is an icon button, as in the demo, or a labelled button, whose width is
2023        // declared exactly: an icon button is charged the whole leading slot and drawn
2024        // narrower, and that slack alone would hide a gap the budget forgot.
2025        //
2026        // Each from the narrowest bar it can be, and not a pixel wider: below that, with every
2027        // action folded, the leading, the spacing, the title's floor and the overflow button
2028        // are wider than the bar on their own, and there is nothing left to fold. The
2029        // reference's toolbar has no margin and no gaps (milestone 611), so it is their sum:
2030        // an icon leading's 56 px slot + 16 + 64 + the 48 px overflow button is 184; a
2031        // labelled "Menu" leading is 87 px, so 215; with no leading the 16 px spacing still
2032        // opens the title, so 128; a centred title costs nothing more, and a 12 px padding
2033        // two sides of the actions does.
2034        //
2035        // And without a title: no floor and no spacing, the leading and the overflow alone.
2036        let configurations = [
2037            ("icon", false, None, true, 184),
2038            ("button", false, None, true, 215),
2039            ("none", false, None, true, 128),
2040            ("button", true, None, true, 215),
2041            ("button", false, Some(12.0), true, 239),
2042            ("button", false, None, false, 135),
2043            ("button", true, None, false, 135),
2044        ];
2045        for (leading, centered, padding, titled, narrowest) in configurations {
2046            for width in (narrowest..=1600).step_by(7) {
2047                let size = Size::new(width as f32, 80.0);
2048                let bar = MediaQuery::new(size).scope(|| {
2049                    let mut bar = AppBar::new()
2050                        .center_title(centered)
2051                        .overflow(false, Msg::PopupMenuButton);
2052                    if titled {
2053                        bar = bar.title(Text::new("My Tasks"));
2054                    }
2055                    match leading {
2056                        "icon" => {
2057                            bar = bar.leading(
2058                                crate::IconButton::new(crate::Icons::MENU).on_press(Msg::A),
2059                            )
2060                        }
2061                        "button" => bar = bar.leading(button::<Msg>("Menu", Msg::A)),
2062                        _ => {}
2063                    }
2064                    if let Some(padding) = padding {
2065                        bar = bar.actions_padding(padding);
2066                    }
2067                    for label in LABELS {
2068                        bar = bar.foldable_action(label, Msg::B);
2069                    }
2070                    bar.build()
2071                });
2072                let ui = build_ui(bar.as_ref(), size, &Runtime::default(), &Theme::default());
2073                for o in ui.overflows() {
2074                    worst.push(format!(
2075                        "{width} px, leading {leading}, centred {centered}, padding {padding:?}, \
2076                         titled {titled}: \
2077                         {:?} by {:.1} px",
2078                        o.side, o.amount
2079                    ));
2080                }
2081            }
2082        }
2083        assert!(worst.is_empty(), "{worst:#?}");
2084    }
2085
2086    /// **A title with nothing beside it stops short of the edge too.**
2087    ///
2088    /// The join between the title's spring and the actions is charged to each action. A
2089    /// bar with none still has that join — the actions' row is there, empty — so leaving it
2090    /// out caps a long title one gap too wide.
2091    #[test]
2092    fn a_title_with_no_actions_stops_short_of_the_edge() {
2093        let mut worst: Vec<String> = Vec::new();
2094        for width in (240..=900).step_by(11) {
2095            let size = Size::new(width as f32, 80.0);
2096            let bar = MediaQuery::new(size).scope(|| {
2097                AppBar::new()
2098                    .title(Text::new(
2099                        "A title long enough to be cut short at every width this sweeps, and more",
2100                    ))
2101                    // A leading of an exact width, so no slack in its slot hides the gap.
2102                    .leading(button::<Msg>("Menu", Msg::A))
2103                    .build()
2104            });
2105            let ui = build_ui(bar.as_ref(), size, &Runtime::default(), &Theme::default());
2106            for o in ui.overflows() {
2107                worst.push(format!("{width} px: {:?} by {:.1} px", o.side, o.amount));
2108            }
2109        }
2110        assert!(worst.is_empty(), "{worst:#?}");
2111    }
2112
2113    fn inline_buttons(width: f32, open: bool) -> usize {
2114        let bar = AppBar::new()
2115            .title(Text::new("Title"))
2116            .width(width)
2117            .overflow(open, Msg::PopupMenuButton)
2118            .foldable_action("Action One", Msg::A)
2119            .foldable_action("Action Two", Msg::B)
2120            .foldable_action("Action Three", Msg::C)
2121            .build();
2122        let ui = build_ui(
2123            bar.as_ref(),
2124            Size::new(width, 80.0),
2125            &Runtime::default(),
2126            &Theme::default(),
2127        );
2128        // Count the buttons by what they *are* rather than by what they paint: they used
2129        // to be counted by their shadows, and milestone 313 took the shadow off every
2130        // button but the elevated one — a proxy that stopped standing for anything.
2131        ui.semantics()
2132            .iter()
2133            .filter(|(_, _, s)| s.role == frus_core::Role::Button)
2134            .count()
2135    }
2136
2137    /// The middle term milestone 318 could not add. `center_title` decides which children
2138    /// exist and in what order, so it cannot be resolved by any hook that runs after the
2139    /// row has been assembled — the composition is deferred to the theme instead.
2140    #[test]
2141    fn center_title_resolves_caller_then_theme_then_platform() {
2142        // A centred title is a spring, the title, another spring; a flush one is the
2143        // title straight after the leading. Counting the springs reads the decision.
2144        fn springs(widget: &dyn Widget<Msg>, theme: &crate::theme::Theme) -> usize {
2145            widget.build_themed(theme);
2146            let mine = usize::from(matches!(
2147                widget.style().flex_grow,
2148                g if g > 0.0
2149            ));
2150            mine + widget
2151                .children()
2152                .iter()
2153                .map(|c| springs(c.as_ref(), theme))
2154                .sum::<usize>()
2155        }
2156        let bar = || AppBar::<Msg>::new().title(Text::new("Title")).width(400.0);
2157        let plain = crate::theme::Theme::default();
2158        let mut centred = crate::theme::Theme::default();
2159        centred.widgets.app_bar.center_title = Some(true);
2160        let mut flush = crate::theme::Theme::default();
2161        flush.widgets.app_bar.center_title = Some(false);
2162
2163        let untold = springs(bar().build().as_ref(), &plain);
2164        // Guard the instrument before trusting it: if centring did not change the
2165        // composition, every assertion below would pass while proving nothing.
2166        assert_ne!(
2167            springs(bar().center_title(true).build().as_ref(), &plain),
2168            springs(bar().center_title(false).build().as_ref(), &plain),
2169            "centring changes the row, or this test cannot see anything"
2170        );
2171        assert_eq!(
2172            springs(bar().build().as_ref(), &centred),
2173            springs(bar().center_title(true).build().as_ref(), &plain),
2174            "the theme centres it, as the caller would have"
2175        );
2176        assert_eq!(
2177            springs(bar().build().as_ref(), &flush),
2178            springs(bar().center_title(false).build().as_ref(), &plain),
2179            "and un-centres it"
2180        );
2181        // The caller outranks the theme, whichever way the theme leant.
2182        assert_eq!(
2183            springs(bar().center_title(false).build().as_ref(), &centred),
2184            springs(bar().center_title(false).build().as_ref(), &plain)
2185        );
2186        // And with neither, the platform still has the last word.
2187        assert_eq!(
2188            untold,
2189            springs(
2190                bar()
2191                    .center_title(platform_centers_title(plain.platform, 0))
2192                    .build()
2193                    .as_ref(),
2194                &plain
2195            )
2196        );
2197    }
2198
2199    /// Chrome is a **fixed** height, not its content's. A bar that hugged what happened
2200    /// to be in it made every screen a slightly different shape, and moved the page below
2201    /// it the moment an action appeared or a title grew.
2202    #[test]
2203    fn the_bar_is_the_references_height() {
2204        let plain = AppBar::<Msg>::new()
2205            .title(Text::new("Title"))
2206            .width(400.0)
2207            .build();
2208        let busy = AppBar::<Msg>::new()
2209            .title(Text::new("Title"))
2210            .width(400.0)
2211            .leading(button("=", Msg::A))
2212            .foldable_action("One", Msg::A)
2213            .foldable_action("Two", Msg::B)
2214            .build();
2215        assert_eq!(bar_height(plain.as_ref()), Some(APP_BAR_HEIGHT));
2216        assert_eq!(
2217            bar_height(busy.as_ref()),
2218            Some(APP_BAR_HEIGHT),
2219            "content does not change the chrome's height"
2220        );
2221        let tall = AppBar::<Msg>::new()
2222            .title(Text::new("Title"))
2223            .width(400.0)
2224            .height(96.0)
2225            .build();
2226        assert_eq!(
2227            bar_height(tall.as_ref()),
2228            Some(96.0),
2229            "and the caller still decides"
2230        );
2231    }
2232
2233    /// The toolbar's imposed height, wherever it sits in the composition.
2234    ///
2235    /// The bar defers its composition to the theme, so the walk down has to build it —
2236    /// exactly as the layout pass does — before the children are there to look at.
2237    fn bar_height(widget: &dyn Widget<Msg>) -> Option<f32> {
2238        widget.build_themed(&crate::theme::Theme::default());
2239        if let frus_layout::Dimension::Length(h) = widget.style().height {
2240            if (h - APP_BAR_HEIGHT).abs() < 0.01 || h > APP_BAR_HEIGHT {
2241                return Some(h);
2242            }
2243        }
2244        widget
2245            .children()
2246            .iter()
2247            .find_map(|c| bar_height(c.as_ref()))
2248    }
2249
2250    #[test]
2251    fn wide_bar_shows_all_actions_inline() {
2252        // Wide enough: the 3 actions fit, and there is no overflow button.
2253        assert_eq!(inline_buttons(1200.0, false), 3);
2254    }
2255
2256    #[test]
2257    fn narrow_bar_collapses_into_overflow() {
2258        // Narrow: at most one or two inline actions + the `⋯` button.
2259        let n = inline_buttons(300.0, false);
2260        assert!(
2261            n < 3,
2262            "expected a fold into overflow, got {n} inline buttons"
2263        );
2264        assert!(n >= 1, "the overflow button must be present");
2265    }
2266
2267    /// The bar keeps a horizontal margin: the content (the leading on the left, the
2268    /// last action on the right) does not touch the viewport's edges.
2269    #[test]
2270    fn content_keeps_a_horizontal_margin() {
2271        const W: f32 = 400.0;
2272        let bar = AppBar::new()
2273            .title(Text::new("Title"))
2274            .width(W)
2275            .leading(button("M", Msg::PopupMenuButton).size(16.0))
2276            .overflow(false, Msg::PopupMenuButton)
2277            .foldable_action("One", Msg::A)
2278            .build();
2279        let ui = build_ui(
2280            bar.as_ref(),
2281            Size::new(W, 80.0),
2282            &Runtime::default(),
2283            &Theme::default(),
2284        );
2285        // The horizontal bounds of the **texts** (title + labels): having no shadow,
2286        // they reflect the content's real position (a shadow's blur, by contrast,
2287        // legitimately overflows).
2288        let mut min_x = f32::MAX;
2289        let mut max_x = f32::MIN;
2290        for p in ui.scene().primitives() {
2291            if let frus_core::Primitive::Text {
2292                position,
2293                size,
2294                text,
2295                ..
2296            } = p
2297            {
2298                min_x = min_x.min(position.x);
2299                // An approximate text width (an upper bound is enough here).
2300                max_x = max_x.max(position.x + text.chars().count() as f32 * size * 0.7);
2301            }
2302        }
2303        assert!(
2304            min_x >= H_PAD - 0.5,
2305            "content flush against the left edge ({min_x})"
2306        );
2307        assert!(
2308            max_x <= W - H_PAD + 0.5,
2309            "content overflowing on the right ({max_x} > {})",
2310            W - H_PAD
2311        );
2312    }
2313
2314    /// **A bar outside a shell keeps its own content out of the status bar.**
2315    ///
2316    /// It did not, and that was the recorded cost of the shell owning the switch: the
2317    /// scaffold insetted the bar from outside, so a bar used on its own had nothing to
2318    /// inset it and would not inset itself. Milestone 417 gives it the reference's
2319    /// arrangement — the shell says what there is to consume, the bar consumes it.
2320    ///
2321    /// The **surface** still runs behind the status bar; only the toolbar is held clear.
2322    /// That is what a Material bar looks like, and what `app_bar.dart:1189` says in words.
2323    #[test]
2324    fn a_bar_on_its_own_clears_the_status_bar() {
2325        const TOP: f32 = 40.0;
2326        let title_y = |primary: bool| {
2327            let size = frus_core::Size::new(400.0, 200.0);
2328            let surface = crate::MediaQuery::new(size).with_insets(frus_core::WindowInsets::bars(
2329                frus_core::Insets::new(TOP, 0.0, 0.0, 0.0),
2330            ));
2331            // Built **and laid out** under the surface, the way a frame does it: the
2332            // shell holds one description across the build, the layout and the paint
2333            // (milestone 408), and a bar reads it when the walk reaches it.
2334            surface.scope(|| {
2335                let bar = AppBar::<Msg>::new()
2336                    .title(Text::new("Inbox"))
2337                    .primary(primary)
2338                    .build();
2339                crate::build_ui(
2340                    bar.as_ref(),
2341                    size,
2342                    &crate::Runtime::default(),
2343                    &Theme::default(),
2344                )
2345                .scene()
2346                .primitives()
2347                .iter()
2348                .find_map(|p| match p {
2349                    frus_core::Primitive::Text { position, .. } => Some(position.y),
2350                    _ => None,
2351                })
2352                .expect("the title is drawn")
2353            })
2354        };
2355        assert!(
2356            (title_y(true) - title_y(false) - TOP).abs() < 0.5,
2357            "a primary bar holds its title off by exactly the intrusion: {} vs {}",
2358            title_y(true),
2359            title_y(false)
2360        );
2361        assert!(
2362            title_y(true) >= TOP,
2363            "and it is below the status bar, not under it: {}",
2364            title_y(true)
2365        );
2366    }
2367
2368    #[test]
2369    fn title_style_is_customizable() {
2370        // An overridden title style: bold 24, instead of the default medium 20.
2371        let bar = AppBar::<Msg>::new()
2372            .title(Text::new("Title"))
2373            .title_style(TextStyle::new(24.0).weight(FontWeight::Bold))
2374            .build();
2375        let ui = build_ui(
2376            bar.as_ref(),
2377            Size::new(800.0, 80.0),
2378            &Runtime::default(),
2379            &Theme::default(),
2380        );
2381        let styled = ui.scene().primitives().iter().any(|p| {
2382            matches!(
2383                p,
2384                frus_core::Primitive::Text { text, size, weight, .. }
2385                    if text == "Title" && *size == 24.0 && *weight == FontWeight::Bold
2386            )
2387        });
2388        assert!(styled, "the title must carry the overridden style");
2389    }
2390
2391    #[test]
2392    fn title_can_be_an_arbitrary_widget() {
2393        let bar = AppBar::<Msg>::new()
2394            .title(Text::new("ignored"))
2395            .title(Text::new("Logo").size(18.0))
2396            .build();
2397        let ui = build_ui(
2398            bar.as_ref(),
2399            Size::new(800.0, 80.0),
2400            &Runtime::default(),
2401            &Theme::default(),
2402        );
2403        let texts: Vec<_> = ui
2404            .scene()
2405            .primitives()
2406            .iter()
2407            .filter_map(|p| match p {
2408                frus_core::Primitive::Text { text, .. } => Some(text.clone()),
2409                _ => None,
2410            })
2411            .collect();
2412        assert!(
2413            texts.contains(&"Logo".to_string()),
2414            "the title widget is rendered"
2415        );
2416        assert!(
2417            !texts.contains(&"ignored".to_string()),
2418            "the text title is replaced"
2419        );
2420    }
2421
2422    /// The texts a bar paints, in paint order.
2423    fn texts_of(bar: &dyn Widget<Msg>, width: f32) -> Vec<String> {
2424        let ui = build_ui(
2425            bar,
2426            Size::new(width, 120.0),
2427            &Runtime::default(),
2428            &Theme::default(),
2429        );
2430        ui.scene()
2431            .primitives()
2432            .iter()
2433            .filter_map(|p| match p {
2434                frus_core::Primitive::Text { text, .. } => Some(text.clone()),
2435                _ => None,
2436            })
2437            .collect()
2438    }
2439
2440    #[test]
2441    fn a_title_too_long_for_the_bar_is_cut_not_pushed_off() {
2442        const W: f32 = 320.0;
2443        let bar = AppBar::new()
2444            .title(Text::new(
2445                "A title far too long to fit in a narrow application bar",
2446            ))
2447            .width(W)
2448            .overflow(false, Msg::PopupMenuButton)
2449            .foldable_action("One", Msg::A)
2450            .build();
2451        let texts = texts_of(bar.as_ref(), W);
2452        let title = texts.first().expect("a title");
2453        assert!(title.ends_with('\u{2026}'), "cut with an ellipsis: {title}");
2454        assert!(
2455            title.len() < "A title far too long to fit in a narrow application bar".len(),
2456            "actually shortened: {title}"
2457        );
2458        // And the action survived: it is the actions that are served first.
2459        assert!(texts.iter().any(|t| t == "One"), "{texts:?}");
2460    }
2461
2462    #[test]
2463    fn a_title_that_fits_is_left_exactly_as_it_was() {
2464        let bar = AppBar::<Msg>::new()
2465            .title(Text::new("Short"))
2466            .width(900.0)
2467            .build();
2468        assert_eq!(
2469            texts_of(bar.as_ref(), 900.0).first().map(String::as_str),
2470            Some("Short")
2471        );
2472    }
2473
2474    /// **The reference's table** (`app_bar.dart:805`): iOS and macOS centre a title with
2475    /// fewer than two actions; the other four never do.
2476    #[test]
2477    fn the_title_follows_the_platform_until_it_is_told_otherwise() {
2478        use frus_core::TargetPlatform as P;
2479        for platform in P::ALL {
2480            let apple = matches!(platform, P::Ios | P::MacOs);
2481            assert_eq!(
2482                platform_centers_title(platform, 0),
2483                apple,
2484                "{platform}, bare"
2485            );
2486            assert_eq!(
2487                platform_centers_title(platform, 1),
2488                apple,
2489                "{platform}, one"
2490            );
2491            assert!(!platform_centers_title(platform, 2), "{platform}, crowded");
2492        }
2493    }
2494
2495    /// **The theme's platform, not the build's** (milestone 615): a theme set to iOS
2496    /// centres the title on whatever this test runs on, and one set to Android does not;
2497    /// and two actions put an iOS bar's title flush.
2498    #[test]
2499    fn the_title_follows_the_theme_s_platform() {
2500        fn springs(widget: &dyn Widget<Msg>, theme: &crate::theme::Theme) -> usize {
2501            widget.build_themed(theme);
2502            let mine = usize::from(widget.style().flex_grow > 0.0);
2503            mine + widget
2504                .children()
2505                .iter()
2506                .map(|c| springs(c.as_ref(), theme))
2507                .sum::<usize>()
2508        }
2509        let bar = || AppBar::<Msg>::new().title(Text::new("Title")).width(400.0);
2510        let ios = crate::theme::Theme::default().with_platform(frus_core::TargetPlatform::Ios);
2511        let android =
2512            crate::theme::Theme::default().with_platform(frus_core::TargetPlatform::Android);
2513        let centred = springs(bar().center_title(true).build().as_ref(), &android);
2514        let flush = springs(bar().center_title(false).build().as_ref(), &android);
2515        assert_ne!(centred, flush, "the instrument tells the two apart");
2516        assert_eq!(springs(bar().build().as_ref(), &ios), centred);
2517        assert_eq!(springs(bar().build().as_ref(), &android), flush);
2518        // Two actions of its own, and an iOS bar puts its title flush.
2519        let crowded = || bar().action(Text::new("A")).action(Text::new("B"));
2520        assert_eq!(
2521            springs(crowded().build().as_ref(), &ios),
2522            springs(crowded().center_title(false).build().as_ref(), &ios)
2523        );
2524    }
2525
2526    /// **A width nobody gave is not a width of `f32::MAX`.** `AppBar::new` reads the
2527    /// surface from the ambient description and falls back to that sentinel when nothing
2528    /// described one — a bar built outside a frame, which is every bar built by hand.
2529    /// The sentinel is finite, so the bar laid a row out `f32::MAX` wide, and the spring
2530    /// before a centred title pushed it to `x = 1.7e38` with an ellipsis where the words
2531    /// had been. Nothing caught it for as long as the platforms CI ran on all put their
2532    /// titles flush: only Apple's centre one by default, and the leading spring is the
2533    /// half of the arrangement that only a centred title has.
2534    #[test]
2535    fn a_centred_title_with_no_described_surface_stays_on_the_screen() {
2536        const W: f32 = 800.0;
2537        let bar = AppBar::<Msg>::new()
2538            .title(Text::new("Title"))
2539            .center_title(true)
2540            .build();
2541        let ui = build_ui(
2542            bar.as_ref(),
2543            Size::new(W, 80.0),
2544            &Runtime::default(),
2545            &Theme::default(),
2546        );
2547        let title = ui
2548            .scene()
2549            .primitives()
2550            .iter()
2551            .find_map(|p| match p {
2552                frus_core::Primitive::Text { text, position, .. } if text.starts_with("Title") => {
2553                    Some((text.clone(), position.x))
2554                }
2555                _ => None,
2556            })
2557            .expect("the bar paints its title");
2558        assert_eq!(title.0, "Title", "and paints all of it, un-ellipsised");
2559        assert!(
2560            title.1.is_finite() && (0.0..W).contains(&title.1),
2561            "on the surface rather than at infinity: x = {}",
2562            title.1
2563        );
2564    }
2565
2566    #[test]
2567    fn a_centred_title_sits_between_the_two_ends() {
2568        const W: f32 = 800.0;
2569        let centred = AppBar::new()
2570            .title(Text::new("Task"))
2571            .width(W)
2572            .center_title(true)
2573            .leading(button("M", Msg::PopupMenuButton).size(16.0))
2574            .build();
2575        // Explicitly flush: the default now follows the platform, and this test is
2576        // about the two arrangements, not about which one this build prefers.
2577        let flush = AppBar::new()
2578            .title(Text::new("Task"))
2579            .width(W)
2580            .center_title(false)
2581            .leading(button("M", Msg::PopupMenuButton).size(16.0))
2582            .build();
2583        let x_of = |bar: &dyn Widget<Msg>| {
2584            let ui = build_ui(
2585                bar,
2586                Size::new(W, 80.0),
2587                &Runtime::default(),
2588                &Theme::default(),
2589            );
2590            ui.scene()
2591                .primitives()
2592                .iter()
2593                .find_map(|p| match p {
2594                    frus_core::Primitive::Text { text, position, .. } if text == "Task" => {
2595                        Some(position.x)
2596                    }
2597                    _ => None,
2598                })
2599                .expect("the title")
2600        };
2601        let centred_x = x_of(centred.as_ref());
2602        let flush_x = x_of(flush.as_ref());
2603        assert!(
2604            centred_x > flush_x + 100.0,
2605            "centred ({centred_x}) should sit well right of flush ({flush_x})"
2606        );
2607        assert!(centred_x < W * 0.6, "and not off to the right: {centred_x}");
2608    }
2609
2610    #[test]
2611    fn a_bottom_slot_is_part_of_the_bar() {
2612        let bar = AppBar::<Msg>::new()
2613            .title(Text::new("Title"))
2614            .width(600.0)
2615            .bottom(Text::new("Tabs").size(14.0))
2616            .build();
2617        let texts = texts_of(bar.as_ref(), 600.0);
2618        assert!(texts.iter().any(|t| t == "Tabs"), "{texts:?}");
2619        // Under the title, not beside it.
2620        let ui = build_ui(
2621            bar.as_ref(),
2622            Size::new(600.0, 120.0),
2623            &Runtime::default(),
2624            &Theme::default(),
2625        );
2626        let y = |wanted: &str| {
2627            ui.scene()
2628                .primitives()
2629                .iter()
2630                .find_map(|p| match p {
2631                    frus_core::Primitive::Text { text, position, .. } if text == wanted => {
2632                        Some(position.y)
2633                    }
2634                    _ => None,
2635                })
2636                .expect("the text")
2637        };
2638        assert!(
2639            y("Tabs") > y("Title"),
2640            "the bottom slot sits under the toolbar"
2641        );
2642    }
2643
2644    #[test]
2645    fn a_wide_leading_is_counted_in_the_budget() {
2646        // A leading far wider than the Material slot: declared, it must eat into what
2647        // the actions may claim, so more of them fold.
2648        let narrow = AppBar::new()
2649            .title(Text::new("Title"))
2650            .width(520.0)
2651            .overflow(false, Msg::PopupMenuButton)
2652            .foldable_action("Action One", Msg::A)
2653            .foldable_action("Action Two", Msg::B)
2654            .build();
2655        let wide = AppBar::new()
2656            .title(Text::new("Title"))
2657            .width(520.0)
2658            .leading_width(300.0)
2659            .leading(button("M", Msg::PopupMenuButton).size(16.0))
2660            .overflow(false, Msg::PopupMenuButton)
2661            .foldable_action("Action One", Msg::A)
2662            .foldable_action("Action Two", Msg::B)
2663            .build();
2664        let inline = |bar: &dyn Widget<Msg>| {
2665            texts_of(bar, 520.0)
2666                .iter()
2667                .filter(|t| t.starts_with("Action"))
2668                .count()
2669        };
2670        assert!(
2671            inline(wide.as_ref()) < inline(narrow.as_ref()),
2672            "a wide leading must push actions into the overflow"
2673        );
2674    }
2675
2676    #[test]
2677    fn custom_widget_action_never_folds() {
2678        // A very narrow bar: the labelled actions fold, but the free widget (which
2679        // cannot be represented as a menu row) stays inline.
2680        let bar = AppBar::new()
2681            .title(Text::new("Title"))
2682            .width(260.0)
2683            .overflow(false, Msg::PopupMenuButton)
2684            .foldable_action("A long labelled action", Msg::A)
2685            .action(Text::new("★badge★").size(14.0))
2686            .foldable_action("Another long action", Msg::B)
2687            .build();
2688        let ui = build_ui(
2689            bar.as_ref(),
2690            Size::new(260.0, 80.0),
2691            &Runtime::default(),
2692            &Theme::default(),
2693        );
2694        let has_badge = ui
2695            .scene()
2696            .primitives()
2697            .iter()
2698            .any(|p| matches!(p, frus_core::Primitive::Text { text, .. } if text == "★badge★"));
2699        assert!(
2700            has_badge,
2701            "the widget action stays inline even when cramped"
2702        );
2703    }
2704}