[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.
This commit is contained in:
Yann Lanthony 2024-12-06 10:13:51 +01:00
parent 6d2e9a2ba9
commit 05eabb2b13
2 changed files with 203 additions and 138 deletions

View file

@ -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)

View file

@ -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