separate border colors (#18682)

# Objective

allow specifying the left/top/right/bottom border colors separately for
ui elements

fixes #14773

## Solution

- change `BorderColor` to 
```rs
pub struct BorderColor {
    pub left: Color,
    pub top: Color,
    pub right: Color,
    pub bottom: Color,
}
```
- generate one ui node per distinct border color, set flags for the
active borders
- render only the active borders

i chose to do this rather than adding multiple colors to the
ExtractedUiNode in order to minimize the impact for the common case
where all border colors are the same.

## Testing

modified the `borders` example to use separate colors:


![image](https://github.com/user-attachments/assets/5d9a4492-429a-4ee1-9656-215511886164)

the behaviour is a bit weird but it mirrors html/css border behaviour.

---

## Migration:

To keep the existing behaviour, just change `BorderColor(color)` into
`BorderColor::all(color)`.

---------

Co-authored-by: ickshonpe <david.curthoys@googlemail.com>
This commit is contained in:
robtfm 2025-05-26 18:57:13 +02:00 committed by GitHub
parent fb2d79ad60
commit b641aa0ecf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 194 additions and 85 deletions

View File

@ -1,3 +1,4 @@
use crate::shader_flags;
use crate::ui_node::ComputedNodeTarget;
use crate::CalculatedClip;
use crate::ComputedNode;
@ -106,7 +107,7 @@ pub fn extract_debug_overlay(
flip_y: false,
border: BorderRect::all(debug_options.line_width / uinode.inverse_scale_factor()),
border_radius: uinode.border_radius(),
node_type: NodeType::Border,
node_type: NodeType::Border(shader_flags::BORDER_ALL),
},
main_entity: entity.into(),
});

View File

