diff --git a/src/framework/shortcuts/qml/Muse/Shortcuts/internal/ShortcutsList.qml b/src/framework/shortcuts/qml/Muse/Shortcuts/internal/ShortcutsList.qml index df58fa01246bf..86130f9959458 100644 --- a/src/framework/shortcuts/qml/Muse/Shortcuts/internal/ShortcutsList.qml +++ b/src/framework/shortcuts/qml/Muse/Shortcuts/internal/ShortcutsList.qml @@ -42,22 +42,13 @@ ValueList { signal startEditCurrentShortcutRequested() - model: SortFilterProxyModel { + model: FuzzySortFilterProxyModel { id: filterModel - sourceModel: root.sourceModel - filters: [ - FilterValue { - roleName: "searchKey" - roleValue: root.searchText - compareType: CompareType.Contains - }, - FilterValue { - roleName: "title" - roleValue: "" - compareType: CompareType.NotEqual - } - ] + sourceModel: root.sourceModel + filterCaseSensitivity: Qt.CaseInsensitive + filterRoleName: "title" + fuzzyPattern: root.searchText } onHandleItem: { diff --git a/src/framework/shortcuts/qml/Muse/Shortcuts/shortcutsmodel.cpp b/src/framework/shortcuts/qml/Muse/Shortcuts/shortcutsmodel.cpp index 05c62c7a4be55..a201e22947e6e 100644 --- a/src/framework/shortcuts/qml/Muse/Shortcuts/shortcutsmodel.cpp +++ b/src/framework/shortcuts/qml/Muse/Shortcuts/shortcutsmodel.cpp @@ -108,6 +108,9 @@ void ShortcutsModel::load() if (action.scCtx == CTX_DISABLED) { continue; } + if (action.description.isEmpty() && action.title.isEmpty()) { + continue; + } Shortcut shortcut = shortcutsRegister()->shortcut(action.code); if (!shortcut.isValid()) { diff --git a/src/framework/uicomponents/qml/Muse/UiComponents/CMakeLists.txt b/src/framework/uicomponents/qml/Muse/UiComponents/CMakeLists.txt index 6877c2434089f..f7e19ce8a1842 100644 --- a/src/framework/uicomponents/qml/Muse/UiComponents/CMakeLists.txt +++ b/src/framework/uicomponents/qml/Muse/UiComponents/CMakeLists.txt @@ -44,6 +44,8 @@ qt_add_qml_module(muse_uicomponents_qml filteredflyoutmodel.h filtervalue.cpp filtervalue.h + fuzzysortfilterproxymodel.cpp + fuzzysortfilterproxymodel.h iconview.cpp iconview.h internal/focuslistener.cpp diff --git a/src/framework/uicomponents/qml/Muse/UiComponents/fuzzysortfilterproxymodel.cpp b/src/framework/uicomponents/qml/Muse/UiComponents/fuzzysortfilterproxymodel.cpp new file mode 100644 index 0000000000000..e4e83d780986f --- /dev/null +++ b/src/framework/uicomponents/qml/Muse/UiComponents/fuzzysortfilterproxymodel.cpp @@ -0,0 +1,274 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-CLA-applies + * + * MuseScore + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "fuzzysortfilterproxymodel.h" + +#include +#include + +#include +#include + +using namespace Qt::StringLiterals; + +namespace muse::uicomponents { +namespace { +struct FuzzyMatch { + size_t startPos = 0; + size_t endPos = 0; + size_t editDistance = 0; +}; + +FuzzyMatch fuzzyFindBestMatch(const std::u32string_view text, const std::u32string_view pattern) +{ + // The two-dimensional table holds edit distances (Damerau-Levenshtein (OSA)) + // between prefixes of pattern and text, such that distances[i,j] + // contains the distance between pattern[0:i] and text[0:j]. + const size_t numTextPrefixes = text.size() + 1; + const size_t numPatternPrefixes = pattern.size() + 1; + std::vector distances(numTextPrefixes * numPatternPrefixes); + + for (size_t patternEnd = 1; patternEnd < numPatternPrefixes; ++patternEnd) { + // patternEnd deletions to transform pattern[0:patternEnd] to text[0:0] + distances[numTextPrefixes * patternEnd] = patternEnd; + } + // 0 insertions to transform the empty pattern prefix to any text prefix. + // This allows the match to start at any position in text. + + for (size_t patternEnd = 1; patternEnd < numPatternPrefixes; ++patternEnd) { + const size_t patternOffset = numTextPrefixes * patternEnd; + const size_t prevPatternOffset = numTextPrefixes * (patternEnd - 1); + + for (size_t textEnd = 1; textEnd < numTextPrefixes; ++textEnd) { + if (pattern[patternEnd - 1] == text[textEnd - 1]) { + distances[patternOffset + textEnd] = distances[prevPatternOffset + textEnd - 1]; + } else { + const size_t replaceDistance = distances[prevPatternOffset + textEnd - 1]; + const size_t deleteDistance = distances[prevPatternOffset + textEnd]; + const size_t insertDistance = distances[patternOffset + textEnd - 1]; + const size_t minDistance = std::min(replaceDistance, std::min(deleteDistance, insertDistance)); + distances[patternOffset + textEnd] = minDistance + 1; + + if (patternEnd > 1 && textEnd > 1 + && pattern[patternEnd - 1] == text[textEnd - 2] + && pattern[patternEnd - 2] == text[textEnd - 1]) { + const size_t transpositionDistance = 1 + distances[patternOffset - 2 + textEnd - 2]; + distances[patternOffset + textEnd] = std::min(transpositionDistance, distances[patternOffset + textEnd]); + } + } + } + } + + // the text end position of the best match is the column index + // of the lowest edit distance in the last row of the table + const size_t patternOffset = numTextPrefixes * pattern.size(); + size_t matchTextEnd = 0; + size_t matchDistance = distances[patternOffset]; + for (size_t textEnd = 1; textEnd < numTextPrefixes; ++textEnd) { + const size_t distance = distances[patternOffset + textEnd]; + if (distance < matchDistance) { + matchDistance = distance; + matchTextEnd = textEnd; + } + } + + // reconstruct an edit sequence to determine match start position + size_t patternEnd = pattern.size(); + size_t matchTextStart = matchTextEnd; + while (patternEnd > 0 && matchTextStart > 0) { + const size_t replaceDistance = distances[numTextPrefixes * (patternEnd - 1) + matchTextStart - 1]; + const size_t deleteDistance = distances[numTextPrefixes * (patternEnd - 1) + matchTextStart]; + const size_t insertDistance = distances[numTextPrefixes * patternEnd + matchTextStart - 1]; + const size_t minDistance = std::min(replaceDistance, std::min(deleteDistance, insertDistance)); + if (minDistance == insertDistance) { + --matchTextStart; + } else if (minDistance == replaceDistance) { + --patternEnd; + --matchTextStart; + } else { + --patternEnd; + } + } + + return FuzzyMatch{ matchTextStart, matchTextEnd, matchDistance }; +} + +void split(std::vector& out, const std::u32string_view str, const std::u32string_view delim) +{ + const std::size_t delimLen = delim.length(); + std::size_t previous = 0; + std::size_t current = str.find(delim); + + while (current != std::u32string::npos) { + out.emplace_back(str.substr(previous, current - previous)); + previous = current + delimLen; + current = str.find(delim, previous); + } + + out.emplace_back(str.substr(previous, current - previous)); +} +} + +FuzzySortFilterProxyModel::FuzzySortFilterProxyModel(QObject* parent) + : QSortFilterProxyModel(parent), m_filterRoleName(u"display"_s) +{ + bindableFilterRole().setBinding([this] { + return roleNames().key(m_filterRoleName.value().toUtf8(), -1); + }); + connect(this, &QAbstractItemModel::modelAboutToBeReset, this, &FuzzySortFilterProxyModel::clearScoreCache); + connect(this, &QSortFilterProxyModel::filterCaseSensitivityChanged, this, &FuzzySortFilterProxyModel::clearScoreCache); + connect(this, &QSortFilterProxyModel::filterRoleChanged, this, &FuzzySortFilterProxyModel::clearScoreCache); +} + +QString FuzzySortFilterProxyModel::fuzzyPattern() const +{ + return m_fuzzyPattern; +} + +void FuzzySortFilterProxyModel::setFuzzyPattern(const QString pattern) +{ + const QString simplifiedPattern = pattern.simplified(); + if (m_fuzzyPattern == simplifiedPattern) { + return; + } + + m_fuzzyPattern = simplifiedPattern; + + m_patternTokens.clear(); + const std::u32string caseAdjustedPattern = filterCaseSensitivity() == Qt::CaseInsensitive + ? m_fuzzyPattern.toLower().toStdU32String() + : m_fuzzyPattern.toStdU32String(); + split(m_patternTokens, caseAdjustedPattern, U" "); + + clearScoreCache(); + invalidate(); + + if (m_fuzzyPattern.isEmpty()) { + sort(-1, Qt::AscendingOrder); + } else { + sort(filterKeyColumn(), Qt::DescendingOrder); + } + + emit fuzzyPatternChanged(m_fuzzyPattern); +} + +QString FuzzySortFilterProxyModel::filterRoleName() const +{ + return m_filterRoleName; +} + +void FuzzySortFilterProxyModel::setFilterRoleName(const QString roleName) +{ + m_filterRoleName = roleName; +} + +QBindable FuzzySortFilterProxyModel::bindableFilterRoleName() +{ + return &m_filterRoleName; +} + +bool FuzzySortFilterProxyModel::filterAcceptsRow(const int sourceRow, const QModelIndex& sourceParent) const +{ + if (m_fuzzyPattern.isEmpty()) { + return true; + } + + const QModelIndex sourceIndex = sourceModel()->index(sourceRow, filterKeyColumn(), sourceParent); + const std::optional score = getScore(sourceIndex); + + return score.has_value(); +} + +bool FuzzySortFilterProxyModel::lessThan(const QModelIndex& sourceLeft, const QModelIndex& sourceRight) const +{ + if (m_fuzzyPattern.isEmpty()) { + return sourceLeft < sourceRight; + } + + const std::optional leftScore = getScore(sourceLeft); + const std::optional rightScore = getScore(sourceRight); + + return leftScore < rightScore; +} + +std::optional FuzzySortFilterProxyModel::getScore(const QPersistentModelIndex& sourceIndex) const +{ + auto scoreIt = m_scoreCache.find(sourceIndex); + if (scoreIt != m_scoreCache.end()) { + return scoreIt.value(); + } + + std::optional score = calcScore(sourceIndex); + m_scoreCache.try_emplace(sourceIndex, score); + + return score; +} + +void FuzzySortFilterProxyModel::clearScoreCache() const +{ + m_scoreCache.clear(); +} + +std::optional FuzzySortFilterProxyModel::calcScore(const QModelIndex& sourceIndex) const +{ + const QString text = sourceModel()->data(sourceIndex, filterRole()) + .toString(); + const std::u32string caseAdjustedText = filterCaseSensitivity() == Qt::CaseInsensitive + ? text.toLower().toStdU32String() + : text.toStdU32String(); + + double score = 0.0; + for (const auto& patternToken : m_patternTokens) { + const size_t tokenSize = patternToken.size(); + if (tokenSize == 0) { + continue; + } + const double inverseTokenSize = 1.0 / tokenSize; + + // allow one additional error every 8 characters + const size_t maxDistance = tokenSize > 1 + ? tokenSize / 8 + 1 + : 0; + const FuzzyMatch bestMatch = fuzzyFindBestMatch(caseAdjustedText, patternToken); + if (bestMatch.editDistance > maxDistance) { + return std::nullopt; + } + + const double matchSimilarity = 1.0 - (bestMatch.editDistance * inverseTokenSize); + score += 5.0 * matchSimilarity; + + const bool isMatchStartAtStartOfWord = bestMatch.startPos == 0 + || caseAdjustedText[bestMatch.startPos - 1] == U' '; + if (isMatchStartAtStartOfWord) { + const bool isMatchEndAtEndOfWord = bestMatch.endPos == caseAdjustedText.size() + || caseAdjustedText[bestMatch.endPos] == U' '; + if (isMatchEndAtEndOfWord) { + score += 2.0 * inverseTokenSize; + } else { + score += inverseTokenSize; + } + } + } + + return score; +} +} diff --git a/src/framework/uicomponents/qml/Muse/UiComponents/fuzzysortfilterproxymodel.h b/src/framework/uicomponents/qml/Muse/UiComponents/fuzzysortfilterproxymodel.h new file mode 100644 index 0000000000000..ebf12a5c56503 --- /dev/null +++ b/src/framework/uicomponents/qml/Muse/UiComponents/fuzzysortfilterproxymodel.h @@ -0,0 +1,75 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-CLA-applies + * + * MuseScore + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +namespace muse::uicomponents { +// filters rows by a fuzzy pattern and sorts by similarity +class FuzzySortFilterProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT + Q_PROPERTY(QString fuzzyPattern READ fuzzyPattern WRITE setFuzzyPattern NOTIFY fuzzyPatternChanged) + Q_PROPERTY(QString filterRoleName READ filterRoleName WRITE setFilterRoleName BINDABLE bindableFilterRoleName) + + QML_ELEMENT + +public: + explicit FuzzySortFilterProxyModel(QObject* parent = nullptr); + + QString fuzzyPattern() const; + void setFuzzyPattern(QString); + + QString filterRoleName() const; + void setFilterRoleName(QString); + QBindable bindableFilterRoleName(); + +signals: + void fuzzyPatternChanged(QString); + +protected: + bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override; + bool lessThan(const QModelIndex& sourceLeft, const QModelIndex& sourceRight) const override; + +private: + std::optional getScore(const QPersistentModelIndex& sourceIndex) const; + void clearScoreCache() const; + std::optional calcScore(const QModelIndex& sourceIndex) const; + + QString m_fuzzyPattern; + Q_OBJECT_BINDABLE_PROPERTY(FuzzySortFilterProxyModel, QString, m_filterRoleName); + + std::vector m_patternTokens; + mutable QHash > m_scoreCache; +}; +}