From a67c036ec329b558f848c783a1a1c7f76274cdaf Mon Sep 17 00:00:00 2001 From: Talin Date: Tue, 1 Jul 2025 21:49:32 -0700 Subject: [PATCH 01/16] Color swatch and alpha pattern. --- crates/bevy_feathers/Cargo.toml | 1 + crates/bevy_feathers/src/alpha_pattern.rs | 59 +++++++++++++++++++ .../src/assets/shaders/alpha_pattern.wgsl | 39 ++++++++++++ .../src/controls/color_swatch.rs | 40 +++++++++++++ crates/bevy_feathers/src/controls/mod.rs | 5 ++ crates/bevy_feathers/src/lib.rs | 10 ++++ examples/ui/feathers.rs | 1 + 7 files changed, 155 insertions(+) create mode 100644 crates/bevy_feathers/src/alpha_pattern.rs create mode 100644 crates/bevy_feathers/src/assets/shaders/alpha_pattern.wgsl create mode 100644 crates/bevy_feathers/src/controls/color_swatch.rs diff --git a/crates/bevy_feathers/Cargo.toml b/crates/bevy_feathers/Cargo.toml index 07d883704ac73..f449692a51c54 100644 --- a/crates/bevy_feathers/Cargo.toml +++ b/crates/bevy_feathers/Cargo.toml @@ -21,6 +21,7 @@ bevy_log = { path = "../bevy_log", version = "0.17.0-dev" } bevy_math = { path = "../bevy_math", version = "0.17.0-dev" } bevy_picking = { path = "../bevy_picking", version = "0.17.0-dev" } bevy_platform = { path = "../bevy_platform", version = "0.17.0-dev" } +bevy_reflect = { path = "../bevy_reflect", version = "0.17.0-dev" } bevy_render = { path = "../bevy_render", version = "0.17.0-dev" } bevy_text = { path = "../bevy_text", version = "0.17.0-dev" } bevy_ui = { path = "../bevy_ui", version = "0.17.0-dev", features = [ diff --git a/crates/bevy_feathers/src/alpha_pattern.rs b/crates/bevy_feathers/src/alpha_pattern.rs new file mode 100644 index 0000000000000..c4195b1dd3cd9 --- /dev/null +++ b/crates/bevy_feathers/src/alpha_pattern.rs @@ -0,0 +1,59 @@ +use bevy_app::Plugin; +use bevy_asset::{Asset, Assets, Handle}; +use bevy_ecs::{ + component::Component, + lifecycle::Add, + observer::On, + resource::Resource, + system::{Query, Res}, + world::FromWorld, +}; +use bevy_reflect::TypePath; +use bevy_render::render_resource::{AsBindGroup, ShaderRef}; +use bevy_ui::{MaterialNode, UiMaterial}; + +#[derive(AsBindGroup, Asset, TypePath, Default, Debug, Clone)] +pub(crate) struct AlphaPatternMaterial {} + +impl UiMaterial for AlphaPatternMaterial { + fn fragment_shader() -> ShaderRef { + "embedded://bevy_feathers/assets/shaders/alpha_pattern.wgsl".into() + } +} + +#[derive(Resource)] +pub(crate) struct AlphaPatternResource(pub(crate) Handle); + +impl FromWorld for AlphaPatternResource { + fn from_world(world: &mut bevy_ecs::world::World) -> Self { + let mut ui_materials = world + .get_resource_mut::>() + .unwrap(); + Self(ui_materials.add(AlphaPatternMaterial::default())) + } +} + +/// Marker that tells us we want to fill in the [`MaterialNode`] with the alpha material. +#[derive(Component, Default, Clone)] +pub(crate) struct AlphaPattern; + +/// Observer to fill in the material handle (since we don't have access to the materials asset +/// in the template) +fn on_add_color_swatch( + ev: On, + mut q_swatch: Query<&mut MaterialNode>, + r_material: Res, +) { + if let Ok(mut material) = q_swatch.get_mut(ev.target()) { + material.0 = r_material.0.clone(); + } +} + +/// Plugin which registers the systems for updating the button styles. +pub struct AlphaPatternPlugin; + +impl Plugin for AlphaPatternPlugin { + fn build(&self, app: &mut bevy_app::App) { + app.add_observer(on_add_color_swatch); + } +} diff --git a/crates/bevy_feathers/src/assets/shaders/alpha_pattern.wgsl b/crates/bevy_feathers/src/assets/shaders/alpha_pattern.wgsl new file mode 100644 index 0000000000000..053645ed5b89c --- /dev/null +++ b/crates/bevy_feathers/src/assets/shaders/alpha_pattern.wgsl @@ -0,0 +1,39 @@ +// This shader draws a checkerboard pattern +#import bevy_ui::ui_vertex_output::UiVertexOutput + +@fragment +fn fragment(in: UiVertexOutput) -> @location(0) vec4 { + let uv = (in.uv - vec2(0.5, 0.5)) * in.size / 16.; + let check = select(0.0, 1.0, (fract(uv.x) < 0.5) != (fract(uv.y) < 0.5)); + let bg = mix(vec3(0.2, 0.2, 0.2), vec3(0.6, 0.6, 0.6), check); + + let size = vec2(in.size.x, in.size.y); + let external_distance = sd_rounded_box((in.uv - 0.5) * size, size, in.border_radius); + let alpha = smoothstep(0.5, -0.5, external_distance); + + return vec4(bg, alpha); +} + +// From: https://github.com/bevyengine/bevy/pull/8973 +// The returned value is the shortest distance from the given point to the boundary of the rounded box. +// Negative values indicate that the point is inside the rounded box, positive values that the point is outside, and zero is exactly on the boundary. +// arguments +// point -> The function will return the distance from this point to the closest point on the boundary. +// size -> The maximum width and height of the box. +// corner_radii -> The radius of each rounded corner. Ordered counter clockwise starting top left: +// x = top left, y = top right, z = bottom right, w = bottom left. +fn sd_rounded_box(point: vec2, size: vec2, corner_radii: vec4) -> f32 { + // if 0.0 < y then select bottom left (w) and bottom right corner radius (z) + // else select top left (x) and top right corner radius (y) + let rs = select(corner_radii.xy, corner_radii.wz, 0.0 < point.y); + // w and z are swapped so that both pairs are in left to right order, otherwise this second select statement would return the incorrect value for the bottom pair. + let radius = select(rs.x, rs.y, 0.0 < point.x); + // Vector from the corner closest to the point, to the point + let corner_to_point = abs(point) - 0.5 * size; + // Vector from the center of the radius circle to the point + let q = corner_to_point + radius; + // length from center of the radius circle to the point, 0s a component if the point is not within the quadrant of the radius circle that is part of the curved corner. + let l = length(max(q, vec2(0.0))); + let m = min(max(q.x, q.y), 0.0); + return l + m - radius; +} diff --git a/crates/bevy_feathers/src/controls/color_swatch.rs b/crates/bevy_feathers/src/controls/color_swatch.rs new file mode 100644 index 0000000000000..9235cd3e1bde8 --- /dev/null +++ b/crates/bevy_feathers/src/controls/color_swatch.rs @@ -0,0 +1,40 @@ +use bevy_asset::Handle; +use bevy_color::Alpha; +use bevy_ecs::{bundle::Bundle, children, spawn::SpawnRelated}; +use bevy_ui::{BackgroundColor, BorderRadius, MaterialNode, Node, PositionType, Val}; + +use crate::{ + alpha_pattern::{AlphaPattern, AlphaPatternMaterial}, + constants::size, + palette, +}; + +/// Template function to spawn a color swatch. +/// +/// # Arguments +/// * `overrides` - a bundle of components that are merged in with the normal swatch components. +pub fn color_swatch(overrides: B) -> impl Bundle { + ( + Node { + height: size::ROW_HEIGHT, + min_width: size::ROW_HEIGHT, + ..Default::default() + }, + AlphaPattern, + MaterialNode::(Handle::default()), + BorderRadius::all(Val::Px(5.0)), + overrides, + children![( + Node { + position_type: PositionType::Absolute, + left: Val::Px(0.), + top: Val::Px(0.), + bottom: Val::Px(0.), + right: Val::Px(0.), + ..Default::default() + }, + BackgroundColor(palette::ACCENT.with_alpha(0.5)), + BorderRadius::all(Val::Px(5.0)) + ),], + ) +} diff --git a/crates/bevy_feathers/src/controls/mod.rs b/crates/bevy_feathers/src/controls/mod.rs index ecad39707b925..30b4ef9aea11f 100644 --- a/crates/bevy_feathers/src/controls/mod.rs +++ b/crates/bevy_feathers/src/controls/mod.rs @@ -3,22 +3,27 @@ use bevy_app::Plugin; mod button; mod checkbox; +mod color_swatch; mod radio; mod slider; mod toggle_switch; pub use button::{button, ButtonPlugin, ButtonProps, ButtonVariant}; pub use checkbox::{checkbox, CheckboxPlugin, CheckboxProps}; +pub use color_swatch::color_swatch; pub use radio::{radio, RadioPlugin}; pub use slider::{slider, SliderPlugin, SliderProps}; pub use toggle_switch::{toggle_switch, ToggleSwitchPlugin, ToggleSwitchProps}; +use crate::alpha_pattern::AlphaPatternPlugin; + /// Plugin which registers all `bevy_feathers` controls. pub struct ControlsPlugin; impl Plugin for ControlsPlugin { fn build(&self, app: &mut bevy_app::App) { app.add_plugins(( + AlphaPatternPlugin, ButtonPlugin, CheckboxPlugin, RadioPlugin, diff --git a/crates/bevy_feathers/src/lib.rs b/crates/bevy_feathers/src/lib.rs index ab02304a85b30..4a1b6c5e4c61d 100644 --- a/crates/bevy_feathers/src/lib.rs +++ b/crates/bevy_feathers/src/lib.rs @@ -22,14 +22,17 @@ use bevy_app::{HierarchyPropagatePlugin, Plugin, PostUpdate}; use bevy_asset::embedded_asset; use bevy_ecs::query::With; use bevy_text::{TextColor, TextFont}; +use bevy_ui::UiMaterialPlugin; use bevy_winit::cursor::CursorIcon; use crate::{ + alpha_pattern::{AlphaPatternMaterial, AlphaPatternResource}, controls::ControlsPlugin, cursor::{CursorIconPlugin, DefaultCursorIcon}, theme::{ThemedText, UiTheme}, }; +mod alpha_pattern; pub mod constants; pub mod controls; pub mod cursor; @@ -48,17 +51,22 @@ impl Plugin for FeathersPlugin { fn build(&self, app: &mut bevy_app::App) { app.init_resource::(); + // Embedded font embedded_asset!(app, "assets/fonts/FiraSans-Bold.ttf"); embedded_asset!(app, "assets/fonts/FiraSans-BoldItalic.ttf"); embedded_asset!(app, "assets/fonts/FiraSans-Regular.ttf"); embedded_asset!(app, "assets/fonts/FiraSans-Italic.ttf"); embedded_asset!(app, "assets/fonts/FiraMono-Medium.ttf"); + // Embedded shader + embedded_asset!(app, "assets/shaders/alpha_pattern.wgsl"); + app.add_plugins(( ControlsPlugin, CursorIconPlugin, HierarchyPropagatePlugin::>::default(), HierarchyPropagatePlugin::>::default(), + UiMaterialPlugin::::default(), )); app.insert_resource(DefaultCursorIcon(CursorIcon::System( @@ -70,5 +78,7 @@ impl Plugin for FeathersPlugin { .add_observer(theme::on_changed_border) .add_observer(theme::on_changed_font_color) .add_observer(font_styles::on_changed_font); + + app.init_resource::(); } } diff --git a/examples/ui/feathers.rs b/examples/ui/feathers.rs index ae6ec31f4c009..776a62eab1ce3 100644 --- a/examples/ui/feathers.rs +++ b/examples/ui/feathers.rs @@ -261,6 +261,7 @@ fn demo_root(commands: &mut Commands) -> impl Bundle { }, SliderStep(10.) ), + color_swatch(()) ] ),], ) From d152cd76da50fb348232a8ae7a6f33db0e90fa12 Mon Sep 17 00:00:00 2001 From: Talin Date: Wed, 2 Jul 2025 18:41:47 -0700 Subject: [PATCH 02/16] Gradient slider work. --- .../src/controls/gradient_slider.rs | 241 ++++++++++++++++++ crates/bevy_feathers/src/controls/mod.rs | 1 + 2 files changed, 242 insertions(+) create mode 100644 crates/bevy_feathers/src/controls/gradient_slider.rs diff --git a/crates/bevy_feathers/src/controls/gradient_slider.rs b/crates/bevy_feathers/src/controls/gradient_slider.rs new file mode 100644 index 0000000000000..da86898efa651 --- /dev/null +++ b/crates/bevy_feathers/src/controls/gradient_slider.rs @@ -0,0 +1,241 @@ +use core::f32::consts::PI; + +use bevy_app::{Plugin, PreUpdate}; +use bevy_color::Color; +use bevy_core_widgets::{Callback, CoreSlider, SliderRange, SliderValue, TrackClick}; +use bevy_ecs::{ + bundle::Bundle, + children, + component::Component, + entity::Entity, + hierarchy::Children, + lifecycle::RemovedComponents, + query::{Added, Changed, Has, Or, Spawned, With}, + schedule::IntoScheduleConfigs, + spawn::SpawnRelated, + system::{In, Query, Res}, +}; +use bevy_input_focus::tab_navigation::TabIndex; +use bevy_picking::PickingSystems; +use bevy_ui::{ + widget::Text, AlignItems, BackgroundGradient, ColorStop, Display, FlexDirection, Gradient, + InteractionDisabled, InterpolationColorSpace, LinearGradient, Node, UiRect, Val, +}; +use bevy_winit::cursor::CursorIcon; + +use crate::{constants::size, rounded_corners::RoundedCorners, theme::UiTheme, tokens}; + +pub enum ColorChannel { + Red, + Green, + Blue, + Alpha, +} + +impl ColorChannel { + /// Return the range of this color channel. + pub fn range(&self) -> SliderRange { + match self { + ColorChannel::Red | ColorChannel::Green | ColorChannel::Blue | ColorChannel::Alpha => { + SliderRange::new(0., 1.) + } + } + } +} + +/// Slider template properties, passed to [`slider`] function. +pub struct GradientSliderProps { + /// Slider current value + pub value: f32, + /// On-change handler + pub on_change: Callback>, + /// Which color component we're editing + pub channel: ColorChannel, +} + +impl Default for GradientSliderProps { + fn default() -> Self { + Self { + value: 0.0, + on_change: Callback::Ignore, + channel: ColorChannel::Alpha, + } + } +} + +#[derive(Component, Default, Clone)] +#[require(CoreSlider)] +struct GradientSlider; + +/// Marker for the thumb +#[derive(Component, Default, Clone)] +struct GradientSliderThumb; + +#[derive(Component, Default, Clone)] +#[require(CoreSlider)] +struct GradientSliderEndcap0; + +#[derive(Component, Default, Clone)] +#[require(CoreSlider)] +struct GradientSliderTrack; + +#[derive(Component, Default, Clone)] +#[require(CoreSlider)] +struct GradientSliderEndcap1; + +/// Spawn a new slider widget. +/// +/// # Arguments +/// +/// * `props` - construction properties for the slider. +/// * `overrides` - a bundle of components that are merged in with the normal slider components. +pub fn gradient_slider(props: GradientSliderProps, overrides: B) -> impl Bundle { + ( + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + height: size::ROW_HEIGHT, + align_items: AlignItems::Stretch, + flex_grow: 1.0, + padding: UiRect::axes(Val::Px(0.), Val::Px(2.)), + ..Default::default() + }, + CoreSlider { + on_change: props.on_change, + track_click: TrackClick::Drag, + }, + GradientSlider, + SliderValue(props.value), + props.channel.range(), + CursorIcon::System(bevy_window::SystemCursorIcon::EwResize), + TabIndex(0), + overrides, + children![ + // Left endcap + ( + Node { + width: Val::Px(6.0), + ..Default::default() + }, + RoundedCorners::Left.to_border_radius(6.0), + GradientSliderEndcap0 + ), + // Track with gradient + ( + Node { + flex_grow: 1.0, + ..Default::default() + }, + GradientSliderTrack, + BackgroundGradient(vec![Gradient::Linear(LinearGradient { + angle: PI * 0.5, + stops: vec![ + ColorStop::new(Color::NONE, Val::Percent(0.)), + ColorStop::new(Color::NONE, Val::Percent(50.)), + ColorStop::new(Color::NONE, Val::Percent(50.)), + ColorStop::new(Color::NONE, Val::Percent(100.)), + ], + color_space: InterpolationColorSpace::Srgb, + })]), + ), + // Right endcap + ( + Node { + width: Val::Px(6.0), + ..Default::default() + }, + RoundedCorners::Right.to_border_radius(6.0), + GradientSliderEndcap1 + ), + ], + ) +} + +fn update_slider_colors( + mut q_sliders: Query< + (Has, &mut BackgroundGradient), + ( + With, + Or<(Spawned, Added)>, + ), + >, + theme: Res, +) { + for (disabled, mut gradient) in q_sliders.iter_mut() { + set_slider_colors(&theme, disabled, gradient.as_mut()); + } +} + +fn update_slider_colors_remove( + mut q_sliders: Query<(Has, &mut BackgroundGradient)>, + mut removed_disabled: RemovedComponents, + theme: Res, +) { + removed_disabled.read().for_each(|ent| { + if let Ok((disabled, mut gradient)) = q_sliders.get_mut(ent) { + set_slider_colors(&theme, disabled, gradient.as_mut()); + } + }); +} + +fn set_slider_colors(theme: &Res<'_, UiTheme>, disabled: bool, gradient: &mut BackgroundGradient) { + let bar_color = theme.color(match disabled { + true => tokens::SLIDER_BAR_DISABLED, + false => tokens::SLIDER_BAR, + }); + let bg_color = theme.color(tokens::SLIDER_BG); + if let [Gradient::Linear(linear_gradient)] = &mut gradient.0[..] { + linear_gradient.stops[0].color = bar_color; + linear_gradient.stops[1].color = bar_color; + linear_gradient.stops[2].color = bg_color; + linear_gradient.stops[3].color = bg_color; + } +} + +fn update_slider_pos( + mut q_sliders: Query< + (Entity, &SliderValue, &SliderRange, &mut BackgroundGradient), + ( + With, + Or<( + Changed, + Changed, + Changed, + )>, + ), + >, + q_children: Query<&Children>, + mut q_slider_text: Query<&mut Text, With>, +) { + for (slider_ent, value, range, mut gradient) in q_sliders.iter_mut() { + if let [Gradient::Linear(linear_gradient)] = &mut gradient.0[..] { + let percent_value = range.thumb_position(value.0) * 100.0; + linear_gradient.stops[1].point = Val::Percent(percent_value); + linear_gradient.stops[2].point = Val::Percent(percent_value); + } + + // Find slider text child entity and update its text with the formatted value + q_children.iter_descendants(slider_ent).for_each(|child| { + if let Ok(mut text) = q_slider_text.get_mut(child) { + text.0 = format!("{}", value.0); + } + }); + } +} + +/// Plugin which registers the systems for updating the slider styles. +pub struct GradientSliderPlugin; + +impl Plugin for GradientSliderPlugin { + fn build(&self, app: &mut bevy_app::App) { + app.add_systems( + PreUpdate, + ( + update_slider_colors, + update_slider_colors_remove, + update_slider_pos, + ) + .in_set(PickingSystems::Last), + ); + } +} diff --git a/crates/bevy_feathers/src/controls/mod.rs b/crates/bevy_feathers/src/controls/mod.rs index 30b4ef9aea11f..e28f8164225d8 100644 --- a/crates/bevy_feathers/src/controls/mod.rs +++ b/crates/bevy_feathers/src/controls/mod.rs @@ -4,6 +4,7 @@ use bevy_app::Plugin; mod button; mod checkbox; mod color_swatch; +mod gradient_slider; mod radio; mod slider; mod toggle_switch; From 473ec9f10dc79e4998a59014f75bc841c3600876 Mon Sep 17 00:00:00 2001 From: Talin Date: Thu, 3 Jul 2025 13:54:52 -0700 Subject: [PATCH 03/16] Color sliders. --- .../src/controls/color_slider.rs | 376 ++++++++++++++++++ .../src/controls/gradient_slider.rs | 241 ----------- crates/bevy_feathers/src/controls/mod.rs | 4 +- examples/ui/feathers.rs | 80 +++- 4 files changed, 456 insertions(+), 245 deletions(-) create mode 100644 crates/bevy_feathers/src/controls/color_slider.rs delete mode 100644 crates/bevy_feathers/src/controls/gradient_slider.rs diff --git a/crates/bevy_feathers/src/controls/color_slider.rs b/crates/bevy_feathers/src/controls/color_slider.rs new file mode 100644 index 0000000000000..9675f6fa7b8c6 --- /dev/null +++ b/crates/bevy_feathers/src/controls/color_slider.rs @@ -0,0 +1,376 @@ +use core::f32::consts::PI; + +use bevy_app::{Plugin, PreUpdate}; +use bevy_asset::Handle; +use bevy_color::{Alpha, Color, Oklcha}; +use bevy_core_widgets::{ + Callback, CoreSlider, CoreSliderThumb, SliderRange, SliderValue, TrackClick, +}; +use bevy_ecs::{ + bundle::Bundle, + children, + component::Component, + entity::Entity, + hierarchy::Children, + query::{Changed, Or, With}, + schedule::IntoScheduleConfigs, + spawn::SpawnRelated, + system::{In, Query}, +}; +use bevy_input_focus::tab_navigation::TabIndex; +use bevy_log::warn_once; +use bevy_picking::PickingSystems; +use bevy_ui::{ + AlignItems, BackgroundColor, BackgroundGradient, BorderColor, BorderRadius, ColorStop, Display, + FlexDirection, Gradient, InterpolationColorSpace, LinearGradient, MaterialNode, Node, Outline, + PositionType, UiRect, UiTransform, Val, Val2, ZIndex, +}; +use bevy_winit::cursor::CursorIcon; + +use crate::{ + alpha_pattern::{AlphaPattern, AlphaPatternMaterial}, + palette, + rounded_corners::RoundedCorners, +}; + +const SLIDER_HEIGHT: f32 = 16.0; +const TRACK_PADDING: f32 = 3.0; +const TRACK_RADIUS: f32 = SLIDER_HEIGHT * 0.5 - TRACK_PADDING; +const THUMB_SIZE: f32 = SLIDER_HEIGHT - 2.0; + +/// Indicates which color channel we want to edit. +#[derive(Component, Default, Clone)] +pub enum ColorChannel { + /// Editing the RGB red channel (0..=1) + #[default] + Red, + /// Editing the RGB green channel (0..=1) + Green, + /// Editing the RGB blue channel (0..=1) + Blue, + /// Editing the luminance channel (0..=1) + Lightness, + /// Editing the chroma / saturation channel (0..=1) + Chroma, + /// Editing the hue channel (0..=360) + Hue, + /// Editing the alpha channel (0..=1) + Alpha, +} + +impl ColorChannel { + /// Return the range of this color channel. + pub fn range(&self) -> SliderRange { + match self { + ColorChannel::Red + | ColorChannel::Green + | ColorChannel::Blue + | ColorChannel::Alpha + | ColorChannel::Chroma + | ColorChannel::Lightness => SliderRange::new(0., 1.), + ColorChannel::Hue => SliderRange::new(0., 360.), + } + } + + /// Return the color endpoints and midpoint of the gradient. This is determined by both the + /// channel being edited and the base color. + pub fn gradient_ends(&self, base_color: Color) -> (Color, Color, Color) { + match self { + ColorChannel::Red => { + let base_rgb = base_color.to_srgba(); + ( + Color::srgb(0.0, base_rgb.green, base_rgb.blue), + Color::srgb(0.5, base_rgb.green, base_rgb.blue), + Color::srgb(1.0, base_rgb.green, base_rgb.blue), + ) + } + + ColorChannel::Green => { + let base_rgb = base_color.to_srgba(); + ( + Color::srgb(base_rgb.red, 0.0, base_rgb.blue), + Color::srgb(base_rgb.red, 0.5, base_rgb.blue), + Color::srgb(base_rgb.red, 1.0, base_rgb.blue), + ) + } + + ColorChannel::Blue => { + let base_rgb = base_color.to_srgba(); + ( + Color::srgb(base_rgb.red, base_rgb.green, 0.0), + Color::srgb(base_rgb.red, base_rgb.green, 0.5), + Color::srgb(base_rgb.red, base_rgb.green, 1.0), + ) + } + + ColorChannel::Lightness => { + let base_oklcha: Oklcha = base_color.into(); + ( + Color::oklch(0.0, base_oklcha.chroma, base_oklcha.hue), + Color::oklch(0.5, base_oklcha.chroma, base_oklcha.hue), + Color::oklch(1.0, base_oklcha.chroma, base_oklcha.hue), + ) + } + + ColorChannel::Chroma => { + let base_oklcha: Oklcha = base_color.into(); + ( + Color::oklch(base_oklcha.lightness, 0.0, base_oklcha.hue), + Color::oklch(base_oklcha.lightness, 0.5, base_oklcha.hue), + Color::oklch(base_oklcha.lightness, 1.0, base_oklcha.hue), + ) + } + + // For now, hard-code to Oklcha since that's the only hue-rotation color space + // supported by [`ColorGradient`] currently. + ColorChannel::Hue => ( + Color::oklch(0.7, 0.3, 0.0 + 0.0001), + Color::oklch(0.7, 0.3, 180.0), + Color::oklch(0.7, 0.3, 360.0 - 0.0001), + ), + + ColorChannel::Alpha => ( + base_color.with_alpha(0.), + base_color.with_alpha(0.5), + base_color.with_alpha(1.), + ), + } + } +} + +/// Used to store the color channels that we are not editing: the components of the color +/// that are constant for this slider. +#[derive(Component, Default, Clone)] +pub struct BaseColor(pub Color); + +/// Slider template properties, passed to [`slider`] function. +pub struct ColorSliderProps { + /// Slider current value + pub value: f32, + /// On-change handler + pub on_change: Callback>, + /// Which color component we're editing + pub channel: ColorChannel, +} + +impl Default for ColorSliderProps { + fn default() -> Self { + Self { + value: 0.0, + on_change: Callback::Ignore, + channel: ColorChannel::Alpha, + } + } +} + +#[derive(Component, Default, Clone)] +#[require(CoreSlider, BaseColor(Color::WHITE))] +pub struct ColorSlider { + pub channel: ColorChannel, +} + +/// Marker for the track +#[derive(Component, Default, Clone)] +struct ColorSliderTrack; + +/// Marker for the thumb +#[derive(Component, Default, Clone)] +struct ColorSliderThumb; + +/// Spawn a new slider widget. +/// +/// # Arguments +/// +/// * `props` - construction properties for the slider. +/// * `overrides` - a bundle of components that are merged in with the normal slider components. +pub fn color_slider(props: ColorSliderProps, overrides: B) -> impl Bundle { + ( + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + height: Val::Px(SLIDER_HEIGHT), + align_items: AlignItems::Stretch, + flex_grow: 1.0, + ..Default::default() + }, + CoreSlider { + on_change: props.on_change, + track_click: TrackClick::Snap, + }, + ColorSlider { + channel: props.channel.clone(), + }, + SliderValue(props.value), + props.channel.range(), + CursorIcon::System(bevy_window::SystemCursorIcon::Pointer), + TabIndex(0), + overrides, + children![ + // track + ( + Node { + position_type: PositionType::Absolute, + left: Val::Px(0.), + right: Val::Px(0.), + top: Val::Px(TRACK_PADDING), + bottom: Val::Px(TRACK_PADDING), + ..Default::default() + }, + RoundedCorners::All.to_border_radius(TRACK_RADIUS), + ColorSliderTrack, + AlphaPattern, + MaterialNode::(Handle::default()), + children![ + // Left endcap + ( + Node { + width: Val::Px(THUMB_SIZE * 0.5), + ..Default::default() + }, + RoundedCorners::Left.to_border_radius(TRACK_RADIUS), + BackgroundColor(palette::X_AXIS), + ), + // Track with gradient + ( + Node { + flex_grow: 1.0, + ..Default::default() + }, + BackgroundGradient(vec![Gradient::Linear(LinearGradient { + angle: PI * 0.5, + stops: vec![ + ColorStop::new(Color::NONE, Val::Percent(0.)), + ColorStop::new(Color::NONE, Val::Percent(50.)), + ColorStop::new(Color::NONE, Val::Percent(100.)), + ], + color_space: InterpolationColorSpace::Srgb, + })]), + ZIndex(1), + children![( + Node { + position_type: PositionType::Absolute, + left: Val::Percent(0.), + top: Val::Percent(50.), + width: Val::Px(THUMB_SIZE), + height: Val::Px(THUMB_SIZE), + border: UiRect::all(Val::Px(2.0)), + ..Default::default() + }, + CoreSliderThumb, + ColorSliderThumb, + BorderRadius::MAX, + BorderColor::all(palette::WHITE), + Outline { + width: Val::Px(1.), + offset: Val::Px(0.), + color: palette::BLACK + }, + UiTransform::from_translation(Val2::new( + Val::Percent(-50.0), + Val::Percent(-50.0), + )) + )] + ), + // Right endcap + ( + Node { + width: Val::Px(THUMB_SIZE * 0.5), + ..Default::default() + }, + RoundedCorners::Right.to_border_radius(TRACK_RADIUS), + BackgroundColor(palette::Z_AXIS), + ), + ] + ), + ], + ) +} + +fn update_slider_pos( + mut q_sliders: Query< + (Entity, &SliderValue, &SliderRange), + ( + With, + Or<(Changed, Changed)>, + ), + >, + q_children: Query<&Children>, + mut q_slider_thumb: Query<&mut Node, With>, +) { + for (slider_ent, value, range) in q_sliders.iter_mut() { + for child in q_children.iter_descendants(slider_ent) { + if let Ok(mut thumb_node) = q_slider_thumb.get_mut(child) { + thumb_node.left = Val::Percent(range.thumb_position(value.0) * 100.0); + } + } + } +} + +fn update_track_color( + mut q_sliders: Query<(Entity, &ColorSlider, &BaseColor), Changed>, + q_children: Query<&Children>, + q_track: Query<(), With>, + mut q_background: Query<&mut BackgroundColor>, + mut q_gradient: Query<&mut BackgroundGradient>, +) { + for (slider_ent, slider, BaseColor(base_color)) in q_sliders.iter_mut() { + let (start, middle, end) = slider.channel.gradient_ends(*base_color); + if let Some(track_ent) = q_children + .iter_descendants(slider_ent) + .find(|ent| q_track.contains(*ent)) + { + let Ok(track_children) = q_children.get(track_ent) else { + continue; + }; + + if let Ok(mut cap_bg) = q_background.get_mut(track_children[0]) { + cap_bg.0 = start; + } + + if let Ok(mut gradient) = q_gradient.get_mut(track_children[1]) { + if let [Gradient::Linear(linear_gradient)] = &mut gradient.0[..] { + linear_gradient.stops[0].color = start; + linear_gradient.stops[1].color = middle; + linear_gradient.stops[2].color = end; + linear_gradient.color_space = match slider.channel { + ColorChannel::Red | ColorChannel::Green | ColorChannel::Blue => { + InterpolationColorSpace::Srgb + } + ColorChannel::Hue | ColorChannel::Lightness | ColorChannel::Chroma => { + InterpolationColorSpace::OkLch + } + ColorChannel::Alpha => match base_color { + Color::Srgba(_) => InterpolationColorSpace::Srgb, + Color::LinearRgba(_) => InterpolationColorSpace::LinearRgb, + Color::Oklaba(_) => InterpolationColorSpace::OkLab, + Color::Oklcha(_) => InterpolationColorSpace::OkLchLong, + _ => { + warn_once!( + "Unsupported color space for ColorSlider: {:?}", + base_color + ); + InterpolationColorSpace::Srgb + } + }, + }; + } + } + + if let Ok(mut cap_bg) = q_background.get_mut(track_children[2]) { + cap_bg.0 = end; + } + } + } +} + +/// Plugin which registers the systems for updating the slider styles. +pub struct ColorSliderPlugin; + +impl Plugin for ColorSliderPlugin { + fn build(&self, app: &mut bevy_app::App) { + app.add_systems( + PreUpdate, + (update_slider_pos, update_track_color).in_set(PickingSystems::Last), + ); + } +} diff --git a/crates/bevy_feathers/src/controls/gradient_slider.rs b/crates/bevy_feathers/src/controls/gradient_slider.rs deleted file mode 100644 index da86898efa651..0000000000000 --- a/crates/bevy_feathers/src/controls/gradient_slider.rs +++ /dev/null @@ -1,241 +0,0 @@ -use core::f32::consts::PI; - -use bevy_app::{Plugin, PreUpdate}; -use bevy_color::Color; -use bevy_core_widgets::{Callback, CoreSlider, SliderRange, SliderValue, TrackClick}; -use bevy_ecs::{ - bundle::Bundle, - children, - component::Component, - entity::Entity, - hierarchy::Children, - lifecycle::RemovedComponents, - query::{Added, Changed, Has, Or, Spawned, With}, - schedule::IntoScheduleConfigs, - spawn::SpawnRelated, - system::{In, Query, Res}, -}; -use bevy_input_focus::tab_navigation::TabIndex; -use bevy_picking::PickingSystems; -use bevy_ui::{ - widget::Text, AlignItems, BackgroundGradient, ColorStop, Display, FlexDirection, Gradient, - InteractionDisabled, InterpolationColorSpace, LinearGradient, Node, UiRect, Val, -}; -use bevy_winit::cursor::CursorIcon; - -use crate::{constants::size, rounded_corners::RoundedCorners, theme::UiTheme, tokens}; - -pub enum ColorChannel { - Red, - Green, - Blue, - Alpha, -} - -impl ColorChannel { - /// Return the range of this color channel. - pub fn range(&self) -> SliderRange { - match self { - ColorChannel::Red | ColorChannel::Green | ColorChannel::Blue | ColorChannel::Alpha => { - SliderRange::new(0., 1.) - } - } - } -} - -/// Slider template properties, passed to [`slider`] function. -pub struct GradientSliderProps { - /// Slider current value - pub value: f32, - /// On-change handler - pub on_change: Callback>, - /// Which color component we're editing - pub channel: ColorChannel, -} - -impl Default for GradientSliderProps { - fn default() -> Self { - Self { - value: 0.0, - on_change: Callback::Ignore, - channel: ColorChannel::Alpha, - } - } -} - -#[derive(Component, Default, Clone)] -#[require(CoreSlider)] -struct GradientSlider; - -/// Marker for the thumb -#[derive(Component, Default, Clone)] -struct GradientSliderThumb; - -#[derive(Component, Default, Clone)] -#[require(CoreSlider)] -struct GradientSliderEndcap0; - -#[derive(Component, Default, Clone)] -#[require(CoreSlider)] -struct GradientSliderTrack; - -#[derive(Component, Default, Clone)] -#[require(CoreSlider)] -struct GradientSliderEndcap1; - -/// Spawn a new slider widget. -/// -/// # Arguments -/// -/// * `props` - construction properties for the slider. -/// * `overrides` - a bundle of components that are merged in with the normal slider components. -pub fn gradient_slider(props: GradientSliderProps, overrides: B) -> impl Bundle { - ( - Node { - display: Display::Flex, - flex_direction: FlexDirection::Row, - height: size::ROW_HEIGHT, - align_items: AlignItems::Stretch, - flex_grow: 1.0, - padding: UiRect::axes(Val::Px(0.), Val::Px(2.)), - ..Default::default() - }, - CoreSlider { - on_change: props.on_change, - track_click: TrackClick::Drag, - }, - GradientSlider, - SliderValue(props.value), - props.channel.range(), - CursorIcon::System(bevy_window::SystemCursorIcon::EwResize), - TabIndex(0), - overrides, - children![ - // Left endcap - ( - Node { - width: Val::Px(6.0), - ..Default::default() - }, - RoundedCorners::Left.to_border_radius(6.0), - GradientSliderEndcap0 - ), - // Track with gradient - ( - Node { - flex_grow: 1.0, - ..Default::default() - }, - GradientSliderTrack, - BackgroundGradient(vec![Gradient::Linear(LinearGradient { - angle: PI * 0.5, - stops: vec![ - ColorStop::new(Color::NONE, Val::Percent(0.)), - ColorStop::new(Color::NONE, Val::Percent(50.)), - ColorStop::new(Color::NONE, Val::Percent(50.)), - ColorStop::new(Color::NONE, Val::Percent(100.)), - ], - color_space: InterpolationColorSpace::Srgb, - })]), - ), - // Right endcap - ( - Node { - width: Val::Px(6.0), - ..Default::default() - }, - RoundedCorners::Right.to_border_radius(6.0), - GradientSliderEndcap1 - ), - ], - ) -} - -fn update_slider_colors( - mut q_sliders: Query< - (Has, &mut BackgroundGradient), - ( - With, - Or<(Spawned, Added)>, - ), - >, - theme: Res, -) { - for (disabled, mut gradient) in q_sliders.iter_mut() { - set_slider_colors(&theme, disabled, gradient.as_mut()); - } -} - -fn update_slider_colors_remove( - mut q_sliders: Query<(Has, &mut BackgroundGradient)>, - mut removed_disabled: RemovedComponents, - theme: Res, -) { - removed_disabled.read().for_each(|ent| { - if let Ok((disabled, mut gradient)) = q_sliders.get_mut(ent) { - set_slider_colors(&theme, disabled, gradient.as_mut()); - } - }); -} - -fn set_slider_colors(theme: &Res<'_, UiTheme>, disabled: bool, gradient: &mut BackgroundGradient) { - let bar_color = theme.color(match disabled { - true => tokens::SLIDER_BAR_DISABLED, - false => tokens::SLIDER_BAR, - }); - let bg_color = theme.color(tokens::SLIDER_BG); - if let [Gradient::Linear(linear_gradient)] = &mut gradient.0[..] { - linear_gradient.stops[0].color = bar_color; - linear_gradient.stops[1].color = bar_color; - linear_gradient.stops[2].color = bg_color; - linear_gradient.stops[3].color = bg_color; - } -} - -fn update_slider_pos( - mut q_sliders: Query< - (Entity, &SliderValue, &SliderRange, &mut BackgroundGradient), - ( - With, - Or<( - Changed, - Changed, - Changed, - )>, - ), - >, - q_children: Query<&Children>, - mut q_slider_text: Query<&mut Text, With>, -) { - for (slider_ent, value, range, mut gradient) in q_sliders.iter_mut() { - if let [Gradient::Linear(linear_gradient)] = &mut gradient.0[..] { - let percent_value = range.thumb_position(value.0) * 100.0; - linear_gradient.stops[1].point = Val::Percent(percent_value); - linear_gradient.stops[2].point = Val::Percent(percent_value); - } - - // Find slider text child entity and update its text with the formatted value - q_children.iter_descendants(slider_ent).for_each(|child| { - if let Ok(mut text) = q_slider_text.get_mut(child) { - text.0 = format!("{}", value.0); - } - }); - } -} - -/// Plugin which registers the systems for updating the slider styles. -pub struct GradientSliderPlugin; - -impl Plugin for GradientSliderPlugin { - fn build(&self, app: &mut bevy_app::App) { - app.add_systems( - PreUpdate, - ( - update_slider_colors, - update_slider_colors_remove, - update_slider_pos, - ) - .in_set(PickingSystems::Last), - ); - } -} diff --git a/crates/bevy_feathers/src/controls/mod.rs b/crates/bevy_feathers/src/controls/mod.rs index e28f8164225d8..8f2195d17e812 100644 --- a/crates/bevy_feathers/src/controls/mod.rs +++ b/crates/bevy_feathers/src/controls/mod.rs @@ -3,14 +3,15 @@ use bevy_app::Plugin; mod button; mod checkbox; +mod color_slider; mod color_swatch; -mod gradient_slider; mod radio; mod slider; mod toggle_switch; pub use button::{button, ButtonPlugin, ButtonProps, ButtonVariant}; pub use checkbox::{checkbox, CheckboxPlugin, CheckboxProps}; +pub use color_slider::{color_slider, ColorChannel, ColorSliderPlugin, ColorSliderProps}; pub use color_swatch::color_swatch; pub use radio::{radio, RadioPlugin}; pub use slider::{slider, SliderPlugin, SliderProps}; @@ -27,6 +28,7 @@ impl Plugin for ControlsPlugin { AlphaPatternPlugin, ButtonPlugin, CheckboxPlugin, + ColorSliderPlugin, RadioPlugin, SliderPlugin, ToggleSwitchPlugin, diff --git a/examples/ui/feathers.rs b/examples/ui/feathers.rs index 776a62eab1ce3..a8b9cbfa036fc 100644 --- a/examples/ui/feathers.rs +++ b/examples/ui/feathers.rs @@ -4,8 +4,9 @@ use bevy::{ core_widgets::{Callback, CoreRadio, CoreRadioGroup, CoreWidgetsPlugin, SliderStep}, feathers::{ controls::{ - button, checkbox, radio, slider, toggle_switch, ButtonProps, ButtonVariant, - CheckboxProps, SliderProps, ToggleSwitchProps, + button, checkbox, color_slider, color_swatch, radio, slider, toggle_switch, + ButtonProps, ButtonVariant, CheckboxProps, ColorChannel, ColorSliderProps, SliderProps, + ToggleSwitchProps, }, dark_theme::create_dark_theme, rounded_corners::RoundedCorners, @@ -261,7 +262,80 @@ fn demo_root(commands: &mut Commands) -> impl Bundle { }, SliderStep(10.) ), - color_swatch(()) + ( + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::SpaceBetween, + ..default() + }, + children![Text("Srgba".to_owned()), color_swatch(()),] + ), + color_slider( + ColorSliderProps { + value: 0.5, + on_change: Callback::Ignore, + channel: ColorChannel::Red + }, + () + ), + color_slider( + ColorSliderProps { + value: 0.5, + on_change: Callback::Ignore, + channel: ColorChannel::Green + }, + () + ), + color_slider( + ColorSliderProps { + value: 0.5, + on_change: Callback::Ignore, + channel: ColorChannel::Blue + }, + () + ), + color_slider( + ColorSliderProps { + value: 0.5, + on_change: Callback::Ignore, + channel: ColorChannel::Alpha + }, + () + ), + ( + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::SpaceBetween, + ..default() + }, + children![Text("Oklcha".to_owned()), color_swatch(()),] + ), + color_slider( + ColorSliderProps { + value: 0.5, + on_change: Callback::Ignore, + channel: ColorChannel::Hue + }, + () + ), + color_slider( + ColorSliderProps { + value: 0.5, + on_change: Callback::Ignore, + channel: ColorChannel::Chroma + }, + () + ), + color_slider( + ColorSliderProps { + value: 0.5, + on_change: Callback::Ignore, + channel: ColorChannel::Lightness + }, + () + ) ] ),], ) From f755c0b9fe46696ffb2bbec4179a1348f42c1bee Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Sun, 6 Jul 2025 21:30:16 +0100 Subject: [PATCH 04/16] Added colour interpolation path variants for hsv and hsl --- crates/bevy_ui/src/gradients.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/bevy_ui/src/gradients.rs b/crates/bevy_ui/src/gradients.rs index eb1d255cc72d2..7086b374167cf 100644 --- a/crates/bevy_ui/src/gradients.rs +++ b/crates/bevy_ui/src/gradients.rs @@ -631,6 +631,14 @@ pub enum InterpolationColorSpace { Srgb, /// Interpolates in linear sRGB space. LinearRgb, + /// Interpolates in HSL space, taking the shortest hue path. + Hsl, + /// Interpolates in HSL space, taking the longest hue path. + HslLong, + /// Interpolates in HSV space, taking the shortest hue path. + Hsv, + /// Interpolates in HSV space, taking the longest hue path. + HsvLong, } /// Set the color space used for interpolation. From 263948efa2976997efd4d79b573cc774b6b50b91 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Sun, 6 Jul 2025 22:03:45 +0100 Subject: [PATCH 05/16] Added shader specializations for hsl and hsv colour interpolation --- crates/bevy_ui_render/src/gradient.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/bevy_ui_render/src/gradient.rs b/crates/bevy_ui_render/src/gradient.rs index 9bef5340cb9df..12cfcbeb68cb8 100644 --- a/crates/bevy_ui_render/src/gradient.rs +++ b/crates/bevy_ui_render/src/gradient.rs @@ -186,6 +186,10 @@ impl SpecializedRenderPipeline for GradientPipeline { InterpolationColorSpace::OkLchLong => "IN_OKLCH_LONG", InterpolationColorSpace::Srgb => "IN_SRGB", InterpolationColorSpace::LinearRgb => "IN_LINEAR_RGB", + InterpolationColorSpace::Hsl => "IN_HSL", + InterpolationColorSpace::HslLong => "IN_HSL_LONG", + InterpolationColorSpace::Hsv => "IN_HSV", + InterpolationColorSpace::HsvLong => "IN_HSV_LONG", }; let shader_defs = if key.anti_alias { From 1b3b56354537f6048c4731f7c7e3709c7af86e98 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Sun, 6 Jul 2025 22:06:44 +0100 Subject: [PATCH 06/16] Added hsv and hsl interpolation to gradients shader --- crates/bevy_ui_render/src/gradient.wgsl | 155 +++++++++++++++++++++++- 1 file changed, 152 insertions(+), 3 deletions(-) diff --git a/crates/bevy_ui_render/src/gradient.wgsl b/crates/bevy_ui_render/src/gradient.wgsl index 074cf35a35f03..c39a6d3244863 100644 --- a/crates/bevy_ui_render/src/gradient.wgsl +++ b/crates/bevy_ui_render/src/gradient.wgsl @@ -31,7 +31,7 @@ struct GradientVertexOutput { @location(0) uv: vec2, @location(1) @interpolate(flat) size: vec2, @location(2) @interpolate(flat) flags: u32, - @location(3) @interpolate(flat) radius: vec4, + @location(3) @interpolate(flat) radius: vec4, @location(4) @interpolate(flat) border: vec4, // Position relative to the center of the rectangle. @@ -129,7 +129,7 @@ fn linear_rgb_to_oklab(c: vec4) -> vec4 { return vec4( 0.21045426 * l + 0.7936178 * m - 0.004072047 * s, 1.9779985 * l - 2.4285922 * m + 0.4505937 * s, - 0.025904037 * l + 0.78277177 * m - 0.80867577 * s, + 0.025904037 * l + 0.78277177 * m - 0.80867577 * s, c.w ); } @@ -149,7 +149,108 @@ fn oklab_to_linear_rgba(c: vec4) -> vec4 { ); } -fn mix_linear_rgb_in_oklab_space(a: vec4, b: vec4, t: f32) -> vec4 { +fn linear_rgb_to_hsl(c: vec4) -> vec4 { + let maxc = max(max(c.r, c.g), b); + let minc = min(min(c.r, c.g), b); + let delta = maxc - minc; + let l = (maxc + minc) * 0.5; + var h: f32 = 0.0; + var s: f32 = 0.0; + if delta != 0.0 { + s = delta / (1.0 - abs(2.0 * l - 1.0)); + if maxc == c.r { + h = ((c.g - c.b) / delta) % 6.0; + } else if maxc == g { + h = ((c.b - c.r) / delta) + 2.0; + } else { + h = ((c.r - c.g) / delta) + 4.0; + } + h = h / 6.0; + if h < 0.0 { + h = h + 1.0; + } + } + return vec4(h, s, l, c.a); +} + +fn hsl_to_linear_rgb(hsl: vec4) -> vec4 { + let h = hsl.x; + let s = hsl.y; + let l = hsl.z; + let c = (1.0 - abs(2.0 * l - 1.0)) * s; + let hp = h * 6.0; + let x = c * (1.0 - abs(hp % 2.0 - 1.0)); + var r: f32 = 0.0; + var g: f32 = 0.0; + var b: f32 = 0.0; + if 0.0 <= hp && hp < 1.0 { + r = c; g = x; b = 0.0; + } else if 1.0 <= hp && hp < 2.0 { + r = x; g = c; b = 0.0; + } else if 2.0 <= hp && hp < 3.0 { + r = 0.0; g = c; b = x; + } else if 3.0 <= hp && hp < 4.0 { + r = 0.0; g = x; b = c; + } else if 4.0 <= hp && hp < 5.0 { + r = x; g = 0.0; b = c; + } else if 5.0 <= hp && hp < 6.0 { + r = c; g = 0.0; b = x; + } + let m = l - 0.5 * c; + return vec4(r + m, g + m, b + m, hsl.a); +} + +fn linear_rgba_to_hsva(c: vec4) -> vec4 { + let maxc = max(max(c.r, c.g), c.b); + let minc = min(min(c.r, c.g), c.b); + let delta = maxc - minc; + var h: f32 = 0.0; + var s: f32 = 0.0; + let v: f32 = maxc; + if delta != 0.0 { + s = delta / maxc; + if maxc == r { + h = ((c.g - c.b) / delta) % 6.0; + } else if maxc == c.g { + h = ((c.b - c.r) / delta) + 2.0; + } else { + h = ((c.r - c.g) / delta) + 4.0; + } + h = h / 6.0; + if h < 0.0 { + h = h + 1.0; + } + } + return vec4(h, s, v, c.a); +} + +fn hsva_to_linear_rgba(hsva: vec4) -> vec4 { + let h = hsva.x * 6.0; + let s = hsva.y; + let v = hsva.z; + let c = v * s; + let x = c * (1.0 - abs(h % 2.0 - 1.0)); + let m = v - c; + var r: f32 = 0.0; + var g: f32 = 0.0; + var b: f32 = 0.0; + if 0.0 <= h && h < 1.0 { + r = c; g = x; b = 0.0; + } else if 1.0 <= h && h < 2.0 { + r = x; g = c; b = 0.0; + } else if 2.0 <= h && h < 3.0 { + r = 0.0; g = c; b = x; + } else if 3.0 <= h && h < 4.0 { + r = 0.0; g = x; b = c; + } else if 4.0 <= h && h < 5.0 { + r = x; g = 0.0; b = c; + } else if 5.0 <= h && h < 6.0 { + r = c; g = 0.0; b = x; + } + return vec4(r + m, g + m, b + m, hsva.a); +} + +fn mix_linear_rgb_in_oklab_space(a: vec4, b: vec4, t: f32) -> vec4 { return oklab_to_linear_rgba(mix(linear_rgb_to_oklab(a), linear_rgb_to_oklab(b), t)); } @@ -205,6 +306,46 @@ fn mix_linear_rgb_in_oklch_space_long(a: vec4, b: vec4, t: f32) -> vec return oklch_to_linear_rgb(mix_oklch_long(linear_rgb_to_oklch(a), linear_rgb_to_oklch(b), t)); } +fn mix_linear_rgb_in_hsv_space(a: vec4, b: vec4, t: f32) -> vec4 { + let ha = linear_rgba_to_hsva(a); + let hb = linear_rgba_to_hsva(b); + let h = lerp_hue(ha.x * TAU, hb.x * TAU, t) / TAU; + let s = mix(ha.y, hb.y, t); + let v = mix(ha.z, hb.z, t); + let a_alpha = mix(ha.w, hb.w, t); + return hsva_to_linear_rgba(vec4(h, s, v, a_alpha)); +} + +fn mix_linear_rgb_in_hsv_space_long(a: vec4, b: vec4, t: f32) -> vec4 { + let ha = linear_rgba_to_hsva(a); + let hb = linear_rgba_to_hsva(b); + let h = lerp_hue_long(ha.x * TAU, hb.x * TAU, t) / TAU; + let s = mix(ha.y, hb.y, t); + let v = mix(ha.z, hb.z, t); + let a_alpha = mix(ha.w, hb.w, t); + return hsva_to_linear_rgba(vec4(h, s, v, a_alpha)); +} + +fn mix_linear_rgb_in_hsl_space(a: vec4, b: vec4, t: f32) -> vec4 { + let ha = linear_rgb_to_hsl(a); + let hb = linear_rgb_to_hsl(b); + let h = lerp_hue(ha.x * TAU, hb.x * TAU, t) / TAU; + let s = mix(ha.y, hb.y, t); + let l = mix(ha.z, hb.z, t); + let a_alpha = mix(ha.w, hb.w, t); + return hsl_to_linear_rgb(vec4(h, s, l, a_alpha)); +} + +fn mix_linear_rgb_in_hsl_space_long(a: vec4, b: vec4, t: f32) -> vec4 { + let ha = linear_rgb_to_hsl(a); + let hb = linear_rgb_to_hsl(b); + let h = lerp_hue_long(ha.x * TAU, hb.x * TAU, t) / TAU; + let s = mix(ha.y, hb.y, t); + let l = mix(ha.z, hb.z, t); + let a_alpha = mix(ha.w, hb.w, t); + return hsl_to_linear_rgb(vec4(h, s, l, a_alpha)); +} + // These functions are used to calculate the distance in gradient space from the start of the gradient to the point. // The distance in gradient space is then used to interpolate between the start and end colors. @@ -284,6 +425,14 @@ fn interpolate_gradient( return mix_linear_rgb_in_oklch_space(start_color, end_color, t); #else ifdef IN_OKLCH_LONG return mix_linear_rgb_in_oklch_space_long(start_color, end_color, t); +#else ifdef IN_HSV + return mix_linear_rgb_in_hsv_space(start_color, end_color, t); +#else ifdef IN_HSV_LONG + return mix_linear_rgb_in_hsv_space_long(start_color, end_color, t); +#else ifdef IN_HSL + return mix_linear_rgb_in_hsl_space(start_color, end_color, t); +#else ifdef IN_HSL_LONG + return mix_linear_rgb_in_hsl_space_long(start_color, end_color, t); #else return mix(start_color, end_color, t); #endif From ffcf323f98b19e70fd80941872c74b3d89df81f2 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Sun, 6 Jul 2025 22:06:59 +0100 Subject: [PATCH 07/16] updated gradients example --- examples/ui/gradients.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/examples/ui/gradients.rs b/examples/ui/gradients.rs index 72b915813c673..82c69d4f8ecbb 100644 --- a/examples/ui/gradients.rs +++ b/examples/ui/gradients.rs @@ -245,6 +245,18 @@ fn setup(mut commands: Commands) { InterpolationColorSpace::LinearRgb } InterpolationColorSpace::LinearRgb => { + InterpolationColorSpace::Hsl + } + InterpolationColorSpace::Hsl => { + InterpolationColorSpace::HslLong + } + InterpolationColorSpace::HslLong => { + InterpolationColorSpace::Hsv + } + InterpolationColorSpace::Hsv => { + InterpolationColorSpace::HsvLong + } + InterpolationColorSpace::HsvLong => { InterpolationColorSpace::OkLab } }; From c2f5fa4bf71bb9c1ebc7229f40208feb46219649 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Sun, 6 Jul 2025 22:09:39 +0100 Subject: [PATCH 08/16] fixes for a few syntax errors --- crates/bevy_ui_render/src/gradient.wgsl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/bevy_ui_render/src/gradient.wgsl b/crates/bevy_ui_render/src/gradient.wgsl index c39a6d3244863..d272907b52223 100644 --- a/crates/bevy_ui_render/src/gradient.wgsl +++ b/crates/bevy_ui_render/src/gradient.wgsl @@ -150,8 +150,8 @@ fn oklab_to_linear_rgba(c: vec4) -> vec4 { } fn linear_rgb_to_hsl(c: vec4) -> vec4 { - let maxc = max(max(c.r, c.g), b); - let minc = min(min(c.r, c.g), b); + let maxc = max(max(c.r, c.g), c.b); + let minc = min(min(c.r, c.g), c.b); let delta = maxc - minc; let l = (maxc + minc) * 0.5; var h: f32 = 0.0; @@ -160,7 +160,7 @@ fn linear_rgb_to_hsl(c: vec4) -> vec4 { s = delta / (1.0 - abs(2.0 * l - 1.0)); if maxc == c.r { h = ((c.g - c.b) / delta) % 6.0; - } else if maxc == g { + } else if maxc == c.g { h = ((c.b - c.r) / delta) + 2.0; } else { h = ((c.r - c.g) / delta) + 4.0; @@ -209,7 +209,7 @@ fn linear_rgba_to_hsva(c: vec4) -> vec4 { let v: f32 = maxc; if delta != 0.0 { s = delta / maxc; - if maxc == r { + if maxc == c.r { h = ((c.g - c.b) / delta) % 6.0; } else if maxc == c.g { h = ((c.b - c.r) / delta) + 2.0; From a497ebcc957a6e806cd4f19bfb40d3356d6cdc0e Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Mon, 7 Jul 2025 10:57:19 +0100 Subject: [PATCH 09/16] shader cleanup, use -a suffix for colour spaces consistantly --- crates/bevy_ui_render/src/gradient.wgsl | 94 ++++++++++++------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/crates/bevy_ui_render/src/gradient.wgsl b/crates/bevy_ui_render/src/gradient.wgsl index d272907b52223..bec0300e6b89a 100644 --- a/crates/bevy_ui_render/src/gradient.wgsl +++ b/crates/bevy_ui_render/src/gradient.wgsl @@ -114,15 +114,15 @@ fn fragment(in: GradientVertexOutput) -> @location(0) vec4 { } } -// This function converts two linear rgb colors to srgb space, mixes them, and then converts the result back to linear rgb space. -fn mix_linear_rgb_in_srgb_space(a: vec4, b: vec4, t: f32) -> vec4 { +// This function converts two linear rgba colors to srgba space, mixes them, and then converts the result back to linear rgb space. +fn mix_linear_rgba_in_srgba_space(a: vec4, b: vec4, t: f32) -> vec4 { let a_srgb = pow(a.rgb, vec3(1. / 2.2)); let b_srgb = pow(b.rgb, vec3(1. / 2.2)); let mixed_srgb = mix(a_srgb, b_srgb, t); return vec4(pow(mixed_srgb, vec3(2.2)), mix(a.a, b.a, t)); } -fn linear_rgb_to_oklab(c: vec4) -> vec4 { +fn linear_rgba_to_oklaba(c: vec4) -> vec4 { let l = pow(0.41222146 * c.x + 0.53633255 * c.y + 0.051445995 * c.z, 1. / 3.); let m = pow(0.2119035 * c.x + 0.6806995 * c.y + 0.10739696 * c.z, 1. / 3.); let s = pow(0.08830246 * c.x + 0.28171885 * c.y + 0.6299787 * c.z, 1. / 3.); @@ -130,11 +130,11 @@ fn linear_rgb_to_oklab(c: vec4) -> vec4 { 0.21045426 * l + 0.7936178 * m - 0.004072047 * s, 1.9779985 * l - 2.4285922 * m + 0.4505937 * s, 0.025904037 * l + 0.78277177 * m - 0.80867577 * s, - c.w + c.a ); } -fn oklab_to_linear_rgba(c: vec4) -> vec4 { +fn oklaba_to_linear_rgba(c: vec4) -> vec4 { let l_ = c.x + 0.39633778 * c.y + 0.21580376 * c.z; let m_ = c.x - 0.105561346 * c.y - 0.06385417 * c.z; let s_ = c.x - 0.08948418 * c.y - 1.2914855 * c.z; @@ -145,11 +145,15 @@ fn oklab_to_linear_rgba(c: vec4) -> vec4 { 4.0767417 * l - 3.3077116 * m + 0.23096994 * s, -1.268438 * l + 2.6097574 * m - 0.34131938 * s, -0.0041960863 * l - 0.7034186 * m + 1.7076147 * s, - c.w + c.a ); } -fn linear_rgb_to_hsl(c: vec4) -> vec4 { +fn mix_linear_rgba_in_oklaba_space(a: vec4, b: vec4, t: f32) -> vec4 { + return oklaba_to_linear_rgba(mix(linear_rgba_to_oklaba(a), linear_rgba_to_oklaba(b), t)); +} + +fn linear_rgba_to_hsla(c: vec4) -> vec4 { let maxc = max(max(c.r, c.g), c.b); let minc = min(min(c.r, c.g), c.b); let delta = maxc - minc; @@ -173,7 +177,7 @@ fn linear_rgb_to_hsl(c: vec4) -> vec4 { return vec4(h, s, l, c.a); } -fn hsl_to_linear_rgb(hsl: vec4) -> vec4 { +fn hsla_to_linear_rgba(hsl: vec4) -> vec4 { let h = hsl.x; let s = hsl.y; let l = hsl.z; @@ -250,22 +254,18 @@ fn hsva_to_linear_rgba(hsva: vec4) -> vec4 { return vec4(r + m, g + m, b + m, hsva.a); } -fn mix_linear_rgb_in_oklab_space(a: vec4, b: vec4, t: f32) -> vec4 { - return oklab_to_linear_rgba(mix(linear_rgb_to_oklab(a), linear_rgb_to_oklab(b), t)); -} - /// hue is left in radians and not converted to degrees -fn linear_rgb_to_oklch(c: vec4) -> vec4 { - let o = linear_rgb_to_oklab(c); +fn linear_rgba_to_oklcha(c: vec4) -> vec4 { + let o = linear_rgba_to_oklaba(c); let chroma = sqrt(o.y * o.y + o.z * o.z); let hue = atan2(o.z, o.y); - return vec4(o.x, chroma, select(hue + TAU, hue, hue < 0.0), o.w); + return vec4(o.x, chroma, select(hue + TAU, hue, hue < 0.0), o.a); } -fn oklch_to_linear_rgb(c: vec4) -> vec4 { +fn oklcha_to_linear_rgba(c: vec4) -> vec4 { let a = c.y * cos(c.z); let b = c.y * sin(c.z); - return oklab_to_linear_rgba(vec4(c.x, a, b, c.w)); + return oklaba_to_linear_rgba(vec4(c.x, a, b, c.a)); } fn rem_euclid(a: f32, b: f32) -> f32 { @@ -282,68 +282,68 @@ fn lerp_hue_long(a: f32, b: f32, t: f32) -> f32 { return rem_euclid(a + select(diff - TAU, diff + TAU, 0. < diff) * t, TAU); } -fn mix_oklch(a: vec4, b: vec4, t: f32) -> vec4 { +fn mix_oklcha(a: vec4, b: vec4, t: f32) -> vec4 { return vec4( mix(a.xy, b.xy, t), lerp_hue(a.z, b.z, t), - mix(a.w, b.w, t) + mix(a.a, b.a, t) ); } -fn mix_oklch_long(a: vec4, b: vec4, t: f32) -> vec4 { +fn mix_oklcha_long(a: vec4, b: vec4, t: f32) -> vec4 { return vec4( mix(a.xy, b.xy, t), lerp_hue_long(a.z, b.z, t), - mix(a.w, b.w, t) + mix(a.a, b.a, t) ); } -fn mix_linear_rgb_in_oklch_space(a: vec4, b: vec4, t: f32) -> vec4 { - return oklch_to_linear_rgb(mix_oklch(linear_rgb_to_oklch(a), linear_rgb_to_oklch(b), t)); +fn mix_linear_rgba_in_oklcha_space(a: vec4, b: vec4, t: f32) -> vec4 { + return oklcha_to_linear_rgba(mix_oklcha(linear_rgba_to_oklcha(a), linear_rgba_to_oklcha(b), t)); } -fn mix_linear_rgb_in_oklch_space_long(a: vec4, b: vec4, t: f32) -> vec4 { - return oklch_to_linear_rgb(mix_oklch_long(linear_rgb_to_oklch(a), linear_rgb_to_oklch(b), t)); +fn mix_linear_rgba_in_oklcha_space_long(a: vec4, b: vec4, t: f32) -> vec4 { + return oklcha_to_linear_rgba(mix_oklcha_long(linear_rgba_to_oklcha(a), linear_rgba_to_oklcha(b), t)); } -fn mix_linear_rgb_in_hsv_space(a: vec4, b: vec4, t: f32) -> vec4 { +fn mix_linear_rgba_in_hsva_space(a: vec4, b: vec4, t: f32) -> vec4 { let ha = linear_rgba_to_hsva(a); let hb = linear_rgba_to_hsva(b); let h = lerp_hue(ha.x * TAU, hb.x * TAU, t) / TAU; let s = mix(ha.y, hb.y, t); let v = mix(ha.z, hb.z, t); - let a_alpha = mix(ha.w, hb.w, t); + let a_alpha = mix(ha.a, hb.a, t); return hsva_to_linear_rgba(vec4(h, s, v, a_alpha)); } -fn mix_linear_rgb_in_hsv_space_long(a: vec4, b: vec4, t: f32) -> vec4 { +fn mix_linear_rgba_in_hsva_space_long(a: vec4, b: vec4, t: f32) -> vec4 { let ha = linear_rgba_to_hsva(a); let hb = linear_rgba_to_hsva(b); let h = lerp_hue_long(ha.x * TAU, hb.x * TAU, t) / TAU; let s = mix(ha.y, hb.y, t); let v = mix(ha.z, hb.z, t); - let a_alpha = mix(ha.w, hb.w, t); + let a_alpha = mix(ha.a, hb.a, t); return hsva_to_linear_rgba(vec4(h, s, v, a_alpha)); } -fn mix_linear_rgb_in_hsl_space(a: vec4, b: vec4, t: f32) -> vec4 { - let ha = linear_rgb_to_hsl(a); - let hb = linear_rgb_to_hsl(b); +fn mix_linear_rgba_in_hsla_space(a: vec4, b: vec4, t: f32) -> vec4 { + let ha = linear_rgba_to_hsla(a); + let hb = linear_rgba_to_hsla(b); let h = lerp_hue(ha.x * TAU, hb.x * TAU, t) / TAU; let s = mix(ha.y, hb.y, t); let l = mix(ha.z, hb.z, t); - let a_alpha = mix(ha.w, hb.w, t); - return hsl_to_linear_rgb(vec4(h, s, l, a_alpha)); + let a_alpha = mix(ha.a, hb.a, t); + return hsla_to_linear_rgba(vec4(h, s, l, a_alpha)); } -fn mix_linear_rgb_in_hsl_space_long(a: vec4, b: vec4, t: f32) -> vec4 { - let ha = linear_rgb_to_hsl(a); - let hb = linear_rgb_to_hsl(b); +fn mix_linear_rgba_in_hsla_space_long(a: vec4, b: vec4, t: f32) -> vec4 { + let ha = linear_rgba_to_hsla(a); + let hb = linear_rgba_to_hsla(b); let h = lerp_hue_long(ha.x * TAU, hb.x * TAU, t) / TAU; let s = mix(ha.y, hb.y, t); let l = mix(ha.z, hb.z, t); - let a_alpha = mix(ha.w, hb.w, t); - return hsl_to_linear_rgb(vec4(h, s, l, a_alpha)); + let a_alpha = mix(ha.a, hb.a, t); + return hsla_to_linear_rgba(vec4(h, s, l, a_alpha)); } // These functions are used to calculate the distance in gradient space from the start of the gradient to the point. @@ -418,21 +418,21 @@ fn interpolate_gradient( } #ifdef IN_SRGB - return mix_linear_rgb_in_srgb_space(start_color, end_color, t); + return mix_linear_rgba_in_srgba_space(start_color, end_color, t); #else ifdef IN_OKLAB - return mix_linear_rgb_in_oklab_space(start_color, end_color, t); + return mix_linear_rgba_in_oklaba_space(start_color, end_color, t); #else ifdef IN_OKLCH - return mix_linear_rgb_in_oklch_space(start_color, end_color, t); + return mix_linear_rgba_in_oklcha_space(start_color, end_color, t); #else ifdef IN_OKLCH_LONG - return mix_linear_rgb_in_oklch_space_long(start_color, end_color, t); + return mix_linear_rgba_in_oklcha_space_long(start_color, end_color, t); #else ifdef IN_HSV - return mix_linear_rgb_in_hsv_space(start_color, end_color, t); + return mix_linear_rgba_in_hsva_space(start_color, end_color, t); #else ifdef IN_HSV_LONG - return mix_linear_rgb_in_hsv_space_long(start_color, end_color, t); + return mix_linear_rgba_in_hsva_space_long(start_color, end_color, t); #else ifdef IN_HSL - return mix_linear_rgb_in_hsl_space(start_color, end_color, t); + return mix_linear_rgba_in_hsla_space(start_color, end_color, t); #else ifdef IN_HSL_LONG - return mix_linear_rgb_in_hsl_space_long(start_color, end_color, t); + return mix_linear_rgba_in_hsla_space_long(start_color, end_color, t); #else return mix(start_color, end_color, t); #endif From f0a2b9543b179095f7280df210d98f839568eb45 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Mon, 7 Jul 2025 12:41:34 +0100 Subject: [PATCH 10/16] Updated gradients release note --- release-content/release-notes/ui_gradients.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/release-content/release-notes/ui_gradients.md b/release-content/release-notes/ui_gradients.md index 15ea3bf5edb0f..147377087cd63 100644 --- a/release-content/release-notes/ui_gradients.md +++ b/release-content/release-notes/ui_gradients.md @@ -1,7 +1,7 @@ --- title: UI Gradients authors: ["@Ickshonpe"] -pull_requests: [18139, 19330] +pull_requests: [18139, 19330, 19992] --- Support for UI node's that display a gradient that transitions smoothly between two or more colors. @@ -19,7 +19,7 @@ vec![vec![ColorStop::new(RED, Val::Percent(90.), ColorStop::new(Color::GREEN, Va the colors will be reordered and the gradient will transition from green at 10% to red at 90%. -Colors can be interpolated between the stops in OKLab, OKLCH, SRGB and linear RGB color spaces. The hint is a normalized value that can be used to shift the mid-point where the colors are mixed 50-50 between the stop with the hint and the following stop. +Colors can be interpolated between the stops in OKLab, OKLCH, SRGB, HSL, HSV and linear RGB color spaces. The hint is a normalized value that can be used to shift the mid-point where the colors are mixed 50-50 between the stop with the hint and the following stop. Cylindrical color spaces support interpolation along both short and long hue paths. For sharp stops with no interpolated transition, place two stops at the same point. From 6381b9df25ecfac24be57f22fa10cd87c1fd2a62 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Mon, 7 Jul 2025 12:48:46 +0100 Subject: [PATCH 11/16] Fixed the syntax of color stops example in the UI Gradients release note --- release-content/release-notes/ui_gradients.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-content/release-notes/ui_gradients.md b/release-content/release-notes/ui_gradients.md index 147377087cd63..155e394892aac 100644 --- a/release-content/release-notes/ui_gradients.md +++ b/release-content/release-notes/ui_gradients.md @@ -14,7 +14,7 @@ Each gradient type consists of the geometric properties for that gradient, a lis Color stops consist of a color, a position or angle and an optional hint. If no position is specified for a stop, it's evenly spaced between the previous and following stops. Color stop positions are absolute. With the list of stops: ```rust -vec![vec![ColorStop::new(RED, Val::Percent(90.), ColorStop::new(Color::GREEN, Val::Percent(10.)) +vec![ColorStop::new(RED, Val::Percent(90.), ColorStop::new(GREEN), Val::Percent(10.))] ``` the colors will be reordered and the gradient will transition from green at 10% to red at 90%. From 756c9f7eecf4617cc8251e792d626d24ea936312 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Mon, 7 Jul 2025 15:44:44 +0100 Subject: [PATCH 12/16] Don't interpolate the hue when the saturation is 0. --- crates/bevy_ui_render/src/gradient.wgsl | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/bevy_ui_render/src/gradient.wgsl b/crates/bevy_ui_render/src/gradient.wgsl index bec0300e6b89a..7ee4ce886272a 100644 --- a/crates/bevy_ui_render/src/gradient.wgsl +++ b/crates/bevy_ui_render/src/gradient.wgsl @@ -309,7 +309,14 @@ fn mix_linear_rgba_in_oklcha_space_long(a: vec4, b: vec4, t: f32) -> v fn mix_linear_rgba_in_hsva_space(a: vec4, b: vec4, t: f32) -> vec4 { let ha = linear_rgba_to_hsva(a); let hb = linear_rgba_to_hsva(b); - let h = lerp_hue(ha.x * TAU, hb.x * TAU, t) / TAU; + var h: f32; + if ha.y == 0. { + h = hb.x; + } else if hb.y == 0. { + h = ha.x; + } else { + h = lerp_hue(ha.x * TAU, hb.x * TAU, t) / TAU; + } let s = mix(ha.y, hb.y, t); let v = mix(ha.z, hb.z, t); let a_alpha = mix(ha.a, hb.a, t); From 47e2fc7f226b00e127b244c537b5892156a58f4c Mon Sep 17 00:00:00 2001 From: Talin Date: Mon, 7 Jul 2025 11:33:15 -0700 Subject: [PATCH 13/16] Rebased to upstream. --- crates/bevy_feathers/Cargo.toml | 1 + crates/bevy_feathers/src/alpha_pattern.rs | 2 +- crates/bevy_feathers/src/controls/color_slider.rs | 5 +++-- crates/bevy_feathers/src/controls/color_swatch.rs | 3 ++- crates/bevy_feathers/src/lib.rs | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/bevy_feathers/Cargo.toml b/crates/bevy_feathers/Cargo.toml index f449692a51c54..5402b4f822768 100644 --- a/crates/bevy_feathers/Cargo.toml +++ b/crates/bevy_feathers/Cargo.toml @@ -27,6 +27,7 @@ bevy_text = { path = "../bevy_text", version = "0.17.0-dev" } bevy_ui = { path = "../bevy_ui", version = "0.17.0-dev", features = [ "bevy_ui_picking_backend", ] } +bevy_ui_render = { path = "../bevy_ui_render", version = "0.17.0-dev" } bevy_window = { path = "../bevy_window", version = "0.17.0-dev" } bevy_winit = { path = "../bevy_winit", version = "0.17.0-dev" } diff --git a/crates/bevy_feathers/src/alpha_pattern.rs b/crates/bevy_feathers/src/alpha_pattern.rs index c4195b1dd3cd9..81fbea1e8ac30 100644 --- a/crates/bevy_feathers/src/alpha_pattern.rs +++ b/crates/bevy_feathers/src/alpha_pattern.rs @@ -10,7 +10,7 @@ use bevy_ecs::{ }; use bevy_reflect::TypePath; use bevy_render::render_resource::{AsBindGroup, ShaderRef}; -use bevy_ui::{MaterialNode, UiMaterial}; +use bevy_ui_render::ui_material::{MaterialNode, UiMaterial}; #[derive(AsBindGroup, Asset, TypePath, Default, Debug, Clone)] pub(crate) struct AlphaPatternMaterial {} diff --git a/crates/bevy_feathers/src/controls/color_slider.rs b/crates/bevy_feathers/src/controls/color_slider.rs index 9675f6fa7b8c6..db65166718fbb 100644 --- a/crates/bevy_feathers/src/controls/color_slider.rs +++ b/crates/bevy_feathers/src/controls/color_slider.rs @@ -22,9 +22,10 @@ use bevy_log::warn_once; use bevy_picking::PickingSystems; use bevy_ui::{ AlignItems, BackgroundColor, BackgroundGradient, BorderColor, BorderRadius, ColorStop, Display, - FlexDirection, Gradient, InterpolationColorSpace, LinearGradient, MaterialNode, Node, Outline, - PositionType, UiRect, UiTransform, Val, Val2, ZIndex, + FlexDirection, Gradient, InterpolationColorSpace, LinearGradient, Node, Outline, PositionType, + UiRect, UiTransform, Val, Val2, ZIndex, }; +use bevy_ui_render::ui_material::MaterialNode; use bevy_winit::cursor::CursorIcon; use crate::{ diff --git a/crates/bevy_feathers/src/controls/color_swatch.rs b/crates/bevy_feathers/src/controls/color_swatch.rs index 9235cd3e1bde8..3dc537addeb55 100644 --- a/crates/bevy_feathers/src/controls/color_swatch.rs +++ b/crates/bevy_feathers/src/controls/color_swatch.rs @@ -1,7 +1,8 @@ use bevy_asset::Handle; use bevy_color::Alpha; use bevy_ecs::{bundle::Bundle, children, spawn::SpawnRelated}; -use bevy_ui::{BackgroundColor, BorderRadius, MaterialNode, Node, PositionType, Val}; +use bevy_ui::{BackgroundColor, BorderRadius, Node, PositionType, Val}; +use bevy_ui_render::ui_material::MaterialNode; use crate::{ alpha_pattern::{AlphaPattern, AlphaPatternMaterial}, diff --git a/crates/bevy_feathers/src/lib.rs b/crates/bevy_feathers/src/lib.rs index 4a1b6c5e4c61d..88ae6c03276e5 100644 --- a/crates/bevy_feathers/src/lib.rs +++ b/crates/bevy_feathers/src/lib.rs @@ -22,7 +22,7 @@ use bevy_app::{HierarchyPropagatePlugin, Plugin, PostUpdate}; use bevy_asset::embedded_asset; use bevy_ecs::query::With; use bevy_text::{TextColor, TextFont}; -use bevy_ui::UiMaterialPlugin; +use bevy_ui_render::UiMaterialPlugin; use bevy_winit::cursor::CursorIcon; use crate::{ From d21a9662556e20ecf02c880138cb2f08ecd3cecf Mon Sep 17 00:00:00 2001 From: Talin Date: Mon, 7 Jul 2025 15:12:56 -0700 Subject: [PATCH 14/16] HSL sliders. --- .../src/controls/color_slider.rs | 69 ++++---- .../src/controls/color_swatch.rs | 14 +- crates/bevy_feathers/src/controls/mod.rs | 6 +- examples/ui/feathers.rs | 151 ++++++++++++++++-- 4 files changed, 188 insertions(+), 52 deletions(-) diff --git a/crates/bevy_feathers/src/controls/color_slider.rs b/crates/bevy_feathers/src/controls/color_slider.rs index db65166718fbb..c1ae9d010dd80 100644 --- a/crates/bevy_feathers/src/controls/color_slider.rs +++ b/crates/bevy_feathers/src/controls/color_slider.rs @@ -2,7 +2,7 @@ use core::f32::consts::PI; use bevy_app::{Plugin, PreUpdate}; use bevy_asset::Handle; -use bevy_color::{Alpha, Color, Oklcha}; +use bevy_color::{Alpha, Color, Hsla}; use bevy_core_widgets::{ Callback, CoreSlider, CoreSliderThumb, SliderRange, SliderValue, TrackClick, }; @@ -49,12 +49,12 @@ pub enum ColorChannel { Green, /// Editing the RGB blue channel (0..=1) Blue, - /// Editing the luminance channel (0..=1) - Lightness, - /// Editing the chroma / saturation channel (0..=1) - Chroma, /// Editing the hue channel (0..=360) - Hue, + HslHue, + /// Editing the chroma / saturation channel (0..=1) + HslSaturation, + /// Editing the luminance channel (0..=1) + HslLightness, /// Editing the alpha channel (0..=1) Alpha, } @@ -67,9 +67,9 @@ impl ColorChannel { | ColorChannel::Green | ColorChannel::Blue | ColorChannel::Alpha - | ColorChannel::Chroma - | ColorChannel::Lightness => SliderRange::new(0., 1.), - ColorChannel::Hue => SliderRange::new(0., 360.), + | ColorChannel::HslSaturation + | ColorChannel::HslLightness => SliderRange::new(0., 1.), + ColorChannel::HslHue => SliderRange::new(0., 360.), } } @@ -104,32 +104,30 @@ impl ColorChannel { ) } - ColorChannel::Lightness => { - let base_oklcha: Oklcha = base_color.into(); + ColorChannel::HslHue => ( + Color::hsl(0.0 + 0.0001, 1.0, 0.5), + Color::hsl(180.0, 1.0, 0.5), + Color::hsl(360.0 - 0.01, 1.0, 0.5), + ), + + ColorChannel::HslSaturation => { + let base_hsla: Hsla = base_color.into(); ( - Color::oklch(0.0, base_oklcha.chroma, base_oklcha.hue), - Color::oklch(0.5, base_oklcha.chroma, base_oklcha.hue), - Color::oklch(1.0, base_oklcha.chroma, base_oklcha.hue), + Color::hsl(base_hsla.hue, 0.0, base_hsla.lightness), + Color::hsl(base_hsla.hue, 0.5, base_hsla.lightness), + Color::hsl(base_hsla.hue, 1.0, base_hsla.lightness), ) } - ColorChannel::Chroma => { - let base_oklcha: Oklcha = base_color.into(); + ColorChannel::HslLightness => { + let base_hsla: Hsla = base_color.into(); ( - Color::oklch(base_oklcha.lightness, 0.0, base_oklcha.hue), - Color::oklch(base_oklcha.lightness, 0.5, base_oklcha.hue), - Color::oklch(base_oklcha.lightness, 1.0, base_oklcha.hue), + Color::hsl(base_hsla.hue, base_hsla.saturation, 0.0), + Color::hsl(base_hsla.hue, base_hsla.saturation, 0.5), + Color::hsl(base_hsla.hue, base_hsla.saturation, 1.0), ) } - // For now, hard-code to Oklcha since that's the only hue-rotation color space - // supported by [`ColorGradient`] currently. - ColorChannel::Hue => ( - Color::oklch(0.7, 0.3, 0.0 + 0.0001), - Color::oklch(0.7, 0.3, 180.0), - Color::oklch(0.7, 0.3, 360.0 - 0.0001), - ), - ColorChannel::Alpha => ( base_color.with_alpha(0.), base_color.with_alpha(0.5), @@ -142,7 +140,7 @@ impl ColorChannel { /// Used to store the color channels that we are not editing: the components of the color /// that are constant for this slider. #[derive(Component, Default, Clone)] -pub struct BaseColor(pub Color); +pub struct SliderBaseColor(pub Color); /// Slider template properties, passed to [`slider`] function. pub struct ColorSliderProps { @@ -164,9 +162,11 @@ impl Default for ColorSliderProps { } } +/// A color slider widget. #[derive(Component, Default, Clone)] -#[require(CoreSlider, BaseColor(Color::WHITE))] +#[require(CoreSlider, SliderBaseColor(Color::WHITE))] pub struct ColorSlider { + /// Which channel is being edited by this slider. pub channel: ColorChannel, } @@ -308,13 +308,13 @@ fn update_slider_pos( } fn update_track_color( - mut q_sliders: Query<(Entity, &ColorSlider, &BaseColor), Changed>, + mut q_sliders: Query<(Entity, &ColorSlider, &SliderBaseColor), Changed>, q_children: Query<&Children>, q_track: Query<(), With>, mut q_background: Query<&mut BackgroundColor>, mut q_gradient: Query<&mut BackgroundGradient>, ) { - for (slider_ent, slider, BaseColor(base_color)) in q_sliders.iter_mut() { + for (slider_ent, slider, SliderBaseColor(base_color)) in q_sliders.iter_mut() { let (start, middle, end) = slider.channel.gradient_ends(*base_color); if let Some(track_ent) = q_children .iter_descendants(slider_ent) @@ -337,14 +337,15 @@ fn update_track_color( ColorChannel::Red | ColorChannel::Green | ColorChannel::Blue => { InterpolationColorSpace::Srgb } - ColorChannel::Hue | ColorChannel::Lightness | ColorChannel::Chroma => { - InterpolationColorSpace::OkLch - } + ColorChannel::HslHue + | ColorChannel::HslLightness + | ColorChannel::HslSaturation => InterpolationColorSpace::Hsl, ColorChannel::Alpha => match base_color { Color::Srgba(_) => InterpolationColorSpace::Srgb, Color::LinearRgba(_) => InterpolationColorSpace::LinearRgb, Color::Oklaba(_) => InterpolationColorSpace::OkLab, Color::Oklcha(_) => InterpolationColorSpace::OkLchLong, + Color::Hsla(_) | Color::Hsva(_) => InterpolationColorSpace::Hsl, _ => { warn_once!( "Unsupported color space for ColorSlider: {:?}", diff --git a/crates/bevy_feathers/src/controls/color_swatch.rs b/crates/bevy_feathers/src/controls/color_swatch.rs index 3dc537addeb55..b43497805f6cd 100644 --- a/crates/bevy_feathers/src/controls/color_swatch.rs +++ b/crates/bevy_feathers/src/controls/color_swatch.rs @@ -1,6 +1,6 @@ use bevy_asset::Handle; use bevy_color::Alpha; -use bevy_ecs::{bundle::Bundle, children, spawn::SpawnRelated}; +use bevy_ecs::{bundle::Bundle, children, component::Component, spawn::SpawnRelated}; use bevy_ui::{BackgroundColor, BorderRadius, Node, PositionType, Val}; use bevy_ui_render::ui_material::MaterialNode; @@ -10,6 +10,16 @@ use crate::{ palette, }; +/// Marker identifying a color swatch. +#[derive(Component, Default, Clone)] +pub struct ColorSwatch; + +/// Marker identifying the color swatch foreground, the piece that actually displays the color +/// in front of the alpha pattern. This exists so that users can reach in and change the color +/// dynamically. +#[derive(Component, Default, Clone)] +pub struct ColorSwatchFg; + /// Template function to spawn a color swatch. /// /// # Arguments @@ -21,6 +31,7 @@ pub fn color_swatch(overrides: B) -> impl Bundle { min_width: size::ROW_HEIGHT, ..Default::default() }, + ColorSwatch, AlphaPattern, MaterialNode::(Handle::default()), BorderRadius::all(Val::Px(5.0)), @@ -34,6 +45,7 @@ pub fn color_swatch(overrides: B) -> impl Bundle { right: Val::Px(0.), ..Default::default() }, + ColorSwatchFg, BackgroundColor(palette::ACCENT.with_alpha(0.5)), BorderRadius::all(Val::Px(5.0)) ),], diff --git a/crates/bevy_feathers/src/controls/mod.rs b/crates/bevy_feathers/src/controls/mod.rs index 8f2195d17e812..209fd8e32dc0d 100644 --- a/crates/bevy_feathers/src/controls/mod.rs +++ b/crates/bevy_feathers/src/controls/mod.rs @@ -11,8 +11,10 @@ mod toggle_switch; pub use button::{button, ButtonPlugin, ButtonProps, ButtonVariant}; pub use checkbox::{checkbox, CheckboxPlugin, CheckboxProps}; -pub use color_slider::{color_slider, ColorChannel, ColorSliderPlugin, ColorSliderProps}; -pub use color_swatch::color_swatch; +pub use color_slider::{ + color_slider, ColorChannel, ColorSlider, ColorSliderPlugin, ColorSliderProps, SliderBaseColor, +}; +pub use color_swatch::{color_swatch, ColorSwatch, ColorSwatchFg}; pub use radio::{radio, RadioPlugin}; pub use slider::{slider, SliderPlugin, SliderProps}; pub use toggle_switch::{toggle_switch, ToggleSwitchPlugin, ToggleSwitchProps}; diff --git a/examples/ui/feathers.rs b/examples/ui/feathers.rs index a8b9cbfa036fc..6f57648457845 100644 --- a/examples/ui/feathers.rs +++ b/examples/ui/feathers.rs @@ -1,12 +1,15 @@ //! This example shows off the various Bevy Feathers widgets. use bevy::{ - core_widgets::{Callback, CoreRadio, CoreRadioGroup, CoreWidgetsPlugin, SliderStep}, + color::palettes, + core_widgets::{ + Callback, CoreRadio, CoreRadioGroup, CoreWidgetsPlugin, SliderStep, SliderValue, + }, feathers::{ controls::{ button, checkbox, color_slider, color_swatch, radio, slider, toggle_switch, - ButtonProps, ButtonVariant, CheckboxProps, ColorChannel, ColorSliderProps, SliderProps, - ToggleSwitchProps, + ButtonProps, ButtonVariant, CheckboxProps, ColorChannel, ColorSlider, ColorSliderProps, + ColorSwatch, SliderBaseColor, SliderProps, ToggleSwitchProps, }, dark_theme::create_dark_theme, rounded_corners::RoundedCorners, @@ -22,6 +25,19 @@ use bevy::{ winit::WinitSettings, }; +/// A struct to hold the state of various widgets shown in the demo. +#[derive(Resource)] +struct DemoWidgetStates { + rgb_color: Srgba, + hsl_color: Hsla, +} + +#[derive(Component, Clone, Copy, PartialEq)] +enum SwatchType { + Rgb, + Hsl, +} + fn main() { App::new() .add_plugins(( @@ -32,9 +48,14 @@ fn main() { FeathersPlugin, )) .insert_resource(UiTheme(create_dark_theme())) + .insert_resource(DemoWidgetStates { + rgb_color: palettes::tailwind::EMERALD_800.with_alpha(0.7), + hsl_color: palettes::tailwind::AMBER_800.into(), + }) // 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) + .add_systems(Update, update_colors) .run(); } @@ -59,6 +80,41 @@ fn demo_root(commands: &mut Commands) -> impl Bundle { }, ); + let change_red = + commands.register_system(|value: In, mut color: ResMut| { + color.rgb_color.red = *value; + }); + + let change_green = + commands.register_system(|value: In, mut color: ResMut| { + color.rgb_color.green = *value; + }); + + let change_blue = + commands.register_system(|value: In, mut color: ResMut| { + color.rgb_color.blue = *value; + }); + + let change_alpha = + commands.register_system(|value: In, mut color: ResMut| { + color.rgb_color.alpha = *value; + }); + + let change_hue = + commands.register_system(|value: In, mut color: ResMut| { + color.hsl_color.hue = *value; + }); + + let change_saturation = + commands.register_system(|value: In, mut color: ResMut| { + color.hsl_color.saturation = *value; + }); + + let change_lightness = + commands.register_system(|value: In, mut color: ResMut| { + color.hsl_color.lightness = *value; + }); + ( Node { width: Val::Percent(100.0), @@ -269,12 +325,12 @@ fn demo_root(commands: &mut Commands) -> impl Bundle { justify_content: JustifyContent::SpaceBetween, ..default() }, - children![Text("Srgba".to_owned()), color_swatch(()),] + children![Text("Srgba".to_owned()), color_swatch(SwatchType::Rgb),] ), color_slider( ColorSliderProps { value: 0.5, - on_change: Callback::Ignore, + on_change: Callback::System(change_red), channel: ColorChannel::Red }, () @@ -282,7 +338,7 @@ fn demo_root(commands: &mut Commands) -> impl Bundle { color_slider( ColorSliderProps { value: 0.5, - on_change: Callback::Ignore, + on_change: Callback::System(change_green), channel: ColorChannel::Green }, () @@ -290,7 +346,7 @@ fn demo_root(commands: &mut Commands) -> impl Bundle { color_slider( ColorSliderProps { value: 0.5, - on_change: Callback::Ignore, + on_change: Callback::System(change_blue), channel: ColorChannel::Blue }, () @@ -298,7 +354,7 @@ fn demo_root(commands: &mut Commands) -> impl Bundle { color_slider( ColorSliderProps { value: 0.5, - on_change: Callback::Ignore, + on_change: Callback::System(change_alpha), channel: ColorChannel::Alpha }, () @@ -310,29 +366,29 @@ fn demo_root(commands: &mut Commands) -> impl Bundle { justify_content: JustifyContent::SpaceBetween, ..default() }, - children![Text("Oklcha".to_owned()), color_swatch(()),] + children![Text("Hsl".to_owned()), color_swatch(SwatchType::Hsl),] ), color_slider( ColorSliderProps { value: 0.5, - on_change: Callback::Ignore, - channel: ColorChannel::Hue + on_change: Callback::System(change_hue), + channel: ColorChannel::HslHue }, () ), color_slider( ColorSliderProps { value: 0.5, - on_change: Callback::Ignore, - channel: ColorChannel::Chroma + on_change: Callback::System(change_saturation), + channel: ColorChannel::HslSaturation }, () ), color_slider( ColorSliderProps { value: 0.5, - on_change: Callback::Ignore, - channel: ColorChannel::Lightness + on_change: Callback::System(change_lightness), + channel: ColorChannel::HslLightness }, () ) @@ -340,3 +396,68 @@ fn demo_root(commands: &mut Commands) -> impl Bundle { ),], ) } + +fn update_colors( + colors: Res, + mut sliders: Query<(Entity, &ColorSlider, &mut SliderBaseColor)>, + swatches: Query<(&SwatchType, &Children), With>, + mut commands: Commands, +) { + if colors.is_changed() { + for (slider_ent, slider, mut base) in sliders.iter_mut() { + match slider.channel { + ColorChannel::Red => { + base.0 = colors.rgb_color.into(); + commands + .entity(slider_ent) + .insert(SliderValue(colors.rgb_color.red)); + } + ColorChannel::Green => { + base.0 = colors.rgb_color.into(); + commands + .entity(slider_ent) + .insert(SliderValue(colors.rgb_color.green)); + } + ColorChannel::Blue => { + base.0 = colors.rgb_color.into(); + commands + .entity(slider_ent) + .insert(SliderValue(colors.rgb_color.blue)); + } + ColorChannel::HslHue => { + base.0 = colors.hsl_color.into(); + commands + .entity(slider_ent) + .insert(SliderValue(colors.hsl_color.hue)); + } + ColorChannel::HslSaturation => { + base.0 = colors.hsl_color.into(); + commands + .entity(slider_ent) + .insert(SliderValue(colors.hsl_color.saturation)); + } + ColorChannel::HslLightness => { + base.0 = colors.hsl_color.into(); + commands + .entity(slider_ent) + .insert(SliderValue(colors.hsl_color.lightness)); + } + ColorChannel::Alpha => { + base.0 = colors.rgb_color.into(); + commands + .entity(slider_ent) + .insert(SliderValue(colors.rgb_color.alpha)); + } + } + } + + for (swatch_type, children) in swatches.iter() { + commands + .entity(children[0]) + .insert(BackgroundColor(match swatch_type { + SwatchType::Rgb => colors.rgb_color.into(), + SwatchType::Hsl => colors.hsl_color.into(), + })); + } + } +} From a0990a01078d5c0a9810f47a3d337a72cd954be5 Mon Sep 17 00:00:00 2001 From: Talin Date: Mon, 7 Jul 2025 15:17:25 -0700 Subject: [PATCH 15/16] Remove hack. --- crates/bevy_feathers/src/controls/color_slider.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_feathers/src/controls/color_slider.rs b/crates/bevy_feathers/src/controls/color_slider.rs index c1ae9d010dd80..2887c7393377d 100644 --- a/crates/bevy_feathers/src/controls/color_slider.rs +++ b/crates/bevy_feathers/src/controls/color_slider.rs @@ -107,7 +107,7 @@ impl ColorChannel { ColorChannel::HslHue => ( Color::hsl(0.0 + 0.0001, 1.0, 0.5), Color::hsl(180.0, 1.0, 0.5), - Color::hsl(360.0 - 0.01, 1.0, 0.5), + Color::hsl(360.0 - 0.0001, 1.0, 0.5), ), ColorChannel::HslSaturation => { From d5b2e59d33b6cf6e4249ddda6d69c41c35889d04 Mon Sep 17 00:00:00 2001 From: Talin Date: Mon, 7 Jul 2025 17:01:12 -0700 Subject: [PATCH 16/16] Doc fix. --- crates/bevy_feathers/src/controls/color_slider.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_feathers/src/controls/color_slider.rs b/crates/bevy_feathers/src/controls/color_slider.rs index 2887c7393377d..e4828ef9c15ed 100644 --- a/crates/bevy_feathers/src/controls/color_slider.rs +++ b/crates/bevy_feathers/src/controls/color_slider.rs @@ -142,7 +142,7 @@ impl ColorChannel { #[derive(Component, Default, Clone)] pub struct SliderBaseColor(pub Color); -/// Slider template properties, passed to [`slider`] function. +/// Slider template properties, passed to [`color_slider`] function. pub struct ColorSliderProps { /// Slider current value pub value: f32,