pythonpyqt5qslider

Change the stylesheet from a customized dual-handle QSlider


I found this customized dual-handle slider, I'd like to use it in my PyQt5 application, changing the styleSheet to match to my app style.

from PyQt5 import QtCore, QtGui, QtWidgets

class RangeSlider(QtWidgets.QSlider):
   sliderMoved = QtCore.pyqtSignal(int, int)

    """ A slider for ranges.

        This class provides a dual-slider for ranges, where there is a defined
        maximum and minimum, as is a normal slider, but instead of having a
        single slider value, there are 2 slider values.

        This class emits the same signals as the QSlider base class, with the 
        exception of valueChanged
    """

    def __init__(self, *args):
        super(RangeSlider, self).__init__(*args)

        self._low = self.minimum()
        self._high = self.maximum()

        self.pressed_control = QtWidgets.QStyle.SC_None
        self.tick_interval = 0
        self.tick_position = QtWidgets.QSlider.NoTicks
        self.hover_control = QtWidgets.QStyle.SC_None
        self.click_offset = 0

        # 0 for the low, 1 for the high, -1 for both
        self.active_slider = 0

    def low(self):
        return self._low

    def setLow(self, low: int):
        self._low = low
        self.update()

    def high(self):
        return self._high

    def setHigh(self, high):
        self._high = high
        self.update()

    def paintEvent(self, event):

        painter = QtGui.QPainter(self)
        style = QtWidgets.QApplication.style()

        # draw groove
        opt = QtWidgets.QStyleOptionSlider()
        self.initStyleOption(opt)
        opt.siderValue = 0
        opt.sliderPosition = 0
        opt.subControls = QtWidgets.QStyle.SC_SliderGroove
        if self.tickPosition() != self.NoTicks:
            opt.subControls |= QtWidgets.QStyle.SC_SliderTickmarks
        style.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt, painter, self)
        groove = style.subControlRect(QtWidgets.QStyle.CC_Slider, opt, QtWidgets.QStyle.SC_SliderGroove, self)

        self.initStyleOption(opt)
        opt.subControls = QtWidgets.QStyle.SC_SliderGroove
        opt.siderValue = 0
        opt.sliderPosition = self._low
        low_rect = style.subControlRect(QtWidgets.QStyle.CC_Slider, opt, QtWidgets.QStyle.SC_SliderHandle, self)
        opt.sliderPosition = self._high
        high_rect = style.subControlRect(QtWidgets.QStyle.CC_Slider, opt, QtWidgets.QStyle.SC_SliderHandle, self)

        low_pos = self.__pick(low_rect.center())
        high_pos = self.__pick(high_rect.center())

        min_pos = min(low_pos, high_pos)
        max_pos = max(low_pos, high_pos)

        c = QtCore.QRect(low_rect.center(), high_rect.center()).center()
        if opt.orientation == QtCore.Qt.Horizontal:
            span_rect = QtCore.QRect(QtCore.QPoint(min_pos, c.y() - 2), QtCore.QPoint(max_pos, c.y() + 1))
        else:
            span_rect = QtCore.QRect(QtCore.QPoint(c.x() - 2, min_pos), QtCore.QPoint(c.x() + 1, max_pos))

        if opt.orientation == QtCore.Qt.Horizontal:
            groove.adjust(0, 0, -1, 0)
        else:
            groove.adjust(0, 0, 0, -1)

        if True:  # self.isEnabled():
            highlight = self.palette().color(QtGui.QPalette.Highlight)
            painter.setBrush(QtGui.QBrush(highlight))
            painter.setPen(QtGui.QPen(highlight, 0))
            '''
            if opt.orientation == QtCore.Qt.Horizontal:
                self.setupPainter(painter, opt.orientation, groove.center().x(), groove.top(), groove.center().x(), groove.bottom())
            else:
                self.setupPainter(painter, opt.orientation, groove.left(), groove.center().y(), groove.right(), groove.center().y())
            '''
            painter.drawRect(span_rect.intersected(groove))

        for i, value in enumerate([self._low, self._high]):
            opt = QtWidgets.QStyleOptionSlider()
            self.initStyleOption(opt)

            # Only draw the groove for the first slider, so it doesn't get drawn
            # on top of the existing ones every time
            if i == 0:
                opt.subControls = QtWidgets.QStyle.SC_SliderHandle  # | QtWidgets.QStyle.SC_SliderGroove
            else:
                opt.subControls = QtWidgets.QStyle.SC_SliderHandle

            if self.tickPosition() != self.NoTicks:
                opt.subControls |= QtWidgets.QStyle.SC_SliderTickmarks

            if self.pressed_control:
                opt.activeSubControls = self.pressed_control
            else:
                opt.activeSubControls = self.hover_control

            opt.sliderPosition = value
            opt.sliderValue = value
            style.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt, painter, self)

    def mousePressEvent(self, event):
        event.accept()

        style = QtWidgets.QApplication.style()
        button = event.button()

        # In a normal slider control, when the user clicks on a point in the
        # slider's total range, but not on the slider part of the control the
        # control would jump the slider value to where the user clicked.
        # For this control, clicks which are not direct hits will slide both
        # slider parts

        if button:
            opt = QtWidgets.QStyleOptionSlider()
            self.initStyleOption(opt)

            self.active_slider = -1

            for i, value in enumerate([self._low, self._high]):
                opt.sliderPosition = value
                hit = style.hitTestComplexControl(style.CC_Slider, opt, event.pos(), self)
                if hit == style.SC_SliderHandle:
                    self.active_slider = i
                    self.pressed_control = hit

                    self.triggerAction(self.SliderMove)
                    self.setRepeatAction(self.SliderNoAction)
                    self.setSliderDown(True)
                    break

            if self.active_slider < 0:
                self.pressed_control = QtWidgets.QStyle.SC_SliderHandle
                self.click_offset = self.__pixelPosToRangeValue(self.__pick(event.pos()))
                self.triggerAction(self.SliderMove)
                self.setRepeatAction(self.SliderNoAction)
        else:
            event.ignore()

    def mouseMoveEvent(self, event):
        if self.pressed_control != QtWidgets.QStyle.SC_SliderHandle:
            event.ignore()
            return

        event.accept()
        new_pos = self.__pixelPosToRangeValue(self.__pick(event.pos()))
        opt = QtWidgets.QStyleOptionSlider()
        self.initStyleOption(opt)

        if self.active_slider < 0:
            offset = new_pos - self.click_offset
            self._high += offset
            self._low += offset
            if self._low < self.minimum():
                diff = self.minimum() - self._low
                self._low += diff
                self._high += diff
            if self._high > self.maximum():
                diff = self.maximum() - self._high
                self._low += diff
                self._high += diff
        elif self.active_slider == 0:
            if new_pos >= self._high:
                new_pos = self._high - 1
            self._low = new_pos
        else:
            if new_pos <= self._low:
                new_pos = self._low + 1
            self._high = new_pos

        self.click_offset = new_pos

        self.update()

        self.sliderMoved.emit(self._low, self._high)

    def __pick(self, pt):
        if self.orientation() == QtCore.Qt.Horizontal:
            return pt.x()
        else:
            return pt.y()

    def __pixelPosToRangeValue(self, pos):
        opt = QtWidgets.QStyleOptionSlider()
        self.initStyleOption(opt)
        style = QtWidgets.QApplication.style()

        gr = style.subControlRect(style.CC_Slider, opt, style.SC_SliderGroove, self)
        sr = style.subControlRect(style.CC_Slider, opt, style.SC_SliderHandle, self)

        if self.orientation() == QtCore.Qt.Horizontal:
            slider_length = sr.width()
            slider_min = gr.x()
            slider_max = gr.right() - slider_length + 1
        else:
            slider_length = sr.height()
            slider_min = gr.y()
            slider_max = gr.bottom() - slider_length + 1

        return style.sliderValueFromPosition(self.minimum(), self.maximum(),
                                         pos - slider_min, slider_max - slider_min,
                                         opt.upsideDown)

