Search code examples
pythonpyqtpyqt5qdial

QDial ange per revolution


I'm trying to make an Application with PyQt5, Python 3.7.3 using a Raspberry pi4B and a 5 inch touch screen. The thing is that I need to make a QDial, but I want it to make more than one revolution if it goes from min range to max range. For example, if the Qdial has range from 0 to 500, I want it to make 100 points per revolution, so you have to do a full rotation 5 times to go from the min value to the max value.

This is what I've tried: `

from PyQt5.QtWidgets import *
import sys

class Window(QWidget):
    def __init__(self):
        QWidget.__init__(self)
        layout = QGridLayout()
        self.setLayout(layout)
        self.dial = QDial()
        self.dial.setMinimum(0)
        self.dial.setMaximum(100)
        self.dial.setValue(40)
        self.dial.valueChanged.connect(self.sliderMoved)
        self.dial.setWrapping(True)
        self.text=QLabel()
        layout.addWidget(self.dial)
        layout.addWidget(self.text)
        self.isHigher=False

    def sliderMoved(self):
        print("Dial value = %i" % (self.dial.value()))
        self.text.setText(str(self.dial.value()))
        if(self.dial.value()==100 and self.isHigher==False):
            self.higher_range()
            self.isHigher=True
        if(self.dial.value()==100 and self.isHigher==True):
            self.lower_range()
            self.isHigher=False



    def higher_range(self):
        self.dial.setRange(100,200)
        self.dial.setValue(105)

    def lower_range(self):
        self.dial.setRange(0,100)
        self.dial.setValue(95)



        

app = QApplication(sys.argv)
screen = Window()
screen.show()
sys.exit(app.exec_())

