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#[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 pub fn disabled(mut self, disabled: bool) -> Self {
67 self.disabled = disabled;
68 self
69 }
70
71 pub fn selected(mut self, selected: bool) -> Self {
78 self.selected = selected;
79 self
80 }
81
82 pub fn styles(mut self, build: impl FnOnce(ButtonStyles) -> ButtonStyles) -> Self {
84 self.semantic_styles = build(self.semantic_styles);
85 self
86 }
87
88 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
90 self.accessibility_label = Some(label.into());
91 self
92 }
93
94 pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
96 self.role = role.into();
97 self
98 }
99
100 pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
102 self.provided_focus_handle = Some(focus_handle.clone());
103 self
104 }
105
106 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 pub fn tab_index(mut self, tab_index: isize) -> Self {
117 self.tab_index = tab_index;
118 self
119 }
120
121 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
123 self.tab_stop = tab_stop;
124 self
125 }
126
127 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#[derive(Default)]
173pub struct ButtonStyles {
174 selected: StyleRefinement,
175 disabled: StyleRefinement,
176}
177
178impl ButtonStyles {
179 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 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 .flex()
227 .items_center()
228 .justify_center()
229 .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 .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}