diff options
Diffstat (limited to 'silx/gui/widgets')
-rw-r--r-- | silx/gui/widgets/FrameBrowser.py | 307 | ||||
-rw-r--r-- | silx/gui/widgets/HierarchicalTableView.py | 172 | ||||
-rw-r--r-- | silx/gui/widgets/MedianFilterDialog.py | 74 | ||||
-rw-r--r-- | silx/gui/widgets/PeriodicTable.py | 825 | ||||
-rw-r--r-- | silx/gui/widgets/TableWidget.py | 488 | ||||
-rw-r--r-- | silx/gui/widgets/ThreadPoolPushButton.py | 233 | ||||
-rw-r--r-- | silx/gui/widgets/WaitingPushButton.py | 243 | ||||
-rw-r--r-- | silx/gui/widgets/__init__.py | 27 | ||||
-rw-r--r-- | silx/gui/widgets/setup.py | 41 | ||||
-rw-r--r-- | silx/gui/widgets/test/__init__.py | 45 | ||||
-rw-r--r-- | silx/gui/widgets/test/test_hierarchicaltableview.py | 117 | ||||
-rw-r--r-- | silx/gui/widgets/test/test_periodictable.py | 163 | ||||
-rw-r--r-- | silx/gui/widgets/test/test_tablewidget.py | 61 | ||||
-rw-r--r-- | silx/gui/widgets/test/test_threadpoolpushbutton.py | 129 |
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') |