Skip to content

Commit

Permalink
Ignore clicks on uinodes outside of rounded corners (bevyengine#14957)
Browse files Browse the repository at this point in the history
# Objective

Fixes bevyengine#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>
  • Loading branch information
3 people committed Sep 3, 2024
1 parent 32f40f1 commit 4e9a62f
Show file tree
Hide file tree
Showing 5 changed files with 158 additions and 108 deletions.
35 changes: 23 additions & 12 deletions crates/bevy_ui/src/focus.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -249,19 +252,18 @@ 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| {
// 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())
});
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
Expand All @@ -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
Expand Down
30 changes: 25 additions & 5 deletions crates/bevy_ui/src/layout/mod.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand Down Expand Up @@ -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>,
) {
Expand Down Expand Up @@ -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;
};
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions crates/bevy_ui/src/picking_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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.
}
124 changes: 33 additions & 91 deletions crates/bevy_ui/src/render/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -202,7 +202,6 @@ pub fn extract_uinode_background_colors(
Option<&CalculatedClip>,
Option<&TargetCamera>,
&BackgroundColor,
Option<&BorderRadius>,
&Style,
Option<&Parent>,
)>,
Expand All @@ -217,7 +216,6 @@ pub fn extract_uinode_background_colors(
clip,
camera,
background_color,
border_radius,
style,
parent,
) in &uinode_query
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -311,7 +306,6 @@ pub fn extract_uinode_images(
Option<&TargetCamera>,
&UiImage,
Option<&TextureAtlas>,
Option<&BorderRadius>,
Option<&Parent>,
&Style,
),
Expand All @@ -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 {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -503,7 +457,6 @@ pub fn extract_uinode_borders(
Option<&Parent>,
&Style,
&BorderColor,
Option<&BorderRadius>,
),
Without<ContentSize>,
>,
Expand All @@ -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 {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 4e9a62f

Please sign in to comment.