Skip to main content

egui_material3/
button.rs

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/// Material Design button with support for different variants.
11///
12/// Supports filled, outlined, text, elevated, and filled tonal button variants
13/// following Material Design 3 specifications.
14///
15/// ## Usage Examples
16/// ```rust
17/// # egui::__run_test_ui(|ui| {
18/// # fn do_stuff() {}
19///
20/// // Material Design filled button (default, high emphasis)
21/// if ui.add(MaterialButton::filled("Click me")).clicked() {
22///     do_stuff();
23/// }
24///
25/// // Material Design outlined button (medium emphasis)
26/// if ui.add(MaterialButton::outlined("Outlined")).clicked() {
27///     do_stuff();
28/// }
29///
30/// // Material Design text button (low emphasis)
31/// if ui.add(MaterialButton::text("Text")).clicked() {
32///     do_stuff();
33/// }
34///
35/// // Material Design elevated button (medium emphasis with shadow)
36/// if ui.add(MaterialButton::elevated("Elevated")).clicked() {
37///     do_stuff();
38/// }
39///
40/// // Material Design filled tonal button (medium emphasis, toned down)
41/// if ui.add(MaterialButton::filled_tonal("Tonal")).clicked() {
42///     do_stuff();
43/// }
44///
45/// // Button with custom properties
46/// if ui.add(
47///     MaterialButton::filled("Custom")
48///         .min_size(Vec2::new(120.0, 40.0))
49///         .enabled(true)
50///         .selected(false)
51/// ).clicked() {
52///     do_stuff();
53/// }
54/// # });
55/// ```
56
57/// Material Design button variants following Material Design 3 specifications
58#[derive(Clone, Copy, Debug, PartialEq)]
59pub enum MaterialButtonVariant {
60    /// Filled button - High emphasis, filled background with primary color
61    Filled,
62    /// Outlined button - Medium emphasis, transparent background with outline
63    Outlined,
64    /// Text button - Low emphasis, transparent background, no outline  
65    Text,
66    /// Elevated button - Medium emphasis, filled background with shadow elevation
67    Elevated,
68    /// Filled tonal button - Medium emphasis, filled background with secondary container color
69    FilledTonal,
70}
71
72/// Material Design button widget implementing Material Design 3 button specifications
73///
74/// This widget provides a button that follows Material Design guidelines including:
75/// - Proper color schemes for different variants
76/// - Hover and pressed state animations
77/// - Material Design typography
78/// - Accessibility support
79/// - Icon and text support
80#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
81pub struct MaterialButton<'a> {
82    /// Optional image/icon to display alongside or instead of text
83    image: Option<Image<'a>>,
84    /// Text content of the button
85    text: Option<WidgetText>,
86    /// Keyboard shortcut text displayed on the button (usually right-aligned)
87    shortcut_text: WidgetText,
88    /// Text wrapping behavior for long button text
89    wrap_mode: Option<TextWrapMode>,
90
91    /// Button variant (filled, outlined, text, elevated, filled tonal)
92    variant: MaterialButtonVariant,
93    /// Custom background fill color (None uses variant default)
94    fill: Option<Color32>,
95    /// Custom stroke/outline settings (None uses variant default)
96    stroke: Option<Stroke>,
97    /// Mouse/touch interaction sensitivity settings
98    sense: Sense,
99    /// Whether to render as a smaller compact button
100    small: bool,
101    /// Whether to show the button frame/background (None uses variant default)
102    frame: Option<bool>,
103    /// Minimum size constraints for the button
104    min_size: Vec2,
105    /// Custom corner radius (None uses Material Design default of 20dp/10px)
106    corner_radius: Option<CornerRadius>,
107    /// Whether the button appears in selected/pressed state
108    selected: bool,
109    /// If true, the tint of the image is multiplied by the widget text color.
110    ///
111    /// This makes sense for images that are white, that should have the same color as the text color.
112    /// This will also make the icon color depend on hover state.
113    ///
114    /// Default: `false`.
115    image_tint_follows_text_color: bool,
116    /// Custom elevation shadow for the button (None uses variant default)
117    elevation: Option<Shadow>,
118    /// Whether the button is disabled (non-interactive)
119    disabled: bool,
120    /// Leading icon name (rendered using Material Symbols font)
121    leading_icon: Option<String>,
122    /// Trailing icon name (rendered using Material Symbols font)
123    trailing_icon: Option<String>,
124    /// Custom text color override (None uses variant default)
125    text_color: Option<Color32>,
126}
127
128impl<'a> MaterialButton<'a> {
129    /// Create a filled Material Design button with high emphasis
130    ///
131    /// Filled buttons have the most visual impact and should be used for
132    /// the primary action in a set of buttons.
133    ///
134    /// ## Material Design Spec
135    /// - Background: Primary color
136    /// - Text: On-primary color  
137    /// - Elevation: 0dp (no shadow)
138    /// - Corner radius: 20dp
139    pub fn filled(text: impl Into<WidgetText>) -> Self {
140        Self::new_with_variant(MaterialButtonVariant::Filled, text)
141    }
142
143    /// Create an outlined Material Design button with medium emphasis
144    ///
145    /// Outlined buttons are medium-emphasis buttons. They contain actions
146    /// that are important but aren't the primary action in an app.
147    ///
148    /// ## Material Design Spec  
149    /// - Background: Transparent
150    /// - Text: Primary color
151    /// - Outline: 1dp primary color
152    /// - Corner radius: 20dp
153    pub fn outlined(text: impl Into<WidgetText>) -> Self {
154        Self::new_with_variant(MaterialButtonVariant::Outlined, text)
155    }
156
157    /// Create a text Material Design button with low emphasis
158    ///
159    /// Text buttons are used for the least important actions in a UI.
160    /// They're often used for secondary actions.
161    ///
162    /// ## Material Design Spec
163    /// - Background: Transparent  
164    /// - Text: Primary color
165    /// - No outline or elevation
166    /// - Corner radius: 20dp
167    pub fn text(text: impl Into<WidgetText>) -> Self {
168        Self::new_with_variant(MaterialButtonVariant::Text, text)
169    }
170
171    /// Create an elevated Material Design button with medium emphasis
172    ///
173    /// Elevated buttons are essentially filled buttons with a shadow.
174    /// Use them to add separation between button and background.
175    ///
176    /// ## Material Design Spec
177    /// - Background: Surface color
178    /// - Text: Primary color
179    /// - Elevation: 1dp shadow
180    /// - Corner radius: 20dp  
181    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    /// Create a filled tonal Material Design button with medium emphasis
191    ///
192    /// Filled tonal buttons are used to convey a secondary action that is
193    /// still important, but not the primary action.
194    ///
195    /// ## Material Design Spec
196    /// - Background: Secondary container color
197    /// - Text: On-secondary-container color
198    /// - Elevation: 0dp (no shadow)
199    /// - Corner radius: 20dp
200    pub fn filled_tonal(text: impl Into<WidgetText>) -> Self {
201        Self::new_with_variant(MaterialButtonVariant::FilledTonal, text)
202    }
203
204    /// Internal constructor that creates a button with the specified variant and text
205    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    /// Creates a button with an image. The size of the image as displayed is defined by the provided size.
214    #[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    /// Creates a button with an image to the left of the text. The size of the image as displayed is defined by the provided size.
220    #[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    /// Creates a button with an image. The size of the image as displayed is defined by the provided size.
226    ///
227    /// Use this when you need both or either an image and text, or when text might be None.
228    ///
229    /// ## Parameters
230    /// - `image`: Optional icon/image to display
231    /// - `text`: Optional text content
232    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    /// Create a Material Design button with specific variant and optional image and text
237    ///
238    /// This is the most flexible constructor allowing full control over button content.
239    ///
240    /// ## Parameters
241    /// - `variant`: The Material Design button variant to use
242    /// - `image`: Optional icon/image to display  
243    /// - `text`: Optional text content
244    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    /// Set the wrap mode for the text.
273    ///
274    /// By default, [`egui::Ui::wrap_mode`] will be used, which can be overridden with [`egui::Style::wrap_mode`].
275    ///
276    /// Note that any `\n` in the text will always produce a new line.
277    #[inline]
278    pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
279        self.wrap_mode = Some(wrap_mode);
280        self
281    }
282
283    /// Set [`Self::wrap_mode`] to [`TextWrapMode::Wrap`].
284    #[inline]
285    pub fn wrap(mut self) -> Self {
286        self.wrap_mode = Some(TextWrapMode::Wrap);
287
288        self
289    }
290
291    /// Set [`Self::wrap_mode`] to [`TextWrapMode::Truncate`].
292    #[inline]
293    pub fn truncate(mut self) -> Self {
294        self.wrap_mode = Some(TextWrapMode::Truncate);
295        self
296    }
297
298    /// Override background fill color. Note that this will override any on-hover effects.
299    /// Calling this will also turn on the frame.
300    #[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    /// Override button stroke. Note that this will override any on-hover effects.
308    /// Calling this will also turn on the frame.
309    #[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    /// Make this a small button, suitable for embedding into text.
317    #[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    /// Turn off the frame
327    #[inline]
328    pub fn frame(mut self, frame: bool) -> Self {
329        self.frame = Some(frame);
330        self
331    }
332
333    /// By default, buttons senses clicks.
334    /// Change this to a drag-button with `Sense::drag()`.
335    #[inline]
336    pub fn sense(mut self, sense: Sense) -> Self {
337        self.sense = sense;
338        self
339    }
340
341    /// Set the minimum size of the button.
342    #[inline]
343    pub fn min_size(mut self, min_size: Vec2) -> Self {
344        self.min_size = min_size;
345        self
346    }
347
348    /// Set the rounding of the button.
349    #[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    /// If true, the tint of the image is multiplied by the widget text color.
362    ///
363    /// This makes sense for images that are white, that should have the same color as the text color.
364    /// This will also make the icon color depend on hover state.
365    ///
366    /// Default: `false`.
367    #[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    /// Show some text on the right side of the button, in weak color.
374    ///
375    /// Designed for menu buttons, for setting a keyboard shortcut text (e.g. `Ctrl+S`).
376    ///
377    /// The text can be created with [`egui::Context::format_shortcut`].
378    #[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    /// If `true`, mark this button as "selected".
385    #[inline]
386    pub fn selected(mut self, selected: bool) -> Self {
387        self.selected = selected;
388        self
389    }
390
391    /// Enable or disable the button.
392    #[inline]
393    pub fn enabled(mut self, enabled: bool) -> Self {
394        self.disabled = !enabled;
395        self
396    }
397
398    /// Set the elevation shadow for the button.
399    #[inline]
400    pub fn elevation(mut self, elevation: Shadow) -> Self {
401        self.elevation = Some(elevation);
402        self
403    }
404
405    /// Add a leading icon to the button (rendered before the text).
406    ///
407    /// Uses Material Symbols icon font. Pass the icon name (e.g., "upload", "search").
408    #[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    /// Add a trailing icon to the button (rendered after the text).
415    ///
416    /// Uses Material Symbols icon font. Pass the icon name (e.g., "arrow_forward", "open_in_new").
417    #[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    /// Override the text color for this button.
424    ///
425    /// When set, overrides the variant-based text color.
426    /// Icon colors also follow this override.
427    #[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        // Material Design color palette from theme
459        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        // Material Design button defaults based on variant
510        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        // Material Design button padding
549        // With leading icon: 16px left, 24px right
550        // With trailing icon: 24px left, 16px right
551        // With both icons: 16px left, 16px right
552        // No icons: 24px left, 24px right
553        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        // Material Design minimum button height
571        let min_button_height = if small { 32.0 } else { 40.0 };
572        let icon_spacing = 8.0; // Material Design icon-to-text gap
573
574        // Resolve the variant-based text color (used for text and icons)
575        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        // Build leading icon galley
590        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        // Build trailing icon galley
596        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        // Note: we don't wrap the shortcut text
631        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        // Leading icon
650        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        // Image
656        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        // Gap between leading content and text
665        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        // Trailing icon
675        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            // Apply disabled styling - Material Design spec
731            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            // Material Design state layers (hover/press overlays)
743            if !disabled {
744                let state_layer_color = resolved_text_color;
745                if response.is_pointer_button_down_on() {
746                    // Pressed: 12% overlay
747                    frame_fill = blend_overlay(frame_fill, state_layer_color, 0.12);
748                } else if response.hovered() {
749                    // Hovered: 8% overlay
750                    frame_fill = blend_overlay(frame_fill, state_layer_color, 0.08);
751                }
752            }
753
754            // Draw elevation shadow if present
755            if let Some(shadow) = &elevation {
756                // Hover elevation boost for elevated buttons
757                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            // Draw leading icon
787            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            // Draw image
797            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            // Draw main text
821            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                // Center text if no leading/trailing elements
825                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            // Draw trailing icon
853            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            // Draw shortcut text
863            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
896/// Blend an overlay color on top of a base color with given opacity.
897fn 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    // Simple alpha blending
901    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}