Skip to main content

gpui_macos/
button.rs

1//! Button presentation that suppresses transient styles while disabled.
2use 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/// A macOS button backed by gpui-base's activation and focus behavior.
14/// Transient styles are applied only after the final disabled state is known.
15///
16/// The fill eases between states over 120ms;
17/// borders and text change at once.
18#[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/// The pointer state the fill follows, kept per button across frames.
35#[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    /// The fill while selected. Unlike a `styles` selected refinement, it
61    /// takes part in the fill transition.
62    pub(crate) fn selected_fill(mut self, fill: Hsla) -> Self {
63        self.selected_fill = Some(fill);
64        self
65    }
66
67    /// How long the fill takes to reach a new state.
68    pub(crate) fn fill_duration(mut self, duration: Duration) -> Self {
69        self.fill_duration = duration;
70        self
71    }
72
73    /// A native filled bezel becomes neutral in an inactive window or when
74    /// disabled. This does not change its activation or disabled semantics.
75    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    /// Chained with the listener that drives the fill transition.
176    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
187/// The solid color of a refinement's background, if it sets one.
188fn 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        // The fill for the current state, in interaction precedence: pressed,
223        // keyboard focus, hover, selected, rest. A gradient fill is left to the
224        // instant state styles.
225        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            // Every state paints the sampled fill, so GPUI's instant state
268            // styles keep their borders but cannot jump the background.
269            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    /// The fill a button sampled on its last render.
343    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                            // Resolve the actual Base element used by the
492                            // public macOS constructor and setters. The macOS
493                            // RenderOnce output is an opaque Base component;
494                            // its ViewElement delays this final node writer.
495                            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}