[ui/core] First version of For Loop implementation

If you connect a list to an attribute, you can iterate over the list as a for loop
This commit is contained in:
Aurore LAFAURIE 2024-08-16 19:29:24 +02:00
parent 52cb124589
commit 019e137386
9 changed files with 363 additions and 120 deletions

View file

@ -36,7 +36,7 @@ def attributeFactory(description, value, isOutput, node, root=None, parent=None)
class Attribute(BaseObject):
"""
"""
stringIsLinkRe = re.compile(r'^\{[A-Za-z]+[A-Za-z0-9_.]*\}$')
stringIsLinkRe = re.compile(r'^\{[A-Za-z]+[A-Za-z0-9_.\[\]]*\}$')
def __init__(self, node, attributeDesc, isOutput, root=None, parent=None):
"""
@ -324,6 +324,9 @@ class Attribute(BaseObject):
# safety check to avoid evaluation errors
if not self.node.graph or not self.node.graph.edges:
return False
# if the attribute is a ListAttribute, we need to check if any of its elements has output connections
if isinstance(self, ListAttribute):
return next((edge for edge in self.node.graph.edges.values() if edge.src == self), None) is not None or any(attr.hasOutputConnections for attr in self._value if hasattr(attr, 'hasOutputConnections'))
return next((edge for edge in self.node.graph.edges.values() if edge.src == self), None) is not None
def _applyExpr(self):
@ -447,6 +450,7 @@ class Attribute(BaseObject):
uidIgnoreValue = Property(Variant, getUidIgnoreValue, constant=True)
validValueChanged = Signal()
validValue = Property(bool, getValidValue, setValidValue, notify=validValueChanged)
root = Property(BaseObject, root.fget, constant=True)
def raiseIfLink(func):

View file

@ -687,6 +687,8 @@ class UIGraph(QObject):
Args:
startNode (Node): the node 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
@ -706,7 +708,7 @@ class UIGraph(QObject):
list[Node]: the list of duplicated nodes
"""
nodes = self.filterNodes(nodes)
nPositions = []
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):
# disable graph updates during duplication
@ -716,9 +718,8 @@ class UIGraph(QObject):
bbox = self._layout.boundingBox(nodes)
for n in duplicates:
idx = duplicates.index(n)
yPos = n.y + self.layout.gridSpacing + bbox[3]
if idx > 0 and (n.x, yPos) in nPositions:
if (n.x, yPos) in nPositions:
# make sure the node will not be moved on top of another node
while (n.x, yPos) in nPositions:
yPos = yPos + self.layout.gridSpacing + self.layout.nodeHeight
@ -739,12 +740,59 @@ class UIGraph(QObject):
Returns:
list[Node]: 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
uniqueNodesToDuplicate = list(dict.fromkeys(nodesToDuplicate))
duplicates = self.duplicateNodes(uniqueNodesToDuplicate)
return duplicates
@Slot(Edge, result=bool)
def canExpandForLoop(self, currentEdge):
""" Check if the list attribute can be expanded by looking at all the edges connected to it. """
listAttribute = currentEdge.src.root
srcIndex = listAttribute.index(currentEdge.src)
allSrc = [e.src for e in self._graph.edges.values()]
for i in range(len(listAttribute)):
if i == srcIndex:
continue
if listAttribute.at(i) in allSrc:
return False
return True
@Slot(Edge)
def expandForLoop(self, currentEdge):
""" Expand 'node' by creating all its output nodes. """
with self.groupedGraphModification("Expand For Loop Node"):
listAttribute = currentEdge.src.root
srcIndex = listAttribute.index(currentEdge.src)
dst = currentEdge.dst
for i in range(len(listAttribute)):
if i == srcIndex:
continue
self.duplicateNodesFrom(dst.node)
newNode = self.graph.nodes.at(-1)
previousEdge = self.graph.edge(newNode.attribute(dst.name))
self.replaceEdge(previousEdge, listAttribute.at(i), previousEdge.dst)
@Slot(Edge)
def collapseForLoop(self, currentEdge):
""" Collapse 'node' by removing all its output nodes. """
with self.groupedGraphModification("Collapse For Loop Node"):
listAttribute = currentEdge.src.root
srcIndex = listAttribute.index(currentEdge.src)
allSrc = [e.src for e in self._graph.edges.values()]
for i in range(len(listAttribute)):
if i == srcIndex:
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)
# remove the edge from allSrc
allSrc.pop(occurence)
@Slot(QObject)
def clearData(self, nodes):
@ -765,7 +813,9 @@ class UIGraph(QObject):
@Slot(Attribute, Attribute)
def addEdge(self, src, dst):
if isinstance(dst, ListAttribute) and not isinstance(src, ListAttribute):
if isinstance(src, ListAttribute) and not isinstance(dst, ListAttribute):
self._addEdge(src.at(0), dst)
elif isinstance(dst, ListAttribute) and not isinstance(src, ListAttribute):
with self.groupedGraphModification("Insert and Add Edge on {}".format(dst.getFullNameToNode())):
self.appendAttribute(dst)
self._addEdge(src, dst.at(-1))
@ -787,6 +837,17 @@ class UIGraph(QObject):
else:
self.push(commands.RemoveEdgeCommand(self._graph, edge))
@Slot(Edge, Attribute, Attribute, result=Edge)
def replaceEdge(self, edge, newSrc, newDst):
with self.groupedGraphModification("Replace Edge '{}'->'{}' with '{}'->'{}'".format(edge.src.getFullNameToNode(), edge.dst.getFullNameToNode(), newSrc.getFullNameToNode(), newDst.getFullNameToNode())):
self.removeEdge(edge)
self.addEdge(newSrc, newDst)
return self._graph.edge(newDst)
@Slot(Attribute, result=Edge)
def getEdge(self, dst):
return self._graph.edge(dst)
@Slot(Attribute, "QVariant")
def setAttribute(self, attribute, value):
self.push(commands.SetAttributeCommand(self._graph, attribute, value))
@ -794,6 +855,12 @@ class UIGraph(QObject):
@Slot(Attribute)
def resetAttribute(self, attribute):
""" Reset 'attribute' to its default value """
# if the attribute is a ListAttribute, remove all edges
if isinstance(attribute, ListAttribute):
for edge in self._graph.edges:
# if the edge is connected to one of the ListAttribute's elements, remove it
if edge.src in attribute.value:
self.removeEdge(edge)
self.push(commands.SetAttributeCommand(self._graph, attribute, attribute.defaultValue()))
@Slot(CompatibilityNode, result=Node)

