Skip to main content

egui_shadcn/
button.rs

1use crate::theme::Theme;
2use crate::tokens::{ColorPalette, ControlSize, ControlVariant, ease_out_cubic, mix};
3use egui::{
4    Color32, CornerRadius, FontId, Painter, Pos2, Rect, Response, Sense, Stroke, StrokeKind,
5    TextStyle, TextWrapMode, Ui, Vec2, WidgetText, pos2, vec2,
6};
7use log::trace;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
10pub enum ButtonVariant {
11    #[default]
12    Default,
13
14    Solid,
15
16    Classic,
17
18    Soft,
19
20    Surface,
21
22    Destructive,
23
24    Outline,
25
26    Secondary,
27
28    Ghost,
29
30    Link,
31}
32
33impl From<ControlVariant> for ButtonVariant {
34    fn from(variant: ControlVariant) -> Self {
35        match variant {
36            ControlVariant::Primary => ButtonVariant::Default,
37            ControlVariant::Destructive => ButtonVariant::Destructive,
38            ControlVariant::Outline => ButtonVariant::Outline,
39            ControlVariant::Secondary => ButtonVariant::Secondary,
40            ControlVariant::Ghost => ButtonVariant::Ghost,
41            ControlVariant::Link => ButtonVariant::Link,
42        }
43    }
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
47pub enum ButtonRadius {
48    None,
49
50    Small,
51
52    #[default]
53    Medium,
54
55    Large,
56
57    Full,
58
59    Custom(CornerRadius),
60}
61
62impl ButtonRadius {
63    pub fn corner_radius(self) -> CornerRadius {
64        match self {
65            ButtonRadius::None => CornerRadius::same(0),
66            ButtonRadius::Small => CornerRadius::same(4),
67            ButtonRadius::Medium => CornerRadius::same(8),
68            ButtonRadius::Large => CornerRadius::same(12),
69            ButtonRadius::Full => CornerRadius::same(255),
70            ButtonRadius::Custom(r) => r,
71        }
72    }
73}
74
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
76pub enum ButtonSize {
77    Sm,
78
79    #[default]
80    Default,
81
82    Lg,
83
84    Icon,
85
86    IconSm,
87
88    IconLg,
89}
90
91impl ButtonSize {
92    pub fn height(self) -> f32 {
93        match self {
94            ButtonSize::Sm | ButtonSize::IconSm => 32.0,
95            ButtonSize::Default | ButtonSize::Icon => 36.0,
96            ButtonSize::Lg | ButtonSize::IconLg => 40.0,
97        }
98    }
99
100    pub fn padding_x(self) -> f32 {
101        match self {
102            ButtonSize::Sm => 12.0,
103            ButtonSize::Default => 16.0,
104            ButtonSize::Lg => 24.0,
105            ButtonSize::Icon | ButtonSize::IconSm | ButtonSize::IconLg => 0.0,
106        }
107    }
108
109    pub fn padding_y(self) -> f32 {
110        match self {
111            ButtonSize::Sm => 6.0,
112            ButtonSize::Default => 8.0,
113            ButtonSize::Lg => 10.0,
114            ButtonSize::Icon | ButtonSize::IconSm | ButtonSize::IconLg => 0.0,
115        }
116    }
117
118    pub fn padding(self) -> Vec2 {
119        vec2(self.padding_x(), self.padding_y())
120    }
121
122    pub fn rounding(self) -> CornerRadius {
123        match self {
124            ButtonSize::Sm | ButtonSize::IconSm => CornerRadius::same(6),
125            ButtonSize::Default | ButtonSize::Icon => CornerRadius::same(8),
126            ButtonSize::Lg | ButtonSize::IconLg => CornerRadius::same(10),
127        }
128    }
129
130    pub fn font_size(self) -> f32 {
131        match self {
132            ButtonSize::Sm | ButtonSize::IconSm => 13.0,
133            ButtonSize::Default | ButtonSize::Icon => 14.0,
134            ButtonSize::Lg | ButtonSize::IconLg => 15.0,
135        }
136    }
137
138    pub fn font(self) -> FontId {
139        FontId::proportional(self.font_size())
140    }
141
142    pub fn is_icon(self) -> bool {
143        matches!(
144            self,
145            ButtonSize::Icon | ButtonSize::IconSm | ButtonSize::IconLg
146        )
147    }
148
149    pub fn icon_width(self) -> f32 {
150        self.height()
151    }
152
153    pub fn gap(self) -> f32 {
154        match self {
155            ButtonSize::Sm | ButtonSize::IconSm => 6.0,
156            ButtonSize::Default | ButtonSize::Icon => 8.0,
157            ButtonSize::Lg | ButtonSize::IconLg => 12.0,
158        }
159    }
160
161    pub fn icon_size(self) -> f32 {
162        match self {
163            ButtonSize::Sm | ButtonSize::IconSm => 14.0,
164            ButtonSize::Default | ButtonSize::Icon => 16.0,
165            ButtonSize::Lg | ButtonSize::IconLg => 18.0,
166        }
167    }
168}
169
170#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
171pub enum ButtonScale {
172    #[default]
173    Size2,
174    Size1,
175    Size3,
176    Size4,
177}
178
179impl From<ButtonScale> for ButtonSize {
180    fn from(scale: ButtonScale) -> Self {
181        match scale {
182            ButtonScale::Size1 => ButtonSize::Sm,
183            ButtonScale::Size2 => ButtonSize::Default,
184            ButtonScale::Size3 | ButtonScale::Size4 => ButtonSize::Lg,
185        }
186    }
187}
188
189impl From<ControlSize> for ButtonSize {
190    fn from(size: ControlSize) -> Self {
191        match size {
192            ControlSize::Sm => ButtonSize::Sm,
193            ControlSize::Md => ButtonSize::Default,
194            ControlSize::Lg => ButtonSize::Lg,
195            ControlSize::IconSm => ButtonSize::IconSm,
196            ControlSize::Icon => ButtonSize::Icon,
197            ControlSize::IconLg => ButtonSize::IconLg,
198        }
199    }
200}
201
202#[derive(Clone, Debug)]
203pub struct ButtonStyle {
204    pub bg: Color32,
205
206    pub bg_hover: Color32,
207
208    pub bg_active: Color32,
209
210    pub text: Color32,
211
212    pub text_hover: Color32,
213
214    pub text_active: Color32,
215
216    pub border: Color32,
217
218    pub border_hover: Color32,
219
220    pub focus_ring: Color32,
221
222    pub disabled_opacity: f32,
223
224    pub rounding: CornerRadius,
225}
226
227impl ButtonStyle {
228    pub fn from_variant(palette: &ColorPalette, variant: ButtonVariant) -> Self {
229        let focus_ring = Color32::from_rgba_unmultiplied(
230            palette.ring.r(),
231            palette.ring.g(),
232            palette.ring.b(),
233            128,
234        );
235        match variant {
236            ButtonVariant::Default | ButtonVariant::Solid => Self {
237                bg: palette.primary,
238                bg_hover: mix(palette.primary, palette.background, 0.12),
239                bg_active: mix(palette.primary, palette.background, 0.22),
240                text: palette.primary_foreground,
241                text_hover: palette.primary_foreground,
242                text_active: palette.primary_foreground,
243                border: Color32::TRANSPARENT,
244                border_hover: Color32::TRANSPARENT,
245                focus_ring,
246                disabled_opacity: 0.5,
247                rounding: CornerRadius::same(8),
248            },
249            ButtonVariant::Classic => Self {
250                bg: palette.primary,
251                bg_hover: mix(palette.primary, palette.background, 0.08),
252                bg_active: mix(palette.primary, palette.background, 0.15),
253                text: palette.primary_foreground,
254                text_hover: palette.primary_foreground,
255                text_active: palette.primary_foreground,
256                border: mix(palette.primary, palette.background, 0.22),
257                border_hover: mix(palette.primary, palette.background, 0.27),
258                focus_ring,
259                disabled_opacity: 0.5,
260                rounding: CornerRadius::same(8),
261            },
262            ButtonVariant::Soft => {
263                let soft_bg = Color32::from_rgba_unmultiplied(
264                    palette.primary.r(),
265                    palette.primary.g(),
266                    palette.primary.b(),
267                    30,
268                );
269                Self {
270                    bg: soft_bg,
271                    bg_hover: Color32::from_rgba_unmultiplied(
272                        palette.primary.r(),
273                        palette.primary.g(),
274                        palette.primary.b(),
275                        45,
276                    ),
277                    bg_active: Color32::from_rgba_unmultiplied(
278                        palette.primary.r(),
279                        palette.primary.g(),
280                        palette.primary.b(),
281                        60,
282                    ),
283                    text: palette.foreground,
284                    text_hover: palette.foreground,
285                    text_active: palette.foreground,
286                    border: Color32::TRANSPARENT,
287                    border_hover: Color32::TRANSPARENT,
288                    focus_ring,
289                    disabled_opacity: 0.5,
290                    rounding: CornerRadius::same(8),
291                }
292            }
293            ButtonVariant::Surface => {
294                let surface_bg = Color32::from_rgba_unmultiplied(
295                    palette.primary.r(),
296                    palette.primary.g(),
297                    palette.primary.b(),
298                    20,
299                );
300                Self {
301                    bg: surface_bg,
302                    bg_hover: Color32::from_rgba_unmultiplied(
303                        palette.primary.r(),
304                        palette.primary.g(),
305                        palette.primary.b(),
306                        30,
307                    ),
308                    bg_active: Color32::from_rgba_unmultiplied(
309                        palette.primary.r(),
310                        palette.primary.g(),
311                        palette.primary.b(),
312                        45,
313                    ),
314                    text: palette.foreground,
315                    text_hover: palette.foreground,
316                    text_active: palette.foreground,
317                    border: Color32::from_rgba_unmultiplied(
318                        palette.primary.r(),
319                        palette.primary.g(),
320                        palette.primary.b(),
321                        100,
322                    ),
323                    border_hover: Color32::from_rgba_unmultiplied(
324                        palette.primary.r(),
325                        palette.primary.g(),
326                        palette.primary.b(),
327                        130,
328                    ),
329                    focus_ring,
330                    disabled_opacity: 0.5,
331                    rounding: CornerRadius::same(8),
332                }
333            }
334            ButtonVariant::Destructive => {
335                let destructive_ring = Color32::from_rgba_unmultiplied(
336                    palette.destructive.r(),
337                    palette.destructive.g(),
338                    palette.destructive.b(),
339                    51,
340                );
341                Self {
342                    bg: palette.destructive,
343                    bg_hover: mix(palette.destructive, Color32::WHITE, 0.1),
344                    bg_active: mix(palette.destructive, Color32::WHITE, 0.15),
345                    text: Color32::WHITE,
346                    text_hover: Color32::WHITE,
347                    text_active: Color32::WHITE,
348                    border: Color32::TRANSPARENT,
349                    border_hover: Color32::TRANSPARENT,
350                    focus_ring: destructive_ring,
351                    disabled_opacity: 0.5,
352                    rounding: CornerRadius::same(8),
353                }
354            }
355            ButtonVariant::Outline => outline_variant_style(palette, palette.accent, palette.input),
356            ButtonVariant::Secondary => Self {
357                bg: palette.secondary,
358                bg_hover: mix(palette.secondary, Color32::WHITE, 0.08),
359                bg_active: mix(palette.secondary, Color32::WHITE, 0.12),
360                text: palette.secondary_foreground,
361                text_hover: palette.secondary_foreground,
362                text_active: palette.secondary_foreground,
363                border: Color32::TRANSPARENT,
364                border_hover: Color32::TRANSPARENT,
365                focus_ring,
366                disabled_opacity: 0.5,
367                rounding: CornerRadius::same(8),
368            },
369            ButtonVariant::Ghost => Self {
370                bg: Color32::TRANSPARENT,
371                bg_hover: palette.accent,
372                bg_active: mix(palette.accent, Color32::WHITE, 0.1),
373                text: palette.foreground,
374                text_hover: palette.foreground,
375                text_active: palette.foreground,
376                border: Color32::TRANSPARENT,
377                border_hover: Color32::TRANSPARENT,
378                focus_ring,
379                disabled_opacity: 0.5,
380                rounding: CornerRadius::same(8),
381            },
382            ButtonVariant::Link => Self {
383                bg: Color32::TRANSPARENT,
384                bg_hover: Color32::TRANSPARENT,
385                bg_active: Color32::TRANSPARENT,
386                text: palette.primary,
387                text_hover: palette.primary,
388                text_active: palette.primary,
389                border: Color32::TRANSPARENT,
390                border_hover: Color32::TRANSPARENT,
391                focus_ring,
392                disabled_opacity: 0.5,
393                rounding: CornerRadius::same(8),
394            },
395        }
396    }
397
398    pub fn from_variant_with_accent(
399        palette: &ColorPalette,
400        variant: ButtonVariant,
401        accent: Color32,
402    ) -> Self {
403        let mut style = Self::from_variant(palette, variant);
404        match variant {
405            ButtonVariant::Default | ButtonVariant::Solid => {
406                style.bg = accent;
407                style.bg_hover = mix(accent, palette.background, 0.12);
408                style.bg_active = mix(accent, palette.background, 0.22);
409                style.text = compute_contrast_color(accent, palette);
410                style.focus_ring = mix(accent, palette.background, 0.35);
411            }
412            ButtonVariant::Classic => {
413                style.bg = accent;
414                style.bg_hover = mix(accent, palette.background, 0.08);
415                style.bg_active = mix(accent, palette.background, 0.15);
416                style.text = compute_contrast_color(accent, palette);
417                style.border = mix(accent, palette.background, 0.22);
418                style.border_hover = mix(accent, palette.background, 0.27);
419                style.focus_ring = mix(accent, palette.background, 0.4);
420            }
421            ButtonVariant::Soft => {
422                style.bg = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 30);
423                style.bg_hover =
424                    Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 45);
425                style.bg_active =
426                    Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 60);
427                style.text = accent;
428                style.focus_ring =
429                    Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 100);
430            }
431            ButtonVariant::Surface => {
432                style.bg = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 20);
433                style.bg_hover =
434                    Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 30);
435                style.bg_active =
436                    Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 45);
437                style.text = accent;
438                style.border =
439                    Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 100);
440                style.border_hover =
441                    Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 130);
442                style.focus_ring =
443                    Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 100);
444            }
445            ButtonVariant::Destructive => {}
446            ButtonVariant::Outline => {
447                style = outline_variant_style(palette, accent, accent);
448            }
449            ButtonVariant::Secondary => {}
450            ButtonVariant::Ghost => {
451                style.text = accent;
452                style.text_hover = accent;
453                style.text_active = accent;
454            }
455            ButtonVariant::Link => {
456                style.text = accent;
457                style.text_hover = accent;
458                style.text_active = accent;
459                style.focus_ring =
460                    Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 128);
461            }
462        }
463        style
464    }
465
466    pub fn high_contrast(mut self, palette: &ColorPalette) -> Self {
467        self.bg = mix(self.bg, palette.foreground, 0.15);
468        self.bg_hover = mix(self.bg_hover, palette.foreground, 0.15);
469        self.text = palette.foreground;
470        self
471    }
472}
473
474fn outline_variant_style(
475    palette: &ColorPalette,
476    _accent: Color32,
477    _border_color: Color32,
478) -> ButtonStyle {
479    let focus_ring =
480        Color32::from_rgba_unmultiplied(palette.ring.r(), palette.ring.g(), palette.ring.b(), 128);
481
482    // Согласно Radix UI Themes (референс для shadcn/ui):
483    // - Фон прозрачный
484    // - Border: на основе accent цвета с alpha ~50% (accent-a8 в Radix)
485    // - Текст: foreground цвет
486    // - Hover: заметный полупрозрачный фон
487    let bg_transparent = Color32::TRANSPARENT;
488    let bg_hover_subtle = Color32::from_rgba_unmultiplied(
489        palette.foreground.r(),
490        palette.foreground.g(),
491        palette.foreground.b(),
492        55, // ~10% непрозрачности для заметного hover
493    );
494    let bg_active_subtle = Color32::from_rgba_unmultiplied(
495        palette.foreground.r(),
496        palette.foreground.g(),
497        palette.foreground.b(),
498        68, // ~15% непрозрачности для active
499    );
500
501    // Граница на основе foreground с хорошей видимостью (~50% альфа)
502    let border_visible = Color32::from_rgba_unmultiplied(
503        palette.foreground.r(),
504        palette.foreground.g(),
505        palette.foreground.b(),
506        128, // 50% непрозрачности для хорошей видимости
507    );
508
509    ButtonStyle {
510        bg: bg_transparent,
511        bg_hover: bg_hover_subtle,
512        bg_active: bg_active_subtle,
513        text: palette.foreground,
514        text_hover: palette.foreground,
515        text_active: palette.foreground,
516        border: border_visible,
517        border_hover: border_visible,
518        focus_ring,
519        disabled_opacity: 0.5,
520        rounding: CornerRadius::same(8),
521    }
522}
523
524fn compute_contrast_color(bg: Color32, palette: &ColorPalette) -> Color32 {
525    let luminance = 0.299 * bg.r() as f32 + 0.587 * bg.g() as f32 + 0.114 * bg.b() as f32;
526    if luminance > 128.0 {
527        palette.background
528    } else {
529        palette.foreground
530    }
531}
532
533fn apply_disabled_opacity(color: Color32, disabled_opacity: f32) -> Color32 {
534    Color32::from_rgba_unmultiplied(
535        color.r(),
536        color.g(),
537        color.b(),
538        (color.a() as f32 * disabled_opacity) as u8,
539    )
540}
541
542fn resolve_style(theme: &Theme, props: &ButtonProps<'_>) -> ButtonStyle {
543    let mut style = props.style.clone().unwrap_or_else(|| {
544        if let Some(accent) = props.accent_color {
545            ButtonStyle::from_variant_with_accent(&theme.palette, props.variant, accent)
546        } else {
547            ButtonStyle::from_variant(&theme.palette, props.variant)
548        }
549    });
550
551    style.rounding = props.radius.corner_radius();
552
553    if props.high_contrast {
554        style = style.high_contrast(&theme.palette);
555    }
556
557    style
558}
559
560#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
561pub enum ButtonJustify {
562    Start,
563
564    #[default]
565    Center,
566
567    Between,
568}
569
570fn desired_button_size(ui: &Ui, props: &ButtonProps<'_>) -> Vec2 {
571    let height = props.size.height();
572
573    let width = if props.size.is_icon() {
574        props.size.icon_width()
575    } else {
576        let text_galley = props.label.clone().into_galley(
577            ui,
578            Some(TextWrapMode::Extend),
579            f32::INFINITY,
580            TextStyle::Button,
581        );
582        let text_width = text_galley.size().x;
583
584        let leading_width = if props.icon.is_some() || props.loading {
585            props.size.icon_size() + props.size.gap()
586        } else {
587            0.0
588        };
589
590        let trailing_width = if props.trailing_icon.is_some() {
591            props.size.gap() + props.size.icon_size()
592        } else {
593            0.0
594        };
595
596        text_width + leading_width + trailing_width + props.size.padding_x() * 2.0
597    };
598
599    let base_width = width.max(40.0);
600    let target_width = props.min_width.unwrap_or(base_width);
601
602    vec2(target_width.max(base_width), height)
603}
604
605fn background_color(
606    style: &ButtonStyle,
607    effectively_disabled: bool,
608    hover_t: f32,
609    active_t: f32,
610) -> Color32 {
611    if effectively_disabled {
612        apply_disabled_opacity(style.bg, style.disabled_opacity)
613    } else {
614        let hover_bg = mix(style.bg, style.bg_hover, hover_t);
615        mix(hover_bg, style.bg_active, active_t)
616    }
617}
618
619fn text_color(
620    style: &ButtonStyle,
621    effectively_disabled: bool,
622    hover_t: f32,
623    active_t: f32,
624) -> Color32 {
625    if effectively_disabled {
626        apply_disabled_opacity(style.text, style.disabled_opacity)
627    } else {
628        let hover_text = mix(style.text, style.text_hover, hover_t);
629        mix(hover_text, style.text_active, active_t)
630    }
631}
632
633fn border_color(style: &ButtonStyle, effectively_disabled: bool, hover_t: f32) -> Color32 {
634    if effectively_disabled {
635        apply_disabled_opacity(style.border, style.disabled_opacity)
636    } else {
637        mix(style.border, style.border_hover, hover_t)
638    }
639}
640
641fn paint_background(
642    painter: &Painter,
643    rect: egui::Rect,
644    style: &ButtonStyle,
645    bg_color: Color32,
646    border_color: Color32,
647) {
648    painter.rect_filled(rect, style.rounding, bg_color);
649
650    if border_color != Color32::TRANSPARENT {
651        painter.rect_stroke(
652            rect,
653            style.rounding,
654            Stroke::new(1.0, border_color),
655            StrokeKind::Inside,
656        );
657    }
658}
659
660fn paint_focus_ring(painter: &Painter, rect: egui::Rect, style: &ButtonStyle, has_focus: bool) {
661    if has_focus {
662        let ring_rect = rect.expand(2.0);
663        painter.rect_stroke(
664            ring_rect,
665            style.rounding,
666            Stroke::new(3.0, style.focus_ring),
667            StrokeKind::Outside,
668        );
669    }
670}
671
672fn paint_icon_button(
673    ui: &Ui,
674    painter: &Painter,
675    props: &ButtonProps<'_>,
676    text_color: Color32,
677    center: Pos2,
678) {
679    let icon_size = props.size.icon_size();
680    if props.loading {
681        let t = ui.ctx().input(|i| i.time) as f32;
682        draw_spinner(painter, center, icon_size, text_color, t * 2.0);
683        ui.ctx().request_repaint();
684    } else if let Some(icon_fn) = props.icon {
685        icon_fn(painter, center, icon_size, text_color);
686    } else {
687        let label_text = props.label.text().to_string();
688        if !label_text.is_empty() {
689            let text_galley = painter.layout_no_wrap(label_text, props.size.font(), text_color);
690            let text_pos = pos2(
691                center.x - text_galley.rect.width() / 2.0,
692                center.y - text_galley.rect.height() / 2.0,
693            );
694            painter.galley(text_pos, text_galley, text_color);
695        }
696    }
697}
698
699fn paint_text_button(
700    ui: &Ui,
701    painter: &Painter,
702    props: &ButtonProps<'_>,
703    text_color: Color32,
704    rect: Rect,
705) {
706    let icon_size = props.size.icon_size();
707    let gap = props.size.gap();
708
709    let text_galley = props.label.clone().into_galley(
710        ui,
711        Some(TextWrapMode::Extend),
712        f32::INFINITY,
713        TextStyle::Button,
714    );
715    let text_width = text_galley.size().x;
716
717    let leading_width = if props.loading || props.icon.is_some() {
718        icon_size + gap
719    } else {
720        0.0
721    };
722
723    let trailing_width = if props.trailing_icon.is_some() {
724        gap + icon_size
725    } else {
726        0.0
727    };
728
729    let content_width = leading_width + text_width + trailing_width;
730    let center = rect.center();
731    let padding_x = props.size.padding_x();
732
733    let start_x = match props.justify {
734        ButtonJustify::Center => center.x - content_width / 2.0,
735        ButtonJustify::Start | ButtonJustify::Between => rect.left() + padding_x,
736    };
737
738    let text_y = center.y - text_galley.size().y / 2.0;
739
740    let trailing_anchor_x =
741        if props.justify == ButtonJustify::Between && props.trailing_icon.is_some() {
742            rect.right() - padding_x
743        } else {
744            start_x + content_width
745        };
746
747    if props.loading {
748        let spinner_center = pos2(start_x + icon_size / 2.0, center.y);
749        let t = ui.ctx().input(|i| i.time) as f32;
750        draw_spinner(painter, spinner_center, icon_size, text_color, t * 2.0);
751        ui.ctx().request_repaint();
752
753        let text_pos = pos2(start_x + icon_size + gap, text_y);
754        painter.galley(text_pos, text_galley, text_color);
755    } else if let Some(icon_fn) = props.icon {
756        let icon_center = pos2(start_x + icon_size / 2.0, center.y);
757        icon_fn(painter, icon_center, icon_size, text_color);
758
759        let text_pos = pos2(start_x + icon_size + gap, text_y);
760        painter.galley(text_pos, text_galley, text_color);
761    } else {
762        let text_pos = pos2(start_x, text_y);
763        painter.galley(text_pos, text_galley, text_color);
764    }
765
766    if let Some(trailing_icon) = props.trailing_icon {
767        let icon_center = pos2(trailing_anchor_x - icon_size / 2.0, center.y);
768        trailing_icon(painter, icon_center, icon_size, text_color);
769    }
770}
771
772fn paint_link_underline(
773    ui: &Ui,
774    painter: &Painter,
775    props: &ButtonProps<'_>,
776    text_color: Color32,
777    center: Pos2,
778) {
779    let text_galley = props.label.clone().into_galley(
780        ui,
781        Some(TextWrapMode::Extend),
782        f32::INFINITY,
783        TextStyle::Button,
784    );
785    let text_width = text_galley.size().x;
786    let text_bottom = center.y + text_galley.size().y / 2.0 - 2.0;
787    let underline_y = text_bottom + 2.0;
788
789    painter.line_segment(
790        [
791            pos2(center.x - text_width / 2.0, underline_y),
792            pos2(center.x + text_width / 2.0, underline_y),
793        ],
794        Stroke::new(1.0, text_color),
795    );
796}
797
798#[derive(Clone)]
799pub struct ButtonProps<'a> {
800    pub label: WidgetText,
801
802    pub variant: ButtonVariant,
803
804    pub size: ButtonSize,
805
806    pub scale: ButtonScale,
807
808    pub radius: ButtonRadius,
809
810    pub enabled: bool,
811
812    pub loading: bool,
813
814    pub high_contrast: bool,
815
816    pub accent_color: Option<Color32>,
817
818    pub style: Option<ButtonStyle>,
819
820    #[allow(clippy::type_complexity)]
821    pub icon: Option<&'a dyn Fn(&Painter, Pos2, f32, Color32)>,
822
823    #[allow(clippy::type_complexity)]
824    pub trailing_icon: Option<&'a dyn Fn(&Painter, Pos2, f32, Color32)>,
825
826    pub justify: ButtonJustify,
827
828    pub min_width: Option<f32>,
829}
830
831impl<'a> std::fmt::Debug for ButtonProps<'a> {
832    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
833        f.debug_struct("ButtonProps")
834            .field("label", &self.label)
835            .field("variant", &self.variant)
836            .field("size", &self.size)
837            .field("radius", &self.radius)
838            .field("enabled", &self.enabled)
839            .field("loading", &self.loading)
840            .field("high_contrast", &self.high_contrast)
841            .field("accent_color", &self.accent_color)
842            .field("style", &self.style)
843            .field("icon", &self.icon.as_ref().map(|_| "<fn>"))
844            .field(
845                "trailing_icon",
846                &self.trailing_icon.as_ref().map(|_| "<fn>"),
847            )
848            .field("justify", &self.justify)
849            .finish()
850    }
851}
852
853impl<'a> ButtonProps<'a> {
854    pub fn new(label: impl Into<WidgetText>) -> Self {
855        Self {
856            label: label.into(),
857            variant: ButtonVariant::Default,
858            size: ButtonSize::Default,
859            scale: ButtonScale::Size2,
860            radius: ButtonRadius::default(),
861            enabled: true,
862            loading: false,
863            high_contrast: false,
864            accent_color: None,
865            style: None,
866            icon: None,
867            trailing_icon: None,
868            justify: ButtonJustify::default(),
869            min_width: None,
870        }
871    }
872
873    pub fn variant(mut self, variant: ButtonVariant) -> Self {
874        self.variant = variant;
875        self
876    }
877
878    pub fn size(mut self, size: ButtonSize) -> Self {
879        self.size = size;
880        self
881    }
882
883    pub fn scale(mut self, scale: ButtonScale) -> Self {
884        self.scale = scale;
885        self.size = ButtonSize::from(scale);
886        self
887    }
888
889    pub fn radius(mut self, radius: ButtonRadius) -> Self {
890        self.radius = radius;
891        self
892    }
893
894    pub fn enabled(mut self, enabled: bool) -> Self {
895        self.enabled = enabled;
896        self
897    }
898
899    pub fn loading(mut self, loading: bool) -> Self {
900        self.loading = loading;
901        self
902    }
903
904    pub fn high_contrast(mut self, high_contrast: bool) -> Self {
905        self.high_contrast = high_contrast;
906        self
907    }
908
909    pub fn accent_color(mut self, color: Color32) -> Self {
910        self.accent_color = Some(color);
911        self
912    }
913
914    pub fn color(mut self, color: Color32) -> Self {
915        self.accent_color = Some(color);
916        self
917    }
918
919    pub fn style(mut self, style: ButtonStyle) -> Self {
920        self.style = Some(style);
921        self
922    }
923
924    pub fn icon(mut self, icon: &'a dyn Fn(&Painter, Pos2, f32, Color32)) -> Self {
925        self.icon = Some(icon);
926        self
927    }
928
929    pub fn trailing_icon(mut self, icon: &'a dyn Fn(&Painter, Pos2, f32, Color32)) -> Self {
930        self.trailing_icon = Some(icon);
931        self
932    }
933
934    pub fn justify(mut self, justify: ButtonJustify) -> Self {
935        self.justify = justify;
936        self
937    }
938
939    pub fn min_width(mut self, width: f32) -> Self {
940        self.min_width = Some(width);
941        self
942    }
943
944    pub fn show(self, ui: &mut Ui, theme: &Theme) -> Response {
945        button_with_props(ui, theme, self)
946    }
947}
948
949#[derive(Clone)]
950pub struct Button<'a> {
951    props: ButtonProps<'a>,
952}
953
954impl<'a> Button<'a> {
955    pub fn new(label: impl Into<WidgetText>) -> Self {
956        Self {
957            props: ButtonProps::new(label),
958        }
959    }
960
961    pub fn variant(mut self, variant: ButtonVariant) -> Self {
962        self.props.variant = variant;
963        self
964    }
965
966    pub fn size(mut self, size: ButtonSize) -> Self {
967        self.props.size = size;
968        self
969    }
970
971    pub fn scale(mut self, scale: ButtonScale) -> Self {
972        self.props.scale = scale;
973        self.props.size = ButtonSize::from(scale);
974        self
975    }
976
977    pub fn radius(mut self, radius: ButtonRadius) -> Self {
978        self.props.radius = radius;
979        self
980    }
981
982    pub fn enabled(mut self, enabled: bool) -> Self {
983        self.props.enabled = enabled;
984        self
985    }
986
987    pub fn loading(mut self, loading: bool) -> Self {
988        self.props.loading = loading;
989        self
990    }
991
992    pub fn high_contrast(mut self, high_contrast: bool) -> Self {
993        self.props.high_contrast = high_contrast;
994        self
995    }
996
997    pub fn accent_color(mut self, color: Color32) -> Self {
998        self.props.accent_color = Some(color);
999        self
1000    }
1001
1002    pub fn color(mut self, color: Color32) -> Self {
1003        self.props.accent_color = Some(color);
1004        self
1005    }
1006
1007    pub fn style(mut self, style: ButtonStyle) -> Self {
1008        self.props.style = Some(style);
1009        self
1010    }
1011
1012    pub fn icon(mut self, icon: &'a dyn Fn(&Painter, Pos2, f32, Color32)) -> Self {
1013        self.props.icon = Some(icon);
1014        self
1015    }
1016
1017    pub fn trailing_icon(mut self, icon: &'a dyn Fn(&Painter, Pos2, f32, Color32)) -> Self {
1018        self.props.trailing_icon = Some(icon);
1019        self
1020    }
1021
1022    pub fn justify(mut self, justify: ButtonJustify) -> Self {
1023        self.props.justify = justify;
1024        self
1025    }
1026
1027    pub fn min_width(mut self, width: f32) -> Self {
1028        self.props.min_width = Some(width);
1029        self
1030    }
1031
1032    pub fn show(self, ui: &mut Ui, theme: &Theme) -> Response {
1033        button_with_props(ui, theme, self.props)
1034    }
1035}
1036
1037fn draw_spinner(painter: &Painter, center: Pos2, size: f32, color: Color32, t: f32) {
1038    let segments = 12;
1039    let angle_offset = t * std::f32::consts::TAU;
1040
1041    for i in 0..segments {
1042        let angle = (i as f32 / segments as f32) * std::f32::consts::TAU + angle_offset;
1043        let opacity = ((segments - i) as f32 / segments as f32 * 255.0) as u8;
1044        let seg_color = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), opacity);
1045
1046        let inner_r = size * 0.35;
1047        let outer_r = size * 0.5;
1048
1049        let inner = pos2(
1050            center.x + angle.cos() * inner_r,
1051            center.y + angle.sin() * inner_r,
1052        );
1053        let outer = pos2(
1054            center.x + angle.cos() * outer_r,
1055            center.y + angle.sin() * outer_r,
1056        );
1057
1058        painter.line_segment([inner, outer], Stroke::new(2.0, seg_color));
1059    }
1060}
1061
1062fn button_with_props(ui: &mut Ui, theme: &Theme, props: ButtonProps<'_>) -> Response {
1063    trace!(
1064        "Rendering button variant={:?} size={:?} enabled={} loading={}",
1065        props.variant, props.size, props.enabled, props.loading
1066    );
1067
1068    ui.scope(|ui| {
1069        let mut scoped_style = ui.style().as_ref().clone();
1070        scoped_style
1071            .text_styles
1072            .insert(TextStyle::Button, props.size.font());
1073        ui.set_style(scoped_style);
1074
1075        let style = resolve_style(theme, &props);
1076        let effectively_disabled = !props.enabled || props.loading;
1077
1078        let desired_size = desired_button_size(ui, &props);
1079        let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
1080
1081        let painter = ui.painter();
1082
1083        let is_hovered = response.hovered() && !effectively_disabled;
1084        let is_pressed = response.is_pointer_button_down_on() && !effectively_disabled;
1085        let has_focus = response.has_focus() && !effectively_disabled;
1086
1087        let anim_duration = theme.motion.base_ms / 1000.0;
1088        let active_t = ui.ctx().animate_bool_with_time_and_easing(
1089            response.id.with("active"),
1090            is_pressed,
1091            anim_duration,
1092            ease_out_cubic,
1093        );
1094        let hover_t = ui.ctx().animate_bool_with_time_and_easing(
1095            response.id.with("hover"),
1096            is_hovered,
1097            anim_duration,
1098            ease_out_cubic,
1099        );
1100
1101        let bg_color = background_color(&style, effectively_disabled, hover_t, active_t);
1102        let text_color = text_color(&style, effectively_disabled, hover_t, active_t);
1103        let border_color = border_color(&style, effectively_disabled, hover_t);
1104
1105        paint_background(painter, rect, &style, bg_color, border_color);
1106        paint_focus_ring(painter, rect, &style, has_focus);
1107
1108        if props.size.is_icon() {
1109            paint_icon_button(ui, painter, &props, text_color, rect.center());
1110        } else {
1111            paint_text_button(ui, painter, &props, text_color, rect);
1112        }
1113
1114        if props.variant == ButtonVariant::Link && is_hovered {
1115            paint_link_underline(ui, painter, &props, text_color, rect.center());
1116        }
1117
1118        response
1119    })
1120    .inner
1121}
1122
1123pub fn button(
1124    ui: &mut Ui,
1125    theme: &Theme,
1126    label: impl Into<WidgetText>,
1127    variant: ControlVariant,
1128    size: ControlSize,
1129    enabled: bool,
1130) -> Response {
1131    Button::new(label)
1132        .variant(ButtonVariant::from(variant))
1133        .size(ButtonSize::from(size))
1134        .enabled(enabled)
1135        .show(ui, theme)
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141
1142    #[test]
1143    fn size_from_scale_matches_expected() {
1144        assert_eq!(ButtonSize::from(ButtonScale::Size1), ButtonSize::Sm);
1145        assert_eq!(ButtonSize::from(ButtonScale::Size2), ButtonSize::Default);
1146        assert_eq!(ButtonSize::from(ButtonScale::Size3), ButtonSize::Lg);
1147        assert_eq!(ButtonSize::from(ButtonScale::Size4), ButtonSize::Lg);
1148    }
1149
1150    #[test]
1151    fn builder_color_alias_sets_accent() {
1152        let btn = Button::new("Test").color(Color32::RED);
1153        assert_eq!(btn.props.accent_color, Some(Color32::RED));
1154    }
1155
1156    #[test]
1157    fn builder_scale_sets_size() {
1158        let btn = Button::new("Test").scale(ButtonScale::Size1);
1159        assert_eq!(btn.props.scale, ButtonScale::Size1);
1160        assert_eq!(btn.props.size, ButtonSize::Sm);
1161    }
1162}