[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 enum import Enum
from threading import Thread, Event, Lock from threading import Thread, Event, Lock
from multiprocessing.pool import ThreadPool 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.core import sessionUid
from meshroom.common.qt import QObjectListModel from meshroom.common.qt import QObjectListModel
@ -359,6 +370,8 @@ class UIGraph(QObject):
self._layout = GraphLayout(self) self._layout = GraphLayout(self)
self._selectedNode = None self._selectedNode = None
self._selectedNodes = QObjectListModel(parent=self) self._selectedNodes = QObjectListModel(parent=self)
self._nodeSelection = QItemSelectionModel(self._graph.nodes, parent=self)
self._nodeSelection.selectionChanged.connect(self.onNodeSelectionChanged)
self._hoveredNode = None self._hoveredNode = None
self.submitLabel = "{projectName}" self.submitLabel = "{projectName}"
@ -395,6 +408,8 @@ class UIGraph(QObject):
self._layout.reset() self._layout.reset()
# clear undo-stack after layout # clear undo-stack after layout
self._undoStack.clear() self._undoStack.clear()
self._nodeSelection.setModel(self._graph.nodes)
self.graphChanged.emit() self.graphChanged.emit()
def onGraphUpdated(self): def onGraphUpdated(self):
@ -642,27 +657,24 @@ class UIGraph(QObject):
nodes = [nodes] nodes = [nodes]
return [ n for n in nodes if n in self._graph.nodes.values() ] return [ n for n in nodes if n in self._graph.nodes.values() ]
@Slot(Node, QPoint, QObject) def moveNode(self, node: Node, position: Position):
def moveNode(self, node, position, nodes=None):
""" """
Move 'node' to the given 'position' and also update the positions of 'nodes' if necessary. Move `node` to the given `position`.
Args: Args:
node (Node): the node to move node: The node to move.
position (QPoint): the target position position: The target position.
nodes (list[Node]): the nodes to update the position of
""" """
if not nodes: self.push(commands.MoveNodeCommand(self._graph, node, position))
nodes = [node]
nodes = self.filterNodes(nodes) @Slot(QPoint)
if isinstance(position, QPoint): def moveSelectedNodesBy(self, offset: QPoint):
position = Position(position.x(), position.y()) """Move all the selected nodes by the given `offset`."""
deltaX = position.x - node.x
deltaY = position.y - node.y
with self.groupedGraphModification("Move Selected Nodes"): with self.groupedGraphModification("Move Selected Nodes"):
for n in nodes: for node in self.iterSelectedNodes():
position = Position(n.x + deltaX, n.y + deltaY) position = Position(node.x + offset.x(), node.y + offset.y())
self.push(commands.MoveNodeCommand(self._graph, n, position)) self.moveNode(node, position)
@Slot(QObject) @Slot(QObject)
def removeNodes(self, nodes): def removeNodes(self, nodes):
@ -934,23 +946,80 @@ class UIGraph(QObject):
with self.groupedGraphModification("Remove Images From All CameraInit Nodes"): with self.groupedGraphModification("Remove Images From All CameraInit Nodes"):
self.push(commands.RemoveImagesCommand(self._graph, list(self.cameraInits))) self.push(commands.RemoveImagesCommand(self._graph, list(self.cameraInits)))
@Slot(Node) def onNodeSelectionChanged(self, selected, deselected):
def appendSelection(self, node): # Update internal cache of selected Node instances.
""" Append 'node' to the selection if it is not already part of the selection. """ self._selectedNodes.setObjectList(list(self.iterSelectedNodes()))
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)
self.selectedNodesChanged.emit() 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) @Slot(Node)
def selectFollowing(self, node): def selectFollowing(self, node: Node):
""" Select all the nodes the depend on 'node'. """ """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])
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) @Slot(str)
def setSelectedNodesColor(self, color: str): def setSelectedNodesColor(self, color: str):
@ -962,48 +1031,12 @@ class UIGraph(QObject):
# Update the color attribute of the nodes which are currently selected # Update the color attribute of the nodes which are currently selected
with self.groupedGraphModification("Set Nodes Color"): 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 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"): if node.hasInternalAttribute("color"):
self.setAttribute(node.internalAttribute("color"), 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) @Slot(result=str)
def getSelectedNodesContent(self): def getSelectedNodesContent(self) -> str:
""" """
Return the content of the currently selected nodes in a string, formatted to JSON. 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. If no node is currently selected, an empty string is returned.
@ -1158,6 +1191,8 @@ class UIGraph(QObject):
# Currently selected nodes # Currently selected nodes
selectedNodes = makeProperty(QObject, "_selectedNodes", selectedNodesChanged, resetOnDestroy=True) selectedNodes = makeProperty(QObject, "_selectedNodes", selectedNodesChanged, resetOnDestroy=True)
nodeSelection = makeProperty(QObject, "_nodeSelection")
hoveredNodeChanged = Signal() hoveredNodeChanged = Signal()
# Currently hovered node # Currently hovered node
hoveredNode = makeProperty(QObject, "_hoveredNode", hoveredNodeChanged, resetOnDestroy=True) hoveredNode = makeProperty(QObject, "_hoveredNode", hoveredNodeChanged, resetOnDestroy=True)

View file

@ -195,24 +195,21 @@ Item {
if (mouse.button != Qt.MiddleButton && mouse.modifiers == Qt.NoModifier) { if (mouse.button != Qt.MiddleButton && mouse.modifiers == Qt.NoModifier) {
uigraph.clearNodeSelection() uigraph.clearNodeSelection()
} }
if (mouse.button == Qt.LeftButton && (mouse.modifiers == Qt.NoModifier || mouse.modifiers == Qt.ControlModifier)) { if (mouse.button == Qt.LeftButton && (mouse.modifiers == Qt.NoModifier || mouse.modifiers & (Qt.ControlModifier | Qt.ShiftModifier))) {
boxSelect.startX = mouseX nodeSelectionBox.startSelection(mouse);
boxSelect.startY = mouseY
boxSelectDraggable.x = mouseX
boxSelectDraggable.y = mouseY
drag.target = boxSelectDraggable
} }
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 drag.target = draggable // start drag
} }
} }
onReleased: { onReleased: {
drag.target = undefined // stop drag nodeSelectionBox.endSelection();
drag.target = null;
root.forceActiveFocus() root.forceActiveFocus()
workspaceClicked() workspaceClicked()
} }
onPositionChanged: { onPositionChanged: {
if (drag.active) if (drag.active)
workspaceMoved() workspaceMoved()
@ -820,15 +817,20 @@ Item {
} }
// Nodes // Nodes
Repeater { Repeater {
id: nodeRepeater id: nodeRepeater
model: root.graph ? root.graph.nodes : undefined model: root.graph ? root.graph.nodes : undefined
property bool loaded: model ? count === model.count : false property bool loaded: model ? count === model.count : false
property bool dragging: false property bool ongoingDrag: false
property bool updateSelectionOnClick: false
property var temporaryEdgeAboutToBeRemoved: undefined property var temporaryEdgeAboutToBeRemoved: undefined
function isNodeSelected(index: int) {
return uigraph.nodeSelection.isRowSelected(index);
}
delegate: Node { delegate: Node {
id: nodeDelegate id: nodeDelegate
@ -836,44 +838,77 @@ Item {
width: uigraph.layout.nodeWidth width: uigraph.layout.nodeWidth
mainSelected: uigraph.selectedNode === node mainSelected: uigraph.selectedNode === node
selected: uigraph.selectedNodes.contains(node)
hovered: uigraph.hoveredNode === node hovered: uigraph.hoveredNode === node
selected: nodeRepeater.isNodeSelected(index);
onAttributePinCreated: function(attribute, pin) { registerAttributePin(attribute, pin) } onAttributePinCreated: function(attribute, pin) { registerAttributePin(attribute, pin) }
onAttributePinDeleted: function(attribute, pin) { unregisterAttributePin(attribute, pin) } onAttributePinDeleted: function(attribute, pin) { unregisterAttributePin(attribute, pin) }
Connections {
target: uigraph.nodeSelection
function onSelectionChanged() {
selected = nodeRepeater.isNodeSelected(index);
}
}
onPressed: function(mouse) { 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.button === Qt.LeftButton) {
if (mouse.modifiers & Qt.ControlModifier && !(mouse.modifiers & Qt.AltModifier)) { if(mouse.modifiers & Qt.ShiftModifier) {
if (mainSelected && selected) { selectionMode = ItemSelectionModel.Select;
// 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()
} }
} else if (mouse.button === Qt.RightButton) { if(mouse.modifiers & Qt.ControlModifier) {
if (!mainSelected && !selected) { selectionMode = ItemSelectionModel.Deselect;
uigraph.clearNodeSelection() }
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.currentNode = node
nodeMenu.popup() 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) } onDoubleClicked: function(mouse) { root.nodeDoubleClicked(mouse, node) }
onMoved: function(position) { uigraph.moveNode(node, position, uigraph.selectedNodes) }
onEntered: uigraph.hoveredNode = node onEntered: uigraph.hoveredNode = node
onExited: uigraph.hoveredNode = null onExited: uigraph.hoveredNode = null
@ -899,62 +934,57 @@ Item {
} }
} }
// Interactive dragging: move the visual delegates
onPositionChanged: { onPositionChanged: {
if (dragging && uigraph.selectedNodes.contains(node)) { if(!selected || !dragging) {
// Update all selected nodes positions with this node that is being dragged return;
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)
}
}
} }
// 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 // After drag: apply the final offset to all selected nodes
onDraggingChanged: nodeRepeater.dragging = dragging 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 { Behavior on x {
enabled: !nodeRepeater.dragging enabled: !nodeRepeater.ongoingDrag
NumberAnimation { duration: 100 } NumberAnimation { duration: 100 }
} }
Behavior on y { Behavior on y {
enabled: !nodeRepeater.dragging enabled: !nodeRepeater.ongoingDrag
NumberAnimation { duration: 100 } NumberAnimation { duration: 100 }
} }
} }
} }
} }
Rectangle { DelegateSelectionBox {
id: boxSelect id: nodeSelectionBox
property int startX: 0 mouseArea: mouseArea
property int startY: 0 modelInstantiator: nodeRepeater
property int toX: boxSelectDraggable.x - startX container: draggable
property int toY: boxSelectDraggable.y - startY onDelegateSelectionEnded: function(selectedIndices, modifiers) {
let selectionMode = ItemSelectionModel.ClearAndSelect;
x: toX < 0 ? startX + toX : startX if(modifiers & Qt.ShiftModifier) {
y: toY < 0 ? startY + toY : startY selectionMode = ItemSelectionModel.Select;
width: Math.abs(toX) } else if(modifiers & Qt.ControlModifier) {
height: Math.abs(toY) selectionMode = ItemSelectionModel.Deselect;
color: "transparent"
border.color: activePalette.text
visible: mouseArea.drag.target == boxSelectDraggable
onVisibleChanged: {
if (!visible) {
uigraph.boxSelect(boxSelect, draggable)
} }
uigraph.selectNodesByIndices(selectedIndices, selectionMode);
} }
} }
Item {
id: boxSelectDraggable
}
DropArea { DropArea {
id: dropArea id: dropArea
anchors.fill: parent anchors.fill: parent