diff --git a/meshroom/core/attribute.py b/meshroom/core/attribute.py index 043804fba4..779974e19a 100644 --- a/meshroom/core/attribute.py +++ b/meshroom/core/attribute.py @@ -143,6 +143,10 @@ def setEnabled(self, v): self._enabled = v self.enabledChanged.emit() + def getUidIgnoreValue(self): + """ Value for which the attribute should be ignored during the UID computation. """ + return self.attributeDesc.uidIgnoreValue + def _get_value(self): if self.isLink: return self.getLinkParam().value @@ -168,6 +172,10 @@ def _set_value(self, value): # TODO: only update the graph if this attribute participates to a UID if self.isInput: self.requestGraphUpdate() + # TODO: only call update of the node if the attribute is internal + # Internal attributes are set as inputs + self.requestNodeUpdate() + self.valueChanged.emit() def upgradeValue(self, exportedValue): @@ -181,6 +189,12 @@ def requestGraphUpdate(self): self.node.graph.markNodesDirty(self.node) self.node.graph.update() + def requestNodeUpdate(self): + # Update specific node information that do not affect the rest of the graph + # (like internal attributes) + if self.node: + self.node.updateInternalAttributes() + @property def isOutput(self): return self._isOutput @@ -323,6 +337,7 @@ def updateInternals(self): node = Property(BaseObject, node.fget, constant=True) enabledChanged = Signal() enabled = Property(bool, getEnabled, setEnabled, notify=enabledChanged) + uidIgnoreValue = Property(Variant, getUidIgnoreValue, constant=True) def raiseIfLink(func): diff --git a/meshroom/core/desc.py b/meshroom/core/desc.py index 1425b18217..f2df5988a5 100644 --- a/meshroom/core/desc.py +++ b/meshroom/core/desc.py @@ -14,7 +14,7 @@ class Attribute(BaseObject): """ """ - def __init__(self, name, label, description, value, advanced, semantic, uid, group, enabled): + def __init__(self, name, label, description, value, advanced, semantic, uid, group, enabled, uidIgnoreValue=None): super(Attribute, self).__init__() self._name = name self._label = label @@ -25,6 +25,7 @@ def __init__(self, name, label, description, value, advanced, semantic, uid, gro self._advanced = advanced self._enabled = enabled self._semantic = semantic + self._uidIgnoreValue = uidIgnoreValue name = Property(str, lambda self: self._name, constant=True) label = Property(str, lambda self: self._label, constant=True) @@ -35,6 +36,7 @@ def __init__(self, name, label, description, value, advanced, semantic, uid, gro advanced = Property(bool, lambda self: self._advanced, constant=True) enabled = Property(Variant, lambda self: self._enabled, constant=True) semantic = Property(str, lambda self: self._semantic, constant=True) + uidIgnoreValue = Property(Variant, lambda self: self._uidIgnoreValue, constant=True) type = Property(str, lambda self: self.__class__.__name__, constant=True) def validateValue(self, value): @@ -201,8 +203,9 @@ def retrieveChildrenUids(self): class Param(Attribute): """ """ - def __init__(self, name, label, description, value, uid, group, advanced, semantic, enabled): - super(Param, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, semantic=semantic, enabled=enabled) + def __init__(self, name, label, description, value, uid, group, advanced, semantic, enabled, uidIgnoreValue=None): + super(Param, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, semantic=semantic, enabled=enabled, + uidIgnoreValue=uidIgnoreValue) class File(Attribute): @@ -329,8 +332,9 @@ def checkValueTypes(self): class StringParam(Param): """ """ - def __init__(self, name, label, description, value, uid, group='allParams', advanced=False, semantic='', enabled=True): - super(StringParam, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, semantic=semantic, enabled=enabled) + def __init__(self, name, label, description, value, uid, group='allParams', advanced=False, semantic='', enabled=True, uidIgnoreValue=None): + super(StringParam, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, semantic=semantic, enabled=enabled, + uidIgnoreValue=uidIgnoreValue) def validateValue(self, value): if not isinstance(value, str): @@ -343,6 +347,19 @@ def checkValueTypes(self): return "" +class ColorParam(Param): + """ + """ + def __init__(self, name, label, description, value, uid, group='allParams', advanced=False, semantic='', enabled=True): + super(ColorParam, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, semantic=semantic, enabled=enabled) + + def validateValue(self, value): + if not isinstance(value, str) or len(value.split(" ")) > 1: + raise ValueError('ColorParam value should be a string containing either an SVG name or an hexadecimal ' + 'color code (param: {}, value: {}, type: {})'.format(self.name, value, type(value))) + return value + + class Level(Enum): NONE = 0 NORMAL = 1 @@ -485,6 +502,43 @@ class Node(object): ram = Level.NORMAL packageName = '' packageVersion = '' + internalInputs = [ + StringParam( + name="invalidation", + label="Invalidation Message", + description="A message that will invalidate the node's output folder.\n" + "This is useful for development, we can invalidate the output of the node when we modify the code.\n" + "It is displayed in bold font in the invalidation/comment messages tooltip.", + value="", + semantic="multiline", + uid=[0], + advanced=True, + uidIgnoreValue="", # If the invalidation string is empty, it does not participate to the node's UID + ), + StringParam( + name="comment", + label="Comments", + description="User comments describing this specific node instance.\n" + "It is displayed in regular font in the invalidation/comment messages tooltip.", + value="", + semantic="multiline", + uid=[], + ), + StringParam( + name="label", + label="Node's Label", + description="Customize the default label (to replace the technical name of the node instance).", + value="", + uid=[], + ), + ColorParam( + name="color", + label="Color", + description="Custom color for the node (SVG name or hexadecimal code).", + value="", + uid=[], + ) + ] inputs = [] outputs = [] size = StaticNodeSize(1) diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 8acd9295ce..4ad153c1fd 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -687,9 +687,24 @@ def attribute(self, fullName): # type: (str) -> Attribute """ Return the attribute identified by the unique name 'fullName'. + If it does not exist, return None. """ node, attribute = fullName.split('.', 1) - return self.node(node).attribute(attribute) + if self.node(node).hasAttribute(attribute): + return self.node(node).attribute(attribute) + return None + + @Slot(str, result=Attribute) + def internalAttribute(self, fullName): + # type: (str) -> Attribute + """ + Return the internal attribute identified by the unique name 'fullName'. + If it does not exist, return None. + """ + node, attribute = fullName.split('.', 1) + if self.node(node).hasInternalAttribute(attribute): + return self.node(node).internalAttribute(attribute) + return None @staticmethod def getNodeIndexFromName(name): @@ -1221,14 +1236,14 @@ def save(self, filepath=None, setupProjectFile=True, template=False): def getNonDefaultInputAttributes(self): """ - Instead of getting all the inputs attribute keys, only get the keys of + Instead of getting all the inputs and internal attribute keys, only get the keys of the attributes whose value is not the default one. The output attributes, UIDs, parallelization parameters and internal folder are not relevant for templates, so they are explicitly removed from the returned dictionary. Returns: dict: self.toDict() with the output attributes, UIDs, parallelization parameters, internal folder - and input attributes with default values removed + and input/internal attributes with default values removed """ graph = self.toDict() for nodeName in graph.keys(): @@ -1236,12 +1251,27 @@ def getNonDefaultInputAttributes(self): inputKeys = list(graph[nodeName]["inputs"].keys()) + internalInputKeys = [] + internalInputs = graph[nodeName].get("internalInputs", None) + if internalInputs: + internalInputKeys = list(internalInputs.keys()) + for attrName in inputKeys: attribute = node.attribute(attrName) # check that attribute is not a link for choice attributes if attribute.isDefault and not attribute.isLink: del graph[nodeName]["inputs"][attrName] + for attrName in internalInputKeys: + attribute = node.internalAttribute(attrName) + # check that internal attribute is not a link for choice attributes + if attribute.isDefault and not attribute.isLink: + del graph[nodeName]["internalInputs"][attrName] + + # If all the internal attributes are set to their default values, remove the entry + if len(graph[nodeName]["internalInputs"]) == 0: + del graph[nodeName]["internalInputs"] + del graph[nodeName]["outputs"] del graph[nodeName]["uids"] del graph[nodeName]["internalFolder"] diff --git a/meshroom/core/node.py b/meshroom/core/node.py index 08a0f1adaf..7e5999f606 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -499,6 +499,7 @@ def __init__(self, nodeType, position=None, parent=None, **kwargs): self._size = 0 self._position = position or Position() self._attributes = DictModel(keyAttrName='name', parent=self) + self._internalAttributes = DictModel(keyAttrName='name', parent=self) self.attributesPerUid = defaultdict(set) self._alive = True # for QML side to know if the node can be used or is going to be deleted self._locked = False @@ -523,10 +524,41 @@ def getName(self): def getLabel(self): """ Returns: - str: the high-level label of this node + str: the user-provided label if it exists, the high-level label of this node otherwise """ + if self.hasInternalAttribute("label"): + label = self.internalAttribute("label").value.strip() + if label: + return label return self.nameToLabel(self._name) + def getColor(self): + """ + Returns: + str: the user-provided custom color of the node if it exists, empty string otherwise + """ + if self.hasInternalAttribute("color"): + return self.internalAttribute("color").value.strip() + return "" + + def getInvalidationMessage(self): + """ + Returns: + str: the invalidation message on the node if it exists, empty string otherwise + """ + if self.hasInternalAttribute("invalidation"): + return self.internalAttribute("invalidation").value + return "" + + def getComment(self): + """ + Returns: + str: the comments on the node if they exist, empty string otherwise + """ + if self.hasInternalAttribute("comment"): + return self.internalAttribute("comment").value + return "" + @Slot(str, result=str) def nameToLabel(self, name): """ @@ -568,13 +600,37 @@ def attribute(self, name): att = self._attributes.get(name) return att + @Slot(str, result=Attribute) + def internalAttribute(self, name): + # No group or list attributes for internal attributes + # The internal attribute itself can be returned directly + return self._internalAttributes.get(name) + + def setInternalAttributeValues(self, values): + # initialize internal attribute values + for k, v in values.items(): + attr = self.internalAttribute(k) + attr.value = v + def getAttributes(self): return self._attributes + def getInternalAttributes(self): + return self._internalAttributes + @Slot(str, result=bool) def hasAttribute(self, name): + # Complex name indicating group or list attribute: parse it and get the + # first output element to check for the attribute's existence + if "[" in name or "." in name: + p = self.attributeRE.findall(name) + return p[0][0] in self._attributes.keys() or p[0][1] in self._attributes.keys() return name in self._attributes.keys() + @Slot(str, result=bool) + def hasInternalAttribute(self, name): + return name in self._internalAttributes.keys() + def _applyExpr(self): for attr in self._attributes: attr._applyExpr() @@ -632,7 +688,7 @@ def _computeUids(self): """ Compute node uids by combining associated attributes' uids. """ for uidIndex, associatedAttributes in self.attributesPerUid.items(): # uid is computed by hashing the sorted list of tuple (name, value) of all attributes impacting this uid - uidAttributes = [(a.getName(), a.uid(uidIndex)) for a in associatedAttributes if a.enabled] + uidAttributes = [(a.getName(), a.uid(uidIndex)) for a in associatedAttributes if a.enabled and a.value != a.uidIgnoreValue] uidAttributes.sort() self._uids[uidIndex] = hashValue(uidAttributes) @@ -842,6 +898,9 @@ def updateInternals(self, cacheDir=None): if self.internalFolder != folder: self.internalFolderChanged.emit() + def updateInternalAttributes(self): + self.internalAttributesChanged.emit() + @property def internalFolder(self): return self._internalFolder.format(**self._cmdVars) @@ -1066,7 +1125,6 @@ def canBeCanceled(self): name = Property(str, getName, constant=True) - label = Property(str, getLabel, constant=True) nodeType = Property(str, nodeType.fget, constant=True) documentation = Property(str, getDocumentation, constant=True) positionChanged = Signal() @@ -1074,6 +1132,12 @@ def canBeCanceled(self): x = Property(float, lambda self: self._position.x, notify=positionChanged) y = Property(float, lambda self: self._position.y, notify=positionChanged) attributes = Property(BaseObject, getAttributes, constant=True) + internalAttributes = Property(BaseObject, getInternalAttributes, constant=True) + internalAttributesChanged = Signal() + label = Property(str, getLabel, notify=internalAttributesChanged) + color = Property(str, getColor, notify=internalAttributesChanged) + invalidation = Property(str, getInvalidationMessage, notify=internalAttributesChanged) + comment = Property(str, getComment, notify=internalAttributesChanged) internalFolderChanged = Signal() internalFolder = Property(str, internalFolder.fget, notify=internalFolderChanged) depthChanged = Signal() @@ -1123,11 +1187,19 @@ def __init__(self, nodeType, position=None, parent=None, **kwargs): for attrDesc in self.nodeDesc.outputs: self._attributes.add(attributeFactory(attrDesc, None, True, self)) + for attrDesc in self.nodeDesc.internalInputs: + self._internalAttributes.add(attributeFactory(attrDesc, None, False, self)) + # List attributes per uid for attr in self._attributes: for uidIndex in attr.attributeDesc.uid: self.attributesPerUid[uidIndex].add(attr) + # Add internal attributes with a UID to the list + for attr in self._internalAttributes: + for uidIndex in attr.attributeDesc.uid: + self.attributesPerUid[uidIndex].add(attr) + self.setAttributeValues(kwargs) def setAttributeValues(self, values): @@ -1150,8 +1222,22 @@ def upgradeAttributeValues(self, values): except ValueError: pass + def upgradeInternalAttributeValues(self, values): + # initialize internal attibute values + for k, v in values.items(): + if not self.hasInternalAttribute(k): + # skip missing atributes + continue + attr = self.internalAttribute(k) + if attr.isInput: + try: + attr.upgradeValue(v) + except ValueError: + pass + def toDict(self): inputs = {k: v.getExportValue() for k, v in self._attributes.objects.items() if v.isInput} + internalInputs = {k: v.getExportValue() for k, v in self._internalAttributes.objects.items()} outputs = ({k: v.getExportValue() for k, v in self._attributes.objects.items() if v.isOutput}) return { @@ -1165,6 +1251,7 @@ def toDict(self): 'uids': self._uids, 'internalFolder': self._internalFolder, 'inputs': {k: v for k, v in inputs.items() if v is not None}, # filter empty values + 'internalInputs': {k: v for k, v in internalInputs.items() if v is not None}, 'outputs': outputs, } @@ -1220,6 +1307,7 @@ def __init__(self, nodeType, nodeDict, position=None, issue=CompatibilityIssue.U self.version = Version(self.nodeDict.get("version", None)) self._inputs = self.nodeDict.get("inputs", {}) + self._internalInputs = self.nodeDict.get("internalInputs", {}) self.outputs = self.nodeDict.get("outputs", {}) self._internalFolder = self.nodeDict.get("internalFolder", "") self._uids = self.nodeDict.get("uids", {}) @@ -1231,11 +1319,15 @@ def __init__(self, nodeType, nodeDict, position=None, issue=CompatibilityIssue.U # create input attributes for attrName, value in self._inputs.items(): - self._addAttribute(attrName, value, False) + self._addAttribute(attrName, value, isOutput=False) # create outputs attributes for attrName, value in self.outputs.items(): - self._addAttribute(attrName, value, True) + self._addAttribute(attrName, value, isOutput=True) + + # create internal attributes + for attrName, value in self._internalInputs.items(): + self._addAttribute(attrName, value, isOutput=False, internalAttr=True) # create NodeChunks matching serialized parallelization settings self._chunks.setObjectList([ @@ -1328,7 +1420,7 @@ def attributeDescFromName(refAttributes, name, value, strict=True): return None - def _addAttribute(self, name, val, isOutput): + def _addAttribute(self, name, val, isOutput, internalAttr=False): """ Add a new attribute on this node. @@ -1336,19 +1428,26 @@ def _addAttribute(self, name, val, isOutput): name (str): the name of the attribute val: the attribute's value isOutput: whether the attribute is an output + internalAttr: whether the attribute is internal Returns: bool: whether the attribute exists in the node description """ attrDesc = None if self.nodeDesc: - refAttrs = self.nodeDesc.outputs if isOutput else self.nodeDesc.inputs + if internalAttr: + refAttrs = self.nodeDesc.internalInputs + else: + refAttrs = self.nodeDesc.outputs if isOutput else self.nodeDesc.inputs attrDesc = CompatibilityNode.attributeDescFromName(refAttrs, name, val) matchDesc = attrDesc is not None - if not matchDesc: + if attrDesc is None: attrDesc = CompatibilityNode.attributeDescFromValue(name, val, isOutput) attribute = attributeFactory(attrDesc, val, isOutput, self) - self._attributes.add(attribute) + if internalAttr: + self._internalAttributes.add(attribute) + else: + self._attributes.add(attribute) return matchDesc @property @@ -1373,6 +1472,13 @@ def inputs(self): return self._inputs return {k: v.getExportValue() for k, v in self._attributes.objects.items() if v.isInput} + @property + def internalInputs(self): + """ Get current node's internal attributes """ + if not self.graph: + return self._internalInputs + return {k: v.getExportValue() for k, v in self._internalAttributes.objects.items()} + def toDict(self): """ Return the original serialized node that generated a compatibility issue. @@ -1406,9 +1512,16 @@ def upgrade(self): # store attributes that could be used during node upgrade commonInputs.append(attrName) + commonInternalAttributes = [] + for attrName, value in self._internalInputs.items(): + if self.attributeDescFromName(self.nodeDesc.internalInputs, attrName, value, strict=False): + # store internal attributes that could be used during node upgrade + commonInternalAttributes.append(attrName) + node = Node(self.nodeType, position=self.position) # convert attributes from a list of tuples into a dict attrValues = {key: value for (key, value) in self.inputs.items()} + intAttrValues = {key: value for (key, value) in self.internalInputs.items()} # Use upgrade method of the node description itself if available try: @@ -1421,9 +1534,15 @@ def upgrade(self): logging.error("Error in the upgrade implementation of the node: {}. The return type is incorrect.".format(self.name)) upgradedAttrValues = attrValues - upgradedAttrValuesTmp = {key: value for (key, value) in upgradedAttrValues.items() if key in commonInputs} - node.upgradeAttributeValues(upgradedAttrValues) + + try: + upgradedIntAttrValues = node.nodeDesc.upgradeAttributeValues(intAttrValues, self.version) + except Exception as e: + logging.error("Error in the upgrade implementation of the node: {}.\n{}".format(self.name, str(e))) + upgradedIntAttrValues = intAttrValues + + node.upgradeInternalAttributeValues(upgradedIntAttrValues) return node compatibilityIssue = Property(int, lambda self: self.issue.value, constant=True) @@ -1453,6 +1572,7 @@ def nodeFactory(nodeDict, name=None, template=False): # get node inputs/outputs inputs = nodeDict.get("inputs", {}) + internalInputs = nodeDict.get("internalInputs", {}) outputs = nodeDict.get("outputs", {}) version = nodeDict.get("version", None) internalFolder = nodeDict.get("internalFolder", None) @@ -1475,16 +1595,38 @@ def nodeFactory(nodeDict, name=None, template=False): compatibilityIssue = CompatibilityIssue.VersionConflict # in other cases, check attributes compatibility between serialized node and its description else: - # check that the node has the exact same set of inputs/outputs as its description, except if the node - # is described in a template file, in which only non-default parameters are saved + # check that the node has the exact same set of inputs/outputs as its description, except + # if the node is described in a template file, in which only non-default parameters are saved; + # do not perform that check for internal attributes because there is no point in + # raising compatibility issues if their number differs: in that case, it is only useful + # if some internal attributes do not exist or are invalid if not template and (sorted([attr.name for attr in nodeDesc.inputs]) != sorted(inputs.keys()) or \ sorted([attr.name for attr in nodeDesc.outputs]) != sorted(outputs.keys())): compatibilityIssue = CompatibilityIssue.DescriptionConflict + + # check whether there are any internal attributes that are invalidating in the node description: if there + # are, then check that these internal attributes are part of nodeDict; if they are not, a compatibility + # issue must be raised to warn the user, as this will automatically change the node's UID + if not template: + invalidatingIntInputs = [] + for attr in nodeDesc.internalInputs: + if attr.uid == [0]: + invalidatingIntInputs.append(attr.name) + for attr in invalidatingIntInputs: + if attr not in internalInputs.keys(): + compatibilityIssue = CompatibilityIssue.DescriptionConflict + break + # verify that all inputs match their descriptions for attrName, value in inputs.items(): if not CompatibilityNode.attributeDescFromName(nodeDesc.inputs, attrName, value): compatibilityIssue = CompatibilityIssue.DescriptionConflict break + # verify that all internal inputs match their description + for attrName, value in internalInputs.items(): + if not CompatibilityNode.attributeDescFromName(nodeDesc.internalInputs, attrName, value): + compatibilityIssue = CompatibilityIssue.DescriptionConflict + break # verify that all outputs match their descriptions for attrName, value in outputs.items(): if not CompatibilityNode.attributeDescFromName(nodeDesc.outputs, attrName, value): @@ -1493,6 +1635,7 @@ def nodeFactory(nodeDict, name=None, template=False): if compatibilityIssue is None: node = Node(nodeType, position, **inputs) + node.setInternalAttributeValues(internalInputs) else: logging.warning("Compatibility issue detected for node '{}': {}".format(name, compatibilityIssue.name)) node = CompatibilityNode(nodeType, nodeDict, position, compatibilityIssue) diff --git a/meshroom/ui/commands.py b/meshroom/ui/commands.py index bbfb30631c..f9f2b0f2f9 100755 --- a/meshroom/ui/commands.py +++ b/meshroom/ui/commands.py @@ -264,11 +264,17 @@ def __init__(self, graph, attribute, value, parent=None): def redoImpl(self): if self.value == self.oldValue: return False - self.graph.attribute(self.attrName).value = self.value + if self.graph.attribute(self.attrName) is not None: + self.graph.attribute(self.attrName).value = self.value + else: + self.graph.internalAttribute(self.attrName).value = self.value return True def undoImpl(self): - self.graph.attribute(self.attrName).value = self.oldValue + if self.graph.attribute(self.attrName) is not None: + self.graph.attribute(self.attrName).value = self.oldValue + else: + self.graph.internalAttribute(self.attrName).value = self.oldValue class AddEdgeCommand(GraphCommand): diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 3dfffd4587..988739d59f 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -710,7 +710,8 @@ def upgradeAllNodes(self): """ Upgrade all upgradable CompatibilityNode instances in the graph. """ with self.groupedGraphModification("Upgrade all Nodes"): nodes = [n for n in self._graph._compatibilityNodes.values() if n.canUpgrade] - for node in nodes: + sortedNodes = sorted(nodes, key=lambda x: x.name) + for node in sortedNodes: self.upgradeNode(node) @Slot() diff --git a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml index 9ee2692b43..e7152b6505 100644 --- a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml +++ b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml @@ -1,6 +1,7 @@ import QtQuick 2.9 import QtQuick.Layouts 1.3 import QtQuick.Controls 2.2 +import QtQuick.Dialogs 1.0 import MaterialIcons 2.2 import Utils 1.0 @@ -152,6 +153,12 @@ RowLayout { case "BoolParam": return checkbox_component case "ListAttribute": return listAttribute_component case "GroupAttribute": return groupAttribute_component + case "StringParam": + if (attribute.desc.semantic === 'multiline') + return textArea_component + return textField_component + case "ColorParam": + return color_component default: return textField_component } } @@ -184,6 +191,121 @@ RowLayout { } } + Component { + id: textArea_component + + Rectangle { + // Fixed background for the flickable object + color: palette.base + width: parent.width + height: 70 + + Flickable { + width: parent.width + height: parent.height + contentWidth: width + contentHeight: height + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AlwaysOn + } + + TextArea.flickable: TextArea { + wrapMode: Text.WordWrap + padding: 0 + rightPadding: 5 + bottomPadding: 2 + topPadding: 2 + readOnly: !root.editable + onEditingFinished: setTextFieldAttribute(text) + text: attribute.value + selectByMouse: true + onPressed: { + root.forceActiveFocus() + } + Component.onDestruction: { + if (activeFocus) + setTextFieldAttribute(text) + } + DropArea { + enabled: root.editable + anchors.fill: parent + onDropped: { + if (drop.hasUrls) + setTextFieldAttribute(Filepath.urlToString(drop.urls[0])) + else if (drop.hasText && drop.text != '') + setTextFieldAttribute(drop.text) + } + } + } + } + } + } + + Component { + id: color_component + RowLayout { + CheckBox { + id: color_checkbox + Layout.alignment: Qt.AlignLeft + checked: node && node.color === "" ? false : true + text: "Custom Color" + onClicked: { + if(checked) { + _reconstruction.setAttribute(attribute, "#0000FF") + } else { + _reconstruction.setAttribute(attribute, "") + } + } + } + TextField { + id: colorText + Layout.alignment: Qt.AlignLeft + implicitWidth: 100 + enabled: color_checkbox.checked + visible: enabled + text: enabled ? attribute.value : "" + selectByMouse: true + onEditingFinished: setTextFieldAttribute(text) + onAccepted: setTextFieldAttribute(text) + Component.onDestruction: { + if(activeFocus) + setTextFieldAttribute(text) + } + } + + Rectangle { + height: colorText.height + width: colorText.width / 2 + Layout.alignment: Qt.AlignLeft + visible: color_checkbox.checked + color: color_checkbox.checked ? attribute.value : "" + + MouseArea { + anchors.fill: parent + onClicked: colorDialog.open() + } + } + + ColorDialog { + id: colorDialog + title: "Please choose a color" + color: attribute.value + onAccepted: { + colorText.text = color + // Artificially trigger change of attribute value + colorText.editingFinished() + close() + } + onRejected: close() + } + Item { + // Dummy item to fill out the space if needed + Layout.fillWidth: true + } + } + } + Component { id: comboBox_component ComboBox { diff --git a/meshroom/ui/qml/GraphEditor/Node.qml b/meshroom/ui/qml/GraphEditor/Node.qml index 8ec12f0792..e0f7fc33c1 100755 --- a/meshroom/ui/qml/GraphEditor/Node.qml +++ b/meshroom/ui/qml/GraphEditor/Node.qml @@ -73,6 +73,29 @@ Item { } } + function formatInternalAttributesTooltip(invalidation, comment) { + /* + * Creates a string that contains the invalidation message (if it is not empty) in bold, + * followed by the comment message (if it exists) in regular font, separated by an empty + * line. + * Invalidation and comment messages have their tabs or line returns in plain text format replaced + * by their HTML equivalents. + */ + let str = "" + if (invalidation !== "") { + let replacedInvalidation = node.invalidation.replace(/\n/g, "
").replace(/\t/g, "    ") + str += "" + replacedInvalidation + "" + } + if (invalidation !== "" && comment !== "") { + str += "

