Skip to content

Adding interaction states for headless widgets. #19238

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 15 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
265 changes: 264 additions & 1 deletion crates/bevy_picking/src/hover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::{
};

use bevy_derive::{Deref, DerefMut};
use bevy_ecs::prelude::*;
use bevy_ecs::{entity::EntityHashSet, prelude::*};
use bevy_math::FloatOrd;
use bevy_platform::collections::HashMap;
use bevy_reflect::prelude::*;
Expand Down Expand Up @@ -275,3 +275,266 @@ fn merge_interaction_states(
new_interaction_state.insert(*hovered_entity, new_interaction);
}
}

/// A component that allows users to use regular Bevy change detection to determine when the pointer
/// enters or leaves an entity. Users should insert this component on an entity to indicate interest
/// in knowing about hover state changes.
///
/// This is similar to the old Bevy [`Interaction`] component, except that it only tracks
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels a bit awkward as "old" implies Interaction either no longer exists or is deprecated. Otherwise this is just a history lesson about what came first, which isn't really something users need to care about.

/// hover state, not button presses or other interactions.
///
/// The component's boolean value will be `true` whenever the pointer is currently hovering over the
/// entity, or any of the entity's children. This is consistent with the behavior of the CSS
/// `:hover` pseudo-class, which applies to the element and all of its children.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense to say "descendants" here instead of "children" as this applies to arbitrary levels of the hierarchy. It would also be good to use "directly" here, to connect this to HoveredDirect.

Suggested change
/// The component's boolean value will be `true` whenever the pointer is currently hovering over the
/// entity, or any of the entity's children. This is consistent with the behavior of the CSS
/// `:hover` pseudo-class, which applies to the element and all of its children.
/// The component's boolean value will be `true` whenever the pointer is currently directly hovering over the
/// entity, or any of the entity's descendants (as defined by the [`ChildOf`] relationship). This is consistent with the behavior of the CSS
/// `:hover` pseudo-class, which applies to the element and all of its children.

///
/// The contained boolean value is guaranteed to only be mutated when the pointer enters or leaves
/// the entity, allowing Bevy change detection to be used efficiently. This is in contrast to the
/// [`HoverMap`] resource, which is updated every frame.
///
/// Typically, a simple hoverable entity or widget will have this component added to it. More
/// complex widgets can have this component added to each hoverable part.
///
/// The computational cost of keeping the `IsHovered` components up to date is relatively cheap,
/// and linear in the number of entities that have the `IsHovered` component inserted.
///
/// [`Interaction`]: https://docs.rs/bevy/0.15.0/bevy/prelude/enum.Interaction.html
#[derive(Component, Copy, Clone, Default, Eq, PartialEq, Debug, Reflect)]
#[reflect(Component, Default, PartialEq, Debug, Clone)]
pub struct IsHovered(pub bool);
Copy link
Contributor

@ickshonpe ickshonpe May 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly this naming could be confusing because Is- components in bevy tend to be unit structs used as markers? It seems natural to use a With<IsHovered> query filter assuming that it would return only hovered UI nodes.


impl IsHovered {
/// Get whether the entity is currently hovered.
pub fn get(&self) -> bool {
self.0
}
}

/// A component that allows users to use regular Bevy change detection to determine when the pointer
/// is directly hovering over an entity. Users should insert this component on an entity to indicate
/// interest in knowing about hover state changes.
///
/// This is similar to [`IsHovered`] component, except that it does not include children in the
/// hover state.
#[derive(Component, Copy, Clone, Default, Eq, PartialEq, Debug, Reflect)]
#[reflect(Component, Default, PartialEq, Debug, Clone)]
pub struct IsHoveredDirect(pub bool);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think "directly hovered" feels more natural than "hovered direct". Thoughts?


impl IsHoveredDirect {
/// Get whether the entity is currently hovered.
pub fn get(&self) -> bool {
self.0
}
}

