diff --git a/meshroom/ui/app.py b/meshroom/ui/app.py index 03b907b867..132431d3aa 100644 --- a/meshroom/ui/app.py +++ b/meshroom/ui/app.py @@ -18,7 +18,7 @@ from meshroom.core.desc import NodeVersionTypeEnum from meshroom.core.submitter import BaseSubmitter from meshroom.core.taskManager import TaskManager -from meshroom.common import Property, Variant, Signal, Slot +from meshroom.common import Property, Variant, Signal, Slot, strtobool from meshroom.env import EnvVar, EnvVarHelpAction @@ -36,6 +36,9 @@ from meshroom.ui import commands +QML_DIR = os.path.join(os.path.dirname(__file__), "qml") + + class FileStatus(Enum): MISSING=0 EXISTS=1 @@ -233,7 +236,8 @@ def __init__(self, inputArgs): self.debugger = QQmlDebuggingEnabler(printWarning=True) qtArgs = [f"-qmljsdebugger={debuggerParams}"] - logging.getLogger().setLevel(meshroom.logStringToPython[args.verbose]) + verbosity = meshroom.logStringToPython[args.verbose] + logging.getLogger().setLevel(verbosity) super().__init__(inputArgs[:1] + qtArgs) @@ -271,25 +275,13 @@ def __init__(self, inputArgs): components.registerTypes() # QML engine setup - qmlDir = os.path.join(pwd, "qml") - url = os.path.join(qmlDir, "main.qml") - self.engine = QmlInstantEngine() - self.engine.addFilesFromDirectory(qmlDir, recursive=True) - self.engine.setWatching(os.environ.get("MESHROOM_INSTANT_CODING", False)) - # whether to output qml warnings to stderr (disable by default) - self.engine.setOutputWarningsToStandardError(MessageHandler.outputQmlWarnings) - if QtCore.__version_info__ < (5, 14, 2): - # After 5.14.1, it gets stuck during logging - qInstallMessageHandler(MessageHandler.handler) + url = os.path.normpath(os.path.join(QML_DIR, "main.qml")) - self.engine.addImportPath(qmlDir) - # Add PySide6's bundled QML modules path (Qt3D, QtQuick.Scene3D, etc.) - pyside6QmlPath = os.path.join(os.path.dirname(QtCore.__file__), "Qt", "qml") - if os.path.isdir(pyside6QmlPath): - self.engine.addImportPath(pyside6QmlPath) - - # expose available node types that can be instantiated - self.engine.rootContext().setContextProperty("_nodeTypes", {n: {"category": pluginManager.getRegisteredNodePlugins()[n].nodeDescriptor.category} for n in sorted(pluginManager.getRegisteredNodePlugins().keys())}) + watching = bool(strtobool(os.environ.get("MESHROOM_INSTANT_CODING", "0"))) + self.engine = QmlInstantEngine(url, self.setupEngine, watching=watching) + if verbosity < logging.INFO: + self.engine.setVerbose(True) + self.engine.addFilesFromDirectory(QML_DIR, recursive=True) # instantiate the 3D Scene object self._undoStack = commands.UndoStack(self) @@ -297,27 +289,19 @@ def __init__(self, inputArgs): self._taskManager = TaskManager(self) self._activeProject = Scene(undoStack=self._undoStack, taskManager=self._taskManager, defaultPipeline=args.pipeline, parent=self) self._activeProject.setSubmitLabel(args.submitLabel) - self.engine.rootContext().setContextProperty("_currentScene", self._activeProject) - - # those helpers should be available from QML Utils module as singletons, but: - # - qmlRegisterUncreatableType is not yet available in PySide2 - # - declaring them as singleton in qmldir file causes random crash at exit - # => expose them as context properties instead - self.engine.rootContext().setContextProperty("Filepath", FilepathHelper(parent=self)) - self.engine.rootContext().setContextProperty("Scene3DHelper", Scene3DHelper(parent=self)) - self.engine.rootContext().setContextProperty("Transformations3DHelper", Transformations3DHelper(parent=self)) - self.engine.rootContext().setContextProperty("Clipboard", ClipboardHelper(parent=self)) - self.engine.rootContext().setContextProperty("ThumbnailCache", ThumbnailCache(parent=self)) - self.engine.rootContext().setContextProperty("ShapeFilesHelper", ShapeFilesHelper(self.activeProject, parent=self)) - self.engine.rootContext().setContextProperty("ShapeViewerHelper", ShapeViewerHelper(parent=self)) - # additional context properties + # helper objects + self._filepathHelper = FilepathHelper(parent=self) + self._scene3DHelper = Scene3DHelper(parent=self) + self._transformations3DHelper = Transformations3DHelper(parent=self) + self._clipboardHelper = ClipboardHelper(parent=self) + self._thumbnailCache = ThumbnailCache(parent=self) + self._shapeFilesHelper = ShapeFilesHelper(self.activeProject, parent=self) + self._shapeViewerHelper = ShapeViewerHelper(parent=self) self._messageController = MessageController(parent=self) - self.engine.rootContext().setContextProperty("_messageController", self._messageController) - self.engine.rootContext().setContextProperty("_PaletteManager", PaletteManager(self.engine, parent=self)) - self.engine.rootContext().setContextProperty("ScriptEditorManager", ScriptEditorManager(parent=self)) - self.engine.rootContext().setContextProperty("MeshroomApp", self) - self.engine.rootContext().setContextProperty("NodeVersionType", NodeVersionTypeEnum(parent=self)) + self._paletteManager = PaletteManager(self.engine, parent=self) + self._scriptEditorManager = ScriptEditorManager(parent=self) + self._nodeVersionType = NodeVersionTypeEnum(parent=self) # request any potential computation to stop on exit self.aboutToQuit.connect(self._activeProject.stopChildThreads) @@ -374,7 +358,49 @@ def __init__(self, inputArgs): self._activeProject.saveAs(args.save) self.addRecentProjectFile(args.save) - self.engine.load(os.path.normpath(url)) + self.engine.reload() + + def setupEngine(self, engine): + """Setup context properties and QML paths on the engine setup. + Runs on every build of the engine (first load + hot reloads). + """ + + # whether to output qml warnings to stderr (disable by default) + engine.setOutputWarningsToStandardError(MessageHandler.outputQmlWarnings) + if QtCore.__version_info__ < (5, 14, 2): + # After 5.14.1, it gets stuck during logging + qInstallMessageHandler(MessageHandler.handler) + + engine.addImportPath(QML_DIR) + # Add PySide6's bundled QML modules path (Qt3D, QtQuick.Scene3D, etc.) + pyside6QmlPath = os.path.join(os.path.dirname(QtCore.__file__), "Qt", "qml") + if os.path.isdir(pyside6QmlPath): + engine.addImportPath(pyside6QmlPath) + + # expose available node types that can be instantiated + engine.rootContext().setContextProperty("_nodeTypes", {n: {"category": pluginManager.getRegisteredNodePlugins()[n].nodeDescriptor.category} for n in sorted(pluginManager.getRegisteredNodePlugins().keys())}) + # expose the Scene object to access the graph + engine.rootContext().setContextProperty("_currentScene", self._activeProject) + + # those helpers should be available from QML Utils module as singletons, but: + # - qmlRegisterUncreatableType is not yet available in PySide2 + # - declaring them as singleton in QML_DIR file causes random crash at exit + # => expose them as context properties instead + engine.rootContext().setContextProperty("Filepath", self._filepathHelper) + engine.rootContext().setContextProperty("Scene3DHelper", self._scene3DHelper) + engine.rootContext().setContextProperty("Transformations3DHelper", self._transformations3DHelper) + engine.rootContext().setContextProperty("Clipboard", self._clipboardHelper) + engine.rootContext().setContextProperty("ThumbnailCache", self._thumbnailCache) + engine.rootContext().setContextProperty("ShapeFilesHelper", self._shapeFilesHelper) + engine.rootContext().setContextProperty("ShapeViewerHelper", self._shapeViewerHelper) + + + # additional context properties + engine.rootContext().setContextProperty("_messageController", self._messageController) + engine.rootContext().setContextProperty("_PaletteManager", self._paletteManager) + engine.rootContext().setContextProperty("ScriptEditorManager", self._scriptEditorManager) + engine.rootContext().setContextProperty("NodeVersionType", self._nodeVersionType) + engine.rootContext().setContextProperty("MeshroomApp", self) def terminateManual(self): self.engine.clearComponentCache() diff --git a/meshroom/ui/palette.py b/meshroom/ui/palette.py index c233c43015..50c92c31a7 100644 --- a/meshroom/ui/palette.py +++ b/meshroom/ui/palette.py @@ -1,4 +1,4 @@ -from PySide6.QtCore import QObject, Qt, Slot, Property, Signal +from PySide6.QtCore import QObject, Qt, Slot, Property, Signal, QTimer from PySide6.QtGui import QPalette, QColor from PySide6.QtWidgets import QApplication @@ -53,9 +53,9 @@ def togglePalette(self): app.setPalette(self.defaultPalette) else: app.setPalette(self.darkPalette) - if self.qmlEngine.rootObjects(): - self.qmlEngine.reload() self.paletteChanged.emit() + if self.qmlEngine.rootObjects(): + QTimer.singleShot(0, self.qmlEngine.reload) paletteChanged = Signal() palette = Property(QPalette, lambda self: QApplication.instance().palette(), notify=paletteChanged) diff --git a/meshroom/ui/utils.py b/meshroom/ui/utils.py index 4927c7d5c8..f9fa21f26c 100755 --- a/meshroom/ui/utils.py +++ b/meshroom/ui/utils.py @@ -9,49 +9,46 @@ import shiboken6 -class QmlInstantEngine(QQmlApplicationEngine): +class QmlInstantEngine(QObject): """ QmlInstantEngine is a utility class helping to develop QML applications. It reloads itself whenever one of the watched source files is modified. As it consumes resources, make sure to disable file watching in production mode. """ - def __init__(self, sourceFile="", watching=True, verbose=False, parent=None): + def __init__(self, sourceFile, setupEngine, watching=True, verbose=False, parent=None): """ - watching -- Defines whether the watcher is active (default: True) - verbose -- if True, output log information (default: False) + sourceFile -- Main QML file. + setupEngine -- Callback to call on reload after creating the engine (for hot-reload). + watching -- Defines whether the watcher is active (default: True) + verbose -- if True, output log information (default: False) """ super().__init__(parent) self._fileWatcher = QFileSystemWatcher() # Internal Qt File Watcher - self._sourceFile = "" + self._sourceFile = str(sourceFile) or "" self._watchedFiles = [] # Internal watched files list - self._verbose = verbose # Verbose bool - self._watching = False # - self._extensions = ["qml", "js"] # File extensions that defines files to watch when adding a folder + self._verbose = verbose # Verbose bool + self._watching = False + self._extensions = ["qml", "js"] + # Callback to call for the engine setup (set the context properties, etc) + self._setupEngine = setupEngine + self._engine = QQmlApplicationEngine() self._rootItem = None - def onObjectCreated(root, url): - if not root: - return - # Restore root item geometry - if self._rootItem: - root.setGeometry(self._rootItem.geometry()) - self._rootItem.deleteLater() - self._rootItem = root - - self.objectCreated.connect(onObjectCreated) + # Add a single shot timer to launch the reload after all events are processed + self._debounceTimer = QTimer(singleShot=True, interval=100) + self._debounceTimer.timeout.connect(self.reload) # Update the watching status self.setWatching(watching) - if sourceFile: - self.load(sourceFile) - - def load(self, sourceFile): - self._sourceFile = sourceFile - super().load(sourceFile) + def __getattr__(self, name): + engine = self.__dict__.get("_engine") + if engine is not None and hasattr(engine, name): + return getattr(engine, name) + raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}") def setWatching(self, watchValue): """ @@ -145,8 +142,8 @@ def addFilesFromDirectory(self, dirname, recursive=False): self.addFile(os.path.join(dirpath, filename)) else: filenames = os.listdir(dirname) - filenames = [os.path.join(dirname, filename) for filename in filenames if - os.path.splitext(filename)[1][1:] in self._extensions] + filenames = [os.path.join(dirname, f) for f in filenames if + os.path.splitext(f)[1][1:] in self._extensions] self.addFiles(filenames) def removeFile(self, filename): @@ -173,28 +170,76 @@ def onFileChanged(self, filepath): if self._verbose: print("Source file changed : ", filepath) - # Clear the QQuickEngine cache - self.clearComponentCache() - # Remove the modified file from the watched list - self.removeFile(filepath) - cptTry = 0 + # Re-add file before debounce + if os.path.isfile(filepath): + self._fileWatcher.addPath(filepath) + self._debounceTimer.start() - # Make sure file is available before doing anything - # NOTE: useful to handle editors (Qt Creator) that deletes the source file and - # creates a new one when saving - while not os.path.exists(filepath) and cptTry < 10: - time.sleep(0.1) - cptTry += 1 + def rootContext(self): + """ Context of the currently-live engine (None before the first reload). """ + return self._engine.rootContext() if self._engine else None - self.reload() + @property + def engine(self): + return self._engine - # Finally, re-add the modified file to the watch system - # after a short cooldown to avoid multiple consecutive reloads - QTimer.singleShot(200, lambda: self.addFile(filepath)) + @property + def rootItem(self): + return self._rootItem def reload(self): print(f"Reloading {self._sourceFile}") - self.load(self._sourceFile) + + # Preserve window geometry across the swap. + oldPos, oldSize = None, None + if self._rootItem is not None and shiboken6.isValid(self._rootItem): + try: + oldPos = self._rootItem.position() + oldSize = self._rootItem.size() + except AttributeError: + pass + + # Destroy old root item and engine before building the new one. + if self._rootItem is not None and shiboken6.isValid(self._rootItem): + shiboken6.delete(self._rootItem) + self._rootItem = None + + if self._engine is not None and shiboken6.isValid(self._engine): + shiboken6.delete(self._engine) + self._engine = None + + # Build the new engine and load. + engine = QQmlApplicationEngine() + self._setupEngine(engine) + + def onObjectCreated(root, url): + if root is None: + print(f"Failed to load {url.toString()} - check QML warnings above.") + return + self._rootItem = root + if oldPos is not None: + root.setPosition(oldPos) + if oldSize is not None: + root.resize(oldSize) + + engine.objectCreated.connect(onObjectCreated) + engine.load(QUrl.fromLocalFile(self._sourceFile)) + engine.objectCreated.disconnect(onObjectCreated) + + self._engine = engine + + def clearComponentCache(self): + if self._engine: + self._engine.clearComponentCache() + + def collectGarbage(self): + if self._engine: + self._engine.collectGarbage() + + def deleteLater(self): + if self._engine: + self._engine.deleteLater() + super().deleteLater() def makeProperty(T, attributeName, notify=None, resetOnDestroy=False):