diff --git a/README.md b/README.md index 34397c69c28b..402335b47416 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ Toggle via command palette > `Toggle vim mode`. | Put / undo / repeat | `p`, `P`, `u`, `Ctrl+r`, `.` | | Visual selection | `v`, `V` | +Numeric count prefixes are supported for motions and common operators. + > [!NOTE] > `y` copies the prompt selection when present; configure it with `keybinds.prompt_copy_selection`. > For clipboard sync, see [System clipboard register](#system-clipboard-register). diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts index a639cc8cf2e2..be9e4b7d66a8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts @@ -14,7 +14,6 @@ import { deleteSelection, deleteSpan, deleteUnderCursor, - findChar, findCharInLine, findCharTargetInLine, firstNonWhitespace, @@ -23,7 +22,6 @@ import { insertLineStart, joinLines, lineBeginningOperation, - lineEndOperation, matchingBracketOperation, matchingBracketTarget, moveBigWordEnd, @@ -66,8 +64,6 @@ import { toggleSelectionCase, wordEnd, wordTextObjectOperation, - yankLine, - yankLineSpan, yankSelection, } from "./vim-motions" @@ -105,7 +101,11 @@ function normalizedKeyName(event: VimKeyLike) { if (event.name === "apostrophe") return "'" if (event.name === "backtick") return "`" const text = vimEventText(event) - if (text && (text === "/" || text === "?" || text === "@" || text === '"' || text === "'" || text === "`" || "()[]{}<>".includes(text))) return text + if ( + text && + (text === "/" || text === "?" || text === "@" || text === '"' || text === "'" || text === "`" || "()[]{}<>".includes(text)) + ) + return text if (event.shift) { if (event.name === "9") return "(" if (event.name === "0") return ")" @@ -238,6 +238,48 @@ export function createVimHandler(input: { wantedColumn = undefined } + function repeatCount(count: number, run: () => void) { + Array.from({ length: count }).forEach(() => run()) + } + + function takeCount(defaultValue = 1) { + return input.state.takeCount(defaultValue) + } + + function countedMotion(run: () => void) { + repeatCount(takeCount(), run) + } + + function isCountDigit(event: VimEvent, key: string) { + return !event.shift && !hasModifier(event) && /^[1-9]$/.test(key) + } + + function isCountInput(event: VimEvent, key: string) { + return ( + !input.state.pending() && + !input.state.isVisual() && + (isCountDigit(event, key) || (key === "0" && input.state.count())) + ) + } + + function lineStartOffset(text: string, offset: number) { + if (offset <= 0) return 0 + const index = text.lastIndexOf("\n", offset - 1) + return index === -1 ? 0 : index + 1 + } + + function lineEndOffset(text: string, offset: number) { + const index = text.indexOf("\n", offset) + return index === -1 ? text.length : index + } + + function lineStartForCount(text: string, offset: number, count: number) { + return Array.from({ length: count - 1 }).reduce((current) => { + const end = lineEndOffset(text, current) + return end >= text.length ? current : end + 1 + }, offset) + } + function moveVertical(direction: "up" | "down") { const column = wantedColumn ?? getLineColumn(input.textarea()) if (direction === "up") moveLineUp(input.textarea(), column) @@ -325,11 +367,10 @@ export function createVimHandler(input: { function paragraphOperator(key: string, operation: VimOperator): boolean { if (key !== "{" && key !== "}") return false + const count = takeCount() applyOperatorResult( () => - key === "}" - ? nextParagraphOperation(input.textarea(), operation) - : previousParagraphOperation(input.textarea(), operation), + key === "}" ? nextParagraphCountOperation(operation, count) : previousParagraphCountOperation(operation, count), operation, ) @@ -349,54 +390,139 @@ export function createVimHandler(input: { return { span, register: { text: input.textarea().plainText.slice(span.start, span.end), linewise: false } } } - function nextWordOperation(big: boolean) { + function linewiseOperation(span: VimSpan | null): VimOperatorResult { + if (!span) return { span: null, register: null } + const text = input.textarea().plainText.slice(span.start, span.end) + return { span, register: { text: text.endsWith("\n") ? text : text + "\n", linewise: true } } + } + + function nextParagraphCountOperation(operation: VimOperator, count: number) { + if (count === 1) return nextParagraphOperation(input.textarea(), operation) + const textarea = input.textarea() + const cursor = textarea.cursorOffset + repeatCount(count - 1, () => moveNextParagraph(textarea)) + const next = nextParagraphOperation(textarea, operation) + textarea.cursorOffset = cursor + if (!next.span) return next + return next.register?.linewise + ? linewiseOperation({ start: lineStartOffset(textarea.plainText, cursor), end: next.span.end }) + : charwiseOperation({ start: cursor, end: next.span.end }) + } + + function previousParagraphCountOperation(operation: VimOperator, count: number) { + if (count === 1) return previousParagraphOperation(input.textarea(), operation) + const textarea = input.textarea() + const cursor = textarea.cursorOffset + repeatCount(count - 1, () => movePreviousParagraph(textarea)) + const next = previousParagraphOperation(textarea, operation) + textarea.cursorOffset = cursor + if (!next.span) return next + if (!next.register?.linewise) return charwiseOperation({ start: next.span.start, end: cursor }) + const end = operation === "c" && textarea.plainText[cursor - 1] === "\n" ? cursor - 1 : cursor + return linewiseOperation(end > next.span.start ? { start: next.span.start, end } : null) + } + + function nextWordOperation(big: boolean, count = 1) { const textarea = input.textarea() const start = textarea.cursorOffset - const end = nextWordStart(textarea.plainText, start, big) + const end = Array.from({ length: count }).reduce( + (offset) => nextWordStart(textarea.plainText, offset, big), + start, + ) return charwiseOperation(end > start ? { start, end } : null) } - function previousWordOperation() { + function previousWordOperation(count = 1) { const textarea = input.textarea() const end = textarea.cursorOffset - const start = prevWordStart(textarea.plainText, end, false) + const start = Array.from({ length: count }).reduce( + (offset) => prevWordStart(textarea.plainText, offset, false), + end, + ) return charwiseOperation(start < end ? { start, end } : null) } - function wordEndOperation(big: boolean) { + function wordEndOperation(big: boolean, count = 1) { const textarea = input.textarea() const start = textarea.cursorOffset if (start >= textarea.plainText.length) return charwiseOperation(null) - const end = wordEnd(textarea.plainText, start, big) + 1 + const end = Array.from({ length: count }).reduce( + (offset) => wordEnd(textarea.plainText, offset, big) + 1, + start, + ) return charwiseOperation(end > start ? { start, end } : null) } - function changeWordOperation(big: boolean) { + function lineSpanCount(count: number) { + const textarea = input.textarea() + const start = lineStartOffset(textarea.plainText, textarea.cursorOffset) + const target = lineStartForCount(textarea.plainText, textarea.cursorOffset, count) + return { start, end: lineEndOffset(textarea.plainText, target) } + } + + function yankLineCount(count: number) { + const span = lineSpanCount(count) + return { span, register: { text: input.textarea().plainText.slice(span.start, span.end), linewise: true } } + } + + function deleteLineCount(count: number) { + const textarea = input.textarea() + const start = textarea.cursorOffset + repeatCount(count - 1, () => moveLineDown(textarea, 0)) + const anchor = textarea.cursorOffset + textarea.cursorOffset = start + return deleteLine(textarea, anchor) + } + + function substituteLineCount(count: number) { + const textarea = input.textarea() + const start = textarea.cursorOffset + repeatCount(count - 1, () => moveLineDown(textarea, 0)) + const anchor = textarea.cursorOffset + textarea.cursorOffset = start + return substituteLine(textarea, anchor) + } + + function changeWordOperation(big: boolean, count = 1) { const textarea = input.textarea() const char = textarea.plainText[textarea.cursorOffset] - return char && !/\s/.test(char) ? wordEndOperation(big) : nextWordOperation(big) + return char && !/\s/.test(char) ? wordEndOperation(big, count) : nextWordOperation(big, count) } function wordOperator(event: VimEvent, key: string, operation: VimOperator): boolean { if ((key === "w" || isShifted(event, "w")) && !hasModifier(event)) { const big = isShifted(event, "w") - applyOperatorResult(() => (operation === "c" ? changeWordOperation(big) : nextWordOperation(big)), operation) + const count = takeCount() + applyOperatorResult( + () => (operation === "c" ? changeWordOperation(big, count) : nextWordOperation(big, count)), + operation, + ) return true } if (key === "b" && !event.shift && !hasModifier(event) && operation !== "y") { - applyOperatorResult(() => previousWordOperation(), operation) + const count = takeCount() + applyOperatorResult(() => previousWordOperation(count), operation) return true } if ((key === "e" || isShifted(event, "e")) && !hasModifier(event)) { - applyOperatorResult(() => wordEndOperation(isShifted(event, "e")), operation) + const count = takeCount() + applyOperatorResult(() => wordEndOperation(isShifted(event, "e"), count), operation) return true } return false } + function lineEndCountOperation(count: number) { + const textarea = input.textarea() + const start = textarea.cursorOffset + const end = lineEndOffset(textarea.plainText, lineStartForCount(textarea.plainText, start, count)) + return charwiseOperation(end > start ? { start, end } : null) + } + function lineBoundaryMotion(event: VimEvent, key: string, operation: VimOperator): boolean { if (key === "$" && !hasModifier(event)) { - applyOperatorResult(() => lineEndOperation(input.textarea()), operation) + const count = takeCount() + applyOperatorResult(() => lineEndCountOperation(count), operation) return true } if (key === "0" && !event.shift && !hasModifier(event)) { @@ -410,20 +536,30 @@ export function createVimHandler(input: { return false } - function findOperation(char: string, forward: boolean, till: boolean) { + function findCharTargetOffset(char: string, forward: boolean, till: boolean, count: number, repeat = false) { const textarea = input.textarea() const start = textarea.cursorOffset const lineStart = textarea.plainText.lastIndexOf("\n", start - 1) + 1 const lineEnd = textarea.plainText.indexOf("\n", start) - const target = findCharTargetInLine( - textarea.plainText.slice(lineStart, lineEnd === -1 ? textarea.plainText.length : lineEnd), - start - lineStart, - char, - forward, - ) - if (target === null) return charwiseOperation(null) + const line = textarea.plainText.slice(lineStart, lineEnd === -1 ? textarea.plainText.length : lineEnd) + const target = Array.from({ length: count }).reduce((offset, _, index) => { + if (offset === null) return null + return findCharTargetInLine(line, offset, char, forward, till && repeat && index === 0 ? 2 : 1) + }, start - lineStart) + return target === null ? null : lineStart + target + } + + function findCharCount(char: string, forward: boolean, till: boolean, count: number, repeat = false) { + const target = findCharTargetOffset(char, forward, till, count, repeat) + if (target !== null) input.textarea().cursorOffset = target + (till ? (forward ? -1 : 1) : 0) + } + + function findOperation(char: string, forward: boolean, till: boolean, count = 1) { + const textarea = input.textarea() + const start = textarea.cursorOffset + const offset = findCharTargetOffset(char, forward, till, count) + if (offset === null) return charwiseOperation(null) - const offset = lineStart + target if (forward) { const spanEnd = till ? offset : offset + 1 return charwiseOperation(spanEnd > start ? { start, end: spanEnd } : null) @@ -506,8 +642,9 @@ export function createVimHandler(input: { const till = pendingOperatorFind.find === "t" || pendingOperatorFind.find === "T" const char = value(event) const operation = pendingOperatorFind.operation + const count = takeCount() pendingOperatorFind = undefined - applyOperatorResult(() => findOperation(char, forward, till), operation) + applyOperatorResult(() => findOperation(char, forward, till, count), operation) input.state.setLastFind({ char, forward, till }) event.preventDefault() return true @@ -597,7 +734,7 @@ export function createVimHandler(input: { const forward = find === "f" || find === "t" const till = find === "t" || find === "T" const char = value(event) - findChar(input.textarea(), char, forward, till) + findCharCount(char, forward, till, takeCount()) input.state.setLastFind({ char, forward, till }) input.state.clearPending() event.preventDefault() @@ -637,6 +774,11 @@ export function createVimHandler(input: { } if (key === "escape") { + if (!input.state.pending() && input.state.count()) { + input.state.clearCount() + event.preventDefault() + return true + } if (input.state.isVisual()) { clearSelection(input.textarea()) input.state.setMode("normal") @@ -649,6 +791,12 @@ export function createVimHandler(input: { return true } + if (isCountInput(event, key)) { + input.state.appendCountDigit(key) + event.preventDefault() + return true + } + if (key === "." && !event.shift && !hasModifier(event) && !input.state.isVisual() && !input.state.pending()) { const repeat = input.state.repeat() if (repeat) repeat.run() @@ -706,8 +854,8 @@ export function createVimHandler(input: { const anchor = input.state.anchor() if (anchor !== null) { - input.textarea().cursorOffset = anchor - input.state.setAnchor(cursor) + input.textarea().cursorOffset = anchor + input.state.setAnchor(cursor) toggleVisualEnd(input.textarea(), cursor, input.state.isVisualLine()) } event.preventDefault() @@ -819,8 +967,9 @@ export function createVimHandler(input: { } if (key === "c" && !event.shift) { + const count = takeCount() begin(() => { - const reg = substituteLine(input.textarea()) + const reg = substituteLineCount(count) if (reg) setRegister(reg) input.state.clearPending() input.state.setMode("insert") @@ -864,8 +1013,9 @@ export function createVimHandler(input: { } if (key === "d" && !event.shift) { + const count = takeCount() edit(() => { - const reg = deleteLine(input.textarea()) + const reg = deleteLineCount(count) if (reg) setRegister(reg) input.state.clearPending() }) @@ -908,10 +1058,9 @@ export function createVimHandler(input: { } if (key === "y" && !event.shift) { - const span = yankLineSpan(input.textarea()) - const reg = yankLine(input.textarea()) - if (reg) setRegister(reg, true) - if (span.end > span.start) input.flash?.(span) + const result = yankLineCount(takeCount()) + setRegister(result.register, true) + if (result.span.end > result.span.start) input.flash?.(result.span) input.state.clearPending() event.preventDefault() return true @@ -1029,14 +1178,14 @@ export function createVimHandler(input: { if (key === ";" && !event.shift && !hasModifier(event)) { const last = input.state.lastFind() - if (last) findChar(input.textarea(), last.char, last.forward, last.till, true) + if (last) findCharCount(last.char, last.forward, last.till, takeCount(), true) event.preventDefault() return true } if (key === "," && !event.shift && !hasModifier(event)) { const last = input.state.lastFind() - if (last) findChar(input.textarea(), last.char, !last.forward, last.till, true) + if (last) findCharCount(last.char, !last.forward, last.till, takeCount(), true) event.preventDefault() return true } @@ -1154,13 +1303,13 @@ export function createVimHandler(input: { } if (key === "h" && !event.shift && !hasModifier(event)) { - moveLeft(input.textarea()) + countedMotion(() => moveLeft(input.textarea())) event.preventDefault() return true } if (key === "l" && !event.shift && !hasModifier(event)) { - moveRight(input.textarea()) + countedMotion(() => moveRight(input.textarea())) event.preventDefault() return true } @@ -1175,7 +1324,7 @@ export function createVimHandler(input: { } if ((key === "j" || key === "down") && !event.shift && !hasModifier(event)) { - moveVertical("down") + countedMotion(() => moveVertical("down")) event.preventDefault() return true } @@ -1199,7 +1348,7 @@ export function createVimHandler(input: { } if ((key === "k" || key === "up") && !event.shift && !hasModifier(event)) { - moveVertical("up") + countedMotion(() => moveVertical("up")) event.preventDefault() return true } @@ -1217,6 +1366,7 @@ export function createVimHandler(input: { } if (key === "$" && !hasModifier(event)) { + repeatCount(takeCount() - 1, () => moveLineDown(input.textarea(), 0)) moveLineEnd(input.textarea()) wantedColumn = "end" event.preventDefault() @@ -1230,13 +1380,13 @@ export function createVimHandler(input: { } if (key === "{" && !hasModifier(event)) { - movePreviousParagraph(input.textarea()) + countedMotion(() => movePreviousParagraph(input.textarea())) event.preventDefault() return true } if (key === "}" && !hasModifier(event)) { - moveNextParagraph(input.textarea()) + countedMotion(() => moveNextParagraph(input.textarea())) event.preventDefault() return true } @@ -1273,37 +1423,37 @@ export function createVimHandler(input: { } if (key === "w" && !event.shift && !hasModifier(event)) { - moveWordNext(input.textarea()) + countedMotion(() => moveWordNext(input.textarea())) event.preventDefault() return true } if (key === "b" && !event.shift && !hasModifier(event)) { - moveWordPrev(input.textarea()) + countedMotion(() => moveWordPrev(input.textarea())) event.preventDefault() return true } if (key === "e" && !event.shift && !hasModifier(event)) { - moveWordEnd(input.textarea()) + countedMotion(() => moveWordEnd(input.textarea())) event.preventDefault() return true } if (isShifted(event, "w") && !hasModifier(event)) { - moveBigWordNext(input.textarea()) + countedMotion(() => moveBigWordNext(input.textarea())) event.preventDefault() return true } if (isShifted(event, "b") && !hasModifier(event)) { - moveBigWordPrev(input.textarea()) + countedMotion(() => moveBigWordPrev(input.textarea())) event.preventDefault() return true } if (isShifted(event, "e") && !hasModifier(event)) { - moveBigWordEnd(input.textarea()) + countedMotion(() => moveBigWordEnd(input.textarea())) event.preventDefault() return true } @@ -1853,6 +2003,9 @@ export function createVimHandler(input: { const key = normalizedKeyName(mapped) const result = dispatch(mapped, key) + if (result && input.state.count() && !input.state.pending() && !isCountInput(mapped, key)) + input.state.clearCount() + if (result && input.state.isVisual()) { const a = input.state.anchor() if (a !== null) syncSelection(input.textarea(), a, input.state.isVisualLine()) diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts index 2ba2c5605b31..e48df7fddaac 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-indicator.ts @@ -12,6 +12,7 @@ export function useVimIndicator(input: { if (!input.enabled() || !input.active()) return const key = input.state.pending() if (key && key !== "w") return (input.state.pendingDisplay() || key) + ".." + if (input.state.count()) return input.state.count() if (input.state.isCopy()) { const search = input.copySearch?.() if (search !== undefined) return search diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts index 70cd784fe777..17f8cfe95c22 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-state.ts @@ -12,10 +12,14 @@ type VimHistory = { after: VimSnapshot } +const VIM_COUNT_MAX = 9999 +const VIM_COUNT_MAX_DIGITS = String(VIM_COUNT_MAX).length + export function createVimState(input: { enabled: Accessor; initial?: Accessor }) { const [mode, setMode] = createSignal(input.initial?.() ?? "insert") const [pending, setPendingValue] = createSignal("") const [pendingDisplay, setPendingDisplay] = createSignal("") + const [count, setCountValue] = createSignal("") const [lastFind, setLastFind] = createSignal(null) const [register, setRegister] = createSignal(null) const [anchor, setAnchor] = createSignal(null) @@ -38,6 +42,21 @@ export function createVimState(input: { enabled: Accessor; initial?: Ac function clearPending() { if (pending()) setPendingValue("") if (pendingDisplay()) setPendingDisplay("") + clearCount() + } + + function clearCount() { + if (count()) setCountValue("") + } + + function appendCountDigit(digit: string) { + setCountValue((value) => (value.length >= VIM_COUNT_MAX_DIGITS ? value : value + digit)) + } + + function takeCount(defaultValue = 1) { + const value = count() ? Number(count()) : defaultValue + clearCount() + return Math.max(1, Math.min(Number.isSafeInteger(value) ? value : defaultValue, VIM_COUNT_MAX)) } function clearEdit() { @@ -58,6 +77,7 @@ export function createVimState(input: { enabled: Accessor; initial?: Ac function changeMode(next: VimMode) { clearPending() + clearCount() if (next !== "visual" && next !== "visual-line") setAnchor(null) if (next !== "replace") { setReplace(null) @@ -91,6 +111,10 @@ export function createVimState(input: { enabled: Accessor; initial?: Ac pendingDisplay, setPending, clearPending, + count, + appendCountDigit, + clearCount, + takeCount, lastFind, setLastFind, register, @@ -143,6 +167,7 @@ export function createVimState(input: { enabled: Accessor; initial?: Ac resetHistory: clearHistory, reset() { clearPending() + clearCount() setAnchor(null) setReplace(null) setTyped(false) diff --git a/packages/opencode/test/cli/tui/vim-indicator.test.ts b/packages/opencode/test/cli/tui/vim-indicator.test.ts index 1e262c3b5ac5..775cb61f00bd 100644 --- a/packages/opencode/test/cli/tui/vim-indicator.test.ts +++ b/packages/opencode/test/cli/tui/vim-indicator.test.ts @@ -9,6 +9,7 @@ function label(opts?: { mode?: VimMode pending?: VimPending pendingDisplay?: string + count?: string copy?: undefined | "char" | "line" | "block" }) { return createRoot((dispose) => { @@ -22,6 +23,7 @@ function label(opts?: { if (opts?.mode && opts.mode !== "normal") state.setMode(opts.mode) if (opts?.pending) state.setPending(opts.pending, opts.pendingDisplay) + opts?.count?.split("").forEach((digit) => state.appendCountDigit(digit)) const result = useVimIndicator({ enabled, @@ -49,6 +51,10 @@ describe("vim indicator", () => { expect(label({ pending: "f", pendingDisplay: "df" })).toBe("df..") }) + test("shows count when present", () => { + expect(label({ count: "12" })).toBe("12") + }) + test("shows copy label when no key is pending", () => { expect(label({ mode: "copy" })).toBe("-- COPY --") }) diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 7bfa1f14e22b..e18d83a32128 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -18,6 +18,9 @@ import { deleteSelection, } from "../../../src/cli/cmd/tui/component/vim/vim-motions" +const VIM_COUNT_MAX = 9999 +const VIM_COUNT_MAX_DIGITS = String(VIM_COUNT_MAX).length + function rowColToOffset(text: string, row: number, col: number) { let index = 0 let current = 0 @@ -178,6 +181,7 @@ function createHandler( "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr" >("") const [pendingDisplay, setPendingDisplay] = createSignal("") + const [count, setCountValue] = createSignal("") const [lastFind, setLastFind] = createSignal<{ char: string; forward: boolean; till: boolean } | null>(null) const [register, setRegister] = createSignal<{ text: string; linewise: boolean } | null>(null) const [anchor, setAnchor] = createSignal(null) @@ -226,10 +230,7 @@ function createHandler( let copyExitPreserveScrolls = 0 let copyFocusInputs = 0 - function setPending( - next: "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr", - display = "", - ) { + function setPending(next: "" | "c" | "d" | "g" | "z" | "f" | "F" | "t" | "T" | "y" | "w" | "r" | "vr", display = "") { setPendingValue(next) setPendingDisplay(display) } @@ -237,6 +238,17 @@ function createHandler( function clearPending() { setPendingValue("") setPendingDisplay("") + clearCount() + } + + function clearCount() { + setCountValue("") + } + + function takeCount(defaultValue = 1) { + const value = count() ? Number(count()) : defaultValue + clearCount() + return Math.max(1, Math.min(Number.isSafeInteger(value) ? value : defaultValue, VIM_COUNT_MAX)) } function changeMode(next: "normal" | "insert" | "replace" | "visual" | "visual-line" | "copy") { @@ -261,6 +273,12 @@ function createHandler( pendingDisplay, setPending, clearPending, + count, + appendCountDigit(digit) { + setCountValue((value) => (value.length >= VIM_COUNT_MAX_DIGITS ? value : value + digit)) + }, + clearCount, + takeCount, lastFind, setLastFind, register, @@ -327,6 +345,7 @@ function createHandler( canRedo: () => redos().length > 0, reset() { clearPending() + clearCount() setAnchor(null) setReplace(null) setTyped(false) @@ -601,6 +620,50 @@ describe("vim motion handler", () => { expect(ctx.textarea.cursorOffset).toBe(0) }) + test("count prefixes repeat normal motions and clear after use", () => { + const ctx = createHandler("abcdef") + + ctx.handler.handleKey(createEvent("3").event) + expect(ctx.state.count()).toBe("3") + ctx.handler.handleKey(createEvent("l").event) + + expect(ctx.textarea.cursorOffset).toBe(3) + expect(ctx.state.count()).toBe("") + }) + + test("ignored count prefixes clear after handled normal commands", () => { + const ctx = createHandler("abc", { register: { get: () => ({ text: "X", linewise: false }) } }) + + ctx.handler.handleKey(createEvent("2").event) + ctx.handler.handleKey(createEvent("p").event) + ctx.handler.handleKey(createEvent("l").event) + + expect(ctx.textarea.plainText).toBe("aXbc") + expect(ctx.textarea.cursorOffset).toBe(2) + expect(ctx.state.count()).toBe("") + }) + + test("count prefixes find the nth target for till motions", () => { + const ctx = createHandler("xaxax") + + ctx.handler.handleKey(createEvent("2").event) + ctx.handler.handleKey(createEvent("t").event) + ctx.handler.handleKey(createEvent("a").event) + + expect(ctx.textarea.cursorOffset).toBe(2) + }) + + test("count prefixes apply to operator find motions", () => { + const ctx = createHandler("abxcdxef") + + ctx.handler.handleKey(createEvent("2").event) + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("f").event) + ctx.handler.handleKey(createEvent("x").event) + + expect(ctx.textarea.plainText).toBe("ef") + }) + test("maps langmap keys in normal mode", () => { const ctx = createHandler("abc\nxy", { langmap: { р: "h", о: "j", л: "k", д: "l" } }) @@ -1218,6 +1281,72 @@ describe("vim motion handler", () => { expect(ctx.state.register()).toEqual({ text: "one\ntwo\n", linewise: true }) }) + test("counted d} deletes through target paragraph boundary", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour\n\nfive") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("2").event) + ctx.handler.handleKey(createEvent("d").event) + const motion = createEvent("}") + expect(ctx.handler.handleKey(motion.event)).toBe(true) + expect(motion.prevented()).toBe(true) + + expect(ctx.textarea.plainText).toBe("\nfive") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "one\ntwo\n\nthree\nfour\n", linewise: true }) + }) + + test("counted c} changes through target paragraph boundary", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour\n\nfive") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("2").event) + ctx.handler.handleKey(createEvent("c").event) + const motion = createEvent("}") + expect(ctx.handler.handleKey(motion.event)).toBe(true) + expect(motion.prevented()).toBe(true) + + expect(ctx.textarea.plainText).toBe("\n\nfive") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "one\ntwo\n\nthree\nfour\n", linewise: true }) + }) + + test("counted y{ yanks backward through target paragraph boundary", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour\n\nfive") + ctx.textarea.cursorOffset = 21 + + ctx.handler.handleKey(createEvent("2").event) + ctx.handler.handleKey(createEvent("y").event) + const motion = createEvent("{") + expect(ctx.handler.handleKey(motion.event)).toBe(true) + expect(motion.prevented()).toBe(true) + + expect(ctx.textarea.plainText).toBe("one\ntwo\n\nthree\nfour\n\nfive") + expect(ctx.textarea.cursorOffset).toBe(21) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "\nthree\nfour\n\n", linewise: true }) + }) + + test("counted c{ changes backward through target paragraph boundary", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour\n\nfive") + ctx.textarea.cursorOffset = 21 + + ctx.handler.handleKey(createEvent("2").event) + ctx.handler.handleKey(createEvent("c").event) + const motion = createEvent("{") + expect(ctx.handler.handleKey(motion.event)).toBe(true) + expect(motion.prevented()).toBe(true) + + expect(ctx.textarea.plainText).toBe("one\ntwo\n\nfive") + expect(ctx.textarea.cursorOffset).toBe(8) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "\nthree\nfour\n", linewise: true }) + }) + test("d{ deletes backward through previous paragraph boundary", () => { const ctx = createHandler("one\ntwo\n\nthree\nfour") ctx.textarea.cursorOffset = 13 @@ -1435,12 +1564,18 @@ describe("vim motion handler", () => { ctx.handler.handleKey(createEvent("j").event) expect(ctx.textarea.cursorOffset).toBe(rowColToOffset(ctx.textarea.plainText, 2, 0)) expect(ctx.state.anchor()).toBe(0) - expect((ctx.textarea as any).editorView.getSelection()).toEqual({ start: 0, end: rowColToOffset(ctx.textarea.plainText, 2, 0) + 1 }) + expect((ctx.textarea as any).editorView.getSelection()).toEqual({ + start: 0, + end: rowColToOffset(ctx.textarea.plainText, 2, 0) + 1, + }) ctx.handler.handleKey(createEvent("o").event) expect(ctx.textarea.cursorOffset).toBe(0) expect(ctx.state.anchor()).toBe(rowColToOffset(ctx.textarea.plainText, 2, 0)) - expect((ctx.textarea as any).editorView.getSelection()).toEqual({ start: 0, end: rowColToOffset(ctx.textarea.plainText, 2, 0) + 1 }) + expect((ctx.textarea as any).editorView.getSelection()).toEqual({ + start: 0, + end: rowColToOffset(ctx.textarea.plainText, 2, 0) + 1, + }) }) test("o toggles cursor when cursor is above anchor", () => { @@ -1874,6 +2009,20 @@ describe("vim motion handler", () => { expect(ctx.state.pending()).toBe("") }) + test("counted cc clears multiple lines and enters insert", () => { + const ctx = createHandler("one\ntwo\nthree\nfour") + + ctx.handler.handleKey(createEvent("3").event) + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("c").event) + + expect(ctx.state.mode()).toBe("insert") + expect(ctx.textarea.plainText).toBe("\nfour") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "one\ntwo\nthree", linewise: true }) + }) + test("c$ changes to end of line", () => { const ctx = createHandler("one\ntwo three\nfour") ctx.textarea.cursorOffset = 5 @@ -1889,6 +2038,22 @@ describe("vim motion handler", () => { expect(ctx.state.register()).toEqual({ text: "wo three", linewise: false }) }) + test("counted c$ changes through target line end", () => { + const ctx = createHandler("one\ntwo three\nfour") + ctx.textarea.cursorOffset = 5 + + ctx.handler.handleKey(createEvent("2").event) + ctx.handler.handleKey(createEvent("c").event) + const motion = createEvent("$") + expect(ctx.handler.handleKey(motion.event)).toBe(true) + expect(motion.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("one\nt") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "wo three\nfour", linewise: false }) + }) + test("c$ at end of line is no-op", () => { const ctx = createHandler("one\ntwo") ctx.textarea.cursorOffset = 3 @@ -2609,6 +2774,21 @@ describe("vim motion handler", () => { expect(ctx.state.register()).toEqual({ text: "wo three", linewise: false }) }) + test("counted d$ deletes through target line end", () => { + const ctx = createHandler("one\ntwo three\nfour") + ctx.textarea.cursorOffset = 5 + + ctx.handler.handleKey(createEvent("2").event) + ctx.handler.handleKey(createEvent("d").event) + const motion = createEvent("$") + expect(ctx.handler.handleKey(motion.event)).toBe(true) + expect(motion.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("one\nt") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "wo three\nfour", linewise: false }) + }) + test("d$ at end of line is no-op", () => { const ctx = createHandler("one\ntwo") ctx.textarea.cursorOffset = 3 @@ -4584,7 +4764,6 @@ describe("vim motion handler", () => { ctx.textarea.cursorOffset = 1 ctx.handler.handleKey(createEvent(".").event) expect(ctx.textarea.plainText).toBe("xxc") - }) test("yy yanks current line into register", () => { @@ -4601,6 +4780,19 @@ describe("vim motion handler", () => { expect(ctx.textarea.plainText).toBe("one\ntwo\nthree") }) + test("counted yy yanks multiple lines into register", () => { + const ctx = createHandler("one\ntwo\nthree\nfour") + + ctx.handler.handleKey(createEvent("3").event) + ctx.handler.handleKey(createEvent("y").event) + ctx.handler.handleKey(createEvent("y").event) + + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "one\ntwo\nthree", linewise: true }) + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.textarea.plainText).toBe("one\ntwo\nthree\nfour") + }) + test("yy flashes current line span", () => { const spans: Array<{ start: number; end: number }> = [] const ctx = createHandler("one\ntwo\nthree", { @@ -4630,6 +4822,21 @@ describe("vim motion handler", () => { expect(ctx.state.register()).toEqual({ text: "wo three", linewise: false }) }) + test("counted y$ yanks through target line end", () => { + const ctx = createHandler("one\ntwo three\nfour") + ctx.textarea.cursorOffset = 5 + + ctx.handler.handleKey(createEvent("2").event) + ctx.handler.handleKey(createEvent("y").event) + const motion = createEvent("$") + expect(ctx.handler.handleKey(motion.event)).toBe(true) + expect(motion.prevented()).toBe(true) + expect(ctx.textarea.plainText).toBe("one\ntwo three\nfour") + expect(ctx.textarea.cursorOffset).toBe(5) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "wo three\nfour", linewise: false }) + }) + test("y$ at end of line is no-op", () => { const ctx = createHandler("one\ntwo") ctx.textarea.cursorOffset = 3 @@ -6346,6 +6553,19 @@ describe("vim dot repeat", () => { expect(ctx.textarea.cursorOffset).toBe(0) }) + test("dot repeats counted dd", () => { + const ctx = createHandler("one\ntwo\nthree\nfour\nfive\nsix") + + press(ctx, "3") + press(ctx, "d") + press(ctx, "d") + expect(ctx.textarea.plainText).toBe("four\nfive\nsix") + + press(ctx, ".") + expect(ctx.textarea.plainText).toBe("") + expect(ctx.textarea.cursorOffset).toBe(0) + }) + test("dot repeats d% from the current cursor", () => { const ctx = createHandler("(a) (b)") @@ -9044,7 +9264,7 @@ describe("copy mode", () => { }) test("copyToggleVisualEnd swaps anchor and cursor in copy mode", () => { - const min = 7 // row.col (3) + gutter (4) + const min = 7 // row.col (3) + gutter (4) createRoot((dispose) => { const cm = createRenderedCopyMode(["alpha", "beta", "gamma"]) cm.prompt.visual("char") @@ -9178,7 +9398,7 @@ describe("copy mode", () => { }) test("copyToggleVisualEnd updates stick so vertical movement uses new cursor column", () => { - const min = 7 // row.col (3) + gutter (4) + const min = 7 // row.col (3) + gutter (4) createRoot((dispose) => { const cm = createRenderedCopyMode(["alpha", "beta", "gamma"]) cm.prompt.visual("char")