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#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>
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#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
Qt merges consecutive keystrokes into a single undo command with no upper bound: no word boundary, no idle timeout, nothing but a cursor move or a paragraph break ends the run. So everything typed since the last such break was one step, and a single Ctrl+Z threw it all away and left the caret where the run began, which for a document started from the top is the top of the document. End the run when the writer finishes a word. Re-applying the line height a block already carries appends a formatting-only command that stops the merge, so the next keystroke opens a fresh one and undo steps back a word at a time, caret at the edit. The marker moves no text of its own, so undoEdit()/redoEdit() step past it and every press changes the writing. The Shortcut items for Ctrl+Z and Ctrl+Y never ran: TextEdit claims both as built-in text editing shortcuts, so they never reached the window. Take them in Keys.onPressed, where they do arrive. While undo and redo are replaying, leave the stack alone. The commands being replayed already carry their formatting, and appending from there threw away the redo half of the stack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSyDzGm6gk92nyWA6nWN3h
Both branches wrote to the same places in the editor and its tests. The conflicts themselves were additions side by side, but making word-sized undo runs sit under vim's own grouping took four changes: - Vim.js asks the editor to undo rather than the document, so u and C-r step past the formatting-only edits that end a run instead of spending a press on one. - The run marker stands in an edit block of its own. Joined to the command before it, it reopened that command's block and chained a vim command to whatever preceded it, so one u unwound both. - undoEdit() reads the document instead of its own text property, which stands still inside the edit block vim holds open while u runs. A caller reading its own text there saw no change and kept undoing until the stack was empty. - Runs are not ended by the word while vim mode is on. Vim groups undo its own way, one step per command and one per insert session, and that grouping is what u is expected to step back through. Measured against the branch before the merge, u leaves the same text it did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSyDzGm6gk92nyWA6nWN3h
|
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. |
x and r were already careful with astral characters, but the visual range and the word-end motions still walked by UTF-16 unit. On a😀b, lvd left a lone low surrogate; through e and ge the same arithmetic reached d, c and y, so de and dge did it too. A caret in the wrong place is visible and recoverable — half a surrogate pair is neither, and goes to disk on the next save. The engine already had stepForward and stepBackward, and clampNormal already used them. What was left was the four places that meant "past this character" and wrote + 1: the two ends of a visual selection, an inclusive motion's range, and ge's backwards range. wordEnd and wordEndBackward now walk by character as well, and return the start of the character they land on rather than its last unit, which is where the caret belongs. Checked against the old versions over every position of fourteen BMP samples, both word sizes: identical answers throughout, so this is the astral case only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXHc91hBNDVXyoaCpqXDae
The link paste returns as soon as the editor accepts the wrap, which skipped the two steps every other paste takes on its way out: writing what was pasted to the unnamed register, and recording the command as the last change. So . replayed whatever was recorded before it. After a dd, selecting a word and wrapping it as a link, a single . deleted a line — on a one-line document, the whole of it. Both steps now run on that path too, so the register and . follow the paste rather than the shape of its payload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXHc91hBNDVXyoaCpqXDae
Every command opens one of the document's edit blocks so that a single u undoes the command rather than the edits that carried it out. Nothing guaranteed the close. A throw anywhere inside dispatch left the depth counter standing above zero for the rest of the session, and a block that never closes is not a lost command: the document stops emitting its change signal, TextEdit.text freezes, onTextChanged never runs again, and the modified flag, word count, search refresh and recovery draft all stop while the writer keeps typing into what still looks like an editor. I could not find a reachable throw either, so this is blast radius rather than a bug today — but it is the same handler that threw in 7a69964, and a count is unbounded, so 999999999p sits in repeatString with the block open. The three places that open a block now go through withEditBlock, which closes it in a finally and carries the exception on out. The invariant gets stated once, next to the reason it matters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXHc91hBNDVXyoaCpqXDae
The landing line is recorded on the last matching line and the loop then goes on editing the lines above it. A replacement can carry \n, so those edits push the recorded line down and its own new lines extend the change past it, and the caret came to rest above the end of what had just changed. On one x / two x / three, :%s/x/a\nb/ left it on line 2 where vim leaves it on line 4, the second b — checked against vim itself rather than assumed. Both effects are the same count, so the fix is to add the lines the replacements introduced to the line they were recorded against. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXHc91hBNDVXyoaCpqXDae
The four findings, fixedThanks for the differential run on the opt-in — building both sides and driving them through the same script is a better answer than anything reading the diff could have given, and the 16-of-21 failure with the mode on is the part that makes the other 21 mean something. All four are fixed, one commit each. Every one of them was checked by reverting the fix and watching its own test fail, so none of these assertions are decoration. Astral characters (
|
Greptile SummaryAdds opt-in Vim key bindings, persisted settings, mode indicators, and an editor-hosted Vim state machine while preserving the existing insert-mode editing behavior.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| src/Vim.js | Introduces the Vim state machine, motions, operators, registers, repeat support, and Ex command execution; no eligible follow-up defect remains. |
| src/Main.qml | Integrates Vim routing, mode and command-line UI, host callbacks, grouped history navigation, and the footer toggle. |
| src/backend.cpp | Adds native support for persisted Vim mode, edit blocks, history navigation, clipboard registers, document text, and path resolution. |
| src/backend.h | Exposes the new QML-facing backend properties, signals, and editing and filesystem APIs. |
| src/EditorMutations.js | Uses the live document length while grouped edits are open so replacement ranges remain valid. |
| tests/tst_omawrite.cpp | Adds broad engine and integration coverage, including a real-QML-runtime regression test that bounds pathological substitute evaluation. |
| src/resources.qrc | Packages the new Vim JavaScript module with the runtime QML resources. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Input[Keyboard or command-line input] --> Main[Main.qml routing]
Main -->|Insert mode| Editor[Existing TextEdit behavior]
Main -->|Normal or visual mode| Vim[Vim.js state machine]
Vim --> Host[Editor host wrapper]
Host --> Editor
Host --> Backend[Backend services]
Backend --> Files[Local files and settings]
Backend --> Clipboard[Clipboard and primary selection]
Reviews (4): Last reviewed commit: "Keep a text object, a dot repeat and a ~..." | Re-trigger Greptile
A review flagged :s as able to freeze the window on a pattern like (a+)+$, which is true of the same expression under V8 — 6.6s at 28 characters, doubling with each one after that. It is not true here. QML's JS engine compiles RegExp through PCRE2, whose match limit stops the backtracking: the same pattern over a 2000-character line goes through the real :s path in 10ms and reports no match. That is worth a test rather than a reply, since it is the runtime's property and not the engine's, and nothing in this branch would notice if it changed. The line is long enough that an unbounded engine would never finish the suite. The bound trades a hang for a pattern that can report no match where a match exists but costs more than the limit allows. For a writer's substitute that is the right way round, and it is the same trade every PCRE2 caller in the backend already makes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXHc91hBNDVXyoaCpqXDae
On the substitute regex finding: correct for V8, and this does not run on V8Thank you for the review. The finding is a real property of backtracking regular expressions and the reasoning is sound — it just does not hold in the engine this code runs in, and I would rather show that than assert it.
The measurements
The same patterns in Qt's V4 engine, which is what QML runs: Flat, not exponential, and legitimate matches and replaces are unaffected. V4 compiles Through the real What does hold, in a smaller wayThe limit is per match attempt, and So a pathological pattern over a large document still blocks the UI for seconds — bounded rather than unbounded, and one The trade PCRE2 makesThe bound is not free: a pattern that would match, but costs more than the limit allows, reports no match rather than hanging. For a writer's substitute that is the right way round, and it is the same trade every One caveat on my own claim, since it is the load-bearing one: I verified PCRE2's bound behaviourally, across four patterns and four input sizes, rather than by citing Qt's source. The flat timings are strong evidence, not a citation. |
3e62c07 counted characters in the four places a motion feeds an operator, which is where a split pair reaches the document in one step. Two places are left where a motion sets the caret rather than a range, and they reach the document in two: the caret stops between the halves of an astral character, and the next x, X or r takes one code unit of it. findInLine returns one short of its target for t, which is the last unit of whatever precedes it. gj and gk carry a column measured in code units on the line they left onto the line they land on. Both now step by character, using the helpers the file already has, and the column verticalMove remembers counts characters so it means the same thing on either line. The repeat argument had to move with the return: ; after a t sits on the character before the target and skips past it by comparing the unit after the caret, which is the pair's low half once the caret is on a character. Stepping there too keeps ; finding the next target rather than the same one. A sweep of 86 key sequences over 11 documents holding astral characters, from every caret a motion can reach, left 17 unpaired surrogates before this and none after. t and gj account for all 17. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three more places where a position crosses a character rather than a newline, found by a second reviewer after the previous commit closed t and gj. Each leaves the caret or a count between the halves of an astral character, and the next edit takes one code unit of it. A text object's end is exclusive, so applyObject stepped back one code unit to put the visual head on its last character. On a document holding one emoji, viw then Escape leaves the head on the low half and x writes a lone high surrogate. What . deletes behind the caret is measured during the insert session and spent wherever the repeat lands, so it has to be a count of characters rather than of code units. On "a 😀x" from the space, i BS Esc records "delete one back"; w l then . spent that unit on the emoji's low half. ~ restored the offset the run ended at before the toggle, which is stale whenever toggling changes a length. ß becomes SS, so on "ß😀x" the old offset lands inside the emoji and the following x splits it. Reading the end from what was actually written fixes both that and the caret being a character out for every non-astral ß. The sweep from the previous commit, widened to 10600 combinations over documents holding astral and length-changing characters, reports 114 unpaired surrogates at d0b5170 and none here. Co-Authored-By: Codex XHigh <noreply@openai.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Re-reviewed at The substitute regex: right conclusion, wrong engineThe bound is real. I could not make Qt 6.11.2's Codex found the mechanism I could only measure, and it is better news than a JIT artefact would have been: YARR carries a private The backreference patterns are there because YARR's JIT cannot compile all of them, so those run through the interpreter. Flat there too. Two things follow that are worth having. The limit is private to Qt's vendored copy of masm, not a documented contract like PCRE2's And the trade bites earlier than "a match that costs more than the limit allows" suggests. So On the test at Five more astral sites —
|
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, as does the diamond in the footer, 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,EscorCtrl+[h j k l,w W b B e E ge gE,0 ^ $,gg G,{ },( ),f F t Twith;,iw aw ip ap,i" a" i' a'and the bracket pairs, under an operator or from visual moded c ywith any motion, doubled for whole lines, plusD C Y S s x X r J ~ p P"a–"zhold text aside,"+is the system clipboard and"*the primary selection. Yanks and deletes land in the unnamed register, which stays inside the editor, so anxnever clobbers what you copied from a browser.3j,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.oandOgo through the editor's ownsmartReturn, and a visualpthrough its link paste, so a bullet carries down and a URL pasted over a selection becomes a Markdown link exactly as they do with the mode off. Insertion and paste get Omawrite's Markdown behaviour; motions and operator ranges stay mechanical, because vim's contract is that you can predict them.jandkfollow the display line,gj/gkthe logical one — the mirror of vim, wheregis the display-line prefix. In Markdown a paragraph is one long line, so plainjjumping the whole of it means the key you press most does not move the way the text reads. Operators stay logical:djtakes two whole paragraphs.Consolidated with #10: @ryanyogan's commit is merged in rather than paraphrased, and every commit carrying ported code credits them. Text objects, the sentence motions,
ge/gE, surrogate-safe stepping, the footer toggle and its icon came from there.Still out of scope: macros, marks,
%matching.Testing
tests/tst_omawrite.cppcovers motions, operators, counts, text objects under an operator and from visual mode, registers including a"+and"*round trip, astral characters through every motion that feeds an operator, dot repeat, undo, ex ranges, substitute flags and error messages, and hook dispatch for every file command.The engine's fast tests drive a bare
TextEdit, which has no edit blocks — so they run it through a proxy whosetextfreezes while a block is open while its live length stays honest, which is whatQTextDocumentactually does. Without that stand-in the harness reports that the integration is fine because it has replaced the thing being integrated with. Several more drive a real window with key events: normal-mode routing and the fall-through when vim mode is off, the command line,oandOacross bullets, numbers and quotes, and the link paste from both the clipboard and a named register.Full suite: 26 passed, 0 failed, 1 skipped — the skip is the primary-selection half of the clipboard test, which the offscreen platform has none of.
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