python-3.xpyqt5qt-signalsqtwidgets

Coupling slider and dial widgets in PyQt5. Solving inconsistent results


I am working on a small widget. It has a dial and a slider and both modify the same number, although I want the slider to have a higher changing rate than the dial. Think of it as the dial being the fine tuning. For example, the slider changes in units of 0.1, but the dial changes in 0.01. Additionally, I want the dial to increase or decrease the value of the number as long as I turn it in one direction. So, if I turn 5 complete revolutions the number will increase in 50 units (considering there are 100 steps in the dial 'axis').

I successfully implemented most of this, but there is a problem and I do not know how to solve it.

The dial works as I want it to. It turns without bounds. It also changes with a slower rate than the slider. The problem is that this rate only works if I turn clockwise (increasing the number).

The dial increases in 0.01 and the slider in 0.1. I can go from 1 to 1.1 (1.01, 1.02 ...) by actioning the dial and the slider will only move once, however if I bo back with the dial from 1.1, it will change directly to 1 and then 0.9 (skipping the numbers in between). In other words the rate of the dial will be different as long as I turn clockwise.

This is the code

import sys
from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QVBoxLayout, QDial, QSlider
from PyQt5.QtCore import Qt


class DialWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self._label_number = 0.000
        self.label = QLabel(str(self._label_number), self)
        self.label.setAlignment(Qt.AlignCenter)

        self.dial = QDial(self)
        self.dial.setNotchesVisible(True)
        self.dial.setWrapping(True)
        self.dial.setMinimum(0)
        self.dial.setMaximum(100)  # 3600 for full revolutions (0-3600 = 0-360 degrees)

        self.dial.valueChanged.connect(self.dial_value_changed)

        self.slider = QSlider(Qt.Horizontal, self)
        self.slider.setMinimum(0)
        self.slider.setMaximum(80)
        self.slider.valueChanged.connect(self.slider_value_changed)
        self._slider_value = self.slider.value()

        layout = QVBoxLayout()
        layout.addWidget(self.label)
        layout.addWidget(self.dial)
        layout.addWidget(self.slider)

        self.dial_value = self.dial.value()
        self.setLayout(layout)

    @property
    def label_number(self):
        return self._label_number

    @label_number.setter
    def label_number(self, number):
        print(f'    Inside label_number.setter')
        if number < 0 or number > 8:
            pass
        else:
            print(f'    Changing label_number {self._label_number} --> {number}')
            self._label_number = number
            #print(f'    Changing label text {self.label.text()} --> {str(round(number, 4))}')
            self.label.setText(str(round(number, 4)))
            print(f'    Changing slider value: {self.slider.value()} --> {number * 10.000} ')
            print(f'self.slider.setValue({number * 10.000})')
            self.slider_number = number * 10.000
            self.slider.setValue(self.slider_number)
        print(f'    Exiting label_number.setter')

    def dial_value_changed(self):
        print(f'Inside dial_value_changed')
        # Get the current value of the dial
        dial_delta = self.dial.value() - self.dial_value
        print(f'Delta number of the dial: {dial_delta}')
        if dial_delta == 1:
            print(f'dn = {1/100}')
            new_number = self.label_number + 1/100
            self.label_number = new_number
        elif dial_delta == -1:
            print(f'dn = {1/100}')
            new_number = self.label_number - 1/100
            self.label_number = new_number
        elif dial_delta == -100:
            print(f'dn = {1/100}')
            new_number = self.label_number + 1/100
            self.label_number = new_number
        elif dial_delta == 99:
            print(f'dn = {1/100}')
            new_number = self.label_number - 1/100
            self.label_number = new_number
        #print(f'Setting self.dial_value to {self.dial.value()}')
        self.dial_value = self.dial.value()
        print(f'Exiting dial_value_changed')
        print()

    def slider_value_changed(self, value):
        print(f'        Inside slider_value_changed')
        print(f'        {self.slider.value()}')
        print(f'        Value sent {value}')
        #print(f'        Changing self.label_number {self.label_number} --> {value / 10.0}')
        self.label_number = value / 10.000
        
        print(f'        Exiting slider_value_changed')


