Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ui] add support for selecting multiple nodes at once #1227

Merged
merged 7 commits into from
May 3, 2021
2 changes: 2 additions & 0 deletions meshroom/common/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ def pop(self, key):
############
# List API #
############
@QtCore.Slot(QtCore.QObject)
def append(self, obj):
""" Insert object at the end of the model. """
self.extend([obj])
Expand Down Expand Up @@ -182,6 +183,7 @@ def removeAt(self, i, count=1):
self.endRemoveRows()
self.countChanged.emit()

@QtCore.Slot(QtCore.QObject)
def remove(self, obj):
""" Removes the first occurrence of the given object. Raises a ValueError if not in list. """
if not self.contains(obj):
Expand Down
16 changes: 16 additions & 0 deletions meshroom/core/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,22 @@ def duplicateNodesFromNode(self, fromNode):
OrderedDict[Node, Node]: the source->duplicate map
"""
srcNodes, srcEdges = self.dfsOnDiscover(startNodes=[fromNode], reverse=True, dependenciesOnly=True)
return self.duplicateNodes(srcNodes, srcEdges)

def duplicateNodesFromList(self, nodes):
"""
Duplicate 'nodes'.

Args:
nodes (list[Node]): the nodes to duplicate

Returns:
OrderedDict[Node, Node]: the source->duplicate map
"""
srcEdges = [ self.nodeInEdges(n) for n in nodes ]
return self.duplicateNodes(nodes, srcEdges)

def duplicateNodes(self, srcNodes, srcEdges):
# use OrderedDict to keep duplicated nodes creation order
duplicates = OrderedDict()

Expand Down
1 change: 0 additions & 1 deletion meshroom/core/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,6 @@ def hasStatus(self, status):
def _isComputed(self):
return self.hasStatus(Status.SUCCESS)

@Slot()
def clearData(self):
""" Delete this Node internal folder.
Status will be reset to Status.NONE
Expand Down
33 changes: 27 additions & 6 deletions meshroom/ui/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,15 +173,25 @@ def undoImpl(self):
self.graph.attribute(dstAttr))


class DuplicateNodeCommand(GraphCommand):
class _DuplicateNodes(GraphCommand):
def __init__(self, graph, parent=None):
super(_DuplicateNodes, self).__init__(graph, parent)
self.duplicates = []

def undoImpl(self):
# delete all the duplicated nodes
for nodeName in self.duplicates:
self.graph.removeNode(nodeName)


class DuplicateNodeCommand(_DuplicateNodes):
"""
Handle node duplication in a Graph.
"""
def __init__(self, graph, srcNode, duplicateFollowingNodes, parent=None):
super(DuplicateNodeCommand, self).__init__(graph, parent)
self.srcNodeName = srcNode.name
self.duplicateFollowingNodes = duplicateFollowingNodes
self.duplicates = []

def redoImpl(self):
srcNode = self.graph.node(self.srcNodeName)
Expand All @@ -196,10 +206,21 @@ def redoImpl(self):
self.duplicates = [n.name for n in duplicates]
return duplicates

def undoImpl(self):
# delete all the duplicated nodes
for nodeName in self.duplicates:
self.graph.removeNode(nodeName)

class DuplicateNodeListCommand(_DuplicateNodes):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't need to have 2 classes to do it with one of multiple nodes and inheritance, etc.
Either we keep DuplicateNodeListCommand and always use it (even for a single node) or keep DuplicateNodeCommand and group the commands for multiple nodes.

"""
Handle node duplication in a Graph.
"""
def __init__(self, graph, srcNodes, parent=None):
super(DuplicateNodeListCommand, self).__init__(graph, parent)
self.srcNodeNames = [ srcNode.name for srcNode in srcNodes ]
self.setText("Duplicate selected nodes")

def redoImpl(self):
srcNodes = [ self.graph.node(srcNodeName) for srcNodeName in self.srcNodeNames ]
duplicates = list(self.graph.duplicateNodesFromList(srcNodes).values())
self.duplicates = [ n.name for n in duplicates ]
return duplicates


class SetAttributeCommand(GraphCommand):
Expand Down
79 changes: 75 additions & 4 deletions meshroom/ui/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,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._hoveredNode = None

self.computeStatusChanged.connect(self.updateLockedUndoStack)
Expand Down Expand Up @@ -499,6 +500,12 @@ def addNewNode(self, nodeType, position=None, **kwargs):
position = Position(position.x(), position.y())
return self.push(commands.AddNodeCommand(self._graph, nodeType, position=position, **kwargs))

@Slot(QObject, result=bool)
def nodeSelection(self, node):
""" If the node is part of the selection or not. """
length = len(self._selectedNodes) > 1
return length and self._selectedNodes.contains(node) if node else length

@Slot(Node, QPoint)
def moveNode(self, node, position):
"""
Expand All @@ -509,26 +516,49 @@ def moveNode(self, node, position):
position (QPoint): the target position
"""
if isinstance(position, QPoint):
if self.nodeSelection(node):
self.moveSelectedNodes(position.x() - node.x, position.y() - node.y)
return
position = Position(position.x(), position.y())
self.push(commands.MoveNodeCommand(self._graph, node, position))

def moveSelectedNodes(self, deltaX, deltaY):
with self.groupedGraphModification("Move Selected Nodes"):
for node in self._selectedNodes:
position = Position(node.x + deltaX, node.y + deltaY)
self.push(commands.MoveNodeCommand(self._graph, node, position))

