Skip to content
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

Remove Interaction, and replace it with bevy_picking based fields on Button #15597

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 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
289 changes: 2 additions & 287 deletions crates/bevy_ui/src/focus.rs
Original file line number Diff line number Diff line change
@@ -1,74 +1,11 @@
use crate::{
CalculatedClip, DefaultUiCamera, Node, ResolvedBorderRadius, TargetCamera, UiScale, UiStack,
};
use bevy_ecs::{
change_detection::DetectChangesMut,
entity::Entity,
prelude::{Component, With},
query::QueryData,
reflect::ReflectComponent,
system::{Local, Query, Res},
};
use bevy_input::{mouse::MouseButton, touch::Touches, ButtonInput};
use crate::ResolvedBorderRadius;
use bevy_ecs::prelude::*;
use bevy_math::{Rect, Vec2};
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
use bevy_render::{camera::NormalizedRenderTarget, prelude::Camera, view::ViewVisibility};
use bevy_transform::components::GlobalTransform;
use bevy_utils::HashMap;
use bevy_window::{PrimaryWindow, Window};

use smallvec::SmallVec;

#[cfg(feature = "serialize")]
use bevy_reflect::{ReflectDeserialize, ReflectSerialize};

/// Describes what type of input interaction has occurred for a UI node.
///
/// This is commonly queried with a `Changed<Interaction>` filter.
///
/// Updated in [`ui_focus_system`].
///
/// If a UI node has both [`Interaction`] and [`ViewVisibility`] components,
/// [`Interaction`] will always be [`Interaction::None`]
/// when [`ViewVisibility::get()`] is false.
/// This ensures that hidden UI nodes are not interactable,
/// and do not end up stuck in an active state if hidden at the wrong time.
///
/// Note that you can also control the visibility of a node using the [`Display`](crate::ui_node::Display) property,
/// which fully collapses it during layout calculations.
///
/// # See also
///
/// - [`ButtonBundle`](crate::node_bundles::ButtonBundle) which includes this component
/// - [`RelativeCursorPosition`] to obtain the position of the cursor relative to current node
#[derive(Component, Copy, Clone, Eq, PartialEq, Debug, Reflect)]
#[reflect(Component, Default, PartialEq, Debug)]
#[cfg_attr(
feature = "serialize",
derive(serde::Serialize, serde::Deserialize),
reflect(Serialize, Deserialize)
)]
pub enum Interaction {
/// The node has been pressed.
///
/// Note: This does not capture click/press-release action.
Pressed,
/// The node has been hovered over
Hovered,
/// Nothing has happened
None,
}

impl Interaction {
const DEFAULT: Self = Self::None;
}

impl Default for Interaction {
fn default() -> Self {
Self::DEFAULT
}
}

/// A component storing the position of the mouse relative to the node, (0., 0.) being the top-left corner and (1., 1.) being the bottom-right
/// If the mouse is not over the node, the value will go beyond the range of (0., 0.) to (1., 1.)
///
Expand Down Expand Up @@ -124,228 +61,6 @@ impl Default for FocusPolicy {
}
}

/// Contains entities whose Interaction should be set to None
#[derive(Default)]
pub struct State {
entities_to_reset: SmallVec<[Entity; 1]>,
}

/// Main query for [`ui_focus_system`]
#[derive(QueryData)]
#[query_data(mutable)]
pub struct NodeQuery {
entity: Entity,
node: &'static Node,
global_transform: &'static GlobalTransform,
interaction: Option<&'static mut Interaction>,
relative_cursor_position: Option<&'static mut RelativeCursorPosition>,
focus_policy: Option<&'static FocusPolicy>,
calculated_clip: Option<&'static CalculatedClip>,
view_visibility: Option<&'static ViewVisibility>,
target_camera: Option<&'static TargetCamera>,
}

