diff --git a/README.md b/README.md index f154b13adfdc..9c0c5012979b 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Toggle via command palette (`Ctrl+p` -> `Toggle vim mode`). **Movement** -`h` `j` `k` `l` `w` `b` `e` `W` `B` `E` `0` `^` `_` `$` `gg` `G` +`h` `j` `k` `l` `w` `b` `e` `W` `B` `E` `0` `^` `_` `$` `{` `}` `gg` `G` `f` `F` `t` `T` `;` `,` `Ctrl+e` `Ctrl+y` `Ctrl+d` `Ctrl+u` `Ctrl+f` `Ctrl+b` @@ -63,11 +63,11 @@ Toggle via command palette (`Ctrl+p` -> `Toggle vim mode`). **Editing** -`i` `I` `a` `A` `o` `O` `R` `x` `~` `dd` `dw` `db` `cc` `cw` `cb` `S` `J` +`i` `I` `a` `A` `o` `O` `R` `x` `~` `dd` `dw` `db` `d}` `d{` `cc` `cw` `cb` `c}` `c{` `S` `J` **yank / put / undo** -`yy` `yw` `p` `P` `u` `ctrl+r` +`yy` `yw` `y}` `y{` `p` `P` `u` `ctrl+r` - Copy the current prompt selection with `y` (default: `ctrl+x` then `y`). - Configure it with `keybinds.prompt_copy_selection`. diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 6b200147cdc1..cf484027c634 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -101,6 +101,8 @@ export type PromptProps = { jump: (action: "top" | "bottom" | "high" | "middle" | "low") => void wordNext: (big: boolean) => boolean wordPrev: (big: boolean) => boolean + nextParagraph: () => boolean + previousParagraph: () => boolean text: () => string col: () => number setCol: (offset: number) => void @@ -530,6 +532,12 @@ export function Prompt(props: PromptProps) { copyWordPrev(big) { return props.copy?.wordPrev(big) ?? false }, + copyNextParagraph() { + return props.copy?.nextParagraph() ?? false + }, + copyPreviousParagraph() { + return props.copy?.previousParagraph() ?? false + }, copyText() { return props.copy?.text() ?? "" }, 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 9883744facb9..fa0220b8bba2 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 @@ -10,6 +10,7 @@ import { deleteLine, deleteLineEnd, deleteSelection, + deleteSpan, deleteUnderCursor, deleteWord, deleteWordBackward, @@ -27,16 +28,22 @@ import { moveLineBeginning, moveLineDown, moveLineUp, + moveNextParagraph, + movePreviousParagraph, moveRight, moveLineEnd, moveWordEnd, moveWordNext, moveWordPrev, + nextParagraphOperation, nextWordStart, openLineAbove, openLineBelow, + type ParagraphOperation, + type ParagraphResult, pasteAfter, pasteBefore, + previousParagraphOperation, prevWordStart, replaceUnderCursor, substituteLine, @@ -80,6 +87,8 @@ export function createVimHandler(input: { copyJump?: (action: VimJump) => void copyWordNext?: (big: boolean) => boolean copyWordPrev?: (big: boolean) => boolean + copyNextParagraph?: () => boolean + copyPreviousParagraph?: () => boolean copyText?: () => string copyCol?: () => number setCopyCol?: (offset: number) => void @@ -167,6 +176,39 @@ export function createVimHandler(input: { run?.() } + function applyParagraphYank(result: ParagraphResult) { + if (result.register) setRegister(result.register, true) + if (result.span && result.span.end > result.span.start) input.flash?.(result.span) + input.state.clearPending() + } + + function applyParagraphEdit(textarea: TextareaRenderable, result: ParagraphResult, operation: "d" | "c") { + const apply = () => { + if (result.span) deleteSpan(textarea, result.span) + if (result.register) setRegister(result.register) + input.state.clearPending() + if (operation === "c") input.state.setMode("insert") + } + if (operation === "c") begin(apply) + else edit(apply) + } + + function paragraphOperator(key: string, operation: ParagraphOperation): boolean { + if (key !== "{" && key !== "}") return false + + const textarea = input.textarea() + + const result = + key === "}" ? nextParagraphOperation(textarea, operation) : previousParagraphOperation(textarea, operation) + + // no motion: vim no-ops the operator without editing or changing mode. + if (!result.span && !result.register) input.state.clearPending() + else if (operation === "y") applyParagraphYank(result) + else applyParagraphEdit(textarea, result, operation) + + return true + } + function undo() { if (!tracked()) return false const next = input.state.undo(snapshot()) @@ -323,7 +365,12 @@ export function createVimHandler(input: { } if (input.state.pending() === "c") { - if (key === "c" && !event.shift && !hasModifier(event)) { + if (hasModifier(event)) { + input.state.clearPending() + return false + } + + if (key === "c" && !event.shift) { begin(() => { const reg = substituteLine(input.textarea()) if (reg) setRegister(reg) @@ -334,7 +381,7 @@ export function createVimHandler(input: { return true } - if (key === "w" && !event.shift && !hasModifier(event)) { + if (key === "w" && !event.shift) { begin(() => { const reg = deleteWord(input.textarea()) if (reg) setRegister(reg) @@ -345,7 +392,7 @@ export function createVimHandler(input: { return true } - if (key === "b" && !event.shift && !hasModifier(event)) { + if (key === "b" && !event.shift) { begin(() => { const reg = deleteWordBackward(input.textarea()) if (reg) setRegister(reg) @@ -368,16 +415,21 @@ export function createVimHandler(input: { return true } - if (hasModifier(event)) { - input.state.clearPending() - return false + if (paragraphOperator(key, "c")) { + event.preventDefault() + return true } input.state.clearPending() } if (input.state.pending() === "d") { - if (key === "d" && !event.shift && !hasModifier(event)) { + if (hasModifier(event)) { + input.state.clearPending() + return false + } + + if (key === "d" && !event.shift) { edit(() => { const reg = deleteLine(input.textarea()) if (reg) setRegister(reg) @@ -387,7 +439,7 @@ export function createVimHandler(input: { return true } - if (key === "w" && !event.shift && !hasModifier(event)) { + if (key === "w" && !event.shift) { edit(() => { const reg = deleteWord(input.textarea()) if (reg) setRegister(reg) @@ -397,7 +449,7 @@ export function createVimHandler(input: { return true } - if (key === "b" && !event.shift && !hasModifier(event)) { + if (key === "b" && !event.shift) { edit(() => { const reg = deleteWordBackward(input.textarea()) if (reg) setRegister(reg) @@ -418,16 +470,21 @@ export function createVimHandler(input: { return true } - if (hasModifier(event)) { - input.state.clearPending() - return false + if (paragraphOperator(key, "d")) { + event.preventDefault() + return true } input.state.clearPending() } if (input.state.pending() === "y") { - if (key === "y" && !event.shift && !hasModifier(event)) { + if (hasModifier(event)) { + input.state.clearPending() + return false + } + + if (key === "y" && !event.shift) { const span = yankLineSpan(input.textarea()) const reg = yankLine(input.textarea()) if (reg) setRegister(reg, true) @@ -437,7 +494,7 @@ export function createVimHandler(input: { return true } - if (key === "w" && !event.shift && !hasModifier(event)) { + if (key === "w" && !event.shift) { const span = yankWordSpan(input.textarea()) const reg = yankWord(input.textarea()) if (reg) setRegister(reg, true) @@ -458,9 +515,9 @@ export function createVimHandler(input: { return true } - if (hasModifier(event)) { - input.state.clearPending() - return false + if (paragraphOperator(key, "y")) { + event.preventDefault() + return true } input.state.clearPending() @@ -736,6 +793,18 @@ export function createVimHandler(input: { return true } + if (key === "{" && !hasModifier(event)) { + movePreviousParagraph(input.textarea()) + event.preventDefault() + return true + } + + if (key === "}" && !hasModifier(event)) { + moveNextParagraph(input.textarea()) + event.preventDefault() + return true + } + if (key === "x" && !event.shift && !hasModifier(event)) { edit(() => { const reg = deleteUnderCursor(input.textarea()) @@ -1029,6 +1098,19 @@ export function createVimHandler(input: { return true } + // paragraph motions + if (key === "{" && !hasModifier(event)) { + input.copyPreviousParagraph?.() + event.preventDefault() + return true + } + + if (key === "}" && !hasModifier(event)) { + input.copyNextParagraph?.() + event.preventDefault() + return true + } + // find-char pending if ((key === "f" || key === "t") && !event.shift) { input.state.setPending(key) diff --git a/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts b/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts index 7a9d4368a5ed..a901c9cdc992 100644 --- a/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts +++ b/packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts @@ -36,6 +36,56 @@ function nextLineStart(text: string, offset: number) { return end + 1 } +function isBlankLine(text: string, lineStartOffset: number) { + return lineEnd(text, lineStartOffset) === lineStartOffset +} + +// vim treats a trailing \n as EOL of the last line, not a new empty line. +// Differs from nextLineStart on "abc\n": this returns null, that returns 4. +function paragraphAdvance(text: string, lineOffset: number): number | null { + const end = lineEnd(text, lineOffset) + if (end >= text.length - 1) return null + return end + 1 +} + +function paragraphRetreat(text: string, lineOffset: number): number | null { + if (lineOffset <= 0) return null + return lineStart(text, lineOffset - 1) +} + +function nextParagraphTarget(text: string, cursor: number): number { + if (text.length === 0) return 0 + let probe = lineStart(text, cursor) + while (isBlankLine(text, probe)) { + const next = paragraphAdvance(text, probe) + if (next === null) return probe + probe = next + } + while (!isBlankLine(text, probe)) { + const next = paragraphAdvance(text, probe) + if (next === null) return lineLast(text, text.length - 1) + probe = next + } + return probe +} + +function previousParagraphTarget(text: string, cursor: number): number { + if (text.length === 0 || cursor === 0) return 0 + let probe = lineStart(text, cursor) + while (isBlankLine(text, probe)) { + const previous = paragraphRetreat(text, probe) + if (previous === null) return 0 + probe = previous + } + while (probe > 0) { + const previous = paragraphRetreat(text, probe) + if (previous === null) return 0 + if (isBlankLine(text, previous)) return previous + probe = previous + } + return 0 +} + function moveUp(text: string, offset: number) { const currentStart = lineStart(text, offset) const targetStart = prevLineStart(text, offset) @@ -91,6 +141,132 @@ export function moveLineDown(textarea: TextareaRenderable) { textarea.cursorOffset = moveDown(text, textarea.cursorOffset) } +export function movePreviousParagraph(textarea: TextareaRenderable) { + textarea.cursorOffset = previousParagraphTarget(textarea.plainText, textarea.cursorOffset) +} + +export function moveNextParagraph(textarea: TextareaRenderable) { + textarea.cursorOffset = nextParagraphTarget(textarea.plainText, textarea.cursorOffset) +} + +export type ParagraphOperation = "d" | "c" | "y" + +export type ParagraphResult = { + span: VimSpan | null + register: VimRegister +} + +// vim linewise register convention: content ends with \n per line terminator. +function asLinewise(slice: string): string { + return slice.endsWith("\n") ? slice : slice + "\n" +} + +function buildParagraphResult( + text: string, + span: VimSpan | null, + registerSpan: VimSpan | null, + linewise: boolean, +): ParagraphResult { + if (!span) return { span: null, register: null } + const register = registerSpan ?? span + const slice = text.slice(register.start, register.end) + return { span, register: { text: linewise ? asLinewise(slice) : slice, linewise } } +} + +type NextClassification = { + lineStartOffset: number + onBlank: boolean + lineAligned: boolean + target: number + targetIsBlank: boolean + multiLine: boolean +} + +function classifyNextParagraph(text: string, cursor: number): NextClassification { + const lineStartOffset = lineStart(text, cursor) + const onBlank = isBlankLine(text, lineStartOffset) + const target = nextParagraphTarget(text, cursor) + const targetLineStart = lineStart(text, target) + return { + lineStartOffset, + onBlank, + lineAligned: onBlank || cursor === lineStartOffset, + target, + targetIsBlank: target === targetLineStart && target < text.length && isBlankLine(text, target), + multiLine: lineStartOffset !== targetLineStart, + } +} + +// linewise rules derived empirically from nvim: +// d: line-aligned cursor + (blank target OR motion crosses lines) +// y/c: line-aligned cursor AND blank target +function isLinewiseNext(c: NextClassification, op: ParagraphOperation): boolean { + if (!c.lineAligned) return false + return op === "d" ? c.targetIsBlank || c.multiLine : c.targetIsBlank +} + +// d at EOF with no trailing \n extends the delete span back to swallow the +// preceding \n separator, but keeps the register range tight. +// c strips the trailing \n that d/y keep (preserves line structure). +function nextLinewiseSpan( + text: string, + c: NextClassification, + op: ParagraphOperation, +): { span: VimSpan | null; registerSpan: VimSpan | null } { + if (op === "d" && !c.targetIsBlank) { + const extendBack = text[text.length - 1] !== "\n" && c.lineStartOffset > 0 + return { + span: { start: extendBack ? c.lineStartOffset - 1 : c.lineStartOffset, end: text.length }, + registerSpan: { start: c.lineStartOffset, end: text.length }, + } + } + const end = op === "c" ? c.target - 1 : c.target + if (end <= c.lineStartOffset) return { span: null, registerSpan: null } + return { span: { start: c.lineStartOffset, end }, registerSpan: null } +} + +// onBlank branch is only reached by y/c from a blank line with a non-blank +// target (d-from-blank is always linewise via the multi-line rule). +function nextCharwiseSpan(text: string, cursor: number, c: NextClassification): VimSpan | null { + const end = c.targetIsBlank ? c.target - 1 : c.onBlank ? text.length : c.target + 1 + if (end <= cursor) return null + return { start: cursor, end } +} + +export function nextParagraphOperation( + textarea: TextareaRenderable, + operation: ParagraphOperation, +): ParagraphResult { + const text = textarea.plainText + const cursor = textarea.cursorOffset + if (text.length === 0) return { span: null, register: null } + + const c = classifyNextParagraph(text, cursor) + if (!isLinewiseNext(c, operation)) return buildParagraphResult(text, nextCharwiseSpan(text, cursor, c), null, false) + const { span, registerSpan } = nextLinewiseSpan(text, c, operation) + return buildParagraphResult(text, span, registerSpan, true) +} + +// vim `{` operator. linewise for all of y/d/c when cursor is line-aligned. +// c strips the trailing \n at cursor-1; d/y keep it. +export function previousParagraphOperation( + textarea: TextareaRenderable, + operation: ParagraphOperation, +): ParagraphResult { + const text = textarea.plainText + const cursor = textarea.cursorOffset + if (text.length === 0 || cursor === 0) return { span: null, register: null } + + const lineStartOffset = lineStart(text, cursor) + const linewise = isBlankLine(text, lineStartOffset) || cursor === lineStartOffset + const target = previousParagraphTarget(text, cursor) + if (target >= cursor) return { span: null, register: null } + + if (!linewise || operation !== "c") return buildParagraphResult(text, { start: target, end: cursor }, null, linewise) + const end = text[cursor - 1] === "\n" ? cursor - 1 : cursor + return buildParagraphResult(text, end > target ? { start: target, end } : null, null, true) +} + export function isWord(char: string) { return /[A-Za-z0-9_]/.test(char) } @@ -260,6 +436,41 @@ export function copyWordPrev(rows: VimCopyRow[], get: (idx: number) => string, i return { idx, col: min } } +export type CopyParagraphResult = { index: number; atEnd: boolean } + +// `atEnd` is true only when content runs to EOF without a trailing blank line, +// the only case where vim `}` lands on end-of-line instead of column 0. +export function copyNextParagraph( + rows: VimCopyRow[], + get: (index: number) => string, + index: number, +): CopyParagraphResult { + if (!rows.length) return { index: 0, atEnd: false } + let cursor = index + while (cursor < rows.length && get(cursor) === "") cursor++ + if (cursor === rows.length) return { index: rows.length - 1, atEnd: false } + while (cursor < rows.length && get(cursor) !== "") cursor++ + if (cursor === rows.length) return { index: rows.length - 1, atEnd: true } + return { index: cursor, atEnd: false } +} + +// no `atEnd` counterpart: vim `{` always lands on column 0 of the target row. +export function copyPreviousParagraph( + rows: VimCopyRow[], + get: (index: number) => string, + index: number, +): CopyParagraphResult { + if (!rows.length) return { index: 0, atEnd: false } + let cursor = index + while (cursor > 0 && get(cursor) === "") cursor-- + if (get(cursor) === "") return { index: 0, atEnd: false } + while (cursor > 0) { + cursor-- + if (get(cursor) === "") return { index: cursor, atEnd: false } + } + return { index: 0, atEnd: false } +} + export function appendAfterCursor(textarea: TextareaRenderable) { const text = textarea.plainText const end = lineEnd(text, textarea.cursorOffset) @@ -367,6 +578,11 @@ export function deleteLineEnd(textarea: TextareaRenderable): VimRegister { return { text: yanked, linewise: false } } +export function deleteSpan(textarea: TextareaRenderable, span: VimSpan | null): void { + if (!span || span.end <= span.start) return + deleteOffsets(textarea, span.start, span.end) +} + export function findChar(textarea: TextareaRenderable, char: string, forward: boolean, till = false, repeat = false) { const text = textarea.plainText const offset = textarea.cursorOffset diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index 9378d5b9e5f1..d6832e79588e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -1,7 +1,13 @@ import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import type { ScrollBoxRenderable } from "@opentui/core" import type { Part } from "@opencode-ai/sdk/v2" -import { copyWordNext, copyWordPrev, firstNonWhitespace } from "@/cli/cmd/tui/component/vim/vim-motions" +import { + copyNextParagraph, + copyPreviousParagraph, + copyWordNext, + copyWordPrev, + firstNonWhitespace, +} from "@/cli/cmd/tui/component/vim/vim-motions" import * as Clipboard from "../../util/clipboard" export type CopyRow = { @@ -378,6 +384,41 @@ export function createCopyMode(input: { return true } + function paragraphColumn( + row: CopyRow, + atEnd: boolean, + sameRow: boolean, + currentCol: number, + ): { col: number; stick: "start" | "end" } | null { + const col = atEnd ? resolveStick(row, "end") : copyMin(row) + if (sameRow && currentCol === col) return null + return { col, stick: atEnd ? "end" : "start" } + } + + function paragraphMove(motion: typeof copyNextParagraph): boolean { + const s = state() + if (!s.active) return false + const list = rows() + if (!list.length) return false + const result = motion(list, (idx) => rowText(list[idx]!), s.idx) + const sameRow = result.index === s.idx + if (!sameRow) sync(result.index) + const row = rows()[state().idx] + if (!row) return false + const update = paragraphColumn(row, result.atEnd, sameRow, state().col) + if (!update) return false + setState((prev) => ({ ...prev, ...update })) + return true + } + + function nextParagraph() { + return paragraphMove(copyNextParagraph) + } + + function previousParagraph() { + return paragraphMove(copyPreviousParagraph) + } + // --- visual --- function visual(mode: "char" | "line") { @@ -601,6 +642,8 @@ export function createCopyMode(input: { jump, wordNext, wordPrev, + nextParagraph, + previousParagraph, text: copyText, col, setCol, diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 10367f127520..70e61156671f 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -6,7 +6,13 @@ import { createVimState } from "../../../src/cli/cmd/tui/component/vim/vim-state import type { VimScroll } from "../../../src/cli/cmd/tui/component/vim/vim-scroll" import { vimScroll } from "../../../src/cli/cmd/tui/component/vim/vim-scroll" import type { VimJump } from "../../../src/cli/cmd/tui/component/vim/vim-motion-jump" -import { copyWordNext, copyWordPrev, deleteSelection } from "../../../src/cli/cmd/tui/component/vim/vim-motions" +import { + copyNextParagraph, + copyPreviousParagraph, + copyWordNext, + copyWordPrev, + deleteSelection, +} from "../../../src/cli/cmd/tui/component/vim/vim-motions" function rowColToOffset(text: string, row: number, col: number) { let index = 0 @@ -325,6 +331,24 @@ function createHandler( setCopyCol(prev.col) return moved }, + copyNextParagraph() { + if (!copyRows) return false + const next = copyNextParagraph(copyRows, (idx) => options?.copy?.texts?.[idx] ?? "", copyIdx()) + const col = copyRows[next.index]?.col ?? 0 + if (next.index === copyIdx() && !next.atEnd && col === copyCol()) return false + setCopyIdx(next.index) + setCopyCol(col) + return true + }, + copyPreviousParagraph() { + if (!copyRows) return false + const previous = copyPreviousParagraph(copyRows, (idx) => options?.copy?.texts?.[idx] ?? "", copyIdx()) + const col = copyRows[previous.index]?.col ?? 0 + if (previous.index === copyIdx() && col === copyCol()) return false + setCopyIdx(previous.index) + setCopyCol(col) + return true + }, copyText() { return options?.copy?.texts?.[copyIdx()] ?? options?.copy?.text ?? "alpha beta gamma" }, @@ -627,6 +651,321 @@ describe("vim motion handler", () => { expect(ctx.textarea.cursorOffset).toBe(4) }) + test("} jumps to the next blank line", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour") + ctx.textarea.cursorOffset = 0 + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.textarea.cursorOffset).toBe(8) + }) + + test("} lands on the first of consecutive blank lines", () => { + const ctx = createHandler("a\n\n\n\nb") + ctx.textarea.cursorOffset = 0 + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.textarea.cursorOffset).toBe(2) + }) + + test("} from a blank line skips blanks then next paragraph", () => { + const ctx = createHandler("a\n\n\nb\n\nc") + ctx.textarea.cursorOffset = 2 + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.textarea.cursorOffset).toBe(6) + }) + + test("{ jumps to the previous blank line", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour") + ctx.textarea.cursorOffset = 14 + ctx.handler.handleKey(createEvent("{").event) + expect(ctx.textarea.cursorOffset).toBe(8) + }) + + test("{ at start of buffer stays at 0", () => { + const ctx = createHandler("abc\ndef") + ctx.textarea.cursorOffset = 2 + ctx.handler.handleKey(createEvent("{").event) + expect(ctx.textarea.cursorOffset).toBe(0) + }) + + test("} with no blank lines lands on last char", () => { + const ctx = createHandler("only\nparagraph") + ctx.textarea.cursorOffset = 0 + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.textarea.cursorOffset).toBe(13) + }) + + test("} does not treat whitespace-only line as blank", () => { + const ctx = createHandler("a\n \nb") + ctx.textarea.cursorOffset = 0 + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.textarea.cursorOffset).toBe(6) + }) + + test("{ does not treat whitespace-only line as blank", () => { + const ctx = createHandler("a\n\t \nb") + ctx.textarea.cursorOffset = 5 + ctx.handler.handleKey(createEvent("{").event) + expect(ctx.textarea.cursorOffset).toBe(0) + }) + + test("} with trailing newline lands on the trailing empty line", () => { + const ctx = createHandler("abc\n\ndef\n") + ctx.textarea.cursorOffset = 0 + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.textarea.cursorOffset).toBe(4) + }) + + test("} with trailing newline lands on last char of last line", () => { + const ctx = createHandler("abc\n") + ctx.textarea.cursorOffset = 0 + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.textarea.cursorOffset).toBe(2) + }) + + test("} with no blank line but trailing newline lands on last char of last line", () => { + const ctx = createHandler("one\ntwo\n") + ctx.textarea.cursorOffset = 0 + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.textarea.cursorOffset).toBe(6) + }) + + test("} with no blank lines anywhere goes to last char", () => { + const ctx = createHandler("one\ntwo\nthree") + ctx.textarea.cursorOffset = 0 + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.textarea.cursorOffset).toBe(12) + }) + + test("{ with no blank lines anywhere goes to start", () => { + const ctx = createHandler("one\ntwo\nthree") + ctx.textarea.cursorOffset = 10 + ctx.handler.handleKey(createEvent("{").event) + expect(ctx.textarea.cursorOffset).toBe(0) + }) + + test("d} deletes through next paragraph boundary", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour") + ctx.textarea.cursorOffset = 0 + + 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("\nthree\nfour") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "one\ntwo\n", linewise: true }) + }) + + test("d{ deletes backward through previous paragraph boundary", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour") + ctx.textarea.cursorOffset = 13 + + 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\ntwo\ne\nfour") + expect(ctx.textarea.cursorOffset).toBe(8) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "\nthre", linewise: false }) + }) + + test("c} deletes through next paragraph boundary and enters insert", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("c").event) + const motion = createEvent("}") + expect(ctx.handler.handleKey(motion.event)).toBe(true) + expect(motion.prevented()).toBe(true) + + // c linewise preserves the line separator before the blank, unlike d + expect(ctx.textarea.plainText).toBe("\n\nthree\nfour") + 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", linewise: true }) + }) + + test("c} on last char of last paragraph deletes the char and enters insert", () => { + const ctx = createHandler("one\ntwo\nthree") + ctx.textarea.cursorOffset = 12 + + 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\nthre") + expect(ctx.textarea.cursorOffset).toBe(12) + expect(ctx.state.mode()).toBe("insert") + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "e", linewise: false }) + }) + + test("y} yanks through next paragraph boundary", () => { + const flashes: Array<{ start: number; end: number }> = [] + const ctx = createHandler("one\ntwo\n\nthree\nfour", { flash: (span) => flashes.push(span) }) + ctx.textarea.cursorOffset = 0 + + 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") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "one\ntwo\n", linewise: true }) + expect(flashes).toEqual([{ start: 0, end: 8 }]) + }) + + test("y} on last char of last paragraph yanks the char", () => { + const flashes: Array<{ start: number; end: number }> = [] + const ctx = createHandler("one\ntwo\nthree", { flash: (span) => flashes.push(span) }) + ctx.textarea.cursorOffset = 12 + + 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\nthree") + expect(ctx.textarea.cursorOffset).toBe(12) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "e", linewise: false }) + expect(flashes).toEqual([{ start: 12, end: 13 }]) + }) + + test("d} on last char of last paragraph deletes the char", () => { + const ctx = createHandler("one\ntwo\nthree") + ctx.textarea.cursorOffset = 12 + + 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\ntwo\nthre") + expect(ctx.textarea.cursorOffset).toBe(12) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "e", linewise: false }) + }) + + test("y{ yanks backward through previous paragraph boundary", () => { + const flashes: Array<{ start: number; end: number }> = [] + const ctx = createHandler("one\ntwo\n\nthree\nfour", { flash: (span) => flashes.push(span) }) + ctx.textarea.cursorOffset = 13 + + 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") + expect(ctx.textarea.cursorOffset).toBe(13) + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toEqual({ text: "\nthre", linewise: false }) + expect(flashes).toEqual([{ start: 8, end: 13 }]) + }) + + test("v then } extends selection to next blank line", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("v").event) + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.textarea.cursorOffset).toBe(8) + expect((ctx.textarea as any).editorView.getSelection()).toEqual({ start: 0, end: 9 }) + }) + + test("v then { extends selection backward to previous blank line", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour") + ctx.textarea.cursorOffset = 14 + + ctx.handler.handleKey(createEvent("v").event) + ctx.handler.handleKey(createEvent("{").event) + expect(ctx.textarea.cursorOffset).toBe(8) + expect((ctx.textarea as any).editorView.getSelection()).toEqual({ start: 8, end: 15 }) + }) + + test("V then } extends linewise selection across paragraph", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("V").event) + expect(ctx.state.mode()).toBe("visual-line") + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.textarea.cursorOffset).toBe(8) + }) + + test("v then } then d deletes selection charwise through blank", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("v").event) + ctx.handler.handleKey(createEvent("}").event) + ctx.handler.handleKey(createEvent("d").event) + + expect(ctx.textarea.plainText).toBe("three\nfour") + expect(ctx.state.mode()).toBe("normal") + expect(ctx.state.register()).toEqual({ text: "one\ntwo\n\n", linewise: false }) + }) + + test("v then } then y yanks selection charwise", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("v").event) + ctx.handler.handleKey(createEvent("}").event) + ctx.handler.handleKey(createEvent("y").event) + + expect(ctx.textarea.plainText).toBe("one\ntwo\n\nthree\nfour") + expect(ctx.state.mode()).toBe("normal") + expect(ctx.state.register()).toEqual({ text: "one\ntwo\n\n", linewise: false }) + }) + + test("v then { then d deletes selection charwise backward", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour") + ctx.textarea.cursorOffset = 14 + + ctx.handler.handleKey(createEvent("v").event) + ctx.handler.handleKey(createEvent("{").event) + ctx.handler.handleKey(createEvent("d").event) + + expect(ctx.textarea.plainText).toBe("one\ntwo\nfour") + expect(ctx.state.mode()).toBe("normal") + expect(ctx.state.register()).toEqual({ text: "\nthree\n", linewise: false }) + }) + + test("V then } then d deletes full lines linewise", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("V").event) + ctx.handler.handleKey(createEvent("}").event) + ctx.handler.handleKey(createEvent("d").event) + + expect(ctx.textarea.plainText).toBe("three\nfour") + expect(ctx.state.mode()).toBe("normal") + expect(ctx.state.register()?.linewise).toBe(true) + }) + + test("V then } then y yanks full lines linewise", () => { + const ctx = createHandler("one\ntwo\n\nthree\nfour") + ctx.textarea.cursorOffset = 0 + + ctx.handler.handleKey(createEvent("V").event) + ctx.handler.handleKey(createEvent("}").event) + ctx.handler.handleKey(createEvent("y").event) + + expect(ctx.textarea.plainText).toBe("one\ntwo\n\nthree\nfour") + expect(ctx.state.mode()).toBe("normal") + expect(ctx.state.register()?.linewise).toBe(true) + }) + test("supports insert transitions for A I O", () => { const i0 = createHandler("abc") i0.textarea.cursorOffset = 1 @@ -2752,6 +3091,387 @@ describe("vim motion handler", () => { }) }) +// vim parity fixtures for `{` and `}` operators. Each row is a behavior +// observed from nvim (--clean, nofixendofline). Fixtures drive the same +// handler path as normal operator-pending input. +// +// (row, col) are 1-indexed vim coordinates. buf is the buffer after the op. +// reg is the expected register content, with linewise flag. +type ParaFixture = { + text: string + row: number + col: number + op: "d" | "c" | "y" + motion: "}" | "{" + buf: string + reg: { text: string; linewise: boolean } +} + +function rowColToOffsetPara(text: string, row: number, col: number) { + const lines = text.split("\n") + let offset = 0 + for (let i = 0; i < row - 1; i++) offset += lines[i]!.length + 1 + return offset + (col - 1) +} + +function runFixture(f: ParaFixture) { + const ctx = createHandler(f.text) + ctx.textarea.cursorOffset = rowColToOffsetPara(f.text, f.row, f.col) + ctx.handler.handleKey(createEvent(f.op).event) + ctx.handler.handleKey(createEvent(f.motion).event) + expect(ctx.textarea.plainText).toBe(f.buf) + expect(ctx.state.register()).toEqual(f.reg) +} + +describe("vim paragraph operator parity", () => { + test("d} col 0 multi-line no trailing \\n: linewise", () => { + runFixture({ + text: "one\ntwo", + row: 1, + col: 1, + op: "d", + motion: "}", + buf: "", + reg: { text: "one\ntwo\n", linewise: true }, + }) + }) + + test("d} col 0 multi-line trailing \\n: linewise consumes trailing \\n", () => { + runFixture({ + text: "one\ntwo\n", + row: 1, + col: 1, + op: "d", + motion: "}", + buf: "", + reg: { text: "one\ntwo\n", linewise: true }, + }) + }) + + test("y} col 0 multi-line trailing \\n: char-wise (not linewise)", () => { + runFixture({ + text: "one\ntwo\n", + row: 1, + col: 1, + op: "y", + motion: "}", + buf: "one\ntwo\n", + reg: { text: "one\ntwo", linewise: false }, + }) + }) + + test("c} col 0 multi-line trailing \\n: char-wise", () => { + runFixture({ + text: "one\ntwo\n", + row: 1, + col: 1, + op: "c", + motion: "}", + buf: "\n", + reg: { text: "one\ntwo", linewise: false }, + }) + }) + + test("d} col 0 single line trailing \\n: char-wise (no multi-line promotion)", () => { + runFixture({ + text: "abc\n", + row: 1, + col: 1, + op: "d", + motion: "}", + buf: "\n", + reg: { text: "abc", linewise: false }, + }) + }) + + test("d} col > 0: char-wise with exclusive-to-inclusive (end-of-prev-line)", () => { + runFixture({ + text: "one\n\nb", + row: 1, + col: 2, + op: "d", + motion: "}", + buf: "o\n\nb", + reg: { text: "ne", linewise: false }, + }) + }) + + test("d} col 0 blank target: linewise through blank's \\n", () => { + runFixture({ + text: "one\ntwo\n\nthree", + row: 1, + col: 1, + op: "d", + motion: "}", + buf: "\nthree", + reg: { text: "one\ntwo\n", linewise: true }, + }) + }) + + test("y} col 0 blank target: linewise", () => { + runFixture({ + text: "one\ntwo\n\nthree", + row: 1, + col: 1, + op: "y", + motion: "}", + buf: "one\ntwo\n\nthree", + reg: { text: "one\ntwo\n", linewise: true }, + }) + }) + + test("c} col 0 blank target: linewise strips trailing \\n", () => { + runFixture({ + text: "one\ntwo\n\nthree", + row: 1, + col: 1, + op: "c", + motion: "}", + buf: "\n\nthree", + reg: { text: "one\ntwo\n", linewise: true }, + }) + }) + + test("d} cursor on blank, target blank: linewise delete of content paragraph", () => { + runFixture({ + text: "a\n\n\nb\n\nc", + row: 2, + col: 1, + op: "d", + motion: "}", + buf: "a\n\nc", + reg: { text: "\n\nb\n", linewise: true }, + }) + }) + + test("c} cursor on blank, target blank: linewise strip trailing \\n", () => { + runFixture({ + text: "a\n\n\nb\n\nc", + row: 2, + col: 1, + op: "c", + motion: "}", + buf: "a\n\n\nc", + reg: { text: "\n\nb\n", linewise: true }, + }) + }) + + test("d} cursor on blank, target EOF no trailing \\n: extends start backward", () => { + runFixture({ + text: "one\n\ntwo", + row: 2, + col: 1, + op: "d", + motion: "}", + buf: "one", + reg: { text: "\ntwo\n", linewise: true }, + }) + }) + + test("y} cursor on blank, target EOF: char-wise", () => { + runFixture({ + text: "one\n\ntwo", + row: 2, + col: 1, + op: "y", + motion: "}", + buf: "one\n\ntwo", + reg: { text: "\ntwo", linewise: false }, + }) + }) + + test("d} cursor on blank, target EOF trailing \\n: no backward extension", () => { + runFixture({ + text: "one\n\ntwo\n", + row: 2, + col: 1, + op: "d", + motion: "}", + buf: "one\n", + reg: { text: "\ntwo\n", linewise: true }, + }) + }) + + test("d} on last char EOF no trailing \\n: char-wise single char", () => { + runFixture({ + text: "abc", + row: 1, + col: 3, + op: "d", + motion: "}", + buf: "ab", + reg: { text: "c", linewise: false }, + }) + }) + + test("d} on single-char buffer: deletes everything", () => { + runFixture({ + text: "a", + row: 1, + col: 1, + op: "d", + motion: "}", + buf: "", + reg: { text: "a", linewise: false }, + }) + }) + + test("d} content col 0, blank line 2 target: linewise delete of single line", () => { + runFixture({ + text: "one\n\n", + row: 1, + col: 1, + op: "d", + motion: "}", + buf: "\n", + reg: { text: "one\n", linewise: true }, + }) + }) + + test("y} from blank with only blanks ahead: linewise single step", () => { + runFixture({ + text: "a\n\n\n", + row: 2, + col: 1, + op: "y", + motion: "}", + buf: "a\n\n\n", + reg: { text: "\n", linewise: true }, + }) + }) + + // c} on blank-only buffer with adjacent blank target produces an empty + // linewise span, so Vim no-ops the operator (no edit, no insert mode). + test("c} blank-only buffer: no-op, mode unchanged, register unchanged", () => { + const ctx = createHandler("\n\n") + ctx.textarea.cursorOffset = 0 + ctx.handler.handleKey(createEvent("c").event) + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.textarea.plainText).toBe("\n\n") + expect(ctx.textarea.cursorOffset).toBe(0) + expect(ctx.state.mode()).toBe("normal") + expect(ctx.state.pending()).toBe("") + expect(ctx.state.register()).toBeNull() + }) + + test("d{ col 0, target 0: linewise", () => { + runFixture({ + text: "one\ntwo", + row: 2, + col: 1, + op: "d", + motion: "{", + buf: "two", + reg: { text: "one\n", linewise: true }, + }) + }) + + test("y{ col 0, target 0: linewise", () => { + runFixture({ + text: "one\ntwo", + row: 2, + col: 1, + op: "y", + motion: "{", + buf: "one\ntwo", + reg: { text: "one\n", linewise: true }, + }) + }) + + test("c{ col 0, target 0: linewise strips trailing \\n", () => { + runFixture({ + text: "one\ntwo", + row: 2, + col: 1, + op: "c", + motion: "{", + buf: "\ntwo", + reg: { text: "one\n", linewise: true }, + }) + }) + + test("d{ col > 0: char-wise", () => { + runFixture({ + text: "one\ntwo\n\nthree", + row: 4, + col: 2, + op: "d", + motion: "{", + buf: "one\ntwo\nhree", + reg: { text: "\nt", linewise: false }, + }) + }) + + test("d{ cursor on blank, target 0: linewise", () => { + runFixture({ + text: "one\n\ntwo", + row: 2, + col: 1, + op: "d", + motion: "{", + buf: "\ntwo", + reg: { text: "one\n", linewise: true }, + }) + }) + + test("c{ cursor on blank, target 0: linewise strips \\n", () => { + runFixture({ + text: "one\n\ntwo", + row: 2, + col: 1, + op: "c", + motion: "{", + buf: "\n\ntwo", + reg: { text: "one\n", linewise: true }, + }) + }) + + test("d{ content col 0 between paragraphs: deletes blank separator", () => { + runFixture({ + text: "one\n\ntwo", + row: 3, + col: 1, + op: "d", + motion: "{", + buf: "one\ntwo", + reg: { text: "\n", linewise: true }, + }) + }) + + test("d{ cursor at start of buffer: no-op, register unchanged", () => { + const ctx = createHandler("abc") + ctx.textarea.cursorOffset = 0 + ctx.handler.handleKey(createEvent("d").event) + ctx.handler.handleKey(createEvent("{").event) + expect(ctx.textarea.plainText).toBe("abc") + expect(ctx.state.register()).toBeNull() + }) + + test("d{ crosses multiple paragraphs from col 0: linewise delete to blank", () => { + runFixture({ + text: "one\ntwo\n\nthree", + row: 3, + col: 1, + op: "d", + motion: "{", + buf: "\nthree", + reg: { text: "one\ntwo\n", linewise: true }, + }) + }) + + test("d{ col > 0 crossing paragraphs: char-wise to prev blank \\n", () => { + runFixture({ + text: "one\ntwo\n\nthree\nfour", + row: 5, + col: 4, + op: "d", + motion: "{", + buf: "one\ntwo\nr", + reg: { text: "\nthree\nfou", linewise: false }, + }) + }) +}) + describe("vim undo redo", () => { test("u undoes and ctrl+r redoes normal mode edits", () => { const ctx = createHandler("abcd") @@ -3063,6 +3783,157 @@ describe("copy mode", () => { expect(ctx.copyCol()).toBe(8) }) + test("} advances to next blank copy row", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 0, + col: 0, + rows: [{ col: 0 }, { col: 0 }, { col: 0 }, { col: 0 }, { col: 0 }], + texts: ["one", "two", "", "three", "four"], + }, + }) + + const evt = createEvent("}") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(evt.prevented()).toBe(true) + expect(ctx.copyIdx()).toBe(2) + }) + + test("} from blank skips blanks then jumps to next blank", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 0, + col: 0, + rows: [{ col: 0 }, { col: 0 }, { col: 0 }, { col: 0 }, { col: 0 }, { col: 0 }], + texts: ["a", "", "", "b", "", "c"], + }, + }) + + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.copyIdx()).toBe(1) + + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.copyIdx()).toBe(4) + }) + + test("{ retreats to previous blank copy row", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 4, + col: 0, + rows: [{ col: 0 }, { col: 0 }, { col: 0 }, { col: 0 }, { col: 0 }], + texts: ["one", "two", "", "three", "four"], + }, + }) + + const evt = createEvent("{") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(evt.prevented()).toBe(true) + expect(ctx.copyIdx()).toBe(2) + }) + + test("{ on first copy row moves to row start", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 0, + col: 7, + rows: [{ col: 3 }, { col: 3 }], + texts: ["one", "two"], + }, + }) + + const evt = createEvent("{") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(evt.prevented()).toBe(true) + expect(ctx.copyIdx()).toBe(0) + expect(ctx.copyCol()).toBe(3) + }) + + test("} with no blank rows lands on last row", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 0, + col: 0, + rows: [{ col: 0 }, { col: 0 }, { col: 0 }], + texts: ["one", "two", "three"], + }, + }) + + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.copyIdx()).toBe(2) + }) + + test("{ with no blank rows lands on first row", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 2, + col: 0, + rows: [{ col: 0 }, { col: 0 }, { col: 0 }], + texts: ["one", "two", "three"], + }, + }) + + ctx.handler.handleKey(createEvent("{").event) + expect(ctx.copyIdx()).toBe(0) + }) + + test("} extends selection in visual copy mode", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 0, + col: 0, + isVisual: true, + rows: [{ col: 0 }, { col: 0 }, { col: 0 }, { col: 0 }], + texts: ["one", "two", "", "three"], + }, + }) + + const evt = createEvent("}") + expect(ctx.handler.handleKey(evt.event)).toBe(true) + expect(evt.prevented()).toBe(true) + expect(ctx.copyIdx()).toBe(2) + expect(ctx.copyVisual()).toBe("char") + }) + + test("} resets column to the target row's minimum col", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 0, + col: 5, + rows: [{ col: 3 }, { col: 3 }, { col: 7 }, { col: 3 }], + texts: ["one", "two", "", "three"], + }, + }) + + ctx.handler.handleKey(createEvent("}").event) + expect(ctx.copyIdx()).toBe(2) + expect(ctx.copyCol()).toBe(7) + }) + + test("{ resets column to the target row's minimum col", () => { + const ctx = createHandler("abc", { + mode: "copy", + copy: { + idx: 4, + col: 9, + rows: [{ col: 2 }, { col: 2 }, { col: 5 }, { col: 2 }, { col: 2 }], + texts: ["one", "two", "", "three", "four"], + }, + }) + + ctx.handler.handleKey(createEvent("{").event) + expect(ctx.copyIdx()).toBe(2) + expect(ctx.copyCol()).toBe(5) + }) + test("q exits copy mode", () => { const ctx = createHandler("abc", { mode: "copy" }) diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index fd45d1d4f6d2..80b9c1f3923d 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -410,7 +410,7 @@ Enable it from the command palette or in your config. **Supported keys:** -**Movement:** `h`, `j`, `k`, `l`, `0`, `^`/`_`, `$`; `w`, `b`, `e`, `W`, `B`, `E`; `f`, `F`, `t`, `T`, `;`, `,` +**Movement:** `h`, `j`, `k`, `l`, `0`, `^`/`_`, `$`; `w`, `b`, `e`, `W`, `B`, `E`; `f`, `F`, `t`, `T`, `;`, `,`; `{`, `}` **Insert / edit:** `i`, `I`, `a`, `A`, `o`, `O`; `x`; `S`, `J`, `cc`, `cw`; `dd`, `dw`