Skip to main content

liora_components/
button.rs

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    // Keep the original hue so disabled custom buttons still look related to
127    // the caller-provided color. Only soften saturation/contrast and opacity.
128    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)); // square button
498        }
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        // Build children: icons + label
525        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            // icon_top
553            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            // icon_start
565            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            // label
590            children.push(gpui::div().child(lbl).into_any_element());
591            // icon_end
592            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            // icon_bottom
617            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}