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_camera::visibility::InheritedVisibility;
30use bevy_ecs::{
31    component::Component,
32    entity::Entity,
33    hierarchy::{ChildOf, Children},
34    observer::On,
35    query::{With, Without},
36    system::{Commands, Query, Res, ResMut, SystemParam},
37};
38use bevy_input::{
39    keyboard::{KeyCode, KeyboardInput},
40    ButtonInput, ButtonState,
41};
42use bevy_window::{PrimaryWindow, Window};
43use log::warn;
44use thiserror::Error;
45
46use crate::{AcquireFocus, FocusCause, FocusedInput, InputFocus, InputFocusVisible};
47
48#[cfg(feature = "bevy_reflect")]
49use {
50    bevy_ecs::prelude::ReflectComponent,
51    bevy_reflect::{prelude::*, Reflect},
52};
53
54/// A component which indicates that an entity wants to participate in tab navigation.
55///
56/// Note that you must also add the [`TabGroup`] component to the entity's ancestor in order
57/// for this component to have any effect.
58#[derive(Debug, Default, Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
59#[cfg_attr(
60    feature = "bevy_reflect",
61    derive(Reflect),
62    reflect(Debug, Default, Component, PartialEq, Clone)
63)]
64pub struct TabIndex(pub i32);
65
66/// A component used to mark a tree of entities as containing tabbable elements.
67#[derive(Debug, Default, Component, Copy, Clone)]
68#[cfg_attr(
69    feature = "bevy_reflect",
70    derive(Reflect),
71    reflect(Debug, Default, Component, Clone)
72)]
73pub struct TabGroup {
74    /// The order of the tab group relative to other tab groups.
75    pub order: i32,
76
77    /// Whether this is a 'modal' group. If true, then tabbing within the group (that is,
78    /// if the current focus entity is a child of this group) will cycle through the children
79    /// of this group. If false, then tabbing within the group will cycle through all non-modal
80    /// tab groups.
81    pub modal: bool,
82}
83
84impl TabGroup {
85    /// Create a new tab group with the given order.
86    pub fn new(order: i32) -> Self {
87        Self {
88            order,
89            modal: false,
90        }
91    }
92
93    /// Create a modal tab group.
94    pub fn modal() -> Self {
95        Self {
96            order: 0,
97            modal: true,
98        }
99    }
100}
101
102/// A navigation action that users might take to navigate your user interface in a cyclic fashion.
103///
104/// These values are consumed by the [`TabNavigation`] system param.
105#[derive(Default, Clone, Copy, Debug, PartialEq)]
106#[cfg_attr(
107    feature = "bevy_reflect",
108    derive(Reflect),
109    reflect(Debug, Clone, PartialEq)
110)]
111pub enum NavAction {
112    /// Navigate to the next focusable entity, wrapping around to the beginning if at the end.
113    ///
114    /// This is commonly triggered by pressing the Tab key.
115    #[default]
116    Next,
117    /// Navigate to the previous focusable entity, wrapping around to the end if at the beginning.
118    ///
119    /// This is commonly triggered by pressing Shift+Tab.
120    Previous,
121    /// Navigate to the first focusable entity.
122    ///
123    /// This is commonly triggered by pressing Home.
124    First,
125    /// Navigate to the last focusable entity.
126    ///
127    /// This is commonly triggered by pressing End.
128    Last,
129}
130
131/// An error that can occur during [tab navigation](crate::tab_navigation).
132#[derive(Debug, Error, PartialEq, Eq, Clone)]
133pub enum TabNavigationError {
134    /// No tab groups were found.
135    #[error("No tab groups found")]
136    NoTabGroups,
137    /// No focusable entities were found.
138    #[error("No focusable entities found")]
139    NoFocusableEntities,
140    /// Could not navigate to the next focusable entity.
141    ///
142    /// This can occur if your tab groups are malformed.
143    #[error("Failed to navigate to next focusable entity")]
144    FailedToNavigateToNextFocusableEntity,
145    /// No tab group for the current focus entity was found.
146    #[error("No tab group found for currently focused entity {previous_focus}. Users will not be able to navigate back to this entity.")]
147    NoTabGroupForCurrentFocus {
148        /// The entity that was previously focused,
149        /// and is missing its tab group.
150        previous_focus: Entity,
151        /// The new entity that will be focused.
152        ///
153        /// If you want to recover from this error, set [`InputFocus`] to this entity.
154        new_focus: Entity,
155    },
156}
157
158/// An injectable helper object that provides tab navigation functionality.
159#[doc(hidden)]
160#[derive(SystemParam)]
161pub struct TabNavigation<'w, 's> {
162    // Query for tab groups.
163    tabgroup_query: Query<'w, 's, (Entity, &'static TabGroup, &'static Children)>,
164    // Query for tab indices.
165    tabindex_query: Query<
166        'w,
167        's,
168        (
169            Entity,
170            Option<&'static TabIndex>,
171            Option<&'static Children>,
172            Option<&'static InheritedVisibility>,
173        ),
174        Without<TabGroup>,
175    >,
176    // Query for parents.
177    parent_query: Query<'w, 's, &'static ChildOf>,
178}
179
180impl TabNavigation<'_, '_> {
181    /// Navigate to the desired focusable entity, relative to the current focused entity.
182    ///
183    /// Change the [`NavAction`] to navigate in a different direction.
184    /// Focusable entities are determined by the presence of the [`TabIndex`] component.
185    ///
186    /// If there is no currently focused entity, then this function will return either the first
187    /// or last focusable entity, depending on the direction of navigation. For example, if
188    /// `action` is `Next` and no focusable entities are found, then this function will return
189    /// the first focusable entity.
190    pub fn navigate(
191        &self,
192        focus: &InputFocus,
193        action: NavAction,
194    ) -> Result<Entity, TabNavigationError> {
195        // If there are no tab groups, then there are no focusable entities.
196        if self.tabgroup_query.is_empty() {
197            return Err(TabNavigationError::NoTabGroups);
198        }
199
200        // Start by identifying which tab group we are in. Mainly what we want to know is if
201        // we're in a modal group.
202        let tabgroup = focus.get().and_then(|focus_ent| {
203            self.parent_query
204                .iter_ancestors(focus_ent)
205                .find_map(|entity| {
206                    self.tabgroup_query
207                        .get(entity)
208                        .ok()
209                        .map(|(_, tg, _)| (entity, tg))
210                })
211        });
212
213        self.navigate_internal(focus.get(), action, tabgroup)
214    }
215
216    /// Initialize focus to a focusable child of a container, either the first or last
217    /// depending on [`NavAction`]. This assumes that the parent entity has a [`TabGroup`]
218    /// component.
219    ///
220    /// Focusable entities are determined by the presence of the [`TabIndex`] component.
221    pub fn initialize(
222        &self,
223        parent: Entity,
224        action: NavAction,
225    ) -> Result<Entity, TabNavigationError> {
226        // If there are no tab groups, then there are no focusable entities.
227        if self.tabgroup_query.is_empty() {
228            return Err(TabNavigationError::NoTabGroups);
229        }
230
231        // Look for the tab group on the parent entity.
232        match self.tabgroup_query.get(parent) {
233            Ok(tabgroup) => self.navigate_internal(None, action, Some((parent, tabgroup.1))),
234            Err(_) => Err(TabNavigationError::NoTabGroups),
235        }
236    }
237
238    pub fn navigate_internal(
239        &self,
240        focus: Option<Entity>,
241        action: NavAction,
242        tabgroup: Option<(Entity, &TabGroup)>,
243    ) -> Result<Entity, TabNavigationError> {
244        let navigation_result = self.navigate_in_group(tabgroup, focus, action);
245
246        match navigation_result {
247            Ok(entity) => {
248                if let Some(previous_focus) = focus
249                    && tabgroup.is_none()
250                {
251                    Err(TabNavigationError::NoTabGroupForCurrentFocus {
252                        previous_focus,
253                        new_focus: entity,
254                    })
255                } else {
256                    Ok(entity)
257                }
258            }
259            Err(e) => Err(e),
260        }
261    }
262
263    fn navigate_in_group(
264        &self,
265        tabgroup: Option<(Entity, &TabGroup)>,
266        focus: Option<Entity>,
267        action: NavAction,
268    ) -> Result<Entity, TabNavigationError> {
269        // List of all focusable entities found.
270        let mut focusable: Vec<(Entity, TabIndex, usize)> =
271            Vec::with_capacity(self.tabindex_query.iter().len());
272
273        match tabgroup {
274            Some((tg_entity, tg)) if tg.modal => {
275                // We're in a modal tab group, then gather all tab indices in that group.
276                if let Ok((_, _, children)) = self.tabgroup_query.get(tg_entity) {
277                    for child in children.iter() {
278                        self.gather_focusable(&mut focusable, *child, 0);
279                    }
280                }
281            }
282            _ => {
283                // Otherwise, gather all tab indices in all non-modal tab groups.
284                let mut tab_groups: Vec<(Entity, TabGroup)> = self
285                    .tabgroup_query
286                    .iter()
287                    .filter(|(_, tg, _)| !tg.modal)
288                    .map(|(e, tg, _)| (e, *tg))
289                    .collect();
290                // Stable sort by group order
291                tab_groups.sort_by_key(|(_, tg)| tg.order);
292
293                // Search group descendants
294                tab_groups
295                    .iter()
296                    .enumerate()
297                    .for_each(|(idx, (tg_entity, _))| {
298                        self.gather_focusable(&mut focusable, *tg_entity, idx);
299                    });
300            }
301        }
302
303        if focusable.is_empty() {
304            return Err(TabNavigationError::NoFocusableEntities);
305        }
306
307        // Sort by TabGroup and then TabIndex
308        focusable.sort_by(|(_, a_tab_idx, a_group), (_, b_tab_idx, b_group)| {
309            if a_group == b_group {
310                a_tab_idx.cmp(b_tab_idx)
311            } else {
312                a_group.cmp(b_group)
313            }
314        });
315
316        let index = focusable.iter().position(|e| Some(e.0) == focus);
317        let count = focusable.len();
318        let next = match (index, action) {
319            (Some(idx), NavAction::Next) => (idx + 1).rem_euclid(count),
320            (Some(idx), NavAction::Previous) => (idx + count - 1).rem_euclid(count),
321            (None, NavAction::Next) | (_, NavAction::First) => 0,
322            (None, NavAction::Previous) | (_, NavAction::Last) => count - 1,
323        };
324        match focusable.get(next) {
325            Some((entity, _, _)) => Ok(*entity),
326            None => Err(TabNavigationError::FailedToNavigateToNextFocusableEntity),
327        }
328    }
329
330    /// Gather all focusable entities in tree order.
331    fn gather_focusable(
332        &self,
333        out: &mut Vec<(Entity, TabIndex, usize)>,
334        parent: Entity,
335        tab_group_idx: usize,
336    ) {
337        if let Ok((entity, tabindex, children, inherited_visibility)) =
338            self.tabindex_query.get(parent)
339        {
340            // Skip hidden entities and their entire subtree. An entity without an
341            // `InheritedVisibility` component (e.g. a non-UI entity) is treated as visible.
342            if inherited_visibility.is_none_or(|v| v.get()) {
343                if let Some(tabindex) = tabindex
344                    && tabindex.0 >= 0
345                {
346                    out.push((entity, *tabindex, tab_group_idx));
347                }
348                if let Some(children) = children {
349                    for child in children.iter() {
350                        // Don't traverse into tab groups, as they are handled separately.
351                        if self.tabgroup_query.get(*child).is_err() {
352                            self.gather_focusable(out, *child, tab_group_idx);
353                        }
354                    }
355                }
356            }
357        } else if let Ok((_, tabgroup, children)) = self.tabgroup_query.get(parent)
358            && !tabgroup.modal
359        {
360            for child in children.iter() {
361                self.gather_focusable(out, *child, tab_group_idx);
362            }
363        }
364    }
365}
366
367/// Observer which focuses the target of an [`AcquireFocus`] request when it has a [`TabIndex`].
368///
369/// This is the tab-navigation-specific half of focus acquisition: only entities that opt into tab
370/// navigation via [`TabIndex`] are treated as focus targets here. When the target is focusable, this
371/// stops the request (so it never reaches the window) and focuses it; otherwise the request keeps
372/// bubbling and is eventually handled by the generalized
373/// [`on_window_acquire_focus_clear`](crate::on_window_acquire_focus_clear) observer at the window.
374///
375/// The [`Without<Window>`] bound is a defensive guard — a window would never carry a [`TabIndex`] in
376/// practice, but excluding it keeps this observer's responsibility (focus a focusable child) cleanly
377/// separate from the window-clearing fallback in [`on_window_acquire_focus_clear`](crate::on_window_acquire_focus_clear).
378///
379/// The `focus.get()` guard avoids spurious mutations so change detection only fires on real changes.
380///
381#[cfg_attr(
382    feature = "bevy_picking",
383    doc = "This observer is also registered by
384[`PointerFocusPlugin`](crate::pointer_focus::PointerFocusPlugin) as a temporary bridge so pointer
385clicks can acquire focus without [`TabNavigationPlugin`]. Because `add_observer` does not
386deduplicate, it may therefore run twice per request when both plugins are present; keep it
387idempotent (stop propagation, only mutate focus on a real change) so the second run is a no-op."
388)]
389pub fn acquire_focus_tab_index(
390    mut acquire_focus: On<AcquireFocus>,
391    focusable: Query<(), (With<TabIndex>, Without<Window>)>,
392    mut focus: ResMut<InputFocus>,
393) {
394    // If the entity has a TabIndex
395    if focusable.contains(acquire_focus.focused_entity) {
396        // Stop and focus it
397        acquire_focus.propagate(false);
398        // Don't mutate unless we need to, for change detection
399        if focus.get() != Some(acquire_focus.focused_entity) {
400            focus.set(acquire_focus.focused_entity, FocusCause::Navigated);
401        }
402    }
403}
404
405/// Plugin for navigating between focusable entities using keyboard input.
406pub struct TabNavigationPlugin;
407
408impl Plugin for TabNavigationPlugin {
409    fn build(&self, app: &mut App) {
410        app.add_systems(Startup, setup_tab_navigation);
411        app.add_observer(acquire_focus_tab_index);
412    }
413}
414
415fn setup_tab_navigation(mut commands: Commands, window: Query<Entity, With<PrimaryWindow>>) {
416    for window in window.iter() {
417        commands.entity(window).observe(handle_tab_navigation);
418    }
419}
420
421/// Observer function which handles tab navigation.
422///
423/// This observer responds to [`KeyCode::Tab`] events and Shift+Tab events,
424/// cycling through focusable entities in the order determined by their tab index.
425///
426/// Any [`TabNavigationError`]s that occur during tab navigation are logged as warnings.
427pub fn handle_tab_navigation(
428    mut event: On<FocusedInput<KeyboardInput>>,
429    nav: TabNavigation,
430    mut focus: ResMut<InputFocus>,
431    mut visible: ResMut<InputFocusVisible>,
432    keys: Res<ButtonInput<KeyCode>>,
433) {
434    // Tab navigation.
435    let key_event = &event.input;
436    if key_event.key_code == KeyCode::Tab
437        && key_event.state == ButtonState::Pressed
438        && !key_event.repeat
439    {
440        let maybe_next = nav.navigate(
441            &focus,
442            if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {
443                NavAction::Previous
444            } else {
445                NavAction::Next
446            },
447        );
448
449        match maybe_next {
450            Ok(next) => {
451                event.propagate(false);
452                focus.set(next, FocusCause::Navigated);
453                visible.0 = true;
454            }
455            Err(e) => {
456                warn!("Tab navigation error: {e}");
457                // This failure mode is recoverable, but still indicates a problem.
458                if let TabNavigationError::NoTabGroupForCurrentFocus { new_focus, .. } = e {
459                    event.propagate(false);
460                    focus.set(new_focus, FocusCause::Navigated);
461                    visible.0 = true;
462                }
463            }
464        }
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use bevy_ecs::system::SystemState;
471
472    use super::*;
473
474    #[test]
475    fn test_tab_navigation() {
476        let mut app = App::new();
477        let world = app.world_mut();
478
479        let tab_group_entity = world.spawn(TabGroup::new(0)).id();
480        let tab_entity_1 = world.spawn((TabIndex(0), ChildOf(tab_group_entity))).id();
481        let tab_entity_2 = world.spawn((TabIndex(1), ChildOf(tab_group_entity))).id();
482
483        let mut system_state: SystemState<TabNavigation> = SystemState::new(world);
484        let tab_navigation = system_state.get(world).unwrap();
485        assert_eq!(tab_navigation.tabgroup_query.iter().count(), 1);
486        assert!(tab_navigation.tabindex_query.iter().count() >= 2);
487
488        let next_entity =
489            tab_navigation.navigate(&InputFocus::from_entity(tab_entity_1), NavAction::Next);
490        assert_eq!(next_entity, Ok(tab_entity_2));
491
492        let prev_entity =
493            tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Previous);
494        assert_eq!(prev_entity, Ok(tab_entity_1));
495
496        let first_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::First);
497        assert_eq!(first_entity, Ok(tab_entity_1));
498
499        let last_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::Last);
500        assert_eq!(last_entity, Ok(tab_entity_2));
501    }
502
503    #[test]
504    fn test_tab_navigation_between_groups_is_sorted_by_group() {
505        let mut app = App::new();
506        let world = app.world_mut();
507
508        let tab_group_1 = world.spawn(TabGroup::new(0)).id();
509        let tab_entity_1 = world.spawn((TabIndex(0), ChildOf(tab_group_1))).id();
510        let tab_entity_2 = world.spawn((TabIndex(1), ChildOf(tab_group_1))).id();
511
512        let tab_group_2 = world.spawn(TabGroup::new(1)).id();
513        let tab_entity_3 = world.spawn((TabIndex(0), ChildOf(tab_group_2))).id();
514        let tab_entity_4 = world.spawn((TabIndex(1), ChildOf(tab_group_2))).id();
515
516        let mut system_state: SystemState<TabNavigation> = SystemState::new(world);
517        let tab_navigation = system_state.get(world).unwrap();
518        assert_eq!(tab_navigation.tabgroup_query.iter().count(), 2);
519        assert!(tab_navigation.tabindex_query.iter().count() >= 4);
520
521        let next_entity =
522            tab_navigation.navigate(&InputFocus::from_entity(tab_entity_1), NavAction::Next);
523        assert_eq!(next_entity, Ok(tab_entity_2));
524
525        let prev_entity =
526            tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Previous);
527        assert_eq!(prev_entity, Ok(tab_entity_1));
528
529        let first_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::First);
530        assert_eq!(first_entity, Ok(tab_entity_1));
531
532        let last_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::Last);
533        assert_eq!(last_entity, Ok(tab_entity_4));
534
535        let next_from_end_of_group_entity =
536            tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Next);
537        assert_eq!(next_from_end_of_group_entity, Ok(tab_entity_3));
538
539        let prev_entity_from_start_of_group =
540            tab_navigation.navigate(&InputFocus::from_entity(tab_entity_3), NavAction::Previous);
541        assert_eq!(prev_entity_from_start_of_group, Ok(tab_entity_2));
542    }
543
544    /// Sets up an app with a primary window and both the input-focus and tab-navigation plugins,
545    /// so both `AcquireFocus` observers (window-clearing in `bevy_input_focus`, and
546    /// `acquire_focus_tab_index` here) are registered, with initial focus resolved.
547    fn acquire_focus_app() -> (App, Entity) {
548        use crate::InputFocusPlugin;
549        use bevy_input::InputPlugin;
550
551        let mut app = App::new();
552        app.add_plugins((InputPlugin, InputFocusPlugin, TabNavigationPlugin));
553        let window = app
554            .world_mut()
555            .spawn((Window::default(), PrimaryWindow))
556            .id();
557        // Resolve initial focus (focus goes to the primary window).
558        app.update();
559        (app, window)
560    }
561
562    #[test]
563    fn acquire_focus_focuses_entity_with_tab_index() {
564        let (mut app, window) = acquire_focus_app();
565
566        let focusable = app.world_mut().spawn(TabIndex(0)).id();
567
568        app.world_mut().trigger(AcquireFocus {
569            focused_entity: focusable,
570            window,
571        });
572        app.update();
573
574        assert_eq!(app.world().resource::<InputFocus>().get(), Some(focusable));
575    }
576
577    #[test]
578    fn acquire_focus_does_not_focus_entity_without_tab_index() {
579        let (mut app, window) = acquire_focus_app();
580
581        // A non-focusable entity must never become focused just because it was the request target:
582        // only `TabIndex` entities are valid focus targets for `acquire_focus_tab_index`. The
583        // request instead bubbles up to the window, where the generalized
584        // `on_window_acquire_focus_clear` observer clears focus.
585        let non_focusable = app.world_mut().spawn(ChildOf(window)).id();
586        app.world_mut().trigger(AcquireFocus {
587            focused_entity: non_focusable,
588            window,
589        });
590        app.update();
591
592        assert_ne!(
593            app.world().resource::<InputFocus>().get(),
594            Some(non_focusable)
595        );
596        assert_eq!(app.world().resource::<InputFocus>().get(), None);
597    }
598}