Polish and improve docs for bevy_input_focus (#16887)

# Objective

`bevy_input_focus` needs some love before we ship it to users. There's a
few missing helper methods, the docs could be improved, and `AutoFocus`
should be more generally available.

## Solution

The changes here are broken down by commit, and should generally be
uncontroversial. The ones to focus on during review are:

- Make navigate take a & InputFocus argument: this makes the intended
pattern clearer to users
- Remove TabGroup requirement from `AutoFocus`: I want auto-focusing
even with gamepad-style focus navigation!
- Handle case where tab group is None more gracefully: I think we can
try harder to provide something usable, and shouldn't just fail to
navigate

## Testing

The `tab_navigation` example continues to work.
This commit is contained in:
Alice Cecile 2024-12-18 12:29:26 -08:00 committed by GitHub
parent 20049d4c34
commit d8796ae8b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 110 additions and 60 deletions

View File

@ -0,0 +1,20 @@
//! Contains the [`AutoFocus`] component and related machinery.
use bevy_ecs::{component::ComponentId, prelude::*, world::DeferredWorld};
use crate::SetInputFocus;
/// Indicates that this widget should automatically receive [`InputFocus`](crate::InputFocus).
///
/// This can be useful for things like dialog boxes, the first text input in a form,
/// or the first button in a game menu.
///
/// The focus is swapped when this component is added
/// or an entity with this component is spawned.
#[derive(Debug, Default, Component, Copy, Clone)]
#[component(on_add = on_auto_focus_added)]
pub struct AutoFocus;
fn on_auto_focus_added(mut world: DeferredWorld, entity: Entity, _: ComponentId) {
world.set_input_focus(entity);
}

View File

@ -8,16 +8,20 @@
//! Keyboard focus system for Bevy. //! Keyboard focus system for Bevy.
//! //!
//! This crate provides a system for managing input focus in Bevy applications, including: //! This crate provides a system for managing input focus in Bevy applications, including:
//! * A resource for tracking which entity has input focus. //! * [`InputFocus`], a resource for tracking which entity has input focus.
//! * Methods for getting and setting input focus. //! * Methods for getting and setting input focus via [`SetInputFocus`], [`InputFocus`] and [`IsFocusedHelper`].
//! * Event definitions for triggering bubble-able keyboard input events to the focused entity. //! * A generic [`FocusedInput`] event for input events which bubble up from the focused entity.
//! * A system for dispatching keyboard input events to the focused entity.
//! //!
//! This crate does *not* provide any integration with UI widgets, or provide functions for //! This crate does *not* provide any integration with UI widgets: this is the responsibility of the widget crate,
//! tab navigation or gamepad-based focus navigation, as those are typically application-specific. //! which should depend on [`bevy_input_focus`](crate).
pub mod tab_navigation; pub mod tab_navigation;
// This module is too small / specific to be exported by the crate,
// but it's nice to have it separate for code organization.
mod autofocus;
pub use autofocus::*;
use bevy_app::{App, Plugin, PreUpdate, Startup}; use bevy_app::{App, Plugin, PreUpdate, Startup};
use bevy_ecs::{ use bevy_ecs::{
prelude::*, query::QueryData, system::SystemParam, traversal::Traversal, world::DeferredWorld, prelude::*, query::QueryData, system::SystemParam, traversal::Traversal, world::DeferredWorld,
@ -29,11 +33,36 @@ use core::fmt::Debug;
/// Resource representing which entity has input focus, if any. Keyboard events will be /// 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. /// dispatched to the current focus entity, or to the primary window if no entity has focus.
#[derive(Clone, Debug, Resource)] #[derive(Clone, Debug, Default, Resource)]
pub struct InputFocus(pub Option<Entity>); pub struct InputFocus(pub Option<Entity>);
/// Resource representing whether the input focus indicator should be visible. It's up to the impl InputFocus {
/// current focus navigation system to set this resource. For a desktop/web style of user interface /// Create a new [`InputFocus`] resource with the given entity.
///
/// This is mostly useful for tests.
pub const fn from_entity(entity: Entity) -> Self {
Self(Some(entity))
}
/// Set the entity with input focus.
pub const fn set(&mut self, entity: Entity) {
self.0 = Some(entity);
}
/// Returns the entity with input focus, if any.
pub const fn get(&self) -> Option<Entity> {
self.0
}
/// Clears input focus.
pub const fn clear(&mut self) {
self.0 = None;
}
}
/// Resource representing whether the input focus indicator should be visible on UI elements.
///
/// It's up to the current focus navigation system to set this resource. For a desktop/web style of user interface
/// this would be set to true when the user presses the tab key, and set to false when the user /// this would be set to true when the user presses the tab key, and set to false when the user
/// clicks on a different element. /// clicks on a different element.
#[derive(Clone, Debug, Resource)] #[derive(Clone, Debug, Resource)]
@ -43,6 +72,8 @@ pub struct InputFocusVisible(pub bool);
/// ///
/// These methods are equivalent to modifying the [`InputFocus`] resource directly, /// These methods are equivalent to modifying the [`InputFocus`] resource directly,
/// but only take effect when commands are applied. /// but only take effect when commands are applied.
///
/// See [`IsFocused`] for methods to check if an entity has focus.
pub trait SetInputFocus { pub trait SetInputFocus {
/// Set input focus to the given entity. /// Set input focus to the given entity.
/// ///
@ -151,8 +182,10 @@ impl<E: Event + Clone> Traversal<FocusedInput<E>> for WindowTraversal {
} }
} }
/// Plugin which registers the system for dispatching keyboard events based on focus and /// Plugin which sets up systems for dispatching bubbling keyboard and gamepad button events to the focused entity.
/// hover state. ///
/// To add bubbling to your own input events, add the [`dispatch_focused_input::<MyEvent>`](dispatch_focused_input) system to your app,
/// as described in the docs for [`FocusedInput`].
pub struct InputDispatchPlugin; pub struct InputDispatchPlugin;
impl Plugin for InputDispatchPlugin { impl Plugin for InputDispatchPlugin {
@ -198,19 +231,19 @@ pub fn dispatch_focused_input<E: Event + Clone>(
mut commands: Commands, mut commands: Commands,
) { ) {
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 an element has keyboard focus, then dispatch the input event to that element.
if let Some(focus_elt) = focus.0 { if let Some(focused_entity) = focus.0 {
for ev in key_events.read() { for ev in key_events.read() {
commands.trigger_targets( commands.trigger_targets(
FocusedInput { FocusedInput {
input: ev.clone(), input: ev.clone(),
window, window,
}, },
focus_elt, focused_entity,
); );
} }
} else { } else {
// If no element has input focus, then dispatch the key event to the primary window. // If no element has input focus, then dispatch the input event to the primary window.
// There should be only one primary window. // There should be only one primary window.
for ev in key_events.read() { for ev in key_events.read() {
commands.trigger_targets( commands.trigger_targets(
@ -225,27 +258,36 @@ pub fn dispatch_focused_input<E: Event + Clone>(
} }
} }
/// Trait which defines methods to check if an entity currently has focus. This is implemented /// Trait which defines methods to check if an entity currently has focus.
/// for [`World`] and [`IsFocusedHelper`]. ///
/// This is implemented for [`World`] and [`IsFocusedHelper`].
/// [`DeferredWorld`] indirectly implements it through [`Deref`]. /// [`DeferredWorld`] indirectly implements it through [`Deref`].
/// ///
/// For use within systems, use [`IsFocusedHelper`].
///
/// See [`SetInputFocus`] for methods to set and clear input focus.
///
/// [`Deref`]: std::ops::Deref /// [`Deref`]: std::ops::Deref
pub trait IsFocused { pub trait IsFocused {
/// Returns true if the given entity has input focus. /// Returns true if the given entity has input focus.
fn is_focused(&self, entity: Entity) -> bool; fn is_focused(&self, entity: Entity) -> bool;
/// Returns true if the given entity or any of its descendants has input focus. /// Returns true if the given entity or any of its descendants has input focus.
///
/// Note that for unusual layouts, the focus may not be within the entity's visual bounds.
fn is_focus_within(&self, entity: Entity) -> bool; fn is_focus_within(&self, entity: Entity) -> bool;
/// Returns true if the given entity has input focus and the focus indicator is visible. /// Returns true if the given entity has input focus and the focus indicator should be visible.
fn is_focus_visible(&self, entity: Entity) -> bool; fn is_focus_visible(&self, entity: Entity) -> bool;
/// Returns true if the given entity, or any descendant, has input focus and the focus /// Returns true if the given entity, or any descendant, has input focus and the focus
/// indicator is visible. /// indicator should be visible.
fn is_focus_within_visible(&self, entity: Entity) -> bool; fn is_focus_within_visible(&self, entity: Entity) -> bool;
} }
/// System param that helps get information about the current focused entity. /// A system param that helps get information about the current focused entity.
///
/// When working with the entire [`World`], consider using the [`IsFocused`] instead.
#[derive(SystemParam)] #[derive(SystemParam)]
pub struct IsFocusedHelper<'w, 's> { pub struct IsFocusedHelper<'w, 's> {
parent_query: Query<'w, 's, &'static Parent>, parent_query: Query<'w, 's, &'static Parent>,

View File

@ -9,31 +9,27 @@
//! * An index < 0 means that the entity is not focusable via sequential navigation, but //! * An index < 0 means that the entity is not focusable via sequential navigation, but
//! can still be focused via direct selection. //! can still be focused via direct selection.
//! //!
//! Tabbable entities must be descendants of a `TabGroup` entity, which is a component that //! 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 //! 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 //! is determined by the [`TabGroup::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. //! 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 //! To enable automatic tabbing, add the
//! `TabNavigationPlugin` to your app. (Make sure you also have `InputDispatchPlugin` installed). //! [`TabNavigationPlugin`] and [`InputDispatchPlugin`](crate::InputDispatchPlugin) to your app.
//! This will install a keyboard event observer on the primary window which automatically handles //! This will install a keyboard event observer on the primary window which automatically handles
//! tab navigation for you. //! tab navigation for you.
//! //!
//! Alternatively, if you want to have more control over tab navigation, or are using an event //! Alternatively, if you want to have more control over tab navigation, or are using an input-action-mapping framework,
//! mapping framework such as LWIM, you can use the `TabNavigation` helper object directly instead. //! you can use the [`TabNavigation`] system parameter directly instead.
//! This object can be injected into your systems, and provides a `navigate` method which can be //! This object can be injected into your systems, and provides a [`navigate`](`TabNavigation::navigate`) method which can be
//! used to navigate between focusable entities. //! 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_app::{App, Plugin, Startup};
use bevy_ecs::{ use bevy_ecs::{
component::{Component, ComponentId}, component::Component,
entity::Entity, entity::Entity,
observer::Trigger, observer::Trigger,
query::{With, Without}, query::{With, Without},
system::{Commands, Query, Res, ResMut, SystemParam}, system::{Commands, Query, Res, ResMut, SystemParam},
world::DeferredWorld,
}; };
use bevy_hierarchy::{Children, HierarchyQueryExt, Parent}; use bevy_hierarchy::{Children, HierarchyQueryExt, Parent};
use bevy_input::{ use bevy_input::{
@ -43,7 +39,7 @@ use bevy_input::{
use bevy_utils::tracing::warn; use bevy_utils::tracing::warn;
use bevy_window::PrimaryWindow; use bevy_window::PrimaryWindow;
use crate::{FocusedInput, InputFocus, InputFocusVisible, SetInputFocus}; use crate::{FocusedInput, InputFocus, InputFocusVisible};
/// A component which indicates that an entity wants to participate in tab navigation. /// A component which indicates that an entity wants to participate in tab navigation.
/// ///
@ -52,10 +48,6 @@ use crate::{FocusedInput, InputFocus, InputFocusVisible, SetInputFocus};
#[derive(Debug, Default, Component, Copy, Clone)] #[derive(Debug, Default, Component, Copy, Clone)]
pub struct TabIndex(pub i32); 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. /// A component used to mark a tree of entities as containing tabbable elements.
#[derive(Debug, Default, Component, Copy, Clone)] #[derive(Debug, Default, Component, Copy, Clone)]
pub struct TabGroup { pub struct TabGroup {
@ -87,7 +79,9 @@ impl TabGroup {
} }
} }
/// Navigation action for tabbing. /// A navigation action for tabbing.
///
/// These values are consumed by the [`TabNavigation`] system param.
pub enum NavAction { pub enum NavAction {
/// Navigate to the next focusable entity, wrapping around to the beginning if at the end. /// Navigate to the next focusable entity, wrapping around to the beginning if at the end.
Next, Next,
@ -120,6 +114,8 @@ pub struct TabNavigation<'w, 's> {
impl TabNavigation<'_, '_> { impl TabNavigation<'_, '_> {
/// Navigate to the next focusable entity. /// Navigate to the next focusable entity.
/// ///
/// Focusable entities are determined by the presence of the [`TabIndex`] component.
///
/// Arguments: /// Arguments:
/// * `focus`: The current focus entity, or `None` if no entity has focus. /// * `focus`: The current focus entity, or `None` if no entity has focus.
/// * `action`: Whether to select the next, previous, first, or last focusable entity. /// * `action`: Whether to select the next, previous, first, or last focusable entity.
@ -128,7 +124,7 @@ impl TabNavigation<'_, '_> {
/// or last focusable entity, depending on the direction of navigation. For example, if /// 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 /// `action` is `Next` and no focusable entities are found, then this function will return
/// the first focusable entity. /// the first focusable entity.
pub fn navigate(&self, focus: Option<Entity>, action: NavAction) -> Option<Entity> { pub fn navigate(&self, focus: &InputFocus, action: NavAction) -> Option<Entity> {
// If there are no tab groups, then there are no focusable entities. // If there are no tab groups, then there are no focusable entities.
if self.tabgroup_query.is_empty() { if self.tabgroup_query.is_empty() {
warn!("No tab groups found"); warn!("No tab groups found");
@ -137,7 +133,7 @@ impl TabNavigation<'_, '_> {
// Start by identifying which tab group we are in. Mainly what we want to know is if // Start by identifying which tab group we are in. Mainly what we want to know is if
// we're in a modal group. // we're in a modal group.
let tabgroup = focus.and_then(|focus_ent| { let tabgroup = focus.0.and_then(|focus_ent| {
self.parent_query self.parent_query
.iter_ancestors(focus_ent) .iter_ancestors(focus_ent)
.find_map(|entity| { .find_map(|entity| {
@ -148,9 +144,8 @@ impl TabNavigation<'_, '_> {
}) })
}); });
if focus.is_some() && tabgroup.is_none() { if focus.0.is_some() && tabgroup.is_none() {
warn!("No tab group found for focus entity"); warn!("No tab group found for focus entity. Users will not be able to navigate back to this entity.");
return None;
} }
self.navigate_in_group(tabgroup, focus, action) self.navigate_in_group(tabgroup, focus, action)
@ -159,7 +154,7 @@ impl TabNavigation<'_, '_> {
fn navigate_in_group( fn navigate_in_group(
&self, &self,
tabgroup: Option<(Entity, &TabGroup)>, tabgroup: Option<(Entity, &TabGroup)>,
focus: Option<Entity>, focus: &InputFocus,
action: NavAction, action: NavAction,
) -> Option<Entity> { ) -> Option<Entity> {
// List of all focusable entities found. // List of all focusable entities found.
@ -201,7 +196,7 @@ impl TabNavigation<'_, '_> {
// Stable sort by tabindex // Stable sort by tabindex
focusable.sort_by(compare_tab_indices); focusable.sort_by(compare_tab_indices);
let index = focusable.iter().position(|e| Some(e.0) == focus); let index = focusable.iter().position(|e| Some(e.0) == focus.0);
let count = focusable.len(); let count = focusable.len();
let next = match (index, action) { let next = match (index, action) {
(Some(idx), NavAction::Next) => (idx + 1).rem_euclid(count), (Some(idx), NavAction::Next) => (idx + 1).rem_euclid(count),
@ -247,15 +242,12 @@ fn compare_tab_indices(a: &(Entity, TabIndex), b: &(Entity, TabIndex)) -> core::
a.1 .0.cmp(&b.1 .0) a.1 .0.cmp(&b.1 .0)
} }
/// Plugin for handling keyboard input. /// Plugin for navigating between focusable entities using keyboard input.
pub struct TabNavigationPlugin; pub struct TabNavigationPlugin;
impl Plugin for TabNavigationPlugin { impl Plugin for TabNavigationPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(Startup, setup_tab_navigation); app.add_systems(Startup, setup_tab_navigation);
app.world_mut()
.register_component_hooks::<AutoFocus>()
.on_add(on_auto_focus_added);
} }
} }
@ -283,7 +275,7 @@ pub fn handle_tab_navigation(
&& !key_event.repeat && !key_event.repeat
{ {
let next = nav.navigate( let next = nav.navigate(
focus.0, &focus,
if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) { if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {
NavAction::Previous NavAction::Previous
} else { } else {
@ -298,12 +290,6 @@ pub fn handle_tab_navigation(
} }
} }
fn on_auto_focus_added(mut world: DeferredWorld, entity: Entity, _: ComponentId) {
if world.entity(entity).contains::<TabIndex>() {
world.set_input_focus(entity);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use bevy_ecs::system::SystemState; use bevy_ecs::system::SystemState;
@ -326,16 +312,18 @@ mod tests {
assert_eq!(tab_navigation.tabgroup_query.iter().count(), 1); assert_eq!(tab_navigation.tabgroup_query.iter().count(), 1);
assert_eq!(tab_navigation.tabindex_query.iter().count(), 2); assert_eq!(tab_navigation.tabindex_query.iter().count(), 2);
let next_entity = tab_navigation.navigate(Some(tab_entity_1), NavAction::Next); let next_entity =
tab_navigation.navigate(&InputFocus::from_entity(tab_entity_1), NavAction::Next);
assert_eq!(next_entity, Some(tab_entity_2)); assert_eq!(next_entity, Some(tab_entity_2));
let prev_entity = tab_navigation.navigate(Some(tab_entity_2), NavAction::Previous); let prev_entity =
tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Previous);
assert_eq!(prev_entity, Some(tab_entity_1)); assert_eq!(prev_entity, Some(tab_entity_1));
let first_entity = tab_navigation.navigate(None, NavAction::First); let first_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::First);
assert_eq!(first_entity, Some(tab_entity_1)); assert_eq!(first_entity, Some(tab_entity_1));
let last_entity = tab_navigation.navigate(None, NavAction::Last); let last_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::Last);
assert_eq!(last_entity, Some(tab_entity_2)); assert_eq!(last_entity, Some(tab_entity_2));
} }
} }