Skip to main content

herogpui_components/
toggle_button.rs

1//! ToggleButton & ToggleButtonGroup — port of `@heroui/toggle-button` v3.
2//!
3//! Toggle between selected/unselected. Supports all button variants, sizes,
4//! icon-only, controlled `isSelected` and group selection modes.
5
6use 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// ---------------------------------------------------------------------------
16// ToggleButton
17// ---------------------------------------------------------------------------
18
19/// Visual variant of a toggle button.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub enum ToggleVariant {
22    /// Filled when unselected (the default).
23    #[default]
24    Default,
25    /// Transparent when unselected.
26    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/// A button that toggles between selected and unselected.
38#[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    /// Selection key inside a group. Defaults to the element id, so a group can
43    /// namespace its ids without breaking selection.
44    key: Option<SharedString>,
45    label: Option<SharedString>,
46    /// v3's `children`-as-a-function: handed the interactive state, `isSelected`
47    /// included, and drawn in place of the label.
48    content: Option<std::sync::Arc<dyn Fn(crate::util::InteractiveState) -> AnyElement + 'static>>,
49    variant: ToggleVariant,
50    size: Size,
51    /// Whether the child explicitly set `size`. HeroUI's group context only
52    /// supplies a size when the child did not override it.
53    size_explicit: bool,
54    /// `isSelected` — `None` leaves the button holding the state, seeded from
55    /// `defaultSelected`.
56    is_selected: Option<bool>,
57    default_selected: bool,
58    is_icon_only: bool,
59    /// Supplied by a toggle group so it can navigate its typed children
60    /// without falling through to the window-wide tab order.
61    group_focus_handle: Option<gpui::FocusHandle>,
62    /// Set by [`ToggleButtonGroup`] when it selects one member at a time.
63    /// `useToggleButtonGroupItem` swaps the member's role from `button` to
64    /// `radio` and its `aria-pressed` for `aria-checked` in exactly that case.
65    group_single_selection: bool,
66    /// Set by [`ToggleButtonGroup`]: which end of the group this member is,
67    /// and whether the group stacks. `.toggle-button-group .toggle-button` is
68    /// `rounded-none` with the outer radius on the first and last member.
69    group_edge: Option<(crate::button::GroupEdge, bool)>,
70    is_disabled: bool,
71    disabled_explicit: bool,
72    children: Vec<AnyElement>,
73    /// Set by [`ToggleButton::hover_bg`]: the fill the hover fade eases *to*.
74    hover_bg: Option<gpui::Hsla>,
75    /// The corner radius, in place of the owning helper. Group edges, the
76    /// hover fade and the press scale still apply.
77    radius: Option<Pixels>,
78    /// `Arc` for the same reason as `on_change`: the pointer and the keyboard
79    /// each hold it.
80    on_press: Option<std::sync::Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
81    /// `Arc` rather than `Box`: the handler is bound twice, once for the
82    /// pointer and once for Enter and Space.
83    on_change: Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
84    /// The `sx` slot, refined over the root style at the end of render.
85    sx: Option<Box<gpui::StyleRefinement>>,
86}
87
88impl ToggleButton {
89    /// Overrides the selection key, which otherwise mirrors the element id.
90    pub fn key(mut self, key: impl Into<SharedString>) -> Self {
91        self.key = Some(key.into());
92        self
93    }
94
95    /// The member's key inside a [`ToggleButtonGroup`].
96    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    /// Creates a toggle button with the given element id.
104    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    /// v3's render function for the button's children, handed `isHovered`,
131    /// `isPressed`, `isFocused`, `isFocusVisible` and `isSelected`. The hover and
132    /// the press are a frame behind the pointer -- gpui reports both to a
133    /// handler, not to the render that draws them.
134    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    /// Sets the text label.
143    pub fn label(mut self, l: impl Into<SharedString>) -> Self {
144        self.label = Some(l.into());
145        self
146    }
147
148    /// Sets the variant.
149    pub fn variant(mut self, v: ToggleVariant) -> Self {
150        self.variant = v;
151        self
152    }
153
154    /// Sets the size; a size set here takes precedence over one inherited from a group.
155    pub fn size(mut self, s: Size) -> Self {
156        self.size = s;
157        self.size_explicit = true;
158        self
159    }
160
161    /// The fill the hover fade eases to, in place of the variant's hover
162    /// colour. The fade runs from the resting background — the `sx` background
163    /// when one is set, the variant's resting colour otherwise — so this is the
164    /// Button contract, on a toggle. v3 has no such prop.
165    pub fn hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
166        self.hover_bg = Some(color.into());
167        self
168    }
169
170    /// The corner radius, in place of the owning `control_radius` helper.
171    /// Group edges, the hover fade and the press scale still apply. Not a v3
172    /// prop; the removed v2 `radius` prop is prohibited and this is a
173    /// per-component repository extension.
174    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
175        self.radius = Some(radius.into());
176        self
177    }
178
179    /// The one slot for caller-owned low-level styling: GPUI's styling methods
180    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
181    /// applied to the button's root element after every value the variant, the
182    /// size and the active theme chose, so they win.
183    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    /// Sets the selected state (controlled).
196    pub fn is_selected(mut self, v: bool) -> Self {
197        self.is_selected = Some(v);
198        self
199    }
200
201    /// `defaultSelected` — the uncontrolled initial state.
202    ///
203    /// Only consulted when `isSelected` is not supplied; the button then owns
204    /// the state and toggles itself. A group always drives selection, so this
205    /// is for a standalone toggle.
206    pub fn default_selected(mut self, v: bool) -> Self {
207        self.default_selected = v;
208        self
209    }
210
211    /// Sets whether the button is icon-only.
212    pub fn is_icon_only(mut self, v: bool) -> Self {
213        self.is_icon_only = v;
214        self
215    }
216
217    /// Joins this toggle to a group edge. Internal: a caller reaches it by
218    /// putting the toggle in a [`ToggleButtonGroup`].
219    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    /// `useToggleButtonGroupItem` reads the group's selection mode, so the
235    /// member has to be told which one it is in.
236    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    /// Sets whether the button is disabled; takes precedence over the group's disabled state.
257    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    /// Appends a child element.
271    pub fn child(mut self, el: impl IntoElement) -> Self {
272        self.children.push(el.into_any_element());
273        self
274    }
275
276    /// `onChange` — reports the selection the press moves to.
277    ///
278    /// Fires alongside [`ToggleButton::on_press`]; use whichever shape suits.
279    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    /// Sets the handler called when the button is pressed.
285    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        // `controlled` takes `cx` mutably, so it precedes the theme tokens.
300        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        // `.toggle-button:focus-visible` is `status-focused`.
309        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        // Where the hover and press a `content` closure is handed come from.
313        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        // `useToggleButton` is `useButton` plus `aria-pressed`. Inside a
349        // single-selection group, `useToggleButtonGroupItem` overwrites both:
350        // `role = 'radio'`, `aria-checked`, and `delete aria-pressed`.
351        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            // `.toggle-button` is not `overflow-hidden` -- `Button`, whose box
368            // this one mirrors, is not either. The clip that used to be here
369            // only kept the hover fade's fill inside the rounded box on the
370            // patched renderer; the fill carries the same `group_radius_any`
371            // corners as the box now, and dropping the clip is what lets the
372            // focus ring below be the overlay, which hangs outside the box.
373            .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        // sizing — kept in locals so the press geometry below scales exactly
400        // what was applied here.
401        // `.toggle-button` is `h-10 md:h-9` with `--sm` at `h-9 md:h-8` and
402        // `--lg` at `h-11 md:h-10`: 32 / 36 / 40 on a desktop, the same pair as
403        // `.button`. This had them a step too tall.
404        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        // The interaction tracking belongs on the press slot, like Button:
466        // key events dispatch along the focus path, which runs through the
467        // slot the focus handle below tracks.
468        el = el.children(self.children);
469
470        // The press wrap (or the disabled dimming, which must cover the label
471        // above the skin too) comes after every visual child: children added
472        // after `pressed` land on the slot and fight the skin for width.
473        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            // v3 documents ToggleButton's pressed state as including the same
487            // size-specific scale. Group members suppress it so the attached
488            // control never opens gaps between buttons while pressed.
489            //
490            // Standalone, the press rides `pressed_with_background_ramp`:
491            // `toggle-button.css` declares the press as a transition
492            // (`transform 250ms var(--ease-smooth), background-color 100ms
493            // var(--ease-out)`), so the fill eases between the resting
494            // colour the hover fade holds and the pressed endpoint.
495            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                // Uncontrolled: flip our own copy, or a standalone toggle could
534                // never change.
535                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        // The ring is the overlay form wherever the four corners resolve to
554        // one radius, as `Button` does it: an overlay is crisp and concentric
555        // where a spread shadow keeps the element's own corner. A grouped
556        // `Start` or `End` member rounds only its outer edge and an `sx`
557        // refinement can break the symmetry of any member, and the overlay's
558        // outer band is built from a scalar, so those keep the shadow ring,
559        // which dilates whatever per-corner shape the element already has.
560        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// ---------------------------------------------------------------------------
585// ToggleButtonGroup
586// ---------------------------------------------------------------------------
587
588/// A group of toggle buttons that share selection.
589#[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    /// `Vec` alone cannot distinguish controlled empty from uncontrolled.
596    is_controlled: bool,
597    selection_mode: SelectionMode,
598    size: Size,
599    is_disabled: bool,
600    is_detached: bool,
601    /// Whether a `ToggleButtonGroup.Separator` sits before each member after
602    /// the first. v3 composes it as a child, and hides it when detached.
603    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    /// The `sx` slot, refined over the root style at the end of render.
610    sx: Option<Box<gpui::StyleRefinement>>,
611}
612
613impl ToggleButtonGroup {
614    /// `orientation` — lays the group out along the given axis.
615    pub fn orientation(mut self, orientation: SelectionOrientation) -> Self {
616        self.is_vertical = orientation == SelectionOrientation::Vertical;
617        self
618    }
619
620    /// `disallowEmptySelection` — keeps at least one member selected.
621    pub fn disallow_empty_selection(mut self, v: bool) -> Self {
622        self.disallow_empty_selection = v;
623        self
624    }
625
626    /// `onSelectionChange` — the v3 name for [`ToggleButtonGroup::on_change`].
627    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    /// Creates an empty group with the given element id.
635    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    /// Sets whether one or several buttons can be selected.
656    pub fn selection_mode(mut self, m: SelectionMode) -> Self {
657        self.selection_mode = m;
658        self
659    }
660
661    /// `size` — inherited by children that do not set their own size.
662    pub fn size(mut self, size: Size) -> Self {
663        self.size = size;
664        self
665    }
666
667    /// `isDisabled` — disables every child, matching React Aria's group state.
668    pub fn is_disabled(mut self, v: bool) -> Self {
669        self.is_disabled = v;
670        self
671    }
672
673    /// `ToggleButtonGroup.Separator` — the hairline between members.
674    pub fn separators(mut self, v: bool) -> Self {
675        self.separators = v;
676        self
677    }
678
679    /// Sets whether members are spaced apart instead of joined (detached members have a 4px gap and no separators).
680    pub fn is_detached(mut self, v: bool) -> Self {
681        self.is_detached = v;
682        self
683    }
684
685    /// Sets whether the group fills the width of its parent.
686    pub fn full_width(mut self, v: bool) -> Self {
687        self.full_width = v;
688        self
689    }
690
691    /// The one slot for caller-owned low-level styling: GPUI's styling methods
692    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
693    /// applied to the group's root element after every value the orientation and
694    /// the active theme chose, so they win. It restyles the row the members sit
695    /// in; each member keeps its own `sx` slot.
696    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    /// Sets the selected keys (controlled).
702    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    /// `defaultSelectedKeys` — seeds the group's own selection state.
712    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    /// Sets the handler called with the selected keys when the selection changes.
721    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    /// Appends a toggle button to the group.
730    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        // `.toggle-button-group` is `inline-flex items-center justify-center
746        // gap-0`; `--detached` is `gap-1` and restores each member's full
747        // radius.
748        let gap = if self.is_detached { px(4.) } else { px(0.) };
749        // `useToggleButtonGroup` starts from `useToolbar` — whose role is
750        // `toolbar` and which always reports `aria-orientation` — and replaces
751        // the role with `radiogroup` when the group selects one member.
752        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        // `.toggle-button-group__separator` is `bg-current opacity-15`, 1px by
778        // half the member, one pixel before its leading edge. This used to be a
779        // 20px line in `--separator` with a 2px margin, which is neither the
780        // colour nor the geometry v3 draws.
781        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                // Pinned `useToolbar` omits the nested group's handler when an
870                // ancestor toolbar owns the arrows. GPUI bubbles key handlers,
871                // so mark the exit and let that ancestor handle this key.
872                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                // Cross-axis arrows return above unconsumed. The group owns
882                // every key on its own axis, including one that cannot move at
883                // an edge, matching the pinned `useToolbar` handler.
884                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                // A cross-axis arrow may belong to an enclosing toolbar. If it
909                // moves focus out, do not restore the inner group's last item.
910                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            // `useToolbar` moves to the edge and deliberately leaves Tab
919            // unconsumed. The app root then performs its ordinary one step,
920            // which exits the whole group instead of walking another member.
921            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            // Reflect the group's selection into the child, and let the child
934            // report the next selection back through the group's callback.
935            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(&current, &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            // The edge decides which corners stay round, so it has to reach the
970            // `ToggleButton` before it becomes an element.
971            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/// Separator element for ToggleButtonGroup — visual divider.
1014#[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        // A group namespaces its child ids, so selection has to fall back to
1031        // the id when no explicit key is given.
1032        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);