/// Uses [`HoverMap`] changes to update [`IsHovered`] components.
pub fn update_is_hovered(
hover_map: Option<Res<HoverMap>>,
mut hovers: Query<(Entity, &mut IsHovered)>,
parent_query: Query<&ChildOf>,
) {
// Don't do any work if there's no hover map.
let Some(hover_map) = hover_map else { return };

// Don't bother collecting ancestors if there are no hovers.
if hovers.is_empty() {
return;
}

// Algorithm: for each entity having a `IsHovered` component, we want to know if the current
// entry in the hover map is "within" (that is, in the set of descenants of) that entity. Rather
// than doing an expensive breadth-first traversal of children, instead start with the hovermap
// entry and search upwards. We can make this even cheaper by building a set of ancestors for
// the hovermap entry, and then testing each `IsHovered` entity against that set.

// A set which contains the hovered for the current pointer entity and its ancestors. The
// capacity is based on the likely tree depth of the hierarchy, which is typically greater for
// UI (because of layout issues) than for 3D scenes. A depth of 32 is a reasonable upper bound
// for most use cases.
let mut hover_ancestors = EntityHashSet::with_capacity(32);
if let Some(map) = hover_map.get(&PointerId::Mouse) {
for hovered_entity in map.keys() {
hover_ancestors.insert(*hovered_entity);
hover_ancestors.extend(parent_query.iter_ancestors(*hovered_entity));
}
}

// For each hovered entity, it is considered "hovering" if it's in the set of hovered ancestors.
for (entity, mut hoverable) in hovers.iter_mut() {
let is_hovering = hover_ancestors.contains(&entity);
hoverable.set_if_neq(IsHovered(is_hovering));
}
}

/// Uses [`HoverMap`] changes to update [`IsHoveredDirect`] components.
pub fn update_is_hovered_direct(
hover_map: Option<Res<HoverMap>>,
mut hovers: Query<(Entity, &mut IsHoveredDirect)>,
) {
// Don't do any work if there's no hover map.
let Some(hover_map) = hover_map else { return };

// Don't bother collecting ancestors if there are no hovers.
if hovers.is_empty() {
return;
}

if let Some(map) = hover_map.get(&PointerId::Mouse) {
// For each hovered entity, it is considered "hovering" if it's in the set of hovered ancestors.
for (entity, mut hoverable) in hovers.iter_mut() {
hoverable.set_if_neq(IsHoveredDirect(map.contains_key(&entity)));
}
} else {
// For each hovered entity, it is considered "hovering" if it's in the set of hovered ancestors.
for (_, mut hoverable) in hovers.iter_mut() {
hoverable.set_if_neq(IsHoveredDirect(false));
}
}
}

