Merge pull request #2605 from alicevision/fix/nodeSelectionPerfs

Refactor Node selection for better UX and performance
This commit is contained in:
Fabien Castan 2024-12-09 20:07:20 +01:00 committed by GitHub
commit 2d56016770
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 670 additions and 513 deletions

View file

@ -1,3 +1,5 @@
# [GraphEditor] Indentation fix
87c0cef605e4ef2b359d7e678155e79b65b2e762
# [qt6][qml] Clean-up code and harmonize comments # [qt6][qml] Clean-up code and harmonize comments
5a0b1c0c9547b0d00f3f10fae6994d6d8ea0b45e 5a0b1c0c9547b0d00f3f10fae6994d6d8ea0b45e
# [nodes] Linting: Clean-up files # [nodes] Linting: Clean-up files

View file

@ -283,6 +283,15 @@ class QObjectListModel(QtCore.QAbstractListModel):
self._objectByKey[key] = 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): def _dereferenceItem(self, item):
# Ask for object deletion if parented to the model # Ask for object deletion if parented to the model
if shiboken6.isValid(item) and item.parent() == self: if shiboken6.isValid(item) and item.parent() == self:

View file

@ -1,11 +1,12 @@
def registerTypes(): def registerTypes():
from PySide6.QtQml import qmlRegisterType from PySide6.QtQml import qmlRegisterType, qmlRegisterSingletonType
from meshroom.ui.components.clipboard import ClipboardHelper from meshroom.ui.components.clipboard import ClipboardHelper
from meshroom.ui.components.edge import EdgeMouseArea from meshroom.ui.components.edge import EdgeMouseArea
from meshroom.ui.components.filepath import FilepathHelper from meshroom.ui.components.filepath import FilepathHelper
from meshroom.ui.components.scene3D import Scene3DHelper, TrackballController, Transformations3DHelper from meshroom.ui.components.scene3D import Scene3DHelper, TrackballController, Transformations3DHelper
from meshroom.ui.components.csvData import CsvData from meshroom.ui.components.csvData import CsvData
from meshroom.ui.components.geom2D import Geom2D
qmlRegisterType(EdgeMouseArea, "GraphEditor", 1, 0, "EdgeMouseArea") qmlRegisterType(EdgeMouseArea, "GraphEditor", 1, 0, "EdgeMouseArea")
qmlRegisterType(ClipboardHelper, "Meshroom.Helpers", 1, 0, "ClipboardHelper") # TODO: uncreatable 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(Transformations3DHelper, "Meshroom.Helpers", 1, 0, "Transformations3DHelper") # TODO: uncreatable
qmlRegisterType(TrackballController, "Meshroom.Helpers", 1, 0, "TrackballController") qmlRegisterType(TrackballController, "Meshroom.Helpers", 1, 0, "TrackballController")
qmlRegisterType(CsvData, "DataObjects", 1, 0, "CsvData") qmlRegisterType(CsvData, "DataObjects", 1, 0, "CsvData")
qmlRegisterSingletonType(Geom2D, "Meshroom.Helpers", 1, 0, "Geom2D")

