Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions _release-content/migration-guides/input_focus_setting_getting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
title: "`InputFocus` fields are no longer public"
pull_requests: [23723]
---

The `.0` field on `InputFocus` is no longer public.
Use the getter and setters methods instead.

Before:

```rust
let focused_entity = input_focus.0;
input_focus.0 = Some(entity);
input_focus.0 = None;
```

After:

```rust
let focused_entity = input_focus.get();
input_focus.set(entity);
input_focus.clear();
```

Additionally, the core setup of `InputFocus` and related resources now occurs in `InputFocusPlugin`,
rather than `InputDispatchPlugin`.
This is part of `DefaultPlugins`, so most users will not need to make any changes.
2 changes: 1 addition & 1 deletion crates/bevy_feathers/src/controls/text_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ fn update_text_input_focus(
// We're not using FocusIndicator here because (a) the focus ring is inset rather than
// an outline, and (b) we want to detect focus on a descendant rather than an ancestor.
if focus.is_changed() {
let focus_parent = focus.0.and_then(|focus_ent| {
let focus_parent = focus.get().and_then(|focus_ent| {
if focus_visible.0 && q_inputs.contains(focus_ent) {
parents
.iter_ancestors(focus_ent)
Expand Down
2 changes: 1 addition & 1 deletion crates/bevy_feathers/src/focus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ fn manage_focus_indicators(
}

let mut visited = HashSet::<Entity>::with_capacity(q_indicators.count());
if let Some(focus) = input_focus.0
if let Some(focus) = input_focus.get()
&& input_focus_visible.0
{
for entity in q_children
Expand Down
2 changes: 1 addition & 1 deletion crates/bevy_input_focus/src/directional_navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ impl<'w> DirectionalNavigation<'w> {
&mut self,
direction: CompassOctant,
) -> Result<Entity, DirectionalNavigationError> {
if let Some(current_focus) = self.focus.0 {
if let Some(current_focus) = self.focus.get() {
// Respect manual edges first
match self.map.get_neighbor(current_focus, direction) {
NavNeighbor::Auto => Err(DirectionalNavigationError::NoNeighborInDirection {
Expand Down
300 changes: 300 additions & 0 deletions crates/bevy_input_focus/src/gained_and_lost.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
//! Contains [`FocusGained`] and [`FocusLost`] events,
//! as well as [`process_recorded_focus_changes`] to send them when the focused entity changes.

use super::InputFocus;
use bevy_ecs::prelude::*;

/// An [`EntityEvent`] that is sent when an entity gains [`InputFocus`].
///
/// This event bubbles up the entity hierarchy, so if a child entity gains focus, its parents will also receive this event.
#[derive(EntityEvent, Debug, Clone)]
#[entity_event(auto_propagate)]
pub struct FocusGained {
/// The entity that gained focus.
pub entity: Entity,
}

/// An [`EntityEvent`] that is sent when an entity loses [`InputFocus`].
///
/// This event bubbles up the entity hierarchy, so if a child entity loses focus, its parents will also receive this event.
#[derive(EntityEvent, Debug, Clone)]
#[entity_event(auto_propagate)]
pub struct FocusLost {
/// The entity that lost focus.
pub entity: Entity,
}

/// Reads the recorded focus changes from the [`InputFocus`] resource and sends the appropriate [`FocusGained`] and [`FocusLost`] events.
///
/// This system is part of [`InputFocusPlugin`](super::InputFocusPlugin).
pub fn process_recorded_focus_changes(mut focus: ResMut<InputFocus>, mut commands: Commands) {
// We need to track the previous focus as we go,
// so we can send the correct FocusLost events when focus changes.
let mut previous_focus = focus.original_focus;
for change in focus.recorded_changes.drain(..) {
match change {
Some(new_focus) => {
if let Some(old_focus) = previous_focus {
commands.trigger(FocusLost { entity: old_focus });
}
commands.trigger(FocusGained { entity: new_focus });
previous_focus = Some(new_focus);
}
None => {
if let Some(old_focus) = previous_focus {
commands.trigger(FocusLost { entity: old_focus });
}
previous_focus = None;
}
}
}

focus.original_focus = focus.current_focus;
}

#[cfg(test)]
mod tests {
use super::*;
use alloc::vec;
use alloc::vec::Vec;
use bevy_app::App;
use bevy_ecs::observer::On;
use bevy_input::InputPlugin;

/// Tracks the sequence of FocusGained and FocusLost events for assertions.
#[derive(Debug, Clone, PartialEq)]
enum FocusEvent {
Gained(Entity),
Lost(Entity),
}

#[derive(Resource, Default)]
struct FocusEventLog(Vec<FocusEvent>);

fn setup_app() -> App {
let mut app = App::new();
app.add_plugins((InputPlugin, super::super::InputFocusPlugin));
app.init_resource::<FocusEventLog>();

app.add_observer(|trigger: On<FocusGained>, mut log: ResMut<FocusEventLog>| {
log.0.push(FocusEvent::Gained(trigger.entity));
});
app.add_observer(|trigger: On<FocusLost>, mut log: ResMut<FocusEventLog>| {
log.0.push(FocusEvent::Lost(trigger.entity));
});

// Run once to finish startup
app.update();

app
}

// Convenience method to extract and clear the log values for assertions
fn take_log(app: &mut App) -> Vec<FocusEvent> {
core::mem::take(&mut app.world_mut().resource_mut::<FocusEventLog>().0)
}

#[test]
fn no_changes_no_events() {
let mut app = setup_app();

app.update();
assert!(take_log(&mut app).is_empty());
}

#[test]
fn gain_focus_from_none() {
let mut app = setup_app();

let entity = app.world_mut().spawn_empty().id();
app.world_mut().resource_mut::<InputFocus>().set(entity);
app.update();

assert_eq!(take_log(&mut app), vec![FocusEvent::Gained(entity)]);
}

#[test]
fn lose_focus_to_none() {
let mut app = setup_app();
let entity = app.world_mut().spawn_empty().id();

// Establish initial focus.
app.world_mut().resource_mut::<InputFocus>().set(entity);
app.update();
take_log(&mut app);

app.world_mut().resource_mut::<InputFocus>().clear();
app.update();

assert_eq!(take_log(&mut app), vec![FocusEvent::Lost(entity)]);
}

#[test]
fn switch_focus_between_entities() {
let mut app = setup_app();
let a = app.world_mut().spawn_empty().id();
let b = app.world_mut().spawn_empty().id();

app.world_mut().resource_mut::<InputFocus>().set(a);
app.update();
take_log(&mut app);

app.world_mut().resource_mut::<InputFocus>().set(b);
app.update();

assert_eq!(
take_log(&mut app),
vec![FocusEvent::Lost(a), FocusEvent::Gained(b)]
);
}

#[test]
fn multiple_changes_in_single_frame() {
let mut app = setup_app();
take_log(&mut app);

let a = app.world_mut().spawn_empty().id();
let b = app.world_mut().spawn_empty().id();
let c = app.world_mut().spawn_empty().id();

let mut focus = app.world_mut().resource_mut::<InputFocus>();
focus.set(a);
focus.set(b);
focus.clear();
focus.set(c);

app.update();

assert_eq!(
take_log(&mut app),
vec![
FocusEvent::Gained(a),
FocusEvent::Lost(a),
FocusEvent::Gained(b),
FocusEvent::Lost(b),
FocusEvent::Gained(c),
]
);
}

#[test]
fn set_focus_to_same_entity() {
let mut app = setup_app();
let entity = app.world_mut().spawn_empty().id();

app.world_mut().resource_mut::<InputFocus>().set(entity);
app.update();
take_log(&mut app);

// Setting focus to the already-focused entity still records a change.
app.world_mut().resource_mut::<InputFocus>().set(entity);
app.update();

assert_eq!(
take_log(&mut app),
vec![FocusEvent::Lost(entity), FocusEvent::Gained(entity)]
);
}

#[test]
fn clear_when_already_none() {
let mut app = setup_app();
take_log(&mut app);

app.world_mut().resource_mut::<InputFocus>().clear();
app.update();

// No entity was focused, so no FocusLost should fire.
assert!(take_log(&mut app).is_empty());
}

#[test]
fn double_clear() {
let mut app = setup_app();
let entity = app.world_mut().spawn_empty().id();

app.world_mut().resource_mut::<InputFocus>().set(entity);
app.update();
take_log(&mut app);

// Clear twice — only one FocusLost should fire (the second clear has no previous focus).
let mut focus = app.world_mut().resource_mut::<InputFocus>();
focus.clear();
focus.clear();
app.update();

assert_eq!(take_log(&mut app), vec![FocusEvent::Lost(entity)]);
}

#[test]
fn events_propagate_to_parent() {
let mut app = setup_app();
take_log(&mut app);

let child = app.world_mut().spawn_empty().id();
let parent = app.world_mut().spawn_empty().add_child(child).id();

app.world_mut().resource_mut::<InputFocus>().set(child);
app.update();

// The event fires on the child, then bubbles to the parent.
let log = take_log(&mut app);
assert!(
log.contains(&FocusEvent::Gained(child)),
"child should receive FocusGained"
);
assert!(
log.contains(&FocusEvent::Gained(parent)),
"parent should receive FocusGained via propagation"
);

app.world_mut().resource_mut::<InputFocus>().clear();
app.update();

let log = take_log(&mut app);
assert!(
log.contains(&FocusEvent::Lost(child)),
"child should receive FocusLost"
);
assert!(
log.contains(&FocusEvent::Lost(parent)),
"parent should receive FocusLost via propagation"
);
}

#[test]
fn focus_lost_on_despawned_entity() {
let mut app = setup_app();
let entity = app.world_mut().spawn_empty().id();

app.world_mut().resource_mut::<InputFocus>().set(entity);
app.update();
take_log(&mut app);

// Record a focus change away from the entity, then despawn it before processing.
app.world_mut().resource_mut::<InputFocus>().clear();
app.world_mut().entity_mut(entity).despawn();
app.update();

// FocusLost should still fire (and not panic).
let log = take_log(&mut app);
assert_eq!(log, vec![FocusEvent::Lost(entity)]);
}

#[test]
fn from_entity_fires_gained_event() {
let mut app = setup_app();
take_log(&mut app);

let entity = app.world_mut().spawn_empty().id();
app.world_mut()
.insert_resource(InputFocus::from_entity(entity));
app.update();

let log = take_log(&mut app);
assert!(
log.contains(&FocusEvent::Gained(entity)),
"from_entity should record a change that fires FocusGained"
);
}
}
Loading
Loading