diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index a72a5499..1cafc838 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -199,6 +199,9 @@ export class Editor implements Component, Focusable { // Character jump mode private jumpMode: "forward" | "backward" | null = null; + // Preferred visual column for vertical cursor movement (sticky column) + private preferredVisualCol: number | null = null; + // Undo support private undoStack: EditorState[] = []; @@ -303,7 +306,7 @@ export class Editor implements Component, Focusable { const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); this.state.lines = lines.length === 0 ? [""] : lines; this.state.cursorLine = this.state.lines.length - 1; - this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0; + this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0); // Reset scroll - render() will adjust to show cursor this.scrollOffset = 0; @@ -523,7 +526,7 @@ export class Editor implements Component, Focusable { ); this.state.lines = result.lines; this.state.cursorLine = result.cursorLine; - this.state.cursorCol = result.cursorCol; + this.setCursorCol(result.cursorCol); this.cancelAutocomplete(); if (this.onChange) this.onChange(this.getText()); } @@ -544,7 +547,7 @@ export class Editor implements Component, Focusable { ); this.state.lines = result.lines; this.state.cursorLine = result.cursorLine; - this.state.cursorCol = result.cursorCol; + this.setCursorCol(result.cursorCol); if (this.autocompletePrefix.startsWith("/")) { this.cancelAutocomplete(); @@ -890,7 +893,7 @@ export class Editor implements Component, Focusable { if (insertedLines.length === 1) { // Single line - insert at cursor position this.state.lines[this.state.cursorLine] = beforeCursor + normalized + afterCursor; - this.state.cursorCol += normalized.length; + this.setCursorCol(this.state.cursorCol + normalized.length); } else { // Multi-line insertion this.state.lines = [ @@ -911,7 +914,7 @@ export class Editor implements Component, Focusable { ]; this.state.cursorLine += insertedLines.length - 1; - this.state.cursorCol = (insertedLines[insertedLines.length - 1] || "").length; + this.setCursorCol((insertedLines[insertedLines.length - 1] || "").length); } if (this.onChange) { @@ -941,7 +944,7 @@ export class Editor implements Component, Focusable { const after = line.slice(this.state.cursorCol); this.state.lines[this.state.cursorLine] = before + char + after; - this.state.cursorCol += char.length; // Fix: increment by the length of the inserted string + this.setCursorCol(this.state.cursorCol + char.length); if (this.onChange) { this.onChange(this.getText()); @@ -1058,7 +1061,7 @@ export class Editor implements Component, Focusable { // Move cursor to start of new line this.state.cursorLine++; - this.state.cursorCol = 0; + this.setCursorCol(0); if (this.onChange) { this.onChange(this.getText()); @@ -1085,7 +1088,7 @@ export class Editor implements Component, Focusable { const after = line.slice(this.state.cursorCol); this.state.lines[this.state.cursorLine] = before + after; - this.state.cursorCol -= graphemeLength; + this.setCursorCol(this.state.cursorCol - graphemeLength); } else if (this.state.cursorLine > 0) { this.pushUndoSnapshot(); @@ -1097,7 +1100,7 @@ export class Editor implements Component, Focusable { this.state.lines.splice(this.state.cursorLine, 1); this.state.cursorLine--; - this.state.cursorCol = previousLine.length; + this.setCursorCol(previousLine.length); } if (this.onChange) { @@ -1122,15 +1125,117 @@ export class Editor implements Component, Focusable { } } + /** + * Set cursor column and clear preferredVisualCol. + * Use this for all non-vertical cursor movements to reset sticky column behavior. + */ + private setCursorCol(col: number): void { + this.state.cursorCol = col; + this.preferredVisualCol = null; + } + + /** + * Move cursor to a target visual line, applying sticky column logic. + * Shared by moveCursor() and pageScroll(). + */ + private moveToVisualLine( + visualLines: Array<{ logicalLine: number; startCol: number; length: number }>, + currentVisualLine: number, + targetVisualLine: number, + ): void { + const currentVL = visualLines[currentVisualLine]; + const targetVL = visualLines[targetVisualLine]; + + if (currentVL && targetVL) { + const 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); + + 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, + ); + + // 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); + } + } + + /** + * Compute the target visual column for vertical cursor movement. + * Implements the sticky column decision table: + * + * | P | S | T | U | Scenario | Set Preferred | Move To | + * |---|---|---|---| ---------------------------------------------------- |---------------|-------------| + * | 0 | * | 0 | - | Start nav, target fits | null | current | + * | 0 | * | 1 | - | Start nav, target shorter | current | target end | + * | 1 | 0 | 0 | 0 | Clamped, target fits preferred | null | preferred | + * | 1 | 0 | 0 | 1 | Clamped, target longer but still can't fit preferred | keep | target end | + * | 1 | 0 | 1 | - | Clamped, target even shorter | keep | target end | + * | 1 | 1 | 0 | - | Rewrapped, target fits current | null | current | + * | 1 | 1 | 1 | - | Rewrapped, target shorter than current | current | target end | + * + * Where: + * - P = preferred col is set + * - S = cursor in middle of source line (not clamped to end) + * - T = target line shorter than current visual col + * - U = target line shorter than preferred col + */ + private computeVerticalMoveColumn( + currentVisualCol: number, + sourceMaxVisualCol: number, + targetMaxVisualCol: number, + ): number { + const hasPreferred = this.preferredVisualCol !== null; // P + const cursorInMiddle = currentVisualCol < sourceMaxVisualCol; // S + const targetTooShort = targetMaxVisualCol < currentVisualCol; // T + + if (!hasPreferred || cursorInMiddle) { + if (targetTooShort) { + // Cases 2 and 7 + this.preferredVisualCol = currentVisualCol; + return targetMaxVisualCol; + } + + // Cases 1 and 6 + this.preferredVisualCol = null; + return currentVisualCol; + } + + const targetCantFitPreferred = targetMaxVisualCol < this.preferredVisualCol!; // U + if (targetTooShort || targetCantFitPreferred) { + // Cases 4 and 5 + return targetMaxVisualCol; + } + + // Case 3 + const result = this.preferredVisualCol!; + this.preferredVisualCol = null; + return result; + } + private moveToLineStart(): void { this.lastAction = null; - this.state.cursorCol = 0; + this.setCursorCol(0); } private moveToLineEnd(): void { this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; - this.state.cursorCol = currentLine.length; + this.setCursorCol(currentLine.length); } private deleteToStartOfLine(): void { @@ -1148,7 +1253,7 @@ export class Editor implements Component, Focusable { // Delete from start of line up to cursor this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol); - this.state.cursorCol = 0; + this.setCursorCol(0); } else if (this.state.cursorLine > 0) { this.pushUndoSnapshot(); @@ -1160,7 +1265,7 @@ export class Editor implements Component, Focusable { this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; this.state.lines.splice(this.state.cursorLine, 1); this.state.cursorLine--; - this.state.cursorCol = previousLine.length; + this.setCursorCol(previousLine.length); } if (this.onChange) { @@ -1218,7 +1323,7 @@ export class Editor implements Component, Focusable { this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; this.state.lines.splice(this.state.cursorLine, 1); this.state.cursorLine--; - this.state.cursorCol = previousLine.length; + this.setCursorCol(previousLine.length); } } else { this.pushUndoSnapshot(); @@ -1229,7 +1334,7 @@ export class Editor implements Component, Focusable { const oldCursorCol = this.state.cursorCol; this.moveWordBackwards(); const deleteFrom = this.state.cursorCol; - this.state.cursorCol = oldCursorCol; + this.setCursorCol(oldCursorCol); // Restore kill state for accumulation check, then save to kill ring this.lastAction = wasKill ? "kill" : null; @@ -1239,7 +1344,7 @@ export class Editor implements Component, Focusable { this.state.lines[this.state.cursorLine] = currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol); - this.state.cursorCol = deleteFrom; + this.setCursorCol(deleteFrom); } if (this.onChange) { @@ -1274,7 +1379,7 @@ export class Editor implements Component, Focusable { const oldCursorCol = this.state.cursorCol; this.moveWordForwards(); const deleteTo = this.state.cursorCol; - this.state.cursorCol = oldCursorCol; + this.setCursorCol(oldCursorCol); // Restore kill state for accumulation check, then save to kill ring this.lastAction = wasKill ? "kill" : null; @@ -1401,29 +1506,14 @@ export class Editor implements Component, Focusable { private moveCursor(deltaLine: number, deltaCol: number): void { this.lastAction = null; - const width = this.lastWidth; + const visualLines = this.buildVisualLineMap(this.lastWidth); + const currentVisualLine = this.findCurrentVisualLine(visualLines); if (deltaLine !== 0) { - // Build visual line map for navigation - const visualLines = this.buildVisualLineMap(width); - const currentVisualLine = this.findCurrentVisualLine(visualLines); - - // Calculate column position within current visual line - const currentVL = visualLines[currentVisualLine]; - const visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0; - - // Move to target visual line const targetVisualLine = currentVisualLine + deltaLine; if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) { - const targetVL = visualLines[targetVisualLine]; - if (targetVL) { - this.state.cursorLine = targetVL.logicalLine; - // Try to maintain visual column position, clamped to line length - const targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length); - const logicalLine = this.state.lines[targetVL.logicalLine] || ""; - this.state.cursorCol = Math.min(targetCol, logicalLine.length); - } + this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine); } } @@ -1436,11 +1526,17 @@ export class Editor implements Component, Focusable { const afterCursor = currentLine.slice(this.state.cursorCol); const graphemes = [...segmenter.segment(afterCursor)]; const firstGrapheme = graphemes[0]; - this.state.cursorCol += firstGrapheme ? firstGrapheme.segment.length : 1; + this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1)); } else if (this.state.cursorLine < this.state.lines.length - 1) { // Wrap to start of next logical line this.state.cursorLine++; - this.state.cursorCol = 0; + this.setCursorCol(0); + } else { + // At end of last line - can't move, but set preferredVisualCol for up/down navigation + const currentVL = visualLines[currentVisualLine]; + if (currentVL) { + this.preferredVisualCol = this.state.cursorCol - currentVL.startCol; + } } } else { // Moving left - move by one grapheme (handles emojis, combining characters, etc.) @@ -1448,12 +1544,12 @@ export class Editor implements Component, Focusable { const beforeCursor = currentLine.slice(0, this.state.cursorCol); const graphemes = [...segmenter.segment(beforeCursor)]; const lastGrapheme = graphemes[graphemes.length - 1]; - this.state.cursorCol -= lastGrapheme ? lastGrapheme.segment.length : 1; + this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1)); } else if (this.state.cursorLine > 0) { // Wrap to end of previous logical line this.state.cursorLine--; const prevLine = this.state.lines[this.state.cursorLine] || ""; - this.state.cursorCol = prevLine.length; + this.setCursorCol(prevLine.length); } } } @@ -1465,29 +1561,14 @@ export class Editor implements Component, Focusable { */ private pageScroll(direction: -1 | 1): void { this.lastAction = null; - const width = this.lastWidth; const terminalRows = this.tui.terminal.rows; const pageSize = Math.max(5, Math.floor(terminalRows * 0.3)); - // Build visual line map - const visualLines = this.buildVisualLineMap(width); + const visualLines = this.buildVisualLineMap(this.lastWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); - - // Calculate target visual line const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * pageSize)); - // Move cursor to target visual line - const targetVL = visualLines[targetVisualLine]; - if (targetVL) { - // Preserve column position within the line - const currentVL = visualLines[currentVisualLine]; - const visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0; - - this.state.cursorLine = targetVL.logicalLine; - const targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length); - const logicalLine = this.state.lines[targetVL.logicalLine] || ""; - this.state.cursorCol = Math.min(targetCol, logicalLine.length); - } + this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine); } private moveWordBackwards(): void { @@ -1499,7 +1580,7 @@ export class Editor implements Component, Focusable { if (this.state.cursorLine > 0) { this.state.cursorLine--; const prevLine = this.state.lines[this.state.cursorLine] || ""; - this.state.cursorCol = prevLine.length; + this.setCursorCol(prevLine.length); } return; } @@ -1532,7 +1613,7 @@ export class Editor implements Component, Focusable { } } - this.state.cursorCol = newCol; + this.setCursorCol(newCol); } /** @@ -1586,7 +1667,7 @@ export class Editor implements Component, Focusable { const before = currentLine.slice(0, this.state.cursorCol); const after = currentLine.slice(this.state.cursorCol); this.state.lines[this.state.cursorLine] = before + text + after; - this.state.cursorCol += text.length; + this.setCursorCol(this.state.cursorCol + text.length); } else { // Multi-line insert const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1607,7 +1688,7 @@ export class Editor implements Component, Focusable { // Update cursor position this.state.cursorLine = lastLineIndex; - this.state.cursorCol = (lines[lines.length - 1] || "").length; + this.setCursorCol((lines[lines.length - 1] || "").length); } if (this.onChange) { @@ -1632,7 +1713,7 @@ export class Editor implements Component, Focusable { const before = currentLine.slice(0, this.state.cursorCol - deleteLen); const after = currentLine.slice(this.state.cursorCol); this.state.lines[this.state.cursorLine] = before + after; - this.state.cursorCol -= deleteLen; + this.setCursorCol(this.state.cursorCol - deleteLen); } else { // Multi-line delete - cursor is at end of last yanked line const startLine = this.state.cursorLine - (yankLines.length - 1); @@ -1649,7 +1730,7 @@ export class Editor implements Component, Focusable { // Update cursor this.state.cursorLine = startLine; - this.state.cursorCol = startCol; + this.setCursorCol(startCol); } if (this.onChange) { @@ -1698,6 +1779,7 @@ export class Editor implements Component, Focusable { const snapshot = this.undoStack.pop()!; this.restoreUndoSnapshot(snapshot); this.lastAction = null; + this.preferredVisualCol = null; if (this.onChange) { this.onChange(this.getText()); } @@ -1730,7 +1812,7 @@ export class Editor implements Component, Focusable { if (idx !== -1) { this.state.cursorLine = lineIdx; - this.state.cursorCol = idx; + this.setCursorCol(idx); return; } } @@ -1745,7 +1827,7 @@ export class Editor implements Component, Focusable { if (this.state.cursorCol >= currentLine.length) { if (this.state.cursorLine < this.state.lines.length - 1) { this.state.cursorLine++; - this.state.cursorCol = 0; + this.setCursorCol(0); } return; } @@ -1754,10 +1836,11 @@ export class Editor implements Component, Focusable { const segments = segmenter.segment(textAfterCursor); const iterator = segments[Symbol.iterator](); let next = iterator.next(); + let newCol = this.state.cursorCol; // Skip leading whitespace while (!next.done && isWhitespaceChar(next.value.segment)) { - this.state.cursorCol += next.value.segment.length; + newCol += next.value.segment.length; next = iterator.next(); } @@ -1766,17 +1849,19 @@ export class Editor implements Component, Focusable { if (isPunctuationChar(firstGrapheme)) { // Skip punctuation run while (!next.done && isPunctuationChar(next.value.segment)) { - this.state.cursorCol += next.value.segment.length; + newCol += next.value.segment.length; next = iterator.next(); } } else { // Skip word run while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) { - this.state.cursorCol += next.value.segment.length; + newCol += next.value.segment.length; next = iterator.next(); } } } + + this.setCursorCol(newCol); } // Slash menu only allowed when all other lines are empty (no mixed content) @@ -1886,7 +1971,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ ); this.state.lines = result.lines; this.state.cursorLine = result.cursorLine; - this.state.cursorCol = result.cursorCol; + this.setCursorCol(result.cursorCol); if (this.onChange) this.onChange(this.getText()); return; } diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 45b7dbeb..842a1666 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -2186,4 +2186,443 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "xhello world"); }); }); + + describe("Sticky column", () => { + it("preserves target column when moving up through a shorter line", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Line 0: "2222222222x222" (x at col 10) + // Line 1: "" (empty) + // Line 2: "1111111111_111111111111" (_ at col 10) + editor.setText("2222222222x222\n\n1111111111_111111111111"); + + // Position cursor on _ (line 2, col 10) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 23 }); // At end + editor.handleInput("\x01"); // Ctrl+A - go to start of line + for (let i = 0; i < 10; i++) editor.handleInput("\x1b[C"); // Move right to col 10 + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 10 }); + + // Press Up - should move to empty line (col clamped to 0) + editor.handleInput("\x1b[A"); // Up arrow + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 0 }); + + // Press Up again - should move to line 0 at col 10 (on 'x') + editor.handleInput("\x1b[A"); // Up arrow + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 10 }); + }); + + it("preserves target column when moving down through a shorter line", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1111111111_111\n\n2222222222x222222222222"); + + // Position cursor on _ (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 + for (let i = 0; i < 10; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 10 }); + + // Press Down - should move to empty line (col clamped to 0) + editor.handleInput("\x1b[B"); // Down arrow + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 0 }); + + // Press Down again - should move to line 2 at col 10 (on 'x') + editor.handleInput("\x1b[B"); // Down arrow + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 10 }); + }); + + it("resets sticky column on horizontal movement (left arrow)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 2, col 5 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 5 }); + + // Move up through empty line + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 5 (sticky) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); + + // Move left - resets sticky column + editor.handleInput("\x1b[D"); // Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 4 }); + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 4 (new sticky from col 4) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 4 }); + }); + + it("resets sticky column on horizontal movement (right arrow)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 0, col 5 + editor.handleInput("\x1b[A"); // Up to line 1 + editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); + + // Move down through empty line + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 5 (sticky) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 5 }); + + // Move right - resets sticky column + editor.handleInput("\x1b[C"); // Right + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 6 }); + + // Move up twice + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 6 (new sticky from col 6) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); + }); + + it("resets sticky column on typing", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 2, col 8 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + + // Move up through empty line + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x1b[A"); // Up - line 0, col 8 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); + + // Type a character - resets sticky column + editor.handleInput("X"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 9 }); + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 9 (new sticky from col 9) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 9 }); + }); + + it("resets sticky column on backspace", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 2, col 8 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + + // Move up through empty line + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x1b[A"); // Up - line 0, col 8 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); + + // Backspace - resets sticky column + editor.handleInput("\x7f"); // Backspace + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 7 (new sticky from col 7) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 7 }); + }); + + it("resets sticky column on Ctrl+A (move to line start)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 2, col 8 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + + // Move up - establishes sticky col 8 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + + // Ctrl+A - resets sticky column to 0 + editor.handleInput("\x01"); // Ctrl+A + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 0 }); + + // Move up + editor.handleInput("\x1b[A"); // Up - line 0, col 0 (new sticky from col 0) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + }); + + it("resets sticky column on Ctrl+E (move to line end)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("12345\n\n1234567890"); + + // Start at line 2, col 3 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 3; i++) editor.handleInput("\x1b[C"); + + // Move up through empty line - establishes sticky col 3 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 3 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + + // Ctrl+E - resets sticky column to end + editor.handleInput("\x05"); // Ctrl+E + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 5 (new sticky from col 5) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 5 }); + }); + + it("resets sticky column on word movement (Ctrl+Left)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world\n\nhello world"); + + // Start at end of line 2 (col 11) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 11 }); + + // Move up through empty line - establishes sticky col 11 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 11 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 11 }); + + // Ctrl+Left - word movement resets sticky column + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); // Before "world" + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 6 (new sticky from col 6) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 6 }); + }); + + it("resets sticky column on word movement (Ctrl+Right)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world\n\nhello world"); + + // Start at line 0, col 0 + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x01"); // Ctrl+A + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // Move down through empty line - establishes sticky col 0 + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 0 + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 0 }); + + // Ctrl+Right - word movement resets sticky column + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 5 }); // After "hello" + + // Move up twice + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 5 (new sticky from col 5) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); + }); + + it("resets sticky column on undo", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Go to line 0, col 8 + editor.handleInput("\x1b[A"); // Up to line 1 + editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); + + // Move down through empty line - establishes sticky col 8 + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 8 (sticky) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 8 }); + + // Type something to create undo state - this clears sticky and sets col to 9 + editor.handleInput("X"); + assert.strictEqual(editor.getText(), "1234567890\n\n12345678X90"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 9 }); + + // Move up - establishes new sticky col 9 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 9 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 9 }); + + // Undo - resets sticky column and restores cursor to line 2, col 8 + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "1234567890\n\n1234567890"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 8 }); + + // Move up - should capture new sticky from restored col 8, not old col 9 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 8 (new sticky from restored position) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); + }); + + it("handles multiple consecutive up/down movements", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\nab\ncd\nef\n1234567890"); + + // Start at line 4, col 7 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 7; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 4, col: 7 }); + + // Move up multiple times through short lines + editor.handleInput("\x1b[A"); // Up - line 3, col 2 (clamped) + editor.handleInput("\x1b[A"); // Up - line 2, col 2 (clamped) + editor.handleInput("\x1b[A"); // Up - line 1, col 2 (clamped) + editor.handleInput("\x1b[A"); // Up - line 0, col 7 (restored) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); + + // Move down multiple times - sticky should still be 7 + editor.handleInput("\x1b[B"); // Down - line 1, col 2 + editor.handleInput("\x1b[B"); // Down - line 2, col 2 + editor.handleInput("\x1b[B"); // Down - line 3, col 2 + editor.handleInput("\x1b[B"); // Down - line 4, col 7 (restored) + assert.deepStrictEqual(editor.getCursor(), { line: 4, col: 7 }); + }); + + it("moves correctly through wrapped visual lines without getting stuck", () => { + const tui = createTestTUI(15, 24); // Narrow terminal + const editor = new Editor(tui, defaultEditorTheme); + + // Line 0: short + // Line 1: 30 chars = wraps to 3 visual lines at width 10 (after padding) + editor.setText("short\n123456789012345678901234567890"); + editor.render(15); // This gives 14 layout width + + // Position at end of line 1 (col 30) + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 30 }); + + // Move up repeatedly - should traverse all visual lines of the wrapped text + // and eventually reach line 0 + editor.handleInput("\x1b[A"); // Up - to previous visual line within line 1 + assert.strictEqual(editor.getCursor().line, 1); + + editor.handleInput("\x1b[A"); // Up - another visual line + assert.strictEqual(editor.getCursor().line, 1); + + editor.handleInput("\x1b[A"); // Up - should reach line 0 + assert.strictEqual(editor.getCursor().line, 0); + }); + + it("handles setText resetting sticky column", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Establish sticky column + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + editor.handleInput("\x1b[A"); // Up + + // setText should reset sticky column + editor.setText("abcdefghij\n\nabcdefghij"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 10 }); // At end + + // Move up - should capture new sticky from current position (10) + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 10 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 10 }); + }); + + it("sets preferredVisualCol when pressing right at end of prompt (last line)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Line 0: 20 chars with 'x' at col 10 + // Line 1: empty + // Line 2: 10 chars ending with '_' + editor.setText("111111111x1111111111\n\n333333333_"); + + // Go to line 0, press Ctrl+E (end of line) - col 20 + editor.handleInput("\x1b[A"); // Up to line 1 + editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x05"); // Ctrl+E - move to end of line + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 20 }); + + // Move down to line 2 - cursor clamped to col 10 (end of line) + editor.handleInput("\x1b[B"); // Down to line 1, col 0 + editor.handleInput("\x1b[B"); // Down to line 2, col 10 (clamped) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 10 }); + + // Press Right at end of prompt - nothing visible happens, but sets preferredVisualCol to 10 + editor.handleInput("\x1b[C"); // Right - can't move, but sets preferredVisualCol + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 10 }); // Still at same position + + // Move up twice to line 0 - should use preferredVisualCol (10) to land on 'x' + editor.handleInput("\x1b[A"); // Up to line 1, col 0 + editor.handleInput("\x1b[A"); // Up to line 0, col 10 (on 'x') + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 10 }); + }); + + it("handles editor resizes when preferredVisualCol is on the same line", () => { + // Create editor with wider terminal + const tui = createTestTUI(80, 24); + const editor = new Editor(tui, defaultEditorTheme); + + editor.setText("12345678901234567890\n\n12345678901234567890"); + + // Start at line 2, col 15 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 15; i++) editor.handleInput("\x1b[C"); + + // Move up through empty line - establishes sticky col 15 + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x1b[A"); // Up - line 0, col 15 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 15 }); + + // Render with narrower width to simulate resize + editor.render(12); // Width 12 + + // Move down - sticky should be clamped to new width + editor.handleInput("\x1b[B"); // Down - line 1 + editor.handleInput("\x1b[B"); // Down - line 2, col should be clamped + assert.equal(editor.getCursor().col, 4); + }); + + it("handles editor resizes when preferredVisualCol is on a different line", () => { + const tui = createTestTUI(80, 24); + const editor = new Editor(tui, defaultEditorTheme); + + // Create a line that wraps into multiple visual lines at width 10 + // "12345678901234567890" = 20 chars, wraps to 2 visual lines at width 10 + editor.setText("short\n12345678901234567890"); + + // Go to line 1, col 15 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 15; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 15 }); + + // Move up to establish sticky col 15 + editor.handleInput("\x1b[A"); // Up to line 0 + // Line 0 has only 5 chars, so cursor at col 5 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); + + // Narrow the editor + editor.render(10); + + // Move down - preferredVisualCol was 15, but width is 10 + // Should land on line 1, clamped to width (visual col 9, which is logical col 9) + editor.handleInput("\x1b[B"); // Down to line 1 + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 8 }); + + // Move up + editor.handleInput("\x1b[A"); // Up - should go to line 0 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); // Line 0 only has 5 chars + + // Restore the original width + editor.render(80); + + // Move down - preferredVisualCol was kept at 15 + editor.handleInput("\x1b[B"); // Down to line 1 + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 15 }); + }); + }); });