2024-08-15 14:43:55 +00:00
|
|
|
//! A picking backend for UI nodes.
|
|
|
|
//!
|
|
|
|
//! # Usage
|
|
|
|
//!
|
|
|
|
//! This backend does not require markers on cameras or entities to function. It will look for any
|
|
|
|
//! pointers using the same render target as the UI camera, and run hit tests on the UI node tree.
|
|
|
|
//!
|
|
|
|
//! ## Important Note
|
|
|
|
//!
|
|
|
|
//! This backend completely ignores [`FocusPolicy`](crate::FocusPolicy). The design of `bevy_ui`'s
|
2024-10-07 17:09:57 +00:00
|
|
|
//! focus systems and the picking plugin are not compatible. Instead, use the optional [`PickingBehavior`] component
|
|
|
|
//! to override how an entity responds to picking focus. Nodes without the [`PickingBehavior`] component
|
|
|
|
//! will still trigger events and block items below it from being hovered.
|
2024-08-15 14:43:55 +00:00
|
|
|
//!
|
|
|
|
//! ## Implementation Notes
|
|
|
|
//!
|
|
|
|
//! - `bevy_ui` can only render to the primary window
|
|
|
|
//! - `bevy_ui` can render on any camera with a flag, it is special, and is not tied to a particular
|
|
|
|
//! camera.
|
|
|
|
//! - To correctly sort picks, the order of `bevy_ui` is set to be the camera order plus 0.5.
|
|
|
|
|
|
|
|
#![allow(clippy::type_complexity)]
|
|
|
|
#![allow(clippy::too_many_arguments)]
|
|
|
|
#![deny(missing_docs)]
|
|
|
|
|
2024-09-05 19:16:57 +00:00
|
|
|
use crate::{focus::pick_rounded_rect, prelude::*, UiStack};
|
2024-08-15 14:43:55 +00:00
|
|
|
use bevy_app::prelude::*;
|
|
|
|
use bevy_ecs::{prelude::*, query::QueryData};
|
2024-09-26 23:10:35 +00:00
|
|
|
use bevy_math::{Rect, Vec2};
|
2024-08-15 14:43:55 +00:00
|
|
|
use bevy_render::prelude::*;
|
|
|
|
use bevy_transform::prelude::*;
|
|
|
|
use bevy_utils::hashbrown::HashMap;
|
|
|
|
use bevy_window::PrimaryWindow;
|
|
|
|
|
|
|
|
use bevy_picking::backend::prelude::*;
|
|
|
|
|
|
|
|
/// A plugin that adds picking support for UI nodes.
|
|
|
|
#[derive(Clone)]
|
2024-10-25 20:11:51 +00:00
|
|
|
pub struct UiPickingPlugin;
|
|
|
|
impl Plugin for UiPickingPlugin {
|
2024-08-15 14:43:55 +00:00
|
|
|
fn build(&self, app: &mut App) {
|
|
|
|
app.add_systems(PreUpdate, ui_picking.in_set(PickSet::Backend));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Main query from bevy's `ui_focus_system`
|
|
|
|
#[derive(QueryData)]
|
|
|
|
#[query_data(mutable)]
|
|
|
|
pub struct NodeQuery {
|
|
|
|
entity: Entity,
|
Merge Style properties into Node. Use ComputedNode for computed properties. (#15975)
# Objective
Continue improving the user experience of our UI Node API in the
direction specified by [Bevy's Next Generation Scene / UI
System](https://github.com/bevyengine/bevy/discussions/14437)
## Solution
As specified in the document above, merge `Style` fields into `Node`,
and move "computed Node fields" into `ComputedNode` (I chose this name
over something like `ComputedNodeLayout` because it currently contains
more than just layout info. If we want to break this up / rename these
concepts, lets do that in a separate PR). `Style` has been removed.
This accomplishes a number of goals:
## Ergonomics wins
Specifying both `Node` and `Style` is now no longer required for
non-default styles
Before:
```rust
commands.spawn((
Node::default(),
Style {
width: Val::Px(100.),
..default()
},
));
```
After:
```rust
commands.spawn(Node {
width: Val::Px(100.),
..default()
});
```
## Conceptual clarity
`Style` was never a comprehensive "style sheet". It only defined "core"
style properties that all `Nodes` shared. Any "styled property" that
couldn't fit that mold had to be in a separate component. A "real" style
system would style properties _across_ components (`Node`, `Button`,
etc). We have plans to build a true style system (see the doc linked
above).
By moving the `Style` fields to `Node`, we fully embrace `Node` as the
driving concept and remove the "style system" confusion.
## Next Steps
* Consider identifying and splitting out "style properties that aren't
core to Node". This should not happen for Bevy 0.15.
---
## Migration Guide
Move any fields set on `Style` into `Node` and replace all `Style`
component usage with `Node`.
Before:
```rust
commands.spawn((
Node::default(),
Style {
width: Val::Px(100.),
..default()
},
));
```
After:
```rust
commands.spawn(Node {
width: Val::Px(100.),
..default()
});
```
For any usage of the "computed node properties" that used to live on
`Node`, use `ComputedNode` instead:
Before:
```rust
fn system(nodes: Query<&Node>) {
for node in &nodes {
let computed_size = node.size();
}
}
```
After:
```rust
fn system(computed_nodes: Query<&ComputedNode>) {
for computed_node in &computed_nodes {
let computed_size = computed_node.size();
}
}
```
2024-10-18 22:25:33 +00:00
|
|
|
node: &'static ComputedNode,
|
2024-08-15 14:43:55 +00:00
|
|
|
global_transform: &'static GlobalTransform,
|
2024-10-07 17:09:57 +00:00
|
|
|
picking_behavior: Option<&'static PickingBehavior>,
|
2024-08-15 14:43:55 +00:00
|
|
|
calculated_clip: Option<&'static CalculatedClip>,
|
|
|
|
view_visibility: Option<&'static ViewVisibility>,
|
|
|
|
target_camera: Option<&'static TargetCamera>,
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Computes the UI node entities under each pointer.
|
|
|
|
///
|
|
|
|
/// Bevy's [`UiStack`] orders all nodes in the order they will be rendered, which is the same order
|
|
|
|
/// we need for determining picking.
|
|
|
|
pub fn ui_picking(
|
|
|
|
pointers: Query<(&PointerId, &PointerLocation)>,
|
|
|
|
camera_query: Query<(Entity, &Camera, Has<IsDefaultUiCamera>)>,
|
|
|
|
default_ui_camera: DefaultUiCamera,
|
|
|
|
primary_window: Query<Entity, With<PrimaryWindow>>,
|
|
|
|
ui_stack: Res<UiStack>,
|
2024-10-03 21:30:52 +00:00
|
|
|
node_query: Query<NodeQuery>,
|
2024-08-15 14:43:55 +00:00
|
|
|
mut output: EventWriter<PointerHits>,
|
|
|
|
) {
|
|
|
|
// For each camera, the pointer and its position
|
|
|
|
let mut pointer_pos_by_camera = HashMap::<Entity, HashMap<PointerId, Vec2>>::new();
|
|
|
|
|
|
|
|
for (pointer_id, pointer_location) in
|
|
|
|
pointers.iter().filter_map(|(pointer, pointer_location)| {
|
|
|
|
Some(*pointer).zip(pointer_location.location().cloned())
|
|
|
|
})
|
|
|
|
{
|
|
|
|
// This pointer is associated with a render target, which could be used by multiple
|
|
|
|
// cameras. We want to ensure we return all cameras with a matching target.
|
|
|
|
for camera in camera_query
|
|
|
|
.iter()
|
|
|
|
.map(|(entity, camera, _)| {
|
|
|
|
(
|
|
|
|
entity,
|
|
|
|
camera.target.normalize(primary_window.get_single().ok()),
|
|
|
|
)
|
|
|
|
})
|
|
|
|
.filter_map(|(entity, target)| Some(entity).zip(target))
|
|
|
|
.filter(|(_entity, target)| target == &pointer_location.target)
|
|
|
|
.map(|(cam_entity, _target)| cam_entity)
|
|
|
|
{
|
|
|
|
let Ok((_, camera_data, _)) = camera_query.get(camera) else {
|
|
|
|
continue;
|
|
|
|
};
|
2024-11-22 00:45:07 +00:00
|
|
|
let mut pointer_pos =
|
|
|
|
pointer_location.position * camera_data.target_scaling_factor().unwrap_or(1.);
|
|
|
|
if let Some(viewport) = camera_data.physical_viewport_rect() {
|
|
|
|
pointer_pos -= viewport.min.as_vec2();
|
2024-08-15 14:43:55 +00:00
|
|
|
}
|
|
|
|
pointer_pos_by_camera
|
|
|
|
.entry(camera)
|
|
|
|
.or_default()
|
2024-11-22 00:45:07 +00:00
|
|
|
.insert(pointer_id, pointer_pos);
|
2024-08-15 14:43:55 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// The list of node entities hovered for each (camera, pointer) combo
|
|
|
|
let mut hit_nodes = HashMap::<(Entity, PointerId), Vec<Entity>>::new();
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
for node_entity in ui_stack
|
|
|
|
.uinodes
|
|
|
|
.iter()
|
|
|
|
// reverse the iterator to traverse the tree from closest nodes to furthest
|
|
|
|
.rev()
|
|
|
|
{
|
2024-10-03 21:30:52 +00:00
|
|
|
let Ok(node) = node_query.get(*node_entity) else {
|
2024-08-15 14:43:55 +00:00
|
|
|
continue;
|
|
|
|
};
|
|
|
|
|
|
|
|
// Nodes that are not rendered should not be interactable
|
|
|
|
if node
|
|
|
|
.view_visibility
|
|
|
|
.map(|view_visibility| view_visibility.get())
|
|
|
|
!= Some(true)
|
|
|
|
{
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
let Some(camera_entity) = node
|
|
|
|
.target_camera
|
|
|
|
.map(TargetCamera::entity)
|
|
|
|
.or(default_ui_camera.get())
|
|
|
|
else {
|
|
|
|
continue;
|
|
|
|
};
|
|
|
|
|
2024-09-26 23:10:35 +00:00
|
|
|
let node_rect = Rect::from_center_size(
|
|
|
|
node.global_transform.translation().truncate(),
|
|
|
|
node.node.size(),
|
|
|
|
);
|
2024-08-15 14:43:55 +00:00
|
|
|
|
|
|
|
// Nodes with Display::None have a (0., 0.) logical rect and can be ignored
|
|
|
|
if node_rect.size() == Vec2::ZERO {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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 pointers_on_this_cam = pointer_pos_by_camera.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.
|
|
|
|
for (pointer_id, cursor_position) in pointers_on_this_cam.iter().flat_map(|h| h.iter()) {
|
|
|
|
let relative_cursor_position = (*cursor_position - node_rect.min) / node_rect.size();
|
|
|
|
|
|
|
|
if visible_rect
|
|
|
|
.normalize(node_rect)
|
|
|
|
.contains(relative_cursor_position)
|
Ignore clicks on uinodes outside of rounded corners (#14957)
# Objective
Fixes #14941
## Solution
1. Add a `resolved_border_radius` field to `Node` to hold the resolved
border radius values.
2. Remove the border radius calculations from the UI's extraction
functions.
4. Compute the border radius during UI relayouts in `ui_layout_system`
and store them in `Node`.
5. New `pick_rounded_rect` function based on the border radius SDF from
`ui.wgsl`.
6. Use `pick_rounded_rect` in `focus` and `picking_backend` to check if
the pointer is hovering UI nodes with rounded corners.
---
## Showcase
```
cargo run --example button
```
https://github.com/user-attachments/assets/ea951a64-17ef-455e-b5c9-a2e6f6360648
## Testing
Modified button example with buttons with different corner radius:
```
use bevy::{color::palettes::basic::*, prelude::*, winit::WinitSettings};
fn main() {
App::new()
.add_plugins(DefaultPlugins)
// Only run the app when there is user input. This will significantly reduce CPU/GPU use.
.insert_resource(WinitSettings::desktop_app())
.add_systems(Startup, setup)
.add_systems(Update, button_system)
.run();
}
const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);
fn button_system(
mut interaction_query: Query<
(
&Interaction,
&mut BackgroundColor,
&mut BorderColor,
&Children,
),
(Changed<Interaction>, With<Button>),
>,
mut text_query: Query<&mut Text>,
) {
for (interaction, mut color, mut border_color, children) in &mut interaction_query {
let mut text = text_query.get_mut(children[0]).unwrap();
match *interaction {
Interaction::Pressed => {
text.sections[0].value = "Press".to_string();
*color = PRESSED_BUTTON.into();
border_color.0 = RED.into();
}
Interaction::Hovered => {
text.sections[0].value = "Hover".to_string();
*color = HOVERED_BUTTON.into();
border_color.0 = Color::WHITE;
}
Interaction::None => {
text.sections[0].value = "Button".to_string();
*color = NORMAL_BUTTON.into();
border_color.0 = Color::BLACK;
}
}
}
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
// ui camera
commands.spawn(Camera2dBundle::default());
commands
.spawn(NodeBundle {
style: Style {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
row_gap: Val::Px(10.),
..default()
},
..default()
})
.with_children(|parent| {
for border_radius in [
BorderRadius {
top_left: Val::ZERO,
..BorderRadius::MAX
},
BorderRadius {
top_right: Val::ZERO,
..BorderRadius::MAX
},
BorderRadius {
bottom_right: Val::ZERO,
..BorderRadius::MAX
},
BorderRadius {
bottom_left: Val::ZERO,
..BorderRadius::MAX
},
] {
parent
.spawn(ButtonBundle {
style: Style {
width: Val::Px(150.0),
height: Val::Px(65.0),
border: UiRect::all(Val::Px(5.0)),
// horizontally center child text
justify_content: JustifyContent::Center,
// vertically center child text
align_items: AlignItems::Center,
..default()
},
border_color: BorderColor(Color::BLACK),
border_radius,
background_color: NORMAL_BUTTON.into(),
..default()
})
.with_child(TextBundle::from_section(
"Button",
TextStyle {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 40.0,
color: Color::srgb(0.9, 0.9, 0.9),
},
));
}
});
}
```
---------
Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
Co-authored-by: Matty <weatherleymatthew@gmail.com>
2024-09-03 12:38:59 +00:00
|
|
|
&& pick_rounded_rect(
|
|
|
|
*cursor_position - node_rect.center(),
|
|
|
|
node_rect.size(),
|
|
|
|
node.node.border_radius,
|
|
|
|
)
|
2024-08-15 14:43:55 +00:00
|
|
|
{
|
|
|
|
hit_nodes
|
|
|
|
.entry((camera_entity, *pointer_id))
|
|
|
|
.or_default()
|
|
|
|
.push(*node_entity);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
for ((camera, pointer), hovered_nodes) in hit_nodes.iter() {
|
|
|
|
// 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 picks = Vec::new();
|
|
|
|
let mut depth = 0.0;
|
|
|
|
|
2024-10-03 21:30:52 +00:00
|
|
|
for node in node_query.iter_many(hovered_nodes) {
|
2024-08-15 14:43:55 +00:00
|
|
|
let Some(camera_entity) = node
|
|
|
|
.target_camera
|
|
|
|
.map(TargetCamera::entity)
|
|
|
|
.or(default_ui_camera.get())
|
|
|
|
else {
|
|
|
|
continue;
|
|
|
|
};
|
|
|
|
|
|
|
|
picks.push((node.entity, HitData::new(camera_entity, depth, None, None)));
|
|
|
|
|
2024-10-07 17:09:57 +00:00
|
|
|
if let Some(picking_behavior) = node.picking_behavior {
|
|
|
|
// If an entity has a `PickingBehavior` component, we will use that as the source of truth.
|
|
|
|
if picking_behavior.should_block_lower {
|
2024-08-15 14:43:55 +00:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
} else {
|
2024-10-07 17:09:57 +00:00
|
|
|
// If the PickingBehavior component doesn't exist, default behavior is to block.
|
2024-08-15 14:43:55 +00:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
depth += 0.00001; // keep depth near 0 for precision
|
|
|
|
}
|
|
|
|
|
|
|
|
let order = camera_query
|
|
|
|
.get(*camera)
|
|
|
|
.map(|(_, cam, _)| cam.order)
|
|
|
|
.unwrap_or_default() as f32
|
|
|
|
+ 0.5; // bevy ui can run on any camera, it's a special case
|
|
|
|
|
|
|
|
output.send(PointerHits::new(*pointer, picks, order));
|
|
|
|
}
|
|
|
|
}
|