1 /**
2 Knob.
3
4 Copyright: Copyright Auburn Sounds 2015-2017.
5 License: $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0)
6 Authors: Guillaume Piolat
7 */
8 module dplug.pbrwidgets.knob;
9
10 import std.math: PI, exp, abs, sin, cos;
11
12 import dplug.core.math;
13
14 import dplug.gui.element;
15
16 import dplug.client.params;
17
18 enum KnobStyle
19 {
20 thumb, // with a hole
21 cylinder,
22 cone,
23 ball
24 }
25
26 class UIKnob : UIElement, IParameterListener
27 {
28 public:
29 nothrow:
30 @nogc:
31
32 // This will change to 1.0f at one point for consistency, so better express your knob
33 // sensivity with that.
34 enum defaultSensivity = 0.25f;
35
36 //
37 // Modify these public members to customize knobs!
38 //
39 @ScriptProperty float knobRadius = 0.75f;
40 @ScriptProperty RGBA knobDiffuse = RGBA(233, 235, 236, 0); // Last channel is ignored.
41 @ScriptProperty RGBA knobMaterial = RGBA(0, 255, 128, 255);
42 @ScriptProperty KnobStyle style = KnobStyle.thumb;
43 @ScriptProperty ubyte emissive = 0;
44 @ScriptProperty ubyte emissiveHovered = 30;
45 @ScriptProperty ubyte emissiveDragged = 0;
46
47 // LEDs
48 @ScriptProperty int numLEDs = 7;
49 @ScriptProperty float LEDRadiusMin = 0.127f;
50 @ScriptProperty float LEDRadiusMax = 0.127f;
51 @ScriptProperty RGBA LEDDiffuseLit = RGBA(255, 140, 220, 215);
52 @ScriptProperty RGBA LEDDiffuseUnlit = RGBA(255, 140, 220, 40);
53 @ScriptProperty RGBA LEDMaterial = RGBA(128, 128, 255, 255);
54 @ScriptProperty float LEDDistanceFromCenter = 0.8f;
55 @ScriptProperty float LEDDistanceFromCenterDragged = 0.7f;
56 @ScriptProperty ushort LEDDepth = 65000;
57
58 // trail
59 @ScriptProperty bool hasTrail = true;
60 @ScriptProperty RGBA litTrailDiffuse = RGBA(230, 80, 43, 192);
61 @ScriptProperty RGBA unlitTrailDiffuse = RGBA(150, 40, 20, 8);
62 @ScriptProperty float trailRadiusMin = 0.85f;
63 @ScriptProperty float trailRadiusMax = 0.97f;
64 @ScriptProperty float trailOffsetX = 0.0f; // in ratio of knob size
65 @ScriptProperty float trailOffsetY = 0.0f; // in ratio of knob size
66
67 @ScriptProperty float trailMinAngle = -PI * 0.75f;
68 @ScriptProperty float trailBaseAngle = -PI * 0.75f;
69 @ScriptProperty float trailMaxAngle = +PI * 0.75f;
70
71 // alternate trail is for values below base angle
72 // For example, knob trails can be blue for positive values,
73 // and orange for negative.
74 @ScriptProperty RGBA litTrailDiffuseAlt = RGBA(43, 80, 230, 192);
75 @ScriptProperty bool hasAlternateTrail = false;
76
77
78 @ScriptProperty float animationTimeConstant = 40.0f;
79
80 /// Sensivity: given a mouse movement in 100th of the height of the knob,
81 /// how much should the normalized parameter change.
82 @ScriptProperty float sensivity = defaultSensivity;
83
84 this(UIContext context, Parameter param)
85 {
86 super(context, flagAnimated | flagPBR);
87 _param = param;
88 _param.addListener(this);
89 _pushedAnimation = 0;
90 clearCrosspoints();
91 setCursorWhenDragged(MouseCursor.hidden);
92 setCursorWhenMouseOver(MouseCursor.move);
93 }
94
95 ~this()
96 {
97 _param.removeListener(this);
98 }
99
100 override void onAnimate(double dt, double time) nothrow @nogc
101 {
102 float target = isDragged() ? 1 : 0;
103
104 float newAnimation = lerp(_pushedAnimation, target, 1.0 - exp(-dt * animationTimeConstant));
105
106 if (abs(newAnimation - _pushedAnimation) > 0.001f)
107 {
108 _pushedAnimation = newAnimation;
109 setDirtyWhole();
110 }
111 }
112
113 override void onDrawPBR(ImageRef!RGBA diffuseMap, ImageRef!L16 depthMap, ImageRef!RGBA materialMap, box2i[] dirtyRects) nothrow @nogc
114 {
115 if (hasTrail)
116 drawTrail(diffuseMap, depthMap, materialMap, dirtyRects);
117 drawKnob(diffuseMap, depthMap, materialMap, dirtyRects);
118 }
119
120 void drawTrail(ImageRef!RGBA diffuseMap, ImageRef!L16 depthMap, ImageRef!RGBA materialMap, box2i[] dirtyRects) nothrow @nogc
121 {
122 float radius = getRadius();
123 vec2f center = getCenter();
124
125 float knobRadiusPx = radius * knobRadius;
126
127 vec2f trailOffset = vec2f(knobRadiusPx * trailOffsetX, knobRadiusPx * trailOffsetY);
128
129 // Eventually, use the alternative trail color
130 RGBA litTrail = litTrailDiffuse;
131 bool alternateTrail = false;
132 if (getValueAngle < getBaseAngle)
133 {
134 alternateTrail = true;
135 if (hasAlternateTrail)
136 {
137 litTrail = litTrailDiffuseAlt;
138 }
139 }
140
141 // when dragged, trail is two times brighter
142 if (isDragged)
143 {
144 int alpha = 2 * litTrail.a;
145 if (alpha > 255) alpha = 255;
146 litTrail.a = cast(ubyte) alpha;
147 }
148
149 foreach(dirtyRect; dirtyRects)
150 {
151 auto croppedDiffuse = diffuseMap.cropImageRef(dirtyRect);
152 auto croppedDepth = depthMap.cropImageRef(dirtyRect);
153 auto croppedMaterial = materialMap.cropImageRef(dirtyRect);
154
155 int bx = dirtyRect.min.x;
156 int by = dirtyRect.min.y;
157
158 //
159 // Draw trail
160 //
161 {
162 float trailCenterX = center.x - bx + trailOffset.x;
163 float trailCenterY = center.y - by + trailOffset.y;
164
165 float valueAngle = getValueAngle();
166 float minAngle = getMinAngle();
167 float maxAngle = getMaxAngle();
168 float baseAngle = getBaseAngle();
169
170 // Order is minAngle <= valueAngle-when-alt <= baseAngle <= valueAngle-when-not-alt <= maxAngle
171
172 float rmin = radius * trailRadiusMin;
173 float rmax = radius * trailRadiusMax;
174
175 // trail below baseAngle
176 if (alternateTrail)
177 {
178 croppedDiffuse.aaFillSector(trailCenterX, trailCenterY, rmin, rmax, minAngle, valueAngle, unlitTrailDiffuse);
179 croppedDiffuse.aaFillSector(trailCenterX, trailCenterY, rmin, rmax, valueAngle, baseAngle, litTrail);
180 }
181 else
182 {
183 croppedDiffuse.aaFillSector(trailCenterX, trailCenterY, rmin, rmax, minAngle, baseAngle, unlitTrailDiffuse);
184 }
185
186 // trail above baseAngle
187 if (alternateTrail)
188 {
189 croppedDiffuse.aaFillSector(trailCenterX, trailCenterY, rmin, rmax, baseAngle, maxAngle, unlitTrailDiffuse);
190 }
191 else
192 {
193 croppedDiffuse.aaFillSector(trailCenterX, trailCenterY, rmin, rmax, baseAngle, valueAngle, litTrail);
194 croppedDiffuse.aaFillSector(trailCenterX, trailCenterY, rmin, rmax, valueAngle, maxAngle, unlitTrailDiffuse);
195 }
196 }
197 }
198 }
199
200 void drawKnob(ImageRef!RGBA diffuseMap, ImageRef!L16 depthMap, ImageRef!RGBA materialMap, box2i[] dirtyRects) nothrow @nogc
201 {
202 float radius = getRadius();
203 vec2f center = getCenter();
204
205 float knobRadiusPx = radius * knobRadius;
206
207 foreach(dirtyRect; dirtyRects)
208 {
209 auto croppedDiffuse = diffuseMap.cropImageRef(dirtyRect);
210 auto croppedDepth = depthMap.cropImageRef(dirtyRect);
211 auto croppedMaterial = materialMap.cropImageRef(dirtyRect);
212
213 int bx = dirtyRect.min.x;
214 int by = dirtyRect.min.y;
215
216 //
217 // Draw knob
218 //
219 float angle = getValueAngle + PI * 0.5f;
220 float depthRadius = knobRadiusPx * 3.0f / 5.0f;
221 if (depthRadius < 0)
222 depthRadius = 0;
223 float depthRadius2 = knobRadiusPx * 3.0f / 5.0f;
224 if (depthRadius2 < 0)
225 depthRadius2 = 0;
226
227 float posEdgeX = center.x + sin(angle) * depthRadius2;
228 float posEdgeY = center.y - cos(angle) * depthRadius2;
229
230 ubyte emissiveComputed = emissive;
231 if (_shouldBeHighlighted)
232 emissiveComputed = emissiveHovered;
233 if (isDragged)
234 emissiveComputed = emissiveDragged;
235
236 if (style == KnobStyle.thumb)
237 {
238 croppedDepth.aaSoftDisc(center.x - bx, center.y - by, depthRadius, knobRadiusPx, L16(65535));
239 croppedDepth.aaSoftDisc(center.x - bx, center.y - by, 0, depthRadius, L16(38400));
240 }
241 else if (style == KnobStyle.cylinder)
242 {
243 L16 depth = L16(cast(ushort)(0.5f + lerp(65535.0f, 45000.0f, _pushedAnimation)) );
244 croppedDepth.aaSoftDisc(center.x - bx, center.y - by, knobRadiusPx - 5, knobRadiusPx, depth);
245 }
246 else if (style == KnobStyle.cone)
247 {
248 L16 depth = L16(cast(ushort)(0.5f + lerp(65535.0f, 45000.0f, _pushedAnimation)) );
249 croppedDepth.aaSoftDisc(center.x - bx, center.y - by, 0, knobRadiusPx, depth);
250 }
251 else if (style == KnobStyle.ball)
252 {
253 L16 depth = L16(cast(ushort)(0.5f + lerp(65535.0f, 45000.0f, _pushedAnimation)) );
254 croppedDepth.aaSoftDisc!1.2f(center.x - bx, center.y - by, 2, knobRadiusPx, depth);
255 }
256 RGBA knobDiffuseLit = knobDiffuse;
257 knobDiffuseLit.a = emissiveComputed;
258 croppedDiffuse.aaSoftDisc(center.x - bx, center.y - by, knobRadiusPx - 1, knobRadiusPx, knobDiffuseLit);
259 croppedMaterial.aaSoftDisc(center.x - bx, center.y - by, knobRadiusPx - 5, knobRadiusPx, knobMaterial);
260
261
262 // LEDs
263 for (int i = 0; i < numLEDs; ++i)
264 {
265 float disp = i * 2 * PI / numLEDs;
266 float distance = lerp(LEDDistanceFromCenter, LEDDistanceFromCenterDragged, _pushedAnimation);
267 float x = center.x + sin(angle + disp) * knobRadiusPx * distance;
268 float y = center.y - cos(angle + disp) * knobRadiusPx * distance;
269
270 float t = -1 + 2 * abs(disp - PI) / PI;
271
272 float LEDRadius = lerp(LEDRadiusMin, LEDRadiusMax, t);
273 if (LEDRadius < 0)
274 LEDRadius = 0;
275
276 float smallRadius = knobRadiusPx * LEDRadius * 0.714f;
277 float largerRadius = knobRadiusPx * LEDRadius;
278
279 RGBA LEDDiffuse;
280 LEDDiffuse.r = cast(ubyte)lerp!float(LEDDiffuseUnlit.r, LEDDiffuseLit.r, _pushedAnimation);
281 LEDDiffuse.g = cast(ubyte)lerp!float(LEDDiffuseUnlit.g, LEDDiffuseLit.g, _pushedAnimation);
282 LEDDiffuse.b = cast(ubyte)lerp!float(LEDDiffuseUnlit.b, LEDDiffuseLit.b, _pushedAnimation);
283 LEDDiffuse.a = cast(ubyte)lerp!float(LEDDiffuseUnlit.a, LEDDiffuseLit.a, _pushedAnimation);
284
285 croppedDepth.aaSoftDisc(x - bx, y - by, 0, largerRadius, L16(LEDDepth));
286 croppedDiffuse.aaSoftDisc(x - bx, y - by, 0, largerRadius, LEDDiffuse);
287 croppedMaterial.aaSoftDisc(x - bx, y - by, smallRadius, largerRadius, LEDMaterial);
288 }
289 }
290 }
291
292 /// Override this to round the parameter normalized value to some particular values.
293 /// For example for a special drag movement with right-click or control-click.
294 /// Default does nothing.
295 /// Params:
296 /// normalizedValue The main parameter normalized value (0 to 1).
297 /// Returns: the mapped value in normalized space (0 to 1).
298 double roundParamValue(double normalizedValue, MouseState mstate)
299 {
300 return normalizedValue;
301 }
302
303 override Click onMouseClick(int x, int y, int button, bool isDoubleClick, MouseState mstate)
304 {
305 if (!containsPoint(x, y))
306 return Click.unhandled;
307
308 // double-click => set to default
309 if (isDoubleClick || mstate.altPressed)
310 {
311 _param.beginParamEdit();
312 if (auto fp = cast(FloatParameter)_param)
313 fp.setFromGUI(fp.defaultValue());
314 else if (auto ip = cast(IntegerParameter)_param)
315 ip.setFromGUI(ip.defaultValue());
316 else
317 assert(false);
318 _param.endParamEdit();
319 }
320
321 _normalizedValueWhileDragging = _param.getNormalized();
322 _displacementInHeightDebt = 0;
323 return Click.startDrag;
324 }
325
326 // Called when mouse drag this Element.
327 override void onMouseDrag(int x, int y, int dx, int dy, MouseState mstate)
328 {
329 _displacementInHeightDebt += cast(float)(dy) / _position.height;
330
331 float modifier = 1.0f;
332 if (mstate.shiftPressed || mstate.ctrlPressed)
333 modifier *= 0.1f;
334
335 double oldParamValue = _normalizedValueWhileDragging;
336 double newParamValue = oldParamValue - _displacementInHeightDebt * modifier * sensivity;
337 if (mstate.altPressed)
338 newParamValue = _param.getNormalizedDefault();
339
340 if (y > _mousePosOnLast0Cross)
341 return;
342 if (y < _mousePosOnLast1Cross)
343 return;
344
345 if (newParamValue <= 0 && oldParamValue > 0)
346 _mousePosOnLast0Cross = y;
347
348 if (newParamValue >= 1 && oldParamValue < 1)
349 _mousePosOnLast1Cross = y;
350
351 if (newParamValue < 0)
352 newParamValue = 0;
353 if (newParamValue > 1)
354 newParamValue = 1;
355
356 if (newParamValue > 0)
357 _mousePosOnLast0Cross = float.infinity;
358
359 if (newParamValue < 1)
360 _mousePosOnLast1Cross = -float.infinity;
361
362 newParamValue = roundParamValue(newParamValue, mstate);
363
364 if (newParamValue != oldParamValue)
365 {
366 if (auto fp = cast(FloatParameter)_param)
367 fp.setFromGUINormalized(newParamValue);
368 else if (auto ip = cast(IntegerParameter)_param)
369 ip.setFromGUINormalized(newParamValue);
370 else
371 assert(false);
372 _normalizedValueWhileDragging = newParamValue;
373 _displacementInHeightDebt = 0;
374 }
375
376 }
377
378 // For lazy updates
379 override void onBeginDrag()
380 {
381 _param.beginParamEdit();
382 setDirtyWhole();
383 }
384
385 override void onStopDrag()
386 {
387 _param.endParamEdit();
388 clearCrosspoints();
389 setDirtyWhole();
390 }
391
392 override void onMouseMove(int x, int y, int dx, int dy, MouseState mstate)
393 {
394 _shouldBeHighlighted = containsPoint(x, y);
395 setDirtyWhole();
396 }
397
398 override void onMouseEnter()
399 {
400 _param.beginParamHover();
401 }
402
403 override void onMouseExit()
404 {
405 _param.endParamHover();
406 _shouldBeHighlighted = false;
407 setDirtyWhole();
408 }
409
410 override void onParameterChanged(Parameter sender) nothrow @nogc
411 {
412 setDirtyWhole();
413 }
414
415 override void onBeginParameterEdit(Parameter sender)
416 {
417 }
418
419 override void onEndParameterEdit(Parameter sender)
420 {
421 }
422
423 override void onBeginParameterHover(Parameter sender)
424 {
425 }
426
427 override void onEndParameterHover(Parameter sender)
428 {
429 }
430
431 protected:
432
433 /// The parameter this knob is linked with.
434 Parameter _param;
435
436 float _pushedAnimation;
437
438 bool _shouldBeHighlighted = false;
439
440 float _mousePosOnLast0Cross;
441 float _mousePosOnLast1Cross;
442
443 // Normalized value last set while dragging. Necessary for integer paramerers
444 // that may round that normalized value when setting the parameter.
445 float _normalizedValueWhileDragging;
446
447
448 // To support rounded mappings properly.
449 double _displacementInHeightDebt = 0.0;
450
451 /// Exists because public angle properties are given in a
452 /// different referential, where 0 is at the top
453 static float angleConvert(float angle) nothrow @nogc pure
454 {
455 return angle + PI * 1.5f;
456 }
457
458 /// Min angle of the trail, fit for `aaFillSector`.
459 float getMinAngle() const pure nothrow @nogc
460 {
461 return angleConvert(trailMinAngle);
462 }
463
464 /// Min angle of the trail, fit for `aaFillSector`.
465 float getMaxAngle() const pure nothrow @nogc
466 {
467 return angleConvert(trailMaxAngle);
468 }
469
470 /// Max angle of the trail, fit for `aaFillSector`.
471 float getBaseAngle() const pure nothrow @nogc
472 {
473 return angleConvert(trailBaseAngle);
474 }
475
476 /// Angle of the trail, fit for `aaFillSector`.
477 float getValueAngle() nothrow @nogc
478 {
479 return lerp(getMinAngle, getMaxAngle, _param.getNormalized());
480 }
481
482 void clearCrosspoints() nothrow @nogc
483 {
484 _mousePosOnLast0Cross = float.infinity;
485 _mousePosOnLast1Cross = -float.infinity;
486 }
487
488 final bool containsPoint(int x, int y) nothrow @nogc
489 {
490 vec2f center = getCenter();
491 return vec2f(x, y).distanceTo(center) < getRadius();
492 }
493
494 /// Returns: largest square centered in _position
495 final box2i getSubsquare() pure const nothrow @nogc
496 {
497 // We'll draw entirely in the largest centered square in _position.
498 box2i subSquare;
499 if (_position.width > _position.height)
500 {
501 int offset = (_position.width - _position.height) / 2;
502 int minX = offset;
503 subSquare = box2i(minX, 0, minX + _position.height, _position.height);
504 }
505 else
506 {
507 int offset = (_position.height - _position.width) / 2;
508 int minY = offset;
509 subSquare = box2i(0, minY, _position.width, minY + _position.width);
510 }
511 return subSquare;
512 }
513
514 final float getRadius() pure const nothrow @nogc
515 {
516 return getSubsquare().width * 0.5f;
517
518 }
519
520 final vec2f getCenter() pure const nothrow @nogc
521 {
522 box2i subSquare = getSubsquare();
523 float centerx = (subSquare.min.x + subSquare.max.x - 1) * 0.5f;
524 float centery = (subSquare.min.y + subSquare.max.y - 1) * 0.5f;
525 return vec2f(centerx, centery);
526 }
527 }