# /*##########################################################################
#
# Copyright (c) 2015-2023 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""This module provides a :class:`RangeSlider` widget.
.. image:: img/RangeSlider.png
   :align: center
"""
__authors__ = ["D. Naudet", "T. Vincent"]
__license__ = "MIT"
__date__ = "14/12/2023"
import numpy
from silx.gui import qt, icons, colors
from silx.gui.utils.image import convertArrayToQImage
[docs]
class StyleOptionRangeSlider(qt.QStyleOption):
    def __init__(self):
        super(StyleOptionRangeSlider, self).__init__()
        self.minimum = None
        self.maximum = None
        self.sliderPosition1 = None
        self.sliderPosition2 = None
        self.handlerRect1 = None
        self.handlerRect2 = None 
[docs]
class RangeSlider(qt.QWidget):
    """Range slider with 2 thumbs and an optional colored groove.
    The position of the slider thumbs can be retrieved either as values
    in the slider range or as a number of steps or pixels.
    :param QWidget parent: See QWidget
    """
    _SLIDER_WIDTH = 10
    """Width of the slider rectangle"""
    _PIXMAP_VOFFSET = 7
    """Vertical groove pixmap offset"""
    sigRangeChanged = qt.Signal(float, float)
    """Signal emitted when the value range has changed.
    It provides the new range (min, max).
    """
    sigValueChanged = qt.Signal(float, float)
    """Signal emitted when the value of the sliders has changed.
    It provides the slider values (first, second).
    """
    sigPositionCountChanged = qt.Signal(object)
    """This signal is emitted when the number of steps has changed.
    It provides the new position count.
    """
    sigPositionChanged = qt.Signal(int, int)
    """Signal emitted when the position of the sliders has changed.
    It provides the slider positions in steps or pixels (first, second).
    """
    def __init__(self, parent=None):
        self.__pixmap = None
        self.__positionCount = None
        self.__firstValue = 0.0
        self.__secondValue = 1.0
        self.__minValue = 0.0
        self.__maxValue = 1.0
        self.__hoverRect = qt.QRect()
        self.__hoverControl = None
        self.__focus = None
        self.__moving = None
        self.__icons = {
            "first": icons.getQIcon("previous"),
            "second": icons.getQIcon("next"),
        }
        # call the super constructor AFTER defining all members that
        # are used in the "paint" method
        super(RangeSlider, self).__init__(parent)
        self.setFocusPolicy(qt.Qt.ClickFocus)
        self.setAttribute(qt.Qt.WA_Hover)
        self.setMinimumSize(qt.QSize(50, 20))
        self.setMaximumHeight(20)
        # Broadcast value changed signal
        self.sigValueChanged.connect(self.__emitPositionChanged)
[docs]
    def event(self, event):
        t = event.type()
        if (
            t == qt.QEvent.HoverEnter
            or t == qt.QEvent.HoverLeave
            or t == qt.QEvent.HoverMove
        ):
            if qt.BINDING in ("PyQt5",):
                # qt-5
                return self.__updateHoverControl(event.pos())
            else:
                # qt-6
                return self.__updateHoverControl(event.position().toPoint())
        else:
            return super(RangeSlider, self).event(event) 
    def __updateHoverControl(self, pos):
        hoverControl, hoverRect = self.__findHoverControl(pos)
        if hoverControl != self.__hoverControl:
            self.update(self.__hoverRect)
            self.update(hoverRect)
            self.__hoverControl = hoverControl
            self.__hoverRect = hoverRect
            return True
        return hoverControl is not None
    def __findHoverControl(self, pos):
        """Returns the control at the position and it's rect location"""
        for name in ["first", "second"]:
            rect = self.__sliderRect(name)
            if rect.contains(pos):
                return name, rect
        rect = self.__drawArea()
        if rect.contains(pos):
            return "groove", rect
        return None, qt.QRect()
    # Position <-> Value conversion
    def __positionToValue(self, position):
        """Returns value corresponding to position
        :param int position:
        :rtype: float
        """
        min_, max_ = self.getMinimum(), self.getMaximum()
        maxPos = self.__getCurrentPositionCount() - 1
        return min_ + (max_ - min_) * int(position) / maxPos
    def __valueToPosition(self, value):
        """Returns closest position corresponding to value
        :param float value:
        :rtype: int
        """
        min_, max_ = self.getMinimum(), self.getMaximum()
        maxPos = self.__getCurrentPositionCount() - 1
        return int(0.5 + maxPos * (float(value) - min_) / (max_ - min_))
    # Position (int) API
    def __getCurrentPositionCount(self):
        """Return current count (either position count or widget width
        :rtype: int
        """
        count = self.getPositionCount()
        if count is not None:
            return count
        else:
            return max(2, self.width() - self._SLIDER_WIDTH)
