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;
+};
+}