View file

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

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, Optional, Union
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
@ -358,7 +369,7 @@ class UIGraph(QObject):
self._sortedDFSChunks = QObjectListModel(parent=self) self._sortedDFSChunks = QObjectListModel(parent=self)
self._layout = GraphLayout(self) self._layout = GraphLayout(self)
self._selectedNode = None self._selectedNode = None
self._selectedNodes = QObjectListModel(parent=self) self._nodeSelection = QItemSelectionModel(self._graph.nodes, parent=self)
self._hoveredNode = None self._hoveredNode = None
self.submitLabel = "{projectName}" self.submitLabel = "{projectName}"
@ -395,6 +406,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):
@ -501,9 +514,10 @@ class UIGraph(QObject):
else: else:
self._undoStack.unlock() self._undoStack.unlock()
@Slot(QObjectListModel) @Slot()
@Slot(Node) @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 nodes = [nodes] if not isinstance(nodes, Iterable) and nodes else nodes
self._taskManager.compute(self._graph, nodes) self._taskManager.compute(self._graph, nodes)
self.updateLockedUndoStack() # explicitly call the update while it is already computing self.updateLockedUndoStack() # explicitly call the update while it is already computing
@ -539,9 +553,10 @@ class UIGraph(QObject):
n.clearSubmittedChunks() n.clearSubmittedChunks()
self._taskManager.removeNode(n, displayList=True, processList=True) self._taskManager.removeNode(n, displayList=True, processList=True)
@Slot(QObjectListModel) @Slot()
@Slot(Node) @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. """ Submit the graph to the default Submitter.
If a node is specified, submit this node and its uncomputed predecessors. If a node is specified, submit this node and its uncomputed predecessors.
Otherwise, submit the whole Otherwise, submit the whole
@ -636,59 +651,53 @@ class UIGraph(QObject):
position = Position(position.x(), position.y()) position = Position(position.x(), position.y())
return self.push(commands.AddNodeCommand(self._graph, nodeType, position=position, **kwargs)) return self.push(commands.AddNodeCommand(self._graph, nodeType, position=position, **kwargs))
def filterNodes(self, nodes): def moveNode(self, node: Node, position: Position):
"""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() ]
@Slot(Node, QPoint, QObject)
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)
if isinstance(position, QPoint):
position = Position(position.x(), position.y())
deltaX = position.x - node.x
deltaY = position.y - node.y
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))
@Slot(QObject) @Slot(QPoint)
def removeNodes(self, nodes): def moveSelectedNodesBy(self, offset: QPoint):
"""Move all the selected nodes by the given `offset`."""
with self.groupedGraphModification("Move Selected Nodes"):
for node in self.iterSelectedNodes():
position = Position(node.x + offset.x(), node.y + offset.y())
self.moveNode(node, position)
@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. Remove 'nodes' from the graph.
Args: 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 return
with self.groupedGraphModification("Remove Selected Nodes"):
with self.groupedGraphModification("Remove Nodes"):
for node in nodes: for node in nodes:
self.push(commands.RemoveNodeCommand(self._graph, node)) self.push(commands.RemoveNodeCommand(self._graph, node))
@Slot(QObject) @Slot(list)
def removeNodesFrom(self, nodes): 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: 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"): with self.groupedGraphModification("Remove Nodes From Selected Nodes"):
nodesToRemove, _ = self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True) nodesToRemove, _ = self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True)
# filter out nodes that will be removed more than once # filter out nodes that will be removed more than once
@ -697,17 +706,17 @@ class UIGraph(QObject):
# can be re-created in correct order on redo. # can be re-created in correct order on redo.
self.removeNodes(list(reversed(uniqueNodesToRemove))) self.removeNodes(list(reversed(uniqueNodesToRemove)))
@Slot(QObject, result="QVariantList") @Slot(list, result=list)
def duplicateNodes(self, nodes): def duplicateNodes(self, nodes: list[Node]) -> list[Node]:
""" """
Duplicate 'nodes'. Duplicate 'nodes'.
Args: Args:
nodes (list[Node]): the nodes to duplicate nodes: the nodes to duplicate.
Returns: 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] nPositions = [(n.x, n.y) for n in self._graph.nodes]
# enable updates between duplication and layout to get correct depths during layout # enable updates between duplication and layout to get correct depths during layout
with self.groupedGraphModification("Duplicate Selected Nodes", disableUpdates=False): with self.groupedGraphModification("Duplicate Selected Nodes", disableUpdates=False):
@ -730,18 +739,16 @@ class UIGraph(QObject):
return duplicates return duplicates
@Slot(QObject, result="QVariantList") @Slot(list, result=list)
def duplicateNodesFrom(self, nodes): def duplicateNodesFrom(self, nodes: list[Node]) -> list[Node]:
""" """
Duplicate all nodes starting from 'nodes' to graph leaves. Duplicate all nodes starting from 'nodes' to graph leaves.
Args: Args:
nodes (list[Node]): the nodes to start from. node: The nodes to start from.
Returns: 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"): with self.groupedGraphModification("Duplicate Nodes From Selected Nodes"):
nodesToDuplicate, _ = self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True) nodesToDuplicate, _ = self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True)
# filter out nodes that will be duplicated more than once # filter out nodes that will be duplicated more than once
@ -772,7 +779,7 @@ class UIGraph(QObject):
dst = currentEdge.dst dst = currentEdge.dst
for i in range(1, len(listAttribute)): for i in range(1, len(listAttribute)):
duplicates = self.duplicateNodesFrom(dst.node) duplicates = self.duplicateNodesFrom([dst.node])
newNode = duplicates[0] newNode = duplicates[0]
previousEdge = self.graph.edge(newNode.attribute(dst.name)) previousEdge = self.graph.edge(newNode.attribute(dst.name))
self.replaceEdge(previousEdge, listAttribute.at(i), previousEdge.dst) self.replaceEdge(previousEdge, listAttribute.at(i), previousEdge.dst)
@ -792,25 +799,28 @@ class UIGraph(QObject):
continue continue
occurence = allSrc.index(listAttribute.at(i)) if listAttribute.at(i) in allSrc else -1 occurence = allSrc.index(listAttribute.at(i)) if listAttribute.at(i) in allSrc else -1
if occurence != -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 # update the edges from allSrc
allSrc = [e.src for e in self._graph.edges.values()] 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) @Slot(list)
def clearData(self, nodes): def clearData(self, nodes: list[Node]):
""" Clear data from 'nodes'. """ """ Clear data from 'nodes'. """
nodes = self.filterNodes(nodes)
for n in nodes: for n in nodes:
n.clearData() n.clearData()
@Slot(QObject) @Slot(list)
def clearDataFrom(self, nodes): def clearDataFrom(self, nodes: list[Node]):
""" """
Clear data from all nodes starting from 'nodes' to graph leaves. Clear data from all nodes starting from 'nodes' to graph leaves.
Args: 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]) self.clearData(self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True)[0])
@ -934,23 +944,83 @@ 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) @Slot(list)
def appendSelection(self, node): @Slot(list, int)
""" Append 'node' to the selection if it is not already part of the selection. """ def selectNodes(self, nodes, command=QItemSelectionModel.SelectionFlag.ClearAndSelect):
if not self._selectedNodes.contains(node): """Update selection with `nodes` using the specified `command`."""
self._selectedNodes.append(node) indices = [self._graph._nodes.indexOf(node) for node in nodes]
self.selectNodesByIndices(indices, command)
@Slot("QVariantList")
def selectNodes(self, nodes):
""" Append 'nodes' to the selection. """
for node in nodes:
self.appendSelection(node)
self.selectedNodesChanged.emit()
@Slot(Node) @Slot(Node)
def selectFollowing(self, node): @Slot(Node, int)
""" Select all the nodes the depend on 'node'. """ def selectFollowing(self, node: Node, command=QItemSelectionModel.SelectionFlag.ClearAndSelect):
self.selectNodes(self._graph.dfsOnDiscover(startNodes=[node], reverse=True, dependenciesOnly=True)[0]) """Select all the nodes that depend on `node`."""
self.selectNodes(
self._graph.dfsOnDiscover(startNodes=[node], reverse=True, dependenciesOnly=True)[0], command
)
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(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."""
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,62 +1032,24 @@ 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. Serialize the current node selection and return it as JSON formatted string.
If no node is currently selected, an empty string is returned.
"""
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") Returns an empty string if the selection is empty.
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 Parse the content of the clipboard to see whether it contains
valid node descriptions. If that is the case, the nodes described valid node descriptions. If that is the case, the nodes described
@ -1154,9 +1186,7 @@ class UIGraph(QObject):
# Current main selected node # Current main selected node
selectedNode = makeProperty(QObject, "_selectedNode", selectedNodeChanged, resetOnDestroy=True) selectedNode = makeProperty(QObject, "_selectedNode", selectedNodeChanged, resetOnDestroy=True)
selectedNodesChanged = Signal() nodeSelection = makeProperty(QObject, "_nodeSelection")
# Currently selected nodes
selectedNodes = makeProperty(QObject, "_selectedNodes", selectedNodesChanged, resetOnDestroy=True)
hoveredNodeChanged = Signal() hoveredNodeChanged = Signal()
# Currently hovered node # Currently hovered node

View file

@ -30,21 +30,6 @@ Page {
property alias showImageGallery: imageGalleryVisibilityCB.checked 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: { property url imagesFolder: {
var recentImportedImagesFolders = MeshroomApp.recentImportedImagesFolders var recentImportedImagesFolders = MeshroomApp.recentImportedImagesFolders
@ -531,31 +516,21 @@ Page {
Action { Action {
id: cutAction id: cutAction
property string tooltip: { property string tooltip: "Cut Selected Node(s)"
var s = "Copy selected node" text: "Cut Node(s)"
s += (_reconstruction && _reconstruction.selectedNodes.count > 1 ? "s (" : " (") + getSelectedNodesName() enabled: _reconstruction ? _reconstruction.nodeSelection.hasSelection : false
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
onTriggered: { onTriggered: {
graphEditor.copyNodes() graphEditor.copyNodes()
graphEditor.uigraph.removeNodes(graphEditor.uigraph.selectedNodes) graphEditor.uigraph.removeSelectedNodes()
} }
} }
Action { Action {
id: copyAction id: copyAction
property string tooltip: { property string tooltip: "Copy Selected Node(s)"
var s = "Copy selected node" text: "Copy Node(s)"
s += (_reconstruction && _reconstruction.selectedNodes.count > 1 ? "s (" : " (") + getSelectedNodesName() enabled: _reconstruction ? _reconstruction.nodeSelection.hasSelection : false
s += ") to the clipboard"
return s
}
text: "Copy Node" + (_reconstruction && _reconstruction.selectedNodes.count > 1 ? "s " : " ")
enabled: _reconstruction ? _reconstruction.selectedNodes.count > 0 : false
onTriggered: graphEditor.copyNodes() onTriggered: graphEditor.copyNodes()
} }

View file

@ -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<int> 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);
}
}

View file

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

View file

@ -17,3 +17,5 @@ IntSelector 1.0 IntSelector.qml
MScrollBar 1.0 MScrollBar.qml MScrollBar 1.0 MScrollBar.qml
MSplitView 1.0 MSplitView.qml MSplitView 1.0 MSplitView.qml
DirectionalLightPane 1.0 DirectionalLightPane.qml DirectionalLightPane 1.0 DirectionalLightPane.qml
SelectionBox 1.0 SelectionBox.qml
DelegateSelectionBox 1.0 DelegateSelectionBox.qml

View file

@ -31,8 +31,6 @@ Item {
signal computeRequest(var nodes) signal computeRequest(var nodes)
signal submitRequest(var nodes) signal submitRequest(var nodes)
signal dataDeleted()
property int nbMeshroomScenes: 0 property int nbMeshroomScenes: 0
property int nbDraggedFiles: 0 property int nbDraggedFiles: 0
signal filesDropped(var drop, var mousePosition) // Files have been dropped signal filesDropped(var drop, var mousePosition) // Files have been dropped
@ -61,35 +59,14 @@ Item {
return undefined return undefined
} }
/// Select node delegate
function selectNode(node) {
uigraph.selectedNode = node
if (node !== null) {
uigraph.appendSelection(node)
uigraph.selectedNodesChanged()
}
}
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 /// Duplicate a node and optionally all the following ones
function duplicateNode(duplicateFollowingNodes) { function duplicateNode(duplicateFollowingNodes) {
var nodes var nodes
if (duplicateFollowingNodes) { if (duplicateFollowingNodes) {
nodes = uigraph.duplicateNodesFrom(uigraph.selectedNodes) nodes = uigraph.duplicateNodesFrom(uigraph.getSelectedNodes())
} else { } else {
nodes = uigraph.duplicateNodes(uigraph.selectedNodes) nodes = uigraph.duplicateNodes(uigraph.getSelectedNodes())
} }
uigraph.clearNodeSelection()
uigraph.selectedNode = nodes[0] uigraph.selectedNode = nodes[0]
uigraph.selectNodes(nodes) uigraph.selectNodes(nodes)
} }
@ -122,7 +99,6 @@ Item {
var copiedContent = Clipboard.getText() var copiedContent = Clipboard.getText()
var nodes = uigraph.pasteNodes(copiedContent, finalPosition, centerPosition) var nodes = uigraph.pasteNodes(copiedContent, finalPosition, centerPosition)
if (nodes.length > 0) { if (nodes.length > 0) {
uigraph.clearNodeSelection()
uigraph.selectedNode = nodes[0] uigraph.selectedNode = nodes[0]
uigraph.selectNodes(nodes) uigraph.selectNodes(nodes)
} }
@ -138,15 +114,15 @@ Item {
fit() fit()
} else if (event.key === Qt.Key_Delete) { } else if (event.key === Qt.Key_Delete) {
if (event.modifiers === Qt.AltModifier) { if (event.modifiers === Qt.AltModifier) {
uigraph.removeNodesFrom(uigraph.selectedNodes) uigraph.removeNodesFrom(uigraph.getSelectedNodes())
} else { } else {
uigraph.removeNodes(uigraph.selectedNodes) uigraph.removeSelectedNodes()
} }
} else if (event.key === Qt.Key_D) { } else if (event.key === Qt.Key_D) {
duplicateNode(event.modifiers === Qt.AltModifier) duplicateNode(event.modifiers === Qt.AltModifier)
} else if (event.key === Qt.Key_X && event.modifiers === Qt.ControlModifier) { } else if (event.key === Qt.Key_X && event.modifiers === Qt.ControlModifier) {
copyNodes() copyNodes()
uigraph.removeNodes(uigraph.selectedNodes) uigraph.removeSelectedNodes()
} else if (event.key === Qt.Key_C) { } else if (event.key === Qt.Key_C) {
if (event.modifiers === Qt.ControlModifier) { if (event.modifiers === Qt.ControlModifier) {
copyNodes() copyNodes()
@ -195,20 +171,17 @@ 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()
} }
@ -235,14 +208,13 @@ Item {
height: searchBar.height + nodeMenuRepeater.height + instantiator.height height: searchBar.height + nodeMenuRepeater.height + instantiator.height
function createNode(nodeType) { 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 // "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 it is not a pipeline to import, then it must be a node
if (!importPipeline(nodeType)) { if (!importPipeline(nodeType)) {
// Add node via the proper command in uigraph // Add node via the proper command in uigraph
var node = uigraph.addNewNode(nodeType, spawnPosition) var node = uigraph.addNewNode(nodeType, spawnPosition);
selectNode(node) uigraph.selectedNode = node;
uigraph.selectNodes([node])
} }
close() close()
} }
@ -552,134 +524,168 @@ 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 { Menu {
id: nodeMenu id: nodeMenu
property var currentNode: null
property bool canComputeNode: currentNode != null && uigraph.graph.canComputeTopologically(currentNode) property var currentNode: nodeMenuLoader.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) // Cache computatibility/submitability status of each selected node.
property bool isComputed: { readonly property var nodeSubmitOrComputeStatus: {
var count = 0 var collectedStatus = ({});
for (var i = 0; i < uigraph.selectedNodes.count; ++i) { uigraph.nodeSelection.selectedIndexes.forEach(function(idx) {
var node = uigraph.selectedNodes.at(i) const node = uigraph.graph.nodes.at(idx.row);
if (!node) collectedStatus[node] = uigraph.graph.canSubmitOrCompute(node);
continue });
if (!node.isComputed) return collectedStatus;
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)
|| nodeSubmitOrComputeStatus[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)
|| nodeSubmitOrComputeStatus[node] > 1
)
});
}
width: 220 width: 220
onClosed: currentNode = null
Component.onCompleted: popup()
onClosed: nodeMenuLoader.unload()
MenuItem { MenuItem {
id: computeMenuItem id: computeMenuItem
property bool recompute: false text: nodeMenu.isSelectionFullyComputed ? "Recompute" : "Compute"
text: nodeMenu.isComputed ? "Recompute" : "Compute" visible: nodeMenu.isSelectionOnlyComputableNodes
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
}
height: visible ? implicitHeight : 0 height: visible ? implicitHeight : 0
enabled: nodeMenu.canSelectionBeComputed
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)
}
onTriggered: { onTriggered: {
if (nodeMenu.isComputed) { if (nodeMenu.isSelectionFullyComputed) {
recompute = true nodeMenuLoader.showDataDeletionDialog(
deleteDataMenuItem.showConfirmationDialog(false) false,
function(request, uigraph) {
request(uigraph.getSelectedNodes());
}.bind(null, computeRequest, uigraph)
);
} else { } else {
computeRequest(uigraph.selectedNodes) computeRequest(uigraph.getSelectedNodes());
} }
} }
} }
MenuItem { MenuItem {
id: submitMenuItem 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
}
height: visible ? implicitHeight : 0
enabled: { text: nodeMenu.isSelectionFullyComputed ? "Re-Submit" : "Submit"
var canSubmit = false visible: nodeMenu.isSelectionSubmittable
for (var i = 0; i < uigraph.selectedNodes.count; ++i) { height: visible ? implicitHeight : 0
var node = uigraph.selectedNodes.at(i) enabled: nodeMenu.canSelectionBeSubmitted
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: { onTriggered: {
if (nodeMenu.isComputed) { if (nodeMenu.isSelectionFullyComputed) {
resubmit = true nodeMenuLoader.showDataDeletionDialog(
deleteDataMenuItem.showConfirmationDialog(false) false,
function(request, uigraph) {
request(uigraph.getSelectedNodes());
}.bind(null, submitRequest, uigraph)
);
} else { } else {
submitRequest(uigraph.selectedNodes) submitRequest(uigraph.getSelectedNodes());
} }
} }
} }
MenuItem { MenuItem {
text: "Stop Computation" text: "Stop Computation"
enabled: nodeMenu.currentNode ? nodeMenu.currentNode.canBeStopped() : false enabled: nodeMenu.currentNode.canBeStopped()
visible: enabled visible: enabled
height: visible ? implicitHeight : 0 height: visible ? implicitHeight : 0
onTriggered: uigraph.stopNodeComputation(nodeMenu.currentNode) onTriggered: uigraph.stopNodeComputation(nodeMenu.currentNode)
} }
MenuItem { MenuItem {
text: "Cancel Computation" text: "Cancel Computation"
enabled: nodeMenu.currentNode ? nodeMenu.currentNode.canBeCanceled() : false enabled: nodeMenu.currentNode.canBeCanceled()
visible: enabled visible: enabled
height: visible ? implicitHeight : 0 height: visible ? implicitHeight : 0
onTriggered: uigraph.cancelNodeComputation(nodeMenu.currentNode) onTriggered: uigraph.cancelNodeComputation(nodeMenu.currentNode)
} }
MenuItem { MenuItem {
text: "Open Folder" text: "Open Folder"
visible: nodeMenu.currentNode ? nodeMenu.currentNode.isComputable : false visible: nodeMenu.currentNode.isComputable
height: visible ? implicitHeight : 0 height: visible ? implicitHeight : 0
onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(nodeMenu.currentNode.internalFolder)) onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(nodeMenu.currentNode.internalFolder))
} }
MenuSeparator { MenuSeparator {
visible: nodeMenu.currentNode ? nodeMenu.currentNode.isComputable : false visible: nodeMenu.currentNode.isComputable
} }
MenuItem { MenuItem {
text: "Cut Node(s)" text: "Cut Node(s)"
@ -688,7 +694,7 @@ Item {
ToolTip.visible: hovered ToolTip.visible: hovered
onTriggered: { onTriggered: {
copyNodes() copyNodes()
uigraph.removeNodes(uigraph.selectedNodes) uigraph.removeSelectedNodes()
} }
} }
MenuItem { MenuItem {
@ -728,8 +734,8 @@ Item {
} }
MenuItem { MenuItem {
text: "Remove Node(s)" + (removeFollowingButton.hovered ? " From Here" : "") text: "Remove Node(s)" + (removeFollowingButton.hovered ? " From Here" : "")
enabled: nodeMenu.currentNode ? !nodeMenu.currentNode.locked : false enabled: !nodeMenu.currentNode.locked
onTriggered: uigraph.removeNodes(uigraph.selectedNodes) onTriggered: uigraph.removeSelectedNodes()
MaterialToolButton { MaterialToolButton {
id: removeFollowingButton id: removeFollowingButton
height: parent.height height: parent.height
@ -739,18 +745,18 @@ Item {
} }
text: MaterialIcons.fast_forward text: MaterialIcons.fast_forward
onClicked: { onClicked: {
uigraph.removeNodesFrom(uigraph.selectedNodes) uigraph.removeNodesFrom(uigraph.getSelectedNodes())
nodeMenu.close() nodeMenu.close()
} }
} }
} }
MenuSeparator { MenuSeparator {
visible: nodeMenu.currentNode ? nodeMenu.currentNode.isComputable : false visible: nodeMenu.currentNode.isComputable
} }
MenuItem { MenuItem {
id: deleteDataMenuItem id: deleteDataMenuItem
text: "Delete Data" + (deleteFollowingButton.hovered ? " From Here" : "" ) + "..." text: "Delete Data" + (deleteFollowingButton.hovered ? " From Here" : "" ) + "..."
visible: nodeMenu.currentNode ? nodeMenu.currentNode.isComputable : false visible: nodeMenu.currentNode.isComputable
height: visible ? implicitHeight : 0 height: visible ? implicitHeight : 0
enabled: { enabled: {
if (!nodeMenu.currentNode) if (!nodeMenu.currentNode)
@ -766,18 +772,7 @@ Item {
return true return true
} }
function showConfirmationDialog(deleteFollowing) { onTriggered: nodeMenuLoader.showDataDeletionDialog(false)
uigraph.forceNodesStatusUpdate()
var obj = deleteDataDialog.createObject(root,
{
"node": nodeMenu.currentNode,
"deleteFollowing": deleteFollowing
})
obj.open()
nodeMenu.close()
}
onTriggered: showConfirmationDialog(false)
MaterialToolButton { MaterialToolButton {
id: deleteFollowingButton id: deleteFollowingButton
@ -787,7 +782,14 @@ Item {
} }
height: parent.height height: parent.height
text: MaterialIcons.fast_forward text: MaterialIcons.fast_forward
onClicked: parent.showConfirmationDialog(true) onClicked: {
nodeMenuLoader.showDataDeletionDialog(true);
nodeMenu.close();
}
}
}
}
} }
// Confirmation dialog for node cache deletion // Confirmation dialog for node cache deletion
@ -797,27 +799,26 @@ Item {
property var node property var node
property bool deleteFollowing: false property bool deleteFollowing: false
signal dataDeleted()
focus: true focus: true
modal: false modal: false
header.visible: 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." helperText: "Warning: This operation cannot be undone."
standardButtons: Dialog.Yes | Dialog.Cancel standardButtons: Dialog.Yes | Dialog.Cancel
onAccepted: { onAccepted: {
if (deleteFollowing) if (deleteFollowing)
uigraph.clearDataFrom(uigraph.selectedNodes) uigraph.clearDataFrom(uigraph.getSelectedNodes());
else else
uigraph.clearData(uigraph.selectedNodes) uigraph.clearSelectedNodesData();
dataDeleted();
root.dataDeleted()
} }
onClosed: destroy() onClosed: destroy()
} }
} }
}
}
// Nodes // Nodes
Repeater { Repeater {
@ -826,7 +827,8 @@ Item {
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
delegate: Node { delegate: Node {
@ -836,44 +838,79 @@ 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
// 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) } onAttributePinCreated: function(attribute, pin) { registerAttributePin(attribute, pin) }
onAttributePinDeleted: function(attribute, pin) { unregisterAttributePin(attribute, pin) } onAttributePinDeleted: function(attribute, pin) { unregisterAttributePin(attribute, pin) }
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) {
if (!(mouse.modifiers & Qt.ControlModifier)) { selectionMode = ItemSelectionModel.Toggle;
uigraph.clearNodeSelection()
} }
uigraph.selectFollowing(node) if(mouse.modifiers & Qt.AltModifier) {
} else if (!mainSelected && !selected) { let selectFollowingMode = ItemSelectionModel.ClearAndSelect;
uigraph.clearNodeSelection() if(mouse.modifiers & Qt.ShiftModifier) {
selectFollowingMode = ItemSelectionModel.Select;
} }
} else if (mouse.button === Qt.RightButton) { uigraph.selectFollowing(node, selectFollowingMode);
if (!mainSelected && !selected) { // Indicate selection has been dealt with by setting conservative Select mode.
uigraph.clearNodeSelection() selectionMode = ItemSelectionModel.Select;
} }
nodeMenu.currentNode = node
nodeMenu.popup()
} }
selectNode(node) else if (mouse.button === Qt.RightButton) {
if(selected) {
// Keep the full selection when right-clicking on an already selected node.
nodeRepeater.updateSelectionOnClick = false;
}
}
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;
}
// Open the node context menu once selection has been updated.
if(mouse.button == Qt.RightButton) {
nodeMenuLoader.load(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 +936,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

View file

@ -40,6 +40,8 @@ Item {
// Mouse interaction related signals // Mouse interaction related signals
signal pressed(var mouse) signal pressed(var mouse)
signal released(var mouse)
signal clicked(var mouse)
signal doubleClicked(var mouse) signal doubleClicked(var mouse)
signal moved(var position) signal moved(var position)
signal entered() signal entered()
@ -125,8 +127,10 @@ Item {
drag.threshold: 2 drag.threshold: 2
hoverEnabled: true hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton acceptedButtons: Qt.LeftButton | Qt.RightButton
onPressed: function(mouse) { root.pressed(mouse) } onPressed: (mouse) => root.pressed(mouse)
onDoubleClicked: function(mouse) { root.doubleClicked(mouse) } onReleased: (mouse) => root.released(mouse)
onClicked: (mouse) => root.clicked(mouse)
onDoubleClicked: (mouse) => root.doubleClicked(mouse)
onEntered: root.entered() onEntered: root.entered()
onExited: root.exited() onExited: root.exited()
drag.onActiveChanged: { drag.onActiveChanged: {