diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 8acbe767a3..ff4f1abd9c 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,3 +1,5 @@ +# [GraphEditor] Indentation fix +87c0cef605e4ef2b359d7e678155e79b65b2e762 # [qt6][qml] Clean-up code and harmonize comments 5a0b1c0c9547b0d00f3f10fae6994d6d8ea0b45e # [nodes] Linting: Clean-up files diff --git a/meshroom/common/qt.py b/meshroom/common/qt.py index 80a0950469..6e067dbe13 100644 --- a/meshroom/common/qt.py +++ b/meshroom/common/qt.py @@ -283,6 +283,15 @@ def _referenceItem(self, 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): # Ask for object deletion if parented to the model if shiboken6.isValid(item) and item.parent() == self: diff --git a/meshroom/ui/components/__init__.py b/meshroom/ui/components/__init__.py index 59c77618f8..aef07a61f2 100755 --- a/meshroom/ui/components/__init__.py +++ b/meshroom/ui/components/__init__.py @@ -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") diff --git a/meshroom/ui/components/geom2D.py b/meshroom/ui/components/geom2D.py new file mode 100644 index 0000000000..5cc972f1c2 --- /dev/null +++ b/meshroom/ui/components/geom2D.py @@ -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) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 0a8e80d455..0cde6b2ddd 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -7,8 +7,19 @@ from enum import Enum from threading import Thread, Event, Lock from multiprocessing.pool import ThreadPool - -from PySide6.QtCore import Slot, QJsonValue, QObject, QUrl, Property, Signal, QPoint +from typing import Iterator, Optional, Union + +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 @@ def __init__(self, undoStack, taskManager, parent=None): self._sortedDFSChunks = QObjectListModel(parent=self) self._layout = GraphLayout(self) self._selectedNode = None - self._selectedNodes = QObjectListModel(parent=self) + self._nodeSelection = QItemSelectionModel(self._graph.nodes, parent=self) self._hoveredNode = None self.submitLabel = "{projectName}" @@ -395,6 +406,8 @@ def setGraph(self, g): 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 @@ def updateLockedUndoStack(self): 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 @@ def cancelNodeComputation(self, node): 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 @@ def addNewNode(self, nodeType, position=None, **kwargs): 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 + self.push(commands.MoveNodeCommand(self._graph, node, position)) + + @Slot(QPoint) + def moveSelectedNodesBy(self, offset: QPoint): + """Move all the selected nodes by the given `offset`.""" + with self.groupedGraphModification("Move Selected Nodes"): - for n in nodes: - position = Position(n.x + deltaX, n.y + deltaY) - self.push(commands.MoveNodeCommand(self._graph, n, position)) + for node in self.iterSelectedNodes(): + position = Position(node.x + offset.x(), node.y + offset.y()) + self.moveNode(node, position) + + @Slot() + def removeSelectedNodes(self): + """Remove selected nodes from the graph.""" + self.removeNodes(list(self.iterSelectedNodes())) - @Slot(QObject) - def removeNodes(self, nodes): + @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 @@ def removeNodesFrom(self, nodes): # 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 @@ def duplicateNodes(self, nodes): 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 @@ def expandForLoop(self, currentEdge): 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 @@ def collapseForLoop(self, currentEdge): 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 @@ def removeImagesFromAllGroups(self): 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 @@ def setSelectedNodesColor(self, color: str): # 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): + @Slot(result=str) + def getSelectedNodesContent(self) -> str: """ - Select nodes that overlap with 'selection'. - Takes into account the zoom and position of 'draggable'. + Serialize the current node selection and return it as JSON formatted string. - Args: - selection: the rectangle selection widget. - draggable: the parent widget that has position and scale data. + Returns an empty string if the selection is empty. """ - 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() + if not self._nodeSelection.hasSelection(): + return "" + serializedSelection = {node.name: node.toDict() for node in self.iterSelectedNodes()} + return json.dumps(serializedSelection, indent=4) - @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): - """ - 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 '' - - @Slot(str, QPoint, bool, result="QVariantList") - def pasteNodes(self, clipboardContent, position=None, centerPosition=False): + @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 @@ def pasteNodes(self, clipboardContent, position=None, centerPosition=False): # 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 diff --git a/meshroom/ui/qml/Application.qml b/meshroom/ui/qml/Application.qml index 64ce5a3ce4..48884e2f33 100644 --- a/meshroom/ui/qml/Application.qml +++ b/meshroom/ui/qml/Application.qml @@ -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() } diff --git a/meshroom/ui/qml/Controls/DelegateSelectionBox.qml b/meshroom/ui/qml/Controls/DelegateSelectionBox.qml new file mode 100644 index 0000000000..a6034c8ac3 --- /dev/null +++ b/meshroom/ui/qml/Controls/DelegateSelectionBox.qml @@ -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 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); + } +} diff --git a/meshroom/ui/qml/Controls/SelectionBox.qml b/meshroom/ui/qml/Controls/SelectionBox.qml new file mode 100644 index 0000000000..e7763e8437 --- /dev/null +++ b/meshroom/ui/qml/Controls/SelectionBox.qml @@ -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 + } +} diff --git a/meshroom/ui/qml/Controls/qmldir b/meshroom/ui/qml/Controls/qmldir index 80d5d9690f..9d3e23cb75 100644 --- a/meshroom/ui/qml/Controls/qmldir +++ b/meshroom/ui/qml/Controls/qmldir @@ -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 \ No newline at end of file diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 3e052acde3..c74acbc7d1 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -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,273 +524,302 @@ 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 + ); + dialog.open(); + if(callback) + dialog.dataDeleted.connect(callback); + } + } + + 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; } - height: visible ? implicitHeight : 0 - - 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) + + readonly property bool isSelectionFullyComputed: { + return uigraph.nodeSelection.selectedIndexes.every(function(idx) { + return uigraph.graph.nodes.at(idx.row).isComputed; + }); } - onTriggered: { - if (nodeMenu.isComputed) { - recompute = true - deleteDataMenuItem.showConfirmationDialog(false) - } else { - computeRequest(uigraph.selectedNodes) - } + 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) + ); + }); } - } - 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 + + 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 + ); + }); } - height: visible ? implicitHeight : 0 - - 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 - } + + 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 canSubmit } - onTriggered: { - if (nodeMenu.isComputed) { - resubmit = true - deleteDataMenuItem.showConfirmationDialog(false) - } else { - submitRequest(uigraph.selectedNodes) + MenuItem { + id: submitMenuItem + + text: nodeMenu.isSelectionFullyComputed ? "Re-Submit" : "Submit" + visible: nodeMenu.isSelectionSubmittable + height: visible ? implicitHeight : 0 + enabled: nodeMenu.canSelectionBeSubmitted + + onTriggered: { + if (nodeMenu.isSelectionFullyComputed) { + nodeMenuLoader.showDataDeletionDialog( + false, + function(request, uigraph) { + request(uigraph.getSelectedNodes()); + }.bind(null, submitRequest, uigraph) + ); + } else { + submitRequest(uigraph.getSelectedNodes()); + } } } - } - 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: "Stop Computation" + enabled: nodeMenu.currentNode.canBeStopped() + visible: enabled + height: visible ? implicitHeight : 0 + onTriggered: uigraph.stopNodeComputation(nodeMenu.currentNode) } - } - 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: "Cancel Computation" + enabled: nodeMenu.currentNode.canBeCanceled() + visible: enabled + height: visible ? implicitHeight : 0 + onTriggered: uigraph.cancelNodeComputation(nodeMenu.currentNode) } - } - 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: "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: "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 + 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() } - text: MaterialIcons.fast_forward - onClicked: { - uigraph.removeNodesFrom(uigraph.selectedNodes) - nodeMenu.close() + } + 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() + } } } - } - 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 + 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() + } } - return true } - - function showConfirmationDialog(deleteFollowing) { - uigraph.forceNodesStatusUpdate() - var obj = deleteDataDialog.createObject(root, - { - "node": nodeMenu.currentNode, - "deleteFollowing": deleteFollowing - }) - obj.open() - nodeMenu.close() + MenuSeparator { + visible: nodeMenu.currentNode.isComputable } - - onTriggered: showConfirmationDialog(false) - - MaterialToolButton { - id: deleteFollowingButton - anchors { - right: parent.right - rightMargin: parent.padding + 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 } - 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 + onTriggered: nodeMenuLoader.showDataDeletionDialog(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() + MaterialToolButton { + id: deleteFollowingButton + anchors { + right: parent.right + rightMargin: parent.padding + } + height: parent.height + text: MaterialIcons.fast_forward + onClicked: { + nodeMenuLoader.showDataDeletionDialog(true); + nodeMenu.close(); } - onClosed: destroy() } + } } } + // 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() + } + } + // Nodes Repeater { id: nodeRepeater @@ -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,43 +838,78 @@ 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) { + nodeRepeater.updateSelectionOnClick = true; + nodeRepeater.ongoingDrag = true; + + let selectionMode = ItemSelectionModel.NoUpdate; + + if(!selected) { + selectionMode = ItemSelectionModel.ClearAndSelect; + } + if (mouse.button === Qt.LeftButton) { - if (mouse.modifiers & Qt.ControlModifier && !(mouse.modifiers & Qt.AltModifier)) { - if (mainSelected && selected) { - // Left clicking a selected node twice with control will deselect it - uigraph.selectedNodes.remove(node) - uigraph.selectedNodesChanged() - selectNode(null) - return - } - } else if (mouse.modifiers & Qt.AltModifier) { - if (!(mouse.modifiers & Qt.ControlModifier)) { - uigraph.clearNodeSelection() + 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) - } else if (!mainSelected && !selected) { - uigraph.clearNodeSelection() + 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 (!mainSelected && !selected) { - uigraph.clearNodeSelection() + } + else if (mouse.button === Qt.RightButton) { + if(selected) { + // Keep the full selection when right-clicking on an already selected node. + nodeRepeater.updateSelectionOnClick = false; } - nodeMenu.currentNode = node - nodeMenu.popup() } - selectNode(node) + + if(selectionMode != ItemSelectionModel.NoUpdate) { + nodeRepeater.updateSelectionOnClick = false; + uigraph.selectNodeByIndex(index, selectionMode); + } + + // If the node is selected after this, make it the active selected node. + if(selected) { + uigraph.selectedNode = node; + } + + // Open the node context menu once selection has been updated. + if(mouse.button == Qt.RightButton) { + nodeMenuLoader.load(node) + } + } - onDoubleClicked: function(mouse) { root.nodeDoubleClicked(mouse, node) } + onReleased: function(mouse, wasDragged) { + nodeRepeater.ongoingDrag = false; + } - onMoved: function(position) { uigraph.moveNode(node, position, uigraph.selectedNodes) } + // 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) } 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 diff --git a/meshroom/ui/qml/GraphEditor/Node.qml b/meshroom/ui/qml/GraphEditor/Node.qml index ffce841d1c..65b95cd827 100755 --- a/meshroom/ui/qml/GraphEditor/Node.qml +++ b/meshroom/ui/qml/GraphEditor/Node.qml @@ -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: {