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_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);
}
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

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