1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
24pub enum GroupSelection {
25 None,
27 #[default]
29 Single,
30 Multi,
32}
33
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
36pub enum GroupLabels {
37 #[default]
38 Always,
39 Selected,
41 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
54fn 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 pub fn selection(mut self, selection: GroupSelection) -> Self {
134 self.selection = selection;
135 self
136 }
137 pub fn required(mut self, required: bool) -> Self {
141 self.required = required;
142 self
143 }
144 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 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 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 pub fn vertical(mut self, vertical: bool) -> Self {
171 self.vertical = vertical;
172 self
173 }
174 pub fn density(mut self, density: ButtonDensity) -> Self {
176 self.density = density;
177 self
178 }
179 pub fn equal_widths(mut self, equal: bool) -> Self {
182 self.equal_widths = equal;
183 self
184 }
185 pub fn labels(mut self, labels: GroupLabels) -> Self {
187 self.labels = labels;
188 self
189 }
190 pub fn variant(mut self, variant: ButtonVariant) -> Self {
193 self.variant = variant;
194 self
195 }
196 pub fn button_size(mut self, size: ButtonSize) -> Self {
200 self.size = Some(size);
201 self
202 }
203 pub fn shape(mut self, shape: ButtonShape) -> Self {
206 self.shape = shape;
207 self
208 }
209 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 pub fn full_width(mut self) -> Self {
233 self.full_width = true;
234 self
235 }
236 pub fn segmented(mut self) -> Self {
239 self.segmented = true;
240 self.full_width = true;
241 self
242 }
243 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 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 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 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#[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
309const INNER_RADIUS: f32 = 8.;
311const GAP: f32 = 2.;
312
313#[derive(Clone, Copy, Debug, PartialEq)]
315struct Metrics {
316 height: f32,
317 inner: f32,
319 outer: Option<f32>,
321 padding: f32,
324 icon: f32,
325 text: Option<TypeRole>,
326 vertical: bool,
328}
329
330impl Metrics {
331 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 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#[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 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 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 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
441fn 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 .when(!fill, |item| item.min_w_0())
458 .h(height)
459 .min_h(height)
460 .px(rem(if fill {
465 metrics.padding
466 } else {
467 metrics.padding + 2.
468 }))
469 .py_0()
470 .map(|item| {
471 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 .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 .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}
514fn 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
550fn 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 .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 window.request_animation_frame();
579 }
580 },
581 |_, _, _, _| {},
582 )
583 .absolute()
584 .size_full(),
585 )
586 .into_any_element()
587}
588
589fn 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 widths.iter().for_each(|width| width.set(0.));
656 }
657 widths.clone()
658 });
659 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(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 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 let option_disabled = disabled || forced.is_some_and(|p| p.is_disabled());
711 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 .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 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 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 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 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 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 assert!(!same(paint.hover, token(ColorRole::SECONDARY_CONTAINER)));
1354 assert!(!same(paint.hover, paint.background));
1355 }
1356
1357 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 assert_eq!(options[index].size.height, px(48.));
1410 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 for option in &options[1..] {
1420 assert!((option.size.width - options[0].size.width).abs() <= px(0.5));
1421 }
1422 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 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 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 #[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 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 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 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 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 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 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 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 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 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}