QSlider: add "special" tick markers at arbitrary indices

1k Views Asked by At

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).

2

There are 2 best solutions below

0
musicamante On BEST ANSWER

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.

2
deponovo On

Here is a basic implementation (using QPixmap):

class NewSlider(QtWidgets.QSlider):

    indicator_up = None

    def __init__(self, *args):
        # for now, this class is prepared for horizontal sliders only
        super().__init__(*args)
        self._secondary_slider_pos = []
        if self.__class__.indicator_up is None:
            indicator_up = QPixmap(r'path_to_image.png')
            center = self.height() / 2
            if indicator_up.height() > center:
                indicator_up = indicator_up.scaledToHeight(center)
            self.__class__.indicator_up = indicator_up

    def set_secondary_slider_pos(self, other_pos: List[int]):
        self._secondary_slider_pos = other_pos

    def get_px_of_secondary_slider_pos(self):
        return [
            QtWidgets.QStyle.sliderPositionFromValue(self.minimum(), self.maximum(), idx, self.width())
            for idx in self._secondary_slider_pos
        ]

    def paintEvent(self, ev: QtGui.QPaintEvent) -> None:
        super().paintEvent(ev)
        pix_secondary_slider_pos = self.get_px_of_secondary_slider_pos()

        if len(pix_secondary_slider_pos) > 0:
            painter = QtGui.QPainter(self)
            center = self.height() / 2
            for x_pos in pix_secondary_slider_pos:
                painter.drawPixmap(QtCore.QPoint(x_pos, center), self.__class__.indicator_up)

Usage example:

enter image description here

Somehow I could not make it work with painter.drawImage.

The image used was:

enter image description here