Add opt-in vim key bindings - #7
Conversation
Normal mode has to swallow every printable key, so vim mode is off by default and toggles with Ctrl+Alt+V, remembered in QSettings. Insert mode consumes nothing but Escape, which leaves smart returns, list continuation and Markdown paste working exactly as they do with the mode off; only normal and visual mode reach the engine. Vim.js holds the state machine behind handleKey() and touches the editor through a host wrapper, so the tests drive a bare TextEdit while Main.qml supplies the application hooks: the find bar behind /, n and N, paging for Ctrl+D and Ctrl+U, and the existing hidden-marker skipping, without which a motion could rest on a zero-width ** and look like the caret had stopped moving. Each command groups its document changes into one edit block through the new Backend::beginEditBlock, so u undoes the command rather than the remove-and-insert pair that carried it out. The dot command replays what an insert session did to the document instead of the keys that did it, which keeps it honest when list continuation rewrites what a Return would otherwise have typed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QvoQB2xpX5Gkp8LtXVBPvw
Write, quit and open reuse the paths the rest of the app already takes, so :w on an unsaved document opens the portal picker and :q on a modified one raises the same unsaved-changes dialog the close button does. Paths typed on the command line are read the way a shell reads them, through the new Backend::resolvePath: ~ is home and a relative name is a sibling of the open document. Ranges cover %, a line number, a pair, and '<,'>, which pressing : in visual mode prefills from the selection before dropping to normal. Substitute patterns are JavaScript regular expressions rather than vim's, since that is what the engine can offer honestly; replacements keep vim's spelling, where & is the whole match and \1 a group, and any punctuation can stand in for the separator. Ex commands run through the same edit block as normal mode commands, so u takes back a whole :s or :d rather than the line-by-line edits that carried it out. The substitution walks the range bottom up, which keeps the positions of the lines still to come from shifting under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QvoQB2xpX5Gkp8LtXVBPvw
Replacing the document text leaves the editor's caret wherever the new text ends, which is the trailing empty line below the last paragraph. A one pixel caret sitting there goes unnoticed; the block caret normal mode draws reads as a rectangle adrift in the middle of the canvas. loadDocumentText is the one place every path that replaces the text passes through, whether it came from opening a file, reloading from disk, keeping the version on disk, or restoring a recovery snapshot, so announce it from there and let the interface decide where the caret belongs. In vim mode that is the first character, where vim opens a file, along with a clean normal mode. Left alone with vim mode off: for a writing app, opening a draft and carrying on from where the text ends is a defensible place to start, and that is not this change's argument to make. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QvoQB2xpX5Gkp8LtXVBPvw
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-io#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-io#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-io#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-io#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-io#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-io#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-io#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-io#10. Co-Authored-By: Ryan Yogan <ryanyogan@gmail.com>
The consolidation is done, so src/VimEngine.js goes, along with the tests
that drove it and the replaceRange it needed — EditorMutations.replaceRange
inside our edit blocks already groups a compound change into one undo, and
persistsVimMode was a narrower version of remembersVimModePreference.
Its test suite had found three things ours had wrong, so those assertions move
across along with fixes for what they caught:
- dw on the last word of a line dragged the line below up. An exclusive
motion landing in column one now stops at the end of the line before it,
and turns linewise from at or before the first word, which is the rest of
:h exclusive that we were missing.
- dj on the last line deleted the line the caret was on. A line motion with
nowhere to go now fails its operator instead.
- J after a line already ending in a space added a second one.
Co-Authored-By: Ryan Yogan <ryanyogan@gmail.com>
Consolidated with @ryanyogan's #10@ryanyogan opened #10 with a second vim mode and offered to consolidate if one approach suited the app better. Each was stronger in a different place, so rather than pick a winner this branch takes their commit and ports the parts of their engine that were better than mine. Their commit What came from #10
Decisions
Finding the neighbouring line needs their probe loop rather than a single Registers answer the clipboard question vim's way. This was the one real disagreement between the two branches: I kept an internal register, #10 made every yank the system clipboard. Both have a cost. Mine can't carry text between applications; theirs means every So Both ways to toggle. The footer diamond from #10, and What this branch keptThe host-adapter engine and string key names, so the grammar never sees a Qt enum. The ex command language — Dropped as redundant: #10's Bugs #10's tests foundPorting those edge cases caught three things this branch had wrong, each fixed alongside the assertion that caught it:
Tests23 passing, 1 skipped — the skip is the primary-selection half of the clipboard test, which the offscreen platform has no primary selection for; the clipboard half runs. New coverage for text objects under operators and from visual mode, sentence motions, |
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-io#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
Vim mode defers to Omawrite, rather than replacing itTesting the consolidated branch turned up something worth fixing properly: pasting a URL over selected text makes a Markdown link in Omawrite, but under vim mode Both are the same mistake, and it is the mistake a vim mode is most likely to make: reimplementing the editor instead of driving it. Omawrite has already decided what Return means on a list line, what pasting a URL over a selection means, and what a single undo should cover. A vim mode that quietly disagrees with any of that is a second editor sharing a window with the first, and the writer is the one who has to keep track of which one they are talking to. So the engine defers. Where the line falls: insertion and paste get Omawrite's Markdown behaviour, because that is what they are for and the writer already knows how they behave. Motions and operator ranges stay mechanical — Two details on the paste. It follows the register rather than the clipboard, now that Three bugs this surfacedWiring it up ran into three things, none of them belonging to the feature. The first is mine, from resolving the merge in 7a69964. The An open edit block leaves Worth saying plainly: the bare Closing an edit block makes the document announce itself whether or not anything changed, so every keystroke in vim mode reaches Tests24 passing, 1 skipped — the skip is the primary-selection half of the clipboard test, which the offscreen platform has none of. New coverage for |
A code review of the branch found four things, three of them mine from the
last two commits.
The worst corrupts documents. Every command runs inside one of the document's
edit blocks, and the editor's text property does not move until the block
closes. host.text(), setCursor and select were taught to ask the document
instead, but EditorMutations.replaceRange still clamped its range against the
editor's copy, so any edit landing past where the document ended when the
command began was dragged back inside the old length. Typing o, some text,
Escape and then . at the end of a document produced "one\n\ntwotwo\n\n" out of
"one\n\ntwo" — two paragraphs run together and a stray break at the end.
replaceRange now asks the editor for a live length when it can offer one. That
covers the callers reached through the openLine and linkPaste hooks too, which
run inside the engine's blocks and were clamping against the same stale copy —
harmless today, since each does a single edit inside the old text, but only by
luck.
The other three:
- "*p read the clipboard rather than the primary selection, because
clipboardUrl had no mode argument while clipboardText had gained one. The
hook now carries the register name instead of a bool.
- A V-LINE p offered its raw anchors to the link paste, which would have
wrapped part of the selection. Both ends have to be charwise.
- Leaving the search bar or the command line replaced the whole vim state,
emptying every register, the last change and the last search. Yanking a
paragraph and then going to look for where it belongs is the reason to go.
Returning now clears the mode and any half-typed command, nothing else.
The reason all of this hid: the engine harness drove a bare TextEdit with no
edit block, so the layer where these live was never exercised. It now runs the
engine through a proxy whose text freezes while a block is open, the way the
document behaves, and reverting any of the fixes above fails a test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TyQRJCyR76uk7XaNAB8jMC
A review pass, and the bug that was hiding behind a testI ran a review over the branch. It found four things worth fixing, three of them mine from the last two commits, and one of them serious enough that it should have blocked the merge. The document-corrupting oneEvery vim command runs inside one of the document's edit blocks, so that a single The previous commit taught On Two paragraphs run together and a stray break at the end — silent corruption of the writer's document, from a keystroke as ordinary as The first fix I wrote added an opt-in length argument and passed it from the engine. That was too narrow. The other three
Why none of this was caughtThis is the part worth keeping. The engine's fast tests drive a bare The harness now runs the engine through a proxy whose 25 passing, 1 skipped — the skip is the primary-selection half of the clipboard test, which the offscreen platform has none of. Still openThree smaller findings, verified but not yet fixed. None lose work, so I would rather they were their own commit than padding this one:
|
r over a visual selection forwarded the span's width to the single-line r, which stops at the end of the line the caret is on: on abc/def, v j r z gave zzz/def where vim gives zzz/zef. It also passed a UTF-16 unit count where a character count was wanted, so a selection holding an emoji came back shorter than it went in. Visual r now walks the selection a character at a time, replacing each and stepping over the line breaks so the shape of the selection survives. V-LINE covers its lines whole, since a linewise range carries the anchors rather than the lines they sit on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyQRJCyR76uk7XaNAB8jMC
:s runs bottom up, so that replacing a line cannot shift the lines still to come. It recorded the line it landed on at every match, so the record ended holding the topmost one and the caret jumped to the start of the range rather than to the end of the work. After :%s over a long document you were sent back to the top. It now keeps the first line the loop reaches with a match, which running bottom up is the last one in the file — where vim leaves the caret. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyQRJCyR76uk7XaNAB8jMC
The engine has accepted "C-[" as an Escape alias since vim mode landed, but the key never reached it: vimKeyName only names a control chord when the key is a letter, and Ctrl+[ is not one. Anyone who leaves insert mode that way, which is most people who learned vim on a keyboard where Escape is far away, found the key silently swallowed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyQRJCyR76uk7XaNAB8jMC
|
Reviewed this branch. The headline first: the opt-in holds up. I did not want to take that on reading alone, so I built master's With the mode on, four things. Astral characters still come apart in two places.
A visual link paste does not record itself for The edit block has no
Two smaller notes. The PR body is stale — it still lists text objects as out of scope and reports "Nine new cases … 20 passed", which undersells a branch that now has Whether Omawrite wants a vim layer at all is the maintainer's call and I have not made it. |
Adds vim key bindings behind a toggle, for writers who reach for
hjklout of habit.Off by default: normal mode has to swallow every printable key, so it would be a surprise for anyone who didn't ask for it.
Ctrl+Alt+Vtoggles it and the choice is remembered inQSettings. The mode shows in the bottom-left corner and normal mode draws the caret as a block.Insert mode consumes nothing but Escape. Smart returns, list continuation and Markdown paste behave exactly as they do with the mode off — only normal and visual mode go through the engine.
What's supported
i I a A o O,v/V,Esch j k l,w W b B e E,0 ^ $,gg G,{ },f F t Twith;,, andgj/gkfor wrapped linesd c ywith any motion, doubled for whole lines, plusD C Y S s x X r J ~ p P3j,d2w,2ddu/Ctrl+R/./opens the existing find bar,n/Nstep matches,Ctrl+D/Ctrl+UpageEvery
Ctrlshortcut keeps working in either mode.The
:command line:opens a command line along the bottom edge;Enterruns it,Escor backspacing past the start abandons it.:w:w <path>:wq:x:q:q!!discards:e <path>:e!:42:$:s/pat/rep/[gi]%32,5'<,'>ranges:dpuses:nohThe usual abbreviations resolve (
:wr,:qa,:substitute,:nohlsearch). Pressing:in visual mode prefills'<,'>.Write, quit and open reuse the paths the app already takes:
:won an unsaved document opens the portal picker,:qon a modified one raises the same unsaved-changes dialog as the close button,:wqgoes throughBackend::saveForClose. Paths are read the way a shell reads them, through the newBackend::resolvePath—~is home, a relative name is a sibling of the open document.One deliberate divergence: substitute patterns are JavaScript regular expressions, not vim's, since that is what the engine can offer honestly. Replacements keep vim's spelling (
&,\1), and any punctuation can stand in for the separator.Notes on the implementation
src/Vim.jsholds the state machine behindhandleKey()and touches the editor only through a host wrapper, so the tests drive a bareTextEditwhileMain.qmlsupplies the application hooks. Three things worth a second look::sand:dincluded, groups its document changes into one edit block through the newBackend::beginEditBlock, souundoes the command rather than the edits that carried it out..replays what an insert session did to the document rather than the keys that did it. Replaying keys would go wrong exactly where this editor is interesting — list continuation rewrites what aReturnwould otherwise have typed.skipHiddenForward/skipHiddenBackward, without which the caret could rest on a zero-width**and look stuck.Still out of scope: text objects (
ciw), macros, marks,%matching.Testing
Nine new cases in
tests/tst_omawrite.cpp. The engine ones cover motions, operators, counts, visual mode, dot repeat, undo, ex ranges, substitute flags and error messages, and hook dispatch for every file command. Two drive the real window with key events: normal-mode routing and the fall-through when vim mode is off, and the command line opening on:, jumping on:2, rewriting on:%s/fish/cat/, reverting in oneu, and abandoning onEsc. Full suite: 20 passed, 0 failed.Three bugs the tests caught while writing them:
cwwas eating the trailing space (vim makes it behave likece), a lone0after a count parsed as a digit instead of the line-start motion, and the mode indicator did not clear when vim mode was switched off from outside the shortcut.🤖 Generated with Claude Code
https://claude.ai/code/session_01QvoQB2xpX5Gkp8LtXVBPvw