use accesskit::Role;
use bevy_a11y::AccessibilityNode;
use bevy_app::{App, Plugin, Update};
use bevy_ecs::{
component::Component,
entity::Entity,
event::EntityEvent,
hierarchy::ChildOf,
observer::On,
query::{Has, With},
schedule::IntoScheduleConfigs,
system::{Commands, Query, Res, ResMut},
};
use bevy_input::{
keyboard::{KeyCode, KeyboardInput},
ButtonState,
};
use bevy_input_focus::{
tab_navigation::{NavAction, TabGroup, TabNavigation},
FocusCause, FocusedInput, InputFocus,
};
use bevy_log::warn;
use bevy_picking::events::{Cancel, Click, DragEnd, Pointer, Press, Release};
use bevy_ui::{widget::Button, InteractionDisabled, Pressed};
use crate::{Activate, ActivateOnPress};
#[derive(Clone, Copy, Debug)]
pub enum MenuAction {
Open(NavAction),
Toggle,
CloseAll,
FocusRoot,
}
#[derive(EntityEvent, Clone, Debug)]
#[entity_event(propagate, auto_propagate)]
pub struct MenuEvent {
#[event_target]
pub source: Entity,
pub action: MenuAction,
}
#[derive(Default, Debug, Clone, PartialEq)]
pub enum MenuLayout {
#[default]
Column,
Row,
Grid,
}
#[derive(Component, Debug, Default, Clone)]
#[require(
AccessibilityNode(accesskit::Node::new(Role::MenuListPopup)),
TabGroup::modal()
)]
#[require(MenuFocusState::Closed)]
pub struct MenuPopup {
pub layout: MenuLayout,
}
#[derive(Component, Debug, Clone, Default)]
#[require(AccessibilityNode(accesskit::Node::new(Role::MenuItem)))]
pub struct MenuItem;
#[derive(Component, Debug, Clone, Default, PartialEq)]
pub enum MenuFocusState {
Opening(NavAction),
Open,
#[default]
Closed,
}
fn menu_acquire_focus(
mut q_menus: Query<(Entity, &mut MenuFocusState), With<MenuPopup>>,
mut focus: ResMut<InputFocus>,
tab_navigation: TabNavigation,
) {
for (menu, mut menu_focus) in q_menus.iter_mut() {
if let MenuFocusState::Opening(nav) = *menu_focus {
match tab_navigation.initialize(menu, nav) {
Ok(next) => {
*menu_focus = MenuFocusState::Open;
focus.set(next, FocusCause::Navigated);
}
Err(e) => {
warn!(
"No focusable menu items for popup menu: {}, error: {:?}",
menu, e
);
}
}
}
}
}
fn menu_on_lose_focus(
mut q_menus: Query<(Entity, &mut MenuFocusState), With<MenuPopup>>,
q_parent: Query<&ChildOf>,
focus: Res<InputFocus>,
mut commands: Commands,
) {
for (menu, mut menu_focus) in q_menus.iter_mut() {
match *menu_focus {
MenuFocusState::Opening(_) | MenuFocusState::Open => {
let contains_focus = match focus.get() {
Some(focus_ent) => {
focus_ent == menu
|| q_parent.iter_ancestors(focus_ent).any(|ent| ent == menu)
}
None => false,
};
if !contains_focus {
*menu_focus = MenuFocusState::Closed;
commands.trigger(MenuEvent {
source: menu,
action: MenuAction::CloseAll,
});
}
}
_ => {}
}
}
}
fn menu_on_key_event(
mut ev: On<FocusedInput<KeyboardInput>>,
q_item: Query<Has<InteractionDisabled>, With<MenuItem>>,
q_popup: Query<&MenuPopup>,
tab_navigation: TabNavigation,
mut focus: ResMut<InputFocus>,
mut commands: Commands,
) {
if let Ok(disabled) = q_item.get(ev.focused_entity) {
if !disabled {
let event = &ev.event().input;
let entity = ev.event().focused_entity;
if !event.repeat && event.state == ButtonState::Pressed {
match event.key_code {
KeyCode::Enter | KeyCode::Space => {
ev.propagate(false);
commands.trigger(Activate { entity });
commands.trigger(MenuEvent {
source: entity,
action: MenuAction::FocusRoot,
});
commands.trigger(MenuEvent {
source: entity,
action: MenuAction::CloseAll,
});
}
_ => (),
}
}
}
} else if let Ok(menu) = q_popup.get(ev.focused_entity) {
let event = &ev.event().input;
if !event.repeat && event.state == ButtonState::Pressed {
match event.key_code {
KeyCode::Escape => {
ev.propagate(false);
commands.trigger(MenuEvent {
source: ev.focused_entity,
action: MenuAction::CloseAll,
});
commands.trigger(MenuEvent {
source: ev.focused_entity,
action: MenuAction::FocusRoot,
});
}
KeyCode::ArrowUp if menu.layout == MenuLayout::Column => {
ev.propagate(false);
if let Ok(next) = tab_navigation.navigate(&focus, NavAction::Previous) {
focus.set(next, FocusCause::Navigated);
} else {
focus.clear();
}
}
KeyCode::ArrowDown if menu.layout == MenuLayout::Column => {
ev.propagate(false);
if let Ok(next) = tab_navigation.navigate(&focus, NavAction::Next) {
focus.set(next, FocusCause::Navigated);
} else {
focus.clear();
}
}
KeyCode::ArrowLeft if menu.layout == MenuLayout::Row => {
ev.propagate(false);
if let Ok(next) = tab_navigation.navigate(&focus, NavAction::Previous) {
focus.set(next, FocusCause::Navigated);
} else {
focus.clear();
}
}
KeyCode::ArrowRight if menu.layout == MenuLayout::Row => {
ev.propagate(false);
if let Ok(next) = tab_navigation.navigate(&focus, NavAction::Next) {
focus.set(next, FocusCause::Navigated);
focus.clear();
}
}
KeyCode::Home => {
ev.propagate(false);
if let Ok(next) = tab_navigation.navigate(&focus, NavAction::First) {
focus.set(next, FocusCause::Navigated);
} else {
focus.clear();
}
}
KeyCode::End => {
ev.propagate(false);
if let Ok(next) = tab_navigation.navigate(&focus, NavAction::Last) {
focus.set(next, FocusCause::Navigated);
} else {
focus.clear();
}
}
_ => (),
}
}
}
}
fn menu_item_on_pointer_click(
mut ev: On<Pointer<Click>>,
mut q_state: Query<(Has<Pressed>, Has<InteractionDisabled>), With<MenuItem>>,
mut commands: Commands,
) {
if let Ok((pressed, disabled)) = q_state.get_mut(ev.entity) {
ev.propagate(false);
if pressed && !disabled {
commands.trigger(Activate { entity: ev.entity });
commands.trigger(MenuEvent {
source: ev.entity,
action: MenuAction::FocusRoot,
});
commands.trigger(MenuEvent {
source: ev.entity,
action: MenuAction::CloseAll,
});
}
}
}
fn menu_item_on_pointer_down(
mut ev: On<Pointer<Press>>,
mut q_state: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<MenuItem>>,
mut commands: Commands,
) {
if let Ok((item, disabled, pressed)) = q_state.get_mut(ev.entity) {
ev.propagate(false);
if !disabled && !pressed {
commands.entity(item).insert(Pressed);
}
}
}
fn menu_item_on_pointer_up(
mut ev: On<Pointer<Release>>,
mut q_state: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<MenuItem>>,
mut commands: Commands,
) {
if let Ok((item, disabled, pressed)) = q_state.get_mut(ev.entity) {
ev.propagate(false);
if !disabled && pressed {
commands.entity(item).remove::<Pressed>();
}
}
}
fn menu_item_on_pointer_drag_end(
mut ev: On<Pointer<DragEnd>>,
mut q_state: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<MenuItem>>,
mut commands: Commands,
) {
if let Ok((item, disabled, pressed)) = q_state.get_mut(ev.entity) {
ev.propagate(false);
if !disabled && pressed {
commands.entity(item).remove::<Pressed>();
}
}
}
fn menu_item_on_pointer_cancel(
mut ev: On<Pointer<Cancel>>,
mut q_state: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<MenuItem>>,
mut commands: Commands,
) {
if let Ok((item, disabled, pressed)) = q_state.get_mut(ev.entity) {
ev.propagate(false);
if !disabled && pressed {
commands.entity(item).remove::<Pressed>();
}
}
}
#[derive(Component, Default, Debug, Clone)]
#[require(
AccessibilityNode(accesskit::Node::new(Role::Button)),
Button,
ActivateOnPress
)]
pub struct MenuButton;
fn menubutton_on_activate(
activate: On<Activate>,
q_menu_button: Query<Has<InteractionDisabled>, With<MenuButton>>,
mut commands: Commands,
) {
if let Ok(disabled) = q_menu_button.get(activate.entity)
&& !disabled
{
commands.trigger(MenuEvent {
source: activate.entity,
action: MenuAction::Toggle,
});
}
}
fn menubutton_on_key_event(
mut event: On<FocusedInput<KeyboardInput>>,
q_menu_button: Query<Has<InteractionDisabled>, With<MenuButton>>,
mut commands: Commands,
) {
if let Ok(disabled) = q_menu_button.get(event.focused_entity) {
event.propagate(false);
if disabled {
return;
}
let input_event = &event.input;
if !input_event.repeat && input_event.state == ButtonState::Pressed {
match input_event.key_code {
KeyCode::ArrowUp | KeyCode::ArrowLeft => {
event.propagate(false);
commands.trigger(MenuEvent {
action: MenuAction::Open(NavAction::Last),
source: event.focused_entity,
});
}
KeyCode::ArrowDown | KeyCode::ArrowRight => {
event.propagate(false);
commands.trigger(MenuEvent {
action: MenuAction::Open(NavAction::First),
source: event.focused_entity,
});
}
_ => {}
}
}
}
}
pub struct MenuPlugin;
impl Plugin for MenuPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, (menu_acquire_focus, menu_on_lose_focus).chain())
.add_observer(menu_on_key_event)
.add_observer(menu_item_on_pointer_down)
.add_observer(menu_item_on_pointer_up)
.add_observer(menu_item_on_pointer_click)
.add_observer(menu_item_on_pointer_drag_end)
.add_observer(menu_item_on_pointer_cancel)
.add_observer(menubutton_on_key_event)
.add_observer(menubutton_on_activate);
}
}