diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 408d9d9ee..04d091efa 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed terminal width accounting for Indic conjunct grapheme clusters ([#6124](https://github.com/earendil-works/pi/issues/6124) by [@petrroll](https://github.com/petrroll)). + ### Added - Added interface-compatible main-screen and alternate-screen TUI renderers with application-owned scrolling ([#7304](https://github.com/earendil-works/pi/issues/7304)). diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 29b339632..a3cc7fc6a 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -39,6 +39,12 @@ function couldBeEmoji(segment: string): boolean { // Regexes for character classification (same as string-width library) const zeroWidthRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v; const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v; +const nonPrintingCharRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Mark}|\p{Surrogate})$/v; +const markCharRegex = /^\p{Mark}$/v; +// Marks that terminals allocate cells for when attached to a base character. +// This includes Unicode spacing marks and non-spacing exceptions in legacy wcwidth tables. +const terminalSpacingMarkRegex = + /^(?:[\p{Spacing_Mark}--[\u1734\u302E\u302F]]|[\u065F\u0F7F\u102B\u102C\u1031\u1033-\u1035\u1038\u103A-\u103E])+$/v; const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; // Cache for non-ASCII strings @@ -169,6 +175,11 @@ function graphemeWidth(segment: string): number { return 3; } + // Some marks occupy cells even without a base character. + if (terminalSpacingMarkRegex.test(segment)) { + return [...segment].length; + } + // Zero-width clusters if (zeroWidthRegex.test(segment)) { return 0; @@ -195,15 +206,27 @@ function graphemeWidth(segment: string): number { let width = eastAsianWidth(cp); - // Trailing halfwidth/fullwidth forms and AM vowels that segment with a base. - if (segment.length > 1) { - for (const char of segment.slice(1)) { + // Intl.Segmenter can group multiple terminal-spacing code points into one + // grapheme. Count trailing visible code points that terminals may allocate + // cells for: Indic consonants after marks, halfwidth/fullwidth forms, and + // Thai/Lao AM vowels. + let followsMark = false; + const chars = [...base]; + for (const char of chars.slice(1)) { + if (terminalSpacingMarkRegex.test(char)) { + width += 1; + followsMark = false; + } else if (markCharRegex.test(char)) { + followsMark = true; + } else if (!nonPrintingCharRegex.test(char)) { const c = char.codePointAt(0)!; - if (c >= 0xff00 && c <= 0xffef) { + if (followsMark || (c >= 0xff00 && c <= 0xffef)) { + // halfwidth + fullwidth forms width += eastAsianWidth(c); } else if (c === 0x0e33 || c === 0x0eb3) { width += 1; } + followsMark = false; } } diff --git a/packages/tui/test/truncate-to-width.test.ts b/packages/tui/test/truncate-to-width.test.ts index 321ba8983..31f80a379 100644 --- a/packages/tui/test/truncate-to-width.test.ts +++ b/packages/tui/test/truncate-to-width.test.ts @@ -60,6 +60,49 @@ describe("visibleWidth", () => { assert.strictEqual(visibleWidth("\t\x1b[31m界\x1b[0m"), 5); }); + it("counts Indic conjunct spacing code points within grapheme clusters", () => { + assert.strictEqual(visibleWidth("र्क"), 2); + assert.strictEqual(visibleWidth("नेटवर्क"), 5); + assert.strictEqual(visibleWidth("सर्वाधिकार सुरक्षित। ऑर्डर पर क्लिक करें"), 33); + assert.strictEqual(visibleWidth("র্ক"), 2); + assert.strictEqual(visibleWidth("ર્ક"), 2); + assert.strictEqual(visibleWidth("ର୍କ"), 2); + assert.strictEqual(visibleWidth("ర్క"), 2); + assert.strictEqual(visibleWidth("ര്‍ക"), 2); + }); + + it("keeps ordinary combining marks zero-width", () => { + assert.strictEqual(visibleWidth("e\u0301"), 1); + assert.strictEqual(visibleWidth("čřžůú"), 5); + assert.strictEqual(visibleWidth("שָׁ"), 1); + assert.strictEqual(visibleWidth("بّ"), 1); + assert.strictEqual(visibleWidth("རྐ"), 1); + assert.strictEqual(visibleWidth("ᜠ᜴"), 1); + assert.strictEqual(visibleWidth("가〮"), 2); + assert.strictEqual(visibleWidth("가〯"), 2); + }); + + it("keeps CJK and Japanese width accounting unchanged", () => { + assert.strictEqual(visibleWidth("网络"), 4); + assert.strictEqual(visibleWidth("ネットワーク"), 12); + assert.strictEqual(visibleWidth("が"), 2); + assert.strictEqual(visibleWidth("か\u3099"), 2); + }); + + it("counts Myanmar marks that terminals allocate cells for", () => { + assert.strictEqual(visibleWidth("ကာ"), 2); + assert.strictEqual(visibleWidth("ကေ"), 2); + assert.strictEqual(visibleWidth("က်"), 2); + assert.strictEqual(visibleWidth("ကျ"), 2); + assert.strictEqual(visibleWidth("ကြ"), 2); + assert.strictEqual(visibleWidth("ကဳ"), 2); + assert.strictEqual(visibleWidth("ကဴ"), 2); + assert.strictEqual(visibleWidth("ကဵ"), 2); + assert.strictEqual(visibleWidth("ကး"), 2); + assert.strictEqual(visibleWidth("ကို"), 1); + assert.strictEqual(visibleWidth("က္"), 1); + }); + it("keeps Thai and Lao AM clusters at their normal cell width", () => { assert.strictEqual(visibleWidth("ำ"), 1); assert.strictEqual(visibleWidth("ຳ"), 1);