diff --git a/AGENTS.md b/AGENTS.md index 6e74784..ec66175 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on it, understand the plugin system: **References (read these, don't guess):** + - Official plugin docs: https://opencode.ai/docs/plugins/ - TUI plugin spec: https://github.com/sst/opencode/blob/dev/packages/opencode/specs/tui-plugins.md - Plugin types: `@opencode-ai/plugin/tui` exports `TuiPluginModule`, `TuiPluginApi` @@ -14,6 +15,7 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on i `keymap` (register layers, intercepts, dispatch commands), `slots` (register UI into named slots), `ui` (toasts, dialogs), `theme` (colors), `prompt` (read/write prompt text), `state` (session, config), `client` (SDK), `lifecycle` (disposal), `kv` (persistent storage), `route` (custom screens). **Gotchas we hit during development:** + - TUI plugins go in `tui.json`, not `opencode.json`. The config field is `"plugin"`. - The plugin `package.json` needs `exports: { "./tui": "./src/index.ts" }` — the loader checks `./tui`, not `.`. - **`key:before` is NOT a valid intercept type.** The keymap only supports `"key"`, `"key:after"`, and `"raw"`. Passing `"key:before"` silently registers a raw terminal sequence handler that crashes on key events. @@ -30,20 +32,23 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on i `api.renderer.currentFocusedEditor` (same object as `currentFocusedRenderable`) exposes the underlying Textarea widget. Not part of the documented plugin API, but stable and available at runtime. The codebase currently uses `plainText`, `cursorOffset`, `visualCursor`, `cursorStyle`, `insertText()`, and `editorView`. The rest of the surface below is available but unused. **Top-level properties (read/write):** -- `cursorOffset: number` — absolute cursor position, readable and writable -- `visualCursor: { visualRow, visualCol, logicalRow, logicalCol, offset }` — full cursor coordinates (read-only in practice) + +- `cursorOffset: number` — rendered cursor position, readable and writable; its setter uses display columns, so tabs make it unsuitable for raw buffer offsets +- `visualCursor: { visualRow, visualCol, logicalRow, logicalCol, offset }` — full cursor coordinates (read-only in practice); `logicalCol` and `offset` use display-width units - `cursorStyle: { style: "block" | "line" | "underline" | "default", blinking: boolean }` — set directly, no DECSCUSR escape needed - `plainText: string` — buffer content - `selectionBg: RGBA`, `selectionFg: RGBA` — custom selection highlight colors **Top-level methods:** + - `moveCursorLeft/Right/Up/Down()` — direct cursor movement -- `setSelection(start, end)`, `setSelectionInclusive(start, end)`, `clearSelection()` — selection control +- `setSelection(start, end)`, `setSelectionInclusive(start, end)`, `clearSelection()` — selection control; range offsets use display-width units - `gotoVisualLineEnd()`, `gotoLineEnd()` — line boundary jumps - `insertText(text)` — insert at cursor **editorView methods (lower-level):** -- `setCursorByOffset(n)` — position cursor by offset + +- `setCursorByOffset(n)` — position cursor by a global display-width offset, like `cursorOffset`; translate string offsets before using either - `getNextWordBoundary()`, `getPrevWordBoundary()` — word boundary detection (enables proper `e` vs `w`) - `getEOL()`, `getVisualSOL()`, `getVisualEOL()` — line boundary info - `getLineInfo()`, `getLogicalLineInfo()` — line metadata @@ -52,43 +57,48 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on i - `moveUpVisual()`, `moveDownVisual()` — visual line movement - `setSelection()`, `resetSelection()`, `hasSelection()` — selection management +**editBuffer methods:** + +- `setCursor(row, col)` — set the cursor without selection handling; row and column use display-width units + This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, and accurate line operations feasible. The current `setTimeout` + `dispatchCommand` approach can be replaced with direct widget manipulation for most operations. ## Architecture ``` src/ - index.ts (414 lines) Plugin entry: intercept registration, action application + index.ts (479 lines) Plugin entry: intercept registration, action application vim/ Pure vim engine (thin barrel re-exports the public surface): index.ts (7 lines) Barrel — public surface only. No export *, no internals. - types.ts (57 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess - text.ts (210 lines) Pure string algorithms: charKind, endOfWord, currentLineRange, wordRange, bracketRange, quoteRange, anyBracketRange, anyQuoteRange - tables.ts (35 lines) Keybinding maps: MOTIONS, SELECT_MOTIONS, DELETE_MOTION (engine-internal) + types.ts (59 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess + text.ts (224 lines) Pure string algorithms: charKind, endOfWord, currentLineRange, firstNonBlankOnLine, wordRange, bracketRange, quoteRange, anyBracketRange, anyQuoteRange + tables.ts (32 lines) Keybinding maps: MOTIONS, SELECT_MOTIONS, DELETE_MOTION (engine-internal) textobject.ts (36 lines) resolveTextObject — object char → inclusive Range seam (iw/aw, quote/bracket pairs, iq/ib) - util.ts (19 lines) State-agnostic primitives: translateKey, PASS, pushN - state.ts (76 lines) VimState lifecycle + transitions - insert.ts (32 lines) handleInsertKey - normal.ts (378 lines) handleNormalKey (+ file-local finishUndoableChange, applyOperatorRange, isInputEmpty) - visual.ts (104 lines) handleVisualKey + util.ts (20 lines) State-agnostic primitives: translateKey, PASS, pushN + state.ts (79 lines) VimState lifecycle + transitions + insert.ts (39 lines) handleInsertKey + normal.ts (444 lines) handleNormalKey (+ file-local finishUndoableChange, applyOperatorRange, isInputEmpty) + visual.ts (124 lines) handleVisualKey leader.ts (73 lines) Leader key matching: matchesKeyLike, findMatchingLeader, leaderChar 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/ - support.ts (33 lines) Shared assertion helpers + ev() + support.ts (37 lines) Shared assertion helpers + ev() fixtures.ts (17 lines) Prompt fixtures: mockPrompt, emptyPrompt vim/ Per-module engine tests mirroring src/vim/: - text.test.ts (385) endOfWord, charKind, currentLineRange, wordRange, bracketRange, quoteRange, any* units + text.test.ts (412) endOfWord, charKind, currentLineRange, firstNonBlankOnLine, wordRange, bracketRange, quoteRange, any* units state.test.ts (70) createVimState, toggleVimMode - util.test.ts (31) translateKey - insert.test.ts (92) handleInsertKey - normal.test.ts (823) handleNormalKey branches - visual.test.ts (287) handleVisualKey branches + util.test.ts (35) translateKey + insert.test.ts (108) handleInsertKey + normal.test.ts (977) handleNormalKey branches + visual.test.ts (329) handleVisualKey branches textobject.test.ts (64) resolveTextObject dispatch seam - integration.test.ts (579) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking + integration.test.ts (797) Full pipeline: cursor positioning, one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking leader.test.ts (125 lines) Unit tests for leader key matching functions ``` **Data flow:** + ``` KeyEvent → translateKey() → handleInsertKey/handleNormalKey/handleVisualKey() → HandlerResult { consume, actions[] } ↓ ↓ @@ -99,6 +109,7 @@ KeyEvent → translateKey() → handleInsertKey/handleNormalKey/handleVisualKey( Handlers in `src/vim/` (`insert.ts`, `normal.ts`, `visual.ts`) are pure — they take state + key + event, mutate state, return actions. They never touch `api`. The only file that calls `api.keymap.dispatchCommand` is `index.ts`. `src/vim/index.ts` is a strict barrel: it re-exports only the public surface (no `export *`, no internal helpers), and sibling modules import each other directly (`./types`, `./state`, …) never through the barrel. **Action types:** + - `{ type: "cmd", cmd: string }` — dispatched via `setTimeout(() => api.keymap.dispatchCommand(cmd), 0)` - `{ type: "mode", mode: Mode }` — updates the SolidJS signal for the indicator - `{ type: "toast", message: string }` — shows a notification @@ -106,7 +117,8 @@ Handlers in `src/vim/` (`insert.ts`, `normal.ts`, `visual.ts`) are pure — they - `{ 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: "cursorTo", offset: number }` — moves the textarea cursor to a buffer offset +- `{ type: "cursorLeft" }` — moves the textarea cursor left once - `{ type: "selectRange", start: number, end: number }` — calls `editor.setSelectionInclusive(start, end)` - `{ type: "deleteRange", start: number, end: number }` — deletes text between inclusive offsets via `editBuffer.deleteRange()`. Saves a snapshot for single-step undo (see below). - `{ type: "undo" }` — if an undo snapshot exists (from a `deleteRange`), restores the full buffer from it. Otherwise falls back to `dispatchCommand("input.undo")`. @@ -117,15 +129,23 @@ Handlers in `src/vim/` (`insert.ts`, `normal.ts`, `visual.ts`) are pure — they 2. Add the key check and return appropriate actions: ```ts if (key === "yourkey") { - return { consume: true, actions: [{ type: "cmd", cmd: "input.some.command" }] } + return { + consume: true, + actions: [{ type: "cmd", cmd: "input.some.command" }], + }; } ``` 3. Add a test in the matching `test/vim/*.test.ts` (e.g. `test/vim/normal.test.ts`), importing helpers from `../support` and fixtures from `../fixtures`: ```ts it("yourkey dispatches some.command", () => { - const result = handleNormalKey(state, "yourkey", ev("yourkey"), mockPrompt) - expect(cmds(result.actions)).toEqual(["input.some.command"]) - }) + const result = handleNormalKey( + state, + "yourkey", + ev("yourkey"), + mockPrompt, + ); + expect(cmds(result.actions)).toEqual(["input.some.command"]); + }); ``` 4. Run `bun test`, then `just dev` to verify in OpenCode. @@ -134,6 +154,7 @@ Handlers in `src/vim/` (`insert.ts`, `normal.ts`, `visual.ts`) are pure — they Operators (d/c/y) use two tables in `src/vim/tables.ts`: `MOTIONS` maps key → standalone cursor command, `DELETE_MOTION` maps key → destructive command. When an operator is pending and a motion key arrives, `handleNormalKey` (in `src/vim/normal.ts`) looks up `DELETE_MOTION[key]` and dispatches it. To add a new motion that works with operators: + 1. Add the standalone motion to `MOTIONS`: `{ "yourkey": "input.move.whatever" }` 2. Add the destructive version to `DELETE_MOTION`: `{ "yourkey": "input.delete.whatever" }` 3. If the motion needs special handling with operators (like j/k which delete multiple lines), add an explicit branch in the `state.pending.kind === "operator" && key in MOTIONS` section. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cc206f..9396a00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ## [Unreleased] +### Added + +- Added `_` and `I` support with Vim's first-non-blank behavior. + +### Fixed + +- `^` now moves to the first non-blank character instead of the start of the line. +- Escape after `I` now moves left from the actual textarea cursor instead of misplacing the cursor after indentation. + ## [0.18.1] — 2026-09-11 ### Changed diff --git a/README.md b/README.md index 096e68c..eb5939b 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,9 @@ The plugin checks GitHub for new versions once per day on startup. No other netw |-----|--------| | `h` `j` `k` `l` | Left, down, up, right | | `w` `b` `e` | Word forward, backward, end of word | -| `0` `^` | Line start | +| `0` | Line start | +| `^` | First non-blank character | +| `_` | First non-blank character on the count-th line down (`4_` = `3j^`) | | `$` | Line end | | `gg` | Buffer start | | `G` | Buffer end | @@ -124,7 +126,8 @@ When the input is empty, `j`/`k` scroll through prompt history instead of moving | `de` `ce` `ye` | To end of word | | `d$` `c$` `y$` | To end of line | | `d0` `c0` `y0` | To start of line | -| `d^` `c^` `y^` | To start of line | +| `d^` `c^` `y^` | To first non-blank character | +| `d_` `c_` `y_` | Operate on whole line(s), like `dd` `cc` `yy` | | `dh` `ch` `yh` | Character left | | `dl` `cl` `yl` | Character right | | `dj` `cj` `yj` | Current + line below | @@ -155,6 +158,7 @@ So `diw`, `ci"`, `da(`, `yi{`, `vib`, `ciq` all work. `iw` covers the run under |-----|--------| | `i` | Insert at cursor | | `a` | Insert after cursor | +| `I` | Insert at first non-blank character | | `A` | Insert at end of line | | `o` | Open line below | | `O` | Open line above | @@ -173,7 +177,7 @@ Press `v` in normal mode to enter character-wise visual mode. Press `V` to selec | `V` | Select current line | | `Escape` `v` | Exit visual mode | -All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b` `e` `0` `$` `G`, with counts. +All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b` `e` `0` `^` `_` `$` `G`, with counts. ### Other diff --git a/src/index.ts b/src/index.ts index 1e56199..c53e986 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,7 +97,7 @@ 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, + getCursorOffset: () => stringOffsetForCursor(api.renderer?.currentFocusedEditor, getInputText()), getPlainText: () => getInputText(), }; @@ -184,8 +184,8 @@ const plugin: TuiPluginModule = { const eb = editor?.editBuffer; if (eb?.deleteRange) { const text = editor.plainText ?? ""; - const [sl, sc] = offsetToLineCol(text, action.start); - const [el, ec] = offsetToLineCol(text, action.end + 1); + const [sl, sc] = stringOffsetToLineCol(text, action.start); + const [el, ec] = stringOffsetToLineCol(text, action.end + 1); eb.deleteRange(sl, sc, el, ec); } break; @@ -217,13 +217,36 @@ const plugin: TuiPluginModule = { } case "cursorTo": { const editor = api.renderer?.currentFocusedEditor; - if (editor) editor.cursorOffset = action.offset; + if (editor) { + const text = editor.plainText ?? ""; + const target = Math.min(Math.max(action.offset, 0), text.length); + const [row, col] = stringOffsetToLineCol(text, target); + if (editor.editBuffer?.setCursor) editor.editBuffer.setCursor(row, col); + else editor.cursorOffset = stringOffsetToDisplayOffset(text, target); + editor.getLayoutNode?.().markDirty?.(); + api.renderer?.requestRender?.(); + } + break; + } + case "cursorLeft": { + const editor = api.renderer?.currentFocusedEditor; + if (editor?.moveCursorLeft) { + editor.moveCursorLeft(); + editor.getLayoutNode?.().markDirty?.(); + api.renderer?.requestRender?.(); + } else if (editor) { + editor.cursorOffset = Math.max(0, (editor.cursorOffset ?? 0) - 1); + } break; } case "selectRange": { const editor = api.renderer?.currentFocusedEditor; if (editor) { - editor.setSelectionInclusive?.(action.start, action.end); + const text = editor.plainText ?? ""; + const start = stringOffsetToDisplayOffset(text, Math.min(action.start, action.end)); + const end = stringOffsetToDisplayOffset(text, Math.max(action.start, action.end) + 1); + if (editor.setSelection) editor.setSelection(start, end); + else editor.setSelectionInclusive?.(start, end - 1); } break; } @@ -405,10 +428,52 @@ const plugin: TuiPluginModule = { }, }; -function offsetToLineCol(text: string, offset: number): [number, number] { - const before = text.substring(0, offset); - const lines = before.split("\n"); - return [lines.length - 1, lines[lines.length - 1].length]; +// OpenTUI offsets and columns count tabs as two display cells. Vim's pure +// engine uses JavaScript string offsets, so translate only at this boundary. +function stringOffsetToDisplayOffset(text: string, offset: number): number { + const target = Math.min(Math.max(offset, 0), text.length); + let displayOffset = 0; + for (let i = 0; i < target; i++) displayOffset += text[i] === "\t" ? 2 : 1; + return displayOffset; +} + +function stringOffsetToLineCol(text: string, offset: number): [number, number] { + const target = Math.min(Math.max(offset, 0), text.length); + let row = 0; + let col = 0; + for (let i = 0; i < target; i++) { + if (text[i] === "\n") { + row++; + col = 0; + } else { + col += text[i] === "\t" ? 2 : 1; + } + } + return [row, col]; +} + +function stringOffsetForCursor( + editor: { visualCursor?: { logicalRow?: number; logicalCol?: number }; cursorOffset?: number } | undefined, + text: string, +): number { + const cursor = editor?.visualCursor; + if (typeof cursor?.logicalRow === "number" && typeof cursor?.logicalCol === "number") { + const lineStart = text + .split("\n") + .slice(0, cursor.logicalRow) + .reduce((offset, line) => offset + line.length + 1, 0); + return lineStart + stringOffsetForDisplayOffset(text.slice(lineStart), cursor.logicalCol); + } + return stringOffsetForDisplayOffset(text, editor?.cursorOffset ?? 0); +} + +function stringOffsetForDisplayOffset(text: string, displayOffset: number): number { + let display = 0; + for (let i = 0; i < text.length; i++) { + if (display >= displayOffset) return i; + display += text[i] === "\t" ? 2 : 1; + } + return text.length; } export default plugin; diff --git a/src/vim/insert.ts b/src/vim/insert.ts index 31acf32..43bd289 100644 --- a/src/vim/insert.ts +++ b/src/vim/insert.ts @@ -8,25 +8,32 @@ export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent, pro // Vim moves cursor one left when leaving insert mode, // unless at position 0 or start of line. const offset = prompt.getCursorOffset(); - if (offset > 0 && prompt.getPlainText()[offset - 1] !== "\n") { - actions.push({ type: "cursorTo", offset: offset - 1 }); + const stayedAtIEntry = offset === state.insertEntryOffset; + state.insertEntryOffset = undefined; + if (!stayedAtIEntry && offset > 0 && prompt.getPlainText()[offset - 1] !== "\n") { + actions.push({ type: "cursorLeft" }); } actions.push({ type: "mode", mode: "normal" }); return { consume: true, actions }; } if (ev.name === "return" && ev.ctrl) { + state.insertEntryOffset = undefined; return { consume: true, actions: [{ type: "cmd", cmd: "input.submit" }] }; } if (ev.name === "return") { + state.insertEntryOffset = undefined; return { consume: true, actions: [{ type: "cmd", cmd: "input.newline" }] }; } if (ev.name === "tab") { + state.insertEntryOffset = undefined; return { consume: true, actions: [{ type: "insertText", text: "\t" }] }; } if (ev.name === "o" && ev.ctrl) { state.mode = "normal"; state.oneShotNormal = true; + state.insertEntryOffset = undefined; return { consume: true, actions: [{ type: "mode", mode: "(insert)" }] }; } + state.insertEntryOffset = undefined; return PASS; } diff --git a/src/vim/normal.ts b/src/vim/normal.ts index 8d21c48..1be24d4 100644 --- a/src/vim/normal.ts +++ b/src/vim/normal.ts @@ -1,6 +1,6 @@ import { consumeCount, enterInsert, resetPending } from "./state"; import { DELETE_MOTION, MOTIONS, SELECT_MOTIONS } from "./tables"; -import { currentLineRange, endOfWord } from "./text"; +import { currentLineRange, endOfWord, firstNonBlankOnLine } from "./text"; import { resolveTextObject } from "./textobject"; import type { Action, HandlerResult, KeyEvent, Operator, PromptAccess, VimState } from "./types"; import { PASS, pushN } from "./util"; @@ -187,6 +187,52 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return finishUndoableChange(actions); } + // `_` is linewise when used with an operator, like `dd`/`cc`/`yy`. + if (state.pending.kind === "operator" && key === "_") { + const op = state.pending.op; + const n = consumeCount(state); + + if (op === "y") { + const cursorLine = prompt.getCursorLine(); + const lines: string[] = []; + for (let i = 0; i < n; i++) lines.push(prompt.getLine(cursorLine + i)); + const text = `${lines.join("\n")}\n`; + state.yankRegister = text; + actions.push({ type: "yank", text }); + resetPending(state); + return { consume: true, actions }; + } + + pushN(actions, "input.delete.line", n); + if (op === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + + // `^` is an exclusive characterwise motion to the first non-blank character. + if (state.pending.kind === "operator" && key === "^") { + const op = state.pending.op; + consumeCount(state); + const text = prompt.getPlainText(); + const offset = prompt.getCursorOffset(); + const target = firstNonBlankOnLine(text, offset); + const start = Math.min(offset, target); + const end = Math.max(offset, target) - 1; + + if (op === "y") { + const yanked = text.slice(start, end + 1); + state.yankRegister = yanked; + actions.push({ type: "yank", text: yanked }); + resetPending(state); + return { consume: true, actions }; + } + + if (start <= end) actions.push({ type: "deleteRange", start, end }); + if (op === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + // Pending operator + e (end-of-word needs special handling) if (state.pending.kind === "operator" && key === "e") { const op = state.pending.op; @@ -252,6 +298,15 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return { consume: true, actions }; } + if (key === "^" || key === "_") { + const n = consumeCount(state); + actions.push({ + type: "cursorTo", + offset: firstNonBlankOnLine(prompt.getPlainText(), prompt.getCursorOffset(), key === "_" ? n - 1 : 0), + }); + return { consume: true, actions }; + } + // Standalone motions if (key in MOTIONS) { const n = consumeCount(state); @@ -322,6 +377,17 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return { consume: true, actions }; } + if (key === "I") { + const offset = firstNonBlankOnLine(prompt.getPlainText(), prompt.getCursorOffset()); + actions.push({ + type: "cursorTo", + offset, + }); + enterInsert(state, actions); + state.insertEntryOffset = offset; + return { consume: true, actions }; + } + if (key === "A") { actions.push({ type: "cmd", cmd: "input.line.end" }); enterInsert(state, actions); diff --git a/src/vim/state.ts b/src/vim/state.ts index b84a4dc..b939826 100644 --- a/src/vim/state.ts +++ b/src/vim/state.ts @@ -20,6 +20,7 @@ export function toggleVimMode(state: VimState): HandlerResult { state.pending = { kind: "none" }; state.count = 0; state.oneShotNormal = false; + state.insertEntryOffset = undefined; return { consume: true, actions: [ @@ -60,6 +61,7 @@ export function enterInsert(state: VimState, actions: Action[]) { resetPending(state); state.mode = "insert"; state.oneShotNormal = false; + state.insertEntryOffset = undefined; actions.push({ type: "mode", mode: "insert" }); } @@ -67,6 +69,7 @@ export function enterNormal(state: VimState, actions: Action[]) { state.mode = "normal"; state.count = 0; state.oneShotNormal = false; + state.insertEntryOffset = undefined; actions.push({ type: "mode", mode: "normal" }); } diff --git a/src/vim/tables.ts b/src/vim/tables.ts index 9c01aa7..b316e1b 100644 --- a/src/vim/tables.ts +++ b/src/vim/tables.ts @@ -6,7 +6,6 @@ export const MOTIONS: Record = { w: "input.word.forward", b: "input.word.backward", "0": "input.line.home", - "^": "input.line.home", $: "input.line.end", G: "input.buffer.end", }; @@ -19,7 +18,6 @@ export const SELECT_MOTIONS: Record = { w: "input.select.word.forward", b: "input.select.word.backward", "0": "input.select.line.home", - "^": "input.select.line.home", $: "input.select.line.end", G: "input.select.buffer.end", }; @@ -29,7 +27,6 @@ export const DELETE_MOTION: Record = { b: "input.delete.word.backward", $: "input.delete.to.line.end", "0": "input.delete.to.line.start", - "^": "input.delete.to.line.start", h: "input.backspace", l: "input.delete", }; diff --git a/src/vim/text.ts b/src/vim/text.ts index 56ee4eb..e0ec69b 100644 --- a/src/vim/text.ts +++ b/src/vim/text.ts @@ -43,6 +43,20 @@ export function currentLineRange(text: string, offset: number): { start: number; return { start, end: newline === -1 ? text.length - 1 : newline }; } +export function firstNonBlankOnLine(text: string, offset: number, linesDown = 0): number { + const safeOffset = Math.min(Math.max(offset, 0), text.length); + let start = safeOffset === 0 ? 0 : text.lastIndexOf("\n", safeOffset - 1) + 1; + for (let i = 0; i < linesDown; i++) { + const newline = text.indexOf("\n", start); + if (newline === -1) break; + start = newline + 1; + } + const lineEnd = text.indexOf("\n", start); + const line = text.slice(start, lineEnd === -1 ? text.length : lineEnd); + const firstNonBlank = line.search(/[^ \t\r]/); + return start + Math.max(0, firstNonBlank); +} + // Inclusive [start, end] offsets for the `iw`/`aw` text object under the // cursor. A "word" is a run of one charKind (word/punct/space); runs never // cross a newline. `around` extends past a word/punct run to its trailing diff --git a/src/vim/types.ts b/src/vim/types.ts index ca296c1..733d28e 100644 --- a/src/vim/types.ts +++ b/src/vim/types.ts @@ -22,6 +22,7 @@ export type Action = | { type: "saveUndoSnapshot" } | { type: "undo" } | { type: "cursorTo"; offset: number } + | { type: "cursorLeft" } | { type: "selectRange"; start: number; end: number }; export type HandlerResult = { @@ -36,6 +37,7 @@ export type VimState = { yankRegister: string; oneShotNormal: boolean; disabled: boolean; + insertEntryOffset?: number; visualAnchor?: number; }; diff --git a/src/vim/util.ts b/src/vim/util.ts index e24a561..1e1417a 100644 --- a/src/vim/util.ts +++ b/src/vim/util.ts @@ -8,6 +8,7 @@ export function translateKey(ev: KeyEvent): string { if (/[a-z]/.test(ev.name)) key = ev.name.toUpperCase(); else if (ev.name === "4") key = "$"; else if (ev.name === "6") key = "^"; + else if (ev.name === "-") key = "_"; else if (ev.name === "[") key = "{"; else if (ev.name === "]") key = "}"; } diff --git a/src/vim/visual.ts b/src/vim/visual.ts index 4ab479d..0d34ef5 100644 --- a/src/vim/visual.ts +++ b/src/vim/visual.ts @@ -1,6 +1,6 @@ import { consumeCount, enterInsert, enterNormal, exitVisual } from "./state"; import { SELECT_MOTIONS } from "./tables"; -import { endOfWord } from "./text"; +import { endOfWord, firstNonBlankOnLine } from "./text"; import { resolveTextObject } from "./textobject"; import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; import { PASS, pushN } from "./util"; @@ -76,7 +76,37 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom if (key === "e") { const n = consumeCount(state); const target = endOfWord(prompt.getPlainText(), prompt.getCursorOffset(), n); - actions.push({ type: "selectRange", start: state.visualAnchor ?? 0, end: target }); + actions.push({ + type: "selectRange", + start: state.visualAnchor ?? 0, + end: target, + }); + actions.push({ type: "cursorTo", offset: target }); + return { consume: true, actions }; + } + + if (key === "^" || key === "_") { + const n = consumeCount(state); + const target = firstNonBlankOnLine(prompt.getPlainText(), prompt.getCursorOffset(), key === "_" ? n - 1 : 0); + actions.push({ + type: "selectRange", + start: state.visualAnchor ?? 0, + end: target, + }); + actions.push({ type: "cursorTo", offset: target }); + return { consume: true, actions }; + } + + if (key === "h" || key === "l") { + const text = prompt.getPlainText(); + const offset = prompt.getCursorOffset(); + const count = consumeCount(state); + const target = key === "h" ? Math.max(0, offset - count) : Math.min(Math.max(0, text.length - 1), offset + count); + actions.push({ + type: "selectRange", + start: state.visualAnchor ?? 0, + end: target, + }); actions.push({ type: "cursorTo", offset: target }); return { consume: true, actions }; } diff --git a/test/integration.test.ts b/test/integration.test.ts index e208911..d114a16 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -417,6 +417,224 @@ describe("undo snapshot — deleteRange + u", () => { }); }); +// ── direct cursor positioning ─────────────────────────────── + +describe("direct cursor positioning", () => { + async function setup(text: string, cursor: number, displayCursor = cursor) { + const plugin = (await import("../src/index")).default; + let editorText = text; + let editorCursor = cursor; + let selection: { start: number; end: number } | undefined; + // biome-ignore lint/suspicious/noExplicitAny: test mock + let handler: (ctx: any) => void; + + const stringOffsetForDisplayOffset = (value: string, displayOffset: number) => { + let display = 0; + for (let i = 0; i < value.length; i++) { + if (display >= displayOffset) return i; + display += value[i] === "\t" ? 2 : 1; + } + return value.length; + }; + const displayOffsetForStringOffset = (value: string, offset: number) => { + let display = 0; + for (let i = 0; i < offset; i++) display += value[i] === "\t" ? 2 : 1; + return display; + }; + const lineColToOffset = (line: number, col: number) => { + const lines = editorText.split("\n"); + const lineStart = lines.slice(0, line).reduce((offset, current) => offset + current.length + 1, 0); + return lineStart + stringOffsetForDisplayOffset(lines[line] ?? "", col); + }; + const deleteSelection = () => { + if (!selection) return; + editorText = editorText.slice(0, selection.start) + editorText.slice(selection.end + 1); + editorCursor = selection.start; + selection = undefined; + }; + const editor = { + get plainText() { + return editorText; + }, + get cursorOffset() { + return displayCursor; + }, + set cursorOffset(value: number) { + editorCursor = stringOffsetForDisplayOffset(editorText, value); + displayCursor = value; + }, + get visualCursor() { + const lineStart = editorText.lastIndexOf("\n", editorCursor - 1) + 1; + return { + logicalRow: editorText.slice(0, editorCursor).split("\n").length - 1, + logicalCol: displayOffsetForStringOffset(editorText.slice(lineStart), editorCursor - lineStart), + offset: displayOffsetForStringOffset(editorText, editorCursor), + }; + }, + cursorStyle: { style: "block" as const, blinking: true }, + moveCursorRight: () => { + // This matches the host behavior that made cursorTo skip past a visual selection. + editorCursor = selection ? selection.end + 1 : editorCursor + 1; + }, + moveCursorLeft: () => { + editorCursor = Math.max(0, editorCursor - 1); + }, + insertText: (input: string) => { + editorText = editorText.slice(0, editorCursor) + input + editorText.slice(editorCursor); + editorCursor += input.length; + }, + setSelectionInclusive: (start: number, end: number) => { + selection = { start, end }; + }, + setSelection: (start: number, end: number) => { + selection = { + start: stringOffsetForDisplayOffset(editorText, start), + end: stringOffsetForDisplayOffset(editorText, end) - 1, + }; + }, + editorView: { + setCursorByOffset: (offset: number) => { + editorCursor = stringOffsetForDisplayOffset(editorText, offset); + displayCursor = offset; + }, + resetSelection: () => { + selection = undefined; + }, + }, + editBuffer: { + setCursor: (row: number, col: number) => { + editorCursor = lineColToOffset(row, col); + displayCursor = displayOffsetForStringOffset(editorText, editorCursor); + }, + deleteRange: (startLine: number, startCol: number, endLine: number, endCol: number) => { + const start = lineColToOffset(startLine, startCol); + const end = lineColToOffset(endLine, endCol); + editorText = editorText.slice(0, start) + editorText.slice(end); + editorCursor = start; + }, + }, + getLayoutNode: () => ({ markDirty: () => {} }), + }; + + const api = { + renderer: { + currentFocusedEditor: editor, + currentFocusedRenderable: editor, + requestRender: () => {}, + }, + ui: { toast: () => {}, dialog: { open: false } }, + keymap: { + intercept: (_event: string, callback: typeof handler) => { + handler = callback; + }, + dispatchCommand: (command: string) => { + if (command === "input.delete") { + editorText = editorText.slice(0, editorCursor) + editorText.slice(editorCursor + 1); + } + if (command === "input.backspace") deleteSelection(); + // OpenTUI incorrectly moves to the preceding line's end here. + if (command === "input.line.home") editorCursor = editorText.lastIndexOf("\n", editorCursor - 1); + return { ok: false }; + }, + }, + route: { current: { name: "home", params: {} } }, + state: { session: { question: () => [], permission: () => [] } }, + lifecycle: { onDispose: () => {} }, + kv: {}, + }; + + // biome-ignore lint/suspicious/noExplicitAny: test mock + await plugin.tui(api as any, { startMode: "normal", updateCheck: false } as any, undefined as any); + + const press = (name: string, opts: Record = {}) => { + let consumed = false; + handler?.({ + event: { name, eventType: "press", ...opts }, + consume: () => { + consumed = true; + }, + }); + if (!consumed && name.length === 1 && !opts.ctrl && !opts.meta && !opts.super) editor.insertText(name); + }; + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + + return { flush, getCursor: () => editorCursor, getText: () => editorText, press }; + } + + it("vex deletes the word's last character instead of the following space", async () => { + const { flush, getText, press } = await setup("hello world", 1); + + press("v"); + press("e"); + press("escape"); + press("x"); + await flush(); + + expect(getText()).toBe("hell world"); + }); + + it("l extends a visual selection instead of restarting it", async () => { + const { flush, getText, press } = await setup("test next", 0); + + press("v"); + press("e"); + press("l"); + press("d"); + await flush(); + + expect(getText()).toBe("next"); + }); + + it("I inserts at the current line's first non-blank character", async () => { + const { flush, getText, press } = await setup("previous\nnext", "previous\n".length); + + press("i", { shift: true }); + await flush(); + press("x"); + + expect(getText()).toBe("previous\nxnext"); + }); + + it("I positions after all leading tabs", async () => { + const text = "\t\t\tText"; + const { getText, press } = await setup(text, text.indexOf("Text")); + + press("i", { shift: true }); + press("x"); + + expect(getText()).toBe("\t\t\txText"); + }); + + it("I then escape stays after tabbed indentation", async () => { + const text = "\t\t\tTest"; + const { getCursor, press } = await setup(text, text.indexOf("Test")); + + press("i", { shift: true }); + press("escape"); + + expect(getCursor()).toBe(text.indexOf("Test")); + }); + + it("d^ preserves tabbed indentation", async () => { + const text = "\t hello world"; + const { getText, press } = await setup(text, text.indexOf("world"), text.indexOf("world") + 3); + + press("d"); + press("6", { shift: true }); + + expect(getText()).toBe("\t world"); + }); + + it("gg reaches the initial blank line", async () => { + const { getCursor, press } = await setup("\nhello", 3); + + press("g"); + press("g"); + + expect(getCursor()).toBe(0); + }); +}); + // ── arrow keys pass through the intercept (issue #63) ───── describe("arrow keys pass through the intercept", () => { diff --git a/test/support.ts b/test/support.ts index fbfb60a..1ff0341 100644 --- a/test/support.ts +++ b/test/support.ts @@ -8,6 +8,10 @@ export function cursorTos(actions: Action[]): number[] { return actions.filter((a): a is Extract => a.type === "cursorTo").map((a) => a.offset); } +export function cursorLefts(actions: Action[]): number { + return actions.filter((a) => a.type === "cursorLeft").length; +} + export function deleteRanges(actions: Action[]): Array<{ start: number; end: number }> { return actions .filter((a): a is Extract => a.type === "deleteRange") diff --git a/test/vim/insert.test.ts b/test/vim/insert.test.ts index c6f5ae0..a31a98e 100644 --- a/test/vim/insert.test.ts +++ b/test/vim/insert.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; -import { createVimState, handleInsertKey, type PromptAccess, type VimState } from "../../src/vim"; +import { createVimState, handleInsertKey, handleNormalKey, type PromptAccess, type VimState } from "../../src/vim"; import { mockPrompt } from "../fixtures"; -import { cmds, cursorTos, ev } from "../support"; +import { cmds, cursorLefts, ev } from "../support"; let state: VimState; @@ -57,12 +57,28 @@ describe("handleInsertKey", () => { }; const r = handleInsertKey(state, "escape", ev("escape"), midLinePrompt); expect(r.consume).toBe(true); - expect(cursorTos(r.actions)).toEqual([4]); + expect(cursorLefts(r.actions)).toBe(1); + }); + + it("I then escape keeps the cursor at the first non-blank character", () => { + const prompt: PromptAccess = { + getLine: () => " test test test", + getLineCount: () => 1, + getCursorLine: () => 0, + getCursorOffset: () => 7, + getPlainText: () => " test test test", + }; + + const enterInsert = handleNormalKey(state, "I", ev("i", { shift: true }), prompt); + expect(enterInsert.actions).toContainEqual({ type: "mode", mode: "insert" }); + + const leaveInsert = handleInsertKey(state, "escape", ev("escape"), prompt); + expect(cursorLefts(leaveInsert.actions)).toBe(0); }); it("escape at position 0 does not move cursor", () => { const r = handleInsertKey(state, "escape", ev("escape"), mockPrompt); - expect(cursorTos(r.actions)).toEqual([]); + expect(cursorLefts(r.actions)).toBe(0); }); it("escape at start of line does not move cursor", () => { @@ -74,7 +90,7 @@ describe("handleInsertKey", () => { getPlainText: () => "hello world\nsecond line", }; const r = handleInsertKey(state, "escape", ev("escape"), startOfLinePrompt); - expect(cursorTos(r.actions)).toEqual([]); + expect(cursorLefts(r.actions)).toBe(0); }); it("ctrl+o enters normal mode with oneShotNormal flag", () => { diff --git a/test/vim/normal.test.ts b/test/vim/normal.test.ts index d32c74b..958b182 100644 --- a/test/vim/normal.test.ts +++ b/test/vim/normal.test.ts @@ -49,6 +49,64 @@ describe("handleNormalKey — motions", () => { expect(cmds(r.actions)).toEqual(["input.line.home"]); }); + it("^ moves to the first non-blank character", () => { + const prompt: PromptAccess = { + ...mockPrompt, + getLine: () => " \t hello", + getPlainText: () => " \t hello", + }; + const r = handleNormalKey(state, "^", ev("6", { shift: true }), prompt); + expect(cursorTos(r.actions)).toEqual([4]); + }); + + it("_ moves to the first non-blank character", () => { + const prompt: PromptAccess = { + ...mockPrompt, + getLine: () => " \t hello", + getPlainText: () => " \t hello", + }; + const r = handleNormalKey(state, "_", ev("-", { shift: true }), prompt); + expect(cursorTos(r.actions)).toEqual([4]); + }); + + it("^ and _ move past indentation tabs", () => { + const prompt: PromptAccess = { + ...mockPrompt, + getLine: () => " \thello", + getPlainText: () => " \thello", + }; + + expect(cursorTos(handleNormalKey(state, "^", ev("6", { shift: true }), prompt).actions)).toEqual([3]); + expect(cursorTos(handleNormalKey(state, "_", ev("-", { shift: true }), prompt).actions)).toEqual([3]); + }); + + it("4_ moves to the first non-blank character three lines down", () => { + const text = "first\n second\n\t third\n fourth"; + const prompt: PromptAccess = { + ...mockPrompt, + getPlainText: () => text, + getLineCount: () => 4, + }; + handleNormalKey(state, "4", ev("4"), prompt); + const r = handleNormalKey(state, "_", ev("-", { shift: true }), prompt); + expect(cursorTos(r.actions)).toEqual([text.indexOf("fourth")]); + }); + + it("^ moves to the first non-blank character on a subsequent line", () => { + const text = "first line\n\t second line"; + const prompt: PromptAccess = { + ...mockPrompt, + getCursorLine: () => 1, + getCursorOffset: () => text.indexOf("second") + 3, + getPlainText: () => text, + getLine: (n) => (n === 0 ? "first line" : "\t second line"), + getLineCount: () => 2, + }; + + const r = handleNormalKey(state, "^", ev("6", { shift: true }), prompt); + expect(cursorTos(r.actions)).toEqual([text.indexOf("second")]); + }); + it("0 after count > 0 accumulates as digit", () => { handleNormalKey(state, "1", ev("1"), mockPrompt); handleNormalKey(state, "0", ev("0"), mockPrompt); @@ -348,6 +406,77 @@ describe("handleNormalKey — operators", () => { expect(cmds(r.actions)).toEqual(["input.delete.to.line.start"]); }); + it("d^ deletes to the first non-blank character, preserving indentation", () => { + const text = " test1 test2 test3"; + const prompt: PromptAccess = { + ...mockPrompt, + getCursorOffset: () => 17, + getPlainText: () => text, + }; + handleNormalKey(state, "d", ev("d"), prompt); + const r = handleNormalKey(state, "^", ev("6", { shift: true }), prompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 5, end: 16 }]); + }); + + it("d_ deletes the current line", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "_", ev("_"), mockPrompt); + + expect(cmds(r.actions)).toEqual(["input.delete.line"]); + expect(saveUndoSnapshots(r.actions)).toHaveLength(1); + }); + + it("d4_ deletes four lines", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "4", ev("4"), mockPrompt); + const r = handleNormalKey(state, "_", ev("_"), mockPrompt); + + expect(cmds(r.actions)).toEqual([ + "input.delete.line", + "input.delete.line", + "input.delete.line", + "input.delete.line", + ]); + }); + + it("c_ changes the current line", () => { + handleNormalKey(state, "c", ev("c"), mockPrompt); + const r = handleNormalKey(state, "_", ev("_"), mockPrompt); + + expect(cmds(r.actions)).toEqual(["input.delete.line"]); + expect(state.mode).toBe("insert"); + }); + + it("y_ yanks the current line", () => { + handleNormalKey(state, "y", ev("y"), mockPrompt); + const r = handleNormalKey(state, "_", ev("_"), mockPrompt); + + expect(state.yankRegister).toBe("hello world\n"); + expect(r.actions).toContainEqual({ type: "yank", text: "hello world\n" }); + }); + + it("y2_ yanks two complete lines", () => { + handleNormalKey(state, "y", ev("y"), mockPrompt); + handleNormalKey(state, "2", ev("2"), mockPrompt); + const r = handleNormalKey(state, "_", ev("_"), mockPrompt); + + expect(state.yankRegister).toBe("hello world\nsecond line\n"); + expect(r.actions).toContainEqual({ type: "yank", text: "hello world\nsecond line\n" }); + }); + + it("c^ deletes to the first non-blank character and enters insert", () => { + const text = " test1 test2 test3"; + const prompt: PromptAccess = { + ...mockPrompt, + getCursorOffset: () => 17, + getPlainText: () => text, + }; + handleNormalKey(state, "c", ev("c"), prompt); + const r = handleNormalKey(state, "^", ev("6", { shift: true }), prompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 5, end: 16 }]); + expect(state.mode).toBe("insert"); + }); + it("dj dispatches input.delete.line twice", () => { handleNormalKey(state, "d", ev("d"), mockPrompt); const r = handleNormalKey(state, "j", ev("j"), mockPrompt); @@ -396,6 +525,19 @@ describe("handleNormalKey — operators", () => { expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); }); + it("y^ yanks to the first non-blank character, excluding indentation", () => { + const text = " test1 test2 test3"; + const prompt: PromptAccess = { + ...mockPrompt, + getCursorOffset: () => 17, + getPlainText: () => text, + }; + handleNormalKey(state, "y", ev("y"), prompt); + const r = handleNormalKey(state, "^", ev("6", { shift: true }), prompt); + expect(state.yankRegister).toBe("test1 test2 "); + expect(r.actions).toContainEqual({ type: "yank", text: "test1 test2 " }); + }); + it("y3w selects 3 words and yanks", () => { handleNormalKey(state, "y", ev("y"), mockPrompt); handleNormalKey(state, "3", ev("3"), mockPrompt); @@ -629,6 +771,18 @@ describe("handleNormalKey — insert entries", () => { expect(state.mode).toBe("insert"); }); + it("I moves to the first non-blank character and enters insert", () => { + const prompt: PromptAccess = { + ...mockPrompt, + getLine: () => " \t hello", + getPlainText: () => " \t hello", + }; + const r = handleNormalKey(state, "I", ev("i", { shift: true }), prompt); + expect(cursorTos(r.actions)).toEqual([4]); + expect(cmds(r.actions)).toEqual([]); + expect(state.mode).toBe("insert"); + }); + it("A dispatches input.line.end, enters insert", () => { const r = handleNormalKey(state, "A", ev("a", { shift: true }), mockPrompt); expect(cmds(r.actions)).toContain("input.line.end"); diff --git a/test/vim/text.test.ts b/test/vim/text.test.ts index 72b0fa9..b848a68 100644 --- a/test/vim/text.test.ts +++ b/test/vim/text.test.ts @@ -6,6 +6,7 @@ import { bracketRange, charKind, currentLineRange, + firstNonBlankOnLine, isWhitespace, quoteRange, wordRange, @@ -144,6 +145,32 @@ describe("currentLineRange", () => { }); }); +// ── firstNonBlankOnLine ───────────────────────────────────── + +describe("firstNonBlankOnLine", () => { + it("skips spaces and tabs", () => { + expect(firstNonBlankOnLine(" \t hello", 0)).toBe(4); + }); + + it("uses the cursor's current line", () => { + const text = "first line\n\t second line"; + expect(firstNonBlankOnLine(text, text.indexOf("second") + 3)).toBe(text.indexOf("second")); + }); + + it("moves down the requested number of lines", () => { + const text = "first\n second\n\t third\n fourth"; + expect(firstNonBlankOnLine(text, 0, 3)).toBe(text.indexOf("fourth")); + }); + + it("returns the line start when the line is blank", () => { + expect(firstNonBlankOnLine(" \t\r\nnext", 0)).toBe(0); + }); + + it("keeps the cursor on an initial blank line", () => { + expect(firstNonBlankOnLine("\nhello", 0)).toBe(0); + }); +}); + // ── wordRange (inner) ────────────────────────────────────── describe("wordRange (inner)", () => { diff --git a/test/vim/util.test.ts b/test/vim/util.test.ts index 2352e7a..ff4d341 100644 --- a/test/vim/util.test.ts +++ b/test/vim/util.test.ts @@ -21,6 +21,10 @@ describe("translateKey", () => { expect(translateKey(ev("6", { shift: true }))).toBe("^"); }); + it("shift+- → _", () => { + expect(translateKey(ev("-", { shift: true }))).toBe("_"); + }); + it("shift+[ → {", () => { expect(translateKey(ev("[", { shift: true }))).toBe("{"); }); diff --git a/test/vim/visual.test.ts b/test/vim/visual.test.ts index 1dd820d..0421fc1 100644 --- a/test/vim/visual.test.ts +++ b/test/vim/visual.test.ts @@ -17,14 +17,18 @@ describe("handleVisualKey — motions", () => { state.mode = "visual"; }); - it("h dispatches input.select.left", () => { - const r = handleVisualKey(state, "h", ev("h")); - expect(cmds(r.actions)).toEqual(["input.select.left"]); + it("h extends the selection left", () => { + state.visualAnchor = 2; + const prompt: PromptAccess = { ...mockPrompt, getCursorOffset: () => 2 }; + const r = handleVisualKey(state, "h", ev("h"), prompt); + expect(selectRanges(r.actions)).toEqual([{ start: 2, end: 1 }]); + expect(cursorTos(r.actions)).toEqual([1]); }); - it("l dispatches input.select.right", () => { - const r = handleVisualKey(state, "l", ev("l")); - expect(cmds(r.actions)).toEqual(["input.select.right"]); + it("l extends the selection right", () => { + const r = handleVisualKey(state, "l", ev("l"), mockPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 1 }]); + expect(cursorTos(r.actions)).toEqual([1]); }); it("j dispatches input.select.down", () => { @@ -47,10 +51,48 @@ describe("handleVisualKey — motions", () => { expect(cmds(r.actions)).toEqual(["input.select.line.end"]); }); - it("3l dispatches input.select.right 3 times", () => { - handleVisualKey(state, "3", ev("3")); - const r = handleVisualKey(state, "l", ev("l")); - expect(cmds(r.actions)).toEqual(["input.select.right", "input.select.right", "input.select.right"]); + it("^ extends to the first non-blank character", () => { + const prompt: PromptAccess = { + ...mockPrompt, + getLine: () => "\t hello", + getPlainText: () => "\t hello", + }; + const r = handleVisualKey(state, "^", ev("6", { shift: true }), prompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 3 }]); + expect(cursorTos(r.actions)).toEqual([3]); + }); + + it("_ extends to the first non-blank character", () => { + const prompt: PromptAccess = { + ...mockPrompt, + getLine: () => "\t hello", + getPlainText: () => "\t hello", + }; + const r = handleVisualKey(state, "_", ev("_"), prompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 3 }]); + expect(cursorTos(r.actions)).toEqual([3]); + }); + + it("4_ extends to the first non-blank character three lines down", () => { + const text = "first\n second\n\t third\n fourth"; + const prompt: PromptAccess = { + ...mockPrompt, + getCursorOffset: () => 2, + getPlainText: () => text, + getLineCount: () => 4, + }; + state.visualAnchor = 2; + handleVisualKey(state, "4", ev("4"), prompt); + const r = handleVisualKey(state, "_", ev("_"), prompt); + expect(selectRanges(r.actions)).toEqual([{ start: 2, end: text.indexOf("fourth") }]); + expect(cursorTos(r.actions)).toEqual([text.indexOf("fourth")]); + }); + + it("3l extends the selection right three characters", () => { + handleVisualKey(state, "3", ev("3"), mockPrompt); + const r = handleVisualKey(state, "l", ev("l"), mockPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 3 }]); + expect(cursorTos(r.actions)).toEqual([3]); }); it("G dispatches input.select.buffer.end", () => {