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    /// The factory may return any view, which is erased here:
1851    /// `.leading_icon(|| icon(icons::CHECK))`.
1852    pub fn leading_icon<F, V>(mut self, icon: F) -> Self
1853    where
1854        F: Fn() -> V + 'static,
1855        V: View<State>,
1856    {
1857        self.leading_icon = Some(Rc::new(move || AnyView::new(icon())));
1858        self
1859    }
1860
1861    /// Override the trailing half's accessible name (default
1862    /// `"Show menu"`) — the reference's `trailingTooltip` in the one role this
1863    /// framework has for it.
1864    pub fn trailing_label(mut self, label: impl Into<String>) -> Self {
1865        self.trailing_label = Some(label.into());
1866        self
1867    }
1868
1869    /// Override the gap between the halves, in logical px
1870    /// (`M3ESplitButtonDecoration.gap`). Unset resolves
1871    /// [`SPLIT_ELEVATED_INNER_GAP`] for [`SplitButtonVariant::Elevated`] and
1872    /// [`SPLIT_INNER_GAP`] otherwise.
1873    pub fn gap(mut self, gap: f64) -> Self {
1874        self.gap = Some(gap);
1875        self
1876    }
1877
1878    /// The haptic signal both halves fire on activation. Defaults to
1879    /// [`HapticSignal::None`], matching the reference (a button is silent unless
1880    /// its decoration names a signal).
1881    pub fn haptic(mut self, haptic: HapticSignal) -> Self {
1882        self.haptic = haptic;
1883        self
1884    }
1885
1886    /// The menu's items (`M3ESplitButton.items`) — the list both
1887    /// [`SplitButtonMenuStyle`]s present.
1888    pub fn items(mut self, items: Vec<SplitButtonItem>) -> Self {
1889        self.items = items;
1890        self
1891    }
1892
1893    /// The app-confirmed selected value the menu's rows check against
1894    /// (`M3ESplitButton.selectedValue`).
1895    pub fn selected_value(mut self, value: Option<String>) -> Self {
1896        self.selected_value = value;
1897        self
1898    }
1899
1900    /// Which presentation the menu takes (`M3ESplitButtonDecoration.menuStyle`).
1901    pub fn menu_style(mut self, style: SplitButtonMenuStyle) -> Self {
1902        self.menu_style = style;
1903        self
1904    }
1905
1906    /// Fired when a menu row is activated (`M3ESplitButton.onSelected`).
1907    pub fn on_select<F: Fn(&mut State, MenuSelection) + 'static>(mut self, on_select: F) -> Self {
1908        self.on_select = Some(Rc::new(on_select));
1909        self
1910    }
1911
1912    /// Capture the **trailing segment's** window rect into `anchor` on every
1913    /// paint, so [`Self::popup_menu`] has a rect to place against — the same
1914    /// [`OverlayAnchor`] handoff
1915    /// [`crate::button_group::ButtonGroupView::overflow_anchor`] takes.
1916    pub fn menu_anchor(mut self, anchor: &OverlayAnchor) -> Self {
1917        self.menu_anchor = anchor.clone();
1918        self
1919    }
1920
1921    /// Whether the trailing half is live: the whole control's gate AND its own.
1922    fn trailing_is_enabled(&self) -> bool {
1923        self.enabled && self.trailing_enabled
1924    }
1925
1926    /// The resolved gap between the halves, before the focus-ring widening the
1927    /// widget's own `layout` adds (`_computeSegmentGeometry`'s `baseGap`).
1928    fn base_gap(&self) -> f64 {
1929        self.gap
1930            .unwrap_or(if self.variant == SplitButtonVariant::Elevated {
1931                SPLIT_ELEVATED_INNER_GAP
1932            } else {
1933                SPLIT_INNER_GAP
1934            })
1935    }
1936
1937    /// The item list as menu nodes.
1938    fn menu_nodes(&self) -> Vec<MenuNode> {
1939        self.items
1940            .iter()
1941            .map(SplitButtonItem::to_menu_node)
1942            .collect()
1943    }
1944
1945    /// The popup-style menu — a [`crate::menu()`] anchored below the trailing
1946    /// segment. Mount it separately (the top of the app's own [`frust::Stack`],
1947    /// or a transparent navigator page) and keep it mounted, toggling the
1948    /// `open` flag [`split_button`] takes rather than unmounting it, for the
1949    /// host's exit ramp. See the [module docs](self)' Menu styles section.
1950    pub fn popup_menu(&self) -> AnyView<State> {
1951        let on_select = self.on_select.clone();
1952        any(
1953            menu(self.menu_nodes(), move |state: &mut State, selection| {
1954                if let Some(callback) = &on_select {
1955                    callback(state, selection);
1956                }
1957            })
1958            .anchor(&self.menu_anchor)
1959            .selected(self.selected_value.clone())
1960            .open(self.open),
1961        )
1962    }
1963
1964    /// The bottom-sheet-style menu — the same items inside a
1965    /// [`crate::bottom_sheet`], to hand to [`crate::show_bottom_sheet`] (which
1966    /// needs a [`frust::NavigatorController`] this widget cannot reach). See the
1967    /// [module docs](self)' Menu styles section.
1968    pub fn sheet_menu(&self) -> BottomSheetView<State> {
1969        let on_select = self.on_select.clone();
1970        bottom_sheet(
1971            menu_panel(self.menu_nodes(), move |state: &mut State, selection| {
1972                if let Some(callback) = &on_select {
1973                    callback(state, selection);
1974                }
1975            })
1976            .selected(self.selected_value.clone()),
1977        )
1978    }
1979
1980    /// The presentation this button's [`Self::menu_style`] calls for — the
1981    /// route selector `_openMenu` performs (`m3e_split_button_menu.dart:22`),
1982    /// minus the descoped `native` arm.
1983    pub fn menu_route(&self) -> SplitButtonMenuRoute<State> {
1984        match self.menu_style {
1985            SplitButtonMenuStyle::Popup => SplitButtonMenuRoute::Popup(self.popup_menu()),
1986            SplitButtonMenuStyle::BottomSheet => SplitButtonMenuRoute::Sheet(self.sheet_menu()),
1987        }
1988    }
1989
1990    /// The leading segment's view for this pass.
1991    fn leading_view(&self) -> SegmentView<State> {
1992        let on_press = self.on_press.clone();
1993        SegmentView {
1994            kind: SegmentKind::Leading,
1995            size: self.size,
1996            shape: self.shape,
1997            variant: self.variant,
1998            enabled: self.enabled,
1999            open: self.open,
2000            trailing_alignment: self.trailing_alignment,
2001            label: self.label.clone(),
2002            icon: self.leading_icon.as_ref().map(|factory| factory()),
2003            haptic: self.haptic,
2004            anchor: None,
2005            on_press,
2006        }
2007    }
2008
2009    /// The trailing segment's view for this pass.
2010    fn trailing_view(&self) -> SegmentView<State> {
2011        let on_open = self.on_open.clone();
2012        SegmentView {
2013            kind: SegmentKind::Trailing,
2014            size: self.size,
2015            shape: self.shape,
2016            variant: self.variant,
2017            enabled: self.trailing_is_enabled(),
2018            open: self.open,
2019            trailing_alignment: self.trailing_alignment,
2020            label: self
2021                .trailing_label
2022                .clone()
2023                .unwrap_or_else(|| DEFAULT_TRAILING_LABEL.to_string()),
2024            icon: None,
2025            haptic: self.haptic,
2026            anchor: Some(self.menu_anchor.clone()),
2027            on_press: on_open,
2028        }
2029    }
2030}
2031
2032/// The retained widget for a [`SplitButtonView`]: the two segment pods plus the
2033/// gap between them. It paints nothing of its own — each segment owns its
2034/// container, clip, state layer and content.
2035pub struct SplitButtonWidget {
2036    /// `[leading, trailing]`, in paint order.
2037    segments: Vec<ChildPod>,
2038    size: SplitButtonSize,
2039    base_gap: f64,
2040    /// The gap this frame's layout resolved, focus widening included.
2041    gap: f64,
2042}
2043
2044impl SplitButtonWidget {
2045    /// The gap to lay out with — `baseGap` plus [`SPLIT_FOCUS_RING_OUTSET`]
2046    /// while **either** half holds focus, so a ring never overlaps its
2047    /// neighbour (`_computeSegmentGeometry`'s `eitherFocused`).
2048    fn resolved_gap(&self) -> f64 {
2049        let focused = self.segments.iter().any(ChildPod::is_focused);
2050        self.base_gap
2051            + if focused {
2052                SPLIT_FOCUS_RING_OUTSET
2053            } else {
2054                0.0
2055            }
2056    }
2057}
2058
2059impl<State: 'static> View<State> for SplitButtonView<State> {
2060    type Element = SplitButtonWidget;
2061
2062    fn build(&self, ctx: &mut BuildCtx<'_>) -> SplitButtonWidget {
2063        SplitButtonWidget {
2064            segments: vec![
2065                build_segment(&self.leading_view(), ctx),
2066                build_segment(&self.trailing_view(), ctx),
2067            ],
2068            size: self.size,
2069            base_gap: self.base_gap(),
2070            gap: self.base_gap(),
2071        }
2072    }
2073
2074    fn rebuild(
2075        &self,
2076        prev: &Self,
2077        element: &mut SplitButtonWidget,
2078        ctx: &mut BuildCtx<'_>,
2079    ) -> ChangeFlags {
2080        let mut flags = ChangeFlags::NONE;
2081        if prev.size != self.size {
2082            element.size = self.size;
2083            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2084        }
2085        let base_gap = self.base_gap();
2086        if element.base_gap != base_gap {
2087            element.base_gap = base_gap;
2088            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2089        }
2090        flags |= View::<State>::rebuild(
2091            &self.leading_view(),
2092            &prev.leading_view(),
2093            segment_of(&mut element.segments[0]),
2094            ctx,
2095        );
2096        flags |= View::<State>::rebuild(
2097            &self.trailing_view(),
2098            &prev.trailing_view(),
2099            segment_of(&mut element.segments[1]),
2100            ctx,
2101        );
2102        flags
2103    }
2104
2105    fn teardown(&self, element: &mut SplitButtonWidget, ctx: &mut BuildCtx<'_>) {
2106        View::<State>::teardown(
2107            &self.leading_view(),
2108            segment_of(&mut element.segments[0]),
2109            ctx,
2110        );
2111        View::<State>::teardown(
2112            &self.trailing_view(),
2113            segment_of(&mut element.segments[1]),
2114            ctx,
2115        );
2116    }
2117}
2118
2119impl Widget for SplitButtonWidget {
2120    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2121        let gap = self.resolved_gap();
2122        self.gap = gap;
2123        let segment_height = self.size.height();
2124        // `ConstrainedBox(minHeight: minTapTarget)` around the row, with each
2125        // segment centred inside it (`_wrapSegmentChrome`'s `Center`).
2126        let height = segment_height.max(SPLIT_MIN_TAP_TARGET);
2127
2128        // The trailing half self-sizes off its own size row and open state, so
2129        // it measures first and the leading half gets whatever is left.
2130        let trailing = self.segments[1].layout_child(
2131            ctx,
2132            &BoxConstraints::loose(Size::new(bc.max().width, segment_height)),
2133        );
2134        let leading_max = if bc.max().width.is_finite() {
2135            (bc.max().width - gap - trailing.width).max(0.0)
2136        } else {
2137            f64::INFINITY
2138        };
2139        let leading = self.segments[0].layout_child(
2140            ctx,
2141            &BoxConstraints::loose(Size::new(leading_max, segment_height)),
2142        );
2143
2144        self.segments[0].set_origin(Point::new(0.0, (height - leading.height) / 2.0));
2145        self.segments[1].set_origin(Point::new(
2146            leading.width + gap,
2147            (height - trailing.height) / 2.0,
2148        ));
2149        bc.constrain(Size::new(leading.width + gap + trailing.width, height))
2150    }
2151
2152    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
2153        for pod in &mut self.segments {
2154            pod.paint_child(ctx, scene);
2155        }
2156    }
2157
2158    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2159        // The standard container discipline: broadcast to every child first,
2160        // then the capture path, then the focus path, then a topmost-first hit
2161        // test. The gap between the halves belongs to neither segment and is
2162        // inert, exactly like the reference's `SizedBox`.
2163        route_event(&mut self.segments, ctx, event)
2164    }
2165
2166    fn semantics(&self, ctx: &mut SemanticsCtx) {
2167        // One `Role::Group` (the reference's `FocusTraversalGroup`) over the two
2168        // segments' own nodes — each half reports itself, including its own
2169        // disabled state and the trailing half's expanded state.
2170        ctx.push_container(
2171            Role::Group,
2172            |_| {},
2173            |ctx| {
2174                for pod in &self.segments {
2175                    pod.semantics_child(ctx);
2176                }
2177            },
2178        );
2179    }
2180
2181    visit_children!(segments);
2182}
2183
2184#[cfg(test)]
2185mod tests {
2186    use super::*;
2187    use frust::Brightness;
2188    use frust::authoring::scene::GlyphRun;
2189    use frust::authoring::{KeyEvent, Modifiers, PointerButton, PointerEvent};
2190    use std::any::Any;
2191
2192    // ---- fixtures ---------------------------------------------------------
2193
2194    #[derive(Default)]
2195    struct Log {
2196        presses: u32,
2197        opens: u32,
2198        selections: Vec<MenuSelection>,
2199    }
2200
2201    fn ft_secs(s: f64) -> FrameTime {
2202        FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
2203    }
2204
2205    fn build<S: 'static>(view: &SplitButtonView<S>) -> SplitButtonWidget {
2206        let mut counter = 0u64;
2207        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
2208    }
2209
2210    fn rebuild<S: 'static>(
2211        prev: &SplitButtonView<S>,
2212        next: &SplitButtonView<S>,
2213        widget: &mut SplitButtonWidget,
2214    ) {
2215        let mut counter = 0u64;
2216        View::<S>::rebuild(next, prev, widget, &mut BuildCtx::new(&mut counter));
2217    }
2218
2219    fn plain(open: bool) -> SplitButtonView<Log> {
2220        split_button::<Log, _, _>(
2221            "Save",
2222            open,
2223            |s: &mut Log| s.presses += 1,
2224            |s: &mut Log| s.opens += 1,
2225        )
2226    }
2227
2228    fn layout_with(widget: &mut SplitButtonWidget, max_width: f64, theme: Option<&Theme>) -> Size {
2229        let mut tcx = TextContext::new();
2230        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2231        if let Some(theme) = theme {
2232            lctx = lctx.with_theme(theme as &dyn Any);
2233        }
2234        widget.layout(
2235            &mut lctx,
2236            &BoxConstraints::loose(Size::new(max_width, 500.0)),
2237        )
2238    }
2239
2240    fn laid_out(view: &SplitButtonView<Log>) -> SplitButtonWidget {
2241        let mut widget = build(view);
2242        layout_with(&mut widget, 600.0, None);
2243        widget
2244    }
2245
2246    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
2247        InputEvent::Pointer(PointerEvent {
2248            phase,
2249            position: Point::new(x, y),
2250            button: PointerButton::Primary,
2251        })
2252    }
2253
2254    fn key(named: NamedKey) -> InputEvent {
2255        InputEvent::Key(KeyEvent {
2256            key: Key::Named(named),
2257            modifiers: Modifiers::default(),
2258            repeat: false,
2259        })
2260    }
2261
2262    fn dispatch(widget: &mut SplitButtonWidget, state: &mut Log, size: Size, event: &InputEvent) {
2263        let state_any: &mut dyn Any = state;
2264        let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2265        widget.event(&mut ctx, event);
2266    }
2267
2268    /// A click at `(x, y)` in the widget's own local space.
2269    fn click(widget: &mut SplitButtonWidget, state: &mut Log, size: Size, x: f64, y: f64) {
2270        dispatch(widget, state, size, &ev(PointerPhase::Down, x, y));
2271        dispatch(widget, state, size, &ev(PointerPhase::Up, x, y));
2272    }
2273
2274    fn segment(widget: &mut SplitButtonWidget, index: usize) -> &mut SegmentWidget {
2275        segment_of(&mut widget.segments[index])
2276    }
2277
2278    #[derive(Default)]
2279    struct Recorder {
2280        rrects: Vec<(Point, Size, CornerRadii, Color)>,
2281        clips: Vec<(Point, Size, CornerRadii)>,
2282        pops: usize,
2283        paths: Vec<Color>,
2284        strokes: Vec<(f64, Color)>,
2285        runs: Vec<(Point, Color)>,
2286        shadows: usize,
2287    }
2288
2289    fn solid(brush: &Brush) -> Color {
2290        match brush {
2291            Brush::Solid(c) => *c,
2292            _ => Color::TRANSPARENT,
2293        }
2294    }
2295
2296    impl PaintScene for Recorder {
2297        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
2298        fn draw_text(&mut self, _o: Point, _t: &str) {}
2299        fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
2300            self.rrects.push((o, s, radii, color));
2301        }
2302        fn push_clip_rounded_radii(&mut self, o: Point, s: Size, radii: CornerRadii) {
2303            self.clips.push((o, s, radii));
2304        }
2305        fn pop_clip(&mut self) {
2306            self.pops += 1;
2307        }
2308        fn fill_path(&mut self, _o: Point, _p: &BezPath, brush: &Brush) {
2309            self.paths.push(solid(brush));
2310        }
2311        fn stroke_path(&mut self, _o: Point, _p: &BezPath, width: f64, brush: &Brush) {
2312            self.strokes.push((width, solid(brush)));
2313        }
2314        fn draw_glyph_run(&mut self, run: GlyphRun) {
2315            let t = run.transform.translation();
2316            self.runs.push((Point::new(t.x, t.y), solid(&run.brush)));
2317        }
2318        fn draw_shadow(&mut self, _o: Point, _s: Size, _r: f64, _sd: f64, _c: Color) {
2319            self.shadows += 1;
2320        }
2321    }
2322
2323    fn paint_at(widget: &mut SplitButtonWidget, size: Size, theme: Option<&Theme>) -> Recorder {
2324        let mut rec = Recorder::default();
2325        let mut pctx = PaintCtx::new(Point::ZERO, size);
2326        if let Some(theme) = theme {
2327            pctx = pctx.with_theme(theme as &dyn Any);
2328        }
2329        widget.paint(&mut pctx, &mut rec);
2330        rec
2331    }
2332
2333    /// Advance a segment's shape morph past its settling point.
2334    fn settle_morph(segment: &mut SegmentWidget) {
2335        let mut t = 0.0;
2336        for _ in 0..600 {
2337            t += 1.0 / 60.0;
2338            if !segment.motion.advance(ft_secs(t)) {
2339                return;
2340            }
2341        }
2342        panic!("the shape morph never settled");
2343    }
2344
2345    // ---- 1. the size / variant matrix -------------------------------------
2346
2347    #[test]
2348    fn every_size_row_matches_the_reference_theme_tables() {
2349        // m3e_split_button_theme.dart:76 (_splitHeight), :84
2350        // (_splitTrailingWidth), :164 (_splitLeftOuterPadding), :180
2351        // (_splitLabelRightPadding), :124 (_splitIcon).
2352        let table = [
2353            (SplitButtonSize::Xs, 32.0, 22.0, 12.0, 10.0, 20.0),
2354            (SplitButtonSize::Sm, 40.0, 22.0, 16.0, 12.0, 24.0),
2355            (SplitButtonSize::Md, 56.0, 26.0, 24.0, 24.0, 24.0),
2356            (SplitButtonSize::Lg, 96.0, 38.0, 48.0, 48.0, 32.0),
2357            (SplitButtonSize::Xl, 136.0, 50.0, 64.0, 64.0, 40.0),
2358        ];
2359        for (size, height, chevron, left, right, icon) in table {
2360            assert_eq!(size.height(), height, "{size:?} height");
2361            assert_eq!(size.trailing_icon_size(), chevron, "{size:?} chevron");
2362            assert_eq!(size.left_outer_padding(), left, "{size:?} left pad");
2363            assert_eq!(size.label_right_padding(), right, "{size:?} right pad");
2364            assert_eq!(size.icon_size(), icon, "{size:?} icon");
2365        }
2366        assert_eq!(SplitButtonSize::default(), SplitButtonSize::Sm);
2367        assert_eq!(SplitButtonSize::ALL.len(), 5);
2368    }
2369
2370    #[test]
2371    fn the_three_trailing_width_formulas_agree() {
2372        // `_trailingPadsAndWidth` resolves three widths (unselected, selected,
2373        // circle); every one of them lands on the same number for every size,
2374        // which is why the laid-out trailing width never moves when the menu
2375        // opens.
2376        for size in SplitButtonSize::ALL {
2377            assert_eq!(
2378                size.trailing_width_unselected(),
2379                size.trailing_width_selected(),
2380                "{size:?}"
2381            );
2382            if size.allows_circle_trailing() {
2383                assert_eq!(
2384                    size.trailing_width_unselected(),
2385                    size.height(),
2386                    "{size:?} circle width is the segment height"
2387                );
2388            }
2389            assert_eq!(
2390                size.trailing_width(),
2391                size.trailing_width_unselected().max(SPLIT_MIN_TAP_TARGET)
2392            );
2393        }
2394        assert_eq!(SplitButtonSize::Xs.trailing_width(), 48.0);
2395        assert_eq!(SplitButtonSize::Md.trailing_width(), 56.0);
2396        assert_eq!(SplitButtonSize::Xl.trailing_width(), 136.0);
2397    }
2398
2399    #[test]
2400    fn each_size_lays_its_segments_out_at_the_table_height_and_trailing_width() {
2401        for size in SplitButtonSize::ALL {
2402            let mut widget = laid_out(&plain(false).size(size));
2403            let expected_height = size.height().max(SPLIT_MIN_TAP_TARGET);
2404            let outer = layout_with(&mut widget, 600.0, None);
2405            assert_eq!(outer.height, expected_height, "{size:?} outer height");
2406            assert_eq!(
2407                widget.segments[0].size().height,
2408                size.height(),
2409                "{size:?} leading segment height"
2410            );
2411            assert_eq!(
2412                widget.segments[1].size().width,
2413                size.trailing_width(),
2414                "{size:?} trailing segment width"
2415            );
2416            // The two halves abut across exactly the resolved gap.
2417            let leading_right = widget.segments[0].size().width;
2418            assert_eq!(
2419                widget.segments[1].origin().x,
2420                leading_right + SPLIT_INNER_GAP,
2421                "{size:?} gap"
2422            );
2423        }
2424    }
2425
2426    #[test]
2427    fn each_variant_resolves_its_own_container_and_content_roles() {
2428        // m3e_button_theme.dart:111/:127, reached through
2429        // `_resolveColorsAndShapes`.
2430        let theme = crate::baseline().with_brightness(Brightness::Light);
2431        let s = theme.scheme();
2432        let expected = [
2433            (SplitButtonVariant::Filled, s.primary, s.on_primary, false),
2434            (
2435                SplitButtonVariant::Tonal,
2436                s.secondary_container,
2437                s.on_secondary_container,
2438                false,
2439            ),
2440            (
2441                SplitButtonVariant::Elevated,
2442                s.surface_container_low,
2443                s.primary,
2444                false,
2445            ),
2446            (
2447                SplitButtonVariant::Outlined,
2448                Color::TRANSPARENT,
2449                s.primary,
2450                true,
2451            ),
2452        ];
2453        for (variant, container, content, outlined) in expected {
2454            let colors = resolve_colors(Some(&theme), variant, true);
2455            assert_eq!(colors.container, container, "{variant:?} container");
2456            assert_eq!(colors.content, content, "{variant:?} content");
2457            assert_eq!(colors.outline.is_some(), outlined, "{variant:?} outline");
2458            assert_eq!(colors.focus_ring, s.primary, "{variant:?} focus ring");
2459        }
2460        assert_eq!(SplitButtonVariant::default(), SplitButtonVariant::Filled);
2461        assert_eq!(SplitButtonVariant::ALL.len(), 4);
2462    }
2463
2464    #[test]
2465    fn the_unthemed_fallbacks_are_the_m3_baseline_light_values() {
2466        let light = crate::baseline().with_brightness(Brightness::Light);
2467        for variant in SplitButtonVariant::ALL {
2468            for enabled in [true, false] {
2469                assert_eq!(
2470                    resolve_colors(None, variant, enabled),
2471                    resolve_colors(Some(&light), variant, enabled),
2472                    "{variant:?} enabled={enabled}"
2473                );
2474            }
2475        }
2476    }
2477
2478    #[test]
2479    fn a_disabled_segment_takes_the_reference_disabled_alphas() {
2480        // m3e_split_button_style.dart:129 — content 38%, opaque container 12%,
2481        // over `on_surface`; a transparent (outlined) container stays clear.
2482        let theme = crate::baseline().with_brightness(Brightness::Light);
2483        let on_surface = theme.scheme().on_surface;
2484        let filled = resolve_colors(Some(&theme), SplitButtonVariant::Filled, false);
2485        assert_eq!(
2486            filled.content,
2487            with_alpha(on_surface, DISABLED_CONTENT_OPACITY)
2488        );
2489        assert_eq!(
2490            filled.container,
2491            with_alpha(on_surface, DISABLED_CONTAINER_OPACITY)
2492        );
2493        let outlined = resolve_colors(Some(&theme), SplitButtonVariant::Outlined, false);
2494        assert_eq!(outlined.container, Color::TRANSPARENT);
2495        assert_eq!(
2496            outlined.outline,
2497            Some(with_alpha(on_surface, DISABLED_CONTAINER_OPACITY))
2498        );
2499    }
2500
2501    #[test]
2502    fn the_gap_is_wider_for_the_elevated_variant() {
2503        // `_computeSegmentGeometry`'s `baseGap`.
2504        assert_eq!(plain(false).base_gap(), SPLIT_INNER_GAP);
2505        assert_eq!(
2506            plain(false)
2507                .variant(SplitButtonVariant::Elevated)
2508                .base_gap(),
2509            SPLIT_ELEVATED_INNER_GAP
2510        );
2511        assert_eq!(plain(false).gap(9.0).base_gap(), 9.0);
2512        assert_eq!(SPLIT_INNER_GAP, 2.0);
2513        assert_eq!(SPLIT_ELEVATED_INNER_GAP, 4.0);
2514        assert_eq!(SPLIT_MIN_TAP_TARGET, 48.0);
2515    }
2516
2517    // ---- 2. connected geometry (per-corner radii) --------------------------
2518
2519    #[test]
2520    fn the_resting_connected_radii_are_outer_outside_and_inner_at_the_seam() {
2521        for size in SplitButtonSize::ALL {
2522            let mut widget = laid_out(&plain(false).size(size));
2523            let pill = size.height() / 2.0;
2524            let inner = size.inner_radius(None);
2525
2526            let leading = segment(&mut widget, 0).target_radii(None, size.height());
2527            assert_eq!(
2528                leading,
2529                CornerRadii::new(pill, inner, inner, pill),
2530                "{size:?}"
2531            );
2532            let trailing = segment(&mut widget, 1).target_radii(None, size.height());
2533            assert_eq!(
2534                trailing,
2535                CornerRadii::new(inner, pill, pill, inner),
2536                "{size:?}"
2537            );
2538        }
2539    }
2540
2541    #[test]
2542    fn the_square_shape_family_swaps_the_outer_corners_for_the_token_radius() {
2543        for size in SplitButtonSize::ALL {
2544            let mut widget = laid_out(&plain(false).size(size).shape(SplitButtonShape::Square));
2545            let outer = size.square_outer_radius();
2546            let inner = size.inner_radius(None);
2547            assert_eq!(
2548                segment(&mut widget, 0).target_radii(None, size.height()),
2549                CornerRadii::new(outer, inner, inner, outer),
2550                "{size:?}"
2551            );
2552        }
2553        assert_eq!(SplitButtonShape::default(), SplitButtonShape::Round);
2554    }
2555
2556    #[test]
2557    fn the_inner_radius_column_resolves_from_the_shape_scale() {
2558        // 4/4/4/8/12 lands exactly on extra_small/small/medium.
2559        let theme = crate::baseline();
2560        assert_eq!(
2561            SplitButtonSize::Sm.inner_radius(Some(&theme)),
2562            theme.shape.extra_small
2563        );
2564        assert_eq!(
2565            SplitButtonSize::Lg.inner_radius(Some(&theme)),
2566            theme.shape.small
2567        );
2568        assert_eq!(
2569            SplitButtonSize::Xl.inner_radius(Some(&theme)),
2570            theme.shape.medium
2571        );
2572        // ...and the literal fallback is the same table.
2573        assert_eq!(SplitButtonSize::Sm.inner_radius(None), 4.0);
2574        assert_eq!(SplitButtonSize::Lg.inner_radius(None), 8.0);
2575        assert_eq!(SplitButtonSize::Xl.inner_radius(None), 12.0);
2576        // The hovered / pressed / square-outer columns stay literal.
2577        assert_eq!(SplitButtonSize::Sm.hovered_inner_radius(), 12.0);
2578        assert_eq!(SplitButtonSize::Sm.pressed_radius(), 2.0);
2579        assert_eq!(SplitButtonSize::Sm.square_outer_radius(), 10.0);
2580        assert_eq!(SplitButtonSize::Xl.hovered_inner_radius(), 20.0);
2581        assert_eq!(SplitButtonSize::Xl.pressed_radius(), 6.0);
2582    }
2583
2584    #[test]
2585    fn pressed_outranks_hovered_at_the_seam() {
2586        let mut widget = laid_out(&plain(false));
2587        let size = SplitButtonSize::Sm;
2588        let seg = segment(&mut widget, 0);
2589        seg.state.set_hovered(true);
2590        assert_eq!(
2591            seg.target_radii(None, size.height()).top_right,
2592            size.hovered_inner_radius()
2593        );
2594        seg.state.set_pressed(true);
2595        assert_eq!(
2596            seg.target_radii(None, size.height()).top_right,
2597            size.pressed_radius(),
2598            "pressed outranks hovered"
2599        );
2600        // A disabled segment resolves neither.
2601        seg.enabled = false;
2602        assert_eq!(
2603            seg.target_radii(None, size.height()).top_right,
2604            size.inner_radius(None)
2605        );
2606    }
2607
2608    #[test]
2609    fn an_open_trailing_half_goes_fully_round_and_suppresses_hover() {
2610        let mut widget = laid_out(&plain(true).size(SplitButtonSize::Md));
2611        let height = SplitButtonSize::Md.height();
2612        let seg = segment(&mut widget, 1);
2613        seg.state.set_hovered(true);
2614        let radii = seg.target_radii(None, height);
2615        assert_eq!(
2616            radii,
2617            CornerRadii::uniform(height * TRAILING_SELECTED_CORNER_PERCENT / 100.0),
2618            "the open trailing half is a pill on every corner"
2619        );
2620        assert_eq!(TRAILING_SELECTED_CORNER_PERCENT, 50.0);
2621    }
2622
2623    #[test]
2624    fn the_circle_trailing_branch_coincides_with_the_open_branch() {
2625        // `circleTrailing` only fires for a round shape, whose outer radius is
2626        // already `height / 2` — the very value `trailingSelectedRadius`
2627        // resolves to. Ported anyway; this pins that it changes nothing.
2628        for size in SplitButtonSize::ALL {
2629            let mut widget = laid_out(&plain(true).size(size));
2630            let seg = segment(&mut widget, 1);
2631            assert_eq!(seg.circle_trailing(), size.allows_circle_trailing());
2632            let height = size.height();
2633            let selected = height * TRAILING_SELECTED_CORNER_PERCENT / 100.0;
2634            assert_eq!(
2635                seg.target_radii(None, height),
2636                CornerRadii::uniform(selected),
2637                "{size:?}"
2638            );
2639        }
2640    }
2641
2642    // ---- 3. the spring clip + chevron rotation -----------------------------
2643
2644    #[test]
2645    fn the_shape_spring_matches_expressive_spatial_press() {
2646        let token = crate::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
2647        assert_eq!(SPLIT_SHAPE_SPRING.stiffness, token.stiffness);
2648        assert_eq!(SPLIT_SHAPE_SPRING.damping_ratio, token.damping_ratio);
2649        assert_eq!(SPLIT_SHAPE_SPRING.mass, 1.0);
2650        assert_eq!(SPLIT_SHAPE_SPRING.stiffness, 380.0);
2651        assert_eq!(SPLIT_SHAPE_SPRING.damping_ratio, 0.55);
2652    }
2653
2654    #[test]
2655    fn the_shape_morph_snaps_on_mount_then_springs_and_stays_continuous() {
2656        let mut motion = ShapeMotion::new();
2657        let rest = CornerRadii::new(20.0, 4.0, 4.0, 20.0);
2658        assert!(
2659            !motion.retarget(rest),
2660            "the mount frame seeds, never animates"
2661        );
2662        assert_eq!(motion.radii(), rest);
2663        assert!(!motion.advance(ft_secs(0.0)), "nothing to advance");
2664
2665        // A sub-tolerance retarget is inside the dead band.
2666        let nudged = CornerRadii::new(20.0 + RETARGET_TOLERANCE / 2.0, 4.0, 4.0, 20.0);
2667        assert!(!motion.retarget(nudged));
2668        assert_eq!(motion.target_radii(), rest);
2669
2670        // A real one springs, starting where it stood.
2671        let pressed = CornerRadii::new(20.0, 2.0, 2.0, 20.0);
2672        assert!(motion.retarget(pressed));
2673        assert_eq!(motion.radii(), rest, "the leg starts where it was");
2674        motion.advance(ft_secs(0.0));
2675        motion.advance(ft_secs(0.05));
2676        let mid = motion.radii().top_right;
2677        assert!(mid < 4.0 && mid > 2.0, "sampled mid-flight (got {mid})");
2678
2679        // Retargeting mid-flight paints exactly what the last frame painted.
2680        assert!(motion.retarget(rest));
2681        assert_eq!(motion.radii().top_right, mid);
2682        let mut t = 0.05;
2683        for _ in 0..600 {
2684            t += 1.0 / 60.0;
2685            if !motion.advance(ft_secs(t)) {
2686                break;
2687            }
2688            assert!(motion.radii().top_right >= 0.0, "a corner went negative");
2689        }
2690        assert_eq!(motion.radii(), rest, "and settles exactly on target");
2691    }
2692
2693    #[test]
2694    fn a_segment_clips_its_content_to_the_animating_radii() {
2695        let mut widget = laid_out(&plain(false));
2696        let rec = paint_at(&mut widget, Size::new(300.0, 48.0), None);
2697        assert_eq!(rec.clips.len(), 2, "one clip per segment");
2698        assert_eq!(rec.pops, 2, "every clip is popped");
2699        // The clip shape is the same one the container filled with.
2700        for (index, (clip_origin, clip_size, clip_radii)) in rec.clips.iter().enumerate() {
2701            let (fill_origin, fill_size, fill_radii, _) = rec.rrects[index];
2702            assert_eq!(*clip_origin, fill_origin);
2703            assert_eq!(*clip_size, fill_size);
2704            assert_eq!(*clip_radii, fill_radii);
2705        }
2706    }
2707
2708    #[test]
2709    fn opening_the_menu_springs_the_trailing_clip_toward_the_pill() {
2710        let prev = plain(false).size(SplitButtonSize::Md);
2711        let mut widget = laid_out(&prev);
2712        paint_at(&mut widget, Size::new(300.0, 56.0), None);
2713        let closed = segment(&mut widget, 1).motion.target_radii();
2714
2715        rebuild(&prev, &plain(true).size(SplitButtonSize::Md), &mut widget);
2716        paint_at(&mut widget, Size::new(300.0, 56.0), None);
2717        let seg = segment(&mut widget, 1);
2718        assert_ne!(seg.motion.target_radii(), closed, "the target moved");
2719        assert_eq!(seg.motion.target_radii(), CornerRadii::uniform(28.0));
2720        settle_morph(seg);
2721        assert_eq!(seg.motion.radii(), CornerRadii::uniform(28.0));
2722    }
2723
2724    #[test]
2725    fn the_chevron_rotates_a_half_turn_on_open_over_the_reference_tween() {
2726        assert_eq!(CHEVRON_OPEN_TURNS, 0.5);
2727        assert_eq!(CHEVRON_ROTATION_DURATION, Duration::from_millis(120));
2728
2729        // Geometric centring, so only the rotation moves the glyph (the
2730        // optical nudge has its own test below).
2731        let geometric = |open: bool| {
2732            plain(open).trailing_alignment(SplitButtonTrailingAlignment::GeometricCenter)
2733        };
2734        let prev = geometric(false);
2735        let mut widget = laid_out(&prev);
2736        assert_eq!(segment(&mut widget, 1).chevron_anim.value(), 0.0);
2737
2738        let extent = Size::new(48.0, 40.0);
2739        let glyph_box = |w: &mut SplitButtonWidget| {
2740            segment(w, 1)
2741                .chevron_path(extent)
2742                .expect("the trailing chevron path")
2743                .bounding_box()
2744        };
2745        let closed = glyph_box(&mut widget);
2746
2747        rebuild(&prev, &geometric(true), &mut widget);
2748        // Drive the tween to its end.
2749        let seg = segment(&mut widget, 1);
2750        let mut t = 0.0;
2751        for _ in 0..60 {
2752            t += 1.0 / 60.0;
2753            if !seg.chevron_anim.advance(ft_secs(t)) {
2754                break;
2755            }
2756        }
2757        assert_eq!(
2758            seg.chevron_anim.value(),
2759            1.0,
2760            "the tween lands on a half turn"
2761        );
2762
2763        // A half turn about the segment's centre is a point reflection through
2764        // it: the open glyph sits exactly opposite the closed one.
2765        let open = glyph_box(&mut widget);
2766        let cx = extent.width / 2.0;
2767        let cy = extent.height / 2.0;
2768        assert!((open.center().x - (2.0 * cx - closed.center().x)).abs() < 1e-6);
2769        assert!((open.center().y - (2.0 * cy - closed.center().y)).abs() < 1e-6);
2770        assert!(
2771            closed.center().y > open.center().y,
2772            "the closed chevron hangs below the open one"
2773        );
2774    }
2775
2776    #[test]
2777    fn the_chevron_takes_the_optical_nudge_only_while_closed() {
2778        // `_computeTrailingGeometry`'s `iconOffsetBase`.
2779        assert_eq!(SplitButtonSize::Md.menu_icon_offset(), -2.0);
2780        assert_eq!(SplitButtonSize::Xl.menu_icon_offset(), -6.0);
2781
2782        let closed = laid_out(&plain(false).size(SplitButtonSize::Md));
2783        let mut closed = closed;
2784        let box_closed = segment(&mut closed, 1)
2785            .chevron_path(Size::new(56.0, 56.0))
2786            .expect("a chevron")
2787            .bounding_box();
2788
2789        let mut geometric = laid_out(
2790            &plain(false)
2791                .size(SplitButtonSize::Md)
2792                .trailing_alignment(SplitButtonTrailingAlignment::GeometricCenter),
2793        );
2794        let box_geometric = segment(&mut geometric, 1)
2795            .chevron_path(Size::new(56.0, 56.0))
2796            .expect("a chevron")
2797            .bounding_box();
2798
2799        assert!(
2800            (box_closed.center().x - (box_geometric.center().x - 2.0)).abs() < 1e-6,
2801            "the optical centre nudges the glyph leading by the size's offset"
2802        );
2803        assert_eq!(
2804            SplitButtonTrailingAlignment::default(),
2805            SplitButtonTrailingAlignment::OpticalCenter
2806        );
2807    }
2808
2809    // ---- 4. focus-separated segments ---------------------------------------
2810
2811    #[test]
2812    fn each_half_is_its_own_hit_target() {
2813        let mut widget = laid_out(&plain(false));
2814        let size = layout_with(&mut widget, 600.0, None);
2815        let leading_center = Point::new(widget.segments[0].size().width / 2.0, size.height / 2.0);
2816        let trailing_center = Point::new(
2817            widget.segments[1].origin().x + widget.segments[1].size().width / 2.0,
2818            size.height / 2.0,
2819        );
2820
2821        let mut state = Log::default();
2822        click(
2823            &mut widget,
2824            &mut state,
2825            size,
2826            leading_center.x,
2827            leading_center.y,
2828        );
2829        assert_eq!(state.presses, 1);
2830        assert_eq!(state.opens, 0, "the leading half never opens the menu");
2831
2832        click(
2833            &mut widget,
2834            &mut state,
2835            size,
2836            trailing_center.x,
2837            trailing_center.y,
2838        );
2839        assert_eq!(state.opens, 1);
2840        assert_eq!(state.presses, 1, "the trailing half never fires the action");
2841    }
2842
2843    #[test]
2844    fn a_key_activation_reaches_only_the_focused_half() {
2845        let mut widget = laid_out(&plain(false));
2846        let size = layout_with(&mut widget, 600.0, None);
2847        let mut state = Log::default();
2848
2849        // No focus recorded anywhere: a focus-routed key reaches nobody.
2850        dispatch(&mut widget, &mut state, size, &key(NamedKey::Enter));
2851        assert_eq!((state.presses, state.opens), (0, 0));
2852
2853        widget.segments[0].set_focused(true);
2854        dispatch(&mut widget, &mut state, size, &key(NamedKey::Enter));
2855        assert_eq!(
2856            (state.presses, state.opens),
2857            (1, 0),
2858            "the focused leading half activated"
2859        );
2860
2861        widget.segments[0].set_focused(false);
2862        widget.segments[1].set_focused(true);
2863        dispatch(&mut widget, &mut state, size, &key(NamedKey::Enter));
2864        assert_eq!(
2865            (state.presses, state.opens),
2866            (1, 1),
2867            "focus moved, and so did the activation"
2868        );
2869
2870        // Space activates too; an auto-repeat never does.
2871        dispatch(
2872            &mut widget,
2873            &mut state,
2874            size,
2875            &InputEvent::Key(KeyEvent {
2876                key: Key::Character(" ".to_string()),
2877                modifiers: Modifiers::default(),
2878                repeat: false,
2879            }),
2880        );
2881        assert_eq!(state.opens, 2);
2882        dispatch(
2883            &mut widget,
2884            &mut state,
2885            size,
2886            &InputEvent::Key(KeyEvent {
2887                key: Key::Named(NamedKey::Enter),
2888                modifiers: Modifiers::default(),
2889                repeat: true,
2890            }),
2891        );
2892        assert_eq!(state.opens, 2, "an auto-repeat is not a fresh activation");
2893    }
2894
2895    #[test]
2896    fn the_trailing_half_disables_independently_of_the_leading_one() {
2897        let view = plain(false).trailing_enabled(false);
2898        let mut widget = laid_out(&view);
2899        let size = layout_with(&mut widget, 600.0, None);
2900        let mut state = Log::default();
2901
2902        let trailing_x = widget.segments[1].origin().x + widget.segments[1].size().width / 2.0;
2903        click(&mut widget, &mut state, size, trailing_x, size.height / 2.0);
2904        assert_eq!(state.opens, 0, "a disabled trailing half never arms");
2905
2906        click(&mut widget, &mut state, size, 20.0, size.height / 2.0);
2907        assert_eq!(state.presses, 1, "the leading action is still live");
2908
2909        // ...and the whole-control gate still wins over the trailing one.
2910        let mut both_off = laid_out(&plain(false).enabled(false).trailing_enabled(true));
2911        let size = layout_with(&mut both_off, 600.0, None);
2912        let mut state = Log::default();
2913        click(&mut both_off, &mut state, size, 20.0, size.height / 2.0);
2914        let trailing_x = both_off.segments[1].origin().x + 10.0;
2915        click(
2916            &mut both_off,
2917            &mut state,
2918            size,
2919            trailing_x,
2920            size.height / 2.0,
2921        );
2922        assert_eq!((state.presses, state.opens), (0, 0));
2923    }
2924
2925    #[test]
2926    fn focus_on_either_half_widens_the_gap_by_the_ring_outset() {
2927        assert_eq!(SPLIT_FOCUS_RING_OUTSET, 4.0);
2928        let mut widget = laid_out(&plain(false));
2929        layout_with(&mut widget, 600.0, None);
2930        let unfocused = widget.gap;
2931        assert_eq!(unfocused, SPLIT_INNER_GAP);
2932
2933        widget.segments[1].set_focused(true);
2934        layout_with(&mut widget, 600.0, None);
2935        assert_eq!(widget.gap, SPLIT_INNER_GAP + SPLIT_FOCUS_RING_OUTSET);
2936        assert_eq!(
2937            widget.segments[1].origin().x,
2938            widget.segments[0].size().width + widget.gap
2939        );
2940    }
2941
2942    #[test]
2943    fn semantics_yields_a_group_over_two_independently_reporting_buttons() {
2944        fn logic(_s: &mut ()) -> SplitButtonView<()> {
2945            split_button::<(), _, _>("Save", true, |_s: &mut ()| {}, |_s: &mut ()| {})
2946                .trailing_enabled(false)
2947        }
2948        let mut root: frust_core::RenderRoot<(), SplitButtonView<()>> =
2949            frust_core::RenderRoot::new();
2950        let mut state = ();
2951        root.rebuild(&mut logic, &mut state);
2952        let mut tcx = TextContext::new();
2953        root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
2954        let update = root.semantics();
2955
2956        let group = update
2957            .nodes
2958            .iter()
2959            .find(|(_, n)| n.role() == Role::Group)
2960            .expect("a Group container node is contributed");
2961        assert_eq!(group.1.children().len(), 2);
2962
2963        let leading = update
2964            .nodes
2965            .iter()
2966            .find(|(_, n)| n.label() == Some("Save"))
2967            .expect("the leading action node");
2968        assert!(leading.1.supports_action(Action::Click));
2969        assert!(!leading.1.is_disabled());
2970
2971        let trailing = update
2972            .nodes
2973            .iter()
2974            .find(|(_, n)| n.label() == Some(DEFAULT_TRAILING_LABEL))
2975            .expect("the trailing menu node");
2976        assert_eq!(trailing.1.is_expanded(), Some(true));
2977        assert!(
2978            trailing.1.is_disabled(),
2979            "the trailing half reports its own disabled state"
2980        );
2981        assert!(!trailing.1.supports_action(Action::Expand));
2982    }
2983
2984    #[test]
2985    fn trailing_label_reaches_the_trailing_nodes_semantics() {
2986        // Revert-verify: pre-fix, `trailing_label` was stored but dropped
2987        // before the trailing `SegmentWidget` ever saw it, so this node
2988        // always reported the hardcoded `DEFAULT_TRAILING_LABEL` regardless
2989        // of what was set here.
2990        fn logic(_s: &mut ()) -> SplitButtonView<()> {
2991            split_button::<(), _, _>("Save", true, |_s: &mut ()| {}, |_s: &mut ()| {})
2992                .trailing_label("More actions")
2993        }
2994        let mut root: frust_core::RenderRoot<(), SplitButtonView<()>> =
2995            frust_core::RenderRoot::new();
2996        let mut state = ();
2997        root.rebuild(&mut logic, &mut state);
2998        let mut tcx = TextContext::new();
2999        root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
3000        let update = root.semantics();
3001
3002        let trailing = update
3003            .nodes
3004            .iter()
3005            .find(|(_, n)| n.label() == Some("More actions"))
3006            .expect("the custom trailing label reaches the trailing node");
3007        assert_eq!(trailing.1.is_expanded(), Some(true));
3008        assert!(
3009            update
3010                .nodes
3011                .iter()
3012                .all(|(_, n)| n.label() != Some(DEFAULT_TRAILING_LABEL)),
3013            "a custom trailing label replaces the default, not just adds to it"
3014        );
3015    }
3016
3017    #[test]
3018    fn rebuild_with_a_changed_trailing_label_updates_the_node() {
3019        // A struct (not a bare `String`) so `logic`'s `&mut` parameter stays
3020        // clean of clippy's `ptr_arg` lint.
3021        struct LabelState {
3022            label: &'static str,
3023        }
3024        fn logic(s: &mut LabelState) -> SplitButtonView<LabelState> {
3025            split_button::<LabelState, _, _>(
3026                "Save",
3027                true,
3028                |_s: &mut LabelState| {},
3029                |_s: &mut LabelState| {},
3030            )
3031            .trailing_label(s.label)
3032        }
3033        let mut root: frust_core::RenderRoot<LabelState, SplitButtonView<LabelState>> =
3034            frust_core::RenderRoot::new();
3035        let mut state = LabelState { label: "First" };
3036        let mut tcx = TextContext::new();
3037        root.rebuild(&mut logic, &mut state);
3038        root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
3039        let before = root.semantics();
3040        assert!(
3041            before.nodes.iter().any(|(_, n)| n.label() == Some("First")),
3042            "the initial trailing label reaches the node"
3043        );
3044
3045        state.label = "Second";
3046        root.rebuild(&mut logic, &mut state);
3047        root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
3048        let after = root.semantics();
3049
3050        assert!(
3051            after.nodes.iter().any(|(_, n)| n.label() == Some("Second")),
3052            "the rebuilt trailing label reaches the node"
3053        );
3054        assert!(
3055            after.nodes.iter().all(|(_, n)| n.label() != Some("First")),
3056            "the stale label is gone after rebuild"
3057        );
3058    }
3059
3060    // ---- 5. the two menu styles --------------------------------------------
3061
3062    fn with_items(open: bool) -> SplitButtonView<Log> {
3063        plain(open)
3064            .items(vec![
3065                split_button_item("Save as").value("save-as"),
3066                split_button_item("Export").leading(icons::CHECK),
3067                split_button_item("Delete").enabled(false),
3068            ])
3069            .on_select(|s: &mut Log, selection| s.selections.push(selection))
3070    }
3071
3072    #[test]
3073    fn the_menu_style_selects_the_route() {
3074        let popup = with_items(true);
3075        assert!(matches!(popup.menu_route(), SplitButtonMenuRoute::Popup(_)));
3076        assert_eq!(popup.menu_style, SplitButtonMenuStyle::Popup);
3077        assert_eq!(SplitButtonMenuStyle::default(), SplitButtonMenuStyle::Popup);
3078
3079        let sheet = with_items(true).menu_style(SplitButtonMenuStyle::BottomSheet);
3080        assert!(matches!(sheet.menu_route(), SplitButtonMenuRoute::Sheet(_)));
3081    }
3082
3083    #[test]
3084    fn items_map_onto_selectable_menu_nodes() {
3085        let view = with_items(true);
3086        let nodes = view.menu_nodes();
3087        assert_eq!(nodes.len(), 3);
3088        let labels: Vec<&str> = view.items.iter().map(SplitButtonItem::label).collect();
3089        assert_eq!(labels, ["Save as", "Export", "Delete"]);
3090        assert_eq!(view.items[0].resolved_value(), "save-as");
3091        assert_eq!(
3092            view.items[1].resolved_value(),
3093            "Export",
3094            "an item with no explicit value reports its label"
3095        );
3096        for node in &nodes {
3097            assert!(
3098                matches!(node, MenuNode::Selectable(_)),
3099                "every item maps onto `M3EMenuSelectable`"
3100            );
3101        }
3102        let MenuNode::Selectable(delete) = &nodes[2] else {
3103            panic!("a selectable node");
3104        };
3105        assert_eq!(delete.label(), "Delete");
3106        assert!(
3107            !nodes[2].is_activatable(),
3108            "a disabled item stays disabled in the menu"
3109        );
3110    }
3111
3112    #[test]
3113    fn the_popup_route_places_its_panel_against_the_trailing_segment() {
3114        // The trailing segment captures its own window rect into the anchor on
3115        // every paint (the inlined `overlay_anchor` capture), which is what the
3116        // popup then places against.
3117        let anchor = OverlayAnchor::new();
3118        let view = with_items(true).menu_anchor(&anchor);
3119        let mut widget = laid_out(&view);
3120        let size = layout_with(&mut widget, 600.0, None);
3121        paint_at(&mut widget, size, None);
3122
3123        let captured = anchor.rect();
3124        assert_eq!(captured.x0, widget.segments[1].origin().x);
3125        assert_eq!(captured.width(), widget.segments[1].size().width);
3126        assert!(captured.height() > 0.0);
3127    }
3128
3129    #[test]
3130    fn a_menu_selection_reports_through_on_select() {
3131        // Mount the popup route the documented way and click its first row.
3132        let anchor = OverlayAnchor::new();
3133        anchor.set(Rect::new(40.0, 100.0, 160.0, 140.0));
3134        let mut root: frust_core::RenderRoot<Log, frust::StackView<Log>> =
3135            frust_core::RenderRoot::new();
3136        let mut state = Log::default();
3137        let mut tcx = TextContext::new();
3138        let cell = anchor.clone();
3139        let mut logic = move |_s: &mut Log| {
3140            frust::stack().child(with_items(true).menu_anchor(&cell).popup_menu())
3141        };
3142        root.rebuild(&mut logic, &mut state);
3143        root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
3144
3145        // The first row's centre, per the anchored menu's documented placement
3146        // (below the trigger, leading edges flush, at the anchor gap).
3147        let x = 40.0 + crate::menu::MENU_MIN_WIDTH / 2.0;
3148        let y = 140.0 + crate::overlay::OVERLAY_ANCHOR_GAP + 8.0 + (4.0 + 48.0) / 2.0;
3149        for phase in [PointerPhase::Down, PointerPhase::Up] {
3150            root.event(
3151                &mut state,
3152                &InputEvent::Pointer(PointerEvent {
3153                    phase,
3154                    position: Point::new(x, y),
3155                    button: PointerButton::Primary,
3156                }),
3157            );
3158        }
3159        assert_eq!(state.selections.len(), 1);
3160        assert_eq!(state.selections[0].label, "Save as");
3161        assert_eq!(state.selections[0].value(), Some("save-as"));
3162    }
3163
3164    #[test]
3165    fn the_sheet_route_carries_the_same_items() {
3166        // The sheet is content for `show_bottom_sheet`; what this pins is that
3167        // the same item list reaches it (the panel is the catalog's one
3168        // item-list surface, per the module docs' porting decision).
3169        let mut root: frust_core::RenderRoot<Log, BottomSheetView<Log>> =
3170            frust_core::RenderRoot::new();
3171        let mut state = Log::default();
3172        let mut tcx = TextContext::new();
3173        // Mount whatever the sheet-style route hands back, per pass.
3174        let mut logic = move |_s: &mut Log| {
3175            let view = with_items(true).menu_style(SplitButtonMenuStyle::BottomSheet);
3176            match view.menu_route() {
3177                SplitButtonMenuRoute::Sheet(sheet) => sheet,
3178                SplitButtonMenuRoute::Popup(_) => {
3179                    panic!("the bottom-sheet style routes to a sheet")
3180                }
3181            }
3182        };
3183        root.rebuild(&mut logic, &mut state);
3184        root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
3185        let update = root.semantics();
3186        // Every row reaches the accessibility tree through the sheet.
3187        for label in ["Save as", "Export", "Delete"] {
3188            assert!(
3189                update.nodes.iter().any(|(_, n)| n.label() == Some(label)),
3190                "`{label}` is present in the sheet-style menu"
3191            );
3192        }
3193    }
3194
3195    // ---- paint / consumer smoke -------------------------------------------
3196
3197    #[test]
3198    fn a_themed_paint_fills_both_halves_with_the_variant_container() {
3199        let theme = crate::baseline().with_brightness(Brightness::Light);
3200        let mut widget = laid_out(&plain(false).variant(SplitButtonVariant::Tonal));
3201        let rec = paint_at(&mut widget, Size::new(300.0, 48.0), Some(&theme));
3202        let container = theme.scheme().secondary_container;
3203        assert_eq!(rec.rrects[0].3, container, "the leading container");
3204        assert_eq!(rec.rrects[1].3, container, "the trailing container");
3205        assert!(!rec.runs.is_empty(), "the leading label painted");
3206        assert_eq!(
3207            rec.paths.len(),
3208            1,
3209            "exactly one chevron path, on the trailing half"
3210        );
3211        assert_eq!(rec.paths[0], theme.scheme().on_secondary_container);
3212    }
3213
3214    #[test]
3215    fn an_outlined_split_button_strokes_a_hairline_per_half() {
3216        let mut widget = laid_out(&plain(false).variant(SplitButtonVariant::Outlined));
3217        let rec = paint_at(&mut widget, Size::new(300.0, 48.0), None);
3218        assert_eq!(rec.strokes.len(), 2, "one hairline per segment");
3219        for (width, _) in &rec.strokes {
3220            assert_eq!(*width, OUTLINE_WIDTH);
3221        }
3222        assert!(
3223            rec.rrects.iter().all(|(_, _, _, c)| c.components[3] > 0.0),
3224            "a transparent container is never filled"
3225        );
3226    }
3227
3228    #[test]
3229    fn an_elevated_split_button_casts_a_shadow_per_half() {
3230        let mut widget = laid_out(&plain(false).variant(SplitButtonVariant::Elevated));
3231        let rec = paint_at(&mut widget, Size::new(300.0, 48.0), None);
3232        assert_eq!(rec.shadows, 2);
3233        assert_eq!(
3234            elevation_dp(SplitButtonVariant::Elevated, true, false, false),
3235            1.0
3236        );
3237        assert_eq!(
3238            elevation_dp(SplitButtonVariant::Elevated, true, false, true),
3239            3.0
3240        );
3241        assert_eq!(
3242            elevation_dp(SplitButtonVariant::Elevated, true, true, false),
3243            0.0
3244        );
3245        assert_eq!(
3246            elevation_dp(SplitButtonVariant::Elevated, false, false, false),
3247            0.0
3248        );
3249    }
3250
3251    #[test]
3252    fn a_leading_icon_lays_out_before_the_label_at_the_size_s_gap() {
3253        let with_icon = plain(false)
3254            .size(SplitButtonSize::Md)
3255            .leading_icon(|| frust::icon(icons::CHECK));
3256        let mut widget = laid_out(&with_icon);
3257        layout_with(&mut widget, 600.0, None);
3258        let seg = segment(&mut widget, 0);
3259        let icon = seg.icon.as_ref().expect("the icon pod");
3260        assert_eq!(icon.origin().x, SplitButtonSize::Md.left_outer_padding());
3261        assert_eq!(icon.size(), Size::new(24.0, 24.0));
3262        assert_eq!(
3263            seg.label_origin.x,
3264            SplitButtonSize::Md.left_outer_padding()
3265                + 24.0
3266                + SplitButtonSize::Md.icon_to_label_gap()
3267        );
3268    }
3269
3270    #[test]
3271    fn leading_icon_accepts_a_concrete_view_or_an_any_view() {
3272        let concrete = plain(false).leading_icon(|| frust::icon(icons::CHECK));
3273        let erased = plain(false).leading_icon(|| any::<Log, _>(frust::icon(icons::CHECK)));
3274        let mut a = laid_out(&concrete);
3275        let mut b = laid_out(&erased);
3276        assert_eq!(a.segments.len(), b.segments.len());
3277        assert!(segment(&mut a, 0).icon.is_some());
3278        assert!(segment(&mut b, 0).icon.is_some());
3279        assert_eq!(
3280            segment(&mut a, 0).icon.as_ref().unwrap().size(),
3281            segment(&mut b, 0).icon.as_ref().unwrap().size()
3282        );
3283    }
3284
3285    #[test]
3286    fn the_original_four_argument_surface_still_compiles_and_fires() {
3287        // The compatibility pin: the pre-rework call shape, unchanged.
3288        let view: SplitButtonView<Log> = split_button(
3289            "Save",
3290            false,
3291            |s: &mut Log| s.presses += 1,
3292            |s: &mut Log| s.opens += 1,
3293        );
3294        let _pascal: SplitButtonView<Log> = SplitButton(
3295            "Save",
3296            false,
3297            |s: &mut Log| s.presses += 1,
3298            |s: &mut Log| s.opens += 1,
3299        );
3300        let mut widget = laid_out(&view);
3301        let size = layout_with(&mut widget, 600.0, None);
3302        let mut state = Log::default();
3303        click(&mut widget, &mut state, size, 20.0, size.height / 2.0);
3304        assert_eq!(state.presses, 1);
3305    }
3306
3307    #[test]
3308    fn a_release_outside_the_armed_half_fires_nothing() {
3309        let mut widget = laid_out(&plain(false));
3310        let size = layout_with(&mut widget, 600.0, None);
3311        let mut state = Log::default();
3312        let trailing_x = widget.segments[1].origin().x + 10.0;
3313        dispatch(
3314            &mut widget,
3315            &mut state,
3316            size,
3317            &ev(PointerPhase::Down, 20.0, 20.0),
3318        );
3319        dispatch(
3320            &mut widget,
3321            &mut state,
3322            size,
3323            &ev(PointerPhase::Move, trailing_x, 20.0),
3324        );
3325        dispatch(
3326            &mut widget,
3327            &mut state,
3328            size,
3329            &ev(PointerPhase::Up, trailing_x, 20.0),
3330        );
3331        assert_eq!((state.presses, state.opens), (0, 0));
3332    }
3333
3334    #[test]
3335    fn a_cancel_disarms_without_firing() {
3336        let mut widget = laid_out(&plain(false));
3337        let size = layout_with(&mut widget, 600.0, None);
3338        let mut state = Log::default();
3339        let trailing_x = widget.segments[1].origin().x + 10.0;
3340        dispatch(
3341            &mut widget,
3342            &mut state,
3343            size,
3344            &ev(PointerPhase::Down, trailing_x, 20.0),
3345        );
3346        dispatch(
3347            &mut widget,
3348            &mut state,
3349            size,
3350            &ev(PointerPhase::Cancel, trailing_x, 20.0),
3351        );
3352        assert_eq!(state.opens, 0);
3353        assert!(!segment(&mut widget, 1).captured);
3354    }
3355
3356    #[test]
3357    fn a_non_material_theme_still_resolves_every_role() {
3358        // An app is free to thread a bare theme; nothing here may panic.
3359        let bare = Theme::neutral();
3360        for variant in SplitButtonVariant::ALL {
3361            let _ = resolve_colors(Some(&bare), variant, true);
3362            let _ = resolve_colors(Some(&bare), variant, false);
3363        }
3364        for size in SplitButtonSize::ALL {
3365            let _ = size.inner_radius(Some(&bare));
3366            let _ = size.label_style(Some(&bare));
3367        }
3368        let mut widget = laid_out(&plain(true));
3369        let _ = paint_at(&mut widget, Size::new(300.0, 48.0), Some(&bare));
3370    }
3371}