Skip to content

Commit 6cc9c30

Browse files
committed
core: fzy finder singleton
1 parent a5431dd commit 6cc9c30

File tree

4 files changed

+269
-0
lines changed

4 files changed

+269
-0
lines changed

src/core/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ qt_add_library(quickshell-core STATIC
3939
scriptmodel.cpp
4040
colorquantizer.cpp
4141
toolsupport.cpp
42+
fzy.cpp
4243
)
4344

4445
qt_add_qml_module(quickshell-core

src/core/fzy.cpp

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
#include "fzy.h"
2+
3+
#include <algorithm>
4+
#include <array>
5+
#include <bitset>
6+
#include <generator>
7+
#include <string_view>
8+
9+
namespace {
10+
constexpr size_t MATCH_MAX_LEN = 1024;
11+
12+
constexpr double SCORE_MAX = std::numeric_limits<double>::infinity();
13+
constexpr double SCORE_MIN = -std::numeric_limits<double>::infinity();
14+
15+
constexpr double SCORE_GAP_LEADING = -0.005;
16+
constexpr double SCORE_GAP_TRAILING = -0.005;
17+
constexpr double SCORE_GAP_INNER = -0.01;
18+
constexpr double SCORE_MATCH_CONSECUTIVE = 1.0;
19+
constexpr double SCORE_MATCH_SLASH = 0.9;
20+
constexpr double SCORE_MATCH_WORD = 0.8;
21+
constexpr double SCORE_MATCH_CAPITAL = 0.7;
22+
constexpr double SCORE_MATCH_DOT = 0.6;
23+
24+
struct ScoredResult {
25+
double score{};
26+
QString str;
27+
QObject* obj = nullptr;
28+
};
29+
30+
bool isUpper(char16_t ch) {
31+
return 'A' <= ch && ch <= 'Z';
32+
}
33+
34+
bool isOrdinary(char16_t ch) {
35+
return
36+
('0' <= ch && ch <= '9') ||
37+
('a' <= ch && ch <= 'z') ||
38+
('A' <= ch && ch <= 'Z')
39+
;
40+
}
41+
42+
// This is from llvm but with char16_t
43+
// But will return pointer to end rather than nullptr
44+
const char16_t* strpbrk(const char16_t *src, const char16_t *segment) {
45+
std::bitset<256> bitset;
46+
47+
for (; *segment; ++segment) // NOLINT
48+
bitset.set(*reinterpret_cast<const unsigned char *>(segment));
49+
for (; *src && !bitset.test(*reinterpret_cast<const unsigned char *>(src));
50+
++src) // NOLINT
51+
;
52+
return src; // NOLINT
53+
}
54+
55+
const char16_t *strcasechr(const char16_t *s, char16_t c) {
56+
const char16_t accept[3] = {c, static_cast<char16_t>(toupper(c)), 0}; // NOLINT
57+
return strpbrk(s, accept);
58+
}
59+
60+
bool hasMatch(std::u16string_view needle, std::u16string_view haystack) {
61+
const auto *haystackIter = haystack.begin();
62+
for (auto needleChar : needle){
63+
haystackIter = strcasechr(haystackIter, needleChar);
64+
if (haystackIter == haystack.end()) {
65+
return false;
66+
}
67+
haystackIter++; // NOLINT
68+
}
69+
return true;
70+
}
71+
72+
struct MatchStruct {
73+
std::u16string lowerNeedle;
74+
std::u16string lowerHaystack;
75+
76+
std::array<double, MATCH_MAX_LEN> matchBonus{};
77+
};
78+
79+
80+
double getBonus(char16_t ch, char16_t lastCh){
81+
if (!isOrdinary(lastCh)) {
82+
return 0.0;
83+
}
84+
switch (ch) {
85+
case '/':
86+
return SCORE_MATCH_SLASH;
87+
case '-':
88+
case '_':
89+
case ' ': return SCORE_MATCH_WORD;
90+
case '.': return SCORE_MATCH_DOT;
91+
case 'a':
92+
case 'b':
93+
case 'c':
94+
case 'd':
95+
case 'e':
96+
case 'f':
97+
case 'g':
98+
case 'h':
99+
case 'i':
100+
case 'j':
101+
case 'k':
102+
case 'l':
103+
case 'm':
104+
case 'n':
105+
case 'o':
106+
case 'p':
107+
case 'q':
108+
case 'r':
109+
case 's':
110+
case 't':
111+
case 'u':
112+
case 'v':
113+
case 'w':
114+
case 'x':
115+
case 'y':
116+
case 'z':
117+
return isUpper(lastCh) ? SCORE_MATCH_CAPITAL : 0.0;
118+
default: return 0.0;
119+
}
120+
}
121+
122+
void precomputeBonus(std::u16string_view haystack, std::span<double> matchBonus) {
123+
/* Which positions are beginning of words */
124+
char16_t lastCh = '/';
125+
for (size_t index = 0; index < haystack.size(); index++) {
126+
char16_t ch = haystack[index];
127+
matchBonus[index] = getBonus(lastCh, ch);
128+
lastCh = ch;
129+
}
130+
}
131+
132+
MatchStruct setupMatchStruct(std::u16string_view needle, std::u16string_view haystack) {
133+
MatchStruct match{};
134+
135+
for (const auto nch : needle){
136+
match.lowerNeedle.push_back(tolower(nch));
137+
}
138+
for (const auto hch : haystack){
139+
match.lowerHaystack.push_back(tolower(hch));
140+
}
141+
142+
precomputeBonus(haystack, match.matchBonus);
143+
144+
return match;
145+
}
146+
147+
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) {
148+
size_t needleLen = match.lowerNeedle.size();
149+
size_t haystackLen = match.lowerHaystack.size();
150+
151+
std::u16string_view lowerNeedle = match.lowerNeedle;
152+
std::u16string_view lowerHaystack = match.lowerHaystack;
153+
const auto& matchBonus = match.matchBonus;
154+
155+
double prevScore = SCORE_MIN;
156+
double gapScore = row == needleLen - 1 ? SCORE_GAP_TRAILING : SCORE_GAP_INNER;
157+
158+
/* These will not be used with this value, but not all compilers see it */
159+
double prevM = SCORE_MIN;
160+
double prevD = SCORE_MIN;
161+
162+
for (size_t index = 0; index < haystackLen; index++) {
163+
if (lowerNeedle[row] == lowerHaystack[index]) {
164+
double score = SCORE_MIN;
165+
if (!row) {
166+
score = (static_cast<double>(index) * SCORE_GAP_LEADING) + matchBonus[index];
167+
} else if (index) { /* row > 0 && index > 0*/
168+
score = fmax(
169+
prevM + matchBonus[index],
170+
171+
/* consecutive match, doesn't stack with match_bonus */
172+
prevD + SCORE_MATCH_CONSECUTIVE);
173+
}
174+
prevD = lastD[index];
175+
prevM = lastM[index];
176+
currD[index] = score;
177+
currM[index] = prevScore = fmax(score, prevScore + gapScore);
178+
} else {
179+
prevD = lastD[index];
180+
prevM = lastM[index];
181+
currD[index] = SCORE_MIN;
182+
currM[index] = prevScore = prevScore + gapScore;
183+
}
184+
}
185+
}
186+
187+
double match(std::u16string_view needle, std::u16string_view haystack) {
188+
if (needle.empty())
189+
return SCORE_MIN;
190+
191+
if (haystack.size() > MATCH_MAX_LEN || needle.size() > haystack.size()) {
192+
return SCORE_MIN;
193+
} else if (haystack.size() == needle.size()){
194+
return SCORE_MAX;
195+
}
196+
197+
MatchStruct match = setupMatchStruct(needle, haystack);
198+
199+
/*
200+
* D Stores the best score for this position ending with a match.
201+
* M Stores the best possible score at this position.
202+
*/
203+
std::array<double, MATCH_MAX_LEN> d{};
204+
std::array<double, MATCH_MAX_LEN> m{};
205+
206+
for (size_t index = 0; index < needle.size(); index++) {
207+
matchRow(match, index, d, m, d, m);
208+
}
209+
210+
return m[haystack.size() - 1];
211+
}
212+
213+
}
214+
215+
namespace qs {
216+
217+
QList<QObject*> FzyFinder::filter(const QString& needle, const QList<QObject*>& haystacks, const QString& name) {
218+
std::vector<ScoredResult> list;
219+
for (const auto& haystack : haystacks){
220+
const auto h = haystack->property(name.toUtf8()).toString();
221+
if (hasMatch(needle, h)) {
222+
list.emplace_back(match(needle, h), h, haystack);
223+
}
224+
}
225+
std::ranges::stable_sort(list, std::ranges::greater(), &ScoredResult::score);
226+
auto out = QList<QObject*>(static_cast<qsizetype>(list.size()));
227+
std::ranges::transform(list, out.begin(), [](const ScoredResult& result) -> QObject* { return result.obj; });
228+
return out;
229+
}
230+
231+
}

