Skip to content

Add opt-in vim key bindings - #7

Open
rodgco wants to merge 17 commits into
omacom-io:masterfrom
rodgco:feat/vim-mode
Open

Add opt-in vim key bindings#7
rodgco wants to merge 17 commits into
omacom-io:masterfrom
rodgco:feat/vim-mode

Conversation

@rodgco

@rodgco rodgco commented Aug 15, 2026

Copy link
Copy Markdown

Adds vim key bindings behind a toggle, for writers who reach for hjkl out 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+V toggles it and the choice is remembered in QSettings. 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

  • Modes: i I a A o O, v / V, Esc
  • Motions: h j k l, w W b B e E, 0 ^ $, gg G, { }, f F t T with ; ,, and gj / gk for wrapped lines
  • Operators: d c y with any motion, doubled for whole lines, plus D C Y S s x X r J ~ p P
  • Counts throughout: 3j, d2w, 2dd
  • u / Ctrl+R / .
  • / opens the existing find bar, n / N step matches, Ctrl+D / Ctrl+U page

Every Ctrl shortcut keeps working in either mode.

The : command line

: opens a command line along the bottom edge; Enter runs it, Esc or backspacing past the start abandons it.

:w :w <path> :wq :x :q :q! write / quit, ! discards
:e <path> :e! open a file / reload from disk
:42 :$ jump to a line
:s/pat/rep/[gi] substitute, with % 3 2,5 '<,'> ranges
:d delete the range's lines into the register p uses
:noh clear the search highlight

The usual abbreviations resolve (:wr, :qa, :substitute, :nohlsearch). Pressing : in visual mode prefills '<,'>.

Write, quit and open reuse the paths the app already takes: :w on an unsaved document opens the portal picker, :q on a modified one raises the same unsaved-changes dialog as the close button, :wq goes through Backend::saveForClose. Paths are read the way a shell reads them, through the new Backend::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.js holds the state machine behind handleKey() and touches the editor only through a host wrapper, so the tests drive a bare TextEdit while Main.qml supplies the application hooks. Three things worth a second look:

  • Undo: every command, :s and :d included, groups its document changes into one edit block through the new Backend::beginEditBlock, so u undoes the command rather than the edits that carried it out.
  • Dot repeat: . 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 a Return would otherwise have typed.
  • Hidden markers: motions route through the existing 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 one u, and abandoning on Esc. Full suite: 20 passed, 0 failed.

Three bugs the tests caught while writing them: cw was eating the trailing space (vim makes it behave like ce), a lone 0 after 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

rodgco and others added 4 commits August 15, 2026 10:41
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>
rodgco and others added 8 commits August 16, 2026 07:16
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>
@rodgco

rodgco commented Aug 16, 2026

Copy link
Copy Markdown
Author

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 ef9a3a6 is merged into this branch rather than paraphrased, so the work is theirs in the history, and every commit carrying ported code credits them as co-author.

What came from #10

  • Text objectsiw aw ip ap i" a" i' a' and the bracket pairs. This was the biggest gap Simple vim key movements to make writing a delight #10 exposed: prose editing reaches for ciw and dap more than anything else I had. Rewritten against my character classes, which number them the other way round.
  • Sentence motions ( ), and ge / gE.
  • Surrogate-pair-safe stepping, so x on an emoji removes the whole character instead of half of one.
  • The t / ; quirk — a repeated t was re-finding the target it had already stopped short of, and standing still.
  • Display-line j / k, see below.
  • The footer diamond toggle and its icon, unchanged from their commit.
  • Backend::setClipboardText, which the registers build on.
  • Their test suite's edge cases, which found three real bugs in mine.

Decisions

j and k follow the wrapped line; gj and gk reach the logical one. The mirror of vim, where g is the display-line prefix. In a Markdown document a paragraph is one long line, so plain j was jumping the whole of it — the key you press most not moving the way the text reads. Operators stay logical: dj still takes two whole paragraphs, since vim's display-line operator is dgj.