View file

@ -0,0 +1,101 @@
import QtQuick 2.15
import MaterialIcons 2.2
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.11
/*
* IntSelector with arrows and a text input to select a number
*/
Item {
id: root
property int value: 0
property var range: { "min" : 0, "max" : 0 }
Layout.preferredWidth: previousIntButton.width + intMetrics.width + nextIntButton.width
Layout.preferredHeight: intInput.height
MouseArea {
id: mouseAreaIntLabel
anchors.fill: parent
hoverEnabled: true
onEntered: {
previousIntButton.opacity = 1
nextIntButton.opacity = 1
}
onExited: {
previousIntButton.opacity = 0
nextIntButton.opacity = 0
}
MaterialToolButton {
id: previousIntButton
anchors.verticalCenter: mouseAreaIntLabel.verticalCenter
opacity: 0
width: 10
text: MaterialIcons.navigate_before
ToolTip.text: "Previous Integer"
onClicked: {
if (value > range.min) {
value -= 1
}
}
}
TextInput {
id: intInput
anchors.horizontalCenter: mouseAreaIntLabel.horizontalCenter
anchors.verticalCenter: mouseAreaIntLabel.verticalCenter
Layout.preferredWidth: intMetrics.width
color: palette.text
horizontalAlignment: Text.AlignHCenter
selectByMouse: true
text: value
onEditingFinished: {
// We first assign the frame to the entered text even if it is an invalid frame number. We do it for extreme cases, for example without doing it, if we are at 0, and put a negative number, value would be still 0 and nothing happens but we will still see the wrong number
value = parseInt(text)
value = Math.min(range.max, Math.max(range.min, parseInt(text)))
focus = false
}
}
MaterialToolButton {
id: nextIntButton
anchors.right: mouseAreaIntLabel.right
anchors.verticalCenter: mouseAreaIntLabel.verticalCenter
width: 10
opacity: 0
text: MaterialIcons.navigate_next
ToolTip.text: "Next Integer"
onClicked: {
if (value < range.max) {
value += 1
}
}
}
TextMetrics {
id: intMetrics
font: intInput.font
text: "10000"
}
}
}

