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