1#![doc = include_str!("../Readme.md")]
37#![forbid(missing_docs)]
38#![allow(clippy::unnecessary_lazy_evaluations)]
39
40mod commands;
41#[cfg(feature = "bevy_ui")]
42pub mod components;
43#[cfg(feature = "cuicui_dsl")]
44mod dsl;
45pub mod events;
46mod marker;
47pub mod menu;
48mod named;
49mod resolve;
50pub mod systems;
51
52use std::marker::PhantomData;
53
54use bevy::ecs::system::{SystemParam, SystemParamItem};
55use bevy::prelude::*;
56
57pub use non_empty_vec::NonEmpty;
58
59#[cfg(feature = "bevy_ui")]
60use resolve::UiProjectionQuery;
61
62pub mod prelude {
64 #[cfg(feature = "cuicui_dsl")]
65 pub use crate::dsl::NavigationDsl;
66 pub use crate::events::{NavEvent, NavEventReaderExt, NavRequest};
67 pub use crate::menu::{MenuBuilder, MenuSetting};
68 pub use crate::resolve::{
69 FocusAction, FocusState, Focusable, Focused, MenuNavigationStrategy, NavLock,
70 };
71 pub use crate::NavRequestSystem;
72 #[cfg(feature = "bevy_ui")]
73 pub use crate::{DefaultNavigationPlugins, NavigationPlugin};
74}
75pub mod mark {
77 pub use crate::menu::NavMarker;
78 pub use crate::NavMarkerPropagationPlugin;
79}
80pub mod custom {
82 #[cfg(feature = "bevy_ui")]
83 pub use crate::resolve::UiProjectionQuery;
84 pub use crate::resolve::{Rect, ScreenBoundaries};
85 pub use crate::GenericNavigationPlugin;
86}
87
88pub struct NavMarkerPropagationPlugin<T>(PhantomData<T>);
96impl<T> NavMarkerPropagationPlugin<T> {
97 #[allow(clippy::new_without_default)]
98 pub fn new() -> Self {
100 NavMarkerPropagationPlugin(PhantomData)
101 }
102}
103
104impl<T: 'static + Sync + Send + Component + Clone> Plugin for NavMarkerPropagationPlugin<T> {
105 fn build(&self, app: &mut App) {
106 app.add_systems(
107 Update,
108 (
109 marker::mark_new_menus::<T>,
110 marker::mark_new_focusables::<T>,
111 ),
112 );
113 }
114}
115
116#[derive(Clone, Debug, Hash, PartialEq, Eq, SystemSet)]
164pub struct NavRequestSystem;
165
166#[derive(Default)]
183pub struct GenericNavigationPlugin<STGY>(PhantomData<fn() -> STGY>);
184#[cfg(feature = "bevy_ui")]
185pub type NavigationPlugin<'w, 's> = GenericNavigationPlugin<UiProjectionQuery<'w, 's>>;
187
188impl<STGY: resolve::MenuNavigationStrategy> GenericNavigationPlugin<STGY> {
189 pub fn new() -> Self {
192 Self(PhantomData)
193 }
194}
195impl<STGY: SystemParam + 'static> Plugin for GenericNavigationPlugin<STGY>
196where
197 for<'w, 's> SystemParamItem<'w, 's, STGY>: resolve::MenuNavigationStrategy,
198{
199 fn build(&self, app: &mut App) {
200 #[cfg(feature = "bevy_reflect")]
201 app.register_type::<menu::MenuBuilder>()
202 .register_type::<menu::MenuSetting>()
203 .register_type::<resolve::Focusable>()
204 .register_type::<resolve::FocusAction>()
205 .register_type::<resolve::FocusState>()
206 .register_type::<resolve::LockReason>()
207 .register_type::<resolve::NavLock>()
208 .register_type::<resolve::Rect>()
209 .register_type::<resolve::ScreenBoundaries>()
210 .register_type::<resolve::TreeMenu>()
211 .register_type::<systems::InputMapping>();
212
213 app.add_event::<events::NavRequest>()
214 .add_event::<events::NavEvent>()
215 .insert_resource(resolve::NavLock::new())
216 .add_systems(
217 Update,
218 (
219 (resolve::set_first_focused, resolve::consistent_menu),
220 resolve::listen_nav_requests::<STGY>.in_set(NavRequestSystem),
221 )
222 .chain(),
223 )
224 .add_systems(
225 PreUpdate,
226 (named::resolve_named_menus, resolve::insert_tree_menus).chain(),
227 );
228 }
229}
230
231#[cfg(feature = "bevy_ui")]
239pub struct DefaultNavigationPlugins;
240#[cfg(feature = "bevy_ui")]
241impl PluginGroup for DefaultNavigationPlugins {
242 fn build(self) -> bevy::app::PluginGroupBuilder {
243 bevy::app::PluginGroupBuilder::start::<Self>()
244 .add(NavigationPlugin::new())
245 .add(systems::DefaultNavigationSystems)
246 }
247}
248
249#[cfg(test)]
250mod test {
251 use crate::prelude::*;
252 use bevy::{
253 ecs::{event::Event, world::EntityWorldMut},
254 prelude::*,
255 };
256
257 use super::*;
258 enum SpawnHierarchy {
282 Rootless(SpawnRootless),
283 Menu(SpawnMenu),
284 }
285 impl SpawnHierarchy {
286 fn spawn(self, world: &mut World) {
287 match self {
288 Self::Rootless(menu) => menu.spawn(world),
289 Self::Menu(menu) => menu.spawn(&mut world.spawn_empty()),
290 };
291 }
292 }
293 struct SpawnFocusable {
294 name: &'static str,
295 prioritized: bool,
296 child_menu: Option<SpawnMenu>,
297 }
298
299 impl SpawnFocusable {
300 fn spawn(self, mut entity: EntityWorldMut) {
301 let SpawnFocusable {
302 name,
303 prioritized,
304 child_menu,
305 } = self;
306 entity.insert(Name::new(name));
307 let focusable = if prioritized {
308 Focusable::new().prioritized()
309 } else {
310 Focusable::new()
311 };
312 entity.insert(focusable);
313 if let Some(child_menu) = child_menu {
314 unsafe {
316 child_menu.spawn(&mut entity.world_mut().spawn_empty());
317 };
318 std::mem::drop(entity);
319 }
320 }
321 }
322 struct SpawnMenu {
323 name: &'static str,
324 children: Vec<SpawnFocusable>,
325 }
326 impl SpawnMenu {
327 fn spawn(self, entity: &mut EntityWorldMut) {
328 let SpawnMenu { name, children } = self;
329 let parent_focusable = name.strip_suffix(" Menu");
330 let menu_builder = match parent_focusable {
331 Some(name) => MenuBuilder::from_named(name),
332 None => MenuBuilder::Root,
333 };
334 entity.insert((Name::new(name), menu_builder, MenuSetting::new()));
335 entity.with_children(|commands| {
336 for child in children.into_iter() {
337 child.spawn(commands.spawn_empty());
338 }
339 });
340 }
341 }
342 struct SpawnRootless {
343 focusables: Vec<SpawnFocusable>,
344 }
345 impl SpawnRootless {
346 fn spawn(self, world: &mut World) {
347 for focusable in self.focusables.into_iter() {
348 focusable.spawn(world.spawn_empty())
349 }
350 }
351 }
352 macro_rules! spawn_hierarchy {
368 ( @rootless [ $( $elem_kind:ident $elem_args:tt ),* $(,)? ] ) => (
369 SpawnHierarchy::Rootless(SpawnRootless {
370 focusables: vec![ $(
371 spawn_hierarchy!(@elem $elem_kind $elem_args),
372 )* ],
373 })
374 );
375 ( @menu $name:expr, $( $elem_name:ident $elem_args:tt ),* $(,)? ) => (
376 SpawnMenu {
377 name: $name,
378 children: vec![ $(
379 spawn_hierarchy!(@elem $elem_name $elem_args),
380 )* ],
381 }
382 );
383 ( @elem prioritized ( $name:literal ) ) => (
384 SpawnFocusable {
385 name: $name,
386 prioritized: true,
387 child_menu: None,
388 }
389 );
390 ( @elem focusable ( $name:literal ) ) => (
391 SpawnFocusable {
392 name: $name,
393 prioritized: false,
394 child_menu: None,
395 }
396 );
397 ( @elem focusable_to ( $name:literal [ $( $submenu:tt )* ] ) ) => (
398 SpawnFocusable {
399 name: $name,
400 prioritized: false,
401 child_menu: Some( spawn_hierarchy!(@menu concat!( $name , " Menu"), $( $submenu )* ) ),
402 }
403 );
404 ($( $elem_name:ident $elem_args:tt ),* $(,)? ) => (
405 SpawnHierarchy::Menu(spawn_hierarchy!(@menu "Root", $( $elem_name $elem_args ),*))
406 );
407 }
408
409 macro_rules! assert_expected_focus_change {
418 ($app:expr, $events:expr, $expected_from:expr, $expected_to:expr $(,)?) => {
419 if let [NavEvent::FocusChanged { to, from }] = $events {
420 let actual_from = $app.name_list(&*from);
421 assert_eq!(&*actual_from, $expected_from);
422
423 let actual_to = $app.name_list(&*to);
424 assert_eq!(&*actual_to, $expected_to);
425 } else {
426 panic!(
427 "Expected a signle FocusChanged NavEvent, got: {:#?}",
428 $events
429 );
430 }
431 };
432 }
433
434 #[derive(SystemParam)]
436 struct MockNavigationStrategy<'w, 's> {
437 #[system_param(ignore)]
438 _f: PhantomData<fn() -> (&'w (), &'s ())>,
439 }
440 use events::Direction as D;
442 impl<'w, 's> MenuNavigationStrategy for MockNavigationStrategy<'w, 's> {
443 fn resolve_2d<'a>(&self, _: Entity, _: D, _: bool, _: &'a [Entity]) -> Option<&'a Entity> {
444 None
445 }
446 }
447 fn receive_events<E: Event + Clone>(world: &World) -> Vec<E> {
448 let events = world.resource::<Events<E>>();
449 events.iter_current_update_events().cloned().collect()
450 }
451
452 struct NavEcsMock {
454 app: App,
455 }
456 impl NavEcsMock {
457 fn currently_focused(&mut self) -> &str {
458 let mut query = self.app.world.query_filtered::<&Name, With<Focused>>();
459 &**query.iter(&self.app.world).next().unwrap()
460 }
461 fn kill_named(&mut self, to_kill: &str) -> Vec<NavEvent> {
462 let mut query = self.app.world.query::<(Entity, &Name)>();
463 let requested = query
464 .iter(&self.app.world)
465 .find_map(|(e, name)| (&**name == to_kill).then(|| e));
466 if let Some(to_kill) = requested {
467 self.app.world.despawn(to_kill);
468 }
469 self.app.update();
470 receive_events(&mut self.app.world)
471 }
472 fn name_list(&mut self, entity_list: &[Entity]) -> Vec<&str> {
473 let mut query = self.app.world.query::<&Name>();
474 entity_list
475 .iter()
476 .filter_map(|e| query.get(&self.app.world, *e).ok())
477 .map(|name| &**name)
478 .collect()
479 }
480 fn new(hierarchy: SpawnHierarchy) -> Self {
481 let mut app = App::new();
482 app.add_plugins(GenericNavigationPlugin::<MockNavigationStrategy>::new());
483 hierarchy.spawn(&mut app.world);
484 app.update();
486
487 Self { app }
488 }
489 fn run_focus_on(&mut self, entity_name: &str) -> Vec<NavEvent> {
490 let mut query = self.app.world.query::<(Entity, &Name)>();
491 let requested = query
492 .iter(&self.app.world)
493 .find_map(|(e, name)| (&**name == entity_name).then(|| e))
494 .unwrap();
495 self.app.world.send_event(NavRequest::FocusOn(requested));
496 self.app.update();
497 receive_events(&mut self.app.world)
498 }
499 fn run_request(&mut self, request: NavRequest) -> Vec<NavEvent> {
500 self.app.world.send_event(request);
501 self.app.update();
502 receive_events(&mut self.app.world)
503 }
504 fn state_of(&mut self, requested: &str) -> FocusState {
505 let mut query = self.app.world.query::<(&Focusable, &Name)>();
506 let requested = query
507 .iter(&self.app.world)
508 .find_map(|(focus, name)| (&**name == requested).then(|| focus));
509 requested.unwrap().state()
510 }
511 }
512
513 #[test]
518 fn move_in_menuless() {
519 let mut app = NavEcsMock::new(spawn_hierarchy!(@rootless [
520 prioritized("Initial"),
521 focusable("Left"),
522 focusable("Right"),
523 ]));
524 assert_eq!(app.currently_focused(), "Initial");
525 app.run_focus_on("Left");
526 assert_eq!(app.currently_focused(), "Left");
527 }
528
529 #[test]
530 fn deep_initial_focusable() {
531 let mut app = NavEcsMock::new(spawn_hierarchy![
532 focusable("Middle"),
533 focusable_to("Left" [
534 focusable("LCenter1"),
535 focusable("LCenter2"),
536 focusable_to("LTop" [
537 prioritized("LTopForward"),
538 focusable("LTopBackward"),
539 ]),
540 focusable("LCenter3"),
541 focusable("LBottom"),
542 ]),
543 focusable("Right"),
544 ]);
545 use FocusState::{Active, Inert};
546 assert_eq!(app.currently_focused(), "LTopForward");
547 assert_eq!(app.state_of("Left"), Active);
548 assert_eq!(app.state_of("Right"), Inert);
549 assert_eq!(app.state_of("Middle"), Inert);
550 assert_eq!(app.state_of("LTop"), Active);
551 assert_eq!(app.state_of("LCenter1"), Inert);
552 assert_eq!(app.state_of("LTopBackward"), Inert);
553 }
554
555 #[test]
556 fn move_in_complex_menu_hierarchy() {
557 let mut app = NavEcsMock::new(spawn_hierarchy![
558 prioritized("Initial"),
559 focusable_to("Left" [
560 focusable_to("LTop" [
561 focusable("LTopForward"),
562 focusable("LTopBackward"),
563 ]),
564 focusable_to("LBottom" [
565 focusable("LBottomForward"),
566 focusable("LBottomForward1"),
567 focusable("LBottomForward2"),
568 prioritized("LBottomBackward"),
569 focusable("LBottomForward3"),
570 focusable("LBottomForward4"),
571 focusable("LBottomForward5"),
572 ]),
573 ]),
574 focusable_to("Right" [
575 focusable_to("RTop" [
576 focusable("RTopForward"),
577 focusable("RTopBackward"),
578 ]),
579 focusable("RBottom"),
580 ]),
581 ]);
582 assert_eq!(app.currently_focused(), "Initial");
583
584 let events = app.run_focus_on("RBottom");
586 assert_expected_focus_change!(app, &events[..], ["Initial"], ["RBottom", "Right"]);
587
588 let events = app.run_focus_on("LTopForward");
590 assert_expected_focus_change!(
591 app,
592 &events[..],
593 ["RBottom", "Right"],
594 ["LTopForward", "LTop", "Left"],
595 );
596 let events = app.run_request(NavRequest::Cancel);
598 assert_expected_focus_change!(app, &events[..], ["LTopForward", "LTop"], ["LTop"]);
599
600 let events = app.run_focus_on("LBottom");
602 assert_expected_focus_change!(app, &events[..], ["LTop"], ["LBottom"]);
603
604 let events = app.run_request(NavRequest::Action);
607 assert_expected_focus_change!(
608 app,
609 &events[..],
610 ["LBottom"],
611 ["LBottomBackward", "LBottom"]
612 );
613 }
614
615 #[test]
621 fn focus_rootless_kill_robust() {
622 let mut app = NavEcsMock::new(spawn_hierarchy!(@rootless [
623 prioritized("Initial"),
624 focusable("Right"),
625 ]));
626 assert_eq!(app.currently_focused(), "Initial");
627 app.kill_named("Initial");
628 assert_eq!(app.currently_focused(), "Right");
629
630 app.kill_named("Right");
631 let events = app.run_request(NavRequest::Action);
632 assert_eq!(events.len(), 0, "{:#?}", events);
633 }
634
635 #[test]
638 fn menu_elem_kill_robust() {
639 let mut app = NavEcsMock::new(spawn_hierarchy![
640 focusable_to("Left" [
641 focusable("LTop"),
642 focusable("LBottom"),
643 ]),
644 focusable_to("Antony" [
645 prioritized("Caesar"),
646 focusable("Brutus"),
647 ]),
648 focusable_to("Octavian" [
649 focusable("RTop"),
650 focusable("RBottom"),
651 ]),
652 ]);
653 assert_eq!(app.currently_focused(), "Caesar");
657 assert_eq!(app.state_of("Antony"), FocusState::Active);
658 app.kill_named("Caesar");
659 assert_eq!(app.currently_focused(), "Brutus");
660 app.kill_named("Brutus");
661 assert_eq!(app.currently_focused(), "Antony");
662 }
663
664 }