Path: blob/main/crates/bevy_input_focus/src/tab_navigation.rs
6595 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 ev: On<AcquireFocus>,319focusable: Query<(), With<TabIndex>>,320windows: Query<(), With<Window>>,321mut focus: ResMut<InputFocus>,322) {323// If the entity has a TabIndex324if focusable.contains(ev.entity()) {325// Stop and focus it326ev.propagate(false);327// Don't mutate unless we need to, for change detection328if focus.0 != Some(ev.entity()) {329focus.0 = Some(ev.entity());330}331} else if windows.contains(ev.entity()) {332// Stop and clear focus333ev.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(359ev: 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 ev.entity() == ev.original_entity() {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() {375commands376.entity(ev.entity())377.trigger(AcquireFocus { window });378}379}380}381382/// Observer function which handles tab navigation.383///384/// This observer responds to [`KeyCode::Tab`] events and Shift+Tab events,385/// cycling through focusable entities in the order determined by their tab index.386///387/// Any [`TabNavigationError`]s that occur during tab navigation are logged as warnings.388pub fn handle_tab_navigation(389mut event: On<FocusedInput<KeyboardInput>>,390nav: TabNavigation,391mut focus: ResMut<InputFocus>,392mut visible: ResMut<InputFocusVisible>,393keys: Res<ButtonInput<KeyCode>>,394) {395// Tab navigation.396let key_event = &event.input;397if key_event.key_code == KeyCode::Tab398&& key_event.state == ButtonState::Pressed399&& !key_event.repeat400{401let maybe_next = nav.navigate(402&focus,403if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {404NavAction::Previous405} else {406NavAction::Next407},408);409410match maybe_next {411Ok(next) => {412event.propagate(false);413focus.set(next);414visible.0 = true;415}416Err(e) => {417warn!("Tab navigation error: {e}");418// This failure mode is recoverable, but still indicates a problem.419if let TabNavigationError::NoTabGroupForCurrentFocus { new_focus, .. } = e {420event.propagate(false);421focus.set(new_focus);422visible.0 = true;423}424}425}426}427}428429#[cfg(test)]430mod tests {431use bevy_ecs::system::SystemState;432433use super::*;434435#[test]436fn test_tab_navigation() {437let mut app = App::new();438let world = app.world_mut();439440let tab_group_entity = world.spawn(TabGroup::new(0)).id();441let tab_entity_1 = world.spawn((TabIndex(0), ChildOf(tab_group_entity))).id();442let tab_entity_2 = world.spawn((TabIndex(1), ChildOf(tab_group_entity))).id();443444let mut system_state: SystemState<TabNavigation> = SystemState::new(world);445let tab_navigation = system_state.get(world);446assert_eq!(tab_navigation.tabgroup_query.iter().count(), 1);447assert_eq!(tab_navigation.tabindex_query.iter().count(), 2);448449let next_entity =450tab_navigation.navigate(&InputFocus::from_entity(tab_entity_1), NavAction::Next);451assert_eq!(next_entity, Ok(tab_entity_2));452453let prev_entity =454tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Previous);455assert_eq!(prev_entity, Ok(tab_entity_1));456457let first_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::First);458assert_eq!(first_entity, Ok(tab_entity_1));459460let last_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::Last);461assert_eq!(last_entity, Ok(tab_entity_2));462}463464#[test]465fn test_tab_navigation_between_groups_is_sorted_by_group() {466let mut app = App::new();467let world = app.world_mut();468469let tab_group_1 = world.spawn(TabGroup::new(0)).id();470let tab_entity_1 = world.spawn((TabIndex(0), ChildOf(tab_group_1))).id();471let tab_entity_2 = world.spawn((TabIndex(1), ChildOf(tab_group_1))).id();472473let tab_group_2 = world.spawn(TabGroup::new(1)).id();474let tab_entity_3 = world.spawn((TabIndex(0), ChildOf(tab_group_2))).id();475let tab_entity_4 = world.spawn((TabIndex(1), ChildOf(tab_group_2))).id();476477let mut system_state: SystemState<TabNavigation> = SystemState::new(world);478let tab_navigation = system_state.get(world);479assert_eq!(tab_navigation.tabgroup_query.iter().count(), 2);480assert_eq!(tab_navigation.tabindex_query.iter().count(), 4);481482let next_entity =483tab_navigation.navigate(&InputFocus::from_entity(tab_entity_1), NavAction::Next);484assert_eq!(next_entity, Ok(tab_entity_2));485486let prev_entity =487tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Previous);488assert_eq!(prev_entity, Ok(tab_entity_1));489490let first_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::First);491assert_eq!(first_entity, Ok(tab_entity_1));492493let last_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::Last);494assert_eq!(last_entity, Ok(tab_entity_4));495496let next_from_end_of_group_entity =497tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Next);498assert_eq!(next_from_end_of_group_entity, Ok(tab_entity_3));499500let prev_entity_from_start_of_group =501tab_navigation.navigate(&InputFocus::from_entity(tab_entity_3), NavAction::Previous);502assert_eq!(prev_entity_from_start_of_group, Ok(tab_entity_2));503}504}505506507