From 90830872f3d621f3da14213db12363a850c82971 Mon Sep 17 00:00:00 2001 From: Talin Date: Thu, 12 Dec 2024 16:53:58 -0800 Subject: [PATCH 1/7] Tab navigation framework for bevy_input_focus. --- Cargo.toml | 5 + crates/bevy_input_focus/Cargo.toml | 1 + crates/bevy_input_focus/src/lib.rs | 75 +++- crates/bevy_input_focus/src/tab_navigation.rs | 336 ++++++++++++++++++ examples/ui/tab_navigation.rs | 221 ++++++++++++ 5 files changed, 623 insertions(+), 15 deletions(-) create mode 100644 crates/bevy_input_focus/src/tab_navigation.rs create mode 100644 examples/ui/tab_navigation.rs diff --git a/Cargo.toml b/Cargo.toml index fd61ba79853ba..b7050744fc756 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3933,5 +3933,10 @@ name = "testbed_ui_layout_rounding" path = "examples/testbed/ui_layout_rounding.rs" doc-scrape-examples = true +[[example]] +name = "tab_navigation" +path = "examples/ui/tab_navigation.rs" +doc-scrape-examples = true + [package.metadata.example.testbed_ui_layout_rounding] hidden = true diff --git a/crates/bevy_input_focus/Cargo.toml b/crates/bevy_input_focus/Cargo.toml index e460f28dc6988..eb5420225fc5a 100644 --- a/crates/bevy_input_focus/Cargo.toml +++ b/crates/bevy_input_focus/Cargo.toml @@ -14,6 +14,7 @@ bevy_app = { path = "../bevy_app", version = "0.15.0-dev", default-features = fa bevy_ecs = { path = "../bevy_ecs", version = "0.15.0-dev", default-features = false } bevy_input = { path = "../bevy_input", version = "0.15.0-dev", default-features = false } bevy_hierarchy = { path = "../bevy_hierarchy", version = "0.15.0-dev", default-features = false } +bevy_utils = { path = "../bevy_utils", version = "0.15.0-dev", default-features = false } bevy_window = { path = "../bevy_window", version = "0.15.0-dev", default-features = false } [dev-dependencies] diff --git a/crates/bevy_input_focus/src/lib.rs b/crates/bevy_input_focus/src/lib.rs index c4a26ec0d8546..d0717aea203d5 100644 --- a/crates/bevy_input_focus/src/lib.rs +++ b/crates/bevy_input_focus/src/lib.rs @@ -16,18 +16,22 @@ //! This crate does *not* provide any integration with UI widgets, or provide functions for //! tab navigation or gamepad-based focus navigation, as those are typically application-specific. +pub mod tab_navigation; + use bevy_app::{App, Plugin, PreUpdate}; use bevy_ecs::{ component::Component, entity::Entity, event::{Event, EventReader}, - query::With, + query::{QueryData, With}, system::{Commands, Query, Res, Resource, SystemParam}, + traversal::Traversal, world::{Command, DeferredWorld, World}, }; use bevy_hierarchy::{HierarchyQueryExt, Parent}; use bevy_input::keyboard::KeyboardInput; -use bevy_window::PrimaryWindow; +use bevy_window::{PrimaryWindow, Window}; +use core::fmt::Debug; /// Resource representing which entity has input focus, if any. Keyboard events will be /// dispatched to the current focus entity, or to the primary window if no entity has focus. @@ -102,14 +106,43 @@ impl SetInputFocus for Commands<'_, '_> { /// input focus entity, if any. If no entity has input focus, then the event is dispatched to /// the main window. #[derive(Clone, Debug, Component)] -pub struct FocusKeyboardInput(pub KeyboardInput); +pub struct FocusKeyboardInput { + /// The keyboard input event. + pub input: KeyboardInput, + window: Entity, +} impl Event for FocusKeyboardInput { - type Traversal = &'static Parent; + type Traversal = WindowTraversal; const AUTO_PROPAGATE: bool = true; } +#[derive(QueryData)] +/// These are for accessing components defined on the targeted entity +pub struct WindowTraversal { + parent: Option<&'static Parent>, + window: Option<&'static Window>, +} + +impl Traversal for WindowTraversal { + fn traverse(item: Self::Item<'_>, event: &FocusKeyboardInput) -> Option { + let WindowTraversalItem { parent, window } = item; + + // Send event to parent, if it has one. + if let Some(parent) = parent { + return Some(parent.get()); + }; + + // Otherwise, send it to the window entity (unless this is a window entity). + if window.is_none() { + return Some(event.window); + } + + None + } +} + /// Plugin which registers the system for dispatching keyboard events based on focus and /// hover state. pub struct InputDispatchPlugin; @@ -130,17 +163,29 @@ fn dispatch_keyboard_input( windows: Query>, mut commands: Commands, ) { - // If an element has keyboard focus, then dispatch the key event to that element. - if let Some(focus_elt) = focus.0 { - for ev in key_events.read() { - commands.trigger_targets(FocusKeyboardInput(ev.clone()), focus_elt); - } - } else { - // If no element has input focus, then dispatch the key event to the primary window. - // There should be only one primary window. - if let Ok(window) = windows.get_single() { + if let Ok(window) = windows.get_single() { + // If an element has keyboard focus, then dispatch the key event to that element. + if let Some(focus_elt) = focus.0 { + for ev in key_events.read() { + commands.trigger_targets( + FocusKeyboardInput { + input: ev.clone(), + window, + }, + focus_elt, + ); + } + } else { + // If no element has input focus, then dispatch the key event to the primary window. + // There should be only one primary window. for ev in key_events.read() { - commands.trigger_targets(FocusKeyboardInput(ev.clone()), window); + commands.trigger_targets( + FocusKeyboardInput { + input: ev.clone(), + window, + }, + window, + ); } } } @@ -266,7 +311,7 @@ mod tests { mut query: Query<&mut GatherKeyboardEvents>, ) { if let Ok(mut gather) = query.get_mut(trigger.target()) { - if let Key::Character(c) = &trigger.0.logical_key { + if let Key::Character(c) = &trigger.input.logical_key { gather.0.push_str(c.as_str()); } } diff --git a/crates/bevy_input_focus/src/tab_navigation.rs b/crates/bevy_input_focus/src/tab_navigation.rs new file mode 100644 index 0000000000000..f8b6df847e09d --- /dev/null +++ b/crates/bevy_input_focus/src/tab_navigation.rs @@ -0,0 +1,336 @@ +//! This module provides a framework for handling linear tab-key navigation in Bevy applications. +//! +//! The rules of tabbing are derived from the HTML specification, and are as follows: +//! +//! * An index >= 0 means that the entity is tabbable via sequential navigation. +//! The order of tabbing is determined by the index, with lower indices being tabbed first. +//! If two entities have the same index, then the order is determined by the order of +//! the entities in the ECS hierarchy (as determined by Parent/Child). +//! * An index < 0 means that the entity is not focusable via sequential navigation, but +//! can still be focused via direct selection. +//! +//! Tabbable entities must be descendants of a `TabGroup` entity, which is a component that +//! marks a tree of entities as containing tabbable elements. The order of tab groups +//! is determined by the `order` field, with lower orders being tabbed first. Modal tab groups +//! are used for ui elements that should only tab within themselves, such as modal dialog boxes. +//! +//! There are several different ways to use this module. To enable automatic tabbing, add the +//! `TabNavigationPlugin` to your app. (Make sure you also have `InputDispatchPlugin` installed). +//! This will install a keyboard event observer on the primary window which automatically handles +//! tab navigation for you. +//! +//! Alternatively, if you want to have more control over tab navigation, or are using an event +//! mapping framework such as LWIM, you can use the `TabNavigation` helper object directly instead. +//! This object can be injected into your systems, and provides a `navigate` method which can be +//! used to navigate between focusable entities. +//! +//! This module also provides `AutoFocus`, a component which can be added to an entity to +//! automatically focus it when it is added to the world. +use bevy_app::{App, Plugin, PreUpdate, Startup}; +use bevy_ecs::{ + component::Component, + entity::Entity, + observer::Trigger, + query::{Added, With, Without}, + system::{Commands, Query, Res, ResMut, SystemParam}, +}; +use bevy_hierarchy::{Children, HierarchyQueryExt, Parent}; +use bevy_input::{keyboard::KeyCode, ButtonInput, ButtonState}; +use bevy_utils::tracing::{info, warn}; +use bevy_window::PrimaryWindow; + +use crate::{FocusKeyboardInput, InputFocus, InputFocusVisible}; + +/// A component which indicates that an entity wants to participate in tab navigation. +/// +/// Note that you must also add the [`TabGroup`] component to the entity's ancestor in order +/// for this component to have any effect. +#[derive(Debug, Default, Component, Copy, Clone)] +pub struct TabIndex(pub i32); + +/// Indicates that this widget should automatically receive focus when it's added. +#[derive(Debug, Default, Component, Copy, Clone)] +pub struct AutoFocus; + +/// A component used to mark a tree of entities as containing tabbable elements. +#[derive(Debug, Default, Component, Copy, Clone)] +pub struct TabGroup { + /// The order of the tab group relative to other tab groups. + pub order: i32, + + /// Whether this is a 'modal' group. If true, then tabbing within the group (that is, + /// if the current focus entity is a child of this group) will cycle through the children + /// of this group. If false, then tabbing within the group will cycle through all non-modal + /// tab groups. + pub modal: bool, +} + +impl TabGroup { + /// Create a new tab group with the given order. + pub fn new(order: i32) -> Self { + Self { + order, + modal: false, + } + } + + /// Create a modal tab group. + pub fn modal() -> Self { + Self { + order: 0, + modal: true, + } + } +} + +/// Navigation action for tabbing. +pub enum NavAction { + /// Navigate to the next focusable entity, wrapping around to the beginning if at the end. + Next, + /// Navigate to the previous focusable entity, wrapping around to the end if at the beginning. + Previous, + /// Navigate to the first focusable entity. + First, + /// Navigate to the last focusable entity. + Last, +} + +/// An injectable helper object that provides tab navigation functionality. +#[doc(hidden)] +#[derive(SystemParam)] +#[allow(clippy::type_complexity)] +pub struct TabNavigation<'w, 's> { + // Query for tab groups. + tabgroup_query: Query<'w, 's, (Entity, &'static TabGroup, &'static Children)>, + // Query for tab indices. + tabindex_query: Query< + 'w, + 's, + (Entity, Option<&'static TabIndex>, Option<&'static Children>), + Without, + >, + // Query for parents. + parent_query: Query<'w, 's, &'static Parent>, +} + +impl TabNavigation<'_, '_> { + /// Navigate to the next focusable entity. + /// + /// Arguments: + /// * `focus`: The current focus entity, or `None` if no entity has focus. + /// * `action`: Whether to select the next, previous, first, or last focusable entity. + /// + /// If no focusable entities are found, then this function will return either the first + /// or last focusable entity, depending on the direction of navigation. For example, if + /// `action` is `Next` and no focusable entities are found, then this function will return + /// the first focusable entity. + pub fn navigate(&self, focus: Option, action: NavAction) -> Option { + // If there are no tab groups, then there are no focusable entities. + if self.tabgroup_query.is_empty() { + warn!("No tab groups found"); + return None; + } + + // Start by identifying which tab group we are in. Mainly what we want to know is if + // we're in a modal group. + let tabgroup = focus.and_then(|focus_ent| { + self.parent_query + .iter_ancestors(focus_ent) + .find_map(|entity| { + self.tabgroup_query + .get(entity) + .ok() + .map(|(_, tg, _)| (entity, tg)) + }) + }); + + if focus.is_some() && tabgroup.is_none() { + warn!("No tab group found for focus entity"); + return None; + } + + self.navigate_in_group(tabgroup, focus, action) + } + + fn navigate_in_group( + &self, + tabgroup: Option<(Entity, &TabGroup)>, + focus: Option, + action: NavAction, + ) -> Option { + // List of all focusable entities found. + let mut focusable: Vec<(Entity, TabIndex)> = + Vec::with_capacity(self.tabindex_query.iter().len()); + + match tabgroup { + Some((tg_entity, tg)) if tg.modal => { + // We're in a modal tab group, then gather all tab indices in that group. + if let Ok((_, _, children)) = self.tabgroup_query.get(tg_entity) { + for child in children.iter() { + self.gather_focusable(&mut focusable, *child); + } + } + } + _ => { + // Otherwise, gather all tab indices in all non-modal tab groups. + let mut tab_groups: Vec<(Entity, TabGroup)> = self + .tabgroup_query + .iter() + .filter(|(_, tg, _)| !tg.modal) + .map(|(e, tg, _)| (e, *tg)) + .collect(); + // Stable sort by group order + tab_groups.sort_by(compare_tab_groups); + + // Search group descendants + tab_groups.iter().for_each(|(tg_entity, _)| { + self.gather_focusable(&mut focusable, *tg_entity); + }); + } + } + + if focusable.is_empty() { + warn!("No focusable entities found"); + return None; + } + + // Stable sort by tabindex + focusable.sort_by(compare_tab_indices); + info!("Focusable entities: {:?}", focusable.len()); + + let index = focusable.iter().position(|e| Some(e.0) == focus); + let count = focusable.len(); + let next = match (index, action) { + (Some(idx), NavAction::Next) => (idx + 1).rem_euclid(count), + (Some(idx), NavAction::Previous) => (idx + count - 1).rem_euclid(count), + (None, NavAction::Next) | (_, NavAction::First) => 0, + (None, NavAction::Previous) | (_, NavAction::Last) => count - 1, + }; + focusable.get(next).map(|(e, _)| e).copied() + } + + /// Gather all focusable entities in tree order. + fn gather_focusable(&self, out: &mut Vec<(Entity, TabIndex)>, parent: Entity) { + if let Ok((entity, tabindex, children)) = self.tabindex_query.get(parent) { + if let Some(tabindex) = tabindex { + if tabindex.0 >= 0 { + out.push((entity, *tabindex)); + } + } + if let Some(children) = children { + for child in children.iter() { + // Don't traverse into tab groups, as they are handled separately. + if self.tabgroup_query.get(*child).is_err() { + self.gather_focusable(out, *child); + } + } + } + } else if let Ok((_, tabgroup, children)) = self.tabgroup_query.get(parent) { + if !tabgroup.modal { + for child in children.iter() { + self.gather_focusable(out, *child); + } + } + } + } +} + +fn compare_tab_groups(a: &(Entity, TabGroup), b: &(Entity, TabGroup)) -> core::cmp::Ordering { + a.1.order.cmp(&b.1.order) +} + +// Stable sort which compares by tab index +fn compare_tab_indices(a: &(Entity, TabIndex), b: &(Entity, TabIndex)) -> core::cmp::Ordering { + a.1 .0.cmp(&b.1 .0) +} + +/// Plugin for handling keyboard input. +pub struct TabNavigationPlugin; + +impl Plugin for TabNavigationPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Startup, setup_tab_navigation) + .add_systems(PreUpdate, handle_auto_focus); + } +} + +fn setup_tab_navigation(mut commands: Commands, window: Query>) { + for window in window.iter() { + commands.entity(window).observe(handle_tab_navigation); + } +} + +/// Observer function which handles tab navigation. +pub fn handle_tab_navigation( + mut trigger: Trigger, + nav: TabNavigation, + mut focus: ResMut, + mut visible: ResMut, + keys: Res>, +) { + // Tab navigation. + let key_event = &trigger.event().input; + if key_event.key_code == KeyCode::Tab + && key_event.state == ButtonState::Pressed + && !key_event.repeat + { + let next = nav.navigate( + focus.0, + if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) { + NavAction::Previous + } else { + NavAction::Next + }, + ); + if next.is_some() { + trigger.propagate(false); + focus.0 = next; + visible.0 = true; + } + } +} + +fn handle_auto_focus( + mut focus: ResMut, + query: Query, Added)>, +) { + if let Some(entity) = query.iter().next() { + focus.0 = Some(entity); + } +} + +#[cfg(test)] +mod tests { + use bevy_ecs::system::SystemState; + use bevy_hierarchy::BuildChildren; + + use super::*; + + #[test] + fn test_tab_navigation() { + let mut app = App::new(); + let world = app.world_mut(); + + let tab_entity_1 = world.spawn(TabIndex(0)).id(); + let tab_entity_2 = world.spawn(TabIndex(1)).id(); + let mut tab_group_entity = world.spawn(TabGroup::new(0)); + tab_group_entity.replace_children(&[tab_entity_1, tab_entity_2]); + + let mut system_state: SystemState = SystemState::new(world); + let tab_navigation = system_state.get(world); + assert_eq!(tab_navigation.tabgroup_query.iter().count(), 1); + assert_eq!(tab_navigation.tabindex_query.iter().count(), 2); + + let next_entity = tab_navigation.navigate(Some(tab_entity_1), NavAction::Next); + assert_eq!(next_entity, Some(tab_entity_2)); + + let prev_entity = tab_navigation.navigate(Some(tab_entity_2), NavAction::Previous); + assert_eq!(prev_entity, Some(tab_entity_1)); + + let first_entity = tab_navigation.navigate(None, NavAction::First); + assert_eq!(first_entity, Some(tab_entity_1)); + + let last_entity = tab_navigation.navigate(None, NavAction::Last); + assert_eq!(last_entity, Some(tab_entity_2)); + } +} diff --git a/examples/ui/tab_navigation.rs b/examples/ui/tab_navigation.rs new file mode 100644 index 0000000000000..00a020362c5ac --- /dev/null +++ b/examples/ui/tab_navigation.rs @@ -0,0 +1,221 @@ +//! This example illustrates the use of tab navigation. + +use bevy::{ + color::palettes::basic::*, + input_focus::{ + tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin}, + InputDispatchPlugin, InputFocus, + }, + prelude::*, + winit::WinitSettings, +}; + +fn main() { + App::new() + .add_plugins((DefaultPlugins, InputDispatchPlugin, TabNavigationPlugin)) + // Only run the app when there is user input. This will significantly reduce CPU/GPU use. + .insert_resource(WinitSettings::desktop_app()) + .add_systems(Startup, setup) + .add_systems(Update, (button_system, focus_system)) + .run(); +} + +const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15); +const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25); +const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35); + +fn button_system( + mut interaction_query: Query< + ( + &Interaction, + &mut BackgroundColor, + &mut BorderColor, + &Children, + ), + (Changed, With