A game framework written with osu! in mind.
at master 267 lines 10 kB view raw
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. 2// See the LICENCE file in the repository root for full licence text. 3 4using System; 5using osu.Framework.Allocation; 6using osu.Framework.Bindables; 7using osu.Framework.Graphics.Containers; 8using osu.Framework.Graphics.OpenGL.Vertices; 9using osu.Framework.Graphics.Shaders; 10using osu.Framework.Graphics.Shapes; 11using osu.Framework.Graphics.Sprites; 12using osu.Framework.Input.Events; 13using osu.Framework.Utils; 14using osuTK; 15 16namespace osu.Framework.Graphics.UserInterface 17{ 18 public abstract partial class HSVColourPicker 19 { 20 public abstract class SaturationValueSelector : CompositeDrawable 21 { 22 public readonly Bindable<Colour4> Current = new Bindable<Colour4>(); 23 24 public Bindable<float> Hue { get; } = new BindableFloat 25 { 26 MinValue = 0, 27 MaxValue = 1 28 }; 29 30 public Bindable<float> Saturation { get; } = new BindableFloat 31 { 32 MinValue = 0, 33 MaxValue = 1 34 }; 35 36 public Bindable<float> Value { get; } = new BindableFloat 37 { 38 MinValue = 0, 39 MaxValue = 1 40 }; 41 42 /// <summary> 43 /// The gradiented box serving as the selection area. 44 /// </summary> 45 protected Container SelectionArea { get; } 46 47 private readonly Drawable marker; 48 private readonly SaturationBox box; 49 50 protected SaturationValueSelector() 51 { 52 RelativeSizeAxes = Axes.X; 53 54 InternalChildren = new[] 55 { 56 SelectionArea = new Container 57 { 58 RelativeSizeAxes = Axes.Both, 59 Child = box = new SaturationBox() 60 }, 61 marker = CreateMarker().With(d => 62 { 63 d.Current.BindTo(Current); 64 65 d.Origin = Anchor.Centre; 66 d.RelativePositionAxes = Axes.Both; 67 }) 68 }; 69 } 70 71 /// <summary> 72 /// Creates the marker which will be used for selecting the final colour from the gamut. 73 /// </summary> 74 protected abstract Marker CreateMarker(); 75 76 protected override void LoadComplete() 77 { 78 base.LoadComplete(); 79 80 // the following handlers aren't fired immediately to avoid mutating Current by accident when ran prematurely. 81 // if necessary, they will run when the Current value change callback fires at the end of this method. 82 Hue.BindValueChanged(_ => debounce(hueChanged)); 83 Saturation.BindValueChanged(_ => debounce(saturationChanged)); 84 Value.BindValueChanged(_ => debounce(valueChanged)); 85 86 // Current takes precedence over HSV controls, and as such it must run last after HSV handlers have been set up for correct operation. 87 Current.BindValueChanged(_ => currentChanged(), true); 88 } 89 90 // As Current and {Hue,Saturation,Value} are mutually bound together, 91 // using unprotected value change callbacks can end up causing partial colour updates (e.g. only the hue changing when Current is set), 92 // or circular updates (e.g. Hue.Changed -> Current.Changed -> Hue.Changed). 93 // To prevent this, this flag is set on every original change on each of the four bindables, 94 // and any subsequent value change callbacks are supposed to not mutate any of those bindables further if the flag is set. 95 private bool changeInProgress; 96 97 private void debounce(Action updateFunc) 98 { 99 if (changeInProgress) 100 { 101 // if changeInProgress is set, it means that this call is triggered by Current changing. 102 // the update cannot be scheduled, because due to floating-point / HSV-to-RGB conversion foibles it could potentially slightly change Current again in the next frame. 103 // running immediately is fine, however, as updateCurrent() guards against that by checking changeInProgress itself. 104 updateFunc.Invoke(); 105 } 106 else 107 { 108 // if changeInProgress is not set, it means that this call is triggered by actual user input on the hue/saturation/value controls. 109 // as such it can be debounced to reduce the amount of performed work. 110 Scheduler.AddOnce(updateFunc); 111 } 112 } 113 114 private void currentChanged() 115 { 116 if (changeInProgress) 117 return; 118 119 var asHSV = Current.Value.ToHSV(); 120 121 changeInProgress = true; 122 123 Saturation.Value = asHSV.Y; 124 Value.Value = asHSV.Z; 125 126 if (shouldUpdateHue(asHSV.X)) 127 Hue.Value = asHSV.X; 128 129 changeInProgress = false; 130 } 131 132 private bool shouldUpdateHue(float newHue) 133 { 134 // there are two situations in which a hue value change is possibly unwanted. 135 // * if saturation is near-zero, it may not be really possible to accurately measure the hue of the colour, 136 // as hsv(x, 0, y) == hsv(z, 0, y) for any x,y,z. 137 // * similarly, the hues of 0 and 1 are functionally equivalent, 138 // as hsv(0, x, y) == hsv(1, x, y) for any x,y. 139 // in those cases, just keep the hue as it was, as the colour will still be roughly the same to the point of being imperceptible, 140 // and doing this will prevent UX idiosyncrasies (such as the hue slider jumping to 0 for no apparent reason). 141 return Precision.DefinitelyBigger(Saturation.Value, 0) 142 && !Precision.AlmostEquals(Hue.Value - newHue, 1); 143 } 144 145 private void hueChanged() 146 { 147 box.Hue = Hue.Value; 148 updateCurrent(); 149 } 150 151 private void saturationChanged() 152 { 153 marker.X = Saturation.Value; 154 updateCurrent(); 155 } 156 157 private void valueChanged() 158 { 159 marker.Y = 1 - Value.Value; 160 updateCurrent(); 161 } 162 163 private void updateCurrent() 164 { 165 if (changeInProgress) 166 return; 167 168 changeInProgress = true; 169 Current.Value = Colour4.FromHSV(Hue.Value, Saturation.Value, Value.Value); 170 changeInProgress = false; 171 } 172 173 protected override void Update() 174 { 175 base.Update(); 176 177 // manually preserve aspect ratio. 178 // Fill{Mode,AspectRatio} do not work here, because they require RelativeSizeAxes = Both, 179 // which in turn causes BypassAutoSizeAxes to be set to Both, and so the parent ignores the child height and assumes 0. 180 Height = DrawWidth; 181 } 182 183 protected override bool OnMouseDown(MouseDownEvent e) 184 { 185 handleMouseInput(e.ScreenSpaceMousePosition); 186 return true; 187 } 188 189 protected override bool OnDragStart(DragStartEvent e) => true; 190 191 protected override void OnDrag(DragEvent e) 192 { 193 handleMouseInput(e.ScreenSpaceMousePosition); 194 } 195 196 private void handleMouseInput(Vector2 mousePosition) 197 { 198 var localSpacePosition = ToLocalSpace(mousePosition); 199 Saturation.Value = localSpacePosition.X / DrawWidth; 200 Value.Value = 1 - localSpacePosition.Y / DrawHeight; 201 } 202 203 protected abstract class Marker : CompositeDrawable 204 { 205 public IBindable<Colour4> Current { get; } = new Bindable<Colour4>(); 206 } 207 208 private class SaturationBox : Box, ITexturedShaderDrawable 209 { 210 public new IShader TextureShader { get; private set; } 211 public new IShader RoundedTextureShader { get; private set; } 212 213 private float hue; 214 215 public float Hue 216 { 217 get => hue; 218 set 219 { 220 if (hue == value) return; 221 222 hue = value; 223 Invalidate(Invalidation.DrawNode); 224 } 225 } 226 227 public SaturationBox() 228 { 229 RelativeSizeAxes = Axes.Both; 230 } 231 232 [BackgroundDependencyLoader] 233 private void load(ShaderManager shaders) 234 { 235 TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, "SaturationSelectorBackground"); 236 RoundedTextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, "SaturationSelectorBackgroundRounded"); 237 } 238 239 protected override DrawNode CreateDrawNode() => new SaturationBoxDrawNode(this); 240 241 private class SaturationBoxDrawNode : SpriteDrawNode 242 { 243 public new SaturationBox Source => (SaturationBox)base.Source; 244 245 public SaturationBoxDrawNode(SaturationBox source) 246 : base(source) 247 { 248 } 249 250 private float hue; 251 252 public override void ApplyState() 253 { 254 base.ApplyState(); 255 hue = Source.hue; 256 } 257 258 protected override void Blit(Action<TexturedVertex2D> vertexAction) 259 { 260 Shader.GetUniform<float>("hue").UpdateValue(ref hue); 261 base.Blit(vertexAction); 262 } 263 } 264 } 265 } 266 } 267}