diff options
Diffstat (limited to 'src/silx/gui/widgets')
32 files changed, 6663 insertions, 0 deletions
diff --git a/src/silx/gui/widgets/BoxLayoutDockWidget.py b/src/silx/gui/widgets/BoxLayoutDockWidget.py new file mode 100644 index 0000000..3d2b853 --- /dev/null +++ b/src/silx/gui/widgets/BoxLayoutDockWidget.py @@ -0,0 +1,90 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 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. +# +# ###########################################################################*/ +"""A QDockWidget that update the layout direction of its widget +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/03/2018" + + +from .. import qt + + +class BoxLayoutDockWidget(qt.QDockWidget): + """QDockWidget adjusting its child widget QBoxLayout direction. + + The child widget layout direction is set according to dock widget area. + The child widget MUST use a QBoxLayout + + :param parent: See :class:`QDockWidget` + :param flags: See :class:`QDockWidget` + """ + + def __init__(self, parent=None, flags=qt.Qt.Widget): + super(BoxLayoutDockWidget, self).__init__(parent, flags) + self._currentArea = qt.Qt.NoDockWidgetArea + self.dockLocationChanged.connect(self._dockLocationChanged) + self.topLevelChanged.connect(self._topLevelChanged) + + def setWidget(self, widget): + """Set the widget of this QDockWidget + + See :meth:`QDockWidget.setWidget` + """ + super(BoxLayoutDockWidget, self).setWidget(widget) + # Update widget's layout direction + self._dockLocationChanged(self._currentArea) + + def _dockLocationChanged(self, area): + self._currentArea = area + + widget = self.widget() + if widget is not None: + layout = widget.layout() + if isinstance(layout, qt.QBoxLayout): + if area in (qt.Qt.LeftDockWidgetArea, qt.Qt.RightDockWidgetArea): + direction = qt.QBoxLayout.TopToBottom + else: + direction = qt.QBoxLayout.LeftToRight + layout.setDirection(direction) + self.resize(widget.minimumSize()) + self.adjustSize() + + def _topLevelChanged(self, topLevel): + widget = self.widget() + if widget is not None and topLevel: + layout = widget.layout() + if isinstance(layout, qt.QBoxLayout): + layout.setDirection(qt.QBoxLayout.LeftToRight) + self.resize(widget.minimumSize()) + self.adjustSize() + + def showEvent(self, event): + """Make sure this dock widget is raised when it is shown. + + This is useful for tabbed dock widgets. + """ + self.raise_() diff --git a/src/silx/gui/widgets/ColormapNameComboBox.py b/src/silx/gui/widgets/ColormapNameComboBox.py new file mode 100644 index 0000000..fa8faf1 --- /dev/null +++ b/src/silx/gui/widgets/ColormapNameComboBox.py @@ -0,0 +1,166 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-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. +# +# ###########################################################################*/ +"""A QComboBox to display prefered colormaps +""" + +from __future__ import division + +__authors__ = ["V.A. Sole", "T. Vincent", "H. Payno"] +__license__ = "MIT" +__date__ = "27/11/2018" + + +import logging +import numpy + +from .. import qt +from .. import colors as colors_mdl + +_logger = logging.getLogger(__name__) + + +_colormapIconPreview = {} + + +class ColormapNameComboBox(qt.QComboBox): + def __init__(self, parent=None): + qt.QComboBox.__init__(self, parent) + self.__initItems() + + LUT_NAME = qt.Qt.UserRole + 1 + LUT_COLORS = qt.Qt.UserRole + 2 + + def __initItems(self): + for colormapName in colors_mdl.preferredColormaps(): + index = self.count() + self.addItem(str.title(colormapName)) + self.setItemIcon(index, self.getIconPreview(name=colormapName)) + self.setItemData(index, colormapName, role=self.LUT_NAME) + + def getIconPreview(self, name=None, colors=None): + """Return an icon preview from a LUT name. + + This icons are cached into a global structure. + + :param str name: Name of the LUT + :param numpy.ndarray colors: Colors identify the LUT + :rtype: qt.QIcon + """ + if name is not None: + iconKey = name + else: + iconKey = tuple(colors) + icon = _colormapIconPreview.get(iconKey, None) + if icon is None: + icon = self.createIconPreview(name, colors) + _colormapIconPreview[iconKey] = icon + return icon + + def createIconPreview(self, name=None, colors=None): + """Create and return an icon preview from a LUT name. + + This icons are cached into a global structure. + + :param str name: Name of the LUT + :param numpy.ndarray colors: Colors identify the LUT + :rtype: qt.QIcon + """ + colormap = colors_mdl.Colormap(name) + size = 32 + if name is not None: + lut = colormap.getNColors(size) + else: + lut = colors + if len(lut) > size: + # Down sample + step = int(len(lut) / size) + lut = lut[::step] + elif len(lut) < size: + # Over sample + indexes = numpy.arange(size) / float(size) * (len(lut) - 1) + indexes = indexes.astype("int") + lut = lut[indexes] + if lut is None or len(lut) == 0: + return qt.QIcon() + + pixmap = qt.QPixmap(size, size) + painter = qt.QPainter(pixmap) + for i in range(size): + rgb = lut[i] + r, g, b = rgb[0], rgb[1], rgb[2] + painter.setPen(qt.QColor(r, g, b)) + painter.drawPoint(qt.QPoint(i, 0)) + + painter.drawPixmap(0, 1, size, size - 1, pixmap, 0, 0, size, 1) + painter.end() + + return qt.QIcon(pixmap) + + def getCurrentName(self): + return self.itemData(self.currentIndex(), self.LUT_NAME) + + def getCurrentColors(self): + return self.itemData(self.currentIndex(), self.LUT_COLORS) + + def findLutName(self, name): + return self.findData(name, role=self.LUT_NAME) + + def findLutColors(self, lut): + for index in range(self.count()): + if self.itemData(index, role=self.LUT_NAME) is not None: + continue + colors = self.itemData(index, role=self.LUT_COLORS) + if colors is None: + continue + if numpy.array_equal(colors, lut): + return index + return -1 + + def setCurrentLut(self, colormap): + name = colormap.getName() + if name is not None: + self._setCurrentName(name) + else: + lut = colormap.getColormapLUT() + self._setCurrentLut(lut) + + def _setCurrentLut(self, lut): + index = self.findLutColors(lut) + if index == -1: + index = self.count() + self.addItem("Custom") + self.setItemIcon(index, self.getIconPreview(colors=lut)) + self.setItemData(index, None, role=self.LUT_NAME) + self.setItemData(index, lut, role=self.LUT_COLORS) + self.setCurrentIndex(index) + + def _setCurrentName(self, name): + index = self.findLutName(name) + if index < 0: + index = self.count() + self.addItem(str.title(name)) + self.setItemIcon(index, self.getIconPreview(name=name)) + self.setItemData(index, name, role=self.LUT_NAME) + self.setCurrentIndex(index) diff --git a/src/silx/gui/widgets/ElidedLabel.py b/src/silx/gui/widgets/ElidedLabel.py new file mode 100644 index 0000000..7c6dfb5 --- /dev/null +++ b/src/silx/gui/widgets/ElidedLabel.py @@ -0,0 +1,140 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2021 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. +# +# ###########################################################################*/ +"""Module contains an elidable label +""" + +__license__ = "MIT" +__date__ = "07/12/2018" + +from silx.gui import qt + + +class ElidedLabel(qt.QLabel): + """QLabel with an edile property. + + By default if the text is too big, it is elided on the right. + + This mode can be changed with :func:`setElideMode`. + + In case the text is elided, the full content is displayed as part of the + tool tip. This behavior can be disabled with :func:`setTextAsToolTip`. + """ + + def __init__(self, parent=None): + super(ElidedLabel, self).__init__(parent) + self.__text = "" + self.__toolTip = "" + self.__textAsToolTip = True + self.__textIsElided = False + self.__elideMode = qt.Qt.ElideRight + self.__updateMinimumSize() + + def resizeEvent(self, event): + self.__updateText() + return qt.QLabel.resizeEvent(self, event) + + def setFont(self, font): + qt.QLabel.setFont(self, font) + self.__updateMinimumSize() + self.__updateText() + + def __updateMinimumSize(self): + metrics = self.fontMetrics() + if qt.BINDING in ('PySide2', 'PyQt5'): + width = metrics.width("...") + else: # Qt6 + width = metrics.horizontalAdvance("...") + self.setMinimumWidth(width) + + def __updateText(self): + metrics = self.fontMetrics() + elidedText = metrics.elidedText(self.__text, self.__elideMode, self.width()) + qt.QLabel.setText(self, elidedText) + wasElided = self.__textIsElided + self.__textIsElided = elidedText != self.__text + if self.__textIsElided or wasElided != self.__textIsElided: + self.__updateToolTip() + + def __updateToolTip(self): + if self.__textIsElided and self.__textAsToolTip: + qt.QLabel.setToolTip(self, self.__text + "<br/>" + self.__toolTip) + else: + qt.QLabel.setToolTip(self, self.__toolTip) + + # Properties + + def setText(self, text): + self.__text = text + self.__updateText() + + def getText(self): + return self.__text + + text = qt.Property(str, getText, setText) + + def setToolTip(self, toolTip): + self.__toolTip = toolTip + self.__updateToolTip() + + def getToolTip(self): + return self.__toolTip + + toolTip = qt.Property(str, getToolTip, setToolTip) + + def setElideMode(self, elideMode): + """Set the elide mode. + + :param qt.Qt.TextElideMode elidMode: Elide mode to use + """ + self.__elideMode = elideMode + self.__updateText() + + def getElideMode(self): + """Returns the used elide mode. + + :rtype: qt.Qt.TextElideMode + """ + return self.__elideMode + + elideMode = qt.Property(qt.Qt.TextElideMode, getToolTip, setToolTip) + + def setTextAsToolTip(self, enabled): + """Enable displaying text as part of the tooltip if it is elided. + + :param bool enabled: Enable the behavior + """ + if self.__textAsToolTip == enabled: + return + self.__textAsToolTip = enabled + self.__updateToolTip() + + def getTextAsToolTip(self): + """True if an elided text is displayed as part of the tooltip. + + :rtype: bool + """ + return self.__textAsToolTip + + textAsToolTip = qt.Property(bool, getTextAsToolTip, setTextAsToolTip) diff --git a/src/silx/gui/widgets/FloatEdit.py b/src/silx/gui/widgets/FloatEdit.py new file mode 100644 index 0000000..08ed67d --- /dev/null +++ b/src/silx/gui/widgets/FloatEdit.py @@ -0,0 +1,71 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2021 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. +# +# ###########################################################################*/ +"""Module contains a float editor +""" + +from __future__ import division + +__authors__ = ["V.A. Sole", "T. Vincent"] +__license__ = "MIT" +__date__ = "02/10/2017" + +from .. import qt + + +class FloatEdit(qt.QLineEdit): + """Field to edit a float value. + + :param parent: See :class:`QLineEdit` + :param float value: The value to set the QLineEdit to. + """ + def __init__(self, parent=None, value=None): + qt.QLineEdit.__init__(self, parent) + validator = qt.QDoubleValidator(self) + self.setValidator(validator) + self.setAlignment(qt.Qt.AlignRight) + if value is not None: + self.setValue(value) + + def value(self): + """Return the QLineEdit current value as a float.""" + text = self.text() + value, validated = self.validator().locale().toDouble(text) + if not validated: + self.setValue(value) + return value + + def setValue(self, value): + """Set the current value of the LineEdit + + :param float value: The value to set the QLineEdit to. + """ + locale = self.validator().locale() + if qt.BINDING == "PySide6": + # Fix for PySide6 not selecting the right method + text = locale.toString(float(value), 'g') + else: + text = locale.toString(float(value)) + + self.setText(text) diff --git a/src/silx/gui/widgets/FlowLayout.py b/src/silx/gui/widgets/FlowLayout.py new file mode 100644 index 0000000..3c4c9dd --- /dev/null +++ b/src/silx/gui/widgets/FlowLayout.py @@ -0,0 +1,177 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 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 flow layout for QWidget: :class:`FlowLayout`. +""" + +from __future__ import division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "20/07/2018" + + +from .. import qt + + +class FlowLayout(qt.QLayout): + """Layout widgets on (possibly) multiple lines in the available width. + + See Qt :class:`QLayout` for API documentation. + + Adapted from C++ `Qt FlowLayout example + <http://doc.qt.io/qt-5/qtwidgets-layouts-flowlayout-example.html>`_ + + :param QWidget parent: See :class:`QLayout` + """ + + def __init__(self, parent=None): + super(FlowLayout, self).__init__(parent) + self._items = [] + self._horizontalSpacing = -1 + self._verticalSpacing = -1 + + def addItem(self, item): + self._items.append(item) + + def count(self): + return len(self._items) + + def itemAt(self, index): + if 0 <= index < len(self._items): + return self._items[index] + else: + return None + + def takeAt(self, index): + if 0 <= index < len(self._items): + return self._items.pop(index) + else: + return None + + def expandingDirections(self): + return qt.Qt.Orientations() + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + return self._layout(qt.QRect(0, 0, width, 0), test=True) + + def setGeometry(self, rect): + super(FlowLayout, self).setGeometry(rect) + self._layout(rect) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = qt.QSize() + for item in self._items: + size = size.expandedTo(item.minimumSize()) + + left, top, right, bottom = self.getContentsMargins() + size += qt.QSize(left + right, top + bottom) + return size + + def _layout(self, rect, test=False): + left, top, right, bottom = self.getContentsMargins() + effectiveRect = rect.adjusted(left, top, -right, -bottom) + x, y = effectiveRect.x(), effectiveRect.y() + lineHeight = 0 + + for item in self._items: + widget = item.widget() + spaceX = self.horizontalSpacing() + if spaceX == -1: + spaceX = widget.style().layoutSpacing( + qt.QSizePolicy.PushButton, + qt.QSizePolicy.PushButton, + qt.Qt.Horizontal) + spaceY = self.verticalSpacing() + if spaceY == -1: + spaceY = widget.style().layoutSpacing( + qt.QSizePolicy.PushButton, + qt.QSizePolicy.PushButton, + qt.Qt.Vertical) + + nextX = x + item.sizeHint().width() + spaceX + if (nextX - spaceX) > effectiveRect.right() and lineHeight > 0: + x = effectiveRect.x() + y += lineHeight + spaceY + nextX = x + item.sizeHint().width() + spaceX + lineHeight = 0 + + if not test: + item.setGeometry(qt.QRect(qt.QPoint(x, y), item.sizeHint())) + + x = nextX + lineHeight = max(lineHeight, item.sizeHint().height()) + + return y + lineHeight - rect.y() + bottom + + def setHorizontalSpacing(self, spacing): + """Set the horizontal spacing between widgets laid out side by side + + :param int spacing: + """ + self._horizontalSpacing = spacing + self.update() + + def horizontalSpacing(self): + """Returns the horizontal spacing between widgets laid out side by side + + :rtype: int + """ + if self._horizontalSpacing >= 0: + return self._horizontalSpacing + else: + return self._smartSpacing(qt.QStyle.PM_LayoutHorizontalSpacing) + + def setVerticalSpacing(self, spacing): + """Set the vertical spacing between lines + + :param int spacing: + """ + self._verticalSpacing = spacing + self.update() + + def verticalSpacing(self): + """Returns the vertical spacing between lines + + :rtype: int + """ + if self._verticalSpacing >= 0: + return self._verticalSpacing + else: + return self._smartSpacing(qt.QStyle.PM_LayoutVerticalSpacing) + + def _smartSpacing(self, pm): + parent = self.parent() + if parent is None: + return -1 + if parent.isWidgetType(): + return parent.style().pixelMetric(pm, None, parent) + else: + return parent.spacing() diff --git a/src/silx/gui/widgets/FrameBrowser.py b/src/silx/gui/widgets/FrameBrowser.py new file mode 100644 index 0000000..671991f --- /dev/null +++ b/src/silx/gui/widgets/FrameBrowser.py @@ -0,0 +1,324 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-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 defines two main classes: + + - :class:`FrameBrowser`: a widget with 4 buttons (first, previous, next, + last) to browse between frames and a text entry to access a specific frame + by typing it's number) + - :class:`HorizontalSliderWithBrowser`: a FrameBrowser with an additional + slider. This class inherits :class:`qt.QAbstractSlider`. + +""" +from silx.gui import qt +from silx.gui import icons +from silx.utils import deprecation + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "16/01/2017" + + +class FrameBrowser(qt.QWidget): + """Frame browser widget, with 4 buttons/icons and a line edit to provide + a way of selecting a frame index in a stack of images. + + .. image:: img/FrameBrowser.png + + It can be used in more generic case to select an integer within a range. + + :param QWidget parent: Parent widget + :param int n: Number of frames. This will set the range + of frame indices to 0--n-1. + If None, the range is initialized to the default QSlider range (0--99). + """ + + sigIndexChanged = qt.pyqtSignal(object) + + def __init__(self, parent=None, n=None): + qt.QWidget.__init__(self, parent) + + # Use the font size as the icon size to avoid to create bigger buttons + fontMetric = self.fontMetrics() + iconSize = qt.QSize(fontMetric.height(), fontMetric.height()) + + self.mainLayout = qt.QHBoxLayout(self) + self.mainLayout.setContentsMargins(0, 0, 0, 0) + self.mainLayout.setSpacing(0) + self.firstButton = qt.QPushButton(self) + self.firstButton.setIcon(icons.getQIcon("first")) + self.firstButton.setIconSize(iconSize) + self.previousButton = qt.QPushButton(self) + self.previousButton.setIcon(icons.getQIcon("previous")) + self.previousButton.setIconSize(iconSize) + self._lineEdit = qt.QLineEdit(self) + + self._label = qt.QLabel(self) + self.nextButton = qt.QPushButton(self) + self.nextButton.setIcon(icons.getQIcon("next")) + self.nextButton.setIconSize(iconSize) + self.lastButton = qt.QPushButton(self) + self.lastButton.setIcon(icons.getQIcon("last")) + self.lastButton.setIconSize(iconSize) + + self.mainLayout.addWidget(self.firstButton) + self.mainLayout.addWidget(self.previousButton) + self.mainLayout.addWidget(self._lineEdit) + self.mainLayout.addWidget(self._label) + self.mainLayout.addWidget(self.nextButton) + self.mainLayout.addWidget(self.lastButton) + + if n is None: + first = qt.QSlider().minimum() + last = qt.QSlider().maximum() + else: + first, last = 0, n + + self._lineEdit.setFixedWidth(self._lineEdit.fontMetrics().boundingRect('%05d' % last).width()) + validator = qt.QIntValidator(first, last, self._lineEdit) + self._lineEdit.setValidator(validator) + self._lineEdit.setText("%d" % first) + self._label.setText("of %d" % last) + + self._index = first + """0-based index""" + + self.firstButton.clicked.connect(self._firstClicked) + self.previousButton.clicked.connect(self._previousClicked) + self.nextButton.clicked.connect(self._nextClicked) + self.lastButton.clicked.connect(self._lastClicked) + self._lineEdit.editingFinished.connect(self._textChangedSlot) + + def lineEdit(self): + """Returns the line edit provided by this widget. + + :rtype: qt.QLineEdit + """ + return self._lineEdit + + def limitWidget(self): + """Returns the widget displaying axes limits. + + :rtype: qt.QLabel + """ + return self._label + + def _firstClicked(self): + """Select first/lowest frame number""" + self.setValue(self.getRange()[0]) + + def _previousClicked(self): + """Select previous frame number""" + self.setValue(self.getValue() - 1) + + def _nextClicked(self): + """Select next frame number""" + self.setValue(self.getValue() + 1) + + def _lastClicked(self): + """Select last/highest frame number""" + self.setValue(self.getRange()[1]) + + def _textChangedSlot(self): + """Select frame number typed in the line edit widget""" + txt = self._lineEdit.text() + if not len(txt): + self._lineEdit.setText("%d" % self._index) + return + new_value = int(txt) + if new_value == self._index: + return + ddict = { + "event": "indexChanged", + "old": self._index, + "new": new_value, + "id": id(self) + } + self._index = new_value + self.sigIndexChanged.emit(ddict) + + def getRange(self): + """Returns frame range + + :return: (first_index, last_index) + """ + validator = self.lineEdit().validator() + return validator.bottom(), validator.top() + + def setRange(self, first, last): + """Set minimum and maximum frame indices. + + Initialize the frame index to *first*. + Update the label text to *" limits: first, last"* + + :param int first: Minimum frame index + :param int last: Maximum frame index""" + bottom = min(first, last) + top = max(first, last) + self._lineEdit.validator().setTop(top) + self._lineEdit.validator().setBottom(bottom) + self.setValue(bottom) + + # Update limits + self._label.setText(" limits: %d, %d " % (bottom, top)) + + @deprecation.deprecated(replacement="FrameBrowser.setRange", + since_version="0.8") + def setLimits(self, first, last): + return self.setRange(first, last) + + def setNFrames(self, nframes): + """Set minimum=0 and maximum=nframes-1 frame numbers. + + Initialize the frame index to 0. + Update the label text to *"1 of nframes"* + + :param int nframes: Number of frames""" + top = nframes - 1 + self.setRange(0, top) + # display 1-based index in label + self._label.setText(" of %d " % top) + + @deprecation.deprecated(replacement="FrameBrowser.getValue", + since_version="0.8") + def getCurrentIndex(self): + return self._index + + def getValue(self): + """Return current frame index""" + return self._index + + def setValue(self, value): + """Set 0-based frame index + + Value is clipped to current range. + + :param int value: Frame number""" + bottom = self.lineEdit().validator().bottom() + top = self.lineEdit().validator().top() + value = int(value) + + if value < bottom: + value = bottom + elif value > top: + value = top + + self._lineEdit.setText("%d" % value) + self._textChangedSlot() + + +class HorizontalSliderWithBrowser(qt.QAbstractSlider): + """ + Slider widget combining a :class:`QSlider` and a :class:`FrameBrowser`. + + .. image:: img/HorizontalSliderWithBrowser.png + + The data model is an integer within a range. + + The default value is the default :class:`QSlider` value (0), + and the default range is the default QSlider range (0 -- 99) + + The signal emitted when the value is changed is the usual QAbstractSlider + signal :attr:`valueChanged`. The signal carries the value (as an integer). + + :param QWidget parent: Optional parent widget + """ + def __init__(self, parent=None): + qt.QAbstractSlider.__init__(self, parent) + self.setOrientation(qt.Qt.Horizontal) + + self.mainLayout = qt.QHBoxLayout(self) + self.mainLayout.setContentsMargins(0, 0, 0, 0) + self.mainLayout.setSpacing(2) + + self._slider = qt.QSlider(self) + self._slider.setOrientation(qt.Qt.Horizontal) + + self._browser = FrameBrowser(self) + + self.mainLayout.addWidget(self._slider, 1) + self.mainLayout.addWidget(self._browser) + + self._slider.valueChanged[int].connect(self._sliderSlot) + self._browser.sigIndexChanged.connect(self._browserSlot) + + def lineEdit(self): + """Returns the line edit provided by this widget. + + :rtype: qt.QLineEdit + """ + return self._browser.lineEdit() + + def limitWidget(self): + """Returns the widget displaying axes limits. + + :rtype: qt.QLabel + """ + return self._browser.limitWidget() + + def setMinimum(self, value): + """Set minimum value + + :param int value: Minimum value""" + self._slider.setMinimum(value) + maximum = self._slider.maximum() + self._browser.setRange(value, maximum) + + def setMaximum(self, value): + """Set maximum value + + :param int value: Maximum value + """ + self._slider.setMaximum(value) + minimum = self._slider.minimum() + self._browser.setRange(minimum, value) + + def setRange(self, first, last): + """Set minimum/maximum values + + :param int first: Minimum value + :param int last: Maximum value""" + self._slider.setRange(first, last) + self._browser.setRange(first, last) + + def _sliderSlot(self, value): + """Emit selected value when slider is activated + """ + self._browser.setValue(value) + self.valueChanged.emit(value) + + def _browserSlot(self, ddict): + """Emit selected value when browser state is changed""" + self._slider.setValue(ddict['new']) + + def setValue(self, value): + """Set value + + :param int value: value""" + self._slider.setValue(value) + self._browser.setValue(value) + + def value(self): + """Get selected value""" + return self._slider.value() diff --git a/src/silx/gui/widgets/HierarchicalTableView.py b/src/silx/gui/widgets/HierarchicalTableView.py new file mode 100644 index 0000000..3ccf4c7 --- /dev/null +++ b/src/silx/gui/widgets/HierarchicalTableView.py @@ -0,0 +1,172 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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 define a hierarchical table view and model. + +It allows to define many headers in the middle of a table. + +The implementation hide the default header and allows to custom each cells +to became a header. + +Row and column span is a concept of the view in a QTableView. +This implementation also provide a span property as part of the model of the +cell. A role is define to custom this information. +The view is updated everytime the model is reset to take care of the +changes of this information. + +A default item delegate is used to redefine the paint of the cells. +""" +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/04/2017" + +from silx.gui import qt + + +class HierarchicalTableModel(qt.QAbstractTableModel): + """ + Abstract table model to provide more custom on row and column span and + headers. + + Default headers are ignored and each cells can define IsHeaderRole and + SpanRole using the `data` function. + """ + + SpanRole = qt.Qt.UserRole + 0 + """Role returning a tuple for number of row span then column span. + + None and (1, 1) are neutral for the rendering. + """ + + IsHeaderRole = qt.Qt.UserRole + 1 + """Role returning True is the identified cell is a header.""" + + UserRole = qt.Qt.UserRole + 2 + """First index of user defined roles""" + + def headerData(self, section, orientation, role=qt.Qt.DisplayRole): + """Returns the 0-based row or column index, for display in the + horizontal and vertical headers + + In this case the headers are just ignored. Header information is part + of each cells. + """ + return None + + +class HierarchicalItemDelegate(qt.QStyledItemDelegate): + """ + Delegate item to take care of the rendering of the default table cells and + also the header cells. + """ + + def __init__(self, parent=None): + """ + Constructor + + :param qt.QObject parent: Parent of the widget + """ + qt.QStyledItemDelegate.__init__(self, parent) + + def paint(self, painter, option, index): + """Override the paint function to inject the style of the header. + + :param qt.QPainter painter: Painter context used to displayed the cell + :param qt.QStyleOptionViewItem option: Control how the editor is shown + :param qt.QIndex index: Index of the data to display + """ + isHeader = index.data(role=HierarchicalTableModel.IsHeaderRole) + if isHeader: + span = index.data(role=HierarchicalTableModel.SpanRole) + span = 1 if span is None else span[1] + columnCount = index.model().columnCount() + if span == columnCount: + mainTitle = True + position = qt.QStyleOptionHeader.OnlyOneSection + else: + mainTitle = False + col = index.column() + if col == 0: + position = qt.QStyleOptionHeader.Beginning + elif col < columnCount - 1: + position = qt.QStyleOptionHeader.Middle + else: + position = qt.QStyleOptionHeader.End + opt = qt.QStyleOptionHeader() + opt.direction = option.direction + opt.text = index.data() + opt.textAlignment = qt.Qt.AlignCenter if mainTitle else qt.Qt.AlignVCenter + opt.direction = option.direction + opt.fontMetrics = option.fontMetrics + opt.palette = option.palette + opt.rect = option.rect + opt.state = option.state + opt.position = position + margin = -1 + style = qt.QApplication.instance().style() + opt.rect = opt.rect.adjusted(margin, margin, -margin, -margin) + style.drawControl(qt.QStyle.CE_HeaderSection, opt, painter, None) + margin = 3 + opt.rect = opt.rect.adjusted(margin, margin, -margin, -margin) + style.drawControl(qt.QStyle.CE_HeaderLabel, opt, painter, None) + else: + qt.QStyledItemDelegate.paint(self, painter, option, index) + + +class HierarchicalTableView(qt.QTableView): + """A TableView which allow to display a `HierarchicalTableModel`.""" + + def __init__(self, parent=None): + """ + Constructor + + :param qt.QWidget parent: Parent of the widget + """ + super(HierarchicalTableView, self).__init__(parent) + self.setItemDelegate(HierarchicalItemDelegate(self)) + self.verticalHeader().setVisible(False) + self.horizontalHeader().setVisible(False) + + def setModel(self, model): + """Override the default function to connect the model to update + function""" + if self.model() is not None: + model.modelReset.disconnect(self.__modelReset) + super(HierarchicalTableView, self).setModel(model) + if self.model() is not None: + model.modelReset.connect(self.__modelReset) + self.__modelReset() + + def __modelReset(self): + """Update the model to take care of the changes of the span + information""" + self.clearSpans() + model = self.model() + for row in range(model.rowCount()): + for column in range(model.columnCount()): + index = model.index(row, column, qt.QModelIndex()) + span = model.data(index, HierarchicalTableModel.SpanRole) + if span is not None and span != (1, 1): + self.setSpan(row, column, span[0], span[1]) diff --git a/src/silx/gui/widgets/LegendIconWidget.py b/src/silx/gui/widgets/LegendIconWidget.py new file mode 100755 index 0000000..1c95e41 --- /dev/null +++ b/src/silx/gui/widgets/LegendIconWidget.py @@ -0,0 +1,514 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-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. +# +# ###########################################################################*/ +"""Widget displaying a symbol (marker symbol, line style and color) to identify +an item displayed by a plot. +""" + +__authors__ = ["V.A. Sole", "T. Rueter", "T. Vincent"] +__license__ = "MIT" +__data__ = "11/11/2019" + + +import logging + +import numpy + +from .. import qt, colors + + +_logger = logging.getLogger(__name__) + + +# Build all symbols +# Courtesy of the pyqtgraph project + +_Symbols = None +""""Cache supported symbols as Qt paths""" + + +_NoSymbols = (None, 'None', 'none', '', ' ') +"""List of values resulting in no symbol being displayed for a curve""" + + +_LineStyles = { + None: qt.Qt.NoPen, + 'None': qt.Qt.NoPen, + 'none': qt.Qt.NoPen, + '': qt.Qt.NoPen, + ' ': qt.Qt.NoPen, + '-': qt.Qt.SolidLine, + '--': qt.Qt.DashLine, + ':': qt.Qt.DotLine, + '-.': qt.Qt.DashDotLine +} +"""Conversion from matplotlib-like linestyle to Qt""" + +_NoLineStyle = (None, 'None', 'none', '', ' ') +"""List of style values resulting in no line being displayed for a curve""" + + +_colormapImage = {} +"""Store cached pixmap""" +# FIXME: Could be better to use a LRU dictionary + +_COLORMAP_PIXMAP_SIZE = 32 +"""Size of the cached pixmaps for the colormaps""" + + +def _initSymbols(): + """Init the cached symbol structure if not yet done.""" + global _Symbols + if _Symbols is not None: + return + + symbols = dict([(name, qt.QPainterPath()) + for name in ['o', 's', 't', 'd', '+', 'x', '.', ',']]) + symbols['o'].addEllipse(qt.QRectF(.1, .1, .8, .8)) + symbols['.'].addEllipse(qt.QRectF(.3, .3, .4, .4)) + symbols[','].addEllipse(qt.QRectF(.4, .4, .2, .2)) + symbols['s'].addRect(qt.QRectF(.1, .1, .8, .8)) + + coords = { + 't': [(0.5, 0.), (.1, .8), (.9, .8)], + 'd': [(0.1, 0.5), (0.5, 0.), (0.9, 0.5), (0.5, 1.)], + '+': [(0.0, 0.40), (0.40, 0.40), (0.40, 0.), (0.60, 0.), + (0.60, 0.40), (1., 0.40), (1., 0.60), (0.60, 0.60), + (0.60, 1.), (0.40, 1.), (0.40, 0.60), (0., 0.60)], + 'x': [(0.0, 0.40), (0.40, 0.40), (0.40, 0.), (0.60, 0.), + (0.60, 0.40), (1., 0.40), (1., 0.60), (0.60, 0.60), + (0.60, 1.), (0.40, 1.), (0.40, 0.60), (0., 0.60)] + } + for s, c in coords.items(): + symbols[s].moveTo(*c[0]) + for x, y in c[1:]: + symbols[s].lineTo(x, y) + symbols[s].closeSubpath() + tr = qt.QTransform() + tr.rotate(45) + symbols['x'].translate(qt.QPointF(-0.5, -0.5)) + symbols['x'] = tr.map(symbols['x']) + symbols['x'].translate(qt.QPointF(0.5, 0.5)) + + _Symbols = symbols + + +class LegendIconWidget(qt.QWidget): + """Object displaying linestyle and symbol of plots. + + :param QWidget parent: See :class:`QWidget` + """ + + def __init__(self, parent=None): + super(LegendIconWidget, self).__init__(parent) + _initSymbols() + + # Visibilities + self.showLine = True + self.showSymbol = True + self.showColormap = True + + # Line attributes + self.lineStyle = qt.Qt.NoPen + self.lineWidth = 1. + self.lineColor = qt.Qt.green + + self.symbol = '' + # Symbol attributes + self.symbolStyle = qt.Qt.SolidPattern + self.symbolColor = qt.Qt.green + self.symbolOutlineBrush = qt.QBrush(qt.Qt.white) + self.symbolColormap = None + """Name or array of colors""" + + self.colormap = None + """Name or array of colors""" + + # Control widget size: sizeHint "is the only acceptable + # alternative, so the widget can never grow or shrink" + # (c.f. Qt Doc, enum QSizePolicy::Policy) + self.setSizePolicy(qt.QSizePolicy.Fixed, + qt.QSizePolicy.Fixed) + + def sizeHint(self): + return qt.QSize(50, 15) + + def setSymbol(self, symbol): + """Set the symbol""" + symbol = str(symbol) + if symbol not in _NoSymbols: + if symbol not in _Symbols: + raise ValueError("Unknown symbol: <%s>" % symbol) + self.symbol = symbol + self.update() + + def setSymbolColor(self, color): + """ + :param color: determines the symbol color + :type style: qt.QColor + """ + self.symbolColor = qt.QColor(color) + self.update() + + # Modify Line + + def setLineColor(self, color): + self.lineColor = qt.QColor(color) + self.update() + + def setLineWidth(self, width): + self.lineWidth = float(width) + self.update() + + def setLineStyle(self, style): + """Set the linestyle. + + Possible line styles: + + - '', ' ', 'None': No line + - '-': solid + - '--': dashed + - ':': dotted + - '-.': dash and dot + + :param str style: The linestyle to use + """ + if style not in _LineStyles: + raise ValueError('Unknown style: %s', style) + self.lineStyle = _LineStyles[style] + self.update() + + def _toLut(self, colormap): + """Returns an internal LUT object used by this widget to manage + a colormap LUT. + + If the argument is a `Colormap` object, only the current state will be + displayed. The object itself will not be stored, and further changes + of this `Colormap` will not update this widget. + + :param Union[str,numpy.ndarray,Colormap] colormap: The colormap to + display + :rtype: Union[None,str,numpy.ndarray] + """ + if isinstance(colormap, colors.Colormap): + # Helper to allow to support Colormap objects + c = colormap.getName() + if c is None: + c = colormap.getNColors() + colormap = c + + return colormap + + def setColormap(self, colormap): + """Set the colormap to display + + If the argument is a `Colormap` object, only the current state will be + displayed. The object itself will not be stored, and further changes + of this `Colormap` will not update this widget. + + :param Union[str,numpy.ndarray,Colormap] colormap: The colormap to + display + """ + colormap = self._toLut(colormap) + + if colormap is None: + if self.colormap is None: + return + self.colormap = None + self.update() + return + + if numpy.array_equal(self.colormap, colormap): + # This also works with strings + return + + self.colormap = colormap + self.update() + + def getColormap(self): + """Returns the used colormap. + + If the argument was set with a `Colormap` object, this function will + returns the LUT, represented by a string name or by an array or colors. + + :returns: Union[None,str,numpy.ndarray,Colormap] + """ + return self.colormap + + def setSymbolColormap(self, colormap): + """Set the colormap to display a symbol + + If the argument is a `Colormap` object, only the current state will be + displayed. The object itself will not be stored, and further changes + of this `Colormap` will not update this widget. + + :param Union[str,numpy.ndarray,Colormap] colormap: The colormap to + display + """ + colormap = self._toLut(colormap) + + if colormap is None: + if self.colormap is None: + return + self.symbolColormap = None + self.update() + return + + if numpy.array_equal(self.symbolColormap, colormap): + # This also works with strings + return + + self.symbolColormap = colormap + self.update() + + def getSymbolColormap(self): + """Returns the used symbol colormap. + + If the argument was set with a `Colormap` object, this function will + returns the LUT, represented by a string name or by an array or colors. + + :returns: Union[None,str,numpy.ndarray,Colormap] + """ + return self.colormap + + # Paint + + def paintEvent(self, event): + """ + :param event: event + :type event: QPaintEvent + """ + painter = qt.QPainter(self) + self.paint(painter, event.rect(), self.palette()) + + def paint(self, painter, rect, palette): + painter.save() + painter.setRenderHint(qt.QPainter.Antialiasing) + # Scale painter to the icon height + # current -> width = 2.5, height = 1.0 + scale = float(self.height()) + ratio = float(self.width()) / scale + symbolOffset = qt.QPointF(.5 * (ratio - 1.), 0.) + # Determine and scale offset + offset = qt.QPointF(float(rect.left()) / scale, float(rect.top()) / scale) + + # Override color when disabled + if self.isEnabled(): + overrideColor = None + else: + overrideColor = palette.color(qt.QPalette.Disabled, + qt.QPalette.WindowText) + + # Draw BG rectangle (for debugging) + # bottomRight = qt.QPointF( + # float(rect.right())/scale, + # float(rect.bottom())/scale) + # painter.fillRect(qt.QRectF(offset, bottomRight), + # qt.QBrush(qt.Qt.green)) + + if self.showColormap: + if self.colormap is not None: + if self.isEnabled(): + image = self.getColormapImage(self.colormap) + else: + image = self.getGrayedColormapImage(self.colormap) + pixmapRect = qt.QRect(0, 0, _COLORMAP_PIXMAP_SIZE, 1) + widthMargin = 0 + halfHeight = 4 + widgetRect = self.rect() + dest = qt.QRect( + widgetRect.left() + widthMargin, + widgetRect.center().y() - halfHeight + 1, + widgetRect.width() - widthMargin * 2, + halfHeight * 2, + ) + painter.drawImage(dest, image, pixmapRect) + + painter.scale(scale, scale) + + llist = [] + if self.showLine: + linePath = qt.QPainterPath() + linePath.moveTo(0., 0.5) + linePath.lineTo(ratio, 0.5) + # linePath.lineTo(2.5, 0.5) + lineBrush = qt.QBrush( + self.lineColor if overrideColor is None else overrideColor) + linePen = qt.QPen( + lineBrush, + (self.lineWidth / self.height()), + self.lineStyle, + qt.Qt.FlatCap + ) + llist.append((linePath, linePen, lineBrush)) + + isValidSymbol = (len(self.symbol) and + self.symbol not in _NoSymbols) + if self.showSymbol and isValidSymbol: + if self.symbolColormap is None: + # PITFALL ahead: Let this be a warning to others + # symbolPath = Symbols[self.symbol] + # Copy before translate! Dict is a mutable type + symbolPath = qt.QPainterPath(_Symbols[self.symbol]) + symbolPath.translate(symbolOffset) + symbolBrush = qt.QBrush( + self.symbolColor if overrideColor is None else overrideColor, + self.symbolStyle) + symbolPen = qt.QPen( + self.symbolOutlineBrush, # Brush + 1. / self.height(), # Width + qt.Qt.SolidLine # Style + ) + llist.append((symbolPath, + symbolPen, + symbolBrush)) + else: + nbSymbols = int(ratio + 2) + for i in range(nbSymbols): + if self.isEnabled(): + image = self.getColormapImage(self.symbolColormap) + else: + image = self.getGrayedColormapImage(self.symbolColormap) + pos = int((_COLORMAP_PIXMAP_SIZE / nbSymbols) * i) + pos = numpy.clip(pos, 0, _COLORMAP_PIXMAP_SIZE-1) + color = image.pixelColor(pos, 0) + delta = qt.QPointF(ratio * ((i - (nbSymbols-1)/2) / nbSymbols), 0) + + symbolPath = qt.QPainterPath(_Symbols[self.symbol]) + symbolPath.translate(symbolOffset + delta) + symbolBrush = qt.QBrush(color, self.symbolStyle) + symbolPen = qt.QPen( + self.symbolOutlineBrush, # Brush + 1. / self.height(), # Width + qt.Qt.SolidLine # Style + ) + llist.append((symbolPath, + symbolPen, + symbolBrush)) + + # Draw + for path, pen, brush in llist: + path.translate(offset) + painter.setPen(pen) + painter.setBrush(brush) + painter.drawPath(path) + + painter.restore() + + # Helpers + + @staticmethod + def isEmptySymbol(symbol): + """Returns True if this symbol description will result in an empty + symbol.""" + return symbol in _NoSymbols + + @staticmethod + def isEmptyLineStyle(lineStyle): + """Returns True if this line style description will result in an empty + line.""" + return lineStyle in _NoLineStyle + + @staticmethod + def _getColormapKey(colormap): + """ + Returns the key used to store the image in the data storage + """ + if isinstance(colormap, numpy.ndarray): + key = tuple(colormap) + else: + key = colormap + return key + + @staticmethod + def getGrayedColormapImage(colormap): + """Return a grayed version image preview from a LUT name. + + This images are cached into a global structure. + + :param Union[str,numpy.ndarray] colormap: Description of the LUT + :rtype: qt.QImage + """ + key = LegendIconWidget._getColormapKey(colormap) + grayKey = (key, "gray") + image = _colormapImage.get(grayKey, None) + if image is None: + image = LegendIconWidget.getColormapImage(colormap) + image = image.convertToFormat(qt.QImage.Format_Grayscale8) + _colormapImage[grayKey] = image + return image + + @staticmethod + def getColormapImage(colormap): + """Return an image preview from a LUT name. + + This images are cached into a global structure. + + :param Union[str,numpy.ndarray] colormap: Description of the LUT + :rtype: qt.QImage + """ + key = LegendIconWidget._getColormapKey(colormap) + image = _colormapImage.get(key, None) + if image is None: + image = LegendIconWidget.createColormapImage(colormap) + _colormapImage[key] = image + return image + + @staticmethod + def createColormapImage(colormap): + """Create and return an icon preview from a LUT name. + + This icons are cached into a global structure. + + :param Union[str,numpy.ndarray] colormap: Description of the LUT + :rtype: qt.QImage + """ + size = _COLORMAP_PIXMAP_SIZE + if isinstance(colormap, numpy.ndarray): + lut = colormap + if len(lut) > size: + # Down sample + step = int(len(lut) / size) + lut = lut[::step] + elif len(lut) < size: + # Over sample + indexes = numpy.arange(size) / float(size) * (len(lut) - 1) + indexes = indexes.astype("int") + lut = lut[indexes] + else: + colormap = colors.Colormap(colormap) + lut = colormap.getNColors(size) + + if lut is None or len(lut) == 0: + return qt.QIcon() + + pixmap = qt.QPixmap(size, 1) + painter = qt.QPainter(pixmap) + for i in range(size): + rgb = lut[i] + r, g, b = rgb[0], rgb[1], rgb[2] + painter.setPen(qt.QColor(r, g, b)) + painter.drawPoint(qt.QPoint(i, 0)) + painter.end() + return pixmap.toImage() diff --git a/src/silx/gui/widgets/MedianFilterDialog.py b/src/silx/gui/widgets/MedianFilterDialog.py new file mode 100644 index 0000000..dd4a00d --- /dev/null +++ b/src/silx/gui/widgets/MedianFilterDialog.py @@ -0,0 +1,80 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-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. +# +# ###########################################################################*/ +""" MedianFilterDialog +Classes +------- + +Widgets: + + - :class:`MedianFilterDialog` +""" + +__authors__ = ["H. Payno"] +__license__ = "MIT" +__date__ = "14/02/2017" + + +import logging + +from silx.gui import qt + + +_logger = logging.getLogger(__name__) + +class MedianFilterDialog(qt.QDialog): + """QDialog window featuring a :class:`BackgroundWidget`""" + sigFilterOptChanged = qt.Signal(int, bool) + + def __init__(self, parent=None): + qt.QDialog.__init__(self, parent) + + self.setWindowTitle("Median filter options") + self.mainLayout = qt.QHBoxLayout(self) + self.setLayout(self.mainLayout) + + # filter width GUI + self.mainLayout.addWidget(qt.QLabel('filter width:', parent = self)) + self._filterWidth = qt.QSpinBox(parent=self) + self._filterWidth.setMinimum(1) + self._filterWidth.setValue(1) + self._filterWidth.setSingleStep(2); + widthTooltip = """radius width of the pixel including in the filter + for each pixel""" + self._filterWidth.setToolTip(widthTooltip) + self._filterWidth.valueChanged.connect(self._filterOptionChanged) + self.mainLayout.addWidget(self._filterWidth) + + # filter option GUI + self._filterOption = qt.QCheckBox('conditional', parent=self) + conditionalTooltip = """if check, implement a conditional filter""" + self._filterOption.stateChanged.connect(self._filterOptionChanged) + self.mainLayout.addWidget(self._filterOption) + + def _filterOptionChanged(self): + """Call back used when the filter values are changed""" + if self._filterWidth.value()%2 == 0: + _logger.warning('median filter only accept odd values') + else: + self.sigFilterOptChanged.emit(self._filterWidth.value(), self._filterOption.isChecked())
\ No newline at end of file diff --git a/src/silx/gui/widgets/MultiModeAction.py b/src/silx/gui/widgets/MultiModeAction.py new file mode 100644 index 0000000..502275d --- /dev/null +++ b/src/silx/gui/widgets/MultiModeAction.py @@ -0,0 +1,83 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-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. +# +# ###########################################################################*/ +"""Action to hold many mode actions, usually for a tool bar. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__data__ = "22/04/2020" + + +from silx.gui import qt + + +class MultiModeAction(qt.QWidgetAction): + """This action provides a default checkable action from a list of checkable + actions. + + The default action can be selected from a drop down list. The last one used + became the default one. + + The default action is directly usable without using the drop down list. + """ + + def __init__(self, parent=None): + assert isinstance(parent, qt.QWidget) + qt.QWidgetAction.__init__(self, parent) + button = qt.QToolButton(parent) + button.setPopupMode(qt.QToolButton.MenuButtonPopup) + self.setDefaultWidget(button) + self.__button = button + + def getMenu(self): + """Returns the menu. + + :rtype: qt.QMenu + """ + button = self.__button + menu = button.menu() + if menu is None: + menu = qt.QMenu(button) + button.setMenu(menu) + return menu + + def addAction(self, action): + """Add a new action to the list. + + :param qt.QAction action: New action + """ + menu = self.getMenu() + button = self.__button + menu.addAction(action) + if button.defaultAction() is None: + button.setDefaultAction(action) + if action.isCheckable(): + action.toggled.connect(self._toggled) + + def _toggled(self, checked): + if checked: + action = self.sender() + button = self.__button + button.setDefaultAction(action) diff --git a/src/silx/gui/widgets/PeriodicTable.py b/src/silx/gui/widgets/PeriodicTable.py new file mode 100644 index 0000000..6fed109 --- /dev/null +++ b/src/silx/gui/widgets/PeriodicTable.py @@ -0,0 +1,831 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2021 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. +# +# ###########################################################################*/ +"""Periodic table widgets + +Classes +------- + +Widgets: + + - :class:`PeriodicTable` + - :class:`PeriodicList` + - :class:`PeriodicCombo` + +Data model: + + - :class:`PeriodicTableItem` + - :class:`ColoredPeriodicTableItem` + + +Example of usage +---------------- + +This example uses the widgets with the standard builtin elements list. + +.. code-block:: python + + from silx.gui import qt + from silx.gui.widgets.PeriodicTable import PeriodicTable, \ + PeriodicCombo, PeriodicList + + a = qt.QApplication([]) + + w = qt.QTabWidget() + + ptable = PeriodicTable(w, selectable=True) + pcombo = PeriodicCombo(w) + plist = PeriodicList(w) + + w.addTab(ptable, "PeriodicTable") + w.addTab(plist, "PeriodicList") + w.addTab(pcombo, "PeriodicCombo") + + ptable.setSelection(['H', 'Fe', 'Si']) + plist.setSelectedElements(['H', 'Be', 'F']) + pcombo.setSelection("Li") + + def change_list(items): + print("New list selection:", [item.symbol for item in items]) + + def change_combo(item): + print("New combo selection:", item.symbol) + + def click_table(item): + print("New table click:", item.symbol) + + def change_table(items): + print("New table selection:", [item.symbol for item in items]) + + ptable.sigElementClicked.connect(click_table) + ptable.sigSelectionChanged.connect(change_table) + plist.sigSelectionChanged.connect(change_list) + pcombo.sigSelectionChanged.connect(change_combo) + + w.show() + a.exec() + + +The second example explains how to define custom elements. + +.. code-block:: python + + from silx.gui import qt + from silx.gui.widgets.PeriodicTable import PeriodicTable, \ + PeriodicCombo, PeriodicList + from silx.gui.widgets.PeriodicTable import PeriodicTableItem + + # subclass PeriodicTableItem + class MyPeriodicTableItem(PeriodicTableItem): + "New item with added mass number and number of protons" + def __init__(self, symbol, Z, A, col, row, name, mass, + subcategory=""): + PeriodicTableItem.__init__( + self, symbol, Z, col, row, name, mass, + subcategory) + + self.A = A + "Mass number (neutrons + protons)" + + self.num_neutrons = A - Z + "Number of neutrons" + + # build your list of elements + my_elements = [MyPeriodicTableItem("H", 1, 1, 1, 1, "hydrogen", + 1.00800, "diatomic nonmetal"), + MyPeriodicTableItem("He", 2, 4, 18, 1, "helium", + 4.0030, "noble gas"), + # etc ... + ] + + app = qt.QApplication([]) + + ptable = PeriodicTable(elements=my_elements, selectable=True) + ptable.show() + + def click_table(item): + "Callback function printing the mass number of clicked element" + print("New table click, mass number:", item.A) + + ptable.sigElementClicked.connect(click_table) + app.exec() + +""" + +__authors__ = ["E. Papillon", "V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "26/01/2017" + +from collections import OrderedDict +import logging +from silx.gui import qt + +_logger = logging.getLogger(__name__) + +# Symbol Atomic Number col row name mass subcategory +_elements = [("H", 1, 1, 1, "hydrogen", 1.00800, "diatomic nonmetal"), + ("He", 2, 18, 1, "helium", 4.0030, "noble gas"), + ("Li", 3, 1, 2, "lithium", 6.94000, "alkali metal"), + ("Be", 4, 2, 2, "beryllium", 9.01200, "alkaline earth metal"), + ("B", 5, 13, 2, "boron", 10.8110, "metalloid"), + ("C", 6, 14, 2, "carbon", 12.0100, "polyatomic nonmetal"), + ("N", 7, 15, 2, "nitrogen", 14.0080, "diatomic nonmetal"), + ("O", 8, 16, 2, "oxygen", 16.0000, "diatomic nonmetal"), + ("F", 9, 17, 2, "fluorine", 19.0000, "diatomic nonmetal"), + ("Ne", 10, 18, 2, "neon", 20.1830, "noble gas"), + ("Na", 11, 1, 3, "sodium", 22.9970, "alkali metal"), + ("Mg", 12, 2, 3, "magnesium", 24.3200, "alkaline earth metal"), + ("Al", 13, 13, 3, "aluminium", 26.9700, "post transition metal"), + ("Si", 14, 14, 3, "silicon", 28.0860, "metalloid"), + ("P", 15, 15, 3, "phosphorus", 30.9750, "polyatomic nonmetal"), + ("S", 16, 16, 3, "sulphur", 32.0660, "polyatomic nonmetal"), + ("Cl", 17, 17, 3, "chlorine", 35.4570, "diatomic nonmetal"), + ("Ar", 18, 18, 3, "argon", 39.9440, "noble gas"), + ("K", 19, 1, 4, "potassium", 39.1020, "alkali metal"), + ("Ca", 20, 2, 4, "calcium", 40.0800, "alkaline earth metal"), + ("Sc", 21, 3, 4, "scandium", 44.9600, "transition metal"), + ("Ti", 22, 4, 4, "titanium", 47.9000, "transition metal"), + ("V", 23, 5, 4, "vanadium", 50.9420, "transition metal"), + ("Cr", 24, 6, 4, "chromium", 51.9960, "transition metal"), + ("Mn", 25, 7, 4, "manganese", 54.9400, "transition metal"), + ("Fe", 26, 8, 4, "iron", 55.8500, "transition metal"), + ("Co", 27, 9, 4, "cobalt", 58.9330, "transition metal"), + ("Ni", 28, 10, 4, "nickel", 58.6900, "transition metal"), + ("Cu", 29, 11, 4, "copper", 63.5400, "transition metal"), + ("Zn", 30, 12, 4, "zinc", 65.3800, "transition metal"), + ("Ga", 31, 13, 4, "gallium", 69.7200, "post transition metal"), + ("Ge", 32, 14, 4, "germanium", 72.5900, "metalloid"), + ("As", 33, 15, 4, "arsenic", 74.9200, "metalloid"), + ("Se", 34, 16, 4, "selenium", 78.9600, "polyatomic nonmetal"), + ("Br", 35, 17, 4, "bromine", 79.9200, "diatomic nonmetal"), + ("Kr", 36, 18, 4, "krypton", 83.8000, "noble gas"), + ("Rb", 37, 1, 5, "rubidium", 85.4800, "alkali metal"), + ("Sr", 38, 2, 5, "strontium", 87.6200, "alkaline earth metal"), + ("Y", 39, 3, 5, "yttrium", 88.9050, "transition metal"), + ("Zr", 40, 4, 5, "zirconium", 91.2200, "transition metal"), + ("Nb", 41, 5, 5, "niobium", 92.9060, "transition metal"), + ("Mo", 42, 6, 5, "molybdenum", 95.9500, "transition metal"), + ("Tc", 43, 7, 5, "technetium", 99.0000, "transition metal"), + ("Ru", 44, 8, 5, "ruthenium", 101.0700, "transition metal"), + ("Rh", 45, 9, 5, "rhodium", 102.9100, "transition metal"), + ("Pd", 46, 10, 5, "palladium", 106.400, "transition metal"), + ("Ag", 47, 11, 5, "silver", 107.880, "transition metal"), + ("Cd", 48, 12, 5, "cadmium", 112.410, "transition metal"), + ("In", 49, 13, 5, "indium", 114.820, "post transition metal"), + ("Sn", 50, 14, 5, "tin", 118.690, "post transition metal"), + ("Sb", 51, 15, 5, "antimony", 121.760, "metalloid"), + ("Te", 52, 16, 5, "tellurium", 127.600, "metalloid"), + ("I", 53, 17, 5, "iodine", 126.910, "diatomic nonmetal"), + ("Xe", 54, 18, 5, "xenon", 131.300, "noble gas"), + ("Cs", 55, 1, 6, "caesium", 132.910, "alkali metal"), + ("Ba", 56, 2, 6, "barium", 137.360, "alkaline earth metal"), + ("La", 57, 3, 6, "lanthanum", 138.920, "lanthanide"), + ("Ce", 58, 4, 9, "cerium", 140.130, "lanthanide"), + ("Pr", 59, 5, 9, "praseodymium", 140.920, "lanthanide"), + ("Nd", 60, 6, 9, "neodymium", 144.270, "lanthanide"), + ("Pm", 61, 7, 9, "promethium", 147.000, "lanthanide"), + ("Sm", 62, 8, 9, "samarium", 150.350, "lanthanide"), + ("Eu", 63, 9, 9, "europium", 152.000, "lanthanide"), + ("Gd", 64, 10, 9, "gadolinium", 157.260, "lanthanide"), + ("Tb", 65, 11, 9, "terbium", 158.930, "lanthanide"), + ("Dy", 66, 12, 9, "dysprosium", 162.510, "lanthanide"), + ("Ho", 67, 13, 9, "holmium", 164.940, "lanthanide"), + ("Er", 68, 14, 9, "erbium", 167.270, "lanthanide"), + ("Tm", 69, 15, 9, "thulium", 168.940, "lanthanide"), + ("Yb", 70, 16, 9, "ytterbium", 173.040, "lanthanide"), + ("Lu", 71, 17, 9, "lutetium", 174.990, "lanthanide"), + ("Hf", 72, 4, 6, "hafnium", 178.500, "transition metal"), + ("Ta", 73, 5, 6, "tantalum", 180.950, "transition metal"), + ("W", 74, 6, 6, "tungsten", 183.920, "transition metal"), + ("Re", 75, 7, 6, "rhenium", 186.200, "transition metal"), + ("Os", 76, 8, 6, "osmium", 190.200, "transition metal"), + ("Ir", 77, 9, 6, "iridium", 192.200, "transition metal"), + ("Pt", 78, 10, 6, "platinum", 195.090, "transition metal"), + ("Au", 79, 11, 6, "gold", 197.200, "transition metal"), + ("Hg", 80, 12, 6, "mercury", 200.610, "transition metal"), + ("Tl", 81, 13, 6, "thallium", 204.390, "post transition metal"), + ("Pb", 82, 14, 6, "lead", 207.210, "post transition metal"), + ("Bi", 83, 15, 6, "bismuth", 209.000, "post transition metal"), + ("Po", 84, 16, 6, "polonium", 209.000, "post transition metal"), + ("At", 85, 17, 6, "astatine", 210.000, "metalloid"), + ("Rn", 86, 18, 6, "radon", 222.000, "noble gas"), + ("Fr", 87, 1, 7, "francium", 223.000, "alkali metal"), + ("Ra", 88, 2, 7, "radium", 226.000, "alkaline earth metal"), + ("Ac", 89, 3, 7, "actinium", 227.000, "actinide"), + ("Th", 90, 4, 10, "thorium", 232.000, "actinide"), + ("Pa", 91, 5, 10, "proactinium", 231.03588, "actinide"), + ("U", 92, 6, 10, "uranium", 238.070, "actinide"), + ("Np", 93, 7, 10, "neptunium", 237.000, "actinide"), + ("Pu", 94, 8, 10, "plutonium", 239.100, "actinide"), + ("Am", 95, 9, 10, "americium", 243, "actinide"), + ("Cm", 96, 10, 10, "curium", 247, "actinide"), + ("Bk", 97, 11, 10, "berkelium", 247, "actinide"), + ("Cf", 98, 12, 10, "californium", 251, "actinide"), + ("Es", 99, 13, 10, "einsteinium", 252, "actinide"), + ("Fm", 100, 14, 10, "fermium", 257, "actinide"), + ("Md", 101, 15, 10, "mendelevium", 258, "actinide"), + ("No", 102, 16, 10, "nobelium", 259, "actinide"), + ("Lr", 103, 17, 10, "lawrencium", 262, "actinide"), + ("Rf", 104, 4, 7, "rutherfordium", 261, "transition metal"), + ("Db", 105, 5, 7, "dubnium", 262, "transition metal"), + ("Sg", 106, 6, 7, "seaborgium", 266, "transition metal"), + ("Bh", 107, 7, 7, "bohrium", 264, "transition metal"), + ("Hs", 108, 8, 7, "hassium", 269, "transition metal"), + ("Mt", 109, 9, 7, "meitnerium", 268)] + + +class PeriodicTableItem(object): + """Periodic table item, used as generic item in :class:`PeriodicTable`, + :class:`PeriodicCombo` and :class:`PeriodicList`. + + This implementation stores the minimal amount of information needed by the + widgets: + + - atomic symbol + - atomic number + - element name + - atomic mass + - column of element in periodic table + - row of element in periodic table + + You can subclass this class to add additional information. + + :param str symbol: Atomic symbol (e.g. H, He, Li...) + :param int Z: Proton number + :param int col: 1-based column index of element in periodic table + :param int row: 1-based row index of element in periodic table + :param str name: PeriodicTableItem name ("hydrogen", ...) + :param float mass: Atomic mass (gram per mol) + :param str subcategory: Subcategory, based on physical properties + (e.g. "alkali metal", "noble gas"...) + """ + def __init__(self, symbol, Z, col, row, name, mass, + subcategory=""): + self.symbol = symbol + """Atomic symbol (e.g. H, He, Li...)""" + self.Z = Z + """Atomic number (Proton number)""" + self.col = col + """1-based column index of element in periodic table""" + self.row = row + """1-based row index of element in periodic table""" + self.name = name + """PeriodicTableItem name ("hydrogen", ...)""" + self.mass = mass + """Atomic mass (gram per mol)""" + self.subcategory = subcategory + """Subcategory, based on physical properties + (e.g. "alkali metal", "noble gas"...)""" + + # pymca compatibility (elements used to be stored as a list of lists) + def __getitem__(self, idx): + if idx == 6: + _logger.warning("density not implemented in silx, returning 0.") + + ret = [self.symbol, self.Z, + self.col, self.row, + self.name, self.mass, + 0.] + return ret[idx] + + def __len__(self): + return 6 + + +class ColoredPeriodicTableItem(PeriodicTableItem): + """:class:`PeriodicTableItem` with an added :attr:`bgcolor`. + The background color can be passed as a parameter to the constructor. + If it is not specified, it will be defined based on + :attr:`subcategory`. + + :param str bgcolor: Custom background color for element in + periodic table, as a RGB string *#RRGGBB*""" + COLORS = { + "diatomic nonmetal": "#7FFF00", # chartreuse + "noble gas": "#00FFFF", # cyan + "alkali metal": "#FFE4B5", # Moccasin + "alkaline earth metal": "#FFA500", # orange + "polyatomic nonmetal": "#7FFFD4", # aquamarine + "transition metal": "#FFA07A", # light salmon + "metalloid": "#8FBC8F", # Dark Sea Green + "post transition metal": "#D3D3D3", # light gray + "lanthanide": "#FFB6C1", # light pink + "actinide": "#F08080", # Light Coral + "": "#FFFFFF" # white + } + """Dictionary defining RGB colors for each subcategory.""" + + def __init__(self, symbol, Z, col, row, name, mass, + subcategory="", bgcolor=None): + PeriodicTableItem.__init__(self, symbol, Z, col, row, name, mass, + subcategory) + + self.bgcolor = self.COLORS.get(subcategory, "#FFFFFF") + """Background color of element in the periodic table, + based on its subcategory. This should be a string of a hexadecimal + RGB code, with the format *#RRGGBB*. + If the subcategory is unknown, use white (*#FFFFFF*) + """ + + # possible custom color + if bgcolor is not None: + self.bgcolor = bgcolor + + +_defaultTableItems = [ColoredPeriodicTableItem(*info) for info in _elements] + + +class _ElementButton(qt.QPushButton): + """Atomic element button, used as a cell in the periodic table + """ + sigElementEnter = qt.pyqtSignal(object) + """Signal emitted as the cursor enters the widget""" + sigElementLeave = qt.pyqtSignal(object) + """Signal emitted as the cursor leaves the widget""" + sigElementClicked = qt.pyqtSignal(object) + """Signal emitted when the widget is clicked""" + + def __init__(self, item, parent=None): + """ + + :param parent: Parent widget + :param PeriodicTableItem item: :class:`PeriodicTableItem` object + """ + qt.QPushButton.__init__(self, parent) + + self.item = item + """:class:`PeriodicTableItem` object represented by this button""" + + self.setText(item.symbol) + self.setFlat(1) + self.setCheckable(0) + + self.setSizePolicy(qt.QSizePolicy(qt.QSizePolicy.Expanding, + qt.QSizePolicy.Expanding)) + + self.selected = False + self.current = False + + # selection colors + self.selected_color = qt.QColor(qt.Qt.yellow) + self.current_color = qt.QColor(qt.Qt.gray) + self.selected_current_color = qt.QColor(qt.Qt.darkYellow) + + # element colors + + if hasattr(item, "bgcolor"): + self.bgcolor = qt.QColor(item.bgcolor) + else: + self.bgcolor = qt.QColor("#FFFFFF") + + self.brush = qt.QBrush() + self.__setBrush() + + self.clicked.connect(self.clickedSlot) + + def sizeHint(self): + return qt.QSize(40, 40) + + def setCurrent(self, b): + """Set this element button as current. + Multiple buttons can be selected. + + :param b: boolean + """ + self.current = b + self.__setBrush() + + def isCurrent(self): + """ + :return: True if element button is current + """ + return self.current + + def isSelected(self): + """ + :return: True if element button is selected + """ + return self.selected + + def setSelected(self, b): + """Set this element button as selected. + Only a single button can be selected. + + :param b: boolean + """ + self.selected = b + self.__setBrush() + + def __setBrush(self): + """Selected cells are yellow when not current. + The current cell is dark yellow when selected or grey when not + selected. + Other cells have no bg color by default, unless specified at + instantiation (:attr:`bgcolor`)""" + palette = self.palette() + # if self.current and self.selected: + # self.brush = qt.QBrush(self.selected_current_color) + # el + if self.selected: + self.brush = qt.QBrush(self.selected_color) + # elif self.current: + # self.brush = qt.QBrush(self.current_color) + elif self.bgcolor is not None: + self.brush = qt.QBrush(self.bgcolor) + else: + self.brush = qt.QBrush() + palette.setBrush(self.backgroundRole(), + self.brush) + self.setPalette(palette) + self.update() + + def paintEvent(self, pEvent): + # get button geometry + widgGeom = self.rect() + paintGeom = qt.QRect(widgGeom.left() + 1, + widgGeom.top() + 1, + widgGeom.width() - 2, + widgGeom.height() - 2) + + # paint background color + painter = qt.QPainter(self) + if self.brush is not None: + painter.fillRect(paintGeom, self.brush) + # paint frame + pen = qt.QPen(qt.Qt.black) + pen.setWidth(1 if not self.isCurrent() else 5) + painter.setPen(pen) + painter.drawRect(paintGeom) + painter.end() + qt.QPushButton.paintEvent(self, pEvent) + + def enterEvent(self, e): + """Emit a :attr:`sigElementEnter` signal and send a + :class:`PeriodicTableItem` object""" + self.sigElementEnter.emit(self.item) + + def leaveEvent(self, e): + """Emit a :attr:`sigElementLeave` signal and send a + :class:`PeriodicTableItem` object""" + self.sigElementLeave.emit(self.item) + + def clickedSlot(self): + """Emit a :attr:`sigElementClicked` signal and send a + :class:`PeriodicTableItem` object""" + self.sigElementClicked.emit(self.item) + + +class PeriodicTable(qt.QWidget): + """Periodic Table widget + + .. image:: img/PeriodicTable.png + + The following example shows how to connect clicking to selection:: + + from silx.gui import qt + from silx.gui.widgets.PeriodicTable import PeriodicTable + app = qt.QApplication([]) + pt = PeriodicTable() + pt.sigElementClicked.connect(pt.elementToggle) + pt.show() + app.exec() + + To print all selected elements each time a new element is selected:: + + def my_slot(item): + pt.elementToggle(item) + selected_elements = pt.getSelection() + for e in selected_elements: + print(e.symbol) + + pt.sigElementClicked.connect(my_slot) + + """ + sigElementClicked = qt.pyqtSignal(object) + """When any element is clicked in the table, the widget emits + this signal and sends a :class:`PeriodicTableItem` object. + """ + + sigSelectionChanged = qt.pyqtSignal(object) + """When any element is selected/unselected in the table, the widget emits + this signal and sends a list of :class:`PeriodicTableItem` objects. + + .. note:: + + To enable selection of elements, you must set *selectable=True* + when you instantiate the widget. Alternatively, you can also connect + :attr:`sigElementClicked` to :meth:`elementToggle` manually:: + + pt = PeriodicTable() + pt.sigElementClicked.connect(pt.elementToggle) + + + :param parent: parent QWidget + :param str name: Widget window title + :param elements: List of items (:class:`PeriodicTableItem` objects) to + be represented in the table. By default, take elements from + a predefined list with minimal information (symbol, atomic number, + name, mass). + :param bool selectable: If *True*, multiple elements can be + selected by clicking with the mouse. If *False* (default), + selection is only possible with method :meth:`setSelection`. + """ + + def __init__(self, parent=None, name="PeriodicTable", elements=None, + selectable=False): + self.selectable = selectable + qt.QWidget.__init__(self, parent) + self.setWindowTitle(name) + self.gridLayout = qt.QGridLayout(self) + self.gridLayout.setContentsMargins(0, 0, 0, 0) + self.gridLayout.addItem(qt.QSpacerItem(0, 5), 7, 0) + + for idx in range(10): + self.gridLayout.setRowStretch(idx, 3) + # row 8 (above lanthanoids is empty) + self.gridLayout.setRowStretch(7, 2) + + # Element information displayed when cursor enters a cell + self.eltLabel = qt.QLabel(self) + f = self.eltLabel.font() + f.setBold(1) + self.eltLabel.setFont(f) + self.eltLabel.setAlignment(qt.Qt.AlignHCenter) + self.gridLayout.addWidget(self.eltLabel, 1, 1, 3, 10) + + self._eltCurrent = None + """Current :class:`_ElementButton` (last clicked)""" + + self._eltButtons = OrderedDict() + """Dictionary of all :class:`_ElementButton`. Keys are the symbols + ("H", "He", "Li"...)""" + + if elements is None: + elements = _defaultTableItems + # fill cells with elements + for elmt in elements: + self.__addElement(elmt) + + def __addElement(self, elmt): + """Add one :class:`_ElementButton` widget into the grid, + connect its signals to interact with the cursor""" + b = _ElementButton(elmt, self) + b.setAutoDefault(False) + + self._eltButtons[elmt.symbol] = b + self.gridLayout.addWidget(b, elmt.row, elmt.col) + + b.sigElementEnter.connect(self.elementEnter) + b.sigElementLeave.connect(self._elementLeave) + b.sigElementClicked.connect(self._elementClicked) + + def elementEnter(self, item): + """Update label with element info (e.g. "Nb(41) - niobium") + when mouse cursor hovers an element. + + :param PeriodicTableItem item: Element entered by cursor + """ + self.eltLabel.setText("%s(%d) - %s" % (item.symbol, item.Z, item.name)) + + def _elementLeave(self, item): + """Clear label when the cursor leaves the cell + + :param PeriodicTableItem item: Element left + """ + self.eltLabel.setText("") + + def _elementClicked(self, item): + """Emit :attr:`sigElementClicked`, + toggle selected state of element + + :param PeriodicTableItem item: Element clicked + """ + if self._eltCurrent is not None: + self._eltCurrent.setCurrent(False) + self._eltButtons[item.symbol].setCurrent(True) + self._eltCurrent = self._eltButtons[item.symbol] + if self.selectable: + self.elementToggle(item) + self.sigElementClicked.emit(item) + + def getSelection(self): + """Return a list of selected elements, as a list of :class:`PeriodicTableItem` + objects. + + :return: Selected items + :rtype: List[PeriodicTableItem] + """ + return [b.item for b in self._eltButtons.values() if b.isSelected()] + + def setSelection(self, symbols): + """Set selected elements. + + This causes the sigSelectionChanged signal + to be emitted, even if the selection didn't actually change. + + :param List[str] symbols: List of symbols of elements to be selected + (e.g. *["Fe", "Hg", "Li"]*) + """ + # accept list of PeriodicTableItems as input, because getSelection + # returns these objects and it makes sense to have getter and setter + # use same type of data + if isinstance(symbols[0], PeriodicTableItem): + symbols = [elmt.symbol for elmt in symbols] + + for (e, b) in self._eltButtons.items(): + b.setSelected(e in symbols) + self.sigSelectionChanged.emit(self.getSelection()) + + def setElementSelected(self, symbol, state): + """Modify *selected* status of a single element (select or unselect) + + :param str symbol: PeriodicTableItem symbol to be selected + :param bool state: *True* to select, *False* to unselect + """ + self._eltButtons[symbol].setSelected(state) + self.sigSelectionChanged.emit(self.getSelection()) + + def isElementSelected(self, symbol): + """Return *True* if element is selected, else *False* + + :param str symbol: PeriodicTableItem symbol + :return: *True* if element is selected, else *False* + """ + return self._eltButtons[symbol].isSelected() + + def elementToggle(self, item): + """Toggle selected/unselected state for element + + :param item: PeriodicTableItem object + """ + b = self._eltButtons[item.symbol] + b.setSelected(not b.isSelected()) + self.sigSelectionChanged.emit(self.getSelection()) + + +class PeriodicCombo(qt.QComboBox): + """ + Combo list with all atomic elements of the periodic table + + .. image:: img/PeriodicCombo.png + + :param bool detailed: True (default) display element symbol, Z and name. + False display only element symbol and Z. + :param elements: List of items (:class:`PeriodicTableItem` objects) to + be represented in the table. By default, take elements from + a predefined list with minimal information (symbol, atomic number, + name, mass). + """ + sigSelectionChanged = qt.pyqtSignal(object) + """Signal emitted when the selection changes. Send + :class:`PeriodicTableItem` object representing selected + element + """ + + def __init__(self, parent=None, detailed=True, elements=None): + qt.QComboBox.__init__(self, parent) + + # add all elements from global list + if elements is None: + elements = _defaultTableItems + for i, elmt in enumerate(elements): + if detailed: + txt = "%2s (%d) - %s" % (elmt.symbol, elmt.Z, elmt.name) + else: + txt = "%2s (%d)" % (elmt.symbol, elmt.Z) + self.insertItem(i, txt) + + self.currentIndexChanged[int].connect(self.__selectionChanged) + + def __selectionChanged(self, idx): + """Emit :attr:`sigSelectionChanged`""" + self.sigSelectionChanged.emit(_defaultTableItems[idx]) + + def getSelection(self): + """Get selected element + + :return: Selected element + :rtype: PeriodicTableItem + """ + return _defaultTableItems[self.currentIndex()] + + def setSelection(self, symbol): + """Set selected item in combobox by giving the atomic symbol + + :param symbol: Symbol of element to be selected + """ + # accept PeriodicTableItem for getter/setter consistency + if isinstance(symbol, PeriodicTableItem): + symbol = symbol.symbol + symblist = [elmt.symbol for elmt in _defaultTableItems] + self.setCurrentIndex(symblist.index(symbol)) + + +class PeriodicList(qt.QTreeWidget): + """List of atomic elements in a :class:`QTreeView` + + .. image:: img/PeriodicList.png + + :param QWidget parent: Parent widget + :param bool detailed: True (default) display element symbol, Z and name. + False display only element symbol and Z. + :param single: *True* for single element selection with mouse click, + *False* for multiple element selection mode. + """ + sigSelectionChanged = qt.pyqtSignal(object) + """When any element is selected/unselected in the widget, it emits + this signal and sends a list of currently selected + :class:`PeriodicTableItem` objects. + """ + + def __init__(self, parent=None, detailed=True, single=False, elements=None): + qt.QTreeWidget.__init__(self, parent) + + self.detailed = detailed + + headers = ["Z", "Symbol"] + if detailed: + headers.append("Name") + self.setColumnCount(3) + else: + self.setColumnCount(2) + self.setHeaderLabels(headers) + self.header().setStretchLastSection(False) + + self.setRootIsDecorated(0) + self.itemClicked.connect(self.__selectionChanged) + self.setSelectionMode(qt.QAbstractItemView.SingleSelection if single + else qt.QAbstractItemView.ExtendedSelection) + self.__fill_widget(elements) + self.resizeColumnToContents(0) + self.resizeColumnToContents(1) + if detailed: + self.resizeColumnToContents(2) + + def __fill_widget(self, elements): + """Fill tree widget with elements """ + if elements is None: + elements = _defaultTableItems + + self.tree_items = [] + + previous_item = None + for elmt in elements: + if previous_item is None: + item = qt.QTreeWidgetItem(self) + else: + item = qt.QTreeWidgetItem(self, previous_item) + item.setText(0, str(elmt.Z)) + item.setText(1, elmt.symbol) + if self.detailed: + item.setText(2, elmt.name) + self.tree_items.append(item) + previous_item = item + + def __selectionChanged(self, treeItem, column): + """Emit a :attr:`sigSelectionChanged` and send a list of + :class:`PeriodicTableItem` objects.""" + self.sigSelectionChanged.emit(self.getSelection()) + + def getSelection(self): + """Get a list of selected elements, as a list of :class:`PeriodicTableItem` + objects. + + :return: Selected elements + :rtype: List[PeriodicTableItem]""" + return [_defaultTableItems[idx] for idx in range(len(self.tree_items)) + if self.tree_items[idx].isSelected()] + + # setSelection is a bad name (name of a QTreeWidget method) + def setSelectedElements(self, symbolList): + """ + + :param symbolList: List of atomic symbols ["H", "He", "Li"...] + to be selected in the widget + """ + # accept PeriodicTableItem for getter/setter consistency + if isinstance(symbolList[0], PeriodicTableItem): + symbolList = [elmt.symbol for elmt in symbolList] + for idx in range(len(self.tree_items)): + self.tree_items[idx].setSelected(_defaultTableItems[idx].symbol in symbolList) diff --git a/src/silx/gui/widgets/PrintGeometryDialog.py b/src/silx/gui/widgets/PrintGeometryDialog.py new file mode 100644 index 0000000..98ff8d1 --- /dev/null +++ b/src/silx/gui/widgets/PrintGeometryDialog.py @@ -0,0 +1,222 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 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. +# +# ###########################################################################*/ + + +from silx.gui import qt +from silx.gui.widgets.FloatEdit import FloatEdit + + +class PrintGeometryWidget(qt.QWidget): + """Widget to specify the size and aspect ratio of an item + before sending it to the print preview dialog. + + Use methods :meth:`setPrintGeometry` and :meth:`getPrintGeometry` + to interact with the widget. + """ + def __init__(self, parent=None): + super(PrintGeometryWidget, self).__init__(parent) + self.mainLayout = qt.QGridLayout(self) + self.mainLayout.setContentsMargins(0, 0, 0, 0) + self.mainLayout.setSpacing(2) + hbox = qt.QWidget(self) + hboxLayout = qt.QHBoxLayout(hbox) + hboxLayout.setContentsMargins(0, 0, 0, 0) + hboxLayout.setSpacing(2) + label = qt.QLabel(self) + label.setText("Units") + label.setAlignment(qt.Qt.AlignCenter) + self._pageButton = qt.QRadioButton() + self._pageButton.setText("Page") + self._inchButton = qt.QRadioButton() + self._inchButton.setText("Inches") + self._cmButton = qt.QRadioButton() + self._cmButton.setText("Centimeters") + self._buttonGroup = qt.QButtonGroup(self) + self._buttonGroup.addButton(self._pageButton) + self._buttonGroup.addButton(self._inchButton) + self._buttonGroup.addButton(self._cmButton) + self._buttonGroup.setExclusive(True) + + # units + self.mainLayout.addWidget(label, 0, 0, 1, 4) + hboxLayout.addWidget(self._pageButton) + hboxLayout.addWidget(self._inchButton) + hboxLayout.addWidget(self._cmButton) + self.mainLayout.addWidget(hbox, 1, 0, 1, 4) + self._pageButton.setChecked(True) + + # xOffset + label = qt.QLabel(self) + label.setText("X Offset:") + self.mainLayout.addWidget(label, 2, 0) + self._xOffset = FloatEdit(self, 0.1) + self.mainLayout.addWidget(self._xOffset, 2, 1) + + # yOffset + label = qt.QLabel(self) + label.setText("Y Offset:") + self.mainLayout.addWidget(label, 2, 2) + self._yOffset = FloatEdit(self, 0.1) + self.mainLayout.addWidget(self._yOffset, 2, 3) + + # width + label = qt.QLabel(self) + label.setText("Width:") + self.mainLayout.addWidget(label, 3, 0) + self._width = FloatEdit(self, 0.9) + self.mainLayout.addWidget(self._width, 3, 1) + + # height + label = qt.QLabel(self) + label.setText("Height:") + self.mainLayout.addWidget(label, 3, 2) + self._height = FloatEdit(self, 0.9) + self.mainLayout.addWidget(self._height, 3, 3) + + # aspect ratio + self._aspect = qt.QCheckBox(self) + self._aspect.setText("Keep screen aspect ratio") + self._aspect.setChecked(True) + self.mainLayout.addWidget(self._aspect, 4, 1, 1, 2) + + def getPrintGeometry(self): + """Return the print geometry dictionary. + + See :meth:`setPrintGeometry` for documentation about the + print geometry dictionary.""" + ddict = {} + if self._inchButton.isChecked(): + ddict['units'] = "inches" + elif self._cmButton.isChecked(): + ddict['units'] = "centimeters" + else: + ddict['units'] = "page" + + ddict['xOffset'] = self._xOffset.value() + ddict['yOffset'] = self._yOffset.value() + ddict['width'] = self._width.value() + ddict['height'] = self._height.value() + + if self._aspect.isChecked(): + ddict['keepAspectRatio'] = True + else: + ddict['keepAspectRatio'] = False + return ddict + + def setPrintGeometry(self, geometry=None): + """Set the print geometry. + + The geometry parameters must be provided as a dictionary with + the following keys: + + - *"xOffset"* (float) + - *"yOffset"* (float) + - *"width"* (float) + - *"height"* (float) + - *"units"*: possible values *"page", "inch", "cm"* + - *"keepAspectRatio"*: *True* or *False* + + If *units* is *"page"*, the values should be floats in [0, 1.] + and are interpreted as a fraction of the page width or height. + + :param dict geometry: Geometry parameters, as a dictionary.""" + if geometry is None: + geometry = {} + oldDict = self.getPrintGeometry() + for key in ["units", "xOffset", "yOffset", + "width", "height", "keepAspectRatio"]: + geometry[key] = geometry.get(key, oldDict[key]) + + if geometry['units'].lower().startswith("inc"): + self._inchButton.setChecked(True) + elif geometry['units'].lower().startswith("c"): + self._cmButton.setChecked(True) + else: + self._pageButton.setChecked(True) + + self._xOffset.setText("%s" % float(geometry['xOffset'])) + self._yOffset.setText("%s" % float(geometry['yOffset'])) + self._width.setText("%s" % float(geometry['width'])) + self._height.setText("%s" % float(geometry['height'])) + if geometry['keepAspectRatio']: + self._aspect.setChecked(True) + else: + self._aspect.setChecked(False) + + +class PrintGeometryDialog(qt.QDialog): + """Dialog embedding a :class:`PrintGeometryWidget`. + + Use methods :meth:`setPrintGeometry` and :meth:`getPrintGeometry` + to interact with the widget. + + Execute method :meth:`exec` to run the dialog. + The return value of that method is *True* if the geometry was set + (*Ok* button clicked) or *False* if the user clicked the *Cancel* + button. + """ + + def __init__(self, parent=None): + qt.QDialog.__init__(self, parent) + self.setWindowTitle("Set print size preferences") + layout = qt.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.configurationWidget = PrintGeometryWidget(self) + hbox = qt.QWidget(self) + hboxLayout = qt.QHBoxLayout(hbox) + self.okButton = qt.QPushButton(hbox) + self.okButton.setText("Accept") + self.okButton.setAutoDefault(False) + self.rejectButton = qt.QPushButton(hbox) + self.rejectButton.setText("Dismiss") + self.rejectButton.setAutoDefault(False) + self.okButton.clicked.connect(self.accept) + self.rejectButton.clicked.connect(self.reject) + hboxLayout.setContentsMargins(0, 0, 0, 0) + hboxLayout.setSpacing(2) + # hboxLayout.addWidget(qt.HorizontalSpacer(hbox)) + hboxLayout.addWidget(self.okButton) + hboxLayout.addWidget(self.rejectButton) + # hboxLayout.addWidget(qt.HorizontalSpacer(hbox)) + layout.addWidget(self.configurationWidget) + layout.addWidget(hbox) + + def setPrintGeometry(self, geometry): + """Return the print geometry dictionary. + + See :meth:`PrintGeometryWidget.setPrintGeometry` for documentation on + print geometry dictionary. + + :param dict geometry: Print geometry parameters dictionary. + """ + self.configurationWidget.setPrintGeometry(geometry) + + def getPrintGeometry(self): + """Return the print geometry dictionary. + + See :meth:`PrintGeometryWidget.setPrintGeometry` for documentation on + print geometry dictionary.""" + return self.configurationWidget.getPrintGeometry() diff --git a/src/silx/gui/widgets/PrintPreview.py b/src/silx/gui/widgets/PrintPreview.py new file mode 100644 index 0000000..53e0a1f --- /dev/null +++ b/src/silx/gui/widgets/PrintPreview.py @@ -0,0 +1,697 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2021 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 implements a print preview dialog. + +The dialog provides methods to send images, pixmaps and SVG +items to the page to be printed. + +The user can interactively move and resize the items. +""" +import sys +import logging +from silx.gui import qt, printer + + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "11/07/2017" + + +_logger = logging.getLogger(__name__) + + +class PrintPreviewDialog(qt.QDialog): + """Print preview dialog widget. + """ + def __init__(self, parent=None, printer=None): + + qt.QDialog.__init__(self, parent) + self.setWindowTitle("Print Preview") + self.setModal(False) + self.resize(400, 500) + + self.mainLayout = qt.QVBoxLayout(self) + self.mainLayout.setContentsMargins(0, 0, 0, 0) + self.mainLayout.setSpacing(0) + + self._buildToolbar() + + self.printer = printer + # :class:`QPrinter` (paint device that paints on a printer). + # :meth:`showEvent` has been reimplemented to enforce printer + # setup. + + self.printDialog = None + # :class:`QPrintDialog` (dialog for specifying the printer's + # configuration) + + self.scene = None + # :class:`QGraphicsScene` (surface for managing + # 2D graphical items) + + self.page = None + # :class:`QGraphicsRectItem` used as white background page on which + # to display the print preview. + + self.view = None + # :class:`QGraphicsView` widget for displaying :attr:`scene` + + self._svgItems = [] + # List storing :class:`QSvgRenderer` items to be printed, added in + # :meth:`addSvgItem`, cleared in :meth:`_clearAll`. + # This ensures that there is a reference pointing to the items, + # which ensures they are not destroyed before being printed. + + self._viewScale = 1.0 + # Zoom level (1.0 is 100%) + + self._toBeCleared = False + # Flag indicating that all items must be removed from :attr:`scene` + # and from :attr:`_svgItems`. + # Set to True after a successful printing. The widget is then hidden, + # and it will be cleared the next time it is shown. + # Reset to False after :meth:`_clearAll` has done its job. + + def _buildToolbar(self): + toolBar = qt.QWidget(self) + # a layout for the toolbar + toolsLayout = qt.QHBoxLayout(toolBar) + toolsLayout.setContentsMargins(0, 0, 0, 0) + toolsLayout.setSpacing(0) + + hideBut = qt.QPushButton("Hide", toolBar) + hideBut.setToolTip("Hide print preview dialog") + hideBut.clicked.connect(self.hide) + + cancelBut = qt.QPushButton("Clear All", toolBar) + cancelBut.setToolTip("Remove all items") + cancelBut.clicked.connect(self._clearAll) + + removeBut = qt.QPushButton("Remove", + toolBar) + removeBut.setToolTip("Remove selected item (use left click to select)") + removeBut.clicked.connect(self._remove) + + setupBut = qt.QPushButton("Setup", toolBar) + setupBut.setToolTip("Select and configure a printer") + setupBut.clicked.connect(self.setup) + + printBut = qt.QPushButton("Print", toolBar) + printBut.setToolTip("Print page and close print preview") + printBut.clicked.connect(self._print) + + zoomPlusBut = qt.QPushButton("Zoom +", toolBar) + zoomPlusBut.clicked.connect(self._zoomPlus) + + zoomMinusBut = qt.QPushButton("Zoom -", toolBar) + zoomMinusBut.clicked.connect(self._zoomMinus) + + toolsLayout.addWidget(hideBut) + toolsLayout.addWidget(printBut) + toolsLayout.addWidget(cancelBut) + toolsLayout.addWidget(removeBut) + toolsLayout.addWidget(setupBut) + # toolsLayout.addStretch() + # toolsLayout.addWidget(marginLabel) + # toolsLayout.addWidget(self.marginSpin) + toolsLayout.addStretch() + # toolsLayout.addWidget(scaleLabel) + # toolsLayout.addWidget(self.scaleCombo) + toolsLayout.addWidget(zoomPlusBut) + toolsLayout.addWidget(zoomMinusBut) + # toolsLayout.addStretch() + self.toolBar = toolBar + self.mainLayout.addWidget(self.toolBar) + + def _buildStatusBar(self): + """Create the status bar used to display the printer name + or output file name.""" + # status bar + statusBar = qt.QStatusBar(self) + self.targetLabel = qt.QLabel(statusBar) + self._updateTargetLabel() + statusBar.addWidget(self.targetLabel) + self.mainLayout.addWidget(statusBar) + + def _updateTargetLabel(self): + """Update printer name or file name shown in the status bar.""" + if self.printer is None: + self.targetLabel.setText("Undefined printer") + return + if self.printer.outputFileName(): + self.targetLabel.setText("File:" + + self.printer.outputFileName()) + else: + self.targetLabel.setText("Printer:" + + self.printer.printerName()) + + def _updatePrinter(self): + """Resize :attr:`page`, :attr:`scene` and :attr:`view` to :attr:`printer` + width and height.""" + printer = self.printer + assert printer is not None, \ + "_updatePrinter should not be called unless a printer is defined" + if self.scene is None: + self.scene = qt.QGraphicsScene() + self.scene.setBackgroundBrush(qt.QColor(qt.Qt.lightGray)) + self.scene.setSceneRect(qt.QRectF(0, 0, printer.width(), printer.height())) + + if self.page is None: + self.page = qt.QGraphicsRectItem(0, 0, printer.width(), printer.height()) + self.page.setBrush(qt.QColor(qt.Qt.white)) + self.scene.addItem(self.page) + + self.scene.setSceneRect(qt.QRectF(0, 0, printer.width(), printer.height())) + self.page.setPos(qt.QPointF(0.0, 0.0)) + self.page.setRect(qt.QRectF(0, 0, printer.width(), printer.height())) + + if self.view is None: + self.view = qt.QGraphicsView(self.scene) + self.mainLayout.addWidget(self.view) + self._buildStatusBar() + # self.view.scale(1./self._viewScale, 1./self._viewScale) + self.view.fitInView(self.page.rect(), qt.Qt.KeepAspectRatio) + self._viewScale = 1.00 + self._updateTargetLabel() + + # Public methods + def addImage(self, image, title=None, comment=None, commentPosition=None): + """Add an image to the print preview scene. + + :param QImage image: Image to be added to the scene + :param str title: Title shown above (centered) the image + :param str comment: Comment displayed below the image + :param commentPosition: "CENTER" or "LEFT" + """ + self.addPixmap(qt.QPixmap.fromImage(image), + title=title, comment=comment, + commentPosition=commentPosition) + + def addPixmap(self, pixmap, title=None, comment=None, commentPosition=None): + """Add a pixmap to the print preview scene + + :param QPixmap pixmap: Pixmap to be added to the scene + :param str title: Title shown above (centered) the pixmap + :param str comment: Comment displayed below the pixmap + :param commentPosition: "CENTER" or "LEFT" + """ + if self._toBeCleared: + self._clearAll() + self.ensurePrinterIsSet() + if self.printer is None: + _logger.error("printer is not set, cannot add pixmap to page") + return + if title is None: + title = ' ' * 88 + if comment is None: + comment = ' ' * 88 + if commentPosition is None: + commentPosition = "CENTER" + rectItem = qt.QGraphicsRectItem(self.page) + rectItem.setRect(qt.QRectF(1, 1, + pixmap.width(), pixmap.height())) + + pen = rectItem.pen() + color = qt.QColor(qt.Qt.red) + color.setAlpha(1) + pen.setColor(color) + rectItem.setPen(pen) + rectItem.setZValue(1) + rectItem.setFlag(qt.QGraphicsItem.ItemIsSelectable, True) + rectItem.setFlag(qt.QGraphicsItem.ItemIsMovable, True) + rectItem.setFlag(qt.QGraphicsItem.ItemIsFocusable, False) + + rectItemResizeRect = _GraphicsResizeRectItem(rectItem, self.scene) + rectItemResizeRect.setZValue(2) + + pixmapItem = qt.QGraphicsPixmapItem(rectItem) + pixmapItem.setPixmap(pixmap) + pixmapItem.setZValue(0) + + # I add the title + textItem = qt.QGraphicsTextItem(title, rectItem) + textItem.setTextInteractionFlags(qt.Qt.TextEditorInteraction) + offset = 0.5 * textItem.boundingRect().width() + textItem.moveBy(0.5 * pixmap.width() - offset, -20) + textItem.setZValue(2) + + # I add the comment + commentItem = qt.QGraphicsTextItem(comment, rectItem) + commentItem.setTextInteractionFlags(qt.Qt.TextEditorInteraction) + offset = 0.5 * commentItem.boundingRect().width() + if commentPosition.upper() == "LEFT": + x = 1 + else: + x = 0.5 * pixmap.width() - offset + commentItem.moveBy(x, pixmap.height() + 20) + commentItem.setZValue(2) + + rectItem.moveBy(20, 40) + + def addSvgItem(self, item, title=None, + comment=None, commentPosition=None, + viewBox=None, keepRatio=True): + """Add a SVG item to the scene. + + :param QSvgRenderer item: SVG item to be added to the scene. + :param str title: Title shown above (centered) the SVG item. + :param str comment: Comment displayed below the SVG item. + :param str commentPosition: "CENTER" or "LEFT" + :param QRectF viewBox: Bounding box for the item on the print page + (xOffset, yOffset, width, height). If None, use original + item size. + :param bool keepRatio: If True, resizing the item will preserve its + original aspect ratio. + """ + if not qt.HAS_SVG: + raise RuntimeError("Missing QtSvg library.") + if not isinstance(item, qt.QSvgRenderer): + raise TypeError("addSvgItem: QSvgRenderer expected") + if self._toBeCleared: + self._clearAll() + self.ensurePrinterIsSet() + if self.printer is None: + _logger.error("printer is not set, cannot add SvgItem to page") + return + + if title is None: + title = 50 * ' ' + if comment is None: + comment = 80 * ' ' + if commentPosition is None: + commentPosition = "CENTER" + + if viewBox is None: + if hasattr(item, "_viewBox"): + # PyMca compatibility: viewbox attached to item + viewBox = item._viewBox + else: + # try the original item viewbox + viewBox = item.viewBoxF() + + svgItem = _GraphicsSvgRectItem(viewBox, self.page) + svgItem.setSvgRenderer(item) + + svgItem.setCacheMode(qt.QGraphicsItem.NoCache) + svgItem.setZValue(0) + svgItem.setFlag(qt.QGraphicsItem.ItemIsSelectable, True) + svgItem.setFlag(qt.QGraphicsItem.ItemIsMovable, True) + svgItem.setFlag(qt.QGraphicsItem.ItemIsFocusable, False) + + rectItemResizeRect = _GraphicsResizeRectItem(svgItem, self.scene, + keepratio=keepRatio) + rectItemResizeRect.setZValue(2) + + self._svgItems.append(item) + + # Comment / legend + dummyComment = 80 * "1" + commentItem = qt.QGraphicsTextItem(dummyComment, svgItem) + commentItem.setTextInteractionFlags(qt.Qt.TextEditorInteraction) + # we scale the text to have the legend box have the same width as the graph + scaleCalculationRect = qt.QRectF(commentItem.boundingRect()) + scale = svgItem.boundingRect().width() / scaleCalculationRect.width() + + commentItem.setPlainText(comment) + commentItem.setZValue(1) + + commentItem.setFlag(qt.QGraphicsItem.ItemIsMovable, True) + commentItem.setScale(scale) + + # align + if commentPosition.upper() == "CENTER": + alignment = qt.Qt.AlignCenter + elif commentPosition.upper() == "RIGHT": + alignment = qt.Qt.AlignRight + else: + alignment = qt.Qt.AlignLeft + commentItem.setTextWidth(commentItem.boundingRect().width()) + center_format = qt.QTextBlockFormat() + center_format.setAlignment(alignment) + cursor = commentItem.textCursor() + cursor.select(qt.QTextCursor.Document) + cursor.mergeBlockFormat(center_format) + cursor.clearSelection() + commentItem.setTextCursor(cursor) + if alignment == qt.Qt.AlignLeft: + deltax = 0 + else: + deltax = (svgItem.boundingRect().width() - commentItem.boundingRect().width()) / 2. + commentItem.moveBy(svgItem.boundingRect().x() + deltax, + svgItem.boundingRect().y() + svgItem.boundingRect().height()) + + # Title + textItem = qt.QGraphicsTextItem(title, svgItem) + textItem.setTextInteractionFlags(qt.Qt.TextEditorInteraction) + textItem.setZValue(1) + textItem.setFlag(qt.QGraphicsItem.ItemIsMovable, True) + + title_offset = 0.5 * textItem.boundingRect().width() + textItem.moveBy(svgItem.boundingRect().x() + + 0.5 * svgItem.boundingRect().width() - title_offset * scale, + svgItem.boundingRect().y()) + textItem.setScale(scale) + + def setup(self): + """Open a print dialog to ensure the :attr:`printer` is set. + + If the setting fails or is cancelled, :attr:`printer` is reset to + *None*. + """ + if self.printer is None: + self.printer = printer.getDefaultPrinter() + if self.printDialog is None: + self.printDialog = qt.QPrintDialog(self.printer, self) + if self.printDialog.exec(): + if self.printer.width() <= 0 or self.printer.height() <= 0: + self.message = qt.QMessageBox(self) + self.message.setIcon(qt.QMessageBox.Critical) + self.message.setText("Unknown library error \non printer initialization") + self.message.setWindowTitle("Library Error") + self.message.setModal(0) + self.printer = None + return + self.printer.setFullPage(True) + self._updatePrinter() + else: + # printer setup cancelled, check for a possible previous configuration + if self.page is None: + # not initialized + self.printer = None + + def ensurePrinterIsSet(self): + """If the printer is not already set, try to interactively + setup the printer using a QPrintDialog. + In case of failure, hide widget and log a warning. + + :return: True if printer was set. False if it failed or if the + selection dialog was canceled. + """ + if self.printer is None: + self.setup() + if self.printer is None: + self.hide() + _logger.warning("Printer setup failed or was cancelled, " + + "but printer is required.") + return self.printer is not None + + def setOutputFileName(self, name): + """Set output filename. + + Setting a non-empty name enables printing to file. + + :param str name: File name (path)""" + self.printer.setOutputFileName(name) + + # overloaded methods + def exec(self): + if self._toBeCleared: + self._clearAll() + return qt.QDialog.exec(self) + + def exec_(self): # Qt5 compatibility + return self.exec() + + def raise_(self): + if self._toBeCleared: + self._clearAll() + return qt.QDialog.raise_(self) + + def showEvent(self, event): + """Reimplemented to force printer setup. + In case of failure, hide the widget.""" + if self._toBeCleared: + self._clearAll() + self.ensurePrinterIsSet() + + return super(PrintPreviewDialog, self).showEvent(event) + + # button callbacks + def _print(self): + """Do the printing, hide the print preview dialog, + set :attr:`_toBeCleared` flag to True to trigger clearing the + next time the dialog is shown. + + If the printer is not setup, do it first.""" + printer = self.printer + + painter = qt.QPainter() + if not painter.begin(printer) or printer is None: + _logger.error("Cannot initialize printer") + return + try: + self.scene.render(painter, qt.QRectF(0, 0, printer.width(), printer.height()), + qt.QRectF(self.page.rect().x(), self.page.rect().y(), + self.page.rect().width(), self.page.rect().height()), + qt.Qt.KeepAspectRatio) + painter.end() + self.hide() + self.accept() + self._toBeCleared = True + except: # FIXME + painter.end() + qt.QMessageBox.critical(self, "ERROR", + 'Printing problem:\n %s' % sys.exc_info()[1]) + _logger.error('printing problem:\n %s' % sys.exc_info()[1]) + return + + def _zoomPlus(self): + self._viewScale *= 1.20 + self.view.scale(1.20, 1.20) + + def _zoomMinus(self): + self._viewScale *= 0.80 + self.view.scale(0.80, 0.80) + + def _clearAll(self): + """ + Clear the print preview window, remove all items + but keep the page. + """ + itemlist = self.scene.items() + keep = self.page + while len(itemlist) != 1: + if itemlist.index(keep) == 0: + self.scene.removeItem(itemlist[1]) + else: + self.scene.removeItem(itemlist[0]) + itemlist = self.scene.items() + self._svgItems = [] + self._toBeCleared = False + + def _remove(self): + """Remove selected item in :attr:`scene`. + """ + itemlist = self.scene.items() + + # this loop is not efficient if there are many items ... + for item in itemlist: + if item.isSelected(): + self.scene.removeItem(item) + + +class SingletonPrintPreviewDialog(PrintPreviewDialog): + """Singleton print preview dialog. + + All widgets in a program that instantiate this class will share + a single print preview dialog. This enables sending + multiple images to a single page to be printed. + """ + _instance = None + + def __new__(self, *var, **kw): + if self._instance is None: + self._instance = PrintPreviewDialog(*var, **kw) + return self._instance + + +class _GraphicsSvgRectItem(qt.QGraphicsRectItem): + """:class:`qt.QGraphicsRectItem` with an attached + :class:`qt.QSvgRenderer`, and with a painter redefined to render + the SVG item.""" + def setSvgRenderer(self, renderer): + """ + + :param QSvgRenderer renderer: svg renderer + """ + self._renderer = renderer + + def paint(self, painter, *var, **kw): + self._renderer.render(painter, self.boundingRect()) + + +class _GraphicsResizeRectItem(qt.QGraphicsRectItem): + """Resizable QGraphicsRectItem.""" + def __init__(self, parent=None, scene=None, keepratio=True): + qt.QGraphicsRectItem.__init__(self, parent) + rect = parent.boundingRect() + x = rect.x() + y = rect.y() + w = rect.width() + h = rect.height() + self._newRect = None + self.keepRatio = keepratio + self.setRect(qt.QRectF(x + w - 40, y + h - 40, 40, 40)) + self.setAcceptHoverEvents(True) + pen = qt.QPen() + color = qt.QColor(qt.Qt.white) + color.setAlpha(0) + pen.setColor(color) + pen.setStyle(qt.Qt.NoPen) + self.setPen(pen) + self.setBrush(color) + self.setFlag(self.ItemIsMovable, True) + self.show() + + def hoverEnterEvent(self, event): + if self.parentItem().isSelected(): + self.parentItem().setSelected(False) + if self.keepRatio: + self.setCursor(qt.QCursor(qt.Qt.SizeFDiagCursor)) + else: + self.setCursor(qt.QCursor(qt.Qt.SizeAllCursor)) + self.setBrush(qt.QBrush(qt.Qt.yellow, qt.Qt.SolidPattern)) + return qt.QGraphicsRectItem.hoverEnterEvent(self, event) + + def hoverLeaveEvent(self, event): + self.setCursor(qt.QCursor(qt.Qt.ArrowCursor)) + pen = qt.QPen() + color = qt.QColor(qt.Qt.white) + color.setAlpha(0) + pen.setColor(color) + pen.setStyle(qt.Qt.NoPen) + self.setPen(pen) + self.setBrush(color) + return qt.QGraphicsRectItem.hoverLeaveEvent(self, event) + + def mousePressEvent(self, event): + if self._newRect is not None: + self._newRect = None + self._point0 = self.pos() + parent = self.parentItem() + scene = self.scene() + # following line prevents dragging along the previously selected + # item when resizing another one + scene.clearSelection() + + rect = parent.boundingRect() + self._x = rect.x() + self._y = rect.y() + self._w = rect.width() + self._h = rect.height() + self._ratio = self._w / self._h + self._newRect = qt.QGraphicsRectItem(parent) + self._newRect.setRect(qt.QRectF(self._x, + self._y, + self._w, + self._h)) + qt.QGraphicsRectItem.mousePressEvent(self, event) + + def mouseMoveEvent(self, event): + point1 = self.pos() + deltax = point1.x() - self._point0.x() + deltay = point1.y() - self._point0.y() + if self.keepRatio: + r1 = (self._w + deltax) / self._w + r2 = (self._h + deltay) / self._h + if r1 < r2: + self._newRect.setRect(qt.QRectF(self._x, + self._y, + self._w + deltax, + (self._w + deltax) / self._ratio)) + else: + self._newRect.setRect(qt.QRectF(self._x, + self._y, + (self._h + deltay) * self._ratio, + self._h + deltay)) + else: + self._newRect.setRect(qt.QRectF(self._x, + self._y, + self._w + deltax, + self._h + deltay)) + qt.QGraphicsRectItem.mouseMoveEvent(self, event) + + def mouseReleaseEvent(self, event): + point1 = self.pos() + deltax = point1.x() - self._point0.x() + deltay = point1.y() - self._point0.y() + self.moveBy(-deltax, -deltay) + parent = self.parentItem() + + # deduce scale from rectangle + if self.keepRatio: + scalex = self._newRect.rect().width() / self._w + scaley = scalex + else: + scalex = self._newRect.rect().width() / self._w + scaley = self._newRect.rect().height() / self._h + + # apply the scale to the previous transformation matrix + previousTransform = parent.transform() + parent.setTransform( + previousTransform.scale(scalex, scaley)) + + self.scene().removeItem(self._newRect) + self._newRect = None + qt.QGraphicsRectItem.mouseReleaseEvent(self, event) + + +def main(): + """ + """ + if len(sys.argv) < 2: + print("give an image file as parameter please.") + sys.exit(1) + + if len(sys.argv) > 2: + print("only one parameter please.") + sys.exit(1) + + filename = sys.argv[1] + w = PrintPreviewDialog() + w.resize(400, 500) + + comment = "" + for i in range(20): + comment += "Line number %d: En un lugar de La Mancha de cuyo nombre ...\n" % i + + if filename[-3:] == "svg": + item = qt.QSvgRenderer(filename, w.page) + w.addSvgItem(item, title=filename, + comment=comment, commentPosition="CENTER") + else: + w.addPixmap(qt.QPixmap.fromImage(qt.QImage(filename)), + title=filename, + comment=comment, + commentPosition="CENTER") + w.addImage(qt.QImage(filename), comment=comment, commentPosition="LEFT") + + sys.exit(w.exec()) + + +if __name__ == '__main__': + a = qt.QApplication(sys.argv) + main() + a.exec() diff --git a/src/silx/gui/widgets/RangeSlider.py b/src/silx/gui/widgets/RangeSlider.py new file mode 100644 index 0000000..61b73fc --- /dev/null +++ b/src/silx/gui/widgets/RangeSlider.py @@ -0,0 +1,776 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2021 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 + + +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 + + +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) + + 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) + + 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: + :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() + + 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: + :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() + + 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: + :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() + + 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[~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 + + 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: + 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) + + 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 + 1, + 0) + + def __pixMapRect(self): + return self.__sliderAreaRect().adjusted(0, + self._PIXMAP_VOFFSET, + -1, + -self._PIXMAP_VOFFSET) + + 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) + + def sizeHint(self): + return qt.QSize(200, self.minimumHeight()) + + @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() diff --git a/src/silx/gui/widgets/TableWidget.py b/src/silx/gui/widgets/TableWidget.py new file mode 100644 index 0000000..50eb9e2 --- /dev/null +++ b/src/silx/gui/widgets/TableWidget.py @@ -0,0 +1,626 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2021 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 table widgets handling cut, copy and paste for +multiple cell selections. These actions can be triggered using keyboard +shortcuts or through a context menu (right-click). + +:class:`TableView` is a subclass of :class:`QTableView`. The added features +are made available to users after a model is added to the widget, using +:meth:`TableView.setModel`. + +:class:`TableWidget` is a subclass of :class:`qt.QTableWidget`, a table view +with a built-in standard data model. The added features are available as soon as +the widget is initialized. + +The cut, copy and paste actions are implemented as QActions: + + - :class:`CopySelectedCellsAction` (*Ctrl+C*) + - :class:`CopyAllCellsAction` + - :class:`CutSelectedCellsAction` (*Ctrl+X*) + - :class:`CutAllCellsAction` + - :class:`PasteCellsAction` (*Ctrl+V*) + +The copy actions are enabled by default. The cut and paste actions must be +explicitly enabled, by passing parameters ``cut=True, paste=True`` when +creating the widgets, or later by calling their :meth:`enableCut` and +:meth:`enablePaste` methods. +""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "03/07/2017" + + +import sys +from .. import qt + + +if sys.platform.startswith("win"): + row_separator = "\r\n" +else: + row_separator = "\n" + +col_separator = "\t" + + +class CopySelectedCellsAction(qt.QAction): + """QAction to copy text from selected cells in a :class:`QTableWidget` + into the clipboard. + + If multiple cells are selected, the copied text will be a concatenation + of the texts in all selected cells, tabulated with tabulation and + newline characters. + + If the cells are sparsely selected, the structure is preserved by + representing the unselected cells as empty strings in between two + tabulation characters. + Beware of pasting this data in another table widget, because depending + on how the paste is implemented, the empty cells may cause data in the + target table to be deleted, even though you didn't necessarily select the + corresponding cell in the origin table. + + :param table: :class:`QTableView` to which this action belongs. + """ + def __init__(self, table): + if not isinstance(table, qt.QTableView): + raise ValueError('CopySelectedCellsAction must be initialised ' + + 'with a QTableWidget.') + super(CopySelectedCellsAction, self).__init__(table) + self.setText("Copy selection") + self.setToolTip("Copy selected cells into the clipboard.") + self.setShortcut(qt.QKeySequence.Copy) + self.setShortcutContext(qt.Qt.WidgetShortcut) + self.triggered.connect(self.copyCellsToClipboard) + self.table = table + self.cut = False + """:attr:`cut` can be set to True by classes inheriting this action, + to do a cut action.""" + + def copyCellsToClipboard(self): + """Concatenate the text content of all selected cells into a string + using tabulations and newlines to keep the table structure. + Put this text into the clipboard. + """ + selected_idx = self.table.selectedIndexes() + if not selected_idx: + return + selected_idx_tuples = [(idx.row(), idx.column()) for idx in selected_idx] + + selected_rows = [idx[0] for idx in selected_idx_tuples] + selected_columns = [idx[1] for idx in selected_idx_tuples] + + data_model = self.table.model() + + copied_text = "" + for row in range(min(selected_rows), max(selected_rows) + 1): + for col in range(min(selected_columns), max(selected_columns) + 1): + index = data_model.index(row, col) + cell_text = data_model.data(index) + flags = data_model.flags(index) + + if (row, col) in selected_idx_tuples and cell_text is not None: + copied_text += cell_text + if self.cut and (flags & qt.Qt.ItemIsEditable): + data_model.setData(index, "") + copied_text += col_separator + # remove the right-most tabulation + copied_text = copied_text[:-len(col_separator)] + # add a newline + copied_text += row_separator + # remove final newline + copied_text = copied_text[:-len(row_separator)] + + # put this text into clipboard + qapp = qt.QApplication.instance() + qapp.clipboard().setText(copied_text) + + +class CopyAllCellsAction(qt.QAction): + """QAction to copy text from all cells in a :class:`QTableWidget` + into the clipboard. + + The copied text will be a concatenation + of the texts in all cells, tabulated with tabulation and + newline characters. + + :param table: :class:`QTableView` to which this action belongs. + """ + def __init__(self, table): + if not isinstance(table, qt.QTableView): + raise ValueError('CopyAllCellsAction must be initialised ' + + 'with a QTableWidget.') + super(CopyAllCellsAction, self).__init__(table) + self.setText("Copy all") + self.setToolTip("Copy all cells into the clipboard.") + self.triggered.connect(self.copyCellsToClipboard) + self.table = table + self.cut = False + + def copyCellsToClipboard(self): + """Concatenate the text content of all cells into a string + using tabulations and newlines to keep the table structure. + Put this text into the clipboard. + """ + data_model = self.table.model() + copied_text = "" + for row in range(data_model.rowCount()): + for col in range(data_model.columnCount()): + index = data_model.index(row, col) + cell_text = data_model.data(index) + flags = data_model.flags(index) + if cell_text is not None: + copied_text += cell_text + if self.cut and (flags & qt.Qt.ItemIsEditable): + data_model.setData(index, "") + copied_text += col_separator + # remove the right-most tabulation + copied_text = copied_text[:-len(col_separator)] + # add a newline + copied_text += row_separator + # remove final newline + copied_text = copied_text[:-len(row_separator)] + + # put this text into clipboard + qapp = qt.QApplication.instance() + qapp.clipboard().setText(copied_text) + + +class CutSelectedCellsAction(CopySelectedCellsAction): + """QAction to cut text from selected cells in a :class:`QTableWidget` + into the clipboard. + + The text is deleted from the original table widget + (use :class:`CopySelectedCellsAction` to preserve the original data). + + If multiple cells are selected, the cut text will be a concatenation + of the texts in all selected cells, tabulated with tabulation and + newline characters. + + If the cells are sparsely selected, the structure is preserved by + representing the unselected cells as empty strings in between two + tabulation characters. + Beware of pasting this data in another table widget, because depending + on how the paste is implemented, the empty cells may cause data in the + target table to be deleted, even though you didn't necessarily select the + corresponding cell in the origin table. + + :param table: :class:`QTableView` to which this action belongs.""" + def __init__(self, table): + super(CutSelectedCellsAction, self).__init__(table) + self.setText("Cut selection") + self.setShortcut(qt.QKeySequence.Cut) + self.setShortcutContext(qt.Qt.WidgetShortcut) + # cutting is already implemented in CopySelectedCellsAction (but + # it is disabled), we just need to enable it + self.cut = True + + +class CutAllCellsAction(CopyAllCellsAction): + """QAction to cut text from all cells in a :class:`QTableWidget` + into the clipboard. + + The text is deleted from the original table widget + (use :class:`CopyAllCellsAction` to preserve the original data). + + The cut text will be a concatenation + of the texts in all cells, tabulated with tabulation and + newline characters. + + :param table: :class:`QTableView` to which this action belongs.""" + def __init__(self, table): + super(CutAllCellsAction, self).__init__(table) + self.setText("Cut all") + self.setToolTip("Cut all cells into the clipboard.") + self.cut = True + + +def _parseTextAsTable(text, row_separator=row_separator, col_separator=col_separator): + """Parse text into list of lists (2D sequence). + + The input text must be tabulated using tabulation characters and + newlines to separate columns and rows. + + :param text: text to be parsed + :param record_separator: String, or single character, to be interpreted + as a record/row separator. + :param field_separator: String, or single character, to be interpreted + as a field/column separator. + :return: 2D sequence of strings + """ + rows = text.split(row_separator) + table_data = [row.split(col_separator) for row in rows] + return table_data + + +class PasteCellsAction(qt.QAction): + """QAction to paste text from the clipboard into the table. + + If the text contains tabulations and + newlines, they are interpreted as column and row separators. + In such a case, the text is split into multiple texts to be pasted + into multiple cells. + + If a cell content is an empty string in the original text, it is + ignored: the destination cell's text will not be deleted. + + :param table: :class:`QTableView` to which this action belongs. + """ + def __init__(self, table): + if not isinstance(table, qt.QTableView): + raise ValueError('PasteCellsAction must be initialised ' + + 'with a QTableWidget.') + super(PasteCellsAction, self).__init__(table) + self.table = table + self.setText("Paste") + self.setShortcut(qt.QKeySequence.Paste) + self.setShortcutContext(qt.Qt.WidgetShortcut) + self.setToolTip("Paste data. The selected cell is the top-left" + + "corner of the paste area.") + self.triggered.connect(self.pasteCellFromClipboard) + + def pasteCellFromClipboard(self): + """Paste text from clipboard into the table. + + :return: *True* in case of success, *False* if pasting data failed. + """ + selected_idx = self.table.selectedIndexes() + if len(selected_idx) != 1: + msgBox = qt.QMessageBox(parent=self.table) + msgBox.setText("A single cell must be selected to paste data") + msgBox.exec() + return False + + data_model = self.table.model() + + selected_row = selected_idx[0].row() + selected_col = selected_idx[0].column() + + qapp = qt.QApplication.instance() + clipboard_text = qapp.clipboard().text() + table_data = _parseTextAsTable(clipboard_text) + + protected_cells = 0 + out_of_range_cells = 0 + + # paste table data into cells, using selected cell as origin + for row_offset in range(len(table_data)): + for col_offset in range(len(table_data[row_offset])): + target_row = selected_row + row_offset + target_col = selected_col + col_offset + + if target_row >= data_model.rowCount() or\ + target_col >= data_model.columnCount(): + out_of_range_cells += 1 + continue + + index = data_model.index(target_row, target_col) + flags = data_model.flags(index) + + # ignore empty strings + if table_data[row_offset][col_offset] != "": + if not flags & qt.Qt.ItemIsEditable: + protected_cells += 1 + continue + data_model.setData(index, table_data[row_offset][col_offset]) + # item.setText(table_data[row_offset][col_offset]) + + if protected_cells or out_of_range_cells: + msgBox = qt.QMessageBox(parent=self.table) + msg = "Some data could not be inserted, " + msg += "due to out-of-range or write-protected cells." + msgBox.setText(msg) + msgBox.exec() + return False + return True + + +class CopySingleCellAction(qt.QAction): + """QAction to copy text from a single cell in a modified + :class:`QTableWidget`. + + This action relies on the fact that the text in the last clicked cell + are stored in :attr:`_last_cell_clicked` of the modified widget. + + In most cases, :class:`CopySelectedCellsAction` handles single cells, + but if the selection mode of the widget has been set to NoSelection + it is necessary to use this class instead. + + :param table: :class:`QTableView` to which this action belongs. + """ + def __init__(self, table): + if not isinstance(table, qt.QTableView): + raise ValueError('CopySingleCellAction must be initialised ' + + 'with a QTableWidget.') + super(CopySingleCellAction, self).__init__(table) + self.setText("Copy cell") + self.setToolTip("Copy cell content into the clipboard.") + self.triggered.connect(self.copyCellToClipboard) + self.table = table + + def copyCellToClipboard(self): + """ + """ + cell_text = self.table._text_last_cell_clicked + if cell_text is None: + return + + # put this text into clipboard + qapp = qt.QApplication.instance() + qapp.clipboard().setText(cell_text) + + +class TableWidget(qt.QTableWidget): + """:class:`QTableWidget` with a context menu displaying up to 5 actions: + + - :class:`CopySelectedCellsAction` + - :class:`CopyAllCellsAction` + - :class:`CutSelectedCellsAction` + - :class:`CutAllCellsAction` + - :class:`PasteCellsAction` + + These actions interact with the clipboard and can be used to copy data + to or from an external application, or another widget. + + The cut and paste actions are disabled by default, due to the risk of + overwriting data (no *Undo* action is available). Use :meth:`enablePaste` + and :meth:`enableCut` to activate them. + + .. image:: img/TableWidget.png + + :param parent: Parent QWidget + :param bool cut: Enable cut action + :param bool paste: Enable paste action + """ + def __init__(self, parent=None, cut=False, paste=False): + super(TableWidget, self).__init__(parent) + self._text_last_cell_clicked = None + + self.copySelectedCellsAction = CopySelectedCellsAction(self) + self.copyAllCellsAction = CopyAllCellsAction(self) + self.copySingleCellAction = None + self.pasteCellsAction = None + self.cutSelectedCellsAction = None + self.cutAllCellsAction = None + + self.addAction(self.copySelectedCellsAction) + self.addAction(self.copyAllCellsAction) + if cut: + self.enableCut() + if paste: + self.enablePaste() + + self.setContextMenuPolicy(qt.Qt.ActionsContextMenu) + + def mousePressEvent(self, event): + item = self.itemAt(event.pos()) + if item is not None: + self._text_last_cell_clicked = item.text() + super(TableWidget, self).mousePressEvent(event) + + def enablePaste(self): + """Enable paste action, to paste data from the clipboard into the + table. + + .. warning:: + + This action can cause data to be overwritten. + There is currently no *Undo* action to retrieve lost data. + """ + self.pasteCellsAction = PasteCellsAction(self) + self.addAction(self.pasteCellsAction) + + def enableCut(self): + """Enable cut action. + + .. warning:: + + This action can cause data to be deleted. + There is currently no *Undo* action to retrieve lost data.""" + self.cutSelectedCellsAction = CutSelectedCellsAction(self) + self.cutAllCellsAction = CutAllCellsAction(self) + self.addAction(self.cutSelectedCellsAction) + self.addAction(self.cutAllCellsAction) + + def setSelectionMode(self, mode): + """Overloaded from QTableWidget to disable cut/copy selection + actions in case mode is NoSelection + + :param mode: + :return: + """ + if mode == qt.QTableView.NoSelection: + self.copySelectedCellsAction.setVisible(False) + self.copySelectedCellsAction.setEnabled(False) + if self.cutSelectedCellsAction is not None: + self.cutSelectedCellsAction.setVisible(False) + self.cutSelectedCellsAction.setEnabled(False) + if self.copySingleCellAction is None: + self.copySingleCellAction = CopySingleCellAction(self) + self.insertAction(self.copySelectedCellsAction, # before first action + self.copySingleCellAction) + self.copySingleCellAction.setVisible(True) + self.copySingleCellAction.setEnabled(True) + else: + self.copySelectedCellsAction.setVisible(True) + self.copySelectedCellsAction.setEnabled(True) + if self.cutSelectedCellsAction is not None: + self.cutSelectedCellsAction.setVisible(True) + self.cutSelectedCellsAction.setEnabled(True) + if self.copySingleCellAction is not None: + self.copySingleCellAction.setVisible(False) + self.copySingleCellAction.setEnabled(False) + super(TableWidget, self).setSelectionMode(mode) + + +class TableView(qt.QTableView): + """:class:`QTableView` with a context menu displaying up to 5 actions: + + - :class:`CopySelectedCellsAction` + - :class:`CopyAllCellsAction` + - :class:`CutSelectedCellsAction` + - :class:`CutAllCellsAction` + - :class:`PasteCellsAction` + + These actions interact with the clipboard and can be used to copy data + to or from an external application, or another widget. + + The cut and paste actions are disabled by default, due to the risk of + overwriting data (no *Undo* action is available). Use :meth:`enablePaste` + and :meth:`enableCut` to activate them. + + .. note:: + + These actions will be available only after a model is associated + with this view, using :meth:`setModel`. + + :param parent: Parent QWidget + :param bool cut: Enable cut action + :param bool paste: Enable paste action + """ + def __init__(self, parent=None, cut=False, paste=False): + super(TableView, self).__init__(parent) + self._text_last_cell_clicked = None + + self.cut = cut + self.paste = paste + + self.copySelectedCellsAction = None + self.copyAllCellsAction = None + self.copySingleCellAction = None + self.pasteCellsAction = None + self.cutSelectedCellsAction = None + self.cutAllCellsAction = None + + def mousePressEvent(self, event): + qindex = self.indexAt(event.pos()) + if self.copyAllCellsAction is not None: # model was set + self._text_last_cell_clicked = self.model().data(qindex) + super(TableView, self).mousePressEvent(event) + + def setModel(self, model): + """Set the data model for the table view, activate the actions + and the context menu. + + :param model: :class:`qt.QAbstractItemModel` object + """ + super(TableView, self).setModel(model) + + self.copySelectedCellsAction = CopySelectedCellsAction(self) + self.copyAllCellsAction = CopyAllCellsAction(self) + self.addAction(self.copySelectedCellsAction) + self.addAction(self.copyAllCellsAction) + if self.cut: + self.enableCut() + if self.paste: + self.enablePaste() + + self.setContextMenuPolicy(qt.Qt.ActionsContextMenu) + + def enablePaste(self): + """Enable paste action, to paste data from the clipboard into the + table. + + .. warning:: + + This action can cause data to be overwritten. + There is currently no *Undo* action to retrieve lost data. + """ + self.pasteCellsAction = PasteCellsAction(self) + self.addAction(self.pasteCellsAction) + + def enableCut(self): + """Enable cut action. + + .. warning:: + + This action can cause data to be deleted. + There is currently no *Undo* action to retrieve lost data. + """ + self.cutSelectedCellsAction = CutSelectedCellsAction(self) + self.cutAllCellsAction = CutAllCellsAction(self) + self.addAction(self.cutSelectedCellsAction) + self.addAction(self.cutAllCellsAction) + + def addAction(self, action): + # ensure the actions are not added multiple times: + # compare action type and parent widget with those of existing actions + for existing_action in self.actions(): + if type(action) == type(existing_action): + if hasattr(action, "table") and\ + action.table is existing_action.table: + return None + super(TableView, self).addAction(action) + + def setSelectionMode(self, mode): + """Overloaded from QTableView to disable cut/copy selection + actions in case mode is NoSelection + + :param mode: + :return: + """ + if mode == qt.QTableView.NoSelection: + self.copySelectedCellsAction.setVisible(False) + self.copySelectedCellsAction.setEnabled(False) + if self.cutSelectedCellsAction is not None: + self.cutSelectedCellsAction.setVisible(False) + self.cutSelectedCellsAction.setEnabled(False) + if self.copySingleCellAction is None: + self.copySingleCellAction = CopySingleCellAction(self) + self.insertAction(self.copySelectedCellsAction, # before first action + self.copySingleCellAction) + self.copySingleCellAction.setVisible(True) + self.copySingleCellAction.setEnabled(True) + else: + self.copySelectedCellsAction.setVisible(True) + self.copySelectedCellsAction.setEnabled(True) + if self.cutSelectedCellsAction is not None: + self.cutSelectedCellsAction.setVisible(True) + self.cutSelectedCellsAction.setEnabled(True) + if self.copySingleCellAction is not None: + self.copySingleCellAction.setVisible(False) + self.copySingleCellAction.setEnabled(False) + super(TableView, self).setSelectionMode(mode) + + +if __name__ == "__main__": + app = qt.QApplication([]) + + tablewidget = TableWidget() + tablewidget.setWindowTitle("TableWidget") + tablewidget.setColumnCount(10) + tablewidget.setRowCount(7) + tablewidget.enableCut() + tablewidget.enablePaste() + tablewidget.show() + + tableview = TableView(cut=True, paste=True) + tableview.setWindowTitle("TableView") + model = qt.QStandardItemModel() + model.setColumnCount(10) + model.setRowCount(7) + tableview.setModel(model) + tableview.show() + + app.exec() diff --git a/src/silx/gui/widgets/ThreadPoolPushButton.py b/src/silx/gui/widgets/ThreadPoolPushButton.py new file mode 100644 index 0000000..949b6ef --- /dev/null +++ b/src/silx/gui/widgets/ThreadPoolPushButton.py @@ -0,0 +1,238 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-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. +# +# ###########################################################################*/ +"""ThreadPoolPushButton module +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "13/10/2016" + +import logging +from .. import qt +from .WaitingPushButton import WaitingPushButton + + +_logger = logging.getLogger(__name__) + + +class _Wrapper(qt.QRunnable): + """Wrapper to allow to call a function into a `QThreadPool` and + sending signals during the life cycle of the object""" + + def __init__(self, signalHolder, function, args, kwargs): + """Constructor""" + super(_Wrapper, self).__init__() + self.__signalHolder = signalHolder + self.__callable = function + self.__args = args + self.__kwargs = kwargs + + def run(self): + holder = self.__signalHolder + holder.started.emit() + try: + result = self.__callable(*self.__args, **self.__kwargs) + holder.succeeded.emit(result) + except Exception as e: + module = self.__callable.__module__ + name = self.__callable.__name__ + _logger.error("Error while executing callable %s.%s.", module, name, exc_info=True) + holder.failed.emit(e) + finally: + holder.finished.emit() + holder._sigReleaseRunner.emit(self) + + def autoDelete(self): + """Returns true to ask the QThreadPool to manage the life cycle of + this QRunner.""" + return True + + +class ThreadPoolPushButton(WaitingPushButton): + """ + ThreadPoolPushButton provides a simple push button to execute + a threaded task with user feedback when the task is running. + + The task can be defined with the method `setCallable`. It takes a python + function and arguments as parameters. + + WARNING: This task is run in a separate thread. + + Everytime the button is pushed a new runner is created to execute the + function with defined arguments. An animated waiting icon is displayed + to show the activity. By default the button is disabled when an execution + is requested. This behaviour can be disabled by using + `setDisabledWhenWaiting`. + + When the button is clicked a `beforeExecuting` signal is sent from the + Qt main thread. Then the task is started in a thread pool and the following + signals are emitted from the thread pool. Right before calling the + registered callable, the widget emits a `started` signal. + When the task ends, its result is emitted by the `succeeded` signal, but + if it fails the signal `failed` is emitted with the resulting exception. + At the end, the `finished` signal is emitted. + + The task can be programatically executed by using `executeCallable`. + + >>> # Compute a value + >>> import math + >>> button = ThreadPoolPushButton(text="Compute 2^16") + >>> button.setCallable(math.pow, 2, 16) + >>> button.succeeded.connect(print) # python3 + + .. image:: img/ThreadPoolPushButton.png + + >>> # Compute a wrong value + >>> import math + >>> button = ThreadPoolPushButton(text="Compute sqrt(-1)") + >>> button.setCallable(math.sqrt, -1) + >>> button.failed.connect(print) # python3 + """ + + def __init__(self, parent=None, text=None, icon=None): + """Constructor + + :param str text: Text displayed on the button + :param qt.QIcon icon: Icon displayed on the button + :param qt.QWidget parent: Parent of the widget + """ + WaitingPushButton.__init__(self, parent=parent, text=text, icon=icon) + self.__callable = None + self.__args = None + self.__kwargs = None + self.__runnerCount = 0 + self.__runnerSet = set([]) + self.clicked.connect(self.executeCallable) + self.finished.connect(self.__runnerFinished) + self._sigReleaseRunner.connect(self.__releaseRunner) + + beforeExecuting = qt.Signal() + """Signal emitted just before execution of the callable by the main Qt + thread. In synchronous mode (direct mode), it can be used to define + dynamically `setCallable`, or to execute something in the Qt thread before + the execution, or both.""" + + started = qt.Signal() + """Signal emitted from the thread pool when the defined callable is + started. + + WARNING: This signal is emitted from the thread performing the task, and + might be received after the registered callable has been called. If you + want to perform some initialisation or set the callable to run, use the + `beforeExecuting` signal instead. + """ + + finished = qt.Signal() + """Signal emitted from the thread pool when the defined callable is + finished""" + + succeeded = qt.Signal(object) + """Signal emitted from the thread pool when the callable exit with a + success. + + The parameter of the signal is the result returned by the callable. + """ + + failed = qt.Signal(object) + """Signal emitted emitted from the thread pool when the callable raises an + exception. + + The parameter of the signal is the raised exception. + """ + + _sigReleaseRunner = qt.Signal(object) + """Callback to release runners""" + + def __runnerStarted(self): + """Called when a runner is started. + + Count the number of executed tasks to change the state of the widget. + """ + self.__runnerCount += 1 + if self.__runnerCount > 0: + self.wait() + + def __runnerFinished(self): + """Called when a runner is finished. + + Count the number of executed tasks to change the state of the widget. + """ + self.__runnerCount -= 1 + if self.__runnerCount <= 0: + self.stopWaiting() + + @qt.Slot() + def executeCallable(self): + """Execute the defined callable in QThreadPool. + + First emit a `beforeExecuting` signal. + If callable is not defined, nothing append. + If a callable is defined, it will be started + as a new thread using the `QThreadPool` system. At start of the thread + the `started` will be emitted. When the callable returns a result it + is emitted by the `succeeded` signal. If the callable fail, the signal + `failed` is emitted with the resulting exception. Then the `finished` + signal is emitted. + """ + self.beforeExecuting.emit() + if self.__callable is None: + return + self.__runnerStarted() + runner = self._createRunner(self.__callable, self.__args, self.__kwargs) + qt.silxGlobalThreadPool().start(runner) + self.__runnerSet.add(runner) + + def __releaseRunner(self, runner): + self.__runnerSet.remove(runner) + + def hasPendingOperations(self): + return len(self.__runnerSet) > 0 + + def _createRunner(self, function, args, kwargs): + """Create a QRunnable from a callable object. + + :param callable function: A callable Python object. + :param List args: List of arguments to call the function. + :param dict kwargs: Dictionary of arguments used to call the function. + :rtpye: qt.QRunnable + """ + runnable = _Wrapper(self, function, args, kwargs) + return runnable + + def setCallable(self, function, *args, **kwargs): + """Define a callable which will be executed on QThreadPool everytime + the button is clicked. + + To retrieve the results, connect to the `succeeded` signal. + + WARNING: The callable will be called in a separate thread. + + :param callable function: A callable Python object + :param List args: List of arguments to call the function. + :param dict kwargs: Dictionary of arguments used to call the function. + """ + self.__callable = function + self.__args = args + self.__kwargs = kwargs diff --git a/src/silx/gui/widgets/UrlSelectionTable.py b/src/silx/gui/widgets/UrlSelectionTable.py new file mode 100644 index 0000000..bc75d32 --- /dev/null +++ b/src/silx/gui/widgets/UrlSelectionTable.py @@ -0,0 +1,169 @@ +# /*########################################################################## +# Copyright (C) 2017-2021 European Synchrotron Radiation Facility +# +# This file is part of the PyMca X-ray Fluorescence Toolkit developed at +# the ESRF by the Software group. +# +# 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. +# +#############################################################################*/ +"""Some widget construction to check if a sample moved""" + +__author__ = ["H. Payno"] +__license__ = "MIT" +__date__ = "19/03/2018" + +from silx.gui import qt +from collections import OrderedDict +from silx.gui.widgets.TableWidget import TableWidget +from silx.io.url import DataUrl +import functools +import logging +import os + +logger = logging.getLogger(__name__) + + +class UrlSelectionTable(TableWidget): + """Table used to select the color channel to be displayed for each""" + + COLUMS_INDEX = OrderedDict([ + ('url', 0), + ('img A', 1), + ('img B', 2), + ]) + + sigImageAChanged = qt.Signal(str) + """Signal emitted when the image A change. Param is the image url path""" + + sigImageBChanged = qt.Signal(str) + """Signal emitted when the image B change. Param is the image url path""" + + def __init__(self, parent=None): + TableWidget.__init__(self, parent) + self.clear() + + def clear(self): + qt.QTableWidget.clear(self) + self.setRowCount(0) + self.setColumnCount(len(self.COLUMS_INDEX)) + self.setHorizontalHeaderLabels(list(self.COLUMS_INDEX.keys())) + self.verticalHeader().hide() + self.horizontalHeader().setSectionResizeMode(0, + qt.QHeaderView.Stretch) + + self.setSortingEnabled(True) + self._checkBoxes = {} + + def setUrls(self, urls: list) -> None: + """ + + :param urls: urls to be displayed + """ + for url in urls: + self.addUrl(url=url) + + def addUrl(self, url, **kwargs): + """ + + :param url: + :param args: + :return: index of the created items row + :rtype int + """ + assert isinstance(url, DataUrl) + row = self.rowCount() + self.setRowCount(row + 1) + + _item = qt.QTableWidgetItem() + _item.setText(os.path.basename(url.path())) + _item.setFlags(qt.Qt.ItemIsEnabled | qt.Qt.ItemIsSelectable) + self.setItem(row, self.COLUMS_INDEX['url'], _item) + + widgetImgA = qt.QRadioButton(parent=self) + widgetImgA.setAutoExclusive(False) + self.setCellWidget(row, self.COLUMS_INDEX['img A'], widgetImgA) + callbackImgA = functools.partial(self._activeImgAChanged, url.path()) + widgetImgA.toggled.connect(callbackImgA) + + widgetImgB = qt.QRadioButton(parent=self) + widgetImgA.setAutoExclusive(False) + self.setCellWidget(row, self.COLUMS_INDEX['img B'], widgetImgB) + callbackImgB = functools.partial(self._activeImgBChanged, url.path()) + widgetImgB.toggled.connect(callbackImgB) + + self._checkBoxes[url.path()] = {'img A': widgetImgA, + 'img B': widgetImgB} + self.resizeColumnsToContents() + return row + + def _activeImgAChanged(self, name): + self._updatecheckBoxes('img A', name) + self.sigImageAChanged.emit(name) + + def _activeImgBChanged(self, name): + self._updatecheckBoxes('img B', name) + self.sigImageBChanged.emit(name) + + def _updatecheckBoxes(self, whichImg, name): + assert name in self._checkBoxes + assert whichImg in self._checkBoxes[name] + if self._checkBoxes[name][whichImg].isChecked(): + for radioUrl in self._checkBoxes: + if radioUrl != name: + self._checkBoxes[radioUrl][whichImg].blockSignals(True) + self._checkBoxes[radioUrl][whichImg].setChecked(False) + self._checkBoxes[radioUrl][whichImg].blockSignals(False) + + def getSelection(self): + """ + + :return: url selected for img A and img B. + """ + imgA = imgB = None + for radioUrl in self._checkBoxes: + if self._checkBoxes[radioUrl]['img A'].isChecked(): + imgA = radioUrl + if self._checkBoxes[radioUrl]['img B'].isChecked(): + imgB = radioUrl + return imgA, imgB + + def setSelection(self, url_img_a, url_img_b): + """ + + :param ddict: key: image url, values: list of active channels + """ + for radioUrl in self._checkBoxes: + for img in ('img A', 'img B'): + self._checkBoxes[radioUrl][img].blockSignals(True) + self._checkBoxes[radioUrl][img].setChecked(False) + self._checkBoxes[radioUrl][img].blockSignals(False) + + self._checkBoxes[radioUrl][img].blockSignals(True) + self._checkBoxes[url_img_a]['img A'].setChecked(True) + self._checkBoxes[radioUrl][img].blockSignals(False) + + self._checkBoxes[radioUrl][img].blockSignals(True) + self._checkBoxes[url_img_b]['img B'].setChecked(True) + self._checkBoxes[radioUrl][img].blockSignals(False) + self.sigImageAChanged.emit(url_img_a) + self.sigImageBChanged.emit(url_img_b) + + def removeUrl(self, url): + raise NotImplementedError("") diff --git a/src/silx/gui/widgets/WaitingPushButton.py b/src/silx/gui/widgets/WaitingPushButton.py new file mode 100644 index 0000000..443dc9a --- /dev/null +++ b/src/silx/gui/widgets/WaitingPushButton.py @@ -0,0 +1,241 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2021 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. +# +# ###########################################################################*/ +"""WaitingPushButton module +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "26/04/2017" + +from .. import qt +from .. import icons + + +class WaitingPushButton(qt.QPushButton): + """Button which allows to display a waiting status when, for example, + something is still computing. + + The component is graphically disabled when it is in waiting. Then we + overwrite the enabled method to dissociate the 2 concepts: + graphically enabled/disabled, and enabled/disabled + + .. image:: img/WaitingPushButton.png + """ + + def __init__(self, parent=None, text=None, icon=None): + """Constructor + + :param str text: Text displayed on the button + :param qt.QIcon icon: Icon displayed on the button + :param qt.QWidget parent: Parent of the widget + """ + if icon is not None: + qt.QPushButton.__init__(self, icon, text, parent) + elif text is not None: + qt.QPushButton.__init__(self, text, parent) + else: + qt.QPushButton.__init__(self, parent) + + self.__waiting = False + self.__enabled = True + self.__icon = icon + self.__disabled_when_waiting = True + self.__waitingIcon = icons.getWaitIcon() + + def sizeHint(self): + """Returns the recommended size for the widget. + + This implementation of the recommended size always consider there is an + icon. In this way it avoid to update the layout when the waiting icon + is displayed. + """ + self.ensurePolished() + + w = 0 + h = 0 + + opt = qt.QStyleOptionButton() + self.initStyleOption(opt) + + # Content with icon + # no condition, assume that there is an icon to avoid blinking + # when the widget switch to waiting state + ih = opt.iconSize.height() + iw = opt.iconSize.width() + 4 + w += iw + h = max(h, ih) + + # Content with text + text = self.text() + isEmpty = text == "" + if isEmpty: + text = "XXXX" + fm = self.fontMetrics() + textSize = fm.size(qt.Qt.TextShowMnemonic, text) + if not isEmpty or w == 0: + w += textSize.width() + if not isEmpty or h == 0: + h = max(h, textSize.height()) + + # Content with menu indicator + opt.rect.setSize(qt.QSize(w, h)) # PM_MenuButtonIndicator depends on the height + if self.menu() is not None: + w += self.style().pixelMetric(qt.QStyle.PM_MenuButtonIndicator, opt, self) + + contentSize = qt.QSize(w, h) + sizeHint = self.style().sizeFromContents(qt.QStyle.CT_PushButton, opt, contentSize, self) + if qt.BINDING in ('PySide2', 'PyQt5'): # Qt6: globalStrut not available + sizeHint = sizeHint.expandedTo(qt.QApplication.globalStrut()) + return sizeHint + + def setDisabledWhenWaiting(self, isDisabled): + """Enable or disable the auto disable behaviour when the button is waiting. + + :param bool isDisabled: Enable the auto-disable behaviour + """ + if self.__disabled_when_waiting == isDisabled: + return + self.__disabled_when_waiting = isDisabled + self.__updateVisibleEnabled() + + def isDisabledWhenWaiting(self): + """Returns true if the button is auto disabled when it is waiting. + + :rtype: bool + """ + return self.__disabled_when_waiting + + disabledWhenWaiting = qt.Property(bool, isDisabledWhenWaiting, setDisabledWhenWaiting) + """Property to enable/disable the auto disabled state when the button is waiting.""" + + def __setWaitingIcon(self, icon): + """Called when the waiting icon is updated. It is called every frames + of the animation. + + :param qt.QIcon icon: The new waiting icon + """ + qt.QPushButton.setIcon(self, icon) + + def setIcon(self, icon): + """Set the button icon. If the button is waiting, the icon is not + visible directly, but will be visible when the waiting state will be + removed. + + :param qt.QIcon icon: An icon + """ + self.__icon = icon + self.__updateVisibleIcon() + + def getIcon(self): + """Returns the icon set to the button. If the widget is waiting + it is not returning the visible icon, but the one requested by + the application (the one displayed when the widget is not in + waiting state). + + :rtype: qt.QIcon + """ + return self.__icon + + icon = qt.Property(qt.QIcon, getIcon, setIcon) + """Property providing access to the icon.""" + + def __updateVisibleIcon(self): + """Update the visible icon according to the state of the widget.""" + if not self.isWaiting(): + icon = self.__icon + else: + icon = self.__waitingIcon.currentIcon() + if icon is None: + icon = qt.QIcon() + qt.QPushButton.setIcon(self, icon) + + def setEnabled(self, enabled): + """Set the enabled state of the widget. + + :param bool enabled: The enabled state + """ + if self.__enabled == enabled: + return + self.__enabled = enabled + self.__updateVisibleEnabled() + + def isEnabled(self): + """Returns the enabled state of the widget. + + :rtype: bool + """ + return self.__enabled + + enabled = qt.Property(bool, isEnabled, setEnabled) + """Property providing access to the enabled state of the widget""" + + def __updateVisibleEnabled(self): + """Update the visible enabled state according to the state of the + widget.""" + if self.__disabled_when_waiting: + enabled = not self.isWaiting() and self.__enabled + else: + enabled = self.__enabled + qt.QPushButton.setEnabled(self, enabled) + + def setWaiting(self, waiting): + """Set the waiting state of the widget. + + :param bool waiting: Requested state""" + if self.__waiting == waiting: + return + self.__waiting = waiting + + if self.__waiting: + self.__waitingIcon.register(self) + self.__waitingIcon.iconChanged.connect(self.__setWaitingIcon) + else: + # unregister only if the object is registred + self.__waitingIcon.unregister(self) + self.__waitingIcon.iconChanged.disconnect(self.__setWaitingIcon) + + self.__updateVisibleEnabled() + self.__updateVisibleIcon() + + def isWaiting(self): + """Returns true if the widget is in waiting state. + + :rtype: bool""" + return self.__waiting + + @qt.Slot() + def wait(self): + """Enable the waiting state.""" + self.setWaiting(True) + + @qt.Slot() + def stopWaiting(self): + """Disable the waiting state.""" + self.setWaiting(False) + + @qt.Slot() + def swapWaiting(self): + """Swap the waiting state.""" + self.setWaiting(not self.isWaiting()) diff --git a/src/silx/gui/widgets/__init__.py b/src/silx/gui/widgets/__init__.py new file mode 100644 index 0000000..9d0299d --- /dev/null +++ b/src/silx/gui/widgets/__init__.py @@ -0,0 +1,27 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-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 package provides a few simple Qt widgets that rely only on a Qt binding for Python. + +No other optional dependencies of *silx* should be required.""" diff --git a/src/silx/gui/widgets/setup.py b/src/silx/gui/widgets/setup.py new file mode 100644 index 0000000..e96ac8d --- /dev/null +++ b/src/silx/gui/widgets/setup.py @@ -0,0 +1,41 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016 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. +# +# ###########################################################################*/ +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "11/10/2016" + + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('widgets', parent_package, top_path) + config.add_subpackage('test') + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + setup(configuration=configuration) diff --git a/src/silx/gui/widgets/test/__init__.py b/src/silx/gui/widgets/test/__init__.py new file mode 100644 index 0000000..243dbc7 --- /dev/null +++ b/src/silx/gui/widgets/test/__init__.py @@ -0,0 +1,24 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 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. +# +# ###########################################################################*/ diff --git a/src/silx/gui/widgets/test/test_boxlayoutdockwidget.py b/src/silx/gui/widgets/test/test_boxlayoutdockwidget.py new file mode 100644 index 0000000..5df8df9 --- /dev/null +++ b/src/silx/gui/widgets/test/test_boxlayoutdockwidget.py @@ -0,0 +1,72 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 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. +# +# ###########################################################################*/ +"""Tests for BoxLayoutDockWidget""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/03/2018" + +import unittest + +from silx.gui.widgets.BoxLayoutDockWidget import BoxLayoutDockWidget +from silx.gui import qt +from silx.gui.utils.testutils import TestCaseQt + + +class TestBoxLayoutDockWidget(TestCaseQt): + """Tests for BoxLayoutDockWidget""" + + def setUp(self): + """Create and show a main window""" + self.window = qt.QMainWindow() + self.qWaitForWindowExposed(self.window) + + def tearDown(self): + """Delete main window""" + self.window.setAttribute(qt.Qt.WA_DeleteOnClose) + self.window.close() + del self.window + self.qapp.processEvents() + + def test(self): + """Test update of layout direction according to dock area""" + # Create a widget with a QBoxLayout + layout = qt.QBoxLayout(qt.QBoxLayout.LeftToRight) + layout.addWidget(qt.QLabel('First')) + layout.addWidget(qt.QLabel('Second')) + widget = qt.QWidget() + widget.setLayout(layout) + + # Add it to a BoxLayoutDockWidget + dock = BoxLayoutDockWidget() + dock.setWidget(widget) + + self.window.addDockWidget(qt.Qt.BottomDockWidgetArea, dock) + self.qapp.processEvents() + self.assertEqual(layout.direction(), qt.QBoxLayout.LeftToRight) + + self.window.addDockWidget(qt.Qt.LeftDockWidgetArea, dock) + self.qapp.processEvents() + self.assertEqual(layout.direction(), qt.QBoxLayout.TopToBottom) diff --git a/src/silx/gui/widgets/test/test_elidedlabel.py b/src/silx/gui/widgets/test/test_elidedlabel.py new file mode 100644 index 0000000..693e43c --- /dev/null +++ b/src/silx/gui/widgets/test/test_elidedlabel.py @@ -0,0 +1,100 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 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. +# +# ###########################################################################*/ +"""Tests for ElidedLabel""" + +__license__ = "MIT" +__date__ = "08/06/2020" + +import unittest + +from silx.gui import qt +from silx.gui.widgets.ElidedLabel import ElidedLabel +from silx.gui.utils import testutils + + +class TestElidedLabel(testutils.TestCaseQt): + + def setUp(self): + self.label = ElidedLabel() + self.label.show() + self.qWaitForWindowExposed(self.label) + + def tearDown(self): + self.label.setAttribute(qt.Qt.WA_DeleteOnClose) + self.label.close() + del self.label + self.qapp.processEvents() + + def testElidedValue(self): + """Test elided text""" + raw = "mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm" + self.label.setText(raw) + self.label.setFixedWidth(30) + displayedText = qt.QLabel.text(self.label) + self.assertNotEqual(raw, displayedText) + self.assertIn("…", displayedText) + self.assertIn("m", displayedText) + + def testNotElidedValue(self): + """Test elided text""" + raw = "mmmmmmm" + self.label.setText(raw) + self.label.setFixedWidth(200) + displayedText = qt.QLabel.text(self.label) + self.assertNotIn("…", displayedText) + self.assertEqual(raw, displayedText) + + def testUpdateFromElidedToNotElided(self): + """Test tooltip when not elided""" + raw1 = "mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm" + raw2 = "nn" + self.label.setText(raw1) + self.label.setFixedWidth(30) + self.label.setText(raw2) + displayedTooltip = qt.QLabel.toolTip(self.label) + self.assertNotIn(raw1, displayedTooltip) + self.assertNotIn(raw2, displayedTooltip) + + def testUpdateFromNotElidedToElided(self): + """Test tooltip when elided""" + raw1 = "nn" + raw2 = "mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm" + self.label.setText(raw1) + self.label.setFixedWidth(30) + self.label.setText(raw2) + displayedTooltip = qt.QLabel.toolTip(self.label) + self.assertNotIn(raw1, displayedTooltip) + self.assertIn(raw2, displayedTooltip) + + def testUpdateFromElidedToElided(self): + """Test tooltip when elided""" + raw1 = "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn" + raw2 = "mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm" + self.label.setText(raw1) + self.label.setFixedWidth(30) + self.label.setText(raw2) + displayedTooltip = qt.QLabel.toolTip(self.label) + self.assertNotIn(raw1, displayedTooltip) + self.assertIn(raw2, displayedTooltip) diff --git a/src/silx/gui/widgets/test/test_flowlayout.py b/src/silx/gui/widgets/test/test_flowlayout.py new file mode 100644 index 0000000..85d7cfe --- /dev/null +++ b/src/silx/gui/widgets/test/test_flowlayout.py @@ -0,0 +1,66 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 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. +# +# ###########################################################################*/ +"""Tests for FlowLayout""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "02/08/2018" + +import unittest + +from silx.gui.widgets.FlowLayout import FlowLayout +from silx.gui import qt +from silx.gui.utils.testutils import TestCaseQt + + +class TestFlowLayout(TestCaseQt): + """Tests for FlowLayout""" + + def setUp(self): + """Create and show a widget""" + self.widget = qt.QWidget() + self.widget.show() + self.qWaitForWindowExposed(self.widget) + + def tearDown(self): + """Delete widget""" + self.widget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.widget.close() + del self.widget + self.qapp.processEvents() + + def test(self): + """Basic tests""" + layout = FlowLayout() + self.widget.setLayout(layout) + + layout.addWidget(qt.QLabel('first')) + layout.addWidget(qt.QLabel('second')) + self.assertEqual(layout.count(), 2) + + layout.setHorizontalSpacing(10) + self.assertEqual(layout.horizontalSpacing(), 10) + layout.setVerticalSpacing(5) + self.assertEqual(layout.verticalSpacing(), 5) diff --git a/src/silx/gui/widgets/test/test_framebrowser.py b/src/silx/gui/widgets/test/test_framebrowser.py new file mode 100644 index 0000000..8233622 --- /dev/null +++ b/src/silx/gui/widgets/test/test_framebrowser.py @@ -0,0 +1,62 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 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. +# +# ###########################################################################*/ +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "23/03/2018" + + +import unittest + +from silx.gui.utils.testutils import TestCaseQt +from silx.gui.widgets.FrameBrowser import FrameBrowser + + +class TestFrameBrowser(TestCaseQt): + """Test for FrameBrowser""" + + def test(self): + """Test FrameBrowser""" + widget = FrameBrowser() + widget.show() + self.qWaitForWindowExposed(widget) + + nFrames = 20 + widget.setNFrames(nFrames) + self.assertEqual(widget.getRange(), (0, nFrames - 1)) + self.assertEqual(widget.getValue(), 0) + + range_ = -100, 100 + widget.setRange(*range_) + self.assertEqual(widget.getRange(), range_) + self.assertEqual(widget.getValue(), range_[0]) + + widget.setValue(0) + self.assertEqual(widget.getValue(), 0) + + widget.setValue(range_[1] + 100) + self.assertEqual(widget.getValue(), range_[1]) + + widget.setValue(range_[0] - 100) + self.assertEqual(widget.getValue(), range_[0]) diff --git a/src/silx/gui/widgets/test/test_hierarchicaltableview.py b/src/silx/gui/widgets/test/test_hierarchicaltableview.py new file mode 100644 index 0000000..302086a --- /dev/null +++ b/src/silx/gui/widgets/test/test_hierarchicaltableview.py @@ -0,0 +1,103 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2021 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. +# +# ###########################################################################*/ +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/04/2017" + +import unittest + +from .. import HierarchicalTableView +from silx.gui.utils.testutils import TestCaseQt +from silx.gui import qt + + +class TableModel(HierarchicalTableView.HierarchicalTableModel): + + def __init__(self, parent): + HierarchicalTableView.HierarchicalTableModel.__init__(self, parent) + self.__content = {} + + def rowCount(self, parent=qt.QModelIndex()): + return 3 + + def columnCount(self, parent=qt.QModelIndex()): + return 3 + + def setData1(self): + self.beginResetModel() + + content = {} + content[0, 0] = ("title", True, (1, 3)) + content[0, 1] = ("a", True, (2, 1)) + content[1, 1] = ("b", False, (1, 2)) + content[1, 2] = ("c", False, (1, 1)) + content[2, 2] = ("d", False, (1, 1)) + self.__content = content + + self.endResetModel() + + def data(self, index, role=qt.Qt.DisplayRole): + if not index.isValid(): + return None + cell = self.__content.get((index.column(), index.row()), None) + if cell is None: + return None + + if role == self.SpanRole: + return cell[2] + elif role == self.IsHeaderRole: + return cell[1] + elif role == qt.Qt.DisplayRole: + return cell[0] + return None + + +class TestHierarchicalTableView(TestCaseQt): + """Test for HierarchicalTableView""" + + def testEmpty(self): + widget = HierarchicalTableView.HierarchicalTableView() + widget.show() + self.qWaitForWindowExposed(widget) + + def testModel(self): + widget = HierarchicalTableView.HierarchicalTableView() + model = TableModel(widget) + # set the data before using the model into the widget + model.setData1() + widget.setModel(model) + span = widget.rowSpan(0, 0), widget.columnSpan(0, 0) + self.assertEqual(span, (1, 3)) + widget.show() + self.qWaitForWindowExposed(widget) + + def testModelUpdate(self): + widget = HierarchicalTableView.HierarchicalTableView() + model = TableModel(widget) + widget.setModel(model) + # set the data after using the model into the widget + model.setData1() + span = widget.rowSpan(0, 0), widget.columnSpan(0, 0) + self.assertEqual(span, (1, 3)) diff --git a/src/silx/gui/widgets/test/test_legendiconwidget.py b/src/silx/gui/widgets/test/test_legendiconwidget.py new file mode 100644 index 0000000..fe320f6 --- /dev/null +++ b/src/silx/gui/widgets/test/test_legendiconwidget.py @@ -0,0 +1,63 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 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. +# +# ###########################################################################*/ +"""Tests for LegendIconWidget""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "23/10/2020" + +import unittest + +from silx.gui import qt +from silx.gui.widgets.LegendIconWidget import LegendIconWidget +from silx.gui.utils.testutils import TestCaseQt +from silx.utils.testutils import ParametricTestCase + + +class TestLegendIconWidget(TestCaseQt, ParametricTestCase): + """Tests for TestRangeSlider""" + + def setUp(self): + self.widget = LegendIconWidget() + self.widget.show() + self.qWaitForWindowExposed(self.widget) + + def tearDown(self): + self.widget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.widget.close() + del self.widget + self.qapp.processEvents() + + def testCreate(self): + self.qapp.processEvents() + + def testColormap(self): + self.widget.setColormap("viridis") + self.qapp.processEvents() + + def testSymbol(self): + self.widget.setSymbol("o") + self.widget.setSymbolColormap("viridis") + self.qapp.processEvents() diff --git a/src/silx/gui/widgets/test/test_periodictable.py b/src/silx/gui/widgets/test/test_periodictable.py new file mode 100644 index 0000000..de9e1af --- /dev/null +++ b/src/silx/gui/widgets/test/test_periodictable.py @@ -0,0 +1,148 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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. +# +# ###########################################################################*/ +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "05/12/2016" + +import unittest + +from .. import PeriodicTable +from silx.gui.utils.testutils import TestCaseQt +from silx.gui import qt + + +class TestPeriodicTable(TestCaseQt): + """Basic test for ArrayTableWidget with a numpy array""" + + def testShow(self): + """basic test (instantiation done in setUp)""" + pt = PeriodicTable.PeriodicTable() + pt.show() + self.qWaitForWindowExposed(pt) + + def testSelectable(self): + """basic test (instantiation done in setUp)""" + pt = PeriodicTable.PeriodicTable(selectable=True) + self.assertTrue(pt.selectable) + + def testCustomElements(self): + PTI = PeriodicTable.ColoredPeriodicTableItem + my_items = [ + PTI("Xx", 42, 43, 44, "xaxatorium", 1002.2, + bgcolor="#FF0000"), + PTI("Yy", 25, 22, 44, "yoyotrium", 8.8) + ] + + pt = PeriodicTable.PeriodicTable(elements=my_items) + + pt.setSelection(["He", "Xx"]) + selection = pt.getSelection() + self.assertEqual(len(selection), 1) # "He" not found + self.assertEqual(selection[0].symbol, "Xx") + self.assertEqual(selection[0].Z, 42) + self.assertEqual(selection[0].col, 43) + self.assertAlmostEqual(selection[0].mass, 1002.2) + self.assertEqual(qt.QColor(selection[0].bgcolor), + qt.QColor(qt.Qt.red)) + + self.assertTrue(pt.isElementSelected("Xx")) + self.assertFalse(pt.isElementSelected("Yy")) + self.assertRaises(KeyError, pt.isElementSelected, "Yx") + + def testVeryCustomElements(self): + class MyPTI(PeriodicTable.PeriodicTableItem): + def __init__(self, *args): + PeriodicTable.PeriodicTableItem.__init__(self, *args[:6]) + self.my_feature = args[6] + + my_items = [ + MyPTI("Xx", 42, 43, 44, "xaxatorium", 1002.2, "spam"), + MyPTI("Yy", 25, 22, 44, "yoyotrium", 8.8, "eggs") + ] + + pt = PeriodicTable.PeriodicTable(elements=my_items) + + pt.setSelection(["Xx", "Yy"]) + selection = pt.getSelection() + self.assertEqual(len(selection), 2) + self.assertEqual(selection[1].symbol, "Yy") + self.assertEqual(selection[1].Z, 25) + self.assertEqual(selection[1].col, 22) + self.assertEqual(selection[1].row, 44) + self.assertAlmostEqual(selection[0].mass, 1002.2) + self.assertAlmostEqual(selection[0].my_feature, "spam") + + +class TestPeriodicCombo(TestCaseQt): + """Basic test for ArrayTableWidget with a numpy array""" + def setUp(self): + super(TestPeriodicCombo, self).setUp() + self.pc = PeriodicTable.PeriodicCombo() + + def tearDown(self): + del self.pc + super(TestPeriodicCombo, self).tearDown() + + def testShow(self): + """basic test (instantiation done in setUp)""" + self.pc.show() + self.qWaitForWindowExposed(self.pc) + + def testSelect(self): + self.pc.setSelection("Sb") + selection = self.pc.getSelection() + self.assertIsInstance(selection, + PeriodicTable.PeriodicTableItem) + self.assertEqual(selection.symbol, "Sb") + self.assertEqual(selection.Z, 51) + self.assertEqual(selection.name, "antimony") + + +class TestPeriodicList(TestCaseQt): + """Basic test for ArrayTableWidget with a numpy array""" + def setUp(self): + super(TestPeriodicList, self).setUp() + self.pl = PeriodicTable.PeriodicList() + + def tearDown(self): + del self.pl + super(TestPeriodicList, self).tearDown() + + def testShow(self): + """basic test (instantiation done in setUp)""" + self.pl.show() + self.qWaitForWindowExposed(self.pl) + + def testSelect(self): + self.pl.setSelectedElements(["Li", "He", "Au"]) + sel_elmts = self.pl.getSelection() + + self.assertEqual(len(sel_elmts), 3, + "Wrong number of elements selected") + for e in sel_elmts: + self.assertIsInstance(e, PeriodicTable.PeriodicTableItem) + self.assertIn(e.symbol, ["Li", "He", "Au"]) + self.assertIn(e.Z, [2, 3, 79]) + self.assertIn(e.name, ["lithium", "helium", "gold"]) diff --git a/src/silx/gui/widgets/test/test_printpreview.py b/src/silx/gui/widgets/test/test_printpreview.py new file mode 100644 index 0000000..8602666 --- /dev/null +++ b/src/silx/gui/widgets/test/test_printpreview.py @@ -0,0 +1,63 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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. +# +# ###########################################################################*/ +"""Test PrintPreview""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "19/07/2017" + + +import unittest +from silx.gui.utils.testutils import TestCaseQt +from silx.gui.widgets.PrintPreview import PrintPreviewDialog +from silx.gui import qt + +from silx.resources import resource_filename + + +class TestPrintPreview(TestCaseQt): + def testShow(self): + p = qt.QPrinter() + d = PrintPreviewDialog(printer=p) + d.show() + self.qapp.processEvents() + + def testAddImage(self): + p = qt.QPrinter() + d = PrintPreviewDialog(printer=p) + d.addImage(qt.QImage(resource_filename("gui/icons/clipboard.png"))) + self.qapp.processEvents() + + def testAddSvg(self): + p = qt.QPrinter() + d = PrintPreviewDialog(printer=p) + d.addSvgItem(qt.QSvgRenderer(resource_filename("gui/icons/clipboard.svg"), d.page)) + self.qapp.processEvents() + + def testAddPixmap(self): + p = qt.QPrinter() + d = PrintPreviewDialog(printer=p) + d.addPixmap(qt.QPixmap.fromImage(qt.QImage(resource_filename("gui/icons/clipboard.png")))) + self.qapp.processEvents() diff --git a/src/silx/gui/widgets/test/test_rangeslider.py b/src/silx/gui/widgets/test/test_rangeslider.py new file mode 100644 index 0000000..f829857 --- /dev/null +++ b/src/silx/gui/widgets/test/test_rangeslider.py @@ -0,0 +1,103 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 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. +# +# ###########################################################################*/ +"""Tests for RangeSlider""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "01/08/2018" + +import unittest + +from silx.gui import qt, colors +from silx.gui.widgets.RangeSlider import RangeSlider +from silx.gui.utils.testutils import TestCaseQt +from silx.utils.testutils import ParametricTestCase + + +class TestRangeSlider(TestCaseQt, ParametricTestCase): + """Tests for TestRangeSlider""" + + def setUp(self): + self.slider = RangeSlider() + self.slider.show() + self.qWaitForWindowExposed(self.slider) + + def tearDown(self): + self.slider.setAttribute(qt.Qt.WA_DeleteOnClose) + self.slider.close() + del self.slider + self.qapp.processEvents() + + def testRangeValue(self): + """Test slider range and values""" + + # Play with range + self.slider.setRange(1, 2) + self.assertEqual(self.slider.getRange(), (1., 2.)) + self.assertEqual(self.slider.getValues(), (1., 1.)) + + self.slider.setMinimum(-1) + self.assertEqual(self.slider.getRange(), (-1., 2.)) + self.assertEqual(self.slider.getValues(), (1., 1.)) + + self.slider.setMaximum(0) + self.assertEqual(self.slider.getRange(), (-1., 0.)) + self.assertEqual(self.slider.getValues(), (0., 0.)) + + # Play with values + self.slider.setFirstValue(-2.) + self.assertEqual(self.slider.getValues(), (-1., 0.)) + + self.slider.setFirstValue(-0.5) + self.assertEqual(self.slider.getValues(), (-0.5, 0.)) + + self.slider.setSecondValue(2.) + self.assertEqual(self.slider.getValues(), (-0.5, 0.)) + + self.slider.setSecondValue(-0.1) + self.assertEqual(self.slider.getValues(), (-0.5, -0.1)) + + def testStepCount(self): + """Test related to step count""" + self.slider.setPositionCount(11) + self.assertEqual(self.slider.getPositionCount(), 11) + self.slider.setFirstValue(0.32) + self.assertEqual(self.slider.getFirstValue(), 0.3) + self.assertEqual(self.slider.getFirstPosition(), 3) + + self.slider.setPositionCount(3) # Value is adjusted + self.assertEqual(self.slider.getValues(), (0.5, 1.)) + self.assertEqual(self.slider.getPositions(), (1, 2)) + + def testGroove(self): + """Test Groove pixmap""" + profile = list(range(100)) + + for cmap in ('jet', colors.Colormap('viridis')): + with self.subTest(str(cmap)): + self.slider.setGroovePixmapFromProfile(profile, cmap) + pixmap = self.slider.getGroovePixmap() + self.assertIsInstance(pixmap, qt.QPixmap) + self.assertEqual(pixmap.width(), len(profile)) diff --git a/src/silx/gui/widgets/test/test_tablewidget.py b/src/silx/gui/widgets/test/test_tablewidget.py new file mode 100644 index 0000000..09122ca --- /dev/null +++ b/src/silx/gui/widgets/test/test_tablewidget.py @@ -0,0 +1,50 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016 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. +# +# ###########################################################################*/ +"""Test TableWidget""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "05/12/2016" + + +import unittest +from silx.gui.utils.testutils import TestCaseQt +from silx.gui.widgets.TableWidget import TableWidget + + +class TestTableWidget(TestCaseQt): + def setUp(self): + super(TestTableWidget, self).setUp() + self._result = [] + + def testShow(self): + table = TableWidget() + table.setColumnCount(10) + table.setRowCount(7) + table.enableCut() + table.enablePaste() + table.show() + table.hide() + self.qapp.processEvents() diff --git a/src/silx/gui/widgets/test/test_threadpoolpushbutton.py b/src/silx/gui/widgets/test/test_threadpoolpushbutton.py new file mode 100644 index 0000000..3808be0 --- /dev/null +++ b/src/silx/gui/widgets/test/test_threadpoolpushbutton.py @@ -0,0 +1,124 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2021 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. +# +# ###########################################################################*/ +"""Test for silx.gui.hdf5 module""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "17/01/2018" + + +import unittest +import time +from silx.gui import qt +from silx.gui.utils.testutils import TestCaseQt +from silx.gui.utils.testutils import SignalListener +from silx.gui.widgets.ThreadPoolPushButton import ThreadPoolPushButton +from silx.utils.testutils import LoggingValidator + + +class TestThreadPoolPushButton(TestCaseQt): + + def setUp(self): + super(TestThreadPoolPushButton, self).setUp() + self._result = [] + + def waitForPendingOperations(self, object): + for i in range(50): + if not object.hasPendingOperations(): + break + self.qWait(10) + else: + raise RuntimeError("Still waiting for a pending operation") + + def _trace(self, name, delay=0): + self._result.append(name) + if delay != 0: + time.sleep(delay / 1000.0) + + def _compute(self): + return "result" + + def _computeFail(self): + raise Exception("exception") + + def testExecute(self): + button = ThreadPoolPushButton() + button.setCallable(self._trace, "a", 0) + button.executeCallable() + time.sleep(0.1) + self.assertListEqual(self._result, ["a"]) + self.waitForPendingOperations(button) + + def testMultiExecution(self): + button = ThreadPoolPushButton() + button.setCallable(self._trace, "a", 0) + number = qt.silxGlobalThreadPool().maxThreadCount() + for _ in range(number): + button.executeCallable() + self.waitForPendingOperations(button) + self.assertListEqual(self._result, ["a"] * number) + + def testSaturateThreadPool(self): + button = ThreadPoolPushButton() + button.setCallable(self._trace, "a", 100) + number = qt.silxGlobalThreadPool().maxThreadCount() * 2 + for _ in range(number): + button.executeCallable() + self.waitForPendingOperations(button) + self.assertListEqual(self._result, ["a"] * number) + + def testSuccess(self): + listener = SignalListener() + button = ThreadPoolPushButton() + button.setCallable(self._compute) + button.beforeExecuting.connect(listener.partial(test="be")) + button.started.connect(listener.partial(test="s")) + button.succeeded.connect(listener.partial(test="result")) + button.failed.connect(listener.partial(test="Unexpected exception")) + button.finished.connect(listener.partial(test="f")) + button.executeCallable() + self.qapp.processEvents() + time.sleep(0.1) + self.qapp.processEvents() + result = listener.karguments(argumentName="test") + self.assertListEqual(result, ["be", "s", "result", "f"]) + + def testFail(self): + listener = SignalListener() + button = ThreadPoolPushButton() + button.setCallable(self._computeFail) + button.beforeExecuting.connect(listener.partial(test="be")) + button.started.connect(listener.partial(test="s")) + button.succeeded.connect(listener.partial(test="Unexpected success")) + button.failed.connect(listener.partial(test="exception")) + button.finished.connect(listener.partial(test="f")) + with LoggingValidator('silx.gui.widgets.ThreadPoolPushButton', error=1): + button.executeCallable() + self.qapp.processEvents() + time.sleep(0.1) + self.qapp.processEvents() + result = listener.karguments(argumentName="test") + self.assertListEqual(result, ["be", "s", "exception", "f"]) + listener.clear() |