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