[docs]
    def getPositionCount(self):
        """Returns the number of positions.
        :rtype: Union[int,None]"""
        return self.__positionCount 
[docs]
    def setPositionCount(self, count):
        """Set the number of positions.
        Slider values are eventually adjusted.
        :param Union[int,None] count:
            Either the number of possible positions or
            None to allow any values.
        :raise ValueError: If count <= 1
        """
        count = None if count is None else int(count)
        if count != self.getPositionCount():
            if count is not None and count <= 1:
                raise ValueError("Position count must be higher than 1")
            self.__positionCount = count
            emit = self.__setValues(*self.getValues())
            self.sigPositionCountChanged.emit(count)
            if emit:
                self.sigValueChanged.emit(*self.getValues()) 
[docs]
    def getFirstPosition(self):
        """Returns first slider position
        :rtype: int
        """
        return self.__valueToPosition(self.getFirstValue()) 
[docs]
    def setFirstPosition(self, position):
        """Set the position of the first slider
        The position is adjusted to valid values
        :param int position:
        """
        self.setFirstValue(self.__positionToValue(position)) 
[docs]
    def getSecondPosition(self):
        """Returns second slider position
        :rtype: int
        """
        return self.__valueToPosition(self.getSecondValue()) 
[docs]
    def setSecondPosition(self, position):
        """Set the position of the second slider
        The position is adjusted to valid values
        :param int position:
        """
        self.setSecondValue(self.__positionToValue(position)) 
[docs]
    def getPositions(self):
        """Returns slider positions (first, second)
        :rtype: List[int]
        """
        return self.getFirstPosition(), self.getSecondPosition() 
[docs]
    def setPositions(self, first, second):
        """Set the position of both sliders at once
        First is clipped to the slider range: [0, max].
        Second is clipped to valid values: [first, max]
        :param int first:
        :param int second:
        """
        self.setValues(self.__positionToValue(first), self.__positionToValue(second)) 
    # Value (float) API
    def __emitPositionChanged(self, *args, **kwargs):
        self.sigPositionChanged.emit(*self.getPositions())
    def __rangeChanged(self):
        """Handle change of value range"""
        emit = self.__setValues(*self.getValues())
        self.sigRangeChanged.emit(*self.getRange())
        if emit:
            self.sigValueChanged.emit(*self.getValues())
[docs]
    def getMinimum(self):
        """Returns the minimum value of the slider range
        :rtype: float
        """
        return self.__minValue 
[docs]
    def setMinimum(self, minimum):
        """Set the minimum value of the slider range.
        It eventually adjusts maximum.
        Slider positions remains unchanged and slider values are modified.
        :param float minimum:
        :raises ValueError:
        """
        minimum = float(minimum)
        if minimum == self.getMaximum():
            raise ValueError("min and max must be different")
        if minimum != self.getMinimum():
            if minimum > self.getMaximum():
                self.__maxValue = minimum
            self.__minValue = minimum
            self.__rangeChanged() 
[docs]
    def getMaximum(self):
        """Returns the maximum value of the slider range
        :rtype: float
        """
        return self.__maxValue 
