From f7aa83a247475abde4539c94cf853e3cf225d0ac Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Wed, 14 Jun 2023 23:43:38 +0100 Subject: [PATCH] Ui Node Borders (#7795) # Objective Implement borders for UI nodes. Relevant discussion: #7785 Related: #5924, #3991 borders ## 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 --- Cargo.toml | 20 +++ crates/bevy_ui/src/lib.rs | 1 + crates/bevy_ui/src/node_bundles.rs | 9 +- crates/bevy_ui/src/render/mod.rs | 125 ++++++++++++++- crates/bevy_ui/src/ui_node.rs | 21 +++ examples/README.md | 2 + examples/ui/borders.rs | 117 ++++++++++++++ examples/ui/button.rs | 14 +- examples/ui/ui.rs | 1 + examples/ui/viewport_debug.rs | 241 +++++++++++++++++++++++++++++ 10 files changed, 547 insertions(+), 4 deletions(-) create mode 100644 examples/ui/borders.rs create mode 100644 examples/ui/viewport_debug.rs diff --git a/Cargo.toml b/Cargo.toml index 4168bcbb78083..355b0a52aed6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index b48e7714495a5..6b41048277203 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -116,6 +116,7 @@ impl Plugin for UiPlugin { .register_type::() .register_type::() .register_type::() + .register_type::() .register_type::() .register_type::() .register_type::() diff --git a/crates/bevy_ui/src/node_bundles.rs b/crates/bevy_ui/src/node_bundles.rs index dbd5f74f057ef..dd04f780e83a4 100644 --- a/crates/bevy_ui/src/node_bundles.rs +++ b/crates/bevy_ui/src/node_bundles.rs @@ -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(), diff --git a/crates/bevy_ui/src/render/mod.rs b/crates/bevy_ui/src/render/mod.rs index f203c4b227565..18e3bb560d8c2 100644 --- a/crates/bevy_ui/src/render/mod.rs +++ b/crates/bevy_ui/src/render/mod.rs @@ -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::, extract_default_ui_camera_view::, 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, } +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, + windows: Extract>>, + ui_stack: Extract>, + uinode_query: Extract< + Query< + ( + &Node, + &GlobalTransform, + &Style, + &BorderColor, + Option<&Parent>, + &ComputedVisibility, + Option<&CalculatedClip>, + ), + Without, + >, + >, + parent_node_query: Extract>>, +) { + 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 + // + 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, images: Extract>>, @@ -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) diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index e9857566e0c83..96a69972024ad 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -1563,6 +1563,27 @@ impl From 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 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)] diff --git a/examples/README.md b/examples/README.md index 4751f537cfabf..904125a84b8b9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 diff --git a/examples/ui/borders.rs b/examples/ui/borders.rs new file mode 100644 index 0000000000000..bf6cac544ce75 --- /dev/null +++ b/examples/ui/borders.rs @@ -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); + } +} diff --git a/examples/ui/button.rs b/examples/ui/button.rs index aed15cb1142fe..288ad8d2c32f9 100644 --- a/examples/ui/button.rs +++ b/examples/ui/button.rs @@ -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, With