Skip to content

Commit 1d808fe

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

File tree

4 files changed

+253
-0
lines changed

4 files changed

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

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)