# 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` -> **R**ename **S**ymbol) 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+` 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 — 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 in `OllieCommands::runVerb(view, cmd, msg)` and transforms the selection in place (or the whole document when nothing is selected); `pipe ` runs `sh -c ` 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`. - **Verification**: `test_plumbmsg` + `test_plumbresolve` run headless; `test_plumb_live` does a **real round-trip** against a running plumber (send `: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. - **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 project file whose path matches `re` (`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 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 file** (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`). - **Deviations (documented)**: regexes use `QRegularExpression` (PCRE), not plan9 `regexp(7)` — practical patterns match identically; `longest-leftmost` edge cases differ. 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 ```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.