Repository: biolab/orange3

Issue:
add RangeSlider gui component
<!-- 
Thanks for taking the time to submit a feature request!
For the best chance at our team considering your request, please answer the following questions to the best of your ability.
-->

**What's your use case?**
<!-- In other words, what's your pain point? -->

Orange3 does not currently support RangeSlider gui components [see here](https://doc.qt.io/qt-6/qml-qtquick-controls2-rangeslider.html)

<!-- Is your request related to a problem, or perhaps a frustration? -->

Not a problem. More a frustration.

<!-- Tell us the story that led you to write this request. -->

I am building an Orange addon for text analysis that calculates scores for words based on certain metrics. I want to be able to filter words which have scores within a certain range. I am aware that there are other ways to specify numerical ranges using two [QSpinBox](https://www.tutorialspoint.com/pyqt/pyqt_qspinbox_widget.htm) components for example for the min and max values. However, I think it is more user-friendly and intuitive to use a range slider because then the user can more quickly and easily slide through different ranges to dynamically update the filtered results. Rather than manually typing arbitrary numbers for the min and max values for the QSpinBox components.

**What's your proposed solution?**
<!-- Be specific, clear, and concise. -->

Port Range Slider widget to PyQt like in [this thread](https://stackoverflow.com/questions/47342158/porting-range-slider-widget-to-pyqt5).

**Are there any alternative solutions?**

I don't know.






Discussion:
Thanks for this suggestion and for the link! It seems to work nicely (after the fix below), and properly paints the slider to have native look. Some small fixes are necessary, e.g. ticks are hard-coded, which also changes handle shapes from circular to triangular.

If we introduce this, I'd apply the same criteria as for regular sliders. We use them in cases when the actual number being set is opaque and meaningless to user (e.g. setting transparency in visualization, or regularization in regression), but not when the user might care about the number (e.g. minimal number of instances in tree leaves), in which case we prefer spin boxes or even pure line edits.

I personally dislike such double sliders for setting price intervals when buying bicycle parts. (Or whatever I'm buying on the web. That is, bicycle parts.)

**Note:** the linked code for PySide core dumps in PyQt, which we are using, when `sizeHint` calls `QStyle.sizeFromContents`, which in turn calls `QAbstractSlider.orientation`, which crashes. This can be fixed by

- deriving from `QSlider` - probably bad, because it adds other redundant methods
- removing the last argument, `self` from `sizeFromContents(QStyle.CT_Slider, self.opt, QSize(w, h), self)`. This does not seem to affect the sizing.

We must also see whether this class should be derived from `QAbstractSlider` instead of from `QWidget`. On the first glance, both work.


At today's core group meeting we decided to add this control to the library. @markotoplak) also expressed concern about its potential overuse, and @BlazZupan confirmed its potential use in text mining widgets.
Thank you very much @janezd , @markotoplak  and @BlazZupan! I agree there is a potential to overuse it. But there are use cases where workarounds for no RangeSlider become unnecessarily complicated and unintuitive for qualitative researchers to use. Right now I need it for the Text mining use case above but I also know colleagues of mine building Orange addons who also need range sliders for Network Analysis. For example when filtering nodes in the network based on node centrality scores in a certain range. The usage of a single slider to filter scores above and below a certain threshold is useful but limited in its analytical power.

Resolving patch:
diff --git a/Orange/widgets/utils/colorgradientselection.py b/Orange/widgets/utils/colorgradientselection.py
--- a/Orange/widgets/utils/colorgradientselection.py
+++ b/Orange/widgets/utils/colorgradientselection.py
@@ -2,15 +2,13 @@
 
 from AnyQt.QtCore import Qt, QSize, QAbstractItemModel, Property
 from AnyQt.QtWidgets import (
-    QWidget, QSlider, QFormLayout, QComboBox, QStyle, QHBoxLayout, QLabel,
-    QSizePolicy
+    QWidget, QSlider, QFormLayout, QComboBox, QStyle, QSizePolicy
 )
 from AnyQt.QtCore import Signal
 
-from orangewidget.gui import Slider
-
 from Orange.widgets.utils import itemmodels, colorpalettes
 from Orange.widgets.utils.spinbox import DoubleSpinBox, DBL_MIN, DBL_MAX
+from Orange.widgets.utils.intervalslider import IntervalSlider
 
 
 class ColorGradientSelection(QWidget):
@@ -48,59 +46,37 @@ def __init__(self, *args, thresholds=(0.0, 1.0), center=None, **kwargs):
         self.gradient_cb.setModel(model)
         self.gradient_cb.activated[int].connect(self.activated)
         self.gradient_cb.currentIndexChanged.connect(self.currentIndexChanged)
+        self.gradient_cb.currentIndexChanged.connect(
+            self.__update_center_visibility)
+        form.setWidget(0, QFormLayout.SpanningRole, self.gradient_cb)
+
+        def on_center_spin_value_changed(value):
+            if self.__center != value:
+                self.__center = value
+                self.centerChanged.emit(self.__center)
 
         if center is not None:
-            def on_center_spin_value_changed(value):
-                if self.__center != value:
-                    self.__center = value
-                    self.centerChanged.emit(self.__center)
-
-            self.center_box = QWidget()
-            center_layout = QHBoxLayout()
-            self.center_box.setLayout(center_layout)
             self.center_edit = DoubleSpinBox(
                 value=self.__center,
                 minimum=DBL_MIN, maximum=DBL_MAX, minimumStep=0.01,
-                minimumContentsLenght=8,
+                minimumContentsLenght=8, alignment=Qt.AlignRight,
                 stepType=DoubleSpinBox.AdaptiveDecimalStepType,
                 keyboardTracking=False,
-                sizePolicy=QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+                sizePolicy=QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed),
             )
             self.center_edit.valueChanged.connect(on_center_spin_value_changed)
-            center_layout.setContentsMargins(0, 0, 0, 0)
-            center_layout.addStretch(1)
-            center_layout.addWidget(QLabel("Centered at"))
-            center_layout.addWidget(self.center_edit)
-            self.gradient_cb.currentIndexChanged.connect(
-                self.__update_center_visibility)
         else:
-            self.center_box = None
+            self.center_edit = None
 
-        slider_low = Slider(
-            objectName="threshold-low-slider", minimum=0, maximum=100,
-            value=int(low * 100), orientation=Qt.Horizontal,
-            tickPosition=QSlider.TicksBelow, pageStep=10,
+        slider = self.slider = IntervalSlider(
+            int(low * 100), int(high * 100), minimum=0, maximum=100,
+            tickPosition=QSlider.NoTicks,
             toolTip=self.tr("Low gradient threshold"),
             whatsThis=self.tr("Applying a low threshold will squeeze the "
                               "gradient from the lower end")
         )
-        slider_high = Slider(
-            objectName="threshold-low-slider", minimum=0, maximum=100,
-            value=int(high * 100), orientation=Qt.Horizontal,
-            tickPosition=QSlider.TicksAbove, pageStep=10,
-            toolTip=self.tr("High gradient threshold"),
-            whatsThis=self.tr("Applying a high threshold will squeeze the "
-                              "gradient from the higher end")
-        )
-        form.setWidget(0, QFormLayout.SpanningRole, self.gradient_cb)
-        if self.center_box:
-            form.setWidget(1, QFormLayout.SpanningRole, self.center_box)
-        form.addRow(self.tr("Low:"), slider_low)
-        form.addRow(self.tr("High:"), slider_high)
-        self.slider_low = slider_low
-        self.slider_high = slider_high
-        self.slider_low.valueChanged.connect(self.__on_slider_low_moved)
-        self.slider_high.valueChanged.connect(self.__on_slider_high_moved)
+        form.addRow(self.tr("Range:"), slider)
+        self.slider.intervalChanged.connect(self.__on_slider_moved)
         self.setLayout(form)
 
     def setModel(self, model: QAbstractItemModel) -> None:
@@ -148,24 +124,10 @@ def setThresholdHigh(self, high: float) -> None:
     thresholdHigh_ = Property(
         float, thresholdLow, setThresholdLow, notify=thresholdsChanged)
 
-    def __on_slider_low_moved(self, value: int) -> None:
-        high = self.slider_high
+    def __on_slider_moved(self, low: int, high: int) -> None:
         old = self.__threshold_low, self.__threshold_high
-        self.__threshold_low = value / 100.
-        if value >= high.value():
-            self.__threshold_high = value / 100.
-            high.setSliderPosition(value)
-        new = self.__threshold_low, self.__threshold_high
-        if new != old:
-            self.thresholdsChanged.emit(*new)
-
-    def __on_slider_high_moved(self, value: int) -> None:
-        low = self.slider_low
-        old = self.__threshold_low, self.__threshold_high
-        self.__threshold_high = value / 100.
-        if low.value() >= value:
-            self.__threshold_low = value / 100
-            low.setSliderPosition(value)
+        self.__threshold_low = low / 100.
+        self.__threshold_high = high / 100.
         new = self.__threshold_low, self.__threshold_high
         if new != old:
             self.thresholdsChanged.emit(*new)
@@ -178,18 +140,22 @@ def setThresholds(self, low: float, high: float) -> None:
         if self.__threshold_low != low or self.__threshold_high != high:
             self.__threshold_high = high
             self.__threshold_low = low
-            self.slider_low.setSliderPosition(int(low * 100))
-            self.slider_high.setSliderPosition(int(high * 100))
+            self.slider.setInterval(int(low * 100), int(high * 100))
             self.thresholdsChanged.emit(high, low)
 
     def __update_center_visibility(self):
-        if self.center_box is None:
+        palette = self.currentData()
+        if self.center_edit is None or \
+                (visible := self.center_edit.parent() is not None) \
+                == bool(isinstance(palette, colorpalettes.Palette)
+                        and palette.flags & palette.Flags.Diverging):
             return
+        if visible:
+            self.layout().takeRow(1).labelItem.widget().setParent(None)
+            self.center_edit.setParent(None)
+        else:
+            self.layout().insertRow(1, "Center at:", self.center_edit)
 
-        palette = self.currentData()
-        self.center_box.setVisible(
-            isinstance(palette, colorpalettes.Palette)
-            and palette.flags & palette.Flags.Diverging != 0)
 
     def center(self) -> float:
         return self.__center
diff --git a/Orange/widgets/utils/intervalslider.py b/Orange/widgets/utils/intervalslider.py
new file mode 100644
--- /dev/null
+++ b/Orange/widgets/utils/intervalslider.py
@@ -0,0 +1,253 @@
+from typing import Tuple
+
+from AnyQt.QtCore import pyqtSignal as Signal
+
+from AnyQt.QtWidgets import \
+    QWidget, QStyleOptionSlider, QSizePolicy, QStyle, QSlider
+from AnyQt.QtGui import QPainter, QMouseEvent, QPalette, QBrush
+from AnyQt.QtCore import QRect, Qt, QSize
+
+
+# Based on idea and in part the code from
+# https://stackoverflow.com/questions/47342158/porting-range-slider-widget-to-pyqt5
+
+class IntervalSlider(QWidget):
+    """
+    Slider with two handles for setting an interval of values.
+
+    Only horizontal orientation is supported.
+
+    Signals:
+
+    rangeChanged(minimum: int, maximum: int):
+        minumum or maximum or both have changed
+
+    intervalChanged(low: int, high: int):
+        One or both boundaries have been changed.The tracking determines
+        whether this signal is emitted during user interaction or only when
+        the mouse is released
+
+    sliderPressed(id: int)
+        The user started dragging.
+        Id is IntervarlSlider.LowHandle or IntervalSlider.HighHandle
+
+    sliderMoved(id: int, value: int)
+        the user drags the slider
+        Id is IntervarlSlider.LowHandle or IntervalSlider.HighHandle
+
+    sliderReleased(id: int)
+        The user finished dragging.
+        Id is IntervarlSlider.LowHandle or IntervalSlider.HighHandle
+
+    """
+    NoHandle, LowHandle, HighHandle = 0, 1, 2
+
+    rangeChanged = Signal((int, int))  # notifier for setMinimum/setMaximum
+    intervalChanged = Signal((int, int))  # setRange, slider move (when tracking)
+    sliderPressed = Signal(int)  # argument is handle id (see above)
+    sliderMoved = Signal(int, int)  #
+    sliderReleased = Signal(int)
+
+    def __init__(self, low=1, high=8, minimum=0, maximum=10,
+                 parent: QWidget = None, **args):
+        super().__init__(parent)
+        self.setSizePolicy(
+            QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed,
+                        QSizePolicy.Slider))
+
+        self._dragged = self.NoHandle
+        self._start_drag_value = None
+
+        self.opt = QStyleOptionSlider()
+        self._interval = self._pos = (low, high)
+        self.opt.minimum = minimum
+        self.opt.maximum = maximum
+        self._tracking = True
+        for opt, value in args.items():
+            getattr(self, f"set{opt[0].upper()}{opt[1:]}")(value)
+
+    # Properties
+
+    def orientation(self):
+        """Orientation. Always Qt.Horizontal"""
+        return Qt.Horizontal
+
+    def setOrientation(self, orientation):
+        """Set orientation (to Qt.Horizontal)"""
+        if orientation != Qt.Horizontal:
+            raise ValueError("IntervalSlider supports only horizontal direction")
+
+    def sliderPosition(self) -> Tuple[int, int]:
+        """
+        Current position of sliders.
+        This is the same as `interval` if tracking is enabled.
+        """
+        return self._pos
+
+    def setSliderPosition(self, low: int, high: int) -> None:
+        """
+        Set position of sliders.
+
+        This also changes `interval` if tracking is enabled.
+        """
+        self._pos = (low, high)
+        self.update()
+        if self._tracking:
+            self._interval = self._pos
+            self.intervalChanged.emit(low, high)
+        self.sliderMoved.emit(self.LowHandle, low)
+        self.sliderMoved.emit(self.HighHandle, high)
+
+    def interval(self) -> Tuple[int, int]:
+        """Current interval"""
+        return self._interval
+
+    def setInterval(self, low: int, high: int) -> None:
+        """Set the current interval"""
+        self._interval = (low, high)
+        self.setSliderPosition(low, high)
+
+    def low(self) -> int:
+        """The lower bound of the interavl"""
+        return self._interval[0]
+
+    def setLow(self, low: int) -> None:
+        """Set the lower bound"""
+        self.setInterval(low, self.high())
+
+    def high(self) -> int:
+        """The higher bound of the interval"""
+        return self._interval[1]
+
+    def setHigh(self, high: int) -> None:
+        """Set the higher bound of the interval"""
+        self.setInterval(self.low(), high)
+
+    def setMinimum(self, minimum: int) -> None:
+        """Set the minimal value of the lower bound"""
+        self.opt.minimum = minimum
+        self.rangeChanged.emit(minimum, self.opt.maximum)
+
+    def setMaximum(self, maximum: int) -> None:
+        """Set the maximal value of the higher bound"""
+        self.opt.maximum = maximum
+        self.rangeChanged.emit(self.opt.minimum, maximum)
+
+    def setTickPosition(self, position: QSlider.TickPosition) -> None:
+        """See QSlider.setTickPosition"""
+        self.opt.tickPosition = position
+
+    def setTickInterval(self, ti: int) -> None:
+        """See QSlider.setInterval"""
+        self.opt.tickInterval = ti
+
+    def setTracking(self, enabled: bool) -> None:
+        """
+        If enabled, interval is changed during user interaction and the
+        notifier signal is emitted immediately.
+        """
+        self._tracking = enabled
+
+    def hasTracking(self) -> bool:
+        return self._tracking
+
+    # Mouse events
+
+    def mousePressEvent(self, event: QMouseEvent) -> None:
+        self._dragged = self.NoHandle
+        args = (QStyle.CC_Slider, self.opt, event.pos(), self)
+        self.opt.sliderPosition = self._pos[0]
+        if self.style().hitTestComplexControl(*args) == QStyle.SC_SliderHandle:
+            self._dragged |= self.LowHandle
+        self.opt.sliderPosition = self._pos[1]
+        if self.style().hitTestComplexControl(*args) == QStyle.SC_SliderHandle:
+            self._dragged |= self.HighHandle
+        if self._dragged != self.NoHandle:
+            self.sliderPressed.emit(self._dragged)
+
+    def mouseMoveEvent(self, event: QMouseEvent) -> None:
+        distance = self.opt.maximum - self.opt.minimum
+        pos = self.style().sliderValueFromPosition(
+            0, distance, event.pos().x(), self.rect().width())
+        low, high = self._pos
+
+        # If handles overlap, determine which one is dragged
+        if self._dragged == self.LowHandle | self.HighHandle:
+            if pos < high:
+                self._dragged = self.LowHandle
+            elif pos > low:
+                self._dragged = self.HighHandle
+
+        if self._dragged == self.LowHandle:
+            low = min(pos, high)
+        elif self._dragged == self.HighHandle:
+            high = max(pos, low)
+
+        if self._pos == (low, high):
+            return
+
+        self._pos = (low, high)
+        self.update()
+        if self._tracking:
+            self._interval = self._pos
+            self.intervalChanged.emit(*self._interval)
+
+    def mouseReleaseEvent(self, _) -> None:
+        if self._dragged == self.NoHandle:
+            return
+
+        self.sliderReleased.emit(self._dragged)
+        if self._interval != self._pos:
+            self._interval = self._pos
+            self.intervalChanged.emit(*self._interval)
+
+    # Paint
+
+    def paintEvent(self, _) -> None:
+        painter = QPainter(self)
+
+        # Draw groove
+        self.opt.initFrom(self)
+        self.opt.rect = self.rect()
+        self.opt.sliderPosition = 0
+        self.opt.subControls = QStyle.SC_SliderGroove | QStyle.SC_SliderTickmarks
+        self.style().drawComplexControl(QStyle.CC_Slider, self.opt, painter)
+
+        # Draw interval
+        # Interval has an arbitrary width of 4; I don't know how to get the
+        # actual groove thickness.
+        color = self.palette().color(QPalette.Highlight)
+        painter.setBrush(QBrush(color))
+        painter.setPen(Qt.NoPen)
+
+        args = QStyle.CC_Slider, self.opt, QStyle.SC_SliderHandle
+        self.opt.sliderPosition = self._pos[0]
+        x_left_handle = self.style().subControlRect(*args).right()
+        self.opt.sliderPosition = self._pos[1]
+        x_right_handle = self.style().subControlRect(*args).left()
+
+        groove_rect = self.style().subControlRect(
+            QStyle.CC_Slider, self.opt, QStyle.SC_SliderGroove, self)
+        selection = QRect(
+            x_left_handle,
+            groove_rect.y() + groove_rect.height() // 2 - 2,
+            x_right_handle - x_left_handle,
+            4)
+        painter.drawRect(selection)
+
+        # Draw handles
+        self.opt.subControls = QStyle.SC_SliderHandle
+        for self.opt.sliderPosition in self._pos:
+            self.style().drawComplexControl(QStyle.CC_Slider, self.opt, painter)
+
+    def sizeHint(self) -> QSize:
+        SliderLength = 84
+        TickSpace = 5
+
+        w = SliderLength
+        h = self.style().pixelMetric(QStyle.PM_SliderThickness, self.opt, self)
+        if self.opt.tickPosition != QSlider.NoTicks:
+            h += TickSpace
+
+        return self.style().sizeFromContents(
+            QStyle.CT_Slider, self.opt, QSize(w, h))


Test patch:
diff --git a/Orange/widgets/utils/tests/test_colorgradientselection.py b/Orange/widgets/utils/tests/test_colorgradientselection.py
--- a/Orange/widgets/utils/tests/test_colorgradientselection.py
+++ b/Orange/widgets/utils/tests/test_colorgradientselection.py
@@ -1,5 +1,3 @@
-from unittest.mock import Mock
-
 import numpy as np
 
 from AnyQt.QtTest import QSignalSpy, QTest
@@ -56,22 +54,14 @@ def test_slider_move(self):
         w.adjustSize()
         w.setThresholds(0.5, 0.5)
         changed = QSignalSpy(w.thresholdsChanged)
-        sl, sh = w.slider_low, w.slider_high
-        sl.triggerAction(sl.SliderToMinimum)
+        w.slider.setLow(25)
         self.assertEqual(len(changed), 1)
-        low, high = changed[-1]
-        self.assertLessEqual(low, high)
-        self.assertEqual(low, 0.0)
-        sl.triggerAction(sl.SliderToMaximum)
+        self.assertEqual(changed[-1], [0.25, 0.5])
+        self.assertEqual(w.thresholds(), (0.25, 0.5))
+        w.slider.setHigh(75)
         self.assertEqual(len(changed), 2)
-        low, high = changed[-1]
-        self.assertLessEqual(low, high)
-        self.assertEqual(low, 1.0)
-        sh.triggerAction(sl.SliderToMinimum)
-        self.assertEqual(len(changed), 3)
-        low, high = changed[-1]
-        self.assertLessEqual(low, high)
-        self.assertEqual(high, 0.0)
+        self.assertEqual(changed[-1], [0.25, 0.75])
+        self.assertEqual(w.thresholds(), (0.25, 0.75))
 
     def test_center(self):
         w = ColorGradientSelection(center=42)
@@ -81,7 +71,6 @@ def test_center(self):
 
     def test_center_visibility(self):
         w = ColorGradientSelection(center=0)
-        w.center_box.setVisible = Mock()
         model = itemmodels.ContinuousPalettesModel()
         w.setModel(model)
         for row in range(model.rowCount(QModelIndex())):
@@ -93,14 +82,14 @@ def test_center_visibility(self):
                     nondiverging = row
 
         w.setCurrentIndex(diverging)
-        w.center_box.setVisible.assert_called_with(True)
+        self.assertIsNotNone(w.center_edit.parent())
         w.setCurrentIndex(nondiverging)
-        w.center_box.setVisible.assert_called_with(False)
+        self.assertIsNone(w.center_edit.parent())
         w.setCurrentIndex(diverging)
-        w.center_box.setVisible.assert_called_with(True)
+        self.assertIsNotNone(w.center_edit.parent())
 
         w = ColorGradientSelection()
-        self.assertIsNone(w.center_box)
+        self.assertIsNone(w.center_edit)
 
     def test_center_changed(self):
         w = ColorGradientSelection(center=42)