From 4a9a7bc6e344ac3ada61a70357d82c83b82487b4 Mon Sep 17 00:00:00 2001 From: cameron Date: Wed, 20 Aug 2025 18:00:26 +1000 Subject: [PATCH 1/2] core: fzy finder singleton core: fzy remove unneeded include core: move to qt types - this should fix reliance on qt6.7 --- src/core/CMakeLists.txt | 1 + src/core/fzy.cpp | 198 ++++++++++++++++++++++++++++++++++++++++ src/core/fzy.h | 36 ++++++++ src/core/module.md | 1 + 4 files changed, 236 insertions(+) create mode 100644 src/core/fzy.cpp create mode 100644 src/core/fzy.h diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 7cef987a..fa1e3c77 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -39,6 +39,7 @@ qt_add_library(quickshell-core STATIC scriptmodel.cpp colorquantizer.cpp toolsupport.cpp + fzy.cpp ) qt_add_qml_module(quickshell-core diff --git a/src/core/fzy.cpp b/src/core/fzy.cpp new file mode 100644 index 00000000..685944c8 --- /dev/null +++ b/src/core/fzy.cpp @@ -0,0 +1,198 @@ +#include "fzy.h" + +#include +#include +#include + +namespace { +constexpr qsizetype MATCH_MAX_LEN = 1024; + +constexpr double SCORE_MAX = std::numeric_limits::infinity(); +constexpr double SCORE_MIN = -std::numeric_limits::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 hasMatch(QStringView needle, QStringView haystack) { + qsizetype index = 0; + for (auto needleChar : needle){ + index = haystack.indexOf(needleChar); + if (index == -1) { + return false; + } + index++; + } + return true; +} + +struct MatchStruct { + QString lowerNeedle; + QString lowerHaystack; + + std::array matchBonus{}; +}; + +double getBonus(QChar ch, QChar lastCh){ + if (!lastCh.isLetterOrNumber()) { + return 0.0; + } + switch (ch.unicode()) { + 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 lastCh.isUpper() ? SCORE_MATCH_CAPITAL : 0.0; + default: return 0.0; + } +} + +void precomputeBonus(QStringView haystack, std::span matchBonus) { + /* Which positions are beginning of words */ + QChar lastCh = '/'; + for (qsizetype index = 0; index < haystack.size(); index++) { + QChar ch = haystack[index]; + matchBonus[index] = getBonus(lastCh, ch); + lastCh = ch; + } +} + +MatchStruct setupMatchStruct(QStringView needle, QStringView haystack) { + MatchStruct match{}; + + for (const auto nch : needle){ + match.lowerNeedle.push_back(nch.toLower()); + } + for (const auto hch : haystack){ + match.lowerHaystack.push_back(hch.toLower()); + } + + precomputeBonus(haystack, match.matchBonus); + + return match; +} + +void matchRow(const MatchStruct& match, qsizetype row, std::span currD, std::span currM, std::span lastD, std::span lastM) { + qsizetype needleLen = match.lowerNeedle.size(); + qsizetype haystackLen = match.lowerHaystack.size(); + + QStringView lowerNeedle = match.lowerNeedle; + QStringView lowerHaystack = match.lowerHaystack; + std::span 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 (qsizetype index = 0; index < haystackLen; index++) { + if (lowerNeedle[row] == lowerHaystack[index]) { + double score = SCORE_MIN; + if (!row) { + score = (static_cast(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(QStringView needle, QStringView 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 d{}; + std::array m{}; + + for (qsizetype index = 0; index < needle.size(); index++) { + matchRow(match, index, d, m, d, m); + } + + return m[haystack.size() - 1]; +} + +} + +namespace qs { + +QList FzyFinder::filter(const QString& needle, const QList& haystacks, const QString& name) { + QList list; + for (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(static_cast(list.size())); + std::ranges::transform(list, out.begin(), [](const ScoredResult& result) -> QObject* { return result.obj; }); + return out; +} + +} diff --git a/src/core/fzy.h b/src/core/fzy.h new file mode 100644 index 00000000..db9000c5 --- /dev/null +++ b/src/core/fzy.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include + +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 filter(const QString& needle, const QList& haystacks, const QString& name); +}; + +} diff --git a/src/core/module.md b/src/core/module.md index b9404ea9..0b01bce6 100644 --- a/src/core/module.md +++ b/src/core/module.md @@ -30,5 +30,6 @@ headers = [ "clock.hpp", "scriptmodel.hpp", "colorquantizer.hpp", + "fzy.hpp", ] ----- From 8ae23948a0436df6d02acb5b5678a943aa528c74 Mon Sep 17 00:00:00 2001 From: cameron Date: Thu, 21 Aug 2025 01:36:29 +1000 Subject: [PATCH 2/2] add span include --- src/core/fzy.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/fzy.cpp b/src/core/fzy.cpp index 685944c8..0de3fa44 100644 --- a/src/core/fzy.cpp +++ b/src/core/fzy.cpp @@ -3,6 +3,7 @@ #include #include #include +#include namespace { constexpr qsizetype MATCH_MAX_LEN = 1024;