Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions _release-content/migration-guides/focusable_component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
title: "Input focus is now represented by `Focusable`"
pull_requests: []
---

The new `Focusable` component indicates that an entity may receive input focus, independently of
the navigation method used to reach it. When an explicit sequential-navigation order is not needed,
replace a default `TabIndex` with `Focusable`:

```rust
// Before
TabIndex(0)

// After
Focusable
```

`TabIndex` now only controls sequential navigation and automatically requires `Focusable`.
`TabIndex(-1)` therefore remains the way to make an entity focusable but exclude it from sequential
navigation.

A `Focusable` entity inside a `TabGroup` participates in sequential navigation with an implicit
Comment thread
agluszak marked this conversation as resolved.
`TabIndex(0)` unless it has a negative `TabIndex`. Manual directional-navigation destinations must
also have `Focusable`; adding an edge to the `DirectionalNavigationMap` no longer makes its
destination focusable by itself.

`DirectionalNavigation` SystemParam struct gained a second lifetime parameter.
2 changes: 0 additions & 2 deletions crates/bevy_feathers/src/controls/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ use bevy_ecs::{
schedule::IntoScheduleConfigs,
system::{Commands, Query},
};
use bevy_input_focus::tab_navigation::TabIndex;
use bevy_picking::{cursor::EntityCursor, hover::Hovered, PickingSystems};
use bevy_reflect::{prelude::ReflectDefault, Reflect};
use bevy_scene::prelude::*;
Expand Down Expand Up @@ -104,7 +103,6 @@ impl FeathersButton {
props.variant
Hovered
EntityCursor::System(bevy_window::SystemCursorIcon::Pointer)
TabIndex(0)
FocusIndicator
ThemeBackgroundColor(tokens::BUTTON_BG)
InheritableThemeTextColor(tokens::BUTTON_TEXT)
Expand Down
2 changes: 0 additions & 2 deletions crates/bevy_feathers/src/controls/checkbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ use bevy_ecs::{
system::{Commands, Query},
template::FromTemplate,
};
use bevy_input_focus::tab_navigation::TabIndex;
use bevy_math::Rot2;
use bevy_picking::{cursor::EntityCursor, hover::Hovered, PickingSystems};
use bevy_reflect::{prelude::ReflectDefault, Reflect};
Expand Down Expand Up @@ -77,7 +76,6 @@ impl FeathersCheckbox {
CheckboxFrame
Hovered
EntityCursor::System(bevy_window::SystemCursorIcon::Pointer)
TabIndex(0)
InheritableThemeTextColor(tokens::CHECKBOX_TEXT)
InheritableFont {
font: fonts::REGULAR,
Expand Down
2 changes: 0 additions & 2 deletions crates/bevy_feathers/src/controls/color_slider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ use bevy_ecs::{
schedule::IntoScheduleConfigs,
system::Query,
};
use bevy_input_focus::tab_navigation::TabIndex;
use bevy_log::warn_once;
use bevy_picking::{cursor::EntityCursor, PickingSystems};
use bevy_reflect::std_traits::ReflectDefault;
Expand Down Expand Up @@ -246,7 +245,6 @@ impl FeathersColorSlider {
SliderValue({props.value})
props.channel.range()
EntityCursor::System(bevy_window::SystemCursorIcon::Pointer)
TabIndex(0)
FocusIndicator
Children [
// track
Expand Down
2 changes: 0 additions & 2 deletions crates/bevy_feathers/src/controls/color_swatch_grid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ use bevy_ecs::{
reflect::ReflectComponent,
system::{Commands, Query, SystemParam},
};
use bevy_input_focus::tab_navigation::TabIndex;
use bevy_math::UVec2;
use bevy_picking::{cursor::EntityCursor, Pickable};
use bevy_reflect::{prelude::ReflectDefault, Reflect};
Expand Down Expand Up @@ -67,7 +66,6 @@ impl FeathersColorSwatchGrid {
align_items: AlignItems::Stretch,
}
RadioGroup
TabIndex
FocusIndicator
on(swatch_grid_ready)
on(swatch_grid_on_value_change)
Expand Down
2 changes: 0 additions & 2 deletions crates/bevy_feathers/src/controls/disclosure_toggle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ use bevy_ecs::{
schedule::IntoScheduleConfigs,
system::{Commands, Query},
};
use bevy_input_focus::tab_navigation::TabIndex;
use bevy_math::Rot2;
use bevy_picking::{cursor::EntityCursor, PickingSystems};
use bevy_reflect::std_traits::ReflectDefault;
Expand Down Expand Up @@ -51,7 +50,6 @@ impl FeathersDisclosureToggle {
EntityCursor::System(SystemCursorIcon::Pointer)
FocusIndicator
InheritableThemeTextColor(tokens::BUTTON_TEXT)
TabIndex(0)
Children [
@icon(icons::CHEVRON_RIGHT)
]
Expand Down
5 changes: 1 addition & 4 deletions crates/bevy_feathers/src/controls/listview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ use bevy_ecs::{
schedule::IntoScheduleConfigs as _,
system::{Commands, Query, Res},
};
use bevy_input_focus::{
tab_navigation::TabIndex, InputFocus, InputFocusSystems, InputFocusVisible,
};
use bevy_input_focus::{InputFocus, InputFocusSystems, InputFocusVisible};
use bevy_picking::{cursor::EntityCursor, hover::Hovered, PickingSystems};
use bevy_reflect::{prelude::ReflectDefault, Reflect};
use bevy_scene::{bsn, bsn_list, Scene, SceneComponent, SceneList};
Expand Down Expand Up @@ -75,7 +73,6 @@ impl FeathersListView {
ScrollbarGutter(px(14))
ListBox
AccessibilityNode(accesskit::Node::new(Role::ListBox))
TabIndex(0)
Children [
// Inner part that scrolls
(
Expand Down
4 changes: 1 addition & 3 deletions crates/bevy_feathers/src/controls/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ use crate::{
tokens,
};
use bevy_input_focus::{
tab_navigation::{NavAction, TabIndex},
FocusCause, InputFocus, InputFocusSystems, InputFocusVisible,
tab_navigation::NavAction, FocusCause, InputFocus, InputFocusSystems, InputFocusVisible,
};

/// Top-level menu container. This wraps the menu button and provides an anchor for the popover.
Expand Down Expand Up @@ -427,7 +426,6 @@ impl FeathersMenuItem {
MenuItem
Hovered
EntityCursor::System(bevy_window::SystemCursorIcon::Pointer)
TabIndex(0)
ThemeBackgroundColor(tokens::MENU_BG) // Same as menu
InheritableThemeTextColor(tokens::MENUITEM_TEXT)
InheritableFont {
Expand Down
4 changes: 2 additions & 2 deletions crates/bevy_feathers/src/controls/radio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use bevy_ecs::{
schedule::IntoScheduleConfigs,
system::{Commands, Query},
};
use bevy_input_focus::tab_navigation::TabIndex;
use bevy_input_focus::Focusable;
use bevy_picking::{cursor::EntityCursor, hover::Hovered, PickingSystems};
use bevy_reflect::{prelude::ReflectDefault, Reflect};
use bevy_scene::prelude::*;
Expand Down Expand Up @@ -75,7 +75,7 @@ impl FeathersRadio {
RadioButton
Hovered
EntityCursor::System(bevy_window::SystemCursorIcon::Pointer)
TabIndex(0)
Focusable
InheritableThemeTextColor(tokens::RADIO_TEXT)
InheritableFont {
font: fonts::REGULAR,
Expand Down
2 changes: 0 additions & 2 deletions crates/bevy_feathers/src/controls/slider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ use bevy_ecs::{
schedule::IntoScheduleConfigs,
system::{Commands, Query, Res},
};
use bevy_input_focus::tab_navigation::TabIndex;
use bevy_picking::{cursor::EntityCursor, hover::Hovered, PickingSystems};
use bevy_reflect::{prelude::ReflectDefault, Reflect};
use bevy_scene::prelude::*;
Expand Down Expand Up @@ -89,7 +88,6 @@ impl FeathersSlider {
SliderValue({props.min})
SliderRange::new(props.min, props.max)
EntityCursor::System(bevy_window::SystemCursorIcon::EwResize)
TabIndex(0)
FocusIndicator
InheritableThemeTextColor(tokens::SLIDER_TEXT)
// Use a gradient to draw the moving bar
Expand Down
2 changes: 0 additions & 2 deletions crates/bevy_feathers/src/controls/text_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ use bevy_ecs::{
system::{Commands, Query, Res},
template::template,
};
use bevy_input_focus::tab_navigation::TabIndex;
use bevy_picking::{cursor::EntityCursor, PickingSystems};
use bevy_reflect::std_traits::ReflectDefault;
use bevy_reflect::Reflect;
Expand Down Expand Up @@ -120,7 +119,6 @@ impl FeathersTextInput {
TextLayout {
linebreak: LineBreak::NoWrap,
}
TabIndex(0)
template(|ctx| {
Ok(TextFont {
font: FontSource::Handle(ctx.resource::<AssetServer>().load(fonts::REGULAR)),
Expand Down
2 changes: 0 additions & 2 deletions crates/bevy_feathers/src/controls/toggle_switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ use bevy_ecs::{
system::{Commands, Query},
world::Mut,
};
use bevy_input_focus::tab_navigation::TabIndex;
use bevy_picking::{cursor::EntityCursor, hover::Hovered, PickingSystems};
use bevy_reflect::{prelude::ReflectDefault, Reflect};
use bevy_scene::prelude::*;
Expand Down Expand Up @@ -58,7 +57,6 @@ impl FeathersToggleSwitch {
AccessibilityNode(accesskit::Node::new(Role::Switch))
Hovered
EntityCursor::System(bevy_window::SystemCursorIcon::Pointer)
TabIndex(0)
FocusIndicator
Children [(
Node {
Expand Down
63 changes: 53 additions & 10 deletions crates/bevy_input_focus/src/directional_navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
//!
//! Manually define your navigation using the [`DirectionalNavigationMap`], and use the
//! [`DirectionalNavigation`] system parameter to navigate between components.
//! Destinations must have the [`Focusable`] component when navigation occurs.
//! You can define navigation connections using methods like
//! [`add_edge`](DirectionalNavigationMap::add_edge) and
//! [`add_looping_edges`](DirectionalNavigationMap::add_looping_edges).
Expand All @@ -56,7 +57,7 @@
//! - **Cross-layer navigation**: Connect elements across different UI layers or z-index levels
//! - **Custom behavior**: Implement domain-specific navigation patterns (e.g., spreadsheet-style wrapping)

use crate::{navigator::find_best_candidate, FocusCause, InputFocus};
use crate::{navigator::find_best_candidate, FocusCause, Focusable, InputFocus};
use bevy_app::prelude::*;
use bevy_ecs::{
entity::{EntityHashMap, EntityHashSet},
Expand Down Expand Up @@ -393,14 +394,16 @@ impl DirectionalNavigationMap {

/// A system parameter for navigating between focusable entities in a directional way.
#[derive(SystemParam, Debug)]
pub struct DirectionalNavigation<'w> {
pub struct DirectionalNavigation<'w, 's> {
/// The currently focused entity.
pub focus: ResMut<'w, InputFocus>,
/// The directional navigation map containing manually defined connections between entities.
pub map: Res<'w, DirectionalNavigationMap>,
/// Entities which may receive input focus.
focusable: Query<'w, 's, (), With<Focusable>>,
}

impl<'w> DirectionalNavigation<'w> {
impl DirectionalNavigation<'_, '_> {
/// Navigates to the neighbor in a given direction from the current focus, if any.
///
/// Returns the new focus if successful.
Expand All @@ -423,8 +426,15 @@ impl<'w> DirectionalNavigation<'w> {
direction,
}),
NavNeighbor::Set(new_focus) => {
self.focus.set(new_focus, FocusCause::Navigated);
Ok(new_focus)
if self.focusable.contains(new_focus) {
self.focus.set(new_focus, FocusCause::Navigated);
Ok(new_focus)
} else {
Err(DirectionalNavigationError::NoNeighborInDirection {
current_focus,
direction,
})
}
}
}
} else {
Expand All @@ -436,8 +446,8 @@ impl<'w> DirectionalNavigation<'w> {
/// An error that can occur when navigating between focusable entities using [directional navigation](crate::directional_navigation).
#[derive(Debug, PartialEq, Clone, Error)]
pub enum DirectionalNavigationError {
/// No focusable entity is currently set.
#[error("No focusable entity is currently set.")]
/// No entity currently has input focus.
#[error("No entity currently has input focus.")]
NoFocus,
/// No neighbor in the requested direction.
#[error("No neighbor from {current_focus} in the {direction:?} direction.")]
Expand Down Expand Up @@ -747,9 +757,9 @@ mod tests {
#[test]
fn manual_nav_with_system_param() {
let mut world = World::new();
let a = world.spawn_empty().id();
let b = world.spawn_empty().id();
let c = world.spawn_empty().id();
let a = world.spawn(Focusable).id();
let b = world.spawn(Focusable).id();
let c = world.spawn(Focusable).id();

let mut map = DirectionalNavigationMap::default();
map.add_looping_edges(&[a, b, c], CompassOctant::East);
Expand Down Expand Up @@ -779,6 +789,39 @@ mod tests {
assert_eq!(world.resource::<InputFocus>().get(), Some(a));
}

#[test]
fn manual_navigation_skips_non_focusable_destination() {
let mut world = World::new();
let current = world.spawn_empty().id();
let destination = world.spawn_empty().id();
let mut map = DirectionalNavigationMap::default();
map.add_edge(current, destination, CompassOctant::East);
world.insert_resource(map);
world.insert_resource(InputFocus::from_entity(current));

fn navigate_east(
mut nav: DirectionalNavigation,
) -> Result<Entity, DirectionalNavigationError> {
nav.navigate(CompassOctant::East)
}

assert_eq!(
world.run_system_once(navigate_east).unwrap(),
Err(DirectionalNavigationError::NoNeighborInDirection {
current_focus: current,
direction: CompassOctant::East,
})
);
assert_eq!(world.resource::<InputFocus>().get(), Some(current));

world.entity_mut(destination).insert(Focusable);
assert_eq!(
world.run_system_once(navigate_east).unwrap(),
Ok(destination)
);
assert_eq!(world.resource::<InputFocus>().get(), Some(destination));
}

#[test]
fn test_auto_generate_navigation_edges() {
let mut nav_map = DirectionalNavigationMap::default();
Expand Down
Loading