use accesskit::Role;
use bevy_a11y::AccessibilityNode;
use bevy_app::{App, Plugin, PostUpdate};
use bevy_camera::visibility::VisibilitySystems;
use bevy_ecs::{
component::Component,
entity::Entity,
event::EntityEvent,
hierarchy::ChildOf,
observer::On,
query::{Has, With},
reflect::{ReflectComponent, ReflectEvent},
schedule::{IntoScheduleConfigs, SystemSet},
system::{Commands, Query, Res, ResMut},
};
use bevy_input::{
keyboard::{KeyCode, KeyboardInput},
ButtonState,
};
use bevy_input_focus::{
tab_navigation::{NavAction, TabGroup, TabNavigation},
FocusCause, FocusedInput, InputFocus, InputFocusSystems,
};
use bevy_log::warn;
use bevy_picking::events::{
PointerCancel, PointerClick, PointerDragEnd, PointerPress, PointerRelease,
};
use bevy_reflect::Reflect;
use bevy_ui::{InteractionDisabled, Pressed, UiSystems};
use crate::{text_input::text_input_autoscroll_system, Activate, ActivateOnPress, Button};
#[derive(Clone, Copy, Debug, Reflect)]
pub enum MenuAction {
Open(NavAction),
Toggle,
CloseAll,
FocusRoot,
}
#[derive(EntityEvent, Clone, Debug)]
#[entity_event(propagate, auto_propagate)]
#[derive(Reflect)]
#[reflect(Event)]
pub struct MenuEvent {
#[event_target]
pub source: Entity,
pub action: MenuAction,
}
#[derive(Default, Debug, Clone, PartialEq, Reflect)]
pub enum MenuLayout {
#[default]
Column,
Row,
Grid,
}
#[derive(Component, Debug, Default, Clone)]
#[require(
AccessibilityNode(accesskit::Node::new(Role::MenuListPopup)),
TabGroup::modal()
)]
#[require(MenuFocusState::Closed)]
#[derive(Reflect)]
#[reflect(Component)]
pub struct MenuPopup {
pub layout: MenuLayout,
}
#[derive(Component, Debug, Clone, Default)]
#[require(AccessibilityNode(accesskit::Node::new(Role::MenuItem)))]
#[derive(Reflect)]
#[reflect(Component)]
pub struct MenuItem;
#[derive(Component, Debug, Clone, Default, PartialEq, Reflect)]
#[reflect(Component)]
pub enum MenuFocusState {
Opening(NavAction),
Open,
#[default]
Closed,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, SystemSet)]
pub struct MenuFocusSystem;
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<PointerClick>,
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<PointerPress>,
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<PointerRelease>,
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<PointerDragEnd>,
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<PointerCancel>,
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
)]
#[derive(Reflect)]
#[reflect(Component)]
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) {
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(
PostUpdate,
(
menu_acquire_focus.in_set(MenuFocusSystem),
menu_on_lose_focus,
)
.chain()
.after(VisibilitySystems::VisibilityPropagate)
.before(InputFocusSystems::FocusChangeEvents)
.before(text_input_autoscroll_system)
.before(UiSystems::PostLayout),
)
.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);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy_ecs::resource::Resource;
use bevy_input::InputPlugin;
use bevy_input_focus::{tab_navigation::TabIndex, InputFocusPlugin};
use bevy_window::{PrimaryWindow, Window};
#[derive(Resource, Default)]
struct CloseAllCount(usize);
fn count_close_all(event: On<MenuEvent>, mut count: ResMut<CloseAllCount>) {
if matches!(event.action, MenuAction::CloseAll)
&& event.event_target() == event.original_event_target()
{
count.0 += 1;
}
}
fn menu_app() -> (App, Entity) {
let mut app = App::new();
app.add_plugins((InputPlugin, InputFocusPlugin, MenuPlugin));
app.init_resource::<CloseAllCount>();
app.add_observer(count_close_all);
let window = app
.world_mut()
.spawn((Window::default(), PrimaryWindow))
.id();
app.update();
(app, window)
}
fn spawn_open_menu(app: &mut App, window: Entity) -> (Entity, Entity) {
let popup = app
.world_mut()
.spawn((MenuPopup::default(), MenuFocusState::Open, ChildOf(window)))
.id();
let item = app
.world_mut()
.spawn((MenuItem, TabIndex(0), ChildOf(popup)))
.id();
app.world_mut()
.resource_mut::<InputFocus>()
.set(item, FocusCause::Navigated);
app.update();
(popup, item)
}
#[test]
fn menu_stays_open_while_item_focused() {
let (mut app, window) = menu_app();
let (popup, _item) = spawn_open_menu(&mut app, window);
app.update();
assert_eq!(
*app.world().entity(popup).get::<MenuFocusState>().unwrap(),
MenuFocusState::Open,
"menu must remain open while one of its items holds focus"
);
assert_eq!(app.world().resource::<CloseAllCount>().0, 0);
}
#[test]
fn menu_closes_when_focus_leaves() {
let (mut app, window) = menu_app();
let (popup, _item) = spawn_open_menu(&mut app, window);
let outside = app.world_mut().spawn((TabIndex(0), ChildOf(window))).id();
app.world_mut()
.resource_mut::<InputFocus>()
.set(outside, FocusCause::Navigated);
app.update();
assert_eq!(
*app.world().entity(popup).get::<MenuFocusState>().unwrap(),
MenuFocusState::Closed,
"menu must close once focus leaves its subtree"
);
assert_eq!(
app.world().resource::<CloseAllCount>().0,
1,
"closing must fire exactly one CloseAll (no open/close/reopen flicker)"
);
app.update();
assert_eq!(app.world().resource::<CloseAllCount>().0, 1);
}
#[test]
fn menu_closes_when_focus_cleared() {
let (mut app, window) = menu_app();
let (popup, _item) = spawn_open_menu(&mut app, window);
app.world_mut().resource_mut::<InputFocus>().clear();
app.update();
assert_eq!(
*app.world().entity(popup).get::<MenuFocusState>().unwrap(),
MenuFocusState::Closed,
);
assert_eq!(app.world().resource::<CloseAllCount>().0, 1);
}
}