use crate::{get_global_color, material_symbol::material_symbol_text};
use egui::{
ecolor::Color32,
emath::NumExt,
epaint::{CornerRadius, Shadow, Stroke},
Align, Image, Rect, Response, Sense, TextStyle, TextWrapMode, Ui, Vec2, Widget, WidgetInfo,
WidgetText, WidgetType,
};
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum MaterialButtonVariant {
Filled,
Outlined,
Text,
Elevated,
FilledTonal,
}
#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
pub struct MaterialButton<'a> {
image: Option<Image<'a>>,
text: Option<WidgetText>,
shortcut_text: WidgetText,
wrap_mode: Option<TextWrapMode>,
variant: MaterialButtonVariant,
fill: Option<Color32>,
stroke: Option<Stroke>,
sense: Sense,
small: bool,
frame: Option<bool>,
min_size: Vec2,
corner_radius: Option<CornerRadius>,
selected: bool,
image_tint_follows_text_color: bool,
elevation: Option<Shadow>,
disabled: bool,
leading_icon: Option<String>,
trailing_icon: Option<String>,
leading_svg: Option<String>,
trailing_svg: Option<String>,
text_color: Option<Color32>,
}
impl<'a> MaterialButton<'a> {
pub fn filled(text: impl Into<WidgetText>) -> Self {
Self::new_with_variant(MaterialButtonVariant::Filled, text)
}
pub fn outlined(text: impl Into<WidgetText>) -> Self {
Self::new_with_variant(MaterialButtonVariant::Outlined, text)
}
pub fn text(text: impl Into<WidgetText>) -> Self {
Self::new_with_variant(MaterialButtonVariant::Text, text)
}
pub fn elevated(text: impl Into<WidgetText>) -> Self {
Self::new_with_variant(MaterialButtonVariant::Elevated, text).elevation(Shadow {
offset: [0, 2],
blur: 6,
spread: 0,
color: Color32::from_rgba_unmultiplied(0, 0, 0, 30),
})
}
pub fn filled_tonal(text: impl Into<WidgetText>) -> Self {
Self::new_with_variant(MaterialButtonVariant::FilledTonal, text)
}
fn new_with_variant(variant: MaterialButtonVariant, text: impl Into<WidgetText>) -> Self {
Self::opt_image_and_text_with_variant(variant, None, Some(text.into()))
}
pub fn new(text: impl Into<WidgetText>) -> Self {
Self::filled(text)
}
#[allow(clippy::needless_pass_by_value)]
pub fn image(image: impl Into<Image<'a>>) -> Self {
Self::opt_image_and_text(Some(image.into()), None)
}
#[allow(clippy::needless_pass_by_value)]
pub fn image_and_text(image: impl Into<Image<'a>>, text: impl Into<WidgetText>) -> Self {
Self::opt_image_and_text(Some(image.into()), Some(text.into()))
}
pub fn opt_image_and_text(image: Option<Image<'a>>, text: Option<WidgetText>) -> Self {
Self::opt_image_and_text_with_variant(MaterialButtonVariant::Filled, image, text)
}
pub fn opt_image_and_text_with_variant(
variant: MaterialButtonVariant,
image: Option<Image<'a>>,
text: Option<WidgetText>,
) -> Self {
Self {
variant,
text,
image,
shortcut_text: Default::default(),
wrap_mode: None,
fill: None,
stroke: None,
sense: Sense::click(),
small: false,
frame: None,
min_size: Vec2::ZERO,
corner_radius: None,
selected: false,
image_tint_follows_text_color: false,
elevation: None,
disabled: false,
leading_icon: None,
trailing_icon: None,
leading_svg: None,
trailing_svg: None,
text_color: None,
}
}
#[inline]
pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
self.wrap_mode = Some(wrap_mode);
self
}
#[inline]
pub fn wrap(mut self) -> Self {
self.wrap_mode = Some(TextWrapMode::Wrap);
self
}
#[inline]
pub fn truncate(mut self) -> Self {
self.wrap_mode = Some(TextWrapMode::Truncate);
self
}
#[inline]
pub fn fill(mut self, fill: impl Into<Color32>) -> Self {
self.fill = Some(fill.into());
self.frame = Some(true);
self
}
#[inline]
pub fn stroke(mut self, stroke: impl Into<Stroke>) -> Self {
self.stroke = Some(stroke.into());
self.frame = Some(true);
self
}
#[inline]
pub fn small(mut self) -> Self {
if let Some(text) = self.text {
self.text = Some(text.text_style(TextStyle::Body));
}
self.small = true;
self
}
#[inline]
pub fn frame(mut self, frame: bool) -> Self {
self.frame = Some(frame);
self
}
#[inline]
pub fn sense(mut self, sense: Sense) -> Self {
self.sense = sense;
self
}
#[inline]
pub fn min_size(mut self, min_size: Vec2) -> Self {
self.min_size = min_size;
self
}
#[inline]
pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
self.corner_radius = Some(corner_radius.into());
self
}
#[inline]
#[deprecated = "Renamed to `corner_radius`"]
pub fn rounding(self, corner_radius: impl Into<CornerRadius>) -> Self {
self.corner_radius(corner_radius)
}
#[inline]
pub fn image_tint_follows_text_color(mut self, image_tint_follows_text_color: bool) -> Self {
self.image_tint_follows_text_color = image_tint_follows_text_color;
self
}
#[inline]
pub fn shortcut_text(mut self, shortcut_text: impl Into<WidgetText>) -> Self {
self.shortcut_text = shortcut_text.into();
self
}
#[inline]
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
#[inline]
pub fn enabled(mut self, enabled: bool) -> Self {
self.disabled = !enabled;
self
}
#[inline]
pub fn elevation(mut self, elevation: Shadow) -> Self {
self.elevation = Some(elevation);
self
}
#[inline]
pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
self.leading_icon = Some(icon.into());
self
}
#[inline]
pub fn trailing_icon(mut self, icon: impl Into<String>) -> Self {
self.trailing_icon = Some(icon.into());
self
}
#[inline]
pub fn leading_svg(mut self, svg_data: impl Into<String>) -> Self {
self.leading_svg = Some(svg_data.into());
self
}
#[inline]
pub fn trailing_svg(mut self, svg_data: impl Into<String>) -> Self {
self.trailing_svg = Some(svg_data.into());
self
}
#[inline]
pub fn text_color(mut self, color: Color32) -> Self {
self.text_color = Some(color);
self
}
}
impl Widget for MaterialButton<'_> {
fn ui(self, ui: &mut Ui) -> Response {
let MaterialButton {
variant,
text,
image,
shortcut_text,
wrap_mode,
fill,
stroke,
sense,
small,
frame,
min_size,
corner_radius,
selected,
image_tint_follows_text_color,
elevation,
disabled,
leading_icon,
trailing_icon,
leading_svg,
trailing_svg,
text_color: custom_text_color,
} = self;
let primary = get_global_color("primary"); let on_primary = get_global_color("onPrimary"); let secondary_container = get_global_color("secondaryContainer"); let on_secondary_container = get_global_color("onSecondaryContainer"); let surface = get_global_color("surface"); let on_surface = get_global_color("onSurface"); let outline = get_global_color("outline");
let (default_fill, default_stroke, default_corner_radius, _has_elevation) = match variant {
MaterialButtonVariant::Filled => (
Some(primary), Some(Stroke::NONE),
CornerRadius::from(20),
false,
),
MaterialButtonVariant::Outlined => (
Some(Color32::TRANSPARENT), Some(Stroke::new(1.0, outline)), CornerRadius::from(20),
false,
),
MaterialButtonVariant::Text => (
Some(Color32::TRANSPARENT), Some(Stroke::NONE), CornerRadius::from(20),
false,
),
MaterialButtonVariant::Elevated => (
Some(surface), Some(Stroke::NONE),
CornerRadius::from(20),
true,
),
MaterialButtonVariant::FilledTonal => (
Some(secondary_container), Some(Stroke::NONE),
CornerRadius::from(20),
false,
),
};
let frame = frame.unwrap_or(!matches!(variant, MaterialButtonVariant::Text));
let leading_svg_texture = leading_svg.and_then(|svg_data| {
crate::image_utils::create_texture_from_svg(ui.ctx(), &svg_data, &format!("btn_lead_{}", svg_data.len())).ok()
});
let trailing_svg_texture = trailing_svg.and_then(|svg_data| {
crate::image_utils::create_texture_from_svg(ui.ctx(), &svg_data, &format!("btn_trail_{}", svg_data.len())).ok()
});
let leading_icon_galley = if leading_svg_texture.is_none() {
leading_icon.map(|name| {
let icon_str: WidgetText = material_symbol_text(&name).into();
icon_str.into_galley(ui, Some(TextWrapMode::Extend), f32::INFINITY, TextStyle::Body)
})
} else {
None
};
let trailing_icon_galley = if trailing_svg_texture.is_none() {
trailing_icon.map(|name| {
let icon_str: WidgetText = material_symbol_text(&name).into();
icon_str.into_galley(ui, Some(TextWrapMode::Extend), f32::INFINITY, TextStyle::Body)
})
} else {
None
};
let has_leading = leading_icon_galley.is_some() || leading_svg_texture.is_some() || image.is_some();
let has_trailing = trailing_icon_galley.is_some() || trailing_svg_texture.is_some();
let padding_multiplier = if small { 0.25 } else { 1.0 };
let padding_left = if has_leading { 16.0 } else { 24.0 } * padding_multiplier;
let padding_right = if has_trailing { 16.0 } else { 24.0 } * padding_multiplier;
let button_padding_left;
let button_padding_right;
let button_padding_y;
if frame || variant == MaterialButtonVariant::Text {
button_padding_left = padding_left;
button_padding_right = padding_right;
button_padding_y = if small { 4.0 } else { 10.0 };
} else {
button_padding_left = 0.0;
button_padding_right = 0.0;
button_padding_y = 0.0;
}
let min_button_height = if small { 32.0 } else { 40.0 };
let icon_spacing = if small { 4.0 } else { 8.0 }; let svg_icon_size = 18.0;
let resolved_text_color = if disabled {
on_surface.linear_multiply(0.38)
} else if let Some(custom) = custom_text_color {
custom
} else {
match variant {
MaterialButtonVariant::Filled => on_primary, MaterialButtonVariant::Outlined => on_surface, MaterialButtonVariant::Text => on_surface, MaterialButtonVariant::Elevated => on_surface, MaterialButtonVariant::FilledTonal => on_secondary_container, }
};
let space_available_for_image = if let Some(_text) = &text {
let font_height = ui.text_style_height(&TextStyle::Body);
Vec2::splat(font_height)
} else {
let total_h_padding = button_padding_left + button_padding_right;
ui.available_size() - Vec2::new(total_h_padding, 2.0 * button_padding_y)
};
let image_size = if let Some(image) = &image {
image
.load_and_calc_size(ui, space_available_for_image)
.unwrap_or(space_available_for_image)
} else {
Vec2::ZERO
};
let gap_before_shortcut_text = ui.spacing().item_spacing.x;
let mut text_wrap_width = ui.available_width() - button_padding_left - button_padding_right;
if image.is_some() {
text_wrap_width -= image_size.x + icon_spacing;
}
if let Some(galley) = &leading_icon_galley {
text_wrap_width -= galley.size().x + icon_spacing;
}
if leading_svg_texture.is_some() {
text_wrap_width -= svg_icon_size + icon_spacing;
}
if let Some(galley) = &trailing_icon_galley {
text_wrap_width -= galley.size().x + icon_spacing;
}
if trailing_svg_texture.is_some() {
text_wrap_width -= svg_icon_size + icon_spacing;
}
let shortcut_galley = (!shortcut_text.is_empty()).then(|| {
shortcut_text.into_galley(
ui,
Some(TextWrapMode::Extend),
f32::INFINITY,
TextStyle::Body,
)
});
if let Some(shortcut_galley) = &shortcut_galley {
text_wrap_width -= gap_before_shortcut_text + shortcut_galley.size().x;
}
let galley =
text.map(|text| text.into_galley(ui, wrap_mode, text_wrap_width, TextStyle::Body));
let mut desired_size = Vec2::ZERO;
if let Some(lg) = &leading_icon_galley {
desired_size.x += lg.size().x;
desired_size.y = desired_size.y.max(lg.size().y);
}
if leading_svg_texture.is_some() {
desired_size.x += svg_icon_size;
desired_size.y = desired_size.y.max(svg_icon_size);
}
if image.is_some() {
if leading_icon_galley.is_some() || leading_svg_texture.is_some() {
desired_size.x += icon_spacing;
}
desired_size.x += image_size.x;
desired_size.y = desired_size.y.max(image_size.y);
}
if (leading_icon_galley.is_some() || leading_svg_texture.is_some() || image.is_some()) && galley.is_some() {
desired_size.x += icon_spacing;
}
if let Some(galley) = &galley {
desired_size.x += galley.size().x;
desired_size.y = desired_size.y.max(galley.size().y);
}
if let Some(tg) = &trailing_icon_galley {
if galley.is_some() || image.is_some() || leading_icon_galley.is_some() || leading_svg_texture.is_some() {
desired_size.x += icon_spacing;
}
desired_size.x += tg.size().x;
desired_size.y = desired_size.y.max(tg.size().y);
}
if trailing_svg_texture.is_some() {
if galley.is_some() || image.is_some() || leading_icon_galley.is_some() || leading_svg_texture.is_some() {
desired_size.x += icon_spacing;
}
desired_size.x += svg_icon_size;
desired_size.y = desired_size.y.max(svg_icon_size);
}
if let Some(shortcut_galley) = &shortcut_galley {
desired_size.x += gap_before_shortcut_text + shortcut_galley.size().x;
desired_size.y = desired_size.y.max(shortcut_galley.size().y);
}
desired_size.x += button_padding_left + button_padding_right;
desired_size.y += 2.0 * button_padding_y;
if !small {
desired_size.y = desired_size.y.at_least(min_button_height);
}
desired_size = desired_size.at_least(min_size);
let (rect, response) = ui.allocate_at_least(desired_size, sense);
response.widget_info(|| {
if let Some(galley) = &galley {
WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), galley.text())
} else {
WidgetInfo::new(WidgetType::Button)
}
});
if ui.is_rect_visible(rect) {
let visuals = ui.style().interact(&response);
let (frame_expansion, _frame_cr, frame_fill, frame_stroke) = if selected {
let selection = ui.visuals().selection;
(
Vec2::ZERO,
CornerRadius::ZERO,
selection.bg_fill,
selection.stroke,
)
} else if frame {
let expansion = Vec2::splat(visuals.expansion);
(
expansion,
visuals.corner_radius,
visuals.weak_bg_fill,
visuals.bg_stroke,
)
} else {
Default::default()
};
let frame_cr = corner_radius.unwrap_or(default_corner_radius);
let mut frame_fill = fill.unwrap_or(default_fill.unwrap_or(frame_fill));
let mut frame_stroke = stroke.unwrap_or(default_stroke.unwrap_or(frame_stroke));
if disabled {
frame_fill = surface; frame_stroke.color = on_surface.linear_multiply(0.12); frame_stroke.width = if matches!(variant, MaterialButtonVariant::Outlined) {
1.0 } else {
0.0 };
}
if !disabled {
let state_layer_color = resolved_text_color;
if response.is_pointer_button_down_on() {
frame_fill = blend_overlay(frame_fill, state_layer_color, 0.12);
} else if response.hovered() {
frame_fill = blend_overlay(frame_fill, state_layer_color, 0.08);
}
}
if let Some(shadow) = &elevation {
let shadow = if !disabled && response.hovered() {
Shadow {
offset: [shadow.offset[0], shadow.offset[1] + 2],
blur: shadow.blur + 4,
spread: shadow.spread,
color: shadow.color,
}
} else {
*shadow
};
let shadow_offset = Vec2::new(shadow.offset[0] as f32, shadow.offset[1] as f32);
let shadow_rect = rect.expand2(frame_expansion).translate(shadow_offset);
ui.painter()
.rect_filled(shadow_rect, frame_cr, shadow.color);
}
ui.painter().rect(
rect.expand2(frame_expansion),
frame_cr,
frame_fill,
frame_stroke,
egui::epaint::StrokeKind::Outside,
);
let mut cursor_x = rect.min.x + button_padding_left;
let content_rect_y_min = rect.min.y + button_padding_y;
let content_rect_y_max = rect.max.y - button_padding_y;
let content_height = content_rect_y_max - content_rect_y_min;
if let Some(leading_galley) = &leading_icon_galley {
let icon_y =
content_rect_y_min + (content_height - leading_galley.size().y) / 2.0;
let icon_pos = egui::pos2(cursor_x, icon_y);
ui.painter()
.galley(icon_pos, leading_galley.clone(), resolved_text_color);
cursor_x += leading_galley.size().x + icon_spacing;
}
if let Some(texture) = &leading_svg_texture {
let icon_y = content_rect_y_min + (content_height - svg_icon_size) / 2.0;
let icon_rect = Rect::from_min_size(
egui::pos2(cursor_x, icon_y),
Vec2::splat(svg_icon_size),
);
ui.painter().image(
texture.id(),
icon_rect,
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE, );
cursor_x += svg_icon_size;
if image.is_some() || galley.is_some() || trailing_icon_galley.is_some() || trailing_svg_texture.is_some() || shortcut_galley.is_some() {
cursor_x += icon_spacing;
}
}
if let Some(image) = &image {
let mut image_pos = ui
.layout()
.align_size_within_rect(
image_size,
Rect::from_min_max(
egui::pos2(cursor_x, content_rect_y_min),
egui::pos2(rect.max.x - button_padding_right, content_rect_y_max),
),
)
.min;
if galley.is_some() || shortcut_galley.is_some() || trailing_icon_galley.is_some() {
image_pos.x = cursor_x;
}
let image_rect = Rect::from_min_size(image_pos, image_size);
cursor_x += image_size.x + icon_spacing;
let mut image_widget = image.clone();
if image_tint_follows_text_color {
image_widget = image_widget.tint(visuals.text_color());
}
image_widget.paint_at(ui, image_rect);
}
let has_text = galley.is_some();
if let Some(galley) = galley {
let text_y = content_rect_y_min + (content_height - galley.size().y) / 2.0 + if small { 1.0 } else { 0.0 };
let mut text_pos = egui::pos2(cursor_x, text_y);
if leading_icon_galley.is_none()
&& leading_svg_texture.is_none()
&& image.is_none()
&& trailing_icon_galley.is_none()
&& trailing_svg_texture.is_none()
&& shortcut_galley.is_none()
{
text_pos = ui
.layout()
.align_size_within_rect(
galley.size(),
Rect::from_min_max(
egui::pos2(
rect.min.x + button_padding_left,
content_rect_y_min,
),
egui::pos2(
rect.max.x - button_padding_right,
content_rect_y_max,
),
),
)
.min;
}
cursor_x = text_pos.x + galley.size().x;
ui.painter().galley(text_pos, galley, resolved_text_color);
}
if let Some(trailing_galley) = &trailing_icon_galley {
cursor_x += icon_spacing;
let icon_y =
content_rect_y_min + (content_height - trailing_galley.size().y) / 2.0;
let icon_pos = egui::pos2(cursor_x, icon_y);
ui.painter()
.galley(icon_pos, trailing_galley.clone(), resolved_text_color);
}
if let Some(texture) = &trailing_svg_texture {
if has_text || image.is_some() || leading_icon_galley.is_some() || leading_svg_texture.is_some() {
cursor_x += icon_spacing;
}
let icon_y = content_rect_y_min + (content_height - svg_icon_size) / 2.0;
let icon_rect = Rect::from_min_size(
egui::pos2(cursor_x, icon_y),
Vec2::splat(svg_icon_size),
);
ui.painter().image(
texture.id(),
icon_rect,
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE, );
}
if let Some(shortcut_galley) = shortcut_galley {
let layout = if ui.layout().is_horizontal() {
ui.layout().with_main_align(Align::Max)
} else {
ui.layout().with_cross_align(Align::Max)
};
let shortcut_text_pos = layout
.align_size_within_rect(
shortcut_galley.size(),
Rect::from_min_max(
egui::pos2(rect.min.x + button_padding_left, content_rect_y_min),
egui::pos2(rect.max.x - button_padding_right, content_rect_y_max),
),
)
.min;
ui.painter().galley(
shortcut_text_pos,
shortcut_galley,
ui.visuals().weak_text_color(),
);
}
}
if let Some(cursor) = ui.visuals().interact_cursor {
if response.hovered() {
ui.ctx().set_cursor_icon(cursor);
}
}
response
}
}
fn blend_overlay(base: Color32, overlay: Color32, opacity: f32) -> Color32 {
let alpha = (opacity * 255.0) as u8;
let overlay_with_alpha = Color32::from_rgba_unmultiplied(overlay.r(), overlay.g(), overlay.b(), alpha);
let inv_alpha = 255 - alpha;
Color32::from_rgba_unmultiplied(
((base.r() as u16 * inv_alpha as u16 + overlay_with_alpha.r() as u16 * alpha as u16) / 255) as u8,
((base.g() as u16 * inv_alpha as u16 + overlay_with_alpha.g() as u16 * alpha as u16) / 255) as u8,
((base.b() as u16 * inv_alpha as u16 + overlay_with_alpha.b() as u16 * alpha as u16) / 255) as u8,
base.a(),
)
}