import sys, os
from PyQt5 import QtCore, QtGui, QtWidgets

def echo(low_value, high_value):
    print(low_value, high_value)

def main(argv):
    app = QtWidgets.QApplication(sys.argv)

    slider = RangeSlider(QtCore.Qt.Horizontal)
    slider.setMinimumHeight(30)
    slider.setMinimum(0)
    slider.setMaximum(255)
    slider.setLow(15)
    slider.setHigh(35)
    slider.setTickPosition(QtWidgets.QSlider.NoTicks)
    slider.sliderMoved.connect(echo)
    slider.setStyleSheet("QSlider::groove:horizontal {\n"
                         "  border-radius: 5px;\n"
                         "  height: 10px;\n"
                         "  margin: 0px;\n"
                         "  background-color: rgb(52, 59, 72);}\n"
                         "QSlider::groove:horizontal:hover {\n"
                         "  background-color: rgb(55, 62, 76);\n}\n"
                         "QSlider::handle:horizontal {\n"
                         "  background-color:#ff5555;\n"
                         "  border: none;\n"
                         "  height: 10px;\n"
                         "  width: 10px;\n"
                         "  margin: 0px;\n  "
                         "  border-radius: 5px;}\n"
                         "QSlider::handle:horizontal:hover {\n"
                         "  background-color: rgb(195, 155, 255);}\n"
                         "QSlider::handle:horizontal:pressed {\n"
                         "  background-color: rgb(255, 121, 198);}\n"
                         "QSlider::groove:vertical {\n"
                         "  border-radius: 5px;\n"
                         "  width: 10px;\n"
                         "  margin: 0px;\n"
                         "  background-color: rgb(52, 59, 72);}\n"
                         "QSlider::groove:vertical:hover {\n"
                         "  background-color: rgb(55, 62, 76);}\n"
                         "QSlider::handle:vertical {\n"
                         "  background-color:#ff5555;\n"
                         "  border: none;\n"
                         "  height: 10px;\b"
                         "  width: 10px;\n"
                         "  margin: 0px;\n"
                         "  border-radius: 5px;}\n"
                         "QSlider::handle:vertical:hover {\n"
                         "  background-color: rgb(195, 155, 255);}\n"
                         "QSlider::handle:vertical:pressed {\n"
                         "  background-color: rgb(255, 121, 198);}")
    slider.show()
    slider.raise_()
    app.exec_()

