18 KiB
18 KiB
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-xand 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.
Guiding principle: natural actions drive editing
Each door suits a different kind of action, and placing an action in the wrong door breaks flow:
- Radial is for actions that complete in place — cut, copy, paste, comment, case changes, join lines. The gesture starts and ends at the cursor; attention never leaves the text.
- Actions that open a panel demanding immediate focus elsewhere (Find, Replace, goto) do not belong in the radial. Being flung to a panel right after a cursor gesture is jarring. These belong in the command palette or a keybind, where the context switch is expected. The radial keeps a "Command Palette…" slice as the escape hatch to them.
Why a custom matcher is the keystone
Kate's built-in command bar cannot be fixed in place:
KCommandBar(KConfigWidgets) exposes onlysetActions(). 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:
- exact
- substring
- word-boundary initials (
rs-> Rename Symbol) - in-order subsequence
- bounded typo via a single adjacent transposition (
gti->git; deliberately narrow to avoid false positives likeren-> "Open File")
- 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, includinggti -> gitandren sym -> Rename Symbol.
M2 — Palette widget (replaces KCommandBar) — DONE
PaletteModel(src/palette/palettemodel.{h,cpp}):QAbstractListModel, re-ranks viaFuzzyRankeronsetQuery, exposes id/label/group/score and highlight ranges per row, frecency bonus. No widget dependency.PaletteWidget(src/palette/palettewidget.{h,cpp}): frameless popup, filter line + results list, live company-style updates, top hit preselected, custom delegate bolds matched ranges. Keyboard: Up/Down/PageUp/PageDown, Enter activates, Esc cancels — cursor stays in the filter line, no chords. Mouse: click a row to activate. EmitsactivatedId(id)/cancelled().src/palette/test_palettemodel.cpp: 10 QTest cases, all green (filtering, orderless, typo, best-first ordering, frecency tie-break, highlight ranges).
M3 — KTextEditor plugin + M-x + action registry — DONE
src/plugin/ollieplugin.{h,cpp}+ollieplugin.json: a loadableKTextEditor::Plugin(olliepalette.so) built withkcoreaddons_add_plugin.OllieView(one per MainWindow) provides the M-x launcher onAlt+X. LAUNCHER KEYS ARE NOT QAction SHORTCUTS:Alt+X(M-x),Alt+P(Go to File) andAlt+G(Go to Symbol) are intercepted ineventFilter()(accept theShortcutOverride, act on theKeyPress), scoped to this plugin's window. Registering them asApplicationShortcutQActions clashed with Kate's ownAlt+<letter>bindings and Qt refused to fire either ("ambiguous shortcut"). The event-filter route cannot be ambiguous because nothing is registered — the same technique already used for the Acme line-editing keys. The QActions still exist (objectName/label) for command-palette harvest and as radial escape-hatch slices (ollie_command_palette/ollie_goto_file/ollie_goto_symbol, handled directly inonRadialActivated).- On trigger it aggregates every enabled, visible
QActionfrom the window'sguiFactory()->clients()action collections, keyed by stableobjectName, and feeds them toPaletteWidget. The action's current shortcut is shown in the group column. Accepting a row triggers the underlyingQAction. - Installs to
kf6/ktexteditor; metadata verified valid viaKPluginMetaData. - Frecency: wired.
OllieViewholds a persistedFrecencyStore; activations are recorded andPaletteItem::frecencyis populated for every palette (see the Frecency entry under M5). - Duplicate handling: actions are deduplicated only by pointer identity (the exact same action object reachable through several GUI clients is listed once — functionally lossless). Distinct actions are never dropped. When two distinct actions would render with the same label, the label is enriched through a fallback chain until every visible row is unique: component name → objectName → shortcut → numeric suffix. Each entry also gets a unique id so activation always triggers the intended action.
M4 — Radial caret menu (mouse door) — DONE (first increment)
src/radial/radialmodel.{h,cpp}: pure tree + geometry. Multi-level (branch/leaf)RadialNodetree, angle hit-testing (0 = up, clockwise), inner dead zone, far-flick snaps by angle, descend/ascend navigation. 14 tests.src/radial/radialmenu.{h,cpp}: frameless translucentQWidget; paints the current ring (wedges, labels, branch ▸, central "back" hub), highlights the slice under the pointer. Two interaction modes from one widget: a drag gesture (opened with a button held — drag out and release to select; a release that never left the dead zone switches to click mode and keeps the menu open) and a discrete click mode (click a slice to select, click outside the ring to cancel). Esc ascends/cancels. Opens centred on the given point without moving the caret.src/radial/radialconfig.{h,cpp}: JSON → radial tree parser (+ built-in default) so multiple radials can be bound to different triggers. 8 tests.- Wired into the plugin:
OllieView::setupRadials()registers a key trigger (default Alt+R) and a mouse-button gesture (default right mouse button) per configured radial. Because the opening RMB press leaves an implicit grab on the editor view, the whole gesture (hover + release) is driven from the plugin's application event filter in global coordinates (driveHoverGlobal/driveReleaseGlobal), not from a popup mouse grab — this fixes stale hover and wrong-slice selection. The press and the followingContextMenuare consumed, so the context menu is suppressed and the caret does not move. Key-triggered radials pop at the caret (View::cursorPositionCoordinates()) and use the widget's own click handling. - A slice may reference any Kate action by objectName, resolved via
findActionByName; the specialollie_command_paletteslice opens the palette. - Honours the agreed model: radials are triggerable by keys (weapon-wheel style)
and by mouse buttons/gestures; the config carries both a key
triggerand a mousebuttonper radial. - Not yet done: free-form stroke gestures (only button-press gesture so far).
- User-file radial config — DONE.
RadialConfig::load()reads$XDG_CONFIG_HOME/olliepalette/radials.jsonwhen present and non-empty, otherwise returnsbuiltinDefault(); a present-but-broken file also falls back (never leaves the user with no radials).userConfigPath()andloadFile(path, &error)are exposed for callers/tests.setupRadials()now callsload(). Example config atdocs/radials.example.json. 4 added tests (valid file, missing, invalid JSON, non-empty fallback).
Mouse chording (Acme-style) — DONE
- Left-button chords (button 1 held while selecting is the anchor):
left + middle = Cut, left + right = Paste. Both use
edit_cut/edit_paste→ the standard KDE clipboard (Klipper-backed), not the X11 primary selection. - The right-button paste chord is free of conflict because the radial gesture is already suppressed while the left button is held: right alone = radial, right while left-held = paste.
- Cut/Copy/Paste were removed from the radial once the chords covered them — the radial should not duplicate what a more in-flow gesture already does.
- Middle-click primary paste is suppressed inside the editor — the middle
button is consumed on both press and release (the paste fires on release, so
swallowing only the press leaked it). Paste in the editor is the KDE-clipboard
paste (
Ctrl+V, palette, or the left+right chord).
Bracket-pair double-click selection — DONE
- Double-clicking next to a bracket (
()[]{}) selects the text strictly between the matching pair, multi-line aware and nesting-aware. Adjacency is checked on both sides of the click; openers select forward, closers select backward. - Implemented in the event filter via
selectBracketPairAt: maps the click to a document cursor (coordinatesToCursor), reads neighbouring characters (characterAt), scans with depth counting across line ends, and sets the selection. When no bracket is adjacent it falls through to Kate's default double-click (word select). - A left double-click leaves the button held, so it also arms the chord anchor: the user can go straight from a double-click (bracket- or word-select) into a left+middle / left+right chord without releasing.
Acme line-editing keys — DONE
- Keys bound (overriding Kate/KDE defaults,
ApplicationShortcut): C-a beginning of line, C-e end of line, C-h erase char back, C-u erase to line start, C-w erase word back. Semantics ported from plan9port acmetext.c(^A/^Ustop at the line's newline;^Wskips non-alphanumerics then eats the alphanumeric run; isalnum excludes_). - Implemented directly against the View/Document API (
cursorPosition,setCursorPosition,removeText,characterAt,lineLength). - Bound via the application event filter, not QActions: QActions for
Ctrl+A/W/…collide with Kate's own (Select All, Close) and Qt reports ambiguous shortcuts. Instead the filter accepts theShortcutOverridefor these combos (so Kate's shortcut never fires) and acts on theKeyPress, consuming it — no registered shortcut, so no ambiguity, no per-view sweeping.
M5 — Command vocabulary + project model + switchers — IN PROGRESS
:-verb pack — DONE (palette and:command line).src/commands/:texttransforms.{h,cpp}(pure, unit-tested: sort/rsort, case upper|lower|title|snake|camel|kebab, base64 enc/dec, rot13, uuid) +olliecommands.{h,cpp}. The verb logic lives inOllieCommands::runVerb(view, cmd, msg)and transforms the selection in place (or the whole document when nothing is selected);pipe <shell>runssh -c <shell>with the selection on stdin.- Verbs are invoked from the M-x palette (entries prefixed
ollie:cmd:, executed by callingOllieCommands::runVerbdirectly).caseis expanded into one entry per style;pipeis palette-omitted (needs free text). - They are also reachable from Kate's
:command line.OllieCommandsis aKTextEditor::Command; its base constructor auto-registers its names at the globalEditor::instance(), andexec()forwards torunVerb. - ROOT-CAUSE FIX of the earlier "not reachable from
:" problem: the KTextEditor registry aborts registering a Command object entirely if any one of its names collides with an already-registered command. Our list led withsort, which Kate ships as a built-in, so the collision silently dropped all our verbs. The sort verb is now registered asosort(runVerb still accepts the baresort, so palette entries are unchanged).test_ollieregistrationasserts every verb resolves to our object viaEditor::queryCommand/commandListand guards against reclaimingsort. - A live test (
test_olliecommands_live, headless KTextEditor doc/view) proves the transforms mutate a real buffer.
- Verbs are invoked from the M-x palette (entries prefixed
- Project = directory ("open folder == open project"): DONE (explicit + core +
switcher).
src/project/projectindex.{h,cpp}(project_lib, Qt::Core only, 15 unit tests):findRoot(startDir)walks up for a VCS marker (.git/.hg/.svn) and returns that ancestor, else the start directory itself — so any folder is a usable project with zero config.resolveRoot(explicitRoot, startDir)is the effective-root decision: an existing explicit pick is honoured exactly (no walk-up — VSCode/Sublime "open folder" semantics); otherwise it derives viafindRoot(startDir)with cwd as the final fallback. A stale explicit root reverts to discovery.- Explicit folder-as-project is not reimplemented: Kate's project plugin
already opens folders and shows a file tree, so we build on it rather than
duplicate it (see
KateProjectBridgebelow). KateProjectBridge(src/project/kateprojectbridge.{h,cpp}, inproject_lib, 7 unit tests) reads Kate's built-inkateprojectpluginview through the meta-object system —projectBaseDir/projectName/projectFilesproperties and theprojectMapChanged/projectFileNameChanged/pluginProjectAdded|Removedsignals (relayed to a singleprojectChanged()), with no link dependency.OllieViewattaches it on construction and followsMainWindow::pluginViewCreated/pluginViewDeleted. Tested headlessly against a stand-in QObject that mirrors the plugin's property/signal surface.currentProjectRoot()is a two-tier resolution:KateProjectBridge::baseDir()(Kate's loaded project) →ProjectIndex::resolveRoot("", activeDocDir)(VCS discovery, cwd fallback).listFiles(root)prefersgit ls-files --cached --others --exclude-standardinside a git work tree (honours .gitignore, includes untracked-not-ignored), and falls back to a bounded recursive walk (walkDirectory) that skips noise dirs (node_modules, build, target, …) and symlinks when there is no git (or git yields nothing).- Wired into the plugin: Alt+P "Go to File (project)" opens a second
PaletteWidget populated with the project's files. The full project-relative
path is the matched/displayed label, so FuzzyRanker scores across the
whole path (orderless: "palette model" matches
src/palette/palettemodel.cpp) and highlights matched characters along it; a base-name hit still ranks first because/is a word boundary and the length penalty favours shorter paths. The root iscurrentProjectRoot()(Kate's loaded project, else derived). Activation opens the file viaMainWindow::openUrl+activateView. Also reachable from the M-x palette (ollie:switch:file) and, by objectNameollie_goto_file, from a radial. (2 path-ranking tests intest_palettemodel.)
- Fuzzy symbol switcher scoped to the folder: DONE.
src/project/symbolindex.{h,cpp}(inproject_lib, 7 unit tests incl. a live ctags run):- System ctags is Exuberant 5.9 (no JSON), so
parseTagsparses the classic extended tab format fromctags -f - -L - --fields=+nK --excmd=number --sort=no(file list fed on stdin so a big project never overflows ARG_MAX; paths relative to the root). EachSymbolcarries name / file / line / kind / scope. - Wired into the plugin: Alt+G "Go to Symbol (project)" opens a third
PaletteWidget of the project's symbols. The label is
Scope::name — kind file— the name leads (so a name hit ranks first) while the kind and file are both visible and filterable in the same query (e.g. "method olliecommands"). Activation opens the file viaMainWindow::openUrland jumps the cursor to the symbol's line (ctags 1-based → cursor 0-based). Also in the M-x palette (ollie:switch:symbol) and reachable from a radial by objectNameollie_goto_symbol. (Alt+R is reserved for the radial key trigger, so this uses Alt+G.)
- System ctags is Exuberant 5.9 (no JSON), so
- Frecency (usage-aware ranking) — DONE.
src/palette/frecencystore.{h,cpp}(inpalette, Qt::Core only, 11 unit tests):- Per-id
{count, last-used}persisted as flat JSON (viaQSaveFile) at$XDG_CONFIG_HOME/olliepalette/frecency.json.bonus(id, now)combines a recency bucket (<1d=100, <1w=70, <1mo=50, <3mo=30, else 10) with a visit count capped at 10 → an integer in [0,100]. The cap keeps the bonus well below a textual-match tier (substring=400) so frecency nudges ordering and breaks ties without overriding a clearly better match; on an empty query it floats habitual choices to the top. - Wired into the plugin:
OllieViewloads the store in its ctor, every activation (runActionfor the M-x palette; the file/symbol switcher lambdas) callsrecordUsage(bump + save), and each palette setsPaletteItem::frecencyfrombonus()before showing. File/symbol keys are project-qualified (prefixed by the resolved root) so usage never bleeds between repos that share relative paths.
- Per-id
Build and test
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.