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    /// Leading icon SVG data (rendered as texture, takes precedence over leading_icon)
125    leading_svg: Option<String>,
126    /// Trailing icon SVG data (rendered as texture, takes precedence over trailing_icon)
127    trailing_svg: Option<String>,
128    /// Custom text color override (None uses variant default)
129    text_color: Option<Color32>,
130}
131
132impl<'a> MaterialButton<'a> {
133    /// Create a filled Material Design button with high emphasis
134    ///
135    /// Filled buttons have the most visual impact and should be used for
136    /// the primary action in a set of buttons.
137    ///
138    /// ## Material Design Spec
139    /// - Background: Primary color
140    /// - Text: On-primary color  
141    /// - Elevation: 0dp (no shadow)
142    /// - Corner radius: 20dp
143    pub fn filled(text: impl Into<WidgetText>) -> Self {
144        Self::new_with_variant(MaterialButtonVariant::Filled, text)
145    }
146
147    /// Create an outlined Material Design button with medium emphasis
148    ///
149    /// Outlined buttons are medium-emphasis buttons. They contain actions
150    /// that are important but aren't the primary action in an app.
151    ///
152    /// ## Material Design Spec  
153    /// - Background: Transparent
154    /// - Text: Primary color
155    /// - Outline: 1dp primary color
156    /// - Corner radius: 20dp
157    pub fn outlined(text: impl Into<WidgetText>) -> Self {
158        Self::new_with_variant(MaterialButtonVariant::Outlined, text)
159    }
160
161    /// Create a text Material Design button with low emphasis
162    ///
163    /// Text buttons are used for the least important actions in a UI.
164    /// They're often used for secondary actions.
165    ///
166    /// ## Material Design Spec
167    /// - Background: Transparent  
168    /// - Text: Primary color
169    /// - No outline or elevation
170    /// - Corner radius: 20dp
171    pub fn text(text: impl Into<WidgetText>) -> Self {
172        Self::new_with_variant(MaterialButtonVariant::Text, text)
173    }
174
175    /// Create an elevated Material Design button with medium emphasis
176    ///
177    /// Elevated buttons are essentially filled buttons with a shadow.
178    /// Use them to add separation between button and background.
179    ///
180    /// ## Material Design Spec
181    /// - Background: Surface color
182    /// - Text: Primary color
183    /// - Elevation: 1dp shadow
184    /// - Corner radius: 20dp  
185    pub fn elevated(text: impl Into<WidgetText>) -> Self {
186        Self::new_with_variant(MaterialButtonVariant::Elevated, text).elevation(Shadow {
187            offset: [0, 2],
188            blur: 6,
189            spread: 0,
190            color: Color32::from_rgba_unmultiplied(0, 0, 0, 30),
191        })
192    }
193
194    /// Create a filled tonal Material Design button with medium emphasis
195    ///
196    /// Filled tonal buttons are used to convey a secondary action that is
197    /// still important, but not the primary action.
198    ///
199    /// ## Material Design Spec
200    /// - Background: Secondary container color
201    /// - Text: On-secondary-container color
202    /// - Elevation: 0dp (no shadow)
203    /// - Corner radius: 20dp
204    pub fn filled_tonal(text: impl Into<WidgetText>) -> Self {
205        Self::new_with_variant(MaterialButtonVariant::FilledTonal, text)
206    }
207
208    /// Internal constructor that creates a button with the specified variant and text
209    fn new_with_variant(variant: MaterialButtonVariant, text: impl Into<WidgetText>) -> Self {
210        Self::opt_image_and_text_with_variant(variant, None, Some(text.into()))
211    }
212
213    pub fn new(text: impl Into<WidgetText>) -> Self {
214        Self::filled(text)
215    }
216
217    /// Creates a button with an image. The size of the image as displayed is defined by the provided size.
218    #[allow(clippy::needless_pass_by_value)]
219    pub fn image(image: impl Into<Image<'a>>) -> Self {
220        Self::opt_image_and_text(Some(image.into()), None)
221    }
222
223    /// 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.
224    #[allow(clippy::needless_pass_by_value)]
225    pub fn image_and_text(image: impl Into<Image<'a>>, text: impl Into<WidgetText>) -> Self {
226        Self::opt_image_and_text(Some(image.into()), Some(text.into()))
227    }
228
229    /// Creates a button with an image. The size of the image as displayed is defined by the provided size.
230    ///
231    /// Use this when you need both or either an image and text, or when text might be None.
232    ///
233    /// ## Parameters
234    /// - `image`: Optional icon/image to display
235    /// - `text`: Optional text content
236    pub fn opt_image_and_text(image: Option<Image<'a>>, text: Option<WidgetText>) -> Self {
237        Self::opt_image_and_text_with_variant(MaterialButtonVariant::Filled, image, text)
238    }
239
240    /// Create a Material Design button with specific variant and optional image and text
241    ///
242    /// This is the most flexible constructor allowing full control over button content.
243    ///
244    /// ## Parameters
245    /// - `variant`: The Material Design button variant to use
246    /// - `image`: Optional icon/image to display  
247    /// - `text`: Optional text content
248    pub fn opt_image_and_text_with_variant(
249        variant: MaterialButtonVariant,
250        image: Option<Image<'a>>,
251        text: Option<WidgetText>,
252    ) -> Self {
253        Self {
254            variant,
255            text,
256            image,
257            shortcut_text: Default::default(),
258            wrap_mode: None,
259            fill: None,
260            stroke: None,
261            sense: Sense::click(),
262            small: false,
263            frame: None,
264            min_size: Vec2::ZERO,
265            corner_radius: None,
266            selected: false,
267            image_tint_follows_text_color: false,
268            elevation: None,
269            disabled: false,
270            leading_icon: None,
271            trailing_icon: None,
272            leading_svg: None,
273            trailing_svg: None,
274            text_color: None,
275        }
276    }
277
278    /// Set the wrap mode for the text.
279    ///
280    /// By default, [`egui::Ui::wrap_mode`] will be used, which can be overridden with [`egui::Style::wrap_mode`].
281    ///
282    /// Note that any `\n` in the text will always produce a new line.
283    #[inline]
284    pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
285        self.wrap_mode = Some(wrap_mode);
286        self
287    }
288
289    /// Set [`Self::wrap_mode`] to [`TextWrapMode::Wrap`].
290    #[inline]
291    pub fn wrap(mut self) -> Self {
292        self.wrap_mode = Some(TextWrapMode::Wrap);
293
294        self
295    }
296
297    /// Set [`Self::wrap_mode`] to [`TextWrapMode::Truncate`].
298    #[inline]
299    pub fn truncate(mut self) -> Self {
300        self.wrap_mode = Some(TextWrapMode::Truncate);
301        self
302    }
303
304    /// Override background fill color. Note that this will override any on-hover effects.
305    /// Calling this will also turn on the frame.
306    #[inline]
307    pub fn fill(mut self, fill: impl Into<Color32>) -> Self {
308        self.fill = Some(fill.into());
309        self.frame = Some(true);
310        self
311    }
312
313    /// Override button stroke. Note that this will override any on-hover effects.
314    /// Calling this will also turn on the frame.
315    #[inline]
316    pub fn stroke(mut self, stroke: impl Into<Stroke>) -> Self {
317        self.stroke = Some(stroke.into());
318        self.frame = Some(true);
319        self
320    }
321
322    /// Make this a small button, suitable for embedding into text.
323    #[inline]
324    pub fn small(mut self) -> Self {
325        if let Some(text) = self.text {
326            self.text = Some(text.text_style(TextStyle::Body));
327        }
328        self.small = true;
329        self
330    }
331
332    /// Turn off the frame
333    #[inline]
334    pub fn frame(mut self, frame: bool) -> Self {
335        self.frame = Some(frame);
336        self
337    }
338
339    /// By default, buttons senses clicks.
340    /// Change this to a drag-button with `Sense::drag()`.
341    #[inline]
342    pub fn sense(mut self, sense: Sense) -> Self {
343        self.sense = sense;
344        self
345    }
346
347    /// Set the minimum size of the button.
348    #[inline]
349    pub fn min_size(mut self, min_size: Vec2) -> Self {
350        self.min_size = min_size;
351        self
352    }
353
354    /// Set the rounding of the button.
355    #[inline]
356    pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
357        self.corner_radius = Some(corner_radius.into());
358        self
359    }
360
361    #[inline]
362    #[deprecated = "Renamed to `corner_radius`"]
363    pub fn rounding(self, corner_radius: impl Into<CornerRadius>) -> Self {
364        self.corner_radius(corner_radius)
365    }
366
367    /// If true, the tint of the image is multiplied by the widget text color.
368    ///
369    /// This makes sense for images that are white, that should have the same color as the text color.
370    /// This will also make the icon color depend on hover state.
371    ///
372    /// Default: `false`.
373    #[inline]
374    pub fn image_tint_follows_text_color(mut self, image_tint_follows_text_color: bool) -> Self {
375        self.image_tint_follows_text_color = image_tint_follows_text_color;
376        self
377    }
378
379    /// Show some text on the right side of the button, in weak color.
380    ///
381    /// Designed for menu buttons, for setting a keyboard shortcut text (e.g. `Ctrl+S`).
382    ///
383    /// The text can be created with [`egui::Context::format_shortcut`].
384    #[inline]
385    pub fn shortcut_text(mut self, shortcut_text: impl Into<WidgetText>) -> Self {
386        self.shortcut_text = shortcut_text.into();
387        self
388    }
389
390    /// If `true`, mark this button as "selected".
391    #[inline]
392    pub fn selected(mut self, selected: bool) -> Self {
393        self.selected = selected;
394        self
395    }
396
397    /// Enable or disable the button.
398    #[inline]
399    pub fn enabled(mut self, enabled: bool) -> Self {
400        self.disabled = !enabled;
401        self
402    }
403
404    /// Set the elevation shadow for the button.
405    #[inline]
406    pub fn elevation(mut self, elevation: Shadow) -> Self {
407        self.elevation = Some(elevation);
408        self
409    }
410
411    /// Add a leading icon to the button (rendered before the text).
412    ///
413    /// Uses Material Symbols icon font. Pass the icon name (e.g., "upload", "search").
414    #[inline]
415    pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
416        self.leading_icon = Some(icon.into());
417        self
418    }
419
420    /// Add a trailing icon to the button (rendered after the text).
421    ///
422    /// Uses Material Symbols icon font. Pass the icon name (e.g., "arrow_forward", "open_in_new").
423    #[inline]
424    pub fn trailing_icon(mut self, icon: impl Into<String>) -> Self {
425        self.trailing_icon = Some(icon.into());
426        self
427    }
428
429    /// Add a leading SVG icon to the button (rendered before the text).
430    ///
431    /// Takes SVG data as a string. This takes precedence over `leading_icon`.
432    #[inline]
433    pub fn leading_svg(mut self, svg_data: impl Into<String>) -> Self {
434        self.leading_svg = Some(svg_data.into());
435        self
436    }
437
438    /// Add a trailing SVG icon to the button (rendered after the text).
439    ///
440    /// Takes SVG data as a string. This takes precedence over `trailing_icon`.
441    #[inline]
442    pub fn trailing_svg(mut self, svg_data: impl Into<String>) -> Self {
443        self.trailing_svg = Some(svg_data.into());
444        self
445    }
446
447    /// Override the text color for this button.
448    ///
449    /// When set, overrides the variant-based text color.
450    /// Icon colors also follow this override.
451    #[inline]
452    pub fn text_color(mut self, color: Color32) -> Self {
453        self.text_color = Some(color);
454        self
455    }
456}
457
458impl Widget for MaterialButton<'_> {
459    fn ui(self, ui: &mut Ui) -> Response {
460        let MaterialButton {
461            variant,
462            text,
463            image,
464            shortcut_text,
465            wrap_mode,
466            fill,
467            stroke,
468            sense,
469            small,
470            frame,
471            min_size,
472            corner_radius,
473            selected,
474            image_tint_follows_text_color,
475            elevation,
476            disabled,
477            leading_icon,
478            trailing_icon,
479            leading_svg,
480            trailing_svg,
481            text_color: custom_text_color,
482        } = self;
483
484        // Material Design color palette from theme
485        let md_primary = get_global_color("primary");
486        let _md_surface_tint = get_global_color("surfaceTint");
487        let _md_on_primary = get_global_color("onPrimary");
488        let _md_primary_container = get_global_color("primaryContainer");
489        let _md_on_primary_container = get_global_color("onPrimaryContainer");
490        let _md_secondary = get_global_color("secondary");
491        let _md_on_secondary = get_global_color("onSecondary");
492        let _md_secondary_container = get_global_color("secondaryContainer");
493        let _md_on_secondary_container = get_global_color("onSecondaryContainer");
494        let _md_tertiary = get_global_color("tertiary");
495        let _md_on_tertiary = get_global_color("onTertiary");
496        let _md_tertiary_container = get_global_color("tertiaryContainer");
497        let _md_on_tertiary_container = get_global_color("onTertiaryContainer");
498        let _md_error = get_global_color("error");
499        let _md_on_error = get_global_color("onError");
500        let _md_error_container = get_global_color("errorContainer");
501        let _md_on_error_container = get_global_color("onErrorContainer");
502        let md_background = get_global_color("background");
503        let md_on_background = get_global_color("onBackground");
504        let md_surface = get_global_color("surface");
505        let md_on_surface = get_global_color("onSurface");
506        let md_surface_variant = get_global_color("surfaceVariant");
507        let _md_on_surface_variant = get_global_color("onSurfaceVariant");
508        let md_outline = get_global_color("outline");
509        let _md_outline_variant = get_global_color("outlineVariant");
510        let _md_shadow = get_global_color("shadow");
511        let _md_scrim = get_global_color("scrim");
512        let _md_inverse_surface = get_global_color("inverseSurface");
513        let _md_inverse_on_surface = get_global_color("inverseOnSurface");
514        let _md_inverse_primary = get_global_color("inversePrimary");
515        let _md_primary_fixed = get_global_color("primaryFixed");
516        let _md_on_primary_fixed = get_global_color("onPrimaryFixed");
517        let _md_primary_fixed_dim = get_global_color("primaryFixedDim");
518        let _md_on_primary_fixed_variant = get_global_color("onPrimaryFixedVariant");
519        let _md_secondary_fixed = get_global_color("secondaryFixed");
520        let _md_on_secondary_fixed = get_global_color("onSecondaryFixed");
521        let _md_secondary_fixed_dim = get_global_color("secondaryFixedDim");
522        let _md_on_secondary_fixed_variant = get_global_color("onSecondaryFixedVariant");
523        let _md_tertiary_fixed = get_global_color("tertiaryFixed");
524        let _md_on_tertiary_fixed = get_global_color("onTertiaryFixed");
525        let _md_tertiary_fixed_dim = get_global_color("tertiaryFixedDim");
526        let _md_on_tertiary_fixed_variant = get_global_color("onTertiaryFixedVariant");
527        let _md_surface_dim = get_global_color("surfaceDim");
528        let _md_surface_bright = get_global_color("surfaceBright");
529        let _md_surface_container_lowest = get_global_color("surfaceContainerLowest");
530        let _md_surface_container_low = get_global_color("surfaceContainerLow");
531        let _md_surface_container = get_global_color("surfaceContainer");
532        let _md_surface_container_high = get_global_color("surfaceContainerHigh");
533        let _md_surface_container_highest = get_global_color("surfaceContainerHighest");
534
535        // Material Design button defaults based on variant
536        let (default_fill, default_stroke, default_corner_radius, _has_elevation) = match variant {
537            MaterialButtonVariant::Filled => (
538                Some(md_primary),
539                Some(Stroke::NONE),
540                CornerRadius::from(20),
541                false,
542            ),
543            MaterialButtonVariant::Outlined => (
544                Some(Color32::TRANSPARENT),
545                Some(Stroke::new(1.0, md_outline)),
546                CornerRadius::from(20),
547                false,
548            ),
549            MaterialButtonVariant::Text => (
550                Some(Color32::TRANSPARENT),
551                Some(Stroke::NONE),
552                CornerRadius::from(20),
553                false,
554            ),
555            MaterialButtonVariant::Elevated => (
556                Some(md_surface),
557                Some(Stroke::NONE),
558                CornerRadius::from(20),
559                true,
560            ),
561            MaterialButtonVariant::FilledTonal => (
562                Some(md_surface_variant),
563                Some(Stroke::NONE),
564                CornerRadius::from(20),
565                false,
566            ),
567        };
568
569        let frame = frame.unwrap_or_else(|| match variant {
570            MaterialButtonVariant::Text => false,
571            _ => true,
572        });
573
574        // Load SVG textures early if provided (takes precedence over font icons)
575        let leading_svg_texture = leading_svg.and_then(|svg_data| {
576            crate::image_utils::create_texture_from_svg(ui.ctx(), &svg_data, &format!("btn_lead_{}", svg_data.len())).ok()
577        });
578        let trailing_svg_texture = trailing_svg.and_then(|svg_data| {
579            crate::image_utils::create_texture_from_svg(ui.ctx(), &svg_data, &format!("btn_trail_{}", svg_data.len())).ok()
580        });
581
582        // Build icon galleys early (only if no SVG provided)
583        let leading_icon_galley = if leading_svg_texture.is_none() {
584            leading_icon.map(|name| {
585                let icon_str: WidgetText = material_symbol_text(&name).into();
586                icon_str.into_galley(ui, Some(TextWrapMode::Extend), f32::INFINITY, TextStyle::Body)
587            })
588        } else {
589            None
590        };
591        let trailing_icon_galley = if trailing_svg_texture.is_none() {
592            trailing_icon.map(|name| {
593                let icon_str: WidgetText = material_symbol_text(&name).into();
594                icon_str.into_galley(ui, Some(TextWrapMode::Extend), f32::INFINITY, TextStyle::Body)
595            })
596        } else {
597            None
598        };
599
600        // Material Design button padding
601        // With leading icon: 16px left, 24px right
602        // With trailing icon: 24px left, 16px right
603        // With both icons: 16px left, 16px right
604        // No icons: 24px left, 24px right
605        // For small buttons: 4px (with icon) or 6px (without icon)
606        let has_leading = leading_icon_galley.is_some() || leading_svg_texture.is_some() || image.is_some();
607        let has_trailing = trailing_icon_galley.is_some() || trailing_svg_texture.is_some();
608        let padding_multiplier = if small { 0.25 } else { 1.0 };
609        let padding_left = if has_leading { 16.0 } else { 24.0 } * padding_multiplier;
610        let padding_right = if has_trailing { 16.0 } else { 24.0 } * padding_multiplier;
611        let button_padding_left;
612        let button_padding_right;
613        let button_padding_y;
614        if frame || variant == MaterialButtonVariant::Text {
615            button_padding_left = padding_left;
616            button_padding_right = padding_right;
617            button_padding_y = if small { 4.0 } else { 10.0 };
618        } else {
619            button_padding_left = 0.0;
620            button_padding_right = 0.0;
621            button_padding_y = 0.0;
622        }
623
624        // Material Design minimum button height
625        let min_button_height = if small { 32.0 } else { 40.0 };
626        let icon_spacing = if small { 4.0 } else { 8.0 }; // Material Design icon-to-text gap
627        let svg_icon_size = 18.0; // Size for SVG icons
628
629        // Resolve the variant-based text color (used for text and icons)
630        let resolved_text_color = if disabled {
631            md_background.gamma_multiply(0.38)
632        } else if let Some(custom) = custom_text_color {
633            custom
634        } else {
635            match variant {
636                MaterialButtonVariant::Filled => md_background,
637                MaterialButtonVariant::Outlined => md_on_background,
638                MaterialButtonVariant::Text => md_on_background,
639                MaterialButtonVariant::Elevated => md_on_background,
640                MaterialButtonVariant::FilledTonal => get_global_color("onSecondaryContainer"),
641            }
642        };
643
644        let space_available_for_image = if let Some(_text) = &text {
645            let font_height = ui.text_style_height(&TextStyle::Body);
646            Vec2::splat(font_height)
647        } else {
648            let total_h_padding = button_padding_left + button_padding_right;
649            ui.available_size() - Vec2::new(total_h_padding, 2.0 * button_padding_y)
650        };
651
652        let image_size = if let Some(image) = &image {
653            image
654                .load_and_calc_size(ui, space_available_for_image)
655                .unwrap_or(space_available_for_image)
656        } else {
657            Vec2::ZERO
658        };
659
660        let gap_before_shortcut_text = ui.spacing().item_spacing.x;
661
662        let mut text_wrap_width = ui.available_width() - button_padding_left - button_padding_right;
663        if image.is_some() {
664            text_wrap_width -= image_size.x + icon_spacing;
665        }
666        if leading_icon_galley.is_some() {
667            text_wrap_width -= leading_icon_galley.as_ref().unwrap().size().x + icon_spacing;
668        }
669        if leading_svg_texture.is_some() {
670            text_wrap_width -= svg_icon_size + icon_spacing;
671        }
672        if trailing_icon_galley.is_some() {
673            text_wrap_width -= trailing_icon_galley.as_ref().unwrap().size().x + icon_spacing;
674        }
675        if trailing_svg_texture.is_some() {
676            text_wrap_width -= svg_icon_size + icon_spacing;
677        }
678
679        // Note: we don't wrap the shortcut text
680        let shortcut_galley = (!shortcut_text.is_empty()).then(|| {
681            shortcut_text.into_galley(
682                ui,
683                Some(TextWrapMode::Extend),
684                f32::INFINITY,
685                TextStyle::Body,
686            )
687        });
688
689        if let Some(shortcut_galley) = &shortcut_galley {
690            text_wrap_width -= gap_before_shortcut_text + shortcut_galley.size().x;
691        }
692
693        let galley =
694            text.map(|text| text.into_galley(ui, wrap_mode, text_wrap_width, TextStyle::Body));
695
696        let mut desired_size = Vec2::ZERO;
697
698        // Leading icon (font or SVG)
699        if let Some(lg) = &leading_icon_galley {
700            desired_size.x += lg.size().x;
701            desired_size.y = desired_size.y.max(lg.size().y);
702        }
703        if leading_svg_texture.is_some() {
704            desired_size.x += svg_icon_size;
705            desired_size.y = desired_size.y.max(svg_icon_size);
706        }
707
708        // Image
709        if image.is_some() {
710            if leading_icon_galley.is_some() || leading_svg_texture.is_some() {
711                desired_size.x += icon_spacing;
712            }
713            desired_size.x += image_size.x;
714            desired_size.y = desired_size.y.max(image_size.y);
715        }
716
717        // Gap between leading content and text
718        if (leading_icon_galley.is_some() || leading_svg_texture.is_some() || image.is_some()) && galley.is_some() {
719            desired_size.x += icon_spacing;
720        }
721
722        if let Some(galley) = &galley {
723            desired_size.x += galley.size().x;
724            desired_size.y = desired_size.y.max(galley.size().y);
725        }
726
727        // Trailing icon (font or SVG)
728        if let Some(tg) = &trailing_icon_galley {
729            if galley.is_some() || image.is_some() || leading_icon_galley.is_some() || leading_svg_texture.is_some() {
730                desired_size.x += icon_spacing;
731            }
732            desired_size.x += tg.size().x;
733            desired_size.y = desired_size.y.max(tg.size().y);
734        }
735        if trailing_svg_texture.is_some() {
736            if galley.is_some() || image.is_some() || leading_icon_galley.is_some() || leading_svg_texture.is_some() {
737                desired_size.x += icon_spacing;
738            }
739            desired_size.x += svg_icon_size;
740            desired_size.y = desired_size.y.max(svg_icon_size);
741        }
742
743        if let Some(shortcut_galley) = &shortcut_galley {
744            desired_size.x += gap_before_shortcut_text + shortcut_galley.size().x;
745            desired_size.y = desired_size.y.max(shortcut_galley.size().y);
746        }
747
748        desired_size.x += button_padding_left + button_padding_right;
749        desired_size.y += 2.0 * button_padding_y;
750        if !small {
751            desired_size.y = desired_size.y.at_least(min_button_height);
752        }
753        desired_size = desired_size.at_least(min_size);
754
755        let (rect, response) = ui.allocate_at_least(desired_size, sense);
756        response.widget_info(|| {
757            if let Some(galley) = &galley {
758                WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), galley.text())
759            } else {
760                WidgetInfo::new(WidgetType::Button)
761            }
762        });
763
764        if ui.is_rect_visible(rect) {
765            let visuals = ui.style().interact(&response);
766
767            let (frame_expansion, _frame_cr, frame_fill, frame_stroke) = if selected {
768                let selection = ui.visuals().selection;
769                (
770                    Vec2::ZERO,
771                    CornerRadius::ZERO,
772                    selection.bg_fill,
773                    selection.stroke,
774                )
775            } else if frame {
776                let expansion = Vec2::splat(visuals.expansion);
777                (
778                    expansion,
779                    visuals.corner_radius,
780                    visuals.weak_bg_fill,
781                    visuals.bg_stroke,
782                )
783            } else {
784                Default::default()
785            };
786            let frame_cr = corner_radius.unwrap_or(default_corner_radius);
787            let mut frame_fill = fill.unwrap_or(default_fill.unwrap_or(frame_fill));
788            let mut frame_stroke = stroke.unwrap_or(default_stroke.unwrap_or(frame_stroke));
789
790            // Apply disabled styling - Material Design spec
791            if disabled {
792                let surface_color = get_global_color("surface");
793                frame_fill = surface_color;
794                frame_stroke.color = md_on_surface.gamma_multiply(0.12);
795                frame_stroke.width = if matches!(variant, MaterialButtonVariant::Outlined) {
796                    1.0
797                } else {
798                    0.0
799                };
800            }
801
802            // Material Design state layers (hover/press overlays)
803            if !disabled {
804                let state_layer_color = resolved_text_color;
805                if response.is_pointer_button_down_on() {
806                    // Pressed: 12% overlay
807                    frame_fill = blend_overlay(frame_fill, state_layer_color, 0.12);
808                } else if response.hovered() {
809                    // Hovered: 8% overlay
810                    frame_fill = blend_overlay(frame_fill, state_layer_color, 0.08);
811                }
812            }
813
814            // Draw elevation shadow if present
815            if let Some(shadow) = &elevation {
816                // Hover elevation boost for elevated buttons
817                let shadow = if !disabled && response.hovered() {
818                    Shadow {
819                        offset: [shadow.offset[0], shadow.offset[1] + 2],
820                        blur: shadow.blur + 4,
821                        spread: shadow.spread,
822                        color: shadow.color,
823                    }
824                } else {
825                    *shadow
826                };
827                let shadow_offset = Vec2::new(shadow.offset[0] as f32, shadow.offset[1] as f32);
828                let shadow_rect = rect.expand2(frame_expansion).translate(shadow_offset);
829                ui.painter()
830                    .rect_filled(shadow_rect, frame_cr, shadow.color);
831            }
832
833            ui.painter().rect(
834                rect.expand2(frame_expansion),
835                frame_cr,
836                frame_fill,
837                frame_stroke,
838                egui::epaint::StrokeKind::Outside,
839            );
840
841            let mut cursor_x = rect.min.x + button_padding_left;
842            let content_rect_y_min = rect.min.y + button_padding_y;
843            let content_rect_y_max = rect.max.y - button_padding_y;
844            let content_height = content_rect_y_max - content_rect_y_min;
845
846            // Draw leading icon (font icon)
847            if let Some(leading_galley) = &leading_icon_galley {
848                let icon_y =
849                    content_rect_y_min + (content_height - leading_galley.size().y) / 2.0;
850                let icon_pos = egui::pos2(cursor_x, icon_y);
851                ui.painter()
852                    .galley(icon_pos, leading_galley.clone(), resolved_text_color);
853                cursor_x += leading_galley.size().x + icon_spacing;
854            }
855
856            // Draw leading icon (SVG texture)
857            if let Some(texture) = &leading_svg_texture {
858                let icon_y = content_rect_y_min + (content_height - svg_icon_size) / 2.0;
859                let icon_rect = Rect::from_min_size(
860                    egui::pos2(cursor_x, icon_y),
861                    Vec2::splat(svg_icon_size),
862                );
863                ui.painter().image(
864                    texture.id(),
865                    icon_rect,
866                    Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
867                    Color32::WHITE, // Use WHITE to preserve original SVG colors (e.g., emoji)
868                );
869                cursor_x += svg_icon_size;
870                // Add spacing only if there's content after the icon
871                if image.is_some() || galley.is_some() || trailing_icon_galley.is_some() || trailing_svg_texture.is_some() || shortcut_galley.is_some() {
872                    cursor_x += icon_spacing;
873                }
874            }
875
876            // Draw image
877            if let Some(image) = &image {
878                let mut image_pos = ui
879                    .layout()
880                    .align_size_within_rect(
881                        image_size,
882                        Rect::from_min_max(
883                            egui::pos2(cursor_x, content_rect_y_min),
884                            egui::pos2(rect.max.x - button_padding_right, content_rect_y_max),
885                        ),
886                    )
887                    .min;
888                if galley.is_some() || shortcut_galley.is_some() || trailing_icon_galley.is_some() {
889                    image_pos.x = cursor_x;
890                }
891                let image_rect = Rect::from_min_size(image_pos, image_size);
892                cursor_x += image_size.x + icon_spacing;
893                let mut image_widget = image.clone();
894                if image_tint_follows_text_color {
895                    image_widget = image_widget.tint(visuals.text_color());
896                }
897                image_widget.paint_at(ui, image_rect);
898            }
899
900            // Draw main text
901            let has_text = galley.is_some();
902            if let Some(galley) = galley {
903                let text_y = content_rect_y_min + (content_height - galley.size().y) / 2.0 + if small { 1.0 } else { 0.0 };
904                let mut text_pos = egui::pos2(cursor_x, text_y);
905                // Center text if no leading/trailing elements
906                if leading_icon_galley.is_none()
907                    && leading_svg_texture.is_none()
908                    && image.is_none()
909                    && trailing_icon_galley.is_none()
910                    && trailing_svg_texture.is_none()
911                    && shortcut_galley.is_none()
912                {
913                    text_pos = ui
914                        .layout()
915                        .align_size_within_rect(
916                            galley.size(),
917                            Rect::from_min_max(
918                                egui::pos2(
919                                    rect.min.x + button_padding_left,
920                                    content_rect_y_min,
921                                ),
922                                egui::pos2(
923                                    rect.max.x - button_padding_right,
924                                    content_rect_y_max,
925                                ),
926                            ),
927                        )
928                        .min;
929                }
930
931                cursor_x = text_pos.x + galley.size().x;
932                ui.painter().galley(text_pos, galley, resolved_text_color);
933            }
934
935            // Draw trailing icon (font icon)
936            if let Some(trailing_galley) = &trailing_icon_galley {
937                cursor_x += icon_spacing;
938                let icon_y =
939                    content_rect_y_min + (content_height - trailing_galley.size().y) / 2.0;
940                let icon_pos = egui::pos2(cursor_x, icon_y);
941                ui.painter()
942                    .galley(icon_pos, trailing_galley.clone(), resolved_text_color);
943            }
944
945            // Draw trailing icon (SVG texture)
946            if let Some(texture) = &trailing_svg_texture {
947                // Add spacing before the icon if there's content before it
948                if has_text || image.is_some() || leading_icon_galley.is_some() || leading_svg_texture.is_some() {
949                    cursor_x += icon_spacing;
950                }
951                let icon_y = content_rect_y_min + (content_height - svg_icon_size) / 2.0;
952                let icon_rect = Rect::from_min_size(
953                    egui::pos2(cursor_x, icon_y),
954                    Vec2::splat(svg_icon_size),
955                );
956                ui.painter().image(
957                    texture.id(),
958                    icon_rect,
959                    Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
960                    Color32::WHITE, // Use WHITE to preserve original SVG colors (e.g., emoji)
961                );
962            }
963
964            // Draw shortcut text
965            if let Some(shortcut_galley) = shortcut_galley {
966                let layout = if ui.layout().is_horizontal() {
967                    ui.layout().with_main_align(Align::Max)
968                } else {
969                    ui.layout().with_cross_align(Align::Max)
970                };
971                let shortcut_text_pos = layout
972                    .align_size_within_rect(
973                        shortcut_galley.size(),
974                        Rect::from_min_max(
975                            egui::pos2(rect.min.x + button_padding_left, content_rect_y_min),
976                            egui::pos2(rect.max.x - button_padding_right, content_rect_y_max),
977                        ),
978                    )
979                    .min;
980                ui.painter().galley(
981                    shortcut_text_pos,
982                    shortcut_galley,
983                    ui.visuals().weak_text_color(),
984                );
985            }
986        }
987
988        if let Some(cursor) = ui.visuals().interact_cursor {
989            if response.hovered() {
990                ui.ctx().set_cursor_icon(cursor);
991            }
992        }
993
994        response
995    }
996}
997
998/// Blend an overlay color on top of a base color with given opacity.
999fn blend_overlay(base: Color32, overlay: Color32, opacity: f32) -> Color32 {
1000    let alpha = (opacity * 255.0) as u8;
1001    let overlay_with_alpha = Color32::from_rgba_unmultiplied(overlay.r(), overlay.g(), overlay.b(), alpha);
1002    // Simple alpha blending
1003    let inv_alpha = 255 - alpha;
1004    Color32::from_rgba_unmultiplied(
1005        ((base.r() as u16 * inv_alpha as u16 + overlay_with_alpha.r() as u16 * alpha as u16) / 255) as u8,
1006        ((base.g() as u16 * inv_alpha as u16 + overlay_with_alpha.g() as u16 * alpha as u16) / 255) as u8,
1007        ((base.b() as u16 * inv_alpha as u16 + overlay_with_alpha.b() as u16 * alpha as u16) / 255) as u8,
1008        base.a(),
1009    )
1010}