[docs]
    def setMaximum(self, maximum):
        """Set the maximum value of the slider range
        It eventually adjusts minimum.
        Slider positions remains unchanged and slider values are modified.
        :param float maximum:
        :raises ValueError:
        """
        maximum = float(maximum)
        if maximum == self.getMinimum():
            raise ValueError("min and max must be different")
        if maximum != self.getMaximum():
            if maximum < self.getMinimum():
                self.__minValue = maximum
            self.__maxValue = maximum
            self.__rangeChanged() 
[docs]
    def getRange(self):
        """Returns the range of values (min, max)
        :rtype: List[float]
        """
        return self.getMinimum(), self.getMaximum() 
[docs]
    def setRange(self, minimum, maximum):
        """Set the range of values.
        If maximum is lower than minimum, minimum is the only valid value.
        Slider positions remains unchanged and slider values are modified.
        :param float minimum:
        :param float maximum:
        :raises ValueError:
        """
        minimum, maximum = float(minimum), float(maximum)
        if minimum == maximum:
            raise ValueError("min and max must be different")
        if minimum != self.getMinimum() or maximum != self.getMaximum():
            self.__minValue = minimum
            self.__maxValue = max(maximum, minimum)
            self.__rangeChanged() 
[docs]
    def getFirstValue(self):
        """Returns the value of the first slider
        :rtype: float
        """
        return self.__firstValue 
    def __clipFirstValue(self, value, max_=None):
        """Clip first value to range and steps
        :param float value:
        :param float max_: Alternative maximum to use
        """
        if max_ is None:
            max_ = self.getSecondValue()
        value = min(max(self.getMinimum(), float(value)), max_)
        if self.getPositionCount() is not None:  # Clip to steps
            value = self.__positionToValue(self.__valueToPosition(value))
        return value
[docs]
    def setFirstValue(self, value):
        """Set the value of the first slider
        Value is clipped to valid values.
        :param float value:
        """
        value = self.__clipFirstValue(value)
        if value != self.getFirstValue():
            self.__firstValue = value
            self.update()
            self.sigValueChanged.emit(*self.getValues()) 
[docs]
    def getSecondValue(self):
        """Returns the value of the second slider
        :rtype: float
        """
        return self.__secondValue 
    def __clipSecondValue(self, value):
        """Clip second value to range and steps
        :param float value:
        """
        value = min(max(self.getFirstValue(), float(value)), self.getMaximum())
        if self.getPositionCount() is not None:  # Clip to steps
            value = self.__positionToValue(self.__valueToPosition(value))
        return value
[docs]
    def setSecondValue(self, value):
        """Set the value of the second slider
        Value is clipped to valid values.
        :param float value:
        """
        value = self.__clipSecondValue(value)
        if value != self.getSecondValue():
            self.__secondValue = value
            self.update()
            self.sigValueChanged.emit(*self.getValues()) 
[docs]
    def getValues(self):
        """Returns value of both sliders at once
        :return: (first value, second value)
        :rtype: List[float]
        """
        return self.getFirstValue(), self.getSecondValue() 
[docs]
    def setValues(self, first, second):
        """Set values for both sliders at once
        First is clipped to the slider range: [minimum, maximum].
        Second is clipped to valid values: [first, maximum]
        :param float first:
        :param float second:
        """
        if self.__setValues(first, second):
            self.sigValueChanged.emit(*self.getValues()) 
    def __setValues(self, first, second):
        """Set values for both sliders at once
        First is clipped to the slider range: [minimum, maximum].
        Second is clipped to valid values: [first, maximum]
        :param float first:
        :param float second:
        :return: True if values has changed, False otherwise
        :rtype: bool
        """
        first = self.__clipFirstValue(first, self.getMaximum())
        second = self.__clipSecondValue(second)
        values = first, second
        if self.getValues() != values:
            self.__firstValue, self.__secondValue = values
            self.update()
            return True
        return False
    # Groove API
[docs]
    def getGroovePixmap(self):
        """Returns the pixmap displayed in the slider groove if any.
        :rtype: Union[QPixmap,None]
        """
        return self.__pixmap 