Finding the neighbouring line needs their probe loop rather than a single positionAt. The document is set in 140% line spacing, and the leading between lines is dead space where positionAt resolves a column badly — which is why my one-shot version drifted.

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 x and dd clobbers whatever you copied from a browser.

So " now names a register. Yanks and deletes still land in the unnamed register, which stays inside the editor. "a"z hold text aside. "+ is the system clipboard and "* the primary selection, for when carrying text out of the window is what you actually meant. A named yank fills the unnamed register too, so a bare p still pastes what you last took. A clipboard cannot carry the linewise flag, so a trailing newline stands in for it — their convention, kept, since there is no other way to do it.

Both ways to toggle. The footer diamond from #10, and Ctrl+Alt+V. The diamond is discoverable without reading anything; the chord is faster once you know it.

What this branch kept

The host-adapter engine and string key names, so the grammar never sees a Qt enum. The ex command language — :s with ranges, :e, :d, :noh, :w <path>. beginEditBlock / endEditBlock, so one command is exactly one undo, including a :%s across forty lines. The settle hook into skipHiddenForward / skipHiddenBackward, so motions don't appear to stall on zero-width Markdown markers. Opening a document on its first line. And the insertDelta dot repeat, which diffs the insert session rather than replaying keystrokes, so list continuation and Markdown paste replay correctly.

Dropped as redundant: #10's Backend::replaceRange, since EditorMutations.replaceRange inside the edit blocks already groups a compound change into one undo, and src/VimEngine.js itself once its tests had moved across.

Bugs #10's tests found

Porting those edge cases caught three things this branch had wrong, each fixed alongside the assertion that caught it:

  • dw on a line's last word dragged the line below up. I was missing half of :h exclusive: an exclusive motion landing in column one stops at the end of the line before it, and turns linewise from at or before the first word.
  • dj on the last line deleted the line the caret was on, instead of failing the way a motion with nowhere to go should.
  • J after a line that already ended in a space added a second one.

Tests

23 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, ge, registers including a "+ and "* round trip, the astral cases, the t / ; repeat, and a windowed test that wraps a real paragraph to prove j stays inside it while dj still takes whole lines.

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
@rodgco

rodgco commented Aug 16, 2026

Copy link
Copy Markdown
Author

Vim mode defers to Omawrite, rather than replacing it

Testing 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 p just pasted the URL. Same for o on a list item — Return continues the bullet, o gave you an empty line.

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. o and O go through the editor's own smartReturn, and a visual p through its link paste. That is what the host adapter in this branch is for, and two motions already used it — settle reuses skipHiddenForward/skipHiddenBackward so the caret doesn't stall on zero-width Markdown markers, and page reuses movePage. This is the same seam, used twice more, not a new mechanism.

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 — dw takes a word, and no cleverness gets to reinterpret it. Vim's contract is that you can predict the mechanics; the app's contract is that Markdown structure looks after itself. Those meet at the edit, not at the motion.

Two details on the paste. It follows the register rather than the clipboard, now that " names one, so "+p from a browser and "ap yanked out of the document both wrap the selection — Backend::normalizedLinkUrl() already worked on any string. And P stays the literal paste, so there is still a way to say you meant the text itself.

Three bugs this surfaced

Wiring it up ran into three things, none of them belonging to the feature.

The first is mine, from resolving the merge in 7a69964. The onTextChanged handler came over cleanly from #10 and still referenced the vim object I removed alongside it, so it threw on every text change while vim mode was on. A throw there aborts the handler, so backend.editorTextChanged() never ran: no modified flag, no word count, no search refresh, for as long as vim mode was on. My fault, and a good argument for the integration tests that caught it.

An open edit block leaves TextEdit.text behind. The document holds its change signals until the block closes, and the editor's text property only refreshes when one arrives — so a command reading the text after its own edit read the version from before it. 3J joined one line and stopped. The caret clamped against a document shorter than the real one and was dragged back to where the edit began. The host now reads the document itself while a block is open.

