From 8c7efe538eb00cbdd1a7516eaec9043ad2375f1a Mon Sep 17 00:00:00 2001 From: Nicola Papale Date: Tue, 19 Jul 2022 14:31:53 +0200 Subject: [PATCH] Initial port of bevy_ui_navigation to bevy --- Cargo.toml | 43 ++ crates/bevy_internal/Cargo.toml | 1 + crates/bevy_internal/src/lib.rs | 6 + crates/bevy_ui/Cargo.toml | 1 + crates/bevy_ui/src/entity.rs | 26 +- crates/bevy_ui/src/lib.rs | 3 +- crates/bevy_ui/src/navigation/mod.rs | 2 + crates/bevy_ui/src/navigation/queries.rs | 105 +++ crates/bevy_ui/src/navigation/systems.rs | 438 +++++++++++ crates/bevy_ui_navigation/Cargo.toml | 24 + crates/bevy_ui_navigation/README.md | 513 +++++++++++++ crates/bevy_ui_navigation/src/commands.rs | 21 + .../bevy_ui_navigation/src/event_helpers.rs | 485 ++++++++++++ crates/bevy_ui_navigation/src/events.rs | 118 +++ crates/bevy_ui_navigation/src/lib.rs | 94 +++ crates/bevy_ui_navigation/src/named.rs | 45 ++ crates/bevy_ui_navigation/src/resolve.rs | 695 ++++++++++++++++++ crates/bevy_ui_navigation/src/seeds.rs | 288 ++++++++ examples/ui_navigation/locking.rs | 104 +++ examples/ui_navigation/menu_navigation.rs | 198 +++++ examples/ui_navigation/nav_3d.rs | 0 .../ui_navigation/off_screen_focusables.rs | 61 ++ tools/publish.sh | 1 + 23 files changed, 3249 insertions(+), 23 deletions(-) create mode 100644 crates/bevy_ui/src/navigation/mod.rs create mode 100644 crates/bevy_ui/src/navigation/queries.rs create mode 100644 crates/bevy_ui/src/navigation/systems.rs create mode 100644 crates/bevy_ui_navigation/Cargo.toml create mode 100644 crates/bevy_ui_navigation/README.md create mode 100644 crates/bevy_ui_navigation/src/commands.rs create mode 100644 crates/bevy_ui_navigation/src/event_helpers.rs create mode 100644 crates/bevy_ui_navigation/src/events.rs create mode 100644 crates/bevy_ui_navigation/src/lib.rs create mode 100644 crates/bevy_ui_navigation/src/named.rs create mode 100644 crates/bevy_ui_navigation/src/resolve.rs create mode 100644 crates/bevy_ui_navigation/src/seeds.rs create mode 100644 examples/ui_navigation/locking.rs create mode 100644 examples/ui_navigation/menu_navigation.rs create mode 100644 examples/ui_navigation/nav_3d.rs create mode 100644 examples/ui_navigation/off_screen_focusables.rs diff --git a/Cargo.toml b/Cargo.toml index ba1947661a0784..2a5af0a877a13b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1427,6 +1427,49 @@ description = "Illustrates various features of Bevy UI" category = "UI (User Interface)" wasm = true + +# UI navigation +[[example]] +name = "locking" +path = "examples/ui_navigation/locking.rs" + +[package.metadata.example.locking] +name = "UI locking" +description = "Illustrates UI locking" +category = "UI navigation" +wasm = true + +[[example]] +name = "menu_navigation" +path = "examples/ui_navigation/menu_navigation.rs" + +[package.metadata.example.menu_navigation] +name = "menu navigation" +description = "Illustrates movement between menus" +category = "UI navigation" +wasm = true + +[[example]] +name = "nav_3d" +path = "examples/ui_navigation/nav_3d.rs" + +[package.metadata.example.nav_3d] +name = "UI in 3d world" +description = "Illustrates using Focusable on 3d entities" +category = "UI navigation" +wasm = true + +[[example]] +name = "off_screen_focusables" +path = "examples/ui_navigation/off_screen_focusables.rs" + +[package.metadata.example.off_screen_focusables] +name = "Focusable wrapping" +description = "Illustrates wrapping within a menu" +category = "UI navigation" +wasm = true + + # Window [[example]] name = "clear_color" diff --git a/crates/bevy_internal/Cargo.toml b/crates/bevy_internal/Cargo.toml index 7fccda37863d09..4c72884e3a64d8 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -94,6 +94,7 @@ bevy_dynamic_plugin = { path = "../bevy_dynamic_plugin", optional = true, versio bevy_sprite = { path = "../bevy_sprite", optional = true, version = "0.8.0-dev" } bevy_text = { path = "../bevy_text", optional = true, version = "0.8.0-dev" } bevy_ui = { path = "../bevy_ui", optional = true, version = "0.8.0-dev" } +bevy_ui_navigation = { path = "../bevy_ui_navigation", optional = true, version = "0.8.0-dev" } bevy_winit = { path = "../bevy_winit", optional = true, version = "0.8.0-dev" } bevy_gilrs = { path = "../bevy_gilrs", optional = true, version = "0.8.0-dev" } diff --git a/crates/bevy_internal/src/lib.rs b/crates/bevy_internal/src/lib.rs index cc63909ab3cb81..1a05145b15d2c6 100644 --- a/crates/bevy_internal/src/lib.rs +++ b/crates/bevy_internal/src/lib.rs @@ -159,6 +159,12 @@ pub mod ui { pub use bevy_ui::*; } +#[cfg(feature = "bevy_ui")] +pub mod ui_navigation { + //! User interface navigation. + pub use bevy_ui_navigation::*; +} + #[cfg(feature = "bevy_winit")] pub mod winit { //! Window creation, configuration, and handling diff --git a/crates/bevy_ui/Cargo.toml b/crates/bevy_ui/Cargo.toml index 170b26b0ff0d5c..4da6129558285b 100644 --- a/crates/bevy_ui/Cargo.toml +++ b/crates/bevy_ui/Cargo.toml @@ -27,6 +27,7 @@ bevy_sprite = { path = "../bevy_sprite", version = "0.8.0-dev" } bevy_text = { path = "../bevy_text", version = "0.8.0-dev" } bevy_transform = { path = "../bevy_transform", version = "0.8.0-dev" } bevy_window = { path = "../bevy_window", version = "0.8.0-dev" } +bevy_ui_navigation = { path = "../bevy_ui_navigation", version = "0.8.0-dev" } bevy_utils = { path = "../bevy_utils", version = "0.8.0-dev" } # other diff --git a/crates/bevy_ui/src/entity.rs b/crates/bevy_ui/src/entity.rs index 5345ee3dd67264..5cddfeb92f3c88 100644 --- a/crates/bevy_ui/src/entity.rs +++ b/crates/bevy_ui/src/entity.rs @@ -15,6 +15,7 @@ use bevy_render::{ }; use bevy_text::Text; use bevy_transform::prelude::{GlobalTransform, Transform}; +use bevy_ui_navigation::Focusable; /// The basic UI node #[derive(Bundle, Clone, Debug, Default)] @@ -106,7 +107,7 @@ impl Default for TextBundle { } /// A UI node that is a button -#[derive(Bundle, Clone, Debug)] +#[derive(Bundle, Clone, Debug, Default)] pub struct ButtonBundle { /// Describes the size of the node pub node: Node, @@ -114,10 +115,8 @@ pub struct ButtonBundle { pub button: Button, /// Describes the style including flexbox settings pub style: Style, - /// Describes whether and how the button has been interacted with by the input - pub interaction: Interaction, - /// Whether this node should block interaction with lower nodes - pub focus_policy: FocusPolicy, + /// How this button fits with other ones + pub focusable: Focusable, /// The color of the node pub color: UiColor, /// The image of the node @@ -132,23 +131,6 @@ pub struct ButtonBundle { pub computed_visibility: ComputedVisibility, } -impl Default for ButtonBundle { - fn default() -> Self { - ButtonBundle { - button: Button, - interaction: Default::default(), - focus_policy: Default::default(), - node: Default::default(), - style: Default::default(), - color: Default::default(), - image: Default::default(), - transform: Default::default(), - global_transform: Default::default(), - visibility: Default::default(), - computed_visibility: Default::default(), - } - } -} /// Configuration for cameras related to UI. /// /// When a [`Camera`] doesn't have the [`UiCameraConfig`] component, diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index 54a0e75189f3bf..b527d82dbc8c67 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -3,8 +3,9 @@ //! Spawn UI elements with [`entity::ButtonBundle`], [`entity::ImageBundle`], [`entity::TextBundle`] and [`entity::NodeBundle`] //! This UI is laid out with the Flexbox paradigm (see ) except the vertical axis is inverted mod flex; -mod focus; +// mod focus; mod geometry; +mod navigation; mod render; mod ui_node; diff --git a/crates/bevy_ui/src/navigation/mod.rs b/crates/bevy_ui/src/navigation/mod.rs new file mode 100644 index 00000000000000..d283fd01a7ce77 --- /dev/null +++ b/crates/bevy_ui/src/navigation/mod.rs @@ -0,0 +1,2 @@ +mod queries; +mod systems; diff --git a/crates/bevy_ui/src/navigation/queries.rs b/crates/bevy_ui/src/navigation/queries.rs new file mode 100644 index 00000000000000..f47b2e22e69514 --- /dev/null +++ b/crates/bevy_ui/src/navigation/queries.rs @@ -0,0 +1,105 @@ +#[derive(Debug, Clone, Copy)] +pub struct Rect { + pub min: Vec2, + pub max: Vec2, +} +/// Camera modifiers for movement cycling. +/// +/// This is only used by the cycling routine to find [`Focusable`]s at the +/// opposite side of the screen. It's expected to contain the ui camera +/// projection screen boundaries and position. See implementation of +/// [`systems::update_boundaries`](crate::systems::update_boundaries) to +/// see how to implement it yourself. +/// +/// # Note +/// +/// This is a [resource](Res). It is optional and will log a warning if +/// a cycling request is made and it does not exist. +#[derive(Debug)] +pub struct ScreenBoundaries { + pub position: Vec2, + pub screen_edge: Rect, + pub scale: f32, +} + +/// System parameter for the default cursor navigation system. +/// +/// It uses the bevy [`GlobalTransform`] to compute relative positions +/// and change focus to the correct entity. +/// It uses the [`ScreenBoundaries`] resource to compute screen boundaries +/// and move the cursor accordingly when it reaches a screen border +/// in a cycling menu. +#[derive(SystemParam)] +pub struct UiProjectionQuery<'w, 's> { + boundaries: Option>, + transforms: Query<'w, 's, &'static GlobalTransform>, +} +impl<'w, 's> MoveParam for UiProjectionQuery<'w, 's> { + fn resolve_2d<'a>( + &self, + focused: Entity, + direction: events::Direction, + cycles: bool, + siblings: &'a [Entity], + ) -> Option<&'a Entity> { + use events::Direction::*; + + let pos_of = |entity: Entity| { + self.transforms + .get(entity) + .expect("Focusable entities must have a GlobalTransform component") + .translation() + .xy() + }; + let focused_pos = pos_of(focused); + let closest = siblings.iter().filter(|sibling| { + direction.is_in(focused_pos, pos_of(**sibling)) && **sibling != focused + }); + let closest = max_by_in_iter(closest, |s| -focused_pos.distance_squared(pos_of(**s))); + match (closest, self.boundaries.as_ref()) { + (None, None) if cycles => { + warn!( + "Tried to move in {direction:?} from Focusable {focused:?} while no other \ + Focusables were there. There were no `Res`, so we couldn't \ + compute the screen edges for cycling. Make sure you either add the \ + bevy_ui_navigation::systems::update_boundaries system to your app or implement \ + your own routine to manage a `Res`." + ); + None + } + (None, Some(boundaries)) if cycles => { + let (x, y) = (boundaries.position.x, boundaries.position.y); + let edge = boundaries.screen_edge; + let scale = boundaries.scale; + let focused_pos = match direction { + // NOTE: up/down axises are inverted in bevy + North => Vec2::new(focused_pos.x, y - scale * edge.min.y), + South => Vec2::new(focused_pos.x, y + scale * edge.max.y), + East => Vec2::new(x - edge.min.x * scale, focused_pos.y), + West => Vec2::new(x + edge.max.x * scale, focused_pos.y), + }; + max_by_in_iter(siblings.iter(), |s| { + -focused_pos.distance_squared(pos_of(**s)) + }) + } + (anyelse, _) => anyelse, + } + } +} + +pub type NavigationPlugin<'w, 's> = GenericNavigationPlugin>; + +/// The navigation plugin and the default input scheme. +/// +/// Add it to your app with `.add_plugins(DefaultNavigationPlugins)`. +/// +/// This provides default implementations for input handling, if you want +/// your own custom input handling, you should use [`NavigationPlugin`] and +/// provide your own input handling systems. +pub struct DefaultNavigationPlugins; +impl PluginGroup for DefaultNavigationPlugins { + fn build(&mut self, group: &mut bevy::app::PluginGroupBuilder) { + group.add(GenericNavigationPlugin::::new()); + group.add(DefaultNavigationSystems); + } +} diff --git a/crates/bevy_ui/src/navigation/systems.rs b/crates/bevy_ui/src/navigation/systems.rs new file mode 100644 index 00000000000000..df8da5fc0161c5 --- /dev/null +++ b/crates/bevy_ui/src/navigation/systems.rs @@ -0,0 +1,438 @@ +//! System for the navigation tree and default input systems to get started. + +use bevy_ecs::system::SystemParam; +use bevy_input::prelude::*; +use bevy_ui_navigation::{ + events::{Direction, NavRequest, ScopeDirection}, + resolve::{max_by_in_iter, ScreenBoundaries}, + Focusable, Focused, Rect, +}; + +/// Control default ui navigation input buttons +pub struct InputMapping { + /// Whether to use keybaord keys for navigation (instead of just actions). + pub keyboard_navigation: bool, + /// The gamepads to use for the UI. If empty, default to gamepad 0 + pub gamepads: Vec, + /// Deadzone on the gamepad left stick for ui navigation + pub joystick_ui_deadzone: f32, + /// X axis of gamepad stick + pub move_x: GamepadAxisType, + /// Y axis of gamepad stick + pub move_y: GamepadAxisType, + /// Gamepad button for [`Direction::West`] [`NavRequest::Move`] + pub left_button: GamepadButtonType, + /// Gamepad button for [`Direction::East`] [`NavRequest::Move`] + pub right_button: GamepadButtonType, + /// Gamepad button for [`Direction::North`] [`NavRequest::Move`] + pub up_button: GamepadButtonType, + /// Gamepad button for [`Direction::South`] [`NavRequest::Move`] + pub down_button: GamepadButtonType, + /// Gamepad button for [`NavRequest::Action`] + pub action_button: GamepadButtonType, + /// Gamepad button for [`NavRequest::Cancel`] + pub cancel_button: GamepadButtonType, + /// Gamepad button for [`ScopeDirection::Previous`] [`NavRequest::ScopeMove`] + pub previous_button: GamepadButtonType, + /// Gamepad button for [`ScopeDirection::Next`] [`NavRequest::ScopeMove`] + pub next_button: GamepadButtonType, + /// Gamepad button for [`NavRequest::Free`] + pub free_button: GamepadButtonType, + /// Keyboard key for [`Direction::West`] [`NavRequest::Move`] + pub key_left: KeyCode, + /// Keyboard key for [`Direction::East`] [`NavRequest::Move`] + pub key_right: KeyCode, + /// Keyboard key for [`Direction::North`] [`NavRequest::Move`] + pub key_up: KeyCode, + /// Keyboard key for [`Direction::South`] [`NavRequest::Move`] + pub key_down: KeyCode, + /// Alternative keyboard key for [`Direction::West`] [`NavRequest::Move`] + pub key_left_alt: KeyCode, + /// Alternative keyboard key for [`Direction::East`] [`NavRequest::Move`] + pub key_right_alt: KeyCode, + /// Alternative keyboard key for [`Direction::North`] [`NavRequest::Move`] + pub key_up_alt: KeyCode, + /// Alternative keyboard key for [`Direction::South`] [`NavRequest::Move`] + pub key_down_alt: KeyCode, + /// Keyboard key for [`NavRequest::Action`] + pub key_action: KeyCode, + /// Keyboard key for [`NavRequest::Cancel`] + pub key_cancel: KeyCode, + /// Keyboard key for [`ScopeDirection::Next`] [`NavRequest::ScopeMove`] + pub key_next: KeyCode, + /// Alternative keyboard key for [`ScopeDirection::Next`] [`NavRequest::ScopeMove`] + pub key_next_alt: KeyCode, + /// Keyboard key for [`ScopeDirection::Previous`] [`NavRequest::ScopeMove`] + pub key_previous: KeyCode, + /// Keyboard key for [`NavRequest::Free`] + pub key_free: KeyCode, + /// Mouse button for [`NavRequest::Action`] + pub mouse_action: MouseButton, +} +impl Default for InputMapping { + fn default() -> Self { + InputMapping { + keyboard_navigation: false, + gamepads: vec![Gamepad { id: 0 }], + joystick_ui_deadzone: 0.36, + move_x: GamepadAxisType::LeftStickX, + move_y: GamepadAxisType::LeftStickY, + left_button: GamepadButtonType::DPadLeft, + right_button: GamepadButtonType::DPadRight, + up_button: GamepadButtonType::DPadUp, + down_button: GamepadButtonType::DPadDown, + action_button: GamepadButtonType::South, + cancel_button: GamepadButtonType::East, + previous_button: GamepadButtonType::LeftTrigger, + next_button: GamepadButtonType::RightTrigger, + free_button: GamepadButtonType::Start, + key_left: KeyCode::A, + key_right: KeyCode::D, + key_up: KeyCode::W, + key_down: KeyCode::S, + key_left_alt: KeyCode::Left, + key_right_alt: KeyCode::Right, + key_up_alt: KeyCode::Up, + key_down_alt: KeyCode::Down, + key_action: KeyCode::Space, + key_cancel: KeyCode::Back, + key_next: KeyCode::E, + key_next_alt: KeyCode::Tab, + key_previous: KeyCode::Q, + key_free: KeyCode::Escape, + mouse_action: MouseButton::Left, + } + } +} + +/// `mapping { XYZ::X => ABC::A, XYZ::Y => ABC::B, XYZ::Z => ABC::C }: [(XYZ, ABC)]` +macro_rules! mapping { + ($($from:expr => $to:expr),* ) => ([$( ( $from, $to ) ),*]) +} + +/// A system to send gamepad control events to the focus system +/// +/// Dpad and left stick for movement, `LT` and `RT` for scopped menus, `A` `B` +/// for selection and cancel. +/// +/// The button mapping may be controlled through the [`InputMapping`] resource. +/// You may however need to customize the behavior of this system (typically +/// when integrating in the game) in this case, you should write your own +/// system that sends [`NavRequest`](crate::NavRequest) events +pub fn default_gamepad_input( + mut nav_cmds: EventWriter, + has_focused: Query>, + input_mapping: Res, + buttons: Res>, + axis: Res>, + mut ui_input_status: Local, +) { + use Direction::*; + use NavRequest::{Action, Cancel, Free, Move, ScopeMove}; + + if has_focused.is_empty() { + // Do not compute navigation if there is no focus to change + return; + } + + for &gamepad in &input_mapping.gamepads { + macro_rules! axis_delta { + ($dir:ident, $axis:ident) => {{ + let axis_type = input_mapping.$axis; + axis.get(GamepadAxis { gamepad, axis_type }) + .map_or(Vec2::ZERO, |v| Vec2::$dir * v) + }}; + } + + let delta = axis_delta!(Y, move_y) + axis_delta!(X, move_x); + if delta.length_squared() > input_mapping.joystick_ui_deadzone && !*ui_input_status { + let direction = match () { + () if delta.y < delta.x && delta.y < -delta.x => South, + () if delta.y < delta.x => East, + () if delta.y >= delta.x && delta.y > -delta.x => North, + () => West, + }; + nav_cmds.send(Move(direction)); + *ui_input_status = true; + } else if delta.length_squared() <= input_mapping.joystick_ui_deadzone { + *ui_input_status = false; + } + + let command_mapping = mapping! { + input_mapping.action_button => Action, + input_mapping.cancel_button => Cancel, + input_mapping.left_button => Move(Direction::West), + input_mapping.right_button => Move(Direction::East), + input_mapping.up_button => Move(Direction::North), + input_mapping.down_button => Move(Direction::South), + input_mapping.next_button => ScopeMove(ScopeDirection::Next), + input_mapping.free_button => Free, + input_mapping.previous_button => ScopeMove(ScopeDirection::Previous) + }; + for (button_type, request) in command_mapping { + let button = GamepadButton { + gamepad, + button_type, + }; + if buttons.just_pressed(button) { + nav_cmds.send(request) + } + } + } +} + +/// A system to send keyboard control events to the focus system. +/// +/// supports `WASD` and arrow keys for the directions, `E`, `Q` and `Tab` for +/// scopped menus, `Backspace` and `Enter` for cancel and selection. +/// +/// The button mapping may be controlled through the [`InputMapping`] resource. +/// You may however need to customize the behavior of this system (typically +/// when integrating in the game) in this case, you should write your own +/// system that sends [`NavRequest`](crate::NavRequest) events. +pub fn default_keyboard_input( + has_focused: Query<(), With>, + keyboard: Res>, + input_mapping: Res, + mut nav_cmds: EventWriter, +) { + use Direction::*; + use NavRequest::*; + + if has_focused.is_empty() { + // Do not compute navigation if there is no focus to change + return; + } + + let with_movement = mapping! { + input_mapping.key_up => Move(North), + input_mapping.key_down => Move(South), + input_mapping.key_left => Move(West), + input_mapping.key_right => Move(East), + input_mapping.key_up_alt => Move(North), + input_mapping.key_down_alt => Move(South), + input_mapping.key_left_alt => Move(West), + input_mapping.key_right_alt => Move(East) + }; + let without_movement = mapping! { + input_mapping.key_action => Action, + input_mapping.key_cancel => Cancel, + input_mapping.key_next => ScopeMove(ScopeDirection::Next), + input_mapping.key_next_alt => ScopeMove(ScopeDirection::Next), + input_mapping.key_free => Free, + input_mapping.key_previous => ScopeMove(ScopeDirection::Previous) + }; + let mut send_command = |&(key, request)| { + if keyboard.just_pressed(key) { + nav_cmds.send(request) + } + }; + if input_mapping.keyboard_navigation { + with_movement.iter().for_each(&mut send_command); + } + without_movement.iter().for_each(send_command); +} + +/// [`SystemParam`](https://docs.rs/bevy/0.7.0/bevy/ecs/system/trait.SystemParam.html) +/// used to compute UI focusable physical positions in mouse input systems. +#[derive(SystemParam)] +pub struct NodePosQuery<'w, 's, T: Component> { + entities: Query<'w, 's, (Entity, &'static T, &'static GlobalTransform), With>, + boundaries: Option>, +} +impl<'w, 's, T: Component> NodePosQuery<'w, 's, T> { + fn cursor_pos(&self, at: Vec2) -> Option { + let boundaries = self.boundaries.as_ref()?; + Some(at * boundaries.scale + boundaries.position) + } +} + +fn is_in_node(at: Vec2, (_, node, trans): &(Entity, &T, &GlobalTransform)) -> bool { + let ui_pos = trans.translation().truncate(); + let node_half_size = node.size() / 2.0; + let min = ui_pos - node_half_size; + let max = ui_pos + node_half_size; + (min.x..max.x).contains(&at.x) && (min.y..max.y).contains(&at.y) +} + +/// Check which [`Focusable`] is at position `at` if any. +/// +/// NOTE: returns `None` if there is no [`ScreenBoundaries`] resource. +pub fn ui_focusable_at(at: Vec2, query: &NodePosQuery) -> Option +where + T: ScreenSize + Component, +{ + let world_at = query.cursor_pos(at)?; + let under_mouse = query + .entities + .iter() + .filter(|query_elem| is_in_node(world_at, query_elem)); + max_by_in_iter(under_mouse, |elem| elem.2.translation().z).map(|elem| elem.0) +} + +fn cursor_pos(windows: &Windows) -> Option { + windows.get_primary().and_then(|w| w.cursor_position()) +} + +pub trait ScreenSize { + fn size(&self) -> Vec2; +} + +impl ScreenSize for Node { + fn size(&self) -> Vec2 { + self.size + } +} + +/// A system to send mouse control events to the focus system +/// +/// Unlike [`generic_default_mouse_input`], this system is gated by the +/// `bevy::render::Camera` and `bevy::ui::Node`. +/// +/// Which button to press to cause an action event is specified in the +/// [`InputMapping`] resource. +/// +/// You may however need to customize the behavior of this system (typically +/// when integrating in the game) in this case, you should write your own +/// system that sends [`NavRequest`](crate::NavRequest) events. You may use +/// [`ui_focusable_at`] to tell which focusable is currently being hovered. +pub fn default_mouse_input( + input_mapping: Res, + windows: Res, + mouse: Res>, + focusables: NodePosQuery, + focused: Query>, + nav_cmds: EventWriter, + last_pos: Local, +) { + generic_default_mouse_input( + input_mapping, + windows, + mouse, + focusables, + focused, + nav_cmds, + last_pos, + ); +} + +/// A generic system to send mouse control events to the focus system +/// +/// `T` must be a component assigned to `Focusable` elements that implements +/// the [`ScreenSize`] trait. +/// +/// Which button to press to cause an action event is specified in the +/// [`InputMapping`] resource. +/// +/// You may however need to customize the behavior of this system (typically +/// when integrating in the game) in this case, you should write your own +/// system that sends [`NavRequest`](crate::NavRequest) events. You may use +/// [`ui_focusable_at`] to tell which focusable is currently being hovered. +pub fn generic_default_mouse_input( + input_mapping: Res, + windows: Res, + mouse: Res>, + focusables: NodePosQuery, + focused: Query>, + mut nav_cmds: EventWriter, + mut last_pos: Local, +) { + let cursor_pos = match cursor_pos(&windows) { + Some(c) => c, + None => return, + }; + let world_cursor_pos = match focusables.cursor_pos(cursor_pos) { + Some(c) => c, + None => return, + }; + let released = mouse.just_released(input_mapping.mouse_action); + let focused = focused.get_single(); + // Return early if cursor didn't move since last call + let camera_moved = focusables.boundaries.map_or(false, |b| b.is_changed()); + if !released && *last_pos == cursor_pos && !camera_moved { + return; + } else { + *last_pos = cursor_pos; + } + let not_hovering_focused = |focused: &Entity| { + let focused = focusables + .entities + .get(*focused) + .expect("Entity with `Focused` component must also have a `Focusable` component"); + !is_in_node(world_cursor_pos, &focused) + }; + // If the currently hovered node is the focused one, there is no need to + // find which node we are hovering and to switch focus to it (since we are + // already focused on it) + if focused.iter().all(not_hovering_focused) { + // We only run this code when we really need it because we iterate over all + // focusables, which can eat a lot of CPU. + let under_mouse = focusables + .entities + .iter() + .filter(|query_elem| is_in_node(world_cursor_pos, query_elem)); + let under_mouse = + max_by_in_iter(under_mouse, |elem| elem.2.translation().z).map(|elem| elem.0); + let to_target = match under_mouse { + Some(c) => c, + None => return, + }; + nav_cmds.send(NavRequest::FocusOn(to_target)); + } else if released { + nav_cmds.send(NavRequest::Action); + } +} + +/// Update [`ScreenBoundaries`] resource when the UI camera change +/// (assuming there is a unique one). +/// +/// See [`ScreenBoundaries`] doc for details. +#[allow(clippy::type_complexity)] +pub fn update_boundaries( + mut commands: Commands, + mut boundaries: Option>, + cam: Query<(&Camera, Option<&UiCameraConfig>), Or<(Changed, Changed)>>, + windows: Res, + images: Res>, +) { + // TODO: this assumes there is only a single camera with activated UI. + let first_visible_ui_cam = |(cam, config): (_, Option<&UiCameraConfig>)| { + config.map_or(true, |c| c.show_ui).then_some(cam) + }; + let mut update_boundaries = || { + let cam = cam.iter().find_map(first_visible_ui_cam)?; + let target_info = cam.target.get_render_target_info(&windows, &images)?; + let new_boundaries = ScreenBoundaries { + position: Vec2::ZERO, + screen_edge: Rect { + max: target_info.physical_size.as_vec2(), + min: Vec2::ZERO, + }, + scale: 1.0, + }; + if let Some(boundaries) = boundaries.as_mut() { + **boundaries = new_boundaries; + } else { + commands.insert_resource(new_boundaries); + } + Some(()) + }; + update_boundaries(); +} + +/// Default input systems for ui navigation. +pub struct DefaultNavigationSystems; +impl Plugin for DefaultNavigationSystems { + fn build(&self, app: &mut App) { + use crate::NavRequestSystem; + app.init_resource::() + .add_system(default_mouse_input.before(NavRequestSystem)) + .add_system(default_gamepad_input.before(NavRequestSystem)) + .add_system(default_keyboard_input.before(NavRequestSystem)) + .add_system( + update_boundaries + .before(NavRequestSystem) + .before(default_mouse_input), + ); + } +} diff --git a/crates/bevy_ui_navigation/Cargo.toml b/crates/bevy_ui_navigation/Cargo.toml new file mode 100644 index 00000000000000..d8e31d886642f6 --- /dev/null +++ b/crates/bevy_ui_navigation/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "bevy_ui_navigation" +version = "0.8.0-dev" +edition = "2021" +description = "A ECS-driven generic UI navigation for Bevy engine" +homepage = "https://bevyengine.org" +repository = "https://github.com/bevyengine/bevy" +license = "MIT OR Apache-2.0" +keywords = ["bevy"] + +[dependencies] +# bevy +bevy_app = { path = "../bevy_app", version = "0.8.0-dev" } +bevy_core = { path = "../bevy_core", version = "0.8.0-dev" } +bevy_derive = { path = "../bevy_derive", version = "0.8.0-dev" } +bevy_ecs = { path = "../bevy_ecs", version = "0.8.0-dev" } +bevy_hierarchy = { path = "../bevy_hierarchy", version = "0.8.0-dev" } +bevy_log = { path = "../bevy_log", version = "0.8.0-dev" } +bevy_reflect = { path = "../bevy_reflect", version = "0.8.0-dev", features = [ + "bevy", +] } + +# other +non-empty-vec = { version = "0.2.2", default-features = false } diff --git a/crates/bevy_ui_navigation/README.md b/crates/bevy_ui_navigation/README.md new file mode 100644 index 00000000000000..a5f6ac5b0812d2 --- /dev/null +++ b/crates/bevy_ui_navigation/README.md @@ -0,0 +1,513 @@ +# Bevy UI navigation + +The in-depth design specification is [available here][rfc41]. + +### Examples + +Check out the [`examples`][examples] directory for bevy examples. + +![Demonstration of "Ultimate navigation" example](https://user-images.githubusercontent.com/26321040/141612751-ba0e62b2-23d6-429a-b5d1-48b09c10d526.gif) + + +## Usage + +See [this example][example-simple] for a quick start guide. + +[The crate documentation is extensive][doc-root], but for practical reason +doesn't include many examples. This page contains most of the doc examples, +you should check the [examples directory][examples] for examples showcasing +all features of this crate. + + +### Simple case + +To create a simple menu with navigation between buttons, simply replace usages +of [`ButtonBundle`] with [`FocusableButtonBundle`]. + +You will need to create your own system to change the color of focused elements, and add +manually the input systems, but with that setup you get: **Complete physical +position based navigation with controller, mouse and keyboard. Including rebindable +mapping**. + +```rust, no_run +use bevy::prelude::*; +use bevy_ui_navigation::DefaultNavigationPlugins; +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugins(DefaultNavigationPlugins) + .run(); +} +``` + +Use the [`InputMapping`] resource to change keyboard and gamepad button mapping. + +If you want to change entirely how input is handled, you should do as follow. All +interaction with the navigation engine is done through +[`EventWriter`][`NavRequest`]: + +```rust, no_run +use bevy::prelude::*; +use bevy_ui_navigation::{NavigationPlugin, NavRequest}; + +fn custom_input_system_emitting_nav_requests(mut events: EventWriter) { + // handle input and events.send(NavRequest::FooBar) +} + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugin(NavigationPlugin::new()) + .add_system(custom_input_system_emitting_nav_requests) + .run(); +} +``` + +Check the [`examples directory`][examples] for more example code. + +`bevy-ui-navigation` provides a variety of ways to handle navigation actions. +Check out the [`event_helpers`][module-event_helpers] module for more examples. + +```rust +use bevy::{app::AppExit, prelude::*}; +use bevy_ui_navigation::event_helpers::NavEventQuery; + +#[derive(Component)] +enum MenuButton { + StartGame, + ToggleFullscreen, + ExitGame, + Counter(i32), + //.. etc. +} + +fn handle_nav_events(mut buttons: NavEventQuery<&mut MenuButton>, mut exit: EventWriter) { + // Do something when player activates (click, press "A" etc.) a `Focusable` button. + match buttons.single_activated_mut().deref_mut().ignore_remaining() { + Some(MenuButton::StartGame) => { + // start the game + } + Some(MenuButton::ToggleFullscreen) => { + // toggle fullscreen here + } + Some(MenuButton::ExitGame) => { + exit.send(AppExit); + } + Some(MenuButton::Counter(count)) => { + *count += 1; + } + //.. etc. + None => {} + } +} +``` + +The focus navigation works across the whole UI tree, regardless of how or where +you've put your focusable entities. You just move in the direction you want to +go, and you get there. + +Any [`Entity`] can be converted into a focusable entity by adding the [`Focusable`] +component to it. To do so, just: +```rust +# use bevy::prelude::*; +# use bevy_ui_navigation::Focusable; +fn system(mut cmds: Commands, my_entity: Entity) { + cmds.entity(my_entity).insert(Focusable::default()); +} +``` +That's it! Now `my_entity` is part of the navigation tree. The player can select +it with their controller the same way as any other [`Focusable`] element. + +You probably want to render the focused button differently than other buttons, +this can be done with the [`Changed`][Changed] query parameter as follow: +```rust +use bevy::prelude::*; +use bevy_ui_navigation::{FocusState, Focusable}; + +fn button_system( + mut focusables: Query<(&Focusable, &mut UiColor), Changed>, +) { + for (focus, mut color) in focusables.iter_mut() { + let new_color = if matches!(focus.state(), FocusState::Focused) { + Color::RED + } else { + Color::BLACK + }; + *color = new_color.into(); + } +} +``` + +### Snappy feedback + +You will want the interaction feedback to be snappy. This means the +interaction feedback should run the same frame as the focus change. For this to +happen every frame, you should add `button_system` to your app using the +[`NavRequestSystem`] label like so: +```rust, no_run +use bevy::prelude::*; +use bevy_ui_navigation::{NavRequestSystem, NavRequest, NavigationPlugin}; + +fn custom_mouse_input(mut events: EventWriter) { + // handle input and events.send(NavRequest::FooBar) +} + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugin(NavigationPlugin::new()) + // ... + // Add the button color update system after the focus update system + .add_system(button_system.after(NavRequestSystem)) + // Add input systems before the focus update system + .add_system(custom_mouse_input.before(NavRequestSystem)) + // ... + .run(); +} +// Implementation from earlier +fn button_system() {} +``` + + +## More complex use cases + +### Locking + +If you need to supress the navigation algorithm temporarily, you can declare a +[`Focusable`] as [`Focusable::lock`]. + +This is useful for example if you want to implement custom widget with their +own controls, or if you want to disable menu navigation while in game. To +resume the navigation system, you'll need to send a [`NavRequest::Free`]. + + +### `NavRequest::FocusOn` + +You can't directly manipulate which entity is focused, because we need to keep +track of a lot of thing on the backend to make the navigation work as expected. +But you can set the focused element to any arbitrary `Focusable` entity with +[`NavRequest::FocusOn`]. + +```rust +use bevy::prelude::*; +use bevy_ui_navigation::NavRequest; + +fn set_focus_to_arbitrary_focusable( + entity: Entity, + mut requests: EventWriter, +) { + requests.send(NavRequest::FocusOn(entity)); +} +``` + +### Set the first focused element + +You probably want to be able to chose which element is the first one to gain +focus. By default, the system picks the first [`Focusable`] it finds. To change +this behavior, spawn a dormant [`Focusable`] with [`Focusable::dormant`]. + +### `NavMenu`s + +Suppose you have a more complex game with menus sub-menus and sub-sub-menus etc. +For example, in your everyday 2021 AAA game, to change the antialiasing you +would go through a few menus: +```text +game menu → options menu → graphics menu → custom graphics menu → AA +``` +In this case, you need to be capable of specifying which button in the previous +menu leads to the next menu (for example, you would press the "Options" button +in the game menu to access the options menu). + +For that, you need to use [`NavMenu`]. + +The high level usage of [`NavMenu`] is as follow: +1. First you need a "root" [`NavMenu`]. +2. You need to spawn into the ECS your "options" button with a [`Focusable`] + component. To link the button to your options menu, you need to do one of + the following: + * Add a [`Name("opt_btn_name")`][Name] component in addition to the + [`Focusable`] component to your options button. + * Pre-spawn the options button and store somewhere it's [`Entity` id][entity-id] + (`let opt_btn = commands.spawn_bundle(FocusableButtonBundle).id();`) +3. to the `NodeBundle` containing all the options menu [`Focusable`] entities, + you add the following bundle: + * [`NavMenu::Bound2d.reachable_from_named("opt_btn_name")`][NavMenu::reachable_from_named] + if you opted for adding the `Name` component. + * [`NavMenu::Bound2d.reachable_from(opt_btn)`][NavMenu::reachable_from] + if you have the [`Entity`] id. + +In code, This will look like this: +```rust +use bevy::prelude::*; +use bevy_ui_navigation::{Focusable, NavMenu}; +use bevy_ui_navigation::components::FocusableButtonBundle; + +struct SaveFile; +impl SaveFile { + fn bundle(&self) -> impl Bundle { + // UI bundle to show this in game + NodeBundle::default() + } +} +fn spawn_menu(mut cmds: Commands, save_files: Vec) { + let menu_node = NodeBundle { + style: Style { flex_direction: FlexDirection::Column, ..Default::default()}, + ..Default::default() + }; + let button = FocusableButtonBundle::from(ButtonBundle { + color: Color::rgb(1.0, 0.3, 1.0).into(), + ..Default::default() + }); + let mut spawn = |bundle: &FocusableButtonBundle, name: &'static str| { + cmds.spawn_bundle(bundle.clone()).insert(Name::new(name)).id() + }; + let options = spawn(&button, "options"); + let graphics_option = spawn(&button, "graphics"); + let audio_options = spawn(&button, "audio"); + let input_options = spawn(&button, "input"); + let game = spawn(&button, "game"); + let quit = spawn(&button, "quit"); + let load = spawn(&button, "load"); + + // Spawn the game menu + cmds.spawn_bundle(menu_node.clone()) + // Root NavMenu vvvvvvvvvvvvvv + .insert_bundle(NavMenu::Bound2d.root()) + .push_children(&[options, game, quit, load]); + + // Spawn the load menu + cmds.spawn_bundle(menu_node.clone()) + // Sub menu accessible through the load button + // vvvvvvvvvvvvvvvvvvvv + .insert_bundle(NavMenu::Bound2d.reachable_from(load)) + .with_children(|cmds| { + // can only access the save file UI nodes from the load menu + for file in save_files.iter() { + cmds.spawn_bundle(file.bundle()).insert(Focusable::default()); + } + }); + + // Spawn the options menu + cmds.spawn_bundle(menu_node) + // Sub menu accessible through the "options" button + // vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv + .insert_bundle(NavMenu::Bound2d.reachable_from_named("options")) + .push_children(&[graphics_option, audio_options, input_options]); +} +``` + +With this, your game menu will be isolated from your options menu, you can only +access it by sending [`NavRequest::Action`] when `options_button` is focused, or +by sending a [`NavRequest::FocusOn(entity)`][`NavRequest::FocusOn`] where `entity` +is any of `graphics_option`, `audio_options` or `input_options`. + +Note that you won't need to manually send the [`NavRequest`] if you are using one +of the default input systems provided in the [`systems` module][module-systems]. + +Specifically, navigation between [`Focusable`] entities will be constrained to other +[`Focusable`] that are children of the same [`NavMenu`]. It creates a self-contained +menu. + +### Types of `NavMenu`s + +A [`NavMenu`] doesn't only define menu-to-menu navigation, but it also gives you +finner-grained control on how navigation is handled within a menu: +* `NavMenu::Wrapping*` (as opposed to `NavMenu::Bound*`) enables looping + navigation, where going offscreen in one direction "wraps" to the opposite + screen edge. +* `NavMenu::*Scope` creates a "scope" menu that catches [`NavRequest::ScopeMove`] + requests even when the focused entity is in another sub-menu reachable from this + menu. This behaves like you would expect a tabbed menu to behave. + +See the [`NavMenu`] documentation or the ["ultimate" menu navigation +example][example-ultimate] for details. + + +#### Marking + +If you need to know from which menu a [`NavEvent::FocusChanged`] originated, you +can use one of the [`marking`][module-marking] methods on the `NavMenu` seeds. + +A usage demo is available in [the `marking.rs` example][example-marking]. + + +## Changelog + +* `0.8.2`: Fix offsetting of mouse focus with `UiCamera`s with a transform set + to anything else than zero. +* `0.9.0`: Add [`Focusable::cancel`] (see documentation for details); Add warning + message rather than do dumb things when there is more than a single [`NavRequest`] + per frame +* `0.9.1`: Fix #8, Panic on diagonal gamepad input +* `0.10.0`: Add the `bevy-ui` feature, technically this includes breaking + changes, but it is very unlikely you need to change your code to get it + working + * **Breaking**: if you were manually calling `default_mouse_input`, it now has + additional parameters + * **Breaking**: `ui_focusable_at` and `NodePosQuery` now have type parameters +* `0.11.0`: Add the `Focusable::lock` feature. A focusable now can be declared + as "lock" and block the ui navigation systems until the user sends a + `NavRequest::Free`. See the `locking.rs` example for illustration. + * Breaking: New enum variants on `NavRequest` and `NavEvent` +* `0.11.1`: Add the `marker` module, enabling propagation of user-specified + components to `Focusable` children of a `NavMenu`. +* `0.12.0`: Remove `NavMenu` methods from `MarkingMenu` and make the `menu` + field public instead. Internally, this represented too much duplicate code. +* `0.12.1`: Add *by-name* menus, making declaring complex menus in one go much easier. +* `0.13.0`: Complete rewrite of the [`NavMenu`] declaration system: + * Add automatic submenu access for `scope` menus. + * Rename examples, they were named weirdly. + * **Breaking**: Replace `NavMenu` constructor API with an enum (KISS) and a + set of methods that return various types of `Bundle`s. Each variant does + what the `cycle` and `scope` methods used to do. + * **Breaking**: `NavMenu` is not a component anymore, the one used in the + navigation algorithm is now private, you can't match on `NavMenu` in query + parameters anymore. If you need that functionality, create your own marker + component and add it manually to your menu entities. + * **Breaking**: Remove `is_*` methods from `Focusable`. Please use the + `state` method instead. The resulting program will be more correct. If you + are only worried about discriminating the `Focused` element from others, + just use a `if let Focused = focus.state() {} else {}`. Please see the + examples directory for usage patterns. + * **Breaking**: A lot of struct and module reordering to make documentation + more discoverable. Notably `Direction` and `ScopeDirection` are now in the + `events` module. +* `0.13.1`: Fix broken URLs in Readme.md +* `0.14.0`: Some important changes, and a bunch of new very useful features. + * Add a [`Focusable::dormant`] constructor to specify which focusable you want + to be the first to focus, this also works for [`Focusable`]s within + [`NavMenu`]s. + * **Important**: This changes the library behavior, now there will + automatically be a `Focused` entity set. Add a system to set the first + `Focused` whenever `Focusable`s are added to the world. + * Add [`NavEvent::InitiallyFocused`] to handle this first `Focused` event. + * Early-return in `default_gamepad_input` and `default_keyboard_input` when + there are no `Focusable` elements in the world. This saves your precious + CPU cycles. And prevents spurious `warn` log messages. + * Do not crash when resolving `NavRequest`s while no `Focusable`s exists in + the world. Instead, it now prints a warning message. + * **Important**: Now the focus handling algorithm supports multiple `NavRequest`s + per frame. If previously you erroneously sent multiple `NavRequest` per + update and relied on the ignore mechanism, you'll have a bad time. + * This also means the focus changes are visible as soon as the system ran, + the new `NavRequestSystem` label can be used to order your system in + relation to the focus update system. **This makes the focus change much + snappier**. + * Rewrite the `ultimate_menu_navigation.rs` without the `build_ui!` macro + because we shouldn't expect users to be familiar with my personal weird + macro. + * **Breaking**: Remove `Default` impl on `NavLock`. The user shouldn't be + able to mutate it, you could technically overwrite the `NavLock` resource + by using `insert_resource(NavLock::default())`. +* `0.15.0`: **Breaking**: bump bevy version to `0.7` (you should be able to + upgrade from `0.14.0` without changing your code) +* `0.15.1`: Fix the `marker` systems panicking at startup. +* `0.16.0`: + * Cycling now wraps around the screen properly, regardless of UI camera + position and scale. See the new `off_screen_focusables.rs` example for a demo. + * Fix the `default_mouse_input` system not accounting for camera scale. + * Update examples to make use of `NavRequestSystem` label, add more recommendations + with regard to system ordering. + * **Warning**: if you have some funky UI that goes beyond the screen (which + you are likely to have if you use the `Overflow` feature), this might result + in unexpected behavior. Please fill a bug if you hit that limitation. + * Add a nice stress test example with 96000 focusable nodes. This crate is not + particularly optimized, but it's nice to see it holds up! + * **Breaking**: Removed the undocumented public `UiCamera` marker component, please + use the bevy native `bevy::ui::entity::CameraUi` instead. Furthermore, the + `default_mouse_input` system has one less parameter. + * **Warning**: if you have multiple UI camera, things will definitively break. Please + fill an issue if you stumble uppon that case. +* `0.17.0`: Non-breaking, but due to cargo semver handling is a minor bump. + * Add the `event_helpers` module to simplify ui event handling + * Fix README and crate-level documentation links +* `0.18.0`: + * **Breaking**: Remove marker generic type parameter from `systems::NodePosQuery`. + The [`generic_default_mouse_input`] system now relies on the newly introduced + [`ScreenBoundaries`] that abstracts camera offset and zoom settings. + * **Important**: Introduced new plugins to make it even simpler to get started. + * Add `event_helpers` module introduction to README. + * Fix `bevy-ui` feature not building. This issue was introduced in `0.16.0`. +* `0.19.0`: **Breaking**: Update to bevy 0.8.0 + * **Warning**: 0.8.0 removed the ability for the user to change the ui camera position + and perspective, see + Generic support for user-defined UIs still allows custom cropping, but it not a relevant + use case to the default bevy_ui library. + * Keyboard navigation in the style of games pre-dating computer mouses is now disabled by default. + While you can still use the escape and tab keys for interaction, you cannot use keyboard keys + to navigate between focusables anymore, this prevents keyboard input conflicts. + You can enable keyboard movement using the [`InputMapping::keyboard_navigation`] field. + * Touch input handling has been removed, it was untested and probably broken, better let + the user who knows what they are doing do it. + * **NEW**: Add complete user-customizable focus movement. Now it should be possible to implement + focus navigation in 3d space. + * **Breaking**: This requires making the plugin generic over the navigation system, if you were + manually adding `NavigationPlugin`, please consider using `DefaultNavigationPlugins` instead, + if it is not possible, then use `NavigationPlugin::new()`. + +[`ButtonBundle`]: https://docs.rs/bevy/0.8.0/bevy/ui/entity/struct.ButtonBundle.html +[Changed]: https://docs.rs/bevy/0.8.0/bevy/ecs/prelude/struct.Changed.html +[doc-root]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/ +[`Entity`]: https://docs.rs/bevy/0.8.0/bevy/ecs/entity/struct.Entity.html +[entity-id]: https://docs.rs/bevy/0.8.0/bevy/ecs/system/struct.EntityCommands.html#method.id +[example-marking]: https://github.com/nicopap/ui-navigation/tree/v0.19.0/examples/marking.rs +[examples]: https://github.com/nicopap/ui-navigation/tree/v0.19.0/examples +[example-simple]: https://github.com/nicopap/ui-navigation/tree/v0.19.0/examples/simple.rs +[example-ultimate]: https://github.com/nicopap/ui-navigation/blob/v0.19.0/examples/ultimate_menu_navigation.rs +[`FocusableButtonBundle`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/components/struct.FocusableButtonBundle.html +[`Focusable::cancel`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/struct.Focusable.html#method.cancel +[`Focusable::dormant`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/struct.Focusable.html#method.dormant +[`Focusable`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/struct.Focusable.html +[`Focusable::lock`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/struct.Focusable.html#method.lock +[`generic_default_mouse_input`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/systems/fn.generic_default_mouse_input.html +[`InputMapping`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/systems/struct.InputMapping.html +[module-event_helpers]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/event_helpers/index.html +[module-marking]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/bundles/struct.MenuSeed.html#method.marking +[module-systems]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/systems/index.html +[Name]: https://docs.rs/bevy/0.8.0/bevy/core/enum.Name.html +[`NavEvent::FocusChanged`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/events/enum.NavEvent.html#variant.FocusChanged +[`NavEvent`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/events/enum.NavEvent.html +[`NavEvent::InitiallyFocused`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/events/enum.NavEvent.html#variant.InitiallyFocused +[`NavMenu`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/enum.NavMenu.html +[NavMenu::reachable_from]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/enum.NavMenu.html#method.reachable_from +[NavMenu::reachable_from_named]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/enum.NavMenu.html#method.reachable_from_named +[`NavRequest`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/events/enum.NavRequest.html +[`NavRequest::Action`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/events/enum.NavRequest.html#variant.Action +[`NavRequest::FocusOn`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/events/enum.NavRequest.html#variant.FocusOn +[`NavRequest::Free`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/events/enum.NavRequest.html#variant.Free +[`NavRequest::ScopeMove`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/events/enum.NavRequest.html#variant.ScopeMove +[`NavRequestSystem`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/struct.NavRequestSystem.html +[rfc41]: https://github.com/nicopap/rfcs/blob/ui-navigation/rfcs/41-ui-navigation.md +[`ScreenBoundaries`]: https://docs.rs/bevy-ui-navigation/0.19.0/bevy_ui_navigation/struct.ScreenBoundaries.html + + +### Version matrix + +| bevy | latest supporting version | +|------|--------| +| 0.8 | 0.19.0 | +| 0.7 | 0.18.0 | +| 0.6 | 0.14.0 | + +### Notes on API Stability + +In the 4th week of January, there has been 5 breaking version changes. `0.13.0` +marks the end of this wave of API changes. And things should go much slower in +the future. + +The new `NavMenu` construction system helps adding orthogonal features to the +library without breaking API changes. However, since bevy is still in `0.*` +territory, it doesn't make sense for this library to leave the `0.*` range. + +Also, the way cargo handles versioning for `0.*` crates is in infraction of +the semver specification. Meaning that additional features without breakages +requires bumping the minor version rather than the patch version (as should +pre-`1.` versions do). + + +# License + +Copyright © 2022 Nicola Papale + +This software is licensed under either MIT or Apache 2.0 at your leisure. See +licenses directory for details. diff --git a/crates/bevy_ui_navigation/src/commands.rs b/crates/bevy_ui_navigation/src/commands.rs new file mode 100644 index 00000000000000..3f104300ae6616 --- /dev/null +++ b/crates/bevy_ui_navigation/src/commands.rs @@ -0,0 +1,21 @@ +use bevy_ecs::{entity::Entity, prelude::World, system::Command}; + +use crate::{FocusState, Focused}; + +pub(crate) fn set_focus_state(entity: Entity, new_state: FocusState) -> UpdateFocusable { + UpdateFocusable { entity, new_state } +} +pub(crate) struct UpdateFocusable { + entity: Entity, + new_state: FocusState, +} +impl Command for UpdateFocusable { + fn write(self, world: &mut World) { + let mut entity = world.entity_mut(self.entity); + if matches!(self.new_state, FocusState::Focused) { + entity.insert(Focused); + } else { + entity.remove::(); + } + } +} diff --git a/crates/bevy_ui_navigation/src/event_helpers.rs b/crates/bevy_ui_navigation/src/event_helpers.rs new file mode 100644 index 00000000000000..41a080ac646744 --- /dev/null +++ b/crates/bevy_ui_navigation/src/event_helpers.rs @@ -0,0 +1,485 @@ +//! Helpers for reacting to [`NavEvent`]s. +//! +//! This module defines [`SystemParam`] with methods that helps structure code +//! reacting to [`NavEvent`]s. +//! +//! The goal is to provide a conceptually simple abstraction over the specifics +//! of `bevy_ui_navigation` while preserving access to the specifics for the +//! rare occurences when it is needed. +//! +//! # Motivation +//! +//! The default way of hooking your UI to your code in `bevy_ui_navigation` is +//! a bit rough: +//! * You need to listen for [`NavEvent`], +//! * filter for the ones you care about (typically [`NavEvent::NoChanges`]), +//! * check what [`NavRequest`] triggered it, +//! * retrieve the focused entity from the event, +//! * check against your own queries what entity it is, +//! * write code for each case you want to handle +//! +//! It is not _awful_, but it requires a deep familiarity with this crates' +//! way of doing things: +//! ```rust +//! use bevy::prelude::*; +//! use bevy_ui_navigation::events::{NavEvent, NavRequest}; +//! +//! #[derive(Component)] +//! enum MainMenuButton { Start, Options, Exit } +//! /// Marker component +//! #[derive(Component)] struct ActiveButton; +//! +//! fn handle_ui( +//! mut events: EventReader, +//! buttons: Query<&MainMenuButton, With>, +//! ) { +//! // iterate NavEvents +//! for event in events.iter() { +//! // Check for a `NoChanges` event with Action +//! if let NavEvent::NoChanges { from, request: NavRequest::Action } = event { +//! // Get the focused entity (from.first()), check the button for it. +//! match buttons.get(*from.first()) { +//! // Do things when specific button is activated +//! Ok(MainMenuButton::Start) => {} +//! Ok(MainMenuButton::Options) => {} +//! Ok(MainMenuButton::Exit) => {} +//! Err(_) => {} +//! } +//! } +//! } +//! } +//! ``` +//! +//! This module defines two [`SystemParam`]: +//! * [`NavEventQuery`]: A wrapper around a query to limit iteration to +//! item activated this cycle. +//! * [`NavEventReader`]: A wrapper around `EventReader` that +//! add extra methods specific to iterating `NavEvent`s. +//! +//! Those `SystemParam` are accessed by specifying them as system arguments. +//! +//! ## [`NavEventReader`] +//! +//! [`NavEventReader`] works exactly like `EventReader` (you can even access +//! the inner reader) but gives you choice on how to iterate the `NavEvent`s. +//! +//! Check the [`NavEventReader`] docs for usage examples and use cases. +//! +//! ## [`NavEventQuery`] +//! +//! [`NavEventQuery`] works exactly like a query, appart that it doesn't define +//! `iter()`, but [`iter_requested()`](NavEventQuery::iter_requested) and +//! [`iter_activated()`](NavEventQuery::iter_activated). You use it exactly +//! like the bevy `Query` system parameter. This let you just iterate over +//! the entities that got activated since last frame. +//! +//! Check the [`NavEventQuery`] docs for usage examples and use cases. +//! +//! # Examples +//! +//! Those `SystemParam` should let you react to `NavEvent`s under your own terms. +//! The simplest way to handle `NavEvent`s is with the `NavEventQuery` parameter: +//! +//! ```rust +//! use bevy::prelude::*; +//! use bevy_ui_navigation::event_helpers::NavEventQuery; +//! +//! #[derive(Component)] +//! enum MainMenuButton { Start, Options, Exit } +//! /// Marker component +//! #[derive(Component)] struct ActiveButton; +//! +//! fn handle_ui(mut button_events: NavEventQuery<&MainMenuButton, With>) { +//! // NOTE: this will silently ignore multiple navigation event at the same frame. +//! // It should be a very rare occurance. +//! match button_events.single_activated().ignore_remaining() { +//! // Do things when specific button is activated +//! Some(MainMenuButton::Start) => {} +//! Some(MainMenuButton::Options) => {} +//! Some(MainMenuButton::Exit) => {} +//! None => {} +//! } +//! } +//! ``` +//! +//! # Disclaimer +//! +//! This is a very new API and it is likely to be too awkward to use in a real-world +//! use case. But the aim is to make it possible to use this API in 90% of cases. I +//! don't think it's currently the case. Feel free to open an issue discussing possible +//! improvements. +use std::ops::{Deref, DerefMut}; + +use bevy_ecs::{ + entity::Entity, + event::EventReader, + query::{QueryItem, ROQueryItem, WorldQuery}, + system::{Query, SystemParam}, +}; + +use crate::events::{NavEvent, NavRequest}; + +/// A thing that should exist or not, but possibly could erroneously be multiple. +pub enum SingleResult { + /// There is exactly one `T`. + One(T), + /// There is exactly no `T`. + None, + /// There is more than one `T`, holding the first `T`. + MoreThanOne(T), +} +impl SingleResult { + /// Assume the [`SingleResult::MoreThanOne`] case doesn't exist. + /// + /// # Panics + /// + /// When `self` is [`SingleResult::MoreThanOne`]. + pub fn unwrap_opt(self) -> Option { + match self { + Self::MoreThanOne(_) => panic!("There was more than one `SingleResult`"), + Self::One(t) => Some(t), + Self::None => None, + } + } + /// Return contained value, not caring even if there is more than one result. + pub fn ignore_remaining(self) -> Option { + match self { + Self::MoreThanOne(t) | Self::One(t) => Some(t), + Self::None => None, + } + } + pub fn deref_mut(&mut self) -> SingleResult<&mut ::Target> + where + T: DerefMut, + { + match self { + Self::MoreThanOne(t) => SingleResult::MoreThanOne(t.deref_mut()), + Self::One(t) => SingleResult::One(t.deref_mut()), + Self::None => SingleResult::None, + } + } + fn new(from: Option, is_multiple: bool) -> Self { + match (from, is_multiple) { + (Some(t), false) => Self::One(t), + (Some(t), true) => Self::MoreThanOne(t), + (None, _) => Self::None, + } + } +} + +/// Types of [`NavEvent`] that can be "emitted" by focused elements. +pub enum NavEventType { + /// [`NavEvent::Locked`]. + Locked, + /// [`NavEvent::Unlocked`]. + Unlocked, + /// [`NavEvent::FocusChanged`]. + FocusChanged, + /// [`NavEvent::NoChanges`]. + NoChanges(NavRequest), +} + +/// An [`EventReader`] with methods to filter for meaningful events. +/// Use this like an `EventReader`, but with extra functionalities: +/// ```rust +/// # use bevy::prelude::*; +/// use bevy_ui_navigation::event_helpers::{NavEventReader, NavEventType}; +/// use bevy_ui_navigation::events::NavRequest; +/// +/// # #[derive(Component)] enum MainMenuButton { Start, Options, Exit } +/// # #[derive(Component)] struct ActiveButton; +/// fn handle_ui( +/// mut events: NavEventReader, +/// buttons: Query<&MainMenuButton, With>, +/// ) { +/// for (event_type, from) in events.type_iter() { +/// match (buttons.get(from), event_type) { +/// (Ok(MainMenuButton::Start), NavEventType::NoChanges(NavRequest::Action)) => {} +/// (Ok(MainMenuButton::Start), NavEventType::Locked) => {} +/// (Ok(MainMenuButton::Start), _) => {} +/// (Ok(MainMenuButton::Options), _) => {} +/// _ => {} +/// // etc.. +/// } +/// } +/// } +/// ``` +/// See methods documentation for what type of iterators you can get. +#[derive(SystemParam)] +pub struct NavEventReader<'w, 's> { + pub events: EventReader<'w, 's, NavEvent>, +} +impl<'w, 's> NavEventReader<'w, 's> { + /// Event reader of [`events`](NavEventReader::events) filtered + /// to only keep [`NavEvent::NoChanges`]. + /// + /// Note that this iterator usually has 0 or 1 element. + pub fn unchanged(&mut self) -> impl DoubleEndedIterator + '_ { + self.events.iter().filter_map(|event| { + if let NavEvent::NoChanges { from, request } = event { + Some((*request, *from.first())) + } else { + None + } + }) + } + + /// Iterate [`NavEvent`] by type. + /// + /// Note that this iterator usually has 0 or 1 element. + pub fn type_iter(&mut self) -> impl DoubleEndedIterator + '_ { + use NavEventType::{FocusChanged, Locked, NoChanges, Unlocked}; + self.events.iter().filter_map(|event| match event { + NavEvent::FocusChanged { from, .. } => Some((FocusChanged, *from.first())), + NavEvent::Locked(from) => Some((Locked, *from)), + NavEvent::Unlocked(from) => Some((Unlocked, *from)), + NavEvent::NoChanges { from, request } => Some((NoChanges(*request), *from.first())), + _ => None, + }) + } + + /// The entities that got an unhandled `request` [`NavRequest`] this frame. + /// + /// Note that this iterator usually has 0 or 1 element. + pub fn caught(&mut self, request: NavRequest) -> impl DoubleEndedIterator + '_ { + self.unchanged() + .filter_map(move |(req, entity)| (req == request).then(|| entity)) + } + + /// The entities that got an unhandled [`NavRequest::Action`] this frame. + /// + /// An unhandled `Action` happens typically when the user presses the action + /// key on a focused entity. The [`Entity`] returned here will be the focused + /// entity. + /// + /// Typically there will be 0 or 1 such entity. It's technically possible to + /// have more than one activated entity in the same frame, but extremely + /// unlikely. Please account for that case. + pub fn activated(&mut self) -> impl DoubleEndedIterator + '_ { + self.caught(NavRequest::Action) + } + + /// Variation of [`NavEventReader::caught`] where more than one `NavEvent` is + /// explicitly excluded. + pub fn single_caught(&mut self, request: NavRequest) -> SingleResult { + let mut iterated = self.caught(request); + let first = iterated.next(); + let one_more = iterated.next().is_some(); + SingleResult::new(first, one_more) + } + + /// Variation of [`NavEventReader::activated`] where more than one `NavEvent` is + /// explicitly excluded. + pub fn single_activated(&mut self) -> SingleResult { + self.single_caught(NavRequest::Action) + } +} + +/// Convinient wrapper around a query for a quick way of handling UI events. +/// +/// See [the module level doc](crate::event_helpers) for justification and +/// use case, see the following methods documentation for specifics on how +/// to use this [`SystemParam`]. +/// +/// This is a bevy [`SystemParam`], you access it by specifying it as parameter +/// to your systems: +/// +/// ```rust +/// # use bevy::prelude::*; +/// use bevy_ui_navigation::event_helpers::NavEventQuery; +/// # #[derive(Component)] enum MenuButton { StartGame, Quit, JoinFriend } +/// +/// fn handle_ui(mut button_events: NavEventQuery<&MenuButton>) {} +/// ``` +#[derive(SystemParam)] +pub struct NavEventQuery<'w, 's, Q: WorldQuery + 'static, F: 'static + WorldQuery = ()> { + query: Query<'w, 's, Q, F>, + events: NavEventReader<'w, 's>, +} +impl<'w, 's, Q: WorldQuery, F: WorldQuery> NavEventQuery<'w, 's, Q, F> { + /// Like [`NavEventQuery::iter_activated`], but for mutable queries. + /// + /// You can use this method to get mutable access to items from the `Q` + /// `WorldQuery`. + /// + /// It is however recommended that you use a [`NavEventReader`] method + /// with your own queries if you have any kind of complex access pattern + /// to your mutable data. + /// + /// With the `MenuButton` example from [`NavEventQuery::iter_activated`], + /// you could use this method as follow: + /// + /// ```rust + /// use bevy::prelude::*; + /// use bevy::app::AppExit; + /// use bevy_ui_navigation::event_helpers::NavEventQuery; + /// # use bevy::ecs::system::SystemParam; + /// # #[derive(SystemParam)] struct StartGameQuery<'w, 's> { foo: Query<'w, 's, ()> } + /// #[derive(Component)] + /// enum MenuButton { + /// StartGame, + /// Quit, + /// Counter(i64), + /// } + /// + /// fn start_game(queries: &mut StartGameQuery) { /* ... */ } + /// + /// fn handle_menu_button( + /// mut buttons: NavEventQuery<&mut MenuButton>, + /// mut app_evs: EventWriter, + /// mut start_game_query: StartGameQuery, + /// ) { + /// // WARNING: using `deref_mut` here triggers change detection regardless + /// // of whether we changed anything, but there is no other ways to + /// // pattern-match on `MenuButton` in rust in this case. + /// match buttons.single_activated_mut().deref_mut().unwrap_opt() { + /// Some(MenuButton::Counter(i)) => *i += 1, + /// Some(MenuButton::StartGame) => start_game(&mut start_game_query), + /// Some(MenuButton::Quit) => app_evs.send(AppExit), + /// None => {}, + /// } + /// } + /// ``` + pub fn single_activated_mut(&mut self) -> SingleResult> { + self.single_caught_mut(NavRequest::Action) + } + /// Like [`NavEventQuery::single_activated_mut`] but for non-mutable queries. + /// + /// ```rust + /// use bevy::prelude::*; + /// use bevy::app::AppExit; + /// use bevy_ui_navigation::event_helpers::NavEventQuery; + /// # use bevy::ecs::system::SystemParam; + /// # #[derive(SystemParam)] struct StartGameQuery<'w, 's> { foo: Query<'w, 's, ()> } + /// #[derive(Component)] + /// enum MenuButton { + /// StartGame, + /// Quit, + /// Options, + /// } + /// + /// fn start_game(queries: &mut StartGameQuery) { /* ... */ } + /// + /// fn handle_menu_button( + /// mut buttons: NavEventQuery<&MenuButton>, + /// mut app_evs: EventWriter, + /// mut start_game_query: StartGameQuery, + /// ) { + /// match buttons.single_activated().unwrap_opt() { + /// Some(MenuButton::Options) => {/* do something optiony */}, + /// Some(MenuButton::StartGame) => start_game(&mut start_game_query), + /// Some(MenuButton::Quit) => app_evs.send(AppExit), + /// None => {}, + /// } + /// } + /// ``` + pub fn single_activated(&mut self) -> SingleResult> { + match self.events.single_caught(NavRequest::Action) { + SingleResult::MoreThanOne(e) => SingleResult::new(self.query.get(e).ok(), true), + SingleResult::One(e) => SingleResult::new(self.query.get(e).ok(), false), + SingleResult::None => SingleResult::None, + } + } + + /// Like [`NavEventQuery::single_activated_mut`] but for arbitrary + /// `react_to` [`NavRequest`]. + pub fn single_caught_mut(&mut self, react_to: NavRequest) -> SingleResult> { + match self.events.single_caught(react_to) { + SingleResult::MoreThanOne(e) => SingleResult::new(self.query.get_mut(e).ok(), true), + SingleResult::One(e) => SingleResult::new(self.query.get_mut(e).ok(), false), + SingleResult::None => SingleResult::None, + } + } + + /// Iterate over items of `Q` that received a `react_to` [`NavRequest`] + /// not handled by the navigation engine. + /// + /// This method is used like [`NavEventQuery::iter_activated`], but with + /// the additional `react_to` argument. + /// + /// Note that this iterator is usualy of length 0 or 1. + /// + /// See the [`NavEventQuery::iter_activated`] documentation for details. + pub fn iter_requested(&mut self, react_to: NavRequest) -> impl Iterator> { + let Self { events, query } = self; + events.caught(react_to).filter_map(|e| query.get(e).ok()) + } + + /// Iterate over received [`NavEvent`] types associated with the + /// corresponding result from `Q`. + /// + /// Note that this iterator is usualy of length 0 or 1. + /// + /// This is similar to [`NavEventReader::type_iter`], but instead of + /// returning an entity, it returns the query item of `Q` + pub fn iter_types( + &mut self, + ) -> impl DoubleEndedIterator)> { + let Self { events, query } = self; + events + .type_iter() + .filter_map(|(t, e)| query.get(e).ok().map(|e| (t, e))) + } + + /// Iterate over items of `Q` that received a [`NavRequest::Action`] + /// not handled by the navigation engine. + /// + /// This method is very useful for UI logic. You typically want to react + /// to [`NavEvent::NoChanges`] where the `request` field is + /// [`NavRequest::Action`]. This happens when the user clicks a button or + /// when they press the `Action` button on their controller. + /// + /// Note that this iterator is usualy of length 0 or 1. + /// + /// # Mutable queries + /// + /// This method cannot be used with `WorldQueries` with mutable + /// world access. You should use [`NavEventQuery::single_activated_mut`] instead. + /// + /// # Example + /// + /// We have a menu where each button has a `MenuButton` component. We want + /// to do something special when the player clicks a specific button, to + /// react to specific buttons we would create a system that accepts a + /// `NavEventQuery<&MenuButton>` and uses it as follow: + /// + /// ```rust + /// use bevy::prelude::*; + /// use bevy::app::AppExit; + /// # use bevy::ecs::system::SystemParam; + /// # #[derive(SystemParam)] struct StartGameQuery<'w, 's> { foo: Query<'w, 's, ()> } + /// # #[derive(SystemParam)] struct JoinFriendQuery<'w, 's> { foo: Query<'w, 's, ()> } + /// use bevy_ui_navigation::event_helpers::NavEventQuery; + /// + /// #[derive(Component)] + /// enum MenuButton { + /// StartGame, + /// Quit, + /// JoinFriend, + /// } + /// + /// fn start_game(queries: &mut StartGameQuery) { /* ... */ } + /// fn join_friend(queries: &mut JoinFriendQuery) { /* ... */ } + /// + /// fn handle_menu_button( + /// mut buttons: NavEventQuery<&MenuButton>, + /// mut app_evs: EventWriter, + /// mut start_game_query: StartGameQuery, + /// mut join_friend_query: JoinFriendQuery, + /// ) { + /// for activated_button in buttons.iter_activated() { + /// match activated_button { + /// MenuButton::StartGame => start_game(&mut start_game_query), + /// MenuButton::Quit => app_evs.send(AppExit), + /// MenuButton::JoinFriend => join_friend(&mut join_friend_query), + /// } + /// } + /// } + /// ``` + /// + /// If you are curious how `StartGameQuery` was defined, check out the bevy + /// [`SystemParam`] trait! + pub fn iter_activated(&mut self) -> impl Iterator> { + self.iter_requested(NavRequest::Action) + } +} diff --git a/crates/bevy_ui_navigation/src/events.rs b/crates/bevy_ui_navigation/src/events.rs new file mode 100644 index 00000000000000..faa346c41c66a3 --- /dev/null +++ b/crates/bevy_ui_navigation/src/events.rs @@ -0,0 +1,118 @@ +//! Navigation events and requests. +//! +//! The navigation system works through bevy's `Events` system. Basically, it is +//! a system with one input and two outputs: +//! * Input [`Events`](https://docs.rs/bevy/0.8.0/bevy/app/struct.Events.html), +//! tells the navigation system what to do. Your app should have a system +//! that writes to a [`EventWriter`](https://docs.rs/bevy/0.8.0/bevy/app/struct.EventWriter.html) +//! based on inputs or internal game state. Usually, the default input systems specified +//! in [`crate::systems`] do that for you. But you can add your own requests +//! on top of the ones the default systems send. For example to unlock the UI with +//! [`NavRequest::Free`]. +//! * Output [`Focusable`](crate::Focusable) components. The navigation system +//! updates the focusables component according to the focus state of the +//! navigation system. See examples directory for how to read those +//! * Output [`EventReader`](https://docs.rs/bevy/0.8.0/bevy/app/struct.EventReader.html), +//! contains specific information about what the navigation system is doing. +use bevy_ecs::entity::Entity; +use non_empty_vec::NonEmpty; + +/// Requests to send to the navigation system to update focus. +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum NavRequest { + /// Move in 2d in provided direction. + Move(Direction), + /// Move within the encompassing [`NavMenu::BoundScope`](crate::NavMenu::BoundScope). + ScopeMove(ScopeDirection), + /// Enter submenu if any [`NavMenu::reachable_from`](crate::NavMenu::reachable_from) + /// the currently focused entity. + Action, + /// Leave this submenu to enter the one it is [`reachable_from`](crate::NavMenu::reachable_from). + Cancel, + /// Move the focus to any arbitrary [`Focusable`](crate::Focusable) entity. + FocusOn(Entity), + /// Unlocks the navigation system. + /// + /// A [`NavEvent::Unlocked`] will be emitted as a response if the + /// navigation system was indeed locked. + Free, +} + +/// Direction for movement in [`NavMenu::BoundScope`](crate::NavMenu::BoundScope) menus. +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum ScopeDirection { + Next, + Previous, +} + +/// 2d direction to move in normal menus +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum Direction { + South, + North, + East, + West, +} + +/// Events emitted by the navigation system. +/// +/// Useful if you want to react to [`NavEvent::NoChanges`] event, for example +/// when a "start game" button is focused and the [`NavRequest::Action`] is +/// pressed. +#[derive(Debug, Clone)] +pub enum NavEvent { + /// Tells the app which element is the first one to be focused. + /// + /// This will be sent whenever the number of focused elements go from 0 to 1. + /// Meaning: whenever you spawn a new UI with [`crate::Focusable`] elements. + InitiallyFocused(Entity), + + /// Focus changed + /// + /// ## Notes + /// + /// Both `to` and `from` are ascending, meaning that the focused and newly + /// focused elements are the first of their respective vectors. + /// + /// [`NonEmpty`] enables you to safely check `to.first()` or `from.first()` + /// without returning an option. It is guaranteed that there is at least + /// one element. + FocusChanged { + /// The list of elements that has become active after the focus + /// change + to: NonEmpty, + /// The list of active elements from the focused one to the last + /// active which is affected by the focus change + from: NonEmpty, + }, + /// The [`NavRequest`] didn't lead to any change in focus. + NoChanges { + /// The list of active elements from the focused one to the last + /// active which is affected by the focus change + from: NonEmpty, + /// The [`NavRequest`] that didn't do anything + request: NavRequest, + }, + /// A [lock focusable](crate::Focusable::lock) has been triggered + /// + /// Once the navigation plugin enters a locked state, the only way to exit + /// it is to send a [`NavRequest::Free`]. + Locked(Entity), + + /// A [lock focusable](crate::Focusable::lock) has been triggered + /// + /// Once the navigation plugin enters a locked state, the only way to exit + /// it is to send a [`NavRequest::Free`]. + Unlocked(Entity), +} +impl NavEvent { + /// Convenience function to construct a `FocusChanged` with a single `to` + /// + /// Usually the `NavEvent::FocusChanged.to` field has a unique value. + pub(crate) fn focus_changed(to: Entity, from: NonEmpty) -> NavEvent { + NavEvent::FocusChanged { + from, + to: NonEmpty::new(to), + } + } +} diff --git a/crates/bevy_ui_navigation/src/lib.rs b/crates/bevy_ui_navigation/src/lib.rs new file mode 100644 index 00000000000000..643e7391d7136b --- /dev/null +++ b/crates/bevy_ui_navigation/src/lib.rs @@ -0,0 +1,94 @@ +mod commands; +pub mod event_helpers; +pub mod events; +mod named; +mod resolve; +mod seeds; + +use std::marker::PhantomData; + +use bevy_app::prelude::*; +use bevy_ecs::{ + schedule::{ParallelSystemDescriptorCoercion, SystemLabel}, + system::{SystemParam, SystemParamItem}, +}; + +pub use events::{NavEvent, NavRequest}; +pub use non_empty_vec::NonEmpty; +pub use resolve::{FocusAction, FocusState, Focusable, Focused, MoveParam, NavLock}; +pub use seeds::NavMenu; + +/// The [`Bundle`](bevy::prelude::Bundle)s +/// returned by the [`NavMenu`] methods. +pub mod bundles { + pub use crate::seeds::{MenuSeed, NamedMenuSeed}; +} + +/// The label of the system in which the [`NavRequest`] events are handled, the +/// focus state of the [`Focusable`]s is updated and the [`NavEvent`] events +/// are sent. +/// +/// Systems updating visuals of UI elements should run _after_ the `NavRequestSystem`, +/// while systems that emit [`NavRequest`] should run _before_ it. For example, the +/// [`systems::default_mouse_input`] systems should run before the `NavRequestSystem`. +/// +/// Failing to do so won't cause logical errors, but will make the UI feel more slugish +/// than necessary. This is especially critical of you are running on low framerate. +/// +/// # Example +/// +/// ```rust, no_run +/// use bevy::prelude::*; +/// use bevy_ui_navigation::{NavRequestSystem, DefaultNavigationPlugins}; +/// # fn button_system() {} +/// fn main() { +/// App::new() +/// .add_plugins(DefaultPlugins) +/// .add_plugins(DefaultNavigationPlugins) +/// // ... +/// // Add the button color update system after the focus update system +/// .add_system(button_system.after(NavRequestSystem)) +/// // ... +/// .run(); +/// } +/// ``` +#[derive(Clone, Debug, Hash, PartialEq, Eq, SystemLabel)] +pub struct NavRequestSystem; + +/// The navigation plugin. +/// +/// Add it to your app with `.add_plugin(NavigationPlugin)` and send +/// [`NavRequest`]s to move focus within declared [`Focusable`] entities. +/// +/// This means you'll also have to add manaully the systems from [`systems`] +/// and [`systems::InputMapping`]. You should prefer [`DefaultNavigationPlugins`] +/// if you don't want to bother with that. +/// +/// # Note on generic parameters +/// +/// The `MP` type parameter might seem complicated, but all you have to do +/// is for your type to implement [`SystemParam`] and [`MoveParam`]. +/// See the [`resolve::UiProjectionQuery`] source code for implementation hints. +pub struct GenericNavigationPlugin(PhantomData); +unsafe impl Send for GenericNavigationPlugin {} +unsafe impl Sync for GenericNavigationPlugin {} + +impl GenericNavigationPlugin { + pub fn new() -> Self { + Self(PhantomData) + } +} +impl Plugin for GenericNavigationPlugin +where + for<'w, 's> SystemParamItem<'w, 's, MP>: MoveParam, +{ + fn build(&self, app: &mut App) { + app.add_event::() + .add_event::() + .insert_resource(NavLock::new()) + .add_system(resolve::listen_nav_requests::.label(NavRequestSystem)) + .add_system(resolve::set_first_focused) + .add_system(resolve::insert_tree_menus) + .add_system(named::resolve_named_menus.before(resolve::insert_tree_menus)); + } +} diff --git a/crates/bevy_ui_navigation/src/named.rs b/crates/bevy_ui_navigation/src/named.rs new file mode 100644 index 00000000000000..954a8e1725cace --- /dev/null +++ b/crates/bevy_ui_navigation/src/named.rs @@ -0,0 +1,45 @@ +//! Declare menu navigation through +//! [`Name`](https://docs.rs/bevy/0.8.0/bevy/core/struct.Name.html). +//! +//! The most difficult part of the API to deal with was giving +//! [`NavMenu::reachable_from`](crate::NavMenu::reachable_from) the `Entity` for of +//! the button used to reach it. +//! +//! This forced you to divide the whole menu construction in multiple +//! parts and keep track of intermediary values if you want to make multple menus. +//! +//! *By-name declaration* let you simply add a label to your `Focusable` and +//! refer to it in [`NavMenu::reachable_from_named`](crate::NavMenu::reachable_from_named). +//! The runtime then detects labelled stuff and replace the partial +//! [`NavMenu`](crate::NavMenu) with the full [`TreeMenu`](crate::resolve::TreeMenu) +//! with the proper entity id reference. This saves you from pre-spawning your +//! buttons so that you can associate their `id` with the proper submenu. + +use bevy_core::Name; +use bevy_ecs::{entity::Entity, prelude::With, system::Query}; + +use crate::{ + seeds::{FailableOption, ParentName, TreeMenuSeed}, + Focusable, +}; + +pub(crate) fn resolve_named_menus( + mut unresolved: Query<(&mut TreeMenuSeed, &ParentName)>, + named: Query<(Entity, &Name), With>, +) { + for (mut seed, ParentName(parent_name)) in unresolved.iter_mut() { + match named.iter().find(|(_, n)| *n == parent_name) { + Some((focus_parent, _)) => { + seed.focus_parent = FailableOption::Some(focus_parent); + } + None => { + let name = parent_name.as_str(); + bevy_log::warn!( + "Tried to spawn a `NavMenu` with parent focusable {name}, but no\ + `Focusable` has a `Name` component with that value." + ); + continue; + } + } + } +} diff --git a/crates/bevy_ui_navigation/src/resolve.rs b/crates/bevy_ui_navigation/src/resolve.rs new file mode 100644 index 00000000000000..3f89dc39239af8 --- /dev/null +++ b/crates/bevy_ui_navigation/src/resolve.rs @@ -0,0 +1,695 @@ +//! The resolution algorithm for the navigation system. +//! +//! # Overview +//! +//! This module defines two systems: +//! 1. [`listen_nav_requests`]: the system gathering [`NavRequest`] and running +//! the [`resolve`] algorithm on them, updating the [`Focusable`] states and +//! sending [`NavEvent`] as result. +//! 2. [`insert_tree_menus`]: system responsible to convert seed bundles defined +//! in [`crate::seeds`] into [`TreeMenu`], which is the component used by +//! the resolution algorithm. +//! +//! The module also defines the [`Focusable`] component (also used in the +//! resolution algorithm) and its fields. +//! +//! The bulk of the resolution algorithm is implemented in [`resolve`], +//! delegating some abstract tasks to helper functions, of which: +//! * [`parent_menu`] +//! * [`ChildQueries::focusables_of`] +//! * [`child_menu`] +//! * [`focus_deep`] +//! * [`root_path`] +//! * [`MoveParam::resolve_2d`] +//! * [`resolve_scope`] +//! +//! A trait [`MoveParam`] allows user-defined movements through a custom system parameter +//! by implementing `resolve_2d`. +//! +//! We define some `SystemParam`: +//! * [`ChildQueries`]: queries used to find the focusable children of a given entity. +//! * [`NavQueries`]: All **immutable** queries used by the resolution algorithm. +//! * [`MutQueries`]: Queries with mutable access to [`Focusable`] and [`TreeMenu`] +//! for updating them in [`listen_nav_requests`]. +//! +//! [`listen_nav_requests`] uses a `ParamSet` to access the focusables immutably for +//! navigation resolution and mutably for updating them with the new navigation state. +use std::cmp::Ordering; +use std::num::NonZeroUsize; + +use bevy_ecs::{ + entity::Entity, + event::{EventReader, EventWriter}, + prelude::{Component, Or, With, Without}, + system::{Commands, ParamSet, Query, ResMut, StaticSystemParam, SystemParam, SystemParamItem}, +}; +use bevy_hierarchy::{Children, Parent}; +use bevy_log::warn; + +use non_empty_vec::NonEmpty; + +use crate::{ + commands::set_focus_state, + events::{self, NavEvent, NavRequest}, + seeds::{self, NavMenu}, +}; + +/// System parameter used to resolve movement and cycling focus updates. +/// +/// This is useful if you don't want to depend on bevy's [`GlobalTransform`] +/// for your UI, or want to implement your own navigation algorithm. For example, +/// if you want your ui to be 3d elements in the world. +/// +/// See the [`UiProjectionQuery`] source code for implementation hints. +pub trait MoveParam { + /// Which `Entity` in `siblings` can be reached from `focused` in + /// `direction` if any, otherwise `None`. + /// + /// * `focused`: The currently focused entity in the menu + /// * `direction`: The direction in which the focus should move + /// * `cycles`: Whether the navigation should loop + /// * `sibligns`: All the other focusable entities in this menu + /// + /// Note that `focused` appears once in `siblings`. + fn resolve_2d<'a>( + &self, + focused: Entity, + direction: events::Direction, + cycles: bool, + siblings: &'a [Entity], + ) -> Option<&'a Entity>; +} + +#[derive(SystemParam)] +pub(crate) struct ChildQueries<'w, 's> { + children: Query<'w, 's, &'static Children>, + is_focusable: Query<'w, 's, With>, + is_menu: Query<'w, 's, Or<(With, With)>>, +} + +/// Collection of queries to manage the navigation tree. +#[allow(clippy::type_complexity)] +#[derive(SystemParam)] +pub(crate) struct NavQueries<'w, 's> { + pub(crate) children: ChildQueries<'w, 's>, + parents: Query<'w, 's, &'static Parent>, + focusables: Query<'w, 's, (Entity, &'static Focusable), Without>, + menus: Query<'w, 's, (Entity, &'static TreeMenu), Without>, +} +impl<'w, 's> NavQueries<'w, 's> { + fn focused(&self) -> Option { + use FocusState::{Dormant, Focused}; + let menu_dormant = + |menu: &TreeMenu| menu.focus_parent.is_none().then_some(menu.active_child); + let any_dormant = |(e, focus): (Entity, &Focusable)| (focus.state == Dormant).then_some(e); + let any_dormant = || self.focusables.iter().find_map(any_dormant); + let root_dormant = || self.menus.iter().find_map(|(_, menu)| menu_dormant(menu)); + let fallback = || self.focusables.iter().next().map(|(entity, _)| entity); + self.focusables + .iter() + .find_map(|(e, focus)| (focus.state == Focused).then_some(e)) + .or_else(root_dormant) + .or_else(any_dormant) + .or_else(fallback) + } +} + +/// Queries [`Focusable`] and [`TreeMenu`] in a mutable way. +/// +/// NOTE: This is separate from [`NavQueries`] to work around +/// [bevy's query exclusion rules][bevy-exclusion] in the `marker.rs` +/// module. +/// +/// [bevy-exclusion]: https://github.com/bevyengine/bevy/issues/4624 +#[derive(SystemParam)] +pub(crate) struct MutQueries<'w, 's> { + parents: Query<'w, 's, &'static Parent>, + focusables: Query<'w, 's, &'static mut Focusable, Without>, + menus: Query<'w, 's, &'static mut TreeMenu, Without>, +} +impl<'w, 's> MutQueries<'w, 's> { + /// Set the [`active_child`](TreeMenu::active_child) field of the enclosing + /// [`TreeMenu`] and disables the previous one. + fn set_active_child(&mut self, cmds: &mut Commands, child: Entity) { + let mut focusable = child; + let mut nav_menu = loop { + // Find the enclosing parent menu. + if let Ok(parent) = self.parents.get(focusable) { + let parent = parent.get(); + focusable = parent; + if let Ok(menu) = self.menus.get_mut(parent) { + break menu; + } + } else { + return; + } + }; + let entity = nav_menu.active_child; + nav_menu.active_child = child; + self.set_entity_focus(cmds, entity, FocusState::Inert); + } + + fn set_entity_focus(&mut self, cmds: &mut Commands, entity: Entity, state: FocusState) { + if let Ok(mut focusable) = self.focusables.get_mut(entity) { + focusable.state = state; + cmds.add(set_focus_state(entity, state)); + } + } +} + +/// State of a [`Focusable`]. +#[derive(Clone, Debug, Copy, PartialEq)] +pub enum FocusState { + /// An entity that was previously [`Active`](FocusState::Active) from a branch of + /// the menu tree that is currently not _focused_. When focus comes back to + /// the [`NavMenu`](NavMenu) containing this [`Focusable`], the `Dormant` element + /// will be the [`Focused`](FocusState::Focused) entity. + Dormant, + /// The currently highlighted/used entity, there is only a signle _focused_ + /// entity. + /// + /// All navigation requests start from it. + /// + /// To set an arbitrary [`Focusable`] to _focused_, you should send a + /// [`NavRequest::FocusOn`] request. + Focused, + /// This [`Focusable`] is on the path in the menu tree to the current + /// [`Focused`](FocusState::Focused) entity. + /// + /// [`FocusState::Active`] focusables are the [`Focusable`]s from + /// previous menus that were activated in order to reach the + /// [`NavMenu`](NavMenu) containing the currently _focused_ element. + Active, + /// None of the above: This [`Focusable`] is neither `Dormant`, `Focused` + /// or `Active`. + Inert, +} + +/// The navigation system's lock. +/// +/// When locked, the navigation system doesn't process any [`NavRequest`]. +/// It only waits on a [`NavRequest::Free`] event. It will then continue +/// processing new requests. +pub struct NavLock { + entity: Option, +} +impl NavLock { + pub(crate) fn new() -> Self { + Self { entity: None } + } + /// The [`Entity`](https://docs.rs/bevy/0.8.0/bevy/ecs/entity/struct.Entity.html) + /// that triggered the lock. + pub fn entity(&self) -> Option { + self.entity + } + /// Whether the navigation system is locked. + pub fn is_locked(&self) -> bool { + self.entity.is_some() + } +} + +/// A menu that isolate children [`Focusable`]s from other focusables and +/// specify navigation method within itself. +/// +/// The user can't create a `TreeMenu`, they will use the +/// [`NavMenu`](NavMenu) API and the `TreeMenu` component will be inserted +/// by the [`insert_tree_menus`] system. +#[derive(Debug, Component, Clone)] +pub(crate) struct TreeMenu { + /// The [`Focusable`] that sends to this `NavMenu` when recieving + /// [`NavRequest::Action`]. + pub(crate) focus_parent: Option, + /// How we want the user to move between [`Focusable`]s within this menu. + pub(crate) setting: NavMenu, + /// The currently dormant or active focusable in this menu. + pub(crate) active_child: Entity, +} + +/// The actions triggered by a [`Focusable`]. +#[derive(Clone, Copy, PartialEq, Debug)] +#[non_exhaustive] +pub enum FocusAction { + /// Acts like a standard navigation node. + /// + /// Goes into relevant menu if any [`NavMenu`](NavMenu) is + /// [`reachable_from`](NavMenu::reachable_from) this [`Focusable`]. + Normal, + + /// If we receive [`NavRequest::Action`] while this [`Focusable`] is + /// focused, it will act as a [`NavRequest::Cancel`] (leaving submenu to + /// enter the parent one). + Cancel, + + /// If we receive [`NavRequest::Action`] while this [`Focusable`] is + /// focused, the navigation system will freeze until [`NavRequest::Free`] + /// is received, sending a [`NavEvent::Unlocked`]. + /// + /// This is useful to implement widgets with complex controls you don't + /// want to accidentally unfocus, or suspending the navigation system while + /// in-game. + Lock, +} + +/// An [`Entity`] that can be navigated to using the ui navigation system. +/// +/// It is in one of multiple [`FocusState`], you can check its state with +/// the [`Focusable::state`] method. +/// +/// A `Focusable` can execute a variety of [`FocusAction`] when receiving +/// [`NavRequest::Action`], the default one is [`FocusAction::Normal`] +#[derive(Component, Clone, Debug)] +pub struct Focusable { + pub(crate) state: FocusState, + action: FocusAction, +} +impl Default for Focusable { + fn default() -> Self { + Focusable { + state: FocusState::Inert, + action: FocusAction::Normal, + } + } +} +impl Focusable { + /// Default Focusable + pub fn new() -> Self { + Self::default() + } + /// The [`FocusState`] of this `Focusable`. + pub fn state(&self) -> FocusState { + self.state + } + /// The [`FocusAction`] of this `Focusable`. + pub fn action(&self) -> FocusAction { + self.action + } + /// Spawn a "cancel" focusable, see [`FocusAction::Cancel`]. + pub fn cancel() -> Self { + Focusable { + state: FocusState::Inert, + action: FocusAction::Cancel, + } + } + /// Spawn a "lock" focusable, see [`FocusAction::Lock`]. + pub fn lock() -> Self { + Focusable { + state: FocusState::Inert, + action: FocusAction::Lock, + } + } + /// Spawn a focusable that will get highlighted in priority when none are set yet. + /// + /// **WARNING**: Only use this to spawn the UI. Any of the following state is + /// unspecified and will likely result in broken behavior: + /// * Having multiple dormant `Focusable`s in the same menu. + /// * Updating an already existing `Focusable` with this. + pub fn dormant(self) -> Self { + Self { + state: FocusState::Dormant, + ..self + } + } +} + +/// The currently _focused_ [`Focusable`]. +/// +/// You cannot edit it or create new `Focused` component. To set an arbitrary +/// [`Focusable`] to _focused_, you should send [`NavRequest::FocusOn`]. +/// +/// This [`Component`](https://docs.rs/bevy/0.8.0/bevy/ecs/component/trait.Component.html) +/// is useful if you need to query for the _currently focused_ element using a +/// `Query>` for example. +/// +/// If a [`Focusable`] is focused, its [`Focusable::state()`] will be +/// [`FocusState::Focused`], if you have a [`Focusable`] but can't query +/// filter on [`Focused`], you can check for equality. +/// +/// # Notes +/// +/// The `Focused` marker component is only updated at the end of the +/// `CoreStage::Update` stage. This means it might lead to a single frame of +/// latency compared to using [`Focusable::state()`]. +#[derive(Component)] +#[non_exhaustive] +pub struct Focused; + +pub(crate) fn max_by_in_iter( + iter: impl Iterator, + f: impl Fn(&U) -> T, +) -> Option { + iter.max_by(|s1, s2| { + let s1_val = f(s1); + let s2_val = f(s2); + s1_val.partial_cmp(&s2_val).unwrap_or(Ordering::Equal) + }) +} + +/// Returns the next or previous entity based on `direction` +fn resolve_scope( + focused: Entity, + direction: events::ScopeDirection, + cycles: bool, + siblings: &NonEmpty, +) -> Option<&Entity> { + let focused_index = siblings.iter().position(|e| *e == focused)?; + let new_index = resolve_index(focused_index, cycles, direction, siblings.len().get() - 1); + new_index.and_then(|i| siblings.get(i)) +} + +/// Resolve `request` where the focused element is `focused` +fn resolve( + focused: Entity, + request: NavRequest, + queries: &NavQueries, + lock: &mut NavLock, + from: Vec, + mquery: &MP, +) -> NavEvent { + use NavRequest::*; + + assert!( + queries.focusables.get(focused).is_ok(), + "The resolution algorithm MUST go from a focusable element" + ); + assert!( + !from.contains(&focused), + "Navigation graph cycle detected! This panic has prevented a stack overflow, \ + please check usages of `NavMenu::reachable_from`" + ); + + let mut from = (from, focused).into(); + + macro_rules! or_none { + ($to_match:expr) => { + match $to_match { + Some(x) => x, + None => return NavEvent::NoChanges { from, request }, + } + }; + } + match request { + Move(direction) => { + let (parent, cycles) = match parent_menu(focused, queries) { + Some(val) if !val.1.setting.is_2d() => { + return NavEvent::NoChanges { from, request } + } + Some(val) => (Some(val.0), !val.1.setting.bound()), + None => (None, true), + }; + let siblings = match parent { + Some(parent) => queries.children.focusables_of(parent), + None => { + let focusables: Vec<_> = queries.focusables.iter().map(|tpl| tpl.0).collect(); + NonEmpty::try_from(focusables).expect( + "There must be at least one `Focusable` when sending a `NavRequest`!", + ) + } + }; + let to = mquery.resolve_2d(focused, direction, cycles, &siblings); + NavEvent::focus_changed(*or_none!(to), from) + } + Cancel => { + let to = or_none!(parent_menu(focused, queries)); + let to = or_none!(to.1.focus_parent); + from.push(to); + NavEvent::focus_changed(to, from) + } + Action => { + if let Ok((_, focusable)) = queries.focusables.get(focused) { + match focusable.action { + FocusAction::Cancel => { + let mut from = from.to_vec(); + from.truncate(from.len() - 1); + return resolve(focused, NavRequest::Cancel, queries, lock, from, mquery); + } + FocusAction::Lock => { + lock.entity = Some(focused); + return NavEvent::Locked(focused); + } + FocusAction::Normal => {} + } + } + let child_menu = child_menu(focused, queries); + let (_, menu) = or_none!(child_menu); + let to = (menu.active_child, from.clone().into()).into(); + NavEvent::FocusChanged { to, from } + } + ScopeMove(scope_dir) => { + let (parent, menu) = or_none!(parent_menu(focused, queries)); + let siblings = queries.children.focusables_of(parent); + if !menu.setting.is_scope() { + let focused = or_none!(menu.focus_parent); + resolve(focused, request, queries, lock, from.into(), mquery) + } else { + let cycles = !menu.setting.bound(); + let to = or_none!(resolve_scope(focused, scope_dir, cycles, &siblings)); + let extra = match child_menu(*to, queries) { + Some((_, menu)) => focus_deep(menu, queries), + None => Vec::new(), + }; + let to = (extra, *to).into(); + NavEvent::FocusChanged { to, from } + } + } + FocusOn(new_to_focus) => { + let mut from = root_path(focused, queries); + let mut to = root_path(new_to_focus, queries); + trim_common_tail(&mut from, &mut to); + if from == to { + NavEvent::NoChanges { from, request } + } else { + NavEvent::FocusChanged { from, to } + } + } + Free => { + if let Some(lock_entity) = lock.entity.take() { + NavEvent::Unlocked(lock_entity) + } else { + warn!("Received a NavRequest::Free while not locked"); + NavEvent::NoChanges { from, request } + } + } + } +} + +/// Replaces [`seeds::TreeMenuSeed`]s with proper [`TreeMenu`]s. +pub(crate) fn insert_tree_menus( + mut cmds: Commands, + seeds: Query<(Entity, &seeds::TreeMenuSeed)>, + queries: NavQueries, +) { + use FocusState::{Active, Dormant, Focused}; + let mut inserts = Vec::new(); + for (entity, seed) in seeds.iter() { + let children = queries.children.focusables_of(entity); + let child = children + .iter() + .find_map(|e| { + let (_, focusable) = queries.focusables.get(*e).ok()?; + matches!(focusable.state, Dormant | Active | Focused).then_some(e) + }) + .unwrap_or_else(|| children.first()); + let menu = seed.clone().with_child(*child); + inserts.push((entity, (menu,))); + cmds.entity(entity).remove::(); + } + cmds.insert_or_spawn_batch(inserts) +} + +/// System to set the first [`Focusable`] to [`FocusState::Focused`] when no +/// navigation has been done yet. +pub(crate) fn set_first_focused( + has_focused: Query<(), With>, + mut queries: ParamSet<(NavQueries, MutQueries)>, + mut cmds: Commands, + mut events: EventWriter, +) { + use FocusState::Focused; + if has_focused.is_empty() { + if let Some(to_focus) = queries.p0().focused() { + queries.p1().set_entity_focus(&mut cmds, to_focus, Focused); + events.send(NavEvent::InitiallyFocused(to_focus)); + } + } +} + +/// Listen to [`NavRequest`] and update the state of [`Focusable`] entities if +/// relevant. +pub(crate) fn listen_nav_requests( + mut cmds: Commands, + mut queries: ParamSet<(NavQueries, MutQueries)>, + mquery: StaticSystemParam, + mut lock: ResMut, + mut requests: EventReader, + mut events: EventWriter, +) where + for<'w, 's> SystemParamItem<'w, 's, MP>: MoveParam, +{ + use FocusState as Fs; + + let no_focused = "Tried to execute a NavRequest when no focusables exist, NavRequest does nothing if there isn't any navigation to do."; + for request in requests.iter() { + if lock.is_locked() && *request != NavRequest::Free { + continue; + } + // TODO: ensure no multiple Focused entities + let focused = if let Some(e) = queries.p0().focused() { + e + } else { + warn!(no_focused); + continue; + }; + let from = Vec::new(); + let event = resolve(focused, *request, &queries.p0(), &mut lock, from, &*mquery); + // Change focus state of relevant entities + if let NavEvent::FocusChanged { to, from } = &event { + let mut mut_queries = queries.p1(); + if to == from { + continue; + } + let (&disable, put_to_sleep) = from.split_last(); + mut_queries.set_entity_focus(&mut cmds, disable, Fs::Inert); + for &entity in put_to_sleep { + mut_queries.set_entity_focus(&mut cmds, entity, Fs::Dormant); + } + let (&focus, activate) = to.split_first(); + mut_queries.set_active_child(&mut cmds, focus); + mut_queries.set_entity_focus(&mut cmds, focus, Fs::Focused); + for &entity in activate { + mut_queries.set_active_child(&mut cmds, entity); + mut_queries.set_entity_focus(&mut cmds, entity, Fs::Active); + } + }; + events.send(event); + } +} + +/// The child [`TreeMenu`] of `focusable`. +fn child_menu<'a>(focusable: Entity, queries: &'a NavQueries) -> Option<(Entity, &'a TreeMenu)> { + queries + .menus + .iter() + .find(|e| e.1.focus_parent == Some(focusable)) +} + +/// The [`TreeMenu`] containing `focusable`, if any. +pub(crate) fn parent_menu(focusable: Entity, queries: &NavQueries) -> Option<(Entity, TreeMenu)> { + let parent = queries.parents.get(focusable).ok()?.get(); + match queries.menus.get(parent) { + Ok(menu) => Some((parent, menu.1.clone())), + Err(_) => parent_menu(parent, queries), + } +} + +impl<'w, 's> ChildQueries<'w, 's> { + /// All sibling [`Focusable`]s within a single [`TreeMenu`]. + pub(crate) fn focusables_of(&self, menu: Entity) -> NonEmpty { + let ret = self.focusables_of_helper(menu); + NonEmpty::try_from(ret) + .expect("A NavMenu MUST AT LEAST HAVE ONE Focusable child, found one without") + } + + fn focusables_of_helper(&self, menu: Entity) -> Vec { + match self.children.get(menu).ok() { + Some(direct_children) => { + let focusables = direct_children + .iter() + .filter(|e| self.is_focusable.get(**e).is_ok()) + .cloned(); + let transitive_focusables = direct_children + .iter() + .filter(|e| self.is_focusable.get(**e).is_err()) + .filter(|e| self.is_menu.get(**e).is_err()) + .flat_map(|e| self.focusables_of_helper(*e)); + focusables.chain(transitive_focusables).collect() + } + None => Vec::new(), + } + } +} + +/// Remove all mutually identical elements at the end of `v1` and `v2`. +fn trim_common_tail(v1: &mut NonEmpty, v2: &mut NonEmpty) { + let mut i1 = v1.len().get() - 1; + let mut i2 = v2.len().get() - 1; + loop { + if v1[i1] != v2[i2] { + // unwraps: any usize + 1 (saturating) is NonZero + let l1 = NonZeroUsize::new(i1.saturating_add(1)).unwrap(); + let l2 = NonZeroUsize::new(i2.saturating_add(1)).unwrap(); + v1.truncate(l1); + v2.truncate(l2); + return; + } else if i1 != 0 && i2 != 0 { + i1 -= 1; + i2 -= 1; + } else { + // There is no changes to be made to the input vectors + return; + } + } +} + +fn root_path(mut from: Entity, queries: &NavQueries) -> NonEmpty { + let mut ret = NonEmpty::new(from); + loop { + from = match parent_menu(from, queries) { + // purely personal preference over deeply nested pattern match + Some((_, menu)) if menu.focus_parent.is_some() => menu.focus_parent.unwrap(), + _ => return ret, + }; + assert!( + !ret.contains(&from), + "Navigation graph cycle detected! This panic has prevented a stack \ + overflow, please check usages of `NavMenu::reachable_from`" + ); + ret.push(from); + } +} + +/// Navigate downward the menu hierarchy, traversing all dormant children. +fn focus_deep<'a>(mut menu: &'a TreeMenu, queries: &'a NavQueries) -> Vec { + let mut ret = Vec::with_capacity(4); + loop { + let last = menu.active_child; + ret.insert(0, last); + menu = match child_menu(last, queries) { + Some((_, menu)) => menu, + None => return ret, + }; + } +} + +/// Cycle through a [scoped menu](NavMenu::BoundScope) according to menu settings +/// +/// Returns the index of the element to focus according to `direction`. Cycles +/// if `cycles` and goes over `max_value` or goes bellow 0. `None` if the +/// direction is a dead end. +fn resolve_index( + from: usize, + cycles: bool, + direction: events::ScopeDirection, + max_value: usize, +) -> Option { + use events::ScopeDirection::*; + match (direction, from) { + (Previous, 0) => cycles.then_some(max_value), + (Previous, from) => Some(from - 1), + (Next, from) if from == max_value => cycles.then_some(0), + (Next, from) => Some(from + 1), + } +} + +#[cfg(test)] +mod tests { + use super::trim_common_tail; + #[test] + fn test_trim_common_tail() { + use non_empty_vec::ne_vec; + let mut v1 = ne_vec![1, 2, 3, 4, 5, 6, 7]; + let mut v2 = ne_vec![3, 2, 1, 4, 5, 6, 7]; + trim_common_tail(&mut v1, &mut v2); + assert_eq!(v1, ne_vec![1, 2, 3]); + assert_eq!(v2, ne_vec![3, 2, 1]); + } +} diff --git a/crates/bevy_ui_navigation/src/seeds.rs b/crates/bevy_ui_navigation/src/seeds.rs new file mode 100644 index 00000000000000..728b825df15eed --- /dev/null +++ b/crates/bevy_ui_navigation/src/seeds.rs @@ -0,0 +1,288 @@ +//! [`NavMenu`] builders to convert into [`TreeMenu`]. +//! +//! This module defines a bunch of "seed" bundles. Systems in [`crate::named`], +//! and [`crate::resolve`] will take the components +//! defined in those seeds and replace them by [`NavMenu`]s. It is necessary +//! for a few things: +//! * The [`active_child`](TreeMenu::active_child) field of `NavMenu`, which +//! cannot be inferred without the [`Focusable`](crate::Focusable)s children of that menu +//! * Finding the [`Focusable`](crate::Focusable) specified in [`ParentName`] +//! +//! # Seed bundles +//! +//! Seed bundles are collections of components that will trigger various +//! pre-processing to create a [`TreeMenu`]. They are a combination of those +//! components: +//! * [`TreeMenuSeed`]: the base seed, which will be converted into a [`TreeMenu`] +//! in [`crate::resolve::insert_tree_menus`]. +//! * [`ParentName`], the *by-name* marker: marks a [`TreeMenuSeed`] as needing +//! its [`focus_parent`](TreeMenuSeed::focus_parent) to be updated by +//! [`crate::named::resolve_named_menus`] with the [`Focusable`](crate::Focusable) which +//! [`Name`](https://docs.rs/bevy/0.8.0/bevy/core/struct.Name.html) matches +//! the one in [`ParentName`]. If for whatever reason that update doesn't +//! happen, [`crate::resolve::insert_tree_menus`] will panic. +//! +//! Those components are combined in the seed bundles. Which processing step is +//! applied to the [`TreeMenuSeed`] depends on which components it was inserted +//! with. The bundles are: +//! * [`MenuSeed`]: Creates a [`NavMenu`]. +//! * [`NamedMenuSeed`]: Creates a [`NavMenu`] "reachable from" the +//! [`Focusable`](crate::Focusable) named in the [`ParentName`]. +//! +//! # Ordering +//! +//! In order to correctly create the [`TreeMenu`] specified with the bundles +//! declared int this module, the systems need to be ran in this order: +//! +//! ```text +//! named::resolve_named_menus → resolve::insert_tree_menus +//! ``` +//! +//! The resolve_named/insert relationship should be upheld. Otherwise, root +//! NavMenus will spawn instead of NavMenus with parent with given name. +#![allow(unused_parens)] + +use std::borrow::Cow; + +use bevy_core::Name; +use bevy_ecs::{ + entity::Entity, + prelude::{Bundle, Component}, +}; + +use crate::resolve::TreeMenu; + +/// Option of an option. +/// +/// It's really just `Option>` with some semantic sparkled on top. +#[derive(Clone)] +pub(crate) enum FailableOption { + Uninit, + None, + Some(T), +} +impl FailableOption { + fn into_opt(self) -> Option> { + match self { + Self::Some(t) => Some(Some(t)), + Self::None => Some(None), + Self::Uninit => None, + } + } +} +impl From> for FailableOption { + fn from(option: Option) -> Self { + option.map_or(Self::None, Self::Some) + } +} + +/// An uninitialized [`TreeMenu`]. +/// +/// It is added through one of the bundles defined in this crate by the user, +/// and picked up by the [`crate::resolve::insert_tree_menus`] system to create the +/// actual [`TreeMenu`] handled by the resolution algorithm. +#[derive(Component, Clone)] +pub(crate) struct TreeMenuSeed { + pub(crate) focus_parent: FailableOption, + menu: NavMenu, +} +impl TreeMenuSeed { + /// Initialize a [`TreeMenu`] with given active child. + /// + /// (Menus without focusables are a programming error) + pub(crate) fn with_child(self, active_child: Entity) -> TreeMenu { + let TreeMenuSeed { focus_parent, menu } = self; + let msg = "An initialized parent value"; + TreeMenu { + focus_parent: focus_parent.into_opt().expect(msg), + setting: menu, + active_child, + } + } +} + +/// Component to specify creation of a [`TreeMenu`] refering to their parent +/// focusable by [`Name`](https://docs.rs/bevy/0.8.0/bevy/core/struct.Name.html) +/// +/// It is used in [`crate::named::resolve_named_menus`] to figure out the +/// `Entity` id of the named parent of the [`TreeMenuSeed`] and set its +/// `focus_parent` field. +#[derive(Component, Clone)] +pub(crate) struct ParentName(pub(crate) Name); + +/// Component to add to [`NavMenu`] entities to propagate `T` to all +/// [`Focusable`](crate::Focusable) children of that menu. +#[derive(Component, Clone)] +pub(crate) struct NavMarker(pub(crate) T); + +/// A menu that isolate children [`Focusable`](crate::Focusable)s from other +/// focusables and specify navigation method within itself. +/// +/// # Usage +/// +/// A [`NavMenu`] can be used to: +/// * Prevent navigation from one specific submenu to another +/// * Specify if 2d navigation wraps around the screen, see +/// [`NavMenu::Wrapping2d`]. +/// * Specify "scope menus" such that a +/// [`NavRequest::ScopeMove`](crate::NavRequest::ScopeMove) emitted when +/// the focused element is a [`Focusable`](crate::Focusable) nested within this `NavMenu` +/// will navigate this menu. See [`NavMenu::BoundScope`] and +/// [`NavMenu::WrappingScope`]. +/// * Specify _submenus_ and specify from where those submenus are reachable. +/// * Add a specific component to all [`Focusable`](crate::Focusable)s in this menu. You must +/// first create a "seed" bundle with any of the [`NavMenu`] methods and then +/// call [`marking`](MenuSeed::marking) on it. +/// * Specify which entity will be the parents of this [`NavMenu`], see +/// [`NavMenu::reachable_from`] or [`NavMenu::reachable_from_named`] if you don't +/// have access to the [`Entity`](https://docs.rs/bevy/0.8.0/bevy/ecs/entity/struct.Entity.html) +/// for the parent [`Focusable`](crate::Focusable) +/// +/// If you want to specify which [`Focusable`](crate::Focusable) should be +/// focused first when entering a menu, you should mark one of the children of +/// this menu with [`Focusable::dormant`](crate::Focusable::dormant). +/// +/// ## Example +/// +/// See the example in this [crate]'s root level documentation page. +/// +/// # Invariants +/// +/// **You need to follow those rules (invariants) to avoid panics**: +/// 1. A `Menu` must have **at least one** [`Focusable`](crate::Focusable) child in the UI +/// hierarchy. +/// 2. There must not be a menu loop. Ie: a way to go from menu A to menu B and +/// then from menu B to menu A while never going back. +/// 3. Focusables in 2d menus must have a `GlobalTransform`. +/// +/// # Panics +/// +/// Thankfully, programming errors are caught early and you'll probably get a +/// panic fairly quickly if you don't follow the invariants. +/// * Invariant (1) panics as soon as you add the menu without focusable +/// children. +/// * Invariant (2) panics if the focus goes into a menu loop. +#[derive(Clone, Debug, Copy, PartialEq)] +#[non_exhaustive] +pub enum NavMenu { + /// Non-wrapping menu with 2d navigation. + /// + /// It is possible to move around this menu in all cardinal directions, the + /// focus changes according to the physical position of the + /// [`Focusable`](crate::Focusable) in it. + /// + /// If the player moves to a direction where there aren't any focusables, + /// nothing will happen. + Bound2d, + + /// Wrapping menu with 2d navigation. + /// + /// It is possible to move around this menu in all cardinal directions, the + /// focus changes according to the physical position of the + /// [`Focusable`](crate::Focusable) in it. + /// + /// If the player moves to a direction where there aren't any focusables, + /// the focus will "wrap" to the other direction of the screen. + Wrapping2d, + + /// Non-wrapping scope menu + /// + /// Controlled with [`NavRequest::ScopeMove`](crate::NavRequest::ScopeMove) + /// even when the focused element is not in this menu, but in a submenu + /// reachable from this one. + BoundScope, + + /// Wrapping scope menu + /// + /// Controlled with [`NavRequest::ScopeMove`](crate::NavRequest::ScopeMove) even + /// when the focused element is not in this menu, but in a submenu reachable from this one. + WrappingScope, +} +impl NavMenu { + pub(crate) fn bound(&self) -> bool { + matches!(self, NavMenu::BoundScope | NavMenu::Bound2d) + } + pub(crate) fn is_2d(&self) -> bool { + !self.is_scope() + } + pub(crate) fn is_scope(&self) -> bool { + matches!(self, NavMenu::BoundScope | NavMenu::WrappingScope) + } +} + +/// A "seed" for creation of a [`NavMenu`]. +/// +/// Internally, `bevy_ui_navigation` uses a special component to mark UI nodes +/// as "menus", this tells the navigation algorithm to add that component to +/// this `Entity`. +#[derive(Bundle, Clone)] +pub struct MenuSeed { + seed: TreeMenuSeed, +} + +/// Bundle to specify creation of a [`NavMenu`] refering to their parent +/// focusable by [`Name`](https://docs.rs/bevy/0.8.0/bevy/core/struct.Name.html) +/// +/// This is useful if, for example, you just want to spawn your UI without +/// keeping track of entity ids of buttons that leads to submenus. +#[derive(Bundle, Clone)] +pub struct NamedMenuSeed { + seed: TreeMenuSeed, + parent_name: ParentName, +} + +impl NavMenu { + fn seed(self, focus_parent: FailableOption) -> TreeMenuSeed { + TreeMenuSeed { + focus_parent, + menu: self, + } + } + + /// Spawn a [`NavMenu`] seed with provided parent entity (or root if + /// `None`). + /// + /// Prefer [`Self::reachable_from`] and [`Self::root`] to this if you don't + /// already have an `Option`. + pub fn with_parent(self, focus_parent: Option) -> MenuSeed { + let seed = self.seed(focus_parent.into()); + MenuSeed { seed } + } + + /// Spawn this menu with no parents. + /// + /// No [`Focusable`](crate::Focusable) will "lead to" this menu. You either need to + /// programmatically give focus to this menu tree with + /// [`NavRequest::FocusOn`](crate::NavRequest::FocusOn) or have only one root menu. + pub fn root(self) -> MenuSeed { + self.with_parent(None) + } + + /// Spawn this menu as reachable from a given [`Focusable`](crate::Focusable) + /// + /// When requesting [`NavRequest::Action`](crate::NavRequest::Action) + /// when `focusable` is focused, the focus will be changed to a focusable + /// within this menu. + /// + /// # Important + /// + /// You must ensure this doesn't create a cycle. Eg: you shouldn't be able + /// to reach `NavMenu` X from `Focusable` Y if there is a path from + /// `NavMenu` X to `Focusable` Y. + pub fn reachable_from(self, focusable: Entity) -> MenuSeed { + self.with_parent(Some(focusable)) + } + + /// Spawn this menu as reachable from a [`Focusable`](crate::Focusable) with a + /// [`Name`](https://docs.rs/bevy/0.8.0/bevy/core/struct.Name.html) + /// component. + /// + /// This is useful if, for example, you just want to spawn your UI without + /// keeping track of entity ids of buttons that leads to submenus. + pub fn reachable_from_named(self, parent_label: impl Into>) -> NamedMenuSeed { + NamedMenuSeed { + parent_name: ParentName(Name::new(parent_label)), + seed: self.seed(FailableOption::Uninit), + } + } +} diff --git a/examples/ui_navigation/locking.rs b/examples/ui_navigation/locking.rs new file mode 100644 index 00000000000000..a83f8c78ce95e6 --- /dev/null +++ b/examples/ui_navigation/locking.rs @@ -0,0 +1,104 @@ +use bevy::prelude::*; + +use bevy::ui_navigation::NavRequestSystem; + +/// This example illustrates how to make a button "lock". To lock the UI, press +/// 'A' on controller or 'left click' on mouse when the button with the wrench is +/// focused. +/// +/// To leave lock mode, press 'escape' on keyboard or 'start' on controller. +/// This will emit a `NavRequest::Free` in the default input systems. Allowing +/// the focus to change again. +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .init_resource::() + .add_startup_system(setup) + .add_system(button_system.after(NavRequestSystem)) + .add_system(print_nav_events.after(NavRequestSystem)) + .run(); +} + +fn print_nav_events(mut events: EventReader) { + for event in events.iter() { + println!("{:?}", event); + } +} + +#[allow(clippy::type_complexity)] +fn button_system( + mut interaction_query: Query<(&Focusable, &mut UiColor), (Changed, With