[docs]
    def setGroovePixmap(self, pixmap):
        """Set the pixmap displayed in the slider groove.
        :param Union[QPixmap,None] pixmap: The QPixmap to use or None to unset.
        """
        assert pixmap is None or isinstance(pixmap, qt.QPixmap)
        self.__pixmap = pixmap
        self.update() 
[docs]
    def setGroovePixmapFromProfile(self, profile, colormap=None):
        """Set the pixmap displayed in the slider groove from histogram values.
        :param Union[numpy.ndarray,None] profile:
            1D array of values to display
        :param Union[~silx.gui.colors.Colormap,str] colormap:
            The colormap name or object to convert profile values to colors
        """
        if profile is None:
            self.setSliderPixmap(None)
            return
        profile = numpy.asarray(profile)
        if profile.size == 0:
            self.setSliderPixmap(None)
            return
        if colormap is None:
            colormap = colors.Colormap()
        elif isinstance(colormap, str):
            colormap = colors.Colormap(name=colormap)
        assert isinstance(colormap, colors.Colormap)
        rgbImage = colormap.applyToData(profile.reshape(1, -1))[:, :, :3]
        qimage = convertArrayToQImage(rgbImage)
        qpixmap = qt.QPixmap.fromImage(qimage)
        self.setGroovePixmap(qpixmap) 
    # Handle interaction
    def _mouseEventPosition(self, event):
        if qt.BINDING in ("PyQt5",):
            # qt-5 returns QPoint
            position = event.pos()
        else:
            # qt-6 returns QPointF
            # convert it to QPoint
            position = event.position().toPoint()
        return position
[docs]
    def mousePressEvent(self, event):
        super(RangeSlider, self).mousePressEvent(event)
        if event.buttons() == qt.Qt.LeftButton:
            picked = None
            for name in ("first", "second"):
                area = self.__sliderRect(name)
                position = self._mouseEventPosition(event)
                if area.contains(position):
                    picked = name
                    break
            self.__moving = picked
            self.__focus = picked
            self.update() 
[docs]
    def mouseMoveEvent(self, event):
        super(RangeSlider, self).mouseMoveEvent(event)
        if self.__moving is not None:
            event_pos = self._mouseEventPosition(event)
            delta = self._SLIDER_WIDTH // 2
            if self.__moving == "first":
                position = self.__xPixelToPosition(event_pos.x() + delta)
                self.setFirstPosition(position)
            else:
                position = self.__xPixelToPosition(event_pos.x() - delta)
                self.setSecondPosition(position) 
[docs]
    def mouseReleaseEvent(self, event):
        super(RangeSlider, self).mouseReleaseEvent(event)
        if event.button() == qt.Qt.LeftButton and self.__moving is not None:
            self.__moving = None
            self.update() 
[docs]
    def focusOutEvent(self, event):
        if self.__focus is not None:
            self.__focus = None
            self.update()
        super(RangeSlider, self).focusOutEvent(event) 
[docs]
    def keyPressEvent(self, event):
        key = event.key()
        if event.modifiers() == qt.Qt.NoModifier and self.__focus is not None:
            if key in (qt.Qt.Key_Left, qt.Qt.Key_Down):
                if self.__focus == "first":
                    self.setFirstPosition(self.getFirstPosition() - 1)
                else:
                    self.setSecondPosition(self.getSecondPosition() - 1)
                return  # accept event
            elif key in (qt.Qt.Key_Right, qt.Qt.Key_Up):
                if self.__focus == "first":
                    self.setFirstPosition(self.getFirstPosition() + 1)
                else:
                    self.setSecondPosition(self.getSecondPosition() + 1)
                return  # accept event
        super(RangeSlider, self).keyPressEvent(event) 
    # Handle resize
