diff --git a/app/qml/CMakeLists.txt b/app/qml/CMakeLists.txt index d9662b019..161b08cdb 100644 --- a/app/qml/CMakeLists.txt +++ b/app/qml/CMakeLists.txt @@ -101,6 +101,7 @@ set(MM_QML form/components/photo/MMPhotoPreview.qml form/components/MMFormActionBar.qml form/editors/MMFormCalendarEditor.qml + form/editors/MMFormChipEditor.qml form/editors/MMFormComboboxBaseEditor.qml form/editors/MMFormGalleryEditor.qml form/editors/MMFormNotAvailable.qml diff --git a/app/qml/form/editors/MMFormChipEditor.qml b/app/qml/form/editors/MMFormChipEditor.qml new file mode 100644 index 000000000..580d069eb --- /dev/null +++ b/app/qml/form/editors/MMFormChipEditor.qml @@ -0,0 +1,211 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +import "../../components" as MMComponents +import "../../components/private" as MMPrivateComponents + +/* + * Chip-button selector for QGIS Attribute Form value-map fields. + * + * Displays all options as horizontally-wrapping pill buttons instead of opening + * a drawer. Intended for fields with a small number of options (≤ 4) where + * showing all choices inline is more ergonomic than a dropdown. + * + * Requires various global properties set to function, see featureform Loader section. + * These properties are injected here via 'fieldXYZ' properties and captured with + * underscore `_`. + * + * Should be used only within feature form. + * See MMBaseInput for base class properties (title, errorMsg, warningMsg, etc.). + */ + +MMPrivateComponents.MMBaseInput { + id: root + + // === Properties injected by MMFormPage === + + property var _fieldValue: parent.fieldValue + property var _fieldConfig: parent.fieldConfig + property bool _fieldValueIsNull: parent.fieldValueIsNull + property bool _fieldHasMixedValues: parent.fieldHasMixedValues + + property bool _fieldShouldShowTitle: parent.fieldShouldShowTitle + property bool _fieldFormIsReadOnly: parent.fieldFormIsReadOnly + property bool _fieldIsEditable: parent.fieldIsEditable + + property string _fieldTitle: parent.fieldTitle + property string _fieldErrorMessage: parent.fieldErrorMessage + property string _fieldWarningMessage: parent.fieldWarningMessage + + property bool _fieldRememberValueSupported: parent.fieldRememberValueSupported + property bool _fieldRememberValueState: parent.fieldRememberValueState + + // === Internal state === + + // The raw field value of the currently selected option (from the value-map's value column). + property var _currentValue: undefined + + // === Signals expected by MMFormPage === + + signal editorValueChanged( var newValue, bool isNull ) + signal rememberValueBoxClicked( bool state ) + + // === MMBaseInput bindings === + + title: _fieldShouldShowTitle ? _fieldTitle : "" + + errorMsg: _fieldErrorMessage + warningMsg: _fieldWarningMessage + + readOnly: _fieldFormIsReadOnly || !_fieldIsEditable + shouldShowValidation: !_fieldFormIsReadOnly + + hasCheckbox: _fieldRememberValueSupported + checkboxChecked: _fieldRememberValueState + + onCheckboxCheckedChanged: root.rememberValueBoxClicked( checkboxChecked ) + + on_FieldValueChanged: { + root._currentValue = _fieldValueIsNull ? undefined : _fieldValue + } + + // === Chip grid === + + inputContent: Rectangle { + id: chipContainer + + width: parent.width + // Height grows with the wrapped chips + top/bottom padding + height: chipsFlow.implicitHeight + 2 * __style.margin12 + + radius: __style.radius12 + color: __style.polarColor + + Flow { + id: chipsFlow + + anchors { + top: parent.top + left: parent.left + right: parent.right + margins: __style.margin12 + } + + spacing: __style.margin8 + + Repeater { + id: chipsRepeater + + model: chipsModel + + delegate: Item { + id: chipItem + + readonly property bool isSelected: { + if ( root._fieldValueIsNull || root._currentValue === undefined ) return false + if ( model.value === undefined || model.value === null ) return false + return model.value.toString() === root._currentValue.toString() + } + + width: chipBackground.width + height: chipBackground.height + + Rectangle { + id: chipBackground + + height: chipRow.implicitHeight + 2 * __style.margin8 + width: chipRow.implicitWidth + 2 * __style.margin16 + + radius: __style.radius30 + + // Selected: solid grassColor fill. + // Unselected: white with a muted grey border so selected chip + // stands out clearly even when only 2 options exist. + color: chipItem.isSelected ? __style.grassColor : __style.polarColor + + border.width: __style.width2 + border.color: chipItem.isSelected ? __style.grassColor : __style.greyColor + + Row { + id: chipRow + + anchors.centerIn: parent + spacing: __style.margin4 + + // Checkmark shown only on the selected chip for unambiguous feedback. + MMComponents.MMIcon { + id: chipCheckmark + + visible: chipItem.isSelected + source: __style.checkmarkIcon + size: __style.icon16 + color: __style.forestColor + anchors.verticalCenter: parent.verticalCenter + } + + MMComponents.MMText { + id: chipLabel + + text: model.text || "" + font: __style.p5 + color: __style.nightColor + anchors.verticalCenter: parent.verticalCenter + } + } + } + + MouseArea { + anchors.fill: parent + + enabled: !root.readOnly + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + + onClicked: function ( mouse ) { + mouse.accepted = true + root._currentValue = model.value + root.editorValueChanged( model.value, false ) + } + } + } + } + } + } + + // === Model === + + ListModel { id: chipsModel } + + // === Initialisation === + + Component.onCompleted: { + // Set the initial selection from the current field value + root._currentValue = _fieldValueIsNull ? undefined : _fieldValue + + if ( !root._fieldConfig['map'] ) { + __inputUtils.log( "Chip editor", root._fieldTitle + " config is not configured properly" ) + return + } + + let config = root._fieldConfig['map'] + + if ( !config.length ) { + __inputUtils.log( "Chip editor", root._fieldTitle + " is using unsupported format (map, <=QGIS2.18)" ) + return + } + + for ( let i = 0; i < config.length; i++ ) { + chipsModel.append( { + text: Object.keys( config[i] )[0], + value: Object.values( config[i] )[0] + } ) + } + } +} diff --git a/app/qml/form/editors/MMFormValueMapEditor.qml b/app/qml/form/editors/MMFormValueMapEditor.qml index effcbedb9..5b027731f 100644 --- a/app/qml/form/editors/MMFormValueMapEditor.qml +++ b/app/qml/form/editors/MMFormValueMapEditor.qml @@ -12,17 +12,27 @@ import QtQuick import "../../components" as MMComponents /* - * Dropdown (value map) editor for QGIS Attribute Form + * Value-map editor for QGIS Attribute Form. + * + * When the field has 4 or fewer options the editor renders all options as + * inline chip buttons (MMFormChipEditor), so the user can select a value + * with a single tap without opening a drawer. + * + * When there are 5 or more options the original dropdown drawer + * (MMFormComboboxBaseEditor + MMListMultiselectDrawer) is used unchanged. + * * Requires various global properties set to function, see featureform Loader section. - * These properties are injected here via 'fieldXYZ' properties and captured with underscore `_`. + * These properties are injected here via 'fieldXYZ' properties and captured with + * underscore `_`. * * Should be used only within feature form. - * See MMFormComboboxBaseEditor for more info. */ -MMFormComboboxBaseEditor { +Item { id: root + // === Properties injected by MMFormPage === + property var _fieldValue: parent.fieldValue property var _fieldConfig: parent.fieldConfig property bool _fieldValueIsNull: parent.fieldValueIsNull @@ -39,127 +49,205 @@ MMFormComboboxBaseEditor { property bool _fieldRememberValueSupported: parent.fieldRememberValueSupported property bool _fieldRememberValueState: parent.fieldRememberValueState - property var preselectedItems: [] + // === Signals expected by MMFormPage === signal editorValueChanged( var newValue, bool isNull ) signal rememberValueBoxClicked( bool state ) - title: _fieldShouldShowTitle ? _fieldTitle : "" + // === Layout === + + implicitHeight: editorLoader.item ? editorLoader.item.implicitHeight : 0 + + // === Internal: shared value-map model === + + // Parsed once from _fieldConfig; both chip and dropdown sub-editors read from it. + ListModel { id: listModel } + + // Tracks whether the model has been populated so we can choose the right sub-editor. + property bool _modelReady: false + + // Initial text displayed in the dropdown (combobox mode only). + property string _displayText: "" - placeholderText: _fieldHasMixedValues ? _fieldValue : "" + // Initial pre-selected items list passed to the drawer (combobox mode only). + property var _preselectedItems: [] - errorMsg: _fieldErrorMessage - warningMsg: _fieldWarningMessage + // === Sub-editor loader === - readOnly: _fieldFormIsReadOnly || !_fieldIsEditable - shouldShowValidation: !_fieldFormIsReadOnly + Loader { + id: editorLoader - hasCheckbox: _fieldRememberValueSupported - checkboxChecked: _fieldRememberValueState + width: parent.width - onCheckboxCheckedChanged: { - root.rememberValueBoxClicked( checkboxChecked ) + // sourceComponent remains null until Component.onCompleted has populated the + // model; this prevents the Loader from briefly showing the wrong variant. + sourceComponent: root._modelReady + ? ( listModel.count <= 4 ? chipEditorComponent : comboboxEditorComponent ) + : null } - on_FieldValueChanged: { + // Forward signals from whichever sub-editor is active. + Connections { + target: editorLoader.item + ignoreUnknownSignals: true - if ( _fieldValueIsNull || _fieldValue === undefined ) { - text = "" - preselectedItems = [] + function onEditorValueChanged( newValue, isNull ) { + root.editorValueChanged( newValue, isNull ) } - // let's find the new value in the model - for ( let i = 0; i < listModel.count; i++ ) { - let item_i = listModel.get( i ) + function onRememberValueBoxClicked( state ) { + root.rememberValueBoxClicked( state ) + } + } - if ( _fieldValue && _fieldValue.toString() === item_i.value.toString() ) { - text = item_i.text - preselectedItems = [item_i.value] - } + // === Chip editor (≤ 4 options) === + + Component { + id: chipEditorComponent + + MMFormChipEditor { + + // Bind all form-field properties from the wrapper. + _fieldValue: root._fieldValue + _fieldConfig: root._fieldConfig + _fieldValueIsNull: root._fieldValueIsNull + _fieldHasMixedValues: root._fieldHasMixedValues + _fieldShouldShowTitle: root._fieldShouldShowTitle + _fieldFormIsReadOnly: root._fieldFormIsReadOnly + _fieldIsEditable: root._fieldIsEditable + _fieldTitle: root._fieldTitle + _fieldErrorMessage: root._fieldErrorMessage + _fieldWarningMessage: root._fieldWarningMessage + _fieldRememberValueSupported: root._fieldRememberValueSupported + _fieldRememberValueState: root._fieldRememberValueState } } - dropdownLoader.sourceComponent: Component { + // === Combobox / dropdown editor (> 4 options) === - MMComponents.MMListMultiselectDrawer { + Component { + id: comboboxEditorComponent - drawerHeader.title: root._fieldTitle + MMFormComboboxBaseEditor { + id: combobox - emptyStateDelegate: Item { - width: parent.width - height: noItemsText.implicitHeight + __style.margin40 - - MMComponents.MMText { - id: noItemsText - text: qsTr( "No items" ) - anchors.centerIn: parent - } - } + title: root._fieldShouldShowTitle ? root._fieldTitle : "" - list.model: listModel + placeholderText: root._fieldHasMixedValues ? root._fieldValue : "" - selected: root.preselectedItems + errorMsg: root._fieldErrorMessage + warningMsg: root._fieldWarningMessage - showFullScreen: false - multiSelect: false - withSearch: false + readOnly: root._fieldFormIsReadOnly || !root._fieldIsEditable + shouldShowValidation: !root._fieldFormIsReadOnly - onClosed: dropdownLoader.active = false + hasCheckbox: root._fieldRememberValueSupported + checkboxChecked: root._fieldRememberValueState - onSelectionFinished: function ( selectedItems ) { - if ( !selectedItems || ( Array.isArray( selectedItems ) && selectedItems.length !== 1 ) ) { - // should not happen... - __inputUtils.log( "Value map", root._fieldTitle + " received unexpected values" ) + // Initialise displayed text from the pre-computed value in the wrapper. + text: root._displayText + + onCheckboxCheckedChanged: root.rememberValueBoxClicked( checkboxChecked ) + + // Watch for field-value changes while the combobox is mounted. + property var _watchValue: root._fieldValue + on_WatchValueChanged: { + if ( root._fieldValueIsNull || root._fieldValue === undefined ) { + combobox.text = "" + root._preselectedItems = [] return } - - root.editorValueChanged( selectedItems[0], selectedItems[0] === null ) + for ( let i = 0; i < listModel.count; i++ ) { + let item = listModel.get( i ) + if ( root._fieldValue.toString() === item.value.toString() ) { + combobox.text = item.text + root._preselectedItems = [ item.value ] + break + } + } } - Component.onCompleted: open() + dropdownLoader.sourceComponent: Component { + + MMComponents.MMListMultiselectDrawer { + + drawerHeader.title: root._fieldTitle + + emptyStateDelegate: Item { + width: parent.width + height: noItemsText.implicitHeight + __style.margin40 + + MMComponents.MMText { + id: noItemsText + text: qsTr( "No items" ) + anchors.centerIn: parent + } + } + + list.model: listModel + + selected: root._preselectedItems + + showFullScreen: false + multiSelect: false + withSearch: false + + onClosed: combobox.dropdownLoader.active = false + + onSelectionFinished: function ( selectedItems ) { + if ( !selectedItems || ( Array.isArray( selectedItems ) && selectedItems.length !== 1 ) ) { + __inputUtils.log( "Value map", root._fieldTitle + " received unexpected values" ) + return + } + + root.editorValueChanged( selectedItems[0], selectedItems[0] === null ) + } + + Component.onCompleted: open() + } + } } } - ListModel { id: listModel } + // === Initialisation === Component.onCompleted: { - // - // Parses value map options from config into ListModel. - // This functionality should be moved to cpp model in order to support search. - // - if ( !root._fieldConfig['map'] ) { __inputUtils.log( "Value map", root._fieldTitle + " config is not configured properly" ) + root._modelReady = true + return } let config = root._fieldConfig['map'] - if ( config.length ) - { - //it's a list (>=QGIS3.0) - for ( var i = 0; i < config.length; i++ ) - { + if ( config.length ) { + // QGIS ≥ 3.0 list format + for ( let i = 0; i < config.length; i++ ) { let modelItem = { - text: Object.keys( config[i] )[0], + text: Object.keys( config[i] )[0], value: Object.values( config[i] )[0] } listModel.append( modelItem ) - // Is this the current item? If so, set the text - if ( !root._fieldValueIsNull ) { + // Pre-compute display text and pre-selected list for the combobox variant. + if ( !root._fieldValueIsNull && root._fieldValue !== undefined ) { if ( root._fieldValue.toString() === modelItem.value.toString() ) { - root.text = modelItem.text - root.preselectedItems = [modelItem.value] + root._displayText = modelItem.text + root._preselectedItems = [ modelItem.value ] } } } } - else - { - //it's a map (<=QGIS2.18) <--- sorry, dropped support for that in 2024.1.0 + else { + // QGIS ≤ 2.18 map format — no longer supported __inputUtils.log( "Value map", root._fieldTitle + " is using unsupported format (map, <=QGIS2.18)" ) } + + // Setting _modelReady = true triggers the Loader to choose a sub-editor. + // This happens synchronously within Component.onCompleted, before the first + // frame is painted, so there is no visible flicker between the two variants. + root._modelReady = true } }