Path: blob/main/crates/bevy_input_focus/src/tab_navigation.rs
6849 views
//! This module provides a framework for handling linear tab-key navigation in Bevy applications.1//!2//! The rules of tabbing are derived from the HTML specification, and are as follows:3//!4//! * An index >= 0 means that the entity is tabbable via sequential navigation.5//! The order of tabbing is determined by the index, with lower indices being tabbed first.6//! If two entities have the same index, then the order is determined by the order of7//! the entities in the ECS hierarchy (as determined by Parent/Child).8//! * An index < 0 means that the entity is not focusable via sequential navigation, but9//! can still be focused via direct selection.10//!11//! Tabbable entities must be descendants of a [`TabGroup`] entity, which is a component that12//! marks a tree of entities as containing tabbable elements. The order of tab groups13//! is determined by the [`TabGroup::order`] field, with lower orders being tabbed first. Modal tab groups14//! are used for ui elements that should only tab within themselves, such as modal dialog boxes.15//!16//! To enable automatic tabbing, add the17//! [`TabNavigationPlugin`] and [`InputDispatchPlugin`](crate::InputDispatchPlugin) to your app.18//! This will install a keyboard event observer on the primary window which automatically handles19//! tab navigation for you.20//!21//! Alternatively, if you want to have more control over tab navigation, or are using an input-action-mapping framework,22//! you can use the [`TabNavigation`] system parameter directly instead.23//! This object can be injected into your systems, and provides a [`navigate`](`TabNavigation::navigate`) method which can be24//! used to navigate between focusable entities.2526use alloc::vec::Vec;27use bevy_app::{App, Plugin, Startup};28use bevy_ecs::{29component::Component,30entity::Entity,31hierarchy::{ChildOf, Children},32observer::On,33query::{With, Without},34system::{Commands, Query, Res, ResMut, SystemParam},35};36use bevy_input::{37keyboard::{KeyCode, KeyboardInput},38ButtonInput, ButtonState,39};40use bevy_picking::events::{Pointer, Press};41use bevy_window::{PrimaryWindow, Window};42use log::warn;43use thiserror::Error;4445use crate::{AcquireFocus, FocusedInput, InputFocus, InputFocusVisible};4647#[cfg(feature = "bevy_reflect")]48use {49bevy_ecs::prelude::ReflectComponent,50bevy_reflect::{prelude::*, Reflect},51};5253/// A component which indicates that an entity wants to participate in tab navigation.54///55/// Note that you must also add the [`TabGroup`] component to the entity's ancestor in order56/// for this component to have any effect.57#[derive(Debug, Default, Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]58#[cfg_attr(59feature = "bevy_reflect",60derive(Reflect),61reflect(Debug, Default, Component, PartialEq, Clone)62)]63pub struct TabIndex(pub i32);6465/// A component used to mark a tree of entities as containing tabbable elements.66#[derive(Debug, Default, Component, Copy, Clone)]67#[cfg_attr(68feature = "bevy_reflect",69derive(Reflect),70reflect(Debug, Default, Component, Clone)71)]72pub struct TabGroup {73/// The order of the tab group relative to other tab groups.74pub order: i32,7576/// Whether this is a 'modal' group. If true, then tabbing within the group (that is,77/// if the current focus entity is a child of this group) will cycle through the children78/// of this group. If false, then tabbing within the group will cycle through all non-modal79/// tab groups.80pub modal: bool,81}8283impl TabGroup {84/// Create a new tab group with the given order.85pub fn new(order: i32) -> Self {86Self {87order,88modal: false,89}90}9192/// Create a modal tab group.93pub fn modal() -> Self {94Self {95order: 0,96modal: true,97}98}99}100101/// A navigation action that users might take to navigate your user interface in a cyclic fashion.102///103/// These values are consumed by the [`TabNavigation`] system param.104#[derive(Clone, Copy)]105pub enum NavAction {106/// Navigate to the next focusable entity, wrapping around to the beginning if at the end.107///108/// This is commonly triggered by pressing the Tab key.109Next,110/// Navigate to the previous focusable entity, wrapping around to the end if at the beginning.111///112/// This is commonly triggered by pressing Shift+Tab.113Previous,114/// Navigate to the first focusable entity.115///116/// This is commonly triggered by pressing Home.117First,118/// Navigate to the last focusable entity.119///120/// This is commonly triggered by pressing End.121Last,122}123124/// An error that can occur during [tab navigation](crate::tab_navigation).125#[derive(Debug, Error, PartialEq, Eq, Clone)]126pub enum TabNavigationError {127/// No tab groups were found.128#[error("No tab groups found")]129NoTabGroups,130/// No focusable entities were found.131#[error("No focusable entities found")]132NoFocusableEntities,133/// Could not navigate to the next focusable entity.134///135/// This can occur if your tab groups are malformed.136#[error("Failed to navigate to next focusable entity")]137FailedToNavigateToNextFocusableEntity,138/// No tab group for the current focus entity was found.139#[error("No tab group found for currently focused entity {previous_focus}. Users will not be able to navigate back to this entity.")]140NoTabGroupForCurrentFocus {141/// The entity that was previously focused,142/// and is missing its tab group.143previous_focus: Entity,144/// The new entity that will be focused.145///146/// If you want to recover from this error, set [`InputFocus`] to this entity.147new_focus: Entity,148},149}150151/// An injectable helper object that provides tab navigation functionality.152#[doc(hidden)]153#[derive(SystemParam)]154pub struct TabNavigation<'w, 's> {155// Query for tab groups.156tabgroup_query: Query<'w, 's, (Entity, &'static TabGroup, &'static Children)>,157// Query for tab indices.158tabindex_query: Query<159'w,160's,161(Entity, Option<&'static TabIndex>, Option<&'static Children>),162Without<TabGroup>,163>,164// Query for parents.165parent_query: Query<'w, 's, &'static ChildOf>,166}167168impl TabNavigation<'_, '_> {169/// Navigate to the desired focusable entity.170///171/// Change the [`NavAction`] to navigate in a different direction.172/// Focusable entities are determined by the presence of the [`TabIndex`] component.173///174/// If no focusable entities are found, then this function will return either the first175/// or last focusable entity, depending on the direction of navigation. For example, if176/// `action` is `Next` and no focusable entities are found, then this function will return177/// the first focusable entity.178pub fn navigate(179&self,180focus: &InputFocus,181action: NavAction,182) -> Result<Entity, TabNavigationError> {183// If there are no tab groups, then there are no focusable entities.184if self.tabgroup_query.is_empty() {185return Err(TabNavigationError::NoTabGroups);186}187188// Start by identifying which tab group we are in. Mainly what we want to know is if189// we're in a modal group.190let tabgroup = focus.0.and_then(|focus_ent| {191self.parent_query192.iter_ancestors(focus_ent)193.find_map(|entity| {194self.tabgroup_query195.get(entity)196.ok()197.map(|(_, tg, _)| (entity, tg))198})199});200201let navigation_result = self.navigate_in_group(tabgroup, focus, action);202203match navigation_result {204Ok(entity) => {205if focus.0.is_some() && tabgroup.is_none() {206Err(TabNavigationError::NoTabGroupForCurrentFocus {207previous_focus: focus.0.unwrap(),208new_focus: entity,209})210} else {211Ok(entity)212}213}214Err(e) => Err(e),215}216}217218fn navigate_in_group(219&self,220tabgroup: Option<(Entity, &TabGroup)>,221focus: &InputFocus,222action: NavAction,223) -> Result<Entity, TabNavigationError> {224// List of all focusable entities found.225let mut focusable: Vec<(Entity, TabIndex, usize)> =226Vec::with_capacity(self.tabindex_query.iter().len());227228match tabgroup {229Some((tg_entity, tg)) if tg.modal => {230// We're in a modal tab group, then gather all tab indices in that group.231if let Ok((_, _, children)) = self.tabgroup_query.get(tg_entity) {232for child in children.iter() {233self.gather_focusable(&mut focusable, *child, 0);234}235}236}237_ => {238// Otherwise, gather all tab indices in all non-modal tab groups.239let mut tab_groups: Vec<(Entity, TabGroup)> = self240.tabgroup_query241.iter()242.filter(|(_, tg, _)| !tg.modal)243.map(|(e, tg, _)| (e, *tg))244.collect();245// Stable sort by group order246tab_groups.sort_by_key(|(_, tg)| tg.order);247248// Search group descendants249tab_groups250.iter()251.enumerate()252.for_each(|(idx, (tg_entity, _))| {253self.gather_focusable(&mut focusable, *tg_entity, idx);254});255}256}257258if focusable.is_empty() {259return Err(TabNavigationError::NoFocusableEntities);260}261262// Sort by TabGroup and then TabIndex263focusable.sort_by(|(_, a_tab_idx, a_group), (_, b_tab_idx, b_group)| {264if a_group == b_group {265a_tab_idx.cmp(b_tab_idx)266} else {267a_group.cmp(b_group)268}269});270271let index = focusable.iter().position(|e| Some(e.0) == focus.0);272let count = focusable.len();273let next = match (index, action) {274(Some(idx), NavAction::Next) => (idx + 1).rem_euclid(count),275(Some(idx), NavAction::Previous) => (idx + count - 1).rem_euclid(count),276(None, NavAction::Next) | (_, NavAction::First) => 0,277(None, NavAction::Previous) | (_, NavAction::Last) => count - 1,278};279match focusable.get(next) {280Some((entity, _, _)) => Ok(*entity),281None => Err(TabNavigationError::FailedToNavigateToNextFocusableEntity),282}283}284285/// Gather all focusable entities in tree order.286fn gather_focusable(287&self,288out: &mut Vec<(Entity, TabIndex, usize)>,289parent: Entity,290tab_group_idx: usize,291) {292if let Ok((entity, tabindex, children)) = self.tabindex_query.get(parent) {293if let Some(tabindex) = tabindex {294if tabindex.0 >= 0 {295out.push((entity, *tabindex, tab_group_idx));296}297}298if let Some(children) = children {299for child in children.iter() {300// Don't traverse into tab groups, as they are handled separately.301if self.tabgroup_query.get(*child).is_err() {302self.gather_focusable(out, *child, tab_group_idx);303}304}305}306} else if let Ok((_, tabgroup, children)) = self.tabgroup_query.get(parent) {307if !tabgroup.modal {308for child in children.iter() {309self.gather_focusable(out, *child, tab_group_idx);310}311}312}313}314}315316/// Observer which sets focus to the nearest ancestor that has tab index, using bubbling.317pub(crate) fn acquire_focus(318mut acquire_focus: On<AcquireFocus>,319focusable: Query<(), With<TabIndex>>,320windows: Query<(), With<Window>>,321mut focus: ResMut<InputFocus>,322) {323// If the entity has a TabIndex324if focusable.contains(acquire_focus.focused_entity) {325// Stop and focus it326acquire_focus.propagate(false);327// Don't mutate unless we need to, for change detection328if focus.0 != Some(acquire_focus.focused_entity) {329focus.0 = Some(acquire_focus.focused_entity);330}331} else if windows.contains(acquire_focus.focused_entity) {332// Stop and clear focus333acquire_focus.propagate(false);334// Don't mutate unless we need to, for change detection335if focus.0.is_some() {336focus.clear();337}338}339}340341/// Plugin for navigating between focusable entities using keyboard input.342pub struct TabNavigationPlugin;343344impl Plugin for TabNavigationPlugin {345fn build(&self, app: &mut App) {346app.add_systems(Startup, setup_tab_navigation);347app.add_observer(acquire_focus);348app.add_observer(click_to_focus);349}350}351352fn setup_tab_navigation(mut commands: Commands, window: Query<Entity, With<PrimaryWindow>>) {353for window in window.iter() {354commands.entity(window).observe(handle_tab_navigation);355}356}357358fn click_to_focus(359press: On<Pointer<Press>>,360mut focus_visible: ResMut<InputFocusVisible>,361windows: Query<Entity, With<PrimaryWindow>>,362mut commands: Commands,363) {364// Because `Pointer` is a bubbling event, we don't want to trigger an `AcquireFocus` event365// for every ancestor, but only for the original entity. Also, users may want to stop366// propagation on the pointer event at some point along the bubbling chain, so we need our367// own dedicated event whose propagation we can control.368if press.entity == press.original_event_target() {369// Clicking hides focus370if focus_visible.0 {371focus_visible.0 = false;372}373// Search for a focusable parent entity, defaulting to window if none.374if let Ok(window) = windows.single() {375commands.trigger(AcquireFocus {376focused_entity: press.entity,377window,378});379}380}381}382383/// Observer function which handles tab navigation.384///385/// This observer responds to [`KeyCode::Tab`] events and Shift+Tab events,386/// cycling through focusable entities in the order determined by their tab index.387///388/// Any [`TabNavigationError`]s that occur during tab navigation are logged as warnings.389pub fn handle_tab_navigation(390mut event: On<FocusedInput<KeyboardInput>>,391nav: TabNavigation,392mut focus: ResMut<InputFocus>,393mut visible: ResMut<InputFocusVisible>,394keys: Res<ButtonInput<KeyCode>>,395) {396// Tab navigation.397let key_event = &event.input;398if key_event.key_code == KeyCode::Tab399&& key_event.state == ButtonState::Pressed400&& !key_event.repeat401{402let maybe_next = nav.navigate(403&focus,404if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {405NavAction::Previous406} else {407NavAction::Next408},409);410411match maybe_next {412Ok(next) => {413event.propagate(false);414focus.set(next);415visible.0 = true;416}417Err(e) => {418warn!("Tab navigation error: {e}");419// This failure mode is recoverable, but still indicates a problem.420if let TabNavigationError::NoTabGroupForCurrentFocus { new_focus, .. } = e {421event.propagate(false);422focus.set(new_focus);423visible.0 = true;424}425}426}427}428}429430#[cfg(test)]431mod tests {432use bevy_ecs::system::SystemState;433434use super::*;435436#[test]437fn test_tab_navigation() {438let mut app = App::new();439let world = app.world_mut();440441let tab_group_entity = world.spawn(TabGroup::new(0)).id();442let tab_entity_1 = world.spawn((TabIndex(0), ChildOf(tab_group_entity))).id();443let tab_entity_2 = world.spawn((TabIndex(1), ChildOf(tab_group_entity))).id();444445let mut system_state: SystemState<TabNavigation> = SystemState::new(world);446let tab_navigation = system_state.get(world);447assert_eq!(tab_navigation.tabgroup_query.iter().count(), 1);448assert_eq!(tab_navigation.tabindex_query.iter().count(), 2);449450let next_entity =451tab_navigation.navigate(&InputFocus::from_entity(tab_entity_1), NavAction::Next);452assert_eq!(next_entity, Ok(tab_entity_2));453454let prev_entity =455tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Previous);456assert_eq!(prev_entity, Ok(tab_entity_1));457458let first_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::First);459assert_eq!(first_entity, Ok(tab_entity_1));460461let last_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::Last);462assert_eq!(last_entity, Ok(tab_entity_2));463}464465#[test]466fn test_tab_navigation_between_groups_is_sorted_by_group() {467let mut app = App::new();468let world = app.world_mut();469470let tab_group_1 = world.spawn(TabGroup::new(0)).id();471let tab_entity_1 = world.spawn((TabIndex(0), ChildOf(tab_group_1))).id();472let tab_entity_2 = world.spawn((TabIndex(1), ChildOf(tab_group_1))).id();473474let tab_group_2 = world.spawn(TabGroup::new(1)).id();475let tab_entity_3 = world.spawn((TabIndex(0), ChildOf(tab_group_2))).id();476let tab_entity_4 = world.spawn((TabIndex(1), ChildOf(tab_group_2))).id();477478let mut system_state: SystemState<TabNavigation> = SystemState::new(world);479let tab_navigation = system_state.get(world);480assert_eq!(tab_navigation.tabgroup_query.iter().count(), 2);481assert_eq!(tab_navigation.tabindex_query.iter().count(), 4);482483let next_entity =484tab_navigation.navigate(&InputFocus::from_entity(tab_entity_1), NavAction::Next);485assert_eq!(next_entity, Ok(tab_entity_2));486487let prev_entity =488tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Previous);489assert_eq!(prev_entity, Ok(tab_entity_1));490491let first_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::First);492assert_eq!(first_entity, Ok(tab_entity_1));493494let last_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::Last);495assert_eq!(last_entity, Ok(tab_entity_4));496497let next_from_end_of_group_entity =498tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Next);499assert_eq!(next_from_end_of_group_entity, Ok(tab_entity_3));500501let prev_entity_from_start_of_group =502tab_navigation.navigate(&InputFocus::from_entity(tab_entity_3), NavAction::Previous);503assert_eq!(prev_entity_from_start_of_group, Ok(tab_entity_2));504}505}506507508