1use gpui_kit::base::motion::transition;
3use gpui_kit::base::{ButtonStyles, RoleOverride};
4use gpui_kit::{
5 AnyElement, App, ClickEvent, ElementId, Fill, FocusHandle, Hsla, InteractiveElement,
6 Interactivity, IntoElement, MouseButton, ParentElement, RenderOnce, SharedString,
7 StatefulInteractiveElement, StyleRefinement, Styled, Window,
8};
9use std::{rc::Rc, time::Duration};
10
11type HoverListener = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
12
13#[derive(IntoElement)]
19pub struct Button {
20 id: ElementId,
21 base: gpui_kit::base::Button,
22 disabled: bool,
23 selected: bool,
24 selected_fill: Option<Hsla>,
25 inactive_style: Option<(Hsla, Hsla)>,
26 fill_duration: Duration,
27 focus: Option<FocusHandle>,
28 on_hover: Option<HoverListener>,
29 hover: Option<Box<StyleRefinement>>,
30 active: Option<Box<StyleRefinement>>,
31 focus_visible: Option<Box<StyleRefinement>>,
32}
33
34#[derive(Default)]
36struct PointerState {
37 hovered: bool,
38 pressed: bool,
39}
40
41impl Button {
42 pub fn new(id: impl Into<ElementId>) -> Self {
43 let id = id.into();
44 Self {
45 base: gpui_kit::base::Button::new(id.clone()),
46 id,
47 disabled: false,
48 selected: false,
49 selected_fill: None,
50 inactive_style: None,
51 fill_duration: crate::motion::CONTROL_COLOR,
52 focus: None,
53 on_hover: None,
54 hover: None,
55 active: None,
56 focus_visible: None,
57 }
58 }
59
60 pub(crate) fn selected_fill(mut self, fill: Hsla) -> Self {
63 self.selected_fill = Some(fill);
64 self
65 }
66
67 pub(crate) fn fill_duration(mut self, duration: Duration) -> Self {
69 self.fill_duration = duration;
70 self
71 }
72
73 pub(crate) fn inactive_style(mut self, fill: Hsla, foreground: Hsla) -> Self {
76 self.inactive_style = Some((fill, foreground));
77 self
78 }
79
80 pub fn disabled(mut self, disabled: bool) -> Self {
81 self.disabled = disabled;
82 self.base = self.base.disabled(disabled);
83 self
84 }
85
86 pub fn selected(mut self, selected: bool) -> Self {
87 self.selected = selected;
88 self.base = self.base.selected(selected);
89 self
90 }
91
92 pub fn styles(mut self, build: impl FnOnce(ButtonStyles) -> ButtonStyles) -> Self {
93 self.base = self.base.styles(build);
94 self
95 }
96
97 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
98 self.base = self.base.accessibility_label(label);
99 self
100 }
101
102 pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
103 self.base = self.base.role(role);
104 self
105 }
106
107 pub fn track_focus(mut self, focus: &FocusHandle) -> Self {
108 self.focus = Some(focus.clone());
109 self.base = self.base.track_focus(focus);
110 self
111 }
112
113 pub fn tab_index(mut self, index: isize) -> Self {
114 self.base = self.base.tab_index(index);
115 self
116 }
117
118 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
119 self.base = self.base.tab_stop(tab_stop);
120 self
121 }
122
123 pub fn focusable(mut self, focusable: bool) -> Self {
124 self.base = self.base.focusable(focusable);
125 self
126 }
127
128 pub fn on_click(
129 mut self,
130 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
131 ) -> Self {
132 self.base = self.base.on_click(handler);
133 self
134 }
135}
136
137impl gpui_kit::base::Selectable for Button {
138 fn selected(self, selected: bool) -> Self {
139 Button::selected(self, selected)
140 }
141
142 fn is_selected(&self) -> bool {
143 self.base.is_selected()
144 }
145}
146
147impl Styled for Button {
148 fn style(&mut self) -> &mut StyleRefinement {
149 self.base.style()
150 }
151}
152
153impl ParentElement for Button {
154 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
155 self.base.extend(elements);
156 }
157}
158
159impl InteractiveElement for Button {
160 fn interactivity(&mut self) -> &mut Interactivity {
161 self.base.interactivity()
162 }
163
164 fn hover(mut self, build: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self {
165 self.hover = Some(Box::new(build(StyleRefinement::default())));
166 self
167 }
168 fn focus_visible(mut self, build: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self {
169 self.focus_visible = Some(Box::new(build(StyleRefinement::default())));
170 self
171 }
172}
173
174impl StatefulInteractiveElement for Button {
175 fn on_hover(mut self, listener: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
177 self.on_hover = Some(Rc::new(listener));
178 self
179 }
180
181 fn active(mut self, build: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self {
182 self.active = Some(Box::new(build(StyleRefinement::default())));
183 self
184 }
185}
186
187fn solid(style: &StyleRefinement) -> Option<Hsla> {
189 style
190 .background
191 .as_ref()
192 .and_then(Fill::color)
193 .and_then(|background| background.as_solid())
194}
195
196impl RenderOnce for Button {
197 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
198 let window_active = window.is_window_active();
199 if (!window_active || self.disabled)
200 && let Some((fill, foreground)) = self.inactive_style
201 {
202 self.base = self.base.bg(fill).text_color(foreground);
203 }
204 let focus = self.focus.clone().unwrap_or_else(|| {
205 window
206 .use_keyed_state((self.id.clone(), "macos-focus"), cx, |_, cx| {
207 cx.focus_handle()
208 })
209 .read(cx)
210 .clone()
211 });
212 let pointer = window.use_keyed_state((self.id.clone(), "macos-pointer"), cx, |_, _| {
213 PointerState::default()
214 });
215 if !window_active {
216 pointer.update(cx, |state, _| {
217 state.hovered = false;
218 state.pressed = false;
219 });
220 }
221
222 let rest = match self.base.style().background.as_ref() {
226 Some(_) => solid(self.base.style()),
227 None => Some(gpui_kit::transparent_black()),
228 };
229 let fill = rest.map(|rest| {
230 let state = pointer.read(cx);
231 let focus_visible =
232 window_active && focus.is_focused(window) && window.last_input_was_keyboard();
233 let transient = |style: &Option<Box<StyleRefinement>>| style.as_deref().and_then(solid);
234 let target = if self.disabled || !window_active {
235 None
236 } else if state.pressed {
237 transient(&self.active)
238 } else if focus_visible {
239 transient(&self.focus_visible)
240 } else if state.hovered {
241 transient(&self.hover)
242 } else {
243 None
244 }
245 .or((self.selected && window_active && !self.disabled)
246 .then_some(self.selected_fill)
247 .flatten())
248 .unwrap_or(rest);
249 transition(
250 (self.id.clone(), "macos-fill"),
251 target,
252 crate::motion::color(crate::motion::animated_duration(window, self.fill_duration)),
255 window,
256 cx,
257 )
258 });
259 #[cfg(test)]
260 if let Some(fill) = fill {
261 tests::record_fill(&self.id, fill);
262 }
263
264 let mut base = self.base;
265 if self.focus.is_none() {
266 base = base.track_focus(&focus);
267 }
268 if let Some(fill) = fill {
269 base = base.bg(fill);
272 for style in [&mut self.hover, &mut self.active, &mut self.focus_visible]
273 .into_iter()
274 .flatten()
275 {
276 if style.background.is_some() {
277 style.background = Some(fill.into());
278 }
279 }
280 }
281 if !self.disabled {
282 if let Some(style) = self.hover.filter(|_| window_active) {
283 base = base.hover(|_| *style);
284 }
285 if let Some(style) = self.active.filter(|_| window_active) {
286 base = base.active(|_| *style);
287 }
288 if let Some(style) = self.focus_visible.filter(|_| window_active) {
289 base = base.focus_visible(|_| *style);
290 }
291 let on_hover = self.on_hover;
292 let hover_state = pointer.clone();
293 let down_state = pointer.clone();
294 let up_state = pointer.clone();
295 let out_state = pointer;
296 base = base
297 .on_hover(move |hovered, window, cx| {
298 hover_state.update(cx, |state, cx| {
299 state.hovered = *hovered;
300 cx.notify();
301 });
302 if let Some(on_hover) = on_hover.as_ref() {
303 on_hover(hovered, window, cx);
304 }
305 })
306 .on_mouse_down(MouseButton::Left, move |_, _, cx| {
307 down_state.update(cx, |state, cx| {
308 state.pressed = true;
309 cx.notify();
310 });
311 })
312 .on_mouse_up(MouseButton::Left, move |_, _, cx| {
313 up_state.update(cx, |state, cx| {
314 state.pressed = false;
315 cx.notify();
316 });
317 })
318 .on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
319 out_state.update(cx, |state, cx| {
320 state.pressed = false;
321 cx.notify();
322 });
323 });
324 } else if let Some(on_hover) = self.on_hover {
325 base = base.on_hover(move |hovered, window, cx| on_hover(hovered, window, cx));
326 }
327 base
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::ActiveTheme;
335 use gpui_kit::px;
336 use gpui_kit::rems;
337 use gpui_kit::{Context, Modifiers, MouseButton, Render, TestAppContext, div, point};
338 use std::{cell::RefCell, collections::HashMap};
339
340 thread_local! {
341 static FILLS: RefCell<HashMap<String, Hsla>> = RefCell::default();
342 }
343
344 pub(super) fn record_fill(id: &ElementId, fill: Hsla) {
346 FILLS.with(|fills| fills.borrow_mut().insert(id.to_string(), fill));
347 }
348
349 fn fill(id: &str) -> Hsla {
350 FILLS.with(|fills| fills.borrow()[id])
351 }
352
353 #[gpui_kit::test]
354 fn fill_eases_to_the_hover_state_and_back(cx: &mut TestAppContext) {
355 cx.update(crate::init);
356 struct Harness;
357 impl Render for Harness {
358 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
359 div().size(rems(25.)).child(
360 crate::button("fill", "Apply", crate::ButtonVariant::Secondary, cx)
361 .w(rems(6.25))
362 .h(rems(2.5)),
363 )
364 }
365 }
366 let (view, cx) = cx.add_window_view(|_, _| Harness);
367 cx.update(|window, _| window.activate_window());
368 cx.run_until_parked();
369 cx.simulate_mouse_move(point(px(300.), px(300.)), None, Modifiers::default());
370 let redraw = |cx: &mut gpui_kit::VisualTestContext| {
371 view.update(cx, |_, cx| cx.notify());
372 cx.update(|window, cx| window.draw(cx).clear(cx));
373 };
374 redraw(cx);
375 let (rest, hover) = cx.update(|_, cx| {
376 let t = cx.macos();
377 (t.control_fill(), t.control_fill().blend(t.hover_fill()))
378 });
379 assert_eq!(fill("fill"), rest);
380
381 cx.simulate_mouse_move(point(px(10.), px(10.)), None, Modifiers::default());
382 redraw(cx);
383 cx.executor()
384 .advance_clock(crate::motion::CONTROL_COLOR / 2);
385 redraw(cx);
386 let halfway = fill("fill");
387 assert_ne!(halfway, rest, "the hover transition has started");
388 assert_ne!(halfway, hover, "the hover transition is still running");
389
390 cx.executor().advance_clock(crate::motion::CONTROL_COLOR);
391 redraw(cx);
392 assert_eq!(fill("fill"), hover);
393
394 cx.simulate_mouse_move(point(px(300.), px(300.)), None, Modifiers::default());
395 redraw(cx);
396 cx.executor()
397 .advance_clock(crate::motion::CONTROL_COLOR * 2);
398 redraw(cx);
399 assert_eq!(fill("fill"), rest);
400 }
401
402 #[gpui_kit::test]
403 fn background_button_fill_updates_immediately_when_the_theme_changes(cx: &mut TestAppContext) {
404 cx.update(crate::init);
405 struct Harness;
406 impl Render for Harness {
407 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
408 div().children([false, true].map(|disabled| {
409 crate::button(
410 if disabled {
411 "disabled-fill"
412 } else {
413 "background-fill"
414 },
415 "Choose",
416 crate::ButtonVariant::Secondary,
417 cx,
418 )
419 .bg(cx.macos().control_fill())
420 .disabled(disabled)
421 }))
422 }
423 }
424 let (view, cx) = cx.add_window_view(|_, _| Harness);
425 cx.deactivate_window();
426 for theme in [
427 crate::Theme::dark(),
428 crate::Theme::light(),
429 crate::Theme::dark(),
430 ] {
431 cx.update(|window, cx| {
432 assert!(!window.is_window_active());
433 theme.apply(cx);
434 });
435 view.update(cx, |_, cx| cx.notify());
436 cx.update(|window, cx| {
437 window.draw(cx).clear(cx);
438 let expected = cx.macos().control_fill();
439 for id in ["background-fill", "disabled-fill"] {
440 assert_eq!(
441 fill(id),
442 expected,
443 "Inactive fills must follow the new theme without animation frames"
444 );
445 }
446 });
447 }
448 }
449
450 #[gpui_kit::test]
451 fn disabled_button_suppresses_hover_and_pressed_geometry(cx: &mut TestAppContext) {
452 cx.update(crate::init);
453 struct States {
454 disabled: bool,
455 disabled_first: bool,
456 }
457 impl Render for States {
458 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
459 let button = crate::button("button", "Apply", crate::ButtonVariant::Primary, cx)
460 .w(rems(6.25))
461 .h(rems(2.5));
462 let button = if self.disabled_first {
463 button
464 .disabled(self.disabled)
465 .hover(|s| s.w(rems(10.)))
466 .active(|s| s.w(rems(12.5)))
467 } else {
468 button
469 .hover(|s| s.w(rems(10.)))
470 .active(|s| s.w(rems(12.5)))
471 .disabled(self.disabled)
472 };
473 div()
474 .size(rems(25.))
475 .child(button.debug_selector(|| "state-button".into()))
476 }
477 }
478 for disabled_first in [false, true] {
479 let (view, cx) = cx.add_window_view(move |_, _| States {
480 disabled: true,
481 disabled_first,
482 });
483 cx.update(|window, _| window.activate_window());
484 cx.run_until_parked();
485 cx.update(|window, cx| window.draw(cx).clear(cx));
486 let position = point(px(10.), px(10.));
487 cx.simulate_mouse_move(position, None, Modifiers::default());
488 cx.update(|window, cx| window.draw(cx).clear(cx));
489 assert_eq!(
490 cx.debug_bounds("state-button").unwrap().size.width,
491 px(100.)
492 );
493 cx.simulate_mouse_down(position, MouseButton::Left, Modifiers::default());
494 cx.update(|window, cx| window.draw(cx).clear(cx));
495 assert_eq!(
496 cx.debug_bounds("state-button").unwrap().size.width,
497 px(100.)
498 );
499 cx.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
500 view.update(cx, |this, cx| {
501 this.disabled = false;
502 cx.notify();
503 });
504 cx.update(|window, cx| window.draw(cx).clear(cx));
505 cx.simulate_mouse_move(point(px(300.), px(300.)), None, Modifiers::default());
506 cx.simulate_mouse_move(position, None, Modifiers::default());
507 cx.update(|window, cx| window.draw(cx).clear(cx));
508 assert_eq!(
509 cx.debug_bounds("state-button").unwrap().size.width,
510 px(160.)
511 );
512 cx.simulate_mouse_down(position, MouseButton::Left, Modifiers::default());
513 cx.update(|window, cx| window.draw(cx).clear(cx));
514 assert_eq!(
515 cx.debug_bounds("state-button").unwrap().size.width,
516 px(200.)
517 );
518 cx.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
519 }
520 }
521
522 #[gpui_kit::test]
523 fn native_button_exposes_disabled_metadata_without_activation_or_focus_actions(
524 cx: &mut TestAppContext,
525 ) {
526 use gpui_kit::{Element as _, Role, accesskit, canvas};
527
528 type Captured = Rc<RefCell<Option<(accesskit::Node, accesskit::Node)>>>;
529 struct MetadataProbe(Captured);
530
531 impl Render for MetadataProbe {
532 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
533 let captured = self.0.clone();
534 canvas(
535 move |_, window, cx| {
536 let mut metadata = |id: &'static str, disabled| {
537 let native =
538 crate::button(id, "Apply", crate::ButtonVariant::Primary, cx)
539 .accessibility_label("Apply changes")
540 .disabled(disabled)
541 .on_click(|_, _, _| {});
542 let element = native.base.render(window, cx).into_element();
547 assert_eq!(element.id(), Some(id.into()));
548 assert_eq!(element.a11y_role(), Some(Role::Button));
549 let mut node = accesskit::Node::new(Role::Button);
550 element.write_a11y_info(&mut node);
551 node
552 };
553 *captured.borrow_mut() = Some((
554 metadata("metadata-enabled", false),
555 metadata("metadata-disabled", true),
556 ));
557 },
558 |_, _, _, _| {},
559 )
560 }
561 }
562
563 cx.update(crate::init);
564 let captured = Rc::new(RefCell::new(None));
565 let (_, cx) = cx.add_window_view({
566 let captured = captured.clone();
567 move |_, _| MetadataProbe(captured)
568 });
569 cx.update(|window, cx| window.draw(cx).clear(cx));
570 let (enabled, disabled) = captured.borrow_mut().take().unwrap();
571 for node in [&enabled, &disabled] {
572 assert_eq!(node.role(), Role::Button);
573 assert_eq!(node.label(), Some("Apply changes"));
574 }
575 assert!(!enabled.is_disabled());
576 assert!(enabled.supports_action(accesskit::Action::Click));
577 assert!(enabled.supports_action(accesskit::Action::Focus));
578 assert!(disabled.is_disabled());
579 assert!(!disabled.supports_action(accesskit::Action::Click));
580 assert!(!disabled.supports_action(accesskit::Action::Focus));
581 }
582}