use bevy::prelude::*;
use bevy::ui::{BoxShadow, Val};
use crate::{
elevation::Elevation,
ripple::RippleHost,
theme::{blend_state_layer, MaterialTheme},
tokens::{CornerRadius, Spacing},
};
pub struct ButtonPlugin;
impl Plugin for ButtonPlugin {
fn build(&self, app: &mut App) {
if !app.is_plugin_added::<crate::MaterialUiCorePlugin>() {
app.add_plugins(crate::MaterialUiCorePlugin);
}
app.add_message::<ButtonClickEvent>().add_systems(
Update,
(
button_interaction_system,
button_style_system,
button_label_style_system,
button_theme_refresh_system,
button_shadow_system,
),
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum ButtonVariant {
Elevated,
#[default]
Filled,
FilledTonal,
Outlined,
Text,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum IconGravity {
#[default]
Start,
TextStart,
End,
TextEnd,
Top,
TextTop,
}
#[derive(Component, Clone)]
pub struct MaterialButton {
pub variant: ButtonVariant,
pub disabled: bool,
pub label: String,
pub icon: Option<String>,
pub trailing_icon: Option<String>,
pub icon_gravity: IconGravity,
pub icon_padding: f32,
pub icon_size: f32,
pub corner_radius: Option<f32>,
pub min_width: Option<f32>,
pub min_height: Option<f32>,
pub custom_background_color: Option<Color>,
pub custom_text_color: Option<Color>,
pub stroke_width: f32,
pub stroke_color: Option<Color>,
pub checkable: bool,
pub checked: bool,
pub pressed: bool,
pub hovered: bool,
pub focused: bool,
}
impl MaterialButton {
pub fn new(label: impl Into<String>) -> Self {
Self {
variant: ButtonVariant::default(),
disabled: false,
label: label.into(),
icon: None,
trailing_icon: None,
icon_gravity: IconGravity::default(),
icon_padding: 8.0,
icon_size: 18.0,
corner_radius: None,
min_width: None,
min_height: None,
custom_background_color: None,
custom_text_color: None,
stroke_width: 1.0,
stroke_color: None,
checkable: false,
checked: false,
pressed: false,
hovered: false,
focused: false,
}
}
pub fn with_variant(mut self, variant: ButtonVariant) -> Self {
self.variant = variant;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn with_trailing_icon(mut self, icon: impl Into<String>) -> Self {
self.trailing_icon = Some(icon.into());
self
}
pub fn icon_gravity(mut self, gravity: IconGravity) -> Self {
self.icon_gravity = gravity;
self
}
pub fn icon_padding(mut self, padding: f32) -> Self {
self.icon_padding = padding;
self
}
pub fn icon_size(mut self, size: f32) -> Self {
self.icon_size = size;
self
}
pub fn corner_radius(mut self, radius: f32) -> Self {
self.corner_radius = Some(radius);
self
}
pub fn min_width(mut self, width: f32) -> Self {
self.min_width = Some(width);
self
}
pub fn min_height(mut self, height: f32) -> Self {
self.min_height = Some(height);
self
}
pub fn custom_background_color(mut self, color: Color) -> Self {
self.custom_background_color = Some(color);
self
}
pub fn custom_text_color(mut self, color: Color) -> Self {
self.custom_text_color = Some(color);
self
}
pub fn stroke_width(mut self, width: f32) -> Self {
self.stroke_width = width;
self
}
pub fn stroke_color(mut self, color: Color) -> Self {
self.stroke_color = Some(color);
self
}
pub fn checkable(mut self, checkable: bool) -> Self {
self.checkable = checkable;
self
}
pub fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
pub fn toggle(&mut self) {
if self.checkable {
self.checked = !self.checked;
}
}
pub fn effective_corner_radius(&self) -> f32 {
self.corner_radius.unwrap_or(CornerRadius::FULL)
}
pub fn background_color(&self, theme: &MaterialTheme) -> Color {
if self.disabled {
return theme.on_surface.with_alpha(0.12);
}
if let Some(custom_color) = self.custom_background_color {
return custom_color;
}
let state_opacity = self.state_layer_opacity();
match self.variant {
ButtonVariant::Elevated => {
blend_state_layer(theme.surface_container_low, theme.primary, state_opacity)
}
ButtonVariant::Filled => {
blend_state_layer(theme.primary, theme.on_primary, state_opacity)
}
ButtonVariant::FilledTonal => {
blend_state_layer(
theme.secondary_container,
theme.on_secondary_container,
state_opacity,
)
}
ButtonVariant::Outlined => {
if state_opacity > 0.0 {
theme.primary.with_alpha(state_opacity)
} else {
Color::NONE
}
}
ButtonVariant::Text => {
if state_opacity > 0.0 {
theme.primary.with_alpha(state_opacity)
} else {
Color::NONE
}
}
}
}
pub fn text_color(&self, theme: &MaterialTheme) -> Color {
if self.disabled {
return theme.on_surface.with_alpha(0.38);
}
if let Some(custom_color) = self.custom_text_color {
return custom_color;
}
match self.variant {
ButtonVariant::Elevated => theme.primary,
ButtonVariant::Filled => theme.on_primary,
ButtonVariant::FilledTonal => theme.on_secondary_container,
ButtonVariant::Outlined => theme.primary,
ButtonVariant::Text => theme.primary,
}
}
pub fn border_color(&self, theme: &MaterialTheme) -> Color {
if self.disabled {
return theme.on_surface.with_alpha(0.12);
}
match self.variant {
ButtonVariant::Outlined => theme.outline,
_ => Color::NONE,
}
}
pub fn elevation(&self) -> Elevation {
if self.disabled {
return Elevation::Level0;
}
match self.variant {
ButtonVariant::Elevated => {
if self.pressed {
Elevation::Level1
} else if self.hovered {
Elevation::Level2
} else {
Elevation::Level1
}
}
ButtonVariant::Filled | ButtonVariant::FilledTonal => {
if self.pressed || self.hovered {
Elevation::Level1
} else {
Elevation::Level0
}
}
_ => Elevation::Level0,
}
}
pub fn state_layer_opacity(&self) -> f32 {
if self.disabled {
0.0
} else if self.pressed {
0.12
} else if self.hovered {
0.08
} else {
0.0
}
}
}
#[derive(Event, bevy::prelude::Message)]
pub struct ButtonClickEvent {
pub entity: Entity,
}
fn button_interaction_system(
mut interaction_query: Query<
(Entity, &Interaction, &mut MaterialButton),
(Changed<Interaction>, With<MaterialButton>),
>,
mut click_events: MessageWriter<ButtonClickEvent>,
) {
for (entity, interaction, mut button) in interaction_query.iter_mut() {
if button.disabled {
continue;
}
match *interaction {
Interaction::Pressed => {
button.pressed = true;
button.hovered = false;
click_events.write(ButtonClickEvent { entity });
}
Interaction::Hovered => {
button.pressed = false;
button.hovered = true;
}
Interaction::None => {
button.pressed = false;
button.hovered = false;
}
}
}
}
fn button_style_system(
theme: Option<Res<MaterialTheme>>,
mut buttons: Query<
(&MaterialButton, &mut BackgroundColor, &mut BorderColor),
Changed<MaterialButton>,
>,
) {
let Some(theme) = theme else { return };
for (button, mut bg_color, mut border_color) in buttons.iter_mut() {
*bg_color = BackgroundColor(button.background_color(&theme));
*border_color = BorderColor::all(button.border_color(&theme));
}
}
fn button_label_style_system(
theme: Option<Res<MaterialTheme>>,
buttons: Query<(&MaterialButton, &Children), Changed<MaterialButton>>,
mut labels: Query<&mut TextColor, With<ButtonLabel>>,
) {
let Some(theme) = theme else { return };
for (button, children) in buttons.iter() {
let label_color = button.text_color(&theme);
for child in children.iter() {
if let Ok(mut color) = labels.get_mut(child) {
color.0 = label_color;
}
}
}
}
fn button_theme_refresh_system(
theme: Option<Res<MaterialTheme>>,
mut buttons: Query<(
&MaterialButton,
&Children,
&mut BackgroundColor,
&mut BorderColor,
)>,
mut labels: Query<&mut TextColor, With<ButtonLabel>>,
) {
let Some(theme) = theme else { return };
if !theme.is_changed() {
return;
}
for (button, children, mut bg_color, mut border_color) in buttons.iter_mut() {
*bg_color = BackgroundColor(button.background_color(&theme));
*border_color = BorderColor::all(button.border_color(&theme));
let label_color = button.text_color(&theme);
for child in children.iter() {
if let Ok(mut color) = labels.get_mut(child) {
color.0 = label_color;
}
}
}
}
fn button_shadow_system(
mut buttons: Query<(&MaterialButton, &mut BoxShadow), Changed<MaterialButton>>,
) {
for (button, mut box_shadow) in buttons.iter_mut() {
let elevation = button.elevation();
*box_shadow = elevation.to_box_shadow();
}
}
pub struct MaterialButtonBuilder {
button: MaterialButton,
}
impl MaterialButtonBuilder {
pub fn new(label: impl Into<String>) -> Self {
Self {
button: MaterialButton::new(label),
}
}
pub fn variant(mut self, variant: ButtonVariant) -> Self {
self.button.variant = variant;
self
}
pub fn elevated(self) -> Self {
self.variant(ButtonVariant::Elevated)
}
pub fn filled(self) -> Self {
self.variant(ButtonVariant::Filled)
}
pub fn filled_tonal(self) -> Self {
self.variant(ButtonVariant::FilledTonal)
}
pub fn outlined(self) -> Self {
self.variant(ButtonVariant::Outlined)
}
pub fn text(self) -> Self {
self.variant(ButtonVariant::Text)
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.button.disabled = disabled;
self
}
pub fn checkable(mut self, checkable: bool) -> Self {
self.button.checkable = checkable;
self
}
pub fn checked(mut self, checked: bool) -> Self {
self.button.checked = checked;
self
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.button.icon = Some(icon.into());
self
}
pub fn build(self, theme: &MaterialTheme) -> impl Bundle {
let bg_color = self.button.background_color(theme);
let border_color = self.button.border_color(theme);
let border_width = if self.button.variant == ButtonVariant::Outlined {
1.0
} else {
0.0
};
let elevation = self.button.elevation();
let corner_radius = self.button.effective_corner_radius();
(
self.button,
Button,
RippleHost::new(),
Node {
padding: UiRect::axes(Val::Px(Spacing::EXTRA_LARGE), Val::Px(Spacing::MEDIUM)),
border: UiRect::all(Val::Px(border_width)),
border_radius: BorderRadius::all(Val::Px(corner_radius)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(bg_color),
BorderColor::all(border_color),
elevation.to_box_shadow(),
)
}
pub fn build_without_shadow(self, theme: &MaterialTheme) -> impl Bundle {
let bg_color = self.button.background_color(theme);
let border_color = self.button.border_color(theme);
let border_width = if self.button.variant == ButtonVariant::Outlined {
1.0
} else {
0.0
};
let corner_radius = self.button.effective_corner_radius();
(
self.button,
Button,
RippleHost::new(),
Node {
padding: UiRect::axes(Val::Px(Spacing::EXTRA_LARGE), Val::Px(Spacing::MEDIUM)),
border: UiRect::all(Val::Px(border_width)),
border_radius: BorderRadius::all(Val::Px(corner_radius)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(bg_color),
BorderColor::all(border_color),
)
}
}
pub fn spawn_material_button(
commands: &mut Commands,
theme: &MaterialTheme,
label: impl Into<String>,
variant: ButtonVariant,
) -> Entity {
let label_text = label.into();
let builder = MaterialButtonBuilder::new(label_text.clone()).variant(variant);
let button = builder.button.clone();
let text_color = button.text_color(theme);
commands
.spawn(
MaterialButtonBuilder::new(label_text.clone())
.variant(variant)
.build(theme),
)
.with_children(|parent| {
parent.spawn((
Text::new(label_text),
TextColor(text_color),
TextFont {
font_size: 14.0,
..default()
},
));
})
.id()
}
pub fn material_button_bundle(
theme: &MaterialTheme,
label: impl Into<String>,
variant: ButtonVariant,
) -> impl Bundle {
MaterialButtonBuilder::new(label)
.variant(variant)
.build(theme)
}
#[derive(Component, Clone, Copy, Debug, Default)]
pub struct ButtonLabel;
pub trait SpawnButtonChild {
fn spawn_button(
&mut self,
theme: &MaterialTheme,
label: impl Into<String>,
variant: ButtonVariant,
);
fn spawn_filled_button(&mut self, theme: &MaterialTheme, label: impl Into<String>);
fn spawn_outlined_button(&mut self, theme: &MaterialTheme, label: impl Into<String>);
fn spawn_text_button(&mut self, theme: &MaterialTheme, label: impl Into<String>);
fn spawn_filled_tonal_button(&mut self, theme: &MaterialTheme, label: impl Into<String>);
fn spawn_elevated_button(&mut self, theme: &MaterialTheme, label: impl Into<String>);
fn spawn_button_with(&mut self, theme: &MaterialTheme, button: MaterialButton);
}
impl SpawnButtonChild for ChildSpawnerCommands<'_> {
fn spawn_button(
&mut self,
theme: &MaterialTheme,
label: impl Into<String>,
variant: ButtonVariant,
) {
let label_str = label.into();
let builder = MaterialButtonBuilder::new(label_str.clone()).variant(variant);
let text_color = builder.button.text_color(theme);
self.spawn(builder.build(theme)).with_children(|button| {
button.spawn((
ButtonLabel,
Text::new(label_str),
TextColor(text_color),
TextFont {
font_size: 14.0,
..default()
},
));
});
}
fn spawn_filled_button(&mut self, theme: &MaterialTheme, label: impl Into<String>) {
self.spawn_button(theme, label, ButtonVariant::Filled);
}
fn spawn_outlined_button(&mut self, theme: &MaterialTheme, label: impl Into<String>) {
self.spawn_button(theme, label, ButtonVariant::Outlined);
}
fn spawn_text_button(&mut self, theme: &MaterialTheme, label: impl Into<String>) {
self.spawn_button(theme, label, ButtonVariant::Text);
}
fn spawn_filled_tonal_button(&mut self, theme: &MaterialTheme, label: impl Into<String>) {
self.spawn_button(theme, label, ButtonVariant::FilledTonal);
}
fn spawn_elevated_button(&mut self, theme: &MaterialTheme, label: impl Into<String>) {
self.spawn_button(theme, label, ButtonVariant::Elevated);
}
fn spawn_button_with(&mut self, theme: &MaterialTheme, button: MaterialButton) {
let text_color = button.text_color(theme);
let label_str = button.label.clone();
let builder = MaterialButtonBuilder { button };
self.spawn(builder.build(theme)).with_children(|btn| {
btn.spawn((
ButtonLabel,
Text::new(label_str),
TextColor(text_color),
TextFont {
font_size: 14.0,
..default()
},
));
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_button_variant_default() {
assert_eq!(ButtonVariant::default(), ButtonVariant::Filled);
}
#[test]
fn test_button_variant_all_types() {
let variants = [
ButtonVariant::Elevated,
ButtonVariant::Filled,
ButtonVariant::FilledTonal,
ButtonVariant::Outlined,
ButtonVariant::Text,
];
for i in 0..variants.len() {
for j in (i + 1)..variants.len() {
assert_ne!(variants[i], variants[j]);
}
}
}
#[test]
fn test_icon_gravity_default() {
assert_eq!(IconGravity::default(), IconGravity::Start);
}
#[test]
fn test_icon_gravity_all_types() {
let gravities = [
IconGravity::Start,
IconGravity::TextStart,
IconGravity::End,
IconGravity::TextEnd,
IconGravity::Top,
IconGravity::TextTop,
];
for i in 0..gravities.len() {
for j in (i + 1)..gravities.len() {
assert_ne!(gravities[i], gravities[j]);
}
}
}
#[test]
fn test_button_new_defaults() {
let button = MaterialButton::new("Test");
assert_eq!(button.label, "Test");
assert_eq!(button.variant, ButtonVariant::Filled);
assert!(!button.disabled);
assert!(button.icon.is_none());
assert!(button.trailing_icon.is_none());
assert_eq!(button.icon_gravity, IconGravity::Start);
assert_eq!(button.icon_padding, 8.0);
assert_eq!(button.icon_size, 18.0);
assert!(button.corner_radius.is_none());
assert!(button.min_width.is_none());
assert!(button.min_height.is_none());
assert!(button.custom_background_color.is_none());
assert!(button.custom_text_color.is_none());
assert_eq!(button.stroke_width, 1.0);
assert!(button.stroke_color.is_none());
assert!(!button.checkable);
assert!(!button.checked);
assert!(!button.pressed);
assert!(!button.hovered);
assert!(!button.focused);
}
#[test]
fn test_button_with_variant() {
let button = MaterialButton::new("Test").with_variant(ButtonVariant::Outlined);
assert_eq!(button.variant, ButtonVariant::Outlined);
}
#[test]
fn test_button_disabled() {
let button = MaterialButton::new("Test").disabled(true);
assert!(button.disabled);
let button = MaterialButton::new("Test").disabled(false);
assert!(!button.disabled);
}
#[test]
fn test_button_with_icon() {
let button = MaterialButton::new("Test").with_icon("add");
assert_eq!(button.icon, Some("add".to_string()));
}
#[test]
fn test_button_with_trailing_icon() {
let button = MaterialButton::new("Test").with_trailing_icon("arrow_forward");
assert_eq!(button.trailing_icon, Some("arrow_forward".to_string()));
}
#[test]
fn test_button_icon_gravity() {
let button = MaterialButton::new("Test").icon_gravity(IconGravity::End);
assert_eq!(button.icon_gravity, IconGravity::End);
}
#[test]
fn test_button_icon_padding() {
let button = MaterialButton::new("Test").icon_padding(16.0);
assert_eq!(button.icon_padding, 16.0);
}
#[test]
fn test_button_icon_size() {
let button = MaterialButton::new("Test").icon_size(24.0);
assert_eq!(button.icon_size, 24.0);
}
#[test]
fn test_button_corner_radius() {
let button = MaterialButton::new("Test").corner_radius(8.0);
assert_eq!(button.corner_radius, Some(8.0));
}
#[test]
fn test_button_min_width() {
let button = MaterialButton::new("Test").min_width(100.0);
assert_eq!(button.min_width, Some(100.0));
}
#[test]
fn test_button_min_height() {
let button = MaterialButton::new("Test").min_height(48.0);
assert_eq!(button.min_height, Some(48.0));
}
#[test]
fn test_button_custom_background_color() {
let color = Color::srgb(1.0, 0.0, 0.0);
let button = MaterialButton::new("Test").custom_background_color(color);
assert_eq!(button.custom_background_color, Some(color));
}
#[test]
fn test_button_custom_text_color() {
let color = Color::srgb(0.0, 1.0, 0.0);
let button = MaterialButton::new("Test").custom_text_color(color);
assert_eq!(button.custom_text_color, Some(color));
}
#[test]
fn test_button_stroke_width() {
let button = MaterialButton::new("Test").stroke_width(2.0);
assert_eq!(button.stroke_width, 2.0);
}
#[test]
fn test_button_stroke_color() {
let color = Color::srgb(0.0, 0.0, 1.0);
let button = MaterialButton::new("Test").stroke_color(color);
assert_eq!(button.stroke_color, Some(color));
}
#[test]
fn test_button_checkable() {
let button = MaterialButton::new("Test").checkable(true);
assert!(button.checkable);
}
#[test]
fn test_button_checked() {
let button = MaterialButton::new("Test").checked(true);
assert!(button.checked);
}
#[test]
fn test_button_toggle_when_checkable() {
let mut button = MaterialButton::new("Test").checkable(true);
assert!(!button.checked);
button.toggle();
assert!(button.checked);
button.toggle();
assert!(!button.checked);
}
#[test]
fn test_button_toggle_when_not_checkable() {
let mut button = MaterialButton::new("Test").checkable(false);
assert!(!button.checked);
button.toggle();
assert!(!button.checked); }
#[test]
fn test_button_effective_corner_radius_default() {
let button = MaterialButton::new("Test");
assert_eq!(button.effective_corner_radius(), CornerRadius::FULL);
}
#[test]
fn test_button_effective_corner_radius_custom() {
let button = MaterialButton::new("Test").corner_radius(12.0);
assert_eq!(button.effective_corner_radius(), 12.0);
}
#[test]
fn test_button_builder_chain() {
let button = MaterialButton::new("Submit")
.with_variant(ButtonVariant::Outlined)
.with_icon("send")
.icon_gravity(IconGravity::End)
.icon_padding(12.0)
.icon_size(20.0)
.corner_radius(8.0)
.stroke_width(2.0)
.disabled(false)
.checkable(true)
.checked(true);
assert_eq!(button.label, "Submit");
assert_eq!(button.variant, ButtonVariant::Outlined);
assert_eq!(button.icon, Some("send".to_string()));
assert_eq!(button.icon_gravity, IconGravity::End);
assert_eq!(button.icon_padding, 12.0);
assert_eq!(button.icon_size, 20.0);
assert_eq!(button.corner_radius, Some(8.0));
assert_eq!(button.stroke_width, 2.0);
assert!(!button.disabled);
assert!(button.checkable);
assert!(button.checked);
}
#[test]
fn test_button_builder_new() {
let builder = MaterialButtonBuilder::new("Test");
assert_eq!(builder.button.label, "Test");
}
#[test]
fn test_button_builder_variant() {
let builder = MaterialButtonBuilder::new("Test").variant(ButtonVariant::Text);
assert_eq!(builder.button.variant, ButtonVariant::Text);
}
#[test]
fn test_button_builder_icon() {
let builder = MaterialButtonBuilder::new("Test").icon("add");
assert_eq!(builder.button.icon, Some("add".to_string()));
}
#[test]
fn test_button_builder_disabled() {
let builder = MaterialButtonBuilder::new("Test").disabled(true);
assert!(builder.button.disabled);
}
}