use bevy::prelude::*;
use bevy::ui::BoxShadow;
use crate::{
elevation::Elevation,
ripple::RippleHost,
theme::{blend_state_layer, MaterialTheme},
tokens::Spacing,
};
pub struct ChipPlugin;
impl Plugin for ChipPlugin {
fn build(&self, app: &mut App) {
if !app.is_plugin_added::<crate::MaterialUiCorePlugin>() {
app.add_plugins(crate::MaterialUiCorePlugin);
}
app.add_message::<ChipClickEvent>()
.add_message::<ChipDeleteEvent>()
.add_systems(
Update,
(
chip_interaction_system,
chip_style_system,
chip_content_style_system,
chip_theme_refresh_system,
chip_shadow_system,
),
);
}
}
#[derive(Event, Clone, bevy::prelude::Message)]
pub struct ChipClickEvent {
pub entity: Entity,
pub value: Option<String>,
}
#[derive(Event, Clone, bevy::prelude::Message)]
pub struct ChipDeleteEvent {
pub entity: Entity,
pub value: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum ChipVariant {
#[default]
Assist,
Filter,
Input,
Suggestion,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum ChipElevation {
#[default]
Flat,
Elevated,
}
impl ChipElevation {
pub fn to_elevation(&self) -> Elevation {
match self {
ChipElevation::Flat => Elevation::Level0,
ChipElevation::Elevated => Elevation::Level1,
}
}
pub fn to_box_shadow(&self) -> BoxShadow {
self.to_elevation().to_box_shadow()
}
}
#[derive(Component)]
pub struct MaterialChip {
pub variant: ChipVariant,
pub label: String,
pub value: Option<String>,
pub selected: bool,
pub disabled: bool,
pub deletable: bool,
pub has_leading_icon: bool,
pub elevation: ChipElevation,
pub pressed: bool,
pub hovered: bool,
}
impl MaterialChip {
pub fn new(label: impl Into<String>) -> Self {
Self {
variant: ChipVariant::default(),
label: label.into(),
value: None,
selected: false,
disabled: false,
deletable: false,
has_leading_icon: false,
elevation: ChipElevation::default(),
pressed: false,
hovered: false,
}
}
pub fn with_variant(mut self, variant: ChipVariant) -> Self {
self.variant = variant;
self
}
pub fn assist(label: impl Into<String>) -> Self {
Self::new(label).with_variant(ChipVariant::Assist)
}
pub fn filter(label: impl Into<String>) -> Self {
Self::new(label).with_variant(ChipVariant::Filter)
}
pub fn input(label: impl Into<String>) -> Self {
Self::new(label)
.with_variant(ChipVariant::Input)
.with_deletable(true)
}
pub fn suggestion(label: impl Into<String>) -> Self {
Self::new(label).with_variant(ChipVariant::Suggestion)
}
pub fn with_value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
pub fn with_selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn with_deletable(mut self, deletable: bool) -> Self {
self.deletable = deletable;
self
}
pub fn elevated(mut self) -> Self {
self.elevation = ChipElevation::Elevated;
self
}
pub fn with_leading_icon(mut self) -> Self {
self.has_leading_icon = true;
self
}
pub fn background_color(&self, theme: &MaterialTheme) -> Color {
if self.disabled {
return theme.on_surface.with_alpha(0.12);
}
let base = match (self.variant, self.selected, self.elevation) {
(ChipVariant::Filter, true, _) => theme.secondary_container,
(_, _, ChipElevation::Elevated) => theme.surface_container_low,
_ => Color::NONE,
};
let state_opacity = self.state_layer_opacity();
let state_color = self.state_layer_color(theme);
if state_opacity > 0.0 {
if base == Color::NONE {
state_color.with_alpha(state_opacity)
} else {
blend_state_layer(base, state_color, state_opacity)
}
} else {
base
}
}
pub fn outline_color(&self, theme: &MaterialTheme) -> Color {
if self.disabled {
return theme.on_surface.with_alpha(0.12);
}
match (self.variant, self.selected, self.elevation) {
(ChipVariant::Filter, true, _) => Color::NONE,
(_, _, ChipElevation::Elevated) => Color::NONE,
_ => theme.outline,
}
}
pub fn label_color(&self, theme: &MaterialTheme) -> Color {
if self.disabled {
return theme.on_surface.with_alpha(0.38);
}
match (self.variant, self.selected) {
(ChipVariant::Filter, true) => theme.on_secondary_container,
_ => theme.on_surface_variant,
}
}
pub fn icon_color(&self, theme: &MaterialTheme) -> Color {
if self.disabled {
return theme.on_surface.with_alpha(0.38);
}
match (self.variant, self.selected) {
(ChipVariant::Filter, true) => theme.on_secondary_container,
_ => theme.primary,
}
}
pub fn state_layer_color(&self, theme: &MaterialTheme) -> Color {
match (self.variant, self.selected) {
(ChipVariant::Filter, true) => theme.on_secondary_container,
_ => theme.on_surface_variant,
}
}
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
}
}
}
impl Default for MaterialChip {
fn default() -> Self {
Self::new("")
}
}
#[derive(Component)]
pub struct ChipDeleteButton;
#[derive(Component)]
pub struct ChipLeadingIcon;
#[derive(Component)]
pub struct ChipLabel;
#[derive(Component, Clone, Copy, Debug, Default)]
pub struct ChipDeleteIcon;
pub const CHIP_HEIGHT: f32 = 32.0;
pub const CHIP_PADDING_HORIZONTAL: f32 = 16.0;
pub const CHIP_ICON_SIZE: f32 = 18.0;
pub const CHIP_PADDING_WITH_ICON: f32 = 8.0;
pub struct ChipBuilder {
chip: MaterialChip,
leading_icon: Option<String>,
}
impl ChipBuilder {
pub fn new(label: impl Into<String>) -> Self {
Self {
chip: MaterialChip::new(label),
leading_icon: None,
}
}
pub fn variant(mut self, variant: ChipVariant) -> Self {
self.chip.variant = variant;
self
}
pub fn assist(label: impl Into<String>) -> Self {
Self::new(label).variant(ChipVariant::Assist)
}
pub fn filter(label: impl Into<String>) -> Self {
Self::new(label).variant(ChipVariant::Filter)
}
pub fn input(label: impl Into<String>) -> Self {
Self::new(label).variant(ChipVariant::Input).deletable(true)
}
pub fn suggestion(label: impl Into<String>) -> Self {
Self::new(label).variant(ChipVariant::Suggestion)
}
pub fn value(mut self, value: impl Into<String>) -> Self {
self.chip.value = Some(value.into());
self
}
pub fn selected(mut self, selected: bool) -> Self {
self.chip.selected = selected;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.chip.disabled = disabled;
self
}
pub fn deletable(mut self, deletable: bool) -> Self {
self.chip.deletable = deletable;
self
}
pub fn elevated(mut self) -> Self {
self.chip.elevation = ChipElevation::Elevated;
self
}
pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
self.leading_icon = Some(icon.into());
self.chip.has_leading_icon = true;
self
}
pub fn build(self, theme: &MaterialTheme) -> impl Bundle {
let bg_color = self.chip.background_color(theme);
let outline_color = self.chip.outline_color(theme);
let has_outline = outline_color != Color::NONE;
let elevation = self.chip.elevation;
let padding_left = if self.chip.has_leading_icon {
CHIP_PADDING_WITH_ICON
} else {
CHIP_PADDING_HORIZONTAL
};
let padding_right = if self.chip.deletable {
CHIP_PADDING_WITH_ICON
} else {
CHIP_PADDING_HORIZONTAL
};
(
self.chip,
Button,
RippleHost::new(),
Node {
height: Val::Px(CHIP_HEIGHT),
padding: UiRect {
left: Val::Px(padding_left),
right: Val::Px(padding_right),
top: Val::Px(0.0),
bottom: Val::Px(0.0),
},
border: UiRect::all(Val::Px(if has_outline { 1.0 } else { 0.0 })),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(Spacing::SMALL),
border_radius: BorderRadius::all(Val::Px(CHIP_HEIGHT / 2.0)), ..default()
},
BackgroundColor(bg_color),
BorderColor::all(outline_color),
elevation.to_box_shadow(),
)
}
}
pub fn spawn_chip(commands: &mut Commands, theme: &MaterialTheme, builder: ChipBuilder) -> Entity {
let label = builder.chip.label.clone();
let label_color = builder.chip.label_color(theme);
let icon_color = builder.chip.icon_color(theme);
let deletable = builder.chip.deletable;
let has_leading = builder.chip.has_leading_icon;
let selected = builder.chip.selected;
let variant = builder.chip.variant;
let leading_icon = builder.leading_icon.clone();
commands
.spawn(builder.build(theme))
.with_children(|parent| {
if variant == ChipVariant::Filter && selected {
parent.spawn((
ChipLeadingIcon,
Text::new("✓"),
TextFont {
font_size: CHIP_ICON_SIZE,
..default()
},
TextColor(icon_color),
));
} else if has_leading {
parent.spawn((
ChipLeadingIcon,
Text::new(leading_icon.as_deref().unwrap_or("★")),
TextFont {
font_size: CHIP_ICON_SIZE,
..default()
},
TextColor(icon_color),
));
}
parent.spawn((
ChipLabel,
Text::new(&label),
TextFont {
font_size: 14.0,
..default()
},
TextColor(label_color),
));
if deletable {
parent
.spawn((
ChipDeleteButton,
Button,
Node {
width: Val::Px(CHIP_ICON_SIZE),
height: Val::Px(CHIP_ICON_SIZE),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::NONE),
))
.with_children(|btn| {
btn.spawn((
ChipDeleteIcon,
Text::new("✕"),
TextFont {
font_size: 14.0,
..default()
},
TextColor(icon_color),
));
});
}
})
.id()
}
pub trait SpawnChipChild {
fn spawn_chip_with(&mut self, theme: &MaterialTheme, builder: ChipBuilder);
fn spawn_assist_chip(&mut self, theme: &MaterialTheme, label: impl Into<String>);
fn spawn_filter_chip(
&mut self,
theme: &MaterialTheme,
label: impl Into<String>,
selected: bool,
);
fn spawn_input_chip(
&mut self,
theme: &MaterialTheme,
label: impl Into<String>,
deletable: bool,
);
fn spawn_suggestion_chip(&mut self, theme: &MaterialTheme, label: impl Into<String>);
}
impl SpawnChipChild for ChildSpawnerCommands<'_> {
fn spawn_chip_with(&mut self, theme: &MaterialTheme, builder: ChipBuilder) {
let label = builder.chip.label.clone();
let label_color = builder.chip.label_color(theme);
let icon_color = builder.chip.icon_color(theme);
let deletable = builder.chip.deletable;
let has_leading = builder.chip.has_leading_icon;
let selected = builder.chip.selected;
let variant = builder.chip.variant;
let leading_icon = builder.leading_icon.clone();
self.spawn(builder.build(theme)).with_children(|parent| {
if variant == ChipVariant::Filter && selected {
parent.spawn((
ChipLeadingIcon,
Text::new("✓"),
TextFont {
font_size: CHIP_ICON_SIZE,
..default()
},
TextColor(icon_color),
));
} else if has_leading {
parent.spawn((
ChipLeadingIcon,
Text::new(leading_icon.as_deref().unwrap_or("★")),
TextFont {
font_size: CHIP_ICON_SIZE,
..default()
},
TextColor(icon_color),
));
}
parent.spawn((
ChipLabel,
Text::new(&label),
TextFont {
font_size: 14.0,
..default()
},
TextColor(label_color),
));
if deletable {
parent
.spawn((
ChipDeleteButton,
Button,
Node {
width: Val::Px(CHIP_ICON_SIZE),
height: Val::Px(CHIP_ICON_SIZE),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::NONE),
))
.with_children(|btn| {
btn.spawn((
ChipDeleteIcon,
Text::new("✕"),
TextFont {
font_size: 14.0,
..default()
},
TextColor(icon_color),
));
});
}
});
}
fn spawn_assist_chip(&mut self, theme: &MaterialTheme, label: impl Into<String>) {
self.spawn_chip_with(theme, ChipBuilder::assist(label));
}
fn spawn_filter_chip(
&mut self,
theme: &MaterialTheme,
label: impl Into<String>,
selected: bool,
) {
self.spawn_chip_with(theme, ChipBuilder::filter(label).selected(selected));
}
fn spawn_input_chip(
&mut self,
theme: &MaterialTheme,
label: impl Into<String>,
deletable: bool,
) {
let mut builder = ChipBuilder::input(label);
if deletable {
builder = builder.deletable(true);
}
self.spawn_chip_with(theme, builder);
}
fn spawn_suggestion_chip(&mut self, theme: &MaterialTheme, label: impl Into<String>) {
self.spawn_chip_with(theme, ChipBuilder::suggestion(label));
}
}
fn chip_interaction_system(
mut interaction_query: Query<(Entity, &Interaction, &mut MaterialChip), Changed<Interaction>>,
delete_buttons: Query<(&Interaction, &ChildOf), (Changed<Interaction>, With<ChipDeleteButton>)>,
mut click_events: MessageWriter<ChipClickEvent>,
mut delete_events: MessageWriter<ChipDeleteEvent>,
) {
for (entity, interaction, mut chip) in interaction_query.iter_mut() {
if chip.disabled {
continue;
}
match *interaction {
Interaction::Pressed => {
chip.pressed = true;
chip.hovered = false;
if chip.variant == ChipVariant::Filter {
chip.selected = !chip.selected;
}
click_events.write(ChipClickEvent {
entity,
value: chip.value.clone(),
});
}
Interaction::Hovered => {
chip.pressed = false;
chip.hovered = true;
}
Interaction::None => {
chip.pressed = false;
chip.hovered = false;
}
}
}
for (interaction, parent) in delete_buttons.iter() {
if *interaction == Interaction::Pressed {
delete_events.write(ChipDeleteEvent {
entity: parent.parent(),
value: None, });
}
}
}
fn chip_style_system(
theme: Option<Res<MaterialTheme>>,
mut chips: Query<
(&MaterialChip, &mut BackgroundColor, &mut BorderColor),
Changed<MaterialChip>,
>,
) {
let Some(theme) = theme else { return };
for (chip, mut bg_color, mut border_color) in chips.iter_mut() {
*bg_color = BackgroundColor(chip.background_color(&theme));
*border_color = BorderColor::all(chip.outline_color(&theme));
}
}
fn chip_content_style_system(
theme: Option<Res<MaterialTheme>>,
chips: Query<(Entity, &MaterialChip), Changed<MaterialChip>>,
children_q: Query<&Children>,
mut colors: ParamSet<(
Query<&mut TextColor, With<ChipLabel>>,
Query<&mut TextColor, With<ChipLeadingIcon>>,
Query<&mut TextColor, With<ChipDeleteIcon>>,
)>,
) {
let Some(theme) = theme else { return };
for (chip_entity, chip) in chips.iter() {
let Ok(children) = children_q.get(chip_entity) else {
continue;
};
let label_color = chip.label_color(&theme);
let icon_color = chip.icon_color(&theme);
for child in children.iter() {
if let Ok(mut color) = colors.p0().get_mut(child) {
color.0 = label_color;
}
if let Ok(mut color) = colors.p1().get_mut(child) {
color.0 = icon_color;
}
if let Ok(grandchildren) = children_q.get(child) {
for grandchild in grandchildren.iter() {
if let Ok(mut color) = colors.p2().get_mut(grandchild) {
color.0 = icon_color;
}
}
}
}
}
}
fn chip_shadow_system(mut chips: Query<(&MaterialChip, &mut BoxShadow), Changed<MaterialChip>>) {
for (chip, mut shadow) in chips.iter_mut() {
*shadow = chip.elevation.to_box_shadow();
}
}
fn chip_theme_refresh_system(
theme: Option<Res<MaterialTheme>>,
mut chips: Query<(
Entity,
&MaterialChip,
&mut BackgroundColor,
&mut BorderColor,
)>,
children_q: Query<&Children>,
mut colors: ParamSet<(
Query<&mut TextColor, With<ChipLabel>>,
Query<&mut TextColor, With<ChipLeadingIcon>>,
Query<&mut TextColor, With<ChipDeleteIcon>>,
)>,
) {
let Some(theme) = theme else { return };
if !theme.is_changed() {
return;
}
for (chip_entity, chip, mut bg_color, mut border_color) in chips.iter_mut() {
*bg_color = BackgroundColor(chip.background_color(&theme));
*border_color = BorderColor::all(chip.outline_color(&theme));
let Ok(children) = children_q.get(chip_entity) else {
continue;
};
let label_color = chip.label_color(&theme);
let icon_color = chip.icon_color(&theme);
for child in children.iter() {
if let Ok(mut color) = colors.p0().get_mut(child) {
color.0 = label_color;
}
if let Ok(mut color) = colors.p1().get_mut(child) {
color.0 = icon_color;
}
if let Ok(grandchildren) = children_q.get(child) {
for grandchild in grandchildren.iter() {
if let Ok(mut color) = colors.p2().get_mut(grandchild) {
color.0 = icon_color;
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chip_creation() {
let chip = MaterialChip::new("Test");
assert_eq!(chip.label, "Test");
assert_eq!(chip.variant, ChipVariant::Assist);
assert!(!chip.selected);
assert!(!chip.disabled);
}
#[test]
fn test_filter_chip() {
let chip = MaterialChip::filter("Category").with_selected(true);
assert_eq!(chip.variant, ChipVariant::Filter);
assert!(chip.selected);
}
#[test]
fn test_input_chip() {
let chip = MaterialChip::input("Tag");
assert_eq!(chip.variant, ChipVariant::Input);
assert!(chip.deletable);
}
#[test]
fn test_chip_with_value() {
let chip = MaterialChip::new("Label").with_value("value-123");
assert_eq!(chip.value, Some("value-123".to_string()));
}
#[test]
fn test_chip_builder() {
let builder = ChipBuilder::filter("Size")
.value("size-large")
.selected(true);
assert_eq!(builder.chip.label, "Size");
assert_eq!(builder.chip.value, Some("size-large".to_string()));
assert!(builder.chip.selected);
}
}