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