Tab navigation framework for bevy_input_focus. (#16795)

# Objective

This PR continues the work of `bevy_input_focus` by adding a pluggable
tab navigation framework.

As part of this work, `FocusKeyboardEvent` now propagates to the window
after exhausting all ancestors.

## Testing

Unit tests and manual tests.

---------

Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
This commit is contained in:
Talin 2024-12-16 15:54:53 -08:00 committed by GitHub
parent bf3692a011
commit 5c67cfc8b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 636 additions and 15 deletions

View File

@ -3947,3 +3947,14 @@ doc-scrape-examples = true
[package.metadata.example.testbed_ui_layout_rounding]
hidden = true
[[example]]
name = "tab_navigation"
path = "examples/ui/tab_navigation.rs"
doc-scrape-examples = true
[package.metadata.example.tab_navigation]
name = "Tab Navigation"
description = "Demonstration of Tab Navigation"
category = "UI (User Interface)"
wasm = true

View File

@ -14,6 +14,7 @@ bevy_app = { path = "../bevy_app", version = "0.15.0-dev", default-features = fa
bevy_ecs = { path = "../bevy_ecs", version = "0.15.0-dev", default-features = false }
bevy_input = { path = "../bevy_input", version = "0.15.0-dev", default-features = false }
bevy_hierarchy = { path = "../bevy_hierarchy", version = "0.15.0-dev", default-features = false }
bevy_utils = { path = "../bevy_utils", version = "0.15.0-dev", default-features = false }
bevy_window = { path = "../bevy_window", version = "0.15.0-dev", default-features = false }
[dev-dependencies]

View File

@ -16,18 +16,22 @@
//! This crate does *not* provide any integration with UI widgets, or provide functions for
//! tab navigation or gamepad-based focus navigation, as those are typically application-specific.
pub mod tab_navigation;
use bevy_app::{App, Plugin, PreUpdate};
use bevy_ecs::{
component::Component,
entity::Entity,
event::{Event, EventReader},
query::With,
query::{QueryData, With},
system::{Commands, Query, Res, Resource, SystemParam},
traversal::Traversal,
world::{Command, DeferredWorld, World},
};
use bevy_hierarchy::{HierarchyQueryExt, Parent};
use bevy_input::keyboard::KeyboardInput;
use bevy_window::PrimaryWindow;
use bevy_window::{PrimaryWindow, Window};
use core::fmt::Debug;
/// Resource representing which entity has input focus, if any. Keyboard events will be
/// dispatched to the current focus entity, or to the primary window if no entity has focus.
@ -102,14 +106,43 @@ impl SetInputFocus for Commands<'_, '_> {
/// input focus entity, if any. If no entity has input focus, then the event is dispatched to
/// the main window.
#[derive(Clone, Debug, Component)]
pub struct FocusKeyboardInput(pub KeyboardInput);
pub struct FocusKeyboardInput {
/// The keyboard input event.
pub input: KeyboardInput,
window: Entity,
}
impl Event for FocusKeyboardInput {
type Traversal = &'static Parent;
type Traversal = WindowTraversal;
const AUTO_PROPAGATE: bool = true;
}
#[derive(QueryData)]
/// These are for accessing components defined on the targeted entity
pub struct WindowTraversal {
parent: Option<&'static Parent>,
window: Option<&'static Window>,
}
impl Traversal<FocusKeyboardInput> for WindowTraversal {
fn traverse(item: Self::Item<'_>, event: &FocusKeyboardInput) -> Option<Entity> {
let WindowTraversalItem { parent, window } = item;
// Send event to parent, if it has one.
if let Some(parent) = parent {
return Some(parent.get());
};
// Otherwise, send it to the window entity (unless this is a window entity).
if window.is_none() {
return Some(event.window);
}
None
}
}
/// Plugin which registers the system for dispatching keyboard events based on focus and
/// hover state.
pub struct InputDispatchPlugin;
@ -130,17 +163,29 @@ fn dispatch_keyboard_input(
windows: Query<Entity, With<PrimaryWindow>>,
mut commands: Commands,
) {
// If an element has keyboard focus, then dispatch the key event to that element.
if let Some(focus_elt) = focus.0 {
for ev in key_events.read() {
commands.trigger_targets(FocusKeyboardInput(ev.clone()), focus_elt);
}
} else {
// If no element has input focus, then dispatch the key event to the primary window.
// There should be only one primary window.
if let Ok(window) = windows.get_single() {
if let Ok(window) = windows.get_single() {
// If an element has keyboard focus, then dispatch the key event to that element.
if let Some(focus_elt) = focus.0 {
for ev in key_events.read() {
commands.trigger_targets(FocusKeyboardInput(ev.clone()), window);
commands.trigger_targets(
FocusKeyboardInput {
input: ev.clone(),
window,
},
focus_elt,
);
}
} else {
// If no element has input focus, then dispatch the key event to the primary window.
// There should be only one primary window.
for ev in key_events.read() {
commands.trigger_targets(
FocusKeyboardInput {
input: ev.clone(),
window,
},
window,
);
}
}
}
@ -248,6 +293,7 @@ mod tests {
keyboard::{Key, KeyCode},
ButtonState, InputPlugin,
};
use bevy_window::WindowResolution;
use smol_str::SmolStr;
#[derive(Component)]
@ -266,7 +312,7 @@ mod tests {
mut query: Query<&mut GatherKeyboardEvents>,
) {
if let Ok(mut gather) = query.get_mut(trigger.target()) {
if let Key::Character(c) = &trigger.0.logical_key {
if let Key::Character(c) = &trigger.input.logical_key {
gather.0.push_str(c.as_str());
}
}
@ -324,6 +370,12 @@ mod tests {
app.add_plugins((InputPlugin, InputDispatchPlugin))
.add_observer(gather_keyboard_events);
let window = Window {
resolution: WindowResolution::new(800., 600.),
..Default::default()
};
app.world_mut().spawn((window, PrimaryWindow));
let entity_a = app
.world_mut()
.spawn((GatherKeyboardEvents::default(), SetFocusOnAdd))

View File

@ -0,0 +1,335 @@
//! This module provides a framework for handling linear tab-key navigation in Bevy applications.
//!
//! The rules of tabbing are derived from the HTML specification, and are as follows:
//!
//! * An index >= 0 means that the entity is tabbable via sequential navigation.
//! The order of tabbing is determined by the index, with lower indices being tabbed first.
//! If two entities have the same index, then the order is determined by the order of
//! the entities in the ECS hierarchy (as determined by Parent/Child).
//! * An index < 0 means that the entity is not focusable via sequential navigation, but
//! can still be focused via direct selection.
//!
//! Tabbable entities must be descendants of a `TabGroup` entity, which is a component that
//! marks a tree of entities as containing tabbable elements. The order of tab groups
//! is determined by the `order` field, with lower orders being tabbed first. Modal tab groups
//! are used for ui elements that should only tab within themselves, such as modal dialog boxes.
//!
//! There are several different ways to use this module. To enable automatic tabbing, add the
//! `TabNavigationPlugin` to your app. (Make sure you also have `InputDispatchPlugin` installed).
//! This will install a keyboard event observer on the primary window which automatically handles
//! tab navigation for you.
//!
//! Alternatively, if you want to have more control over tab navigation, or are using an event
//! mapping framework such as LWIM, you can use the `TabNavigation` helper object directly instead.
//! This object can be injected into your systems, and provides a `navigate` method which can be
//! used to navigate between focusable entities.
//!
//! This module also provides `AutoFocus`, a component which can be added to an entity to
//! automatically focus it when it is added to the world.
use bevy_app::{App, Plugin, Startup};
use bevy_ecs::{
component::{Component, ComponentId},
entity::Entity,
observer::Trigger,
query::{With, Without},
system::{Commands, Query, Res, ResMut, SystemParam},
world::DeferredWorld,
};
use bevy_hierarchy::{Children, HierarchyQueryExt, Parent};
use bevy_input::{keyboard::KeyCode, ButtonInput, ButtonState};
use bevy_utils::tracing::warn;
use bevy_window::PrimaryWindow;
use crate::{FocusKeyboardInput, InputFocus, InputFocusVisible, SetInputFocus};
/// A component which indicates that an entity wants to participate in tab navigation.
///
/// Note that you must also add the [`TabGroup`] component to the entity's ancestor in order
/// for this component to have any effect.
#[derive(Debug, Default, Component, Copy, Clone)]
pub struct TabIndex(pub i32);
/// Indicates that this widget should automatically receive focus when it's added.
#[derive(Debug, Default, Component, Copy, Clone)]
pub struct AutoFocus;
/// A component used to mark a tree of entities as containing tabbable elements.
#[derive(Debug, Default, Component, Copy, Clone)]
pub struct TabGroup {
/// The order of the tab group relative to other tab groups.
pub order: i32,
/// Whether this is a 'modal' group. If true, then tabbing within the group (that is,
/// if the current focus entity is a child of this group) will cycle through the children
/// of this group. If false, then tabbing within the group will cycle through all non-modal
/// tab groups.
pub modal: bool,
}
impl TabGroup {
/// Create a new tab group with the given order.
pub fn new(order: i32) -> Self {
Self {
order,
modal: false,
}
}
/// Create a modal tab group.
pub fn modal() -> Self {
Self {
order: 0,
modal: true,
}
}
}
/// Navigation action for tabbing.
pub enum NavAction {
/// Navigate to the next focusable entity, wrapping around to the beginning if at the end.
Next,
/// Navigate to the previous focusable entity, wrapping around to the end if at the beginning.
Previous,
/// Navigate to the first focusable entity.
First,
/// Navigate to the last focusable entity.
Last,
}
/// An injectable helper object that provides tab navigation functionality.
#[doc(hidden)]
#[derive(SystemParam)]
#[allow(clippy::type_complexity)]
pub struct TabNavigation<'w, 's> {
// Query for tab groups.
tabgroup_query: Query<'w, 's, (Entity, &'static TabGroup, &'static Children)>,
// Query for tab indices.
tabindex_query: Query<
'w,
's,
(Entity, Option<&'static TabIndex>, Option<&'static Children>),
Without<TabGroup>,
>,
// Query for parents.
parent_query: Query<'w, 's, &'static Parent>,
}
impl TabNavigation<'_, '_> {
/// Navigate to the next focusable entity.
///
/// Arguments:
/// * `focus`: The current focus entity, or `None` if no entity has focus.
/// * `action`: Whether to select the next, previous, first, or last focusable entity.
///
/// If no focusable entities are found, then this function will return either the first
/// or last focusable entity, depending on the direction of navigation. For example, if
/// `action` is `Next` and no focusable entities are found, then this function will return
/// the first focusable entity.
pub fn navigate(&self, focus: Option<Entity>, action: NavAction) -> Option<Entity> {
// If there are no tab groups, then there are no focusable entities.
if self.tabgroup_query.is_empty() {
warn!("No tab groups found");
return None;
}
// Start by identifying which tab group we are in. Mainly what we want to know is if
// we're in a modal group.
let tabgroup = focus.and_then(|focus_ent| {
self.parent_query
.iter_ancestors(focus_ent)
.find_map(|entity| {
self.tabgroup_query
.get(entity)
.ok()
.map(|(_, tg, _)| (entity, tg))
})
});
if focus.is_some() && tabgroup.is_none() {
warn!("No tab group found for focus entity");
return None;
}
self.navigate_in_group(tabgroup, focus, action)
}
fn navigate_in_group(
&self,
tabgroup: Option<(Entity, &TabGroup)>,
focus: Option<Entity>,
action: NavAction,
) -> Option<Entity> {
// List of all focusable entities found.
let mut focusable: Vec<(Entity, TabIndex)> =
Vec::with_capacity(self.tabindex_query.iter().len());
match tabgroup {
Some((tg_entity, tg)) if tg.modal => {
// We're in a modal tab group, then gather all tab indices in that group.
if let Ok((_, _, children)) = self.tabgroup_query.get(tg_entity) {
for child in children.iter() {
self.gather_focusable(&mut focusable, *child);
}
}
}
_ => {
// Otherwise, gather all tab indices in all non-modal tab groups.
let mut tab_groups: Vec<(Entity, TabGroup)> = self
.tabgroup_query
.iter()
.filter(|(_, tg, _)| !tg.modal)
.map(|(e, tg, _)| (e, *tg))
.collect();
// Stable sort by group order
tab_groups.sort_by(compare_tab_groups);
// Search group descendants
tab_groups.iter().for_each(|(tg_entity, _)| {
self.gather_focusable(&mut focusable, *tg_entity);
});
}
}
if focusable.is_empty() {
warn!("No focusable entities found");
return None;
}
// Stable sort by tabindex
focusable.sort_by(compare_tab_indices);
let index = focusable.iter().position(|e| Some(e.0) == focus);
let count = focusable.len();
let next = match (index, action) {
(Some(idx), NavAction::Next) => (idx + 1).rem_euclid(count),
(Some(idx), NavAction::Previous) => (idx + count - 1).rem_euclid(count),
(None, NavAction::Next) | (_, NavAction::First) => 0,
(None, NavAction::Previous) | (_, NavAction::Last) => count - 1,
};
focusable.get(next).map(|(e, _)| e).copied()
}
/// Gather all focusable entities in tree order.
fn gather_focusable(&self, out: &mut Vec<(Entity, TabIndex)>, parent: Entity) {
if let Ok((entity, tabindex, children)) = self.tabindex_query.get(parent) {
if let Some(tabindex) = tabindex {
if tabindex.0 >= 0 {
out.push((entity, *tabindex));
}
}
if let Some(children) = children {
for child in children.iter() {
// Don't traverse into tab groups, as they are handled separately.
if self.tabgroup_query.get(*child).is_err() {
self.gather_focusable(out, *child);
}
}
}
} else if let Ok((_, tabgroup, children)) = self.tabgroup_query.get(parent) {
if !tabgroup.modal {
for child in children.iter() {
self.gather_focusable(out, *child);
}
}
}
}
}
fn compare_tab_groups(a: &(Entity, TabGroup), b: &(Entity, TabGroup)) -> core::cmp::Ordering {
a.1.order.cmp(&b.1.order)
}
// Stable sort which compares by tab index
fn compare_tab_indices(a: &(Entity, TabIndex), b: &(Entity, TabIndex)) -> core::cmp::Ordering {
a.1 .0.cmp(&b.1 .0)
}
/// Plugin for handling keyboard input.
pub struct TabNavigationPlugin;
impl Plugin for TabNavigationPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, setup_tab_navigation);
app.world_mut()
.register_component_hooks::<AutoFocus>()
.on_add(on_auto_focus_added);
}
}
fn setup_tab_navigation(mut commands: Commands, window: Query<Entity, With<PrimaryWindow>>) {
for window in window.iter() {
commands.entity(window).observe(handle_tab_navigation);
}
}
/// Observer function which handles tab navigation.
pub fn handle_tab_navigation(
mut trigger: Trigger<FocusKeyboardInput>,
nav: TabNavigation,
mut focus: ResMut<InputFocus>,
mut visible: ResMut<InputFocusVisible>,
keys: Res<ButtonInput<KeyCode>>,
) {
// Tab navigation.
let key_event = &trigger.event().input;
if key_event.key_code == KeyCode::Tab
&& key_event.state == ButtonState::Pressed
&& !key_event.repeat
{
let next = nav.navigate(
focus.0,
if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {
NavAction::Previous
} else {
NavAction::Next
},
);
if next.is_some() {
trigger.propagate(false);
focus.0 = next;
visible.0 = true;
}
}
}
fn on_auto_focus_added(mut world: DeferredWorld, entity: Entity, _: ComponentId) {
if world.entity(entity).contains::<TabIndex>() {
world.set_input_focus(entity);
}
}
#[cfg(test)]
mod tests {
use bevy_ecs::system::SystemState;
use bevy_hierarchy::BuildChildren;
use super::*;
#[test]
fn test_tab_navigation() {
let mut app = App::new();
let world = app.world_mut();
let tab_entity_1 = world.spawn(TabIndex(0)).id();
let tab_entity_2 = world.spawn(TabIndex(1)).id();
let mut tab_group_entity = world.spawn(TabGroup::new(0));
tab_group_entity.replace_children(&[tab_entity_1, tab_entity_2]);
let mut system_state: SystemState<TabNavigation> = SystemState::new(world);
let tab_navigation = system_state.get(world);
assert_eq!(tab_navigation.tabgroup_query.iter().count(), 1);
assert_eq!(tab_navigation.tabindex_query.iter().count(), 2);
let next_entity = tab_navigation.navigate(Some(tab_entity_1), NavAction::Next);
assert_eq!(next_entity, Some(tab_entity_2));
let prev_entity = tab_navigation.navigate(Some(tab_entity_2), NavAction::Previous);
assert_eq!(prev_entity, Some(tab_entity_1));
let first_entity = tab_navigation.navigate(None, NavAction::First);
assert_eq!(first_entity, Some(tab_entity_1));
let last_entity = tab_navigation.navigate(None, NavAction::Last);
assert_eq!(last_entity, Some(tab_entity_2));
}
}

View File

@ -518,6 +518,7 @@ Example | Description
[Render UI to Texture](../examples/ui/render_ui_to_texture.rs) | An example of rendering UI as a part of a 3D world
[Scroll](../examples/ui/scroll.rs) | Demonstrates scrolling UI containers
[Size Constraints](../examples/ui/size_constraints.rs) | Demonstrates how the to use the size constraints to control the size of a UI node.
[Tab Navigation](../examples/ui/tab_navigation.rs) | Demonstration of Tab Navigation
[Text](../examples/ui/text.rs) | Illustrates creating and updating text
[Text Debug](../examples/ui/text_debug.rs) | An example for debugging text layout
[Text Wrap Debug](../examples/ui/text_wrap_debug.rs) | Demonstrates text wrapping

View File

@ -0,0 +1,221 @@
//! This example illustrates the use of tab navigation.
use bevy::{
color::palettes::basic::*,
input_focus::{
tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin},
InputDispatchPlugin, InputFocus,
},
prelude::*,
winit::WinitSettings,
};
fn main() {
App::new()
.add_plugins((DefaultPlugins, InputDispatchPlugin, TabNavigationPlugin))
// Only run the app when there is user input. This will significantly reduce CPU/GPU use.
.insert_resource(WinitSettings::desktop_app())
.add_systems(Startup, setup)
.add_systems(Update, (button_system, focus_system))
.run();
}
const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);
fn button_system(
mut interaction_query: Query<
(
&Interaction,
&mut BackgroundColor,
&mut BorderColor,
&Children,
),
(Changed<Interaction>, With<Button>),
>,
mut text_query: Query<&mut Text>,
) {
for (interaction, mut color, mut border_color, children) in &mut interaction_query {
let mut text = text_query.get_mut(children[0]).unwrap();
match *interaction {
Interaction::Pressed => {
**text = "Press".to_string();
*color = PRESSED_BUTTON.into();
border_color.0 = RED.into();
}
Interaction::Hovered => {
**text = "Hover".to_string();
*color = HOVERED_BUTTON.into();
border_color.0 = Color::WHITE;
}
Interaction::None => {
**text = "Button".to_string();
*color = NORMAL_BUTTON.into();
border_color.0 = Color::BLACK;
}
}
}
}
fn focus_system(
mut commands: Commands,
focus: Res<InputFocus>,
mut query: Query<Entity, With<Button>>,
) {
if focus.is_changed() {
for button in query.iter_mut() {
if focus.0 == Some(button) {
commands.entity(button).insert(Outline {
color: Color::WHITE,
width: Val::Px(2.0),
offset: Val::Px(2.0),
});
} else {
commands.entity(button).remove::<Outline>();
}
}
}
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
// ui camera
commands.spawn(Camera2d);
commands
.spawn(Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
display: Display::Flex,
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
})
.observe(
|mut trigger: Trigger<Pointer<Click>>, mut focus: ResMut<InputFocus>| {
focus.0 = None;
trigger.propagate(false);
},
)
.with_children(|parent| {
parent.spawn(Text::new("Tab Group 0"));
parent
.spawn((
Node {
display: Display::Flex,
flex_direction: FlexDirection::Row,
column_gap: Val::Px(6.0),
margin: UiRect {
bottom: Val::Px(10.0),
..default()
},
..default()
},
TabGroup::new(0),
))
.with_children(|parent| {
create_button(parent, &asset_server);
create_button(parent, &asset_server);
create_button(parent, &asset_server);
create_button(parent, &asset_server);
});
parent.spawn(Text::new("Tab Group 2"));
parent
.spawn((
Node {
display: Display::Flex,
flex_direction: FlexDirection::Row,
column_gap: Val::Px(6.0),
margin: UiRect {
bottom: Val::Px(10.0),
..default()
},
..default()
},
TabGroup::new(2),
))
.with_children(|parent| {
create_button(parent, &asset_server);
create_button(parent, &asset_server);
create_button(parent, &asset_server);
create_button(parent, &asset_server);
});
parent.spawn(Text::new("Tab Group 1"));
parent
.spawn((
Node {
display: Display::Flex,
flex_direction: FlexDirection::Row,
column_gap: Val::Px(6.0),
margin: UiRect {
bottom: Val::Px(10.0),
..default()
},
..default()
},
TabGroup::new(1),
))
.with_children(|parent| {
create_button(parent, &asset_server);
create_button(parent, &asset_server);
create_button(parent, &asset_server);
create_button(parent, &asset_server);
});
parent.spawn(Text::new("Modal Tab Group"));
parent
.spawn((
Node {
display: Display::Flex,
flex_direction: FlexDirection::Row,
column_gap: Val::Px(6.0),
..default()
},
TabGroup::modal(),
))
.with_children(|parent| {
create_button(parent, &asset_server);
create_button(parent, &asset_server);
create_button(parent, &asset_server);
create_button(parent, &asset_server);
});
});
}
fn create_button(parent: &mut ChildBuilder<'_>, asset_server: &AssetServer) {
parent
.spawn((
Button,
Node {
width: Val::Px(150.0),
height: Val::Px(65.0),
border: UiRect::all(Val::Px(5.0)),
// horizontally center child text
justify_content: JustifyContent::Center,
// vertically center child text
align_items: AlignItems::Center,
..default()
},
BorderColor(Color::BLACK),
BorderRadius::MAX,
BackgroundColor(NORMAL_BUTTON),
TabIndex(0),
))
.observe(
|mut trigger: Trigger<Pointer<Click>>, mut focus: ResMut<InputFocus>| {
focus.0 = Some(trigger.target());
trigger.propagate(false);
},
)
.with_child((
Text::new("Button"),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 23.0,
..default()
},
TextColor(Color::srgb(0.9, 0.9, 0.9)),
));
}