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(self.fill_duration),
253 window,
254 cx,
255 )
256 });
257 #[cfg(test)]
258 if let Some(fill) = fill {
259 tests::record_fill(&self.id, fill);
260 }
261
262 let mut base = self.base;
263 if self.focus.is_none() {
264 base = base.track_focus(&focus);
265 }
266 if let Some(fill) = fill {
267 base = base.bg(fill);
270 for style in [&mut self.hover, &mut self.active, &mut self.focus_visible]
271 .into_iter()
272 .flatten()
273 {
274 if style.background.is_some() {
275 style.background = Some(fill.into());
276 }
277 }
278 }
279 if !self.disabled {
280 if let Some(style) = self.hover.filter(|_| window_active) {
281 base = base.hover(|_| *style);
282 }
283 if let Some(style) = self.active.filter(|_| window_active) {
284 base = base.active(|_| *style);
285 }
286 if let Some(style) = self.focus_visible.filter(|_| window_active) {
287 base = base.focus_visible(|_| *style);
288 }
289 let on_hover = self.on_hover;
290 let hover_state = pointer.clone();
291 let down_state = pointer.clone();
292 let up_state = pointer.clone();
293 let out_state = pointer;
294 base = base
295 .on_hover(move |hovered, window, cx| {
296 hover_state.update(cx, |state, cx| {
297 state.hovered = *hovered;
298 cx.notify();
299 });
300 if let Some(on_hover) = on_hover.as_ref() {
301 on_hover(hovered, window, cx);
302 }
303 })
304 .on_mouse_down(MouseButton::Left, move |_, _, cx| {
305 down_state.update(cx, |state, cx| {
306 state.pressed = true;
307 cx.notify();
308 });
309 })
310 .on_mouse_up(MouseButton::Left, move |_, _, cx| {
311 up_state.update(cx, |state, cx| {
312 state.pressed = false;
313 cx.notify();
314 });
315 })
316 .on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
317 out_state.update(cx, |state, cx| {
318 state.pressed = false;
319 cx.notify();
320 });
321 });
322 } else if let Some(on_hover) = self.on_hover {
323 base = base.on_hover(move |hovered, window, cx| on_hover(hovered, window, cx));
324 }
325 base
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use crate::ActiveTheme;
333 use gpui_kit::px;
334 use gpui_kit::rems;
335 use gpui_kit::{Context, Modifiers, MouseButton, Render, TestAppContext, div, point};
336 use std::{cell::RefCell, collections::HashMap};
337
338 thread_local! {
339 static FILLS: RefCell<HashMap<String, Hsla>> = RefCell::default();
340 }
341
342 pub(super) fn record_fill(id: &ElementId, fill: Hsla) {
344 FILLS.with(|fills| fills.borrow_mut().insert(id.to_string(), fill));
345 }
346
347 fn fill(id: &str) -> Hsla {
348 FILLS.with(|fills| fills.borrow()[id])
349 }
350
351 #[gpui_kit::test]
352 fn fill_eases_to_the_hover_state_and_back(cx: &mut TestAppContext) {
353 cx.update(crate::init);
354 struct Harness;
355 impl Render for Harness {
356 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
357 div().size(rems(25.)).child(
358 crate::button("fill", "Apply", crate::ButtonVariant::Secondary, cx)
359 .w(rems(6.25))
360 .h(rems(2.5)),
361 )
362 }
363 }
364 let (view, cx) = cx.add_window_view(|_, _| Harness);
365 cx.update(|window, _| window.activate_window());
366 cx.run_until_parked();
367 cx.simulate_mouse_move(point(px(300.), px(300.)), None, Modifiers::default());
368 let redraw = |cx: &mut gpui_kit::VisualTestContext| {
369 view.update(cx, |_, cx| cx.notify());
370 cx.update(|window, cx| window.draw(cx).clear(cx));
371 };
372 redraw(cx);
373 let (rest, hover) = cx.update(|_, cx| {
374 let t = cx.macos();
375 (t.control_fill(), t.control_fill().blend(t.hover_fill()))
376 });
377 assert_eq!(fill("fill"), rest);
378
379 cx.simulate_mouse_move(point(px(10.), px(10.)), None, Modifiers::default());
380 redraw(cx);
381 cx.executor()
382 .advance_clock(crate::motion::CONTROL_COLOR / 2);
383 redraw(cx);
384 let halfway = fill("fill");
385 assert_ne!(halfway, rest, "the hover transition has started");
386 assert_ne!(halfway, hover, "the hover transition is still running");
387
388 cx.executor().advance_clock(crate::motion::CONTROL_COLOR);
389 redraw(cx);
390 assert_eq!(fill("fill"), hover);
391
392 cx.simulate_mouse_move(point(px(300.), px(300.)), None, Modifiers::default());
393 redraw(cx);
394 cx.executor()
395 .advance_clock(crate::motion::CONTROL_COLOR * 2);
396 redraw(cx);
397 assert_eq!(fill("fill"), rest);
398 }
399 #[gpui_kit::test]
400 fn disabled_button_suppresses_hover_and_pressed_geometry(cx: &mut TestAppContext) {
401 cx.update(crate::init);
402 struct States {
403 disabled: bool,
404 disabled_first: bool,
405 }
406 impl Render for States {
407 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
408 let button = crate::button("button", "Apply", crate::ButtonVariant::Primary, cx)
409 .w(rems(6.25))
410 .h(rems(2.5));
411 let button = if self.disabled_first {
412 button
413 .disabled(self.disabled)
414 .hover(|s| s.w(rems(10.)))
415 .active(|s| s.w(rems(12.5)))
416 } else {
417 button
418 .hover(|s| s.w(rems(10.)))
419 .active(|s| s.w(rems(12.5)))
420 .disabled(self.disabled)
421 };
422 div()
423 .size(rems(25.))
424 .child(button.debug_selector(|| "state-button".into()))
425 }
426 }
427 for disabled_first in [false, true] {
428 let (view, cx) = cx.add_window_view(move |_, _| States {
429 disabled: true,
430 disabled_first,
431 });
432 cx.update(|window, _| window.activate_window());
433 cx.run_until_parked();
434 cx.update(|window, cx| window.draw(cx).clear(cx));
435 let position = point(px(10.), px(10.));
436 cx.simulate_mouse_move(position, None, Modifiers::default());
437 cx.update(|window, cx| window.draw(cx).clear(cx));
438 assert_eq!(
439 cx.debug_bounds("state-button").unwrap().size.width,
440 px(100.)
441 );
442 cx.simulate_mouse_down(position, MouseButton::Left, Modifiers::default());
443 cx.update(|window, cx| window.draw(cx).clear(cx));
444 assert_eq!(
445 cx.debug_bounds("state-button").unwrap().size.width,
446 px(100.)
447 );
448 cx.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
449 view.update(cx, |this, cx| {
450 this.disabled = false;
451 cx.notify();
452 });
453 cx.update(|window, cx| window.draw(cx).clear(cx));
454 cx.simulate_mouse_move(point(px(300.), px(300.)), None, Modifiers::default());
455 cx.simulate_mouse_move(position, None, Modifiers::default());
456 cx.update(|window, cx| window.draw(cx).clear(cx));
457 assert_eq!(
458 cx.debug_bounds("state-button").unwrap().size.width,
459 px(160.)
460 );
461 cx.simulate_mouse_down(position, MouseButton::Left, Modifiers::default());
462 cx.update(|window, cx| window.draw(cx).clear(cx));
463 assert_eq!(
464 cx.debug_bounds("state-button").unwrap().size.width,
465 px(200.)
466 );
467 cx.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
468 }
469 }
470
471 #[gpui_kit::test]
472 fn native_button_exposes_disabled_metadata_without_activation_or_focus_actions(
473 cx: &mut TestAppContext,
474 ) {
475 use gpui_kit::{Element as _, Role, accesskit, canvas};
476
477 type Captured = Rc<RefCell<Option<(accesskit::Node, accesskit::Node)>>>;
478 struct MetadataProbe(Captured);
479
480 impl Render for MetadataProbe {
481 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
482 let captured = self.0.clone();
483 canvas(
484 move |_, window, cx| {
485 let mut metadata = |id: &'static str, disabled| {
486 let native =
487 crate::button(id, "Apply", crate::ButtonVariant::Primary, cx)
488 .accessibility_label("Apply changes")
489 .disabled(disabled)
490 .on_click(|_, _, _| {});
491 let element = native.base.render(window, cx).into_element();
496 assert_eq!(element.id(), Some(id.into()));
497 assert_eq!(element.a11y_role(), Some(Role::Button));
498 let mut node = accesskit::Node::new(Role::Button);
499 element.write_a11y_info(&mut node);
500 node
501 };
502 *captured.borrow_mut() = Some((
503 metadata("metadata-enabled", false),
504 metadata("metadata-disabled", true),
505 ));
506 },
507 |_, _, _, _| {},
508 )
509 }
510 }
511
512 cx.update(crate::init);
513 let captured = Rc::new(RefCell::new(None));
514 let (_, cx) = cx.add_window_view({
515 let captured = captured.clone();
516 move |_, _| MetadataProbe(captured)
517 });
518 cx.update(|window, cx| window.draw(cx).clear(cx));
519 let (enabled, disabled) = captured.borrow_mut().take().unwrap();
520 for node in [&enabled, &disabled] {
521 assert_eq!(node.role(), Role::Button);
522 assert_eq!(node.label(), Some("Apply changes"));
523 }
524 assert!(!enabled.is_disabled());
525 assert!(enabled.supports_action(accesskit::Action::Click));
526 assert!(enabled.supports_action(accesskit::Action::Focus));
527 assert!(disabled.is_disabled());
528 assert!(!disabled.supports_action(accesskit::Action::Click));
529 assert!(!disabled.supports_action(accesskit::Action::Focus));
530 }
531}