# 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) registers the **M-x** action, `Alt+X`, a single modifier with the keys adjacent on a compact layout; `ApplicationShortcut` context so it is always live. - 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). - 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 path). `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). - A live test (`test_olliecommands_live`, headless KTextEditor doc/view) proves the transforms mutate a real buffer. - KNOWN LIMITATION: `KTextEditor::Command` auto-registration via the base constructor is NOT visible to `Editor::queryCommand()`/`commandList()` on this build, so the verbs are not (yet) reachable from Kate's `:` command line — only via the palette. Proper command-line registration is a follow-up. - Project = directory ("open folder == open project"): DONE (core + switcher). `src/project/projectindex.{h,cpp}` (`project_lib`, Qt::Core only, 12 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. - `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 derived from the active document's directory (fallback: cwd). 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`.) - 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. ## 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.