frust_material/toolbar.rs
1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// `lib/components/toolbars/` — `m3e_toolbars.dart`,
3// `m3e_toolbar_scroll_behavior.dart`, `controllers/m3e_toolbar_visibility_controller.dart`,
4// `components/` (`m3e_toolbar_build.dart`, `m3e_toolbar_body.dart`,
5// `m3e_toolbar_actions_row.dart`, `m3e_toolbar_fab_layout.dart`,
6// `m3e_toolbar_fab_slot.dart`, `m3e_toolbar_overflow_menu.dart`,
7// `m3e_toolbar_icon_button.dart`, `m3e_toolbar_measure_size.dart`,
8// `m3e_toolbar_title_block.dart`, `m3e_toolbar_expanding_actions.dart`),
9// `enums/m3e_toolbar_enums.dart`, `models/m3e_toolbar_item.dart`,
10// `res/m3e_toolbar_tokens.dart`, `styles/m3e_toolbar_theme.dart`, and
11// `utils/` (`m3e_toolbar_item_layout.dart`, `m3e_toolbar_spring_motion.dart`)
12// (retrieved 2026-08-20). Upstream:
13// https://github.com/paadevelopments/material_3_expressive
14// Porting decisions (each restated with its reference site in the module docs
15// below): expansion and scroll-hide are app-owned controlled props rather than
16// the reference's internal `_expanded` mirror and ticker-driven visibility
17// controller; the adjacent FAB is morphed through this widget's own tight
18// constraints rather than the reference's `FittedBox` rescale; the overflow
19// panel is a second, app-mounted view rather than something this widget hosts;
20// and the vertical axis, the title/subtitle block and the labeled-action width
21// spring stay unported.
22
23//! The M3 Expressive **floating/docked toolbar**
24//! (m3.material.io/components/toolbars/specs; Compose
25//! `HorizontalFloatingToolbar`/`FlexibleBottomAppBar`).
26//!
27//! [`ToolbarView`]/[`ToolbarWidget`] mirror [`super::appbar`]'s slot
28//! conventions (read that module first): **leading**, **center**, and
29//! **trailing** slots, each an ordered `Vec` of opaque [`AnyView`] children
30//! this widget lays out and routes events to but never paints or tints itself.
31//! An optional **fab** slot (`.fab(...)`) is the adjacent prominent action.
32//! Alongside the opaque slots sits a *typed* [`ToolbarAction`] list
33//! ([`ToolbarView::actions`], upstream's own `actions`) — the only slot this
34//! widget composes itself, because it is the one that has to survive the
35//! overflow split below.
36//!
37//! # Variants ([`ToolbarVariant`]) and color styles ([`ToolbarColorStyle`])
38//!
39//! * **Floating**: self-sized (hugs its content), a fully-rounded pill
40//! (`shape.full`, resolved to a true pill via [`frust::ShapeScale::resolve`]
41//! against the bar's own fixed height). "Floating with inset margins above
42//! content" describes the *placement* a caller gives it (a [`frust::Stack`]
43//! with [`frust::Align`]/[`frust::Padding`] around this self-sized widget) —
44//! this widget implements no margin/anchoring logic of its own.
45//! * **Docked**: fills the available width (`bc.max().width`, like
46//! [`super::appbar`]/[`super::navbar`]), flat geometry (`shape.none`) — the
47//! flat replacement for the static bottom-app-bar pattern. The leading group
48//! hugs the leading edge, the trailing group (+ the fab slot, if any) the
49//! trailing edge in reading order, and the center group is centered as a
50//! whole between them.
51//!
52//! `M3EToolbarTheme.colors` maps the two color styles onto four roles each:
53//!
54//! | Style | container | content | fab container | fab content |
55//! |---|---|---|---|---|
56//! | [`ToolbarColorStyle::Standard`] | `surface_container` | `on_surface` | `primary_container` | `on_primary_container` |
57//! | [`ToolbarColorStyle::Vibrant`] | `primary_container` | `on_primary_container` | `tertiary_container` | `on_tertiary_container` |
58//!
59//! [`ToolbarColors::resolve`] is that table as a public function. Only
60//! **container** is applied here; the other three are *advisory*, and for the
61//! same reason: content is caller-owned. The fab slot is an opaque caller view
62//! like every other slot, so a caller pairs a vibrant bar with
63//! [`crate::FabColor::Tertiary`] (upstream's own
64//! `style == vibrant ? M3EFabColor.tertiary : M3EFabColor.primary`) itself; and
65//! `content` reaches upstream's own inline actions only through
66//! `M3EToolbarTheme.scopedTheme`, a `Theme`-override push around the whole
67//! content band that has no analogue on this framework's public surface — the
68//! same ambient-tint gap [`mod@super::appbar`] documents for its own slots. A
69//! vibrant bar's actions therefore keep [`mod@crate::icon_button`]'s own
70//! `on_surface_variant` ink; an app that needs the vibrant pairing supplies its
71//! own tinted views in the `center` slot instead of typed actions.
72//!
73//! # Metrics
74//!
75//! Every value is a `M3EToolbarTokens` constant:
76//!
77//! | Token | Value | Constant |
78//! |---|---|---|
79//! | `containerSize` | 64 | [`TOOLBAR_HEIGHT`] |
80//! | `floatingContentPadding` | 8 | [`TOOLBAR_FLOATING_PAD`] |
81//! | `dockedHorizontalPadding` | 16 | [`TOOLBAR_DOCKED_PAD_X`] |
82//! | `containerBetweenSpace` | 4 | [`TOOLBAR_GAP`] |
83//! | `toolbarToFabGap` | 8 | [`TOOLBAR_TO_FAB_GAP`] |
84//! | `screenOffset` | 16 | [`TOOLBAR_SCREEN_OFFSET`] |
85//! | `fabBaseline` | 56 | [`TOOLBAR_FAB_BASELINE`] |
86//! | `fabMedium` | 80 | [`TOOLBAR_FAB_MEDIUM`] |
87//! | `elevationNone` / `elevationWithFabExpanded` | 0 / 1 | [`ToolbarElevation`] |
88//!
89//! The elevation pair is a **behavior change** against this module's previous
90//! shape, which pinned an M3 level-2 shadow under every floating pill: upstream
91//! rests at `elevationNone` and only lifts to `elevationWithFabExpanded` when a
92//! FAB is attached. [`ToolbarView::elevation`] reaches the old look explicitly
93//! ([`ToolbarElevation::Level2`]) — upstream's own `elevation` override.
94//!
95//! # The FAB morph (80 → 56), and what the fab seam can carry
96//!
97//! With a FAB attached, a **floating** bar lays out as
98//! `RenderM3EToolbarHorizontalFabLayout` does: a box
99//! `pill + toolbarToFabGap + fabBaseline` wide and `fabMedium` (80) tall, the
100//! pill vertically centered in it and the FAB pinned to
101//! [`ToolbarFabPosition`]'s edge. The FAB's square is
102//! `lerp(80, 56, progress)` — upstream's `_resolvedFabSize` — so it is **80dp
103//! while the bar is collapsed and 56dp once expanded**, and the pill reveals
104//! from the FAB's side across the same ramp (`toolbarWidth = natural *
105//! progress`, clipped to the revealed window, faded through
106//! `Interval(0.5, 1, easeIn)` — [`ToolbarMorph::reveal_alpha`]). `progress` is driven by
107//! [`ToolbarView::expanded`] on
108//! [`MaterialSpring::EXPRESSIVE_SPATIAL_FAST`](crate::MaterialSpring::EXPRESSIVE_SPATIAL_FAST)
109//! ([`TOOLBAR_EXPAND_SPRING`]), upstream's own `m3eToolbarExpandMotion()`.
110//! [`ToolbarView::fab_expands_toolbar`] pins the pill open and the FAB at 56
111//! (upstream's `fabExpandsToolbar: false`).
112//!
113//! **Who owns the toggle**: upstream's FAB press flips an internal `_expanded`
114//! mirror. The fab slot here is an opaque caller view holding its own press
115//! handler, so expansion is a *controlled prop* instead — the same
116//! never-self-mutating contract [`mod@crate::navigation_rail`] documents for its
117//! own expansion. The app flips `expanded` from the FAB's `on_press`.
118//!
119//! **The fab-seam degrade, stated plainly**: upstream sizes its FAB by wrapping
120//! it in a `FittedBox` — a *uniform rescale* of the whole button, container,
121//! corner radius and icon alike. There is no rescale seam here, so the morph is
122//! applied as **tight constraints** on the fab slot instead: the pod's laid-out
123//! rect is the morphing square, which is what [`mod@crate::fab`] fills its
124//! container, shadow and state layer against, and what hit-testing and the
125//! accessibility bounds read. What tight constraints cannot reach is
126//! [`mod@crate::fab`]'s *icon centering*, which is computed against its own
127//! [`crate::FabSize`] tier (56dp for the default `Medium`) rather than the box
128//! it is handed — so at the collapsed 80dp end the icon reads 12dp off-center.
129//! Closing that needs either an edit to `fab.rs` (out of this module's scope)
130//! or painting a child at a size it was not laid out at (which would desync
131//! hit-testing from the visual). An app that wants a pixel-exact collapsed end
132//! supplies its own fab view sized off [`ToolbarMorph::size`].
133//!
134//! # Scroll-hide: the app owns the wiring
135//!
136//! A widget here has no window or scroll handle, so — exactly as
137//! [`super::appbar`]'s collapse contract documents — the hide is a **controlled
138//! prop**: the app installs [`on_scroll`](frust::ScrollView::on_scroll) on its
139//! own scroll view, decides visibility, and feeds it back down.
140//!
141//! ```
142//! use frust::{Component, ScrollInfo, View, column, scroll_view, text};
143//! use frust_material::{ToolbarScrollHide, ToolbarView, floating_toolbar};
144//!
145//! struct App {
146//! /// The last offset the body reported — the hide's delta source.
147//! scrolled: f64,
148//! /// Upstream's `M3EToolbarVisibilityController`, as a plain value.
149//! hide: ToolbarScrollHide,
150//! }
151//!
152//! struct Inbox;
153//!
154//! impl Component for Inbox {
155//! type State = App;
156//!
157//! fn init(&self) -> App {
158//! App {
159//! scrolled: 0.0,
160//! hide: ToolbarScrollHide::for_bar(80.0),
161//! }
162//! }
163//!
164//! fn build(&self, state: &mut App) -> impl View<App> {
165//! column().child(Self::bar(state)).child(Self::body())
166//! }
167//! }
168//!
169//! impl Inbox {
170//! fn bar(state: &App) -> ToolbarView<App> {
171//! floating_toolbar().visible(!state.hide.is_hidden())
172//! }
173//!
174//! fn body() -> impl View<App> {
175//! scroll_view(text("content")).on_scroll(|state: &mut App, info: ScrollInfo| {
176//! let delta = info.offset - state.scrolled;
177//! state.scrolled = info.offset;
178//! state.hide.scroll(delta);
179//! })
180//! }
181//! }
182//! ```
183//!
184//! [`ToolbarScrollHide`] is upstream's `M3EToolbarVisibilityController` as a
185//! pure value (its ticker belongs to the widget, not the controller): the same
186//! `offset -= delta` accumulation clamped into `[offset_limit, 0]`, the same
187//! `collapsedFraction`, the same `-(extent + screenOffset)` measured limit, and
188//! the same `|velocity| > 150` settle rule ([`TOOLBAR_SETTLE_VELOCITY`]).
189//!
190//! Two props consume it, mirroring upstream's own two paths:
191//!
192//! * [`ToolbarView::visible`] — the animated `show()`/`hide()` path, sprung on
193//! [`TOOLBAR_EXPAND_SPRING`] (upstream's controller shares the expand
194//! motion).
195//! * [`ToolbarView::hidden_fraction`] — the 1:1 drag path (upstream's plain
196//! `offset` setter during a scroll update): pinned every frame, no spring.
197//!
198//! The bar slides along [`ToolbarExitDirection`] by
199//! `fraction × exit_extent`, clipped to its own box — upstream's
200//! `ClipRect(Transform.translate(...))` pair. The extent is
201//! [`ToolbarView::exit_extent`] or, unset, the measured
202//! `cross extent + screenOffset` (`M3EToolbarMeasureSize`'s own rule).
203//!
204//! **Both the hide offset and the FAB square are layout values**, so an
205//! in-flight ramp calls [`frust::authoring::PaintCtx::request_layout`] rather
206//! than merely asking for another frame — the layout-skip class
207//! [`mod@crate::navigation_rail`] documents at length, including its
208//! settling-frame refinement (the frame that both stops animating *and* lands
209//! on the target still needs the relayout). `Theme.motion.reduce_motion` snaps
210//! instead, and a snap that actually moved a value requests exactly one
211//! relayout.
212//!
213//! # Overflow
214//!
215//! `M3EToolbarItemLayout.partitionInline` keeps every non-action slot inline
216//! and overflows only surplus actions: an expand-trigger action always stays
217//! inline and *reserves* one slot (`actionBudget = maxInline - reservedTrigger`),
218//! and the first `actionBudget` remaining actions stay inline in order — the
219//! rest overflow. [`partition_actions`] is that rule as a pure function;
220//! [`ToolbarView::max_inline_actions`] is `maxInlineActions` (default
221//! [`TOOLBAR_MAX_INLINE_ACTIONS`], 4).
222//!
223//! When anything overflows, a trailing trigger ([`ToolbarView::overflow_icon`],
224//! upstream's `more_vert`) joins the inline row and reports
225//! [`ToolbarView::on_overflow`] on press. The hidden tail presents as an
226//! anchored menu through [`ToolbarView::overflow_menu`] — the same two-piece
227//! shape [`mod@crate::button_group`]'s `Popup` overflow establishes, and for the
228//! same reason: [`mod@crate::overlay::anchored`] is a plain widget with no
229//! navigator to reach out through, so the app mounts the panel itself (the top
230//! of its own [`frust::Stack`]), anchored to the [`OverlayAnchor`] handed to
231//! [`ToolbarView::overflow_anchor`], and toggles `open` rather than unmounting
232//! it (the kept-mounted pattern). Activating a row runs that action's own
233//! `on_press`, exactly as its inline button would.
234//!
235//! One additive divergence: upstream's overflow entry is label-only, while a
236//! [`ToolbarAction`] always carries an [`frust::IconSource`], so the same glyph
237//! the inline button shows leads the overflowed row.
238//!
239//! # Semantics
240//!
241//! The whole bar is one [`Role::Toolbar`] container node (the accesskit role
242//! purpose-built for this UI region), optionally labelled with
243//! [`ToolbarView::semantic_label`], whose children are every slot pod in visual
244//! order — leading, center, inline actions, the overflow trigger, trailing,
245//! then the fab slot — via
246//! [`frust::authoring::SemanticsCtx::push_container`].
247//!
248//! # Not ported
249//!
250//! * **The vertical axis** (`VerticalFloatingToolbar`,
251//! `M3EToolbarVerticalFabLayout`) — a horizontal bar is the whole of this
252//! module's v1; the vertical layout is a second, independent geometry.
253//! * **The title/subtitle block** (`M3EToolbarTitleBlock`, `centerTitle`) — a
254//! toolbar has no mandatory title, and the `center` slot already carries any
255//! caller view a title would be.
256//! * **The labeled-action width spring** (`M3EToolbarIconButton`'s in-button
257//! label morph, `pillActiveSpring`, `reservedLabeledSelectionExtent`) —
258//! [`mod@crate::icon_button`] has no label slot to morph, so an active action
259//! changes variant only. A [`ToolbarAction::label`] is still the overflow
260//! row's title, its other upstream use.
261//! * **`safeArea` / `MediaQuery.viewPadding`** — this catalog's chrome widgets
262//! never self-inset (`docs/CODE_STANDARDS.md`'s
263//! self-sizing-chrome-consumes-its-own-inset rule leaves the choice to the
264//! composer); a caller wraps the bar in `frust::safe_area(...)`.
265//! * **`clipBehavior` / `foregroundColor` / `scopedTheme` / `M3EToolbarTheme`
266//! as a theme extension** — slot content is opaque here, there is no ambient
267//! `Theme`-override push to scope a foreground onto (see the color table
268//! above), and every color/shape below resolves from [`frust::Theme`]
269//! directly (MaterialTokens-only), an explicit builder value winning over
270//! both.
271//! * **`ExcludeFocus` while hidden** — a fully-hidden bar's slots are
272//! translated outside its own box, so pointer input cannot reach them; a
273//! focus-routed key event still can, which an app avoids by not focusing a
274//! hidden bar.
275//!
276//! # Clamp discipline
277//!
278//! `f64::clamp` panics if `min > max` or either bound is NaN — a hard abort
279//! under `panic = "abort"`, not a catchable error (see [`crate::slider`]'s own
280//! core for the same rule stated at length, which this one mirrors). Two
281//! caller-fed values are the module's non-finite entry points, and both are
282//! funneled to a safe value right where they enter (the module's private
283//! `finite_or_zero`) rather than trusted downstream:
284//!
285//! * [`ToolbarScrollHide::new`] (and [`ToolbarScrollHide::for_bar`], which
286//! delegates to it) funnels its `exit_extent`/`cross_extent` construction
287//! parameter through `finite_or_zero` before it can reach `limit` — an
288//! unguarded non-finite `limit` would abort `set_offset`'s clamp on the
289//! very next [`ToolbarScrollHide::scroll`].
290//! * `ToolbarWidget::layout`'s `extent` (the resolved
291//! [`ToolbarView::exit_extent`] prop) is the same `finite_or_zero` funnel:
292//! unguarded, a non-finite `exit_extent` would carry through `shift` into
293//! `pill_origin`, which `reveal_window` then clamps *against* (`x0` is the
294//! second clamp's `min` bound) — a caller-fed extent reaching a clamp
295//! *bound*, not just a clamped value.
296//!
297//! That leaves two clamp shapes in the module tree:
298//!
299//! - **Bounds are literal `0.0`/`1.0` (or another literal constant)**
300//! (`clamp01`, `layout`'s `progress.clamp(0.0, REVEAL_OVERSHOOT)`) —
301//! trivially ordered, never a hazard.
302//! - **Bounds are already-finite by construction, funneled at the one point
303//! caller input enters** (`set_offset`'s `self.limit`, `reveal_window`'s
304//! `box_size.width`/`x0`) — both trace back to a `finite_or_zero` funnel
305//! rather than a re-check at every site.
306//!
307//! A new clamp against a computed bound must fit one of these two shapes, not
308//! introduce a third.
309//!
310//! # Attribution
311//!
312//! See `plugins/material/NOTICE`'s "MIT License — Additional Copyright Holders
313//! (Vendored Components)" section and its Module Attribution Header Convention.
314
315use std::rc::Rc;
316use std::time::Duration;
317
318use frust::authoring::{
319 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
320 LayoutCtx, PaintCtx, PaintScene, Role, SemanticsCtx, View, ViewSeq, Widget,
321};
322use frust::{
323 AnimationController, Curve, FrameTime, IconSource, ShapeScale, SpringDesc, Theme, Tween, icon,
324};
325use kurbo::{Point, Size, Vec2};
326use peniko::Color;
327
328use crate::icon_button::{IconButtonSize, IconButtonVariant, icon_button};
329use crate::menu::{MenuNode, MenuSelection, menu, menu_entry};
330use crate::overlay::{OverlayAlign, OverlayAnchor, overlay_anchor};
331
332// ---- Tokens (`res/m3e_toolbar_tokens.dart`'s `M3EToolbarTokens`) -----------
333
334/// Cross-axis container size of both toolbar variants, in logical px —
335/// `M3EToolbarTokens.containerSize`. The same M3 bar-height family
336/// [`super::appbar`]'s `HEIGHT` and [`super::navbar`]'s `HEIGHT` share.
337pub const TOOLBAR_HEIGHT: f64 = 64.0;
338
339/// Content inset of the **floating** pill on every edge, in logical px —
340/// `M3EToolbarTokens.floatingContentPadding` (`M3EToolbarTheme.metricsFor`
341/// resolves it as `EdgeInsets.all(8)`).
342pub const TOOLBAR_FLOATING_PAD: f64 = 8.0;
343
344/// Horizontal content inset of the **docked** bar, in logical px —
345/// `M3EToolbarTokens.dockedHorizontalPadding` (its vertical inset is
346/// [`TOOLBAR_FLOATING_PAD`], which this fixed-height port has no use for).
347pub const TOOLBAR_DOCKED_PAD_X: f64 = 16.0;
348
349/// Gap between adjacent slot elements, in logical px —
350/// `M3EToolbarTokens.containerBetweenSpace`, which
351/// `M3EToolbarTheme.metricsFor` publishes as its `gap`.
352pub const TOOLBAR_GAP: f64 = 4.0;
353
354/// Gap between the pill and an adjacent FAB, in logical px —
355/// `M3EToolbarTokens.toolbarToFabGap`.
356pub const TOOLBAR_TO_FAB_GAP: f64 = 8.0;
357
358/// Extra distance a scroll-hidden bar travels past its own extent, in logical
359/// px — `M3EToolbarTokens.screenOffset`, the margin
360/// `M3EToolbarMeasureSize`'s measured `offsetLimit` adds so the bar clears the
361/// screen edge entirely.
362pub const TOOLBAR_SCREEN_OFFSET: f64 = 16.0;
363
364/// The adjacent FAB's **expanded** square, in logical px —
365/// `M3EToolbarTokens.fabBaseline`. Also the width the pill's box always
366/// reserves for the FAB, whatever the morph is currently painting.
367pub const TOOLBAR_FAB_BASELINE: f64 = 56.0;
368
369/// The adjacent FAB's **collapsed** square, in logical px —
370/// `M3EToolbarTokens.fabMedium`, which is also the whole box's height whenever
371/// a FAB is attached.
372pub const TOOLBAR_FAB_MEDIUM: f64 = 80.0;
373
374/// Default number of actions kept inline before the overflow split —
375/// `M3EToolbar.maxInlineActions`'s own default.
376pub const TOOLBAR_MAX_INLINE_ACTIONS: usize = 4;
377
378/// The expand/hide spring — upstream's `m3eToolbarExpandMotion()`, i.e.
379/// `M3EMotion.expressiveSpatialFast` (stiffness 800, damping ratio 0.6).
380/// Restated as a [`SpringDesc`] rather than read from a theme for the reason
381/// [`mod@crate::navigation_rail`]'s own springs document: a `fling` needs one, and
382/// paint/event code reads no theme for motion. A test pins it to
383/// [`MaterialSpring::EXPRESSIVE_SPATIAL_FAST`](crate::MaterialSpring::EXPRESSIVE_SPATIAL_FAST).
384pub const TOOLBAR_EXPAND_SPRING: SpringDesc = SpringDesc {
385 mass: 1.0,
386 stiffness: 800.0,
387 damping_ratio: 0.6,
388};
389
390/// Fling velocity above which a scroll settle commits to the direction of
391/// travel instead of the nearer end —
392/// `M3EToolbarVisibilityController.settle`'s own `velocity.abs() > 150`.
393pub const TOOLBAR_SETTLE_VELOCITY: f64 = 150.0;
394
395/// Fraction of the expand ramp the revealing pill stays fully transparent for
396/// — the start of upstream's `Interval(0.5, 1, curve: Curves.easeIn)`. See
397/// [`ToolbarMorph::reveal_alpha`], which is that interval as a function
398/// (`frust::Curve::interval`'s own `SegmentedCurve` return type is not
399/// re-exported through the facade, so it cannot be named in a `const` here).
400pub const TOOLBAR_REVEAL_FADE_START: f64 = 0.5;
401
402/// Widest pill-reveal overshoot the layout honors — upstream's own
403/// `_progress.clamp(0.0, 1.2)` on the revealed width, which lets the expand
404/// spring's overshoot read as travel rather than being flattened.
405const REVEAL_OVERSHOOT: f64 = 1.2;
406
407/// Nominal period seeding a ramp [`AnimationController`]'s clock; both ramps
408/// here are spring-driven via `fling`, so this backs construction only.
409const RAMP_PERIOD: Duration = Duration::from_millis(300);
410
411/// Launch velocity for a ramp leg. Sub-visible on purpose: a spring's shape
412/// comes from its displacement, so only the sign matters, and it is always
413/// positive here because a leg runs `0 → 1` in progress space — the same
414/// sign-only convention [`mod@crate::navigation_rail`]'s own travel legs document.
415const RAMP_LAUNCH_VELOCITY: f64 = 1e-3;
416
417/// Progress difference under which a ramp counts as already heading somewhere
418/// (a retarget guard, so an unchanged prop never relaunches a settled spring).
419const RAMP_EPSILON: f64 = 1e-6;
420
421// ---- Unthemed fallbacks ----------------------------------------------------
422
423/// Unthemed fallback for `colors.surface_container` — matches
424/// [`super::navbar`]'s own `CONTAINER` constant exactly (same M3 role).
425const SURFACE_CONTAINER: Color = Color::from_rgb8(0xF3, 0xED, 0xF7);
426/// Unthemed fallback for `colors.on_surface`.
427const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
428/// Unthemed fallback for `colors.primary_container` (matches [`mod@crate::fab`]'s).
429const PRIMARY_CONTAINER: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
430/// Unthemed fallback for `colors.on_primary_container`.
431const ON_PRIMARY_CONTAINER: Color = Color::from_rgb8(0x21, 0x00, 0x5D);
432/// Unthemed fallback for `colors.tertiary_container`.
433const TERTIARY_CONTAINER: Color = Color::from_rgb8(0xFF, 0xD8, 0xE4);
434/// Unthemed fallback for `colors.on_tertiary_container`.
435const ON_TERTIARY_CONTAINER: Color = Color::from_rgb8(0x31, 0x11, 0x1D);
436
437/// Unthemed-fallback shadow y-offset at M3 elevation level 1, matching
438/// `crate::tokens::elevation().level1`'s own `dp / 2.0 + 1.0` at `dp = 1.0`.
439const LEVEL1_SHADOW_Y_OFFSET: f64 = 1.5;
440/// Unthemed-fallback shadow blur std-dev at M3 elevation level 1.
441const LEVEL1_SHADOW_BLUR: f64 = 1.0;
442/// Unthemed-fallback shadow y-offset at M3 elevation level 2 (`dp = 3.0`).
443const LEVEL2_SHADOW_Y_OFFSET: f64 = 2.5;
444/// Unthemed-fallback shadow blur std-dev at M3 elevation level 2.
445const LEVEL2_SHADOW_BLUR: f64 = 3.0;
446/// Unthemed-fallback shadow color (opaque black at `crate::tokens::elevation()`'s
447/// `0.3` alpha — identical at every level).
448const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
449
450/// The accessible name of the overflow trigger — upstream's own
451/// `tooltip: 'More options'`, overridable per bar with
452/// [`ToolbarView::overflow_label`].
453const OVERFLOW_LABEL: &str = "More options";
454
455// ---- Enums -----------------------------------------------------------------
456
457/// The M3X toolbar container variant — `M3EToolbarPlacement`. See the
458/// [module docs](self).
459#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
460pub enum ToolbarVariant {
461 /// A self-sized, fully-rounded pill floating over content.
462 #[default]
463 Floating,
464 /// A full-width, flat bar docked to an edge (bottom typical).
465 Docked,
466}
467
468/// Standard vs vibrant container color mapping — `M3EToolbarColorStyle`. See
469/// the [module docs](self)' color table.
470#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
471pub enum ToolbarColorStyle {
472 /// `surface_container` container, `on_surface` content — the default.
473 #[default]
474 Standard,
475 /// `primary_container` container, `on_primary_container` content.
476 Vibrant,
477}
478
479/// The bar's resting elevation — upstream's `elevationNone` /
480/// `elevationWithFabExpanded` pair, plus the level this module used to pin
481/// unconditionally.
482#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
483pub enum ToolbarElevation {
484 /// No shadow at all — `M3EToolbarTokens.elevationNone`, the resting
485 /// default of a bar with no FAB.
486 #[default]
487 None,
488 /// M3 elevation level 1 — `M3EToolbarTokens.elevationWithFabExpanded`, the
489 /// resting default once a FAB is attached.
490 Level1,
491 /// M3 elevation level 2 — no upstream token; the level this module pinned
492 /// under every floating pill before the reference's own pair was ported.
493 Level2,
494}
495
496/// Which edge of the box an adjacent FAB occupies — `M3EToolbarFabPosition`'s
497/// horizontal pair (its `top`/`bottom` belong to the unported vertical axis).
498#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
499pub enum ToolbarFabPosition {
500 /// The FAB leads the pill.
501 Start,
502 /// The FAB trails the pill — the default.
503 #[default]
504 End,
505}
506
507/// Direction a bar slides while scroll-hidden — `M3EToolbarExitDirection`.
508#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
509pub enum ToolbarExitDirection {
510 /// Exits upwards.
511 Top,
512 /// Exits downwards — the default (a bottom-docked bar).
513 #[default]
514 Bottom,
515 /// Exits toward the leading edge.
516 Start,
517 /// Exits toward the trailing edge.
518 End,
519}
520
521impl ToolbarExitDirection {
522 /// Whether this direction travels on the vertical axis, which is what
523 /// picks the measured extent's own axis (`M3EToolbarMeasureSize`'s
524 /// `vertical ? size.height : size.width`).
525 pub fn is_vertical(self) -> bool {
526 matches!(self, Self::Top | Self::Bottom)
527 }
528
529 /// The translation a `distance` of travel produces — upstream's
530 /// `_exitOffset`, restated for a non-negative distance (its own `offset`
531 /// is `≤ 0`, so every arm there carries a sign flip this one does not).
532 pub fn offset(self, distance: f64) -> Vec2 {
533 match self {
534 Self::Top => Vec2::new(0.0, -distance),
535 Self::Bottom => Vec2::new(0.0, distance),
536 Self::Start => Vec2::new(-distance, 0.0),
537 Self::End => Vec2::new(distance, 0.0),
538 }
539 }
540}
541
542/// The icon-button density a bar renders its typed actions at — the legacy
543/// `M3EToolbarSize`, which maps to icon-button density and never to container
544/// height (`M3EToolbarTheme.iconButtonSize`).
545#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
546pub enum ToolbarSize {
547 /// `IconButtonSize::Xs`.
548 Small,
549 /// `IconButtonSize::Sm` — the default.
550 #[default]
551 Medium,
552 /// `IconButtonSize::Sm`, same as [`Self::Medium`] upstream.
553 Large,
554}
555
556impl ToolbarSize {
557 /// The icon-button tier this density resolves — `iconButtonSize`'s own
558 /// `small → xs`, `medium | large → sm`.
559 pub fn icon_button_size(self) -> IconButtonSize {
560 match self {
561 Self::Small => IconButtonSize::Xs,
562 Self::Medium | Self::Large => IconButtonSize::Sm,
563 }
564 }
565}
566
567// ---- Colors ----------------------------------------------------------------
568
569/// The four resolved roles of one [`ToolbarColorStyle`] —
570/// `M3EToolbarColors`, as produced by `M3EToolbarTheme.colors`.
571///
572/// ```
573/// use frust_material::{ToolbarColorStyle, ToolbarColors, baseline};
574///
575/// let theme = baseline();
576/// let vibrant = ToolbarColors::resolve(Some(&theme), ToolbarColorStyle::Vibrant);
577/// assert_eq!(vibrant.container, theme.scheme().primary_container);
578/// assert_eq!(vibrant.fab_container, theme.scheme().tertiary_container);
579/// ```
580#[derive(Clone, Copy, Debug, PartialEq)]
581pub struct ToolbarColors {
582 /// The bar container's fill.
583 pub container: Color,
584 /// The ink every content slot the bar owns is tinted with.
585 pub content: Color,
586 /// The container an adjacent FAB should take — advisory here (the fab slot
587 /// is an opaque caller view; see the [module docs](self)).
588 pub fab_container: Color,
589 /// The ink an adjacent FAB should take — advisory, as above.
590 pub fab_content: Color,
591}
592
593impl ToolbarColors {
594 /// Resolve `style` against `theme`, falling back to the M3 baseline
595 /// literals when no theme is threaded. See the [module docs](self)' table.
596 pub fn resolve(theme: Option<&Theme>, style: ToolbarColorStyle) -> Self {
597 match (theme, style) {
598 (Some(theme), ToolbarColorStyle::Standard) => {
599 let s = theme.scheme();
600 Self {
601 container: s.surface_container,
602 content: s.on_surface,
603 fab_container: s.primary_container,
604 fab_content: s.on_primary_container,
605 }
606 }
607 (Some(theme), ToolbarColorStyle::Vibrant) => {
608 let s = theme.scheme();
609 Self {
610 container: s.primary_container,
611 content: s.on_primary_container,
612 fab_container: s.tertiary_container,
613 fab_content: s.on_tertiary_container,
614 }
615 }
616 (None, ToolbarColorStyle::Standard) => Self {
617 container: SURFACE_CONTAINER,
618 content: ON_SURFACE,
619 fab_container: PRIMARY_CONTAINER,
620 fab_content: ON_PRIMARY_CONTAINER,
621 },
622 (None, ToolbarColorStyle::Vibrant) => Self {
623 container: PRIMARY_CONTAINER,
624 content: ON_PRIMARY_CONTAINER,
625 fab_container: TERTIARY_CONTAINER,
626 fab_content: ON_TERTIARY_CONTAINER,
627 },
628 }
629 }
630}
631
632/// `value` clamped into `[0, 1]`, mapping NaN to the resting `0.0` rather than
633/// propagating it into a layout (the same guard [`super::appbar`] applies).
634fn clamp01(value: f64) -> f64 {
635 if value.is_nan() {
636 0.0
637 } else {
638 value.clamp(0.0, 1.0)
639 }
640}
641
642/// Linear interpolation from `a` to `b` at `t` (deliberately unclamped — the
643/// FAB square reads the expand spring's overshoot as real travel, exactly as
644/// upstream's own `_resolvedFabSize` does).
645fn lerp(a: f64, b: f64, t: f64) -> f64 {
646 a + (b - a) * t
647}
648
649/// `constraint` if finite, `0.0` otherwise — the degenerate unbounded-width
650/// case every full-width bar in this catalog resolves the same way.
651fn finite_or_zero(constraint: f64) -> f64 {
652 if constraint.is_finite() {
653 constraint
654 } else {
655 0.0
656 }
657}
658
659/// Return `color` with its alpha channel replaced by `alpha`.
660fn with_alpha(color: Color, alpha: f32) -> Color {
661 let c = color.components;
662 Color::new([c[0], c[1], c[2], alpha])
663}
664
665/// The resolved corner radius for `variant`, against a bar of `width`×`height`.
666/// Themed: `shape.full` (floating, resolved to a true pill) / `shape.none`
667/// (docked). Unthemed: the equivalent literal tokens
668/// (`crate::tokens::shape_scale`'s own values), resolved the same way.
669fn resolve_radius(theme: Option<&Theme>, variant: ToolbarVariant, width: f64, height: f64) -> f64 {
670 let token = match (theme, variant) {
671 (Some(theme), ToolbarVariant::Floating) => theme.shape.full,
672 (Some(theme), ToolbarVariant::Docked) => theme.shape.none,
673 (None, ToolbarVariant::Floating) => f64::INFINITY,
674 (None, ToolbarVariant::Docked) => 0.0,
675 };
676 ShapeScale::resolve(token, width, height)
677}
678
679/// The resolved `(blur_std_dev, y_offset, color)` shadow parameters for
680/// `level`, or `None` at [`ToolbarElevation::None`] (upstream's
681/// `elevationNone`, which paints nothing at all). Themed: the matching
682/// `theme.elevation` `ShadowSpec`, colored by `colors.shadow` at its
683/// `color_alpha`. Unthemed: the matching `LEVEL{1,2}_SHADOW_*` constants.
684fn resolve_shadow(theme: Option<&Theme>, level: ToolbarElevation) -> Option<(f64, f64, Color)> {
685 match (theme, level) {
686 (_, ToolbarElevation::None) => None,
687 (Some(theme), level) => {
688 let spec = match level {
689 ToolbarElevation::Level1 => theme.elevation.level1,
690 _ => theme.elevation.level2,
691 };
692 let shadow = spec.shadow(theme.brightness);
693 let color = with_alpha(theme.scheme().shadow, shadow.color_alpha);
694 Some((shadow.blur_std_dev, shadow.y_offset, color))
695 }
696 (None, ToolbarElevation::Level1) => Some((
697 LEVEL1_SHADOW_BLUR,
698 LEVEL1_SHADOW_Y_OFFSET,
699 FALLBACK_SHADOW_COLOR,
700 )),
701 (None, ToolbarElevation::Level2) => Some((
702 LEVEL2_SHADOW_BLUR,
703 LEVEL2_SHADOW_Y_OFFSET,
704 FALLBACK_SHADOW_COLOR,
705 )),
706 }
707}
708
709// ---- The expand-ramp geometry (pure) ---------------------------------------
710
711/// The expand ramp's pure geometry: what the bar's expand progress does to the
712/// adjacent FAB's square and to the revealing pill's opacity — an uninhabited
713/// namespace type (no instance is ever constructed), the same shape
714/// [`crate::MaterialSpring`]'s own token namespace uses.
715///
716/// ```
717/// use frust_material::ToolbarMorph;
718///
719/// // Collapsed is the 80dp end; expanded the 56dp one.
720/// assert_eq!(ToolbarMorph::size(0.0), 80.0);
721/// assert_eq!(ToolbarMorph::size(1.0), 56.0);
722/// assert_eq!(ToolbarMorph::size(0.5), 68.0);
723///
724/// // The spring's overshoot past 1 is real travel, never clamped away.
725/// assert!(ToolbarMorph::size(1.1) < 56.0);
726/// // …but never negative, whatever the input.
727/// assert_eq!(ToolbarMorph::size(f64::NAN), 80.0);
728///
729/// // The pill stays invisible through the ramp's first half.
730/// assert_eq!(ToolbarMorph::reveal_alpha(0.5), 0.0);
731/// assert_eq!(ToolbarMorph::reveal_alpha(1.0), 1.0);
732/// ```
733#[derive(Debug)]
734pub enum ToolbarMorph {}
735
736impl ToolbarMorph {
737 /// The FAB's square at expand `progress` — `_resolvedFabSize`'s own
738 /// `fabMedium + (fabBaseline - fabMedium) * progress`, floored at zero and
739 /// NaN-mapped to the collapsed (resting) end.
740 pub fn size(progress: f64) -> f64 {
741 if progress.is_nan() {
742 return TOOLBAR_FAB_MEDIUM;
743 }
744 lerp(TOOLBAR_FAB_MEDIUM, TOOLBAR_FAB_BASELINE, progress).max(0.0)
745 }
746
747 /// The revealing pill's opacity at expand `progress` — upstream's
748 /// `Interval(0.5, 1, curve: Curves.easeIn)`, whose control points
749 /// ([`Curve::EaseIn`], `cubic-bezier(0.42, 0, 1, 1)`) are Flutter's
750 /// `Curves.easeIn` exactly. See [`TOOLBAR_REVEAL_FADE_START`].
751 pub fn reveal_alpha(progress: f64) -> f64 {
752 Curve::EaseIn
753 .interval(TOOLBAR_REVEAL_FADE_START, 1.0)
754 .transform(clamp01(progress))
755 }
756}
757
758// ---- Scroll-hide (pure) ----------------------------------------------------
759
760/// Upstream's `M3EToolbarVisibilityController` as a pure value: the scroll
761/// accumulation, clamp, and settle rule an app runs from its own
762/// [`on_scroll`](frust::ScrollView::on_scroll), with the ticker left to the
763/// widget (see the [module docs](self)' scroll-hide section).
764///
765/// `offset` is always `≤ 0` and clamped into `[offset_limit, 0]`.
766///
767/// ```
768/// use frust_material::{TOOLBAR_HEIGHT, ToolbarScrollHide, TOOLBAR_SCREEN_OFFSET};
769///
770/// // The measured limit is `-(extent + screenOffset)`.
771/// let mut hide = ToolbarScrollHide::for_bar(TOOLBAR_HEIGHT);
772/// assert_eq!(hide.offset_limit(), -(TOOLBAR_HEIGHT + TOOLBAR_SCREEN_OFFSET));
773///
774/// // Scrolling down hides; scrolling back up returns.
775/// hide.scroll(40.0);
776/// assert_eq!(hide.offset(), -40.0);
777/// hide.scroll(-15.0);
778/// assert_eq!(hide.offset(), -25.0);
779///
780/// // A fling past the velocity threshold commits to its direction.
781/// assert!(!hide.settle(400.0));
782/// assert!(hide.is_hidden());
783/// ```
784#[derive(Clone, Copy, Debug, PartialEq)]
785pub struct ToolbarScrollHide {
786 limit: f64,
787 offset: f64,
788}
789
790impl ToolbarScrollHide {
791 /// A controller with a fixed exit distance — upstream's `exitExtent`,
792 /// which pins `offsetLimit` to `-|extent|`. A non-finite `exit_extent`
793 /// (NaN or ±∞) degrades to a resting `0.0` exit distance rather than
794 /// carrying a non-finite `limit` into [`Self::set_offset`]'s clamp — see
795 /// the [module docs](self)' clamp discipline.
796 pub fn new(exit_extent: f64) -> Self {
797 Self {
798 limit: -finite_or_zero(exit_extent).abs(),
799 offset: 0.0,
800 }
801 }
802
803 /// A controller whose exit distance is measured off the bar — upstream's
804 /// `M3EToolbarMeasureSize` rule, `-(cross_extent + screenOffset)`. A
805 /// non-finite `cross_extent` degrades the same way [`Self::new`]'s own
806 /// guard does — this delegates to it, so the guard applies whether the
807 /// non-finite value reaches `new` directly or through here.
808 pub fn for_bar(cross_extent: f64) -> Self {
809 Self::new(cross_extent.abs() + TOOLBAR_SCREEN_OFFSET)
810 }
811
812 /// The current translation, in `[offset_limit, 0]`.
813 pub fn offset(&self) -> f64 {
814 self.offset
815 }
816
817 /// The most negative translation this controller allows.
818 pub fn offset_limit(&self) -> f64 {
819 self.limit
820 }
821
822 /// Consume a scroll `delta` (positive scrolling down) — upstream's
823 /// `_updateOffset`'s `offset -= delta`, clamped. A non-finite delta is
824 /// ignored rather than propagated into the offset.
825 pub fn scroll(&mut self, delta: f64) {
826 if !delta.is_finite() {
827 return;
828 }
829 self.set_offset(self.offset - delta);
830 }
831
832 /// How far hidden the bar is, `0.0` fully visible through `1.0` fully
833 /// hidden — upstream's `collapsedFraction`.
834 pub fn collapsed_fraction(&self) -> f64 {
835 if self.limit == 0.0 {
836 return 0.0;
837 }
838 clamp01(self.offset / self.limit)
839 }
840
841 /// Whether the bar is fully off-screen — upstream's `isHidden`.
842 pub fn is_hidden(&self) -> bool {
843 self.collapsed_fraction() >= 1.0
844 }
845
846 /// Snap fully visible — upstream's `show()` target.
847 pub fn show(&mut self) {
848 self.offset = 0.0;
849 }
850
851 /// Snap fully hidden — upstream's `hide()` target.
852 pub fn hide(&mut self) {
853 self.offset = self.limit;
854 }
855
856 /// Flip about the halfway point — upstream's `toggle()`.
857 pub fn toggle(&mut self) {
858 if self.collapsed_fraction() < 0.5 {
859 self.hide();
860 } else {
861 self.show();
862 }
863 }
864
865 /// Settle after a fling at `velocity` (positive scrolling down), reporting
866 /// whether the bar ends up **visible** — upstream's `settle`: past
867 /// [`TOOLBAR_SETTLE_VELOCITY`] the direction of travel wins, otherwise the
868 /// nearer end does. Already-settled at either end, nothing moves.
869 pub fn settle(&mut self, velocity: f64) -> bool {
870 if self.offset == 0.0 || self.offset == self.limit {
871 return self.offset == 0.0;
872 }
873 let hide = if velocity.abs() > TOOLBAR_SETTLE_VELOCITY {
874 velocity > 0.0
875 } else {
876 self.collapsed_fraction() >= 0.5
877 };
878 if hide {
879 self.hide();
880 } else {
881 self.show();
882 }
883 !hide
884 }
885
886 /// Clamp and store `value` — upstream's `offset` setter, whose clamp
887 /// bounds swap when the limit is (degenerately) positive.
888 fn set_offset(&mut self, value: f64) {
889 self.offset = if self.limit <= 0.0 {
890 value.clamp(self.limit, 0.0)
891 } else {
892 value.clamp(0.0, self.limit)
893 };
894 }
895}
896
897// ---- The overflow capacity rule (pure) -------------------------------------
898
899/// The inline/overflow split of one action list —
900/// `M3EToolbarItemLayout.partitionInline`'s own result shape, as flat index
901/// lists.
902#[derive(Clone, Debug, Default, PartialEq, Eq)]
903pub struct ToolbarPartition {
904 /// Indices kept in the bar, in order.
905 pub inline: Vec<usize>,
906 /// Indices moved to the overflow menu, in order.
907 pub overflow: Vec<usize>,
908}
909
910/// Split `count` actions into the inline and overflow halves —
911/// `M3EToolbarItemLayout.partitionInline`.
912///
913/// An expand-trigger action (`trigger`) always stays inline **and** reserves
914/// one of `max_inline`'s slots (upstream's `reservedTrigger`), so the budget
915/// available to ordinary actions is `max_inline - 1` when one is present.
916///
917/// ```
918/// use frust_material::{TOOLBAR_MAX_INLINE_ACTIONS, partition_actions};
919///
920/// // Six actions at the default budget: four inline, two overflowed.
921/// let split = partition_actions(6, TOOLBAR_MAX_INLINE_ACTIONS, None);
922/// assert_eq!(split.inline, vec![0, 1, 2, 3]);
923/// assert_eq!(split.overflow, vec![4, 5]);
924///
925/// // A trigger stays inline and costs one budget slot.
926/// let split = partition_actions(6, TOOLBAR_MAX_INLINE_ACTIONS, Some(5));
927/// assert_eq!(split.inline, vec![0, 1, 2, 5]);
928/// assert_eq!(split.overflow, vec![3, 4]);
929/// ```
930pub fn partition_actions(
931 count: usize,
932 max_inline: usize,
933 trigger: Option<usize>,
934) -> ToolbarPartition {
935 let trigger = trigger.filter(|index| *index < count);
936 let budget = max_inline.saturating_sub(usize::from(trigger.is_some()));
937 let mut partition = ToolbarPartition::default();
938 let mut used = 0usize;
939 for index in 0..count {
940 if Some(index) == trigger {
941 partition.inline.push(index);
942 } else if used < budget {
943 partition.inline.push(index);
944 used += 1;
945 } else {
946 partition.overflow.push(index);
947 }
948 }
949 partition
950}
951
952// ---- Typed actions ---------------------------------------------------------
953
954/// A view-held, typed press callback (erased per-action on build).
955type OnPress<State> = Rc<dyn Fn(&mut State)>;
956
957/// One typed action in [`ToolbarView::actions`] — `M3EToolbarAction`, the
958/// upstream item kind that can move to the overflow menu (its
959/// `M3EToolbarWidget` sibling, which always stays inline, is this port's
960/// opaque `center` slot).
961pub struct ToolbarAction<State: 'static> {
962 icon: IconSource,
963 label: Option<String>,
964 tooltip: Option<String>,
965 semantic_label: Option<String>,
966 enabled: bool,
967 destructive: bool,
968 active: bool,
969 expand_trigger: bool,
970 on_press: OnPress<State>,
971}
972
973/// Create a toolbar action painting `icon`, running `on_press` on release
974/// inside its bounds.
975pub fn toolbar_action<State: 'static, F: Fn(&mut State) + 'static>(
976 icon: IconSource,
977 on_press: F,
978) -> ToolbarAction<State> {
979 ToolbarAction {
980 icon,
981 label: None,
982 tooltip: None,
983 semantic_label: None,
984 enabled: true,
985 destructive: false,
986 active: false,
987 expand_trigger: false,
988 on_press: Rc::new(on_press),
989 }
990}
991
992impl<State: 'static> ToolbarAction<State> {
993 /// The action's label — upstream's `label`. Used as the overflow row's
994 /// title; the in-button label morph it also drives upstream is unported
995 /// (see the [module docs](self)' *Not ported*).
996 pub fn label(mut self, label: impl Into<String>) -> Self {
997 self.label = Some(label.into());
998 self
999 }
1000
1001 /// The action's tooltip text — upstream's `tooltip`. This catalog's icon
1002 /// button carries no tooltip slot, so this contributes an accessible name
1003 /// (behind [`Self::semantic_label`]) and an overflow-row title (behind
1004 /// [`Self::label`]) only.
1005 pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
1006 self.tooltip = Some(tooltip.into());
1007 self
1008 }
1009
1010 /// The action's accessible name — upstream's `semanticLabel`.
1011 pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
1012 self.semantic_label = Some(label.into());
1013 self
1014 }
1015
1016 /// Whether the action accepts presses (`enabled`, default `true`).
1017 pub fn enabled(mut self, enabled: bool) -> Self {
1018 self.enabled = enabled;
1019 self
1020 }
1021
1022 /// Whether the overflow row reads destructive (`isDestructive`).
1023 pub fn destructive(mut self, destructive: bool) -> Self {
1024 self.destructive = destructive;
1025 self
1026 }
1027
1028 /// Whether the inline button reads selected (`active`) — filled icon-button
1029 /// styling, per `M3EToolbarIconButton._resolvedVariant`.
1030 pub fn active(mut self, active: bool) -> Self {
1031 self.active = active;
1032 self
1033 }
1034
1035 /// Mark this action as the bar's expand/collapse trigger
1036 /// (`isExpandTrigger`): it always stays inline, reserves one inline slot,
1037 /// and takes the same filled styling an active action does. At most one
1038 /// action should set it — the first one wins.
1039 ///
1040 /// The toggle itself stays app-owned (see the [module docs](self)'
1041 /// controlled-prop note): a trigger's own `on_press` is where an app flips
1042 /// [`ToolbarView::expanded`].
1043 pub fn expand_trigger(mut self, trigger: bool) -> Self {
1044 self.expand_trigger = trigger;
1045 self
1046 }
1047
1048 /// The title this action's overflow row takes — upstream's
1049 /// `label ?? tooltip ?? semanticLabel ?? 'Action {n}'` fallback chain.
1050 pub fn menu_label(&self, index: usize) -> String {
1051 self.label
1052 .as_deref()
1053 .or(self.tooltip.as_deref())
1054 .or(self.semantic_label.as_deref())
1055 .map(str::to_owned)
1056 .unwrap_or_else(|| format!("Action {}", index + 1))
1057 }
1058
1059 /// The inline icon-button view for this action.
1060 fn inline_view(&self, size: ToolbarSize) -> AnyView<State> {
1061 let on_press = self.on_press.clone();
1062 let mut button = icon_button::<State, _>(
1063 frust::authoring::any::<State, _>(icon(self.icon)),
1064 move |state: &mut State| on_press(state),
1065 )
1066 .size(size.icon_button_size())
1067 .enabled(self.enabled)
1068 .variant(if self.active || self.expand_trigger {
1069 IconButtonVariant::Filled
1070 } else {
1071 IconButtonVariant::Standard
1072 });
1073 if let Some(label) = self.semantic_label.as_deref().or(self.tooltip.as_deref()) {
1074 button = button.semantic_label(label);
1075 }
1076 frust::authoring::any::<State, _>(button)
1077 }
1078}
1079
1080// ---- Motion driver ---------------------------------------------------------
1081
1082/// One spring-driven `0 → 1` ramp (the expand progress, the hide fraction).
1083struct RampTrack {
1084 /// The progress `layout` reads this frame — **unclamped**, so the spring's
1085 /// overshoot is real travel; every consumer clamps at its own use site
1086 /// exactly as upstream does.
1087 value: f64,
1088 /// The progress the current leg is heading for.
1089 target: f64,
1090 /// Maps the controller's `0 → 1` leg onto `from → target`.
1091 tween: Tween<f64>,
1092 driver: AnimationController,
1093}
1094
1095impl RampTrack {
1096 /// A ramp resting at `value` with no leg in flight — a bar built already
1097 /// expanded shows its full pill on the first frame rather than animating
1098 /// into it.
1099 fn resting(value: f64) -> Self {
1100 Self {
1101 value,
1102 target: value,
1103 tween: Tween::new(value, value),
1104 driver: AnimationController::new(RAMP_PERIOD),
1105 }
1106 }
1107
1108 /// Launch a leg toward `target` on [`TOOLBAR_EXPAND_SPRING`], starting
1109 /// from wherever the ramp currently reads (so an interrupted leg reverses
1110 /// smoothly). A no-op if already heading there.
1111 fn set_target(&mut self, target: f64) {
1112 if (self.target - target).abs() < RAMP_EPSILON {
1113 return;
1114 }
1115 self.target = target;
1116 self.tween = Tween::new(self.value, target);
1117 self.driver = AnimationController::new(RAMP_PERIOD);
1118 self.driver
1119 .fling(RAMP_LAUNCH_VELOCITY, TOOLBAR_EXPAND_SPRING);
1120 }
1121
1122 /// Jump to `to`, cancelling any leg (a prop-pinned fraction, or a fresh
1123 /// build).
1124 fn jump(&mut self, to: f64) {
1125 self.value = to;
1126 self.target = to;
1127 self.tween = Tween::new(to, to);
1128 self.driver.stop();
1129 }
1130
1131 /// Jump to the target, cancelling any leg (the reduce-motion path).
1132 /// Returns whether that actually moved the value.
1133 fn snap(&mut self) -> bool {
1134 let moved = self.value != self.target;
1135 self.jump(self.target);
1136 moved
1137 }
1138
1139 /// Advance one frame, returning whether the leg is still in flight.
1140 fn advance(&mut self, now: FrameTime) -> bool {
1141 let animating = self.driver.advance(now);
1142 self.value = self.tween.lerp(self.driver.value());
1143 animating
1144 }
1145}
1146
1147// ---- The view --------------------------------------------------------------
1148
1149/// A view-held, typed overflow-trigger callback.
1150type OnOverflow<State> = Rc<dyn Fn(&mut State)>;
1151
1152/// A declarative M3X toolbar. See the [module docs](self).
1153pub struct ToolbarView<State: 'static> {
1154 variant: ToolbarVariant,
1155 color_style: ToolbarColorStyle,
1156 elevation: Option<ToolbarElevation>,
1157 size: ToolbarSize,
1158 leading: Vec<AnyView<State>>,
1159 center: Vec<AnyView<State>>,
1160 trailing: Vec<AnyView<State>>,
1161 fab: Option<AnyView<State>>,
1162 actions: Vec<ToolbarAction<State>>,
1163 max_inline_actions: usize,
1164 overflow_icon: IconSource,
1165 overflow_label: String,
1166 on_overflow: Option<OnOverflow<State>>,
1167 overflow_anchor: Option<OverlayAnchor>,
1168 expanded: bool,
1169 fab_expands_toolbar: bool,
1170 fab_position: ToolbarFabPosition,
1171 visible: bool,
1172 hidden_fraction: Option<f64>,
1173 exit_direction: ToolbarExitDirection,
1174 exit_extent: Option<f64>,
1175 semantic_label: Option<String>,
1176}
1177
1178/// Build a [`ToolbarView`] of `variant` with every prop at its upstream
1179/// default.
1180fn toolbar<State: 'static>(variant: ToolbarVariant) -> ToolbarView<State> {
1181 ToolbarView {
1182 variant,
1183 color_style: ToolbarColorStyle::default(),
1184 elevation: None,
1185 size: ToolbarSize::default(),
1186 leading: Vec::new(),
1187 center: Vec::new(),
1188 trailing: Vec::new(),
1189 fab: None,
1190 actions: Vec::new(),
1191 max_inline_actions: TOOLBAR_MAX_INLINE_ACTIONS,
1192 overflow_icon: crate::icons::MORE_VERT,
1193 overflow_label: OVERFLOW_LABEL.to_owned(),
1194 on_overflow: None,
1195 overflow_anchor: None,
1196 expanded: true,
1197 fab_expands_toolbar: true,
1198 fab_position: ToolbarFabPosition::default(),
1199 visible: true,
1200 hidden_fraction: None,
1201 exit_direction: ToolbarExitDirection::default(),
1202 exit_extent: None,
1203 semantic_label: None,
1204 }
1205}
1206
1207/// Create a floating toolbar (self-sized pill, elevated above content) with
1208/// no slots attached — attach them with [`ToolbarView::leading`]/
1209/// [`ToolbarView::center`]/[`ToolbarView::trailing`]/[`ToolbarView::actions`]/
1210/// [`ToolbarView::fab`].
1211pub fn floating_toolbar<State: 'static>() -> ToolbarView<State> {
1212 toolbar(ToolbarVariant::Floating)
1213}
1214
1215/// PascalCase alias for [`floating_toolbar`], matching the container view-fn
1216/// vocabulary.
1217#[allow(non_snake_case)]
1218pub fn FloatingToolbar<State: 'static>() -> ToolbarView<State> {
1219 floating_toolbar()
1220}
1221
1222/// Create a docked toolbar (full-width, flat bar) with no slots attached.
1223pub fn docked_toolbar<State: 'static>() -> ToolbarView<State> {
1224 toolbar(ToolbarVariant::Docked)
1225}
1226
1227/// PascalCase alias for [`docked_toolbar`], matching the container view-fn
1228/// vocabulary.
1229#[allow(non_snake_case)]
1230pub fn DockedToolbar<State: 'static>() -> ToolbarView<State> {
1231 docked_toolbar()
1232}
1233
1234impl<State: 'static> ToolbarView<State> {
1235 /// Attach leading slot children, in reading order (hugs the leading edge).
1236 /// Tint/styling is each supplied view's own responsibility.
1237 pub fn leading<M>(mut self, leading: impl ViewSeq<State, M>) -> Self {
1238 self.leading.clear();
1239 leading.extend_views(&mut self.leading);
1240 self
1241 }
1242
1243 /// Attach center slot children, in reading order — upstream's inline
1244 /// `M3EToolbarWidget` items, which never overflow. They lead the typed
1245 /// [`Self::actions`] within the same center group.
1246 pub fn center<M>(mut self, center: impl ViewSeq<State, M>) -> Self {
1247 self.center.clear();
1248 center.extend_views(&mut self.center);
1249 self
1250 }
1251
1252 /// Attach trailing slot children, in reading order (hugs the trailing
1253 /// edge, or the fab slot if also attached).
1254 pub fn trailing<M>(mut self, trailing: impl ViewSeq<State, M>) -> Self {
1255 self.trailing.clear();
1256 trailing.extend_views(&mut self.trailing);
1257 self
1258 }
1259
1260 /// Attach the optional adjacent FAB — upstream's `floatingActionButton`.
1261 /// On a **floating** bar this takes the adjacent morph layout described in
1262 /// the [module docs](self); on a **docked** one it simply trails the row,
1263 /// as it always has (upstream has no docked FAB at all).
1264 pub fn fab(mut self, fab: impl View<State>) -> Self {
1265 self.fab = Some(AnyView::new(fab));
1266 self
1267 }
1268
1269 /// Attach the typed action list — upstream's `actions`, the only slot
1270 /// subject to the overflow split.
1271 pub fn actions(mut self, actions: Vec<ToolbarAction<State>>) -> Self {
1272 self.actions = actions;
1273 self
1274 }
1275
1276 /// How many actions stay inline before overflowing (`maxInlineActions`,
1277 /// default [`TOOLBAR_MAX_INLINE_ACTIONS`]).
1278 pub fn max_inline_actions(mut self, max: usize) -> Self {
1279 self.max_inline_actions = max;
1280 self
1281 }
1282
1283 /// Replace the overflow trigger's glyph (`overflowIcon`, default
1284 /// `more_vert`).
1285 pub fn overflow_icon(mut self, icon: IconSource) -> Self {
1286 self.overflow_icon = icon;
1287 self
1288 }
1289
1290 /// Replace the overflow trigger's accessible name (upstream's own
1291 /// `'More options'`).
1292 pub fn overflow_label(mut self, label: impl Into<String>) -> Self {
1293 self.overflow_label = label.into();
1294 self
1295 }
1296
1297 /// Fired when the overflow trigger is pressed — where an app flips the
1298 /// open flag it hands [`Self::overflow_menu`].
1299 pub fn on_overflow<F: Fn(&mut State) + 'static>(mut self, on_overflow: F) -> Self {
1300 self.on_overflow = Some(Rc::new(on_overflow));
1301 self
1302 }
1303
1304 /// Capture the overflow trigger's window rect into `anchor` on every
1305 /// paint, so [`Self::overflow_menu`] has a rect to place against — the
1306 /// same [`OverlayAnchor`] handoff [`mod@crate::button_group`]'s own popup
1307 /// overflow takes.
1308 pub fn overflow_anchor(mut self, anchor: &OverlayAnchor) -> Self {
1309 self.overflow_anchor = Some(anchor.clone());
1310 self
1311 }
1312
1313 /// Pick the standard or vibrant color resolution (`colorStyle`).
1314 pub fn color_style(mut self, style: ToolbarColorStyle) -> Self {
1315 self.color_style = style;
1316 self
1317 }
1318
1319 /// Override the resting elevation (upstream's `elevation`). Unset, a bar
1320 /// rests at [`ToolbarElevation::None`], or [`ToolbarElevation::Level1`]
1321 /// once a FAB is attached.
1322 pub fn elevation(mut self, elevation: ToolbarElevation) -> Self {
1323 self.elevation = Some(elevation);
1324 self
1325 }
1326
1327 /// The icon-button density typed actions render at (`size`).
1328 pub fn size(mut self, size: ToolbarSize) -> Self {
1329 self.size = size;
1330 self
1331 }
1332
1333 /// Whether the pill is expanded (`expanded`, default `true`) — the
1334 /// controlled prop driving the FAB morph and pill reveal. See the
1335 /// [module docs](self) for who owns the toggle.
1336 pub fn expanded(mut self, expanded: bool) -> Self {
1337 self.expanded = expanded;
1338 self
1339 }
1340
1341 /// Whether an adjacent FAB morphs with the pill (`fabExpandsToolbar`,
1342 /// default `true`). Set `false` to pin the pill open and the FAB at
1343 /// [`TOOLBAR_FAB_BASELINE`].
1344 pub fn fab_expands_toolbar(mut self, expands: bool) -> Self {
1345 self.fab_expands_toolbar = expands;
1346 self
1347 }
1348
1349 /// Which edge an adjacent FAB occupies (`fabPosition`, default
1350 /// [`ToolbarFabPosition::End`]).
1351 pub fn fab_position(mut self, position: ToolbarFabPosition) -> Self {
1352 self.fab_position = position;
1353 self
1354 }
1355
1356 /// Whether the bar is shown (`show()`/`hide()`, default `true`) — sprung
1357 /// on [`TOOLBAR_EXPAND_SPRING`]. Ignored while [`Self::hidden_fraction`]
1358 /// pins a value.
1359 pub fn visible(mut self, visible: bool) -> Self {
1360 self.visible = visible;
1361 self
1362 }
1363
1364 /// Pin the hidden fraction directly, `0.0` visible through `1.0` hidden —
1365 /// upstream's plain `offset` setter during a scroll drag. `Some(f)`
1366 /// overrides [`Self::visible`] and applies with no spring; `None` (the
1367 /// default) hands the ramp back to `visible`, resuming from wherever the
1368 /// pin left it.
1369 pub fn hidden_fraction(mut self, fraction: Option<f64>) -> Self {
1370 self.hidden_fraction = fraction;
1371 self
1372 }
1373
1374 /// The direction the bar slides while hidden (`exitDirection`, default
1375 /// [`ToolbarExitDirection::Bottom`]).
1376 pub fn exit_direction(mut self, direction: ToolbarExitDirection) -> Self {
1377 self.exit_direction = direction;
1378 self
1379 }
1380
1381 /// Override the hide travel distance (`exitExtent`). Unset, the bar
1382 /// measures its own cross extent and adds [`TOOLBAR_SCREEN_OFFSET`].
1383 pub fn exit_extent(mut self, extent: Option<f64>) -> Self {
1384 self.exit_extent = extent;
1385 self
1386 }
1387
1388 /// The accessible name of the bar as a whole (`semanticLabel`).
1389 pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
1390 self.semantic_label = Some(label.into());
1391 self
1392 }
1393
1394 /// The index of the expand-trigger action, if any (the first one wins —
1395 /// upstream asserts at most one).
1396 fn trigger_index(&self) -> Option<usize> {
1397 self.actions.iter().position(|a| a.expand_trigger)
1398 }
1399
1400 /// This bar's inline/overflow split (see [`partition_actions`]).
1401 fn partition(&self) -> ToolbarPartition {
1402 partition_actions(
1403 self.actions.len(),
1404 self.max_inline_actions,
1405 self.trigger_index(),
1406 )
1407 }
1408
1409 /// The overflowed rows, as `(node, target)` pairs: the menu row to render
1410 /// and the action callback activating it runs.
1411 fn overflow_entries(&self) -> Vec<(MenuNode, OnPress<State>)> {
1412 self.partition()
1413 .overflow
1414 .into_iter()
1415 .map(|index| {
1416 let action = &self.actions[index];
1417 let node: MenuNode = menu_entry(action.menu_label(index))
1418 .leading(action.icon)
1419 .enabled(action.enabled)
1420 .destructive(action.destructive)
1421 .into();
1422 (node, action.on_press.clone())
1423 })
1424 .collect()
1425 }
1426
1427 /// The composed views for the typed actions plus, when anything
1428 /// overflowed, the trailing overflow trigger — the center group's own
1429 /// tail.
1430 fn action_views(&self) -> Vec<AnyView<State>> {
1431 let split = self.partition();
1432 let mut views: Vec<AnyView<State>> = split
1433 .inline
1434 .iter()
1435 .map(|index| self.actions[*index].inline_view(self.size))
1436 .collect();
1437 if !split.overflow.is_empty() {
1438 views.push(self.trigger_view());
1439 }
1440 views
1441 }
1442
1443 /// The overflow trigger's own view — an icon button reporting
1444 /// [`Self::on_overflow`], wrapped in [`overlay_anchor`] when an anchor is
1445 /// wired so its window rect reaches [`Self::overflow_menu`].
1446 fn trigger_view(&self) -> AnyView<State> {
1447 let on_overflow = self.on_overflow.clone();
1448 let trigger = icon_button::<State, _>(
1449 frust::authoring::any::<State, _>(icon(self.overflow_icon)),
1450 move |state: &mut State| {
1451 if let Some(callback) = &on_overflow {
1452 callback(state);
1453 }
1454 },
1455 )
1456 .size(self.size.icon_button_size())
1457 .semantic_label(self.overflow_label.clone());
1458 match &self.overflow_anchor {
1459 Some(anchor) => frust::authoring::any::<State, _>(overlay_anchor(anchor, trigger)),
1460 None => frust::authoring::any::<State, _>(trigger),
1461 }
1462 }
1463
1464 /// The anchored overflow panel for the actions that did not fit — mount
1465 /// this separately as the top of the app's own [`frust::Stack`], anchored
1466 /// to the same [`OverlayAnchor`] handed to [`Self::overflow_anchor`], and
1467 /// toggle `open` rather than unmounting it (the kept-mounted pattern
1468 /// [`mod@crate::overlay::anchored`] documents).
1469 ///
1470 /// Each overflowed action becomes a [`crate::menu::MenuEntry`] row led by
1471 /// its own glyph; activating one runs that action's `on_press`, exactly as
1472 /// its inline button would. The placement is upstream's own
1473 /// `M3EMenuAnchorPosition.bottomEnd`.
1474 pub fn overflow_menu(&self, open: bool) -> AnyView<State> {
1475 let anchor = self.overflow_anchor.clone().unwrap_or_default();
1476 let entries = self.overflow_entries();
1477 let nodes: Vec<MenuNode> = entries.iter().map(|(node, _)| node.clone()).collect();
1478 let targets: Vec<OnPress<State>> = entries.into_iter().map(|(_, target)| target).collect();
1479
1480 frust::authoring::any::<State, _>(
1481 menu(nodes, move |state: &mut State, selection: MenuSelection| {
1482 if let Some(target) = targets.get(selection.index) {
1483 target(state);
1484 }
1485 })
1486 .anchor(&anchor)
1487 .align(OverlayAlign::End)
1488 .open(open),
1489 )
1490 }
1491
1492 /// The bar's resting elevation — the explicit override, else upstream's
1493 /// own `_hasFab ? elevationWithFab : elevation`.
1494 fn resolved_elevation(&self) -> ToolbarElevation {
1495 self.elevation.unwrap_or(if self.fab.is_some() {
1496 ToolbarElevation::Level1
1497 } else {
1498 ToolbarElevation::None
1499 })
1500 }
1501
1502 /// The expand progress this configuration rests at: a bar with no morph to
1503 /// run is always fully expanded (upstream's own `startExpanded`).
1504 fn resting_progress(&self) -> f64 {
1505 if self.morphs() && !self.expanded {
1506 0.0
1507 } else {
1508 1.0
1509 }
1510 }
1511
1512 /// Whether this bar runs the adjacent FAB morph at all — upstream's
1513 /// `_usesFabExpand` (`_hasFab && fabExpandsToolbar`, floating only).
1514 fn morphs(&self) -> bool {
1515 self.variant == ToolbarVariant::Floating && self.fab.is_some() && self.fab_expands_toolbar
1516 }
1517
1518 /// Whether an adjacent FAB sits beside the pill rather than inside the row
1519 /// — upstream's `_hasFab` on a floating bar, morphing or not.
1520 fn adjacent_fab(&self) -> bool {
1521 self.variant == ToolbarVariant::Floating && self.fab.is_some()
1522 }
1523}
1524
1525/// Collect `view`'s slot children into one ordered slice of [`AnyView`]
1526/// references — leading, center, the composed action views (held in
1527/// `actions`), trailing, then the fab slot. This is the shape both
1528/// `build`/`teardown` and [`frust::authoring::rebuild_children`] walk.
1529///
1530/// The composed action views cannot live in the view itself (they are derived
1531/// from the split, fresh per pass), so they are passed in alongside — the
1532/// one-slot storage handoff [`super::appbar`]'s own `title_ref` uses,
1533/// generalized to a list.
1534fn slot_views<'a, State: 'static>(
1535 view: &'a ToolbarView<State>,
1536 actions: &'a [AnyView<State>],
1537) -> Vec<&'a AnyView<State>> {
1538 let mut views = Vec::with_capacity(
1539 view.leading.len()
1540 + view.center.len()
1541 + actions.len()
1542 + view.trailing.len()
1543 + usize::from(view.fab.is_some()),
1544 );
1545 views.extend(view.leading.iter());
1546 views.extend(view.center.iter());
1547 views.extend(actions.iter());
1548 views.extend(view.trailing.iter());
1549 if let Some(fab) = &view.fab {
1550 views.push(fab);
1551 }
1552 views
1553}
1554
1555// ---- The widget ------------------------------------------------------------
1556
1557/// The retained widget for a [`ToolbarView`].
1558pub struct ToolbarWidget {
1559 variant: ToolbarVariant,
1560 color_style: ToolbarColorStyle,
1561 elevation: ToolbarElevation,
1562 /// Every slot pod, in the fixed order [`slot_views`] produces: leading,
1563 /// center, inline actions (plus the overflow trigger), trailing, then the
1564 /// fab slot — the one list [`frust::authoring::route_event`] hit-tests and
1565 /// [`frust::authoring::rebuild_children`] reconciles.
1566 slots: Vec<ChildPod>,
1567 leading_count: usize,
1568 center_count: usize,
1569 trailing_count: usize,
1570 has_fab: bool,
1571 adjacent_fab: bool,
1572 fab_expands: bool,
1573 fab_position: ToolbarFabPosition,
1574 semantic_label: Option<String>,
1575 exit_direction: ToolbarExitDirection,
1576 exit_extent: Option<f64>,
1577 /// The app-pinned hidden fraction, if any (bypasses [`Self::hide`]).
1578 hidden_pinned: Option<f64>,
1579 /// `0` collapsed through `1` expanded — the FAB morph and pill reveal.
1580 expand: RampTrack,
1581 /// `0` visible through `1` hidden — the scroll-hide travel.
1582 hide: RampTrack,
1583 /// The pill's own box, in bar-local px (set by `layout`, read by `paint`).
1584 pill_size: Size,
1585 /// The pill's origin within the bar's box, hide shift already applied.
1586 pill_origin: Point,
1587 /// The clamped expand progress `paint` clips and fades the pill with.
1588 reveal: f64,
1589}
1590
1591impl<State: 'static> View<State> for ToolbarView<State> {
1592 type Element = ToolbarWidget;
1593
1594 fn build(&self, ctx: &mut BuildCtx<'_>) -> ToolbarWidget {
1595 let actions = self.action_views();
1596 let slots = slot_views(self, &actions)
1597 .into_iter()
1598 .map(|view| frust::authoring::build_child(view, ctx))
1599 .collect();
1600 let hidden = self
1601 .hidden_fraction
1602 .map_or(if self.visible { 0.0 } else { 1.0 }, clamp01);
1603 ToolbarWidget {
1604 variant: self.variant,
1605 color_style: self.color_style,
1606 elevation: self.resolved_elevation(),
1607 slots,
1608 leading_count: self.leading.len(),
1609 center_count: self.center.len() + actions.len(),
1610 trailing_count: self.trailing.len(),
1611 has_fab: self.fab.is_some(),
1612 adjacent_fab: self.adjacent_fab(),
1613 fab_expands: self.fab_expands_toolbar,
1614 fab_position: self.fab_position,
1615 semantic_label: self.semantic_label.clone(),
1616 exit_direction: self.exit_direction,
1617 exit_extent: self.exit_extent,
1618 hidden_pinned: self.hidden_fraction.map(clamp01),
1619 expand: RampTrack::resting(self.resting_progress()),
1620 hide: RampTrack::resting(hidden),
1621 pill_size: Size::ZERO,
1622 pill_origin: Point::ZERO,
1623 reveal: 1.0,
1624 }
1625 }
1626
1627 fn rebuild(
1628 &self,
1629 prev: &Self,
1630 element: &mut ToolbarWidget,
1631 ctx: &mut BuildCtx<'_>,
1632 ) -> ChangeFlags {
1633 let mut flags = ChangeFlags::NONE;
1634
1635 let prev_actions = prev.action_views();
1636 let next_actions = self.action_views();
1637 let prev_views = slot_views(prev, &prev_actions);
1638 let next_views = slot_views(self, &next_actions);
1639 flags |= frust::authoring::rebuild_children(
1640 &prev_views,
1641 &next_views,
1642 &mut element.slots,
1643 ctx,
1644 |view: &&AnyView<State>| *view,
1645 |_| None,
1646 );
1647
1648 let center_count = self.center.len() + next_actions.len();
1649 for (slot, next) in [
1650 (&mut element.leading_count, self.leading.len()),
1651 (&mut element.center_count, center_count),
1652 (&mut element.trailing_count, self.trailing.len()),
1653 ] {
1654 if *slot != next {
1655 *slot = next;
1656 flags |= ChangeFlags::LAYOUT;
1657 }
1658 }
1659
1660 let now_fab = self.fab.is_some();
1661 if element.has_fab != now_fab {
1662 element.has_fab = now_fab;
1663 flags |= ChangeFlags::LAYOUT;
1664 }
1665 let adjacent = self.adjacent_fab();
1666 if element.adjacent_fab != adjacent {
1667 element.adjacent_fab = adjacent;
1668 flags |= ChangeFlags::LAYOUT;
1669 }
1670 if element.fab_expands != self.fab_expands_toolbar {
1671 element.fab_expands = self.fab_expands_toolbar;
1672 flags |= ChangeFlags::LAYOUT;
1673 }
1674 if element.fab_position != self.fab_position {
1675 element.fab_position = self.fab_position;
1676 flags |= ChangeFlags::LAYOUT;
1677 }
1678 if element.variant != self.variant {
1679 element.variant = self.variant;
1680 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1681 }
1682 if element.color_style != self.color_style {
1683 element.color_style = self.color_style;
1684 flags |= ChangeFlags::PAINT;
1685 }
1686 let elevation = self.resolved_elevation();
1687 if element.elevation != elevation {
1688 element.elevation = elevation;
1689 flags |= ChangeFlags::PAINT;
1690 }
1691 if element.exit_direction != self.exit_direction {
1692 element.exit_direction = self.exit_direction;
1693 flags |= ChangeFlags::LAYOUT;
1694 }
1695 if element.exit_extent != self.exit_extent {
1696 element.exit_extent = self.exit_extent;
1697 flags |= ChangeFlags::LAYOUT;
1698 }
1699 if element.semantic_label != self.semantic_label {
1700 element.semantic_label = self.semantic_label.clone();
1701 }
1702
1703 // Both ramps are layout values, so a retarget asks for a relayout
1704 // rather than only a repaint; `paint` keeps asking while the leg runs.
1705 let progress = self.resting_progress();
1706 if (element.expand.target - progress).abs() >= RAMP_EPSILON {
1707 element.expand.set_target(progress);
1708 flags |= ChangeFlags::LAYOUT;
1709 }
1710
1711 let pinned = self.hidden_fraction.map(clamp01);
1712 element.hidden_pinned = pinned;
1713 match pinned {
1714 // A pinned fraction applies with no spring, and keeps the ramp in
1715 // step so an unpin resumes from where the drag left it.
1716 Some(fraction) => {
1717 if element.hide.value != fraction {
1718 element.hide.jump(fraction);
1719 flags |= ChangeFlags::LAYOUT;
1720 }
1721 }
1722 None => {
1723 let hidden = if self.visible { 0.0 } else { 1.0 };
1724 if (element.hide.target - hidden).abs() >= RAMP_EPSILON {
1725 element.hide.set_target(hidden);
1726 flags |= ChangeFlags::LAYOUT;
1727 }
1728 }
1729 }
1730
1731 flags
1732 }
1733
1734 fn teardown(&self, element: &mut ToolbarWidget, ctx: &mut BuildCtx<'_>) {
1735 let actions = self.action_views();
1736 for (view, pod) in slot_views(self, &actions)
1737 .into_iter()
1738 .zip(element.slots.iter_mut())
1739 {
1740 frust::authoring::teardown_child(view, pod, ctx);
1741 }
1742 }
1743}
1744
1745impl ToolbarWidget {
1746 /// Whether the adjacent FAB morphs with the pill (upstream's
1747 /// `_usesFabExpand`).
1748 fn morphs(&self) -> bool {
1749 self.adjacent_fab && self.fab_expands
1750 }
1751
1752 /// The index of the fab pod, when there is one.
1753 fn fab_index(&self) -> Option<usize> {
1754 self.has_fab.then(|| self.slots.len() - 1)
1755 }
1756
1757 /// How many pods lay out inside the pill's row: everything but an adjacent
1758 /// FAB, which takes the morph layout instead.
1759 fn row_len(&self) -> usize {
1760 let row = self.leading_count + self.center_count + self.trailing_count;
1761 if self.has_fab && !self.adjacent_fab {
1762 row + 1
1763 } else {
1764 row
1765 }
1766 }
1767
1768 /// The hidden fraction this frame — the app's pin, else the ramp.
1769 fn hidden(&self) -> f64 {
1770 self.hidden_pinned
1771 .unwrap_or_else(|| clamp01(self.hide.value))
1772 }
1773
1774 /// The revealed window of the bar's box, in bar-local px: the sub-rect the
1775 /// pill is clipped to while the reveal runs (upstream's own clip, further
1776 /// intersected with this widget's own box so nothing paints outside it).
1777 fn reveal_window(&self, box_size: Size) -> (Point, Size) {
1778 if !self.morphs() || self.reveal >= 1.0 {
1779 return (Point::ZERO, box_size);
1780 }
1781 let natural = self.pill_size.width;
1782 let (x0, x1) = match self.fab_position {
1783 // Reveals from the trailing edge: the pill's own leading edge
1784 // walks left as `progress` grows.
1785 ToolbarFabPosition::End => (
1786 self.pill_origin.x + natural * (1.0 - self.reveal),
1787 box_size.width,
1788 ),
1789 // Reveals from the leading edge instead; the pill itself stays put.
1790 ToolbarFabPosition::Start => (0.0, self.pill_origin.x + natural * self.reveal),
1791 };
1792 let x0 = x0.clamp(0.0, box_size.width);
1793 let x1 = x1.clamp(x0, box_size.width);
1794 (Point::new(x0, 0.0), Size::new(x1 - x0, box_size.height))
1795 }
1796}
1797
1798impl Widget for ToolbarWidget {
1799 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1800 let progress = if self.morphs() {
1801 self.expand.value
1802 } else {
1803 1.0
1804 };
1805 self.reveal = clamp01(progress);
1806
1807 let row_len = self.row_len();
1808 let row_bc = BoxConstraints::loose(Size::new(f64::INFINITY, TOOLBAR_HEIGHT));
1809 let sizes: Vec<Size> = self.slots[..row_len]
1810 .iter_mut()
1811 .map(|pod| pod.layout_child(ctx, &row_bc))
1812 .collect();
1813
1814 // The row, laid out in pill-local coordinates.
1815 let pill_width = match self.variant {
1816 ToolbarVariant::Floating => {
1817 // Self-sized: one contiguous row, a `TOOLBAR_GAP` between every
1818 // adjacent pair (naturally collapsing across an empty group's
1819 // boundary, since the gap is per-item, not per-group) and
1820 // `TOOLBAR_FLOATING_PAD` at both ends.
1821 let mut x = TOOLBAR_FLOATING_PAD;
1822 for (pod, size) in self.slots[..row_len].iter_mut().zip(sizes.iter()) {
1823 pod.set_origin(Point::new(x, (TOOLBAR_HEIGHT - size.height) / 2.0));
1824 x += size.width + TOOLBAR_GAP;
1825 }
1826 if row_len > 0 {
1827 x -= TOOLBAR_GAP;
1828 }
1829 x + TOOLBAR_FLOATING_PAD
1830 }
1831 ToolbarVariant::Docked => {
1832 let width = finite_or_zero(bc.max().width);
1833
1834 // Leading: left-anchored, sequential.
1835 let mut left = TOOLBAR_DOCKED_PAD_X;
1836 for (pod, size) in self.slots[..self.leading_count]
1837 .iter_mut()
1838 .zip(sizes[..self.leading_count].iter())
1839 {
1840 pod.set_origin(Point::new(left, (TOOLBAR_HEIGHT - size.height) / 2.0));
1841 left += size.width + TOOLBAR_GAP;
1842 }
1843 if self.leading_count > 0 {
1844 left -= TOOLBAR_GAP;
1845 }
1846
1847 // Trailing (+ the in-row fab, if any): right-anchored, reverse
1848 // order, so the tail lands with the last item flush against
1849 // the trailing edge and reading order preserved.
1850 let tail_start = self.leading_count + self.center_count;
1851 let mut right = width - TOOLBAR_DOCKED_PAD_X;
1852 for (pod, size) in self.slots[tail_start..row_len]
1853 .iter_mut()
1854 .zip(sizes[tail_start..row_len].iter())
1855 .rev()
1856 {
1857 right -= size.width;
1858 pod.set_origin(Point::new(right, (TOOLBAR_HEIGHT - size.height) / 2.0));
1859 right -= TOOLBAR_GAP;
1860 }
1861 if tail_start < row_len {
1862 right += TOOLBAR_GAP;
1863 }
1864
1865 // Center: centered as a group within `[left, right]`.
1866 let center_range = self.leading_count..tail_start;
1867 let mut center_total: f64 =
1868 sizes[center_range.clone()].iter().map(|s| s.width).sum();
1869 if center_range.len() > 1 {
1870 center_total += TOOLBAR_GAP * (center_range.len() - 1) as f64;
1871 }
1872 let available = (right - left).max(0.0);
1873 let mut cx = left + ((available - center_total) / 2.0).max(0.0);
1874 for (pod, size) in self.slots[center_range.clone()]
1875 .iter_mut()
1876 .zip(sizes[center_range].iter())
1877 {
1878 pod.set_origin(Point::new(cx, (TOOLBAR_HEIGHT - size.height) / 2.0));
1879 cx += size.width + TOOLBAR_GAP;
1880 }
1881
1882 width
1883 }
1884 };
1885 self.pill_size = Size::new(pill_width, TOOLBAR_HEIGHT);
1886
1887 // The bar's own box, and where the pill sits inside it — upstream's
1888 // `RenderM3EToolbarHorizontalFabLayout.performLayout` whenever a FAB
1889 // sits beside the pill.
1890 let (box_size, pill_origin) = if self.adjacent_fab {
1891 let total = Size::new(
1892 pill_width + TOOLBAR_TO_FAB_GAP + TOOLBAR_FAB_BASELINE,
1893 TOOLBAR_FAB_MEDIUM,
1894 );
1895 let revealed = pill_width * progress.clamp(0.0, REVEAL_OVERSHOOT);
1896 let x = match self.fab_position {
1897 ToolbarFabPosition::End => pill_width - revealed,
1898 ToolbarFabPosition::Start => total.width - pill_width,
1899 };
1900 (
1901 total,
1902 Point::new(x, (TOOLBAR_FAB_MEDIUM - TOOLBAR_HEIGHT) / 2.0),
1903 )
1904 } else {
1905 (self.pill_size, Point::ZERO)
1906 };
1907
1908 // The adjacent FAB's own square. Tight constraints are what carry the
1909 // morph here — see the [module docs](self)' fab-seam note.
1910 if self.adjacent_fab
1911 && let Some(index) = self.fab_index()
1912 {
1913 let fab_size = if self.fab_expands {
1914 ToolbarMorph::size(progress)
1915 } else {
1916 TOOLBAR_FAB_BASELINE
1917 };
1918 let pod = &mut self.slots[index];
1919 pod.layout_child(ctx, &BoxConstraints::tight(Size::new(fab_size, fab_size)));
1920 let x = match self.fab_position {
1921 ToolbarFabPosition::End => box_size.width - fab_size,
1922 ToolbarFabPosition::Start => 0.0,
1923 };
1924 pod.set_origin(Point::new(x, (TOOLBAR_FAB_MEDIUM - fab_size) / 2.0));
1925 }
1926
1927 // The scroll-hide travel: a layout value, applied to every pod (and to
1928 // the pill origin `paint` fills against) so hit-testing follows the
1929 // visual exactly.
1930 let hidden = self.hidden();
1931 // `finite_or_zero`-funneled: `exit_extent` is caller-fed, and a
1932 // non-finite value here would carry through `shift` into
1933 // `pill_origin` and, from there, into `reveal_window`'s clamp bound
1934 // — see the [module docs](self)' clamp discipline.
1935 let extent = finite_or_zero(self.exit_extent.unwrap_or(
1936 if self.exit_direction.is_vertical() {
1937 box_size.height + TOOLBAR_SCREEN_OFFSET
1938 } else {
1939 box_size.width + TOOLBAR_SCREEN_OFFSET
1940 },
1941 ));
1942 let shift = self.exit_direction.offset(hidden * extent.abs());
1943 self.pill_origin = pill_origin + shift;
1944
1945 // Row pods sit in pill-local coordinates; lift them into the box, with
1946 // the travel already folded into the pill's own origin.
1947 let pill_shift = self.pill_origin.to_vec2();
1948 if pill_shift != Vec2::ZERO {
1949 for pod in self.slots[..row_len].iter_mut() {
1950 let origin = pod.origin();
1951 pod.set_origin(origin + pill_shift);
1952 }
1953 }
1954 // An adjacent FAB was placed in box coordinates already, so it takes
1955 // the travel alone.
1956 if self.adjacent_fab
1957 && shift != Vec2::ZERO
1958 && let Some(index) = self.fab_index()
1959 {
1960 let origin = self.slots[index].origin();
1961 self.slots[index].set_origin(origin + shift);
1962 }
1963
1964 bc.constrain(box_size)
1965 }
1966
1967 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1968 // Resolve every theme-derived value before the `&mut ctx` reborrows
1969 // below (`request_layout`) — `theme` borrows `ctx` shared, so its last
1970 // use must precede them for this to typecheck under NLL.
1971 let theme = Theme::from_paint_ctx(ctx);
1972 let colors = ToolbarColors::resolve(theme, self.color_style);
1973 let shadow = resolve_shadow(theme, self.elevation);
1974 let radius = resolve_radius(
1975 theme,
1976 self.variant,
1977 self.pill_size.width,
1978 self.pill_size.height,
1979 );
1980 let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
1981 let now = ctx.frame_time();
1982
1983 // Both ramps drive layout values (the FAB square, the hide travel), so
1984 // an in-flight leg needs an explicit relayout, not merely another
1985 // frame — the layout-skip class the module docs describe. Either arm
1986 // asks: still in flight (the next frame moves it again), or the value
1987 // moved this frame — the *settling* frame is the one that both stops
1988 // animating and lands on the target, so gating on the animating flag
1989 // alone would leave the bar a fraction short of rest.
1990 let moved = if reduce_motion {
1991 let expand = self.expand.snap();
1992 let hide = self.hide.snap();
1993 expand || hide
1994 } else {
1995 let before = (self.expand.value, self.hide.value);
1996 let expand = self.expand.advance(now);
1997 let hide = self.hide.advance(now);
1998 expand || hide || (self.expand.value, self.hide.value) != before
1999 };
2000 if moved {
2001 ctx.request_layout();
2002 }
2003
2004 let origin = ctx.origin();
2005 let box_size = ctx.size();
2006 let alpha = if self.morphs() {
2007 ToolbarMorph::reveal_alpha(self.reveal) as f32
2008 } else {
2009 1.0
2010 };
2011 let (window_origin, window_size) = self.reveal_window(box_size);
2012 let clipping =
2013 window_origin != Point::ZERO || window_size != box_size || self.hidden() > 0.0;
2014
2015 if clipping {
2016 scene.push_clip(
2017 Point::new(origin.x + window_origin.x, origin.y + window_origin.y),
2018 window_size,
2019 );
2020 }
2021 if alpha < 1.0 {
2022 scene.push_layer(origin, box_size, alpha);
2023 }
2024
2025 let pill_origin = Point::new(origin.x + self.pill_origin.x, origin.y + self.pill_origin.y);
2026 if let Some((blur, y_offset, shadow_color)) = shadow {
2027 scene.draw_shadow(
2028 Point::new(pill_origin.x, pill_origin.y + y_offset),
2029 self.pill_size,
2030 radius,
2031 blur,
2032 shadow_color,
2033 );
2034 }
2035 scene.fill_rounded_rect(pill_origin, self.pill_size, radius, colors.container);
2036
2037 let row_len = self.row_len();
2038 for pod in self.slots[..row_len].iter_mut() {
2039 pod.paint_child(ctx, scene);
2040 }
2041
2042 if alpha < 1.0 {
2043 scene.pop_layer();
2044 }
2045 if clipping {
2046 scene.pop_clip();
2047 }
2048
2049 // An adjacent FAB is outside the pill's reveal clip and fade entirely
2050 // (upstream paints it after, unclipped) — it is the one thing on
2051 // screen while the pill is fully collapsed.
2052 if self.adjacent_fab
2053 && let Some(index) = self.fab_index()
2054 {
2055 self.slots[index].paint_child(ctx, scene);
2056 }
2057 }
2058
2059 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2060 frust::authoring::route_event(&mut self.slots, ctx, event)
2061 }
2062
2063 fn semantics(&self, ctx: &mut SemanticsCtx) {
2064 let slots = &self.slots;
2065 let label = self.semantic_label.as_deref();
2066 ctx.push_container(
2067 Role::Toolbar,
2068 |node| {
2069 if let Some(label) = label {
2070 node.set_label(label);
2071 }
2072 },
2073 |ctx| {
2074 for pod in slots {
2075 pod.semantics_child(ctx);
2076 }
2077 },
2078 );
2079 }
2080
2081 frust::authoring::visit_children!(slots);
2082}
2083
2084#[cfg(test)]
2085mod tests {
2086 use super::*;
2087 use frust::authoring::BuildCtx;
2088 use frust_widgets::test_support::leaf_any;
2089
2090 fn ctx(counter: &mut u64) -> BuildCtx<'_> {
2091 BuildCtx::new(counter)
2092 }
2093
2094 /// Records each `fill_rounded_rect` call's `(origin, size, radius,
2095 /// color)` — this widget paints its container as a rounded rect, even at
2096 /// a docked-variant radius of `0.0`, so the shared
2097 /// [`frust_widgets::test_support::RecordingScene`] (which only records
2098 /// `fill_rect`) can't observe it.
2099 #[derive(Default)]
2100 struct RRectRecorder {
2101 rrects: Vec<(Point, Size, f64, Color)>,
2102 shadows: Vec<(Point, Size)>,
2103 clips: usize,
2104 layers: Vec<f32>,
2105 }
2106 impl PaintScene for RRectRecorder {
2107 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
2108 fn draw_text(&mut self, _o: Point, _t: &str) {}
2109 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
2110 self.rrects.push((o, s, radius, color));
2111 }
2112 fn draw_shadow(&mut self, o: Point, s: Size, _r: f64, _sd: f64, _c: Color) {
2113 self.shadows.push((o, s));
2114 }
2115 fn push_clip(&mut self, _o: Point, _s: Size) {
2116 self.clips += 1;
2117 }
2118 fn push_layer(&mut self, _o: Point, _s: Size, alpha: f32) {
2119 self.layers.push(alpha);
2120 }
2121 }
2122
2123 fn build(view: &ToolbarView<()>) -> ToolbarWidget {
2124 let mut counter = 0u64;
2125 View::<()>::build(view, &mut ctx(&mut counter))
2126 }
2127
2128 fn layout(w: &mut ToolbarWidget, bc: &BoxConstraints) -> Size {
2129 let mut lctx = LayoutCtx::new();
2130 w.layout(&mut lctx, bc)
2131 }
2132
2133 /// Paint one frame `millis` into the ramp, reporting whether a relayout
2134 /// was requested (the in-flight/settling signal).
2135 fn paint_at(w: &mut ToolbarWidget, millis: u64, theme: Option<&Theme>) -> bool {
2136 let mut scene = RRectRecorder::default();
2137 let mut pctx = PaintCtx::for_test(
2138 Point::ZERO,
2139 Size::new(400.0, TOOLBAR_FAB_MEDIUM),
2140 FrameTime::from_nanos(millis * 1_000_000),
2141 );
2142 if let Some(theme) = theme {
2143 pctx = pctx.with_theme(theme);
2144 }
2145 w.paint(&mut pctx, &mut scene);
2146 pctx.needs_layout()
2147 }
2148
2149 // ---- Metrics ----------------------------------------------------------
2150
2151 #[test]
2152 fn the_ported_tokens_match_the_reference_table_exactly() {
2153 assert_eq!(TOOLBAR_HEIGHT, 64.0);
2154 assert_eq!(TOOLBAR_FLOATING_PAD, 8.0);
2155 assert_eq!(TOOLBAR_DOCKED_PAD_X, 16.0);
2156 assert_eq!(TOOLBAR_GAP, 4.0);
2157 assert_eq!(TOOLBAR_TO_FAB_GAP, 8.0);
2158 assert_eq!(TOOLBAR_SCREEN_OFFSET, 16.0);
2159 assert_eq!(TOOLBAR_FAB_BASELINE, 56.0);
2160 assert_eq!(TOOLBAR_FAB_MEDIUM, 80.0);
2161 assert_eq!(TOOLBAR_MAX_INLINE_ACTIONS, 4);
2162 assert_eq!(TOOLBAR_SETTLE_VELOCITY, 150.0);
2163 }
2164
2165 #[test]
2166 fn the_expand_spring_is_the_reference_expressive_spatial_fast_token() {
2167 let token = crate::MaterialSpring::EXPRESSIVE_SPATIAL_FAST;
2168 assert_eq!(TOOLBAR_EXPAND_SPRING.stiffness, token.stiffness);
2169 assert_eq!(TOOLBAR_EXPAND_SPRING.damping_ratio, token.damping_ratio);
2170 assert_eq!(TOOLBAR_EXPAND_SPRING.mass, 1.0);
2171 }
2172
2173 // ---- Layout: the two variants -----------------------------------------
2174
2175 #[test]
2176 fn floating_layout_preserves_slot_order_and_applies_inset_pill_geometry() {
2177 let view: ToolbarView<()> = floating_toolbar()
2178 .leading(vec![leaf_any(24.0, 24.0)])
2179 .center(vec![leaf_any(20.0, 20.0)])
2180 .trailing(vec![leaf_any(24.0, 24.0)])
2181 .fab(leaf_any(40.0, 40.0));
2182 let mut w = build(&view);
2183 let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
2184
2185 // A floating bar with a FAB reserves the adjacent morph box.
2186 assert_eq!(size.height, TOOLBAR_FAB_MEDIUM);
2187 assert!(size.width < 800.0);
2188
2189 // Slot order preserved: leading < center < trailing, left to right.
2190 assert!(w.slots[0].origin().x < w.slots[1].origin().x);
2191 assert!(w.slots[1].origin().x < w.slots[2].origin().x);
2192
2193 // Inset from the pill's leading edge by `TOOLBAR_FLOATING_PAD`.
2194 assert_eq!(w.slots[0].origin().x, TOOLBAR_FLOATING_PAD);
2195
2196 // A fully-rounded pill: radius resolves to half the fixed bar height
2197 // (the shorter side), the `ShapeScale::full` clamp.
2198 let radius = resolve_radius(
2199 None,
2200 ToolbarVariant::Floating,
2201 w.pill_size.width,
2202 w.pill_size.height,
2203 );
2204 assert_eq!(radius, TOOLBAR_HEIGHT / 2.0);
2205 }
2206
2207 #[test]
2208 fn a_floating_toolbar_without_a_fab_hugs_its_own_content() {
2209 let view: ToolbarView<()> = floating_toolbar()
2210 .leading(vec![leaf_any(24.0, 24.0)])
2211 .trailing(vec![leaf_any(24.0, 24.0)]);
2212 let mut w = build(&view);
2213 let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
2214
2215 assert_eq!(size.height, TOOLBAR_HEIGHT);
2216 assert_eq!(
2217 size.width,
2218 TOOLBAR_FLOATING_PAD * 2.0 + 24.0 + TOOLBAR_GAP + 24.0,
2219 "pad + item + gap + item + pad"
2220 );
2221 assert_eq!(
2222 w.slots[1].origin().x + w.slots[1].size().width,
2223 size.width - TOOLBAR_FLOATING_PAD
2224 );
2225 }
2226
2227 #[test]
2228 fn docked_layout_spans_the_available_width_with_flat_geometry() {
2229 let view: ToolbarView<()> = docked_toolbar()
2230 .leading(vec![leaf_any(24.0, 24.0)])
2231 .trailing(vec![leaf_any(24.0, 24.0)]);
2232 let mut w = build(&view);
2233 let size = layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
2234 assert_eq!(size, Size::new(400.0, TOOLBAR_HEIGHT));
2235
2236 let radius = resolve_radius(None, ToolbarVariant::Docked, size.width, size.height);
2237 assert_eq!(radius, 0.0, "docked variant is flat (square corners)");
2238
2239 assert_eq!(w.slots[0].origin().x, TOOLBAR_DOCKED_PAD_X);
2240 assert_eq!(
2241 w.slots[1].origin().x + w.slots[1].size().width,
2242 400.0 - TOOLBAR_DOCKED_PAD_X
2243 );
2244 }
2245
2246 #[test]
2247 fn docked_layout_centers_the_center_group_between_leading_and_trailing() {
2248 let view: ToolbarView<()> = docked_toolbar()
2249 .leading(vec![leaf_any(24.0, 24.0)])
2250 .center(vec![leaf_any(20.0, 20.0)])
2251 .trailing(vec![leaf_any(24.0, 24.0)]);
2252 let mut w = build(&view);
2253 layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
2254
2255 assert!(w.slots[0].origin().x < w.slots[1].origin().x);
2256 assert!(w.slots[1].origin().x < w.slots[2].origin().x);
2257
2258 let leading_end = w.slots[0].origin().x + w.slots[0].size().width;
2259 let trailing_start = w.slots[2].origin().x;
2260 let center_start = w.slots[1].origin().x;
2261 let center_end = center_start + w.slots[1].size().width;
2262 let left_gap = center_start - leading_end;
2263 let right_gap = trailing_start - center_end;
2264 assert!(
2265 (left_gap - right_gap).abs() < 0.01,
2266 "the center group is centered in the remaining space \
2267 (left_gap={left_gap}, right_gap={right_gap})"
2268 );
2269 }
2270
2271 #[test]
2272 fn docked_layout_places_fab_flush_against_the_trailing_edge_after_trailing() {
2273 let view: ToolbarView<()> = docked_toolbar()
2274 .trailing(vec![leaf_any(24.0, 24.0)])
2275 .fab(leaf_any(40.0, 40.0));
2276 let mut w = build(&view);
2277 let size = layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
2278
2279 // Docked keeps the fab in-row: the box is still one bar tall.
2280 assert_eq!(size, Size::new(400.0, TOOLBAR_HEIGHT));
2281 assert!(w.slots[0].origin().x < w.slots[1].origin().x);
2282 assert_eq!(
2283 w.slots[1].origin().x + w.slots[1].size().width,
2284 400.0 - TOOLBAR_DOCKED_PAD_X,
2285 "the fab slot sits flush against the trailing edge"
2286 );
2287 }
2288
2289 // ---- The FAB morph ----------------------------------------------------
2290
2291 #[test]
2292 fn the_fab_morphs_from_80_collapsed_to_56_expanded() {
2293 assert_eq!(ToolbarMorph::size(0.0), TOOLBAR_FAB_MEDIUM);
2294 assert_eq!(ToolbarMorph::size(1.0), TOOLBAR_FAB_BASELINE);
2295 assert_eq!(ToolbarMorph::size(0.5), 68.0);
2296 // The expand spring's overshoot is real travel, never clamped away…
2297 assert!(ToolbarMorph::size(1.2) < TOOLBAR_FAB_BASELINE);
2298 // …but the square never goes negative, and NaN rests collapsed.
2299 assert_eq!(ToolbarMorph::size(100.0), 0.0);
2300 assert_eq!(ToolbarMorph::size(f64::NAN), TOOLBAR_FAB_MEDIUM);
2301 }
2302
2303 #[test]
2304 fn a_collapsed_bar_lays_its_fab_out_at_the_80dp_end_and_hides_the_pill() {
2305 let view: ToolbarView<()> = floating_toolbar()
2306 .leading(vec![leaf_any(24.0, 24.0)])
2307 .fab(leaf_any(40.0, 40.0))
2308 .expanded(false);
2309 let mut w = build(&view);
2310 let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
2311
2312 let fab = w.slots[w.fab_index().expect("a fab pod")].size();
2313 assert_eq!(fab, Size::new(TOOLBAR_FAB_MEDIUM, TOOLBAR_FAB_MEDIUM));
2314 assert_eq!(size.height, TOOLBAR_FAB_MEDIUM);
2315 assert_eq!(w.reveal, 0.0, "collapsed: nothing of the pill is revealed");
2316
2317 // The reveal window opens past the pill's own trailing edge, so none
2318 // of the pill paints at all — only the FAB is on screen.
2319 let (window_origin, _) = w.reveal_window(size);
2320 assert!(window_origin.x >= w.pill_origin.x + w.pill_size.width);
2321 }
2322
2323 /// A NaN `exit_extent` prop must not corrupt `pill_origin` into a value
2324 /// `reveal_window`'s clamp would abort on — `x0` becomes the second
2325 /// clamp's `min` bound there, so an unguarded NaN reaching it panics on
2326 /// `assert!(min <= max)`, not merely on the clamped value itself. This is
2327 /// the layout/paint counterpart to the constructor guard above; see the
2328 /// module docs' clamp discipline.
2329 #[test]
2330 fn a_non_finite_exit_extent_prop_reaches_layout_and_paint_without_panicking() {
2331 for extent in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2332 let view: ToolbarView<()> = floating_toolbar()
2333 .leading(vec![leaf_any(24.0, 24.0)])
2334 .fab(leaf_any(40.0, 40.0))
2335 .expanded(false)
2336 .exit_extent(Some(extent));
2337 let mut w = build(&view);
2338 let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
2339
2340 assert!(w.pill_origin.x.is_finite());
2341 assert!(w.pill_origin.y.is_finite());
2342
2343 let (window_origin, window_size) = w.reveal_window(size);
2344 assert!(window_origin.x.is_finite());
2345 assert!(window_size.width.is_finite());
2346
2347 // Painting must not panic either — the same clamp reruns inside
2348 // `paint`.
2349 paint_at(&mut w, 0, None);
2350 }
2351 }
2352
2353 #[test]
2354 fn an_expanded_bar_lays_its_fab_out_at_the_56dp_end_beside_the_pill() {
2355 let view: ToolbarView<()> = floating_toolbar()
2356 .leading(vec![leaf_any(24.0, 24.0)])
2357 .fab(leaf_any(40.0, 40.0));
2358 let mut w = build(&view);
2359 let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
2360
2361 let index = w.fab_index().expect("a fab pod");
2362 assert_eq!(
2363 w.slots[index].size(),
2364 Size::new(TOOLBAR_FAB_BASELINE, TOOLBAR_FAB_BASELINE)
2365 );
2366 // The box always reserves `fabBaseline` beside the pill, whatever the
2367 // morph is currently painting.
2368 assert_eq!(
2369 size.width,
2370 w.pill_size.width + TOOLBAR_TO_FAB_GAP + TOOLBAR_FAB_BASELINE
2371 );
2372 // Expanded, the pill sits flush at the box's leading edge and the FAB
2373 // flush at its trailing one.
2374 assert_eq!(w.pill_origin.x, 0.0);
2375 assert_eq!(
2376 w.slots[index].origin().x + TOOLBAR_FAB_BASELINE,
2377 size.width,
2378 "the fab hugs the trailing edge"
2379 );
2380 assert_eq!(w.reveal, 1.0);
2381 }
2382
2383 #[test]
2384 fn pinning_fab_expands_toolbar_off_keeps_the_fab_at_the_baseline_square() {
2385 let view: ToolbarView<()> = floating_toolbar()
2386 .leading(vec![leaf_any(24.0, 24.0)])
2387 .fab(leaf_any(40.0, 40.0))
2388 .fab_expands_toolbar(false)
2389 .expanded(false);
2390 let mut w = build(&view);
2391 layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
2392
2393 let index = w.fab_index().expect("a fab pod");
2394 assert_eq!(
2395 w.slots[index].size(),
2396 Size::new(TOOLBAR_FAB_BASELINE, TOOLBAR_FAB_BASELINE)
2397 );
2398 assert_eq!(w.reveal, 1.0, "the pill stays open");
2399 }
2400
2401 #[test]
2402 fn a_start_positioned_fab_leads_the_pill() {
2403 let view: ToolbarView<()> = floating_toolbar()
2404 .leading(vec![leaf_any(24.0, 24.0)])
2405 .fab(leaf_any(40.0, 40.0))
2406 .fab_position(ToolbarFabPosition::Start);
2407 let mut w = build(&view);
2408 let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
2409
2410 let index = w.fab_index().expect("a fab pod");
2411 assert_eq!(w.slots[index].origin().x, 0.0);
2412 assert_eq!(w.pill_origin.x, size.width - w.pill_size.width);
2413 }
2414
2415 #[test]
2416 fn the_expand_ramp_settles_onto_the_target_and_stops_requesting_layout() {
2417 let view: ToolbarView<()> = floating_toolbar()
2418 .leading(vec![leaf_any(24.0, 24.0)])
2419 .fab(leaf_any(40.0, 40.0))
2420 .expanded(false);
2421 let next: ToolbarView<()> = floating_toolbar()
2422 .leading(vec![leaf_any(24.0, 24.0)])
2423 .fab(leaf_any(40.0, 40.0));
2424
2425 let mut counter = 0u64;
2426 let mut w = View::<()>::build(&view, &mut ctx(&mut counter));
2427 layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
2428 assert!(!paint_at(&mut w, 0, None), "a resting bar asks for nothing");
2429
2430 let flags = View::<()>::rebuild(&next, &view, &mut w, &mut ctx(&mut counter));
2431 assert!(flags.needs_layout(), "a retarget is a layout change");
2432
2433 let mut frames = 0u64;
2434 loop {
2435 layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
2436 let still = paint_at(&mut w, frames * 16, None);
2437 frames += 1;
2438 if !still {
2439 break;
2440 }
2441 assert!(frames < 400, "the ramp settles well inside 400 frames");
2442 }
2443 layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
2444 assert_eq!(w.expand.value, 1.0, "the settled ramp lands on the target");
2445 assert_eq!(
2446 w.slots[w.fab_index().expect("a fab pod")].size().width,
2447 TOOLBAR_FAB_BASELINE
2448 );
2449 }
2450
2451 #[test]
2452 fn reduce_motion_snaps_both_ramps_and_requests_exactly_one_relayout() {
2453 let mut theme = crate::baseline();
2454 theme.motion.reduce_motion = true;
2455
2456 let view: ToolbarView<()> = floating_toolbar()
2457 .leading(vec![leaf_any(24.0, 24.0)])
2458 .fab(leaf_any(40.0, 40.0))
2459 .expanded(false);
2460 let next: ToolbarView<()> = floating_toolbar()
2461 .leading(vec![leaf_any(24.0, 24.0)])
2462 .fab(leaf_any(40.0, 40.0));
2463
2464 let mut counter = 0u64;
2465 let mut w = View::<()>::build(&view, &mut ctx(&mut counter));
2466 layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
2467 View::<()>::rebuild(&next, &view, &mut w, &mut ctx(&mut counter));
2468
2469 assert!(
2470 paint_at(&mut w, 16, Some(&theme)),
2471 "the snap moved a layout value, so it asks for one relayout"
2472 );
2473 assert_eq!(w.expand.value, 1.0, "snapped");
2474 assert!(
2475 !paint_at(&mut w, 32, Some(&theme)),
2476 "and never asks again once there is nothing left to move"
2477 );
2478 }
2479
2480 // ---- Scroll-hide ------------------------------------------------------
2481
2482 #[test]
2483 fn the_measured_exit_limit_is_the_bar_extent_plus_the_screen_offset() {
2484 let hide = ToolbarScrollHide::for_bar(TOOLBAR_HEIGHT);
2485 assert_eq!(
2486 hide.offset_limit(),
2487 -(TOOLBAR_HEIGHT + TOOLBAR_SCREEN_OFFSET)
2488 );
2489 assert_eq!(hide.collapsed_fraction(), 0.0);
2490 assert!(!hide.is_hidden());
2491
2492 // An explicit extent pins the limit instead, sign-independently.
2493 assert_eq!(ToolbarScrollHide::new(-40.0).offset_limit(), -40.0);
2494 }
2495
2496 /// A degenerate (NaN/∞) construction parameter must degrade to a resting
2497 /// controller, never panic — the module docs' clamp-discipline
2498 /// funnel-at-entry rule, exercised through both constructors and a
2499 /// following `scroll()` sequence (the site that would otherwise abort on
2500 /// a non-finite `limit`).
2501 #[test]
2502 fn a_non_finite_construction_parameter_degrades_instead_of_panicking() {
2503 for degenerate in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2504 let mut hide = ToolbarScrollHide::new(degenerate);
2505 assert_eq!(hide.offset_limit(), 0.0, "degrades to a resting limit");
2506 // A subsequent scroll/settle/toggle sequence must not panic —
2507 // the site that would otherwise abort on a non-finite `limit` —
2508 // and every reachable state stays finite.
2509 hide.scroll(40.0);
2510 hide.scroll(-1_000.0);
2511 hide.settle(1_000.0);
2512 hide.toggle();
2513 assert!(hide.offset().is_finite());
2514
2515 let mut hide = ToolbarScrollHide::for_bar(degenerate);
2516 assert_eq!(
2517 hide.offset_limit(),
2518 0.0,
2519 "for_bar degrades the same way, delegating to new"
2520 );
2521 hide.scroll(degenerate);
2522 hide.scroll(40.0);
2523 hide.toggle();
2524 assert!(hide.offset().is_finite());
2525 }
2526 }
2527
2528 #[test]
2529 fn scrolling_accumulates_into_a_clamped_hidden_fraction() {
2530 let mut hide = ToolbarScrollHide::new(80.0);
2531 hide.scroll(40.0);
2532 assert_eq!(hide.offset(), -40.0);
2533 assert_eq!(hide.collapsed_fraction(), 0.5);
2534
2535 // Scrolling back up returns the bar.
2536 hide.scroll(-15.0);
2537 assert_eq!(hide.offset(), -25.0);
2538
2539 // Both ends clamp, and a non-finite delta is ignored outright.
2540 hide.scroll(1_000.0);
2541 assert_eq!(hide.offset(), -80.0);
2542 assert!(hide.is_hidden());
2543 hide.scroll(f64::NAN);
2544 assert_eq!(hide.offset(), -80.0);
2545 hide.scroll(-1_000.0);
2546 assert_eq!(hide.offset(), 0.0);
2547 }
2548
2549 #[test]
2550 fn a_settle_commits_to_the_fling_direction_past_the_velocity_threshold() {
2551 let mut hide = ToolbarScrollHide::new(80.0);
2552 hide.scroll(10.0);
2553 // A downward fling hides even from barely-moved.
2554 assert!(!hide.settle(TOOLBAR_SETTLE_VELOCITY + 1.0));
2555 assert!(hide.is_hidden());
2556
2557 let mut hide = ToolbarScrollHide::new(80.0);
2558 hide.scroll(70.0);
2559 // An upward fling shows even from nearly-hidden.
2560 assert!(hide.settle(-(TOOLBAR_SETTLE_VELOCITY + 1.0)));
2561 assert_eq!(hide.offset(), 0.0);
2562
2563 // Under the threshold the nearer end wins.
2564 let mut hide = ToolbarScrollHide::new(80.0);
2565 hide.scroll(30.0);
2566 assert!(hide.settle(10.0));
2567 let mut hide = ToolbarScrollHide::new(80.0);
2568 hide.scroll(50.0);
2569 assert!(!hide.settle(10.0));
2570
2571 // Already settled, nothing moves.
2572 let mut hide = ToolbarScrollHide::new(80.0);
2573 assert!(hide.settle(1_000.0));
2574 assert_eq!(hide.offset(), 0.0);
2575 }
2576
2577 #[test]
2578 fn toggle_flips_about_the_halfway_point() {
2579 let mut hide = ToolbarScrollHide::new(80.0);
2580 hide.toggle();
2581 assert!(hide.is_hidden());
2582 hide.toggle();
2583 assert_eq!(hide.offset(), 0.0);
2584 }
2585
2586 #[test]
2587 fn a_pinned_hidden_fraction_translates_every_slot_along_the_exit_direction() {
2588 let visible: ToolbarView<()> = docked_toolbar().leading(vec![leaf_any(24.0, 24.0)]);
2589 let mut w = build(&visible);
2590 layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
2591 let resting = w.slots[0].origin();
2592
2593 let hidden: ToolbarView<()> = docked_toolbar()
2594 .leading(vec![leaf_any(24.0, 24.0)])
2595 .hidden_fraction(Some(1.0));
2596 let mut counter = 0u64;
2597 let flags = View::<()>::rebuild(&hidden, &visible, &mut w, &mut ctx(&mut counter));
2598 assert!(flags.needs_layout(), "the hide travel is a layout value");
2599 let size = layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
2600
2601 // Bottom exit: the whole bar slides down by its own extent plus the
2602 // screen offset, so nothing of it remains inside the box.
2603 let travel = size.height + TOOLBAR_SCREEN_OFFSET;
2604 assert_eq!(w.slots[0].origin().y, resting.y + travel);
2605 assert_eq!(w.pill_origin.y, travel);
2606 assert!(w.slots[0].origin().y >= size.height);
2607 }
2608
2609 #[test]
2610 fn every_exit_direction_translates_on_its_own_axis() {
2611 for (direction, expected) in [
2612 (ToolbarExitDirection::Top, Vec2::new(0.0, -10.0)),
2613 (ToolbarExitDirection::Bottom, Vec2::new(0.0, 10.0)),
2614 (ToolbarExitDirection::Start, Vec2::new(-10.0, 0.0)),
2615 (ToolbarExitDirection::End, Vec2::new(10.0, 0.0)),
2616 ] {
2617 assert_eq!(direction.offset(10.0), expected);
2618 }
2619 assert!(ToolbarExitDirection::Top.is_vertical());
2620 assert!(!ToolbarExitDirection::End.is_vertical());
2621 }
2622
2623 #[test]
2624 fn an_explicit_exit_extent_overrides_the_measured_one() {
2625 let view: ToolbarView<()> = docked_toolbar()
2626 .leading(vec![leaf_any(24.0, 24.0)])
2627 .hidden_fraction(Some(1.0))
2628 .exit_extent(Some(20.0));
2629 let mut w = build(&view);
2630 layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
2631 assert_eq!(w.pill_origin.y, 20.0);
2632 }
2633
2634 #[test]
2635 fn the_visible_prop_springs_the_hide_travel_and_settles_hidden() {
2636 let visible: ToolbarView<()> = docked_toolbar().leading(vec![leaf_any(24.0, 24.0)]);
2637 let mut counter = 0u64;
2638 let mut w = View::<()>::build(&visible, &mut ctx(&mut counter));
2639 layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
2640
2641 let hidden: ToolbarView<()> = docked_toolbar()
2642 .leading(vec![leaf_any(24.0, 24.0)])
2643 .visible(false);
2644 View::<()>::rebuild(&hidden, &visible, &mut w, &mut ctx(&mut counter));
2645
2646 let mut frames = 0u64;
2647 loop {
2648 layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
2649 let still = paint_at(&mut w, frames * 16, None);
2650 frames += 1;
2651 if !still {
2652 break;
2653 }
2654 assert!(frames < 400, "the hide ramp settles well inside 400 frames");
2655 }
2656 assert_eq!(w.hidden(), 1.0, "the settled ramp lands fully hidden");
2657 }
2658
2659 // ---- Overflow ---------------------------------------------------------
2660
2661 #[test]
2662 fn the_capacity_rule_matches_the_reference_partition() {
2663 // Everything fits: nothing overflows and there is no trigger slot.
2664 let split = partition_actions(3, TOOLBAR_MAX_INLINE_ACTIONS, None);
2665 assert_eq!(split.inline, vec![0, 1, 2]);
2666 assert!(split.overflow.is_empty());
2667
2668 // Surplus actions overflow in order.
2669 let split = partition_actions(7, TOOLBAR_MAX_INLINE_ACTIONS, None);
2670 assert_eq!(split.inline, vec![0, 1, 2, 3]);
2671 assert_eq!(split.overflow, vec![4, 5, 6]);
2672
2673 // An expand trigger always stays inline and reserves one slot.
2674 let split = partition_actions(7, TOOLBAR_MAX_INLINE_ACTIONS, Some(0));
2675 assert_eq!(split.inline, vec![0, 1, 2, 3]);
2676 assert_eq!(split.overflow, vec![4, 5, 6]);
2677 let split = partition_actions(7, TOOLBAR_MAX_INLINE_ACTIONS, Some(6));
2678 assert_eq!(split.inline, vec![0, 1, 2, 6]);
2679 assert_eq!(split.overflow, vec![3, 4, 5]);
2680
2681 // A zero budget overflows everything but the trigger.
2682 let split = partition_actions(3, 0, Some(1));
2683 assert_eq!(split.inline, vec![1]);
2684 assert_eq!(split.overflow, vec![0, 2]);
2685
2686 // An out-of-range trigger index is ignored rather than reserving.
2687 let split = partition_actions(2, 1, Some(9));
2688 assert_eq!(split.inline, vec![0]);
2689 assert_eq!(split.overflow, vec![1]);
2690 }
2691
2692 fn action(label: &str) -> ToolbarAction<Vec<String>> {
2693 let owned = label.to_owned();
2694 toolbar_action::<Vec<String>, _>(crate::icons::MORE_HORIZ, move |log: &mut Vec<String>| {
2695 log.push(owned.clone())
2696 })
2697 .label(label)
2698 }
2699
2700 #[test]
2701 fn overflowed_actions_become_menu_rows_routing_back_to_their_own_action() {
2702 let view: ToolbarView<Vec<String>> = floating_toolbar()
2703 .actions(vec![
2704 action("one"),
2705 action("two"),
2706 action("three"),
2707 action("four"),
2708 action("five"),
2709 action("six"),
2710 ])
2711 .max_inline_actions(4);
2712
2713 let entries = view.overflow_entries();
2714 assert_eq!(entries.len(), 2, "two actions past the budget");
2715 let labels: Vec<String> = entries
2716 .iter()
2717 .map(|(node, _)| match node {
2718 MenuNode::Entry(entry) => entry.label().to_owned(),
2719 _ => unreachable!("an overflowed action is always a plain entry"),
2720 })
2721 .collect();
2722 assert_eq!(labels, vec!["five".to_owned(), "six".to_owned()]);
2723
2724 // Activating row `k` runs the action it was split from.
2725 let mut log: Vec<String> = Vec::new();
2726 entries[1].1(&mut log);
2727 assert_eq!(log, vec!["six".to_owned()]);
2728 }
2729
2730 #[test]
2731 fn an_overflowed_row_falls_back_through_the_reference_label_chain() {
2732 let bare = toolbar_action::<(), _>(crate::icons::MORE_VERT, |_| {});
2733 assert_eq!(bare.menu_label(3), "Action 4");
2734 assert_eq!(
2735 toolbar_action::<(), _>(crate::icons::MORE_VERT, |_| {})
2736 .tooltip("Tip")
2737 .menu_label(0),
2738 "Tip"
2739 );
2740 assert_eq!(
2741 toolbar_action::<(), _>(crate::icons::MORE_VERT, |_| {})
2742 .tooltip("Tip")
2743 .label("Label")
2744 .menu_label(0),
2745 "Label"
2746 );
2747 assert_eq!(
2748 toolbar_action::<(), _>(crate::icons::MORE_VERT, |_| {})
2749 .semantic_label("Named")
2750 .menu_label(0),
2751 "Named"
2752 );
2753 }
2754
2755 #[test]
2756 fn the_overflow_trigger_joins_the_row_only_once_something_overflows() {
2757 let fits: ToolbarView<Vec<String>> =
2758 floating_toolbar().actions(vec![action("one"), action("two")]);
2759 let mut w = build_any(&fits);
2760 assert_eq!(w.center_count, 2, "no trigger while everything fits");
2761
2762 let spills: ToolbarView<Vec<String>> = floating_toolbar().actions(vec![
2763 action("one"),
2764 action("two"),
2765 action("three"),
2766 action("four"),
2767 action("five"),
2768 ]);
2769 w = build_any(&spills);
2770 assert_eq!(w.center_count, 5, "four inline actions plus the trigger");
2771 assert_eq!(w.slots.len(), 5);
2772 }
2773
2774 fn build_any<State: 'static>(view: &ToolbarView<State>) -> ToolbarWidget {
2775 let mut counter = 0u64;
2776 View::<State>::build(view, &mut ctx(&mut counter))
2777 }
2778
2779 // ---- Paint -------------------------------------------------------------
2780
2781 #[test]
2782 fn unthemed_paint_uses_the_standard_fallback_container() {
2783 let view: ToolbarView<()> = docked_toolbar();
2784 let mut w = build(&view);
2785 layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 100.0)));
2786 let mut scene = RRectRecorder::default();
2787 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(300.0, TOOLBAR_HEIGHT));
2788 w.paint(&mut pctx, &mut scene);
2789 assert_eq!(scene.rrects[0].1, Size::new(300.0, TOOLBAR_HEIGHT));
2790 assert_eq!(scene.rrects[0].3, SURFACE_CONTAINER);
2791 assert!(
2792 scene.shadows.is_empty(),
2793 "a bar with no fab rests at `elevationNone`"
2794 );
2795 }
2796
2797 #[test]
2798 fn themed_paint_resolves_surface_container() {
2799 let theme = crate::baseline();
2800 let view: ToolbarView<()> = floating_toolbar().leading(vec![leaf_any(24.0, 24.0)]);
2801 let mut w = build(&view);
2802 let size = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 100.0)));
2803
2804 let mut scene = RRectRecorder::default();
2805 let mut pctx = PaintCtx::new(Point::ZERO, size).with_theme(&theme);
2806 w.paint(&mut pctx, &mut scene);
2807 assert_eq!(scene.rrects[0].3, theme.scheme().surface_container);
2808 assert_eq!(
2809 scene.rrects[0].2,
2810 ShapeScale::resolve(theme.shape.full, size.width, size.height)
2811 );
2812 }
2813
2814 #[test]
2815 fn the_vibrant_style_resolves_the_reference_primary_container_pair() {
2816 let theme = crate::baseline();
2817 let standard = ToolbarColors::resolve(Some(&theme), ToolbarColorStyle::Standard);
2818 assert_eq!(standard.container, theme.scheme().surface_container);
2819 assert_eq!(standard.content, theme.scheme().on_surface);
2820 assert_eq!(standard.fab_container, theme.scheme().primary_container);
2821 assert_eq!(standard.fab_content, theme.scheme().on_primary_container);
2822
2823 let vibrant = ToolbarColors::resolve(Some(&theme), ToolbarColorStyle::Vibrant);
2824 assert_eq!(vibrant.container, theme.scheme().primary_container);
2825 assert_eq!(vibrant.content, theme.scheme().on_primary_container);
2826 assert_eq!(vibrant.fab_container, theme.scheme().tertiary_container);
2827 assert_eq!(vibrant.fab_content, theme.scheme().on_tertiary_container);
2828
2829 // Unthemed, the same four roles resolve to their baseline literals.
2830 let unthemed = ToolbarColors::resolve(None, ToolbarColorStyle::Vibrant);
2831 assert_eq!(unthemed.container, PRIMARY_CONTAINER);
2832 assert_eq!(unthemed.fab_container, TERTIARY_CONTAINER);
2833
2834 let view: ToolbarView<()> = docked_toolbar().color_style(ToolbarColorStyle::Vibrant);
2835 let mut w = build(&view);
2836 let size = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 100.0)));
2837 let mut scene = RRectRecorder::default();
2838 let mut pctx = PaintCtx::new(Point::ZERO, size).with_theme(&theme);
2839 w.paint(&mut pctx, &mut scene);
2840 assert_eq!(scene.rrects[0].3, theme.scheme().primary_container);
2841 }
2842
2843 #[test]
2844 fn the_elevation_pair_follows_the_reference_rule_with_an_explicit_override() {
2845 // No fab: `elevationNone`, i.e. no shadow at all.
2846 let plain: ToolbarView<()> = floating_toolbar();
2847 assert_eq!(plain.resolved_elevation(), ToolbarElevation::None);
2848 assert!(resolve_shadow(None, ToolbarElevation::None).is_none());
2849
2850 // With a fab: `elevationWithFabExpanded`, i.e. M3 level 1.
2851 let with_fab: ToolbarView<()> = floating_toolbar().fab(leaf_any(40.0, 40.0));
2852 assert_eq!(with_fab.resolved_elevation(), ToolbarElevation::Level1);
2853 let (blur, y, _) =
2854 resolve_shadow(None, ToolbarElevation::Level1).expect("a level-1 shadow");
2855 assert_eq!((blur, y), (LEVEL1_SHADOW_BLUR, LEVEL1_SHADOW_Y_OFFSET));
2856
2857 // The explicit override wins over both.
2858 let pinned: ToolbarView<()> = floating_toolbar()
2859 .fab(leaf_any(40.0, 40.0))
2860 .elevation(ToolbarElevation::Level2);
2861 assert_eq!(pinned.resolved_elevation(), ToolbarElevation::Level2);
2862 let (blur, y, _) =
2863 resolve_shadow(None, ToolbarElevation::Level2).expect("a level-2 shadow");
2864 assert_eq!((blur, y), (LEVEL2_SHADOW_BLUR, LEVEL2_SHADOW_Y_OFFSET));
2865
2866 let theme = crate::baseline();
2867 let (blur, y, _) =
2868 resolve_shadow(Some(&theme), ToolbarElevation::Level1).expect("a themed shadow");
2869 let spec = theme.elevation.level1.shadow(theme.brightness);
2870 assert_eq!((blur, y), (spec.blur_std_dev, spec.y_offset));
2871 }
2872
2873 #[test]
2874 fn a_revealing_pill_is_clipped_and_faded_while_the_fab_stays_opaque() {
2875 let view: ToolbarView<()> = floating_toolbar()
2876 .leading(vec![leaf_any(24.0, 24.0)])
2877 .fab(leaf_any(40.0, 40.0))
2878 .expanded(false);
2879 let mut w = build(&view);
2880 layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
2881
2882 let mut scene = RRectRecorder::default();
2883 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(200.0, TOOLBAR_FAB_MEDIUM));
2884 w.paint(&mut pctx, &mut scene);
2885 assert_eq!(scene.clips, 1, "the pill paints inside a reveal clip");
2886 assert_eq!(
2887 scene.layers,
2888 vec![0.0],
2889 "collapsed, the pill is fully faded out"
2890 );
2891
2892 // The fade only starts halfway through the ramp — upstream's own
2893 // `Interval(0.5, 1, easeIn)`.
2894 assert_eq!(ToolbarMorph::reveal_alpha(0.0), 0.0);
2895 assert_eq!(ToolbarMorph::reveal_alpha(0.5), 0.0);
2896 assert_eq!(ToolbarMorph::reveal_alpha(1.0), 1.0);
2897 assert!(ToolbarMorph::reveal_alpha(0.75) > 0.0);
2898 }
2899
2900 // ---- Semantics + rebuild ----------------------------------------------
2901
2902 #[test]
2903 fn semantics_node_is_toolbar_forwarding_every_slot() {
2904 // A leaf without its own `semantics()` override (e.g. `leaf_any`)
2905 // contributes no node — use `Text` children instead, so the
2906 // "forwards every slot" assertion is meaningful.
2907 fn logic(_state: &mut ()) -> ToolbarView<()> {
2908 docked_toolbar()
2909 .semantic_label("Editing")
2910 .leading(vec![frust::authoring::any::<(), _>(frust::text("Leading"))])
2911 .trailing(vec![frust::authoring::any::<(), _>(frust::text(
2912 "Trailing",
2913 ))])
2914 }
2915 let mut root: frust_core::RenderRoot<(), ToolbarView<()>> = frust_core::RenderRoot::new();
2916 let mut state = ();
2917 root.rebuild(&mut logic, &mut state);
2918 let mut tcx = frust::authoring::text::TextContext::new();
2919 root.layout_with_text(
2920 Size::new(300.0, TOOLBAR_HEIGHT),
2921 &mut tcx as &mut dyn std::any::Any,
2922 );
2923 let update = root.semantics();
2924 let (_, node) = update
2925 .nodes
2926 .iter()
2927 .find(|(_, n)| n.role() == Role::Toolbar)
2928 .expect("a Toolbar node is contributed");
2929 assert_eq!(node.children().len(), 2, "leading + trailing forwarded");
2930 assert_eq!(node.label(), Some("Editing"));
2931 }
2932
2933 #[test]
2934 fn rebuild_adopts_new_slot_counts() {
2935 let mut counter = 0u64;
2936 let prev: ToolbarView<()> = floating_toolbar().leading(vec![leaf_any(24.0, 24.0)]);
2937 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
2938 assert_eq!(w.leading_count, 1);
2939
2940 let next: ToolbarView<()> = floating_toolbar()
2941 .leading(vec![leaf_any(24.0, 24.0)])
2942 .trailing(vec![leaf_any(24.0, 24.0), leaf_any(24.0, 24.0)]);
2943 let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
2944 assert!(flags.needs_layout());
2945 assert_eq!(w.trailing_count, 2);
2946 assert_eq!(w.slots.len(), 3);
2947 }
2948
2949 #[test]
2950 fn attaching_a_fab_switches_a_floating_bar_onto_the_adjacent_layout() {
2951 let mut counter = 0u64;
2952 let prev: ToolbarView<()> = floating_toolbar().leading(vec![leaf_any(24.0, 24.0)]);
2953 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
2954 let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
2955 assert_eq!(size.height, TOOLBAR_HEIGHT);
2956
2957 let next: ToolbarView<()> = floating_toolbar()
2958 .leading(vec![leaf_any(24.0, 24.0)])
2959 .fab(leaf_any(40.0, 40.0));
2960 let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
2961 assert!(flags.needs_layout());
2962 assert!(w.adjacent_fab);
2963 let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
2964 assert_eq!(size.height, TOOLBAR_FAB_MEDIUM);
2965 }
2966}