feat(tui): runtime Hangul Compatibility Jamo width override + Ghostty detection

Replace the compile-time cfg!(target_os = "macos") jamo-width heuristic
with a runtime override (process-global AtomicU8 in pi-natives, mirrored in
the TS width engine) plus terminal-identity detection: Ghostty renders
Hangul Compatibility Jamo (U+3131..U+318E) at 2 cells, so it is forced wide;
every other terminal keeps the platform default (macOS narrow, otherwise
UAX#11), making the override a no-op outside Ghostty.

Fixes doubled/ghosted jamo during Korean IME composition on Ghostty, where
the hardware cursor landed inside the typed text and the IME candidate window
drifted from the glyph. The width is resolved synchronously before the first
paint, so no stdin/CPR probe or async cache invalidation is needed.

A runtime DSR/CPR probe that auto-detects the width on unknown terminals is
tracked in a follow-up.
This commit is contained in:
changhee.an
2026-06-23 20:29:13 +09:00
parent af2e53e070
commit 82ef6c0a04
10 changed files with 336 additions and 29 deletions
+83 -2
View File
@@ -2,6 +2,7 @@ import {
Ellipsis,
type ExtractSegmentsResult,
extractSegments as nativeExtractSegments,
setHangulCompatJamoWidthOverride as nativeSetHangulCompatJamoWidthOverride,
sliceWithWidth as nativeSliceWithWidth,
truncateToWidth as nativeTruncateToWidth,
wrapTextWithAnsi as nativeWrapTextWithAnsi,
@@ -13,6 +14,34 @@ export { Ellipsis } from "@oh-my-pi/pi-natives";
export { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils";
export type HangulCompatibilityJamoWidth = "platform" | "unicode" | 1 | 2;
let hangulCompatibilityJamoWidth: HangulCompatibilityJamoWidth = "platform";
// Wire encoding for the native override (see crates/pi-natives text.rs):
// 0 = platform default, 1 = narrow, 2 = wide, 3 = unicode (no correction).
function nativeHangulCompatibilityJamoOverride(width: HangulCompatibilityJamoWidth): number {
if (width === "unicode") return 3;
if (typeof width === "number") return width;
return 0;
}
export function getHangulCompatibilityJamoWidth(): HangulCompatibilityJamoWidth {
return hangulCompatibilityJamoWidth;
}
export function setHangulCompatibilityJamoWidth(width: HangulCompatibilityJamoWidth): boolean {
const changed = hangulCompatibilityJamoWidth !== width;
hangulCompatibilityJamoWidth = width;
nativeSetHangulCompatJamoWidthOverride(nativeHangulCompatibilityJamoOverride(width));
return changed;
}
export function resetHangulCompatibilityJamoWidthForTests(): void {
hangulCompatibilityJamoWidth = "platform";
nativeSetHangulCompatJamoWidthOverride(0);
}
export type TextSizingScale = 1 | 2 | 3;
export type TextSizingVerticalAlign = "top" | "bottom" | "center";
export type TextSizingHorizontalAlign = "left" | "right" | "center";
@@ -157,6 +186,58 @@ const LONG_WIDTH_FAST_PATH_MIN = 128;
// non-CJK tables that back truncate/slice/wrap. Hoisted so no per-call alloc.
const STRING_WIDTH_OPTS = { countAnsiEscapeCodes: false, ambiguousIsNarrow: true } as const;
// Hangul Compatibility Jamo (U+3131..=U+318E). `Bun.stringWidth` follows UAX#11
// and reports these at 2 cells (the U+3164 HANGUL FILLER at 0), but the actual
// rendered width is decided by the *client* terminal (1 cell on Terminal.app /
// iTerm2, 2 on Ghostty and most Linux terminals). The width is resolved from
// the terminal identity and pushed into the native engine through
// `setHangulCompatibilityJamoWidth`; mirror the same correction here so the TS
// width stays in parity with the native truncate/slice/wrap model — and so the
// hardware cursor column lands on the actual glyph during Korean IME input.
const HANGUL_COMPAT_JAMO_REGEX = /[\u3131-\u318e]/;
const HANGUL_COMPAT_JAMO_GLOBAL_REGEX = /[\u3131-\u318e]/g;
const HANGUL_FILLER_CODE_POINT = 0x3164;
// `Bun.stringWidth` counts every code point in the Compatibility Jamo block as
// 2 cells (even the U+3164 filler that `unicode-width` treats as zero-width).
const HANGUL_COMPAT_JAMO_BUN_WIDTH = 2;
// Effective target cell width for Compatibility Jamo, or `null` to follow the
// Unicode width (no correction). Mirrors `hangul_compat_jamo_target_width` in
// crates/pi-natives/src/text.rs.
function hangulCompatibilityJamoTargetWidth(): 1 | 2 | null {
switch (hangulCompatibilityJamoWidth) {
case 1:
return 1;
case 2:
return 2;
case "unicode":
return null;
default:
// "platform": macOS terminals historically render these narrow.
return process.platform === "darwin" ? 1 : null;
}
}
// Reconcile the `Bun.stringWidth` count for Compatibility Jamo to the native
// width engine: subtract Bun's per-jamo cell count and add back the effective
// width — the runtime target when one is active, otherwise the `unicode-width`
// value. Mirrors `char_width_corrected` / `apply_hangul_compat_jamo_delta` in
// crates/pi-natives/src/text.rs, including the rule that the zero-width filler
// (U+3164) is never widened past the narrow correction (a wide terminal still
// renders it at its Unicode width of 0).
function correctHangulCompatibilityJamoWidth(width: number, str: string): number {
if (!HANGUL_COMPAT_JAMO_REGEX.test(str)) return width;
const target = hangulCompatibilityJamoTargetWidth();
let corrected = width;
HANGUL_COMPAT_JAMO_GLOBAL_REGEX.lastIndex = 0;
for (let m = HANGUL_COMPAT_JAMO_GLOBAL_REGEX.exec(str); m !== null; m = HANGUL_COMPAT_JAMO_GLOBAL_REGEX.exec(str)) {
const unicodeWidth = m[0].codePointAt(0) === HANGUL_FILLER_CODE_POINT ? 0 : 2;
const finalWidth = target === null || (unicodeWidth === 0 && target > 1) ? unicodeWidth : target;
corrected += finalWidth - HANGUL_COMPAT_JAMO_BUN_WIDTH;
}
return corrected;
}
/**
* Visible width of a string in terminal columns, excluding ANSI/OSC escapes.
*
@@ -177,7 +258,7 @@ export function visibleWidth(str: string): number {
tabCount++;
}
if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
return width;
return correctHangulCompatibilityJamoWidth(width, str);
}
let tabCount = 0;
@@ -238,7 +319,7 @@ export function visibleWidth(str: string): number {
}
}
return width;
return correctHangulCompatibilityJamoWidth(width, str);
}
const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;