diff --git a/framework/extensions/qml/Muse/Extensions/internal/ExtensionsListView.qml b/framework/extensions/qml/Muse/Extensions/internal/ExtensionsListView.qml index af7f7b9f6f..b83b255141 100644 --- a/framework/extensions/qml/Muse/Extensions/internal/ExtensionsListView.qml +++ b/framework/extensions/qml/Muse/Extensions/internal/ExtensionsListView.qml @@ -65,10 +65,12 @@ Column { id: filterModel filters: [ - FilterValue { + FuzzyFilter { + id: fuzzyFilter + + enabled: Boolean(fuzzyPattern) + fuzzyPattern: root.search roleName: "name" - roleValue: root.search - compareType: CompareType.Contains }, FilterValue { roleName: "enabled" @@ -81,6 +83,12 @@ Column { compareType: CompareType.Contains } ] + sorters: [ + FuzzyScoreSorter { + enabled: fuzzyFilter.enabled + fuzzyFilter: fuzzyFilter + } + ] } StyledTextLabel { diff --git a/framework/learn/qml/Muse/Learn/LearnPage.qml b/framework/learn/qml/Muse/Learn/LearnPage.qml index 61e8c527d4..7021c2f72b 100644 --- a/framework/learn/qml/Muse/Learn/LearnPage.qml +++ b/framework/learn/qml/Muse/Learn/LearnPage.qml @@ -209,7 +209,6 @@ FocusScope { enabled: Boolean(fuzzyPattern) fuzzyPattern: searchField.searchText roleName: "searchKey" - caseSensitivity: Qt.CaseInsensitive } ] diff --git a/framework/shortcuts/qml/Muse/Shortcuts/internal/ShortcutsList.qml b/framework/shortcuts/qml/Muse/Shortcuts/internal/ShortcutsList.qml index fb87612cb2..34eabcf37e 100644 --- a/framework/shortcuts/qml/Muse/Shortcuts/internal/ShortcutsList.qml +++ b/framework/shortcuts/qml/Muse/Shortcuts/internal/ShortcutsList.qml @@ -58,7 +58,6 @@ ValueList { enabled: Boolean(fuzzyPattern) fuzzyPattern: root.searchText roleName: "searchKey" - caseSensitivity: Qt.CaseInsensitive } ] sorters: [ diff --git a/framework/shortcuts_v2/qml/Muse/Shortcuts/internal/ShortcutsList.qml b/framework/shortcuts_v2/qml/Muse/Shortcuts/internal/ShortcutsList.qml index c8d479d57a..25edf96860 100644 --- a/framework/shortcuts_v2/qml/Muse/Shortcuts/internal/ShortcutsList.qml +++ b/framework/shortcuts_v2/qml/Muse/Shortcuts/internal/ShortcutsList.qml @@ -78,7 +78,6 @@ ValueList { enabled: Boolean(fuzzyPattern) fuzzyPattern: root.searchText roleName: "searchKey" - caseSensitivity: Qt.CaseInsensitive } ] sorters: [ diff --git a/framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.cpp b/framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.cpp index 4ba466a3f0..05ef45ac75 100644 --- a/framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.cpp +++ b/framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.cpp @@ -22,8 +22,7 @@ #include "fuzzyfilter.h" -#include -#include +#include #include "sortfilterproxymodel.h" @@ -36,7 +35,7 @@ FuzzyFilter::FuzzyFilter(QObject* parent) bool FuzzyFilter::acceptsRow(int sourceRow, const QModelIndex& sourceParent, const SortFilterProxyModel& proxyModel) { const QModelIndex sourceIndex = proxyModel.sourceModel()->index(sourceRow, 0, sourceParent); - const std::optional score = getScore(sourceIndex, proxyModel); + const std::optional score = getOrCalcScore(sourceIndex, proxyModel); return score.has_value(); } @@ -83,31 +82,36 @@ void FuzzyFilter::setRoleName(const QString& roleName) emit dataChanged(); } -Qt::CaseSensitivity FuzzyFilter::caseSensitivity() const +std::optional FuzzyFilter::getScore(const QModelIndex& sourceIndex) const { - return m_caseSensitivity; + const auto scoreIt = m_scoreCache.find(sourceIndex); + if (scoreIt != m_scoreCache.end()) { + return scoreIt.value(); + } + + return std::nullopt; } -void FuzzyFilter::setCaseSensitivity(const Qt::CaseSensitivity caseSensitivity) +void FuzzyFilter::compilePattern() { - if (m_caseSensitivity == caseSensitivity) { - return; - } + clearScoreCache(); - m_caseSensitivity = caseSensitivity; - compilePattern(); - emit caseSensitivityChanged(); - emit dataChanged(); + const QStringList tokens = m_fuzzyPattern.toLower().split(u' '); + + m_patternTokens.clear(); + m_patternTokens.reserve(tokens.size()); + for (const auto& token : tokens) { + m_patternTokens.push_back(token.toStdU32String()); + } } -std::optional FuzzyFilter::getScore(const QPersistentModelIndex& sourceIndex, const SortFilterProxyModel& proxyModel) +std::optional FuzzyFilter::getOrCalcScore(const QModelIndex& sourceIndex, const SortFilterProxyModel& proxyModel) { - auto scoreIt = m_scoreCache.find(sourceIndex); - if (scoreIt != m_scoreCache.end()) { - return scoreIt.value(); + if (const std::optional score = getScore(sourceIndex)) { + return score; } - std::optional score = calcScore(sourceIndex, proxyModel); + const std::optional score = calcScore(sourceIndex, proxyModel); // don't cache score of filtered out items because the cache // is always reset before filtering and therefore only used for sorting // already filtered items @@ -120,22 +124,6 @@ std::optional FuzzyFilter::getScore(const QPersistentModelIndex& sourceI return score; } -void FuzzyFilter::compilePattern() -{ - clearScoreCache(); - - m_patternTokens.clear(); - - const QString caseAdjustedPattern = caseSensitivity() == Qt::CaseInsensitive - ? m_fuzzyPattern.toLower() - : m_fuzzyPattern; - - const QStringList tokens = caseAdjustedPattern.split(u' '); - std::transform(tokens.begin(), tokens.end(), std::back_inserter(m_patternTokens), [](const QString& token) { - return token.toStdU32String(); - }); -} - std::optional FuzzyFilter::calcScore(const QModelIndex& sourceIndex, const SortFilterProxyModel& proxyModel) { const int role = proxyModel.roleIdFromName(m_roleName); @@ -145,9 +133,7 @@ std::optional FuzzyFilter::calcScore(const QModelIndex& sourceIndex, con const QString rawText = proxyModel.sourceModel()->data(sourceIndex, role) .toString(); - const std::u32string text = caseSensitivity() == Qt::CaseInsensitive - ? rawText.toLower().toStdU32String() - : rawText.toStdU32String(); + const std::u32string text = rawText.toLower().toStdU32String(); double score = 0.0; for (const auto& patternToken : m_patternTokens) { @@ -162,36 +148,44 @@ std::optional FuzzyFilter::calcScore(const QModelIndex& sourceIndex, con ? 1 + (tokenSize / CHARS_PER_ERROR) : 0; - const double inverseTokenSize = 1.0 / tokenSize; - std::optional bestTokenScore; - + const double perCharScore = 1.0 / tokenSize; + std::optional tokenScore; for (const auto& match : m_matcher(text, patternToken, maxDistance)) { - const double matchSimilarity = 1.0 - (match.editDistance * inverseTokenSize); - double matchScore = 5.0 * matchSimilarity; - - const bool isMatchStartAtStartOfWord = match.beginPos == 0 - || text[match.beginPos - 1] == U' '; - if (isMatchStartAtStartOfWord) { - const bool isMatchEndAtEndOfWord = match.endPos == text.size() - || text[match.endPos] == U' '; - if (isMatchEndAtEndOfWord) { - matchScore += 2.0 * inverseTokenSize; - } else { - matchScore += inverseTokenSize; + const double matchSimilarity = 1.0 - (match.editDistance * perCharScore); + + const double scoreBonus = [&] { + const bool isFullMatch = (match.endPos - match.beginPos) == text.size(); + if (isFullMatch) { + return 3.0 * perCharScore; } - } - if (bestTokenScore < matchScore) { - bestTokenScore = matchScore; + const bool isMatchStartAtStartOfWord = match.beginPos == 0 + || !QChar::isLetter(text[match.beginPos - 1]); + if (isMatchStartAtStartOfWord) { + const bool isMatchEndAtEndOfWord = match.endPos == text.size() + || !QChar::isLetter(text[match.endPos]); + if (isMatchEndAtEndOfWord) { + return 2.0 * perCharScore; + } else { + return perCharScore; + } + } + + return 0.0; + }(); + + const double matchScore = 5.0 * matchSimilarity + scoreBonus; + if (tokenScore < matchScore) { + tokenScore = matchScore; } } // no match for token found -> no score for entire pattern - if (!bestTokenScore) { - return bestTokenScore; + if (!tokenScore) { + return std::nullopt; } - score += *bestTokenScore; + score += *tokenScore; } return score; diff --git a/framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.h b/framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.h index e498df71a4..c726c180ef 100644 --- a/framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.h +++ b/framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.h @@ -43,7 +43,6 @@ class FuzzyFilter : public Filter Q_PROPERTY(QString fuzzyPattern READ fuzzyPattern WRITE setFuzzyPattern NOTIFY fuzzyPatternChanged) Q_PROPERTY(QString roleName READ roleName WRITE setRoleName NOTIFY roleNameChanged) - Q_PROPERTY(Qt::CaseSensitivity caseSensitivity READ caseSensitivity WRITE setCaseSensitivity NOTIFY caseSensitivityChanged) public: explicit FuzzyFilter(QObject* parent = nullptr); @@ -57,25 +56,21 @@ class FuzzyFilter : public Filter QString roleName() const; void setRoleName(const QString&); - Qt::CaseSensitivity caseSensitivity() const; - void setCaseSensitivity(Qt::CaseSensitivity); - - std::optional getScore(const QPersistentModelIndex& sourceIndex, const SortFilterProxyModel&); + std::optional getScore(const QModelIndex& sourceIndex) const; signals: void fuzzyPatternChanged(); void roleNameChanged(); - void caseSensitivityChanged(); private: void compilePattern(); + std::optional getOrCalcScore(const QModelIndex& sourceIndex, const SortFilterProxyModel&); std::optional calcScore(const QModelIndex& sourceIndex, const SortFilterProxyModel&); void clearScoreCache(); QString m_fuzzyPattern; QString m_roleName; - Qt::CaseSensitivity m_caseSensitivity = Qt::CaseSensitive; std::vector m_patternTokens; FuzzyMatcher m_matcher; diff --git a/framework/uicomponents/qml/Muse/UiComponents/fuzzyscoresorter.cpp b/framework/uicomponents/qml/Muse/UiComponents/fuzzyscoresorter.cpp index 055ca7805b..b468231b79 100644 --- a/framework/uicomponents/qml/Muse/UiComponents/fuzzyscoresorter.cpp +++ b/framework/uicomponents/qml/Muse/UiComponents/fuzzyscoresorter.cpp @@ -22,11 +22,33 @@ #include "fuzzyscoresorter.h" +#include "sortfilterproxymodel.h" + +#include + namespace muse::uicomponents { +namespace { +std::optional getMaxScoreFromChildren(const FuzzyFilter& fuzzyFilter, const QAbstractItemModel& model, + const QModelIndex& parentIndex) +{ + const int childRowCount = model.rowCount(parentIndex); + + std::optional maxChildScore; + for (int i = 0; i < childRowCount; ++i) { + const QModelIndex childIndex = model.index(i, 0, parentIndex); + const std::optional maxGrandChildrenScore = getMaxScoreFromChildren(fuzzyFilter, model, childIndex); + const std::optional childScore = std::max(maxGrandChildrenScore, fuzzyFilter.getScore(childIndex)); + + maxChildScore = std::max(maxChildScore, childScore); + } + + return maxChildScore; +} +} + FuzzyScoreSorter::FuzzyScoreSorter(QObject* parent) : Sorter(parent) { - setSortOrder(Qt::DescendingOrder); } bool FuzzyScoreSorter::lessThan(const QModelIndex& sourceLeft, const QModelIndex& sourceRight, @@ -36,10 +58,38 @@ bool FuzzyScoreSorter::lessThan(const QModelIndex& sourceLeft, const QModelIndex return sourceLeft < sourceRight; } - const std::optional leftScore = m_fuzzyFilter->getScore(sourceLeft, proxyModel); - const std::optional rightScore = m_fuzzyFilter->getScore(sourceRight, proxyModel); + // don't sort children when their parent has a score + // checking for one parent is enough. left and right always have the same parent + if (const QModelIndex leftParent = sourceLeft.parent(); leftParent.isValid()) { + if (m_fuzzyFilter->getScore(leftParent)) { + return sourceLeft < sourceRight; + } + } + + const std::optional leftScore = m_fuzzyFilter->getScore(sourceLeft); + const std::optional rightScore = m_fuzzyFilter->getScore(sourceRight); + + if (!proxyModel.isRecursiveFilteringEnabled()) { + return leftScore > rightScore; + } + + // prefer parents that match over parents with only children that match + if (leftScore && !rightScore) { + return true; + } + + if (!leftScore && rightScore) { + return false; + } + + // rank parent items according to their own score or the highest child score, whichever is higher + + const QAbstractItemModel& srcModel = *proxyModel.sourceModel(); + + const std::optional maxLeftChildScore = getMaxScoreFromChildren(*m_fuzzyFilter, srcModel, sourceLeft); + const std::optional maxRightChildScore = getMaxScoreFromChildren(*m_fuzzyFilter, srcModel, sourceRight); - return leftScore < rightScore; + return std::max(leftScore, maxLeftChildScore) > std::max(rightScore, maxRightChildScore); } FuzzyFilter* FuzzyScoreSorter::fuzzyFilter() const