/// The system that sets Interaction for all UI elements based on the mouse cursor activity
///
/// Entities with a hidden [`ViewVisibility`] are always treated as released.
#[allow(clippy::too_many_arguments)]
pub fn ui_focus_system(
mut state: Local<State>,
camera_query: Query<(Entity, &Camera)>,
default_ui_camera: DefaultUiCamera,
primary_window: Query<Entity, With<PrimaryWindow>>,
windows: Query<&Window>,
mouse_button_input: Res<ButtonInput<MouseButton>>,
touches_input: Res<Touches>,
ui_scale: Res<UiScale>,
ui_stack: Res<UiStack>,
mut node_query: Query<NodeQuery>,
) {
let primary_window = primary_window.iter().next();

// reset entities that were both clicked and released in the last frame
for entity in state.entities_to_reset.drain(..) {
if let Ok(NodeQueryItem {
interaction: Some(mut interaction),
..
}) = node_query.get_mut(entity)
{
*interaction = Interaction::None;
}
}

let mouse_released =
mouse_button_input.just_released(MouseButton::Left) || touches_input.any_just_released();
if mouse_released {
for node in &mut node_query {
if let Some(mut interaction) = node.interaction {
if *interaction == Interaction::Pressed {
*interaction = Interaction::None;
}
}
}
}

let mouse_clicked =
mouse_button_input.just_pressed(MouseButton::Left) || touches_input.any_just_pressed();

let camera_cursor_positions: HashMap<Entity, Vec2> = camera_query
.iter()
.filter_map(|(entity, camera)| {
// Interactions are only supported for cameras rendering to a window.
let Some(NormalizedRenderTarget::Window(window_ref)) =
camera.target.normalize(primary_window)
else {
return None;
};

let viewport_position = camera
.logical_viewport_rect()
.map(|rect| rect.min)
.unwrap_or_default();
windows
.get(window_ref.entity())
.ok()
.and_then(Window::cursor_position)
.or_else(|| touches_input.first_pressed_position())
.map(|cursor_position| (entity, cursor_position - viewport_position))
})
// The cursor position returned by `Window` only takes into account the window scale factor and not `UiScale`.
// To convert the cursor position to logical UI viewport coordinates we have to divide it by `UiScale`.
.map(|(entity, cursor_position)| (entity, cursor_position / ui_scale.0))
.collect();

// prepare an iterator that contains all the nodes that have the cursor in their rect,
// from the top node to the bottom one. this will also reset the interaction to `None`
// for all nodes encountered that are no longer hovered.
let mut hovered_nodes = ui_stack
.uinodes
.iter()
// reverse the iterator to traverse the tree from closest nodes to furthest
.rev()
.filter_map(|entity| {
let Ok(node) = node_query.get_mut(*entity) else {
return None;
};

let view_visibility = node.view_visibility?;
// Nodes that are not rendered should not be interactable
if !view_visibility.get() {
// Reset their interaction to None to avoid strange stuck state
if let Some(mut interaction) = node.interaction {
// We cannot simply set the interaction to None, as that will trigger change detection repeatedly
interaction.set_if_neq(Interaction::None);
}
return None;
}
let camera_entity = node
.target_camera
.map(TargetCamera::entity)
.or(default_ui_camera.get())?;

let node_rect = Rect::from_center_size(
node.global_transform.translation().truncate(),
node.node.size(),
);

// Intersect with the calculated clip rect to find the bounds of the visible region of the node
let visible_rect = node
.calculated_clip
.map(|clip| node_rect.intersect(clip.clip))
.unwrap_or(node_rect);

let cursor_position = camera_cursor_positions.get(&camera_entity);

// The mouse position relative to the node
// (0., 0.) is the top-left corner, (1., 1.) is the bottom-right corner
// Coordinates are relative to the entire node, not just the visible region.
let relative_cursor_position = cursor_position.and_then(|cursor_position| {
// ensure node size is non-zero in all dimensions, otherwise relative position will be
// +/-inf. if the node is hidden, the visible rect min/max will also be -inf leading to
// false positives for mouse_over (#12395)
(node_rect.size().cmpgt(Vec2::ZERO).all())
.then_some((*cursor_position - node_rect.min) / node_rect.size())
});

// If the current cursor position is within the bounds of the node's visible area, consider it for
// clicking
let relative_cursor_position_component = RelativeCursorPosition {
normalized_visible_node_rect: visible_rect.normalize(node_rect),
normalized: relative_cursor_position,
};

let contains_cursor = relative_cursor_position_component.mouse_over()
&& cursor_position
.map(|point| {
pick_rounded_rect(
*point - node_rect.center(),
node_rect.size(),
node.node.border_radius,
)
})
.unwrap_or(false);

// Save the relative cursor position to the correct component
if let Some(mut node_relative_cursor_position_component) = node.relative_cursor_position
{
*node_relative_cursor_position_component = relative_cursor_position_component;
}

if contains_cursor {
Some(*entity)
} else {
if let Some(mut interaction) = node.interaction {
if *interaction == Interaction::Hovered || (relative_cursor_position.is_none())
{
interaction.set_if_neq(Interaction::None);
}
}
None
}
})
.collect::<Vec<Entity>>()
.into_iter();

// set Pressed or Hovered on top nodes. as soon as a node with a `Block` focus policy is detected,
// the iteration will stop on it because it "captures" the interaction.
let mut iter = node_query.iter_many_mut(hovered_nodes.by_ref());
while let Some(node) = iter.fetch_next() {
if let Some(mut interaction) = node.interaction {
if mouse_clicked {
// only consider nodes with Interaction "pressed"
if *interaction != Interaction::Pressed {
*interaction = Interaction::Pressed;
// if the mouse was simultaneously released, reset this Interaction in the next
// frame
if mouse_released {
state.entities_to_reset.push(node.entity);
}
}
} else if *interaction == Interaction::None {
*interaction = Interaction::Hovered;
}
}

match node.focus_policy.unwrap_or(&FocusPolicy::Block) {
FocusPolicy::Block => {
break;
}
FocusPolicy::Pass => { /* allow the next node to be hovered/pressed */ }
}
}
// reset `Interaction` for the remaining lower nodes to `None`. those are the nodes that remain in
// `moused_over_nodes` after the previous loop is exited.
let mut iter = node_query.iter_many_mut(hovered_nodes);
while let Some(node) = iter.fetch_next() {
if let Some(mut interaction) = node.interaction {
// don't reset pressed nodes because they're handled separately
if *interaction != Interaction::Pressed {
interaction.set_if_neq(Interaction::None);
}
}
}
}

