fix(tui): preserve column of cursor before it entered a paste marker (#3092)

This commit is contained in:
Sviatoslav Abakumov
2026-04-13 12:03:18 +03:00
committed by GitHub
parent 3166dae728
commit 4dda448a3f
2 changed files with 337 additions and 48 deletions

View File

@@ -271,6 +271,13 @@ export class Editor implements Component, Focusable {
// Preferred visual column for vertical cursor movement (sticky column)
private preferredVisualCol: number | null = null;
// When the cursor is snapped to the start of an atomic segment, e.g. a
// paste marker, cursorCol no longer reflects where the cursor would have
// landed. This field stores the pre-snap cursorCol so that the next
// vertical move can resolve it to a visual column on whatever VL it belongs
// to.
private snappedFromCursorCol: number | null = null;
// Undo support
private undoStack = new UndoStack<EditorState>();
@@ -1246,6 +1253,7 @@ export class Editor implements Component, Focusable {
private setCursorCol(col: number): void {
this.state.cursorCol = col;
this.preferredVisualCol = null;
this.snappedFromCursorCol = null;
}
/**
@@ -1259,47 +1267,79 @@ export class Editor implements Component, Focusable {
): void {
const currentVL = visualLines[currentVisualLine];
const targetVL = visualLines[targetVisualLine];
if (!(currentVL && targetVL)) return;
if (currentVL && targetVL) {
const currentVisualCol = this.state.cursorCol - currentVL.startCol;
// When the cursor was snapped to a segment start, resolve the pre-snap
// position against the VL it belongs to. This gives the correct visual
// column even after a resize reshuffles VLs.
let currentVisualCol: number;
if (this.snappedFromCursorCol !== null) {
const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
} else {
currentVisualCol = this.state.cursorCol - currentVL.startCol;
}
// For non-last segments, clamp to length-1 to stay within the segment
const isLastSourceSegment =
currentVisualLine === visualLines.length - 1 ||
visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
// For non-last segments, clamp to length-1 to stay within the segment
const isLastSourceSegment =
currentVisualLine === visualLines.length - 1 ||
visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
const isLastTargetSegment =
targetVisualLine === visualLines.length - 1 ||
visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
const isLastTargetSegment =
targetVisualLine === visualLines.length - 1 ||
visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
const moveToVisualCol = this.computeVerticalMoveColumn(
currentVisualCol,
sourceMaxVisualCol,
targetMaxVisualCol,
);
const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
// Set cursor position
this.state.cursorLine = targetVL.logicalLine;
const targetCol = targetVL.startCol + moveToVisualCol;
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
// Set cursor position
this.state.cursorLine = targetVL.logicalLine;
const targetCol = targetVL.startCol + moveToVisualCol;
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
// Snap cursor to atomic segment boundary (e.g. paste markers)
// so the cursor never lands in the middle of a multi-grapheme unit.
// Single-grapheme segments don't need snapping.
const segments = [...this.segment(logicalLine)];
for (const seg of segments) {
if (seg.index > this.state.cursorCol) break;
if (seg.segment.length <= 1) continue;
if (this.state.cursorCol < seg.index + seg.segment.length) {
// jump to the start of the segment when moving up, to the end when moving down.
this.state.cursorCol = currentVisualLine > targetVisualLine ? seg.index : seg.index + seg.segment.length;
break;
// Snap cursor to atomic segment boundary (e.g. paste markers)
// so the cursor never lands in the middle of a multi-grapheme unit.
// Single-grapheme segments don't need snapping.
const segments = [...this.segment(logicalLine)];
for (const seg of segments) {
if (seg.index > this.state.cursorCol) break;
if (seg.segment.length <= 1) continue;
if (this.state.cursorCol < seg.index + seg.segment.length) {
const isContinuation = seg.index < targetVL.startCol;
const isMovingDown = targetVisualLine > currentVisualLine;
if (isContinuation && isMovingDown) {
// The segment started on a previous visual line, and we
// already visited it on the way down. Skip all remaining
// continuation VLs and land on the first VL past it.
const segEnd = seg.index + seg.segment.length;
let next = targetVisualLine + 1;
while (
next < visualLines.length &&
visualLines[next].logicalLine === targetVL.logicalLine &&
visualLines[next].startCol < segEnd
) {
next++;
}
if (next < visualLines.length) {
this.moveToVisualLine(visualLines, currentVisualLine, next);
return;
}
}
// Snap to the start of the segment so it gets highlighted.
// Store the pre-snap position so the next vertical move can
// resolve it to the correct visual column.
this.snappedFromCursorCol = this.state.cursorCol;
this.state.cursorCol = seg.index;
return;
}
}
// No snap occurred we moved out of the atomic segment.
this.snappedFromCursorCol = null;
}
/**
@@ -1604,28 +1644,35 @@ export class Editor implements Component, Focusable {
return visualLines;
}
/**
* Find the visual line index that contains the given logical position.
*/
private findVisualLineAt(
visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
line: number,
col: number,
): number {
for (let i = 0; i < visualLines.length; i++) {
const vl = visualLines[i];
if (!vl || vl.logicalLine !== line) continue;
const offset = col - vl.startCol;
// Cursor is in this segment if it's within range. For the last
// segment of a logical line, cursor can be at length (end position)
const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
return i;
}
}
return visualLines.length - 1;
}
/**
* Find the visual line index for the current cursor position.
*/
private findCurrentVisualLine(
visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
): number {
for (let i = 0; i < visualLines.length; i++) {
const vl = visualLines[i];
if (!vl) continue;
if (vl.logicalLine === this.state.cursorLine) {
const colInSegment = this.state.cursorCol - vl.startCol;
// Cursor is in this segment if it's within range
// For the last segment of a logical line, cursor can be at length (end position)
const isLastSegmentOfLine =
i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
if (colInSegment >= 0 && (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))) {
return i;
}
}
}
// Fallback: return last visual line
return visualLines.length - 1;
return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
}
private moveCursor(deltaLine: number, deltaCol: number): void {

View File

@@ -2746,6 +2746,17 @@ describe("Editor component", () => {
});
describe("Sticky column", () => {
// Helper: position cursor at a specific line and column
function positionCursor(editor: Editor, line: number, col: number): void {
// Go to line 0 first
for (let i = 0; i < 20; i++) editor.handleInput("\x1b[A");
// Go to target line
for (let i = 0; i < line; i++) editor.handleInput("\x1b[B");
// Go to target col
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < col; i++) editor.handleInput("\x1b[C");
}
it("preserves target column when moving up through a shorter line", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
@@ -3182,6 +3193,58 @@ describe("Editor component", () => {
editor.handleInput("\x1b[B"); // Down to line 1
assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 15 });
});
it("rewrapped lines: target fits current visual column", () => {
const tui = createTestTUI(80, 24);
const editor = new Editor(tui, defaultEditorTheme);
editor.setText("abcdefghijklmnopqr\n123456789012345678");
positionCursor(editor, 0, 18);
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 18 });
// Narrow to width 10 (layoutWidth = 9).
// Line 0 last segment has visual col max 9, line 1 first segment max 8
editor.render(10);
// Move down: cursor clamps to 8
editor.handleInput("\x1b[B");
assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 8 });
// Widen back. Move up, the current visual col wins
editor.render(80);
editor.handleInput("\x1b[A");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 });
// Preferred was cleared by the rewrapped branch
editor.handleInput("\x1b[B");
assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 8 });
});
it("rewrapped lines: target shorter than current visual column", () => {
const tui = createTestTUI(80, 24);
const editor = new Editor(tui, defaultEditorTheme);
editor.setText("abcdefghijklmnopqr\n123456789012345678\nab");
positionCursor(editor, 0, 18);
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 18 });
// Narrow to width 10 (layoutWidth = 9). Moving down clamps to col 8
editor.render(10);
editor.handleInput("\x1b[B");
assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 8 });
// Widen the editor
editor.render(80);
// Move down to short line "ab".
// preferredVisualCol is replaced with current visual col (8), cursor clamps to 2
editor.handleInput("\x1b[B");
assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 2 });
// Moving up restores to preferred col 8
editor.handleInput("\x1b[A");
assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 8 });
});
});
describe("Paste marker atomic behavior", () => {
@@ -3483,6 +3546,185 @@ describe("Editor component", () => {
assert.strictEqual(editor.getExpandedText(), pastedText);
});
it("snaps to the paste marker start when navigating down into it", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
// Line 0: long enough text to establish a sticky column
editor.setText("12345678901234567890\n\nhello ");
// Create a large paste to get a marker
const bigContent = "x".repeat(2000);
editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`);
editor.render(80);
const text = editor.getText();
const _marker = text.match(/\[paste #\d+ \d+ chars\]/)![0];
// Line 0: "12345678901234567890"
// Line 1: "" (empty)
// Line 2: "hello [paste #1 2000 chars]"
// marker starts at col 6
// Navigate to line 0, col 10
editor.handleInput("\x1b[A"); // Up to line 1
editor.handleInput("\x1b[A"); // Up to line 0
editor.handleInput("\x01"); // Ctrl+A (start of line)
for (let i = 0; i < 10; i++) editor.handleInput("\x1b[C"); // Right 10
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 10 });
// Down to empty line
editor.handleInput("\x1b[B");
assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 0 });
// Down to paste marker line - sticky col 10 falls inside marker (starts at col 6).
// Cursor should snap to start of marker (col 6), not end (col 6 + marker.length).
editor.handleInput("\x1b[B");
assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 6 });
});
it("preserves sticky column when navigating through paste marker line", () => {
const tui = createTestTUI(30, 24);
const editor = new Editor(tui, defaultEditorTheme);
// Build:
// Line 0: "1234567890123456" (16 chars)
// Line 1: "" (empty)
// Line 2: "[paste #1 2000 chars]" (22 chars, paste marker)
// Line 3: "" (empty)
// Line 4: "abcdefghijklmnop" (16 chars)
for (const ch of "1234567890123456") editor.handleInput(ch);
editor.handleInput("\n");
editor.handleInput("\n");
editor.handleInput(`\x1b[200~${"x".repeat(2000)}\x1b[201~`);
editor.handleInput("\n");
editor.handleInput("\n");
for (const ch of "abcdefghijklmnop") editor.handleInput(ch);
editor.render(30);
// Navigate to line 0, col 10
for (let i = 0; i < 4; i++) editor.handleInput("\x1b[A"); // Up to line 0
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 10; i++) editor.handleInput("\x1b[C");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 10 });
// Down to empty line - sticky col 10 established
editor.handleInput("\x1b[B");
assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 0 });
// Down to paste marker - cursor snapped to col 0 (start of marker)
editor.handleInput("\x1b[B");
assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 0 });
// Down to empty line
editor.handleInput("\x1b[B");
assert.deepStrictEqual(editor.getCursor(), { line: 3, col: 0 });
// Down to last line - should restore sticky col 10
editor.handleInput("\x1b[B");
assert.deepStrictEqual(editor.getCursor(), { line: 4, col: 10 });
});
it("does not get stuck moving down from a multi-visual-line paste marker", () => {
const tui = createTestTUI(20, 24);
const editor = new Editor(tui, defaultEditorTheme);
// Build:
// Logical line 0: "abcdefgh" + marker(21 chars) + "ijklmnopqr"
// Logical line 1: "123456789012345678"
//
// Marker "[paste #1 +100 lines]" (21 chars) is wider than the
// terminal (20). Word-wrap splits at the space before "lines",
// producing:
// VL1: abcdefgh (startCol 0, len 8)
// VL2: [paste #1 +100 (startCol 8, len 15) <- marker head
// VL3: lines]ijklmnopqr (startCol 23, len 16) <- marker tail + content
// VL4: 123456789012345678 (line 1)
//
// On VL3 the marker tail "lines]" occupies visual cols 0-5.
// Content ("i") starts at visual col 6 = logical col 29.
for (const ch of "abcdefgh") editor.handleInput(ch);
const bigContent = "line\n".repeat(100).trimEnd();
editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`);
for (const ch of "ijklmnopqr") editor.handleInput(ch);
editor.handleInput("\n");
for (const ch of "123456789012345678") editor.handleInput(ch);
editor.render(20);
const text = editor.getText();
const markerMatch = text.match(/\[paste #\d+ \+\d+ lines]/);
assert.ok(markerMatch, "paste marker should be created");
const markerLen = markerMatch[0].length; // 21
assert.ok(markerLen > 20, "marker should be wider than terminal");
const markerStart = 8;
const markerEnd = markerStart + markerLen; // 29
// Navigate to line 0, col 6 (on "g"). Preferred col 6 is past the
// marker tail on VL3, so the cursor should land on content ("i" at
// col 29) without snapping back.
editor.handleInput("\x1b[A"); // Up to line 0
editor.handleInput("\x01"); // Ctrl+A (start of line)
for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); // Right to col 6
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 });
// Down: cursor lands on paste marker start
editor.handleInput("\x1b[B");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: markerStart });
// Down again: preferred col 6 lands at VL3 col 29 ("i"), which is
// past the marker. Cursor stays on line 0.
editor.handleInput("\x1b[B");
assert.strictEqual(editor.getCursor().line, 0);
assert.strictEqual(editor.getCursor().col, markerEnd); // col 29 = "i"
// Up: back to paste marker
editor.handleInput("\x1b[A");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: markerStart });
// Up again: back to col 6 ("g")
editor.handleInput("\x1b[A");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 });
});
it("skips marker continuation VLs when preferred col falls in marker tail", () => {
const tui = createTestTUI(20, 24);
const editor = new Editor(tui, defaultEditorTheme);
// Same layout. Start at col 3 ("d"). Preferred col 3 maps to VL3
// visual col 3 which is inside the "lines]" marker tail.
// moveToVisualLine detects the continuation VL and skips to VL4
// (line 1).
// VL1: abcdefgh (startCol 0, len 8)
// VL2: [paste #1 +100 (startCol 8, len 15) <- marker head
// VL3: lines]ijklmnopqr (startCol 23, len 16) <- marker tail + content
// VL4: 123456789012345678 (line 1)
for (const ch of "abcdefgh") editor.handleInput(ch);
const bigContent = "line\n".repeat(100).trimEnd();
editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`);
for (const ch of "ijklmnopqr") editor.handleInput(ch);
editor.handleInput("\n");
for (const ch of "123456789012345678") editor.handleInput(ch);
editor.render(20);
// Navigate to line 0, col 3 (on "d")
editor.handleInput("\x1b[A"); // Up to line 0
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 3; i++) editor.handleInput("\x1b[C");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 });
// Down: marker
editor.handleInput("\x1b[B");
assert.strictEqual(editor.getCursor().col, 8);
// Down: skips VL3 (col 3 in marker tail) and lands on line 1
editor.handleInput("\x1b[B");
assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 3 });
// Round-trip back
editor.handleInput("\x1b[A");
assert.strictEqual(editor.getCursor().col, 8); // marker
editor.handleInput("\x1b[A");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 });
});
it("submits large pasted content literally", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
const pastedText = [