From edb07ed99d10224739015244d386cea3df00f241 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sat, 19 Feb 2022 10:46:53 +0100 Subject: [PATCH 1/6] fix https://github.com/ifm/nexxT/issues/41 --- nexxT/services/gui/BrowserWidget.py | 34 +++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/nexxT/services/gui/BrowserWidget.py b/nexxT/services/gui/BrowserWidget.py index 82cc73a..866ca68 100644 --- a/nexxT/services/gui/BrowserWidget.py +++ b/nexxT/services/gui/BrowserWidget.py @@ -18,12 +18,28 @@ logger = logging.getLogger(__name__) +class StatCache: + MAX_NUM_CACHE_ENTRIES = 20*1024 # 1024 entries are ~40 kB -> ~ 1 MB cache + + def __init__(self): + self._cache = {} + + def __call__(self, method, *args): + if (method, args) not in self._cache: + # remove entries from cache until the size is within reasonable limits + while len(self._cache) > self.MAX_NUM_CACHE_ENTRIES: + self._cache.pop(next(iter(self._cache))) + self._cache[method, args] = method(*args) + return self._cache[method, args] + class FolderListModel(QAbstractTableModel): """ This class provides a model for browsing a folder. """ folderChanged = Signal(str) # emitted when the folder changes - + + statCache = StatCache() + def __init__(self, parent): super().__init__(parent=parent) self._folder = None @@ -68,7 +84,7 @@ def fileToIndex(self, filename): return QModelIndex() def _match(self, path): - if path.is_dir(): + if self.statCache(path.is_dir): return True res = QDir.match(self._filter, path.name) return res @@ -79,7 +95,7 @@ def _reset(self, folder, flt): self.endRemoveRows() if folder is not None: listDrives = False - f = Path(folder).resolve() + f = self.statCache(Path(folder).resolve) if platform.system() == "Windows": folder = Path(folder) if folder.name == ".." and folder.parent == Path(folder.drive + "/"): @@ -89,20 +105,20 @@ def _reset(self, folder, flt): self._filter = flt if platform.system() == "Windows": if listDrives: - self._children = [Path("%s:/" % dl) for dl in string.ascii_uppercase if Path("%s:/" % dl).exists()] + self._children = [Path("%s:/" % dl) for dl in string.ascii_uppercase if self.statCache(Path("%s:/" % dl).exists)] else: self._children = [f / ".."] else: self._children = ([] if f.root == f else [f / ".."]) if not listDrives: self._children += [x for x in f.glob("*") if self._match(x)] - self._children.sort(key=lambda c: (c.is_file(), c.drive, int(c.name != ".."), c.name)) + self._children.sort(key=lambda c: (self.statCache(c.is_file), c.drive, int(c.name != ".."), c.name)) self.beginInsertRows(QModelIndex(), 0, len(self._children)-1) self.endInsertRows() if listDrives: self.folderChanged.emit("") else: - self.folderChanged.emit(str(self._folder) + (os.path.sep if self._folder.is_dir() else "")) + self.folderChanged.emit(str(self._folder) + (os.path.sep if self.statCache(self._folder.is_dir) else "")) def folder(self): """ @@ -154,7 +170,7 @@ def data(self, index, role): if c.is_dir(): return "" try: - s = c.stat().st_size + s = self.statCache(c.stat).st_size except Exception: # pylint: disable=broad-except return "" if s >= 1024*1024*1024: @@ -171,7 +187,7 @@ def data(self, index, role): return "" if role == Qt.DecorationRole: if index.column() == 0: - if c.is_dir(): + if self.statCache(c.is_dir): return self._iconProvider.icon(QFileIconProvider.Drive) return self._iconProvider.icon(QFileInfo(str(c.absolute()))) if role == Qt.UserRole: @@ -180,7 +196,7 @@ def data(self, index, role): if role in [Qt.DisplayRole, Qt.EditRole]: if index.column() == 3: if index.row() > 0: - return str(c) + (os.path.sep if c.is_dir() else "") + return str(c) + (os.path.sep if self.statCache(c.is_dir) else "") return str(c.parent) + os.path.sep return None From 41ca60645605da860a889322ebb445b38867a412 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 20 Feb 2022 15:59:53 +0100 Subject: [PATCH 2/6] add test case for https://github.com/ifm/nexxT/issues/40 and fix the issue --- nexxT/core/FilterMockup.py | 13 +++ nexxT/core/Graph.py | 9 +- nexxT/core/PropertyCollectionImpl.py | 14 ++- nexxT/core/SubConfiguration.py | 17 +-- nexxT/interface/PropertyCollections.py | 5 +- nexxT/tests/core/test_ConfigFiles.py | 1 + nexxT/tests/integration/test_gui.py | 138 +++++++++++++++++++++++++ 7 files changed, 185 insertions(+), 12 deletions(-) diff --git a/nexxT/core/FilterMockup.py b/nexxT/core/FilterMockup.py index 7e2c347..dc97ab3 100644 --- a/nexxT/core/FilterMockup.py +++ b/nexxT/core/FilterMockup.py @@ -33,6 +33,19 @@ def __init__(self, library, factoryFunction, propertyCollection, graph): self._propertyCollectionImpl = propertyCollection self._pluginClass = None self._createFilterAndUpdatePending = None + rootPc = propertyCollection + while rootPc.parent() is not None: + rootPc = rootPc.parent() + tmpRootPc = PropertyCollectionImpl("root", None) + try: + cfgfile = rootPc.getProperty("CFGFILE") + tmpRootPc.defineProperty("CFGFILE", cfgfile, "copy of original CFGFILE.", options=dict(enum=[cfgfile])) + except: + pass + tmpPc = PropertyCollectionImpl("temp", tmpRootPc) + with FilterEnvironment(self._library, self._factoryFunction, tmpPc, self) as tmpEnv: + self.updatePortInformation(tmpEnv) + del tmpPc try: # add also a child collection for the nexxT internals pc = PropertyCollectionImpl("_nexxT", propertyCollection) diff --git a/nexxT/core/Graph.py b/nexxT/core/Graph.py index 071717f..e2a62b8 100644 --- a/nexxT/core/Graph.py +++ b/nexxT/core/Graph.py @@ -75,7 +75,8 @@ def cleanup(self): # BaseGraph gets the node name, this method gets arguments # for constructing a filter @Slot(str, str, object) - def addNode(self, library, factoryFunction, suggestedName=None): + def addNode(self, library, factoryFunction, suggestedName=None, + dynamicInputPorts=[], dynamicOutputPorts=[]): """ Add a node to the graph, given a library and a factory function for instantiating the plugin. :param library: definition file of the plugin @@ -93,8 +94,12 @@ def addNode(self, library, factoryFunction, suggestedName=None): propColl = PropertyCollectionImpl(name, self._properties) propColl.propertyChanged.connect(self.setDirty) filterMockup = FilterMockup(library, factoryFunction, propColl, self) - filterMockup.createFilterAndUpdate() self._filters[name] = filterMockup + for din in dynamicInputPorts: + self.addDynamicInputPort(name, din) + for dout in dynamicOutputPorts: + self.addDynamicOutputPort(name, dout) + filterMockup.createFilterAndUpdate() assert super().addNode(name) == name if factoryFunction == "compositeNode" and hasattr(library, "checkRecursion"): try: diff --git a/nexxT/core/PropertyCollectionImpl.py b/nexxT/core/PropertyCollectionImpl.py index 44e8e19..960391a 100644 --- a/nexxT/core/PropertyCollectionImpl.py +++ b/nexxT/core/PropertyCollectionImpl.py @@ -115,12 +115,17 @@ def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandle "Pass either options or propertyHandler to defineProperty but not both.") if options is None: options = {} + ignoreInconsistentOptions = False + if "ignoreInconsistentOptions" in options: + ignoreInconsistentOptions = options["ignoreInconsistentOptions"] + del options["ignoreInconsistentOptions"] if propertyHandler is None: propertyHandler = defaultHandler(defaultVal)(options) assert isinstance(propertyHandler, PropertyHandler) assert isinstance(options, dict) if propertyHandler.validate(defaultVal) != defaultVal: - raise PropertyInconsistentDefinition("The validation of the default value must be the identity!") + raise PropertyInconsistentDefinition("The validation of the default value must be the identity (%s != %s)!" % ( + repr(propertyHandler.validate(defaultVal)), repr(defaultVal))) if not name in self._properties: self._properties[name] = Property(defaultVal, helpstr, propertyHandler) p = self._properties[name] @@ -138,7 +143,12 @@ def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandle if p.defaultVal != defaultVal or p.helpstr != helpstr: raise PropertyInconsistentDefinition(name) if not isinstance(p.handler, type(propertyHandler)) or options != p.handler.options(): - raise PropertyInconsistentDefinition(name) + if ignoreInconsistentOptions: + p.handler = propertyHandler + logger.debug("option %s has inconsistent options but ignoring as requested.", name) + else: + raise PropertyInconsistentDefinition(name) + p.used = True return p.value diff --git a/nexxT/core/SubConfiguration.py b/nexxT/core/SubConfiguration.py index a4ca3cf..3e2b898 100644 --- a/nexxT/core/SubConfiguration.py +++ b/nexxT/core/SubConfiguration.py @@ -123,24 +123,27 @@ def load(self, cfg, compositeLookup): # apply node gui state nextP = PropertyCollectionImpl("_nexxT", p, {"thread": n["thread"]}) logger.debug("loading: subconfig %s / node %s -> thread: %s", self._name, n["name"], n["thread"]) - tmp = self._graph.addNode(n["library"], n["factoryFunction"], suggestedName=n["name"]) + tmp = self._graph.addNode(n["library"], n["factoryFunction"], suggestedName=n["name"], + dynamicInputPorts=n["dynamicInputPorts"], + dynamicOutputPorts=n["dynamicOutputPorts"]) if tmp != n["name"]: raise NexTInternalError("addNode(...) has set unexpected name for node.") else: # composite node handling if n["library"] == "composite://port": # the special nodes are already there, nothing to do here - pass + for dip in n["dynamicInputPorts"]: + self._graph.addDynamicInputPort(n["name"], dip) + for dop in n["dynamicOutputPorts"]: + self._graph.addDynamicOutputPort(n["name"], dop) elif n["library"] == "composite://ref": name = n["factoryFunction"] cf = compositeLookup(name) - tmp = self._graph.addNode(cf, "compositeNode", suggestedName=n["name"]) + tmp = self._graph.addNode(cf, "compositeNode", suggestedName=n["name"], + dynamicInputPorts=n["dynamicInputPorts"], + dynamicOutputPorts=n["dynamicOutputPorts"]) if tmp != n["name"]: raise NexTInternalError("addNode(...) has set unexpected name for node.") - for dip in n["dynamicInputPorts"]: - self._graph.addDynamicInputPort(n["name"], dip) - for dop in n["dynamicOutputPorts"]: - self._graph.addDynamicOutputPort(n["name"], dop) # make sure that the filter is instantiated and the port information is updated immediately self._graph.getMockup(n["name"]).createFilterAndUpdate() for c in cfg["connections"]: diff --git a/nexxT/interface/PropertyCollections.py b/nexxT/interface/PropertyCollections.py index 3c3341d..30b7792 100644 --- a/nexxT/interface/PropertyCollections.py +++ b/nexxT/interface/PropertyCollections.py @@ -182,7 +182,10 @@ def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandle :param defaultVal: the default value of the property. Note that this value will be used to determine the property's type. Currently supported types are string, int and float :param helpstr: a help string for the user (presented as a tool tip) - :param options: a dict mapping string to qvariant (common options: min, max, enum) + :param options: a dict mapping string to qvariant (common options: 'min', 'max', 'enum') + all properties support the option 'ignoreInconsistentOptions' (default: False). If this option + is True, then nexxT allows that the options change over time. Even if present, the option type + and its default values are not allowed to change. :param propertyHandler: a PropertyHandler instance, or None for automatic choice according to defaultVal :return: the current value of this property """ diff --git a/nexxT/tests/core/test_ConfigFiles.py b/nexxT/tests/core/test_ConfigFiles.py index c2a8d75..ada1e0a 100644 --- a/nexxT/tests/core/test_ConfigFiles.py +++ b/nexxT/tests/core/test_ConfigFiles.py @@ -95,4 +95,5 @@ def test_smoke(): simple_setup(4) if __name__ == "__main__": + setup() test_smoke() diff --git a/nexxT/tests/integration/test_gui.py b/nexxT/tests/integration/test_gui.py index a88e14b..c675396 100644 --- a/nexxT/tests/integration/test_gui.py +++ b/nexxT/tests/integration/test_gui.py @@ -328,6 +328,44 @@ def setFilterProperty(self, conf, subConfig, filterName, propName, propVal, expe expectedVal = propVal assert conf.model.data(idxPropVal, Qt.DisplayRole) == expectedVal + def getFilterProperty(self, conf, subConfig, filterName, propName): + """ + Sets a filter property in the configuration gui service. + :param conf: the configuration gui service + :param subConfig: the SubConfiguration instance + :param filterName: the name of the filter + :param propName: the name of the property + :return: the current property value + """ + idxapp = conf.model.indexOfSubConfig(subConfig) + # search for filter + idxFilter = None + for r in range(conf.model.rowCount(idxapp)): + idxFilter = conf.model.index(r, 0, idxapp) + name = conf.model.data(idxFilter, Qt.DisplayRole) + if name == filterName: + break + else: + idxFilter = None + assert idxFilter is not None + # search for property + idxProp = None + row = None + for r in range(conf.model.rowCount(idxFilter)): + idxProp = conf.model.index(r, 0, idxFilter) + name = conf.model.data(idxProp, Qt.DisplayRole) + if name == propName: + row = r + break + else: + idxProp = None + assert idxProp is not None + assert row is not None + # start the editor by pressing F2 on the property value + idxPropVal = conf.model.index(row, 1, idxFilter) + return conf.model.data(idxPropVal, Qt.DisplayRole) + + def getLastLogFrameIdx(self, log): """ Convert the last received log line to a frame index (assuming that the PySimpleStaticFilter has been used) @@ -904,6 +942,92 @@ def __init__(self, env): QTimer.singleShot(self.delay, self.clickDiscardChanges) mw.close() + def _dynamic_properties(self): + conf = None + mw = None + thefilter_py = (Path(self.tmpdir) / "thedynfilter.py") + thefilter_py.write_text( +""" +from nexxT.interface import Filter + +class TheDynFilter(Filter): + def __init__(self, env): + super().__init__(True, True, env) + + def onInit(self): + pc = self.propertyCollection() + din = self.getDynamicInputPorts() + dout = self.getDynamicOutputPorts() + pc.defineProperty("enum_input_ports", "(none)", "help", + dict(enum=["(none)"] +[p.name() for p in din], ignoreInconsistentOptions=True)) + pc.defineProperty("enum_output_ports", "(none)", "help", + dict(enum=["(none)"] +[p.name() for p in dout], ignoreInconsistentOptions=True)) +""" + ) + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxComposites = conf.model.index(0, 0) + idxApplications = conf.model.index(1, 0) + # add application + conf.treeView.setMinimumSize(QSize(300, 300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + # create a node "TheFilter" + the_filter = self.addNodeToGraphEditor(gev, QPoint(20, 20), + CM_FILTER_FROM_FILE, str(thefilter_py), "TheDynFilter") + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNINPORT)) + QTimer.singleShot(self.delay * 2, lambda: self.enterText("input_port_1")) + self.gsContextMenu(gev, the_filter.nodeGrItem.sceneBoundingRect().center()) + + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNINPORT)) + QTimer.singleShot(self.delay * 2, lambda: self.enterText("input_port_2")) + self.gsContextMenu(gev, the_filter.nodeGrItem.sceneBoundingRect().center()) + + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNOUTPORT)) + QTimer.singleShot(self.delay * 2, lambda: self.enterText("output_port")) + self.gsContextMenu(gev, the_filter.nodeGrItem.sceneBoundingRect().center()) + + logger.info("Filter: %s", repr(the_filter)) + self.setFilterProperty(conf, app, "TheDynFilter", "enum_input_ports", + [Qt.Key_Down, Qt.Key_Return], "input_port_1") + self.setFilterProperty(conf, app, "TheDynFilter", "enum_input_ports", + [Qt.Key_Down, Qt.Key_Return], "input_port_2") + self.setFilterProperty(conf, app, "TheDynFilter", "enum_output_ports", + [Qt.Key_Down, Qt.Key_Return], "output_port") + + cfgfile = str((Path(self.tmpdir) / "dynprops.json").absolute()) + QTimer.singleShot(self.delay, lambda: self.enterText(cfgfile)) + conf.actSave.trigger() + + logger.info("saved application") + + QTimer.singleShot(self.delay, lambda: self.enterText(cfgfile)) + conf.actLoad.trigger() + + logger.info("loaded application") + app = conf.configuration().applicationByName("application") + assert self.getFilterProperty(conf, app, "TheDynFilter", "enum_input_ports") == "input_port_2" + assert self.getFilterProperty(conf, app, "TheDynFilter", "enum_output_ports") == "output_port" + + + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + def test(self): """ test property editing in config editor @@ -912,12 +1036,26 @@ def test(self): QTimer.singleShot(self.delay, self._properties) startNexT(None, None, [], [], True) + def test_dyn(self): + """ + test property editing in config editor + :return: + """ + QTimer.singleShot(self.delay, self._dynamic_properties) + startNexT(None, None, [], [], True) + @pytest.mark.gui @pytest.mark.parametrize("delay", [300]) def test_properties(qtbot, xvfb, keep_open, delay, tmpdir): test = PropertyTest(qtbot, xvfb, keep_open, delay, tmpdir) test.test() +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_dyn_properties(qtbot, xvfb, keep_open, delay, tmpdir): + test = PropertyTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test_dyn() + class GuiStateTest(GuiTestBase): """ Concrete test class for the guistate test case From b072773c98efa8f0c391d2d29ec5b8e7997e3976 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Mon, 21 Feb 2022 19:07:59 +0100 Subject: [PATCH 3/6] fix pylint findings --- nexxT/core/FilterMockup.py | 5 +++-- nexxT/core/Graph.py | 6 +++++- nexxT/core/PropertyCollectionImpl.py | 5 +++-- nexxT/services/gui/BrowserWidget.py | 15 ++++++++++----- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/nexxT/core/FilterMockup.py b/nexxT/core/FilterMockup.py index dc97ab3..86d23b0 100644 --- a/nexxT/core/FilterMockup.py +++ b/nexxT/core/FilterMockup.py @@ -13,7 +13,8 @@ from nexxT.interface import InputPort, OutputPort, InputPortInterface, OutputPortInterface from nexxT.core.FilterEnvironment import FilterEnvironment from nexxT.core.PropertyCollectionImpl import PropertyCollectionImpl -from nexxT.core.Exceptions import PortNotFoundError, PortExistsError, PropertyCollectionChildExists +from nexxT.core.Exceptions import (PortNotFoundError, PortExistsError, PropertyCollectionChildExists, + PropertyCollectionPropertyNotFound) from nexxT.core.Utils import assertMainThread, MethodInvoker import nexxT @@ -40,7 +41,7 @@ def __init__(self, library, factoryFunction, propertyCollection, graph): try: cfgfile = rootPc.getProperty("CFGFILE") tmpRootPc.defineProperty("CFGFILE", cfgfile, "copy of original CFGFILE.", options=dict(enum=[cfgfile])) - except: + except PropertyCollectionPropertyNotFound: pass tmpPc = PropertyCollectionImpl("temp", tmpRootPc) with FilterEnvironment(self._library, self._factoryFunction, tmpPc, self) as tmpEnv: diff --git a/nexxT/core/Graph.py b/nexxT/core/Graph.py index e2a62b8..03490b6 100644 --- a/nexxT/core/Graph.py +++ b/nexxT/core/Graph.py @@ -76,7 +76,7 @@ def cleanup(self): # for constructing a filter @Slot(str, str, object) def addNode(self, library, factoryFunction, suggestedName=None, - dynamicInputPorts=[], dynamicOutputPorts=[]): + dynamicInputPorts=None, dynamicOutputPorts=None): """ Add a node to the graph, given a library and a factory function for instantiating the plugin. :param library: definition file of the plugin @@ -87,6 +87,10 @@ def addNode(self, library, factoryFunction, suggestedName=None, assertMainThread() if suggestedName is None: suggestedName = factoryFunction + if dynamicInputPorts is None: + dynamicInputPorts = [] + if dynamicOutputPorts is None: + dynamicOutputPorts = [] name = super().uniqueNodeName(suggestedName) try: propColl = self._properties.getChildCollection(name) diff --git a/nexxT/core/PropertyCollectionImpl.py b/nexxT/core/PropertyCollectionImpl.py index 960391a..af9e1d4 100644 --- a/nexxT/core/PropertyCollectionImpl.py +++ b/nexxT/core/PropertyCollectionImpl.py @@ -124,8 +124,9 @@ def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandle assert isinstance(propertyHandler, PropertyHandler) assert isinstance(options, dict) if propertyHandler.validate(defaultVal) != defaultVal: - raise PropertyInconsistentDefinition("The validation of the default value must be the identity (%s != %s)!" % ( - repr(propertyHandler.validate(defaultVal)), repr(defaultVal))) + raise PropertyInconsistentDefinition( + "The validation of the default value must be the identity (%s != %s)!" % + (repr(propertyHandler.validate(defaultVal)), repr(defaultVal))) if not name in self._properties: self._properties[name] = Property(defaultVal, helpstr, propertyHandler) p = self._properties[name] diff --git a/nexxT/services/gui/BrowserWidget.py b/nexxT/services/gui/BrowserWidget.py index 866ca68..9df1a7a 100644 --- a/nexxT/services/gui/BrowserWidget.py +++ b/nexxT/services/gui/BrowserWidget.py @@ -19,11 +19,14 @@ logger = logging.getLogger(__name__) class StatCache: + """ + Class for caching file-system related accesses to prevent unnecessary slowliness for network drives. + """ MAX_NUM_CACHE_ENTRIES = 20*1024 # 1024 entries are ~40 kB -> ~ 1 MB cache def __init__(self): self._cache = {} - + def __call__(self, method, *args): if (method, args) not in self._cache: # remove entries from cache until the size is within reasonable limits @@ -37,9 +40,9 @@ class FolderListModel(QAbstractTableModel): This class provides a model for browsing a folder. """ folderChanged = Signal(str) # emitted when the folder changes - + statCache = StatCache() - + def __init__(self, parent): super().__init__(parent=parent) self._folder = None @@ -105,7 +108,8 @@ def _reset(self, folder, flt): self._filter = flt if platform.system() == "Windows": if listDrives: - self._children = [Path("%s:/" % dl) for dl in string.ascii_uppercase if self.statCache(Path("%s:/" % dl).exists)] + self._children = [Path("%s:/" % dl) for dl in string.ascii_uppercase + if self.statCache(Path("%s:/" % dl).exists)] else: self._children = [f / ".."] else: @@ -118,7 +122,8 @@ def _reset(self, folder, flt): if listDrives: self.folderChanged.emit("") else: - self.folderChanged.emit(str(self._folder) + (os.path.sep if self.statCache(self._folder.is_dir) else "")) + self.folderChanged.emit(str(self._folder) + (os.path.sep if self.statCache(self._folder.is_dir) + else "")) def folder(self): """ From 48bd16eb30e1f4417da26e048c24f4c03ddec39d Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Mon, 28 Mar 2022 11:01:05 +0200 Subject: [PATCH 4/6] Implement a deferred update concept attached to the MainWindow service. Disply widgets can use it by calling the mainwindow's slot mw.deferredUpdate. --- nexxT/examples/framework/ImageView.py | 18 ++++-- nexxT/services/gui/MainWindow.py | 81 ++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/nexxT/examples/framework/ImageView.py b/nexxT/examples/framework/ImageView.py index 248b6db..7e4c974 100644 --- a/nexxT/examples/framework/ImageView.py +++ b/nexxT/examples/framework/ImageView.py @@ -79,6 +79,15 @@ def onClose(self): # delete the widget reference self._widget = None + def interpretAndUpdate(self): + """ + The deferred update method, called from the MainWindow service at user-defined framerate. + """ + sample = self.inPort.getData() + if sample.getDatatype() == "example/image": + npa = byteArrayToNumpy(sample.getContent()) + self._widget.setData(npa) + def onPortDataChanged(self, port): """ Notification of new data. @@ -86,11 +95,10 @@ def onPortDataChanged(self, port): :param port: the port where the data arrived. :return: """ - if port.getData().getDatatype() == "example/image": - # convert to numpy array - npa = byteArrayToNumpy(port.getData().getContent()) - # send to the widget - self._widget.setData(npa) + if self._widget.isVisible(): + # don't consume processing time if not shown + mw = Services.getService("MainWindow") + mw.deferredUpdate(self, "interpretAndUpdate") class DisplayWidget(QWidget): """ diff --git a/nexxT/services/gui/MainWindow.py b/nexxT/services/gui/MainWindow.py index 5ebc572..ee213d9 100644 --- a/nexxT/services/gui/MainWindow.py +++ b/nexxT/services/gui/MainWindow.py @@ -12,14 +12,16 @@ import subprocess import sys import shiboken2 +import time from PySide2.QtWidgets import (QMainWindow, QMdiArea, QMdiSubWindow, QDockWidget, QAction, QWidget, QGridLayout, - QMenuBar, QMessageBox, QScrollArea, QLabel) + QMenuBar, QMessageBox, QScrollArea, QLabel, QActionGroup) from PySide2.QtCore import (QObject, Signal, Slot, Qt, QByteArray, QDataStream, QIODevice, QRect, QPoint, QSettings, QTimer, QUrl, QEvent) from PySide2.QtGui import QDesktopServices import nexxT from nexxT.interface import Filter from nexxT.core.Application import Application +from nexxT.core.Utils import handleException, assertMainThread logger = logging.getLogger(__name__) @@ -174,6 +176,22 @@ def __init__(self, config): 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("%d Hz" % framerate) + 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) + self.menu.addSeparator() self.aboutMenu = QMenuBar(self.menuBar()) self.menuBar().setCornerWidget(self.aboutMenu) m = self.aboutMenu.addMenu("&Help") @@ -200,6 +218,11 @@ def __init__(self, config): 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) def eventFilter(self, obj, event): if obj is self.mdi.viewport() and event.type() == QEvent.Wheel: @@ -207,6 +230,43 @@ def eventFilter(self, obj, event): return True return False + @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() + callable = getattr(obj, slotName) + if callable in self._deferredUpdateHistory: + lastUpdateTime = self._deferredUpdateHistory[callable] + 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(callable) + return + self._deferredUpdateHistory[callable] = time.monotonic_ns() + callable() + + def _deferredUpdateTimeout(self): + t = time.monotonic_ns() + for pu in self._pendingUpdates: + self._deferredUpdateHistory[pu] = t + pu() + self._pendingUpdates.clear() + + def _setFramerate(self, checked): + if checked: + self.framerate = self.sender().data() + def closeEvent(self, closeEvent): """ Override from QMainWindow, saves the state. @@ -240,7 +300,7 @@ def restoreState(self): :return: """ - logger.info("restoring main window's state") + logger.debug("restoring main window's state") settings = QSettings() v = settings.value("MainWindowState") if v is not None: @@ -251,17 +311,32 @@ def restoreState(self): if self.toolbar is not None: self.toolbar.show() + def restoreConfigSpecifics(self): + 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) + def saveState(self): """ saves the state of the main window including the dock windows of Services :return: """ - logger.info("saving main window's state") + logger.debug("saving main window's state") settings = QSettings() settings.setValue("MainWindowState", super().saveState()) settings.setValue("MainWindowGeometry", self.saveGeometry()) + def saveConfigSpecifics(self): + 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) + def saveMdiState(self): """ saves the state of the individual MDI windows From 2c0ded8e472e2d6ff4e03548d1ad67f269b3c624 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Mon, 28 Mar 2022 12:00:44 +0200 Subject: [PATCH 5/6] fix pylint findings --- nexxT/examples/framework/ImageView.py | 2 +- nexxT/services/gui/MainWindow.py | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/nexxT/examples/framework/ImageView.py b/nexxT/examples/framework/ImageView.py index 7e4c974..fe41c39 100644 --- a/nexxT/examples/framework/ImageView.py +++ b/nexxT/examples/framework/ImageView.py @@ -88,7 +88,7 @@ def interpretAndUpdate(self): npa = byteArrayToNumpy(sample.getContent()) self._widget.setData(npa) - def onPortDataChanged(self, port): + def onPortDataChanged(self, port): # pylint: disable=unused-argument """ Notification of new data. diff --git a/nexxT/services/gui/MainWindow.py b/nexxT/services/gui/MainWindow.py index ee213d9..832b6cb 100644 --- a/nexxT/services/gui/MainWindow.py +++ b/nexxT/services/gui/MainWindow.py @@ -11,8 +11,8 @@ import re import subprocess import sys -import shiboken2 import time +import shiboken2 from PySide2.QtWidgets import (QMainWindow, QMdiArea, QMdiSubWindow, QDockWidget, QAction, QWidget, QGridLayout, QMenuBar, QMessageBox, QScrollArea, QLabel, QActionGroup) from PySide2.QtCore import (QObject, Signal, Slot, Qt, QByteArray, QDataStream, QIODevice, QRect, QPoint, QSettings, @@ -244,17 +244,17 @@ def deferredUpdate(self, obj, slotName): @handleException def _deferredUpdate(self, obj, slotName): assertMainThread() - callable = getattr(obj, slotName) - if callable in self._deferredUpdateHistory: - lastUpdateTime = self._deferredUpdateHistory[callable] + clb = getattr(obj, slotName) + if clb in self._deferredUpdateHistory: + lastUpdateTime = self._deferredUpdateHistory[clb] 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(callable) + self._pendingUpdates.add(clb) return - self._deferredUpdateHistory[callable] = time.monotonic_ns() - callable() + self._deferredUpdateHistory[clb] = time.monotonic_ns() + clb() def _deferredUpdateTimeout(self): t = time.monotonic_ns() @@ -312,6 +312,9 @@ def restoreState(self): self.toolbar.show() 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") @@ -332,6 +335,9 @@ def saveState(self): settings.setValue("MainWindowGeometry", self.saveGeometry()) 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) From 9439ab85a874b04ef6d6e34a8b393b3dfa14335f Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Mon, 28 Mar 2022 13:30:39 +0200 Subject: [PATCH 6/6] fix framerate-behaviour with C++ filters --- nexxT/services/gui/MainWindow.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/nexxT/services/gui/MainWindow.py b/nexxT/services/gui/MainWindow.py index 832b6cb..2bd5c9b 100644 --- a/nexxT/services/gui/MainWindow.py +++ b/nexxT/services/gui/MainWindow.py @@ -244,23 +244,26 @@ def deferredUpdate(self, obj, slotName): @handleException def _deferredUpdate(self, obj, slotName): assertMainThread() - clb = getattr(obj, slotName) - if clb in self._deferredUpdateHistory: - lastUpdateTime = self._deferredUpdateHistory[clb] + 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(clb) - return - self._deferredUpdateHistory[clb] = time.monotonic_ns() + self._pendingUpdates.add((obj, slotName)) + #logger.info("Deferred call: %s", (obj, slotName)) + return + #logger.info("Instant call: %s", (obj, slotName)) + self._deferredUpdateHistory[obj, slotName] = time.monotonic_ns() + clb = getattr(obj, slotName) clb() def _deferredUpdateTimeout(self): t = time.monotonic_ns() - for pu in self._pendingUpdates: - self._deferredUpdateHistory[pu] = t - pu() + for obj, slotName in self._pendingUpdates: + clb = getattr(obj, slotName) + self._deferredUpdateHistory[obj, slotName] = t + clb() self._pendingUpdates.clear() def _setFramerate(self, checked):