244 lines
14 KiB
Markdown
244 lines
14 KiB
Markdown
# 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`.
|
|
- Not yet wired: frecency (the model supports a bonus, but the plugin currently
|
|
passes 0 for every action — usage tracking is deferred).
|
|
- 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 config loading (built-in default only); frecency.
|
|
|
|
### 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 <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).
|
|
- 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.)
|
|
|
|
## 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.
|