" + } + if (comment !== "") { + let replacedComment = node.comment.replace(/\n/g, "
").replace(/\t/g, "    ") + str += replacedComment + } + return str + } + // Whether an attribute can be displayed as an attribute pin on the node function isFileAttributeBaseType(attribute) { // ATM, only File attributes are meant to be connected @@ -138,7 +161,7 @@ Item { Rectangle { id: background anchors.fill: nodeContent - color: Qt.lighter(activePalette.base, 1.4) + color: node.color === "" ? Qt.lighter(activePalette.base, 1.4) : node.color layer.enabled: true layer.effect: DropShadow { radius: 3; color: shadowColor } radius: 3 @@ -181,6 +204,7 @@ Item { // Node Name Label { + id: nodeLabel Layout.fillWidth: true text: node ? node.label : "" padding: 4 @@ -252,6 +276,35 @@ Item { palette.text: "red" ToolTip.text: "Locked" } + + MaterialLabel { + id: nodeComment + visible: node.comment !== "" || node.invalidation !== "" + text: MaterialIcons.comment + padding: 2 + font.pointSize: 7 + + ToolTip { + id: nodeCommentTooltip + parent: header + visible: nodeCommentMA.containsMouse && nodeComment.visible + text: formatInternalAttributesTooltip(node.invalidation, node.comment) + implicitWidth: 400 // Forces word-wrap for long comments but the tooltip will be bigger than needed for short comments + delay: 300 + + // Relative position for the tooltip to ensure we won't get stuck in a case where it starts appearing over the mouse's + // position because it's a bit long and cutting off the hovering of the mouse area (which leads to the tooltip beginning + // to appear and immediately disappearing, over and over again) + x: implicitWidth / 2.5 + } + + MouseArea { + // If the node header is hovered, comments may be displayed + id: nodeCommentMA + anchors.fill: parent + hoverEnabled: true + } + } } } } diff --git a/meshroom/ui/qml/GraphEditor/NodeEditor.qml b/meshroom/ui/qml/GraphEditor/NodeEditor.qml index 6a748cb94f..782ade07f9 100644 --- a/meshroom/ui/qml/GraphEditor/NodeEditor.qml +++ b/meshroom/ui/qml/GraphEditor/NodeEditor.qml @@ -185,6 +185,7 @@ Panel { currentIndex: tabBar.currentIndex AttributeEditor { + id: inOutAttr Layout.fillHeight: true Layout.fillWidth: true model: root.node.attributes @@ -247,6 +248,16 @@ Panel { Layout.fillWidth: true node: root.node } + + AttributeEditor { + id: nodeInternalAttr + Layout.fillHeight: true + Layout.fillWidth: true + model: root.node.internalAttributes + readOnly: root.readOnly || root.isCompatibilityNode + onAttributeDoubleClicked: root.attributeDoubleClicked(mouse, attribute) + onUpgradeRequest: root.upgradeRequest() + } } } } @@ -285,6 +296,12 @@ Panel { leftPadding: 8 rightPadding: leftPadding } + TabButton { + text: "Notes" + padding: 4 + leftPadding: 8 + rightPadding: leftPadding + } } } } diff --git a/tests/test_templatesVersion.py b/tests/test_templatesVersion.py index 3d391ecf93..e27a41ce15 100644 --- a/tests/test_templatesVersion.py +++ b/tests/test_templatesVersion.py @@ -38,6 +38,7 @@ def test_templateVersions(): currentNodeVersion = meshroom.core.nodeVersion(nodeDesc) inputs = nodeData.get("inputs", {}) + internalInputs = nodeData.get("internalInputs", {}) version = nodesVersions.get(nodeType, None) compatibilityIssue = None @@ -49,5 +50,9 @@ def test_templateVersions(): if not CompatibilityNode.attributeDescFromName(nodeDesc.inputs, attrName, value): compatibilityIssue = CompatibilityIssue.DescriptionConflict break + for attrName, value in internalInputs.items(): + if not CompatibilityNode.attributeDescFromName(nodeDesc.internalInputs, attrName, value): + compatibilityIssue = CompatibilityIssue.DescriptionConflict + break assert compatibilityIssue is None