Skip to content

Commit

Permalink
Add "bevy_input_focus" crate. (#15611)
Browse files Browse the repository at this point in the history
# Objective

Define a framework for handling keyboard focus and bubbled keyboard
events, as discussed in #15374.

## Solution

Introduces a new crate, `bevy_input_focus`. This crate provides:

* A resource for tracking which entity has keyboard focus.
* Methods for getting and setting keyboard focus.
* Event definitions for triggering bubble-able keyboard input events to
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
tab navigation or gamepad-based focus navigation, as those are typically
application-specific.

## Testing

Most of the code has been copied from a different project, one that has
been well tested. However, most of what's in this module consists of
type definitions, with relatively small amounts of executable code. That
being said, I expect that there will be substantial bikeshedding on the
design, and I would prefer to hold off writing tests until after things
have settled.

I think that an example would be appropriate, however I'm waiting on a
few other pending changes to Bevy before doing so. In particular, I can
see a simple example with four buttons, with focus navigation between
them, and which can be triggered by the keyboard.

@alice-i-cecile
  • Loading branch information
viridia authored Dec 5, 2024
1 parent a35811d commit ea33fc0
Show file tree
Hide file tree
Showing 4 changed files with 276 additions and 0 deletions.
25 changes: 25 additions & 0 deletions crates/bevy_input_focus/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[package]
name = "bevy_input_focus"
version = "0.15.0-dev"
edition = "2021"
description = "Keyboard focus management"
homepage = "https://bevyengine.org"
repository = "https://github.com/bevyengine/bevy"
license = "MIT OR Apache-2.0"
keywords = ["bevy", "color"]
rust-version = "1.76.0"

[dependencies]
bevy_app = { path = "../bevy_app", version = "0.15.0-dev", default-features = false }
bevy_ecs = { path = "../bevy_ecs", version = "0.15.0-dev", default-features = false }
bevy_input = { path = "../bevy_input", version = "0.15.0-dev", default-features = false }
bevy_hierarchy = { path = "../bevy_hierarchy", 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 }

[lints]
workspace = true

[package.metadata.docs.rs]
rustdoc-args = ["-Zunstable-options", "--generate-link-to-definition"]
all-features = true
7 changes: 7 additions & 0 deletions crates/bevy_input_focus/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Bevy Input Focus

[![License](https://img.shields.io/badge/license-MIT%2FApache-blue.svg)](https://github.com/bevyengine/bevy#license)
[![Crates.io](https://img.shields.io/crates/v/bevy_input_focus.svg)](https://crates.io/crates/bevy_input_focus)
[![Downloads](https://img.shields.io/crates/d/bevy_input_focus.svg)](https://crates.io/crates/bevy_input_focus)
[![Docs](https://docs.rs/bevy_input_focus/badge.svg)](https://docs.rs/bevy_input_focus/latest/bevy_input_focus/)
[![Discord](https://img.shields.io/discord/691052431525675048.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/bevy)
243 changes: 243 additions & 0 deletions crates/bevy_input_focus/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![forbid(unsafe_code)]
#![doc(
html_logo_url = "https://bevyengine.org/assets/icon.png",
html_favicon_url = "https://bevyengine.org/assets/icon.png"
)]

//! Keyboard focus system for Bevy.
//!
//! This crate provides a system for managing input focus in Bevy applications, including:
//! * A resource for tracking which entity has input focus.
//! * Methods for getting and setting input focus.
//! * Event definitions for triggering bubble-able keyboard input events to 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
//! tab navigation or gamepad-based focus navigation, as those are typically application-specific.
use bevy_app::{App, Plugin, Update};
use bevy_ecs::{
component::Component,
entity::Entity,
event::{Event, EventReader},
query::With,
system::{Commands, Query, Res, Resource},
world::{Command, DeferredWorld, World},
};
use bevy_hierarchy::Parent;
use bevy_input::keyboard::KeyboardInput;
use bevy_window::PrimaryWindow;

/// 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.
#[derive(Clone, Debug, Resource)]
pub struct InputFocus(pub Option<Entity>);

/// Resource representing whether the input focus indicator should be visible. 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
/// clicks on a different element.
#[derive(Clone, Debug, Resource)]
pub struct InputFocusVisible(pub bool);

/// Helper functions for [`World`] and [`DeferredWorld`] to set and clear input focus.
pub trait SetInputFocus {
/// Set input focus to the given entity.
fn set_input_focus(&mut self, entity: Entity);
/// Clear input focus.
fn clear_input_focus(&mut self);
}

impl SetInputFocus for World {
fn set_input_focus(&mut self, entity: Entity) {
if let Some(mut focus) = self.get_resource_mut::<InputFocus>() {
focus.0 = Some(entity);
}
}

fn clear_input_focus(&mut self) {
if let Some(mut focus) = self.get_resource_mut::<InputFocus>() {
focus.0 = None;
}
}
}

impl<'w> SetInputFocus for DeferredWorld<'w> {
fn set_input_focus(&mut self, entity: Entity) {
if let Some(mut focus) = self.get_resource_mut::<InputFocus>() {
focus.0 = Some(entity);
}
}

fn clear_input_focus(&mut self) {
if let Some(mut focus) = self.get_resource_mut::<InputFocus>() {
focus.0 = None;
}
}
}

/// Command to set input focus to the given entity.
pub struct SetFocusCommand(Option<Entity>);

impl Command for SetFocusCommand {
fn apply(self, world: &mut World) {
if let Some(mut focus) = world.get_resource_mut::<InputFocus>() {
focus.0 = self.0;
}
}
}

/// A bubble-able event for keyboard input. This event is normally dispatched to the current
/// input focus entity, if any. If no entity has input focus, then the event is dispatched to
/// the main window.
#[derive(Clone, Debug, Component)]
pub struct FocusKeyboardInput(pub KeyboardInput);

impl Event for FocusKeyboardInput {
type Traversal = &'static Parent;

const AUTO_PROPAGATE: bool = true;
}

/// Plugin which registers the system for dispatching keyboard events based on focus and
/// hover state.
pub struct InputDispatchPlugin;

impl Plugin for InputDispatchPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(InputFocus(None))
.add_systems(Update, dispatch_keyboard_input);
}
}

/// System which dispatches keyboard input events to the focused entity, or to the primary window
/// if no entity has focus.
fn dispatch_keyboard_input(
mut key_events: EventReader<KeyboardInput>,
focus: Res<InputFocus>,
windows: Query<Entity, With<PrimaryWindow>>,
mut commands: Commands,
) {
// If an element has keyboard focus, then dispatch the key event to that element.
if let Some(focus_elt) = focus.0 {
for ev in key_events.read() {
commands.trigger_targets(FocusKeyboardInput(ev.clone()), focus_elt);
}
} else {
// If no element has input focus, then dispatch the key event to the primary window.
// There should be only one primary window.
if let Ok(window) = windows.get_single() {
for ev in key_events.read() {
commands.trigger_targets(FocusKeyboardInput(ev.clone()), window);
}
}
}
}

/// Trait which defines methods to check if an entity currently has focus. This is implemented
/// for both [`World`] and [`DeferredWorld`].
pub trait IsFocused {
/// Returns true if the given entity has input focus.
fn is_focused(&self, entity: Entity) -> bool;

/// Returns true if the given entity or any of its descendants has input focus.
fn is_focus_within(&self, entity: Entity) -> bool;

/// Returns true if the given entity has input focus and the focus indicator is visible.
fn is_focus_visible(&self, entity: Entity) -> bool;

/// Returns true if the given entity, or any descenant, has input focus and the focus
/// indicator is visible.
fn is_focus_within_visible(&self, entity: Entity) -> bool;
}

impl IsFocused for DeferredWorld<'_> {
fn is_focused(&self, entity: Entity) -> bool {
self.get_resource::<InputFocus>()
.map(|f| f.0)
.unwrap_or_default()
.map(|f| f == entity)
.unwrap_or_default()
}

fn is_focus_within(&self, entity: Entity) -> bool {
let Some(focus_resource) = self.get_resource::<InputFocus>() else {
return false;
};
let Some(focus) = focus_resource.0 else {
return false;
};
let mut e = entity;
loop {
if e == focus {
return true;
}
if let Some(parent) = self.entity(e).get::<Parent>() {
e = parent.get();
} else {
break;
}
}
false
}

fn is_focus_visible(&self, entity: Entity) -> bool {
self.get_resource::<InputFocusVisible>()
.map(|vis| vis.0)
.unwrap_or_default()
&& self.is_focused(entity)
}

fn is_focus_within_visible(&self, entity: Entity) -> bool {
self.get_resource::<InputFocusVisible>()
.map(|vis| vis.0)
.unwrap_or_default()
&& self.is_focus_within(entity)
}
}

impl IsFocused for World {
fn is_focused(&self, entity: Entity) -> bool {
self.get_resource::<InputFocus>()
.map(|f| f.0)
.unwrap_or_default()
.map(|f| f == entity)
.unwrap_or_default()
}

fn is_focus_within(&self, entity: Entity) -> bool {
let Some(focus_resource) = self.get_resource::<InputFocus>() else {
return false;
};
let Some(focus) = focus_resource.0 else {
return false;
};
let mut e = entity;
loop {
if e == focus {
return true;
}
if let Some(parent) = self.entity(e).get::<Parent>() {
e = parent.get();
} else {
break;
}
}
false
}

fn is_focus_visible(&self, entity: Entity) -> bool {
self.get_resource::<InputFocusVisible>()
.map(|vis| vis.0)
.unwrap_or_default()
&& self.is_focused(entity)
}

fn is_focus_within_visible(&self, entity: Entity) -> bool {
self.get_resource::<InputFocusVisible>()
.map(|vis| vis.0)
.unwrap_or_default()
&& self.is_focus_within(entity)
}
}
1 change: 1 addition & 0 deletions tools/publish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ crates=(
bevy_gizmos
bevy_text
bevy_a11y
bevy_input_focus
bevy_ui
bevy_winit
bevy_dev_tools
Expand Down

0 comments on commit ea33fc0

Please sign in to comment.