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}