Source code for silx.gui.widgets.RangeSlider

# /*##########################################################################
#
# 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()