# coding: utf-8
# /*##########################################################################
#
# Copyright (c) 2015-2020 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
"""
from __future__ import absolute_import, division
__authors__ = ["D. Naudet", "T. Vincent"]
__license__ = "MIT"
__date__ = "26/11/2018"
import numpy as 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.
self.__secondValue = 1.
self.__minValue = 0.
self.__maxValue = 1.
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:
return self.__updateHoverControl(event.pos())
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:
"""
minimum = float(minimum)
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:
"""
maximum = float(maximum)
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:
"""
minimum, maximum = float(minimum), float(maximum)
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.array(profile, copy=False)
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
[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)
if area.contains(event.pos()):
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:
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.Background).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()