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