diff --git a/meshroom/ui/qml/Controls/FilterComboBox.qml b/meshroom/ui/qml/Controls/FilterComboBox.qml index 6af29ce151..ce1a9fa75a 100644 --- a/meshroom/ui/qml/Controls/FilterComboBox.qml +++ b/meshroom/ui/qml/Controls/FilterComboBox.qml @@ -1,177 +1,135 @@ import QtQuick import QtQuick.Controls - +import QtQuick.Layouts import Utils 1.0 +import MaterialIcons + /** -* ComboBox with filter text area -* -* @param inputModel - model to filter -* @param editingFinished - signal emitted when editing is finished -* @alias filterText - text to filter the model +* ComboBox with filtering capabilities and support for custom values (i.e: outside the source model). */ ComboBox { - id: combo + id: root - property var inputModel - signal editingFinished(var value) + // Model to populate the combobox. + required property var sourceModel + // Input value to use as the current combobox value. + property var inputValue + // The text to filter the combobox model when the choices are displayed. + property alias filterText: filterTextArea.text + // Whether the current input value is within the source model. + readonly property bool validValue: sourceModel.includes(inputValue) - property alias filterText: filterTextArea - property bool validValue: true + signal editingFinished(var value) - enabled: root.editable - model: { - var filteredData = inputModel.filter(condition => { - if (filterTextArea.text.length > 0) return condition.toString().toLowerCase().includes(filterTextArea.text.toLowerCase()) - return true - }) - if (filteredData.length > 0) { - filterTextArea.background.color = Qt.lighter(palette.base, 2) - validValue = true - - // order filtered data by relevance (results that start with the filter text come first) - filteredData.sort((a, b) => { - const nameA = a.toString().toLowerCase(); - const nameB = b.toString().toLowerCase(); - const filterText = filterTextArea.text.toLowerCase() - if (nameA.startsWith(filterText) && !nameB.startsWith(filterText)) - return -1 - if (!nameA.startsWith(filterText) && nameB.startsWith(filterText)) - return 1 - return 0 - }) - } else { - filterTextArea.background.color = Colors.red - validValue = false - } + function clearFilter() { + filterText = ""; + } - if (filteredData.length == 0 || filterTextArea.length == 0) { - filteredData = inputModel - } + // Re-computing current index when source values are set. + Component.onCompleted: _updateCurrentIndex() + onInputValueChanged: _updateCurrentIndex() + onModelChanged: _updateCurrentIndex() - return filteredData + function _updateCurrentIndex() { + currentIndex = find(inputValue); } - background: Rectangle { - implicitHeight: root.implicitHeight - color: { - if (validValue) { - return palette.mid - } else { - return Colors.red - } - } - border.color: palette.base + displayText: inputValue + + model: { + return sourceModel.filter(item => { + return item.toString().toLowerCase().includes(filterText.toLowerCase()); + }); } - popup: Popup { - width: combo.width - implicitHeight: contentItem.implicitHeight + popup.onClosed: clearFilter() - onAboutToShow: { - filterTextArea.forceActiveFocus() + // Allows typing into the filter text area while the combobox has focus. + Keys.forwardTo: [filterTextArea] - if (mapToGlobal(popup.x, popup.y).y + root.implicitHeight * (model.length + 1) > _window.contentItem.height) { - y = -((combo.height * (combo.model.length + 1) > _window.contentItem.height) ? _window.contentItem.height*2/3 : combo.height * (combo.model.length + 1)) - } else { - y = 0 - } + onActivated: index => { + const isValidEntry = model.length > 0; + if (!isValidEntry) { + return; } + editingFinished(model[index]); + } - contentItem: Item { - anchors.fill: parent - TextArea { - id: filterTextArea - leftPadding: 12 - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - - selectByMouse: true - hoverEnabled: true - wrapMode: TextEdit.WrapAnywhere - placeholderText: "Filter" - background: Rectangle {} - - onEditingFinished: { - combo.popup.close() - combo.editingFinished(displayText) + StateGroup { + id: filterState + // Override properties depending on filter text status. + states: [ + State { + name: "Invalid" + when: root.delegateModel.count === 0 + PropertyChanges { + target: filterTextArea + color: Colors.orange } + } + ] + } - Keys.onEnterPressed: { - if (!validValue) { - displayText = filterTextArea.text - } else { - displayText = currentText - } - editingFinished() - } + popup.contentItem: ColumnLayout { + width: parent.width + spacing: 0 - Keys.onReturnPressed: { - if (!validValue) { - displayText = filterTextArea.text - } else { - displayText = currentText - } - editingFinished() - } + RowLayout { + Layout.fillWidth: true + spacing: 2 - Keys.onUpPressed: { - // if the current index is 0, the user wants to go to the last item - if (combo.currentIndex == 0) { - combo.currentIndex = combo.model.length - 1 - } else { - combo.currentIndex-- - } + TextField { + id: filterTextArea + placeholderText: "Type to filter..." + Layout.fillWidth: true + leftPadding: 18 + MouseArea { + // Prevent textfield from stealing combobox's active focus, without disabling it. + anchors.fill: parent } - - Keys.onDownPressed: { - // if the current index is the last one, the user wants to go to the first item - if (combo.currentIndex == combo.model.length - 1) { - combo.currentIndex = 0 - } else { - combo.currentIndex++ + background: Item { + MaterialLabel { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 2 + text: MaterialIcons.search } } } - } - - ListView { - id: listView - clip: true - anchors.left: parent.left - anchors.right: parent.right - anchors.top: filterTextArea.bottom - - implicitHeight: (combo.height * (combo.model.length + 1) > _window.contentItem.height) ? _window.contentItem.height*2/3 : contentHeight - model: combo.popup.visible ? combo.delegateModel : null - ScrollBar.vertical: MScrollBar {} + MaterialToolButton { + enabled: root.filterText !== "" + text: MaterialIcons.add_task + ToolTip.text: "Force custom value" + onClicked: { + editingFinished(root.filterText); + root.popup.close(); + } + } } - } - - delegate: ItemDelegate { - width: combo.width - height: combo.height - contentItem: Text { - text: modelData - color: palette.text + Rectangle { + height: 1 + Layout.fillWidth: true + color: Colors.sysPalette.mid } - highlighted: validValue ? combo.currentIndex === index : false + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - hoverEnabled: true - } + ListView { + implicitHeight: contentHeight + clip: true - onHighlightedIndexChanged: { - if (highlightedIndex >= 0) { - combo.currentIndex = highlightedIndex + model: root.delegateModel + highlightRangeMode: ListView.ApplyRange + currentIndex: root.highlightedIndex + ScrollBar.vertical: ScrollBar {} + } } } - - onCurrentTextChanged: { - displayText = currentText - } } diff --git a/meshroom/ui/qml/GraphEditor/AttributeControls/Choice.qml b/meshroom/ui/qml/GraphEditor/AttributeControls/Choice.qml new file mode 100644 index 0000000000..c4624d50b9 --- /dev/null +++ b/meshroom/ui/qml/GraphEditor/AttributeControls/Choice.qml @@ -0,0 +1,34 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import MaterialIcons +import Controls + +/** + * A combobox-type control with a single current `value` and a list of possible `values`. + * Provides filtering capabilities and support for custom values (i.e: `value` not in `values`). + */ +RowLayout { + id: root + + required property var value + required property var values + + signal editingFinished(var value) + + FilterComboBox { + id: comboBox + + Layout.fillWidth: true + sourceModel: root.values + inputValue: root.value + onEditingFinished: value => root.editingFinished(value) + } + + MaterialLabel { + visible: !comboBox.validValue + text: MaterialIcons.warning + ToolTip.text: "Custom value detected" + } +} diff --git a/meshroom/ui/qml/GraphEditor/AttributeControls/ChoiceMulti.qml b/meshroom/ui/qml/GraphEditor/AttributeControls/ChoiceMulti.qml new file mode 100644 index 0000000000..a4807b34f6 --- /dev/null +++ b/meshroom/ui/qml/GraphEditor/AttributeControls/ChoiceMulti.qml @@ -0,0 +1,41 @@ +import QtQuick +import QtQuick.Controls +import Controls + +/** + * A multi-checkboxes control with a current `value` (list of 0-N elements) and a list of possible `values`. + * Provides support for custom values (`value` elements not in `values`). + */ +Flow { + id: root + + required property var value + required property var values + property color customValueColor: "orange" + + signal toggled(var value, var checked) + + // Predefined possible values. + Repeater { + model: root.values + delegate: CheckBox { + text: modelData + checked: root.value.includes(modelData) + onToggled: root.toggled(modelData, checked) + } + } + + // Custom elements outside the predefined possible values. + Repeater { + model: root.value.filter(v => !root.values.includes(v)) + delegate: CheckBox { + text: modelData + palette.text: root.customValueColor + font.italic: true + checked: true + ToolTip.text: "Custom value" + ToolTip.visible: hovered + onToggled: root.toggled(modelData, checked) + } + } +} diff --git a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml index 7914dd86c5..5a6ecbedd2 100644 --- a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml +++ b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml @@ -6,6 +6,7 @@ import QtQuick.Dialogs import MaterialIcons 2.2 import Utils 1.0 import Controls 1.0 +import "AttributeControls" as AttributeControls /** * Instantiate a control to visualize and edit an Attribute based on its type. @@ -208,7 +209,7 @@ RowLayout { case "PushButtonParam": return pushButtonComponent case "ChoiceParam": - return attribute.desc.exclusive ? comboBoxComponent : multiChoiceComponent + return attribute.desc.exclusive ? choiceComponent : choiceMultiComponent case "IntParam": return sliderComponent case "FloatParam": if (attribute.desc.semantic === 'color/hue') @@ -469,67 +470,35 @@ RowLayout { } Component { - id: comboBoxComponent + id: choiceComponent - FilterComboBox { - inputModel: attribute.values - - Component.onCompleted: { - // If value not in list, override the text and precise it is not valid - var idx = find(attribute.value) - if (idx === -1) { - displayText = attribute.value - validValue = false - } else { - currentIndex = idx - } - } + AttributeControls.Choice { + value: root.attribute.value + values: root.attribute.values + enabled: root.editable - onEditingFinished: function(value) { - _reconstruction.setAttribute(attribute, value) - } - - Connections { - target: attribute - function onValueChanged() { - // When reset, clear and find the current index - // but if only reopen the combo box, keep the current value - - // Convert all values of desc values as string - var valuesAsString = attribute.values.map(function(value) { - return value.toString() - }) - if (valuesAsString.includes(attribute.value) || attribute.value === attribute.desc.value) { - filterText.clear() - validValue = true - displayText = currentText - currentIndex = find(attribute.value) - } - } + onEditingFinished: (value) => { + _reconstruction.setAttribute(root.attribute, value) } } } Component { - id: multiChoiceComponent - Flow { - Repeater { - id: checkboxRepeater - model: attribute.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) - } + id: choiceMultiComponent + + AttributeControls.ChoiceMulti { + value: root.attribute.value + values: root.attribute.values + enabled: root.editable + customValueColor: Colors.orange + onToggled: (value, checked) => { + var currentValue = root.attribute.value; + if (!checked) { + currentValue.splice(currentValue.indexOf(value), 1); + } else { + currentValue.push(value); } + _reconstruction.setAttribute(attribute, currentValue); } } }