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:
Talin 2025-06-21 11:05:26 -07:00 committed by GitHub
parent d770648c2f
commit 4ff595a4bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 104 additions and 51 deletions

View File

@ -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;
}
} }
} }
} }

View File

@ -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;
} }

View File

@ -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",

View File

@ -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,

View File

@ -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,

View File

@ -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 })

View File

@ -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 })