#[cfg(test)]
mod tests {
use bevy_render::camera::Camera;

use super::*;

#[test]
fn update_is_hovered_memoized() {
let mut world = World::default();
let camera = world.spawn(Camera::default()).id();

// Setup entities
let hovered_child = world.spawn_empty().id();
let hovered_entity = world.spawn(IsHovered(false)).add_child(hovered_child).id();

// Setup hover map with hovered_entity hovered by mouse
let mut hover_map = HoverMap::default();
let mut entity_map = HashMap::new();
entity_map.insert(
hovered_child,
HitData {
depth: 0.0,
camera,
position: None,
normal: None,
},
);
hover_map.insert(PointerId::Mouse, entity_map);
world.insert_resource(hover_map);

// Run the system
assert!(world.run_system_cached(update_is_hovered).is_ok());

// Check to insure that the hovered entity has the IsHovered component set to true
let hover = world.get_mut::<IsHovered>(hovered_entity).unwrap();
assert!(hover.get());
assert!(hover.is_changed());

// Now do it again, but don't change the hover map.
world.increment_change_tick();

assert!(world.run_system_cached(update_is_hovered).is_ok());
let hover = world.get_mut::<IsHovered>(hovered_entity).unwrap();
assert!(hover.get());

// Should not be changed
// NOTE: Test doesn't work - thinks it is always changed
// assert!(!hover.is_changed());

// Clear the hover map and run again.
world.insert_resource(HoverMap::default());
world.increment_change_tick();

assert!(world.run_system_cached(update_is_hovered).is_ok());
let hover = world.get_mut::<IsHovered>(hovered_entity).unwrap();
assert!(!hover.get());
assert!(hover.is_changed());
}

#[test]
fn update_is_hovered_direct_self() {
let mut world = World::default();
let camera = world.spawn(Camera::default()).id();

// Setup entities
let hovered_entity = world.spawn(IsHoveredDirect(false)).id();

// Setup hover map with hovered_entity hovered by mouse
let mut hover_map = HoverMap::default();
let mut entity_map = HashMap::new();
entity_map.insert(
hovered_entity,
HitData {
depth: 0.0,
camera,
position: None,
normal: None,
},
);
hover_map.insert(PointerId::Mouse, entity_map);
world.insert_resource(hover_map);

// Run the system
assert!(world.run_system_cached(update_is_hovered_direct).is_ok());

// Check to insure that the hovered entity has the IsHoveredDirect component set to true
let hover = world.get_mut::<IsHoveredDirect>(hovered_entity).unwrap();
assert!(hover.get());
assert!(hover.is_changed());

// Now do it again, but don't change the hover map.
world.increment_change_tick();

assert!(world.run_system_cached(update_is_hovered_direct).is_ok());
let hover = world.get_mut::<IsHoveredDirect>(hovered_entity).unwrap();
assert!(hover.get());

// Should not be changed
// NOTE: Test doesn't work - thinks it is always changed
// assert!(!hover.is_changed());

// Clear the hover map and run again.
world.insert_resource(HoverMap::default());
world.increment_change_tick();

assert!(world.run_system_cached(update_is_hovered_direct).is_ok());
let hover = world.get_mut::<IsHoveredDirect>(hovered_entity).unwrap();
assert!(!hover.get());
assert!(hover.is_changed());
}

#[test]
fn update_is_hovered_direct_child() {
let mut world = World::default();
let camera = world.spawn(Camera::default()).id();

// Setup entities
let hovered_child = world.spawn_empty().id();
let hovered_entity = world
.spawn(IsHoveredDirect(false))
.add_child(hovered_child)
.id();

// Setup hover map with hovered_entity hovered by mouse
let mut hover_map = HoverMap::default();
let mut entity_map = HashMap::new();
entity_map.insert(
hovered_child,
HitData {
depth: 0.0,
camera,
position: None,
normal: None,
},
);
hover_map.insert(PointerId::Mouse, entity_map);
world.insert_resource(hover_map);

// Run the system
assert!(world.run_system_cached(update_is_hovered_direct).is_ok());

// Check to insure that the IsHoveredDirect component is still false
let hover = world.get_mut::<IsHoveredDirect>(hovered_entity).unwrap();
assert!(!hover.get());
assert!(hover.is_changed());
}
}
11 changes: 9 additions & 2 deletions crates/bevy_picking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ pub mod window;
use bevy_app::{prelude::*, PluginGroupBuilder};
use bevy_ecs::prelude::*;
use bevy_reflect::prelude::*;
use hover::update_is_hovered_direct;

