diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 652a6bee..d12b9edd 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed editor rendering artifacts for Thai Sara Am and Lao AM vowel characters ([#3904](https://github.com/badlogic/pi-mono/issues/3904)) + ## [0.70.6] - 2026-04-28 ## [0.70.5] - 2026-04-27 diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 7c4ea7c1..b8be6461 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -178,14 +178,26 @@ function graphemeWidth(segment: string): number { return 2; } + // Thai SARA AM (U+0E33) and Lao VOWEL SIGN AM (U+0EB3) are encoded as + // spacing letters, but terminals commonly render isolated occurrences as + // mark-like clusters (compat decomposition: nonspacing niggahita + vowel). + // Count isolated clusters conservatively to avoid cursor drift and stale + // cells during differential rendering. When attached to a base consonant, + // the base code point is first and normal per-codepoint width below applies. + if (cp === 0x0e33 || cp === 0x0eb3) { + return 2; + } + let width = eastAsianWidth(cp); - // Trailing halfwidth/fullwidth forms + // Trailing halfwidth/fullwidth forms and AM vowels that segment with a base. if (segment.length > 1) { for (const char of segment.slice(1)) { const c = char.codePointAt(0)!; if (c >= 0xff00 && c <= 0xffef) { width += eastAsianWidth(c); + } else if (c === 0x0e33 || c === 0x0eb3) { + width += 1; } } } diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 99dd1cc7..36f7ee7b 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -626,6 +626,18 @@ describe("Editor component", () => { } }); + it("renders isolated Thai and Lao AM clusters without width drift", () => { + for (const text of ["ำabc", "ຳabc"]) { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const width = 8; + editor.setText(text); + + for (const line of editor.render(width)) { + assert.strictEqual(visibleWidth(line), width, `line width drift for ${JSON.stringify(text)}: ${line}`); + } + } + }); + it("wraps CJK characters correctly (each is 2 columns wide)", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); const width = 10 + 1; // +1 col reserved for cursor diff --git a/packages/tui/test/truncate-to-width.test.ts b/packages/tui/test/truncate-to-width.test.ts index fdfa9d4d..256a8f29 100644 --- a/packages/tui/test/truncate-to-width.test.ts +++ b/packages/tui/test/truncate-to-width.test.ts @@ -59,4 +59,11 @@ describe("visibleWidth", () => { it("counts tabs inline and skips ANSI inline", () => { assert.strictEqual(visibleWidth("\t\x1b[31m界\x1b[0m"), 5); }); + + it("counts isolated Thai and Lao AM clusters conservatively", () => { + assert.strictEqual(visibleWidth("ำ"), 2); + assert.strictEqual(visibleWidth("ຳ"), 2); + assert.strictEqual(visibleWidth("กำ"), 2); + assert.strictEqual(visibleWidth("ກຳ"), 2); + }); });