mirror of
https://github.com/bevyengine/bevy
synced 2024-11-10 07:04:33 +00:00
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>
This commit is contained in:
parent
32f40f11b5
commit
4e9a62f094
5 changed files with 158 additions and 108 deletions
|
@ -1,4 +1,7 @@
|
|||
use crate::{CalculatedClip, DefaultUiCamera, Node, TargetCamera, UiScale, UiStack};
|
||||
use crate::{
|
||||
picking_backend::pick_rounded_rect, CalculatedClip, DefaultUiCamera, Node, TargetCamera,
|
||||
UiScale, UiStack,
|
||||
};
|
||||
use bevy_ecs::{
|
||||
change_detection::DetectChangesMut,
|
||||
entity::Entity,
|
||||
|
@ -249,13 +252,12 @@ pub fn ui_focus_system(
|
|||
.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 =
|
||||
camera_cursor_positions
|
||||
.get(&camera_entity)
|
||||
.and_then(|cursor_position| {
|
||||
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)
|
||||
|
@ -270,7 +272,16 @@ pub fn ui_focus_system(
|
|||
normalized: relative_cursor_position,
|
||||
};
|
||||
|
||||
let contains_cursor = relative_cursor_position_component.mouse_over();
|
||||
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
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
use crate::{ContentSize, DefaultUiCamera, Node, Outline, Style, TargetCamera, UiScale};
|
||||
use crate::{
|
||||
BorderRadius, ContentSize, DefaultUiCamera, Node, Outline, Style, TargetCamera, UiScale,
|
||||
};
|
||||
use bevy_ecs::{
|
||||
change_detection::{DetectChanges, DetectChangesMut},
|
||||
entity::{Entity, EntityHashMap, EntityHashSet},
|
||||
|
@ -110,7 +112,12 @@ pub fn ui_layout_system(
|
|||
children_query: Query<(Entity, Ref<Children>), With<Node>>,
|
||||
just_children_query: Query<&Children>,
|
||||
mut removed_components: UiLayoutSystemRemovedComponentParam,
|
||||
mut node_transform_query: Query<(&mut Node, &mut Transform, Option<&Outline>)>,
|
||||
mut node_transform_query: Query<(
|
||||
&mut Node,
|
||||
&mut Transform,
|
||||
Option<&BorderRadius>,
|
||||
Option<&Outline>,
|
||||
)>,
|
||||
#[cfg(feature = "bevy_text")] mut buffer_query: Query<&mut CosmicBuffer>,
|
||||
#[cfg(feature = "bevy_text")] mut text_pipeline: ResMut<TextPipeline>,
|
||||
) {
|
||||
|
@ -280,13 +287,20 @@ pub fn ui_layout_system(
|
|||
entity: Entity,
|
||||
ui_surface: &UiSurface,
|
||||
root_size: Option<Vec2>,
|
||||
node_transform_query: &mut Query<(&mut Node, &mut Transform, Option<&Outline>)>,
|
||||
node_transform_query: &mut Query<(
|
||||
&mut Node,
|
||||
&mut Transform,
|
||||
Option<&BorderRadius>,
|
||||
Option<&Outline>,
|
||||
)>,
|
||||
children_query: &Query<&Children>,
|
||||
inverse_target_scale_factor: f32,
|
||||
parent_size: Vec2,
|
||||
mut absolute_location: Vec2,
|
||||
) {
|
||||
if let Ok((mut node, mut transform, outline)) = node_transform_query.get_mut(entity) {
|
||||
if let Ok((mut node, mut transform, maybe_border_radius, maybe_outline)) =
|
||||
node_transform_query.get_mut(entity)
|
||||
{
|
||||
let Ok(layout) = ui_surface.get_layout(entity) else {
|
||||
return;
|
||||
};
|
||||
|
@ -311,7 +325,13 @@ pub fn ui_layout_system(
|
|||
|
||||
let viewport_size = root_size.unwrap_or(node.calculated_size);
|
||||
|
||||
if let Some(outline) = outline {
|
||||
if let Some(border_radius) = maybe_border_radius {
|
||||
// We don't trigger change detection for changes to border radius
|
||||
node.bypass_change_detection().border_radius =
|
||||
border_radius.resolve(node.calculated_size, viewport_size);
|
||||
}
|
||||
|
||||
if let Some(outline) = maybe_outline {
|
||||
// don't trigger change detection when only outlines are changed
|
||||
let node = node.bypass_change_detection();
|
||||
node.outline_width = outline
|
||||
|
|
|
@ -163,6 +163,11 @@ pub fn ui_picking(
|
|||
if visible_rect
|
||||
.normalize(node_rect)
|
||||
.contains(relative_cursor_position)
|
||||
&& pick_rounded_rect(
|
||||
*cursor_position - node_rect.center(),
|
||||
node_rect.size(),
|
||||
node.node.border_radius,
|
||||
)
|
||||
{
|
||||
hit_nodes
|
||||
.entry((camera_entity, *pointer_id))
|
||||
|
@ -212,3 +217,26 @@ pub fn ui_picking(
|
|||
output.send(PointerHits::new(*pointer, picks, order));
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Matches the sdf function in `ui.wgsl` that is used by the UI renderer to draw rounded rectangles.
|
||||
pub(crate) fn pick_rounded_rect(
|
||||
point: Vec2,
|
||||
size: Vec2,
|
||||
border_radius: ResolvedBorderRadius,
|
||||
) -> bool {
|
||||
let s = point.signum();
|
||||
let r = (border_radius.top_left * (1. - s.x) * (1. - s.y)
|
||||
+ border_radius.top_right * (1. + s.x) * (1. - s.y)
|
||||
+ border_radius.bottom_right * (1. + s.x) * (1. + s.y)
|
||||
+ border_radius.bottom_left * (1. - s.x) * (1. + s.y))
|
||||
/ 4.;
|
||||
|
||||
let corner_to_point = point.abs() - 0.5 * size;
|
||||
let q = corner_to_point + r;
|
||||
let l = q.max(Vec2::ZERO).length();
|
||||
let m = q.max_element().min(0.);
|
||||
l + m - r < 0.
|
||||
}
|
||||
|
|
|
@ -24,8 +24,8 @@ use ui_texture_slice_pipeline::UiTextureSlicerPlugin;
|
|||
|
||||
use crate::graph::{NodeUi, SubGraphUi};
|
||||
use crate::{
|
||||
BackgroundColor, BorderColor, BorderRadius, CalculatedClip, ContentSize, DefaultUiCamera, Node,
|
||||
Outline, Style, TargetCamera, UiImage, UiScale, Val,
|
||||
BackgroundColor, BorderColor, CalculatedClip, ContentSize, DefaultUiCamera, Node, Outline,
|
||||
Style, TargetCamera, UiImage, UiScale, Val,
|
||||
};
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
@ -202,7 +202,6 @@ pub fn extract_uinode_background_colors(
|
|||
Option<&CalculatedClip>,
|
||||
Option<&TargetCamera>,
|
||||
&BackgroundColor,
|
||||
Option<&BorderRadius>,
|
||||
&Style,
|
||||
Option<&Parent>,
|
||||
)>,
|
||||
|
@ -217,7 +216,6 @@ pub fn extract_uinode_background_colors(
|
|||
clip,
|
||||
camera,
|
||||
background_color,
|
||||
border_radius,
|
||||
style,
|
||||
parent,
|
||||
) in &uinode_query
|
||||
|
@ -258,16 +256,13 @@ pub fn extract_uinode_background_colors(
|
|||
|
||||
let border = [left, top, right, bottom];
|
||||
|
||||
let border_radius = if let Some(border_radius) = border_radius {
|
||||
resolve_border_radius(
|
||||
border_radius,
|
||||
uinode.size(),
|
||||
ui_logical_viewport_size,
|
||||
ui_scale.0,
|
||||
)
|
||||
} else {
|
||||
[0.; 4]
|
||||
};
|
||||
let border_radius = [
|
||||
uinode.border_radius.top_left,
|
||||
uinode.border_radius.top_right,
|
||||
uinode.border_radius.bottom_right,
|
||||
uinode.border_radius.bottom_left,
|
||||
]
|
||||
.map(|r| r * ui_scale.0);
|
||||
|
||||
extracted_uinodes.uinodes.insert(
|
||||
entity,
|
||||
|
@ -311,7 +306,6 @@ pub fn extract_uinode_images(
|
|||
Option<&TargetCamera>,
|
||||
&UiImage,
|
||||
Option<&TextureAtlas>,
|
||||
Option<&BorderRadius>,
|
||||
Option<&Parent>,
|
||||
&Style,
|
||||
),
|
||||
|
@ -320,18 +314,8 @@ pub fn extract_uinode_images(
|
|||
>,
|
||||
node_query: Extract<Query<&Node>>,
|
||||
) {
|
||||
for (
|
||||
uinode,
|
||||
transform,
|
||||
view_visibility,
|
||||
clip,
|
||||
camera,
|
||||
image,
|
||||
atlas,
|
||||
border_radius,
|
||||
parent,
|
||||
style,
|
||||
) in &uinode_query
|
||||
for (uinode, transform, view_visibility, clip, camera, image, atlas, parent, style) in
|
||||
&uinode_query
|
||||
{
|
||||
let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get())
|
||||
else {
|
||||
|
@ -393,16 +377,13 @@ pub fn extract_uinode_images(
|
|||
|
||||
let border = [left, top, right, bottom];
|
||||
|
||||
let border_radius = if let Some(border_radius) = border_radius {
|
||||
resolve_border_radius(
|
||||
border_radius,
|
||||
uinode.size(),
|
||||
ui_logical_viewport_size,
|
||||
ui_scale.0,
|
||||
)
|
||||
} else {
|
||||
[0.; 4]
|
||||
};
|
||||
let border_radius = [
|
||||
uinode.border_radius.top_left,
|
||||
uinode.border_radius.top_right,
|
||||
uinode.border_radius.bottom_right,
|
||||
uinode.border_radius.bottom_left,
|
||||
]
|
||||
.map(|r| r * ui_scale.0);
|
||||
|
||||
extracted_uinodes.uinodes.insert(
|
||||
commands.spawn_empty().id(),
|
||||
|
@ -437,33 +418,6 @@ pub(crate) fn resolve_border_thickness(value: Val, parent_width: f32, viewport_s
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_border_radius(
|
||||
&values: &BorderRadius,
|
||||
node_size: Vec2,
|
||||
viewport_size: Vec2,
|
||||
ui_scale: f32,
|
||||
) -> [f32; 4] {
|
||||
let max_radius = 0.5 * node_size.min_element() * ui_scale;
|
||||
[
|
||||
values.top_left,
|
||||
values.top_right,
|
||||
values.bottom_right,
|
||||
values.bottom_left,
|
||||
]
|
||||
.map(|value| {
|
||||
match value {
|
||||
Val::Auto => 0.,
|
||||
Val::Px(px) => ui_scale * px,
|
||||
Val::Percent(percent) => node_size.min_element() * percent / 100.,
|
||||
Val::Vw(percent) => viewport_size.x * percent / 100.,
|
||||
Val::Vh(percent) => viewport_size.y * percent / 100.,
|
||||
Val::VMin(percent) => viewport_size.min_element() * percent / 100.,
|
||||
Val::VMax(percent) => viewport_size.max_element() * percent / 100.,
|
||||
}
|
||||
.clamp(0., max_radius)
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clamp_corner(r: f32, size: Vec2, offset: Vec2) -> f32 {
|
||||
let s = 0.5 * size + offset;
|
||||
|
@ -503,7 +457,6 @@ pub fn extract_uinode_borders(
|
|||
Option<&Parent>,
|
||||
&Style,
|
||||
&BorderColor,
|
||||
Option<&BorderRadius>,
|
||||
),
|
||||
Without<ContentSize>,
|
||||
>,
|
||||
|
@ -512,17 +465,8 @@ pub fn extract_uinode_borders(
|
|||
) {
|
||||
let image = AssetId::<Image>::default();
|
||||
|
||||
for (
|
||||
node,
|
||||
global_transform,
|
||||
view_visibility,
|
||||
clip,
|
||||
camera,
|
||||
parent,
|
||||
style,
|
||||
border_color,
|
||||
maybe_border_radius,
|
||||
) in &uinode_query
|
||||
for (uinode, global_transform, view_visibility, clip, camera, parent, style, border_color) in
|
||||
&uinode_query
|
||||
{
|
||||
let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get())
|
||||
else {
|
||||
|
@ -532,8 +476,8 @@ pub fn extract_uinode_borders(
|
|||
// Skip invisible borders
|
||||
if !view_visibility.get()
|
||||
|| border_color.0.is_fully_transparent()
|
||||
|| node.size().x <= 0.
|
||||
|| node.size().y <= 0.
|
||||
|| uinode.size().x <= 0.
|
||||
|| uinode.size().y <= 0.
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
@ -569,28 +513,26 @@ pub fn extract_uinode_borders(
|
|||
continue;
|
||||
}
|
||||
|
||||
let border_radius = if let Some(border_radius) = maybe_border_radius {
|
||||
let resolved_radius = resolve_border_radius(
|
||||
border_radius,
|
||||
node.size(),
|
||||
ui_logical_viewport_size,
|
||||
ui_scale.0,
|
||||
);
|
||||
clamp_radius(resolved_radius, node.size(), border.into())
|
||||
} else {
|
||||
[0.; 4]
|
||||
};
|
||||
let border_radius = [
|
||||
uinode.border_radius.top_left,
|
||||
uinode.border_radius.top_right,
|
||||
uinode.border_radius.bottom_right,
|
||||
uinode.border_radius.bottom_left,
|
||||
]
|
||||
.map(|r| r * ui_scale.0);
|
||||
|
||||
let border_radius = clamp_radius(border_radius, uinode.size(), border.into());
|
||||
let transform = global_transform.compute_matrix();
|
||||
|
||||
extracted_uinodes.uinodes.insert(
|
||||
commands.spawn_empty().id(),
|
||||
ExtractedUiNode {
|
||||
stack_index: node.stack_index,
|
||||
stack_index: uinode.stack_index,
|
||||
// This translates the uinode's transform to the center of the current border rectangle
|
||||
transform,
|
||||
color: border_color.0.into(),
|
||||
rect: Rect {
|
||||
max: node.size(),
|
||||
max: uinode.size(),
|
||||
..Default::default()
|
||||
},
|
||||
image,
|
||||
|
|
|
@ -48,6 +48,11 @@ pub struct Node {
|
|||
///
|
||||
/// Automatically calculated by [`super::layout::ui_layout_system`].
|
||||
pub(crate) unrounded_size: Vec2,
|
||||
/// Resolved border radius values in logical pixels.
|
||||
/// Border radius updates bypass change detection.
|
||||
///
|
||||
/// Automatically calculated by [`super::layout::ui_layout_system`].
|
||||
pub(crate) border_radius: ResolvedBorderRadius,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
|
@ -122,6 +127,7 @@ impl Node {
|
|||
outline_width: 0.,
|
||||
outline_offset: 0.,
|
||||
unrounded_size: Vec2::ZERO,
|
||||
border_radius: ResolvedBorderRadius::ZERO,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -2188,6 +2194,49 @@ impl BorderRadius {
|
|||
self.bottom_right = radius;
|
||||
self
|
||||
}
|
||||
|
||||
/// Compute the logical border radius for a single corner from the given values
|
||||
pub fn resolve_single_corner(radius: Val, node_size: Vec2, viewport_size: Vec2) -> f32 {
|
||||
match radius {
|
||||
Val::Auto => 0.,
|
||||
Val::Px(px) => px,
|
||||
Val::Percent(percent) => node_size.min_element() * percent / 100.,
|
||||
Val::Vw(percent) => viewport_size.x * percent / 100.,
|
||||
Val::Vh(percent) => viewport_size.y * percent / 100.,
|
||||
Val::VMin(percent) => viewport_size.min_element() * percent / 100.,
|
||||
Val::VMax(percent) => viewport_size.max_element() * percent / 100.,
|
||||
}
|
||||
.clamp(0., 0.5 * node_size.min_element())
|
||||
}
|
||||
|
||||
pub fn resolve(&self, node_size: Vec2, viewport_size: Vec2) -> ResolvedBorderRadius {
|
||||
ResolvedBorderRadius {
|
||||
top_left: Self::resolve_single_corner(self.top_left, node_size, viewport_size),
|
||||
top_right: Self::resolve_single_corner(self.top_right, node_size, viewport_size),
|
||||
bottom_left: Self::resolve_single_corner(self.bottom_left, node_size, viewport_size),
|
||||
bottom_right: Self::resolve_single_corner(self.bottom_right, node_size, viewport_size),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the resolved border radius values for a UI node.
|
||||
///
|
||||
/// The values are in logical pixels.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Reflect)]
|
||||
pub struct ResolvedBorderRadius {
|
||||
pub top_left: f32,
|
||||
pub top_right: f32,
|
||||
pub bottom_left: f32,
|
||||
pub bottom_right: f32,
|
||||
}
|
||||
|
||||
impl ResolvedBorderRadius {
|
||||
pub const ZERO: Self = Self {
|
||||
top_left: 0.,
|
||||
top_right: 0.,
|
||||
bottom_left: 0.,
|
||||
bottom_right: 0.,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
Loading…
Reference in a new issue