Skip to content

Commit be4114b

Browse files
viridiaalice-i-cecileickshonpe
authored
Color plane widget. (#21743)
# Objective Part of #19236 Note: I did not include a release note because there's already one in the BSN branch. ## Solution * Color Plane widget * Also, some improvements to color swatch API ## Showcase <img width="377" height="171" alt="color_plane3" src="https://github.com/user-attachments/assets/a043684b-e9a9-4450-b39f-15876586459d" /> <img width="379" height="176" alt="color_plane2" src="https://github.com/user-attachments/assets/1662b625-4cf5-49db-a76f-5475765d39fb" /> --------- Co-authored-by: Alice Cecile <[email protected]> Co-authored-by: ickshonpe <[email protected]>
1 parent e8099c0 commit be4114b

File tree

9 files changed

+470
-16
lines changed

9 files changed

+470
-16
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// This shader draws the color plane in various color spaces.
2+
#import bevy_ui::ui_vertex_output::UiVertexOutput
3+
#import bevy_ui_render::color_space::{
4+
srgb_to_linear_rgb,
5+
hsl_to_linear_rgb,
6+
}
7+
8+
@group(1) @binding(0) var<uniform> fixed_channel: f32;
9+
10+
@fragment
11+
fn fragment(in: UiVertexOutput) -> @location(0) vec4<f32> {
12+
let uv = in.uv;
13+
#ifdef PLANE_RG
14+
return vec4(srgb_to_linear_rgb(vec3(uv.x, uv.y, fixed_channel)), 1.0);
15+
#else ifdef PLANE_RB
16+
return vec4(srgb_to_linear_rgb(vec3(uv.x, fixed_channel, uv.y)), 1.0);
17+
#else ifdef PLANE_GB
18+
return vec4(srgb_to_linear_rgb(vec3(fixed_channel, uv.x, uv.y)), 1.0);
19+
#else ifdef PLANE_HS
20+
return vec4(hsl_to_linear_rgb(vec3(uv.x, 1.0 - uv.y, fixed_channel)), 1.0);
21+
#else ifdef PLANE_HL
22+
return vec4(hsl_to_linear_rgb(vec3(uv.x, fixed_channel, 1.0 - uv.y)), 1.0);
23+
#else
24+
// Error color
25+
return vec4(1.0, 0.0, 1.0, 1.0);
26+
#endif
27+
}
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
use bevy_app::{Plugin, PostUpdate};
2+
use bevy_asset::{Asset, Assets};
3+
use bevy_ecs::{
4+
bundle::Bundle,
5+
children,
6+
component::Component,
7+
entity::Entity,
8+
hierarchy::{ChildOf, Children},
9+
observer::On,
10+
query::{Changed, Has, Or, With},
11+
reflect::ReflectComponent,
12+
spawn::SpawnRelated,
13+
system::{Commands, Query, Res, ResMut},
14+
};
15+
use bevy_math::{Vec2, Vec3};
16+
use bevy_picking::{
17+
events::{Cancel, Drag, DragEnd, DragStart, Pointer, Press},
18+
Pickable,
19+
};
20+
use bevy_reflect::{prelude::ReflectDefault, Reflect, TypePath};
21+
use bevy_render::render_resource::AsBindGroup;
22+
use bevy_shader::{ShaderDefVal, ShaderRef};
23+
use bevy_ui::{
24+
px, AlignSelf, BorderColor, BorderRadius, ComputedNode, ComputedUiRenderTargetInfo, Display,
25+
InteractionDisabled, Node, Outline, PositionType, UiGlobalTransform, UiRect, UiScale,
26+
UiTransform, Val, Val2,
27+
};
28+
use bevy_ui_render::{prelude::UiMaterial, ui_material::MaterialNode, UiMaterialPlugin};
29+
use bevy_ui_widgets::ValueChange;
30+
31+
use crate::{cursor::EntityCursor, palette, theme::ThemeBackgroundColor, tokens};
32+
33+
/// Marker identifying a color plane widget.
34+
///
35+
/// The variant selects which view of the color pane is shown.
36+
#[derive(Component, Default, Debug, Clone, Reflect, Copy, PartialEq, Eq, Hash)]
37+
#[reflect(Component, Clone, Default)]
38+
#[require(ColorPlaneDragState)]
39+
pub enum ColorPlane {
40+
/// Show red on the horizontal axis and green on the vertical.
41+
RedGreen,
42+
/// Show red on the horizontal axis and blue on the vertical.
43+
RedBlue,
44+
/// Show green on the horizontal axis and blue on the vertical.
45+
GreenBlue,
46+
/// Show hue on the horizontal axis and saturation on the vertical.
47+
HueSaturation,
48+
/// Show hue on the horizontal axis and lightness on the vertical.
49+
#[default]
50+
HueLightness,
51+
}
52+
53+
/// Component that contains the two components of the selected color, as well as the "z" value.
54+
/// The x and y values determine the placement of the thumb element, while the z value controls
55+
/// the background gradient.
56+
#[derive(Component, Default, Clone, Reflect)]
57+
#[reflect(Component, Clone, Default)]
58+
pub struct ColorPlaneValue(pub Vec3);
59+
60+
/// Marker identifying the inner element of the color plane.
61+
#[derive(Component, Default, Clone, Reflect)]
62+
#[reflect(Component, Clone, Default)]
63+
struct ColorPlaneInner;
64+
65+
/// Marker identifying the thumb element of the color plane.
66+
#[derive(Component, Default, Clone, Reflect)]
67+
#[reflect(Component, Clone, Default)]
68+
struct ColorPlaneThumb;
69+
70+
/// Component used to manage the state of a slider during dragging.
71+
#[derive(Component, Default, Reflect)]
72+
#[reflect(Component)]
73+
struct ColorPlaneDragState(bool);
74+
75+
#[repr(C)]
76+
#[derive(Eq, PartialEq, Hash, Copy, Clone)]
77+
struct ColorPlaneMaterialKey {
78+
plane: ColorPlane,
79+
}
80+
81+
#[derive(AsBindGroup, Asset, TypePath, Default, Debug, Clone)]
82+
#[bind_group_data(ColorPlaneMaterialKey)]
83+
struct ColorPlaneMaterial {
84+
plane: ColorPlane,
85+
86+
#[uniform(0)]
87+
fixed_channel: f32,
88+
}
89+
90+
impl From<&ColorPlaneMaterial> for ColorPlaneMaterialKey {
91+
fn from(material: &ColorPlaneMaterial) -> Self {
92+
Self {
93+
plane: material.plane,
94+
}
95+
}
96+
}
97+
98+
impl UiMaterial for ColorPlaneMaterial {
99+
fn fragment_shader() -> ShaderRef {
100+
"embedded://bevy_feathers/assets/shaders/color_plane.wgsl".into()
101+
}
102+
103+
fn specialize(
104+
descriptor: &mut bevy_render::render_resource::RenderPipelineDescriptor,
105+
key: bevy_ui_render::prelude::UiMaterialKey<Self>,
106+
) {
107+
let plane_def = match key.bind_group_data.plane {
108+
ColorPlane::RedGreen => "PLANE_RG",
109+
ColorPlane::RedBlue => "PLANE_RB",
110+
ColorPlane::GreenBlue => "PLANE_GB",
111+
ColorPlane::HueSaturation => "PLANE_HS",
112+
ColorPlane::HueLightness => "PLANE_HL",
113+
};
114+
descriptor.fragment.as_mut().unwrap().shader_defs =
115+
vec![ShaderDefVal::Bool(plane_def.into(), true)];
116+
}
117+
}
118+
119+
/// Template function to spawn a "color plane", which is a 2d picker that allows selecting two
120+
/// components of a color space.
121+
///
122+
/// The control emits a [`ValueChange<Vec2>`] representing the current x and y values, ranging
123+
/// from 0 to 1. The control accepts a [`Vec3`] input value, where the third component ('z')
124+
/// is used to provide the fixed constant channel for the background gradient.
125+
///
126+
/// The control does not do any color space conversions internally, other than the shader code
127+
/// for displaying gradients. Avoiding excess conversions helps avoid gimble-lock problems when
128+
/// implementing a color picker for cylindrical color spaces such as HSL.
129+
///
130+
/// # Arguments
131+
/// * `overrides` - a bundle of components that are merged in with the normal swatch components.
132+
pub fn color_plane<B: Bundle>(plane: ColorPlane, overrides: B) -> impl Bundle {
133+
(
134+
Node {
135+
display: Display::Flex,
136+
min_height: px(100.0),
137+
align_self: AlignSelf::Stretch,
138+
padding: UiRect::all(px(4)),
139+
..Default::default()
140+
},
141+
plane,
142+
ColorPlaneValue::default(),
143+
ThemeBackgroundColor(tokens::COLOR_PLANE_BG),
144+
BorderRadius::all(px(5)),
145+
EntityCursor::System(bevy_window::SystemCursorIcon::Crosshair),
146+
overrides,
147+
children![(
148+
Node {
149+
align_self: AlignSelf::Stretch,
150+
flex_grow: 1.0,
151+
..Default::default()
152+
},
153+
ColorPlaneInner,
154+
children![(
155+
Node {
156+
position_type: PositionType::Absolute,
157+
left: Val::Percent(0.),
158+
top: Val::Percent(0.),
159+
width: px(10),
160+
height: px(10),
161+
border: UiRect::all(Val::Px(1.0)),
162+
..Default::default()
163+
},
164+
ColorPlaneThumb,
165+
BorderRadius::MAX,
166+
BorderColor::all(palette::WHITE),
167+
Outline {
168+
width: Val::Px(1.),
169+
offset: Val::Px(0.),
170+
color: palette::BLACK
171+
},
172+
Pickable::IGNORE,
173+
UiTransform::from_translation(Val2::new(Val::Percent(-50.0), Val::Percent(-50.0),))
174+
)],
175+
),],
176+
)
177+
}
178+
179+
fn update_plane_color(
180+
q_color_plane: Query<
181+
(Entity, &ColorPlane, &ColorPlaneValue),
182+
Or<(Changed<ColorPlane>, Changed<ColorPlaneValue>)>,
183+
>,
184+
q_children: Query<&Children>,
185+
q_material_node: Query<&MaterialNode<ColorPlaneMaterial>>,
186+
mut q_node: Query<&mut Node>,
187+
mut r_materials: ResMut<Assets<ColorPlaneMaterial>>,
188+
mut commands: Commands,
189+
) {
190+
for (plane_ent, plane, plane_value) in q_color_plane.iter() {
191+
// Find the inner entity
192+
let Ok(children) = q_children.get(plane_ent) else {
193+
continue;
194+
};
195+
let Some(inner_ent) = children.first() else {
196+
continue;
197+
};
198+
199+
if let Ok(material_node) = q_material_node.get(*inner_ent) {
200+
// Node component exists, update it
201+
if let Some(material) = r_materials.get_mut(material_node.id()) {
202+
// Update properties
203+
material.plane = *plane;
204+
material.fixed_channel = plane_value.0.z;
205+
}
206+
} else {
207+
// Insert new node component
208+
let material = r_materials.add(ColorPlaneMaterial {
209+
plane: *plane,
210+
fixed_channel: plane_value.0.z,
211+
});
212+
commands.entity(*inner_ent).insert(MaterialNode(material));
213+
}
214+
215+
// Find the thumb.
216+
let Ok(children_inner) = q_children.get(*inner_ent) else {
217+
continue;
218+
};
219+
let Some(thumb_ent) = children_inner.first() else {
220+
continue;
221+
};
222+
223+
let Ok(mut thumb_node) = q_node.get_mut(*thumb_ent) else {
224+
continue;
225+
};
226+
227+
thumb_node.left = Val::Percent(plane_value.0.x * 100.0);
228+
thumb_node.top = Val::Percent(plane_value.0.y * 100.0);
229+
}
230+
}
231+
232+
fn on_pointer_press(
233+
mut press: On<Pointer<Press>>,
234+
q_color_planes: Query<Has<InteractionDisabled>, With<ColorPlane>>,
235+
q_color_plane_inner: Query<
236+
(
237+
&ComputedNode,
238+
&ComputedUiRenderTargetInfo,
239+
&UiGlobalTransform,
240+
&ChildOf,
241+
),
242+
With<ColorPlaneInner>,
243+
>,
244+
ui_scale: Res<UiScale>,
245+
mut commands: Commands,
246+
) {
247+
if let Ok((node, node_target, transform, parent)) = q_color_plane_inner.get(press.entity)
248+
&& let Ok(disabled) = q_color_planes.get(parent.0)
249+
{
250+
press.propagate(false);
251+
if !disabled {
252+
let local_pos = transform.try_inverse().unwrap().transform_point2(
253+
press.pointer_location.position * node_target.scale_factor() / ui_scale.0,
254+
);
255+
let pos = local_pos / node.size() + Vec2::splat(0.5);
256+
let new_value = pos.clamp(Vec2::ZERO, Vec2::ONE);
257+
commands.trigger(ValueChange {
258+
source: parent.0,
259+
value: new_value,
260+
});
261+
}
262+
}
263+
}
264+
265+
fn on_drag_start(
266+
mut drag_start: On<Pointer<DragStart>>,
267+
mut q_color_planes: Query<
268+
(&mut ColorPlaneDragState, Has<InteractionDisabled>),
269+
With<ColorPlane>,
270+
>,
271+
q_color_plane_inner: Query<&ChildOf, With<ColorPlaneInner>>,
272+
) {
273+
if let Ok(parent) = q_color_plane_inner.get(drag_start.entity)
274+
&& let Ok((mut state, disabled)) = q_color_planes.get_mut(parent.0)
275+
{
276+
drag_start.propagate(false);
277+
if !disabled {
278+
state.0 = true;
279+
}
280+
}
281+
}
282+
283+
fn on_drag(
284+
mut drag: On<Pointer<Drag>>,
285+
q_color_planes: Query<(&ColorPlaneDragState, Has<InteractionDisabled>), With<ColorPlane>>,
286+
q_color_plane_inner: Query<
287+
(
288+
&ComputedNode,
289+
&ComputedUiRenderTargetInfo,
290+
&UiGlobalTransform,
291+
&ChildOf,
292+
),
293+
With<ColorPlaneInner>,
294+
>,
295+
ui_scale: Res<UiScale>,
296+
mut commands: Commands,
297+
) {
298+
if let Ok((node, node_target, transform, parent)) = q_color_plane_inner.get(drag.entity)
299+
&& let Ok((state, disabled)) = q_color_planes.get(parent.0)
300+
{
301+
drag.propagate(false);
302+
if state.0 && !disabled {
303+
let local_pos = transform.try_inverse().unwrap().transform_point2(
304+
drag.pointer_location.position * node_target.scale_factor() / ui_scale.0,
305+
);
306+
let pos = local_pos / node.size() + Vec2::splat(0.5);
307+
let new_value = pos.clamp(Vec2::ZERO, Vec2::ONE);
308+
commands.trigger(ValueChange {
309+
source: parent.0,
310+
value: new_value,
311+
});
312+
}
313+
}
314+
}
315+
316+
fn on_drag_end(
317+
mut drag_end: On<Pointer<DragEnd>>,
318+
mut q_color_planes: Query<&mut ColorPlaneDragState, With<ColorPlane>>,
319+
q_color_plane_inner: Query<&ChildOf, With<ColorPlaneInner>>,
320+
) {
321+
if let Ok(parent) = q_color_plane_inner.get(drag_end.entity)
322+
&& let Ok(mut state) = q_color_planes.get_mut(parent.0)
323+
{
324+
drag_end.propagate(false);
325+
state.0 = false;
326+
}
327+
}
328+
329+
fn on_drag_cancel(
330+
drag_cancel: On<Pointer<Cancel>>,
331+
mut q_color_planes: Query<&mut ColorPlaneDragState, With<ColorPlane>>,
332+
q_color_plane_inner: Query<&ChildOf, With<ColorPlaneInner>>,
333+
) {
334+
if let Ok(parent) = q_color_plane_inner.get(drag_cancel.entity)
335+
&& let Ok(mut state) = q_color_planes.get_mut(parent.0)
336+
{
337+
state.0 = false;
338+
}
339+
}
340+
341+
/// Plugin which registers the observers for updating the swatch color.
342+
pub struct ColorPlanePlugin;
343+
344+
impl Plugin for ColorPlanePlugin {
345+
fn build(&self, app: &mut bevy_app::App) {
346+
app.add_plugins(UiMaterialPlugin::<ColorPlaneMaterial>::default());
347+
app.add_systems(PostUpdate, update_plane_color);
348+
app.add_observer(on_pointer_press)
349+
.add_observer(on_drag_start)
350+
.add_observer(on_drag)
351+
.add_observer(on_drag_end)
352+
.add_observer(on_drag_cancel);
353+
}
354+
}

0 commit comments

Comments
 (0)