diff --git a/meshroom/ui/qml/AttributeEditor.qml b/meshroom/ui/qml/AttributeEditor.qml new file mode 100644 index 00000000..c8fb8f95 --- /dev/null +++ b/meshroom/ui/qml/AttributeEditor.qml @@ -0,0 +1,60 @@ +import QtQuick 2.9 +import QtQuick.Layouts 1.3 +import QtQuick.Controls 2.2 + +/** + A component to display and edit a Node's attributes. +*/ +ColumnLayout { + id: root + + // the node to edit + property variant node: null + + SystemPalette { id: palette } + + Button { + text: "Open Node Folder" + onClicked: Qt.openUrlExternally("file://" + node.internalFolder) + ToolTip.text: node.internalFolder + ToolTip.visible: hovered + } + + ListView { + id: attributesListView + + Layout.fillHeight: true + Layout.fillWidth: true + clip: true + spacing: 4 + ScrollBar.vertical: ScrollBar {} + + model: node ? node.attributes : undefined + + delegate: RowLayout { + width: attributesListView.width + spacing: 4 + + Label { + id: parameterLabel + text: object.label + Layout.preferredWidth: 200 + color: object.isOutput ? "orange" : palette.text + ToolTip.text: object.desc.description + ToolTip.visible: parameterMA.containsMouse + ToolTip.delay: 200 + MouseArea { + id: parameterMA + anchors.fill: parent + hoverEnabled: true + } + } + + AttributeItemDelegate { + Layout.fillWidth: true + height: childrenRect.height + attribute: object + } + } + } +} diff --git a/meshroom/ui/qml/AttributeItemDelegate.qml b/meshroom/ui/qml/AttributeItemDelegate.qml new file mode 100644 index 00000000..0bc0c3a4 --- /dev/null +++ b/meshroom/ui/qml/AttributeItemDelegate.qml @@ -0,0 +1,173 @@ +import QtQuick 2.9 +import QtQuick.Layouts 1.3 +import QtQuick.Controls 2.2 + +/** + Instantiate a control to visualize and edit an Attribute based on its type. +*/ +Loader { + id: root + property variant attribute: null + + sourceComponent: { + switch(attribute.type) + { + case "ChoiceParam": return attribute.desc.exclusive ? comboBox_component : multiChoice_component + case "IntParam": return slider_component + case "FloatParam": return slider_component + case "BoolParam": return checkbox_component + case "ListAttribute": return listAttribute_component + case "GroupAttribute": return groupAttribute_component + default: return textField_component + } + } + + Component { + id: textField_component + TextField { + text: attribute.value + readOnly: attribute.isOutput || attribute.isLink + selectByMouse: true + onEditingFinished: _reconstruction.setAttribute(attribute, text.trim()) + } + } + + Component { + id: comboBox_component + ComboBox { + model: attribute.desc.values + enabled: !attribute.isOutput && !attribute.isLink + Component.onCompleted: currentIndex = find(attribute.value) + onActivated: _reconstruction.setAttribute(attribute, currentText) + Connections { + target: attribute + onValueChanged: currentIndex = find(attribute.value) + } + } + } + + Component { + id: multiChoice_component + Flow { + Repeater { + id: checkbox_repeater + model: attribute.desc.values + delegate: CheckBox { + text: modelData + checked: attribute.value.indexOf(modelData) >= 0 + onToggled: { + var t = attribute.value + if(!checked) { t.splice(t.indexOf(modelData), 1) } // remove element + else { t.push(modelData) } // add element + _reconstruction.setAttribute(attribute, t) + } + } + } + } + } + + Component { + id: slider_component + RowLayout { + Slider { + id: s + Layout.fillWidth: true + value: attribute.value + // TODO: range from desc + + onPressedChanged: { + if(!pressed) + _reconstruction.setAttribute(attribute, value) + } + } + IntValidator { + id: intValidator + } + DoubleValidator { + id: doubleValidator + } + TextField { + text: attribute.value + selectByMouse: true + validator: attribute.type == "FloatParam" ? doubleValidator : intValidator + onEditingFinished: _reconstruction.setAttribute(attribute, text) + } + } + } + + Component { + id: checkbox_component + Row { + CheckBox { + checked: attribute.value + onToggled: _reconstruction.setAttribute(attribute, !attribute.value) + } + } + } + + Component { + id: listAttribute_component + RowLayout { + width: parent.width + Label { + Layout.alignment: Qt.AlignTop + text: attribute.value.count + " elements" + } + Button { + Layout.alignment: Qt.AlignTop + text: "+" + onClicked: _reconstruction.appendAttribute(attribute, undefined) + } + ListView { + id: lv + model: attribute.value + implicitHeight: childrenRect.height + Layout.fillWidth: true + + delegate: RowLayout { + id: item + property var childAttrib: object + layoutDirection: Qt.RightToLeft + //height: childrenRect.height + width: lv.width + Component.onCompleted: { + var cpt = Qt.createComponent("AttributeItemDelegate.qml") + var obj = cpt.createObject(item, {'attribute': Qt.binding(function() { return item.childAttrib })}) + obj.Layout.fillWidth = true + } + Button { + text: "-" + onClicked: _reconstruction.removeAttribute(item.childAttrib) + } + } + } + } + } + + Component { + id: groupAttribute_component + ListView { + id: someview + model: attribute.value + implicitWidth: parent.width + implicitHeight: childrenRect.height + onCountChanged: forceLayout() + spacing: 4 + + delegate: RowLayout { + id: row + width: someview.width + //height: childrenRect.height + property var childAttrib: object + + Label { text: childAttrib.name } + Component.onCompleted: { + var cpt = Qt.createComponent("AttributeItemDelegate.qml") + var obj = cpt.createObject(row, {'attribute': Qt.binding(function() { return row.childAttrib })}) + obj.Layout.fillWidth = true + } + } + } + } + +} diff --git a/meshroom/ui/qml/AttributePin.qml b/meshroom/ui/qml/AttributePin.qml new file mode 100755 index 00000000..f5f3c090 --- /dev/null +++ b/meshroom/ui/qml/AttributePin.qml @@ -0,0 +1,114 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.3 + +/** + The representation of an Attribute on a Node. +*/ +Row { + id: root + + property var nodeItem + property var attribute + + // position of the anchor for attaching and edge to this attribute pin + readonly property point edgeAnchorPos: Qt.point(edgeAnchor.x + edgeAnchor.width/2, + edgeAnchor.y + edgeAnchor.height/2) + + objectName: attribute.name + "." + layoutDirection: attribute.isOutput ? Qt.RightToLeft : Qt.LeftToRight + spacing: 1 + + Rectangle { + id: edgeAnchor + + width: 6 + height: width + radius: width/2 + anchors.verticalCenter: parent.verticalCenter + border.color: "#3e3e3e" + color: (dropArea.containsDrag && dropArea.containsDrag) || attribute.isLink ? "#3e3e3e" : "white" + + DropArea { + id: dropArea + + property bool acceptableDrop: false + + anchors.fill: parent + + onEntered: { + // Filter drops: + if( drag.source.objectName != dragTarget.objectName // not an edge connector + || drag.source.nodeItem == dragTarget.nodeItem // connection between attributes of the same node + || (dragTarget.isOutput) // connection on an output + || dragTarget.attribute.isLink) // already connected attribute + { + drag.accepted = false + } + dropArea.acceptableDrop = drag.accepted + } + onExited: acceptableDrop = false + + onDropped: { + _reconstruction.addEdge(drag.source.attribute, dragTarget.attribute) + } + } + + Item { + id: dragTarget + objectName: "edgeConnector" + readonly property alias attribute: root.attribute + readonly property alias nodeItem: root.nodeItem + readonly property bool isOutput: attribute.isOutput + anchors.centerIn: root.state == "Dragging" ? undefined : parent + //anchors.verticalCenter: root.verticalCenter + width: 2 + height: 2 + Drag.active: connectMA.drag.active + Drag.hotSpot.x: width*0.5 + Drag.hotSpot.y: height*0.5 + } + + MouseArea { + id: connectMA + drag.target: dragTarget + drag.threshold: 0 + anchors.fill: parent + onReleased: dragTarget.Drag.drop() + } + + Edge { + id: connectEdge + visible: false + point1x: parent.width / 2 + point1y: parent.width / 2 + point2x: dragTarget.x + dragTarget.width/2 + point2y: dragTarget.y + dragTarget.height/2 + } + } + + // Attribute name + Label { + text: attribute.name + font.pointSize: 5 + color: "#333" // TODO: style + } + + state: connectMA.pressed ? "Dragging" : "" + + states: [ + State { + name: "" + }, + + State { + name: "Dragging" + PropertyChanges { + target: connectEdge + z: 100 + visible: true + } + + } + ] + +} diff --git a/meshroom/ui/qml/Edge.qml b/meshroom/ui/qml/Edge.qml new file mode 100644 index 00000000..38e4fbf9 --- /dev/null +++ b/meshroom/ui/qml/Edge.qml @@ -0,0 +1,65 @@ +import QtQuick 2.9 +import GraphEditor 1.0 +import QtQuick.Shapes 1.0 + +/** + A cubic spline representing an edge, going from point1 to point2, providing mouse interaction. +*/ +Shape { + id: root + + property var edge + property real point1x + property real point1y + property real point2x + property real point2y + property alias thickness: path.strokeWidth + property color color + + signal pressed(var event) + signal released(var event) + + x: point1x + y: point1y + width: point2x - point1x + height: point2y - point1y + + property real startX: 0 + property real startY: 0 + property real endX: width + property real endY: height + + // cause rendering artifacts when enabled (and don't support hot reload really well) + vendorExtensionsEnabled: false + + ShapePath { + id: path + startX: root.startX + startY: root.startY + fillColor: "transparent" + // BUG: edgeArea is destroyed before path, need to test if not null to avoid warnings + strokeColor: edgeArea && edgeArea.containsMouse ? "#E91E63" : "#3E3E3E" + capStyle: ShapePath.RoundCap + strokeWidth: 1 + + PathCubic { + id: cubic + property real curveScale: 0.7 + property real ctrlPtDist: Math.abs(root.width * curveScale) + x: root.endX + y: root.endY + relativeControl1X: ctrlPtDist; relativeControl1Y: 0 + control2X: x - ctrlPtDist; control2Y: y + } + } + + EdgeMouseArea { + id: edgeArea + anchors.fill: parent + curveScale: cubic.curveScale + acceptedButtons: Qt.LeftButton | Qt.RightButton + thickness: root.thickness + 2 + onPressed: root.pressed(arguments[0]) // can't get named args, use arguments array + onReleased: root.released(arguments[0]) + } +} diff --git a/meshroom/ui/qml/GraphEditor.qml b/meshroom/ui/qml/GraphEditor.qml new file mode 100755 index 00000000..ec21e6df --- /dev/null +++ b/meshroom/ui/qml/GraphEditor.qml @@ -0,0 +1,198 @@ +import QtQuick 2.7 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.3 + +/** + A component displaying a Graph (nodes, attributes and edges). +*/ +Item { + id: root + + property variant graph: null + + property variant selectedNode: null + + property int nodeWidth: 140 + property int nodeHeight: 40 + property int gridSpacing: 10 + property var _attributeToDelegate: ({}) + + // signals + signal workspaceMoved() + signal workspaceClicked() + + clip: true + // Activate multisampling for edges antialiasing + layer.enabled: true + layer.samples: 8 + + MouseArea { + id: mouseArea + anchors.fill: parent + property double factor: 1.15 + + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + drag.threshold: 0 + onWheel: { + var zoomFactor = wheel.angleDelta.y > 0 ? factor : 1/factor + if(Math.min(draggable.width*draggable.scale*zoomFactor, draggable.height*draggable.scale*zoomFactor) < 10) + return + var point = mapToItem(draggable, wheel.x, wheel.y) + draggable.x += (1-zoomFactor) * point.x * draggable.scale + draggable.y += (1-zoomFactor) * point.y * draggable.scale + draggable.scale *= zoomFactor + draggable.scale = draggable.scale.toFixed(2) + workspaceMoved() + } + + onPressed: { + if(mouse.button & Qt.MiddleButton) + drag.target = draggable // start drag + } + onReleased: { + drag.target = undefined // stop drag + workspaceClicked() + } + onPositionChanged: { + if(drag.active) + workspaceMoved() + } + Item { + id: draggable + transformOrigin: Item.TopLeft + width: 1000 + height: 1000 + + // Edges + Repeater { + id: edgesRepeater + + // delay edges loading after nodes (edges needs attribute pins to be created) + model: nodeRepeater.loaded ? root.graph.edges : undefined + + delegate: Edge { + property var src: root._attributeToDelegate[edge.src] + property var dst: root._attributeToDelegate[edge.dst] + property var srcAnchor: src.nodeItem.mapFromItem(src, src.edgeAnchorPos.x, src.edgeAnchorPos.y) + property var dstAnchor: dst.nodeItem.mapFromItem(dst, dst.edgeAnchorPos.x, dst.edgeAnchorPos.y) + + edge: object + point1x: src.nodeItem.x + srcAnchor.x + point1y: src.nodeItem.y + srcAnchor.y + point2x: dst.nodeItem.x + dstAnchor.x + point2y: dst.nodeItem.y + dstAnchor.y + onPressed: { + if(event.button == Qt.RightButton) + _reconstruction.removeEdge(edge) + } + } + } + + // Nodes + Repeater { + id: nodeRepeater + + model: root.graph.nodes + property bool loaded: count === model.count + onLoadedChanged: if(loaded) { doAutoLayout() } + + delegate: Node { + node: object + width: root.nodeWidth + height: Math.max(root.nodeHeight, implicitHeight) + radius: 1 + border.color: root.selectedNode == node ? Qt.darker(color, 1.8) : Qt.darker(color, 1.1) + + onAttributePinCreated: root._attributeToDelegate[attribute] = pin + + onPressed: { + root.selectedNode = object + } + + Behavior on x { + NumberAnimation {} + } + Behavior on y { + NumberAnimation {} + } + } + } + } + } + + Row { + anchors.bottom: parent.bottom + + Button { + text: "Fit" + onClicked: root.fit() + z: 10 + } + + Button { + text: "AutoLayout" + onClicked: root.doAutoLayout() + z: 10 + } + } + + // Fit graph to fill root + function fit() { + // compute bounding box + var first = nodeRepeater.itemAt(0) + var bbox = Qt.rect(first.x, first.y, 1, 1) + for(var i=0; i