diff --git a/CHANGELOG.md b/CHANGELOG.md index bed1dd5..f26996e 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.15.3] — 2026-07-01 ### Fixed diff --git a/README.md b/README.md index 99c76b7..a03c3ae 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 | @@ -139,6 +142,7 @@ Counts work on both operator and motion: `2dd` deletes 2 lines, `d3w` deletes 3 |-----|--------| | `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 | @@ -157,7 +161,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 7fe99d4..74fd374 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.ts b/src/vim.ts index f3e01d6..651b092 100644 --- a/src/vim.ts +++ b/src/vim.ts @@ -13,6 +13,7 @@ export type Action = | { type: "saveUndoSnapshot" } | { type: "undo" } | { type: "cursorTo"; offset: number } + | { type: "cursorLeft" } | { type: "selectRange"; start: number; end: number }; export type HandlerResult = { @@ -56,7 +57,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", }; @@ -69,7 +69,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", }; @@ -79,7 +78,6 @@ 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", }; @@ -155,6 +153,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 = "}"; } @@ -169,7 +168,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 }; @@ -345,6 +344,50 @@ 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.pendingOp && key === "_") { + const n = consumeCount(state); + + if (state.pendingOp === "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 (state.pendingOp === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + + // ^ is an exclusive characterwise motion to the first non-blank character. + if (state.pendingOp && key === "^") { + 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 (state.pendingOp === "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 (state.pendingOp === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + // Pending operator + e (end-of-word needs special handling) if (state.pendingOp && key === "e") { const n = consumeCount(state); @@ -420,6 +463,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); @@ -490,6 +542,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); @@ -572,6 +630,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)); @@ -651,6 +717,26 @@ function pushN(actions: Action[], cmd: string, n: number) { for (let i = 0; i < n; i++) actions.push({ type: "cmd", cmd }); } +function moveToFirstNonBlank(actions: Action[], line: string, home = "input.line.home", right = "input.move.right") { + actions.push({ type: "cmd", cmd: home }); + const firstNonBlank = line.search(/[^ \t]/); + if (firstNonBlank !== -1) pushN(actions, right, firstNonBlank); +} + +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]/); + return start + Math.max(0, firstNonBlank); +} + function isInputEmpty(prompt: PromptAccess): boolean { return prompt.getLineCount() === 1 && prompt.getLine(0) === ""; } diff --git a/test/vim.test.ts b/test/vim.test.ts index 70dc454..99c6dad 100644 --- a/test/vim.test.ts +++ b/test/vim.test.ts @@ -21,6 +21,10 @@ function cursorTos(actions: Action[]): number[] { return actions.filter((a): a is Extract => a.type === "cursorTo").map((a) => a.offset); } +function cursorLefts(actions: Action[]): number { + return actions.filter((a) => a.type === "cursorLeft").length; +} + function deleteRanges(actions: Action[]): Array<{ start: number; end: number }> { return actions .filter((a): a is Extract => a.type === "deleteRange") @@ -209,6 +213,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("{"); }); @@ -265,12 +273,29 @@ 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); + expect(cursorTos(leaveInsert.actions)).toEqual([]); }); 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", () => { @@ -282,7 +307,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", () => { @@ -338,6 +363,78 @@ 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 a trailing indentation tab", () => { + 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("^ and _ move past multiple indentation tabs", () => { + const prompt: PromptAccess = { + ...mockPrompt, + getLine: () => "\t\t\t\t\t\t\t\thello", + getPlainText: () => "\t\t\t\t\t\t\t\thello", + }; + + expect(cursorTos(handleNormalKey(state, "^", ev("6", { shift: true }), prompt).actions)).toEqual([8]); + expect(cursorTos(handleNormalKey(state, "_", ev("-", { shift: true }), prompt).actions)).toEqual([8]); + }); + + 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 subsequent lines", () => { + const text = "first line\n\t second line"; + + const prompt: PromptAccess = { + ...mockPrompt, + getCursorLine: () => 1, + // Cursor somewhere in "second" + 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); @@ -483,6 +580,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); @@ -531,6 +699,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); @@ -748,6 +929,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"); @@ -929,6 +1126,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"));