fix(tui): preserve column of cursor before it entered a paste marker (#3092)
This commit is contained in:
committed by
GitHub
parent
3166dae728
commit
4dda448a3f
@@ -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 {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user