summaryrefslogtreecommitdiff
path: root/src/silx/app/view/Viewer.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/silx/app/view/Viewer.py')
-rw-r--r--src/silx/app/view/Viewer.py1033
1 files changed, 1033 insertions, 0 deletions
diff --git a/src/silx/app/view/Viewer.py b/src/silx/app/view/Viewer.py
new file mode 100644
index 0000000..12426a1
--- /dev/null
+++ b/src/silx/app/view/Viewer.py
@@ -0,0 +1,1033 @@
+# /*##########################################################################
+# Copyright (C) 2016-2023 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+"""Browse a data file with a GUI"""
+
+from __future__ import annotations
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "15/01/2019"
+
+
+import os
+import logging
+import functools
+import traceback
+from types import TracebackType
+from typing import Optional
+
+import silx.io.nxdata
+from silx.gui import qt
+from silx.gui import icons
+import silx.gui.hdf5
+from .ApplicationContext import ApplicationContext
+from .CustomNxdataWidget import CustomNxdataWidget
+from .CustomNxdataWidget import CustomNxDataToolBar
+from ..utils import parseutils
+from silx.gui.utils import projecturl
+from .DataPanel import DataPanel
+
+
+_logger = logging.getLogger(__name__)
+
+
+class Viewer(qt.QMainWindow):
+ """
+ This window allows to browse a data file like images or HDF5 and it's
+ content.
+ """
+
+ def __init__(self, parent=None, settings=None):
+ """
+ Constructor
+ """
+
+ qt.QMainWindow.__init__(self, parent)
+ self.setWindowTitle("Silx viewer")
+
+ silxIcon = icons.getQIcon("silx")
+ self.setWindowIcon(silxIcon)
+
+ self.__error = ""
+
+ self.__context = self.createApplicationContext(settings)
+ self.__context.restoreLibrarySettings()
+
+ self.__dialogState = None
+ self.__customNxDataItem = None
+ self.__treeview = silx.gui.hdf5.Hdf5TreeView(self)
+ self.__treeview.setExpandsOnDoubleClick(False)
+ """Silx HDF5 TreeView"""
+
+ rightPanel = qt.QSplitter(self)
+ rightPanel.setOrientation(qt.Qt.Vertical)
+ self.__splitter2 = rightPanel
+
+ self.__displayIt = None
+ self.__treeWindow = self.__createTreeWindow(self.__treeview)
+
+ # Custom the model to be able to manage the life cycle of the files
+ treeModel = silx.gui.hdf5.Hdf5TreeModel(self.__treeview, ownFiles=False)
+ treeModel.sigH5pyObjectLoaded.connect(self.__h5FileLoaded)
+ treeModel.sigH5pyObjectRemoved.connect(self.__h5FileRemoved)
+ treeModel.sigH5pyObjectSynchronized.connect(self.__h5FileSynchonized)
+ treeModel.setDatasetDragEnabled(True)
+ self.__treeModelSorted = silx.gui.hdf5.NexusSortFilterProxyModel(
+ self.__treeview
+ )
+ self.__treeModelSorted.setSourceModel(treeModel)
+ self.__treeModelSorted.sort(0, qt.Qt.AscendingOrder)
+ self.__treeModelSorted.setSortCaseSensitivity(qt.Qt.CaseInsensitive)
+
+ self.__treeview.setModel(self.__treeModelSorted)
+ rightPanel.addWidget(self.__treeWindow)
+
+ self.__customNxdata = CustomNxdataWidget(self)
+ self.__customNxdata.setSelectionBehavior(qt.QAbstractItemView.SelectRows)
+ # optimise the rendering
+ self.__customNxdata.setUniformRowHeights(True)
+ self.__customNxdata.setIconSize(qt.QSize(16, 16))
+ self.__customNxdata.setExpandsOnDoubleClick(False)
+
+ self.__customNxdataWindow = self.__createCustomNxdataWindow(self.__customNxdata)
+ self.__customNxdataWindow.setVisible(False)
+ rightPanel.addWidget(self.__customNxdataWindow)
+
+ rightPanel.setStretchFactor(1, 1)
+ rightPanel.setCollapsible(0, False)
+ rightPanel.setCollapsible(1, False)
+
+ self.__dataPanel = DataPanel(self, self.__context)
+
+ spliter = qt.QSplitter(self)
+ spliter.addWidget(rightPanel)
+ spliter.addWidget(self.__dataPanel)
+ spliter.setStretchFactor(1, 1)
+ spliter.setCollapsible(0, False)
+ spliter.setCollapsible(1, False)
+ self.__splitter = spliter
+
+ main_panel = qt.QWidget(self)
+ layout = qt.QVBoxLayout()
+ layout.addWidget(spliter)
+ layout.setStretchFactor(spliter, 1)
+ main_panel.setLayout(layout)
+
+ self.setCentralWidget(main_panel)
+
+ self.__treeview.activated.connect(self.displaySelectedData)
+ self.__customNxdata.activated.connect(self.displaySelectedCustomData)
+ self.__customNxdata.sigNxdataItemRemoved.connect(self.__customNxdataRemoved)
+ self.__customNxdata.sigNxdataItemUpdated.connect(self.__customNxdataUpdated)
+ self.__treeview.addContextMenuCallback(self.customContextMenu)
+
+ treeModel = self.__treeview.findHdf5TreeModel()
+ columns = list(treeModel.COLUMN_IDS)
+ columns.remove(treeModel.VALUE_COLUMN)
+ columns.remove(treeModel.NODE_COLUMN)
+ columns.remove(treeModel.DESCRIPTION_COLUMN)
+ columns.insert(1, treeModel.DESCRIPTION_COLUMN)
+ self.__treeview.header().setSections(columns)
+
+ self._iconUpward = icons.getQIcon("plot-yup")
+ self._iconDownward = icons.getQIcon("plot-ydown")
+
+ self.createActions()
+ self.createMenus()
+ self.__context.restoreSettings()
+
+ def createApplicationContext(self, settings):
+ return ApplicationContext(self, settings)
+
+ def __createTreeWindow(self, treeView):
+ toolbar = qt.QToolBar(self)
+ toolbar.setIconSize(qt.QSize(16, 16))
+ toolbar.setStyleSheet("QToolBar { border: 0px }")
+
+ action = qt.QAction(toolbar)
+ action.setIcon(icons.getQIcon("view-refresh"))
+ action.setText("Refresh")
+ action.setToolTip("Refresh all selected items")
+ action.triggered.connect(self.__refreshSelected)
+ action.setShortcuts(
+ [
+ qt.QKeySequence(qt.Qt.Key_F5),
+ qt.QKeySequence(qt.Qt.CTRL | qt.Qt.Key_R),
+ ]
+ )
+ toolbar.addAction(action)
+ treeView.addAction(action)
+ self.__refreshAction = action
+
+ action = qt.QAction(toolbar)
+ # action.setIcon(icons.getQIcon("view-refresh"))
+ action.setText("Close")
+ action.setToolTip("Close selected item")
+ action.triggered.connect(self.__removeSelected)
+ action.setShortcut(qt.QKeySequence.Delete)
+ treeView.addAction(action)
+ self.__closeAction = action
+
+ toolbar.addSeparator()
+
+ action = qt.QAction(toolbar)
+ action.setIcon(icons.getQIcon("tree-expand-all"))
+ action.setText("Expand all")
+ action.setToolTip("Expand all selected items")
+ action.triggered.connect(self.__expandAllSelected)
+ action.setShortcut(qt.QKeySequence(qt.Qt.CTRL | qt.Qt.Key_Plus))
+ toolbar.addAction(action)
+ treeView.addAction(action)
+ self.__expandAllAction = action
+
+ action = qt.QAction(toolbar)
+ action.setIcon(icons.getQIcon("tree-collapse-all"))
+ action.setText("Collapse all")
+ action.setToolTip("Collapse all selected items")
+ action.triggered.connect(self.__collapseAllSelected)
+ action.setShortcut(qt.QKeySequence(qt.Qt.CTRL | qt.Qt.Key_Minus))
+ toolbar.addAction(action)
+ treeView.addAction(action)
+ self.__collapseAllAction = action
+
+ action = qt.QAction("&Sort file content", toolbar)
+ action.setIcon(icons.getQIcon("tree-sort"))
+ action.setToolTip("Toggle sorting of file content")
+ action.setCheckable(True)
+ action.setChecked(True)
+ action.triggered.connect(self.setContentSorted)
+ toolbar.addAction(action)
+ treeView.addAction(action)
+ self._sortContentAction = action
+
+ widget = qt.QWidget(self)
+ layout = qt.QVBoxLayout(widget)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.addWidget(toolbar)
+ layout.addWidget(treeView)
+ return widget
+
+ def __removeSelected(self):
+ """Close selected items"""
+ qt.QApplication.setOverrideCursor(qt.Qt.WaitCursor)
+
+ selection = self.__treeview.selectionModel()
+ indexes = selection.selectedIndexes()
+ selectedItems = []
+ model = self.__treeview.model()
+ h5files = set([])
+ while len(indexes) > 0:
+ index = indexes.pop(0)
+ if index.column() != 0:
+ continue
+ h5 = model.data(index, role=silx.gui.hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
+ rootIndex = index
+ # Reach the root of the tree
+ while rootIndex.parent().isValid():
+ rootIndex = rootIndex.parent()
+ rootRow = rootIndex.row()
+ relativePath = self.__getRelativePath(model, rootIndex, index)
+ selectedItems.append((rootRow, relativePath))
+ h5files.add(h5.file)
+
+ if len(h5files) != 0:
+ model = self.__treeview.findHdf5TreeModel()
+ for h5 in h5files:
+ row = model.h5pyObjectRow(h5)
+ model.removeH5pyObject(h5)
+
+ qt.QApplication.restoreOverrideCursor()
+
+ def __refreshSelected(self):
+ """Refresh all selected items"""
+ qt.QApplication.setOverrideCursor(qt.Qt.WaitCursor)
+
+ selection = self.__treeview.selectionModel()
+ indexes = selection.selectedIndexes()
+ selectedItems = []
+ model = self.__treeview.model()
+ h5files = []
+ while len(indexes) > 0:
+ index = indexes.pop(0)
+ if index.column() != 0:
+ continue
+ rootIndex = index
+ # Reach the root of the tree
+ while rootIndex.parent().isValid():
+ rootIndex = rootIndex.parent()
+ rootRow = rootIndex.row()
+ relativePath = self.__getRelativePath(model, rootIndex, index)
+ selectedItems.append((rootRow, relativePath))
+ h5 = model.data(
+ rootIndex, role=silx.gui.hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE
+ )
+ item = model.data(
+ rootIndex, role=silx.gui.hdf5.Hdf5TreeModel.H5PY_ITEM_ROLE
+ )
+ h5files.append((h5, item._openedPath))
+
+ if len(h5files) == 0:
+ qt.QApplication.restoreOverrideCursor()
+ return
+
+ model = self.__treeview.findHdf5TreeModel()
+ for h5, filename in h5files:
+ self.__synchronizeH5pyObject(h5, filename)
+
+ model = self.__treeview.model()
+ itemSelection = qt.QItemSelection()
+ for rootRow, relativePath in selectedItems:
+ rootIndex = model.index(rootRow, 0, qt.QModelIndex())
+ index = self.__indexFromPath(model, rootIndex, relativePath)
+ if index is None:
+ continue
+ indexEnd = model.index(index.row(), model.columnCount() - 1, index.parent())
+ itemSelection.select(index, indexEnd)
+ selection.select(itemSelection, qt.QItemSelectionModel.ClearAndSelect)
+
+ qt.QApplication.restoreOverrideCursor()
+
+ def __synchronizeH5pyObject(self, h5, filename: Optional[str] = None):
+ model = self.__treeview.findHdf5TreeModel()
+ # This is buggy right now while h5py do not allow to close a file
+ # while references are still used.
+ # FIXME: The architecture have to be reworked to support this feature.
+ # model.synchronizeH5pyObject(h5)
+
+ if filename is None:
+ filename = f"{h5.file.filename}::{h5.name}"
+ row = model.h5pyObjectRow(h5)
+ index = self.__treeview.model().index(row, 0, qt.QModelIndex())
+ paths = self.__getPathFromExpandedNodes(self.__treeview, index)
+ model.removeH5pyObject(h5)
+ model.insertFile(filename, row)
+ index = self.__treeview.model().index(row, 0, qt.QModelIndex())
+ self.__expandNodesFromPaths(self.__treeview, index, paths)
+
+ def __getRelativePath(self, model, rootIndex, index):
+ """Returns a relative path from an index to his rootIndex.
+
+ If the path is empty the index is also the rootIndex.
+ """
+ path = ""
+ while index.isValid():
+ if index == rootIndex:
+ return path
+ name = model.data(index)
+ if path == "":
+ path = name
+ else:
+ path = name + "/" + path
+ index = index.parent()
+
+ # index is not a children of rootIndex
+ raise ValueError("index is not a children of the rootIndex")
+
+ def __getPathFromExpandedNodes(self, view, rootIndex):
+ """Return relative path from the root index of the extended nodes"""
+ model = view.model()
+ rootPath = None
+ paths = []
+ indexes = [rootIndex]
+ while len(indexes):
+ index = indexes.pop(0)
+ if not view.isExpanded(index):
+ continue
+
+ node = model.data(index, role=silx.gui.hdf5.Hdf5TreeModel.H5PY_ITEM_ROLE)
+ path = node._getCanonicalName()
+ if rootPath is None:
+ rootPath = path
+ path = path[len(rootPath) :]
+ paths.append(path)
+
+ for child in range(model.rowCount(index)):
+ childIndex = model.index(child, 0, index)
+ indexes.append(childIndex)
+ return paths
+
+ def __indexFromPath(self, model, rootIndex, path):
+ elements = path.split("/")
+ if elements[0] == "":
+ elements.pop(0)
+ index = rootIndex
+ while len(elements) != 0:
+ element = elements.pop(0)
+ found = False
+ for child in range(model.rowCount(index)):
+ childIndex = model.index(child, 0, index)
+ name = model.data(childIndex)
+ if element == name:
+ index = childIndex
+ found = True
+ break
+ if not found:
+ return None
+ return index
+
+ def __expandNodesFromPaths(self, view, rootIndex, paths):
+ model = view.model()
+ for path in paths:
+ index = self.__indexFromPath(model, rootIndex, path)
+ if index is not None:
+ view.setExpanded(index, True)
+
+ def __expandAllSelected(self):
+ """Expand all selected items of the tree.
+
+ The depth is fixed to avoid infinite loop with recurssive links.
+ """
+ qt.QApplication.setOverrideCursor(qt.Qt.WaitCursor)
+
+ selection = self.__treeview.selectionModel()
+ indexes = selection.selectedIndexes()
+ model = self.__treeview.model()
+ while len(indexes) > 0:
+ index = indexes.pop(0)
+ if isinstance(index, tuple):
+ index, depth = index
+ else:
+ depth = 0
+ if index.column() != 0:
+ continue
+
+ if depth > 10:
+ # Avoid infinite loop with recursive links
+ break
+
+ if model.hasChildren(index):
+ self.__treeview.setExpanded(index, True)
+ for row in range(model.rowCount(index)):
+ childIndex = model.index(row, 0, index)
+ indexes.append((childIndex, depth + 1))
+ qt.QApplication.restoreOverrideCursor()
+
+ def __collapseAllSelected(self):
+ """Collapse all selected items of the tree.
+
+ The depth is fixed to avoid infinite loop with recurssive links.
+ """
+ selection = self.__treeview.selectionModel()
+ indexes = selection.selectedIndexes()
+ model = self.__treeview.model()
+ while len(indexes) > 0:
+ index = indexes.pop(0)
+ if isinstance(index, tuple):
+ index, depth = index
+ else:
+ depth = 0
+ if index.column() != 0:
+ continue
+
+ if depth > 10:
+ # Avoid infinite loop with recursive links
+ break
+
+ if model.hasChildren(index):
+ self.__treeview.setExpanded(index, False)
+ for row in range(model.rowCount(index)):
+ childIndex = model.index(row, 0, index)
+ indexes.append((childIndex, depth + 1))
+
+ def __createCustomNxdataWindow(self, customNxdataWidget):
+ toolbar = CustomNxDataToolBar(self)
+ toolbar.setCustomNxDataWidget(customNxdataWidget)
+ toolbar.setIconSize(qt.QSize(16, 16))
+ toolbar.setStyleSheet("QToolBar { border: 0px }")
+
+ widget = qt.QWidget(self)
+ layout = qt.QVBoxLayout(widget)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.addWidget(toolbar)
+ layout.addWidget(customNxdataWidget)
+ return widget
+
+ def __h5FileLoaded(self, loadedH5, filename):
+ self.__context.pushRecentFile(loadedH5.file.filename)
+ if filename == self.__displayIt:
+ self.__displayIt = None
+ self.displayData(loadedH5)
+
+ def __h5FileRemoved(self, removedH5):
+ self.__dataPanel.removeDatasetsFrom(removedH5)
+ self.__customNxdata.removeDatasetsFrom(removedH5)
+ removedH5.close()
+
+ def __h5FileSynchonized(self, removedH5, loadedH5):
+ self.__dataPanel.replaceDatasetsFrom(removedH5, loadedH5)
+ self.__customNxdata.replaceDatasetsFrom(removedH5, loadedH5)
+ removedH5.close()
+
+ def closeEvent(self, event):
+ self.__context.saveSettings()
+
+ # Clean up as much as possible Python objects
+ self.displayData(None)
+ customModel = self.__customNxdata.model()
+ customModel.clear()
+ hdf5Model = self.__treeview.findHdf5TreeModel()
+ hdf5Model.clear()
+
+ def saveSettings(self, settings):
+ """Save the window settings to this settings object
+
+ :param qt.QSettings settings: Initialized settings
+ """
+ isFullScreen = bool(self.windowState() & qt.Qt.WindowFullScreen)
+ if isFullScreen:
+ # show in normal to catch the normal geometry
+ self.showNormal()
+
+ settings.beginGroup("mainwindow")
+ settings.setValue("size", self.size())
+ settings.setValue("pos", self.pos())
+ settings.setValue("full-screen", isFullScreen)
+ settings.endGroup()
+
+ settings.beginGroup("mainlayout")
+ settings.setValue("spliter", self.__splitter.sizes())
+ settings.setValue("spliter2", self.__splitter2.sizes())
+ isVisible = self.__customNxdataWindow.isVisible()
+ settings.setValue("custom-nxdata-window-visible", isVisible)
+ settings.endGroup()
+
+ settings.beginGroup("content")
+ isSorted = self._sortContentAction.isChecked()
+ settings.setValue("is-sorted", isSorted)
+ settings.endGroup()
+
+ if isFullScreen:
+ self.showFullScreen()
+
+ def restoreSettings(self, settings):
+ """Restore the window settings using this settings object
+
+ :param qt.QSettings settings: Initialized settings
+ """
+ settings.beginGroup("mainwindow")
+ size = settings.value("size", qt.QSize(640, 480))
+ pos = settings.value("pos", qt.QPoint())
+ isFullScreen = settings.value("full-screen", False)
+ isFullScreen = parseutils.to_bool(isFullScreen, False)
+ settings.endGroup()
+
+ settings.beginGroup("mainlayout")
+ try:
+ data = settings.value("spliter")
+ data = [int(d) for d in data]
+ self.__splitter.setSizes(data)
+ except Exception:
+ _logger.debug("Backtrace", exc_info=True)
+ try:
+ data = settings.value("spliter2")
+ data = [int(d) for d in data]
+ self.__splitter2.setSizes(data)
+ except Exception:
+ _logger.debug("Backtrace", exc_info=True)
+ isVisible = settings.value("custom-nxdata-window-visible", False)
+ isVisible = parseutils.to_bool(isVisible, False)
+ self.__customNxdataWindow.setVisible(isVisible)
+ self._displayCustomNxdataWindow.setChecked(isVisible)
+ settings.endGroup()
+
+ settings.beginGroup("content")
+ isSorted = settings.value("is-sorted", True)
+ isSorted = parseutils.to_bool(isSorted, True)
+ self.setContentSorted(isSorted)
+ settings.endGroup()
+
+ if not pos.isNull():
+ self.move(pos)
+ if not size.isNull():
+ self.resize(size)
+ if isFullScreen:
+ self.showFullScreen()
+
+ def createActions(self):
+ action = qt.QAction("E&xit", self)
+ action.setShortcut(qt.QKeySequence.Quit)
+ action.setStatusTip("Exit the application")
+ action.triggered.connect(self.close)
+ self._exitAction = action
+
+ action = qt.QAction("&Open...", self)
+ action.setShortcut(qt.QKeySequence.Open)
+ action.setStatusTip("Open a file")
+ action.triggered.connect(self.open)
+ self._openAction = action
+
+ menu = qt.QMenu("Open Recent", self)
+ menu.setStatusTip("Open a recently opened file")
+ self._openRecentMenu = menu
+
+ action = qt.QAction("Close All", self)
+ action.setShortcut(qt.QKeySequence.Close)
+ action.setStatusTip("Close all opened files")
+ action.triggered.connect(self.closeAll)
+ self._closeAllAction = action
+
+ action = qt.QAction("&About", self)
+ action.setStatusTip("Show the application's About box")
+ action.triggered.connect(self.about)
+ self._aboutAction = action
+
+ action = qt.QAction("&Documentation", self)
+ action.setStatusTip("Show the Silx library's documentation")
+ action.triggered.connect(self.showDocumentation)
+ self._documentationAction = action
+
+ # Plot backend
+
+ self._plotBackendMenu = qt.QMenu("Plot rendering backend", self)
+ self._plotBackendMenu.setStatusTip("Select plot rendering backend")
+
+ group = qt.QActionGroup(self)
+ group.setExclusive(True)
+
+ action = qt.QAction("matplotlib", self)
+ action.setStatusTip("Plot will be rendered using matplotlib")
+ action.setCheckable(True)
+ action.triggered.connect(self.__forceMatplotlibBackend)
+ group.addAction(action)
+ self._plotBackendMenu.addAction(action)
+ self._usePlotWithMatplotlib = action
+
+ action = qt.QAction("OpenGL", self)
+ action.setStatusTip("Plot will be rendered using OpenGL")
+ action.setCheckable(True)
+ action.triggered.connect(self.__forceOpenglBackend)
+ group.addAction(action)
+ self._plotBackendMenu.addAction(action)
+ self._usePlotWithOpengl = action
+
+ # Plot image orientation
+
+ self._plotImageOrientationMenu = qt.QMenu(
+ "Default plot image y-axis orientation", self
+ )
+ self._plotImageOrientationMenu.setStatusTip(
+ "Select the default y-axis orientation used by plot displaying images"
+ )
+
+ group = qt.QActionGroup(self)
+ group.setExclusive(True)
+
+ action = qt.QAction("Downward, origin on top", self)
+ action.setIcon(self._iconDownward)
+ action.setStatusTip("Plot images will use a downward Y-axis orientation")
+ action.setCheckable(True)
+ action.triggered.connect(self.__forcePlotImageDownward)
+ group.addAction(action)
+ self._plotImageOrientationMenu.addAction(action)
+ self._useYAxisOrientationDownward = action
+
+ action = qt.QAction("Upward, origin on bottom", self)
+ action.setIcon(self._iconUpward)
+ action.setStatusTip("Plot images will use a upward Y-axis orientation")
+ action.setCheckable(True)
+ action.triggered.connect(self.__forcePlotImageUpward)
+ group.addAction(action)
+ self._plotImageOrientationMenu.addAction(action)
+ self._useYAxisOrientationUpward = action
+
+ # mpl layout
+
+ action = qt.QAction("Use MPL tight layout", self)
+ action.setCheckable(True)
+ action.triggered.connect(self.__forceMplTightLayout)
+ self._useMplTightLayout = action
+
+ # Windows
+
+ action = qt.QAction("Show custom NXdata selector", self)
+ action.setStatusTip(
+ "Show a widget which allow to create plot by selecting data and axes"
+ )
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_F6))
+ action.toggled.connect(self.__toggleCustomNxdataWindow)
+ self._displayCustomNxdataWindow = action
+
+ def __toggleCustomNxdataWindow(self):
+ isVisible = self._displayCustomNxdataWindow.isChecked()
+ self.__customNxdataWindow.setVisible(isVisible)
+
+ def __updateFileMenu(self):
+ files = self.__context.getRecentFiles()
+ self._openRecentMenu.clear()
+ self._openRecentMenu.setEnabled(len(files) != 0)
+ if len(files) != 0:
+ for filePath in files:
+ baseName = os.path.basename(filePath)
+ action = qt.QAction(baseName, self)
+ action.setToolTip(filePath)
+ action.triggered.connect(
+ functools.partial(self.__openRecentFile, filePath)
+ )
+ self._openRecentMenu.addAction(action)
+ self._openRecentMenu.addSeparator()
+ baseName = os.path.basename(filePath)
+ action = qt.QAction("Clear history", self)
+ action.setToolTip("Clear the history of the recent files")
+ action.triggered.connect(self.__clearRecentFile)
+ self._openRecentMenu.addAction(action)
+
+ def __clearRecentFile(self):
+ self.__context.clearRencentFiles()
+
+ def __openRecentFile(self, filePath):
+ self.appendFile(filePath)
+
+ def __updateOptionMenu(self):
+ """Update the state of the checked options as it is based on global
+ environment values."""
+
+ # plot backend
+
+ title = self._plotBackendMenu.title().split(": ", 1)[0]
+ backend = self.__context.getDefaultPlotBackend()
+ self._plotBackendMenu.setTitle(f"{title}: {backend}")
+
+ action = self._usePlotWithMatplotlib
+ action.setChecked(backend in ["matplotlib", "mpl"])
+ title = action.text().split(" (", 1)[0]
+ if not action.isChecked():
+ title += " (applied after application restart)"
+ action.setText(title)
+
+ action = self._usePlotWithOpengl
+ action.setChecked(backend in ["opengl", "gl"])
+ title = action.text().split(" (", 1)[0]
+ if not action.isChecked():
+ title += " (applied after application restart)"
+ action.setText(title)
+
+ # plot orientation
+
+ menu = self._plotImageOrientationMenu
+ if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == "downward":
+ menu.setIcon(self._iconDownward)
+ else:
+ menu.setIcon(self._iconUpward)
+
+ action = self._useYAxisOrientationDownward
+ action.setChecked(
+ silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == "downward"
+ )
+ title = action.text().split(" (", 1)[0]
+ if not action.isChecked():
+ title += " (applied after application restart)"
+ action.setText(title)
+
+ action = self._useYAxisOrientationUpward
+ action.setChecked(
+ silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION != "downward"
+ )
+ title = action.text().split(" (", 1)[0]
+ if not action.isChecked():
+ title += " (applied after application restart)"
+ action.setText(title)
+
+ # mpl
+
+ action = self._useMplTightLayout
+ action.setChecked(silx.config._MPL_TIGHT_LAYOUT)
+
+ def createMenus(self):
+ fileMenu = self.menuBar().addMenu("&File")
+ fileMenu.addAction(self._openAction)
+ fileMenu.addMenu(self._openRecentMenu)
+ fileMenu.addAction(self._closeAllAction)
+ fileMenu.addSeparator()
+ fileMenu.addAction(self._exitAction)
+ fileMenu.aboutToShow.connect(self.__updateFileMenu)
+
+ optionMenu = self.menuBar().addMenu("&Options")
+ optionMenu.addMenu(self._plotImageOrientationMenu)
+ optionMenu.addMenu(self._plotBackendMenu)
+ optionMenu.addAction(self._useMplTightLayout)
+ optionMenu.aboutToShow.connect(self.__updateOptionMenu)
+
+ viewMenu = self.menuBar().addMenu("&Views")
+ viewMenu.addAction(self._displayCustomNxdataWindow)
+
+ helpMenu = self.menuBar().addMenu("&Help")
+ helpMenu.addAction(self._aboutAction)
+ helpMenu.addAction(self._documentationAction)
+
+ self.__errorButton = qt.QToolButton(self)
+ self.__errorButton.setIcon(
+ self.style().standardIcon(qt.QStyle.SP_MessageBoxWarning)
+ )
+ self.__errorButton.setToolTip(
+ "An error occured!\nClick to display last error\nor check messages in the console"
+ )
+ self.__errorButton.setVisible(False)
+ self.__errorButton.clicked.connect(self.__errorButtonClicked)
+ self.menuBar().setCornerWidget(self.__errorButton)
+
+ def open(self):
+ dialog = self.createFileDialog()
+ if self.__dialogState is None:
+ currentDirectory = os.getcwd()
+ dialog.setDirectory(currentDirectory)
+ else:
+ dialog.restoreState(self.__dialogState)
+
+ result = dialog.exec()
+ if not result:
+ return
+
+ self.__dialogState = dialog.saveState()
+
+ filenames = dialog.selectedFiles()
+ for filename in filenames:
+ self.appendFile(filename)
+
+ def closeAll(self):
+ """Close all currently opened files"""
+ model = self.__treeview.findHdf5TreeModel()
+ model.clear()
+
+ def createFileDialog(self):
+ dialog = qt.QFileDialog(self)
+ dialog.setWindowTitle("Open")
+ dialog.setModal(True)
+
+ # NOTE: hdf5plugin have to be loaded before
+ extensions = {}
+ for description, ext in silx.io.supported_extensions().items():
+ extensions[description] = " ".join(sorted(list(ext)))
+
+ # Add extensions supported by fabio
+ extensions["NeXus layout from EDF files"] = "*.edf"
+ extensions["NeXus layout from TIFF image files"] = "*.tif *.tiff"
+ extensions["NeXus layout from CBF files"] = "*.cbf"
+ extensions["NeXus layout from MarCCD image files"] = "*.mccd"
+
+ all_supported_extensions = set()
+ for name, exts in extensions.items():
+ exts = exts.split(" ")
+ all_supported_extensions.update(exts)
+ all_supported_extensions = sorted(list(all_supported_extensions))
+
+ filters = []
+ filters.append("All supported files (%s)" % " ".join(all_supported_extensions))
+ for name, extension in extensions.items():
+ filters.append("%s (%s)" % (name, extension))
+ filters.append("All files (*)")
+
+ dialog.setNameFilters(filters)
+ dialog.setFileMode(qt.QFileDialog.ExistingFiles)
+ return dialog
+
+ def about(self):
+ from .About import About
+
+ About.about(self, "Silx viewer")
+
+ def showDocumentation(self):
+ subpath = "index.html"
+ url = projecturl.getDocumentationUrl(subpath)
+ qt.QDesktopServices.openUrl(qt.QUrl(url))
+
+ def setContentSorted(self, sort):
+ """Set whether file content should be sorted or not.
+
+ :param bool sort:
+ """
+ sort = bool(sort)
+ if sort != self.isContentSorted():
+ # save expanded nodes
+ pathss = []
+ root = qt.QModelIndex()
+ model = self.__treeview.model()
+ for i in range(model.rowCount(root)):
+ index = model.index(i, 0, root)
+ paths = self.__getPathFromExpandedNodes(self.__treeview, index)
+ pathss.append(paths)
+
+ self.__treeview.setModel(
+ self.__treeModelSorted if sort else self.__treeModelSorted.sourceModel()
+ )
+ self._sortContentAction.setChecked(self.isContentSorted())
+
+ # restore expanded nodes
+ model = self.__treeview.model()
+ for i in range(model.rowCount(root)):
+ index = model.index(i, 0, root)
+ paths = pathss.pop(0)
+ self.__expandNodesFromPaths(self.__treeview, index, paths)
+
+ def isContentSorted(self):
+ """Returns whether the file content is sorted or not.
+
+ :rtype: bool
+ """
+ return self.__treeview.model() is self.__treeModelSorted
+
+ def __forcePlotImageDownward(self):
+ silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = "downward"
+
+ def __forcePlotImageUpward(self):
+ silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = "upward"
+
+ def __forceMatplotlibBackend(self):
+ silx.config.DEFAULT_PLOT_BACKEND = "matplotlib"
+
+ def __forceOpenglBackend(self):
+ silx.config.DEFAULT_PLOT_BACKEND = "opengl", "matplotlib"
+
+ def __forceMplTightLayout(self):
+ silx.config._MPL_TIGHT_LAYOUT = self._useMplTightLayout.isChecked()
+
+ def appendFile(self, filename):
+ if self.__displayIt is None:
+ # Store the file to display it (loading could be async)
+ self.__displayIt = filename
+ self.__treeview.findHdf5TreeModel().appendFile(filename)
+
+ def displaySelectedData(self):
+ """Called to update the dataviewer with the selected data."""
+ selected = list(self.__treeview.selectedH5Nodes(ignoreBrokenLinks=False))
+ if len(selected) == 1:
+ # Update the viewer for a single selection
+ data = selected[0]
+ self.__dataPanel.setData(data)
+ else:
+ _logger.debug("Too many data selected")
+
+ def displayData(self, data):
+ """Called to update the dataviewer with a secific data."""
+ self.__dataPanel.setData(data)
+
+ def displaySelectedCustomData(self):
+ selected = list(self.__customNxdata.selectedItems())
+ if len(selected) == 1:
+ # Update the viewer for a single selection
+ item = selected[0]
+ self.__dataPanel.setCustomDataItem(item)
+ else:
+ _logger.debug("Too many items selected")
+
+ def __customNxdataRemoved(self, item):
+ if self.__dataPanel.getCustomNxdataItem() is item:
+ self.__dataPanel.setCustomDataItem(None)
+
+ def __customNxdataUpdated(self, item):
+ if self.__dataPanel.getCustomNxdataItem() is item:
+ self.__dataPanel.setCustomDataItem(item)
+
+ def __makeSureCustomNxDataWindowIsVisible(self):
+ if not self.__customNxdataWindow.isVisible():
+ self.__customNxdataWindow.setVisible(True)
+ self._displayCustomNxdataWindow.setChecked(True)
+
+ def useAsNewCustomSignal(self, h5dataset):
+ self.__makeSureCustomNxDataWindowIsVisible()
+ model = self.__customNxdata.model()
+ model.createFromSignal(h5dataset)
+
+ def useAsNewCustomNxdata(self, h5nxdata):
+ self.__makeSureCustomNxDataWindowIsVisible()
+ model = self.__customNxdata.model()
+ model.createFromNxdata(h5nxdata)
+
+ def customContextMenu(self, event):
+ """Called to populate the context menu
+
+ :param silx.gui.hdf5.Hdf5ContextMenuEvent event: Event
+ containing expected information to populate the context menu
+ """
+ selectedObjects = event.source().selectedH5Nodes(ignoreBrokenLinks=False)
+ menu = event.menu()
+
+ if not menu.isEmpty():
+ menu.addSeparator()
+
+ for obj in selectedObjects:
+ h5 = obj.h5py_object
+
+ name = obj.name
+ if name.startswith("/"):
+ name = name[1:]
+ if name == "":
+ name = "the root"
+
+ action = qt.QAction("Show %s" % name, event.source())
+ action.triggered.connect(lambda: self.displayData(h5))
+ menu.addAction(action)
+
+ if silx.io.is_dataset(h5):
+ action = qt.QAction("Use as a new custom signal", event.source())
+ action.triggered.connect(lambda: self.useAsNewCustomSignal(h5))
+ menu.addAction(action)
+
+ if silx.io.is_group(h5) and silx.io.nxdata.is_valid_nxdata(h5):
+ action = qt.QAction("Use as a new custom NXdata", event.source())
+ action.triggered.connect(lambda: self.useAsNewCustomNxdata(h5))
+ menu.addAction(action)
+
+ if silx.io.is_file(h5):
+ action = qt.QAction("Close %s" % obj.local_filename, event.source())
+ action.triggered.connect(
+ lambda: self.__treeview.findHdf5TreeModel().removeH5pyObject(h5)
+ )
+ menu.addAction(action)
+ action = qt.QAction(
+ "Synchronize %s" % obj.local_filename, event.source()
+ )
+ action.triggered.connect(lambda: self.__synchronizeH5pyObject(h5))
+ menu.addAction(action)
+
+ def __errorButtonClicked(self):
+ button = qt.QMessageBox.warning(
+ self,
+ "Error",
+ self.getError(),
+ qt.QMessageBox.Reset | qt.QMessageBox.Close,
+ qt.QMessageBox.Close,
+ )
+ if button == qt.QMessageBox.Reset:
+ self.setError("")
+
+ def getError(self) -> str:
+ """Returns error information string"""
+ return self.__error
+
+ def setError(self, error: str):
+ """Set error information string"""
+ if error == self.__error:
+ return
+
+ self.__error = error
+ self.__errorButton.setVisible(error != "")
+
+ def setErrorFromException(
+ self, type_: type[BaseException], value: BaseException, trace: TracebackType
+ ):
+ """Set information about the last exception that occured"""
+ formattedTrace = "\n".join(traceback.format_tb(trace))
+ self.setError(f"{type_.__name__}:\n{value}\n\n{formattedTrace}")