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