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