Ui Node Borders (#7795)

# Objective

Implement borders for UI nodes.

Relevant discussion: #7785
Related: #5924, #3991

<img width="283" alt="borders"
src="https://user-images.githubusercontent.com/27962798/220968899-7661d5ec-6f5b-4b0f-af29-bf9af02259b5.PNG">

## Solution

Add an extraction function to draw the borders.

---

Can only do one colour rectangular borders due to the limitations of the
Bevy UI renderer.

Maybe it can be combined with #3991 eventually to add curved border
support.

## Changelog
* Added a component `BorderColor`.
* Added the `extract_uinode_borders` system to the UI Render App.
* Added the UI example `borders`

---------

Co-authored-by: Nico Burns <nico@nicoburns.com>
This commit is contained in:
ickshonpe 2023-06-14 23:43:38 +01:00 committed by GitHub
parent 2551ccbe34
commit f7aa83a247
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 547 additions and 4 deletions

View file

@ -1762,6 +1762,16 @@ category = "Transforms"
wasm = true
# UI (User Interface)
[[example]]
name = "borders"
path = "examples/ui/borders.rs"
[package.metadata.example.borders]
name = "Borders"
description = "Demonstrates how to create a node with a border"
category = "UI (User Interface)"
wasm = true
[[example]]
name = "button"
path = "examples/ui/button.rs"
@ -1923,6 +1933,16 @@ description = "Illustrates how to scale the UI"
category = "UI (User Interface)"
wasm = true
[[example]]
name = "viewport_debug"
path = "examples/ui/viewport_debug.rs"
[package.metadata.example.viewport_debug]
name = "Viewport Debug"
description = "An example for debugging viewport coordinates"
category = "UI (User Interface)"
wasm = true
# Window
[[example]]
name = "clear_color"

View file

@ -116,6 +116,7 @@ impl Plugin for UiPlugin {
.register_type::<UiImageSize>()
.register_type::<UiRect>()
.register_type::<Val>()
.register_type::<BorderColor>()
.register_type::<widget::Button>()
.register_type::<widget::Label>()
.register_type::<ZIndex>()

View file

@ -2,7 +2,8 @@
use crate::{
widget::{Button, TextFlags, UiImageSize},
BackgroundColor, ContentSize, FocusPolicy, Interaction, Node, Style, UiImage, ZIndex,
BackgroundColor, BorderColor, ContentSize, FocusPolicy, Interaction, Node, Style, UiImage,
ZIndex,
};
use bevy_ecs::bundle::Bundle;
use bevy_render::{
@ -25,6 +26,8 @@ pub struct NodeBundle {
pub style: Style,
/// The background color, which serves as a "fill" for this node
pub background_color: BackgroundColor,
/// The color of the Node's border
pub border_color: BorderColor,
/// Whether this node should block interaction with lower nodes
pub focus_policy: FocusPolicy,
/// The transform of the node
@ -50,6 +53,7 @@ impl Default for NodeBundle {
NodeBundle {
// Transparent background
background_color: Color::NONE.into(),
border_color: Color::NONE.into(),
node: Default::default(),
style: Default::default(),
focus_policy: Default::default(),
@ -225,6 +229,8 @@ pub struct ButtonBundle {
///
/// When combined with `UiImage`, tints the provided image.
pub background_color: BackgroundColor,
/// The color of the Node's border
pub border_color: BorderColor,
/// The image of the node
pub image: UiImage,
/// The transform of the node
@ -252,6 +258,7 @@ impl Default for ButtonBundle {
node: Default::default(),
button: Default::default(),
style: Default::default(),
border_color: BorderColor(Color::NONE),
interaction: Default::default(),
background_color: Default::default(),
image: Default::default(),

View file

@ -2,13 +2,17 @@ mod pipeline;
mod render_pass;
use bevy_core_pipeline::{core_2d::Camera2d, core_3d::Camera3d};
use bevy_hierarchy::Parent;
use bevy_render::{ExtractSchedule, Render};
#[cfg(feature = "bevy_text")]
use bevy_window::{PrimaryWindow, Window};
pub use pipeline::*;
pub use render_pass::*;
use crate::{prelude::UiCameraConfig, BackgroundColor, CalculatedClip, Node, UiImage, UiStack};
use crate::{
prelude::UiCameraConfig, BackgroundColor, BorderColor, CalculatedClip, Node, UiImage, UiStack,
};
use crate::{ContentSize, Style, Val};
use bevy_app::prelude::*;
use bevy_asset::{load_internal_asset, AssetEvent, Assets, Handle, HandleUntyped};
use bevy_ecs::prelude::*;
@ -78,6 +82,7 @@ pub fn build_ui_render(app: &mut App) {
extract_default_ui_camera_view::<Camera2d>,
extract_default_ui_camera_view::<Camera3d>,
extract_uinodes.in_set(RenderUiSystem::ExtractNode),
extract_uinode_borders.after(RenderUiSystem::ExtractNode),
#[cfg(feature = "bevy_text")]
extract_text_uinodes.after(RenderUiSystem::ExtractNode),
),
@ -161,6 +166,123 @@ pub struct ExtractedUiNodes {
pub uinodes: Vec<ExtractedUiNode>,
}
fn resolve_border_thickness(value: Val, parent_width: f32, viewport_size: Vec2) -> f32 {
match value {
Val::Auto => 0.,
Val::Px(px) => px.max(0.),
Val::Percent(percent) => (parent_width * percent / 100.).max(0.),
Val::Vw(percent) => (viewport_size.x * percent / 100.).max(0.),
Val::Vh(percent) => (viewport_size.y * percent / 100.).max(0.),
Val::VMin(percent) => (viewport_size.min_element() * percent / 100.).max(0.),
Val::VMax(percent) => (viewport_size.max_element() * percent / 100.).max(0.),
}
}
pub fn extract_uinode_borders(
mut extracted_uinodes: ResMut<ExtractedUiNodes>,
windows: Extract<Query<&Window, With<PrimaryWindow>>>,
ui_stack: Extract<Res<UiStack>>,
uinode_query: Extract<
Query<
(
&Node,
&GlobalTransform,
&Style,
&BorderColor,
Option<&Parent>,
&ComputedVisibility,
Option<&CalculatedClip>,
),
Without<ContentSize>,
>,
>,
parent_node_query: Extract<Query<&Node, With<Parent>>>,
) {
let image = bevy_render::texture::DEFAULT_IMAGE_HANDLE.typed();
let viewport_size = windows
.get_single()
.map(|window| Vec2::new(window.resolution.width(), window.resolution.height()))
.unwrap_or(Vec2::ZERO);
for (stack_index, entity) in ui_stack.uinodes.iter().enumerate() {
if let Ok((node, global_transform, style, border_color, parent, visibility, clip)) =
uinode_query.get(*entity)
{
// Skip invisible borders
if !visibility.is_visible()
|| border_color.0.a() == 0.0
|| node.size().x <= 0.
|| node.size().y <= 0.
{
continue;
}
// Both vertical and horizontal percentage border values are calculated based on the width of the parent node
// <https://developer.mozilla.org/en-US/docs/Web/CSS/border-width>
let parent_width = parent
.and_then(|parent| parent_node_query.get(parent.get()).ok())
.map(|parent_node| parent_node.size().x)
.unwrap_or(viewport_size.x);
let left = resolve_border_thickness(style.border.left, parent_width, viewport_size);
let right = resolve_border_thickness(style.border.right, parent_width, viewport_size);
let top = resolve_border_thickness(style.border.top, parent_width, viewport_size);
let bottom = resolve_border_thickness(style.border.bottom, parent_width, viewport_size);
// Calculate the border rects, ensuring no overlap.
// The border occupies the space between the node's bounding rect and the node's bounding rect inset in each direction by the node's corresponding border value.
let max = 0.5 * node.size();
let min = -max;
let inner_min = min + Vec2::new(left, top);
let inner_max = (max - Vec2::new(right, bottom)).max(inner_min);
let border_rects = [
// Left border
Rect {
min,
max: Vec2::new(inner_min.x, max.y),
},
// Right border
Rect {
min: Vec2::new(inner_max.x, min.y),
max,
},
// Top border
Rect {
min: Vec2::new(inner_min.x, min.y),
max: Vec2::new(inner_max.x, inner_min.y),
},
// Bottom border
Rect {
min: Vec2::new(inner_min.x, inner_max.y),
max: Vec2::new(inner_max.x, max.y),
},
];
let transform = global_transform.compute_matrix();
for edge in border_rects {
if edge.min.x < edge.max.x && edge.min.y < edge.max.y {
extracted_uinodes.uinodes.push(ExtractedUiNode {
stack_index,
// This translates the uinode's transform to the center of the current border rectangle
transform: transform * Mat4::from_translation(edge.center().extend(0.)),
color: border_color.0,
rect: Rect {
max: edge.size(),
..Default::default()
},
image: image.clone_weak(),
atlas_size: None,
clip: clip.map(|clip| clip.clip),
flip_x: false,
flip_y: false,
});
}
}
}
}
}
pub fn extract_uinodes(
mut extracted_uinodes: ResMut<ExtractedUiNodes>,
images: Extract<Res<Assets<Image>>>,
@ -177,6 +299,7 @@ pub fn extract_uinodes(
>,
) {
extracted_uinodes.uinodes.clear();
for (stack_index, entity) in ui_stack.uinodes.iter().enumerate() {
if let Ok((uinode, transform, color, maybe_image, visibility, clip)) =
uinode_query.get(*entity)

View file

@ -1563,6 +1563,27 @@ impl From<Color> for BackgroundColor {
}
}
/// The border color of the UI node.
#[derive(Component, Copy, Clone, Debug, Reflect, FromReflect)]
#[reflect(FromReflect, Component, Default)]
pub struct BorderColor(pub Color);
impl From<Color> for BorderColor {
fn from(color: Color) -> Self {
Self(color)
}
}
impl BorderColor {
pub const DEFAULT: Self = BorderColor(Color::WHITE);
}
impl Default for BorderColor {
fn default() -> Self {
Self::DEFAULT
}
}
/// The 2D texture displayed for this UI node
#[derive(Component, Clone, Debug, Reflect)]
#[reflect(Component, Default)]

View file

@ -335,6 +335,7 @@ Example | Description
Example | Description
--- | ---
[Borders](../examples/ui/borders.rs) | Demonstrates how to create a node with a border
[Button](../examples/ui/button.rs) | Illustrates creating and updating a button
[CSS Grid](../examples/ui/grid.rs) | An example for CSS Grid layout
[Flex Layout](../examples/ui/flex_layout.rs) | Demonstrates how the AlignItems and JustifyContent properties can be composed to layout nodes and position text
@ -350,6 +351,7 @@ Example | Description
[UI](../examples/ui/ui.rs) | Illustrates various features of Bevy UI
[UI Scaling](../examples/ui/ui_scaling.rs) | Illustrates how to scale the UI
[UI Z-Index](../examples/ui/z_index.rs) | Demonstrates how to control the relative depth (z-position) of UI elements
[Viewport Debug](../examples/ui/viewport_debug.rs) | An example for debugging viewport coordinates
[Window Fallthrough](../examples/ui/window_fallthrough.rs) | Illustrates how to access `winit::window::Window`'s `hittest` functionality.
## Window

117
examples/ui/borders.rs Normal file
View file

@ -0,0 +1,117 @@
//! Example demonstrating bordered UI nodes
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.run();
}
fn setup(mut commands: Commands) {
commands.spawn(Camera2dBundle::default());
let root = commands
.spawn(NodeBundle {
style: Style {
flex_basis: Val::Percent(100.0),
margin: UiRect::all(Val::Px(25.0)),
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::FlexStart,
align_content: AlignContent::FlexStart,
..Default::default()
},
background_color: BackgroundColor(Color::BLACK),
..Default::default()
})
.id();
// all the different combinations of border edges
let borders = [
UiRect::default(),
UiRect::all(Val::Px(10.)),
UiRect::left(Val::Px(10.)),
UiRect::right(Val::Px(10.)),
UiRect::top(Val::Px(10.)),
UiRect::bottom(Val::Px(10.)),
UiRect::horizontal(Val::Px(10.)),
UiRect::vertical(Val::Px(10.)),
UiRect {
left: Val::Px(10.),
top: Val::Px(10.),
..Default::default()
},
UiRect {
left: Val::Px(10.),
bottom: Val::Px(10.),
..Default::default()
},
UiRect {
right: Val::Px(10.),
top: Val::Px(10.),
..Default::default()
},
UiRect {
right: Val::Px(10.),
bottom: Val::Px(10.),
..Default::default()
},
UiRect {
right: Val::Px(10.),
top: Val::Px(10.),
bottom: Val::Px(10.),
..Default::default()
},
UiRect {
left: Val::Px(10.),
top: Val::Px(10.),
bottom: Val::Px(10.),
..Default::default()
},
UiRect {
left: Val::Px(10.),
right: Val::Px(10.),
top: Val::Px(10.),
..Default::default()
},
UiRect {
left: Val::Px(10.),
right: Val::Px(10.),
bottom: Val::Px(10.),
..Default::default()
},
];
for i in 0..64 {
let inner_spot = commands
.spawn(NodeBundle {
style: Style {
width: Val::Px(10.),
height: Val::Px(10.),
..Default::default()
},
background_color: Color::YELLOW.into(),
..Default::default()
})
.id();
let bordered_node = commands
.spawn(NodeBundle {
style: Style {
width: Val::Px(50.),
height: Val::Px(50.),
border: borders[i % borders.len()],
margin: UiRect::all(Val::Px(2.)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..Default::default()
},
background_color: Color::BLUE.into(),
border_color: Color::WHITE.with_a(0.5).into(),
..Default::default()
})
.add_child(inner_spot)
.id();
commands.entity(root).add_child(bordered_node);
}
}

View file

@ -19,25 +19,33 @@ const PRESSED_BUTTON: Color = Color::rgb(0.35, 0.75, 0.35);
fn button_system(
mut interaction_query: Query<
(&Interaction, &mut BackgroundColor, &Children),
(
&Interaction,
&mut BackgroundColor,
&mut BorderColor,
&Children,
),
(Changed<Interaction>, With<Button>),
>,
mut text_query: Query<&mut Text>,
) {
for (interaction, mut color, children) in &mut interaction_query {
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::Clicked => {
text.sections[0].value = "Press".to_string();
*color = PRESSED_BUTTON.into();
border_color.0 = Color::RED;
}
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;
}
}
}
@ -62,12 +70,14 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
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),
background_color: NORMAL_BUTTON.into(),
..default()
})

View file

@ -165,6 +165,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
border: UiRect::all(Val::Px(20.)),
..default()
},
border_color: Color::GREEN.into(),
background_color: Color::rgb(0.4, 0.4, 1.).into(),
..default()
})

View file

@ -0,0 +1,241 @@
//! An example for debugging viewport coordinates
use bevy::prelude::*;
const PALETTE: [Color; 10] = [
Color::ORANGE,
Color::BLUE,
Color::WHITE,
Color::BEIGE,
Color::CYAN,
Color::CRIMSON,
Color::NAVY,
Color::AZURE,
Color::GREEN,
Color::BLACK,
];
#[derive(Default, Debug, Hash, Eq, PartialEq, Clone, States)]
enum Coords {
#[default]
Viewport,
Pixel,
}
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
resolution: [800., 600.].into(),
title: "Viewport Coordinates Debug".to_string(),
resizable: false,
..Default::default()
}),
..Default::default()
}))
.add_state::<Coords>()
.add_systems(Startup, setup)
.add_systems(OnEnter(Coords::Viewport), spawn_with_viewport_coords)
.add_systems(OnEnter(Coords::Pixel), spawn_with_pixel_coords)
.add_systems(OnExit(Coords::Viewport), despawn_nodes)
.add_systems(OnExit(Coords::Pixel), despawn_nodes)
.add_systems(Update, update)
.run();
}
fn despawn_nodes(mut commands: Commands, query: Query<Entity, With<Node>>) {
for entity in query.iter() {
commands.entity(entity).despawn();
}
}
fn update(
mut timer: Local<f32>,
time: Res<Time>,
state: Res<State<Coords>>,
mut next_state: ResMut<NextState<Coords>>,
) {
*timer += time.delta_seconds();
if 1. <= *timer {
*timer = 0.;
next_state.set(if *state.get() == Coords::Viewport {
Coords::Pixel
} else {
Coords::Viewport
});
}
}
fn setup(mut commands: Commands) {
commands.spawn(Camera2dBundle::default());
}
fn spawn_with_viewport_coords(mut commands: Commands) {
commands
.spawn(NodeBundle {
style: Style {
width: Val::Vw(100.),
height: Val::Vh(100.),
border: UiRect::axes(Val::Vw(5.), Val::Vh(5.)),
flex_wrap: FlexWrap::Wrap,
..default()
},
background_color: PALETTE[0].into(),
border_color: PALETTE[1].into(),
..default()
})
.with_children(|builder| {
builder.spawn(NodeBundle {
style: Style {
width: Val::Vw(30.),
height: Val::Vh(30.),
border: UiRect::all(Val::VMin(5.)),
..default()
},
background_color: PALETTE[2].into(),
border_color: PALETTE[9].into(),
..default()
});
builder.spawn(NodeBundle {
style: Style {
width: Val::Vw(60.),
height: Val::Vh(30.),
..default()
},
background_color: PALETTE[3].into(),
..default()
});
builder.spawn(NodeBundle {
style: Style {
width: Val::Vw(45.),
height: Val::Vh(30.),
border: UiRect::left(Val::VMax(45. / 2.)),
..default()
},
background_color: PALETTE[4].into(),
border_color: PALETTE[8].into(),
..default()
});
builder.spawn(NodeBundle {
style: Style {
width: Val::Vw(45.),
height: Val::Vh(30.),
border: UiRect::right(Val::VMax(45. / 2.)),
..default()
},
background_color: PALETTE[5].into(),
border_color: PALETTE[8].into(),
..default()
});
builder.spawn(NodeBundle {
style: Style {
width: Val::Vw(60.),
height: Val::Vh(30.),
..default()
},
background_color: PALETTE[6].into(),
..default()
});
builder.spawn(NodeBundle {
style: Style {
width: Val::Vw(30.),
height: Val::Vh(30.),
border: UiRect::all(Val::VMin(5.)),
..default()
},
background_color: PALETTE[7].into(),
border_color: PALETTE[9].into(),
..default()
});
});
}
fn spawn_with_pixel_coords(mut commands: Commands) {
commands
.spawn(NodeBundle {
style: Style {
width: Val::Px(800.),
height: Val::Px(600.),
border: UiRect::axes(Val::Px(40.), Val::Px(30.)),
flex_wrap: FlexWrap::Wrap,
..default()
},
background_color: PALETTE[1].into(),
border_color: PALETTE[0].into(),
..default()
})
.with_children(|builder| {
builder.spawn(NodeBundle {
style: Style {
width: Val::Px(240.),
height: Val::Px(180.),
border: UiRect::axes(Val::Px(30.), Val::Px(30.)),
..default()
},
background_color: PALETTE[2].into(),
border_color: PALETTE[9].into(),
..default()
});
builder.spawn(NodeBundle {
style: Style {
width: Val::Px(480.),
height: Val::Px(180.),
..default()
},
background_color: PALETTE[3].into(),
..default()
});
builder.spawn(NodeBundle {
style: Style {
width: Val::Px(360.),
height: Val::Px(180.),
border: UiRect::left(Val::Px(180.)),
..default()
},
background_color: PALETTE[4].into(),
border_color: PALETTE[8].into(),
..default()
});
builder.spawn(NodeBundle {
style: Style {
width: Val::Px(360.),
height: Val::Px(180.),
border: UiRect::right(Val::Px(180.)),
..default()
},
background_color: PALETTE[5].into(),
border_color: PALETTE[8].into(),
..default()
});
builder.spawn(NodeBundle {
style: Style {
width: Val::Px(480.),
height: Val::Px(180.),
..default()
},
background_color: PALETTE[6].into(),
..default()
});
builder.spawn(NodeBundle {
style: Style {
width: Val::Px(240.),
height: Val::Px(180.),
border: UiRect::axes(Val::Px(30.), Val::Px(30.)),
..default()
},
background_color: PALETTE[7].into(),
border_color: PALETTE[9].into(),
..default()
});
});
}