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