From 05eabb2b131eeb8ed8130588a17d897ada0ff3b7 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 6 Dec 2024 10:13:51 +0100 Subject: [PATCH] [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 | 171 ++++++++++++-------- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 170 +++++++++++-------- 2 files changed, 203 insertions(+), 138 deletions(-) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 0a8e80d4..ed8dae94 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -7,8 +7,19 @@ import json from enum import Enum from threading import Thread, Event, Lock from multiprocessing.pool import ThreadPool +from typing import Iterator -from PySide6.QtCore import Slot, QJsonValue, QObject, QUrl, Property, Signal, QPoint +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 @@ class UIGraph(QObject): 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 @@ class UIGraph(QObject): 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 @@ class UIGraph(QObject): 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 @@ class UIGraph(QObject): 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 @@ class UIGraph(QObject): # 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 @@ class UIGraph(QObject): # 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 3e052acd..f73251d9 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,44 +838,77 @@ 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; + } + } + + 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); } onDoubleClicked: function(mouse) { root.nodeDoubleClicked(mouse, node) } - onMoved: function(position) { uigraph.moveNode(node, position, uigraph.selectedNodes) } - 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