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 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, );
494 let bg_active_subtle = Color32::from_rgba_unmultiplied(
495 palette.foreground.r(),
496 palette.foreground.g(),
497 palette.foreground.b(),
498 68, );
500
501 let border_visible = Color32::from_rgba_unmultiplied(
503 palette.foreground.r(),
504 palette.foreground.g(),
505 palette.foreground.b(),
506 128, );
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}