diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index b9023d5edf..ce63cbf815 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -1169,8 +1169,29 @@ def pasteNodes(self, serializedData: str, position: Optional[QPoint]=None) -> li logging.warning("Content is not a valid graph data.") return [] return result - - + + @Slot(Node, result=bool) + def canComputeNode(self, node: Node) -> bool: + """ Check if the node can be computed """ + if node.isCompatibilityNode or not node.isComputableType or node.getLocked(): + return False + if node.isComputed: + return True + if self._graph.canComputeTopologically(node) and self._graph.canSubmitOrCompute(node) % 2 == 1: + return True + return False + + @Slot(Node, result=bool) + def canSubmitNode(self, node: Node) -> bool: + """ Check if the node can be submitted """ + if node.isCompatibilityNode or not node.isComputableType or node.getLocked(): + return False + if node.isComputed: + return True + if self._graph.canComputeTopologically(node) and self._graph.canSubmitOrCompute(node)> 1: + return True + return False + undoStack = Property(QObject, lambda self: self._undoStack, constant=True) graphChanged = Signal() graph = Property(Graph, lambda self: self._graph, notify=graphChanged) diff --git a/meshroom/ui/qml/Controls/NodeActions.qml b/meshroom/ui/qml/Controls/NodeActions.qml new file mode 100644 index 0000000000..26c199b988 --- /dev/null +++ b/meshroom/ui/qml/Controls/NodeActions.qml @@ -0,0 +1,297 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import MaterialIcons 2.2 +import Utils 1.0 + +Item { + id: root + + // Settings + readonly property real headerOffset: 10 // Distance above the node in screen pixels + readonly property real _opacity: 0.9 + + // Objects passed from the graph editor + property var uigraph: null + property var draggable: null // The draggable container from GraphEditor + property var nodeRepeater: null // Reference to nodeRepeater to find delegates + + // Signals + signal computeRequest(var node) + signal stopComputeRequest(var node) + signal deleteDataRequest(var node) + signal submitRequest(var node) + + SystemPalette { id: activePalette } + + /** + * Get the node delegate + */ + function nodeDelegate(node) { + if (!nodeRepeater) + return null + for (var i = 0; i < nodeRepeater.count; ++i) { + if (nodeRepeater.itemAt(i).node === node) + return nodeRepeater.itemAt(i) + } + return null + } + + enum ButtonState { + DISABLED = 0, + LAUNCHABLE = 1, + DELETABLE = 2, + STOPPABLE = 3 + } + + Rectangle { + id: actionHeader + + readonly property bool hasSelectedNode: uigraph && uigraph.nodeSelection.selectedIndexes.length === 1 + readonly property var selectedNode: hasSelectedNode ? uigraph.selectedNode : null + readonly property var selectedNodeDelegate: selectedNode ? root.nodeDelegate(selectedNode) : null + + visible: selectedNodeDelegate !== null + color: "transparent" + width: actionItemsRow.width + height: actionItemsRow.height + + // + // ===== Manage NodeActions position ===== + // + + // Prevents losing focus on the node when we click on buttons of the actionItems + MouseArea { + anchors.fill: parent + onPressed: function(mouse) { mouse.accepted = true } + onReleased: function(mouse) { mouse.accepted = true } + onClicked: function(mouse) { mouse.accepted = true } + onDoubleClicked: function(mouse) { mouse.accepted = true } + hoverEnabled: false + } + + // Update position + function updatePosition() { + if (!selectedNodeDelegate || !draggable) return + // Calculate node position in screen coordinates + const nodeScreenX = selectedNodeDelegate.x * draggable.scale + draggable.x + const nodeScreenY = selectedNodeDelegate.y * draggable.scale + draggable.y + // Position header above the node (fixed offset in screen pixels) + x = nodeScreenX + (selectedNodeDelegate.width * draggable.scale - width) / 2 + y = nodeScreenY - height - headerOffset + } + + onWidthChanged: { + updatePosition() + } + + // Update position when the user moves on the graph + Connections { + target: root.draggable + function onXChanged() { actionHeader.updatePosition() } + function onYChanged() { actionHeader.updatePosition() } + function onScaleChanged() { actionHeader.updatePosition() } + } + + // Update position when nodes are moved + Connections { + target: actionHeader.selectedNodeDelegate + function onXChanged() { actionHeader.updatePosition() } + function onYChanged() { actionHeader.updatePosition() } + ignoreUnknownSignals: true + } + + // + // ===== Manage buttons ===== + // + + property bool nodeIsLocked: false + property bool canComputeNode: false + property bool canStopNode: false + property bool canRestartNode: false + property bool canSubmitNode: false + property bool nodeSubmitted: false + + property int computeButtonState: NodeActions.ButtonState.LAUNCHABLE + property string computeButtonIcon: { + switch (computeButtonState) { + case NodeActions.ButtonState.STOPPABLE: return MaterialIcons.cancel_schedule_send + default: return MaterialIcons.send + } + } + property int submitButtonState: NodeActions.ButtonState.LAUNCHABLE + + function getComputeButtonState(node) { + if (actionHeader.canStopNode) + return NodeActions.ButtonState.STOPPABLE + if (!actionHeader.nodeIsLocked && node.globalStatus == "SUCCESS") + return NodeActions.ButtonState.DELETABLE + if (actionHeader.canComputeNode) + return NodeActions.ButtonState.LAUNCHABLE + return NodeActions.ButtonState.DISABLED + } + + function getSubmitButtonState(node) { + if (actionHeader.nodeIsLocked || actionHeader.canStopNode) + return NodeActions.ButtonState.DISABLED + if (!actionHeader.nodeIsLocked && node.globalStatus == "SUCCESS") + return NodeActions.ButtonState.DISABLED + if (actionHeader.canSubmitNode) + return NodeActions.ButtonState.LAUNCHABLE + return NodeActions.ButtonState.DISABLED + } + + function isSubmittedExternally(node) { + return node.globalExecMode == "EXTERN" && ["RUNNING", "SUBMITTED"].includes(node.globalStatus) + } + + function isNodeRestartable(node) { + return actionHeader.computeButtonState == NodeActions.ButtonState.LAUNCHABLE && + ["ERROR", "STOPPED", "KILLED"].includes(node.globalStatus) + } + + function updateProperties(node) { + if (!node) return + // Update properties values + actionHeader.canComputeNode = uigraph.canComputeNode(node) + actionHeader.canSubmitNode = uigraph.canSubmitNode(node) + actionHeader.canStopNode = node.canBeStopped() || node.canBeCanceled() + actionHeader.nodeIsLocked = node.locked + actionHeader.nodeSubmitted = isSubmittedExternally(node) + // Update button states + actionHeader.computeButtonState = getComputeButtonState(node) + actionHeader.submitButtonState = getSubmitButtonState(node) + actionHeader.canRestartNode = isNodeRestartable(node) + } + + // Set initial state & position + onSelectedNodeDelegateChanged: { + if (actionHeader.selectedNode) { + actionHeader.updateProperties(actionHeader.selectedNode) + Qt.callLater(actionHeader.updatePosition) + } + } + + // Listen to updates to status + Connections { + target: actionHeader.selectedNode + function onGlobalStatusChanged() { + actionHeader.updateProperties(target) + } + function onLockedChanged() { + actionHeader.nodeIsLocked = target.locked + } + ignoreUnknownSignals: true + } + + // Listen to updates from nodes that are not selected + Connections { + target: root.uigraph + function onComputingChanged() { + actionHeader.updateProperties(actionHeader.selectedNode) + } + ignoreUnknownSignals: true + } + + Row { + id: actionItemsRow + anchors.centerIn: parent + spacing: 2 + + // Compute button + MaterialToolButton { + id: computeButton + font.pointSize: 16 + text: actionHeader.computeButtonIcon + padding: 6 + ToolTip.text: "Start/Stop/Restart Compute" + ToolTip.visible: hovered + ToolTip.delay: 1000 + visible: actionHeader.computeButtonState != NodeActions.ButtonState.DELETABLE + enabled: actionHeader.computeButtonState % 2 == 1 // Launchable & Stoppable + background: Rectangle { + color: { + if (!computeButton.enabled) { + if (actionHeader.nodeSubmitted) + return Qt.darker(Colors.statusColors["SUBMITTED"], 1.2) + return activePalette.button + } + if (actionHeader.computeButtonState == NodeActions.ButtonState.STOPPABLE) + return computeButton.hovered ? Colors.orange : Qt.darker(Colors.orange, 1.3) + return computeButton.hovered ? activePalette.highlight : activePalette.button + } + opacity: computeButton.hovered ? 1 : root._opacity + border.color: computeButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.3) + border.width: 1 + radius: 3 + } + onClicked: { + switch (actionHeader.computeButtonState) { + case NodeActions.ButtonState.STOPPABLE: + root.stopComputeRequest(actionHeader.selectedNode) + break + case NodeActions.ButtonState.LAUNCHABLE: + root.computeRequest(actionHeader.selectedNode) + break + } + } + } + + // Clear node + MaterialToolButton { + id: deleteDataButton + font.pointSize: 16 + text: MaterialIcons.delete_ + padding: 6 + ToolTip.text: "Delete data" + ToolTip.visible: hovered + ToolTip.delay: 1000 + visible: actionHeader.canRestartNode || actionHeader.computeButtonState == NodeActions.ButtonState.DELETABLE + enabled: visible + background: Rectangle { + color: computeButton.hovered ? Colors.red : Qt.darker(Colors.red, 1.3) + opacity: computeButton.hovered ? 1 : root._opacity + border.color: computeButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.3) + border.width: 1 + radius: 3 + } + onClicked: { + root.deleteDataRequest(actionHeader.selectedNode) + } + } + + // Submit button + MaterialToolButton { + id: submitButton + font.pointSize: 16 + text: MaterialIcons.rocket_launch + padding: 6 + ToolTip.text: "Submit on Render Farm" + ToolTip.visible: hovered + ToolTip.delay: 1000 + visible: root.uigraph ? root.uigraph.canSubmit : false + enabled: actionHeader.submitButtonState != NodeActions.ButtonState.DISABLED + background: Rectangle { + color: { + if (!submitButton.enabled) { + if (actionHeader.nodeSubmitted) + return Qt.darker(Colors.statusColors["SUBMITTED"], 1.2) + return activePalette.button + } + return submitButton.hovered ? activePalette.highlight : activePalette.button + } + opacity: submitButton.hovered ? 1 : root._opacity + border.color: submitButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.3) + border.width: 1 + radius: 3 + } + onClicked: { + if (actionHeader.selectedNode) { + root.submitRequest(actionHeader.selectedNode) + } + } + } + } + } +} \ No newline at end of file diff --git a/meshroom/ui/qml/Controls/qmldir b/meshroom/ui/qml/Controls/qmldir index d3a3f3c359..d53c6b2757 100644 --- a/meshroom/ui/qml/Controls/qmldir +++ b/meshroom/ui/qml/Controls/qmldir @@ -22,3 +22,4 @@ SelectionLine 1.0 SelectionLine.qml DelegateSelectionBox 1.0 DelegateSelectionBox.qml DelegateSelectionLine 1.0 DelegateSelectionLine.qml StatusBar 1.0 StatusBar.qml +NodeActions 1.0 NodeActions.qml diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 57b3c2d19e..2c29d962af 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -1075,6 +1075,34 @@ Item { } } + NodeActions { + id: nodeActions + uigraph: root.uigraph + draggable: draggable + nodeRepeater: nodeRepeater + anchors.fill: parent + + onComputeRequest: function(node) { + root.computeRequest([node]) + } + + onStopComputeRequest: function(node) { + if (node.canBeStopped()) { + uigraph.stopNodeComputation(node) + } else if (node.canBeCanceled()) { + uigraph.cancelNodeComputation(node) + } + } + + onDeleteDataRequest: function(node) { + uigraph.clearSelectedNodesData(); + } + + onSubmitRequest: function(node) { + root.submitRequest([node]) + } + } + MessageDialog { id: errorDialog