1use crate::gpui_compat::element_id;
2use crate::motion::spin_icon;
3use gpui::{
4 AbsoluteLength, AnyElement, App, Background, Component, ElementId, Hsla, IntoElement,
5 RenderOnce, Rgba, SharedString, Window, linear_color_stop, linear_gradient, prelude::*, px,
6};
7use liora_core::{Config, stable_unique_id};
8use liora_icons::Icon;
9use liora_icons_lucide::IconName;
10use liora_theme::{ButtonSize, ButtonVariant, ButtonVariantColors, Theme};
11
12fn rgba(r: u8, g: u8, b: u8, a: f32) -> Hsla {
13 Rgba {
14 r: r as f32 / 255.0,
15 g: g as f32 / 255.0,
16 b: b as f32 / 255.0,
17 a,
18 }
19 .into()
20}
21
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct ButtonColors {
24 pub bg: Hsla,
25 pub hover_bg: Hsla,
26 pub active_bg: Hsla,
27 pub text: Hsla,
28 pub border: Hsla,
29 pub text_hover: Hsla,
30 pub border_hover: Hsla,
31 pub disabled_bg: Hsla,
32 pub disabled_text: Hsla,
33 pub disabled_border: Hsla,
34}
35
36impl ButtonColors {
37 pub fn filled(bg: Hsla, text: Hsla) -> Self {
38 Self {
39 bg,
40 hover_bg: derive_hover_bg(bg),
41 active_bg: derive_active_bg(bg),
42 text,
43 border: bg,
44 text_hover: text,
45 border_hover: derive_hover_bg(bg),
46 disabled_bg: derive_disabled_bg(bg),
47 disabled_text: text.opacity(0.58),
48 disabled_border: derive_disabled_bg(bg),
49 }
50 }
51
52 pub fn outline(accent: Hsla, text: Hsla, bg: Hsla) -> Self {
53 Self {
54 bg,
55 hover_bg: accent.opacity(0.10),
56 active_bg: accent.opacity(0.18),
57 text,
58 border: accent,
59 text_hover: accent,
60 border_hover: derive_hover_bg(accent),
61 disabled_bg: bg.opacity(0.35),
62 disabled_text: text.opacity(0.45),
63 disabled_border: accent.opacity(0.30),
64 }
65 }
66}
67
68#[derive(Clone, Debug, PartialEq)]
69pub struct ButtonGradient {
70 pub from: Hsla,
71 pub to: Hsla,
72 pub angle: f32,
73 pub hover_from: Hsla,
74 pub hover_to: Hsla,
75 pub active_from: Hsla,
76 pub active_to: Hsla,
77 pub disabled_from: Hsla,
78 pub disabled_to: Hsla,
79}
80
81impl ButtonGradient {
82 pub fn new(from: Hsla, to: Hsla) -> Self {
83 Self::with_angle(from, to, 90.0)
84 }
85
86 pub fn with_angle(from: Hsla, to: Hsla, angle: f32) -> Self {
87 Self {
88 from,
89 to,
90 angle,
91 hover_from: derive_hover_bg(from),
92 hover_to: derive_hover_bg(to),
93 active_from: derive_active_bg(from),
94 active_to: derive_active_bg(to),
95 disabled_from: derive_disabled_bg(from),
96 disabled_to: derive_disabled_bg(to),
97 }
98 }
99
100 fn background(&self) -> Background {
101 gradient_background(self.angle, self.from, self.to)
102 }
103
104 fn hover_background(&self) -> Background {
105 gradient_background(self.angle, self.hover_from, self.hover_to)
106 }
107
108 fn active_background(&self) -> Background {
109 gradient_background(self.angle, self.active_from, self.active_to)
110 }
111
112 fn disabled_background(&self) -> Background {
113 gradient_background(self.angle, self.disabled_from, self.disabled_to)
114 }
115}
116
117fn derive_hover_bg(color: Hsla) -> Hsla {
118 color.blend(gpui::white().opacity(0.14))
119}
120
121fn derive_active_bg(color: Hsla) -> Hsla {
122 color.blend(gpui::black().opacity(0.18))
123}
124
125fn derive_disabled_bg(color: Hsla) -> Hsla {
126 Hsla {
129 h: color.h,
130 s: (color.s * 0.45).clamp(0.0, 1.0),
131 l: (color.l + (1.0 - color.l) * 0.38).clamp(0.0, 1.0),
132 a: (color.a * 0.62).clamp(0.0, 1.0),
133 }
134}
135
136fn gradient_background(angle: f32, from: Hsla, to: Hsla) -> Background {
137 linear_gradient(
138 angle,
139 linear_color_stop(from, 0.0),
140 linear_color_stop(to, 1.0),
141 )
142}
143
144pub enum ButtonIcon {
145 IconName(IconName),
146 Icon(Icon),
147 Element(AnyElement),
148}
149
150impl From<IconName> for ButtonIcon {
151 fn from(name: IconName) -> Self {
152 ButtonIcon::IconName(name)
153 }
154}
155
156impl From<AnyElement> for ButtonIcon {
157 fn from(el: AnyElement) -> Self {
158 ButtonIcon::Element(el)
159 }
160}
161
162impl From<Icon> for ButtonIcon {
163 fn from(icon: Icon) -> Self {
164 ButtonIcon::Icon(icon)
165 }
166}
167
168pub struct Button {
169 label: SharedString,
170 variant: ButtonVariant,
171 size: ButtonSize,
172 disabled: bool,
173 loading: bool,
174 secondary: bool,
175 background: bool,
176 border: bool,
177 rounded: Option<AbsoluteLength>,
178 id: Option<ElementId>,
179 icon_start: Option<ButtonIcon>,
180 icon_end: Option<ButtonIcon>,
181 icon_top: Option<IconName>,
182 icon_bottom: Option<IconName>,
183 icon_only: Option<IconName>,
184 custom_colors: Option<ButtonColors>,
185 gradient: Option<ButtonGradient>,
186 on_click: Option<Box<dyn Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static>>,
187}
188
189impl Button {
190 pub fn new(label: impl Into<SharedString>) -> Self {
191 Self {
192 label: label.into(),
193 variant: ButtonVariant::Default,
194 size: ButtonSize::Default,
195 disabled: false,
196 loading: false,
197 secondary: false,
198 background: true,
199 border: true,
200 rounded: None,
201 id: None,
202 icon_start: None,
203 icon_end: None,
204 icon_top: None,
205 icon_bottom: None,
206 icon_only: None,
207 custom_colors: None,
208 gradient: None,
209 on_click: None,
210 }
211 }
212 pub fn variant(mut self, v: ButtonVariant) -> Self {
213 self.variant = v;
214 self
215 }
216 pub fn primary(mut self) -> Self {
217 self.variant = ButtonVariant::Primary;
218 self
219 }
220 pub fn tertiary(mut self) -> Self {
221 self.variant = ButtonVariant::Tertiary;
222 self
223 }
224 pub fn text(mut self) -> Self {
225 self.variant = ButtonVariant::Text;
226 self
227 }
228 pub fn info(mut self) -> Self {
229 self.variant = ButtonVariant::Info;
230 self
231 }
232 pub fn success(mut self) -> Self {
233 self.variant = ButtonVariant::Success;
234 self
235 }
236 pub fn warning(mut self) -> Self {
237 self.variant = ButtonVariant::Warning;
238 self
239 }
240 pub fn danger(mut self) -> Self {
241 self.variant = ButtonVariant::Danger;
242 self
243 }
244 pub fn size(mut self, s: ButtonSize) -> Self {
245 self.size = s;
246 self
247 }
248 pub fn small(mut self) -> Self {
249 self.size = ButtonSize::Small;
250 self
251 }
252 pub fn large(mut self) -> Self {
253 self.size = ButtonSize::Large;
254 self
255 }
256 pub fn disabled(mut self, d: bool) -> Self {
257 self.disabled = d;
258 self
259 }
260 pub fn loading(mut self, l: bool) -> Self {
261 self.loading = l;
262 self
263 }
264 pub fn secondary(mut self) -> Self {
265 self.secondary = true;
266 self
267 }
268 pub fn background(mut self, show: bool) -> Self {
269 self.background = show;
270 self
271 }
272 pub fn border(mut self, show: bool) -> Self {
273 self.border = show;
274 self
275 }
276 pub fn rounded(mut self, r: impl Into<AbsoluteLength>) -> Self {
277 self.rounded = Some(r.into());
278 self
279 }
280
281 pub fn rounded_sm(self) -> Self {
282 self.rounded(px(4.0))
283 }
284
285 pub fn rounded_md(self) -> Self {
286 self.rounded(px(12.0))
287 }
288
289 pub fn rounded_lg(self) -> Self {
290 self.rounded(px(20.0))
291 }
292
293 pub fn pill(self) -> Self {
294 self.rounded(px(9999.0))
295 }
296 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
297 self.id = Some(id.into());
298 self
299 }
300 pub fn icon_start(mut self, icon: impl Into<ButtonIcon>) -> Self {
301 self.icon_start = Some(icon.into());
302 self
303 }
304 pub fn icon_end(mut self, icon: impl Into<ButtonIcon>) -> Self {
305 self.icon_end = Some(icon.into());
306 self
307 }
308 pub fn icon_top(mut self, icon: IconName) -> Self {
309 self.icon_top = Some(icon);
310 self
311 }
312 pub fn icon_bottom(mut self, icon: IconName) -> Self {
313 self.icon_bottom = Some(icon);
314 self
315 }
316 pub fn icon_only(mut self, icon: IconName) -> Self {
317 self.icon_only = Some(icon);
318 self
319 }
320 pub fn colors(mut self, colors: ButtonColors) -> Self {
321 self.custom_colors = Some(colors);
322 self.gradient = None;
323 self
324 }
325
326 pub fn custom_colors(self, colors: ButtonColors) -> Self {
327 self.colors(colors)
328 }
329
330 pub fn custom_color(mut self, bg: Hsla, text: Hsla) -> Self {
331 self.custom_colors = Some(ButtonColors::filled(bg, text));
332 self.gradient = None;
333 self
334 }
335
336 pub fn gradient(mut self, from: Hsla, to: Hsla) -> Self {
337 self.gradient = Some(ButtonGradient::new(from, to));
338 self.custom_colors = None;
339 self
340 }
341
342 pub fn gradient_with_angle(mut self, angle: f32, from: Hsla, to: Hsla) -> Self {
343 self.gradient = Some(ButtonGradient::with_angle(from, to, angle));
344 self.custom_colors = None;
345 self
346 }
347
348 pub fn on_click(
349 mut self,
350 cb: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static,
351 ) -> Self {
352 self.on_click = Some(Box::new(cb));
353 self
354 }
355
356 fn resolved_colors(&self, theme: &Theme) -> ButtonVariantColors {
357 if let Some(colors) = self.custom_colors {
358 if self.disabled {
359 return ButtonVariantColors {
360 bg: colors.disabled_bg,
361 hover_bg: colors.disabled_bg,
362 active_bg: colors.disabled_bg,
363 text: colors.disabled_text,
364 border: colors.disabled_border,
365 text_hover: colors.disabled_text,
366 border_hover: colors.disabled_border,
367 };
368 }
369
370 return ButtonVariantColors {
371 bg: colors.bg,
372 hover_bg: colors.hover_bg,
373 active_bg: colors.active_bg,
374 text: colors.text,
375 border: colors.border,
376 text_hover: colors.text_hover,
377 border_hover: colors.border_hover,
378 };
379 }
380
381 if self.gradient.is_some() {
382 let text = theme.neutral.inverted;
383 return ButtonVariantColors {
384 bg: rgba(0, 0, 0, 0.0),
385 hover_bg: rgba(0, 0, 0, 0.0),
386 active_bg: rgba(0, 0, 0, 0.0),
387 text: if self.disabled {
388 text.opacity(0.58)
389 } else {
390 text
391 },
392 border: rgba(0, 0, 0, 0.0),
393 text_hover: if self.disabled {
394 text.opacity(0.58)
395 } else {
396 text
397 },
398 border_hover: rgba(0, 0, 0, 0.0),
399 };
400 }
401
402 if self.disabled {
403 ButtonVariantColors {
404 bg: rgba(0, 0, 0, 0.0),
405 hover_bg: rgba(0, 0, 0, 0.0),
406 active_bg: rgba(0, 0, 0, 0.0),
407 text: theme.neutral.text_disabled,
408 border: theme.neutral.border,
409 text_hover: theme.neutral.text_disabled,
410 border_hover: theme.neutral.border,
411 }
412 } else {
413 theme.color_by_variant(self.variant, self.secondary, self.background, self.border)
414 }
415 }
416
417 fn icon_size(&self) -> f32 {
418 match self.size {
419 ButtonSize::Small => 12.0,
420 ButtonSize::Default => 14.0,
421 ButtonSize::Large => 16.0,
422 }
423 }
424
425 fn render_with_theme(
426 self,
427 theme: Theme,
428 window: &mut Window,
429 cx: &mut App,
430 ) -> impl IntoElement {
431 let c = self.resolved_colors(&theme);
432 let h = self.size.height();
433 let px_h = self.size.padding_x();
434 let fs = match self.size {
435 ButtonSize::Small => theme.font_size.xs,
436 ButtonSize::Default => theme.font_size.md,
437 ButtonSize::Large => theme.font_size.lg,
438 };
439 let r = self.rounded.unwrap_or_else(|| px(theme.radius.md).into());
440 let id = self.id.clone().unwrap_or_else(|| {
441 stable_unique_id(
442 format!(
443 "liora-button:{}:{:?}:{:?}:secondary={}:background={}:border={}:rounded={:?}",
444 self.label,
445 self.variant,
446 self.size,
447 self.secondary,
448 self.background,
449 self.border,
450 self.rounded
451 ),
452 "liora-button",
453 window,
454 cx,
455 )
456 .into()
457 });
458 let icon_sz = self.icon_size();
459
460 let icon_only = self.icon_only.is_some();
461 let vertical = self.icon_top.is_some() || self.icon_bottom.is_some() || icon_only;
462
463 let label = self.label.clone();
464 let hover_group = SharedString::from(format!("{}:hover", id));
465
466 let gradient = self.gradient.clone();
467 let mut div = gpui::div()
468 .flex()
469 .justify_center()
470 .items_center()
471 .gap_1()
472 .h(px(if vertical { h + icon_sz + 6.0 } else { h }))
473 .rounded(r)
474 .text_color(c.text)
475 .text_size(px(fs));
476
477 div = if let Some(gradient) = gradient.as_ref() {
478 if self.disabled {
479 div.bg(gradient.disabled_background())
480 } else {
481 div.bg(gradient.background())
482 }
483 } else {
484 div.bg(c.bg)
485 };
486
487 if vertical {
488 div = div.flex_col();
489 if !icon_only {
490 div = div.px(px(px_h));
491 }
492 } else {
493 div = div.flex_row().px(px(px_h));
494 }
495
496 if icon_only {
497 div = div.size(px(h)).w(px(h)); }
499
500 if !self.disabled {
501 div = div.cursor_pointer();
502 } else {
503 div = div.cursor_not_allowed();
504 }
505 if !c.border.is_transparent() {
506 div = div.border_1().border_color(c.border);
507 }
508 if self.disabled {
509 if let Some(icon) = self.icon_only {
510 let sz = icon_sz * 2.0;
511 let group = hover_group.clone();
512 return div
513 .child(
514 Icon::new(icon)
515 .size(px(sz))
516 .color(c.text)
517 .group_hover_color(group, c.text_hover),
518 )
519 .into_any_element();
520 }
521 return div.child(label.clone()).into_any_element();
522 }
523
524 let mut children: Vec<AnyElement> = Vec::new();
526
527 if let Some(icon) = self.icon_only {
528 let group = hover_group.clone();
529 children.push(
530 Icon::new(icon)
531 .size(px(icon_sz))
532 .color(c.text)
533 .group_hover_color(group, c.text_hover)
534 .into_any_element(),
535 );
536 } else if self.loading {
537 let sz = icon_sz;
538 let group = hover_group.clone();
539 children.push(
540 spin_icon(
541 element_id(format!("{id}:loading-spinner-motion")),
542 Icon::new(IconName::LoaderCircle)
543 .size(px(sz))
544 .color(c.text)
545 .group_hover_color(group, c.text_hover),
546 )
547 .into_any_element(),
548 );
549 children.push(gpui::div().child(label.clone()).into_any_element());
550 } else {
551 let lbl = label.clone();
552 if let Some(icon) = self.icon_top {
554 let sz = icon_sz;
555 let group = hover_group.clone();
556 children.push(
557 Icon::new(icon)
558 .size(px(sz))
559 .color(c.text)
560 .group_hover_color(group, c.text_hover)
561 .into_any_element(),
562 );
563 }
564 if let Some(icon) = self.icon_start {
566 match icon {
567 ButtonIcon::IconName(name) => {
568 let group = hover_group.clone();
569 children.push(
570 Icon::new(name)
571 .size(px(icon_sz))
572 .color(c.text)
573 .group_hover_color(group, c.text_hover)
574 .into_any_element(),
575 );
576 }
577 ButtonIcon::Icon(icon) => {
578 let group = hover_group.clone();
579 children.push(
580 icon.size(px(icon_sz))
581 .color(c.text)
582 .group_hover_color(group, c.text_hover)
583 .into_any_element(),
584 );
585 }
586 ButtonIcon::Element(el) => children.push(el),
587 }
588 }
589 children.push(gpui::div().child(lbl).into_any_element());
591 if let Some(icon) = self.icon_end {
593 match icon {
594 ButtonIcon::IconName(name) => {
595 let group = hover_group.clone();
596 children.push(
597 Icon::new(name)
598 .size(px(icon_sz))
599 .color(c.text)
600 .group_hover_color(group, c.text_hover)
601 .into_any_element(),
602 );
603 }
604 ButtonIcon::Icon(icon) => {
605 let group = hover_group.clone();
606 children.push(
607 icon.size(px(icon_sz))
608 .color(c.text)
609 .group_hover_color(group, c.text_hover)
610 .into_any_element(),
611 );
612 }
613 ButtonIcon::Element(el) => children.push(el),
614 }
615 }
616 if let Some(icon) = self.icon_bottom {
618 let sz = icon_sz;
619 let group = hover_group.clone();
620 children.push(
621 Icon::new(icon)
622 .size(px(sz))
623 .color(c.text)
624 .group_hover_color(group, c.text_hover)
625 .into_any_element(),
626 );
627 }
628 }
629
630 let click_handler = self.on_click;
631 let hover_gradient = gradient.clone();
632 let active_gradient = gradient.clone();
633
634 div.id(id)
635 .group(hover_group)
636 .hover(move |style| {
637 let hover_bg: gpui::Fill = hover_gradient
638 .as_ref()
639 .map(ButtonGradient::hover_background)
640 .map_or_else(|| c.hover_bg.into(), Into::into);
641 let mut s = style.bg(hover_bg).text_color(c.text_hover);
642 if !c.border_hover.is_transparent() {
643 s = s.border_color(c.border_hover);
644 }
645 s
646 })
647 .active(move |style| {
648 let active_bg: gpui::Fill = active_gradient
649 .as_ref()
650 .map(ButtonGradient::active_background)
651 .map_or_else(|| c.active_bg.into(), Into::into);
652 style.bg(active_bg)
653 })
654 .on_click(move |event, window, cx| {
655 if let Some(ref handler) = click_handler {
656 handler(event, window, cx);
657 }
658 })
659 .children(children)
660 .into_any_element()
661 }
662}
663
664impl RenderOnce for Button {
665 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
666 let theme = cx.global::<Config>().theme.clone();
667 self.render_with_theme(theme, _window, cx)
668 }
669}
670
671impl IntoElement for Button {
672 type Element = Component<Self>;
673 fn into_element(self) -> Self::Element {
674 Component::new(self)
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 #[test]
683 fn button_rounded_helpers_set_custom_radius() {
684 assert!(Button::new("small").rounded_sm().rounded.is_some());
685 assert!(Button::new("pill").pill().rounded.is_some());
686 }
687
688 #[test]
689 fn button_loading_icon_uses_spin_motion() {
690 let source = include_str!("button.rs")
691 .split("#[cfg(test)]")
692 .next()
693 .unwrap();
694
695 assert!(source.contains("spin_icon("));
696 assert!(source.contains("loading-spinner-motion"));
697 }
698
699 #[test]
700 fn custom_color_derives_interaction_states() {
701 let bg = rgba(99, 102, 241, 1.0);
702 let colors = ButtonColors::filled(bg, gpui::white());
703
704 assert_ne!(colors.bg, colors.hover_bg);
705 assert_ne!(colors.bg, colors.active_bg);
706 assert_eq!(colors.disabled_bg.h, colors.bg.h);
707 assert!(colors.disabled_bg.s < colors.bg.s);
708 assert!(colors.disabled_bg.a < colors.bg.a);
709 }
710
711 #[test]
712 fn gradient_builder_derives_state_gradients() {
713 let button = Button::new("gradient").gradient(gpui::blue(), gpui::green());
714 let gradient = button.gradient.unwrap();
715
716 assert_eq!(gradient.angle, 90.0);
717 assert_ne!(gradient.from, gradient.hover_from);
718 assert_ne!(gradient.to, gradient.active_to);
719 }
720}