kate-deft/docs/PLAN.md

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-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.

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 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 -> Rename Symbol)
    4. in-order subsequence
    5. bounded typo via a single adjacent transposition (gti -> git; deliberately narrow to avoid false positives like ren -> "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, including gti -> git and ren sym -> Rename Symbol.

M2 — Palette widget (replaces KCommandBar) — DONE

  • PaletteModel (src/palette/palettemodel.{h,cpp}): QAbstractListModel, re-ranks via FuzzyRanker on setQuery, 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. Emits activatedId(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 loadable KTextEditor::Plugin (olliepalette.so) built with kcoreaddons_add_plugin.
  • OllieView (one per MainWindow) provides the M-x launcher on Alt+X. LAUNCHER KEYS ARE NOT QAction SHORTCUTS: Alt+X (M-x), Alt+P (Go to File) and Alt+G (Go to Symbol) are intercepted in eventFilter() (accept the ShortcutOverride, act on the KeyPress), scoped to this plugin's window. Registering them as ApplicationShortcut QActions clashed with Kate's own Alt+<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 in onRadialActivated).
  • On trigger it aggregates every enabled, visible QAction from the window's guiFactory()->clients() action collections, keyed by stable objectName, and feeds them to PaletteWidget. The action's current shortcut is shown in the group column. Accepting a row triggers the underlying QAction.
  • Installs to kf6/ktexteditor; metadata verified valid via KPluginMetaData.
  • Frecency: wired. OllieView holds a persisted FrecencyStore; activations are recorded and PaletteItem::frecency is 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) RadialNode tree, 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 translucent QWidget; 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 following ContextMenu are 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 special ollie_command_palette slice 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 trigger and a mouse button per 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 of go_*_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_view slice (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 (one wl_surface), so this is a warp within that surface — pure geometry (view->mapTo(topLevel, view->rect().center())). On X11 it uses QCursor::setPos. On Wayland (which forbids that) it uses the zwp_pointer_constraints_v1 protocol: 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 in src/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_plugin drops .c sources). 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.json when present and non-empty, otherwise returns builtinDefault(); a present-but-broken file also falls back (never leaves the user with no radials). userConfigPath() and loadFile(path, &error) are exposed for callers/tests. setupRadials() now calls load(). Example config at docs/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 acme text.c (^A/^U stop at the line's newline; ^W skips 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 the ShortcutOverride for these combos (so Kate's shortcut never fires) and acts on the KeyPress, consuming it — no registered shortcut, so no ambiguity, no per-view sweeping.

