diff --git a/packages/tui/src/editor.ts b/packages/tui/src/editor.ts index d0bf877c..8f0c4975 100644 --- a/packages/tui/src/editor.ts +++ b/packages/tui/src/editor.ts @@ -201,6 +201,7 @@ export function decodeKeys(data: string): EditorKey[] { const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); const wordCharacter = /[\p{L}\p{N}_]/u; +const blankCharacter = /\s/u; function boundaries(value: string): number[] { const result = [0]; @@ -312,6 +313,30 @@ export class LineEditor { } } + /** + * Vim `cw`: delete to the end of the current word but keep the separator. + * Unlike `dw`, `cw` does not consume the whitespace after the word; on + * whitespace (or at the end of the buffer) Vim falls back to `dw`. + */ + changeWordForward(): void { + const at = this.cursor < this.buffer.length ? graphemeAt(this.buffer, this.cursor) : ""; + // On whitespace (or at the end of the buffer) Vim's `cw` falls back to `dw`. + if (!at || blankCharacter.test(at)) { + this.deleteWordForward(); + return; + } + // Otherwise `cw` is `ce`: change to the end of the current word, where a run + // of non-blank punctuation counts as a word of its own. + const inWord = wordCharacter.test(at); + const sameClass = (char: string): boolean => + inWord ? wordCharacter.test(char) : !wordCharacter.test(char) && !blankCharacter.test(char); + let to = this.cursor; + while (to < this.buffer.length && sameClass(graphemeAt(this.buffer, to))) { + to = nextBoundary(this.buffer, to); + } + this.kill(this.cursor, to); + } + deleteWordForward(): void { let to = this.wordRight(); while (to < this.buffer.length && !wordCharacter.test(graphemeAt(this.buffer, to))) { @@ -327,6 +352,13 @@ export class LineEditor { else this.kill(0, lineEnd); } + /** Remove the current line's text, keep the line itself, and put the cursor at its start. */ + clearCurrentLine(): void { + const { lineStart, lineEnd } = this.position(); + if (lineStart === lineEnd) return; + this.kill(lineStart, lineEnd); + } + openLine(direction: "above" | "below"): void { const { lineStart, lineEnd } = this.position(); this.snapshot(); diff --git a/packages/tui/src/vim-mode.ts b/packages/tui/src/vim-mode.ts index 83571b7b..bbb7dd74 100644 --- a/packages/tui/src/vim-mode.ts +++ b/packages/tui/src/vim-mode.ts @@ -32,12 +32,23 @@ export class VimModeController { this.pending = ""; return true; } - if (key.kind === "enter" || key.kind === "follow-up" || key.kind === "tab") return false; + if (key.kind === "enter" || key.kind === "follow-up" || key.kind === "tab") { + // Any other key cancels a pending operator, including keys the editor + // handles itself. + this.pending = ""; + return false; + } if (key.kind === "ctrl" && key.char === "r") { + this.pending = ""; editor.apply({ kind: "redo" }); return true; } - if (key.kind !== "char") return this.handleNavigation(key, editor); + if (key.kind !== "char") { + // Navigation cancels a pending operator: `c`, Left, `w` must move by a + // word rather than change one. + this.pending = ""; + return this.handleNavigation(key, editor); + } const char = key.char; if (this.pending === "f") { @@ -51,7 +62,18 @@ export class VimModeController { else if (char === "w") editor.deleteWordForward(); return true; } - if (char === "d" || char === "f") { + if (this.pending === "c") { + this.pending = ""; + if (char === "w") { + editor.changeWordForward(); + this.current = "insert"; + } else if (char === "c") { + editor.clearCurrentLine(); + this.current = "insert"; + } + return true; + } + if (char === "d" || char === "f" || char === "c") { this.pending = char; return true; } diff --git a/packages/tui/test/productivity.test.ts b/packages/tui/test/productivity.test.ts index 7d24b253..8191760d 100644 --- a/packages/tui/test/productivity.test.ts +++ b/packages/tui/test/productivity.test.ts @@ -54,6 +54,113 @@ test("Vim mode edits through the standard grapheme-safe editor", () => { assert.equal(editor.render(40).cursorColumn, 3); }); +test("Vim mode cw changes the word but keeps the separator", () => { + const editor = new LineEditor(); + const vim = new VimModeController(); + editor.setText("one two"); + + assert.equal(vim.handle({ kind: "escape" }, editor), true); + vim.handle({ kind: "char", char: "0" }, editor); + vim.handle({ kind: "char", char: "c" }, editor); + vim.handle({ kind: "char", char: "w" }, editor); + // Vim's cw stops at the end of the word; unlike dw it does not eat the space. + assert.equal(editor.text, " two"); + assert.equal(vim.mode, "insert"); +}); + +test("Vim mode cw mid-word and on whitespace match Vim", () => { + const editor = new LineEditor(); + const vim = new VimModeController(); + editor.setText("alpha beta gamma"); + + vim.handle({ kind: "escape" }, editor); + // Mid-word: delete from the cursor to the end of "alpha". + vim.handle({ kind: "char", char: "0" }, editor); + vim.handle({ kind: "char", char: "l" }, editor); + vim.handle({ kind: "char", char: "l" }, editor); + vim.handle({ kind: "char", char: "c" }, editor); + vim.handle({ kind: "char", char: "w" }, editor); + assert.equal(editor.text, "al beta gamma"); + + // On whitespace cw falls back to dw, which takes the whitespace run and the + // following word with it. + const second = new LineEditor(); + const secondVim = new VimModeController(); + second.setText("one two"); + secondVim.handle({ kind: "escape" }, second); + secondVim.handle({ kind: "char", char: "0" }, second); + secondVim.handle({ kind: "right" }, second); + secondVim.handle({ kind: "right" }, second); + secondVim.handle({ kind: "right" }, second); + secondVim.handle({ kind: "char", char: "c" }, second); + secondVim.handle({ kind: "char", char: "w" }, second); + assert.equal(second.text, "one"); +}); + +test("Vim mode cc clears the current line but keeps it", () => { + const editor = new LineEditor(); + const vim = new VimModeController(); + editor.setText("hello\nworld"); + + assert.equal(vim.handle({ kind: "escape" }, editor), true); + vim.handle({ kind: "char", char: "k" }, editor); + vim.handle({ kind: "char", char: "0" }, editor); + vim.handle({ kind: "char", char: "c" }, editor); + vim.handle({ kind: "char", char: "c" }, editor); + assert.equal(editor.text, "\nworld"); + assert.equal(vim.mode, "insert"); +}); + +test("Vim mode c followed by another key cancels without editing", () => { + const editor = new LineEditor(); + const vim = new VimModeController(); + editor.setText("abc"); + + assert.equal(vim.handle({ kind: "escape" }, editor), true); + vim.handle({ kind: "char", char: "c" }, editor); + vim.handle({ kind: "char", char: "x" }, editor); + assert.equal(editor.text, "abc"); + assert.equal(vim.mode, "normal"); +}); + +test("Vim mode navigation cancels a pending operator", () => { + const editor = new LineEditor(); + const vim = new VimModeController(); + editor.setText("one two three"); + + vim.handle({ kind: "escape" }, editor); + vim.handle({ kind: "char", char: "0" }, editor); + vim.handle({ kind: "char", char: "w" }, editor); + assert.equal(editor.render(40).cursorColumn, 3); + vim.handle({ kind: "char", char: "c" }, editor); + // The exact sequence from review: `c`, Left, `w`. The Left cancels `c`, so + // `w` moves a word instead of changing one. + vim.handle({ kind: "left" }, editor); + vim.handle({ kind: "char", char: "w" }, editor); + + assert.equal(editor.text, "one two three"); + assert.equal(vim.mode, "normal"); + assert.equal(editor.render(40).cursorColumn, 3); +}); + +test("Vim mode cw on punctuation changes only the punctuation run", () => { + const editor = new LineEditor(); + const vim = new VimModeController(); + editor.setText("foo.bar baz"); + + vim.handle({ kind: "escape" }, editor); + vim.handle({ kind: "char", char: "0" }, editor); + vim.handle({ kind: "char", char: "l" }, editor); + vim.handle({ kind: "char", char: "l" }, editor); + vim.handle({ kind: "char", char: "l" }, editor); + vim.handle({ kind: "char", char: "c" }, editor); + vim.handle({ kind: "char", char: "w" }, editor); + + // Vim treats the punctuation run as its own word, so `.bar ` is not removed. + assert.equal(editor.text, "foobar baz"); + assert.equal(vim.mode, "insert"); +}); + test("vertical movement preserves the preferred visual column", () => { const editor = new LineEditor(); editor.setText("abcdef\nx\nabcdef");