use std::rc::Rc;
use gpui_kit::{
AnyElement, App, ClickEvent, ElementId, FocusHandle, Hsla, InteractiveElement, Interactivity,
IntoElement, ParentElement, Pixels, RenderOnce, Role, SharedString,
StatefulInteractiveElement as _, StyleRefinement, Styled, StyledText, TextLayout, Window,
base::{
self, Disableable, ElementExt as _, Interpolate, Placement, Selectable, StyledExt as _,
transition,
},
div,
prelude::FluentBuilder as _,
px, relative,
};
use crate::{
ActiveTheme as _, Icon, Theme, ThemeTokens, TooltipHost, theme::mix, tooltip::TooltipTrigger,
};
type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
type HoverHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ButtonVariant {
#[default]
Default,
Primary,
Destructive,
Outline,
Ghost,
Link,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ButtonSize {
#[default]
Default,
Xs,
Sm,
Lg,
}
impl ButtonSize {
fn is_xs(self) -> bool {
matches!(self, Self::Xs)
}
fn is_compact(self) -> bool {
matches!(self, Self::Xs | Self::Sm)
}
}
#[derive(IntoElement)]
pub struct Button {
id: ElementId,
base: base::Button,
style: StyleRefinement,
variant: ButtonVariant,
size: ButtonSize,
label: Option<SharedString>,
icon: Option<Icon>,
trailing_icon: Option<Icon>,
children: Vec<AnyElement>,
disabled: bool,
selected: bool,
open: bool,
toggled: Option<bool>,
accessibility_label: Option<SharedString>,
tooltip: Option<(SharedString, Option<Placement>)>,
truncation_tooltip: bool,
on_click: Option<ClickHandler>,
on_hover: Option<HoverHandler>,
focus_handle: Option<FocusHandle>,
tab_index: isize,
tab_stop: bool,
}
impl Button {
pub fn new(id: impl Into<ElementId>) -> Self {
let id = id.into();
Self {
base: base::Button::new(id.clone()),
id,
style: StyleRefinement::default(),
variant: ButtonVariant::default(),
size: ButtonSize::default(),
label: None,
icon: None,
trailing_icon: None,
children: Vec::new(),
disabled: false,
selected: false,
open: false,
toggled: None,
accessibility_label: None,
tooltip: None,
truncation_tooltip: true,
on_click: None,
on_hover: None,
focus_handle: None,
tab_index: 0,
tab_stop: true,
}
}
pub fn variant(mut self, variant: ButtonVariant) -> Self {
self.variant = variant;
self
}
pub fn primary(self) -> Self {
self.variant(ButtonVariant::Primary)
}
pub fn destructive(self) -> Self {
self.variant(ButtonVariant::Destructive)
}
pub fn outline(self) -> Self {
self.variant(ButtonVariant::Outline)
}
pub fn ghost(self) -> Self {
self.variant(ButtonVariant::Ghost)
}
pub fn link(self) -> Self {
self.variant(ButtonVariant::Link)
}
pub fn size(mut self, size: ButtonSize) -> Self {
self.size = size;
self
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn trailing_icon(mut self, icon: impl Into<Icon>) -> Self {
self.trailing_icon = Some(icon.into());
self
}
pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
self.accessibility_label = Some(label.into());
self
}
pub fn toggled(mut self, toggled: bool) -> Self {
self.toggled = Some(toggled);
self
}
pub fn open(mut self, open: bool) -> Self {
self.open = open;
self
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn tooltip(mut self, text: impl Into<SharedString>) -> Self {
self.tooltip = Some((text.into(), None));
self
}
pub fn truncation_tooltip(mut self, enabled: bool) -> Self {
self.truncation_tooltip = enabled;
self
}
pub fn tooltip_at(mut self, placement: Placement, text: impl Into<SharedString>) -> Self {
self.tooltip = Some((text.into(), Some(placement)));
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_hover = Some(Rc::new(handler));
self
}
pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
self.focus_handle = Some(focus_handle.clone());
self
}
pub fn tab_index(mut self, tab_index: isize) -> Self {
self.tab_index = tab_index;
self
}
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
self.tab_stop = tab_stop;
self
}
pub fn is_icon_only(&self) -> bool {
self.icon.is_some() && self.label.is_none() && self.children.is_empty()
}
fn is_interactive(&self) -> bool {
!self.disabled
}
}
impl Disableable for Button {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Selectable for Button {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl Styled for Button {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl ParentElement for Button {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl InteractiveElement for Button {
fn interactivity(&mut self) -> &mut Interactivity {
self.base.interactivity()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct Surface {
background: Hsla,
foreground: Hsla,
border: Hsla,
}
impl Surface {
fn plain(background: Hsla, foreground: Hsla) -> Self {
Self {
background,
foreground,
border: gpui_kit::transparent_black(),
}
}
}
impl Interpolate for Surface {
fn interpolate(&self, target: &Self, progress: f32) -> Self {
let lerp = |from: Hsla, to: Hsla| Hsla {
h: from.h + (to.h - from.h) * progress,
s: from.s + (to.s - from.s) * progress,
l: from.l + (to.l - from.l) * progress,
a: from.a + (to.a - from.a) * progress,
};
Self {
background: lerp(self.background, target.background),
foreground: lerp(self.foreground, target.foreground),
border: lerp(self.border, target.border),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PointerState {
Rest,
Hovered,
Pressed,
}
impl ButtonVariant {
fn surface(
self,
state: PointerState,
selected: bool,
in_sidebar: bool,
theme: &ThemeTokens,
) -> Surface {
use PointerState::*;
let dark = theme.is_dark();
let ink = theme.foreground();
let (surface, soft, selected_rest) = if in_sidebar {
(theme.sidebar, theme.sidebar_accent, theme.sidebar_selected)
} else {
(theme.background(), theme.secondary(), theme.selected)
};
let soft_hover = mix(soft, ink, 0.05);
let soft_pressed = mix(soft, ink, 0.10);
let selected_fill = match state {
Rest => selected_rest,
Hovered => mix(selected_rest, ink, 0.03),
Pressed => mix(selected_rest, ink, 0.06),
};
match self {
Self::Default => {
if selected {
return Surface::plain(selected_fill, ink);
}
let fill = match state {
Rest => soft,
Hovered => soft_hover,
Pressed => soft_pressed,
};
Surface::plain(fill, ink)
}
Self::Primary => {
let primary = theme.primary();
let fill = match state {
Rest => primary,
Hovered => mix(primary, surface, 0.09),
Pressed => mix(primary, surface, 0.18),
};
Surface::plain(fill, theme.primary_foreground())
}
Self::Destructive => {
let red = theme.destructive();
let fill = match state {
Rest => red.opacity(if dark { 0.2 } else { 0.1 }),
Hovered => red.opacity(if dark { 0.3 } else { 0.16 }),
Pressed => red.opacity(if dark { 0.4 } else { 0.22 }),
};
Surface::plain(fill, red)
}
Self::Outline => {
let hover = soft;
let background = if selected {
selected_fill
} else {
match state {
Rest => hover.alpha(0.),
Hovered => hover,
Pressed => soft_hover,
}
};
Surface {
background,
foreground: ink,
border: theme.input(),
}
}
Self::Ghost => {
if selected {
return Surface::plain(selected_fill, ink);
}
match state {
Rest => Surface::plain(soft.alpha(0.), theme.muted_foreground()),
Hovered => Surface::plain(soft, ink),
Pressed => Surface::plain(soft_hover, ink),
}
}
Self::Link => Surface::plain(gpui_kit::transparent_black(), theme.link),
}
}
}
struct Look {
target: Surface,
underline: bool,
ring: Hsla,
ring_spread: Pixels,
outline_focus_border: Hsla,
radius: Pixels,
height: Pixels,
padding: Pixels,
text_size: Pixels,
}
impl RenderOnce for Button {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let settings = Theme::global(cx);
let fast = settings.motion.fast_transition();
let pointer_cursors = settings.pointer_cursors;
let interactive = self.is_interactive();
let icon_only = self.is_icon_only();
let variant = self.variant;
let size = self.size;
let disabled = self.disabled;
let shows_selected = self.selected || self.open;
let pointer = window.use_keyed_state(
ElementId::NamedChild(self.id.clone().into(), "pointer".into()),
cx,
|_, _| PointerState::Rest,
);
if !interactive && *pointer.read(cx) != PointerState::Rest {
pointer.update(cx, |state, _| *state = PointerState::Rest);
}
let touch = cx.theme().touch;
let pointer_state = match *pointer.read(cx) {
PointerState::Hovered if touch => PointerState::Rest,
state => state,
};
let in_sidebar = crate::sidebar::in_sidebar(cx);
let look =
{
let theme = cx.theme();
let mut target = variant.surface(pointer_state, shows_selected, in_sidebar, theme);
if disabled {
target.background = target.background.opacity(0.55);
target.border = target.border.opacity(0.55);
target.foreground = match variant {
ButtonVariant::Primary => target.foreground,
_ => mix(
theme.foreground(),
theme.background(),
if theme.is_dark() { 0.52 } else { 0.64 },
),
};
}
Look {
target,
underline: variant == ButtonVariant::Link
&& (shows_selected || pointer_state != PointerState::Rest),
ring: match variant {
ButtonVariant::Destructive => theme
.destructive()
.opacity(if theme.is_dark() { 0.4 } else { 0.2 }),
_ => theme.ring().opacity(0.5),
},
ring_spread: theme.metrics.focus_ring,
outline_focus_border: theme.ring(),
radius: if size == ButtonSize::Lg {
theme.radius_lg()
} else {
theme.radius_md()
},
height: match size {
ButtonSize::Xs => theme.metrics.control_xs,
ButtonSize::Sm => theme.metrics.control_sm,
ButtonSize::Default => theme.metrics.control_md,
ButtonSize::Lg => theme.metrics.control_lg,
},
padding: match size {
ButtonSize::Xs => theme.metrics.control_padding_xs,
ButtonSize::Sm => theme.metrics.control_padding_sm,
ButtonSize::Default => theme.metrics.control_padding_md,
ButtonSize::Lg => theme.metrics.control_padding_lg,
},
text_size: match size {
ButtonSize::Xs => theme.base.typography.xs.size,
ButtonSize::Sm | ButtonSize::Default => theme.text_control.size,
ButtonSize::Lg => theme.base.typography.sm.size,
},
}
};
let surface = transition(
ElementId::NamedChild(self.id.clone().into(), "surface".into()),
look.target,
fast,
window,
cx,
);
let focus_handle = self.focus_handle.clone().unwrap_or_else(|| {
window
.use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
.read(cx)
.clone()
});
let focus_visible = focus_handle.is_focused(window) && window.last_input_was_keyboard();
let icon_size_rem = move |icon: Icon| {
if size.is_xs() {
icon.size_3()
} else if size.is_compact() {
icon.size_3p5()
} else {
icon.size_4()
}
};
let accessibility_label = self.accessibility_label.or_else(|| self.label.clone());
let on_click = self.on_click;
let on_hover = self.on_hover;
let pointer_for_hover = pointer.clone();
let pointer_for_down = pointer.clone();
let pointer_for_up = pointer.clone();
let pointer_for_up_out = pointer;
let label = self.label.map(|label| {
let text = StyledText::new(label.clone());
let layout = text.layout().clone();
(label, text, layout)
});
let has_overlay = TooltipHost::overlay(window, cx).is_some();
let tooltip = match (self.tooltip, &label) {
(Some((text, placement)), _) => {
Some((TooltipTrigger::text(self.id.clone(), placement, text), None))
}
(None, Some((label, _, layout))) if has_overlay && self.truncation_tooltip => Some((
TooltipTrigger::text(self.id.clone(), None, label.clone()),
Some((label.clone(), layout.clone())),
)),
_ => None,
};
let (managed_tooltip, native_tooltip) = match tooltip {
Some((trigger, only_when_truncated)) if has_overlay => {
(Some((trigger, only_when_truncated)), None)
}
Some((trigger, _)) => (None, Some(trigger)),
None => (None, None),
};
let content = div()
.flex()
.items_center()
.justify_center()
.line_height(relative(1.25))
.min_w_0()
.whitespace_nowrap()
.map(|this| {
if size.is_xs() {
this.gap_1()
} else {
this.gap_1p5()
}
})
.when_some(self.icon, |this, icon| this.child(icon_size_rem(icon)))
.when_some(label, |this, (_, text, _)| {
this.child(
div()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.when(look.underline && interactive, |this| {
this.text_decoration_1()
})
.child(text),
)
})
.children(self.children)
.when_some(self.trailing_icon, |this, icon| {
this.child(icon_size_rem(icon))
});
self.base
.flex_shrink_0()
.font_medium()
.text_size(look.text_size)
.rounded(look.radius)
.h(look.height)
.px(look.padding)
.when(icon_only, |this| this.px_0().w(look.height))
.bg(surface.background)
.text_color(surface.foreground)
.border_1()
.border_color(if focus_visible && variant == ButtonVariant::Outline {
look.outline_focus_border
} else {
surface.border
})
.when(focus_visible, |this| {
this.shadow(vec![gpui_kit::BoxShadow {
color: look.ring,
offset: gpui_kit::point(px(0.), px(0.)),
blur_radius: px(0.),
spread_radius: look.ring_spread,
inset: false,
}])
})
.map(|this| {
if interactive && (pointer_cursors || variant == ButtonVariant::Link) {
this.cursor_pointer()
} else {
this.cursor_default()
}
})
.refine_style(&self.style)
.role(if variant == ButtonVariant::Link {
Role::Link
} else {
Role::Button
})
.selected(shows_selected)
.disabled(disabled)
.when_some(accessibility_label, |this, label| {
this.accessibility_label(label)
})
.when_some(self.toggled, |this, toggled| {
this.aria_toggled(if toggled {
gpui_kit::accesskit::Toggled::True
} else {
gpui_kit::accesskit::Toggled::False
})
})
.track_focus(&focus_handle)
.tab_index(self.tab_index)
.tab_stop(self.tab_stop)
.child(content)
.when_some(managed_tooltip.as_ref(), |this, (trigger, _)| {
this.on_prepaint(trigger.track_bounds())
})
.when_some(native_tooltip, |this, trigger| {
this.tooltip(trigger.native())
})
.on_hover(move |hovered, window, cx| {
if let Some((trigger, only_when_truncated)) = &managed_tooltip {
let wanted = match only_when_truncated {
Some((label, layout)) => is_truncated(layout, label),
None => true,
};
if wanted || !*hovered {
trigger.hovered(*hovered, window, cx);
}
}
if !interactive {
return;
}
pointer_for_hover.update(cx, |state, cx| {
let next = match (*hovered, *state) {
(false, PointerState::Pressed) => PointerState::Pressed,
(true, _) => PointerState::Hovered,
(false, _) => PointerState::Rest,
};
if *state != next {
*state = next;
cx.notify();
}
});
if let Some(on_hover) = &on_hover {
on_hover(hovered, window, cx);
}
})
.when(interactive, |this| {
this.on_mouse_down(gpui_kit::MouseButton::Left, move |_, window, cx| {
window.prevent_default();
base::GlobalState::suppress_text_selection(cx);
TooltipTrigger::pressed(window, cx);
pointer_for_down.update(cx, |state, cx| {
*state = PointerState::Pressed;
cx.notify();
});
})
.on_mouse_up(gpui_kit::MouseButton::Left, move |_, _, cx| {
pointer_for_up.update(cx, |state, cx| {
if *state == PointerState::Pressed {
*state = PointerState::Hovered;
cx.notify();
}
});
})
.on_mouse_up_out(gpui_kit::MouseButton::Left, move |_, _, cx| {
pointer_for_up_out.update(cx, |state, cx| {
if *state != PointerState::Rest {
*state = PointerState::Rest;
cx.notify();
}
});
})
})
.when_some(on_click.filter(|_| interactive), |this, on_click| {
this.on_click(move |event, window, cx| on_click(event, window, cx))
})
}
}
fn is_truncated(layout: &TextLayout, label: &SharedString) -> bool {
layout.text() != label.as_ref()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{ThemeConfig, to_hex};
use gpui_kit::base::ThemeAppearance;
fn dark() -> ThemeTokens {
crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark)
}
#[test]
fn primary_reads_the_theme_tokens() {
let theme = dark();
let rest = ButtonVariant::Primary.surface(PointerState::Rest, false, false, &theme);
assert_eq!(to_hex(rest.background), "#dfdfdf");
assert_eq!(to_hex(rest.foreground), "#2d2d2d");
let hovered = ButtonVariant::Primary.surface(PointerState::Hovered, false, false, &theme);
assert!(hovered.background.l < rest.background.l);
}
#[test]
fn rest_surfaces_without_a_fill_share_the_hover_color_at_zero_alpha() {
let theme = dark();
for variant in [ButtonVariant::Outline, ButtonVariant::Ghost] {
let rest = variant.surface(PointerState::Rest, false, false, &theme);
let hovered = variant.surface(PointerState::Hovered, false, false, &theme);
assert_eq!(rest.background.a, 0., "{variant:?} rest is clear");
assert_eq!(
rest.background.l, hovered.background.l,
"{variant:?} only alpha moves"
);
let mid = rest.interpolate(&hovered, 0.5);
assert_eq!(mid.background.l, hovered.background.l);
assert!((mid.background.a - 0.5).abs() < 1e-6);
}
}
#[test]
fn default_soft_fill_matches_the_reference() {
let theme = dark();
let rest = ButtonVariant::Default.surface(PointerState::Rest, false, false, &theme);
assert_eq!(to_hex(rest.background), "#222222");
assert_eq!(to_hex(rest.foreground), "#dfdfdf");
let ghost = ButtonVariant::Ghost.surface(PointerState::Rest, false, false, &theme);
assert_eq!(to_hex(ghost.foreground), "#969696");
}
#[test]
fn inside_a_sidebar_the_fills_are_the_sidebars() {
let theme = dark();
let hovered = ButtonVariant::Ghost.surface(PointerState::Hovered, false, true, &theme);
assert_eq!(hovered.background, theme.sidebar_accent);
let selected = ButtonVariant::Ghost.surface(PointerState::Rest, true, true, &theme);
assert_eq!(selected.background, theme.sidebar_selected);
assert!(hovered.background.l < selected.background.l);
let selected_hovered =
ButtonVariant::Ghost.surface(PointerState::Hovered, true, true, &theme);
assert!(selected_hovered.background.l > selected.background.l);
let window = ButtonVariant::Ghost.surface(PointerState::Hovered, false, false, &theme);
assert_eq!(window.background, theme.secondary());
}
}