Worth saying plainly: the bare TextEdit the engine tests drive has no edit blocks, so none of this was visible there. Every one of these assertions runs in a real window instead. A unit harness that removes the thing you are integrating with will tell you the integration is fine.

Closing an edit block makes the document announce itself whether or not anything changed, so every keystroke in vim mode reaches onTextChanged. Anything hanging off it has to ask whether the text actually moved — my first attempt at resetting stale visual anchors fired on all of them and dropped visual mode on the following key.

Tests

24 passing, 1 skipped — the skip is the primary-selection half of the clipboard test, which the offscreen platform has none of. New coverage for o and O across bullets, numbers, quotes and plain paragraphs; the link paste from both the clipboard and a named register, with P and a non-URL payload as controls; and, for the edit-block bugs, a multi-edit 3J, a yyp, and the caret landing where the command meant to leave it.

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
@rodgco

rodgco commented Aug 16, 2026

Copy link
Copy Markdown
Author

A review pass, and the bug that was hiding behind a test

I 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 one

Every vim command runs inside one of the document's edit blocks, so that a single u undoes the command rather than the several edits that carried it out. Inside a block, QTextDocument holds its change signals back — and TextEdit.text only refreshes when one arrives. So partway through a command, the editor's copy of the text is the version from before the command started.

The previous commit taught host.text(), setCursor and select to ask the document instead. It missed EditorMutations.replaceRange, which clamps its range against editor.text.length. Any edit landing past where the document ended when the command began was quietly dragged back inside the old length.

On one\n\ntwo: o, some text, Esc, then . at the end of the document gave

one\n\ntwotwo\n\n

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. replaceSelectionWith, smartReturn's empty-list branch and openLineForVim all call replaceRange without one, and all three now run inside an engine edit block, reached through the openLine and linkPaste hooks the previous commit added. They are safe today only because each happens to do a single edit inside the old text — luck, not design. So replaceRange now asks the editor for a live length whenever it can offer one, which closes the whole class rather than the engine's corner of it. An editor that cannot answer was never in a block, and falls back to what it did before.

The other three

  • "*p read the clipboard, not the primary selection. clipboardText gained a mode argument when the registers landed; clipboardUrl did not. The linkPaste hook now carries the register name rather than a bool, so "+ and "* each reach the one they name.
  • A V-LINE p handed its raw anchors to the link paste. A linewise range carries anchors, not whole lines, so wrapping one would have taken part of the selection. Both ends have to be charwise.
  • Leaving the search bar or the : line emptied every register. It replaced the whole vim state, which also discarded the last change, the last search and the last substitute. Yanking a paragraph and then going to find where it belongs is the reason you would go. Returning now clears the mode and any half-typed command, and nothing else. The review caught closeSearch; closeCommandLine had the same bug and both are fixed.

Why none of this was caught

This is the part worth keeping.

The engine's fast tests drive a bare TextEdit with no beginChange/endChange hooks — no edit block, so its text is always current. Every one of these bugs lives in the gap between the engine and the application, and the harness had removed the very thing being integrated with. It reported that the integration was fine because it had quietly replaced it with something simpler.

The harness now runs the engine through a proxy whose text freezes while a block is open, while its live length stays honest — a stand-in for what QTextDocument actually does. I checked it earns its keep by reverting each fix in turn: the corruption reproduces in the fast tests as "ab\nXX\n", and the register fix fails its own test. Neither needed a window to catch.

25 passing, 1 skipped — the skip is the primary-selection half of the clipboard test, which the offscreen platform has none of.

Still open

