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
5a0b1c0c9547b0d00f3f10fae6994d6d8ea0b45e
# [nodes] Linting: Clean-up files

View file

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

View file

@ -1,11 +1,12 @@
def registerTypes():
from PySide6.QtQml import qmlRegisterType
from PySide6.QtQml import qmlRegisterType, qmlRegisterSingletonType
from meshroom.ui.components.clipboard import ClipboardHelper
from meshroom.ui.components.edge import EdgeMouseArea
from meshroom.ui.components.filepath import FilepathHelper
from meshroom.ui.components.scene3D import Scene3DHelper, TrackballController, Transformations3DHelper
from meshroom.ui.components.csvData import CsvData
from meshroom.ui.components.geom2D import Geom2D
qmlRegisterType(EdgeMouseArea, "GraphEditor", 1, 0, "EdgeMouseArea")
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(TrackballController, "Meshroom.Helpers", 1, 0, "TrackballController")
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 threading import Thread, Event, Lock
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.common.qt import QObjectListModel
@ -358,7 +369,7 @@ class UIGraph(QObject):
self._sortedDFSChunks = QObjectListModel(parent=self)
self._layout = GraphLayout(self)
self._selectedNode = None
self._selectedNodes = QObjectListModel(parent=self)
self._nodeSelection = QItemSelectionModel(self._graph.nodes, parent=self)
self._hoveredNode = None
self.submitLabel = "{projectName}"
@ -395,6 +406,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):
@ -501,9 +514,10 @@ class UIGraph(QObject):
else:
self._undoStack.unlock()
@Slot(QObjectListModel)
@Slot()
@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
self._taskManager.compute(self._graph, nodes)
self.updateLockedUndoStack() # explicitly call the update while it is already computing
@ -539,9 +553,10 @@ class UIGraph(QObject):
n.clearSubmittedChunks()
self._taskManager.removeNode(n, displayList=True, processList=True)
@Slot(QObjectListModel)
@Slot()
@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.
If a node is specified, submit this node and its uncomputed predecessors.
Otherwise, submit the whole
@ -636,59 +651,53 @@ class UIGraph(QObject):
position = Position(position.x(), position.y())
return self.push(commands.AddNodeCommand(self._graph, nodeType, position=position, **kwargs))
def filterNodes(self, nodes):
"""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):
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
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))
self.push(commands.MoveNodeCommand(self._graph, node, position))
@Slot(QObject)
def removeNodes(self, nodes):
@Slot(QPoint)
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.
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
with self.groupedGraphModification("Remove Selected Nodes"):
with self.groupedGraphModification("Remove Nodes"):
for node in nodes:
self.push(commands.RemoveNodeCommand(self._graph, node))
@Slot(QObject)
def removeNodesFrom(self, nodes):
@Slot(list)
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:
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"):
nodesToRemove, _ = self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True)
# 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.
self.removeNodes(list(reversed(uniqueNodesToRemove)))
@Slot(QObject, result="QVariantList")
def duplicateNodes(self, nodes):
@Slot(list, result=list)
def duplicateNodes(self, nodes: list[Node]) -> list[Node]:
"""
Duplicate 'nodes'.
Args:
nodes (list[Node]): the nodes to duplicate
nodes: the nodes to duplicate.
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]
# enable updates between duplication and layout to get correct depths during layout
with self.groupedGraphModification("Duplicate Selected Nodes", disableUpdates=False):
@ -730,18 +739,16 @@ class UIGraph(QObject):
return duplicates
@Slot(QObject, result="QVariantList")
def duplicateNodesFrom(self, nodes):
@Slot(list, result=list)
def duplicateNodesFrom(self, nodes: list[Node]) -> list[Node]:
"""
Duplicate all nodes starting from 'nodes' to graph leaves.
Args:
nodes (list[Node]): the nodes to start from.
node: The nodes to start from.
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"):
nodesToDuplicate, _ = self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True)
# filter out nodes that will be duplicated more than once
@ -772,7 +779,7 @@ class UIGraph(QObject):
dst = currentEdge.dst
for i in range(1, len(listAttribute)):
duplicates = self.duplicateNodesFrom(dst.node)
duplicates = self.duplicateNodesFrom([dst.node])
newNode = duplicates[0]
previousEdge = self.graph.edge(newNode.attribute(dst.name))
self.replaceEdge(previousEdge, listAttribute.at(i), previousEdge.dst)
@ -792,25 +799,28 @@ class UIGraph(QObject):
continue
occurence = allSrc.index(listAttribute.at(i)) if listAttribute.at(i) in allSrc else -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
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)
def clearData(self, nodes):
@Slot(list)
def clearData(self, nodes: list[Node]):
""" Clear data from 'nodes'. """
nodes = self.filterNodes(nodes)
for n in nodes:
n.clearData()
@Slot(QObject)
def clearDataFrom(self, nodes):
@Slot(list)
def clearDataFrom(self, nodes: list[Node]):
"""
Clear data from all nodes starting from 'nodes' to graph leaves.
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])
@ -934,23 +944,83 @@ 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)
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'. """
self.selectNodes(self._graph.dfsOnDiscover(startNodes=[node], reverse=True, dependenciesOnly=True)[0])
@Slot(Node, int)
def selectFollowing(self, node: Node, command=QItemSelectionModel.SelectionFlag.ClearAndSelect):
"""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)
def setSelectedNodesColor(self, color: str):
@ -962,62 +1032,24 @@ 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.
"""
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 ''
Serialize the current node selection and return it as JSON formatted string.
@Slot(str, QPoint, bool, result="QVariantList")
def pasteNodes(self, clipboardContent, position=None, centerPosition=False):
Returns an empty string if the selection is empty.
"""
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
valid node descriptions. If that is the case, the nodes described
@ -1154,9 +1186,7 @@ class UIGraph(QObject):
# Current main selected node
selectedNode = makeProperty(QObject, "_selectedNode", selectedNodeChanged, resetOnDestroy=True)
selectedNodesChanged = Signal()
# Currently selected nodes
selectedNodes = makeProperty(QObject, "_selectedNodes", selectedNodesChanged, resetOnDestroy=True)
nodeSelection = makeProperty(QObject, "_nodeSelection")
hoveredNodeChanged = Signal()
# Currently hovered node

View file

@ -30,21 +30,6 @@ Page {
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: {
var recentImportedImagesFolders = MeshroomApp.recentImportedImagesFolders
@ -531,31 +516,21 @@ Page {
Action {
id: cutAction
property string tooltip: {
var s = "Copy selected node"
s += (_reconstruction && _reconstruction.selectedNodes.count > 1 ? "s (" : " (") + getSelectedNodesName()
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
property string tooltip: "Cut Selected Node(s)"
text: "Cut Node(s)"
enabled: _reconstruction ? _reconstruction.nodeSelection.hasSelection : false
onTriggered: {
graphEditor.copyNodes()
graphEditor.uigraph.removeNodes(graphEditor.uigraph.selectedNodes)
graphEditor.uigraph.removeSelectedNodes()
}
}
Action {
id: copyAction
property string tooltip: {
var s = "Copy selected node"
s += (_reconstruction && _reconstruction.selectedNodes.count > 1 ? "s (" : " (") + getSelectedNodesName()
s += ") to the clipboard"
return s
}
text: "Copy Node" + (_reconstruction && _reconstruction.selectedNodes.count > 1 ? "s " : " ")
enabled: _reconstruction ? _reconstruction.selectedNodes.count > 0 : false
property string tooltip: "Copy Selected Node(s)"
text: "Copy Node(s)"
enabled: _reconstruction ? _reconstruction.nodeSelection.hasSelection : false
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
MSplitView 1.0 MSplitView.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 submitRequest(var nodes)
signal dataDeleted()
property int nbMeshroomScenes: 0
property int nbDraggedFiles: 0
signal filesDropped(var drop, var mousePosition) // Files have been dropped
@ -61,35 +59,14 @@ Item {
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
function duplicateNode(duplicateFollowingNodes) {
var nodes
if (duplicateFollowingNodes) {
nodes = uigraph.duplicateNodesFrom(uigraph.selectedNodes)
nodes = uigraph.duplicateNodesFrom(uigraph.getSelectedNodes())
} else {
nodes = uigraph.duplicateNodes(uigraph.selectedNodes)
nodes = uigraph.duplicateNodes(uigraph.getSelectedNodes())
}
uigraph.clearNodeSelection()
uigraph.selectedNode = nodes[0]
uigraph.selectNodes(nodes)
}
@ -122,7 +99,6 @@ Item {
var copiedContent = Clipboard.getText()
var nodes = uigraph.pasteNodes(copiedContent, finalPosition, centerPosition)
if (nodes.length > 0) {
uigraph.clearNodeSelection()
uigraph.selectedNode = nodes[0]
uigraph.selectNodes(nodes)
}
@ -138,15 +114,15 @@ Item {
fit()
} else if (event.key === Qt.Key_Delete) {
if (event.modifiers === Qt.AltModifier) {
uigraph.removeNodesFrom(uigraph.selectedNodes)
uigraph.removeNodesFrom(uigraph.getSelectedNodes())
} else {
uigraph.removeNodes(uigraph.selectedNodes)
uigraph.removeSelectedNodes()
}
} else if (event.key === Qt.Key_D) {
duplicateNode(event.modifiers === Qt.AltModifier)
} else if (event.key === Qt.Key_X && event.modifiers === Qt.ControlModifier) {
copyNodes()
uigraph.removeNodes(uigraph.selectedNodes)
uigraph.removeSelectedNodes()
} else if (event.key === Qt.Key_C) {
if (event.modifiers === Qt.ControlModifier) {
copyNodes()
@ -195,24 +171,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()
@ -235,14 +208,13 @@ Item {
height: searchBar.height + nodeMenuRepeater.height + instantiator.height
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
// If it is not a pipeline to import, then it must be a node
if (!importPipeline(nodeType)) {
// Add node via the proper command in uigraph
var node = uigraph.addNewNode(nodeType, spawnPosition)
selectNode(node)
var node = uigraph.addNewNode(nodeType, spawnPosition);
uigraph.selectedNode = node;
uigraph.selectNodes([node])
}
close()
}
@ -552,270 +524,299 @@ Item {
}
}
Menu {
id: nodeMenu
Loader {
id: nodeMenuLoader
property var currentNode: null
property bool canComputeNode: currentNode != null && uigraph.graph.canComputeTopologically(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)
property bool isComputed: {
var count = 0
for (var i = 0; i < uigraph.selectedNodes.count; ++i) {
var node = uigraph.selectedNodes.at(i)
if (!node)
continue
if (!node.isComputed)
return false
count += 1
}
return count > 0
active: currentNode != null
sourceComponent: nodeMenuComponent
function load(node) {
currentNode = node;
}
width: 220
onClosed: currentNode = null
MenuItem {
id: computeMenuItem
property bool recompute: false
text: nodeMenu.isComputed ? "Recompute" : "Compute"
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
function unload() {
currentNode = null;
}
function showDataDeletionDialog(deleteFollowing: bool, callback) {
uigraph.forceNodesStatusUpdate();
const dialog = deleteDataDialog.createObject(
root,
{
"node": currentNode,
"deleteFollowing": deleteFollowing
}
return count > 0
}
height: visible ? implicitHeight : 0
);
dialog.open();
if(callback)
dialog.dataDeleted.connect(callback);
}
}
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
}
Component {
id: nodeMenuComponent
Menu {
id: nodeMenu
property var currentNode: nodeMenuLoader.currentNode
// Cache computatibility/submitability status of each selected node.
readonly property var nodeSubmitOrComputeStatus: {
var collectedStatus = ({});
uigraph.nodeSelection.selectedIndexes.forEach(function(idx) {
const node = uigraph.graph.nodes.at(idx.row);
collectedStatus[node] = uigraph.graph.canSubmitOrCompute(node);
});
return collectedStatus;
}
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
Component.onCompleted: popup()
onClosed: nodeMenuLoader.unload()
MenuItem {
id: computeMenuItem
text: nodeMenu.isSelectionFullyComputed ? "Recompute" : "Compute"
visible: nodeMenu.isSelectionOnlyComputableNodes
height: visible ? implicitHeight : 0
enabled: nodeMenu.canSelectionBeComputed
onTriggered: {
if (nodeMenu.isSelectionFullyComputed) {
nodeMenuLoader.showDataDeletionDialog(
false,
function(request, uigraph) {
request(uigraph.getSelectedNodes());
}.bind(null, computeRequest, uigraph)
);
} else {
computeRequest(uigraph.getSelectedNodes());
}
}
return canCompute // canSubmit if canSubmitOrCompute == 1(can compute) or 3(can compute & submit)
}
MenuItem {
id: submitMenuItem
onTriggered: {
if (nodeMenu.isComputed) {
recompute = true
deleteDataMenuItem.showConfirmationDialog(false)
} else {
computeRequest(uigraph.selectedNodes)
}
}
}
MenuItem {
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
text: nodeMenu.isSelectionFullyComputed ? "Re-Submit" : "Submit"
visible: nodeMenu.isSelectionSubmittable
height: visible ? implicitHeight : 0
enabled: nodeMenu.canSelectionBeSubmitted
enabled: {
var canSubmit = 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) {
canSubmit = true
} else if (uigraph.graph.canSubmitOrCompute(node) > 1) {
canSubmit = true
}
onTriggered: {
if (nodeMenu.isSelectionFullyComputed) {
nodeMenuLoader.showDataDeletionDialog(
false,
function(request, uigraph) {
request(uigraph.getSelectedNodes());
}.bind(null, submitRequest, uigraph)
);
} else {
submitRequest(uigraph.getSelectedNodes());
}
}
return canSubmit
}
onTriggered: {
if (nodeMenu.isComputed) {
resubmit = true
deleteDataMenuItem.showConfirmationDialog(false)
} else {
submitRequest(uigraph.selectedNodes)
MenuItem {
text: "Stop Computation"
enabled: nodeMenu.currentNode.canBeStopped()
visible: enabled
height: visible ? implicitHeight : 0
onTriggered: uigraph.stopNodeComputation(nodeMenu.currentNode)
}
MenuItem {
text: "Cancel Computation"
enabled: nodeMenu.currentNode.canBeCanceled()
visible: enabled
height: visible ? implicitHeight : 0
onTriggered: uigraph.cancelNodeComputation(nodeMenu.currentNode)
}
MenuItem {
text: "Open Folder"
visible: nodeMenu.currentNode.isComputable
height: visible ? implicitHeight : 0
onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(nodeMenu.currentNode.internalFolder))
}
MenuSeparator {
visible: nodeMenu.currentNode.isComputable
}
MenuItem {
text: "Cut Node(s)"
enabled: true
ToolTip.text: "Copy selection to the clipboard and remove it"
ToolTip.visible: hovered
onTriggered: {
copyNodes()
uigraph.removeSelectedNodes()
}
}
}
MenuItem {
text: "Stop Computation"
enabled: nodeMenu.currentNode ? nodeMenu.currentNode.canBeStopped() : false
visible: enabled
height: visible ? implicitHeight : 0
onTriggered: uigraph.stopNodeComputation(nodeMenu.currentNode)
}
MenuItem {
text: "Cancel Computation"
enabled: nodeMenu.currentNode ? nodeMenu.currentNode.canBeCanceled() : false
visible: enabled
height: visible ? implicitHeight : 0
onTriggered: uigraph.cancelNodeComputation(nodeMenu.currentNode)
}
MenuItem {
text: "Open Folder"
visible: nodeMenu.currentNode ? nodeMenu.currentNode.isComputable : false
height: visible ? implicitHeight : 0
onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(nodeMenu.currentNode.internalFolder))
}
MenuSeparator {
visible: nodeMenu.currentNode ? nodeMenu.currentNode.isComputable : false
}
MenuItem {
text: "Cut Node(s)"
enabled: true
ToolTip.text: "Copy selection to the clipboard and remove it"
ToolTip.visible: hovered
onTriggered: {
copyNodes()
uigraph.removeNodes(uigraph.selectedNodes)
MenuItem {
text: "Copy Node(s)"
enabled: true
ToolTip.text: "Copy selection to the clipboard"
ToolTip.visible: hovered
onTriggered: copyNodes()
}
}
MenuItem {
text: "Copy Node(s)"
enabled: true
ToolTip.text: "Copy selection to the clipboard"
ToolTip.visible: hovered
onTriggered: copyNodes()
}
MenuItem {
text: "Paste Node(s)"
enabled: true
ToolTip.text: "Copy selection to the clipboard and immediately paste it"
ToolTip.visible: hovered
onTriggered: {
copyNodes()
pasteNodes()
}
}
MenuItem {
text: "Duplicate Node(s)" + (duplicateFollowingButton.hovered ? " From Here" : "")
enabled: true
onTriggered: duplicateNode(false)
MaterialToolButton {
id: duplicateFollowingButton
height: parent.height
anchors {
right: parent.right
rightMargin: parent.padding
}
text: MaterialIcons.fast_forward
onClicked: {
duplicateNode(true)
nodeMenu.close()
MenuItem {
text: "Paste Node(s)"
enabled: true
ToolTip.text: "Copy selection to the clipboard and immediately paste it"
ToolTip.visible: hovered
onTriggered: {
copyNodes()
pasteNodes()
}
}
}
MenuItem {
text: "Remove Node(s)" + (removeFollowingButton.hovered ? " From Here" : "")
enabled: nodeMenu.currentNode ? !nodeMenu.currentNode.locked : false
onTriggered: uigraph.removeNodes(uigraph.selectedNodes)
MaterialToolButton {
id: removeFollowingButton
height: parent.height
anchors {
right: parent.right
rightMargin: parent.padding
}
text: MaterialIcons.fast_forward
onClicked: {
uigraph.removeNodesFrom(uigraph.selectedNodes)
nodeMenu.close()
}
}
}
MenuSeparator {
visible: nodeMenu.currentNode ? nodeMenu.currentNode.isComputable : false
}
MenuItem {
id: deleteDataMenuItem
text: "Delete Data" + (deleteFollowingButton.hovered ? " From Here" : "" ) + "..."
visible: nodeMenu.currentNode ? nodeMenu.currentNode.isComputable : false
height: visible ? implicitHeight : 0
enabled: {
if (!nodeMenu.currentNode)
return false
// Check if the current node is locked (needed because it does not belong to its own duplicates list)
if (nodeMenu.currentNode.locked)
return false
// Check if at least one of the duplicate nodes is locked
for (let i = 0; i < nodeMenu.currentNode.duplicates.count; ++i) {
if (nodeMenu.currentNode.duplicates.at(i).locked)
return false
}
return true
}
function showConfirmationDialog(deleteFollowing) {
uigraph.forceNodesStatusUpdate()
var obj = deleteDataDialog.createObject(root,
{
"node": nodeMenu.currentNode,
"deleteFollowing": deleteFollowing
})
obj.open()
nodeMenu.close()
}
onTriggered: showConfirmationDialog(false)
MaterialToolButton {
id: deleteFollowingButton
anchors {
right: parent.right
rightMargin: parent.padding
}
height: parent.height
text: MaterialIcons.fast_forward
onClicked: parent.showConfirmationDialog(true)
}
// Confirmation dialog for node cache deletion
Component {
id: deleteDataDialog
MessageDialog {
property var node
property bool deleteFollowing: false
focus: true
modal: false
header.visible: false
text: "Delete Data of '" + node.label + "'" + (uigraph.selectedNodes.count > 1 ? " and other selected Nodes" : "") + (deleteFollowing ? " and following Nodes?" : "?")
helperText: "Warning: This operation cannot be undone."
standardButtons: Dialog.Yes | Dialog.Cancel
onAccepted: {
if (deleteFollowing)
uigraph.clearDataFrom(uigraph.selectedNodes)
else
uigraph.clearData(uigraph.selectedNodes)
root.dataDeleted()
MenuItem {
text: "Duplicate Node(s)" + (duplicateFollowingButton.hovered ? " From Here" : "")
enabled: true
onTriggered: duplicateNode(false)
MaterialToolButton {
id: duplicateFollowingButton
height: parent.height
anchors {
right: parent.right
rightMargin: parent.padding
}
text: MaterialIcons.fast_forward
onClicked: {
duplicateNode(true)
nodeMenu.close()
}
onClosed: destroy()
}
}
MenuItem {
text: "Remove Node(s)" + (removeFollowingButton.hovered ? " From Here" : "")
enabled: !nodeMenu.currentNode.locked
onTriggered: uigraph.removeSelectedNodes()
MaterialToolButton {
id: removeFollowingButton
height: parent.height
anchors {
right: parent.right
rightMargin: parent.padding
}
text: MaterialIcons.fast_forward
onClicked: {
uigraph.removeNodesFrom(uigraph.getSelectedNodes())
nodeMenu.close()
}
}
}
MenuSeparator {
visible: nodeMenu.currentNode.isComputable
}
MenuItem {
id: deleteDataMenuItem
text: "Delete Data" + (deleteFollowingButton.hovered ? " From Here" : "" ) + "..."
visible: nodeMenu.currentNode.isComputable
height: visible ? implicitHeight : 0
enabled: {
if (!nodeMenu.currentNode)
return false
// Check if the current node is locked (needed because it does not belong to its own duplicates list)
if (nodeMenu.currentNode.locked)
return false
// Check if at least one of the duplicate nodes is locked
for (let i = 0; i < nodeMenu.currentNode.duplicates.count; ++i) {
if (nodeMenu.currentNode.duplicates.at(i).locked)
return false
}
return true
}
onTriggered: nodeMenuLoader.showDataDeletionDialog(false)
MaterialToolButton {
id: deleteFollowingButton
anchors {
right: parent.right
rightMargin: parent.padding
}
height: parent.height
text: MaterialIcons.fast_forward
onClicked: {
nodeMenuLoader.showDataDeletionDialog(true);
nodeMenu.close();
}
}
}
}
}
// Confirmation dialog for node cache deletion
Component {
id: deleteDataDialog
MessageDialog {
property var node
property bool deleteFollowing: false
signal dataDeleted()
focus: true
modal: false
header.visible: false
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."
standardButtons: Dialog.Yes | Dialog.Cancel
onAccepted: {
if (deleteFollowing)
uigraph.clearDataFrom(uigraph.getSelectedNodes());
else
uigraph.clearSelectedNodesData();
dataDeleted();
}
onClosed: destroy()
}
}
@ -826,7 +827,8 @@ Item {
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
delegate: Node {
@ -836,44 +838,79 @@ Item {
width: uigraph.layout.nodeWidth
mainSelected: uigraph.selectedNode === node
selected: uigraph.selectedNodes.contains(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) }
onAttributePinDeleted: function(attribute, pin) { unregisterAttributePin(attribute, pin) }
onPressed: function(mouse) {
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()
}
} else if (mouse.button === Qt.RightButton) {
if (!mainSelected && !selected) {
uigraph.clearNodeSelection()
}
nodeMenu.currentNode = node
nodeMenu.popup()
nodeRepeater.updateSelectionOnClick = true;
nodeRepeater.ongoingDrag = true;
let selectionMode = ItemSelectionModel.NoUpdate;
if(!selected) {
selectionMode = ItemSelectionModel.ClearAndSelect;
}
selectNode(node)
if (mouse.button === Qt.LeftButton) {
if(mouse.modifiers & Qt.ShiftModifier) {
selectionMode = ItemSelectionModel.Select;
}
if(mouse.modifiers & Qt.ControlModifier) {
selectionMode = ItemSelectionModel.Toggle;
}
if(mouse.modifiers & Qt.AltModifier) {
let selectFollowingMode = ItemSelectionModel.ClearAndSelect;
if(mouse.modifiers & Qt.ShiftModifier) {
selectFollowingMode = ItemSelectionModel.Select;
}
uigraph.selectFollowing(node, selectFollowingMode);
// Indicate selection has been dealt with by setting conservative Select mode.
selectionMode = ItemSelectionModel.Select;
}
}
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) }
onMoved: function(position) { uigraph.moveNode(node, position, uigraph.selectedNodes) }
onEntered: uigraph.hoveredNode = node
onExited: uigraph.hoveredNode = null
@ -899,62 +936,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

View file

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