1use crate::{get_global_color, material_symbol::material_symbol_text};
2use egui::{
3 ecolor::Color32,
4 emath::NumExt,
5 epaint::{CornerRadius, Shadow, Stroke},
6 Align, Image, Rect, Response, Sense, TextStyle, TextWrapMode, Ui, Vec2, Widget, WidgetInfo,
7 WidgetText, WidgetType,
8};
9
10#[derive(Clone, Copy, Debug, PartialEq)]
59pub enum MaterialButtonVariant {
60 Filled,
62 Outlined,
64 Text,
66 Elevated,
68 FilledTonal,
70}
71
72#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
81pub struct MaterialButton<'a> {
82 image: Option<Image<'a>>,
84 text: Option<WidgetText>,
86 shortcut_text: WidgetText,
88 wrap_mode: Option<TextWrapMode>,
90
91 variant: MaterialButtonVariant,
93 fill: Option<Color32>,
95 stroke: Option<Stroke>,
97 sense: Sense,
99 small: bool,
101 frame: Option<bool>,
103 min_size: Vec2,
105 corner_radius: Option<CornerRadius>,
107 selected: bool,
109 image_tint_follows_text_color: bool,
116 elevation: Option<Shadow>,
118 disabled: bool,
120 leading_icon: Option<String>,
122 trailing_icon: Option<String>,
124 text_color: Option<Color32>,
126}
127
128impl<'a> MaterialButton<'a> {
129 pub fn filled(text: impl Into<WidgetText>) -> Self {
140 Self::new_with_variant(MaterialButtonVariant::Filled, text)
141 }
142
143 pub fn outlined(text: impl Into<WidgetText>) -> Self {
154 Self::new_with_variant(MaterialButtonVariant::Outlined, text)
155 }
156
157 pub fn text(text: impl Into<WidgetText>) -> Self {
168 Self::new_with_variant(MaterialButtonVariant::Text, text)
169 }
170
171 pub fn elevated(text: impl Into<WidgetText>) -> Self {
182 Self::new_with_variant(MaterialButtonVariant::Elevated, text).elevation(Shadow {
183 offset: [0, 2],
184 blur: 6,
185 spread: 0,
186 color: Color32::from_rgba_unmultiplied(0, 0, 0, 30),
187 })
188 }
189
190 pub fn filled_tonal(text: impl Into<WidgetText>) -> Self {
201 Self::new_with_variant(MaterialButtonVariant::FilledTonal, text)
202 }
203
204 fn new_with_variant(variant: MaterialButtonVariant, text: impl Into<WidgetText>) -> Self {
206 Self::opt_image_and_text_with_variant(variant, None, Some(text.into()))
207 }
208
209 pub fn new(text: impl Into<WidgetText>) -> Self {
210 Self::filled(text)
211 }
212
213 #[allow(clippy::needless_pass_by_value)]
215 pub fn image(image: impl Into<Image<'a>>) -> Self {
216 Self::opt_image_and_text(Some(image.into()), None)
217 }
218
219 #[allow(clippy::needless_pass_by_value)]
221 pub fn image_and_text(image: impl Into<Image<'a>>, text: impl Into<WidgetText>) -> Self {
222 Self::opt_image_and_text(Some(image.into()), Some(text.into()))
223 }
224
225 pub fn opt_image_and_text(image: Option<Image<'a>>, text: Option<WidgetText>) -> Self {
233 Self::opt_image_and_text_with_variant(MaterialButtonVariant::Filled, image, text)
234 }
235
236 pub fn opt_image_and_text_with_variant(
245 variant: MaterialButtonVariant,
246 image: Option<Image<'a>>,
247 text: Option<WidgetText>,
248 ) -> Self {
249 Self {
250 variant,
251 text,
252 image,
253 shortcut_text: Default::default(),
254 wrap_mode: None,
255 fill: None,
256 stroke: None,
257 sense: Sense::click(),
258 small: false,
259 frame: None,
260 min_size: Vec2::ZERO,
261 corner_radius: None,
262 selected: false,
263 image_tint_follows_text_color: false,
264 elevation: None,
265 disabled: false,
266 leading_icon: None,
267 trailing_icon: None,
268 text_color: None,
269 }
270 }
271
272 #[inline]
278 pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
279 self.wrap_mode = Some(wrap_mode);
280 self
281 }
282
283 #[inline]
285 pub fn wrap(mut self) -> Self {
286 self.wrap_mode = Some(TextWrapMode::Wrap);
287
288 self
289 }
290
291 #[inline]
293 pub fn truncate(mut self) -> Self {
294 self.wrap_mode = Some(TextWrapMode::Truncate);
295 self
296 }
297
298 #[inline]
301 pub fn fill(mut self, fill: impl Into<Color32>) -> Self {
302 self.fill = Some(fill.into());
303 self.frame = Some(true);
304 self
305 }
306
307 #[inline]
310 pub fn stroke(mut self, stroke: impl Into<Stroke>) -> Self {
311 self.stroke = Some(stroke.into());
312 self.frame = Some(true);
313 self
314 }
315
316 #[inline]
318 pub fn small(mut self) -> Self {
319 if let Some(text) = self.text {
320 self.text = Some(text.text_style(TextStyle::Body));
321 }
322 self.small = true;
323 self
324 }
325
326 #[inline]
328 pub fn frame(mut self, frame: bool) -> Self {
329 self.frame = Some(frame);
330 self
331 }
332
333 #[inline]
336 pub fn sense(mut self, sense: Sense) -> Self {
337 self.sense = sense;
338 self
339 }
340
341 #[inline]
343 pub fn min_size(mut self, min_size: Vec2) -> Self {
344 self.min_size = min_size;
345 self
346 }
347
348 #[inline]
350 pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
351 self.corner_radius = Some(corner_radius.into());
352 self
353 }
354
355 #[inline]
356 #[deprecated = "Renamed to `corner_radius`"]
357 pub fn rounding(self, corner_radius: impl Into<CornerRadius>) -> Self {
358 self.corner_radius(corner_radius)
359 }
360
361 #[inline]
368 pub fn image_tint_follows_text_color(mut self, image_tint_follows_text_color: bool) -> Self {
369 self.image_tint_follows_text_color = image_tint_follows_text_color;
370 self
371 }
372
373 #[inline]
379 pub fn shortcut_text(mut self, shortcut_text: impl Into<WidgetText>) -> Self {
380 self.shortcut_text = shortcut_text.into();
381 self
382 }
383
384 #[inline]
386 pub fn selected(mut self, selected: bool) -> Self {
387 self.selected = selected;
388 self
389 }
390
391 #[inline]
393 pub fn enabled(mut self, enabled: bool) -> Self {
394 self.disabled = !enabled;
395 self
396 }
397
398 #[inline]
400 pub fn elevation(mut self, elevation: Shadow) -> Self {
401 self.elevation = Some(elevation);
402 self
403 }
404
405 #[inline]
409 pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
410 self.leading_icon = Some(icon.into());
411 self
412 }
413
414 #[inline]
418 pub fn trailing_icon(mut self, icon: impl Into<String>) -> Self {
419 self.trailing_icon = Some(icon.into());
420 self
421 }
422
423 #[inline]
428 pub fn text_color(mut self, color: Color32) -> Self {
429 self.text_color = Some(color);
430 self
431 }
432}
433
434impl Widget for MaterialButton<'_> {
435 fn ui(self, ui: &mut Ui) -> Response {
436 let MaterialButton {
437 variant,
438 text,
439 image,
440 shortcut_text,
441 wrap_mode,
442 fill,
443 stroke,
444 sense,
445 small,
446 frame,
447 min_size,
448 corner_radius,
449 selected,
450 image_tint_follows_text_color,
451 elevation,
452 disabled,
453 leading_icon,
454 trailing_icon,
455 text_color: custom_text_color,
456 } = self;
457
458 let md_primary = get_global_color("primary");
460 let md_surface_tint = get_global_color("surfaceTint");
461 let md_on_primary = get_global_color("onPrimary");
462 let md_primary_container = get_global_color("primaryContainer");
463 let md_on_primary_container = get_global_color("onPrimaryContainer");
464 let md_secondary = get_global_color("secondary");
465 let md_on_secondary = get_global_color("onSecondary");
466 let md_secondary_container = get_global_color("secondaryContainer");
467 let md_on_secondary_container = get_global_color("onSecondaryContainer");
468 let md_tertiary = get_global_color("tertiary");
469 let md_on_tertiary = get_global_color("onTertiary");
470 let md_tertiary_container = get_global_color("tertiaryContainer");
471 let md_on_tertiary_container = get_global_color("onTertiaryContainer");
472 let md_error = get_global_color("error");
473 let md_on_error = get_global_color("onError");
474 let md_error_container = get_global_color("errorContainer");
475 let md_on_error_container = get_global_color("onErrorContainer");
476 let md_background = get_global_color("background");
477 let md_on_background = get_global_color("onBackground");
478 let md_surface = get_global_color("surface");
479 let md_on_surface = get_global_color("onSurface");
480 let md_surface_variant = get_global_color("surfaceVariant");
481 let md_on_surface_variant = get_global_color("onSurfaceVariant");
482 let md_outline = get_global_color("outline");
483 let md_outline_variant = get_global_color("outlineVariant");
484 let md_shadow = get_global_color("shadow");
485 let md_scrim = get_global_color("scrim");
486 let md_inverse_surface = get_global_color("inverseSurface");
487 let md_inverse_on_surface = get_global_color("inverseOnSurface");
488 let md_inverse_primary = get_global_color("inversePrimary");
489 let md_primary_fixed = get_global_color("primaryFixed");
490 let md_on_primary_fixed = get_global_color("onPrimaryFixed");
491 let md_primary_fixed_dim = get_global_color("primaryFixedDim");
492 let md_on_primary_fixed_variant = get_global_color("onPrimaryFixedVariant");
493 let md_secondary_fixed = get_global_color("secondaryFixed");
494 let md_on_secondary_fixed = get_global_color("onSecondaryFixed");
495 let md_secondary_fixed_dim = get_global_color("secondaryFixedDim");
496 let md_on_secondary_fixed_variant = get_global_color("onSecondaryFixedVariant");
497 let md_tertiary_fixed = get_global_color("tertiaryFixed");
498 let md_on_tertiary_fixed = get_global_color("onTertiaryFixed");
499 let md_tertiary_fixed_dim = get_global_color("tertiaryFixedDim");
500 let md_on_tertiary_fixed_variant = get_global_color("onTertiaryFixedVariant");
501 let md_surface_dim = get_global_color("surfaceDim");
502 let md_surface_bright = get_global_color("surfaceBright");
503 let md_surface_container_lowest = get_global_color("surfaceContainerLowest");
504 let md_surface_container_low = get_global_color("surfaceContainerLow");
505 let md_surface_container = get_global_color("surfaceContainer");
506 let md_surface_container_high = get_global_color("surfaceContainerHigh");
507 let md_surface_container_highest = get_global_color("surfaceContainerHighest");
508
509 let (default_fill, default_stroke, default_corner_radius, _has_elevation) = match variant {
511 MaterialButtonVariant::Filled => (
512 Some(md_primary),
513 Some(Stroke::NONE),
514 CornerRadius::from(20),
515 false,
516 ),
517 MaterialButtonVariant::Outlined => (
518 Some(Color32::TRANSPARENT),
519 Some(Stroke::new(1.0, md_outline)),
520 CornerRadius::from(20),
521 false,
522 ),
523 MaterialButtonVariant::Text => (
524 Some(Color32::TRANSPARENT),
525 Some(Stroke::NONE),
526 CornerRadius::from(20),
527 false,
528 ),
529 MaterialButtonVariant::Elevated => (
530 Some(md_surface),
531 Some(Stroke::NONE),
532 CornerRadius::from(20),
533 true,
534 ),
535 MaterialButtonVariant::FilledTonal => (
536 Some(md_surface_variant),
537 Some(Stroke::NONE),
538 CornerRadius::from(20),
539 false,
540 ),
541 };
542
543 let frame = frame.unwrap_or_else(|| match variant {
544 MaterialButtonVariant::Text => false,
545 _ => true,
546 });
547
548 let has_leading = leading_icon.is_some() || image.is_some();
554 let has_trailing = trailing_icon.is_some();
555 let padding_left = if has_leading { 16.0 } else { 24.0 };
556 let padding_right = if has_trailing { 16.0 } else { 24.0 };
557 let button_padding_left;
558 let button_padding_right;
559 let button_padding_y;
560 if frame || variant == MaterialButtonVariant::Text {
561 button_padding_left = padding_left;
562 button_padding_right = padding_right;
563 button_padding_y = if small { 0.0 } else { 10.0 };
564 } else {
565 button_padding_left = 0.0;
566 button_padding_right = 0.0;
567 button_padding_y = 0.0;
568 }
569
570 let min_button_height = if small { 32.0 } else { 40.0 };
572 let icon_spacing = 8.0; let resolved_text_color = if disabled {
576 md_background.gamma_multiply(0.38)
577 } else if let Some(custom) = custom_text_color {
578 custom
579 } else {
580 match variant {
581 MaterialButtonVariant::Filled => md_background,
582 MaterialButtonVariant::Outlined => md_on_background,
583 MaterialButtonVariant::Text => md_on_background,
584 MaterialButtonVariant::Elevated => md_on_background,
585 MaterialButtonVariant::FilledTonal => get_global_color("onSecondaryContainer"),
586 }
587 };
588
589 let leading_icon_galley = leading_icon.map(|name| {
591 let icon_str: WidgetText = material_symbol_text(&name).into();
592 icon_str.into_galley(ui, Some(TextWrapMode::Extend), f32::INFINITY, TextStyle::Body)
593 });
594
595 let trailing_icon_galley = trailing_icon.map(|name| {
597 let icon_str: WidgetText = material_symbol_text(&name).into();
598 icon_str.into_galley(ui, Some(TextWrapMode::Extend), f32::INFINITY, TextStyle::Body)
599 });
600
601 let space_available_for_image = if let Some(_text) = &text {
602 let font_height = ui.text_style_height(&TextStyle::Body);
603 Vec2::splat(font_height)
604 } else {
605 let total_h_padding = button_padding_left + button_padding_right;
606 ui.available_size() - Vec2::new(total_h_padding, 2.0 * button_padding_y)
607 };
608
609 let image_size = if let Some(image) = &image {
610 image
611 .load_and_calc_size(ui, space_available_for_image)
612 .unwrap_or(space_available_for_image)
613 } else {
614 Vec2::ZERO
615 };
616
617 let gap_before_shortcut_text = ui.spacing().item_spacing.x;
618
619 let mut text_wrap_width = ui.available_width() - button_padding_left - button_padding_right;
620 if image.is_some() {
621 text_wrap_width -= image_size.x + icon_spacing;
622 }
623 if leading_icon_galley.is_some() {
624 text_wrap_width -= leading_icon_galley.as_ref().unwrap().size().x + icon_spacing;
625 }
626 if trailing_icon_galley.is_some() {
627 text_wrap_width -= trailing_icon_galley.as_ref().unwrap().size().x + icon_spacing;
628 }
629
630 let shortcut_galley = (!shortcut_text.is_empty()).then(|| {
632 shortcut_text.into_galley(
633 ui,
634 Some(TextWrapMode::Extend),
635 f32::INFINITY,
636 TextStyle::Body,
637 )
638 });
639
640 if let Some(shortcut_galley) = &shortcut_galley {
641 text_wrap_width -= gap_before_shortcut_text + shortcut_galley.size().x;
642 }
643
644 let galley =
645 text.map(|text| text.into_galley(ui, wrap_mode, text_wrap_width, TextStyle::Body));
646
647 let mut desired_size = Vec2::ZERO;
648
649 if let Some(lg) = &leading_icon_galley {
651 desired_size.x += lg.size().x;
652 desired_size.y = desired_size.y.max(lg.size().y);
653 }
654
655 if image.is_some() {
657 if leading_icon_galley.is_some() {
658 desired_size.x += icon_spacing;
659 }
660 desired_size.x += image_size.x;
661 desired_size.y = desired_size.y.max(image_size.y);
662 }
663
664 if (leading_icon_galley.is_some() || image.is_some()) && galley.is_some() {
666 desired_size.x += icon_spacing;
667 }
668
669 if let Some(galley) = &galley {
670 desired_size.x += galley.size().x;
671 desired_size.y = desired_size.y.max(galley.size().y);
672 }
673
674 if let Some(tg) = &trailing_icon_galley {
676 if galley.is_some() || image.is_some() || leading_icon_galley.is_some() {
677 desired_size.x += icon_spacing;
678 }
679 desired_size.x += tg.size().x;
680 desired_size.y = desired_size.y.max(tg.size().y);
681 }
682
683 if let Some(shortcut_galley) = &shortcut_galley {
684 desired_size.x += gap_before_shortcut_text + shortcut_galley.size().x;
685 desired_size.y = desired_size.y.max(shortcut_galley.size().y);
686 }
687
688 desired_size.x += button_padding_left + button_padding_right;
689 desired_size.y += 2.0 * button_padding_y;
690 if !small {
691 desired_size.y = desired_size.y.at_least(min_button_height);
692 }
693 desired_size = desired_size.at_least(min_size);
694
695 let (rect, response) = ui.allocate_at_least(desired_size, sense);
696 response.widget_info(|| {
697 if let Some(galley) = &galley {
698 WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), galley.text())
699 } else {
700 WidgetInfo::new(WidgetType::Button)
701 }
702 });
703
704 if ui.is_rect_visible(rect) {
705 let visuals = ui.style().interact(&response);
706
707 let (frame_expansion, _frame_cr, frame_fill, frame_stroke) = if selected {
708 let selection = ui.visuals().selection;
709 (
710 Vec2::ZERO,
711 CornerRadius::ZERO,
712 selection.bg_fill,
713 selection.stroke,
714 )
715 } else if frame {
716 let expansion = Vec2::splat(visuals.expansion);
717 (
718 expansion,
719 visuals.corner_radius,
720 visuals.weak_bg_fill,
721 visuals.bg_stroke,
722 )
723 } else {
724 Default::default()
725 };
726 let frame_cr = corner_radius.unwrap_or(default_corner_radius);
727 let mut frame_fill = fill.unwrap_or(default_fill.unwrap_or(frame_fill));
728 let mut frame_stroke = stroke.unwrap_or(default_stroke.unwrap_or(frame_stroke));
729
730 if disabled {
732 let surface_color = get_global_color("surface");
733 frame_fill = surface_color;
734 frame_stroke.color = md_on_surface.gamma_multiply(0.12);
735 frame_stroke.width = if matches!(variant, MaterialButtonVariant::Outlined) {
736 1.0
737 } else {
738 0.0
739 };
740 }
741
742 if !disabled {
744 let state_layer_color = resolved_text_color;
745 if response.is_pointer_button_down_on() {
746 frame_fill = blend_overlay(frame_fill, state_layer_color, 0.12);
748 } else if response.hovered() {
749 frame_fill = blend_overlay(frame_fill, state_layer_color, 0.08);
751 }
752 }
753
754 if let Some(shadow) = &elevation {
756 let shadow = if !disabled && response.hovered() {
758 Shadow {
759 offset: [shadow.offset[0], shadow.offset[1] + 2],
760 blur: shadow.blur + 4,
761 spread: shadow.spread,
762 color: shadow.color,
763 }
764 } else {
765 *shadow
766 };
767 let shadow_offset = Vec2::new(shadow.offset[0] as f32, shadow.offset[1] as f32);
768 let shadow_rect = rect.expand2(frame_expansion).translate(shadow_offset);
769 ui.painter()
770 .rect_filled(shadow_rect, frame_cr, shadow.color);
771 }
772
773 ui.painter().rect(
774 rect.expand2(frame_expansion),
775 frame_cr,
776 frame_fill,
777 frame_stroke,
778 egui::epaint::StrokeKind::Outside,
779 );
780
781 let mut cursor_x = rect.min.x + button_padding_left;
782 let content_rect_y_min = rect.min.y + button_padding_y;
783 let content_rect_y_max = rect.max.y - button_padding_y;
784 let content_height = content_rect_y_max - content_rect_y_min;
785
786 if let Some(leading_galley) = &leading_icon_galley {
788 let icon_y =
789 content_rect_y_min + (content_height - leading_galley.size().y) / 2.0;
790 let icon_pos = egui::pos2(cursor_x, icon_y);
791 ui.painter()
792 .galley(icon_pos, leading_galley.clone(), resolved_text_color);
793 cursor_x += leading_galley.size().x + icon_spacing;
794 }
795
796 if let Some(image) = &image {
798 let mut image_pos = ui
799 .layout()
800 .align_size_within_rect(
801 image_size,
802 Rect::from_min_max(
803 egui::pos2(cursor_x, content_rect_y_min),
804 egui::pos2(rect.max.x - button_padding_right, content_rect_y_max),
805 ),
806 )
807 .min;
808 if galley.is_some() || shortcut_galley.is_some() || trailing_icon_galley.is_some() {
809 image_pos.x = cursor_x;
810 }
811 let image_rect = Rect::from_min_size(image_pos, image_size);
812 cursor_x += image_size.x + icon_spacing;
813 let mut image_widget = image.clone();
814 if image_tint_follows_text_color {
815 image_widget = image_widget.tint(visuals.text_color());
816 }
817 image_widget.paint_at(ui, image_rect);
818 }
819
820 if let Some(galley) = galley {
822 let text_y = content_rect_y_min + (content_height - galley.size().y) / 2.0;
823 let mut text_pos = egui::pos2(cursor_x, text_y);
824 if leading_icon_galley.is_none()
826 && image.is_none()
827 && trailing_icon_galley.is_none()
828 && shortcut_galley.is_none()
829 {
830 text_pos = ui
831 .layout()
832 .align_size_within_rect(
833 galley.size(),
834 Rect::from_min_max(
835 egui::pos2(
836 rect.min.x + button_padding_left,
837 content_rect_y_min,
838 ),
839 egui::pos2(
840 rect.max.x - button_padding_right,
841 content_rect_y_max,
842 ),
843 ),
844 )
845 .min;
846 }
847
848 cursor_x = text_pos.x + galley.size().x;
849 ui.painter().galley(text_pos, galley, resolved_text_color);
850 }
851
852 if let Some(trailing_galley) = &trailing_icon_galley {
854 cursor_x += icon_spacing;
855 let icon_y =
856 content_rect_y_min + (content_height - trailing_galley.size().y) / 2.0;
857 let icon_pos = egui::pos2(cursor_x, icon_y);
858 ui.painter()
859 .galley(icon_pos, trailing_galley.clone(), resolved_text_color);
860 }
861
862 if let Some(shortcut_galley) = shortcut_galley {
864 let layout = if ui.layout().is_horizontal() {
865 ui.layout().with_main_align(Align::Max)
866 } else {
867 ui.layout().with_cross_align(Align::Max)
868 };
869 let shortcut_text_pos = layout
870 .align_size_within_rect(
871 shortcut_galley.size(),
872 Rect::from_min_max(
873 egui::pos2(rect.min.x + button_padding_left, content_rect_y_min),
874 egui::pos2(rect.max.x - button_padding_right, content_rect_y_max),
875 ),
876 )
877 .min;
878 ui.painter().galley(
879 shortcut_text_pos,
880 shortcut_galley,
881 ui.visuals().weak_text_color(),
882 );
883 }
884 }
885
886 if let Some(cursor) = ui.visuals().interact_cursor {
887 if response.hovered() {
888 ui.ctx().set_cursor_icon(cursor);
889 }
890 }
891
892 response
893 }
894}
895
896fn blend_overlay(base: Color32, overlay: Color32, opacity: f32) -> Color32 {
898 let alpha = (opacity * 255.0) as u8;
899 let overlay_with_alpha = Color32::from_rgba_unmultiplied(overlay.r(), overlay.g(), overlay.b(), alpha);
900 let inv_alpha = 255 - alpha;
902 Color32::from_rgba_unmultiplied(
903 ((base.r() as u16 * inv_alpha as u16 + overlay_with_alpha.r() as u16 * alpha as u16) / 255) as u8,
904 ((base.g() as u16 * inv_alpha as u16 + overlay_with_alpha.g() as u16 * alpha as u16) / 255) as u8,
905 ((base.b() as u16 * inv_alpha as u16 + overlay_with_alpha.b() as u16 * alpha as u16) / 255) as u8,
906 base.a(),
907 )
908}