From b21192282d2dd70d1727773448c075f325b8bf8a Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:12:11 +0100 Subject: [PATCH 01/19] QObjectListModel: implement 'index' method Implementing this method allows to use QObjectListModel in combination with other Qt classes that can act on a model (eg: QSelectionItemModel, QSortFilterProxyModel...). --- meshroom/common/qt.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/meshroom/common/qt.py b/meshroom/common/qt.py index 80a0950469..6e067dbe13 100644 --- a/meshroom/common/qt.py +++ b/meshroom/common/qt.py @@ -283,6 +283,15 @@ def _referenceItem(self, item): self._objectByKey[key] = item + @QtCore.Slot(int, result=QtCore.QModelIndex) + def index(self, row: int, column: int = 0, parent=QtCore.QModelIndex()): + """ Returns the model index for the given row, column and parent index. """ + if parent.isValid() or column != 0: + return QtCore.QModelIndex() + if row < 0 or row >= self.size(): + return QtCore.QModelIndex() + return self.createIndex(row, column, self._objects[row]) + def _dereferenceItem(self, item): # Ask for object deletion if parented to the model if shiboken6.isValid(item) and item.parent() == self: From dfe2166942847b3c871d3e9e69ca307bae0f6d32 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:12:11 +0100 Subject: [PATCH 02/19] [ui] Node: expose additional mouse events Forward mouse 'released' and 'clicked' events for giving more control over node selection management. --- meshroom/ui/qml/GraphEditor/Node.qml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/meshroom/ui/qml/GraphEditor/Node.qml b/meshroom/ui/qml/GraphEditor/Node.qml index ffce841d1c..65b95cd827 100755 --- a/meshroom/ui/qml/GraphEditor/Node.qml +++ b/meshroom/ui/qml/GraphEditor/Node.qml @@ -40,6 +40,8 @@ Item { // Mouse interaction related signals signal pressed(var mouse) + signal released(var mouse) + signal clicked(var mouse) signal doubleClicked(var mouse) signal moved(var position) signal entered() @@ -125,8 +127,10 @@ Item { drag.threshold: 2 hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton - onPressed: function(mouse) { root.pressed(mouse) } - onDoubleClicked: function(mouse) { root.doubleClicked(mouse) } + onPressed: (mouse) => root.pressed(mouse) + onReleased: (mouse) => root.released(mouse) + onClicked: (mouse) => root.clicked(mouse) + onDoubleClicked: (mouse) => root.doubleClicked(mouse) onEntered: root.entered() onExited: root.exited() drag.onActiveChanged: { From 6d2e9a2ba987e701ef23a7ef3152a7406e76b063 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:12:11 +0100 Subject: [PATCH 03/19] [ui] Utils: add SelectionBox and DelegateSelectionBox - SelectionBox: generic Selection box component. - DelegateSelectionBox: specialized SelectionBox to select model delegates from an instantiator (Repeater, ListView). Also Introduce a Geom2D helper class to provide missing features for intersection testing in QML. --- meshroom/ui/components/__init__.py | 5 +- meshroom/ui/components/geom2D.py | 8 +++ .../ui/qml/Controls/DelegateSelectionBox.qml | 32 ++++++++++ meshroom/ui/qml/Controls/SelectionBox.qml | 60 +++++++++++++++++++ meshroom/ui/qml/Controls/qmldir | 2 + 5 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 meshroom/ui/components/geom2D.py create mode 100644 meshroom/ui/qml/Controls/DelegateSelectionBox.qml create mode 100644 meshroom/ui/qml/Controls/SelectionBox.qml diff --git a/meshroom/ui/components/__init__.py b/meshroom/ui/components/__init__.py index 59c77618f8..aef07a61f2 100755 --- a/meshroom/ui/components/__init__.py +++ b/meshroom/ui/components/__init__.py @@ -1,11 +1,12 @@ def registerTypes(): - from PySide6.QtQml import qmlRegisterType + from PySide6.QtQml import qmlRegisterType, qmlRegisterSingletonType from meshroom.ui.components.clipboard import ClipboardHelper from meshroom.ui.components.edge import EdgeMouseArea from meshroom.ui.components.filepath import FilepathHelper from meshroom.ui.components.scene3D import Scene3DHelper, TrackballController, Transformations3DHelper from meshroom.ui.components.csvData import CsvData + from meshroom.ui.components.geom2D import Geom2D qmlRegisterType(EdgeMouseArea, "GraphEditor", 1, 0, "EdgeMouseArea") qmlRegisterType(ClipboardHelper, "Meshroom.Helpers", 1, 0, "ClipboardHelper") # TODO: uncreatable @@ -14,3 +15,5 @@ def registerTypes(): qmlRegisterType(Transformations3DHelper, "Meshroom.Helpers", 1, 0, "Transformations3DHelper") # TODO: uncreatable qmlRegisterType(TrackballController, "Meshroom.Helpers", 1, 0, "TrackballController") qmlRegisterType(CsvData, "DataObjects", 1, 0, "CsvData") + + qmlRegisterSingletonType(Geom2D, "Meshroom.Helpers", 1, 0, "Geom2D") diff --git a/meshroom/ui/components/geom2D.py b/meshroom/ui/components/geom2D.py new file mode 100644 index 0000000000..5cc972f1c2 --- /dev/null +++ b/meshroom/ui/components/geom2D.py @@ -0,0 +1,8 @@ +from PySide6.QtCore import QObject, Slot, QRectF + + +class Geom2D(QObject): + @Slot(QRectF, QRectF, result=bool) + def rectRectIntersect(self, rect1: QRectF, rect2: QRectF) -> bool: + """Check if two rectangles intersect.""" + return rect1.intersects(rect2) diff --git a/meshroom/ui/qml/Controls/DelegateSelectionBox.qml b/meshroom/ui/qml/Controls/DelegateSelectionBox.qml new file mode 100644 index 0000000000..a6034c8ac3 --- /dev/null +++ b/meshroom/ui/qml/Controls/DelegateSelectionBox.qml @@ -0,0 +1,32 @@ +import QtQuick +import Meshroom.Helpers + +/* +A SelectionBox that can be used to select delegates in a model instantiator (Repeater, ListView...). +Interesection test is done in the coordinate system of the container Item, using delegate's bounding boxes. +The list of selected indices is emitted when the selection ends. +*/ + +SelectionBox { + id: root + + // The Item instantiating the delegates. + property Item modelInstantiator + // The Item containing the delegates (used for coordinate mapping). + property Item container + // Emitted when the selection has ended, with the list of selected indices and modifiers. + signal delegateSelectionEnded(list indices, int modifiers) + + onSelectionEnded: function(selectionRect, modifiers) { + let selectedIndices = []; + const mappedSelectionRect = mapToItem(container, selectionRect); + for (var i = 0; i < modelInstantiator.count; ++i) { + const delegate = modelInstantiator.itemAt(i); + const delegateRect = Qt.rect(delegate.x, delegate.y, delegate.width, delegate.height); + if (Geom2D.rectRectIntersect(mappedSelectionRect, delegateRect)) { + selectedIndices.push(i); + } + } + delegateSelectionEnded(selectedIndices, modifiers); + } +} diff --git a/meshroom/ui/qml/Controls/SelectionBox.qml b/meshroom/ui/qml/Controls/SelectionBox.qml new file mode 100644 index 0000000000..e7763e8437 --- /dev/null +++ b/meshroom/ui/qml/Controls/SelectionBox.qml @@ -0,0 +1,60 @@ +import QtQuick + +/* +Simple selection box that can be used by a MouseArea. + +Usage: +1. Create a MouseArea and a SelectionBox. +2. Bind the SelectionBox to the MouseArea by setting the `mouseArea` property. +3. Call startSelection() with coordinates when the selection starts. +4. Call endSelection() when the selection ends. +5. Listen to the selectionEnded signal to get the selection rectangle. +*/ + +Item { + id: root + + property MouseArea mouseArea + property alias color: selectionBox.color + property alias border: selectionBox.border + + readonly property bool active: mouseArea.drag.target == dragTarget + + signal selectionEnded(rect selectionRect, int modifiers) + + function startSelection(mouse) { + dragTarget.startPos.x = dragTarget.x = mouse.x; + dragTarget.startPos.y = dragTarget.y = mouse.y; + dragTarget.modifiers = mouse.modifiers; + mouseArea.drag.target = dragTarget; + } + + function endSelection() { + if (!active) { + return; + } + mouseArea.drag.target = null; + const rect = Qt.rect(selectionBox.x, selectionBox.y, selectionBox.width, selectionBox.height) + selectionEnded(rect, dragTarget.modifiers); + } + + visible: active + + Rectangle { + id: selectionBox + color: "#109b9b9b" + border.width: 1 + border.color: "#b4b4b4" + + x: Math.min(dragTarget.startPos.x, dragTarget.x) + y: Math.min(dragTarget.startPos.y, dragTarget.y) + width: Math.abs(dragTarget.x - dragTarget.startPos.x) + height: Math.abs(dragTarget.y - dragTarget.startPos.y) + } + + Item { + id: dragTarget + property point startPos + property var modifiers + } +} diff --git a/meshroom/ui/qml/Controls/qmldir b/meshroom/ui/qml/Controls/qmldir index 80d5d9690f..9d3e23cb75 100644 --- a/meshroom/ui/qml/Controls/qmldir +++ b/meshroom/ui/qml/Controls/qmldir @@ -17,3 +17,5 @@ IntSelector 1.0 IntSelector.qml MScrollBar 1.0 MScrollBar.qml MSplitView 1.0 MSplitView.qml DirectionalLightPane 1.0 DirectionalLightPane.qml +SelectionBox 1.0 SelectionBox.qml +DelegateSelectionBox 1.0 DelegateSelectionBox.qml \ No newline at end of file From 05eabb2b131eeb8ed8130588a17d897ada0ff3b7 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:13:51 +0100 Subject: [PATCH 04/19] [ui] Graph: Node selection refactor (1) Switch selection management backend to a QItemSelectionModel, while keeping the current 'selectedNodes' API for now. Use DelegateSectionBox for node selection in the graph, and rewrite the handling of node selection / displacement. --- meshroom/ui/graph.py | 173 ++++++++++++-------- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 170 +++++++++++-------- 2 files changed, 204 insertions(+), 139 deletions(-) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 0a8e80d455..ed8dae9468 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -7,8 +7,19 @@ from enum import Enum from threading import Thread, Event, Lock from multiprocessing.pool import ThreadPool - -from PySide6.QtCore import Slot, QJsonValue, QObject, QUrl, Property, Signal, QPoint +from typing import Iterator + +from PySide6.QtCore import ( + Slot, + QJsonValue, + QObject, + QUrl, + Property, + Signal, + QPoint, + QItemSelectionModel, + QItemSelection, +) from meshroom.core import sessionUid from meshroom.common.qt import QObjectListModel @@ -359,6 +370,8 @@ def __init__(self, undoStack, taskManager, parent=None): self._layout = GraphLayout(self) self._selectedNode = None self._selectedNodes = QObjectListModel(parent=self) + self._nodeSelection = QItemSelectionModel(self._graph.nodes, parent=self) + self._nodeSelection.selectionChanged.connect(self.onNodeSelectionChanged) self._hoveredNode = None self.submitLabel = "{projectName}" @@ -395,6 +408,8 @@ def setGraph(self, g): self._layout.reset() # clear undo-stack after layout self._undoStack.clear() + + self._nodeSelection.setModel(self._graph.nodes) self.graphChanged.emit() def onGraphUpdated(self): @@ -642,27 +657,24 @@ def filterNodes(self, nodes): nodes = [nodes] return [ n for n in nodes if n in self._graph.nodes.values() ] - @Slot(Node, QPoint, QObject) - def moveNode(self, node, position, nodes=None): + def moveNode(self, node: Node, position: Position): """ - Move 'node' to the given 'position' and also update the positions of 'nodes' if necessary. + Move `node` to the given `position`. Args: - node (Node): the node to move - position (QPoint): the target position - nodes (list[Node]): the nodes to update the position of + node: The node to move. + position: The target position. """ - if not nodes: - nodes = [node] - nodes = self.filterNodes(nodes) - if isinstance(position, QPoint): - position = Position(position.x(), position.y()) - deltaX = position.x - node.x - deltaY = position.y - node.y + self.push(commands.MoveNodeCommand(self._graph, node, position)) + + @Slot(QPoint) + def moveSelectedNodesBy(self, offset: QPoint): + """Move all the selected nodes by the given `offset`.""" + with self.groupedGraphModification("Move Selected Nodes"): - for n in nodes: - position = Position(n.x + deltaX, n.y + deltaY) - self.push(commands.MoveNodeCommand(self._graph, n, position)) + for node in self.iterSelectedNodes(): + position = Position(node.x + offset.x(), node.y + offset.y()) + self.moveNode(node, position) @Slot(QObject) def removeNodes(self, nodes): @@ -934,23 +946,80 @@ def removeImagesFromAllGroups(self): with self.groupedGraphModification("Remove Images From All CameraInit Nodes"): self.push(commands.RemoveImagesCommand(self._graph, list(self.cameraInits))) - @Slot(Node) - def appendSelection(self, node): - """ Append 'node' to the selection if it is not already part of the selection. """ - if not self._selectedNodes.contains(node): - self._selectedNodes.append(node) - - @Slot("QVariantList") - def selectNodes(self, nodes): - """ Append 'nodes' to the selection. """ - for node in nodes: - self.appendSelection(node) + def onNodeSelectionChanged(self, selected, deselected): + # Update internal cache of selected Node instances. + self._selectedNodes.setObjectList(list(self.iterSelectedNodes())) self.selectedNodesChanged.emit() + @Slot(list) + @Slot(list, int) + def selectNodes(self, nodes, command=QItemSelectionModel.SelectionFlag.ClearAndSelect): + """Update selection with `nodes` using the specified `command`.""" + indices = [self._graph._nodes.indexOf(node) for node in nodes] + self.selectNodesByIndices(indices, command) + @Slot(Node) - def selectFollowing(self, node): - """ Select all the nodes the depend on 'node'. """ + def selectFollowing(self, node: Node): + """Select all the nodes that depend on `node`.""" self.selectNodes(self._graph.dfsOnDiscover(startNodes=[node], reverse=True, dependenciesOnly=True)[0]) + self.selectedNode = node + + @Slot(int) + @Slot(int, int) + def selectNodeByIndex(self, index: int, command=QItemSelectionModel.SelectionFlag.ClearAndSelect): + """Update selection with node at the given `index` using the specified `command`.""" + if isinstance(command, int): + command = QItemSelectionModel.SelectionFlag(command) + + self.selectNodesByIndices([index], command) + + if self._nodeSelection.isRowSelected(index): + self.selectedNode = self._graph.nodes.at(index) + + @Slot(list) + @Slot(list, int) + def selectNodesByIndices( + self, indices: list[int], command=QItemSelectionModel.SelectionFlag.ClearAndSelect + ): + """Update selection with node at given `indices` using the specified `command`. + + Args: + indices: The list of indices to select. + command: The selection command to use. + """ + if isinstance(command, int): + command = QItemSelectionModel.SelectionFlag(command) + + itemSelection = QItemSelection() + for index in indices: + itemSelection.select( + self._graph.nodes.index(index), self._graph.nodes.index(index) + ) + + self._nodeSelection.select(itemSelection, command) + + if self.selectedNode and not self.isSelected(self.selectedNode): + self.selectedNode = None + + def iterSelectedNodes(self) -> Iterator[Node]: + """Iterate over the currently selected nodes.""" + for idx in self._nodeSelection.selectedRows(): + yield self._graph.nodes.at(idx.row()) + + @Slot(Node, result=bool) + def isSelected(self, node: Node) -> bool: + """Whether `node` is part of the current selection.""" + return self._nodeSelection.isRowSelected(self._graph.nodes.indexOf(node)) + + @Slot() + def clearNodeSelection(self): + """Clear all node selection.""" + self.selectedNode = None + self._nodeSelection.clear() + + def clearNodeHover(self): + """ Reset currently hovered node to None. """ + self.hoveredNode = None @Slot(str) def setSelectedNodesColor(self, color: str): @@ -962,48 +1031,12 @@ def setSelectedNodesColor(self, color: str): # Update the color attribute of the nodes which are currently selected with self.groupedGraphModification("Set Nodes Color"): # For each of the selected nodes -> Check if the node has a color -> Apply the color if it has - for node in self._selectedNodes: + for node in self.iterSelectedNodes(): if node.hasInternalAttribute("color"): self.setAttribute(node.internalAttribute("color"), color) - @Slot(QObject, QObject) - def boxSelect(self, selection, draggable): - """ - Select nodes that overlap with 'selection'. - Takes into account the zoom and position of 'draggable'. - - Args: - selection: the rectangle selection widget. - draggable: the parent widget that has position and scale data. - """ - x = selection.x() - draggable.x() - y = selection.y() - draggable.y() - otherX = x + selection.width() - otherY = y + selection.height() - x, y, otherX, otherY = [ i / draggable.scale() for i in [x, y, otherX, otherY] ] - if x == otherX or y == otherY: - return - for n in self._graph.nodes: - bbox = self._layout.boundingBox([n]) - # evaluate if the selection and node intersect - if not (x > bbox[2] + bbox[0] or otherX < bbox[0] or y > bbox[3] + bbox[1] or otherY < bbox[1]): - self.appendSelection(n) - self.selectedNodesChanged.emit() - - @Slot() - def clearNodeSelection(self): - """ Clear all node selection. """ - self._selectedNode = None - self._selectedNodes.clear() - self.selectedNodeChanged.emit() - self.selectedNodesChanged.emit() - - def clearNodeHover(self): - """ Reset currently hovered node to None. """ - self.hoveredNode = None - @Slot(result=str) - def getSelectedNodesContent(self): + def getSelectedNodesContent(self) -> str: """ Return the content of the currently selected nodes in a string, formatted to JSON. If no node is currently selected, an empty string is returned. @@ -1158,6 +1191,8 @@ def pasteNodes(self, clipboardContent, position=None, centerPosition=False): # Currently selected nodes selectedNodes = makeProperty(QObject, "_selectedNodes", selectedNodesChanged, resetOnDestroy=True) + nodeSelection = makeProperty(QObject, "_nodeSelection") + hoveredNodeChanged = Signal() # Currently hovered node hoveredNode = makeProperty(QObject, "_hoveredNode", hoveredNodeChanged, resetOnDestroy=True) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 3e052acde3..f73251d9d5 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -195,24 +195,21 @@ Item { if (mouse.button != Qt.MiddleButton && mouse.modifiers == Qt.NoModifier) { uigraph.clearNodeSelection() } - if (mouse.button == Qt.LeftButton && (mouse.modifiers == Qt.NoModifier || mouse.modifiers == Qt.ControlModifier)) { - boxSelect.startX = mouseX - boxSelect.startY = mouseY - boxSelectDraggable.x = mouseX - boxSelectDraggable.y = mouseY - drag.target = boxSelectDraggable + if (mouse.button == Qt.LeftButton && (mouse.modifiers == Qt.NoModifier || mouse.modifiers & (Qt.ControlModifier | Qt.ShiftModifier))) { + nodeSelectionBox.startSelection(mouse); } - if (mouse.button == Qt.MiddleButton || (mouse.button == Qt.LeftButton && mouse.modifiers & Qt.ShiftModifier)) { + if (mouse.button == Qt.MiddleButton || (mouse.button == Qt.LeftButton && mouse.modifiers & Qt.AltModifier)) { drag.target = draggable // start drag } } onReleased: { - drag.target = undefined // stop drag + nodeSelectionBox.endSelection(); + drag.target = null; root.forceActiveFocus() workspaceClicked() } - + onPositionChanged: { if (drag.active) workspaceMoved() @@ -820,15 +817,20 @@ Item { } // Nodes - Repeater { + Repeater { id: nodeRepeater model: root.graph ? root.graph.nodes : undefined property bool loaded: model ? count === model.count : false - property bool dragging: false + property bool ongoingDrag: false + property bool updateSelectionOnClick: false property var temporaryEdgeAboutToBeRemoved: undefined + function isNodeSelected(index: int) { + return uigraph.nodeSelection.isRowSelected(index); + } + delegate: Node { id: nodeDelegate @@ -836,43 +838,76 @@ Item { width: uigraph.layout.nodeWidth mainSelected: uigraph.selectedNode === node - selected: uigraph.selectedNodes.contains(node) hovered: uigraph.hoveredNode === node + selected: nodeRepeater.isNodeSelected(index); + onAttributePinCreated: function(attribute, pin) { registerAttributePin(attribute, pin) } onAttributePinDeleted: function(attribute, pin) { unregisterAttributePin(attribute, pin) } + Connections { + target: uigraph.nodeSelection + + function onSelectionChanged() { + selected = nodeRepeater.isNodeSelected(index); + } + } + onPressed: function(mouse) { + nodeRepeater.updateSelectionOnClick = true; + nodeRepeater.ongoingDrag = true; + + let selectionMode = ItemSelectionModel.NoUpdate; + + if(!selected) { + selectionMode = ItemSelectionModel.ClearAndSelect; + } + if (mouse.button === Qt.LeftButton) { - if (mouse.modifiers & Qt.ControlModifier && !(mouse.modifiers & Qt.AltModifier)) { - if (mainSelected && selected) { - // Left clicking a selected node twice with control will deselect it - uigraph.selectedNodes.remove(node) - uigraph.selectedNodesChanged() - selectNode(null) - return - } - } else if (mouse.modifiers & Qt.AltModifier) { - if (!(mouse.modifiers & Qt.ControlModifier)) { - uigraph.clearNodeSelection() - } - uigraph.selectFollowing(node) - } else if (!mainSelected && !selected) { - uigraph.clearNodeSelection() + if(mouse.modifiers & Qt.ShiftModifier) { + selectionMode = ItemSelectionModel.Select; } - } else if (mouse.button === Qt.RightButton) { - if (!mainSelected && !selected) { - uigraph.clearNodeSelection() + if(mouse.modifiers & Qt.ControlModifier) { + selectionMode = ItemSelectionModel.Deselect; + } + if(mouse.modifiers & Qt.AltModifier) { + uigraph.selectFollowing(node); + selectionMode = ItemSelectionModel.Select; + } + } + else if (mouse.button === Qt.RightButton) { + if(selected) { + // Keep the full selection when right-clicking on a node. + nodeRepeater.updateSelectionOnClick = false; } nodeMenu.currentNode = node nodeMenu.popup() } - selectNode(node) + + if(selectionMode != ItemSelectionModel.NoUpdate) { + nodeRepeater.updateSelectionOnClick = false; + uigraph.selectNodeByIndex(index, selectionMode); + } + + // If the node is selected after this, make it the active selected node. + if(selected) { + uigraph.selectedNode = node; + } } - onDoubleClicked: function(mouse) { root.nodeDoubleClicked(mouse, node) } + onReleased: function(mouse, wasDragged) { + nodeRepeater.ongoingDrag = false; + } + + // Only called when the node has not been dragged. + onClicked: function(mouse) { + if(!nodeRepeater.updateSelectionOnClick) { + return; + } + uigraph.selectNodeByIndex(index); + } - onMoved: function(position) { uigraph.moveNode(node, position, uigraph.selectedNodes) } + onDoubleClicked: function(mouse) { root.nodeDoubleClicked(mouse, node) } onEntered: uigraph.hoveredNode = node onExited: uigraph.hoveredNode = null @@ -899,62 +934,57 @@ Item { } } + // Interactive dragging: move the visual delegates onPositionChanged: { - if (dragging && uigraph.selectedNodes.contains(node)) { - // Update all selected nodes positions with this node that is being dragged - for (var i = 0; i < nodeRepeater.count; i++) { - var otherNode = nodeRepeater.itemAt(i) - if (uigraph.selectedNodes.contains(otherNode.node) && otherNode.node !== node) { - otherNode.x = otherNode.node.x + (x - node.x) - otherNode.y = otherNode.node.y + (y - node.y) - } - } + if(!selected || !dragging) { + return; } + // Compute offset between the delegate and the stored node position. + const offset = Qt.point(x - node.x, y - node.y); + + uigraph.nodeSelection.selectedIndexes.forEach(function(idx) { + if(idx != index) { + const delegate = nodeRepeater.itemAt(idx.row); + delegate.x = delegate.node.x + offset.x; + delegate.y = delegate.node.y + offset.y; + } + }); } - // Allow all nodes to know if they are being dragged - onDraggingChanged: nodeRepeater.dragging = dragging + // After drag: apply the final offset to all selected nodes + onMoved: function(position) { + const offset = Qt.point(position.x - node.x, position.y - node.y); + uigraph.moveSelectedNodesBy(offset); + } - // Must not be enabled during drag because the other nodes will be slow to match the movement of the node being dragged Behavior on x { - enabled: !nodeRepeater.dragging + enabled: !nodeRepeater.ongoingDrag NumberAnimation { duration: 100 } } Behavior on y { - enabled: !nodeRepeater.dragging + enabled: !nodeRepeater.ongoingDrag NumberAnimation { duration: 100 } } } } } - Rectangle { - id: boxSelect - property int startX: 0 - property int startY: 0 - property int toX: boxSelectDraggable.x - startX - property int toY: boxSelectDraggable.y - startY - - x: toX < 0 ? startX + toX : startX - y: toY < 0 ? startY + toY : startY - width: Math.abs(toX) - height: Math.abs(toY) - - color: "transparent" - border.color: activePalette.text - visible: mouseArea.drag.target == boxSelectDraggable - - onVisibleChanged: { - if (!visible) { - uigraph.boxSelect(boxSelect, draggable) + DelegateSelectionBox { + id: nodeSelectionBox + mouseArea: mouseArea + modelInstantiator: nodeRepeater + container: draggable + onDelegateSelectionEnded: function(selectedIndices, modifiers) { + let selectionMode = ItemSelectionModel.ClearAndSelect; + if(modifiers & Qt.ShiftModifier) { + selectionMode = ItemSelectionModel.Select; + } else if(modifiers & Qt.ControlModifier) { + selectionMode = ItemSelectionModel.Deselect; } + uigraph.selectNodesByIndices(selectedIndices, selectionMode); } } - Item { - id: boxSelectDraggable - } - DropArea { id: dropArea anchors.fill: parent From 686927a92d00f77d5deb3cbae870aa91274914a8 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:14:43 +0100 Subject: [PATCH 05/19] [ui] Graph: add `removeSelectedNodes` method Add and use an explicit method to remove the currently selected nodes in a graph. --- meshroom/ui/graph.py | 17 +++++++++++------ meshroom/ui/qml/Application.qml | 2 +- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 8 ++++---- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index ed8dae9468..00583a34dd 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -676,18 +676,23 @@ def moveSelectedNodesBy(self, offset: QPoint): position = Position(node.x + offset.x(), node.y + offset.y()) self.moveNode(node, position) - @Slot(QObject) - def removeNodes(self, nodes): + @Slot() + def removeSelectedNodes(self): + """Remove selected nodes from the graph.""" + self.removeNodes(list(self.iterSelectedNodes())) + + @Slot(list) + def removeNodes(self, nodes: list[Node]): """ Remove 'nodes' from the graph. Args: - nodes (list[Node]): the nodes to remove + nodes: The nodes to remove. """ - nodes = self.filterNodes(nodes) - if any([ n.locked for n in nodes ]): + if any(n.locked for n in nodes): return - with self.groupedGraphModification("Remove Selected Nodes"): + + with self.groupedGraphModification("Remove Nodes"): for node in nodes: self.push(commands.RemoveNodeCommand(self._graph, node)) diff --git a/meshroom/ui/qml/Application.qml b/meshroom/ui/qml/Application.qml index 64ce5a3ce4..9d5c2f5c15 100644 --- a/meshroom/ui/qml/Application.qml +++ b/meshroom/ui/qml/Application.qml @@ -541,7 +541,7 @@ Page { enabled: _reconstruction ? _reconstruction.selectedNodes.count > 0 : false onTriggered: { graphEditor.copyNodes() - graphEditor.uigraph.removeNodes(graphEditor.uigraph.selectedNodes) + graphEditor.uigraph.removeSelectedNodes() } } diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index f73251d9d5..7d2acc55f2 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -140,13 +140,13 @@ Item { if (event.modifiers === Qt.AltModifier) { uigraph.removeNodesFrom(uigraph.selectedNodes) } else { - uigraph.removeNodes(uigraph.selectedNodes) + uigraph.removeSelectedNodes() } } else if (event.key === Qt.Key_D) { duplicateNode(event.modifiers === Qt.AltModifier) } else if (event.key === Qt.Key_X && event.modifiers === Qt.ControlModifier) { copyNodes() - uigraph.removeNodes(uigraph.selectedNodes) + uigraph.removeSelectedNodes() } else if (event.key === Qt.Key_C) { if (event.modifiers === Qt.ControlModifier) { copyNodes() @@ -685,7 +685,7 @@ Item { ToolTip.visible: hovered onTriggered: { copyNodes() - uigraph.removeNodes(uigraph.selectedNodes) + uigraph.removeSelectedNodes() } } MenuItem { @@ -726,7 +726,7 @@ Item { MenuItem { text: "Remove Node(s)" + (removeFollowingButton.hovered ? " From Here" : "") enabled: nodeMenu.currentNode ? !nodeMenu.currentNode.locked : false - onTriggered: uigraph.removeNodes(uigraph.selectedNodes) + onTriggered: uigraph.removeSelectedNodes() MaterialToolButton { id: removeFollowingButton height: parent.height From b3a8c6a1f2fe6532e21b152c66bc77726612e68f Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:14:50 +0100 Subject: [PATCH 06/19] [ui] GraphEditor: move node context menu to a Loader Avoid having the node context menu always evaluating the current state of the selected nodes for its own display, by dynamically creating it on demand with a Loader. Use callbacks for recomputing/resubmitting actions, instead of storing state in the UI components. --- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 163 +++++++++++--------- 1 file changed, 93 insertions(+), 70 deletions(-) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 7d2acc55f2..13051c9515 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -31,8 +31,6 @@ Item { signal computeRequest(var nodes) signal submitRequest(var nodes) - signal dataDeleted() - property int nbMeshroomScenes: 0 property int nbDraggedFiles: 0 signal filesDropped(var drop, var mousePosition) // Files have been dropped @@ -70,17 +68,6 @@ Item { } } - onDataDeleted: { - if (computeMenuItem.recompute) { - computeRequest(uigraph.selectedNodes) - computeMenuItem.recompute = false - } - else if (submitMenuItem.resubmit) { - submitRequest(uigraph.selectedNodes) - submitMenuItem.resubmit = false - } - } - /// Duplicate a node and optionally all the following ones function duplicateNode(duplicateFollowingNodes) { var nodes @@ -549,12 +536,44 @@ Item { } } + Loader { + id: nodeMenuLoader + property var currentNode: null + active: currentNode != null + sourceComponent: nodeMenuComponent + + function load(node) { + currentNode = node; + } + + function unload() { + currentNode = null; + } + + function showDataDeletionDialog(deleteFollowing: bool, callback) { + uigraph.forceNodesStatusUpdate(); + const dialog = deleteDataDialog.createObject( + root, + { + "node": currentNode, + "deleteFollowing": deleteFollowing + } + ); + dialog.open(); + if(callback) + dialog.dataDeleted.connect(callback); + } + } + + Component { + id: nodeMenuComponent Menu { id: nodeMenu - property var currentNode: null - property bool canComputeNode: currentNode != null && uigraph.graph.canComputeTopologically(currentNode) + + property var currentNode: nodeMenuLoader.currentNode + property bool canComputeNode: uigraph.graph.canComputeTopologically(currentNode) // canSubmitOrCompute: return int n : 0 >= n <= 3 | n=0 cannot submit or compute | n=1 can compute | n=2 can submit | n=3 can compute & submit - property int canSubmitOrCompute: currentNode != null && uigraph.graph.canSubmitOrCompute(currentNode) + property int canSubmitOrCompute: uigraph.graph.canSubmitOrCompute(currentNode) property bool isComputed: { var count = 0 for (var i = 0; i < uigraph.selectedNodes.count; ++i) { @@ -567,12 +586,14 @@ Item { } return count > 0 } + width: 220 - onClosed: currentNode = null + + Component.onCompleted: popup() + onClosed: nodeMenuLoader.unload() MenuItem { id: computeMenuItem - property bool recompute: false text: nodeMenu.isComputed ? "Recompute" : "Compute" visible: { var count = 0 @@ -607,10 +628,14 @@ Item { onTriggered: { if (nodeMenu.isComputed) { - recompute = true - deleteDataMenuItem.showConfirmationDialog(false) + nodeMenuLoader.showDataDeletionDialog( + false, + function(request, uigraph) { + request(uigraph.selectedNodes); + }.bind(null, computeRequest, uigraph) + ); } else { - computeRequest(uigraph.selectedNodes) + computeRequest(uigraph.selectedNodes); } } } @@ -648,35 +673,39 @@ Item { } onTriggered: { if (nodeMenu.isComputed) { - resubmit = true - deleteDataMenuItem.showConfirmationDialog(false) + nodeMenuLoader.showDataDeletionDialog( + false, + function(request, uigraph) { + request(uigraph.selectedNodes); + }.bind(null, submitRequest, uigraph) + ); } else { - submitRequest(uigraph.selectedNodes) + submitRequest(uigraph.selectedNodes); } } } MenuItem { text: "Stop Computation" - enabled: nodeMenu.currentNode ? nodeMenu.currentNode.canBeStopped() : false + enabled: nodeMenu.currentNode.canBeStopped() visible: enabled height: visible ? implicitHeight : 0 onTriggered: uigraph.stopNodeComputation(nodeMenu.currentNode) } MenuItem { text: "Cancel Computation" - enabled: nodeMenu.currentNode ? nodeMenu.currentNode.canBeCanceled() : false + enabled: nodeMenu.currentNode.canBeCanceled() visible: enabled height: visible ? implicitHeight : 0 onTriggered: uigraph.cancelNodeComputation(nodeMenu.currentNode) } MenuItem { text: "Open Folder" - visible: nodeMenu.currentNode ? nodeMenu.currentNode.isComputable : false + visible: nodeMenu.currentNode.isComputable height: visible ? implicitHeight : 0 onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(nodeMenu.currentNode.internalFolder)) } MenuSeparator { - visible: nodeMenu.currentNode ? nodeMenu.currentNode.isComputable : false + visible: nodeMenu.currentNode.isComputable } MenuItem { text: "Cut Node(s)" @@ -725,7 +754,7 @@ Item { } MenuItem { text: "Remove Node(s)" + (removeFollowingButton.hovered ? " From Here" : "") - enabled: nodeMenu.currentNode ? !nodeMenu.currentNode.locked : false + enabled: !nodeMenu.currentNode.locked onTriggered: uigraph.removeSelectedNodes() MaterialToolButton { id: removeFollowingButton @@ -742,12 +771,12 @@ Item { } } MenuSeparator { - visible: nodeMenu.currentNode ? nodeMenu.currentNode.isComputable : false + visible: nodeMenu.currentNode.isComputable } MenuItem { id: deleteDataMenuItem text: "Delete Data" + (deleteFollowingButton.hovered ? " From Here" : "" ) + "..." - visible: nodeMenu.currentNode ? nodeMenu.currentNode.isComputable : false + visible: nodeMenu.currentNode.isComputable height: visible ? implicitHeight : 0 enabled: { if (!nodeMenu.currentNode) @@ -763,18 +792,7 @@ Item { return true } - function showConfirmationDialog(deleteFollowing) { - uigraph.forceNodesStatusUpdate() - var obj = deleteDataDialog.createObject(root, - { - "node": nodeMenu.currentNode, - "deleteFollowing": deleteFollowing - }) - obj.open() - nodeMenu.close() - } - - onTriggered: showConfirmationDialog(false) + onTriggered: nodeMenuLoader.showDataDeletionDialog(false) MaterialToolButton { id: deleteFollowingButton @@ -784,35 +802,41 @@ Item { } height: parent.height text: MaterialIcons.fast_forward - onClicked: parent.showConfirmationDialog(true) + onClicked: { + nodeMenuLoader.showDataDeletionDialog(true); + nodeMenu.close(); + } } - // Confirmation dialog for node cache deletion - Component { - id: deleteDataDialog - MessageDialog { - property var node - property bool deleteFollowing: false - - focus: true - modal: false - header.visible: false - - text: "Delete Data of '" + node.label + "'" + (uigraph.selectedNodes.count > 1 ? " and other selected Nodes" : "") + (deleteFollowing ? " and following Nodes?" : "?") - helperText: "Warning: This operation cannot be undone." - standardButtons: Dialog.Yes | Dialog.Cancel - - onAccepted: { - if (deleteFollowing) - uigraph.clearDataFrom(uigraph.selectedNodes) - else - uigraph.clearData(uigraph.selectedNodes) + } + } + } - root.dataDeleted() - } - onClosed: destroy() - } + // Confirmation dialog for node cache deletion + Component { + id: deleteDataDialog + MessageDialog { + property var node + property bool deleteFollowing: false + + signal dataDeleted() + + focus: true + modal: false + header.visible: false + + text: "Delete Data of '" + node.label + "'" + (uigraph.selectedNodes.count > 1 ? " and other selected Nodes" : "") + (deleteFollowing ? " and following Nodes?" : "?") + helperText: "Warning: This operation cannot be undone." + standardButtons: Dialog.Yes | Dialog.Cancel + + onAccepted: { + if (deleteFollowing) + uigraph.clearDataFrom(uigraph.selectedNodes); + else + uigraph.clearData(uigraph.selectedNodes); + dataDeleted(); } + onClosed: destroy() } } @@ -880,8 +904,7 @@ Item { // Keep the full selection when right-clicking on a node. nodeRepeater.updateSelectionOnClick = false; } - nodeMenu.currentNode = node - nodeMenu.popup() + nodeMenuLoader.load(node) } if(selectionMode != ItemSelectionModel.NoUpdate) { From b5836d96ed20272a74069ee31f3d78b2cd078575 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:14:50 +0100 Subject: [PATCH 07/19] [ui] GraphEditor: Create node context menu after selection update Delay the loading of the node context menu once the node selection has been updated, for it to consider the proper selection. --- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 13051c9515..d1feb24e42 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -901,10 +901,9 @@ Item { } else if (mouse.button === Qt.RightButton) { if(selected) { - // Keep the full selection when right-clicking on a node. + // Keep the full selection when right-clicking on an already selected node. nodeRepeater.updateSelectionOnClick = false; } - nodeMenuLoader.load(node) } if(selectionMode != ItemSelectionModel.NoUpdate) { @@ -916,6 +915,12 @@ Item { if(selected) { uigraph.selectedNode = node; } + + // Open the node context menu once selection has been updated. + if(mouse.button == Qt.RightButton) { + nodeMenuLoader.load(node) + } + } onReleased: function(mouse, wasDragged) { From ade1f87b8fc00499e54bc0e8ea6db4898fd11bdb Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:14:50 +0100 Subject: [PATCH 08/19] [ui] GraphEditor: Centralize node selection computability status Re-write the computability status of the current node selection as properties within the node menu component. Note that this should be further improved to better scale with the size of the selection, as it requires to traverse the graph for each node. --- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 127 ++++++++------------ 1 file changed, 53 insertions(+), 74 deletions(-) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index d1feb24e42..9db6aaa687 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -571,20 +571,50 @@ Item { id: nodeMenu property var currentNode: nodeMenuLoader.currentNode - property bool canComputeNode: uigraph.graph.canComputeTopologically(currentNode) - // canSubmitOrCompute: return int n : 0 >= n <= 3 | n=0 cannot submit or compute | n=1 can compute | n=2 can submit | n=3 can compute & submit - property int canSubmitOrCompute: uigraph.graph.canSubmitOrCompute(currentNode) - property bool isComputed: { - var count = 0 - for (var i = 0; i < uigraph.selectedNodes.count; ++i) { - var node = uigraph.selectedNodes.at(i) - if (!node) - continue - if (!node.isComputed) - return false - count += 1 - } - return count > 0 + + readonly property bool isSelectionFullyComputed: { + return uigraph.nodeSelection.selectedIndexes.every(function(idx) { + return uigraph.graph.nodes.at(idx.row).isComputed; + }); + } + readonly property bool isSelectionOnlyComputableNodes: { + return uigraph.nodeSelection.selectedIndexes.every(function(idx) { + const node = uigraph.graph.nodes.at(idx.row); + return ( + node.isComputable + && uigraph.graph.canComputeTopologically(node) + ); + }); + } + readonly property bool canSelectionBeComputed: { + if(!isSelectionOnlyComputableNodes) + return false; + if(isSelectionFullyComputed) + return true; + return uigraph.nodeSelection.selectedIndexes.every(function(idx) { + const node = uigraph.graph.nodes.at(idx.row); + return ( + node.isComputed + // canCompute if canSubmitOrCompute == 1(can compute) or 3(can compute & submit) + || uigraph.graph.canSubmitOrCompute(node) % 2 == 1 + ); + }); + } + readonly property bool isSelectionSubmittable: uigraph.canSubmit && isSelectionOnlyComputableNodes + + readonly property bool canSelectionBeSubmitted: { + if(!isSelectionOnlyComputableNodes) + return false; + if(isSelectionFullyComputed) + return true; + return uigraph.nodeSelection.selectedIndexes.every(function(idx) { + const node = uigraph.graph.nodes.at(idx.row); + return ( + node.isComputed + // canSubmit if canSubmitOrCompute == 2(can submit) or 3(can compute & submit) + || uigraph.graph.canSubmitOrCompute(node) > 1 + ) + }); } width: 220 @@ -594,40 +624,13 @@ Item { MenuItem { id: computeMenuItem - text: nodeMenu.isComputed ? "Recompute" : "Compute" - visible: { - var count = 0 - for (var i = 0; i < uigraph.selectedNodes.count; ++i) { - var node = uigraph.selectedNodes.at(i) - if (!node) - continue - if (!node.isComputable) - return false - count += 1 - } - return count > 0 - } + text: nodeMenu.isSelectionFullyComputed ? "Recompute" : "Compute" + visible: nodeMenu.isSelectionOnlyComputableNodes height: visible ? implicitHeight : 0 - - enabled: { - var canCompute = false - for (var i = 0; i < uigraph.selectedNodes.count; ++i) { - var node = uigraph.selectedNodes.at(i) - if (!node) - continue - if (uigraph.graph.canComputeTopologically(node)) { - if (nodeMenu.isComputed) { - canCompute = true - } else if (uigraph.graph.canSubmitOrCompute(node) % 2 == 1) { - canCompute = true - } - } - } - return canCompute // canSubmit if canSubmitOrCompute == 1(can compute) or 3(can compute & submit) - } + enabled: nodeMenu.canSelectionBeComputed onTriggered: { - if (nodeMenu.isComputed) { + if (nodeMenu.isSelectionFullyComputed) { nodeMenuLoader.showDataDeletionDialog( false, function(request, uigraph) { @@ -641,38 +644,14 @@ Item { } MenuItem { id: submitMenuItem - property bool resubmit: false - text: nodeMenu.isComputed ? "Re-Submit" : "Submit" - visible: { - var count = 0 - for (var i = 0; i < uigraph.selectedNodes.count; ++i) { - var node = uigraph.selectedNodes.at(i) - if (node && !node.isComputable) - return false - count += 1 - } - return count > 0 || uigraph.canSubmit - } + + text: nodeMenu.isSelectionFullyComputed ? "Re-Submit" : "Submit" + visible: nodeMenu.isSelectionSubmittable height: visible ? implicitHeight : 0 + enabled: nodeMenu.canSelectionBeSubmitted - enabled: { - var canSubmit = false - for (var i = 0; i < uigraph.selectedNodes.count; ++i) { - var node = uigraph.selectedNodes.at(i) - if (!node) - continue - if (uigraph.graph.canComputeTopologically(node)) { - if (nodeMenu.isComputed) { - canSubmit = true - } else if (uigraph.graph.canSubmitOrCompute(node) > 1) { - canSubmit = true - } - } - } - return canSubmit - } onTriggered: { - if (nodeMenu.isComputed) { + if (nodeMenu.isSelectionFullyComputed) { nodeMenuLoader.showDataDeletionDialog( false, function(request, uigraph) { From 107b1e959a74784099397fb53db9587b0d8eba29 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:14:50 +0100 Subject: [PATCH 09/19] [ui] GraphEditor: cache selected node computability status Avoid to evaluate the computability/submitability status of each node twice by caching the information when creating the node context menu. --- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 9db6aaa687..d7de413d4d 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -572,11 +572,22 @@ Item { property var currentNode: nodeMenuLoader.currentNode + // Cache computatibility/submitability status of each selected node. + readonly property var nodeSubmitOrComputeStatus: { + var collectedStatus = ({}); + uigraph.nodeSelection.selectedIndexes.forEach(function(idx) { + const node = uigraph.graph.nodes.at(idx.row); + collectedStatus[node] = uigraph.graph.canSubmitOrCompute(node); + }); + return collectedStatus; + } + readonly property bool isSelectionFullyComputed: { return uigraph.nodeSelection.selectedIndexes.every(function(idx) { return uigraph.graph.nodes.at(idx.row).isComputed; }); } + readonly property bool isSelectionOnlyComputableNodes: { return uigraph.nodeSelection.selectedIndexes.every(function(idx) { const node = uigraph.graph.nodes.at(idx.row); @@ -586,6 +597,7 @@ Item { ); }); } + readonly property bool canSelectionBeComputed: { if(!isSelectionOnlyComputableNodes) return false; @@ -596,10 +608,11 @@ Item { return ( node.isComputed // canCompute if canSubmitOrCompute == 1(can compute) or 3(can compute & submit) - || uigraph.graph.canSubmitOrCompute(node) % 2 == 1 + || nodeSubmitOrComputeStatus[node] % 2 == 1 ); }); } + readonly property bool isSelectionSubmittable: uigraph.canSubmit && isSelectionOnlyComputableNodes readonly property bool canSelectionBeSubmitted: { @@ -612,7 +625,7 @@ Item { return ( node.isComputed // canSubmit if canSubmitOrCompute == 2(can submit) or 3(can compute & submit) - || uigraph.graph.canSubmitOrCompute(node) > 1 + || nodeSubmitOrComputeStatus[node] > 1 ) }); } From d11a1f62cc55f08658470cd02e1dfcd5d9352103 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:14:50 +0100 Subject: [PATCH 10/19] [ui] Graph Editor: fix remaining use of deleted function --- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index d7de413d4d..595998b9ea 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -59,15 +59,6 @@ Item { return undefined } - /// Select node delegate - function selectNode(node) { - uigraph.selectedNode = node - if (node !== null) { - uigraph.appendSelection(node) - uigraph.selectedNodesChanged() - } - } - /// Duplicate a node and optionally all the following ones function duplicateNode(duplicateFollowingNodes) { var nodes @@ -219,14 +210,13 @@ Item { height: searchBar.height + nodeMenuRepeater.height + instantiator.height function createNode(nodeType) { - uigraph.clearNodeSelection() // Ensures that only the created node / imported pipeline will be selected - // "nodeType" might be a pipeline (artificially added in the "Pipelines" category) instead of a node // If it is not a pipeline to import, then it must be a node if (!importPipeline(nodeType)) { // Add node via the proper command in uigraph - var node = uigraph.addNewNode(nodeType, spawnPosition) - selectNode(node) + var node = uigraph.addNewNode(nodeType, spawnPosition); + uigraph.selectedNode = node; + uigraph.selectNodes([node]) } close() } From 8dbcfa392fe4211ce2ac536ab07d0a8f7f910d74 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:14:50 +0100 Subject: [PATCH 11/19] [ui] Application: Simplify Cut/CopyNodes actions Remove dynamic tooltip for cut/copy actions that displays all selected node names: - This inline textual information is hard to process as a user. - Avoid binding to and iteration over the selection. --- meshroom/ui/qml/Application.qml | 37 ++++++--------------------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/meshroom/ui/qml/Application.qml b/meshroom/ui/qml/Application.qml index 9d5c2f5c15..48884e2f33 100644 --- a/meshroom/ui/qml/Application.qml +++ b/meshroom/ui/qml/Application.qml @@ -30,21 +30,6 @@ Page { property alias showImageGallery: imageGalleryVisibilityCB.checked } - // Utility functions for elements in the menubar - function getSelectedNodesName() { - if (!_reconstruction) - return "" - var nodesName = "" - for (var i = 0; i < _reconstruction.selectedNodes.count; i++) { - if (nodesName !== "") - nodesName += ", " - var node = _reconstruction.selectedNodes.at(i) - if(node) { - nodesName += node.name - } - } - return nodesName - } property url imagesFolder: { var recentImportedImagesFolders = MeshroomApp.recentImportedImagesFolders @@ -531,14 +516,9 @@ Page { Action { id: cutAction - property string tooltip: { - var s = "Copy selected node" - s += (_reconstruction && _reconstruction.selectedNodes.count > 1 ? "s (" : " (") + getSelectedNodesName() - s += ") to the clipboard and remove them from the graph" - return s - } - text: "Cut Node" + (_reconstruction && _reconstruction.selectedNodes.count > 1 ? "s " : " ") - enabled: _reconstruction ? _reconstruction.selectedNodes.count > 0 : false + property string tooltip: "Cut Selected Node(s)" + text: "Cut Node(s)" + enabled: _reconstruction ? _reconstruction.nodeSelection.hasSelection : false onTriggered: { graphEditor.copyNodes() graphEditor.uigraph.removeSelectedNodes() @@ -548,14 +528,9 @@ Page { Action { id: copyAction - property string tooltip: { - var s = "Copy selected node" - s += (_reconstruction && _reconstruction.selectedNodes.count > 1 ? "s (" : " (") + getSelectedNodesName() - s += ") to the clipboard" - return s - } - text: "Copy Node" + (_reconstruction && _reconstruction.selectedNodes.count > 1 ? "s " : " ") - enabled: _reconstruction ? _reconstruction.selectedNodes.count > 0 : false + property string tooltip: "Copy Selected Node(s)" + text: "Copy Node(s)" + enabled: _reconstruction ? _reconstruction.nodeSelection.hasSelection : false onTriggered: graphEditor.copyNodes() } From cdfa6186b1feccf98ec79b79d2796b9be980ce60 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:14:50 +0100 Subject: [PATCH 12/19] [ui] Graph: Add `clearSelectedNodesData` method Convenient function to directly work on the current node selection. --- meshroom/ui/graph.py | 15 +++++++++------ meshroom/ui/qml/GraphEditor/GraphEditor.qml | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 00583a34dd..0ec0c6139c 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -813,21 +813,24 @@ def collapseForLoop(self, currentEdge): # update the edges from allSrc allSrc = [e.src for e in self._graph.edges.values()] + @Slot() + def clearSelectedNodesData(self): + """Clear data from all selected nodes.""" + self.clearData(self.iterSelectedNodes()) - @Slot(QObject) - def clearData(self, nodes): + @Slot(list) + def clearData(self, nodes: list[Node]): """ Clear data from 'nodes'. """ - nodes = self.filterNodes(nodes) for n in nodes: n.clearData() - @Slot(QObject) - def clearDataFrom(self, nodes): + @Slot(list) + def clearDataFrom(self, nodes: list[Node]): """ Clear data from all nodes starting from 'nodes' to graph leaves. Args: - nodes (list[Node]): the nodes to start from. + nodes: The nodes to start from. """ self.clearData(self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True)[0]) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 595998b9ea..e0b3621758 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -815,7 +815,7 @@ Item { if (deleteFollowing) uigraph.clearDataFrom(uigraph.selectedNodes); else - uigraph.clearData(uigraph.selectedNodes); + uigraph.clearSelectedNodesData(); dataDeleted(); } onClosed: destroy() From 67bd43e0404e3f0cdc451986500f5f775a83fd01 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:14:50 +0100 Subject: [PATCH 13/19] [ui] Graph: remove `selectedNodes` model Expose `getSelectedNode` that relies on the QItemSelectionModel for imperative code in QML that still requires to access the selected node instances. --- meshroom/ui/graph.py | 84 +++++++++------------ meshroom/ui/qml/GraphEditor/GraphEditor.qml | 22 +++--- 2 files changed, 47 insertions(+), 59 deletions(-) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 0ec0c6139c..9fd8b089e1 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -7,7 +7,7 @@ from enum import Enum from threading import Thread, Event, Lock from multiprocessing.pool import ThreadPool -from typing import Iterator +from typing import Iterator, Optional, Union from PySide6.QtCore import ( Slot, @@ -369,9 +369,7 @@ def __init__(self, undoStack, taskManager, parent=None): self._sortedDFSChunks = QObjectListModel(parent=self) self._layout = GraphLayout(self) self._selectedNode = None - self._selectedNodes = QObjectListModel(parent=self) self._nodeSelection = QItemSelectionModel(self._graph.nodes, parent=self) - self._nodeSelection.selectionChanged.connect(self.onNodeSelectionChanged) self._hoveredNode = None self.submitLabel = "{projectName}" @@ -516,9 +514,10 @@ def updateLockedUndoStack(self): else: self._undoStack.unlock() - @Slot(QObjectListModel) + @Slot() @Slot(Node) - def execute(self, nodes=None): + @Slot(list) + def execute(self, nodes: Optional[Union[list[Node], Node]] = None): nodes = [nodes] if not isinstance(nodes, Iterable) and nodes else nodes self._taskManager.compute(self._graph, nodes) self.updateLockedUndoStack() # explicitly call the update while it is already computing @@ -554,9 +553,10 @@ def cancelNodeComputation(self, node): n.clearSubmittedChunks() self._taskManager.removeNode(n, displayList=True, processList=True) - @Slot(QObjectListModel) + @Slot() @Slot(Node) - def submit(self, nodes=None): + @Slot(list) + def submit(self, nodes: Optional[Union[list[Node], Node]] = None): """ Submit the graph to the default Submitter. If a node is specified, submit this node and its uncomputed predecessors. Otherwise, submit the whole @@ -696,16 +696,14 @@ def removeNodes(self, nodes: list[Node]): for node in nodes: self.push(commands.RemoveNodeCommand(self._graph, node)) - @Slot(QObject) - def removeNodesFrom(self, nodes): + @Slot(list) + def removeNodesFrom(self, nodes: list[Node]): """ - Remove all nodes starting from 'startNode' to graph leaves. + Remove all nodes starting from 'nodes' to graph leaves. Args: - startNode (Node): the node to start from. + nodes: the nodes to start from. """ - if isinstance(nodes, Node): - nodes = [nodes] with self.groupedGraphModification("Remove Nodes From Selected Nodes"): nodesToRemove, _ = self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True) # filter out nodes that will be removed more than once @@ -714,17 +712,17 @@ def removeNodesFrom(self, nodes): # can be re-created in correct order on redo. self.removeNodes(list(reversed(uniqueNodesToRemove))) - @Slot(QObject, result="QVariantList") - def duplicateNodes(self, nodes): + @Slot(list, result=list) + def duplicateNodes(self, nodes: list[Node]) -> list[Node]: """ Duplicate 'nodes'. Args: - nodes (list[Node]): the nodes to duplicate + nodes: the nodes to duplicate. + Returns: - list[Node]: the list of duplicated nodes + The list of duplicated nodes. """ - nodes = self.filterNodes(nodes) nPositions = [(n.x, n.y) for n in self._graph.nodes] # enable updates between duplication and layout to get correct depths during layout with self.groupedGraphModification("Duplicate Selected Nodes", disableUpdates=False): @@ -747,18 +745,16 @@ def duplicateNodes(self, nodes): return duplicates - @Slot(QObject, result="QVariantList") - def duplicateNodesFrom(self, nodes): + @Slot(list, result=list) + def duplicateNodesFrom(self, nodes: list[Node]) -> list[Node]: """ Duplicate all nodes starting from 'nodes' to graph leaves. Args: - nodes (list[Node]): the nodes to start from. + node: The nodes to start from. Returns: - list[Node]: the list of duplicated nodes + The list of duplicated nodes. """ - if isinstance(nodes, Node): - nodes = [nodes] with self.groupedGraphModification("Duplicate Nodes From Selected Nodes"): nodesToDuplicate, _ = self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True) # filter out nodes that will be duplicated more than once @@ -789,7 +785,7 @@ def expandForLoop(self, currentEdge): dst = currentEdge.dst for i in range(1, len(listAttribute)): - duplicates = self.duplicateNodesFrom(dst.node) + duplicates = self.duplicateNodesFrom([dst.node]) newNode = duplicates[0] previousEdge = self.graph.edge(newNode.attribute(dst.name)) self.replaceEdge(previousEdge, listAttribute.at(i), previousEdge.dst) @@ -809,7 +805,7 @@ def collapseForLoop(self, currentEdge): continue occurence = allSrc.index(listAttribute.at(i)) if listAttribute.at(i) in allSrc else -1 if occurence != -1: - self.removeNodesFrom(self.graph.edges.at(occurence).dst.node) + self.removeNodesFrom([self.graph.edges.at(occurence).dst.node]) # update the edges from allSrc allSrc = [e.src for e in self._graph.edges.values()] @@ -954,11 +950,6 @@ def removeImagesFromAllGroups(self): with self.groupedGraphModification("Remove Images From All CameraInit Nodes"): self.push(commands.RemoveImagesCommand(self._graph, list(self.cameraInits))) - def onNodeSelectionChanged(self, selected, deselected): - # Update internal cache of selected Node instances. - self._selectedNodes.setObjectList(list(self.iterSelectedNodes())) - self.selectedNodesChanged.emit() - @Slot(list) @Slot(list, int) def selectNodes(self, nodes, command=QItemSelectionModel.SelectionFlag.ClearAndSelect): @@ -1014,6 +1005,11 @@ def iterSelectedNodes(self) -> Iterator[Node]: for idx in self._nodeSelection.selectedRows(): yield self._graph.nodes.at(idx.row()) + @Slot(result=list) + def getSelectedNodes(self) -> list[Node]: + """Return the list of selected Node instances.""" + return list(self.iterSelectedNodes()) + @Slot(Node, result=bool) def isSelected(self, node: Node) -> bool: """Whether `node` is part of the current selection.""" @@ -1046,19 +1042,17 @@ def setSelectedNodesColor(self, color: str): @Slot(result=str) def getSelectedNodesContent(self) -> str: """ - Return the content of the currently selected nodes in a string, formatted to JSON. - If no node is currently selected, an empty string is returned. + Serialize the current node selection and return it as JSON formatted string. + + Returns an empty string if the selection is empty. """ - if self._selectedNodes: - d = self._graph.toDict() - selection = {} - for node in self._selectedNodes: - selection[node.name] = d[node.name] - return json.dumps(selection, indent=4) - return '' - - @Slot(str, QPoint, bool, result="QVariantList") - def pasteNodes(self, clipboardContent, position=None, centerPosition=False): + if not self._nodeSelection.hasSelection(): + return "" + serializedSelection = {node.name: node.toDict() for node in self.iterSelectedNodes()} + return json.dumps(serializedSelection, indent=4) + + @Slot(str, QPoint, bool, result=list) + def pasteNodes(self, clipboardContent, position=None, centerPosition=False) -> list[Node]: """ Parse the content of the clipboard to see whether it contains valid node descriptions. If that is the case, the nodes described @@ -1195,10 +1189,6 @@ def pasteNodes(self, clipboardContent, position=None, centerPosition=False): # Current main selected node selectedNode = makeProperty(QObject, "_selectedNode", selectedNodeChanged, resetOnDestroy=True) - selectedNodesChanged = Signal() - # Currently selected nodes - selectedNodes = makeProperty(QObject, "_selectedNodes", selectedNodesChanged, resetOnDestroy=True) - nodeSelection = makeProperty(QObject, "_nodeSelection") hoveredNodeChanged = Signal() diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index e0b3621758..2fedca463b 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -63,11 +63,10 @@ Item { function duplicateNode(duplicateFollowingNodes) { var nodes if (duplicateFollowingNodes) { - nodes = uigraph.duplicateNodesFrom(uigraph.selectedNodes) + nodes = uigraph.duplicateNodesFrom(uigraph.getSelectedNodes()) } else { - nodes = uigraph.duplicateNodes(uigraph.selectedNodes) + nodes = uigraph.duplicateNodes(uigraph.getSelectedNodes()) } - uigraph.clearNodeSelection() uigraph.selectedNode = nodes[0] uigraph.selectNodes(nodes) } @@ -100,7 +99,6 @@ Item { var copiedContent = Clipboard.getText() var nodes = uigraph.pasteNodes(copiedContent, finalPosition, centerPosition) if (nodes.length > 0) { - uigraph.clearNodeSelection() uigraph.selectedNode = nodes[0] uigraph.selectNodes(nodes) } @@ -116,7 +114,7 @@ Item { fit() } else if (event.key === Qt.Key_Delete) { if (event.modifiers === Qt.AltModifier) { - uigraph.removeNodesFrom(uigraph.selectedNodes) + uigraph.removeNodesFrom(uigraph.getSelectedNodes()) } else { uigraph.removeSelectedNodes() } @@ -637,11 +635,11 @@ Item { nodeMenuLoader.showDataDeletionDialog( false, function(request, uigraph) { - request(uigraph.selectedNodes); + request(uigraph.getSelectedNodes()); }.bind(null, computeRequest, uigraph) ); } else { - computeRequest(uigraph.selectedNodes); + computeRequest(uigraph.getSelectedNodes()); } } } @@ -658,11 +656,11 @@ Item { nodeMenuLoader.showDataDeletionDialog( false, function(request, uigraph) { - request(uigraph.selectedNodes); + request(uigraph.getSelectedNodes()); }.bind(null, submitRequest, uigraph) ); } else { - submitRequest(uigraph.selectedNodes); + submitRequest(uigraph.getSelectedNodes()); } } } @@ -747,7 +745,7 @@ Item { } text: MaterialIcons.fast_forward onClicked: { - uigraph.removeNodesFrom(uigraph.selectedNodes) + uigraph.removeNodesFrom(uigraph.getSelectedNodes()) nodeMenu.close() } } @@ -807,13 +805,13 @@ Item { modal: false header.visible: false - text: "Delete Data of '" + node.label + "'" + (uigraph.selectedNodes.count > 1 ? " and other selected Nodes" : "") + (deleteFollowing ? " and following Nodes?" : "?") + text: "Delete Data of '" + node.label + "'" + (uigraph.nodeSelection.selectedIndexes.length > 1 ? " and other selected Nodes" : "") + (deleteFollowing ? " and following Nodes?" : "?") helperText: "Warning: This operation cannot be undone." standardButtons: Dialog.Yes | Dialog.Cancel onAccepted: { if (deleteFollowing) - uigraph.clearDataFrom(uigraph.selectedNodes); + uigraph.clearDataFrom(uigraph.getSelectedNodes()); else uigraph.clearSelectedNodesData(); dataDeleted(); From a3268f456c7ca6749824f8667e8a12a24305671b Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:14:50 +0100 Subject: [PATCH 14/19] [ui] Graph: cleanup unused function --- meshroom/ui/graph.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 9fd8b089e1..e1a756fbdd 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -651,12 +651,6 @@ def addNewNode(self, nodeType, position=None, **kwargs): position = Position(position.x(), position.y()) return self.push(commands.AddNodeCommand(self._graph, nodeType, position=position, **kwargs)) - def filterNodes(self, nodes): - """Filter out the nodes that do not exist on the graph.""" - if not isinstance(nodes, Iterable): - nodes = [nodes] - return [ n for n in nodes if n in self._graph.nodes.values() ] - def moveNode(self, node: Node, position: Position): """ Move `node` to the given `position`. From 4a60e24c2e39a4d526ca3273cb525f8b05a4ec8c Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:47:20 +0100 Subject: [PATCH 15/19] [ui] GraphEditor: Improve node selected status logic Instead of connecting to onSelectionChanged, use ItemSelectionModel.hasSelection property, that can be use for direct bindings with the same behavior. https://doc.qt.io/qt-6/qml-qtqml-models-itemselectionmodel.html#hasSelection-prop --- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 2fedca463b..23713a117a 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -831,10 +831,6 @@ Item { property bool updateSelectionOnClick: false property var temporaryEdgeAboutToBeRemoved: undefined - function isNodeSelected(index: int) { - return uigraph.nodeSelection.isRowSelected(index); - } - delegate: Node { id: nodeDelegate @@ -844,19 +840,12 @@ Item { mainSelected: uigraph.selectedNode === node hovered: uigraph.hoveredNode === node - selected: nodeRepeater.isNodeSelected(index); + // ItemSelectionModel.hasSelection triggers updates anytime the selectionChanged() signal is emitted. + selected: uigraph.nodeSelection.hasSelection ? uigraph.nodeSelection.isRowSelected(index) : false onAttributePinCreated: function(attribute, pin) { registerAttributePin(attribute, pin) } onAttributePinDeleted: function(attribute, pin) { unregisterAttributePin(attribute, pin) } - Connections { - target: uigraph.nodeSelection - - function onSelectionChanged() { - selected = nodeRepeater.isNodeSelected(index); - } - } - onPressed: function(mouse) { nodeRepeater.updateSelectionOnClick = true; nodeRepeater.ongoingDrag = true; From 87c0cef605e4ef2b359d7e678155e79b65b2e762 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 11:03:30 +0100 Subject: [PATCH 16/19] [ui] GraphEditor: fix indentation --- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 422 ++++++++++---------- 1 file changed, 211 insertions(+), 211 deletions(-) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 23713a117a..b2e7707952 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -555,242 +555,242 @@ Item { Component { id: nodeMenuComponent - Menu { - id: nodeMenu - - property var currentNode: nodeMenuLoader.currentNode - - // Cache computatibility/submitability status of each selected node. - readonly property var nodeSubmitOrComputeStatus: { - var collectedStatus = ({}); - uigraph.nodeSelection.selectedIndexes.forEach(function(idx) { - const node = uigraph.graph.nodes.at(idx.row); - collectedStatus[node] = uigraph.graph.canSubmitOrCompute(node); - }); - return collectedStatus; - } - - readonly property bool isSelectionFullyComputed: { - return uigraph.nodeSelection.selectedIndexes.every(function(idx) { - return uigraph.graph.nodes.at(idx.row).isComputed; - }); - } + Menu { + id: nodeMenu + + property var currentNode: nodeMenuLoader.currentNode - readonly property bool isSelectionOnlyComputableNodes: { - return uigraph.nodeSelection.selectedIndexes.every(function(idx) { - const node = uigraph.graph.nodes.at(idx.row); - return ( - node.isComputable - && uigraph.graph.canComputeTopologically(node) - ); - }); - } + // Cache computatibility/submitability status of each selected node. + readonly property var nodeSubmitOrComputeStatus: { + var collectedStatus = ({}); + uigraph.nodeSelection.selectedIndexes.forEach(function(idx) { + const node = uigraph.graph.nodes.at(idx.row); + collectedStatus[node] = uigraph.graph.canSubmitOrCompute(node); + }); + return collectedStatus; + } - readonly property bool canSelectionBeComputed: { - if(!isSelectionOnlyComputableNodes) - return false; - if(isSelectionFullyComputed) - return true; - return uigraph.nodeSelection.selectedIndexes.every(function(idx) { - const node = uigraph.graph.nodes.at(idx.row); - return ( - node.isComputed - // canCompute if canSubmitOrCompute == 1(can compute) or 3(can compute & submit) - || nodeSubmitOrComputeStatus[node] % 2 == 1 - ); - }); - } + readonly property bool isSelectionFullyComputed: { + return uigraph.nodeSelection.selectedIndexes.every(function(idx) { + return uigraph.graph.nodes.at(idx.row).isComputed; + }); + } - readonly property bool isSelectionSubmittable: uigraph.canSubmit && isSelectionOnlyComputableNodes - - readonly property bool canSelectionBeSubmitted: { - if(!isSelectionOnlyComputableNodes) - return false; - if(isSelectionFullyComputed) - return true; - return uigraph.nodeSelection.selectedIndexes.every(function(idx) { - const node = uigraph.graph.nodes.at(idx.row); - return ( - node.isComputed - // canSubmit if canSubmitOrCompute == 2(can submit) or 3(can compute & submit) - || nodeSubmitOrComputeStatus[node] > 1 - ) - }); - } + readonly property bool isSelectionOnlyComputableNodes: { + return uigraph.nodeSelection.selectedIndexes.every(function(idx) { + const node = uigraph.graph.nodes.at(idx.row); + return ( + node.isComputable + && uigraph.graph.canComputeTopologically(node) + ); + }); + } - width: 220 + readonly property bool canSelectionBeComputed: { + if(!isSelectionOnlyComputableNodes) + return false; + if(isSelectionFullyComputed) + return true; + return uigraph.nodeSelection.selectedIndexes.every(function(idx) { + const node = uigraph.graph.nodes.at(idx.row); + return ( + node.isComputed + // canCompute if canSubmitOrCompute == 1(can compute) or 3(can compute & submit) + || nodeSubmitOrComputeStatus[node] % 2 == 1 + ); + }); + } - Component.onCompleted: popup() - onClosed: nodeMenuLoader.unload() + readonly property bool isSelectionSubmittable: uigraph.canSubmit && isSelectionOnlyComputableNodes + + readonly property bool canSelectionBeSubmitted: { + if(!isSelectionOnlyComputableNodes) + return false; + if(isSelectionFullyComputed) + return true; + return uigraph.nodeSelection.selectedIndexes.every(function(idx) { + const node = uigraph.graph.nodes.at(idx.row); + return ( + node.isComputed + // canSubmit if canSubmitOrCompute == 2(can submit) or 3(can compute & submit) + || nodeSubmitOrComputeStatus[node] > 1 + ) + }); + } - MenuItem { - id: computeMenuItem - text: nodeMenu.isSelectionFullyComputed ? "Recompute" : "Compute" - visible: nodeMenu.isSelectionOnlyComputableNodes - height: visible ? implicitHeight : 0 - enabled: nodeMenu.canSelectionBeComputed - - onTriggered: { - if (nodeMenu.isSelectionFullyComputed) { - nodeMenuLoader.showDataDeletionDialog( - false, - function(request, uigraph) { - request(uigraph.getSelectedNodes()); - }.bind(null, computeRequest, uigraph) - ); - } else { - computeRequest(uigraph.getSelectedNodes()); + width: 220 + + Component.onCompleted: popup() + onClosed: nodeMenuLoader.unload() + + MenuItem { + id: computeMenuItem + text: nodeMenu.isSelectionFullyComputed ? "Recompute" : "Compute" + visible: nodeMenu.isSelectionOnlyComputableNodes + height: visible ? implicitHeight : 0 + enabled: nodeMenu.canSelectionBeComputed + + onTriggered: { + if (nodeMenu.isSelectionFullyComputed) { + nodeMenuLoader.showDataDeletionDialog( + false, + function(request, uigraph) { + request(uigraph.getSelectedNodes()); + }.bind(null, computeRequest, uigraph) + ); + } else { + computeRequest(uigraph.getSelectedNodes()); + } } } - } - MenuItem { - id: submitMenuItem - - text: nodeMenu.isSelectionFullyComputed ? "Re-Submit" : "Submit" - visible: nodeMenu.isSelectionSubmittable - height: visible ? implicitHeight : 0 - enabled: nodeMenu.canSelectionBeSubmitted - - onTriggered: { - if (nodeMenu.isSelectionFullyComputed) { - nodeMenuLoader.showDataDeletionDialog( - false, - function(request, uigraph) { - request(uigraph.getSelectedNodes()); - }.bind(null, submitRequest, uigraph) - ); - } else { - submitRequest(uigraph.getSelectedNodes()); + MenuItem { + id: submitMenuItem + + text: nodeMenu.isSelectionFullyComputed ? "Re-Submit" : "Submit" + visible: nodeMenu.isSelectionSubmittable + height: visible ? implicitHeight : 0 + enabled: nodeMenu.canSelectionBeSubmitted + + onTriggered: { + if (nodeMenu.isSelectionFullyComputed) { + nodeMenuLoader.showDataDeletionDialog( + false, + function(request, uigraph) { + request(uigraph.getSelectedNodes()); + }.bind(null, submitRequest, uigraph) + ); + } else { + submitRequest(uigraph.getSelectedNodes()); + } } } - } - MenuItem { - text: "Stop Computation" - enabled: nodeMenu.currentNode.canBeStopped() - visible: enabled - height: visible ? implicitHeight : 0 - onTriggered: uigraph.stopNodeComputation(nodeMenu.currentNode) - } - MenuItem { - text: "Cancel Computation" - enabled: nodeMenu.currentNode.canBeCanceled() - visible: enabled - height: visible ? implicitHeight : 0 - onTriggered: uigraph.cancelNodeComputation(nodeMenu.currentNode) - } - MenuItem { - text: "Open Folder" - visible: nodeMenu.currentNode.isComputable - height: visible ? implicitHeight : 0 - onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(nodeMenu.currentNode.internalFolder)) - } - MenuSeparator { - visible: nodeMenu.currentNode.isComputable - } - MenuItem { - text: "Cut Node(s)" - enabled: true - ToolTip.text: "Copy selection to the clipboard and remove it" - ToolTip.visible: hovered - onTriggered: { - copyNodes() - uigraph.removeSelectedNodes() + MenuItem { + text: "Stop Computation" + enabled: nodeMenu.currentNode.canBeStopped() + visible: enabled + height: visible ? implicitHeight : 0 + onTriggered: uigraph.stopNodeComputation(nodeMenu.currentNode) } - } - MenuItem { - text: "Copy Node(s)" - enabled: true - ToolTip.text: "Copy selection to the clipboard" - ToolTip.visible: hovered - onTriggered: copyNodes() - } - MenuItem { - text: "Paste Node(s)" - enabled: true - ToolTip.text: "Copy selection to the clipboard and immediately paste it" - ToolTip.visible: hovered - onTriggered: { - copyNodes() - pasteNodes() + MenuItem { + text: "Cancel Computation" + enabled: nodeMenu.currentNode.canBeCanceled() + visible: enabled + height: visible ? implicitHeight : 0 + onTriggered: uigraph.cancelNodeComputation(nodeMenu.currentNode) } - } - MenuItem { - text: "Duplicate Node(s)" + (duplicateFollowingButton.hovered ? " From Here" : "") - enabled: true - onTriggered: duplicateNode(false) - MaterialToolButton { - id: duplicateFollowingButton - height: parent.height - anchors { - right: parent.right - rightMargin: parent.padding + MenuItem { + text: "Open Folder" + visible: nodeMenu.currentNode.isComputable + height: visible ? implicitHeight : 0 + onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(nodeMenu.currentNode.internalFolder)) + } + MenuSeparator { + visible: nodeMenu.currentNode.isComputable + } + MenuItem { + text: "Cut Node(s)" + enabled: true + ToolTip.text: "Copy selection to the clipboard and remove it" + ToolTip.visible: hovered + onTriggered: { + copyNodes() + uigraph.removeSelectedNodes() } - text: MaterialIcons.fast_forward - onClicked: { - duplicateNode(true) - nodeMenu.close() + } + MenuItem { + text: "Copy Node(s)" + enabled: true + ToolTip.text: "Copy selection to the clipboard" + ToolTip.visible: hovered + onTriggered: copyNodes() + } + MenuItem { + text: "Paste Node(s)" + enabled: true + ToolTip.text: "Copy selection to the clipboard and immediately paste it" + ToolTip.visible: hovered + onTriggered: { + copyNodes() + pasteNodes() } } - } - MenuItem { - text: "Remove Node(s)" + (removeFollowingButton.hovered ? " From Here" : "") - enabled: !nodeMenu.currentNode.locked - onTriggered: uigraph.removeSelectedNodes() - MaterialToolButton { - id: removeFollowingButton - height: parent.height - anchors { - right: parent.right - rightMargin: parent.padding + MenuItem { + text: "Duplicate Node(s)" + (duplicateFollowingButton.hovered ? " From Here" : "") + enabled: true + onTriggered: duplicateNode(false) + MaterialToolButton { + id: duplicateFollowingButton + height: parent.height + anchors { + right: parent.right + rightMargin: parent.padding + } + text: MaterialIcons.fast_forward + onClicked: { + duplicateNode(true) + nodeMenu.close() + } } - text: MaterialIcons.fast_forward - onClicked: { - uigraph.removeNodesFrom(uigraph.getSelectedNodes()) - nodeMenu.close() + } + MenuItem { + text: "Remove Node(s)" + (removeFollowingButton.hovered ? " From Here" : "") + enabled: !nodeMenu.currentNode.locked + onTriggered: uigraph.removeSelectedNodes() + MaterialToolButton { + id: removeFollowingButton + height: parent.height + anchors { + right: parent.right + rightMargin: parent.padding + } + text: MaterialIcons.fast_forward + onClicked: { + uigraph.removeNodesFrom(uigraph.getSelectedNodes()) + nodeMenu.close() + } } } - } - MenuSeparator { - visible: nodeMenu.currentNode.isComputable - } - MenuItem { - id: deleteDataMenuItem - text: "Delete Data" + (deleteFollowingButton.hovered ? " From Here" : "" ) + "..." - visible: nodeMenu.currentNode.isComputable - height: visible ? implicitHeight : 0 - enabled: { - if (!nodeMenu.currentNode) - return false - // Check if the current node is locked (needed because it does not belong to its own duplicates list) - if (nodeMenu.currentNode.locked) - return false - // Check if at least one of the duplicate nodes is locked - for (let i = 0; i < nodeMenu.currentNode.duplicates.count; ++i) { - if (nodeMenu.currentNode.duplicates.at(i).locked) + MenuSeparator { + visible: nodeMenu.currentNode.isComputable + } + MenuItem { + id: deleteDataMenuItem + text: "Delete Data" + (deleteFollowingButton.hovered ? " From Here" : "" ) + "..." + visible: nodeMenu.currentNode.isComputable + height: visible ? implicitHeight : 0 + enabled: { + if (!nodeMenu.currentNode) + return false + // Check if the current node is locked (needed because it does not belong to its own duplicates list) + if (nodeMenu.currentNode.locked) return false + // Check if at least one of the duplicate nodes is locked + for (let i = 0; i < nodeMenu.currentNode.duplicates.count; ++i) { + if (nodeMenu.currentNode.duplicates.at(i).locked) + return false + } + return true } - return true - } - onTriggered: nodeMenuLoader.showDataDeletionDialog(false) + onTriggered: nodeMenuLoader.showDataDeletionDialog(false) - MaterialToolButton { - id: deleteFollowingButton - anchors { - right: parent.right - rightMargin: parent.padding - } - height: parent.height - text: MaterialIcons.fast_forward - onClicked: { - nodeMenuLoader.showDataDeletionDialog(true); - nodeMenu.close(); + MaterialToolButton { + id: deleteFollowingButton + anchors { + right: parent.right + rightMargin: parent.padding + } + height: parent.height + text: MaterialIcons.fast_forward + onClicked: { + nodeMenuLoader.showDataDeletionDialog(true); + nodeMenu.close(); + } } - } + } } } - } // Confirmation dialog for node cache deletion Component { @@ -821,7 +821,7 @@ Item { } // Nodes - Repeater { + Repeater { id: nodeRepeater model: root.graph ? root.graph.nodes : undefined From fc8599901180ddaec752af6f372df7d113d0dce4 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Mon, 9 Dec 2024 10:18:48 +0100 Subject: [PATCH 17/19] [ui] GraphEditor: toggle node selected state on Ctrl+click Closer to the standard behavior of the Ctrl modifier key for selection. --- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index b2e7707952..8ff01d4dbc 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -861,7 +861,7 @@ Item { selectionMode = ItemSelectionModel.Select; } if(mouse.modifiers & Qt.ControlModifier) { - selectionMode = ItemSelectionModel.Deselect; + selectionMode = ItemSelectionModel.Toggle; } if(mouse.modifiers & Qt.AltModifier) { uigraph.selectFollowing(node); From 4730cc08008aab6f33e94512d98ba95b557d0c98 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Mon, 9 Dec 2024 10:27:32 +0100 Subject: [PATCH 18/19] Update git-blame-ignore-revs --- .git-blame-ignore-revs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 8acbe767a3..ff4f1abd9c 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,3 +1,5 @@ +# [GraphEditor] Indentation fix +87c0cef605e4ef2b359d7e678155e79b65b2e762 # [qt6][qml] Clean-up code and harmonize comments 5a0b1c0c9547b0d00f3f10fae6994d6d8ea0b45e # [nodes] Linting: Clean-up files From 944ff150025c579f9cc61694c4cf522c07e333c1 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Mon, 9 Dec 2024 11:16:33 +0100 Subject: [PATCH 19/19] [ui] Additive mode for select following Nodes Implement additive selection behavior when selecting downstream nodes from a node, using Alt+Shift+Click. --- meshroom/ui/graph.py | 7 +++++-- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index e1a756fbdd..0cde6b2ddd 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -952,9 +952,12 @@ def selectNodes(self, nodes, command=QItemSelectionModel.SelectionFlag.ClearAndS self.selectNodesByIndices(indices, command) @Slot(Node) - def selectFollowing(self, node: Node): + @Slot(Node, int) + def selectFollowing(self, node: Node, command=QItemSelectionModel.SelectionFlag.ClearAndSelect): """Select all the nodes that depend on `node`.""" - self.selectNodes(self._graph.dfsOnDiscover(startNodes=[node], reverse=True, dependenciesOnly=True)[0]) + self.selectNodes( + self._graph.dfsOnDiscover(startNodes=[node], reverse=True, dependenciesOnly=True)[0], command + ) self.selectedNode = node @Slot(int) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 8ff01d4dbc..c74acbc7d1 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -864,7 +864,12 @@ Item { selectionMode = ItemSelectionModel.Toggle; } if(mouse.modifiers & Qt.AltModifier) { - uigraph.selectFollowing(node); + let selectFollowingMode = ItemSelectionModel.ClearAndSelect; + if(mouse.modifiers & Qt.ShiftModifier) { + selectFollowingMode = ItemSelectionModel.Select; + } + uigraph.selectFollowing(node, selectFollowingMode); + // Indicate selection has been dealt with by setting conservative Select mode. selectionMode = ItemSelectionModel.Select; } }