Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@

#include "filteredflyoutmodel.h"

#include "global/translation.h"
#include <algorithm>

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)
{
Expand Down Expand Up @@ -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<std::u32string>& 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)
{
Expand Down Expand Up @@ -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);
Expand All @@ -140,3 +171,4 @@ void FilteredFlyoutModel::setFilterText(const QString& filterText)

emit modelChanged();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,17 @@

#pragma once

#include <QObject>
#include <string>
#include <vector>

#include <qqmlintegration.h>

#include <QObject>
#include <QString>
#include <QVariant>

#include "global/stringsearch.h"

namespace muse::uicomponents {
class FilteredFlyoutModel : public QObject
{
Expand All @@ -49,6 +57,8 @@ class FilteredFlyoutModel : public QObject

private:
QString m_filterText;
std::vector<std::u32string> m_patternTokens;
FuzzyMatcher m_matcher;

QVariant m_rawModel;

Expand Down
113 changes: 68 additions & 45 deletions framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,62 @@
#include "fuzzyfilter.h"

#include <algorithm>
#include <iterator>

#include "sortfilterproxymodel.h"

namespace muse::uicomponents {
namespace {
std::optional<double> 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<double> 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<double> bestSimilarity;
for (const auto& match : matcher(text, pattern, maxDistance)) {
const std::optional<double> matchSimilarity = calcMatchScore(text, pattern, match);
bestSimilarity = std::max(bestSimilarity, matchSimilarity);
}

return bestSimilarity;
}
}

FuzzyFilter::FuzzyFilter(QObject* parent)
: Filter(parent)
{
Expand Down Expand Up @@ -100,9 +151,10 @@ void FuzzyFilter::setCaseSensitivity(const Qt::CaseSensitivity caseSensitivity)
emit dataChanged();
}

std::optional<double> FuzzyFilter::getScore(const QPersistentModelIndex& sourceIndex, const SortFilterProxyModel& proxyModel)
std::optional<double> 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();
}
Expand All @@ -115,7 +167,7 @@ std::optional<double> FuzzyFilter::getScore(const QPersistentModelIndex& sourceI
return score;
}

m_scoreCache.try_emplace(sourceIndex, *score);
m_scoreCache.try_emplace(persistentSourceIndex, *score);

return score;
}
Expand All @@ -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<double> FuzzyFilter::calcScore(const QModelIndex& sourceIndex, const SortFilterProxyModel& proxyModel)
Expand All @@ -151,47 +203,18 @@ std::optional<double> 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<double> 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<double> 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;
Expand Down
2 changes: 1 addition & 1 deletion framework/uicomponents/qml/Muse/UiComponents/fuzzyfilter.h
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class FuzzyFilter : public Filter
Qt::CaseSensitivity caseSensitivity() const;
void setCaseSensitivity(Qt::CaseSensitivity);

std::optional<double> getScore(const QPersistentModelIndex& sourceIndex, const SortFilterProxyModel&);
std::optional<double> getScore(const QModelIndex& sourceIndex, const SortFilterProxyModel&);

signals:
void fuzzyPatternChanged();
Expand Down