diff options
author | Picca Frédéric-Emmanuel <picca@debian.org> | 2018-03-04 10:20:27 +0100 |
---|---|---|
committer | Picca Frédéric-Emmanuel <picca@debian.org> | 2018-03-04 10:20:27 +0100 |
commit | 270d5ddc31c26b62379e3caa9044dd75ccc71847 (patch) | |
tree | 55c5bfc851dfce7172d335cd2405b214323e3caf /silx/gui/dialog | |
parent | e19c96eff0c310c06c4f268c8b80cb33bd08996f (diff) |
New upstream version 0.7.0+dfsg
Diffstat (limited to 'silx/gui/dialog')
-rw-r--r-- | silx/gui/dialog/AbstractDataFileDialog.py | 1718 | ||||
-rw-r--r-- | silx/gui/dialog/DataFileDialog.py | 342 | ||||
-rw-r--r-- | silx/gui/dialog/FileTypeComboBox.py | 213 | ||||
-rw-r--r-- | silx/gui/dialog/ImageFileDialog.py | 338 | ||||
-rw-r--r-- | silx/gui/dialog/SafeFileIconProvider.py | 150 | ||||
-rw-r--r-- | silx/gui/dialog/SafeFileSystemModel.py | 802 | ||||
-rw-r--r-- | silx/gui/dialog/__init__.py | 29 | ||||
-rw-r--r-- | silx/gui/dialog/setup.py | 40 | ||||
-rw-r--r-- | silx/gui/dialog/test/__init__.py | 47 | ||||
-rw-r--r-- | silx/gui/dialog/test/test_datafiledialog.py | 981 | ||||
-rw-r--r-- | silx/gui/dialog/test/test_imagefiledialog.py | 803 | ||||
-rw-r--r-- | silx/gui/dialog/utils.py | 104 |
12 files changed, 5567 insertions, 0 deletions
diff --git a/silx/gui/dialog/AbstractDataFileDialog.py b/silx/gui/dialog/AbstractDataFileDialog.py new file mode 100644 index 0000000..1bd52bb --- /dev/null +++ b/silx/gui/dialog/AbstractDataFileDialog.py @@ -0,0 +1,1718 @@ +# 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. +# +# ###########################################################################*/ +""" +This module contains an :class:`AbstractDataFileDialog`. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "12/02/2018" + + +import sys +import os +import logging +import numpy +import functools +import silx.io.url +from silx.gui import qt +from silx.gui.hdf5.Hdf5TreeModel import Hdf5TreeModel +from . import utils +from silx.third_party import six +from .FileTypeComboBox import FileTypeComboBox +try: + import fabio +except ImportError: + fabio = None + + +_logger = logging.getLogger(__name__) + + +class _IconProvider(object): + + FileDialogToParentDir = qt.QStyle.SP_CustomBase + 1 + + FileDialogToParentFile = qt.QStyle.SP_CustomBase + 2 + + def __init__(self): + self.__iconFileDialogToParentDir = None + self.__iconFileDialogToParentFile = None + + def _createIconToParent(self, standardPixmap): + """ + + FIXME: It have to be tested for some OS (arrow icon do not have always + the same direction) + """ + style = qt.QApplication.style() + baseIcon = style.standardIcon(qt.QStyle.SP_FileDialogToParent) + backgroundIcon = style.standardIcon(standardPixmap) + icon = qt.QIcon() + + sizes = baseIcon.availableSizes() + sizes = sorted(sizes, key=lambda s: s.height()) + sizes = filter(lambda s: s.height() < 100, sizes) + sizes = list(sizes) + if len(sizes) > 0: + baseSize = sizes[-1] + else: + baseSize = baseIcon.availableSizes()[0] + size = qt.QSize(baseSize.width(), baseSize.height() * 3 // 2) + + modes = [qt.QIcon.Normal, qt.QIcon.Disabled] + for mode in modes: + pixmap = qt.QPixmap(size) + pixmap.fill(qt.Qt.transparent) + painter = qt.QPainter(pixmap) + painter.drawPixmap(0, 0, backgroundIcon.pixmap(baseSize, mode=mode)) + painter.drawPixmap(0, size.height() // 3, baseIcon.pixmap(baseSize, mode=mode)) + painter.end() + icon.addPixmap(pixmap, mode=mode) + + return icon + + def getFileDialogToParentDir(self): + if self.__iconFileDialogToParentDir is None: + self.__iconFileDialogToParentDir = self._createIconToParent(qt.QStyle.SP_DirIcon) + return self.__iconFileDialogToParentDir + + def getFileDialogToParentFile(self): + if self.__iconFileDialogToParentFile is None: + self.__iconFileDialogToParentFile = self._createIconToParent(qt.QStyle.SP_FileIcon) + return self.__iconFileDialogToParentFile + + def icon(self, kind): + if kind == self.FileDialogToParentDir: + return self.getFileDialogToParentDir() + elif kind == self.FileDialogToParentFile: + return self.getFileDialogToParentFile() + else: + style = qt.QApplication.style() + icon = style.standardIcon(kind) + return icon + + +class _SideBar(qt.QListView): + """Sidebar containing shortcuts for common directories""" + + def __init__(self, parent=None): + super(_SideBar, self).__init__(parent) + self.__iconProvider = qt.QFileIconProvider() + self.setUniformItemSizes(True) + model = qt.QStandardItemModel(self) + self.setModel(model) + self._initModel() + self.setEditTriggers(qt.QAbstractItemView.NoEditTriggers) + + def iconProvider(self): + return self.__iconProvider + + def _initModel(self): + urls = self._getDefaultUrls() + self.setUrls(urls) + + def _getDefaultUrls(self): + """Returns the default shortcuts. + + It uses the default QFileDialog shortcuts if it is possible, else + provides a link to the computer's root and the user's home. + + :rtype: List[str] + """ + urls = [] + if qt.qVersion().startswith("5.") and sys.platform in ["linux", "linux2"]: + # Avoid segfault on PyQt5 + gtk + _logger.debug("Skip default sidebar URLs (avoid PyQt5 segfault)") + pass + elif qt.qVersion().startswith("4.") and sys.platform in ["win32"]: + # Avoid 5min of locked GUI relative to network driver + _logger.debug("Skip default sidebar URLs (avoid lock when using network drivers)") + else: + # Get default shortcut + # There is no other way + d = qt.QFileDialog(self) + # Needed to be able to reach the sidebar urls + d.setOption(qt.QFileDialog.DontUseNativeDialog, True) + urls = d.sidebarUrls() + d.deleteLater() + d = None + + if len(urls) == 0: + urls.append(qt.QUrl("file://")) + urls.append(qt.QUrl.fromLocalFile(qt.QDir.homePath())) + + return urls + + def setSelectedPath(self, path): + selected = None + model = self.model() + for i in range(model.rowCount()): + index = model.index(i, 0) + url = model.data(index, qt.Qt.UserRole) + urlPath = url.toLocalFile() + if path == urlPath: + selected = index + + selectionModel = self.selectionModel() + if selected is not None: + selectionModel.setCurrentIndex(selected, qt.QItemSelectionModel.ClearAndSelect) + else: + selectionModel.clear() + + def setUrls(self, urls): + model = self.model() + model.clear() + + names = {} + names[qt.QDir.rootPath()] = "Computer" + names[qt.QDir.homePath()] = "Home" + + style = qt.QApplication.style() + iconProvider = self.iconProvider() + for url in urls: + path = url.toLocalFile() + if path == "": + if sys.platform != "win32": + url = qt.QUrl(qt.QDir.rootPath()) + name = "Computer" + icon = style.standardIcon(qt.QStyle.SP_ComputerIcon) + else: + fileInfo = qt.QFileInfo(path) + name = names.get(path, fileInfo.fileName()) + icon = iconProvider.icon(fileInfo) + + if icon.isNull(): + icon = style.standardIcon(qt.QStyle.SP_MessageBoxCritical) + + item = qt.QStandardItem() + item.setText(name) + item.setIcon(icon) + item.setData(url, role=qt.Qt.UserRole) + model.appendRow(item) + + def urls(self): + result = [] + model = self.model() + for i in range(model.rowCount()): + index = model.index(i, 0) + url = model.data(index, qt.Qt.UserRole) + result.append(url) + return result + + def sizeHint(self): + index = self.model().index(0, 0) + return self.sizeHintForIndex(index) + qt.QSize(2 * self.frameWidth(), 2 * self.frameWidth()) + + +class _Browser(qt.QStackedWidget): + + activated = qt.Signal(qt.QModelIndex) + selected = qt.Signal(qt.QModelIndex) + rootIndexChanged = qt.Signal(qt.QModelIndex) + + def __init__(self, parent, listView, detailView): + qt.QStackedWidget.__init__(self, parent) + self.__listView = listView + self.__detailView = detailView + self.insertWidget(0, self.__listView) + self.insertWidget(1, self.__detailView) + + self.__listView.activated.connect(self.__emitActivated) + self.__detailView.activated.connect(self.__emitActivated) + + def __emitActivated(self, index): + self.activated.emit(index) + + def __emitSelected(self, selected, deselected): + index = self.selectedIndex() + if index is not None: + self.selected.emit(index) + + def selectedIndex(self): + if self.currentIndex() == 0: + selectionModel = self.__listView.selectionModel() + else: + selectionModel = self.__detailView.selectionModel() + + if selectionModel is None: + return None + + indexes = selectionModel.selectedIndexes() + # Filter non-main columns + indexes = [i for i in indexes if i.column() == 0] + if len(indexes) == 1: + index = indexes[0] + return index + return None + + def model(self): + """Returns the current model.""" + if self.currentIndex() == 0: + return self.__listView.model() + else: + return self.__detailView.model() + + def selectIndex(self, index): + if self.currentIndex() == 0: + selectionModel = self.__listView.selectionModel() + else: + selectionModel = self.__detailView.selectionModel() + if selectionModel is None: + return + selectionModel.setCurrentIndex(index, qt.QItemSelectionModel.ClearAndSelect) + + def viewMode(self): + """Returns the current view mode. + + :rtype: qt.QFileDialog.ViewMode + """ + if self.currentIndex() == 0: + return qt.QFileDialog.List + elif self.currentIndex() == 1: + return qt.QFileDialog.Detail + else: + assert(False) + + def setViewMode(self, mode): + """Set the current view mode. + + :param qt.QFileDialog.ViewMode mode: The new view mode + """ + if mode == qt.QFileDialog.Detail: + self.showDetails() + elif mode == qt.QFileDialog.List: + self.showList() + else: + assert(False) + + def showList(self): + self.__listView.show() + self.__detailView.hide() + self.setCurrentIndex(0) + + def showDetails(self): + self.__listView.hide() + self.__detailView.show() + self.setCurrentIndex(1) + self.__detailView.updateGeometry() + + def clear(self): + self.__listView.setRootIndex(qt.QModelIndex()) + self.__detailView.setRootIndex(qt.QModelIndex()) + selectionModel = self.__listView.selectionModel() + if selectionModel is not None: + selectionModel.selectionChanged.disconnect() + selectionModel.clear() + selectionModel = self.__detailView.selectionModel() + if selectionModel is not None: + selectionModel.selectionChanged.disconnect() + selectionModel.clear() + self.__listView.setModel(None) + self.__detailView.setModel(None) + + def setRootIndex(self, index, model=None): + """Sets the root item to the item at the given index. + """ + rootIndex = self.__listView.rootIndex() + newModel = model or index.model() + assert(newModel is not None) + + if rootIndex is None or rootIndex.model() is not newModel: + # update the model + selectionModel = self.__listView.selectionModel() + if selectionModel is not None: + selectionModel.selectionChanged.disconnect() + selectionModel.clear() + selectionModel = self.__detailView.selectionModel() + if selectionModel is not None: + selectionModel.selectionChanged.disconnect() + selectionModel.clear() + pIndex = qt.QPersistentModelIndex(index) + self.__listView.setModel(newModel) + # changing the model of the tree view change the index mapping + # that is why we are using a persistance model index + self.__detailView.setModel(newModel) + index = newModel.index(pIndex.row(), pIndex.column(), pIndex.parent()) + selectionModel = self.__listView.selectionModel() + selectionModel.selectionChanged.connect(self.__emitSelected) + selectionModel = self.__detailView.selectionModel() + selectionModel.selectionChanged.connect(self.__emitSelected) + + self.__listView.setRootIndex(index) + self.__detailView.setRootIndex(index) + self.rootIndexChanged.emit(index) + + def rootIndex(self): + """Returns the model index of the model's root item. The root item is + the parent item to the view's toplevel items. The root can be invalid. + """ + return self.__listView.rootIndex() + + __serialVersion = 1 + """Store the current version of the serialized data""" + + def visualRect(self, index): + """Returns the rectangle on the viewport occupied by the item at index. + + :param qt.QModelIndex index: An index + :rtype: QRect + """ + if self.currentIndex() == 0: + return self.__listView.visualRect(index) + else: + return self.__detailView.visualRect(index) + + def viewport(self): + """Returns the viewport widget. + + :param qt.QModelIndex index: An index + :rtype: QRect + """ + if self.currentIndex() == 0: + return self.__listView.viewport() + else: + return self.__detailView.viewport() + + def restoreState(self, state): + """Restores the dialogs's layout, history and current directory to the + state specified. + + :param qt.QByeArray state: Stream containing the new state + :rtype: bool + """ + stream = qt.QDataStream(state, qt.QIODevice.ReadOnly) + + nameId = stream.readQString() + if nameId != "Browser": + _logger.warning("Stored state contains an invalid name id. Browser restoration cancelled.") + return False + + version = stream.readInt32() + if version != self.__serialVersion: + _logger.warning("Stored state contains an invalid version. Browser restoration cancelled.") + return False + + headerData = stream.readQVariant() + self.__detailView.header().restoreState(headerData) + + viewMode = stream.readInt32() + self.setViewMode(viewMode) + return True + + def saveState(self): + """Saves the state of the dialog's layout. + + :rtype: qt.QByteArray + """ + data = qt.QByteArray() + stream = qt.QDataStream(data, qt.QIODevice.WriteOnly) + + nameId = u"Browser" + stream.writeQString(nameId) + stream.writeInt32(self.__serialVersion) + stream.writeQVariant(self.__detailView.header().saveState()) + stream.writeInt32(self.viewMode()) + + return data + + +class _FabioData(object): + + def __init__(self, fabioFile): + self.__fabioFile = fabioFile + + @property + def dtype(self): + # Let say it is a valid type + return numpy.dtype("float") + + @property + def shape(self): + if self.__fabioFile.nframes == 0: + return None + return [self.__fabioFile.nframes, slice(None), slice(None)] + + def __getitem__(self, selector): + if isinstance(selector, tuple) and len(selector) == 1: + selector = selector[0] + + if isinstance(selector, six.integer_types): + if 0 <= selector < self.__fabioFile.nframes: + if self.__fabioFile.nframes == 1: + return self.__fabioFile.data + else: + frame = self.__fabioFile.getframe(selector) + return frame.data + else: + raise ValueError("Invalid selector %s" % selector) + else: + raise TypeError("Unsupported selector type %s" % type(selector)) + + +class _PathEdit(qt.QLineEdit): + pass + + +class _CatchResizeEvent(qt.QObject): + + resized = qt.Signal(qt.QResizeEvent) + + def __init__(self, parent, target): + super(_CatchResizeEvent, self).__init__(parent) + self.__target = target + self.__target_oldResizeEvent = self.__target.resizeEvent + self.__target.resizeEvent = self.__resizeEvent + + def __resizeEvent(self, event): + result = self.__target_oldResizeEvent(event) + self.resized.emit(event) + return result + + +class AbstractDataFileDialog(qt.QDialog): + """The `AbstractFileDialog` provides a generic GUI to create a custom dialog + allowing to access to file resources like HDF5 files or HDF5 datasets + + The dialog contains: + + - Shortcuts: It provides few links to have a fast access of browsing + locations. + - Browser: It provides a display to browse throw the file system and inside + HDF5 files or fabio files. A file format selector is provided. + - URL: Display the URL available to reach the data using + :meth:`silx.io.get_data`, :meth:`silx.io.open`. + - Data selector: A widget to apply a sub selection of the browsed dataset. + This widget can be provided, else nothing will be used. + - Data preview: A widget to preview the selected data, which is the result + of the filter from the data selector. + This widget can be provided, else nothing will be used. + - Preview's toolbar: Provides tools used to custom data preview or data + selector. + This widget can be provided, else nothing will be used. + - Buttons to validate the dialog + """ + + _defaultIconProvider = None + """Lazy loaded default icon provider""" + + def __init__(self, parent=None): + super(AbstractDataFileDialog, self).__init__(parent) + self._init() + + def _init(self): + self.setWindowTitle("Open") + + self.__directory = None + self.__directoryLoadedFilter = None + self.__errorWhileLoadingFile = None + self.__selectedFile = None + self.__selectedData = None + self.__currentHistory = [] + """Store history of URLs, last index one is the latest one""" + self.__currentHistoryLocation = -1 + """Store the location in the history. Bigger is older""" + + self.__processing = 0 + """Number of asynchronous processing tasks""" + self.__h5 = None + self.__fabio = None + + if qt.qVersion() < "5.0": + # On Qt4 it is needed to provide a safe file system model + _logger.debug("Uses SafeFileSystemModel") + from .SafeFileSystemModel import SafeFileSystemModel + self.__fileModel = SafeFileSystemModel(self) + else: + # On Qt5 a safe icon provider is still needed to avoid freeze + _logger.debug("Uses default QFileSystemModel with a SafeFileIconProvider") + self.__fileModel = qt.QFileSystemModel(self) + from .SafeFileIconProvider import SafeFileIconProvider + iconProvider = SafeFileIconProvider() + self.__fileModel.setIconProvider(iconProvider) + + # The common file dialog filter only on Mac OS X + self.__fileModel.setNameFilterDisables(sys.platform == "darwin") + self.__fileModel.setReadOnly(True) + self.__fileModel.directoryLoaded.connect(self.__directoryLoaded) + + self.__dataModel = Hdf5TreeModel(self) + + self.__createWidgets() + self.__initLayout() + self.__showAsListView() + + path = os.getcwd() + self.__fileModel_setRootPath(path) + + self.__clearData() + self.__updatePath() + + # Update the file model filter + self.__fileTypeCombo.setCurrentIndex(0) + self.__filterSelected(0) + + self.__openedFiles = [] + """Store the list of files opened by the model itself.""" + # FIXME: It should be managed one by one by Hdf5Item itself + + # It is not possible to override the QObject destructor nor + # to access to the content of the Python object with the `destroyed` + # signal cause the Python method was already removed with the QWidget, + # while the QObject still exists. + # We use a static method plus explicit references to objects to + # release. The callback do not use any ref to self. + onDestroy = functools.partial(self._closeFileList, self.__openedFiles) + self.destroyed.connect(onDestroy) + + @staticmethod + def _closeFileList(fileList): + """Static method to close explicit references to internal objects.""" + _logger.debug("Clear AbstractDataFileDialog") + for obj in fileList: + _logger.debug("Close file %s", obj.filename) + obj.close() + fileList[:] = [] + + def done(self, result): + self._clear() + super(AbstractDataFileDialog, self).done(result) + + def _clear(self): + """Explicit method to clear data stored in the dialog. + After this call it is not anymore possible to use the widget. + + This method is triggered by the destruction of the object and the + QDialog :meth:`done`. Then it can be triggered more than once. + """ + _logger.debug("Clear dialog") + self.__errorWhileLoadingFile = None + self.__clearData() + if self.__fileModel is not None: + # Cache the directory before cleaning the model + self.__directory = self.directory() + self.__browser.clear() + self.__closeFile() + self.__fileModel = None + self.__dataModel = None + + def hasPendingEvents(self): + """Returns true if the dialog have asynchronous tasks working on the + background.""" + return self.__processing > 0 + + # User interface + + def __createWidgets(self): + self.__sidebar = self._createSideBar() + if self.__sidebar is not None: + sideBarModel = self.__sidebar.selectionModel() + sideBarModel.selectionChanged.connect(self.__shortcutSelected) + self.__sidebar.setSelectionMode(qt.QAbstractItemView.SingleSelection) + + listView = qt.QListView(self) + listView.setSelectionBehavior(qt.QAbstractItemView.SelectRows) + listView.setSelectionMode(qt.QAbstractItemView.SingleSelection) + listView.setResizeMode(qt.QListView.Adjust) + listView.setWrapping(True) + listView.setEditTriggers(qt.QAbstractItemView.NoEditTriggers) + listView.setContextMenuPolicy(qt.Qt.CustomContextMenu) + utils.patchToConsumeReturnKey(listView) + + treeView = qt.QTreeView(self) + treeView.setSelectionBehavior(qt.QAbstractItemView.SelectRows) + treeView.setSelectionMode(qt.QAbstractItemView.SingleSelection) + treeView.setRootIsDecorated(False) + treeView.setItemsExpandable(False) + treeView.setSortingEnabled(True) + treeView.header().setSortIndicator(0, qt.Qt.AscendingOrder) + treeView.header().setStretchLastSection(False) + treeView.setTextElideMode(qt.Qt.ElideMiddle) + treeView.setEditTriggers(qt.QAbstractItemView.NoEditTriggers) + treeView.setContextMenuPolicy(qt.Qt.CustomContextMenu) + treeView.setDragDropMode(qt.QAbstractItemView.InternalMove) + utils.patchToConsumeReturnKey(treeView) + + self.__browser = _Browser(self, listView, treeView) + self.__browser.activated.connect(self.__browsedItemActivated) + self.__browser.selected.connect(self.__browsedItemSelected) + self.__browser.rootIndexChanged.connect(self.__rootIndexChanged) + self.__browser.setObjectName("browser") + + self.__previewWidget = self._createPreviewWidget(self) + + self.__fileTypeCombo = FileTypeComboBox(self) + self.__fileTypeCombo.setObjectName("fileTypeCombo") + self.__fileTypeCombo.setDuplicatesEnabled(False) + self.__fileTypeCombo.setSizeAdjustPolicy(qt.QComboBox.AdjustToMinimumContentsLength) + self.__fileTypeCombo.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed) + self.__fileTypeCombo.activated[int].connect(self.__filterSelected) + self.__fileTypeCombo.setFabioUrlSupproted(self._isFabioFilesSupported()) + + self.__pathEdit = _PathEdit(self) + self.__pathEdit.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed) + self.__pathEdit.textChanged.connect(self.__textChanged) + self.__pathEdit.setObjectName("url") + utils.patchToConsumeReturnKey(self.__pathEdit) + + self.__buttons = qt.QDialogButtonBox(self) + self.__buttons.setSizePolicy(qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed) + types = qt.QDialogButtonBox.Open | qt.QDialogButtonBox.Cancel + self.__buttons.setStandardButtons(types) + self.__buttons.button(qt.QDialogButtonBox.Cancel).setObjectName("cancel") + self.__buttons.button(qt.QDialogButtonBox.Open).setObjectName("open") + + self.__buttons.accepted.connect(self.accept) + self.__buttons.rejected.connect(self.reject) + + self.__browseToolBar = self._createBrowseToolBar() + self.__backwardAction.setEnabled(False) + self.__forwardAction.setEnabled(False) + self.__fileDirectoryAction.setEnabled(False) + self.__parentFileDirectoryAction.setEnabled(False) + + self.__selectorWidget = self._createSelectorWidget(self) + if self.__selectorWidget is not None: + self.__selectorWidget.selectionChanged.connect(self.__selectorWidgetChanged) + + self.__previewToolBar = self._createPreviewToolbar(self, self.__previewWidget, self.__selectorWidget) + + self.__dataIcon = qt.QLabel(self) + self.__dataIcon.setSizePolicy(qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed) + self.__dataIcon.setScaledContents(True) + self.__dataIcon.setMargin(2) + self.__dataIcon.setAlignment(qt.Qt.AlignCenter) + + self.__dataInfo = qt.QLabel(self) + self.__dataInfo.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed) + + def _createSideBar(self): + sidebar = _SideBar(self) + sidebar.setObjectName("sidebar") + return sidebar + + def iconProvider(self): + iconProvider = self.__class__._defaultIconProvider + if iconProvider is None: + iconProvider = _IconProvider() + self.__class__._defaultIconProvider = iconProvider + return iconProvider + + def _createBrowseToolBar(self): + toolbar = qt.QToolBar(self) + toolbar.setIconSize(qt.QSize(16, 16)) + iconProvider = self.iconProvider() + + backward = qt.QAction(toolbar) + backward.setText("Back") + backward.setObjectName("backwardAction") + backward.setIcon(iconProvider.icon(qt.QStyle.SP_ArrowBack)) + backward.triggered.connect(self.__navigateBackward) + self.__backwardAction = backward + + forward = qt.QAction(toolbar) + forward.setText("Forward") + forward.setObjectName("forwardAction") + forward.setIcon(iconProvider.icon(qt.QStyle.SP_ArrowForward)) + forward.triggered.connect(self.__navigateForward) + self.__forwardAction = forward + + parentDirectory = qt.QAction(toolbar) + parentDirectory.setText("Go to parent") + parentDirectory.setObjectName("toParentAction") + parentDirectory.setIcon(iconProvider.icon(qt.QStyle.SP_FileDialogToParent)) + parentDirectory.triggered.connect(self.__navigateToParent) + self.__toParentAction = parentDirectory + + fileDirectory = qt.QAction(toolbar) + fileDirectory.setText("Root of the file") + fileDirectory.setObjectName("toRootFileAction") + fileDirectory.setIcon(iconProvider.icon(iconProvider.FileDialogToParentFile)) + fileDirectory.triggered.connect(self.__navigateToParentFile) + self.__fileDirectoryAction = fileDirectory + + parentFileDirectory = qt.QAction(toolbar) + parentFileDirectory.setText("Parent directory of the file") + parentFileDirectory.setObjectName("toDirectoryAction") + parentFileDirectory.setIcon(iconProvider.icon(iconProvider.FileDialogToParentDir)) + parentFileDirectory.triggered.connect(self.__navigateToParentDir) + self.__parentFileDirectoryAction = parentFileDirectory + + listView = qt.QAction(toolbar) + listView.setText("List view") + listView.setObjectName("listModeAction") + listView.setIcon(iconProvider.icon(qt.QStyle.SP_FileDialogListView)) + listView.triggered.connect(self.__showAsListView) + listView.setCheckable(True) + + detailView = qt.QAction(toolbar) + detailView.setText("Detail view") + detailView.setObjectName("detailModeAction") + detailView.setIcon(iconProvider.icon(qt.QStyle.SP_FileDialogDetailedView)) + detailView.triggered.connect(self.__showAsDetailedView) + detailView.setCheckable(True) + + self.__listViewAction = listView + self.__detailViewAction = detailView + + toolbar.addAction(backward) + toolbar.addAction(forward) + toolbar.addSeparator() + toolbar.addAction(parentDirectory) + toolbar.addAction(fileDirectory) + toolbar.addAction(parentFileDirectory) + toolbar.addSeparator() + toolbar.addAction(listView) + toolbar.addAction(detailView) + + toolbar.setStyleSheet("QToolBar { border: 0px }") + + return toolbar + + def __initLayout(self): + sideBarLayout = qt.QVBoxLayout() + sideBarLayout.setContentsMargins(0, 0, 0, 0) + dummyToolBar = qt.QWidget(self) + dummyToolBar.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed) + dummyCombo = qt.QWidget(self) + dummyCombo.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed) + sideBarLayout.addWidget(dummyToolBar) + if self.__sidebar is not None: + sideBarLayout.addWidget(self.__sidebar) + sideBarLayout.addWidget(dummyCombo) + sideBarWidget = qt.QWidget(self) + sideBarWidget.setLayout(sideBarLayout) + + dummyCombo.setFixedHeight(self.__fileTypeCombo.height()) + self.__resizeCombo = _CatchResizeEvent(self, self.__fileTypeCombo) + self.__resizeCombo.resized.connect(lambda e: dummyCombo.setFixedHeight(e.size().height())) + + dummyToolBar.setFixedHeight(self.__browseToolBar.height()) + self.__resizeToolbar = _CatchResizeEvent(self, self.__browseToolBar) + self.__resizeToolbar.resized.connect(lambda e: dummyToolBar.setFixedHeight(e.size().height())) + + datasetSelection = qt.QWidget(self) + layoutLeft = qt.QVBoxLayout() + layoutLeft.setContentsMargins(0, 0, 0, 0) + layoutLeft.addWidget(self.__browseToolBar) + layoutLeft.addWidget(self.__browser) + layoutLeft.addWidget(self.__fileTypeCombo) + datasetSelection.setLayout(layoutLeft) + datasetSelection.setSizePolicy(qt.QSizePolicy.MinimumExpanding, qt.QSizePolicy.Expanding) + + infoLayout = qt.QHBoxLayout() + infoLayout.setContentsMargins(0, 0, 0, 0) + infoLayout.addWidget(self.__dataIcon) + infoLayout.addWidget(self.__dataInfo) + + dataFrame = qt.QFrame(self) + dataFrame.setFrameShape(qt.QFrame.StyledPanel) + layout = qt.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.__previewWidget) + layout.addLayout(infoLayout) + dataFrame.setLayout(layout) + + dataSelection = qt.QWidget(self) + dataLayout = qt.QVBoxLayout() + dataLayout.setContentsMargins(0, 0, 0, 0) + if self.__previewToolBar is not None: + dataLayout.addWidget(self.__previewToolBar) + else: + # Add dummy space + dummyToolbar2 = qt.QWidget(self) + dummyToolbar2.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed) + dummyToolbar2.setFixedHeight(self.__browseToolBar.height()) + self.__resizeToolbar = _CatchResizeEvent(self, self.__browseToolBar) + self.__resizeToolbar.resized.connect(lambda e: dummyToolbar2.setFixedHeight(e.size().height())) + dataLayout.addWidget(dummyToolbar2) + + dataLayout.addWidget(dataFrame) + if self.__selectorWidget is not None: + dataLayout.addWidget(self.__selectorWidget) + else: + # Add dummy space + dummyCombo2 = qt.QWidget(self) + dummyCombo2.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed) + dummyCombo2.setFixedHeight(self.__fileTypeCombo.height()) + self.__resizeToolbar = _CatchResizeEvent(self, self.__fileTypeCombo) + self.__resizeToolbar.resized.connect(lambda e: dummyCombo2.setFixedHeight(e.size().height())) + dataLayout.addWidget(dummyCombo2) + dataSelection.setLayout(dataLayout) + + self.__splitter = qt.QSplitter(self) + self.__splitter.setContentsMargins(0, 0, 0, 0) + self.__splitter.addWidget(sideBarWidget) + self.__splitter.addWidget(datasetSelection) + self.__splitter.addWidget(dataSelection) + self.__splitter.setStretchFactor(1, 10) + + bottomLayout = qt.QHBoxLayout() + bottomLayout.setContentsMargins(0, 0, 0, 0) + bottomLayout.addWidget(self.__pathEdit) + bottomLayout.addWidget(self.__buttons) + + layout = qt.QVBoxLayout(self) + layout.addWidget(self.__splitter) + layout.addLayout(bottomLayout) + + self.setLayout(layout) + self.updateGeometry() + + # Logic + + def __navigateBackward(self): + """Navigate through the history one step backward.""" + if len(self.__currentHistory) > 0 and self.__currentHistoryLocation > 0: + self.__currentHistoryLocation -= 1 + url = self.__currentHistory[self.__currentHistoryLocation] + self.selectUrl(url) + + def __navigateForward(self): + """Navigate through the history one step forward.""" + if len(self.__currentHistory) > 0 and self.__currentHistoryLocation < len(self.__currentHistory) - 1: + self.__currentHistoryLocation += 1 + url = self.__currentHistory[self.__currentHistoryLocation] + self.selectUrl(url) + + def __navigateToParent(self): + index = self.__browser.rootIndex() + if index.model() is self.__fileModel: + # browse throw the file system + index = index.parent() + path = self.__fileModel.filePath(index) + self.__fileModel_setRootPath(path) + self.__browser.selectIndex(qt.QModelIndex()) + self.__updatePath() + elif index.model() is self.__dataModel: + index = index.parent() + if index.isValid(): + # browse throw the hdf5 + self.__browser.setRootIndex(index) + self.__browser.selectIndex(qt.QModelIndex()) + self.__updatePath() + else: + # go back to the file system + self.__navigateToParentDir() + else: + # Root of the file system (my computer) + pass + + def __navigateToParentFile(self): + index = self.__browser.rootIndex() + if index.model() is self.__dataModel: + index = self.__dataModel.indexFromH5Object(self.__h5) + self.__browser.setRootIndex(index) + self.__browser.selectIndex(qt.QModelIndex()) + self.__updatePath() + + def __navigateToParentDir(self): + index = self.__browser.rootIndex() + if index.model() is self.__dataModel: + path = os.path.dirname(self.__h5.file.filename) + index = self.__fileModel.index(path) + self.__browser.setRootIndex(index) + self.__browser.selectIndex(qt.QModelIndex()) + self.__closeFile() + self.__updatePath() + + def viewMode(self): + """Returns the current view mode. + + :rtype: qt.QFileDialog.ViewMode + """ + return self.__browser.viewMode() + + def setViewMode(self, mode): + """Set the current view mode. + + :param qt.QFileDialog.ViewMode mode: The new view mode + """ + if mode == qt.QFileDialog.Detail: + self.__browser.showDetails() + self.__listViewAction.setChecked(False) + self.__detailViewAction.setChecked(True) + elif mode == qt.QFileDialog.List: + self.__browser.showList() + self.__listViewAction.setChecked(True) + self.__detailViewAction.setChecked(False) + else: + assert(False) + + def __showAsListView(self): + self.setViewMode(qt.QFileDialog.List) + + def __showAsDetailedView(self): + self.setViewMode(qt.QFileDialog.Detail) + + def __shortcutSelected(self): + self.__browser.selectIndex(qt.QModelIndex()) + self.__clearData() + self.__updatePath() + selectionModel = self.__sidebar.selectionModel() + indexes = selectionModel.selectedIndexes() + if len(indexes) == 1: + index = indexes[0] + url = self.__sidebar.model().data(index, role=qt.Qt.UserRole) + path = url.toLocalFile() + self.__fileModel_setRootPath(path) + + def __browsedItemActivated(self, index): + if not index.isValid(): + return + if index.model() is self.__fileModel: + path = self.__fileModel.filePath(index) + if self.__fileModel.isDir(index): + self.__fileModel_setRootPath(path) + if os.path.isfile(path): + self.__fileActivated(index) + elif index.model() is self.__dataModel: + obj = self.__dataModel.data(index, role=Hdf5TreeModel.H5PY_OBJECT_ROLE) + if silx.io.is_group(obj): + self.__browser.setRootIndex(index) + else: + assert(False) + + def __browsedItemSelected(self, index): + self.__dataSelected(index) + self.__updatePath() + + def __fileModel_setRootPath(self, path): + """Set the root path of the fileModel with a filter on the + directoryLoaded event. + + Without this filter an extra event is received (at least with PyQt4) + when we use for the first time the sidebar. + + :param str path: Path to load + """ + assert(path is not None) + if path != "" and not os.path.exists(path): + return + if self.hasPendingEvents(): + # Make sure the asynchronous fileModel setRootPath is finished + qt.QApplication.instance().processEvents() + + if self.__directoryLoadedFilter is not None: + if utils.samefile(self.__directoryLoadedFilter, path): + return + self.__directoryLoadedFilter = path + self.__processing += 1 + index = self.__fileModel.setRootPath(path) + if not index.isValid(): + self.__processing -= 1 + self.__browser.setRootIndex(index, model=self.__fileModel) + self.__clearData() + self.__updatePath() + else: + # asynchronous process + pass + + def __directoryLoaded(self, path): + if self.__directoryLoadedFilter is not None: + if not utils.samefile(self.__directoryLoadedFilter, path): + # Filter event which should not arrive in PyQt4 + # The first click on the sidebar sent 2 events + self.__processing -= 1 + return + index = self.__fileModel.index(path) + self.__browser.setRootIndex(index, model=self.__fileModel) + self.__updatePath() + self.__processing -= 1 + + def __closeFile(self): + self.__openedFiles[:] = [] + self.__fileDirectoryAction.setEnabled(False) + self.__parentFileDirectoryAction.setEnabled(False) + if self.__h5 is not None: + self.__dataModel.removeH5pyObject(self.__h5) + self.__h5.close() + self.__h5 = None + if self.__fabio is not None: + if hasattr(self.__fabio, "close"): + self.__fabio.close() + self.__fabio = None + + def __openFabioFile(self, filename): + self.__closeFile() + try: + if fabio is None: + raise ImportError("Fabio module is not available") + self.__fabio = fabio.open(filename) + self.__openedFiles.append(self.__fabio) + self.__selectedFile = filename + except Exception as e: + _logger.error("Error while loading file %s: %s", filename, e.args[0]) + _logger.debug("Backtrace", exc_info=True) + self.__errorWhileLoadingFile = filename, e.args[0] + return False + else: + return True + + def __openSilxFile(self, filename): + self.__closeFile() + try: + self.__h5 = silx.io.open(filename) + self.__openedFiles.append(self.__h5) + self.__selectedFile = filename + except IOError as e: + _logger.error("Error while loading file %s: %s", filename, e.args[0]) + _logger.debug("Backtrace", exc_info=True) + self.__errorWhileLoadingFile = filename, e.args[0] + return False + else: + self.__fileDirectoryAction.setEnabled(True) + self.__parentFileDirectoryAction.setEnabled(True) + self.__dataModel.insertH5pyObject(self.__h5) + return True + + def __isSilxHavePriority(self, filename): + """Silx have priority when there is a specific decoder + """ + _, ext = os.path.splitext(filename) + ext = "*%s" % ext + formats = silx.io.supported_extensions(flat_formats=False) + for extensions in formats.values(): + if ext in extensions: + return True + return False + + def __openFile(self, filename): + codec = self.__fileTypeCombo.currentCodec() + openners = [] + if codec.is_autodetect(): + if self.__isSilxHavePriority(filename): + openners.append(self.__openSilxFile) + if fabio is not None and self._isFabioFilesSupported(): + openners.append(self.__openFabioFile) + else: + if fabio is not None and self._isFabioFilesSupported(): + openners.append(self.__openFabioFile) + openners.append(self.__openSilxFile) + elif codec.is_silx_codec(): + openners.append(self.__openSilxFile) + elif self._isFabioFilesSupported() and codec.is_fabio_codec(): + # It is requested to use fabio, anyway fabio is here or not + openners.append(self.__openFabioFile) + + for openner in openners: + ref = openner(filename) + if ref is not None: + return True + return False + + def __fileActivated(self, index): + self.__selectedFile = None + path = self.__fileModel.filePath(index) + if os.path.isfile(path): + loaded = self.__openFile(path) + if loaded: + if self.__h5 is not None: + index = self.__dataModel.indexFromH5Object(self.__h5) + self.__browser.setRootIndex(index) + elif self.__fabio is not None: + data = _FabioData(self.__fabio) + self.__setData(data) + self.__updatePath() + else: + self.__clearData() + + def __dataSelected(self, index): + selectedData = None + if index is not None: + if index.model() is self.__dataModel: + obj = self.__dataModel.data(index, self.__dataModel.H5PY_OBJECT_ROLE) + if self._isDataSupportable(obj): + selectedData = obj + elif index.model() is self.__fileModel: + self.__closeFile() + if self._isFabioFilesSupported(): + path = self.__fileModel.filePath(index) + if os.path.isfile(path): + codec = self.__fileTypeCombo.currentCodec() + is_fabio_decoder = codec.is_fabio_codec() + is_fabio_have_priority = not codec.is_silx_codec() and not self.__isSilxHavePriority(path) + if is_fabio_decoder or is_fabio_have_priority: + # Then it's flat frame container + if fabio is not None: + self.__openFabioFile(path) + if self.__fabio is not None: + selectedData = _FabioData(self.__fabio) + else: + assert(False) + + self.__setData(selectedData) + + def __filterSelected(self, index): + filters = self.__fileTypeCombo.itemExtensions(index) + self.__fileModel.setNameFilters(filters) + + def __setData(self, data): + self.__data = data + + if data is not None and self._isDataSupportable(data): + if self.__selectorWidget is not None: + self.__selectorWidget.setData(data) + if not self.__selectorWidget.isUsed(): + # Needed to fake the fact we have to reset the zoom in preview + self.__selectedData = None + self.__setSelectedData(data) + self.__selectorWidget.hide() + else: + self.__selectorWidget.setVisible(self.__selectorWidget.hasVisibleSelectors()) + # Needed to fake the fact we have to reset the zoom in preview + self.__selectedData = None + self.__selectorWidget.selectionChanged.emit() + else: + # Needed to fake the fact we have to reset the zoom in preview + self.__selectedData = None + self.__setSelectedData(data) + else: + self.__clearData() + self.__updatePath() + + def _isDataSupported(self, data): + """Check if the data can be returned by the dialog. + + If true, this data can be returned by the dialog and the open button + while be enabled. If false the button will be disabled. + + :rtype: bool + """ + raise NotImplementedError() + + def _isDataSupportable(self, data): + """Check if the selected data can be supported at one point. + + If true, the data selector will be checked and it will update the data + preview. Else the selecting is disabled. + + :rtype: bool + """ + raise NotImplementedError() + + def __clearData(self): + """Clear the data part of the GUI""" + if self.__previewWidget is not None: + self.__previewWidget.setData(None) + if self.__selectorWidget is not None: + self.__selectorWidget.hide() + self.__selectedData = None + self.__data = None + self.__updateDataInfo() + button = self.__buttons.button(qt.QDialogButtonBox.Open) + button.setEnabled(False) + + def __selectorWidgetChanged(self): + data = self.__selectorWidget.getSelectedData(self.__data) + self.__setSelectedData(data) + + def __setSelectedData(self, data): + """Set the data selected by the dialog. + + If :meth:`_isDataSupported` returns false, this function will be + inhibited and no data will be selected. + """ + if self.__previewWidget is not None: + fromDataSelector = self.__selectedData is not None + self.__previewWidget.setData(data, fromDataSelector=fromDataSelector) + if self._isDataSupported(data): + self.__selectedData = data + else: + self.__clearData() + return + self.__updateDataInfo() + self.__updatePath() + button = self.__buttons.button(qt.QDialogButtonBox.Open) + button.setEnabled(True) + + def __updateDataInfo(self): + if self.__errorWhileLoadingFile is not None: + filename, message = self.__errorWhileLoadingFile + message = "<b>Error while loading file '%s'</b><hr/>%s" % (filename, message) + size = self.__dataInfo.height() + icon = self.style().standardIcon(qt.QStyle.SP_MessageBoxCritical) + pixmap = icon.pixmap(size, size) + + self.__dataInfo.setText("Error while loading file") + self.__dataInfo.setToolTip(message) + self.__dataIcon.setToolTip(message) + self.__dataIcon.setVisible(True) + self.__dataIcon.setPixmap(pixmap) + + self.__errorWhileLoadingFile = None + return + + self.__dataIcon.setVisible(False) + self.__dataInfo.setToolTip("") + if self.__selectedData is None: + self.__dataInfo.setText("No data selected") + else: + text = self._displayedDataInfo(self.__data, self.__selectedData) + self.__dataInfo.setVisible(text is not None) + if text is not None: + self.__dataInfo.setText(text) + + def _displayedDataInfo(self, dataBeforeSelection, dataAfterSelection): + """Returns the text displayed under the data preview. + + This zone is used to display error in case or problem of data selection + or problems with IO. + + :param numpy.ndarray dataAfterSelection: Data as it is after the + selection widget (basically the data from the preview widget) + :param numpy.ndarray dataAfterSelection: Data as it is before the + selection widget (basically the data from the browsing widget) + :rtype: bool + """ + return None + + def __createUrlFromIndex(self, index, useSelectorWidget=True): + if index.model() is self.__fileModel: + filename = self.__fileModel.filePath(index) + dataPath = None + elif index.model() is self.__dataModel: + obj = self.__dataModel.data(index, role=Hdf5TreeModel.H5PY_OBJECT_ROLE) + filename = obj.file.filename + dataPath = obj.name + else: + # root of the computer + filename = "" + dataPath = None + + if useSelectorWidget and self.__selectorWidget is not None and self.__selectorWidget.isVisible(): + slicing = self.__selectorWidget.slicing() + else: + slicing = None + + if self.__fabio is not None: + scheme = "fabio" + elif self.__h5 is not None: + scheme = "silx" + else: + if os.path.isfile(filename): + codec = self.__fileTypeCombo.currentCodec() + if codec.is_fabio_codec(): + scheme = "fabio" + elif codec.is_silx_codec(): + scheme = "silx" + else: + scheme = None + else: + scheme = None + + url = silx.io.url.DataUrl(file_path=filename, data_path=dataPath, data_slice=slicing, scheme=scheme) + return url + + def __updatePath(self): + index = self.__browser.selectedIndex() + if index is None: + index = self.__browser.rootIndex() + url = self.__createUrlFromIndex(index) + if url.path() != self.__pathEdit.text(): + old = self.__pathEdit.blockSignals(True) + self.__pathEdit.setText(url.path()) + self.__pathEdit.blockSignals(old) + + def __rootIndexChanged(self, index): + url = self.__createUrlFromIndex(index, useSelectorWidget=False) + + currentUrl = None + if 0 <= self.__currentHistoryLocation < len(self.__currentHistory): + currentUrl = self.__currentHistory[self.__currentHistoryLocation] + + if currentUrl is None or currentUrl != url.path(): + # clean up the forward history + self.__currentHistory = self.__currentHistory[0:self.__currentHistoryLocation + 1] + self.__currentHistory.append(url.path()) + self.__currentHistoryLocation += 1 + + if index.model() != self.__dataModel: + if sys.platform == "win32": + # path == "" + isRoot = not index.isValid() + else: + # path in ["", "/"] + isRoot = not index.isValid() or not index.parent().isValid() + else: + isRoot = False + + if index.isValid(): + self.__dataSelected(index) + self.__toParentAction.setEnabled(not isRoot) + self.__updateActionHistory() + self.__updateSidebar() + + def __updateSidebar(self): + """Called when the current directory location change""" + if self.__sidebar is None: + return + selectionModel = self.__sidebar.selectionModel() + selectionModel.selectionChanged.disconnect(self.__shortcutSelected) + index = self.__browser.rootIndex() + if index.model() == self.__fileModel: + path = self.__fileModel.filePath(index) + self.__sidebar.setSelectedPath(path) + elif index.model() is None: + path = "" + self.__sidebar.setSelectedPath(path) + else: + selectionModel.clear() + selectionModel.selectionChanged.connect(self.__shortcutSelected) + + def __updateActionHistory(self): + self.__forwardAction.setEnabled(len(self.__currentHistory) - 1 > self.__currentHistoryLocation) + self.__backwardAction.setEnabled(self.__currentHistoryLocation > 0) + + def __textChanged(self, text): + self.__pathChanged() + + def _isFabioFilesSupported(self): + """Returns true fabio files can be loaded. + """ + return True + + def _isLoadableUrl(self, url): + """Returns true if the URL is loadable by this dialog. + + :param DataUrl url: The requested URL + """ + return True + + def __pathChanged(self): + url = silx.io.url.DataUrl(path=self.__pathEdit.text()) + if url.is_valid() or url.path() == "": + if url.path() in ["", "/"] or url.file_path() in ["", "/"]: + self.__fileModel_setRootPath(qt.QDir.rootPath()) + elif os.path.exists(url.file_path()): + rootIndex = None + if os.path.isdir(url.file_path()): + self.__fileModel_setRootPath(url.file_path()) + index = self.__fileModel.index(url.file_path()) + elif os.path.isfile(url.file_path()): + if self._isLoadableUrl(url): + if url.scheme() == "silx": + loaded = self.__openSilxFile(url.file_path()) + elif url.scheme() == "fabio" and self._isFabioFilesSupported(): + loaded = self.__openFabioFile(url.file_path()) + else: + loaded = self.__openFile(url.file_path()) + else: + loaded = False + if loaded: + if self.__h5 is not None: + rootIndex = self.__dataModel.indexFromH5Object(self.__h5) + elif self.__fabio is not None: + index = self.__fileModel.index(url.file_path()) + rootIndex = index + if rootIndex is None: + index = self.__fileModel.index(url.file_path()) + index = index.parent() + + if rootIndex is not None: + if rootIndex.model() == self.__dataModel: + if url.data_path() is not None: + dataPath = url.data_path() + if dataPath in self.__h5: + obj = self.__h5[dataPath] + else: + path = utils.findClosestSubPath(self.__h5, dataPath) + if path is None: + path = "/" + obj = self.__h5[path] + + if silx.io.is_file(obj): + self.__browser.setRootIndex(rootIndex) + elif silx.io.is_group(obj): + index = self.__dataModel.indexFromH5Object(obj) + self.__browser.setRootIndex(index) + else: + index = self.__dataModel.indexFromH5Object(obj) + self.__browser.setRootIndex(index.parent()) + self.__browser.selectIndex(index) + else: + self.__browser.setRootIndex(rootIndex) + self.__clearData() + elif rootIndex.model() == self.__fileModel: + # that's a fabio file + self.__browser.setRootIndex(rootIndex.parent()) + self.__browser.selectIndex(rootIndex) + # data = _FabioData(self.__fabio) + # self.__setData(data) + else: + assert(False) + else: + self.__browser.setRootIndex(index, model=self.__fileModel) + self.__clearData() + + if self.__selectorWidget is not None: + self.__selectorWidget.setVisible(url.data_slice() is not None) + if url.data_slice() is not None: + self.__selectorWidget.setSlicing(url.data_slice()) + else: + self.__errorWhileLoadingFile = (url.file_path(), "File not found") + self.__clearData() + else: + self.__errorWhileLoadingFile = (url.file_path(), "Path invalid") + self.__clearData() + + def previewToolbar(self): + return self.__previewToolbar + + def previewWidget(self): + return self.__previewWidget + + def selectorWidget(self): + return self.__selectorWidget + + def _createPreviewToolbar(self, parent, dataPreviewWidget, dataSelectorWidget): + return None + + def _createPreviewWidget(self, parent): + return None + + def _createSelectorWidget(self, parent): + return None + + # Selected file + + def setDirectory(self, path): + """Sets the data dialog's current directory.""" + self.__fileModel_setRootPath(path) + + def selectedFile(self): + """Returns the file path containing the selected data. + + :rtype: str + """ + return self.__selectedFile + + def selectFile(self, filename): + """Sets the data dialog's current file.""" + self.__directoryLoadedFilter = "" + old = self.__pathEdit.blockSignals(True) + try: + self.__pathEdit.setText(filename) + finally: + self.__pathEdit.blockSignals(old) + self.__pathChanged() + + # Selected data + + def selectUrl(self, url): + """Sets the data dialog's current data url. + + :param Union[str,DataUrl] url: URL identifying a data (it can be a + `DataUrl` object) + """ + if isinstance(url, silx.io.url.DataUrl): + url = url.path() + self.__directoryLoadedFilter = "" + old = self.__pathEdit.blockSignals(True) + try: + self.__pathEdit.setText(url) + finally: + self.__pathEdit.blockSignals(old) + self.__pathChanged() + + def selectedUrl(self): + """Returns the URL from the file system to the data. + + If the dialog is not validated, the path can be an intermediat + selected path, or an invalid path. + + :rtype: str + """ + return self.__pathEdit.text() + + def selectedDataUrl(self): + """Returns the URL as a :class:`DataUrl` from the file system to the + data. + + If the dialog is not validated, the path can be an intermediat + selected path, or an invalid path. + + :rtype: DataUrl + """ + url = self.selectedUrl() + return silx.io.url.DataUrl(url) + + def directory(self): + """Returns the path from the current browsed directory. + + :rtype: str + """ + if self.__directory is not None: + # At post execution, returns the cache + return self.__directory + + index = self.__browser.rootIndex() + if index.model() is self.__fileModel: + path = self.__fileModel.filePath(index) + return path + elif index.model() is self.__dataModel: + path = os.path.dirname(self.__h5.file.filename) + return path + else: + return "" + + def _selectedData(self): + """Returns the internal selected data + + :rtype: numpy.ndarray + """ + return self.__selectedData + + # Filters + + def selectedNameFilter(self): + """Returns the filter that the user selected in the file dialog.""" + return self.__fileTypeCombo.currentText() + + # History + + def history(self): + """Returns the browsing history of the filedialog as a list of paths. + + :rtype: List<str> + """ + if len(self.__currentHistory) <= 1: + return [] + history = self.__currentHistory[0:self.__currentHistoryLocation] + return list(history) + + def setHistory(self, history): + self.__currentHistory = [] + self.__currentHistory.extend(history) + self.__currentHistoryLocation = len(self.__currentHistory) - 1 + self.__updateActionHistory() + + # Colormap + + def colormap(self): + if self.__previewWidget is None: + return None + return self.__previewWidget.colormap() + + def setColormap(self, colormap): + if self.__previewWidget is None: + raise RuntimeError("No preview widget defined") + self.__previewWidget.setColormap(colormap) + + # Sidebar + + def setSidebarUrls(self, urls): + """Sets the urls that are located in the sidebar.""" + if self.__sidebar is None: + return + self.__sidebar.setUrls(urls) + + def sidebarUrls(self): + """Returns a list of urls that are currently in the sidebar.""" + if self.__sidebar is None: + return [] + return self.__sidebar.urls() + + # State + + __serialVersion = 1 + """Store the current version of the serialized data""" + + @classmethod + def qualifiedName(cls): + return "%s.%s" % (cls.__module__, cls.__name__) + + def restoreState(self, state): + """Restores the dialogs's layout, history and current directory to the + state specified. + + :param qt.QByteArray state: Stream containing the new state + :rtype: bool + """ + stream = qt.QDataStream(state, qt.QIODevice.ReadOnly) + + qualifiedName = stream.readQString() + if qualifiedName != self.qualifiedName(): + _logger.warning("Stored state contains an invalid qualified name. %s restoration cancelled.", self.__class__.__name__) + return False + + version = stream.readInt32() + if version != self.__serialVersion: + _logger.warning("Stored state contains an invalid version. %s restoration cancelled.", self.__class__.__name__) + return False + + result = True + + splitterData = stream.readQVariant() + sidebarUrls = stream.readQStringList() + history = stream.readQStringList() + workingDirectory = stream.readQString() + browserData = stream.readQVariant() + viewMode = stream.readInt32() + colormapData = stream.readQVariant() + + result &= self.__splitter.restoreState(splitterData) + sidebarUrls = [qt.QUrl(s) for s in sidebarUrls] + self.setSidebarUrls(list(sidebarUrls)) + history = [s for s in history] + self.setHistory(list(history)) + if workingDirectory is not None: + self.setDirectory(workingDirectory) + result &= self.__browser.restoreState(browserData) + self.setViewMode(viewMode) + colormap = self.colormap() + if colormap is not None: + result &= self.colormap().restoreState(colormapData) + + return result + + def saveState(self): + """Saves the state of the dialog's layout, history and current + directory. + + :rtype: qt.QByteArray + """ + data = qt.QByteArray() + stream = qt.QDataStream(data, qt.QIODevice.WriteOnly) + + s = self.qualifiedName() + stream.writeQString(u"%s" % s) + stream.writeInt32(self.__serialVersion) + stream.writeQVariant(self.__splitter.saveState()) + strings = [u"%s" % s.toString() for s in self.sidebarUrls()] + stream.writeQStringList(strings) + strings = [u"%s" % s for s in self.history()] + stream.writeQStringList(strings) + stream.writeQString(u"%s" % self.directory()) + stream.writeQVariant(self.__browser.saveState()) + stream.writeInt32(self.viewMode()) + colormap = self.colormap() + if colormap is not None: + stream.writeQVariant(self.colormap().saveState()) + else: + stream.writeQVariant(None) + + return data diff --git a/silx/gui/dialog/DataFileDialog.py b/silx/gui/dialog/DataFileDialog.py new file mode 100644 index 0000000..7ff1258 --- /dev/null +++ b/silx/gui/dialog/DataFileDialog.py @@ -0,0 +1,342 @@ +# 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. +# +# ###########################################################################*/ +""" +This module contains an :class:`DataFileDialog`. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "14/02/2018" + +import logging +from silx.gui import qt +from silx.gui.hdf5.Hdf5Formatter import Hdf5Formatter +import silx.io +from .AbstractDataFileDialog import AbstractDataFileDialog +from silx.third_party import enum +try: + import fabio +except ImportError: + fabio = None + + +_logger = logging.getLogger(__name__) + + +class _DataPreview(qt.QWidget): + """Provide a preview of the selected image""" + + def __init__(self, parent=None): + super(_DataPreview, self).__init__(parent) + + self.__formatter = Hdf5Formatter(self) + self.__data = None + self.__info = qt.QTableView(self) + self.__model = qt.QStandardItemModel(self) + self.__info.setModel(self.__model) + self.__info.horizontalHeader().hide() + self.__info.horizontalHeader().setStretchLastSection(True) + layout = qt.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.__info) + self.setLayout(layout) + + def colormap(self): + return None + + def setColormap(self, colormap): + # Ignored + pass + + def sizeHint(self): + return qt.QSize(200, 200) + + def setData(self, data, fromDataSelector=False): + self.__info.setEnabled(data is not None) + if data is None: + self.__model.clear() + else: + self.__model.clear() + + if silx.io.is_dataset(data): + kind = "Dataset" + elif silx.io.is_group(data): + kind = "Group" + elif silx.io.is_file(data): + kind = "File" + else: + kind = "Unknown" + + headers = [] + + basename = data.name.split("/")[-1] + if basename == "": + basename = "/" + headers.append("Basename") + self.__model.appendRow([qt.QStandardItem(basename)]) + headers.append("Kind") + self.__model.appendRow([qt.QStandardItem(kind)]) + if hasattr(data, "dtype"): + headers.append("Type") + text = self.__formatter.humanReadableType(data) + self.__model.appendRow([qt.QStandardItem(text)]) + if hasattr(data, "shape"): + headers.append("Shape") + text = self.__formatter.humanReadableShape(data) + self.__model.appendRow([qt.QStandardItem(text)]) + if hasattr(data, "attrs") and "NX_class" in data.attrs: + headers.append("NX_class") + value = data.attrs["NX_class"] + formatter = self.__formatter.textFormatter() + old = formatter.useQuoteForText() + formatter.setUseQuoteForText(False) + text = self.__formatter.textFormatter().toString(value) + formatter.setUseQuoteForText(old) + self.__model.appendRow([qt.QStandardItem(text)]) + self.__model.setVerticalHeaderLabels(headers) + self.__data = data + + def __imageItem(self): + image = self.__plot.getImage("data") + return image + + def data(self): + if self.__data is not None: + if hasattr(self.__data, "name"): + # in case of HDF5 + if self.__data.name is None: + # The dataset was closed + self.__data = None + return self.__data + + def clear(self): + self.__data = None + self.__info.setText("") + + +class DataFileDialog(AbstractDataFileDialog): + """The `DataFileDialog` class provides a dialog that allow users to select + any datasets or groups from an HDF5-like file. + + The `DataFileDialog` class enables a user to traverse the file system in + order to select an HDF5-like file. Then to traverse the file to select an + HDF5 node. + + .. image:: img/datafiledialog.png + + The selected data is any kind of group or dataset. It can be restricted + to only existing datasets or only existing groups using + :meth:`setFilterMode`. A callback can be defining using + :meth:`setFilterCallback` to filter even more data which can be returned. + + Filtering data which can be returned by a `DataFileDialog` can be done like + that: + + .. code-block:: python + + # Force to return only a dataset + dialog = DataFileDialog() + dialog.setFilterMode(DataFileDialog.FilterMode.ExistingDataset) + + .. code-block:: python + + def customFilter(obj): + if "NX_class" in obj.attrs: + return obj.attrs["NX_class"] in [b"NXentry", u"NXentry"] + return False + + # Force to return an NX entry + dialog = DataFileDialog() + # 1st, filter out everything which is not a group + dialog.setFilterMode(DataFileDialog.FilterMode.ExistingGroup) + # 2nd, check what NX_class is an NXentry + dialog.setFilterCallback(customFilter) + + Executing a `DataFileDialog` can be done like that: + + .. code-block:: python + + dialog = DataFileDialog() + result = dialog.exec_() + if result: + print("Selection:") + print(dialog.selectedFile()) + print(dialog.selectedUrl()) + else: + print("Nothing selected") + + If the selection is a dataset you can access to the data using + :meth:`selectedData`. + + If the selection is a group or if you want to read the selected object on + your own you can use the `silx.io` API. + + .. code-block:: python + + url = dialog.selectedUrl() + with silx.io.open(url) as data: + pass + + Or by loading the file first + + .. code-block:: python + + url = dialog.selectedDataUrl() + with silx.io.open(url.file_path()) as h5: + data = h5[url.data_path()] + + Or by using `h5py` library + + .. code-block:: python + + url = dialog.selectedDataUrl() + with h5py.File(url.file_path()) as h5: + data = h5[url.data_path()] + """ + + class FilterMode(enum.Enum): + """This enum is used to indicate what the user may select in the + dialog; i.e. what the dialog will return if the user clicks OK.""" + + AnyNode = 0 + """Any existing node from an HDF5-like file.""" + ExistingDataset = 1 + """An existing HDF5-like dataset.""" + ExistingGroup = 2 + """An existing HDF5-like group. A file root is a group.""" + + def __init__(self, parent=None): + AbstractDataFileDialog.__init__(self, parent=parent) + self.__filter = DataFileDialog.FilterMode.AnyNode + self.__filterCallback = None + + def selectedData(self): + """Returns the selected data by using the :meth:`silx.io.get_data` + API with the selected URL provided by the dialog. + + If the URL identify a group of a file it will raise an exception. For + group or file you have to use on your own the API :meth:`silx.io.open`. + + :rtype: numpy.ndarray + :raise ValueError: If the URL do not link to a dataset + """ + url = self.selectedUrl() + return silx.io.get_data(url) + + def _createPreviewWidget(self, parent): + previewWidget = _DataPreview(parent) + previewWidget.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding) + return previewWidget + + def _createSelectorWidget(self, parent): + # There is no selector + return None + + def _createPreviewToolbar(self, parent, dataPreviewWidget, dataSelectorWidget): + # There is no toolbar + return None + + def _isDataSupportable(self, data): + """Check if the selected data can be supported at one point. + + If true, the data selector will be checked and it will update the data + preview. Else the selecting is disabled. + + :rtype: bool + """ + # Everything is supported + return True + + def _isFabioFilesSupported(self): + # Everything is supported + return False + + def _isDataSupported(self, data): + """Check if the data can be returned by the dialog. + + If true, this data can be returned by the dialog and the open button + will be enabled. If false the button will be disabled. + + :rtype: bool + """ + if self.__filter == DataFileDialog.FilterMode.AnyNode: + accepted = True + elif self.__filter == DataFileDialog.FilterMode.ExistingDataset: + accepted = silx.io.is_dataset(data) + elif self.__filter == DataFileDialog.FilterMode.ExistingGroup: + accepted = silx.io.is_group(data) + else: + raise ValueError("Filter %s is not supported" % self.__filter) + if not accepted: + return False + if self.__filterCallback is not None: + try: + return self.__filterCallback(data) + except Exception: + _logger.error("Error while executing custom callback", exc_info=True) + return False + return True + + def setFilterCallback(self, callback): + """Set the filter callback. This filter is applied only if the filter + mode (:meth:`filterMode`) first accepts the selected data. + + It is not supposed to be set while the dialog is being used. + + :param callable callback: Define a custom function returning a boolean + and taking as argument an h5-like node. If the function returns true + the dialog can return the associated URL. + """ + self.__filterCallback = callback + + def setFilterMode(self, mode): + """Set the filter mode. + + It is not supposed to be set while the dialog is being used. + + :param DataFileDialog.FilterMode mode: The new filter. + """ + self.__filter = mode + + def fileMode(self): + """Returns the filter mode. + + :rtype: DataFileDialog.FilterMode + """ + return self.__filter + + def _displayedDataInfo(self, dataBeforeSelection, dataAfterSelection): + """Returns the text displayed under the data preview. + + This zone is used to display error in case or problem of data selection + or problems with IO. + + :param numpy.ndarray dataAfterSelection: Data as it is after the + selection widget (basically the data from the preview widget) + :param numpy.ndarray dataAfterSelection: Data as it is before the + selection widget (basically the data from the browsing widget) + :rtype: bool + """ + return u"" diff --git a/silx/gui/dialog/FileTypeComboBox.py b/silx/gui/dialog/FileTypeComboBox.py new file mode 100644 index 0000000..07b11cf --- /dev/null +++ b/silx/gui/dialog/FileTypeComboBox.py @@ -0,0 +1,213 @@ +# 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. +# +# ###########################################################################*/ +""" +This module contains utilitaries used by other dialog modules. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "06/02/2018" + +try: + import fabio +except ImportError: + fabio = None +import silx.io +from silx.gui import qt + + +class Codec(object): + + def __init__(self, any_fabio=False, any_silx=False, fabio_codec=None, auto=False): + self.__any_fabio = any_fabio + self.__any_silx = any_silx + self.fabio_codec = fabio_codec + self.__auto = auto + + def is_autodetect(self): + return self.__auto + + def is_fabio_codec(self): + return self.__any_fabio or self.fabio_codec is not None + + def is_silx_codec(self): + return self.__any_silx + + +class FileTypeComboBox(qt.QComboBox): + """ + A combobox providing all image file formats supported by fabio and silx. + + It provides access for each fabio codecs individually. + """ + + EXTENSIONS_ROLE = qt.Qt.UserRole + 1 + + CODEC_ROLE = qt.Qt.UserRole + 2 + + INDENTATION = u"\u2022 " + + def __init__(self, parent=None): + qt.QComboBox.__init__(self, parent) + self.__fabioUrlSupported = True + self.__initItems() + + def setFabioUrlSupproted(self, isSupported): + if self.__fabioUrlSupported == isSupported: + return + self.__fabioUrlSupported = isSupported + self.__initItems() + + def __initItems(self): + self.clear() + if fabio is not None and self.__fabioUrlSupported: + self.__insertFabioFormats() + self.__insertSilxFormats() + self.__insertAllSupported() + self.__insertAnyFiles() + + def __insertAnyFiles(self): + index = self.count() + self.addItem("All files (*)") + self.setItemData(index, ["*"], role=self.EXTENSIONS_ROLE) + self.setItemData(index, Codec(auto=True), role=self.CODEC_ROLE) + + def __insertAllSupported(self): + allExtensions = set([]) + for index in range(self.count()): + ext = self.itemExtensions(index) + allExtensions.update(ext) + allExtensions = allExtensions - set("*") + list(sorted(list(allExtensions))) + index = 0 + self.insertItem(index, "All supported files") + self.setItemData(index, allExtensions, role=self.EXTENSIONS_ROLE) + self.setItemData(index, Codec(auto=True), role=self.CODEC_ROLE) + + def __insertSilxFormats(self): + formats = silx.io.supported_extensions() + + extensions = [] + allExtensions = set([]) + + for description, ext in formats.items(): + allExtensions.update(ext) + if ext == []: + ext = ["*"] + extensions.append((description, ext, "silx")) + extensions = list(sorted(extensions)) + + allExtensions = list(sorted(list(allExtensions))) + index = self.count() + self.addItem("All supported files, using Silx") + self.setItemData(index, allExtensions, role=self.EXTENSIONS_ROLE) + self.setItemData(index, Codec(any_silx=True), role=self.CODEC_ROLE) + + for e in extensions: + index = self.count() + if len(e[1]) < 10: + self.addItem("%s%s (%s)" % (self.INDENTATION, e[0], " ".join(e[1]))) + else: + self.addItem("%s%s" % (self.INDENTATION, e[0])) + codec = Codec(any_silx=True) + self.setItemData(index, e[1], role=self.EXTENSIONS_ROLE) + self.setItemData(index, codec, role=self.CODEC_ROLE) + + def __insertFabioFormats(self): + formats = fabio.fabioformats.get_classes(reader=True) + + extensions = [] + allExtensions = set([]) + + for reader in formats: + if not hasattr(reader, "DESCRIPTION"): + continue + if not hasattr(reader, "DEFAULT_EXTENSIONS"): + continue + + ext = reader.DEFAULT_EXTENSIONS + ext = ["*.%s" % e for e in ext] + allExtensions.update(ext) + if ext == []: + ext = ["*"] + extensions.append((reader.DESCRIPTION, ext, reader.codec_name())) + extensions = list(sorted(extensions)) + + allExtensions = list(sorted(list(allExtensions))) + index = self.count() + self.addItem("All supported files, using Fabio") + self.setItemData(index, allExtensions, role=self.EXTENSIONS_ROLE) + self.setItemData(index, Codec(any_fabio=True), role=self.CODEC_ROLE) + + for e in extensions: + index = self.count() + if len(e[1]) < 10: + self.addItem("%s%s (%s)" % (self.INDENTATION, e[0], " ".join(e[1]))) + else: + self.addItem(e[0]) + codec = Codec(fabio_codec=e[2]) + self.setItemData(index, e[1], role=self.EXTENSIONS_ROLE) + self.setItemData(index, codec, role=self.CODEC_ROLE) + + def itemExtensions(self, index): + """Returns the extensions associated to an index.""" + result = self.itemData(index, self.EXTENSIONS_ROLE) + if result is None: + result = None + return result + + def currentExtensions(self): + """Returns the current selected extensions.""" + index = self.currentIndex() + return self.itemExtensions(index) + + def indexFromCodec(self, codecName): + for i in range(self.count()): + codec = self.itemCodec(i) + if codecName == "auto": + if codec.is_autodetect(): + return i + elif codecName == "silx": + if codec.is_silx_codec(): + return i + elif codecName == "fabio": + if codec.is_fabio_codec() and codec.fabio_codec is None: + return i + elif codecName == codec.fabio_codec: + return i + return -1 + + def itemCodec(self, index): + """Returns the codec associated to an index.""" + result = self.itemData(index, self.CODEC_ROLE) + if result is None: + result = None + return result + + def currentCodec(self): + """Returns the current selected codec. None if nothing selected + or if the item is not a codec""" + index = self.currentIndex() + return self.itemCodec(index) diff --git a/silx/gui/dialog/ImageFileDialog.py b/silx/gui/dialog/ImageFileDialog.py new file mode 100644 index 0000000..c324071 --- /dev/null +++ b/silx/gui/dialog/ImageFileDialog.py @@ -0,0 +1,338 @@ +# 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. +# +# ###########################################################################*/ +""" +This module contains an :class:`ImageFileDialog`. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "12/02/2018" + +import logging +from silx.gui.plot import actions +from silx.gui import qt +from silx.gui.plot.PlotWidget import PlotWidget +from .AbstractDataFileDialog import AbstractDataFileDialog +import silx.io +try: + import fabio +except ImportError: + fabio = None + + +_logger = logging.getLogger(__name__) + + +class _ImageSelection(qt.QWidget): + """Provide a widget allowing to select an image from an hypercube by + selecting a slice.""" + + selectionChanged = qt.Signal() + """Emitted when the selection change.""" + + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent) + self.__shape = None + self.__axis = [] + layout = qt.QVBoxLayout() + self.setLayout(layout) + + def hasVisibleSelectors(self): + return self.__visibleSliders > 0 + + def isUsed(self): + if self.__shape is None: + return None + return len(self.__shape) > 2 + + def getSelectedData(self, data): + slicing = self.slicing() + image = data[slicing] + return image + + def setData(self, data): + shape = data.shape + if self.__shape is not None: + # clean up + for widget in self.__axis: + self.layout().removeWidget(widget) + widget.deleteLater() + self.__axis = [] + + self.__shape = shape + self.__visibleSliders = 0 + + if shape is not None: + # create expected axes + for index in range(len(shape) - 2): + axis = qt.QSlider(self) + axis.setMinimum(0) + axis.setMaximum(shape[index] - 1) + axis.setOrientation(qt.Qt.Horizontal) + if shape[index] == 1: + axis.setVisible(False) + else: + self.__visibleSliders += 1 + + axis.valueChanged.connect(self.__axisValueChanged) + self.layout().addWidget(axis) + self.__axis.append(axis) + + self.selectionChanged.emit() + + def __axisValueChanged(self): + self.selectionChanged.emit() + + def slicing(self): + slicing = [] + for axes in self.__axis: + slicing.append(axes.value()) + return tuple(slicing) + + def setSlicing(self, slicing): + for i, value in enumerate(slicing): + if i > len(self.__axis): + break + self.__axis[i].setValue(value) + + +class _ImagePreview(qt.QWidget): + """Provide a preview of the selected image""" + + def __init__(self, parent=None): + super(_ImagePreview, self).__init__(parent) + + self.__data = None + self.__plot = PlotWidget(self) + self.__plot.setAxesDisplayed(False) + self.__plot.setKeepDataAspectRatio(True) + layout = qt.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.__plot) + self.setLayout(layout) + + def resizeEvent(self, event): + self.__updateConstraints() + return qt.QWidget.resizeEvent(self, event) + + def sizeHint(self): + return qt.QSize(200, 200) + + def plot(self): + return self.__plot + + def setData(self, data, fromDataSelector=False): + if data is None: + self.clear() + return + + resetzoom = not fromDataSelector + previousImage = self.data() + if previousImage is not None and data.shape != previousImage.shape: + resetzoom = True + + self.__plot.addImage(legend="data", data=data, resetzoom=resetzoom) + self.__data = data + self.__updateConstraints() + + def __updateConstraints(self): + """ + Update the constraints depending on the size of the widget + """ + image = self.data() + if image is None: + return + size = self.size() + if size.width() == 0 or size.height() == 0: + return + + heightData, widthData = image.shape + + widthContraint = heightData * size.width() / size.height() + if widthContraint > widthData: + heightContraint = heightData + else: + heightContraint = heightData * size.height() / size.width() + widthContraint = widthData + + midWidth, midHeight = widthData * 0.5, heightData * 0.5 + heightContraint, widthContraint = heightContraint * 0.5, widthContraint * 0.5 + + axis = self.__plot.getXAxis() + axis.setLimitsConstraints(midWidth - widthContraint, midWidth + widthContraint) + axis = self.__plot.getYAxis() + axis.setLimitsConstraints(midHeight - heightContraint, midHeight + heightContraint) + + def __imageItem(self): + image = self.__plot.getImage("data") + return image + + def data(self): + if self.__data is not None: + if hasattr(self.__data, "name"): + # in case of HDF5 + if self.__data.name is None: + # The dataset was closed + self.__data = None + return self.__data + + def colormap(self): + image = self.__imageItem() + if image is not None: + return image.getColormap() + return self.__plot.getDefaultColormap() + + def setColormap(self, colormap): + self.__plot.setDefaultColormap(colormap) + + def clear(self): + self.__data = None + image = self.__imageItem() + if image is not None: + self.__plot.removeImage(legend="data") + + +class ImageFileDialog(AbstractDataFileDialog): + """The `ImageFileDialog` class provides a dialog that allow users to select + an image from a file. + + The `ImageFileDialog` class enables a user to traverse the file system in + order to select one file. Then to traverse the file to select a frame or + a slice of a dataset. + + .. image:: img/imagefiledialog_h5.png + + It supports fast access to image files using `FabIO`. Which is not the case + of the default silx API. Image files still also can be available using the + NeXus layout, by editing the file type combo box. + + .. image:: img/imagefiledialog_edf.png + + The selected data is an numpy array with 2 dimension. + + Using an `ImageFileDialog` can be done like that. + + .. code-block:: python + + dialog = ImageFileDialog() + result = dialog.exec_() + if result: + print("Selection:") + print(dialog.selectedFile()) + print(dialog.selectedUrl()) + print(dialog.selectedImage()) + else: + print("Nothing selected") + """ + + def selectedImage(self): + """Returns the selected image data as numpy + + :rtype: numpy.ndarray + """ + url = self.selectedUrl() + return silx.io.get_data(url) + + def _createPreviewWidget(self, parent): + previewWidget = _ImagePreview(parent) + previewWidget.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding) + return previewWidget + + def _createSelectorWidget(self, parent): + return _ImageSelection(parent) + + def _createPreviewToolbar(self, parent, dataPreviewWidget, dataSelectorWidget): + plot = dataPreviewWidget.plot() + toolbar = qt.QToolBar(parent) + toolbar.setIconSize(qt.QSize(16, 16)) + toolbar.setStyleSheet("QToolBar { border: 0px }") + toolbar.addAction(actions.mode.ZoomModeAction(plot, parent)) + toolbar.addAction(actions.mode.PanModeAction(plot, parent)) + toolbar.addSeparator() + toolbar.addAction(actions.control.ResetZoomAction(plot, parent)) + toolbar.addSeparator() + toolbar.addAction(actions.control.ColormapAction(plot, parent)) + return toolbar + + def _isDataSupportable(self, data): + """Check if the selected data can be supported at one point. + + If true, the data selector will be checked and it will update the data + preview. Else the selecting is disabled. + + :rtype: bool + """ + if not hasattr(data, "dtype"): + # It is not an HDF5 dataset nor a fabio image wrapper + return False + + if data is None or data.shape is None: + return False + + if data.dtype.kind not in set(["f", "u", "i", "b"]): + return False + + dim = len(data.shape) + return dim >= 2 + + def _isFabioFilesSupported(self): + return True + + def _isDataSupported(self, data): + """Check if the data can be returned by the dialog. + + If true, this data can be returned by the dialog and the open button + while be enabled. If false the button will be disabled. + + :rtype: bool + """ + dim = len(data.shape) + return dim == 2 + + def _displayedDataInfo(self, dataBeforeSelection, dataAfterSelection): + """Returns the text displayed under the data preview. + + This zone is used to display error in case or problem of data selection + or problems with IO. + + :param numpy.ndarray dataAfterSelection: Data as it is after the + selection widget (basically the data from the preview widget) + :param numpy.ndarray dataAfterSelection: Data as it is before the + selection widget (basically the data from the browsing widget) + :rtype: bool + """ + destination = self.__formatShape(dataAfterSelection.shape) + source = self.__formatShape(dataBeforeSelection.shape) + return u"%s \u2192 %s" % (source, destination) + + def __formatShape(self, shape): + result = [] + for s in shape: + if isinstance(s, slice): + v = u"\u2026" + else: + v = str(s) + result.append(v) + return u" \u00D7 ".join(result) diff --git a/silx/gui/dialog/SafeFileIconProvider.py b/silx/gui/dialog/SafeFileIconProvider.py new file mode 100644 index 0000000..7fac7c0 --- /dev/null +++ b/silx/gui/dialog/SafeFileIconProvider.py @@ -0,0 +1,150 @@ +# 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. +# +# ###########################################################################*/ +""" +This module contains :class:`SafeIconProvider`. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "31/10/2017" + +import sys +import logging +from silx.gui import qt + + +_logger = logging.getLogger(__name__) + + +class SafeFileIconProvider(qt.QFileIconProvider): + """ + This class reimplement :class:`qt.QFileIconProvider` to avoid blocking + access to the file system. + + It avoid to use `qt.QFileInfo.absoluteFilePath` or + `qt.QFileInfo.canonicalPath` to reach drive icons which are known to + freeze the file system using network drives. + + Computer root, and drive root paths are filtered. Other paths are not + filtered while it is anyway needed to synchronoze a drive to accesss to it. + """ + + WIN32_DRIVE_UNKNOWN = 0 + """The drive type cannot be determined.""" + WIN32_DRIVE_NO_ROOT_DIR = 1 + """The root path is invalid; for example, there is no volume mounted at the + specified path.""" + WIN32_DRIVE_REMOVABLE = 2 + """The drive has removable media; for example, a floppy drive, thumb drive, + or flash card reader.""" + WIN32_DRIVE_FIXED = 3 + """The drive has fixed media; for example, a hard disk drive or flash + drive.""" + WIN32_DRIVE_REMOTE = 4 + """The drive is a remote (network) drive.""" + WIN32_DRIVE_CDROM = 5 + """The drive is a CD-ROM drive.""" + WIN32_DRIVE_RAMDISK = 6 + """The drive is a RAM disk.""" + + def __init__(self): + qt.QFileIconProvider.__init__(self) + self.__filterDirAndFiles = False + if sys.platform == "win32": + self._windowsTypes = {} + item = "Drive", qt.QStyle.SP_DriveHDIcon + self._windowsTypes[self.WIN32_DRIVE_UNKNOWN] = item + item = "Invalid root", qt.QStyle.SP_DriveHDIcon + self._windowsTypes[self.WIN32_DRIVE_NO_ROOT_DIR] = item + item = "Removable", qt.QStyle.SP_DriveNetIcon + self._windowsTypes[self.WIN32_DRIVE_REMOVABLE] = item + item = "Drive", qt.QStyle.SP_DriveHDIcon + self._windowsTypes[self.WIN32_DRIVE_FIXED] = item + item = "Remote", qt.QStyle.SP_DriveNetIcon + self._windowsTypes[self.WIN32_DRIVE_REMOTE] = item + item = "CD-ROM", qt.QStyle.SP_DriveCDIcon + self._windowsTypes[self.WIN32_DRIVE_CDROM] = item + item = "RAM disk", qt.QStyle.SP_DriveHDIcon + self._windowsTypes[self.WIN32_DRIVE_RAMDISK] = item + + def __windowsDriveTypeId(self, info): + try: + import ctypes + path = info.filePath() + dtype = ctypes.cdll.kernel32.GetDriveTypeW(path) + except Exception: + _logger.warning("Impossible to identify drive %s" % path) + _logger.debug("Backtrace", exc_info=True) + return self.WIN32_DRIVE_UNKNOWN + return dtype + + def __windowsDriveIcon(self, info): + dtype = self.__windowsDriveTypeId(info) + default = self._windowsTypes[self.WIN32_DRIVE_UNKNOWN] + driveInfo = self._windowsTypes.get(dtype, default) + style = qt.QApplication.instance().style() + icon = style.standardIcon(driveInfo[1]) + return icon + + def __windowsDriveType(self, info): + dtype = self.__windowsDriveTypeId(info) + default = self._windowsTypes[self.WIN32_DRIVE_UNKNOWN] + driveInfo = self._windowsTypes.get(dtype, default) + return driveInfo[0] + + def icon(self, info): + style = qt.QApplication.instance().style() + path = info.filePath() + if path in ["", "/"]: + # That's the computer root on Windows or Linux + result = style.standardIcon(qt.QStyle.SP_ComputerIcon) + elif sys.platform == "win32" and path[-2] == ":": + # That's a drive on Windows + result = self.__windowsDriveIcon(info) + elif self.__filterDirAndFiles: + if info.isDir(): + result = style.standardIcon(qt.QStyle.SP_DirIcon) + else: + result = style.standardIcon(qt.QStyle.SP_FileIcon) + else: + result = qt.QFileIconProvider.icon(self, info) + return result + + def type(self, info): + path = info.filePath() + if path in ["", "/"]: + # That's the computer root on Windows or Linux + result = "Computer" + elif sys.platform == "win32" and path[-2] == ":": + # That's a drive on Windows + result = self.__windowsDriveType(info) + elif self.__filterDirAndFiles: + if info.isDir(): + result = "Directory" + else: + result = info.suffix() + else: + result = qt.QFileIconProvider.type(self, info) + return result diff --git a/silx/gui/dialog/SafeFileSystemModel.py b/silx/gui/dialog/SafeFileSystemModel.py new file mode 100644 index 0000000..8a97974 --- /dev/null +++ b/silx/gui/dialog/SafeFileSystemModel.py @@ -0,0 +1,802 @@ +# 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. +# +# ###########################################################################*/ +""" +This module contains an :class:`SafeFileSystemModel`. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "22/11/2017" + +import sys +import os.path +import logging +import weakref +from silx.gui import qt +from silx.third_party import six +from .SafeFileIconProvider import SafeFileIconProvider + +_logger = logging.getLogger(__name__) + + +class _Item(object): + + def __init__(self, fileInfo): + self.__fileInfo = fileInfo + self.__parent = None + self.__children = None + self.__absolutePath = None + + def isDrive(self): + if sys.platform == "win32": + return self.parent().parent() is None + else: + return False + + def isRoot(self): + return self.parent() is None + + def isFile(self): + """ + Returns true if the path is a file. + + It avoid to access to the `Qt.QFileInfo` in case the file is a drive. + """ + if self.isDrive(): + return False + return self.__fileInfo.isFile() + + def isDir(self): + """ + Returns true if the path is a directory. + + The default `qt.QFileInfo.isDir` can freeze the file system with + network drives. This function avoid the freeze in case of browsing + the root. + """ + if self.isDrive(): + # A drive is a directory, we don't have to synchronize the + # drive to know that + return True + return self.__fileInfo.isDir() + + def absoluteFilePath(self): + """ + Returns an absolute path including the file name. + + This function uses in most cases the default + `qt.QFileInfo.absoluteFilePath`. But it is known to freeze the file + system with network drives. + + This function uses `qt.QFileInfo.filePath` in case of root drives, to + avoid this kind of issues. In case of drive, the result is the same, + while the file path is already absolute. + + :rtype: str + """ + if self.__absolutePath is None: + if self.isRoot(): + path = "" + elif self.isDrive(): + path = self.__fileInfo.filePath() + else: + path = os.path.join(self.parent().absoluteFilePath(), self.__fileInfo.fileName()) + if path == "": + return "/" + self.__absolutePath = path + return self.__absolutePath + + def child(self): + self.populate() + return self.__children + + def childAt(self, position): + self.populate() + return self.__children[position] + + def childCount(self): + self.populate() + return len(self.__children) + + def indexOf(self, item): + self.populate() + return self.__children.index(item) + + def parent(self): + parent = self.__parent + if parent is None: + return None + return parent() + + def filePath(self): + return self.__fileInfo.filePath() + + def fileName(self): + if self.isDrive(): + name = self.absoluteFilePath() + if name[-1] == "/": + name = name[:-1] + return name + return os.path.basename(self.absoluteFilePath()) + + def fileInfo(self): + """ + Returns the Qt file info. + + :rtype: Qt.QFileInfo + """ + return self.__fileInfo + + def _setParent(self, parent): + self.__parent = weakref.ref(parent) + + def findChildrenByPath(self, path): + if path == "": + return self + path = path.replace("\\", "/") + if path[-1] == "/": + path = path[:-1] + names = path.split("/") + caseSensitive = qt.QFSFileEngine(path).caseSensitive() + count = len(names) + cursor = self + for name in names: + for item in cursor.child(): + if caseSensitive: + same = item.fileName() == name + else: + same = item.fileName().lower() == name.lower() + if same: + cursor = item + count -= 1 + break + else: + return None + if count == 0: + break + else: + return None + return cursor + + def populate(self): + if self.__children is not None: + return + self.__children = [] + if self.isRoot(): + items = qt.QDir.drives() + else: + directory = qt.QDir(self.absoluteFilePath()) + filters = qt.QDir.AllEntries | qt.QDir.Hidden | qt.QDir.System + items = directory.entryInfoList(filters) + for fileInfo in items: + i = _Item(fileInfo) + self.__children.append(i) + i._setParent(self) + + +class _RawFileSystemModel(qt.QAbstractItemModel): + """ + This class implement a file system model and try to avoid freeze. On Qt4, + :class:`qt.QFileSystemModel` is known to freeze the file system when + network drives are available. + + To avoid this behaviour, this class does not use + `qt.QFileInfo.absoluteFilePath` nor `qt.QFileInfo.canonicalPath` to reach + information on drives. + + This model do not take care of sorting and filtering. This features are + managed by another model, by composition. + + And because it is the end of life of Qt4, we do not implement asynchronous + loading of files as it is done by :class:`qt.QFileSystemModel`, nor some + useful features. + """ + + __directoryLoadedSync = qt.Signal(str) + """This signal is connected asynchronously to a slot. It allows to + emit directoryLoaded as an asynchronous signal.""" + + directoryLoaded = qt.Signal(str) + """This signal is emitted when the gatherer thread has finished to load the + path.""" + + rootPathChanged = qt.Signal(str) + """This signal is emitted whenever the root path has been changed to a + newPath.""" + + NAME_COLUMN = 0 + SIZE_COLUMN = 1 + TYPE_COLUMN = 2 + LAST_MODIFIED_COLUMN = 3 + + def __init__(self, parent=None): + qt.QAbstractItemModel.__init__(self, parent) + self.__computer = _Item(qt.QFileInfo()) + self.__header = "Name", "Size", "Type", "Last modification" + self.__currentPath = "" + self.__iconProvider = SafeFileIconProvider() + self.__directoryLoadedSync.connect(self.__emitDirectoryLoaded, qt.Qt.QueuedConnection) + + def headerData(self, section, orientation, role=qt.Qt.DisplayRole): + if orientation == qt.Qt.Horizontal: + if role == qt.Qt.DisplayRole: + return self.__header[section] + if role == qt.Qt.TextAlignmentRole: + return qt.Qt.AlignRight if section == 1 else qt.Qt.AlignLeft + return None + + def flags(self, index): + if not index.isValid(): + return 0 + return qt.Qt.ItemIsEnabled | qt.Qt.ItemIsSelectable + + def columnCount(self, parent=qt.QModelIndex()): + return len(self.__header) + + def rowCount(self, parent=qt.QModelIndex()): + item = self.__getItem(parent) + return item.childCount() + + def data(self, index, role=qt.Qt.DisplayRole): + if not index.isValid(): + return None + + column = index.column() + if role in [qt.Qt.DisplayRole, qt.Qt.EditRole]: + if column == self.NAME_COLUMN: + return self.__displayName(index) + elif column == self.SIZE_COLUMN: + return self.size(index) + elif column == self.TYPE_COLUMN: + return self.type(index) + elif column == self.LAST_MODIFIED_COLUMN: + return self.lastModified(index) + else: + _logger.warning("data: invalid display value column %d", index.column()) + elif role == qt.QFileSystemModel.FilePathRole: + return self.filePath(index) + elif role == qt.QFileSystemModel.FileNameRole: + return self.fileName(index) + elif role == qt.Qt.DecorationRole: + if column == self.NAME_COLUMN: + icon = self.fileIcon(index) + if icon is None or icon.isNull(): + if self.isDir(index): + self.__iconProvider.icon(qt.QFileIconProvider.Folder) + else: + self.__iconProvider.icon(qt.QFileIconProvider.File) + return icon + elif role == qt.Qt.TextAlignmentRole: + if column == self.SIZE_COLUMN: + return qt.Qt.AlignRight + elif role == qt.QFileSystemModel.FilePermissions: + return self.permissions(index) + + return None + + def index(self, *args, **kwargs): + path_api = False + path_api |= len(args) >= 1 and isinstance(args[0], six.string_types) + path_api |= "path" in kwargs + + if path_api: + return self.__indexFromPath(*args, **kwargs) + else: + return self.__index(*args, **kwargs) + + def __index(self, row, column, parent=qt.QModelIndex()): + if parent.isValid() and parent.column() != 0: + return None + + parentItem = self.__getItem(parent) + item = parentItem.childAt(row) + return self.createIndex(row, column, item) + + def __indexFromPath(self, path, column=0): + """ + Uses the index(str) C++ API + + :rtype: qt.QModelIndex + """ + if path == "": + return qt.QModelIndex() + + item = self.__computer.findChildrenByPath(path) + if item is None: + return qt.QModelIndex() + + return self.createIndex(item.parent().indexOf(item), column, item) + + def parent(self, index): + if not index.isValid(): + return qt.QModelIndex() + + item = self.__getItem(index) + if index is None: + return qt.QModelIndex() + + parent = item.parent() + if parent is None or parent is self.__computer: + return qt.QModelIndex() + + return self.createIndex(parent.parent().indexOf(parent), 0, parent) + + def __emitDirectoryLoaded(self, path): + self.directoryLoaded.emit(path) + + def __emitRootPathChanged(self, path): + self.rootPathChanged.emit(path) + + def __getItem(self, index): + if not index.isValid(): + return self.__computer + item = index.internalPointer() + return item + + def fileIcon(self, index): + item = self.__getItem(index) + if self.__iconProvider is not None: + fileInfo = item.fileInfo() + result = self.__iconProvider.icon(fileInfo) + else: + style = qt.QApplication.instance().style() + if item.isRoot(): + result = style.standardIcon(qt.QStyle.SP_ComputerIcon) + elif item.isDrive(): + result = style.standardIcon(qt.QStyle.SP_DriveHDIcon) + elif item.isDir(): + result = style.standardIcon(qt.QStyle.SP_DirIcon) + else: + result = style.standardIcon(qt.QStyle.SP_FileIcon) + return result + + def _item(self, index): + item = self.__getItem(index) + return item + + def fileInfo(self, index): + item = self.__getItem(index) + result = item.fileInfo() + return result + + def __fileIcon(self, index): + item = self.__getItem(index) + result = item.fileName() + return result + + def __displayName(self, index): + item = self.__getItem(index) + result = item.fileName() + return result + + def fileName(self, index): + item = self.__getItem(index) + result = item.fileName() + return result + + def filePath(self, index): + item = self.__getItem(index) + result = item.fileInfo().filePath() + return result + + def isDir(self, index): + item = self.__getItem(index) + result = item.isDir() + return result + + def lastModified(self, index): + item = self.__getItem(index) + result = item.fileInfo().lastModified() + return result + + def permissions(self, index): + item = self.__getItem(index) + result = item.fileInfo().permissions() + return result + + def size(self, index): + item = self.__getItem(index) + result = item.fileInfo().size() + return result + + def type(self, index): + item = self.__getItem(index) + if self.__iconProvider is not None: + fileInfo = item.fileInfo() + result = self.__iconProvider.type(fileInfo) + else: + if item.isRoot(): + result = "Computer" + elif item.isDrive(): + result = "Drive" + elif item.isDir(): + result = "Directory" + else: + fileInfo = item.fileInfo() + result = fileInfo.suffix() + return result + + # File manipulation + + # bool remove(const QModelIndex & index) const + # bool rmdir(const QModelIndex & index) const + # QModelIndex mkdir(const QModelIndex & parent, const QString & name) + + # Configuration + + def rootDirectory(self): + return qt.QDir(self.rootPath()) + + def rootPath(self): + return self.__currentPath + + def setRootPath(self, path): + if self.__currentPath == path: + return + self.__currentPath = path + item = self.__computer.findChildrenByPath(path) + self.__emitRootPathChanged(path) + if item is None or item.parent() is None: + return qt.QModelIndex() + index = self.createIndex(item.parent().indexOf(item), 0, item) + self.__directoryLoadedSync.emit(path) + return index + + def iconProvider(self): + # FIXME: invalidate the model + return self.__iconProvider + + def setIconProvider(self, provider): + # FIXME: invalidate the model + self.__iconProvider = provider + + # bool resolveSymlinks() const + # void setResolveSymlinks(bool enable) + + def setNameFilterDisables(self, enable): + return None + + def nameFilterDisables(self): + return None + + def myComputer(self, role=qt.Qt.DisplayRole): + return None + + def setNameFilters(self, filters): + return + + def nameFilters(self): + return None + + def filter(self): + return self.__filters + + def setFilter(self, filters): + return + + def setReadOnly(self, enable): + assert(enable is True) + + def isReadOnly(self): + return False + + +class SafeFileSystemModel(qt.QSortFilterProxyModel): + """ + This class implement a file system model and try to avoid freeze. On Qt4, + :class:`qt.QFileSystemModel` is known to freeze the file system when + network drives are available. + + To avoid this behaviour, this class does not use + `qt.QFileInfo.absoluteFilePath` nor `qt.QFileInfo.canonicalPath` to reach + information on drives. + + And because it is the end of life of Qt4, we do not implement asynchronous + loading of files as it is done by :class:`qt.QFileSystemModel`, nor some + useful features. + """ + + def __init__(self, parent=None): + qt.QSortFilterProxyModel.__init__(self, parent=parent) + self.__nameFilterDisables = sys.platform == "darwin" + self.__nameFilters = [] + self.__filters = qt.QDir.AllEntries | qt.QDir.NoDotAndDotDot | qt.QDir.AllDirs + sourceModel = _RawFileSystemModel(self) + self.setSourceModel(sourceModel) + + @property + def directoryLoaded(self): + return self.sourceModel().directoryLoaded + + @property + def rootPathChanged(self): + return self.sourceModel().rootPathChanged + + def index(self, *args, **kwargs): + path_api = False + path_api |= len(args) >= 1 and isinstance(args[0], six.string_types) + path_api |= "path" in kwargs + + if path_api: + return self.__indexFromPath(*args, **kwargs) + else: + return self.__index(*args, **kwargs) + + def __index(self, row, column, parent=qt.QModelIndex()): + return qt.QSortFilterProxyModel.index(self, row, column, parent) + + def __indexFromPath(self, path, column=0): + """ + Uses the index(str) C++ API + + :rtype: qt.QModelIndex + """ + if path == "": + return qt.QModelIndex() + + index = self.sourceModel().index(path, column) + index = self.mapFromSource(index) + return index + + def lessThan(self, leftSourceIndex, rightSourceIndex): + sourceModel = self.sourceModel() + sortColumn = self.sortColumn() + if sortColumn == _RawFileSystemModel.NAME_COLUMN: + leftItem = sourceModel._item(leftSourceIndex) + rightItem = sourceModel._item(rightSourceIndex) + if sys.platform != "darwin": + # Sort directories before files + leftIsDir = leftItem.isDir() + rightIsDir = rightItem.isDir() + if leftIsDir ^ rightIsDir: + return leftIsDir + return leftItem.fileName().lower() < rightItem.fileName().lower() + elif sortColumn == _RawFileSystemModel.SIZE_COLUMN: + left = sourceModel.fileInfo(leftSourceIndex) + right = sourceModel.fileInfo(rightSourceIndex) + return left.size() < right.size() + elif sortColumn == _RawFileSystemModel.TYPE_COLUMN: + left = sourceModel.type(leftSourceIndex) + right = sourceModel.type(rightSourceIndex) + return left < right + elif sortColumn == _RawFileSystemModel.LAST_MODIFIED_COLUMN: + left = sourceModel.fileInfo(leftSourceIndex) + right = sourceModel.fileInfo(rightSourceIndex) + return left.lastModified() < right.lastModified() + else: + _logger.warning("Unsupported sorted column %d", sortColumn) + + return False + + def __filtersAccepted(self, item, filters): + """ + Check individual flag filters. + """ + if not (filters & (qt.QDir.Dirs | qt.QDir.AllDirs)): + # Hide dirs + if item.isDir(): + return False + if not (filters & qt.QDir.Files): + # Hide files + if item.isFile(): + return False + if not (filters & qt.QDir.Drives): + # Hide drives + if item.isDrive(): + return False + + fileInfo = item.fileInfo() + if fileInfo is None: + return False + + filterPermissions = (filters & qt.QDir.PermissionMask) != 0 + if filterPermissions and (filters & (qt.QDir.Dirs | qt.QDir.Files)): + if (filters & qt.QDir.Readable): + # Hide unreadable + if not fileInfo.isReadable(): + return False + if (filters & qt.QDir.Writable): + # Hide unwritable + if not fileInfo.isWritable(): + return False + if (filters & qt.QDir.Executable): + # Hide unexecutable + if not fileInfo.isExecutable(): + return False + + if (filters & qt.QDir.NoSymLinks): + # Hide sym links + if fileInfo.isSymLink(): + return False + + if not (filters & qt.QDir.System): + # Hide system + if not item.isDir() and not item.isFile(): + return False + + fileName = item.fileName() + isDot = fileName == "." + isDotDot = fileName == ".." + + if not (filters & qt.QDir.Hidden): + # Hide hidden + if not (isDot or isDotDot) and fileInfo.isHidden(): + return False + + if filters & (qt.QDir.NoDot | qt.QDir.NoDotDot | qt.QDir.NoDotAndDotDot): + # Hide parent/self references + if filters & qt.QDir.NoDot: + if isDot: + return False + if filters & qt.QDir.NoDotDot: + if isDotDot: + return False + if filters & qt.QDir.NoDotAndDotDot: + if isDot or isDotDot: + return False + + return True + + def filterAcceptsRow(self, sourceRow, sourceParent): + if not sourceParent.isValid(): + return True + + sourceModel = self.sourceModel() + index = sourceModel.index(sourceRow, 0, sourceParent) + if not index.isValid(): + return True + item = sourceModel._item(index) + + filters = self.__filters + + if item.isDrive(): + # Let say a user always have access to a drive + # It avoid to access to fileInfo then avoid to freeze the file + # system + return True + + if not self.__filtersAccepted(item, filters): + return False + + if self.__nameFilterDisables: + return True + + if item.isDir() and (filters & qt.QDir.AllDirs): + # dont apply the filters to directory names + return True + + return self.__nameFiltersAccepted(item) + + def __nameFiltersAccepted(self, item): + if len(self.__nameFilters) == 0: + return True + + fileName = item.fileName() + for reg in self.__nameFilters: + if reg.exactMatch(fileName): + return True + return False + + def setNameFilterDisables(self, enable): + self.__nameFilterDisables = enable + self.invalidate() + + def nameFilterDisables(self): + return self.__nameFilterDisables + + def myComputer(self, role=qt.Qt.DisplayRole): + return self.sourceModel().myComputer(role) + + def setNameFilters(self, filters): + self.__nameFilters = [] + isCaseSensitive = self.__filters & qt.QDir.CaseSensitive + caseSensitive = qt.Qt.CaseSensitive if isCaseSensitive else qt.Qt.CaseInsensitive + for f in filters: + reg = qt.QRegExp(f, caseSensitive, qt.QRegExp.Wildcard) + self.__nameFilters.append(reg) + self.invalidate() + + def nameFilters(self): + return [f.pattern() for f in self.__nameFilters] + + def filter(self): + return self.__filters + + def setFilter(self, filters): + self.__filters = filters + # In case of change of case sensitivity + self.setNameFilters(self.nameFilters()) + self.invalidate() + + def setReadOnly(self, enable): + assert(enable is True) + + def isReadOnly(self): + return False + + def rootPath(self): + return self.sourceModel().rootPath() + + def setRootPath(self, path): + index = self.sourceModel().setRootPath(path) + index = self.mapFromSource(index) + return index + + def flags(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + filters = sourceModel.flags(index) + + if self.__nameFilterDisables: + item = sourceModel._item(index) + if not self.__nameFiltersAccepted(item): + filters &= ~qt.Qt.ItemIsEnabled + + return filters + + def fileIcon(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.fileIcon(index) + + def fileInfo(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.fileInfo(index) + + def fileName(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.fileName(index) + + def filePath(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.filePath(index) + + def isDir(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.isDir(index) + + def lastModified(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.lastModified(index) + + def permissions(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.permissions(index) + + def size(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.size(index) + + def type(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.type(index) diff --git a/silx/gui/dialog/__init__.py b/silx/gui/dialog/__init__.py new file mode 100644 index 0000000..77c5949 --- /dev/null +++ b/silx/gui/dialog/__init__.py @@ -0,0 +1,29 @@ +# 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. +# +# ###########################################################################*/ +"""Qt dialogs""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "11/10/2017" diff --git a/silx/gui/dialog/setup.py b/silx/gui/dialog/setup.py new file mode 100644 index 0000000..48ab8d8 --- /dev/null +++ b/silx/gui/dialog/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__ = "23/10/2017" + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('dialog', parent_package, top_path) + config.add_subpackage('test') + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + setup(configuration=configuration) diff --git a/silx/gui/dialog/test/__init__.py b/silx/gui/dialog/test/__init__.py new file mode 100644 index 0000000..eee8aea --- /dev/null +++ b/silx/gui/dialog/test/__init__.py @@ -0,0 +1,47 @@ +# 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. +# +# ###########################################################################*/ +"""Tests for Qt dialogs""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/02/2018" + + +import logging +import os +import sys +import unittest + + +_logger = logging.getLogger(__name__) + + +def suite(): + test_suite = unittest.TestSuite() + from . import test_imagefiledialog + from . import test_datafiledialog + test_suite.addTest(test_imagefiledialog.suite()) + test_suite.addTest(test_datafiledialog.suite()) + return test_suite diff --git a/silx/gui/dialog/test/test_datafiledialog.py b/silx/gui/dialog/test/test_datafiledialog.py new file mode 100644 index 0000000..bdda810 --- /dev/null +++ b/silx/gui/dialog/test/test_datafiledialog.py @@ -0,0 +1,981 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Test for silx.gui.hdf5 module""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "14/02/2018" + + +import unittest +import tempfile +import numpy +import shutil +import os +import io +import weakref + +try: + import fabio +except ImportError: + fabio = None +try: + import h5py +except ImportError: + h5py = None + +import silx.io.url +from silx.gui import qt +from silx.gui.test import utils +from ..DataFileDialog import DataFileDialog +from silx.gui.hdf5 import Hdf5TreeModel + +_tmpDirectory = None + + +def setUpModule(): + global _tmpDirectory + _tmpDirectory = tempfile.mkdtemp(prefix=__name__) + + data = numpy.arange(100 * 100) + data.shape = 100, 100 + + if fabio is not None: + filename = _tmpDirectory + "/singleimage.edf" + image = fabio.edfimage.EdfImage(data=data) + image.write(filename) + + if h5py is not None: + filename = _tmpDirectory + "/data.h5" + f = h5py.File(filename, "w") + f["scalar"] = 10 + f["image"] = data + f["cube"] = [data, data + 1, data + 2] + f["complex_image"] = data * 1j + f["group/image"] = data + f["nxdata/foo"] = 10 + f["nxdata"].attrs["NX_class"] = u"NXdata" + f.close() + + filename = _tmpDirectory + "/badformat.h5" + with io.open(filename, "wb") as f: + f.write(b"{\nHello Nurse!") + + +def tearDownModule(): + global _tmpDirectory + shutil.rmtree(_tmpDirectory) + _tmpDirectory = None + + +class _UtilsMixin(object): + + def createDialog(self): + self._deleteDialog() + self._dialog = self._createDialog() + return self._dialog + + def _createDialog(self): + return DataFileDialog() + + def _deleteDialog(self): + if not hasattr(self, "_dialog"): + return + if self._dialog is not None: + ref = weakref.ref(self._dialog) + self._dialog = None + self.qWaitForDestroy(ref) + + def qWaitForPendingActions(self, dialog): + for _ in range(20): + if not dialog.hasPendingEvents(): + return + self.qWait(10) + raise RuntimeError("Still have pending actions") + + def assertSamePath(self, path1, path2): + path1_ = os.path.normcase(path1) + path2_ = os.path.normcase(path2) + if path1_ != path2_: + # Use the unittest API to log and display error + self.assertEquals(path1, path2) + + def assertNotSamePath(self, path1, path2): + path1_ = os.path.normcase(path1) + path2_ = os.path.normcase(path2) + if path1_ == path2_: + # Use the unittest API to log and display error + self.assertNotEquals(path1, path2) + + +class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): + + def tearDown(self): + self._deleteDialog() + utils.TestCaseQt.tearDown(self) + + def testDisplayAndKeyEscape(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + + self.keyClick(dialog, qt.Qt.Key_Escape) + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Rejected) + + def testDisplayAndClickCancel(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + + button = utils.findChildren(dialog, qt.QPushButton, name="cancel")[0] + self.mouseClick(button, qt.Qt.LeftButton) + self.assertFalse(dialog.isVisible()) + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Rejected) + + def testDisplayAndClickLockedOpen(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.mouseClick(button, qt.Qt.LeftButton) + # open button locked, dialog is not closed + self.assertTrue(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Rejected) + + def testSelectRoot_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertTrue(button.isEnabled()) + self.mouseClick(button, qt.Qt.LeftButton) + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertTrue(url.data_path() is not None) + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Accepted) + + def testSelectGroup_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/group"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertTrue(button.isEnabled()) + self.mouseClick(button, qt.Qt.LeftButton) + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertEqual(url.data_path(), "/group") + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Accepted) + + def testSelectDataset_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/scalar"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertTrue(button.isEnabled()) + self.mouseClick(button, qt.Qt.LeftButton) + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertEqual(url.data_path(), "/scalar") + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Accepted) + + def testClickOnBackToParentTool(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0] + toParentButton = utils.getQToolButtonFromAction(action) + filename = _tmpDirectory + "/data.h5" + + # init state + path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path() + dialog.selectUrl(path) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path() + self.assertSamePath(url.text(), path) + # test + self.mouseClick(toParentButton, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + self.assertSamePath(url.text(), path) + + self.mouseClick(toParentButton, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), _tmpDirectory) + + self.mouseClick(toParentButton, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), os.path.dirname(_tmpDirectory)) + + def testClickOnBackToRootTool(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = utils.findChildren(dialog, qt.QAction, name="toRootFileAction")[0] + button = utils.getQToolButtonFromAction(action) + filename = _tmpDirectory + "/data.h5" + + # init state + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path() + dialog.selectUrl(path) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), path) + self.assertTrue(button.isEnabled()) + # test + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + self.assertSamePath(url.text(), path) + # self.assertFalse(button.isEnabled()) + + def testClickOnBackToDirectoryTool(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = utils.findChildren(dialog, qt.QAction, name="toDirectoryAction")[0] + button = utils.getQToolButtonFromAction(action) + filename = _tmpDirectory + "/data.h5" + + # init state + path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path() + dialog.selectUrl(path) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path() + self.assertSamePath(url.text(), path) + self.assertTrue(button.isEnabled()) + # test + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), _tmpDirectory) + self.assertFalse(button.isEnabled()) + + # FIXME: There is an unreleased qt.QWidget without nameObject + # No idea where it come from. + self.allowedLeakingWidgets = 1 + + def testClickOnHistoryTools(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + forwardAction = utils.findChildren(dialog, qt.QAction, name="forwardAction")[0] + backwardAction = utils.findChildren(dialog, qt.QAction, name="backwardAction")[0] + filename = _tmpDirectory + "/data.h5" + + dialog.setDirectory(_tmpDirectory) + self.qWaitForPendingActions(dialog) + # No way to use QTest.mouseDClick with QListView, QListWidget + # Then we feed the history using selectPath + dialog.selectUrl(filename) + self.qWaitForPendingActions(dialog) + path2 = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + dialog.selectUrl(path2) + self.qWaitForPendingActions(dialog) + path3 = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group").path() + dialog.selectUrl(path3) + self.qWaitForPendingActions(dialog) + self.assertFalse(forwardAction.isEnabled()) + self.assertTrue(backwardAction.isEnabled()) + + button = utils.getQToolButtonFromAction(backwardAction) + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertTrue(forwardAction.isEnabled()) + self.assertTrue(backwardAction.isEnabled()) + self.assertSamePath(url.text(), path2) + + button = utils.getQToolButtonFromAction(forwardAction) + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertFalse(forwardAction.isEnabled()) + self.assertTrue(backwardAction.isEnabled()) + self.assertSamePath(url.text(), path3) + + def testSelectImageFromEdf(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/singleimage.edf" + url = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/scan_0/instrument/detector_0/data") + dialog.selectUrl(url.path()) + self.assertTrue(dialog._selectedData().shape, (100, 100)) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), url.path()) + + def testSelectImage(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/data.h5" + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/image").path() + dialog.selectUrl(path) + # test + self.assertTrue(dialog._selectedData().shape, (100, 100)) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectScalar(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/data.h5" + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/scalar").path() + dialog.selectUrl(path) + # test + self.assertEqual(dialog._selectedData()[()], 10) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectGroup(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/data.h5" + uri = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group") + dialog.selectUrl(uri.path()) + self.qWaitForPendingActions(dialog) + # test + self.assertTrue(silx.io.is_group(dialog._selectedData())) + self.assertSamePath(dialog.selectedFile(), filename) + uri = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertSamePath(uri.data_path(), "/group") + + def testSelectRoot(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/data.h5" + uri = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/") + dialog.selectUrl(uri.path()) + self.qWaitForPendingActions(dialog) + # test + self.assertTrue(silx.io.is_file(dialog._selectedData())) + self.assertSamePath(dialog.selectedFile(), filename) + uri = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertSamePath(uri.data_path(), "/") + + def testSelectH5_Activate(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + filename = _tmpDirectory + "/data.h5" + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + index = browser.rootIndex().model().index(filename) + # click + browser.selectIndex(index) + # double click + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + # test + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectBadFileFormat_Activate(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + filename = _tmpDirectory + "/badformat.h5" + index = browser.rootIndex().model().index(filename) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + # test + self.assertTrue(dialog.selectedUrl(), filename) + + def _countSelectableItems(self, model, rootIndex): + selectable = 0 + for i in range(model.rowCount(rootIndex)): + index = model.index(i, 0, rootIndex) + flags = model.flags(index) + isEnabled = (int(flags) & qt.Qt.ItemIsEnabled) != 0 + if isEnabled: + selectable += 1 + return selectable + + def testFilterExtensions(self): + if h5py is None: + self.skipTest("h5py is missing") + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 3) + + +class TestDataFileDialog_FilterDataset(utils.TestCaseQt, _UtilsMixin): + + def tearDown(self): + self._deleteDialog() + utils.TestCaseQt.tearDown(self) + + def _createDialog(self): + dialog = DataFileDialog() + dialog.setFilterMode(DataFileDialog.FilterMode.ExistingDataset) + return dialog + + def testSelectGroup_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/group"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertFalse(button.isEnabled()) + + def testSelectDataset_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/scalar"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertTrue(button.isEnabled()) + self.mouseClick(button, qt.Qt.LeftButton) + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertEqual(url.data_path(), "/scalar") + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Accepted) + + data = dialog.selectedData() + self.assertEqual(data, 10) + + +class TestDataFileDialog_FilterGroup(utils.TestCaseQt, _UtilsMixin): + + def tearDown(self): + self._deleteDialog() + utils.TestCaseQt.tearDown(self) + + def _createDialog(self): + dialog = DataFileDialog() + dialog.setFilterMode(DataFileDialog.FilterMode.ExistingGroup) + return dialog + + def testSelectGroup_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/group"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertTrue(button.isEnabled()) + self.mouseClick(button, qt.Qt.LeftButton) + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertEqual(url.data_path(), "/group") + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Accepted) + + self.assertRaises(Exception, dialog.selectedData) + + def testSelectDataset_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/scalar"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertFalse(button.isEnabled()) + + +class TestDataFileDialog_FilterNXdata(utils.TestCaseQt, _UtilsMixin): + + def tearDown(self): + self._deleteDialog() + utils.TestCaseQt.tearDown(self) + + def _createDialog(self): + def customFilter(obj): + if "NX_class" in obj.attrs: + return obj.attrs["NX_class"] == u"NXdata" + return False + + dialog = DataFileDialog() + dialog.setFilterMode(DataFileDialog.FilterMode.ExistingGroup) + dialog.setFilterCallback(customFilter) + return dialog + + def testSelectGroupRefused_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/group"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertFalse(button.isEnabled()) + + self.assertRaises(Exception, dialog.selectedData) + + def testSelectNXdataAccepted_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/nxdata"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertTrue(button.isEnabled()) + self.mouseClick(button, qt.Qt.LeftButton) + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertEqual(url.data_path(), "/nxdata") + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Accepted) + + +class TestDataFileDialogApi(utils.TestCaseQt, _UtilsMixin): + + def tearDown(self): + self._deleteDialog() + utils.TestCaseQt.tearDown(self) + + def _createDialog(self): + dialog = DataFileDialog() + return dialog + + def testSaveRestoreState(self): + dialog = self.createDialog() + dialog.setDirectory(_tmpDirectory) + self.qWaitForPendingActions(dialog) + state = dialog.saveState() + dialog = None + + dialog2 = self.createDialog() + result = dialog2.restoreState(state) + self.assertTrue(result) + dialog2 = None + + def printState(self): + """ + Print state of the ImageFileDialog. + + Can be used to add or regenerate `STATE_VERSION1_QT4` or + `STATE_VERSION1_QT5`. + + >>> ./run_tests.py -v silx.gui.dialog.test.test_datafiledialog.TestDataFileDialogApi.printState + """ + dialog = self.createDialog() + dialog.setDirectory("") + dialog.setHistory([]) + dialog.setSidebarUrls([]) + state = dialog.saveState() + string = "" + strings = [] + for i in range(state.size()): + d = state.data()[i] + if not isinstance(d, int): + d = ord(d) + if d > 0x20 and d < 0x7F: + string += chr(d) + else: + string += "\\x%02X" % d + if len(string) > 60: + strings.append(string) + string = "" + strings.append(string) + strings = ["b'%s'" % s for s in strings] + print() + print("\\\n".join(strings)) + + STATE_VERSION1_QT4 = b''\ + b'\x00\x00\x00Z\x00s\x00i\x00l\x00x\x00.\x00g\x00u\x00i\x00.\x00'\ + b'd\x00i\x00a\x00l\x00o\x00g\x00.\x00D\x00a\x00t\x00a\x00F\x00i'\ + b'\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00.\x00D\x00a\x00t\x00'\ + b'a\x00F\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00\x00\x00'\ + b'\x01\x00\x00\x00\x0C\x00\x00\x00\x00"\x00\x00\x00\xFF\x00\x00'\ + b'\x00\x00\x00\x00\x00\x03\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF'\ + b'\xFF\xFF\x01\x00\x00\x00\x06\x01\x00\x00\x00\x01\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00\x00\x00\x00'\ + b'}\x00\x00\x00\x0E\x00B\x00r\x00o\x00w\x00s\x00e\x00r\x00\x00\x00'\ + b'\x01\x00\x00\x00\x0C\x00\x00\x00\x00Z\x00\x00\x00\xFF\x00\x00'\ + b'\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\ + b'\x00\x01\x90\x00\x00\x00\x04\x01\x01\x00\x00\x00\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00d\xFF\xFF\xFF\xFF\x00\x00\x00\x81'\ + b'\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x01\x90\x00\x00\x00\x04'\ + b'\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00'\ + b'\x01\xFF\xFF\xFF\xFF' + """Serialized state on Qt4. Generated using :meth:`printState`""" + + STATE_VERSION1_QT5 = b''\ + b'\x00\x00\x00Z\x00s\x00i\x00l\x00x\x00.\x00g\x00u\x00i\x00.\x00'\ + b'd\x00i\x00a\x00l\x00o\x00g\x00.\x00D\x00a\x00t\x00a\x00F\x00i'\ + b'\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00.\x00D\x00a\x00t\x00'\ + b'a\x00F\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00\x00\x00'\ + b'\x01\x00\x00\x00\x0C\x00\x00\x00\x00#\x00\x00\x00\xFF\x00\x00'\ + b'\x00\x01\x00\x00\x00\x03\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF'\ + b'\xFF\xFF\x01\xFF\xFF\xFF\xFF\x01\x00\x00\x00\x01\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00\x00\x00'\ + b'\x00\xAA\x00\x00\x00\x0E\x00B\x00r\x00o\x00w\x00s\x00e\x00r\x00'\ + b'\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00\x87\x00\x00\x00\xFF'\ + b'\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00'\ + b'\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\ + b'\x00\x00\x00\x01\x90\x00\x00\x00\x04\x01\x01\x00\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00d\xFF\xFF\xFF\xFF\x00\x00'\ + b'\x00\x81\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00d\x00\x00'\ + b'\x00\x01\x00\x00\x00\x00\x00\x00\x00d\x00\x00\x00\x01\x00\x00'\ + b'\x00\x00\x00\x00\x00d\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00'\ + b'\x00d\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x03\xE8\x00\xFF'\ + b'\xFF\xFF\xFF\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01' + """Serialized state on Qt5. Generated using :meth:`printState`""" + + def testAvoidRestoreRegression_Version1(self): + version = qt.qVersion().split(".")[0] + if version == "4": + state = self.STATE_VERSION1_QT4 + elif version == "5": + state = self.STATE_VERSION1_QT5 + else: + self.skipTest("Resource not available") + + state = qt.QByteArray(state) + dialog = self.createDialog() + result = dialog.restoreState(state) + self.assertTrue(result) + + def testRestoreRobusness(self): + """What's happen if you try to open a config file with a different + binding.""" + state = qt.QByteArray(self.STATE_VERSION1_QT4) + dialog = self.createDialog() + dialog.restoreState(state) + state = qt.QByteArray(self.STATE_VERSION1_QT5) + dialog = None + dialog = self.createDialog() + dialog.restoreState(state) + + def testRestoreNonExistingDirectory(self): + directory = os.path.join(_tmpDirectory, "dir") + os.mkdir(directory) + dialog = self.createDialog() + dialog.setDirectory(directory) + self.qWaitForPendingActions(dialog) + state = dialog.saveState() + os.rmdir(directory) + dialog = None + + dialog2 = self.createDialog() + result = dialog2.restoreState(state) + self.assertTrue(result) + self.assertNotEquals(dialog2.directory(), directory) + + def testHistory(self): + dialog = self.createDialog() + history = dialog.history() + dialog.setHistory([]) + self.assertEqual(dialog.history(), []) + dialog.setHistory(history) + self.assertEqual(dialog.history(), history) + + def testSidebarUrls(self): + dialog = self.createDialog() + urls = dialog.sidebarUrls() + dialog.setSidebarUrls([]) + self.assertEqual(dialog.sidebarUrls(), []) + dialog.setSidebarUrls(urls) + self.assertEqual(dialog.sidebarUrls(), urls) + + def testDirectory(self): + dialog = self.createDialog() + self.qWaitForPendingActions(dialog) + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + self.assertSamePath(dialog.directory(), _tmpDirectory) + + def testBadFileFormat(self): + dialog = self.createDialog() + dialog.selectUrl(_tmpDirectory + "/badformat.h5") + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + def testBadPath(self): + dialog = self.createDialog() + dialog.selectUrl("#$%/#$%") + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + def testBadSubpath(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + self.qWaitForPendingActions(dialog) + + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + + filename = _tmpDirectory + "/data.h5" + url = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/foobar") + dialog.selectUrl(url.path()) + self.qWaitForPendingActions(dialog) + self.assertIsNotNone(dialog._selectedData()) + + # an existing node is browsed, but the wrong path is selected + index = browser.rootIndex() + obj = index.model().data(index, role=Hdf5TreeModel.H5PY_OBJECT_ROLE) + self.assertEqual(obj.name, "/group") + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertEqual(url.data_path(), "/group") + + def testUnsupportedSlicingPath(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + self.qWaitForPendingActions(dialog) + dialog.selectUrl(_tmpDirectory + "/data.h5?path=/cube&slice=0") + self.qWaitForPendingActions(dialog) + data = dialog._selectedData() + if data is None: + # Maybe nothing is selected + self.assertTrue(True) + else: + # Maybe the cube is selected but not sliced + self.assertEqual(len(data.shape), 3) + + +def suite(): + test_suite = unittest.TestSuite() + loadTests = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite.addTest(loadTests(TestDataFileDialogInteraction)) + test_suite.addTest(loadTests(TestDataFileDialogApi)) + test_suite.addTest(loadTests(TestDataFileDialog_FilterDataset)) + test_suite.addTest(loadTests(TestDataFileDialog_FilterGroup)) + test_suite.addTest(loadTests(TestDataFileDialog_FilterNXdata)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/dialog/test/test_imagefiledialog.py b/silx/gui/dialog/test/test_imagefiledialog.py new file mode 100644 index 0000000..7909f10 --- /dev/null +++ b/silx/gui/dialog/test/test_imagefiledialog.py @@ -0,0 +1,803 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Test for silx.gui.hdf5 module""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "12/02/2018" + + +import unittest +import tempfile +import numpy +import shutil +import os +import io +import weakref + +try: + import fabio +except ImportError: + fabio = None +try: + import h5py +except ImportError: + h5py = None + +import silx.io.url +from silx.gui import qt +from silx.gui.test import utils +from ..ImageFileDialog import ImageFileDialog +from silx.gui.plot.Colormap import Colormap +from silx.gui.hdf5 import Hdf5TreeModel + +_tmpDirectory = None + + +def setUpModule(): + global _tmpDirectory + _tmpDirectory = tempfile.mkdtemp(prefix=__name__) + + data = numpy.arange(100 * 100) + data.shape = 100, 100 + + if fabio is not None: + filename = _tmpDirectory + "/singleimage.edf" + image = fabio.edfimage.EdfImage(data=data) + image.write(filename) + + filename = _tmpDirectory + "/multiframe.edf" + image = fabio.edfimage.EdfImage(data=data) + image.appendFrame(data=data + 1) + image.appendFrame(data=data + 2) + image.write(filename) + + filename = _tmpDirectory + "/singleimage.msk" + image = fabio.fit2dmaskimage.Fit2dMaskImage(data=data % 2 == 1) + image.write(filename) + + if h5py is not None: + filename = _tmpDirectory + "/data.h5" + f = h5py.File(filename, "w") + f["scalar"] = 10 + f["image"] = data + f["cube"] = [data, data + 1, data + 2] + f["complex_image"] = data * 1j + f["group/image"] = data + f.close() + + filename = _tmpDirectory + "/badformat.edf" + with io.open(filename, "wb") as f: + f.write(b"{\nHello Nurse!") + + +def tearDownModule(): + global _tmpDirectory + shutil.rmtree(_tmpDirectory) + _tmpDirectory = None + + +class _UtilsMixin(object): + + def createDialog(self): + self._deleteDialog() + self._dialog = self._createDialog() + return self._dialog + + def _createDialog(self): + return ImageFileDialog() + + def _deleteDialog(self): + if not hasattr(self, "_dialog"): + return + if self._dialog is not None: + ref = weakref.ref(self._dialog) + self._dialog = None + self.qWaitForDestroy(ref) + + def qWaitForPendingActions(self, dialog): + for _ in range(20): + if not dialog.hasPendingEvents(): + return + self.qWait(10) + raise RuntimeError("Still have pending actions") + + def assertSamePath(self, path1, path2): + path1_ = os.path.normcase(path1) + path2_ = os.path.normcase(path2) + if path1_ != path2_: + # Use the unittest API to log and display error + self.assertEquals(path1, path2) + + def assertNotSamePath(self, path1, path2): + path1_ = os.path.normcase(path1) + path2_ = os.path.normcase(path2) + if path1_ == path2_: + # Use the unittest API to log and display error + self.assertNotEquals(path1, path2) + + +class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): + + def tearDown(self): + self._deleteDialog() + utils.TestCaseQt.tearDown(self) + + def testDisplayAndKeyEscape(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + + self.keyClick(dialog, qt.Qt.Key_Escape) + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Rejected) + + def testDisplayAndClickCancel(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + + button = utils.findChildren(dialog, qt.QPushButton, name="cancel")[0] + self.mouseClick(button, qt.Qt.LeftButton) + self.assertFalse(dialog.isVisible()) + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Rejected) + + def testDisplayAndClickLockedOpen(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.mouseClick(button, qt.Qt.LeftButton) + # open button locked, dialog is not closed + self.assertTrue(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Rejected) + + def testDisplayAndClickOpen(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/singleimage.edf" + dialog.selectFile(filename) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertTrue(button.isEnabled()) + self.mouseClick(button, qt.Qt.LeftButton) + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Accepted) + + def testClickOnShortcut(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + sidebar = utils.findChildren(dialog, qt.QListView, name="sidebar")[0] + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.setDirectory(_tmpDirectory) + self.qWaitForPendingActions(dialog) + + self.assertSamePath(url.text(), _tmpDirectory) + + urls = sidebar.urls() + if len(urls) == 0: + self.skipTest("No sidebar path") + path = urls[0].path() + if path != "" and not os.path.exists(path): + self.skipTest("Sidebar path do not exists") + + index = sidebar.model().index(0, 0) + # rect = sidebar.visualRect(index) + # self.mouseClick(sidebar, qt.Qt.LeftButton, pos=rect.center()) + # Using mouse click is not working, let's use the selection API + sidebar.selectionModel().select(index, qt.QItemSelectionModel.ClearAndSelect) + self.qWaitForPendingActions(dialog) + + index = browser.rootIndex() + if not index.isValid(): + path = "" + else: + path = index.model().filePath(index) + self.assertNotSamePath(_tmpDirectory, path) + self.assertNotSamePath(url.text(), _tmpDirectory) + + def testClickOnDetailView(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + action = utils.findChildren(dialog, qt.QAction, name="detailModeAction")[0] + detailModeButton = utils.getQToolButtonFromAction(action) + self.mouseClick(detailModeButton, qt.Qt.LeftButton) + self.assertEqual(dialog.viewMode(), qt.QFileDialog.Detail) + + action = utils.findChildren(dialog, qt.QAction, name="listModeAction")[0] + listModeButton = utils.getQToolButtonFromAction(action) + self.mouseClick(listModeButton, qt.Qt.LeftButton) + self.assertEqual(dialog.viewMode(), qt.QFileDialog.List) + + def testClickOnBackToParentTool(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0] + toParentButton = utils.getQToolButtonFromAction(action) + filename = _tmpDirectory + "/data.h5" + + # init state + path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path() + dialog.selectUrl(path) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path() + self.assertSamePath(url.text(), path) + # test + self.mouseClick(toParentButton, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + self.assertSamePath(url.text(), path) + + self.mouseClick(toParentButton, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), _tmpDirectory) + + self.mouseClick(toParentButton, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), os.path.dirname(_tmpDirectory)) + + def testClickOnBackToRootTool(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = utils.findChildren(dialog, qt.QAction, name="toRootFileAction")[0] + button = utils.getQToolButtonFromAction(action) + filename = _tmpDirectory + "/data.h5" + + # init state + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path() + dialog.selectUrl(path) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), path) + self.assertTrue(button.isEnabled()) + # test + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + self.assertSamePath(url.text(), path) + # self.assertFalse(button.isEnabled()) + + def testClickOnBackToDirectoryTool(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = utils.findChildren(dialog, qt.QAction, name="toDirectoryAction")[0] + button = utils.getQToolButtonFromAction(action) + filename = _tmpDirectory + "/data.h5" + + # init state + path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path() + dialog.selectUrl(path) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path() + self.assertSamePath(url.text(), path) + self.assertTrue(button.isEnabled()) + # test + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), _tmpDirectory) + self.assertFalse(button.isEnabled()) + + # FIXME: There is an unreleased qt.QWidget without nameObject + # No idea where it come from. + self.allowedLeakingWidgets = 1 + + def testClickOnHistoryTools(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + forwardAction = utils.findChildren(dialog, qt.QAction, name="forwardAction")[0] + backwardAction = utils.findChildren(dialog, qt.QAction, name="backwardAction")[0] + filename = _tmpDirectory + "/data.h5" + + dialog.setDirectory(_tmpDirectory) + self.qWaitForPendingActions(dialog) + # No way to use QTest.mouseDClick with QListView, QListWidget + # Then we feed the history using selectPath + dialog.selectUrl(filename) + self.qWaitForPendingActions(dialog) + path2 = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + dialog.selectUrl(path2) + self.qWaitForPendingActions(dialog) + path3 = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group").path() + dialog.selectUrl(path3) + self.qWaitForPendingActions(dialog) + self.assertFalse(forwardAction.isEnabled()) + self.assertTrue(backwardAction.isEnabled()) + + button = utils.getQToolButtonFromAction(backwardAction) + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertTrue(forwardAction.isEnabled()) + self.assertTrue(backwardAction.isEnabled()) + self.assertSamePath(url.text(), path2) + + button = utils.getQToolButtonFromAction(forwardAction) + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertFalse(forwardAction.isEnabled()) + self.assertTrue(backwardAction.isEnabled()) + self.assertSamePath(url.text(), path3) + + def testSelectImageFromEdf(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/singleimage.edf" + path = filename + dialog.selectUrl(path) + self.assertTrue(dialog.selectedImage().shape, (100, 100)) + self.assertSamePath(dialog.selectedFile(), filename) + path = silx.io.url.DataUrl(scheme="fabio", file_path=filename).path() + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectImageFromEdf_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + filename = _tmpDirectory + "/singleimage.edf" + path = silx.io.url.DataUrl(scheme="fabio", file_path=filename).path() + index = browser.rootIndex().model().index(filename) + # click + browser.selectIndex(index) + # double click + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + # test + self.assertTrue(dialog.selectedImage().shape, (100, 100)) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectFrameFromEdf(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/multiframe.edf" + path = silx.io.url.DataUrl(scheme="fabio", file_path=filename, data_slice=(1,)).path() + dialog.selectUrl(path) + # test + image = dialog.selectedImage() + self.assertTrue(image.shape, (100, 100)) + self.assertTrue(image[0, 0], 1) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectImageFromMsk(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/singleimage.msk" + path = silx.io.url.DataUrl(scheme="fabio", file_path=filename).path() + dialog.selectUrl(path) + # test + self.assertTrue(dialog.selectedImage().shape, (100, 100)) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectImageFromH5(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/data.h5" + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/image").path() + dialog.selectUrl(path) + # test + self.assertTrue(dialog.selectedImage().shape, (100, 100)) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectH5_Activate(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + filename = _tmpDirectory + "/data.h5" + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + index = browser.rootIndex().model().index(filename) + # click + browser.selectIndex(index) + # double click + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + # test + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectFrameFromH5(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/data.h5" + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/cube", data_slice=(1, )).path() + dialog.selectUrl(path) + # test + self.assertTrue(dialog.selectedImage().shape, (100, 100)) + self.assertTrue(dialog.selectedImage()[0, 0], 1) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectBadFileFormat_Activate(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + filename = _tmpDirectory + "/badformat.edf" + index = browser.rootIndex().model().index(filename) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + # test + self.assertTrue(dialog.selectedUrl(), filename) + + def _countSelectableItems(self, model, rootIndex): + selectable = 0 + for i in range(model.rowCount(rootIndex)): + index = model.index(i, 0, rootIndex) + flags = model.flags(index) + isEnabled = (int(flags) & qt.Qt.ItemIsEnabled) != 0 + if isEnabled: + selectable += 1 + return selectable + + def testFilterExtensions(self): + if h5py is None: + self.skipTest("h5py is missing") + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + filters = utils.findChildren(dialog, qt.QWidget, name="fileTypeCombo")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 5) + + codecName = fabio.edfimage.EdfImage.codec_name() + index = filters.indexFromCodec(codecName) + filters.setCurrentIndex(index) + filters.activated[int].emit(index) + self.qWait(50) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 3) + + codecName = fabio.fit2dmaskimage.Fit2dMaskImage.codec_name() + index = filters.indexFromCodec(codecName) + filters.setCurrentIndex(index) + filters.activated[int].emit(index) + self.qWait(50) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 1) + + +class TestImageFileDialogApi(utils.TestCaseQt, _UtilsMixin): + + def tearDown(self): + self._deleteDialog() + utils.TestCaseQt.tearDown(self) + + def testSaveRestoreState(self): + dialog = self.createDialog() + dialog.setDirectory(_tmpDirectory) + colormap = Colormap(normalization=Colormap.LOGARITHM) + dialog.setColormap(colormap) + self.qWaitForPendingActions(dialog) + state = dialog.saveState() + dialog = None + + dialog2 = self.createDialog() + result = dialog2.restoreState(state) + self.qWaitForPendingActions(dialog2) + self.assertTrue(result) + self.assertTrue(dialog2.colormap().getNormalization(), "log") + + def printState(self): + """ + Print state of the ImageFileDialog. + + Can be used to add or regenerate `STATE_VERSION1_QT4` or + `STATE_VERSION1_QT5`. + + >>> ./run_tests.py -v silx.gui.dialog.test.test_imagefiledialog.TestImageFileDialogApi.printState + """ + dialog = self.createDialog() + colormap = Colormap(normalization=Colormap.LOGARITHM) + dialog.setDirectory("") + dialog.setHistory([]) + dialog.setColormap(colormap) + dialog.setSidebarUrls([]) + state = dialog.saveState() + string = "" + strings = [] + for i in range(state.size()): + d = state.data()[i] + if not isinstance(d, int): + d = ord(d) + if d > 0x20 and d < 0x7F: + string += chr(d) + else: + string += "\\x%02X" % d + if len(string) > 60: + strings.append(string) + string = "" + strings.append(string) + strings = ["b'%s'" % s for s in strings] + print() + print("\\\n".join(strings)) + + STATE_VERSION1_QT4 = b''\ + b'\x00\x00\x00^\x00s\x00i\x00l\x00x\x00.\x00g\x00u\x00i\x00.\x00'\ + b'd\x00i\x00a\x00l\x00o\x00g\x00.\x00I\x00m\x00a\x00g\x00e\x00F'\ + b'\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00.\x00I\x00m\x00'\ + b'a\x00g\x00e\x00F\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g'\ + b'\x00\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00"\x00\x00\x00'\ + b'\xFF\x00\x00\x00\x00\x00\x00\x00\x03\xFF\xFF\xFF\xFF\xFF\xFF\xFF'\ + b'\xFF\xFF\xFF\xFF\xFF\x01\x00\x00\x00\x06\x01\x00\x00\x00\x01\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00'\ + b'\x00\x00\x00}\x00\x00\x00\x0E\x00B\x00r\x00o\x00w\x00s\x00e\x00'\ + b'r\x00\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00Z\x00\x00\x00'\ + b'\xFF\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00'\ + b'\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x01\x90\x00\x00\x00\x04\x01\x01\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00d\xFF\xFF\xFF\xFF\x00'\ + b'\x00\x00\x81\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x01\x90\x00'\ + b'\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00'\ + b'\x00\x00\x0C\x00\x00\x00\x000\x00\x00\x00\x10\x00C\x00o\x00l\x00'\ + b'o\x00r\x00m\x00a\x00p\x00\x00\x00\x01\x00\x00\x00\x08\x00g\x00'\ + b'r\x00a\x00y\x01\x01\x00\x00\x00\x06\x00l\x00o\x00g' + """Serialized state on Qt4. Generated using :meth:`printState`""" + + STATE_VERSION1_QT5 = b''\ + b'\x00\x00\x00^\x00s\x00i\x00l\x00x\x00.\x00g\x00u\x00i\x00.\x00'\ + b'd\x00i\x00a\x00l\x00o\x00g\x00.\x00I\x00m\x00a\x00g\x00e\x00F'\ + b'\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00.\x00I\x00m\x00'\ + b'a\x00g\x00e\x00F\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g'\ + b'\x00\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00#\x00\x00\x00'\ + b'\xFF\x00\x00\x00\x01\x00\x00\x00\x03\xFF\xFF\xFF\xFF\xFF\xFF\xFF'\ + b'\xFF\xFF\xFF\xFF\xFF\x01\xFF\xFF\xFF\xFF\x01\x00\x00\x00\x01\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C'\ + b'\x00\x00\x00\x00\xAA\x00\x00\x00\x0E\x00B\x00r\x00o\x00w\x00s'\ + b'\x00e\x00r\x00\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00\x87'\ + b'\x00\x00\x00\xFF\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x01\x90\x00\x00\x00\x04\x01\x01\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00d\xFF\xFF'\ + b'\xFF\xFF\x00\x00\x00\x81\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00'\ + b'\x00d\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00d\x00\x00\x00'\ + b'\x01\x00\x00\x00\x00\x00\x00\x00d\x00\x00\x00\x01\x00\x00\x00'\ + b'\x00\x00\x00\x00d\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x03'\ + b'\xE8\x00\xFF\xFF\xFF\xFF\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00'\ + b'\x00\x0C\x00\x00\x00\x000\x00\x00\x00\x10\x00C\x00o\x00l\x00o'\ + b'\x00r\x00m\x00a\x00p\x00\x00\x00\x01\x00\x00\x00\x08\x00g\x00'\ + b'r\x00a\x00y\x01\x01\x00\x00\x00\x06\x00l\x00o\x00g' + """Serialized state on Qt5. Generated using :meth:`printState`""" + + def testAvoidRestoreRegression_Version1(self): + version = qt.qVersion().split(".")[0] + if version == "4": + state = self.STATE_VERSION1_QT4 + elif version == "5": + state = self.STATE_VERSION1_QT5 + else: + self.skipTest("Resource not available") + + state = qt.QByteArray(state) + dialog = self.createDialog() + result = dialog.restoreState(state) + self.assertTrue(result) + colormap = dialog.colormap() + self.assertTrue(colormap.getNormalization(), "log") + + def testRestoreRobusness(self): + """What's happen if you try to open a config file with a different + binding.""" + state = qt.QByteArray(self.STATE_VERSION1_QT4) + dialog = self.createDialog() + dialog.restoreState(state) + state = qt.QByteArray(self.STATE_VERSION1_QT5) + dialog = None + dialog = self.createDialog() + dialog.restoreState(state) + + def testRestoreNonExistingDirectory(self): + directory = os.path.join(_tmpDirectory, "dir") + os.mkdir(directory) + dialog = self.createDialog() + dialog.setDirectory(directory) + self.qWaitForPendingActions(dialog) + state = dialog.saveState() + os.rmdir(directory) + dialog = None + + dialog2 = self.createDialog() + result = dialog2.restoreState(state) + self.assertTrue(result) + self.assertNotEquals(dialog2.directory(), directory) + + def testHistory(self): + dialog = self.createDialog() + history = dialog.history() + dialog.setHistory([]) + self.assertEqual(dialog.history(), []) + dialog.setHistory(history) + self.assertEqual(dialog.history(), history) + + def testSidebarUrls(self): + dialog = self.createDialog() + urls = dialog.sidebarUrls() + dialog.setSidebarUrls([]) + self.assertEqual(dialog.sidebarUrls(), []) + dialog.setSidebarUrls(urls) + self.assertEqual(dialog.sidebarUrls(), urls) + + def testColomap(self): + dialog = self.createDialog() + colormap = dialog.colormap() + self.assertEqual(colormap.getNormalization(), "linear") + colormap = Colormap(normalization=Colormap.LOGARITHM) + dialog.setColormap(colormap) + self.assertEqual(colormap.getNormalization(), "log") + + def testDirectory(self): + dialog = self.createDialog() + self.qWaitForPendingActions(dialog) + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + self.assertSamePath(dialog.directory(), _tmpDirectory) + + def testBadDataType(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.selectUrl(_tmpDirectory + "/data.h5::/complex_image") + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + def testBadDataShape(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.selectUrl(_tmpDirectory + "/data.h5::/unknown") + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + def testBadDataFormat(self): + dialog = self.createDialog() + dialog.selectUrl(_tmpDirectory + "/badformat.edf") + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + def testBadPath(self): + dialog = self.createDialog() + dialog.selectUrl("#$%/#$%") + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + def testBadSubpath(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + self.qWaitForPendingActions(dialog) + + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + + filename = _tmpDirectory + "/data.h5" + url = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/foobar") + dialog.selectUrl(url.path()) + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + # an existing node is browsed, but the wrong path is selected + index = browser.rootIndex() + obj = index.model().data(index, role=Hdf5TreeModel.H5PY_OBJECT_ROLE) + self.assertEqual(obj.name, "/group") + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertEqual(url.data_path(), "/group") + + def testBadSlicingPath(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + self.qWaitForPendingActions(dialog) + dialog.selectUrl(_tmpDirectory + "/data.h5::/cube[a;45,-90]") + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + +def suite(): + test_suite = unittest.TestSuite() + loadTests = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite.addTest(loadTests(TestImageFileDialogInteraction)) + test_suite.addTest(loadTests(TestImageFileDialogApi)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/dialog/utils.py b/silx/gui/dialog/utils.py new file mode 100644 index 0000000..1c16b44 --- /dev/null +++ b/silx/gui/dialog/utils.py @@ -0,0 +1,104 @@ +# 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. +# +# ###########################################################################*/ +""" +This module contains utilitaries used by other dialog modules. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "25/10/2017" + +import os +import sys +import types +from silx.gui import qt +from silx.third_party import six + + +def samefile(path1, path2): + """Portable :func:`os.path.samepath` function. + + :param str path1: A path to a file + :param str path2: Another path to a file + :rtype: bool + """ + if six.PY2 and sys.platform == "win32": + path1 = os.path.normcase(path1) + path2 = os.path.normcase(path2) + return path1 == path2 + if path1 == path2: + return True + if path1 == "": + return False + if path2 == "": + return False + return os.path.samefile(path1, path2) + + +def findClosestSubPath(hdf5Object, path): + """Find the closest existing path from the hdf5Object using a subset of the + provided path. + + Returns None if no path found. It is possible if the path is a relative + path. + + :param h5py.Node hdf5Object: An HDF5 node + :param str path: A path + :rtype: str + """ + if path in ["", "/"]: + return "/" + names = path.split("/") + if path[0] == "/": + names.pop(0) + for i in range(len(names)): + n = len(names) - i + path2 = "/".join(names[0:n]) + if path2 == "": + return "" + if path2 in hdf5Object: + return path2 + + if path[0] == "/": + return "/" + return None + + +def patchToConsumeReturnKey(widget): + """ + Monkey-patch a widget to consume the return key instead of propagating it + to the dialog. + """ + assert(not hasattr(widget, "_oldKeyPressEvent")) + + def keyPressEvent(self, event): + k = event.key() + result = self._oldKeyPressEvent(event) + if k in [qt.Qt.Key_Return, qt.Qt.Key_Enter]: + event.accept() + return result + + widget._oldKeyPressEvent = widget.keyPressEvent + widget.keyPressEvent = types.MethodType(keyPressEvent, widget) |