diff options
author | Alexandre Marie <alexandre.marie@synchrotron-soleil.fr> | 2018-12-17 12:28:24 +0100 |
---|---|---|
committer | Alexandre Marie <alexandre.marie@synchrotron-soleil.fr> | 2018-12-17 12:28:24 +0100 |
commit | cebdc9244c019224846cb8d2668080fe386a6adc (patch) | |
tree | aedec55da0f9dd4fc4d6c7eb0f58489a956e2e8c /silx/gui/widgets/RangeSlider.py | |
parent | 159ef14fb9e198bb0066ea14e6b980f065de63dd (diff) |
New upstream version 0.9.0+dfsg
Diffstat (limited to 'silx/gui/widgets/RangeSlider.py')
-rw-r--r-- | silx/gui/widgets/RangeSlider.py | 627 |
1 files changed, 627 insertions, 0 deletions
diff --git a/silx/gui/widgets/RangeSlider.py b/silx/gui/widgets/RangeSlider.py new file mode 100644 index 0000000..0b72e71 --- /dev/null +++ b/silx/gui/widgets/RangeSlider.py @@ -0,0 +1,627 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2018 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__ = "02/08/2018" + + +import numpy as numpy + +from silx.gui import qt, icons, colors +from silx.gui.utils.image import convertArrayToQImage + + +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.__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.setMinimumSize(qt.QSize(50, 20)) + self.setMaximumHeight(20) + + # Broadcast value changed signal + self.sigValueChanged.connect(self.__emitPositionChanged) + + # 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) + + def getPositionCount(self): + """Returns the number of positions. + + :rtype: Union[int,None]""" + return self.__positionCount + + 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()) + + def getFirstPosition(self): + """Returns first slider position + + :rtype: int + """ + return self.__valueToPosition(self.getFirstValue()) + + 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)) + + def getSecondPosition(self): + """Returns second slider position + + :rtype: int + """ + return self.__valueToPosition(self.getSecondValue()) + + 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)) + + def getPositions(self): + """Returns slider positions (first, second) + + :rtype: List[int] + """ + return self.getFirstPosition(), self.getSecondPosition() + + 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()) + + def getMinimum(self): + """Returns the minimum value of the slider range + + :rtype: float + """ + return self.__minValue + + 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() + + def getMaximum(self): + """Returns the maximum value of the slider range + + :rtype: float + """ + return self.__maxValue + + 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() + + def getRange(self): + """Returns the range of values (min, max) + + :rtype: List[float] + """ + return self.getMinimum(), self.getMaximum() + + 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() + + 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 + + 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()) + + 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 + + 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()) + + def getValues(self): + """Returns value of both sliders at once + + :return: (first value, second value) + :rtype: List[float] + """ + return self.getFirstValue(), self.getSecondValue() + + 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 + + def getGroovePixmap(self): + """Returns the pixmap displayed in the slider groove if any. + + :rtype: Union[QPixmap,None] + """ + return self.__pixmap + + 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() + + 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[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 + + 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() + + def mouseMoveEvent(self, event): + super(RangeSlider, self).mouseMoveEvent(event) + + if self.__moving is not None: + position = self.__xPixelToPosition(event.pos().x()) + if self.__moving == 'first': + self.setFirstPosition(position) + else: + self.setSecondPosition(position) + + 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() + + def focusOutEvent(self, event): + if self.__focus is not None: + self.__focus = None + self.update() + super(RangeSlider, self).focusOutEvent(event) + + 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 + + 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., + 0) + + def __pixMapRect(self): + return self.__sliderAreaRect().adjusted(0, + self._PIXMAP_VOFFSET, + 0, + -self._PIXMAP_VOFFSET) + + def paintEvent(self, event): + painter = qt.QPainter(self) + + style = qt.QApplication.style() + + area = self.__drawArea() + pixmapRect = self.__pixMapRect() + + option = qt.QStyleOptionProgressBar() + option.initFrom(self) + option.rect = area + option.state = ((self.isEnabled() and qt.QStyle.State_Enabled) + or 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, 1, 1)) + painter.restore() + + if self.isEnabled() and self.__pixmap is not None: + painter.drawPixmap(area.adjusted(self._SLIDER_WIDTH / 2, + self._PIXMAP_VOFFSET, + -self._SLIDER_WIDTH / 2 + 1, + -self._PIXMAP_VOFFSET + 1), + self.__pixmap, + self.__pixmap.rect()) + + 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 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) + + def sizeHint(self): + return qt.QSize(200, self.minimumHeight()) |