diff --git a/framework/uicomponents/qml/Muse/UiComponents/filteredflyoutmodel.cpp b/framework/uicomponents/qml/Muse/UiComponents/filteredflyoutmodel.cpp index 1e8c5b0edd..b9022b27df 100644 --- a/framework/uicomponents/qml/Muse/UiComponents/filteredflyoutmodel.cpp +++ b/framework/uicomponents/qml/Muse/UiComponents/filteredflyoutmodel.cpp @@ -22,10 +22,12 @@ #include "filteredflyoutmodel.h" -#include "global/translation.h" +#include -using namespace muse::uicomponents; +#include "global/stringsearch.h" +#include "global/translation.h" +namespace muse::uicomponents { // Recursively traverse a flyout tree, collect all "leaves" (items without a sub item)... static void flattenTreeModel(const QVariant& treeModel, const QString& categoryTitle, QVariantList& result, QVariant& alwaysAppend) { @@ -67,6 +69,25 @@ static void flattenTreeModel(const QVariant& treeModel, const QString& categoryT } } +static bool containsFuzzy(FuzzyMatcher& matcher, const std::u32string_view text, const std::vector& patternTokens) +{ + return std::all_of(patternTokens.begin(), patternTokens.end(), [&](const std::u32string& patternToken) { + const std::size_t tokenSize = patternToken.size(); + if (tokenSize == 0) { + return true; + } + + constexpr std::size_t MIN_TOKEN_SIZE_FOR_FUZZY_MATCH = 4; + constexpr std::size_t CHARS_PER_ERROR = 8; + const std::size_t maxDistance = tokenSize >= MIN_TOKEN_SIZE_FOR_FUZZY_MATCH + ? 1 + (tokenSize / CHARS_PER_ERROR) + : 0; + + matcher.match(text, patternToken, maxDistance); + return !matcher.empty(); + }); +} + FilteredFlyoutModel::FilteredFlyoutModel(QObject* parent) : QObject(parent) { @@ -105,15 +126,25 @@ void FilteredFlyoutModel::setFilterText(const QString& filterText) } m_filterText = filterText; + const QString caseAdjustedPattern = m_filterText.simplified().toLower(); + const QStringList tokens = caseAdjustedPattern.split(u' '); + + m_patternTokens.clear(); + m_patternTokens.reserve(tokens.size()); + for (const auto& token : tokens) { + m_patternTokens.push_back(token.toStdU32String()); + } + QVariantList newModel; newModel.reserve(m_flattenedModel.toList().size()); QString currentPrefix; + FuzzyMatcher matcher; for (const QVariant& item : m_flattenedModel.toList()) { QVariantMap itemMap = item.toMap(); const QString title = itemMap.value("title").toString(); - if (!title.contains(m_filterText, Qt::CaseInsensitive)) { + if (!containsFuzzy(matcher, title.toLower().toStdU32String(), m_patternTokens)) { continue; } const QString prefix = title.section("-", 0, 0); @@ -140,3 +171,4 @@ void FilteredFlyoutModel::setFilterText(const QString& filterText) emit modelChanged(); } +} diff --git a/framework/uicomponents/qml/Muse/UiComponents/filteredflyoutmodel.h b/framework/uicomponents/qml/Muse/UiComponents/filteredflyoutmodel.h index c26f0fa12a..c6ac4dfcd2 100644 --- a/framework/uicomponents/qml/Muse/UiComponents/filteredflyoutmodel.h +++ b/framework/uicomponents/qml/Muse/UiComponents/filteredflyoutmodel.h @@ -22,9 +22,17 @@ #pragma once -#include +#include +#include + #include +#include +#include +#include + +#include "global/stringsearch.h" + namespace muse::uicomponents { class FilteredFlyoutModel : public QObject { @@ -49,6 +57,8 @@ class FilteredFlyoutModel : public QObject private: QString m_filterText; + std::vector m_patternTokens; + FuzzyMatcher m_matcher; QVariant m_rawModel; diff --git a/framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.cpp b/framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.cpp index 4ba466a3f0..9d52caf65f 100644 --- a/framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.cpp +++ b/framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.cpp @@ -23,11 +23,62 @@ #include "fuzzyfilter.h" #include -#include #include "sortfilterproxymodel.h" namespace muse::uicomponents { +namespace { +std::optional calcMatchScore(const std::u32string_view text, const std::u32string_view pattern, + const FuzzyMatch& match) +{ + const std::size_t patternSize = pattern.size(); + if (patternSize == 0) { + return 0.0; + } + + const double perCharScore = 1.0 / patternSize; + const double matchSimilarity = 1.0 - (match.editDistance * perCharScore); + 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 * perCharScore; + } else { + matchScore += perCharScore; + } + } + + return matchScore; +} + +std::optional findBestMatchScore(FuzzyMatcher& matcher, const std::u32string_view text, + const std::u32string_view pattern) +{ + const std::size_t patternSize = pattern.size(); + if (patternSize == 0) { + return 0.0; + } + + constexpr std::size_t MIN_TOKEN_SIZE_FOR_FUZZY_MATCH = 4; + constexpr std::size_t CHARS_PER_ERROR = 8; + const std::size_t maxDistance = patternSize >= MIN_TOKEN_SIZE_FOR_FUZZY_MATCH + ? 1 + (patternSize / CHARS_PER_ERROR) + : 0; + + std::optional bestSimilarity; + for (const auto& match : matcher(text, pattern, maxDistance)) { + const std::optional matchSimilarity = calcMatchScore(text, pattern, match); + bestSimilarity = std::max(bestSimilarity, matchSimilarity); + } + + return bestSimilarity; +} +} + FuzzyFilter::FuzzyFilter(QObject* parent) : Filter(parent) { @@ -100,9 +151,10 @@ void FuzzyFilter::setCaseSensitivity(const Qt::CaseSensitivity caseSensitivity) emit dataChanged(); } -std::optional FuzzyFilter::getScore(const QPersistentModelIndex& sourceIndex, const SortFilterProxyModel& proxyModel) +std::optional FuzzyFilter::getScore(const QModelIndex& sourceIndex, const SortFilterProxyModel& proxyModel) { - auto scoreIt = m_scoreCache.find(sourceIndex); + const QPersistentModelIndex persistentSourceIndex(sourceIndex); + const auto scoreIt = m_scoreCache.find(persistentSourceIndex); if (scoreIt != m_scoreCache.end()) { return scoreIt.value(); } @@ -115,7 +167,7 @@ std::optional FuzzyFilter::getScore(const QPersistentModelIndex& sourceI return score; } - m_scoreCache.try_emplace(sourceIndex, *score); + m_scoreCache.try_emplace(persistentSourceIndex, *score); return score; } @@ -124,16 +176,16 @@ 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(); - }); + + m_patternTokens.clear(); + m_patternTokens.reserve(tokens.size()); + for (const auto& token : tokens) { + m_patternTokens.push_back(token.toStdU32String()); + } } std::optional FuzzyFilter::calcScore(const QModelIndex& sourceIndex, const SortFilterProxyModel& proxyModel) @@ -151,47 +203,18 @@ std::optional FuzzyFilter::calcScore(const QModelIndex& sourceIndex, con double score = 0.0; for (const auto& patternToken : m_patternTokens) { - const std::size_t tokenSize = patternToken.size(); - if (tokenSize == 0) { + // ignore empty token + if (patternToken.empty()) { continue; } - constexpr std::size_t MIN_TOKEN_SIZE_FOR_FUZZY_MATCH = 4; - constexpr std::size_t CHARS_PER_ERROR = 8; - const std::size_t maxDistance = tokenSize >= MIN_TOKEN_SIZE_FOR_FUZZY_MATCH - ? 1 + (tokenSize / CHARS_PER_ERROR) - : 0; - - const double inverseTokenSize = 1.0 / tokenSize; - std::optional bestTokenScore; - - 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; - } - } - - if (bestTokenScore < matchScore) { - bestTokenScore = matchScore; - } - } - + const std::optional tokenScore = findBestMatchScore(m_matcher, text, patternToken); // 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..bfbe84bf9b 100644 --- a/framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.h +++ b/framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.h @@ -60,7 +60,7 @@ class FuzzyFilter : public Filter Qt::CaseSensitivity caseSensitivity() const; void setCaseSensitivity(Qt::CaseSensitivity); - std::optional getScore(const QPersistentModelIndex& sourceIndex, const SortFilterProxyModel&); + std::optional getScore(const QModelIndex& sourceIndex, const SortFilterProxyModel&); signals: void fuzzyPatternChanged();