Combining QSpinBox with QSlider

I am attempting to create a stand-alone widget (call it QSpinSlider) that combines the functionality of a QSpinBox and QSlider. Here is a basic working example of my QSpinSlider class:

from PyQt6.QtWidgets import QWidget, QHBoxLayout, QSpinBox, QSlider
from PyQt6.QtCore import Qt

class QSpinSlider(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        
        self.layout = QHBoxLayout(self)
        
        self.spin_box = QSpinBox()
        self.spin_box.setKeyboardTracking(False)
        self.slider = QSlider(Qt.Orientation.Horizontal)
        
        self.layout.addWidget(self.spin_box)
        self.layout.addWidget(self.slider)
        
        self.spin_box.valueChanged.connect(self.slider.setValue)
        self.slider.valueChanged.connect(self.spin_box.setValue)

And a sample of the usage:

if __name__ == "__main__":
    import sys
    from PyQt6.QtWidgets import QApplication, QMainWindow
    app = QApplication(sys.argv)
    window = QMainWindow()
    
    spin_slider_widget = QSpinSlider()
    
    window.setCentralWidget(spin_slider_widget)
    window.show()
    
    sys.exit(app.exec())

The problem that I have is that by extending QWidget I am losing all of the functionality of the QSpinBox and QSlider. I would still like to be able to use the standard properties, functions, signals, and slots of both widgets.

Here are just a few examples:

  • QSpinSlider.setValue(12)
    • Sets the value of both the slider and the spin box
  • QSpinSlider.value()
    • Returns the value of the widget
  • QSpinSlider.setOrientation(Qt.Orientation.Horizontal)
    • Sets the orientation of the slider (and the layout of QSpinSlider)
  • QSpinSlider.setKeyboardTracking(False)
    • Sets the keyboard tracking property of the spin box
  • QSpinSlider.setMinimum(3)
    • Sets the minimum value of both the slider and the spin box
  • QSpinSlider.setMinimumSize(QSize(12, 50))
    • Sets the minumum size of the QSpinSlider widget
  • Etc, Etc…

What makes this even more challenging is that the QSpinBox and QSlider classes implement many properties and functions that are the same, and many that are unique to their functionality. I am trying to merge the two lists of properties, fucntions, signals, and slots…

I know that I can recreate all the functions and properties in my custom widget class and pass the changes on to the spin box and slider. But, this doesn’t seem very efficient to me (and it sounds really tedious, error prone, and hard to debug).

I’ve tried directly inheriting QAbstractSlider instead of QWidget but couldn’t get the results I was looking for.

Any ideas of how I can access all the individual properties, functions, signals, and slots of the two widgets inside the custom widget?

Hi @DevMolasses interesting question!

For compound widgets like this I would usually start from QWidget as you’ve done. However, wanting to maintain access to the underlying widgets makes things a little trickier.

One obvious solution would be to just subclass from one or the other widget & then override the paintEvent to draw the additional UI. You can then rely on a single set of methods to modify both the main widget and your custom additions. But, while definitely possible, these are pretty complex widgets to recreate.

The other option would be as you say pass the calls up to the subwidgets. The good news here is that you don’t actually have to recreate them, you can just use Python to route them dependent on where the properties are. The bad news is that property access is via method calls, which makes it tricky. When you do QSpinSlider.setMinimum(3) the setMinimum method is returned and then called, and so your wrapper can only call one method. You can work around that by returning a wrapper which calls both, but …see what you think.

from PyQt6.QtWidgets import QWidget, QHBoxLayout, QSpinBox, QSlider
from PyQt6.QtCore import Qt

class QSpinSlider(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        
        self.layout = QHBoxLayout(self)
        
        self.spin_box = QSpinBox()
        self.spin_box.setKeyboardTracking(False)
        self.slider = QSlider(Qt.Orientation.Horizontal)
        
        self.layout.addWidget(self.spin_box)
        self.layout.addWidget(self.slider)
           
        # Access via the container widget.
        self.setKeyboardTracking(False)
        self.setOrientation(Qt.Orientation.Vertical)
        
        self.spin_box.valueChanged.connect(self.slider.setValue)
        self.slider.valueChanged.connect(self.spin_box.setValue)
         
        # self.valueChanged.connect(print) # Won't work, valueChanged is callable but has attributes.
        # However, you _can_ override that signal on QSpinSlider with a custom signal.
        self.spin_box.valueChanged.connect(self.demo_get_value)
       
        
    def __getattr__(self, name):
        attrs = []
        for obj in [self.spin_box, self.slider]:
            a = getattr(obj, name, None)
            if a is not None:
                attrs.append(a)
        
        if attrs:
            if not all(callable(a) for a in attrs):
                return attrs[0] # Prefer spin box.

            def callfn(*args, **kwargs):
                for fn in attrs[::-1]: # Prefer spin box.
                    result = fn(*args, **kwargs)
                return result
            return callfn
            
        return super().__getattr__(name)
            
    def demo_get_value(self):
        print("Current value:", self.value())
        
        
        
if __name__ == "__main__":
    import sys
    from PyQt6.QtWidgets import QApplication, QMainWindow
    app = QApplication(sys.argv)
    window = QMainWindow()
    
    spin_slider_widget = QSpinSlider()
    
    window.setCentralWidget(spin_slider_widget)
    window.show()
    
    sys.exit(app.exec())          

The magic happens in __getattr__. This is only called if the attribute isn’t present on the current object, so any overrides you write on QSpinSlider will be called first. This method searches through the two sub-widgets looking for matching properties and then depending on whether those attributes are callable either (a) returns the first non-callable value, or (b) returns a wrapper function which calls both of the child widgets functions returning the first value (so the value from the spin box. See for example the demo_get_value method.

Even though this works, I’m not sure I like it.

There is a less intrusive approach, where you just define a custom method which explicitly sets values to both children, for example.

from PyQt6.QtWidgets import QWidget, QHBoxLayout, QSpinBox, QSlider
from PyQt6.QtCore import Qt

class QSpinSlider(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        
        self.layout = QHBoxLayout(self)
        
        self.spin_box = QSpinBox()
        self.spin_box.setKeyboardTracking(False)
        self.slider = QSlider(Qt.Orientation.Horizontal)
        
        self.layout.addWidget(self.spin_box)
        self.layout.addWidget(self.slider)
           
        # Access via the container widget.
        self.setval('setKeyboardTracking', False)
        self.setval('setOrientation', Qt.Orientation.Vertical)
        
        self.spin_box.valueChanged.connect(self.slider.setValue)
        self.slider.valueChanged.connect(self.spin_box.setValue)
        
        
    def setval(self, name, value):
        for obj in [self.spin_box, self.slider]:
            fn = getattr(obj, name, None)
            if fn is not None and callable(fn):
                return fn(value)
            

For retrieving values you would just use the normal approach on the individual subwidgets.

I’d be interested to hear if you come up with anything else.

Thanks for the reply @martin. The __getattr__(self, name) method that you proposed is very interesting to me. In the “solution” that I came up with on my own to keep my project pressing forward was to use __getattr__() to handle all the unique properties and functions (i.e. setTracking() for the slider and setKeyboardTracking() for the spinbox. I then also added methods to QSpinSlider to have duplicates (i.e. setValue() and setMinimum()) But my methods looked a bit different than what you proposed with def setval(self, name, value):

def setValue(self, value):
   self.spin_box.setValue(value)
   self.slider.setValue(value)

def setMinumum(self, value):
   self.spin_box.setMinimum(value)
   self.slider.setMinimum(value)

etc...

This obviously is the tedious part that I am trying to avoid. Using something like your proposed setval() method would save me from typing out all the separate functions, but I would would have to call setval() and pass 'setMinimum' instead of calling setMinimum() directly (at least that is how I am reading the code). The only concern with the __getattr__() method is that it is a bit abstract to understand and would likely be challenging for another engineer to grasp what I am trying to accomplish, but a few descriptive comments should solve that.

I’m going to play around with your ideas. Thank you for your input.

That’s right, I’m not sure it is entirely avoidable. On the first example

  • setting will set to both subwidgets if they both have the same methods
  • getting will get from one widget in preference to the other

…the discomfort mostly comes when both widgets share a set method which take different values or have different purposes. I think in that case you could just specifically implement a function wrapper for that one & make the custom change.

The setval way is just trying to be less magic, but I agree it’s not very pleasant to use.

Did you come up with anything else?