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