/// The picking prelude.
///
Expand Down Expand Up @@ -392,6 +393,7 @@ impl Plugin for PickingPlugin {
.register_type::<Self>()
.register_type::<Pickable>()
.register_type::<hover::PickingInteraction>()
.register_type::<hover::IsHovered>()
.register_type::<pointer::PointerId>()
.register_type::<pointer::PointerLocation>()
.register_type::<pointer::PointerPress>()
Expand All @@ -407,7 +409,7 @@ pub struct InteractionPlugin;
impl Plugin for InteractionPlugin {
fn build(&self, app: &mut App) {
use events::*;
use hover::{generate_hovermap, update_interactions};
use hover::{generate_hovermap, update_interactions, update_is_hovered};

app.init_resource::<hover::HoverMap>()
.init_resource::<hover::PreviousHoverMap>()
Expand All @@ -429,7 +431,12 @@ impl Plugin for InteractionPlugin {
.add_event::<Pointer<Scroll>>()
.add_systems(
PreUpdate,
(generate_hovermap, update_interactions, pointer_events)
(
generate_hovermap,
update_interactions,
(update_is_hovered, update_is_hovered_direct),
pointer_events,
)
.chain()
.in_set(PickingSystems::Hover),
);
Expand Down
61 changes: 61 additions & 0 deletions crates/bevy_ui/src/interaction_states.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/// This module contains components that are used to track the interaction state of UI widgets.
///
// Note to implementers: This uses a combination of both marker components and newtype components
// containing a bool. Markers are used for one-way binding, "write-only" components
// (like `InteractionDisabled`) that relay instructions from the user to the framework, whereas
// newtype components are used to request state updates from the framework, which mutates the
// content of those components on update.
use bevy_a11y::AccessibilityNode;
use bevy_ecs::{
component::{Component, HookContext},
world::DeferredWorld,
};

/// A marker component to indicate that a widget is disabled and should be "grayed out".
/// This is used to prevent user interaction with the widget. It should not, however, prevent
/// the widget from being updated or rendered, or from acquiring keyboard focus.
///
/// For apps which support a11y: if a widget (such as a slider) contains multiple entities,
/// the `InteractionDisabled` component should be added to the root entity of the widget - the
/// same entity that contains the `AccessibilityNode` component. This will ensure that
/// the a11y tree is updated correctly.
#[derive(Component, Debug, Clone, Copy)]
#[component(on_add = on_add_disabled, on_remove = on_remove_disabled)]
pub struct InteractionDisabled;

// Hook to set the a11y "disabled" state when the widget is disabled.
fn on_add_disabled(mut world: DeferredWorld, context: HookContext) {
let mut entity = world.entity_mut(context.entity);
if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() {
accessibility.set_disabled();
}
}

// Hook to remove the a11y "disabled" state when the widget is enabled.
fn on_remove_disabled(mut world: DeferredWorld, context: HookContext) {
let mut entity = world.entity_mut(context.entity);
if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() {
accessibility.clear_disabled();
}
}

/// Component that indicates whether a button is currently pressed.
#[derive(Component, Default, Debug)]
#[component(immutable)]
pub struct ButtonPressed(pub bool);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that the other "bool state" components are immutable, maybe we should make this immutable too for consistency / to enable reliable observer-driven management?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok


/// Component that indicates whether a checkbox or radio button is in a checked state.
#[derive(Component, Default, Debug)]
#[component(immutable, on_add = on_add_checked, on_replace = on_add_checked)]
pub struct Checked(pub bool);

// Hook to set the a11y "checked" state when the checkbox is added.
fn on_add_checked(mut world: DeferredWorld, context: HookContext) {
let mut entity = world.entity_mut(context.entity);
let checked = entity.get::<Checked>().unwrap().0;
let mut accessibility = entity.get_mut::<AccessibilityNode>().unwrap();
accessibility.set_toggled(match checked {
true => accesskit::Toggled::True,
false => accesskit::Toggled::False,
});
}
2 changes: 2 additions & 0 deletions crates/bevy_ui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//! Spawn UI elements with [`widget::Button`], [`ImageNode`], [`Text`](prelude::Text) and [`Node`]
//! This UI is laid out with the Flexbox and CSS Grid layout models (see <https://cssreference.io/flexbox/>)

pub mod interaction_states;
pub mod measurement;
pub mod ui_material;
pub mod update;
Expand Down Expand Up @@ -37,6 +38,7 @@ mod ui_node;
pub use focus::*;
pub use geometry::*;
pub use gradients::*;
pub use interaction_states::*;
pub use layout::*;
pub use measurement::*;
pub use render::*;
Expand Down
Loading