Three smaller findings, verified but not yet fixed. None lose work, so I would rather they were their own commit than padding this one:

  • Multi-line visual r replaces only the first line, and counts UTF-16 units where it should count characters.
  • :s leaves the caret on the first changed line rather than the last, because the substitution loop runs bottom-up and keeps overwriting the line it recorded.
  • C-[ never reaches the engine: the key-name mapping only emits C-<letter> for Key_A..Key_Z, so that Escape alias is dead.

rodgco and others added 3 commits August 16, 2026 10:16
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
@omarchybot

Copy link
Copy Markdown
Collaborator

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 src/Main.qml and this branch's side by side and drove both through the same 21-step key script with vimMode false, comparing text, cursor and selection after every step — list continuation on -, *, >, 1. and 3), the empty-item branch, a code fence, plain Return, Shift+Return, plain typing, Escape, Ctrl+B, Ctrl+I, Ctrl+F/Escape/type, editor focus, Backspace, Left, Delete, Up, Home, Ctrl+V paste, and the closing word count and modified flag. Identical at all 21. Flipping the harness to enable vim mode on this side made it fail at 16 of the 21, so the comparison is not vacuous. smartReturn through continuationMarker() is textually equivalent to master's inline branch, and nothing installs an always-live handler that no-ops when the mode is off. ./bin/test gives 25 passed, 1 skipped, and ./bin/build is clean.

With the mode on, four things.

Astral characters still come apart in two places. x and r are safe, and the test at tests/tst_omawrite.cpp:207 covers h l x r — but the visual range and the word-end motions still step by UTF-16 code unit. Reproduced against this branch:

a😀b  lvd  ->  0061 DE00 0062     (a + lone low surrogate + b)
a😀b  x    ->  0061 0062          (correct, for contrast)
a😀 b le   ->  caret lands at offset 2, inside the pair
a😀 b lex  ->  0061 D83D 0020 0062  (a + lone high surrogate + space + b)
a😀 b gex  ->  same

showSelection and selectionRange (src/Vim.js:561, :563, :573) extend by head + 1; wordEnd (:275) and wordEndBackward (:287) walk by i++. Since e and ge feed d, c and y, the corruption reaches operators as well as x. An unpaired surrogate then goes to disk on the next save. I did not push a fix because there are two reasonable shapes for it — step by character in each of those four functions, or normalise to a character boundary once in moveCaret/setCursor — and which one you want is yours to pick, with your own motion tests riding on it.

A visual link paste does not record itself for .src/Vim.js:1497. The branch returns as soon as host.linkPaste succeeds, so it never reaches commitChange(state), while the plain-paste path below it does through applyOperator/paste. state.lastChange keeps whatever was there before, so after a dd, selecting a word, "+p to wrap it as a link, and then . deletes a line.

The edit block has no try/finallysrc/Vim.js:724-734. If anything in dispatch() throws, Backend::m_editBlockDepth and editor.vimEditDepth are both stranded above zero for the rest of the session: the document stops emitting its change signal, TextEdit.text freezes, onTextChanged never fires again, and the modified flag, word count, search refresh and recovery draft all stop silently while the writer keeps typing. I could not find a reachable throw, so this is about blast radius rather than a bug today — but it is the same handler that threw on you in 7a69964, and effectiveCount() is unbounded, so 999999999p sits in repeatString() (src/Vim.js:395) with the block open. substitute() and deleteRange() are open the same way.

:s still lands one short when the replacement adds linessrc/Vim.js:1795. landing is a line number recorded on the last matching line, and the loop then keeps editing the lines above it; since expandReplacement supports \n, :%s/x/a\nb/ shifts that line down and the caret ends up above the real end of the change. A residual of adbc5e2, caret only.

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 iw aw ip ap and the bracket pairs, registers, and 25 passing; the README is current. And #5 conflicts with this branch, not just textually. It puts a StandardKey.Undo/Redo interception at the top of the same Keys.onPressed where this puts the vim branch, and it replaces editor.undo() with a Backend::replayHistory that deliberately consumes several document undo entries per user action. u here calls editor.undo() directly (src/Vim.js:78), so with both in, u and Ctrl+Z would undo different amounts. Worth agreeing on before either lands. #12 merges clean, and its blank-line branch actually improves o, which now routes through smartReturn.

Whether Omawrite wants a vim layer at all is the maintainer's call and I have not made it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants