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#[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 pub fn disabled(mut self, disabled: bool) -> Self {
60 self.disabled = disabled;
61 self
62 }
63
64 pub fn selected(mut self, selected: bool) -> Self {
71 self.selected = selected;
72 self
73 }
74
75 pub fn styles(mut self, build: impl FnOnce(ButtonStyles) -> ButtonStyles) -> Self {
77 self.semantic_styles = build(self.semantic_styles);
78 self
79 }
80
81 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
83 self.accessibility_label = Some(label.into());
84 self
85 }
86
87 pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
89 self.role = role.into();
90 self
91 }
92
93 pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
95 self.provided_focus_handle = Some(focus_handle.clone());
96 self
97 }
98
99 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 pub fn tab_index(mut self, tab_index: isize) -> Self {
110 self.tab_index = tab_index;
111 self
112 }
113
114 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
116 self.tab_stop = tab_stop;
117 self
118 }
119
120 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#[derive(Default)]
166pub struct ButtonStyles {
167 selected: StyleRefinement,
168 disabled: StyleRefinement,
169}
170
171impl ButtonStyles {
172 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 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 .flex()
220 .items_center()
221 .justify_center()
222 .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 assert!(!disabled.is_disabled());
555 }
556}