Skip to content

core: fzy finder singleton #188

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
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
1 change: 1 addition & 0 deletions src/core/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ qt_add_library(quickshell-core STATIC
scriptmodel.cpp
colorquantizer.cpp
toolsupport.cpp
fzy.cpp
)

qt_add_qml_module(quickshell-core
Expand Down
230 changes: 230 additions & 0 deletions src/core/fzy.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
#include "fzy.h"

#include <algorithm>
#include <array>
#include <bitset>
#include <string_view>

namespace {
constexpr size_t MATCH_MAX_LEN = 1024;

constexpr double SCORE_MAX = std::numeric_limits<double>::infinity();
constexpr double SCORE_MIN = -std::numeric_limits<double>::infinity();

constexpr double SCORE_GAP_LEADING = -0.005;
constexpr double SCORE_GAP_TRAILING = -0.005;
constexpr double SCORE_GAP_INNER = -0.01;
constexpr double SCORE_MATCH_CONSECUTIVE = 1.0;
constexpr double SCORE_MATCH_SLASH = 0.9;
constexpr double SCORE_MATCH_WORD = 0.8;
constexpr double SCORE_MATCH_CAPITAL = 0.7;
constexpr double SCORE_MATCH_DOT = 0.6;

struct ScoredResult {
double score{};
QString str;
QObject* obj = nullptr;
};

bool isUpper(char16_t ch) {
return 'A' <= ch && ch <= 'Z';
}

bool isOrdinary(char16_t ch) {
return
('0' <= ch && ch <= '9') ||
('a' <= ch && ch <= 'z') ||
('A' <= ch && ch <= 'Z')
;
}

// This is from llvm but with char16_t
// But will return pointer to end rather than nullptr
const char16_t* strpbrk(const char16_t *src, const char16_t *segment) {
std::bitset<256> bitset;

for (; *segment; ++segment) // NOLINT
bitset.set(*reinterpret_cast<const unsigned char *>(segment));
for (; *src && !bitset.test(*reinterpret_cast<const unsigned char *>(src));
++src) // NOLINT
;
return src; // NOLINT
}

const char16_t *strcasechr(const char16_t *s, char16_t c) {
const char16_t accept[3] = {c, static_cast<char16_t>(toupper(c)), 0}; // NOLINT
return strpbrk(s, accept);
}

bool hasMatch(std::u16string_view needle, std::u16string_view haystack) {
const auto *haystackIter = haystack.begin();
for (auto needleChar : needle){
haystackIter = strcasechr(haystackIter, needleChar);
if (haystackIter == haystack.end()) {
return false;
}
haystackIter++; // NOLINT
}
return true;
}

struct MatchStruct {
std::u16string lowerNeedle;
std::u16string lowerHaystack;

std::array<double, MATCH_MAX_LEN> matchBonus{};
};


double getBonus(char16_t ch, char16_t lastCh){
if (!isOrdinary(lastCh)) {
return 0.0;
}
switch (ch) {
case '/':
return SCORE_MATCH_SLASH;
case '-':
case '_':
case ' ': return SCORE_MATCH_WORD;
case '.': return SCORE_MATCH_DOT;
case 'a':
case 'b':
case 'c':
case 'd':
case 'e':
case 'f':
case 'g':
case 'h':
case 'i':
case 'j':
case 'k':
case 'l':
case 'm':
case 'n':
case 'o':
case 'p':
case 'q':
case 'r':
case 's':
case 't':
case 'u':
case 'v':
case 'w':
case 'x':
case 'y':
case 'z':
return isUpper(lastCh) ? SCORE_MATCH_CAPITAL : 0.0;
default: return 0.0;
}
}

void precomputeBonus(std::u16string_view haystack, std::span<double> matchBonus) {
/* Which positions are beginning of words */
char16_t lastCh = '/';
for (size_t index = 0; index < haystack.size(); index++) {
char16_t ch = haystack[index];
matchBonus[index] = getBonus(lastCh, ch);
lastCh = ch;
}
}

MatchStruct setupMatchStruct(std::u16string_view needle, std::u16string_view haystack) {
MatchStruct match{};

for (const auto nch : needle){
match.lowerNeedle.push_back(tolower(nch));
}
for (const auto hch : haystack){
match.lowerHaystack.push_back(tolower(hch));
}

precomputeBonus(haystack, match.matchBonus);

return match;
}

void matchRow(const MatchStruct& match, size_t row, std::span<double> currD, std::span<double> currM, std::span<const double> lastD, std::span<const double> lastM) {
size_t needleLen = match.lowerNeedle.size();
size_t haystackLen = match.lowerHaystack.size();

std::u16string_view lowerNeedle = match.lowerNeedle;
std::u16string_view lowerHaystack = match.lowerHaystack;
const auto& matchBonus = match.matchBonus;

double prevScore = SCORE_MIN;
double gapScore = row == needleLen - 1 ? SCORE_GAP_TRAILING : SCORE_GAP_INNER;

/* These will not be used with this value, but not all compilers see it */
double prevM = SCORE_MIN;
double prevD = SCORE_MIN;

for (size_t index = 0; index < haystackLen; index++) {
if (lowerNeedle[row] == lowerHaystack[index]) {
double score = SCORE_MIN;
if (!row) {
score = (static_cast<double>(index) * SCORE_GAP_LEADING) + matchBonus[index];
} else if (index) { /* row > 0 && index > 0*/
score = fmax(
prevM + matchBonus[index],

/* consecutive match, doesn't stack with match_bonus */
prevD + SCORE_MATCH_CONSECUTIVE);
}
prevD = lastD[index];
prevM = lastM[index];
currD[index] = score;
currM[index] = prevScore = fmax(score, prevScore + gapScore);
} else {
prevD = lastD[index];
prevM = lastM[index];
currD[index] = SCORE_MIN;
currM[index] = prevScore = prevScore + gapScore;
}
}
}

double match(std::u16string_view needle, std::u16string_view haystack) {
if (needle.empty())
return SCORE_MIN;

if (haystack.size() > MATCH_MAX_LEN || needle.size() > haystack.size()) {
return SCORE_MIN;
} else if (haystack.size() == needle.size()){
return SCORE_MAX;
}

MatchStruct match = setupMatchStruct(needle, haystack);

/*
* D Stores the best score for this position ending with a match.
* M Stores the best possible score at this position.
*/
std::array<double, MATCH_MAX_LEN> d{};
std::array<double, MATCH_MAX_LEN> m{};

for (size_t index = 0; index < needle.size(); index++) {
matchRow(match, index, d, m, d, m);
}

return m[haystack.size() - 1];
}

}

namespace qs {

QList<QObject*> FzyFinder::filter(const QString& needle, const QList<QObject*>& haystacks, const QString& name) {
std::vector<ScoredResult> list;
for (const auto& haystack : haystacks){
const auto h = haystack->property(name.toUtf8()).toString();
if (hasMatch(needle, h)) {
list.emplace_back(match(needle, h), h, haystack);
}
}
std::ranges::stable_sort(list, std::ranges::greater(), &ScoredResult::score);
auto out = QList<QObject*>(static_cast<qsizetype>(list.size()));
std::ranges::transform(list, out.begin(), [](const ScoredResult& result) -> QObject* { return result.obj; });
return out;
}

}
36 changes: 36 additions & 0 deletions src/core/fzy.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#pragma once

#include <qlist.h>
#include <qobject.h>
#include <qqmlintegration.h>
#include <qstring.h>

namespace qs {

///! A fzy finder.
/// A fzy finder.
///
/// You can use this singleton to filter desktop entries like below.
///
/// ```qml
/// model: ScriptModel {
/// values: FzyFinder.filter(search.text, @@DesktopEntries.applications.values, "name");
/// }
/// ```
class FzyFinder : public QObject {
Q_OBJECT;
QML_SINGLETON;
QML_ELEMENT;

public:
explicit FzyFinder(QObject* parent = nullptr): QObject(parent) {}

/// Filters the list haystacks that don't contain the needle.
/// `needle` is the query to search with.
/// `haystacks` is what will be searched.
/// `name` is a property of each object in `haystacks` if `haystacks[n].name` is not a `string` then it will be treated as an empty string.
/// The returned list is the objects that contain the query in fzy score order.
Q_INVOKABLE [[nodiscard]] static QList<QObject*> filter(const QString& needle, const QList<QObject*>& haystacks, const QString& name);
};

}
1 change: 1 addition & 0 deletions src/core/module.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ headers = [
"clock.hpp",
"scriptmodel.hpp",
"colorquantizer.hpp",
"fzy.hpp",
]
-----
Loading