Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/tui/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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))) {
Expand All @@ -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();
Expand Down
28 changes: 25 additions & 3 deletions packages/tui/src/vim-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Expand Down
107 changes: 107 additions & 0 deletions packages/tui/test/productivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down