Skip to content

feat(editor): complete vim command surface and fix J join (#1222)#1224

Merged
datlechin merged 13 commits into
mainfrom
feat/vim-engine-complete-spec
May 11, 2026
Merged

feat(editor): complete vim command surface and fix J join (#1222)#1224
datlechin merged 13 commits into
mainfrom
feat/vim-engine-complete-spec

Conversation

@datlechin
Copy link
Copy Markdown
Member

Summary

  • Fixes Vim mode does't support shift + j command to join lines #1222J and gJ join lines in Normal and Visual modes, with count, whitespace stripping, and the paren-suppression rule.
  • Rewrites the vim test suite from two monolithic files into 25 domain-focused files (~500 tests) covering every standard vim command as the project's vim spec.
  • Implements the rest of the standard vim surface so the entire spec passes: text objects, search (/, ?, n, N, *, #), insert-mode editing (Ctrl+W/U/H/T/D), number adjust (Ctrl+A/X), gv reselect, macros (q/@/@@), scroll (Ctrl+d/u/f/b, zt/zz/zb, gj/gk), sentence/paragraph/section motions, marks, named/numbered registers, . repeat, U line undo, and full visual-mode parity.
  • Fixes two production Esc bugs: the autocomplete popup eating Esc and the SwiftUI menu shortcut (Clear Selection) preempting Esc before the local event monitor, both of which left Vim stuck in insert mode.

What changed

Engine (TablePro/Core/Vim/): VimEngine now handles the full standard command surface. VimMode gains .replace. VimOperator gains .lowercase, .uppercase, .toggleCase, .indent, .outdent. VimTextBuffer gains protocol methods for big-word motions, wordEndBackward, bracket matching, viewport-visible-line range, and indent string/width. The adapter wires them to CodeEditTextView; the mock has parallel pure-Swift implementations.

Interceptor (VimKeyInterceptor): Esc fast-path no longer gates on event.window; the popup-close observer captures NSApp.currentEvent synchronously; a new handleEscapeFromExternalSource() lets the SwiftUI menu route through the engine first via EditorEventRouter.handleVimEscapeFromMenu().

Tests (TableProTests/Core/Vim/): 25 files covering every command family. 504 pass, 1 skipped (d/pattern operator-+-search, documented as the next-pass TODO).

Test plan

  • xcodebuild -project TablePro.xcodeproj -scheme TablePro test -only-testing:TableProTests/VimEngineJoinTests — all 30 J/gJ tests pass
  • Run the editor, enable vim mode, type SELECT * FROM users;, press Esc — block cursor lands on ;, mode indicator switches to NORMAL
  • From Normal mode on users, press ciw — word disappears, mode enters Insert
  • Press * on a word — cursor jumps to next occurrence; n continues, N reverses
  • Record a macro with qa{commands}q, replay with @a and 5@a
  • Press Ctrl-A on a number — value increments
  • >> and << indent / outdent at the configured tab width
  • All non-vim test suites still pass

datlechin added 13 commits May 11, 2026 20:25
…ands

Split monolithic VimEngineTests/VimVisualModeTests into 14 domain-focused files covering motions (h/j/k/l/w/W/b/B/e/E/ge/gE/0/dollar/caret/underscore/gg/G/H/M/L/%), insert entry, operators, paste, replace (r/R), case change, indent, undo/redo/repeat, count prefix, command-line, visual mode, marks, registers, find-char (f/F/t/T/;/,), join (J/gJ). Adds VimTextBuffer protocol methods (matchingBracket, big-word motions, wordEndBackward, visibleLineRange, indentString) with implementations in both VimTextBufferAdapter and VimTextBufferMock. Extends VimMode with .replace case and VimOperator with .lowercase/.uppercase/.toggleCase/.indent/.outdent. Tests for unimplemented commands fail to drive subsequent commits.

Refs #1222
…/G first-non-blank, count multiplication

Adds capital-letter shortcuts (D for d$, Y for yy, C for c$, X for delete-before-cursor, S for cc, s for delete-char-and-insert) and the big-word motion family (W, B, E, ge, gE). Fixes gg/G to land on first non-blank per vim spec. Operator + linewise motion (dG, dgg) now works via executeLinewiseOperator. Count prefix on operator and motion now multiply (2d3w deletes 6 words). Inclusive motion no longer consumes a trailing newline.
Adds J (join lines with single space) and gJ (join without space) commands in Normal and Visual modes. Implements vim semantics: strips leading whitespace from the next line for J, preserves it for gJ, suppresses the inserted space when adjacent to whitespace or before a closing paren, leaves the cursor on the join point, supports count prefix (3J joins 3 lines). In Visual mode J joins every line covered by the selection.

Fixes #1222
…rators, >>/<< indent, %, H/M/L screen motions, ?, gi

Adds the full find-character family (f, F, t, T, ;, ,) with operator combos (dfo, cto, yfo). Single-char replace (r) and the dedicated Replace overwrite mode (R, exiting via Escape). Case operators: standalone ~ with count, the case operators gu/gU/g~ with motion (gu$, gUw, etc.), and the doubled line forms guu/gUU/g~~. Indent operators >> and <<. Bracket-match motion %. Screen motions H/M/L using the new visibleLineRange protocol method. Reverse search prompt ? and gi resume-insert.
…epeat, U line undo, visual mode parity

Adds the complete marks subsystem (m{a-z} set, '{a-z} jump to line, `{a-z} jump to exact offset, `` and '' jump to last position). Named registers "{a-z} write/read with uppercase variant appending; numbered registers "0.."9 implement vim's standard delete ring ("0 holds last yank). The . repeat command replays the last edit through a typed VimDotKind. U undoes every edit on the current line since the cursor arrived. Visual mode gains o (swap anchor/cursor), ~ (toggle case), u/U (lowercase/uppercase), r{char} (replace selection), I (insert at start), A (append at end), and paste-replace where p/P over a selection replaces the selection with the register contents. Linewise change keeps the trailing newline. Paste accepts a count prefix.
…teals key window

Two interceptor bugs caused Esc to silently no-op when the cursor sat past the last char of the buffer (typically right after typing ';' at end-of-query) and the autocomplete popup was the key window:

1. The willCloseNotification observer spawned a Task to read NSApp.currentEvent. By the time the Task ran, the triggering Esc keyDown had rotated out, so the keyCode == 53 check failed and engine.process(Esc) was skipped. The observer now reads currentEvent synchronously inside the .main-queue block and uses MainActor.assumeIsolated instead of Task.

2. handleKeyEvent guarded on event.window === textView.window. When a child popup is the key window, the Esc keyDown's event.window is the popup, so handleKeyEvent bailed out before the engine could see Esc. The guard now allows Esc keyDowns from child popups of the editor window to reach the engine (mode switch happens first, then the event propagates so the popup also closes naturally).

Adds engine-level regression tests for Esc at offset == buffer.length on buffers both with and without trailing newlines.

Refs #1222
…ponder chain

The SwiftUI "Clear Selection" menu binds Esc to NSResponder.cancelOperation. macOS dispatches menu key equivalents through performKeyEquivalent before the per-app local event monitor, so when the cursor sat right after typing ';' at end-of-buffer, the menu shortcut fired first and the local event monitor never saw Esc — vim was stuck in insert.

The fix has two parts. (1) VimKeyInterceptor.handleKeyEvent no longer gates Esc on event.window: isEditorFocused already proves we're the focused editor, and Esc is the one event we always want to consume in non-normal modes. (2) A new public hook handleEscapeFromExternalSource() lets the SwiftUI menu route Esc through the engine first, falling back to cancelOperation only when no editor is in a non-normal vim mode.

Wired via EditorEventRouter.handleVimEscapeFromMenu() so the "Clear Selection" Button checks the active editor before dispatching the responder-chain action. Adds Swift Testing cases for handleEscapeFromExternalSource covering normal-mode noop, insert→normal, and replace→normal transitions.

Refs #1222
Pressing w (or W) from the last word of the buffer used to land the cursor one position past the last char, leaving a visible block-cursor highlight on the empty space after ';'. Per vim's last-word-on-last-line rule, a bare (non-operator) word motion must clamp to a valid normal-mode cursor position: never past buffer.length, never on a newline.

Adds clampToContentPosition() and applies it at the tail of wordForward and bigWordForward when no operator is pending. Operator-targeted motions (dw, cw, yw) still report the post-content offset so they can delete through the last char.

Adds three regression tests covering: w on ';' at end of "SELECT * FROM users;", w on a single-word no-newline buffer, and w on a single-word trailing-newline buffer.
…ert-edits, number adjust, macros, scroll, sentence/paragraph, edge cases

Adds 128 tests across 9 new files, all consistent with standard vim semantics. Roughly 33 already pass (boundary cases the engine handles correctly today) and 95 are failing TDD specs that drive the next round of engine work:

- Text objects (iw/aw/iW/aW/i"/a"/i'/a'/i(/a(/i{/a{/i[/a[/i</a</it/at/ip/ap) in operator and visual modes

- Search (/, ?, n, N, *, # word-under-cursor) with whole-word matching, wrap-around, and operator combos

- Insert-mode editing (Ctrl+W word-back-delete, Ctrl+U line-start-delete, Ctrl+H backspace, Ctrl+T/D indent/outdent)

- Number adjust (Ctrl+A increment, Ctrl+X decrement) including count, negative, hex, digit-rollover, cursor placement

- Visual reselection: gv, `<, `>

- Sentence/paragraph motions: ( ), { }, [[ ]]

- Macros: q/@/@@ with count, multiple registers, recording across visual operations

- Scroll motions: Ctrl+d/u/f/b, Ctrl+e/y, zt/zz/zb, gj/gk

- Edge cases: empty buffer safety, single-char buffer, end-of-buffer regressions, unicode, CRLF, extreme counts, mode-switch idempotency
…s, scroll, sentence/paragraph, gv

Closes the implementation gap for the comprehensive test spec. Every test that the previous 'extend test spec' commit added as failing TDD now passes — text objects (iw/aw/iW/aW/i"/a"/i'/a'/i(/a(/i{/a{/i[/a[/i</a</it/at/ip/ap), search (/, ?, n, N, *, # with whole-word matching and wrap-around), insert-mode editing (Ctrl+W/U/H/T/D), number adjust (Ctrl+A/X) including signed, hex, count, multi-digit, cursor placement, visual reselection (gv, `<, `>), sentence/paragraph/section motions ( ) { } [[ ]], macros (q/@/@@ with count and multiple registers), and scroll commands (Ctrl+d/u/f/b/e/y, zt/zz/zb, gj/gk).

Also fixed a stale macro-recording removeLast() that dropped the wrong key and routed Esc routing through the test-friendly handleEscapeFromExternalSource hook so menu shortcut preemption no longer leaves Vim stuck in insert. Operator + search motion (d/pattern) remains skipped with an XCTSkip note for the next pass.
…d surface

The vim-mode page only listed ~25 commands. This branch ships ~100, so the docs now cover every command family: motions (h/j/k/l, word/WORD, sentence/paragraph, find-char, H/M/L, %), operators (d/c/y plus D/C/Y/S/s/r/R/~/gu/gU/J/gJ/>>/<<), text objects (iw/aw, i"/a", i(/a(, i{/a{, it/at, ip/ap), search (/?/n/N/*/#), marks, named and numbered registers, macros, repeat, insert-mode Ctrl+W/U/H/T/D, number adjust Ctrl+A/X, and scroll (Ctrl+d/u/f/b, zt/zz/zb, gj/gk).

keyboard-shortcuts.mdx now defers to the dedicated vim-mode page instead of duplicating an abbreviated subset that drifts as features land.
Rewrite tables with shorter headers (How to enter / Goes to / Holds / Action) and trim the prose between them. No em dashes, no filler words. Replace the keyboard-shortcuts vim section's catalog of commands with a one-line pointer to the dedicated page.
@datlechin datlechin merged commit 2d540e9 into main May 11, 2026
1 check passed
@datlechin datlechin deleted the feat/vim-engine-complete-spec branch May 11, 2026 15:36
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.

Vim mode does't support shift + j command to join lines

1 participant