Skip to main content

frust_material/
segmented_button.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// `lib/components/segmented_buttons/m3e_segmented_buttons.dart` +
3// `models/m3e_segment.dart` + `styles/m3e_segmented_button_theme.dart` +
4// `components/m3e_segment_divider.dart` (retrieved 2026-08-20).
5// Upstream: <https://github.com/paadevelopments/material_3_expressive>
6
7//! The Material 3 Expressive **segmented button**: a row of 2-5 connected
8//! segments for selecting an option, switching a view, or sorting — single-
9//! or multi-select, with an optional checkmark on the selected member(s).
10//!
11//! [`segmented_button`] builds a [`SegmentedButtonView`]; [`segment`] builds
12//! one [`Segment`] of it. It is a **controlled component** (see
13//! `docs/CODE_STANDARDS.md`'s Interaction Semantics): a tap fires
14//! `on_selection_changed` with the *requested* selection and leaves
15//! `selected` untouched — the app feeds the confirmed selection back on the
16//! next rebuild, exactly like [`mod@crate::button_group`]/[`mod@crate::switch`].
17//!
18//! # One `Vec<T>` shape for both selection modes
19//!
20//! The reference carries a single `Set<T> selected` field and a single
21//! `ValueChanged<Set<T>> onSelectionChanged` callback regardless of
22//! `multiSelect` — `_handleTap` (`m3e_segmented_buttons.dart:47-61`) is what
23//! branches on the mode, not the widget's own shape. This port mirrors that
24//! exactly rather than splitting into two view-fns with two different
25//! reported-value types (`T` for single-select, `Vec<T>` for multi-select):
26//! [`segmented_button`] always takes/reports `Vec<T>` (this framework's
27//! ordered stand-in for Dart's `Set<T>` — order is caller-controlled and
28//! never reshuffled by this widget), and [`SegmentedButtonView::multi_select`]
29//! only changes what [`next_selection`] computes internally on a tap. One
30//! controlled component, one reported-value type, matching the reference's
31//! own single-widget shape.
32//!
33//! # Selection semantics: single can't deselect, multi toggles
34//!
35//! [`next_selection`] transcribes `_handleTap` exactly:
36//! - **Single-select** (the default) always *replaces* the whole selection
37//!   with the tapped value alone (`next..clear()..add(value)`) — including
38//!   when the tapped segment is already the sole selected member. A
39//!   single-select segmented button can never be tapped down to zero
40//!   selected members; there is no toggle-off branch in the reference for
41//!   this mode.
42//! - **Multi-select** ([`SegmentedButtonView::multi_select`]`(true)`) toggles
43//!   the tapped value's membership: absent → inserted, present → removed. A
44//!   multi-select group *can* reach zero selected members.
45//!
46//! # Checkmark: swaps in over the segment's own icon, even a label-only one
47//!
48//! [`SegmentedButtonView::show_selected_icon`] (default `true`, matching the
49//! reference) makes a selected segment's leading icon slot resolve to
50//! [`crate::icons::CHECK`] instead of [`Segment::icon`] —
51//! `_resolveLeading` (`m3e_segmented_buttons.dart:268-286`) checks
52//! `selected && showSelectedIcon` *first*, unconditionally, before falling
53//! back to the segment's own icon. One consequence transcribed faithfully
54//! rather than "fixed": a label-only segment (no [`Segment::icon`]) gains a
55//! leading checkmark the instant it is selected, which **changes that
56//! segment's measured width** — this port's [`Widget::layout`] recomputes
57//! icon presence from the *current* selection every layout pass, so the row
58//! reflows exactly as the reference's `Row` does. **Porting decision:**
59//! [`Segment::icon`] takes an [`frust::IconSource`] rather than the
60//! reference's arbitrary `Widget? icon` — the swap target is itself a
61//! same-kind [`frust::IconSource`] ([`crate::icons::CHECK`]), so both slots
62//! stay the same closed shape, mirroring [`mod@crate::switch`]'s
63//! `selected_icon`/`unselected_icon` pair rather than
64//! [`mod@crate::toggle_button`]'s arbitrary-`AnyView` icon slot (a label is
65//! still a plain string, painted the same directly-shaped-and-rebrushed way
66//! [`mod@crate::toggle_button`]'s own `LabelRun` is).
67//!
68//! # Connected geometry: outer pill, square inner edges, hairline dividers
69//!
70//! The reference wraps the whole row in one `Container` whose
71//! `borderRadius` is `M3EShapes.resolve(height / 2)` — a literal pill radius
72//! for the group's *own* height, not a themed shape-scale rung — and clips
73//! the row to it via `ClipRRect`; individual members are plain, un-rounded
74//! `Container`s (`m3e_segmented_buttons.dart:80-98`). This port reproduces
75//! the same pixels without a clip primitive: [`outer_radius`] resolves
76//! [`frust::ShapeScale::full`] against the group's own `(width, height)` —
77//! the theme-scale token whose [`frust::ShapeScale::resolve`] contract
78//! (`min(width, height) / 2`, clamped-not-literal) coincides with the
79//! reference's `height / 2` for every realistic (wider-than-tall) group, and
80//! degrades gracefully instead of overflowing for a pathologically narrow
81//! one the reference does not defend against — and [`segment_radii`] gives
82//! only the leftmost segment's left corners / rightmost segment's right
83//! corners that resolved radius, **`0.0`** (square) on every other corner,
84//! including every inner, adjacent-segment corner. This is a different inner
85//! value than [`mod@crate::button_group`]'s own `member_radii` (a small
86//! nonzero [`frust::ShapeScale::extra_small`] rung) — that module clips
87//! nothing and rounds every member independently; this one has no clip to
88//! stand in for, so inner corners are literally square, exactly as the
89//! reference's own un-rounded member `Container`s paint. Each member's
90//! background/state-layer overlay is painted with its own [`segment_radii`]
91//! via [`frust::authoring::PaintScene::fill_rounded_rect_radii`] — the same
92//! per-corner-native paint call [`mod@crate::toggle_button`]'s own connected-group
93//! corners use, rather than [`mod@crate::button_group`]'s `kurbo`-path route;
94//! both reach the same pixels.
95//!
96//! **Dividers — solid color, not the reference's sampled gradient.** The
97//! reference's `M3ESegmentDivider` samples an optional gradient
98//! across the whole row via a `GlobalKey`-hosted `RenderBox`
99//! (`components/m3e_segment_divider.dart`) so every hairline lines up with
100//! one continuous ramp instead of each stretching its own copy over a
101//! sub-pixel width. This framework has no GPU shader-gradient divider
102//! primitive and no cross-widget geometry query a `GlobalKey` read would
103//! need; this port paints every divider as a solid
104//! [`frust::authoring::PaintScene::stroke_line`] in the theme's `outline`
105//! role (`segmentedButtonTheme.divider`'s own default, since
106//! `dividerColor`/`dividerGradient` are never set by any of this crate's
107//! constructors) — the visual result for the only configuration this crate
108//! ships (solid dividers), with the gradient-sampling case left unported.
109//!
110//! # Generic selection model — no `T` on the retained widget
111//!
112//! Mirrors [`mod@crate::radio`]'s own "Generic selection model" section
113//! exactly: [`segmented_button`] takes owned `T: PartialEq + Clone +
114//! 'static` values, but the retained [`SegmentedButtonWidget`] carries no
115//! generic parameter of its own. [`bind_on_tap`] closes over
116//! `on_selection_changed`, the *current* `selected`/`multi_select`, and one
117//! segment's own `value` at `build`/`rebuild` time (where `T` is still in
118//! scope) into a plain [`frust::authoring::ErasedCallback`] — the type-erase-
119//! to-avoid-a-generic idiom `docs/CODE_STANDARDS.md`'s Language Idioms
120//! describes for a downstream-crate dependency, applied here to a generic
121//! instead. Closures aren't comparable, so every `rebuild` reinstalls the
122//! whole `Vec` of them unconditionally, same as [`mod@crate::radio`]/
123//! [`mod@crate::switch`]/[`mod@crate::button_group`]'s own callback rebinding.
124//!
125//! # State layer + press via the interaction core, no hover/focus
126//!
127//! Each segment tracks its own [`crate::interaction::InteractionState`] (the
128//! newer, precedence-resolving substrate — see that module's docs), but only
129//! ever calls [`crate::interaction::InteractionState::set_pressed`]: no row-
130//! of-items widget in this catalog wires hover or focus claiming today
131//! ([`mod@crate::button_group`], `navbar`, `chips` all leave both unwired
132//! too), so this module follows the same established scope rather than
133//! inventing a new per-segment hover-claim mechanism this task does not
134//! need. The reference's `pressedScale` stays at its class default (`1`,
135//! i.e. no press-scale spring — `M3ESegmentedButton` never overrides
136//! `M3ETappable.pressedScale`), so unlike [`mod@crate::button_group`]'s own
137//! shape-morph press emphasis, a pressed segment here paints a plain
138//! [`crate::interaction::InteractionState::resolve_opacity`] state-layer
139//! fill in its own foreground color — the same mechanism
140//! [`mod@crate::icon_button`]/[`mod@crate::toggle_button`] use, standing in for
141//! the reference's `M3EStateLayerOverlay` (Flutter's `InkWell` ripple this
142//! framework has no equivalent of — this crate's whole-catalog convention,
143//! see `mod@crate::icon_button`'s `suppress_ink` doc).
144//!
145//! Haptics fire [`crate::interaction::HapticSignal::None`] on every confirmed
146//! tap through [`crate::interaction::MaterialHaptics::fire`] — `M3ETappable`'s
147//! own default (`m3e_tappable.dart:36`'s `haptic = M3EHapticFeedback.none`,
148//! never overridden by `M3ESegmentedButton`), a documented no-op today, wired
149//! for a future revision to override, exactly [`mod@crate::switch`]'s own
150//! Haptics section documents.
151//!
152//! # Bounds: 2-5 segments, `debug_assert`-checked
153//!
154//! [`segmented_button`] `debug_assert!`s [`MIN_SEGMENTS`]`..=`[`MAX_SEGMENTS`].
155//! The reference's own constructor `assert`s only the lower bound
156//! (`segments.length >= 2`, `m3e_segmented_buttons.dart:26`) — the upper
157//! bound of 5 is the class doc's own stated scope ("Presents 2-5 connected
158//! `M3ESegment`s", `m3e_segmented_buttons.dart:14`), not a second runtime
159//! `assert` in the Dart source. This port pins *both* ends as one
160//! `debug_assert!`, matching [`mod@crate::slider`]'s own precedent of
161//! `debug_assert!`-checking a documented-but-unenforced-upstream invariant
162//! rather than leaving it unchecked. [`Segment`]'s own "a segment needs a
163//! label or an icon" invariant (`m3e_segment.dart:8-11`) is likewise
164//! `debug_assert!`-checked, in [`segmented_button`] rather than at
165//! [`segment`]-construction time, since this crate's builder pattern adds
166//! `label`/`icon` *after* [`segment`] returns.
167
168use std::rc::Rc;
169
170use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
171use frust::authoring::{
172    Action, BoxConstraints, BuildCtx, ChangeFlags, CornerRadii, ErasedCallback, EventCtx,
173    EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role, SemanticsCtx,
174    Toggled, TypedArgCallback, View, Widget,
175};
176use frust::{IconData, IconSource, ShapeScale, Theme};
177use kurbo::{Affine, BezPath, Point, Rect, RoundedRect, Shape, Size, Vec2};
178use peniko::{Brush, Color};
179
180use crate::interaction::{HapticSignal, InteractionState, MaterialHaptics};
181use crate::press::presses;
182
183/// Fewest segments a group may carry — the reference's own `assert`
184/// (`m3e_segmented_buttons.dart:26`).
185pub const MIN_SEGMENTS: usize = 2;
186/// Most segments a group may carry — the reference class doc's stated scope
187/// (`m3e_segmented_buttons.dart:14`; see the [module docs](self)' Bounds
188/// section for why this is a `debug_assert!` here rather than a Dart
189/// `assert`).
190pub const MAX_SEGMENTS: usize = 5;
191
192/// Fixed row height, in logical px (`M3ESegmentedButtonTheme.height`'s
193/// default). Unthemed: `frust::Theme` has no matching per-component height
194/// slot, so — like [`crate::toggle_button::CONNECTED_INNER_RADIUS`] — this
195/// stays a plain constant.
196const HEIGHT: f64 = 40.0;
197/// Leading-icon side length, in logical px (`iconSize`'s default).
198const ICON_SIZE: f64 = 18.0;
199/// Horizontal padding inside each segment around its content, in logical px
200/// (`segmentHorizontalPadding`'s default).
201const SEGMENT_H_PADDING: f64 = 12.0;
202/// Gap between a segment's icon and label, in logical px (`iconLabelGap`'s
203/// default).
204const ICON_LABEL_GAP: f64 = 8.0;
205/// Group outline / divider / member-boundary stroke width, in logical px
206/// (`borderWidth`'s default).
207const BORDER_WIDTH: f64 = 1.0;
208/// Tessellation tolerance for the group outline's `kurbo` path (matches
209/// [`crate::button_group`]'s own `PATH_TOLERANCE`).
210const PATH_TOLERANCE: f64 = 0.1;
211
212/// The ink every label run is *shaped* with; never painted — recolored at
213/// paint time instead of reshaped (see [`mod@crate::button`]'s
214/// `SHAPING_INK` for why).
215const SHAPING_INK: Color = Color::BLACK;
216
217/// One row of the M3 type scale, as `(size, line_height, letter_spacing,
218/// weight)` — the unthemed fallback for the segment label role (`labelLarge`,
219/// `m3e_segmented_buttons.dart:237`'s `theme.typeScale.labelLarge`).
220const LABEL_LARGE: (f32, f32, f32, FontWeight) = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
221
222/// Unthemed-fallback `outline` (a theme resolves `colors.outline`).
223const OUTLINE: Color = Color::from_rgb8(0x79, 0x74, 0x7E);
224/// Unthemed-fallback `secondary_container`.
225const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
226/// Unthemed-fallback `on_secondary_container`.
227const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
228/// Unthemed-fallback `on_surface`.
229const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
230
231// ---- Selection logic (m3e_segmented_buttons.dart:47-61) -------------------
232
233/// The `_handleTap` selection-update rule, pure and directly testable. See
234/// the [module docs](self)' Selection semantics section for the exact
235/// single-vs-multi contract this transcribes.
236fn next_selection<T: PartialEq + Clone>(current: &[T], multi_select: bool, tapped: &T) -> Vec<T> {
237    if multi_select {
238        let mut next = current.to_vec();
239        if let Some(pos) = next.iter().position(|v| v == tapped) {
240            next.remove(pos);
241        } else {
242            next.push(tapped.clone());
243        }
244        next
245    } else {
246        vec![tapped.clone()]
247    }
248}
249
250/// Bind one segment's tap handler at `build`/`rebuild` time, type-erasing
251/// `T` out of the retained widget. See the [module docs](self)' Generic
252/// selection model section.
253fn bind_on_tap<State: 'static, T: PartialEq + Clone + 'static>(
254    on_selection_changed: &TypedArgCallback<State, Vec<T>>,
255    current_selected: &[T],
256    multi_select: bool,
257    tapped_value: T,
258) -> ErasedCallback {
259    let on_selection_changed = on_selection_changed.clone();
260    let next = next_selection(current_selected, multi_select, &tapped_value);
261    Box::new(move |ctx: &mut EventCtx| {
262        MaterialHaptics::fire(HapticSignal::None);
263        let state = ctx.state_mut::<State>();
264        on_selection_changed(state, next.clone());
265    })
266}
267
268// ---- Geometry helpers -------------------------------------------------------
269
270/// Per-segment corner radii: `outer` on the leftmost segment's left corners /
271/// rightmost segment's right corners, `0.0` (square) on every other corner —
272/// see the [module docs](self)' Connected geometry section for why this
273/// differs from [`crate::button_group::member_radii`]'s nonzero inner rung.
274/// A single-segment group (`count == 1`, never reached at runtime — see
275/// [`MIN_SEGMENTS`]) is all outer corners, the same convention
276/// `crate::button_group::member_radii` documents for its own `count == 1`
277/// case.
278fn segment_radii(index: usize, count: usize, outer: f64) -> CornerRadii {
279    let is_first = index == 0;
280    let is_last = index + 1 == count;
281    let left = if is_first { outer } else { 0.0 };
282    let right = if is_last { outer } else { 0.0 };
283    CornerRadii::new(left, right, right, left)
284}
285
286/// The group's outer corner radius, resolved against its own `(width,
287/// height)` — see the [module docs](self)' Connected geometry section for
288/// why [`ShapeScale::full`] (rather than a themed shape-scale rung) is the
289/// right token here, and how its `resolve` contract coincides with the
290/// reference's literal `height / 2`.
291fn outer_radius(theme: Option<&Theme>, width: f64, height: f64) -> f64 {
292    match theme {
293        Some(theme) => ShapeScale::resolve(theme.shape.full, width, height),
294        None => width.min(height) / 2.0,
295    }
296}
297
298/// `color` with its alpha channel replaced by `alpha`.
299fn with_alpha(color: Color, alpha: f32) -> Color {
300    let c = color.components;
301    Color::new([c[0], c[1], c[2], alpha])
302}
303
304/// Whether two optional icon sources name the same geometry (`d`/`design`
305/// equality) — lets `rebuild` skip re-resolving an unchanged icon, mirroring
306/// [`mod@crate::switch`]'s own `same_icon_source`.
307fn same_icon_source(a: Option<IconSource>, b: Option<IconSource>) -> bool {
308    match (a, b) {
309        (Some(x), Some(y)) => x.d == y.d && x.design == y.design,
310        (None, None) => true,
311        _ => false,
312    }
313}
314
315/// The unthemed/themed `(outline, divider, selected_bg, selected_fg,
316/// unselected_fg)` color table — `M3ESegmentedButtonTheme.outline`/
317/// `.divider`/`.backgroundColor`/`.foregroundColor`
318/// (`styles/m3e_segmented_button_theme.dart:83`-`:101`). `divider` defaults
319/// to `outline` (never overridden by any constructor in this crate — see the
320/// [module docs](self)' Dividers section).
321struct Colors {
322    outline: Color,
323    divider: Color,
324    selected_bg: Color,
325    selected_fg: Color,
326    unselected_fg: Color,
327}
328
329fn resolve_colors(theme: Option<&Theme>) -> Colors {
330    match theme {
331        Some(theme) => {
332            let s = theme.scheme();
333            Colors {
334                outline: s.outline,
335                divider: s.outline,
336                selected_bg: s.secondary_container,
337                selected_fg: s.on_secondary_container,
338                unselected_fg: s.on_surface,
339            }
340        }
341        None => Colors {
342            outline: OUTLINE,
343            divider: OUTLINE,
344            selected_bg: SECONDARY_CONTAINER,
345            selected_fg: ON_SECONDARY_CONTAINER,
346            unselected_fg: ON_SURFACE,
347        },
348    }
349}
350
351/// The segment label's type role (`labelLarge`, themed) or its unthemed
352/// [`LABEL_LARGE`] fallback. The returned style always carries
353/// [`SHAPING_INK`] (see that constant's doc).
354fn label_style(theme: Option<&Theme>) -> TextStyle {
355    let mut style = match theme {
356        Some(theme) => theme.type_scale.label_large.clone(),
357        None => {
358            let (size, line_height, letter_spacing, weight) = LABEL_LARGE;
359            let mut style = TextStyle::new(size, SHAPING_INK);
360            style.line_height = LineHeight::Absolute(line_height);
361            style.letter_spacing = letter_spacing;
362            style.weight = weight;
363            style
364        }
365    };
366    style.color = SHAPING_INK;
367    style
368}
369
370// ---- The label run (sibling of toggle_button::LabelRun) -------------------
371
372/// A lazily-shaped, paint-time-rebrushed label run — a sibling copy of
373/// [`mod@crate::toggle_button`]'s own `LabelRun` (`pub(self)` to that module and
374/// out of scope to import), scoped down to what this module needs: no
375/// max-width squeeze (this module does not implement one — see the [module
376/// docs](self)' Connected geometry section's sibling scope note).
377struct LabelRun {
378    content: String,
379    layout: Option<TextLayout>,
380    shaped_for: Option<TextStyle>,
381}
382
383impl LabelRun {
384    fn new(content: String) -> Self {
385        Self {
386            content,
387            layout: None,
388            shaped_for: None,
389        }
390    }
391
392    fn set_content(&mut self, content: &str) {
393        if self.content != content {
394            self.content = content.to_string();
395            self.layout = None;
396            self.shaped_for = None;
397        }
398    }
399
400    fn content(&self) -> &str {
401        &self.content
402    }
403
404    fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
405        if let Some(cached) = &self.layout
406            && self.shaped_for.as_ref() == Some(style)
407        {
408            return cached.size();
409        }
410        let text_ctx = ctx.text_context::<TextContext>();
411        let laid = text_ctx.layout(&self.content, style, None);
412        let size = laid.size();
413        self.layout = Some(laid);
414        self.shaped_for = Some(style.clone());
415        size
416    }
417
418    fn size(&self) -> Size {
419        self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
420    }
421
422    fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
423        let Some(layout) = &self.layout else {
424            return;
425        };
426        for mut run in layout.to_scene_runs(origin) {
427            run.brush = Brush::Solid(color);
428            scene.draw_glyph_run(run);
429        }
430    }
431}
432
433// ---- View -------------------------------------------------------------------
434
435/// One option within a [`SegmentedButtonView`] — the reference's
436/// `M3ESegment<T>` (`models/m3e_segment.dart`).
437pub struct Segment<T> {
438    value: T,
439    label: Option<String>,
440    icon: Option<IconSource>,
441}
442
443/// Create a segment reporting `value` when tapped/selected. Attach
444/// [`Segment::label`] and/or [`Segment::icon`] — a segment needs at least
445/// one (`debug_assert!`-checked by [`segmented_button`]; see the [module
446/// docs](self)' Bounds section).
447pub fn segment<T>(value: T) -> Segment<T> {
448    Segment {
449        value,
450        label: None,
451        icon: None,
452    }
453}
454
455impl<T> Segment<T> {
456    /// Attach a text label, shaped in the group's `labelLarge` role.
457    pub fn label(mut self, label: impl Into<String>) -> Self {
458        self.label = Some(label.into());
459        self
460    }
461
462    /// Attach a leading icon — replaced by [`crate::icons::CHECK`] while
463    /// this segment is selected and [`SegmentedButtonView::show_selected_icon`]
464    /// is set (the default). See the [module docs](self)' Checkmark section.
465    pub fn icon(mut self, icon: IconSource) -> Self {
466        self.icon = Some(icon);
467        self
468    }
469}
470
471/// A view-held, typed selection callback (erased per-segment on build/rebuild
472/// — see the [module docs](self)' Generic selection model section).
473type OnSelectionChanged<State, T> = TypedArgCallback<State, Vec<T>>;
474
475/// A declarative M3 Expressive segmented button. See the [module docs](self).
476pub struct SegmentedButtonView<State: 'static, T: PartialEq + Clone + 'static> {
477    segments: Vec<Segment<T>>,
478    selected: Vec<T>,
479    multi_select: bool,
480    show_selected_icon: bool,
481    on_selection_changed: OnSelectionChanged<State, T>,
482}
483
484/// Create a segmented button from `segments` (2-5, `debug_assert!`-checked —
485/// see [`MIN_SEGMENTS`]/[`MAX_SEGMENTS`]), reflecting `selected` (the
486/// currently-confirmed selection). Single-select by default; call
487/// [`SegmentedButtonView::multi_select`]`(true)` to allow more than one
488/// member selected at once. `on_selection_changed` fires with the
489/// *requested* next selection on release inside a member — see the [module
490/// docs](self)' Selection semantics section.
491pub fn segmented_button<
492    State: 'static,
493    T: PartialEq + Clone + 'static,
494    F: Fn(&mut State, Vec<T>) + 'static,
495>(
496    segments: impl IntoIterator<Item = Segment<T>>,
497    selected: impl IntoIterator<Item = T>,
498    on_selection_changed: F,
499) -> SegmentedButtonView<State, T> {
500    let segments: Vec<Segment<T>> = segments.into_iter().collect();
501    debug_assert!(
502        (MIN_SEGMENTS..=MAX_SEGMENTS).contains(&segments.len()),
503        "a segmented button needs {MIN_SEGMENTS}-{MAX_SEGMENTS} segments \
504         (m3e_segmented_buttons.dart:26 asserts >= 2 in code; its class doc \
505         pins the upper bound at 5), got {}",
506        segments.len()
507    );
508    for s in &segments {
509        debug_assert!(
510            s.label.is_some() || s.icon.is_some(),
511            "a segment needs at least a label or an icon (m3e_segment.dart:8-11)"
512        );
513    }
514    SegmentedButtonView {
515        segments,
516        selected: selected.into_iter().collect(),
517        multi_select: false,
518        show_selected_icon: true,
519        on_selection_changed: Rc::new(on_selection_changed),
520    }
521}
522
523/// PascalCase alias for [`segmented_button`], matching this catalog's
524/// view-fn vocabulary.
525#[allow(non_snake_case)]
526pub fn SegmentedButton<
527    State: 'static,
528    T: PartialEq + Clone + 'static,
529    F: Fn(&mut State, Vec<T>) + 'static,
530>(
531    segments: impl IntoIterator<Item = Segment<T>>,
532    selected: impl IntoIterator<Item = T>,
533    on_selection_changed: F,
534) -> SegmentedButtonView<State, T> {
535    segmented_button(segments, selected, on_selection_changed)
536}
537
538impl<State: 'static, T: PartialEq + Clone + 'static> SegmentedButtonView<State, T> {
539    /// Allow more than one segment selected at once, toggling membership on
540    /// tap instead of replacing the whole selection. See the [module
541    /// docs](self)' Selection semantics section.
542    pub fn multi_select(mut self, multi_select: bool) -> Self {
543        self.multi_select = multi_select;
544        self
545    }
546
547    /// Whether a selected segment swaps its leading icon for
548    /// [`crate::icons::CHECK`] (default `true`). See the [module
549    /// docs](self)' Checkmark section.
550    pub fn show_selected_icon(mut self, show: bool) -> Self {
551        self.show_selected_icon = show;
552        self
553    }
554}
555
556// ---- Widget -------------------------------------------------------------------
557
558/// The retained widget for a [`SegmentedButtonView`] — carries no generic
559/// parameter; see the [module docs](self)' Generic selection model section.
560pub struct SegmentedButtonWidget {
561    /// One label run per segment (`None` for an icon-only segment).
562    labels: Vec<Option<LabelRun>>,
563    /// Each segment's own resolved icon geometry (`None` for a label-only
564    /// segment) — unaffected by selection; the checkmark swap happens in
565    /// [`Self::icon_geom`].
566    icon_geoms: Vec<Option<(BezPath, f64)>>,
567    /// [`crate::icons::CHECK`]'s geometry, resolved once (never changes).
568    check_icon_geom: (BezPath, f64),
569    /// Per-segment selection membership, recomputed from `selected` on
570    /// every build/rebuild.
571    selected_flags: Vec<bool>,
572    show_selected_icon: bool,
573    multi_select: bool,
574    /// Per-segment background rects in the widget's local space, computed in
575    /// [`Widget::layout`] and used for paint + hit-testing.
576    segment_rects: Vec<Rect>,
577    /// Per-segment hover/focus/pressed/dragged tracking; only `pressed` is
578    /// ever set — see the [module docs](self)' State layer section.
579    interactions: Vec<InteractionState>,
580    /// Whether a press is currently armed (a `Down` landed on a segment).
581    armed: bool,
582    /// The segment a press is currently tracking.
583    active_segment: Option<usize>,
584    /// Whether the armed pointer is currently inside [`Self::active_segment`].
585    pressed_inside: bool,
586    /// One bound tap handler per segment — see [`bind_on_tap`].
587    on_taps: Vec<ErasedCallback>,
588}
589
590impl SegmentedButtonWidget {
591    /// The geometry to paint in segment `i`'s leading-icon slot: the shared
592    /// checkmark while selected and [`SegmentedButtonWidget::show_selected_icon`],
593    /// else the segment's own icon (if any). See the [module docs](self)'
594    /// Checkmark section.
595    fn icon_geom(&self, i: usize) -> Option<&(BezPath, f64)> {
596        if self.selected_flags[i] && self.show_selected_icon {
597            Some(&self.check_icon_geom)
598        } else {
599            self.icon_geoms[i].as_ref()
600        }
601    }
602
603    /// Whether segment `i` currently has an effective leading icon.
604    fn has_icon(&self, i: usize) -> bool {
605        self.icon_geom(i).is_some()
606    }
607
608    /// Which segment (if any) contains local point `pos`.
609    fn segment_at(&self, pos: Point) -> Option<usize> {
610        self.segment_rects.iter().position(|r| r.contains(pos))
611    }
612}
613
614/// The per-segment label-run/icon-geometry vectors [`build_segment_runtime`]
615/// returns.
616type SegmentRuntime = (Vec<Option<LabelRun>>, Vec<Option<(BezPath, f64)>>);
617
618/// Build the per-segment label-run/icon-geometry vectors from `segments` —
619/// shared by `build` and a structural (segment-count-changed) `rebuild`.
620fn build_segment_runtime<T>(segments: &[Segment<T>]) -> SegmentRuntime {
621    let labels = segments
622        .iter()
623        .map(|s| s.label.as_ref().map(|l| LabelRun::new(l.clone())))
624        .collect();
625    let icon_geoms = segments
626        .iter()
627        .map(|s| s.icon.map(|src| IconData::from(src).resolve()))
628        .collect();
629    (labels, icon_geoms)
630}
631
632fn compute_selected_flags<T: PartialEq>(segments: &[Segment<T>], selected: &[T]) -> Vec<bool> {
633    segments
634        .iter()
635        .map(|s| selected.iter().any(|v| v == &s.value))
636        .collect()
637}
638
639fn bind_on_taps<State: 'static, T: PartialEq + Clone + 'static>(
640    segments: &[Segment<T>],
641    on_selection_changed: &OnSelectionChanged<State, T>,
642    selected: &[T],
643    multi_select: bool,
644) -> Vec<ErasedCallback> {
645    segments
646        .iter()
647        .map(|s| {
648            bind_on_tap::<State, T>(
649                on_selection_changed,
650                selected,
651                multi_select,
652                s.value.clone(),
653            )
654        })
655        .collect()
656}
657
658impl<State: 'static, T: PartialEq + Clone + 'static> View<State> for SegmentedButtonView<State, T> {
659    type Element = SegmentedButtonWidget;
660
661    fn build(&self, _ctx: &mut BuildCtx<'_>) -> SegmentedButtonWidget {
662        let (labels, icon_geoms) = build_segment_runtime(&self.segments);
663        let selected_flags = compute_selected_flags(&self.segments, &self.selected);
664        let on_taps = bind_on_taps::<State, T>(
665            &self.segments,
666            &self.on_selection_changed,
667            &self.selected,
668            self.multi_select,
669        );
670        let count = self.segments.len();
671        SegmentedButtonWidget {
672            labels,
673            icon_geoms,
674            check_icon_geom: IconData::from(crate::icons::CHECK).resolve(),
675            selected_flags,
676            show_selected_icon: self.show_selected_icon,
677            multi_select: self.multi_select,
678            segment_rects: vec![Rect::ZERO; count],
679            interactions: vec![InteractionState::new(); count],
680            armed: false,
681            active_segment: None,
682            pressed_inside: false,
683            on_taps,
684        }
685    }
686
687    fn rebuild(
688        &self,
689        prev: &Self,
690        element: &mut SegmentedButtonWidget,
691        _ctx: &mut BuildCtx<'_>,
692    ) -> ChangeFlags {
693        let mut flags = ChangeFlags::NONE;
694
695        if prev.segments.len() != self.segments.len() {
696            // Structural: rebuild every per-segment vector fresh.
697            let (labels, icon_geoms) = build_segment_runtime(&self.segments);
698            element.labels = labels;
699            element.icon_geoms = icon_geoms;
700            element.segment_rects = vec![Rect::ZERO; self.segments.len()];
701            element.interactions = vec![InteractionState::new(); self.segments.len()];
702            element.armed = false;
703            element.active_segment = None;
704            element.pressed_inside = false;
705            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
706        } else {
707            for (i, (prev_s, next_s)) in prev.segments.iter().zip(self.segments.iter()).enumerate()
708            {
709                match (&prev_s.label, &next_s.label) {
710                    (None, None) => {}
711                    (Some(p), Some(n)) if p == n => {}
712                    (_, Some(n)) => {
713                        let run =
714                            element.labels[i].get_or_insert_with(|| LabelRun::new(String::new()));
715                        run.set_content(n);
716                        flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
717                    }
718                    (Some(_), None) => {
719                        element.labels[i] = None;
720                        flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
721                    }
722                }
723                if !same_icon_source(prev_s.icon, next_s.icon) {
724                    element.icon_geoms[i] = next_s.icon.map(|src| IconData::from(src).resolve());
725                    flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
726                }
727            }
728        }
729
730        // Selection membership can affect both which icon shows and the
731        // measured content width — see the [module docs](self)' Checkmark
732        // section.
733        let selected_flags = compute_selected_flags(&self.segments, &self.selected);
734        if selected_flags != element.selected_flags {
735            element.selected_flags = selected_flags;
736            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
737        }
738
739        if element.show_selected_icon != self.show_selected_icon {
740            element.show_selected_icon = self.show_selected_icon;
741            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
742        }
743        if element.multi_select != self.multi_select {
744            element.multi_select = self.multi_select;
745            flags |= ChangeFlags::PAINT;
746        }
747
748        // Closures aren't comparable — always reinstall, mirroring
749        // `radio`/`switch`/`button_group`'s own callback rebinding.
750        element.on_taps = bind_on_taps::<State, T>(
751            &self.segments,
752            &self.on_selection_changed,
753            &self.selected,
754            self.multi_select,
755        );
756
757        flags
758    }
759}
760
761impl Widget for SegmentedButtonWidget {
762    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
763        let count = self.labels.len();
764        let style = {
765            let theme = Theme::from_layout_ctx(ctx);
766            label_style(theme)
767        };
768
769        let mut content_widths = Vec::with_capacity(count);
770        for i in 0..count {
771            let has_icon = self.has_icon(i);
772            let has_label = self.labels[i].is_some();
773            let label_w = if let Some(run) = self.labels[i].as_mut() {
774                run.shape(ctx, &style).width
775            } else {
776                0.0
777            };
778            let icon_w = if has_icon { ICON_SIZE } else { 0.0 };
779            let gap = if has_icon && has_label {
780                ICON_LABEL_GAP
781            } else {
782                0.0
783            };
784            content_widths.push(icon_w + gap + label_w + SEGMENT_H_PADDING * 2.0);
785        }
786
787        let dividers = count.saturating_sub(1) as f64 * BORDER_WIDTH;
788        let total_w: f64 = content_widths.iter().sum::<f64>() + dividers;
789        let size = bc.constrain(Size::new(total_w, HEIGHT));
790
791        self.segment_rects.clear();
792        let mut x = 0.0_f64;
793        for w in &content_widths {
794            self.segment_rects.push(Rect::new(x, 0.0, x + w, HEIGHT));
795            x += w + BORDER_WIDTH;
796        }
797
798        size
799    }
800
801    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
802        let origin = ctx.origin();
803        let size = ctx.size();
804        let count = self.segment_rects.len();
805
806        let (colors, outer) = {
807            let theme = Theme::from_paint_ctx(ctx);
808            (
809                resolve_colors(theme),
810                outer_radius(theme, size.width, size.height),
811            )
812        };
813
814        // Per-segment backgrounds, state layer, and content.
815        for i in 0..count {
816            let rect = self.segment_rects[i];
817            let radii = segment_radii(i, count, outer);
818            let selected = self.selected_flags[i];
819            let fg = if selected {
820                colors.selected_fg
821            } else {
822                colors.unselected_fg
823            };
824            let seg_origin = Point::new(origin.x + rect.x0, origin.y + rect.y0);
825
826            if selected {
827                scene.fill_rounded_rect_radii(seg_origin, rect.size(), radii, colors.selected_bg);
828            }
829
830            let opacity = self.interactions[i].resolve_opacity();
831            if opacity > 0.0 {
832                scene.fill_rounded_rect_radii(
833                    seg_origin,
834                    rect.size(),
835                    radii,
836                    with_alpha(fg, opacity),
837                );
838            }
839
840            let has_icon = self.has_icon(i);
841            let has_label = self.labels[i].is_some();
842            let icon_w = if has_icon { ICON_SIZE } else { 0.0 };
843            let gap = if has_icon && has_label {
844                ICON_LABEL_GAP
845            } else {
846                0.0
847            };
848            let label_w = self.labels[i].as_ref().map_or(0.0, |run| run.size().width);
849            let content_w = icon_w + gap + label_w;
850            let content_x = rect.x0 + (rect.width() - content_w) / 2.0;
851
852            if let Some((path, design)) = self.icon_geom(i) {
853                let scale = if *design > 0.0 {
854                    ICON_SIZE / *design
855                } else {
856                    1.0
857                };
858                let icon_x = content_x;
859                let icon_y = rect.y0 + (rect.height() - ICON_SIZE) / 2.0;
860                let transform = Affine::translate(Vec2::new(icon_x, icon_y)) * Affine::scale(scale);
861                let scaled = transform * path.clone();
862                scene.fill_path(origin, &scaled, &Brush::Solid(fg));
863            }
864
865            if let Some(run) = &self.labels[i] {
866                let label_x = content_x + icon_w + gap;
867                let label_y = rect.y0 + (rect.height() - run.size().height) / 2.0;
868                run.paint(
869                    Point::new(origin.x + label_x, origin.y + label_y),
870                    fg,
871                    scene,
872                );
873            }
874        }
875
876        // Group outline ring.
877        let ring_rect = Rect::from_origin_size(Point::ORIGIN, size);
878        let ring_path = RoundedRect::from_rect(ring_rect, outer).to_path(PATH_TOLERANCE);
879        scene.stroke_path(
880            origin,
881            &ring_path,
882            BORDER_WIDTH,
883            &Brush::Solid(colors.outline),
884        );
885
886        // Segment dividers.
887        for i in 1..count {
888            let x = self.segment_rects[i - 1].x1 + BORDER_WIDTH / 2.0;
889            scene.stroke_line(
890                Point::new(origin.x + x, origin.y),
891                Point::new(origin.x + x, origin.y + size.height),
892                BORDER_WIDTH,
893                colors.divider,
894            );
895        }
896    }
897
898    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
899        let InputEvent::Pointer(p) = event else {
900            return EventResult::Ignored;
901        };
902        match p.phase {
903            PointerPhase::Down => {
904                if !presses(p) {
905                    return EventResult::Ignored;
906                }
907                let Some(i) = self.segment_at(p.position) else {
908                    return EventResult::Ignored;
909                };
910                self.armed = true;
911                self.active_segment = Some(i);
912                self.pressed_inside = true;
913                self.interactions[i].set_pressed(true);
914                ctx.capture_pointer();
915                ctx.request_redraw();
916                EventResult::Handled
917            }
918            PointerPhase::Move => {
919                if !self.armed {
920                    return EventResult::Ignored;
921                }
922                let Some(i) = self.active_segment else {
923                    return EventResult::Ignored;
924                };
925                let inside = self
926                    .segment_rects
927                    .get(i)
928                    .is_some_and(|r| r.contains(p.position));
929                if inside != self.pressed_inside {
930                    self.pressed_inside = inside;
931                    self.interactions[i].set_pressed(inside);
932                    ctx.request_redraw();
933                }
934                EventResult::Handled
935            }
936            PointerPhase::Up => {
937                if !self.armed {
938                    return EventResult::Ignored;
939                }
940                if let Some(i) = self.active_segment {
941                    self.interactions[i].set_pressed(false);
942                    if self.pressed_inside {
943                        (self.on_taps[i])(ctx);
944                    }
945                }
946                self.armed = false;
947                self.pressed_inside = false;
948                self.active_segment = None;
949                ctx.request_redraw();
950                EventResult::Handled
951            }
952            PointerPhase::Cancel => {
953                if !self.armed {
954                    return EventResult::Ignored;
955                }
956                if let Some(i) = self.active_segment {
957                    self.interactions[i].set_pressed(false);
958                }
959                self.armed = false;
960                self.pressed_inside = false;
961                self.active_segment = None;
962                ctx.request_redraw();
963                EventResult::Handled
964            }
965        }
966    }
967
968    fn semantics(&self, ctx: &mut SemanticsCtx) {
969        // Single-select reads as a RadioGroup of RadioButtons (mirroring
970        // `crate::button_group`); multi-select reads as a plain Group of
971        // CheckBoxes, since more than one member can be toggled true.
972        let group_role = if self.multi_select {
973            Role::Group
974        } else {
975            Role::RadioGroup
976        };
977        let multi_select = self.multi_select;
978        let labels = &self.labels;
979        let selected_flags = &self.selected_flags;
980        ctx.push_container(
981            group_role,
982            |_| {},
983            |ctx| {
984                for (i, selected) in selected_flags.iter().enumerate() {
985                    let child_role = if multi_select {
986                        Role::CheckBox
987                    } else {
988                        Role::RadioButton
989                    };
990                    let label = labels[i].as_ref().map(LabelRun::content);
991                    ctx.push_node(child_role, |node| {
992                        if let Some(l) = label {
993                            node.set_label(l);
994                        }
995                        if multi_select {
996                            node.set_toggled(if *selected {
997                                Toggled::True
998                            } else {
999                                Toggled::False
1000                            });
1001                        } else {
1002                            node.set_selected(*selected);
1003                        }
1004                        node.add_action(Action::Click);
1005                    });
1006                }
1007            },
1008        );
1009    }
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015    use frust::authoring::{PointerButton, PointerEvent};
1016    use std::any::Any;
1017
1018    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1019    enum Density {
1020        Compact,
1021        Cozy,
1022        Comfortable,
1023    }
1024
1025    fn build_view(selected: Vec<Density>) -> SegmentedButtonView<Vec<Density>, Density> {
1026        segmented_button(
1027            [
1028                segment(Density::Compact).label("Compact"),
1029                segment(Density::Cozy).label("Cozy"),
1030                segment(Density::Comfortable).label("Comfortable"),
1031            ],
1032            selected,
1033            |s: &mut Vec<Density>, next| *s = next,
1034        )
1035    }
1036
1037    fn build_widget(selected: Vec<Density>) -> SegmentedButtonWidget {
1038        let view = build_view(selected);
1039        let mut counter = 0u64;
1040        View::<Vec<Density>>::build(&view, &mut BuildCtx::new(&mut counter))
1041    }
1042
1043    fn with_rects(w: &mut SegmentedButtonWidget, widths: &[f64]) {
1044        w.segment_rects.clear();
1045        let mut x = 0.0;
1046        for width in widths {
1047            w.segment_rects.push(Rect::new(x, 0.0, x + width, HEIGHT));
1048            x += width + BORDER_WIDTH;
1049        }
1050    }
1051
1052    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1053        InputEvent::Pointer(PointerEvent {
1054            phase,
1055            position: Point::new(x, y),
1056            button: PointerButton::Primary,
1057        })
1058    }
1059
1060    // `state` must stay `&mut Vec<Density>`, not `&mut [Density]`: it is
1061    // coerced to `&mut dyn Any` below, and `EventCtx::state_mut::<Vec<Density>>`
1062    // downcasts against that concrete type — a slice would erase the wrong
1063    // type and break every closure's downcast.
1064    #[allow(clippy::ptr_arg)]
1065    fn dispatch(w: &mut SegmentedButtonWidget, state: &mut Vec<Density>, event: &InputEvent) {
1066        let state_any: &mut dyn Any = state;
1067        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, HEIGHT));
1068        w.event(&mut ctx, event);
1069    }
1070
1071    // ---- next_selection (the `_handleTap` rule) ---------------------------
1072
1073    #[test]
1074    fn single_select_replaces_the_whole_selection() {
1075        let next = next_selection(&[Density::Compact], false, &Density::Cozy);
1076        assert_eq!(next, vec![Density::Cozy]);
1077    }
1078
1079    #[test]
1080    fn single_select_tapping_the_already_selected_member_cannot_deselect() {
1081        // Upstream's `_handleTap` runs `clear()` then `add(value)`
1082        // unconditionally for single-select — there is no toggle-off branch.
1083        let next = next_selection(&[Density::Cozy], false, &Density::Cozy);
1084        assert_eq!(
1085            next,
1086            vec![Density::Cozy],
1087            "a single-select tap on the already-selected member must not clear it"
1088        );
1089    }
1090
1091    #[test]
1092    fn multi_select_toggles_an_absent_value_in() {
1093        let next = next_selection(&[Density::Compact], true, &Density::Cozy);
1094        assert_eq!(next, vec![Density::Compact, Density::Cozy]);
1095    }
1096
1097    #[test]
1098    fn multi_select_toggles_a_present_value_out() {
1099        let next = next_selection(&[Density::Compact, Density::Cozy], true, &Density::Cozy);
1100        assert_eq!(
1101            next,
1102            vec![Density::Compact],
1103            "multi-select can reach a reduced (even empty) selection"
1104        );
1105    }
1106
1107    #[test]
1108    fn multi_select_can_reach_zero_selected_members() {
1109        let next = next_selection(&[Density::Compact], true, &Density::Compact);
1110        assert!(next.is_empty());
1111    }
1112
1113    // ---- segment_radii ------------------------------------------------------
1114
1115    #[test]
1116    fn single_segment_group_is_all_outer_corners() {
1117        let r = segment_radii(0, 1, 20.0);
1118        assert_eq!(r.top_left, 20.0);
1119        assert_eq!(r.top_right, 20.0);
1120        assert_eq!(r.bottom_right, 20.0);
1121        assert_eq!(r.bottom_left, 20.0);
1122    }
1123
1124    #[test]
1125    fn end_segments_get_outer_on_their_outer_side_and_square_elsewhere() {
1126        let first = segment_radii(0, 3, 20.0);
1127        assert_eq!((first.top_left, first.bottom_left), (20.0, 20.0));
1128        assert_eq!((first.top_right, first.bottom_right), (0.0, 0.0));
1129        let last = segment_radii(2, 3, 20.0);
1130        assert_eq!((last.top_left, last.bottom_left), (0.0, 0.0));
1131        assert_eq!((last.top_right, last.bottom_right), (20.0, 20.0));
1132    }
1133
1134    #[test]
1135    fn middle_segments_are_entirely_square() {
1136        let mid = segment_radii(1, 3, 20.0);
1137        assert_eq!(mid.top_left, 0.0);
1138        assert_eq!(mid.top_right, 0.0);
1139        assert_eq!(mid.bottom_right, 0.0);
1140        assert_eq!(mid.bottom_left, 0.0);
1141    }
1142
1143    // ---- Bounds (2-5 segments, label-or-icon) --------------------------------
1144
1145    #[test]
1146    #[should_panic(expected = "needs 2-5 segments")]
1147    fn fewer_than_two_segments_panics() {
1148        let _ = segmented_button::<(), u32, _>(
1149            [Segment {
1150                value: 0,
1151                label: Some("Only".into()),
1152                icon: None,
1153            }],
1154            [],
1155            |_s: &mut (), _v| {},
1156        );
1157    }
1158
1159    #[test]
1160    #[should_panic(expected = "needs 2-5 segments")]
1161    fn more_than_five_segments_panics() {
1162        let segs: Vec<Segment<u32>> = (0..6).map(|i| segment(i).label(i.to_string())).collect();
1163        let _ = segmented_button::<(), u32, _>(segs, [], |_s: &mut (), _v| {});
1164    }
1165
1166    #[test]
1167    fn exactly_two_and_exactly_five_segments_are_accepted() {
1168        let two: Vec<Segment<u32>> = (0..2).map(|i| segment(i).label(i.to_string())).collect();
1169        let _ = segmented_button::<(), u32, _>(two, [], |_s: &mut (), _v| {});
1170        let five: Vec<Segment<u32>> = (0..5).map(|i| segment(i).label(i.to_string())).collect();
1171        let _ = segmented_button::<(), u32, _>(five, [], |_s: &mut (), _v| {});
1172    }
1173
1174    #[test]
1175    #[should_panic(expected = "label or an icon")]
1176    fn a_segment_with_neither_label_nor_icon_panics() {
1177        let _ = segmented_button::<(), u32, _>(
1178            [
1179                Segment {
1180                    value: 0,
1181                    label: None,
1182                    icon: None,
1183                },
1184                segment(1u32).label("B"),
1185            ],
1186            [],
1187            |_s: &mut (), _v| {},
1188        );
1189    }
1190
1191    // ---- Generic T (enum), build/rebuild -------------------------------------
1192
1193    #[test]
1194    fn build_creates_one_run_per_segment_for_a_generic_enum_value() {
1195        let w = build_widget(vec![Density::Cozy]);
1196        assert_eq!(w.labels.len(), 3);
1197        assert_eq!(w.selected_flags, vec![false, true, false]);
1198    }
1199
1200    #[test]
1201    fn is_controlled_selection_only_moves_via_rebuild() {
1202        let mut w = build_widget(vec![Density::Compact]);
1203        with_rects(&mut w, &[80.0, 80.0, 80.0]);
1204        let mut state = vec![Density::Compact];
1205        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0, 20.0));
1206        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 100.0, 20.0));
1207        assert_eq!(
1208            w.selected_flags,
1209            vec![true, false, false],
1210            "widget did not self-mutate — its own flags still reflect the original selection"
1211        );
1212
1213        let next = build_view(vec![Density::Cozy]);
1214        let prev = build_view(vec![Density::Compact]);
1215        let mut counter = 0u64;
1216        View::<Vec<Density>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1217        assert_eq!(w.selected_flags, vec![false, true, false]);
1218    }
1219
1220    #[test]
1221    fn tap_reports_the_tapped_segments_value_single_select() {
1222        let mut w = build_widget(vec![Density::Compact]);
1223        with_rects(&mut w, &[80.0, 80.0, 80.0]);
1224        let mut state = vec![Density::Compact];
1225        // Middle segment starts at x = 81 (80 + BORDER_WIDTH).
1226        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0, 20.0));
1227        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 100.0, 20.0));
1228        assert_eq!(state, vec![Density::Cozy]);
1229    }
1230
1231    #[test]
1232    fn tap_on_the_selected_single_select_member_does_not_clear_it() {
1233        let mut w = build_widget(vec![Density::Compact]);
1234        with_rects(&mut w, &[80.0, 80.0, 80.0]);
1235        let mut state = vec![Density::Compact];
1236        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 20.0));
1237        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 20.0));
1238        assert_eq!(state, vec![Density::Compact]);
1239    }
1240
1241    #[test]
1242    fn multi_select_tap_toggles_membership() {
1243        let view = segmented_button(
1244            [
1245                segment(Density::Compact).label("Compact"),
1246                segment(Density::Cozy).label("Cozy"),
1247                segment(Density::Comfortable).label("Comfortable"),
1248            ],
1249            vec![Density::Compact],
1250            |s: &mut Vec<Density>, next| *s = next,
1251        )
1252        .multi_select(true);
1253        let mut counter = 0u64;
1254        let mut w = View::<Vec<Density>>::build(&view, &mut BuildCtx::new(&mut counter));
1255        with_rects(&mut w, &[80.0, 80.0, 80.0]);
1256        let mut state = vec![Density::Compact];
1257
1258        // Tap Cozy: should add, not replace.
1259        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0, 20.0));
1260        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 100.0, 20.0));
1261        assert_eq!(state, vec![Density::Compact, Density::Cozy]);
1262    }
1263
1264    #[test]
1265    fn up_outside_the_pressed_segment_does_not_fire() {
1266        let mut w = build_widget(vec![Density::Compact]);
1267        with_rects(&mut w, &[80.0, 80.0, 80.0]);
1268        let mut state = vec![Density::Compact];
1269        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 20.0));
1270        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 100.0, 20.0));
1271        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 100.0, 20.0));
1272        assert_eq!(state, vec![Density::Compact]);
1273    }
1274
1275    #[test]
1276    fn cancel_clears_the_press_without_firing() {
1277        let mut w = build_widget(vec![Density::Compact]);
1278        with_rects(&mut w, &[80.0, 80.0, 80.0]);
1279        let mut state = vec![Density::Compact];
1280        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 20.0));
1281        assert!(w.armed);
1282        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 20.0));
1283        assert!(!w.armed);
1284        assert_eq!(state, vec![Density::Compact]);
1285    }
1286
1287    #[test]
1288    fn down_outside_all_segments_is_ignored() {
1289        let mut w = build_widget(vec![Density::Compact]);
1290        with_rects(&mut w, &[80.0, 80.0, 80.0]);
1291        let mut state = vec![Density::Compact];
1292        let state_any: &mut dyn Any = &mut state;
1293        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, HEIGHT));
1294        let result = w.event(&mut ctx, &ev(PointerPhase::Down, 1000.0, 20.0));
1295        assert!(matches!(result, EventResult::Ignored));
1296        assert!(w.active_segment.is_none());
1297    }
1298
1299    // ---- Checkmark swap -------------------------------------------------------
1300
1301    #[test]
1302    fn checkmark_swaps_in_only_for_the_selected_segment() {
1303        let w = build_widget(vec![Density::Cozy]);
1304        // Cozy (index 1) is selected: its slot resolves to the shared CHECK
1305        // geometry, not its own (absent) icon.
1306        assert!(w.has_icon(1));
1307        let (check_path, check_design) = &w.check_icon_geom;
1308        let (resolved_path, resolved_design) = w.icon_geom(1).expect("selected slot has an icon");
1309        assert_eq!(resolved_design, check_design);
1310        assert_eq!(resolved_path.elements().len(), check_path.elements().len());
1311        // The unselected, label-only segments have no icon at all.
1312        assert!(!w.has_icon(0));
1313        assert!(!w.has_icon(2));
1314    }
1315
1316    #[test]
1317    fn show_selected_icon_false_suppresses_the_checkmark() {
1318        let view = segmented_button(
1319            [
1320                segment(Density::Compact).label("Compact"),
1321                segment(Density::Cozy).label("Cozy"),
1322            ],
1323            vec![Density::Cozy],
1324            |_s: &mut (), _v: Vec<Density>| {},
1325        )
1326        .show_selected_icon(false);
1327        let mut counter = 0u64;
1328        let w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
1329        assert!(
1330            !w.has_icon(1),
1331            "checkmark suppressed by show_selected_icon(false)"
1332        );
1333    }
1334
1335    #[test]
1336    fn a_segments_own_icon_survives_while_unselected() {
1337        let view = segmented_button::<(), u32, _>(
1338            [
1339                segment(0u32).icon(crate::icons::SEARCH),
1340                segment(1u32).label("Text only"),
1341            ],
1342            [],
1343            |_s: &mut (), _v| {},
1344        );
1345        let mut counter = 0u64;
1346        let w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
1347        assert!(
1348            w.has_icon(0),
1349            "unselected segment 0 still shows its own icon"
1350        );
1351        assert!(
1352            !w.has_icon(1),
1353            "label-only, unselected segment 1 has no icon"
1354        );
1355    }
1356
1357    // ---- Paint: dividers, outline, backgrounds ---------------------------------
1358
1359    /// A recording scene capturing filled rounded-rect (per-corner) calls,
1360    /// stroked lines (dividers), and stroked paths (the outline ring).
1361    #[derive(Default)]
1362    struct PaintRecorder {
1363        radii_fills: Vec<(Point, Size, CornerRadii, Color)>,
1364        lines: usize,
1365        strokes: usize,
1366    }
1367
1368    impl PaintScene for PaintRecorder {
1369        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1370        fn draw_text(&mut self, _o: Point, _t: &str) {}
1371        fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
1372            self.radii_fills.push((o, s, radii, color));
1373        }
1374        fn stroke_line(&mut self, _p0: Point, _p1: Point, _w: f64, _c: Color) {
1375            self.lines += 1;
1376        }
1377        fn stroke_path(&mut self, _o: Point, _p: &BezPath, _w: f64, _b: &Brush) {
1378            self.strokes += 1;
1379        }
1380        fn fill_path(&mut self, _o: Point, _p: &BezPath, _b: &Brush) {}
1381    }
1382
1383    fn layout_widget(w: &mut SegmentedButtonWidget, width: f64) -> Size {
1384        let bc = BoxConstraints::loose(Size::new(width, f64::INFINITY));
1385        let mut tcx = TextContext::new();
1386        let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1387        w.layout(&mut ctx, &bc)
1388    }
1389
1390    #[test]
1391    fn divider_count_is_segment_count_minus_one() {
1392        let mut w = build_widget(vec![Density::Compact]);
1393        layout_widget(&mut w, 400.0);
1394        let mut rec = PaintRecorder::default();
1395        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT));
1396        w.paint(&mut ctx, &mut rec);
1397        assert_eq!(rec.lines, 2, "3 segments have 2 internal dividers");
1398        assert_eq!(rec.strokes, 1, "exactly one outline ring stroke");
1399    }
1400
1401    #[test]
1402    fn only_the_selected_segment_paints_a_background_fill() {
1403        let mut w = build_widget(vec![Density::Cozy]);
1404        layout_widget(&mut w, 400.0);
1405        let mut rec = PaintRecorder::default();
1406        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT));
1407        w.paint(&mut ctx, &mut rec);
1408        assert_eq!(
1409            rec.radii_fills.len(),
1410            1,
1411            "only the selected member fills a background"
1412        );
1413    }
1414
1415    #[test]
1416    fn themed_paint_resolves_container_and_outline_tokens() {
1417        let theme = crate::baseline();
1418        let mut w = build_widget(vec![Density::Compact]);
1419        layout_widget(&mut w, 400.0);
1420
1421        #[derive(Default)]
1422        struct ColorRec {
1423            fills: Vec<Color>,
1424        }
1425        impl PaintScene for ColorRec {
1426            fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1427            fn draw_text(&mut self, _o: Point, _t: &str) {}
1428            fn fill_rounded_rect_radii(
1429                &mut self,
1430                _o: Point,
1431                _s: Size,
1432                _r: CornerRadii,
1433                color: Color,
1434            ) {
1435                self.fills.push(color);
1436            }
1437        }
1438        let mut rec = ColorRec::default();
1439        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT)).with_theme(&theme);
1440        w.paint(&mut ctx, &mut rec);
1441        let scheme = theme.scheme();
1442        assert_eq!(rec.fills[0], scheme.secondary_container);
1443    }
1444
1445    // ---- Semantics --------------------------------------------------------------
1446
1447    #[test]
1448    fn semantics_yields_a_radiogroup_for_single_select() {
1449        fn logic(_s: &mut ()) -> SegmentedButtonView<(), u32> {
1450            segmented_button::<(), u32, _>(
1451                [segment(0u32).label("A"), segment(1u32).label("B")],
1452                [1u32],
1453                |_s: &mut (), _v| {},
1454            )
1455        }
1456        let mut root: frust_core::RenderRoot<(), SegmentedButtonView<(), u32>> =
1457            frust_core::RenderRoot::new();
1458        let mut state = ();
1459        root.rebuild(&mut logic, &mut state);
1460        let mut tcx = TextContext::new();
1461        root.layout_with_text(Size::new(300.0, 60.0), &mut tcx as &mut dyn Any);
1462        let update = root.semantics();
1463
1464        let group = update
1465            .nodes
1466            .iter()
1467            .find(|(_, n)| n.role() == Role::RadioGroup)
1468            .expect("a RadioGroup container node is contributed");
1469        assert_eq!(group.1.children().len(), 2);
1470
1471        let selected = update
1472            .nodes
1473            .iter()
1474            .find(|(_, n)| n.role() == Role::RadioButton && n.label() == Some("B"))
1475            .expect("segment B is present");
1476        assert_eq!(selected.1.is_selected(), Some(true));
1477    }
1478
1479    #[test]
1480    fn semantics_yields_a_group_of_checkboxes_for_multi_select() {
1481        fn logic(_s: &mut ()) -> SegmentedButtonView<(), u32> {
1482            segmented_button::<(), u32, _>(
1483                [segment(0u32).label("A"), segment(1u32).label("B")],
1484                [0u32, 1u32],
1485                |_s: &mut (), _v| {},
1486            )
1487            .multi_select(true)
1488        }
1489        let mut root: frust_core::RenderRoot<(), SegmentedButtonView<(), u32>> =
1490            frust_core::RenderRoot::new();
1491        let mut state = ();
1492        root.rebuild(&mut logic, &mut state);
1493        let mut tcx = TextContext::new();
1494        root.layout_with_text(Size::new(300.0, 60.0), &mut tcx as &mut dyn Any);
1495        let update = root.semantics();
1496
1497        let group = update
1498            .nodes
1499            .iter()
1500            .find(|(_, n)| n.role() == Role::Group)
1501            .expect("a Group container node is contributed");
1502        assert_eq!(group.1.children().len(), 2);
1503
1504        let boxes: Vec<_> = update
1505            .nodes
1506            .iter()
1507            .filter(|(_, n)| n.role() == Role::CheckBox)
1508            .collect();
1509        assert_eq!(boxes.len(), 2);
1510        assert!(
1511            boxes
1512                .iter()
1513                .all(|(_, n)| n.toggled() == Some(Toggled::True))
1514        );
1515    }
1516}