M5 — Command vocabulary + project model + switchers — DONE

  • :-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 in OllieCommands::runVerb(view, cmd, msg) and transforms the selection in place (or the whole document when nothing is selected); pipe <shell> runs sh -c <shell> with the selection on stdin.
    • Verbs are invoked from the M-x palette (entries prefixed ollie:cmd:, executed by calling OllieCommands::runVerb directly). case is expanded into one entry per style; pipe is palette-omitted (needs free text).
    • They are also reachable from Kate's : command line. OllieCommands is a KTextEditor::Command; its base constructor auto-registers its names at the global Editor::instance(), and exec() forwards to runVerb.
    • 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 with sort, which Kate ships as a built-in, so the collision silently dropped all our verbs. The sort verb is now registered as osort (runVerb still accepts the bare sort, so palette entries are unchanged). test_ollieregistration asserts every verb resolves to our object via Editor::queryCommand/commandList and guards against reclaiming sort.
    • A live test (test_olliecommands_live, headless KTextEditor doc/view) proves the transforms mutate a real buffer.
  • 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 via findRoot(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 KateProjectBridge below).
    • KateProjectBridge (src/project/kateprojectbridge.{h,cpp}, in project_lib, 7 unit tests) reads Kate's built-in kateprojectplugin view through the meta-object system — projectBaseDir / projectName / projectFiles properties and the projectMapChanged / projectFileNameChanged / pluginProjectAdded|Removed signals (relayed to a single projectChanged()), with no link dependency. OllieView attaches it on construction and follows MainWindow::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) prefers git ls-files --cached --others --exclude-standard inside 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 is currentProjectRoot() (Kate's loaded project, else derived). Activation opens the file via MainWindow::openUrl + activateView. Also reachable from the M-x palette (ollie:switch:file) and, by objectName ollie_goto_file, from a radial. (2 path-ranking tests in test_palettemodel.)
    • PERFORMANCE: file/symbol indexing runs off the UI thread via ProjectIndexer (QtConcurrent, cached per root, invalidated on projectChanged); the palette opens instantly and fills in. The palette re-rank is incremental on type-forward (see PaletteModel). See docs/PERF.md for measured numbers.
  • Fuzzy symbol switcher scoped to the folder: DONE. src/project/symbolindex.{h,cpp} (in project_lib, 7 unit tests incl. a live ctags run):
    • System ctags is Exuberant 5.9 (no JSON), so parseTags parses the classic extended tab format from ctags -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). Each Symbol carries 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 via MainWindow::openUrl and 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 objectName ollie_goto_symbol. (Alt+R is reserved for the radial key trigger, so this uses Alt+G.)
  • Frecency (usage-aware ranking) — DONE. src/palette/frecencystore.{h,cpp} (in palette, Qt::Core only, 11 unit tests):
    • Per-id {count, last-used} persisted as flat JSON (via QSaveFile) 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: OllieView loads the store in its ctor, every activation (runAction for the M-x palette; the file/symbol switcher lambdas) calls recordUsage (bump + save), and each palette sets PaletteItem::frecency from bonus() before showing. File/symbol keys are project-qualified (prefixed by the resolved root) so usage never bleeds between repos that share relative paths.

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 over QLocalSocket — Tversion/Tattach/Twalk/Topen/Tread/ Twrite/Tclunk, little-endian, msize negotiated from 8192. No linking of plan9port's C libplumb/lib9pclient; it's just the wire protocol, verified against /usr/local/plan9 sources. For the blocking edit-port read it adds a cancellable split (beginRead + recvReadReply with a first-byte timeout) so the reader thread polls its stop flag between windows and never issues a second Tread on the same tag, and never touches the socket from another thread.
  • Message codec (src/plumb/plumbmsg.{h,cpp}): PlumbMsg::pack/unpack with 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}): plan9 addr (N, N:C, N.C, 1-based) → 0-based KTextEditor::Cursor. Unit-tested.
  • Facade (src/plumb/plumber.{h,cpp}): Plumber::send(data, wdir) opens the send port and writes a packed message (src=kate, empty dst so the rules route it); a PlumbReader QThread holds the edit port open and emits edit(file, addr, wdir) via a queued signal. Namespace socket resolved as $NAMESPACE/plumb, else /tmp/ns.$USER.$DISPLAY/plumb (same as plan9 getns()), display canonicalised.
  • Plugin wiring (src/plugin/ollieplugin.cpp): an ollie_plumb QAction 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, else Document::wordAt(cursorPosition), with the active doc's dir as wdir; on send failure it falls back to an internal resolver (URL → QDesktopServices, path[:line] → open here). Incoming edit messages are opened via MainWindow::openUrl + activateView + setCursorPosition; a plumbed path that resolves to a directory opens as a folder in Kate instead (relaunches the hosting kate binary on the dir — Kate is single-instance, so it opens in the running window; no in-process folder-open API exists, so this is the documented command-line path).
  • Verification: test_plumbmsg + test_plumbresolve run headless; test_plumb_live does 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 (plan9port src/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*)*b blowup). 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 plan9port src/cmd/sam (cmd.c command table, address.c address eval incl. lineaddr/charaddr, xec.c s_cmd/looper).

  • Language: addresses #n, n, 0, $, ., ' (k mark within a run), /re/, ?re?, compound + - , ; with sam defaults/precedence; commands a c i d, s (with sN, g, &, \1..\9), p =, m t, k, loops x y g v (nestable, {} groups), and shell filters < > | ! via sh -c. 27 unit tests cover each.

  • Multi-file X/Y (SamEngine::peelFileLoop + Kate driver): a leading X/re/ cmd runs cmd on every open buffer whose path matches re (Y = non-match). The set is the documents Kate currently has open (Application::documents()) — acme's open-window set — matched by local file path, or display name for an unsaved scratch buffer. Nothing is opened or read from disk; the live Documents are edited in place. sam permits only one X/Y per 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-file X/Y is undoable per buffer (KTextEditor has no global multi-file undo).

  • Panel (src/plugin/sampanel.{h,cpp}): program editor + Run button + output log; OllieView creates 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 own u (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.