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}