New simplified "click to focus" logic for core widgets. (#19736)
Click to focus is now a global observer. # Objective Previously, the "click to focus" behavior was implemented in each individual headless widget, producing redundant logic. ## Solution The new scheme is to have a global observer which looks for pointer down events and triggers an `AcquireFocus` event on the target. This event bubbles until it finds an entity with `TabIndex`, and then focuses it. ## Testing Tested the changes using the various examples that have focusable widgets. (This will become easier to test when I add focus ring support to the examples, but that's for another day. For now you just have to know which keys to press.) ## Migration This change is backwards-compatible. People who want the new behavior will need to install the new plugin.
This commit is contained in:
parent
d770648c2f
commit
4ff595a4bc
@ -2,7 +2,6 @@ use accesskit::Role;
|
|||||||
use bevy_a11y::AccessibilityNode;
|
use bevy_a11y::AccessibilityNode;
|
||||||
use bevy_app::{App, Plugin};
|
use bevy_app::{App, Plugin};
|
||||||
use bevy_ecs::query::Has;
|
use bevy_ecs::query::Has;
|
||||||
use bevy_ecs::system::ResMut;
|
|
||||||
use bevy_ecs::{
|
use bevy_ecs::{
|
||||||
component::Component,
|
component::Component,
|
||||||
entity::Entity,
|
entity::Entity,
|
||||||
@ -11,7 +10,8 @@ use bevy_ecs::{
|
|||||||
system::{Commands, Query, SystemId},
|
system::{Commands, Query, SystemId},
|
||||||
};
|
};
|
||||||
use bevy_input::keyboard::{KeyCode, KeyboardInput};
|
use bevy_input::keyboard::{KeyCode, KeyboardInput};
|
||||||
use bevy_input_focus::{FocusedInput, InputFocus, InputFocusVisible};
|
use bevy_input::ButtonState;
|
||||||
|
use bevy_input_focus::FocusedInput;
|
||||||
use bevy_picking::events::{Cancel, Click, DragEnd, Pointer, Press, Release};
|
use bevy_picking::events::{Cancel, Click, DragEnd, Pointer, Press, Release};
|
||||||
use bevy_ui::{InteractionDisabled, Pressed};
|
use bevy_ui::{InteractionDisabled, Pressed};
|
||||||
|
|
||||||
@ -36,6 +36,7 @@ fn button_on_key_event(
|
|||||||
if !disabled {
|
if !disabled {
|
||||||
let event = &trigger.event().input;
|
let event = &trigger.event().input;
|
||||||
if !event.repeat
|
if !event.repeat
|
||||||
|
&& event.state == ButtonState::Pressed
|
||||||
&& (event.key_code == KeyCode::Enter || event.key_code == KeyCode::Space)
|
&& (event.key_code == KeyCode::Enter || event.key_code == KeyCode::Space)
|
||||||
{
|
{
|
||||||
if let Some(on_click) = bstate.on_click {
|
if let Some(on_click) = bstate.on_click {
|
||||||
@ -65,24 +66,12 @@ fn button_on_pointer_click(
|
|||||||
fn button_on_pointer_down(
|
fn button_on_pointer_down(
|
||||||
mut trigger: On<Pointer<Press>>,
|
mut trigger: On<Pointer<Press>>,
|
||||||
mut q_state: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<CoreButton>>,
|
mut q_state: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<CoreButton>>,
|
||||||
focus: Option<ResMut<InputFocus>>,
|
|
||||||
focus_visible: Option<ResMut<InputFocusVisible>>,
|
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
if let Ok((button, disabled, pressed)) = q_state.get_mut(trigger.target()) {
|
if let Ok((button, disabled, pressed)) = q_state.get_mut(trigger.target()) {
|
||||||
trigger.propagate(false);
|
trigger.propagate(false);
|
||||||
if !disabled {
|
if !disabled && !pressed {
|
||||||
if !pressed {
|
commands.entity(button).insert(Pressed);
|
||||||
commands.entity(button).insert(Pressed);
|
|
||||||
}
|
|
||||||
// Clicking on a button makes it the focused input,
|
|
||||||
// and hides the focus ring if it was visible.
|
|
||||||
if let Some(mut focus) = focus {
|
|
||||||
focus.0 = (trigger.target() != Entity::PLACEHOLDER).then_some(trigger.target());
|
|
||||||
}
|
|
||||||
if let Some(mut focus_visible) = focus_visible {
|
|
||||||
focus_visible.0 = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,12 +3,11 @@ use core::ops::RangeInclusive;
|
|||||||
use accesskit::{Orientation, Role};
|
use accesskit::{Orientation, Role};
|
||||||
use bevy_a11y::AccessibilityNode;
|
use bevy_a11y::AccessibilityNode;
|
||||||
use bevy_app::{App, Plugin};
|
use bevy_app::{App, Plugin};
|
||||||
use bevy_ecs::entity::Entity;
|
|
||||||
use bevy_ecs::event::{EntityEvent, Event};
|
use bevy_ecs::event::{EntityEvent, Event};
|
||||||
use bevy_ecs::hierarchy::{ChildOf, Children};
|
use bevy_ecs::hierarchy::Children;
|
||||||
use bevy_ecs::lifecycle::Insert;
|
use bevy_ecs::lifecycle::Insert;
|
||||||
use bevy_ecs::query::Has;
|
use bevy_ecs::query::Has;
|
||||||
use bevy_ecs::system::{In, Res, ResMut};
|
use bevy_ecs::system::{In, Res};
|
||||||
use bevy_ecs::world::DeferredWorld;
|
use bevy_ecs::world::DeferredWorld;
|
||||||
use bevy_ecs::{
|
use bevy_ecs::{
|
||||||
component::Component,
|
component::Component,
|
||||||
@ -18,7 +17,7 @@ use bevy_ecs::{
|
|||||||
};
|
};
|
||||||
use bevy_input::keyboard::{KeyCode, KeyboardInput};
|
use bevy_input::keyboard::{KeyCode, KeyboardInput};
|
||||||
use bevy_input::ButtonState;
|
use bevy_input::ButtonState;
|
||||||
use bevy_input_focus::{FocusedInput, InputFocus, InputFocusVisible};
|
use bevy_input_focus::FocusedInput;
|
||||||
use bevy_log::warn_once;
|
use bevy_log::warn_once;
|
||||||
use bevy_picking::events::{Drag, DragEnd, DragStart, Pointer, Press};
|
use bevy_picking::events::{Drag, DragEnd, DragStart, Pointer, Press};
|
||||||
use bevy_ui::{ComputedNode, ComputedNodeTarget, InteractionDisabled, UiGlobalTransform, UiScale};
|
use bevy_ui::{ComputedNode, ComputedNodeTarget, InteractionDisabled, UiGlobalTransform, UiScale};
|
||||||
@ -207,43 +206,18 @@ pub(crate) fn slider_on_pointer_down(
|
|||||||
)>,
|
)>,
|
||||||
q_thumb: Query<&ComputedNode, With<CoreSliderThumb>>,
|
q_thumb: Query<&ComputedNode, With<CoreSliderThumb>>,
|
||||||
q_children: Query<&Children>,
|
q_children: Query<&Children>,
|
||||||
q_parents: Query<&ChildOf>,
|
|
||||||
focus: Option<ResMut<InputFocus>>,
|
|
||||||
focus_visible: Option<ResMut<InputFocusVisible>>,
|
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
ui_scale: Res<UiScale>,
|
ui_scale: Res<UiScale>,
|
||||||
) {
|
) {
|
||||||
if q_thumb.contains(trigger.target()) {
|
if q_thumb.contains(trigger.target()) {
|
||||||
// Thumb click, stop propagation to prevent track click.
|
// Thumb click, stop propagation to prevent track click.
|
||||||
trigger.propagate(false);
|
trigger.propagate(false);
|
||||||
|
|
||||||
// Find the slider entity that's an ancestor of the thumb
|
|
||||||
if let Some(slider_entity) = q_parents
|
|
||||||
.iter_ancestors(trigger.target())
|
|
||||||
.find(|entity| q_slider.contains(*entity))
|
|
||||||
{
|
|
||||||
// Set focus to slider and hide focus ring
|
|
||||||
if let Some(mut focus) = focus {
|
|
||||||
focus.0 = Some(slider_entity);
|
|
||||||
}
|
|
||||||
if let Some(mut focus_visible) = focus_visible {
|
|
||||||
focus_visible.0 = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let Ok((slider, value, range, step, node, node_target, transform, disabled)) =
|
} else if let Ok((slider, value, range, step, node, node_target, transform, disabled)) =
|
||||||
q_slider.get(trigger.target())
|
q_slider.get(trigger.target())
|
||||||
{
|
{
|
||||||
// Track click
|
// Track click
|
||||||
trigger.propagate(false);
|
trigger.propagate(false);
|
||||||
|
|
||||||
// Set focus to slider and hide focus ring
|
|
||||||
if let Some(mut focus) = focus {
|
|
||||||
focus.0 = (trigger.target() != Entity::PLACEHOLDER).then_some(trigger.target());
|
|
||||||
}
|
|
||||||
if let Some(mut focus_visible) = focus_visible {
|
|
||||||
focus_visible.0 = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if disabled {
|
if disabled {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -64,6 +64,7 @@ bevy_app = { path = "../bevy_app", version = "0.16.0-dev", default-features = fa
|
|||||||
bevy_ecs = { path = "../bevy_ecs", version = "0.16.0-dev", default-features = false }
|
bevy_ecs = { path = "../bevy_ecs", version = "0.16.0-dev", default-features = false }
|
||||||
bevy_input = { path = "../bevy_input", version = "0.16.0-dev", default-features = false }
|
bevy_input = { path = "../bevy_input", version = "0.16.0-dev", default-features = false }
|
||||||
bevy_math = { path = "../bevy_math", version = "0.16.0-dev", default-features = false }
|
bevy_math = { path = "../bevy_math", version = "0.16.0-dev", default-features = false }
|
||||||
|
bevy_picking = { path = "../bevy_picking", version = "0.16.0-dev", default-features = false }
|
||||||
bevy_window = { path = "../bevy_window", version = "0.16.0-dev", default-features = false }
|
bevy_window = { path = "../bevy_window", version = "0.16.0-dev", default-features = false }
|
||||||
bevy_reflect = { path = "../bevy_reflect", version = "0.16.0-dev", features = [
|
bevy_reflect = { path = "../bevy_reflect", version = "0.16.0-dev", features = [
|
||||||
"glam",
|
"glam",
|
||||||
|
@ -147,6 +147,15 @@ pub struct FocusedInput<E: BufferedEvent + Clone> {
|
|||||||
window: Entity,
|
window: Entity,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An event which is used to set input focus. Trigger this on an entity, and it will bubble
|
||||||
|
/// until it finds a focusable entity, and then set focus to it.
|
||||||
|
#[derive(Clone, Event, EntityEvent)]
|
||||||
|
#[entity_event(traversal = WindowTraversal, auto_propagate)]
|
||||||
|
pub struct AcquireFocus {
|
||||||
|
/// The primary window entity.
|
||||||
|
window: Entity,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(QueryData)]
|
#[derive(QueryData)]
|
||||||
/// These are for accessing components defined on the targeted entity
|
/// These are for accessing components defined on the targeted entity
|
||||||
pub struct WindowTraversal {
|
pub struct WindowTraversal {
|
||||||
@ -172,6 +181,24 @@ impl<E: BufferedEvent + Clone> Traversal<FocusedInput<E>> for WindowTraversal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Traversal<AcquireFocus> for WindowTraversal {
|
||||||
|
fn traverse(item: Self::Item<'_, '_>, event: &AcquireFocus) -> Option<Entity> {
|
||||||
|
let WindowTraversalItem { child_of, window } = item;
|
||||||
|
|
||||||
|
// Send event to parent, if it has one.
|
||||||
|
if let Some(child_of) = child_of {
|
||||||
|
return Some(child_of.parent());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Otherwise, send it to the window entity (unless this is a window entity).
|
||||||
|
if window.is_none() {
|
||||||
|
return Some(event.window);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Plugin which sets up systems for dispatching bubbling keyboard and gamepad button events to the focused entity.
|
/// Plugin which sets up systems for dispatching bubbling keyboard and gamepad button events to the focused entity.
|
||||||
///
|
///
|
||||||
/// To add bubbling to your own input events, add the [`dispatch_focused_input::<MyEvent>`](dispatch_focused_input) system to your app,
|
/// To add bubbling to your own input events, add the [`dispatch_focused_input::<MyEvent>`](dispatch_focused_input) system to your app,
|
||||||
|
@ -38,11 +38,12 @@ use bevy_input::{
|
|||||||
keyboard::{KeyCode, KeyboardInput},
|
keyboard::{KeyCode, KeyboardInput},
|
||||||
ButtonInput, ButtonState,
|
ButtonInput, ButtonState,
|
||||||
};
|
};
|
||||||
use bevy_window::PrimaryWindow;
|
use bevy_picking::events::{Pointer, Press};
|
||||||
|
use bevy_window::{PrimaryWindow, Window};
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::{FocusedInput, InputFocus, InputFocusVisible};
|
use crate::{AcquireFocus, FocusedInput, InputFocus, InputFocusVisible};
|
||||||
|
|
||||||
#[cfg(feature = "bevy_reflect")]
|
#[cfg(feature = "bevy_reflect")]
|
||||||
use {
|
use {
|
||||||
@ -312,6 +313,31 @@ impl TabNavigation<'_, '_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Observer which sets focus to the nearest ancestor that has tab index, using bubbling.
|
||||||
|
pub(crate) fn acquire_focus(
|
||||||
|
mut ev: On<AcquireFocus>,
|
||||||
|
focusable: Query<(), With<TabIndex>>,
|
||||||
|
windows: Query<(), With<Window>>,
|
||||||
|
mut focus: ResMut<InputFocus>,
|
||||||
|
) {
|
||||||
|
// If the entity has a TabIndex
|
||||||
|
if focusable.contains(ev.target()) {
|
||||||
|
// Stop and focus it
|
||||||
|
ev.propagate(false);
|
||||||
|
// Don't mutate unless we need to, for change detection
|
||||||
|
if focus.0 != Some(ev.target()) {
|
||||||
|
focus.0 = Some(ev.target());
|
||||||
|
}
|
||||||
|
} else if windows.contains(ev.target()) {
|
||||||
|
// Stop and clear focus
|
||||||
|
ev.propagate(false);
|
||||||
|
// Don't mutate unless we need to, for change detection
|
||||||
|
if focus.0.is_some() {
|
||||||
|
focus.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Plugin for navigating between focusable entities using keyboard input.
|
/// Plugin for navigating between focusable entities using keyboard input.
|
||||||
pub struct TabNavigationPlugin;
|
pub struct TabNavigationPlugin;
|
||||||
|
|
||||||
@ -321,6 +347,8 @@ impl Plugin for TabNavigationPlugin {
|
|||||||
|
|
||||||
#[cfg(feature = "bevy_reflect")]
|
#[cfg(feature = "bevy_reflect")]
|
||||||
app.register_type::<TabIndex>().register_type::<TabGroup>();
|
app.register_type::<TabIndex>().register_type::<TabGroup>();
|
||||||
|
app.add_observer(acquire_focus);
|
||||||
|
app.add_observer(click_to_focus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -330,6 +358,30 @@ fn setup_tab_navigation(mut commands: Commands, window: Query<Entity, With<Prima
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn click_to_focus(
|
||||||
|
ev: On<Pointer<Press>>,
|
||||||
|
mut focus_visible: ResMut<InputFocusVisible>,
|
||||||
|
windows: Query<Entity, With<PrimaryWindow>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
// Because `Pointer` is a bubbling event, we don't want to trigger an `AcquireFocus` event
|
||||||
|
// for every ancestor, but only for the original entity. Also, users may want to stop
|
||||||
|
// propagation on the pointer event at some point along the bubbling chain, so we need our
|
||||||
|
// own dedicated event whose propagation we can control.
|
||||||
|
if ev.target() == ev.original_target() {
|
||||||
|
// Clicking hides focus
|
||||||
|
if focus_visible.0 {
|
||||||
|
focus_visible.0 = false;
|
||||||
|
}
|
||||||
|
// Search for a focusable parent entity, defaulting to window if none.
|
||||||
|
if let Ok(window) = windows.single() {
|
||||||
|
commands
|
||||||
|
.entity(ev.target())
|
||||||
|
.trigger(AcquireFocus { window });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Observer function which handles tab navigation.
|
/// Observer function which handles tab navigation.
|
||||||
///
|
///
|
||||||
/// This observer responds to [`KeyCode::Tab`] events and Shift+Tab events,
|
/// This observer responds to [`KeyCode::Tab`] events and Shift+Tab events,
|
||||||
|
@ -8,7 +8,7 @@ use bevy::{
|
|||||||
},
|
},
|
||||||
ecs::system::SystemId,
|
ecs::system::SystemId,
|
||||||
input_focus::{
|
input_focus::{
|
||||||
tab_navigation::{TabGroup, TabIndex},
|
tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin},
|
||||||
InputDispatchPlugin,
|
InputDispatchPlugin,
|
||||||
},
|
},
|
||||||
picking::hover::Hovered,
|
picking::hover::Hovered,
|
||||||
@ -19,7 +19,12 @@ use bevy::{
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
App::new()
|
App::new()
|
||||||
.add_plugins((DefaultPlugins, CoreWidgetsPlugin, InputDispatchPlugin))
|
.add_plugins((
|
||||||
|
DefaultPlugins,
|
||||||
|
CoreWidgetsPlugin,
|
||||||
|
InputDispatchPlugin,
|
||||||
|
TabNavigationPlugin,
|
||||||
|
))
|
||||||
// Only run the app when there is user input. This will significantly reduce CPU/GPU use.
|
// Only run the app when there is user input. This will significantly reduce CPU/GPU use.
|
||||||
.insert_resource(WinitSettings::desktop_app())
|
.insert_resource(WinitSettings::desktop_app())
|
||||||
.insert_resource(DemoWidgetStates { slider_value: 50.0 })
|
.insert_resource(DemoWidgetStates { slider_value: 50.0 })
|
||||||
|
@ -8,7 +8,7 @@ use bevy::{
|
|||||||
},
|
},
|
||||||
ecs::system::SystemId,
|
ecs::system::SystemId,
|
||||||
input_focus::{
|
input_focus::{
|
||||||
tab_navigation::{TabGroup, TabIndex},
|
tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin},
|
||||||
InputDispatchPlugin,
|
InputDispatchPlugin,
|
||||||
},
|
},
|
||||||
picking::hover::Hovered,
|
picking::hover::Hovered,
|
||||||
@ -19,7 +19,12 @@ use bevy::{
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
App::new()
|
App::new()
|
||||||
.add_plugins((DefaultPlugins, CoreWidgetsPlugin, InputDispatchPlugin))
|
.add_plugins((
|
||||||
|
DefaultPlugins,
|
||||||
|
CoreWidgetsPlugin,
|
||||||
|
InputDispatchPlugin,
|
||||||
|
TabNavigationPlugin,
|
||||||
|
))
|
||||||
// Only run the app when there is user input. This will significantly reduce CPU/GPU use.
|
// Only run the app when there is user input. This will significantly reduce CPU/GPU use.
|
||||||
.insert_resource(WinitSettings::desktop_app())
|
.insert_resource(WinitSettings::desktop_app())
|
||||||
.insert_resource(DemoWidgetStates { slider_value: 50.0 })
|
.insert_resource(DemoWidgetStates { slider_value: 50.0 })
|
||||||
|
Loading…
Reference in New Issue
Block a user