[docs]
    def resizeEvent(self, event):
        super(RangeSlider, self).resizeEvent(event)
        # If no step, signal position update when width change
        if (
            self.getPositionCount() is None
            and event.size().width() != event.oldSize().width()
        ):
            self.sigPositionChanged.emit(*self.getPositions()) 
    # Handle repaint
    def __xPixelToPosition(self, x):
        """Convert position in pixel to slider position
        :param int x: X in pixel coordinates
        :rtype: int
        """
        sliderArea = self.__sliderAreaRect()
        maxPos = self.__getCurrentPositionCount() - 1
        position = maxPos * (x - sliderArea.left()) / (sliderArea.width() - 1)
        return int(position + 0.5)
    def __sliderRect(self, name):
        """Returns rectangle corresponding to slider in pixels
        :param str name: 'first' or 'second'
        :rtype: QRect
        :raise ValueError: If wrong name
        """
        assert name in ("first", "second")
        if name == "first":
            offset = -self._SLIDER_WIDTH
            position = self.getFirstPosition()
        elif name == "second":
            offset = 0
            position = self.getSecondPosition()
        else:
            raise ValueError("Unknown name")
        sliderArea = self.__sliderAreaRect()
        maxPos = self.__getCurrentPositionCount() - 1
        xOffset = int((sliderArea.width() - 1) * position / maxPos)
        xPos = sliderArea.left() + xOffset + offset
        return qt.QRect(xPos, sliderArea.top(), self._SLIDER_WIDTH, sliderArea.height())
    def __drawArea(self):
        return self.rect().adjusted(self._SLIDER_WIDTH, 0, -self._SLIDER_WIDTH, 0)
    def __sliderAreaRect(self):
        return self.__drawArea().adjusted(
            self._SLIDER_WIDTH // 2, 0, -self._SLIDER_WIDTH // 2 + 1, 0
        )
    def __pixMapRect(self):
        return self.__sliderAreaRect().adjusted(
            0, self._PIXMAP_VOFFSET, -1, -self._PIXMAP_VOFFSET
        )
[docs]
    def paintEvent(self, event):
        painter = qt.QPainter(self)
        style = qt.QApplication.style()
        area = self.__drawArea()
        if self.__pixmap is not None:
            pixmapRect = self.__pixMapRect()
            option = qt.QStyleOptionProgressBar()
            option.initFrom(self)
            option.rect = area
            option.state = (
                qt.QStyle.State_Enabled if self.isEnabled() else qt.QStyle.State_None
            )
            style.drawControl(qt.QStyle.CE_ProgressBarGroove, option, painter, self)
            painter.save()
            pen = painter.pen()
            pen.setWidth(1)
            pen.setColor(qt.Qt.black if self.isEnabled() else qt.Qt.gray)
            painter.setPen(pen)
            painter.drawRect(pixmapRect.adjusted(-1, -1, 0, 1))
            painter.restore()
            if self.isEnabled():
                rect = area.adjusted(
                    self._SLIDER_WIDTH // 2,
                    self._PIXMAP_VOFFSET,
                    -self._SLIDER_WIDTH // 2,
                    -self._PIXMAP_VOFFSET + 1,
                )
                painter.drawPixmap(rect, self.__pixmap, self.__pixmap.rect())
        else:
            option = StyleOptionRangeSlider()
            option.initFrom(self)
            option.rect = area
            option.sliderPosition1 = self.__firstValue
            option.sliderPosition2 = self.__secondValue
            option.handlerRect1 = self.__sliderRect("first")
            option.handlerRect2 = self.__sliderRect("second")
            option.minimum = self.__minValue
            option.maximum = self.__maxValue
            option.state = (
                qt.QStyle.State_Enabled if self.isEnabled() else qt.QStyle.State_None
            )
            if self.__hoverControl == "groove":
                option.state |= qt.QStyle.State_MouseOver
            elif option.state & qt.QStyle.State_MouseOver:
                option.state ^= qt.QStyle.State_MouseOver
            self.drawRangeSliderBackground(painter, option, self)
        # Avoid glitch when moving handles
        hoverControl = self.__moving or self.__hoverControl
        for name in ("first", "second"):
            rect = self.__sliderRect(name)
            option = qt.QStyleOptionButton()
            option.initFrom(self)
            option.icon = self.__icons[name]
            option.iconSize = rect.size() * 0.7
            if hoverControl == name:
                option.state |= qt.QStyle.State_MouseOver
            elif option.state & qt.QStyle.State_MouseOver:
                option.state ^= qt.QStyle.State_MouseOver
            if self.__focus == name:
                option.state |= qt.QStyle.State_HasFocus
            elif option.state & qt.QStyle.State_HasFocus:
                option.state ^= qt.QStyle.State_HasFocus
            option.rect = rect
            style.drawControl(qt.QStyle.CE_PushButton, option, painter, self) 
