Skip to main content

frus_widgets/
appbar.rs

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