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 {
63 self.disabled = disabled;
64 self
65 }
66
67 pub fn selected(mut self, selected: bool) -> Self {
74 self.selected = selected;
75 self
76 }
77
78 pub fn styles(mut self, build: impl FnOnce(ButtonStyles) -> ButtonStyles) -> Self {
80 self.semantic_styles = build(self.semantic_styles);
81 self
82 }
83
84 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
86 self.accessibility_label = Some(label.into());
87 self
88 }
89
90 pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
92 self.role = role.into();
93 self
94 }
95
96 pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
98 self.provided_focus_handle = Some(focus_handle.clone());
99 self
100 }
101
102 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 pub fn tab_index(mut self, tab_index: isize) -> Self {
113 self.tab_index = tab_index;
114 self
115 }
116
117 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
119 self.tab_stop = tab_stop;
120 self
121 }
122
123 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#[derive(Default)]
169pub struct ButtonStyles {
170 selected: StyleRefinement,
171 disabled: StyleRefinement,
172}
173
174impl ButtonStyles {
175 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 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 .flex()
223 .items_center()
224 .justify_center()
225 .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}