diff --git a/crates/bevy_feathers/src/assets/shaders/color_plane.wgsl b/crates/bevy_feathers/src/assets/shaders/color_plane.wgsl new file mode 100644 index 0000000000000..738c74937c29e --- /dev/null +++ b/crates/bevy_feathers/src/assets/shaders/color_plane.wgsl @@ -0,0 +1,27 @@ +// This shader draws the color plane in various color spaces. +#import bevy_ui::ui_vertex_output::UiVertexOutput +#import bevy_ui_render::color_space::{ + srgb_to_linear_rgb, + hsl_to_linear_rgb, +} + +@group(1) @binding(0) var fixed_channel: f32; + +@fragment +fn fragment(in: UiVertexOutput) -> @location(0) vec4 { + let uv = in.uv; +#ifdef PLANE_RG + return vec4(srgb_to_linear_rgb(vec3(uv.x, uv.y, fixed_channel)), 1.0); +#else ifdef PLANE_RB + return vec4(srgb_to_linear_rgb(vec3(uv.x, fixed_channel, uv.y)), 1.0); +#else ifdef PLANE_GB + return vec4(srgb_to_linear_rgb(vec3(fixed_channel, uv.x, uv.y)), 1.0); +#else ifdef PLANE_HS + return vec4(hsl_to_linear_rgb(vec3(uv.x, 1.0 - uv.y, fixed_channel)), 1.0); +#else ifdef PLANE_HL + return vec4(hsl_to_linear_rgb(vec3(uv.x, fixed_channel, 1.0 - uv.y)), 1.0); +#else + // Error color + return vec4(1.0, 0.0, 1.0, 1.0); +#endif +} diff --git a/crates/bevy_feathers/src/controls/color_plane.rs b/crates/bevy_feathers/src/controls/color_plane.rs new file mode 100644 index 0000000000000..cc01a43668764 --- /dev/null +++ b/crates/bevy_feathers/src/controls/color_plane.rs @@ -0,0 +1,354 @@ +use bevy_app::{Plugin, PostUpdate}; +use bevy_asset::{Asset, Assets}; +use bevy_ecs::{ + bundle::Bundle, + children, + component::Component, + entity::Entity, + hierarchy::{ChildOf, Children}, + observer::On, + query::{Changed, Has, Or, With}, + reflect::ReflectComponent, + spawn::SpawnRelated, + system::{Commands, Query, Res, ResMut}, +}; +use bevy_math::{Vec2, Vec3}; +use bevy_picking::{ + events::{Cancel, Drag, DragEnd, DragStart, Pointer, Press}, + Pickable, +}; +use bevy_reflect::{prelude::ReflectDefault, Reflect, TypePath}; +use bevy_render::render_resource::AsBindGroup; +use bevy_shader::{ShaderDefVal, ShaderRef}; +use bevy_ui::{ + px, AlignSelf, BorderColor, BorderRadius, ComputedNode, ComputedUiRenderTargetInfo, Display, + InteractionDisabled, Node, Outline, PositionType, UiGlobalTransform, UiRect, UiScale, + UiTransform, Val, Val2, +}; +use bevy_ui_render::{prelude::UiMaterial, ui_material::MaterialNode, UiMaterialPlugin}; +use bevy_ui_widgets::ValueChange; + +use crate::{cursor::EntityCursor, palette, theme::ThemeBackgroundColor, tokens}; + +/// Marker identifying a color plane widget. +/// +/// The variant selects which view of the color pane is shown. +#[derive(Component, Default, Debug, Clone, Reflect, Copy, PartialEq, Eq, Hash)] +#[reflect(Component, Clone, Default)] +#[require(ColorPlaneDragState)] +pub enum ColorPlane { + /// Show red on the horizontal axis and green on the vertical. + RedGreen, + /// Show red on the horizontal axis and blue on the vertical. + RedBlue, + /// Show green on the horizontal axis and blue on the vertical. + GreenBlue, + /// Show hue on the horizontal axis and saturation on the vertical. + HueSaturation, + /// Show hue on the horizontal axis and lightness on the vertical. + #[default] + HueLightness, +} + +/// Component that contains the two components of the selected color, as well as the "z" value. +/// The x and y values determine the placement of the thumb element, while the z value controls +/// the background gradient. +#[derive(Component, Default, Clone, Reflect)] +#[reflect(Component, Clone, Default)] +pub struct ColorPlaneValue(pub Vec3); + +/// Marker identifying the inner element of the color plane. +#[derive(Component, Default, Clone, Reflect)] +#[reflect(Component, Clone, Default)] +struct ColorPlaneInner; + +/// Marker identifying the thumb element of the color plane. +#[derive(Component, Default, Clone, Reflect)] +#[reflect(Component, Clone, Default)] +struct ColorPlaneThumb; + +/// Component used to manage the state of a slider during dragging. +#[derive(Component, Default, Reflect)] +#[reflect(Component)] +struct ColorPlaneDragState(bool); + +#[repr(C)] +#[derive(Eq, PartialEq, Hash, Copy, Clone)] +struct ColorPlaneMaterialKey { + plane: ColorPlane, +} + +#[derive(AsBindGroup, Asset, TypePath, Default, Debug, Clone)] +#[bind_group_data(ColorPlaneMaterialKey)] +struct ColorPlaneMaterial { + plane: ColorPlane, + + #[uniform(0)] + fixed_channel: f32, +} + +impl From<&ColorPlaneMaterial> for ColorPlaneMaterialKey { + fn from(material: &ColorPlaneMaterial) -> Self { + Self { + plane: material.plane, + } + } +} + +impl UiMaterial for ColorPlaneMaterial { + fn fragment_shader() -> ShaderRef { + "embedded://bevy_feathers/assets/shaders/color_plane.wgsl".into() + } + + fn specialize( + descriptor: &mut bevy_render::render_resource::RenderPipelineDescriptor, + key: bevy_ui_render::prelude::UiMaterialKey, + ) { + let plane_def = match key.bind_group_data.plane { + ColorPlane::RedGreen => "PLANE_RG", + ColorPlane::RedBlue => "PLANE_RB", + ColorPlane::GreenBlue => "PLANE_GB", + ColorPlane::HueSaturation => "PLANE_HS", + ColorPlane::HueLightness => "PLANE_HL", + }; + descriptor.fragment.as_mut().unwrap().shader_defs = + vec![ShaderDefVal::Bool(plane_def.into(), true)]; + } +} + +/// Template function to spawn a "color plane", which is a 2d picker that allows selecting two +/// components of a color space. +/// +/// The control emits a [`ValueChange`] representing the current x and y values, ranging +/// from 0 to 1. The control accepts a [`Vec3`] input value, where the third component ('z') +/// is used to provide the fixed constant channel for the background gradient. +/// +/// The control does not do any color space conversions internally, other than the shader code +/// for displaying gradients. Avoiding excess conversions helps avoid gimble-lock problems when +/// implementing a color picker for cylindrical color spaces such as HSL. +/// +/// # Arguments +/// * `overrides` - a bundle of components that are merged in with the normal swatch components. +pub fn color_plane(plane: ColorPlane, overrides: B) -> impl Bundle { + ( + Node { + display: Display::Flex, + min_height: px(100.0), + align_self: AlignSelf::Stretch, + padding: UiRect::all(px(4)), + ..Default::default() + }, + plane, + ColorPlaneValue::default(), + ThemeBackgroundColor(tokens::COLOR_PLANE_BG), + BorderRadius::all(px(5)), + EntityCursor::System(bevy_window::SystemCursorIcon::Crosshair), + overrides, + children![( + Node { + align_self: AlignSelf::Stretch, + flex_grow: 1.0, + ..Default::default() + }, + ColorPlaneInner, + children![( + Node { + position_type: PositionType::Absolute, + left: Val::Percent(0.), + top: Val::Percent(0.), + width: px(10), + height: px(10), + border: UiRect::all(Val::Px(1.0)), + ..Default::default() + }, + ColorPlaneThumb, + BorderRadius::MAX, + BorderColor::all(palette::WHITE), + Outline { + width: Val::Px(1.), + offset: Val::Px(0.), + color: palette::BLACK + }, + Pickable::IGNORE, + UiTransform::from_translation(Val2::new(Val::Percent(-50.0), Val::Percent(-50.0),)) + )], + ),], + ) +} + +fn update_plane_color( + q_color_plane: Query< + (Entity, &ColorPlane, &ColorPlaneValue), + Or<(Changed, Changed)>, + >, + q_children: Query<&Children>, + q_material_node: Query<&MaterialNode>, + mut q_node: Query<&mut Node>, + mut r_materials: ResMut>, + mut commands: Commands, +) { + for (plane_ent, plane, plane_value) in q_color_plane.iter() { + // Find the inner entity + let Ok(children) = q_children.get(plane_ent) else { + continue; + }; + let Some(inner_ent) = children.first() else { + continue; + }; + + if let Ok(material_node) = q_material_node.get(*inner_ent) { + // Node component exists, update it + if let Some(material) = r_materials.get_mut(material_node.id()) { + // Update properties + material.plane = *plane; + material.fixed_channel = plane_value.0.z; + } + } else { + // Insert new node component + let material = r_materials.add(ColorPlaneMaterial { + plane: *plane, + fixed_channel: plane_value.0.z, + }); + commands.entity(*inner_ent).insert(MaterialNode(material)); + } + + // Find the thumb. + let Ok(children_inner) = q_children.get(*inner_ent) else { + continue; + }; + let Some(thumb_ent) = children_inner.first() else { + continue; + }; + + let Ok(mut thumb_node) = q_node.get_mut(*thumb_ent) else { + continue; + }; + + thumb_node.left = Val::Percent(plane_value.0.x * 100.0); + thumb_node.top = Val::Percent(plane_value.0.y * 100.0); + } +} + +fn on_pointer_press( + mut press: On>, + q_color_planes: Query, With>, + q_color_plane_inner: Query< + ( + &ComputedNode, + &ComputedUiRenderTargetInfo, + &UiGlobalTransform, + &ChildOf, + ), + With, + >, + ui_scale: Res, + mut commands: Commands, +) { + if let Ok((node, node_target, transform, parent)) = q_color_plane_inner.get(press.entity) + && let Ok(disabled) = q_color_planes.get(parent.0) + { + press.propagate(false); + if !disabled { + let local_pos = transform.try_inverse().unwrap().transform_point2( + press.pointer_location.position * node_target.scale_factor() / ui_scale.0, + ); + let pos = local_pos / node.size() + Vec2::splat(0.5); + let new_value = pos.clamp(Vec2::ZERO, Vec2::ONE); + commands.trigger(ValueChange { + source: parent.0, + value: new_value, + }); + } + } +} + +fn on_drag_start( + mut drag_start: On>, + mut q_color_planes: Query< + (&mut ColorPlaneDragState, Has), + With, + >, + q_color_plane_inner: Query<&ChildOf, With>, +) { + if let Ok(parent) = q_color_plane_inner.get(drag_start.entity) + && let Ok((mut state, disabled)) = q_color_planes.get_mut(parent.0) + { + drag_start.propagate(false); + if !disabled { + state.0 = true; + } + } +} + +fn on_drag( + mut drag: On>, + q_color_planes: Query<(&ColorPlaneDragState, Has), With>, + q_color_plane_inner: Query< + ( + &ComputedNode, + &ComputedUiRenderTargetInfo, + &UiGlobalTransform, + &ChildOf, + ), + With, + >, + ui_scale: Res, + mut commands: Commands, +) { + if let Ok((node, node_target, transform, parent)) = q_color_plane_inner.get(drag.entity) + && let Ok((state, disabled)) = q_color_planes.get(parent.0) + { + drag.propagate(false); + if state.0 && !disabled { + let local_pos = transform.try_inverse().unwrap().transform_point2( + drag.pointer_location.position * node_target.scale_factor() / ui_scale.0, + ); + let pos = local_pos / node.size() + Vec2::splat(0.5); + let new_value = pos.clamp(Vec2::ZERO, Vec2::ONE); + commands.trigger(ValueChange { + source: parent.0, + value: new_value, + }); + } + } +} + +fn on_drag_end( + mut drag_end: On>, + mut q_color_planes: Query<&mut ColorPlaneDragState, With>, + q_color_plane_inner: Query<&ChildOf, With>, +) { + if let Ok(parent) = q_color_plane_inner.get(drag_end.entity) + && let Ok(mut state) = q_color_planes.get_mut(parent.0) + { + drag_end.propagate(false); + state.0 = false; + } +} + +fn on_drag_cancel( + drag_cancel: On>, + mut q_color_planes: Query<&mut ColorPlaneDragState, With>, + q_color_plane_inner: Query<&ChildOf, With>, +) { + if let Ok(parent) = q_color_plane_inner.get(drag_cancel.entity) + && let Ok(mut state) = q_color_planes.get_mut(parent.0) + { + state.0 = false; + } +} + +/// Plugin which registers the observers for updating the swatch color. +pub struct ColorPlanePlugin; + +impl Plugin for ColorPlanePlugin { + fn build(&self, app: &mut bevy_app::App) { + app.add_plugins(UiMaterialPlugin::::default()); + app.add_systems(PostUpdate, update_plane_color); + app.add_observer(on_pointer_press) + .add_observer(on_drag_start) + .add_observer(on_drag) + .add_observer(on_drag_end) + .add_observer(on_drag_cancel); + } +} diff --git a/crates/bevy_feathers/src/controls/color_swatch.rs b/crates/bevy_feathers/src/controls/color_swatch.rs index 972f13b7878f5..455e832ec1a02 100644 --- a/crates/bevy_feathers/src/controls/color_swatch.rs +++ b/crates/bevy_feathers/src/controls/color_swatch.rs @@ -1,7 +1,15 @@ +use bevy_app::{Plugin, PostUpdate}; use bevy_asset::Handle; -use bevy_color::Alpha; +use bevy_color::{Alpha, Color}; use bevy_ecs::{ - bundle::Bundle, children, component::Component, reflect::ReflectComponent, spawn::SpawnRelated, + bundle::Bundle, + children, + component::Component, + hierarchy::Children, + query::Changed, + reflect::ReflectComponent, + spawn::SpawnRelated, + system::{Commands, Query}, }; use bevy_reflect::{prelude::ReflectDefault, Reflect}; use bevy_ui::{BackgroundColor, BorderRadius, Node, PositionType, Val}; @@ -18,6 +26,12 @@ use crate::{ #[reflect(Component, Clone, Default)] pub struct ColorSwatch; +/// Component that contains the value of the color swatch. This is copied to the child element +/// background. +#[derive(Component, Default, Clone, Reflect)] +#[reflect(Component, Clone, Default)] +pub struct ColorSwatchValue(pub Color); + /// 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. @@ -37,6 +51,7 @@ pub fn color_swatch(overrides: B) -> impl Bundle { ..Default::default() }, ColorSwatch, + ColorSwatchValue::default(), AlphaPattern, MaterialNode::(Handle::default()), BorderRadius::all(Val::Px(5.0)), @@ -56,3 +71,25 @@ pub fn color_swatch(overrides: B) -> impl Bundle { ),], ) } + +fn update_swatch_color( + q_swatch: Query<(&ColorSwatchValue, &Children), Changed>, + mut commands: Commands, +) { + for (value, children) in q_swatch.iter() { + if let Some(first_child) = children.first() { + commands + .entity(*first_child) + .insert(BackgroundColor(value.0)); + } + } +} + +/// Plugin which registers the observers for updating the swatch color. +pub struct ColorSwatchPlugin; + +impl Plugin for ColorSwatchPlugin { + fn build(&self, app: &mut bevy_app::App) { + app.add_systems(PostUpdate, update_swatch_color); + } +} diff --git a/crates/bevy_feathers/src/controls/mod.rs b/crates/bevy_feathers/src/controls/mod.rs index 8adf2b5911659..80f2b13503752 100644 --- a/crates/bevy_feathers/src/controls/mod.rs +++ b/crates/bevy_feathers/src/controls/mod.rs @@ -3,6 +3,7 @@ use bevy_app::Plugin; mod button; mod checkbox; +mod color_plane; mod color_slider; mod color_swatch; mod radio; @@ -12,16 +13,20 @@ mod virtual_keyboard; pub use button::{button, ButtonPlugin, ButtonProps, ButtonVariant}; pub use checkbox::{checkbox, CheckboxPlugin}; +pub use color_plane::{color_plane, ColorPlane, ColorPlaneValue}; pub use color_slider::{ color_slider, ColorChannel, ColorSlider, ColorSliderPlugin, ColorSliderProps, SliderBaseColor, }; -pub use color_swatch::{color_swatch, ColorSwatch, ColorSwatchFg}; +pub use color_swatch::{color_swatch, ColorSwatch, ColorSwatchFg, ColorSwatchValue}; pub use radio::{radio, RadioPlugin}; pub use slider::{slider, SliderPlugin, SliderProps}; pub use toggle_switch::{toggle_switch, ToggleSwitchPlugin}; pub use virtual_keyboard::{virtual_keyboard, VirtualKeyPressed}; -use crate::alpha_pattern::AlphaPatternPlugin; +use crate::{ + alpha_pattern::AlphaPatternPlugin, + controls::{color_plane::ColorPlanePlugin, color_swatch::ColorSwatchPlugin}, +}; /// Plugin which registers all `bevy_feathers` controls. pub struct ControlsPlugin; @@ -32,7 +37,9 @@ impl Plugin for ControlsPlugin { AlphaPatternPlugin, ButtonPlugin, CheckboxPlugin, + ColorPlanePlugin, ColorSliderPlugin, + ColorSwatchPlugin, RadioPlugin, SliderPlugin, ToggleSwitchPlugin, diff --git a/crates/bevy_feathers/src/dark_theme.rs b/crates/bevy_feathers/src/dark_theme.rs index 4a7248852c51a..7887b35bd5246 100644 --- a/crates/bevy_feathers/src/dark_theme.rs +++ b/crates/bevy_feathers/src/dark_theme.rs @@ -95,6 +95,7 @@ pub fn create_dark_theme() -> ThemeProps { tokens::SWITCH_SLIDE_DISABLED, palette::LIGHT_GRAY_2.with_alpha(0.3), ), + (tokens::COLOR_PLANE_BG, palette::GRAY_1), ]), } } diff --git a/crates/bevy_feathers/src/lib.rs b/crates/bevy_feathers/src/lib.rs index 348b677f98e53..ed6517d84d8d6 100644 --- a/crates/bevy_feathers/src/lib.rs +++ b/crates/bevy_feathers/src/lib.rs @@ -64,6 +64,7 @@ impl Plugin for FeathersPlugin { // Embedded shader embedded_asset!(app, "assets/shaders/alpha_pattern.wgsl"); + embedded_asset!(app, "assets/shaders/color_plane.wgsl"); app.add_plugins(( ControlsPlugin, diff --git a/crates/bevy_feathers/src/tokens.rs b/crates/bevy_feathers/src/tokens.rs index a00a78bc799c0..55084d10aa72c 100644 --- a/crates/bevy_feathers/src/tokens.rs +++ b/crates/bevy_feathers/src/tokens.rs @@ -137,3 +137,8 @@ pub const SWITCH_SLIDE: ThemeToken = ThemeToken::new_static("feathers.switch.sli /// Switch slide (disabled) pub const SWITCH_SLIDE_DISABLED: ThemeToken = ThemeToken::new_static("feathers.switch.slide.disabled"); + +// Color Plane + +/// Color plane frame background +pub const COLOR_PLANE_BG: ThemeToken = ThemeToken::new_static("feathers.colorplane.bg"); diff --git a/examples/ui/feathers.rs b/examples/ui/feathers.rs index 13ea3c212d810..e6a31a7cad0a7 100644 --- a/examples/ui/feathers.rs +++ b/examples/ui/feathers.rs @@ -4,9 +4,10 @@ use bevy::{ color::palettes, feathers::{ controls::{ - button, checkbox, color_slider, color_swatch, radio, slider, toggle_switch, - ButtonProps, ButtonVariant, ColorChannel, ColorSlider, ColorSliderProps, ColorSwatch, - SliderBaseColor, SliderProps, + button, checkbox, color_plane, color_slider, color_swatch, radio, slider, + toggle_switch, ButtonProps, ButtonVariant, ColorChannel, ColorPlane, ColorPlaneValue, + ColorSlider, ColorSliderProps, ColorSwatch, ColorSwatchValue, SliderBaseColor, + SliderProps, }, dark_theme::create_dark_theme, rounded_corners::RoundedCorners, @@ -302,6 +303,15 @@ fn demo_root() -> impl Bundle { }, children![Text("Srgba".to_owned()), color_swatch(SwatchType::Rgb),] ), + ( + color_plane(ColorPlane::RedBlue, ()), + observe( + |change: On>, mut color: ResMut| { + color.rgb_color.red = change.value.x; + color.rgb_color.blue = change.value.y; + } + ) + ), ( color_slider( ColorSliderProps { @@ -417,7 +427,8 @@ fn demo_root() -> impl Bundle { fn update_colors( colors: Res, mut sliders: Query<(Entity, &ColorSlider, &mut SliderBaseColor)>, - swatches: Query<(&SwatchType, &Children), With>, + mut swatches: Query<(&mut ColorSwatchValue, &SwatchType), With>, + mut color_planes: Query<&mut ColorPlaneValue, With>, mut commands: Commands, ) { if colors.is_changed() { @@ -468,13 +479,17 @@ fn update_colors( } } - 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(), - })); + for (mut swatch_value, swatch_type) in swatches.iter_mut() { + swatch_value.0 = match swatch_type { + SwatchType::Rgb => colors.rgb_color.into(), + SwatchType::Hsl => colors.hsl_color.into(), + }; + } + + for mut plane_value in color_planes.iter_mut() { + plane_value.0.x = colors.rgb_color.red; + plane_value.0.y = colors.rgb_color.blue; + plane_value.0.z = colors.rgb_color.green; } } } diff --git a/release-content/release-notes/more_standard_widgets.md b/release-content/release-notes/more_standard_widgets.md index ef6ff4df38348..305e98ea8f9e5 100644 --- a/release-content/release-notes/more_standard_widgets.md +++ b/release-content/release-notes/more_standard_widgets.md @@ -1,7 +1,7 @@ --- title: More Standard Widgets authors: ["@viridia"] -pull_requests: [21636] +pull_requests: [21636, 21743] --- ## More Standard Widgets @@ -32,3 +32,10 @@ Popovers can be used for dropdown menus, but they can also be used for tooltips. The `Menu` component uses `Popover` to provide a dropdown menu widget. This adds events for opening and closing the menu, along with keyboard navigation and activation using the focus system. + +### Color Plane + +The `Color Plane` widget is a two-dimensional color picker that allows selecting two different +channels within a color space, one along the horizontal axis and one along the vertical. It can be +configured to display a variety of different color spaces: hue vs. lightness, hue vs. saturation, +red vs. blue, and so on.