mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 11:02:17 +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
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Editor crash with emojis/CJK characters**: Fixed crash when pasting or typing text containing wide characters (emojis like ✅, CJK characters) that caused line width to exceed terminal width. The editor now uses grapheme-aware text wrapping with proper visible width calculation.
|
||||
|
||||
## [0.12.14] - 2025-12-06
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
|
||||
import type { Component } from "../tui.js";
|
||||
import { visibleWidth } from "../utils.js";
|
||||
import { SelectList, type SelectListTheme } from "./select-list.js";
|
||||
|
||||
// Grapheme segmenter for proper Unicode iteration (handles emojis, etc.)
|
||||
const segmenter = new Intl.Segmenter();
|
||||
|
||||
interface EditorState {
|
||||
lines: string[];
|
||||
cursorLine: number;
|
||||
|
|
@ -146,7 +150,7 @@ export class Editor implements Component {
|
|||
// Render each layout line
|
||||
for (const layoutLine of layoutLines) {
|
||||
let displayText = layoutLine.text;
|
||||
let visibleLength = layoutLine.text.length;
|
||||
let lineVisibleWidth = visibleWidth(layoutLine.text);
|
||||
|
||||
// Add cursor if this line has it
|
||||
if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
|
||||
|
|
@ -154,34 +158,43 @@ export class Editor implements Component {
|
|||
const after = displayText.slice(layoutLine.cursorPos);
|
||||
|
||||
if (after.length > 0) {
|
||||
// Cursor is on a character - replace it with highlighted version
|
||||
const cursor = `\x1b[7m${after[0]}\x1b[0m`;
|
||||
const restAfter = after.slice(1);
|
||||
// Cursor is on a character (grapheme) - replace it with highlighted version
|
||||
// Get the first grapheme from 'after'
|
||||
const afterGraphemes = [...segmenter.segment(after)];
|
||||
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
||||
const restAfter = after.slice(firstGrapheme.length);
|
||||
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
||||
displayText = before + cursor + restAfter;
|
||||
// visibleLength stays the same - we're replacing, not adding
|
||||
// lineVisibleWidth stays the same - we're replacing, not adding
|
||||
} else {
|
||||
// Cursor is at the end - check if we have room for the space
|
||||
if (layoutLine.text.length < width) {
|
||||
if (lineVisibleWidth < width) {
|
||||
// We have room - add highlighted space
|
||||
const cursor = "\x1b[7m \x1b[0m";
|
||||
displayText = before + cursor;
|
||||
// visibleLength increases by 1 - we're adding a space
|
||||
visibleLength = layoutLine.text.length + 1;
|
||||
// lineVisibleWidth increases by 1 - we're adding a space
|
||||
lineVisibleWidth = lineVisibleWidth + 1;
|
||||
} else {
|
||||
// Line is at full width - use reverse video on last character if possible
|
||||
// Line is at full width - use reverse video on last grapheme if possible
|
||||
// or just show cursor at the end without adding space
|
||||
if (before.length > 0) {
|
||||
const lastChar = before[before.length - 1];
|
||||
const cursor = `\x1b[7m${lastChar}\x1b[0m`;
|
||||
displayText = before.slice(0, -1) + cursor;
|
||||
const beforeGraphemes = [...segmenter.segment(before)];
|
||||
if (beforeGraphemes.length > 0) {
|
||||
const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment || "";
|
||||
const cursor = `\x1b[7m${lastGrapheme}\x1b[0m`;
|
||||
// Rebuild 'before' without the last grapheme
|
||||
const beforeWithoutLast = beforeGraphemes
|
||||
.slice(0, -1)
|
||||
.map((g) => g.segment)
|
||||
.join("");
|
||||
displayText = beforeWithoutLast + cursor;
|
||||
}
|
||||
// visibleLength stays the same
|
||||
// lineVisibleWidth stays the same
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate padding based on actual visible length
|
||||
const padding = " ".repeat(Math.max(0, width - visibleLength));
|
||||
// Calculate padding based on actual visible width
|
||||
const padding = " ".repeat(Math.max(0, width - lineVisibleWidth));
|
||||
|
||||
// Render the line (no side borders, just horizontal lines above and below)
|
||||
result.push(displayText + padding);
|
||||
|
|
@ -493,9 +506,9 @@ export class Editor implements Component {
|
|||
for (let i = 0; i < this.state.lines.length; i++) {
|
||||
const line = this.state.lines[i] || "";
|
||||
const isCurrentLine = i === this.state.cursorLine;
|
||||
const maxLineLength = contentWidth;
|
||||
const lineVisibleWidth = visibleWidth(line);
|
||||
|
||||
if (line.length <= maxLineLength) {
|
||||
if (lineVisibleWidth <= contentWidth) {
|
||||
// Line fits in one layout line
|
||||
if (isCurrentLine) {
|
||||
layoutLines.push({
|
||||
|
|
@ -510,35 +523,64 @@ export class Editor implements Component {
|
|||
});
|
||||
}
|
||||
} else {
|
||||
// Line needs wrapping
|
||||
const chunks = [];
|
||||
for (let pos = 0; pos < line.length; pos += maxLineLength) {
|
||||
chunks.push(line.slice(pos, pos + maxLineLength));
|
||||
// Line needs wrapping - use grapheme-aware chunking
|
||||
const chunks: { text: string; startIndex: number; endIndex: number }[] = [];
|
||||
let currentChunk = "";
|
||||
let currentWidth = 0;
|
||||
let chunkStartIndex = 0;
|
||||
let currentIndex = 0;
|
||||
|
||||
for (const seg of segmenter.segment(line)) {
|
||||
const grapheme = seg.segment;
|
||||
const graphemeWidth = visibleWidth(grapheme);
|
||||
|
||||
if (currentWidth + graphemeWidth > contentWidth && currentChunk !== "") {
|
||||
// Start a new chunk
|
||||
chunks.push({
|
||||
text: currentChunk,
|
||||
startIndex: chunkStartIndex,
|
||||
endIndex: currentIndex,
|
||||
});
|
||||
currentChunk = grapheme;
|
||||
currentWidth = graphemeWidth;
|
||||
chunkStartIndex = currentIndex;
|
||||
} else {
|
||||
currentChunk += grapheme;
|
||||
currentWidth += graphemeWidth;
|
||||
}
|
||||
currentIndex += grapheme.length;
|
||||
}
|
||||
|
||||
// Push the last chunk
|
||||
if (currentChunk !== "") {
|
||||
chunks.push({
|
||||
text: currentChunk,
|
||||
startIndex: chunkStartIndex,
|
||||
endIndex: currentIndex,
|
||||
});
|
||||
}
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
||||
const chunk = chunks[chunkIndex];
|
||||
if (!chunk) continue;
|
||||
|
||||
const chunkStart = chunkIndex * maxLineLength;
|
||||
const chunkEnd = chunkStart + chunk.length;
|
||||
const cursorPos = this.state.cursorCol;
|
||||
const isLastChunk = chunkIndex === chunks.length - 1;
|
||||
// For non-last chunks, cursor at chunkEnd belongs to the next chunk
|
||||
// For non-last chunks, cursor at endIndex belongs to the next chunk
|
||||
const hasCursorInChunk =
|
||||
isCurrentLine &&
|
||||
cursorPos >= chunkStart &&
|
||||
(isLastChunk ? cursorPos <= chunkEnd : cursorPos < chunkEnd);
|
||||
cursorPos >= chunk.startIndex &&
|
||||
(isLastChunk ? cursorPos <= chunk.endIndex : cursorPos < chunk.endIndex);
|
||||
|
||||
if (hasCursorInChunk) {
|
||||
layoutLines.push({
|
||||
text: chunk,
|
||||
text: chunk.text,
|
||||
hasCursor: true,
|
||||
cursorPos: cursorPos - chunkStart,
|
||||
cursorPos: cursorPos - chunk.startIndex,
|
||||
});
|
||||
} else {
|
||||
layoutLines.push({
|
||||
text: chunk,
|
||||
text: chunk.text,
|
||||
hasCursor: false,
|
||||
});
|
||||
}
|
||||
|
|
@ -917,16 +959,44 @@ export class Editor implements Component {
|
|||
|
||||
for (let i = 0; i < this.state.lines.length; i++) {
|
||||
const line = this.state.lines[i] || "";
|
||||
const lineVisWidth = visibleWidth(line);
|
||||
if (line.length === 0) {
|
||||
// Empty line still takes one visual line
|
||||
visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
|
||||
} else if (line.length <= width) {
|
||||
} else if (lineVisWidth <= width) {
|
||||
visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
|
||||
} else {
|
||||
// Line needs wrapping
|
||||
for (let pos = 0; pos < line.length; pos += width) {
|
||||
const segmentLength = Math.min(width, line.length - pos);
|
||||
visualLines.push({ logicalLine: i, startCol: pos, length: segmentLength });
|
||||
// Line needs wrapping - use grapheme-aware chunking
|
||||
let currentWidth = 0;
|
||||
let chunkStartIndex = 0;
|
||||
let currentIndex = 0;
|
||||
|
||||
for (const seg of segmenter.segment(line)) {
|
||||
const grapheme = seg.segment;
|
||||
const graphemeWidth = visibleWidth(grapheme);
|
||||
|
||||
if (currentWidth + graphemeWidth > width && currentIndex > chunkStartIndex) {
|
||||
// Start a new chunk
|
||||
visualLines.push({
|
||||
logicalLine: i,
|
||||
startCol: chunkStartIndex,
|
||||
length: currentIndex - chunkStartIndex,
|
||||
});
|
||||
chunkStartIndex = currentIndex;
|
||||
currentWidth = graphemeWidth;
|
||||
} else {
|
||||
currentWidth += graphemeWidth;
|
||||
}
|
||||
currentIndex += grapheme.length;
|
||||
}
|
||||
|
||||
// Push the last chunk
|
||||
if (currentIndex > chunkStartIndex) {
|
||||
visualLines.push({
|
||||
logicalLine: i,
|
||||
startCol: chunkStartIndex,
|
||||
length: currentIndex - chunkStartIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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