Skip to main content

gpui_kit_material/components/
button_group.rs

1//! M3 connected button group with roving keyboard focus: a required single
2//! selection (radio or tab semantics), an optional single or multiple
3//! selection (toggle buttons), or plain actions.
4//!
5//! Each option is its own button 2px from its neighbours; a selected one
6//! morphs to a full pill on the accent while its inner corners open, on the
7//! fast spatial spring. Groups lay out horizontally or vertically, with
8//! density steps, equal widths, and labels shown always, only on selected
9//! options (revealed with the selection) or never (icon options).
10use super::{ButtonDensity, ButtonShape, ButtonSize, ButtonVariant, InteractionPreview};
11use crate::color::ColorRole;
12use crate::icons::Icon;
13use crate::theme::{blend, color};
14use crate::tokens::state;
15use crate::tokens::typography::{TypeRole, rem};
16use gpui::prelude::FluentBuilder as _;
17use gpui::*;
18use std::{collections::BTreeMap, rc::Rc};
19
20use super::roving::{Change, Orientation, Roving, select_value};
21
22/// How a connected group selects.
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
24pub enum GroupSelection {
25    /// Plain actions: no option stays selected.
26    None,
27    /// At most one option (exactly one with [`ConnectedButtonGroup::required`]).
28    #[default]
29    Single,
30    /// Any number of options.
31    Multi,
32}
33
34/// When an option with an icon shows its label.
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
36pub enum GroupLabels {
37    #[default]
38    Always,
39    /// Only while selected, revealed and hidden with the selection.
40    Selected,
41    /// Never: icon-only options (still labelled for assistive technology).
42    Never,
43}
44
45type ToggleHandler = Rc<dyn Fn(&SharedString, bool, &mut Window, &mut App)>;
46type ActivateHandler = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
47
48#[derive(Clone)]
49struct OptionItem {
50    value: SharedString,
51    label: SharedString,
52}
53
54/// `options` without repeated values, keeping each value's first occurrence,
55/// and whether there were none to drop.
56fn unique_options(options: impl IntoIterator<Item = OptionItem>) -> (Vec<OptionItem>, bool) {
57    let mut seen = std::collections::BTreeSet::new();
58    let mut unique = true;
59    let options = options
60        .into_iter()
61        .filter(|option| {
62            let first = seen.insert(option.value.clone());
63            unique &= first;
64            first
65        })
66        .collect();
67    (options, unique)
68}
69
70#[derive(gpui::IntoElement)]
71pub struct ConnectedButtonGroup {
72    id: ElementId,
73    label: SharedString,
74    value: Option<SharedString>,
75    options: Vec<OptionItem>,
76    icons: BTreeMap<SharedString, Icon>,
77    full_width: bool,
78    segmented: bool,
79    label_text_size: Option<Rems>,
80    height: Option<Rems>,
81    tabs: bool,
82    provided_focus: Vec<FocusHandle>,
83    disabled: bool,
84    on_change: Option<Change>,
85    reveal_in: Option<ScrollHandle>,
86    previews: BTreeMap<SharedString, InteractionPreview>,
87    variant: ButtonVariant,
88    size: Option<ButtonSize>,
89    shape: ButtonShape,
90    selection: GroupSelection,
91    required: bool,
92    values: Vec<SharedString>,
93    on_toggle: Option<ToggleHandler>,
94    on_activate: Option<ActivateHandler>,
95    vertical: bool,
96    density: ButtonDensity,
97    equal_widths: bool,
98    labels: GroupLabels,
99}
100impl ConnectedButtonGroup {
101    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
102        Self {
103            id: id.into(),
104            label: label.into(),
105            value: None,
106            options: Vec::new(),
107            icons: BTreeMap::new(),
108            full_width: false,
109            segmented: false,
110            label_text_size: None,
111            height: None,
112            tabs: false,
113            provided_focus: Vec::new(),
114            disabled: false,
115            on_change: None,
116            reveal_in: None,
117            previews: BTreeMap::new(),
118            variant: ButtonVariant::Tonal,
119            size: None,
120            shape: ButtonShape::Round,
121            selection: GroupSelection::Single,
122            required: true,
123            values: Vec::new(),
124            on_toggle: None,
125            on_activate: None,
126            vertical: false,
127            density: ButtonDensity::Default,
128            equal_widths: false,
129            labels: GroupLabels::Always,
130        }
131    }
132    /// Single (default), multiple or no selection.
133    pub fn selection(mut self, selection: GroupSelection) -> Self {
134        self.selection = selection;
135        self
136    }
137    /// Whether a single selection must keep one option selected (default,
138    /// radio semantics). Optional selections are toggle buttons whose
139    /// changes arrive through `on_toggle`.
140    pub fn required(mut self, required: bool) -> Self {
141        self.required = required;
142        self
143    }
144    /// The selected values of a multiple or optional selection.
145    pub fn selected_values<V: Into<SharedString>>(
146        mut self,
147        values: impl IntoIterator<Item = V>,
148    ) -> Self {
149        self.values = values.into_iter().map(Into::into).collect();
150        self
151    }
152    /// The requested selection of one option of a toggle group (multiple,
153    /// or an optional single selection).
154    pub fn on_toggle(
155        mut self,
156        callback: impl Fn(&SharedString, bool, &mut Window, &mut App) + 'static,
157    ) -> Self {
158        self.on_toggle = Some(Rc::new(callback));
159        self
160    }
161    /// An activated option of a group without selection.
162    pub fn on_activate(
163        mut self,
164        callback: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
165    ) -> Self {
166        self.on_activate = Some(Rc::new(callback));
167        self
168    }
169    /// Stack the options vertically (connected top to bottom).
170    pub fn vertical(mut self, vertical: bool) -> Self {
171        self.vertical = vertical;
172        self
173    }
174    /// Shorten the options by the density's step.
175    pub fn density(mut self, density: ButtonDensity) -> Self {
176        self.density = density;
177        self
178    }
179    /// Give every option the widest one's width (horizontal groups that
180    /// fit their content; vertical options always share the column).
181    pub fn equal_widths(mut self, equal: bool) -> Self {
182        self.equal_widths = equal;
183        self
184    }
185    /// When options with icons show their labels.
186    pub fn labels(mut self, labels: GroupLabels) -> Self {
187        self.labels = labels;
188        self
189    }
190    /// The toggle-button colors the options use: tonal by default (the
191    /// kit's connected group), or filled, elevated or outlined.
192    pub fn variant(mut self, variant: ButtonVariant) -> Self {
193        self.variant = variant;
194        self
195    }
196    /// An M3 Expressive button size (XS–XL): the options take that button's
197    /// height, padding, label type and icon, and the size's inner corners.
198    /// Without it the group keeps its compact 40px default.
199    pub fn button_size(mut self, size: ButtonSize) -> Self {
200        self.size = Some(size);
201        self
202    }
203    /// Round (default) or square ends: square ends of unselected options
204    /// take the inner corner radius. The selected option is a full pill.
205    pub fn shape(mut self, shape: ButtonShape) -> Self {
206        self.shape = shape;
207        self
208    }
209    /// The options as `(value, label)` pairs. Values identify an option's
210    /// focus and selection state, so they must be unique: a repeated value
211    /// is a caller bug (a debug assertion), and release builds keep only its
212    /// first occurrence.
213    pub fn options<K: Into<SharedString>, V: Into<SharedString>>(
214        mut self,
215        options: impl IntoIterator<Item = (K, V)>,
216    ) -> Self {
217        let (options, unique) =
218            unique_options(options.into_iter().map(|(value, label)| OptionItem {
219                value: value.into(),
220                label: label.into(),
221            }));
222        debug_assert!(unique, "ConnectedButtonGroup option values must be unique");
223        self.options = options;
224        self
225    }
226    pub fn selected(mut self, value: impl Into<SharedString>) -> Self {
227        self.value = Some(value.into());
228        self
229    }
230    /// Fill the parent's width in equal options, each at least as wide as its
231    /// label; options wrap to another row only when their labels cannot fit.
232    pub fn full_width(mut self) -> Self {
233        self.full_width = true;
234        self
235    }
236    /// Connected segments that fill the parent, with persistent option icons.
237    /// Radio/tab semantics, focus navigation and pointer feedback are unchanged.
238    pub fn segmented(mut self) -> Self {
239        self.segmented = true;
240        self.full_width = true;
241        self
242    }
243    /// Override option text for a page-specific typography role.
244    /// Override the option height (48px segmented, 40px otherwise), e.g. for
245    /// a page's view tabs.
246    pub fn height(mut self, height: Rems) -> Self {
247        self.height = Some(height);
248        self
249    }
250    pub fn label_text_size(mut self, size: Rems) -> Self {
251        self.label_text_size = Some(size);
252        self
253    }
254    /// Navigation uses the same surfaces, with tab semantics and optional leading layout.
255    pub fn tabs(mut self) -> Self {
256        self.tabs = true;
257        self
258    }
259    pub fn focus_handles(mut self, handles: impl IntoIterator<Item = FocusHandle>) -> Self {
260        self.provided_focus = handles.into_iter().collect();
261        self
262    }
263    /// Attach an icon by option value; this may be called before or after `options`.
264    pub fn icon(mut self, value: impl Into<SharedString>, icon: impl Into<Icon>) -> Self {
265        self.icons.insert(value.into(), icon.into());
266        self
267    }
268    pub fn reveal_in(mut self, scroll: ScrollHandle) -> Self {
269        self.reveal_in = Some(scroll);
270        self
271    }
272    pub fn disabled(mut self, disabled: bool) -> Self {
273        self.disabled = disabled;
274        self
275    }
276    /// Render the option with `value` as a static specimen in one forced
277    /// interaction state, painting what that live state paints (its hover or
278    /// press layer, focus ring or disabled colors). A group with any preview
279    /// is a specimen: it leaves the tab order, and its other options ignore
280    /// live hover and press. See [`InteractionPreview`].
281    pub fn preview_state(
282        mut self,
283        value: impl Into<SharedString>,
284        state: InteractionPreview,
285    ) -> Self {
286        self.previews.insert(value.into(), state);
287        self
288    }
289    pub fn on_change(
290        mut self,
291        callback: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
292    ) -> Self {
293        self.on_change = Some(Rc::new(callback));
294        self
295    }
296}
297
298/// The resolved paint of one option for the current motion frame.
299#[derive(Clone, Copy, Debug, PartialEq)]
300struct OptionPaint {
301    background: Rgba,
302    foreground: Rgba,
303    hover: Rgba,
304    press: Rgba,
305    left_radius: Pixels,
306    right_radius: Pixels,
307}
308
309/// Inner corners between neighbours of the compact default group.
310const INNER_RADIUS: f32 = 8.;
311const GAP: f32 = 2.;
312
313/// The size-dependent geometry of one group, in design px.
314#[derive(Clone, Copy, Debug, PartialEq)]
315struct Metrics {
316    height: f32,
317    /// Corners between neighbours.
318    inner: f32,
319    /// Unselected outer ends: `None` is a full half-height round.
320    outer: Option<f32>,
321    /// Horizontal padding of filling options, inside the 2px border;
322    /// content-sized options add the border back.
323    padding: f32,
324    icon: f32,
325    text: Option<TypeRole>,
326    /// Options stack top to bottom.
327    vertical: bool,
328}
329
330impl Metrics {
331    /// M3 Expressive connected group (Figma kit): each option is that
332    /// size's button; inner corners are XS 4, S 8, M 8, L 16, XL 20.
333    fn of(size: ButtonSize, shape: ButtonShape) -> Self {
334        let (height, inner, padding, icon, text) = match size {
335            ButtonSize::ExtraSmall => (32., 4., 12., 20., TypeRole::LabelLarge),
336            ButtonSize::Small => (40., 8., 16., 20., TypeRole::LabelLarge),
337            ButtonSize::Medium => (56., 8., 24., 24., TypeRole::TitleMedium),
338            ButtonSize::Large => (96., 16., 48., 32., TypeRole::HeadlineSmall),
339            ButtonSize::ExtraLarge => (136., 20., 64., 40., TypeRole::HeadlineLarge),
340        };
341        Self {
342            height,
343            inner,
344            // Square groups round their ends like their inner corners.
345            outer: (shape == ButtonShape::Square).then_some(inner),
346            padding: padding - 2.,
347            icon,
348            text: Some(text),
349            vertical: false,
350        }
351    }
352    fn compact(height: f32) -> Self {
353        Self {
354            height,
355            inner: INNER_RADIUS,
356            outer: None,
357            padding: 12.,
358            icon: 18.,
359            text: None,
360            vertical: false,
361        }
362    }
363}
364
365/// M3 connected button group: each option is its own button. `selection` is the
366/// option's animated selection amount (0 unselected, 1 selected), which morphs
367/// both colour and the inner corners toward a full pill.
368#[cfg(test)]
369fn option_paint(
370    selection: f32,
371    index: usize,
372    count: usize,
373    height: Pixels,
374    disabled: bool,
375) -> OptionPaint {
376    option_paint_with(
377        selection,
378        index,
379        count,
380        height,
381        disabled,
382        ButtonVariant::Tonal,
383        Metrics::compact(f32::from(height)),
384    )
385}
386
387fn option_paint_with(
388    selection: f32,
389    index: usize,
390    count: usize,
391    height: Pixels,
392    disabled: bool,
393    variant: ButtonVariant,
394    metrics: Metrics,
395) -> OptionPaint {
396    let t = selection.clamp(0., 1.);
397    let full = height / 2.;
398    let scale = height / px(metrics.height);
399    let morph = |rest: Pixels| rest + (full - rest) * t;
400    let inner = morph(px(metrics.inner) * scale);
401    let outer = morph(metrics.outer.map_or(full, |outer| px(outer) * scale));
402    let left_radius = if index == 0 { outer } else { inner };
403    let right_radius = if index + 1 == count { outer } else { inner };
404    if disabled {
405        // M3 disabled: on-surface at 12% container and 38% content. The pill
406        // shape still tells which option is current.
407        let ink = color(ColorRole::ON_SURFACE);
408        return OptionPaint {
409            background: ink.opacity(state::DISABLED_CONTAINER),
410            foreground: state::disabled(ink),
411            hover: ink.opacity(state::DISABLED_CONTAINER),
412            press: ink.opacity(state::DISABLED_CONTAINER),
413            left_radius,
414            right_radius,
415        };
416    }
417    // The toggle button's own unselected and selected colors.
418    let (rest_bg, rest_fg) = super::button::toggle_colors(variant, false);
419    let (on_bg, on_fg) = super::button::toggle_colors(variant, true);
420    let background = blend(rest_bg, on_bg, t);
421    let foreground = blend(rest_fg, on_fg, t);
422    OptionPaint {
423        background,
424        foreground,
425        // One state layer in the content colour, resolved over the current
426        // container instead of stacking translucent fills, so the selected
427        // accent stays under hover and press.
428        hover: state::hovered(background, foreground),
429        press: state::pressed(background, foreground),
430        left_radius,
431        right_radius,
432    }
433}
434
435#[cfg(test)]
436thread_local! {
437    static PAINTED: std::cell::RefCell<BTreeMap<String, OptionPaint>> =
438        const { std::cell::RefCell::new(BTreeMap::new()) };
439}
440
441/// Containers stay behind the interactive item. Hover/press cannot erase selection.
442fn item_style<T: Styled + StatefulInteractiveElement + gpui::prelude::FluentBuilder>(
443    item: T,
444    paint: OptionPaint,
445    height: Pixels,
446    fill: bool,
447    disabled: bool,
448    preview: Option<InteractionPreview>,
449    metrics: Metrics,
450) -> T {
451    item.relative()
452        .flex()
453        .items_center()
454        .justify_center()
455        // Content-width options may shrink and ellipsize; filling options keep
456        // their automatic minimum, the width of their whole label.
457        .when(!fill, |item| item.min_w_0())
458        .h(height)
459        .min_h(height)
460        // With the 2px focus border: 16px to the content of a content-sized
461        // option; 14px in equal shares. That is also the floor for a label
462        // wider than its share, so a long label at a large text scale never
463        // meets the pill's edge: the options wrap to another row first.
464        .px(rem(if fill {
465            metrics.padding
466        } else {
467            metrics.padding + 2.
468        }))
469        .py_0()
470        .map(|item| {
471            // The first option's outer corners lead the main axis.
472            if metrics.vertical {
473                item.rounded_t(paint.left_radius)
474                    .rounded_b(paint.right_radius)
475            } else {
476                item.rounded_l(paint.left_radius)
477                    .rounded_r(paint.right_radius)
478            }
479        })
480        // A transparent 2px border keeps the content inset; the focus ring is
481        // the shared one, drawn by `focus_ring`.
482        .border_2()
483        .border_color(gpui::transparent_black())
484        .bg(paint.background)
485        .text_color(paint.foreground)
486        .map(|item| match metrics.text {
487            Some(role) => {
488                let scale = role.scale();
489                item.font_family(scale.font_family)
490                    .text_size(scale.font_size)
491                    .line_height(scale.line_height)
492                    .font_weight(FontWeight(scale.font_weight as f32))
493            }
494            None => item
495                .text_size(crate::tokens::typography::TEXT_BODY)
496                .font_weight(FontWeight::MEDIUM),
497        })
498        // Content-width options keep the default shrink so a narrow parent
499        // ellipsizes labels instead of overflowing. Filling options are equal
500        // shares of the row (a zero basis), floored at their label's own
501        // width: a long label ("Speech recognition") takes what it needs and
502        // never truncates, and the others split the rest equally.
503        .when(fill, |item| item.flex_1())
504        .map(|item| match preview {
505            _ if disabled => item,
506            None => item
507                .hover(move |style| style.bg(paint.hover))
508                .active(move |style| style.bg(paint.press)),
509            Some(InteractionPreview::Hovered) => item.bg(paint.hover),
510            Some(InteractionPreview::Pressed) => item.bg(paint.press),
511            Some(_) => item,
512        })
513}
514/// The shared M3 keyboard focus ring around one option. Options sit 2dp
515/// apart, so the ring uses the M3 inner offset: an outer ring would be
516/// covered by the next option.
517fn focus_ring(
518    option: impl IntoElement,
519    focus: &FocusHandle,
520    disabled: bool,
521    forced: bool,
522    paint: OptionPaint,
523    vertical: bool,
524) -> super::button::ButtonFocusRing {
525    let (start, end) = (paint.left_radius, paint.right_radius);
526    super::button::ButtonFocusRing::with_corners(
527        option,
528        focus.clone(),
529        !disabled,
530        if vertical {
531            Corners {
532                top_left: start,
533                top_right: start,
534                bottom_right: end,
535                bottom_left: end,
536            }
537        } else {
538            Corners {
539                top_left: start,
540                top_right: end,
541                bottom_right: end,
542                bottom_left: start,
543            }
544        },
545    )
546    .inward()
547    .forced(forced)
548}
549
550/// An option of an equal-width group: at least `widest` wide, reporting its
551/// own width. Other options pass through unwrapped.
552fn equal_slot(
553    option: impl IntoElement,
554    width: &Rc<std::cell::Cell<f32>>,
555    equal: bool,
556    widest: f32,
557) -> AnyElement {
558    if !equal {
559        return option.into_any_element();
560    }
561    let width = width.clone();
562    div()
563        .relative()
564        .flex()
565        // A column stretches the option across the slot.
566        .flex_col()
567        .items_stretch()
568        .flex_shrink_0()
569        .when(widest > 0., |slot| slot.min_w(px(widest)))
570        .child(option)
571        .child(
572            canvas(
573                move |bounds, window, _| {
574                    let measured = f32::from(bounds.size.width);
575                    if (width.get() - measured).abs() > 0.5 {
576                        width.set(measured);
577                        // The other options take the new width next frame.
578                        window.request_animation_frame();
579                    }
580                },
581                |_, _, _, _| {},
582            )
583            .absolute()
584            .size_full(),
585        )
586        .into_any_element()
587}
588
589/// The focus handle of each option, created on first sight (see
590/// [`super::roving::sync_handles`]).
591fn sync_handles(
592    handles: &mut BTreeMap<SharedString, FocusHandle>,
593    options: &[OptionItem],
594    create: impl FnMut() -> FocusHandle,
595) -> Vec<FocusHandle> {
596    super::roving::sync_handles(handles, options, |option| &option.value, create)
597}
598
599impl RenderOnce for ConnectedButtonGroup {
600    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
601        let disabled = self.disabled;
602        let state = window.use_keyed_state(self.id.clone(), cx, |_, _| {
603            BTreeMap::<SharedString, FocusHandle>::new()
604        });
605        let handles = if self.provided_focus.len() == self.options.len() {
606            self.provided_focus.clone()
607        } else {
608            state.update(cx, |handles, cx| {
609                sync_handles(handles, &self.options, || cx.focus_handle())
610            })
611        };
612        let radio = self.tabs || (self.selection == GroupSelection::Single && self.required);
613        let toggles = !radio && self.selection != GroupSelection::None;
614        let selected = self
615            .options
616            .iter()
617            .position(|option| Some(&option.value) == self.value.as_ref());
618        let is_checked = |index: usize, value: &SharedString| match self.selection {
619            _ if radio => selected == Some(index),
620            GroupSelection::None => false,
621            _ => self.values.contains(value) || self.value.as_ref() == Some(value),
622        };
623        let specimen = !self.previews.is_empty();
624        let roving = Roving::new(handles.clone(), Orientation::Both)
625            .disabled(disabled)
626            .selected(if radio { selected } else { None })
627            .select_on_focus(self.on_change.clone().filter(|_| radio).map(|on_change| {
628                select_value(
629                    self.options.iter().map(|option| option.value.clone()),
630                    on_change,
631                )
632            }));
633        let entry = if specimen {
634            None
635        } else {
636            roving.tab_stop(window)
637        };
638        let count = self.options.len();
639        let mut metrics = match self.size {
640            Some(size) => Metrics::of(size, self.shape),
641            None => Metrics::compact(if self.segmented { 48. } else { 40. }),
642        };
643        metrics.height = (metrics.height + self.density.offset()).max(0.);
644        let vertical = self.vertical;
645        metrics.vertical = vertical;
646        let equal = self.equal_widths && !vertical && !self.full_width;
647        let widths = window
648            .use_keyed_state((self.id.clone(), "equal-widths"), cx, |_, _| {
649                Vec::<Rc<std::cell::Cell<f32>>>::new()
650            })
651            .update(cx, |widths, _| {
652                widths.resize_with(self.options.len(), Rc::default);
653                if !equal {
654                    // Re-measure from natural widths when equal widths return.
655                    widths.iter().for_each(|width| width.set(0.));
656                }
657                widths.clone()
658            });
659        // Equal options share the widest measured width, which holds while
660        // the group stays equal.
661        let widest = if equal {
662            widths.iter().map(|width| width.get()).fold(0., f32::max)
663        } else {
664            0.
665        };
666        let item_height = match self.height {
667            Some(height) => height.to_pixels(window.rem_size()),
668            None => rem(metrics.height).to_pixels(window.rem_size()),
669        };
670        let fill = self.full_width;
671        let icon_size = rem(metrics.icon);
672        let variant = self.variant;
673        let selection_root: std::sync::Arc<ElementId> =
674            std::sync::Arc::new((self.id.clone(), "selection").into());
675        let debug_kind = if fill { "full" } else { "content" };
676        let mut lane = div()
677            .flex()
678            .map(|lane| {
679                if vertical {
680                    lane.flex_col().items_stretch()
681                } else {
682                    lane.flex_row().items_center().w_full().min_w_0()
683                }
684            })
685            .gap(px(GAP))
686            // When even the labels' own widths exceed the parent (large text
687            // in a narrow pane), options wrap to another row rather than
688            // clipping their labels.
689            .when(fill && !vertical, |lane| lane.flex_wrap())
690            .when(cfg!(test), |lane| {
691                lane.debug_selector(|| "full-choice-lane".into())
692            });
693        for (index, option) in self.options.into_iter().enumerate() {
694            let checked = is_checked(index, &option.value);
695            let focus = handles[index].clone().tab_stop(entry == Some(index));
696            let callback = self.on_change.clone();
697            let value = option.value.clone();
698            let label = option.label.clone();
699            // Each option animates its own selection amount; a rapid click
700            // retargets from the current presentation value.
701            let selection = crate::motion::selection_value(
702                ElementId::NamedChild(selection_root.clone(), value.clone()),
703                if checked { 1. } else { 0. },
704                window,
705                cx,
706            );
707            let forced = self.previews.get(&value).copied();
708            // A group-level disabled state wins; a forced one disables only
709            // its own option.
710            let option_disabled = disabled || forced.is_some_and(|p| p.is_disabled());
711            // Every option of a specimen ignores live hover and press.
712            let preview = forced.or(specimen.then_some(InteractionPreview::Enabled));
713            let forced_focus = forced == Some(InteractionPreview::Focused);
714            let paint = option_paint_with(
715                selection,
716                index,
717                count,
718                item_height,
719                option_disabled,
720                variant,
721                metrics,
722            );
723            #[cfg(test)]
724            PAINTED.with(|painted| {
725                painted
726                    .borrow_mut()
727                    .insert(format!("{debug_kind}-{index}"), paint)
728            });
729            let content = gpui_base::h_flex()
730                .when(!fill, |content| content.max_w_full().min_w_0())
731                .gap(rem(8.))
732                .items_center()
733                // Text-only options center their label; no empty icon slot.
734                .when_some(self.icons.get(&value).cloned(), |content, icon| {
735                    content.child(
736                        div()
737                            .w(icon_size)
738                            .h(icon_size)
739                            .flex_shrink_0()
740                            .flex()
741                            .items_center()
742                            .justify_center()
743                            .when(cfg!(test), |slot| {
744                                slot.debug_selector(move || {
745                                    format!("{debug_kind}-choice-icon-{index}")
746                                })
747                            })
748                            .child(icon.size(icon_size)),
749                    )
750                })
751                .map(|content| {
752                    let has_icon = self.icons.contains_key(&value);
753                    match self.labels {
754                        GroupLabels::Never if has_icon => content,
755                        GroupLabels::Selected if has_icon => {
756                            let role = metrics.text.unwrap_or(TypeRole::LabelLarge);
757                            content.gap(rem(8. * selection.clamp(0., 1.))).children(
758                                super::fab::revealed_label(&label, role, selection, window),
759                            )
760                        }
761                        _ => content.child(
762                            div()
763                                .when(!fill, |label| label.min_w_0().text_ellipsis())
764                                .whitespace_nowrap()
765                                .when(cfg!(test), |label| {
766                                    label.debug_selector(move || {
767                                        format!("{debug_kind}-choice-label-{index}")
768                                    })
769                                })
770                                .child(label.clone()),
771                        ),
772                    }
773                });
774            if self.tabs {
775                let click_focus = focus.clone();
776                let tab = item_style(
777                    gpui_base::Tab::new(value.clone()),
778                    paint,
779                    item_height,
780                    fill,
781                    option_disabled,
782                    preview,
783                    metrics,
784                )
785                .when_some(self.label_text_size, |item, size| item.text_size(size))
786                .tab_stop(entry == Some(index))
787                .selected(checked)
788                .disabled(option_disabled)
789                .accessibility_label(label)
790                .set_position(index + 1, count)
791                .track_focus(&focus)
792                .child(content)
793                .when(cfg!(test), |tab| {
794                    tab.debug_selector(move || format!("{debug_kind}-choice-option-{index}"))
795                })
796                .on_click(move |_, window, cx| {
797                    click_focus.focus(window, cx);
798                    if !checked && let Some(callback) = &callback {
799                        callback(&value, window, cx);
800                    }
801                });
802                lane = lane.child(focus_ring(
803                    tab,
804                    &focus,
805                    option_disabled,
806                    forced_focus,
807                    paint,
808                    vertical,
809                ));
810            } else if toggles || self.selection == GroupSelection::None {
811                let click_focus = focus.clone();
812                let on_toggle = self.on_toggle.clone();
813                let on_activate = self.on_activate.clone();
814                let option = if toggles {
815                    item_style(
816                        gpui_base::Toggle::new(value.clone()),
817                        paint,
818                        item_height,
819                        fill,
820                        option_disabled,
821                        preview,
822                        metrics,
823                    )
824                    .when_some(self.label_text_size, |item, size| item.text_size(size))
825                    .pressed(checked)
826                    .tab_stop(entry == Some(index))
827                    .disabled(option_disabled)
828                    .accessibility_label(label)
829                    .track_focus(&focus)
830                    .child(content)
831                    .when(cfg!(test), |item| {
832                        item.debug_selector(move || format!("{debug_kind}-choice-option-{index}"))
833                    })
834                    .on_change(move |pressed, _, window, cx| {
835                        click_focus.focus(window, cx);
836                        if let Some(callback) = &on_toggle {
837                            callback(&value, pressed, window, cx);
838                        }
839                    })
840                    .into_any_element()
841                } else {
842                    item_style(
843                        gpui_base::Button::new(value.clone()),
844                        paint,
845                        item_height,
846                        fill,
847                        option_disabled,
848                        preview,
849                        metrics,
850                    )
851                    .when_some(self.label_text_size, |item, size| item.text_size(size))
852                    .tab_stop(entry == Some(index))
853                    .disabled(option_disabled)
854                    .accessibility_label(label)
855                    .track_focus(&focus)
856                    .child(content)
857                    .when(cfg!(test), |item| {
858                        item.debug_selector(move || format!("{debug_kind}-choice-option-{index}"))
859                    })
860                    .on_click(move |_, window, cx| {
861                        click_focus.focus(window, cx);
862                        if let Some(callback) = &on_activate {
863                            callback(&value, window, cx);
864                        }
865                    })
866                    .into_any_element()
867                };
868                let ring = focus_ring(
869                    option,
870                    &focus,
871                    option_disabled,
872                    forced_focus,
873                    paint,
874                    vertical,
875                );
876                lane = lane.child(equal_slot(ring, &widths[index], equal, widest));
877            } else {
878                let click_focus = focus.clone();
879                let radio = item_style(
880                    gpui_base::Radio::new(value.clone()),
881                    paint,
882                    item_height,
883                    fill,
884                    option_disabled,
885                    preview,
886                    metrics,
887                )
888                .when_some(self.label_text_size, |item, size| item.text_size(size))
889                .checked(checked)
890                .tab_stop(entry == Some(index))
891                .disabled(option_disabled)
892                .accessibility_label(label)
893                .set_position(index + 1, count)
894                .track_focus(&focus)
895                .child(content)
896                .when(cfg!(test), |radio| {
897                    radio.debug_selector(move || format!("{debug_kind}-choice-option-{index}"))
898                })
899                .on_change(move |_, _, window, cx| {
900                    click_focus.focus(window, cx);
901                    if !checked && let Some(callback) = &callback {
902                        callback(&value, window, cx);
903                    }
904                });
905                let ring = focus_ring(
906                    radio,
907                    &focus,
908                    option_disabled,
909                    forced_focus,
910                    paint,
911                    vertical,
912                );
913                lane = lane.child(equal_slot(ring, &widths[index], equal, widest));
914            }
915        }
916        let group = div()
917            .id(self.id.clone())
918            .role(if self.tabs {
919                Role::TabList
920            } else if radio {
921                Role::RadioGroup
922            } else {
923                Role::Group
924            })
925            .aria_label(self.label)
926            .flex()
927            .min_w_0()
928            .max_w_full()
929            .when(fill, |group| group.w_full())
930            .when(cfg!(test), |group| {
931                group.debug_selector(|| "full-choice-track".into())
932            })
933            .child(lane);
934        let group = roving.install(group);
935        if let Some(scroll) = self.reveal_in {
936            let reveal_id = ElementId::from((self.id.clone(), "choice-reveal"));
937            let reveal = super::RevealFocus::new(reveal_id, group, scroll);
938            if fill { reveal } else { reveal.inline() }.into_any_element()
939        } else {
940            group.into_any_element()
941        }
942    }
943}
944
945#[cfg(test)]
946mod tests {
947    use super::{
948        ConnectedButtonGroup, OptionItem, OptionPaint, PAINTED, option_paint, state, sync_handles,
949        unique_options,
950    };
951
952    #[core::prelude::v1::test]
953    fn repeated_option_values_keep_their_first_occurrence() {
954        let item = |value: &str, label: &str| OptionItem {
955            value: value.into(),
956            label: label.into(),
957        };
958        let (options, unique) =
959            unique_options([item("a", "A"), item("b", "B"), item("a", "Again")]);
960        assert!(!unique);
961        let kept: Vec<_> = options
962            .iter()
963            .map(|o| (o.value.as_ref(), o.label.as_ref()))
964            .collect();
965        assert_eq!(kept, [("a", "A"), ("b", "B")]);
966        assert!(unique_options([item("a", "A"), item("b", "B")]).1);
967    }
968
969    #[gpui::test]
970    fn focus_handles_follow_the_current_options(cx: &mut TestAppContext) {
971        let item = |value: &str| OptionItem {
972            value: value.into(),
973            label: value.to_string().into(),
974        };
975        let mut map = std::collections::BTreeMap::new();
976        let first = cx.update(|cx| {
977            sync_handles(&mut map, &[item("a"), item("b"), item("c")], || {
978                cx.focus_handle()
979            })
980        });
981        assert_eq!(map.len(), 3);
982        let second =
983            cx.update(|cx| sync_handles(&mut map, &[item("c"), item("a")], || cx.focus_handle()));
984        assert_eq!(map.len(), 2, "the removed option's handle is pruned");
985        assert!(!map.contains_key("b"));
986        assert_eq!(
987            second,
988            [first[2].clone(), first[0].clone()],
989            "kept handles survive"
990        );
991    }
992
993    #[core::prelude::v1::test]
994    #[cfg_attr(debug_assertions, should_panic(expected = "must be unique"))]
995    fn repeated_option_values_are_rejected_in_debug_builds() {
996        let group = ConnectedButtonGroup::new("dupes", "Dupes").options([("a", "A"), ("a", "B")]);
997        assert_eq!(group.options.len(), 1);
998    }
999
1000    #[core::prelude::v1::test]
1001    fn state_layers_come_from_the_shared_tokens() {
1002        for selection in [0., 0.5, 1.] {
1003            let paint = option_paint(selection, 1, 3, px(40.), false);
1004            assert_eq!(
1005                paint.hover,
1006                state::hovered(paint.background, paint.foreground)
1007            );
1008            assert_eq!(
1009                paint.press,
1010                state::pressed(paint.background, paint.foreground)
1011            );
1012        }
1013        let disabled = option_paint(1., 1, 3, px(40.), true);
1014        let ink = crate::theme::color(ColorRole::ON_SURFACE);
1015        assert_eq!(disabled.foreground, state::disabled(ink));
1016        assert_eq!(disabled.background.a, ink.a * state::DISABLED_CONTAINER);
1017        assert_eq!(
1018            disabled.hover, disabled.background,
1019            "no state layer when disabled"
1020        );
1021    }
1022    use crate::color::ColorRole;
1023    use crate::icons::IconName;
1024    use gpui::{
1025        AppContext, Bounds, Context, FontWeight, IntoElement, Modifiers, ParentElement as _,
1026        Pixels, Render, SharedString, Styled as _, TestAppContext, TextRun, VisualTestContext,
1027        Window, div, font, px,
1028    };
1029    use std::time::Duration;
1030
1031    struct RovingHarness {
1032        handles: [gpui::FocusHandle; 5],
1033        selected: usize,
1034        disabled: bool,
1035        changes: usize,
1036        /// Without a change callback arrows only move focus.
1037        manual: bool,
1038    }
1039
1040    impl Render for RovingHarness {
1041        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1042            use gpui::InteractiveElement as _;
1043            div()
1044                .w(px(600.))
1045                .tab_group()
1046                .child(
1047                    gpui_base::Button::new("before")
1048                        .track_focus(&self.handles[0])
1049                        .child("Before"),
1050                )
1051                .child({
1052                    let group = ConnectedButtonGroup::new("roving", "Choices")
1053                        .options([("0", "First"), ("1", "Second"), ("2", "Third")])
1054                        .focus_handles(self.handles[1..4].iter().cloned())
1055                        .selected(self.selected.to_string())
1056                        .disabled(self.disabled);
1057                    if self.manual {
1058                        group
1059                    } else {
1060                        group.on_change(cx.listener(|this, value: &SharedString, _, cx| {
1061                            this.selected = value.parse().unwrap();
1062                            this.changes += 1;
1063                            cx.notify();
1064                        }))
1065                    }
1066                })
1067                .child(
1068                    gpui_base::Button::new("after")
1069                        .track_focus(&self.handles[4])
1070                        .child("After"),
1071                )
1072        }
1073    }
1074
1075    fn press_roving_key(cx: &mut VisualTestContext, key: &str) {
1076        let keystroke =
1077            cx.update(|_, cx| gpui::Keystroke::parse_with_os(key, cx.platform_info().os).unwrap());
1078        cx.simulate_event(gpui::KeyDownEvent {
1079            keystroke: keystroke.clone(),
1080            is_held: false,
1081            prefer_character_input: false,
1082        });
1083        cx.simulate_event(gpui::KeyUpEvent { keystroke });
1084        draw_choice(cx);
1085    }
1086
1087    #[gpui::test]
1088    fn tab_traversal_uses_only_selected_radio_and_tracks_arrow_changes(cx: &mut TestAppContext) {
1089        cx.update(|cx| {
1090            gpui_base::init(cx);
1091            crate::components::roving::init(cx);
1092            cx.set_reduce_motion(true);
1093        });
1094        for disabled in [false, true] {
1095            let handles: [gpui::FocusHandle; 5] =
1096                cx.update(|cx| std::array::from_fn(|_| cx.focus_handle()));
1097            let controls = cx.new(|_| RovingHarness {
1098                handles: handles.clone(),
1099                selected: 1,
1100                disabled,
1101                changes: 0,
1102                manual: false,
1103            });
1104            let (_, cx) =
1105                cx.add_window_view(|window, cx| gpui_base::Root::new(controls.clone(), window, cx));
1106            cx.update(|window, cx| {
1107                window.activate_window();
1108                window.draw(cx).clear(cx);
1109                handles[0].focus(window, cx);
1110                window.draw(cx).clear(cx);
1111            });
1112            if disabled {
1113                for (key, expected) in [("tab", 4), ("shift-tab", 0)] {
1114                    press_roving_key(cx, key);
1115                    cx.update(|window, cx| {
1116                        assert_eq!(window.focused(cx).as_ref(), Some(&handles[expected]))
1117                    });
1118                }
1119                cx.update(|_, cx| {
1120                    assert_eq!(controls.read(cx).selected, 1);
1121                    assert_eq!(controls.read(cx).changes, 0);
1122                });
1123                continue;
1124            }
1125            // Enter at the selected middle option, leave the entire group in
1126            // one Tab, and re-enter at selection when traversing backwards.
1127            for (key, expected) in [
1128                ("tab", 2),
1129                ("tab", 4),
1130                ("shift-tab", 2),
1131                ("shift-tab", 0),
1132                ("tab", 2),
1133                ("right", 3),
1134            ] {
1135                press_roving_key(cx, key);
1136                cx.update(|window, cx| {
1137                    assert_eq!(
1138                        window.focused(cx).as_ref(),
1139                        Some(&handles[expected]),
1140                        "{key}"
1141                    )
1142                });
1143            }
1144            cx.update(|_, cx| {
1145                assert_eq!(controls.read(cx).selected, 2);
1146                assert_eq!(controls.read(cx).changes, 1);
1147            });
1148            for (key, expected) in [
1149                ("tab", 4),
1150                ("shift-tab", 3),
1151                ("right", 1),
1152                ("tab", 4),
1153                ("shift-tab", 1),
1154            ] {
1155                press_roving_key(cx, key);
1156                cx.update(|window, cx| {
1157                    assert_eq!(
1158                        window.focused(cx).as_ref(),
1159                        Some(&handles[expected]),
1160                        "{key}"
1161                    )
1162                });
1163            }
1164            cx.update(|_, cx| {
1165                assert_eq!(controls.read(cx).selected, 0);
1166                assert_eq!(controls.read(cx).changes, 2);
1167            });
1168        }
1169    }
1170
1171    #[gpui::test]
1172    fn the_tab_stop_follows_focus_without_a_change_callback(cx: &mut TestAppContext) {
1173        cx.update(|cx| {
1174            gpui_base::init(cx);
1175            crate::components::roving::init(cx);
1176            cx.set_reduce_motion(true);
1177        });
1178        let handles: [gpui::FocusHandle; 5] =
1179            cx.update(|cx| std::array::from_fn(|_| cx.focus_handle()));
1180        let controls = cx.new(|_| RovingHarness {
1181            handles: handles.clone(),
1182            selected: 1,
1183            disabled: false,
1184            changes: 0,
1185            manual: true,
1186        });
1187        let (_, cx) =
1188            cx.add_window_view(|window, cx| gpui_base::Root::new(controls.clone(), window, cx));
1189        cx.update(|window, cx| {
1190            window.activate_window();
1191            window.draw(cx).clear(cx);
1192            handles[0].focus(window, cx);
1193            window.draw(cx).clear(cx);
1194        });
1195        // Arrows only move focus; Shift+Tab and Tab then leave the group
1196        // from wherever focus is, and re-entry lands on the selection.
1197        for (key, expected) in [
1198            ("tab", 2),
1199            ("right", 3),
1200            ("shift-tab", 0),
1201            ("tab", 2),
1202            ("left", 1),
1203            ("tab", 4),
1204        ] {
1205            press_roving_key(cx, key);
1206            cx.update(|window, cx| {
1207                assert_eq!(
1208                    window.focused(cx).as_ref(),
1209                    Some(&handles[expected]),
1210                    "{key}"
1211                )
1212            });
1213        }
1214        cx.update(|_, cx| assert_eq!(controls.read(cx).changes, 0));
1215    }
1216
1217    struct FullWidthHarness {
1218        count: usize,
1219        selected: usize,
1220        width: Pixels,
1221        changes: usize,
1222        disabled: bool,
1223    }
1224
1225    struct ContentWidthHarness {
1226        selected: SharedString,
1227        changes: usize,
1228    }
1229
1230    const MODEL_LABELS: [&str; 3] = ["Qwen3-ASR 1.7B", "Qwen3-ASR 0.6B", "Whisper"];
1231
1232    struct ModelWidthHarness {
1233        selected: SharedString,
1234    }
1235
1236    impl Render for ModelWidthHarness {
1237        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1238            window.set_rem_size(px(14.));
1239            div().w(px(600.)).font_family(".SystemUIFont").child(
1240                ConnectedButtonGroup::new("model-width-choice", "Recognition model")
1241                    .options(MODEL_LABELS.map(|label| (label, label)))
1242                    .selected(self.selected.clone())
1243                    .on_change(cx.listener(|this, selected: &SharedString, _, cx| {
1244                        this.selected = selected.clone();
1245                        cx.notify();
1246                    })),
1247            )
1248        }
1249    }
1250
1251    impl Render for ContentWidthHarness {
1252        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1253            window.set_rem_size(px(14.));
1254            div().w(px(600.)).child(
1255                ConnectedButtonGroup::new("content-width-choice", "Speech service")
1256                    .options([
1257                        ("auto", "Auto"),
1258                        ("local", "Whisper"),
1259                        ("remote", "Cloud transcription service"),
1260                    ])
1261                    .selected(self.selected.clone())
1262                    .on_change(cx.listener(|this, next: &SharedString, _, cx| {
1263                        this.selected = next.clone();
1264                        this.changes += 1;
1265                        cx.notify();
1266                    })),
1267            )
1268        }
1269    }
1270
1271    impl Render for FullWidthHarness {
1272        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1273            window.set_rem_size(px(14.));
1274            div().w(self.width).child(
1275                ConnectedButtonGroup::new("full-width-choice", "文字大小")
1276                    .options(
1277                        (0..self.count)
1278                            .map(|index| (index.to_string(), format!("{}%", 100 + 25 * index))),
1279                    )
1280                    .selected(self.selected.to_string())
1281                    .full_width()
1282                    .disabled(self.disabled)
1283                    .on_change(cx.listener(|this, next: &SharedString, _, cx| {
1284                        this.selected = next.parse().unwrap();
1285                        this.changes += 1;
1286                        cx.notify();
1287                    })),
1288            )
1289        }
1290    }
1291
1292    struct SegmentedHarness {
1293        selected: usize,
1294        changes: usize,
1295    }
1296
1297    impl Render for SegmentedHarness {
1298        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1299            window.set_rem_size(px(14.));
1300            div().w(px(360.)).child(
1301                ConnectedButtonGroup::new("connected-choice", "文字来源")
1302                    .options([("0", "自动"), ("1", "仅字幕"), ("2", "语音识别")])
1303                    .icon("0", IconName::Info)
1304                    .icon("1", IconName::Check)
1305                    .icon("2", IconName::Warning)
1306                    .segmented()
1307                    .selected(self.selected.to_string())
1308                    .on_change(cx.listener(|this, next: &SharedString, _, cx| {
1309                        this.selected = next.parse().unwrap();
1310                        this.changes += 1;
1311                        cx.notify();
1312                    })),
1313            )
1314        }
1315    }
1316
1317    fn painted(name: &str) -> OptionPaint {
1318        PAINTED.with(|painted| {
1319            *painted
1320                .borrow()
1321                .get(name)
1322                .unwrap_or_else(|| panic!("no paint for {name}"))
1323        })
1324    }
1325
1326    fn token(role: ColorRole) -> gpui::Rgba {
1327        crate::theme::color(role)
1328    }
1329
1330    /// Blended endpoints may differ from a token in the last float bit.
1331    fn same(a: gpui::Rgba, b: gpui::Rgba) -> bool {
1332        [a.r - b.r, a.g - b.g, a.b - b.b, a.a - b.a]
1333            .iter()
1334            .all(|d| d.abs() < 1e-4)
1335    }
1336
1337    /// Selected: accent container, on-primary content and a full pill.
1338    fn assert_selected(name: &str, height: Pixels) {
1339        let paint = painted(name);
1340        assert!(
1341            same(paint.background, token(ColorRole::SECONDARY)),
1342            "{name}: {:?}",
1343            paint
1344        );
1345        assert!(
1346            same(paint.foreground, token(ColorRole::ON_SECONDARY)),
1347            "{name}: {:?}",
1348            paint
1349        );
1350        assert_eq!(paint.left_radius, height / 2., "{name}");
1351        assert_eq!(paint.right_radius, height / 2., "{name}");
1352        // A state layer resolves over the selected container, never replaces it.
1353        assert!(!same(paint.hover, token(ColorRole::SECONDARY_CONTAINER)));
1354        assert!(!same(paint.hover, paint.background));
1355    }
1356
1357    /// Unselected: neutral container, ink content; only outer ends are round.
1358    fn assert_unselected(name: &str, index: usize, count: usize, height: Pixels) {
1359        let paint = painted(name);
1360        assert_eq!(
1361            paint.background,
1362            token(ColorRole::SECONDARY_CONTAINER),
1363            "{name}"
1364        );
1365        assert!(
1366            same(paint.foreground, token(ColorRole::ON_SECONDARY_CONTAINER)),
1367            "{name}: {:?}",
1368            paint
1369        );
1370        let left = if index == 0 { height / 2. } else { px(8.) };
1371        let right = if index + 1 == count {
1372            height / 2.
1373        } else {
1374            px(8.)
1375        };
1376        assert_eq!(paint.left_radius, left, "{name}");
1377        assert_eq!(paint.right_radius, right, "{name}");
1378    }
1379
1380    #[gpui::test]
1381    fn connected_segments_fit_their_labels_and_keep_them_still_on_selection(
1382        cx: &mut TestAppContext,
1383    ) {
1384        cx.update(|cx| cx.set_reduce_motion(true));
1385        let (view, cx) = cx.add_window_view(|_, _| SegmentedHarness {
1386            selected: 0,
1387            changes: 0,
1388        });
1389        draw_choice(cx);
1390        let labels = [
1391            "full-choice-label-0",
1392            "full-choice-label-1",
1393            "full-choice-label-2",
1394        ];
1395        let before = labels.map(|selector| choice_bounds(cx, selector));
1396        let icons = [
1397            "full-choice-icon-0",
1398            "full-choice-icon-1",
1399            "full-choice-icon-2",
1400        ];
1401        let icon_before = icons.map(|selector| choice_bounds(cx, selector));
1402        let options = OPTION_SELECTORS[..3]
1403            .iter()
1404            .map(|selector| choice_bounds(cx, selector))
1405            .collect::<Vec<_>>();
1406        let lane = choice_bounds(cx, "full-choice-lane");
1407        for index in 0..3 {
1408            // Each label follows its icon; the cluster is centered in a 48px button.
1409            assert_eq!(options[index].size.height, px(48.));
1410            // Each option holds its whole label.
1411            assert!(before[index].right() <= options[index].right());
1412            assert!(icon_before[index].right() <= before[index].left());
1413            let content_center = (icon_before[index].left() + before[index].right()) / 2.;
1414            assert!((content_center - options[index].center().x).abs() <= px(0.5));
1415            assert!((icon_before[index].center().y - options[index].center().y).abs() <= px(0.5));
1416            assert!((before[index].center().y - options[index].center().y).abs() <= px(0.5));
1417        }
1418        // Labels that fit an equal share get equal options.
1419        for option in &options[1..] {
1420            assert!((option.size.width - options[0].size.width).abs() <= px(0.5));
1421        }
1422        // Separate buttons with a 2px gap that together fill the parent.
1423        for pair in options.windows(2) {
1424            assert!((pair[1].left() - pair[0].right() - px(2.)).abs() <= px(0.5));
1425        }
1426        assert!((options[0].left() - lane.left()).abs() < px(0.5));
1427        assert!((options[2].right() - lane.right()).abs() < px(0.5));
1428        assert_eq!(lane.size.width, px(360.));
1429        assert_selected("full-0", px(48.));
1430        assert_unselected("full-1", 1, 3, px(48.));
1431        assert_unselected("full-2", 2, 3, px(48.));
1432        // The narrowest options lane must show the full label.
1433        let required = cx.update(|window, _| {
1434            let mut font = window.text_style().font();
1435            font.weight = FontWeight::MEDIUM;
1436            window
1437                .text_system()
1438                .shape_line(
1439                    "语音识别".into(),
1440                    px(14.),
1441                    &[TextRun {
1442                        len: "语音识别".len(),
1443                        font,
1444                        color: gpui::black(),
1445                        background_color: None,
1446                        underline: None,
1447                        strikethrough: None,
1448                    }],
1449                    None,
1450                )
1451                .width
1452                .ceil()
1453        });
1454        assert!(
1455            before[2].size.width >= required,
1456            "label bounds: {:?}, needed: {:?}",
1457            before[2],
1458            required
1459        );
1460        cx.simulate_click(options[2].center(), Modifiers::default());
1461        draw_choice(cx);
1462        cx.update(|_, cx| {
1463            assert_eq!(view.read(cx).selected, 2);
1464            assert_eq!(view.read(cx).changes, 1);
1465        });
1466        // Reduce motion: the new selection is fully drawn on the changed frame.
1467        assert_selected("full-2", px(48.));
1468        assert_unselected("full-0", 0, 3, px(48.));
1469        for (selector, bounds) in labels.into_iter().zip(before) {
1470            assert_eq!(choice_bounds(cx, selector), bounds);
1471        }
1472        for (selector, bounds) in icons.into_iter().zip(icon_before) {
1473            assert_eq!(choice_bounds(cx, selector), bounds);
1474        }
1475        cx.simulate_click(options[2].center(), Modifiers::default());
1476        cx.update(|_, cx| assert_eq!(view.read(cx).changes, 1));
1477    }
1478
1479    struct LongLabelHarness {
1480        width: Pixels,
1481    }
1482
1483    const LONG_LABELS: [&str; 3] = ["Auto", "Subtitles", "Speech recognition"];
1484
1485    impl Render for LongLabelHarness {
1486        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1487            window.set_rem_size(px(14.));
1488            div().w(self.width).child(
1489                ConnectedButtonGroup::new("long-label-choice", "Text source")
1490                    .options(LONG_LABELS.map(|label| (label, label)))
1491                    .icon("Auto", IconName::Info)
1492                    .icon("Subtitles", IconName::Check)
1493                    .icon("Speech recognition", IconName::Warning)
1494                    .segmented()
1495                    .selected("Auto"),
1496            )
1497        }
1498    }
1499
1500    /// Equal options are floored at their label: a label wider than an equal
1501    /// share keeps its whole width, the others share the rest equally, and
1502    /// when even the labels cannot fit the options wrap instead of clipping.
1503    #[gpui::test]
1504    fn filling_options_never_truncate_a_label_wider_than_its_share(cx: &mut TestAppContext) {
1505        let (view, cx) = cx.add_window_view(|_, _| LongLabelHarness { width: px(480.) });
1506        draw_choice(cx);
1507        let required = cx.update(|window, _| {
1508            let mut font = window.text_style().font();
1509            font.weight = FontWeight::MEDIUM;
1510            LONG_LABELS.map(|label| {
1511                window
1512                    .text_system()
1513                    .shape_line(
1514                        label.into(),
1515                        px(14.),
1516                        &[TextRun {
1517                            len: label.len(),
1518                            font: font.clone(),
1519                            ..Default::default()
1520                        }],
1521                        None,
1522                    )
1523                    .width
1524                    .ceil()
1525            })
1526        });
1527        let labels = [
1528            "full-choice-label-0",
1529            "full-choice-label-1",
1530            "full-choice-label-2",
1531        ];
1532        let check = |cx: &mut VisualTestContext| {
1533            for (index, selector) in labels.into_iter().enumerate() {
1534                let label = choice_bounds(cx, selector);
1535                let option = choice_bounds(cx, OPTION_SELECTORS[index]);
1536                assert!(
1537                    label.size.width + px(0.5) >= required[index],
1538                    "{selector} truncated"
1539                );
1540                assert!(label.right() <= option.right() + px(0.5));
1541            }
1542        };
1543        check(cx);
1544        let options = [0, 1, 2].map(|index| choice_bounds(cx, OPTION_SELECTORS[index]));
1545        assert!(options[2].size.width > options[0].size.width);
1546        assert!((options[1].size.width - options[0].size.width).abs() <= px(0.5));
1547        // Too narrow for all three labels on one row: they wrap.
1548        cx.update(|_, cx| {
1549            view.update(cx, |view, cx| {
1550                view.width = px(220.);
1551                cx.notify();
1552            })
1553        });
1554        draw_choice(cx);
1555        check(cx);
1556        let wrapped = [0, 1, 2].map(|index| choice_bounds(cx, OPTION_SELECTORS[index]));
1557        assert!(wrapped[2].top() > wrapped[0].top());
1558    }
1559
1560    const OPTION_SELECTORS: [&str; 4] = [
1561        "full-choice-option-0",
1562        "full-choice-option-1",
1563        "full-choice-option-2",
1564        "full-choice-option-3",
1565    ];
1566
1567    fn choice_bounds(cx: &mut VisualTestContext, name: &'static str) -> Bounds<Pixels> {
1568        cx.debug_bounds(name)
1569            .unwrap_or_else(|| panic!("missing {name}"))
1570    }
1571
1572    fn draw_choice(cx: &mut VisualTestContext) {
1573        cx.update(|window, cx| {
1574            window.refresh();
1575            window.draw(cx).clear(cx);
1576        });
1577    }
1578
1579    #[gpui::test]
1580    fn content_width_model_labels_keep_their_rounded_text_width(cx: &mut TestAppContext) {
1581        let (_, cx) = cx.add_window_view(|_, _| ModelWidthHarness {
1582            selected: MODEL_LABELS[0].into(),
1583        });
1584        draw_choice(cx);
1585        let required_widths = cx.update(|window, _| {
1586            let mut label_font = font(".SystemUIFont");
1587            label_font.weight = FontWeight::MEDIUM;
1588            MODEL_LABELS.map(|label| {
1589                window
1590                    .text_system()
1591                    .shape_line(
1592                        label.into(),
1593                        px(14.),
1594                        &[TextRun {
1595                            len: label.len(),
1596                            font: label_font.clone(),
1597                            ..Default::default()
1598                        }],
1599                        None,
1600                    )
1601                    .width
1602                    .ceil()
1603            })
1604        });
1605        let option_selectors = [
1606            "content-choice-option-0",
1607            "content-choice-option-1",
1608            "content-choice-option-2",
1609        ];
1610        let label_selectors = [
1611            "content-choice-label-0",
1612            "content-choice-label-1",
1613            "content-choice-label-2",
1614        ];
1615        let before = option_selectors.map(|selector| choice_bounds(cx, selector));
1616        // The native GPUI test window uses a 2x device scale. Check actual
1617        // laid-out labels, including the slightly longer 0.6B option.
1618        for index in 0..MODEL_LABELS.len() {
1619            let label = choice_bounds(cx, label_selectors[index]);
1620            assert!(
1621                label.size.width >= required_widths[index],
1622                "{} was narrowed below its full shaped width",
1623                MODEL_LABELS[index]
1624            );
1625            // Content-sized buttons: 16px from each edge to the label.
1626            let leading = label.left() - before[index].left();
1627            let trailing = before[index].right() - label.right();
1628            assert!((leading - px(16.)).abs() <= px(1.), "leading {leading:?}");
1629            assert!(
1630                (trailing - px(16.)).abs() <= px(1.),
1631                "trailing {trailing:?}"
1632            );
1633            assert_eq!(before[index].size.height, px(40.));
1634        }
1635        cx.simulate_click(before[1].center(), Modifiers::default());
1636        draw_choice(cx);
1637        for index in 0..MODEL_LABELS.len() {
1638            assert_eq!(choice_bounds(cx, option_selectors[index]), before[index]);
1639            assert!(choice_bounds(cx, label_selectors[index]).size.width >= required_widths[index]);
1640        }
1641    }
1642
1643    #[gpui::test]
1644    fn content_width_choices_morph_in_place_and_settle(cx: &mut TestAppContext) {
1645        let (view, cx) = cx.add_window_view(|_, _| ContentWidthHarness {
1646            selected: "auto".into(),
1647            changes: 0,
1648        });
1649        draw_choice(cx);
1650        let selectors = [
1651            "content-choice-option-0",
1652            "content-choice-option-1",
1653            "content-choice-option-2",
1654        ];
1655        let labels = [
1656            "content-choice-label-0",
1657            "content-choice-label-1",
1658            "content-choice-label-2",
1659        ];
1660        let before = selectors.map(|selector| choice_bounds(cx, selector));
1661        let label_before = labels.map(|selector| choice_bounds(cx, selector));
1662        // Each option is sized to its own label and the group hugs the start.
1663        assert!(before[2].size.width > before[0].size.width + px(40.));
1664        assert!((before[1].left() - before[0].right() - px(2.)).abs() < px(0.5));
1665        assert_selected("content-0", px(40.));
1666        cx.simulate_click(before[2].center(), Modifiers::default());
1667        draw_choice(cx);
1668        // The click frame starts from the current presentation, not the endpoint.
1669        assert_eq!(
1670            painted("content-2").background,
1671            token(ColorRole::SECONDARY_CONTAINER)
1672        );
1673        cx.executor().advance_clock(Duration::from_millis(60));
1674        draw_choice(cx);
1675        let entering = painted("content-2");
1676        let leaving = painted("content-0");
1677        assert!(entering.right_radius == px(20.) && entering.left_radius > px(8.));
1678        assert!(
1679            entering.left_radius < px(20.),
1680            "an intermediate shape is drawn"
1681        );
1682        assert!(leaving.right_radius > px(8.) && leaving.right_radius < px(20.));
1683        assert!(!same(entering.background, token(ColorRole::SECONDARY)));
1684        for index in 0..3 {
1685            assert_eq!(choice_bounds(cx, selectors[index]), before[index]);
1686            assert_eq!(choice_bounds(cx, labels[index]), label_before[index]);
1687        }
1688        cx.executor().advance_clock(
1689            crate::motion::curve(crate::motion::MotionToken::FastSpatial).duration(),
1690        );
1691        draw_choice(cx);
1692        assert_selected("content-2", px(40.));
1693        assert_unselected("content-0", 0, 3, px(40.));
1694        cx.simulate_mouse_move(before[2].center(), None, Modifiers::default());
1695        draw_choice(cx);
1696        assert_eq!(choice_bounds(cx, selectors[2]), before[2]);
1697        cx.update(|window, cx| {
1698            assert_eq!(view.read(cx).changes, 1);
1699            window.simulate_next_frame(cx);
1700            window.refresh();
1701            window.draw(cx).clear(cx);
1702            assert_eq!(window.simulate_next_frame(cx), 0);
1703        });
1704    }
1705
1706    fn assert_equal_options(cx: &mut VisualTestContext, count: usize, width: Pixels) {
1707        let track = choice_bounds(cx, "full-choice-track");
1708        let lane = choice_bounds(cx, "full-choice-lane");
1709        assert_eq!(track.size.width, width);
1710        assert_eq!(lane.size.width, width);
1711        let share = (width - px(2.) * (count - 1) as f32) / count as f32;
1712        for selector in &OPTION_SELECTORS[..count] {
1713            let option = choice_bounds(cx, selector);
1714            assert!((option.size.width - share).abs() <= px(0.5));
1715            assert_eq!(option.size.height, px(40.));
1716        }
1717    }
1718
1719    fn exercise_full_width_motion(cx: &mut TestAppContext, count: usize) {
1720        let (view, cx) = cx.add_window_view(|_, _| FullWidthHarness {
1721            count,
1722            selected: 0,
1723            width: px(420.),
1724            changes: 0,
1725            disabled: false,
1726        });
1727        draw_choice(cx);
1728        assert_equal_options(cx, count, px(420.));
1729        let last = format!("full-{}", count - 1);
1730        assert_selected("full-0", px(40.));
1731        assert_unselected(&last, count - 1, count, px(40.));
1732        let options = (0..count)
1733            .map(|index| choice_bounds(cx, OPTION_SELECTORS[index]))
1734            .collect::<Vec<_>>();
1735        let destination = options[count - 1];
1736
1737        cx.simulate_click(destination.center(), Modifiers::default());
1738        cx.update(|window, cx| {
1739            assert_eq!(
1740                view.read(cx).selected,
1741                count - 1,
1742                "click is accepted immediately"
1743            );
1744            assert_eq!(view.read(cx).changes, 1);
1745            window.draw(cx).clear(cx);
1746        });
1747        assert_eq!(painted(&last).left_radius, px(8.));
1748        cx.simulate_click(destination.center(), Modifiers::default());
1749        cx.update(|_, cx| assert_eq!(view.read(cx).changes, 1, "same choice does not save twice"));
1750
1751        cx.executor().advance_clock(Duration::from_millis(60));
1752        draw_choice(cx);
1753        let middle = painted(&last);
1754        assert!(middle.left_radius > px(8.) && middle.left_radius < px(20.));
1755        assert!(!same(
1756            middle.background,
1757            token(ColorRole::SECONDARY_CONTAINER)
1758        ));
1759        assert!(!same(middle.background, token(ColorRole::SECONDARY)));
1760        for (index, before) in options.iter().enumerate() {
1761            assert_eq!(choice_bounds(cx, OPTION_SELECTORS[index]), *before);
1762        }
1763        draw_choice(cx);
1764        assert_eq!(
1765            painted(&last),
1766            middle,
1767            "ordinary redraw must not restart motion"
1768        );
1769
1770        // Reverse before settling, then resize without advancing time. The
1771        // shape continues from its current value instead of jumping.
1772        cx.simulate_click(options[0].center(), Modifiers::default());
1773        cx.update(|window, cx| window.draw(cx).clear(cx));
1774        assert_eq!(painted(&last).left_radius, middle.left_radius);
1775        cx.update(|window, cx| {
1776            view.update(cx, |view, cx| {
1777                view.width = px(284.);
1778                cx.notify();
1779            });
1780            window.draw(cx).clear(cx);
1781        });
1782        assert_equal_options(cx, count, px(284.));
1783        cx.executor().advance_clock(Duration::from_millis(30));
1784        draw_choice(cx);
1785        let returning = painted(&last);
1786        assert!(returning.left_radius < middle.left_radius);
1787        assert!(returning.left_radius >= px(8.));
1788
1789        cx.executor().advance_clock(
1790            crate::motion::curve(crate::motion::MotionToken::FastSpatial).duration(),
1791        );
1792        cx.update(|window, cx| {
1793            window.refresh();
1794            window.draw(cx).clear(cx);
1795            window.simulate_next_frame(cx);
1796            window.refresh();
1797            window.draw(cx).clear(cx);
1798            assert_eq!(
1799                window.simulate_next_frame(cx),
1800                0,
1801                "settled control must stop scheduling frames"
1802            );
1803        });
1804        assert_selected("full-0", px(40.));
1805        assert_unselected(&last, count - 1, count, px(40.));
1806    }
1807
1808    #[gpui::test]
1809    fn full_width_choices_morph_inside_current_geometry_and_settle(cx: &mut TestAppContext) {
1810        for count in [3, 4] {
1811            exercise_full_width_motion(cx, count);
1812        }
1813    }
1814
1815    #[gpui::test]
1816    fn full_width_choices_respect_reduce_motion_on_the_changed_frame(cx: &mut TestAppContext) {
1817        cx.update(|cx| cx.set_reduce_motion(true));
1818        let (view, cx) = cx.add_window_view(|_, _| FullWidthHarness {
1819            count: 4,
1820            selected: 0,
1821            width: px(420.),
1822            changes: 0,
1823            disabled: false,
1824        });
1825        draw_choice(cx);
1826        let destination = choice_bounds(cx, "full-choice-option-3");
1827        cx.simulate_click(destination.center(), Modifiers::default());
1828        cx.update(|window, cx| {
1829            assert_eq!(view.read(cx).selected, 3);
1830            window.draw(cx).clear(cx);
1831            assert_eq!(window.simulate_next_frame(cx), 0);
1832        });
1833        assert_selected("full-3", px(40.));
1834        assert_unselected("full-0", 0, 4, px(40.));
1835        assert_equal_options(cx, 4, px(420.));
1836    }
1837
1838    #[gpui::test]
1839    fn arrow_keys_move_focus_and_selection_once_per_step(cx: &mut TestAppContext) {
1840        cx.update(|cx| {
1841            cx.set_reduce_motion(true);
1842            crate::components::roving::init(cx);
1843        });
1844        let (view, cx) = cx.add_window_view(|_, _| FullWidthHarness {
1845            count: 3,
1846            selected: 0,
1847            width: px(420.),
1848            changes: 0,
1849            disabled: false,
1850        });
1851        draw_choice(cx);
1852        // Clicking the selected option focuses it without a change.
1853        let first = choice_bounds(cx, "full-choice-option-0").center();
1854        cx.simulate_click(first, Modifiers::default());
1855        draw_choice(cx);
1856        cx.update(|_, cx| assert_eq!(view.read(cx).changes, 0));
1857        cx.simulate_keystrokes("right");
1858        draw_choice(cx);
1859        cx.update(|_, cx| {
1860            assert_eq!(view.read(cx).selected, 1);
1861            assert_eq!(view.read(cx).changes, 1);
1862        });
1863        cx.simulate_keystrokes("left left");
1864        draw_choice(cx);
1865        cx.update(|_, cx| {
1866            assert_eq!(
1867                view.read(cx).selected,
1868                2,
1869                "previous wraps to the last option"
1870            );
1871            assert_eq!(view.read(cx).changes, 3);
1872        });
1873        cx.simulate_keystrokes("end");
1874        cx.update(|_, cx| assert_eq!(view.read(cx).changes, 3, "already last"));
1875        cx.simulate_keystrokes("home");
1876        draw_choice(cx);
1877        cx.update(|_, cx| {
1878            assert_eq!(view.read(cx).selected, 0);
1879            assert_eq!(view.read(cx).changes, 4);
1880        });
1881        assert_selected("full-0", px(40.));
1882    }
1883
1884    #[gpui::test]
1885    fn disabled_choices_use_disabled_roles_and_ignore_clicks(cx: &mut TestAppContext) {
1886        let (view, cx) = cx.add_window_view(|_, _| FullWidthHarness {
1887            count: 3,
1888            selected: 1,
1889            width: px(420.),
1890            changes: 0,
1891            disabled: true,
1892        });
1893        draw_choice(cx);
1894        let third = choice_bounds(cx, "full-choice-option-2").center();
1895        cx.simulate_click(third, Modifiers::default());
1896        draw_choice(cx);
1897        cx.update(|_, cx| assert_eq!(view.read(cx).changes, 0));
1898        let ink = token(ColorRole::ON_SURFACE);
1899        for index in 0..3 {
1900            let paint = painted(&format!("full-{index}"));
1901            assert_eq!(paint.background, ink.opacity(0.12));
1902            assert_eq!(paint.foreground, ink.opacity(0.38));
1903            assert_eq!(paint.hover, paint.background, "no hover layer");
1904        }
1905        // The current option keeps its pill shape.
1906        assert_eq!(painted("full-1").left_radius, px(20.));
1907    }
1908}
1909
1910#[cfg(test)]
1911mod mode_tests {
1912    use super::{ConnectedButtonGroup, GroupLabels, GroupSelection};
1913    use crate::components::{ButtonDensity, ButtonSize};
1914    use crate::icons::IconName;
1915    use gpui::{
1916        Context, InteractiveElement as _, IntoElement, Modifiers, ParentElement as _, Render,
1917        SharedString, Styled as _, TestAppContext, VisualTestContext, Window, div, px,
1918    };
1919    use std::{cell::RefCell, rc::Rc};
1920
1921    #[derive(Default)]
1922    struct Harness {
1923        selection: Option<GroupSelection>,
1924        required: bool,
1925        vertical: bool,
1926        density: ButtonDensity,
1927        equal: bool,
1928        labels: GroupLabels,
1929        values: Vec<SharedString>,
1930        events: Rc<RefCell<Vec<String>>>,
1931    }
1932    impl Render for Harness {
1933        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1934            window.set_rem_size(px(14.));
1935            let events = self.events.clone();
1936            let activated = self.events.clone();
1937            let view = cx.entity().downgrade();
1938            div().flex().items_start().p(px(40.)).child(
1939                div().flex().debug_selector(|| "group".into()).child(
1940                    ConnectedButtonGroup::new("group", "Format")
1941                        .button_size(ButtonSize::Small)
1942                        .options([("b", "Bold"), ("i", "Italic"), ("u", "Underline wide")])
1943                        .icon("b", IconName::Check)
1944                        .icon("i", IconName::Check)
1945                        .icon("u", IconName::Check)
1946                        .selection(self.selection.unwrap_or_default())
1947                        .required(self.required)
1948                        .vertical(self.vertical)
1949                        .density(self.density)
1950                        .equal_widths(self.equal)
1951                        .labels(self.labels)
1952                        .selected_values(self.values.clone())
1953                        .on_toggle(move |value: &SharedString, on, _, cx| {
1954                            events.borrow_mut().push(format!("{value}={on}"));
1955                            let _ = view.update(cx, |this, cx| {
1956                                if on {
1957                                    this.values.push(value.clone());
1958                                } else {
1959                                    this.values.retain(|v| v != value);
1960                                }
1961                                cx.notify();
1962                            });
1963                        })
1964                        .on_activate(move |value, _, _| {
1965                            activated.borrow_mut().push(format!("{value}!"))
1966                        }),
1967                ),
1968            )
1969        }
1970    }
1971
1972    fn draw(cx: &mut VisualTestContext) {
1973        for _ in 0..3 {
1974            cx.executor()
1975                .advance_clock(std::time::Duration::from_millis(800));
1976            cx.update(|window, cx| {
1977                window.refresh();
1978                window.draw(cx).clear(cx);
1979            });
1980        }
1981    }
1982
1983    fn setup(cx: &mut TestAppContext, harness: Harness) -> &mut VisualTestContext {
1984        cx.update(|cx| {
1985            gpui_base::init(cx);
1986            cx.set_reduce_motion(true);
1987        });
1988        let (_, cx) = cx.add_window_view(move |_, _| harness);
1989        draw(cx);
1990        cx
1991    }
1992
1993    fn option(cx: &mut VisualTestContext, index: usize) -> gpui::Bounds<gpui::Pixels> {
1994        let name: &'static str = Box::leak(format!("content-choice-option-{index}").into());
1995        cx.debug_bounds(name).unwrap()
1996    }
1997
1998    #[gpui::test]
1999    fn multiple_selection_toggles_each_option(cx: &mut TestAppContext) {
2000        let events = Rc::new(RefCell::new(Vec::new()));
2001        let cx = setup(
2002            cx,
2003            Harness {
2004                selection: Some(GroupSelection::Multi),
2005                events: events.clone(),
2006                ..Harness::default()
2007            },
2008        );
2009        for index in [0, 2, 0] {
2010            let at = option(cx, index).center();
2011            cx.simulate_click(at, Modifiers::none());
2012            draw(cx);
2013        }
2014        assert_eq!(*events.borrow(), ["b=true", "u=true", "b=false"]);
2015    }
2016
2017    #[gpui::test]
2018    fn an_optional_single_selection_can_be_cleared(cx: &mut TestAppContext) {
2019        let events = Rc::new(RefCell::new(Vec::new()));
2020        let cx = setup(
2021            cx,
2022            Harness {
2023                selection: Some(GroupSelection::Single),
2024                required: false,
2025                values: vec!["i".into()],
2026                events: events.clone(),
2027                ..Harness::default()
2028            },
2029        );
2030        let at = option(cx, 1).center();
2031        cx.simulate_click(at, Modifiers::none());
2032        draw(cx);
2033        assert_eq!(*events.borrow(), ["i=false"]);
2034    }
2035
2036    #[gpui::test]
2037    fn a_group_without_selection_activates(cx: &mut TestAppContext) {
2038        let events = Rc::new(RefCell::new(Vec::new()));
2039        let cx = setup(
2040            cx,
2041            Harness {
2042                selection: Some(GroupSelection::None),
2043                events: events.clone(),
2044                ..Harness::default()
2045            },
2046        );
2047        let at = option(cx, 2).center();
2048        cx.simulate_click(at, Modifiers::none());
2049        draw(cx);
2050        assert_eq!(*events.borrow(), ["u!"]);
2051    }
2052
2053    #[gpui::test]
2054    fn vertical_dense_groups_stack_shorter_options_of_one_width(cx: &mut TestAppContext) {
2055        let cx = setup(
2056            cx,
2057            Harness {
2058                required: true,
2059                vertical: true,
2060                density: ButtonDensity::Compact,
2061                ..Harness::default()
2062            },
2063        );
2064        let (first, last) = (option(cx, 0), option(cx, 2));
2065        assert_eq!(first.size.height, px(40. - 8.));
2066        assert_eq!(
2067            first.size.width, last.size.width,
2068            "options share the column"
2069        );
2070        assert_eq!(last.top(), first.top() + px(2. * (32. + 2.)));
2071    }
2072
2073    #[gpui::test]
2074    fn equal_widths_take_the_widest_option(cx: &mut TestAppContext) {
2075        let cx = setup(
2076            cx,
2077            Harness {
2078                required: true,
2079                equal: true,
2080                ..Harness::default()
2081            },
2082        );
2083        draw(cx);
2084        let widths = [0, 1, 2].map(|index| option(cx, index).size.width);
2085        assert_eq!(widths[0], widths[2], "{widths:?}");
2086        assert_eq!(widths[1], widths[2], "{widths:?}");
2087    }
2088
2089    #[gpui::test]
2090    fn selected_labels_show_only_on_selected_options(cx: &mut TestAppContext) {
2091        let cx = setup(
2092            cx,
2093            Harness {
2094                selection: Some(GroupSelection::Multi),
2095                labels: GroupLabels::Selected,
2096                values: vec!["u".into()],
2097                ..Harness::default()
2098            },
2099        );
2100        let (bare, labelled) = (option(cx, 0), option(cx, 2));
2101        // An unselected icon option is the icon, its padding and border.
2102        assert_eq!(bare.size.width, px(20. + (16. + 2.) * 2.));
2103        assert!(labelled.size.width > bare.size.width + px(40.));
2104        let at = bare.center();
2105        cx.simulate_click(at, Modifiers::none());
2106        draw(cx);
2107        assert!(option(cx, 0).size.width > bare.size.width, "revealed");
2108    }
2109}