Skip to main content

frust_material/
button_group.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// whose `lib/components/toggle_button_group/` tree is itself vendored from
3// m3e_buttons (MIT, © 2026 Mudit Purohit) — `m3e_toggle_button_group.dart`
4// plus its nine `components/` parts, `controllers/`, `enums/`, `models/` and
5// `styles/m3e_toggle_button_group_theme.dart` (retrieved 2026-08-20).
6// Upstream: <https://github.com/paadevelopments/material_3_expressive>
7// Porting decisions: the offstage checked/unchecked *measurer* and its
8// `stableAllOverflowMeasured` gate collapse to a plain natural-size layout
9// pass (this framework can lay a child out synchronously, which is the whole
10// problem that machinery exists to work around); `experimentalPaging` (and
11// with it `M3EOverflowStrategy`, the custom strategy seam) is not ported (see
12// the module docs' Overflow section). The `popup` overflow-menu style *is*
13// ported, onto `crate::overlay::anchored` + `crate::menu` — see
14// `ButtonGroupOverflow::Popup` and `ButtonGroupView::overflow_menu`.
15
16//! The Material 3 Expressive **button group**: a row (or column) of
17//! [`mod@crate::toggle_button`] members, `standard` (spaced) or `connected`
18//! (shared-edge), with the reference's neighbor-squish press animation.
19//!
20//! Every member is a real [`crate::ToggleButtonView`] — the group hands it
21//! [`ToggleButtonView::group_connected`](crate::ToggleButtonView::group_connected)/
22//! [`first_in_group`](crate::ToggleButtonView::first_in_group)/
23//! [`last_in_group`](crate::ToggleButtonView::last_in_group) and that widget
24//! owns its own corner asymmetry, state layer, shape morph and haptics. This
25//! module owns only what a *group* adds: the axis layout, the resolved gap,
26//! the squish, the selection fan-out, and the overflow split.
27//!
28//! # Types × densities × direction
29//!
30//! | Axis | Values | Reference |
31//! |---|---|---|
32//! | [`ButtonGroupType`] | `Standard` (gaps) / `Connected` (shared edges) | `M3EButtonGroupType`, `m3e_toggle_button_group_enums.dart:9` |
33//! | [`ButtonGroupDensity`] | `Regular` / `Compact` (75% of the token gap, floored to whole dp) | `M3EButtonGroupDensity`, `:21` |
34//! | [`ButtonGroupDirection`] | `Horizontal` / `Vertical` | `M3EButtonGroup.direction` (`Axis`) |
35//!
36//! The resolved member gap ([`resolve_spacing`]) is:
37//!
38//! | Type | Regular | Compact |
39//! |---|---|---|
40//! | `Standard` | 8dp (`standardSpacing`) | 6dp (`floor(8 × 0.75)`) |
41//! | `Connected` | 2dp (`connectedGap`) | 2dp |
42//!
43//! **The connected row is density-independent on purpose, transcribed not
44//! "fixed".** `M3EToggleButtonGroupTheme.metricsFor` does compute a
45//! density-scaled connected gap (`m3e_toggle_button_group_theme.dart:86`),
46//! but `_buildGroup` then discards it — `spacing = widget.spacing ?? (connected
47//! ? 0.0 : metrics.spacing)` (`..._build.dart:13`) — and the separator each
48//! connected member actually gets is re-resolved from the raw `connectedGap`
49//! by `_buildGap`/`_separatorMainExtent` (`..._layout.dart:518`/`:542`). An
50//! explicit [`ButtonGroupView::spacing`] always wins over both.
51//!
52//! # Selection: single, multi, or uncontrolled
53//!
54//! A **controlled component** in all three modes (see `docs/CODE_STANDARDS.md`'s
55//! Interaction Semantics): a tap reports the *requested* selection and leaves
56//! this view's own props untouched until the app feeds them back.
57//! `_resolveToggleActionSelected` (`..._build.dart:96`) resolves which member
58//! reads checked, in this precedence:
59//!
60//! 1. [`ButtonGroupView::selected_indices`] — multi-select; membership in the set.
61//! 2. [`ButtonGroupView::on_selected_index_changed`] *or*
62//!    [`ButtonGroupView::selected_index`] — single-select; `selected == Some(i)`.
63//! 3. otherwise the per-action [`ButtonGroupAction::checked`] flag (uncontrolled).
64//!
65//! and `_onToggleCheckedChange` (`:184`) fans a tap out in the same order:
66//! the indices callback wins, then the index callback (which reports `None`
67//! when the tap *deselects* the current member), then — this port's own third
68//! arm — the legacy [`button_group`] `on_select`, which always reports the
69//! tapped index (its pre-rework contract, unchanged).
70//!
71//! # Neighbor squish (`ButtonGroupView::neighbor_squish`, default on)
72//!
73//! Pressing a member grows it by `natural × expanded_ratio × anim` and its
74//! neighbours absorb exactly that growth, so the row's total main extent is
75//! unchanged (`M3ERenderButtonGroup._applySquishSizes`/
76//! `_shrinkNeighborsForSquish`, `..._render.dart:245`/`:273`):
77//!
78//! * a **middle** member: each neighbour gives up `growth / 2`;
79//! * the **first** member: the single right neighbour gives up all of it;
80//! * the **last** member: the single left neighbour gives up all of it;
81//! * a **one-member** group grows with nothing to absorb it.
82//!
83//! Every shrink is floored at 0. [`squish_sizes`] is the transcription, unit
84//! tested directly. The reference gates the whole animation on
85//! `_supportsAnimatedSquish` (`m3e_toggle_button_group.dart:217`) — horizontal
86//! **and** not connected **and** `neighborSquish` — and so does
87//! [`ButtonGroupWidget::supports_squish`]. It is a *layout* animation, so it
88//! drives [`PaintCtx::request_layout`], never a bare `request_frame`, and it
89//! is disabled outright under `Theme::motion.reduce_motion` (the reference has
90//! no reduced-motion concept; freezing the squish is this port's own
91//! substitution, per `docs/WIDGETS_CODE_STANDARDS.md`).
92//!
93//! **Including the frame the spring lands on**, which asks for nothing of its
94//! own: [`AnimationController::advance`] reports `false` on the very pass that
95//! snaps the value onto its target, and the two state moves that end a press
96//! (releasing the pressed member, the reduced-motion freeze) animate nothing
97//! either. So `paint` compares the live squish state against the one `layout`
98//! actually ran with and asks for the missing pass itself — otherwise the row
99//! keeps the geometry of the last in-flight frame, a residue far too small to
100//! read as a box edge (~1e-4 px) yet perfectly able to leave a member a hair
101//! under its natural width for good. What a member *does* with a width a hair
102//! under natural is [`mod@crate::toggle_button`]'s own clipping contract; the
103//! two together are why a settled group looks exactly like one that was never
104//! pressed.
105//!
106//! [`SQUISH_SPRING`] is `M3EButtonMotion.standard` (stiffness 1200, ζ 0.8,
107//! `m3e_button_motion.dart:23`) — a named constant rather than a
108//! [`crate::MaterialSpring`] token because no preset carries that pair, and
109//! rather than a paint-time theme read because a press starts in the event
110//! pass, which threads no theme.
111//!
112//! # Overflow
113//!
114//! | [`ButtonGroupOverflow`] | Behaviour |
115//! |---|---|
116//! | `None` | Lay every member out; content wider than the box simply overflows it (`_buildAnimatedLinearLayout` under an unclipped `LayoutBuilder`). |
117//! | `Scroll` (default) | Lay every member out, clip to the box, and pan along the main axis once the content does not fit (`_linearScrollable`). |
118//! | `BottomSheet` | Show as many members as fit *plus* a trailing overflow trigger, and report the first hidden index through [`ButtonGroupView::on_overflow`] when the trigger is tapped — `M3EButtonGroupOverflow.menu` with `M3EButtonGroupOverflowMenuStyle.bottomSheet`. |
119//! | `Popup` | The same split and trigger as `BottomSheet`, but the hidden tail presents as an anchored menu via [`ButtonGroupView::overflow_menu`] — `M3EButtonGroupOverflow.menu` with `M3EButtonGroupOverflowMenuStyle.popup`. |
120//!
121//! `BottomSheet` and `Popup` share one split, [`visible_count_for_overflow`],
122//! the exact transcription of
123//! `M3EButtonGroupOverflowController.computeVisibleCountForMenu`
124//! (`m3e_button_group_overflow_controller.dart:61`), including its
125//! `roundConsumed`/`roundAvailable` (ceil/floor) rounding and its rule that
126//! room for the trigger *and* its separator is reserved as soon as anything
127//! would remain hidden.
128//!
129//! **The sheet itself is the app's to present; the popup is this module's.**
130//! [`crate::show_bottom_sheet`] needs a `NavigatorController` a leaf widget
131//! cannot reach, and its `build` closure is re-invoked on every navigator
132//! rebuild — so `BottomSheet` reports the split and leaves presentation to
133//! `on_overflow`, exactly the seam [`mod@crate::split_button`] uses for its
134//! own menu. `Popup` needs no navigator at all: [`mod@crate::overlay::anchored`]
135//! is a plain widget, so [`ButtonGroupView::overflow_menu`] composes one
136//! directly from the same action list `on_overflow` already reports the split
137//! over — the two-piece field/panel shape [`mod@crate::dropdown`] takes,
138//! since the trigger paints inside the group's own box and there is no portal
139//! to reach out through: the app mounts [`ButtonGroupView::overflow_menu`]
140//! separately (typically the top of its own [`frust::Stack`]), anchored to
141//! the [`OverlayAnchor`] handed to [`ButtonGroupView::overflow_anchor`]. This
142//! mirrors upstream's own `_onCustomOverflowPressed` → `showOverflowMenu` →
143//! `onItemSelected` flow (`m3e_toggle_button_group_layout.dart:181`-`:203`):
144//! a trigger tap never fires a hidden action directly, it opens the picker
145//! surface first, and only a row activation inside it reports a real
146//! selection. Two consequences of the split being a *layout*-time result the
147//! view cannot see: the trigger never reads checked (the reference marks it
148//! checked while the hidden range holds the selection,
149//! `_buildOverflowIndicatorButton`), and a group too narrow for even one
150//! member leaves the trigger's leading corner inner rather than outer
151//! (`isFirst: visibleCount == 0`).
152//!
153//! `experimentalPaging` (and with it `M3EOverflowStrategy`, the custom
154//! strategy seam) is deferred by approved decision.
155//!
156//! # Migrated off the legacy radial shape model
157//!
158//! The pre-rework module painted its own member backgrounds and a pressed
159//! "shape emphasis" overlay through the crate's legacy radial-model polygon
160//! (the crate docs name the module; this one no longer references it, and
161//! `this_module_no_longer_uses_the_legacy_radial_shape_model` is the
162//! tripwire). Both are gone: a member is a real toggle button, which paints
163//! its own container, state layer and per-corner shape morph.
164//! [`member_radii`] is kept as the group's documented outer/inner corner split
165//! (`M3EToggleButtonGroupTheme.connectedRadiusFor`); the shipped members
166//! resolve their own corners through `toggle_button`'s equivalent.
167//!
168//! # Semantics
169//!
170//! One [`Role::Group`] container (the reference's `Semantics(container: true,
171//! label: semanticLabel)`) forwarding every *visible* member's own node — a
172//! toggle button reports `Role::Button` + `Toggled`, so the group contributes
173//! no role of its own per member. Members hidden behind the overflow trigger
174//! are omitted rather than flagged hidden, under the input-parity carve-out in
175//! `docs/CODE_STANDARDS.md`'s Semantics Conventions: input cannot reach them.
176//!
177//! # Attribution
178//!
179//! See `plugins/material/NOTICE`'s "MIT License — Additional Copyright Holders
180//! (Vendored Components)" section (Mudit Purohit / m3e_buttons) and its Module
181//! Attribution Header Convention.
182
183use std::rc::Rc;
184use std::time::Duration;
185
186use frust::authoring::{
187    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedArgCallback, EventCtx,
188    EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent,
189    PointerPhase, Role, ScrollDelta, SemanticsCtx, View, Widget,
190};
191use frust::input::{TOUCH_SLOP, WHEEL_LINE_PX};
192use frust::{AnimationController, SpringDesc, Theme};
193use kurbo::{Point, RoundedRectRadii, Size};
194
195use crate::interaction::HapticSignal;
196use crate::menu::{MenuNode, MenuSelection, menu, menu_toggleable};
197use crate::overlay::{OverlayAnchor, overlay_anchor};
198use crate::press::presses;
199use crate::{ButtonVariant, ToggleButtonSize};
200
201// ---- Tokens (m3e_toggle_button_group_theme.dart's defaults) ----------------
202
203/// Gap between two `Standard`-type members, in logical px —
204/// `M3EToggleButtonGroupTheme.standardSpacing`'s default
205/// (`m3e_toggle_button_group_theme.dart:36`). `frust::Theme` carries no
206/// group-gap token, so this stays a module constant rather than a theme read.
207const STANDARD_SPACING: f64 = 8.0;
208
209/// Gap between two `Connected`-type members, in logical px —
210/// `M3EToggleButtonGroupTheme.connectedGap`'s default (`:37`). The hairline
211/// seam that keeps two abutting members visually distinct; the same value
212/// `toggle_button`'s connected-corner tables are authored against.
213const CONNECTED_GAP: f64 = 2.0;
214
215/// `M3EButtonGroupDensity.compact`'s multiplier — `(raw * 0.75)` floored to
216/// whole dp (`M3EToggleButtonGroupTheme.metricsFor`, `:92`).
217const COMPACT_SCALE: f64 = 0.75;
218
219/// `M3EButtonGroup.expandedRatio`'s default: a pressed member grows by 15% of
220/// its natural main extent (`m3e_toggle_button_group.dart:82`, and the
221/// identical `M3EToggleButtonGroupTheme.expandedRatio` default).
222const DEFAULT_EXPANDED_RATIO: f64 = 0.15;
223
224/// The squish spring: `M3EButtonMotion.standard` (stiffness 1200, ζ 0.8,
225/// `m3e_button_motion.dart:23`), the motion `_buildSquishAnimatedLayout` hands
226/// its `SingleMotionBuilder` when the group carries no decoration override.
227/// See the [module docs](self) for why it is a constant, and why no
228/// [`crate::MaterialSpring`] preset applies.
229const SQUISH_SPRING: SpringDesc = SpringDesc {
230    mass: 1.0,
231    stiffness: 1200.0,
232    damping_ratio: 0.8,
233};
234
235/// Nominal period seeding the squish [`AnimationController`]'s clock; the
236/// motion is spring-driven ([`SQUISH_SPRING`]) via `fling`, so this duration
237/// only backs the controller's construction and is not itself a timing.
238const SQUISH_ANIM_PERIOD: Duration = Duration::from_millis(300);
239
240/// Launch velocity (value-units/sec) handed to the press-in / press-out
241/// [`AnimationController::fling`] — a modest kick so the spring reads snappy.
242const FLING_VELOCITY: f64 = 4.0;
243
244/// Below this the squish spring is treated as settled at rest, releasing the
245/// recorded pressed member (in value units).
246const SQUISH_REST_EPSILON: f64 = 1e-3;
247
248/// Slack (logical px) below which the content is treated as fitting its
249/// viewport, so a sub-pixel rounding residue never arms a scroll pan.
250const FIT_EPSILON: f64 = 0.5;
251
252/// Unthemed-fallback outer corner radius (a theme resolves
253/// [`ShapeScale::large`](frust::ShapeScale::large)) — the `outer` a caller
254/// with no theme in hand passes [`member_radii`].
255pub const OUTER_RADIUS: f64 = 16.0;
256/// Unthemed-fallback inner (adjacent) corner radius (a theme resolves
257/// [`ShapeScale::extra_small`](frust::ShapeScale::extra_small)) — the `inner`
258/// counterpart of [`OUTER_RADIUS`].
259pub const INNER_RADIUS: f64 = 4.0;
260
261// ---- Enums -----------------------------------------------------------------
262
263/// How members are visually connected — `M3EButtonGroupType`
264/// (`m3e_toggle_button_group_enums.dart:9`).
265#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
266pub enum ButtonGroupType {
267    /// Independent members with a gap between them, each keeping its own
268    /// standalone shape. The reference's default.
269    #[default]
270    Standard,
271    /// Members share edges (a hairline [`CONNECTED_GAP`] seam): the group's
272    /// outer corners stay fully round, every inner corner resolves per state.
273    Connected,
274}
275
276/// Spacing compactness between adjacent members — `M3EButtonGroupDensity`
277/// (`m3e_toggle_button_group_enums.dart:21`).
278#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
279pub enum ButtonGroupDensity {
280    /// Full token spacing.
281    #[default]
282    Regular,
283    /// 75% of token spacing, floored to whole dp.
284    Compact,
285}
286
287/// The group's primary layout axis — `M3EButtonGroup.direction` (Flutter's
288/// `Axis`).
289#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
290pub enum ButtonGroupDirection {
291    /// A row: members flow leading to trailing.
292    #[default]
293    Horizontal,
294    /// A column: members flow top to bottom.
295    Vertical,
296}
297
298/// How the group handles members that do not fit — the ported subset of
299/// `M3EButtonGroupOverflow` (`m3e_toggle_button_group_enums.dart:30`). See the
300/// [module docs](self)' Overflow section for the one unported mode
301/// (`experimentalPaging`).
302#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
303pub enum ButtonGroupOverflow {
304    /// Do not handle overflow at all — content wider than the box overflows it.
305    None,
306    /// Clip to the box and pan along the main axis when the content does not
307    /// fit. The reference's default.
308    #[default]
309    Scroll,
310    /// Show only the members that fit plus a trailing overflow trigger,
311    /// reporting the first hidden index through
312    /// [`ButtonGroupView::on_overflow`] — `M3EButtonGroupOverflow.menu` with
313    /// `M3EButtonGroupOverflowMenuStyle.bottomSheet`. Presentation is the
314    /// app's own (see [`crate::show_bottom_sheet`]).
315    BottomSheet,
316    /// The same split and trigger as [`Self::BottomSheet`], presented instead
317    /// as an anchored popup menu via [`ButtonGroupView::overflow_menu`] —
318    /// `M3EButtonGroupOverflow.menu` with
319    /// `M3EButtonGroupOverflowMenuStyle.popup`.
320    Popup,
321}
322
323impl ButtonGroupDensity {
324    /// Apply the density to a raw token gap — `metricsFor`'s
325    /// `density == compact ? (raw * 0.75).floorToDouble() : raw`.
326    fn scale(self, raw: f64) -> f64 {
327        match self {
328            ButtonGroupDensity::Regular => raw,
329            ButtonGroupDensity::Compact => (raw * COMPACT_SCALE).floor(),
330        }
331    }
332}
333
334/// The gap between two adjacent members, in logical px: an explicit
335/// [`ButtonGroupView::spacing`] wins, else the type's token gap under the
336/// density. See the [module docs](self)' resolved-gap table (and why the
337/// connected row is density-independent).
338pub fn resolve_spacing(
339    explicit: Option<f64>,
340    group_type: ButtonGroupType,
341    density: ButtonGroupDensity,
342) -> f64 {
343    match explicit {
344        Some(spacing) => spacing,
345        None => match group_type {
346            ButtonGroupType::Connected => CONNECTED_GAP,
347            ButtonGroupType::Standard => density.scale(STANDARD_SPACING),
348        },
349    }
350}
351
352// ---- Squish + overflow geometry (pure, unit tested) ------------------------
353
354/// The per-member main extents after the neighbor-squish press animation —
355/// `M3ERenderButtonGroup._applySquishSizes` + `_shrinkNeighborsForSquish`
356/// (`..._render.dart:245`/`:273`).
357///
358/// `anim` is the spring's `0..=1` progress; `pressed` is the member being
359/// pressed (out-of-range or `None` leaves `natural` untouched, as does a
360/// non-positive `anim` — the reference's `_canApplySquish` guard). See the
361/// [module docs](self) for the geometry.
362pub fn squish_sizes(
363    natural: &[f64],
364    pressed: Option<usize>,
365    expanded_ratio: f64,
366    anim: f64,
367) -> Vec<f64> {
368    let mut sizes = natural.to_vec();
369    let count = sizes.len();
370    let Some(index) = pressed.filter(|i| *i < count) else {
371        return sizes;
372    };
373    if anim <= 0.0 {
374        return sizes;
375    }
376
377    let growth = natural[index] * expanded_ratio * anim;
378    sizes[index] += growth;
379    if count < 2 {
380        // A one-member group grows with no neighbour to absorb it.
381        return sizes;
382    }
383    if index > 0 && index + 1 < count {
384        let half = growth / 2.0;
385        sizes[index - 1] = (sizes[index - 1] - half).max(0.0);
386        sizes[index + 1] = (sizes[index + 1] - half).max(0.0);
387    } else if index == 0 {
388        sizes[1] = (sizes[1] - growth).max(0.0);
389    } else {
390        sizes[index - 1] = (sizes[index - 1] - growth).max(0.0);
391    }
392    sizes
393}
394
395/// How many members fit before the overflow trigger —
396/// `M3EButtonGroupOverflowController.computeVisibleCountForMenu`
397/// (`m3e_button_group_overflow_controller.dart:61`).
398///
399/// `max_main` is floored (`roundAvailable`); every consumed extent —
400/// `item_extents`, `trigger_extent`, `separator` — is ceiled
401/// (`roundConsumed`). Room for the trigger *and* the separator before it is
402/// reserved as soon as anything would remain hidden, and the comparison is
403/// strict (`<`), so an exactly-filling row still spills its last member.
404pub fn visible_count_for_overflow(
405    max_main: f64,
406    item_extents: &[f64],
407    trigger_extent: f64,
408    separator: f64,
409) -> usize {
410    let available = max_main.floor();
411    let trigger = trigger_extent.ceil();
412    let separator = separator.ceil();
413    let mut current = 0.0_f64;
414    let mut visible = 0usize;
415
416    for (i, extent) in item_extents.iter().enumerate() {
417        let gap_before = if i == 0 { 0.0 } else { separator };
418        let remaining_after = item_extents.len() - i - 1;
419        let reserved = if remaining_after > 0 {
420            separator + trigger
421        } else {
422            0.0
423        };
424        let next = current + gap_before + extent.ceil();
425        if next + reserved < available {
426            current = next;
427            visible = i + 1;
428        } else {
429            break;
430        }
431    }
432    visible
433}
434
435/// The per-corner radii for member `index` of a `count`-member connected
436/// group: outer corners (the group's ends) get `outer`, inner
437/// (adjacent-member) corners get `inner` —
438/// `M3EToggleButtonGroupTheme.connectedRadiusFor`
439/// (`m3e_toggle_button_group_theme.dart:112`).
440///
441/// - `count == 1`: all four corners `outer` (a standalone rounded button).
442/// - leading (`index == 0`): leading corners `outer`, trailing corners `inner`.
443/// - trailing (`index == count - 1`): the mirror.
444/// - middle: all four `inner`.
445///
446/// The shipped members resolve their own corners through
447/// [`mod@crate::toggle_button`]'s equivalent (which additionally carries the
448/// pressed/hovered/checked state arms); this stays the group's own documented
449/// statement of the outer/inner split, and this framework has no RTL layout,
450/// so "leading"/"trailing" map onto left/right unconditionally.
451pub fn member_radii(index: usize, count: usize, outer: f64, inner: f64) -> RoundedRectRadii {
452    let is_first = index == 0;
453    let is_last = index + 1 == count;
454    // top_left, top_right, bottom_right, bottom_left.
455    let left = if is_first { outer } else { inner };
456    let right = if is_last { outer } else { inner };
457    RoundedRectRadii::new(left, right, right, left)
458}
459
460// ---- The action model ------------------------------------------------------
461
462/// A view factory for one of an action's optional icon slots.
463///
464/// A factory rather than a plain [`AnyView`] because a member's toggle-button
465/// view is rebuilt on every frame (and re-created whenever the checked state
466/// flips), and an `AnyView` cannot be cloned out of the borrowed action — the
467/// same reproducible-builder shape [`crate::show_bottom_sheet`]'s own `build`
468/// closure takes.
469type IconFactory<State> = Rc<dyn Fn() -> AnyView<State>>;
470
471/// A declarative description of one member — `M3EButtonGroupAction`
472/// (`models/m3e_button_group_action.dart:12`).
473///
474/// Built with [`button_group_action`] (labelled) or
475/// [`button_group_icon_action`] (icon-only); the reference's
476/// `icon != null || label != null` assertion is a constructor choice here
477/// rather than a runtime check.
478pub struct ButtonGroupAction<State: 'static> {
479    icon: Option<IconFactory<State>>,
480    checked_icon: Option<IconFactory<State>>,
481    label: Option<String>,
482    checked_label: Option<String>,
483    checked: Option<bool>,
484    enabled: bool,
485    width: Option<f64>,
486}
487
488/// Create a labelled group member.
489pub fn button_group_action<State: 'static>(label: impl Into<String>) -> ButtonGroupAction<State> {
490    ButtonGroupAction {
491        icon: None,
492        checked_icon: None,
493        label: Some(label.into()),
494        checked_label: None,
495        checked: None,
496        enabled: true,
497        width: None,
498    }
499}
500
501/// Create an icon-only group member; `icon` is called once per rebuild of the
502/// member (see [`IconFactory`]'s note).
503pub fn button_group_icon_action<State: 'static, F>(icon: F) -> ButtonGroupAction<State>
504where
505    F: Fn() -> AnyView<State> + 'static,
506{
507    ButtonGroupAction {
508        icon: Some(Rc::new(icon)),
509        checked_icon: None,
510        label: None,
511        checked_label: None,
512        checked: None,
513        enabled: true,
514        width: None,
515    }
516}
517
518impl<State: 'static> ButtonGroupAction<State> {
519    /// The icon shown while unchecked (and while checked too, unless
520    /// [`Self::checked_icon`] is set).
521    pub fn icon<F: Fn() -> AnyView<State> + 'static>(mut self, icon: F) -> Self {
522        self.icon = Some(Rc::new(icon));
523        self
524    }
525
526    /// The icon shown while checked. Falls back to [`Self::icon`] when unset.
527    pub fn checked_icon<F: Fn() -> AnyView<State> + 'static>(mut self, icon: F) -> Self {
528        self.checked_icon = Some(Rc::new(icon));
529        self
530    }
531
532    /// The label shown while unchecked (and while checked too, unless
533    /// [`Self::checked_label`] is set).
534    pub fn label(mut self, label: impl Into<String>) -> Self {
535        self.label = Some(label.into());
536        self
537    }
538
539    /// The label shown while checked. Falls back to [`Self::label`] when unset.
540    pub fn checked_label(mut self, label: impl Into<String>) -> Self {
541        self.checked_label = Some(label.into());
542        self
543    }
544
545    /// The **uncontrolled** checked flag: consulted only when the group wires
546    /// neither a selected index nor a selected-index set (the third arm of
547    /// `_resolveToggleActionSelected`).
548    pub fn checked(mut self, checked: bool) -> Self {
549        self.checked = Some(checked);
550        self
551    }
552
553    /// Whether this member accepts a press (`M3EButtonGroupAction.enabled`).
554    pub fn enabled(mut self, enabled: bool) -> Self {
555        self.enabled = enabled;
556        self
557    }
558
559    /// Pin this member's main-axis extent instead of letting it size to its
560    /// content (`M3EButtonGroupAction.width`).
561    pub fn width(mut self, width: f64) -> Self {
562        self.width = Some(width);
563        self
564    }
565
566    /// The label a screen reader and the group's own tests read for this
567    /// member — the checked label when set, else the plain one.
568    fn accessible_label(&self) -> String {
569        self.checked_label
570            .clone()
571            .or_else(|| self.label.clone())
572            .unwrap_or_default()
573    }
574}
575
576// ---- The declarative view --------------------------------------------------
577
578/// The legacy single-select callback: reports the tapped member's index.
579type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
580/// `onSelectedIndexChanged`: `None` when the tap deselects the current member.
581type OnIndexChanged<State> = Rc<dyn Fn(&mut State, Option<usize>)>;
582/// `onSelectedIndicesChanged`: the requested selection set, ascending.
583type OnIndicesChanged<State> = Rc<dyn Fn(&mut State, Vec<usize>)>;
584/// Reports the first hidden index when the overflow trigger is tapped.
585type OnOverflow<State> = Rc<dyn Fn(&mut State, usize)>;
586
587/// A declarative M3 Expressive button group. See the [module docs](self).
588pub struct ButtonGroupView<State: 'static> {
589    actions: Vec<ButtonGroupAction<State>>,
590    group_type: ButtonGroupType,
591    density: ButtonGroupDensity,
592    direction: ButtonGroupDirection,
593    spacing: Option<f64>,
594    variant: ButtonVariant,
595    size: ToggleButtonSize,
596    haptic: HapticSignal,
597    selected: Option<usize>,
598    selected_indices: Option<Vec<usize>>,
599    on_select: Option<OnSelect<State>>,
600    on_index_changed: Option<OnIndexChanged<State>>,
601    on_indices_changed: Option<OnIndicesChanged<State>>,
602    neighbor_squish: bool,
603    expanded_ratio: f64,
604    overflow: ButtonGroupOverflow,
605    overflow_icon: Option<IconFactory<State>>,
606    on_overflow: Option<OnOverflow<State>>,
607    overflow_anchor: Option<OverlayAnchor>,
608    semantic_label: Option<String>,
609}
610
611/// Create a group from explicit [`ButtonGroupAction`]s — the full port of
612/// `M3EButtonGroup`, defaulting to [`ButtonGroupType::Standard`] and
613/// [`ButtonGroupOverflow::Scroll`] exactly like the reference.
614///
615/// Wire a selection with [`ButtonGroupView::selected_index`] +
616/// [`ButtonGroupView::on_selected_index_changed`] (single-select) or
617/// [`ButtonGroupView::selected_indices`] +
618/// [`ButtonGroupView::on_selected_indices_changed`] (multi-select); with
619/// neither, each action's own [`ButtonGroupAction::checked`] is read instead.
620pub fn button_group_actions<State: 'static>(
621    actions: impl IntoIterator<Item = ButtonGroupAction<State>>,
622) -> ButtonGroupView<State> {
623    ButtonGroupView {
624        actions: actions.into_iter().collect(),
625        group_type: ButtonGroupType::default(),
626        density: ButtonGroupDensity::default(),
627        direction: ButtonGroupDirection::default(),
628        spacing: None,
629        variant: ButtonVariant::default(),
630        size: ToggleButtonSize::default(),
631        haptic: HapticSignal::None,
632        selected: None,
633        selected_indices: None,
634        on_select: None,
635        on_index_changed: None,
636        on_indices_changed: None,
637        neighbor_squish: true,
638        expanded_ratio: DEFAULT_EXPANDED_RATIO,
639        overflow: ButtonGroupOverflow::default(),
640        overflow_icon: None,
641        on_overflow: None,
642        overflow_anchor: None,
643        semantic_label: None,
644    }
645}
646
647/// Create a single-select **connected** group with the given member `labels`,
648/// the currently-`selected` index, and an `on_select` callback fired (with the
649/// tapped member's index) on release inside a member.
650///
651/// A `selected` index outside `0..labels.len()` simply highlights no member
652/// (there is no panic); feed a valid index to show a selection.
653///
654/// This is the module's pre-rework constructor and keeps its pre-rework
655/// meaning: [`ButtonGroupType::Connected`], and an `on_select` that always
656/// reports the tapped index (never a deselect `None`). Reach for
657/// [`button_group_actions`] for the full port — icons, multi-select,
658/// densities, direction, and overflow — whose defaults follow the reference's
659/// (`Standard`) instead.
660pub fn button_group<State: 'static, F: Fn(&mut State, usize) + 'static>(
661    labels: impl IntoIterator<Item = impl Into<String>>,
662    selected: usize,
663    on_select: F,
664) -> ButtonGroupView<State> {
665    let actions = labels
666        .into_iter()
667        .map(|label| button_group_action::<State>(label));
668    button_group_actions(actions)
669        .group_type(ButtonGroupType::Connected)
670        .selected_index(Some(selected))
671        .on_select(on_select)
672}
673
674/// PascalCase alias for [`button_group`], matching the container view-fn
675/// vocabulary.
676#[allow(non_snake_case)]
677pub fn ButtonGroup<State: 'static, F: Fn(&mut State, usize) + 'static>(
678    labels: impl IntoIterator<Item = impl Into<String>>,
679    selected: usize,
680    on_select: F,
681) -> ButtonGroupView<State> {
682    button_group(labels, selected, on_select)
683}
684
685impl<State: 'static> ButtonGroupView<State> {
686    /// Set the connection treatment (the [module docs](self)' types table).
687    pub fn group_type(mut self, group_type: ButtonGroupType) -> Self {
688        self.group_type = group_type;
689        self
690    }
691
692    /// Set the spacing compactness (the [module docs](self)' gap table).
693    pub fn density(mut self, density: ButtonGroupDensity) -> Self {
694        self.density = density;
695        self
696    }
697
698    /// Set the primary layout axis.
699    pub fn direction(mut self, direction: ButtonGroupDirection) -> Self {
700        self.direction = direction;
701        self
702    }
703
704    /// Override the resolved member gap, in logical px.
705    pub fn spacing(mut self, spacing: f64) -> Self {
706        self.spacing = Some(spacing);
707        self
708    }
709
710    /// Set every member's container treatment
711    /// (`M3EButtonGroup.style`).
712    pub fn variant(mut self, variant: ButtonVariant) -> Self {
713        self.variant = variant;
714        self
715    }
716
717    /// Set every member's size tier (`M3EButtonGroup.size`).
718    pub fn size(mut self, size: ToggleButtonSize) -> Self {
719        self.size = size;
720        self
721    }
722
723    /// Set the haptic signal each member fires on a confirmed toggle.
724    pub fn haptic(mut self, haptic: HapticSignal) -> Self {
725        self.haptic = haptic;
726        self
727    }
728
729    /// The single-select selected index (`M3EButtonGroup.selectedIndex`).
730    pub fn selected_index(mut self, selected: Option<usize>) -> Self {
731        self.selected = selected;
732        self
733    }
734
735    /// The multi-select selection set (`M3EButtonGroup.selectedIndices`) —
736    /// takes precedence over [`Self::selected_index`].
737    pub fn selected_indices(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
738        let mut indices: Vec<usize> = indices.into_iter().collect();
739        indices.sort_unstable();
740        indices.dedup();
741        self.selected_indices = Some(indices);
742        self
743    }
744
745    /// The legacy single-select callback: always the tapped index. Lowest
746    /// precedence of the three (see the [module docs](self)' selection order).
747    pub fn on_select<F: Fn(&mut State, usize) + 'static>(mut self, on_select: F) -> Self {
748        self.on_select = Some(Rc::new(on_select));
749        self
750    }
751
752    /// `onSelectedIndexChanged`: the requested single selection, `None` when
753    /// the tap deselects the currently-selected member.
754    pub fn on_selected_index_changed<F: Fn(&mut State, Option<usize>) + 'static>(
755        mut self,
756        on_change: F,
757    ) -> Self {
758        self.on_index_changed = Some(Rc::new(on_change));
759        self
760    }
761
762    /// `onSelectedIndicesChanged`: the requested multi selection, ascending.
763    /// Highest precedence of the three.
764    pub fn on_selected_indices_changed<F: Fn(&mut State, Vec<usize>) + 'static>(
765        mut self,
766        on_change: F,
767    ) -> Self {
768        self.on_indices_changed = Some(Rc::new(on_change));
769        self
770    }
771
772    /// Whether a press squishes its neighbours (default `true`) — see the
773    /// [module docs](self)' squish section for when it applies at all.
774    pub fn neighbor_squish(mut self, squish: bool) -> Self {
775        self.neighbor_squish = squish;
776        self
777    }
778
779    /// The fraction of its natural main extent a pressed member grows by
780    /// (default [`DEFAULT_EXPANDED_RATIO`]).
781    pub fn expanded_ratio(mut self, ratio: f64) -> Self {
782        self.expanded_ratio = ratio;
783        self
784    }
785
786    /// How members that do not fit are handled (the [module docs](self)'
787    /// Overflow table).
788    pub fn overflow(mut self, overflow: ButtonGroupOverflow) -> Self {
789        self.overflow = overflow;
790        self
791    }
792
793    /// Replace the overflow trigger's icon (`M3EButtonGroup.overflowIcon`;
794    /// the default is `M3EIcons.more_horiz`).
795    pub fn overflow_icon<F: Fn() -> AnyView<State> + 'static>(mut self, icon: F) -> Self {
796        self.overflow_icon = Some(Rc::new(icon));
797        self
798    }
799
800    /// Fired with the first hidden action's index when the overflow trigger is
801    /// tapped — where an app presents [`crate::show_bottom_sheet`] over
802    /// `actions[first_hidden..]` ([`ButtonGroupOverflow::BottomSheet`]) or
803    /// mounts [`Self::overflow_menu`] ([`ButtonGroupOverflow::Popup`]). See
804    /// the [module docs](self)' Overflow section.
805    pub fn on_overflow<F: Fn(&mut State, usize) + 'static>(mut self, on_overflow: F) -> Self {
806        self.on_overflow = Some(Rc::new(on_overflow));
807        self
808    }
809
810    /// Capture the overflow trigger's window rect into `anchor` on every
811    /// paint — wire this alongside [`ButtonGroupOverflow::Popup`] so
812    /// [`Self::overflow_menu`] has a rect to place against, the same
813    /// [`OverlayAnchor`] handoff [`crate::dropdown::dropdown_field`] takes
814    /// with [`mod@crate::dropdown`]'s panel half. A no-op for every other
815    /// [`ButtonGroupOverflow`] variant.
816    pub fn overflow_anchor(mut self, anchor: &OverlayAnchor) -> Self {
817        self.overflow_anchor = Some(anchor.clone());
818        self
819    }
820
821    /// The accessible name of the group as a whole
822    /// (`M3EButtonGroup.semanticLabel`).
823    pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
824        self.semantic_label = Some(label.into());
825        self
826    }
827
828    /// Whether member `index` reads checked — `_resolveToggleActionSelected`
829    /// (`..._build.dart:96`), in the [module docs](self)' documented
830    /// precedence.
831    fn is_selected(&self, index: usize) -> bool {
832        if let Some(set) = &self.selected_indices {
833            return set.contains(&index);
834        }
835        if self.on_index_changed.is_some() || self.selected.is_some() {
836            return self.selected == Some(index);
837        }
838        self.actions[index].checked.unwrap_or(false)
839    }
840
841    /// The member view for `index`, already wired for selection fan-out and
842    /// (when connected) its end-cap flags.
843    fn member_view(&self, index: usize) -> AnyView<State> {
844        let action = &self.actions[index];
845        let count = self.actions.len();
846        let connected = self.group_type == ButtonGroupType::Connected;
847
848        let on_indices = self.on_indices_changed.clone();
849        let on_index = self.on_index_changed.clone();
850        let on_select = self.on_select.clone();
851        let current = self.selected_indices.clone().unwrap_or_default();
852
853        let mut member = crate::toggle_button::toggle_button::<State, _>(
854            self.is_selected(index),
855            move |state: &mut State, checked: bool| {
856                if let Some(callback) = &on_indices {
857                    let mut next = current.clone();
858                    if checked {
859                        if !next.contains(&index) {
860                            next.push(index);
861                            next.sort_unstable();
862                        }
863                    } else {
864                        next.retain(|i| *i != index);
865                    }
866                    callback(state, next);
867                } else if let Some(callback) = &on_index {
868                    callback(state, checked.then_some(index));
869                } else if let Some(callback) = &on_select {
870                    callback(state, index);
871                }
872            },
873        )
874        .variant(self.variant)
875        .size(self.size)
876        .enabled(action.enabled)
877        .haptic(self.haptic)
878        .group_connected(connected)
879        .first_in_group(index == 0)
880        // The end cap stays on the *list's* last member: when the overflow
881        // split hides a tail, the trigger takes over as the trailing cap and
882        // no visible member claims it, which is exactly the reference's
883        // `_buildOverflowMenuVisibleItems` shape (`isLast: false` per item).
884        .last_in_group(index + 1 == count);
885
886        if let Some(icon) = &action.icon {
887            member = member.icon(icon());
888        }
889        if let Some(icon) = &action.checked_icon {
890            member = member.checked_icon(icon());
891        }
892        if let Some(label) = &action.label {
893            member = member.label(label.clone());
894        }
895        if let Some(label) = &action.checked_label {
896            member = member.checked_label(label.clone());
897        }
898        frust::authoring::any::<State, _>(member)
899    }
900
901    /// The overflow trigger's own view — an icon-only toggle button. Its
902    /// `on_checked_change` is inert: the *group* fires
903    /// [`Self::on_overflow`] from its own release arm, since the first hidden
904    /// index is only known after layout.
905    ///
906    /// Under [`ButtonGroupOverflow::Popup`] with [`Self::overflow_anchor`]
907    /// wired, the trigger is wrapped in [`overlay_anchor`] so its window rect
908    /// is captured into that anchor on every paint — the trigger half of the
909    /// two-piece shape [`Self::overflow_menu`] presents against.
910    fn trigger_view(&self) -> AnyView<State> {
911        let icon = match &self.overflow_icon {
912            Some(factory) => factory(),
913            None => frust::authoring::any::<State, _>(frust::icon(crate::icons::MORE_HORIZ)),
914        };
915        let trigger =
916            crate::toggle_button::toggle_button::<State, _>(false, |_state: &mut State, _v| {})
917                .variant(self.variant)
918                .size(self.size)
919                .haptic(self.haptic)
920                .group_connected(self.group_type == ButtonGroupType::Connected)
921                .first_in_group(false)
922                .last_in_group(true)
923                .icon(icon);
924        match (&self.overflow_anchor, self.overflow) {
925            (Some(anchor), ButtonGroupOverflow::Popup) => {
926                frust::authoring::any::<State, _>(overlay_anchor(anchor, trigger))
927            }
928            _ => frust::authoring::any::<State, _>(trigger),
929        }
930    }
931
932    /// Every member view, in order.
933    fn member_views(&self) -> Vec<AnyView<State>> {
934        (0..self.actions.len())
935            .map(|i| self.member_view(i))
936            .collect()
937    }
938
939    /// The trigger view, when this group can split at all.
940    fn maybe_trigger_view(&self) -> Option<AnyView<State>> {
941        matches!(
942            self.overflow,
943            ButtonGroupOverflow::BottomSheet | ButtonGroupOverflow::Popup
944        )
945        .then(|| self.trigger_view())
946    }
947
948    /// The popup-style overflow menu for [`ButtonGroupOverflow::Popup`] —
949    /// mount this separately as the top of the app's own [`frust::Stack`]
950    /// (or a transparent navigator page), anchored to the same
951    /// [`OverlayAnchor`] handed to [`Self::overflow_anchor`]. See the
952    /// [module docs](self)' Overflow section for why this is a second,
953    /// app-mounted view rather than something [`ButtonGroupWidget`] paints
954    /// itself.
955    ///
956    /// `first_hidden` is the index [`Self::on_overflow`] reported when the
957    /// trigger was tapped; `open` drives the anchored host's ramp (the
958    /// kept-mounted pattern [`mod@crate::overlay::anchored`] documents — keep
959    /// this view mounted and toggle `open` for the exit ramp rather than
960    /// unmounting it, [`mod@crate::dropdown`]'s own convention).
961    ///
962    /// Each hidden member becomes a [`crate::menu::MenuToggleable`] row,
963    /// checked exactly as [`Self::is_selected`] already resolves it for a
964    /// visible member — upstream's own overflow menu marks a hidden action
965    /// checked the same way
966    /// (`_buildOverflowMenuVisibleItems`/`onItemSelected`,
967    /// `m3e_toggle_button_group_layout.dart:181`-`:203`). Activating one
968    /// routes through this group's own selection fan-out —
969    /// [`Self::on_selected_indices_changed`],
970    /// [`Self::on_selected_index_changed`], or [`Self::on_select`], in that
971    /// precedence — exactly as a visible member's own tap does.
972    ///
973    /// A member's [`ButtonGroupAction::icon`]/[`ButtonGroupAction::checked_icon`]
974    /// is an arbitrary view (a member button can paint anything), which a
975    /// menu row's [`crate::menu::MenuIcon`] slot cannot carry (it names a
976    /// vector path, not a view) — a popup row therefore shows the member's
977    /// label only. An icon-only group (this crate's usual toolbar shape)
978    /// should give its actions a plain [`ButtonGroupAction::label`] alongside
979    /// the icon so an overflowed member still reads in the popup.
980    pub fn overflow_menu(&self, first_hidden: usize, open: bool) -> AnyView<State> {
981        let anchor = self.overflow_anchor.clone().unwrap_or_default();
982        let start = first_hidden.min(self.actions.len());
983        let nodes: Vec<MenuNode> = self.actions[start..]
984            .iter()
985            .enumerate()
986            .map(|(offset, action)| {
987                menu_toggleable(action.accessible_label(), self.is_selected(start + offset))
988                    .enabled(action.enabled)
989                    .into()
990            })
991            .collect();
992
993        let on_indices = self.on_indices_changed.clone();
994        let on_index = self.on_index_changed.clone();
995        let on_select = self.on_select.clone();
996        let current = self.selected_indices.clone().unwrap_or_default();
997
998        frust::authoring::any::<State, _>(
999            menu(nodes, move |state: &mut State, selection: MenuSelection| {
1000                let index = start + selection.index;
1001                let checked = selection.checked().unwrap_or(true);
1002                if let Some(callback) = &on_indices {
1003                    let mut next = current.clone();
1004                    if checked {
1005                        if !next.contains(&index) {
1006                            next.push(index);
1007                            next.sort_unstable();
1008                        }
1009                    } else {
1010                        next.retain(|i| *i != index);
1011                    }
1012                    callback(state, next);
1013                } else if let Some(callback) = &on_index {
1014                    callback(state, checked.then_some(index));
1015                } else if let Some(callback) = &on_select {
1016                    callback(state, index);
1017                }
1018            })
1019            .anchor(&anchor)
1020            .open(open),
1021        )
1022    }
1023}
1024
1025// ---- The retained widget ---------------------------------------------------
1026
1027/// Which pod a live gesture is addressed to.
1028#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1029enum Target {
1030    /// `members[index]`.
1031    Member(usize),
1032    /// The overflow trigger.
1033    Trigger,
1034}
1035
1036/// The retained widget for a [`ButtonGroupView`]. See the [module docs](self).
1037pub struct ButtonGroupWidget {
1038    /// One toggle-button pod per action, in order.
1039    members: Vec<ChildPod>,
1040    /// The overflow trigger pod (present only in
1041    /// [`ButtonGroupOverflow::BottomSheet`]/[`ButtonGroupOverflow::Popup`]).
1042    trigger: Option<ChildPod>,
1043    /// Per-action accessible labels, retained for diagnostics/tests.
1044    labels: Vec<String>,
1045    /// Per-action pinned main extents (`M3EButtonGroupAction.width`).
1046    widths: Vec<Option<f64>>,
1047    group_type: ButtonGroupType,
1048    density: ButtonGroupDensity,
1049    direction: ButtonGroupDirection,
1050    spacing: Option<f64>,
1051    neighbor_squish: bool,
1052    expanded_ratio: f64,
1053    overflow: ButtonGroupOverflow,
1054    semantic_label: Option<String>,
1055    on_overflow: Option<ErasedArgCallback<usize>>,
1056
1057    // ---- layout output, read by paint/event ----
1058    /// How many members were laid out this pass (all of them unless the
1059    /// overflow split hid a tail).
1060    visible_count: usize,
1061    /// Whether the overflow trigger was laid out this pass.
1062    trigger_visible: bool,
1063    /// Unscrolled member origins, in this widget's local space.
1064    member_bases: Vec<Point>,
1065    /// The unscrolled trigger origin.
1066    trigger_base: Point,
1067    /// Total laid-out main extent of the visible run.
1068    content_main: f64,
1069    /// This widget's own main extent (the scroll viewport).
1070    viewport_main: f64,
1071
1072    // ---- interaction ----
1073    /// Main-axis scroll position (`>= 0`; members shift back by it).
1074    scroll_offset: f64,
1075    /// The pod a live gesture is addressed to.
1076    active: Option<Target>,
1077    /// Whether the gesture has been taken over as a scroll pan.
1078    panning: bool,
1079    /// Where the capturing `Down` landed (local coordinates).
1080    down_pos: Point,
1081    /// The previous pan sample (local coordinates).
1082    last_drag: Point,
1083    /// The member whose squish is animating — retained through the release
1084    /// spring until it settles back to rest.
1085    pressed_index: Option<usize>,
1086    /// The spring-driven squish progress (0 rest → 1 fully pressed).
1087    squish: AnimationController,
1088    /// The [`Self::squish_state`] the last `layout` pass actually ran with —
1089    /// what `paint` compares the live state against to catch the frame the
1090    /// spring *lands* on (see `paint`'s settle-frame note).
1091    laid_out_squish: SquishState,
1092}
1093
1094/// The layout-relevant squish inputs: the spring progress and the pressed
1095/// member `layout` resolves every member extent from
1096/// ([`ButtonGroupWidget::squish_state`]).
1097type SquishState = (f64, Option<usize>);
1098
1099impl ButtonGroupWidget {
1100    /// Whether the neighbor-squish animation applies at all —
1101    /// `_supportsAnimatedSquish` (`m3e_toggle_button_group.dart:217`):
1102    /// horizontal, not connected, and not opted out.
1103    pub fn supports_squish(&self) -> bool {
1104        self.direction == ButtonGroupDirection::Horizontal
1105            && self.group_type != ButtonGroupType::Connected
1106            && self.neighbor_squish
1107    }
1108
1109    /// The squish inputs a `layout` pass would resolve member extents from
1110    /// right now — the pair recorded as [`Self::laid_out_squish`] and
1111    /// re-read by `paint`.
1112    fn squish_state(&self) -> SquishState {
1113        let anim = if self.supports_squish() {
1114            self.squish.value_clamped()
1115        } else {
1116            0.0
1117        };
1118        (anim, self.pressed_index)
1119    }
1120
1121    /// How far the content can pan; `0.0` when it fits.
1122    fn max_scroll(&self) -> f64 {
1123        (self.content_main - self.viewport_main).max(0.0)
1124    }
1125
1126    /// Whether a drag may be taken over as a pan.
1127    fn can_scroll(&self) -> bool {
1128        self.overflow == ButtonGroupOverflow::Scroll && self.max_scroll() > FIT_EPSILON
1129    }
1130
1131    /// The main-axis component of `p`.
1132    fn main_of(&self, p: Point) -> f64 {
1133        match self.direction {
1134            ButtonGroupDirection::Horizontal => p.x,
1135            ButtonGroupDirection::Vertical => p.y,
1136        }
1137    }
1138
1139    /// The pod a target addresses.
1140    fn pod_mut(&mut self, target: Target) -> Option<&mut ChildPod> {
1141        match target {
1142            Target::Member(i) => self.members.get_mut(i),
1143            Target::Trigger => self.trigger.as_mut(),
1144        }
1145    }
1146
1147    /// Which visible pod contains local point `pos` — the trigger first, since
1148    /// it paints last.
1149    fn hit(&self, pos: Point) -> Option<Target> {
1150        if self.trigger_visible
1151            && let Some(trigger) = &self.trigger
1152            && trigger.contains(pos)
1153        {
1154            return Some(Target::Trigger);
1155        }
1156        let visible = self.visible_count.min(self.members.len());
1157        self.members[..visible]
1158            .iter()
1159            .position(|pod| pod.contains(pos))
1160            .map(Target::Member)
1161    }
1162
1163    /// Start the press-in squish.
1164    fn press_in(&mut self) {
1165        self.squish.fling(FLING_VELOCITY, SQUISH_SPRING);
1166    }
1167
1168    /// Start the release squish back toward rest.
1169    fn press_out(&mut self) {
1170        self.squish.fling(-FLING_VELOCITY, SQUISH_SPRING);
1171    }
1172
1173    /// Re-apply [`Self::scroll_offset`] to every laid-out pod's origin — the
1174    /// event-time counterpart of the layout pass's own positioning.
1175    fn sync_origins(&mut self) {
1176        let horizontal = self.direction == ButtonGroupDirection::Horizontal;
1177        let offset = self.scroll_offset;
1178        let visible = self.visible_count.min(self.members.len());
1179        for (pod, base) in self.members[..visible].iter_mut().zip(&self.member_bases) {
1180            pod.set_origin(shift_main(*base, -offset, horizontal));
1181        }
1182        if self.trigger_visible
1183            && let Some(trigger) = &mut self.trigger
1184        {
1185            trigger.set_origin(shift_main(self.trigger_base, -offset, horizontal));
1186        }
1187    }
1188
1189    /// Pan by `delta` main-axis px, clamping to the scrollable range and
1190    /// reporting whether the position actually moved.
1191    fn pan_by(&mut self, delta: f64) -> bool {
1192        let max = self.max_scroll();
1193        let next = (self.scroll_offset - delta).clamp(0.0, max);
1194        if (next - self.scroll_offset).abs() < f64::EPSILON {
1195            return false;
1196        }
1197        self.scroll_offset = next;
1198        self.sync_origins();
1199        true
1200    }
1201
1202    /// Deliver a synthetic `Cancel` to `target` — the scroll takeover's
1203    /// hand-off, mirroring `ScrollWidget::send_child_cancel`.
1204    fn send_cancel(&mut self, ctx: &mut EventCtx, target: Target, pos: Point) {
1205        let cancel = InputEvent::Pointer(PointerEvent {
1206            phase: PointerPhase::Cancel,
1207            position: pos,
1208            button: PointerButton::Primary,
1209        });
1210        if let Some(pod) = self.pod_mut(target) {
1211            pod.event_child(ctx, &cancel);
1212            pod.set_active(false);
1213        }
1214    }
1215
1216    /// Forward a broadcast to every pod this widget holds, visible or not —
1217    /// `route_event`'s broadcast contract applied to this hand-rolled routing.
1218    fn broadcast(&mut self, ctx: &mut EventCtx, event: &InputEvent) {
1219        for pod in self.members.iter_mut() {
1220            pod.event_child(ctx, event);
1221        }
1222        if let Some(trigger) = &mut self.trigger {
1223            trigger.event_child(ctx, event);
1224        }
1225    }
1226
1227    /// Forward a focus-routed event to whichever pod holds the recorded focus
1228    /// path.
1229    fn route_focused(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1230        if let Some(pod) = self.members.iter_mut().find(|pod| pod.is_focused()) {
1231            return pod.event_child(ctx, event);
1232        }
1233        if let Some(trigger) = self.trigger.as_mut().filter(|pod| pod.is_focused()) {
1234            return trigger.event_child(ctx, event);
1235        }
1236        EventResult::Ignored
1237    }
1238
1239    /// Pull the widget's non-child configuration off `view`, reporting the
1240    /// change flags it earns.
1241    fn apply_config<State: 'static>(&mut self, view: &ButtonGroupView<State>) -> ChangeFlags {
1242        let mut flags = ChangeFlags::NONE;
1243        if self.group_type != view.group_type
1244            || self.density != view.density
1245            || self.direction != view.direction
1246            || self.spacing != view.spacing
1247            || self.overflow != view.overflow
1248            || self.neighbor_squish != view.neighbor_squish
1249            || self.expanded_ratio != view.expanded_ratio
1250        {
1251            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1252        }
1253        self.group_type = view.group_type;
1254        self.density = view.density;
1255        self.direction = view.direction;
1256        self.spacing = view.spacing;
1257        self.overflow = view.overflow;
1258        self.neighbor_squish = view.neighbor_squish;
1259        self.expanded_ratio = view.expanded_ratio;
1260        self.semantic_label = view.semantic_label.clone();
1261        self.labels = view
1262            .actions
1263            .iter()
1264            .map(ButtonGroupAction::accessible_label)
1265            .collect();
1266        self.widths = view.actions.iter().map(|a| a.width).collect();
1267        self.on_overflow = view
1268            .on_overflow
1269            .as_ref()
1270            .map(frust::authoring::erase_callback_arg);
1271        flags
1272    }
1273}
1274
1275/// `base` moved by `delta` along the main axis.
1276fn shift_main(base: Point, delta: f64, horizontal: bool) -> Point {
1277    if horizontal {
1278        Point::new(base.x + delta, base.y)
1279    } else {
1280        Point::new(base.x, base.y + delta)
1281    }
1282}
1283
1284impl<State: 'static> View<State> for ButtonGroupView<State> {
1285    type Element = ButtonGroupWidget;
1286
1287    fn build(&self, ctx: &mut BuildCtx<'_>) -> ButtonGroupWidget {
1288        let members: Vec<ChildPod> = self
1289            .member_views()
1290            .iter()
1291            .map(|view| frust::authoring::build_child(view, ctx))
1292            .collect();
1293        let trigger = self
1294            .maybe_trigger_view()
1295            .map(|view| frust::authoring::build_child(&view, ctx));
1296        let mut widget = ButtonGroupWidget {
1297            visible_count: members.len(),
1298            members,
1299            trigger,
1300            labels: Vec::new(),
1301            widths: Vec::new(),
1302            group_type: self.group_type,
1303            density: self.density,
1304            direction: self.direction,
1305            spacing: self.spacing,
1306            neighbor_squish: self.neighbor_squish,
1307            expanded_ratio: self.expanded_ratio,
1308            overflow: self.overflow,
1309            semantic_label: None,
1310            on_overflow: None,
1311            trigger_visible: false,
1312            member_bases: Vec::new(),
1313            trigger_base: Point::ZERO,
1314            content_main: 0.0,
1315            viewport_main: 0.0,
1316            scroll_offset: 0.0,
1317            active: None,
1318            panning: false,
1319            down_pos: Point::ZERO,
1320            last_drag: Point::ZERO,
1321            pressed_index: None,
1322            squish: AnimationController::new(SQUISH_ANIM_PERIOD),
1323            laid_out_squish: (0.0, None),
1324        };
1325        widget.apply_config(self);
1326        widget
1327    }
1328
1329    fn rebuild(
1330        &self,
1331        prev: &Self,
1332        element: &mut ButtonGroupWidget,
1333        ctx: &mut BuildCtx<'_>,
1334    ) -> ChangeFlags {
1335        // Every member view is re-derived from the (freshly resolved)
1336        // selection each rebuild, so the whole list always reconciles
1337        // positionally — the group carries no per-member identity to key on.
1338        let prev_views = prev.member_views();
1339        let next_views = self.member_views();
1340        let mut flags = frust::authoring::rebuild_children(
1341            &prev_views,
1342            &next_views,
1343            &mut element.members,
1344            ctx,
1345            |view| view,
1346            |_| None,
1347        );
1348
1349        match (prev.maybe_trigger_view(), self.maybe_trigger_view()) {
1350            (Some(prev_view), Some(next_view)) => {
1351                if let Some(pod) = element.trigger.as_mut() {
1352                    flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
1353                }
1354            }
1355            (Some(prev_view), None) => {
1356                if let Some(pod) = element.trigger.as_mut() {
1357                    frust::authoring::teardown_child(&prev_view, pod, ctx);
1358                }
1359                element.trigger = None;
1360                element.trigger_visible = false;
1361                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1362            }
1363            (None, Some(next_view)) => {
1364                element.trigger = Some(frust::authoring::build_child(&next_view, ctx));
1365                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1366            }
1367            (None, None) => {}
1368        }
1369
1370        if prev.actions.len() != self.actions.len() {
1371            // A structural change invalidates the recorded press/pan.
1372            element.active = None;
1373            element.panning = false;
1374            element.pressed_index = None;
1375            element.scroll_offset = 0.0;
1376            element.visible_count = element.members.len();
1377        }
1378        flags |= element.apply_config(self);
1379        flags
1380    }
1381
1382    fn teardown(&self, element: &mut ButtonGroupWidget, ctx: &mut BuildCtx<'_>) {
1383        for (view, pod) in self.member_views().iter().zip(element.members.iter_mut()) {
1384            frust::authoring::teardown_child(view, pod, ctx);
1385        }
1386        if let (Some(view), Some(pod)) = (self.maybe_trigger_view(), element.trigger.as_mut()) {
1387            frust::authoring::teardown_child(&view, pod, ctx);
1388        }
1389    }
1390}
1391
1392impl Widget for ButtonGroupWidget {
1393    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1394        let horizontal = self.direction == ButtonGroupDirection::Horizontal;
1395        let max = bc.max();
1396        let (max_main, max_cross) = if horizontal {
1397            (max.width, max.height)
1398        } else {
1399            (max.height, max.width)
1400        };
1401        self.member_bases.clear();
1402        if self.members.is_empty() {
1403            self.visible_count = 0;
1404            self.trigger_visible = false;
1405            self.content_main = 0.0;
1406            self.viewport_main = 0.0;
1407            return bc.constrain(Size::ZERO);
1408        }
1409
1410        let spacing = resolve_spacing(self.spacing, self.group_type, self.density);
1411
1412        // Pass 1 — natural main extents, main axis unbounded
1413        // (`_layoutNaturalMainSizes`' dry pass).
1414        let natural_bc = if horizontal {
1415            BoxConstraints::loose(Size::new(f64::INFINITY, max_cross))
1416        } else {
1417            BoxConstraints::loose(Size::new(max_cross, f64::INFINITY))
1418        };
1419        let mut natural = Vec::with_capacity(self.members.len());
1420        for (i, pod) in self.members.iter_mut().enumerate() {
1421            let size = pod.layout_child(ctx, &natural_bc);
1422            let measured = if horizontal { size.width } else { size.height };
1423            natural.push(self.widths.get(i).copied().flatten().unwrap_or(measured));
1424        }
1425        let trigger_natural = self.trigger.as_mut().map(|pod| {
1426            let size = pod.layout_child(ctx, &natural_bc);
1427            if horizontal { size.width } else { size.height }
1428        });
1429
1430        // Overflow split — only the trigger-bearing modes hide anything.
1431        let (visible_count, trigger_visible) = match (self.overflow, trigger_natural) {
1432            (ButtonGroupOverflow::BottomSheet | ButtonGroupOverflow::Popup, Some(trigger))
1433                if max_main.is_finite() =>
1434            {
1435                let fit = visible_count_for_overflow(max_main, &natural, trigger, spacing);
1436                if fit >= natural.len() {
1437                    (natural.len(), false)
1438                } else {
1439                    (fit, true)
1440                }
1441            }
1442            _ => (natural.len(), false),
1443        };
1444        self.visible_count = visible_count;
1445        self.trigger_visible = trigger_visible;
1446
1447        // The squish, over the visible run only. The pair is recorded as it
1448        // is read, so `paint` can tell whether the geometry standing on
1449        // screen is the one the live spring state calls for.
1450        let state = self.squish_state();
1451        self.laid_out_squish = state;
1452        let (anim, pressed) = state;
1453        let pressed = pressed.filter(|i| *i < visible_count);
1454        let mut sizes = squish_sizes(
1455            &natural[..visible_count],
1456            pressed,
1457            self.expanded_ratio,
1458            anim,
1459        );
1460        if let Some(trigger) = trigger_natural.filter(|_| trigger_visible) {
1461            sizes.push(trigger);
1462        }
1463
1464        // Pass 2 — tight main, loose cross; then position.
1465        let mut cross = 0.0_f64;
1466        let mut child_sizes = Vec::with_capacity(sizes.len());
1467        {
1468            let pods = self.members[..visible_count]
1469                .iter_mut()
1470                .chain(self.trigger.iter_mut().filter(|_| trigger_visible));
1471            for (pod, main) in pods.zip(sizes.iter()) {
1472                let child_bc = if horizontal {
1473                    BoxConstraints::new(Size::new(*main, 0.0), Size::new(*main, max_cross))
1474                } else {
1475                    BoxConstraints::new(Size::new(0.0, *main), Size::new(max_cross, *main))
1476                };
1477                let size = pod.layout_child(ctx, &child_bc);
1478                cross = cross.max(if horizontal { size.height } else { size.width });
1479                child_sizes.push(size);
1480            }
1481        }
1482
1483        let laid_out = sizes.len();
1484        let total_main: f64 = sizes.iter().sum::<f64>()
1485            + if laid_out > 0 {
1486                spacing * (laid_out - 1) as f64
1487            } else {
1488                0.0
1489            };
1490
1491        let mut main_offset = 0.0_f64;
1492        let mut bases = Vec::with_capacity(laid_out);
1493        for (main, size) in sizes.iter().zip(child_sizes.iter()) {
1494            let child_cross = if horizontal { size.height } else { size.width };
1495            let cross_offset = ((cross - child_cross) / 2.0).max(0.0);
1496            bases.push(if horizontal {
1497                Point::new(main_offset, cross_offset)
1498            } else {
1499                Point::new(cross_offset, main_offset)
1500            });
1501            main_offset += main + spacing;
1502        }
1503        self.trigger_base = if trigger_visible {
1504            bases.pop().unwrap_or(Point::ZERO)
1505        } else {
1506            Point::ZERO
1507        };
1508        self.member_bases = bases;
1509
1510        let size = bc.constrain(if horizontal {
1511            Size::new(total_main, cross)
1512        } else {
1513            Size::new(cross, total_main)
1514        });
1515        self.content_main = total_main;
1516        self.viewport_main = if horizontal { size.width } else { size.height };
1517        self.scroll_offset = self.scroll_offset.clamp(0.0, self.max_scroll());
1518        self.sync_origins();
1519        size
1520    }
1521
1522    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1523        let reduce_motion = Theme::from_paint_ctx(ctx).is_some_and(|t| t.motion.reduce_motion);
1524
1525        if reduce_motion {
1526            // The squish is a layout animation with no reduced-motion analogue:
1527            // freeze it at rest and stop asking for frames.
1528            if self.squish.is_animating() {
1529                self.squish.stop();
1530            }
1531            self.pressed_index = None;
1532        } else {
1533            // A layout-affecting animation asks for `request_layout`; a bare
1534            // `request_frame` would leave the row unresized under the mobile
1535            // intra-frame layout skip.
1536            if self.squish.advance(ctx.frame_time()) {
1537                ctx.request_layout();
1538            } else if self.active.is_none()
1539                && self.squish.value().abs() < SQUISH_REST_EPSILON
1540                && self.pressed_index.is_some()
1541            {
1542                self.pressed_index = None;
1543            }
1544        }
1545        // The frame the spring *lands* on needs a layout too, and asks for
1546        // none of its own: `AnimationController::advance` reports `false` on
1547        // the very pass that snaps the value onto its target, so without this
1548        // the last geometry `layout` ever saw is the one a hair short of rest
1549        // — every member a fraction of a pixel off its natural extent, and
1550        // every origin off with it, for as long as nothing else happens to
1551        // dirty layout. Comparing against the state `layout` actually ran
1552        // with also covers the two moves above that animate nothing (the
1553        // released pressed member, the reduced-motion freeze).
1554        if self.squish_state() != self.laid_out_squish {
1555            ctx.request_layout();
1556        }
1557
1558        let clipped =
1559            self.overflow == ButtonGroupOverflow::Scroll && self.max_scroll() > FIT_EPSILON;
1560        if clipped {
1561            scene.push_clip(ctx.origin(), ctx.size());
1562        }
1563        let visible = self.visible_count.min(self.members.len());
1564        for pod in self.members[..visible].iter_mut() {
1565            pod.paint_child(ctx, scene);
1566        }
1567        if self.trigger_visible
1568            && let Some(trigger) = &mut self.trigger
1569        {
1570            trigger.paint_child(ctx, scene);
1571        }
1572        if clipped {
1573            scene.pop_clip();
1574        }
1575    }
1576
1577    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1578        if event.is_broadcast() {
1579            self.broadcast(ctx, event);
1580            return EventResult::Ignored;
1581        }
1582        if event.is_focus_routed() {
1583            return self.route_focused(ctx, event);
1584        }
1585        match event {
1586            InputEvent::Scroll { delta, .. } => {
1587                if !self.can_scroll() {
1588                    return EventResult::Ignored;
1589                }
1590                let (dx, dy) = match delta {
1591                    ScrollDelta::Lines(x, y) => (x * WHEEL_LINE_PX, y * WHEEL_LINE_PX),
1592                    ScrollDelta::Pixels(x, y) => (*x, *y),
1593                };
1594                // A horizontal row takes the wheel's horizontal axis when it
1595                // carries one, and its vertical axis otherwise (the usual
1596                // shift-less mouse over a horizontal strip).
1597                let main = match self.direction {
1598                    ButtonGroupDirection::Horizontal if dx != 0.0 => dx,
1599                    ButtonGroupDirection::Horizontal => dy,
1600                    ButtonGroupDirection::Vertical => dy,
1601                };
1602                if self.pan_by(-main) {
1603                    ctx.request_redraw();
1604                }
1605                EventResult::Handled
1606            }
1607            InputEvent::Pointer(p) => match p.phase {
1608                PointerPhase::Down => {
1609                    let Some(target) = self.hit(p.position) else {
1610                        return EventResult::Ignored;
1611                    };
1612                    if !presses(p) {
1613                        // A secondary press is a context gesture: it reaches
1614                        // the member but opens no capture and starts no squish.
1615                        return self
1616                            .pod_mut(target)
1617                            .map(|pod| pod.event_child(ctx, event))
1618                            .unwrap_or(EventResult::Ignored);
1619                    }
1620                    self.active = Some(target);
1621                    self.panning = false;
1622                    self.down_pos = p.position;
1623                    self.last_drag = p.position;
1624                    if let Target::Member(i) = target {
1625                        self.pressed_index = Some(i);
1626                        self.press_in();
1627                    }
1628                    ctx.capture_pointer();
1629                    if let Some(pod) = self.pod_mut(target) {
1630                        pod.event_child(ctx, event);
1631                    }
1632                    ctx.request_redraw();
1633                    EventResult::Handled
1634                }
1635                PointerPhase::Move => {
1636                    let Some(target) = self.active else {
1637                        // A hover move: forward it so the member under the
1638                        // pointer can claim hover for itself.
1639                        return match self.hit(p.position) {
1640                            Some(hit) => self
1641                                .pod_mut(hit)
1642                                .map(|pod| pod.event_child(ctx, event))
1643                                .unwrap_or(EventResult::Ignored),
1644                            None => EventResult::Ignored,
1645                        };
1646                    };
1647                    if self.panning {
1648                        let delta = self.main_of(p.position) - self.main_of(self.last_drag);
1649                        self.last_drag = p.position;
1650                        if self.pan_by(delta) {
1651                            ctx.request_redraw();
1652                        }
1653                        return EventResult::Handled;
1654                    }
1655                    let travelled = self.main_of(p.position) - self.main_of(self.down_pos);
1656                    if self.can_scroll() && travelled.abs() > TOUCH_SLOP {
1657                        // Take the gesture over: cancel the member, stop
1658                        // forwarding, release the squish.
1659                        self.panning = true;
1660                        self.last_drag = p.position;
1661                        self.send_cancel(ctx, target, p.position);
1662                        self.press_out();
1663                        ctx.request_redraw();
1664                        return EventResult::Handled;
1665                    }
1666                    if let Some(pod) = self.pod_mut(target) {
1667                        pod.event_child(ctx, event);
1668                    }
1669                    EventResult::Handled
1670                }
1671                PointerPhase::Up => {
1672                    let Some(target) = self.active.take() else {
1673                        return EventResult::Ignored;
1674                    };
1675                    let panned = self.panning;
1676                    self.panning = false;
1677                    if !panned {
1678                        if let Some(pod) = self.pod_mut(target) {
1679                            pod.event_child(ctx, event);
1680                        }
1681                        // The trigger's own callback is inert; the group
1682                        // reports the (layout-derived) first hidden index here.
1683                        if target == Target::Trigger
1684                            && self
1685                                .trigger
1686                                .as_ref()
1687                                .is_some_and(|pod| pod.contains(p.position))
1688                        {
1689                            let first_hidden = self.visible_count;
1690                            if let Some(callback) = self.on_overflow.as_mut() {
1691                                callback(ctx, first_hidden);
1692                            }
1693                        }
1694                    }
1695                    if let Some(pod) = self.pod_mut(target) {
1696                        pod.set_active(false);
1697                    }
1698                    self.press_out();
1699                    ctx.request_redraw();
1700                    EventResult::Handled
1701                }
1702                PointerPhase::Cancel => {
1703                    let Some(target) = self.active.take() else {
1704                        return EventResult::Ignored;
1705                    };
1706                    if !self.panning
1707                        && let Some(pod) = self.pod_mut(target)
1708                    {
1709                        pod.event_child(ctx, event);
1710                    }
1711                    if let Some(pod) = self.pod_mut(target) {
1712                        pod.set_active(false);
1713                    }
1714                    self.panning = false;
1715                    self.press_out();
1716                    ctx.request_redraw();
1717                    EventResult::Handled
1718                }
1719            },
1720            _ => EventResult::Ignored,
1721        }
1722    }
1723
1724    fn semantics(&self, ctx: &mut SemanticsCtx) {
1725        let label = self.semantic_label.clone();
1726        ctx.push_container(
1727            Role::Group,
1728            |node| {
1729                if let Some(label) = &label {
1730                    node.set_label(label.as_str());
1731                }
1732            },
1733            |ctx| {
1734                let visible = self.visible_count.min(self.members.len());
1735                for pod in &self.members[..visible] {
1736                    pod.semantics_child(ctx);
1737                }
1738                if self.trigger_visible
1739                    && let Some(trigger) = &self.trigger
1740                {
1741                    trigger.semantics_child(ctx);
1742                }
1743            },
1744        );
1745    }
1746
1747    frust::authoring::visit_children!(members, trigger);
1748}
1749
1750#[cfg(test)]
1751mod tests {
1752    use super::*;
1753    use frust::FrameTime;
1754    use frust::authoring::text::TextContext;
1755    use frust_widgets::test_support::leaf_any;
1756    use kurbo::Rect;
1757    use std::any::Any;
1758    use std::cell::RefCell;
1759
1760    // ---- fixtures ---------------------------------------------------------
1761
1762    /// Sm-tier metrics, re-derived here so the layout expectations below are
1763    /// arithmetic rather than magic: a `Sm` toggle button is 40dp tall with
1764    /// 16dp horizontal padding, halved for an icon-only member, around a 20dp
1765    /// icon — so an icon-only `Sm` member is exactly 36×40.
1766    const ICON_MEMBER_W: f64 = 36.0;
1767    const MEMBER_H: f64 = 40.0;
1768
1769    /// Icon-only actions with no label at all — the deterministic 36dp members
1770    /// the layout tests measure against.
1771    fn bare_icon_actions(count: usize) -> Vec<ButtonGroupAction<()>> {
1772        (0..count)
1773            .map(|_| button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)))
1774            .collect()
1775    }
1776
1777    fn build<S: 'static>(view: &ButtonGroupView<S>) -> ButtonGroupWidget {
1778        let mut counter = 0u64;
1779        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1780    }
1781
1782    fn layout(widget: &mut ButtonGroupWidget, max: Size) -> Size {
1783        let mut tcx = TextContext::new();
1784        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1785        widget.layout(&mut lctx, &BoxConstraints::loose(max))
1786    }
1787
1788    fn layout_themed(widget: &mut ButtonGroupWidget, max: Size, theme: &Theme) -> Size {
1789        let mut tcx = TextContext::new();
1790        let mut lctx =
1791            LayoutCtx::with_text_context(&mut tcx as &mut dyn Any).with_theme(theme as &dyn Any);
1792        widget.layout(&mut lctx, &BoxConstraints::loose(max))
1793    }
1794
1795    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1796        InputEvent::Pointer(PointerEvent {
1797            phase,
1798            position: Point::new(x, y),
1799            button: PointerButton::Primary,
1800        })
1801    }
1802
1803    fn dispatch<S: 'static>(
1804        widget: &mut ButtonGroupWidget,
1805        state: &mut S,
1806        size: Size,
1807        event: &InputEvent,
1808    ) {
1809        let state_any: &mut dyn Any = state;
1810        let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
1811        widget.event(&mut ctx, event);
1812    }
1813
1814    /// Advance the squish spring far enough to be visibly non-zero.
1815    fn advance_squish(widget: &mut ButtonGroupWidget, secs: f64) {
1816        widget.squish.advance(FrameTime::ZERO);
1817        widget
1818            .squish
1819            .advance(FrameTime::from_nanos((secs * 1e9) as u64));
1820    }
1821
1822    #[derive(Default)]
1823    struct NullScene;
1824
1825    impl PaintScene for NullScene {
1826        fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
1827        fn draw_text(&mut self, _o: Point, _t: &str) {}
1828    }
1829
1830    fn paint(widget: &mut ButtonGroupWidget, size: Size, theme: Option<&Theme>) {
1831        let mut scene = NullScene;
1832        let mut ctx = PaintCtx::new(Point::ZERO, size);
1833        if let Some(theme) = theme {
1834            ctx = ctx.with_theme(theme as &dyn Any);
1835        }
1836        widget.paint(&mut ctx, &mut scene);
1837    }
1838
1839    // ---- the legacy corner helper (unchanged contract) ---------------------
1840
1841    #[test]
1842    fn single_member_group_is_all_outer_corners() {
1843        let r = member_radii(0, 1, OUTER_RADIUS, INNER_RADIUS);
1844        assert_eq!(r.top_left, 16.0);
1845        assert_eq!(r.top_right, 16.0);
1846        assert_eq!(r.bottom_right, 16.0);
1847        assert_eq!(r.bottom_left, 16.0);
1848    }
1849
1850    #[test]
1851    fn end_members_get_outer_on_their_outer_side_only() {
1852        let first = member_radii(0, 3, 16.0, 4.0);
1853        assert_eq!((first.top_left, first.bottom_left), (16.0, 16.0));
1854        assert_eq!((first.top_right, first.bottom_right), (4.0, 4.0));
1855        let last = member_radii(2, 3, 16.0, 4.0);
1856        assert_eq!((last.top_left, last.bottom_left), (4.0, 4.0));
1857        assert_eq!((last.top_right, last.bottom_right), (16.0, 16.0));
1858    }
1859
1860    #[test]
1861    fn middle_members_are_all_inner_corners() {
1862        let mid = member_radii(1, 3, 16.0, 4.0);
1863        assert_eq!(mid.top_left, 4.0);
1864        assert_eq!(mid.top_right, 4.0);
1865        assert_eq!(mid.bottom_right, 4.0);
1866        assert_eq!(mid.bottom_left, 4.0);
1867    }
1868
1869    // ---- types × densities (the resolved gap table) ------------------------
1870
1871    #[test]
1872    fn resolved_gap_pins_every_type_density_pair() {
1873        use ButtonGroupDensity::{Compact, Regular};
1874        use ButtonGroupType::{Connected, Standard};
1875        assert_eq!(resolve_spacing(None, Standard, Regular), 8.0);
1876        // floor(8 * 0.75) = 6.
1877        assert_eq!(resolve_spacing(None, Standard, Compact), 6.0);
1878        // The connected seam is density-independent upstream — see the module
1879        // docs' transcription note.
1880        assert_eq!(resolve_spacing(None, Connected, Regular), 2.0);
1881        assert_eq!(resolve_spacing(None, Connected, Compact), 2.0);
1882        // An explicit spacing always wins.
1883        assert_eq!(resolve_spacing(Some(3.5), Connected, Compact), 3.5);
1884    }
1885
1886    // ---- squish geometry --------------------------------------------------
1887
1888    #[test]
1889    fn squish_middle_splits_the_growth_between_both_neighbours() {
1890        let natural = [100.0, 200.0, 100.0];
1891        let sizes = squish_sizes(&natural, Some(1), 0.15, 1.0);
1892        // growth = 200 * 0.15 * 1.0 = 30; each neighbour gives up 15.
1893        assert_eq!(sizes, vec![85.0, 230.0, 85.0]);
1894    }
1895
1896    #[test]
1897    fn squish_at_the_first_index_moves_all_growth_to_its_only_neighbour() {
1898        let sizes = squish_sizes(&[100.0, 100.0, 100.0], Some(0), 0.15, 1.0);
1899        assert_eq!(sizes, vec![115.0, 85.0, 100.0]);
1900    }
1901
1902    #[test]
1903    fn squish_at_the_last_index_mirrors_the_first() {
1904        let sizes = squish_sizes(&[100.0, 100.0, 100.0], Some(2), 0.15, 1.0);
1905        assert_eq!(sizes, vec![100.0, 85.0, 115.0]);
1906    }
1907
1908    #[test]
1909    fn squish_preserves_the_total_main_extent() {
1910        for pressed in 0..3 {
1911            let natural = [80.0, 120.0, 60.0];
1912            let sizes = squish_sizes(&natural, Some(pressed), 0.15, 0.6);
1913            let before: f64 = natural.iter().sum();
1914            let after: f64 = sizes.iter().sum();
1915            assert!(
1916                (before - after).abs() < 1e-9,
1917                "pressing {pressed} changed the total: {before} -> {after}"
1918            );
1919        }
1920    }
1921
1922    #[test]
1923    fn squish_scales_with_the_spring_progress_and_the_ratio() {
1924        let half = squish_sizes(&[100.0, 100.0], Some(0), 0.15, 0.5);
1925        assert_eq!(half, vec![107.5, 92.5]);
1926        let wider = squish_sizes(&[100.0, 100.0], Some(0), 0.30, 1.0);
1927        assert_eq!(wider, vec![130.0, 70.0]);
1928    }
1929
1930    #[test]
1931    fn squish_is_inert_without_a_press_or_progress() {
1932        let natural = [100.0, 100.0];
1933        assert_eq!(squish_sizes(&natural, None, 0.15, 1.0), natural.to_vec());
1934        assert_eq!(squish_sizes(&natural, Some(0), 0.15, 0.0), natural.to_vec());
1935        // Out of range: `_canApplySquish`'s bounds guard.
1936        assert_eq!(squish_sizes(&natural, Some(9), 0.15, 1.0), natural.to_vec());
1937    }
1938
1939    #[test]
1940    fn squish_of_a_one_member_group_grows_with_nothing_to_absorb_it() {
1941        assert_eq!(squish_sizes(&[100.0], Some(0), 0.15, 1.0), vec![115.0]);
1942    }
1943
1944    #[test]
1945    fn squish_shrink_is_floored_at_zero() {
1946        // A neighbour narrower than the growth clamps instead of going negative.
1947        let sizes = squish_sizes(&[400.0, 10.0], Some(0), 0.15, 1.0);
1948        assert_eq!(sizes, vec![460.0, 0.0]);
1949    }
1950
1951    #[test]
1952    fn squish_applies_only_to_a_horizontal_unconnected_group() {
1953        let cases = [
1954            (
1955                ButtonGroupDirection::Horizontal,
1956                ButtonGroupType::Standard,
1957                true,
1958                true,
1959            ),
1960            (
1961                ButtonGroupDirection::Horizontal,
1962                ButtonGroupType::Connected,
1963                true,
1964                false,
1965            ),
1966            (
1967                ButtonGroupDirection::Vertical,
1968                ButtonGroupType::Standard,
1969                true,
1970                false,
1971            ),
1972            (
1973                ButtonGroupDirection::Horizontal,
1974                ButtonGroupType::Standard,
1975                false,
1976                false,
1977            ),
1978        ];
1979        for (direction, group_type, squish, expected) in cases {
1980            let view = button_group_actions(bare_icon_actions(3))
1981                .direction(direction)
1982                .group_type(group_type)
1983                .neighbor_squish(squish);
1984            let widget = build(&view);
1985            assert_eq!(
1986                widget.supports_squish(),
1987                expected,
1988                "{direction:?}/{group_type:?}/squish={squish}"
1989            );
1990        }
1991    }
1992
1993    // ---- overflow measurement ---------------------------------------------
1994
1995    #[test]
1996    fn visible_count_is_every_item_when_they_all_fit() {
1997        // 3 * 36 + 2 * 8 = 124; nothing hidden means nothing reserved.
1998        assert_eq!(visible_count_for_overflow(200.0, &[36.0; 3], 36.0, 8.0), 3);
1999    }
2000
2001    #[test]
2002    fn visible_count_reserves_the_trigger_and_its_separator() {
2003        // 100px: item0 (36) + sep(8) + trigger(36) = 80 < 100 -> visible 1.
2004        // item1 would make 36+8+36 = 80, +8+36 reserved = 124 >= 100 -> stop.
2005        assert_eq!(visible_count_for_overflow(100.0, &[36.0; 4], 36.0, 8.0), 1);
2006    }
2007
2008    #[test]
2009    fn visible_count_rounds_consumed_up_and_available_down() {
2010        // Ceil'd items (36.2 -> 37) against a floored available (99.9 -> 99):
2011        // 37 + 8 + 36 = 81 < 99 -> 1; adding the next gives 37+8+37 = 82,
2012        // +8+36 = 126 >= 99 -> stop.
2013        assert_eq!(
2014            visible_count_for_overflow(99.9, &[36.2, 36.2, 36.2], 36.0, 8.0),
2015            1
2016        );
2017    }
2018
2019    #[test]
2020    fn visible_count_is_zero_when_even_one_item_cannot_fit() {
2021        assert_eq!(visible_count_for_overflow(40.0, &[36.0; 3], 36.0, 8.0), 0);
2022    }
2023
2024    // ---- construction / defaults ------------------------------------------
2025
2026    #[test]
2027    fn build_creates_one_pod_per_action() {
2028        let view = button_group::<u32, _>(vec!["Day", "Week", "Month"], 0, |s: &mut u32, i| {
2029            *s = i as u32
2030        });
2031        let widget = build(&view);
2032        assert_eq!(widget.members.len(), 3);
2033        assert_eq!(widget.labels, vec!["Day", "Week", "Month"]);
2034        assert!(widget.trigger.is_none(), "no trigger without BottomSheet");
2035    }
2036
2037    #[test]
2038    fn the_legacy_constructor_stays_connected_while_the_action_one_is_standard() {
2039        let legacy = build(&button_group::<(), _>(vec!["A", "B"], 0, |_s, _i| {}));
2040        assert_eq!(legacy.group_type, ButtonGroupType::Connected);
2041        let ported = build(&button_group_actions(bare_icon_actions(2)));
2042        assert_eq!(ported.group_type, ButtonGroupType::Standard);
2043        assert_eq!(ported.overflow, ButtonGroupOverflow::Scroll);
2044    }
2045
2046    #[test]
2047    fn a_bottom_sheet_group_builds_a_trigger_pod() {
2048        let view =
2049            button_group_actions(bare_icon_actions(3)).overflow(ButtonGroupOverflow::BottomSheet);
2050        let widget = build(&view);
2051        assert!(widget.trigger.is_some());
2052    }
2053
2054    // ---- layout -----------------------------------------------------------
2055
2056    #[test]
2057    fn members_abut_with_the_resolved_gap() {
2058        let mut widget = build(&button_group_actions(bare_icon_actions(3)));
2059        let size = layout(&mut widget, Size::new(500.0, 200.0));
2060        // 3 * 36 + 2 * 8 (standard/regular).
2061        assert_eq!(size, Size::new(3.0 * ICON_MEMBER_W + 16.0, MEMBER_H));
2062        assert_eq!(widget.member_bases[0].x, 0.0);
2063        assert_eq!(widget.member_bases[1].x, ICON_MEMBER_W + 8.0);
2064        assert_eq!(widget.member_bases[2].x, 2.0 * (ICON_MEMBER_W + 8.0));
2065    }
2066
2067    #[test]
2068    fn a_connected_group_uses_the_hairline_seam() {
2069        let mut widget = build(
2070            &button_group_actions(bare_icon_actions(3)).group_type(ButtonGroupType::Connected),
2071        );
2072        layout(&mut widget, Size::new(500.0, 200.0));
2073        assert_eq!(widget.member_bases[1].x, ICON_MEMBER_W + CONNECTED_GAP);
2074    }
2075
2076    #[test]
2077    fn a_vertical_group_stacks_along_the_cross_axis() {
2078        let mut widget = build(
2079            &button_group_actions(bare_icon_actions(2)).direction(ButtonGroupDirection::Vertical),
2080        );
2081        let size = layout(&mut widget, Size::new(200.0, 500.0));
2082        assert_eq!(size, Size::new(ICON_MEMBER_W, 2.0 * MEMBER_H + 8.0));
2083        assert_eq!(widget.member_bases[1].y, MEMBER_H + 8.0);
2084    }
2085
2086    #[test]
2087    fn a_pinned_action_width_overrides_the_natural_extent() {
2088        let actions = vec![
2089            button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)).width(90.0),
2090            button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)),
2091        ];
2092        let mut widget = build(&button_group_actions(actions));
2093        let size = layout(&mut widget, Size::new(500.0, 200.0));
2094        assert_eq!(size.width, 90.0 + 8.0 + ICON_MEMBER_W);
2095        assert_eq!(widget.member_bases[1].x, 98.0);
2096    }
2097
2098    #[test]
2099    fn pressing_a_member_squishes_its_neighbour_in_layout() {
2100        let mut widget = build(&button_group_actions(bare_icon_actions(2)));
2101        let unpressed = layout(&mut widget, Size::new(500.0, 200.0));
2102        let gap_start = widget.member_bases[1].x;
2103
2104        let mut state = ();
2105        dispatch(
2106            &mut widget,
2107            &mut state,
2108            unpressed,
2109            &ev(PointerPhase::Down, 4.0, 20.0),
2110        );
2111        advance_squish(&mut widget, 0.05);
2112        let pressed = layout(&mut widget, Size::new(500.0, 200.0));
2113
2114        assert!(
2115            widget.member_bases[1].x > gap_start,
2116            "member 0 grew, pushing member 1 along ({} -> {})",
2117            gap_start,
2118            widget.member_bases[1].x
2119        );
2120        assert!(
2121            (pressed.width - unpressed.width).abs() < 1e-6,
2122            "the squish is extent-preserving: {unpressed:?} -> {pressed:?}"
2123        );
2124    }
2125
2126    // ---- the settle frame (a landed squish is laid out) --------------------
2127
2128    /// The demo's compact three-member row — the labels the device gate
2129    /// probed, long enough that a sub-pixel shortfall costs a whole word.
2130    fn labelled_actions() -> Vec<ButtonGroupAction<()>> {
2131        vec![
2132            button_group_action("Every day"),
2133            button_group_action("Days per week"),
2134            button_group_action("Selected days"),
2135        ]
2136    }
2137
2138    /// Counts the glyphs of every painted run, in paint order — the paint-side
2139    /// window onto whether a member's label ended up ellipsized (`GlyphRun`
2140    /// carries resolved glyph ids, never the shaped text).
2141    #[derive(Default)]
2142    struct GlyphCounter {
2143        runs: Vec<usize>,
2144    }
2145
2146    impl PaintScene for GlyphCounter {
2147        fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
2148        fn draw_text(&mut self, _o: Point, _t: &str) {}
2149        fn draw_glyph_run(&mut self, run: frust::authoring::scene::GlyphRun) {
2150            self.runs.push(run.glyphs.len());
2151        }
2152    }
2153
2154    /// A shell-style frame driver honouring the mobile layout-skip contract
2155    /// (`AppTree`'s rebuild → layout-iff-needed → paint seam): layout runs only
2156    /// on a frame the previous paint asked for one, so an animation that lands
2157    /// without a final `request_layout` leaves its last in-flight geometry
2158    /// standing — which is what a device shows.
2159    struct Frames {
2160        widget: ButtonGroupWidget,
2161        text: TextContext,
2162        theme: Theme,
2163        max: Size,
2164        size: Size,
2165        needs_layout: bool,
2166    }
2167
2168    impl Frames {
2169        fn new(view: &ButtonGroupView<()>, max: Size) -> Self {
2170            Frames {
2171                widget: build(view),
2172                text: TextContext::new(),
2173                theme: crate::baseline(),
2174                max,
2175                size: Size::ZERO,
2176                needs_layout: true,
2177            }
2178        }
2179
2180        /// Run one frame at `t_ms`, returning each painted label run's glyph
2181        /// count in member order.
2182        fn run(&mut self, t_ms: u64) -> Vec<usize> {
2183            if self.needs_layout {
2184                let mut lctx = LayoutCtx::with_text_context(&mut self.text as &mut dyn Any)
2185                    .with_theme(&self.theme as &dyn Any);
2186                self.size = self
2187                    .widget
2188                    .layout(&mut lctx, &BoxConstraints::loose(self.max));
2189            }
2190            let mut scene = GlyphCounter::default();
2191            let mut ctx = PaintCtx::for_test(
2192                Point::ZERO,
2193                self.size,
2194                FrameTime::from_nanos(t_ms * 1_000_000),
2195            )
2196            .with_theme(&self.theme as &dyn Any);
2197            self.widget.paint(&mut ctx, &mut scene);
2198            self.needs_layout = ctx.needs_layout();
2199            scene.runs
2200        }
2201
2202        /// Run frames every 16ms from `from_ms` until `to_ms`.
2203        fn run_until(&mut self, from_ms: u64, to_ms: u64) -> Vec<usize> {
2204            let mut painted = Vec::new();
2205            let mut t = from_ms;
2206            while t <= to_ms {
2207                painted = self.run(t);
2208                t += 16;
2209            }
2210            painted
2211        }
2212
2213        /// The main extent every member was last laid out at.
2214        fn member_mains(&self) -> Vec<f64> {
2215            self.widget.members.iter().map(|p| p.size().width).collect()
2216        }
2217
2218        fn pointer(&mut self, phase: PointerPhase, x: f64) {
2219            let mut state = ();
2220            let size = self.size;
2221            dispatch(&mut self.widget, &mut state, size, &ev(phase, x, 16.0));
2222        }
2223    }
2224
2225    #[test]
2226    fn a_settled_squish_restores_every_member_to_its_natural_shape() {
2227        let view = button_group_actions(labelled_actions())
2228            .density(ButtonGroupDensity::Compact)
2229            .spacing(8.0)
2230            .size(ToggleButtonSize::Sm);
2231        let mut frames = Frames::new(&view, Size::new(1000.0, 200.0));
2232        let resting_runs = frames.run(0);
2233        let resting_mains = frames.member_mains();
2234        assert_eq!(resting_runs.len(), 3, "one label run per member");
2235
2236        // The two legs the device probe took: press the middle member (both
2237        // neighbours shrink), then the first (its single neighbour takes it
2238        // all). Each press ran a full cycle — hold, release, settle.
2239        let mut t = 0u64;
2240        for pressed in [1usize, 0] {
2241            let x = frames.widget.member_bases[pressed].x + 4.0;
2242            frames.pointer(PointerPhase::Down, x);
2243            let mid_squish = frames.run_until(t + 16, t + 16 * 4);
2244            assert_eq!(
2245                mid_squish, resting_runs,
2246                "a squished neighbour of {pressed} covers its label, never re-shapes it"
2247            );
2248            frames.run_until(t + 16 * 5, t + 16 * 60);
2249            frames.pointer(PointerPhase::Up, x);
2250            let settled_runs = frames.run_until(t + 16 * 61, t + 16 * 160);
2251            t += 16 * 160;
2252
2253            assert!(
2254                !frames.widget.squish.is_animating(),
2255                "the squish spring settled within the driven frames (pressed {pressed})"
2256            );
2257            assert_eq!(
2258                frames.widget.pressed_index, None,
2259                "the settled spring released member {pressed}"
2260            );
2261            assert_eq!(
2262                settled_runs, resting_runs,
2263                "every label paints its full glyph run again once member {pressed}'s squish settles"
2264            );
2265            assert_eq!(
2266                frames.member_mains(),
2267                resting_mains,
2268                "every member is back at its natural main extent after pressing {pressed}"
2269            );
2270        }
2271    }
2272
2273    #[test]
2274    fn reduce_motion_freezes_the_squish() {
2275        let mut theme = crate::baseline();
2276        theme.motion.reduce_motion = true;
2277        let mut widget = build(&button_group_actions(bare_icon_actions(2)));
2278        let size = layout_themed(&mut widget, Size::new(500.0, 200.0), &theme);
2279        let mut state = ();
2280        dispatch(
2281            &mut widget,
2282            &mut state,
2283            size,
2284            &ev(PointerPhase::Down, 4.0, 20.0),
2285        );
2286        assert!(widget.squish.is_animating());
2287        paint(&mut widget, size, Some(&theme));
2288        assert!(
2289            !widget.squish.is_animating(),
2290            "reduce_motion stops the spring"
2291        );
2292        assert_eq!(widget.pressed_index, None);
2293    }
2294
2295    // ---- selection fan-out -------------------------------------------------
2296
2297    #[test]
2298    fn tap_reports_the_tapped_member_index() {
2299        let mut widget = build(&button_group::<u32, _>(
2300            vec!["A", "B", "C"],
2301            0,
2302            |s: &mut u32, i| *s = i as u32,
2303        ));
2304        let size = layout(&mut widget, Size::new(500.0, 200.0));
2305        let mid = widget.member_bases[1].x + 4.0;
2306        let mut state = 0u32;
2307        dispatch(
2308            &mut widget,
2309            &mut state,
2310            size,
2311            &ev(PointerPhase::Down, mid, 20.0),
2312        );
2313        assert_eq!(widget.pressed_index, Some(1));
2314        dispatch(
2315            &mut widget,
2316            &mut state,
2317            size,
2318            &ev(PointerPhase::Up, mid, 20.0),
2319        );
2320        assert_eq!(state, 1, "on_select fired with the tapped index");
2321    }
2322
2323    #[test]
2324    fn the_legacy_callback_still_fires_on_the_already_selected_member() {
2325        // The pre-rework contract: `on_select` always reports the tapped index,
2326        // never a deselect.
2327        let mut widget = build(&button_group::<u32, _>(
2328            vec!["A", "B"],
2329            1,
2330            |s: &mut u32, i| *s = 40 + i as u32,
2331        ));
2332        let size = layout(&mut widget, Size::new(500.0, 200.0));
2333        let x = widget.member_bases[1].x + 4.0;
2334        let mut state = 0u32;
2335        dispatch(
2336            &mut widget,
2337            &mut state,
2338            size,
2339            &ev(PointerPhase::Down, x, 20.0),
2340        );
2341        dispatch(
2342            &mut widget,
2343            &mut state,
2344            size,
2345            &ev(PointerPhase::Up, x, 20.0),
2346        );
2347        assert_eq!(state, 41);
2348    }
2349
2350    #[test]
2351    fn single_select_reports_none_when_deselecting_the_current_member() {
2352        let seen: Rc<RefCell<Vec<Option<usize>>>> = Rc::new(RefCell::new(Vec::new()));
2353        let sink = seen.clone();
2354        let view = button_group_actions(bare_icon_actions(2))
2355            .selected_index(Some(1))
2356            .on_selected_index_changed(move |_s: &mut (), next| sink.borrow_mut().push(next));
2357        let mut widget = build(&view);
2358        let size = layout(&mut widget, Size::new(500.0, 200.0));
2359        let x = widget.member_bases[1].x + 4.0;
2360        let mut state = ();
2361        dispatch(
2362            &mut widget,
2363            &mut state,
2364            size,
2365            &ev(PointerPhase::Down, x, 20.0),
2366        );
2367        dispatch(
2368            &mut widget,
2369            &mut state,
2370            size,
2371            &ev(PointerPhase::Up, x, 20.0),
2372        );
2373        assert_eq!(*seen.borrow(), vec![None]);
2374    }
2375
2376    #[test]
2377    fn multi_select_reports_the_requested_index_set() {
2378        let seen: Rc<RefCell<Vec<Vec<usize>>>> = Rc::new(RefCell::new(Vec::new()));
2379        let sink = seen.clone();
2380        let view = button_group_actions(bare_icon_actions(3))
2381            .selected_indices([2])
2382            .on_selected_indices_changed(move |_s: &mut (), next| sink.borrow_mut().push(next));
2383        let mut widget = build(&view);
2384        let size = layout(&mut widget, Size::new(500.0, 200.0));
2385        let x = widget.member_bases[0].x + 4.0;
2386        let mut state = ();
2387        dispatch(
2388            &mut widget,
2389            &mut state,
2390            size,
2391            &ev(PointerPhase::Down, x, 20.0),
2392        );
2393        dispatch(
2394            &mut widget,
2395            &mut state,
2396            size,
2397            &ev(PointerPhase::Up, x, 20.0),
2398        );
2399        assert_eq!(*seen.borrow(), vec![vec![0, 2]], "added, kept ascending");
2400    }
2401
2402    #[test]
2403    fn multi_select_removes_an_already_selected_index() {
2404        let seen: Rc<RefCell<Vec<Vec<usize>>>> = Rc::new(RefCell::new(Vec::new()));
2405        let sink = seen.clone();
2406        let view = button_group_actions(bare_icon_actions(3))
2407            .selected_indices([0, 2])
2408            .on_selected_indices_changed(move |_s: &mut (), next| sink.borrow_mut().push(next));
2409        let mut widget = build(&view);
2410        let size = layout(&mut widget, Size::new(500.0, 200.0));
2411        let x = widget.member_bases[2].x + 4.0;
2412        let mut state = ();
2413        dispatch(
2414            &mut widget,
2415            &mut state,
2416            size,
2417            &ev(PointerPhase::Down, x, 20.0),
2418        );
2419        dispatch(
2420            &mut widget,
2421            &mut state,
2422            size,
2423            &ev(PointerPhase::Up, x, 20.0),
2424        );
2425        assert_eq!(*seen.borrow(), vec![vec![0]]);
2426    }
2427
2428    #[test]
2429    fn selection_resolution_follows_the_documented_precedence() {
2430        // 1. indices win over index.
2431        let view = button_group_actions(bare_icon_actions(3))
2432            .selected_index(Some(0))
2433            .selected_indices([1, 2]);
2434        assert!(!view.is_selected(0));
2435        assert!(view.is_selected(1) && view.is_selected(2));
2436
2437        // 2. index wins over a per-action `checked`.
2438        let actions = vec![
2439            button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)).checked(true),
2440            button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)),
2441        ];
2442        let view = button_group_actions(actions).selected_index(Some(1));
2443        assert!(!view.is_selected(0));
2444        assert!(view.is_selected(1));
2445
2446        // 3. uncontrolled: the action's own flag.
2447        let actions = vec![
2448            button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)).checked(true),
2449            button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)),
2450        ];
2451        let view = button_group_actions(actions);
2452        assert!(view.is_selected(0));
2453        assert!(!view.is_selected(1));
2454    }
2455
2456    #[test]
2457    fn a_selected_index_outside_the_action_range_selects_nothing() {
2458        let view = button_group::<(), _>(vec!["A", "B"], 7, |_s, _i| {});
2459        assert!(!view.is_selected(0));
2460        assert!(!view.is_selected(1));
2461    }
2462
2463    #[test]
2464    fn is_controlled_the_view_never_self_mutates_its_selection() {
2465        // A tap reports the request only; the app feeds the confirmed value
2466        // back on the next rebuild, which is what moves the member's own
2467        // checked state.
2468        let mut widget = build(&button_group::<u32, _>(vec!["A", "B"], 0, |_s, _i| {}));
2469        let size = layout(&mut widget, Size::new(500.0, 200.0));
2470        let x = widget.member_bases[1].x + 4.0;
2471        let mut state = 0u32;
2472        dispatch(
2473            &mut widget,
2474            &mut state,
2475            size,
2476            &ev(PointerPhase::Down, x, 20.0),
2477        );
2478        dispatch(
2479            &mut widget,
2480            &mut state,
2481            size,
2482            &ev(PointerPhase::Up, x, 20.0),
2483        );
2484
2485        let prev = button_group::<u32, _>(vec!["A", "B"], 0, |_s, _i| {});
2486        let next = button_group::<u32, _>(vec!["A", "B"], 1, |_s, _i| {});
2487        let mut counter = 0u64;
2488        let flags =
2489            View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
2490        assert!(!flags.is_empty(), "the selection flip reaches the members");
2491    }
2492
2493    #[test]
2494    fn a_release_outside_the_pressed_member_does_not_fire() {
2495        let mut widget = build(&button_group::<u32, _>(
2496            vec!["A", "B"],
2497            0,
2498            |s: &mut u32, i| *s = i as u32,
2499        ));
2500        let size = layout(&mut widget, Size::new(500.0, 200.0));
2501        let mut state = 7u32;
2502        dispatch(
2503            &mut widget,
2504            &mut state,
2505            size,
2506            &ev(PointerPhase::Down, 4.0, 20.0),
2507        );
2508        let away = widget.member_bases[1].x + 4.0;
2509        dispatch(
2510            &mut widget,
2511            &mut state,
2512            size,
2513            &ev(PointerPhase::Move, away, 20.0),
2514        );
2515        dispatch(
2516            &mut widget,
2517            &mut state,
2518            size,
2519            &ev(PointerPhase::Up, away, 20.0),
2520        );
2521        assert_eq!(state, 7, "the member's own up-inside rule still governs");
2522    }
2523
2524    #[test]
2525    fn a_down_outside_every_member_is_ignored() {
2526        let mut widget = build(&button_group_actions(bare_icon_actions(2)));
2527        let size = layout(&mut widget, Size::new(500.0, 200.0));
2528        let mut state = ();
2529        let state_any: &mut dyn Any = &mut state;
2530        let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2531        let result = widget.event(&mut ctx, &ev(PointerPhase::Down, 400.0, 20.0));
2532        assert!(matches!(result, EventResult::Ignored));
2533        assert_eq!(widget.pressed_index, None);
2534    }
2535
2536    #[test]
2537    fn cancel_clears_the_press_without_firing() {
2538        let mut widget = build(&button_group::<u32, _>(
2539            vec!["A", "B"],
2540            0,
2541            |s: &mut u32, i| *s = i as u32,
2542        ));
2543        let size = layout(&mut widget, Size::new(500.0, 200.0));
2544        let mut state = 3u32;
2545        dispatch(
2546            &mut widget,
2547            &mut state,
2548            size,
2549            &ev(PointerPhase::Down, 4.0, 20.0),
2550        );
2551        assert!(widget.active.is_some());
2552        dispatch(
2553            &mut widget,
2554            &mut state,
2555            size,
2556            &ev(PointerPhase::Cancel, 4.0, 20.0),
2557        );
2558        assert!(widget.active.is_none());
2559        assert_eq!(state, 3);
2560    }
2561
2562    // ---- overflow behaviours ------------------------------------------------
2563
2564    #[test]
2565    fn overflow_none_lets_the_content_exceed_the_box() {
2566        let mut widget =
2567            build(&button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::None));
2568        layout(&mut widget, Size::new(100.0, 200.0));
2569        assert_eq!(widget.visible_count, 6, "nothing is hidden");
2570        assert!(widget.content_main > widget.viewport_main);
2571        assert!(!widget.can_scroll(), "None never pans");
2572    }
2573
2574    #[test]
2575    fn overflow_scroll_pans_after_the_touch_slop() {
2576        let mut widget = build(&button_group_actions(bare_icon_actions(6)));
2577        let size = layout(&mut widget, Size::new(100.0, 200.0));
2578        assert!(widget.can_scroll());
2579        let start_x = widget.member_bases[0].x;
2580        let mut state = ();
2581        dispatch(
2582            &mut widget,
2583            &mut state,
2584            size,
2585            &ev(PointerPhase::Down, 4.0, 20.0),
2586        );
2587        // Below the slop: still the member's gesture, no pan.
2588        dispatch(
2589            &mut widget,
2590            &mut state,
2591            size,
2592            &ev(PointerPhase::Move, 4.0, 20.0),
2593        );
2594        assert!(!widget.panning);
2595        assert_eq!(widget.scroll_offset, 0.0);
2596        // Past the slop: the group takes over.
2597        let far = 4.0 - TOUCH_SLOP - 20.0;
2598        dispatch(
2599            &mut widget,
2600            &mut state,
2601            size,
2602            &ev(PointerPhase::Move, far, 20.0),
2603        );
2604        assert!(widget.panning);
2605        dispatch(
2606            &mut widget,
2607            &mut state,
2608            size,
2609            &ev(PointerPhase::Move, far - 15.0, 20.0),
2610        );
2611        assert!(widget.scroll_offset > 0.0, "the row panned");
2612        assert!(
2613            widget.members[0].origin().x < start_x,
2614            "member origins follow the pan"
2615        );
2616        dispatch(
2617            &mut widget,
2618            &mut state,
2619            size,
2620            &ev(PointerPhase::Up, far - 15.0, 20.0),
2621        );
2622        assert!(!widget.panning);
2623    }
2624
2625    #[test]
2626    fn overflow_scroll_clamps_the_pan_to_the_content() {
2627        let mut widget = build(&button_group_actions(bare_icon_actions(6)));
2628        layout(&mut widget, Size::new(100.0, 200.0));
2629        let max = widget.max_scroll();
2630        widget.pan_by(-10_000.0);
2631        assert_eq!(widget.scroll_offset, max);
2632        widget.pan_by(10_000.0);
2633        assert_eq!(widget.scroll_offset, 0.0);
2634    }
2635
2636    #[test]
2637    fn overflow_scroll_does_not_pan_when_the_content_fits() {
2638        let mut widget = build(&button_group_actions(bare_icon_actions(2)));
2639        layout(&mut widget, Size::new(500.0, 200.0));
2640        assert!(!widget.can_scroll());
2641        assert!(!widget.pan_by(-50.0));
2642    }
2643
2644    #[test]
2645    fn overflow_bottom_sheet_splits_and_shows_a_trigger() {
2646        let mut widget = build(
2647            &button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::BottomSheet),
2648        );
2649        layout(&mut widget, Size::new(140.0, 200.0));
2650        // available 140; item(36) + sep(8) + trigger(36) = 80 < 140 -> 1,
2651        // then 36+8+36 = 80 (+44 reserved) = 124 < 140 -> 2,
2652        // then 80+8+36 = 124 (+44) = 168 >= 140 -> stop.
2653        assert_eq!(widget.visible_count, 2);
2654        assert!(widget.trigger_visible);
2655        assert_eq!(widget.member_bases.len(), 2);
2656    }
2657
2658    #[test]
2659    fn overflow_bottom_sheet_hides_nothing_when_everything_fits() {
2660        let mut widget = build(
2661            &button_group_actions(bare_icon_actions(3)).overflow(ButtonGroupOverflow::BottomSheet),
2662        );
2663        layout(&mut widget, Size::new(500.0, 200.0));
2664        assert_eq!(widget.visible_count, 3);
2665        assert!(!widget.trigger_visible);
2666    }
2667
2668    #[test]
2669    fn tapping_the_overflow_trigger_reports_the_first_hidden_index() {
2670        let seen: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
2671        let sink = seen.clone();
2672        let view = button_group_actions(bare_icon_actions(6))
2673            .overflow(ButtonGroupOverflow::BottomSheet)
2674            .on_overflow(move |_s: &mut (), first| sink.borrow_mut().push(first));
2675        let mut widget = build(&view);
2676        let size = layout(&mut widget, Size::new(140.0, 200.0));
2677        let trigger_x = widget.trigger_base.x + 4.0;
2678        let mut state = ();
2679        dispatch(
2680            &mut widget,
2681            &mut state,
2682            size,
2683            &ev(PointerPhase::Down, trigger_x, 20.0),
2684        );
2685        assert_eq!(widget.active, Some(Target::Trigger));
2686        assert_eq!(widget.pressed_index, None, "the trigger never squishes");
2687        dispatch(
2688            &mut widget,
2689            &mut state,
2690            size,
2691            &ev(PointerPhase::Up, trigger_x, 20.0),
2692        );
2693        assert_eq!(*seen.borrow(), vec![2]);
2694    }
2695
2696    // ---- overflow popup style ----------------------------------------------
2697
2698    #[test]
2699    fn overflow_popup_group_builds_a_trigger_pod() {
2700        let view = button_group_actions(bare_icon_actions(3)).overflow(ButtonGroupOverflow::Popup);
2701        let widget = build(&view);
2702        assert!(widget.trigger.is_some());
2703    }
2704
2705    #[test]
2706    fn overflow_popup_splits_and_shows_a_trigger() {
2707        // Same split as `overflow_bottom_sheet_splits_and_shows_a_trigger`
2708        // above — `Popup` shares `BottomSheet`'s layout, only the
2709        // presentation differs.
2710        let mut widget =
2711            build(&button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::Popup));
2712        layout(&mut widget, Size::new(140.0, 200.0));
2713        assert_eq!(widget.visible_count, 2);
2714        assert!(widget.trigger_visible);
2715    }
2716
2717    #[test]
2718    fn the_trigger_captures_its_window_rect_into_the_overflow_anchor_when_wired() {
2719        let anchor = OverlayAnchor::new();
2720        let cell = anchor.clone();
2721        let mut root: frust_core::RenderRoot<(), ButtonGroupView<()>> =
2722            frust_core::RenderRoot::new();
2723        let mut state = ();
2724        let mut logic = move |_s: &mut ()| {
2725            button_group_actions(bare_icon_actions(6))
2726                .overflow(ButtonGroupOverflow::Popup)
2727                .overflow_anchor(&cell)
2728        };
2729        root.rebuild(&mut logic, &mut state);
2730        let mut tcx = TextContext::new();
2731        root.layout_with_text(Size::new(140.0, 200.0), &mut tcx as &mut dyn Any);
2732        let mut scene = NullScene;
2733        root.paint(&mut scene, FrameTime::ZERO);
2734        let rect = anchor.rect();
2735        assert!(
2736            rect.width() > 0.0 && rect.height() > 0.0,
2737            "the trigger's window rect was captured into the overflow anchor on paint: {rect:?}"
2738        );
2739    }
2740
2741    #[derive(Default)]
2742    struct OverflowMenuState {
2743        selected: Option<usize>,
2744    }
2745
2746    fn overflow_menu_actions() -> Vec<ButtonGroupAction<OverflowMenuState>> {
2747        (0..6)
2748            .map(|i| button_group_action(format!("Item {i}")))
2749            .collect()
2750    }
2751
2752    #[test]
2753    fn overflow_menu_shows_hidden_members_checked_state_per_upstream() {
2754        // Upstream marks a hidden action checked in the overflow menu the
2755        // same way a visible member reads checked
2756        // (`_buildOverflowMenuVisibleItems`/`onItemSelected`,
2757        // `m3e_toggle_button_group_layout.dart:181`-`:203`); this pins the
2758        // port's equivalent via the popup row's own `MenuItemCheckBox`
2759        // semantics.
2760        let anchor = OverlayAnchor::new();
2761        anchor.set(Rect::new(40.0, 100.0, 160.0, 140.0));
2762        let cell = anchor.clone();
2763        let mut root: frust_core::RenderRoot<
2764            OverflowMenuState,
2765            frust::StackView<OverflowMenuState>,
2766        > = frust_core::RenderRoot::new();
2767        let mut state = OverflowMenuState { selected: Some(4) };
2768        let mut logic = move |s: &mut OverflowMenuState| {
2769            let group = button_group_actions(overflow_menu_actions())
2770                .overflow(ButtonGroupOverflow::Popup)
2771                .overflow_anchor(&cell)
2772                .selected_index(s.selected);
2773            // Indices 4 and 5 are the ones `on_overflow` would have reported
2774            // hidden; mounted separately as the popup's own two-piece shape
2775            // documents.
2776            frust::Stack(vec![group.overflow_menu(4, true)])
2777        };
2778        root.rebuild(&mut logic, &mut state);
2779        let mut tcx = TextContext::new();
2780        root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
2781        let update = root.semantics();
2782
2783        let boxes: Vec<_> = update
2784            .nodes
2785            .iter()
2786            .filter(|(_, n)| n.role() == Role::MenuItemCheckBox)
2787            .collect();
2788        assert_eq!(boxes.len(), 2, "only the two hidden members (indices 4, 5)");
2789        let checked = boxes
2790            .iter()
2791            .find(|(_, n)| n.label() == Some("Item 4"))
2792            .expect("Item 4 is present");
2793        assert_eq!(
2794            checked.1.toggled(),
2795            Some(frust::authoring::Toggled::True),
2796            "the group's own selected index shows checked in the popup"
2797        );
2798        let unchecked = boxes
2799            .iter()
2800            .find(|(_, n)| n.label() == Some("Item 5"))
2801            .expect("Item 5 is present");
2802        assert_eq!(
2803            unchecked.1.toggled(),
2804            Some(frust::authoring::Toggled::False)
2805        );
2806    }
2807
2808    #[test]
2809    fn overflow_menu_selection_routes_through_the_groups_own_fan_out() {
2810        // One hidden member only, so the click lands on the popup's first
2811        // (and only) row, at the same window-space center `crate::menu`'s
2812        // own tests pin for a fresh anchored menu.
2813        let anchor_rect = Rect::new(40.0, 100.0, 160.0, 140.0);
2814        let anchor = OverlayAnchor::new();
2815        anchor.set(anchor_rect);
2816        let cell = anchor.clone();
2817        let mut root: frust_core::RenderRoot<
2818            OverflowMenuState,
2819            frust::StackView<OverflowMenuState>,
2820        > = frust_core::RenderRoot::new();
2821        let mut state = OverflowMenuState { selected: None };
2822        let mut logic = move |s: &mut OverflowMenuState| {
2823            let group = button_group_actions(overflow_menu_actions())
2824                .overflow(ButtonGroupOverflow::Popup)
2825                .overflow_anchor(&cell)
2826                .selected_index(s.selected)
2827                .on_selected_index_changed(|s: &mut OverflowMenuState, i| s.selected = i);
2828            frust::Stack(vec![group.overflow_menu(5, true)])
2829        };
2830        root.rebuild(&mut logic, &mut state);
2831        let mut tcx = TextContext::new();
2832        root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
2833
2834        let row = Point::new(
2835            anchor_rect.x0 + crate::menu::MENU_MIN_WIDTH / 2.0,
2836            anchor_rect.y1
2837                + crate::overlay::OVERLAY_ANCHOR_GAP
2838                + crate::menu::MENU_SURFACE_V_PADDING
2839                + (crate::menu::MENU_ITEM_GAP + crate::menu::MENU_ROW_MIN_HEIGHT) / 2.0,
2840        );
2841        root.event(&mut state, &ev(PointerPhase::Down, row.x, row.y));
2842        root.event(&mut state, &ev(PointerPhase::Up, row.x, row.y));
2843
2844        assert_eq!(
2845            state.selected,
2846            Some(5),
2847            "activating the only hidden member's popup row reports through \
2848             `on_selected_index_changed`, exactly as a visible member's own tap does"
2849        );
2850    }
2851
2852    // ---- semantics ---------------------------------------------------------
2853
2854    #[test]
2855    fn semantics_yields_a_group_of_toggle_members() {
2856        // Rewritten for the rework: members are real toggle buttons now, so
2857        // each contributes its own `Role::Button` + `Toggled` node rather than
2858        // the pre-rework `Role::RadioButton` the group synthesised itself.
2859        fn logic(_s: &mut ()) -> ButtonGroupView<()> {
2860            button_group::<(), _>(vec!["One", "Two", "Three"], 2, |_s: &mut (), _i| {})
2861                .semantic_label("Range")
2862        }
2863        let mut root: frust_core::RenderRoot<(), ButtonGroupView<()>> =
2864            frust_core::RenderRoot::new();
2865        let mut state = ();
2866        root.rebuild(&mut logic, &mut state);
2867        let mut tcx = TextContext::new();
2868        root.layout_with_text(Size::new(300.0, 60.0), &mut tcx as &mut dyn Any);
2869        let update = root.semantics();
2870
2871        let group = update
2872            .nodes
2873            .iter()
2874            .find(|(_, n)| n.role() == Role::Group)
2875            .expect("a Group container node is contributed");
2876        assert_eq!(group.1.children().len(), 3);
2877        assert_eq!(group.1.label(), Some("Range"));
2878
2879        let members: Vec<_> = update
2880            .nodes
2881            .iter()
2882            .filter(|(_, n)| n.role() == Role::Button)
2883            .collect();
2884        assert_eq!(members.len(), 3);
2885        let selected = members
2886            .iter()
2887            .find(|(_, n)| n.label() == Some("Three"))
2888            .expect("the Three member is present");
2889        assert_eq!(selected.1.toggled(), Some(frust::authoring::Toggled::True));
2890        let unselected = members
2891            .iter()
2892            .find(|(_, n)| n.label() == Some("One"))
2893            .expect("the One member is present");
2894        assert_eq!(
2895            unselected.1.toggled(),
2896            Some(frust::authoring::Toggled::False)
2897        );
2898    }
2899
2900    #[test]
2901    fn semantics_omit_the_members_the_overflow_split_hid() {
2902        let mut widget = build(
2903            &button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::BottomSheet),
2904        );
2905        layout(&mut widget, Size::new(140.0, 200.0));
2906        assert_eq!(widget.visible_count, 2);
2907        // Input cannot reach a hidden member, so semantics omits it — the
2908        // input-parity carve-out in `docs/CODE_STANDARDS.md`.
2909        assert!(widget.hit(Point::new(300.0, 20.0)).is_none());
2910    }
2911
2912    // ---- the legacy shape model is gone ------------------------------------
2913
2914    #[test]
2915    fn this_module_no_longer_uses_the_legacy_radial_shape_model() {
2916        // Needles are assembled at runtime so this assertion cannot match its
2917        // own source text.
2918        let src = include_str!("button_group.rs");
2919        let import = format!("use super::{}_morph", "shape");
2920        let call = format!("{}_path(", "morph");
2921        assert!(
2922            !src.contains(&import),
2923            "the legacy radial polygon module must not be imported here"
2924        );
2925        assert!(
2926            !src.contains(&call),
2927            "the legacy radial morph primitive must not be called here"
2928        );
2929    }
2930}