Skip to content

Commit 90cce38

Browse files
committed
core: fzy finder singleton
core: fzy remove unneeded include core: move to qt types - this should fix reliance on qt6.7 add span include make hasMatch case insensitive
1 parent a5431dd commit 90cce38

File tree

4 files changed

+237
-0
lines changed

4 files changed

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

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)