From 0f0217f2e1338ec8516b6a5a2ee0dfc64570c10f Mon Sep 17 00:00:00 2001 From: saber Date: Mon, 16 Mar 2026 14:12:20 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(form):=20show=20value-map=20options=20?= =?UTF-8?q?as=20chip=20buttons=20when=20=E2=89=A4=204=20choices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Value-map fields with 4 or fewer options now render all choices as inline pill/chip buttons directly on the form, eliminating the need to open a drawer for short lists (e.g. a 4-value condition rating). Fields with 5 or more options continue to use the existing dropdown drawer (MMFormComboboxBaseEditor + MMListMultiselectDrawer) unchanged, so there is no regression for longer lists. Implementation -------------- • New MMFormChipEditor.qml (extends MMBaseInput): - Displays options as a horizontally-wrapping Flow of pill Rectangles. - Selected chip: grassColor (#73D19C) background. - Unselected chip: white background with forestColor border. - Read-only state: MouseArea disabled, chips still visible. - Emits editorValueChanged / rememberValueBoxClicked for MMFormPage compatibility. • Refactored MMFormValueMapEditor.qml (now an Item wrapper): - Parses _fieldConfig into a shared ListModel in Component.onCompleted. - After parsing, sets _modelReady = true which triggers a Loader to pick the appropriate sub-editor (chip vs combobox) in the same synchronous call — no visible flicker. - Forwards editorValueChanged and rememberValueBoxClicked from the active sub-editor to MMFormPage via Connections. • CMakeLists.txt: registers the new MMFormChipEditor.qml QML source. Co-Authored-By: Claude Sonnet 4.6 --- app/qml/CMakeLists.txt | 1 + app/qml/form/editors/MMFormChipEditor.qml | 191 +++++++++++++++ app/qml/form/editors/MMFormValueMapEditor.qml | 230 ++++++++++++------ 3 files changed, 351 insertions(+), 71 deletions(-) create mode 100644 app/qml/form/editors/MMFormChipEditor.qml 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..a3b17e6c2 --- /dev/null +++ b/app/qml/form/editors/MMFormChipEditor.qml @@ -0,0 +1,191 @@ +/*************************************************************************** + * * + * 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: chipLabel.implicitHeight + 2 * __style.margin8 + width: chipLabel.implicitWidth + 2 * __style.margin16 + + radius: __style.radius30 + + color: chipItem.isSelected ? __style.grassColor : __style.polarColor + + border.width: __style.width2 + border.color: chipItem.isSelected ? __style.grassColor : __style.forestColor + + MMComponents.MMText { + id: chipLabel + + anchors.centerIn: parent + + text: model.text || "" + font: __style.p5 + color: __style.nightColor + } + } + + 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 } } From f6d737ec2b14b33de6def482a687b964bd9a8e72 Mon Sep 17 00:00:00 2001 From: saber Date: Mon, 16 Mar 2026 19:16:04 +0100 Subject: [PATCH 2/2] fix(form): clarify selected chip with checkmark and muted unselected border With only 2 options the previous design (green fill vs white + dark border) could be ambiguous about which chip was active. Changes to MMFormChipEditor: - Selected chip now shows a leading checkmark icon (forestColor) so the active value is unambiguous regardless of how many chips exist. - Unselected chip border changes from forestColor to greyColor (#E2E2E2) so unselected chips visually recede behind the selected one. Co-Authored-By: Claude Sonnet 4.6 --- app/qml/form/editors/MMFormChipEditor.qml | 38 +++++++++++++++++------ 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/app/qml/form/editors/MMFormChipEditor.qml b/app/qml/form/editors/MMFormChipEditor.qml index a3b17e6c2..580d069eb 100644 --- a/app/qml/form/editors/MMFormChipEditor.qml +++ b/app/qml/form/editors/MMFormChipEditor.qml @@ -121,24 +121,44 @@ MMPrivateComponents.MMBaseInput { Rectangle { id: chipBackground - height: chipLabel.implicitHeight + 2 * __style.margin8 - width: chipLabel.implicitWidth + 2 * __style.margin16 + 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.forestColor + border.color: chipItem.isSelected ? __style.grassColor : __style.greyColor - MMComponents.MMText { - id: chipLabel + Row { + id: chipRow anchors.centerIn: parent - - text: model.text || "" - font: __style.p5 - color: __style.nightColor + 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 + } } }