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