From a09c9c0e0a3efe1c386f29ab241d3eddd8281bd7 Mon Sep 17 00:00:00 2001 From: bpavuk Date: Fri, 27 Feb 2026 21:27:57 +0200 Subject: [PATCH] feat: [WIP] `Form.TagPicker` support --- .gitignore | 1 + CMakeLists.txt | 1 + flake.nix | 8 +- src/server/CMakeLists.txt | 4 + src/server/include/extend/form-model.hpp | 9 + src/server/include/extend/tag-model.hpp | 17 ++ src/server/src/extend/form-model.cpp | 15 +- src/server/src/extend/tag-list.cpp | 15 + src/server/src/qml/extension-form-model.cpp | 78 ++++- src/server/src/qml/extension-form-model.hpp | 11 +- .../qml/form-tag-picker-suggestions-model.cpp | 76 +++++ .../qml/form-tag-picker-suggestions-model.hpp | 32 ++ src/server/src/qml/qml/AlertDialog.qml | 8 +- src/server/src/qml/qml/ExtensionFormView.qml | 31 ++ src/server/src/qml/qml/FormTagPicker.qml | 289 ++++++++++++++++++ src/server/src/qml/qml/TagPickerTag.qml | 74 +++++ src/server/src/qml/view-utils.hpp | 17 ++ 17 files changed, 670 insertions(+), 16 deletions(-) create mode 100644 src/server/src/qml/form-tag-picker-suggestions-model.cpp create mode 100644 src/server/src/qml/form-tag-picker-suggestions-model.hpp create mode 100644 src/server/src/qml/qml/FormTagPicker.qml create mode 100644 src/server/src/qml/qml/TagPickerTag.qml diff --git a/.gitignore b/.gitignore index e47aedeaa..31b5d0207 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ __cmake* *.tgz /qt6 +**/.qmlls.ini /AppDir *AppImage diff --git a/CMakeLists.txt b/CMakeLists.txt index b78bf664a..9c3e81d42 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,7 @@ cmake_minimum_required(VERSION 3.16) set(PROJECT_NAME vicinae) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +set(QT_QML_GENERATE_QMLLS_INI ON) project(${PROJECT_NAME} VERSION 1.0.0 LANGUAGES C CXX) set(LIB_NAMESPACE "vicinae") diff --git a/flake.nix b/flake.nix index 6b4fb17a4..53ffd633e 100644 --- a/flake.nix +++ b/flake.nix @@ -68,10 +68,12 @@ export CXX=${pkgs.gcc15}/bin/g++ export CMAKE_C_COMPILER=$CC export CMAKE_CXX_COMPILER=$CXX + + export QML2_IMPORT_PATH=${pkgs.qt6.qtdeclarative}/lib/qt-6/qml + export QML_IMPORT_PATH=${pkgs.qt6.qtdeclarative}/lib/qt-6/qml ''; - }; - } - ); + }; + }); overlays.default = final: prev: { vicinae = final.callPackage ./nix/vicinae.nix { }; mkVicinaeExtension = prev.callPackage ./nix/mkVicinaeExtension.nix { }; diff --git a/src/server/CMakeLists.txt b/src/server/CMakeLists.txt index d8b4ad815..607ea0f35 100644 --- a/src/server/CMakeLists.txt +++ b/src/server/CMakeLists.txt @@ -500,6 +500,8 @@ set(SRCS src/qml/missing-preference-view-host.cpp src/qml/extension-form-model.hpp src/qml/extension-form-model.cpp + src/qml/form-tag-picker-suggestions-model.hpp + src/qml/form-tag-picker-suggestions-model.cpp src/qml/clipboard-history-model.hpp src/qml/clipboard-history-model.cpp src/qml/clipboard-history-view-host.hpp @@ -719,6 +721,7 @@ set(VICINAE_QML_FILES src/qml/qml/FormTextArea.qml src/qml/qml/FormCheckbox.qml src/qml/qml/FormSeparator.qml + src/qml/qml/FormTagPicker.qml src/qml/qml/SnippetFormView.qml src/qml/qml/AliasFormView.qml src/qml/qml/ShortcutFormView.qml @@ -772,6 +775,7 @@ set(VICINAE_QML_FILES src/qml/qml/MissingPreferenceView.qml src/qml/qml/OAuthOverlayView.qml src/qml/qml/HudWindow.qml + src/qml/qml/TagPickerTag.qml ) if(CMAKE_BUILD_TYPE MATCHES "Debug") diff --git a/src/server/include/extend/form-model.hpp b/src/server/include/extend/form-model.hpp index b0f208e4d..03d9437d3 100644 --- a/src/server/include/extend/form-model.hpp +++ b/src/server/include/extend/form-model.hpp @@ -2,6 +2,7 @@ #include "extend/action-model.hpp" #include "extend/list-model.hpp" #include "extend/model.hpp" +#include "extend/tag-model.hpp" #include #include #include @@ -97,6 +98,14 @@ struct FormModel { public: DatePickerField(const FieldBase &base) : IField(base) {} }; + + struct TagPickerField : public IField { + std::vector m_items; + + public: + TagPickerField(const FieldBase &base) : IField(base) {} + }; + struct InvalidField : public FieldBase {}; struct Separator {}; diff --git a/src/server/include/extend/tag-model.hpp b/src/server/include/extend/tag-model.hpp index 852d876e4..907c88bfe 100644 --- a/src/server/include/extend/tag-model.hpp +++ b/src/server/include/extend/tag-model.hpp @@ -1,6 +1,7 @@ #pragma once #include "extend/color-model.hpp" #include "extend/image-model.hpp" +#include "lib/fuzzy/fuzzy-searchable.hpp" #include #include @@ -24,3 +25,19 @@ class TagListParser { TagListModel parse(const QJsonObject &instance); }; + +struct TagPickerItemModel { + QString title; + QString value; + std::optional icon; + +public: + static TagPickerItemModel fromJson(const QJsonObject &instance); +}; + +template <> struct fuzzy::FuzzySearchable { + static int score(const TagPickerItemModel &item, std::string_view query) { + auto name = item.title.toStdString(); + return fuzzy::scoreWeighted({{name, 1.0}}, query); + } +}; diff --git a/src/server/src/extend/form-model.cpp b/src/server/src/extend/form-model.cpp index 505c4d748..20526bcb9 100644 --- a/src/server/src/extend/form-model.cpp +++ b/src/server/src/extend/form-model.cpp @@ -3,9 +3,9 @@ #include -const static std::vector fieldTypes = {"dropdown-field", "password-field", "text-field", - "checkbox-field", "date-picker-field", "text-area-field", - "file-picker-field"}; +const static std::vector fieldTypes = {"dropdown-field", "password-field", "text-field", + "checkbox-field", "date-picker-field", "text-area-field", + "file-picker-field", "tag-picker-field"}; FormModel FormModel::fromJson(const QJsonObject &json) { FormModel model; @@ -123,6 +123,15 @@ FormModel FormModel::fromJson(const QJsonObject &json) { filePicker->showHiddenFiles = props.value("showHiddenFiles").toBool(); model.items.emplace_back(filePicker); + } else if (*it == "tag-picker-field") { + auto tagPicker = std::make_shared(base); + tagPicker->m_items.reserve(children.size()); + + for (const auto &child : children) { + tagPicker->m_items.emplace_back(TagPickerItemModel::fromJson(child.toObject())); + } + + model.items.emplace_back(tagPicker); } } else { qWarning() << "Unknown form children of type" << type; diff --git a/src/server/src/extend/tag-list.cpp b/src/server/src/extend/tag-list.cpp index 4dfc4da20..3901527e0 100644 --- a/src/server/src/extend/tag-list.cpp +++ b/src/server/src/extend/tag-list.cpp @@ -1,8 +1,11 @@ #include "extend/color-model.hpp" #include "extend/image-model.hpp" #include "extend/tag-model.hpp" +#include #include #include +#include +#include TagListParser::TagListParser() = default; @@ -32,3 +35,15 @@ TagListModel TagListParser::parse(const QJsonObject &instance) { return model; } + +TagPickerItemModel TagPickerItemModel::fromJson(const QJsonObject &instance) { + TagPickerItemModel model; + auto props = instance.value("props").toObject(); + + model.title = props.value("title").toString(); + model.value = props.value("value").toString(); + + if (props.contains("icon")) { model.icon = ImageModelParser().parse(props.value("icon")); } + + return model; +} diff --git a/src/server/src/qml/extension-form-model.cpp b/src/server/src/qml/extension-form-model.cpp index 92cc1d154..cd6ac16ea 100644 --- a/src/server/src/qml/extension-form-model.cpp +++ b/src/server/src/qml/extension-form-model.cpp @@ -2,6 +2,9 @@ #include "view-utils.hpp" #include #include +#include +#include +#include #include ExtensionFormModel::ExtensionFormModel(NotifyFn notify, QObject *parent) @@ -54,7 +57,14 @@ void ExtensionFormModel::setFieldValue(int index, const QVariant &value) { auto &item = m_items[index]; if (!item.isField()) return; - item.userValue = QJsonValue::fromVariant(value); + // TODO: find out why Form.TagPicker tags would break without that + QVariant normalizedValue = value; + + if (value.metaType().id() == qMetaTypeId()) { + normalizedValue = value.value().toVariant(); + } + + item.userValue = QJsonValue::fromVariant(normalizedValue); item.hasUserValue = true; emit dataChanged(createIndex(index, 0), createIndex(index, 0), {ValueRole}); @@ -99,6 +109,24 @@ void ExtensionFormModel::setFilePaths(int index, const QVariantList &paths) { if (item.onChange) { m_notify(*item.onChange, QJsonArray{arr}); } } +void ExtensionFormModel::setPickedItems(int index, const QVariantList &pickedItems) { + if (index < 0 || std::cmp_greater_equal(index, m_items.size())) return; + auto &item = m_items[index]; + if (item.type != FormItemData::Type::TagPicker) return; + + QJsonArray arr; + for (const auto &t : pickedItems) { + arr.append(t.toString()); + } + + item.userValue = arr; + item.hasUserValue = true; + syncTagPickerSuggestionsModel(item); + emit dataChanged(createIndex(index, 0), createIndex(index, 0), {ValueRole}); + + if (item.onChange) { m_notify(*item.onChange, QJsonArray{arr}); } +} + void ExtensionFormModel::dropdownSearchTextChanged(int index, const QString &text) { if (index < 0 || std::cmp_greater_equal(index, m_items.size())) return; const auto &item = m_items[index]; @@ -106,6 +134,13 @@ void ExtensionFormModel::dropdownSearchTextChanged(int index, const QString &tex if (!handler.isEmpty()) { m_notify(handler, QJsonArray{text}); } } +void ExtensionFormModel::tagPickerSearchTextChanged(int index, const QString &text) { + if (index < 0 || std::cmp_greater_equal(index, m_items.size())) return; + auto &item = m_items[index]; + if (item.type != FormItemData::Type::TagPicker || !item.tagPickerSuggestionsModel) return; + item.tagPickerSuggestionsModel->setFilter(text); +} + std::optional ExtensionFormModel::filePickerOptions(int index) const { if (index < 0 || std::cmp_greater_equal(index, m_items.size())) return std::nullopt; const auto &item = m_items[index]; @@ -146,6 +181,7 @@ void ExtensionFormModel::setFormData(const FormModel &model) { if (it != savedValues.end()) { newData.userValue = it->second; newData.hasUserValue = true; + syncTagPickerSuggestionsModel(newData); } } m_items[i] = std::move(newData); @@ -162,6 +198,7 @@ void ExtensionFormModel::setFormData(const FormModel &model) { if (it != savedValues.end()) { newData.userValue = it->second; newData.hasUserValue = true; + syncTagPickerSuggestionsModel(newData); } } m_items.push_back(std::move(newData)); @@ -207,13 +244,14 @@ ExtensionFormModel::FormItemData ExtensionFormModel::createItem(const FormModel: data.onChange = field->onChange; data.onBlur = field->onBlur; data.onFocus = field->onFocus; - data.fieldData = buildFieldData(*field); if (field->value) { data.modelValue = *field->value; } else if (field->defaultValue) { data.modelValue = *field->defaultValue; } + + updateFieldData(data, *field); } else if (auto *desc = std::get_if(&item)) { data.type = FormItemData::Type::Description; data.label = desc->title.value_or(QString()); @@ -235,12 +273,13 @@ void ExtensionFormModel::updateItem(FormItemData &existing, const FormModel::Ite existing.onChange = field->onChange; existing.onBlur = field->onBlur; existing.onFocus = field->onFocus; - existing.fieldData = buildFieldData(*field); if (field->value) { existing.modelValue = *field->value; existing.hasUserValue = false; } + + updateFieldData(existing, *field); } else if (auto *desc = std::get_if(&newItem)) { existing.label = desc->title.value_or(QString()); existing.fieldData = {{"text", desc->text}}; @@ -255,10 +294,11 @@ ExtensionFormModel::FormItemData::Type ExtensionFormModel::fieldType(const FormM if (dynamic_cast(&field)) return FormItemData::Type::TextArea; if (dynamic_cast(&field)) return FormItemData::Type::FilePicker; if (dynamic_cast(&field)) return FormItemData::Type::DatePicker; + if (dynamic_cast(&field)) return FormItemData::Type::TagPicker; return FormItemData::Type::Text; } -QVariantMap ExtensionFormModel::buildFieldData(const FormModel::IField &field) { +void ExtensionFormModel::updateFieldData(FormItemData &item, const FormModel::IField &field) const { QVariantMap data; if (auto *f = dynamic_cast(&field)) { @@ -280,9 +320,35 @@ QVariantMap ExtensionFormModel::buildFieldData(const FormModel::IField &field) { if (f->min) data["min"] = *f->min; if (f->max) data["max"] = *f->max; data["includeTime"] = f->type.value_or("date") == "dateTime"; + } else if (auto *f = dynamic_cast(&field)) { + data["items"] = qml::convertTagPickerItems(f->m_items); + if (!item.tagPickerSuggestionsModel) { + item.tagPickerSuggestionsModel = std::make_unique(); + } + item.tagPickerSuggestionsModel->setSourceItems(f->m_items); + syncTagPickerSuggestionsModel(item); + data["suggestionsModel"] = + QVariant::fromValue(static_cast(item.tagPickerSuggestionsModel.get())); } - return data; + item.fieldData = std::move(data); +} + +void ExtensionFormModel::syncTagPickerSuggestionsModel(FormItemData &item) const { + if (!item.tagPickerSuggestionsModel) return; + item.tagPickerSuggestionsModel->setPickedItems(tagPickerPickedItems(item.effectiveValue())); +} + +QStringList ExtensionFormModel::tagPickerPickedItems(const QJsonValue &value) { + QStringList result; + if (!value.isArray()) return result; + + auto const arr = value.toArray(); + result.reserve(arr.size()); + for (const auto &entry : arr) { + if (entry.isString()) { result.append(entry.toString()); } + } + return result; } QString ExtensionFormModel::FormItemData::typeString() const { @@ -305,6 +371,8 @@ QString ExtensionFormModel::FormItemData::typeString() const { return QStringLiteral("description"); case Type::Separator: return QStringLiteral("separator"); + case Type::TagPicker: + return QStringLiteral("tagpicker"); } return QStringLiteral("text"); } diff --git a/src/server/src/qml/extension-form-model.hpp b/src/server/src/qml/extension-form-model.hpp index d5ee4ea09..7ebf7d6fc 100644 --- a/src/server/src/qml/extension-form-model.hpp +++ b/src/server/src/qml/extension-form-model.hpp @@ -1,5 +1,6 @@ #pragma once #include "extend/form-model.hpp" +#include "form-tag-picker-suggestions-model.hpp" #include "services/file-chooser/abstract-file-chooser.hpp" #include #include @@ -7,6 +8,8 @@ #include #include #include +#include +#include #include class ExtensionFormModel : public QAbstractListModel { @@ -40,7 +43,9 @@ class ExtensionFormModel : public QAbstractListModel { Q_INVOKABLE void fieldFocused(int index); Q_INVOKABLE void fieldBlurred(int index); Q_INVOKABLE void setFilePaths(int index, const QVariantList &paths); + Q_INVOKABLE void setPickedItems(int index, const QVariantList &pickedItems); Q_INVOKABLE void dropdownSearchTextChanged(int index, const QString &text); + Q_INVOKABLE void tagPickerSearchTextChanged(int index, const QString &text); std::optional filePickerOptions(int index) const; bool isExtensionControlled(int index) const; @@ -58,6 +63,7 @@ class ExtensionFormModel : public QAbstractListModel { TextArea, FilePicker, DatePicker, + TagPicker, Description, Separator }; @@ -79,6 +85,7 @@ class ExtensionFormModel : public QAbstractListModel { std::optional onBlur; std::optional onFocus; + std::unique_ptr tagPickerSuggestionsModel; QVariantMap fieldData; QJsonValue effectiveValue() const { return hasUserValue ? userValue : modelValue; } @@ -88,9 +95,11 @@ class ExtensionFormModel : public QAbstractListModel { FormItemData createItem(const FormModel::Item &item) const; void updateItem(FormItemData &existing, const FormModel::Item &newItem); + void updateFieldData(FormItemData &item, const FormModel::IField &field) const; + void syncTagPickerSuggestionsModel(FormItemData &item) const; + static QStringList tagPickerPickedItems(const QJsonValue &value); static FormItemData::Type fieldType(const FormModel::IField &field); - static QVariantMap buildFieldData(const FormModel::IField &field); NotifyFn m_notify; std::vector m_items; diff --git a/src/server/src/qml/form-tag-picker-suggestions-model.cpp b/src/server/src/qml/form-tag-picker-suggestions-model.cpp new file mode 100644 index 000000000..39b7b1ca5 --- /dev/null +++ b/src/server/src/qml/form-tag-picker-suggestions-model.cpp @@ -0,0 +1,76 @@ +#include "form-tag-picker-suggestions-model.hpp" +#include "image-url.hpp" +#include "view-utils.hpp" +#include + +void FormTagPickerSuggestionsModel::setSourceItems(std::vector items) { + m_items = std::move(items); + setSelectFirstOnReset(false); + applyFilter(); + setSelectFirstOnReset(true); +} + +void FormTagPickerSuggestionsModel::setPickedItems(const QStringList &pickedItems) { + if (m_pickedItems == pickedItems) return; + m_pickedItems = pickedItems; + applyFilter(); +} + +QVariant FormTagPickerSuggestionsModel::data(const QModelIndex &index, int role) const { + if (role == ValueRole) { return itemValueAt(index.row()); } + return FuzzyListModel::data(index, role); +} + +QHash FormTagPickerSuggestionsModel::roleNames() const { + auto roles = FuzzyListModel::roleNames(); + roles.insert(ValueRole, "value"); + return roles; +} + +QString FormTagPickerSuggestionsModel::itemValueAt(int row) const { + int section = 0; + int item = 0; + if (!dataItemAt(row, section, item)) return {}; + return filteredItem(item).value; +} + +void FormTagPickerSuggestionsModel::applyFilter() { + m_filtered.clear(); + m_filtered.reserve(m_items.size()); + + if (m_query.empty()) { + for (int i = 0; std::cmp_less(i, m_items.size()); ++i) { + if (m_pickedItems.contains(m_items[i].value)) continue; + m_filtered.push_back({.data = i, .score = 0}); + } + } else { + for (int i = 0; std::cmp_less(i, m_items.size()); ++i) { + if (m_pickedItems.contains(m_items[i].value)) continue; + + int const score = fuzzy::FuzzySearchable::score(m_items[i], m_query); + if (score > 0) { m_filtered.push_back({.data = i, .score = score}); } + } + + std::stable_sort(m_filtered.begin(), m_filtered.end(), std::greater{}); + } + + std::vector sections; + if (!m_filtered.empty()) { sections.push_back({.name = {}, .count = static_cast(m_filtered.size())}); } + commitSections(sections); +} + +QString FormTagPickerSuggestionsModel::displayTitle(const TagPickerItemModel &item) const { + return item.title; +} + +QString FormTagPickerSuggestionsModel::displayIconSource(const TagPickerItemModel &item) const { + if (!item.icon) return {}; + return qml::imageSourceFor(ImageURL(*item.icon)); +} + +QString FormTagPickerSuggestionsModel::itemId(int, int item) const { return filteredItem(item).value; } + +std::unique_ptr +FormTagPickerSuggestionsModel::buildActionPanel(const TagPickerItemModel &) const { + return nullptr; +} diff --git a/src/server/src/qml/form-tag-picker-suggestions-model.hpp b/src/server/src/qml/form-tag-picker-suggestions-model.hpp new file mode 100644 index 000000000..3ad08e394 --- /dev/null +++ b/src/server/src/qml/form-tag-picker-suggestions-model.hpp @@ -0,0 +1,32 @@ +#pragma once +#include "extend/tag-model.hpp" +#include "fuzzy-list-model.hpp" +#include +#include + +class FormTagPickerSuggestionsModel : public FuzzyListModel { + Q_OBJECT + +public: + enum Role { ValueRole = CommandListModel::Accessory + 1 }; + + using FuzzyListModel::FuzzyListModel; + + void setSourceItems(std::vector items); + void setPickedItems(const QStringList &pickedItems); + + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + Q_INVOKABLE QString itemValueAt(int row) const; + +protected: + void applyFilter() override; + QString displayTitle(const TagPickerItemModel &item) const override; + QString displayIconSource(const TagPickerItemModel &item) const override; + QString itemId(int section, int item) const override; + std::unique_ptr buildActionPanel(const TagPickerItemModel &item) const override; + +private: + QStringList m_pickedItems; +}; diff --git a/src/server/src/qml/qml/AlertDialog.qml b/src/server/src/qml/qml/AlertDialog.qml index 30bfb5721..aeda6e2b8 100644 --- a/src/server/src/qml/qml/AlertDialog.qml +++ b/src/server/src/qml/qml/AlertDialog.qml @@ -13,10 +13,10 @@ Popup { property bool _confirmed: false - onAboutToShow: { - _confirmed = false; - Qt.callLater(cancelBtn.forceActiveFocus); - } + onAboutToShow: { + _confirmed = false; + Qt.callLater(cancelBtn.forceActiveFocus); + } onClosed: { if (!_confirmed) launcher.alertModel.cancel(); diff --git a/src/server/src/qml/qml/ExtensionFormView.qml b/src/server/src/qml/qml/ExtensionFormView.qml index 4037577f0..2d4bfdd37 100644 --- a/src/server/src/qml/qml/ExtensionFormView.qml +++ b/src/server/src/qml/qml/ExtensionFormView.qml @@ -1,4 +1,6 @@ +pragma ComponentBehavior: Bound import QtQuick +import QtQuick.Controls import QtQuick.Layouts Item { @@ -92,6 +94,8 @@ Item { return filepickerFieldComp; case "datepicker": return datepickerFieldComp; + case "tagpicker": + return tagpickerFieldComp; case "description": return descriptionFieldComp; case "separator": @@ -302,6 +306,33 @@ Item { } } + Component { + id: tagpickerFieldComp + FormField { + id: field + label: parent.label + error: parent.error + info: parent.info + + function focusField() { + tagInput.forceActiveFocus(); + } + + readonly property var _fd: parent.fieldData || ({}) + + FormTagPicker { + id: tagInput + items: field._fd.items || [] + suggestionsModel: field._fd.suggestionsModel || null + pickedItems: field.parent.value + onTextEdited: root.formModel.tagPickerSearchTextChanged(field.parent.index, text) + onPickedItemsEdited: items => { + root.formModel.setPickedItems(field.parent.index, items); + } + } + } + } + Component { id: datepickerFieldComp FormField { diff --git a/src/server/src/qml/qml/FormTagPicker.qml b/src/server/src/qml/qml/FormTagPicker.qml new file mode 100644 index 000000000..82cb2bfdb --- /dev/null +++ b/src/server/src/qml/qml/FormTagPicker.qml @@ -0,0 +1,289 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +Item { + id: root + readonly property real _minHeight: 28 + readonly property real _maxHeight: 360 + readonly property real _contentPadding: 5 + readonly property real _contentHeight: flow.implicitHeight + _contentPadding * 2 + + implicitHeight: Math.max(_minHeight, Math.min(_contentHeight, _maxHeight)) + Layout.minimumHeight: _minHeight + Layout.maximumHeight: _maxHeight + Layout.preferredHeight: implicitHeight + Layout.fillWidth: true + activeFocusOnTab: true + + property var items: [] + property list pickedItems: [] + property var suggestionsModel: null + property var _pendingFocusRequest: null + property alias text: textInput.text + property alias cursorPosition: textInput.cursorPosition + + signal pickedItemsEdited(var items) + signal textEdited + + function forceActiveFocus() { + textInput.forceActiveFocus(); + } + function selectAll() { + textInput.selectAll(); + } + function _queueFocusAfterRemove(removedIndex) { + if (removedIndex > 0) { + _pendingFocusRequest = { + type: "chip", + chipId: root.pickedItems[removedIndex - 1] + }; + return; + } + + _pendingFocusRequest = { + type: "input" + }; + } + + function _applyPendingFocus() { + const request = _pendingFocusRequest; + _pendingFocusRequest = null; + if (!request) + return; + if (request.type === "input" || repeater.count === 0) { + textInput.forceActiveFocus(); + return; + } + + for (let i = 0; i < repeater.count; ++i) { + const chip = repeater.itemAt(i); + if (chip && chip.chipId === request.chipId) { + chip.forceActiveFocus(); + return; + } + } + + textInput.forceActiveFocus(); + } + + onActiveFocusChanged: { + if (activeFocus) + textInput.forceActiveFocus(); + } + + onPickedItemsChanged: { + if (_pendingFocusRequest !== null) { + Qt.callLater(root._applyPendingFocus); + } + } + + Rectangle { + id: border + anchors.fill: parent + radius: 8 + color: Qt.rgba(Theme.secondaryBackground.r, Theme.secondaryBackground.g, Theme.secondaryBackground.b, Config.windowOpacity) + border.color: textInput.activeFocus ? Theme.inputBorderFocus : Theme.inputBorder + border.width: 1 + + Flow { + id: flow + anchors { + fill: parent + margins: 5 + rightMargin: 10 + leftMargin: 10 + } + spacing: 4 + + Repeater { + id: repeater + anchors.verticalCenter: parent.verticalCenter + model: root.pickedItems + + TagPickerTag { + required property int index + required property string modelData + property var chip: items.find(item => item.id === modelData) || ({ + id: modelData, + displayName: modelData, + iconSource: "" + }) + + chipId: chip.id + text: chip.displayName + imageSource: chip.iconSource + + onRemoveRequested: { + const copy = root.pickedItems.slice(); + root._queueFocusAfterRemove(index); + copy.splice(index, 1); + root.pickedItemsEdited(copy); + } + + onFocusOnPreviousChipRequested: { + if (index > 0) { + repeater.itemAt(index - 1).forceActiveFocus(); + } else { + textInput.forceActiveFocus(); + } + } + + onFocusOnNextChipRequested: { + if (index < repeater.count - 1) { + repeater.itemAt(index + 1).forceActiveFocus(); + } else { + textInput.forceActiveFocus(); + } + } + } + } + + TextInput { + id: textInput + verticalAlignment: TextInput.AlignVCenter + font.pointSize: Theme.regularFontSize + color: Theme.foreground + selectionColor: Theme.textSelectionBg + selectedTextColor: Theme.textSelectionFg + clip: true + height: Math.max(contentHeight, 28) + width: Math.min(Math.max(contentWidth + 4, 80), flow.width) + + onTextEdited: { + root.textEdited(); + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + // handling via the usual `Qt.NoModifier` messes with keypad Enter + let anySignificantModifierPressed = Qt.ShiftModifier | Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier; + if (event.modifier & anySignificantModifierPressed) { + console.log("REFUSED: Ctrl, Alt, Super, or Shift are pressed"); + event.accepted = false; + return; + } + if (textInput.text.length == 0 || !root.suggestionsModel) { + event.accepted = false; + return; + } + var copy = pickedItems.slice(); + var selectedId = root.suggestionsModel.itemValueAt(0); + if (!selectedId) { + event.accepted = false; + return; + } + if (copy.includes(selectedId)) { + event.accepted = false; + return; + } + + copy.push(selectedId); + pickedItemsEdited(copy); + textInput.text = ""; + root.textEdited(); + event.accepted = true; + } else if (event.key === Qt.Key_Backspace && text === "" && root.pickedItems.length > 0) { + repeater.itemAt(root.pickedItems.length - 1).forceActiveFocus(); + event.accepted = true; + } else if (event.key === Qt.Key_Left && cursorPosition === 0 && root.pickedItems.length > 0) { + repeater.itemAt(root.pickedItems.length - 1).forceActiveFocus(); + event.accepted = true; + } + } + } + } + } + + Popup { + id: fuzzySearchPopup + parent: border + closePolicy: Popup.NoAutoClose + visible: textInput.text.length > 0 && fuzzyResultList.count > 0 + + x: 0 + y: border.height + 4 + width: border.width + height: Math.min(fuzzyResultList.count * 30, 180) + + focus: false + modal: false + Keys.enabled: false + + background: Rectangle { + radius: 8 + color: Theme.background + border { + color: Theme.divider + width: 1 + } + } + + contentItem: ListView { + id: fuzzyResultList + spacing: 4 + anchors.fill: parent + model: root.suggestionsModel + clip: true + reuseItems: true + boundsBehavior: Flickable.StopAtBounds + + delegate: Item { + width: fuzzyResultList.width + height: 30 + + Rectangle { + anchors.fill: parent + anchors.leftMargin: 2 + anchors.rightMargin: 2 + radius: 6 + color: itemHover.hovered ? Theme.listItemHoverBg : "transparent" + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 4 + + Loader { + active: iconSource !== undefined && iconSource !== "" + Layout.alignment: Qt.AlignVCenter + + sourceComponent: Component { + ViciImage { + width: 20 + height: 20 + source: iconSource + } + } + } + + Text { + text: title + color: Theme.foreground + font.pointSize: Theme.smallerFontSize + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + HoverHandler { + id: itemHover + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: { + var copy = pickedItems.slice(); + copy.push(value); + pickedItemsEdited(copy); + textInput.text = ""; + root.textEdited(); + } + } + } + } + } +} diff --git a/src/server/src/qml/qml/TagPickerTag.qml b/src/server/src/qml/qml/TagPickerTag.qml new file mode 100644 index 000000000..f17ea3ee7 --- /dev/null +++ b/src/server/src/qml/qml/TagPickerTag.qml @@ -0,0 +1,74 @@ +import QtQuick + +Rectangle { + id: chip + + property string chipId + property string text: "" + property var imageSource + + signal removeRequested + signal focusOnPreviousChipRequested + signal focusOnNextChipRequested + + width: row.width + 16 + height: 28 + radius: 4 + color: Qt.rgba(Theme.listItemSelectionBg.r, Theme.listItemSelectionBg.g, Theme.listItemSelectionBg.b, Config.windowOpacity) + border.color: activeFocus ? Theme.inputBorderFocus : "transparent" + border.width: 1 + + activeFocusOnTab: true + + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace || event.key === Qt.Key_Enter || event.key === Qt.Key_Return) { + removeRequested(); + event.accepted = true; + } else if (event.key === Qt.Key_Left) { + focusOnPreviousChipRequested(); + event.accepted = true; + } else if (event.key === Qt.Key_Right) { + focusOnNextChipRequested(); + event.accepted = true; + } + } + + Row { + id: row + anchors.centerIn: parent + spacing: 6 + + Row { + Loader { + id: imageLoader + active: chip.imageSource !== undefined && chip.imageSource !== "" + anchors.verticalCenter: parent.verticalCenter + + sourceComponent: Component { + ViciImage { + anchors.verticalCenter: parent.verticalCenter + width: 20 + height: 20 + source: chip.imageSource + } + } + } + Text { + text: chip.text + anchors.verticalCenter: parent.verticalCenter + color: Theme.foreground + } + } + ViciImage { + source: Img.builtin("xmark") + height: 10 + width: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + anchors.fill: parent + onClicked: chip.removeRequested() + } +} diff --git a/src/server/src/qml/view-utils.hpp b/src/server/src/qml/view-utils.hpp index 7f74a1ce4..b10f94d0f 100644 --- a/src/server/src/qml/view-utils.hpp +++ b/src/server/src/qml/view-utils.hpp @@ -2,12 +2,14 @@ #include "extend/accessory-model.hpp" #include "extend/dropdown-model.hpp" #include "extend/metadata-model.hpp" +#include "extend/tag-model.hpp" #include "image-url.hpp" #include "ui/image/url.hpp" #include #include #include #include +#include #include namespace qml { @@ -85,4 +87,19 @@ inline QVariantList convertDropdownChildren(const std::vector &items) { + QVariantList result; + + for (const auto &item : items) { + QVariantMap m; + + m["id"] = item.value; + m["displayName"] = item.title; + m["iconSource"] = item.icon ? imageSourceFor(ImageURL(*item.icon)) : QString(); + result.append(m); + } + + return result; +} + } // namespace qml