use bevy::prelude::*;
use crate::{ripple::RippleHost, theme::MaterialTheme, tokens::CornerRadius};
#[derive(Component)]
pub struct SwitchStateLayer;
#[derive(Component, Copy, Clone)]
pub struct SwitchLabelFor(pub Entity);
pub struct SwitchPlugin;
impl Plugin for SwitchPlugin {
fn build(&self, app: &mut App) {
app.add_message::<SwitchChangeEvent>().add_systems(
Update,
(
switch_interaction_system,
switch_label_interaction_system,
switch_style_system,
switch_theme_refresh_system,
),
);
if !app.is_plugin_added::<crate::MaterialUiCorePlugin>() {
app.add_plugins(crate::MaterialUiCorePlugin);
}
}
}
#[derive(Component)]
pub struct MaterialSwitch {
pub selected: bool,
pub disabled: bool,
pub with_icon: bool,
pub animation_progress: f32,
pub pressed: bool,
pub hovered: bool,
}
impl MaterialSwitch {
pub fn new() -> Self {
Self {
selected: false,
disabled: false,
with_icon: false,
animation_progress: 0.0,
pressed: false,
hovered: false,
}
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self.animation_progress = if selected { 1.0 } else { 0.0 };
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn with_icon(mut self) -> Self {
self.with_icon = true;
self
}
pub fn track_color(&self, theme: &MaterialTheme) -> Color {
if self.disabled {
if self.selected {
return theme.on_surface.with_alpha(0.12);
} else {
return theme.surface_container_highest.with_alpha(0.12);
}
}
if self.selected {
theme.primary
} else {
theme.surface_container_highest
}
}
pub fn track_outline_color(&self, theme: &MaterialTheme) -> Color {
if self.disabled {
return theme.on_surface.with_alpha(0.12);
}
if self.selected {
Color::NONE
} else {
theme.outline
}
}
pub fn handle_color(&self, theme: &MaterialTheme) -> Color {
if self.disabled {
if self.selected {
return theme.surface;
} else {
return theme.on_surface.with_alpha(0.38);
}
}
if self.selected {
theme.on_primary
} else if self.pressed || self.hovered {
theme.on_surface_variant
} else {
theme.outline
}
}
pub fn icon_color(&self, theme: &MaterialTheme) -> Color {
if self.disabled {
if self.selected {
return theme.on_surface.with_alpha(0.38);
} else {
return theme.surface_container_highest.with_alpha(0.38);
}
}
if self.selected {
theme.on_primary_container
} else {
theme.surface_container_highest
}
}
pub fn handle_size(&self) -> f32 {
if self.pressed {
SWITCH_HANDLE_SIZE_PRESSED
} else if self.selected || self.with_icon {
SWITCH_HANDLE_SIZE_SELECTED
} else {
SWITCH_HANDLE_SIZE_UNSELECTED
}
}
pub fn handle_position(&self) -> f32 {
self.animation_progress
}
}
impl Default for MaterialSwitch {
fn default() -> Self {
Self::new()
}
}
#[derive(Event, bevy::prelude::Message)]
pub struct SwitchChangeEvent {
pub entity: Entity,
pub selected: bool,
}
pub const SWITCH_TRACK_WIDTH: f32 = 52.0;
pub const SWITCH_TRACK_HEIGHT: f32 = 32.0;
pub const SWITCH_HANDLE_SIZE_UNSELECTED: f32 = 16.0;
pub const SWITCH_HANDLE_SIZE_SELECTED: f32 = 24.0;
pub const SWITCH_HANDLE_SIZE_PRESSED: f32 = 28.0;
fn switch_interaction_system(
mut interaction_query: Query<
(Entity, &Interaction, &mut MaterialSwitch),
(Changed<Interaction>, With<MaterialSwitch>),
>,
mut change_events: MessageWriter<SwitchChangeEvent>,
) {
for (entity, interaction, mut switch) in interaction_query.iter_mut() {
if switch.disabled {
continue;
}
match *interaction {
Interaction::Pressed => {
switch.pressed = true;
switch.hovered = false;
switch.selected = !switch.selected;
change_events.write(SwitchChangeEvent {
entity,
selected: switch.selected,
});
}
Interaction::Hovered => {
switch.pressed = false;
switch.hovered = true;
}
Interaction::None => {
switch.pressed = false;
switch.hovered = false;
}
}
}
}
fn switch_label_interaction_system(
mut label_query: Query<(&Interaction, &SwitchLabelFor), Changed<Interaction>>,
mut switches: Query<&mut MaterialSwitch>,
mut change_events: MessageWriter<SwitchChangeEvent>,
) {
for (interaction, label_for) in label_query.iter_mut() {
let Ok(mut switch) = switches.get_mut(label_for.0) else {
continue;
};
if switch.disabled {
continue;
}
match *interaction {
Interaction::Pressed => {
switch.pressed = false;
switch.hovered = false;
switch.selected = !switch.selected;
switch.animation_progress = if switch.selected { 1.0 } else { 0.0 };
change_events.write(SwitchChangeEvent {
entity: label_for.0,
selected: switch.selected,
});
}
Interaction::Hovered => {
switch.pressed = false;
switch.hovered = true;
}
Interaction::None => {
switch.pressed = false;
switch.hovered = false;
}
}
}
}
fn switch_style_system(
theme: Option<Res<MaterialTheme>>,
mut switches: Query<
(
&MaterialSwitch,
&mut BackgroundColor,
&mut BorderColor,
&mut Node,
&Children,
),
Changed<MaterialSwitch>,
>,
mut handles: Query<
(&mut BackgroundColor, &mut Node),
(With<SwitchHandle>, Without<MaterialSwitch>),
>,
) {
let Some(theme) = theme else { return };
for (switch, mut bg_color, mut border_color, mut node, children) in switches.iter_mut() {
*bg_color = BackgroundColor(switch.track_color(&theme));
*border_color = BorderColor::all(switch.track_outline_color(&theme));
node.justify_content = if switch.selected {
JustifyContent::FlexEnd
} else {
JustifyContent::FlexStart
};
node.border = UiRect::all(Val::Px(if switch.selected { 0.0 } else { 2.0 }));
let handle_color = switch.handle_color(&theme);
let handle_size = switch.handle_size();
for child in children.iter() {
if let Ok((mut handle_bg, mut handle_node)) = handles.get_mut(child)
{
*handle_bg = BackgroundColor(handle_color);
handle_node.width = Val::Px(handle_size);
handle_node.height = Val::Px(handle_size);
handle_node.border_radius = BorderRadius::all(Val::Px(handle_size / 2.0));
}
}
}
}
fn switch_theme_refresh_system(
theme: Option<Res<MaterialTheme>>,
mut switches: Query<(
&MaterialSwitch,
&mut BackgroundColor,
&mut BorderColor,
&mut Node,
&Children,
)>,
mut handles: Query<
(&mut BackgroundColor, &mut Node),
(With<SwitchHandle>, Without<MaterialSwitch>),
>,
) {
let Some(theme) = theme else { return };
if !theme.is_changed() {
return;
}
for (switch, mut bg_color, mut border_color, mut node, children) in switches.iter_mut() {
*bg_color = BackgroundColor(switch.track_color(&theme));
*border_color = BorderColor::all(switch.track_outline_color(&theme));
node.justify_content = if switch.selected {
JustifyContent::FlexEnd
} else {
JustifyContent::FlexStart
};
node.border = UiRect::all(Val::Px(if switch.selected { 0.0 } else { 2.0 }));
let handle_color = switch.handle_color(&theme);
let handle_size = switch.handle_size();
for child in children.iter() {
if let Ok((mut handle_bg, mut handle_node)) = handles.get_mut(child)
{
*handle_bg = BackgroundColor(handle_color);
handle_node.width = Val::Px(handle_size);
handle_node.height = Val::Px(handle_size);
handle_node.border_radius = BorderRadius::all(Val::Px(handle_size / 2.0));
}
}
}
}
pub struct SwitchBuilder {
switch: MaterialSwitch,
}
impl SwitchBuilder {
pub fn new() -> Self {
Self {
switch: MaterialSwitch::new(),
}
}
pub fn selected(mut self, selected: bool) -> Self {
self.switch.selected = selected;
self.switch.animation_progress = if selected { 1.0 } else { 0.0 };
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.switch.disabled = disabled;
self
}
pub fn with_icon(mut self) -> Self {
self.switch.with_icon = true;
self
}
pub fn build(self, theme: &MaterialTheme) -> impl Bundle {
let bg_color = self.switch.track_color(theme);
let border_color = self.switch.track_outline_color(theme);
let has_border = !self.switch.selected;
(
self.switch,
Button,
RippleHost::new(),
Node {
width: Val::Px(SWITCH_TRACK_WIDTH),
height: Val::Px(SWITCH_TRACK_HEIGHT),
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
padding: UiRect::horizontal(Val::Px(2.0)),
border: UiRect::all(Val::Px(if has_border { 2.0 } else { 0.0 })),
border_radius: BorderRadius::all(Val::Px(CornerRadius::FULL)),
..default()
},
BackgroundColor(bg_color),
BorderColor::all(border_color),
)
}
}
impl Default for SwitchBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Component)]
pub struct SwitchHandle;
pub trait SpawnSwitch {
fn spawn_switch(&mut self, theme: &MaterialTheme, selected: bool, label: &str) -> Entity;
fn spawn_switch_with(
&mut self,
theme: &MaterialTheme,
builder: SwitchBuilder,
label: &str,
) -> Entity;
}
impl SpawnSwitch for Commands<'_, '_> {
fn spawn_switch(&mut self, theme: &MaterialTheme, selected: bool, label: &str) -> Entity {
let builder = SwitchBuilder::new().selected(selected);
self.spawn_switch_with(theme, builder, label)
}
fn spawn_switch_with(
&mut self,
theme: &MaterialTheme,
builder: SwitchBuilder,
label: &str,
) -> Entity {
let label_color = theme.on_surface;
let label_text = label.to_string();
let switch = builder.switch;
let bg_color = switch.track_color(theme);
let border_color = switch.track_outline_color(theme);
let handle_color = switch.handle_color(theme);
let handle_size = switch.handle_size();
let has_border = !switch.selected;
let justify = if switch.selected {
JustifyContent::FlexEnd
} else {
JustifyContent::FlexStart
};
self.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(12.0),
..default()
})
.with_children(|row| {
let switch_entity = row
.spawn((
switch,
Button,
Interaction::None,
RippleHost::new(),
Node {
width: Val::Px(SWITCH_TRACK_WIDTH),
height: Val::Px(SWITCH_TRACK_HEIGHT),
justify_content: justify,
align_items: AlignItems::Center,
padding: UiRect::horizontal(Val::Px(2.0)),
border: UiRect::all(Val::Px(if has_border { 2.0 } else { 0.0 })),
border_radius: BorderRadius::all(Val::Px(CornerRadius::FULL)),
..default()
},
BackgroundColor(bg_color),
BorderColor::all(border_color),
))
.with_children(|track| {
track.spawn((
SwitchHandle,
Node {
width: Val::Px(handle_size),
height: Val::Px(handle_size),
border_radius: BorderRadius::all(Val::Px(handle_size / 2.0)),
..default()
},
BackgroundColor(handle_color),
));
})
.id();
row.spawn((
SwitchLabelFor(switch_entity),
Button,
Interaction::None,
Text::new(label_text),
TextFont {
font_size: 14.0,
..default()
},
TextColor(label_color),
));
})
.id()
}
}
pub trait SpawnSwitchChild {
fn spawn_switch(&mut self, theme: &MaterialTheme, selected: bool, label: &str);
fn spawn_switch_with(&mut self, theme: &MaterialTheme, builder: SwitchBuilder, label: &str);
}
impl SpawnSwitchChild for ChildSpawnerCommands<'_> {
fn spawn_switch(&mut self, theme: &MaterialTheme, selected: bool, label: &str) {
let builder = SwitchBuilder::new().selected(selected);
self.spawn_switch_with(theme, builder, label);
}
fn spawn_switch_with(&mut self, theme: &MaterialTheme, builder: SwitchBuilder, label: &str) {
let label_color = theme.on_surface;
let label_text = label.to_string();
let switch = builder.switch;
let bg_color = switch.track_color(theme);
let border_color = switch.track_outline_color(theme);
let handle_color = switch.handle_color(theme);
let handle_size = switch.handle_size();
let has_border = !switch.selected;
let justify = if switch.selected {
JustifyContent::FlexEnd
} else {
JustifyContent::FlexStart
};
self.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(12.0),
..default()
})
.with_children(|row| {
let switch_entity = row
.spawn((
switch,
Button,
Interaction::None,
RippleHost::new(),
Node {
width: Val::Px(SWITCH_TRACK_WIDTH),
height: Val::Px(SWITCH_TRACK_HEIGHT),
justify_content: justify,
align_items: AlignItems::Center,
padding: UiRect::horizontal(Val::Px(2.0)),
border: UiRect::all(Val::Px(if has_border { 2.0 } else { 0.0 })),
border_radius: BorderRadius::all(Val::Px(CornerRadius::FULL)),
..default()
},
BackgroundColor(bg_color),
BorderColor::all(border_color),
))
.with_children(|track| {
track.spawn((
SwitchHandle,
Node {
width: Val::Px(handle_size),
height: Val::Px(handle_size),
border_radius: BorderRadius::all(Val::Px(handle_size / 2.0)),
..default()
},
BackgroundColor(handle_color),
));
})
.id();
row.spawn((
SwitchLabelFor(switch_entity),
Button,
Interaction::None,
Text::new(label_text),
TextFont {
font_size: 14.0,
..default()
},
TextColor(label_color),
));
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_switch_new_defaults() {
let switch = MaterialSwitch::new();
assert!(!switch.selected);
assert!(!switch.disabled);
assert!(!switch.with_icon);
assert_eq!(switch.animation_progress, 0.0);
assert!(!switch.pressed);
assert!(!switch.hovered);
}
#[test]
fn test_switch_default_trait() {
let switch = MaterialSwitch::default();
assert!(!switch.selected);
assert!(!switch.disabled);
}
#[test]
fn test_switch_selected_true() {
let switch = MaterialSwitch::new().selected(true);
assert!(switch.selected);
assert_eq!(switch.animation_progress, 1.0);
}
#[test]
fn test_switch_selected_false() {
let switch = MaterialSwitch::new().selected(false);
assert!(!switch.selected);
assert_eq!(switch.animation_progress, 0.0);
}
#[test]
fn test_switch_disabled() {
let switch = MaterialSwitch::new().disabled(true);
assert!(switch.disabled);
let switch = MaterialSwitch::new().disabled(false);
assert!(!switch.disabled);
}
#[test]
fn test_switch_with_icon() {
let switch = MaterialSwitch::new().with_icon();
assert!(switch.with_icon);
}
#[test]
fn test_switch_handle_size_unselected() {
let switch = MaterialSwitch::new();
assert_eq!(switch.handle_size(), SWITCH_HANDLE_SIZE_UNSELECTED);
}
#[test]
fn test_switch_handle_size_selected() {
let switch = MaterialSwitch::new().selected(true);
assert_eq!(switch.handle_size(), SWITCH_HANDLE_SIZE_SELECTED);
}
#[test]
fn test_switch_handle_size_with_icon() {
let switch = MaterialSwitch::new().with_icon();
assert_eq!(switch.handle_size(), SWITCH_HANDLE_SIZE_SELECTED);
}
#[test]
fn test_switch_handle_size_pressed() {
let mut switch = MaterialSwitch::new();
switch.pressed = true;
assert_eq!(switch.handle_size(), SWITCH_HANDLE_SIZE_PRESSED);
}
#[test]
fn test_switch_handle_position_off() {
let switch = MaterialSwitch::new().selected(false);
assert_eq!(switch.handle_position(), 0.0);
}
#[test]
fn test_switch_handle_position_on() {
let switch = MaterialSwitch::new().selected(true);
assert_eq!(switch.handle_position(), 1.0);
}
#[test]
fn test_switch_builder_chain() {
let switch = MaterialSwitch::new()
.selected(true)
.disabled(false)
.with_icon();
assert!(switch.selected);
assert!(!switch.disabled);
assert!(switch.with_icon);
}
#[test]
fn test_switch_builder_new() {
let builder = SwitchBuilder::new();
assert!(!builder.switch.selected);
assert!(!builder.switch.disabled);
}
#[test]
fn test_switch_builder_default() {
let builder = SwitchBuilder::default();
assert!(!builder.switch.selected);
}
#[test]
fn test_switch_builder_selected() {
let builder = SwitchBuilder::new().selected(true);
assert!(builder.switch.selected);
assert_eq!(builder.switch.animation_progress, 1.0);
}
#[test]
fn test_switch_builder_disabled() {
let builder = SwitchBuilder::new().disabled(true);
assert!(builder.switch.disabled);
}
#[test]
fn test_switch_builder_with_icon() {
let builder = SwitchBuilder::new().with_icon();
assert!(builder.switch.with_icon);
}
#[test]
fn test_switch_builder_full_chain() {
let builder = SwitchBuilder::new()
.selected(true)
.disabled(false)
.with_icon();
assert!(builder.switch.selected);
assert!(!builder.switch.disabled);
assert!(builder.switch.with_icon);
}
#[test]
fn test_switch_track_width() {
assert_eq!(SWITCH_TRACK_WIDTH, 52.0);
}
#[test]
fn test_switch_track_height() {
assert_eq!(SWITCH_TRACK_HEIGHT, 32.0);
}
#[test]
fn test_switch_handle_sizes() {
assert_eq!(SWITCH_HANDLE_SIZE_UNSELECTED, 16.0);
assert_eq!(SWITCH_HANDLE_SIZE_SELECTED, 24.0);
assert_eq!(SWITCH_HANDLE_SIZE_PRESSED, 28.0);
}
#[test]
fn test_switch_handle_size_ordering() {
use std::hint::black_box;
assert!(black_box(SWITCH_HANDLE_SIZE_PRESSED) > black_box(SWITCH_HANDLE_SIZE_SELECTED));
assert!(black_box(SWITCH_HANDLE_SIZE_SELECTED) > black_box(SWITCH_HANDLE_SIZE_UNSELECTED));
}
#[test]
fn test_switch_track_dimensions() {
use std::hint::black_box;
assert!(black_box(SWITCH_TRACK_WIDTH) > black_box(SWITCH_TRACK_HEIGHT));
}
}