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                // Background windows pause animation clocks. Apply theme and
253                // control changes immediately rather than retaining stale fills.
254                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            // Every state paints the sampled fill, so GPUI's instant state
270            // styles keep their borders but cannot jump the background.
271            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    /// The fill a button sampled on its last render.
345    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                            // Resolve the actual Base element used by the
543                            // public macOS constructor and setters. The macOS
544                            // RenderOnce output is an opaque Base component;
545                            // its ViewElement delays this final node writer.
546                            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}