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:
Mario Zechner 2025-12-06 21:24:26 +01:00
parent a325c1c7d1
commit d7f84469a7
3 changed files with 212 additions and 35 deletions

View file

@ -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

View file

@ -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,
});
}
}
}

View file

@ -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}`);
}
});
});
});