diff --git a/meshroom/ui/components/__init__.py b/meshroom/ui/components/__init__.py index 56da9753c4..a2f943a591 100755 --- a/meshroom/ui/components/__init__.py +++ b/meshroom/ui/components/__init__.py @@ -9,6 +9,7 @@ def registerTypes(): from meshroom.ui.components.geom2D import Geom2D from meshroom.ui.components.scriptEditor import PySyntaxHighlighter from meshroom.ui.components.logLinesModel import LogLinesModel, LogLevelEnum + from meshroom.ui.components.anySetEditor import NodeAttributeEditor from meshroom.core.node import ChunkIndexEnum qmlRegisterType(EdgeMouseArea, "GraphEditor", 1, 0, "EdgeMouseArea") @@ -20,6 +21,7 @@ def registerTypes(): qmlRegisterType(CsvData, "DataObjects", 1, 0, "CsvData") qmlRegisterType(LogLinesModel, "DataObjects", 1, 0, "LogLinesModel") qmlRegisterType(PySyntaxHighlighter, "ScriptEditor", 1, 0, "PySyntaxHighlighter") + qmlRegisterType(NodeAttributeEditor, "GraphEditor", 1, 0, "NodeAttributeEditor") qmlRegisterSingletonType(Geom2D, "Meshroom.Helpers", 1, 0, "Geom2D") qmlRegisterSingletonType(LogLevelEnum, "DataObjects", 1, 0, "LogLevelEnum") diff --git a/meshroom/ui/components/anySetEditor.py b/meshroom/ui/components/anySetEditor.py new file mode 100644 index 0000000000..a5b8b462a6 --- /dev/null +++ b/meshroom/ui/components/anySetEditor.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- + +""" +AnySet attributes editor: Provide list model and helper to edit a AnySet attribute items +""" + +from PySide6.QtCore import ( + QObject, + Slot, + Signal, + Property, + QAbstractListModel, + Qt, + QModelIndex +) + + +EXPOSED_ATTR_TYPES = ["StringParam", "File", "IntParam", "FloatParam", "BoolParam"] + + +class AttributesSetModel(QAbstractListModel): + TypeRole = Qt.UserRole + 1 # Attribute type + NameRole = Qt.UserRole + 2 # Attribute name + LabelRole = Qt.UserRole + 3 # Attribute label + DescriptionRole = Qt.UserRole + 4 # Attribute description + ValueRole = Qt.UserRole + 5 # Attribute value + ObjectRole = Qt.UserRole + 6 # Attribute value + + def __init__(self, parent=None): + super().__init__(parent) + self._items = [] + + def roleNames(self): + return { + self.TypeRole: b"type", + self.NameRole: b"name", + self.LabelRole: b"label", + self.DescriptionRole: b"description", + self.ValueRole: b"value", + self.ObjectRole: b"attributeObject", + } + + def rowCount(self, parent=QModelIndex()): + return len(self._items) + + def data(self, index, role): + if not index.isValid() or not (0 <= index.row() < len(self._items)): + return None + item = self._items[index.row()] + if role == self.TypeRole: + return item.get("type", "String") + if role == self.NameRole: + return item.get("name", "") + if role == self.LabelRole: + return item.get("label", "") + if role == self.DescriptionRole: + return item.get("description", "") + if role == self.ValueRole: + return item.get("value", "") + if role == self.ObjectRole: + return item.get("attributeObject", "") + return None + + def reset(self, items): + self.beginResetModel() + self._items = list(items) + self.endResetModel() + + def appendItem(self, item): + self.beginInsertRows(QModelIndex(), len(self._items), len(self._items)) + self._items.append(item) + self.endInsertRows() + + def removeByName(self, name): + for i, item in enumerate(self._items): + if item.get("name") == name: + self.beginRemoveRows(QModelIndex(), i, i) + del self._items[i] + self.endRemoveRows() + return True + return False + + +class NodeAttributeEditor(QObject): + def __init__(self, parent=None): + super().__init__(parent) + self._node = None + self._attribute = None + self._model = AttributesSetModel(self) + + def refresh(self): + """Reload the model from the node's current inputs.""" + if self._node is None: + self._model.reset([]) + else: + self._model.reset(self.listInputs()) + self.inputsChanged.emit() + + def getNode(self): + return self._node + + def setNode(self, node): + if self._node is node: + return + self._node = node + self.nodeChanged.emit() + self.refresh() + + def getAttribute(self): + return self._attribute + + def setAttribute(self, attribute): + if self._attribute is attribute: + return + self._attribute = attribute + self.attributeChanged.emit() + self.refresh() + + @Slot(str, str, str, str, str) + def addInput(self, attrType, name, label, description, value): + """Add a new input attribute on the node using params dict.""" + if not name: + self.errorOccurred.emit("Input name cannot be empty") + return + params = { + "type": attrType, + "name": name, + "label": label, + "description": description, + "value": value, + } + try: + insertIndex = len(list(self._attribute._value)) + self._attribute.insertAttribute(params, index=insertIndex) + self._model.appendItem(params) + except Exception as e: + self.errorOccurred.emit(str(e)) + + @Slot(str) + def removeInput(self, inputName): + """Remove the input attribute named inputName from the node.""" + try: + for attr in list(self._attribute._value): + if attr.name == inputName: + self._attribute.removeAttribute(attr) + self._model.removeByName(inputName) + except Exception as e: + self.errorOccurred.emit(str(e)) + + def listInputs(self): + """List inputs (build the list model displayed in the UI).""" + inputs = list(self._attribute.value) + inputsParams = [] + for i in range(len(inputs)): + input_ = inputs[i] + inputParams = { + "type": input_._desc.__class__.__name__, + "name": input_.name, + "label": input_.label, + "description": input_._desc.description, + "value": input_.value, + "attributeObject": input_, + } + inputsParams.append(inputParams) + return inputsParams + + attrTypes = Property(list, lambda _: EXPOSED_ATTR_TYPES, constant=True) + inputsChanged = Signal() + errorOccurred = Signal(str) + nodeChanged = Signal() + node = Property(QObject, getNode, setNode, notify=nodeChanged) + attributeChanged = Signal() + attribute = Property(QObject, getAttribute, setAttribute, notify=attributeChanged) + inputsModel = Property(QObject, lambda self: self._model, constant=True) diff --git a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml index 4667189181..1ccea8ce34 100644 --- a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml +++ b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml @@ -993,6 +993,34 @@ RowLayout { } } + Component { + id: groupAttrsEditorComponent + Dialog { + id: dialog + property alias node: editor.node + property alias attribute: editor.attribute + + parent: Overlay.overlay + modal: true + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + anchors.centerIn: parent + title: "Manage Group Attributes" + standardButtons: Dialog.Close + + NodeAttributeEditor { + id: editor + } + + CustomAttributesEditor { + nodeAttributeEditor: editor + palette: Colors.sysPalette + } + + onClosed: destroy() + } + } + Component { id: groupAttributeComponent ColumnLayout { @@ -1051,6 +1079,22 @@ RowLayout { onDoubleClicked: function(mouse) { root.doubleClicked(mouse, root.attribute) } } } + + ToolButton { + enabled: attribute.type == "AnySet" + visible: enabled + text: MaterialIcons.edit + font.family: MaterialIcons.fontFamily + font.pointSize: 10 + padding: 2 + onClicked: { + var groupAttrEditor = groupAttrsEditorComponent.createObject(root, { + 'node': _currentScene.selectedNode, + 'attribute': attribute, + }) + groupAttrEditor.open() + } + } } Component.onCompleted: { diff --git a/meshroom/ui/qml/GraphEditor/CustomAttributesEditor.qml b/meshroom/ui/qml/GraphEditor/CustomAttributesEditor.qml new file mode 100644 index 0000000000..13cc13721c --- /dev/null +++ b/meshroom/ui/qml/GraphEditor/CustomAttributesEditor.qml @@ -0,0 +1,413 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import Utils 1.0 +import MaterialIcons 2.2 + +Item { + id: root + + property var nodeAttributeEditor + property var palette: null + + readonly property color colorAccent: "#5294e2" + readonly property color colorTextMuted: "#9a9a9a" + + implicitWidth: 520 + implicitHeight: column.implicitHeight + 24 + + Rectangle { + anchors.fill: parent + color: palette.window + radius: 6 + } + + Connections { + target: root.nodeAttributeEditor + function onErrorOccurred(message) { + errorLabel.text = message + errorLabel.visible = true + errorTimer.restart() + } + } + + // Get the component corresponding to an attribute. + function valueDisplayComponent(attrType) { + switch (attrType) { + case "IntParam": + return intValueComponent + case "FloatParam": + return floatValueComponent + case "BoolParam": + return boolValueComponent + case "StringParam": + case "File": + default: + return stringValueComponent + } + } + + // Reads the current value out of a loaded value widget, regardless of type. + function readValue(loaderItem) { + if (loaderItem.hasOwnProperty("checked")) + return loaderItem.checked ? "true" : "false" + if (loaderItem.hasOwnProperty("value") && !loaderItem.hasOwnProperty("text")) + return String(loaderItem.value) + return loaderItem.text + } + + Component { + id: stringValueComponent + TextField { + placeholderText: "value" + selectByMouse: true + background: Rectangle { + radius: 8 + color: root.palette.base + border.color: Qt.darker(root.palette.base, 1.4) + } + } + } + + Component { + id: intValueComponent + SpinBox { + from: -32768 + to: 32767 + editable: true + background: Rectangle { + radius: 8 + color: root.palette.base + border.color: Qt.darker(root.palette.base, 1.4) + } + } + } + + Component { + id: floatValueComponent + TextField { + placeholderText: "0.0" + validator: DoubleValidator {} + selectByMouse: true + background: Rectangle { + radius: 8 + color: root.palette.base + border.color: Qt.darker(root.palette.base, 1.4) + } + } + } + + Component { + id: boolValueComponent + CheckBox { + indicator: Rectangle { + implicitWidth: 16 + implicitHeight: 16 + y: parent.height / 2 - height / 2 + radius: 3 + border.color: root.palette.mid + + Rectangle { + width: 12 + height: 12 + x: 2 + y: 2 + radius: 2 + color: parent.parent.down ? root.palette.mid : root.palette.accent + visible: parent.parent.checked + } + } + } + } + + ColumnLayout { + id: column + anchors.fill: parent + anchors.margins: 14 + spacing: 10 + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Label { + text: "Inputs" + font.pixelSize: 16 + font.bold: true + } + + Item { Layout.fillWidth: true } + + Label { + text: listView.count + (listView.count === 1 ? " input" : " inputs") + color: root.colorTextMuted + font.pixelSize: 12 + } + } + + // Error label + timer that resets after 3 seconds + + Label { + id: errorLabel + color: Colors.firebrick + visible: false + wrapMode: Text.WordWrap + Layout.fillWidth: true + font.pixelSize: 12 + } + + Timer { + id: errorTimer + interval: 3000 + running: false + repeat: false + onTriggered: errorLabel.visible = false + } + + ListView { + id: listView + Layout.fillWidth: true + Layout.preferredHeight: Math.min(contentHeight, 280) + clip: true + model: root.nodeAttributeEditor ? root.nodeAttributeEditor.inputsModel : null + spacing: 6 + + Label { + anchors.centerIn: parent + visible: listView.count === 0 + text: "No inputs yet" + color: root.colorTextMuted + font.italic: true + } + + delegate: Rectangle { + width: listView.width + height: rowLayout.implicitHeight + 16 + radius: 5 + color: Qt.lighter(root.palette.base, rowMouseArea.containsMouse ? 1.4 : 1.2) + border.color: root.palette.mid + border.width: 1 + + Behavior on color { ColorAnimation { duration: 100 } } + + MouseArea { + id: rowMouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + RowLayout { + id: rowLayout + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 8 + spacing: 15 + + Rectangle { + Layout.preferredWidth: 6 + Layout.preferredHeight: 6 + radius: 3 + color: root.palette.accent + Layout.alignment: Qt.AlignVCenter + } + + Label { + id: attrLabel + text: `${model.label} (${model.name})` + Layout.preferredWidth: 170 + elide: Text.ElideRight + + MouseArea { + id: _mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton // hover only, don't swallow clicks + } + + ToolTip { + delay: 200 + text: `${model.type}` + (model.description ? `: ${model.description}` : "") + visible: _mouseArea.containsMouse + x: -width - 40 + y: (attrLabel.height - height) / 2 + } + } + + Loader { + Layout.fillWidth: true + sourceComponent: valueDisplayComponent(model.type) + onLoaded: { + if (item.hasOwnProperty("sourceAttribute")) + item.sourceAttribute = model.attributeObject + if (item.hasOwnProperty("checked")) + item.checked = (model.value === true || value === "true") + else if (item.hasOwnProperty("value") && !item.hasOwnProperty("text")) + item.value = Number(model.value) + else + item.text = value + item.enabled = false + } + } + + MaterialToolButton { + text: MaterialIcons.close + Layout.preferredWidth: 26 + Layout.preferredHeight: 26 + font.pixelSize: 12 + background: Rectangle { + radius: 4 + color: parent.hovered ? Colors.firebrick : "transparent" + } + onClicked: { + if (root.nodeAttributeEditor) + root.nodeAttributeEditor.removeInput(model.name) + } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: root.palette.mid + } + + // --- New input layout --- + + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + text: "New input" + font.pixelSize: 14 + font.bold: true + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 10 + rowSpacing: 8 + + Label { + text: "Type" + color: root.colorTextMuted + Layout.preferredWidth: 90 + } + ComboBox { + id: newType + Layout.fillWidth: true + model: root.nodeAttributeEditor ? root.nodeAttributeEditor.attrTypes : [] + } + + Label { + text: "Name" + color: root.colorTextMuted + Layout.preferredWidth: 90 + } + TextField { + id: newName + Layout.fillWidth: true + placeholderText: "attribute name" + selectByMouse: true + background: Rectangle { + radius: 8 + color: root.palette.base + border.color: Qt.darker(root.palette.base, 1.4) + } + } + + Label { + text: "Label" + color: root.colorTextMuted + Layout.preferredWidth: 90 + } + TextField { + id: newLabel + Layout.fillWidth: true + placeholderText: "display label" + selectByMouse: true + background: Rectangle { + radius: 8 + color: root.palette.base + border.color: Qt.darker(root.palette.base, 1.4) + } + } + + Label { + text: "Description" + color: root.colorTextMuted + Layout.preferredWidth: 90 + } + TextField { + id: newDescription + Layout.fillWidth: true + placeholderText: "description" + selectByMouse: true + background: Rectangle { + radius: 8 + color: root.palette.base + border.color: Qt.darker(root.palette.base, 1.4) + } + } + + Label { + text: "Value" + color: root.colorTextMuted + Layout.preferredWidth: 90 + } + Loader { + id: newValueLoader + Layout.fillWidth: true + sourceComponent: valueDisplayComponent(newType.currentText) + onLoaded: { + if (item.hasOwnProperty("sourceAttribute")) + item.sourceAttribute = model.attributeObject + } + } + } + } + + // Add button + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 4 + + Item { Layout.fillWidth: true } + + Button { + text: MaterialIcons.format_list_bulleted_add + " Add input" + padding: 10 + highlighted: true + + background: Rectangle { + color: parent.pressed ? Colors.green : (parent.hovered ? root.palette.accent : root.palette.mid) + radius: height / 2 + } + onClicked: { + if (!root.nodeAttributeEditor) + return + var value = newValueLoader.item ? readValue(newValueLoader.item) : "" + root.nodeAttributeEditor.addInput( + newType.currentText, + newName.text, + newLabel.text, + newDescription.text, + value + ) + newName.clear() + newLabel.clear() + newDescription.clear() + newValueLoader.active = false + newValueLoader.active = true + listView.forceLayout() + } + } + } + } +} diff --git a/meshroom/ui/qml/GraphEditor/qmldir b/meshroom/ui/qml/GraphEditor/qmldir index 9e41965691..b597bed01a 100644 --- a/meshroom/ui/qml/GraphEditor/qmldir +++ b/meshroom/ui/qml/GraphEditor/qmldir @@ -9,6 +9,7 @@ Backdrop 1.0 Backdrop.qml AttributePin 1.0 AttributePin.qml AttributeEditor 1.0 AttributeEditor.qml AttributeItemDelegate 1.0 AttributeItemDelegate.qml +CustomAttributesEditor 1.0 CustomAttributesEditor.qml CompatibilityBadge 1.0 CompatibilityBadge.qml CompatibilityManager 1.0 CompatibilityManager.qml singleton GraphEditorSettings 1.0 GraphEditorSettings.qml