Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add UI keyboard navigation #8882

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 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
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1782,6 +1782,17 @@ description = "Illustrates creating and updating a button"
category = "UI (User Interface)"
wasm = true


[[example]]
name = "keyboard_navigation"
path = "examples/ui/keyboard_navigation.rs"

[package.metadata.example.keyboard_navigation]
name = "Keyboard Navigation"
description = "A simple UI containing several buttons that modify a counter, to demonstrate keyboard navigation"
category = "UI (User Interface)"
wasm = true

[[example]]
name = "window_fallthrough"
path = "examples/ui/window_fallthrough.rs"
Expand Down
10 changes: 8 additions & 2 deletions crates/bevy_a11y/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,14 @@ impl AccessKitEntityExt for Entity {
}

/// Resource representing which entity has keyboard focus, if any.
#[derive(Resource, Default, Deref, DerefMut)]
pub struct Focus(Option<Entity>);
#[derive(PartialEq, Eq, Debug, Resource, Default)]
pub struct Focus {
/// The entity that has keyboard focus.
pub entity: Option<Entity>,
/// Focus has been reached through keyboard navigation and so a focus style should be displayed.
/// This is similar to the `:focus-visible` pseudo-class in css.
pub focus_visible: bool,
}

/// Plugin managing non-GUI aspects of integrating with accessibility APIs.
pub struct AccessibilityPlugin;
Expand Down
8 changes: 7 additions & 1 deletion crates/bevy_ui/src/focus.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
use crate::{camera_config::UiCameraConfig, CalculatedClip, Node, UiStack};
use bevy_a11y::Focus;
use bevy_derive::{Deref, DerefMut};
use bevy_ecs::{
change_detection::DetectChangesMut,
entity::Entity,
prelude::{Component, With},
query::WorldQuery,
reflect::ReflectComponent,
system::{Local, Query, Res},
system::{Local, Query, Res, ResMut},
};
use bevy_input::{mouse::MouseButton, touch::Touches, Input};
use bevy_math::Vec2;
Expand Down Expand Up @@ -146,6 +147,7 @@ pub fn ui_focus_system(
ui_stack: Res<UiStack>,
mut node_query: Query<NodeQuery>,
primary_window: Query<Entity, With<PrimaryWindow>>,
mut focus: ResMut<Focus>,
) {
let primary_window = primary_window.iter().next();

Expand Down Expand Up @@ -268,6 +270,10 @@ pub fn ui_focus_system(
while let Some(node) = iter.fetch_next() {
if let Some(mut interaction) = node.interaction {
if mouse_clicked {
focus.set_if_neq(Focus {
entity: Some(node.entity),
focus_visible: false,
});
// only consider nodes with Interaction "clickable"
if *interaction != Interaction::Clicked {
*interaction = Interaction::Clicked;
Expand Down
173 changes: 173 additions & 0 deletions crates/bevy_ui/src/keyboard_navigation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
use crate::{FocusPolicy, Interaction, UiStack};
use bevy_a11y::Focus;
use bevy_ecs::prelude::Component;
use bevy_ecs::system::{Local, Query, Res, ResMut};
use bevy_ecs::{change_detection::DetectChangesMut, entity::Entity, query::WorldQuery};
use bevy_input::{prelude::KeyCode, Input};
use bevy_reflect::{FromReflect, Reflect};
use bevy_render::view::ComputedVisibility;

/// A component that represents if a UI element is focused.
#[derive(Reflect, FromReflect, Component, Copy, Clone, Debug, Eq, PartialEq)]
pub enum FocusedState {
0HyperCube marked this conversation as resolved.
Show resolved Hide resolved
0HyperCube marked this conversation as resolved.
Show resolved Hide resolved
/// Nothing has happened
None,
0HyperCube marked this conversation as resolved.
Show resolved Hide resolved
/// Entity is focused
Focus {
/// Focus has been reached through keyboard navigation and so a focus style should be displayed.
/// This is similar to the `:focus-visible` pseudo-class in css.
focus_visible: bool,
},
}
0HyperCube marked this conversation as resolved.
Show resolved Hide resolved

/// Should the [`keyboard_navigation_system`] run?
pub(crate) fn tab_pressed(keyboard_input: Res<Input<KeyCode>>) -> bool {
keyboard_input.just_pressed(KeyCode::Tab)
}
0HyperCube marked this conversation as resolved.
Show resolved Hide resolved

/// Main query for [`keyboard_system`]
#[derive(WorldQuery)]
#[world_query(mutable)]
pub(crate) struct KeyboardQuery {
interaction: Option<&'static mut Interaction>,
focus_policy: Option<&'static FocusPolicy>,
computed_visibility: Option<&'static ComputedVisibility>,
0HyperCube marked this conversation as resolved.
Show resolved Hide resolved
}

/// The system updates the [`Focused`] resource when the user uses keyboard navigation with <kbd>Tab</kbd> or <kbd>Shift</kbd> + <kbd>Tab</kbd>.
///
/// Entities can be focused [`ComputedVisibility`] is visible and [`FocusPolicy`] is block.
pub(crate) fn keyboard_navigation_system(
mut focus: ResMut<Focus>,
mut node_query: Query<KeyboardQuery>,
0HyperCube marked this conversation as resolved.
Show resolved Hide resolved
keyboard_input: Res<Input<KeyCode>>,
ui_stack: Res<UiStack>,
) {
let reverse_order =
keyboard_input.pressed(KeyCode::ShiftLeft) || keyboard_input.pressed(KeyCode::ShiftRight);

let can_focus = |entity: &&Entity| {
let Ok(node) = node_query.get_mut(**entity) else {
0HyperCube marked this conversation as resolved.
Show resolved Hide resolved
return false;
};

// Nodes that are not rendered should not be interactable
if let Some(computed_visibility) = node.computed_visibility {
if !computed_visibility.is_visible() {
return false;
}
}

// Only allow keyboard navigation to nodes that block focus
matches!(node.focus_policy, Some(&FocusPolicy::Block))
};

let ui_nodes = &ui_stack.uinodes;

// Current index of the focused entity within the ui nodes list.
let current_index = ui_nodes
.iter()
.position(|&ui_node| Some(ui_node) == focus.entity);

let new_focus = if reverse_order {
// Start with the entity before the current focused or at the end of the list
let first_index = current_index.map(|index| index - 1).unwrap_or_default();

let wrapping_nodes_iterator = ui_nodes
.iter()
.take(first_index)
.rev()
.chain(ui_nodes.iter().skip(first_index).rev());
0HyperCube marked this conversation as resolved.
Show resolved Hide resolved

wrapping_nodes_iterator.filter(can_focus).next().copied()
} else {
// Start with the entity after the current focused or at the start of the list
let first_index = current_index.map(|index| index + 1).unwrap_or_default();

let wrapping_nodes_iterator = ui_nodes
.iter()
.skip(first_index)
.chain(ui_nodes.iter().take(first_index));

wrapping_nodes_iterator.filter(can_focus).next().copied()
0HyperCube marked this conversation as resolved.
Show resolved Hide resolved
};

// Reset the clicked state
if new_focus != focus.entity {
if let Some(node) = focus
.entity
.and_then(|entity| node_query.get_mut(entity).ok())
{
if let Some(mut interaction) = node.interaction {
if *interaction == Interaction::Clicked {
*interaction = Interaction::None;
}
}
}
}

focus.set_if_neq(Focus {
entity: new_focus,
focus_visible: true,
});
}

/// Change the [`FocusedState`] for the specified entity
fn set_focus_state<'a>(
entity: Option<Entity>,
focus_state: &'a mut Query<&mut FocusedState>,
new_state: FocusedState,
) {
if let Some(mut focus_state) = entity.and_then(|entity| focus_state.get_mut(entity).ok()) {
focus_state.set_if_neq(new_state);
}
}

pub(crate) fn update_focused_state(
0HyperCube marked this conversation as resolved.
Show resolved Hide resolved
mut focus_state: Query<&mut FocusedState>,
focus: Res<Focus>,
mut old_focused_entity: Local<Option<Entity>>,
) {
let new_focused_entity = focus.entity;

// Remove the interaction from the last focused entity
if *old_focused_entity != new_focused_entity {
set_focus_state(*old_focused_entity, &mut focus_state, FocusedState::None);
}

let focus_visible = focus.focus_visible;
let new_state = FocusedState::Focus { focus_visible };
// Set the focused interaction on the newly focused entity
set_focus_state(new_focused_entity, &mut focus_state, new_state);

*old_focused_entity = new_focused_entity;
}

/// Should the [`keyboard_click`] system run?
pub(crate) fn trigger_click(keyboard_input: Res<Input<KeyCode>>) -> bool {
keyboard_input.just_pressed(KeyCode::Space) || keyboard_input.just_pressed(KeyCode::Return)
}

/// Trigger the [`Focused`] entity to be clicked.
pub(crate) fn keyboard_click(mut interactions: Query<&mut Interaction>, focus: Res<Focus>) {
if let Some(mut interaction) = focus
.entity
.and_then(|entity| interactions.get_mut(entity).ok())
{
interaction.set_if_neq(Interaction::Clicked);
}
}

/// Should the [`end_keyboard_click`] system run?
pub(crate) fn trigger_click_end(keyboard_input: Res<Input<KeyCode>>) -> bool {
keyboard_input.just_released(KeyCode::Space) || keyboard_input.just_released(KeyCode::Return)
}

/// Reset the clicked state.
pub(crate) fn end_keyboard_click(mut interactions: Query<&mut Interaction>) {
interactions.for_each_mut(|mut interaction| {
if *interaction == Interaction::Clicked {
*interaction = Interaction::None
0HyperCube marked this conversation as resolved.
Show resolved Hide resolved
}
});
}
36 changes: 35 additions & 1 deletion crates/bevy_ui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//! This UI is laid out with the Flexbox and CSS Grid layout models (see <https://cssreference.io/flexbox/>)
mod focus;
mod geometry;
mod keyboard_navigation;
mod layout;
mod render;
mod stack;
Expand All @@ -19,11 +20,13 @@ pub mod node_bundles;
pub mod update;
pub mod widget;

use bevy_a11y::Focus;
#[cfg(feature = "bevy_text")]
use bevy_render::camera::CameraUpdateSystem;
use bevy_render::{extract_component::ExtractComponentPlugin, RenderApp};
pub use focus::*;
pub use geometry::*;
pub use keyboard_navigation::*;
pub use layout::*;
pub use measurement::*;
pub use render::*;
Expand Down Expand Up @@ -57,8 +60,10 @@ pub struct UiPlugin;
pub enum UiSystem {
/// After this label, the ui layout state has been updated
Layout,
/// After this label, input interactions with UI entities have been updated for this frame
/// After this label, the focused entity has been updated
Focus,
/// After this label, the input interactions with UI entities have been updated for this frame
Interactions,
/// After this label, the [`UiStack`] resource has been updated
Stack,
}
Expand Down Expand Up @@ -100,6 +105,7 @@ impl Plugin for UiPlugin {
.register_type::<GridPlacement>()
.register_type::<GridTrack>()
.register_type::<Interaction>()
.register_type::<FocusedState>()
.register_type::<JustifyContent>()
.register_type::<JustifyItems>()
.register_type::<JustifySelf>()
Expand All @@ -123,6 +129,34 @@ impl Plugin for UiPlugin {
.add_systems(
PreUpdate,
ui_focus_system.in_set(UiSystem::Focus).after(InputSystem),
)
.add_systems(
PreUpdate,
keyboard_navigation_system
.in_set(UiSystem::Focus)
.run_if(tab_pressed)
.after(InputSystem),
)
.add_systems(
PreUpdate,
update_focused_state
.in_set(UiSystem::Interactions)
.after(UiSystem::Focus)
.run_if(resource_changed::<Focus>()),
)
.add_systems(
PreUpdate,
keyboard_click
.after(UiSystem::Focus)
.in_set(UiSystem::Interactions)
.run_if(trigger_click),
)
.add_systems(
PreUpdate,
end_keyboard_click
.after(UiSystem::Focus)
.in_set(UiSystem::Interactions)
.run_if(trigger_click_end),
);
// add these systems to front because these must run before transform update systems
#[cfg(feature = "bevy_text")]
Expand Down
7 changes: 5 additions & 2 deletions crates/bevy_ui/src/node_bundles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

use crate::{
widget::{Button, TextFlags, UiImageSize},
BackgroundColor, BorderColor, ContentSize, FocusPolicy, Interaction, Node, Style, UiImage,
ZIndex,
BackgroundColor, BorderColor, ContentSize, FocusPolicy, FocusedState, Interaction, Node, Style,
UiImage, ZIndex,
};
use bevy_ecs::bundle::Bundle;
use bevy_render::{
Expand Down Expand Up @@ -225,6 +225,8 @@ pub struct ButtonBundle {
pub interaction: Interaction,
/// Whether this node should block interaction with lower nodes
pub focus_policy: FocusPolicy,
/// Describes whether the button is currently focused
pub focused_state: FocusedState,
/// The background color, which serves as a "fill" for this node
///
/// When combined with `UiImage`, tints the provided image.
Expand Down Expand Up @@ -255,6 +257,7 @@ impl Default for ButtonBundle {
fn default() -> Self {
Self {
focus_policy: FocusPolicy::Block,
focused_state: FocusedState::None,
node: Default::default(),
button: Default::default(),
style: Default::default(),
Expand Down
Loading