Meshroom/meshroom/ui/qml/GraphEditor/GraphEditor.qml
Candice Bentéjac bfb9ee3272 [GraphEditor] Convert file to string before testing its extension
This fixes the "Property 'endsWith' of object file xxx is not a function"
warning.
2025-02-20 11:06:07 +01:00

1241 lines
51 KiB
QML
Executable file

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Controls 1.0
import MaterialIcons 2.2
import Utils 1.0
/**
* A component displaying a Graph (nodes, attributes and edges).
*/
Item {
id: root
property variant uigraph: null /// Meshroom UI graph (UIGraph)
readonly property variant graph: uigraph ? uigraph.graph : null /// Core graph contained in the UI graph
property variant nodeTypesModel: null /// The list of node types that can be instantiated
property real maxZoom: 2.0
property real minZoom: 0.1
property var edgeAboutToBeRemoved: undefined
property var _attributeToDelegate: ({})
// Signals
signal workspaceMoved()
signal workspaceClicked()
signal nodeDoubleClicked(var mouse, var node)
signal computeRequest(var nodes)
signal submitRequest(var nodes)
property int nbMeshroomScenes: 0
property int nbDraggedFiles: 0
signal filesDropped(var drop, var mousePosition) // Files have been dropped
// Trigger initial fit() after initialization
// (ensure GraphEditor has its final size)
Component.onCompleted: firstFitTimer.start()
Timer {
id: firstFitTimer
running: false
interval: 10
onTriggered: fit()
}
clip: true
SystemPalette { id: activePalette }
/// Get node delegate for the given node object
function nodeDelegate(node) {
for(var i = 0; i < nodeRepeater.count; ++i) {
if (nodeRepeater.itemAt(i).node === node)
return nodeRepeater.itemAt(i)
}
return undefined
}
/// Duplicate a node and optionally all the following ones
function duplicateNode(duplicateFollowingNodes) {
var nodes
if (duplicateFollowingNodes) {
nodes = uigraph.duplicateNodesFrom(uigraph.getSelectedNodes())
} else {
nodes = uigraph.duplicateNodes(uigraph.getSelectedNodes())
}
uigraph.selectedNode = nodes[0]
uigraph.selectNodes(nodes)
}
/// Copy node content to clipboard
function copyNodes() {
var nodeContent = uigraph.getSelectedNodesContent()
if (nodeContent !== '') {
Clipboard.clear()
Clipboard.setText(nodeContent)
}
}
/// Paste content of clipboard to graph editor and create new node if valid
function pasteNodes() {
let finalPosition = undefined;
if (mouseArea.containsMouse) {
finalPosition = mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY);
} else {
finalPosition = getCenterPosition();
}
const copiedContent = Clipboard.getText();
const nodes = uigraph.pasteNodes(copiedContent, finalPosition);
if (nodes.length > 0) {
uigraph.selectedNode = nodes[0];
uigraph.selectNodes(nodes);
}
}
/// Get the coordinates of the point at the center of the GraphEditor
function getCenterPosition() {
return mapToItem(draggable, mouseArea.width / 2, mouseArea.height / 2)
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_F) {
fit()
} else if (event.key === Qt.Key_Delete) {
if (event.modifiers === Qt.AltModifier) {
uigraph.removeNodesFrom(uigraph.getSelectedNodes())
} else {
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.removeSelectedNodes()
} else if (event.key === Qt.Key_C) {
if (event.modifiers === Qt.ControlModifier) {
copyNodes()
}
else {
colorSelector.toggle()
}
} else if (event.key === Qt.Key_V && event.modifiers === Qt.ControlModifier) {
pasteNodes()
} else if (event.key === Qt.Key_Tab) {
event.accepted = true
if (mouseArea.containsMouse) {
newNodeMenu.spawnPosition = mouseArea.mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY)
newNodeMenu.popup()
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
property double factor: 1.15
// Activate multisampling for edges antialiasing
layer.enabled: true
layer.samples: 8
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
drag.threshold: 0
drag.smoothed: false
cursorShape: drag.target == draggable ? Qt.ClosedHandCursor : Qt.ArrowCursor
onWheel: function(wheel) {
var zoomFactor = wheel.angleDelta.y > 0 ? factor : 1 / factor
var scale = draggable.scale * zoomFactor
scale = Math.min(Math.max(minZoom, scale), maxZoom)
if (draggable.scale == scale)
return
var point = mapToItem(draggable, wheel.x, wheel.y)
draggable.x += (1 - zoomFactor) * point.x * draggable.scale
draggable.y += (1 - zoomFactor) * point.y * draggable.scale
draggable.scale = scale
workspaceMoved()
}
onPressed: function(mouse) {
if (mouse.button != Qt.MiddleButton && mouse.modifiers == Qt.NoModifier) {
uigraph.clearNodeSelection()
}
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.AltModifier)) {
drag.target = draggable // start drag
}
}
onReleased: {
nodeSelectionBox.endSelection();
drag.target = null;
root.forceActiveFocus()
workspaceClicked()
}
onPositionChanged: {
if (drag.active)
workspaceMoved()
}
onClicked: function(mouse) {
if (mouse.button == Qt.RightButton) {
// Store mouse click position in 'draggable' coordinates as new node spawn position
newNodeMenu.spawnPosition = mouseArea.mapToItem(draggable, mouse.x, mouse.y)
newNodeMenu.popup()
}
}
// Contextual Menu for creating new nodes
// TODO: add filtering + validate on 'Enter'
Menu {
id: newNodeMenu
property point spawnPosition
property variant menuKeys: Object.keys(root.nodeTypesModel).concat(Object.values(MeshroomApp.pipelineTemplateNames))
height: searchBar.height + nodeMenuRepeater.height + instantiator.height
function createNode(nodeType) {
// "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);
uigraph.selectedNode = node;
uigraph.selectNodes([node])
}
close()
}
function importPipeline(pipeline) {
if (MeshroomApp.pipelineTemplateNames.includes(pipeline)) {
var url = MeshroomApp.pipelineTemplateFiles[MeshroomApp.pipelineTemplateNames.indexOf(pipeline)]["path"]
var nodes = uigraph.importProject(Filepath.stringToUrl(url), spawnPosition)
uigraph.selectedNode = nodes[0]
uigraph.selectNodes(nodes)
return true
}
return false
}
function parseCategories() {
// Organize nodes based on their category
// {"category1": ["node1", "node2"], "category2": ["node3", "node4"]}
let categories = {};
for (const [name, data] of Object.entries(root.nodeTypesModel)) {
let category = data["category"];
if (categories[category] === undefined) {
categories[category] = []
}
categories[category].push(name)
}
// Add a "Pipelines" category, filled with the list of templates to create pipelines from the menu
categories["Pipelines"] = MeshroomApp.pipelineTemplateNames
return categories
}
onVisibleChanged: {
searchBar.clear()
if (visible) {
// When menu is shown, give focus to the TextField filter
searchBar.forceActiveFocus()
}
}
SearchBar {
id: searchBar
width: parent.width
}
// menuItemDelegate is wrapped in a component so it can be used in both the search bar and sub-menus
Component {
id: menuItemDelegateComponent
MenuItem {
id: menuItemDelegate
font.pointSize: 8
padding: 3
// Hide items that does not match the filter text
visible: modelData.toLowerCase().indexOf(searchBar.text.toLowerCase()) > -1
text: modelData
// Forward key events to the search bar to continue typing seamlessly
// even if this delegate took the activeFocus due to mouse hovering
Keys.forwardTo: [searchBar.textField]
Keys.onPressed: function(event) {
event.accepted = false;
switch (event.key) {
case Qt.Key_Return:
case Qt.Key_Enter:
// Create node on validation (Enter/Return keys)
newNodeMenu.createNode(modelData)
event.accepted = true
break
case Qt.Key_Up:
case Qt.Key_Down:
case Qt.Key_Left:
case Qt.Key_Right:
break // Ignore if arrow key was pressed to let the menu be controlled
default:
searchBar.forceActiveFocus()
}
}
// Set the priority ordering of the keys to be Item's own Key Handling > ForwardTo
Keys.priority: Keys.AfterItem;
// Create node on mouse click
onClicked: newNodeMenu.createNode(modelData)
states: [
State {
// Additional property setting when the MenuItem is not visible
when: !visible
name: "invisible"
PropertyChanges {
target: menuItemDelegate
height: 0 // Make sure the item is no visible by setting height to 0
focusPolicy: Qt.NoFocus // Don't grab focus when not visible
}
}
]
}
}
Repeater {
id: nodeMenuRepeater
model: searchBar.text !== "" ? Object.values(newNodeMenu.menuKeys) : undefined
// Create Menu items from available items
delegate: menuItemDelegateComponent
}
// Dynamically add the menu categories
Instantiator {
id: instantiator
model: (searchBar.text === "") ? Object.keys(newNodeMenu.parseCategories()).sort() : undefined
onObjectAdded: function(index, object) {
// Add sub-menu under the search bar
newNodeMenu.insertMenu(index + 1, object)
}
onObjectRemoved: function(index, object) {
newNodeMenu.removeMenu(object)
}
delegate: Menu {
title: modelData
id: newNodeSubMenu
Instantiator {
model: newNodeMenu.visible ? newNodeMenu.parseCategories()[modelData] : undefined
onObjectAdded: function(index, object) {
newNodeSubMenu.insertItem(index, object)
}
onObjectRemoved: function(index, object) {
newNodeSubMenu.removeItem(object)
}
delegate: menuItemDelegateComponent
}
}
}
}
// Informative contextual menu when graph is read-only
Menu {
id: lockedMenu
MenuItem {
id: item
font.pointSize: 8
enabled: false
text: "Computing - Graph is Locked!"
}
}
Item {
id: draggable
transformOrigin: Item.TopLeft
width: 1000
height: 1000
Popup {
id: edgeMenu
property var currentEdge: null
property bool forLoop: false
onOpened: {
expandButton.canExpand = uigraph.canExpandForLoop(edgeMenu.currentEdge)
}
contentItem: Row {
IntSelector {
id: loopIterationSelector
tooltipText: "Iterations"
visible: edgeMenu.currentEdge && edgeMenu.forLoop
enabled: expandButton.canExpand
property var listAttr: edgeMenu.currentEdge ? edgeMenu.currentEdge.src.root : null
Connections {
target: edgeMenu
function onCurrentEdgeChanged() {
if (edgeMenu.currentEdge) {
loopIterationSelector.listAttr = edgeMenu.currentEdge.src.root
loopIterationSelector.value = loopIterationSelector.listAttr ? loopIterationSelector.listAttr.value.indexOf(edgeMenu.currentEdge.src) + 1 : 0
}
}
}
// We add 1 to the index because of human readable index (starting at 1)
value: listAttr ? listAttr.value.indexOf(edgeMenu.currentEdge.src) + 1 : 0
range: { "min": 1, "max": listAttr ? listAttr.value.count : 0 }
onValueChanged: {
if (listAttr === null) {
return
}
const newSrcAttr = listAttr.value.at(value - 1)
const dst = edgeMenu.currentEdge.dst
// If the edge exists, do not replace it
if (newSrcAttr === edgeMenu.currentEdge.src && dst === edgeMenu.currentEdge.dst) {
return
}
edgeMenu.currentEdge = uigraph.replaceEdge(edgeMenu.currentEdge, newSrcAttr, dst)
}
}
MaterialToolButton {
font.pointSize: 13
ToolTip.text: "Remove Edge"
enabled: edgeMenu.currentEdge && !edgeMenu.currentEdge.dst.node.locked && !edgeMenu.currentEdge.dst.isReadOnly
text: MaterialIcons.delete_
onClicked: {
uigraph.removeEdge(edgeMenu.currentEdge)
edgeMenu.close()
}
}
MaterialToolButton {
id: expandButton
property bool canExpand: edgeMenu.currentEdge && edgeMenu.forLoop
visible: edgeMenu.currentEdge && edgeMenu.forLoop && canExpand
enabled: edgeMenu.currentEdge && !edgeMenu.currentEdge.dst.node.locked && !edgeMenu.currentEdge.dst.isReadOnly
font.pointSize: 13
ToolTip.text: "Expand"
text: MaterialIcons.open_in_full
onClicked: {
edgeMenu.currentEdge = uigraph.expandForLoop(edgeMenu.currentEdge)
canExpand = false
edgeMenu.close()
}
}
MaterialToolButton {
id: collapseButton
visible: edgeMenu.currentEdge && edgeMenu.forLoop && !expandButton.canExpand
enabled: edgeMenu.currentEdge && !edgeMenu.currentEdge.dst.node.locked && !edgeMenu.currentEdge.dst.isReadOnly
font.pointSize: 13
ToolTip.text: "Collapse"
text: MaterialIcons.close_fullscreen
onClicked: {
uigraph.collapseForLoop(edgeMenu.currentEdge)
expandButton.canExpand = true
edgeMenu.close()
}
}
}
}
// Edges
Repeater {
id: edgesRepeater
// Delay edges loading after nodes (edges needs attribute pins to be created)
model: nodeRepeater.loaded && root.graph ? root.graph.edges : undefined
delegate: Edge {
property var src: root._attributeToDelegate[edge.src]
property var dst: root._attributeToDelegate[edge.dst]
property bool isValidEdge: src !== undefined && dst !== undefined
visible: isValidEdge && src.visible && dst.visible
property bool forLoop: src.attribute.type === "ListAttribute" && dst.attribute.type != "ListAttribute"
property bool inFocus: containsMouse || (edgeMenu.opened && edgeMenu.currentEdge === edge)
edge: object
isForLoop: forLoop
loopSize: forLoop ? edge.src.root.value.count : 0
iteration: forLoop ? edge.src.root.value.indexOf(edge.src) : 0
color: edge.dst === root.edgeAboutToBeRemoved ? "red" : inFocus ? activePalette.highlight : activePalette.text
thickness: {
if (forLoop) {
return (inFocus) ? 4 : 3
}
return (inFocus) ? 2 : 1
}
point1x: isValidEdge ? src.globalX + src.outputAnchorPos.x : 0
point1y: isValidEdge ? src.globalY + src.outputAnchorPos.y : 0
point2x: isValidEdge ? dst.globalX + dst.inputAnchorPos.x : 0
point2y: isValidEdge ? dst.globalY + dst.inputAnchorPos.y : 0
onPressed: function(event) {
const canEdit = !edge.dst.node.locked
if (event.button) {
if (canEdit && (event.modifiers & Qt.AltModifier)) {
uigraph.removeEdge(edge)
} else {
edgeMenu.currentEdge = edge
edgeMenu.forLoop = forLoop
var spawnPosition = mouseArea.mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY)
edgeMenu.x = spawnPosition.x
edgeMenu.y = spawnPosition.y
edgeMenu.open()
}
}
}
Component.onDestruction: {
// Handles the case where the edge is destroyed while hidden because it is replaced: the pins should be re-enabled
if (!_window.isClosing) {
// When the window is closing, the QML context becomes invalid, which causes function calls to result in errors
if (src && src !== undefined)
src.updatePin(true, true) // isSrc = true, isVisible = true
if (dst && dst !== undefined)
dst.updatePin(false, true) // isSrc = false, isVisible = true
}
}
}
}
Loader {
id: nodeMenuLoader
property var currentNode: null
active: currentNode != null
sourceComponent: nodeMenuComponent
function load(node) {
currentNode = node;
}
function unload() {
currentNode = null;
}
function showDataDeletionDialog(deleteFollowing: bool, callback) {
uigraph.forceNodesStatusUpdate();
const dialog = deleteDataDialog.createObject(
root,
{
"node": currentNode,
"deleteFollowing": deleteFollowing
}
);
dialog.open();
if(callback)
dialog.dataDeleted.connect(callback);
}
}
Component {
id: nodeMenuComponent
Menu {
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());
}
}
}
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.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: "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: "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()
}
}
// Nodes
Repeater {
id: nodeRepeater
model: root.graph ? root.graph.nodes : undefined
property bool loaded: model ? count === model.count : false
property bool ongoingDrag: false
property bool updateSelectionOnClick: false
property var temporaryEdgeAboutToBeRemoved: undefined
delegate: Node {
id: nodeDelegate
node: object
width: uigraph.layout.nodeWidth
mainSelected: uigraph.selectedNode === 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.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) }
onEntered: uigraph.hoveredNode = node
onExited: uigraph.hoveredNode = null
onEdgeAboutToBeRemoved: function(input) {
/*
* Sometimes the signals are not in the right order because of weird Qt/QML update order
* (next DropArea entered signal before previous DropArea exited signal) so edgeAboutToBeRemoved
* must be set to undefined before it can be set to another attribute object.
*/
if (input === undefined) {
if (nodeRepeater.temporaryEdgeAboutToBeRemoved === undefined) {
root.edgeAboutToBeRemoved = input
} else {
root.edgeAboutToBeRemoved = nodeRepeater.temporaryEdgeAboutToBeRemoved
nodeRepeater.temporaryEdgeAboutToBeRemoved = undefined
}
} else {
if (root.edgeAboutToBeRemoved === undefined) {
root.edgeAboutToBeRemoved = input
} else {
nodeRepeater.temporaryEdgeAboutToBeRemoved = input
}
}
}
// Interactive dragging: move the visual delegates
onPositionChanged: {
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;
}
});
}
// 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);
}
Behavior on x {
enabled: !nodeRepeater.ongoingDrag
NumberAnimation { duration: 100 }
}
Behavior on y {
enabled: !nodeRepeater.ongoingDrag
NumberAnimation { duration: 100 }
}
}
}
}
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);
}
}
DropArea {
id: dropArea
anchors.fill: parent
keys: ["text/uri-list"]
onEntered: function(drag) {
nbMeshroomScenes = 0
nbDraggedFiles = drag.urls.length
drag.urls.forEach(function(file) {
if (String(file).endsWith(".mg")) {
nbMeshroomScenes++
}
})
}
onDropped: function(drop) {
if (nbMeshroomScenes == nbDraggedFiles || nbMeshroomScenes == 0) {
// Retrieve mouse position and convert coordinate system
// from pixel values to graph reference system
var mousePosition = mapToItem(draggable, drag.x, drag.y)
// Send the list of files,
// to create the corresponding nodes or open another scene
filesDropped(drop, mousePosition)
} else {
errorDialog.open()
}
}
}
}
MessageDialog {
id: errorDialog
icon.text: MaterialIcons.error
icon.color: "#F44336"
title: "Different File Types"
text: "Do not mix .mg files and other types of files."
standardButtons: Dialog.Ok
parent: Overlay.overlay
onAccepted: close()
}
// Toolbar
FloatingPane {
padding: 2
anchors.bottom: parent.bottom
RowLayout {
id: navigation
spacing: 4
// Default index for search
property int currentIndex: -1
// Fit
MaterialToolButton {
text: MaterialIcons.fullscreen
ToolTip.text: "Fit"
onClicked: root.fit()
}
// Auto-Layout
MaterialToolButton {
text: MaterialIcons.linear_scale
ToolTip.text: "Auto-Layout"
onClicked: uigraph.layout.reset()
}
// Separator
Rectangle {
Layout.fillHeight: true
Layout.margins: 2
implicitWidth: 1
color: activePalette.window
}
ColorSelector {
id: colorSelector
Layout.minimumWidth: colorSelector.width
// When a Color is selected
onColorSelected: (color)=> {
uigraph.setSelectedNodesColor(color)
}
}
// Separator
Rectangle {
Layout.fillHeight: true
Layout.margins: 2
implicitWidth: 1
color: activePalette.window
}
// Search nodes in the graph
SearchBar {
id: graphSearchBar
toggle: true // enable toggling the actual text field by the search button
Layout.minimumWidth: graphSearchBar.width
maxWidth: 150
textField.background.opacity: 0.5
textField.onTextChanged: navigation.currentIndex = -1
onAccepted: {
navigation.navigateForward()
}
}
MaterialToolButton {
text: MaterialIcons.arrow_left
padding: 0
visible: graphSearchBar.text !== ""
onClicked: navigation.navigateBackward()
}
MaterialToolButton {
id: nextArrow
text: MaterialIcons.arrow_right
padding: 0
visible: graphSearchBar.text !== ""
onClicked: navigation.navigateForward()
}
Label {
id: currentSearchLabel
text: " " + (navigation.currentIndex + 1) + "/" + filteredNodes.count
padding: 0
visible: graphSearchBar.text !== ""
}
Repeater {
id: filteredNodes
model: SortFilterDelegateModel {
model: root.graph ? root.graph.nodes : undefined
sortRole: "label"
filters: [{role: "label", value: graphSearchBar.text}]
delegate: Item {
visible: false // Hide the items to not affect the layout as the nodes model gets changes
property var index_: index
}
function modelData(item, roleName_) {
return item.model.object[roleName_]
}
}
}
function navigateForward() {
/**
* Moves the navigation index forwards and focuses on the next node as per index.
*/
if (!filteredNodes.count)
return
navigation.currentIndex++
if (navigation.currentIndex === filteredNodes.count)
navigation.currentIndex = 0
navigation.nextItem()
}
function navigateBackward() {
/**
* Moves the navigation index backwards and focuses on the previous node as per index.
*/
if (!filteredNodes.count)
return
navigation.currentIndex--
if (navigation.currentIndex === -1)
navigation.currentIndex = filteredNodes.count - 1
navigation.nextItem()
}
function nextItem() {
// Compute bounding box
var node = nodeRepeater.itemAt(filteredNodes.itemAt(navigation.currentIndex).index_)
var bbox = Qt.rect(node.x, node.y, node.width, node.height)
// Rescale to fit the bounding box in the view, zoom is limited to prevent huge text
draggable.scale = Math.min(Math.min(root.width / bbox.width, root.height / bbox.height),maxZoom)
// Recenter
draggable.x = bbox.x*draggable.scale * -1 + (root.width - bbox.width * draggable.scale) * 0.5
draggable.y = bbox.y*draggable.scale * -1 + (root.height - bbox.height * draggable.scale) * 0.5
}
}
}
function registerAttributePin(attribute, pin) {
root._attributeToDelegate[attribute] = pin
}
function unregisterAttributePin(attribute, pin) {
delete root._attributeToDelegate[attribute]
}
function boundingBox() {
var first = nodeRepeater.itemAt(0)
if (first === null) {
return Qt.rect(0, 0, 0, 0)
}
var bbox = Qt.rect(first.x, first.y, first.x + first.width, first.y + first.height)
for (var i = 0; i < root.graph.nodes.count; ++i) {
var item = nodeRepeater.itemAt(i)
bbox.x = Math.min(bbox.x, item.x)
bbox.y = Math.min(bbox.y, item.y)
bbox.width = Math.max(bbox.width, item.x + item.width)
bbox.height = Math.max(bbox.height, item.y + item.height)
}
bbox.width -= bbox.x
bbox.height -= bbox.y
return bbox;
}
function selectionBoundingBox() {
/**
* Returns the bounding box considering the nodes which are selected.
* The returned bounding box starts from the Minumum x,y position to the
* Maximum x,y postion of the selected nodes.
*/
var firstIdx = uigraph.nodeSelection.selectedIndexes[0];
const first = nodeRepeater.itemAt(firstIdx.row);
// Bounding box of the first selected item
var bbox = Qt.rect(first.x, first.y, first.x + first.width, first.y + first.height);
// Iterate over the remaining items in the selection
uigraph.nodeSelection.selectedIndexes.forEach(function(idx) {
if(idx != firstIdx) {
const item = nodeRepeater.itemAt(idx.row);
bbox.x = Math.min(bbox.x, item.x);
bbox.y = Math.min(bbox.y, item.y);
bbox.width = Math.max(bbox.width, item.x + item.width);
bbox.height = Math.max(bbox.height, item.y + item.height);
}
});
bbox.width -= bbox.x;
bbox.height -= bbox.y;
return bbox;
}
// Fit graph to fill root
function fit() {
var bbox;
// Compute bounding box
if (uigraph.nodeSelection.hasSelection) {
bbox = selectionBoundingBox();
}
else {
bbox = boundingBox();
}
// Rescale to fit the bounding box in the view, zoom is limited to prevent huge text
draggable.scale = Math.min(Math.min(root.width / bbox.width, root.height / bbox.height), maxZoom)
// Recenter
draggable.x = bbox.x * draggable.scale * -1 + (root.width - bbox.width * draggable.scale) * 0.5
draggable.y = bbox.y * draggable.scale * -1 + (root.height - bbox.height * draggable.scale) * 0.5
}
}