Simple vim key movements to make writing a delight - #10
Conversation
The diamond next to Save switches the editor into modal editing, and
the choice persists through QSettings like the window geometry. Off by
default; nothing changes for anyone who never turns it on.
The modal grammar lives in src/VimEngine.js as a stateless library in
the mold of EditorMutations.js, with per-window state on a small
QtObject in Main.qml. NORMAL, INSERT, VISUAL, and V-LINE modes are
signalled by a block cursor and a footer label. Motions take counts,
d c y compose with motions and text objects, dot repeat replays the
last change, and an ex command line covers :w :q :wq :q! and :{line}.
Slash opens the existing search bar, with n and N walking the matches.
Prose shapes a few choices: j and k move by display line so wrapped
paragraphs read the way they scroll, words include apostrophes so
contractions travel whole, and the clipboard doubles as the register,
with a trailing newline marking linewise yanks so dd and p round-trip
through other applications.
The backend gains the persisted vimMode property, a clipboard setter
for yanks, and replaceRange, which groups compound edits into one
QTextDocument edit block so a single undo reverts a whole change.
Astral characters step and delete whole, oversized counts stop at the
buffer edges, and failed motions abort their operator with the
register untouched. The test suite grows a vim harness covering
motions, operators, text objects, dot repeat, and the edge cases an
adversarial pass against real vim surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ryan Yogan proposed a second vim mode in omacom#10, offering to consolidate if one approach suited the app better. Both are worth keeping pieces of, so take his commit into this branch rather than paraphrasing his work: the consolidation that follows ports his text objects, sentence motions, ge, surrogate-pair stepping and the t/; repeat fix into src/Vim.js. This merge keeps our engine wired up and takes two things from his outright: the footer diamond that toggles vim mode, which is more discoverable than Ctrl+Alt+V alone, and Backend::setClipboardText, which the named registers ("+y, "*p) need. His src/VimEngine.js lands unregistered and inert, and goes away in the last port commit once its tests have moved into ours. His vimModeLabel is dropped for our vimStatus, which shows the pending count alongside the mode, so his footer test now looks for that instead.
diw, daw, dip, dap, di" and the bracket pairs: the spans an operator can take without a motion, which are the keys prose editing reaches for most. i and a become object prefixes while an operator waits or a selection is open, and stay the insert commands everywhere else. Ported from Ryan Yogan's src/VimEngine.js in omacom#10, rewritten against our character classes, which number the classes the other way round and already fold punctuation into words for the W forms. A linewise object runs past its last line break, so it hands applyOperator one character less; widening from there lands on the same line rather than eating the one below. Co-Authored-By: Ryan Yogan <ryanyogan@gmail.com>
Two motions a writer reaches for that neither ( nor ) nor ge previously did anything for. A sentence ends at . ! or ?, past any closing quote, followed by whitespace, and never runs past the end of its paragraph. ge runs backwards but is inclusive, which the shared motion path only handles forwards, so it hands applyOperator the range itself: back to the word end, forward through the character the caret sits on. Ported from Ryan Yogan's src/VimEngine.js in omacom#10. Co-Authored-By: Ryan Yogan <ryanyogan@gmail.com>
An emoji is two UTF-16 units, so l, h, x, r, s, a, ~ and p were stepping into the middle of one and cutting it in half. Every single-character step now goes through stepForward and stepBackward. r counts characters rather than code units when it repeats its replacement, so a run containing an emoji does not come back longer than it went in. Separately, a repeated t or T already sits one short of its target, so it found the same one again and stood still; ; now starts its search a character further on, while a fresh t still stops short of an adjacent match. Leaving insert at column zero no longer steps the caret onto the line above, since there is no character behind it to land on. Both ported from Ryan Yogan's src/VimEngine.js in omacom#10. Co-Authored-By: Ryan Yogan <ryanyogan@gmail.com>
A Markdown paragraph is one long line, so j jumped the whole of it and k came back over the whole of it: the two keys a writer presses most did not move the way the text reads. They now follow the wrapped line, and gj and gk reach the logical line instead — the mirror of vim, where g is the display-line prefix. Operators are untouched. dj, cj and yj still take whole lines, since that is what they do in vim and dgj is the display-line form. Finding a neighbouring line takes a probe loop rather than one positionAt: the document is set in 140% line spacing, and the leading between lines is dead space where positionAt resolves a column badly. Ported, with the goal-column handling, from Ryan Yogan's src/VimEngine.js in omacom#10. Return is now the linewise motion to the next line's first non-blank that it is in vim, rather than another name for j. Co-Authored-By: Ryan Yogan <ryanyogan@gmail.com>
Where a yank goes was the one real disagreement between the two vim proposals: ours kept an internal register, PR omacom#10 made every yank the system clipboard. Vim already answers this, so answer it vim's way instead of picking a side. Yanks and deletes still land in the unnamed register, which stays inside the editor, so an x never costs you what you copied from a browser. " names a register for the command after it: "a to "z hold text aside, and "+ and "* are the system clipboard and the primary selection, for when carrying text out of the window is what you meant. A named yank fills the unnamed register too, so a bare p still pastes whatever was last taken. A clipboard cannot carry the linewise flag, so a trailing newline stands in for it, which is how vim's own "+ reads a yanked line. That convention is Ryan Yogan's, from src/VimEngine.js in omacom#10, along with the setClipboardText this builds on; the "* register picks the primary selection where the desktop has one and falls back to the clipboard where it does not. Co-Authored-By: Ryan Yogan <ryanyogan@gmail.com>
Two edges the engine was walking into. Mid-composition the keys belong to the input method, so a dead key or a CJK candidate would otherwise run a command; the QML layer now checks inputMethodComposing before offering the key to vim, which is where the check belongs since the engine only sees a key name. A selection dragged out with the mouse now stands in for a visual range, so d or y after one does what it looks like it should rather than waiting for a motion that never comes. Both from Ryan Yogan's src/VimEngine.js in omacom#10. Co-Authored-By: Ryan Yogan <ryanyogan@gmail.com>
|
@ryanyogan — thanks for this, and for the offer to consolidate. I've done that on #7. Rather than paraphrase your work I merged your commit into that branch, so What went across:
On the register question I went a third way rather than choosing between us. Kept from mine: the host-adapter engine, the ex command language, the edit-block undo grouping, and the hook that skips zero-width Markdown markers so motions don't appear to stall on them.
Whether to close this one is yours and the maintainers' call, not mine. Either way, the app is better for #10 existing. |
Pasting a URL over a selection makes a Markdown link here, and o continues a list, but only when the app handled the key. Under vim mode the engine did its own thing, so the same keys lost both. The host adapter already exists for exactly this — settle reuses skipHiddenForward, page reuses movePage — so o and O now go through smartReturn, and a visual p defers to the editor's link paste. P stays the literal paste, and a count means the run was meant as text. The link rule follows the register rather than the clipboard, now that " names one: "+p from a browser and "ap yanked out of the document both wrap the selection. "+ still asks the clipboard first, which carries a uri-list that its plain text does not. Three bugs surfaced while wiring this up, all of them older than the feature. The first is mine, from resolving the merge in 7a69964: the onTextChanged handler that came over from omacom#10 still referenced the vim object I had removed with it. It threw on every text change with vim mode on, which aborted the handler, so backend.editorTextChanged() never ran — no modified flag, no word count, no search refresh, for as long as vim mode was on. The second is that an open edit block holds the document's change signals back, and TextEdit's text property only refreshes when one arrives. Any command that read the text after its own edit was reading the version from before it: 3J joined one line instead of three and then stopped, and the caret clamped against a document shorter than the real one, which dragged it back to where the edit began. The host now reads through the document itself while a block is open. The bare TextEdit the engine tests drive has no edit blocks, so none of this was visible there — the new assertions run in a real window. The third is that closing an edit block makes the document announce itself whether or not anything changed, so every keystroke reaches onTextChanged. Anything hanging off it has to ask whether the text really moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyQRJCyR76uk7XaNAB8jMC
|
I'll close the PR. Amazing work @rodgco ... very much looking forward to this simple yet speedy editor gaining some vim love :) |
A little vim toggle for people who think in modal editing. The diamond next to Save turns it on, and the choice sticks across launches. Off by default, so nothing changes for anyone who never touches it.
Summary
h j k l w b e 0 ^ $ gg G { } ( ) f t ; ,), operatorsd c ywith motions and text objects (iw,ip,i",i(), the usual shorthands (x D C Y s S J ~ r p P), dot repeat, and undo on the editor's own stack:w,:q,:wq,:q!, and:{line}./opens the existing search bar;nandNwalk the matchesjandkmove by display line so wrapped paragraphs read the way they scroll, words include apostrophes so contractions travel whole, and the clipboard doubles as the register soyyhere pastes cleanly anywheresrc/VimEngine.jsas a stateless library in the mold ofEditorMutations.js, with per-window state on a small QtObject inMain.qml. The backend gains a persistedvimModesetting and areplaceRangethat groups compound edits into one undo stepI realized after I pushed up what I have been using locally; there is a different vim PR #7: there are some differentials between this; a visible footer toggle instead of a shortcut, display-line
j/kfor soft-wrapped prose, and text objects. Happy to consolidate if one approach suits the app better (if this is wanted at all).Validation
./bin/build./bin/test: 15 tests passed. The vim harness covers motions, operators, text objects, dot repeat, and edge cases from an adversarial pass checked against real vim: last-line deletes, failed motions keeping the register, oversized counts, emoji stepping, and single-step undo of compound changes