0

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

eyllanesc
  • 235,170
  • 19
  • 170
  • 241
deponovo
  • 1,114
  • 7
  • 23
  • There's no simple way to do that. You need to override `paintEvent()`, call the base implementation and provide custom painting, but the problem is that you need to find the precise position of each "tick index", which can only be achieved using QStyle functions: you have to find the groove and handle rectangles, compute the actual groove size, use [`sliderPositionFromValue`](https://doc.qt.io/qt-5/qstyle.html#sliderPositionFromValue) and then paint the ticks and triangles. – musicamante Nov 18 '21 at 10:17
  • Feels like a good start. Going to give it a try. – deponovo Nov 18 '21 at 10:25
  • @musicamante I can already draw text at the desired locations. But I can't draw an image (triangle, for instance) at the same location. I try `painter.drawImage(QPoint(x, y), QImage('image.svg'))`. Is there some limitation to this rationale I am missing? – deponovo Nov 18 '21 at 14:45
  • I don't know why the image is not shown, maybe its contents are not top-left aligned and they are not painted as they go beyond the slider rectangle. Unfortunately, without a [mre] it's impossible to better understand what's going on. – musicamante Nov 18 '21 at 19:57
  • @musicamante posted my try as an answer using `QPixmap`. Tks for your help. – deponovo Nov 19 '21 at 08:36

2 Answers2

1

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

deponovo
  • 1,114
  • 7
  • 23
  • 1
    I tried your code using a QImage (with the png you provided) and it works. That said, there are issues. First of all, you should not initialize the image using the height in the `__init__`, because at that point the widget is not mapped yet and has a default size of 640x480 (or 100x30 if created with a parent), which means that you will not be getting the proper position of the image; then, as explained in the comments, you cannot just use the width of the slider, because the handle span is almost always smaller (depending on the style), so you'll get unreliable results. – musicamante Nov 19 '21 at 14:39
  • I did notice that, but wanted to revert it for future time point. Thanks for already providing input about it. About the resize, also nice to know, because since my slider only changes size in the slider direction, I did not notice the effects. – deponovo Nov 22 '21 at 08:37
1

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.

deponovo
  • 1,114
  • 7
  • 23
musicamante
  • 41,230
  • 6
  • 33
  • 58