Improve bevy_input_focus (#16749)

# Objective

I was curious to use the newly created `bevy_input_focus`, but I found
some issues with it
  - It was only implementing traits for `World`.
  - Lack of tests
  - `is_focus_within` logic was incorrect.


## Solution
 This PR includes some improvements to the `bevy_input_focus` crate: 
- Add new `IsFocusedHelper` that doesn't require access to `&World`. It
implements `IsFocused`
- Remove `IsFocused` impl for `DeferredWorld`. Since it already
implements `Deref<Target=World>` it was just duplication of code.
- impl `SetInputFocus` for `Commands`. There was no way to use
`SetFocusCommand` directly. This allows it.
- The `is_focus_within` logic has been fixed to check descendants.
Previously it was checking if any of the ancestors had focus which is
not correct according to the documentation.
  - Added a bunch of unit tests to verify the logic of the crate.

## Testing

- Did you test these changes? If so, how? Yes, running newly added unit
tests.

---
This commit is contained in:
Erick Z 2024-12-12 20:15:08 +01:00 committed by GitHub
parent b2d3371814
commit ced6159d93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 244 additions and 56 deletions

View File

@ -6,7 +6,7 @@ description = "Keyboard focus management"
homepage = "https://bevyengine.org" homepage = "https://bevyengine.org"
repository = "https://github.com/bevyengine/bevy" repository = "https://github.com/bevyengine/bevy"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
keywords = ["bevy", "color"] keywords = ["bevy"]
rust-version = "1.76.0" rust-version = "1.76.0"
[dependencies] [dependencies]
@ -14,9 +14,11 @@ bevy_app = { path = "../bevy_app", version = "0.15.0-dev", default-features = fa
bevy_ecs = { path = "../bevy_ecs", 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_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_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 } bevy_window = { path = "../bevy_window", version = "0.15.0-dev", default-features = false }
[dev-dependencies]
smol_str = "0.2"
[lints] [lints]
workspace = true workspace = true

View File

@ -16,16 +16,16 @@
//! This crate does *not* provide any integration with UI widgets, or provide functions for //! 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. //! tab navigation or gamepad-based focus navigation, as those are typically application-specific.
use bevy_app::{App, Plugin, Update}; use bevy_app::{App, Plugin, PreUpdate};
use bevy_ecs::{ use bevy_ecs::{
component::Component, component::Component,
entity::Entity, entity::Entity,
event::{Event, EventReader}, event::{Event, EventReader},
query::With, query::With,
system::{Commands, Query, Res, Resource}, system::{Commands, Query, Res, Resource, SystemParam},
world::{Command, DeferredWorld, World}, world::{Command, DeferredWorld, World},
}; };
use bevy_hierarchy::Parent; use bevy_hierarchy::{HierarchyQueryExt, Parent};
use bevy_input::keyboard::KeyboardInput; use bevy_input::keyboard::KeyboardInput;
use bevy_window::PrimaryWindow; use bevy_window::PrimaryWindow;
@ -41,7 +41,7 @@ pub struct InputFocus(pub Option<Entity>);
#[derive(Clone, Debug, Resource)] #[derive(Clone, Debug, Resource)]
pub struct InputFocusVisible(pub bool); pub struct InputFocusVisible(pub bool);
/// Helper functions for [`World`] and [`DeferredWorld`] to set and clear input focus. /// Helper functions for [`World`], [`DeferredWorld`] and [`Commands`] to set and clear input focus.
pub trait SetInputFocus { pub trait SetInputFocus {
/// Set input focus to the given entity. /// Set input focus to the given entity.
fn set_input_focus(&mut self, entity: Entity); fn set_input_focus(&mut self, entity: Entity);
@ -88,6 +88,16 @@ impl Command for SetFocusCommand {
} }
} }
impl SetInputFocus for Commands<'_, '_> {
fn set_input_focus(&mut self, entity: Entity) {
self.queue(SetFocusCommand(Some(entity)));
}
fn clear_input_focus(&mut self) {
self.queue(SetFocusCommand(None));
}
}
/// A bubble-able event for keyboard input. This event is normally dispatched to the current /// 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 /// input focus entity, if any. If no entity has input focus, then the event is dispatched to
/// the main window. /// the main window.
@ -108,7 +118,7 @@ impl Plugin for InputDispatchPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.insert_resource(InputFocus(None)) app.insert_resource(InputFocus(None))
.insert_resource(InputFocusVisible(false)) .insert_resource(InputFocusVisible(false))
.add_systems(Update, dispatch_keyboard_input); .add_systems(PreUpdate, dispatch_keyboard_input);
} }
} }
@ -137,7 +147,10 @@ fn dispatch_keyboard_input(
} }
/// Trait which defines methods to check if an entity currently has focus. This is implemented /// Trait which defines methods to check if an entity currently has focus. This is implemented
/// for both [`World`] and [`DeferredWorld`]. /// for [`World`] and [`IsFocusedHelper`].
/// [`DeferredWorld`] indirectly implements it through [`Deref`].
///
/// [`Deref`]: std::ops::Deref
pub trait IsFocused { pub trait IsFocused {
/// Returns true if the given entity has input focus. /// Returns true if the given entity has input focus.
fn is_focused(&self, entity: Entity) -> bool; fn is_focused(&self, entity: Entity) -> bool;
@ -148,97 +161,270 @@ pub trait IsFocused {
/// Returns true if the given entity has input focus and the focus indicator is visible. /// Returns true if the given entity has input focus and the focus indicator is visible.
fn is_focus_visible(&self, entity: Entity) -> bool; fn is_focus_visible(&self, entity: Entity) -> bool;
/// Returns true if the given entity, or any descenant, has input focus and the focus /// Returns true if the given entity, or any descendant, has input focus and the focus
/// indicator is visible. /// indicator is visible.
fn is_focus_within_visible(&self, entity: Entity) -> bool; fn is_focus_within_visible(&self, entity: Entity) -> bool;
} }
impl IsFocused for DeferredWorld<'_> { /// System param that helps get information about the current focused entity.
#[derive(SystemParam)]
pub struct IsFocusedHelper<'w, 's> {
parent_query: Query<'w, 's, &'static Parent>,
input_focus: Option<Res<'w, InputFocus>>,
input_focus_visible: Option<Res<'w, InputFocusVisible>>,
}
impl IsFocused for IsFocusedHelper<'_, '_> {
fn is_focused(&self, entity: Entity) -> bool { fn is_focused(&self, entity: Entity) -> bool {
self.get_resource::<InputFocus>() self.input_focus
.map(|f| f.0) .as_deref()
.unwrap_or_default() .and_then(|f| f.0)
.map(|f| f == entity) .is_some_and(|e| e == entity)
.unwrap_or_default()
} }
fn is_focus_within(&self, entity: Entity) -> bool { fn is_focus_within(&self, entity: Entity) -> bool {
let Some(focus_resource) = self.get_resource::<InputFocus>() else { let Some(focus) = self.input_focus.as_deref().and_then(|f| f.0) else {
return false; return false;
}; };
let Some(focus) = focus_resource.0 else { if focus == entity {
return false; return true;
};
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 self.parent_query.iter_ancestors(focus).any(|e| e == entity)
} }
fn is_focus_visible(&self, entity: Entity) -> bool { fn is_focus_visible(&self, entity: Entity) -> bool {
self.get_resource::<InputFocusVisible>() self.input_focus_visible.as_deref().is_some_and(|vis| vis.0) && self.is_focused(entity)
.map(|vis| vis.0)
.unwrap_or_default()
&& self.is_focused(entity)
} }
fn is_focus_within_visible(&self, entity: Entity) -> bool { fn is_focus_within_visible(&self, entity: Entity) -> bool {
self.get_resource::<InputFocusVisible>() self.input_focus_visible.as_deref().is_some_and(|vis| vis.0) && self.is_focus_within(entity)
.map(|vis| vis.0)
.unwrap_or_default()
&& self.is_focus_within(entity)
} }
} }
impl IsFocused for World { impl IsFocused for World {
fn is_focused(&self, entity: Entity) -> bool { fn is_focused(&self, entity: Entity) -> bool {
self.get_resource::<InputFocus>() self.get_resource::<InputFocus>()
.map(|f| f.0) .and_then(|f| f.0)
.unwrap_or_default() .is_some_and(|f| f == entity)
.map(|f| f == entity)
.unwrap_or_default()
} }
fn is_focus_within(&self, entity: Entity) -> bool { fn is_focus_within(&self, entity: Entity) -> bool {
let Some(focus_resource) = self.get_resource::<InputFocus>() else { let Some(focus) = self.get_resource::<InputFocus>().and_then(|f| f.0) else {
return false; return false;
}; };
let Some(focus) = focus_resource.0 else { let mut e = focus;
return false;
};
let mut e = entity;
loop { loop {
if e == focus { if e == entity {
return true; return true;
} }
if let Some(parent) = self.entity(e).get::<Parent>() { if let Some(parent) = self.entity(e).get::<Parent>().map(Parent::get) {
e = parent.get(); e = parent;
} else { } else {
break; return false;
} }
} }
false
} }
fn is_focus_visible(&self, entity: Entity) -> bool { fn is_focus_visible(&self, entity: Entity) -> bool {
self.get_resource::<InputFocusVisible>() self.get_resource::<InputFocusVisible>()
.map(|vis| vis.0) .is_some_and(|vis| vis.0)
.unwrap_or_default()
&& self.is_focused(entity) && self.is_focused(entity)
} }
fn is_focus_within_visible(&self, entity: Entity) -> bool { fn is_focus_within_visible(&self, entity: Entity) -> bool {
self.get_resource::<InputFocusVisible>() self.get_resource::<InputFocusVisible>()
.map(|vis| vis.0) .is_some_and(|vis| vis.0)
.unwrap_or_default()
&& self.is_focus_within(entity) && self.is_focus_within(entity)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use bevy_ecs::{component::ComponentId, observer::Trigger, system::RunSystemOnce};
use bevy_hierarchy::BuildChildren;
use bevy_input::{
keyboard::{Key, KeyCode},
ButtonState, InputPlugin,
};
use smol_str::SmolStr;
#[derive(Component)]
#[component(on_add = set_focus_on_add)]
struct SetFocusOnAdd;
fn set_focus_on_add(mut world: DeferredWorld, entity: Entity, _: ComponentId) {
world.set_input_focus(entity);
}
#[derive(Component, Default)]
struct GatherKeyboardEvents(String);
fn gather_keyboard_events(
trigger: Trigger<FocusKeyboardInput>,
mut query: Query<&mut GatherKeyboardEvents>,
) {
if let Ok(mut gather) = query.get_mut(trigger.target()) {
if let Key::Character(c) = &trigger.0.logical_key {
gather.0.push_str(c.as_str());
}
}
}
const KEY_A_EVENT: KeyboardInput = KeyboardInput {
key_code: KeyCode::KeyA,
logical_key: Key::Character(SmolStr::new_static("A")),
state: ButtonState::Pressed,
repeat: false,
window: Entity::PLACEHOLDER,
};
#[test]
fn test_without_plugin() {
let mut app = App::new();
let entity = app.world_mut().spawn_empty().id();
app.world_mut().set_input_focus(entity);
assert!(!app.world().is_focused(entity));
app.world_mut()
.run_system_once(move |helper: IsFocusedHelper| {
assert!(!helper.is_focused(entity));
assert!(!helper.is_focus_within(entity));
assert!(!helper.is_focus_visible(entity));
assert!(!helper.is_focus_within_visible(entity));
})
.unwrap();
app.world_mut()
.run_system_once(move |world: DeferredWorld| {
assert!(!world.is_focused(entity));
assert!(!world.is_focus_within(entity));
assert!(!world.is_focus_visible(entity));
assert!(!world.is_focus_within_visible(entity));
})
.unwrap();
}
#[test]
fn test_keyboard_events() {
fn get_gathered(app: &App, entity: Entity) -> &str {
app.world()
.entity(entity)
.get::<GatherKeyboardEvents>()
.unwrap()
.0
.as_str()
}
let mut app = App::new();
app.add_plugins((InputPlugin, InputDispatchPlugin))
.add_observer(gather_keyboard_events);
let entity_a = app
.world_mut()
.spawn((GatherKeyboardEvents::default(), SetFocusOnAdd))
.id();
let child_of_b = app
.world_mut()
.spawn((GatherKeyboardEvents::default(),))
.id();
let entity_b = app
.world_mut()
.spawn((GatherKeyboardEvents::default(),))
.add_child(child_of_b)
.id();
assert!(app.world().is_focused(entity_a));
assert!(!app.world().is_focused(entity_b));
assert!(!app.world().is_focused(child_of_b));
assert!(!app.world().is_focus_visible(entity_a));
assert!(!app.world().is_focus_visible(entity_b));
assert!(!app.world().is_focus_visible(child_of_b));
// entity_a should receive this event
app.world_mut().send_event(KEY_A_EVENT);
app.update();
assert_eq!(get_gathered(&app, entity_a), "A");
assert_eq!(get_gathered(&app, entity_b), "");
assert_eq!(get_gathered(&app, child_of_b), "");
app.world_mut().clear_input_focus();
assert!(!app.world().is_focused(entity_a));
assert!(!app.world().is_focus_visible(entity_a));
// This event should be lost
app.world_mut().send_event(KEY_A_EVENT);
app.update();
assert_eq!(get_gathered(&app, entity_a), "A");
assert_eq!(get_gathered(&app, entity_b), "");
assert_eq!(get_gathered(&app, child_of_b), "");
app.world_mut().set_input_focus(entity_b);
assert!(app.world().is_focused(entity_b));
assert!(!app.world().is_focused(child_of_b));
app.world_mut()
.run_system_once(move |mut commands: Commands| {
commands.set_input_focus(child_of_b);
})
.unwrap();
assert!(app.world().is_focus_within(entity_b));
// These events should be received by entity_b and child_of_b
app.world_mut().send_event_batch([KEY_A_EVENT; 4]);
app.update();
assert_eq!(get_gathered(&app, entity_a), "A");
assert_eq!(get_gathered(&app, entity_b), "AAAA");
assert_eq!(get_gathered(&app, child_of_b), "AAAA");
app.world_mut().resource_mut::<InputFocusVisible>().0 = true;
app.world_mut()
.run_system_once(move |helper: IsFocusedHelper| {
assert!(!helper.is_focused(entity_a));
assert!(!helper.is_focus_within(entity_a));
assert!(!helper.is_focus_visible(entity_a));
assert!(!helper.is_focus_within_visible(entity_a));
assert!(!helper.is_focused(entity_b));
assert!(helper.is_focus_within(entity_b));
assert!(!helper.is_focus_visible(entity_b));
assert!(helper.is_focus_within_visible(entity_b));
assert!(helper.is_focused(child_of_b));
assert!(helper.is_focus_within(child_of_b));
assert!(helper.is_focus_visible(child_of_b));
assert!(helper.is_focus_within_visible(child_of_b));
})
.unwrap();
app.world_mut()
.run_system_once(move |world: DeferredWorld| {
assert!(!world.is_focused(entity_a));
assert!(!world.is_focus_within(entity_a));
assert!(!world.is_focus_visible(entity_a));
assert!(!world.is_focus_within_visible(entity_a));
assert!(!world.is_focused(entity_b));
assert!(world.is_focus_within(entity_b));
assert!(!world.is_focus_visible(entity_b));
assert!(world.is_focus_within_visible(entity_b));
assert!(world.is_focused(child_of_b));
assert!(world.is_focus_within(child_of_b));
assert!(world.is_focus_visible(child_of_b));
assert!(world.is_focus_within_visible(child_of_b));
})
.unwrap();
}
}