if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = DialWidget()
    window.setWindowTitle('Dial Widget')
    window.show()
    sys.exit(app.exec_())

I added prints to better follow the code in the terminal. I found that the problem stems from the line self.slider.valueChanged.connect(self.slider_value_changed). The signal only sends integers (because the position of the slider works in integers).

Then I tried solving this by adding an extra attribute that contains the position or number of the slider, but as a float. When I try to do this the dial works fine in both directions, however the slider will not move if I press it.

class DialWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self._label_number = 0.000
        self.label = QLabel(str(self._label_number), self)
        self.label.setAlignment(Qt.AlignCenter)

        self.dial = QDial(self)
        self.dial.setNotchesVisible(True)
        self.dial.setWrapping(True)
        self.dial.setMinimum(0)
        self.dial.setMaximum(100)  # 3600 for full revolutions (0-3600 = 0-360 degrees)

        self.dial.valueChanged.connect(self.dial_value_changed)

        self.slider = QSlider(Qt.Horizontal, self)
        self.slider.setMinimum(0)
        self.slider.setMaximum(80)
        self.slider.valueChanged.connect(self.slider_value_changed)
        self._slider_number = self.slider.value()

        layout = QVBoxLayout()
        layout.addWidget(self.label)
        layout.addWidget(self.dial)
        layout.addWidget(self.slider)

        self.dial_value = self.dial.value()
        self.setLayout(layout)

    @property
    def label_number(self):
        return self._label_number

    @label_number.setter
    def label_number(self, number):
        print(f'    Inside label_number.setter')
        if number < 0 or number > 8:
            pass
        else:
            print(f'    Changing label_number {self._label_number} --> {number}')
            self._label_number = number
            #print(f'    Changing label text {self.label.text()} --> {str(round(number, 4))}')
            self.label.setText(str(round(number, 4)))
            print(f'    Changing slider value: {self.slider.value()} --> {number * 10.000} ')
            print(f'self.slider.setValue({number * 10.000})')
            self.slider_number = number * 10.000
            self.slider.setValue(self.slider_number)
            #self.slider.setValue(number * 10.000)
        print(f'    Exiting label_number.setter')

    @property
    def slider_number(self):
        return self._slider_number

    @slider_number.setter
    def slider_number(self, number):
        self._slider_number = number

    def dial_value_changed(self):
        print(f'Inside dial_value_changed')
        # Get the current value of the dial
        dial_delta = self.dial.value() - self.dial_value
        print(f'Delta number of the dial: {dial_delta}')
        if dial_delta == 1:
            print(f'dn = {1/100}')
            new_number = self.label_number + 1/100
            self.label_number = new_number
        elif dial_delta == -1:
            print(f'dn = {1/100}')
            new_number = self.label_number - 1/100
            self.label_number = new_number
        elif dial_delta == -100:
            print(f'dn = {1/100}')
            new_number = self.label_number + 1/100
            self.label_number = new_number
        elif dial_delta == 99:
            print(f'dn = {1/100}')
            new_number = self.label_number - 1/100
            self.label_number = new_number
        #print(f'Setting self.dial_value to {self.dial.value()}')
        self.dial_value = self.dial.value()
        print(f'Exiting dial_value_changed')
        print()

    def slider_value_changed(self, value):
        print(f'        Inside slider_value_changed')
        print(f'        {self.slider.value()}')
        print(f'        Value sent {value}')
        #print(f'        Changing self.label_number {self.label_number} --> {value / 10.0}')
        value = self.slider_number
        self.label_number = value / 10.000
        
        #self.slider.setValue(self.slider_value)
        print(f'        Exiting slider_value_changed')

This is what I tried. I added the slider_number as a property. I have been trying to solve this the whole day long. I would much appreciate another brain to help me.

EDIT (Possible Solution):

