Skip to content

Commit ffa4b0e

Browse files
Add FocusGained and FocusLost entity events (#23723)
# Objective When working with more complex widgets, users have repeatedly asked for a way to reliably detect when an entity gains or loses focus. They have not, however, filed an issue about this 🎟️ These are called [`focus`](https://developer.mozilla.org/en-US/docs/Web/API/Element/focus_event) / [`focusin`](https://developer.mozilla.org/en-US/docs/Web/API/Element/focusin_event) + [`blur`](https://developer.mozilla.org/en-US/docs/Web/API/Element/blur_event) / [`focusout`](https://developer.mozilla.org/en-US/docs/Web/API/Element/focusout_event) on the web. The former element in these pairs does not bubble, while the latter does. Because Bevy's observer infrastructure allows you to get the original target of a bubbled event, we can create only bubbling variations. @viridia has suggested that #23707 would benefit from a form of this feature. ## Rejected designs The simplest approach would be to simply add a `previously_focused` field on `InputFocus`, and then emit events based on the difference using an ordinary system. Unfortunately, this has a serious flaw: events are lost if this changes state multiple times in the same frame. We can resolve this problem by completely locking down access, and requiring commands or events to be used to change the input focus. This ensures no changes are lost and sends them off semi-immediately, but is a major breaking change to this API and prevents immediate-mode checks of "what is the current input focus". ## Solution We can do better. If we sacrifice "FocusGained and Lost must be emitted immediately", we can track changes inside of `InputFocus`, before sending them off in an ordinary system. This is minimally breaking (you have to use the getters/setters now), and ensures no gained/lost events are ever missed. Users who completely overwrite `InputFocus` (e.g. by using `from_entity`) will miss changes, but frankly, you deserve it if you ignore the nice setters and clear warnings in the docs. - Define `FocusGained` and `FocusLost` events. Split to their own file for cleanliness. - Lock down access to `InputFocus`, forcing users to always go through the existing getters and setters. - Modify `InputFocus` to track changes as they have been made. - Add a system to drain these once-per-frame in `PostUpdate`, converting them into `FocusGained` and `FocusLost`. - This timing helps ensure that any user / library code that changes the focus has time to run, but rendering code that relies on accurate focus information to display widgets has the information available. - I could not find existing systems in `PostUpdate` that this needed a relative ordering for. - Create an `InputFocusPlugin` to store this new system, stealing some of the setup that was previously in `InputDispatchPlugin`. Somewhat incidentally, this fixes #19057, by selecting option 1. ## Testing I was *not* very confident that my implementation of this logic was correct, so I wrote a rather aggressive set of mid-level tests, using `App`. They pass, so apparently my first implementation was actually good enough.
1 parent 0d68d94 commit ffa4b0e

File tree

19 files changed

+496
-69
lines changed

19 files changed

+496
-69
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
title: "`InputFocus` fields are no longer public"
3+
pull_requests: [23723]
4+
---
5+
6+
The `.0` field on `InputFocus` is no longer public.
7+
Use the getter and setters methods instead.
8+
9+
Before:
10+
11+
```rust
12+
let focused_entity = input_focus.0;
13+
input_focus.0 = Some(entity);
14+
input_focus.0 = None;
15+
```
16+
17+
After:
18+
19+
```rust
20+
let focused_entity = input_focus.get();
21+
input_focus.set(entity);
22+
input_focus.clear();
23+
```
24+
25+
Additionally, the core setup of `InputFocus` and related resources now occurs in `InputFocusPlugin`,
26+
rather than `InputDispatchPlugin`.
27+
This is part of `DefaultPlugins`, so most users will not need to make any changes.

crates/bevy_feathers/src/controls/text_input.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ fn update_text_input_focus(
146146
// We're not using FocusIndicator here because (a) the focus ring is inset rather than
147147
// an outline, and (b) we want to detect focus on a descendant rather than an ancestor.
148148
if focus.is_changed() {
149-
let focus_parent = focus.0.and_then(|focus_ent| {
149+
let focus_parent = focus.get().and_then(|focus_ent| {
150150
if focus_visible.0 && q_inputs.contains(focus_ent) {
151151
parents
152152
.iter_ancestors(focus_ent)

crates/bevy_feathers/src/focus.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ fn manage_focus_indicators(
3737
}
3838

3939
let mut visited = HashSet::<Entity>::with_capacity(q_indicators.count());
40-
if let Some(focus) = input_focus.0
40+
if let Some(focus) = input_focus.get()
4141
&& input_focus_visible.0
4242
{
4343
for entity in q_children

crates/bevy_input_focus/src/directional_navigation.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ impl<'w> DirectionalNavigation<'w> {
411411
&mut self,
412412
direction: CompassOctant,
413413
) -> Result<Entity, DirectionalNavigationError> {
414-
if let Some(current_focus) = self.focus.0 {
414+
if let Some(current_focus) = self.focus.get() {
415415
// Respect manual edges first
416416
match self.map.get_neighbor(current_focus, direction) {
417417
NavNeighbor::Auto => Err(DirectionalNavigationError::NoNeighborInDirection {
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
//! Contains [`FocusGained`] and [`FocusLost`] events,
2+
//! as well as [`process_recorded_focus_changes`] to send them when the focused entity changes.
3+
4+
use super::InputFocus;
5+
use bevy_ecs::prelude::*;
6+
7+
/// An [`EntityEvent`] that is sent when an entity gains [`InputFocus`].
8+
///
9+
/// This event bubbles up the entity hierarchy, so if a child entity gains focus, its parents will also receive this event.
10+
#[derive(EntityEvent, Debug, Clone)]
11+
#[entity_event(auto_propagate)]
12+
pub struct FocusGained {
13+
/// The entity that gained focus.
14+
pub entity: Entity,
15+
}
16+
17+
/// An [`EntityEvent`] that is sent when an entity loses [`InputFocus`].
18+
///
19+
/// This event bubbles up the entity hierarchy, so if a child entity loses focus, its parents will also receive this event.
20+
#[derive(EntityEvent, Debug, Clone)]
21+
#[entity_event(auto_propagate)]
22+
pub struct FocusLost {
23+
/// The entity that lost focus.
24+
pub entity: Entity,
25+
}
26+
27+
/// Reads the recorded focus changes from the [`InputFocus`] resource and sends the appropriate [`FocusGained`] and [`FocusLost`] events.
28+
///
29+
/// This system is part of [`InputFocusPlugin`](super::InputFocusPlugin).
30+
pub fn process_recorded_focus_changes(mut focus: ResMut<InputFocus>, mut commands: Commands) {
31+
// We need to track the previous focus as we go,
32+
// so we can send the correct FocusLost events when focus changes.
33+
let mut previous_focus = focus.original_focus;
34+
for change in focus.recorded_changes.drain(..) {
35+
match change {
36+
Some(new_focus) => {
37+
if let Some(old_focus) = previous_focus {
38+
commands.trigger(FocusLost { entity: old_focus });
39+
}
40+
commands.trigger(FocusGained { entity: new_focus });
41+
previous_focus = Some(new_focus);
42+
}
43+
None => {
44+
if let Some(old_focus) = previous_focus {
45+
commands.trigger(FocusLost { entity: old_focus });
46+
}
47+
previous_focus = None;
48+
}
49+
}
50+
}
51+
52+
focus.original_focus = focus.current_focus;
53+
}
54+
55+
#[cfg(test)]
56+
mod tests {
57+
use super::*;
58+
use alloc::vec;
59+
use alloc::vec::Vec;
60+
use bevy_app::App;
61+
use bevy_ecs::observer::On;
62+
use bevy_input::InputPlugin;
63+
64+
/// Tracks the sequence of [`FocusGained`] and [`FocusLost`] events for assertions.
65+
#[derive(Debug, Clone, PartialEq)]
66+
enum FocusEvent {
67+
Gained(Entity),
68+
Lost(Entity),
69+
}
70+
71+
#[derive(Resource, Default)]
72+
struct FocusEventLog(Vec<FocusEvent>);
73+
74+
fn setup_app() -> App {
75+
let mut app = App::new();
76+
app.add_plugins((InputPlugin, super::super::InputFocusPlugin));
77+
app.init_resource::<FocusEventLog>();
78+
79+
app.add_observer(|trigger: On<FocusGained>, mut log: ResMut<FocusEventLog>| {
80+
log.0.push(FocusEvent::Gained(trigger.entity));
81+
});
82+
app.add_observer(|trigger: On<FocusLost>, mut log: ResMut<FocusEventLog>| {
83+
log.0.push(FocusEvent::Lost(trigger.entity));
84+
});
85+
86+
// Run once to finish startup
87+
app.update();
88+
89+
app
90+
}
91+
92+
// Convenience method to extract and clear the log values for assertions
93+
fn take_log(app: &mut App) -> Vec<FocusEvent> {
94+
core::mem::take(&mut app.world_mut().resource_mut::<FocusEventLog>().0)
95+
}
96+
97+
#[test]
98+
fn no_changes_no_events() {
99+
let mut app = setup_app();
100+
101+
app.update();
102+
assert!(take_log(&mut app).is_empty());
103+
}
104+
105+
#[test]
106+
fn gain_focus_from_none() {
107+
let mut app = setup_app();
108+
109+
let entity = app.world_mut().spawn_empty().id();
110+
app.world_mut().resource_mut::<InputFocus>().set(entity);
111+
app.update();
112+
113+
assert_eq!(take_log(&mut app), vec![FocusEvent::Gained(entity)]);
114+
}
115+
116+
#[test]
117+
fn lose_focus_to_none() {
118+
let mut app = setup_app();
119+
let entity = app.world_mut().spawn_empty().id();
120+
121+
// Establish initial focus.
122+
app.world_mut().resource_mut::<InputFocus>().set(entity);
123+
app.update();
124+
take_log(&mut app);
125+
126+
app.world_mut().resource_mut::<InputFocus>().clear();
127+
app.update();
128+
129+
assert_eq!(take_log(&mut app), vec![FocusEvent::Lost(entity)]);
130+
}
131+
132+
#[test]
133+
fn switch_focus_between_entities() {
134+
let mut app = setup_app();
135+
let a = app.world_mut().spawn_empty().id();
136+
let b = app.world_mut().spawn_empty().id();
137+
138+
app.world_mut().resource_mut::<InputFocus>().set(a);
139+
app.update();
140+
take_log(&mut app);
141+
142+
app.world_mut().resource_mut::<InputFocus>().set(b);
143+
app.update();
144+
145+
assert_eq!(
146+
take_log(&mut app),
147+
vec![FocusEvent::Lost(a), FocusEvent::Gained(b)]
148+
);
149+
}
150+
151+
#[test]
152+
fn multiple_changes_in_single_frame() {
153+
let mut app = setup_app();
154+
take_log(&mut app);
155+
156+
let a = app.world_mut().spawn_empty().id();
157+
let b = app.world_mut().spawn_empty().id();
158+
let c = app.world_mut().spawn_empty().id();
159+
160+
let mut focus = app.world_mut().resource_mut::<InputFocus>();
161+
focus.set(a);
162+
focus.set(b);
163+
focus.clear();
164+
focus.set(c);
165+
166+
app.update();
167+
168+
assert_eq!(
169+
take_log(&mut app),
170+
vec![
171+
FocusEvent::Gained(a),
172+
FocusEvent::Lost(a),
173+
FocusEvent::Gained(b),
174+
FocusEvent::Lost(b),
175+
FocusEvent::Gained(c),
176+
]
177+
);
178+
}
179+
180+
#[test]
181+
fn set_focus_to_same_entity() {
182+
let mut app = setup_app();
183+
let entity = app.world_mut().spawn_empty().id();
184+
185+
app.world_mut().resource_mut::<InputFocus>().set(entity);
186+
app.update();
187+
take_log(&mut app);
188+
189+
// Setting focus to the already-focused entity still records a change.
190+
app.world_mut().resource_mut::<InputFocus>().set(entity);
191+
app.update();
192+
193+
assert_eq!(
194+
take_log(&mut app),
195+
vec![FocusEvent::Lost(entity), FocusEvent::Gained(entity)]
196+
);
197+
}
198+
199+
#[test]
200+
fn clear_when_already_none() {
201+
let mut app = setup_app();
202+
take_log(&mut app);
203+
204+
app.world_mut().resource_mut::<InputFocus>().clear();
205+
app.update();
206+
207+
// No entity was focused, so no FocusLost should fire.
208+
assert!(take_log(&mut app).is_empty());
209+
}
210+
211+
#[test]
212+
fn double_clear() {
213+
let mut app = setup_app();
214+
let entity = app.world_mut().spawn_empty().id();
215+
216+
app.world_mut().resource_mut::<InputFocus>().set(entity);
217+
app.update();
218+
take_log(&mut app);
219+
220+
// Clear twice — only one FocusLost should fire (the second clear has no previous focus).
221+
let mut focus = app.world_mut().resource_mut::<InputFocus>();
222+
focus.clear();
223+
focus.clear();
224+
app.update();
225+
226+
assert_eq!(take_log(&mut app), vec![FocusEvent::Lost(entity)]);
227+
}
228+
229+
#[test]
230+
fn events_propagate_to_parent() {
231+
let mut app = setup_app();
232+
take_log(&mut app);
233+
234+
let child = app.world_mut().spawn_empty().id();
235+
let parent = app.world_mut().spawn_empty().add_child(child).id();
236+
237+
app.world_mut().resource_mut::<InputFocus>().set(child);
238+
app.update();
239+
240+
// The event fires on the child, then bubbles to the parent.
241+
let log = take_log(&mut app);
242+
assert!(
243+
log.contains(&FocusEvent::Gained(child)),
244+
"child should receive FocusGained"
245+
);
246+
assert!(
247+
log.contains(&FocusEvent::Gained(parent)),
248+
"parent should receive FocusGained via propagation"
249+
);
250+
251+
app.world_mut().resource_mut::<InputFocus>().clear();
252+
app.update();
253+
254+
let log = take_log(&mut app);
255+
assert!(
256+
log.contains(&FocusEvent::Lost(child)),
257+
"child should receive FocusLost"
258+
);
259+
assert!(
260+
log.contains(&FocusEvent::Lost(parent)),
261+
"parent should receive FocusLost via propagation"
262+
);
263+
}
264+
265+
#[test]
266+
fn focus_lost_on_despawned_entity() {
267+
let mut app = setup_app();
268+
let entity = app.world_mut().spawn_empty().id();
269+
270+
app.world_mut().resource_mut::<InputFocus>().set(entity);
271+
app.update();
272+
take_log(&mut app);
273+
274+
// Record a focus change away from the entity, then despawn it before processing.
275+
app.world_mut().resource_mut::<InputFocus>().clear();
276+
app.world_mut().entity_mut(entity).despawn();
277+
app.update();
278+
279+
// FocusLost should still fire (and not panic).
280+
let log = take_log(&mut app);
281+
assert_eq!(log, vec![FocusEvent::Lost(entity)]);
282+
}
283+
284+
#[test]
285+
fn from_entity_fires_gained_event() {
286+
let mut app = setup_app();
287+
take_log(&mut app);
288+
289+
let entity = app.world_mut().spawn_empty().id();
290+
app.world_mut()
291+
.insert_resource(InputFocus::from_entity(entity));
292+
app.update();
293+
294+
let log = take_log(&mut app);
295+
assert!(
296+
log.contains(&FocusEvent::Gained(entity)),
297+
"from_entity should record a change that fires FocusGained"
298+
);
299+
}
300+
}

0 commit comments

Comments
 (0)