1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3
4# Scalable Dial, a custom Qt widget
5# Copyright (C) 2011-2020 Filipe Coelho <falktx@falktx.com>
6#
7# This program is free software; you can redistribute it and/or
8# modify it under the terms of the GNU General Public License as
9# published by the Free Software Foundation; either version 2 of
10# the License, or any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# For a full copy of the GNU General Public License see the doc/GPL.txt file.
18
19# ------------------------------------------------------------------------------------------------------------
20# Imports (Global)
21
22from math import cos, floor, pi, sin, isnan
23
24from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QEvent, QPointF, QRectF, QTimer, QSize
25from PyQt5.QtGui import QColor, QConicalGradient, QFont, QFontMetrics
26from PyQt5.QtGui import QLinearGradient, QPainter, QPainterPath, QPen, QPixmap
27from PyQt5.QtSvg import QSvgWidget
28from PyQt5.QtWidgets import QDial
29
30# ------------------------------------------------------------------------------------------------------------
31# Widget Class
32
33class ScalableDial(QDial):
34    # enum CustomPaintMode
35    CUSTOM_PAINT_MODE_NULL        = 0 # default (NOTE: only this mode has label gradient)
36    CUSTOM_PAINT_MODE_CARLA_WET   = 1 # color blue-green gradient (reserved #3)
37    CUSTOM_PAINT_MODE_CARLA_VOL   = 2 # color blue (reserved #3)
38    CUSTOM_PAINT_MODE_CARLA_L     = 3 # color yellow (reserved #4)
39    CUSTOM_PAINT_MODE_CARLA_R     = 4 # color yellow (reserved #4)
40    CUSTOM_PAINT_MODE_CARLA_PAN   = 5 # color yellow (reserved #3)
41    CUSTOM_PAINT_MODE_COLOR       = 6 # color, selectable (reserved #3)
42    CUSTOM_PAINT_MODE_ZITA        = 7 # custom zita knob (reserved #6)
43    CUSTOM_PAINT_MODE_NO_GRADIENT = 8 # skip label gradient
44
45    # enum Orientation
46    HORIZONTAL = 0
47    VERTICAL   = 1
48
49    HOVER_MIN = 0
50    HOVER_MAX = 9
51
52    MODE_DEFAULT = 0
53    MODE_LINEAR = 1
54
55    # signals
56    dragStateChanged = pyqtSignal(bool)
57    realValueChanged = pyqtSignal(float)
58
59    def __init__(self, parent, index=0):
60        QDial.__init__(self, parent)
61
62        self.fDialMode = self.MODE_LINEAR
63
64        self.fMinimum   = 0.0
65        self.fMaximum   = 1.0
66        self.fRealValue = 0.0
67        self.fPrecision = 10000
68        self.fIsInteger = False
69
70        self.fIsHovered = False
71        self.fIsPressed = False
72        self.fHoverStep = self.HOVER_MIN
73
74        self.fLastDragPos = None
75        self.fLastDragValue = 0.0
76
77        self.fIndex    = index
78        self.fImage    = QSvgWidget(":/scalable/dial_03.svg")
79        self.fImageNum = "01"
80
81        if self.fImage.sizeHint().width() > self.fImage.sizeHint().height():
82            self.fImageOrientation = self.HORIZONTAL
83        else:
84            self.fImageOrientation = self.VERTICAL
85
86        self.fLabel     = ""
87        self.fLabelPos  = QPointF(0.0, 0.0)
88        self.fLabelFont = QFont(self.font())
89        self.fLabelFont.setPixelSize(8)
90        self.fLabelWidth  = 0
91        self.fLabelHeight = 0
92
93        if self.palette().window().color().lightness() > 100:
94            # Light background
95            c = self.palette().dark().color()
96            self.fLabelGradientColor1 = c
97            self.fLabelGradientColor2 = QColor(c.red(), c.green(), c.blue(), 0)
98            self.fLabelGradientColorT = [self.palette().buttonText().color(), self.palette().mid().color()]
99        else:
100            # Dark background
101            self.fLabelGradientColor1 = QColor(0, 0, 0, 255)
102            self.fLabelGradientColor2 = QColor(0, 0, 0, 0)
103            self.fLabelGradientColorT = [Qt.white, Qt.darkGray]
104
105        self.fLabelGradient = QLinearGradient(0, 0, 0, 1)
106        self.fLabelGradient.setColorAt(0.0, self.fLabelGradientColor1)
107        self.fLabelGradient.setColorAt(0.6, self.fLabelGradientColor1)
108        self.fLabelGradient.setColorAt(1.0, self.fLabelGradientColor2)
109
110        self.fLabelGradientRect = QRectF(0.0, 0.0, 0.0, 0.0)
111
112        self.fCustomPaintMode  = self.CUSTOM_PAINT_MODE_NULL
113        self.fCustomPaintColor = QColor(0xff, 0xff, 0xff)
114
115        self.updateSizes()
116
117        # Fake internal value, custom precision
118        QDial.setMinimum(self, 0)
119        QDial.setMaximum(self, self.fPrecision)
120        QDial.setValue(self, 0)
121
122        self.valueChanged.connect(self.slot_valueChanged)
123
124    def getIndex(self):
125        return self.fIndex
126
127    def getBaseSize(self):
128        return self.fImageBaseSize
129
130    def forceWhiteLabelGradientText(self):
131        self.fLabelGradientColor1 = QColor(0, 0, 0, 255)
132        self.fLabelGradientColor2 = QColor(0, 0, 0, 0)
133        self.fLabelGradientColorT = [Qt.white, Qt.darkGray]
134
135    def setLabelColor(self, enabled, disabled):
136        self.fLabelGradientColor1 = QColor(0, 0, 0, 255)
137        self.fLabelGradientColor2 = QColor(0, 0, 0, 0)
138        self.fLabelGradientColorT = [enabled, disabled]
139
140    def updateSizes(self):
141        if isinstance(self.fImage, QPixmap):
142            self.fImageWidth  = self.fImage.width()
143            self.fImageHeight = self.fImage.height()
144        else:
145            self.fImageWidth  = self.fImage.sizeHint().width()
146            self.fImageHeight = self.fImage.sizeHint().height()
147
148        if self.fImageWidth < 1:
149            self.fImageWidth = 1
150
151        if self.fImageHeight < 1:
152            self.fImageHeight = 1
153
154        if self.fImageOrientation == self.HORIZONTAL:
155            self.fImageBaseSize    = self.fImageHeight
156            self.fImageLayersCount = self.fImageWidth / self.fImageHeight
157        else:
158            self.fImageBaseSize    = self.fImageWidth
159            self.fImageLayersCount = self.fImageHeight / self.fImageWidth
160
161        self.setMinimumSize(self.fImageBaseSize, self.fImageBaseSize + self.fLabelHeight + 5)
162        self.setMaximumSize(self.fImageBaseSize, self.fImageBaseSize + self.fLabelHeight + 5)
163
164        if not self.fLabel:
165            self.fLabelHeight = 0
166            self.fLabelWidth  = 0
167            return
168
169        self.fLabelWidth  = QFontMetrics(self.fLabelFont).width(self.fLabel)
170        self.fLabelHeight = QFontMetrics(self.fLabelFont).height()
171
172        self.fLabelPos.setX(float(self.fImageBaseSize)/2.0 - float(self.fLabelWidth)/2.0)
173
174        if self.fImageNum in ("01", "02", "07", "08", "09", "10"):
175            self.fLabelPos.setY(self.fImageBaseSize + self.fLabelHeight)
176        elif self.fImageNum in ("11",):
177            self.fLabelPos.setY(self.fImageBaseSize + self.fLabelHeight*2/3)
178        else:
179            self.fLabelPos.setY(self.fImageBaseSize + self.fLabelHeight/2)
180
181        self.fLabelGradient.setStart(0, float(self.fImageBaseSize)/2.0)
182        self.fLabelGradient.setFinalStop(0, self.fImageBaseSize + self.fLabelHeight + 5)
183
184        self.fLabelGradientRect = QRectF(float(self.fImageBaseSize)/8.0, float(self.fImageBaseSize)/2.0,
185                                         float(self.fImageBaseSize*3)/4.0, self.fImageBaseSize+self.fLabelHeight+5)
186
187    def setCustomPaintMode(self, paintMode):
188        if self.fCustomPaintMode == paintMode:
189            return
190
191        self.fCustomPaintMode = paintMode
192        self.update()
193
194    def setCustomPaintColor(self, color):
195        if self.fCustomPaintColor == color:
196            return
197
198        self.fCustomPaintColor = color
199        self.update()
200
201    def setLabel(self, label):
202        if self.fLabel == label:
203            return
204
205        self.fLabel = label
206        self.updateSizes()
207        self.update()
208
209    def setIndex(self, index):
210        self.fIndex = index
211
212    def setImage(self, imageId):
213        self.fImageNum = "%02i" % imageId
214        if imageId in (2,6,7,8,9,10,11,12,13):
215            img = ":/bitmaps/dial_%s%s.png" % (self.fImageNum, "" if self.isEnabled() else "d")
216        else:
217            img = ":/scalable/dial_%s%s.svg" % (self.fImageNum, "" if self.isEnabled() else "d")
218
219        if img.endswith(".png"):
220            if not isinstance(self.fImage, QPixmap):
221                self.fImage = QPixmap()
222        else:
223            if not isinstance(self.fImage, QSvgWidget):
224                self.fImage = QSvgWidget()
225
226        self.fImage.load(img)
227
228        if self.fImage.width() > self.fImage.height():
229            self.fImageOrientation = self.HORIZONTAL
230        else:
231            self.fImageOrientation = self.VERTICAL
232
233        # special svgs
234        if self.fCustomPaintMode == self.CUSTOM_PAINT_MODE_NULL:
235            # reserved for carla-wet, carla-vol, carla-pan and color
236            if self.fImageNum == "03":
237                self.fCustomPaintMode = self.CUSTOM_PAINT_MODE_COLOR
238
239            # reserved for carla-L and carla-R
240            elif self.fImageNum == "04":
241                self.fCustomPaintMode = self.CUSTOM_PAINT_MODE_CARLA_L
242
243            # reserved for zita
244            elif self.fImageNum == "06":
245                self.fCustomPaintMode = self.CUSTOM_PAINT_MODE_ZITA
246
247        self.updateSizes()
248        self.update()
249
250    def setPrecision(self, value, isInteger):
251        self.fPrecision = value
252        self.fIsInteger = isInteger
253        QDial.setMaximum(self, value)
254
255    def setMinimum(self, value):
256        self.fMinimum = value
257
258    def setMaximum(self, value):
259        self.fMaximum = value
260
261    def rvalue(self):
262        return self.fRealValue
263
264    def setValue(self, value, emitSignal=False):
265        if self.fRealValue == value or isnan(value):
266            return
267
268        if value <= self.fMinimum:
269            qtValue = 0
270            self.fRealValue = self.fMinimum
271
272        elif value >= self.fMaximum:
273            qtValue = self.fPrecision
274            self.fRealValue = self.fMaximum
275
276        else:
277            qtValue = round(float(value - self.fMinimum) / float(self.fMaximum - self.fMinimum) * self.fPrecision)
278            self.fRealValue = value
279
280        # Block change signal, we'll handle it ourselves
281        self.blockSignals(True)
282        QDial.setValue(self, qtValue)
283        self.blockSignals(False)
284
285        if emitSignal:
286            self.realValueChanged.emit(self.fRealValue)
287
288    @pyqtSlot(int)
289    def slot_valueChanged(self, value):
290        self.fRealValue = float(value)/self.fPrecision * (self.fMaximum - self.fMinimum) + self.fMinimum
291        self.realValueChanged.emit(self.fRealValue)
292
293    @pyqtSlot()
294    def slot_updateImage(self):
295        self.setImage(int(self.fImageNum))
296
297    def minimumSizeHint(self):
298        return QSize(self.fImageBaseSize, self.fImageBaseSize)
299
300    def sizeHint(self):
301        return QSize(self.fImageBaseSize, self.fImageBaseSize)
302
303    def changeEvent(self, event):
304        QDial.changeEvent(self, event)
305
306        # Force svg update if enabled state changes
307        if event.type() == QEvent.EnabledChange:
308            self.slot_updateImage()
309
310    def enterEvent(self, event):
311        self.fIsHovered = True
312        if self.fHoverStep == self.HOVER_MIN:
313            self.fHoverStep = self.HOVER_MIN + 1
314        QDial.enterEvent(self, event)
315
316    def leaveEvent(self, event):
317        self.fIsHovered = False
318        if self.fHoverStep == self.HOVER_MAX:
319            self.fHoverStep = self.HOVER_MAX - 1
320        QDial.leaveEvent(self, event)
321
322    def mousePressEvent(self, event):
323        if self.fDialMode == self.MODE_DEFAULT:
324            return QDial.mousePressEvent(self, event)
325
326        if event.button() == Qt.LeftButton:
327            self.fIsPressed = True
328            self.fLastDragPos = event.pos()
329            self.fLastDragValue = self.fRealValue
330            self.dragStateChanged.emit(True)
331
332    def mouseMoveEvent(self, event):
333        if self.fDialMode == self.MODE_DEFAULT:
334            return QDial.mouseMoveEvent(self, event)
335
336        if not self.fIsPressed:
337            return
338
339        range = (self.fMaximum - self.fMinimum) / 4.0
340        pos   = event.pos()
341        dx    = range * float(pos.x() - self.fLastDragPos.x()) / self.width()
342        dy    = range * float(pos.y() - self.fLastDragPos.y()) / self.height()
343        value = self.fLastDragValue + dx - dy
344
345        if value < self.fMinimum:
346            value = self.fMinimum
347        elif value > self.fMaximum:
348            value = self.fMaximum
349        elif self.fIsInteger:
350            value = float(round(value))
351
352        self.setValue(value, True)
353
354    def mouseReleaseEvent(self, event):
355        if self.fDialMode == self.MODE_DEFAULT:
356            return QDial.mouseReleaseEvent(self, event)
357
358        if self.fIsPressed:
359            self.fIsPressed = False
360            self.dragStateChanged.emit(False)
361
362    def paintEvent(self, event):
363        painter = QPainter(self)
364        event.accept()
365
366        painter.save()
367        painter.setRenderHint(QPainter.Antialiasing, True)
368
369        if self.fLabel:
370            if self.fCustomPaintMode == self.CUSTOM_PAINT_MODE_NULL:
371                painter.setPen(self.fLabelGradientColor2)
372                painter.setBrush(self.fLabelGradient)
373                painter.drawRect(self.fLabelGradientRect)
374
375            painter.setFont(self.fLabelFont)
376            painter.setPen(self.fLabelGradientColorT[0 if self.isEnabled() else 1])
377            painter.drawText(self.fLabelPos, self.fLabel)
378
379        if self.isEnabled():
380            normValue = float(self.fRealValue - self.fMinimum) / float(self.fMaximum - self.fMinimum)
381            curLayer = int((self.fImageLayersCount - 1) * normValue)
382
383            if self.fImageOrientation == self.HORIZONTAL:
384                xpos = self.fImageBaseSize * curLayer
385                ypos = 0.0
386            else:
387                xpos = 0.0
388                ypos = self.fImageBaseSize * curLayer
389
390            source = QRectF(xpos, ypos, self.fImageBaseSize, self.fImageBaseSize)
391
392            if isinstance(self.fImage, QPixmap):
393                target = QRectF(0.0, 0.0, self.fImageBaseSize, self.fImageBaseSize)
394                painter.drawPixmap(target, self.fImage, source)
395            else:
396                self.fImage.renderer().render(painter, source)
397
398            # Custom knobs (Dry/Wet and Volume)
399            if self.fCustomPaintMode in (self.CUSTOM_PAINT_MODE_CARLA_WET, self.CUSTOM_PAINT_MODE_CARLA_VOL):
400                # knob color
401                colorGreen = QColor(0x5D, 0xE7, 0x3D).lighter(100 + self.fHoverStep*6)
402                colorBlue  = QColor(0x3E, 0xB8, 0xBE).lighter(100 + self.fHoverStep*6)
403
404                # draw small circle
405                ballRect = QRectF(8.0, 8.0, 15.0, 15.0)
406                ballPath = QPainterPath()
407                ballPath.addEllipse(ballRect)
408                #painter.drawRect(ballRect)
409                tmpValue  = (0.375 + 0.75*normValue)
410                ballValue = tmpValue - floor(tmpValue)
411                ballPoint = ballPath.pointAtPercent(ballValue)
412
413                # draw arc
414                startAngle = 218*16
415                spanAngle  = -255*16*normValue
416
417                if self.fCustomPaintMode == self.CUSTOM_PAINT_MODE_CARLA_WET:
418                    painter.setBrush(colorBlue)
419                    painter.setPen(QPen(colorBlue, 0))
420                    painter.drawEllipse(QRectF(ballPoint.x(), ballPoint.y(), 2.2, 2.2))
421
422                    gradient = QConicalGradient(15.5, 15.5, -45)
423                    gradient.setColorAt(0.0,   colorBlue)
424                    gradient.setColorAt(0.125, colorBlue)
425                    gradient.setColorAt(0.625, colorGreen)
426                    gradient.setColorAt(0.75,  colorGreen)
427                    gradient.setColorAt(0.76,  colorGreen)
428                    gradient.setColorAt(1.0,   colorGreen)
429                    painter.setBrush(gradient)
430                    painter.setPen(QPen(gradient, 3))
431
432                else:
433                    painter.setBrush(colorBlue)
434                    painter.setPen(QPen(colorBlue, 0))
435                    painter.drawEllipse(QRectF(ballPoint.x(), ballPoint.y(), 2.2, 2.2))
436
437                    painter.setBrush(colorBlue)
438                    painter.setPen(QPen(colorBlue, 3))
439
440                painter.drawArc(4.0, 4.0, 26.0, 26.0, startAngle, spanAngle)
441
442            # Custom knobs (L and R)
443            elif self.fCustomPaintMode in (self.CUSTOM_PAINT_MODE_CARLA_L, self.CUSTOM_PAINT_MODE_CARLA_R):
444                # knob color
445                color = QColor(0xAD, 0xD5, 0x48).lighter(100 + self.fHoverStep*6)
446
447                # draw small circle
448                ballRect = QRectF(7.0, 8.0, 11.0, 12.0)
449                ballPath = QPainterPath()
450                ballPath.addEllipse(ballRect)
451                #painter.drawRect(ballRect)
452                tmpValue  = (0.375 + 0.75*normValue)
453                ballValue = tmpValue - floor(tmpValue)
454                ballPoint = ballPath.pointAtPercent(ballValue)
455
456                painter.setBrush(color)
457                painter.setPen(QPen(color, 0))
458                painter.drawEllipse(QRectF(ballPoint.x(), ballPoint.y(), 2.0, 2.0))
459
460                # draw arc
461                if self.fCustomPaintMode == self.CUSTOM_PAINT_MODE_CARLA_L:
462                    startAngle = 218*16
463                    spanAngle  = -255*16*normValue
464                else:
465                    startAngle = 322.0*16
466                    spanAngle  = 255.0*16*(1.0-normValue)
467
468                painter.setPen(QPen(color, 2.5))
469                painter.drawArc(3.5, 3.5, 22.0, 22.0, startAngle, spanAngle)
470
471            # Custom knobs (Color)
472            elif self.fCustomPaintMode == self.CUSTOM_PAINT_MODE_COLOR:
473                # knob color
474                color = self.fCustomPaintColor.lighter(100 + self.fHoverStep*6)
475
476                # draw small circle
477                ballRect = QRectF(8.0, 8.0, 15.0, 15.0)
478                ballPath = QPainterPath()
479                ballPath.addEllipse(ballRect)
480                tmpValue  = (0.375 + 0.75*normValue)
481                ballValue = tmpValue - floor(tmpValue)
482                ballPoint = ballPath.pointAtPercent(ballValue)
483
484                # draw arc
485                startAngle = 218*16
486                spanAngle  = -255*16*normValue
487
488                painter.setBrush(color)
489                painter.setPen(QPen(color, 0))
490                painter.drawEllipse(QRectF(ballPoint.x(), ballPoint.y(), 2.2, 2.2))
491
492                painter.setBrush(color)
493                painter.setPen(QPen(color, 3))
494                painter.drawArc(4.0, 4.8, 26.0, 26.0, startAngle, spanAngle)
495
496            # Custom knobs (Zita)
497            elif self.fCustomPaintMode == self.CUSTOM_PAINT_MODE_ZITA:
498                a = normValue * pi * 1.5 - 2.35
499                r = 10.0
500                x = 10.5
501                y = 10.5
502                x += r * sin(a)
503                y -= r * cos(a)
504                painter.setBrush(Qt.black)
505                painter.setPen(QPen(Qt.black, 2))
506                painter.drawLine(QPointF(11.0, 11.0), QPointF(x, y))
507
508            # Custom knobs
509            else:
510                painter.restore()
511                return
512
513            if self.HOVER_MIN < self.fHoverStep < self.HOVER_MAX:
514                self.fHoverStep += 1 if self.fIsHovered else -1
515                QTimer.singleShot(20, self.update)
516
517        else: # isEnabled()
518            target = QRectF(0.0, 0.0, self.fImageBaseSize, self.fImageBaseSize)
519            if isinstance(self.fImage, QPixmap):
520                painter.drawPixmap(target, self.fImage, target)
521            else:
522                self.fImage.renderer().render(painter, target)
523
524        painter.restore()
525
526    def resizeEvent(self, event):
527        QDial.resizeEvent(self, event)
528        self.updateSizes()
529
530# ------------------------------------------------------------------------------------------------------------
531# Main Testing
532
533if __name__ == '__main__':
534    import sys
535    from PyQt5.QtWidgets import QApplication
536    import resources_rc
537
538    app = QApplication(sys.argv)
539    gui = ScalableDial(None)
540    #gui.setEnabled(True)
541    #gui.setEnabled(False)
542    gui.setSvg(3)
543    gui.setLabel("hahaha")
544    gui.show()
545
546    sys.exit(app.exec_())
547