Skip to main content

frust_material/
toolbar.rs

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