Skip to main content

gpui_kit/controls/
button.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, Div, FocusHandle, Hsla, InteractiveElement, IntoElement, ParentElement,
5    RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
6    prelude::FluentBuilder, px,
7};
8use gpui_kit_assets::{Icon, icon};
9use gpui_kit_semantics::{NodeSpec, Role, Semantic};
10use gpui_kit_theme::{ActiveTheme, ControlMetrics, ControlSize, Radius, Theme, TypeScale};
11
12use crate::foundation::direction::{ActiveDirection, DirectionalExt, LayoutDirection};
13use crate::foundation::{
14    Disableable, FocusRing, Ident, Pressable, Selectable, Sizable, StyledExt,
15    text as foundation_text,
16};
17
18/// How much weight an action carries. Primary is the one decision a local
19/// area is asking for; Danger is reserved for irreversible intent.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum ButtonVariant {
22    #[default]
23    Primary,
24    Secondary,
25    Ghost,
26    Danger,
27    Link,
28}
29
30/// Which side of the label the glyph sits on.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum IconPosition {
33    #[default]
34    Leading,
35    Trailing,
36}
37
38/// Where a button sits in a joined run of them.
39///
40/// A joined button gives up the radius on the side it touches its neighbour
41/// and overlaps its border, so the run reads as one frame.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum ButtonJoin {
44    #[default]
45    Alone,
46    Leading,
47    Middle,
48    Trailing,
49}
50
51type ClickHandler = Rc<dyn Fn(&mut Window, &mut App)>;
52
53/// A labeled action.
54///
55/// The click handler is only installed when the button is enabled and not
56/// loading, so an unavailable action cannot fire through a stray event.
57#[derive(IntoElement)]
58pub struct Button {
59    ident: Ident,
60    semantic_parent: Option<SharedString>,
61    focus_handle: Option<FocusHandle>,
62    label: Option<SharedString>,
63    /// What the button is called when the label is not what it is called, or
64    /// when there is no label at all.
65    name: Option<SharedString>,
66    description: Option<SharedString>,
67    glyph: Option<Icon>,
68    icon_position: IconPosition,
69    variant: ButtonVariant,
70    size: ControlSize,
71    disabled: bool,
72    selected: bool,
73    checked: Option<bool>,
74    loading: bool,
75    full_width: bool,
76    icon_only: bool,
77    join: ButtonJoin,
78    on_click: Option<ClickHandler>,
79}
80
81impl std::fmt::Debug for Button {
82    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        formatter
84            .debug_struct("Button")
85            .field("ident", &self.ident)
86            .field("label", &self.label)
87            .field("variant", &self.variant)
88            .field("size", &self.size)
89            .field("disabled", &self.disabled)
90            .field("selected", &self.selected)
91            .field("loading", &self.loading)
92            .field("has_handler", &self.on_click.is_some())
93            .finish()
94    }
95}
96
97impl Button {
98    pub fn new(ident: impl Into<Ident>) -> Self {
99        Self {
100            ident: ident.into(),
101            semantic_parent: None,
102            focus_handle: None,
103            label: None,
104            name: None,
105            description: None,
106            glyph: None,
107            icon_position: IconPosition::Leading,
108            variant: ButtonVariant::default(),
109            size: ControlSize::default(),
110            disabled: false,
111            selected: false,
112            checked: None,
113            loading: false,
114            full_width: false,
115            icon_only: false,
116            join: ButtonJoin::Alone,
117            on_click: None,
118        }
119    }
120
121    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
122        self.label = Some(label.into());
123        self
124    }
125
126    /// What assistive technology and a test call this action.
127    ///
128    /// Overrides the label, which a graphic button does not have.
129    pub fn accessible_name(mut self, name: impl Into<SharedString>) -> Self {
130        self.name = Some(name.into());
131        self
132    }
133
134    /// Adds supplementary literal help to the native button node.
135    pub fn accessible_description(mut self, description: impl Into<SharedString>) -> Self {
136        self.description = Some(description.into());
137        self
138    }
139
140    /// Draws the button as a square carrying only its glyph, and names it.
141    ///
142    /// The name is required rather than optional because a glyph on its own
143    /// is an action nobody can announce or address.
144    pub fn icon_only(mut self, glyph: Icon, name: impl Into<SharedString>) -> Self {
145        self.glyph = Some(glyph);
146        self.label = None;
147        self.icon_only = true;
148        self.name = Some(name.into());
149        self
150    }
151
152    /// Places the button in a joined run.
153    pub fn join(mut self, join: ButtonJoin) -> Self {
154        self.join = join;
155        self
156    }
157
158    /// Names the surface this action belongs to in the semantic tree, so a
159    /// reader can tell which notification or row an action came from.
160    pub fn semantic_parent(mut self, parent: impl Into<SharedString>) -> Self {
161        self.semantic_parent = Some(parent.into());
162        self
163    }
164
165    pub fn icon(mut self, glyph: Icon) -> Self {
166        self.glyph = Some(glyph);
167        self
168    }
169
170    pub fn icon_position(mut self, position: IconPosition) -> Self {
171        self.icon_position = position;
172        self
173    }
174
175    pub fn variant(mut self, variant: ButtonVariant) -> Self {
176        self.variant = variant;
177        self
178    }
179
180    pub fn primary(self) -> Self {
181        self.variant(ButtonVariant::Primary)
182    }
183
184    pub fn secondary(self) -> Self {
185        self.variant(ButtonVariant::Secondary)
186    }
187
188    pub fn ghost(self) -> Self {
189        self.variant(ButtonVariant::Ghost)
190    }
191
192    pub fn danger(self) -> Self {
193        self.variant(ButtonVariant::Danger)
194    }
195
196    pub fn link(self) -> Self {
197        self.variant(ButtonVariant::Link)
198    }
199
200    /// Marks the action as in flight. A loading button is not actionable.
201    pub fn loading(mut self, loading: bool) -> Self {
202        self.loading = loading;
203        self
204    }
205
206    pub fn full_width(mut self, full_width: bool) -> Self {
207        self.full_width = full_width;
208        self
209    }
210
211    /// Puts the button on a caller-owned focus handle.
212    ///
213    /// An overlay that keeps its own tab order needs a handle it can focus
214    /// directly, and the published node then reports whether the keyboard is
215    /// on this action.
216    pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
217        self.focus_handle = Some(handle.clone());
218        self
219    }
220
221    pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
222        self.on_click = Some(Rc::new(handler));
223        self
224    }
225
226    /// Publishes an explicit two-state answer, for a button that stays in.
227    ///
228    /// A selected button publishes `checked` only when it is selected, because
229    /// "this is the current one" has no meaningful false. A toggle does: out
230    /// is a state, not the absence of one, so it says so.
231    pub fn checked_state(mut self, checked: bool) -> Self {
232        self.checked = Some(checked);
233        self
234    }
235
236    fn actionable(&self) -> bool {
237        !self.disabled && !self.loading && self.on_click.is_some()
238    }
239
240    fn announced_name(&self) -> Option<SharedString> {
241        self.name.clone().or_else(|| self.label.clone())
242    }
243}
244
245impl Disableable for Button {
246    fn disabled(mut self, disabled: bool) -> Self {
247        self.disabled = disabled;
248        self
249    }
250}
251
252impl Selectable for Button {
253    fn selected(mut self, selected: bool) -> Self {
254        self.selected = selected;
255        self
256    }
257}
258
259impl Sizable for Button {
260    fn control_size(mut self, size: ControlSize) -> Self {
261        self.size = size;
262        self
263    }
264}
265
266impl RenderOnce for Button {
267    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
268        let theme = cx.theme().clone();
269        let metrics = theme.control.get(self.size);
270        let inert = self.disabled || self.loading;
271        let direction = cx.layout_direction();
272        let actionable = self.actionable();
273        let hover_group = self.ident.child("hover").semantic_id();
274
275        let mut content: Vec<AnyElement> = Vec::new();
276        let glyph = self.glyph.map(|glyph| {
277            // SVG paint does not inherit the frame's text color, so the icon
278            // has to name the variant foreground itself.
279            icon(glyph)
280                .size(px(metrics.icon_size))
281                .flex_none()
282                .text_color(foreground(&theme, self.variant))
283                .when(!inert && self.variant == ButtonVariant::Ghost, |element| {
284                    element.group_hover(hover_group.clone(), |style| {
285                        style.text_color(theme.colors.text)
286                    })
287                })
288                .when(!inert && self.variant == ButtonVariant::Link, |element| {
289                    element.group_hover(hover_group.clone(), |style| {
290                        style.text_color(theme.colors.accent_strong)
291                    })
292                })
293                .into_any_element()
294        });
295        if let Some(glyph) = glyph {
296            match self.icon_position {
297                IconPosition::Leading => content.push(glyph),
298                IconPosition::Trailing => content.insert(0, glyph),
299            }
300        }
301        if let Some(label) = self.label.clone() {
302            let label = foundation_text(&theme, TypeScale::Label, label)
303                .text_size(px(metrics.font_size))
304                .text_color(foreground(&theme, self.variant))
305                .when(!inert && self.variant == ButtonVariant::Ghost, |element| {
306                    element.group_hover(hover_group.clone(), |style| {
307                        style.text_color(theme.colors.text)
308                    })
309                })
310                .when(!inert && self.variant == ButtonVariant::Link, |element| {
311                    element.group_hover(hover_group.clone(), |style| {
312                        style.text_color(theme.colors.accent_strong)
313                    })
314                })
315                .flex_none()
316                .into_any_element();
317            match self.icon_position {
318                IconPosition::Leading => content.push(label),
319                IconPosition::Trailing => content.insert(0, label),
320            }
321        }
322
323        let mut button = frame(&theme, self.variant, metrics, inert, direction)
324            .group(hover_group)
325            .when(self.icon_only, |element| {
326                element.w(px(metrics.height)).px(px(0.0))
327            })
328            .map(|element| joined(element, &theme, self.join, direction))
329            .when(self.selected, |element| {
330                element
331                    .bg(theme.colors.selected)
332                    .border_color(theme.colors.hairline_strong)
333            })
334            .id(self.ident.element_id())
335            .when_some(self.focus_handle.clone(), |element, handle| {
336                element.track_focus(&handle)
337            })
338            .role(gpui::Role::Button)
339            .when(self.full_width, |element| element.w_full())
340            .when(actionable, |element| {
341                element
342                    .cursor_pointer()
343                    .tab_index(0)
344                    .focus_ring(&theme)
345                    .pressable(cx)
346            })
347            .children(content);
348
349        if let (true, Some(handler)) = (actionable, self.on_click.clone()) {
350            let on_click = Rc::clone(&handler);
351            button
352                .interactivity()
353                .on_click(move |_, window, cx| on_click(window, cx));
354            button
355                .interactivity()
356                .on_key_down(move |event, window, cx| {
357                    if matches!(event.keystroke.key.as_str(), "enter" | "space") {
358                        handler(window, cx);
359                        cx.stop_propagation();
360                    }
361                });
362        }
363
364        let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Button)
365            .disabled(inert)
366            .busy(self.loading);
367        if let Some(parent) = self.semantic_parent.clone() {
368            spec = spec.parent(parent);
369        }
370        match self.checked {
371            Some(checked) => spec = spec.checked(checked),
372            None if self.selected => spec = spec.checked(true),
373            None => {}
374        }
375        if let Some(handle) = &self.focus_handle {
376            spec = spec.focus(handle);
377        }
378        if let Some(name) = self.announced_name() {
379            spec = spec.text(name);
380        }
381        if let Some(description) = self.description {
382            spec = spec.description(description);
383        }
384        button.semantic_in(cx, spec)
385    }
386}
387
388/// Flattens the edges a joined button shares with its neighbour, and pulls it
389/// onto that neighbour's border so the run carries one hairline, not two.
390fn joined(element: Div, theme: &Theme, join: ButtonJoin, direction: LayoutDirection) -> Div {
391    let flat = px(0.0);
392    let overlap = px(-theme.borders.hairline);
393    // Leading and trailing name places in a run, and a run is read rather
394    // than measured: the first button keeps the corners on the side reading
395    // starts at and gives up the ones it shares with the next.
396    let start_flat = |element: Div| {
397        if direction.is_rtl() {
398            element.rounded_tr(flat).rounded_br(flat)
399        } else {
400            element.rounded_tl(flat).rounded_bl(flat)
401        }
402    };
403    let end_flat = |element: Div| {
404        if direction.is_rtl() {
405            element.rounded_tl(flat).rounded_bl(flat)
406        } else {
407            element.rounded_tr(flat).rounded_br(flat)
408        }
409    };
410    match join {
411        ButtonJoin::Alone => element,
412        ButtonJoin::Leading => end_flat(element),
413        ButtonJoin::Middle => end_flat(start_flat(element.ms(direction, overlap))),
414        ButtonJoin::Trailing => start_flat(element.ms(direction, overlap)),
415    }
416}
417
418fn foreground(theme: &Theme, variant: ButtonVariant) -> Hsla {
419    match variant {
420        ButtonVariant::Primary => theme.colors.text_on_accent,
421        ButtonVariant::Secondary => theme.colors.text,
422        ButtonVariant::Ghost => theme.colors.text_muted,
423        ButtonVariant::Danger => gpui::white(),
424        ButtonVariant::Link => theme.colors.accent,
425    }
426}
427
428fn frame(
429    theme: &Theme,
430    variant: ButtonVariant,
431    metrics: ControlMetrics,
432    inert: bool,
433    direction: LayoutDirection,
434) -> Div {
435    // Leading and trailing are named for reading order, not for the screen,
436    // so the frame runs the way the label does and the glyph stays on the
437    // side of the label the caller asked for.
438    let base = div()
439        .row_reading(direction)
440        .justify_center()
441        .flex_none()
442        .h(px(metrics.height))
443        .gap(px(metrics.gap))
444        .px(px(metrics.padding_x))
445        .radius(theme, Radius::Control)
446        .border(px(theme.borders.hairline))
447        .border_color(gpui::transparent_black())
448        .when(inert, |element| element.opacity(theme.opacity.disabled));
449
450    match variant {
451        ButtonVariant::Primary => base
452            .bg(theme.colors.text)
453            .when(!inert, |element| element.hover(|style| style.opacity(0.9))),
454        ButtonVariant::Secondary => base
455            .bg(theme.colors.raised)
456            .border_color(theme.colors.hairline)
457            .when(!inert, |element| {
458                element.hover(|style| style.bg(theme.colors.hover))
459            }),
460        ButtonVariant::Ghost => base.when(!inert, |element| {
461            element.hover(|style| style.bg(theme.colors.hover))
462        }),
463        ButtonVariant::Danger => base
464            .bg(theme.colors.danger.opacity(0.8))
465            .when(!inert, |element| element.hover(|style| style.opacity(0.9))),
466        ButtonVariant::Link => base.px(px(0.0)),
467    }
468}
469
470/// An action carried by a glyph alone.
471///
472/// The accessible name is a constructor argument rather than an option: an
473/// icon with no name is an action neither a screen reader nor a test can
474/// address, and there is no sensible default for what a picture means.
475/// Everything else — tone, size, refusal, the action in flight — is
476/// [`Button`]'s behaviour, reused rather than reimplemented.
477#[derive(IntoElement)]
478pub struct IconButton {
479    button: Button,
480}
481
482impl std::fmt::Debug for IconButton {
483    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
484        formatter
485            .debug_struct("IconButton")
486            .field("button", &self.button)
487            .finish()
488    }
489}
490
491impl IconButton {
492    pub fn new(ident: impl Into<Ident>, glyph: Icon, name: impl Into<SharedString>) -> Self {
493        Self {
494            button: Button::new(ident)
495                .ghost()
496                .icon_only(glyph, name)
497                .icon_position(IconPosition::Leading),
498        }
499    }
500
501    pub fn variant(mut self, variant: ButtonVariant) -> Self {
502        self.button = self.button.variant(variant);
503        self
504    }
505
506    pub fn primary(self) -> Self {
507        self.variant(ButtonVariant::Primary)
508    }
509
510    pub fn secondary(self) -> Self {
511        self.variant(ButtonVariant::Secondary)
512    }
513
514    pub fn ghost(self) -> Self {
515        self.variant(ButtonVariant::Ghost)
516    }
517
518    pub fn danger(self) -> Self {
519        self.variant(ButtonVariant::Danger)
520    }
521
522    pub fn loading(mut self, loading: bool) -> Self {
523        self.button = self.button.loading(loading);
524        self
525    }
526
527    pub fn semantic_parent(mut self, parent: impl Into<SharedString>) -> Self {
528        self.button = self.button.semantic_parent(parent);
529        self
530    }
531
532    pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
533        self.button = self.button.track_focus(handle);
534        self
535    }
536
537    pub fn join(mut self, join: ButtonJoin) -> Self {
538        self.button = self.button.join(join);
539        self
540    }
541
542    pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
543        self.button = self.button.on_click(handler);
544        self
545    }
546}
547
548impl Disableable for IconButton {
549    fn disabled(mut self, disabled: bool) -> Self {
550        self.button = self.button.disabled(disabled);
551        self
552    }
553}
554
555impl Selectable for IconButton {
556    fn selected(mut self, selected: bool) -> Self {
557        self.button = self.button.selected(selected);
558        self
559    }
560}
561
562impl Sizable for IconButton {
563    fn control_size(mut self, size: ControlSize) -> Self {
564        self.button = self.button.control_size(size);
565        self
566    }
567}
568
569impl RenderOnce for IconButton {
570    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
571        self.button
572    }
573}
574
575/// Adjacent related actions sharing one frame.
576///
577/// The group reports nothing: every action still reports itself, and the
578/// group only decides where the corners are. It publishes a `Group` node so
579/// the actions inside it can be addressed as a set, and names each button as
580/// its child.
581#[derive(IntoElement)]
582pub struct ButtonGroup {
583    ident: Ident,
584    buttons: Vec<Button>,
585    size: ControlSize,
586    disabled: bool,
587}
588
589impl std::fmt::Debug for ButtonGroup {
590    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
591        formatter
592            .debug_struct("ButtonGroup")
593            .field("ident", &self.ident)
594            .field("buttons", &self.buttons.len())
595            .field("disabled", &self.disabled)
596            .finish()
597    }
598}
599
600impl ButtonGroup {
601    pub fn new(ident: impl Into<Ident>) -> Self {
602        Self {
603            ident: ident.into(),
604            buttons: Vec::new(),
605            size: ControlSize::default(),
606            disabled: false,
607        }
608    }
609
610    pub fn child(mut self, button: Button) -> Self {
611        self.buttons.push(button);
612        self
613    }
614
615    pub fn children(mut self, buttons: impl IntoIterator<Item = Button>) -> Self {
616        self.buttons.extend(buttons);
617        self
618    }
619}
620
621impl Disableable for ButtonGroup {
622    fn disabled(mut self, disabled: bool) -> Self {
623        self.disabled = disabled;
624        self
625    }
626}
627
628impl Sizable for ButtonGroup {
629    fn control_size(mut self, size: ControlSize) -> Self {
630        self.size = size;
631        self
632    }
633}
634
635impl RenderOnce for ButtonGroup {
636    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
637        let last = self.buttons.len().saturating_sub(1);
638        let parent = self.ident.semantic_id();
639        let group_disabled = self.disabled;
640        let size = self.size;
641        let buttons = self
642            .buttons
643            .into_iter()
644            .enumerate()
645            .map(|(index, button)| {
646                let join = match (index, last) {
647                    (_, 0) => ButtonJoin::Alone,
648                    (0, _) => ButtonJoin::Leading,
649                    (index, last) if index == last => ButtonJoin::Trailing,
650                    _ => ButtonJoin::Middle,
651                };
652                // One frame means one scale: a run of mismatched heights is
653                // not a shared frame, it is a row of buttons.
654                let button = button
655                    .join(join)
656                    .control_size(size)
657                    .semantic_parent(parent.clone());
658                if group_disabled {
659                    button.disabled(true)
660                } else {
661                    button
662                }
663            })
664            .collect::<Vec<_>>();
665
666        div()
667            .row_reading(cx.layout_direction())
668            .flex_none()
669            .children(buttons)
670            .semantic_in(cx, NodeSpec::new(parent, Role::Toolbar))
671    }
672}