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}