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