Skip to main content

gpui_base/
button.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, ClickEvent, Div, ElementId, FocusHandle, InteractiveElement, Interactivity,
5    IntoElement, MouseButton, ParentElement, Refineable as _, RenderOnce, Role, SharedString,
6    Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
7    prelude::FluentBuilder as _, relative,
8};
9use smallvec::SmallVec;
10
11use crate::{
12    RoleOverride, Selectable, StateStyle, StyledExt as _, TestSupportExt as _,
13    a11y_disabled::A11yDisabledExt as _,
14};
15
16type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
17
18/// An unstyled button that owns interaction, focus, keyboard, and accessibility behavior.
19///
20/// Layout and visual states are intentionally supplied by the application through
21/// GPUI's [`Styled`] API.
22#[derive(IntoElement)]
23pub struct Button {
24    id: ElementId,
25    base: crate::ObservedElement<Stateful<Div>>,
26    style: StyleRefinement,
27    semantic_styles: ButtonStyles,
28    selected: bool,
29    disabled: bool,
30    children: SmallVec<[AnyElement; 2]>,
31    on_click: Option<ClickHandler>,
32    accessibility_label: Option<SharedString>,
33    role: RoleOverride,
34    provided_focus_handle: Option<FocusHandle>,
35    tab_index: isize,
36    tab_stop: bool,
37    focusable: bool,
38}
39
40impl Button {
41    pub fn new(id: impl Into<ElementId>) -> Self {
42        let id = id.into();
43        Self {
44            base: div().id(id.clone()).test_support(),
45            id,
46            style: StyleRefinement::default(),
47            semantic_styles: ButtonStyles::default(),
48            selected: false,
49            disabled: false,
50            children: SmallVec::new(),
51            on_click: None,
52            accessibility_label: None,
53            role: RoleOverride::Implicit,
54            provided_focus_handle: None,
55            tab_index: 0,
56            tab_stop: true,
57            focusable: true,
58        }
59    }
60
61    /// Sets whether the button ignores pointer and keyboard activation.
62    ///
63    /// Disabled buttons are skipped during Tab traversal. If this button already
64    /// owns focus when it becomes disabled, it retains the focus binding until
65    /// focus moves away, so keyboard traversal can continue in either direction.
66    pub fn disabled(mut self, disabled: bool) -> Self {
67        self.disabled = disabled;
68        self
69    }
70
71    /// Sets the application-controlled selected presentation state.
72    ///
73    /// This supports persistent trigger presentation while an associated menu
74    /// or popover is open. It is distinct from momentary `active`, Toggle's
75    /// `pressed` state, and accessibility toggle metadata; selecting a Button
76    /// does not automatically set `aria_toggled`.
77    pub fn selected(mut self, selected: bool) -> Self {
78        self.selected = selected;
79        self
80    }
81
82    /// Defines application-owned styles for the button's semantic states.
83    pub fn styles(mut self, build: impl FnOnce(ButtonStyles) -> ButtonStyles) -> Self {
84        self.semantic_styles = build(self.semantic_styles);
85        self
86    }
87
88    /// Sets the label exposed to accessibility clients.
89    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
90        self.accessibility_label = Some(label.into());
91        self
92    }
93
94    /// Overrides the accessibility role. The default is [`Role::Button`].
95    pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
96        self.role = role.into();
97        self
98    }
99
100    /// Uses a caller-owned focus handle instead of creating keyed state.
101    pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
102        self.provided_focus_handle = Some(focus_handle.clone());
103        self
104    }
105
106    /// Sets the activation handler for pointer, Enter, and Space input.
107    pub fn on_click(
108        mut self,
109        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
110    ) -> Self {
111        self.on_click = Some(Rc::new(handler));
112        self
113    }
114
115    /// Sets the focus traversal index. The default is `0`.
116    pub fn tab_index(mut self, tab_index: isize) -> Self {
117        self.tab_index = tab_index;
118        self
119    }
120
121    /// Sets whether the button participates in keyboard focus traversal.
122    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
123        self.tab_stop = tab_stop;
124        self
125    }
126
127    /// Sets whether pressing the button moves focus onto it. The default is `true`.
128    ///
129    /// A non-focusable button leaves focus wherever it already is, which keeps a
130    /// composed control from flickering its focus ring on every press. It also
131    /// gives up Enter and Space activation, so only use it for a button that a
132    /// focused sibling already exposes to the keyboard, such as a number input's
133    /// step buttons.
134    pub fn focusable(mut self, focusable: bool) -> Self {
135        self.focusable = focusable;
136        self
137    }
138
139    fn focus_handle(&self, window: &mut Window, cx: &mut App) -> FocusHandle {
140        self.provided_focus_handle.clone().unwrap_or_else(|| {
141            window
142                .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
143                .read(cx)
144                .clone()
145        })
146    }
147
148    fn resolved_style(&self) -> StyleRefinement {
149        crate::state_style::resolve_style(
150            &self.style,
151            [
152                self.selected.then_some(&self.semantic_styles.selected),
153                self.disabled.then_some(&self.semantic_styles.disabled),
154            ]
155            .into_iter()
156            .flatten(),
157        )
158    }
159}
160
161impl Selectable for Button {
162    fn selected(self, selected: bool) -> Self {
163        Button::selected(self, selected)
164    }
165
166    fn is_selected(&self) -> bool {
167        self.selected
168    }
169}
170
171/// Semantic styles supported by [`Button`].
172#[derive(Default)]
173pub struct ButtonStyles {
174    selected: StyleRefinement,
175    disabled: StyleRefinement,
176}
177
178impl ButtonStyles {
179    /// Refines the root style when the button is selected.
180    pub fn selected(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
181        self.selected
182            .refine(&build(StateStyle::default()).into_refinement());
183        self
184    }
185
186    /// Refines the root style when the button is disabled.
187    pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
188        self.disabled
189            .refine(&build(StateStyle::default()).into_refinement());
190        self
191    }
192}
193
194impl Styled for Button {
195    fn style(&mut self) -> &mut StyleRefinement {
196        &mut self.style
197    }
198}
199
200impl ParentElement for Button {
201    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
202        self.children.extend(elements);
203    }
204}
205
206impl InteractiveElement for Button {
207    fn interactivity(&mut self) -> &mut Interactivity {
208        self.base.interactivity()
209    }
210}
211
212impl StatefulInteractiveElement for Button {}
213
214impl RenderOnce for Button {
215    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
216        let focus_handle = self.focus_handle(window, cx);
217        let disabled = self.disabled;
218        let style = self.resolved_style();
219        let on_click = self.on_click;
220
221        self.base
222            // Centering is part of Button's control geometry. Without a flex
223            // formatting context an ordinary child starts at the root's
224            // leading edge, so a fixed-height unstyled Button cannot align its
225            // label even though its neutral line height is correct.
226            .flex()
227            .items_center()
228            .justify_center()
229            // A neutral line height is geometry, not visual policy: with the
230            // inherited value the text box is taller than the glyphs, so the
231            // caller's padding no longer determines the control's height and a
232            // button cannot be sized precisely. `relative(1.)` makes the text
233            // box exactly the font size; anything the caller sets refines over
234            // it, so a product that wants looser text still can.
235            .line_height(relative(1.))
236            .when_some(self.role.resolve(|| Role::Button), |this, role| {
237                this.role(role)
238            })
239            .when_some(self.accessibility_label, |this, label| {
240                this.aria_label(label)
241            })
242            // Keep the current focus in the dispatch tree when activation
243            // disables this button, so Tab can still leave it in either direction.
244            .when(
245                self.focusable && (!disabled || focus_handle.is_focused(window)),
246                |this| {
247                    this.track_focus(
248                        &focus_handle
249                            .tab_index(self.tab_index)
250                            .tab_stop(self.tab_stop && !disabled),
251                    )
252                },
253            )
254            .when(disabled, |this| {
255                this.on_mouse_down(MouseButton::Left, |_, _, cx| {
256                    cx.stop_propagation();
257                })
258            })
259            .when_some(
260                (!disabled).then_some(on_click).flatten(),
261                |this, on_click| {
262                    this.on_click(move |event, window, cx| {
263                        on_click(event, window, cx);
264                    })
265                },
266            )
267            .children(self.children)
268            .refine_style(&style)
269            .a11y_disabled(disabled)
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::ElementExt as _;
277    use std::{
278        cell::Cell,
279        rc::Rc,
280        sync::{Arc, Mutex},
281    };
282
283    use gpui::{
284        AppContext as _, ClickEvent, Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke,
285        Modifiers, Render, TestAppContext, VisualTestContext, accesskit, canvas, point, px,
286    };
287
288    struct ButtonHarness {
289        disabled: bool,
290        button_clicks: Rc<Cell<usize>>,
291        parent_clicks: Rc<Cell<usize>>,
292        keyboard_events: Rc<Cell<usize>>,
293    }
294
295    impl Render for ButtonHarness {
296        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
297            let button_clicks = self.button_clicks.clone();
298            let keyboard_events = self.keyboard_events.clone();
299            let parent_clicks = self.parent_clicks.clone();
300
301            div()
302                .id("button-parent")
303                .tab_group()
304                .size(px(100.))
305                .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
306                .child(
307                    Button::new("button")
308                        .disabled(self.disabled)
309                        .size_full()
310                        .on_click(move |event, _, _| {
311                            button_clicks.set(button_clicks.get() + 1);
312                            if matches!(event, ClickEvent::Keyboard(_)) {
313                                keyboard_events.set(keyboard_events.get() + 1);
314                            }
315                        }),
316                )
317        }
318    }
319
320    fn harness(
321        cx: &mut TestAppContext,
322        disabled: bool,
323    ) -> (
324        &mut VisualTestContext,
325        Rc<Cell<usize>>,
326        Rc<Cell<usize>>,
327        Rc<Cell<usize>>,
328    ) {
329        let button_clicks = Rc::new(Cell::new(0));
330        let parent_clicks = Rc::new(Cell::new(0));
331        let keyboard_events = Rc::new(Cell::new(0));
332        let (_, cx) = cx.add_window_view({
333            let button_clicks = button_clicks.clone();
334            let parent_clicks = parent_clicks.clone();
335            let keyboard_events = keyboard_events.clone();
336            move |_, _| ButtonHarness {
337                disabled,
338                button_clicks,
339                parent_clicks,
340                keyboard_events,
341            }
342        });
343        cx.update(|window, cx| {
344            window.draw(cx).clear(cx);
345        });
346        (cx, button_clicks, parent_clicks, keyboard_events)
347    }
348
349    #[gpui::test]
350    fn pointer_activation_fires_once(cx: &mut TestAppContext) {
351        let (cx, button_clicks, _, keyboard_events) = harness(cx, false);
352
353        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
354
355        assert_eq!(button_clicks.get(), 1);
356        assert_eq!(keyboard_events.get(), 0);
357    }
358
359    #[gpui::test]
360    fn enter_and_space_use_one_native_keyboard_click_each(cx: &mut TestAppContext) {
361        let (cx, button_clicks, _, keyboard_events) = harness(cx, false);
362        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
363        button_clicks.set(0);
364        cx.update(|window, cx| {
365            assert!(window.focused(cx).is_some());
366            window.draw(cx).clear(cx);
367        });
368
369        for key in ["enter", "space"] {
370            let keystroke =
371                cx.update(|_, cx| Keystroke::parse_with_os(key, cx.platform_info().os).unwrap());
372            cx.simulate_event(KeyDownEvent {
373                keystroke: keystroke.clone(),
374                is_held: false,
375                prefer_character_input: false,
376            });
377            cx.simulate_event(KeyUpEvent { keystroke });
378        }
379
380        assert_eq!(button_clicks.get(), 2);
381        assert_eq!(keyboard_events.get(), 2);
382    }
383
384    #[gpui::test]
385    fn disabled_button_is_inert_and_blocks_parent_activation(cx: &mut TestAppContext) {
386        let (cx, button_clicks, parent_clicks, _) = harness(cx, true);
387
388        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
389        cx.update(|window, cx| window.focus_next(cx));
390        cx.simulate_keystrokes("enter space");
391
392        assert_eq!(button_clicks.get(), 0);
393        assert_eq!(parent_clicks.get(), 0);
394    }
395
396    struct DisableDuringFocus {
397        handles: [FocusHandle; 3],
398        disabled: bool,
399        activations: usize,
400    }
401
402    impl Render for DisableDuringFocus {
403        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
404            div()
405                .size_full()
406                .flex()
407                .tab_group()
408                .child(
409                    Button::new("before")
410                        .track_focus(&self.handles[0])
411                        .child("Before"),
412                )
413                .child(
414                    Button::new("disable-self")
415                        .track_focus(&self.handles[1])
416                        .disabled(self.disabled)
417                        .child("Disable self")
418                        .on_click(cx.listener(|this, _, _, cx| {
419                            this.disabled = true;
420                            this.activations += 1;
421                            cx.notify();
422                        })),
423                )
424                .child(
425                    Button::new("after")
426                        .track_focus(&self.handles[2])
427                        .child("After"),
428                )
429        }
430    }
431
432    fn press_transition_key(cx: &mut VisualTestContext, key: &str) {
433        let keystroke =
434            cx.update(|_, cx| Keystroke::parse_with_os(key, cx.platform_info().os).unwrap());
435        cx.simulate_event(KeyDownEvent {
436            keystroke: keystroke.clone(),
437            is_held: false,
438            prefer_character_input: false,
439        });
440        cx.simulate_event(KeyUpEvent { keystroke });
441        cx.update(|window, cx| window.draw(cx).clear(cx));
442    }
443
444    #[gpui::test]
445    fn disabling_focused_button_preserves_bidirectional_traversal(cx: &mut TestAppContext) {
446        cx.update(crate::init);
447        for (key, expected, back, opposite) in
448            [("tab", 2, "shift-tab", 0), ("shift-tab", 0, "tab", 2)]
449        {
450            let handles = cx.update(|cx| std::array::from_fn(|_| cx.focus_handle()));
451            let controls = cx.new(|_| DisableDuringFocus {
452                handles: handles.clone(),
453                disabled: false,
454                activations: 0,
455            });
456            let (_, cx) =
457                cx.add_window_view(|window, cx| crate::Root::new(controls.clone(), window, cx));
458            cx.update(|window, cx| {
459                window.activate_window();
460                window.draw(cx).clear(cx);
461                handles[0].focus(window, cx);
462                window.draw(cx).clear(cx);
463            });
464            press_transition_key(cx, "tab");
465            cx.update(|window, cx| {
466                window.draw(cx).clear(cx);
467                assert_eq!(window.focused(cx).as_ref(), Some(&handles[1]));
468            });
469            press_transition_key(cx, "space");
470            cx.update(|window, cx| {
471                window.draw(cx).clear(cx);
472                assert!(controls.read(cx).disabled);
473                assert_eq!(controls.read(cx).activations, 1);
474            });
475            press_transition_key(cx, "enter");
476            press_transition_key(cx, "space");
477            cx.update(|_, cx| {
478                assert_eq!(controls.read(cx).activations, 1, "disabled stays inert");
479            });
480            press_transition_key(cx, key);
481            cx.update(|window, cx| {
482                window.draw(cx).clear(cx);
483                assert_eq!(window.focused(cx).as_ref(), Some(&handles[expected]));
484            });
485            press_transition_key(cx, back);
486            cx.update(|window, cx| {
487                window.draw(cx).clear(cx);
488                assert_eq!(window.focused(cx).as_ref(), Some(&handles[opposite]));
489            });
490            controls.update(cx, |this, cx| {
491                this.disabled = false;
492                cx.notify();
493            });
494            cx.update(|window, cx| window.draw(cx).clear(cx));
495            press_transition_key(cx, key);
496            cx.update(|window, cx| {
497                window.draw(cx).clear(cx);
498                assert_eq!(window.focused(cx).as_ref(), Some(&handles[1]));
499            });
500        }
501    }
502
503    #[gpui::test]
504    fn fixed_height_button_centers_ordinary_child_geometry(cx: &mut TestAppContext) {
505        type Captured = Arc<
506            Mutex<(
507                Option<gpui::Bounds<gpui::Pixels>>,
508                Option<gpui::Bounds<gpui::Pixels>>,
509            )>,
510        >;
511
512        struct AlignmentProbe {
513            captured: Captured,
514        }
515
516        impl Render for AlignmentProbe {
517            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
518                let root_capture = self.captured.clone();
519                let child_capture = self.captured.clone();
520                Button::new("alignment-button")
521                    .w(px(120.))
522                    .h(px(40.))
523                    .child(
524                        div()
525                            .w(px(48.))
526                            .h(px(12.))
527                            .on_prepaint(move |bounds, _, _| {
528                                child_capture.lock().unwrap().1 = Some(bounds);
529                            }),
530                    )
531                    .on_prepaint(move |bounds, _, _| {
532                        root_capture.lock().unwrap().0 = Some(bounds);
533                    })
534            }
535        }
536
537        let captured = Arc::new(Mutex::new((None, None)));
538        let (_, context) = cx.add_window_view({
539            let captured = captured.clone();
540            move |_, _| AlignmentProbe { captured }
541        });
542        context.update(|window, cx| window.draw(cx).clear(cx));
543
544        let (root, child) = *captured.lock().unwrap();
545        let root = root.expect("button bounds");
546        let child = child.expect("child bounds");
547        assert_eq!(child.center(), root.center());
548    }
549
550    #[test]
551    fn state_styling_methods_are_available_to_applications() {
552        let _ = Button::new("states")
553            .styles(|styles| {
554                styles.disabled(|style| {
555                    style
556                        .opacity(0.5)
557                        .when(true, |style| style.border_1())
558                        .when_some(Some(0.4), |style, opacity| style.opacity(opacity))
559                        .when_none(&None::<f32>, |style| style.rounded_sm())
560                })
561            })
562            .hover(|style| style.opacity(0.9))
563            .active(|style| style.opacity(0.8))
564            .focus_visible(|style| style.opacity(0.7));
565    }
566
567    #[test]
568    fn disabled_style_applies_only_while_disabled_and_then_wins() {
569        let enabled = Button::new("enabled")
570            .opacity(0.9)
571            .styles(|styles| styles.disabled(|style| style.opacity(0.5)));
572        assert_eq!(enabled.resolved_style().opacity, Some(0.9));
573
574        let disabled = Button::new("disabled")
575            .styles(|styles| styles.disabled(|style| style.opacity(0.5)))
576            .opacity(0.9)
577            .disabled(true);
578        assert_eq!(disabled.resolved_style().opacity, Some(0.5));
579
580        let semantic_only = Button::new("semantic-only")
581            .styles(|styles| styles.disabled(|style| style.opacity(0.5)))
582            .disabled(true);
583        assert_eq!(semantic_only.resolved_style().opacity, Some(0.5));
584    }
585
586    #[test]
587    fn selected_disabled_and_instance_styles_follow_the_shared_priority() {
588        let selected_color = gpui::hsla(0.6, 0.7, 0.5, 1.0);
589        let disabled_color = gpui::hsla(0.1, 0.2, 0.3, 0.5);
590        let button = |selected, disabled| {
591            Button::new("button")
592                .selected(selected)
593                .disabled(disabled)
594                .styles(|styles| {
595                    styles
596                        .selected(|style| style.bg(selected_color))
597                        .disabled(|style| style.bg(disabled_color))
598                })
599        };
600
601        assert_eq!(button(false, false).resolved_style().background, None);
602        assert_eq!(
603            button(true, false).resolved_style().background,
604            Some(selected_color.into())
605        );
606        assert_eq!(
607            button(true, true).resolved_style().background,
608            Some(disabled_color.into())
609        );
610        assert_eq!(
611            button(true, true)
612                .bg(selected_color)
613                .resolved_style()
614                .background,
615            Some(disabled_color.into())
616        );
617    }
618
619    #[gpui::test]
620    fn accessibility_role_label_and_disabled_action_surface(cx: &mut TestAppContext) {
621        type Captured = Arc<Mutex<Option<(accesskit::Node, accesskit::Node)>>>;
622
623        struct A11yProbe {
624            captured: Captured,
625        }
626
627        impl Render for A11yProbe {
628            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
629                let captured = self.captured.clone();
630                canvas(
631                    move |_, window, cx| {
632                        let mut info = |button: Button| {
633                            let mut node = accesskit::Node::new(Role::Button);
634                            button
635                                .render(window, cx)
636                                .into_element()
637                                .write_a11y_info(&mut node);
638                            node
639                        };
640                        let enabled = info(
641                            Button::new("enabled")
642                                .accessibility_label("Save")
643                                .on_click(|_, _, _| {}),
644                        );
645                        let disabled = info(
646                            Button::new("disabled")
647                                .disabled(true)
648                                .accessibility_label("Save")
649                                .on_click(|_, _, _| {}),
650                        );
651                        *captured.lock().unwrap() = Some((enabled, disabled));
652                    },
653                    |_, _, _, _| {},
654                )
655            }
656        }
657
658        let captured: Captured = Arc::new(Mutex::new(None));
659        let result = captured.clone();
660        let (_, cx) = cx.add_window_view(move |_, _| A11yProbe { captured });
661        cx.update(|window, cx| {
662            window.draw(cx).clear(cx);
663        });
664        let (enabled, disabled) = result.lock().unwrap().take().unwrap();
665        assert_eq!(enabled.role(), Role::Button);
666        assert_eq!(enabled.label(), Some("Save"));
667        assert!(enabled.supports_action(accesskit::Action::Click));
668
669        assert_eq!(disabled.role(), Role::Button);
670        assert!(!disabled.supports_action(accesskit::Action::Click));
671        assert!(!enabled.is_disabled());
672        assert!(disabled.is_disabled());
673    }
674}