diff --git a/meshroom/ui/app.py b/meshroom/ui/app.py index 1e6915cbfb..704349743f 100644 --- a/meshroom/ui/app.py +++ b/meshroom/ui/app.py @@ -10,11 +10,11 @@ from PySide2.QtWidgets import QApplication import meshroom -from meshroom.core import pluginManager from meshroom.core.taskManager import TaskManager from meshroom.common import Property, Variant, Signal, Slot from meshroom.ui import components +from meshroom.ui.plugins import NodesPluginManager from meshroom.ui.components.clipboard import ClipboardHelper from meshroom.ui.components.filepath import FilepathHelper from meshroom.ui.components.scene3D import Scene3DHelper, Transformations3DHelper @@ -240,16 +240,16 @@ def __init__(self, args): self.engine.addImportPath(qmlDir) components.registerTypes() - # expose available node types that can be instantiated - nodesDesc = pluginManager.descriptors - self.engine.rootContext().setContextProperty("_nodeTypes", {n: {"category": nodesDesc[n].category} for n in sorted(nodesDesc.keys())}) - # instantiate Reconstruction object self._undoStack = commands.UndoStack(self) self._taskManager = TaskManager(self) self._activeProject = Reconstruction(undoStack=self._undoStack, taskManager=self._taskManager, defaultPipeline=args.pipeline, parent=self) self._activeProject.setSubmitLabel(args.submitLabel) + + # The Plugin manager for UI to communicate with + self._pluginManager = NodesPluginManager(parent=self) self.engine.rootContext().setContextProperty("_reconstruction", self._activeProject) + self.engine.rootContext().setContextProperty("_pluginator", self._pluginManager) # those helpers should be available from QML Utils module as singletons, but: # - qmlRegisterUncreatableType is not yet available in PySide2 diff --git a/meshroom/ui/plugins.py b/meshroom/ui/plugins.py new file mode 100644 index 0000000000..29c5a41b37 --- /dev/null +++ b/meshroom/ui/plugins.py @@ -0,0 +1,120 @@ +""" UI Component for the Plugin System. +""" +# STD +import urllib.parse as _parser + +# Qt +from PySide2.QtCore import Slot, QObject, Property, Signal + +# Internal +from meshroom.core import pluginManager +from meshroom.common import BaseObject, DictModel + + +class Plugin(BaseObject): + """ Representation of a Plugin in UI. + """ + + def __init__(self, descriptor): + """ Constructor. + + Args: + descriptor (NodeDescriptor): A Plugin descriptor. + """ + super().__init__() + + self._descriptor = descriptor + + # Any Node errors + self._nodeErrors = self._errors() + + def _errors(self) -> str: + """ + """ + if not self._descriptor.errors: + return "" + + errors = ["Following parameters have invalid default values/ranges:"] + + # Add the parameters from the node Errors + errors.extend([f"* Param {param}" for param in self._descriptor.errors]) + + return "\n".join(errors) + + @Slot() + def reload(self): + """ Reloads the plugin descriptor. + """ + self._descriptor.reload() + + # Update the Node errors + self._nodeErrors = self._errors() + + name = Property(str, lambda self: self._descriptor.name, constant=True) + documentation = Property(str, lambda self: self._descriptor.documentation, constant=True) + loaded = Property(bool, lambda self: bool(self._descriptor.status), constant=True) + version = Property(str, lambda self: self._descriptor.version, constant=True) + path = Property(str, lambda self: self._descriptor.path, constant=True) + errors = Property(str, lambda self: self._nodeErrors, constant=True) + category = Property(str, lambda self: self._descriptor.category, constant=True) + + + +class NodesPluginManager(QObject): + """ UI Plugin Manager Component. Serves as a Bridge between the core Nodes' Plugin Manager and how the + users interact with it. + """ + + def __init__(self, parent=None): + """ Constructor. + + Keyword Args: + parent (QObject): The Parent for the Plugin Manager. + """ + super().__init__(parent=parent) + + # The core Plugin Manager + self._manager = pluginManager + + # The plugins as a Model which can be communicated to the frontend + self._plugins = DictModel(keyAttrName='name', parent=self) + + # Reset the plugins model + self._reset() + + # Signals + pluginsChanged = Signal() + + # Properties + plugins = Property(BaseObject, lambda self: self._plugins, notify=pluginsChanged) + + # Protected + def _reset(self): + """ Requeries and Resets the Plugins Model from the core Plugin Manager for UI refreshes. + """ + plugins = [Plugin(desc) for desc in self._manager.descriptors.values()] + + # Reset the plugins model + self._plugins.reset(plugins) + + # Public + @Slot(str) + def load(self, directory): + """ Load plugins from a given directory, which serves as a package of Meshroom Node Modules. + + Args: + directory (str): Path to the plugin package to import. + """ + # The incoming directory to this method from the QML FolderDialog component is of the format + # file:///path/to/a/python/package + # Cleanup the provided directory url and convert to a usable Posix path + uri = _parser.urlparse(directory) + + # Load the plugin(s) from the provided directory package + self._manager.load(_parser.unquote(uri.path)) + + # Reset the plugins model + self._reset() + + # Emit that the plugins have now been updated + self.pluginsChanged.emit() diff --git a/meshroom/ui/qml/Application.qml b/meshroom/ui/qml/Application.qml index 147bd0fee6..cce9150823 100644 --- a/meshroom/ui/qml/Application.qml +++ b/meshroom/ui/qml/Application.qml @@ -433,6 +433,17 @@ Page { uigraph: _reconstruction } + PluginManager { + id: pluginManager + manager: _pluginator + + // When a plugin package has been browsed + onBrowsed: { + // Load Plugins + _pluginator.load(directory) + } + } + // Actions Action { @@ -923,6 +934,25 @@ Page { border.color: Qt.darker(activePalette.window, 1.15) } } + + // Button to Launch Plugin Manager + ToolButton { + id: pluginManagerButton + visible: true + text: MaterialIcons.build + font.family: MaterialIcons.fontFamily + font.pointSize: 12 + onClicked: { + pluginManager.open() + } + ToolTip.text: "Plugin Manager" + ToolTip.visible: hovered + + background: Rectangle { + color: pluginManagerButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.15) + border.color: Qt.darker(activePalette.window, 1.15) + } + } } footer: ToolBar { @@ -1156,7 +1186,7 @@ Page { visible: graphEditorPanel.currentTab === 0 uigraph: _reconstruction - nodeTypesModel: _nodeTypes + nodeTypesModel: _pluginator.plugins onNodeDoubleClicked: { _reconstruction.setActiveNode(node); diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index c55ca2e22f..70b20a51ad 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -14,6 +14,40 @@ Item { property variant uigraph: null /// Meshroom ui graph (UIGraph) readonly property variant graph: uigraph ? uigraph.graph : null /// core graph contained in ui graph property variant nodeTypesModel: null /// the list of node types that can be instantiated + + readonly property var nodeCategories: { + // Map to hold the node category: node type + let categories = {} + + for (var i = 0; i < nodeTypesModel.count; i++) { + // The node at the index + let node = nodeTypesModel.at(i) + + // Node Category + let category = node.category; + + // Setup an array to allow node type(s) to be added to the category + if (categories[category] === undefined) { + categories[category] = [] + } + // Add the nodeType to the category which will show up in the Menu + categories[category].push(node.name) + } + + return categories + } + + readonly property var nodeTypes: { + // An array to hold the node Types + let types = [] + + for (var i = 0; i < nodeTypesModel.count; i++) { + types.push(nodeTypesModel.at(i).name) + } + + return types + } + property real maxZoom: 2.0 property real minZoom: 0.1 @@ -223,7 +257,7 @@ Item { Menu { id: newNodeMenu property point spawnPosition - property variant menuKeys: Object.keys(root.nodeTypesModel).concat(Object.values(MeshroomApp.pipelineTemplateNames)) + property variant menuKeys: nodeTypes.concat(Object.values(MeshroomApp.pipelineTemplateNames)) height: searchBar.height + nodeMenuRepeater.height + instantiator.height function createNode(nodeType) { @@ -251,21 +285,10 @@ Item { } function parseCategories() { - // Organize nodes based on their category - // {"category1": ["node1", "node2"], "category2": ["node3", "node4"]} - let categories = {}; - for (const [name, data] of Object.entries(root.nodeTypesModel)) { - let category = data["category"]; - if (categories[category] === undefined) { - categories[category] = [] - } - categories[category].push(name) - } - // Add a "Pipelines" category, filled with the list of templates to create pipelines from the menu - categories["Pipelines"] = MeshroomApp.pipelineTemplateNames + nodeCategories["Pipelines"] = MeshroomApp.pipelineTemplateNames - return categories + return nodeCategories } onVisibleChanged: { diff --git a/meshroom/ui/qml/GraphEditor/PluginManager.qml b/meshroom/ui/qml/GraphEditor/PluginManager.qml new file mode 100644 index 0000000000..28227412f9 --- /dev/null +++ b/meshroom/ui/qml/GraphEditor/PluginManager.qml @@ -0,0 +1,251 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.11 +import Qt.labs.platform 1.0 as Platform +import MaterialIcons 2.2 +import Controls 1.0 +import Utils 1.0 + +/** + * PluginManager displays available plugins within Meshroom context and allows loading new ones. +*/ +Dialog { + id: root + + // the Plugin Manager instance + property var manager + // alias to underlying plugin model + readonly property var pluginsModel: manager ? manager.plugins : undefined + + // Does not allow any other element outside the dialog to be interacted with till the time this window is open + modal: true + + // Positioning of the Dialog in the screen + x: parent.width / 2 - width / 2 + y: parent.height / 2 - height / 2 + + // Bounds + height: 600 + + // Signals + signal browsed(var directory) + + title: "Node Plugin Manager" + + ColumnLayout { + anchors.fill: parent + spacing: 16 + + ListView { + id: listView + + // Bounds + Layout.preferredWidth: 600 + Layout.fillHeight: true + implicitHeight: contentHeight + + clip: true + model: pluginsModel + + ScrollBar.vertical: MScrollBar { id: scrollbar } + + spacing: 4 + headerPositioning: ListView.OverlayHeader + header: Pane { + z: 2 + width: ListView.view.width + padding: 6 + background: Rectangle { color: Qt.darker(parent.palette.window, 1.15) } + RowLayout { + id: headerLabel + width: parent.width + Label { text: "Plugin"; Layout.preferredWidth: 170; font.bold: true } + Label { text: "Status"; Layout.preferredWidth: 70; font.bold: true } + Label { text: "Version"; Layout.preferredWidth: 70; font.bold: true } + } + } + + delegate: RowLayout { + id: pluginDelegate + + property var plugin: object + + width: ListView.view.width - 12 + anchors.horizontalCenter: parent != null ? parent.horizontalCenter : undefined + + Label { + Layout.preferredWidth: 180 + text: pluginDelegate.plugin ? pluginDelegate.plugin.name : "" + + // Mouse Area to allow clicking on the label + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + metadataPane.plugin = pluginDelegate.plugin + metadataPane.visible = true + } + } + + } + Label { + id: status + Layout.preferredWidth: 70 + text: pluginDelegate.plugin && pluginDelegate.plugin.loaded ? MaterialIcons.check : MaterialIcons.clear + color: pluginDelegate.plugin && pluginDelegate.plugin.loaded ? "#4CAF50" : "#F44336" + font.family: MaterialIcons.fontFamily + font.pointSize: 14 + font.bold: true + } + Label { + id: version + Layout.preferredWidth: 70 + text: pluginDelegate.plugin ? pluginDelegate.plugin.version : "" + } + } + } + + // Bottom pane for showing plugin related information + Pane { + id: metadataPane + + // the plugin to display info for + property var plugin + + // Hidden as default + visible: false + + // Bounds + anchors.topMargin: 0 + Layout.fillWidth: true + Layout.preferredHeight: 200 + + // Clip additional content + clip: true + + background: Rectangle { color: Qt.darker(parent.palette.window, 1.15) } + + // Header + Label { id: infoLabel; text: ( metadataPane.plugin ? metadataPane.plugin.name : "Plugin" ) + " Info"; font.bold: true; } + + MaterialToolButton { + id: paneCloser + text: MaterialIcons.close + + // Alignment + anchors.right: parent.right + anchors.verticalCenter: infoLabel.verticalCenter + + onClicked: { metadataPane.visible = false } + } + + ScrollView { + // Bounds + anchors.top: paneCloser.bottom + width: parent.width + height: parent.height - paneCloser.height + + // clip anything going beyond the boundary + clip: true + + background: Rectangle { color: Qt.darker(parent.palette.window, 1.65) } + + Column { + width: parent.width + + RowLayout { + Label { text: "Name:"; Layout.preferredWidth: 100; font.bold: true } + TextArea { text: metadataPane.plugin ? metadataPane.plugin.name : ""; readOnly: true } + } + + // File Path + RowLayout { + Label { text: "File Path:"; Layout.preferredWidth: 100; font.bold: true } + TextArea { + text: metadataPane.plugin ? metadataPane.plugin.path : "" + Layout.preferredWidth: 450 + wrapMode: Text.WordWrap + readOnly: true + selectByMouse: true + } + } + + // Load Status + RowLayout { + Label { text: "Status:"; Layout.preferredWidth: 100; font.bold: true } + TextArea { text: metadataPane.plugin && metadataPane.plugin.loaded ? "Loaded" : "Errored"; readOnly: true } + } + + // Empty + RowLayout { } + + // Load Status + RowLayout { + Label { text: "Documentation:"; Layout.preferredWidth: 100; font.bold: true } + TextArea { + text: metadataPane.plugin ? metadataPane.plugin.documentation : "" + Layout.preferredWidth: 450 + wrapMode: Text.WordWrap + readOnly: true + } + } + + // Empty + RowLayout { } + + RowLayout { + Label { text: "Errors:"; Layout.preferredWidth: 100; font.bold: true } + TextArea { + text: metadataPane.plugin ? metadataPane.plugin.errors : "" + Layout.preferredWidth: 450 + wrapMode: Text.WordWrap + readOnly: true + selectByMouse: true + } + } + } + } + } + } + + /// Buttons footer + footer: DialogButtonBox { + position: DialogButtonBox.Footer + + // Plugin Browser + Button { + text: "Browse" + + onClicked: { + // Show the dialog to allow browsing of plugins package + loadDialog.open() + } + } + + // Close the plugin manager + Button { + text: "Close" + + onClicked: { + root.close() + } + } + } + + /// The widget should only get closed when either Esc is pressed or Close button is clicked + closePolicy: Popup.CloseOnEscape + + // Folder selecting dialog + Platform.FolderDialog { + id: loadDialog + options: Platform.FileDialog.DontUseNativeDialog + + title: "Browse Plugin Package" + acceptLabel: "Select" + + onAccepted: { + // Emit that a directory has been browsed -> for the loading to occur + root.browsed(loadDialog.folder) + } + } +} diff --git a/meshroom/ui/qml/GraphEditor/qmldir b/meshroom/ui/qml/GraphEditor/qmldir index 4a4d4ca460..a4d40f6613 100644 --- a/meshroom/ui/qml/GraphEditor/qmldir +++ b/meshroom/ui/qml/GraphEditor/qmldir @@ -10,6 +10,7 @@ AttributeEditor 1.0 AttributeEditor.qml AttributeItemDelegate 1.0 AttributeItemDelegate.qml CompatibilityBadge 1.0 CompatibilityBadge.qml CompatibilityManager 1.0 CompatibilityManager.qml +PluginManager 1.0 PluginManager.qml singleton GraphEditorSettings 1.0 GraphEditorSettings.qml TaskManager 1.0 TaskManager.qml ScriptEditor 1.0 ScriptEditor.qml \ No newline at end of file