mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 05:00:16 +00:00
Fix editor crash with wide characters (emojis, CJK)
Editor text wrapping now uses grapheme-aware width calculation instead
of string length. Fixes crash when pasting text containing emojis like
✅ or CJK characters that are 2 terminal columns wide.
This commit is contained in:
parent
a325c1c7d1
commit
d7f84469a7
3 changed files with 212 additions and 35 deletions
|
|
@ -1,6 +1,8 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { stripVTControlCharacters } from "node:util";
|
||||
import { Editor } from "../src/components/editor.js";
|
||||
import { visibleWidth } from "../src/utils.js";
|
||||
import { defaultEditorTheme } from "./test-themes.js";
|
||||
|
||||
describe("Editor component", () => {
|
||||
|
|
@ -370,4 +372,105 @@ describe("Editor component", () => {
|
|||
assert.strictEqual(text, "xab");
|
||||
});
|
||||
});
|
||||
|
||||
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]!);
|
||||
assert.strictEqual(lineWidth, width, `Line ${i} has width ${lineWidth}, expected ${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]!);
|
||||
assert.strictEqual(lineWidth, width, `Line ${i} has width ${lineWidth}, expected ${width}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps CJK characters correctly (each is 2 columns wide)", () => {
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
const width = 10;
|
||||
|
||||
// Each CJK char is 2 columns. "日本語テスト" = 6 chars = 12 columns
|
||||
editor.setText("日本語テスト");
|
||||
const lines = editor.render(width);
|
||||
|
||||
for (let i = 1; i < lines.length - 1; i++) {
|
||||
const lineWidth = visibleWidth(lines[i]!);
|
||||
assert.strictEqual(lineWidth, width, `Line ${i} has width ${lineWidth}, expected ${width}`);
|
||||
}
|
||||
|
||||
// Verify content split correctly
|
||||
const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim());
|
||||
assert.strictEqual(contentLines.length, 2);
|
||||
assert.strictEqual(contentLines[0], "日本語テス"); // 5 chars = 10 columns
|
||||
assert.strictEqual(contentLines[1], "ト"); // 1 char = 2 columns (+ 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);
|
||||
assert.strictEqual(contentLines.length, 1);
|
||||
|
||||
const lineWidth = visibleWidth(contentLines[0]!);
|
||||
assert.strictEqual(lineWidth, 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 (reverse video space) should be visible
|
||||
const contentLine = lines[1]!;
|
||||
assert.ok(contentLine.includes("\x1b[7m"), "Should have reverse video cursor");
|
||||
|
||||
// Line should still be correct width
|
||||
assert.strictEqual(visibleWidth(contentLine), width);
|
||||
});
|
||||
|
||||
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]!);
|
||||
assert.ok(lineWidth <= width, `Line ${i} has width ${lineWidth}, exceeds max ${width}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue