Merge pull request #89 from alicevision/dev_attributeEditor

Improve AttributeEditor UI and features
This commit is contained in:
Fabien Castan 2018-02-07 11:48:21 +01:00 committed by GitHub
commit 4a2d8cdab8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 367 additions and 214 deletions

View file

@ -258,8 +258,11 @@ class Attribute(BaseObject):
return '"{}"'.format(self.value)
return str(self.value)
def isDefault(self):
return self._value == self.desc.value
def defaultValue(self):
return self.desc.value
def _isDefault(self):
return self._value == self.defaultValue()
def getPrimitiveValue(self, exportDefault=True):
return self._value
@ -273,6 +276,7 @@ class Attribute(BaseObject):
isOutput = Property(bool, isOutput.fget, constant=True)
isLinkChanged = Signal()
isLink = Property(bool, isLink.fget, notify=isLinkChanged)
isDefault = Property(bool, _isDefault, notify=valueChanged)
class ListAttribute(Attribute):
@ -289,8 +293,7 @@ class ListAttribute(Attribute):
def _set_value(self, value):
self._value.clear()
if value:
self.extend(value)
self.extend(value)
self.requestGraphUpdate()
def append(self, value):
@ -300,6 +303,7 @@ class ListAttribute(Attribute):
values = value if isinstance(value, list) else [value]
attrs = [attribute_factory(self.attributeDesc.elementDesc, v, self.isOutput, self.node, self) for v in values]
self._value.insert(index, attrs)
self.valueChanged.emit()
self._applyExpr()
self.requestGraphUpdate()
@ -317,6 +321,7 @@ class ListAttribute(Attribute):
self.node.graph.removeEdge(attr) # delete edge if the attribute is linked
self._value.removeAt(index, count)
self.requestGraphUpdate()
self.valueChanged.emit()
def uid(self, uidIndex):
uids = []
@ -334,20 +339,24 @@ class ListAttribute(Attribute):
def getExportValue(self):
return [attr.getExportValue() for attr in self._value]
def isDefault(self):
return bool(self._value)
def defaultValue(self):
return []
def _isDefault(self):
return len(self._value) == 0
def getPrimitiveValue(self, exportDefault=True):
if exportDefault:
return [attr.getPrimitiveValue(exportDefault=exportDefault) for attr in self._value]
else:
return [attr.getPrimitiveValue(exportDefault=exportDefault) for attr in self._value if not attr.isDefault()]
return [attr.getPrimitiveValue(exportDefault=exportDefault) for attr in self._value if not attr.isDefault]
def getValueStr(self):
return self.attributeDesc.joinChar.join([v.getValueStr() for v in self._value])
# Override value property setter
value = Property(Variant, Attribute._get_value, _set_value, notify=Attribute.valueChanged)
isDefault = Property(bool, _isDefault, notify=Attribute.valueChanged)
class GroupAttribute(Attribute):
@ -360,6 +369,7 @@ class GroupAttribute(Attribute):
for subAttrDesc in self.attributeDesc.groupDesc:
childAttr = attribute_factory(subAttrDesc, None, self.isOutput, self.node, self)
subAttributes.append(childAttr)
childAttr.valueChanged.connect(self.valueChanged)
self._value.reset(subAttributes)
@ -391,20 +401,24 @@ class GroupAttribute(Attribute):
def getExportValue(self):
return {key: attr.getExportValue() for key, attr in self._value.objects.items()}
def isDefault(self):
return len(self._value) == 0
def _isDefault(self):
return all(v.isDefault for v in self._value)
def defaultValue(self):
return {key: attr.defaultValue() for key, attr in self._value.items()}
def getPrimitiveValue(self, exportDefault=True):
if exportDefault:
return {name: attr.getPrimitiveValue(exportDefault=exportDefault) for name, attr in self._value.items()}
else:
return {name: attr.getPrimitiveValue(exportDefault=exportDefault) for name, attr in self._value.items() if not attr.isDefault()}
return {name: attr.getPrimitiveValue(exportDefault=exportDefault) for name, attr in self._value.items() if not attr.isDefault}
def getValueStr(self):
return self.attributeDesc.joinChar.join([v.getValueStr() for v in self._value.objects.values()])
# Override value property
value = Property(Variant, Attribute._get_value, _set_value, notify=Attribute.valueChanged)
isDefault = Property(bool, _isDefault, notify=Attribute.valueChanged)
class Edge(BaseObject):

