1use std::rc::Rc;
2
3use crate::motion::StyledSlot;
4use gpui::{
5 div, prelude::*, px, App, ClickEvent, FocusHandle, FontWeight, IntoElement, KeyDownEvent,
6 ParentElement, RenderOnce, SharedString, StyleRefinement, Styled, Window,
7};
8
9use crate::compat::{AccessibilityExt, Role};
10
11use crate::theme::{ActiveTheme, Theme};
12
13use crate::chrome::{box_shadow, button_chrome, focus_ring};
14use crate::icon::{Icon, IconName};
15use crate::spinner::Spinner;
16use crate::tooltip::Tooltip;
17
18type ButtonClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
19
20struct ButtonState {
21 focus_handle: FocusHandle,
22}
23
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum ButtonVariant {
27 #[default]
28 Primary,
29 Secondary,
30 Destructive,
31 Outline,
32 OutlineDestructive,
33 Ghost,
34}
35
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub enum ButtonSize {
39 Small,
40 #[default]
41 Medium,
42 Large,
43 Icon,
44}
45
46impl ButtonSize {
47 pub fn height(self) -> f32 {
48 match self {
49 Self::Small => 28.0,
50 Self::Medium | Self::Icon => 36.0,
51 Self::Large => 44.0,
52 }
53 }
54
55 pub fn pad_x(self) -> f32 {
56 match self {
57 Self::Small => 12.0,
58 Self::Medium => 16.0,
59 Self::Large => 20.0,
60 Self::Icon => 0.0,
61 }
62 }
63
64 pub fn icon_pad_x(self) -> f32 {
65 match self {
66 Self::Icon => 0.0,
67 _ => 14.0,
68 }
69 }
70
71 pub fn font_size(self) -> f32 {
72 match self {
73 Self::Small => 12.0,
74 Self::Medium | Self::Icon => 14.0,
75 Self::Large => 15.0,
76 }
77 }
78
79 pub fn line_height(self) -> f32 {
80 match self {
81 Self::Small => 16.0,
82 Self::Medium | Self::Large | Self::Icon => 18.0,
83 }
84 }
85}
86
87#[derive(IntoElement)]
89pub struct Button {
90 id: SharedString,
91 label: Option<SharedString>,
92 variant: ButtonVariant,
93 size: ButtonSize,
94 theme: Option<Theme>,
95 disabled: bool,
96 loading: bool,
97 muted: bool,
98 grouped: bool,
99 leading_icon: Option<IconName>,
100 trailing_icon: Option<IconName>,
101 tooltip: Option<Tooltip>,
102 focus_handle: Option<FocusHandle>,
103 style: StyleRefinement,
104 on_click: Option<ButtonClickHandler>,
105}
106
107impl Button {
108 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
109 Self {
110 id: id.into(),
111 label: Some(label.into()),
112 variant: ButtonVariant::Primary,
113 size: ButtonSize::Medium,
114 theme: None,
115 disabled: false,
116 loading: false,
117 muted: false,
118 grouped: false,
119 leading_icon: None,
120 trailing_icon: None,
121 tooltip: None,
122 focus_handle: None,
123 style: StyleRefinement::default(),
124 on_click: None,
125 }
126 }
127
128 pub fn icon_only(id: impl Into<SharedString>, icon: IconName) -> Self {
129 Self {
130 id: id.into(),
131 label: None,
132 variant: ButtonVariant::Primary,
133 size: ButtonSize::Icon,
134 theme: None,
135 disabled: false,
136 loading: false,
137 muted: false,
138 grouped: false,
139 leading_icon: Some(icon),
140 trailing_icon: None,
141 tooltip: None,
142 focus_handle: None,
143 style: StyleRefinement::default(),
144 on_click: None,
145 }
146 }
147
148 pub fn variant(mut self, variant: ButtonVariant) -> Self {
149 self.variant = variant;
150 self
151 }
152
153 pub fn size(mut self, size: ButtonSize) -> Self {
154 self.size = size;
155 self
156 }
157
158 pub fn theme(mut self, theme: Theme) -> Self {
160 self.theme = Some(theme);
161 self
162 }
163
164 pub fn disabled(mut self, disabled: bool) -> Self {
165 self.disabled = disabled;
166 self
167 }
168
169 pub fn loading(mut self, loading: bool) -> Self {
170 self.loading = loading;
171 self
172 }
173
174 pub fn muted(mut self, muted: bool) -> Self {
176 self.muted = muted;
177 self
178 }
179
180 pub fn grouped(mut self, grouped: bool) -> Self {
182 self.grouped = grouped;
183 self
184 }
185
186 pub fn leading_icon(mut self, icon: IconName) -> Self {
187 self.leading_icon = Some(icon);
188 self
189 }
190
191 pub fn trailing_icon(mut self, icon: IconName) -> Self {
192 self.trailing_icon = Some(icon);
193 self
194 }
195
196 pub fn tooltip(mut self, tooltip: Tooltip) -> Self {
197 self.tooltip = Some(tooltip);
198 self
199 }
200
201 pub fn focus_handle(mut self, focus_handle: FocusHandle) -> Self {
202 self.focus_handle = Some(focus_handle);
203 self
204 }
205
206 pub fn on_click(
207 mut self,
208 listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
209 ) -> Self {
210 self.on_click = Some(Rc::new(listener));
211 self
212 }
213}
214
215impl Styled for Button {
216 fn style(&mut self) -> &mut StyleRefinement {
217 &mut self.style
218 }
219}
220
221impl RenderOnce for Button {
222 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
223 let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| ButtonState {
224 focus_handle: cx.focus_handle(),
225 });
226 let theme = self.theme.unwrap_or_else(|| cx.theme());
227 let variant = if self.disabled {
228 ButtonVariant::Secondary
229 } else {
230 self.variant
231 };
232 let chrome = button_chrome(theme, variant);
233 let fg = if self.disabled || self.muted {
234 theme.muted_fg()
235 } else {
236 chrome.fg
237 };
238
239 let has_icon = self.loading || self.leading_icon.is_some() || self.trailing_icon.is_some();
240 let pad_x = if self.size == ButtonSize::Icon {
241 0.0
242 } else if has_icon {
243 self.size.icon_pad_x()
244 } else {
245 self.size.pad_x()
246 };
247
248 let mut shadows = vec![box_shadow(0., 1., chrome.inset, 0., 0.)];
249 if chrome.shadow_blur > 0.0 {
250 shadows.push(box_shadow(
251 0.,
252 chrome.shadow_y,
253 chrome.shadow,
254 chrome.shadow_blur,
255 0.,
256 ));
257 }
258
259 let icon_size = if self.loading { 14.0 } else { 16.0 };
260 let hover_bg = chrome.hover_bg;
261 let interactive = !self.disabled && !self.loading;
262 let focus_handle = self
263 .focus_handle
264 .unwrap_or_else(|| state.read(cx).focus_handle.clone())
265 .tab_stop(interactive);
266 let focused = focus_handle.is_focused(window);
267 let aria_label = self.label.clone();
268 let button_id = self.id.clone();
269 let debug_selector = self.id.to_string();
270
271 if focused {
272 shadows.push(focus_ring(theme));
273 }
274
275 let el = div()
276 .id(self.id)
277 .debug_selector(move || debug_selector.clone())
278 .role(Role::Button)
279 .when_some(aria_label, |el, label| el.aria_label(label))
280 .track_focus(&focus_handle)
281 .tab_stop(interactive)
282 .flex()
283 .items_center()
284 .justify_center()
285 .when(has_icon && self.size != ButtonSize::Icon, |el| {
286 el.gap(px(8.))
287 })
288 .when(self.grouped, |el| el.h_full())
289 .when(!self.grouped, |el| el.h(px(self.size.height())))
290 .when(self.size == ButtonSize::Icon, |el| {
291 el.w(px(36.)).flex_shrink_0()
292 })
293 .when(self.size != ButtonSize::Icon, |el| el.px(px(pad_x)))
294 .when(!self.grouped, |el| el.rounded(px(6.)))
295 .border_1()
296 .border_color(chrome.border)
297 .bg(chrome.bg)
298 .shadow(shadows)
299 .text_color(fg)
300 .font_family(theme.font_family)
301 .font_weight(FontWeight::MEDIUM)
302 .text_size(px(self.size.font_size()))
303 .line_height(px(self.size.line_height()))
304 .when(interactive, |el| {
305 el.cursor_pointer().hover(move |s| s.bg(hover_bg))
306 })
307 .when(!interactive, |el| el.cursor_default())
308 .refine_style(&self.style)
309 .when(self.loading, |el| {
310 el.child(Spinner::new().px(px(icon_size)).color(fg))
311 })
312 .when_some(self.leading_icon.filter(|_| !self.loading), |el, icon| {
313 el.child(Icon::new(icon).px(px(icon_size)).color(fg))
314 })
315 .when_some(self.label, |el, label| el.child(label))
316 .when_some(self.trailing_icon.filter(|_| !self.loading), |el, icon| {
317 el.child(Icon::new(icon).px(px(icon_size)).color(fg))
318 });
319
320 let el = if interactive {
321 if let Some(on_click) = self.on_click {
322 let keyboard_click = on_click.clone();
323 let click_focus = focus_handle.clone();
324 el.on_key_down(move |event: &KeyDownEvent, window, cx| {
325 if event.keystroke.modifiers.modified() {
326 return;
327 }
328
329 if matches!(event.keystroke.key.as_str(), "enter" | "space") {
330 keyboard_click(&ClickEvent::default(), window, cx);
331 window.refresh();
332 cx.stop_propagation();
333 }
334 })
335 .on_click(move |event, window, cx| {
336 click_focus.focus(window);
337 on_click(event, window, cx);
338 window.refresh();
339 })
340 } else {
341 el
342 }
343 } else {
344 el
345 };
346
347 if let Some(tooltip) = self.tooltip {
348 tooltip.attach(button_id, el, window, cx).into_any_element()
349 } else {
350 el.into_any_element()
351 }
352 }
353}
354
355#[derive(IntoElement)]
357pub struct ButtonGroup {
358 theme: Option<Theme>,
359 style: StyleRefinement,
360 children: Vec<gpui::AnyElement>,
361}
362
363impl ButtonGroup {
364 pub fn new() -> Self {
365 Self {
366 theme: None,
367 style: StyleRefinement::default(),
368 children: Vec::new(),
369 }
370 }
371
372 pub fn theme(mut self, theme: Theme) -> Self {
374 self.theme = Some(theme);
375 self
376 }
377}
378
379impl Default for ButtonGroup {
380 fn default() -> Self {
381 Self::new()
382 }
383}
384
385impl Styled for ButtonGroup {
386 fn style(&mut self) -> &mut StyleRefinement {
387 &mut self.style
388 }
389}
390
391impl ParentElement for ButtonGroup {
392 fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
393 self.children.extend(elements);
394 }
395}
396
397impl RenderOnce for ButtonGroup {
398 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
399 let theme = self.theme.unwrap_or_else(|| cx.theme());
400 let chrome = button_chrome(theme, ButtonVariant::Outline);
401 div()
402 .flex()
403 .items_center()
404 .h(px(36.))
405 .rounded(px(6.))
406 .overflow_hidden()
407 .border_1()
408 .border_color(chrome.border)
409 .bg(chrome.bg)
410 .shadow(vec![
411 box_shadow(0., 1., chrome.inset, 0., 0.),
412 box_shadow(0., chrome.shadow_y, chrome.shadow, chrome.shadow_blur, 0.),
413 ])
414 .refine_style(&self.style)
415 .children(self.children)
416 }
417}