From 48e4bd94efe9425dc741860690f2863d35328849 Mon Sep 17 00:00:00 2001 From: xu0o0 Date: Fri, 13 Mar 2026 07:46:37 +0800 Subject: [PATCH] fix(tui): wordWrapLine overflow with wide chars at wrap boundary (#2082) --- packages/tui/src/components/editor.ts | 11 ++++++++--- packages/tui/test/editor.test.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index f4a7353c..247c889a 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -58,13 +58,18 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] { // Overflow check before advancing. if (currentWidth + gWidth > maxWidth) { - if (wrapOppIndex >= 0) { - // Backtrack to last wrap opportunity. + if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) { + // Backtrack to last wrap opportunity (the remaining content + // plus the current grapheme still fits within maxWidth). chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex }); chunkStart = wrapOppIndex; currentWidth -= wrapOppWidth; } else if (chunkStart < charIndex) { - // No wrap opportunity: force-break at current position. + // No viable wrap opportunity: force-break at current position. + // This also handles the case where backtracking to a word + // boundary wouldn't help because the remaining content plus + // the current grapheme (e.g. a wide character) still exceeds + // maxWidth. chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex }); chunkStart = charIndex; currentWidth = 0; diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index ac44fea9..02fd77f8 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -874,6 +874,24 @@ describe("Editor component", () => { assert.strictEqual(chunks[1]!.text, "amet, "); assert.strictEqual(chunks[2]!.text, " consectetur"); }); + + it("force-breaks when wide char after word boundary wrap still overflows", () => { + // " " (1) + "a"*186 (186) + "你" (2) = 189 visible width + // maxWidth = 187: backtracking to the space would leave 186 + 2 = 188 > 187, + // so the algorithm must force-break before the wide char instead. + const line = ` ${"a".repeat(186)}你`; + const chunks = wordWrapLine(line, 187); + + for (const chunk of chunks) { + assert.ok( + visibleWidth(chunk.text) <= 187, + `chunk "${chunk.text.slice(0, 20)}..." has visible width ${visibleWidth(chunk.text)}, expected <= 187`, + ); + } + // Verify no content is lost + const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join(""); + assert.strictEqual(reconstructed, line); + }); }); describe("Kill ring", () => {