Skip to main content

gpui_cn/
button.rs

1use std::rc::Rc;
2
3use gpui_kit::{
4    AnyElement, App, ClickEvent, ElementId, FocusHandle, Hsla, InteractiveElement, Interactivity,
5    IntoElement, ParentElement, Pixels, RenderOnce, Role, SharedString,
6    StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
7    base::{
8        self, Disableable, ElementExt as _, Interpolate, Placement, Selectable, StyledExt as _,
9        transition,
10    },
11    div,
12    prelude::FluentBuilder as _,
13    px, relative,
14};
15
16use crate::{
17    ActiveTheme as _, Icon, Spinner, Theme, ThemeTokens, TooltipHost,
18    theme::mix,
19    tooltip::{TooltipTrigger, TruncatedLabel},
20};
21
22type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
23type HoverHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
24
25/// The visual treatment of a [`Button`], by meaning.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum ButtonVariant {
28    /// An ordinary action: a soft filled surface with the foreground as
29    /// text.
30    #[default]
31    Default,
32    /// The one emphasized commitment in a decision area: the theme's
33    /// `primary` fill.
34    Primary,
35    /// A destructive commitment.
36    Destructive,
37    /// An ordinary action with a hairline boundary and no fill.
38    Outline,
39    /// A quiet toolbar or inline action with no surface at rest.
40    Ghost,
41    /// Looks like a link, in the link color, for an action that opens a
42    /// resource.
43    Link,
44}
45
46/// The size tier of a [`Button`]. Height, text size, icon size, and
47/// padding change together. Heights are given at the default 16px rem and
48/// scale with the theme's UI font size.
49///
50/// The tiers are the reference app's, not shadcn's: 28px is its
51/// most common control height and 32px its largest. Any tier can be used
52/// anywhere. An icon-only button (an icon and no label or children) is a
53/// square of its tier's height.
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub enum ButtonSize {
56    /// 28px, 13px text.
57    #[default]
58    Default,
59    /// 20px, 12px text.
60    Xs,
61    /// 24px, 13px text.
62    Sm,
63    /// 32px, 14px text, the large radius.
64    Lg,
65}
66
67/// The box a control of one size tier takes: shared by the button and
68/// the select trigger, so a trigger beside a button lines up with it.
69#[derive(Clone, Copy, Debug, PartialEq)]
70pub(crate) struct ControlGeometry {
71    pub radius: Pixels,
72    pub height: Pixels,
73    pub padding: Pixels,
74    pub text_size: Pixels,
75}
76
77impl ButtonSize {
78    pub(crate) fn geometry(self, theme: &ThemeTokens) -> ControlGeometry {
79        let metrics = &theme.metrics;
80        ControlGeometry {
81            radius: if self == Self::Lg {
82                theme.radius_lg()
83            } else {
84                theme.radius_md()
85            },
86            height: match self {
87                Self::Xs => metrics.control_xs,
88                Self::Sm => metrics.control_sm,
89                Self::Default => metrics.control_md,
90                Self::Lg => metrics.control_lg,
91            },
92            padding: match self {
93                Self::Xs => metrics.control_padding_xs,
94                Self::Sm => metrics.control_padding_sm,
95                Self::Default => metrics.control_padding_md,
96                Self::Lg => metrics.control_padding_lg,
97            },
98            text_size: match self {
99                Self::Xs => theme.base.typography.xs.size,
100                Self::Sm | Self::Default => theme.text_control.size,
101                Self::Lg => theme.base.typography.sm.size,
102            },
103        }
104    }
105
106    fn is_xs(self) -> bool {
107        matches!(self, Self::Xs)
108    }
109
110    /// The tiers that use the smaller icon.
111    fn is_compact(self) -> bool {
112        matches!(self, Self::Xs | Self::Sm)
113    }
114}
115
116/// A shadcn-style button on `gpui_base::Button`.
117///
118/// Base owns focus, Tab order, Enter and Space activation, the disabled
119/// contract, and the accessibility role and name. This type owns the look:
120/// variant colors, size geometry, icon and label layout, the animated hover
121/// and pressed surfaces, and the tooltip.
122///
123/// ```
124/// use gpui_cn::{Button, ButtonSize, ButtonVariant, Icon};
125///
126/// let _ = Button::new("save").label("Save").on_click(|_, _, _| {});
127/// let _ = Button::new("create").primary().label("Create");
128/// let _ = Button::new("delete").destructive().label("Delete");
129/// let _ = Button::new("more")
130///     .ghost()
131///     .icon(Icon::new("icons/ellipsis.svg"))
132///     .accessibility_label("More")
133///     .tooltip("More");
134/// ```
135///
136/// The id keys focus, hover state, and motion, so it must be stable across
137/// frames and unique under the nearest stateful ancestor.
138///
139/// `Styled` refinements (`.w_full()`, `.rounded_full()`, `.h(..)`) apply
140/// after the variant and size styles, so they win. Every component panics
141/// when rendered before [`crate::init`].
142#[derive(IntoElement)]
143pub struct Button {
144    id: ElementId,
145    base: base::Button,
146    style: StyleRefinement,
147    variant: ButtonVariant,
148    size: ButtonSize,
149    label: Option<SharedString>,
150    icon: Option<Icon>,
151    trailing_icon: Option<Icon>,
152    children: Vec<AnyElement>,
153    disabled: bool,
154    loading: bool,
155    selected: bool,
156    open: bool,
157    toggled: Option<bool>,
158    accessibility_label: Option<SharedString>,
159    tooltip: Option<(SharedString, Option<Placement>)>,
160    truncation_tooltip: bool,
161    on_click: Option<ClickHandler>,
162    on_hover: Option<HoverHandler>,
163    focus_handle: Option<FocusHandle>,
164    tab_index: isize,
165    tab_stop: bool,
166    /// The role assistive technology reads, when a composite such as a
167    /// menu bar gives its buttons one of its own.
168    role: Option<Role>,
169    /// Whether the popup this button opens is open, for assistive
170    /// technology, when the button is known to open one.
171    expanded: Option<bool>,
172}
173
174impl Button {
175    /// A button with a stable id.
176    pub fn new(id: impl Into<ElementId>) -> Self {
177        let id = id.into();
178        Self {
179            base: base::Button::new(id.clone()),
180            id,
181            style: StyleRefinement::default(),
182            variant: ButtonVariant::default(),
183            size: ButtonSize::default(),
184            label: None,
185            icon: None,
186            trailing_icon: None,
187            children: Vec::new(),
188            disabled: false,
189            loading: false,
190            selected: false,
191            open: false,
192            toggled: None,
193            accessibility_label: None,
194            tooltip: None,
195            truncation_tooltip: true,
196            on_click: None,
197            on_hover: None,
198            focus_handle: None,
199            tab_index: 0,
200            tab_stop: true,
201            role: None,
202            expanded: None,
203        }
204    }
205
206    /// The visual treatment.
207    pub fn variant(mut self, variant: ButtonVariant) -> Self {
208        self.variant = variant;
209        self
210    }
211
212    /// [`ButtonVariant::Primary`].
213    pub fn primary(self) -> Self {
214        self.variant(ButtonVariant::Primary)
215    }
216
217    /// [`ButtonVariant::Destructive`].
218    pub fn destructive(self) -> Self {
219        self.variant(ButtonVariant::Destructive)
220    }
221
222    /// [`ButtonVariant::Outline`].
223    pub fn outline(self) -> Self {
224        self.variant(ButtonVariant::Outline)
225    }
226
227    /// [`ButtonVariant::Ghost`].
228    pub fn ghost(self) -> Self {
229        self.variant(ButtonVariant::Ghost)
230    }
231
232    /// [`ButtonVariant::Link`].
233    pub fn link(self) -> Self {
234        self.variant(ButtonVariant::Link)
235    }
236
237    /// The size tier.
238    pub fn size(mut self, size: ButtonSize) -> Self {
239        self.size = size;
240        self
241    }
242
243    /// The visible text. It is also the accessible name unless
244    /// [`accessibility_label`](Self::accessibility_label) is set.
245    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
246        self.label = Some(label.into());
247        self
248    }
249
250    /// An icon before the label. With no label and no children the button
251    /// is an icon-only square.
252    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
253        self.icon = Some(icon.into());
254        self
255    }
256
257    /// An icon after the label.
258    pub fn trailing_icon(mut self, icon: impl Into<Icon>) -> Self {
259        self.trailing_icon = Some(icon.into());
260        self
261    }
262
263    /// The name a screen reader announces when the visible content is not
264    /// it: an icon-only button, or a button whose content is not text.
265    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
266        self.accessibility_label = Some(label.into());
267        self
268    }
269
270    /// Exposes the button as a toggle to assistive technology, with `toggled`
271    /// as its pressed state. Pair it with [`Selectable::selected`] for the
272    /// pressed look.
273    pub fn toggled(mut self, toggled: bool) -> Self {
274        self.toggled = Some(toggled);
275        self
276    }
277
278    /// Marks the button busy with work it started: a [`Spinner`] takes the
279    /// leading icon's place (or comes before the label when there is no
280    /// icon), and activation does nothing until the work ends. Unlike
281    /// `disabled`, the button keeps its colors and its focus.
282    pub fn loading(mut self, loading: bool) -> Self {
283        self.loading = loading;
284        self
285    }
286
287    /// Whether the button is busy.
288    pub fn is_loading(&self) -> bool {
289        self.loading
290    }
291
292    /// A text tooltip. Icon-only buttons should have one. A button with a
293    /// label and no tooltip shows the full label as its tooltip whenever
294    /// the label is truncated (through the window's tooltip overlay).
295    pub fn tooltip(mut self, text: impl Into<SharedString>) -> Self {
296        self.tooltip = Some((text.into(), None));
297        self
298    }
299
300    /// Whether a truncated label shows the full text as a tooltip. On by
301    /// default; an explicit [`tooltip`](Self::tooltip) replaces it.
302    pub fn truncation_tooltip(mut self, enabled: bool) -> Self {
303        self.truncation_tooltip = enabled;
304        self
305    }
306
307    /// A text tooltip that prefers a side.
308    pub fn tooltip_at(mut self, placement: Placement, text: impl Into<SharedString>) -> Self {
309        self.tooltip = Some((text.into(), Some(placement)));
310        self
311    }
312
313    /// The activation handler for pointer, Enter, and Space.
314    pub fn on_click(
315        mut self,
316        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
317    ) -> Self {
318        self.on_click = Some(Rc::new(handler));
319        self
320    }
321
322    /// Called when the pointer enters (`true`) or leaves (`false`).
323    pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
324        self.on_hover = Some(Rc::new(handler));
325        self
326    }
327
328    /// Uses a caller-owned focus handle instead of one keyed by the id, so
329    /// the owner can focus the button or ask whether it is focused.
330    pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
331        self.focus_handle = Some(focus_handle.clone());
332        self
333    }
334
335    /// The focus traversal index. The default is `0`.
336    pub fn tab_index(mut self, tab_index: isize) -> Self {
337        self.tab_index = tab_index;
338        self
339    }
340
341    /// Whether Tab reaches the button. The default is `true`.
342    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
343        self.tab_stop = tab_stop;
344        self
345    }
346
347    /// The role a composite gives its buttons, such as a menu bar's items.
348    pub(crate) fn with_role(mut self, role: Role) -> Self {
349        self.role = Some(role);
350        self
351    }
352
353    /// Tells assistive technology whether the popup this button opens is
354    /// open, for a composite whose buttons always open one.
355    pub(crate) fn with_expanded(mut self, expanded: bool) -> Self {
356        self.expanded = Some(expanded);
357        self
358    }
359
360    /// Whether the button is an icon with no label or children, and so
361    /// renders as a square.
362    pub fn is_icon_only(&self) -> bool {
363        self.icon.is_some() && self.label.is_none() && self.children.is_empty()
364    }
365
366    /// Whether the button accepts the pointer and activation: not while
367    /// it is disabled, and not while it is busy.
368    fn is_interactive(&self) -> bool {
369        !self.disabled && !self.loading
370    }
371}
372
373impl Disableable for Button {
374    fn disabled(mut self, disabled: bool) -> Self {
375        self.disabled = disabled;
376        self
377    }
378}
379
380impl Selectable for Button {
381    fn selected(mut self, selected: bool) -> Self {
382        self.selected = selected;
383        self
384    }
385
386    fn is_selected(&self) -> bool {
387        self.selected
388    }
389
390    /// The state a popover, menu, or dropdown holds on its trigger while it
391    /// is open. Paints like `selected`, and is kept apart from it because
392    /// the two mean different things.
393    fn open(mut self, open: bool) -> Self {
394        self.open = open;
395        self
396    }
397
398    fn is_open(&self) -> bool {
399        self.open
400    }
401}
402
403impl Styled for Button {
404    fn style(&mut self) -> &mut StyleRefinement {
405        &mut self.style
406    }
407}
408
409impl ParentElement for Button {
410    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
411        self.children.extend(elements);
412    }
413}
414
415impl InteractiveElement for Button {
416    fn interactivity(&mut self) -> &mut Interactivity {
417        self.base.interactivity()
418    }
419}
420
421/// The colors of one variant in one state, mixed as one value so the
422/// three channels always agree.
423#[derive(Clone, Copy, Debug, PartialEq)]
424struct Surface {
425    background: Hsla,
426    foreground: Hsla,
427    border: Hsla,
428}
429
430impl Surface {
431    fn plain(background: Hsla, foreground: Hsla) -> Self {
432        Self {
433            background,
434            foreground,
435            border: gpui_kit::transparent_black(),
436        }
437    }
438}
439
440/// Interpolates each channel of each color. A rest state with no fill uses
441/// the hover color at zero alpha (not transparent black), so only the alpha
442/// moves and no gray passes through mid-transition.
443impl Interpolate for Surface {
444    fn interpolate(&self, target: &Self, progress: f32) -> Self {
445        // Weighted this way, progress 0 and 1 give exactly `from` and `to`
446        // on every platform, so whole state weights paint the state's own
447        // colors.
448        let weigh = |from: f32, to: f32| from * (1. - progress) + to * progress;
449        let lerp = |from: Hsla, to: Hsla| Hsla {
450            h: weigh(from.h, to.h),
451            s: weigh(from.s, to.s),
452            l: weigh(from.l, to.l),
453            a: weigh(from.a, to.a),
454        };
455        Self {
456            background: lerp(self.background, target.background),
457            foreground: lerp(self.foreground, target.foreground),
458            border: lerp(self.border, target.border),
459        }
460    }
461}
462
463/// What the pointer is doing to the button.
464#[derive(Clone, Copy, Debug, PartialEq, Eq)]
465enum PointerState {
466    Rest,
467    Hovered,
468    Pressed,
469}
470
471/// How far the button is into each state, from 0 to 1. The weights
472/// animate and the colors are mixed from the current theme each frame,
473/// so a theme change repaints at once instead of fading from the old
474/// theme's colors.
475#[derive(Clone, Copy, Debug, PartialEq)]
476struct StateWeights {
477    hovered: f32,
478    pressed: f32,
479    selected: f32,
480    disabled: f32,
481}
482
483impl StateWeights {
484    fn of(pointer: PointerState, selected: bool, disabled: bool) -> Self {
485        let weight = |on: bool| if on { 1. } else { 0. };
486        Self {
487            hovered: weight(pointer != PointerState::Rest),
488            pressed: weight(pointer == PointerState::Pressed),
489            selected: weight(selected),
490            disabled: weight(disabled),
491        }
492    }
493}
494
495impl Interpolate for StateWeights {
496    fn interpolate(&self, target: &Self, progress: f32) -> Self {
497        let lerp = |from: f32, to: f32| from + (to - from) * progress;
498        Self {
499            hovered: lerp(self.hovered, target.hovered),
500            pressed: lerp(self.pressed, target.pressed),
501            selected: lerp(self.selected, target.selected),
502            disabled: lerp(self.disabled, target.disabled),
503        }
504    }
505}
506
507impl ButtonVariant {
508    /// The surface for a pointer state, with `selected` for a persistent
509    /// pressed, checked, or open look on the variants that have a surface.
510    ///
511    /// Every surface carries a border color so it can animate. Only
512    /// `Outline` paints a border width.
513    ///
514    /// Inside a sidebar the soft and selected fills are the sidebar's, as
515    /// shadcn remaps its variables there, so a hover reads on the lifted
516    /// surface. A selected surface still answers the pointer: it lifts a
517    /// step on hover and another when pressed.
518    fn surface(
519        self,
520        state: PointerState,
521        selected: bool,
522        in_sidebar: bool,
523        theme: &ThemeTokens,
524    ) -> Surface {
525        use PointerState::*;
526        let dark = theme.is_dark();
527        let ink = theme.foreground();
528        let (surface, soft, selected_rest) = if in_sidebar {
529            (theme.sidebar, theme.sidebar_accent, theme.sidebar_selected)
530        } else {
531            (theme.background(), theme.secondary(), theme.selected)
532        };
533        // The soft fill steps toward the ink as the pointer presses.
534        let soft_hover = mix(soft, ink, 0.05);
535        let soft_pressed = mix(soft, ink, 0.10);
536        let selected_fill = match state {
537            Rest => selected_rest,
538            Hovered => mix(selected_rest, ink, 0.03),
539            Pressed => mix(selected_rest, ink, 0.06),
540        };
541        match self {
542            Self::Default => {
543                if selected {
544                    return Surface::plain(selected_fill, ink);
545                }
546                let fill = match state {
547                    Rest => soft,
548                    Hovered => soft_hover,
549                    Pressed => soft_pressed,
550                };
551                Surface::plain(fill, ink)
552            }
553            Self::Primary => {
554                let primary = theme.primary();
555                let fill = match state {
556                    Rest => primary,
557                    Hovered => mix(primary, surface, 0.09),
558                    Pressed => mix(primary, surface, 0.18),
559                };
560                Surface::plain(fill, theme.primary_foreground())
561            }
562            Self::Destructive => {
563                // A tint of the destructive color with the color as text,
564                // not a solid fill: the reference keeps the alarm quiet
565                // until the pointer commits to it.
566                let red = theme.destructive();
567                let fill = match state {
568                    Rest => theme.destructive_tint,
569                    Hovered => red.opacity(if dark { 0.3 } else { 0.16 }),
570                    Pressed => red.opacity(if dark { 0.4 } else { 0.22 }),
571                };
572                Surface::plain(fill, red)
573            }
574            Self::Outline => {
575                let hover = soft;
576                let background = if selected {
577                    selected_fill
578                } else {
579                    match state {
580                        Rest => hover.alpha(0.),
581                        Hovered => hover,
582                        Pressed => soft_hover,
583                    }
584                };
585                Surface {
586                    background,
587                    foreground: ink,
588                    border: theme.input(),
589                }
590            }
591            Self::Ghost => {
592                // Quiet at rest, as the reference draws unselected segments
593                // and row icons: muted text, no surface. Hover and selection
594                // bring the foreground and a fill.
595                if selected {
596                    return Surface::plain(selected_fill, ink);
597                }
598                match state {
599                    Rest => Surface::plain(soft.alpha(0.), theme.muted_foreground()),
600                    Hovered => Surface::plain(soft, ink),
601                    Pressed => Surface::plain(soft_hover, ink),
602                }
603            }
604            Self::Link => Surface::plain(gpui_kit::transparent_black(), theme.link),
605        }
606    }
607
608    /// The surface at `weights`, mixed from the surfaces of the current
609    /// theme. Whole weights give exactly the surface of that state.
610    fn surface_at(self, weights: StateWeights, in_sidebar: bool, theme: &ThemeTokens) -> Surface {
611        use PointerState::*;
612        let at = |selected: bool| {
613            let surface = |state| self.surface(state, selected, in_sidebar, theme);
614            surface(Rest)
615                .interpolate(&surface(Hovered), weights.hovered)
616                .interpolate(&surface(Pressed), weights.pressed)
617        };
618        let enabled = at(false).interpolate(&at(true), weights.selected);
619        if weights.disabled == 0. {
620            return enabled;
621        }
622        enabled.interpolate(&self.disabled(enabled, theme), weights.disabled)
623    }
624
625    /// `surface` as a disabled button paints it. The reference keeps a
626    /// disabled button's fill at half strength and drops its text most of
627    /// the way to the surface (#6e6e6e on #181818), rather than fading the
628    /// whole control.
629    fn disabled(self, surface: Surface, theme: &ThemeTokens) -> Surface {
630        Surface {
631            background: surface.background.opacity(theme.disabled_opacity),
632            border: surface.border.opacity(theme.disabled_opacity),
633            foreground: match self {
634                ButtonVariant::Primary => surface.foreground,
635                _ => mix(
636                    theme.foreground(),
637                    theme.background(),
638                    if theme.is_dark() { 0.52 } else { 0.64 },
639                ),
640            },
641        }
642    }
643}
644
645/// Everything the render needs from the theme, read in one borrow so the
646/// tokens are never cloned.
647struct Look {
648    surface: Surface,
649    underline: bool,
650    ring: Hsla,
651    ring_spread: Pixels,
652    outline_focus_border: Hsla,
653    geometry: ControlGeometry,
654}
655
656/// Sizes an icon, or the spinner in its place, to the button's tier.
657fn icon_size_rem<E: Styled>(icon: E, size: ButtonSize) -> E {
658    if size.is_xs() {
659        icon.size_3()
660    } else if size.is_compact() {
661        icon.size_3p5()
662    } else {
663        icon.size_4()
664    }
665}
666
667impl RenderOnce for Button {
668    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
669        let settings = Theme::global(cx);
670        let fast = settings.motion.fast_transition();
671        let pointer_cursors = settings.pointer_cursors;
672
673        let interactive = self.is_interactive();
674        let icon_only = self.is_icon_only();
675        let variant = self.variant;
676        let size = self.size;
677        let disabled = self.disabled;
678        let shows_selected = self.selected || self.open;
679
680        // The pointer state lives in keyed element state so the surface can
681        // animate toward it. GPUI's own `hover`/`active` refinements would
682        // flip instantly.
683        let pointer = window.use_keyed_state(
684            ElementId::NamedChild(self.id.clone().into(), "pointer".into()),
685            cx,
686            |_, _| PointerState::Rest,
687        );
688        if !interactive && *pointer.read(cx) != PointerState::Rest {
689            // A button that is disabled while hovered or pressed must not
690            // come back painted that way.
691            pointer.update(cx, |state, _| *state = PointerState::Rest);
692        }
693        // A finger cannot hover. GPUI still reports the last tap's point
694        // as hovered, so on touch the hover state paints as rest; a press
695        // still paints as pressed.
696        let touch = cx.theme().touch;
697        let pointer_state = match *pointer.read(cx) {
698            PointerState::Hovered if touch => PointerState::Rest,
699            state => state,
700        };
701
702        let in_sidebar = crate::sidebar::in_sidebar(cx);
703        let weights = transition(
704            ElementId::NamedChild(self.id.clone().into(), "state".into()),
705            StateWeights::of(pointer_state, shows_selected, disabled),
706            fast,
707            window,
708            cx,
709        );
710        let look =
711            {
712                let theme = cx.theme();
713                Look {
714                    surface: variant.surface_at(weights, in_sidebar, theme),
715                    underline: variant == ButtonVariant::Link
716                        && (shows_selected || pointer_state != PointerState::Rest),
717                    ring: match variant {
718                        ButtonVariant::Destructive => theme
719                            .destructive()
720                            .opacity(if theme.is_dark() { 0.4 } else { 0.2 }),
721                        _ => theme.focus_ring(),
722                    },
723                    ring_spread: theme.metrics.focus_ring,
724                    outline_focus_border: theme.ring(),
725                    geometry: size.geometry(theme),
726                }
727            };
728        let surface = look.surface;
729
730        let focus_handle = self.focus_handle.clone().unwrap_or_else(|| {
731            window
732                .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
733                .read(cx)
734                .clone()
735        });
736        let focus_visible = focus_handle.is_focused(window) && window.last_input_was_keyboard();
737
738        let spinner = self.loading.then(|| {
739            icon_size_rem(
740                Spinner::new(ElementId::NamedChild(
741                    self.id.clone().into(),
742                    "spinner".into(),
743                )),
744                size,
745            )
746        });
747
748        let accessibility_label = self.accessibility_label.or_else(|| self.label.clone());
749        let on_click = self.on_click;
750        let on_hover = self.on_hover;
751        let pointer_for_hover = pointer.clone();
752        let pointer_for_down = pointer.clone();
753        let pointer_for_up = pointer.clone();
754        let pointer_for_up_out = pointer;
755        let label = self
756            .label
757            .map(|label| (label.clone(), TruncatedLabel::new(label).into_parts()));
758        let has_overlay = TooltipHost::overlay(window, cx).is_some();
759        let tooltip = match (self.tooltip, &label) {
760            (Some((text, placement)), _) => {
761                Some((TooltipTrigger::text(self.id.clone(), placement, text), None))
762            }
763            (None, Some((label, (_, check)))) if has_overlay && self.truncation_tooltip => Some((
764                TooltipTrigger::text(self.id.clone(), None, label.clone()),
765                Some(check.clone()),
766            )),
767            _ => None,
768        };
769        // GPUI allows one hover listener per element, so the tooltip rides on
770        // the button's own; a window without an overlay gets the native one.
771        let (managed_tooltip, native_tooltip) = match tooltip {
772            Some((trigger, only_when_truncated)) if has_overlay => {
773                (Some((trigger, only_when_truncated)), None)
774            }
775            Some((trigger, _)) => (None, Some(trigger)),
776            None => (None, None),
777        };
778        let content = div()
779            .flex()
780            .items_center()
781            .justify_center()
782            // Base sets the button's line box to exactly the font size. The
783            // label clips to its line box for truncation, so the box must be
784            // taller than the glyphs or descenders are cut.
785            .line_height(relative(1.25))
786            .min_w_0()
787            .whitespace_nowrap()
788            .map(|this| {
789                if size.is_xs() {
790                    this.gap_1()
791                } else {
792                    this.gap_1p5()
793                }
794            })
795            .map(|this| match spinner {
796                Some(spinner) => this.child(spinner),
797                None => this.when_some(self.icon, |this, icon| {
798                    this.child(icon_size_rem(icon, size))
799                }),
800            })
801            .when_some(label, |this, (_, (text, _))| {
802                this.child(
803                    div()
804                        .min_w_0()
805                        .overflow_hidden()
806                        .whitespace_nowrap()
807                        .text_ellipsis()
808                        .when(look.underline && interactive, |this| {
809                            this.text_decoration_1()
810                        })
811                        .child(text),
812                )
813            })
814            .children(self.children)
815            .when_some(self.trailing_icon, |this, icon| {
816                this.child(icon_size_rem(icon, size))
817            });
818
819        self.base
820            .flex_shrink_0()
821            .font_medium()
822            .text_size(look.geometry.text_size)
823            .rounded(look.geometry.radius)
824            .h(look.geometry.height)
825            .px(look.geometry.padding)
826            .when(icon_only, |this| this.px_0().w(look.geometry.height))
827            .bg(surface.background)
828            .text_color(surface.foreground)
829            // Every variant carries the same 1px border, transparent where it
830            // does not show, so an outline button is exactly as large as the
831            // others and the border color can animate between states.
832            .border_1()
833            .border_color(if focus_visible && variant == ButtonVariant::Outline {
834                look.outline_focus_border
835            } else {
836                surface.border
837            })
838            .when(focus_visible, |this| {
839                // A 3px spread outside the box; an `overflow_hidden` ancestor
840                // that hugs the button clips it, so leave room.
841                this.shadow(vec![gpui_kit::BoxShadow {
842                    color: look.ring,
843                    offset: gpui_kit::point(px(0.), px(0.)),
844                    blur_radius: px(0.),
845                    spread_radius: look.ring_spread,
846                    inset: false,
847                }])
848            })
849            .map(|this| {
850                if interactive && (pointer_cursors || variant == ButtonVariant::Link) {
851                    this.cursor_pointer()
852                } else {
853                    this.cursor_default()
854                }
855            })
856            .refine_style(&self.style)
857            .role(self.role.unwrap_or(if variant == ButtonVariant::Link {
858                Role::Link
859            } else {
860                Role::Button
861            }))
862            .selected(shows_selected)
863            .disabled(disabled)
864            .when_some(accessibility_label, |this, label| {
865                this.accessibility_label(label)
866            })
867            .when_some(self.expanded, |this, expanded| this.aria_expanded(expanded))
868            .when_some(self.toggled, |this, toggled| {
869                this.aria_toggled(if toggled {
870                    gpui_kit::accesskit::Toggled::True
871                } else {
872                    gpui_kit::accesskit::Toggled::False
873                })
874            })
875            .track_focus(&focus_handle)
876            .tab_index(self.tab_index)
877            .tab_stop(self.tab_stop)
878            .child(content)
879            .when_some(managed_tooltip.as_ref(), |this, (trigger, _)| {
880                this.on_prepaint(trigger.track_bounds())
881            })
882            .when_some(native_tooltip, |this, trigger| {
883                this.tooltip(trigger.native())
884            })
885            .on_hover(move |hovered, window, cx| {
886                match &managed_tooltip {
887                    Some((trigger, Some(check))) => check.hovered(trigger, *hovered, window, cx),
888                    Some((trigger, None)) => trigger.hovered(*hovered, window, cx),
889                    None => {}
890                }
891                if !interactive {
892                    return;
893                }
894                pointer_for_hover.update(cx, |state, cx| {
895                    // GPUI reports "not hovered" while a press is pending
896                    // and the pointer moves; a press stays pressed until the
897                    // button is released.
898                    let next = match (*hovered, *state) {
899                        (false, PointerState::Pressed) => PointerState::Pressed,
900                        (true, _) => PointerState::Hovered,
901                        (false, _) => PointerState::Rest,
902                    };
903                    if *state != next {
904                        *state = next;
905                        cx.notify();
906                    }
907                });
908                if let Some(on_hover) = &on_hover {
909                    on_hover(hovered, window, cx);
910                }
911            })
912            .when(interactive, |this| {
913                this.on_mouse_down(gpui_kit::MouseButton::Left, move |_, window, cx| {
914                    // Pressing must not move focus with the pointer, and must
915                    // not start the window text selection.
916                    window.prevent_default();
917                    base::GlobalState::suppress_text_selection(cx);
918                    TooltipTrigger::pressed(window, cx);
919                    pointer_for_down.update(cx, |state, cx| {
920                        *state = PointerState::Pressed;
921                        cx.notify();
922                    });
923                })
924                .on_mouse_up(gpui_kit::MouseButton::Left, move |_, _, cx| {
925                    pointer_for_up.update(cx, |state, cx| {
926                        if *state == PointerState::Pressed {
927                            *state = PointerState::Hovered;
928                            cx.notify();
929                        }
930                    });
931                })
932                .on_mouse_up_out(gpui_kit::MouseButton::Left, move |_, _, cx| {
933                    pointer_for_up_out.update(cx, |state, cx| {
934                        if *state != PointerState::Rest {
935                            *state = PointerState::Rest;
936                            cx.notify();
937                        }
938                    });
939                })
940            })
941            .when_some(on_click.filter(|_| interactive), |this, on_click| {
942                this.on_click(move |event, window, cx| on_click(event, window, cx))
943            })
944    }
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950    use crate::theme::{ThemeConfig, to_hex};
951    use gpui_kit::base::ThemeAppearance;
952
953    fn dark() -> ThemeTokens {
954        crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark)
955    }
956
957    #[test]
958    fn whole_weights_mix_to_the_surface_of_that_state() {
959        let theme = dark();
960        let ghost = ButtonVariant::Ghost;
961        for pointer in [
962            PointerState::Rest,
963            PointerState::Hovered,
964            PointerState::Pressed,
965        ] {
966            for selected in [false, true] {
967                let weights = StateWeights::of(pointer, selected, false);
968                assert_eq!(
969                    ghost.surface_at(weights, true, &theme),
970                    ghost.surface(pointer, selected, true, &theme),
971                    "{pointer:?}, selected {selected}"
972                );
973            }
974        }
975        let selected = ghost.surface(PointerState::Rest, true, false, &theme);
976        assert_eq!(
977            ghost.surface_at(
978                StateWeights::of(PointerState::Rest, true, true),
979                false,
980                &theme
981            ),
982            ghost.disabled(selected, &theme),
983        );
984    }
985
986    #[test]
987    fn primary_reads_the_theme_tokens() {
988        let theme = dark();
989        let rest = ButtonVariant::Primary.surface(PointerState::Rest, false, false, &theme);
990        assert_eq!(to_hex(rest.background), "#dfdfdf");
991        assert_eq!(to_hex(rest.foreground), "#2d2d2d");
992        let hovered = ButtonVariant::Primary.surface(PointerState::Hovered, false, false, &theme);
993        assert!(hovered.background.l < rest.background.l);
994    }
995
996    #[test]
997    fn rest_surfaces_without_a_fill_share_the_hover_color_at_zero_alpha() {
998        let theme = dark();
999        for variant in [ButtonVariant::Outline, ButtonVariant::Ghost] {
1000            let rest = variant.surface(PointerState::Rest, false, false, &theme);
1001            let hovered = variant.surface(PointerState::Hovered, false, false, &theme);
1002            assert_eq!(rest.background.a, 0., "{variant:?} rest is clear");
1003            assert_eq!(
1004                rest.background.l, hovered.background.l,
1005                "{variant:?} only alpha moves"
1006            );
1007            let mid = rest.interpolate(&hovered, 0.5);
1008            assert_eq!(mid.background.l, hovered.background.l);
1009            assert!((mid.background.a - 0.5).abs() < 1e-6);
1010        }
1011    }
1012
1013    #[test]
1014    fn default_soft_fill_matches_the_reference() {
1015        let theme = dark();
1016        let rest = ButtonVariant::Default.surface(PointerState::Rest, false, false, &theme);
1017        assert_eq!(to_hex(rest.background), "#222222");
1018        assert_eq!(to_hex(rest.foreground), "#dfdfdf");
1019        let ghost = ButtonVariant::Ghost.surface(PointerState::Rest, false, false, &theme);
1020        assert_eq!(to_hex(ghost.foreground), "#969696");
1021    }
1022
1023    #[test]
1024    fn inside_a_sidebar_the_fills_are_the_sidebars() {
1025        let theme = dark();
1026        let hovered = ButtonVariant::Ghost.surface(PointerState::Hovered, false, true, &theme);
1027        assert_eq!(hovered.background, theme.sidebar_accent);
1028        let selected = ButtonVariant::Ghost.surface(PointerState::Rest, true, true, &theme);
1029        assert_eq!(selected.background, theme.sidebar_selected);
1030        assert!(hovered.background.l < selected.background.l);
1031        let selected_hovered =
1032            ButtonVariant::Ghost.surface(PointerState::Hovered, true, true, &theme);
1033        assert!(selected_hovered.background.l > selected.background.l);
1034        let window = ButtonVariant::Ghost.surface(PointerState::Hovered, false, false, &theme);
1035        assert_eq!(window.background, theme.secondary());
1036    }
1037}