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}