diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 91b52474d..9ab6749bf 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed overlay compositing over CJK wide characters so borders stay aligned when an overlay starts inside a full-width cell ([#5297](https://github.com/earendil-works/pi/issues/5297)). - Fixed WezTerm inline Kitty image rendering during full redraw fallbacks so image padding rows are reserved before the placement is drawn without regressing tall-image placement ([#5618](https://github.com/earendil-works/pi/issues/5618), [#4415](https://github.com/earendil-works/pi/issues/4415)). ## [0.79.3] - 2026-06-13 diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index bf228ce0e..b074a3a24 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -1156,7 +1156,7 @@ export function extractSegments( for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) { const w = graphemeWidth(segment); - if (currentCol < beforeEnd) { + if (currentCol < beforeEnd && currentCol + w <= beforeEnd) { if (pendingAnsiBefore) { before += pendingAnsiBefore; pendingAnsiBefore = ""; diff --git a/packages/tui/test/regression-overlay-cjk-boundary.test.ts b/packages/tui/test/regression-overlay-cjk-boundary.test.ts new file mode 100644 index 000000000..2ae1f5ed8 --- /dev/null +++ b/packages/tui/test/regression-overlay-cjk-boundary.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { TUI } from "../src/tui.ts"; +import { extractSegments, sliceByColumn, visibleWidth } from "../src/utils.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +type TuiComposite = { + compositeLineAt( + baseLine: string, + overlayLine: string, + startCol: number, + overlayWidth: number, + totalWidth: number, + ): string; +}; + +function compositeLineAt( + baseLine: string, + overlayLine: string, + startCol: number, + overlayWidth: number, + totalWidth: number, +): string { + const tui = new TUI(new VirtualTerminal(totalWidth, 10)) as unknown as TuiComposite; + return tui.compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth); +} + +describe("overlay CJK boundary regression", () => { + it("excludes a wide grapheme from before when overlay starts inside it", () => { + const segments = extractSegments("abcd让EFGH", 5, 9, 11, true); + + assert.strictEqual(segments.before, "abcd"); + assert.strictEqual(segments.beforeWidth, 4); + assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth); + assert.strictEqual(segments.after, "H"); + assert.strictEqual(segments.afterWidth, 1); + }); + + it("keeps ASCII before-segment behavior at the same boundary", () => { + const segments = extractSegments("abcdG EFGH", 5, 9, 11, true); + + assert.strictEqual(segments.before, "abcdG"); + assert.strictEqual(segments.beforeWidth, 5); + assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth); + }); + + it("composites an overlay at the requested column when it starts inside a wide grapheme", () => { + const out = compositeLineAt("abcd让EFGH", "│XX│", 5, 4, 20); + const prefix = sliceByColumn(out, 0, 5, true); + const overlay = sliceByColumn(out, 5, 4, true); + + assert.strictEqual(out.includes("让"), false); + assert.strictEqual(visibleWidth(out), 20); + assert.strictEqual(visibleWidth(prefix), 5); + assert.strictEqual(visibleWidth(overlay), 4); + assert.strictEqual(overlay.includes("│XX│"), true); + }); + + it("composites an overlay when it starts at a wide grapheme boundary", () => { + const out = compositeLineAt("abcd让EFGH", "│XX│", 4, 4, 20); + const overlay = sliceByColumn(out, 4, 4, true); + + assert.strictEqual(out.includes("让"), false); + assert.strictEqual(visibleWidth(out), 20); + assert.strictEqual(visibleWidth(overlay), 4); + assert.strictEqual(overlay.includes("│XX│"), true); + }); +});