A game framework written with osu! in mind.
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}