bevy/examples/testbed/ui.rs
ickshonpe 45a3f3d138
Color interpolation in OKLab, OKLCH spaces for UI gradients (#19330)
# Objective

Add support for interpolation in OKLab and OKLCH color spaces for UI
gradients.

## Solution
* New `InterpolationColorSpace` enum with `OkLab`, `OkLch`, `OkLchLong`,
`Srgb` and `LinearRgb` variants.
  * Added a color space specialization to the gradients pipeline.
* Added support for interpolation in OkLCH and OkLAB color spaces to the
gradients shader. OKLCH interpolation supports both short and long hue
paths. This is mostly based on the conversion functions from
`bevy_color` except that interpolation in polar space uses radians.
  * Added `color_space` fields to each gradient type.

## Testing

The `gradients` example has been updated to demonstrate the different
color interpolation methods.
Press space to cycle through the different options.

---

## Showcase


![color_spaces](https://github.com/user-attachments/assets/e10f8342-c3c8-487e-b386-7acdf38d638f)
2025-06-21 15:06:35 +00:00

631 lines
23 KiB
Rust

//! UI testbed
//!
//! You can switch scene by pressing the spacebar
mod helpers;
use bevy::prelude::*;
use helpers::Next;
fn main() {
let mut app = App::new();
app.add_plugins((DefaultPlugins,))
.init_state::<Scene>()
.add_systems(OnEnter(Scene::Image), image::setup)
.add_systems(OnEnter(Scene::Text), text::setup)
.add_systems(OnEnter(Scene::Grid), grid::setup)
.add_systems(OnEnter(Scene::Borders), borders::setup)
.add_systems(OnEnter(Scene::BoxShadow), box_shadow::setup)
.add_systems(OnEnter(Scene::TextWrap), text_wrap::setup)
.add_systems(OnEnter(Scene::Overflow), overflow::setup)
.add_systems(OnEnter(Scene::Slice), slice::setup)
.add_systems(OnEnter(Scene::LayoutRounding), layout_rounding::setup)
.add_systems(OnEnter(Scene::RadialGradient), radial_gradient::setup)
.add_systems(Update, switch_scene);
#[cfg(feature = "bevy_ci_testing")]
app.add_systems(Update, helpers::switch_scene_in_ci::<Scene>);
app.run();
}
#[derive(Debug, Clone, Eq, PartialEq, Hash, States, Default)]
#[states(scoped_entities)]
enum Scene {
#[default]
Image,
Text,
Grid,
Borders,
BoxShadow,
TextWrap,
Overflow,
Slice,
LayoutRounding,
RadialGradient,
}
impl Next for Scene {
fn next(&self) -> Self {
match self {
Scene::Image => Scene::Text,
Scene::Text => Scene::Grid,
Scene::Grid => Scene::Borders,
Scene::Borders => Scene::BoxShadow,
Scene::BoxShadow => Scene::TextWrap,
Scene::TextWrap => Scene::Overflow,
Scene::Overflow => Scene::Slice,
Scene::Slice => Scene::LayoutRounding,
Scene::LayoutRounding => Scene::RadialGradient,
Scene::RadialGradient => Scene::Image,
}
}
}
fn switch_scene(
keyboard: Res<ButtonInput<KeyCode>>,
scene: Res<State<Scene>>,
mut next_scene: ResMut<NextState<Scene>>,
) {
if keyboard.just_pressed(KeyCode::Space) {
info!("Switching scene");
next_scene.set(scene.get().next());
}
}
mod image {
use bevy::prelude::*;
pub fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn((Camera2d, DespawnOnExitState(super::Scene::Image)));
commands.spawn((
ImageNode::new(asset_server.load("branding/bevy_logo_dark.png")),
DespawnOnExitState(super::Scene::Image),
));
}
}
mod text {
use bevy::prelude::*;
pub fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn((Camera2d, DespawnOnExitState(super::Scene::Text)));
commands.spawn((
Text::new("Hello World."),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 200.,
..default()
},
DespawnOnExitState(super::Scene::Text),
));
}
}
mod grid {
use bevy::{color::palettes::css::*, prelude::*};
pub fn setup(mut commands: Commands) {
commands.spawn((Camera2d, DespawnOnExitState(super::Scene::Grid)));
// Top-level grid (app frame)
commands.spawn((
Node {
display: Display::Grid,
width: Val::Percent(100.0),
height: Val::Percent(100.0),
grid_template_columns: vec![GridTrack::min_content(), GridTrack::flex(1.0)],
grid_template_rows: vec![
GridTrack::auto(),
GridTrack::flex(1.0),
GridTrack::px(40.),
],
..default()
},
BackgroundColor(Color::WHITE),
DespawnOnExitState(super::Scene::Grid),
children![
// Header
(
Node {
display: Display::Grid,
grid_column: GridPlacement::span(2),
padding: UiRect::all(Val::Px(40.0)),
..default()
},
BackgroundColor(RED.into()),
),
// Main content grid (auto placed in row 2, column 1)
(
Node {
height: Val::Percent(100.0),
aspect_ratio: Some(1.0),
display: Display::Grid,
grid_template_columns: RepeatedGridTrack::flex(3, 1.0),
grid_template_rows: RepeatedGridTrack::flex(2, 1.0),
row_gap: Val::Px(12.0),
column_gap: Val::Px(12.0),
..default()
},
BackgroundColor(Color::srgb(0.25, 0.25, 0.25)),
children![
(Node::default(), BackgroundColor(ORANGE.into())),
(Node::default(), BackgroundColor(BISQUE.into())),
(Node::default(), BackgroundColor(BLUE.into())),
(Node::default(), BackgroundColor(CRIMSON.into())),
(Node::default(), BackgroundColor(AQUA.into())),
]
),
// Right side bar (auto placed in row 2, column 2)
(Node::DEFAULT, BackgroundColor(BLACK.into())),
],
));
}
}
mod borders {
use bevy::{color::palettes::css::*, prelude::*};
pub fn setup(mut commands: Commands) {
commands.spawn((Camera2d, DespawnOnExitState(super::Scene::Borders)));
let root = commands
.spawn((
Node {
flex_wrap: FlexWrap::Wrap,
..default()
},
DespawnOnExitState(super::Scene::Borders),
))
.id();
// all the different combinations of border edges
let borders = [
UiRect::default(),
UiRect::all(Val::Px(20.)),
UiRect::left(Val::Px(20.)),
UiRect::vertical(Val::Px(20.)),
UiRect {
left: Val::Px(40.),
top: Val::Px(20.),
..Default::default()
},
UiRect {
right: Val::Px(20.),
bottom: Val::Px(30.),
..Default::default()
},
UiRect {
right: Val::Px(20.),
top: Val::Px(40.),
bottom: Val::Px(20.),
..Default::default()
},
UiRect {
left: Val::Px(20.),
top: Val::Px(20.),
bottom: Val::Px(20.),
..Default::default()
},
UiRect {
left: Val::Px(20.),
right: Val::Px(20.),
bottom: Val::Px(40.),
..Default::default()
},
];
let non_zero = |x, y| x != Val::Px(0.) && y != Val::Px(0.);
let border_size = |x, y| if non_zero(x, y) { f32::MAX } else { 0. };
for border in borders {
for rounded in [true, false] {
let border_node = commands
.spawn((
Node {
width: Val::Px(100.),
height: Val::Px(100.),
border,
margin: UiRect::all(Val::Px(30.)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
BackgroundColor(MAROON.into()),
BorderColor::all(RED.into()),
Outline {
width: Val::Px(10.),
offset: Val::Px(10.),
color: Color::WHITE,
},
))
.id();
if rounded {
let border_radius = BorderRadius::px(
border_size(border.left, border.top),
border_size(border.right, border.top),
border_size(border.right, border.bottom),
border_size(border.left, border.bottom),
);
commands.entity(border_node).insert(border_radius);
}
commands.entity(root).add_child(border_node);
}
}
}
}
mod box_shadow {
use bevy::{color::palettes::css::*, prelude::*};
pub fn setup(mut commands: Commands) {
commands.spawn((Camera2d, DespawnOnExitState(super::Scene::BoxShadow)));
commands
.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
padding: UiRect::all(Val::Px(30.)),
column_gap: Val::Px(200.),
flex_wrap: FlexWrap::Wrap,
..default()
},
BackgroundColor(GREEN.into()),
DespawnOnExitState(super::Scene::BoxShadow),
))
.with_children(|commands| {
let example_nodes = [
(
Vec2::splat(100.),
Vec2::ZERO,
10.,
0.,
BorderRadius::bottom_right(Val::Px(10.)),
),
(Vec2::new(200., 50.), Vec2::ZERO, 10., 0., BorderRadius::MAX),
(
Vec2::new(100., 50.),
Vec2::ZERO,
10.,
10.,
BorderRadius::ZERO,
),
(
Vec2::splat(100.),
Vec2::splat(20.),
10.,
10.,
BorderRadius::bottom_right(Val::Px(10.)),
),
(
Vec2::splat(100.),
Vec2::splat(50.),
0.,
10.,
BorderRadius::ZERO,
),
(
Vec2::new(50., 100.),
Vec2::splat(10.),
0.,
10.,
BorderRadius::MAX,
),
];
for (size, offset, spread, blur, border_radius) in example_nodes {
commands.spawn((
Node {
width: Val::Px(size.x),
height: Val::Px(size.y),
border: UiRect::all(Val::Px(2.)),
..default()
},
BorderColor::all(WHITE.into()),
border_radius,
BackgroundColor(BLUE.into()),
BoxShadow::new(
Color::BLACK.with_alpha(0.9),
Val::Percent(offset.x),
Val::Percent(offset.y),
Val::Percent(spread),
Val::Px(blur),
),
));
}
});
}
}
mod text_wrap {
use bevy::prelude::*;
pub fn setup(mut commands: Commands) {
commands.spawn((Camera2d, DespawnOnExitState(super::Scene::TextWrap)));
let root = commands
.spawn((
Node {
flex_direction: FlexDirection::Column,
width: Val::Px(200.),
height: Val::Percent(100.),
overflow: Overflow::clip_x(),
..default()
},
BackgroundColor(Color::BLACK),
DespawnOnExitState(super::Scene::TextWrap),
))
.id();
for linebreak in [
LineBreak::AnyCharacter,
LineBreak::WordBoundary,
LineBreak::WordOrCharacter,
LineBreak::NoWrap,
] {
let messages = [
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.".to_string(),
"pneumonoultramicroscopicsilicovolcanoconiosis".to_string(),
];
for (j, message) in messages.into_iter().enumerate() {
commands.entity(root).with_child((
Text(message.clone()),
TextLayout::new(Justify::Left, linebreak),
BackgroundColor(Color::srgb(0.8 - j as f32 * 0.3, 0., 0.)),
));
}
}
}
}
mod overflow {
use bevy::{color::palettes::css::*, prelude::*};
pub fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn((Camera2d, DespawnOnExitState(super::Scene::Overflow)));
let image = asset_server.load("branding/icon.png");
commands
.spawn((
Node {
width: Val::Percent(100.),
height: Val::Percent(100.),
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceAround,
..Default::default()
},
BackgroundColor(BLUE.into()),
DespawnOnExitState(super::Scene::Overflow),
))
.with_children(|parent| {
for overflow in [
Overflow::visible(),
Overflow::clip_x(),
Overflow::clip_y(),
Overflow::clip(),
] {
parent
.spawn((
Node {
width: Val::Px(100.),
height: Val::Px(100.),
padding: UiRect {
left: Val::Px(25.),
top: Val::Px(25.),
..Default::default()
},
border: UiRect::all(Val::Px(5.)),
overflow,
..default()
},
BorderColor::all(RED.into()),
BackgroundColor(Color::WHITE),
))
.with_children(|parent| {
parent.spawn((
ImageNode::new(image.clone()),
Node {
min_width: Val::Px(100.),
min_height: Val::Px(100.),
..default()
},
Interaction::default(),
Outline {
width: Val::Px(2.),
offset: Val::Px(2.),
color: Color::NONE,
},
));
});
}
});
}
}
mod slice {
use bevy::prelude::*;
pub fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn((Camera2d, DespawnOnExitState(super::Scene::Slice)));
let image = asset_server.load("textures/fantasy_ui_borders/numbered_slices.png");
let slicer = TextureSlicer {
border: BorderRect::all(16.0),
center_scale_mode: SliceScaleMode::Tile { stretch_value: 1.0 },
sides_scale_mode: SliceScaleMode::Tile { stretch_value: 1.0 },
..default()
};
commands
.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceAround,
..default()
},
DespawnOnExitState(super::Scene::Slice),
))
.with_children(|parent| {
for [w, h] in [[150.0, 150.0], [300.0, 150.0], [150.0, 300.0]] {
parent.spawn((
Button,
ImageNode {
image: image.clone(),
image_mode: NodeImageMode::Sliced(slicer.clone()),
..default()
},
Node {
width: Val::Px(w),
height: Val::Px(h),
..default()
},
));
}
});
}
}
mod layout_rounding {
use bevy::{color::palettes::css::*, prelude::*};
pub fn setup(mut commands: Commands) {
commands.spawn((Camera2d, DespawnOnExitState(super::Scene::LayoutRounding)));
commands
.spawn((
Node {
display: Display::Grid,
width: Val::Percent(100.),
height: Val::Percent(100.),
grid_template_rows: vec![RepeatedGridTrack::fr(10, 1.)],
..Default::default()
},
BackgroundColor(Color::WHITE),
DespawnOnExitState(super::Scene::LayoutRounding),
))
.with_children(|commands| {
for i in 2..12 {
commands
.spawn(Node {
display: Display::Grid,
grid_template_columns: vec![RepeatedGridTrack::fr(i, 1.)],
..Default::default()
})
.with_children(|commands| {
for _ in 0..i {
commands.spawn((
Node {
border: UiRect::all(Val::Px(5.)),
..Default::default()
},
BackgroundColor(MAROON.into()),
BorderColor::all(DARK_BLUE.into()),
));
}
});
}
});
}
}
mod radial_gradient {
use bevy::color::palettes::css::RED;
use bevy::color::palettes::tailwind::GRAY_700;
use bevy::prelude::*;
use bevy::ui::ColorStop;
const CELL_SIZE: f32 = 80.;
const GAP: f32 = 10.;
pub fn setup(mut commands: Commands) {
let color_stops = vec![
ColorStop::new(Color::BLACK, Val::Px(5.)),
ColorStop::new(Color::WHITE, Val::Px(5.)),
ColorStop::new(Color::WHITE, Val::Percent(100.)),
ColorStop::auto(RED),
];
commands.spawn((Camera2d, DespawnOnExitState(super::Scene::RadialGradient)));
commands
.spawn((
Node {
width: Val::Percent(100.),
height: Val::Percent(100.),
display: Display::Grid,
align_items: AlignItems::Start,
grid_template_columns: vec![RepeatedGridTrack::px(
GridTrackRepetition::AutoFill,
CELL_SIZE,
)],
grid_auto_flow: GridAutoFlow::Row,
row_gap: Val::Px(GAP),
column_gap: Val::Px(GAP),
padding: UiRect::all(Val::Px(GAP)),
..default()
},
DespawnOnExitState(super::Scene::RadialGradient),
))
.with_children(|commands| {
for (shape, shape_label) in [
(RadialGradientShape::ClosestSide, "ClosestSide"),
(RadialGradientShape::FarthestSide, "FarthestSide"),
(
RadialGradientShape::Circle(Val::Percent(55.)),
"Circle(55%)",
),
(RadialGradientShape::FarthestCorner, "FarthestCorner"),
] {
for (position, position_label) in [
(UiPosition::TOP_LEFT, "TOP_LEFT"),
(UiPosition::LEFT, "LEFT"),
(UiPosition::BOTTOM_LEFT, "BOTTOM_LEFT"),
(UiPosition::TOP, "TOP"),
(UiPosition::CENTER, "CENTER"),
(UiPosition::BOTTOM, "BOTTOM"),
(UiPosition::TOP_RIGHT, "TOP_RIGHT"),
(UiPosition::RIGHT, "RIGHT"),
(UiPosition::BOTTOM_RIGHT, "BOTTOM_RIGHT"),
] {
for (w, h) in [(CELL_SIZE, CELL_SIZE), (CELL_SIZE, CELL_SIZE / 2.)] {
commands
.spawn((
BackgroundColor(GRAY_700.into()),
Node {
display: Display::Grid,
width: Val::Px(CELL_SIZE),
..Default::default()
},
))
.with_children(|commands| {
commands.spawn((
Node {
margin: UiRect::all(Val::Px(2.0)),
..default()
},
Text(format!("{shape_label}\n{position_label}")),
TextFont::from_font_size(9.),
));
commands.spawn((
Node {
width: Val::Px(w),
height: Val::Px(h),
..default()
},
BackgroundGradient::from(RadialGradient {
stops: color_stops.clone(),
position,
shape,
..default()
}),
));
});
}
}
}
});
}
}