26 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).
- Built-in radials: an edit radial (Alt+R / right button) of in-place
actions, and a window radial (middle button) for split management
(
view_split_vert|horiz,view_close_current_space|others,view_split_toggle, and a "Focus" sub-branch ofgo_*_split_view). A plain middle press (no left button held) routes to this radial; the Acme chords are all left+something, so a middle press that is part of a chord (left held) still cuts and never opens the radial. The X11 middle-click primary paste is suppressed on release either way. - Cursor-follows-focus: after a radial
go_*_split_viewslice (the window radial's "Focus" sub-branch) the mouse pointer warps onto the newly active pane. Kate's split panes are child widgets of one top-level window (onewl_surface), so this is a warp within that surface — pure geometry (view->mapTo(topLevel, view->rect().center())). On X11 it usesQCursor::setPos. On Wayland (which forbids that) it uses thezwp_pointer_constraints_v1protocol: lock the pointer to the window surface,set_cursor_position_hint, commit, then release — KWin warps to the hint on unlock (the mechanism plan9port/acme use). Implemented insrc/plugin/waylandcursorwarp.{h,cpp}; the pointer-constraints client glue is generated by ECM (ecm_add_wayland_client_protocol) and compiled as a small C static lib (kcoreaddons_add_plugindrops.csources). Everything is guarded: no Wayland global / surface →warp()returns false and the caller falls back. The feature compiles out entirely if wayland-scanner / wayland-client / Qt6 GuiPrivate are absent (a stub provides the symbols). - 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.) - PERFORMANCE: file/symbol indexing runs off the UI thread via
ProjectIndexer(QtConcurrent, cached per root, invalidated onprojectChanged); the palette opens instantly and fills in. The palette re-rank is incremental on type-forward (seePaletteModel). Seedocs/PERF.mdfor measured numbers.
- 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
Plumbing — plan9 "plumb to edit", full client — DONE
Kate is a first-class plan9port plumb client: it both sends plumb messages
and receives them on the edit port, so files plumbed from anywhere (acme,
plumb(1), other tools) open in Kate at the right line, and tokens plumbed from
Kate flow through the real plumber rules.
- Native C++ 9P2000 (
src/plumb/ninep.{h,cpp}): a minimal synchronous client overQLocalSocket—Tversion/Tattach/Twalk/Topen/Tread/Twrite/Tclunk, little-endian, msize negotiated from 8192. No linking of plan9port's Clibplumb/lib9pclient; it's just the wire protocol, verified against/usr/local/plan9sources. For the blocking edit-port read it adds a cancellable split (beginRead+recvReadReplywith a first-byte timeout) so the reader thread polls its stop flag between windows and never issues a secondTreadon the same tag, and never touches the socket from another thread. - Message codec (
src/plumb/plumbmsg.{h,cpp}):PlumbMsg::pack/unpackwith libplumb's exact attribute quoting ('-quote values with space/'/=/tab,''escapes'). Unit tests pin it to golden bytes captured from a live plumber on the edit port, e.g.plumb\nedit\n/tmp\ntext\naddr=2\n18\n/tmp/ plumbtest.txt. - Address parsing (
src/plumb/plumbresolve.{h,cpp}): plan9addr(N,N:C,N.C, 1-based) → 0-basedKTextEditor::Cursor. Unit-tested. - Facade (
src/plumb/plumber.{h,cpp}):Plumber::send(data, wdir)opens thesendport and writes a packed message (src=kate, emptydstso the rules route it); aPlumbReaderQThreadholds theeditport open and emitsedit(file, addr, wdir)via a queued signal. Namespace socket resolved as$NAMESPACE/plumb, else/tmp/ns.$USER.$DISPLAY/plumb(same as plan9getns()), display canonicalised. - Plugin wiring (
src/plugin/ollieplugin.cpp): anollie_plumbQAction on F2 (testing trigger; a function key, so no collision with the Alt+letter door policy — final gesture will be RMB).plumbAtCursor()plumbs the selection, elseDocument::wordAt(cursorPosition), with the active doc's dir aswdir; on send failure it falls back to an internal resolver (URL →QDesktopServices, path[:line] → open here). Incomingeditmessages are opened viaMainWindow::openUrl+activateView+setCursorPosition. - Verification:
test_plumbmsg+test_plumbresolverun headless;test_plumb_livedoes a real round-trip against a running plumber (send<file>:2→ rules → edit port →edit()signal) and skips+passes when no plumber/namespace is reachable, so CI stays green. The live round-trip was confirmed passing. The GUI open/cursor path (openUrl/setCursorPosition) is not exercisable headless and is unverified on a live display.
Sam — structural-regexp editing panel — DONE
A dockable tool view ("Sam", left sidebar) runs the plan9 sam command
language against the active document, or project-wide via X/Y. Type a
program, click Run (or Ctrl+Return); each Run is one undo step.
-
Regex engine (
src/sam/samregex.{h,cpp}, 18 unit tests): a faithful port of sam's own matcher (plan9portsrc/cmd/sam/regexp.c) — a Thompson/Pike NFA. Leftmost-longest (POSIX), so it matches real sam exactly incl. overlapping alternation (a|ab→ab); linear time, no backtracking (immune to(a*)*bblowup). sam dialect only:. * + ? | ( ) [ ] ^ $ \with\n;^/$are per-line and./negated-classes exclude newline, all intrinsic. No PCRE extras — not a deviation, just sam. Replaces the earlier QRegularExpression matcher, eliminating the greedy-vs-longest divergence entirely. -
Pure engine (
src/sam/samengine.{h,cpp}, unit-tested, no Kate dep):SamEngine::run(program, text, dotStart, dotEnd) -> SamResult{edits, dot, output, applied}. Edits are computed against the ORIGINAL snapshot in character offsets (sam computes all of a command's change addresses in the original file — see sam(1) "Grouping and multiple changes") and returned non-overlapping and sorted. A hand-written recursive-descent parser + evaluator mirrors plan9portsrc/cmd/sam(cmd.ccommand table,address.caddress eval incl.lineaddr/charaddr,xec.cs_cmd/looper). -
Language: addresses
#n,n,0,$,.,'(k mark within a run),/re/,?re?, compound+ - , ;with sam defaults/precedence; commandsa c i d,s(withsN,g,&,\1..\9),p =,m t,k, loopsx y g v(nestable,{}groups), and shell filters< > | !viash -c. 27 unit tests cover each. -
Multi-file
X/Y(SamEngine::peelFileLoop+ Kate driver): a leadingX/re/ cmdrunscmdon every project file whose path matchesre(Y= non-match). The file set is the project index (ProjectIndexer::cachedFiles); each matched file is opened as a Document and edited. sam permits only oneX/Yper command, so this is leading-token recognition, not a nested parse. -
Undo: single-document Run applies all edits inside one
Document::EditingTransaction→ one Ctrl+Z reverts the whole Run. Multi-fileX/Yis undoable per file (KTextEditor has no global multi-file undo). -
Panel (
src/plugin/sampanel.{h,cpp}): program editor + Run button + output log;OllieViewcreates the tool view and owns the apply logic (runSamProgram,applySamToDocument,runSamFileLoop). -
Dialect & semantics: the regex engine is sam's own (see the Regex engine bullet above), so matching is leftmost-longest and the dialect is sam's — no PCRE, no greedy-vs-longest divergence, no PCRE extras. Full user-facing treatment with verified examples in
docs/SAM.md. Out of scope: multi-file menu (b B n D), external file I/O (e r w f),"re"file-addressing, and sam's ownu(Kate's undo stack is the undo mechanism). The live GUI panel (tool view + transaction apply) is not exercisable headless and is unverified on a live display.
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.