src/core/fzy.h

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#pragma once
2+
3+
#include <qlist.h>
4+
#include <qobject.h>
5+
#include <qqmlintegration.h>
6+
#include <qstring.h>
7+
8+
namespace qs {
9+
10+
///! A fzy finder.
11+
/// A fzy finder.
12+
///
13+
/// You can use this singleton to filter desktop entries like below.
14+
///
15+
/// ```qml
16+
/// model: ScriptModel {
17+
/// values: FzyFinder.filter(search.text, @@DesktopEntries.applications.values, "name");
18+
/// }
19+
/// ```
20+
class FzyFinder : public QObject {
21+
Q_OBJECT;
22+
QML_SINGLETON;
23+
QML_ELEMENT;
24+
25+
public:
26+
explicit FzyFinder(QObject* parent = nullptr): QObject(parent) {}
27+
28+
/// Filters the list haystacks that don't contain the needle.
29+
/// `needle` is the query to search with.
30+
/// `haystacks` is what will be searched.
31+
/// `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.
32+
/// The returned list is the objects that contain the query in fzy score order.
33+
Q_INVOKABLE [[nodiscard]] static QList<QObject*> filter(const QString& needle, const QList<QObject*>& haystacks, const QString& name);
34+
};
35+
36+
}

src/core/module.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,6 @@ headers = [
3030
"clock.hpp",
3131
"scriptmodel.hpp",
3232
"colorquantizer.hpp",
33+
"fzy.hpp",
3334
]
3435
-----

0 commit comments

Comments
 (0)