@Slot(Node)
def removeNode(self, node):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check on locked makes sense, but should we really care about the notion of selection here?
The function is called removeNode and takes a node in parameter, so it should just do the action.

if self.nodeSelection(node):
self.removeSelectedNodes()
return
if node.locked:
return
self.push(commands.RemoveNodeCommand(self._graph, node))

def removeSelectedNodes(self):
with self.groupedGraphModification("Remove Selected Nodes"):
for node in self._selectedNodes:
if not node.locked:
self.push(commands.RemoveNodeCommand(self._graph, node))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if not node.locked:
self.push(commands.RemoveNodeCommand(self._graph, node))
self.removeNode(node)


@Slot(Node)
def removeNodesFrom(self, startNode):
"""
Remove all nodes starting from 'startNode' to graph leaves.
Args:
startNode (Node): the node to start from.
"""
if not startNode:
return
with self.groupedGraphModification("Remove Nodes from {}".format(startNode.name)):
nodes, _ = self._graph.dfsOnDiscover(startNodes=[startNode], reverse=True, dependenciesOnly=True)
# Perform nodes removal from leaves to start node so that edges
# can be re-created in correct order on redo.
for node in reversed(nodes):
self.removeNode(node)
if not node.locked:
self.removeNode(node)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if not node.locked:
self.removeNode(node)
self.removeNode(node)

Already checked in the function.


@Slot(Attribute, Attribute)
def addEdge(self, src, dst):
Expand Down Expand Up @@ -560,7 +590,7 @@ def resetAttribute(self, attribute):
@Slot(Node, bool, result="QVariantList")
def duplicateNode(self, srcNode, duplicateFollowingNodes=False):
"""
Duplicate a node an optionally all the following nodes to graph leaves.
Duplicate a node and optionally all the following nodes to graph leaves.

Args:
srcNode (Node): node to start the duplication from
Expand All @@ -569,19 +599,33 @@ def duplicateNode(self, srcNode, duplicateFollowingNodes=False):
Returns:
[Nodes]: the list of duplicated nodes
"""
title = "Duplicate Nodes from {}" if duplicateFollowingNodes else "Duplicate {}"
if duplicateFollowingNodes: title = "Duplicate Nodes from {}"
elif self.nodeSelection(srcNode): title = "Duplicate selected nodes"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not have links with the notion of node selection at this level. The UI use the selection to determine the list of nodes. Then we have the list of nodes and perform actions on it. But the low-level functions should not check the notion of selection again.

The function is explicit: duplicateNode(srcNode, duplicateFollowingNodes=False). We don't expect that to check the notion of selection.

else: title = "Duplicate {}"
# enable updates between duplication and layout to get correct depths during layout
with self.groupedGraphModification(title.format(srcNode.name), disableUpdates=False):
# disable graph updates during duplication
with self.groupedGraphModification("Node duplication", disableUpdates=True):
duplicates = self.push(commands.DuplicateNodeCommand(self._graph, srcNode, duplicateFollowingNodes))
if self.nodeSelection(srcNode) and not duplicateFollowingNodes:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same, no notion of selection here.

command = commands.DuplicateNodeListCommand(self._graph, self._selectedNodes)
else:
command = commands.DuplicateNodeCommand(self._graph, srcNode, duplicateFollowingNodes)
duplicates = self.push(command)
# move nodes below the bounding box formed by the duplicated node(s)
bbox = self._layout.boundingBox(duplicates)
for n in duplicates:
self.moveNode(n, Position(n.x, bbox[3] + self.layout.gridSpacing + n.y))

return duplicates

@Slot(QObject)
def clearData(self, node):
if self.nodeSelection(node):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same, no notion of selection here.

for n in self._selectedNodes:
n.clearData()
return
node.clearData()

@Slot(CompatibilityNode, result=Node)
def upgradeNode(self, node):
""" Upgrade a CompatibilityNode. """
Expand Down Expand Up @@ -615,10 +659,33 @@ def appendAttribute(self, attribute, value=QJsonValue()):
def removeAttribute(self, attribute):
self.push(commands.ListAttributeRemoveCommand(self._graph, attribute))

@Slot(QObject, QObject)
def boxSelect(self, selection, draggable):
x = selection.x() - draggable.x()
y = selection.y() - draggable.y()
otherX = x + selection.width()
otherY = y + selection.height()
x, y, otherX, otherY = [ i / j for i, j in zip([x, y, otherX, otherY], [draggable.scale()] * 4) ]
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]):
if not self._selectedNodes.contains(n):
self._selectedNodes.append(n)
self.selectedNodesChanged.emit()

def clearNodeSelection(self):
""" Clear node selection. """
self.selectedNode = None

@Slot()
def clearNodesSelections(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def clearNodesSelections(self):
def clearNodesSelection(self):

""" Clear multiple nodes selection. """
self._selectedNodes.clear()
self.selectedNodesChanged.emit()

def clearNodeHover(self):
""" Reset currently hovered node to None. """
self.hoveredNode = None
Expand All @@ -643,6 +710,10 @@ def clearNodeHover(self):
# Currently selected node
selectedNode = makeProperty(QObject, "_selectedNode", selectedNodeChanged, resetOnDestroy=True)

selectedNodesChanged = Signal()
# Currently selected nodes to drag
selectedNodes = makeProperty(QObject, "_selectedNodes", selectedNodesChanged, resetOnDestroy=True)

hoveredNodeChanged = Signal()
# Currently hovered node
hoveredNode = makeProperty(QObject, "_hoveredNode", hoveredNodeChanged, resetOnDestroy=True)
Loading