1use gpui::{
7 div, prelude::*, px, AnyElement, App, ClickEvent, ElementId, IntoElement, ParentElement,
8 Pixels, RenderOnce, SharedString, Styled, Window,
9};
10use herogpui_core::{element_id, Orientation as SelectionOrientation, SelectionMode, Size};
11use herogpui_theme::ActiveTheme;
12
13use crate::a11y::{self, A11y as _};
14
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub enum ToggleVariant {
22 #[default]
24 Default,
25 Ghost,
27}
28
29#[derive(Clone, Default)]
30struct ToggleGroupFocusState {
31 last_key: Option<SharedString>,
32 was_inside: bool,
33 restore_on_entry: bool,
34 edge_exit: bool,
35}
36
37#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
39#[derive(IntoElement)]
40pub struct ToggleButton {
41 id: ElementId,
42 key: Option<SharedString>,
45 label: Option<SharedString>,
46 content: Option<std::sync::Arc<dyn Fn(crate::util::InteractiveState) -> AnyElement + 'static>>,
49 variant: ToggleVariant,
50 size: Size,
51 size_explicit: bool,
54 is_selected: Option<bool>,
57 default_selected: bool,
58 is_icon_only: bool,
59 group_focus_handle: Option<gpui::FocusHandle>,
62 group_single_selection: bool,
66 group_edge: Option<(crate::button::GroupEdge, bool)>,
70 is_disabled: bool,
71 disabled_explicit: bool,
72 children: Vec<AnyElement>,
73 hover_bg: Option<gpui::Hsla>,
75 radius: Option<Pixels>,
78 on_press: Option<std::sync::Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
81 on_change: Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
84 sx: Option<Box<gpui::StyleRefinement>>,
86}
87
88impl ToggleButton {
89 pub fn key(mut self, key: impl Into<SharedString>) -> Self {
91 self.key = Some(key.into());
92 self
93 }
94
95 pub fn selection_key(&self) -> SharedString {
97 self.key.clone().unwrap_or_else(|| match &self.id {
98 ElementId::Name(name) => name.clone(),
99 other => other.to_string().into(),
100 })
101 }
102
103 pub fn new(id: impl Into<ElementId>) -> Self {
105 Self {
106 content: None,
107 id: id.into(),
108 key: None,
109 label: None,
110 variant: ToggleVariant::Default,
111 size: Size::Md,
112 size_explicit: false,
113 is_selected: None,
114 default_selected: false,
115 is_icon_only: false,
116 group_focus_handle: None,
117 group_single_selection: false,
118 group_edge: None,
119 is_disabled: false,
120 disabled_explicit: false,
121 children: Vec::new(),
122 on_press: None,
123 on_change: None,
124 sx: None,
125 hover_bg: None,
126 radius: None,
127 }
128 }
129
130 pub fn content(
135 mut self,
136 render: impl Fn(crate::util::InteractiveState) -> AnyElement + 'static,
137 ) -> Self {
138 self.content = Some(std::sync::Arc::new(render));
139 self
140 }
141
142 pub fn label(mut self, l: impl Into<SharedString>) -> Self {
144 self.label = Some(l.into());
145 self
146 }
147
148 pub fn variant(mut self, v: ToggleVariant) -> Self {
150 self.variant = v;
151 self
152 }
153
154 pub fn size(mut self, s: Size) -> Self {
156 self.size = s;
157 self.size_explicit = true;
158 self
159 }
160
161 pub fn hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
166 self.hover_bg = Some(color.into());
167 self
168 }
169
170 pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
175 self.radius = Some(radius.into());
176 self
177 }
178
179 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
184 crate::util::refine_sx(&mut self.sx, style);
185 self
186 }
187
188 fn group_size(mut self, size: Size) -> Self {
189 if !self.size_explicit {
190 self.size = size;
191 }
192 self
193 }
194
195 pub fn is_selected(mut self, v: bool) -> Self {
197 self.is_selected = Some(v);
198 self
199 }
200
201 pub fn default_selected(mut self, v: bool) -> Self {
207 self.default_selected = v;
208 self
209 }
210
211 pub fn is_icon_only(mut self, v: bool) -> Self {
213 self.is_icon_only = v;
214 self
215 }
216
217 pub(crate) fn group_edge(mut self, edge: crate::button::GroupEdge, vertical: bool) -> Self {
220 self.group_edge = Some((edge, vertical));
221 self
222 }
223
224 fn group_focus_handle(mut self, handle: gpui::FocusHandle) -> Self {
225 self.group_focus_handle = Some(handle);
226 self
227 }
228
229 fn group_managed(mut self) -> Self {
230 self.on_change = None;
231 self
232 }
233
234 fn group_selection_mode(mut self, mode: SelectionMode) -> Self {
237 self.group_single_selection = mode == SelectionMode::Single;
238 self
239 }
240
241 fn group_on_press(
242 mut self,
243 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
244 ) -> Self {
245 let child = self.on_press.take();
246 self.on_change = None;
247 self.on_press = Some(std::sync::Arc::new(move |event, window, cx| {
248 handler(event, window, cx);
249 if let Some(child) = &child {
250 child(event, window, cx);
251 }
252 }));
253 self
254 }
255
256 pub fn is_disabled(mut self, v: bool) -> Self {
258 self.is_disabled = v;
259 self.disabled_explicit = true;
260 self
261 }
262
263 fn group_disabled(mut self, v: bool) -> Self {
264 if !self.disabled_explicit {
265 self.is_disabled = v;
266 }
267 self
268 }
269
270 pub fn child(mut self, el: impl IntoElement) -> Self {
272 self.children.push(el.into_any_element());
273 self
274 }
275
276 pub fn on_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
280 self.on_change = Some(std::sync::Arc::new(f));
281 self
282 }
283
284 pub fn on_press(mut self, f: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
286 self.on_press = Some(std::sync::Arc::new(f));
287 self
288 }
289}
290
291impl ParentElement for ToggleButton {
292 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
293 self.children.extend(elements);
294 }
295}
296
297impl RenderOnce for ToggleButton {
298 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
299 let (is_selected, own) = crate::util::controlled(
301 window,
302 cx,
303 element_id::scoped(&self.id, "selected"),
304 self.is_selected,
305 self.default_selected,
306 );
307
308 let focus_handle = self.group_focus_handle.clone().unwrap_or_else(|| {
310 crate::util::tab_stop_handle(element_id::scoped(&self.id, "focus"), window, cx)
311 });
312 let interaction = self.content.as_ref().map(|_| {
314 crate::util::interaction(element_id::scoped(&self.id, "interaction"), window, cx)
315 });
316 let colors = cx.colors().clone();
317 let sem = colors.accent;
318 let disabled_opacity = cx.layout().disabled_opacity;
319 let radius = self
320 .radius
321 .unwrap_or_else(|| crate::util::control_radius(cx));
322 let is_grouped = self.group_edge.is_some();
323
324 let sx_background = crate::util::sx_background(&self.sx);
325 let sx_corners = crate::util::sx_radius(&self.sx);
326 let fade = (!self.is_disabled)
327 .then(|| {
328 let idle = if is_selected {
329 sem.soft()
330 } else {
331 match self.variant {
332 ToggleVariant::Default => colors.default.color,
333 ToggleVariant::Ghost => gpui::transparent_black(),
334 }
335 };
336 let hover = if is_selected {
337 colors.accent.soft_hover()
338 } else {
339 match self.variant {
340 ToggleVariant::Default => colors.default.hover(),
341 ToggleVariant::Ghost => colors.default.color,
342 }
343 };
344 (idle, hover)
345 })
346 .and_then(|pair| crate::util::fade_endpoints(Some(pair), sx_background, self.hover_bg));
347
348 let single = self.group_single_selection;
352 let name = a11y::Name::maybe(self.label.clone());
353 let mut el = div()
354 .id(self.id.clone())
355 .map(|e| {
356 if single {
357 e.a11y_named(a11y::Role::RadioButton, &name)
358 .a11y_checked(is_selected, false)
359 } else {
360 e.a11y_named(a11y::Role::Button, &name)
361 .a11y_pressed(is_selected)
362 }
363 })
364 .flex()
365 .items_center()
366 .justify_center()
367 .whitespace_nowrap()
374 .flex_shrink_0()
375 .font_weight(gpui::FontWeight::MEDIUM)
376 .when(is_selected, |e| {
377 let e = if fade.is_none() { e.bg(sem.soft()) } else { e };
378 e.text_color(sem.soft_foreground(colors.foreground))
379 })
380 .when(!is_selected, |e| match self.variant {
381 ToggleVariant::Default => {
382 let e = if fade.is_none() {
383 e.bg(colors.default.color)
384 } else {
385 e
386 };
387 e.text_color(colors.foreground)
388 }
389 ToggleVariant::Ghost => {
390 let e = if fade.is_none() {
391 e.bg(gpui::transparent_black())
392 } else {
393 e
394 };
395 e.text_color(colors.default.foreground)
396 }
397 });
398
399 let (height, pad_x, gap, press_scale) = match self.size {
405 Size::Sm => (px(32.), px(12.), px(8.), crate::anim::PRESSED_SCALE_SUBTLE),
406 Size::Md => (px(36.), px(16.), px(8.), crate::anim::PRESSED_SCALE),
407 Size::Lg => (px(40.), px(16.), px(8.), crate::anim::PRESSED_SCALE_FIRM),
408 };
409 let (text, line) = match self.size {
410 Size::Sm | Size::Md => (px(14.), px(20.)),
411 Size::Lg => (px(16.), px(24.)),
412 };
413 el = el.h(height).text_size(text).line_height(line);
414 el = if self.is_icon_only {
415 el.w(height)
416 } else {
417 el.px(pad_x).gap(gap)
418 };
419
420 el = crate::button::group_radius_any(el, self.group_edge, radius);
421 el = crate::util::round_sx_corners(el, &sx_corners);
422
423 if let Some(colors) = fade {
424 let edge = self.group_edge;
425 el = crate::anim::hover_fade(
426 el,
427 element_id::scoped(&self.id, "fade"),
428 colors,
429 interaction.as_ref(),
430 None,
431 move |fill| {
432 crate::util::round_sx_corners(
433 crate::button::group_radius_any(fill, edge, radius),
434 &sx_corners,
435 )
436 },
437 window,
438 cx,
439 );
440 }
441
442 if self.is_disabled {
443 el = el.opacity(disabled_opacity);
444 }
445
446 if let Some(render) = self.content.clone() {
447 let (is_hovered, is_pressed) = interaction
448 .as_ref()
449 .map(|slot| *slot.read(cx))
450 .unwrap_or_default();
451 let focused = focus_handle.is_focused(window);
452 el = el.child(render(crate::util::InteractiveState {
453 is_hovered,
454 is_pressed,
455 is_focused: focused,
456 is_focus_visible: focused && crate::util::focus_visible(cx),
457 is_selected,
458 is_disabled: self.is_disabled,
459 is_pending: false,
460 is_indeterminate: false,
461 }));
462 } else if let Some(label) = self.label {
463 el = el.child(label.to_string());
464 }
465 el = el.children(self.children);
469
470 if !self.is_disabled {
474 let hover_bg = if is_selected {
475 colors.accent.soft_hover()
476 } else {
477 match self.variant {
478 ToggleVariant::Default => colors.default.hover(),
479 ToggleVariant::Ghost => colors.default.color,
480 }
481 };
482 el = crate::util::cursor_interactive(el, cx);
483 if fade.is_none() {
484 el = el.hover(move |s| s.bg(hover_bg));
485 }
486 let press_endpoints = fade.map(|(idle, _)| (idle, hover_bg));
496 if is_grouped {
497 el = el.active(move |style| style.bg(hover_bg));
498 } else {
499 el = crate::anim::pressed_with_background_ramp(
500 el,
501 crate::anim::PressBox {
502 height,
503 padding_x: (!self.is_icon_only).then_some(pad_x),
504 width: self.is_icon_only.then_some(height),
505 min_width: None,
506 text_size: text,
507 line_height: line,
508 gap,
509 radius,
510 shrink_x: true,
511 scale: press_scale,
512 },
513 press_endpoints,
514 crate::anim::BUTTON_PRESS,
515 interaction.as_ref(),
516 window,
517 cx,
518 );
519 }
520 }
521
522 if let Some(slot) = &interaction {
523 el = crate::util::track_interaction(el, slot);
524 }
525
526 if !self.is_disabled
527 && (self.on_press.is_some() || self.on_change.is_some() || own.is_some())
528 {
529 let on_press = self.on_press;
530 let on_change = self.on_change;
531 let next = !is_selected;
532 el = el.on_click(move |ev, w, cx| {
533 if let Some(held) = &own {
536 held.update(cx, |v, cx| {
537 *v = next;
538 cx.notify();
539 });
540 }
541 if let Some(cb) = &on_change {
542 cb(&next, w, cx);
543 }
544 if let Some(cb) = &on_press {
545 cb(ev, w, cx);
546 }
547 });
548 }
549
550 if self.is_disabled {
551 return crate::util::apply_sx(el, &self.sx);
552 }
553 let el = match crate::button::uniform_ring_radius(self.group_edge, radius, &sx_corners) {
561 Some(ring_radius) => crate::util::ring_overlay_if_focused(
562 el.track_focus(&focus_handle),
563 &focus_handle,
564 !is_grouped,
565 ring_radius,
566 Vec::new(),
567 window,
568 cx,
569 ),
570 None => crate::util::ring_if_focused(
571 el.track_focus(&focus_handle),
572 &focus_handle,
573 !is_grouped,
574 Vec::new(),
575 window,
576 cx,
577 ),
578 };
579 let el = crate::util::apply_sx(el, &self.sx);
580 crate::util::record_focus_bounds(el, &focus_handle, window, cx)
581 }
582}
583
584#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
590#[derive(IntoElement)]
591pub struct ToggleButtonGroup {
592 id: ElementId,
593 selected: Vec<SharedString>,
594 default_selected: Vec<SharedString>,
595 is_controlled: bool,
597 selection_mode: SelectionMode,
598 size: Size,
599 is_disabled: bool,
600 is_detached: bool,
601 separators: bool,
604 is_vertical: bool,
605 disallow_empty_selection: bool,
606 full_width: bool,
607 children: Vec<ToggleButton>,
608 on_change: Option<std::sync::Arc<dyn Fn(&[SharedString], &mut Window, &mut App) + 'static>>,
609 sx: Option<Box<gpui::StyleRefinement>>,
611}
612
613impl ToggleButtonGroup {
614 pub fn orientation(mut self, orientation: SelectionOrientation) -> Self {
616 self.is_vertical = orientation == SelectionOrientation::Vertical;
617 self
618 }
619
620 pub fn disallow_empty_selection(mut self, v: bool) -> Self {
622 self.disallow_empty_selection = v;
623 self
624 }
625
626 pub fn on_selection_change(
628 self,
629 handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
630 ) -> Self {
631 self.on_change(handler)
632 }
633
634 pub fn new(id: impl Into<ElementId>) -> Self {
636 Self {
637 id: id.into(),
638 selected: Vec::new(),
639 default_selected: Vec::new(),
640 is_controlled: false,
641 selection_mode: SelectionMode::Single,
642 size: Size::Md,
643 is_disabled: false,
644 is_detached: false,
645 separators: false,
646 is_vertical: false,
647 disallow_empty_selection: false,
648 full_width: false,
649 children: Vec::new(),
650 on_change: None,
651 sx: None,
652 }
653 }
654
655 pub fn selection_mode(mut self, m: SelectionMode) -> Self {
657 self.selection_mode = m;
658 self
659 }
660
661 pub fn size(mut self, size: Size) -> Self {
663 self.size = size;
664 self
665 }
666
667 pub fn is_disabled(mut self, v: bool) -> Self {
669 self.is_disabled = v;
670 self
671 }
672
673 pub fn separators(mut self, v: bool) -> Self {
675 self.separators = v;
676 self
677 }
678
679 pub fn is_detached(mut self, v: bool) -> Self {
681 self.is_detached = v;
682 self
683 }
684
685 pub fn full_width(mut self, v: bool) -> Self {
687 self.full_width = v;
688 self
689 }
690
691 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
697 crate::util::refine_sx(&mut self.sx, style);
698 self
699 }
700
701 pub fn selected_keys(
703 mut self,
704 keys: impl IntoIterator<Item = impl Into<SharedString>>,
705 ) -> Self {
706 self.selected = keys.into_iter().map(Into::into).collect();
707 self.is_controlled = true;
708 self
709 }
710
711 pub fn default_selected_keys(
713 mut self,
714 keys: impl IntoIterator<Item = impl Into<SharedString>>,
715 ) -> Self {
716 self.default_selected = keys.into_iter().map(Into::into).collect();
717 self
718 }
719
720 pub fn on_change(
722 mut self,
723 f: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
724 ) -> Self {
725 self.on_change = Some(std::sync::Arc::new(f));
726 self
727 }
728
729 pub fn child_toggle(mut self, btn: ToggleButton) -> Self {
731 self.children.push(btn);
732 self
733 }
734}
735
736impl RenderOnce for ToggleButtonGroup {
737 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
738 let (selected, selection_own) = crate::util::controlled(
739 window,
740 cx,
741 element_id::scoped(&self.id, "selected"),
742 self.is_controlled.then_some(self.selected),
743 self.default_selected,
744 );
745 let gap = if self.is_detached { px(4.) } else { px(0.) };
749 let orientation = if self.is_vertical {
753 SelectionOrientation::Vertical
754 } else {
755 SelectionOrientation::Horizontal
756 };
757 let group_role = if self.selection_mode == SelectionMode::Single {
758 a11y::Role::RadioGroup
759 } else {
760 a11y::Role::Toolbar
761 };
762 let mut row = div()
763 .id(self.id.clone())
764 .a11y(group_role)
765 .a11y_orientation(orientation)
766 .flex()
767 .items_center()
768 .justify_center()
769 .when(self.is_vertical, |r| r.flex_col())
770 .when(!self.is_vertical, |r| r.flex_row())
771 .gap(gap)
772 .when(self.full_width, |r| r.w_full());
773 let total = self.children.len();
774 let separators = self.separators && !self.is_detached;
775 let is_vertical = self.is_vertical;
776 let full_width = self.full_width;
777 let separator_color = cx.colors().foreground.alpha(0.15);
782 let separator_radius = crate::util::hairline_radius(cx);
783
784 let mode = self.selection_mode;
785 let disallow_empty = self.disallow_empty_selection;
786
787 let mut children = self
788 .children
789 .into_iter()
790 .map(|button| {
791 button
792 .group_managed()
793 .group_selection_mode(mode)
794 .group_disabled(self.is_disabled)
795 .group_size(self.size)
796 })
797 .collect::<Vec<_>>();
798 let members = children
799 .iter()
800 .filter(|button| !button.is_disabled)
801 .map(|button| {
802 (
803 button.selection_key(),
804 crate::util::tab_stop_handle(
805 element_id::scoped(
806 &element_id::scoped(
807 &element_id::scoped(&self.id, "member"),
808 format!("{:?}", button.id),
809 ),
810 "focus",
811 ),
812 window,
813 cx,
814 ),
815 )
816 })
817 .collect::<Vec<_>>();
818 let focus_state =
819 window.use_keyed_state(element_id::scoped(&self.id, "focus-state"), cx, |_, _| {
820 ToggleGroupFocusState::default()
821 });
822 let current = members
823 .iter()
824 .position(|(_, handle)| handle.is_focused(window));
825 let snapshot = focus_state.read(cx).clone();
826 if let Some(current) = current {
827 let mut effective = current;
828 if !snapshot.was_inside && snapshot.restore_on_entry {
829 if let Some(last) = &snapshot.last_key {
830 if let Some(restored) = members.iter().position(|(key, _)| key == last) {
831 effective = restored;
832 if restored != current {
833 window.focus(&members[restored].1, cx);
834 }
835 }
836 }
837 }
838 let key = members[effective].0.clone();
839 focus_state.update(cx, |state, _| {
840 state.last_key = Some(key);
841 state.was_inside = true;
842 state.restore_on_entry = false;
843 state.edge_exit = false;
844 });
845 } else if snapshot.was_inside {
846 focus_state.update(cx, |state, _| {
847 state.was_inside = false;
848 state.restore_on_entry = !state.edge_exit;
849 state.edge_exit = false;
850 });
851 } else if snapshot.edge_exit {
852 focus_state.update(cx, |state, _| state.edge_exit = false);
853 }
854
855 let vertical = self.is_vertical;
856 let key_focuses = members
857 .iter()
858 .map(|(_, handle)| handle.clone())
859 .collect::<Vec<_>>();
860 let key_focus_state = focus_state.clone();
861 row = row.on_key_down(move |event: &gpui::KeyDownEvent, window, cx| {
862 let movement = match (vertical, event.keystroke.key.as_str()) {
863 (false, "right") | (true, "down") => Some("next"),
864 (false, "left") | (true, "up") => Some("prev"),
865 _ => None,
866 };
867
868 if let Some(movement) = movement {
869 if window
873 .context_stack()
874 .iter()
875 .any(|context| context.contains("Toolbar"))
876 {
877 key_focus_state.update(cx, |state, _| state.edge_exit = true);
878 window.refresh();
879 return;
880 }
881 cx.stop_propagation();
885 let Some(index) = key_focuses
886 .iter()
887 .position(|handle| handle.is_focused(window))
888 else {
889 return;
890 };
891 let next = if movement == "next" {
892 index
893 .checked_add(1)
894 .filter(|next| *next < key_focuses.len())
895 } else {
896 index.checked_sub(1)
897 };
898 if let Some(next) = next {
899 window.focus(&key_focuses[next], cx);
900 }
901 return;
902 }
903
904 if matches!(
905 event.keystroke.key.as_str(),
906 "up" | "down" | "left" | "right"
907 ) {
908 key_focus_state.update(cx, |state, _| state.edge_exit = true);
911 window.refresh();
912 return;
913 }
914
915 if event.keystroke.key != "tab" {
916 return;
917 }
918 let edge = if event.keystroke.modifiers.shift {
922 key_focuses.first()
923 } else {
924 key_focuses.last()
925 };
926 if let Some(edge) = edge {
927 window.focus(edge, cx);
928 }
929 });
930
931 let mut member_focuses = members.into_iter().map(|(_, handle)| handle);
932 for (i, btn) in children.drain(..).enumerate() {
933 let key = btn.selection_key();
936 let is_selected = selected.iter().any(|k| k == &key);
937 let mut btn = if btn.is_disabled {
938 btn.is_selected(is_selected)
939 } else {
940 btn.group_focus_handle(
941 member_focuses
942 .next()
943 .expect("every enabled toggle has a group focus handle"),
944 )
945 .is_selected(is_selected)
946 };
947
948 if self.on_change.is_some() || selection_own.is_some() {
949 let on_change = self.on_change.clone();
950 let own = selection_own.clone();
951 let current = selected.clone();
952 let key = key.clone();
953 btn = btn.group_on_press(move |_, window, cx| {
954 let next =
955 crate::selection::next_selection(¤t, &key, mode, disallow_empty);
956 if let Some(held) = &own {
957 let held_next = next.clone();
958 held.update(cx, |value, cx| {
959 *value = held_next;
960 cx.notify();
961 });
962 }
963 if let Some(change) = &on_change {
964 change(&next, window, cx);
965 }
966 });
967 }
968
969 let edge = if self.is_detached || total <= 1 {
972 crate::button::GroupEdge::Only
973 } else if i == 0 {
974 crate::button::GroupEdge::Start
975 } else if i + 1 == total {
976 crate::button::GroupEdge::End
977 } else {
978 crate::button::GroupEdge::Middle
979 };
980 let mut slot = div()
981 .relative()
982 .child(btn.group_edge(edge, is_vertical))
983 .when(full_width, |sl| sl.flex_1());
984 if separators && i > 0 {
985 slot = slot.child(
986 div()
987 .absolute()
988 .bg(separator_color)
989 .rounded(separator_radius)
990 .map(|sep| {
991 if is_vertical {
992 sep.left(gpui::relative(0.25))
993 .top(px(-1.))
994 .w(gpui::relative(0.5))
995 .h(px(1.))
996 } else {
997 sep.left(px(-1.))
998 .top(gpui::relative(0.25))
999 .w(px(1.))
1000 .h(gpui::relative(0.5))
1001 }
1002 }),
1003 );
1004 }
1005 row = row.child(slot);
1006 }
1007
1008 row = crate::util::apply_sx(row, &self.sx);
1009 row
1010 }
1011}
1012
1013#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
1015#[derive(IntoElement)]
1016pub struct ToggleSeparator;
1017
1018impl RenderOnce for ToggleSeparator {
1019 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
1020 div().w(px(1.)).h(px(20.)).bg(cx.colors().separator)
1021 }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use super::*;
1027
1028 #[test]
1029 fn selection_key_defaults_to_the_element_id() {
1030 let plain = ToggleButton::new("bold");
1033 assert_eq!(plain.selection_key().as_ref(), "bold");
1034 let keyed = ToggleButton::new("grp-bold").key("bold");
1035 assert_eq!(keyed.selection_key().as_ref(), "bold");
1036 }
1037
1038 #[test]
1039 fn group_separators_are_explicit_composition() {
1040 assert!(
1041 !ToggleButtonGroup::new("plain").separators,
1042 "v3 groups without a Separator child must not synthesize dividers"
1043 );
1044 assert!(
1045 ToggleButtonGroup::new("separated")
1046 .separators(true)
1047 .separators
1048 );
1049 }
1050}
1051
1052crate::util::impl_component_styled!(ToggleButton, ToggleButtonGroup);