Skip to main content

frust_material/
split_button.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// whose `lib/components/split_buttons/` tree (17 files) is itself vendored from
3// m3e_buttons (MIT, © 2026 Mudit Purohit) — `m3e_split_buttons.dart` plus its
4// `components/` (widgets, style, content, segments, menu, gradient), `enums/`,
5// `models/` and `styles/` parts, retrieved 2026-08-19.
6// Porting decisions: upstream's third menu style
7// (`M3ESplitButtonMenuStyle.native`, Flutter's own `showMenu`) is **descoped** —
8// this framework hosts no platform menu to route to; the popup and bottom-sheet
9// styles are both ported. The gradient decoration layers
10// (`m3e_split_button_gradient.dart`), `M3EButtonSize.custom` per-instance
11// measurement overrides, and the multi-select bottom sheet
12// (`M3ESplitButtonSelectionMode.multiple`) are likewise out of scope — see the
13// module docs' Not ported section.
14
15//! The Material 3 Expressive **split button**: a leading action segment joined
16//! to a trailing menu-trigger segment across a hairline gap, reading as one
17//! connected shape.
18//!
19//! [`split_button`] keeps its original four-argument shape — label, the
20//! controlled `open` flag, the leading `on_press` action, and the trailing
21//! `on_open` menu callback — and every reference axis is a chained builder on
22//! top: [`SplitButtonView::size`], [`SplitButtonView::variant`],
23//! [`SplitButtonView::shape`], [`SplitButtonView::items`],
24//! [`SplitButtonView::menu_style`].
25//!
26//! # Two segments, two focus targets
27//!
28//! The reference builds the halves as two independent `Focus` nodes inside one
29//! `FocusTraversalGroup`, each with its own `InkWell`, its own
30//! `canRequestFocus`/`skipTraversal` gate and its own key handler
31//! (`m3e_split_button_segments.dart:100` and `:335`). This port matches that
32//! structurally: each half is a real child widget in its own pod, so it is its
33//! own **hit target**, its own **focus target** (a `Key` event routes down the
34//! focus path to exactly one of them), its own **semantics node**, and
35//! separately disable-able — [`SplitButtonView::enabled`] gates the whole
36//! control (the reference's `IgnorePointer(ignoring: !enabled)`) while
37//! [`SplitButtonView::trailing_enabled`] disables the menu trigger alone,
38//! making real the `leadingEnabled`/`trailingEnabled` split the reference
39//! already carries as two distinct variables fed from one prop
40//! (`m3e_split_button_content.dart:212`-`:213`).
41//!
42//! **Honest limitation, the same one [`mod@crate::button`] records:** frust has
43//! no keyboard focus *traversal* — nothing hands a button focus, and neither
44//! segment claims focus on a press (a tap-shows-a-focus-ring button is the
45//! wrong behavior on touch). The focus ring, the focus-widened gap and the
46//! Enter/Space activation below are ported, gated on the authoritative focus
47//! reads and unit-tested by seeding a pod's recorded focus path directly; they
48//! become live the moment focus routing can reach a button.
49//!
50//! # Sizes
51//!
52//! Segment height / trailing width / leading paddings / icon, from
53//! `M3ESplitButtonTheme`'s tables (`m3e_split_button_theme.dart:76`-`:210`),
54//! with the label type role from `_LeadingContent`
55//! (`m3e_split_button_widgets.dart:31`):
56//!
57//! | Size | Height | Trailing width | Left pad | Label right pad | Icon | Label role |
58//! |---|---|---|---|---|---|---|
59//! | [`SplitButtonSize::Xs`] | 32 | 48 | 12 | 10 | 20 | `label_small` |
60//! | [`SplitButtonSize::Sm`] (default) | 40 | 48 | 16 | 12 | 24 | `label_medium` |
61//! | [`SplitButtonSize::Md`] | 56 | 56 | 24 | 24 | 24 | `label_large` |
62//! | [`SplitButtonSize::Lg`] | 96 | 96 | 48 | 48 | 32 | `title_medium` |
63//! | [`SplitButtonSize::Xl`] | 136 | 136 | 64 | 64 | 40 | `title_large` |
64//!
65//! The trailing width column is the reference's own three-way resolution
66//! (unselected = left inner pad + icon + right outer pad; selected = side pad ×
67//! 2 + icon; circle = the segment height), floored at
68//! [`SPLIT_MIN_TAP_TARGET`] — all three agree for every size, which
69//! `the_three_trailing_width_formulas_agree` pins rather than assumes.
70//!
71//! # Variants
72//!
73//! Container / content roles are the plain button's
74//! (`m3e_button_theme.dart:111`/`:127`, reached through
75//! `_resolveColorsAndShapes`): [`SplitButtonVariant::Filled`] `primary` /
76//! `on_primary`, [`SplitButtonVariant::Tonal`] `secondary_container` /
77//! `on_secondary_container`, [`SplitButtonVariant::Elevated`]
78//! `surface_container_low` / `primary` (plus a shadow),
79//! [`SplitButtonVariant::Outlined`] transparent + `outline` hairline /
80//! `primary`. There is no `Text` variant: the reference asserts it away
81//! (`m3e_split_buttons.dart:76`).
82//!
83//! # Connected geometry and the spring clip
84//!
85//! Each segment resolves **four independent corner radii** and springs to
86//! them, then clips its own content to the animating shape — the reference's
87//! `M3ERadiusAndPaddingMotion` feeding a `Material(clipBehavior: antiAlias,
88//! borderRadius: animatedRadius)` (`m3e_split_button_segments.dart:235`,
89//! `:330`). The outer (world-facing) corners take the shape family's radius;
90//! the two inner (adjacent) corners resolve per state:
91//!
92//! | State | Leading inner | Trailing inner | Trailing outer |
93//! |---|---|---|---|
94//! | resting | `inner` | `inner` | `outer` |
95//! | hovered | `hovered` | `hovered` | `outer` |
96//! | pressed | `pressed` | `pressed` | `outer` |
97//! | menu open | — | `height / 2` | `height / 2` |
98//!
99//! `_leadingRadii`/`_trailingRadii` (`m3e_split_button_style.dart:166`/`:182`)
100//! in precedence order — the open (`selected`) state outranks pressed, which
101//! outranks hovered. The per-size inner column (4/4/4/8/12) lands exactly on
102//! this crate's `extra_small`/`small`/`medium` shape tokens and therefore
103//! resolves from the live [`frust::Theme`]; the hovered (8/12/12/20/20),
104//! pressed (2/2/2/4/6) and square-outer (8/10/14/24/34) columns have no
105//! whole-table token to resolve from and stay literal — the same
106//! partial-coincidence rule [`mod@crate::toggle_button`] applies.
107//!
108//! The spring is [`SPLIT_SHAPE_SPRING`] — stiffness 380, damping ratio 0.55,
109//! the `expressiveSpatialPress` preset every button in this family defaults to
110//! (`m3e_base_button_state.dart:183`), i.e.
111//! [`crate::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS`]. It is a named constant
112//! rather than a theme read because a press starts in the event pass, which
113//! never threads a theme; `the_shape_spring_matches_expressive_spatial_press`
114//! is the tripwire. [`ShapeMotion`] carries the retarget contract
115//! [`mod@crate::button`]'s `RadiusPaddingMotion` documents — mount-frame snap,
116//! a 0.1px dead band, mid-flight continuity from the value being painted right
117//! now, and a 1.5× overshoot cap — re-pinned here directly, since that type is
118//! private to a sibling module.
119//!
120//! # The chevron: a tween, not a spring
121//!
122//! Only the *clip* springs. The chevron itself is an `AnimatedRotation`
123//! (`m3e_split_button_segments.dart:276`): [`CHEVRON_OPEN_TURNS`] (half a
124//! turn) over [`CHEVRON_ROTATION_DURATION`] (120ms) on `Curve::EaseOut`,
125//! transcribed as a duration-driven [`frust::AnimationController`] rather than
126//! a fling. It also carries a per-size optical nudge
127//! ([`SplitButtonTrailingAlignment::OpticalCenter`], the default) that drops to
128//! zero while the menu is open, exactly as `_computeTrailingGeometry` does.
129//!
130//! # Menu styles
131//!
132//! [`SplitButtonMenuStyle`] is the ported half of the reference's three-way
133//! `M3ESplitButtonMenuStyle`, and [`SplitButtonView::menu_route`] is the
134//! selector: it hands back the presentation this button's style calls for,
135//! built from the same [`SplitButtonView::items`] list.
136//!
137//! * [`SplitButtonMenuStyle::Popup`] → [`SplitButtonMenuRoute::Popup`], a
138//!   [`crate::menu()`] under [`mod@crate::overlay::anchored`], anchored to the
139//!   **trailing segment** at the reference's `bottomStart` + 4dp
140//!   `popupOffset`. Like [`crate::button_group::ButtonGroupView::overflow_menu`],
141//!   this is a second, app-mounted view (the top of the app's own
142//!   [`frust::Stack`], or a transparent navigator page): the trigger paints
143//!   inside this widget's own box and a leaf widget has no portal to reach out
144//!   through. Wire [`SplitButtonView::menu_anchor`] so it has a rect to place
145//!   against.
146//! * [`SplitButtonMenuStyle::BottomSheet`] → [`SplitButtonMenuRoute::Sheet`], a
147//!   [`crate::bottom_sheet`] over the same items, to hand to
148//!   [`crate::show_bottom_sheet`] — which needs a
149//!   [`frust::NavigatorController`] a leaf widget cannot reach, so presentation
150//!   stays the app's exactly as it does for `button_group`'s sheet-style
151//!   overflow. The staged back-press exit and the scrim are the sheet tier's,
152//!   not this module's.
153//! * `native` is **descoped** (plan decision): Flutter's `showMenu` is a
154//!   platform-menu route with no analogue here, and the popup style already
155//!   covers every case it served.
156//!
157//! Both routes render the item list through the one item-list surface this
158//! catalog owns ([`crate::menu::menu_panel`]) rather than a second row
159//! renderer — a porting decision, since the reference's sheet rows
160//! (`m3e_split_button_bottom_sheet.dart:105`) are a near-duplicate of its own
161//! menu rows.
162//!
163//! # Not ported
164//!
165//! `M3ESplitButtonDecoration`'s gradient layers (the plain button's
166//! [`crate::ButtonDecoration`] seam is where that belongs), `M3EButtonSize.custom`,
167//! the multi-select bottom sheet and its checkbox style, `menuBuilder`/
168//! `m3eMenuBuilder` (an app composes [`crate::menu()`] itself), tooltips and
169//! `onLongPress`. Each is either a seam this catalog already owns elsewhere or
170//! a Flutter-plumbing concept with no frust analogue — the same mapping table
171//! [`mod@crate::button`] records.
172//!
173//! # Attribution
174//!
175//! See `plugins/material/NOTICE`'s "MIT License — Additional Copyright Holders
176//! (Vendored Components)" section (Mudit Purohit / m3e_buttons) and its Module
177//! Attribution Header Convention.
178
179use std::rc::Rc;
180use std::time::Duration;
181
182use frust::authoring::text::{
183    FontWeight, LineHeight, TextContext, TextLayout, TextOverflow, TextStyle,
184};
185use frust::authoring::{
186    Action, AnyView, BoxConstraints, Brush, BuildCtx, ChangeFlags, ChildPod, Color, CornerRadii,
187    CursorIcon, ErasedCallback, EventCtx, EventResult, InputEvent, Key, LayoutCtx, NamedKey,
188    PaintCtx, PaintScene, Point, PointerPhase, Rect, Role, SemanticsCtx, Size, View, Widget, any,
189    build_child, erase_callback, rebuild_child, route_event, teardown_child, visit_children,
190};
191use frust::{AnimationController, Curve, FrameTime, IconData, SpringDesc, Theme};
192use kurbo::{Affine, BezPath, RoundedRect, RoundedRectRadii, Shape, Vec2};
193
194use crate::icons;
195use crate::interaction::{
196    DISABLED_CONTAINER_OPACITY, DISABLED_CONTENT_OPACITY, HapticSignal, InteractionState,
197    MaterialHaptics,
198};
199use crate::menu::{MenuIcon, MenuNode, MenuSelection, menu, menu_panel, menu_selectable};
200use crate::overlay::OverlayAnchor;
201use crate::press::presses;
202use crate::sheet::{BottomSheetView, bottom_sheet};
203
204// ---- Theme defaults (m3e_split_button_theme.dart's constructor) ------------
205
206/// The minimum tap target the control as a whole reserves, in logical px —
207/// `M3ESplitButtonTheme.minTapTarget` (`:13`). A segment shorter than this
208/// (the `xs`/`sm` rows) is centred inside it rather than stretched.
209pub const SPLIT_MIN_TAP_TARGET: f64 = 48.0;
210
211/// The gap between the two segments, in logical px — `innerGap` (`:14`).
212pub const SPLIT_INNER_GAP: f64 = 2.0;
213
214/// The gap an [`SplitButtonVariant::Elevated`] split button takes instead, in
215/// logical px — `elevatedInnerGap` (`:15`), widened so two shadows do not
216/// merge.
217pub const SPLIT_ELEVATED_INNER_GAP: f64 = 4.0;
218
219/// How far the chevron rotates when the menu opens, in turns —
220/// `chevronOpenTurns` (`:16`).
221pub const CHEVRON_OPEN_TURNS: f64 = 0.5;
222
223/// The open trailing segment's corner radius as a percentage of its height —
224/// `trailingInnerSelectedCornerPercent` (`:17`), i.e. a pill.
225pub const TRAILING_SELECTED_CORNER_PERCENT: f64 = 50.0;
226
227/// Stroke width of the focus ring, in logical px —
228/// `M3EButtonConstants.kFocusRingWidth`.
229const FOCUS_RING_WIDTH: f64 = 2.0;
230
231/// Gap between a segment's edge and its focus ring, in logical px —
232/// `M3EButtonConstants.kFocusRingGap`.
233const FOCUS_RING_GAP: f64 = 2.0;
234
235/// How far a focus ring extends past its segment, in logical px — the
236/// `kFocusRingGap + kFocusRingWidth` sum `_computeSegmentGeometry` adds to the
237/// inner gap while either half is focused (`m3e_split_button_content.dart:296`).
238pub const SPLIT_FOCUS_RING_OUTSET: f64 = FOCUS_RING_GAP + FOCUS_RING_WIDTH;
239
240/// The chevron rotation's duration — `AnimatedRotation(duration: 120ms)`
241/// (`m3e_split_button_segments.dart:277`). A tween, not a spring; see the
242/// [module docs](self).
243pub const CHEVRON_ROTATION_DURATION: Duration = Duration::from_millis(120);
244
245/// The chevron rotation's easing — `Curves.easeOut` (`:278`).
246const CHEVRON_ROTATION_CURVE: Curve = Curve::EaseOut;
247
248/// The segment shape (clip) spring: stiffness 380, damping ratio 0.55, mass 1 —
249/// `expressiveSpatialPress`, i.e.
250/// [`crate::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS`]. See the
251/// [module docs](self) for why it is a constant rather than a theme read.
252pub const SPLIT_SHAPE_SPRING: SpringDesc = SpringDesc {
253    mass: 1.0,
254    stiffness: 380.0,
255    damping_ratio: 0.55,
256};
257
258/// Nominal period seeding a morph [`AnimationController`]'s clock; the motion is
259/// spring-driven via `fling`, so this backs construction only (mirrors
260/// `button::motion::PRESS_ANIM_PERIOD`).
261const SHAPE_ANIM_PERIOD: Duration = Duration::from_millis(300);
262
263/// Launch velocity (progress-units/sec) for each shape leg's `fling` — the same
264/// modest kick every spring in this crate uses.
265const FLING_VELOCITY: f64 = 4.0;
266
267/// The dead band (logical px, per corner) a new target must exceed before it
268/// starts a new spring leg — the reference's `kSpringRetargetTolerance`
269/// (`m3e_radius_and_padding_motion.dart:18`).
270const RETARGET_TOLERANCE: f64 = 0.1;
271
272/// How far past its target the spring's progress may read — the reference's
273/// `rawFactor.clamp(0.0, 1.5)` (`:403`).
274const OVERSHOOT_LIMIT: f64 = 1.5;
275
276/// Stroke width of an [`SplitButtonVariant::Outlined`] hairline, in logical px.
277const OUTLINE_WIDTH: f64 = 1.0;
278
279/// Flattening tolerance for the `kurbo` rounded-rect paths this module strokes
280/// (matches [`mod@crate::button_group`]'s `PATH_TOLERANCE`).
281const PATH_TOLERANCE: f64 = 0.1;
282
283/// The ink every label run is *shaped* with; never painted — see
284/// [`mod@crate::button`]'s `SHAPING_INK` for why (recolor-without-reshape).
285const SHAPING_INK: Color = Color::BLACK;
286
287/// The trailing segment's default accessible name, used when no
288/// [`SplitButtonView::trailing_label`] is set.
289const DEFAULT_TRAILING_LABEL: &str = "Show menu";
290
291// ---- Size / shape / variant vocabulary -------------------------------------
292
293/// One row of the M3 type scale, as `(size, line_height, letter_spacing,
294/// weight)` — the unthemed fallback for a label role.
295type TypeToken = (f32, f32, f32, FontWeight);
296
297const LABEL_SMALL: TypeToken = (11.0, 16.0, 0.5, FontWeight::MEDIUM);
298const LABEL_MEDIUM: TypeToken = (12.0, 16.0, 0.5, FontWeight::MEDIUM);
299const LABEL_LARGE: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
300const TITLE_MEDIUM: TypeToken = (16.0, 24.0, 0.15, FontWeight::MEDIUM);
301const TITLE_LARGE: TypeToken = (22.0, 28.0, 0.0, FontWeight::REGULAR);
302
303/// The split button's size tier — the reference's `M3EButtonSize`
304/// (`m3e_button_enums.dart:64`) as `M3ESplitButtonTheme` measures it. See the
305/// [module docs](self)' Sizes table.
306#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
307pub enum SplitButtonSize {
308    /// 32dp segments.
309    Xs,
310    /// 40dp segments (the default, `M3ESplitButton.size`'s own default).
311    #[default]
312    Sm,
313    /// 56dp segments.
314    Md,
315    /// 96dp segments.
316    Lg,
317    /// 136dp segments.
318    Xl,
319}
320
321impl SplitButtonSize {
322    /// Every size, in table order — the axis a matrix test walks.
323    pub const ALL: [SplitButtonSize; 5] = [
324        SplitButtonSize::Xs,
325        SplitButtonSize::Sm,
326        SplitButtonSize::Md,
327        SplitButtonSize::Lg,
328        SplitButtonSize::Xl,
329    ];
330
331    /// A segment's height, in logical px — `_splitHeight` (`:76`).
332    pub fn height(self) -> f64 {
333        match self {
334            SplitButtonSize::Xs => 32.0,
335            SplitButtonSize::Sm => 40.0,
336            SplitButtonSize::Md => 56.0,
337            SplitButtonSize::Lg => 96.0,
338            SplitButtonSize::Xl => 136.0,
339        }
340    }
341
342    /// The chevron's side length, in logical px — `_splitTrailingWidth`
343    /// (`:84`), which `splitTrailingIconSize` reads as the icon size.
344    fn trailing_icon_size(self) -> f64 {
345        match self {
346            SplitButtonSize::Xs | SplitButtonSize::Sm => 22.0,
347            SplitButtonSize::Md => 26.0,
348            SplitButtonSize::Lg => 38.0,
349            SplitButtonSize::Xl => 50.0,
350        }
351    }
352
353    /// The leading segment's icon side length, in logical px — `_splitIcon`
354    /// (`:124`).
355    fn icon_size(self) -> f64 {
356        match self {
357            SplitButtonSize::Xs => 20.0,
358            SplitButtonSize::Sm | SplitButtonSize::Md => 24.0,
359            SplitButtonSize::Lg => 32.0,
360            SplitButtonSize::Xl => 40.0,
361        }
362    }
363
364    /// The leading segment's outer (world-facing) padding, in logical px —
365    /// `_splitLeftOuterPadding` (`:164`).
366    fn left_outer_padding(self) -> f64 {
367        match self {
368            SplitButtonSize::Xs => 12.0,
369            SplitButtonSize::Sm => 16.0,
370            SplitButtonSize::Md => 24.0,
371            SplitButtonSize::Lg => 48.0,
372            SplitButtonSize::Xl => 64.0,
373        }
374    }
375
376    /// The gap between the leading icon and its label, in logical px —
377    /// `_splitGapIconToLabel` (`:172`).
378    fn icon_to_label_gap(self) -> f64 {
379        match self {
380            SplitButtonSize::Xs => 4.0,
381            SplitButtonSize::Sm | SplitButtonSize::Md => 8.0,
382            SplitButtonSize::Lg => 12.0,
383            SplitButtonSize::Xl => 16.0,
384        }
385    }
386
387    /// The leading segment's inner (gap-facing) padding, in logical px —
388    /// `_splitLabelRightPadding` (`:180`).
389    fn label_right_padding(self) -> f64 {
390        match self {
391            SplitButtonSize::Xs => 10.0,
392            SplitButtonSize::Sm => 12.0,
393            SplitButtonSize::Md => 24.0,
394            SplitButtonSize::Lg => 48.0,
395            SplitButtonSize::Xl => 64.0,
396        }
397    }
398
399    /// The trailing segment's inner (gap-facing) padding, in logical px —
400    /// `_splitTrailingLeftInnerPadding` (`:188`).
401    fn trailing_left_padding(self) -> f64 {
402        match self {
403            SplitButtonSize::Xs | SplitButtonSize::Sm => 13.0,
404            SplitButtonSize::Md => 15.0,
405            SplitButtonSize::Lg => 29.0,
406            SplitButtonSize::Xl => 43.0,
407        }
408    }
409
410    /// The trailing segment's outer (world-facing) padding, in logical px —
411    /// `_splitRightOuterPadding` (`:196`).
412    fn right_outer_padding(self) -> f64 {
413        self.trailing_left_padding()
414    }
415
416    /// The trailing segment's side padding while the menu is open, in logical
417    /// px — `_splitSidePaddingSelected` (`:204`).
418    fn side_padding_selected(self) -> f64 {
419        self.trailing_left_padding()
420    }
421
422    /// The closed trailing segment's laid-out width, in logical px — see
423    /// [`Self::trailing_width_when`], of which this is the resting case. All
424    /// three of the reference's formulas agree for every size, so the trailing
425    /// half never changes width when the menu opens.
426    pub fn trailing_width(self) -> f64 {
427        self.trailing_width_when(false, false)
428    }
429
430    /// The reference's three-way trailing-width resolution
431    /// (`_trailingPadsAndWidth`/`_computeTrailingGeometry`): the segment height
432    /// while collapsed to a circle, the selected width while the menu is open,
433    /// the unselected width otherwise — floored at [`SPLIT_MIN_TAP_TARGET`]
434    /// (`effectiveWidth`, `m3e_split_button_segments.dart:219`).
435    fn trailing_width_when(self, open: bool, circle: bool) -> f64 {
436        let base = if circle {
437            self.height()
438        } else if open {
439            self.trailing_width_selected()
440        } else {
441            self.trailing_width_unselected()
442        };
443        base.max(SPLIT_MIN_TAP_TARGET)
444    }
445
446    /// `trailingWidthUnselected`: inner pad + chevron + outer pad.
447    fn trailing_width_unselected(self) -> f64 {
448        self.trailing_left_padding() + self.trailing_icon_size() + self.right_outer_padding()
449    }
450
451    /// `trailingWidthSelected`: side pad × 2 + chevron.
452    fn trailing_width_selected(self) -> f64 {
453        self.side_padding_selected() * 2.0 + self.trailing_icon_size()
454    }
455
456    /// Whether an open, round trailing segment collapses to a circle —
457    /// `allowCircle` (`_computeTrailingGeometry`, `md`/`lg`/`xl` only).
458    fn allows_circle_trailing(self) -> bool {
459        matches!(
460            self,
461            SplitButtonSize::Md | SplitButtonSize::Lg | SplitButtonSize::Xl
462        )
463    }
464
465    /// The chevron's optical-centre nudge along x, in logical px —
466    /// `_splitMenuIconOffset` (`:116`). Negative: the glyph sits slightly
467    /// leading-of-centre while the menu is closed.
468    fn menu_icon_offset(self) -> f64 {
469        match self {
470            SplitButtonSize::Xs | SplitButtonSize::Sm => -1.0,
471            SplitButtonSize::Md => -2.0,
472            SplitButtonSize::Lg => -3.0,
473            SplitButtonSize::Xl => -6.0,
474        }
475    }
476
477    /// The resting inner-corner radius — `_splitInnerCornerRadius` (`:92`:
478    /// 4/4/4/8/12). The table lands exactly on the `extra_small`/`small`/
479    /// `medium` shape tokens, so a themed pass resolves it from the live
480    /// [`frust::Theme`] and the literals are only the unthemed fallback.
481    fn inner_radius(self, theme: Option<&Theme>) -> f64 {
482        match theme {
483            Some(theme) => match self {
484                SplitButtonSize::Xs | SplitButtonSize::Sm | SplitButtonSize::Md => {
485                    theme.shape.extra_small
486                }
487                SplitButtonSize::Lg => theme.shape.small,
488                SplitButtonSize::Xl => theme.shape.medium,
489            },
490            None => match self {
491                SplitButtonSize::Xs | SplitButtonSize::Sm | SplitButtonSize::Md => 4.0,
492                SplitButtonSize::Lg => 8.0,
493                SplitButtonSize::Xl => 12.0,
494            },
495        }
496    }
497
498    /// The hovered inner-corner radius — `_splitHoveredInnerCornerRadius`
499    /// (`:100`: 8/12/12/20/20). No whole-table shape-scale coincidence, so this
500    /// stays literal (the [module docs](self)' partial-coincidence rule).
501    fn hovered_inner_radius(self) -> f64 {
502        match self {
503            SplitButtonSize::Xs => 8.0,
504            SplitButtonSize::Sm | SplitButtonSize::Md => 12.0,
505            SplitButtonSize::Lg | SplitButtonSize::Xl => 20.0,
506        }
507    }
508
509    /// The pressed corner radius — `_splitPressedRadius` (`:148`:
510    /// 2/2/2/4/6). Literal: this crate's shape scale has no 2dp rung.
511    fn pressed_radius(self) -> f64 {
512        match self {
513            SplitButtonSize::Xs | SplitButtonSize::Sm | SplitButtonSize::Md => 2.0,
514            SplitButtonSize::Lg => 4.0,
515            SplitButtonSize::Xl => 6.0,
516        }
517    }
518
519    /// The outer corner radius under [`SplitButtonShape::Square`] —
520    /// `_splitOuterRadiusSquare` (`:140`: 8/10/14/24/34). Literal, for the same
521    /// partial-coincidence reason as the hovered column.
522    fn square_outer_radius(self) -> f64 {
523        match self {
524            SplitButtonSize::Xs => 8.0,
525            SplitButtonSize::Sm => 10.0,
526            SplitButtonSize::Md => 14.0,
527            SplitButtonSize::Lg => 24.0,
528            SplitButtonSize::Xl => 34.0,
529        }
530    }
531
532    /// The label's type role — `_LeadingContent`'s per-size switch
533    /// (`m3e_split_button_widgets.dart:31`). Themed from [`frust::Theme`]'s
534    /// type scale; unthemed from the M3 token literals above. The returned
535    /// style always carries [`SHAPING_INK`].
536    fn label_style(self, theme: Option<&Theme>) -> TextStyle {
537        let mut style = match theme {
538            Some(theme) => match self {
539                SplitButtonSize::Xs => theme.type_scale.label_small.clone(),
540                SplitButtonSize::Sm => theme.type_scale.label_medium.clone(),
541                SplitButtonSize::Md => theme.type_scale.label_large.clone(),
542                SplitButtonSize::Lg => theme.type_scale.title_medium.clone(),
543                SplitButtonSize::Xl => theme.type_scale.title_large.clone(),
544            },
545            None => {
546                let (size, line_height, letter_spacing, weight) = match self {
547                    SplitButtonSize::Xs => LABEL_SMALL,
548                    SplitButtonSize::Sm => LABEL_MEDIUM,
549                    SplitButtonSize::Md => LABEL_LARGE,
550                    SplitButtonSize::Lg => TITLE_MEDIUM,
551                    SplitButtonSize::Xl => TITLE_LARGE,
552                };
553                let mut style = TextStyle::new(size, SHAPING_INK);
554                style.line_height = LineHeight::Absolute(line_height);
555                style.letter_spacing = letter_spacing;
556                style.weight = weight;
557                style
558            }
559        };
560        style.color = SHAPING_INK;
561        style
562    }
563}
564
565/// The split button's corner-radius family — the reference's `M3EButtonShape`
566/// (`m3e_button_enums.dart:45`) as `_computeRadii` consumes it.
567#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
568pub enum SplitButtonShape {
569    /// A pill: the segment's `height / 2` (the default).
570    #[default]
571    Round,
572    /// The per-size token radius ([`SplitButtonSize::square_outer_radius`]).
573    Square,
574}
575
576/// The split button's container treatment — the reference's `M3EButtonStyle`
577/// minus `text`, which `M3ESplitButton` asserts away
578/// (`m3e_split_buttons.dart:76`). See the [module docs](self)' Variants
579/// section.
580#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
581pub enum SplitButtonVariant {
582    /// Solid `primary` container, highest emphasis (the default).
583    #[default]
584    Filled,
585    /// `secondary_container` container, medium emphasis.
586    Tonal,
587    /// `surface_container_low` container that carries a shadow.
588    Elevated,
589    /// Transparent container with an `outline` hairline.
590    Outlined,
591}
592
593impl SplitButtonVariant {
594    /// Every variant, in table order — the axis a matrix test walks.
595    pub const ALL: [SplitButtonVariant; 4] = [
596        SplitButtonVariant::Filled,
597        SplitButtonVariant::Tonal,
598        SplitButtonVariant::Elevated,
599        SplitButtonVariant::Outlined,
600    ];
601}
602
603/// How the trailing chevron is centred — the reference's
604/// `M3ESplitButtonTrailingAlignment`.
605#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
606pub enum SplitButtonTrailingAlignment {
607    /// Nudge the glyph by [`SplitButtonSize::menu_icon_offset`] while the menu
608    /// is closed (the default).
609    #[default]
610    OpticalCenter,
611    /// Sit on the geometric centre in every state.
612    GeometricCenter,
613}
614
615/// Which presentation the trailing trigger's menu takes — the ported half of
616/// the reference's `M3ESplitButtonMenuStyle`. See the [module docs](self)'
617/// Menu styles section (the third, `native`, is descoped).
618#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
619pub enum SplitButtonMenuStyle {
620    /// An anchored popup menu under the trailing segment (the default).
621    #[default]
622    Popup,
623    /// A modal bottom sheet listing the same items.
624    BottomSheet,
625}
626
627// ---- Items ------------------------------------------------------------------
628
629/// One selectable entry in a split button's menu — the reference's
630/// `M3ESplitButtonItem` (`models/m3e_split_button_item.dart`).
631///
632/// `value` is what a selection reports back; it defaults to the label, which is
633/// what `_splitItemToMenuNode`'s `item.child.toString()` fallback amounts to
634/// for the text-child case this port supports.
635#[derive(Clone, Debug, PartialEq)]
636pub struct SplitButtonItem {
637    label: String,
638    value: Option<String>,
639    enabled: bool,
640    leading: Option<MenuIcon>,
641}
642
643/// Create a menu item labelled `label`.
644pub fn split_button_item(label: impl Into<String>) -> SplitButtonItem {
645    SplitButtonItem {
646        label: label.into(),
647        value: None,
648        enabled: true,
649        leading: None,
650    }
651}
652
653impl SplitButtonItem {
654    /// Set the value a selection of this row reports (defaults to the label).
655    pub fn value(mut self, value: impl Into<String>) -> Self {
656        self.value = Some(value.into());
657        self
658    }
659
660    /// Whether the row can be activated (`M3ESplitButtonItem.enabled`).
661    pub fn enabled(mut self, enabled: bool) -> Self {
662        self.enabled = enabled;
663        self
664    }
665
666    /// Attach a leading glyph (`_splitItemToMenuNode`'s `IconData` child arm).
667    pub fn leading(mut self, icon: impl Into<MenuIcon>) -> Self {
668        self.leading = Some(icon.into());
669        self
670    }
671
672    /// The row's label.
673    pub fn label(&self) -> &str {
674        &self.label
675    }
676
677    /// The value this row reports — its own, or its label.
678    pub fn resolved_value(&self) -> &str {
679        self.value.as_deref().unwrap_or(&self.label)
680    }
681
682    /// This item as a menu node — the port of `_splitItemToMenuNode`
683    /// (`m3e_split_button_menu.dart:134`), which maps every item onto an
684    /// `M3EMenuSelectable`.
685    fn to_menu_node(&self) -> MenuNode {
686        let mut node = menu_selectable(self.label.clone(), self.resolved_value().to_string())
687            .enabled(self.enabled);
688        if let Some(icon) = self.leading {
689            node = node.leading(icon);
690        }
691        node.into()
692    }
693}
694
695/// The presentation a split button's [`SplitButtonMenuStyle`] calls for, as
696/// [`SplitButtonView::menu_route`] resolves it. See the [module docs](self)'
697/// Menu styles section for how an app mounts each.
698pub enum SplitButtonMenuRoute<State: 'static> {
699    /// [`SplitButtonMenuStyle::Popup`]: an anchored menu to mount as the top of
700    /// the app's own [`frust::Stack`] (or a transparent navigator page).
701    Popup(AnyView<State>),
702    /// [`SplitButtonMenuStyle::BottomSheet`]: sheet content to hand to
703    /// [`crate::show_bottom_sheet`].
704    Sheet(BottomSheetView<State>),
705}
706
707// ---- Colors -----------------------------------------------------------------
708
709/// Unthemed-fallback `primary`.
710const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
711/// Unthemed-fallback `on_primary`.
712const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
713/// Unthemed-fallback `secondary_container`.
714const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
715/// Unthemed-fallback `on_secondary_container`.
716const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
717/// Unthemed-fallback `surface_container_low`.
718const SURFACE_CONTAINER_LOW: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
719/// Unthemed-fallback `outline`.
720const OUTLINE: Color = Color::from_rgb8(0x79, 0x74, 0x7E);
721/// Unthemed-fallback `on_surface` — the disabled roles' base.
722const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
723/// Unthemed-fallback shadow color (opaque black at `crate::tokens::elevation()`'s
724/// 0.3 alpha).
725const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
726
727/// The resolved inks one segment paints with.
728#[derive(Clone, Copy, Debug, PartialEq)]
729struct SegmentColors {
730    /// The container fill (transparent for an enabled `Outlined` segment).
731    container: Color,
732    /// The label/icon ink, and the state layer's tint.
733    content: Color,
734    /// The hairline, when the variant draws one.
735    outline: Option<Color>,
736    /// The focus ring's stroke (`primary`, `m3e_focus_ring.dart`).
737    focus_ring: Color,
738}
739
740/// `color` with its alpha channel replaced by `alpha`.
741fn with_alpha(color: Color, alpha: f32) -> Color {
742    let c = color.components;
743    Color::new([c[0], c[1], c[2], alpha])
744}
745
746/// Resolve one segment's inks — `_resolveColorsAndShapes`/`_resolveDisabledColors`
747/// (`m3e_split_button_style.dart:102`/`:129`) over the plain button's own
748/// variant table. See the [module docs](self)' Variants section.
749fn resolve_colors(
750    theme: Option<&Theme>,
751    variant: SplitButtonVariant,
752    enabled: bool,
753) -> SegmentColors {
754    let (
755        primary,
756        on_primary,
757        secondary_container,
758        on_secondary_container,
759        container_low,
760        outline,
761        on_surface,
762    ) = match theme {
763        Some(theme) => {
764            let s = theme.scheme();
765            (
766                s.primary,
767                s.on_primary,
768                s.secondary_container,
769                s.on_secondary_container,
770                s.surface_container_low,
771                s.outline,
772                s.on_surface,
773            )
774        }
775        None => (
776            PRIMARY,
777            ON_PRIMARY,
778            SECONDARY_CONTAINER,
779            ON_SECONDARY_CONTAINER,
780            SURFACE_CONTAINER_LOW,
781            OUTLINE,
782            ON_SURFACE,
783        ),
784    };
785    let transparent = variant == SplitButtonVariant::Outlined;
786
787    if !enabled {
788        return SegmentColors {
789            container: if transparent {
790                Color::TRANSPARENT
791            } else {
792                with_alpha(on_surface, DISABLED_CONTAINER_OPACITY)
793            },
794            content: with_alpha(on_surface, DISABLED_CONTENT_OPACITY),
795            outline: transparent.then(|| with_alpha(on_surface, DISABLED_CONTAINER_OPACITY)),
796            focus_ring: primary,
797        };
798    }
799
800    let (container, content) = match variant {
801        SplitButtonVariant::Filled => (primary, on_primary),
802        SplitButtonVariant::Tonal => (secondary_container, on_secondary_container),
803        SplitButtonVariant::Elevated => (container_low, primary),
804        SplitButtonVariant::Outlined => (Color::TRANSPARENT, primary),
805    };
806    SegmentColors {
807        container,
808        content,
809        outline: transparent.then_some(outline),
810        focus_ring: primary,
811    }
812}
813
814/// A segment's container elevation in dp — `_segmentElevation`
815/// (`m3e_split_button_style.dart:195`) reading the plain button's own
816/// elevation table.
817fn elevation_dp(variant: SplitButtonVariant, enabled: bool, pressed: bool, hovered: bool) -> f64 {
818    if !enabled {
819        return 0.0;
820    }
821    match variant {
822        SplitButtonVariant::Elevated => {
823            if pressed {
824                0.0
825            } else if hovered {
826                3.0
827            } else {
828                1.0
829            }
830        }
831        SplitButtonVariant::Filled | SplitButtonVariant::Tonal => {
832            if !pressed && hovered {
833                1.0
834            } else {
835                0.0
836            }
837        }
838        SplitButtonVariant::Outlined => 0.0,
839    }
840}
841
842/// The `(blur_std_dev, y_offset, color)` shadow for an elevation of `dp`, or
843/// `None` for a flat segment — the same M3 elevation-level resolution
844/// [`mod@crate::toggle_button`] performs (duplicated for the same
845/// private-module reason).
846fn resolve_shadow(theme: Option<&Theme>, dp: f64) -> Option<(f64, f64, Color)> {
847    if dp <= 0.0 {
848        return None;
849    }
850    match theme {
851        Some(theme) => {
852            let level = if dp >= theme.elevation.level2.dp {
853                theme.elevation.level2
854            } else {
855                theme.elevation.level1
856            };
857            let shadow = level.shadow(theme.brightness);
858            Some((
859                shadow.blur_std_dev,
860                shadow.y_offset,
861                with_alpha(theme.scheme().shadow, shadow.color_alpha),
862            ))
863        }
864        None => Some((dp, dp / 2.0 + 1.0, FALLBACK_SHADOW_COLOR)),
865    }
866}
867
868// ---- The per-corner shape (clip) spring -------------------------------------
869
870/// Linear interpolation between `from` and `to` at `t` (unclamped — the
871/// spring's overshoot rides past `t = 1`).
872fn lerp(from: f64, to: f64, t: f64) -> f64 {
873    from + (to - from) * t
874}
875
876/// Whether every corner of `a` is within [`RETARGET_TOLERANCE`] of `b`'s.
877fn corners_match(a: CornerRadii, b: CornerRadii) -> bool {
878    (a.top_left - b.top_left).abs() <= RETARGET_TOLERANCE
879        && (a.top_right - b.top_right).abs() <= RETARGET_TOLERANCE
880        && (a.bottom_right - b.bottom_right).abs() <= RETARGET_TOLERANCE
881        && (a.bottom_left - b.bottom_left).abs() <= RETARGET_TOLERANCE
882}
883
884/// Per-corner interpolation of `from` toward `to` at `t`, each corner floored at
885/// `0` (the reference's `v < 0 ? 0.0 : v` corner guard).
886fn lerp_corners(from: CornerRadii, to: CornerRadii, t: f64) -> CornerRadii {
887    CornerRadii::new(
888        lerp(from.top_left, to.top_left, t).max(0.0),
889        lerp(from.top_right, to.top_right, t).max(0.0),
890        lerp(from.bottom_right, to.bottom_right, t).max(0.0),
891        lerp(from.bottom_left, to.bottom_left, t).max(0.0),
892    )
893}
894
895/// A segment's four-corner shape spring — the radius channel of the reference's
896/// `M3ERadiusAndPaddingMotion`. The padding channel is not carried: a split
897/// button's segment paddings are pure functions of its size and never retarget
898/// (`internalLeft`..`internalBottom` are all passed `0`,
899/// `m3e_split_button_segments.dart:32`).
900///
901/// See the [module docs](self) for the retarget/continuity contract this shares
902/// with [`mod@crate::button`]'s `RadiusPaddingMotion`.
903#[derive(Clone, Copy, Debug)]
904struct ShapeMotion {
905    from_radii: CornerRadii,
906    to_radii: CornerRadii,
907    anim: AnimationController,
908    /// Whether a first target has been seeded — the mount frame snaps rather
909    /// than springs.
910    seeded: bool,
911}
912
913impl ShapeMotion {
914    /// An unseeded morph resting at every corner `0`; the first
915    /// [`Self::retarget`] snaps instead of springing.
916    fn new() -> Self {
917        Self {
918            from_radii: CornerRadii::default(),
919            to_radii: CornerRadii::default(),
920            anim: AnimationController::new(SHAPE_ANIM_PERIOD),
921            seeded: false,
922        }
923    }
924
925    /// Aim the morph at `radii`, returning whether this started a new leg.
926    fn retarget(&mut self, radii: CornerRadii) -> bool {
927        if !self.seeded {
928            self.snap_to(radii);
929            return false;
930        }
931        if corners_match(self.to_radii, radii) {
932            return false;
933        }
934        self.from_radii = self.radii();
935        self.to_radii = radii;
936        self.anim = AnimationController::new(SHAPE_ANIM_PERIOD);
937        self.anim.fling(FLING_VELOCITY, SPLIT_SHAPE_SPRING);
938        true
939    }
940
941    /// Pin the morph to `radii` with no motion at all, marking it seeded.
942    fn snap_to(&mut self, radii: CornerRadii) {
943        self.from_radii = radii;
944        self.to_radii = radii;
945        self.anim = AnimationController::new(SHAPE_ANIM_PERIOD);
946        self.seeded = true;
947    }
948
949    /// Advance the spring to frame time `now`, reporting whether it is still
950    /// animating.
951    fn advance(&mut self, now: FrameTime) -> bool {
952        self.anim.advance(now)
953    }
954
955    /// Whether a first target has been seeded.
956    fn is_seeded(&self) -> bool {
957        self.seeded
958    }
959
960    /// The progress factor this frame paints at.
961    fn factor(&self) -> f64 {
962        let raw = self.anim.value();
963        if raw.is_finite() {
964            raw.clamp(0.0, OVERSHOOT_LIMIT)
965        } else {
966            0.0
967        }
968    }
969
970    /// The per-corner radii to paint (and clip to) this frame.
971    fn radii(&self) -> CornerRadii {
972        lerp_corners(self.from_radii, self.to_radii, self.factor())
973    }
974
975    /// The radii the morph is currently springing toward.
976    #[cfg(test)]
977    fn target_radii(&self) -> CornerRadii {
978        self.to_radii
979    }
980}
981
982// ---- Geometry helpers -------------------------------------------------------
983
984/// Whether local point `pos` is inside a widget of `size`.
985fn inside(pos: Point, size: Size) -> bool {
986    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
987}
988
989/// The leading segment's corner radii — `_leadingRadii`
990/// (`m3e_split_button_style.dart:166`): outer on the world-facing (left) side,
991/// the state-resolved inner radius on the gap-facing (right) one.
992///
993/// This framework has no `Directionality`/RTL layout, so — like
994/// [`mod@crate::button_group`]'s `member_radii` — start/end map onto left/right
995/// unconditionally, with no mirrored-in-RTL case to port.
996fn leading_radii(outer: f64, inner: f64) -> CornerRadii {
997    CornerRadii::new(outer, inner, inner, outer)
998}
999
1000/// The trailing segment's corner radii — `_trailingRadii` (`:182`): the
1001/// state-resolved inner radius on the gap-facing (left) side, outer on the
1002/// world-facing (right) one.
1003fn trailing_radii(outer: f64, inner: f64) -> CornerRadii {
1004    CornerRadii::new(inner, outer, outer, inner)
1005}
1006
1007/// The rounded rect to *stroke* for a `width`-wide hairline lying fully inside
1008/// a container of `size` with per-corner `radii` — a stroke is centred on its
1009/// path, so both the rect and every radius pull in by half the width.
1010fn inset_stroke_rrect(size: Size, radii: CornerRadii, width: f64) -> RoundedRect {
1011    let half = width / 2.0;
1012    RoundedRect::from_rect(
1013        Rect::new(half, half, size.width - half, size.height - half),
1014        RoundedRectRadii::new(
1015            (radii.top_left - half).max(0.0),
1016            (radii.top_right - half).max(0.0),
1017            (radii.bottom_right - half).max(0.0),
1018            (radii.bottom_left - half).max(0.0),
1019        ),
1020    )
1021}
1022
1023/// The rounded rect to *stroke* for the focus ring around a segment of `size`
1024/// with per-corner `radii` — [`FOCUS_RING_GAP`] outside the container, with the
1025/// stroke itself centred on that path, so every radius grows by the same
1026/// outset (`m3e_focus_ring.dart`).
1027fn focus_ring_rrect(size: Size, radii: CornerRadii) -> RoundedRect {
1028    let out = FOCUS_RING_GAP + FOCUS_RING_WIDTH / 2.0;
1029    RoundedRect::from_rect(
1030        Rect::new(-out, -out, size.width + out, size.height + out),
1031        RoundedRectRadii::new(
1032            radii.top_left + out,
1033            radii.top_right + out,
1034            radii.bottom_right + out,
1035            radii.bottom_left + out,
1036        ),
1037    )
1038}
1039
1040// ---- The label run ----------------------------------------------------------
1041
1042/// The leading segment's lazily-shaped label run, re-brushed at paint time — a
1043/// sibling of `button::core::LabelRun` (private to that module), the same shape
1044/// [`mod@crate::toggle_button`] carries.
1045struct LabelRun {
1046    content: String,
1047    layout: Option<TextLayout>,
1048    shaped_for: Option<(TextStyle, Option<f64>)>,
1049    natural: Option<(TextStyle, f64)>,
1050}
1051
1052impl LabelRun {
1053    fn new(content: impl Into<String>) -> Self {
1054        Self {
1055            content: content.into(),
1056            layout: None,
1057            shaped_for: None,
1058            natural: None,
1059        }
1060    }
1061
1062    fn set_content(&mut self, content: &str) {
1063        if self.content != content {
1064            self.content = content.to_string();
1065            self.layout = None;
1066            self.shaped_for = None;
1067            self.natural = None;
1068        }
1069    }
1070
1071    fn content(&self) -> &str {
1072        &self.content
1073    }
1074
1075    fn natural_width(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> f64 {
1076        if let Some((cached_style, width)) = &self.natural
1077            && cached_style == style
1078        {
1079            return *width;
1080        }
1081        let text_ctx = ctx.text_context::<TextContext>();
1082        let width = text_ctx.layout(&self.content, style, None).size().width;
1083        self.natural = Some((style.clone(), width));
1084        width
1085    }
1086
1087    fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: Option<f64>) -> Size {
1088        let key = (style.clone(), max_width);
1089        if let Some(cached) = &self.layout
1090            && self.shaped_for.as_ref() == Some(&key)
1091        {
1092            return cached.size();
1093        }
1094        let text_ctx = ctx.text_context::<TextContext>();
1095        let laid = match max_width {
1096            Some(width) => text_ctx.layout_bounded(
1097                &self.content,
1098                style,
1099                Some(width as f32),
1100                Some(1),
1101                TextOverflow::Ellipsis,
1102            ),
1103            None => text_ctx.layout(&self.content, style, None),
1104        };
1105        let size = laid.size();
1106        self.layout = Some(laid);
1107        self.shaped_for = Some(key);
1108        size
1109    }
1110
1111    fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
1112        let Some(layout) = &self.layout else {
1113            return;
1114        };
1115        for mut run in layout.to_scene_runs(origin) {
1116            run.brush = Brush::Solid(color);
1117            scene.draw_glyph_run(run);
1118        }
1119    }
1120}
1121
1122// ---- One segment ------------------------------------------------------------
1123
1124/// Which half a segment is.
1125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1126enum SegmentKind {
1127    /// The action half: label (+ optional icon), fires `on_press`.
1128    Leading,
1129    /// The menu-trigger half: the chevron, fires `on_open`.
1130    Trailing,
1131}
1132
1133/// The declarative half of one segment. Private: a split button's segments are
1134/// its own composition, never something an app builds directly.
1135struct SegmentView<State: 'static> {
1136    kind: SegmentKind,
1137    size: SplitButtonSize,
1138    shape: SplitButtonShape,
1139    variant: SplitButtonVariant,
1140    enabled: bool,
1141    open: bool,
1142    trailing_alignment: SplitButtonTrailingAlignment,
1143    label: String,
1144    icon: Option<AnyView<State>>,
1145    haptic: HapticSignal,
1146    /// The trailing segment's anchor cell, written on every paint — the same
1147    /// one-line window-rect capture [`crate::overlay::overlay_anchor`] performs,
1148    /// inlined so the segment stays one pod (and therefore one focus target).
1149    anchor: Option<OverlayAnchor>,
1150    on_press: Rc<dyn Fn(&mut State)>,
1151}
1152
1153/// The retained half of one segment.
1154struct SegmentWidget {
1155    kind: SegmentKind,
1156    size: SplitButtonSize,
1157    shape: SplitButtonShape,
1158    variant: SplitButtonVariant,
1159    enabled: bool,
1160    open: bool,
1161    trailing_alignment: SplitButtonTrailingAlignment,
1162    label: LabelRun,
1163    label_origin: Point,
1164    icon: Option<ChildPod>,
1165    haptic: HapticSignal,
1166    anchor: Option<OverlayAnchor>,
1167    /// Hover/focus/pressed tracking feeding the state layer and the shape
1168    /// spring's targets.
1169    state: InteractionState,
1170    /// Whether this segment holds the pointer capture a `Down` took.
1171    captured: bool,
1172    /// The four-corner clip spring.
1173    motion: ShapeMotion,
1174    /// The chevron's parsed path + design box (trailing only).
1175    chevron: Option<(BezPath, f64)>,
1176    /// The chevron rotation tween (trailing only, inert on the leading half).
1177    chevron_anim: AnimationController,
1178    on_press: ErasedCallback,
1179}
1180
1181impl SegmentWidget {
1182    /// Whether an open, round trailing segment collapses to a circle —
1183    /// `circleTrailing` (`_computeTrailingGeometry`).
1184    ///
1185    /// Under the ported subset this is *equivalent* to the ordinary open
1186    /// branch: `trailingSelectedRadius` is `height / 2`, which is exactly what
1187    /// [`SplitButtonShape::Round`]'s outer radius already resolves to, and the
1188    /// circle branch only fires for round shapes. It is transcribed anyway, and
1189    /// `the_circle_trailing_branch_coincides_with_the_open_branch` pins the
1190    /// coincidence rather than leaving it as folklore.
1191    fn circle_trailing(&self) -> bool {
1192        self.kind == SegmentKind::Trailing
1193            && self.open
1194            && self.shape == SplitButtonShape::Round
1195            && self.size.allows_circle_trailing()
1196    }
1197
1198    /// This frame's target corner radii — `_leadingRadii`/`_trailingRadii` in
1199    /// their documented precedence. See the [module docs](self)' Connected
1200    /// geometry table.
1201    fn target_radii(&self, theme: Option<&Theme>, height: f64) -> CornerRadii {
1202        let outer = match self.shape {
1203            SplitButtonShape::Round => height / 2.0,
1204            SplitButtonShape::Square => self.size.square_outer_radius(),
1205        };
1206        let selected = height * (TRAILING_SELECTED_CORNER_PERCENT / 100.0);
1207        let pressed = self.enabled && self.state.pressed;
1208        // The reference suppresses the trailing hover shape while the menu is
1209        // open (`trailingHovered`, `m3e_split_button_content.dart:304`).
1210        let hovered = self.enabled && self.state.hovered && !(self.open && self.is_trailing());
1211
1212        match self.kind {
1213            SegmentKind::Leading => {
1214                let inner = if pressed {
1215                    self.size.pressed_radius()
1216                } else if hovered {
1217                    self.size.hovered_inner_radius()
1218                } else {
1219                    self.size.inner_radius(theme)
1220                };
1221                leading_radii(outer, inner)
1222            }
1223            SegmentKind::Trailing => {
1224                if self.circle_trailing() {
1225                    return CornerRadii::uniform(selected);
1226                }
1227                let outer = if self.open { selected } else { outer };
1228                let inner = if self.open {
1229                    selected
1230                } else if pressed {
1231                    self.size.pressed_radius()
1232                } else if hovered {
1233                    self.size.hovered_inner_radius()
1234                } else {
1235                    self.size.inner_radius(theme)
1236                };
1237                trailing_radii(outer, inner)
1238            }
1239        }
1240    }
1241
1242    fn is_trailing(&self) -> bool {
1243        self.kind == SegmentKind::Trailing
1244    }
1245
1246    /// The chevron's transformed path for this frame, or `None` on the leading
1247    /// half — `_buildTrailingChevron`'s `AnimatedRotation` over a
1248    /// `Transform.translate`d icon.
1249    fn chevron_path(&self, size: Size) -> Option<BezPath> {
1250        let (path, design) = self.chevron.as_ref()?;
1251        if *design <= 0.0 {
1252            return None;
1253        }
1254        let dx = match self.trailing_alignment {
1255            SplitButtonTrailingAlignment::OpticalCenter
1256                if !self.circle_trailing() && !self.open =>
1257            {
1258                self.size.menu_icon_offset()
1259            }
1260            _ => 0.0,
1261        };
1262        let center = Point::new(size.width / 2.0 + dx, size.height / 2.0);
1263        let angle = self.chevron_anim.value() * CHEVRON_OPEN_TURNS * std::f64::consts::TAU;
1264        let scale = self.size.trailing_icon_size() / design;
1265        let transform = Affine::translate(Vec2::new(center.x, center.y))
1266            * Affine::rotate(angle)
1267            * Affine::scale(scale)
1268            * Affine::translate(Vec2::new(-design / 2.0, -design / 2.0));
1269        Some(transform * path.clone())
1270    }
1271
1272    /// Whether `key` is one of the reference's activation keys —
1273    /// `_leadingKeyEvent`/`_trailingKeyEvent` (Enter, Space, NumpadEnter; this
1274    /// framework's `NamedKey` has no separate numpad Enter).
1275    fn activates(key: &Key) -> bool {
1276        match key {
1277            Key::Named(NamedKey::Enter) => true,
1278            Key::Character(text) => text == " ",
1279            Key::Named(_) => false,
1280        }
1281    }
1282
1283    /// Fire the segment's action plus its haptic, the up-inside/key-activate
1284    /// edge both entry points share.
1285    fn activate(&mut self, ctx: &mut EventCtx) {
1286        if self.haptic != HapticSignal::None {
1287            MaterialHaptics::fire(self.haptic);
1288        }
1289        (self.on_press)(ctx);
1290    }
1291}
1292
1293impl<State: 'static> View<State> for SegmentView<State> {
1294    type Element = SegmentWidget;
1295
1296    fn build(&self, ctx: &mut BuildCtx<'_>) -> SegmentWidget {
1297        let mut chevron_anim =
1298            AnimationController::new(CHEVRON_ROTATION_DURATION).with_curve(CHEVRON_ROTATION_CURVE);
1299        if self.kind == SegmentKind::Trailing && self.open {
1300            // Mounted already-open: rest at the rotated pose rather than
1301            // animating into it on the first frame.
1302            chevron_anim.animate_to(1.0);
1303            chevron_anim.stop();
1304        }
1305        SegmentWidget {
1306            kind: self.kind,
1307            size: self.size,
1308            shape: self.shape,
1309            variant: self.variant,
1310            enabled: self.enabled,
1311            open: self.open,
1312            trailing_alignment: self.trailing_alignment,
1313            // Both kinds carry their resolved label: the leading half paints
1314            // it, the trailing half only exposes it through semantics (the
1315            // menu-trigger's accessible name) — [`SplitButtonView::trailing_view`]
1316            // already resolved `trailing_label` against
1317            // [`DEFAULT_TRAILING_LABEL`] before this view was built.
1318            label: LabelRun::new(self.label.clone()),
1319            label_origin: Point::ZERO,
1320            icon: self.icon.as_ref().map(|icon| build_child(icon, ctx)),
1321            haptic: self.haptic,
1322            anchor: self.anchor.clone(),
1323            state: InteractionState::new(),
1324            captured: false,
1325            motion: ShapeMotion::new(),
1326            chevron: (self.kind == SegmentKind::Trailing)
1327                .then(|| IconData::from(icons::KEYBOARD_ARROW_DOWN).resolve()),
1328            chevron_anim,
1329            on_press: erase_callback(&self.on_press),
1330        }
1331    }
1332
1333    fn rebuild(
1334        &self,
1335        prev: &Self,
1336        element: &mut SegmentWidget,
1337        ctx: &mut BuildCtx<'_>,
1338    ) -> ChangeFlags {
1339        element.on_press = erase_callback(&self.on_press);
1340        element.haptic = self.haptic;
1341        element.anchor = self.anchor.clone();
1342        let mut flags = ChangeFlags::NONE;
1343
1344        if prev.size != self.size {
1345            element.size = self.size;
1346            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1347        }
1348        if prev.shape != self.shape {
1349            element.shape = self.shape;
1350            flags |= ChangeFlags::PAINT;
1351        }
1352        if prev.variant != self.variant {
1353            element.variant = self.variant;
1354            flags |= ChangeFlags::PAINT;
1355        }
1356        if prev.trailing_alignment != self.trailing_alignment {
1357            element.trailing_alignment = self.trailing_alignment;
1358            flags |= ChangeFlags::PAINT;
1359        }
1360        if prev.enabled != self.enabled {
1361            element.enabled = self.enabled;
1362            if !self.enabled {
1363                // A segment disabled mid-press keeps neither the press nor the
1364                // capture.
1365                element.state.set_pressed(false);
1366                element.captured = false;
1367            }
1368            flags |= ChangeFlags::PAINT;
1369        }
1370        if prev.open != self.open {
1371            element.open = self.open;
1372            if element.kind == SegmentKind::Trailing {
1373                element
1374                    .chevron_anim
1375                    .animate_to(if self.open { 1.0 } else { 0.0 });
1376            }
1377            flags |= ChangeFlags::PAINT;
1378        }
1379        if prev.label != self.label {
1380            element.label.set_content(&self.label);
1381            flags |= if self.kind == SegmentKind::Leading {
1382                // The leading half paints its label, so a change reflows it.
1383                ChangeFlags::LAYOUT | ChangeFlags::PAINT
1384            } else {
1385                // The trailing half's label is accessibility-only (the menu
1386                // trigger's accessible name) — no child to reconcile, the
1387                // same shape `fab.rs`'s icon-only-FAB label rebuild takes.
1388                ChangeFlags::PAINT
1389            };
1390        }
1391
1392        match (prev.icon.as_ref(), self.icon.as_ref()) {
1393            (None, None) => {}
1394            (Some(p), Some(n)) => {
1395                let pod = element.icon.as_mut().expect("icon pod present");
1396                flags |= rebuild_child(p, n, pod, ctx);
1397            }
1398            (None, Some(n)) => {
1399                element.icon = Some(build_child(n, ctx));
1400                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1401            }
1402            (Some(p), None) => {
1403                let mut pod = element.icon.take().expect("icon pod present");
1404                teardown_child(p, &mut pod, ctx);
1405                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1406            }
1407        }
1408
1409        flags
1410    }
1411
1412    fn teardown(&self, element: &mut SegmentWidget, ctx: &mut BuildCtx<'_>) {
1413        if let (Some(icon), Some(pod)) = (self.icon.as_ref(), element.icon.as_mut()) {
1414            teardown_child(icon, pod, ctx);
1415        }
1416    }
1417}
1418
1419impl Widget for SegmentWidget {
1420    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1421        let height = self.size.height();
1422        if !self.motion.is_seeded() {
1423            let theme = Theme::from_layout_ctx(ctx);
1424            self.motion.snap_to(self.target_radii(theme, height));
1425        }
1426
1427        if self.kind == SegmentKind::Trailing {
1428            let width = self
1429                .size
1430                .trailing_width_when(self.open, self.circle_trailing());
1431            return bc.constrain(Size::new(width, height));
1432        }
1433
1434        let left = self.size.left_outer_padding();
1435        let right = self.size.label_right_padding();
1436        let icon_size = self.icon.as_mut().map(|pod| {
1437            let side = self.size.icon_size();
1438            pod.layout_child(ctx, &BoxConstraints::tight(Size::new(side, side)))
1439        });
1440        let icon_width = icon_size.map_or(0.0, |s| s.width);
1441        let has_label = !self.label.content().is_empty();
1442        let gap = if icon_size.is_some() && has_label {
1443            self.size.icon_to_label_gap()
1444        } else {
1445            0.0
1446        };
1447
1448        let style = self.size.label_style(Theme::from_layout_ctx(ctx));
1449        let available = if bc.max().width.is_finite() {
1450            (bc.max().width - left - right - icon_width - gap).max(0.0)
1451        } else {
1452            f64::INFINITY
1453        };
1454        let natural = self.label.natural_width(ctx, &style);
1455        let fit_to = (natural > available)
1456            .then_some(available)
1457            .filter(|w| w.is_finite());
1458        let label_size = self.label.shape(ctx, &style, fit_to);
1459
1460        let width = left + icon_width + gap + label_size.width + right;
1461        let size = bc.constrain(Size::new(width, height));
1462
1463        if let Some(pod) = self.icon.as_mut() {
1464            let icon_height = icon_size.map_or(0.0, |s| s.height);
1465            pod.set_origin(Point::new(left, (size.height - icon_height) / 2.0));
1466        }
1467        self.label_origin = Point::new(
1468            left + icon_width + gap,
1469            (size.height - label_size.height) / 2.0,
1470        );
1471        size
1472    }
1473
1474    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1475        let origin = ctx.origin();
1476        let size = ctx.size();
1477
1478        // The one-line window-rect capture the anchored host's own trigger
1479        // wrapper performs (`crate::overlay::anchored`'s `OverlayAnchorWidget`).
1480        if let Some(anchor) = &self.anchor {
1481            anchor.set(Rect::from_origin_size(origin, size));
1482        }
1483
1484        // Authoritative hover/focus reads, self-correcting the latched flags
1485        // (`docs/CODE_STANDARDS.md`'s Interaction Semantics); both inert while
1486        // disabled, mirroring the reference's `enabled` guards.
1487        self.state.set_hovered(self.enabled && ctx.is_hovered());
1488        self.state.set_focused(self.enabled && ctx.has_focus());
1489
1490        let (colors, shadow, radii_target) = {
1491            let theme = Theme::from_paint_ctx(ctx);
1492            let dp = elevation_dp(
1493                self.variant,
1494                self.enabled,
1495                self.state.pressed,
1496                self.state.hovered,
1497            );
1498            (
1499                resolve_colors(theme, self.variant, self.enabled),
1500                resolve_shadow(theme, dp),
1501                self.target_radii(theme, size.height),
1502            )
1503        };
1504
1505        if self.motion.retarget(radii_target) {
1506            ctx.request_frame();
1507        }
1508        if self.motion.advance(ctx.frame_time()) {
1509            ctx.request_frame();
1510        }
1511        if self.chevron_anim.advance(ctx.frame_time()) {
1512            ctx.request_frame();
1513        }
1514        let radii = self.motion.radii();
1515
1516        if let Some((blur, y_offset, shadow_color)) = shadow {
1517            scene.draw_shadow(
1518                Point::new(origin.x, origin.y + y_offset),
1519                size,
1520                radii.largest(),
1521                blur,
1522                shadow_color,
1523            );
1524        }
1525        if colors.container.components[3] > 0.0 {
1526            scene.fill_rounded_rect_radii(origin, size, radii, colors.container);
1527        }
1528
1529        // The reference's `Material(clipBehavior: Clip.antiAlias, borderRadius:
1530        // animatedRadius)`: everything inside the segment is masked by the
1531        // *animating* shape, which is what makes the morph read as a clip
1532        // rather than a redraw.
1533        scene.push_clip_rounded_radii(origin, size, radii);
1534        if self.enabled {
1535            let opacity = self.state.resolve_opacity();
1536            if opacity > 0.0 {
1537                scene.fill_rounded_rect_radii(
1538                    origin,
1539                    size,
1540                    radii,
1541                    with_alpha(colors.content, opacity),
1542                );
1543            }
1544        }
1545        match self.kind {
1546            SegmentKind::Leading => {
1547                if let Some(pod) = self.icon.as_mut() {
1548                    pod.paint_child(ctx, scene);
1549                }
1550                self.label.paint(
1551                    Point::new(
1552                        origin.x + self.label_origin.x,
1553                        origin.y + self.label_origin.y,
1554                    ),
1555                    colors.content,
1556                    scene,
1557                );
1558            }
1559            SegmentKind::Trailing => {
1560                if let Some(path) = self.chevron_path(size) {
1561                    scene.fill_path(origin, &path, &Brush::Solid(colors.content));
1562                }
1563            }
1564        }
1565        scene.pop_clip();
1566
1567        if let Some(outline) = colors.outline {
1568            let path = inset_stroke_rrect(size, radii, OUTLINE_WIDTH).to_path(PATH_TOLERANCE);
1569            scene.stroke_path(origin, &path, OUTLINE_WIDTH, &Brush::Solid(outline));
1570        }
1571        if self.state.focused {
1572            let ring = focus_ring_rrect(size, radii).to_path(PATH_TOLERANCE);
1573            scene.stroke_path(
1574                origin,
1575                &ring,
1576                FOCUS_RING_WIDTH,
1577                &Brush::Solid(colors.focus_ring),
1578            );
1579        }
1580    }
1581
1582    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1583        if !self.enabled {
1584            // The reference's `IgnorePointer(ignoring: !enabled)` plus each
1585            // segment's own null `onTap`/`canRequestFocus: false`.
1586            return EventResult::Ignored;
1587        }
1588        match event {
1589            InputEvent::Key(key) => {
1590                if key.repeat || !Self::activates(&key.key) {
1591                    return EventResult::Ignored;
1592                }
1593                self.activate(ctx);
1594                ctx.request_redraw();
1595                EventResult::Handled
1596            }
1597            InputEvent::Pointer(p) => match p.phase {
1598                PointerPhase::Down => {
1599                    if !presses(p) {
1600                        return EventResult::Ignored;
1601                    }
1602                    self.state.set_pressed(true);
1603                    self.captured = true;
1604                    ctx.capture_pointer();
1605                    ctx.request_redraw();
1606                    EventResult::Handled
1607                }
1608                PointerPhase::Move => {
1609                    let over = inside(p.position, ctx.size());
1610                    if !self.captured {
1611                        if over {
1612                            ctx.claim_hover();
1613                            ctx.set_cursor(CursorIcon::Pointer);
1614                        }
1615                        if self.state.set_hovered(over) {
1616                            ctx.request_redraw();
1617                        }
1618                        return EventResult::Ignored;
1619                    }
1620                    ctx.set_cursor(CursorIcon::Pointer);
1621                    if self.state.set_pressed(over) {
1622                        ctx.request_redraw();
1623                    }
1624                    EventResult::Handled
1625                }
1626                PointerPhase::Up => {
1627                    if !self.captured {
1628                        return EventResult::Ignored;
1629                    }
1630                    if inside(p.position, ctx.size()) {
1631                        self.activate(ctx);
1632                    }
1633                    self.state.set_pressed(false);
1634                    self.captured = false;
1635                    ctx.request_redraw();
1636                    EventResult::Handled
1637                }
1638                PointerPhase::Cancel => {
1639                    if !self.captured {
1640                        return EventResult::Ignored;
1641                    }
1642                    self.state.set_pressed(false);
1643                    self.captured = false;
1644                    ctx.request_redraw();
1645                    EventResult::Handled
1646                }
1647            },
1648            _ => EventResult::Ignored,
1649        }
1650    }
1651
1652    fn semantics(&self, ctx: &mut SemanticsCtx) {
1653        // Each half is its own `Role::Button` node, so a screen reader reaches
1654        // the action and the menu trigger independently — the semantics mirror
1655        // of the two focus nodes (the [module docs](self)' Two segments
1656        // section). The leading icon child is decoration and is not forwarded,
1657        // matching the plain button.
1658        ctx.push_node(Role::Button, |node| {
1659            if self.is_trailing() {
1660                // The carried name (`SplitButtonView::trailing_label`, already
1661                // resolved by `trailing_view`) wins; `DEFAULT_TRAILING_LABEL`
1662                // is only the last-resort fallback for an explicitly-empty
1663                // custom label.
1664                node.set_label(if self.label.content().is_empty() {
1665                    DEFAULT_TRAILING_LABEL
1666                } else {
1667                    self.label.content()
1668                });
1669                node.set_expanded(self.open);
1670            } else if !self.label.content().is_empty() {
1671                node.set_label(self.label.content());
1672            }
1673            if self.enabled {
1674                node.add_action(Action::Click);
1675                if self.is_trailing() {
1676                    node.add_action(Action::Expand);
1677                }
1678            } else {
1679                node.set_disabled();
1680            }
1681        });
1682    }
1683
1684    visit_children!(icon);
1685}
1686
1687// ---- The declarative view ---------------------------------------------------
1688
1689/// Build one segment's retained pod. Built directly rather than through
1690/// [`frust::authoring::build_child`]'s [`AnyView`] route so the pod holds a
1691/// concrete [`SegmentWidget`] the container can downcast back to — the
1692/// [`mod@crate::navigation_rail`] destination-pod precedent.
1693fn build_segment<State: 'static>(view: &SegmentView<State>, ctx: &mut BuildCtx<'_>) -> ChildPod {
1694    ChildPod::new(Box::new(View::<State>::build(view, ctx)))
1695}
1696
1697/// The concrete segment inside `pod`.
1698fn segment_of(pod: &mut ChildPod) -> &mut SegmentWidget {
1699    pod.widget_mut()
1700        .downcast_mut::<SegmentWidget>()
1701        .expect("a split button pod holds a SegmentWidget")
1702}
1703
1704/// A view-held, typed callback (erased on build).
1705type Callback<State> = Rc<dyn Fn(&mut State)>;
1706/// A view-held, typed menu-selection callback.
1707type OnSelect<State> = Rc<dyn Fn(&mut State, MenuSelection)>;
1708/// A view-held leading-icon factory: the icon view is rebuilt per pass, so it
1709/// cannot be stored as a single `AnyView`.
1710type IconFactory<State> = Rc<dyn Fn() -> AnyView<State>>;
1711
1712/// A declarative split button. See the [module docs](self).
1713pub struct SplitButtonView<State: 'static> {
1714    label: String,
1715    trailing_label: Option<String>,
1716    size: SplitButtonSize,
1717    shape: SplitButtonShape,
1718    variant: SplitButtonVariant,
1719    trailing_alignment: SplitButtonTrailingAlignment,
1720    enabled: bool,
1721    trailing_enabled: bool,
1722    /// The controlled expanded state driving the chevron and the open shape.
1723    open: bool,
1724    gap: Option<f64>,
1725    haptic: HapticSignal,
1726    leading_icon: Option<IconFactory<State>>,
1727    items: Vec<SplitButtonItem>,
1728    selected_value: Option<String>,
1729    menu_style: SplitButtonMenuStyle,
1730    menu_anchor: OverlayAnchor,
1731    on_press: Callback<State>,
1732    on_open: Callback<State>,
1733    on_select: Option<OnSelect<State>>,
1734}
1735
1736/// Create a split button labelled `label`, with a leading `on_press` action and
1737/// a trailing `on_open` menu callback.
1738///
1739/// `open` reflects whether the menu the app presents is currently showing: it
1740/// drives the chevron's rotation and the trailing half's open shape, and — as a
1741/// *controlled* component — it moves only when the app feeds a new value back
1742/// (typically toggled inside `on_open`), never by itself. Pass `false` for a
1743/// plain action+menu split button that never rotates.
1744///
1745/// Chain [`SplitButtonView::size`]/[`SplitButtonView::variant`]/
1746/// [`SplitButtonView::shape`] for the reference's other tiers,
1747/// [`SplitButtonView::items`] plus [`SplitButtonView::on_select`] to describe
1748/// the menu, and [`SplitButtonView::menu_route`] to get the presentation the
1749/// chosen [`SplitButtonView::menu_style`] calls for.
1750pub fn split_button<State, P, O>(
1751    label: impl Into<String>,
1752    open: bool,
1753    on_press: P,
1754    on_open: O,
1755) -> SplitButtonView<State>
1756where
1757    State: 'static,
1758    P: Fn(&mut State) + 'static,
1759    O: Fn(&mut State) + 'static,
1760{
1761    SplitButtonView {
1762        label: label.into(),
1763        trailing_label: None,
1764        size: SplitButtonSize::default(),
1765        shape: SplitButtonShape::default(),
1766        variant: SplitButtonVariant::default(),
1767        trailing_alignment: SplitButtonTrailingAlignment::default(),
1768        enabled: true,
1769        trailing_enabled: true,
1770        open,
1771        gap: None,
1772        haptic: HapticSignal::None,
1773        leading_icon: None,
1774        items: Vec::new(),
1775        selected_value: None,
1776        menu_style: SplitButtonMenuStyle::default(),
1777        menu_anchor: OverlayAnchor::new(),
1778        on_press: Rc::new(on_press),
1779        on_open: Rc::new(on_open),
1780        on_select: None,
1781    }
1782}
1783
1784/// PascalCase alias for [`split_button`], matching the catalog's view-fn
1785/// vocabulary.
1786#[allow(non_snake_case)]
1787pub fn SplitButton<State, P, O>(
1788    label: impl Into<String>,
1789    open: bool,
1790    on_press: P,
1791    on_open: O,
1792) -> SplitButtonView<State>
1793where
1794    State: 'static,
1795    P: Fn(&mut State) + 'static,
1796    O: Fn(&mut State) + 'static,
1797{
1798    split_button(label, open, on_press, on_open)
1799}
1800
1801impl<State: 'static> SplitButtonView<State> {
1802    /// Set the size tier (the [module docs](self)' Sizes table).
1803    pub fn size(mut self, size: SplitButtonSize) -> Self {
1804        self.size = size;
1805        self
1806    }
1807
1808    /// Set the corner-radius family (pill vs. per-size token radius).
1809    pub fn shape(mut self, shape: SplitButtonShape) -> Self {
1810        self.shape = shape;
1811        self
1812    }
1813
1814    /// Set the container treatment (the [module docs](self)' Variants section).
1815    pub fn variant(mut self, variant: SplitButtonVariant) -> Self {
1816        self.variant = variant;
1817        self
1818    }
1819
1820    /// Set how the trailing chevron is centred (`trailingAlignment`).
1821    pub fn trailing_alignment(mut self, alignment: SplitButtonTrailingAlignment) -> Self {
1822        self.trailing_alignment = alignment;
1823        self
1824    }
1825
1826    /// Whether the control as a whole accepts input — the reference's
1827    /// `IgnorePointer(ignoring: !enabled)`. `false` disables **both** halves
1828    /// regardless of [`Self::trailing_enabled`].
1829    pub fn enabled(mut self, enabled: bool) -> Self {
1830        self.enabled = enabled;
1831        self
1832    }
1833
1834    /// Whether the trailing menu trigger accepts input, independently of the
1835    /// leading action (default `true`).
1836    ///
1837    /// The reference carries `leadingEnabled`/`trailingEnabled` as two distinct
1838    /// variables fed from one prop (`m3e_split_button_content.dart:212`); this
1839    /// makes the second one real, so an app can offer the action while the menu
1840    /// has nothing to show. A disabled trailing half paints the 38%/12%
1841    /// disabled roles, reports disabled semantics, and simply never arms.
1842    pub fn trailing_enabled(mut self, enabled: bool) -> Self {
1843        self.trailing_enabled = enabled;
1844        self
1845    }
1846
1847    /// Attach a leading icon before the label (`M3ESplitButton.leadingIcon`).
1848    /// The factory is re-invoked per pass, since a view is a value rebuilt every
1849    /// frame.
1850    pub fn leading_icon<F: Fn() -> AnyView<State> + 'static>(mut self, icon: F) -> Self {
1851        self.leading_icon = Some(Rc::new(icon));
1852        self
1853    }
1854
1855    /// Override the trailing half's accessible name (default
1856    /// `"Show menu"`) — the reference's `trailingTooltip` in the one role this
1857    /// framework has for it.
1858    pub fn trailing_label(mut self, label: impl Into<String>) -> Self {
1859        self.trailing_label = Some(label.into());
1860        self
1861    }
1862
1863    /// Override the gap between the halves, in logical px
1864    /// (`M3ESplitButtonDecoration.gap`). Unset resolves
1865    /// [`SPLIT_ELEVATED_INNER_GAP`] for [`SplitButtonVariant::Elevated`] and
1866    /// [`SPLIT_INNER_GAP`] otherwise.
1867    pub fn gap(mut self, gap: f64) -> Self {
1868        self.gap = Some(gap);
1869        self
1870    }
1871
1872    /// The haptic signal both halves fire on activation. Defaults to
1873    /// [`HapticSignal::None`], matching the reference (a button is silent unless
1874    /// its decoration names a signal).
1875    pub fn haptic(mut self, haptic: HapticSignal) -> Self {
1876        self.haptic = haptic;
1877        self
1878    }
1879
1880    /// The menu's items (`M3ESplitButton.items`) — the list both
1881    /// [`SplitButtonMenuStyle`]s present.
1882    pub fn items(mut self, items: Vec<SplitButtonItem>) -> Self {
1883        self.items = items;
1884        self
1885    }
1886
1887    /// The app-confirmed selected value the menu's rows check against
1888    /// (`M3ESplitButton.selectedValue`).
1889    pub fn selected_value(mut self, value: Option<String>) -> Self {
1890        self.selected_value = value;
1891        self
1892    }
1893
1894    /// Which presentation the menu takes (`M3ESplitButtonDecoration.menuStyle`).
1895    pub fn menu_style(mut self, style: SplitButtonMenuStyle) -> Self {
1896        self.menu_style = style;
1897        self
1898    }
1899
1900    /// Fired when a menu row is activated (`M3ESplitButton.onSelected`).
1901    pub fn on_select<F: Fn(&mut State, MenuSelection) + 'static>(mut self, on_select: F) -> Self {
1902        self.on_select = Some(Rc::new(on_select));
1903        self
1904    }
1905
1906    /// Capture the **trailing segment's** window rect into `anchor` on every
1907    /// paint, so [`Self::popup_menu`] has a rect to place against — the same
1908    /// [`OverlayAnchor`] handoff
1909    /// [`crate::button_group::ButtonGroupView::overflow_anchor`] takes.
1910    pub fn menu_anchor(mut self, anchor: &OverlayAnchor) -> Self {
1911        self.menu_anchor = anchor.clone();
1912        self
1913    }
1914
1915    /// Whether the trailing half is live: the whole control's gate AND its own.
1916    fn trailing_is_enabled(&self) -> bool {
1917        self.enabled && self.trailing_enabled
1918    }
1919
1920    /// The resolved gap between the halves, before the focus-ring widening the
1921    /// widget's own `layout` adds (`_computeSegmentGeometry`'s `baseGap`).
1922    fn base_gap(&self) -> f64 {
1923        self.gap
1924            .unwrap_or(if self.variant == SplitButtonVariant::Elevated {
1925                SPLIT_ELEVATED_INNER_GAP
1926            } else {
1927                SPLIT_INNER_GAP
1928            })
1929    }
1930
1931    /// The item list as menu nodes.
1932    fn menu_nodes(&self) -> Vec<MenuNode> {
1933        self.items
1934            .iter()
1935            .map(SplitButtonItem::to_menu_node)
1936            .collect()
1937    }
1938
1939    /// The popup-style menu — a [`crate::menu()`] anchored below the trailing
1940    /// segment. Mount it separately (the top of the app's own [`frust::Stack`],
1941    /// or a transparent navigator page) and keep it mounted, toggling the
1942    /// `open` flag [`split_button`] takes rather than unmounting it, for the
1943    /// host's exit ramp. See the [module docs](self)' Menu styles section.
1944    pub fn popup_menu(&self) -> AnyView<State> {
1945        let on_select = self.on_select.clone();
1946        any(
1947            menu(self.menu_nodes(), move |state: &mut State, selection| {
1948                if let Some(callback) = &on_select {
1949                    callback(state, selection);
1950                }
1951            })
1952            .anchor(&self.menu_anchor)
1953            .selected(self.selected_value.clone())
1954            .open(self.open),
1955        )
1956    }
1957
1958    /// The bottom-sheet-style menu — the same items inside a
1959    /// [`crate::bottom_sheet`], to hand to [`crate::show_bottom_sheet`] (which
1960    /// needs a [`frust::NavigatorController`] this widget cannot reach). See the
1961    /// [module docs](self)' Menu styles section.
1962    pub fn sheet_menu(&self) -> BottomSheetView<State> {
1963        let on_select = self.on_select.clone();
1964        bottom_sheet(
1965            menu_panel(self.menu_nodes(), move |state: &mut State, selection| {
1966                if let Some(callback) = &on_select {
1967                    callback(state, selection);
1968                }
1969            })
1970            .selected(self.selected_value.clone()),
1971        )
1972    }
1973
1974    /// The presentation this button's [`Self::menu_style`] calls for — the
1975    /// route selector `_openMenu` performs (`m3e_split_button_menu.dart:22`),
1976    /// minus the descoped `native` arm.
1977    pub fn menu_route(&self) -> SplitButtonMenuRoute<State> {
1978        match self.menu_style {
1979            SplitButtonMenuStyle::Popup => SplitButtonMenuRoute::Popup(self.popup_menu()),
1980            SplitButtonMenuStyle::BottomSheet => SplitButtonMenuRoute::Sheet(self.sheet_menu()),
1981        }
1982    }
1983
1984    /// The leading segment's view for this pass.
1985    fn leading_view(&self) -> SegmentView<State> {
1986        let on_press = self.on_press.clone();
1987        SegmentView {
1988            kind: SegmentKind::Leading,
1989            size: self.size,
1990            shape: self.shape,
1991            variant: self.variant,
1992            enabled: self.enabled,
1993            open: self.open,
1994            trailing_alignment: self.trailing_alignment,
1995            label: self.label.clone(),
1996            icon: self.leading_icon.as_ref().map(|factory| factory()),
1997            haptic: self.haptic,
1998            anchor: None,
1999            on_press,
2000        }
2001    }
2002
2003    /// The trailing segment's view for this pass.
2004    fn trailing_view(&self) -> SegmentView<State> {
2005        let on_open = self.on_open.clone();
2006        SegmentView {
2007            kind: SegmentKind::Trailing,
2008            size: self.size,
2009            shape: self.shape,
2010            variant: self.variant,
2011            enabled: self.trailing_is_enabled(),
2012            open: self.open,
2013            trailing_alignment: self.trailing_alignment,
2014            label: self
2015                .trailing_label
2016                .clone()
2017                .unwrap_or_else(|| DEFAULT_TRAILING_LABEL.to_string()),
2018            icon: None,
2019            haptic: self.haptic,
2020            anchor: Some(self.menu_anchor.clone()),
2021            on_press: on_open,
2022        }
2023    }
2024}
2025
2026/// The retained widget for a [`SplitButtonView`]: the two segment pods plus the
2027/// gap between them. It paints nothing of its own — each segment owns its
2028/// container, clip, state layer and content.
2029pub struct SplitButtonWidget {
2030    /// `[leading, trailing]`, in paint order.
2031    segments: Vec<ChildPod>,
2032    size: SplitButtonSize,
2033    base_gap: f64,
2034    /// The gap this frame's layout resolved, focus widening included.
2035    gap: f64,
2036}
2037
2038impl SplitButtonWidget {
2039    /// The gap to lay out with — `baseGap` plus [`SPLIT_FOCUS_RING_OUTSET`]
2040    /// while **either** half holds focus, so a ring never overlaps its
2041    /// neighbour (`_computeSegmentGeometry`'s `eitherFocused`).
2042    fn resolved_gap(&self) -> f64 {
2043        let focused = self.segments.iter().any(ChildPod::is_focused);
2044        self.base_gap
2045            + if focused {
2046                SPLIT_FOCUS_RING_OUTSET
2047            } else {
2048                0.0
2049            }
2050    }
2051}
2052
2053impl<State: 'static> View<State> for SplitButtonView<State> {
2054    type Element = SplitButtonWidget;
2055
2056    fn build(&self, ctx: &mut BuildCtx<'_>) -> SplitButtonWidget {
2057        SplitButtonWidget {
2058            segments: vec![
2059                build_segment(&self.leading_view(), ctx),
2060                build_segment(&self.trailing_view(), ctx),
2061            ],
2062            size: self.size,
2063            base_gap: self.base_gap(),
2064            gap: self.base_gap(),
2065        }
2066    }
2067
2068    fn rebuild(
2069        &self,
2070        prev: &Self,
2071        element: &mut SplitButtonWidget,
2072        ctx: &mut BuildCtx<'_>,
2073    ) -> ChangeFlags {
2074        let mut flags = ChangeFlags::NONE;
2075        if prev.size != self.size {
2076            element.size = self.size;
2077            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2078        }
2079        let base_gap = self.base_gap();
2080        if element.base_gap != base_gap {
2081            element.base_gap = base_gap;
2082            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2083        }
2084        flags |= View::<State>::rebuild(
2085            &self.leading_view(),
2086            &prev.leading_view(),
2087            segment_of(&mut element.segments[0]),
2088            ctx,
2089        );
2090        flags |= View::<State>::rebuild(
2091            &self.trailing_view(),
2092            &prev.trailing_view(),
2093            segment_of(&mut element.segments[1]),
2094            ctx,
2095        );
2096        flags
2097    }
2098
2099    fn teardown(&self, element: &mut SplitButtonWidget, ctx: &mut BuildCtx<'_>) {
2100        View::<State>::teardown(
2101            &self.leading_view(),
2102            segment_of(&mut element.segments[0]),
2103            ctx,
2104        );
2105        View::<State>::teardown(
2106            &self.trailing_view(),
2107            segment_of(&mut element.segments[1]),
2108            ctx,
2109        );
2110    }
2111}
2112
2113impl Widget for SplitButtonWidget {
2114    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2115        let gap = self.resolved_gap();
2116        self.gap = gap;
2117        let segment_height = self.size.height();
2118        // `ConstrainedBox(minHeight: minTapTarget)` around the row, with each
2119        // segment centred inside it (`_wrapSegmentChrome`'s `Center`).
2120        let height = segment_height.max(SPLIT_MIN_TAP_TARGET);
2121
2122        // The trailing half self-sizes off its own size row and open state, so
2123        // it measures first and the leading half gets whatever is left.
2124        let trailing = self.segments[1].layout_child(
2125            ctx,
2126            &BoxConstraints::loose(Size::new(bc.max().width, segment_height)),
2127        );
2128        let leading_max = if bc.max().width.is_finite() {
2129            (bc.max().width - gap - trailing.width).max(0.0)
2130        } else {
2131            f64::INFINITY
2132        };
2133        let leading = self.segments[0].layout_child(
2134            ctx,
2135            &BoxConstraints::loose(Size::new(leading_max, segment_height)),
2136        );
2137
2138        self.segments[0].set_origin(Point::new(0.0, (height - leading.height) / 2.0));
2139        self.segments[1].set_origin(Point::new(
2140            leading.width + gap,
2141            (height - trailing.height) / 2.0,
2142        ));
2143        bc.constrain(Size::new(leading.width + gap + trailing.width, height))
2144    }
2145
2146    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
2147        for pod in &mut self.segments {
2148            pod.paint_child(ctx, scene);
2149        }
2150    }
2151
2152    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2153        // The standard container discipline: broadcast to every child first,
2154        // then the capture path, then the focus path, then a topmost-first hit
2155        // test. The gap between the halves belongs to neither segment and is
2156        // inert, exactly like the reference's `SizedBox`.
2157        route_event(&mut self.segments, ctx, event)
2158    }
2159
2160    fn semantics(&self, ctx: &mut SemanticsCtx) {
2161        // One `Role::Group` (the reference's `FocusTraversalGroup`) over the two
2162        // segments' own nodes — each half reports itself, including its own
2163        // disabled state and the trailing half's expanded state.
2164        ctx.push_container(
2165            Role::Group,
2166            |_| {},
2167            |ctx| {
2168                for pod in &self.segments {
2169                    pod.semantics_child(ctx);
2170                }
2171            },
2172        );
2173    }
2174
2175    visit_children!(segments);
2176}
2177
2178#[cfg(test)]
2179mod tests {
2180    use super::*;
2181    use frust::Brightness;
2182    use frust::authoring::scene::GlyphRun;
2183    use frust::authoring::{KeyEvent, Modifiers, PointerButton, PointerEvent};
2184    use std::any::Any;
2185
2186    // ---- fixtures ---------------------------------------------------------
2187
2188    #[derive(Default)]
2189    struct Log {
2190        presses: u32,
2191        opens: u32,
2192        selections: Vec<MenuSelection>,
2193    }
2194
2195    fn ft_secs(s: f64) -> FrameTime {
2196        FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
2197    }
2198
2199    fn build<S: 'static>(view: &SplitButtonView<S>) -> SplitButtonWidget {
2200        let mut counter = 0u64;
2201        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
2202    }
2203
2204    fn rebuild<S: 'static>(
2205        prev: &SplitButtonView<S>,
2206        next: &SplitButtonView<S>,
2207        widget: &mut SplitButtonWidget,
2208    ) {
2209        let mut counter = 0u64;
2210        View::<S>::rebuild(next, prev, widget, &mut BuildCtx::new(&mut counter));
2211    }
2212
2213    fn plain(open: bool) -> SplitButtonView<Log> {
2214        split_button::<Log, _, _>(
2215            "Save",
2216            open,
2217            |s: &mut Log| s.presses += 1,
2218            |s: &mut Log| s.opens += 1,
2219        )
2220    }
2221
2222    fn layout_with(widget: &mut SplitButtonWidget, max_width: f64, theme: Option<&Theme>) -> Size {
2223        let mut tcx = TextContext::new();
2224        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2225        if let Some(theme) = theme {
2226            lctx = lctx.with_theme(theme as &dyn Any);
2227        }
2228        widget.layout(
2229            &mut lctx,
2230            &BoxConstraints::loose(Size::new(max_width, 500.0)),
2231        )
2232    }
2233
2234    fn laid_out(view: &SplitButtonView<Log>) -> SplitButtonWidget {
2235        let mut widget = build(view);
2236        layout_with(&mut widget, 600.0, None);
2237        widget
2238    }
2239
2240    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
2241        InputEvent::Pointer(PointerEvent {
2242            phase,
2243            position: Point::new(x, y),
2244            button: PointerButton::Primary,
2245        })
2246    }
2247
2248    fn key(named: NamedKey) -> InputEvent {
2249        InputEvent::Key(KeyEvent {
2250            key: Key::Named(named),
2251            modifiers: Modifiers::default(),
2252            repeat: false,
2253        })
2254    }
2255
2256    fn dispatch(widget: &mut SplitButtonWidget, state: &mut Log, size: Size, event: &InputEvent) {
2257        let state_any: &mut dyn Any = state;
2258        let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2259        widget.event(&mut ctx, event);
2260    }
2261
2262    /// A click at `(x, y)` in the widget's own local space.
2263    fn click(widget: &mut SplitButtonWidget, state: &mut Log, size: Size, x: f64, y: f64) {
2264        dispatch(widget, state, size, &ev(PointerPhase::Down, x, y));
2265        dispatch(widget, state, size, &ev(PointerPhase::Up, x, y));
2266    }
2267
2268    fn segment(widget: &mut SplitButtonWidget, index: usize) -> &mut SegmentWidget {
2269        segment_of(&mut widget.segments[index])
2270    }
2271
2272    #[derive(Default)]
2273    struct Recorder {
2274        rrects: Vec<(Point, Size, CornerRadii, Color)>,
2275        clips: Vec<(Point, Size, CornerRadii)>,
2276        pops: usize,
2277        paths: Vec<Color>,
2278        strokes: Vec<(f64, Color)>,
2279        runs: Vec<(Point, Color)>,
2280        shadows: usize,
2281    }
2282
2283    fn solid(brush: &Brush) -> Color {
2284        match brush {
2285            Brush::Solid(c) => *c,
2286            _ => Color::TRANSPARENT,
2287        }
2288    }
2289
2290    impl PaintScene for Recorder {
2291        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
2292        fn draw_text(&mut self, _o: Point, _t: &str) {}
2293        fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
2294            self.rrects.push((o, s, radii, color));
2295        }
2296        fn push_clip_rounded_radii(&mut self, o: Point, s: Size, radii: CornerRadii) {
2297            self.clips.push((o, s, radii));
2298        }
2299        fn pop_clip(&mut self) {
2300            self.pops += 1;
2301        }
2302        fn fill_path(&mut self, _o: Point, _p: &BezPath, brush: &Brush) {
2303            self.paths.push(solid(brush));
2304        }
2305        fn stroke_path(&mut self, _o: Point, _p: &BezPath, width: f64, brush: &Brush) {
2306            self.strokes.push((width, solid(brush)));
2307        }
2308        fn draw_glyph_run(&mut self, run: GlyphRun) {
2309            let t = run.transform.translation();
2310            self.runs.push((Point::new(t.x, t.y), solid(&run.brush)));
2311        }
2312        fn draw_shadow(&mut self, _o: Point, _s: Size, _r: f64, _sd: f64, _c: Color) {
2313            self.shadows += 1;
2314        }
2315    }
2316
2317    fn paint_at(widget: &mut SplitButtonWidget, size: Size, theme: Option<&Theme>) -> Recorder {
2318        let mut rec = Recorder::default();
2319        let mut pctx = PaintCtx::new(Point::ZERO, size);
2320        if let Some(theme) = theme {
2321            pctx = pctx.with_theme(theme as &dyn Any);
2322        }
2323        widget.paint(&mut pctx, &mut rec);
2324        rec
2325    }
2326
2327    /// Advance a segment's shape morph past its settling point.
2328    fn settle_morph(segment: &mut SegmentWidget) {
2329        let mut t = 0.0;
2330        for _ in 0..600 {
2331            t += 1.0 / 60.0;
2332            if !segment.motion.advance(ft_secs(t)) {
2333                return;
2334            }
2335        }
2336        panic!("the shape morph never settled");
2337    }
2338
2339    // ---- 1. the size / variant matrix -------------------------------------
2340
2341    #[test]
2342    fn every_size_row_matches_the_reference_theme_tables() {
2343        // m3e_split_button_theme.dart:76 (_splitHeight), :84
2344        // (_splitTrailingWidth), :164 (_splitLeftOuterPadding), :180
2345        // (_splitLabelRightPadding), :124 (_splitIcon).
2346        let table = [
2347            (SplitButtonSize::Xs, 32.0, 22.0, 12.0, 10.0, 20.0),
2348            (SplitButtonSize::Sm, 40.0, 22.0, 16.0, 12.0, 24.0),
2349            (SplitButtonSize::Md, 56.0, 26.0, 24.0, 24.0, 24.0),
2350            (SplitButtonSize::Lg, 96.0, 38.0, 48.0, 48.0, 32.0),
2351            (SplitButtonSize::Xl, 136.0, 50.0, 64.0, 64.0, 40.0),
2352        ];
2353        for (size, height, chevron, left, right, icon) in table {
2354            assert_eq!(size.height(), height, "{size:?} height");
2355            assert_eq!(size.trailing_icon_size(), chevron, "{size:?} chevron");
2356            assert_eq!(size.left_outer_padding(), left, "{size:?} left pad");
2357            assert_eq!(size.label_right_padding(), right, "{size:?} right pad");
2358            assert_eq!(size.icon_size(), icon, "{size:?} icon");
2359        }
2360        assert_eq!(SplitButtonSize::default(), SplitButtonSize::Sm);
2361        assert_eq!(SplitButtonSize::ALL.len(), 5);
2362    }
2363
2364    #[test]
2365    fn the_three_trailing_width_formulas_agree() {
2366        // `_trailingPadsAndWidth` resolves three widths (unselected, selected,
2367        // circle); every one of them lands on the same number for every size,
2368        // which is why the laid-out trailing width never moves when the menu
2369        // opens.
2370        for size in SplitButtonSize::ALL {
2371            assert_eq!(
2372                size.trailing_width_unselected(),
2373                size.trailing_width_selected(),
2374                "{size:?}"
2375            );
2376            if size.allows_circle_trailing() {
2377                assert_eq!(
2378                    size.trailing_width_unselected(),
2379                    size.height(),
2380                    "{size:?} circle width is the segment height"
2381                );
2382            }
2383            assert_eq!(
2384                size.trailing_width(),
2385                size.trailing_width_unselected().max(SPLIT_MIN_TAP_TARGET)
2386            );
2387        }
2388        assert_eq!(SplitButtonSize::Xs.trailing_width(), 48.0);
2389        assert_eq!(SplitButtonSize::Md.trailing_width(), 56.0);
2390        assert_eq!(SplitButtonSize::Xl.trailing_width(), 136.0);
2391    }
2392
2393    #[test]
2394    fn each_size_lays_its_segments_out_at_the_table_height_and_trailing_width() {
2395        for size in SplitButtonSize::ALL {
2396            let mut widget = laid_out(&plain(false).size(size));
2397            let expected_height = size.height().max(SPLIT_MIN_TAP_TARGET);
2398            let outer = layout_with(&mut widget, 600.0, None);
2399            assert_eq!(outer.height, expected_height, "{size:?} outer height");
2400            assert_eq!(
2401                widget.segments[0].size().height,
2402                size.height(),
2403                "{size:?} leading segment height"
2404            );
2405            assert_eq!(
2406                widget.segments[1].size().width,
2407                size.trailing_width(),
2408                "{size:?} trailing segment width"
2409            );
2410            // The two halves abut across exactly the resolved gap.
2411            let leading_right = widget.segments[0].size().width;
2412            assert_eq!(
2413                widget.segments[1].origin().x,
2414                leading_right + SPLIT_INNER_GAP,
2415                "{size:?} gap"
2416            );
2417        }
2418    }
2419
2420    #[test]
2421    fn each_variant_resolves_its_own_container_and_content_roles() {
2422        // m3e_button_theme.dart:111/:127, reached through
2423        // `_resolveColorsAndShapes`.
2424        let theme = crate::baseline().with_brightness(Brightness::Light);
2425        let s = theme.scheme();
2426        let expected = [
2427            (SplitButtonVariant::Filled, s.primary, s.on_primary, false),
2428            (
2429                SplitButtonVariant::Tonal,
2430                s.secondary_container,
2431                s.on_secondary_container,
2432                false,
2433            ),
2434            (
2435                SplitButtonVariant::Elevated,
2436                s.surface_container_low,
2437                s.primary,
2438                false,
2439            ),
2440            (
2441                SplitButtonVariant::Outlined,
2442                Color::TRANSPARENT,
2443                s.primary,
2444                true,
2445            ),
2446        ];
2447        for (variant, container, content, outlined) in expected {
2448            let colors = resolve_colors(Some(&theme), variant, true);
2449            assert_eq!(colors.container, container, "{variant:?} container");
2450            assert_eq!(colors.content, content, "{variant:?} content");
2451            assert_eq!(colors.outline.is_some(), outlined, "{variant:?} outline");
2452            assert_eq!(colors.focus_ring, s.primary, "{variant:?} focus ring");
2453        }
2454        assert_eq!(SplitButtonVariant::default(), SplitButtonVariant::Filled);
2455        assert_eq!(SplitButtonVariant::ALL.len(), 4);
2456    }
2457
2458    #[test]
2459    fn the_unthemed_fallbacks_are_the_m3_baseline_light_values() {
2460        let light = crate::baseline().with_brightness(Brightness::Light);
2461        for variant in SplitButtonVariant::ALL {
2462            for enabled in [true, false] {
2463                assert_eq!(
2464                    resolve_colors(None, variant, enabled),
2465                    resolve_colors(Some(&light), variant, enabled),
2466                    "{variant:?} enabled={enabled}"
2467                );
2468            }
2469        }
2470    }
2471
2472    #[test]
2473    fn a_disabled_segment_takes_the_reference_disabled_alphas() {
2474        // m3e_split_button_style.dart:129 — content 38%, opaque container 12%,
2475        // over `on_surface`; a transparent (outlined) container stays clear.
2476        let theme = crate::baseline().with_brightness(Brightness::Light);
2477        let on_surface = theme.scheme().on_surface;
2478        let filled = resolve_colors(Some(&theme), SplitButtonVariant::Filled, false);
2479        assert_eq!(
2480            filled.content,
2481            with_alpha(on_surface, DISABLED_CONTENT_OPACITY)
2482        );
2483        assert_eq!(
2484            filled.container,
2485            with_alpha(on_surface, DISABLED_CONTAINER_OPACITY)
2486        );
2487        let outlined = resolve_colors(Some(&theme), SplitButtonVariant::Outlined, false);
2488        assert_eq!(outlined.container, Color::TRANSPARENT);
2489        assert_eq!(
2490            outlined.outline,
2491            Some(with_alpha(on_surface, DISABLED_CONTAINER_OPACITY))
2492        );
2493    }
2494
2495    #[test]
2496    fn the_gap_is_wider_for_the_elevated_variant() {
2497        // `_computeSegmentGeometry`'s `baseGap`.
2498        assert_eq!(plain(false).base_gap(), SPLIT_INNER_GAP);
2499        assert_eq!(
2500            plain(false)
2501                .variant(SplitButtonVariant::Elevated)
2502                .base_gap(),
2503            SPLIT_ELEVATED_INNER_GAP
2504        );
2505        assert_eq!(plain(false).gap(9.0).base_gap(), 9.0);
2506        assert_eq!(SPLIT_INNER_GAP, 2.0);
2507        assert_eq!(SPLIT_ELEVATED_INNER_GAP, 4.0);
2508        assert_eq!(SPLIT_MIN_TAP_TARGET, 48.0);
2509    }
2510
2511    // ---- 2. connected geometry (per-corner radii) --------------------------
2512
2513    #[test]
2514    fn the_resting_connected_radii_are_outer_outside_and_inner_at_the_seam() {
2515        for size in SplitButtonSize::ALL {
2516            let mut widget = laid_out(&plain(false).size(size));
2517            let pill = size.height() / 2.0;
2518            let inner = size.inner_radius(None);
2519
2520            let leading = segment(&mut widget, 0).target_radii(None, size.height());
2521            assert_eq!(
2522                leading,
2523                CornerRadii::new(pill, inner, inner, pill),
2524                "{size:?}"
2525            );
2526            let trailing = segment(&mut widget, 1).target_radii(None, size.height());
2527            assert_eq!(
2528                trailing,
2529                CornerRadii::new(inner, pill, pill, inner),
2530                "{size:?}"
2531            );
2532        }
2533    }
2534
2535    #[test]
2536    fn the_square_shape_family_swaps_the_outer_corners_for_the_token_radius() {
2537        for size in SplitButtonSize::ALL {
2538            let mut widget = laid_out(&plain(false).size(size).shape(SplitButtonShape::Square));
2539            let outer = size.square_outer_radius();
2540            let inner = size.inner_radius(None);
2541            assert_eq!(
2542                segment(&mut widget, 0).target_radii(None, size.height()),
2543                CornerRadii::new(outer, inner, inner, outer),
2544                "{size:?}"
2545            );
2546        }
2547        assert_eq!(SplitButtonShape::default(), SplitButtonShape::Round);
2548    }
2549
2550    #[test]
2551    fn the_inner_radius_column_resolves_from_the_shape_scale() {
2552        // 4/4/4/8/12 lands exactly on extra_small/small/medium.
2553        let theme = crate::baseline();
2554        assert_eq!(
2555            SplitButtonSize::Sm.inner_radius(Some(&theme)),
2556            theme.shape.extra_small
2557        );
2558        assert_eq!(
2559            SplitButtonSize::Lg.inner_radius(Some(&theme)),
2560            theme.shape.small
2561        );
2562        assert_eq!(
2563            SplitButtonSize::Xl.inner_radius(Some(&theme)),
2564            theme.shape.medium
2565        );
2566        // ...and the literal fallback is the same table.
2567        assert_eq!(SplitButtonSize::Sm.inner_radius(None), 4.0);
2568        assert_eq!(SplitButtonSize::Lg.inner_radius(None), 8.0);
2569        assert_eq!(SplitButtonSize::Xl.inner_radius(None), 12.0);
2570        // The hovered / pressed / square-outer columns stay literal.
2571        assert_eq!(SplitButtonSize::Sm.hovered_inner_radius(), 12.0);
2572        assert_eq!(SplitButtonSize::Sm.pressed_radius(), 2.0);
2573        assert_eq!(SplitButtonSize::Sm.square_outer_radius(), 10.0);
2574        assert_eq!(SplitButtonSize::Xl.hovered_inner_radius(), 20.0);
2575        assert_eq!(SplitButtonSize::Xl.pressed_radius(), 6.0);
2576    }
2577
2578    #[test]
2579    fn pressed_outranks_hovered_at_the_seam() {
2580        let mut widget = laid_out(&plain(false));
2581        let size = SplitButtonSize::Sm;
2582        let seg = segment(&mut widget, 0);
2583        seg.state.set_hovered(true);
2584        assert_eq!(
2585            seg.target_radii(None, size.height()).top_right,
2586            size.hovered_inner_radius()
2587        );
2588        seg.state.set_pressed(true);
2589        assert_eq!(
2590            seg.target_radii(None, size.height()).top_right,
2591            size.pressed_radius(),
2592            "pressed outranks hovered"
2593        );
2594        // A disabled segment resolves neither.
2595        seg.enabled = false;
2596        assert_eq!(
2597            seg.target_radii(None, size.height()).top_right,
2598            size.inner_radius(None)
2599        );
2600    }
2601
2602    #[test]
2603    fn an_open_trailing_half_goes_fully_round_and_suppresses_hover() {
2604        let mut widget = laid_out(&plain(true).size(SplitButtonSize::Md));
2605        let height = SplitButtonSize::Md.height();
2606        let seg = segment(&mut widget, 1);
2607        seg.state.set_hovered(true);
2608        let radii = seg.target_radii(None, height);
2609        assert_eq!(
2610            radii,
2611            CornerRadii::uniform(height * TRAILING_SELECTED_CORNER_PERCENT / 100.0),
2612            "the open trailing half is a pill on every corner"
2613        );
2614        assert_eq!(TRAILING_SELECTED_CORNER_PERCENT, 50.0);
2615    }
2616
2617    #[test]
2618    fn the_circle_trailing_branch_coincides_with_the_open_branch() {
2619        // `circleTrailing` only fires for a round shape, whose outer radius is
2620        // already `height / 2` — the very value `trailingSelectedRadius`
2621        // resolves to. Ported anyway; this pins that it changes nothing.
2622        for size in SplitButtonSize::ALL {
2623            let mut widget = laid_out(&plain(true).size(size));
2624            let seg = segment(&mut widget, 1);
2625            assert_eq!(seg.circle_trailing(), size.allows_circle_trailing());
2626            let height = size.height();
2627            let selected = height * TRAILING_SELECTED_CORNER_PERCENT / 100.0;
2628            assert_eq!(
2629                seg.target_radii(None, height),
2630                CornerRadii::uniform(selected),
2631                "{size:?}"
2632            );
2633        }
2634    }
2635
2636    // ---- 3. the spring clip + chevron rotation -----------------------------
2637
2638    #[test]
2639    fn the_shape_spring_matches_expressive_spatial_press() {
2640        let token = crate::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
2641        assert_eq!(SPLIT_SHAPE_SPRING.stiffness, token.stiffness);
2642        assert_eq!(SPLIT_SHAPE_SPRING.damping_ratio, token.damping_ratio);
2643        assert_eq!(SPLIT_SHAPE_SPRING.mass, 1.0);
2644        assert_eq!(SPLIT_SHAPE_SPRING.stiffness, 380.0);
2645        assert_eq!(SPLIT_SHAPE_SPRING.damping_ratio, 0.55);
2646    }
2647
2648    #[test]
2649    fn the_shape_morph_snaps_on_mount_then_springs_and_stays_continuous() {
2650        let mut motion = ShapeMotion::new();
2651        let rest = CornerRadii::new(20.0, 4.0, 4.0, 20.0);
2652        assert!(
2653            !motion.retarget(rest),
2654            "the mount frame seeds, never animates"
2655        );
2656        assert_eq!(motion.radii(), rest);
2657        assert!(!motion.advance(ft_secs(0.0)), "nothing to advance");
2658
2659        // A sub-tolerance retarget is inside the dead band.
2660        let nudged = CornerRadii::new(20.0 + RETARGET_TOLERANCE / 2.0, 4.0, 4.0, 20.0);
2661        assert!(!motion.retarget(nudged));
2662        assert_eq!(motion.target_radii(), rest);
2663
2664        // A real one springs, starting where it stood.
2665        let pressed = CornerRadii::new(20.0, 2.0, 2.0, 20.0);
2666        assert!(motion.retarget(pressed));
2667        assert_eq!(motion.radii(), rest, "the leg starts where it was");
2668        motion.advance(ft_secs(0.0));
2669        motion.advance(ft_secs(0.05));
2670        let mid = motion.radii().top_right;
2671        assert!(mid < 4.0 && mid > 2.0, "sampled mid-flight (got {mid})");
2672
2673        // Retargeting mid-flight paints exactly what the last frame painted.
2674        assert!(motion.retarget(rest));
2675        assert_eq!(motion.radii().top_right, mid);
2676        let mut t = 0.05;
2677        for _ in 0..600 {
2678            t += 1.0 / 60.0;
2679            if !motion.advance(ft_secs(t)) {
2680                break;
2681            }
2682            assert!(motion.radii().top_right >= 0.0, "a corner went negative");
2683        }
2684        assert_eq!(motion.radii(), rest, "and settles exactly on target");
2685    }
2686
2687    #[test]
2688    fn a_segment_clips_its_content_to_the_animating_radii() {
2689        let mut widget = laid_out(&plain(false));
2690        let rec = paint_at(&mut widget, Size::new(300.0, 48.0), None);
2691        assert_eq!(rec.clips.len(), 2, "one clip per segment");
2692        assert_eq!(rec.pops, 2, "every clip is popped");
2693        // The clip shape is the same one the container filled with.
2694        for (index, (clip_origin, clip_size, clip_radii)) in rec.clips.iter().enumerate() {
2695            let (fill_origin, fill_size, fill_radii, _) = rec.rrects[index];
2696            assert_eq!(*clip_origin, fill_origin);
2697            assert_eq!(*clip_size, fill_size);
2698            assert_eq!(*clip_radii, fill_radii);
2699        }
2700    }
2701
2702    #[test]
2703    fn opening_the_menu_springs_the_trailing_clip_toward_the_pill() {
2704        let prev = plain(false).size(SplitButtonSize::Md);
2705        let mut widget = laid_out(&prev);
2706        paint_at(&mut widget, Size::new(300.0, 56.0), None);
2707        let closed = segment(&mut widget, 1).motion.target_radii();
2708
2709        rebuild(&prev, &plain(true).size(SplitButtonSize::Md), &mut widget);
2710        paint_at(&mut widget, Size::new(300.0, 56.0), None);
2711        let seg = segment(&mut widget, 1);
2712        assert_ne!(seg.motion.target_radii(), closed, "the target moved");
2713        assert_eq!(seg.motion.target_radii(), CornerRadii::uniform(28.0));
2714        settle_morph(seg);
2715        assert_eq!(seg.motion.radii(), CornerRadii::uniform(28.0));
2716    }
2717
2718    #[test]
2719    fn the_chevron_rotates_a_half_turn_on_open_over_the_reference_tween() {
2720        assert_eq!(CHEVRON_OPEN_TURNS, 0.5);
2721        assert_eq!(CHEVRON_ROTATION_DURATION, Duration::from_millis(120));
2722
2723        // Geometric centring, so only the rotation moves the glyph (the
2724        // optical nudge has its own test below).
2725        let geometric = |open: bool| {
2726            plain(open).trailing_alignment(SplitButtonTrailingAlignment::GeometricCenter)
2727        };
2728        let prev = geometric(false);
2729        let mut widget = laid_out(&prev);
2730        assert_eq!(segment(&mut widget, 1).chevron_anim.value(), 0.0);
2731
2732        let extent = Size::new(48.0, 40.0);
2733        let glyph_box = |w: &mut SplitButtonWidget| {
2734            segment(w, 1)
2735                .chevron_path(extent)
2736                .expect("the trailing chevron path")
2737                .bounding_box()
2738        };
2739        let closed = glyph_box(&mut widget);
2740
2741        rebuild(&prev, &geometric(true), &mut widget);
2742        // Drive the tween to its end.
2743        let seg = segment(&mut widget, 1);
2744        let mut t = 0.0;
2745        for _ in 0..60 {
2746            t += 1.0 / 60.0;
2747            if !seg.chevron_anim.advance(ft_secs(t)) {
2748                break;
2749            }
2750        }
2751        assert_eq!(
2752            seg.chevron_anim.value(),
2753            1.0,
2754            "the tween lands on a half turn"
2755        );
2756
2757        // A half turn about the segment's centre is a point reflection through
2758        // it: the open glyph sits exactly opposite the closed one.
2759        let open = glyph_box(&mut widget);
2760        let cx = extent.width / 2.0;
2761        let cy = extent.height / 2.0;
2762        assert!((open.center().x - (2.0 * cx - closed.center().x)).abs() < 1e-6);
2763        assert!((open.center().y - (2.0 * cy - closed.center().y)).abs() < 1e-6);
2764        assert!(
2765            closed.center().y > open.center().y,
2766            "the closed chevron hangs below the open one"
2767        );
2768    }
2769
2770    #[test]
2771    fn the_chevron_takes_the_optical_nudge_only_while_closed() {
2772        // `_computeTrailingGeometry`'s `iconOffsetBase`.
2773        assert_eq!(SplitButtonSize::Md.menu_icon_offset(), -2.0);
2774        assert_eq!(SplitButtonSize::Xl.menu_icon_offset(), -6.0);
2775
2776        let closed = laid_out(&plain(false).size(SplitButtonSize::Md));
2777        let mut closed = closed;
2778        let box_closed = segment(&mut closed, 1)
2779            .chevron_path(Size::new(56.0, 56.0))
2780            .expect("a chevron")
2781            .bounding_box();
2782
2783        let mut geometric = laid_out(
2784            &plain(false)
2785                .size(SplitButtonSize::Md)
2786                .trailing_alignment(SplitButtonTrailingAlignment::GeometricCenter),
2787        );
2788        let box_geometric = segment(&mut geometric, 1)
2789            .chevron_path(Size::new(56.0, 56.0))
2790            .expect("a chevron")
2791            .bounding_box();
2792
2793        assert!(
2794            (box_closed.center().x - (box_geometric.center().x - 2.0)).abs() < 1e-6,
2795            "the optical centre nudges the glyph leading by the size's offset"
2796        );
2797        assert_eq!(
2798            SplitButtonTrailingAlignment::default(),
2799            SplitButtonTrailingAlignment::OpticalCenter
2800        );
2801    }
2802
2803    // ---- 4. focus-separated segments ---------------------------------------
2804
2805    #[test]
2806    fn each_half_is_its_own_hit_target() {
2807        let mut widget = laid_out(&plain(false));
2808        let size = layout_with(&mut widget, 600.0, None);
2809        let leading_center = Point::new(widget.segments[0].size().width / 2.0, size.height / 2.0);
2810        let trailing_center = Point::new(
2811            widget.segments[1].origin().x + widget.segments[1].size().width / 2.0,
2812            size.height / 2.0,
2813        );
2814
2815        let mut state = Log::default();
2816        click(
2817            &mut widget,
2818            &mut state,
2819            size,
2820            leading_center.x,
2821            leading_center.y,
2822        );
2823        assert_eq!(state.presses, 1);
2824        assert_eq!(state.opens, 0, "the leading half never opens the menu");
2825
2826        click(
2827            &mut widget,
2828            &mut state,
2829            size,
2830            trailing_center.x,
2831            trailing_center.y,
2832        );
2833        assert_eq!(state.opens, 1);
2834        assert_eq!(state.presses, 1, "the trailing half never fires the action");
2835    }
2836
2837    #[test]
2838    fn a_key_activation_reaches_only_the_focused_half() {
2839        let mut widget = laid_out(&plain(false));
2840        let size = layout_with(&mut widget, 600.0, None);
2841        let mut state = Log::default();
2842
2843        // No focus recorded anywhere: a focus-routed key reaches nobody.
2844        dispatch(&mut widget, &mut state, size, &key(NamedKey::Enter));
2845        assert_eq!((state.presses, state.opens), (0, 0));
2846
2847        widget.segments[0].set_focused(true);
2848        dispatch(&mut widget, &mut state, size, &key(NamedKey::Enter));
2849        assert_eq!(
2850            (state.presses, state.opens),
2851            (1, 0),
2852            "the focused leading half activated"
2853        );
2854
2855        widget.segments[0].set_focused(false);
2856        widget.segments[1].set_focused(true);
2857        dispatch(&mut widget, &mut state, size, &key(NamedKey::Enter));
2858        assert_eq!(
2859            (state.presses, state.opens),
2860            (1, 1),
2861            "focus moved, and so did the activation"
2862        );
2863
2864        // Space activates too; an auto-repeat never does.
2865        dispatch(
2866            &mut widget,
2867            &mut state,
2868            size,
2869            &InputEvent::Key(KeyEvent {
2870                key: Key::Character(" ".to_string()),
2871                modifiers: Modifiers::default(),
2872                repeat: false,
2873            }),
2874        );
2875        assert_eq!(state.opens, 2);
2876        dispatch(
2877            &mut widget,
2878            &mut state,
2879            size,
2880            &InputEvent::Key(KeyEvent {
2881                key: Key::Named(NamedKey::Enter),
2882                modifiers: Modifiers::default(),
2883                repeat: true,
2884            }),
2885        );
2886        assert_eq!(state.opens, 2, "an auto-repeat is not a fresh activation");
2887    }
2888
2889    #[test]
2890    fn the_trailing_half_disables_independently_of_the_leading_one() {
2891        let view = plain(false).trailing_enabled(false);
2892        let mut widget = laid_out(&view);
2893        let size = layout_with(&mut widget, 600.0, None);
2894        let mut state = Log::default();
2895
2896        let trailing_x = widget.segments[1].origin().x + widget.segments[1].size().width / 2.0;
2897        click(&mut widget, &mut state, size, trailing_x, size.height / 2.0);
2898        assert_eq!(state.opens, 0, "a disabled trailing half never arms");
2899
2900        click(&mut widget, &mut state, size, 20.0, size.height / 2.0);
2901        assert_eq!(state.presses, 1, "the leading action is still live");
2902
2903        // ...and the whole-control gate still wins over the trailing one.
2904        let mut both_off = laid_out(&plain(false).enabled(false).trailing_enabled(true));
2905        let size = layout_with(&mut both_off, 600.0, None);
2906        let mut state = Log::default();
2907        click(&mut both_off, &mut state, size, 20.0, size.height / 2.0);
2908        let trailing_x = both_off.segments[1].origin().x + 10.0;
2909        click(
2910            &mut both_off,
2911            &mut state,
2912            size,
2913            trailing_x,
2914            size.height / 2.0,
2915        );
2916        assert_eq!((state.presses, state.opens), (0, 0));
2917    }
2918
2919    #[test]
2920    fn focus_on_either_half_widens_the_gap_by_the_ring_outset() {
2921        assert_eq!(SPLIT_FOCUS_RING_OUTSET, 4.0);
2922        let mut widget = laid_out(&plain(false));
2923        layout_with(&mut widget, 600.0, None);
2924        let unfocused = widget.gap;
2925        assert_eq!(unfocused, SPLIT_INNER_GAP);
2926
2927        widget.segments[1].set_focused(true);
2928        layout_with(&mut widget, 600.0, None);
2929        assert_eq!(widget.gap, SPLIT_INNER_GAP + SPLIT_FOCUS_RING_OUTSET);
2930        assert_eq!(
2931            widget.segments[1].origin().x,
2932            widget.segments[0].size().width + widget.gap
2933        );
2934    }
2935
2936    #[test]
2937    fn semantics_yields_a_group_over_two_independently_reporting_buttons() {
2938        fn logic(_s: &mut ()) -> SplitButtonView<()> {
2939            split_button::<(), _, _>("Save", true, |_s: &mut ()| {}, |_s: &mut ()| {})
2940                .trailing_enabled(false)
2941        }
2942        let mut root: frust_core::RenderRoot<(), SplitButtonView<()>> =
2943            frust_core::RenderRoot::new();
2944        let mut state = ();
2945        root.rebuild(&mut logic, &mut state);
2946        let mut tcx = TextContext::new();
2947        root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
2948        let update = root.semantics();
2949
2950        let group = update
2951            .nodes
2952            .iter()
2953            .find(|(_, n)| n.role() == Role::Group)
2954            .expect("a Group container node is contributed");
2955        assert_eq!(group.1.children().len(), 2);
2956
2957        let leading = update
2958            .nodes
2959            .iter()
2960            .find(|(_, n)| n.label() == Some("Save"))
2961            .expect("the leading action node");
2962        assert!(leading.1.supports_action(Action::Click));
2963        assert!(!leading.1.is_disabled());
2964
2965        let trailing = update
2966            .nodes
2967            .iter()
2968            .find(|(_, n)| n.label() == Some(DEFAULT_TRAILING_LABEL))
2969            .expect("the trailing menu node");
2970        assert_eq!(trailing.1.is_expanded(), Some(true));
2971        assert!(
2972            trailing.1.is_disabled(),
2973            "the trailing half reports its own disabled state"
2974        );
2975        assert!(!trailing.1.supports_action(Action::Expand));
2976    }
2977
2978    #[test]
2979    fn trailing_label_reaches_the_trailing_nodes_semantics() {
2980        // Revert-verify: pre-fix, `trailing_label` was stored but dropped
2981        // before the trailing `SegmentWidget` ever saw it, so this node
2982        // always reported the hardcoded `DEFAULT_TRAILING_LABEL` regardless
2983        // of what was set here.
2984        fn logic(_s: &mut ()) -> SplitButtonView<()> {
2985            split_button::<(), _, _>("Save", true, |_s: &mut ()| {}, |_s: &mut ()| {})
2986                .trailing_label("More actions")
2987        }
2988        let mut root: frust_core::RenderRoot<(), SplitButtonView<()>> =
2989            frust_core::RenderRoot::new();
2990        let mut state = ();
2991        root.rebuild(&mut logic, &mut state);
2992        let mut tcx = TextContext::new();
2993        root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
2994        let update = root.semantics();
2995
2996        let trailing = update
2997            .nodes
2998            .iter()
2999            .find(|(_, n)| n.label() == Some("More actions"))
3000            .expect("the custom trailing label reaches the trailing node");
3001        assert_eq!(trailing.1.is_expanded(), Some(true));
3002        assert!(
3003            update
3004                .nodes
3005                .iter()
3006                .all(|(_, n)| n.label() != Some(DEFAULT_TRAILING_LABEL)),
3007            "a custom trailing label replaces the default, not just adds to it"
3008        );
3009    }
3010
3011    #[test]
3012    fn rebuild_with_a_changed_trailing_label_updates_the_node() {
3013        // A struct (not a bare `String`) so `logic`'s `&mut` parameter stays
3014        // clean of clippy's `ptr_arg` lint.
3015        struct LabelState {
3016            label: &'static str,
3017        }
3018        fn logic(s: &mut LabelState) -> SplitButtonView<LabelState> {
3019            split_button::<LabelState, _, _>(
3020                "Save",
3021                true,
3022                |_s: &mut LabelState| {},
3023                |_s: &mut LabelState| {},
3024            )
3025            .trailing_label(s.label)
3026        }
3027        let mut root: frust_core::RenderRoot<LabelState, SplitButtonView<LabelState>> =
3028            frust_core::RenderRoot::new();
3029        let mut state = LabelState { label: "First" };
3030        let mut tcx = TextContext::new();
3031        root.rebuild(&mut logic, &mut state);
3032        root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
3033        let before = root.semantics();
3034        assert!(
3035            before.nodes.iter().any(|(_, n)| n.label() == Some("First")),
3036            "the initial trailing label reaches the node"
3037        );
3038
3039        state.label = "Second";
3040        root.rebuild(&mut logic, &mut state);
3041        root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
3042        let after = root.semantics();
3043
3044        assert!(
3045            after.nodes.iter().any(|(_, n)| n.label() == Some("Second")),
3046            "the rebuilt trailing label reaches the node"
3047        );
3048        assert!(
3049            after.nodes.iter().all(|(_, n)| n.label() != Some("First")),
3050            "the stale label is gone after rebuild"
3051        );
3052    }
3053
3054    // ---- 5. the two menu styles --------------------------------------------
3055
3056    fn with_items(open: bool) -> SplitButtonView<Log> {
3057        plain(open)
3058            .items(vec![
3059                split_button_item("Save as").value("save-as"),
3060                split_button_item("Export").leading(icons::CHECK),
3061                split_button_item("Delete").enabled(false),
3062            ])
3063            .on_select(|s: &mut Log, selection| s.selections.push(selection))
3064    }
3065
3066    #[test]
3067    fn the_menu_style_selects_the_route() {
3068        let popup = with_items(true);
3069        assert!(matches!(popup.menu_route(), SplitButtonMenuRoute::Popup(_)));
3070        assert_eq!(popup.menu_style, SplitButtonMenuStyle::Popup);
3071        assert_eq!(SplitButtonMenuStyle::default(), SplitButtonMenuStyle::Popup);
3072
3073        let sheet = with_items(true).menu_style(SplitButtonMenuStyle::BottomSheet);
3074        assert!(matches!(sheet.menu_route(), SplitButtonMenuRoute::Sheet(_)));
3075    }
3076
3077    #[test]
3078    fn items_map_onto_selectable_menu_nodes() {
3079        let view = with_items(true);
3080        let nodes = view.menu_nodes();
3081        assert_eq!(nodes.len(), 3);
3082        let labels: Vec<&str> = view.items.iter().map(SplitButtonItem::label).collect();
3083        assert_eq!(labels, ["Save as", "Export", "Delete"]);
3084        assert_eq!(view.items[0].resolved_value(), "save-as");
3085        assert_eq!(
3086            view.items[1].resolved_value(),
3087            "Export",
3088            "an item with no explicit value reports its label"
3089        );
3090        for node in &nodes {
3091            assert!(
3092                matches!(node, MenuNode::Selectable(_)),
3093                "every item maps onto `M3EMenuSelectable`"
3094            );
3095        }
3096        let MenuNode::Selectable(delete) = &nodes[2] else {
3097            panic!("a selectable node");
3098        };
3099        assert_eq!(delete.label(), "Delete");
3100        assert!(
3101            !nodes[2].is_activatable(),
3102            "a disabled item stays disabled in the menu"
3103        );
3104    }
3105
3106    #[test]
3107    fn the_popup_route_places_its_panel_against_the_trailing_segment() {
3108        // The trailing segment captures its own window rect into the anchor on
3109        // every paint (the inlined `overlay_anchor` capture), which is what the
3110        // popup then places against.
3111        let anchor = OverlayAnchor::new();
3112        let view = with_items(true).menu_anchor(&anchor);
3113        let mut widget = laid_out(&view);
3114        let size = layout_with(&mut widget, 600.0, None);
3115        paint_at(&mut widget, size, None);
3116
3117        let captured = anchor.rect();
3118        assert_eq!(captured.x0, widget.segments[1].origin().x);
3119        assert_eq!(captured.width(), widget.segments[1].size().width);
3120        assert!(captured.height() > 0.0);
3121    }
3122
3123    #[test]
3124    fn a_menu_selection_reports_through_on_select() {
3125        // Mount the popup route the documented way and click its first row.
3126        let anchor = OverlayAnchor::new();
3127        anchor.set(Rect::new(40.0, 100.0, 160.0, 140.0));
3128        let mut root: frust_core::RenderRoot<Log, frust::StackView<Log>> =
3129            frust_core::RenderRoot::new();
3130        let mut state = Log::default();
3131        let mut tcx = TextContext::new();
3132        let cell = anchor.clone();
3133        let mut logic = move |_s: &mut Log| {
3134            frust::Stack(vec![with_items(true).menu_anchor(&cell).popup_menu()])
3135        };
3136        root.rebuild(&mut logic, &mut state);
3137        root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
3138
3139        // The first row's centre, per the anchored menu's documented placement
3140        // (below the trigger, leading edges flush, at the anchor gap).
3141        let x = 40.0 + crate::menu::MENU_MIN_WIDTH / 2.0;
3142        let y = 140.0 + crate::overlay::OVERLAY_ANCHOR_GAP + 8.0 + (4.0 + 48.0) / 2.0;
3143        for phase in [PointerPhase::Down, PointerPhase::Up] {
3144            root.event(
3145                &mut state,
3146                &InputEvent::Pointer(PointerEvent {
3147                    phase,
3148                    position: Point::new(x, y),
3149                    button: PointerButton::Primary,
3150                }),
3151            );
3152        }
3153        assert_eq!(state.selections.len(), 1);
3154        assert_eq!(state.selections[0].label, "Save as");
3155        assert_eq!(state.selections[0].value(), Some("save-as"));
3156    }
3157
3158    #[test]
3159    fn the_sheet_route_carries_the_same_items() {
3160        // The sheet is content for `show_bottom_sheet`; what this pins is that
3161        // the same item list reaches it (the panel is the catalog's one
3162        // item-list surface, per the module docs' porting decision).
3163        let mut root: frust_core::RenderRoot<Log, BottomSheetView<Log>> =
3164            frust_core::RenderRoot::new();
3165        let mut state = Log::default();
3166        let mut tcx = TextContext::new();
3167        // Mount whatever the sheet-style route hands back, per pass.
3168        let mut logic = move |_s: &mut Log| {
3169            let view = with_items(true).menu_style(SplitButtonMenuStyle::BottomSheet);
3170            match view.menu_route() {
3171                SplitButtonMenuRoute::Sheet(sheet) => sheet,
3172                SplitButtonMenuRoute::Popup(_) => {
3173                    panic!("the bottom-sheet style routes to a sheet")
3174                }
3175            }
3176        };
3177        root.rebuild(&mut logic, &mut state);
3178        root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
3179        let update = root.semantics();
3180        // Every row reaches the accessibility tree through the sheet.
3181        for label in ["Save as", "Export", "Delete"] {
3182            assert!(
3183                update.nodes.iter().any(|(_, n)| n.label() == Some(label)),
3184                "`{label}` is present in the sheet-style menu"
3185            );
3186        }
3187    }
3188
3189    // ---- paint / consumer smoke -------------------------------------------
3190
3191    #[test]
3192    fn a_themed_paint_fills_both_halves_with_the_variant_container() {
3193        let theme = crate::baseline().with_brightness(Brightness::Light);
3194        let mut widget = laid_out(&plain(false).variant(SplitButtonVariant::Tonal));
3195        let rec = paint_at(&mut widget, Size::new(300.0, 48.0), Some(&theme));
3196        let container = theme.scheme().secondary_container;
3197        assert_eq!(rec.rrects[0].3, container, "the leading container");
3198        assert_eq!(rec.rrects[1].3, container, "the trailing container");
3199        assert!(!rec.runs.is_empty(), "the leading label painted");
3200        assert_eq!(
3201            rec.paths.len(),
3202            1,
3203            "exactly one chevron path, on the trailing half"
3204        );
3205        assert_eq!(rec.paths[0], theme.scheme().on_secondary_container);
3206    }
3207
3208    #[test]
3209    fn an_outlined_split_button_strokes_a_hairline_per_half() {
3210        let mut widget = laid_out(&plain(false).variant(SplitButtonVariant::Outlined));
3211        let rec = paint_at(&mut widget, Size::new(300.0, 48.0), None);
3212        assert_eq!(rec.strokes.len(), 2, "one hairline per segment");
3213        for (width, _) in &rec.strokes {
3214            assert_eq!(*width, OUTLINE_WIDTH);
3215        }
3216        assert!(
3217            rec.rrects.iter().all(|(_, _, _, c)| c.components[3] > 0.0),
3218            "a transparent container is never filled"
3219        );
3220    }
3221
3222    #[test]
3223    fn an_elevated_split_button_casts_a_shadow_per_half() {
3224        let mut widget = laid_out(&plain(false).variant(SplitButtonVariant::Elevated));
3225        let rec = paint_at(&mut widget, Size::new(300.0, 48.0), None);
3226        assert_eq!(rec.shadows, 2);
3227        assert_eq!(
3228            elevation_dp(SplitButtonVariant::Elevated, true, false, false),
3229            1.0
3230        );
3231        assert_eq!(
3232            elevation_dp(SplitButtonVariant::Elevated, true, false, true),
3233            3.0
3234        );
3235        assert_eq!(
3236            elevation_dp(SplitButtonVariant::Elevated, true, true, false),
3237            0.0
3238        );
3239        assert_eq!(
3240            elevation_dp(SplitButtonVariant::Elevated, false, false, false),
3241            0.0
3242        );
3243    }
3244
3245    #[test]
3246    fn a_leading_icon_lays_out_before_the_label_at_the_size_s_gap() {
3247        let with_icon = plain(false)
3248            .size(SplitButtonSize::Md)
3249            .leading_icon(|| any::<Log, _>(frust::icon(icons::CHECK)));
3250        let mut widget = laid_out(&with_icon);
3251        layout_with(&mut widget, 600.0, None);
3252        let seg = segment(&mut widget, 0);
3253        let icon = seg.icon.as_ref().expect("the icon pod");
3254        assert_eq!(icon.origin().x, SplitButtonSize::Md.left_outer_padding());
3255        assert_eq!(icon.size(), Size::new(24.0, 24.0));
3256        assert_eq!(
3257            seg.label_origin.x,
3258            SplitButtonSize::Md.left_outer_padding()
3259                + 24.0
3260                + SplitButtonSize::Md.icon_to_label_gap()
3261        );
3262    }
3263
3264    #[test]
3265    fn the_original_four_argument_surface_still_compiles_and_fires() {
3266        // The compatibility pin: the pre-rework call shape, unchanged.
3267        let view: SplitButtonView<Log> = split_button(
3268            "Save",
3269            false,
3270            |s: &mut Log| s.presses += 1,
3271            |s: &mut Log| s.opens += 1,
3272        );
3273        let _pascal: SplitButtonView<Log> = SplitButton(
3274            "Save",
3275            false,
3276            |s: &mut Log| s.presses += 1,
3277            |s: &mut Log| s.opens += 1,
3278        );
3279        let mut widget = laid_out(&view);
3280        let size = layout_with(&mut widget, 600.0, None);
3281        let mut state = Log::default();
3282        click(&mut widget, &mut state, size, 20.0, size.height / 2.0);
3283        assert_eq!(state.presses, 1);
3284    }
3285
3286    #[test]
3287    fn a_release_outside_the_armed_half_fires_nothing() {
3288        let mut widget = laid_out(&plain(false));
3289        let size = layout_with(&mut widget, 600.0, None);
3290        let mut state = Log::default();
3291        let trailing_x = widget.segments[1].origin().x + 10.0;
3292        dispatch(
3293            &mut widget,
3294            &mut state,
3295            size,
3296            &ev(PointerPhase::Down, 20.0, 20.0),
3297        );
3298        dispatch(
3299            &mut widget,
3300            &mut state,
3301            size,
3302            &ev(PointerPhase::Move, trailing_x, 20.0),
3303        );
3304        dispatch(
3305            &mut widget,
3306            &mut state,
3307            size,
3308            &ev(PointerPhase::Up, trailing_x, 20.0),
3309        );
3310        assert_eq!((state.presses, state.opens), (0, 0));
3311    }
3312
3313    #[test]
3314    fn a_cancel_disarms_without_firing() {
3315        let mut widget = laid_out(&plain(false));
3316        let size = layout_with(&mut widget, 600.0, None);
3317        let mut state = Log::default();
3318        let trailing_x = widget.segments[1].origin().x + 10.0;
3319        dispatch(
3320            &mut widget,
3321            &mut state,
3322            size,
3323            &ev(PointerPhase::Down, trailing_x, 20.0),
3324        );
3325        dispatch(
3326            &mut widget,
3327            &mut state,
3328            size,
3329            &ev(PointerPhase::Cancel, trailing_x, 20.0),
3330        );
3331        assert_eq!(state.opens, 0);
3332        assert!(!segment(&mut widget, 1).captured);
3333    }
3334
3335    #[test]
3336    fn a_non_material_theme_still_resolves_every_role() {
3337        // An app is free to thread a bare theme; nothing here may panic.
3338        let bare = Theme::neutral();
3339        for variant in SplitButtonVariant::ALL {
3340            let _ = resolve_colors(Some(&bare), variant, true);
3341            let _ = resolve_colors(Some(&bare), variant, false);
3342        }
3343        for size in SplitButtonSize::ALL {
3344            let _ = size.inner_radius(Some(&bare));
3345            let _ = size.label_style(Some(&bare));
3346        }
3347        let mut widget = laid_out(&plain(true));
3348        let _ = paint_at(&mut widget, Size::new(300.0, 48.0), Some(&bare));
3349    }
3350}