|
| 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