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_app::{App, Plugin};
|
||||
use bevy_ecs::query::Has;
|
||||
use bevy_ecs::system::ResMut;
|
||||
use bevy_ecs::{
|
||||
component::Component,
|
||||
entity::Entity,
|
||||
@ -11,7 +10,8 @@ use bevy_ecs::{
|
||||
system::{Commands, Query, SystemId},
|
||||
};
|
||||
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_ui::{InteractionDisabled, Pressed};
|
||||
|
||||
@ -36,6 +36,7 @@ fn button_on_key_event(
|
||||
if !disabled {
|
||||
let event = &trigger.event().input;
|
||||
if !event.repeat
|
||||
&& event.state == ButtonState::Pressed
|
||||
&& (event.key_code == KeyCode::Enter || event.key_code == KeyCode::Space)
|
||||
{
|
||||
if let Some(on_click) = bstate.on_click {
|
||||
@ -65,24 +66,12 @@ fn button_on_pointer_click(
|
||||
fn button_on_pointer_down(
|
||||
mut trigger: On<Pointer<Press>>,
|
||||
mut q_state: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<CoreButton>>,
|
||||
focus: Option<ResMut<InputFocus>>,
|
||||
focus_visible: Option<ResMut<InputFocusVisible>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if let Ok((button, disabled, pressed)) = q_state.get_mut(trigger.target()) {
|
||||
trigger.propagate(false);
|
||||
if !disabled {
|
||||
if !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;
|
||||
}
|
||||
if !disabled && !pressed {
|
||||
commands.entity(button).insert(Pressed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,12 +3,11 @@ use core::ops::RangeInclusive;
|
||||
use accesskit::{Orientation, Role};
|
||||
use bevy_a11y::AccessibilityNode;
|
||||
use bevy_app::{App, Plugin};
|
||||
use bevy_ecs::entity::Entity;
|
||||
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::query::Has;
|
||||
use bevy_ecs::system::{In, Res, ResMut};
|
||||
use bevy_ecs::system::{In, Res};
|
||||
use bevy_ecs::world::DeferredWorld;
|
||||
use bevy_ecs::{
|
||||
component::Component,
|
||||
@ -18,7 +17,7 @@ use bevy_ecs::{
|
||||
};
|
||||
use bevy_input::keyboard::{KeyCode, KeyboardInput};
|
||||
use bevy_input::ButtonState;
|
||||
use bevy_input_focus::{FocusedInput, InputFocus, InputFocusVisible};
|
||||
use bevy_input_focus::FocusedInput;
|
||||
use bevy_log::warn_once;
|
||||
use bevy_picking::events::{Drag, DragEnd, DragStart, Pointer, Press};
|
||||
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_children: Query<&Children>,
|
||||
q_parents: Query<&ChildOf>,
|
||||
focus: Option<ResMut<InputFocus>>,
|
||||
focus_visible: Option<ResMut<InputFocusVisible>>,
|
||||
mut commands: Commands,
|
||||
ui_scale: Res<UiScale>,
|
||||
) {
|
||||
if q_thumb.contains(trigger.target()) {
|
||||
// Thumb click, stop propagation to prevent track click.
|
||||
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)) =
|
||||
q_slider.get(trigger.target())
|
||||
{
|
||||
// Track click
|
||||
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 {
|
||||
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_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_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_reflect = { path = "../bevy_reflect", version = "0.16.0-dev", features = [
|
||||
"glam",
|
||||
|
@ -147,6 +147,15 @@ pub struct FocusedInput<E: BufferedEvent + Clone> {
|
||||
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)]
|
||||
/// These are for accessing components defined on the targeted entity
|
||||
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.
|
||||
///
|
||||
/// 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},
|
||||
ButtonInput, ButtonState,
|
||||
};
|
||||
use bevy_window::PrimaryWindow;
|
||||
use bevy_picking::events::{Pointer, Press};
|
||||
use bevy_window::{PrimaryWindow, Window};
|
||||
use log::warn;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{FocusedInput, InputFocus, InputFocusVisible};
|
||||
use crate::{AcquireFocus, FocusedInput, InputFocus, InputFocusVisible};
|
||||
|
||||
#[cfg(feature = "bevy_reflect")]
|
||||
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.
|
||||
pub struct TabNavigationPlugin;
|
||||
|
||||
@ -321,6 +347,8 @@ impl Plugin for TabNavigationPlugin {
|
||||
|
||||
#[cfg(feature = "bevy_reflect")]
|
||||
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.
|
||||
///
|
||||
/// This observer responds to [`KeyCode::Tab`] events and Shift+Tab events,
|
||||
|
@ -8,7 +8,7 @@ use bevy::{
|
||||
},
|
||||
ecs::system::SystemId,
|
||||
input_focus::{
|
||||
tab_navigation::{TabGroup, TabIndex},
|
||||
tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin},
|
||||
InputDispatchPlugin,
|
||||
},
|
||||
picking::hover::Hovered,
|
||||
@ -19,7 +19,12 @@ use bevy::{
|
||||
|
||||
fn main() {
|
||||
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.
|
||||
.insert_resource(WinitSettings::desktop_app())
|
||||
.insert_resource(DemoWidgetStates { slider_value: 50.0 })
|
||||
|
@ -8,7 +8,7 @@ use bevy::{
|
||||
},
|
||||
ecs::system::SystemId,
|
||||
input_focus::{
|
||||
tab_navigation::{TabGroup, TabIndex},
|
||||
tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin},
|
||||
InputDispatchPlugin,
|
||||
},
|
||||
picking::hover::Hovered,
|
||||
@ -19,7 +19,12 @@ use bevy::{
|
||||
|
||||
fn main() {
|
||||
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.
|
||||
.insert_resource(WinitSettings::desktop_app())
|
||||
.insert_resource(DemoWidgetStates { slider_value: 50.0 })
|
||||
|
Loading…
Reference in New Issue
Block a user