egui_material3/
button.rs

1use egui::{
2    emath::NumExt, 
3    ecolor::Color32, 
4    epaint::{Stroke, Shadow, CornerRadius},
5    Align, Image, Rect, Response, Sense, TextStyle,
6    TextWrapMode, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetType,
7};
8use crate::get_global_color;
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}
121
122impl<'a> MaterialButton<'a> {
123    /// Create a filled Material Design button with high emphasis
124    /// 
125    /// Filled buttons have the most visual impact and should be used for
126    /// the primary action in a set of buttons.
127    /// 
128    /// ## Material Design Spec
129    /// - Background: Primary color
130    /// - Text: On-primary color  
131    /// - Elevation: 0dp (no shadow)
132    /// - Corner radius: 20dp
133    pub fn filled(text: impl Into<WidgetText>) -> Self {
134        Self::new_with_variant(MaterialButtonVariant::Filled, text)
135    }
136    
137    /// Create an outlined Material Design button with medium emphasis
138    /// 
139    /// Outlined buttons are medium-emphasis buttons. They contain actions
140    /// that are important but aren't the primary action in an app.
141    /// 
142    /// ## Material Design Spec  
143    /// - Background: Transparent
144    /// - Text: Primary color
145    /// - Outline: 1dp primary color
146    /// - Corner radius: 20dp
147    pub fn outlined(text: impl Into<WidgetText>) -> Self {
148        Self::new_with_variant(MaterialButtonVariant::Outlined, text)
149    }
150    
151    /// Create a text Material Design button with low emphasis
152    /// 
153    /// Text buttons are used for the least important actions in a UI.
154    /// They're often used for secondary actions.
155    /// 
156    /// ## Material Design Spec
157    /// - Background: Transparent  
158    /// - Text: Primary color
159    /// - No outline or elevation
160    /// - Corner radius: 20dp
161    pub fn text(text: impl Into<WidgetText>) -> Self {
162        Self::new_with_variant(MaterialButtonVariant::Text, text)
163    }
164    
165    /// Create an elevated Material Design button with medium emphasis
166    /// 
167    /// Elevated buttons are essentially filled buttons with a shadow.
168    /// Use them to add separation between button and background.
169    /// 
170    /// ## Material Design Spec
171    /// - Background: Surface color
172    /// - Text: Primary color
173    /// - Elevation: 1dp shadow
174    /// - Corner radius: 20dp  
175    pub fn elevated(text: impl Into<WidgetText>) -> Self {
176        Self::new_with_variant(MaterialButtonVariant::Elevated, text)
177            .elevation(Shadow {
178                offset: [0, 2],
179                blur: 6,
180                spread: 0,
181                color: Color32::from_rgba_unmultiplied(0, 0, 0, 30),
182            })
183    }
184    
185    /// Create a filled tonal Material Design button with medium emphasis
186    /// 
187    /// Filled tonal buttons are used to convey a secondary action that is
188    /// still important, but not the primary action.
189    /// 
190    /// ## Material Design Spec
191    /// - Background: Secondary container color
192    /// - Text: On-secondary-container color
193    /// - Elevation: 0dp (no shadow)
194    /// - Corner radius: 20dp
195    pub fn filled_tonal(text: impl Into<WidgetText>) -> Self {
196        Self::new_with_variant(MaterialButtonVariant::FilledTonal, text)
197    }
198    
199    /// Internal constructor that creates a button with the specified variant and text
200    fn new_with_variant(variant: MaterialButtonVariant, text: impl Into<WidgetText>) -> Self {
201        Self::opt_image_and_text_with_variant(variant, None, Some(text.into()))
202    }
203    
204    pub fn new(text: impl Into<WidgetText>) -> Self {
205        Self::filled(text)
206    }
207
208    /// Creates a button with an image. The size of the image as displayed is defined by the provided size.
209    #[allow(clippy::needless_pass_by_value)]
210    pub fn image(image: impl Into<Image<'a>>) -> Self {
211        Self::opt_image_and_text(Some(image.into()), None)
212    }
213
214    /// 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.
215    #[allow(clippy::needless_pass_by_value)]
216    pub fn image_and_text(image: impl Into<Image<'a>>, text: impl Into<WidgetText>) -> Self {
217        Self::opt_image_and_text(Some(image.into()), Some(text.into()))
218    }
219
220    /// Creates a button with an image. The size of the image as displayed is defined by the provided size.
221    /// 
222    /// Use this when you need both or either an image and text, or when text might be None.
223    /// 
224    /// ## Parameters
225    /// - `image`: Optional icon/image to display
226    /// - `text`: Optional text content
227    pub fn opt_image_and_text(image: Option<Image<'a>>, text: Option<WidgetText>) -> Self {
228        Self::opt_image_and_text_with_variant(MaterialButtonVariant::Filled, image, text)
229    }
230    
231    /// Create a Material Design button with specific variant and optional image and text
232    /// 
233    /// This is the most flexible constructor allowing full control over button content.
234    /// 
235    /// ## Parameters
236    /// - `variant`: The Material Design button variant to use
237    /// - `image`: Optional icon/image to display  
238    /// - `text`: Optional text content
239    pub fn opt_image_and_text_with_variant(variant: MaterialButtonVariant, image: Option<Image<'a>>, text: Option<WidgetText>) -> Self {
240        Self {
241            variant,
242            text,
243            image,
244            shortcut_text: Default::default(),
245            wrap_mode: None,
246            fill: None,
247            stroke: None,
248            sense: Sense::click(),
249            small: false,
250            frame: None,
251            min_size: Vec2::ZERO,
252            corner_radius: None,
253            selected: false,
254            image_tint_follows_text_color: false,
255            elevation: None,
256            disabled: false,
257        }
258    }
259
260    /// Set the wrap mode for the text.
261    ///
262    /// By default, [`egui::Ui::wrap_mode`] will be used, which can be overridden with [`egui::Style::wrap_mode`].
263    ///
264    /// Note that any `\n` in the text will always produce a new line.
265    #[inline]
266    pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
267        self.wrap_mode = Some(wrap_mode);
268        self
269    }
270
271    /// Set [`Self::wrap_mode`] to [`TextWrapMode::Wrap`].
272    #[inline]
273    pub fn wrap(mut self) -> Self {
274        self.wrap_mode = Some(TextWrapMode::Wrap);
275
276        self
277    }
278
279    /// Set [`Self::wrap_mode`] to [`TextWrapMode::Truncate`].
280    #[inline]
281    pub fn truncate(mut self) -> Self {
282        self.wrap_mode = Some(TextWrapMode::Truncate);
283        self
284    }
285
286    /// Override background fill color. Note that this will override any on-hover effects.
287    /// Calling this will also turn on the frame.
288    #[inline]
289    pub fn fill(mut self, fill: impl Into<Color32>) -> Self {
290        self.fill = Some(fill.into());
291        self.frame = Some(true);
292        self
293    }
294
295    /// Override button stroke. Note that this will override any on-hover effects.
296    /// Calling this will also turn on the frame.
297    #[inline]
298    pub fn stroke(mut self, stroke: impl Into<Stroke>) -> Self {
299        self.stroke = Some(stroke.into());
300        self.frame = Some(true);
301        self
302    }
303
304    /// Make this a small button, suitable for embedding into text.
305    #[inline]
306    pub fn small(mut self) -> Self {
307        if let Some(text) = self.text {
308            self.text = Some(text.text_style(TextStyle::Body));
309        }
310        self.small = true;
311        self
312    }
313
314    /// Turn off the frame
315    #[inline]
316    pub fn frame(mut self, frame: bool) -> Self {
317        self.frame = Some(frame);
318        self
319    }
320
321    /// By default, buttons senses clicks.
322    /// Change this to a drag-button with `Sense::drag()`.
323    #[inline]
324    pub fn sense(mut self, sense: Sense) -> Self {
325        self.sense = sense;
326        self
327    }
328
329    /// Set the minimum size of the button.
330    #[inline]
331    pub fn min_size(mut self, min_size: Vec2) -> Self {
332        self.min_size = min_size;
333        self
334    }
335
336    /// Set the rounding of the button.
337    #[inline]
338    pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
339        self.corner_radius = Some(corner_radius.into());
340        self
341    }
342
343    #[inline]
344    #[deprecated = "Renamed to `corner_radius`"]
345    pub fn rounding(self, corner_radius: impl Into<CornerRadius>) -> Self {
346        self.corner_radius(corner_radius)
347    }
348
349    /// If true, the tint of the image is multiplied by the widget text color.
350    ///
351    /// This makes sense for images that are white, that should have the same color as the text color.
352    /// This will also make the icon color depend on hover state.
353    ///
354    /// Default: `false`.
355    #[inline]
356    pub fn image_tint_follows_text_color(mut self, image_tint_follows_text_color: bool) -> Self {
357        self.image_tint_follows_text_color = image_tint_follows_text_color;
358        self
359    }
360
361    /// Show some text on the right side of the button, in weak color.
362    ///
363    /// Designed for menu buttons, for setting a keyboard shortcut text (e.g. `Ctrl+S`).
364    ///
365    /// The text can be created with [`egui::Context::format_shortcut`].
366    #[inline]
367    pub fn shortcut_text(mut self, shortcut_text: impl Into<WidgetText>) -> Self {
368        self.shortcut_text = shortcut_text.into();
369        self
370    }
371
372    /// If `true`, mark this button as "selected".
373    #[inline]
374    pub fn selected(mut self, selected: bool) -> Self {
375        self.selected = selected;
376        self
377    }
378
379    /// Enable or disable the button.
380    #[inline]
381    pub fn enabled(mut self, enabled: bool) -> Self {
382        self.disabled = !enabled;
383        self
384    }
385
386    /// Set the elevation shadow for the button.
387    #[inline]
388    pub fn elevation(mut self, elevation: Shadow) -> Self {
389        self.elevation = Some(elevation);
390        self
391    }
392
393    /// Add a leading icon to the button.
394    #[inline]
395    pub fn leading_icon(self, _icon: impl Into<String>) -> Self {
396        // For now, this is a placeholder that returns self unchanged
397        // In a real implementation, you'd store the icon and render it
398        self
399    }
400
401    /// Add a trailing icon to the button.
402    #[inline]
403    pub fn trailing_icon(self, _icon: impl Into<String>) -> Self {
404        // For now, this is a placeholder that returns self unchanged
405        // In a real implementation, you'd store the icon and render it
406        self
407    }
408}
409
410impl Widget for MaterialButton<'_> {
411    fn ui(self, ui: &mut Ui) -> Response {
412        let MaterialButton {
413            variant,
414            text,
415            image,
416            shortcut_text,
417            wrap_mode,
418            fill,
419            stroke,
420            sense,
421            small,
422            frame,
423            min_size,
424            corner_radius,
425            selected,
426            image_tint_follows_text_color,
427            elevation,
428            disabled,
429        } = self;
430
431        // Material Design color palette from theme
432        let md_primary = get_global_color("primary");
433        let md_on_primary = get_global_color("onPrimary");
434        let md_surface = get_global_color("surface");
435        let _md_on_surface = get_global_color("onSurface"); // Prefix with _ to silence warning
436        let md_outline = get_global_color("outline");
437        let md_surface_variant = get_global_color("surfaceVariant");
438        
439        // Material Design button defaults based on variant
440        let (default_fill, default_stroke, default_corner_radius, _has_elevation) = match variant {
441            MaterialButtonVariant::Filled => (
442                Some(md_primary),
443                Some(Stroke::NONE),
444                CornerRadius::from(20),
445                false
446            ),
447            MaterialButtonVariant::Outlined => (
448                Some(Color32::TRANSPARENT),
449                Some(Stroke::new(1.0, md_outline)),
450                CornerRadius::from(20),
451                false
452            ),
453            MaterialButtonVariant::Text => (
454                Some(Color32::TRANSPARENT),
455                Some(Stroke::NONE),
456                CornerRadius::from(20),
457                false
458            ),
459            MaterialButtonVariant::Elevated => (
460                Some(md_surface),
461                Some(Stroke::NONE),
462                CornerRadius::from(20),
463                true
464            ),
465            MaterialButtonVariant::FilledTonal => (
466                Some(md_surface_variant),
467                Some(Stroke::NONE),
468                CornerRadius::from(20),
469                false
470            ),
471        };
472
473        let frame = frame.unwrap_or_else(|| match variant {
474            MaterialButtonVariant::Text => false,
475            _ => true,
476        });
477
478        // Material Design button padding (24px left/right, calculated based on height)
479        let button_padding = if frame {
480            Vec2::new(24.0, if small { 0.0 } else { 10.0 })
481        } else if variant == MaterialButtonVariant::Text {
482            // Text buttons still need horizontal padding for consistent width
483            Vec2::new(24.0, if small { 0.0 } else { 10.0 })
484        } else {
485            Vec2::ZERO
486        };
487        
488        // Material Design minimum button height
489        let min_button_height = if small { 32.0 } else { 40.0 };
490
491        let space_available_for_image = if let Some(_text) = &text {
492            let font_height = ui.text_style_height(&TextStyle::Body);
493            Vec2::splat(font_height) // Reasonable?
494        } else {
495            ui.available_size() - 2.0 * button_padding
496        };
497
498        let image_size = if let Some(image) = &image {
499            image
500                .load_and_calc_size(ui, space_available_for_image)
501                .unwrap_or(space_available_for_image)
502        } else {
503            Vec2::ZERO
504        };
505
506        let gap_before_shortcut_text = ui.spacing().item_spacing.x;
507
508        let mut text_wrap_width = ui.available_width() - 2.0 * button_padding.x;
509        if image.is_some() {
510            text_wrap_width -= image_size.x + ui.spacing().icon_spacing;
511        }
512
513        // Note: we don't wrap the shortcut text
514        let shortcut_galley = (!shortcut_text.is_empty()).then(|| {
515            shortcut_text.into_galley(
516                ui,
517                Some(TextWrapMode::Extend),
518                f32::INFINITY,
519                TextStyle::Body,
520            )
521        });
522
523        if let Some(shortcut_galley) = &shortcut_galley {
524            // Leave space for the shortcut text:
525            text_wrap_width -= gap_before_shortcut_text + shortcut_galley.size().x;
526        }
527
528        let galley =
529            text.map(|text| text.into_galley(ui, wrap_mode, text_wrap_width, TextStyle::Body));
530
531        let mut desired_size = Vec2::ZERO;
532        if image.is_some() {
533            desired_size.x += image_size.x;
534            desired_size.y = desired_size.y.max(image_size.y);
535        }
536        if image.is_some() && galley.is_some() {
537            desired_size.x += ui.spacing().icon_spacing;
538        }
539        if let Some(galley) = &galley {
540            desired_size.x += galley.size().x;
541            desired_size.y = desired_size.y.max(galley.size().y);
542        }
543        if let Some(shortcut_galley) = &shortcut_galley {
544            desired_size.x += gap_before_shortcut_text + shortcut_galley.size().x;
545            desired_size.y = desired_size.y.max(shortcut_galley.size().y);
546        }
547        desired_size += 2.0 * button_padding;
548        if !small {
549            desired_size.y = desired_size.y.at_least(min_button_height);
550        }
551        desired_size = desired_size.at_least(min_size);
552
553        let (rect, response) = ui.allocate_at_least(desired_size, sense);
554        response.widget_info(|| {
555            if let Some(galley) = &galley {
556                WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), galley.text())
557            } else {
558                WidgetInfo::new(WidgetType::Button)
559            }
560        });
561
562        if ui.is_rect_visible(rect) {
563            let visuals = ui.style().interact(&response);
564
565            let (frame_expansion, _frame_cr, frame_fill, frame_stroke) = if selected {
566                let selection = ui.visuals().selection;
567                (
568                    Vec2::ZERO,
569                    CornerRadius::ZERO,
570                    selection.bg_fill,
571                    selection.stroke,
572                )
573            } else if frame {
574                let expansion = Vec2::splat(visuals.expansion);
575                (
576                    expansion,
577                    visuals.corner_radius,
578                    visuals.weak_bg_fill,
579                    visuals.bg_stroke,
580                )
581            } else {
582                Default::default()
583            };
584            let frame_cr = corner_radius.unwrap_or(default_corner_radius);
585            let mut frame_fill = fill.unwrap_or(default_fill.unwrap_or(frame_fill));
586            let mut frame_stroke = stroke.unwrap_or(default_stroke.unwrap_or(frame_stroke));
587            
588            // Apply disabled styling - Material Design spec
589            if disabled {
590                // Disabled buttons have 12% opacity on surface color
591                let surface_color = get_global_color("surface");
592                let _disabled_overlay = get_global_color("onSurface").gamma_multiply(0.12);
593                frame_fill = surface_color; // Use surface as base
594                frame_stroke.color = get_global_color("onSurface").gamma_multiply(0.12);
595                frame_stroke.width = if matches!(variant, MaterialButtonVariant::Outlined) { 1.0 } else { 0.0 };
596            }
597            
598            // Draw elevation shadow if present
599            if let Some(shadow) = elevation {
600                let shadow_offset = Vec2::new(shadow.offset[0] as f32, shadow.offset[1] as f32);
601                let shadow_rect = rect.expand2(frame_expansion).translate(shadow_offset);
602                ui.painter().rect_filled(
603                    shadow_rect,
604                    frame_cr,
605                    shadow.color,
606                );
607            }
608            
609            ui.painter().rect(
610                rect.expand2(frame_expansion),
611                frame_cr,
612                frame_fill,
613                frame_stroke,
614                egui::epaint::StrokeKind::Outside,
615            );
616
617            let mut cursor_x = rect.min.x + button_padding.x;
618
619            if let Some(image) = &image {
620                let mut image_pos = ui
621                    .layout()
622                    .align_size_within_rect(image_size, rect.shrink2(button_padding))
623                    .min;
624                if galley.is_some() || shortcut_galley.is_some() {
625                    image_pos.x = cursor_x;
626                }
627                let image_rect = Rect::from_min_size(image_pos, image_size);
628                cursor_x += image_size.x;
629                let mut image_widget = image.clone();
630                if image_tint_follows_text_color {
631                    image_widget = image_widget.tint(visuals.text_color());
632                }
633                image_widget.paint_at(ui, image_rect);
634            }
635
636            if image.is_some() && galley.is_some() {
637                cursor_x += ui.spacing().icon_spacing;
638            }
639
640            if let Some(galley) = galley {
641                let mut text_pos = ui
642                    .layout()
643                    .align_size_within_rect(galley.size(), rect.shrink2(button_padding))
644                    .min;
645                if image.is_some() || shortcut_galley.is_some() {
646                    text_pos.x = cursor_x;
647                }
648                
649                // Material Design text colors based on button variant
650                let text_color = if disabled {
651                    // Disabled text has 38% opacity of onSurface
652                    get_global_color("onSurface").gamma_multiply(0.38)
653                } else {
654                    match variant {
655                        MaterialButtonVariant::Filled => md_on_primary,
656                        MaterialButtonVariant::Outlined => md_primary,
657                        MaterialButtonVariant::Text => md_primary,
658                        MaterialButtonVariant::Elevated => md_primary,
659                        MaterialButtonVariant::FilledTonal => get_global_color("onSecondaryContainer"),
660                    }
661                };
662                
663                ui.painter().galley(text_pos, galley, text_color);
664            }
665
666            if let Some(shortcut_galley) = shortcut_galley {
667                // Always align to the right
668                let layout = if ui.layout().is_horizontal() {
669                    ui.layout().with_main_align(Align::Max)
670                } else {
671                    ui.layout().with_cross_align(Align::Max)
672                };
673                let shortcut_text_pos = layout
674                    .align_size_within_rect(shortcut_galley.size(), rect.shrink2(button_padding))
675                    .min;
676                ui.painter().galley(
677                    shortcut_text_pos,
678                    shortcut_galley,
679                    ui.visuals().weak_text_color(),
680                );
681            }
682        }
683
684        if let Some(cursor) = ui.visuals().interact_cursor {
685            if response.hovered() {
686                ui.ctx().set_cursor_icon(cursor);
687            }
688        }
689
690        response
691    }
692}