diff --git a/AGENTS.md b/AGENTS.md index a286c8f..573ef57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,31 +57,31 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (408 lines) Plugin entry: intercept registration, action application + index.ts (430 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 (58 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 + util.ts (20 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 + normal.ts (445 lines) handleNormalKey (+ file-local finishUndoableChange, applyOperatorRange, isInputEmpty, moveToFirstNonBlank) + visual.ts (112 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 (408) 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 (981) handleNormalKey branches + visual.test.ts (324) handleVisualKey branches textobject.test.ts (64) resolveTextObject dispatch seam integration.test.ts (418) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync leader.test.ts (125 lines) Unit tests for leader key matching functions @@ -105,7 +105,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")`. diff --git a/CHANGELOG.md b/CHANGELOG.md index aa15950..492fbd9 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.0] — 2026-09-02 ### Added diff --git a/README.md b/README.md index aaa7145..42062bc 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 f5f6433..a8b8867 100644 --- a/src/index.ts +++ b/src/index.ts @@ -211,7 +211,29 @@ const plugin: TuiPluginModule = { } case "cursorTo": { const editor = api.renderer?.currentFocusedEditor; - if (editor) editor.cursorOffset = action.offset; + if (editor?.moveCursorRight) { + const text = editor.plainText ?? ""; + const target = Math.min(Math.max(action.offset, 0), text.length); + const lineStart = text.lastIndexOf("\n", target - 1) + 1; + + editor.cursorOffset = lineStart; + for (let i = lineStart; i < target; i++) editor.moveCursorRight(); + editor.getLayoutNode?.().markDirty?.(); + api.renderer?.requestRender?.(); + } else if (editor) { + editor.cursorOffset = action.offset; + } + 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": { diff --git a/src/vim/insert.ts b/src/vim/insert.ts index 31acf32..f5a2430 100644 --- a/src/vim/insert.ts +++ b/src/vim/insert.ts @@ -9,7 +9,7 @@ export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent, pro // 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 }); + actions.push({ type: "cursorLeft" }); } actions.push({ type: "mode", mode: "normal" }); return { consume: true, actions }; diff --git a/src/vim/normal.ts b/src/vim/normal.ts index 8d21c48..29e6536 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,12 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return { consume: true, actions }; } + if (key === "I") { + moveToFirstNonBlank(actions, prompt.getLine(prompt.getCursorLine())); + enterInsert(state, actions); + return { consume: true, actions }; + } + if (key === "A") { actions.push({ type: "cmd", cmd: "input.line.end" }); enterInsert(state, actions); @@ -376,3 +437,9 @@ function applyOperatorRange( function isInputEmpty(prompt: PromptAccess): boolean { return prompt.getLineCount() === 1 && prompt.getLine(0) === ""; } + +function moveToFirstNonBlank(actions: Action[], line: string) { + actions.push({ type: "cmd", cmd: "input.line.home" }); + const firstNonBlank = line.search(/[^ \t\r]/); + if (firstNonBlank !== -1) pushN(actions, "input.move.right", firstNonBlank); +} 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..191e243 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 = 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..cd51994 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 = { 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..6931b2a 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"; @@ -81,6 +81,14 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom 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 }; + } + // Motions extend selection if (key in SELECT_MOTIONS) { pushN(actions, SELECT_MOTIONS[key], consumeCount(state)); 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..54f67d3 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 moves left from the textarea cursor", () => { + 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(1); }); 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..8ac8987 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,22 @@ 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", + }; + const r = handleNormalKey(state, "I", ev("i", { shift: true }), prompt); + expect(cmds(r.actions)).toEqual([ + "input.line.home", + "input.move.right", + "input.move.right", + "input.move.right", + "input.move.right", + ]); + 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..1a017e7 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,28 @@ 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); + }); +}); + // ── 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..45a1327 100644 --- a/test/vim/visual.test.ts +++ b/test/vim/visual.test.ts @@ -47,6 +47,43 @@ describe("handleVisualKey — motions", () => { expect(cmds(r.actions)).toEqual(["input.select.line.end"]); }); + 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 dispatches input.select.right 3 times", () => { handleVisualKey(state, "3", ev("3")); const r = handleVisualKey(state, "l", ev("l"));