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