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```

<img width="396" alt="overflow-clip-margin" src="https://github.com/user-attachments/assets/07b51cd6-a565-4451-87a0-fa079429b04b">

## 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>
This commit is contained in:
ickshonpe 2024-10-16 14:17:49 +01:00 committed by GitHub
parent 87c33da139
commit 6d3965f520
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 216 additions and 15 deletions

View file

@ -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"

View file

@ -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,

View file

@ -308,6 +308,11 @@ pub struct Style {
/// <https://developer.mozilla.org/en-US/docs/Web/CSS/overflow>
pub overflow: Overflow,
/// How the bounds of clipped content should be determined
///
/// <https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-clip-margin>
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)]

View file

@ -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) {

View file

@ -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

View file

@ -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<AssetServer>) {
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()
});
});
});
}
});
}