pythonpyqtpyqt5qslider

QSlider: add "special" tick markers at arbitrary indices


I wonder if it is possible and what would be the simplest way to add special ticks at arbitrary indices of a QSlider. Any info or documentation in this direction would be highly appreciated.

To shed a bit of light into what I would like to achieve, here is an application case: I have a QSlider with a given amount of ticks, which I can control using the functions pasted in the figure (screenshot from the documentation):

enter image description here

How could I add the little black triangles, or any other "special" tick, at given tick indices? Also, I will want to redraw them at other arbitrary positions, meaning they won't remain at static positions.

(I started with this SO answer, but from there I could not progress towards my goal).


Solution

  • The sliderPositionFromValue cannot be used only with the width (or the height) of the slider itself, because every style draws the slider in different ways, and the space to be considered for the handle movement is usually less than the actual size of the widget.

    The actual space used by the handle movement is considered for the whole extent (the pixel metric PM_SliderSpaceAvailable), which includes the size of the handle itself.

    So, you need to consider that space when computing the position of the indicators, subtract half of the handle size and also subtract half of the indicator size (otherwise the top of the triangle won't coincide with the correct position).

    This is a corrected version of your answer:

    class NewSlider(QtWidgets.QSlider):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self._secondary_slider_pos = []
    
        @property
        def indicator(self):
            try:
                return self._indicator
            except AttributeError:
                image = QtGui.QPixmap('triangle.png')
                if self.orientation() == QtCore.Qt.Horizontal:
                    height = self.height() / 2
                    if image.height() > height:
                        image = image.scaledToHeight(
                            height, QtCore.Qt.SmoothTransformation)
                else:
                    width = self.width() / 2
                    if image.height() > width:
                        image = image.scaledToHeight(
                            width, QtCore.Qt.SmoothTransformation)
                    rotated = QtGui.QPixmap(image.height(), image.width())
                    rotated.fill(QtCore.Qt.transparent)
                    qp = QtGui.QPainter(rotated)
                    qp.rotate(-90)
                    qp.drawPixmap(-image.width(), 0, image)
                    qp.end()
                    image = rotated
                self._indicator = image
                return self._indicator
    
        def set_secondary_slider_pos(self, other_pos):
            self._secondary_slider_pos = other_pos
            self.update()
    
        def paintEvent(self, event):
            super().paintEvent(event)
            if not self._secondary_slider_pos:
                return
            style = self.style()
            opt = QtWidgets.QStyleOptionSlider()
            self.initStyleOption(opt)
    
            # the available space for the handle
            available = style.pixelMetric(style.PM_SliderSpaceAvailable, opt, self)
            # the extent of the slider handle
            sLen = style.pixelMetric(style.PM_SliderLength, opt, self) / 2
    
            x = self.width() / 2
            y = self.height() / 2
            horizontal = self.orientation() == QtCore.Qt.Horizontal
            if horizontal:
                delta = self.indicator.width() / 2
            else:
                delta = self.indicator.height() / 2
    
            minimum = self.minimum()
            maximum = self.maximum()
            qp = QtGui.QPainter(self)
            # just in case
            qp.translate(opt.rect.x(), opt.rect.y())
            for value in self._secondary_slider_pos:
                # get the actual position based on the available space and add half 
                # the slider handle size for the correct position
                pos = style.sliderPositionFromValue(
                    minimum, maximum, value, available, opt.upsideDown) + sLen
                # draw the image by removing half of its size in order to center it
                if horizontal:
                    qp.drawPixmap(pos - delta, y, self.indicator)
                else:
                    qp.drawPixmap(x, pos - delta, self.indicator)
    
        def resizeEvent(self, event):
            # delete the "cached" image so that it gets generated when necessary
            if (self.orientation() == QtCore.Qt.Horizontal and 
                event.size().height() != event.oldSize().height() or
                self.orientation() == QtCore.Qt.Vertical and
                event.size().width() != event.oldSize().width()):
                    try:
                        del self._indicator
                    except AttributeError:
                        pass
    

    Note that, in any case, this approach has its limits: the triangle will always be shown above the handle, which is not a very good thing from the UX perspective. A proper solution would require a partial rewriting of the paintEvent() with multiple calls to drawComplexControl in order to paint all elements in the proper order: the groove and tickmarks, then the indicators and, finally, the handle; it can be done, but you need to add more aspects (including considering the currently active control for visual consistency with the current style).
    I suggest you to study the sources of QSlider in order to understand how to do it.