[docs]
    def sizeHint(self):
        return qt.QSize(200, self.minimumHeight()) 
[docs]
    @classmethod
    def drawRangeSliderBackground(cls, painter, option, widget):
        """Draw the background of the RangeSlider widget into the painter.
        :param qt.QPainter painter: A painter
        :param StyleOptionRangeSlider option: Options to draw the widget
        :param qt.QWidget: The widget which have to be drawn
        """
        painter.save()
        painter.translate(0.5, 0.5)
        backgroundRect = qt.QRect(option.rect)
        if backgroundRect.height() > 8:
            center = backgroundRect.center()
            backgroundRect.setHeight(8)
            backgroundRect.moveCenter(center)
        selectedRangeRect = qt.QRect(backgroundRect)
        selectedRangeRect.setLeft(option.handlerRect1.center().x())
        selectedRangeRect.setRight(option.handlerRect2.center().x())
        highlight = option.palette.color(qt.QPalette.Highlight)
        activeHighlight = highlight
        selectedOutline = option.palette.color(qt.QPalette.Highlight)
        buttonColor = option.palette.button().color()
        val = qt.qGray(buttonColor.rgb())
        buttonColor = buttonColor.lighter(100 + max(1, (180 - val) // 6))
        buttonColor.setHsv(
            buttonColor.hue(), (buttonColor.saturation() * 3) // 4, buttonColor.value()
        )
        grooveColor = qt.QColor()
        grooveColor.setHsv(
            buttonColor.hue(),
            min(255, (int)(buttonColor.saturation())),
            min(255, (int)(buttonColor.value() * 0.9)),
        )
        selectedInnerContrastLine = qt.QColor(255, 255, 255, 30)
        outline = option.palette.color(qt.QPalette.Window).darker(140)
        if (
            option.state & qt.QStyle.State_HasFocus
            and option.state & qt.QStyle.State_KeyboardFocusChange
        ):
            outline = highlight.darker(125)
            if outline.value() > 160:
                outline.setHsl(highlight.hue(), highlight.saturation(), 160)
        # Draw background groove
        painter.setRenderHint(qt.QPainter.Antialiasing, True)
        gradient = qt.QLinearGradient()
        gradient.setStart(backgroundRect.center().x(), backgroundRect.top())
        gradient.setFinalStop(backgroundRect.center().x(), backgroundRect.bottom())
        painter.setPen(qt.QPen(outline))
        gradient.setColorAt(0, grooveColor.darker(110))
        gradient.setColorAt(1, grooveColor.lighter(110))
        painter.setBrush(gradient)
        painter.drawRoundedRect(backgroundRect.adjusted(1, 1, -2, -2), 1, 1)
        # Draw slider background for the value
        gradient = qt.QLinearGradient()
        gradient.setStart(selectedRangeRect.center().x(), selectedRangeRect.top())
        gradient.setFinalStop(
            selectedRangeRect.center().x(), selectedRangeRect.bottom()
        )
        painter.setRenderHint(qt.QPainter.Antialiasing, True)
        painter.setPen(qt.QPen(selectedOutline))
        gradient.setColorAt(0, activeHighlight)
        gradient.setColorAt(1, activeHighlight.lighter(130))
        painter.setBrush(gradient)
        painter.drawRoundedRect(selectedRangeRect.adjusted(1, 1, -2, -2), 1, 1)
        painter.setPen(selectedInnerContrastLine)
        painter.setBrush(qt.Qt.NoBrush)
        painter.drawRoundedRect(selectedRangeRect.adjusted(2, 2, -3, -3), 1, 1)
        painter.restore()