#![doc = include_str!("../Readme.md")]
#![forbid(missing_docs)]
#![allow(clippy::unnecessary_lazy_evaluations)]
mod commands;
#[cfg(feature = "bevy_ui")]
pub mod components;
#[cfg(feature = "cuicui_dsl")]
mod dsl;
pub mod events;
mod marker;
pub mod menu;
mod named;
mod resolve;
pub mod systems;
use std::marker::PhantomData;
use bevy::ecs::system::{SystemParam, SystemParamItem};
use bevy::prelude::*;
pub use non_empty_vec::NonEmpty;
#[cfg(feature = "bevy_ui")]
use resolve::UiProjectionQuery;
pub mod prelude {
#[cfg(feature = "cuicui_dsl")]
pub use crate::dsl::NavigationDsl;
pub use crate::events::{NavEvent, NavEventReaderExt, NavRequest};
pub use crate::menu::{MenuBuilder, MenuSetting};
pub use crate::resolve::{
FocusAction, FocusState, Focusable, Focused, MenuNavigationStrategy, NavLock,
};
pub use crate::NavRequestSystem;
#[cfg(feature = "bevy_ui")]
pub use crate::{DefaultNavigationPlugins, NavigationPlugin};
}
pub mod mark {
pub use crate::menu::NavMarker;
pub use crate::NavMarkerPropagationPlugin;
}
pub mod custom {
#[cfg(feature = "bevy_ui")]
pub use crate::resolve::UiProjectionQuery;
pub use crate::resolve::{Rect, ScreenBoundaries};
pub use crate::GenericNavigationPlugin;
}
pub struct NavMarkerPropagationPlugin<T>(PhantomData<T>);
impl<T> NavMarkerPropagationPlugin<T> {
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
NavMarkerPropagationPlugin(PhantomData)
}
}
impl<T: 'static + Sync + Send + Component + Clone> Plugin for NavMarkerPropagationPlugin<T> {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(
marker::mark_new_menus::<T>,
marker::mark_new_focusables::<T>,
),
);
}
}
#[derive(Clone, Debug, Hash, PartialEq, Eq, SystemSet)]
pub struct NavRequestSystem;
#[derive(Default)]
pub struct GenericNavigationPlugin<STGY>(PhantomData<fn() -> STGY>);
#[cfg(feature = "bevy_ui")]
pub type NavigationPlugin<'w, 's> = GenericNavigationPlugin<UiProjectionQuery<'w, 's>>;
impl<STGY: resolve::MenuNavigationStrategy> GenericNavigationPlugin<STGY> {
pub fn new() -> Self {
Self(PhantomData)
}
}
impl<STGY: SystemParam + 'static> Plugin for GenericNavigationPlugin<STGY>
where
for<'w, 's> SystemParamItem<'w, 's, STGY>: resolve::MenuNavigationStrategy,
{
fn build(&self, app: &mut App) {
#[cfg(feature = "bevy_reflect")]
app.register_type::<menu::MenuBuilder>()
.register_type::<menu::MenuSetting>()
.register_type::<resolve::Focusable>()
.register_type::<resolve::FocusAction>()
.register_type::<resolve::FocusState>()
.register_type::<resolve::LockReason>()
.register_type::<resolve::NavLock>()
.register_type::<resolve::Rect>()
.register_type::<resolve::ScreenBoundaries>()
.register_type::<resolve::TreeMenu>()
.register_type::<systems::InputMapping>();
app.add_event::<events::NavRequest>()
.add_event::<events::NavEvent>()
.insert_resource(resolve::NavLock::new())
.add_systems(
Update,
(
(resolve::set_first_focused, resolve::consistent_menu),
resolve::listen_nav_requests::<STGY>.in_set(NavRequestSystem),
)
.chain(),
)
.add_systems(
PreUpdate,
(named::resolve_named_menus, resolve::insert_tree_menus).chain(),
);
}
}
#[cfg(feature = "bevy_ui")]
pub struct DefaultNavigationPlugins;
#[cfg(feature = "bevy_ui")]
impl PluginGroup for DefaultNavigationPlugins {
fn build(self) -> bevy::app::PluginGroupBuilder {
bevy::app::PluginGroupBuilder::start::<Self>()
.add(NavigationPlugin::new())
.add(systems::DefaultNavigationSystems)
}
}
#[cfg(test)]
mod test {
use crate::prelude::*;
use bevy::{
ecs::{event::Event, world::EntityWorldMut},
prelude::*,
};
use super::*;
enum SpawnHierarchy {
Rootless(SpawnRootless),
Menu(SpawnMenu),
}
impl SpawnHierarchy {
fn spawn(self, world: &mut World) {
match self {
Self::Rootless(menu) => menu.spawn(world),
Self::Menu(menu) => menu.spawn(&mut world.spawn_empty()),
};
}
}
struct SpawnFocusable {
name: &'static str,
prioritized: bool,
child_menu: Option<SpawnMenu>,
}
impl SpawnFocusable {
fn spawn(self, mut entity: EntityWorldMut) {
let SpawnFocusable {
name,
prioritized,
child_menu,
} = self;
entity.insert(Name::new(name));
let focusable = if prioritized {
Focusable::new().prioritized()
} else {
Focusable::new()
};
entity.insert(focusable);
if let Some(child_menu) = child_menu {
unsafe {
child_menu.spawn(&mut entity.world_mut().spawn_empty());
};
std::mem::drop(entity);
}
}
}
struct SpawnMenu {
name: &'static str,
children: Vec<SpawnFocusable>,
}
impl SpawnMenu {
fn spawn(self, entity: &mut EntityWorldMut) {
let SpawnMenu { name, children } = self;
let parent_focusable = name.strip_suffix(" Menu");
let menu_builder = match parent_focusable {
Some(name) => MenuBuilder::from_named(name),
None => MenuBuilder::Root,
};
entity.insert((Name::new(name), menu_builder, MenuSetting::new()));
entity.with_children(|commands| {
for child in children.into_iter() {
child.spawn(commands.spawn_empty());
}
});
}
}
struct SpawnRootless {
focusables: Vec<SpawnFocusable>,
}
impl SpawnRootless {
fn spawn(self, world: &mut World) {
for focusable in self.focusables.into_iter() {
focusable.spawn(world.spawn_empty())
}
}
}
macro_rules! spawn_hierarchy {
( @rootless [ $( $elem_kind:ident $elem_args:tt ),* $(,)? ] ) => (
SpawnHierarchy::Rootless(SpawnRootless {
focusables: vec![ $(
spawn_hierarchy!(@elem $elem_kind $elem_args),
)* ],
})
);
( @menu $name:expr, $( $elem_name:ident $elem_args:tt ),* $(,)? ) => (
SpawnMenu {
name: $name,
children: vec![ $(
spawn_hierarchy!(@elem $elem_name $elem_args),
)* ],
}
);
( @elem prioritized ( $name:literal ) ) => (
SpawnFocusable {
name: $name,
prioritized: true,
child_menu: None,
}
);
( @elem focusable ( $name:literal ) ) => (
SpawnFocusable {
name: $name,
prioritized: false,
child_menu: None,
}
);
( @elem focusable_to ( $name:literal [ $( $submenu:tt )* ] ) ) => (
SpawnFocusable {
name: $name,
prioritized: false,
child_menu: Some( spawn_hierarchy!(@menu concat!( $name , " Menu"), $( $submenu )* ) ),
}
);
($( $elem_name:ident $elem_args:tt ),* $(,)? ) => (
SpawnHierarchy::Menu(spawn_hierarchy!(@menu "Root", $( $elem_name $elem_args ),*))
);
}
macro_rules! assert_expected_focus_change {
($app:expr, $events:expr, $expected_from:expr, $expected_to:expr $(,)?) => {
if let [NavEvent::FocusChanged { to, from }] = $events {
let actual_from = $app.name_list(&*from);
assert_eq!(&*actual_from, $expected_from);
let actual_to = $app.name_list(&*to);
assert_eq!(&*actual_to, $expected_to);
} else {
panic!(
"Expected a signle FocusChanged NavEvent, got: {:#?}",
$events
);
}
};
}
#[derive(SystemParam)]
struct MockNavigationStrategy<'w, 's> {
#[system_param(ignore)]
_f: PhantomData<fn() -> (&'w (), &'s ())>,
}
use events::Direction as D;
impl<'w, 's> MenuNavigationStrategy for MockNavigationStrategy<'w, 's> {
fn resolve_2d<'a>(&self, _: Entity, _: D, _: bool, _: &'a [Entity]) -> Option<&'a Entity> {
None
}
}
fn receive_events<E: Event + Clone>(world: &World) -> Vec<E> {
let events = world.resource::<Events<E>>();
events.iter_current_update_events().cloned().collect()
}
struct NavEcsMock {
app: App,
}
impl NavEcsMock {
fn currently_focused(&mut self) -> &str {
let mut query = self.app.world.query_filtered::<&Name, With<Focused>>();
&**query.iter(&self.app.world).next().unwrap()
}
fn kill_named(&mut self, to_kill: &str) -> Vec<NavEvent> {
let mut query = self.app.world.query::<(Entity, &Name)>();
let requested = query
.iter(&self.app.world)
.find_map(|(e, name)| (&**name == to_kill).then(|| e));
if let Some(to_kill) = requested {
self.app.world.despawn(to_kill);
}
self.app.update();
receive_events(&mut self.app.world)
}
fn name_list(&mut self, entity_list: &[Entity]) -> Vec<&str> {
let mut query = self.app.world.query::<&Name>();
entity_list
.iter()
.filter_map(|e| query.get(&self.app.world, *e).ok())
.map(|name| &**name)
.collect()
}
fn new(hierarchy: SpawnHierarchy) -> Self {
let mut app = App::new();
app.add_plugins(GenericNavigationPlugin::<MockNavigationStrategy>::new());
hierarchy.spawn(&mut app.world);
app.update();
Self { app }
}
fn run_focus_on(&mut self, entity_name: &str) -> Vec<NavEvent> {
let mut query = self.app.world.query::<(Entity, &Name)>();
let requested = query
.iter(&self.app.world)
.find_map(|(e, name)| (&**name == entity_name).then(|| e))
.unwrap();
self.app.world.send_event(NavRequest::FocusOn(requested));
self.app.update();
receive_events(&mut self.app.world)
}
fn run_request(&mut self, request: NavRequest) -> Vec<NavEvent> {
self.app.world.send_event(request);
self.app.update();
receive_events(&mut self.app.world)
}
fn state_of(&mut self, requested: &str) -> FocusState {
let mut query = self.app.world.query::<(&Focusable, &Name)>();
let requested = query
.iter(&self.app.world)
.find_map(|(focus, name)| (&**name == requested).then(|| focus));
requested.unwrap().state()
}
}
#[test]
fn move_in_menuless() {
let mut app = NavEcsMock::new(spawn_hierarchy!(@rootless [
prioritized("Initial"),
focusable("Left"),
focusable("Right"),
]));
assert_eq!(app.currently_focused(), "Initial");
app.run_focus_on("Left");
assert_eq!(app.currently_focused(), "Left");
}
#[test]
fn deep_initial_focusable() {
let mut app = NavEcsMock::new(spawn_hierarchy![
focusable("Middle"),
focusable_to("Left" [
focusable("LCenter1"),
focusable("LCenter2"),
focusable_to("LTop" [
prioritized("LTopForward"),
focusable("LTopBackward"),
]),
focusable("LCenter3"),
focusable("LBottom"),
]),
focusable("Right"),
]);
use FocusState::{Active, Inert};
assert_eq!(app.currently_focused(), "LTopForward");
assert_eq!(app.state_of("Left"), Active);
assert_eq!(app.state_of("Right"), Inert);
assert_eq!(app.state_of("Middle"), Inert);
assert_eq!(app.state_of("LTop"), Active);
assert_eq!(app.state_of("LCenter1"), Inert);
assert_eq!(app.state_of("LTopBackward"), Inert);
}
#[test]
fn move_in_complex_menu_hierarchy() {
let mut app = NavEcsMock::new(spawn_hierarchy![
prioritized("Initial"),
focusable_to("Left" [
focusable_to("LTop" [
focusable("LTopForward"),
focusable("LTopBackward"),
]),
focusable_to("LBottom" [
focusable("LBottomForward"),
focusable("LBottomForward1"),
focusable("LBottomForward2"),
prioritized("LBottomBackward"),
focusable("LBottomForward3"),
focusable("LBottomForward4"),
focusable("LBottomForward5"),
]),
]),
focusable_to("Right" [
focusable_to("RTop" [
focusable("RTopForward"),
focusable("RTopBackward"),
]),
focusable("RBottom"),
]),
]);
assert_eq!(app.currently_focused(), "Initial");
let events = app.run_focus_on("RBottom");
assert_expected_focus_change!(app, &events[..], ["Initial"], ["RBottom", "Right"]);
let events = app.run_focus_on("LTopForward");
assert_expected_focus_change!(
app,
&events[..],
["RBottom", "Right"],
["LTopForward", "LTop", "Left"],
);
let events = app.run_request(NavRequest::Cancel);
assert_expected_focus_change!(app, &events[..], ["LTopForward", "LTop"], ["LTop"]);
let events = app.run_focus_on("LBottom");
assert_expected_focus_change!(app, &events[..], ["LTop"], ["LBottom"]);
let events = app.run_request(NavRequest::Action);
assert_expected_focus_change!(
app,
&events[..],
["LBottom"],
["LBottomBackward", "LBottom"]
);
}
#[test]
fn focus_rootless_kill_robust() {
let mut app = NavEcsMock::new(spawn_hierarchy!(@rootless [
prioritized("Initial"),
focusable("Right"),
]));
assert_eq!(app.currently_focused(), "Initial");
app.kill_named("Initial");
assert_eq!(app.currently_focused(), "Right");
app.kill_named("Right");
let events = app.run_request(NavRequest::Action);
assert_eq!(events.len(), 0, "{:#?}", events);
}
#[test]
fn menu_elem_kill_robust() {
let mut app = NavEcsMock::new(spawn_hierarchy![
focusable_to("Left" [
focusable("LTop"),
focusable("LBottom"),
]),
focusable_to("Antony" [
prioritized("Caesar"),
focusable("Brutus"),
]),
focusable_to("Octavian" [
focusable("RTop"),
focusable("RBottom"),
]),
]);
assert_eq!(app.currently_focused(), "Caesar");
assert_eq!(app.state_of("Antony"), FocusState::Active);
app.kill_named("Caesar");
assert_eq!(app.currently_focused(), "Brutus");
app.kill_named("Brutus");
assert_eq!(app.currently_focused(), "Antony");
}
}