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:
parent
c817864e4f
commit
5f1e762f19
@ -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_window = { path = "../bevy_window", version = "0.15.0-dev", default-features = false }
|
||||
|
||||
thiserror = { version = "2", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
smol_str = "0.2"
|
||||
|
||||
|
@ -38,6 +38,7 @@ use bevy_input::{
|
||||
};
|
||||
use bevy_utils::tracing::warn;
|
||||
use bevy_window::PrimaryWindow;
|
||||
use thiserror::Error;
|
||||
|
||||
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.
|
||||
pub enum NavAction {
|
||||
/// 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,
|
||||
/// Navigate to the previous focusable entity, wrapping around to the end if at the beginning.
|
||||
///
|
||||
/// This is commonly triggered by pressing Shift+Tab.
|
||||
Previous,
|
||||
/// Navigate to the first focusable entity.
|
||||
///
|
||||
/// This is commonly triggered by pressing Home.
|
||||
First,
|
||||
/// Navigate to the last focusable entity.
|
||||
///
|
||||
/// This is commonly triggered by pressing End.
|
||||
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.
|
||||
#[doc(hidden)]
|
||||
#[derive(SystemParam)]
|
||||
@ -112,23 +148,23 @@ pub struct TabNavigation<'w, 's> {
|
||||
}
|
||||
|
||||
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.
|
||||
///
|
||||
/// 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: &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 self.tabgroup_query.is_empty() {
|
||||
warn!("No tab groups found");
|
||||
return None;
|
||||
return Err(TabNavigationError::NoTabGroups);
|
||||
}
|
||||
|
||||
// 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() {
|
||||
warn!("No tab group found for focus entity. Users will not be able to navigate back to this entity.");
|
||||
}
|
||||
let navigation_result = self.navigate_in_group(tabgroup, focus, action);
|
||||
|
||||
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(
|
||||
@ -156,7 +202,7 @@ impl TabNavigation<'_, '_> {
|
||||
tabgroup: Option<(Entity, &TabGroup)>,
|
||||
focus: &InputFocus,
|
||||
action: NavAction,
|
||||
) -> Option<Entity> {
|
||||
) -> Result<Entity, TabNavigationError> {
|
||||
// List of all focusable entities found.
|
||||
let mut focusable: Vec<(Entity, TabIndex)> =
|
||||
Vec::with_capacity(self.tabindex_query.iter().len());
|
||||
@ -189,8 +235,7 @@ impl TabNavigation<'_, '_> {
|
||||
}
|
||||
|
||||
if focusable.is_empty() {
|
||||
warn!("No focusable entities found");
|
||||
return None;
|
||||
return Err(TabNavigationError::NoFocusableEntities);
|
||||
}
|
||||
|
||||
// Stable sort by tabindex
|
||||
@ -204,7 +249,10 @@ impl TabNavigation<'_, '_> {
|
||||
(None, NavAction::Next) | (_, NavAction::First) => 0,
|
||||
(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.
|
||||
@ -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,
|
||||
/// 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(
|
||||
mut trigger: Trigger<FocusedInput<KeyboardInput>>,
|
||||
nav: TabNavigation,
|
||||
@ -265,7 +315,7 @@ pub fn handle_tab_navigation(
|
||||
&& key_event.state == ButtonState::Pressed
|
||||
&& !key_event.repeat
|
||||
{
|
||||
let next = nav.navigate(
|
||||
let maybe_next = nav.navigate(
|
||||
&focus,
|
||||
if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {
|
||||
NavAction::Previous
|
||||
@ -273,11 +323,23 @@ pub fn handle_tab_navigation(
|
||||
NavAction::Next
|
||||
},
|
||||
);
|
||||
if next.is_some() {
|
||||
|
||||
match maybe_next {
|
||||
Ok(next) => {
|
||||
trigger.propagate(false);
|
||||
focus.0 = next;
|
||||
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 =
|
||||
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 =
|
||||
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);
|
||||
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);
|
||||
assert_eq!(last_entity, Some(tab_entity_2));
|
||||
assert_eq!(last_entity, Ok(tab_entity_2));
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user