Return Result from tab navigation API (#17071)

# Objective

Tab navigation can fail in all manner of ways. The current API
recognizes this, but merely logs a warning and returns `None`.

We should supply the actual reason for failure to the caller, so they
can handle it in whatever fashion they please (including logging a
warning!).

Swapping to a Result-oriented pattern is also a bit more idiomatic and
makes the code's control flow easier to follow.

## Solution

- Refactor the `tab_navigation` module to return a `Result` rather than
an `Option` from its key APIs.
- Move the logging to the provided prebuilt observer. This leaves the
default behavior largely unchanged, but allows for better user control.
- Make the case where no tab group was found for the currently focused
entity an error branch, but provide enough information that we can still
recover from it.

## Testing

The `tab_navigation` example continues to function as intended.
This commit is contained in:
Alice Cecile 2024-12-31 23:05:48 -05:00 committed by GitHub
parent c817864e4f
commit 5f1e762f19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 90 additions and 26 deletions

View File

@ -17,6 +17,8 @@ bevy_hierarchy = { path = "../bevy_hierarchy", version = "0.15.0-dev", default-f
bevy_utils = { path = "../bevy_utils", 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 } bevy_window = { path = "../bevy_window", version = "0.15.0-dev", default-features = false }
thiserror = { version = "2", default-features = false }
[dev-dependencies] [dev-dependencies]
smol_str = "0.2" smol_str = "0.2"

View File

@ -38,6 +38,7 @@ use bevy_input::{
}; };
use bevy_utils::tracing::warn; use bevy_utils::tracing::warn;
use bevy_window::PrimaryWindow; use bevy_window::PrimaryWindow;
use thiserror::Error;
use crate::{FocusedInput, InputFocus, InputFocusVisible}; use crate::{FocusedInput, InputFocus, InputFocusVisible};
@ -79,20 +80,55 @@ impl TabGroup {
} }
} }
/// A navigation action for tabbing. /// A navigation action that users might take to navigate your user interface in a cyclic fashion.
/// ///
/// These values are consumed by the [`TabNavigation`] system param. /// 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.
///
/// This is commonly triggered by pressing the Tab key.
Next, Next,
/// Navigate to the previous focusable entity, wrapping around to the end if at the beginning. /// Navigate to the previous focusable entity, wrapping around to the end if at the beginning.
///
/// This is commonly triggered by pressing Shift+Tab.
Previous, Previous,
/// Navigate to the first focusable entity. /// Navigate to the first focusable entity.
///
/// This is commonly triggered by pressing Home.
First, First,
/// Navigate to the last focusable entity. /// Navigate to the last focusable entity.
///
/// This is commonly triggered by pressing End.
Last, Last,
} }
/// An error that can occur during [tab navigation](crate::tab_navigation).
#[derive(Debug, Error, PartialEq, Eq, Clone)]
pub enum TabNavigationError {
/// No tab groups were found.
#[error("No tab groups found")]
NoTabGroups,
/// No focusable entities were found.
#[error("No focusable entities found")]
NoFocusableEntities,
/// Could not navigate to the next focusable entity.
///
/// This can occur if your tab groups are malformed.
#[error("Failed to navigate to next focusable entity")]
FailedToNavigateToNextFocusableEntity,
/// No tab group for the current focus entity was found.
#[error("No tab group found for currently focused entity {previous_focus}. Users will not be able to navigate back to this entity.")]
NoTabGroupForCurrentFocus {
/// The entity that was previously focused,
/// and is missing its tab group.
previous_focus: Entity,
/// The new entity that will be focused.
///
/// If you want to recover from this error, set [`InputFocus`] to this entity.
new_focus: Entity,
},
}
/// An injectable helper object that provides tab navigation functionality. /// An injectable helper object that provides tab navigation functionality.
#[doc(hidden)] #[doc(hidden)]
#[derive(SystemParam)] #[derive(SystemParam)]
@ -112,23 +148,23 @@ pub struct TabNavigation<'w, 's> {
} }
impl TabNavigation<'_, '_> { impl TabNavigation<'_, '_> {
/// Navigate to the next focusable entity. /// Navigate to the desired focusable entity.
/// ///
/// Change the [`NavAction`] to navigate in a different direction.
/// Focusable entities are determined by the presence of the [`TabIndex`] component. /// Focusable entities are determined by the presence of the [`TabIndex`] component.
/// ///
/// 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 /// 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 /// 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: &InputFocus, action: NavAction) -> Option<Entity> { pub fn navigate(
&self,
focus: &InputFocus,
action: NavAction,
) -> Result<Entity, TabNavigationError> {
// 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"); return Err(TabNavigationError::NoTabGroups);
return None;
} }
// 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
@ -144,11 +180,21 @@ impl TabNavigation<'_, '_> {
}) })
}); });
if focus.0.is_some() && tabgroup.is_none() { let navigation_result = self.navigate_in_group(tabgroup, focus, action);
warn!("No tab group found for focus entity. Users will not be able to navigate back to this entity.");
}
self.navigate_in_group(tabgroup, focus, action) match navigation_result {
Ok(entity) => {
if focus.0.is_some() && tabgroup.is_none() {
Err(TabNavigationError::NoTabGroupForCurrentFocus {
previous_focus: focus.0.unwrap(),
new_focus: entity,
})
} else {
Ok(entity)
}
}
Err(e) => Err(e),
}
} }
fn navigate_in_group( fn navigate_in_group(
@ -156,7 +202,7 @@ impl TabNavigation<'_, '_> {
tabgroup: Option<(Entity, &TabGroup)>, tabgroup: Option<(Entity, &TabGroup)>,
focus: &InputFocus, focus: &InputFocus,
action: NavAction, action: NavAction,
) -> Option<Entity> { ) -> Result<Entity, TabNavigationError> {
// List of all focusable entities found. // List of all focusable entities found.
let mut focusable: Vec<(Entity, TabIndex)> = let mut focusable: Vec<(Entity, TabIndex)> =
Vec::with_capacity(self.tabindex_query.iter().len()); Vec::with_capacity(self.tabindex_query.iter().len());
@ -189,8 +235,7 @@ impl TabNavigation<'_, '_> {
} }
if focusable.is_empty() { if focusable.is_empty() {
warn!("No focusable entities found"); return Err(TabNavigationError::NoFocusableEntities);
return None;
} }
// Stable sort by tabindex // Stable sort by tabindex
@ -204,7 +249,10 @@ impl TabNavigation<'_, '_> {
(None, NavAction::Next) | (_, NavAction::First) => 0, (None, NavAction::Next) | (_, NavAction::First) => 0,
(None, NavAction::Previous) | (_, NavAction::Last) => count - 1, (None, NavAction::Previous) | (_, NavAction::Last) => count - 1,
}; };
focusable.get(next).map(|(e, _)| e).copied() match focusable.get(next) {
Some((entity, _)) => Ok(*entity),
None => Err(TabNavigationError::FailedToNavigateToNextFocusableEntity),
}
} }
/// Gather all focusable entities in tree order. /// Gather all focusable entities in tree order.
@ -252,6 +300,8 @@ fn setup_tab_navigation(mut commands: Commands, window: Query<Entity, With<Prima
/// ///
/// This observer responds to [`KeyCode::Tab`] events and Shift+Tab events, /// This observer responds to [`KeyCode::Tab`] events and Shift+Tab events,
/// cycling through focusable entities in the order determined by their tab index. /// cycling through focusable entities in the order determined by their tab index.
///
/// Any [`TabNavigationError`]s that occur during tab navigation are logged as warnings.
pub fn handle_tab_navigation( pub fn handle_tab_navigation(
mut trigger: Trigger<FocusedInput<KeyboardInput>>, mut trigger: Trigger<FocusedInput<KeyboardInput>>,
nav: TabNavigation, nav: TabNavigation,
@ -265,7 +315,7 @@ pub fn handle_tab_navigation(
&& key_event.state == ButtonState::Pressed && key_event.state == ButtonState::Pressed
&& !key_event.repeat && !key_event.repeat
{ {
let next = nav.navigate( let maybe_next = nav.navigate(
&focus, &focus,
if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) { if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {
NavAction::Previous NavAction::Previous
@ -273,10 +323,22 @@ pub fn handle_tab_navigation(
NavAction::Next NavAction::Next
}, },
); );
if next.is_some() {
trigger.propagate(false); match maybe_next {
focus.0 = next; Ok(next) => {
visible.0 = true; trigger.propagate(false);
focus.set(next);
visible.0 = true;
}
Err(e) => {
warn!("Tab navigation error: {}", e);
// This failure mode is recoverable, but still indicates a problem.
if let TabNavigationError::NoTabGroupForCurrentFocus { new_focus, .. } = e {
trigger.propagate(false);
focus.set(new_focus);
visible.0 = true;
}
}
} }
} }
} }
@ -305,16 +367,16 @@ mod tests {
let next_entity = let next_entity =
tab_navigation.navigate(&InputFocus::from_entity(tab_entity_1), NavAction::Next); tab_navigation.navigate(&InputFocus::from_entity(tab_entity_1), NavAction::Next);
assert_eq!(next_entity, Some(tab_entity_2)); assert_eq!(next_entity, Ok(tab_entity_2));
let prev_entity = let prev_entity =
tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Previous); tab_navigation.navigate(&InputFocus::from_entity(tab_entity_2), NavAction::Previous);
assert_eq!(prev_entity, Some(tab_entity_1)); assert_eq!(prev_entity, Ok(tab_entity_1));
let first_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::First); let first_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::First);
assert_eq!(first_entity, Some(tab_entity_1)); assert_eq!(first_entity, Ok(tab_entity_1));
let last_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::Last); let last_entity = tab_navigation.navigate(&InputFocus::default(), NavAction::Last);
assert_eq!(last_entity, Some(tab_entity_2)); assert_eq!(last_entity, Ok(tab_entity_2));
} }
} }