fix(tui): align grapheme widths with terminal cells (#6987)

Intl.Segmenter can group Indic conjuncts and spacing marks into one grapheme even when wcwidth-style terminals allocate multiple cells. Count visible consonants after marks and Unicode spacing marks separately while preserving zero-width exceptions.

This is a compatibility heuristic. Unicode Spacing_Mark indicates positive advance, not a full terminal cell; Kitty keeps many Indic continuations in the initial cell, while xterm-style terminals advance for them, so Pi overcounts some clusters in Kitty.

Overcounting causes horizontal drift, but undercounting can trigger terminal autowrap, so this change favors overcounting.

Clusters can contain repeated marks and mark-consonant continuations, so this implementation leaves their widths uncapped. Collapsing them would undercount terminals that advance for every visible continuation.

Mode 2027 does not report cluster widths, so Pi still needs local width calculations. This change does not implement OSC 66, which would be preferable where supported.

Fixes #6124

Co-authored-by: Mario Zechner <badlogicgames@gmail.com>
This commit is contained in:
Petr Houška 2026-07-30 22:25:46 +02:00 • committed by GitHub
parent 2c30412420
commit dfe47d3fbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 74 additions and 4 deletions

View file

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

View file

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

View file

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