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; and the
10// bar's own edge/slot insets stay this catalog's 4dp rather than upstream's
11// 0/8dp pair.
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//! [`AppBarView::title_view`] replaces it with any caller view — upstream's
50//! `title` widget slot, and what [`search_app_bar`] itself uses.
51//!
52//! # Metrics ([`AppBarMetrics`]), densities, and the one deliberate divergence
53//!
54//! | Band | Regular | Compact | Source |
55//! |---|---|---|---|
56//! | small / collapsed | 64 | 56 | m3.material.io/components/top-app-bars/specs |
57//! | medium expanded | 112 | 104 | `M3EAppBarTheme.mediumExpanded` (= the M3 spec) |
58//! | large expanded | 152 | 144 | `M3EAppBarTheme.largeExpanded` (= the M3 spec) |
59//! | bottom | 80 | 80 | `M3EAppBarTheme.bottomHeight` (= the M3 spec) |
60//!
61//! [`AppBarDensity::Compact`] subtracts upstream's own
62//! `compactHeightReduction` (8dp) from every band **but the bottom one** —
63//! `M3EAppBar.bottom` pins `density: regular` and reads `bottomHeight` straight
64//! off the theme, so there is no compact bottom bar to port.
65//!
66//! **The divergence**: upstream's `smallHeight`/`collapsedHeight` are both 72dp
67//! (its own "8 + 56 + 8" content-padding model, `M3EAppBarTheme`'s own comment);
68//! this port keeps the published M3 small-top-app-bar height of 64dp, which is
69//! also what this catalog already shipped and what
70//! [`crate::selection_app_bar`]'s contextual band mirrors so the two read as one
71//! continuous surface across a selection-mode swap. Upstream's 72dp is
72//! reachable as an explicit [`AppBarView::toolbar_height`] (its own
73//! `toolbarHeight` override). The same reasoning keeps this family's 4dp edge
74//! inset and 4dp slot gap rather than upstream's `contentPadding` (0 horizontal)
75//! + `titleGap` (8dp) pair.
76//!
77//! # Shape families
78//!
79//! [`AppBarShapeFamily::Square`] resolves `theme.shape.none` and
80//! [`AppBarShapeFamily::Round`] resolves `theme.shape.small` (8dp) —
81//! `M3EAppBarTheme.shape`'s own `M3EShapes.radiusNone`/`radiusSmall` pair. A
82//! square bar fills a plain rect, a round one a rounded rect. Upstream's own
83//! per-constructor defaults are ported as-is: square for the top/search bars,
84//! round for the collapsing one, square (unconfigurable) for the bottom one.
85//!
86//! # Scroll-linked collapse: the app owns the wiring
87//!
88//! A widget here has no window or scroll handle — [`frust::ScrollView`]'s two
89//! seams ([`on_scroll`](frust::ScrollView::on_scroll),
90//! [`on_refresh_release`](frust::ScrollView::on_refresh_release)) are installed
91//! on the scroll view at construction, and only the app holds both it and the
92//! bar. So the collapse is a **controlled prop**, the same app-side-rule shape
93//! [`crate::SearchViewMode::for_width`] takes for the search view's
94//! presentation split: the app feeds the [`ScrollInfo`](frust::ScrollInfo) it
95//! observes into [`SliverAppBarView::scroll_offset`] (or a fraction it computes
96//! itself into [`SliverAppBarView::collapse`]), and this widget is a pure
97//! function of it.
98//!
99//! ```
100//! use frust::{ScrollInfo, scroll_view, text};
101//! use frust_material::{AppBarVariant, sliver_app_bar};
102//!
103//! struct App {
104//!     /// The last offset the body reported — the bar's collapse input.
105//!     scrolled: f64,
106//! }
107//!
108//! fn header(state: &App) -> frust_material::SliverAppBarView<App> {
109//!     sliver_app_bar("Inbox")
110//!         .variant(AppBarVariant::Large)
111//!         .scroll_offset(state.scrolled)
112//! }
113//!
114//! fn body(_state: &App) -> impl frust::View<App> + use<> {
115//!     scroll_view(text("content"))
116//!         .on_scroll(|state: &mut App, info: ScrollInfo| state.scrolled = info.offset)
117//! }
118//! ```
119//!
120//! [`AppBarCollapse`] is that mapping as a standalone pure function pair, so an
121//! app can pin the same geometry its layout math needs (how tall the bar is at
122//! a given offset, how far the scroll must travel to collapse it) without
123//! building a view.
124//!
125//! There is no motion controller anywhere in this family: a collapse is a value
126//! change, applied on the rebuild that carries it. That is already the
127//! `reduce_motion` behavior every animated widget in this catalog falls back to,
128//! so there is nothing here for that flag to collapse.
129//!
130//! # Semantics
131//!
132//! A top/collapsing bar is one accesskit container node of [`Role::TitleBar`]
133//! (the closest accesskit vocabulary to "a window/app title bar region", chosen
134//! over a generic `Role::GenericContainer` since accesskit publishes a
135//! purpose-built role for exactly this region), labelled with
136//! [`AppBarView::semantic_label`] or the title text, whose children are the
137//! leading slot, the title, and each action in that order via
138//! [`frust::authoring::SemanticsCtx::push_container`]. A bottom bar is a
139//! [`Role::Toolbar`] container instead — the same role [`crate::toolbar`] uses,
140//! since a bottom action row is not a title bar. This replaces upstream's
141//! `M3ESliverSemantic` render-object wrapper (`m3e_app_bar_semantics.dart`),
142//! which exists only because a Flutter sliver cannot carry a `Semantics` widget
143//! directly.
144//!
145//! # Not ported
146//!
147//! * **`safeArea` / `MediaQuery.viewPadding`.** This family's own bars still
148//!   never self-inset (`docs/CODE_STANDARDS.md`'s
149//!   self-sizing-chrome-consumes-its-own-inset rule leaves the choice to the
150//!   composer); a caller wraps a top/collapsing/bottom app bar in
151//!   `frust::safe_area(...)`, the same way [`crate::selection_app_bar`]
152//!   documents. [`crate::navigation_bar`] is this catalog's one
153//!   self-insetting chrome widget (the bottom edge, `.safe_area(bool)`,
154//!   default on) — an exception to this family's contract, not a precedent
155//!   for it.
156//! * **`automaticallyImplyLeading`.** Upstream reads `Navigator.maybeOf(context)`
157//!   to synthesize a back button; a widget here has no navigator handle, and
158//!   this catalog's convention is an explicit `leading` slot (supply
159//!   [`fn@crate::icon_button`] with [`crate::icons::ARROW_BACK`]).
160//! * **`floating`/`snap`.** Both need the scroll *direction* and a per-frame
161//!   sliver-protocol callback this framework has no seam for; an app that wants
162//!   reveal-on-scroll-up computes its own collapse fraction and feeds it in.
163//!   [`SliverAppBarView::pinned`] *is* ported — it selects whether the bar
164//!   bottoms out at the collapsed band or scrolls away entirely.
165//! * **`elevation` / `Material` shadow.** Upstream's own default is `0`, so a
166//!   ported default paints nothing; this family has no shadow to place behind an
167//!   elevation token (the FAB/Card precedent owns that).
168//! * **`clipBehavior`.** The collapsing bar clips its own content band once the
169//!   bar is shorter than it (see [`mod@sliver`]); nothing else in the family
170//!   overflows its box, so there is no clip knob to expose.
171//! * **`foregroundColor` / `IconTheme.merge`.** Slot content is opaque here —
172//!   the caller styles its own icon buttons, per the slot contract above.
173//! * **`M3EAppBarTheme` as a theme extension.** MaterialTokens-only: every
174//!   color/shape below resolves from [`frust::Theme`]/`ColorScheme` directly,
175//!   with an explicit builder override winning over both, the same precedence
176//!   [`mod@crate::list_item`]/[`mod@crate::card_list`] use.
177//!
178//! # Attribution
179//!
180//! See `plugins/material/NOTICE`'s "MIT License — Additional Copyright Holders
181//! (Vendored Components)" section and its Module Attribution Header Convention.
182
183use frust::authoring::text::{FontWeight, LineHeight, TextOverflow};
184use frust::authoring::{AnyView, ChildPod, Role, ThemeTextType};
185use frust::{ShapeScale, Theme, text};
186use peniko::Color;
187
188pub mod bottom;
189pub mod sliver;
190pub mod top;
191
192pub use bottom::{BottomAppBar, BottomAppBarView, BottomAppBarWidget, bottom_app_bar};
193pub use sliver::{SliverAppBar, SliverAppBarView, SliverAppBarWidget, sliver_app_bar};
194pub use top::{AppBar, AppBarView, AppBarWidget, SearchAppBar, app_bar, search_app_bar};
195
196/// Container height of the small/center-aligned Top App Bar at
197/// [`AppBarDensity::Regular`], in logical px.
198///
199/// Source: m3.material.io/components/top-app-bars/specs (also
200/// material-components-android `docs/components/TopAppBar.md`). Deliberately
201/// *not* upstream's own 72dp `smallHeight`/`collapsedHeight` — see the [module
202/// docs](self)' metrics table for why, and
203/// [`AppBarView::toolbar_height`](top::AppBarView::toolbar_height) for the
204/// per-bar override that reaches it.
205const HEIGHT: f64 = 64.0;
206
207/// Horizontal inset from a bar's leading/trailing edges to the leading/action
208/// slots, in logical px (upstream's `contentPadding`, 0 horizontal).
209const PAD_X: f64 = 4.0;
210
211/// Gap between adjacent slots (leading↔title, title↔actions, action↔action), in
212/// logical px (upstream's `titleGap`, 8dp).
213const GAP: f64 = 4.0;
214
215/// Height reduction [`AppBarDensity::Compact`] applies to every band but the
216/// bottom bar's — upstream's `M3EAppBarTheme.compactHeightReduction`.
217const COMPACT_REDUCTION: f64 = 8.0;
218
219/// Expanded band of the medium collapsing bar, in logical px —
220/// `M3EAppBarTheme.mediumExpanded`, which is also the published M3
221/// medium-top-app-bar height.
222const MEDIUM_EXPANDED: f64 = 112.0;
223
224/// Expanded band of the large collapsing bar, in logical px —
225/// `M3EAppBarTheme.largeExpanded`, which is also the published M3
226/// large-top-app-bar height.
227const LARGE_EXPANDED: f64 = 152.0;
228
229/// Bottom app bar band, in logical px — `M3EAppBarTheme.bottomHeight`, which is
230/// also the published M3 bottom-app-bar height. Density-independent (see the
231/// [module docs](self)).
232const BOTTOM_HEIGHT: f64 = 80.0;
233
234/// Start and bottom inset of a collapsing bar's *expanded* headline, in logical
235/// px — upstream's `FlexibleSpaceBar(titlePadding: start 16, bottom 16, end 16)`.
236const HEADLINE_INSET: f64 = 16.0;
237
238/// The collapsed title's M3 `titleLargeEmphasized` type-scale token, hardcoded
239/// here rather than read from a live `Theme::type_scale`: unlike a themed
240/// *color* or font *family*, `Text` has no layout-time-deferred *size/weight*
241/// resolution seam (see `docs/CODE_STANDARDS.md`'s Theming conventions — only
242/// a color role and an opted-in family role are resolved after `View::build`;
243/// the title opts into the family, see `title_text_view`). Matches
244/// `frust-theme::typography`'s `TITLE_LARGE_EMPHASIZED`: the baseline
245/// `TITLE_LARGE` size/line-height (m3.material.io) with the weight stepped
246/// Regular → Medium.
247const TITLE_SIZE: f32 = 22.0;
248/// Line height of [`TITLE_SIZE`]'s type token, in logical px.
249const TITLE_LINE_HEIGHT: f32 = 28.0;
250/// Weight shared by both title tokens — the M3E *emphasized* type-scale
251/// consumption (Regular → Medium).
252const TITLE_WEIGHT: FontWeight = FontWeight::MEDIUM;
253
254/// The expanded headline's M3 `headlineSmallEmphasized` type-scale token, the
255/// style upstream's `M3EAppBarTheme.titleStyle(collapsed: false)` resolves.
256/// Hardcoded for the reason [`TITLE_SIZE`] is.
257const HEADLINE_SIZE: f32 = 24.0;
258/// Line height of [`HEADLINE_SIZE`]'s type token, in logical px.
259const HEADLINE_LINE_HEIGHT: f32 = 32.0;
260
261/// Unthemed fallback container fill for a top/collapsing bar (a theme resolves
262/// this from `colors.surface`).
263const CONTAINER: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
264
265/// Unthemed fallback container fill for a bottom bar (a theme resolves this from
266/// `colors.surface_container`) — matches [`crate::toolbar`]'s and
267/// [`crate::navbar`]'s own `CONTAINER` constant exactly, the same M3 "surface
268/// container" role.
269const BOTTOM_CONTAINER: Color = Color::from_rgb8(0xF3, 0xED, 0xF7);
270
271/// Unthemed fallback corner radius for [`AppBarShapeFamily::Round`], in logical
272/// px — `crate::tokens::shape_scale()`'s own `small` value.
273const ROUND_RADIUS: f64 = 8.0;
274
275/// Which collapsing-bar layout [`sliver_app_bar`] renders —
276/// `M3EAppBarVariant`. The variant influences only the collapsing bar; the
277/// fixed [`app_bar`] always renders the collapsed, single-line layout.
278#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
279pub enum AppBarVariant {
280    /// A single-line bar with no expanded headline (upstream returns no
281    /// `flexibleSpace` at all for this one).
282    Small,
283    /// A two-line bar whose headline expands beneath the action row.
284    #[default]
285    Medium,
286    /// A taller two-line bar with a larger expanded headline.
287    Large,
288}
289
290/// The corner shape family of an app bar container — `M3EAppBarShapeFamily`.
291#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
292pub enum AppBarShapeFamily {
293    /// Rounded container corners (`theme.shape.small`, 8dp).
294    Round,
295    /// Squared container corners (`theme.shape.none`).
296    #[default]
297    Square,
298}
299
300/// The vertical density of an app bar — `M3EAppBarDensity`.
301#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
302pub enum AppBarDensity {
303    /// Standard M3 heights.
304    #[default]
305    Regular,
306    /// Heights reduced by 8dp (upstream's own `compactHeightReduction`) — the
307    /// bottom bar excepted, per the [module docs](self).
308    Compact,
309}
310
311/// The resolved band heights of one density — upstream's `M3EAppBarMetrics`,
312/// as produced by `M3EAppBarTheme.metrics(density)`.
313///
314/// ```
315/// use frust_material::{AppBarDensity, AppBarMetrics, AppBarVariant};
316///
317/// let regular = AppBarMetrics::for_density(AppBarDensity::Regular);
318/// assert_eq!(regular.small_height, 64.0);
319/// assert_eq!(regular.expanded(AppBarVariant::Large), 152.0);
320///
321/// // Compact takes 8dp off every band but the bottom bar's.
322/// let compact = AppBarMetrics::for_density(AppBarDensity::Compact);
323/// assert_eq!(compact.small_height, 56.0);
324/// assert_eq!(compact.bottom_height, regular.bottom_height);
325/// ```
326#[derive(Clone, Copy, Debug, PartialEq)]
327pub struct AppBarMetrics {
328    /// The fixed top bar's band, in logical px.
329    pub small_height: f64,
330    /// The collapsing bar's fully-collapsed band, in logical px.
331    pub collapsed_height: f64,
332    /// The [`AppBarVariant::Medium`] expanded band, in logical px.
333    pub medium_expanded: f64,
334    /// The [`AppBarVariant::Large`] expanded band, in logical px.
335    pub large_expanded: f64,
336    /// The bottom bar's band, in logical px (density-independent).
337    pub bottom_height: f64,
338}
339
340impl AppBarMetrics {
341    /// The bands `density` resolves to.
342    pub const fn for_density(density: AppBarDensity) -> Self {
343        let cut = match density {
344            AppBarDensity::Regular => 0.0,
345            AppBarDensity::Compact => COMPACT_REDUCTION,
346        };
347        Self {
348            small_height: HEIGHT - cut,
349            collapsed_height: HEIGHT - cut,
350            medium_expanded: MEDIUM_EXPANDED - cut,
351            large_expanded: LARGE_EXPANDED - cut,
352            bottom_height: BOTTOM_HEIGHT,
353        }
354    }
355
356    /// The fully-expanded band of `variant`, in logical px —
357    /// `_buildSliver`'s own `switch (variant)`.
358    pub const fn expanded(&self, variant: AppBarVariant) -> f64 {
359        match variant {
360            AppBarVariant::Small => self.small_height,
361            AppBarVariant::Medium => self.medium_expanded,
362            AppBarVariant::Large => self.large_expanded,
363        }
364    }
365}
366
367/// The collapse geometry of one [`sliver_app_bar`] configuration: the pure
368/// mapping between an app's scroll offset, the bar's collapse fraction, and the
369/// band it occupies.
370///
371/// Every method is clamp-disciplined and NaN-safe — a non-finite input resolves
372/// to the resting (expanded) end rather than propagating.
373///
374/// ```
375/// use frust_material::{AppBarCollapse, AppBarDensity, AppBarVariant};
376///
377/// let geometry = AppBarCollapse::new(AppBarVariant::Large, AppBarDensity::Regular, true);
378/// assert_eq!(geometry.expanded_height(), 152.0);
379/// assert_eq!(geometry.collapsed_height(), 64.0);
380///
381/// // A pinned bar bottoms out at its collapsed band, 88px of scroll later.
382/// assert_eq!(geometry.travel(), 88.0);
383/// assert_eq!(geometry.height(0.0), 152.0);
384/// assert_eq!(geometry.height(1.0), 64.0);
385/// assert_eq!(geometry.fraction_for_offset(44.0), 0.5);
386///
387/// // Past the end, and off the map, both clamp.
388/// assert_eq!(geometry.fraction_for_offset(1_000.0), 1.0);
389/// assert_eq!(geometry.fraction_for_offset(f64::NAN), 0.0);
390/// ```
391#[derive(Clone, Copy, Debug, PartialEq)]
392pub struct AppBarCollapse {
393    expanded: f64,
394    collapsed: f64,
395    min: f64,
396}
397
398impl AppBarCollapse {
399    /// The geometry of a `variant` bar at `density`, bottoming out at its
400    /// collapsed band when `pinned` and at zero height otherwise.
401    pub const fn new(variant: AppBarVariant, density: AppBarDensity, pinned: bool) -> Self {
402        let metrics = AppBarMetrics::for_density(density);
403        let collapsed = metrics.collapsed_height;
404        Self {
405            expanded: metrics.expanded(variant),
406            collapsed,
407            min: if pinned { collapsed } else { 0.0 },
408        }
409    }
410
411    /// The band at collapse `0.0`, in logical px.
412    pub const fn expanded_height(&self) -> f64 {
413        self.expanded
414    }
415
416    /// The collapsed band — the top row's own height, in logical px. A pinned
417    /// bar bottoms out here; an unpinned one keeps shrinking past it.
418    pub const fn collapsed_height(&self) -> f64 {
419        self.collapsed
420    }
421
422    /// The band at collapse `1.0`, in logical px: the collapsed band when
423    /// pinned, `0.0` otherwise.
424    pub const fn min_height(&self) -> f64 {
425        self.min
426    }
427
428    /// The scroll distance, in logical px, that carries this bar from fully
429    /// expanded to fully collapsed — never negative.
430    pub fn travel(&self) -> f64 {
431        (self.expanded - self.min).max(0.0)
432    }
433
434    /// The collapse fraction a scroll `offset` (a [`ScrollInfo`](frust::ScrollInfo)'s
435    /// own clamped `offset`) maps to, in `[0, 1]`.
436    ///
437    /// A bar with no travel at all ([`AppBarVariant::Small`], pinned) reports
438    /// `1.0` for any positive offset and `0.0` at rest — it has no expanded
439    /// state to leave, so the answer is only ever cosmetic.
440    pub fn fraction_for_offset(&self, offset: f64) -> f64 {
441        let travel = self.travel();
442        if travel <= 0.0 {
443            return if offset > 0.0 { 1.0 } else { 0.0 };
444        }
445        clamp01(offset / travel)
446    }
447
448    /// The band this bar occupies at collapse fraction `collapse`, in logical
449    /// px — linear between [`Self::expanded_height`] and [`Self::min_height`].
450    pub fn height(&self, collapse: f64) -> f64 {
451        let t = clamp01(collapse);
452        self.expanded + (self.min - self.expanded) * t
453    }
454
455    /// How far the *headline* has collapsed at collapse fraction `collapse`, in
456    /// `[0, 1]`: `0.0` is the expanded headline (bottom-anchored,
457    /// `headlineSmall`), `1.0` the collapsed one (in the top row, `titleLarge`).
458    ///
459    /// This is not [`Self::height`]'s own fraction: an unpinned bar keeps
460    /// shrinking after the headline has fully collapsed, so the headline
461    /// saturates first.
462    pub fn headline(&self, collapse: f64) -> f64 {
463        let span = self.expanded - self.collapsed;
464        if span <= 0.0 {
465            return 1.0;
466        }
467        clamp01((self.expanded - self.height(collapse)) / span)
468    }
469}
470
471/// `value` clamped into `[0, 1]`, mapping NaN to the resting `0.0` rather than
472/// propagating it into a layout.
473fn clamp01(value: f64) -> f64 {
474    if value.is_nan() {
475        0.0
476    } else {
477        value.clamp(0.0, 1.0)
478    }
479}
480
481/// Linear interpolation from `a` to `b` at an already-clamped `t`.
482fn lerp(a: f64, b: f64, t: f64) -> f64 {
483    a + (b - a) * t
484}
485
486/// `constraint` if finite, `0.0` otherwise — the degenerate unbounded-width
487/// case every full-width bar in this family resolves the same way
488/// ([`crate::toolbar`]'s docked variant included).
489fn finite_or_zero(constraint: f64) -> f64 {
490    if constraint.is_finite() {
491        constraint
492    } else {
493        0.0
494    }
495}
496
497/// A bar's title: either a string this family owns and styles, or a caller view
498/// it merely places (upstream's `titleText` / `title` pair).
499pub(crate) enum TitleSlot<State: 'static> {
500    /// A string composed into a styled child [`frust::TextView`].
501    Text(String),
502    /// A caller-supplied view, placed but never styled.
503    View(AnyView<State>),
504}
505
506impl<State: 'static> TitleSlot<State> {
507    /// The semantics label this slot contributes on its own — a non-empty
508    /// title string, or nothing for a caller view (which labels itself).
509    fn label(&self) -> Option<&str> {
510        match self {
511            TitleSlot::Text(title) if !title.is_empty() => Some(title.as_str()),
512            _ => None,
513        }
514    }
515}
516
517/// Build the title's type-erased child view at `size`/`line_height`: emphasized
518/// title/headline text defaulting to the `Text` widget's own `OnSurface` themed
519/// role (an app bar title reads as ordinary on-surface content), ellipsized to
520/// one line like upstream's own `titleText`.
521///
522/// Its font family follows the live theme's `titleLargeEmphasized` role — the
523/// collapsed, resting role — and by design that one family governs a sliver
524/// bar's whole collapse. The sliver interpolates the title's size AND line
525/// height between `headlineSmallEmphasized` (expanded) and
526/// `titleLargeEmphasized` (collapsed) at build time, but a family cannot
527/// interpolate, and switching faces mid-collapse would make the title visibly
528/// jump. Under a type scale whose headline and title families differ — Glyph's
529/// (Space Mono headline, IBM Plex Mono title) or Cupertino's (SF Pro Display
530/// at 20pt and up, SF Pro Text below) — the expanded headline therefore
531/// renders in the title face. The Material scale gives both roles Roboto Flex.
532fn title_text_view<State: 'static>(title: &str, size: f32, line_height: f32) -> AnyView<State> {
533    frust::authoring::any::<State, _>(
534        text(title.to_string())
535            .size(size)
536            .weight(TITLE_WEIGHT)
537            .line_height(LineHeight::Absolute(line_height))
538            .max_lines(1)
539            .overflow(TextOverflow::Ellipsis)
540            .themed_family(ThemeTextType::TitleLargeEmphasized),
541    )
542}
543
544/// Materialize `slot` into a view reference, using `storage` to hold the text
545/// case's freshly-composed view for the caller's borrow.
546///
547/// The two title cases have to reach [`frust::authoring::rebuild_children`] as
548/// one uniform `&AnyView` list, and only the string case has a view to compose;
549/// this is the one-slot storage that lets both spell it the same way.
550fn title_ref<'a, State: 'static>(
551    slot: &'a TitleSlot<State>,
552    storage: &'a mut Option<AnyView<State>>,
553    size: f32,
554    line_height: f32,
555) -> &'a AnyView<State> {
556    match slot {
557        TitleSlot::Text(title) => {
558            storage.insert(title_text_view::<State>(title, size, line_height))
559        }
560        TitleSlot::View(view) => view,
561    }
562}
563
564/// The resolved container fill of a top/collapsing bar. Precedence is the
565/// builder's own `background` (applied by the caller), then
566/// `colors.surface`, then the [`CONTAINER`] fallback.
567fn resolve_container(theme: Option<&Theme>) -> Color {
568    match theme {
569        Some(theme) => theme.scheme().surface,
570        None => CONTAINER,
571    }
572}
573
574/// The resolved container fill of a bottom bar — `colors.surface_container`
575/// (upstream's `bottomBackgroundColor`), falling back to [`BOTTOM_CONTAINER`].
576fn resolve_bottom_container(theme: Option<&Theme>) -> Color {
577    match theme {
578        Some(theme) => theme.scheme().surface_container,
579        None => BOTTOM_CONTAINER,
580    }
581}
582
583/// The resolved corner radius for `family` against a bar of `width`×`height`.
584/// Themed: `shape.small` (round) / `shape.none` (square). Unthemed: the
585/// equivalent literals, resolved the same way.
586fn resolve_radius(
587    theme: Option<&Theme>,
588    family: AppBarShapeFamily,
589    width: f64,
590    height: f64,
591) -> f64 {
592    let token = match (theme, family) {
593        (Some(theme), AppBarShapeFamily::Round) => theme.shape.small,
594        (Some(theme), AppBarShapeFamily::Square) => theme.shape.none,
595        (None, AppBarShapeFamily::Round) => ROUND_RADIUS,
596        (None, AppBarShapeFamily::Square) => 0.0,
597    };
598    ShapeScale::resolve(token, width, height)
599}
600
601/// Fill a bar's container: a plain rect at a zero radius (so a square bar stays
602/// one `fill_rect`, the shape every recorder scene observes), a rounded rect
603/// otherwise.
604fn fill_container(
605    scene: &mut dyn frust::authoring::PaintScene,
606    origin: kurbo::Point,
607    size: kurbo::Size,
608    radius: f64,
609    color: Color,
610) {
611    if radius > 0.0 {
612        scene.fill_rounded_rect(origin, size, radius, color);
613    } else {
614        scene.fill_rect(origin, size, color);
615    }
616}
617
618/// Push the family's semantics container for `role`, labelled with `label` when
619/// there is one, forwarding every slot pod in visual order.
620fn push_bar_semantics(
621    ctx: &mut frust::authoring::SemanticsCtx,
622    role: Role,
623    label: Option<&str>,
624    slots: &[ChildPod],
625) {
626    ctx.push_container(
627        role,
628        |node| {
629            if let Some(label) = label {
630                node.set_label(label);
631            }
632        },
633        |ctx| {
634            for pod in slots {
635                pod.semantics_child(ctx);
636            }
637        },
638    );
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    /// The ported metric table, pinned against `M3EAppBarTheme`'s own defaults
646    /// (and, where this port diverges, against the published M3 spec — see the
647    /// module docs' metrics table).
648    #[test]
649    fn the_ported_metrics_match_the_reference_theme_defaults() {
650        assert_eq!(MEDIUM_EXPANDED, 112.0);
651        assert_eq!(LARGE_EXPANDED, 152.0);
652        assert_eq!(BOTTOM_HEIGHT, 80.0);
653        assert_eq!(COMPACT_REDUCTION, 8.0);
654        assert_eq!(HEADLINE_INSET, 16.0);
655        // The one deliberate divergence: upstream's own band is 72.
656        assert_eq!(HEIGHT, 64.0);
657    }
658
659    /// The metrics [`crate::selection_app_bar`] mirrors verbatim (its
660    /// `APP_BAR_HEIGHT`/`APP_BAR_PAD_X`/`APP_BAR_GAP`), so the contextual bar
661    /// and an idle one keep reading as one continuous surface.
662    #[test]
663    fn the_selection_bar_coupling_metrics_are_unchanged() {
664        assert_eq!(HEIGHT, 64.0);
665        assert_eq!(PAD_X, 4.0);
666        assert_eq!(GAP, 4.0);
667        assert_eq!(TITLE_SIZE, 22.0);
668        assert_eq!(TITLE_LINE_HEIGHT, 28.0);
669        assert_eq!(TITLE_WEIGHT, FontWeight::MEDIUM);
670    }
671
672    #[test]
673    fn compact_density_reduces_every_band_but_the_bottom_one() {
674        let regular = AppBarMetrics::for_density(AppBarDensity::Regular);
675        let compact = AppBarMetrics::for_density(AppBarDensity::Compact);
676        assert_eq!(
677            compact.small_height,
678            regular.small_height - COMPACT_REDUCTION
679        );
680        assert_eq!(
681            compact.collapsed_height,
682            regular.collapsed_height - COMPACT_REDUCTION
683        );
684        assert_eq!(
685            compact.medium_expanded,
686            regular.medium_expanded - COMPACT_REDUCTION
687        );
688        assert_eq!(
689            compact.large_expanded,
690            regular.large_expanded - COMPACT_REDUCTION
691        );
692        assert_eq!(compact.bottom_height, regular.bottom_height);
693    }
694
695    #[test]
696    fn each_variant_expands_to_its_own_band() {
697        let metrics = AppBarMetrics::for_density(AppBarDensity::Regular);
698        assert_eq!(metrics.expanded(AppBarVariant::Small), HEIGHT);
699        assert_eq!(metrics.expanded(AppBarVariant::Medium), MEDIUM_EXPANDED);
700        assert_eq!(metrics.expanded(AppBarVariant::Large), LARGE_EXPANDED);
701    }
702
703    #[test]
704    fn a_pinned_collapse_bottoms_out_at_the_collapsed_band() {
705        let geometry = AppBarCollapse::new(AppBarVariant::Medium, AppBarDensity::Regular, true);
706        assert_eq!(geometry.min_height(), geometry.collapsed_height());
707        assert_eq!(geometry.height(0.0), MEDIUM_EXPANDED);
708        assert_eq!(geometry.height(1.0), HEIGHT);
709        assert_eq!(geometry.height(0.5), (MEDIUM_EXPANDED + HEIGHT) / 2.0);
710        // The headline saturates exactly when the band does, since a pinned
711        // bar's travel *is* the headline span.
712        assert_eq!(geometry.headline(1.0), 1.0);
713        assert_eq!(geometry.headline(0.0), 0.0);
714    }
715
716    #[test]
717    fn an_unpinned_collapse_scrolls_away_after_the_headline_saturates() {
718        let geometry = AppBarCollapse::new(AppBarVariant::Medium, AppBarDensity::Regular, false);
719        assert_eq!(geometry.min_height(), 0.0);
720        assert_eq!(geometry.height(1.0), 0.0);
721        // The headline is fully collapsed once the band reaches the collapsed
722        // height — 48 of the 112px travel remains after that.
723        let t_at_collapsed = (MEDIUM_EXPANDED - HEIGHT) / MEDIUM_EXPANDED;
724        assert!((geometry.headline(t_at_collapsed) - 1.0).abs() < 1e-9);
725        assert_eq!(geometry.headline(1.0), 1.0);
726    }
727
728    #[test]
729    fn a_small_variant_has_no_headline_travel_at_all() {
730        let geometry = AppBarCollapse::new(AppBarVariant::Small, AppBarDensity::Regular, true);
731        assert_eq!(geometry.travel(), 0.0);
732        assert_eq!(geometry.headline(0.0), 1.0);
733        assert_eq!(geometry.fraction_for_offset(0.0), 0.0);
734        assert_eq!(geometry.fraction_for_offset(10.0), 1.0);
735        // The band never moves, whatever the fraction says.
736        assert_eq!(geometry.height(0.0), HEIGHT);
737        assert_eq!(geometry.height(1.0), HEIGHT);
738    }
739
740    #[test]
741    fn every_collapse_input_is_clamped_and_nan_safe() {
742        let geometry = AppBarCollapse::new(AppBarVariant::Large, AppBarDensity::Regular, true);
743        assert_eq!(geometry.height(-5.0), LARGE_EXPANDED);
744        assert_eq!(geometry.height(5.0), HEIGHT);
745        assert_eq!(geometry.height(f64::NAN), LARGE_EXPANDED);
746        assert_eq!(geometry.headline(f64::NAN), 0.0);
747        assert_eq!(geometry.fraction_for_offset(-100.0), 0.0);
748        assert_eq!(geometry.fraction_for_offset(f64::NAN), 0.0);
749        assert_eq!(geometry.fraction_for_offset(f64::INFINITY), 1.0);
750        assert_eq!(geometry.fraction_for_offset(f64::NEG_INFINITY), 0.0);
751    }
752
753    #[test]
754    fn clamp_and_lerp_helpers_hold_their_contract() {
755        assert_eq!(clamp01(f64::NAN), 0.0);
756        assert_eq!(clamp01(-1.0), 0.0);
757        assert_eq!(clamp01(2.0), 1.0);
758        assert_eq!(lerp(10.0, 20.0, 0.25), 12.5);
759        assert_eq!(finite_or_zero(f64::INFINITY), 0.0);
760        assert_eq!(finite_or_zero(f64::NAN), 0.0);
761        assert_eq!(finite_or_zero(320.0), 320.0);
762    }
763
764    #[test]
765    fn the_shape_families_resolve_their_own_tokens() {
766        let theme = crate::baseline();
767        assert_eq!(
768            resolve_radius(Some(&theme), AppBarShapeFamily::Square, 400.0, 64.0),
769            0.0
770        );
771        assert_eq!(
772            resolve_radius(Some(&theme), AppBarShapeFamily::Round, 400.0, 64.0),
773            theme.shape.small
774        );
775        // Unthemed, the same pair of literals.
776        assert_eq!(
777            resolve_radius(None, AppBarShapeFamily::Round, 400.0, 64.0),
778            ROUND_RADIUS
779        );
780        assert_eq!(
781            resolve_radius(None, AppBarShapeFamily::Square, 400.0, 64.0),
782            0.0
783        );
784    }
785}