Path: blob/main/crates/bevy_input_focus/src/tab_navigation.rs
9353 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_window::{PrimaryWindow, Window};41use log::warn;42use thiserror::Error;4344use crate::{AcquireFocus, FocusedInput, InputFocus, InputFocusVisible};4546#[cfg(feature = "bevy_reflect")]47use {48bevy_ecs::prelude::ReflectComponent,49bevy_reflect::{prelude::*, Reflect},50};5152/// A component which indicates that an entity wants to participate in tab navigation.53///54/// Note that you must also add the [`TabGroup`] component to the entity's ancestor in order55/// for this component to have any effect.56#[derive(Debug, Default, Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]57#[cfg_attr(58feature = "bevy_reflect",59derive(Reflect),60reflect(Debug, Default, Component, PartialEq, Clone)61)]62pub struct TabIndex(pub i32);6364/// A component used to mark a tree of entities as containing tabbable elements.65#[derive(Debug, Default, Component, Copy, Clone)]66#[cfg_attr(67feature = "bevy_reflect",68derive(Reflect),69reflect(Debug, Default, Component, Clone)70)]71pub struct TabGroup {72/// The order of the tab group relative to other tab groups.73pub order: i32,7475/// Whether this is a 'modal' group. If true, then tabbing within the group (that is,76/// if the current focus entity is a child of this group) will cycle through the children77/// of this group. If false, then tabbing within the group will cycle through all non-modal78/// tab groups.79pub modal: bool,80}8182impl TabGroup {83/// Create a new tab group with the given order.84pub fn new(order: i32) -> Self {85Self {86order,87modal: false,88}89}9091/// Create a modal tab group.92pub fn modal() -> Self {93Self {94order: 0,95modal: true,96}97}98}99100/// A navigation action that users might take to navigate your user interface in a cyclic fashion.101///102/// These values are consumed by the [`TabNavigation`] system param.103#[derive(Clone, Copy)]104pub enum NavAction {105/// Navigate to the next focusable entity, wrapping around to the beginning if at the end.106///107/// This is commonly triggered by pressing the Tab key.108Next,109/// Navigate to the previous focusable entity, wrapping around to the end if at the beginning.110///111/// This is commonly triggered by pressing Shift+Tab.112Previous,113/// Navigate to the first focusable entity.114///115/// This is commonly triggered by pressing Home.116First,117/// Navigate to the last focusable entity.118///119/// This is commonly triggered by pressing End.120Last,121}122123/// An error that can occur during [tab navigation](crate::tab_navigation).124#[derive(Debug, Error, PartialEq, Eq, Clone)]125pub enum TabNavigationError {126/// No tab groups were found.127#[error("No tab groups found")]128NoTabGroups,129/// No focusable entities were found.130#[error("No focusable entities found")]131NoFocusableEntities,132/// Could not navigate to the next focusable entity.133///134/// This can occur if your tab groups are malformed.135#[error("Failed to navigate to next focusable entity")]136FailedToNavigateToNextFocusableEntity,137/// No tab group for the current focus entity was found.138#[error("No tab group found for currently focused entity {previous_focus}. Users will not be able to navigate back to this entity.")]139NoTabGroupForCurrentFocus {140/// The entity that was previously focused,141/// and is missing its tab group.142previous_focus: Entity,143/// The new entity that will be focused.144///145/// If you want to recover from this error, set [`InputFocus`] to this entity.146new_focus: Entity,147},148}149150/// An injectable helper object that provides tab navigation functionality.151#[doc(hidden)]152#[derive(SystemParam)]153pub struct TabNavigation<'w, 's> {154// Query for tab groups.155tabgroup_query: Query<'w, 's, (Entity, &'static TabGroup, &'static Children)>,156// Query for tab indices.157tabindex_query: Query<158'w,159's,160(Entity, Option<&'static TabIndex>, Option<&'static Children>),161Without<TabGroup>,162>,163// Query for parents.164parent_query: Query<'w, 's, &'static ChildOf>,165}166167impl TabNavigation<'_, '_> {168/// Navigate to the desired focusable entity, relative to the current focused entity.169///170/// Change the [`NavAction`] to navigate in a different direction.171/// Focusable entities are determined by the presence of the [`TabIndex`] component.172///173/// If there is no currently focused entity, then this function will return either the first174/// or last focusable entity, depending on the direction of navigation. For example, if175/// `action` is `Next` and no focusable entities are found, then this function will return176/// the first focusable entity.177pub fn navigate(178&self,179focus: &InputFocus,180action: NavAction,181) -> Result<Entity, TabNavigationError> {182// If there are no tab groups, then there are no focusable entities.183if self.tabgroup_query.is_empty() {184return Err(TabNavigationError::NoTabGroups);185}186187// Start by identifying which tab group we are in. Mainly what we want to know is if188// we're in a modal group.189let tabgroup = focus.0.and_then(|focus_ent| {190self.parent_query191.iter_ancestors(focus_ent)192.find_map(|entity| {193self.tabgroup_query194.get(entity)195.ok()196.map(|(_, tg, _)| (entity, tg))197})198});199200self.navigate_internal(focus.0, action, tabgroup)201}202203/// Initialize focus to a focusable child of a container, either the first or last204/// depending on [`NavAction`]. This assumes that the parent entity has a [`TabGroup`]205/// component.206///207/// Focusable entities are determined by the presence of the [`TabIndex`] component.208pub fn initialize(209&self,210parent: Entity,211action: NavAction,212) -> Result<Entity, TabNavigationError> {213// If there are no tab groups, then there are no focusable entities.214if self.tabgroup_query.is_empty() {215return Err(TabNavigationError::NoTabGroups);216}217218// Look for the tab group on the parent entity.219match self.tabgroup_query.get(parent) {220Ok(tabgroup) => self.navigate_internal(None, action, Some((parent, tabgroup.1))),221Err(_) => Err(TabNavigationError::NoTabGroups),222}223}224225pub fn navigate_internal(226&self,227focus: Option<Entity>,228action: NavAction,229tabgroup: Option<(Entity, &TabGroup)>,230) -> Result<Entity, TabNavigationError> {231let navigation_result = self.navigate_in_group(tabgroup, focus, action);232233match navigation_result {234Ok(entity) => {235if let Some(previous_focus) = focus236&& tabgroup.is_none()237{238Err(TabNavigationError::NoTabGroupForCurrentFocus {239previous_focus,240new_focus: entity,241})242} else {243Ok(entity)244}245}246Err(e) => Err(e),247}248}249250fn navigate_in_group(251&self,252tabgroup: Option<(Entity, &TabGroup)>,253focus: Option<Entity>,254action: NavAction,255) -> Result<Entity, TabNavigationError> {256// List of all focusable entities found.257let mut focusable: Vec<(Entity, TabIndex, usize)> =258Vec::with_capacity(self.tabindex_query.iter().len());259260match tabgroup {261Some((tg_entity, tg)) if tg.modal => {262// We're in a modal tab group, then gather all tab indices in that group.263if let Ok((_, _, children)) = self.tabgroup_query.get(tg_entity) {264for child in children.iter() {265self.gather_focusable(&mut focusable, *child, 0);266}267}268}269_ => {270// Otherwise, gather all tab indices in all non-modal tab groups.271let mut tab_groups: Vec<(Entity, TabGroup)> = self272.tabgroup_query273.iter()274.filter(|(_, tg, _)| !tg.modal)275.map(|(e, tg, _)| (e, *tg))276.collect();277// Stable sort by group order278tab_groups.sort_by_key(|(_, tg)| tg.order);279280// Search group descendants281tab_groups282.iter()283.enumerate()284.for_each(|(idx, (tg_entity, _))| {285self.gather_focusable(&mut focusable, *tg_entity, idx);286});287}288}289290if focusable.is_empty() {291return Err(TabNavigationError::NoFocusableEntities);292}293294// Sort by TabGroup and then TabIndex295focusable.sort_by(|(_, a_tab_idx, a_group), (_, b_tab_idx, b_group)| {296if a_group == b_group {297a_tab_idx.cmp(b_tab_idx)298} else {299a_group.cmp(b_group)300}301});302303let index = focusable.iter().position(|e| Some(e.0) == focus);304let count = focusable.len();305let next = match (index, action) {306(Some(idx), NavAction::Next) => (idx + 1).rem_euclid(count),307(Some(idx), NavAction::Previous) => (idx + count - 1).rem_euclid(count),308(None, NavAction::Next) | (_, NavAction::First) => 0,309(None, NavAction::Previous) | (_, NavAction::Last) => count - 1,310};311match focusable.get(next) {312Some((entity, _, _)) => Ok(*entity),313None => Err(TabNavigationError::FailedToNavigateToNextFocusableEntity),314}315}316317/// Gather all focusable entities in tree order.318fn gather_focusable(319&self,320out: &mut Vec<(Entity, TabIndex, usize)>,321parent: Entity,322tab_group_idx: usize,323) {324if let Ok((entity, tabindex, children)) = self.tabindex_query.get(parent) {325if let Some(tabindex) = tabindex {326if tabindex.0 >= 0 {327out.push((entity, *tabindex, tab_group_idx));328}329}330if let Some(children) = children {331for child in children.iter() {332// Don't traverse into tab groups, as they are handled separately.333if self.tabgroup_query.get(*child).is_err() {334self.gather_focusable(out, *child, tab_group_idx);335}336}337}338} else if let Ok((_, tabgroup, children)) = self.tabgroup_query.get(parent) {339if !tabgroup.modal {340for child in children.iter() {341self.gather_focusable(out, *child, tab_group_idx);342}343}344}345}346}347348/// Observer which sets focus to the nearest ancestor that has tab index, using bubbling.349pub(crate) fn acquire_focus(350mut acquire_focus: On<AcquireFocus>,351focusable: Query<(), With<TabIndex>>,352windows: Query<(), With<Window>>,353mut focus: ResMut<InputFocus>,354) {355// If the entity has a TabIndex356if focusable.contains(acquire_focus.focused_entity) {357// Stop and focus it358acquire_focus.propagate(false);359// Don't mutate unless we need to, for change detection360if focus.0 != Some(acquire_focus.focused_entity) {361focus.0 = Some(acquire_focus.focused_entity);362}363} else if windows.contains(acquire_focus.focused_entity) {364// Stop and clear focus365acquire_focus.propagate(false);366// Don't mutate unless we need to, for change detection367if focus.0.is_some() {368focus.clear();369}370}371}372373/// Plugin for navigating between focusable entities using keyboard input.374pub struct TabNavigationPlugin;375376impl Plugin for TabNavigationPlugin {377fn build(&self, app: &mut App) {378app.add_systems(Startup, setup_tab_navigation);379app.add_observer(acquire_focus);380#[cfg(feature = "bevy_picking")]381app.add_observer(click_to_focus);382}383}384385fn setup_tab_navigation(mut commands: Commands, window: Query<Entity, With<PrimaryWindow>>) {386for window in window.iter() {387commands.entity(window).observe(handle_tab_navigation);388}389}390391#[cfg(feature = "bevy_picking")]392fn click_to_focus(393press: On<bevy_picking::events::Pointer<bevy_picking::events::Press>>,394mut focus_visible: ResMut<InputFocusVisible>,395windows: Query<Entity, With<PrimaryWindow>>,396mut commands: Commands,397) {398// Because `Pointer` is a bubbling event, we don't want to trigger an `AcquireFocus` event399// for every ancestor, but only for the original entity. Also, users may want to stop400// propagation on the pointer event at some point along the bubbling chain, so we need our401// own dedicated event whose propagation we can control.402if press.entity == press.original_event_target() {403// Clicking hides focus404if focus_visible.0 {405focus_visible.0 = false;406}407// Search for a focusable parent entity, defaulting to window if none.408if let Ok(window) = windows.single() {409commands.trigger(AcquireFocus {410focused_entity: press.entity,411window,412});413}414}415}416417/// Observer function which handles tab navigation.418///419/// This observer responds to [`KeyCode::Tab`] events and Shift+Tab events,420/// cycling through focusable entities in the order determined by their tab index.421///422/// Any [`TabNavigationError`]s that occur during tab navigation are logged as warnings.423pub fn handle_tab_navigation(424mut event: On<FocusedInput<KeyboardInput>>,425nav: TabNavigation,426mut focus: ResMut<InputFocus>,427mut visible: ResMut<InputFocusVisible>,428keys: Res<ButtonInput<KeyCode>>,429) {430// Tab navigation.431let key_event = &event.input;432if key_event.key_code == KeyCode::Tab433&& key_event.state == ButtonState::Pressed434&& !key_event.repeat435{436let maybe_next = nav.navigate(437&focus,438if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {439NavAction::Previous440} else {441NavAction::Next442},443);444445match maybe_next {446Ok(next) => {447event.propagate(false);448focus.set(next);449visible.0 = true;450}451Err(e) => {452warn!("Tab navigation error: {e}");453// This failure mode is recoverable, but still indicates a problem.454if let TabNavigationError::NoTabGroupForCurrentFocus { new_focus, .. } = e {455event.propagate(false);456focus.set(new_focus);457visible.0 = true;458}459}460}461}462}463464#[cfg(test)]465mod tests {466use bevy_ecs::system::SystemState;467468use super::*;469470#[test]471fn test_tab_navigation() {472let mut app = App::new();473let world = app.world_mut();474475let tab_group_entity = world.spawn(TabGroup::new(0)).id();476let tab_entity_1 = world.spawn((TabIndex(0), ChildOf(tab_group_entity))).id();477let tab_entity_2 = world.spawn((TabIndex(1), ChildOf(tab_group_entity))).id();478479let mut system_state: SystemState<TabNavigation> = SystemState::new(world);480let tab_navigation = system_state.get(world);481assert_eq!(tab_navigation.tabgroup_query.iter().count(), 1);482assert!(tab_navigation.tabindex_query.iter().count() >= 2);483484let next_entity =485tab_navigation.navigate(&InputFocus::from_entity(tab_entity_1), NavAction::Next);486assert_eq!(next_entity, Ok(tab_entity_2));487488let prev_entity =489tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Previous);490assert_eq!(prev_entity, Ok(tab_entity_1));491492let first_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::First);493assert_eq!(first_entity, Ok(tab_entity_1));494495let last_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::Last);496assert_eq!(last_entity, Ok(tab_entity_2));497}498499#[test]500fn test_tab_navigation_between_groups_is_sorted_by_group() {501let mut app = App::new();502let world = app.world_mut();503504let tab_group_1 = world.spawn(TabGroup::new(0)).id();505let tab_entity_1 = world.spawn((TabIndex(0), ChildOf(tab_group_1))).id();506let tab_entity_2 = world.spawn((TabIndex(1), ChildOf(tab_group_1))).id();507508let tab_group_2 = world.spawn(TabGroup::new(1)).id();509let tab_entity_3 = world.spawn((TabIndex(0), ChildOf(tab_group_2))).id();510let tab_entity_4 = world.spawn((TabIndex(1), ChildOf(tab_group_2))).id();511512let mut system_state: SystemState<TabNavigation> = SystemState::new(world);513let tab_navigation = system_state.get(world);514assert_eq!(tab_navigation.tabgroup_query.iter().count(), 2);515assert!(tab_navigation.tabindex_query.iter().count() >= 4);516517let next_entity =518tab_navigation.navigate(&InputFocus::from_entity(tab_entity_1), NavAction::Next);519assert_eq!(next_entity, Ok(tab_entity_2));520521let prev_entity =522tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Previous);523assert_eq!(prev_entity, Ok(tab_entity_1));524525let first_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::First);526assert_eq!(first_entity, Ok(tab_entity_1));527528let last_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::Last);529assert_eq!(last_entity, Ok(tab_entity_4));530531let next_from_end_of_group_entity =532tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Next);533assert_eq!(next_from_end_of_group_entity, Ok(tab_entity_3));534535let prev_entity_from_start_of_group =536tab_navigation.navigate(&InputFocus::from_entity(tab_entity_3), NavAction::Previous);537assert_eq!(prev_entity_from_start_of_group, Ok(tab_entity_2));538}539}540541542