Initial commit: FuzzyRanker keystone + project plan

Kate plugin suite for menu-free, mouse-first, one-handed UX.

- FuzzyRanker: orderless + layered fuzzy matcher (exact/substring/
  initials/subsequence/bounded-typo) with merged highlight ranges.
  Fixes Kate's KFuzzyMatcher gaps (no orderless, 'gti' fails 'git').
- 14 QTest cases, all green.
- README + docs/PLAN.md: goal, design constraints, 5-milestone roadmap.
This commit is contained in:
Levi Neely 2026-10-07 15:45:58 +02:00
commit 95bc9f7ed6
9 changed files with 742 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
# Build artifacts
/build/
/prefix.sh
# Editor / OS cruft
*.o
*.a
*.so
*.moc
moc_*.cpp
*~
.DS_Store
compile_commands.json
.cache/

23
CMakeLists.txt Normal file
View File

@ -0,0 +1,23 @@
cmake_minimum_required(VERSION 3.21)
project(kate-custom VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(QT_MIN_VERSION 6.5.0)
set(KF_MIN_VERSION 6.0.0)
find_package(ECM ${KF_MIN_VERSION} REQUIRED NO_MODULE)
list(APPEND CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
include(KDEInstallDirs)
include(KDECMakeSettings)
include(KDECompilerSettings NO_POLICY_SCOPE)
find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS Core)
enable_testing()
add_subdirectory(src)

73
README.md Normal file
View File

@ -0,0 +1,73 @@
# kate-custom
A suite of Kate plugins for people who despise menus. The aim is a text editor
that is driven by the mouse at the cursor and by plain-letter keyboard input —
never by hunting through nested menubars or memorizing complex chords.
## Design constraints
These are hard constraints that shape every decision:
- **Mouse-heavy.** The fastest input is the mouse, used *locally* at the caret
(radial / floating surfaces), not a trek to the menubar.
- **One-handed typing.** Keyboard input must work comfortably with one hand.
- **No complex chords.** Single modifier at most (`Ctrl`+letter). No leader-key
chord trees, no multi-cursor keybinds.
- **Compact keyboard.** F-keys and nav clusters live on a layer toggle, so they
carry extra cognitive cost and are avoided as primary triggers.
- **Emacs/Sublime fluency.** `M-x` (`Alt+x`) is comfortable and welcome as a
primary keyboard entry point, as is a Sublime-style type-to-filter palette.
### Unifying principle
> **One action registry. Three doors onto it:**
> **`M-x` (keyboard), a radial caret menu (mouse), and the `:` command line (optional).**
> **No action requires an F-key or a chord.**
## Why not just use Kate's command bar?
Kate's `KCommandBar` is a sealed widget: it accepts a list of actions via
`setActions()` and exposes **no hook** to customize its matcher. Its matcher,
`KFuzzyMatcher`, is single-needle, strictly in-order subsequence, with **no typo
tolerance** — by KDE's own documentation, `"gti"` will not match `"git"`. There
is no "orderless" (space-separated tokens in any order) and no company-style
live completion.
So the project replaces the palette rather than extending it, built on a custom
matcher (`FuzzyRanker`).
## Environment
- Kate 25.12.3, KDE Frameworks 6, Qt 6
- C++20, CMake, extra-cmake-modules
- C++ is the only first-class plugin path on this install (no Python/Pâté binding present)
## Build
```sh
cmake -B build -S .
cmake --build build
QT_QPA_PLATFORM=offscreen ./build/bin/test_fuzzyranker # run the matcher tests
ctest --test-dir build # or via ctest
```
## Status
See [docs/PLAN.md](docs/PLAN.md) for the full roadmap.
- **Milestone 1 — FuzzyRanker (keystone): DONE.** Orderless, layered scoring
(exact / substring / word-initials / subsequence / bounded typo), merged
highlight ranges. 14 unit tests, all green.
- Milestone 2 — custom palette widget: next.
## Layout
```
CMakeLists.txt top-level KF6/Qt6/ECM project
src/
fuzzy/
fuzzyranker.{h,cpp} orderless + layered fuzzy matcher
test_fuzzyranker.cpp QTest suite
docs/
PLAN.md goal, constraints, roadmap
```

91
docs/PLAN.md Normal file
View File

@ -0,0 +1,91 @@
# Plan — kate-custom
## Goal
Build a suite of Kate plugins that make the editor usable without menus, tuned
to one specific operator profile:
- mouse-heavy, with the mouse used at the caret rather than at the menubar
- one-handed keyboard typing
- no complex chords; single modifier at most
- compact keyboard where F-keys/nav clusters sit behind a layer toggle
- long Emacs history and some Sublime Text; `M-x` and type-to-filter palettes
are familiar and welcome
The spine of the suite is a single **action registry** reached through three
interchangeable doors: `M-x` (keyboard), a radial caret menu (mouse), and the
`:` command line (optional). Every feature registers its commands once and
becomes reachable from all three. No action depends on an F-key or a chord.
## Why a custom matcher is the keystone
Kate's built-in command bar cannot be fixed in place:
- `KCommandBar` (KConfigWidgets) exposes only `setActions()`. There is no hook
to replace or configure its matcher.
- `KFuzzyMatcher` (KCoreAddons) is single-needle, strictly in-order
subsequence, and not typo tolerant. KDE's own docs state `"gti"` will not
match `"git"`. No orderless, no company-style completion.
Therefore the palette is replaced, not extended, and the replacement is built on
`FuzzyRanker`, a matcher with:
- **orderless** tokenization (space-separated needles matched in any order)
- **layered scoring**, strongest tier first:
1. exact
2. substring
3. word-boundary initials (`rs` -> **R**ename **S**ymbol)
4. in-order subsequence
5. bounded typo (one deletion; recovers transpositions like `gti` -> `git`)
- **merged highlight ranges** for the UI
- AND semantics across needles, case-folded, shorter-candidate preference
## Milestones
### M1 — FuzzyRanker (keystone) — DONE
- Repo scaffold: top-level CMake (KF6/Qt6/ECM), `src/`, `src/fuzzy/`.
- `src/fuzzy/fuzzyranker.{h,cpp}`: tokenize + layered scoring + ranges.
- `src/fuzzy/test_fuzzyranker.cpp`: QTest suite, 14 cases, all green,
including `gti -> git` and `ren sym -> Rename Symbol`.
### M2 — Palette widget (replaces KCommandBar) — NEXT
- Custom `QFrame`: filter `QLineEdit` + results list (model/view).
- Live, company-style updates on every keystroke; top hit preselected; `Enter`
executes; matched characters highlighted via `MatchResult::ranges`.
- Consumes `FuzzyRanker`. Pure Qt widget, independent of Kate for testability.
### M3 — KTextEditor plugin + M-x + action registry
- `KTextEditor::Plugin` skeleton producing a loadable `.so`.
- Central action registry aggregating Kate built-in actions + plugin actions.
- `M-x` (`Alt+x`) opens the M2 palette over the registry.
- Frecency ranking: usage history feeds a score bonus.
### M4 — Radial caret menu (mouse door)
- Mouse-triggered radial menu positioned at the caret
(`View::cursorToCoordinate`), flick-to-select by angle.
- Slices invoke registry actions; one slice opens the palette / command line, so
the keyboard-expensive paths are never required.
### M5 — Command vocabulary + project model + switchers
- `:`-verb pack via `KTextEditor::Command`: `sort`, `align`, `json`, `b64`,
`uuid`, `case`, `pipe <shell>`, etc. These populate M-x and the radial.
- **Project = directory.** "Open folder == open project" (VSCode/Sublime model);
no `.kateproject` ceremony.
- File / symbol switchers scoped to the opened folder (e.g. `git ls-files` / `fd`
for files; LSP or ctags for symbols), all through the M2 palette.
## Build and test
```sh
cmake -B build -S .
cmake --build build
ctest --test-dir build
# or directly:
QT_QPA_PLATFORM=offscreen ./build/bin/test_fuzzyranker
```
## Environment facts (verified)
- Kate 25.12.3, KDE Frameworks 6, Qt 6.10, cmake 4.2.3, g++, extra-cmake-modules.
- KTextEditor + KCoreAddons + KConfigWidgets + KXmlGui + KRunner dev headers present.
- No Python/Pâté binding installed; C++ is the only first-class plugin path.

1
src/CMakeLists.txt Normal file
View File

@ -0,0 +1 @@
add_subdirectory(fuzzy)

13
src/fuzzy/CMakeLists.txt Normal file
View File

@ -0,0 +1,13 @@
add_library(fuzzyranker STATIC
fuzzyranker.cpp
fuzzyranker.h
)
target_link_libraries(fuzzyranker PUBLIC Qt6::Core)
target_include_directories(fuzzyranker PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
find_package(Qt6 ${QT_MIN_VERSION} COMPONENTS Test)
if(Qt6Test_FOUND)
add_executable(test_fuzzyranker test_fuzzyranker.cpp)
target_link_libraries(test_fuzzyranker PRIVATE fuzzyranker Qt6::Test)
add_test(NAME fuzzyranker COMMAND test_fuzzyranker)
endif()

277
src/fuzzy/fuzzyranker.cpp Normal file
View File

@ -0,0 +1,277 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "fuzzyranker.h"
#include <QChar>
#include <algorithm>
#include <set>
namespace katecustom
{
namespace
{
// --- character helpers -----------------------------------------------------
QString foldString(QStringView s)
{
return s.toString().toCaseFolded();
}
bool isWordStart(const QString &s, int i)
{
if (i == 0) {
return true;
}
const QChar prev = s.at(i - 1);
const QChar cur = s.at(i);
// Boundary if previous is a separator, or camelCase hump (lower -> Upper).
if (prev == QLatin1Char(' ') || prev == QLatin1Char('-') || prev == QLatin1Char('_')
|| prev == QLatin1Char('.') || prev == QLatin1Char('/') || prev == QLatin1Char(':')) {
return true;
}
if (prev.isLower() && cur.isUpper()) {
return true;
}
if (!prev.isLetterOrNumber() && cur.isLetterOrNumber()) {
return true;
}
return false;
}
// Per-needle outcome: which original-string indices were consumed, a raw
// quality score, and the tier reached.
struct NeedleMatch {
bool matched = false;
MatchTier tier = MatchTier::None;
int score = 0;
std::vector<int> indices; // indices into the ORIGINAL candidate
};
// Tier base weight so a stronger match kind always outranks a weaker one.
int tierWeight(MatchTier t)
{
switch (t) {
case MatchTier::Exact:
return 1000;
case MatchTier::Substring:
return 400;
case MatchTier::Initials:
return 300;
case MatchTier::Subsequence:
return 150;
case MatchTier::Typo:
return 60;
case MatchTier::None:
return 0;
}
return 0;
}
// --- strategy 1: exact / substring ----------------------------------------
NeedleMatch trySubstring(const QString &cand, const QString &needle)
{
NeedleMatch m;
const int at = cand.indexOf(needle);
if (at < 0) {
return m;
}
m.matched = true;
m.tier = (needle.size() == cand.size()) ? MatchTier::Exact : MatchTier::Substring;
for (int i = 0; i < needle.size(); ++i) {
m.indices.push_back(at + i);
}
int bonus = 0;
if (isWordStart(cand, at)) {
bonus += 30;
}
if (at == 0) {
bonus += 20;
}
m.score = tierWeight(m.tier) + needle.size() * 10 + bonus;
return m;
}
// --- strategy 2: word-boundary initials ------------------------------------
// Each needle char must land on a word-start, consumed in order.
NeedleMatch tryInitials(const QString &cand, const QString &needle)
{
NeedleMatch m;
std::vector<int> hits;
int ni = 0;
for (int i = 0; i < cand.size() && ni < needle.size(); ++i) {
if (isWordStart(cand, i) && cand.at(i) == needle.at(ni)) {
hits.push_back(i);
++ni;
}
}
if (ni != needle.size()) {
return m;
}
m.matched = true;
m.tier = MatchTier::Initials;
m.indices = hits;
m.score = tierWeight(m.tier) + needle.size() * 12;
return m;
}
// --- strategy 3: in-order subsequence (greedy, contiguity-bonused) ---------
NeedleMatch trySubsequence(const QString &cand, const QString &needle)
{
NeedleMatch m;
std::vector<int> hits;
int ni = 0;
int contiguous = 0;
int prev = -2;
int bonus = 0;
for (int i = 0; i < cand.size() && ni < needle.size(); ++i) {
if (cand.at(i) == needle.at(ni)) {
hits.push_back(i);
if (i == prev + 1) {
++contiguous;
bonus += 5;
}
if (isWordStart(cand, i)) {
bonus += 8;
}
prev = i;
++ni;
}
}
if (ni != needle.size()) {
return m;
}
m.matched = true;
m.tier = MatchTier::Subsequence;
m.indices = hits;
m.score = tierWeight(m.tier) + needle.size() * 6 + bonus - (hits.empty() ? 0 : hits.front());
return m;
}
// --- strategy 4: bounded typo tolerance ------------------------------------
// Allow up to one deletion in the needle (handles transposition/insert typos
// like "gti" -> "git"): if dropping one needle char yields a subsequence, it
// matches at the lowest tier. We try each single-char deletion.
NeedleMatch tryTypo(const QString &cand, const QString &needle)
{
NeedleMatch m;
if (needle.size() < 2) {
return m;
}
for (int drop = 0; drop < needle.size(); ++drop) {
QString reduced = needle;
reduced.remove(drop, 1);
NeedleMatch sub = trySubsequence(cand, reduced);
if (sub.matched) {
m.matched = true;
m.tier = MatchTier::Typo;
m.indices = sub.indices;
m.score = tierWeight(m.tier) + reduced.size() * 4;
return m;
}
}
return m;
}
NeedleMatch bestNeedleMatch(const QString &cand, const QString &needle)
{
// Escalate: stop at the first (strongest) strategy that matches.
NeedleMatch m = trySubstring(cand, needle);
if (m.matched) {
return m;
}
m = tryInitials(cand, needle);
if (m.matched) {
return m;
}
m = trySubsequence(cand, needle);
if (m.matched) {
return m;
}
return tryTypo(cand, needle);
}
std::vector<MatchRange> indicesToRanges(const std::set<int> &idx)
{
std::vector<MatchRange> ranges;
int start = -1;
int prev = -2;
for (int i : idx) {
if (i == prev + 1) {
prev = i;
continue;
}
if (start >= 0) {
ranges.push_back({start, prev - start + 1});
}
start = i;
prev = i;
}
if (start >= 0) {
ranges.push_back({start, prev - start + 1});
}
return ranges;
}
} // namespace
QList<QString> FuzzyRanker::tokenize(QStringView query)
{
QList<QString> needles;
const QList<QStringView> parts = query.split(QLatin1Char(' '), Qt::SkipEmptyParts);
for (const QStringView &p : parts) {
const QString folded = foldString(p);
if (!folded.isEmpty()) {
needles.push_back(folded);
}
}
return needles;
}
MatchResult FuzzyRanker::score(QStringView candidate, const QList<QString> &needles)
{
MatchResult result;
// Empty query: everything matches, neutral score, shorter candidates win.
if (needles.isEmpty()) {
result.matched = true;
result.score = -candidate.size();
return result;
}
const QString cand = foldString(candidate);
std::set<int> allIndices;
int total = 0;
for (const QString &needle : needles) {
const NeedleMatch m = bestNeedleMatch(cand, needle);
if (!m.matched) {
return MatchResult{}; // AND semantics: any miss rejects.
}
total += m.score;
for (int i : m.indices) {
allIndices.insert(i);
}
}
result.matched = true;
// Prefer shorter candidates and reward coverage of the candidate.
const int coverage = static_cast<int>(allIndices.size());
result.score = total + coverage * 2 - candidate.size();
result.ranges = indicesToRanges(allIndices);
return result;
}
MatchResult FuzzyRanker::score(QStringView candidate, QStringView query)
{
return score(candidate, tokenize(query));
}
} // namespace katecustom

80
src/fuzzy/fuzzyranker.h Normal file
View File

@ -0,0 +1,80 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* FuzzyRanker — orderless, layered fuzzy matching for command palettes.
*
* Unlike KFuzzyMatcher (single-needle, strictly in-order subsequence, no typo
* tolerance: "gti" fails to match "git"), FuzzyRanker:
* - splits the query into whitespace-separated needles matched in ANY order
* (Emacs `orderless` semantics)
* - scores each needle by an escalating strategy (exact substring >
* word-boundary initials > in-order subsequence > bounded typo)
* - returns highlight ranges for every matched character
* - exposes a frecency bonus hook for usage-aware ranking
*/
#ifndef KATECUSTOM_FUZZYRANKER_H
#define KATECUSTOM_FUZZYRANKER_H
#include <QString>
#include <QList>
#include <vector>
namespace katecustom
{
/*! A contiguous span of matched characters in the candidate string. */
struct MatchRange {
int start = 0;
int length = 0;
bool operator==(const MatchRange &o) const
{
return start == o.start && length == o.length;
}
};
/*! Result of scoring one candidate against a query. */
struct MatchResult {
bool matched = false;
int score = 0;
std::vector<MatchRange> ranges; // sorted by start, non-overlapping
bool operator<(const MatchResult &o) const { return score < o.score; }
};
/*!
* How a single needle matched a candidate. Higher is better; the strategy
* tier contributes a base weight so that stronger match kinds rank first even
* when character counts are equal.
*/
enum class MatchTier {
None = 0,
Typo = 1, // bounded edit distance (<=1) subsequence
Subsequence = 2, // in-order subsequence, like KFuzzyMatcher
Initials = 3, // matched word-start characters (R S -> Rename Symbol)
Substring = 4, // contiguous substring
Exact = 5, // candidate equals needle
};
class FuzzyRanker
{
public:
/*!
* Split \a query into lowercased needles on whitespace. Empty query yields
* no needles (every candidate then matches with score 0).
*/
static QList<QString> tokenize(QStringView query);
/*!
* Score \a candidate against the already-tokenized \a needles. All needles
* must match (AND semantics) for \c matched to be true. Order of needles
* relative to the candidate is irrelevant (orderless).
*/
static MatchResult score(QStringView candidate, const QList<QString> &needles);
/*! Convenience: tokenize \a query then score. */
static MatchResult score(QStringView candidate, QStringView query);
};
} // namespace katecustom
#endif

View File

@ -0,0 +1,170 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "fuzzyranker.h"
#include <QTest>
#include <QList>
#include <QString>
#include <algorithm>
using namespace katecustom;
namespace
{
// Rank a list of candidates against a query, return the matching names
// highest-score-first.
QStringList rank(const QStringList &candidates, const QString &query)
{
const QList<QString> needles = FuzzyRanker::tokenize(query);
struct Scored {
QString name;
int score;
};
QList<Scored> hits;
for (const QString &c : candidates) {
const MatchResult r = FuzzyRanker::score(c, needles);
if (r.matched) {
hits.push_back({c, r.score});
}
}
std::stable_sort(hits.begin(), hits.end(),
[](const Scored &a, const Scored &b) { return a.score > b.score; });
QStringList out;
for (const Scored &h : hits) {
out << h.name;
}
return out;
}
}
class TestFuzzyRanker : public QObject
{
Q_OBJECT
private Q_SLOTS:
// --- orderless: space-separated tokens match in any order --------------
void orderless()
{
const QStringList cmds = {
QStringLiteral("Rename Symbol"),
QStringLiteral("Symbol Rename Cursor"),
QStringLiteral("Open File"),
QStringLiteral("Reindent Lines"),
};
const QStringList got = rank(cmds, QStringLiteral("ren sym"));
QVERIFY2(got.contains(QStringLiteral("Rename Symbol")),
"orderless must match 'Rename Symbol'");
QVERIFY2(got.contains(QStringLiteral("Symbol Rename Cursor")),
"orderless must match tokens in reversed order too");
QVERIFY2(!got.contains(QStringLiteral("Open File")), "non-matches rejected");
}
void orderlessTokenOrderIrrelevant()
{
const QString cand = QStringLiteral("Toggle Comment");
QVERIFY(FuzzyRanker::score(cand, QStringLiteral("tog com")).matched);
QVERIFY(FuzzyRanker::score(cand, QStringLiteral("com tog")).matched);
}
// --- typo tolerance: KFuzzyMatcher explicitly fails this ---------------
void typoTolerance()
{
// "gti" is a transposition of "git"; strict subsequence fails it.
QVERIFY2(FuzzyRanker::score(QStringLiteral("git"), QStringLiteral("gti")).matched,
"typo 'gti' must match 'git'");
QVERIFY2(FuzzyRanker::score(QStringLiteral("Git Blame"), QStringLiteral("gti")).matched,
"typo 'gti' must match within 'Git Blame'");
}
void inOrderStillWins()
{
// Exact-order "git" should outrank the typo "gti" against same target.
const MatchResult good = FuzzyRanker::score(QStringLiteral("git"), QStringLiteral("git"));
const MatchResult typo = FuzzyRanker::score(QStringLiteral("git"), QStringLiteral("gti"));
QVERIFY(good.matched && typo.matched);
QVERIFY2(good.score > typo.score, "exact order must score higher than typo");
}
// --- ranking: better match kinds first ---------------------------------
void substringBeatsSubsequence()
{
const QStringList cmds = {
QStringLiteral("Reformat Document"), // 'r','e','f' subsequence
QStringLiteral("Refactor"), // 'ref' substring at start
};
const QStringList got = rank(cmds, QStringLiteral("ref"));
QCOMPARE(got.first(), QStringLiteral("Refactor"));
}
void initialsMatch()
{
// "rs" should match the word initials of "Rename Symbol".
const MatchResult r = FuzzyRanker::score(QStringLiteral("Rename Symbol"),
QStringLiteral("rs"));
QVERIFY(r.matched);
}
void shorterCandidatePreferred()
{
const QStringList cmds = {
QStringLiteral("sort lines by column descending"),
QStringLiteral("sort"),
};
const QStringList got = rank(cmds, QStringLiteral("sort"));
QCOMPARE(got.first(), QStringLiteral("sort"));
}
// --- AND semantics: every needle must match ----------------------------
void andSemantics()
{
QVERIFY(!FuzzyRanker::score(QStringLiteral("Rename Symbol"),
QStringLiteral("ren xyz")).matched);
}
// --- empty query matches everything ------------------------------------
void emptyQueryMatchesAll()
{
const MatchResult r = FuzzyRanker::score(QStringLiteral("anything"), QStringLiteral(""));
QVERIFY(r.matched);
QVERIFY(r.ranges.empty());
}
// --- highlight ranges are correct & merged -----------------------------
void highlightRangesContiguous()
{
const MatchResult r = FuzzyRanker::score(QStringLiteral("Refactor"),
QStringLiteral("ref"));
QCOMPARE(r.ranges.size(), size_t(1));
QCOMPARE(r.ranges.front().start, 0);
QCOMPARE(r.ranges.front().length, 3);
}
void highlightRangesSplit()
{
// Subsequence 'rf' in "Refactor": R(0), e(1), f(2) -> R at 0, f at 2,
// i.e. two non-contiguous single-char ranges.
const MatchResult r = FuzzyRanker::score(QStringLiteral("Refactor"),
QStringLiteral("rf"));
QVERIFY(r.matched);
QCOMPARE(r.ranges.size(), size_t(2));
QCOMPARE(r.ranges[0].start, 0);
QCOMPARE(r.ranges[0].length, 1);
QCOMPARE(r.ranges[1].start, 2);
QCOMPARE(r.ranges[1].length, 1);
}
// --- case insensitivity -------------------------------------------------
void caseInsensitive()
{
QVERIFY(FuzzyRanker::score(QStringLiteral("Open File"),
QStringLiteral("OPEN")).matched);
QVERIFY(FuzzyRanker::score(QStringLiteral("OPEN FILE"),
QStringLiteral("open")).matched);
}
};
QTEST_APPLESS_MAIN(TestFuzzyRanker)
#include "test_fuzzyranker.moc"