summaryrefslogtreecommitdiff
path: root/silx/app/view
diff options
context:
space:
mode:
authorPicca Frédéric-Emmanuel <picca@synchrotron-soleil.fr>2018-07-31 16:22:25 +0200
committerPicca Frédéric-Emmanuel <picca@synchrotron-soleil.fr>2018-07-31 16:22:25 +0200
commit159ef14fb9e198bb0066ea14e6b980f065de63dd (patch)
treebc37c7d4ba09ee59deb708897fa0571709aec293 /silx/app/view
parent270d5ddc31c26b62379e3caa9044dd75ccc71847 (diff)
New upstream version 0.8.0+dfsg
Diffstat (limited to 'silx/app/view')
-rw-r--r--silx/app/view/About.py232
-rw-r--r--silx/app/view/ApplicationContext.py194
-rw-r--r--silx/app/view/CustomNxdataWidget.py1008
-rw-r--r--silx/app/view/DataPanel.py171
-rw-r--r--silx/app/view/Viewer.py686
-rw-r--r--silx/app/view/__init__.py28
-rw-r--r--silx/app/view/main.py168
-rw-r--r--silx/app/view/setup.py40
-rw-r--r--silx/app/view/test/__init__.py41
-rw-r--r--silx/app/view/test/test_launcher.py145
-rw-r--r--silx/app/view/test/test_view.py402
-rw-r--r--silx/app/view/utils.py45
12 files changed, 3160 insertions, 0 deletions
diff --git a/silx/app/view/About.py b/silx/app/view/About.py
new file mode 100644
index 0000000..07306ef
--- /dev/null
+++ b/silx/app/view/About.py
@@ -0,0 +1,232 @@
+# 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.
+#
+# ############################################################################*/
+"""About box for Silx viewer"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "05/06/2018"
+
+import sys
+
+from silx.gui import qt
+from silx.gui import icons
+
+_LICENSE_TEMPLATE = """<p align="center">
+<b>Copyright (C) {year} European Synchrotron Radiation Facility</b>
+</p>
+
+<p align="justify">
+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:
+</p>
+
+<p align="justify">
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+</p>
+
+<p align="justify">
+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.
+</p>
+"""
+
+
+class About(qt.QDialog):
+ """
+ Util dialog to display an common about box for all the silx GUIs.
+ """
+
+ def __init__(self, parent=None):
+ """
+ :param files_: List of HDF5 or Spec files (pathes or
+ :class:`silx.io.spech5.SpecH5` or :class:`h5py.File`
+ instances)
+ """
+ super(About, self).__init__(parent)
+ self.__createLayout()
+ self.setSizePolicy(qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed)
+ self.setModal(True)
+ self.setApplicationName(None)
+
+ def __createLayout(self):
+ layout = qt.QVBoxLayout(self)
+ layout.setContentsMargins(24, 15, 24, 20)
+ layout.setSpacing(8)
+
+ self.__label = qt.QLabel(self)
+ self.__label.setWordWrap(True)
+ flags = self.__label.textInteractionFlags()
+ flags = flags | qt.Qt.TextSelectableByKeyboard
+ flags = flags | qt.Qt.TextSelectableByMouse
+ self.__label.setTextInteractionFlags(flags)
+ self.__label.setOpenExternalLinks(True)
+ self.__label.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Preferred)
+
+ licenseButton = qt.QPushButton(self)
+ licenseButton.setText("License...")
+ licenseButton.clicked.connect(self.__displayLicense)
+ licenseButton.setAutoDefault(False)
+
+ self.__options = qt.QDialogButtonBox()
+ self.__options.addButton(licenseButton, qt.QDialogButtonBox.ActionRole)
+ okButton = self.__options.addButton(qt.QDialogButtonBox.Ok)
+ okButton.setDefault(True)
+ okButton.clicked.connect(self.accept)
+
+ layout.addWidget(self.__label)
+ layout.addWidget(self.__options)
+ layout.setStretch(0, 100)
+ layout.setStretch(1, 0)
+
+ def getHtmlLicense(self):
+ """Returns the text license in HTML format.
+
+ :rtype: str
+ """
+ from silx._version import __date__ as date
+ year = date.split("/")[2]
+ info = dict(
+ year=year
+ )
+ textLicense = _LICENSE_TEMPLATE.format(**info)
+ return textLicense
+
+ def __displayLicense(self):
+ """Displays the license used by silx."""
+ text = self.getHtmlLicense()
+ licenseDialog = qt.QMessageBox(self)
+ licenseDialog.setWindowTitle("License")
+ licenseDialog.setText(text)
+ licenseDialog.exec_()
+
+ def setApplicationName(self, name):
+ self.__applicationName = name
+ if name is None:
+ self.setWindowTitle("About")
+ else:
+ self.setWindowTitle("About %s" % name)
+ self.__updateText()
+
+ @staticmethod
+ def __formatOptionalLibraries(name, isAvailable):
+ """Utils to format availability of features"""
+ if isAvailable:
+ template = '<b>%s</b> is <font color="green">loaded</font>'
+ else:
+ template = '<b>%s</b> is <font color="red">not loaded</font>'
+ return template % name
+
+ def __updateText(self):
+ """Update the content of the dialog according to the settings."""
+ import silx._version
+
+ message = """<table>
+ <tr><td width="50%" align="center" valign="middle">
+ <img src="{silx_image_path}" width="100" />
+ </td><td width="50%" align="center" valign="middle">
+ <b>{application_name}</b>
+ <br />
+ <br />{silx_version}
+ <br />
+ <br /><a href="{project_url}">Upstream project on GitHub</a>
+ </td></tr>
+ </table>
+ <dl>
+ <dt><b>Silx version</b></dt><dd>{silx_version}</dd>
+ <dt><b>Qt version</b></dt><dd>{qt_version}</dd>
+ <dt><b>Qt binding</b></dt><dd>{qt_binding}</dd>
+ <dt><b>Python version</b></dt><dd>{python_version}</dd>
+ <dt><b>Optional libraries</b></dt><dd>{optional_lib}</dd>
+ </dl>
+ <p>
+ Copyright (C) <a href="{esrf_url}">European Synchrotron Radiation Facility</a>
+ </p>
+ """
+
+ hdf5pluginLoaded = "hdf5plugin" in sys.modules
+ fabioLoaded = "fabio" in sys.modules
+ h5pyLoaded = "h5py" in sys.modules
+
+ optional_lib = []
+ optional_lib.append(self.__formatOptionalLibraries("FabIO", fabioLoaded))
+ optional_lib.append(self.__formatOptionalLibraries("H5py", h5pyLoaded))
+ optional_lib.append(self.__formatOptionalLibraries("hdf5plugin", hdf5pluginLoaded))
+
+ # Access to the logo in SVG or PNG
+ logo = icons.getQFile("../logo/silx")
+
+ info = dict(
+ application_name=self.__applicationName,
+ esrf_url="http://www.esrf.eu",
+ project_url="https://github.com/silx-kit/silx",
+ silx_version=silx._version.version,
+ qt_binding=qt.BINDING,
+ qt_version=qt.qVersion(),
+ python_version=sys.version.replace("\n", "<br />"),
+ optional_lib="<br />".join(optional_lib),
+ silx_image_path=logo.fileName()
+ )
+
+ self.__label.setText(message.format(**info))
+ self.__updateSize()
+
+ def __updateSize(self):
+ """Force the size to a QMessageBox like size."""
+ screenSize = qt.QApplication.desktop().availableGeometry(qt.QCursor.pos()).size()
+ hardLimit = min(screenSize.width() - 480, 1000)
+ if screenSize.width() <= 1024:
+ hardLimit = screenSize.width()
+ softLimit = min(screenSize.width() / 2, 420)
+
+ layoutMinimumSize = self.layout().totalMinimumSize()
+ width = layoutMinimumSize.width()
+ if width > softLimit:
+ width = softLimit
+ if width > hardLimit:
+ width = hardLimit
+
+ height = layoutMinimumSize.height()
+ self.setFixedSize(width, height)
+
+ @staticmethod
+ def about(parent, applicationName):
+ """Displays a silx about box with title and text text.
+
+ :param qt.QWidget parent: The parent widget
+ :param str title: The title of the dialog
+ :param str applicationName: The content of the dialog
+ """
+ dialog = About(parent)
+ dialog.setApplicationName(applicationName)
+ dialog.exec_()
diff --git a/silx/app/view/ApplicationContext.py b/silx/app/view/ApplicationContext.py
new file mode 100644
index 0000000..8693848
--- /dev/null
+++ b/silx/app/view/ApplicationContext.py
@@ -0,0 +1,194 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2016-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+"""Browse a data file with a GUI"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "23/05/2018"
+
+import weakref
+import logging
+
+import silx
+from silx.gui.data.DataViews import DataViewHooks
+from silx.gui.colors import Colormap
+from silx.gui.dialog.ColormapDialog import ColormapDialog
+
+
+_logger = logging.getLogger(__name__)
+
+
+class ApplicationContext(DataViewHooks):
+ """
+ Store the conmtext of the application
+
+ It overwrites the DataViewHooks to custom the use of the DataViewer for
+ the silx view application.
+
+ - Create a single colormap shared with all the views
+ - Create a single colormap dialog shared with all the views
+ """
+
+ def __init__(self, parent, settings=None):
+ self.__parent = weakref.ref(parent)
+ self.__defaultColormap = None
+ self.__defaultColormapDialog = None
+ self.__settings = settings
+ self.__recentFiles = []
+
+ def getSettings(self):
+ """Returns actual application settings.
+
+ :rtype: qt.QSettings
+ """
+ return self.__settings
+
+ def restoreLibrarySettings(self):
+ """Restore the library settings, which must be done early"""
+ settings = self.__settings
+ if settings is None:
+ return
+ settings.beginGroup("library")
+ plotBackend = settings.value("plot.backend", "")
+ plotImageYAxisOrientation = settings.value("plot-image.y-axis-orientation", "")
+ settings.endGroup()
+
+ if plotBackend != "":
+ silx.config.DEFAULT_PLOT_BACKEND = plotBackend
+ if plotImageYAxisOrientation != "":
+ silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = plotImageYAxisOrientation
+
+ def restoreSettings(self):
+ """Restore the settings of all the application"""
+ settings = self.__settings
+ if settings is None:
+ return
+ parent = self.__parent()
+ parent.restoreSettings(settings)
+
+ settings.beginGroup("colormap")
+ byteArray = settings.value("default", None)
+ if byteArray is not None:
+ try:
+ colormap = Colormap()
+ colormap.restoreState(byteArray)
+ self.__defaultColormap = colormap
+ except Exception:
+ _logger.debug("Backtrace", exc_info=True)
+ settings.endGroup()
+
+ self.__recentFiles = []
+ settings.beginGroup("recent-files")
+ for index in range(1, 10 + 1):
+ if not settings.contains("path%d" % index):
+ break
+ filePath = settings.value("path%d" % index)
+ self.__recentFiles.append(filePath)
+ settings.endGroup()
+
+ def saveSettings(self):
+ """Save the settings of all the application"""
+ settings = self.__settings
+ if settings is None:
+ return
+ parent = self.__parent()
+ parent.saveSettings(settings)
+
+ if self.__defaultColormap is not None:
+ settings.beginGroup("colormap")
+ settings.setValue("default", self.__defaultColormap.saveState())
+ settings.endGroup()
+
+ settings.beginGroup("library")
+ settings.setValue("plot.backend", silx.config.DEFAULT_PLOT_BACKEND)
+ settings.setValue("plot-image.y-axis-orientation", silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION)
+ settings.endGroup()
+
+ settings.beginGroup("recent-files")
+ for index in range(0, 11):
+ key = "path%d" % (index + 1)
+ if index < len(self.__recentFiles):
+ filePath = self.__recentFiles[index]
+ settings.setValue(key, filePath)
+ else:
+ settings.remove(key)
+ settings.endGroup()
+
+ def getRecentFiles(self):
+ """Returns the list of recently opened files.
+
+ The list is limited to the last 10 entries. The newest file path is
+ in first.
+
+ :rtype: List[str]
+ """
+ return self.__recentFiles
+
+ def pushRecentFile(self, filePath):
+ """Push a new recent file to the list.
+
+ If the file is duplicated in the list, all duplications are removed
+ before inserting the new filePath.
+
+ If the list becan bigger than 10 items, oldest paths are removed.
+
+ :param filePath: File path to push
+ """
+ # Remove old occurencies
+ self.__recentFiles[:] = (f for f in self.__recentFiles if f != filePath)
+ self.__recentFiles.insert(0, filePath)
+ while len(self.__recentFiles) > 10:
+ self.__recentFiles.pop()
+
+ def clearRencentFiles(self):
+ """Clear the history of the rencent files.
+ """
+ self.__recentFiles[:] = []
+
+ def getColormap(self, view):
+ """Returns a default colormap.
+
+ Override from DataViewHooks
+
+ :rtype: Colormap
+ """
+ if self.__defaultColormap is None:
+ self.__defaultColormap = Colormap(name="viridis")
+ return self.__defaultColormap
+
+ def getColormapDialog(self, view):
+ """Returns a shared color dialog as default for all the views.
+
+ Override from DataViewHooks
+
+ :rtype: ColorDialog
+ """
+ if self.__defaultColormapDialog is None:
+ parent = self.__parent()
+ if parent is None:
+ return None
+ dialog = ColormapDialog(parent=parent)
+ dialog.setModal(False)
+ self.__defaultColormapDialog = dialog
+ return self.__defaultColormapDialog
diff --git a/silx/app/view/CustomNxdataWidget.py b/silx/app/view/CustomNxdataWidget.py
new file mode 100644
index 0000000..02ae6c0
--- /dev/null
+++ b/silx/app/view/CustomNxdataWidget.py
@@ -0,0 +1,1008 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2016-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+
+"""Widget to custom NXdata groups"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "15/06/2018"
+
+import logging
+import numpy
+import weakref
+
+from silx.gui import qt
+from silx.io import commonh5
+import silx.io.nxdata
+from silx.gui.hdf5._utils import Hdf5DatasetMimeData
+from silx.gui.data.TextFormatter import TextFormatter
+from silx.gui.hdf5.Hdf5Formatter import Hdf5Formatter
+from silx.gui import icons
+
+
+_logger = logging.getLogger(__name__)
+_formatter = TextFormatter()
+_hdf5Formatter = Hdf5Formatter(textFormatter=_formatter)
+
+
+class _RowItems(qt.QStandardItem):
+ """Define the list of items used for a specific row."""
+
+ def type(self):
+ return qt.QStandardItem.UserType + 1
+
+ def getRowItems(self):
+ """Returns the list of items used for a specific row.
+
+ The first item should be this class.
+
+ :rtype: List[qt.QStandardItem]
+ """
+ raise NotImplementedError()
+
+
+class _DatasetItemRow(_RowItems):
+ """Define a row which can contain a dataset."""
+
+ def __init__(self, label="", dataset=None):
+ """Constructor"""
+ super(_DatasetItemRow, self).__init__(label)
+ self.setEditable(False)
+ self.setDropEnabled(False)
+ self.setDragEnabled(False)
+
+ self.__name = qt.QStandardItem()
+ self.__name.setEditable(False)
+ self.__name.setDropEnabled(True)
+
+ self.__type = qt.QStandardItem()
+ self.__type.setEditable(False)
+ self.__type.setDropEnabled(False)
+ self.__type.setDragEnabled(False)
+
+ self.__shape = qt.QStandardItem()
+ self.__shape.setEditable(False)
+ self.__shape.setDropEnabled(False)
+ self.__shape.setDragEnabled(False)
+
+ self.setDataset(dataset)
+
+ def getDefaultFormatter(self):
+ """Get the formatter used to display dataset informations.
+
+ :rtype: Hdf5Formatter
+ """
+ return _hdf5Formatter
+
+ def setDataset(self, dataset):
+ """Set the dataset stored in this item.
+
+ :param Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset] dataset:
+ The dataset to store.
+ """
+ self.__dataset = dataset
+ if self.__dataset is not None:
+ name = self.__dataset.name
+
+ if silx.io.is_dataset(dataset):
+ type_ = self.getDefaultFormatter().humanReadableType(dataset)
+ shape = self.getDefaultFormatter().humanReadableShape(dataset)
+
+ if dataset.shape is None:
+ icon_name = "item-none"
+ elif len(dataset.shape) < 4:
+ icon_name = "item-%ddim" % len(dataset.shape)
+ else:
+ icon_name = "item-ndim"
+ icon = icons.getQIcon(icon_name)
+ else:
+ type_ = ""
+ shape = ""
+ icon = qt.QIcon()
+ else:
+ name = ""
+ type_ = ""
+ shape = ""
+ icon = qt.QIcon()
+
+ self.__icon = icon
+ self.__name.setText(name)
+ self.__name.setDragEnabled(self.__dataset is not None)
+ self.__name.setIcon(self.__icon)
+ self.__type.setText(type_)
+ self.__shape.setText(shape)
+
+ parent = self.parent()
+ if parent is not None:
+ self.parent()._datasetUpdated()
+
+ def getDataset(self):
+ """Returns the dataset stored within the item."""
+ return self.__dataset
+
+ def getRowItems(self):
+ """Returns the list of items used for a specific row.
+
+ The first item should be this class.
+
+ :rtype: List[qt.QStandardItem]
+ """
+ return [self, self.__name, self.__type, self.__shape]
+
+
+class _DatasetAxisItemRow(_DatasetItemRow):
+ """Define a row describing an axis."""
+
+ def __init__(self):
+ """Constructor"""
+ super(_DatasetAxisItemRow, self).__init__()
+
+ def setAxisId(self, axisId):
+ """Set the id of the axis (the first axis is 0)
+
+ :param int axisId: Identifier of this axis.
+ """
+ self.__axisId = axisId
+ label = "Axis %d" % (axisId + 1)
+ self.setText(label)
+
+ def getAxisId(self):
+ """Returns the identifier of this axis.
+
+ :rtype: int
+ """
+ return self.__axisId
+
+
+class _NxDataItem(qt.QStandardItem):
+ """
+ Define a custom NXdata.
+ """
+
+ def __init__(self):
+ """Constructor"""
+ qt.QStandardItem.__init__(self)
+ self.__error = None
+ self.__title = None
+ self.__axes = []
+ self.__virtual = None
+
+ item = _DatasetItemRow("Signal", None)
+ self.appendRow(item.getRowItems())
+ self.__signal = item
+
+ self.setEditable(False)
+ self.setDragEnabled(False)
+ self.setDropEnabled(False)
+ self.__setError(None)
+
+ def getRowItems(self):
+ """Returns the list of items used for a specific row.
+
+ The first item should be this class.
+
+ :rtype: List[qt.QStandardItem]
+ """
+ row = [self]
+ for _ in range(3):
+ item = qt.QStandardItem("")
+ item.setEditable(False)
+ item.setDragEnabled(False)
+ item.setDropEnabled(False)
+ row.append(item)
+ return row
+
+ def _datasetUpdated(self):
+ """Called when the NXdata contained of the item have changed.
+
+ It invalidates the NXdata stored and send an event `sigNxdataUpdated`.
+ """
+ self.__virtual = None
+ self.__setError(None)
+ model = self.model()
+ if model is not None:
+ model.sigNxdataUpdated.emit(self.index())
+
+ def createVirtualGroup(self):
+ """Returns a new virtual Group using a NeXus NXdata structure to store
+ data
+
+ :rtype: silx.io.commonh5.Group
+ """
+ name = ""
+ if self.__title is not None:
+ name = self.__title
+ virtual = commonh5.Group(name)
+ virtual.attrs["NX_class"] = "NXdata"
+
+ if self.__title is not None:
+ virtual.attrs["title"] = self.__title
+
+ if self.__signal is not None:
+ signal = self.__signal.getDataset()
+ if signal is not None:
+ # Could be done using a link instead of a copy
+ node = commonh5.DatasetProxy("signal", target=signal)
+ virtual.attrs["signal"] = "signal"
+ virtual.add_node(node)
+
+ axesAttr = []
+ for i, axis in enumerate(self.__axes):
+ if axis is None:
+ name = "."
+ else:
+ axis = axis.getDataset()
+ if axis is None:
+ name = "."
+ else:
+ name = "axis%d" % i
+ node = commonh5.DatasetProxy(name, target=axis)
+ virtual.add_node(node)
+ axesAttr.append(name)
+
+ if axesAttr != []:
+ virtual.attrs["axes"] = numpy.array(axesAttr)
+
+ validator = silx.io.nxdata.NXdata(virtual)
+ if not validator.is_valid:
+ message = "<html>"
+ message += "This NXdata is not consistant"
+ message += "<ul>"
+ for issue in validator.issues:
+ message += "<li>%s</li>" % issue
+ message += "</ul>"
+ message += "</html>"
+ self.__setError(message)
+ else:
+ self.__setError(None)
+ return virtual
+
+ def isValid(self):
+ """Returns true if the stored NXdata is valid
+
+ :rtype: bool
+ """
+ return self.__error is None
+
+ def getVirtualGroup(self):
+ """Returns a cached virtual Group using a NeXus NXdata structure to
+ store data.
+
+ If the stored NXdata was invalidated, :meth:`createVirtualGroup` is
+ internally called to update the cache.
+
+ :rtype: silx.io.commonh5.Group
+ """
+ if self.__virtual is None:
+ self.__virtual = self.createVirtualGroup()
+ return self.__virtual
+
+ def getTitle(self):
+ """Returns the title of the NXdata
+
+ :rtype: str
+ """
+ return self.text()
+
+ def setTitle(self, title):
+ """Set the title of the NXdata
+
+ :param str title: The title of this NXdata
+ """
+ self.setText(title)
+
+ def __setError(self, error):
+ """Set the error message in case of the current state of the stored
+ NXdata is not valid.
+
+ :param str error: Message to display
+ """
+ self.__error = error
+ style = qt.QApplication.style()
+ if error is None:
+ message = ""
+ icon = style.standardIcon(qt.QStyle.SP_DirLinkIcon)
+ else:
+ message = error
+ icon = style.standardIcon(qt.QStyle.SP_MessageBoxCritical)
+ self.setIcon(icon)
+ self.setToolTip(message)
+
+ def getError(self):
+ """Returns the error message in case the NXdata is not valid.
+
+ :rtype: str"""
+ return self.__error
+
+ def setSignalDataset(self, dataset):
+ """Set the dataset to use as signal with this NXdata.
+
+ :param Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset] dataset:
+ The dataset to use as signal.
+ """
+
+ self.__signal.setDataset(dataset)
+ self._datasetUpdated()
+
+ def getSignalDataset(self):
+ """Returns the dataset used as signal.
+
+ :rtype: Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset]
+ """
+ return self.__signal.getDataset()
+
+ def setAxesDatasets(self, datasets):
+ """Set all the available dataset used as axes.
+
+ Axes will be created or removed from the GUI in order to provide the
+ same amount of requested axes.
+
+ A `None` element is an axes with no dataset.
+
+ :param List[Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset,None]] datasets:
+ List of dataset to use as axes.
+ """
+ for i, dataset in enumerate(datasets):
+ if i < len(self.__axes):
+ mustAppend = False
+ item = self.__axes[i]
+ else:
+ mustAppend = True
+ item = _DatasetAxisItemRow()
+ item.setAxisId(i)
+ item.setDataset(dataset)
+ if mustAppend:
+ self.__axes.append(item)
+ self.appendRow(item.getRowItems())
+
+ # Clean up extra axis
+ for i in range(len(datasets), len(self.__axes)):
+ item = self.__axes.pop(len(datasets))
+ self.removeRow(item.row())
+
+ self._datasetUpdated()
+
+ def getAxesDatasets(self):
+ """Returns available axes as dataset.
+
+ A `None` element is an axes with no dataset.
+
+ :rtype: List[Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset,None]]
+ """
+ datasets = []
+ for axis in self.__axes:
+ datasets.append(axis.getDataset())
+ return datasets
+
+
+class _Model(qt.QStandardItemModel):
+ """Model storing a list of custom NXdata items.
+
+ Supports drag and drop of datasets.
+ """
+
+ sigNxdataUpdated = qt.Signal(qt.QModelIndex)
+ """Emitted when stored NXdata was edited"""
+
+ def __init__(self, parent=None):
+ """Constructor"""
+ qt.QStandardItemModel.__init__(self, parent)
+ root = self.invisibleRootItem()
+ root.setDropEnabled(True)
+ root.setDragEnabled(False)
+
+ def supportedDropActions(self):
+ """Inherited method to redefine supported drop actions."""
+ return qt.Qt.CopyAction | qt.Qt.MoveAction
+
+ def mimeTypes(self):
+ """Inherited method to redefine draggable mime types."""
+ return [Hdf5DatasetMimeData.MIME_TYPE]
+
+ def mimeData(self, indexes):
+ """
+ Returns an object that contains serialized items of data corresponding
+ to the list of indexes specified.
+
+ :param List[qt.QModelIndex] indexes: List of indexes
+ :rtype: qt.QMimeData
+ """
+ if len(indexes) > 1:
+ return None
+ if len(indexes) == 0:
+ return None
+
+ qindex = indexes[0]
+ qindex = self.index(qindex.row(), 0, parent=qindex.parent())
+ item = self.itemFromIndex(qindex)
+ if isinstance(item, _DatasetItemRow):
+ dataset = item.getDataset()
+ if dataset is None:
+ return None
+ else:
+ mimeData = Hdf5DatasetMimeData(dataset=item.getDataset())
+ else:
+ mimeData = None
+ return mimeData
+
+ def dropMimeData(self, mimedata, action, row, column, parentIndex):
+ """Inherited method to handle a drop operation to this model."""
+ if action == qt.Qt.IgnoreAction:
+ return True
+
+ if mimedata.hasFormat(Hdf5DatasetMimeData.MIME_TYPE):
+ if row != -1 or column != -1:
+ # It is not a drop on a specific item
+ return False
+ item = self.itemFromIndex(parentIndex)
+ if item is None or item is self.invisibleRootItem():
+ # Drop at the end
+ dataset = mimedata.dataset()
+ if silx.io.is_dataset(dataset):
+ self.createFromSignal(dataset)
+ elif silx.io.is_group(dataset):
+ nxdata = dataset
+ try:
+ self.createFromNxdata(nxdata)
+ except ValueError:
+ _logger.error("Error while dropping a group as an NXdata")
+ _logger.debug("Backtrace", exc_info=True)
+ return False
+ else:
+ _logger.error("Dropping a wrong object")
+ return False
+ else:
+ item = item.parent().child(item.row(), 0)
+ if not isinstance(item, _DatasetItemRow):
+ # Dropped at a bad place
+ return False
+ dataset = mimedata.dataset()
+ if silx.io.is_dataset(dataset):
+ item.setDataset(dataset)
+ else:
+ _logger.error("Dropping a wrong object")
+ return False
+ return True
+
+ return False
+
+ def __getNxdataByTitle(self, title):
+ """Returns an NXdata item by its title, else None.
+
+ :rtype: Union[_NxDataItem,None]
+ """
+ for row in range(self.rowCount()):
+ qindex = self.index(row, 0)
+ item = self.itemFromIndex(qindex)
+ if item.getTitle() == title:
+ return item
+ return None
+
+ def findFreeNxdataTitle(self):
+ """Returns an NXdata title which is not yet used.
+
+ :rtype: str
+ """
+ for i in range(self.rowCount() + 1):
+ name = "NXData #%d" % (i + 1)
+ group = self.__getNxdataByTitle(name)
+ if group is None:
+ break
+ return name
+
+ def createNewNxdata(self, name=None):
+ """Create a new NXdata item.
+
+ :param Union[str,None] name: A title for the new NXdata
+ """
+ item = _NxDataItem()
+ if name is None:
+ name = self.findFreeNxdataTitle()
+ item.setTitle(name)
+ self.appendRow(item.getRowItems())
+
+ def createFromSignal(self, dataset):
+ """Create a new NXdata item from a signal dataset.
+
+ This signal will also define an amount of axes according to its number
+ of dimensions.
+
+ :param Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset] dataset:
+ A dataset uses as signal.
+ """
+
+ item = _NxDataItem()
+ name = self.findFreeNxdataTitle()
+ item.setTitle(name)
+ item.setSignalDataset(dataset)
+ item.setAxesDatasets([None] * len(dataset.shape))
+ self.appendRow(item.getRowItems())
+
+ def createFromNxdata(self, nxdata):
+ """Create a new custom NXdata item from an existing NXdata group.
+
+ If the NXdata is not valid, nothing is created, and an exception is
+ returned.
+
+ :param Union[h5py.Group,silx.io.commonh5.Group] nxdata: An h5py group
+ following the NXData specification.
+ :raise ValueError:If `nxdata` is not valid.
+ """
+ validator = silx.io.nxdata.NXdata(nxdata)
+ if validator.is_valid:
+ item = _NxDataItem()
+ title = validator.title
+ if title in [None or ""]:
+ title = self.findFreeNxdataTitle()
+ item.setTitle(title)
+ item.setSignalDataset(validator.signal)
+ item.setAxesDatasets(validator.axes)
+ self.appendRow(item.getRowItems())
+ else:
+ raise ValueError("Not a valid NXdata")
+
+ def removeNxdataItem(self, item):
+ """Remove an NXdata item from this model.
+
+ :param _NxDataItem item: An item
+ """
+ if isinstance(item, _NxDataItem):
+ parent = item.parent()
+ assert(parent is None)
+ model = item.model()
+ model.removeRow(item.row())
+ else:
+ _logger.error("Unexpected item")
+
+ def appendAxisToNxdataItem(self, item):
+ """Append a new axes to this item (or the NXdata item own by this item).
+
+ :param Union[_NxDataItem,qt.QStandardItem] item: An item
+ """
+ if item is not None and not isinstance(item, _NxDataItem):
+ item = item.parent()
+ nxdataItem = item
+ if isinstance(item, _NxDataItem):
+ datasets = nxdataItem.getAxesDatasets()
+ datasets.append(None)
+ nxdataItem.setAxesDatasets(datasets)
+ else:
+ _logger.error("Unexpected item")
+
+ def removeAxisItem(self, item):
+ """Remove an axis item from this model.
+
+ :param _DatasetAxisItemRow item: An axis item
+ """
+ if isinstance(item, _DatasetAxisItemRow):
+ axisId = item.getAxisId()
+ nxdataItem = item.parent()
+ datasets = nxdataItem.getAxesDatasets()
+ del datasets[axisId]
+ nxdataItem.setAxesDatasets(datasets)
+ else:
+ _logger.error("Unexpected item")
+
+
+class CustomNxDataToolBar(qt.QToolBar):
+ """A specialised toolbar to manage custom NXdata model and items."""
+
+ def __init__(self, parent=None):
+ """Constructor"""
+ super(CustomNxDataToolBar, self).__init__(parent=parent)
+ self.__nxdataWidget = None
+ self.__initContent()
+ # Initialize action state
+ self.__currentSelectionChanged(qt.QModelIndex(), qt.QModelIndex())
+
+ def __initContent(self):
+ """Create all expected actions and set the content of this toolbar."""
+ action = qt.QAction("Create a new custom NXdata", self)
+ action.setIcon(icons.getQIcon("nxdata-create"))
+ action.triggered.connect(self.__createNewNxdata)
+ self.addAction(action)
+ self.__addNxDataAction = action
+
+ action = qt.QAction("Remove the selected NXdata", self)
+ action.setIcon(icons.getQIcon("nxdata-remove"))
+ action.triggered.connect(self.__removeSelectedNxdata)
+ self.addAction(action)
+ self.__removeNxDataAction = action
+
+ self.addSeparator()
+
+ action = qt.QAction("Create a new axis to the selected NXdata", self)
+ action.setIcon(icons.getQIcon("nxdata-axis-add"))
+ action.triggered.connect(self.__appendNewAxisToSelectedNxdata)
+ self.addAction(action)
+ self.__addNxDataAxisAction = action
+
+ action = qt.QAction("Remove the selected NXdata axis", self)
+ action.setIcon(icons.getQIcon("nxdata-axis-remove"))
+ action.triggered.connect(self.__removeSelectedAxis)
+ self.addAction(action)
+ self.__removeNxDataAxisAction = action
+
+ def __getSelectedItem(self):
+ """Get the selected item from the linked CustomNxdataWidget.
+
+ :rtype: qt.QStandardItem
+ """
+ selectionModel = self.__nxdataWidget.selectionModel()
+ index = selectionModel.currentIndex()
+ if not index.isValid():
+ return
+ model = self.__nxdataWidget.model()
+ index = model.index(index.row(), 0, index.parent())
+ item = model.itemFromIndex(index)
+ return item
+
+ def __createNewNxdata(self):
+ """Create a new NXdata item to the linked CustomNxdataWidget."""
+ if self.__nxdataWidget is None:
+ return
+ model = self.__nxdataWidget.model()
+ model.createNewNxdata()
+
+ def __removeSelectedNxdata(self):
+ """Remove the NXdata item currently selected in the linked
+ CustomNxdataWidget."""
+ if self.__nxdataWidget is None:
+ return
+ model = self.__nxdataWidget.model()
+ item = self.__getSelectedItem()
+ model.removeNxdataItem(item)
+
+ def __appendNewAxisToSelectedNxdata(self):
+ """Append a new axis to the NXdata item currently selected in the
+ linked CustomNxdataWidget."""
+ if self.__nxdataWidget is None:
+ return
+ model = self.__nxdataWidget.model()
+ item = self.__getSelectedItem()
+ model.appendAxisToNxdataItem(item)
+
+ def __removeSelectedAxis(self):
+ """Remove the axis item currently selected in the linked
+ CustomNxdataWidget."""
+ if self.__nxdataWidget is None:
+ return
+ model = self.__nxdataWidget.model()
+ item = self.__getSelectedItem()
+ model.removeAxisItem(item)
+
+ def setCustomNxDataWidget(self, widget):
+ """Set the linked CustomNxdataWidget to this toolbar."""
+ assert(isinstance(widget, CustomNxdataWidget))
+ if self.__nxdataWidget is not None:
+ selectionModel = self.__nxdataWidget.selectionModel()
+ selectionModel.currentChanged.disconnect(self.__currentSelectionChanged)
+ self.__nxdataWidget = widget
+ if self.__nxdataWidget is not None:
+ selectionModel = self.__nxdataWidget.selectionModel()
+ selectionModel.currentChanged.connect(self.__currentSelectionChanged)
+
+ def __currentSelectionChanged(self, current, previous):
+ """Update the actions according to the linked CustomNxdataWidget
+ item selection"""
+ if not current.isValid():
+ item = None
+ else:
+ model = self.__nxdataWidget.model()
+ index = model.index(current.row(), 0, current.parent())
+ item = model.itemFromIndex(index)
+ self.__removeNxDataAction.setEnabled(isinstance(item, _NxDataItem))
+ self.__removeNxDataAxisAction.setEnabled(isinstance(item, _DatasetAxisItemRow))
+ self.__addNxDataAxisAction.setEnabled(isinstance(item, _NxDataItem) or isinstance(item, _DatasetItemRow))
+
+
+class _HashDropZones(qt.QStyledItemDelegate):
+ """Delegate item displaying a drop zone when the item do not contains
+ dataset."""
+
+ def __init__(self, parent=None):
+ """Constructor"""
+ super(_HashDropZones, self).__init__(parent)
+ pen = qt.QPen()
+ pen.setColor(qt.QColor("#D0D0D0"))
+ pen.setStyle(qt.Qt.DotLine)
+ pen.setWidth(2)
+ self.__dropPen = pen
+
+ def paint(self, painter, option, index):
+ """
+ Paint the item
+
+ :param qt.QPainter painter: A painter
+ :param qt.QStyleOptionViewItem option: Options of the item to paint
+ :param qt.QModelIndex index: Index of the item to paint
+ """
+ displayDropZone = False
+ if index.isValid():
+ model = index.model()
+ rowIndex = model.index(index.row(), 0, index.parent())
+ rowItem = model.itemFromIndex(rowIndex)
+ if isinstance(rowItem, _DatasetItemRow):
+ displayDropZone = rowItem.getDataset() is None
+
+ if displayDropZone:
+ painter.save()
+
+ # Draw background if selected
+ if option.state & qt.QStyle.State_Selected:
+ colorGroup = qt.QPalette.Inactive
+ if option.state & qt.QStyle.State_Active:
+ colorGroup = qt.QPalette.Active
+ if not option.state & qt.QStyle.State_Enabled:
+ colorGroup = qt.QPalette.Disabled
+ brush = option.palette.brush(colorGroup, qt.QPalette.Highlight)
+ painter.fillRect(option.rect, brush)
+
+ painter.setPen(self.__dropPen)
+ painter.drawRect(option.rect.adjusted(3, 3, -3, -3))
+ painter.restore()
+ else:
+ qt.QStyledItemDelegate.paint(self, painter, option, index)
+
+
+class CustomNxdataWidget(qt.QTreeView):
+ """Widget providing a table displaying and allowing to custom virtual
+ NXdata."""
+
+ sigNxdataItemUpdated = qt.Signal(qt.QStandardItem)
+ """Emitted when the NXdata from an NXdata item was edited"""
+
+ sigNxdataItemRemoved = qt.Signal(qt.QStandardItem)
+ """Emitted when an NXdata item was removed"""
+
+ def __init__(self, parent=None):
+ """Constructor"""
+ qt.QTreeView.__init__(self, parent=None)
+ self.__model = _Model(self)
+ self.__model.setColumnCount(4)
+ self.__model.setHorizontalHeaderLabels(["Name", "Dataset", "Type", "Shape"])
+ self.setModel(self.__model)
+
+ self.setItemDelegateForColumn(1, _HashDropZones(self))
+
+ self.__model.sigNxdataUpdated.connect(self.__nxdataUpdate)
+ self.__model.rowsAboutToBeRemoved.connect(self.__rowsAboutToBeRemoved)
+ self.__model.rowsAboutToBeInserted.connect(self.__rowsAboutToBeInserted)
+
+ header = self.header()
+ if qt.qVersion() < "5.0":
+ setResizeMode = header.setResizeMode
+ else:
+ setResizeMode = header.setSectionResizeMode
+ setResizeMode(0, qt.QHeaderView.ResizeToContents)
+ setResizeMode(1, qt.QHeaderView.Stretch)
+ setResizeMode(2, qt.QHeaderView.ResizeToContents)
+ setResizeMode(3, qt.QHeaderView.ResizeToContents)
+
+ self.setSelectionMode(qt.QAbstractItemView.SingleSelection)
+ self.setDropIndicatorShown(True)
+ self.setDragDropOverwriteMode(True)
+ self.setDragEnabled(True)
+ self.viewport().setAcceptDrops(True)
+
+ self.setContextMenuPolicy(qt.Qt.CustomContextMenu)
+ self.customContextMenuRequested[qt.QPoint].connect(self.__executeContextMenu)
+
+ def __rowsAboutToBeInserted(self, parentIndex, start, end):
+ if qt.qVersion()[0:2] == "5.":
+ # FIXME: workaround for https://github.com/silx-kit/silx/issues/1919
+ # Uses of ResizeToContents looks to break nice update of cells with Qt5
+ # This patch make the view blinking
+ self.repaint()
+
+ def __rowsAboutToBeRemoved(self, parentIndex, start, end):
+ """Called when an item was removed from the model."""
+ items = []
+ model = self.model()
+ for index in range(start, end):
+ qindex = model.index(index, 0, parent=parentIndex)
+ item = self.__model.itemFromIndex(qindex)
+ if isinstance(item, _NxDataItem):
+ items.append(item)
+ for item in items:
+ self.sigNxdataItemRemoved.emit(item)
+
+ if qt.qVersion()[0:2] == "5.":
+ # FIXME: workaround for https://github.com/silx-kit/silx/issues/1919
+ # Uses of ResizeToContents looks to break nice update of cells with Qt5
+ # This patch make the view blinking
+ self.repaint()
+
+ def __nxdataUpdate(self, index):
+ """Called when a virtual NXdata was updated from the model."""
+ model = self.model()
+ item = model.itemFromIndex(index)
+ self.sigNxdataItemUpdated.emit(item)
+
+ def createDefaultContextMenu(self, index):
+ """Create a default context menu at this position.
+
+ :param qt.QModelIndex index: Index of the item
+ """
+ index = self.__model.index(index.row(), 0, parent=index.parent())
+ item = self.__model.itemFromIndex(index)
+
+ menu = qt.QMenu()
+
+ weakself = weakref.proxy(self)
+
+ if isinstance(item, _NxDataItem):
+ action = qt.QAction("Add a new axis", menu)
+ action.triggered.connect(lambda: weakself.model().appendAxisToNxdataItem(item))
+ action.setIcon(icons.getQIcon("nxdata-axis-add"))
+ action.setIconVisibleInMenu(True)
+ menu.addAction(action)
+ menu.addSeparator()
+ action = qt.QAction("Remove this NXdata", menu)
+ action.triggered.connect(lambda: weakself.model().removeNxdataItem(item))
+ action.setIcon(icons.getQIcon("remove"))
+ action.setIconVisibleInMenu(True)
+ menu.addAction(action)
+ else:
+ if isinstance(item, _DatasetItemRow):
+ if item.getDataset() is not None:
+ action = qt.QAction("Remove this dataset", menu)
+ action.triggered.connect(lambda: item.setDataset(None))
+ menu.addAction(action)
+
+ if isinstance(item, _DatasetAxisItemRow):
+ menu.addSeparator()
+ action = qt.QAction("Remove this axis", menu)
+ action.triggered.connect(lambda: weakself.model().removeAxisItem(item))
+ action.setIcon(icons.getQIcon("remove"))
+ action.setIconVisibleInMenu(True)
+ menu.addAction(action)
+
+ return menu
+
+ def __executeContextMenu(self, point):
+ """Execute the context menu at this position."""
+ index = self.indexAt(point)
+ menu = self.createDefaultContextMenu(index)
+ if menu is None or menu.isEmpty():
+ return
+ menu.exec_(qt.QCursor.pos())
+
+ def removeDatasetsFrom(self, root):
+ """
+ Remove all datasets provided by this root
+
+ :param root: The root file of datasets to remove
+ """
+ for row in range(self.__model.rowCount()):
+ qindex = self.__model.index(row, 0)
+ item = self.model().itemFromIndex(qindex)
+
+ edited = False
+ datasets = item.getAxesDatasets()
+ for i, dataset in enumerate(datasets):
+ if dataset is not None:
+ # That's an approximation, IS can't be used as h5py generates
+ # To objects for each requests to a node
+ if dataset.file.filename == root.file.filename:
+ datasets[i] = None
+ edited = True
+ if edited:
+ item.setAxesDatasets(datasets)
+
+ dataset = item.getSignalDataset()
+ if dataset is not None:
+ # That's an approximation, IS can't be used as h5py generates
+ # To objects for each requests to a node
+ if dataset.file.filename == root.file.filename:
+ item.setSignalDataset(None)
+
+ def replaceDatasetsFrom(self, removedRoot, loadedRoot):
+ """
+ Replace any dataset from any NXdata items using the same dataset name
+ from another root.
+
+ Usually used when a file was synchronized.
+
+ :param removedRoot: The h5py root file which is replaced
+ (which have to be removed)
+ :param loadedRoot: The new h5py root file which have to be used
+ instread.
+ """
+ for row in range(self.__model.rowCount()):
+ qindex = self.__model.index(row, 0)
+ item = self.model().itemFromIndex(qindex)
+
+ edited = False
+ datasets = item.getAxesDatasets()
+ for i, dataset in enumerate(datasets):
+ newDataset = self.__replaceDatasetRoot(dataset, removedRoot, loadedRoot)
+ if dataset is not newDataset:
+ datasets[i] = newDataset
+ edited = True
+ if edited:
+ item.setAxesDatasets(datasets)
+
+ dataset = item.getSignalDataset()
+ newDataset = self.__replaceDatasetRoot(dataset, removedRoot, loadedRoot)
+ if dataset is not newDataset:
+ item.setSignalDataset(newDataset)
+
+ def __replaceDatasetRoot(self, dataset, fromRoot, toRoot):
+ """
+ Replace the dataset by the same dataset name from another root.
+ """
+ if dataset is None:
+ return None
+
+ if dataset.file is None:
+ # Not from the expected root
+ return dataset
+
+ # That's an approximation, IS can't be used as h5py generates
+ # To objects for each requests to a node
+ if dataset.file.filename == fromRoot.file.filename:
+ # Try to find the same dataset name
+ try:
+ return toRoot[dataset.name]
+ except Exception:
+ _logger.debug("Backtrace", exc_info=True)
+ return None
+ else:
+ # Not from the expected root
+ return dataset
+
+ def selectedItems(self):
+ """Returns the list of selected items containing NXdata
+
+ :rtype: List[qt.QStandardItem]
+ """
+ result = []
+ for qindex in self.selectedIndexes():
+ if qindex.column() != 0:
+ continue
+ if not qindex.isValid():
+ continue
+ item = self.__model.itemFromIndex(qindex)
+ if not isinstance(item, _NxDataItem):
+ continue
+ result.append(item)
+ return result
+
+ def selectedNxdata(self):
+ """Returns the list of selected NXdata
+
+ :rtype: List[silx.io.commonh5.Group]
+ """
+ result = []
+ for qindex in self.selectedIndexes():
+ if qindex.column() != 0:
+ continue
+ if not qindex.isValid():
+ continue
+ item = self.__model.itemFromIndex(qindex)
+ if not isinstance(item, _NxDataItem):
+ continue
+ result.append(item.getVirtualGroup())
+ return result
diff --git a/silx/app/view/DataPanel.py b/silx/app/view/DataPanel.py
new file mode 100644
index 0000000..0653f74
--- /dev/null
+++ b/silx/app/view/DataPanel.py
@@ -0,0 +1,171 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+"""Browse a data file with a GUI"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "06/06/2018"
+
+import logging
+
+from silx.gui import qt
+from silx.gui.data.DataViewerFrame import DataViewerFrame
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _HeaderLabel(qt.QLabel):
+
+ def __init__(self, parent=None):
+ qt.QLabel.__init__(self, parent=parent)
+ self.setFrameShape(qt.QFrame.StyledPanel)
+
+ def sizeHint(self):
+ return qt.QSize(10, 30)
+
+ def paintEvent(self, event):
+ painter = qt.QPainter(self)
+
+ opt = qt.QStyleOptionHeader()
+ opt.orientation = qt.Qt.Horizontal
+ opt.text = self.text()
+ opt.textAlignment = self.alignment()
+ opt.direction = self.layoutDirection()
+ opt.fontMetrics = self.fontMetrics()
+ opt.palette = self.palette()
+ opt.state = qt.QStyle.State_Active
+ opt.position = qt.QStyleOptionHeader.Beginning
+ style = self.style()
+
+ # Background
+ margin = -1
+ opt.rect = self.rect().adjusted(margin, margin, -margin, -margin)
+ style.drawControl(qt.QStyle.CE_HeaderSection, opt, painter, None)
+
+ # Frame border and text
+ super(_HeaderLabel, self).paintEvent(event)
+
+
+class DataPanel(qt.QWidget):
+
+ def __init__(self, parent=None, context=None):
+ qt.QWidget.__init__(self, parent=parent)
+
+ self.__customNxdataItem = None
+
+ self.__dataTitle = _HeaderLabel(self)
+ self.__dataTitle.setVisible(False)
+
+ self.__dataViewer = DataViewerFrame(self)
+ self.__dataViewer.setGlobalHooks(context)
+
+ layout = qt.QVBoxLayout(self)
+ layout.setSpacing(0)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self.__dataTitle)
+ layout.addWidget(self.__dataViewer)
+
+ def getData(self):
+ return self.__dataViewer.data()
+
+ def getCustomNxdataItem(self):
+ return self.__customNxdataItem
+
+ def setData(self, data):
+ self.__customNxdataItem = None
+ self.__dataViewer.setData(data)
+ self.__dataTitle.setVisible(data is not None)
+ if data is not None:
+ self.__dataTitle.setVisible(True)
+ if hasattr(data, "name"):
+ if hasattr(data, "file"):
+ label = str(data.file.filename)
+ label += "::"
+ else:
+ label = ""
+ label += data.name
+ else:
+ label = ""
+ self.__dataTitle.setText(label)
+
+ def setCustomDataItem(self, item):
+ self.__customNxdataItem = item
+ if item is not None:
+ data = item.getVirtualGroup()
+ else:
+ data = None
+ self.__dataViewer.setData(data)
+ self.__dataTitle.setVisible(item is not None)
+ if item is not None:
+ text = item.text()
+ self.__dataTitle.setText(text)
+
+ def removeDatasetsFrom(self, root):
+ """
+ Remove all datasets provided by this root
+
+ .. note:: This function do not update data stored inside
+ customNxdataItem cause in the silx-view context this item is
+ already updated on his own.
+
+ :param root: The root file of datasets to remove
+ """
+ data = self.__dataViewer.data()
+ if data is not None:
+ if data.file is not None:
+ # That's an approximation, IS can't be used as h5py generates
+ # To objects for each requests to a node
+ if data.file.filename == root.file.filename:
+ self.__dataViewer.setData(None)
+
+ def replaceDatasetsFrom(self, removedH5, loadedH5):
+ """
+ Replace any dataset from any NXdata items using the same dataset name
+ from another root.
+
+ Usually used when a file was synchronized.
+
+ .. note:: This function do not update data stored inside
+ customNxdataItem cause in the silx-view context this item is
+ already updated on his own.
+
+ :param removedRoot: The h5py root file which is replaced
+ (which have to be removed)
+ :param loadedRoot: The new h5py root file which have to be used
+ instread.
+ """
+
+ data = self.__dataViewer.data()
+ if data is not None:
+ if data.file is not None:
+ if data.file.filename == removedH5.file.filename:
+ # Try to synchonize the viewed data
+ try:
+ # TODO: It have to update the data without changing the
+ # view which is not so easy
+ newData = loadedH5[data.name]
+ self.__dataViewer.setData(newData)
+ except Exception:
+ _logger.debug("Backtrace", exc_info=True)
diff --git a/silx/app/view/Viewer.py b/silx/app/view/Viewer.py
new file mode 100644
index 0000000..8f5db60
--- /dev/null
+++ b/silx/app/view/Viewer.py
@@ -0,0 +1,686 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2016-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+"""Browse a data file with a GUI"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "25/06/2018"
+
+
+import os
+import collections
+import logging
+import functools
+
+import silx.io.nxdata
+from silx.gui import qt
+from silx.gui import icons
+import silx.gui.hdf5
+from .ApplicationContext import ApplicationContext
+from .CustomNxdataWidget import CustomNxdataWidget
+from .CustomNxdataWidget import CustomNxDataToolBar
+from . import utils
+from .DataPanel import DataPanel
+
+
+_logger = logging.getLogger(__name__)
+
+
+class Viewer(qt.QMainWindow):
+ """
+ This window allows to browse a data file like images or HDF5 and it's
+ content.
+ """
+
+ def __init__(self, parent=None, settings=None):
+ """
+ Constructor
+ """
+
+ qt.QMainWindow.__init__(self, parent)
+ self.setWindowTitle("Silx viewer")
+
+ self.__context = ApplicationContext(self, settings)
+ self.__context.restoreLibrarySettings()
+
+ self.__dialogState = None
+ self.__customNxDataItem = None
+ self.__treeview = silx.gui.hdf5.Hdf5TreeView(self)
+ self.__treeview.setExpandsOnDoubleClick(False)
+ """Silx HDF5 TreeView"""
+
+ rightPanel = qt.QSplitter(self)
+ rightPanel.setOrientation(qt.Qt.Vertical)
+ self.__splitter2 = rightPanel
+
+ self.__treeWindow = self.__createTreeWindow(self.__treeview)
+
+ # Custom the model to be able to manage the life cycle of the files
+ treeModel = silx.gui.hdf5.Hdf5TreeModel(self.__treeview, ownFiles=False)
+ treeModel.sigH5pyObjectLoaded.connect(self.__h5FileLoaded)
+ treeModel.sigH5pyObjectRemoved.connect(self.__h5FileRemoved)
+ treeModel.sigH5pyObjectSynchronized.connect(self.__h5FileSynchonized)
+ treeModel.setDatasetDragEnabled(True)
+ treeModel2 = silx.gui.hdf5.NexusSortFilterProxyModel(self.__treeview)
+ treeModel2.setSourceModel(treeModel)
+ treeModel2.sort(0, qt.Qt.AscendingOrder)
+ treeModel2.setSortCaseSensitivity(qt.Qt.CaseInsensitive)
+
+ self.__treeview.setModel(treeModel2)
+ rightPanel.addWidget(self.__treeWindow)
+
+ self.__customNxdata = CustomNxdataWidget(self)
+ self.__customNxdata.setSelectionBehavior(qt.QAbstractItemView.SelectRows)
+ # optimise the rendering
+ self.__customNxdata.setUniformRowHeights(True)
+ self.__customNxdata.setIconSize(qt.QSize(16, 16))
+ self.__customNxdata.setExpandsOnDoubleClick(False)
+
+ self.__customNxdataWindow = self.__createCustomNxdataWindow(self.__customNxdata)
+ self.__customNxdataWindow.setVisible(False)
+ rightPanel.addWidget(self.__customNxdataWindow)
+
+ rightPanel.setStretchFactor(1, 1)
+ rightPanel.setCollapsible(0, False)
+ rightPanel.setCollapsible(1, False)
+
+ self.__dataPanel = DataPanel(self, self.__context)
+
+ spliter = qt.QSplitter(self)
+ spliter.addWidget(rightPanel)
+ spliter.addWidget(self.__dataPanel)
+ spliter.setStretchFactor(1, 1)
+ self.__splitter = spliter
+
+ main_panel = qt.QWidget(self)
+ layout = qt.QVBoxLayout()
+ layout.addWidget(spliter)
+ layout.setStretchFactor(spliter, 1)
+ main_panel.setLayout(layout)
+
+ self.setCentralWidget(main_panel)
+
+ self.__treeview.activated.connect(self.displaySelectedData)
+ self.__customNxdata.activated.connect(self.displaySelectedCustomData)
+ self.__customNxdata.sigNxdataItemRemoved.connect(self.__customNxdataRemoved)
+ self.__customNxdata.sigNxdataItemUpdated.connect(self.__customNxdataUpdated)
+ self.__treeview.addContextMenuCallback(self.customContextMenu)
+
+ treeModel = self.__treeview.findHdf5TreeModel()
+ columns = list(treeModel.COLUMN_IDS)
+ columns.remove(treeModel.DESCRIPTION_COLUMN)
+ columns.remove(treeModel.NODE_COLUMN)
+ self.__treeview.header().setSections(columns)
+
+ self._iconUpward = icons.getQIcon('plot-yup')
+ self._iconDownward = icons.getQIcon('plot-ydown')
+
+ self.createActions()
+ self.createMenus()
+ self.__context.restoreSettings()
+
+ def __createTreeWindow(self, treeView):
+ toolbar = qt.QToolBar(self)
+ toolbar.setIconSize(qt.QSize(16, 16))
+ toolbar.setStyleSheet("QToolBar { border: 0px }")
+
+ action = qt.QAction(toolbar)
+ action.setIcon(icons.getQIcon("tree-expand-all"))
+ action.setText("Expand all")
+ action.setToolTip("Expand all selected items")
+ action.triggered.connect(self.__expandAllSelected)
+ action.setShortcut(qt.QKeySequence(qt.Qt.ControlModifier + qt.Qt.Key_Plus))
+ toolbar.addAction(action)
+ treeView.addAction(action)
+ self.__expandAllAction = action
+
+ action = qt.QAction(toolbar)
+ action.setIcon(icons.getQIcon("tree-collapse-all"))
+ action.setText("Collapse all")
+ action.setToolTip("Collapse all selected items")
+ action.triggered.connect(self.__collapseAllSelected)
+ action.setShortcut(qt.QKeySequence(qt.Qt.ControlModifier + qt.Qt.Key_Minus))
+ toolbar.addAction(action)
+ treeView.addAction(action)
+ self.__collapseAllAction = action
+
+ widget = qt.QWidget(self)
+ layout = qt.QVBoxLayout(widget)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.addWidget(toolbar)
+ layout.addWidget(treeView)
+ return widget
+
+ def __expandAllSelected(self):
+ """Expand all selected items of the tree.
+
+ The depth is fixed to avoid infinite loop with recurssive links.
+ """
+ qt.QApplication.setOverrideCursor(qt.Qt.WaitCursor)
+
+ selection = self.__treeview.selectionModel()
+ indexes = selection.selectedIndexes()
+ model = self.__treeview.model()
+ while len(indexes) > 0:
+ index = indexes.pop(0)
+ if isinstance(index, tuple):
+ index, depth = index
+ else:
+ depth = 0
+
+ if depth > 10:
+ # Avoid infinite loop with recursive links
+ break
+
+ if model.hasChildren(index):
+ self.__treeview.setExpanded(index, True)
+ for row in range(model.rowCount(index)):
+ childIndex = model.index(row, 0, index)
+ indexes.append((childIndex, depth + 1))
+ qt.QApplication.restoreOverrideCursor()
+
+ def __collapseAllSelected(self):
+ """Collapse all selected items of the tree.
+
+ The depth is fixed to avoid infinite loop with recurssive links.
+ """
+ selection = self.__treeview.selectionModel()
+ indexes = selection.selectedIndexes()
+ model = self.__treeview.model()
+ while len(indexes) > 0:
+ index = indexes.pop(0)
+ if isinstance(index, tuple):
+ index, depth = index
+ else:
+ depth = 0
+
+ if depth > 10:
+ # Avoid infinite loop with recursive links
+ break
+
+ if model.hasChildren(index):
+ self.__treeview.setExpanded(index, False)
+ for row in range(model.rowCount(index)):
+ childIndex = model.index(row, 0, index)
+ indexes.append((childIndex, depth + 1))
+
+ def __createCustomNxdataWindow(self, customNxdataWidget):
+ toolbar = CustomNxDataToolBar(self)
+ toolbar.setCustomNxDataWidget(customNxdataWidget)
+ toolbar.setIconSize(qt.QSize(16, 16))
+ toolbar.setStyleSheet("QToolBar { border: 0px }")
+
+ widget = qt.QWidget(self)
+ layout = qt.QVBoxLayout(widget)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.addWidget(toolbar)
+ layout.addWidget(customNxdataWidget)
+ return widget
+
+ def __h5FileLoaded(self, loadedH5):
+ self.__context.pushRecentFile(loadedH5.file.filename)
+
+ def __h5FileRemoved(self, removedH5):
+ self.__dataPanel.removeDatasetsFrom(removedH5)
+ self.__customNxdata.removeDatasetsFrom(removedH5)
+ removedH5.close()
+
+ def __h5FileSynchonized(self, removedH5, loadedH5):
+ self.__dataPanel.replaceDatasetsFrom(removedH5, loadedH5)
+ self.__customNxdata.replaceDatasetsFrom(removedH5, loadedH5)
+ removedH5.close()
+
+ def closeEvent(self, event):
+ self.__context.saveSettings()
+
+ # Clean up as much as possible Python objects
+ model = self.__customNxdata.model()
+ model.clear()
+ model = self.__treeview.findHdf5TreeModel()
+ model.clear()
+
+ def saveSettings(self, settings):
+ """Save the window settings to this settings object
+
+ :param qt.QSettings settings: Initialized settings
+ """
+ isFullScreen = bool(self.windowState() & qt.Qt.WindowFullScreen)
+ if isFullScreen:
+ # show in normal to catch the normal geometry
+ self.showNormal()
+
+ settings.beginGroup("mainwindow")
+ settings.setValue("size", self.size())
+ settings.setValue("pos", self.pos())
+ settings.setValue("full-screen", isFullScreen)
+ settings.endGroup()
+
+ settings.beginGroup("mainlayout")
+ settings.setValue("spliter", self.__splitter.sizes())
+ settings.setValue("spliter2", self.__splitter2.sizes())
+ isVisible = self.__customNxdataWindow.isVisible()
+ settings.setValue("custom-nxdata-window-visible", isVisible)
+ settings.endGroup()
+
+ if isFullScreen:
+ self.showFullScreen()
+
+ def restoreSettings(self, settings):
+ """Restore the window settings using this settings object
+
+ :param qt.QSettings settings: Initialized settings
+ """
+ settings.beginGroup("mainwindow")
+ size = settings.value("size", qt.QSize(640, 480))
+ pos = settings.value("pos", qt.QPoint())
+ isFullScreen = settings.value("full-screen", False)
+ try:
+ if not isinstance(isFullScreen, bool):
+ isFullScreen = utils.stringToBool(isFullScreen)
+ except ValueError:
+ isFullScreen = False
+ settings.endGroup()
+
+ settings.beginGroup("mainlayout")
+ try:
+ data = settings.value("spliter")
+ data = [int(d) for d in data]
+ self.__splitter.setSizes(data)
+ except Exception:
+ _logger.debug("Backtrace", exc_info=True)
+ try:
+ data = settings.value("spliter2")
+ data = [int(d) for d in data]
+ self.__splitter2.setSizes(data)
+ except Exception:
+ _logger.debug("Backtrace", exc_info=True)
+ isVisible = settings.value("custom-nxdata-window-visible", False)
+ try:
+ if not isinstance(isVisible, bool):
+ isVisible = utils.stringToBool(isVisible)
+ except ValueError:
+ isVisible = False
+ self.__customNxdataWindow.setVisible(isVisible)
+ self._displayCustomNxdataWindow.setChecked(isVisible)
+
+ settings.endGroup()
+
+ if not pos.isNull():
+ self.move(pos)
+ if not size.isNull():
+ self.resize(size)
+ if isFullScreen:
+ self.showFullScreen()
+
+ def createActions(self):
+ action = qt.QAction("E&xit", self)
+ action.setShortcuts(qt.QKeySequence.Quit)
+ action.setStatusTip("Exit the application")
+ action.triggered.connect(self.close)
+ self._exitAction = action
+
+ action = qt.QAction("&Open...", self)
+ action.setStatusTip("Open a file")
+ action.triggered.connect(self.open)
+ self._openAction = action
+
+ action = qt.QAction("Open Recent", self)
+ action.setStatusTip("Open a recently openned file")
+ action.triggered.connect(self.open)
+ self._openRecentAction = action
+
+ action = qt.QAction("&About", self)
+ action.setStatusTip("Show the application's About box")
+ action.triggered.connect(self.about)
+ self._aboutAction = action
+
+ # Plot backend
+
+ action = qt.QAction("Plot rendering backend", self)
+ action.setStatusTip("Select plot rendering backend")
+ self._plotBackendSelection = action
+
+ menu = qt.QMenu()
+ action.setMenu(menu)
+ group = qt.QActionGroup(self)
+ group.setExclusive(True)
+
+ action = qt.QAction("matplotlib", self)
+ action.setStatusTip("Plot will be rendered using matplotlib")
+ action.setCheckable(True)
+ action.triggered.connect(self.__forceMatplotlibBackend)
+ group.addAction(action)
+ menu.addAction(action)
+ self._usePlotWithMatplotlib = action
+
+ action = qt.QAction("OpenGL", self)
+ action.setStatusTip("Plot will be rendered using OpenGL")
+ action.setCheckable(True)
+ action.triggered.connect(self.__forceOpenglBackend)
+ group.addAction(action)
+ menu.addAction(action)
+ self._usePlotWithOpengl = action
+
+ # Plot image orientation
+
+ action = qt.QAction("Default plot image y-axis orientation", self)
+ action.setStatusTip("Select the default y-axis orientation used by plot displaying images")
+ self._plotImageOrientation = action
+
+ menu = qt.QMenu()
+ action.setMenu(menu)
+ group = qt.QActionGroup(self)
+ group.setExclusive(True)
+
+ action = qt.QAction("Downward, origin on top", self)
+ action.setIcon(self._iconDownward)
+ action.setStatusTip("Plot images will use a downward Y-axis orientation")
+ action.setCheckable(True)
+ action.triggered.connect(self.__forcePlotImageDownward)
+ group.addAction(action)
+ menu.addAction(action)
+ self._useYAxisOrientationDownward = action
+
+ action = qt.QAction("Upward, origin on bottom", self)
+ action.setIcon(self._iconUpward)
+ action.setStatusTip("Plot images will use a upward Y-axis orientation")
+ action.setCheckable(True)
+ action.triggered.connect(self.__forcePlotImageUpward)
+ group.addAction(action)
+ menu.addAction(action)
+ self._useYAxisOrientationUpward = action
+
+ # Windows
+
+ action = qt.QAction("Show custom NXdata selector", self)
+ action.setStatusTip("Show a widget which allow to create plot by selecting data and axes")
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_F5))
+ action.toggled.connect(self.__toggleCustomNxdataWindow)
+ self._displayCustomNxdataWindow = action
+
+ def __toggleCustomNxdataWindow(self):
+ isVisible = self._displayCustomNxdataWindow.isChecked()
+ self.__customNxdataWindow.setVisible(isVisible)
+
+ def __updateFileMenu(self):
+ files = self.__context.getRecentFiles()
+ self._openRecentAction.setEnabled(len(files) != 0)
+ menu = None
+ if len(files) != 0:
+ menu = qt.QMenu()
+ for filePath in files:
+ baseName = os.path.basename(filePath)
+ action = qt.QAction(baseName, self)
+ action.setToolTip(filePath)
+ action.triggered.connect(functools.partial(self.__openRecentFile, filePath))
+ menu.addAction(action)
+ menu.addSeparator()
+ baseName = os.path.basename(filePath)
+ action = qt.QAction("Clear history", self)
+ action.setToolTip("Clear the history of the recent files")
+ action.triggered.connect(self.__clearRecentFile)
+ menu.addAction(action)
+ self._openRecentAction.setMenu(menu)
+
+ def __clearRecentFile(self):
+ self.__context.clearRencentFiles()
+
+ def __openRecentFile(self, filePath):
+ self.appendFile(filePath)
+
+ def __updateOptionMenu(self):
+ """Update the state of the checked options as it is based on global
+ environment values."""
+
+ # plot backend
+
+ action = self._plotBackendSelection
+ title = action.text().split(": ", 1)[0]
+ action.setText("%s: %s" % (title, silx.config.DEFAULT_PLOT_BACKEND))
+
+ action = self._usePlotWithMatplotlib
+ action.setChecked(silx.config.DEFAULT_PLOT_BACKEND in ["matplotlib", "mpl"])
+ title = action.text().split(" (", 1)[0]
+ if not action.isChecked():
+ title += " (applied after application restart)"
+ action.setText(title)
+
+ action = self._usePlotWithOpengl
+ action.setChecked(silx.config.DEFAULT_PLOT_BACKEND in ["opengl", "gl"])
+ title = action.text().split(" (", 1)[0]
+ if not action.isChecked():
+ title += " (applied after application restart)"
+ action.setText(title)
+
+ # plot orientation
+
+ action = self._plotImageOrientation
+ if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == "downward":
+ action.setIcon(self._iconDownward)
+ else:
+ action.setIcon(self._iconUpward)
+ action.setIconVisibleInMenu(True)
+
+ action = self._useYAxisOrientationDownward
+ action.setChecked(silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == "downward")
+ title = action.text().split(" (", 1)[0]
+ if not action.isChecked():
+ title += " (applied after application restart)"
+ action.setText(title)
+
+ action = self._useYAxisOrientationUpward
+ action.setChecked(silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION != "downward")
+ title = action.text().split(" (", 1)[0]
+ if not action.isChecked():
+ title += " (applied after application restart)"
+ action.setText(title)
+
+ def createMenus(self):
+ fileMenu = self.menuBar().addMenu("&File")
+ fileMenu.addAction(self._openAction)
+ fileMenu.addAction(self._openRecentAction)
+ fileMenu.addSeparator()
+ fileMenu.addAction(self._exitAction)
+ fileMenu.aboutToShow.connect(self.__updateFileMenu)
+
+ optionMenu = self.menuBar().addMenu("&Options")
+ optionMenu.addAction(self._plotImageOrientation)
+ optionMenu.addAction(self._plotBackendSelection)
+ optionMenu.aboutToShow.connect(self.__updateOptionMenu)
+
+ viewMenu = self.menuBar().addMenu("&Views")
+ viewMenu.addAction(self._displayCustomNxdataWindow)
+
+ helpMenu = self.menuBar().addMenu("&Help")
+ helpMenu.addAction(self._aboutAction)
+
+ def open(self):
+ dialog = self.createFileDialog()
+ if self.__dialogState is None:
+ currentDirectory = os.getcwd()
+ dialog.setDirectory(currentDirectory)
+ else:
+ dialog.restoreState(self.__dialogState)
+
+ result = dialog.exec_()
+ if not result:
+ return
+
+ self.__dialogState = dialog.saveState()
+
+ filenames = dialog.selectedFiles()
+ for filename in filenames:
+ self.appendFile(filename)
+
+ def createFileDialog(self):
+ dialog = qt.QFileDialog(self)
+ dialog.setWindowTitle("Open")
+ dialog.setModal(True)
+
+ # NOTE: hdf5plugin have to be loaded before
+ extensions = collections.OrderedDict()
+ for description, ext in silx.io.supported_extensions().items():
+ extensions[description] = " ".join(sorted(list(ext)))
+
+ try:
+ # NOTE: hdf5plugin have to be loaded before
+ import fabio
+ except Exception:
+ _logger.debug("Backtrace while loading fabio", exc_info=True)
+ fabio = None
+
+ if fabio is not None:
+ extensions["NeXus layout from EDF files"] = "*.edf"
+ extensions["NeXus layout from TIFF image files"] = "*.tif *.tiff"
+ extensions["NeXus layout from CBF files"] = "*.cbf"
+ extensions["NeXus layout from MarCCD image files"] = "*.mccd"
+
+ all_supported_extensions = set()
+ for name, exts in extensions.items():
+ exts = exts.split(" ")
+ all_supported_extensions.update(exts)
+ all_supported_extensions = sorted(list(all_supported_extensions))
+
+ filters = []
+ filters.append("All supported files (%s)" % " ".join(all_supported_extensions))
+ for name, extension in extensions.items():
+ filters.append("%s (%s)" % (name, extension))
+ filters.append("All files (*)")
+
+ dialog.setNameFilters(filters)
+ dialog.setFileMode(qt.QFileDialog.ExistingFiles)
+ return dialog
+
+ def about(self):
+ from .About import About
+ About.about(self, "Silx viewer")
+
+ def __forcePlotImageDownward(self):
+ silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = "downward"
+
+ def __forcePlotImageUpward(self):
+ silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = "upward"
+
+ def __forceMatplotlibBackend(self):
+ silx.config.DEFAULT_PLOT_BACKEND = "matplotlib"
+
+ def __forceOpenglBackend(self):
+ silx.config.DEFAULT_PLOT_BACKEND = "opengl"
+
+ def appendFile(self, filename):
+ self.__treeview.findHdf5TreeModel().appendFile(filename)
+
+ def displaySelectedData(self):
+ """Called to update the dataviewer with the selected data.
+ """
+ selected = list(self.__treeview.selectedH5Nodes(ignoreBrokenLinks=False))
+ if len(selected) == 1:
+ # Update the viewer for a single selection
+ data = selected[0]
+ self.__dataPanel.setData(data)
+ else:
+ _logger.debug("Too many data selected")
+
+ def displayData(self, data):
+ """Called to update the dataviewer with a secific data.
+ """
+ self.__dataPanel.setData(data)
+
+ def displaySelectedCustomData(self):
+ selected = list(self.__customNxdata.selectedItems())
+ if len(selected) == 1:
+ # Update the viewer for a single selection
+ item = selected[0]
+ self.__dataPanel.setCustomDataItem(item)
+ else:
+ _logger.debug("Too many items selected")
+
+ def __customNxdataRemoved(self, item):
+ if self.__dataPanel.getCustomNxdataItem() is item:
+ self.__dataPanel.setCustomDataItem(None)
+
+ def __customNxdataUpdated(self, item):
+ if self.__dataPanel.getCustomNxdataItem() is item:
+ self.__dataPanel.setCustomDataItem(item)
+
+ def __makeSureCustomNxDataWindowIsVisible(self):
+ if not self.__customNxdataWindow.isVisible():
+ self.__customNxdataWindow.setVisible(True)
+ self._displayCustomNxdataWindow.setChecked(True)
+
+ def useAsNewCustomSignal(self, h5dataset):
+ self.__makeSureCustomNxDataWindowIsVisible()
+ model = self.__customNxdata.model()
+ model.createFromSignal(h5dataset)
+
+ def useAsNewCustomNxdata(self, h5nxdata):
+ self.__makeSureCustomNxDataWindowIsVisible()
+ model = self.__customNxdata.model()
+ model.createFromNxdata(h5nxdata)
+
+ def customContextMenu(self, event):
+ """Called to populate the context menu
+
+ :param silx.gui.hdf5.Hdf5ContextMenuEvent event: Event
+ containing expected information to populate the context menu
+ """
+ selectedObjects = event.source().selectedH5Nodes(ignoreBrokenLinks=False)
+ menu = event.menu()
+
+ if not menu.isEmpty():
+ menu.addSeparator()
+
+ for obj in selectedObjects:
+ h5 = obj.h5py_object
+
+ name = obj.name
+ if name.startswith("/"):
+ name = name[1:]
+ if name == "":
+ name = "the root"
+
+ action = qt.QAction("Show %s" % name, event.source())
+ action.triggered.connect(lambda: self.displayData(h5))
+ menu.addAction(action)
+
+ if silx.io.is_dataset(h5):
+ action = qt.QAction("Use as a new custom signal", event.source())
+ action.triggered.connect(lambda: self.useAsNewCustomSignal(h5))
+ menu.addAction(action)
+
+ if silx.io.is_group(h5) and silx.io.nxdata.is_valid_nxdata(h5):
+ action = qt.QAction("Use as a new custom NXdata", event.source())
+ action.triggered.connect(lambda: self.useAsNewCustomNxdata(h5))
+ menu.addAction(action)
+
+ if silx.io.is_file(h5):
+ action = qt.QAction("Remove %s" % obj.local_filename, event.source())
+ action.triggered.connect(lambda: self.__treeview.findHdf5TreeModel().removeH5pyObject(h5))
+ menu.addAction(action)
+ action = qt.QAction("Synchronize %s" % obj.local_filename, event.source())
+ action.triggered.connect(lambda: self.__treeview.findHdf5TreeModel().synchronizeH5pyObject(h5))
+ menu.addAction(action)
diff --git a/silx/app/view/__init__.py b/silx/app/view/__init__.py
new file mode 100644
index 0000000..229c44e
--- /dev/null
+++ b/silx/app/view/__init__.py
@@ -0,0 +1,28 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2016-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+"""Package containing source code of the `silx view` application"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "07/06/2018"
diff --git a/silx/app/view/main.py b/silx/app/view/main.py
new file mode 100644
index 0000000..fc89a22
--- /dev/null
+++ b/silx/app/view/main.py
@@ -0,0 +1,168 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2016-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+"""Module containing launcher of the `silx view` application"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "07/06/2018"
+
+import sys
+import argparse
+import logging
+import signal
+
+
+_logger = logging.getLogger(__name__)
+"""Module logger"""
+
+if "silx.gui.qt" not in sys.modules:
+ # Try first PyQt5 and not the priority imposed by silx.gui.qt.
+ # To avoid problem with unittests we only do it if silx.gui.qt is not
+ # yet loaded.
+ # TODO: Can be removed for silx 0.8, as it should be the default binding
+ # of the silx library.
+ try:
+ import PyQt5.QtCore
+ except ImportError:
+ pass
+
+import silx
+from silx.gui import qt
+
+
+def sigintHandler(*args):
+ """Handler for the SIGINT signal."""
+ qt.QApplication.quit()
+
+
+def createParser():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ 'files',
+ nargs=argparse.ZERO_OR_MORE,
+ help='Data file to show (h5 file, edf files, spec files)')
+ parser.add_argument(
+ '--debug',
+ dest="debug",
+ action="store_true",
+ default=False,
+ help='Set logging system in debug mode')
+ parser.add_argument(
+ '--use-opengl-plot',
+ dest="use_opengl_plot",
+ action="store_true",
+ default=False,
+ help='Use OpenGL for plots (instead of matplotlib)')
+ parser.add_argument(
+ '--fresh',
+ dest="fresh_preferences",
+ action="store_true",
+ default=False,
+ help='Start the application using new fresh user preferences')
+ return parser
+
+
+def main(argv):
+ """
+ Main function to launch the viewer as an application
+
+ :param argv: Command line arguments
+ :returns: exit status
+ """
+ parser = createParser()
+ options = parser.parse_args(argv[1:])
+
+ if options.debug:
+ logging.root.setLevel(logging.DEBUG)
+
+ #
+ # Import most of the things here to be sure to use the right logging level
+ #
+
+ try:
+ # it should be loaded before h5py
+ import hdf5plugin # noqa
+ except ImportError:
+ _logger.debug("Backtrace", exc_info=True)
+
+ try:
+ import h5py
+ except ImportError:
+ _logger.debug("Backtrace", exc_info=True)
+ h5py = None
+
+ if h5py is None:
+ message = "Module 'h5py' is not installed but is mandatory."\
+ + " You can install it using \"pip install h5py\"."
+ _logger.error(message)
+ return -1
+
+ #
+ # Run the application
+ #
+
+ app = qt.QApplication([])
+ qt.QLocale.setDefault(qt.QLocale.c())
+
+ signal.signal(signal.SIGINT, sigintHandler)
+ sys.excepthook = qt.exceptionHandler
+
+ timer = qt.QTimer()
+ timer.start(500)
+ # Application have to wake up Python interpreter, else SIGINT is not
+ # catched
+ timer.timeout.connect(lambda: None)
+
+ settings = qt.QSettings(qt.QSettings.IniFormat,
+ qt.QSettings.UserScope,
+ "silx",
+ "silx-view",
+ None)
+ if options.fresh_preferences:
+ settings.clear()
+
+ from .Viewer import Viewer
+ window = Viewer(parent=None, settings=settings)
+ window.setAttribute(qt.Qt.WA_DeleteOnClose, True)
+
+ if options.use_opengl_plot:
+ # It have to be done after the settings (after the Viewer creation)
+ silx.config.DEFAULT_PLOT_BACKEND = "opengl"
+
+ for filename in options.files:
+ try:
+ window.appendFile(filename)
+ except IOError as e:
+ _logger.error(e.args[0])
+ _logger.debug("Backtrace", exc_info=True)
+
+ window.show()
+ result = app.exec_()
+ # remove ending warnings relative to QTimer
+ app.deleteLater()
+ return result
+
+
+if __name__ == '__main__':
+ main(sys.argv)
diff --git a/silx/app/view/setup.py b/silx/app/view/setup.py
new file mode 100644
index 0000000..fa076cb
--- /dev/null
+++ b/silx/app/view/setup.py
@@ -0,0 +1,40 @@
+# 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__ = "06/06/2018"
+
+from numpy.distutils.misc_util import Configuration
+
+
+def configuration(parent_package='', top_path=None):
+ config = Configuration('view', 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/app/view/test/__init__.py b/silx/app/view/test/__init__.py
new file mode 100644
index 0000000..8e64948
--- /dev/null
+++ b/silx/app/view/test/__init__.py
@@ -0,0 +1,41 @@
+# 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/06/2018"
+
+import unittest
+
+from silx.test.utils import test_options
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ if test_options.WITH_QT_TEST:
+ from . import test_launcher
+ from . import test_view
+ test_suite.addTest(test_view.suite())
+ test_suite.addTest(test_launcher.suite())
+ return test_suite
diff --git a/silx/app/view/test/test_launcher.py b/silx/app/view/test/test_launcher.py
new file mode 100644
index 0000000..aabccf0
--- /dev/null
+++ b/silx/app/view/test/test_launcher.py
@@ -0,0 +1,145 @@
+# 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.
+#
+# ###########################################################################*/
+"""Module testing silx.app.view"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "07/06/2018"
+
+
+import os
+import sys
+import unittest
+import logging
+import subprocess
+
+from silx.test.utils import test_options
+from .. import main
+from silx import __main__ as silx_main
+
+_logger = logging.getLogger(__name__)
+
+
+@unittest.skipUnless(test_options.WITH_QT_TEST, test_options.WITH_QT_TEST_REASON)
+class TestLauncher(unittest.TestCase):
+ """Test command line parsing"""
+
+ def testHelp(self):
+ # option -h must cause a raise SystemExit or a return 0
+ try:
+ parser = main.createParser()
+ parser.parse_args(["view", "--help"])
+ result = 0
+ except SystemExit as e:
+ result = e.args[0]
+ self.assertEqual(result, 0)
+
+ def testWrongOption(self):
+ try:
+ parser = main.createParser()
+ parser.parse_args(["view", "--foo"])
+ self.fail()
+ except SystemExit as e:
+ result = e.args[0]
+ self.assertNotEqual(result, 0)
+
+ def testWrongFile(self):
+ try:
+ parser = main.createParser()
+ result = parser.parse_args(["view", "__file.not.found__"])
+ result = 0
+ except SystemExit as e:
+ result = e.args[0]
+ self.assertEqual(result, 0)
+
+ def executeCommandLine(self, command_line, env):
+ """Execute a command line.
+
+ Log output as debug in case of bad return code.
+ """
+ _logger.info("Execute: %s", " ".join(command_line))
+ p = subprocess.Popen(command_line,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=env)
+ out, err = p.communicate()
+ _logger.info("Return code: %d", p.returncode)
+ try:
+ out = out.decode('utf-8')
+ except UnicodeError:
+ pass
+ try:
+ err = err.decode('utf-8')
+ except UnicodeError:
+ pass
+
+ if p.returncode != 0:
+ _logger.info("stdout:")
+ _logger.info("%s", out)
+ _logger.info("stderr:")
+ _logger.info("%s", err)
+ else:
+ _logger.debug("stdout:")
+ _logger.debug("%s", out)
+ _logger.debug("stderr:")
+ _logger.debug("%s", err)
+ self.assertEqual(p.returncode, 0)
+
+ def createTestEnv(self):
+ """
+ Returns an associated environment with a working project.
+ """
+ env = dict((str(k), str(v)) for k, v in os.environ.items())
+ env["PYTHONPATH"] = os.pathsep.join(sys.path)
+ return env
+
+ def testExecuteViewHelp(self):
+ """Test if the main module is well connected.
+
+ Uses subprocess to avoid to parasite the current environment.
+ """
+ env = self.createTestEnv()
+ commandLine = [sys.executable, main.__file__, "--help"]
+ self.executeCommandLine(commandLine, env)
+
+ def testExecuteSilxViewHelp(self):
+ """Test if the main module is well connected.
+
+ Uses subprocess to avoid to parasite the current environment.
+ """
+ env = self.createTestEnv()
+ commandLine = [sys.executable, silx_main.__file__, "view", "--help"]
+ self.executeCommandLine(commandLine, env)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ loader = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loader(TestLauncher))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/app/view/test/test_view.py b/silx/app/view/test/test_view.py
new file mode 100644
index 0000000..010cda5
--- /dev/null
+++ b/silx/app/view/test/test_view.py
@@ -0,0 +1,402 @@
+# 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.
+#
+# ###########################################################################*/
+"""Module testing silx.app.view"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "07/06/2018"
+
+
+import unittest
+import weakref
+import numpy
+import tempfile
+import shutil
+import os.path
+try:
+ import h5py
+except ImportError:
+ h5py = None
+
+from silx.gui import qt
+from silx.app.view.Viewer import Viewer
+from silx.app.view.About import About
+from silx.app.view.DataPanel import DataPanel
+from silx.app.view.CustomNxdataWidget import CustomNxdataWidget
+from silx.gui.hdf5._utils import Hdf5DatasetMimeData
+from silx.gui.test.utils import TestCaseQt
+from silx.io import commonh5
+
+_tmpDirectory = None
+
+
+def setUpModule():
+ global _tmpDirectory
+ _tmpDirectory = tempfile.mkdtemp(prefix=__name__)
+
+ if h5py is not None:
+ # create h5 data
+ filename = _tmpDirectory + "/data.h5"
+ f = h5py.File(filename, "w")
+ g = f.create_group("arrays")
+ g.create_dataset("scalar", data=10)
+ g.create_dataset("integers", data=numpy.array([10, 20, 30]))
+ f.close()
+
+ # create h5 data
+ filename = _tmpDirectory + "/data2.h5"
+ f = h5py.File(filename, "w")
+ g = f.create_group("arrays")
+ g.create_dataset("scalar", data=20)
+ g.create_dataset("integers", data=numpy.array([10, 20, 30]))
+ f.close()
+
+
+def tearDownModule():
+ global _tmpDirectory
+ shutil.rmtree(_tmpDirectory)
+ _tmpDirectory = None
+
+
+class TestViewer(TestCaseQt):
+ """Test for Viewer class"""
+
+ def testConstruct(self):
+ widget = Viewer()
+ self.qWaitForWindowExposed(widget)
+
+ def testDestroy(self):
+ widget = Viewer()
+ ref = weakref.ref(widget)
+ widget = None
+ self.qWaitForDestroy(ref)
+
+
+class TestAbout(TestCaseQt):
+ """Test for About box class"""
+
+ def testConstruct(self):
+ widget = About()
+ self.qWaitForWindowExposed(widget)
+
+ def testLicense(self):
+ widget = About()
+ widget.getHtmlLicense()
+ self.qWaitForWindowExposed(widget)
+
+ def testDestroy(self):
+ widget = About()
+ ref = weakref.ref(widget)
+ widget = None
+ self.qWaitForDestroy(ref)
+
+
+class TestDataPanel(TestCaseQt):
+
+ def testConstruct(self):
+ widget = DataPanel()
+ self.qWaitForWindowExposed(widget)
+
+ def testDestroy(self):
+ widget = DataPanel()
+ ref = weakref.ref(widget)
+ widget = None
+ self.qWaitForDestroy(ref)
+
+ def testHeaderLabelPaintEvent(self):
+ widget = DataPanel()
+ data = numpy.array([1, 2, 3, 4, 5])
+ widget.setData(data)
+ # Expected to execute HeaderLabel.paintEvent
+ widget.setVisible(True)
+ self.qWaitForWindowExposed(widget)
+
+ def testData(self):
+ widget = DataPanel()
+ data = numpy.array([1, 2, 3, 4, 5])
+ widget.setData(data)
+ self.assertIs(widget.getData(), data)
+ self.assertIs(widget.getCustomNxdataItem(), None)
+
+ def testDataNone(self):
+ widget = DataPanel()
+ widget.setData(None)
+ self.assertIs(widget.getData(), None)
+ self.assertIs(widget.getCustomNxdataItem(), None)
+
+ def testCustomDataItem(self):
+ class CustomDataItemMock(object):
+ def getVirtualGroup(self):
+ return None
+
+ def text(self):
+ return ""
+
+ data = CustomDataItemMock()
+ widget = DataPanel()
+ widget.setCustomDataItem(data)
+ self.assertIs(widget.getData(), None)
+ self.assertIs(widget.getCustomNxdataItem(), data)
+
+ def testCustomDataItemNone(self):
+ data = None
+ widget = DataPanel()
+ widget.setCustomDataItem(data)
+ self.assertIs(widget.getData(), None)
+ self.assertIs(widget.getCustomNxdataItem(), data)
+
+ @unittest.skipIf(h5py is None, "Could not import h5py")
+ def testRemoveDatasetsFrom(self):
+ f = h5py.File(os.path.join(_tmpDirectory, "data.h5"))
+ try:
+ widget = DataPanel()
+ widget.setData(f["arrays/scalar"])
+ widget.removeDatasetsFrom(f)
+ self.assertIs(widget.getData(), None)
+ finally:
+ widget.setData(None)
+ f.close()
+
+ @unittest.skipIf(h5py is None, "Could not import h5py")
+ def testReplaceDatasetsFrom(self):
+ f = h5py.File(os.path.join(_tmpDirectory, "data.h5"))
+ f2 = h5py.File(os.path.join(_tmpDirectory, "data2.h5"))
+ try:
+ widget = DataPanel()
+ widget.setData(f["arrays/scalar"])
+ self.assertEqual(widget.getData()[()], 10)
+ widget.replaceDatasetsFrom(f, f2)
+ self.assertEqual(widget.getData()[()], 20)
+ finally:
+ widget.setData(None)
+ f.close()
+ f2.close()
+
+
+class TestCustomNxdataWidget(TestCaseQt):
+
+ def testConstruct(self):
+ widget = CustomNxdataWidget()
+ self.qWaitForWindowExposed(widget)
+
+ def testDestroy(self):
+ widget = CustomNxdataWidget()
+ ref = weakref.ref(widget)
+ widget = None
+ self.qWaitForDestroy(ref)
+
+ def testCreateNxdata(self):
+ widget = CustomNxdataWidget()
+ model = widget.model()
+ model.createNewNxdata()
+ model.createNewNxdata("Foo")
+ widget.setVisible(True)
+ self.qWaitForWindowExposed(widget)
+
+ def testCreateNxdataFromDataset(self):
+ widget = CustomNxdataWidget()
+ model = widget.model()
+ signal = commonh5.Dataset("foo", data=numpy.array([[[5]]]))
+ model.createFromSignal(signal)
+ widget.setVisible(True)
+ self.qWaitForWindowExposed(widget)
+
+ def testCreateNxdataFromNxdata(self):
+ widget = CustomNxdataWidget()
+ model = widget.model()
+ data = numpy.array([[[5]]])
+ nxdata = commonh5.Group("foo")
+ nxdata.attrs["NX_class"] = "NXdata"
+ nxdata.attrs["signal"] = "signal"
+ nxdata.create_dataset("signal", data=data)
+ model.createFromNxdata(nxdata)
+ widget.setVisible(True)
+ self.qWaitForWindowExposed(widget)
+
+ def testCreateBadNxdata(self):
+ widget = CustomNxdataWidget()
+ model = widget.model()
+ signal = commonh5.Dataset("foo", data=numpy.array([[[5]]]))
+ model.createFromSignal(signal)
+ axis = commonh5.Dataset("foo", data=numpy.array([[[5]]]))
+ nxdataIndex = model.index(0, 0)
+ item = model.itemFromIndex(nxdataIndex)
+ item.setAxesDatasets([axis])
+ nxdata = item.getVirtualGroup()
+ self.assertIsNotNone(nxdata)
+ self.assertFalse(item.isValid())
+
+ @unittest.skipIf(h5py is None, "Could not import h5py")
+ def testRemoveDatasetsFrom(self):
+ f = h5py.File(os.path.join(_tmpDirectory, "data.h5"))
+ try:
+ widget = CustomNxdataWidget()
+ model = widget.model()
+ dataset = f["arrays/integers"]
+ model.createFromSignal(dataset)
+ widget.removeDatasetsFrom(f)
+ finally:
+ model.clear()
+ f.close()
+
+ @unittest.skipIf(h5py is None, "Could not import h5py")
+ def testReplaceDatasetsFrom(self):
+ f = h5py.File(os.path.join(_tmpDirectory, "data.h5"))
+ f2 = h5py.File(os.path.join(_tmpDirectory, "data2.h5"))
+ try:
+ widget = CustomNxdataWidget()
+ model = widget.model()
+ dataset = f["arrays/integers"]
+ model.createFromSignal(dataset)
+ widget.replaceDatasetsFrom(f, f2)
+ finally:
+ model.clear()
+ f.close()
+ f2.close()
+
+
+class TestCustomNxdataWidgetInteraction(TestCaseQt):
+ """Test CustomNxdataWidget with user interaction"""
+
+ def setUp(self):
+ TestCaseQt.setUp(self)
+
+ self.widget = CustomNxdataWidget()
+ self.model = self.widget.model()
+ data = numpy.array([[[5]]])
+ dataset = commonh5.Dataset("foo", data=data)
+ self.model.createFromSignal(dataset)
+ self.selectionModel = self.widget.selectionModel()
+
+ def tearDown(self):
+ self.selectionModel = None
+ self.model.clear()
+ self.model = None
+ self.widget = None
+ TestCaseQt.tearDown(self)
+
+ def testSelectedNxdata(self):
+ index = self.model.index(0, 0)
+ self.selectionModel.setCurrentIndex(index, qt.QItemSelectionModel.ClearAndSelect)
+ nxdata = self.widget.selectedNxdata()
+ self.assertEqual(len(nxdata), 1)
+ self.assertIsNot(nxdata[0], None)
+
+ def testSelectedItems(self):
+ index = self.model.index(0, 0)
+ self.selectionModel.setCurrentIndex(index, qt.QItemSelectionModel.ClearAndSelect)
+ items = self.widget.selectedItems()
+ self.assertEqual(len(items), 1)
+ self.assertIsNot(items[0], None)
+ self.assertIsInstance(items[0], qt.QStandardItem)
+
+ def testRowsAboutToBeRemoved(self):
+ self.model.removeRow(0)
+ self.qWaitForWindowExposed(self.widget)
+
+ def testPaintItems(self):
+ self.widget.expandAll()
+ self.widget.setVisible(True)
+ self.qWaitForWindowExposed(self.widget)
+
+ def testCreateDefaultContextMenu(self):
+ nxDataIndex = self.model.index(0, 0)
+ menu = self.widget.createDefaultContextMenu(nxDataIndex)
+ self.assertIsNot(menu, None)
+ self.assertIsInstance(menu, qt.QMenu)
+
+ signalIndex = self.model.index(0, 0, nxDataIndex)
+ menu = self.widget.createDefaultContextMenu(signalIndex)
+ self.assertIsNot(menu, None)
+ self.assertIsInstance(menu, qt.QMenu)
+
+ axesIndex = self.model.index(1, 0, nxDataIndex)
+ menu = self.widget.createDefaultContextMenu(axesIndex)
+ self.assertIsNot(menu, None)
+ self.assertIsInstance(menu, qt.QMenu)
+
+ def testDropNewDataset(self):
+ dataset = commonh5.Dataset("foo", numpy.array([1, 2, 3, 4]))
+ mimedata = Hdf5DatasetMimeData(dataset=dataset)
+ self.model.dropMimeData(mimedata, qt.Qt.CopyAction, -1, -1, qt.QModelIndex())
+ self.assertEqual(self.model.rowCount(qt.QModelIndex()), 2)
+
+ def testDropNewNxdata(self):
+ data = numpy.array([[[5]]])
+ nxdata = commonh5.Group("foo")
+ nxdata.attrs["NX_class"] = "NXdata"
+ nxdata.attrs["signal"] = "signal"
+ nxdata.create_dataset("signal", data=data)
+ mimedata = Hdf5DatasetMimeData(dataset=nxdata)
+ self.model.dropMimeData(mimedata, qt.Qt.CopyAction, -1, -1, qt.QModelIndex())
+ self.assertEqual(self.model.rowCount(qt.QModelIndex()), 2)
+
+ def testDropAxisDataset(self):
+ dataset = commonh5.Dataset("foo", numpy.array([1, 2, 3, 4]))
+ mimedata = Hdf5DatasetMimeData(dataset=dataset)
+ nxDataIndex = self.model.index(0, 0)
+ axesIndex = self.model.index(1, 0, nxDataIndex)
+ self.model.dropMimeData(mimedata, qt.Qt.CopyAction, -1, -1, axesIndex)
+ self.assertEqual(self.model.rowCount(qt.QModelIndex()), 1)
+ item = self.model.itemFromIndex(axesIndex)
+ self.assertIsNot(item.getDataset(), None)
+
+ def testMimeData(self):
+ nxDataIndex = self.model.index(0, 0)
+ signalIndex = self.model.index(0, 0, nxDataIndex)
+ mimeData = self.model.mimeData([signalIndex])
+ self.assertIsNot(mimeData, None)
+ self.assertIsInstance(mimeData, qt.QMimeData)
+
+ def testRemoveNxdataItem(self):
+ nxdataIndex = self.model.index(0, 0)
+ item = self.model.itemFromIndex(nxdataIndex)
+ self.model.removeNxdataItem(item)
+
+ def testAppendAxisToNxdataItem(self):
+ nxdataIndex = self.model.index(0, 0)
+ item = self.model.itemFromIndex(nxdataIndex)
+ self.model.appendAxisToNxdataItem(item)
+
+ def testRemoveAxisItem(self):
+ nxdataIndex = self.model.index(0, 0)
+ axesIndex = self.model.index(1, 0, nxdataIndex)
+ item = self.model.itemFromIndex(axesIndex)
+ self.model.removeAxisItem(item)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ loader = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loader(TestViewer))
+ test_suite.addTest(loader(TestAbout))
+ test_suite.addTest(loader(TestDataPanel))
+ test_suite.addTest(loader(TestCustomNxdataWidget))
+ test_suite.addTest(loader(TestCustomNxdataWidgetInteraction))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/app/view/utils.py b/silx/app/view/utils.py
new file mode 100644
index 0000000..80167c8
--- /dev/null
+++ b/silx/app/view/utils.py
@@ -0,0 +1,45 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+"""Browse a data file with a GUI"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "28/05/2018"
+
+
+_trueStrings = set(["yes", "true", "1"])
+_falseStrings = set(["no", "false", "0"])
+
+
+def stringToBool(string):
+ """Returns a boolean from a string.
+
+ :raise ValueError: If the string do not contains a boolean information.
+ """
+ lower = string.lower()
+ if lower in _trueStrings:
+ return True
+ if lower in _falseStrings:
+ return False
+ raise ValueError("'%s' is not a valid boolean" % string)