if __name__ == "__main__":
    main(sys.argv)

As you can see, I've tried to change the styleSheet of the slider using slider.setStyleSheet(...) command, passing the style I'd like to apply. But it hasn't work.

I think, to achieve the results I want, I have to implement a setStyleSheet from scratch, changing the slider style after creating it.

Am I right, or is there a easy way to do it?


Solution

  • tl;dr

    Get the style from the widget, not from the application:

        def paintEvent(self, event):
            ...
            style = self.style()
            ...
    

    Note that this has to be done for all style references used in other functions, including mouse handlers.

    The wrong QStyle instance

    The problem is that the painting of the slider relies on the style of the application, but in the code the stylesheet is applied to the widget.

    When using setStyleSheet(), Qt uses an internal QStyle subclass (QStyleSheetStyle) which is set based on what object setStyleSheet() was called upon, but when a style sheet is set on a widget, only the style of that widget is changed (eventually propagating to its children if the stylesheet syntax requires it).

    The following line of paintEvent() uses the wrong style:

        style = QtWidgets.QApplication.style()
    

    The application style has no knowledge of the style sheet set on the widget, so it behaves like there is no style sheet set at all. Add the following line near the end and you'll see that those styles are not the same:

    print(app.style() is slider.style())
    

    In fact, if you change the setStyleSheet() line to the following, it will work as expected:

    app.setStyleSheet("QSlider::groove:horizontal {\n"
        ...
    )
    

    In reality, it is the style reference used in the painting that is wrong, so the important change to do is the following:

        def paintEvent(self, event):
    
            painter = QtGui.QPainter(self)
            style = self.style() # <-- always use the widget style!
    
            ...
    

    That reference change is important, and it's what calls to the QStyle functions rely upon.

    If you look at the code in paintEvent(), you'll see that there are a few calls to the style object functions (drawComplexControl, subControlRect, etc.) that also have self as final argument. QStyle uses the widget to know how to behave depending on the widget, but still based on the style rules.

    If the used style is that of the widget (aka, the QStyleSheetStyle based on its style sheet), it knows how to properly paint the custom slider, since the style has its rules set from the style sheet syntax. But if you use the application style (which has no stylesheet set), it knows nothing about that widget's stylesheet, so it will just paint it using the default behavior.

    Note that the above is also important for any QStyle function that relies on style sheet rules: for example, for mouse handlers, otherwise Qt will use wrong coordinates (based on the wrong style) to get the control positions like those returned by subControlRect().

    Style sheet scope: application or individual widgets?

    Still, it is correct (and normally recommended) to set an application wide stylesheet: it's simpler for code maintenance, and can also partially improve memory performance, since setting a global stylesheet uses a unique inherited QStyleSheetStyle, while setting individual style sheets to different widgets creates separate instances of that style for each one of those widgets (even if their style sheets are identical!).

    You may still not want to change the appearance of any slider, though: maybe you want to keep showing normal sliders with the default appearance.
    In that case you should consider using proper selector types; for situations like these, a type selector is probably the best choice, since it will be applied to all instances of the custom slider. Any reference to QSlider in the stylesheet should then be changed to the class name of the custom slider:

    app.setStyleSheet("RangeSlider::groove:horizontal {\n"
        ... # etc.
    )
    

    Note that the above doesn't change the fact that the widget must always use its own style, even if an application style sheet was set. For example, you may need to set a specialized stylesheet for a single instance of your slider, using slider.setStyleSheet() for simplicity: if you still use the application style, you'll end up with the same issue. So, always use the widget style.

    Further issues

    Note that your style sheet has an invalid character near the end: there is a \b (which is the ASCII Backspace character) instead of a \n (line break):

             "QSlider::handle:vertical {\n"
             "  background-color:#ff5555;\n"
             "  border: none;\n"
             "  height: 10px;\b"
                             ^^^ change to \n
    

    This didn't actually affect your example because the invalid character was after all definitions for horizontal sliders, but it would have affected any vertical sliders because it would have made the CSS invalid from that point on. Similarly to web browsers, Qt considers the CSS syntax (and what it does) until an error occurs, but, from that point on, any further line is ignored.

    Your IDE probably didn't show it, but the error exists and is visible when running the original code from a terminal or prompt, so always remember to test your codes outside the IDE:

    >>> StdErr: Could not parse application stylesheet

    Also note that, for better code readability, you should avoid using that string syntax (you probably got it from a pyuic generated file). In fact, the CSS syntax (on which QSS are based) can avoid line breaks and basically ignores any space between definitions (the semicolon is the only important separator between properties), similarly to real browsers.
    You get that syntax because that's how it's taken from the UI file (spaces and line breaks remain consistent in the CSS editors), but for hand-written QSS it's just annoying and quite unreadable.

    If you want, you can keep the multi-line strings and remove any new line characters, but an easier and more readable approach is to use triple quotes and write the QSS in a single block, without distracting characters:

    app.setStyleSheet('''
        RangeSlider::groove:horizontal {
            border-radius: 5px;
            height: 10px;
            margin: 0px;
            background-color: rgb(52, 59, 72);
        }
        RangeSlider::groove:horizontal:hover {
            background-color: rgb(55, 62, 76);
        }
        ... etc.
    ''')