diff --git a/crates/bevy_ui/src/layout/mod.rs b/crates/bevy_ui/src/layout/mod.rs index a38209e283a8a..e94dae3fa3482 100644 --- a/crates/bevy_ui/src/layout/mod.rs +++ b/crates/bevy_ui/src/layout/mod.rs @@ -1,9 +1,9 @@ mod convert; pub mod debug; -use crate::{ContentSize, Node, Style, UiScale}; +use crate::{ContentSize, Node, Outline, Style, UiScale}; use bevy_ecs::{ - change_detection::DetectChanges, + change_detection::{DetectChanges, DetectChangesMut}, entity::Entity, event::EventReader, query::{With, Without}, @@ -386,6 +386,34 @@ pub fn ui_layout_system( } } +/// Resolve and update the widths of Node outlines +pub fn resolve_outlines_system( + primary_window: Query<&Window, With>, + ui_scale: Res, + mut outlines_query: Query<(&Outline, &mut Node)>, +) { + let viewport_size = primary_window + .get_single() + .map(|window| Vec2::new(window.resolution.width(), window.resolution.height())) + .unwrap_or(Vec2::ZERO) + / ui_scale.0 as f32; + + for (outline, mut node) in outlines_query.iter_mut() { + let node = node.bypass_change_detection(); + node.outline_width = outline + .width + .resolve(node.size().x, viewport_size) + .unwrap_or(0.) + .max(0.); + + node.outline_offset = outline + .width + .resolve(node.size().x, viewport_size) + .unwrap_or(0.) + .max(0.); + } +} + #[inline] /// Round `value` to the nearest whole integer, with ties (values with a fractional part equal to 0.5) rounded towards positive infinity. fn round_ties_up(value: f32) -> f32 { diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index 1cee4a52a1c05..ddfe5f430f446 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -67,6 +67,8 @@ pub enum UiSystem { Focus, /// After this label, the [`UiStack`] resource has been updated Stack, + /// After this label, node outline widths have been updated + Outlines, } /// The current scale of the UI. @@ -126,6 +128,7 @@ impl Plugin for UiPlugin { .register_type::() .register_type::() .register_type::() + .register_type::() .add_systems( PreUpdate, ui_focus_system.in_set(UiSystem::Focus).after(InputSystem), @@ -180,6 +183,9 @@ impl Plugin for UiPlugin { ui_layout_system .in_set(UiSystem::Layout) .before(TransformSystem::TransformPropagate), + resolve_outlines_system + .in_set(UiSystem::Outlines) + .after(UiSystem::Layout), ui_stack_system.in_set(UiSystem::Stack), update_clipping_system.after(TransformSystem::TransformPropagate), ), diff --git a/crates/bevy_ui/src/render/mod.rs b/crates/bevy_ui/src/render/mod.rs index f962298b9b1d9..5c5aca45db17d 100644 --- a/crates/bevy_ui/src/render/mod.rs +++ b/crates/bevy_ui/src/render/mod.rs @@ -10,6 +10,7 @@ use bevy_window::{PrimaryWindow, Window}; pub use pipeline::*; pub use render_pass::*; +use crate::Outline; use crate::{ prelude::UiCameraConfig, BackgroundColor, BorderColor, CalculatedClip, ContentSize, Node, Style, UiImage, UiScale, UiStack, UiTextureAtlasImage, Val, @@ -85,6 +86,7 @@ pub fn build_ui_render(app: &mut App) { extract_uinode_borders.after(RenderUiSystem::ExtractAtlasNode), #[cfg(feature = "bevy_text")] extract_text_uinodes.after(RenderUiSystem::ExtractAtlasNode), + extract_uinode_outlines.after(RenderUiSystem::ExtractAtlasNode), ), ) .add_systems( @@ -389,6 +391,99 @@ pub fn extract_uinode_borders( } } +pub fn extract_uinode_outlines( + mut commands: Commands, + mut extracted_uinodes: ResMut, + ui_stack: Extract>, + uinode_query: Extract< + Query<( + &Node, + &GlobalTransform, + &Outline, + &ViewVisibility, + Option<&Parent>, + )>, + >, + clip_query: Query<&CalculatedClip>, +) { + let image = AssetId::::default(); + + for (stack_index, entity) in ui_stack.uinodes.iter().enumerate() { + if let Ok((node, global_transform, outline, view_visibility, maybe_parent)) = + uinode_query.get(*entity) + { + // Skip invisible outlines + if !view_visibility.get() || outline.color.a() == 0. || node.outline_width == 0. { + continue; + } + + // Outline's are drawn outside of a node's borders, so they are clipped using the clipping Rect of their UI node entity's parent. + let clip = maybe_parent + .and_then(|parent| clip_query.get(parent.get()).ok().map(|clip| clip.clip)); + + // Calculate the outline rects. + let inner_rect = + Rect::from_center_size(Vec2::ZERO, node.size() + 2. * node.outline_offset); + let outer_rect = inner_rect.inset(node.outline_width()); + let outline_edges = [ + // Left edge + Rect::new( + outer_rect.min.x, + outer_rect.min.y, + inner_rect.min.x, + outer_rect.max.y, + ), + // Right edge + Rect::new( + inner_rect.max.x, + outer_rect.min.y, + outer_rect.max.x, + outer_rect.max.y, + ), + // Top edge + Rect::new( + inner_rect.min.x, + outer_rect.min.y, + inner_rect.max.x, + inner_rect.min.y, + ), + // Bottom edge + Rect::new( + inner_rect.min.x, + inner_rect.max.y, + inner_rect.max.x, + outer_rect.max.y, + ), + ]; + + let transform = global_transform.compute_matrix(); + + for edge in outline_edges { + if edge.min.x < edge.max.x && edge.min.y < edge.max.y { + extracted_uinodes.uinodes.insert( + commands.spawn_empty().id(), + 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: outline.color, + rect: Rect { + max: edge.size(), + ..Default::default() + }, + image, + atlas_size: None, + clip, + flip_x: false, + flip_y: false, + }, + ); + } + } + } + } +} + pub fn extract_uinodes( mut extracted_uinodes: ResMut, images: Extract>>, diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index 277ce3f760759..7b6209c685830 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -17,6 +17,12 @@ pub struct Node { /// The size of the node as width and height in logical pixels /// automatically calculated by [`super::layout::ui_layout_system`] pub(crate) calculated_size: Vec2, + /// The width of this node's outline + /// If this value is `Auto`, negative or `0.` then no outline will be rendered + /// automatically calculated by [`super::layout::resolve_outlines_system`] + pub(crate) outline_width: f32, + // The amount of space between the outline and the edge of the node + pub(crate) outline_offset: f32, /// The unrounded size of the node as width and height in logical pixels /// automatically calculated by [`super::layout::ui_layout_system`] pub(crate) unrounded_size: Vec2, @@ -70,11 +76,20 @@ impl Node { ), } } + + #[inline] + /// Returns the thickness of the UI node's outline. + /// If this value is negative or `0.` then no outline will be rendered. + pub fn outline_width(&self) -> f32 { + self.outline_width + } } impl Node { pub const DEFAULT: Self = Self { calculated_size: Vec2::ZERO, + outline_width: 0., + outline_offset: 0., unrounded_size: Vec2::ZERO, }; } @@ -1458,6 +1473,85 @@ impl Default for BorderColor { } } +#[derive(Component, Copy, Clone, Default, Debug, Reflect)] +#[reflect(Component, Default)] +/// The [`Outline`] component adds an outline outside the edge of a UI node. +/// Outlines do not take up space in the layout +/// +/// To add an [`Outline`] to a ui node you can spawn a `(NodeBundle, Outline)` tuple bundle: +/// ``` +/// # use bevy_ecs::prelude::*; +/// # use bevy_ui::prelude::*; +/// # use bevy_render::prelude::Color; +/// fn setup_ui(mut commands: Commands) { +/// commands.spawn(( +/// NodeBundle { +/// style: Style { +/// width: Val::Px(100.), +/// height: Val::Px(100.), +/// ..Default::default() +/// }, +/// background_color: Color::BLUE.into(), +/// ..Default::default() +/// }, +/// Outline::new(Val::Px(10.), Val::ZERO, Color::RED) +/// )); +/// } +/// ``` +/// +/// [`Outline`] components can also be added later to existing UI nodes: +/// ``` +/// # use bevy_ecs::prelude::*; +/// # use bevy_ui::prelude::*; +/// # use bevy_render::prelude::Color; +/// fn outline_hovered_button_system( +/// mut commands: Commands, +/// mut node_query: Query<(Entity, &Interaction, Option<&mut Outline>), Changed>, +/// ) { +/// for (entity, interaction, mut maybe_outline) in node_query.iter_mut() { +/// let outline_color = +/// if matches!(*interaction, Interaction::Hovered) { +/// Color::WHITE +/// } else { +/// Color::NONE +/// }; +/// if let Some(mut outline) = maybe_outline { +/// outline.color = outline_color; +/// } else { +/// commands.entity(entity).insert(Outline::new(Val::Px(10.), Val::ZERO, outline_color)); +/// } +/// } +/// } +/// ``` +/// Inserting and removing an [`Outline`] component repeatedly will result in table moves, so it is generally preferable to +/// set `Outline::color` to `Color::NONE` to hide an outline. +pub struct Outline { + /// The width of the outline. + /// + /// Percentage `Val` values are resolved based on the width of the outlined [`Node`] + pub width: Val, + /// The amount of space between a node's outline the edge of the node + /// + /// Percentage `Val` values are resolved based on the width of the outlined [`Node`] + pub offset: Val, + /// Color of the outline + /// + /// If you are frequently toggling outlines for a UI node on and off it is recommended to set `Color::None` to hide the outline. + /// This avoids the table moves that would occcur from the repeated insertion and removal of the `Outline` component. + pub color: Color, +} + +impl Outline { + /// Create a new outline + pub const fn new(width: Val, offset: Val, color: Color) -> Self { + Self { + width, + offset, + color, + } + } +} + /// The 2D texture displayed for this UI node #[derive(Component, Clone, Debug, Reflect, Default)] #[reflect(Component, Default)] diff --git a/examples/ui/borders.rs b/examples/ui/borders.rs index 9e6cf4e2b3289..32f43d7c6a0b2 100644 --- a/examples/ui/borders.rs +++ b/examples/ui/borders.rs @@ -23,7 +23,7 @@ fn setup(mut commands: Commands) { align_content: AlignContent::FlexStart, ..Default::default() }, - background_color: BackgroundColor(Color::BLACK), + background_color: BackgroundColor(Color::DARK_GRAY), ..Default::default() }) .id(); @@ -97,20 +97,27 @@ fn setup(mut commands: Commands) { }) .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, + .spawn(( + NodeBundle { + style: Style { + width: Val::Px(50.), + height: Val::Px(50.), + border: borders[i % borders.len()], + margin: UiRect::all(Val::Px(20.)), + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + ..Default::default() + }, + background_color: Color::MAROON.into(), + border_color: Color::RED.into(), ..Default::default() }, - background_color: Color::BLUE.into(), - border_color: Color::WHITE.with_a(0.5).into(), - ..Default::default() - }) + Outline { + width: Val::Px(6.), + offset: Val::Px(6.), + color: Color::WHITE, + }, + )) .add_child(inner_spot) .id(); commands.entity(root).add_child(bordered_node);