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