Skip to main content

frust_material/appbar/
mod.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// `lib/components/app_bars/` — `m3e_app_bars.dart`, `enums/m3e_app_bar_enums.dart`,
3// `styles/m3e_app_bar_theme.dart`, `components/m3e_app_bar_semantics.dart`
4// (retrieved 2026-08-20). Upstream:
5// https://github.com/paadevelopments/material_3_expressive
6// Porting decisions (each restated with its reference site in the module docs
7// below): the collapsing bar is a sliver-protocol participant inside a
8// viewport, and an app-fed controlled prop, when set, wins over it; the
9// small/collapsed band keeps M3's published 64dp rather than upstream's own
10// 72dp content-padding model; the
11// bar's slot edge insets and slot gap stay this catalog's 4dp rather than
12// upstream's 0/8dp pair, while a top bar's title keeps M3's 16dp edge inset.
13
14//! The M3 Expressive **app bar** family: four constructors over one set of
15//! metrics, shape families, and densities.
16//!
17//! | Constructor | Upstream | Shape |
18//! |---|---|---|
19//! | [`app_bar`] | `M3EAppBar.top` | a fixed, single-line top bar: leading slot, title, actions |
20//! | [`search_app_bar`] | `M3EAppBar.search` | the same bar with a [`crate::search_bar`] filling the title slot |
21//! | [`bottom_app_bar`] | `M3EAppBar.bottom` | a bottom-docked action row with an optional prominent/FAB slot |
22//! | [`sliver_app_bar`] | `M3EAppBar.sliver` | a collapsing bar whose headline interpolates between an expanded and a collapsed band |
23//!
24//! Every bar in the family shares the slot contract [`crate::toolbar`] and
25//! [`crate::navbar`] also follow: a `leading` slot and an ordered `Vec` of
26//! `actions` (plus the bottom bar's `fab` slot) are opaque caller-supplied
27//! [`AnyView`] children routed through [`ChildPod`]s exactly like
28//! [`frust::Row`]'s children — this family lays them out and routes events to
29//! them, but never paints or tints their content itself (the leading slot's
30//! `onSurface` / actions' `onSurfaceVariant` tinting guidance is the *caller's*
31//! job to apply to whatever `AnyView` it supplies, e.g. an icon button; there is
32//! no icon primitive here to tint, and upstream's own `IconTheme.merge`
33//! ambient-icon-size push has no analogue on this framework's public surface).
34//!
35//! Every top and sliver bar also shifts its leading slot right by
36//! `corner_insets.top_left.width` and its trailing edge left by
37//! `corner_insets.top_right.width` whenever that corner's height is above zero
38//! (the iPadOS 26+ window control). The band height is unchanged, the other
39//! corners are ignored, the bottom bar is unaffected, and the value is never
40//! consumed by `safe_area`, so a bar under a top-consuming safe area still sees it.
41//! The shift assumes the bar spans the window's top edge; a bar placed elsewhere
42//! (detail pane, sheet, dialog) opts out with `corner_shift(false)`. There is no
43//! automatic detection.
44//!
45//! The **title** is the one child a top/collapsing bar fully owns when given as
46//! a string: composed as a child [`frust::TextView`] (no hand-shaped text),
47//! themed `onSurface` (the `Text` default role) so it survives a live theme swap
48//! through `Text`'s own layout-time color resolution, ellipsized to one line
49//! (upstream's `overflow: TextOverflow.ellipsis` on both `titleText` paths).
50//! A top bar on [`AppBarContainer::PrimaryContainer`] themes it
51//! `onPrimaryContainer` instead, through the same deferred color-role seam.
52//! [`AppBarView::title_view`] replaces it with any caller view — upstream's
53//! `title` widget slot, and what [`search_app_bar`] itself uses.
54//!
55//! # Top-bar container, elevation, and self-inset
56//!
57//! A top bar's fill resolves at paint time, so it follows a brightness flip
58//! or a live theme swap. Precedence: [`AppBarView::background`], then an
59//! explicit [`AppBarView::container`] role, then — when
60//! [`AppBarView::elevation`] is above 0 — that elevation level's
61//! `surface_role` (M3 tonal elevation: level 2 is `surfaceContainer`), then
62//! `colors.surface`. An elevation above 0 also paints a drop shadow first, the
63//! way [`mod@crate::card`]'s elevated variant does
64//! ([`frust::authoring::PaintScene::draw_shadow`] from the level's
65//! `ShadowSpec`, `colors.shadow` at its alpha), then the fill, then the slots.
66//! A `Scaffold` paints its body before its app bar, so the shadow lands on the
67//! body. `container(AppBarContainer::InversePrimary)` is the fill Flutter's
68//! counter template gives its `AppBar`; `.elevation(2)` adds a level-2 shadow
69//! beneath it.
70//!
71//! A top bar ([`app_bar`] and [`search_app_bar`]) consumes the **top, left
72//! and right** window insets itself by default — Flutter's `AppBar`, whose
73//! toolbar sits in a `SafeArea(bottom: false)` — the way
74//! [`crate::navigation_bar`] consumes the bottom one: its `layout` reads
75//! `ctx.window_insets().padding()`, grows the bar by the top inset, paints
76//! the fill (and shadow) edge to edge through that band and across the full
77//! width so a colored bar extends under the status bar and a landscape
78//! cutout, and lays the slots out below the top inset and inside the side
79//! ones. Every horizontal slot rule (leading at `PAD_X`, a bare title edge at
80//! `TITLE_EDGE_INSET`, actions ending `PAD_X` in, the centered-title
81//! arithmetic) measures from the inset-adjusted edge. A parent
82//! `frust::safe_area` that already consumed an edge leaves 0 on it here, so
83//! wrapping the bar in `safe_area(..).bottom(false)` never double-insets — it
84//! only leaves the consumed bands outside the bar unpainted.
85//! [`AppBarView::safe_area`]`(false)` opts out of all three edges for a bar
86//! that is not docked to the window's top edge.
87//!
88//! The corner shift above stacks on the consumed side padding: a
89//! `CornerInsets` width is measured from the safe-area edge, not the window
90//! edge, so a slot's origin is `padding.left + corner_insets.top_left.width`
91//! (mirrored on the right). That is exactly what the same bar gets under a
92//! parent safe area that consumed the side padding first (the corner is never
93//! consumed), so both compositions place the slots identically.
94//!
95//! # Metrics ([`AppBarMetrics`]), densities, and the one deliberate divergence
96//!
97//! | Band | Regular | Compact | Source |
98//! |---|---|---|---|
99//! | small / collapsed | 64 | 56 | m3.material.io/components/top-app-bars/specs |
100//! | medium expanded | 112 | 104 | `M3EAppBarTheme.mediumExpanded` (= the M3 spec) |
101//! | large expanded | 152 | 144 | `M3EAppBarTheme.largeExpanded` (= the M3 spec) |
102//! | bottom | 80 | 80 | `M3EAppBarTheme.bottomHeight` (= the M3 spec) |
103//!
104//! [`AppBarDensity::Compact`] subtracts upstream's own
105//! `compactHeightReduction` (8dp) from every band **but the bottom one** —
106//! `M3EAppBar.bottom` pins `density: regular` and reads `bottomHeight` straight
107//! off the theme, so there is no compact bottom bar to port.
108//!
109//! **The divergence**: upstream's `smallHeight`/`collapsedHeight` are both 72dp
110//! (its own "8 + 56 + 8" content-padding model, `M3EAppBarTheme`'s own comment);
111//! this port keeps the published M3 small-top-app-bar height of 64dp, which is
112//! also what this catalog already shipped and what
113//! [`crate::selection_app_bar`]'s contextual band mirrors so the two read as one
114//! continuous surface across a selection-mode swap. Upstream's 72dp is
115//! reachable as an explicit [`AppBarView::toolbar_height`] (its own
116//! `toolbarHeight` override). The same reasoning keeps this family's 4dp slot
117//! edge inset and 4dp slot gap rather than upstream's `contentPadding` (0
118//! horizontal) + `titleGap` (8dp) pair: a leading or action icon button
119//! carries a 48dp touch target, which puts its 24dp glyph at the M3 16dp mark.
120//! The **title** is the exception: a top bar with no leading slot starts its
121//! title `TITLE_EDGE_INSET` (16dp) from the leading edge, and one with no
122//! actions ends the title's available width 16dp from the trailing edge —
123//! the published M3 small-top-app-bar geometry (and Flutter's
124//! `NavigationToolbar` middle spacing), since a bare title has no touch
125//! target to carry it off the edge.
126//!
127//! # Shape families
128//!
129//! [`AppBarShapeFamily::Square`] resolves `theme.shape.none` and
130//! [`AppBarShapeFamily::Round`] resolves `theme.shape.small` (8dp) —
131//! `M3EAppBarTheme.shape`'s own `M3EShapes.radiusNone`/`radiusSmall` pair. A
132//! square bar fills a plain rect, a round one a rounded rect. Upstream's own
133//! per-constructor defaults are ported as-is: square for the top/search bars,
134//! round for the collapsing one, square (unconfigurable) for the bottom one.
135//!
136//! # Scroll-linked collapse: two wirings
137//!
138//! **In a viewport, the bar wires itself.** Placed among the slivers of a
139//! [`frust::custom_scroll_view`] and left uncontrolled, [`sliver_app_bar`] is a
140//! sliver: the viewport's scroll offset collapses it from the expanded band to
141//! the collapsed one, pinned at the leading edge by default
142//! ([`SliverAppBarView::pinned`]) or revealed on a reverse scroll with
143//! [`SliverAppBarView::floating`]. Nothing is wired by the app; see
144//! [`mod@sliver`]'s *In a viewport*.
145//!
146//! ```
147//! use frust::{ChildKey, CustomScrollView, ScrollController, custom_scroll_view, sliver_list, text};
148//! use frust_material::{AppBarVariant, sliver_app_bar};
149//!
150//! fn inbox(controller: ScrollController) -> CustomScrollView<()> {
151//!     custom_scroll_view((
152//!         sliver_app_bar("Inbox").variant(AppBarVariant::Large),
153//!         sliver_list(500, ChildKey::new, |i| text(format!("message {i}"))),
154//!     ))
155//!     .controller(controller)
156//! }
157//! # let _ = inbox(ScrollController::new());
158//! ```
159//!
160//! **Anywhere else, the app owns the wiring.** Outside a viewport a widget here
161//! has no window or scroll handle — [`frust::ScrollView`]'s two seams
162//! ([`on_scroll`](frust::ScrollView::on_scroll),
163//! [`on_refresh_release`](frust::ScrollView::on_refresh_release)) are installed
164//! on the scroll view at construction, and only the app holds both it and the
165//! bar. So the collapse is a **controlled prop**, the same app-side-rule shape
166//! [`crate::SearchViewMode::for_width`] takes for the search view's
167//! presentation split: the app feeds the [`ScrollInfo`](frust::ScrollInfo) it
168//! observes into [`SliverAppBarView::scroll_offset`] (or a fraction it computes
169//! itself into [`SliverAppBarView::collapse`]), and this widget is a pure
170//! function of it.
171//!
172//! **The prop wins.** A bar given either prop is that controlled box wherever
173//! it sits — in a viewport too, where it is laid out as a box child that
174//! scrolls away with the content and the viewport's offset never collapses it.
175//! An app already feeding `scroll_offset(..)` keeps exactly the bar it had.
176//!
177//! ```
178//! use frust::{Component, ScrollInfo, View, column, scroll_view, text};
179//! use frust_material::{AppBarVariant, SliverAppBarView, sliver_app_bar};
180//!
181//! struct App {
182//!     /// The last offset the body reported — the bar's collapse input.
183//!     scrolled: f64,
184//! }
185//!
186//! struct Inbox;
187//!
188//! impl Component for Inbox {
189//!     type State = App;
190//!
191//!     fn init(&self) -> App {
192//!         App { scrolled: 0.0 }
193//!     }
194//!
195//!     fn build(&self, state: &mut App) -> impl View<App> {
196//!         column().child(Self::header(state)).child(Self::body())
197//!     }
198//! }
199//!
200//! impl Inbox {
201//!     fn header(state: &App) -> SliverAppBarView<App> {
202//!         sliver_app_bar("Inbox")
203//!             .variant(AppBarVariant::Large)
204//!             .scroll_offset(state.scrolled)
205//!     }
206//!
207//!     fn body() -> impl View<App> {
208//!         scroll_view(text("content"))
209//!             .on_scroll(|state: &mut App, info: ScrollInfo| state.scrolled = info.offset)
210//!     }
211//! }
212//! ```
213//!
214//! [`AppBarCollapse`] is that mapping as a standalone pure function pair, so an
215//! app can pin the same geometry its layout math needs (how tall the bar is at
216//! a given offset, how far the scroll must travel to collapse it) without
217//! building a view.
218//!
219//! There is no motion controller anywhere in this family: a collapse is a value
220//! change, applied on the rebuild that carries it. That is already the
221//! `reduce_motion` behavior every animated widget in this catalog falls back to,
222//! so there is nothing here for that flag to collapse.
223//!
224//! # Semantics
225//!
226//! A top/collapsing bar is one accesskit container node of [`Role::TitleBar`]
227//! (the closest accesskit vocabulary to "a window/app title bar region", chosen
228//! over a generic `Role::GenericContainer` since accesskit publishes a
229//! purpose-built role for exactly this region), labelled with
230//! [`AppBarView::semantic_label`] or the title text, whose children are the
231//! leading slot, the title, and each action in that order via
232//! [`frust::authoring::SemanticsCtx::push_container`]. A bottom bar is a
233//! [`Role::Toolbar`] container instead — the same role [`crate::toolbar`] uses,
234//! since a bottom action row is not a title bar. This replaces upstream's
235//! `M3ESliverSemantic` render-object wrapper (`m3e_app_bar_semantics.dart`),
236//! which exists only because a Flutter sliver cannot carry a `Semantics` widget
237//! directly.
238//!
239//! # Not ported
240//!
241//! * **`safeArea` / `MediaQuery.viewPadding` on the collapsing and bottom
242//!   bars.** Only the fixed top bar (and [`crate::selection_app_bar`]'s
243//!   contextual branch, which mirrors it) self-insets (see *Top-bar container,
244//!   elevation, and self-inset* above); a caller wraps a collapsing or bottom
245//!   app bar in `frust::safe_area(...)`.
246//! * **`automaticallyImplyLeading`.** Upstream reads `Navigator.maybeOf(context)`
247//!   to synthesize a back button; a widget here has no navigator handle, and
248//!   this catalog's convention is an explicit `leading` slot (supply
249//!   [`fn@crate::icon_button`] with [`crate::icons::ARROW_BACK`]).
250//! * **`snap`.** A half-revealed floating bar stays half revealed when the
251//!   scroll stops: the persistent header [`SliverAppBarView::floating`] rides
252//!   on has no snap animation. `floating` and [`SliverAppBarView::pinned`]
253//!   *are* ported, for a bar inside a viewport; a controlled bar ignores
254//!   `floating`, and an app that wants reveal-on-scroll-up there computes its
255//!   own collapse fraction and feeds it in.
256//! * **`elevation` / `Material` shadow on the collapsing and bottom bars.**
257//!   Upstream's own default is `0`, so a ported default paints nothing; only
258//!   the fixed top bar takes [`AppBarView::elevation`].
259//! * **`clipBehavior`.** The collapsing bar clips its own content band once the
260//!   bar is shorter than it (see [`mod@sliver`]); nothing else in the family
261//!   overflows its box, so there is no clip knob to expose.
262//! * **`foregroundColor` / `IconTheme.merge`.** Slot content is opaque here —
263//!   the caller styles its own icon buttons, per the slot contract above.
264//! * **`M3EAppBarTheme` as a theme extension.** MaterialTokens-only: every
265//!   color/shape below resolves from [`frust::Theme`]/`ColorScheme` directly,
266//!   with an explicit builder override winning over both, the same precedence
267//!   [`mod@crate::list_item`]/[`mod@crate::card_list`] use.
268//!
269//! # Attribution
270//!
271//! See `plugins/material/NOTICE`'s "MIT License — Additional Copyright Holders
272//! (Vendored Components)" section and its Module Attribution Header Convention.
273
274use frust::authoring::text::{FontWeight, LineHeight, TextOverflow};
275use frust::authoring::{AnyView, ChildPod, Role, ThemeTextColor, ThemeTextType};
276use frust::{ElevationLevel, ShapeScale, SurfaceRole, Theme, text};
277use peniko::Color;
278
279pub mod bottom;
280pub mod sliver;
281pub mod top;
282
283pub use bottom::{BottomAppBar, BottomAppBarView, BottomAppBarWidget, bottom_app_bar};
284pub use sliver::{SliverAppBar, SliverAppBarView, SliverAppBarWidget, sliver_app_bar};
285pub use top::{AppBar, AppBarView, AppBarWidget, SearchAppBar, app_bar, search_app_bar};
286
287/// Container height of the small/center-aligned Top App Bar at
288/// [`AppBarDensity::Regular`], in logical px.
289///
290/// Source: m3.material.io/components/top-app-bars/specs (also
291/// material-components-android `docs/components/TopAppBar.md`). Deliberately
292/// *not* upstream's own 72dp `smallHeight`/`collapsedHeight` — see the [module
293/// docs](self)' metrics table for why, and
294/// [`AppBarView::toolbar_height`](top::AppBarView::toolbar_height) for the
295/// per-bar override that reaches it.
296const HEIGHT: f64 = 64.0;
297
298/// Horizontal inset from a bar's leading/trailing edges to the leading/action
299/// slots, in logical px (upstream's `contentPadding`, 0 horizontal).
300const PAD_X: f64 = 4.0;
301
302/// Horizontal inset from a top bar's leading/trailing edge to its title when
303/// no leading slot / no action sits on that side, in logical px — the M3
304/// small-top-app-bar title margin (m3.material.io/components/top-app-bars/specs).
305/// Slots keep [`PAD_X`]; see the [module docs](self)' divergence paragraph.
306const TITLE_EDGE_INSET: f64 = 16.0;
307
308/// Highest elevation level [`AppBarView::elevation`](top::AppBarView::elevation)
309/// accepts (`theme.elevation.level5`); larger values clamp to it.
310const MAX_ELEVATION: u8 = 5;
311
312/// Gap between adjacent slots (leading↔title, title↔actions, action↔action), in
313/// logical px (upstream's `titleGap`, 8dp).
314const GAP: f64 = 4.0;
315
316/// Height reduction [`AppBarDensity::Compact`] applies to every band but the
317/// bottom bar's — upstream's `M3EAppBarTheme.compactHeightReduction`.
318const COMPACT_REDUCTION: f64 = 8.0;
319
320/// Expanded band of the medium collapsing bar, in logical px —
321/// `M3EAppBarTheme.mediumExpanded`, which is also the published M3
322/// medium-top-app-bar height.
323const MEDIUM_EXPANDED: f64 = 112.0;
324
325/// Expanded band of the large collapsing bar, in logical px —
326/// `M3EAppBarTheme.largeExpanded`, which is also the published M3
327/// large-top-app-bar height.
328const LARGE_EXPANDED: f64 = 152.0;
329
330/// Bottom app bar band, in logical px — `M3EAppBarTheme.bottomHeight`, which is
331/// also the published M3 bottom-app-bar height. Density-independent (see the
332/// [module docs](self)).
333const BOTTOM_HEIGHT: f64 = 80.0;
334
335/// Start and bottom inset of a collapsing bar's *expanded* headline, in logical
336/// px — upstream's `FlexibleSpaceBar(titlePadding: start 16, bottom 16, end 16)`.
337const HEADLINE_INSET: f64 = 16.0;
338
339/// The collapsed title's M3 `titleLargeEmphasized` type-scale token, hardcoded
340/// here rather than read from a live `Theme::type_scale`: unlike a themed
341/// *color* or font *family*, `Text` has no layout-time-deferred *size/weight*
342/// resolution seam (see `docs/CODE_STANDARDS.md`'s Theming conventions — only
343/// a color role and an opted-in family role are resolved after `View::build`;
344/// the title opts into the family, see `title_text_view`). Matches
345/// `frust-theme::typography`'s `TITLE_LARGE_EMPHASIZED`: the baseline
346/// `TITLE_LARGE` size/line-height (m3.material.io) with the weight stepped
347/// Regular → Medium.
348const TITLE_SIZE: f32 = 22.0;
349/// Line height of [`TITLE_SIZE`]'s type token, in logical px.
350const TITLE_LINE_HEIGHT: f32 = 28.0;
351/// Weight shared by both title tokens — the M3E *emphasized* type-scale
352/// consumption (Regular → Medium).
353const TITLE_WEIGHT: FontWeight = FontWeight::MEDIUM;
354
355/// The expanded headline's M3 `headlineSmallEmphasized` type-scale token, the
356/// style upstream's `M3EAppBarTheme.titleStyle(collapsed: false)` resolves.
357/// Hardcoded for the reason [`TITLE_SIZE`] is.
358const HEADLINE_SIZE: f32 = 24.0;
359/// Line height of [`HEADLINE_SIZE`]'s type token, in logical px.
360const HEADLINE_LINE_HEIGHT: f32 = 32.0;
361
362/// Unthemed fallback container fill for a top/collapsing bar (a theme resolves
363/// this from `colors.surface`).
364const CONTAINER: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
365
366/// Unthemed fallback container fill for a bottom bar (a theme resolves this from
367/// `colors.surface_container`) — matches [`crate::toolbar`]'s and
368/// [`crate::navbar`]'s own `CONTAINER` constant exactly, the same M3 "surface
369/// container" role.
370const BOTTOM_CONTAINER: Color = Color::from_rgb8(0xF3, 0xED, 0xF7);
371
372/// Unthemed fallback shadow color: opaque black at the elevation table's `0.3`
373/// alpha, the same constant [`mod@crate::card`]'s elevated variant falls back to.
374const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
375
376/// Unthemed fallback corner radius for [`AppBarShapeFamily::Round`], in logical
377/// px — `crate::tokens::shape_scale()`'s own `small` value.
378const ROUND_RADIUS: f64 = 8.0;
379
380/// The theme color role a top bar's container fills with —
381/// [`AppBarView::container`](top::AppBarView::container). Resolved at paint
382/// time from the live theme's scheme; an explicit
383/// [`AppBarView::background`](top::AppBarView::background) still wins.
384///
385/// | Role | Fill | String-title color |
386/// |---|---|---|
387/// | [`Surface`](Self::Surface) | `surface` | `onSurface` |
388/// | [`SurfaceContainer`](Self::SurfaceContainer) | `surfaceContainer` | `onSurface` |
389/// | [`InversePrimary`](Self::InversePrimary) | `inversePrimary` | `onSurface` |
390/// | [`PrimaryContainer`](Self::PrimaryContainer) | `primaryContainer` | `onPrimaryContainer` |
391///
392/// `InversePrimary` is the fill Flutter's own counter template gives its
393/// `AppBar`; Flutter keeps the foreground `onSurface` there, and so does this.
394#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
395pub enum AppBarContainer {
396    /// `colors.surface` — the M3 default.
397    #[default]
398    Surface,
399    /// `colors.surface_container` — the M3 on-scroll fill.
400    SurfaceContainer,
401    /// `colors.inverse_primary`.
402    InversePrimary,
403    /// `colors.primary_container`, with an `onPrimaryContainer` string title.
404    PrimaryContainer,
405}
406
407impl AppBarContainer {
408    /// The themed color role a string title takes over this container.
409    fn title_role(self) -> ThemeTextColor {
410        match self {
411            AppBarContainer::PrimaryContainer => ThemeTextColor::OnPrimaryContainer,
412            AppBarContainer::Surface
413            | AppBarContainer::SurfaceContainer
414            | AppBarContainer::InversePrimary => ThemeTextColor::OnSurface,
415        }
416    }
417}
418
419/// Which collapsing-bar layout [`sliver_app_bar`] renders —
420/// `M3EAppBarVariant`. The variant influences only the collapsing bar; the
421/// fixed [`app_bar`] always renders the collapsed, single-line layout.
422#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
423pub enum AppBarVariant {
424    /// A single-line bar with no expanded headline (upstream returns no
425    /// `flexibleSpace` at all for this one).
426    Small,
427    /// A two-line bar whose headline expands beneath the action row.
428    #[default]
429    Medium,
430    /// A taller two-line bar with a larger expanded headline.
431    Large,
432}
433
434/// The corner shape family of an app bar container — `M3EAppBarShapeFamily`.
435#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
436pub enum AppBarShapeFamily {
437    /// Rounded container corners (`theme.shape.small`, 8dp).
438    Round,
439    /// Squared container corners (`theme.shape.none`).
440    #[default]
441    Square,
442}
443
444/// The vertical density of an app bar — `M3EAppBarDensity`.
445#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
446pub enum AppBarDensity {
447    /// Standard M3 heights.
448    #[default]
449    Regular,
450    /// Heights reduced by 8dp (upstream's own `compactHeightReduction`) — the
451    /// bottom bar excepted, per the [module docs](self).
452    Compact,
453}
454
455/// The resolved band heights of one density — upstream's `M3EAppBarMetrics`,
456/// as produced by `M3EAppBarTheme.metrics(density)`.
457///
458/// ```
459/// use frust_material::{AppBarDensity, AppBarMetrics, AppBarVariant};
460///
461/// let regular = AppBarMetrics::for_density(AppBarDensity::Regular);
462/// assert_eq!(regular.small_height, 64.0);
463/// assert_eq!(regular.expanded(AppBarVariant::Large), 152.0);
464///
465/// // Compact takes 8dp off every band but the bottom bar's.
466/// let compact = AppBarMetrics::for_density(AppBarDensity::Compact);
467/// assert_eq!(compact.small_height, 56.0);
468/// assert_eq!(compact.bottom_height, regular.bottom_height);
469/// ```
470#[derive(Clone, Copy, Debug, PartialEq)]
471pub struct AppBarMetrics {
472    /// The fixed top bar's band, in logical px.
473    pub small_height: f64,
474    /// The collapsing bar's fully-collapsed band, in logical px.
475    pub collapsed_height: f64,
476    /// The [`AppBarVariant::Medium`] expanded band, in logical px.
477    pub medium_expanded: f64,
478    /// The [`AppBarVariant::Large`] expanded band, in logical px.
479    pub large_expanded: f64,
480    /// The bottom bar's band, in logical px (density-independent).
481    pub bottom_height: f64,
482}
483
484impl AppBarMetrics {
485    /// The bands `density` resolves to.
486    pub const fn for_density(density: AppBarDensity) -> Self {
487        let cut = match density {
488            AppBarDensity::Regular => 0.0,
489            AppBarDensity::Compact => COMPACT_REDUCTION,
490        };
491        Self {
492            small_height: HEIGHT - cut,
493            collapsed_height: HEIGHT - cut,
494            medium_expanded: MEDIUM_EXPANDED - cut,
495            large_expanded: LARGE_EXPANDED - cut,
496            bottom_height: BOTTOM_HEIGHT,
497        }
498    }
499
500    /// The fully-expanded band of `variant`, in logical px —
501    /// `_buildSliver`'s own `switch (variant)`.
502    pub const fn expanded(&self, variant: AppBarVariant) -> f64 {
503        match variant {
504            AppBarVariant::Small => self.small_height,
505            AppBarVariant::Medium => self.medium_expanded,
506            AppBarVariant::Large => self.large_expanded,
507        }
508    }
509}
510
511/// The collapse geometry of one [`sliver_app_bar`] configuration: the pure
512/// mapping between an app's scroll offset, the bar's collapse fraction, and the
513/// band it occupies.
514///
515/// Every method is clamp-disciplined and NaN-safe — a non-finite input resolves
516/// to the resting (expanded) end rather than propagating.
517///
518/// ```
519/// use frust_material::{AppBarCollapse, AppBarDensity, AppBarVariant};
520///
521/// let geometry = AppBarCollapse::new(AppBarVariant::Large, AppBarDensity::Regular, true);
522/// assert_eq!(geometry.expanded_height(), 152.0);
523/// assert_eq!(geometry.collapsed_height(), 64.0);
524///
525/// // A pinned bar bottoms out at its collapsed band, 88px of scroll later.
526/// assert_eq!(geometry.travel(), 88.0);
527/// assert_eq!(geometry.height(0.0), 152.0);
528/// assert_eq!(geometry.height(1.0), 64.0);
529/// assert_eq!(geometry.fraction_for_offset(44.0), 0.5);
530///
531/// // Past the end, and off the map, both clamp.
532/// assert_eq!(geometry.fraction_for_offset(1_000.0), 1.0);
533/// assert_eq!(geometry.fraction_for_offset(f64::NAN), 0.0);
534/// ```
535#[derive(Clone, Copy, Debug, PartialEq)]
536pub struct AppBarCollapse {
537    expanded: f64,
538    collapsed: f64,
539    min: f64,
540}
541
542impl AppBarCollapse {
543    /// The geometry of a `variant` bar at `density`, bottoming out at its
544    /// collapsed band when `pinned` and at zero height otherwise.
545    pub const fn new(variant: AppBarVariant, density: AppBarDensity, pinned: bool) -> Self {
546        let metrics = AppBarMetrics::for_density(density);
547        let collapsed = metrics.collapsed_height;
548        Self {
549            expanded: metrics.expanded(variant),
550            collapsed,
551            min: if pinned { collapsed } else { 0.0 },
552        }
553    }
554
555    /// The band at collapse `0.0`, in logical px.
556    pub const fn expanded_height(&self) -> f64 {
557        self.expanded
558    }
559
560    /// The collapsed band — the top row's own height, in logical px. A pinned
561    /// bar bottoms out here; an unpinned one keeps shrinking past it.
562    pub const fn collapsed_height(&self) -> f64 {
563        self.collapsed
564    }
565
566    /// The band at collapse `1.0`, in logical px: the collapsed band when
567    /// pinned, `0.0` otherwise.
568    pub const fn min_height(&self) -> f64 {
569        self.min
570    }
571
572    /// The scroll distance, in logical px, that carries this bar from fully
573    /// expanded to fully collapsed — never negative.
574    pub fn travel(&self) -> f64 {
575        (self.expanded - self.min).max(0.0)
576    }
577
578    /// The collapse fraction a scroll `offset` (a [`ScrollInfo`](frust::ScrollInfo)'s
579    /// own clamped `offset`) maps to, in `[0, 1]`.
580    ///
581    /// A bar with no travel at all ([`AppBarVariant::Small`], pinned) reports
582    /// `1.0` for any positive offset and `0.0` at rest — it has no expanded
583    /// state to leave, so the answer is only ever cosmetic.
584    pub fn fraction_for_offset(&self, offset: f64) -> f64 {
585        let travel = self.travel();
586        if travel <= 0.0 {
587            return if offset > 0.0 { 1.0 } else { 0.0 };
588        }
589        clamp01(offset / travel)
590    }
591
592    /// The band this bar occupies at collapse fraction `collapse`, in logical
593    /// px — linear between [`Self::expanded_height`] and [`Self::min_height`].
594    pub fn height(&self, collapse: f64) -> f64 {
595        let t = clamp01(collapse);
596        self.expanded + (self.min - self.expanded) * t
597    }
598
599    /// How far the *headline* has collapsed at collapse fraction `collapse`, in
600    /// `[0, 1]`: `0.0` is the expanded headline (bottom-anchored,
601    /// `headlineSmall`), `1.0` the collapsed one (in the top row, `titleLarge`).
602    ///
603    /// This is not [`Self::height`]'s own fraction: an unpinned bar keeps
604    /// shrinking after the headline has fully collapsed, so the headline
605    /// saturates first.
606    pub fn headline(&self, collapse: f64) -> f64 {
607        let span = self.expanded - self.collapsed;
608        if span <= 0.0 {
609            return 1.0;
610        }
611        clamp01((self.expanded - self.height(collapse)) / span)
612    }
613}
614
615/// `value` clamped into `[0, 1]`, mapping NaN to the resting `0.0` rather than
616/// propagating it into a layout.
617fn clamp01(value: f64) -> f64 {
618    if value.is_nan() {
619        0.0
620    } else {
621        value.clamp(0.0, 1.0)
622    }
623}
624
625/// Linear interpolation from `a` to `b` at an already-clamped `t`.
626fn lerp(a: f64, b: f64, t: f64) -> f64 {
627    a + (b - a) * t
628}
629
630/// `constraint` if finite, `0.0` otherwise — the degenerate unbounded-width
631/// case every full-width bar in this family resolves the same way
632/// ([`crate::toolbar`]'s docked variant included).
633fn finite_or_zero(constraint: f64) -> f64 {
634    if constraint.is_finite() {
635        constraint
636    } else {
637        0.0
638    }
639}
640
641/// A bar's title: either a string this family owns and styles, or a caller view
642/// it merely places (upstream's `titleText` / `title` pair).
643pub(crate) enum TitleSlot<State: 'static> {
644    /// A string composed into a styled child [`frust::TextView`].
645    Text(String),
646    /// A caller-supplied view, placed but never styled.
647    View(AnyView<State>),
648}
649
650impl<State: 'static> TitleSlot<State> {
651    /// The semantics label this slot contributes on its own — a non-empty
652    /// title string, or nothing for a caller view (which labels itself).
653    fn label(&self) -> Option<&str> {
654        match self {
655            TitleSlot::Text(title) if !title.is_empty() => Some(title.as_str()),
656            _ => None,
657        }
658    }
659}
660
661/// Build the title's type-erased child view at `size`/`line_height`: emphasized
662/// title/headline text defaulting to the `Text` widget's own `OnSurface` themed
663/// role (an app bar title reads as ordinary on-surface content), ellipsized to
664/// one line like upstream's own `titleText`.
665///
666/// Its font family follows the live theme's `titleLargeEmphasized` role — the
667/// collapsed, resting role — and by design that one family governs a sliver
668/// bar's whole collapse. The sliver interpolates the title's size AND line
669/// height between `headlineSmallEmphasized` (expanded) and
670/// `titleLargeEmphasized` (collapsed) at build time, but a family cannot
671/// interpolate, and switching faces mid-collapse would make the title visibly
672/// jump. Under a type scale whose headline and title families differ — Glyph's
673/// (Space Mono headline, IBM Plex Mono title) or Cupertino's (SF Pro Display
674/// at 20pt and up, SF Pro Text below) — the expanded headline therefore
675/// renders in the title face. The Material scale gives both roles Roboto Flex.
676///
677/// `role` is the themed color role: `OnSurface` everywhere but a top bar whose
678/// container calls for a different foreground (see
679/// [`AppBarContainer::title_role`]).
680fn title_text_view<State: 'static>(
681    title: &str,
682    size: f32,
683    line_height: f32,
684    role: ThemeTextColor,
685) -> AnyView<State> {
686    frust::authoring::any::<State, _>(
687        text(title.to_string())
688            .size(size)
689            .weight(TITLE_WEIGHT)
690            .line_height(LineHeight::Absolute(line_height))
691            .max_lines(1)
692            .overflow(TextOverflow::Ellipsis)
693            .themed_role(role)
694            .themed_family(ThemeTextType::TitleLargeEmphasized),
695    )
696}
697
698/// Materialize `slot` into a view reference, using `storage` to hold the text
699/// case's freshly-composed view for the caller's borrow.
700///
701/// The two title cases have to reach [`frust::authoring::rebuild_children`] as
702/// one uniform `&AnyView` list, and only the string case has a view to compose;
703/// this is the one-slot storage that lets both spell it the same way.
704fn title_ref<'a, State: 'static>(
705    slot: &'a TitleSlot<State>,
706    storage: &'a mut Option<AnyView<State>>,
707    size: f32,
708    line_height: f32,
709) -> &'a AnyView<State> {
710    title_ref_in(slot, storage, size, line_height, ThemeTextColor::OnSurface)
711}
712
713/// [`title_ref`] with an explicit themed color `role` for the string case.
714fn title_ref_in<'a, State: 'static>(
715    slot: &'a TitleSlot<State>,
716    storage: &'a mut Option<AnyView<State>>,
717    size: f32,
718    line_height: f32,
719    role: ThemeTextColor,
720) -> &'a AnyView<State> {
721    match slot {
722        TitleSlot::Text(title) => {
723            storage.insert(title_text_view::<State>(title, size, line_height, role))
724        }
725        TitleSlot::View(view) => view,
726    }
727}
728
729/// The resolved container fill of a top/collapsing bar. Precedence is the
730/// builder's own `background` (applied by the caller), then
731/// `colors.surface`, then the [`CONTAINER`] fallback.
732fn resolve_container(theme: Option<&Theme>) -> Color {
733    match theme {
734        Some(theme) => theme.scheme().surface,
735        None => CONTAINER,
736    }
737}
738
739/// The resolved container fill of a fixed top bar with an optional explicit
740/// `container` role at `elevation` (already clamped). Precedence (the
741/// builder's own `background` is applied by the caller first): `container`,
742/// then the elevation level's `surface_role` when `elevation > 0`, then
743/// `colors.surface`. Unthemed, `Surface` keeps the [`CONTAINER`] fallback and
744/// every other role reads the Material 3 baseline light scheme.
745fn resolve_top_container(
746    theme: Option<&Theme>,
747    container: Option<AppBarContainer>,
748    elevation: u8,
749) -> Color {
750    let role = match container {
751        Some(AppBarContainer::Surface) => SurfaceRole::Surface,
752        Some(AppBarContainer::SurfaceContainer) => SurfaceRole::SurfaceContainer,
753        Some(AppBarContainer::InversePrimary) => {
754            return with_scheme(theme, |scheme| scheme.inverse_primary);
755        }
756        Some(AppBarContainer::PrimaryContainer) => {
757            return with_scheme(theme, |scheme| scheme.primary_container);
758        }
759        None if elevation > 0 => elevation_level(theme, elevation).surface_role,
760        None => SurfaceRole::Surface,
761    };
762    if theme.is_none() && role == SurfaceRole::Surface {
763        return CONTAINER;
764    }
765    with_scheme(theme, |scheme| surface_role_color(scheme, role))
766}
767
768/// `pick` applied to the theme's scheme, or to the Material 3 baseline light
769/// scheme unthemed.
770fn with_scheme(theme: Option<&Theme>, pick: impl FnOnce(&frust::ColorScheme) -> Color) -> Color {
771    match theme {
772        Some(theme) => pick(theme.scheme()),
773        None => pick(&crate::tokens::color_scheme_light()),
774    }
775}
776
777/// `role`'s color in `scheme`.
778fn surface_role_color(scheme: &frust::ColorScheme, role: SurfaceRole) -> Color {
779    match role {
780        SurfaceRole::Surface => scheme.surface,
781        SurfaceRole::SurfaceContainerLowest => scheme.surface_container_lowest,
782        SurfaceRole::SurfaceContainerLow => scheme.surface_container_low,
783        SurfaceRole::SurfaceContainer => scheme.surface_container,
784        SurfaceRole::SurfaceContainerHigh => scheme.surface_container_high,
785        SurfaceRole::SurfaceContainerHighest => scheme.surface_container_highest,
786    }
787}
788
789/// Elevation level `level` of the theme's table, or of this catalog's own
790/// [`crate::tokens::elevation`] table unthemed. Callers pass the value
791/// [`AppBarView::elevation`](top::AppBarView::elevation) already clamped to
792/// [`MAX_ELEVATION`]; the match's last arm reads level 5 for anything above
793/// it regardless.
794fn elevation_level(theme: Option<&Theme>, level: u8) -> ElevationLevel {
795    let table = match theme {
796        Some(theme) => theme.elevation,
797        None => crate::tokens::elevation(),
798    };
799    match level {
800        0 => table.level0,
801        1 => table.level1,
802        2 => table.level2,
803        3 => table.level3,
804        4 => table.level4,
805        _ => table.level5,
806    }
807}
808
809/// The drop shadow a top bar at `elevation` paints beneath its fill, as
810/// `(y_offset, blur_std_dev, color)` — `None` at level 0. Themed: the level's
811/// `ShadowSpec` for the theme's brightness, `colors.shadow` at its alpha
812/// (mirroring [`mod@crate::card`]'s `resolve_shadow_color`). Unthemed: the same
813/// level of [`crate::tokens::elevation`] with [`FALLBACK_SHADOW_COLOR`].
814fn resolve_elevation_shadow(theme: Option<&Theme>, elevation: u8) -> Option<(f64, f64, Color)> {
815    if elevation == 0 {
816        return None;
817    }
818    let level = elevation_level(theme, elevation);
819    match theme {
820        Some(theme) => {
821            let spec = level.shadow(theme.brightness);
822            let c = theme.scheme().shadow.components;
823            let color = Color::new([c[0], c[1], c[2], spec.color_alpha]);
824            Some((spec.y_offset, spec.blur_std_dev, color))
825        }
826        None => {
827            let spec = level.shadow_light;
828            Some((spec.y_offset, spec.blur_std_dev, FALLBACK_SHADOW_COLOR))
829        }
830    }
831}
832
833/// The resolved container fill of a bottom bar — `colors.surface_container`
834/// (upstream's `bottomBackgroundColor`), falling back to [`BOTTOM_CONTAINER`].
835fn resolve_bottom_container(theme: Option<&Theme>) -> Color {
836    match theme {
837        Some(theme) => theme.scheme().surface_container,
838        None => BOTTOM_CONTAINER,
839    }
840}
841
842/// The resolved corner radius for `family` against a bar of `width`×`height`.
843/// Themed: `shape.small` (round) / `shape.none` (square). Unthemed: the
844/// equivalent literals, resolved the same way.
845fn resolve_radius(
846    theme: Option<&Theme>,
847    family: AppBarShapeFamily,
848    width: f64,
849    height: f64,
850) -> f64 {
851    let token = match (theme, family) {
852        (Some(theme), AppBarShapeFamily::Round) => theme.shape.small,
853        (Some(theme), AppBarShapeFamily::Square) => theme.shape.none,
854        (None, AppBarShapeFamily::Round) => ROUND_RADIUS,
855        (None, AppBarShapeFamily::Square) => 0.0,
856    };
857    ShapeScale::resolve(token, width, height)
858}
859
860/// Fill a bar's container: a plain rect at a zero radius (so a square bar stays
861/// one `fill_rect`, the shape every recorder scene observes), a rounded rect
862/// otherwise.
863fn fill_container(
864    scene: &mut dyn frust::authoring::PaintScene,
865    origin: kurbo::Point,
866    size: kurbo::Size,
867    radius: f64,
868    color: Color,
869) {
870    if radius > 0.0 {
871        scene.fill_rounded_rect(origin, size, radius, color);
872    } else {
873        scene.fill_rect(origin, size, color);
874    }
875}
876
877/// Push the family's semantics container for `role`, labelled with `label` when
878/// there is one, forwarding every slot pod in visual order.
879fn push_bar_semantics(
880    ctx: &mut frust::authoring::SemanticsCtx,
881    role: Role,
882    label: Option<&str>,
883    slots: &[ChildPod],
884) {
885    ctx.push_container(
886        role,
887        |node| {
888            if let Some(label) = label {
889                node.set_label(label);
890            }
891        },
892        |ctx| {
893            for pod in slots {
894                pod.semantics_child(ctx);
895            }
896        },
897    );
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903
904    /// The ported metric table, pinned against `M3EAppBarTheme`'s own defaults
905    /// (and, where this port diverges, against the published M3 spec — see the
906    /// module docs' metrics table).
907    #[test]
908    fn the_ported_metrics_match_the_reference_theme_defaults() {
909        assert_eq!(MEDIUM_EXPANDED, 112.0);
910        assert_eq!(LARGE_EXPANDED, 152.0);
911        assert_eq!(BOTTOM_HEIGHT, 80.0);
912        assert_eq!(COMPACT_REDUCTION, 8.0);
913        assert_eq!(HEADLINE_INSET, 16.0);
914        // The one deliberate divergence: upstream's own band is 72.
915        assert_eq!(HEIGHT, 64.0);
916    }
917
918    /// The metrics [`crate::selection_app_bar`] mirrors verbatim (its
919    /// `APP_BAR_HEIGHT`/`APP_BAR_PAD_X`/`APP_BAR_GAP`), so the contextual bar
920    /// and an idle one keep reading as one continuous surface.
921    #[test]
922    fn the_selection_bar_coupling_metrics_are_unchanged() {
923        assert_eq!(HEIGHT, 64.0);
924        assert_eq!(PAD_X, 4.0);
925        assert_eq!(GAP, 4.0);
926        assert_eq!(TITLE_SIZE, 22.0);
927        assert_eq!(TITLE_LINE_HEIGHT, 28.0);
928        assert_eq!(TITLE_WEIGHT, FontWeight::MEDIUM);
929    }
930
931    #[test]
932    fn compact_density_reduces_every_band_but_the_bottom_one() {
933        let regular = AppBarMetrics::for_density(AppBarDensity::Regular);
934        let compact = AppBarMetrics::for_density(AppBarDensity::Compact);
935        assert_eq!(
936            compact.small_height,
937            regular.small_height - COMPACT_REDUCTION
938        );
939        assert_eq!(
940            compact.collapsed_height,
941            regular.collapsed_height - COMPACT_REDUCTION
942        );
943        assert_eq!(
944            compact.medium_expanded,
945            regular.medium_expanded - COMPACT_REDUCTION
946        );
947        assert_eq!(
948            compact.large_expanded,
949            regular.large_expanded - COMPACT_REDUCTION
950        );
951        assert_eq!(compact.bottom_height, regular.bottom_height);
952    }
953
954    #[test]
955    fn each_variant_expands_to_its_own_band() {
956        let metrics = AppBarMetrics::for_density(AppBarDensity::Regular);
957        assert_eq!(metrics.expanded(AppBarVariant::Small), HEIGHT);
958        assert_eq!(metrics.expanded(AppBarVariant::Medium), MEDIUM_EXPANDED);
959        assert_eq!(metrics.expanded(AppBarVariant::Large), LARGE_EXPANDED);
960    }
961
962    #[test]
963    fn a_pinned_collapse_bottoms_out_at_the_collapsed_band() {
964        let geometry = AppBarCollapse::new(AppBarVariant::Medium, AppBarDensity::Regular, true);
965        assert_eq!(geometry.min_height(), geometry.collapsed_height());
966        assert_eq!(geometry.height(0.0), MEDIUM_EXPANDED);
967        assert_eq!(geometry.height(1.0), HEIGHT);
968        assert_eq!(geometry.height(0.5), (MEDIUM_EXPANDED + HEIGHT) / 2.0);
969        // The headline saturates exactly when the band does, since a pinned
970        // bar's travel *is* the headline span.
971        assert_eq!(geometry.headline(1.0), 1.0);
972        assert_eq!(geometry.headline(0.0), 0.0);
973    }
974
975    #[test]
976    fn an_unpinned_collapse_scrolls_away_after_the_headline_saturates() {
977        let geometry = AppBarCollapse::new(AppBarVariant::Medium, AppBarDensity::Regular, false);
978        assert_eq!(geometry.min_height(), 0.0);
979        assert_eq!(geometry.height(1.0), 0.0);
980        // The headline is fully collapsed once the band reaches the collapsed
981        // height — 48 of the 112px travel remains after that.
982        let t_at_collapsed = (MEDIUM_EXPANDED - HEIGHT) / MEDIUM_EXPANDED;
983        assert!((geometry.headline(t_at_collapsed) - 1.0).abs() < 1e-9);
984        assert_eq!(geometry.headline(1.0), 1.0);
985    }
986
987    #[test]
988    fn a_small_variant_has_no_headline_travel_at_all() {
989        let geometry = AppBarCollapse::new(AppBarVariant::Small, AppBarDensity::Regular, true);
990        assert_eq!(geometry.travel(), 0.0);
991        assert_eq!(geometry.headline(0.0), 1.0);
992        assert_eq!(geometry.fraction_for_offset(0.0), 0.0);
993        assert_eq!(geometry.fraction_for_offset(10.0), 1.0);
994        // The band never moves, whatever the fraction says.
995        assert_eq!(geometry.height(0.0), HEIGHT);
996        assert_eq!(geometry.height(1.0), HEIGHT);
997    }
998
999    #[test]
1000    fn every_collapse_input_is_clamped_and_nan_safe() {
1001        let geometry = AppBarCollapse::new(AppBarVariant::Large, AppBarDensity::Regular, true);
1002        assert_eq!(geometry.height(-5.0), LARGE_EXPANDED);
1003        assert_eq!(geometry.height(5.0), HEIGHT);
1004        assert_eq!(geometry.height(f64::NAN), LARGE_EXPANDED);
1005        assert_eq!(geometry.headline(f64::NAN), 0.0);
1006        assert_eq!(geometry.fraction_for_offset(-100.0), 0.0);
1007        assert_eq!(geometry.fraction_for_offset(f64::NAN), 0.0);
1008        assert_eq!(geometry.fraction_for_offset(f64::INFINITY), 1.0);
1009        assert_eq!(geometry.fraction_for_offset(f64::NEG_INFINITY), 0.0);
1010    }
1011
1012    #[test]
1013    fn clamp_and_lerp_helpers_hold_their_contract() {
1014        assert_eq!(clamp01(f64::NAN), 0.0);
1015        assert_eq!(clamp01(-1.0), 0.0);
1016        assert_eq!(clamp01(2.0), 1.0);
1017        assert_eq!(lerp(10.0, 20.0, 0.25), 12.5);
1018        assert_eq!(finite_or_zero(f64::INFINITY), 0.0);
1019        assert_eq!(finite_or_zero(f64::NAN), 0.0);
1020        assert_eq!(finite_or_zero(320.0), 320.0);
1021    }
1022
1023    #[test]
1024    fn the_shape_families_resolve_their_own_tokens() {
1025        let theme = crate::baseline();
1026        assert_eq!(
1027            resolve_radius(Some(&theme), AppBarShapeFamily::Square, 400.0, 64.0),
1028            0.0
1029        );
1030        assert_eq!(
1031            resolve_radius(Some(&theme), AppBarShapeFamily::Round, 400.0, 64.0),
1032            theme.shape.small
1033        );
1034        // Unthemed, the same pair of literals.
1035        assert_eq!(
1036            resolve_radius(None, AppBarShapeFamily::Round, 400.0, 64.0),
1037            ROUND_RADIUS
1038        );
1039        assert_eq!(
1040            resolve_radius(None, AppBarShapeFamily::Square, 400.0, 64.0),
1041            0.0
1042        );
1043    }
1044}