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