I managed to solve the issue I had by disconnecting slider.valueChanged from slider_value_changed when the dial was actioned (dial_value_changed) and connecting it again once I exited the function. This worked because the problem arises when slider.valueChanged is called twice, one when if the number changes in 0.1 units by moving the dial, calling set_number() which in turn calls slider.valueChanged again.

    def dial_value_changed(self):
        dial_delta = self.dial.value() - self.dial_value
        self.slider.valueChanged.disconnect()
        if dial_delta == 1:
            new_number = self.number + 1/10000
            self.set_number(new_number)
        elif dial_delta == -1:
            new_number = self.number - 1/10000
            self.set_number(new_number)
        elif dial_delta == -100:
            new_number = self.number + 1/10000
            self.set_number(new_number)
        elif dial_delta == 99:
            new_number = self.number - 1/10000
            self.set_number(new_number)
        self.dial_value = self.dial.value()
        self.slider.valueChanged.connect(self.slider_value_changed)

Solution

  • QDial has important issues related to UX aspects, most importantly related to the fact that, when using the mouse, it maps a 2 dimensional vector to a mono dimensional (linear) space.

    This issue is even more noticeable (and problematic) when using wrapping, because there is no direct way to know if the mouse "movement" was intended for rotating clockwise or counterclockwise. Remember that mouse movement events are not continuous: they are just an apparent movement, but in fact the mouse "jumps" to points giving the illusion of movement.

    This consequentially means that there is no immediate way to know if the slider value should remain the same, or it should eventually add/subtract when the rotation passes through the edge between the minimum and maximum values of the dial.

    We cannot just rely on the difference between a previous value and the current one; a smaller new value might not always mean that the dial has been moved counterclockwise: the assumption is valid if the values were, for instance, 50 and 40, but what if you just moved the dial from 99 to 2?

    A possible solution is to subclass QDial with a custom signal, and emit it based on the previous value and the triggered action.

    This is possible thanks to the actionTriggered signal:

    When the signal is emitted, the sliderPosition has been adjusted according to the action, but the value has not yet been propagated (meaning the valueChanged() signal was not yet emitted), and the visual display has not been updated.

    The trick is to connect that signal to a function that keeps track of the current slider position (before the value is actually changed) and the action that triggers it, then connect to the standard valueChanged signal and emit the custom signal using a value that can be altered depending on the previous value and related action.

    The new value will be incremented or subtracted by the whole dial range whenever the edge is "crossed".

    Some of the actions let know the direction, so it's easy to know if the result: if the previous value was 100, the new is 0 and the action was SliderSingleStepAdd, then we know that we should increase the slider by 1, etc.

    The mouse movement requires to get the direction just by the previous value, so we have to assume that by getting the difference between the values, but using the modulo of the full range. Then we have to make an assumption: if the difference is smaller than half the range, it means that the angle was clockwise.

    diff = (newValue - oldValue) % 100
    if diff < 50: # clockwise
        if newValue < oldValue:
            # increment the slider by 1
            newValue += 100
    elif newValue > oldValue: # counterclockwise is implied
        # decrement by 1
        newValue -= 100
    # emit signal with newValue
    

    The above will give the following results:

    Note that, due to the issues mentioned at the beginning, this may still create unexpected or unreliable results when the "rotation" does not "cross" the edge: for instance, if the current dial value is 25 and is clicked just after 75, that could actually result in a counterclockwise rotation.

    Then, in the main widget we connect the custom signal: if the value is less than 0, then we know that we have to subtract 1 from the slider value, if it's greater or equal than 100, then add 1.

    Note that, to avoid recursion and provide proper range boundaries, we should also prevent the valueChanged signal of the slider to propagate, so that we can eventually adjust the dial to 0 whenever the final value has reached the minimum or maximum.

    Finally, since wrapping can result in a very narrow (and unreliable) mouse/keyboard range between the maximum and the minimum, it's better to prevent the dial to actually reach the maximum: whenever the position equals to 100, we adjust it depending on the previous position or action (as explained above), so if it was greater than 50, it will become 0, otherwise it will be 99. This has be done in the function connected to actionTriggered, because at that point the value has not been adjusted.

    class Dial(QDial):
        AddActions = (
            QAbstractSlider.SliderSingleStepAdd, 
            QAbstractSlider.SliderPageStepAdd, 
        )
        SubActions = (
            QAbstractSlider.SliderSingleStepSub, 
            QAbstractSlider.SliderPageStepSub, 
        )
        lastAction = QAbstractSlider.SliderNoAction
    
        customValueChanged = pyqtSignal(int)
    
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.setNotchesVisible(True)
            self.setWrapping(True)
            self.setRange(0, 100)
            self.lastValue = self.value()
    
            self.actionTriggered.connect(self.checkActionTriggered)
            self.valueChanged.connect(self.emitValueChanged)
    
        def checkActionTriggered(self, action):
            self.lastAction = action
            self.lastValue = self.value()
            # prevent the dial to get to 100
            if self.sliderPosition() == 100:
                if action in self.AddActions:
                    self.setSliderPosition(0)
                elif action in self.SubActions:
                    self.setSliderPosition(99)
                elif action == self.SliderMove:
                    if self.lastValue > 50:
                        self.setSliderPosition(0)
                    else:
                        self.setSliderPosition(99)
    
        def emitValueChanged(self, value):
            if self.lastAction in self.AddActions:
                if value < self.lastValue:
                    value += 100
            elif self.lastAction in self.SubActions:
                if value > self.lastValue:
                    value -= 100
            elif self.lastAction == self.SliderMove:
                if self.lastValue == self.minimum() and value > 50:
                    value -= 100
                else:
                    diff = (value - self.lastValue) % 100
                    if diff <= 50:
                        if value < self.lastValue:
                            value += 100
                    elif value > self.lastValue:
                        value -= 100
            self.customValueChanged.emit(value)
    
    
    class DialWidget(QWidget):
        def __init__(self, minimum=0, maximum=80):
            super().__init__()
            self.label = QLabel()
            self.label.setAlignment(Qt.AlignCenter)
    
            self.dial = Dial()
    
            self.slider = QSlider(Qt.Horizontal)
            self.slider.setMinimum(minimum)
            self.slider.setMaximum(maximum)
    
            layout = QVBoxLayout(self)
            layout.addWidget(self.label)
            layout.addWidget(self.dial)
            layout.addWidget(self.slider)
    
            self.dial.customValueChanged.connect(self.dial_value_changed)
            self.slider.valueChanged.connect(self.slider_value_changed)
    
            self.updateLabel()
    
        @property
        def value(self):
            return self.slider.value() + self.dial.value() * .01
    
        @value.setter
        def value(self, value):
            value = max(self.slider.minimum(), min(value, self.slider.maximum()))
            if self.value != value:
                with QSignalBlocker(self.slider):
                    intVal, decimal = divmod(value, 1.)
                    self.slider.setValue(int(intVal))
                    self.dial.setValue(int(decimal * 100))
                self.updateLabel()
    
        def dial_value_changed(self, value):
            if value < 0:
                if self.slider.value() > self.slider.minimum():
                    with QSignalBlocker(self.slider):
                        self.slider.setValue(self.slider.value() - 1)
                else:
                    self.dial.setValue(0)
            elif value >= 100:
                with QSignalBlocker(self.slider):
                    self.slider.setValue(self.slider.value() + 1)
                if self.slider.value() == self.slider.maximum():
                    self.dial.setValue(0)
            elif self.slider.value() == self.slider.maximum():
                self.dial.setValue(0)
    
            self.updateLabel()
    
        def slider_value_changed(self, value):
            if value == self.slider.minimum() or value == self.slider.maximum():
                self.dial.setValue(0)
    
            self.updateLabel()
    
        def updateLabel(self):
            self.label.setText(str(round(self.value, 2)))