Mar 10, 2023
This post is not about the Heat Map widget. It's about the new double-handled slider at the top of the heatmap. Not about what it does, but about how it does it. The post should be interesting to readers with some knowledge of Qt. Others, who won't understand it, are expected to be impressed by the complexity of implementing something as simple as adding another handle to a slider. On the other hand (wait for the pun!), this complexity is not so surprising: attaching another, fully functional hand to a human would be quite a feat, too.
Why do we need it?
That is, why we need another handle on slider, not another hand. (Though an extra hand would be useful, too - how cool would it be to take a sip of tea while typing?)
A user posted an issue, which he called "Not a problem. More a frustration". Some widgets from the Text add-on allow the user to specify a range of values, for instance for filtering, which they do with a pair of spin boxes (those controls where you can enter a number manually or change it by pressing little up and down button on their right) or a pair of sliders. He suggested using a single slider with two handles. He noted that Orange currently doesn't have such a control in its repertoire.
With a bit of exaggeration, I use such sliders almost daily to specify the price range when ordering bicycle parts, tools and clothes. While I'm no stranger to such sliders, I don't particularly like them. Entering numbers manually is much easier. But then ... they could be useful in cases where numbers are not intepretable, so sliding a handle is more natural than guessing random numbers. A good example is setting scales for color palettes: the user has no idea which particular number will make the image look good.
Drawing native-looking widgets
Problem: Qt doesn't have such widget.
Qt is great - consistent, fast, powerful, and it has native look on all platforms. But if you want to make any small change to some element, its painting falls back to a Qt-based implementation which looks ... not only out of place but, well, ugly. See what becomes of a push button if you would, for whatever reason, want to increase its font size.
So, can we create a double-handled slider that looks as it should? Not with some ugly square-ish handles?
OP provided us with a link to implementation of such a slider. Our version is different, but based on the same trick.
Widget is (re)painted upon receiving a
paintEvent. It must construct a painter, initialize it with proper options and paint whatever needs to be painted.
QLineEdit ... and many other widgets must look different on different platforms. The actual painting is thus not coded within widgets, but relegated to
QStyle is however an class: the actual
QStyle in use by the application is one of
QStyle's subclasses for different platforms and looks.
QPushButton.paintEvent just calls
drawControl(QStyle.CE_PushButton, option, painter), where
CE_PushButton is a constant giving the control to paint, option is an instance of
QStyleOptionButton with the necessary data (the icon, text and button's features) and painter is an ordinary
QPainter. (The actual code uses a convenience class
drawControl then draws all elements of the button -- its text, icon, any frames, borders.
QSlider is a complex control, consisting of subcontrols that represent the slider's groove, tickmarks and handle. Complex controls are not painted through
QStyle.drawControl but through another method,
drawComplexControl. Its signature is similar, but
option has a mask
subcontrols, which define the active subcontrols. Inactive controls are not painted and the user can't interact with them.
The trick to paint a two-handled slider is to call
drawComplexControl multiple times: first to draw the groove and tickmarks, and then twice, once for each handle. In the code fragment below,
self.opt is a
drawComplexControl will put a handle at the position stored in
self.opt.sliderPosition, hence we iterate it through
self._pos, which stores the positions of our two handles.
# Draw groove and tickmarks self.opt.subControls = QStyle.SC_SliderGroove | QStyle.SC_SliderTickmarks self.style().drawComplexControl(QStyle.CC_Slider, self.opt, painter) # Draw handles self.opt.subControls = QStyle.SC_SliderHandle for self.opt.sliderPosition in self._pos: self.style().drawComplexControl(QStyle.CC_Slider, self.opt, painter)
The code adds some overhead (initializing options) and marks the part of the groove between the two handles in blue.
Drawing a native looking widget is only one part of the job. What happens when the user clicks it? The widget receives a
mousePressEvent - but which part of the widget was clicked? This is known only by the one who's drawn the control:
QStyle implements several methods needed for interaction with complex controls. One is already used in
paintEvent: we call
subControlRect(QStyle.CC_Slider, self.opt, QStyle.SC_SliderHandle) to obtain the logical coordinates of each handle in order to mark the interval between them.
args = QStyle.CC_Slider, self.opt, QStyle.SC_SliderHandle self.opt.sliderPosition = self._pos x_left_handle = self.style().subControlRect(*args).right() self.opt.sliderPosition = self._pos x_right_handle = self.style().subControlRect(*args).left()
mousePressEvent, we call
hitTestComplexControl(QStyle.CC_Slider, self.opt, event.pos(), self), which tells us which subcontrol (if any) of the slider contains
event.pos(). As in
paintEvent, we set
self.opt.sliderPosition to each handle's position in turn, to see if
QStyle.SC_SliderHandle for any of them.
def mousePressEvent(self, event: QMouseEvent) -> None: self._dragged = self.NoHandle args = (QStyle.CC_Slider, self.opt, event.pos(), self) self.opt.sliderPosition = self._pos if self.style().hitTestComplexControl(*args) == QStyle.SC_SliderHandle: self._dragged |= self.LowHandle self.opt.sliderPosition = self._pos if self.style().hitTestComplexControl(*args) == QStyle.SC_SliderHandle: self._dragged |= self.HighHandle if self._dragged != self.NoHandle: self.sliderPressed.emit(self._dragged)
The third method of
QStyle, which we use in
mouseEvent determines the slider value from handles position - which is again known only by the class that paints a platform-dependent slider.
sliderValueFromPosition is given the slider span, (mouse) position and the total bounding rectangle of the slider, and returns the slider's value for that position.
distance = self.opt.maximum - self.opt.minimum pos = self.style().sliderValueFromPosition( 0, distance, event.pos().x(), self.rect().width())
How do we use interval sliders?
Using interval sliders is as trivial as using ordinary
QSliders: initialize it with starting position, interval and options (if any), and connect the necessary signals.
slider = IntervalSlider(low, high * 100, minimum=0, maximum=100) self.slider.intervalChanged.connect(self.__on_slider_moved)