use bevy::prelude::*;
use bevy::ui::BoxShadow;
use std::collections::HashMap;
use crate::{
elevation::Elevation,
ripple::RippleHost,
telemetry::{InsertTestIdIfExists, TelemetryConfig, TestId},
theme::MaterialTheme,
tokens::{CornerRadius, Spacing},
};
pub struct MenuPlugin;
impl Plugin for MenuPlugin {
fn build(&self, app: &mut App) {
if !app.is_plugin_added::<crate::MaterialUiCorePlugin>() {
app.add_plugins(crate::MaterialUiCorePlugin);
}
app.add_message::<MenuOpenEvent>()
.add_message::<MenuCloseEvent>()
.add_message::<MenuItemSelectEvent>()
.add_systems(
Update,
(
menu_visibility_system,
menu_shadow_system,
menu_item_interaction_system,
menu_item_style_system,
menu_telemetry_system,
),
);
}
}
fn sanitize_test_id_component(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for ch in raw.chars() {
let c = ch.to_ascii_lowercase();
if c.is_ascii_alphanumeric() {
out.push(c);
} else if (c.is_ascii_whitespace() || c == '-') && !out.ends_with('_') {
out.push('_');
}
}
while out.ends_with('_') {
out.pop();
}
if out.is_empty() {
"item".to_string()
} else {
out
}
}
fn menu_telemetry_system(
mut commands: Commands,
telemetry: Option<Res<TelemetryConfig>>,
menus: Query<(&TestId, &Children), With<MaterialMenu>>,
children_query: Query<&Children>,
menu_items: Query<&MaterialMenuItem>,
menu_dividers: Query<(), With<MenuDivider>>,
) {
let Some(telemetry) = telemetry else {
return;
};
if !telemetry.enabled {
return;
}
for (test_id, children) in menus.iter() {
let base = test_id.id();
let mut item_counts: HashMap<String, u32> = HashMap::new();
let mut divider_index: u32 = 0;
let mut stack: Vec<Entity> = children.iter().collect();
while let Some(entity) = stack.pop() {
if let Ok(item) = menu_items.get(entity) {
let slug = sanitize_test_id_component(item.label.as_str());
let count = item_counts.entry(slug.clone()).or_insert(0);
*count += 1;
let unique = if *count == 1 {
slug
} else {
format!("{slug}_{}", *count)
};
commands.queue(InsertTestIdIfExists {
entity,
id: format!("{base}/item/{unique}"),
});
}
if menu_dividers.get(entity).is_ok() {
divider_index += 1;
commands.queue(InsertTestIdIfExists {
entity,
id: format!("{base}/divider/{divider_index}"),
});
}
if let Ok(children) = children_query.get(entity) {
stack.extend(children.iter());
}
}
}
}
#[derive(Component)]
pub struct MaterialMenu {
pub open: bool,
pub anchor: MenuAnchor,
pub close_on_click_outside: bool,
}
impl MaterialMenu {
pub fn new() -> Self {
Self {
open: false,
anchor: MenuAnchor::default(),
close_on_click_outside: true,
}
}
pub fn anchor(mut self, anchor: MenuAnchor) -> Self {
self.anchor = anchor;
self
}
pub fn open(mut self) -> Self {
self.open = true;
self
}
pub fn no_close_on_outside(mut self) -> Self {
self.close_on_click_outside = false;
self
}
pub fn surface_color(&self, theme: &MaterialTheme) -> Color {
theme.surface_container
}
pub fn elevation(&self) -> Elevation {
Elevation::Level2
}
}
impl Default for MaterialMenu {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum MenuAnchor {
TopLeft,
TopRight,
#[default]
BottomLeft,
BottomRight,
}
#[derive(Component)]
pub struct MaterialMenuItem {
pub label: String,
pub leading_icon: Option<String>,
pub trailing_icon: Option<String>,
pub trailing_text: Option<String>,
pub has_submenu: bool,
pub disabled: bool,
pub selected: bool,
pub pressed: bool,
pub hovered: bool,
}
impl MaterialMenuItem {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
leading_icon: None,
trailing_icon: None,
trailing_text: None,
has_submenu: false,
disabled: false,
selected: false,
pressed: false,
hovered: false,
}
}
pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
self.leading_icon = Some(icon.into());
self
}
pub fn trailing_icon(mut self, icon: impl Into<String>) -> Self {
self.trailing_icon = Some(icon.into());
self
}
pub fn shortcut(mut self, text: impl Into<String>) -> Self {
self.trailing_text = Some(text.into());
self
}
pub fn submenu(mut self) -> Self {
self.has_submenu = true;
self.trailing_icon = Some("chevron_right".into());
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn text_color(&self, theme: &MaterialTheme) -> Color {
if self.disabled {
theme.on_surface.with_alpha(0.38)
} else {
theme.on_surface
}
}
pub fn icon_color(&self, theme: &MaterialTheme) -> Color {
if self.disabled {
theme.on_surface.with_alpha(0.38)
} else {
theme.on_surface_variant
}
}
pub fn background_color(&self, theme: &MaterialTheme) -> Color {
if self.selected {
theme.secondary_container
} else {
Color::NONE
}
}
}
#[derive(Event, bevy::prelude::Message)]
pub struct MenuOpenEvent {
pub entity: Entity,
}
#[derive(Event, bevy::prelude::Message)]
pub struct MenuCloseEvent {
pub entity: Entity,
}
#[derive(Event, bevy::prelude::Message)]
pub struct MenuItemSelectEvent {
pub menu_entity: Entity,
pub item_entity: Entity,
}
pub const MENU_MIN_WIDTH: f32 = 112.0;
pub const MENU_MAX_WIDTH: f32 = 280.0;
pub const MENU_ITEM_HEIGHT: f32 = 48.0;
fn menu_visibility_system(mut menus: Query<(&MaterialMenu, &mut Node), Changed<MaterialMenu>>) {
for (menu, mut node) in menus.iter_mut() {
node.display = if menu.open {
Display::Flex
} else {
Display::None
};
}
}
fn menu_shadow_system(mut menus: Query<(&MaterialMenu, &mut BoxShadow), Changed<MaterialMenu>>) {
for (menu, mut shadow) in menus.iter_mut() {
if menu.open {
*shadow = menu.elevation().to_box_shadow();
} else {
*shadow = BoxShadow::default();
}
}
}
fn menu_item_interaction_system(
mut interaction_query: Query<
(Entity, &Interaction, &mut MaterialMenuItem, &ChildOf),
(Changed<Interaction>, With<MaterialMenuItem>),
>,
menus: Query<Entity, With<MaterialMenu>>,
mut select_events: MessageWriter<MenuItemSelectEvent>,
) {
for (entity, interaction, mut item, parent) in interaction_query.iter_mut() {
if item.disabled {
continue;
}
match *interaction {
Interaction::Pressed => {
item.pressed = true;
item.hovered = false;
if !item.has_submenu {
if let Ok(menu_entity) = menus.get(parent.parent()) {
select_events.write(MenuItemSelectEvent {
menu_entity,
item_entity: entity,
});
}
}
}
Interaction::Hovered => {
item.pressed = false;
item.hovered = true;
}
Interaction::None => {
item.pressed = false;
item.hovered = false;
}
}
}
}
fn menu_item_style_system(
theme: Option<Res<MaterialTheme>>,
mut items: Query<(&MaterialMenuItem, &mut BackgroundColor), Changed<MaterialMenuItem>>,
) {
let Some(theme) = theme else { return };
for (item, mut bg_color) in items.iter_mut() {
*bg_color = BackgroundColor(item.background_color(&theme));
}
}
pub struct MenuBuilder {
menu: MaterialMenu,
}
impl MenuBuilder {
pub fn new() -> Self {
Self {
menu: MaterialMenu::new(),
}
}
pub fn anchor(mut self, anchor: MenuAnchor) -> Self {
self.menu.anchor = anchor;
self
}
pub fn open(mut self) -> Self {
self.menu.open = true;
self
}
pub fn no_close_on_outside(mut self) -> Self {
self.menu.close_on_click_outside = false;
self
}
pub fn build(self, theme: &MaterialTheme) -> impl Bundle {
let bg_color = self.menu.surface_color(theme);
(
self.menu,
Node {
display: Display::None, position_type: PositionType::Absolute,
min_width: Val::Px(MENU_MIN_WIDTH),
max_width: Val::Px(MENU_MAX_WIDTH),
flex_direction: FlexDirection::Column,
padding: UiRect::vertical(Val::Px(Spacing::SMALL)),
border_radius: BorderRadius::all(Val::Px(CornerRadius::EXTRA_SMALL)),
..default()
},
BackgroundColor(bg_color),
BoxShadow::default(),
)
}
}
impl Default for MenuBuilder {
fn default() -> Self {
Self::new()
}
}
pub struct MenuItemBuilder {
item: MaterialMenuItem,
}
impl MenuItemBuilder {
pub fn new(label: impl Into<String>) -> Self {
Self {
item: MaterialMenuItem::new(label),
}
}
pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
self.item.leading_icon = Some(icon.into());
self
}
pub fn trailing_icon(mut self, icon: impl Into<String>) -> Self {
self.item.trailing_icon = Some(icon.into());
self
}
pub fn shortcut(mut self, text: impl Into<String>) -> Self {
self.item.trailing_text = Some(text.into());
self
}
pub fn submenu(mut self) -> Self {
self.item.has_submenu = true;
self.item.trailing_icon = Some("chevron_right".into());
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.item.disabled = disabled;
self
}
pub fn selected(mut self, selected: bool) -> Self {
self.item.selected = selected;
self
}
pub fn build(self, theme: &MaterialTheme) -> impl Bundle {
let bg_color = self.item.background_color(theme);
(
self.item,
Button,
RippleHost::new(),
Node {
width: Val::Percent(100.0),
height: Val::Px(MENU_ITEM_HEIGHT),
padding: UiRect::horizontal(Val::Px(Spacing::MEDIUM)),
align_items: AlignItems::Center,
column_gap: Val::Px(Spacing::MEDIUM),
..default()
},
BackgroundColor(bg_color),
)
}
}
#[derive(Component)]
pub struct MenuDivider;
pub fn create_menu_divider(theme: &MaterialTheme) -> impl Bundle {
(
MenuDivider,
Node {
width: Val::Percent(100.0),
height: Val::Px(1.0),
margin: UiRect::vertical(Val::Px(Spacing::SMALL)),
..default()
},
BackgroundColor(theme.outline_variant),
)
}
pub trait SpawnMenuChild {
fn spawn_menu(
&mut self,
theme: &MaterialTheme,
with_items: impl FnOnce(&mut ChildSpawnerCommands),
);
fn spawn_menu_item(&mut self, theme: &MaterialTheme, label: impl Into<String>);
fn spawn_menu_item_with(&mut self, theme: &MaterialTheme, builder: MenuItemBuilder);
fn spawn_menu_divider(&mut self, theme: &MaterialTheme);
}
impl SpawnMenuChild for ChildSpawnerCommands<'_> {
fn spawn_menu(
&mut self,
theme: &MaterialTheme,
with_items: impl FnOnce(&mut ChildSpawnerCommands),
) {
self.spawn(MenuBuilder::new().build(theme))
.with_children(with_items);
}
fn spawn_menu_item(&mut self, theme: &MaterialTheme, label: impl Into<String>) {
let label_str = label.into();
let label_color = theme.on_surface;
self.spawn(MenuItemBuilder::new(&label_str).build(theme))
.with_children(|item| {
item.spawn((
Text::new(&label_str),
TextFont {
font_size: 14.0,
..default()
},
TextColor(label_color),
));
});
}
fn spawn_menu_item_with(&mut self, theme: &MaterialTheme, builder: MenuItemBuilder) {
let label_str = builder.item.label.clone();
let label_color = theme.on_surface;
self.spawn(builder.build(theme)).with_children(|item| {
item.spawn((
Text::new(&label_str),
TextFont {
font_size: 14.0,
..default()
},
TextColor(label_color),
));
});
}
fn spawn_menu_divider(&mut self, theme: &MaterialTheme) {
self.spawn(create_menu_divider(theme));
}
}