summaryrefslogtreecommitdiff
path: root/silx/gui/widgets
diff options
context:
space:
mode:
authorPicca Frédéric-Emmanuel <picca@synchrotron-soleil.fr>2017-08-18 14:48:52 +0200
committerPicca Frédéric-Emmanuel <picca@synchrotron-soleil.fr>2017-08-18 14:48:52 +0200
commitf7bdc2acff3c13a6d632c28c4569690ab106eed7 (patch)
tree9d67cdb7152ee4e711379e03fe0546c7c3b97303 /silx/gui/widgets
Import Upstream version 0.5.0+dfsg
Diffstat (limited to 'silx/gui/widgets')
-rw-r--r--silx/gui/widgets/FrameBrowser.py307
-rw-r--r--silx/gui/widgets/HierarchicalTableView.py172
-rw-r--r--silx/gui/widgets/MedianFilterDialog.py74
-rw-r--r--silx/gui/widgets/PeriodicTable.py825
-rw-r--r--silx/gui/widgets/TableWidget.py488
-rw-r--r--silx/gui/widgets/ThreadPoolPushButton.py233
-rw-r--r--silx/gui/widgets/WaitingPushButton.py243
-rw-r--r--silx/gui/widgets/__init__.py27
-rw-r--r--silx/gui/widgets/setup.py41
-rw-r--r--silx/gui/widgets/test/__init__.py45
-rw-r--r--silx/gui/widgets/test/test_hierarchicaltableview.py117
-rw-r--r--silx/gui/widgets/test/test_periodictable.py163
-rw-r--r--silx/gui/widgets/test/test_tablewidget.py61
-rw-r--r--silx/gui/widgets/test/test_threadpoolpushbutton.py129
14 files changed, 2925 insertions, 0 deletions
diff --git a/silx/gui/widgets/FrameBrowser.py b/silx/gui/widgets/FrameBrowser.py
new file mode 100644
index 0000000..783a70a
--- /dev/null
+++ b/silx/gui/widgets/FrameBrowser.py
@@ -0,0 +1,307 @@
+# 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 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
+
+__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.
+
+ 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().width('%05d' % last))
+ 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._lineEdit.setText("%d" % self._lineEdit.validator().bottom())
+ self._textChangedSlot()
+
+ def _previousClicked(self):
+ """Select previous frame number"""
+ if self._index > self._lineEdit.validator().bottom():
+ self._lineEdit.setText("%d" % (self._index - 1))
+ self._textChangedSlot()
+
+ def _nextClicked(self):
+ """Select next frame number"""
+ if self._index < (self._lineEdit.validator().top()):
+ self._lineEdit.setText("%d" % (self._index + 1))
+ self._textChangedSlot()
+
+ def _lastClicked(self):
+ """Select last/highest frame number"""
+ self._lineEdit.setText("%d" % self._lineEdit.validator().top())
+ self._textChangedSlot()
+
+ 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 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"""
+ return self.setLimits(first, last)
+
+ def setLimits(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._index = bottom
+ self._lineEdit.setText("%d" % self._index)
+ self._label.setText(" limits: %d, %d " % (bottom, top))
+
+ 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"""
+ bottom = 0
+ top = nframes - 1
+ self._lineEdit.validator().setTop(top)
+ self._lineEdit.validator().setBottom(bottom)
+ self._index = bottom
+ self._lineEdit.setText("%d" % self._index)
+ # display 1-based index in label
+ self._label.setText(" %d of %d " % (self._index + 1, top + 1))
+
+ def getCurrentIndex(self):
+ """Get 0-based frame index
+ """
+ return self._index
+
+ def setValue(self, value):
+ """Set 0-based frame index
+
+ :param int value: Frame number"""
+ self._lineEdit.setText("%d" % value)
+ self._textChangedSlot()
+
+
+class HorizontalSliderWithBrowser(qt.QAbstractSlider):
+ """
+ Slider widget combining a :class:`QSlider` and a :class:`FrameBrowser`.
+
+ 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
+ """
+ sigIndexChanged = qt.pyqtSignal(object)
+
+ 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/silx/gui/widgets/HierarchicalTableView.py b/silx/gui/widgets/HierarchicalTableView.py
new file mode 100644
index 0000000..3ccf4c7
--- /dev/null
+++ b/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/silx/gui/widgets/MedianFilterDialog.py b/silx/gui/widgets/MedianFilterDialog.py
new file mode 100644
index 0000000..3eddff3
--- /dev/null
+++ b/silx/gui/widgets/MedianFilterDialog.py
@@ -0,0 +1,74 @@
+# 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.
+#
+# ###########################################################################*/
+""" MedianFilterDialog
+Classes
+-------
+
+Widgets:
+
+ - :class:`MedianFilterDialog`
+"""
+
+__authors__ = ["H. Payno"]
+__license__ = "MIT"
+__date__ = "14/02/2017"
+
+from silx.gui import qt
+
+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:
+ logging.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/silx/gui/widgets/PeriodicTable.py b/silx/gui/widgets/PeriodicTable.py
new file mode 100644
index 0000000..2f1ca78
--- /dev/null
+++ b/silx/gui/widgets/PeriodicTable.py
@@ -0,0 +1,825 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-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.
+#
+# ###########################################################################*/
+"""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
+
+ 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
+
+ :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`
+
+ :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/silx/gui/widgets/TableWidget.py b/silx/gui/widgets/TableWidget.py
new file mode 100644
index 0000000..fad80ee
--- /dev/null
+++ b/silx/gui/widgets/TableWidget.py
@@ -0,0 +1,488 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-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.
+#
+# ###########################################################################*/
+"""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__ = "26/01/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()
+ 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 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.
+
+ :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.addAction(CopySelectedCellsAction(self))
+ self.addAction(CopyAllCellsAction(self))
+ if cut:
+ self.enableCut()
+ if 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.addAction(PasteCellsAction(self))
+
+ 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.addAction(CutSelectedCellsAction(self))
+ self.addAction(CutAllCellsAction(self))
+
+
+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.cut = cut
+ self.paste = paste
+
+ 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.addAction(CopySelectedCellsAction(self))
+ self.addAction(CopyAllCellsAction(self))
+ 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.addAction(PasteCellsAction(self))
+
+ 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.addAction(CutSelectedCellsAction(self))
+ self.addAction(CutAllCellsAction(self))
+
+ 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)
+
+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/silx/gui/widgets/ThreadPoolPushButton.py b/silx/gui/widgets/ThreadPoolPushButton.py
new file mode 100644
index 0000000..29e831d
--- /dev/null
+++ b/silx/gui/widgets/ThreadPoolPushButton.py
@@ -0,0 +1,233 @@
+# 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.
+#
+# ###########################################################################*/
+"""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
+
+ >>> # 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.QThreadPool.globalInstance().start(runner)
+ self.__runnerSet.add(runner)
+
+ def __releaseRunner(self, runner):
+ self.__runnerSet.remove(runner)
+
+ 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/silx/gui/widgets/WaitingPushButton.py b/silx/gui/widgets/WaitingPushButton.py
new file mode 100644
index 0000000..49ab9b9
--- /dev/null
+++ b/silx/gui/widgets/WaitingPushButton.py
@@ -0,0 +1,243 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-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.
+#
+# ###########################################################################*/
+"""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
+ """
+
+ 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)
+ if qt.qVersion().startswith("4.8."):
+ # On PyQt4/PySide the method QCommonStyle sizeFromContents returns
+ # different size when the widget provides an icon or not.
+ # In Qt5 there is not this problem.
+ opt.icon = qt.QIcon()
+ sizeHint = self.style().sizeFromContents(qt.QStyle.CT_PushButton, opt, contentSize, self)
+ 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/silx/gui/widgets/__init__.py b/silx/gui/widgets/__init__.py
new file mode 100644
index 0000000..034f4d3
--- /dev/null
+++ b/silx/gui/widgets/__init__.py
@@ -0,0 +1,27 @@
+# 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 package provides a few simple Qt widgets that rely only on a Qt wrapper
+for Python (PyQt5, PyQt4 or PySide). No other optional dependencies of *silx*
+should be required."""
diff --git a/silx/gui/widgets/setup.py b/silx/gui/widgets/setup.py
new file mode 100644
index 0000000..e96ac8d
--- /dev/null
+++ b/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/silx/gui/widgets/test/__init__.py b/silx/gui/widgets/test/__init__.py
new file mode 100644
index 0000000..afa0f78
--- /dev/null
+++ b/silx/gui/widgets/test/__init__.py
@@ -0,0 +1,45 @@
+# 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.
+#
+# ###########################################################################*/
+import unittest
+
+from . import test_periodictable
+from . import test_tablewidget
+from . import test_threadpoolpushbutton
+from . import test_hierarchicaltableview
+
+__authors__ = ["V. Valls", "P. Knobel"]
+__license__ = "MIT"
+__date__ = "07/04/2017"
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTests(
+ [test_threadpoolpushbutton.suite(),
+ test_tablewidget.suite(),
+ test_periodictable.suite(),
+ test_hierarchicaltableview.suite(),
+ ])
+ return test_suite
diff --git a/silx/gui/widgets/test/test_hierarchicaltableview.py b/silx/gui/widgets/test/test_hierarchicaltableview.py
new file mode 100644
index 0000000..b3d37ed
--- /dev/null
+++ b/silx/gui/widgets/test/test_hierarchicaltableview.py
@@ -0,0 +1,117 @@
+# 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__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "07/04/2017"
+
+import unittest
+
+from .. import HierarchicalTableView
+from ...test.utils 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):
+ if qt.qVersion() > "4.6":
+ self.beginResetModel()
+ else:
+ self.reset()
+
+ 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
+ if qt.qVersion() > "4.6":
+ 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))
+
+
+def suite():
+ loader = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(loader(TestHierarchicalTableView))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/widgets/test/test_periodictable.py b/silx/gui/widgets/test/test_periodictable.py
new file mode 100644
index 0000000..c6bed81
--- /dev/null
+++ b/silx/gui/widgets/test/test_periodictable.py
@@ -0,0 +1,163 @@
+# 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 ...test.utils 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"])
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPeriodicTable))
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPeriodicList))
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPeriodicCombo))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/widgets/test/test_tablewidget.py b/silx/gui/widgets/test/test_tablewidget.py
new file mode 100644
index 0000000..5ad0a06
--- /dev/null
+++ b/silx/gui/widgets/test/test_tablewidget.py
@@ -0,0 +1,61 @@
+# 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.test.utils 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()
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestTableWidget))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/widgets/test/test_threadpoolpushbutton.py b/silx/gui/widgets/test/test_threadpoolpushbutton.py
new file mode 100644
index 0000000..126f8f3
--- /dev/null
+++ b/silx/gui/widgets/test/test_threadpoolpushbutton.py
@@ -0,0 +1,129 @@
+# 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 for silx.gui.hdf5 module"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "15/12/2016"
+
+
+import unittest
+import time
+from silx.gui import qt
+from silx.gui.test.utils import TestCaseQt
+from silx.gui.test.utils import SignalListener
+from silx.gui.widgets.ThreadPoolPushButton import ThreadPoolPushButton
+from silx.test.utils import TestLogging
+
+
+class TestThreadPoolPushButton(TestCaseQt):
+
+ def setUp(self):
+ super(TestThreadPoolPushButton, self).setUp()
+ self._result = []
+
+ 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.qapp.processEvents()
+
+ def testMultiExecution(self):
+ button = ThreadPoolPushButton()
+ button.setCallable(self._trace, "a", 0)
+ number = qt.QThreadPool.globalInstance().maxThreadCount() * 2
+ for _ in range(number):
+ button.executeCallable()
+ time.sleep(number * 0.01 + 0.1)
+ self.assertListEqual(self._result, ["a"] * number)
+ self.qapp.processEvents()
+
+ def testSaturateThreadPool(self):
+ button = ThreadPoolPushButton()
+ button.setCallable(self._trace, "a", 100)
+ number = qt.QThreadPool.globalInstance().maxThreadCount() * 2
+ for _ in range(number):
+ button.executeCallable()
+ time.sleep(number * 0.1 + 0.1)
+ self.assertListEqual(self._result, ["a"] * number)
+ self.qapp.processEvents()
+
+ 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 TestLogging('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()
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestThreadPoolPushButton))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')