Source code for nexxT.services.gui.MainWindow

# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2020 ifm electronic gmbh
#
# THE PROGRAM IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
#

"""
This module provides a MainWindow GUI service for the nexxT framework.
"""
import logging
import re
import subprocess
import time
import sys
import nexxT.shiboken
from nexxT.Qt.QtWidgets import (QMainWindow, QMdiArea, QMdiSubWindow, QDockWidget, QWidget, QGridLayout,
                               QMenuBar, QMessageBox, QScrollArea, QLabel)
from nexxT.Qt.QtCore import (QObject, Signal, Slot, Qt, QByteArray, QDataStream, QIODevice, QRect, QPoint, QSettings,
                            QTimer, QUrl, QEvent)
from nexxT.Qt.QtGui import QDesktopServices, QAction, QActionGroup
import nexxT
from nexxT.interface import Filter
from nexxT.core.Application import Application
from nexxT.core.Utils import handleException, assertMainThread

logger = logging.getLogger(__name__)

[docs] class NexxTMdiSubWindow(QMdiSubWindow): """ Need subclassing for getting close / show events and saving / restoring state. """ visibleChanged = Signal(bool)
[docs] def closeEvent(self, closeEvent): """ override from QMdiSubWindow :param closeEvent: a QCloseEvent instance :return: """ logger.internal("closeEvent widget=%s", self.widget()) self.visibleChanged.emit(False) return super().closeEvent(closeEvent)
[docs] def showEvent(self, showEvent): """ override from QMdiSubWindow :param closeEvent: a QShowEvent instance :return: """ logger.internal("showEvent widget=%s", self.widget()) self.visibleChanged.emit(True) res = super().showEvent(showEvent) # no idea why this is necessary, but otherwise the child window is not shown if self.widget() is not None: self.widget().show() return res
[docs] def saveGeometry(self): """ Saves the geometry of this subwindow (see https://bugreports.qt.io/browse/QTBUG-18648) :return: a ByteArray instance """ array = QByteArray() stream = QDataStream(array, QIODevice.WriteOnly) stream.writeUInt32(0x1D9D0CB) stream.writeUInt16(1) stream.writeUInt16(0) frameGeom = self.frameGeometry() stream.writeInt64(frameGeom.x()) stream.writeInt64(frameGeom.y()) stream.writeInt64(frameGeom.width()) stream.writeInt64(frameGeom.height()) normalGeom = self.normalGeometry() stream.writeInt64(normalGeom.x()) stream.writeInt64(normalGeom.y()) stream.writeInt64(normalGeom.width()) stream.writeInt64(normalGeom.height()) stream.writeUInt32(int(bool(self.windowState() & Qt.WindowMaximized))) stream.writeUInt32(int(bool(self.windowState() & Qt.WindowFullScreen))) return array
[docs] def restoreGeometry(self, geometry): """ Restores the geometry of this subwindow :param geometry: the saved state as a QByteArray instance :return: """ if geometry.size() < 4: return False stream = QDataStream(geometry) if stream.readUInt32() != 0x1D9D0CB: return False if stream.readUInt16() != 1: return False stream.readUInt16() # minorVersion is ignored. x = stream.readInt64() y = stream.readInt64() width = stream.readInt64() height = stream.readInt64() restoredFrameGeometry = QRect(x, y, width, height) x = stream.readInt64() y = stream.readInt64() width = stream.readInt64() height = stream.readInt64() restoredNormalGeometry = QRect(x, y, width, height) maximized = bool(stream.readUInt32()) fullScreen = bool(stream.readUInt32()) frameHeight = 0 if (not restoredNormalGeometry.isValid()) and (restoredFrameGeometry.isValid()): # there seems to be an issue in PySide6 that the normalGeometry is always invalid (?) restoredNormalGeometry = restoredFrameGeometry if not restoredFrameGeometry.isValid(): restoredFrameGeometry = QRect(QPoint(0, 0), self.sizeHint()) if not restoredNormalGeometry.isValid(): restoredNormalGeometry = QRect(QPoint(0, frameHeight), self.sizeHint()) restoredFrameGeometry.moveTop(max(restoredFrameGeometry.top(), 0)) restoredNormalGeometry.moveTop(max(restoredNormalGeometry.top(), 0 + frameHeight)) if maximized or fullScreen: self.setGeometry(restoredNormalGeometry) ws = self.windowState() if maximized: ws |= Qt.WindowMaximized if fullScreen: ws |= Qt.WindowFullScreen self.setWindowState(ws) else: offset = QPoint() self.setWindowState(self.windowState() & ~(Qt.WindowMaximized|Qt.WindowFullScreen)) self.move(restoredFrameGeometry.topLeft() + offset) self.resize(restoredNormalGeometry.size()) return True
[docs] class NexxTDockWidget(QDockWidget): """ Need subclassing for getting close / show events """ visibleChanged = Signal(bool)
[docs] def closeEvent(self, closeEvent): """ override from QMdiSubWindow :param closeEvent: a QCloseEvent instance :return: """ self.visibleChanged.emit(False) return super().closeEvent(closeEvent)
[docs] def showEvent(self, showEvent): """ override from QMdiSubWindow :param closeEvent: a QShowEvent instance :return: """ self.visibleChanged.emit(True) return super().showEvent(showEvent)
[docs] class MainWindow(QMainWindow): """ Main Window service for the nexxT frameworks. Other services usually create dock windows, filters use the subplot functionality to create grid-layouted views. """ mdiSubWindowCreated = Signal(QMdiSubWindow) # TODO: deprecated, can be removed in later versions aboutToClose = Signal(object) userSelectionChanged = Signal(str, QPoint)
[docs] def __init__(self, config): super().__init__() self.config = config self.config.appActivated.connect(self._appActivated) self.mdi = QMdiArea(self) self.mdi.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.mdi.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.mdi.viewport().installEventFilter(self) self.setCentralWidget(self.mdi) self.menu = self.menuBar().addMenu("&Windows") self.framerateMenu = self.menu.addMenu("Display Framerate") self.framerateActionGroup = QActionGroup(self) self.framerate = 25 for framerate in [1, 2, 5, 10, 15, 20, 25, 40, 50, 100]: a = QAction(self) a.setData(framerate) a.setText(f"{framerate} Hz") a.setCheckable(True) if framerate == self.framerate: a.setChecked(True) a.toggled.connect(self._setFramerate) self.framerateActionGroup.addAction(a) self.framerateMenu.addAction(a) self.config.configLoaded.connect(self.restoreConfigSpecifics) self.config.configAboutToSave.connect(self.saveConfigSpecifics) closeAllPlotsAction = QAction("Close all plots", self) closeAllPlotsAction.triggered.connect(self._closeAllPlots) self.menu.addAction(closeAllPlotsAction) showAllPlotsAction = QAction("Show all plots", self) showAllPlotsAction.triggered.connect(self._showAllPlots) self.menu.addAction(showAllPlotsAction) cascadeAction = QAction("Cascade plots", self) cascadeAction.triggered.connect(self.mdi.cascadeSubWindows) self.menu.addAction(cascadeAction) tileAction = QAction("Tile plots", self) tileAction.triggered.connect(self.mdi.tileSubWindows) self.menu.addAction(tileAction) self.dockWindowsMenu = self.menu.addMenu("Dock Windows") self.menu.addSeparator() self.aboutMenu = QMenuBar(self.menuBar()) self.menuBar().setCornerWidget(self.aboutMenu) m = self.aboutMenu.addMenu("&Help") self.helpNexxT = QAction("Help ...") self.aboutNexxT = QAction("About nexxT ...") self.aboutQt = QAction("About Qt ...") self.aboutPython = QAction("About Python ...") m.addActions([self.helpNexxT]) m.addSeparator() m.addActions([self.aboutNexxT, self.aboutQt, self.aboutPython]) self.helpNexxT.triggered.connect(lambda: QDesktopServices.openUrl(QUrl("https://nexxT.readthedocs.org"))) self.aboutNexxT.triggered.connect(lambda: QMessageBox.about(self, "About nexxT", f"""\ This program uses <b>nexxT</b> {nexxT.__version__}, a generic hybrid python/c++ framework for developing computer vision algorithms.<br><br> nexxT is available under the <a href='https://github.com/ifm/nexxT/blob/master/LICENSE'>Apache 2.0 License</a> together with the <a href='https://github.com/ifm/nexxT/blob/master/NOTICE'>notice</a>. """)) self.aboutQt.triggered.connect(lambda: QMessageBox.aboutQt(self)) self.aboutPython.triggered.connect(self._aboutPython) self.toolbar = None self.managedMdiWindows = [] self.managedSubplots = {} self.windows = {} self.activeApp = None self._ignoreCloseEvent = False self._deferredUpdateHistory = {} self._pendingUpdates = set() self._deferredUpdateTimer = QTimer() self._deferredUpdateTimer.setSingleShot(True) self._deferredUpdateTimer.timeout.connect(self._deferredUpdateTimeout)
[docs] def eventFilter(self, obj, event): """ Overwritten from QObject, for disabling mouse wheel scrolling on the main widget's viewport """ if obj is self.mdi.viewport() and event.type() == QEvent.Wheel: # disable mouse wheel scrolling on the viewport widget it's pretty annoying... return True return False
[docs] @Slot(QObject, str) def deferredUpdate(self, obj, slotName): """ Call this method to defer a call to the sepcified slot (obj->slotName()) in accordance with the user-defined framerate set. The slot will be called via getattr(obj, slotName)() :param obj: A QObject instance :param slotName: The name of the slot. """ self._deferredUpdate(obj, slotName)
@handleException def _deferredUpdate(self, obj, slotName): assertMainThread() if (obj, slotName) in self._deferredUpdateHistory: lastUpdateTime = self._deferredUpdateHistory[obj, slotName] dt = 1e-9*(time.monotonic_ns() - lastUpdateTime) if dt < 1/self.framerate: if len(self._pendingUpdates) == 0: self._deferredUpdateTimer.start(int((1/self.framerate - dt)*1000)) self._pendingUpdates.add((obj, slotName)) return self._deferredUpdateHistory[obj, slotName] = time.monotonic_ns() clb = getattr(obj, slotName) clb() def _deferredUpdateTimeout(self): t = time.monotonic_ns() for obj, slotName in self._pendingUpdates: clb = getattr(obj, slotName) self._deferredUpdateHistory[obj, slotName] = t clb() self._pendingUpdates.clear() def _setFramerate(self, checked): if checked: self.framerate = self.sender().data()
[docs] def closeEvent(self, closeEvent): """ Override from QMainWindow, saves the state. :param closeEvent: a QCloseEvent instance :return: """ self._ignoreCloseEvent = False self.aboutToClose.emit(self) if self._ignoreCloseEvent: logger.info("Ignoring event") closeEvent.ignore() return closeEvent.accept() self.saveState() self.saveMdiState() super().closeEvent(closeEvent)
[docs] def ignoreCloseEvent(self): """ Can be called in slots connected to aboutToClose for requesting to ignore the event. Use case is the "There are unsaved changes" dialog. :return: """ self._ignoreCloseEvent = True
[docs] def restoreState(self): """ restores the state of the main window including the dock windows of Services :return: """ logger.debug("restoring main window's state") settings = QSettings() v = settings.value("MainWindowState") if v is not None: super().restoreState(v) v = settings.value("MainWindowGeometry") if v is not None: self.restoreGeometry(v) if self.toolbar is not None: self.toolbar.show()
[docs] def restoreConfigSpecifics(self): """ restores the config-specific state of the main window. """ propertyCollection = self.config.guiState() propertyCollection.defineProperty("MainWindow_framerate", 10, "Display framerate set by user.") framerate = propertyCollection.getProperty("MainWindow_framerate") logger.debug("Set framerate to %d", framerate) for a in self.framerateActionGroup.actions(): if a.data() == framerate: a.setChecked(True)
[docs] def saveState(self): """ saves the state of the main window including the dock windows of Services :return: """ logger.debug("saving main window's state") settings = QSettings() settings.setValue("MainWindowState", super().saveState()) settings.setValue("MainWindowGeometry", self.saveGeometry())
[docs] def saveConfigSpecifics(self): """ saves the config-specific state of the main window. """ propertyCollection = self.config.guiState() propertyCollection.defineProperty("MainWindow_framerate", 10, "Display framerate set by user.") propertyCollection.setProperty("MainWindow_framerate", self.framerate) logger.debug("Store framerate %d", self.framerate)
[docs] def saveMdiState(self): """ saves the state of the individual MDI windows :return: """ for i in self.managedMdiWindows: window = i["window"] propColl = i["propColl"] prefix = i["prefix"] logger.debug("save window geometry %s: %s", prefix, window.geometry()) geom = str(window.saveGeometry().toBase64(), "ascii") visible = self.windows[nexxT.shiboken.getCppPointer(window)[0]].isChecked() # pylint: disable=no-member propColl.setProperty(prefix + "_geom", geom) logger.debug("%s is visible: %d", prefix, int(visible)) propColl.setProperty(prefix + "_visible", int(visible)) self.managedMdiWindows = []
@Slot() def _closeAllPlots(self): """ Slot to close all MDI windows """ for i in self.managedMdiWindows: w = i["window"] w.hide() self.windows[nexxT.shiboken.getCppPointer(w)[0]].setChecked(False) @Slot() def _showAllPlots(self): """ Slot to close all MDI windows """ for i in self.managedMdiWindows: i["window"].show() def __del__(self): logging.getLogger(__name__).debug("deleting MainWindow")
[docs] @Slot(str, QPoint) def updateSelection(self, group, point): """ QT Meta-function which can be called to update the 2D selection. :param group: the group name given as str/QString :param point: the new selection point given as QPoint """ self.userSelectionChanged.emit(group, point)
[docs] @Slot() def getToolBar(self): """ Get the main toolbar (adds seperators as appropriate). :return: """ if self.toolbar is None: self.toolbar = self.addToolBar("NexxT") self.toolbar.setObjectName("NexxT_main_toolbar") else: self.toolbar.addSeparator() return self.toolbar
[docs] @Slot(str, QObject, int, int) def newDockWidget(self, name, parent, defaultArea, allowedArea=Qt.LeftDockWidgetArea|Qt.BottomDockWidgetArea, defaultLoc=None): """ This function is supposed to be called by services :param name: the name of the dock window :param parent: the parent (usually None) :param defaultArea: the default dock area :param allowedArea: the allowed dock areas :return: a new QDockWindow instance """ res = NexxTDockWidget(name, parent if parent is not None else self) res.setAllowedAreas(allowedArea) res.setAttribute(Qt.WA_DeleteOnClose, False) self.addDockWidget(defaultArea, res) self._registerWindow(res, res.objectNameChanged) res.setObjectName(name) if defaultLoc is not None: dl = self.findChild(QDockWidget, defaultLoc) if dl is not None: self.tabifyDockWidget(dl, res) return res
[docs] @staticmethod def parseWindowId(windowId): """ convers a subplot window id into windowTitle, row and column :param windowId: the window id :return: title, row, column """ regexp = re.compile(r"([^\[]+)\[(\d+),\s*(\d+)\]") match = regexp.match(windowId) if not match is None: return match.group(1), int(match.group(2)), int(match.group(3)) return windowId, 0, 0
@staticmethod def _substWindowTitle(title, theFilter): if title == "": title = "${FULLQUALIFIEDFILTERNAME}" if ("${COMPOSITENAME}" in title or "${FILTERNAME}" in title or "${FULLQUALIFIEDFILTERNAME}" in title): logger.warning("Deprecated: implicit variable substitution for window titles - " "use explicit variable substitution instead.") if isinstance(theFilter, Filter): name = theFilter.environment().getFullQualifiedName() if "/" in name: pos = name.rfind("/") composite_name = name[:pos] filter_name = name[pos+1:] else: composite_name = "<root>" filter_name = name title = (title.replace("${COMPOSITENAME}", composite_name). replace("${FILTERNAME}", filter_name). replace("${FULLQUALIFIEDFILTERNAME}", name)) else: logger.warning("MainWindow.subplot(...,theFilter,...) expected a " "nexxT::Filter instance, but got '%s'", theFilter) return title
[docs] @Slot(str, QObject, QWidget) def subplot(self, windowId, theFilter, widget): """ Adds widget to the GridLayout specified by windowId. :param windowId: a string with the format "<windowTitle>[<row>,<col>]" where <windowTitle> is the caption of the MDI window (and it is used as identifier for saving/restoring window state) and <row>, <col> are the coordinates of the addressed subplots (starting at 0) :param theFilter: a Filter instance which is requesting the subplot :param widget: a QWidget which shall be placed into the grid layout. Note that this widget is reparented as a result of this operation and the parents can be used to get access to the MDI sub window. Use releaseSubplot to remove the window :return: None """ logger.internal("subplot '%s'", windowId) title, row, col = self.parseWindowId(windowId) title = self._substWindowTitle(title, theFilter) if title in self.managedSubplots and (row, col) in self.managedSubplots[title]["plots"]: logger.warning("subplot %s[%d,%d] is already registered. Creating a new window for the plot.", title, row, col) i = 2 while f"{title}({i})" in self.managedSubplots: i += 1 title = f"{title}({i})" row = 0 col = 0 if title not in self.managedSubplots: subWindow = self._newMdiSubWindow(theFilter, title) swwidget = QWidget() subWindow.setWidget(swwidget) layout = QGridLayout(swwidget) swwidget.setLayout(layout) self.managedSubplots[title] = dict(mdiSubWindow=subWindow, layout=layout, swwidget=swwidget, plots={}) self.managedSubplots[title]["layout"].addWidget(widget, row, col) self.managedSubplots[title]["mdiSubWindow"].updateGeometry() widget.setParent(self.managedSubplots[title]["swwidget"]) # note: there seems to be a race condition when decreasing the single shot timeout to 0 # sometimes the window size is then not correctly adjusted # with the 100 ms timeout this couldn't be reproduced QTimer.singleShot(100, lambda: ( self.managedSubplots[title]["mdiSubWindow"].adjustSize() if nexxT.shiboken.isValid(widget) and ( # pylint: disable=no-member widget.parent().size().height() < widget.minimumSizeHint().height() or widget.parent().size().height() < widget.minimumSize().height()) else None )) self.managedSubplots[title]["plots"][row, col] = widget
[docs] @Slot(QWidget) @Slot(str) def releaseSubplot(self, arg): """ This needs to be called to release the previously allocated subplot called windowId. The managed widget is deleted as a consequence of this function. :param arg: the widget as passed to subplot. Passing the windowId is also supported, but deprecated. :return: """ if isinstance(arg, str): windowId = arg logger.warning("Using deprecated API to release a subplot. Please pass the widget instead of the windowId.") logger.internal("releaseSubplot '%s'", windowId) title, row, col = self.parseWindowId(windowId) if title not in self.managedSubplots or (row, col) not in self.managedSubplots[title]["plots"]: logger.warning("releasSubplot: cannot find %s", windowId) return widget = self.managedSubplots[title]["plots"][row, col] elif isinstance(arg, QWidget): widget = arg found = False for title, sp in self.managedSubplots.items(): for row, col in sp["plots"]: if sp["plots"][row, col] is widget: found = True break if found: break if not found: raise RuntimeError("cannot find widget given for releaseSubplot.") else: raise RuntimeError("arg of releaseSubplot must be either a string or a QWidget instance.") self.managedSubplots[title]["layout"].removeWidget(widget) self.managedSubplots[title]["plots"][row, col].deleteLater() del self.managedSubplots[title]["plots"][row, col] if len(self.managedSubplots[title]["plots"]) == 0: self.managedSubplots[title]["layout"].deleteLater() self.managedSubplots[title]["swwidget"].deleteLater() self.managedSubplots[title]["mdiSubWindow"].deleteLater() del self.managedSubplots[title]
[docs] @Slot(QObject) @Slot(QObject, str) def newMdiSubWindow(self, filterOrService, windowTitle=None): """ Deprectated (use subplot(...) instead): This function is supposed to be called by filters. :param filterOrService: a Filter instance :param windowTitle: the title of the window (might be None) :return: a new QMdiSubWindow instance """ logger.warning("This function is deprecated. Use subplot function instead.") return self._newMdiSubWindow(filterOrService, windowTitle)
def _newMdiSubWindow(self, filterOrService, windowTitle): res = NexxTMdiSubWindow(None) res.setAttribute(Qt.WA_DeleteOnClose, False) self.mdi.addSubWindow(res) self._registerWindow(res, res.windowTitleChanged) if isinstance(filterOrService, Filter): propColl = filterOrService.guiState() res.setWindowTitle(propColl.objectName() if windowTitle is None else windowTitle) else: app = Application.activeApplication.getApplication() propColl = app.guiState("services/MainWindow") res.setWindowTitle("<unnamed>" if windowTitle is None else windowTitle) prefix = re.sub(r'[^A-Za-z_0-9]', '_', "MainWindow_MDI_" + res.windowTitle()) i = dict(window=res, propColl=propColl, prefix=prefix) self.managedMdiWindows.append(i) window = i["window"] propColl = i["propColl"] prefix = i["prefix"] propColl.defineProperty(prefix + "_geom", "", "Geometry of MDI window") b = QByteArray.fromBase64(bytes(propColl.getProperty(prefix + "_geom"), "ascii")) window.restoreGeometry(b) logger.debug("restored geometry %s:%s (%s)", prefix, window.geometry(), b) propColl.defineProperty(prefix + "_visible", 1, "Visibility of MDI window") if propColl.getProperty(prefix + "_visible"): window.show() else: window.hide() self.mdiSubWindowCreated.emit(res) return res def _registerWindow(self, window, nameChangedSignal): act = QAction("<unnamed>", self) def ensureVisible(): # see issue https://github.com/ifm/nexxT/issues/64 if isinstance(window, QDockWidget) and window.isFloating(): window.setFloating(False) window.setVisible(True) act.setChecked(True) window.raise_() if isinstance(window, QMdiSubWindow): self.mdi.setActiveSubWindow(window) x = self.mdi.horizontalScrollBar().value() y = self.mdi.verticalScrollBar().value() w = self.mdi.viewport().width() h = self.mdi.viewport().height() r = QRect(x, y, w, h) g = window.geometry() logger.debug("r=%s w=%s", r, g) if not r.intersects(g): self.mdi.horizontalScrollBar().setValue(g.x()) self.mdi.verticalScrollBar().setValue(g.y()) act.setCheckable(True) act.triggered.connect(ensureVisible) window.visibleChanged.connect(act.setChecked) nameChangedSignal.connect(act.setText) self.windows[nexxT.shiboken.getCppPointer(window)[0]] = act # pylint: disable=no-member if not isinstance(window, QDockWidget): self.menu.addAction(act) else: self.dockWindowsMenu.addAction(act) logger.debug("Registering window %s, new len=%d", nexxT.shiboken.getCppPointer(window), len(self.windows)) # pylint: disable=no-member window.destroyed.connect(self._windowDestroyed) def _windowDestroyed(self, obj): logger.internal("_windowDestroyed") ptr = nexxT.shiboken.getCppPointer(obj) # pylint: disable=no-member try: ptr = ptr[0] except TypeError: pass logger.debug("Deregistering window %s, old len=%d", ptr, len(self.windows)) self.windows[ptr].deleteLater() del self.windows[ptr] logger.debug("Deregistering window %s done", ptr) def _appActivated(self, name, app): if app is not None: self.activeApp = name app.aboutToClose.connect(self.saveMdiState, Qt.UniqueConnection) else: self.activeApp = None def _aboutPython(self): piplic = subprocess.check_output([sys.executable, "-m", "piplicenses", "--format=plain"], encoding="utf-8").replace("\n", "<br>").replace(" ", "&nbsp;") piplic = piplic.replace("<br>", "<br><br>", 1) msgBox = QMessageBox() msgBox.setText("This program uses <b>python</b> %(version)s. The used packages are listed below." % dict(version=sys.version, table=piplic)) view = QScrollArea(msgBox) label = QLabel("<pre>%(table)s</pre>" % dict(version=sys.version, table=piplic), msgBox) label.setTextInteractionFlags(Qt.TextSelectableByKeyboard|Qt.TextSelectableByMouse) view.setWidgetResizable(True) view.setWidget(label) msgBox.layout().addWidget(view, 3, 0, 1, msgBox.layout().columnCount()) msgBox.exec_()