Skip to main content

bevy_input_focus/
tab_navigation.rs

1//! This module provides a framework for handling linear tab-key navigation in Bevy applications.
2//!
3//! The rules of tabbing are derived from the HTML specification, and are as follows:
4//!
5//! * An index >= 0 means that the entity is tabbable via sequential navigation.
6//!   The order of tabbing is determined by the index, with lower indices being tabbed first.
7//!   If two entities have the same index, then the order is determined by the order of
8//!   the entities in the ECS hierarchy (as determined by Parent/Child).
9//! * An index < 0 means that the entity is not focusable via sequential navigation, but
10//!   can still be focused via direct selection.
11//!
12//! Tabbable entities must be descendants of a [`TabGroup`] entity, which is a component that
13//! marks a tree of entities as containing tabbable elements. The order of tab groups
14//! is determined by the [`TabGroup::order`] field, with lower orders being tabbed first. Modal tab groups
15//! are used for ui elements that should only tab within themselves, such as modal dialog boxes.
16//!
17//! To enable automatic tabbing, add the
18//! [`TabNavigationPlugin`] and [`InputDispatchPlugin`](crate::InputDispatchPlugin) to your app.
19//! This will install a keyboard event observer on the primary window which automatically handles
20//! tab navigation for you.
21//!
22//! Alternatively, if you want to have more control over tab navigation, or are using an input-action-mapping framework,
23//! you can use the [`TabNavigation`] system parameter directly instead.
24//! This object can be injected into your systems, and provides a [`navigate`](`TabNavigation::navigate`) method which can be
25//! used to navigate between focusable entities.
26
27use alloc::vec::Vec;
28use bevy_app::{App, Plugin, Startup};
29use bevy_ecs::{
30    component::Component,
31    entity::Entity,
32    hierarchy::{ChildOf, Children},
33    observer::On,
34    query::{With, Without},
35    system::{Commands, Query, Res, ResMut, SystemParam},
36};
37use bevy_input::{
38    keyboard::{KeyCode, KeyboardInput},
39    ButtonInput, ButtonState,
40};
41use bevy_window::{PrimaryWindow, Window};
42use log::warn;
43use thiserror::Error;
44
45use crate::{AcquireFocus, FocusCause, FocusedInput, InputFocus, InputFocusVisible};
46
47#[cfg(feature = "bevy_reflect")]
48use {
49    bevy_ecs::prelude::ReflectComponent,
50    bevy_reflect::{prelude::*, Reflect},
51};
52
53/// 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 order
56/// for this component to have any effect.
57#[derive(Debug, Default, Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
58#[cfg_attr(
59    feature = "bevy_reflect",
60    derive(Reflect),
61    reflect(Debug, Default, Component, PartialEq, Clone)
62)]
63pub struct TabIndex(pub i32);
64
65/// A component used to mark a tree of entities as containing tabbable elements.
66#[derive(Debug, Default, Component, Copy, Clone)]
67#[cfg_attr(
68    feature = "bevy_reflect",
69    derive(Reflect),
70    reflect(Debug, Default, Component, Clone)
71)]
72pub struct TabGroup {
73    /// The order of the tab group relative to other tab groups.
74    pub order: i32,
75
76    /// 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 children
78    /// of this group. If false, then tabbing within the group will cycle through all non-modal
79    /// tab groups.
80    pub modal: bool,
81}
82
83impl TabGroup {
84    /// Create a new tab group with the given order.
85    pub fn new(order: i32) -> Self {
86        Self {
87            order,
88            modal: false,
89        }
90    }
91
92    /// Create a modal tab group.
93    pub fn modal() -> Self {
94        Self {
95            order: 0,
96            modal: true,
97        }
98    }
99}
100
101/// 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, Debug, PartialEq)]
105#[cfg_attr(
106    feature = "bevy_reflect",
107    derive(Reflect),
108    reflect(Debug, Clone, PartialEq)
109)]
110pub enum NavAction {
111    /// Navigate to the next focusable entity, wrapping around to the beginning if at the end.
112    ///
113    /// This is commonly triggered by pressing the Tab key.
114    Next,
115    /// Navigate to the previous focusable entity, wrapping around to the end if at the beginning.
116    ///
117    /// This is commonly triggered by pressing Shift+Tab.
118    Previous,
119    /// Navigate to the first focusable entity.
120    ///
121    /// This is commonly triggered by pressing Home.
122    First,
123    /// Navigate to the last focusable entity.
124    ///
125    /// This is commonly triggered by pressing End.
126    Last,
127}
128
129/// An error that can occur during [tab navigation](crate::tab_navigation).
130#[derive(Debug, Error, PartialEq, Eq, Clone)]
131pub enum TabNavigationError {
132    /// No tab groups were found.
133    #[error("No tab groups found")]
134    NoTabGroups,
135    /// No focusable entities were found.
136    #[error("No focusable entities found")]
137    NoFocusableEntities,
138    /// Could not navigate to the next focusable entity.
139    ///
140    /// This can occur if your tab groups are malformed.
141    #[error("Failed to navigate to next focusable entity")]
142    FailedToNavigateToNextFocusableEntity,
143    /// No tab group for the current focus entity was found.
144    #[error("No tab group found for currently focused entity {previous_focus}. Users will not be able to navigate back to this entity.")]
145    NoTabGroupForCurrentFocus {
146        /// The entity that was previously focused,
147        /// and is missing its tab group.
148        previous_focus: Entity,
149        /// The new entity that will be focused.
150        ///
151        /// If you want to recover from this error, set [`InputFocus`] to this entity.
152        new_focus: Entity,
153    },
154}
155
156/// An injectable helper object that provides tab navigation functionality.
157#[doc(hidden)]
158#[derive(SystemParam)]
159pub struct TabNavigation<'w, 's> {
160    // Query for tab groups.
161    tabgroup_query: Query<'w, 's, (Entity, &'static TabGroup, &'static Children)>,
162    // Query for tab indices.
163    tabindex_query: Query<
164        'w,
165        's,
166        (Entity, Option<&'static TabIndex>, Option<&'static Children>),
167        Without<TabGroup>,
168    >,
169    // Query for parents.
170    parent_query: Query<'w, 's, &'static ChildOf>,
171}
172
173impl TabNavigation<'_, '_> {
174    /// Navigate to the desired focusable entity, relative to the current focused entity.
175    ///
176    /// Change the [`NavAction`] to navigate in a different direction.
177    /// Focusable entities are determined by the presence of the [`TabIndex`] component.
178    ///
179    /// If there is no currently focused entity, then this function will return either the first
180    /// or last focusable entity, depending on the direction of navigation. For example, if
181    /// `action` is `Next` and no focusable entities are found, then this function will return
182    /// the first focusable entity.
183    pub fn navigate(
184        &self,
185        focus: &InputFocus,
186        action: NavAction,
187    ) -> Result<Entity, TabNavigationError> {
188        // If there are no tab groups, then there are no focusable entities.
189        if self.tabgroup_query.is_empty() {
190            return Err(TabNavigationError::NoTabGroups);
191        }
192
193        // Start by identifying which tab group we are in. Mainly what we want to know is if
194        // we're in a modal group.
195        let tabgroup = focus.get().and_then(|focus_ent| {
196            self.parent_query
197                .iter_ancestors(focus_ent)
198                .find_map(|entity| {
199                    self.tabgroup_query
200                        .get(entity)
201                        .ok()
202                        .map(|(_, tg, _)| (entity, tg))
203                })
204        });
205
206        self.navigate_internal(focus.get(), action, tabgroup)
207    }
208
209    /// Initialize focus to a focusable child of a container, either the first or last
210    /// depending on [`NavAction`]. This assumes that the parent entity has a [`TabGroup`]
211    /// component.
212    ///
213    /// Focusable entities are determined by the presence of the [`TabIndex`] component.
214    pub fn initialize(
215        &self,
216        parent: Entity,
217        action: NavAction,
218    ) -> Result<Entity, TabNavigationError> {
219        // If there are no tab groups, then there are no focusable entities.
220        if self.tabgroup_query.is_empty() {
221            return Err(TabNavigationError::NoTabGroups);
222        }
223
224        // Look for the tab group on the parent entity.
225        match self.tabgroup_query.get(parent) {
226            Ok(tabgroup) => self.navigate_internal(None, action, Some((parent, tabgroup.1))),
227            Err(_) => Err(TabNavigationError::NoTabGroups),
228        }
229    }
230
231    pub fn navigate_internal(
232        &self,
233        focus: Option<Entity>,
234        action: NavAction,
235        tabgroup: Option<(Entity, &TabGroup)>,
236    ) -> Result<Entity, TabNavigationError> {
237        let navigation_result = self.navigate_in_group(tabgroup, focus, action);
238
239        match navigation_result {
240            Ok(entity) => {
241                if let Some(previous_focus) = focus
242                    && tabgroup.is_none()
243                {
244                    Err(TabNavigationError::NoTabGroupForCurrentFocus {
245                        previous_focus,
246                        new_focus: entity,
247                    })
248                } else {
249                    Ok(entity)
250                }
251            }
252            Err(e) => Err(e),
253        }
254    }
255
256    fn navigate_in_group(
257        &self,
258        tabgroup: Option<(Entity, &TabGroup)>,
259        focus: Option<Entity>,
260        action: NavAction,
261    ) -> Result<Entity, TabNavigationError> {
262        // List of all focusable entities found.
263        let mut focusable: Vec<(Entity, TabIndex, usize)> =
264            Vec::with_capacity(self.tabindex_query.iter().len());
265
266        match tabgroup {
267            Some((tg_entity, tg)) if tg.modal => {
268                // We're in a modal tab group, then gather all tab indices in that group.
269                if let Ok((_, _, children)) = self.tabgroup_query.get(tg_entity) {
270                    for child in children.iter() {
271                        self.gather_focusable(&mut focusable, *child, 0);
272                    }
273                }
274            }
275            _ => {
276                // Otherwise, gather all tab indices in all non-modal tab groups.
277                let mut tab_groups: Vec<(Entity, TabGroup)> = self
278                    .tabgroup_query
279                    .iter()
280                    .filter(|(_, tg, _)| !tg.modal)
281                    .map(|(e, tg, _)| (e, *tg))
282                    .collect();
283                // Stable sort by group order
284                tab_groups.sort_by_key(|(_, tg)| tg.order);
285
286                // Search group descendants
287                tab_groups
288                    .iter()
289                    .enumerate()
290                    .for_each(|(idx, (tg_entity, _))| {
291                        self.gather_focusable(&mut focusable, *tg_entity, idx);
292                    });
293            }
294        }
295
296        if focusable.is_empty() {
297            return Err(TabNavigationError::NoFocusableEntities);
298        }
299
300        // Sort by TabGroup and then TabIndex
301        focusable.sort_by(|(_, a_tab_idx, a_group), (_, b_tab_idx, b_group)| {
302            if a_group == b_group {
303                a_tab_idx.cmp(b_tab_idx)
304            } else {
305                a_group.cmp(b_group)
306            }
307        });
308
309        let index = focusable.iter().position(|e| Some(e.0) == focus);
310        let count = focusable.len();
311        let next = match (index, action) {
312            (Some(idx), NavAction::Next) => (idx + 1).rem_euclid(count),
313            (Some(idx), NavAction::Previous) => (idx + count - 1).rem_euclid(count),
314            (None, NavAction::Next) | (_, NavAction::First) => 0,
315            (None, NavAction::Previous) | (_, NavAction::Last) => count - 1,
316        };
317        match focusable.get(next) {
318            Some((entity, _, _)) => Ok(*entity),
319            None => Err(TabNavigationError::FailedToNavigateToNextFocusableEntity),
320        }
321    }
322
323    /// Gather all focusable entities in tree order.
324    fn gather_focusable(
325        &self,
326        out: &mut Vec<(Entity, TabIndex, usize)>,
327        parent: Entity,
328        tab_group_idx: usize,
329    ) {
330        if let Ok((entity, tabindex, children)) = self.tabindex_query.get(parent) {
331            if let Some(tabindex) = tabindex
332                && tabindex.0 >= 0
333            {
334                out.push((entity, *tabindex, tab_group_idx));
335            }
336            if let Some(children) = children {
337                for child in children.iter() {
338                    // Don't traverse into tab groups, as they are handled separately.
339                    if self.tabgroup_query.get(*child).is_err() {
340                        self.gather_focusable(out, *child, tab_group_idx);
341                    }
342                }
343            }
344        } else if let Ok((_, tabgroup, children)) = self.tabgroup_query.get(parent)
345            && !tabgroup.modal
346        {
347            for child in children.iter() {
348                self.gather_focusable(out, *child, tab_group_idx);
349            }
350        }
351    }
352}
353
354/// Observer which sets focus to the nearest ancestor that has tab index, using bubbling.
355pub(crate) fn acquire_focus(
356    mut acquire_focus: On<AcquireFocus>,
357    focusable: Query<(), With<TabIndex>>,
358    windows: Query<(), With<Window>>,
359    mut focus: ResMut<InputFocus>,
360) {
361    // If the entity has a TabIndex
362    if focusable.contains(acquire_focus.focused_entity) {
363        // Stop and focus it
364        acquire_focus.propagate(false);
365        // Don't mutate unless we need to, for change detection
366        if focus.get() != Some(acquire_focus.focused_entity) {
367            focus.set(acquire_focus.focused_entity, FocusCause::Navigated);
368        }
369    } else if windows.contains(acquire_focus.focused_entity) {
370        // Stop and clear focus
371        acquire_focus.propagate(false);
372        // Don't mutate unless we need to, for change detection
373        if focus.get().is_some() {
374            focus.clear();
375        }
376    }
377}
378
379/// Plugin for navigating between focusable entities using keyboard input.
380pub struct TabNavigationPlugin;
381
382impl Plugin for TabNavigationPlugin {
383    fn build(&self, app: &mut App) {
384        app.add_systems(Startup, setup_tab_navigation);
385        app.add_observer(acquire_focus);
386        #[cfg(feature = "bevy_picking")]
387        app.add_observer(click_to_focus);
388    }
389}
390
391fn setup_tab_navigation(mut commands: Commands, window: Query<Entity, With<PrimaryWindow>>) {
392    for window in window.iter() {
393        commands.entity(window).observe(handle_tab_navigation);
394    }
395}
396
397#[cfg(feature = "bevy_picking")]
398fn click_to_focus(
399    press: On<bevy_picking::events::Pointer<bevy_picking::events::Press>>,
400    mut focus_visible: ResMut<InputFocusVisible>,
401    windows: Query<Entity, With<PrimaryWindow>>,
402    mut commands: Commands,
403) {
404    // Because `Pointer` is a bubbling event, we don't want to trigger an `AcquireFocus` event
405    // for every ancestor, but only for the original entity. Also, users may want to stop
406    // propagation on the pointer event at some point along the bubbling chain, so we need our
407    // own dedicated event whose propagation we can control.
408    if press.entity == press.original_event_target() {
409        // Clicking hides focus
410        if focus_visible.0 {
411            focus_visible.0 = false;
412        }
413        // Search for a focusable parent entity, defaulting to window if none.
414        if let Ok(window) = windows.single() {
415            commands.trigger(AcquireFocus {
416                focused_entity: press.entity,
417                window,
418            });
419        }
420    }
421}
422
423/// Observer function which handles tab navigation.
424///
425/// This observer responds to [`KeyCode::Tab`] events and Shift+Tab events,
426/// cycling through focusable entities in the order determined by their tab index.
427///
428/// Any [`TabNavigationError`]s that occur during tab navigation are logged as warnings.
429pub fn handle_tab_navigation(
430    mut event: On<FocusedInput<KeyboardInput>>,
431    nav: TabNavigation,
432    mut focus: ResMut<InputFocus>,
433    mut visible: ResMut<InputFocusVisible>,
434    keys: Res<ButtonInput<KeyCode>>,
435) {
436    // Tab navigation.
437    let key_event = &event.input;
438    if key_event.key_code == KeyCode::Tab
439        && key_event.state == ButtonState::Pressed
440        && !key_event.repeat
441    {
442        let maybe_next = nav.navigate(
443            &focus,
444            if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {
445                NavAction::Previous
446            } else {
447                NavAction::Next
448            },
449        );
450
451        match maybe_next {
452            Ok(next) => {
453                event.propagate(false);
454                focus.set(next, FocusCause::Navigated);
455                visible.0 = true;
456            }
457            Err(e) => {
458                warn!("Tab navigation error: {e}");
459                // This failure mode is recoverable, but still indicates a problem.
460                if let TabNavigationError::NoTabGroupForCurrentFocus { new_focus, .. } = e {
461                    event.propagate(false);
462                    focus.set(new_focus, FocusCause::Navigated);
463                    visible.0 = true;
464                }
465            }
466        }
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use bevy_ecs::system::SystemState;
473
474    use super::*;
475
476    #[test]
477    fn test_tab_navigation() {
478        let mut app = App::new();
479        let world = app.world_mut();
480
481        let tab_group_entity = world.spawn(TabGroup::new(0)).id();
482        let tab_entity_1 = world.spawn((TabIndex(0), ChildOf(tab_group_entity))).id();
483        let tab_entity_2 = world.spawn((TabIndex(1), ChildOf(tab_group_entity))).id();
484
485        let mut system_state: SystemState<TabNavigation> = SystemState::new(world);
486        let tab_navigation = system_state.get(world).unwrap();
487        assert_eq!(tab_navigation.tabgroup_query.iter().count(), 1);
488        assert!(tab_navigation.tabindex_query.iter().count() >= 2);
489
490        let next_entity =
491            tab_navigation.navigate(&InputFocus::from_entity(tab_entity_1), NavAction::Next);
492        assert_eq!(next_entity, Ok(tab_entity_2));
493
494        let prev_entity =
495            tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Previous);
496        assert_eq!(prev_entity, Ok(tab_entity_1));
497
498        let first_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::First);
499        assert_eq!(first_entity, Ok(tab_entity_1));
500
501        let last_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::Last);
502        assert_eq!(last_entity, Ok(tab_entity_2));
503    }
504
505    #[test]
506    fn test_tab_navigation_between_groups_is_sorted_by_group() {
507        let mut app = App::new();
508        let world = app.world_mut();
509
510        let tab_group_1 = world.spawn(TabGroup::new(0)).id();
511        let tab_entity_1 = world.spawn((TabIndex(0), ChildOf(tab_group_1))).id();
512        let tab_entity_2 = world.spawn((TabIndex(1), ChildOf(tab_group_1))).id();
513
514        let tab_group_2 = world.spawn(TabGroup::new(1)).id();
515        let tab_entity_3 = world.spawn((TabIndex(0), ChildOf(tab_group_2))).id();
516        let tab_entity_4 = world.spawn((TabIndex(1), ChildOf(tab_group_2))).id();
517
518        let mut system_state: SystemState<TabNavigation> = SystemState::new(world);
519        let tab_navigation = system_state.get(world).unwrap();
520        assert_eq!(tab_navigation.tabgroup_query.iter().count(), 2);
521        assert!(tab_navigation.tabindex_query.iter().count() >= 4);
522
523        let next_entity =
524            tab_navigation.navigate(&InputFocus::from_entity(tab_entity_1), NavAction::Next);
525        assert_eq!(next_entity, Ok(tab_entity_2));
526
527        let prev_entity =
528            tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Previous);
529        assert_eq!(prev_entity, Ok(tab_entity_1));
530
531        let first_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::First);
532        assert_eq!(first_entity, Ok(tab_entity_1));
533
534        let last_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::Last);
535        assert_eq!(last_entity, Ok(tab_entity_4));
536
537        let next_from_end_of_group_entity =
538            tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Next);
539        assert_eq!(next_from_end_of_group_entity, Ok(tab_entity_3));
540
541        let prev_entity_from_start_of_group =
542            tab_navigation.navigate(&InputFocus::from_entity(tab_entity_3), NavAction::Previous);
543        assert_eq!(prev_entity_from_start_of_group, Ok(tab_entity_2));
544    }
545}