View file

@ -12,3 +12,4 @@ TabPanel 1.0 TabPanel.qml
TextFileViewer 1.0 TextFileViewer.qml
ExifOrientedViewer 1.0 ExifOrientedViewer.qml
FilterComboBox 1.0 FilterComboBox.qml
IntSelector 1.0 IntSelector.qml

View file

@ -100,7 +100,6 @@ RowLayout {
|| drag.source.objectName != inputDragTarget.objectName // not an edge connector
|| drag.source.baseType !== inputDragTarget.baseType // not the same base type
|| drag.source.nodeItem === inputDragTarget.nodeItem // connection between attributes of the same node
|| (drag.source.isList && !inputDragTarget.isList) // connection between a list and a simple attribute
|| (drag.source.isList && childrenRepeater.count) // source/target are lists but target already has children
|| drag.source.connectorType === "input" // refuse to connect an "input pin" on another one (input attr can be connected to input attr, but not the graphical pin)
) {

View file

@ -1,11 +1,13 @@
import QtQuick 2.15
import GraphEditor 1.0
import QtQuick.Shapes 1.15
import MaterialIcons 2.2
import QtQuick.Controls 2.15
/**
A cubic spline representing an edge, going from point1 to point2, providing mouse interaction.
*/
Shape {
Item {
id: root
property var edge
@ -15,6 +17,8 @@ Shape {
property real point2y
property alias thickness: path.strokeWidth
property alias color: path.strokeColor
property bool isForLoop: false
property int iteration: 0
// BUG: edgeArea is destroyed before path, need to test if not null to avoid warnings
readonly property bool containsMouse: edgeArea && edgeArea.containsMouse
@ -32,31 +36,71 @@ Shape {
property real endX: width
property real endY: height
// cause rendering artifacts when enabled (and don't support hot reload really well)
vendorExtensionsEnabled: false
ShapePath {
id: path
startX: root.startX
startY: root.startY
fillColor: "transparent"
strokeColor: "#3E3E3E"
strokeStyle: edge !== undefined && ((edge.src !== undefined && edge.src.isOutput) || edge.dst === undefined) ? ShapePath.SolidLine : ShapePath.DashLine
strokeWidth: 1
// final visual width of this path (never below 1)
readonly property real visualWidth: Math.max(strokeWidth, 1)
dashPattern: [6 / visualWidth, 4 / visualWidth]
capStyle: ShapePath.RoundCap
Shape {
anchors.fill: parent
// cause rendering artifacts when enabled (and don't support hot reload really well)
vendorExtensionsEnabled: false
opacity: 0.7
PathCubic {
id: cubic
property real ctrlPtDist: 30
x: root.endX
y: root.endY
relativeControl1X: ctrlPtDist
relativeControl1Y: 0
control2X: x - ctrlPtDist
control2Y: y
ShapePath {
id: path
startX: root.startX
startY: root.startY
fillColor: "transparent"
strokeColor: "#3E3E3E"
strokeStyle: edge !== undefined && ((edge.src !== undefined && edge.src.isOutput) || edge.dst === undefined) ? ShapePath.SolidLine : ShapePath.DashLine
strokeWidth: 1
// final visual width of this path (never below 1)
readonly property real visualWidth: Math.max(strokeWidth, 1)
dashPattern: [6 / visualWidth, 4 / visualWidth]
capStyle: ShapePath.RoundCap
PathCubic {
id: cubic
property real ctrlPtDist: 30
x: root.endX
y: root.endY
relativeControl1X: ctrlPtDist
relativeControl1Y: 0
control2X: x - ctrlPtDist
control2Y: y
}
}
}
Item {
// place the label at the middle of the edge
x: (root.startX + root.endX) / 2
y: (root.startY + root.endY) / 2
visible: root.isForLoop
Rectangle {
anchors.centerIn: parent
property int margin: 1
width: childrenRect.width + 2 * margin
height: childrenRect.height + 2 * margin
radius: width
color: path.strokeColor
MaterialLabel {
id: icon
x: parent.margin
y: parent.margin
text: MaterialIcons.loop
color: palette.base
font.pointSize: 24
ToolTip.text: "This is a for loop"
}
Label {
x: icon.width / 2.4
y: icon.height / 3
font.pixelSize: 10
text: root.iteration
color: palette.base
font.bold: true
}
}
}

View file

@ -379,13 +379,104 @@ Item {
width: 1000
height: 1000
Menu {
Popup {
id: edgeMenu
property var currentEdge: null
MenuItem {
enabled: edgeMenu.currentEdge && !edgeMenu.currentEdge.dst.node.locked && !edgeMenu.currentEdge.dst.isReadOnly
text: "Remove"
onTriggered: uigraph.removeEdge(edgeMenu.currentEdge)
property bool forLoop: false
onOpened: {
expandButton.canExpand = uigraph.canExpandForLoop(edgeMenu.currentEdge)
}
contentItem: GridLayout {
layoutDirection: Qt.LeftToRight
columns: 2
columnSpacing: 20
Column {
id: listAttrColumn
visible: edgeMenu.currentEdge && edgeMenu.forLoop && expandButton.canExpand
property var listAttr: edgeMenu.currentEdge ? edgeMenu.currentEdge.src.root : null
Layout.alignment: Qt.AlignTop
Text {
id: listAttrMenuText
text: "<b>Iteration:</b>"
color: activePalette.text
bottomPadding: 15
}
IntSelector {
width: listAttrColumn.width
anchors.top: listAttrMenuText.bottom
anchors.horizontalCenter: listAttrColumn.horizontalCenter
visible: edgeMenu.currentEdge && edgeMenu.forLoop
value: listAttrColumn.listAttr ? listAttrColumn.listAttr.value.indexOf(edgeMenu.currentEdge.src) : 0
range: { "min": 0, "max": listAttrColumn.listAttr ? listAttrColumn.listAttr.value.count - 1 : 0 }
onValueChanged: {
if (listAttrColumn.listAttr === null) {
return
}
const newSrcAttr = listAttrColumn.listAttr.value.at(value)
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)
}
}
}
Column {
Layout.alignment: Qt.AlignTop
Text {
text: "<b>Actions:</b>"
color: activePalette.text
}
RowLayout {
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)
}
MaterialToolButton {
id: expandButton
property bool canExpand: edgeMenu.currentEdge && edgeMenu.forLoop
visible: edgeMenu.currentEdge && edgeMenu.forLoop && canExpand
font.pointSize: 13
ToolTip.text: "Expand"
text: MaterialIcons.open_in_full
onClicked: {
uigraph.expandForLoop(edgeMenu.currentEdge)
canExpand = false
}
}
MaterialToolButton {
id: collapseButton
visible: edgeMenu.currentEdge && edgeMenu.forLoop && !expandButton.canExpand
font.pointSize: 13
ToolTip.text: "Collapse"
text: MaterialIcons.close_fullscreen
onClicked: {
uigraph.collapseForLoop(edgeMenu.currentEdge)
expandButton.canExpand = true
}
}
}
}
}
}
@ -402,12 +493,16 @@ Item {
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
iteration: forLoop ? edge.src.root.value.indexOf(edge.src) : 0
color: edge.dst === root.edgeAboutToBeRemoved ? "red" : inFocus ? activePalette.highlight : activePalette.text
thickness: inFocus ? 2 : 1
opacity: 0.7
thickness: (forLoop && inFocus) ? 3 : (forLoop || 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
@ -415,12 +510,16 @@ Item {
onPressed: {
const canEdit = !edge.dst.node.locked
if (event.button === Qt.RightButton) {
if (event.button) {
if (canEdit && (event.modifiers & Qt.AltModifier)) {
uigraph.removeEdge(edge)
} else {
edgeMenu.currentEdge = edge
edgeMenu.popup()
edgeMenu.forLoop = forLoop
var spawnPosition = mouseArea.mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY)
edgeMenu.x = spawnPosition.x
edgeMenu.y = spawnPosition.y
edgeMenu.open()
}
}
}

View file

@ -423,8 +423,10 @@ Item {
onPressed: root.pressed(mouse)
onEdgeAboutToBeRemoved: root.edgeAboutToBeRemoved(input)
Component.onCompleted: attributePinCreated(object, outPin)
Component.onCompleted: attributePinCreated(attribute, outPin)
onChildPinCreated: attributePinCreated(childAttribute, outPin)
Component.onDestruction: attributePinDeleted(attribute, outPin)
onChildPinDeleted: attributePinDeleted(childAttribute, outPin)
}
}
}

View file

@ -144,81 +144,14 @@ FloatingPane {
anchors.fill: parent
Item {
Layout.preferredWidth: previousFrameButton.width + frameMetrics.width + nextFrameButton.width
Layout.preferredHeight: frameInput.height
IntSelector {
id: frameInput
MouseArea {
id: mouseAreaFrameLabel
value: m.frame
range: frameRange
anchors.fill: parent
hoverEnabled: true
onEntered: {
previousFrameButton.opacity = 1
nextFrameButton.opacity = 1
}
onExited: {
previousFrameButton.opacity = 0
nextFrameButton.opacity = 0
}
MaterialToolButton {
id: previousFrameButton
anchors.verticalCenter: mouseAreaFrameLabel.verticalCenter
opacity: 0
width: 10
text: MaterialIcons.navigate_before
ToolTip.text: "Previous Frame"
onClicked: {
if (m.frame > frameRange.min) {
m.frame -= 1
}
}
}
TextInput {
id: frameInput
anchors.horizontalCenter: mouseAreaFrameLabel.horizontalCenter
Layout.preferredWidth: frameMetrics.width
color: palette.text
horizontalAlignment: Text.AlignHCenter
selectByMouse: true
text: m.frame
onEditingFinished: {
// We first assign the frame to the entered text even if it is an invalid frame number. We do it for extreme cases, for example without doing it, if we are at 0, and put a negative number, m.frame would be still 0 and nothing happens but we will still see the wrong number
m.frame = parseInt(text)
m.frame = Math.min(frameRange.max, Math.max(frameRange.min, parseInt(text)))
focus = false
}
}
MaterialToolButton {
id: nextFrameButton
anchors.right: mouseAreaFrameLabel.right
anchors.verticalCenter: mouseAreaFrameLabel.verticalCenter
width: 10
opacity: 0
text: MaterialIcons.navigate_next
ToolTip.text: "Next Frame"
onClicked: {
if (m.frame < frameRange.max) {
m.frame += 1
}
}
}
onValueChanged: {
m.frame = value
}
}
@ -504,13 +437,6 @@ FloatingPane {
}
}
TextMetrics {
id: frameMetrics
font: frameInput.font
text: "10000"
}
TextMetrics {
id: fpsMetrics