`

But this doesn't work, It keeps changing from 95 to 105 and viceversa.


Solution

  • QDial is a pretty peculiar control. While it's still supported, it's poorly implemented, and I believe it's by choice: due to its nature, it's really hard to add more features. I had quite an amount of experience with it, and I know it's not an easy element to deal with.

    One of its issues is that it represents a monodimensional range but, visually and UI speaking, it is a bidimensional object.

    What you're trying to achieve is possible, but consider that an UI element should always display its state in a clear way and have a corresponding proper behavior; that's the only way UI can tell the user the state. Physical dials don't have this issue: you also have a tactile response that tells you when the gear reaches its end.

    From my experience I could tell you that you should avoid it as much as possible: it seems a nice and intuitive widget, but in reality it's very difficult to get a proper result that is actually intuitive to the user. There are some instances for which it makes sense to use it (in my case, representation of a physical knob of an electronic musical instrument). I suggest you to do some research on skeumorphism and UX aspects.

    That said, this is a possible raw implementation. I've overridden some aspects (most importantly, the valueChanged signal, for naming consistency), but for a proper implementation you should do much more work (and testing).

    The trick is to set the range based on the number of "revolutions": if the maximum is 500 and 5 revolutions are chosen, then the dial will have an actual maximum of 100. Then, whenever the value changes, we check whether previous value was below or above the minimum/maximum of the actual range, and change the revolution count accordingly.

    Two important notes:

    • since QDial inherits from QAbstractSlider, it has a range(minimum, maximum + 1), and since the division could have some rest, the "last" revolution will have a different range;
    • I didn't implement the wheel event, as that requires further inspection and choosing the appropriate behavior depending on the "previous" value and revolution;
    class SpecialDial(QDial):
        _cycleValueChange = pyqtSignal(int)
        def __init__(self, minimum=0, maximum=100, cycleCount=2):
            super().__init__()
            assert cycleCount > 1, 'cycles must be 2 or more'
            self.setWrapping(True)
            self.cycle = 0
            self.cycleCount = cycleCount
            self._minimum = minimum
            self._maximum = maximum
            self._normalMaximum = (maximum - minimum) // cycleCount
            self._lastMaximum = self._normalMaximum + (maximum - minimum) % self._normalMaximum
            self._previousValue = super().value()
            self._valueChanged = self.valueChanged
            self.valueChanged = self._cycleValueChange
            self._valueChanged.connect(self.adjustValueChanged)
    
            self.setRange(0, self._normalMaximum)
    
        def value(self):
            return super().value() + self._normalMaximum * self.cycle
    
        def minimum(self):
            return self._minimum
    
        def maximum(self):
            return self._maximum()
    
        def dialMinimum(self):
            return super().minimum()
    
        def dialMaximum(self):
            return super().maximum()
    
        def adjustValueChanged(self, value):
            if value < self._previousValue:
                if (value < self.dialMaximum() * .3 and self._previousValue > self.dialMaximum() * .6 and 
                    self.cycle + 1 < self.cycleCount):
                        self.cycle += 1
                        if self.cycle == self.cycleCount - 1:
                            self.setMaximum(self._lastMaximum)
            elif (value > self.dialMaximum() * .6 and self._previousValue < self.dialMaximum() * .3 and
                self.cycle > 0):
                    self.cycle -= 1
                    if self.cycle == 0:
                        self.setMaximum(self._normalMaximum)
            new = self.value()
            if self._previousValue != new:
                self._previousValue = value
                self.valueChanged.emit(self.value())
    
        def setValue(self, value):
            value = max(self._minimum, min(self._maximum, value))
            if value == self.value():
                return
            block = self.blockSignals(True)
            self.cycle, value = divmod(value, self._normalMaximum)
            if self.dialMaximum() == self._normalMaximum and self.cycle == self.cycleCount - 1:
                self.setMaximum(self._lastMaximum)
            elif self.dialMaximum() == self._lastMaximum and self.cycle < self.cycleCount - 1:
                self.setMaximum(self._normalMaximum)
            super().setValue(value)
            self.blockSignals(block)
            self._previousValue = self.value()
            self.valueChanged.emit(self._previousValue)
    
        def keyPressEvent(self, event):
            key = event.key()
            if key in (Qt.Key_Right, Qt.Key_Up):
                step = self.singleStep()
            elif key in (Qt.Key_Left, Qt.Key_Down):
                step = -self.singleStep()
            elif key == Qt.Key_PageUp:
                step = self.pageStep()
            elif key == Qt.Key_PageDown:
                step = -self.pageStep()
            elif key in (Qt.Key_Home, Qt.Key_End):
                if key == Qt.Key_Home or self.invertedControls():
                    if super().value() > 0:
                        self.cycle = 0
                        block = self.blockSignals(True)
                        super().setValue(0)
                        self.blockSignals(block)
                        self.valueChanged.emit(self.value())
                else:
                    if self.cycle != self.cycleCount - 1:
                        self.setMaximum(self._lastMaximum)
                        self.cycle = self.cycleCount - 1
                    if super().value() != self._lastMaximum:
                        block = self.blockSignals(True)
                        super().setValue(self._lastMaximum)
                        self.blockSignals(block)
                        self.valueChanged.emit(self.value())
                return
            else:
                super().keyPressEvent(event)
                return
            if self.invertedControls():
                step *= -1
    
            current = self.value()
            new = max(self._minimum, min(self._maximum, current + step))
            if current != new:
                super().setValue(super().value() + (new - current))
    
    
    class Window(QWidget):
        def __init__(self):
            QWidget.__init__(self)
            layout = QGridLayout()
            self.setLayout(layout)
            self.dial = SpecialDial()
            self.dial.valueChanged.connect(self.sliderMoved)
            self.text=QLabel()
            layout.addWidget(self.dial)
            layout.addWidget(self.text)
    
        def sliderMoved(self):
            self.text.setText(str(self.dial.value()))
    

    I strongly suggest you to take your time to:

    • consider is this is really what you want, since, as said, this kind of control can be very tricky from the UX perspective;
    • carefully read the code and understand its logics;