diff --git a/AGENTS.md b/AGENTS.md index 70b2766..e34302e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,12 +53,12 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (136 lines) Plugin entry: intercept registration, action application - vim.ts (459 lines) Pure vim engine: state, handlers, command tables, types + index.ts (148 lines) Plugin entry: intercept registration, action application + vim.ts (517 lines) Pure vim engine: state, handlers, command tables, types clipboard.ts (19 lines) writeClipboard() — cross-platform (pbcopy/xclip/xsel/wl-copy/clip.exe) version.ts (46 lines) Version constant, GitHub update check (cached daily) test/ - vim.test.ts (676 lines) Characterization tests for all key handling branches + vim.test.ts (791 lines) Characterization tests for all key handling branches ``` **Data flow:** @@ -76,8 +76,11 @@ Handlers in `vim.ts` are pure — they take state + key + event, mutate state, r - `{ type: "mode", mode: Mode }` — updates the SolidJS signal for the indicator - `{ type: "toast", message: string }` — shows a notification - `{ type: "yank", text: string }` — writes text to system clipboard via `writeClipboard()` +- `{ type: "insertText", text: string }` — inserts text at cursor via `editor.insertText()` - `{ type: "yankSelection" }` — reads selected text from the focused editor, stores in yank register and clipboard - `{ type: "clearSelection" }` — clears the textarea's selection via `editorView.resetSelection()` +- `{ type: "cursorTo", offset: number }` — sets `editor.cursorOffset` directly +- `{ type: "selectRange", start: number, end: number }` — calls `editor.setSelectionInclusive(start, end)` ### Adding a keybinding diff --git a/BACKLOG.md b/BACKLOG.md index fcf73c9..d63ca40 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -8,7 +8,7 @@ Ordered by priority within each category. 2. **Fix `gg` requiring two keypresses.** Single `g` fires `input.buffer.home` immediately. Real vim waits for a second `g`. Add pending-key state for `g` with a timeout or second-key check, similar to how `r` already works with `pendingChar`. -3. **Fix `e` behaving identically to `w`.** `editorView.getNextWordBoundary()` is available. Use it to implement proper end-of-word motion that stops at the last character of the current word rather than the first character of the next. +3. ~~**Fix `e` behaving identically to `w`.**~~ Done. 4. **Eliminate `setTimeout` command dispatch for operations that can use direct manipulation.** Multi-command sequences like `O` (home + newline + up) depend on setTimeout ordering. Replace with direct buffer manipulation (`cursorOffset` writes, `insertText()`) where possible. Keep setTimeout only for commands that genuinely need `dispatchCommand` (submit, undo/redo, history navigation). diff --git a/CHANGELOG.md b/CHANGELOG.md index 852bdd5..bcf4637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ### Fixed - `yy` reads the cursor position directly from the editor widget instead of a line counter. The old counter drifted on clicks, arrow keys, and word motions, so `yy` would yank the wrong line. +- `e` moves to end of word instead of behaving like `w`. `de`, `ce`, and `ye` operate on the correct range too. ## [0.9.0] — 2026-05-29 diff --git a/README.md b/README.md index 8d3d2bf..0650174 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ The plugin checks GitHub for new versions once per day on startup. No other netw | Key | Action | |-----|--------| | `h` `j` `k` `l` | Left, down, up, right | -| `w` `b` `e` | Word forward, backward, forward | +| `w` `b` `e` | Word forward, backward, end of word | | `0` `^` | Line start | | `$` | Line end | | `G` | Buffer end | @@ -158,7 +158,6 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b - `ciw`, `di"`, etc. (text objects) - not yet implemented - `gg` - single `g` goes to buffer start immediately, doesn't wait for a second keypress - `dG`, `cG` - delete/change to buffer end not yet implemented (`yG` works) -- `e` behaves the same as `w` - the host doesn't expose a separate "end of word" command - No persistent mode indicator - the toast fades after about a second. Cursor shape is the persistent signal, but a status bar indicator would need the host's SolidJS runtime, which external plugins can't access. Configurable key bindings are next once the core vim coverage stabilizes. diff --git a/src/index.ts b/src/index.ts index 0fa70f8..38c47cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,8 @@ const plugin: TuiPluginModule = { getLine: (n: number) => getInputText().split("\n")[n] ?? "", getLineCount: () => getInputText().split("\n").length, getCursorLine: () => api.renderer?.currentFocusedEditor?.visualCursor?.logicalRow ?? 0, + getCursorOffset: () => api.renderer?.currentFocusedEditor?.cursorOffset ?? 0, + getPlainText: () => getInputText(), }; // api.prompt doesn't exist on the TUI plugin API. The actual text lives @@ -59,6 +61,16 @@ const plugin: TuiPluginModule = { case "clearSelection": api.renderer?.currentFocusedEditor?.editorView?.resetSelection?.(); break; + case "cursorTo": { + const editor = api.renderer?.currentFocusedEditor; + if (editor) editor.cursorOffset = action.offset; + break; + } + case "selectRange": { + const editor = api.renderer?.currentFocusedEditor; + editor?.setSelectionInclusive?.(action.start, action.end); + break; + } } } } diff --git a/src/vim.ts b/src/vim.ts index 3122712..c9a692d 100644 --- a/src/vim.ts +++ b/src/vim.ts @@ -8,7 +8,9 @@ export type Action = | { type: "yank"; text: string } | { type: "insertText"; text: string } | { type: "yankSelection" } - | { type: "clearSelection" }; + | { type: "clearSelection" } + | { type: "cursorTo"; offset: number } + | { type: "selectRange"; start: number; end: number }; export type HandlerResult = { consume: boolean; @@ -36,6 +38,8 @@ export type PromptAccess = { getLine: (n: number) => string; getLineCount: () => number; getCursorLine: () => number; + getCursorOffset: () => number; + getPlainText: () => string; }; export const MOTIONS: Record = { @@ -45,7 +49,6 @@ export const MOTIONS: Record = { k: "input.move.up", w: "input.word.forward", b: "input.word.backward", - e: "input.word.forward", "0": "input.line.home", "^": "input.line.home", $: "input.line.end", @@ -69,7 +72,6 @@ export const SELECT_MOTIONS: Record = { const DELETE_MOTION: Record = { w: "input.delete.word.forward", b: "input.delete.word.backward", - e: "input.delete.word.forward", $: "input.delete.to.line.end", "0": "input.delete.to.line.start", "^": "input.delete.to.line.start", @@ -84,6 +86,35 @@ export function createVimState(): VimState { return { mode: "insert", pendingOp: null, pendingChar: null, count: 0, yankRegister: "" }; } +export function endOfWord(text: string, offset: number, count = 1): number { + const len = text.length; + if (len === 0) return 0; + let pos = offset; + for (let step = 0; step < count; step++) { + // If inside a word/punct run, advance one to start looking for next end + if (pos < len - 1 && charKind(text[pos]) !== "space") { + pos++; + } + // Skip whitespace + while (pos < len && isWhitespace(text[pos])) pos++; + if (pos >= len) return len - 1; + // Find end of current word class run + const kind = charKind(text[pos]); + while (pos + 1 < len && charKind(text[pos + 1]) === kind) pos++; + } + return Math.min(pos, len - 1); +} + +function isWhitespace(ch: string): boolean { + return ch === " " || ch === "\t" || ch === "\n" || ch === "\r"; +} + +function charKind(ch: string): "word" | "punct" | "space" { + if (isWhitespace(ch)) return "space"; + if (/\w/.test(ch)) return "word"; + return "punct"; +} + export function translateKey(ev: KeyEvent): string { let key = ev.name; if (ev.shift && ev.name.length === 1) { @@ -246,6 +277,25 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return { consume: true, actions }; } + // Pending operator + e (end-of-word needs special handling) + if (state.pendingOp && key === "e") { + const n = consumeCount(state); + const offset = prompt.getCursorOffset(); + const target = endOfWord(prompt.getPlainText(), offset, n); + if (state.pendingOp === "y") { + const text = prompt.getPlainText().slice(offset, target + 1); + state.yankRegister = text; + actions.push({ type: "yank", text }); + resetPending(state); + } else { + actions.push({ type: "selectRange", start: offset, end: target }); + actions.push({ type: "cmd", cmd: "input.backspace" }); + if (state.pendingOp === "c") enterInsert(state, actions); + else resetPending(state); + } + return { consume: true, actions }; + } + // Pending operator + motion if (state.pendingOp && key in MOTIONS) { const n = consumeCount(state); @@ -286,6 +336,14 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return { consume: true, actions }; } + // Standalone e (end-of-word) + if (key === "e") { + const n = consumeCount(state); + const target = endOfWord(prompt.getPlainText(), prompt.getCursorOffset(), n); + actions.push({ type: "cursorTo", offset: target }); + return { consume: true, actions }; + } + // Standalone motions if (key in MOTIONS) { const n = consumeCount(state); diff --git a/test/vim.test.ts b/test/vim.test.ts index 0d49a5e..dee09f3 100644 --- a/test/vim.test.ts +++ b/test/vim.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { type Action, createVimState, + endOfWord, handleInsertKey, handleNormalKey, handleVisualKey, @@ -14,6 +15,16 @@ function cmds(actions: Action[]): string[] { return actions.filter((a): a is Extract => a.type === "cmd").map((a) => a.cmd); } +function cursorTos(actions: Action[]): number[] { + return actions.filter((a): a is Extract => a.type === "cursorTo").map((a) => a.offset); +} + +function selectRanges(actions: Action[]): Array<{ start: number; end: number }> { + return actions + .filter((a): a is Extract => a.type === "selectRange") + .map((a) => ({ start: a.start, end: a.end })); +} + const ev = (name: string, opts?: { shift?: boolean; ctrl?: boolean; meta?: boolean; super?: boolean }) => ({ name, shift: opts?.shift ?? false, @@ -26,12 +37,16 @@ const mockPrompt: PromptAccess = { getLine: (n) => ["hello world", "second line", "third line"][n] ?? "", getLineCount: () => 3, getCursorLine: () => 0, + getCursorOffset: () => 0, + getPlainText: () => "hello world\nsecond line\nthird line", }; const emptyPrompt: PromptAccess = { getLine: () => "", getLineCount: () => 1, getCursorLine: () => 0, + getCursorOffset: () => 0, + getPlainText: () => "", }; let state: VimState; @@ -41,6 +56,59 @@ beforeEach(() => { state.mode = "normal"; }); +// ── endOfWord ────────────────────────────────────────────── + +describe("endOfWord", () => { + it("from start of word, moves to last char", () => { + expect(endOfWord("hello world", 0)).toBe(4); + }); + + it("from middle of word, moves to last char", () => { + expect(endOfWord("hello world", 2)).toBe(4); + }); + + it("from end of word, moves to end of next word", () => { + expect(endOfWord("hello world", 4)).toBe(10); + }); + + it("from whitespace, skips to end of next word", () => { + expect(endOfWord("hello world", 5)).toBe(10); + }); + + it("stops at punctuation boundary", () => { + expect(endOfWord("hello.world", 0)).toBe(4); + }); + + it("from punctuation, moves to end of punctuation run", () => { + expect(endOfWord("hello...world", 5)).toBe(7); + }); + + it("from end of punctuation, moves to end of next word", () => { + expect(endOfWord("a.b", 1)).toBe(2); + }); + + it("at end of text, stays put", () => { + expect(endOfWord("hello", 4)).toBe(4); + }); + + it("handles count > 1", () => { + expect(endOfWord("one two three", 0, 2)).toBe(6); + }); + + it("handles multiple whitespace", () => { + expect(endOfWord("hello world", 0)).toBe(4); + expect(endOfWord("hello world", 4)).toBe(12); + }); + + it("handles newlines as whitespace", () => { + expect(endOfWord("hello\nworld", 4)).toBe(10); + }); + + it("clamps at end of text", () => { + expect(endOfWord("hi", 0, 5)).toBe(1); + }); +}); + // ── translateKey ──────────────────────────────────────────── describe("translateKey", () => { @@ -158,6 +226,53 @@ describe("handleNormalKey — motions", () => { }); }); +// ── handleNormalKey — e motion ───────────────────────────── + +describe("handleNormalKey — e motion", () => { + const ePrompt: PromptAccess = { + getLine: (n) => ["hello world", "second line"][n] ?? "", + getLineCount: () => 2, + getCursorLine: () => 0, + getCursorOffset: () => 0, + getPlainText: () => "hello world\nsecond line", + }; + + it("e returns cursorTo at end of current word", () => { + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(r.consume).toBe(true); + expect(cursorTos(r.actions)).toEqual([4]); + }); + + it("2e returns cursorTo at end of second word", () => { + handleNormalKey(state, "2", ev("2"), ePrompt); + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(cursorTos(r.actions)).toEqual([10]); + }); + + it("de deletes from cursor to end of word", () => { + handleNormalKey(state, "d", ev("d"), ePrompt); + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(cmds(r.actions)).toContain("input.backspace"); + expect(state.mode).toBe("normal"); + }); + + it("ce deletes from cursor to end of word and enters insert", () => { + handleNormalKey(state, "c", ev("c"), ePrompt); + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(cmds(r.actions)).toContain("input.backspace"); + expect(state.mode).toBe("insert"); + }); + + it("ye yanks from cursor to end of word", () => { + handleNormalKey(state, "y", ev("y"), ePrompt); + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(state.yankRegister).toBe("hello"); + expect(r.actions.some((a) => a.type === "yank" && a.text === "hello")).toBe(true); + }); +}); + // ── handleNormalKey — operators ───────────────────────────── describe("handleNormalKey — operators", () => {