@ -32,6 +32,8 @@ use bevy_sprite::BorderRect;
use bevy_transform::prelude::GlobalTransform;
use bytemuck::{Pod, Zeroable};
use super::shader_flags::BORDER_ALL;
pub const UI_GRADIENT_SHADER_HANDLE: Handle<Shader> =
weak_handle!("10116113-aac4-47fa-91c8-35cbe80dddcb");
@ -388,7 +390,7 @@ pub fn extract_gradients(
for (gradients, node_type) in [
(gradient.map(|g| &g.0), NodeType::Rect),
(gradient_border.map(|g| &g.0), NodeType::Border),
(gradient_border.map(|g| &g.0), NodeType::Border(BORDER_ALL)),
]
.iter()
.filter_map(|(g, n)| g.map(|g| (g, *n)))
@ -742,8 +744,8 @@ pub fn prepare_gradient(
let uvs = { [Vec2::ZERO, Vec2::X, Vec2::ONE, Vec2::Y] };
let mut flags = if gradient.node_type == NodeType::Border {
shader_flags::BORDER
let mut flags = if let NodeType::Border(borders) = gradient.node_type {
borders
} else {
0
};

View File

@ -229,7 +229,7 @@ pub struct ExtractedUiNode {
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum NodeType {
Rect,
Border,
Border(u32), // shader flags
}
pub enum ExtractedUiItem {
@ -522,30 +522,63 @@ pub fn extract_uinode_borders(
// Don't extract borders with zero width along all edges
if computed_node.border() != BorderRect::ZERO {
if let Some(border_color) = maybe_border_color.filter(|bc| !bc.0.is_fully_transparent())
{
extracted_uinodes.uinodes.push(ExtractedUiNode {
stack_index: computed_node.stack_index,
color: border_color.0.into(),
rect: Rect {
max: computed_node.size(),
..Default::default()
},
image,
clip: maybe_clip.map(|clip| clip.clip),
extracted_camera_entity,
item: ExtractedUiItem::Node {
atlas_scaling: None,
transform: global_transform.compute_matrix(),
flip_x: false,
flip_y: false,
border: computed_node.border(),
border_radius: computed_node.border_radius(),
node_type: NodeType::Border,
},
main_entity: entity.into(),
render_entity: commands.spawn(TemporaryRenderEntity).id(),
});
if let Some(border_color) = maybe_border_color {
let border_colors = [
border_color.left.to_linear(),
border_color.top.to_linear(),
border_color.right.to_linear(),
border_color.bottom.to_linear(),
];
const BORDER_FLAGS: [u32; 4] = [
shader_flags::BORDER_LEFT,
shader_flags::BORDER_TOP,
shader_flags::BORDER_RIGHT,
shader_flags::BORDER_BOTTOM,
];
let mut completed_flags = 0;
for (i, &color) in border_colors.iter().enumerate() {
if color.is_fully_transparent() {
continue;
}
let mut border_flags = BORDER_FLAGS[i];
if completed_flags & border_flags != 0 {
continue;
}
for j in i + 1..4 {
if color == border_colors[j] {
border_flags |= BORDER_FLAGS[j];
}
}
completed_flags |= border_flags;
extracted_uinodes.uinodes.push(ExtractedUiNode {
stack_index: computed_node.stack_index,
color,
rect: Rect {
max: computed_node.size(),
..Default::default()
},
image,
clip: maybe_clip.map(|clip| clip.clip),
extracted_camera_entity,
item: ExtractedUiItem::Node {
atlas_scaling: None,
transform: global_transform.compute_matrix(),
flip_x: false,
flip_y: false,
border: computed_node.border(),
border_radius: computed_node.border_radius(),
node_type: NodeType::Border(border_flags),
},
main_entity: entity.into(),
render_entity: commands.spawn(TemporaryRenderEntity).id(),
});
}
}
}
@ -574,7 +607,7 @@ pub fn extract_uinode_borders(
flip_y: false,
border: BorderRect::all(computed_node.outline_width()),
border_radius: computed_node.outline_radius(),
node_type: NodeType::Border,
node_type: NodeType::Border(shader_flags::BORDER_ALL),
},
main_entity: entity.into(),
});
@ -1081,11 +1114,15 @@ pub mod shader_flags {
pub const TEXTURED: u32 = 1;
/// Ordering: top left, top right, bottom right, bottom left.
pub const CORNERS: [u32; 4] = [0, 2, 2 | 4, 4];
pub const BORDER: u32 = 8;
pub const RADIAL: u32 = 16;
pub const FILL_START: u32 = 32;
pub const FILL_END: u32 = 64;
pub const CONIC: u32 = 128;
pub const BORDER_LEFT: u32 = 256;
pub const BORDER_TOP: u32 = 512;
pub const BORDER_RIGHT: u32 = 1024;
pub const BORDER_BOTTOM: u32 = 2048;
pub const BORDER_ALL: u32 = BORDER_LEFT + BORDER_TOP + BORDER_RIGHT + BORDER_BOTTOM;
}
pub fn queue_uinodes(
@ -1394,8 +1431,8 @@ pub fn prepare_uinodes(
};
let color = extracted_uinode.color.to_f32_array();
if *node_type == NodeType::Border {
flags |= shader_flags::BORDER;
if let NodeType::Border(border_flags) = *node_type {
flags |= border_flags;
}
for i in 0..4 {

View File

@ -5,7 +5,12 @@
const TEXTURED = 1u;
const RIGHT_VERTEX = 2u;
const BOTTOM_VERTEX = 4u;
const BORDER: u32 = 8u;
// must align with BORDER_* shader_flags from bevy_ui/render/mod.rs
const BORDER_LEFT: u32 = 256u;
const BORDER_TOP: u32 = 512u;
const BORDER_RIGHT: u32 = 1024u;
const BORDER_BOTTOM: u32 = 2048u;
const BORDER_ANY: u32 = BORDER_LEFT + BORDER_TOP + BORDER_RIGHT + BORDER_BOTTOM;
fn enabled(flags: u32, mask: u32) -> bool {
return (flags & mask) != 0u;
@ -116,6 +121,27 @@ fn sd_inset_rounded_box(point: vec2<f32>, size: vec2<f32>, radius: vec4<f32>, in
return sd_rounded_box(inner_point, inner_size, r);
}
fn nearest_border_active(point_vs_mid: vec2<f32>, size: vec2<f32>, width: vec4<f32>, flags: u32) -> bool {
if (flags & BORDER_ANY) == BORDER_ANY {
return true;
}
// get point vs top left
let point = clamp(point_vs_mid + size * 0.49999, vec2(0.0), size);
let left = point.x / width.x;
let top = point.y / width.y;
let right = (size.x - point.x) / width.z;
let bottom = (size.y - point.y) / width.w;
let min_dist = min(min(left, top), min(right, bottom));
return (enabled(flags, BORDER_LEFT) && min_dist == left) ||
(enabled(flags, BORDER_TOP) && min_dist == top) ||
(enabled(flags, BORDER_RIGHT) && min_dist == right) ||
(enabled(flags, BORDER_BOTTOM) && min_dist == bottom);
}
// get alpha for antialiasing for sdf
fn antialias(distance: f32) -> f32 {
// Using the fwidth(distance) was causing artifacts, so just use the distance.
@ -128,6 +154,7 @@ fn draw_uinode_border(
size: vec2<f32>,
radius: vec4<f32>,
border: vec4<f32>,
flags: u32,
) -> vec4<f32> {
// Signed distances. The magnitude is the distance of the point from the edge of the shape.
// * Negative values indicate that the point is inside the shape.
@ -147,6 +174,9 @@ fn draw_uinode_border(
// outside the outside edge, or inside the inner edge have positive signed distance.
let border_distance = max(external_distance, -internal_distance);
// check if this node should apply color for the nearest border
let nearest_border = select(0.0, 1.0, nearest_border_active(point, size, border, flags));
#ifdef ANTI_ALIAS
// At external edges with no border, `border_distance` is equal to zero.
// This select statement ensures we only perform anti-aliasing where a non-zero width border
@ -158,7 +188,7 @@ fn draw_uinode_border(
#endif
// Blend mode ALPHA_BLENDING is used for UI elements, so we don't premultiply alpha here.
return vec4(color.rgb, saturate(color.a * t));
return vec4(color.rgb, saturate(color.a * t * nearest_border));
}
fn draw_uinode_background(
@ -188,8 +218,8 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// This allows us to draw both textured and untextured shapes together in the same batch.
let color = select(in.color, in.color * texture_color, enabled(in.flags, TEXTURED));
if enabled(in.flags, BORDER) {
return draw_uinode_border(color, in.point, in.size, in.radius, in.border);
if enabled(in.flags, BORDER_ANY) {
return draw_uinode_border(color, in.point, in.size, in.radius, in.border, in.flags);
} else {
return draw_uinode_background(color, in.point, in.size, in.radius, in.border);
}

View File

@ -1,5 +1,5 @@
use crate::{FocusPolicy, UiRect, Val};
use bevy_color::Color;
use bevy_color::{Alpha, Color};
use bevy_derive::{Deref, DerefMut};
use bevy_ecs::{prelude::*, system::SystemParam};
use bevy_math::{vec4, Rect, UVec2, Vec2, Vec4Swizzles};
@ -2036,17 +2036,40 @@ impl<T: Into<Color>> From<T> for BackgroundColor {
derive(serde::Serialize, serde::Deserialize),
reflect(Serialize, Deserialize)
)]
pub struct BorderColor(pub Color);
pub struct BorderColor {
pub top: Color,
pub right: Color,
pub bottom: Color,
pub left: Color,
}
impl<T: Into<Color>> From<T> for BorderColor {
fn from(color: T) -> Self {
Self(color.into())
Self::all(color.into())
}
}
impl BorderColor {
/// Border color is transparent by default.
pub const DEFAULT: Self = BorderColor(Color::NONE);
pub const DEFAULT: Self = BorderColor::all(Color::NONE);
/// Helper to create a `BorderColor` struct with all borders set to the given color
pub const fn all(color: Color) -> Self {
Self {
top: color,
bottom: color,
left: color,
right: color,
}
}
/// Check if all contained border colors are transparent
pub fn is_fully_transparent(&self) -> bool {
self.top.is_fully_transparent()
&& self.bottom.is_fully_transparent()
&& self.left.is_fully_transparent()
&& self.right.is_fully_transparent()
}
}
impl Default for BorderColor {

View File

@ -252,7 +252,7 @@ fn add_button_for_value(
margin: UiRect::right(Val::Px(12.0)),
..default()
},
BorderColor(Color::WHITE),
BorderColor::all(Color::WHITE),
BorderRadius::MAX,
BackgroundColor(Color::BLACK),
))

View File

@ -137,7 +137,7 @@ fn setup(
align_items: AlignItems::Center,
..default()
},
BorderColor(Color::WHITE),
BorderColor::all(Color::WHITE),
BackgroundColor(Color::srgb(0.25, 0.25, 0.25)),
))
.with_children(|parent| {

View File

@ -295,7 +295,7 @@ fn setup_node_rects(commands: &mut Commands) {
justify_content: JustifyContent::Center,
..default()
},
BorderColor(WHITE.into()),
BorderColor::all(WHITE.into()),
Outline::new(Val::Px(1.), Val::ZERO, Color::WHITE),
));
@ -349,7 +349,7 @@ fn setup_node_lines(commands: &mut Commands) {
border: UiRect::bottom(Val::Px(1.0)),
..default()
},
BorderColor(WHITE.into()),
BorderColor::all(WHITE.into()),
));
}
@ -364,7 +364,7 @@ fn setup_node_lines(commands: &mut Commands) {
border: UiRect::left(Val::Px(1.0)),
..default()
},
BorderColor(WHITE.into()),
BorderColor::all(WHITE.into()),
));
}
}

View File

@ -255,7 +255,7 @@ fn add_mask_group_control(
margin: UiRect::ZERO,
..default()
},
BorderColor(Color::WHITE),
BorderColor::all(Color::WHITE),
BorderRadius::all(Val::Px(3.0)),
BackgroundColor(Color::BLACK),
))
@ -292,7 +292,7 @@ fn add_mask_group_control(
border: UiRect::top(Val::Px(1.0)),
..default()
},
BorderColor(Color::WHITE),
BorderColor::all(Color::WHITE),
))
.with_children(|builder| {
for (index, label) in [
@ -321,7 +321,7 @@ fn add_mask_group_control(
},
..default()
},
BorderColor(Color::WHITE),
BorderColor::all(Color::WHITE),
AnimationControl {
group_id: mask_group_id,
label: *label,

View File

@ -28,7 +28,7 @@ pub struct RadioButtonText;
pub const BUTTON_BORDER: UiRect = UiRect::all(Val::Px(1.0));
/// The color of the border that surrounds buttons.
pub const BUTTON_BORDER_COLOR: BorderColor = BorderColor(Color::WHITE);
pub const BUTTON_BORDER_COLOR: BorderColor = BorderColor::all(Color::WHITE);
/// The amount of rounding to apply to button corners.
pub const BUTTON_BORDER_RADIUS_SIZE: Val = Val::Px(6.0);

View File

@ -221,7 +221,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
justify_content: JustifyContent::Center,
..default()
},
BorderColor(LIME.into()),
BorderColor::all(LIME.into()),
BackgroundColor(Color::srgb(0.8, 0.8, 1.)),
))
.with_children(|parent| {

View File

@ -227,7 +227,7 @@ mod borders {
..default()
},
BackgroundColor(MAROON.into()),
BorderColor(RED.into()),
BorderColor::all(RED.into()),
Outline {
width: Val::Px(10.),
offset: Val::Px(10.),
@ -319,7 +319,7 @@ mod box_shadow {
border: UiRect::all(Val::Px(2.)),
..default()
},
BorderColor(WHITE.into()),
BorderColor::all(WHITE.into()),
border_radius,
BackgroundColor(BLUE.into()),
BoxShadow::new(
@ -417,7 +417,7 @@ mod overflow {
overflow,
..default()
},
BorderColor(RED.into()),
BorderColor::all(RED.into()),
BackgroundColor(Color::WHITE),
))
.with_children(|parent| {
@ -519,7 +519,7 @@ mod layout_rounding {
..Default::default()
},
BackgroundColor(MAROON.into()),
BorderColor(DARK_BLUE.into()),
BorderColor::all(DARK_BLUE.into()),
));
}
});

