From 6d3965f520121c3aacb13d0ea559afdff11c76ff Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Wed, 16 Oct 2024 14:17:49 +0100 Subject: [PATCH] Overflow clip margin (#15561) # Objective Limited implementation of the CSS property `overflow-clip-margin` https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-clip-margin Allows you to control the visible area for clipped content when using overfllow-clip, -hidden, or -scroll and expand it with a margin. Based on #15442 Fixes #15468 ## Solution Adds a new field to Style: `overflow_clip_margin: OverflowClipMargin`. The field is ignored unless overflow-clip, -hidden or -scroll is set on at least one axis. `OverflowClipMargin` has these associated constructor functions: ``` pub const fn content_box() -> Self; pub const fn padding_box() -> Self; pub const fn border_box() -> Self; ``` You can also use the method `with_margin` to increases the size of the visible area: ``` commands .spawn(NodeBundle { style: Style { width: Val::Px(100.), height: Val::Px(100.), padding: UiRect::all(Val::Px(20.)), border: UiRect::all(Val::Px(5.)), overflow: Overflow::clip(), overflow_clip_margin: OverflowClipMargin::border_box().with_margin(25.), ..Default::default() }, border_color: Color::BLACK.into(), background_color: GRAY.into(), ..Default::default() }) ``` `with_margin` expects a length in logical pixels, negative values are clamped to zero. ## Notes * To keep this PR as simple as possible I omitted responsive margin values support. This could be added in a follow up if we want it. * CSS also supports a `margin-box` option but we don't have access to the margin values in `Node` so it's probably not feasible to implement atm. ## Testing ```cargo run --example overflow_clip_margin``` overflow-clip-margin ## Migration Guide Style has a new field `OverflowClipMargin`. It allows users to set the visible area for clipped content when using overflow-clip, -hidden, or -scroll and expand it with a margin. There are three associated constructor functions `content_box`, `padding_box` and `border_box`: * `content_box`: elements painted outside of the content box area (the innermost part of the node excluding the padding and border) of the node are clipped. This is the new default behaviour. * `padding_box`: elements painted outside outside of the padding area of the node are clipped. * `border_box`: elements painted outside of the bounds of the node are clipped. This matches the behaviour from Bevy 0.14. There is also a `with_margin` method that increases the size of the visible area by the given number in logical pixels, negative margin values are clamped to zero. `OverflowClipMargin` is ignored unless overflow-clip, -hidden or -scroll is also set on at least one axis of the UI node. --------- Co-authored-by: UkoeHB <37489173+UkoeHB@users.noreply.github.com> --- Cargo.toml | 12 ++++ crates/bevy_ui/src/layout/convert.rs | 1 + crates/bevy_ui/src/ui_node.rs | 78 ++++++++++++++++++++ crates/bevy_ui/src/update.rs | 37 ++++++---- examples/README.md | 1 + examples/ui/overflow_clip_margin.rs | 102 +++++++++++++++++++++++++++ 6 files changed, 216 insertions(+), 15 deletions(-) create mode 100644 examples/ui/overflow_clip_margin.rs diff --git a/Cargo.toml b/Cargo.toml index efc4ea636f..eff543fbf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2977,6 +2977,18 @@ description = "Simple example demonstrating overflow behavior" category = "UI (User Interface)" wasm = true +[[example]] +name = "overflow_clip_margin" +path = "examples/ui/overflow_clip_margin.rs" +doc-scrape-examples = true + +[package.metadata.example.overflow_clip_margin] +name = "Overflow Clip Margin" +description = "Simple example demonstrating the OverflowClipMargin style property" +category = "UI (User Interface)" +wasm = true + + [[example]] name = "overflow_debug" path = "examples/ui/overflow_debug.rs" diff --git a/crates/bevy_ui/src/layout/convert.rs b/crates/bevy_ui/src/layout/convert.rs index f936855c11..6ee10ae0bf 100644 --- a/crates/bevy_ui/src/layout/convert.rs +++ b/crates/bevy_ui/src/layout/convert.rs @@ -492,6 +492,7 @@ mod tests { max_height: Val::ZERO, aspect_ratio: None, overflow: crate::Overflow::clip(), + overflow_clip_margin: crate::OverflowClipMargin::default(), column_gap: Val::ZERO, row_gap: Val::ZERO, grid_auto_flow: GridAutoFlow::ColumnDense, diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index d1def12ce0..f6ebd0f0d5 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -308,6 +308,11 @@ pub struct Style { /// pub overflow: Overflow, + /// How the bounds of clipped content should be determined + /// + /// + pub overflow_clip_margin: OverflowClipMargin, + /// The horizontal position of the left edge of the node. /// - For relatively positioned nodes, this is relative to the node's position as computed during regular layout. /// - For absolutely positioned nodes, this is relative to the *parent* node's bounding box. @@ -585,6 +590,7 @@ impl Style { max_height: Val::Auto, aspect_ratio: None, overflow: Overflow::DEFAULT, + overflow_clip_margin: OverflowClipMargin::DEFAULT, row_gap: Val::ZERO, column_gap: Val::ZERO, grid_auto_flow: GridAutoFlow::DEFAULT, @@ -1042,6 +1048,78 @@ impl Default for OverflowAxis { } } +/// The bounds of the visible area when a UI node is clipped. +#[derive(Default, Copy, Clone, PartialEq, Debug, Reflect)] +#[reflect(Default, PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct OverflowClipMargin { + /// Visible unclipped area + pub visual_box: OverflowClipBox, + /// Width of the margin on each edge of the visual box in logical pixels. + /// The width of the margin will be zero if a negative value is set. + pub margin: f32, +} + +impl OverflowClipMargin { + pub const DEFAULT: Self = Self { + visual_box: OverflowClipBox::ContentBox, + margin: 0., + }; + + /// Clip any content that overflows outside the content box + pub const fn content_box() -> Self { + Self { + visual_box: OverflowClipBox::ContentBox, + ..Self::DEFAULT + } + } + + /// Clip any content that overflows outside the padding box + pub const fn padding_box() -> Self { + Self { + visual_box: OverflowClipBox::PaddingBox, + ..Self::DEFAULT + } + } + + /// Clip any content that overflows outside the border box + pub const fn border_box() -> Self { + Self { + visual_box: OverflowClipBox::BorderBox, + ..Self::DEFAULT + } + } + + /// Add a margin on each edge of the visual box in logical pixels. + /// The width of the margin will be zero if a negative value is set. + pub const fn with_margin(mut self, margin: f32) -> Self { + self.margin = margin; + self + } +} + +/// Used to determine the bounds of the visible area when a UI node is clipped. +#[derive(Default, Copy, Clone, PartialEq, Eq, Debug, Reflect)] +#[reflect(Default, PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub enum OverflowClipBox { + /// Clip any content that overflows outside the content box + #[default] + ContentBox, + /// Clip any content that overflows outside the padding box + PaddingBox, + /// Clip any content that overflows outside the border box + BorderBox, +} + /// The strategy used to position this node #[derive(Copy, Clone, PartialEq, Eq, Debug, Reflect)] #[reflect(Default, PartialEq)] diff --git a/crates/bevy_ui/src/update.rs b/crates/bevy_ui/src/update.rs index 87abca8ad9..22647e2502 100644 --- a/crates/bevy_ui/src/update.rs +++ b/crates/bevy_ui/src/update.rs @@ -9,6 +9,7 @@ use bevy_ecs::{ system::{Commands, Query}, }; use bevy_math::Rect; +use bevy_sprite::BorderRect; use bevy_transform::components::GlobalTransform; use bevy_utils::HashSet; @@ -80,29 +81,35 @@ fn update_clipping( // of nested `Overflow::Hidden` nodes. If parent `clip` is not // defined, use the current node's clip. - let mut node_rect = + let mut clip_rect = Rect::from_center_size(global_transform.translation().truncate(), node.size()); - // Content isn't clipped at the edges of the node but at the edges of its content box. - // The content box is innermost part of the node excluding the padding and border. + // Content isn't clipped at the edges of the node but at the edges of the region specified by [`Style::overflow_clip_margin`]. // - // The `content_inset` should always fit inside the `node_rect`. - // Even if it were to overflow, this won't result in a degenerate clipping rect as `Rect::intersect` clamps the intersection to an empty rect. - let content_inset = node.content_inset(); - node_rect.min.x += content_inset.left; - node_rect.min.y += content_inset.top; - node_rect.max.x -= content_inset.right; - node_rect.max.y -= content_inset.bottom; + // `clip_inset` should always fit inside `node_rect`. + // Even if `clip_inset` were to overflow, we won't return a degenerate result as `Rect::intersect` will clamp the intersection, leaving it empty. + let clip_inset = match style.overflow_clip_margin.visual_box { + crate::OverflowClipBox::BorderBox => BorderRect::ZERO, + crate::OverflowClipBox::ContentBox => node.content_inset(), + crate::OverflowClipBox::PaddingBox => node.border(), + }; + + clip_rect.min.x += clip_inset.left; + clip_rect.min.y += clip_inset.top; + clip_rect.max.x -= clip_inset.right; + clip_rect.max.y -= clip_inset.bottom; + + clip_rect = clip_rect.inflate(style.overflow_clip_margin.margin.max(0.)); if style.overflow.x == OverflowAxis::Visible { - node_rect.min.x = -f32::INFINITY; - node_rect.max.x = f32::INFINITY; + clip_rect.min.x = -f32::INFINITY; + clip_rect.max.x = f32::INFINITY; } if style.overflow.y == OverflowAxis::Visible { - node_rect.min.y = -f32::INFINITY; - node_rect.max.y = f32::INFINITY; + clip_rect.min.y = -f32::INFINITY; + clip_rect.max.y = f32::INFINITY; } - Some(maybe_inherited_clip.map_or(node_rect, |c| c.intersect(node_rect))) + Some(maybe_inherited_clip.map_or(clip_rect, |c| c.intersect(clip_rect))) }; for child in ui_children.iter_ui_children(entity) { diff --git a/examples/README.md b/examples/README.md index 7fdd705ac6..3b6359ad71 100644 --- a/examples/README.md +++ b/examples/README.md @@ -506,6 +506,7 @@ Example | Description [Font Atlas Debug](../examples/ui/font_atlas_debug.rs) | Illustrates how FontAtlases are populated (used to optimize text rendering internally) [Ghost Nodes](../examples/ui/ghost_nodes.rs) | Demonstrates the use of Ghost Nodes to skip entities in the UI layout hierarchy [Overflow](../examples/ui/overflow.rs) | Simple example demonstrating overflow behavior +[Overflow Clip Margin](../examples/ui/overflow_clip_margin.rs) | Simple example demonstrating the OverflowClipMargin style property [Overflow and Clipping Debug](../examples/ui/overflow_debug.rs) | An example to debug overflow and clipping behavior [Relative Cursor Position](../examples/ui/relative_cursor_position.rs) | Showcases the RelativeCursorPosition component [Render UI to Texture](../examples/ui/render_ui_to_texture.rs) | An example of rendering UI as a part of a 3D world diff --git a/examples/ui/overflow_clip_margin.rs b/examples/ui/overflow_clip_margin.rs new file mode 100644 index 0000000000..6d5690bbfb --- /dev/null +++ b/examples/ui/overflow_clip_margin.rs @@ -0,0 +1,102 @@ +//! Simple example demonstrating the `OverflowClipMargin` style property. + +use bevy::{color::palettes::css::*, 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) + .run(); +} + +fn setup(mut commands: Commands, asset_server: Res) { + commands.spawn(Camera2d); + + let image = asset_server.load("branding/icon.png"); + + commands + .spawn(NodeBundle { + style: Style { + width: Val::Percent(100.), + height: Val::Percent(100.), + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + row_gap: Val::Px(40.), + flex_direction: FlexDirection::Column, + ..Default::default() + }, + background_color: ANTIQUE_WHITE.into(), + ..Default::default() + }) + .with_children(|parent| { + for overflow_clip_margin in [ + OverflowClipMargin::border_box().with_margin(25.), + OverflowClipMargin::border_box(), + OverflowClipMargin::padding_box(), + OverflowClipMargin::content_box(), + ] { + parent + .spawn(NodeBundle { + style: Style { + flex_direction: FlexDirection::Row, + column_gap: Val::Px(20.), + ..Default::default() + }, + ..Default::default() + }) + .with_children(|parent| { + parent + .spawn(NodeBundle { + style: Style { + padding: UiRect::all(Val::Px(10.)), + margin: UiRect::bottom(Val::Px(25.)), + ..Default::default() + }, + background_color: Color::srgb(0.25, 0.25, 0.25).into(), + ..Default::default() + }) + .with_child(Text(format!("{overflow_clip_margin:#?}"))); + + parent + .spawn(NodeBundle { + style: Style { + margin: UiRect::top(Val::Px(10.)), + width: Val::Px(100.), + height: Val::Px(100.), + padding: UiRect::all(Val::Px(20.)), + border: UiRect::all(Val::Px(5.)), + overflow: Overflow::clip(), + overflow_clip_margin, + ..Default::default() + }, + border_color: Color::BLACK.into(), + background_color: GRAY.into(), + ..Default::default() + }) + .with_children(|parent| { + parent + .spawn(NodeBundle { + style: Style { + min_width: Val::Px(50.), + min_height: Val::Px(50.), + ..Default::default() + }, + background_color: LIGHT_CYAN.into(), + ..Default::default() + }) + .with_child(ImageBundle { + image: UiImage::new(image.clone()), + style: Style { + min_width: Val::Px(100.), + min_height: Val::Px(100.), + ..Default::default() + }, + ..Default::default() + }); + }); + }); + } + }); +}