50a9f872de
* fix(tui): fixed windows numpad input * numlock handles in kitty CSI p2 * fix(tui): preserved modified numlock keypad nav * fix(tui): restored autocomplete keystroke triggers --------- Co-authored-by: Brit <lol@no.com>
1431 lines
52 KiB
TypeScript
1431 lines
52 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
||
import { stripVTControlCharacters } from "node:util";
|
||
import { Editor } from "@oh-my-pi/pi-tui/components/editor";
|
||
import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
|
||
import { defaultEditorTheme } from "./test-themes";
|
||
|
||
describe("Editor component", () => {
|
||
describe("Prompt history navigation", () => {
|
||
it("does nothing on Up arrow when history is empty", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.handleInput("\x1b[A"); // Up arrow
|
||
|
||
expect(editor.getText()).toBe("");
|
||
});
|
||
|
||
it("shows most recent history entry on Up arrow when editor is empty", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.addToHistory("first prompt");
|
||
editor.addToHistory("second prompt");
|
||
|
||
editor.handleInput("\x1b[A"); // Up arrow
|
||
|
||
expect(editor.getText()).toBe("second prompt");
|
||
});
|
||
|
||
it("cycles through history entries on repeated Up arrow", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.addToHistory("first");
|
||
editor.addToHistory("second");
|
||
editor.addToHistory("third");
|
||
|
||
editor.handleInput("\x1b[A"); // Up - shows "third"
|
||
expect(editor.getText()).toBe("third");
|
||
|
||
editor.handleInput("\x1b[A"); // Up - shows "second"
|
||
expect(editor.getText()).toBe("second");
|
||
|
||
editor.handleInput("\x1b[A"); // Up - shows "first"
|
||
expect(editor.getText()).toBe("first");
|
||
|
||
editor.handleInput("\x1b[A"); // Up - stays at "first" (oldest)
|
||
expect(editor.getText()).toBe("first");
|
||
});
|
||
|
||
it("returns to empty editor on Down arrow after browsing history", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.addToHistory("prompt");
|
||
|
||
editor.handleInput("\x1b[A"); // Up - shows "prompt"
|
||
expect(editor.getText()).toBe("prompt");
|
||
|
||
editor.handleInput("\x1b[B"); // Down - clears editor
|
||
expect(editor.getText()).toBe("");
|
||
});
|
||
|
||
it("navigates forward through history with Down arrow", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.addToHistory("first");
|
||
editor.addToHistory("second");
|
||
editor.addToHistory("third");
|
||
|
||
// Go to oldest
|
||
editor.handleInput("\x1b[A"); // third
|
||
editor.handleInput("\x1b[A"); // second
|
||
editor.handleInput("\x1b[A"); // first
|
||
|
||
// Navigate back
|
||
editor.handleInput("\x1b[B"); // second
|
||
expect(editor.getText()).toBe("second");
|
||
|
||
editor.handleInput("\x1b[B"); // third
|
||
expect(editor.getText()).toBe("third");
|
||
|
||
editor.handleInput("\x1b[B"); // empty
|
||
expect(editor.getText()).toBe("");
|
||
});
|
||
|
||
it("exits history mode when typing a character", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.addToHistory("old prompt");
|
||
|
||
editor.handleInput("\x1b[A"); // Up - shows "old prompt"
|
||
editor.handleInput("x"); // Type a character - exits history mode
|
||
|
||
expect(editor.getText()).toBe("old promptx");
|
||
});
|
||
|
||
it("exits history mode on setText", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.addToHistory("first");
|
||
editor.addToHistory("second");
|
||
|
||
editor.handleInput("\x1b[A"); // Up - shows "second"
|
||
editor.setText(""); // External clear
|
||
|
||
// Up should start fresh from most recent
|
||
editor.handleInput("\x1b[A");
|
||
expect(editor.getText()).toBe("second");
|
||
});
|
||
|
||
it("does not add empty strings to history", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.addToHistory("");
|
||
editor.addToHistory(" ");
|
||
editor.addToHistory("valid");
|
||
|
||
editor.handleInput("\x1b[A");
|
||
expect(editor.getText()).toBe("valid");
|
||
|
||
// Should not have more entries
|
||
editor.handleInput("\x1b[A");
|
||
expect(editor.getText()).toBe("valid");
|
||
});
|
||
|
||
it("does not add consecutive duplicates to history", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.addToHistory("same");
|
||
editor.addToHistory("same");
|
||
editor.addToHistory("same");
|
||
|
||
editor.handleInput("\x1b[A"); // "same"
|
||
expect(editor.getText()).toBe("same");
|
||
|
||
editor.handleInput("\x1b[A"); // stays at "same" (only one entry)
|
||
expect(editor.getText()).toBe("same");
|
||
});
|
||
|
||
it("allows non-consecutive duplicates in history", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.addToHistory("first");
|
||
editor.addToHistory("second");
|
||
editor.addToHistory("first"); // Not consecutive, should be added
|
||
|
||
editor.handleInput("\x1b[A"); // "first"
|
||
expect(editor.getText()).toBe("first");
|
||
|
||
editor.handleInput("\x1b[A"); // "second"
|
||
expect(editor.getText()).toBe("second");
|
||
|
||
editor.handleInput("\x1b[A"); // "first" (older one)
|
||
expect(editor.getText()).toBe("first");
|
||
});
|
||
|
||
it("uses cursor movement instead of history when editor has content", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.addToHistory("history item");
|
||
editor.setText("line1\nline2");
|
||
|
||
// Cursor is at end of line2, Up should move to line1
|
||
editor.handleInput("\x1b[A"); // Up - cursor movement
|
||
|
||
// Insert character to verify cursor position
|
||
editor.handleInput("X");
|
||
|
||
// X should be inserted in line1, not replace with history
|
||
expect(editor.getText()).toBe("line1X\nline2");
|
||
});
|
||
|
||
it("limits history to 100 entries", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
// Add 105 entries
|
||
for (let i = 0; i < 105; i++) {
|
||
editor.addToHistory(`prompt ${i}`);
|
||
}
|
||
|
||
// Navigate to oldest
|
||
for (let i = 0; i < 100; i++) {
|
||
editor.handleInput("\x1b[A");
|
||
}
|
||
|
||
// Should be at entry 5 (oldest kept), not entry 0
|
||
expect(editor.getText()).toBe("prompt 5");
|
||
|
||
// One more Up should not change anything
|
||
editor.handleInput("\x1b[A");
|
||
expect(editor.getText()).toBe("prompt 5");
|
||
});
|
||
|
||
it("anchors history entry at top when navigating with Up", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.addToHistory("line1\nline2\nline3");
|
||
editor.handleInput("\x1b[A");
|
||
|
||
expect(editor.getText()).toBe("line1\nline2\nline3");
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 0 });
|
||
});
|
||
|
||
it("anchors history entry at bottom when navigating with Down", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.addToHistory("older");
|
||
editor.addToHistory("line1\nline2\nline3");
|
||
|
||
editor.handleInput("\x1b[A"); // latest, anchored at top
|
||
editor.handleInput("\x1b[A"); // older, anchored at top
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 0 });
|
||
|
||
editor.handleInput("\x1b[B"); // newer, anchored at bottom
|
||
expect(editor.getText()).toBe("line1\nline2\nline3");
|
||
expect(editor.getCursor()).toEqual({ line: 2, col: 5 });
|
||
});
|
||
|
||
it("still allows in-entry cursor movement while browsing history", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.addToHistory("line1\nline2\nline3");
|
||
editor.handleInput("\x1b[A"); // top anchor
|
||
|
||
editor.handleInput("\x1b[B"); // move within entry
|
||
expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
|
||
|
||
editor.handleInput("\x1b[B");
|
||
editor.handleInput("\x1b[B"); // at bottom, exit history
|
||
expect(editor.getText()).toBe("");
|
||
});
|
||
});
|
||
|
||
describe("public state accessors", () => {
|
||
it("returns cursor position", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 0 });
|
||
|
||
editor.handleInput("a");
|
||
editor.handleInput("b");
|
||
editor.handleInput("c");
|
||
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 3 });
|
||
|
||
editor.handleInput("\x1b[D"); // Left
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 2 });
|
||
});
|
||
|
||
it("returns lines as a defensive copy", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setText("a\nb");
|
||
|
||
const lines = editor.getLines();
|
||
expect(lines).toEqual(["a", "b"]);
|
||
|
||
lines[0] = "mutated";
|
||
expect(editor.getLines()).toEqual(["a", "b"]);
|
||
});
|
||
});
|
||
|
||
describe("autocomplete triggers", () => {
|
||
it("triggers slash-command autocomplete when typing slash", async () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const { promise, resolve } = Promise.withResolvers<string>();
|
||
|
||
editor.setAutocompleteProvider({
|
||
async getSuggestions(lines, cursorLine, cursorCol) {
|
||
const currentLine = lines[cursorLine] ?? "";
|
||
resolve(currentLine.slice(0, cursorCol));
|
||
return { items: [{ label: "/help", value: "/help" }], prefix: "/" };
|
||
},
|
||
applyCompletion(lines, cursorLine, cursorCol) {
|
||
return { lines, cursorLine, cursorCol };
|
||
},
|
||
});
|
||
|
||
editor.handleInput("/");
|
||
|
||
await expect(promise).resolves.toBe("/");
|
||
});
|
||
|
||
it("triggers file-reference autocomplete when typing at-sign", async () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const { promise, resolve } = Promise.withResolvers<string>();
|
||
|
||
editor.setAutocompleteProvider({
|
||
async getSuggestions(lines, cursorLine, cursorCol) {
|
||
const currentLine = lines[cursorLine] ?? "";
|
||
resolve(currentLine.slice(0, cursorCol));
|
||
return { items: [{ label: "src/", value: "src/" }], prefix: "@" };
|
||
},
|
||
applyCompletion(lines, cursorLine, cursorCol) {
|
||
return { lines, cursorLine, cursorCol };
|
||
},
|
||
});
|
||
|
||
editor.handleInput("@");
|
||
|
||
await expect(promise).resolves.toBe("@");
|
||
});
|
||
});
|
||
|
||
describe("Unicode text editing behavior", () => {
|
||
it("inserts mixed ASCII, umlauts, and emojis as literal text", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.handleInput("H");
|
||
editor.handleInput("e");
|
||
editor.handleInput("l");
|
||
editor.handleInput("l");
|
||
editor.handleInput("o");
|
||
editor.handleInput(" ");
|
||
editor.handleInput("ä");
|
||
editor.handleInput("ö");
|
||
editor.handleInput("ü");
|
||
editor.handleInput(" ");
|
||
editor.handleInput("😀");
|
||
|
||
const text = editor.getText();
|
||
expect(text).toBe("Hello äöü 😀");
|
||
});
|
||
|
||
it("inserts NumLock keypad digits instead of treating them as navigation", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.handleInput("a");
|
||
editor.handleInput("\x1b[57400;129u");
|
||
|
||
expect(editor.getText()).toBe("a1");
|
||
});
|
||
|
||
it("deletes single-code-unit unicode characters (umlauts) with Backspace", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.handleInput("ä");
|
||
editor.handleInput("ö");
|
||
editor.handleInput("ü");
|
||
|
||
// Delete the last character (ü)
|
||
editor.handleInput("\x7f"); // Backspace
|
||
|
||
const text = editor.getText();
|
||
expect(text).toBe("äö");
|
||
});
|
||
|
||
it("deletes multi-code-unit emojis with single Backspace", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.handleInput("😀");
|
||
editor.handleInput("👍");
|
||
|
||
// Delete the last emoji (👍) - single backspace deletes whole grapheme cluster
|
||
editor.handleInput("\x7f"); // Backspace
|
||
|
||
const text = editor.getText();
|
||
expect(text).toBe("😀");
|
||
});
|
||
|
||
it("inserts characters at the correct position after cursor movement over umlauts", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.handleInput("ä");
|
||
editor.handleInput("ö");
|
||
editor.handleInput("ü");
|
||
|
||
// Move cursor left twice
|
||
editor.handleInput("\x1b[D"); // Left arrow
|
||
editor.handleInput("\x1b[D"); // Left arrow
|
||
|
||
// Insert 'x' in the middle
|
||
editor.handleInput("x");
|
||
|
||
const text = editor.getText();
|
||
expect(text).toBe("äxöü");
|
||
});
|
||
|
||
it("moves cursor across multi-code-unit emojis with single arrow key", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.handleInput("😀");
|
||
editor.handleInput("👍");
|
||
editor.handleInput("🎉");
|
||
|
||
// Move cursor left over last emoji (🎉) - single arrow moves over whole grapheme
|
||
editor.handleInput("\x1b[D"); // Left arrow
|
||
|
||
// Move cursor left over second emoji (👍)
|
||
editor.handleInput("\x1b[D");
|
||
|
||
// Insert 'x' between first and second emoji
|
||
editor.handleInput("x");
|
||
|
||
const text = editor.getText();
|
||
expect(text).toBe("😀x👍🎉");
|
||
});
|
||
|
||
it("preserves umlauts across line breaks", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.handleInput("ä");
|
||
editor.handleInput("ö");
|
||
editor.handleInput("ü");
|
||
editor.handleInput("\n"); // new line
|
||
editor.handleInput("Ä");
|
||
editor.handleInput("Ö");
|
||
editor.handleInput("Ü");
|
||
|
||
const text = editor.getText();
|
||
expect(text).toBe("äöü\nÄÖÜ");
|
||
});
|
||
|
||
it("replaces the entire document with unicode text via setText (paste simulation)", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
// Simulate bracketed paste / programmatic replacement
|
||
editor.setText("Hällö Wörld! 😀 äöüÄÖÜß");
|
||
|
||
const text = editor.getText();
|
||
expect(text).toBe("Hällö Wörld! 😀 äöüÄÖÜß");
|
||
});
|
||
|
||
it("moves cursor to document start on Ctrl+A and inserts at the beginning", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.handleInput("a");
|
||
editor.handleInput("b");
|
||
editor.handleInput("\x01"); // Ctrl+A (move to start)
|
||
editor.handleInput("x"); // Insert at start
|
||
|
||
const text = editor.getText();
|
||
expect(text).toBe("xab");
|
||
});
|
||
|
||
it("deletes words correctly with Ctrl+W and Alt+Backspace", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
// Basic word deletion
|
||
editor.setText("foo bar baz");
|
||
editor.handleInput("\x17"); // Ctrl+W
|
||
expect(editor.getText()).toBe("foo bar ");
|
||
|
||
// Trailing whitespace
|
||
editor.setText("foo bar ");
|
||
editor.handleInput("\x17");
|
||
expect(editor.getText()).toBe("foo ");
|
||
|
||
// Punctuation run
|
||
editor.setText("foo bar...");
|
||
editor.handleInput("\x17");
|
||
expect(editor.getText()).toBe("foo bar");
|
||
|
||
// Delete across multiple lines
|
||
editor.setText("line one\nline two");
|
||
editor.handleInput("\x17");
|
||
expect(editor.getText()).toBe("line one\nline ");
|
||
|
||
// Delete empty line (merge)
|
||
editor.setText("line one\n");
|
||
editor.handleInput("\x17");
|
||
expect(editor.getText()).toBe("line one");
|
||
|
||
// Grapheme safety (emoji as a word)
|
||
editor.setText("foo 😀😀 bar");
|
||
editor.handleInput("\x17");
|
||
expect(editor.getText()).toBe("foo 😀😀 ");
|
||
editor.handleInput("\x17");
|
||
expect(editor.getText()).toBe("foo ");
|
||
|
||
// Alt+Backspace
|
||
editor.setText("foo bar");
|
||
editor.handleInput("\x1b\x7f"); // Alt+Backspace (legacy)
|
||
expect(editor.getText()).toBe("foo ");
|
||
});
|
||
|
||
it("navigates words correctly with Ctrl+Left/Right", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.setText("foo bar... baz");
|
||
// Cursor at end
|
||
|
||
// Move left over baz
|
||
editor.handleInput("\x1b[1;5D"); // Ctrl+Left
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 11 }); // after '...'
|
||
|
||
// Move left over punctuation
|
||
editor.handleInput("\x1b[1;5D"); // Ctrl+Left
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 7 }); // after 'bar'
|
||
|
||
// Move left over bar
|
||
editor.handleInput("\x1b[1;5D"); // Ctrl+Left
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 4 }); // after 'foo '
|
||
|
||
// Move right over bar
|
||
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 7 }); // at end of 'bar'
|
||
|
||
// Move right over punctuation run
|
||
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 10 }); // after '...'
|
||
|
||
// Move right skips space and lands after baz
|
||
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 14 }); // end of line
|
||
|
||
// Test forward from start with leading whitespace
|
||
editor.setText(" foo bar");
|
||
editor.handleInput("\x01"); // Ctrl+A to go to start
|
||
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 6 }); // after 'foo'
|
||
});
|
||
});
|
||
|
||
describe("Grapheme-aware text wrapping", () => {
|
||
it("wraps lines correctly when text contains wide emojis", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 20;
|
||
|
||
// ✅ is 2 columns wide, so "Hello ✅ World" is 14 columns
|
||
editor.setText("Hello ✅ World");
|
||
const lines = editor.render(width);
|
||
|
||
// All content lines (between borders) should fit within width
|
||
for (let i = 1; i < lines.length - 1; i++) {
|
||
const lineWidth = visibleWidth(lines[i]!);
|
||
expect(lineWidth).toBeLessThanOrEqual(width);
|
||
}
|
||
});
|
||
|
||
it("wraps long text with emojis at correct positions", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 10;
|
||
|
||
// Each ✅ is 2 columns. "✅✅✅✅✅" = 10 columns, fits exactly
|
||
// "✅✅✅✅✅✅" = 12 columns, needs wrap
|
||
editor.setText("✅✅✅✅✅✅");
|
||
const lines = editor.render(width);
|
||
|
||
// Should have 2 content lines (plus 2 border lines)
|
||
// First line: 5 emojis (10 cols), second line: 1 emoji (2 cols) + padding
|
||
for (let i = 1; i < lines.length - 1; i++) {
|
||
const lineWidth = visibleWidth(lines[i]!);
|
||
expect(lineWidth).toBeLessThanOrEqual(width);
|
||
}
|
||
});
|
||
|
||
it("wraps CJK characters correctly (each is 2 columns wide)", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 16; // 6 chars for borders, 10 for content
|
||
|
||
// Each CJK char is 2 columns. "日本語テスト" = 6 chars = 12 columns
|
||
editor.setText("日本語テスト");
|
||
const lines = editor.render(width);
|
||
|
||
// All content lines (including last which has bottom border) should be correct width
|
||
for (let i = 1; i < lines.length; i++) {
|
||
const lineWidth = visibleWidth(lines[i]!);
|
||
expect(lineWidth).toBeLessThanOrEqual(width);
|
||
}
|
||
|
||
// Verify content split correctly - extract content between borders
|
||
// Middle lines use "│ " and " │", last line uses "╰─ " and " ─╯"
|
||
const contentLines = lines.slice(1).map(l => {
|
||
const stripped = stripVTControlCharacters(l);
|
||
// Both border styles use 3 chars on each side
|
||
return stripped.slice(3, -3).trim();
|
||
});
|
||
expect(contentLines.length).toBe(2);
|
||
expect(contentLines[0]).toBe("日本語テス"); // 5 chars = 10 columns
|
||
// Last line has cursor (|) which we need to strip
|
||
expect(contentLines[1]?.replace("|", "")).toBe("ト"); // 1 char = 2 columns (+ cursor + padding)
|
||
});
|
||
|
||
it("handles mixed ASCII and wide characters in wrapping", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 15;
|
||
|
||
// "Test ✅ OK 日本" = 4 + 1 + 2 + 1 + 2 + 1 + 4 = 15 columns (fits exactly)
|
||
editor.setText("Test ✅ OK 日本");
|
||
const lines = editor.render(width);
|
||
|
||
// Should fit in one content line
|
||
const contentLines = lines.slice(1, -1);
|
||
expect(contentLines.length).toBe(1);
|
||
|
||
const lineWidth = visibleWidth(contentLines[0]!);
|
||
expect(lineWidth).toBeLessThanOrEqual(width);
|
||
});
|
||
|
||
it("renders cursor correctly on wide characters", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 20;
|
||
|
||
editor.setText("A✅B");
|
||
// Cursor should be at end (after B)
|
||
const lines = editor.render(width);
|
||
|
||
// The cursor (blinking bar) should be visible
|
||
const contentLine = lines[1]!;
|
||
expect(contentLine.includes("\x1b[5m")).toBeTruthy();
|
||
|
||
// Line should still be correct width
|
||
expect(visibleWidth(contentLine)).toBeLessThanOrEqual(width);
|
||
});
|
||
|
||
it("shows cursor at end before wrap and wraps on next char", () => {
|
||
for (const paddingX of [0, 1]) {
|
||
const editor = new Editor({ ...defaultEditorTheme, editorPaddingX: paddingX });
|
||
const width = 20;
|
||
const contentWidth = width - 2 * (paddingX + 1);
|
||
const layoutWidth = Math.max(1, contentWidth - (paddingX === 0 ? 1 : 0));
|
||
const cursorToken = `\x1b[5m${defaultEditorTheme.symbols.inputCursor}\x1b[0m`;
|
||
|
||
for (let i = 0; i < layoutWidth; i++) {
|
||
editor.handleInput("a");
|
||
}
|
||
|
||
let lines = editor.render(width);
|
||
let contentLines = lines.slice(1);
|
||
expect(contentLines.length).toBe(1);
|
||
expect(contentLines[0]!.includes(cursorToken)).toBeTruthy();
|
||
|
||
editor.handleInput("a");
|
||
lines = editor.render(width);
|
||
contentLines = lines.slice(1);
|
||
expect(contentLines.length).toBe(2);
|
||
}
|
||
});
|
||
|
||
it("does not exceed terminal width with emoji at wrap boundary", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 11;
|
||
|
||
// "0123456789✅" = 10 ASCII + 2-wide emoji = 12 columns
|
||
// Should wrap before the emoji since it would exceed width
|
||
editor.setText("0123456789✅");
|
||
const lines = editor.render(width);
|
||
|
||
for (let i = 1; i < lines.length - 1; i++) {
|
||
const lineWidth = visibleWidth(lines[i]!);
|
||
expect(lineWidth <= width).toBeTruthy();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("Word wrapping", () => {
|
||
function renderContentLines(editor: Editor, width: number): string[] {
|
||
// Move cursor to start so the rendered cursor does not affect line padding/borders.
|
||
editor.handleInput("\x01"); // Ctrl+A
|
||
const lines = editor.render(width);
|
||
const paddingX = defaultEditorTheme.editorPaddingX ?? 2;
|
||
const borderWidth = paddingX + 1;
|
||
return lines.slice(1).map(l => stripVTControlCharacters(l).slice(borderWidth, -borderWidth).trimEnd());
|
||
}
|
||
|
||
it("wraps at word boundaries instead of mid-word", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 40;
|
||
|
||
editor.setText("Hello world this is a test of word wrapping functionality");
|
||
const lines = editor.render(width);
|
||
|
||
// Check that all lines fit within width
|
||
for (const line of lines) {
|
||
const lineWidth = visibleWidth(line);
|
||
expect(lineWidth).toBe(width);
|
||
}
|
||
|
||
// Extract text content (strip borders and control characters)
|
||
const allText = lines
|
||
.map(l => stripVTControlCharacters(l))
|
||
.join("")
|
||
.replace(/[+\-|]/g, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
|
||
// Should contain the full text (with normalized whitespace)
|
||
expect(allText).toBe("Hello world this is a test of word wrapping functionality");
|
||
});
|
||
|
||
it("does not start lines with leading whitespace after word wrap", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 20;
|
||
|
||
editor.setText("Word1 Word2 Word3 Word4 Word5 Word6");
|
||
const lines = editor.render(width);
|
||
|
||
// Get content lines (between borders)
|
||
const contentLines = lines.slice(1, -1);
|
||
|
||
// No line should start with whitespace (except for padding at the end)
|
||
for (let i = 0; i < contentLines.length; i++) {
|
||
const line = stripVTControlCharacters(contentLines[i]!);
|
||
const trimmedStart = line.trimStart();
|
||
// The line should either be all padding or start with a word character
|
||
if (trimmedStart.length > 0) {
|
||
expect(/^\s+\S/.test(line.trimEnd())).toBe(false);
|
||
}
|
||
}
|
||
});
|
||
|
||
it("breaks long words (URLs) at character level", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 30;
|
||
|
||
editor.setText("Check https://example.com/very/long/path/that/exceeds/width here");
|
||
const lines = editor.render(width);
|
||
|
||
// All lines should fit within width
|
||
for (let i = 1; i < lines.length - 1; i++) {
|
||
const lineWidth = visibleWidth(lines[i]!);
|
||
expect(lineWidth).toBe(width);
|
||
}
|
||
});
|
||
|
||
it("uses remaining width before breaking a long token", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 16; // 6 chars for borders, 10 for content
|
||
editor.setText("word 一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十");
|
||
const contentLines = renderContentLines(editor, width);
|
||
expect(contentLines.length).toBeGreaterThanOrEqual(2);
|
||
// The first visual line should not waste remaining width by leaving just "word".
|
||
expect(contentLines[0]?.includes("word")).toBeTruthy();
|
||
expect(contentLines[0]?.includes("一")).toBeTruthy();
|
||
});
|
||
it("uses remaining width before wrapping a short wide token (CJK)", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 16; // 6 chars for borders, 10 for content
|
||
// This CJK token fits within maxWidth, but not within the remaining width after "word ".
|
||
editor.setText("word 一二三四五");
|
||
const contentLines = renderContentLines(editor, width);
|
||
expect(contentLines.length).toBeGreaterThanOrEqual(2);
|
||
// Should fill the first line with as much of the CJK token as fits.
|
||
expect(contentLines[0]?.includes("word")).toBeTruthy();
|
||
expect(contentLines[0]?.includes("一")).toBeTruthy();
|
||
expect(contentLines[0]?.includes("二")).toBeTruthy();
|
||
expect(contentLines.join("\n").includes("三")).toBeTruthy();
|
||
});
|
||
it("wraps a longer friendly Chinese sentence without wasting remaining width", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 16; // 6 chars for borders, 10 for content
|
||
editor.setText(
|
||
"word 愿世界各地的朋友都被善意连接,愿每个人都拥有幸福灿烂的人生;愿AI与人类相互成就、共同成长,携手创造更美好的明天与未来。",
|
||
);
|
||
const contentLines = renderContentLines(editor, width);
|
||
expect(contentLines.length).toBeGreaterThanOrEqual(2);
|
||
// First line should not be just the ASCII prefix.
|
||
expect(contentLines[0]?.includes("word")).toBeTruthy();
|
||
expect(contentLines[0]?.includes("愿")).toBeTruthy();
|
||
});
|
||
it("wraps Japanese kana/kanji without wasting remaining width", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 16; // 6 chars for borders, 10 for content
|
||
const phrase = "天気がいいから、散歩しましょう!";
|
||
editor.setText(`word ${phrase}${phrase}${phrase}`);
|
||
const contentLines = renderContentLines(editor, width);
|
||
expect(contentLines.length).toBeGreaterThanOrEqual(2);
|
||
expect(contentLines[0]?.includes("word")).toBeTruthy();
|
||
expect(contentLines[0]?.includes("天")).toBeTruthy();
|
||
});
|
||
it("uses remaining width when wrapping an emoji token", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 16; // 6 chars for borders, 10 for content
|
||
editor.setText("word ✅✅✅✅✅ emoji-wrap-test with friends");
|
||
const contentLines = renderContentLines(editor, width);
|
||
expect(contentLines.length).toBeGreaterThanOrEqual(2);
|
||
// Each ✅ is 2 columns wide; remaining width should fit two of them.
|
||
expect(contentLines[0]?.includes("word ✅✅")).toBeTruthy();
|
||
expect(contentLines.join("").includes("emoji-wrap-test")).toBeTruthy();
|
||
});
|
||
it("does not split narrow non-ASCII words (German)", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 14; // 6 chars for borders, 8 for content
|
||
editor.setText("word über und danke fuer deine freundschaft");
|
||
const contentLines = renderContentLines(editor, width);
|
||
expect(contentLines.length).toBeGreaterThanOrEqual(2);
|
||
// "über" should wrap as a whole word, not be split into the remaining width.
|
||
expect(contentLines[0]?.includes("ü")).toBe(false);
|
||
expect(contentLines[1]?.startsWith("über")).toBeTruthy();
|
||
});
|
||
it("does not split narrow non-ASCII words (Russian)", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 14; // 6 chars for borders, 8 for content
|
||
editor.setText("word привет мой друг и спасибо за дружбу");
|
||
const contentLines = renderContentLines(editor, width);
|
||
expect(contentLines.length).toBeGreaterThanOrEqual(2);
|
||
// "привет" should wrap as a whole word, not be split into the remaining width.
|
||
expect(contentLines[0]?.includes("п")).toBe(false);
|
||
expect(contentLines[1]?.includes("привет")).toBeTruthy();
|
||
});
|
||
it("uses remaining width for mixed wide and narrow graphemes in one token", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 16; // 6 chars for borders, 10 for content
|
||
editor.setText("word 一a二b三c四d五e六f七g八h九");
|
||
const contentLines = renderContentLines(editor, width);
|
||
expect(contentLines.length).toBeGreaterThanOrEqual(2);
|
||
expect(contentLines[0]?.includes("word 一a二")).toBeTruthy();
|
||
expect(contentLines[1]?.startsWith("b三")).toBeTruthy();
|
||
});
|
||
it("preserves multiple spaces within words on same line", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 50;
|
||
|
||
editor.setText("Word1 Word2 Word3");
|
||
const lines = editor.render(width);
|
||
|
||
const contentLine = stripVTControlCharacters(lines[1]!).trim();
|
||
// Multiple spaces should be preserved
|
||
expect(contentLine.includes("Word1 Word2")).toBeTruthy();
|
||
});
|
||
|
||
it("handles empty string", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 40;
|
||
|
||
editor.setText("");
|
||
const lines = editor.render(width);
|
||
|
||
// Should have at least 2 lines (borders)
|
||
expect(lines.length).toBeGreaterThanOrEqual(2);
|
||
|
||
// All lines should fit within width
|
||
for (const line of lines) {
|
||
const lineWidth = visibleWidth(line);
|
||
expect(lineWidth).toBe(width);
|
||
}
|
||
});
|
||
|
||
it("handles single word that fits exactly", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const width = 20;
|
||
|
||
editor.setText("1234567890");
|
||
const lines = editor.render(width);
|
||
|
||
// Check all lines fit within width
|
||
for (const line of lines) {
|
||
const lineWidth = visibleWidth(line);
|
||
expect(lineWidth).toBe(width);
|
||
}
|
||
|
||
// Extract and verify content
|
||
const allText = lines
|
||
.map(l => stripVTControlCharacters(l))
|
||
.join("")
|
||
.replace(/[+\-|]/g, "")
|
||
.trim();
|
||
expect(allText).toBe("1234567890");
|
||
});
|
||
});
|
||
|
||
describe("Word navigation (Option/Alt + Left/Right)", () => {
|
||
const wordLeft = "\x1bb"; // ESC-b (matches alt+left on most terminals / our matcher)
|
||
const wordRight = "\x1bf"; // ESC-f (matches alt+right on most terminals / our matcher)
|
||
|
||
it("moves by CJK and punctuation blocks in Chinese", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const text = "天气不错,去散步吧!";
|
||
editor.setText(text);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.length });
|
||
// ! is punctuation delimiter
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.length - "!".length });
|
||
// Jump over the CJK run "去散步吧"
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf(",") + 1 });
|
||
// Jump over the punctuation delimiter ","
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf(",") });
|
||
// Jump over the CJK run "天气不错"
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 0 });
|
||
// And forward again
|
||
editor.handleInput(wordRight);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf(",") });
|
||
editor.handleInput(wordRight);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf(",") + 1 });
|
||
editor.handleInput(wordRight);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.length - "!".length });
|
||
editor.handleInput(wordRight);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.length });
|
||
});
|
||
|
||
it("moves by mixed kana/kanji blocks in Japanese", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const text = "天気がいいから、散歩しましょう!";
|
||
editor.setText(text);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.length });
|
||
// Skip the final delimiter
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.length - "!".length });
|
||
// Jump over the CJK run after the comma
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("、") + 1 });
|
||
// Skip the comma delimiter
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("、") });
|
||
// Jump over the first CJK run
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 0 });
|
||
});
|
||
|
||
it("moves by words and Unicode punctuation in Spanish", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const text = "¿Cómo estás? ¡Muy bien!";
|
||
editor.setText(text);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.length });
|
||
// Skip the final delimiter (!), then jump over "bien"
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("!") });
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.lastIndexOf("bien") });
|
||
// Jump over "Muy"
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("Muy") });
|
||
// The inverted exclamation is a delimiter block
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("¡") });
|
||
// Skip space + '?' delimiter (block semantics)
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("?") });
|
||
// Then jump over "estás"
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("estás") });
|
||
});
|
||
|
||
it("treats NBSP as whitespace for word navigation", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const nbsp = "\u00A0";
|
||
const text = `Hola${nbsp}mundo`;
|
||
editor.setText(text);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.length });
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("mundo") });
|
||
});
|
||
|
||
it("keeps common joiners inside words", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const text = "co-operate l’été";
|
||
editor.setText(text);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.length });
|
||
// Jump over the last word as a single unit (keeps ’ inside)
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("l’été") });
|
||
// Then jump over the hyphenated word as a single unit
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 0 });
|
||
});
|
||
|
||
it("recognizes Unicode quotes and dashes as delimiter blocks", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const text = "„überraschend“ — wirklich?";
|
||
editor.setText(text);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.length });
|
||
// '?' delimiter
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("?") });
|
||
// jump over "wirklich"
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("wirklich") });
|
||
// em dash delimiter
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("—") });
|
||
});
|
||
|
||
it("recognizes Russian quotes and dashes as delimiter blocks", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const text = "«Привет — мир»";
|
||
editor.setText(text);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.length });
|
||
// closing quote delimiter
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("»") });
|
||
// jump over "мир"
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("мир") });
|
||
// em dash delimiter
|
||
editor.handleInput(wordLeft);
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("—") });
|
||
});
|
||
});
|
||
|
||
describe("Sticky column", () => {
|
||
it("preserves target column when moving up through a shorter line", () => {
|
||
const editor = new Editor(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)
|
||
expect(editor.getCursor()).toEqual({ 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
|
||
expect(editor.getCursor()).toEqual({ line: 2, col: 10 });
|
||
|
||
// Press Up - should move to empty line (col clamped to 0)
|
||
editor.handleInput("\x1b[A"); // Up arrow
|
||
expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
|
||
|
||
// Press Up again - should move to line 0 at col 10 (on 'x')
|
||
editor.handleInput("\x1b[A"); // Up arrow
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 10 });
|
||
});
|
||
|
||
it("preserves target column when moving down through a shorter line", () => {
|
||
const editor = new Editor(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");
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 10 });
|
||
|
||
// Press Down - should move to empty line (col clamped to 0)
|
||
editor.handleInput("\x1b[B"); // Down arrow
|
||
expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
|
||
|
||
// Press Down again - should move to line 2 at col 10 (on 'x')
|
||
editor.handleInput("\x1b[B"); // Down arrow
|
||
expect(editor.getCursor()).toEqual({ line: 2, col: 10 });
|
||
});
|
||
|
||
it("resets sticky column on horizontal movement (left arrow)", () => {
|
||
const editor = new Editor(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");
|
||
expect(editor.getCursor()).toEqual({ 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)
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 5 });
|
||
|
||
// Move left - resets sticky column
|
||
editor.handleInput("\x1b[D"); // Left
|
||
expect(editor.getCursor()).toEqual({ 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)
|
||
expect(editor.getCursor()).toEqual({ line: 2, col: 4 });
|
||
});
|
||
|
||
it("resets sticky column on horizontal movement (right arrow)", () => {
|
||
const editor = new Editor(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");
|
||
expect(editor.getCursor()).toEqual({ 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)
|
||
expect(editor.getCursor()).toEqual({ line: 2, col: 5 });
|
||
|
||
// Move right - resets sticky column
|
||
editor.handleInput("\x1b[C"); // Right
|
||
expect(editor.getCursor()).toEqual({ 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)
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 6 });
|
||
});
|
||
|
||
it("resets sticky column on typing", () => {
|
||
const editor = new Editor(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
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 8 });
|
||
|
||
// Type a character - resets sticky column
|
||
editor.handleInput("X");
|
||
expect(editor.getCursor()).toEqual({ 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)
|
||
expect(editor.getCursor()).toEqual({ line: 2, col: 9 });
|
||
});
|
||
|
||
it("resets sticky column on backspace", () => {
|
||
const editor = new Editor(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
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 8 });
|
||
|
||
// Backspace - resets sticky column
|
||
editor.handleInput("\x7f"); // Backspace
|
||
expect(editor.getCursor()).toEqual({ 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)
|
||
expect(editor.getCursor()).toEqual({ line: 2, col: 7 });
|
||
});
|
||
|
||
it("resets sticky column on Ctrl+A (move to line start)", () => {
|
||
const editor = new Editor(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
|
||
expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
|
||
|
||
// Move up
|
||
editor.handleInput("\x1b[A"); // Up - line 0, col 0 (new sticky from col 0)
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 0 });
|
||
});
|
||
|
||
it("resets sticky column on Ctrl+E (move to line end)", () => {
|
||
const editor = new Editor(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
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 3 });
|
||
|
||
// Ctrl+E - resets sticky column to end
|
||
editor.handleInput("\x05"); // Ctrl+E
|
||
expect(editor.getCursor()).toEqual({ 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)
|
||
expect(editor.getCursor()).toEqual({ line: 2, col: 5 });
|
||
});
|
||
|
||
it("resets sticky column on word movement (Ctrl+Left)", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.setText("hello world\n\nhello world");
|
||
|
||
// Start at end of line 2 (col 11)
|
||
expect(editor.getCursor()).toEqual({ 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
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 11 });
|
||
|
||
// Ctrl+Left - word movement resets sticky column
|
||
editor.handleInput("\x1b[1;5D"); // Ctrl+Left
|
||
expect(editor.getCursor()).toEqual({ 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)
|
||
expect(editor.getCursor()).toEqual({ line: 2, col: 6 });
|
||
});
|
||
|
||
it("resets sticky column on word movement (Ctrl+Right)", () => {
|
||
const editor = new Editor(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
|
||
expect(editor.getCursor()).toEqual({ 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
|
||
expect(editor.getCursor()).toEqual({ line: 2, col: 0 });
|
||
|
||
// Ctrl+Right - word movement resets sticky column
|
||
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
||
expect(editor.getCursor()).toEqual({ 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)
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 5 });
|
||
});
|
||
|
||
it("resets sticky column on undo", () => {
|
||
const editor = new Editor(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");
|
||
expect(editor.getCursor()).toEqual({ 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)
|
||
expect(editor.getCursor()).toEqual({ line: 2, col: 8 });
|
||
|
||
// Type something to create undo state - this clears sticky and sets col to 9
|
||
editor.handleInput("X");
|
||
expect(editor.getText()).toBe("1234567890\n\n12345678X90");
|
||
expect(editor.getCursor()).toEqual({ 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
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 9 });
|
||
|
||
// Undo - resets sticky column and restores cursor to line 2, col 8
|
||
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||
expect(editor.getText()).toBe("1234567890\n\n1234567890");
|
||
expect(editor.getCursor()).toEqual({ 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)
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 8 });
|
||
});
|
||
|
||
it("handles multiple consecutive up/down movements", () => {
|
||
const editor = new Editor(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");
|
||
expect(editor.getCursor()).toEqual({ 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)
|
||
expect(editor.getCursor()).toEqual({ 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)
|
||
expect(editor.getCursor()).toEqual({ line: 4, col: 7 });
|
||
});
|
||
|
||
it("supports PageUp/PageDown for faster visual navigation", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.setMaxHeight(6);
|
||
editor.setText("l0\nl1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9");
|
||
|
||
editor.handleInput("\x1b[5~"); // PageUp
|
||
expect(editor.getCursor()).toEqual({ line: 6, col: 2 });
|
||
|
||
editor.handleInput("\x1b[5~"); // PageUp
|
||
expect(editor.getCursor()).toEqual({ line: 3, col: 2 });
|
||
|
||
editor.handleInput("\x1b[6~"); // PageDown
|
||
expect(editor.getCursor()).toEqual({ line: 6, col: 2 });
|
||
});
|
||
|
||
it("moves correctly through wrapped visual lines without getting stuck", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
// Line 0: short
|
||
// Line 1: 30 chars = wraps to multiple visual lines at narrow width
|
||
editor.setText("short\n123456789012345678901234567890");
|
||
editor.render(16); // Narrow width to force wrapping
|
||
|
||
// Position at end of line 1 (col 30)
|
||
expect(editor.getCursor()).toEqual({ 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
|
||
expect(editor.getCursor().line).toBe(1);
|
||
|
||
editor.handleInput("\x1b[A"); // Up - another visual line
|
||
expect(editor.getCursor().line).toBe(1);
|
||
|
||
editor.handleInput("\x1b[A"); // Up - should reach line 0
|
||
expect(editor.getCursor().line).toBe(0);
|
||
});
|
||
|
||
it("handles setText resetting sticky column", () => {
|
||
const editor = new Editor(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");
|
||
expect(editor.getCursor()).toEqual({ 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
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 10 });
|
||
});
|
||
|
||
it("sets preferredVisualCol when pressing right at end of prompt (last line)", () => {
|
||
const editor = new Editor(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
|
||
expect(editor.getCursor()).toEqual({ 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)
|
||
expect(editor.getCursor()).toEqual({ 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
|
||
expect(editor.getCursor()).toEqual({ 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')
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 10 });
|
||
});
|
||
|
||
it("handles editor resizes when preferredVisualCol is on the same line", () => {
|
||
const editor = new Editor(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
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 15 });
|
||
|
||
// Render with narrower width to simulate resize
|
||
editor.render(17); // Width 17 -> layoutWidth 11
|
||
|
||
// 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
|
||
expect(editor.getCursor().col).toBe(4);
|
||
});
|
||
|
||
it("handles editor resizes when preferredVisualCol is on a different line", () => {
|
||
const editor = new Editor(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");
|
||
expect(editor.getCursor()).toEqual({ 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
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 5 });
|
||
|
||
// Narrow the editor
|
||
editor.render(15);
|
||
|
||
// 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
|
||
expect(editor.getCursor()).toEqual({ line: 1, col: 8 });
|
||
|
||
// Move up
|
||
editor.handleInput("\x1b[A"); // Up - should go to line 0
|
||
expect(editor.getCursor()).toEqual({ 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
|
||
expect(editor.getCursor()).toEqual({ line: 1, col: 15 });
|
||
});
|
||
});
|
||
});
|