View File

@ -120,7 +120,12 @@ fn setup(mut commands: Commands) {
..default()
},
BackgroundColor(MAROON.into()),
BorderColor(RED.into()),
BorderColor {
top: RED.into(),
bottom: YELLOW.into(),
left: GREEN.into(),
right: BLUE.into(),
},
Outline {
width: Val::Px(6.),
offset: Val::Px(6.),
@ -182,7 +187,12 @@ fn setup(mut commands: Commands) {
..default()
},
BackgroundColor(MAROON.into()),
BorderColor(RED.into()),
BorderColor {
top: RED.into(),
bottom: YELLOW.into(),
left: GREEN.into(),
right: BLUE.into(),
},
BorderRadius::px(
border_size(border.left, border.top),
border_size(border.right, border.top),

View File

@ -202,7 +202,7 @@ fn setup(mut commands: Commands) {
border: UiRect::all(Val::Px(4.)),
..default()
},
BorderColor(LIGHT_SKY_BLUE.into()),
BorderColor::all(LIGHT_SKY_BLUE.into()),
BorderRadius::all(Val::Px(20.)),
BackgroundColor(DEEP_SKY_BLUE.into()),
BoxShadow(vec![
@ -253,7 +253,7 @@ fn box_shadow_node_bundle(
border: UiRect::all(Val::Px(4.)),
..default()
},
BorderColor(LIGHT_SKY_BLUE.into()),
BorderColor::all(LIGHT_SKY_BLUE.into()),
border_radius,
BackgroundColor(DEEP_SKY_BLUE.into()),
BoxShadow::new(

View File

@ -44,7 +44,7 @@ fn button_system(
input_focus.set(entity);
**text = "Press".to_string();
*color = PRESSED_BUTTON.into();
border_color.0 = RED.into();
*border_color = BorderColor::all(RED.into());
// The accessibility system's only update the button's state when the `Button` component is marked as changed.
button.set_changed();
@ -53,14 +53,14 @@ fn button_system(
input_focus.set(entity);
**text = "Hover".to_string();
*color = HOVERED_BUTTON.into();
border_color.0 = Color::WHITE;
*border_color = BorderColor::all(Color::WHITE);
button.set_changed();
}
Interaction::None => {
input_focus.clear();
**text = "Button".to_string();
*color = NORMAL_BUTTON.into();
border_color.0 = Color::BLACK;
*border_color = BorderColor::all(Color::BLACK);
}
}
}
@ -93,7 +93,7 @@ fn button(asset_server: &AssetServer) -> impl Bundle + use<> {
align_items: AlignItems::Center,
..default()
},
BorderColor(Color::BLACK),
BorderColor::all(Color::BLACK),
BorderRadius::MAX,
BackgroundColor(NORMAL_BUTTON),
children![(

View File

@ -361,9 +361,9 @@ fn highlight_focused_element(
if input_focus.0 == Some(entity) && input_focus_visible.0 {
// Don't change the border size / radius here,
// as it would result in wiggling buttons when they are focused
border_color.0 = FOCUSED_BORDER.into();
*border_color = BorderColor::all(FOCUSED_BORDER.into());
} else {
border_color.0 = Color::NONE;
*border_color = BorderColor::DEFAULT;
}
}
}

View File

@ -83,7 +83,7 @@ fn create_button() -> impl Bundle {
align_items: AlignItems::Center,
..default()
},
BorderColor(Color::BLACK),
BorderColor::all(Color::BLACK),
BorderRadius::MAX,
BackgroundColor(Color::srgb(0.15, 0.15, 0.15)),
)

View File

@ -72,7 +72,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
overflow,
..default()
},
BorderColor(Color::BLACK),
BorderColor::all(Color::BLACK),
BackgroundColor(GRAY.into()),
))
.with_children(|parent| {

View File

@ -67,7 +67,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
..default()
},
BackgroundColor(GRAY.into()),
BorderColor(Color::BLACK),
BorderColor::all(Color::BLACK),
))
.with_children(|parent| {
parent

View File

@ -221,7 +221,7 @@ fn spawn_button(
margin: UiRect::horizontal(Val::Px(2.)),
..Default::default()
},
BorderColor(if active {
BorderColor::all(if active {
ACTIVE_BORDER_COLOR
} else {
INACTIVE_BORDER_COLOR
@ -345,7 +345,7 @@ fn update_radio_buttons_colors(
)
};
border_query.get_mut(id).unwrap().0 = border_color;
*border_query.get_mut(id).unwrap() = BorderColor::all(border_color);
for &child in children_query.get(id).into_iter().flatten() {
color_query.get_mut(child).unwrap().0 = inner_color;
for &grandchild in children_query.get(child).into_iter().flatten() {

View File

@ -42,17 +42,17 @@ fn button_system(
Interaction::Pressed => {
**text = "Press".to_string();
*color = PRESSED_BUTTON.into();
border_color.0 = RED.into();
*border_color = BorderColor::all(RED.into());
}
Interaction::Hovered => {
**text = "Hover".to_string();
*color = HOVERED_BUTTON.into();
border_color.0 = Color::WHITE;
*border_color = BorderColor::all(Color::WHITE);
}
Interaction::None => {
**text = "Button".to_string();
*color = NORMAL_BUTTON.into();
border_color.0 = Color::BLACK;
*border_color = BorderColor::all(Color::BLACK);
}
}
}
@ -198,7 +198,7 @@ fn create_button(parent: &mut ChildSpawnerCommands<'_>, asset_server: &AssetServ
align_items: AlignItems::Center,
..default()
},
BorderColor(Color::BLACK),
BorderColor::all(Color::BLACK),
BorderRadius::MAX,
BackgroundColor(NORMAL_BUTTON),
TabIndex(0),

View File

@ -75,7 +75,7 @@ fn spawn_with_viewport_coords(commands: &mut Commands) {
flex_wrap: FlexWrap::Wrap,
..default()
},
BorderColor(PALETTE[0].into()),
BorderColor::all(PALETTE[0].into()),
Coords::Viewport,
))
.with_children(|builder| {
@ -87,7 +87,7 @@ fn spawn_with_viewport_coords(commands: &mut Commands) {
..default()
},
BackgroundColor(PALETTE[2].into()),
BorderColor(PALETTE[9].into()),
BorderColor::all(PALETTE[9].into()),
));
builder.spawn((
@ -107,7 +107,7 @@ fn spawn_with_viewport_coords(commands: &mut Commands) {
..default()
},
BackgroundColor(PALETTE[4].into()),
BorderColor(PALETTE[8].into()),
BorderColor::all(PALETTE[8].into()),
));
builder.spawn((
@ -118,7 +118,7 @@ fn spawn_with_viewport_coords(commands: &mut Commands) {
..default()
},
BackgroundColor(PALETTE[5].into()),
BorderColor(PALETTE[8].into()),
BorderColor::all(PALETTE[8].into()),
));
builder.spawn((
@ -138,7 +138,7 @@ fn spawn_with_viewport_coords(commands: &mut Commands) {
..default()
},
BackgroundColor(PALETTE[7].into()),
BorderColor(PALETTE[9].into()),
BorderColor::all(PALETTE[9].into()),
));
});
}
@ -153,7 +153,7 @@ fn spawn_with_pixel_coords(commands: &mut Commands) {
flex_wrap: FlexWrap::Wrap,
..default()
},
BorderColor(PALETTE[1].into()),
BorderColor::all(PALETTE[1].into()),
Coords::Pixel,
))
.with_children(|builder| {
@ -165,7 +165,7 @@ fn spawn_with_pixel_coords(commands: &mut Commands) {
..default()
},
BackgroundColor(PALETTE[2].into()),
BorderColor(PALETTE[9].into()),
BorderColor::all(PALETTE[9].into()),
));
builder.spawn((
@ -185,7 +185,7 @@ fn spawn_with_pixel_coords(commands: &mut Commands) {
..default()
},
BackgroundColor(PALETTE[4].into()),
BorderColor(PALETTE[8].into()),
BorderColor::all(PALETTE[8].into()),
));
builder.spawn((
@ -196,7 +196,7 @@ fn spawn_with_pixel_coords(commands: &mut Commands) {
..default()
},
BackgroundColor(PALETTE[5].into()),
BorderColor(PALETTE[8].into()),
BorderColor::all(PALETTE[8].into()),
));
builder.spawn((
@ -216,7 +216,7 @@ fn spawn_with_pixel_coords(commands: &mut Commands) {
..default()
},
BackgroundColor(PALETTE[7].into()),
BorderColor(PALETTE[9].into()),
BorderColor::all(PALETTE[9].into()),
));
});
}

View File

@ -102,7 +102,7 @@ fn test(
border: UiRect::all(Val::Px(5.0)),
..default()
},
BorderColor(Color::WHITE),
BorderColor::all(Color::WHITE),
ViewportNode::new(camera),
))
.observe(on_drag_viewport);

View File

@ -0,0 +1,6 @@
---
title: Separate Border Colors
pull_requests: [18682]
---
The `BorderColor` struct now contains separate fields for each edge, `top`, `bottom`, `left`, `right`. To keep the existing behavior, replace `BorderColor(color)` with `BorderColor::all(color)`, and `border_color.0 = new_color` with `*border_color = BorderColor::all(new_color)`.