View file

@ -54,7 +54,13 @@ class FeatureMatching(desc.CommandLineNode):
desc.ChoiceParam(
name='photometricMatchingMethod',
label='Photometric Matching Method',
description='''For Scalar based regions descriptor: * BRUTE_FORCE_L2: L2 BruteForce matching * ANN_L2: L2 Approximate Nearest Neighbor matching * CASCADE_HASHING_L2: L2 Cascade Hashing matching * FAST_CASCADE_HASHING_L2: L2 Cascade Hashing with precomputed hashed regions (faster than CASCADE_HASHING_L2 but use more memory) For Binary based descriptor: * BRUTE_FORCE_HAMMING: BruteForce Hamming matching''',
description='For Scalar based regions descriptor\n'
' * BRUTE_FORCE_L2: L2 BruteForce matching\n'
' * ANN_L2: L2 Approximate Nearest Neighbor matching\n'
' * CASCADE_HASHING_L2: L2 Cascade Hashing matching\n'
' * FAST_CASCADE_HASHING_L2: L2 Cascade Hashing with precomputed hashed regions (faster than CASCADE_HASHING_L2 but use more memory) \n'
'For Binary based descriptor\n'
' * BRUTE_FORCE_HAMMING: BruteForce Hamming matching',
value='ANN_L2',
values=('BRUTE_FORCE_L2', 'ANN_L2', 'CASCADE_HASHING_L2', 'FAST_CASCADE_HASHING_L2', 'BRUTE_FORCE_HAMMING'),
exclusive=True,
@ -73,7 +79,7 @@ class FeatureMatching(desc.CommandLineNode):
name='savePutativeMatches',
label='Save Putative Matches',
description='''putative matches.''',
value='',
value=False,
uid=[0],
),
desc.BoolParam(

View file

@ -276,6 +276,11 @@ class UIGraph(QObject):
def setAttribute(self, attribute, value):
self.push(commands.SetAttributeCommand(self._graph, attribute, value))
@Slot(graph.Attribute)
def resetAttribute(self, attribute):
""" Reset 'attribute' to its default value """
self.push(commands.SetAttributeCommand(self._graph, attribute, attribute.defaultValue()))
@Slot(graph.Attribute, QJsonValue)
def appendAttribute(self, attribute, value=QJsonValue()):
if isinstance(value, QJsonValue):

View file

@ -1,6 +1,7 @@
import QtQuick 2.9
import QtQuick.Layouts 1.3
import QtQuick.Controls 2.2
import MaterialIcons 2.2
/**
A component to display and edit a Node's attributes.
@ -31,8 +32,11 @@ ColumnLayout {
}
ToolButton {
text: "⚙"
text: MaterialIcons.settings
font.family: MaterialIcons.fontFamily
onClicked: settingsMenu.popup()
checkable: true
checked: settingsMenu.visible
}
}
Menu {
@ -64,42 +68,25 @@ ColumnLayout {
id: attributesListView
anchors.fill: parent
anchors.margins: 6
anchors.margins: 4
clip: true
spacing: 4
spacing: 1
ScrollBar.vertical: ScrollBar { id: scrollBar }
model: node ? node.attributes : undefined
delegate: RowLayout {
delegate: AttributeItemDelegate {
labelWidth: 180
width: attributesListView.width
spacing: 4
Label {
id: parameterLabel
text: object.label
Layout.preferredWidth: 180
color: object.isOutput ? "orange" : palette.text
elide: Label.ElideRight
ToolTip.text: object.desc.description
ToolTip.visible: parameterMA.containsMouse && object.desc.description
ToolTip.delay: 200
MouseArea {
id: parameterMA
anchors.fill: parent
hoverEnabled: true
}
}
AttributeItemDelegate {
Layout.fillWidth: true
Layout.rightMargin: scrollBar.width
height: childrenRect.height
attribute: object
readOnly: root.readOnly
}
attribute: object
}
// Helper MouseArea to lose edit/activeFocus
// when clicking on the background
MouseArea {
anchors.fill: parent
onClicked: root.forceActiveFocus()
z: -1
}
}
}

View file

@ -1,216 +1,338 @@
import QtQuick 2.9
import QtQuick.Layouts 1.3
import QtQuick.Controls 2.2
import MaterialIcons 2.2
import "../filepath.js" as Filepath
/**
Instantiate a control to visualize and edit an Attribute based on its type.
*/
Loader {
RowLayout {
id: root
property variant attribute: null
property bool readOnly: false
property bool readOnly: false // whether the attribute's value can be modified
property alias label: parameterLabel // accessor to the internal Label (attribute's name)
property int labelWidth // shortcut to set the fixed size of the Label
readonly property bool editable: !attribute.isOutput && !attribute.isLink && !readOnly
sourceComponent: {
switch(attribute.type)
{
case "ChoiceParam": return attribute.desc.exclusive ? comboBox_component : multiChoice_component
case "IntParam": return slider_component
case "FloatParam": return slider_component
case "BoolParam": return checkbox_component
case "ListAttribute": return listAttribute_component
case "GroupAttribute": return groupAttribute_component
default: return textField_component
}
}
spacing: 4
function setTextFieldAttribute(attribute, value)
{
// editingFinished called even when TextField is readonly
if(editable)
_reconstruction.setAttribute(attribute, value.trim())
}
Label {
id: parameterLabel
Component {
id: textField_component
TextField {
readOnly: !root.editable
text: attribute.value
selectByMouse: true
onEditingFinished: setTextFieldAttribute(attribute, text)
onAccepted: setTextFieldAttribute(attribute, text)
}
}
Layout.preferredWidth: labelWidth || implicitWidth
Layout.fillHeight: true
horizontalAlignment: attribute.isOutput ? Qt.AlignRight : Qt.AlignLeft
elide: Label.ElideRight
padding: 5
wrapMode: Label.WrapAtWordBoundaryOrAnywhere
Component {
id: comboBox_component
ComboBox {
enabled: root.editable
model: attribute.desc.values
Component.onCompleted: currentIndex = find(attribute.value)
onActivated: _reconstruction.setAttribute(attribute, currentText)
Connections {
target: attribute
onValueChanged: currentIndex = find(attribute.value)
text: attribute.label
// Tooltip hint with attribute's description
ToolTip.text: object.desc.description
ToolTip.visible: parameterMA.containsMouse && object.desc.description
ToolTip.delay: 800
// make label bold if attribute's value is not the default one
font.bold: !object.isOutput && !object.isDefault
// make label italic if attribute is a link
font.italic: object.isLink
background: Rectangle { color: Qt.darker(palette.window, 1.2) }
MouseArea {
id: parameterMA
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.AllButtons
property Component menuComp: Menu {
id: paramMenu
property bool isFileAttribute: attribute.type == "File"
property bool isFilepath: isFileAttribute && Filepath.isFile(attribute.value)
MenuItem {
text: "Reset To Default Value"
enabled: !attribute.isOutput && !attribute.isLink && !attribute.isDefault
onTriggered: _reconstruction.resetAttribute(attribute)
}
MenuSeparator {
visible: paramMenu.isFileAttribute
height: visible ? implicitHeight : 0
}
MenuItem {
visible: paramMenu.isFileAttribute
height: visible ? implicitHeight : 0
text: paramMenu.isFilepath ? "Open Containing Folder" : "Open Folder"
onClicked: paramMenu.isFilepath ? Qt.openUrlExternally(Filepath.dirname(attribute.value)) :
Qt.openUrlExternally(attribute.value)
}
MenuItem {
visible: paramMenu.isFilepath
height: visible ? implicitHeight : 0
text: "Open File"
onClicked: Qt.openUrlExternally(attribute.value)
}
}
onClicked: {
forceActiveFocus()
if(mouse.button == Qt.RightButton)
{
var menu = menuComp.createObject(parameterLabel)
menu.parent = parameterLabel
menu.popup()
}
}
}
}
Component {
id: multiChoice_component
Flow {
Repeater {
id: checkbox_repeater
model: attribute.desc.values
delegate: CheckBox {
function setTextFieldAttribute(value)
{
// editingFinished called even when TextField is readonly
if(!editable)
return
switch(attribute.type)
{
case "IntParam":
case "FloatParam":
_reconstruction.setAttribute(root.attribute, Number(value))
break;
case "File":
_reconstruction.setAttribute(root.attribute, value.replace("file://", "").trim())
break;
default:
_reconstruction.setAttribute(root.attribute, value.trim())
}
}
Loader {
Layout.fillWidth: true
sourceComponent: {
switch(attribute.type)
{
case "ChoiceParam": return attribute.desc.exclusive ? comboBox_component : multiChoice_component
case "IntParam": return slider_component
case "FloatParam": return slider_component
case "BoolParam": return checkbox_component
case "ListAttribute": return listAttribute_component
case "GroupAttribute": return groupAttribute_component
default: return textField_component
}
}
Component {
id: textField_component
TextField {
readOnly: !root.editable
text: attribute.value
selectByMouse: true
onEditingFinished: setTextFieldAttribute(text)
onAccepted: {
setTextFieldAttribute(text)
root.forceActiveFocus()
}
DropArea {
enabled: root.editable
text: modelData
checked: attribute.value.indexOf(modelData) >= 0
onToggled: {
var t = attribute.value
if(!checked) { t.splice(t.indexOf(modelData), 1) } // remove element
else { t.push(modelData) } // add element
_reconstruction.setAttribute(attribute, t)
anchors.fill: parent
onDropped: {
if(drop.hasUrls)
setTextFieldAttribute(drop.urls[0].toLocaleString())
else if(drop.hasText && drop.text != '')
setTextFieldAttribute(drop.text)
}
}
}
}
}
Component {
id: slider_component
RowLayout {
TextField {
IntValidator {
id: intValidator
}
DoubleValidator {
id: doubleValidator
}
implicitWidth: 70
Component {
id: comboBox_component
ComboBox {
enabled: root.editable
text: s.pressed ? s.value : attribute.value
selectByMouse: true
validator: attribute.type == "FloatParam" ? doubleValidator : intValidator
onEditingFinished: setTextFieldAttribute(attribute, text)
}
Slider {
id: s
Layout.fillWidth: true
enabled: root.editable
value: attribute.value
from: attribute.desc.range[0]
to: attribute.desc.range[1]
stepSize: attribute.desc.range[2]
snapMode: Slider.SnapAlways
onPressedChanged: {
if(!pressed)
_reconstruction.setAttribute(attribute, value)
model: attribute.desc.values
Component.onCompleted: currentIndex = find(attribute.value)
onActivated: _reconstruction.setAttribute(attribute, currentText)
Connections {
target: attribute
onValueChanged: currentIndex = find(attribute.value)
}
}
}
}
Component {
id: checkbox_component
Row {
CheckBox {
enabled: root.editable
checked: attribute.value
onToggled: _reconstruction.setAttribute(attribute, !attribute.value)
}
}
}
Component {
id: listAttribute_component
ColumnLayout {
id: listAttribute_layout
width: parent.width
property bool expanded: false
Row {
spacing: 2
ToolButton {
text: listAttribute_layout.expanded ? "▾" : "▸"
onClicked: listAttribute_layout.expanded = !listAttribute_layout.expanded
Component {
id: multiChoice_component
Flow {
Repeater {
id: checkbox_repeater
model: attribute.desc.values
delegate: CheckBox {
enabled: root.editable
text: modelData
checked: attribute.value.indexOf(modelData) >= 0
onToggled: {
var t = attribute.value
if(!checked) { t.splice(t.indexOf(modelData), 1) } // remove element
else { t.push(modelData) } // add element
_reconstruction.setAttribute(attribute, t)
}
}
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: attribute.value.count + " elements"
}
ToolButton {
text: "+"
}
}
Component {
id: slider_component
RowLayout {
TextField {
IntValidator {
id: intValidator
}
DoubleValidator {
id: doubleValidator
}
implicitWidth: 70
enabled: root.editable
onClicked: _reconstruction.appendAttribute(attribute, undefined)
text: s.pressed ? s.value : attribute.value
selectByMouse: true
validator: attribute.type == "FloatParam" ? doubleValidator : intValidator
onEditingFinished: setTextFieldAttribute(text)
onAccepted: setTextFieldAttribute(text)
}
Slider {
id: s
Layout.fillWidth: true
enabled: root.editable
value: attribute.value
from: attribute.desc.range[0]
to: attribute.desc.range[1]
stepSize: attribute.desc.range[2]
snapMode: Slider.SnapAlways
onPressedChanged: {
if(!pressed)
_reconstruction.setAttribute(attribute, value)
}
}
}
}
Component {
id: checkbox_component
Row {
CheckBox {
enabled: root.editable
checked: attribute.value
onToggled: _reconstruction.setAttribute(attribute, !attribute.value)
}
}
}
Component {
id: listAttribute_component
ColumnLayout {
id: listAttribute_layout
width: parent.width
property bool expanded: false
RowLayout {
spacing: 4
ToolButton {
text: listAttribute_layout.expanded ? "▾" : "▸"
onClicked: listAttribute_layout.expanded = !listAttribute_layout.expanded
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: attribute.value.count + " elements"
}
ToolButton {
text: MaterialIcons.add_circle_outline
font.family: MaterialIcons.fontFamily
font.pointSize: 11
padding: 2
enabled: root.editable
onClicked: _reconstruction.appendAttribute(attribute, undefined)
}
}
ListView {
id: lv
model: listAttribute_layout.expanded ? attribute.value : undefined
visible: model != undefined && count > 0
implicitHeight: Math.min(childrenRect.height, 300)
Layout.fillWidth: true
Layout.margins: 4
clip: true
spacing: 4
ScrollBar.vertical: ScrollBar { id: sb }
delegate: RowLayout {
id: item
property var childAttrib: object
layoutDirection: Qt.RightToLeft
width: lv.width - sb.width
Component.onCompleted: {
var cpt = Qt.createComponent("AttributeItemDelegate.qml")
var obj = cpt.createObject(item,
{'attribute': Qt.binding(function() { return item.childAttrib }),
'readOnly': Qt.binding(function() { return root.readOnly })
})
obj.Layout.fillWidth = true
obj.label.text = index
obj.label.horizontalAlignment = Text.AlignHCenter
obj.label.verticalAlignment = Text.AlignVCenter
}
ToolButton {
enabled: root.editable
text: MaterialIcons.remove_circle_outline
font.family: MaterialIcons.fontFamily
font.pointSize: 11
padding: 2
ToolTip.text: "Remove Element"
ToolTip.visible: hovered
onClicked: _reconstruction.removeAttribute(item.childAttrib)
}
}
}
}
}
Component {
id: groupAttribute_component
ListView {
id: lv
model: listAttribute_layout.expanded ? attribute.value : undefined
visible: model != undefined && count > 0
implicitHeight: Math.min(childrenRect.height, 300)
Layout.fillWidth: true
Layout.margins: 4
clip: true
spacing: 10
id: chilrenListView
model: attribute.value
implicitWidth: parent.width
implicitHeight: childrenRect.height
onCountChanged: forceLayout()
spacing: 2
ScrollBar.vertical: ScrollBar { id: sb }
delegate: RowLayout {
id: item
delegate: RowLayout {
id: row
width: chilrenListView.width
property var childAttrib: object
layoutDirection: Qt.RightToLeft
width: lv.width - sb.width
Component.onCompleted: {
Component.onCompleted: {
var cpt = Qt.createComponent("AttributeItemDelegate.qml")
var obj = cpt.createObject(item,
{'attribute': Qt.binding(function() { return item.childAttrib }),
var obj = cpt.createObject(row,
{'attribute': Qt.binding(function() { return row.childAttrib }),
'readOnly': Qt.binding(function() { return root.readOnly })
})
obj.Layout.fillWidth = true
}
ToolButton {
enabled: root.editable
text: "∅"
ToolTip.text: "Remove Element"
ToolTip.visible: hovered
onClicked: _reconstruction.removeAttribute(item.childAttrib)
obj.labelWidth = 100 // reduce label width for children (space gain)
}
}
}
}
}
Component {
id: groupAttribute_component
ListView {
id: someview
model: attribute.value
implicitWidth: parent.width
implicitHeight: childrenRect.height
onCountChanged: forceLayout()
spacing: 2
delegate: RowLayout {
id: row
width: someview.width
property var childAttrib: object
Label { text: childAttrib.name }
Component.onCompleted: {
var cpt = Qt.createComponent("AttributeItemDelegate.qml")
var obj = cpt.createObject(row,
{'attribute': Qt.binding(function() { return row.childAttrib }),
'readOnly': Qt.binding(function() { return root.readOnly })
})
obj.Layout.fillWidth = true
}
}
}
}
}

View file

@ -60,6 +60,7 @@ Item {
}
onReleased: {
drag.target = undefined // stop drag
root.forceActiveFocus()
workspaceClicked()
}
onPositionChanged: {

View file

@ -15,3 +15,10 @@ function extension(path) {
var dot_pos = path.lastIndexOf('.');
return dot_pos > -1 ? path.substring(dot_pos, path.length) : ""
}
/// Return whether the given path is a path to a file.
/// (only based on the fact that the last portion of the path
/// matches the 'basename.extension' pattern)
function isFile(path) {
return extension(path) !== ""
}

View file

@ -290,6 +290,9 @@ ApplicationWindow {
anchors.fill: parent
orientation: Qt.Vertical
// Setup global tooltip style
ToolTip.toolTip.background: Rectangle { color: palette.base; border.color: palette.mid }
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true

View file

@ -1,7 +1,7 @@
import os
import time
from PySide2.QtCore import QFileSystemWatcher, QUrl, Slot
from PySide2.QtCore import QFileSystemWatcher, QUrl, Slot, QTimer
from PySide2.QtQml import QQmlApplicationEngine
@ -29,6 +29,8 @@ class QmlInstantEngine(QQmlApplicationEngine):
self._rootItem = None
def onObjectCreated(root, url):
if not root:
return
# Restore root item geometry
if self._rootItem:
root.setGeometry(self._rootItem.geometry())
@ -160,6 +162,11 @@ class QmlInstantEngine(QQmlApplicationEngine):
@Slot(str)
def onFileChanged(self, filepath):
""" Handle changes in a watched file. """
if filepath not in self._watchedFiles:
# could happen if a file has just been reloaded
# and has not been re-added yet to the watched files
return
if self._verbose:
print("Source file changed : ", filepath)
# Clear the QQuickEngine cache
@ -177,9 +184,10 @@ class QmlInstantEngine(QQmlApplicationEngine):
self.reload()
# Finally, read the modified file to the watch system
self.addFile(filepath)
# Finally, re-add the modified file to the watch system
# after a short cooldown to avoid multiple consecutive reloads
QTimer.singleShot(200, lambda: self.addFile(filepath))
def reload(self):
print("Reloading ", self._sourceFile)
print("Reloading {}".format(self._sourceFile))
self.load(self._sourceFile)