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