// Returns true if `point` (relative to the rectangle's center) is within the bounds of a rounded rectangle with
// the given size and border radius.
//
Expand Down
12 changes: 5 additions & 7 deletions crates/bevy_ui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pub mod prelude {
pub use {
crate::{
geometry::*, node_bundles::*, ui_material::*, ui_node::*, widget::Button,
widget::Label, Interaction, UiMaterialPlugin, UiScale,
widget::Label, UiMaterialPlugin, UiScale,
},
// `bevy_sprite` re-exports for texture slicing
bevy_sprite::{BorderRect, ImageScaleMode, SliceScaleMode, TextureSlicer},
Expand All @@ -59,7 +59,6 @@ pub mod prelude {

use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_input::InputSystem;
use bevy_render::{
camera::CameraUpdateSystem,
view::{check_visibility, VisibilitySystems},
Expand Down Expand Up @@ -133,7 +132,6 @@ impl Plugin for UiPlugin {
.register_type::<CalculatedClip>()
.register_type::<ContentSize>()
.register_type::<FocusPolicy>()
.register_type::<Interaction>()
.register_type::<Node>()
.register_type::<RelativeCursorPosition>()
.register_type::<Style>()
Expand All @@ -157,10 +155,6 @@ impl Plugin for UiPlugin {
UiSystem::PostLayout,
)
.chain(),
)
.add_systems(
PreUpdate,
ui_focus_system.in_set(UiSystem::Focus).after(InputSystem),
);

app.add_systems(
Expand All @@ -180,6 +174,7 @@ impl Plugin for UiPlugin {
.ambiguous_with(ui_layout_system)
.in_set(AmbiguousWithTextSystem),
update_clipping_system.after(TransformSystem::TransformPropagate),
widget::update_hover_status.in_set(UiSystem::Focus),
// Potential conflicts: `Assets<Image>`
// They run independently since `widget::image_node_system` will only ever observe
// its own UiImage, and `widget::text_system` & `bevy_text::update_text2d_layout`
Expand All @@ -191,6 +186,9 @@ impl Plugin for UiPlugin {
),
);

app.observe(widget::button_up_observer)
.observe(widget::button_down_observer);

#[cfg(feature = "bevy_text")]
build_text_interop(app);

Expand Down
7 changes: 2 additions & 5 deletions crates/bevy_ui/src/node_bundles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

use crate::{
widget::{Button, UiImageSize},
BackgroundColor, BorderColor, BorderRadius, ContentSize, FocusPolicy, Interaction, Node,
ScrollPosition, Style, UiImage, UiMaterial, ZIndex,
BackgroundColor, BorderColor, BorderRadius, ContentSize, FocusPolicy, Node, ScrollPosition,
Style, UiImage, UiMaterial, ZIndex,
};
use bevy_asset::Handle;
use bevy_ecs::bundle::Bundle;
Expand Down Expand Up @@ -229,8 +229,6 @@ pub struct ButtonBundle {
/// Styles which control the layout (size and position) of the node and its children
/// In some cases these styles also affect how the node drawn/painted.
pub style: Style,
/// Describes whether and how the button has been interacted with by the input
pub interaction: Interaction,
/// Whether this node should block interaction with lower nodes
pub focus_policy: FocusPolicy,
/// The color of the Node's border
Expand Down Expand Up @@ -266,7 +264,6 @@ impl Default for ButtonBundle {
node: Default::default(),
button: Default::default(),
style: Default::default(),
interaction: Default::default(),
focus_policy: FocusPolicy::Block,
border_color: Default::default(),
border_radius: Default::default(),
Expand Down
Loading
Loading