From 82ef6c0a04f751d34ae5de730785aa0aa744a616 Mon Sep 17 00:00:00 2001 From: "changhee.an" Date: Tue, 23 Jun 2026 20:29:13 +0900 Subject: [PATCH] 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. --- crates/pi-natives/src/text.rs | 104 ++++++++++++++---- packages/natives/CHANGELOG.md | 4 + packages/natives/native/index.d.ts | 2 + packages/natives/native/index.js | 1 + packages/tui/CHANGELOG.md | 5 + packages/tui/src/terminal.ts | 20 ++++ packages/tui/src/utils.ts | 85 +++++++++++++- packages/tui/test/input.test.ts | 36 +++++- .../test/terminal-jamo-width-probe.test.ts | 25 +++++ packages/tui/test/visible-width.test.ts | 83 +++++++++++++- 10 files changed, 336 insertions(+), 29 deletions(-) create mode 100644 packages/tui/test/terminal-jamo-width-probe.test.ts diff --git a/crates/pi-natives/src/text.rs b/crates/pi-natives/src/text.rs index a8f2716fe..56de90256 100644 --- a/crates/pi-natives/src/text.rs +++ b/crates/pi-natives/src/text.rs @@ -8,7 +8,10 @@ //! - Ellipsis decoded lazily //! - truncateToWidth returns the original `JsString` when possible -use std::cell::RefCell; +use std::{ + cell::RefCell, + sync::atomic::{AtomicU8, Ordering}, +}; use napi::{JsString, bindgen_prelude::*}; use napi_derive::napi; @@ -529,34 +532,91 @@ const fn ascii_cell_width_u16(u: u16, tab_width: usize) -> usize { } } -const MACOS_HANGUL_COMPAT_JAMO_WIDTH: usize = 1; +const HANGUL_COMPAT_JAMO_NARROW_WIDTH: usize = 1; -#[inline] -const fn is_macos_hangul_compat_jamo(c: char) -> bool { - let cp = c as u32; - cfg!(target_os = "macos") && cp >= 0x3131 && cp <= 0x318e +/// Runtime override for Hangul Compatibility Jamo (U+3131..=U+318E) cell width. +/// 0 = unset → platform default (macOS: narrow 1 cell; otherwise UAX#11) +/// 1 = force narrow (1 cell) +/// 2 = force wide (2 cells) +/// 3 = force Unicode width (no correction) +/// The actual width is decided by the *client* terminal, not the host OS, so it +/// is resolved at runtime from the terminal identity (see packages/tui +/// terminal.ts) and pushed here through +/// `set_hangul_compat_jamo_width_override`. +static HANGUL_COMPAT_JAMO_WIDTH_OVERRIDE: AtomicU8 = AtomicU8::new(0); + +#[napi] +pub fn set_hangul_compat_jamo_width_override(value: u8) { + HANGUL_COMPAT_JAMO_WIDTH_OVERRIDE.store(value, Ordering::Relaxed); } #[inline] -fn apply_macos_hangul_compat_jamo_delta(width: usize, c: char) -> usize { - if !is_macos_hangul_compat_jamo(c) { +const fn is_hangul_compat_jamo(c: char) -> bool { + let cp = c as u32; + cp >= 0x3131 && cp <= 0x318e +} + +/// Effective target cell width for Compatibility Jamo, or `None` to follow the +/// Unicode width (no correction). Reads the runtime override, falling back to +/// the compile-time platform default when unset. +#[inline] +fn hangul_compat_jamo_target_width() -> Option { + match HANGUL_COMPAT_JAMO_WIDTH_OVERRIDE.load(Ordering::Relaxed) { + 1 => Some(1), + 2 => Some(2), + 3 => None, + _ => { + if cfg!(target_os = "macos") { + Some(HANGUL_COMPAT_JAMO_NARROW_WIDTH) + } else { + None + } + }, + } +} + +#[inline] +fn apply_hangul_compat_jamo_delta(width: usize, c: char) -> usize { + if !is_hangul_compat_jamo(c) { return width; } + let Some(target) = hangul_compat_jamo_target_width() else { + return width; + }; let unicode_width = UnicodeWidthChar::width(c).unwrap_or(0); - if unicode_width > MACOS_HANGUL_COMPAT_JAMO_WIDTH { - width.saturating_sub(unicode_width - MACOS_HANGUL_COMPAT_JAMO_WIDTH) + // The zero-width filler (U+3164 HANGUL FILLER) is an invisible placeholder. + // The target is set for *visible* jamo, so only the narrow correction + // (target 1) applies to the filler; a wide terminal renders it at its + // Unicode width (0), not the wide target. Never widen a + // zero-width jamo past the narrow correction. + if unicode_width == 0 && target > 1 { + return width; + } + if unicode_width > target { + width.saturating_sub(unicode_width - target) } else { - width.saturating_add(MACOS_HANGUL_COMPAT_JAMO_WIDTH - unicode_width) + width.saturating_add(target - unicode_width) } } #[inline] fn char_width_corrected(c: char) -> Option { - // Hangul Compatibility Jamo U+3131..=U+318E render as 1 cell on macOS - // terminals (Ghostty, Terminal.app, iTerm2), but follow UAX#11 at 2 - // cells on WezTerm and most Linux terminals. Only force 1 on macOS. - if is_macos_hangul_compat_jamo(c) { - return Some(MACOS_HANGUL_COMPAT_JAMO_WIDTH); + // Hangul Compatibility Jamo U+3131..=U+318E render as 1 cell on some + // terminals (Terminal.app, iTerm2) but follow UAX#11 at 2 cells on others + // (Ghostty, most Linux terminals). The width is resolved at runtime from the + // terminal identity and applied through the override; absent an override we + // fall back to the compile-time platform default. + if is_hangul_compat_jamo(c) + && let Some(target) = hangul_compat_jamo_target_width() + { + // Zero-width filler (U+3164): only the narrow correction applies — a + // wide terminal renders it at its Unicode width (0), not the effective + // wide target set for visible jamo. See apply_hangul_compat_jamo_delta. + let unicode_width = UnicodeWidthChar::width(c).unwrap_or(0); + if unicode_width == 0 && target > 1 { + return Some(unicode_width); + } + return Some(target); } UnicodeWidthChar::width(c) } @@ -575,14 +635,12 @@ fn grapheme_width_str(g: &str, tab_width: usize) -> usize { } // Multi-char grapheme: keep UnicodeWidthStr as the source of truth for // sequence-level width rules (VS16 emoji presentation, keycaps, ZWJ emoji, - // CRLF, script ligatures). A per-char sum is not equivalent. On macOS, - // apply only the same local Compatibility Jamo delta that - // char_width_corrected applies to standalone code points. + // CRLF, script ligatures). A per-char sum is not equivalent. Apply only the + // same local Compatibility Jamo delta that char_width_corrected applies to + // standalone code points; the delta is a no-op when no correction is active. let mut width = UnicodeWidthStr::width(g); - if cfg!(target_os = "macos") { - for c in g.chars() { - width = apply_macos_hangul_compat_jamo_delta(width, c); - } + for c in g.chars() { + width = apply_hangul_compat_jamo_delta(width, c); } width } diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 4875771a8..dc16bcb88 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `setHangulCompatJamoWidthOverride(value)` to override the Hangul Compatibility Jamo (U+3131..U+318E) display width at runtime via a process-global atomic, instead of relying solely on the compile-time `cfg!(target_os = "macos")` heuristic. The actual width is decided by the client terminal (not the host OS), so the TUI resolves it from the terminal identity and pushes the result here. Encoding: `0` = platform default (macOS narrow, otherwise UAX#11), `1` = narrow (1 cell), `2` = wide (2 cells), `3` = Unicode width (no correction). The leaf width helpers read this override, so no width/slice/truncate/wrap signatures change. + ## [16.1.15] - 2026-06-22 ### Added diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 87eda6161..e2475a6b9 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -1377,6 +1377,8 @@ export interface SearchResult { error?: string } +export declare function setHangulCompatJamoWidthOverride(value: number): void + /** Options for executing a shell command via brush-core. */ export interface ShellExecuteOptions { /** Command string to execute in the shell. */ diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index c33c65ffc..27f9b0643 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -62,6 +62,7 @@ export const parseKittySequence = nativeBindings.parseKittySequence; export const readImageFromClipboard = nativeBindings.readImageFromClipboard; export const renderSnapcompactPng = nativeBindings.renderSnapcompactPng; export const search = nativeBindings.search; +export const setHangulCompatJamoWidthOverride = nativeBindings.setHangulCompatJamoWidthOverride; export const sliceWithWidth = nativeBindings.sliceWithWidth; export const summarizeCode = nativeBindings.summarizeCode; export const supportsLanguage = nativeBindings.supportsLanguage; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 9cb0ee6f1..c00fdc87f 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- Added runtime resolution of the Hangul Compatibility Jamo (U+3131..U+318E) display width for terminals known to disagree with the platform default (e.g. Ghostty, which renders these at 2 cells). Fixes doubled/ghosted jamo during Korean IME composition; the resolved width is pushed into the native width engine before the first paint. Other terminals keep the platform default (macOS narrow, otherwise UAX#11), so the override is a no-op outside Ghostty. A runtime DSR/CPR probe for unknown terminals is tracked separately. +- Added `setHangulCompatibilityJamoWidth` / `getHangulCompatibilityJamoWidth` to set the jamo width profile (`"platform" | "unicode" | 1 | 2`); the profile is mirrored into the native `setHangulCompatJamoWidthOverride`. + ## [16.1.10] - 2026-06-21 ### Fixed diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index ff736c7d2..789994696 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -4,10 +4,29 @@ import { $env, isBunTestRuntime, isTerminalHeadless, logger } from "@oh-my-pi/pi import { setKittyProtocolActive } from "./keys"; import { StdinBuffer } from "./stdin-buffer"; import { NotifyProtocol, setCellDimensions, setOsc99Supported, TERMINAL } from "./terminal-capabilities"; +import { type HangulCompatibilityJamoWidth, setHangulCompatibilityJamoWidth } from "./utils"; const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000; const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07"; const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07"; +// Hangul Compatibility Jamo (U+3131..=U+318E) render width is terminal-dependent: +// Ghostty follows UAX#11 (2 cells); Terminal.app and iTerm2 render narrow (1), +// matching the macOS platform default. Override only for terminals known to +// disagree — the rest keep the platform default (macOS narrow, otherwise UAX#11), +// so this is a no-op everywhere except Ghostty. A runtime DSR/CPR probe that +// auto-detects the width on unknown terminals is tracked separately. +export function resolveHangulCompatibilityJamoWidthFromTerminalIdentity( + env: NodeJS.ProcessEnv = Bun.env, +): HangulCompatibilityJamoWidth { + if ( + env.GHOSTTY_RESOURCES_DIR || + env.TERM_PROGRAM?.toLowerCase() === "ghostty" || + env.TERM?.toLowerCase().includes("ghostty") + ) { + return 2; + } + return "platform"; +} /** * Maximum encoded UTF-8 bytes per `process.stdout.write` call on Windows. @@ -509,6 +528,7 @@ export class ProcessTerminal implements Terminal { // The query handler intercepts input temporarily, then installs the user's handler // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ this.#queryAndEnableKittyProtocol(); + setHangulCompatibilityJamoWidth(resolveHangulCompatibilityJamoWidthFromTerminalIdentity()); // Query terminal background color via OSC 11 for dark/light detection. // Uses DA1 (Primary Device Attributes) as a sentinel: terminals process diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 3ff0d8a1f..3fb5e7843 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -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; diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index 9311cab60..d6caf0871 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; import { CURSOR_MARKER } from "@oh-my-pi/pi-tui"; import { Input } from "@oh-my-pi/pi-tui/components/input"; import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; -import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; +import { + resetHangulCompatibilityJamoWidthForTests, + setHangulCompatibilityJamoWidth, + visibleWidth, +} from "@oh-my-pi/pi-tui/utils"; import { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils"; function renderedWidth(input: Input, width: number): number { @@ -23,6 +27,10 @@ describe("Input component", () => { return input; } + afterEach(() => { + resetHangulCompatibilityJamoWidthForTests(); + }); + it("moves by CJK and punctuation blocks (backward)", () => { const text = "天气不错,去散步吧!"; @@ -247,6 +255,30 @@ describe("Input component", () => { expect(input.getUseTerminalCursor()).toBe(true); }); + it("runtime jamo profile controls the cursor marker column", () => { + // The hardware cursor column is `prompt + visibleWidth(value before + // cursor)`. Once the terminal probe sets the jamo width, that column must + // track it: 8 narrow jamo land at +8, 8 wide jamo at +16. + const promptWidth = 2; // "> " + const jamo = "ㅁ".repeat(8); + + const narrow = new Input(); + narrow.focused = true; + setHangulCompatibilityJamoWidth(1); + narrow.setValue(jamo); + narrow.handleInput("\x05"); // Ctrl+E (end) + const narrowLine = narrow.render(80)[0]; + expect(visibleWidth(narrowLine.slice(0, narrowLine.indexOf(CURSOR_MARKER)))).toBe(promptWidth + 8); + + const wide = new Input(); + wide.focused = true; + setHangulCompatibilityJamoWidth(2); + wide.setValue(jamo); + wide.handleInput("\x05"); // Ctrl+E (end) + const wideLine = wide.render(80)[0]; + expect(visibleWidth(wideLine.slice(0, wideLine.indexOf(CURSOR_MARKER)))).toBe(promptWidth + 16); + }); + it("pasteText absorbs a payload from a non-bracketed transport (kitty OSC 5522)", () => { // Regression for #2127: when kitty's enhanced clipboard read delivers the // API key directly via `pasteText`, the modal Input must capture it just diff --git a/packages/tui/test/terminal-jamo-width-probe.test.ts b/packages/tui/test/terminal-jamo-width-probe.test.ts new file mode 100644 index 000000000..7f7063086 --- /dev/null +++ b/packages/tui/test/terminal-jamo-width-probe.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "bun:test"; +import { resolveHangulCompatibilityJamoWidthFromTerminalIdentity } from "@oh-my-pi/pi-tui/terminal"; + +describe("Hangul Compatibility Jamo width terminal-identity resolution", () => { + it("forces wide (2) for Ghostty, platform default otherwise", () => { + // Ghostty follows UAX#11 and renders Hangul Compatibility Jamo at 2 cells; + // every other terminal keeps the platform default (macOS narrow, otherwise + // UAX#11), so the override is a no-op outside Ghostty. + expect( + resolveHangulCompatibilityJamoWidthFromTerminalIdentity({ + GHOSTTY_RESOURCES_DIR: "/Applications/Ghostty.app", + }), + ).toBe(2); + expect(resolveHangulCompatibilityJamoWidthFromTerminalIdentity({ TERM_PROGRAM: "ghostty" })).toBe(2); + // Ghostty identified only via TERM (env-filtered shells that drop + // GHOSTTY_RESOURCES_DIR / TERM_PROGRAM) must still resolve wide — mirrors + // the Ghostty detection in terminal-capabilities.ts. + expect(resolveHangulCompatibilityJamoWidthFromTerminalIdentity({ TERM: "xterm-ghostty" })).toBe(2); + expect(resolveHangulCompatibilityJamoWidthFromTerminalIdentity({ TERM_PROGRAM: "iTerm.app" })).toBe("platform"); + expect(resolveHangulCompatibilityJamoWidthFromTerminalIdentity({ TERM_PROGRAM: "Apple_Terminal" })).toBe( + "platform", + ); + expect(resolveHangulCompatibilityJamoWidthFromTerminalIdentity({})).toBe("platform"); + }); +}); diff --git a/packages/tui/test/visible-width.test.ts b/packages/tui/test/visible-width.test.ts index 98afd6a67..02e07dbf0 100644 --- a/packages/tui/test/visible-width.test.ts +++ b/packages/tui/test/visible-width.test.ts @@ -10,15 +10,27 @@ * on top of `Bun.stringWidth` (tabs, OSC 66 scaling) and catches silent * `Bun.stringWidth` width-table drift across Bun upgrades. */ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; import { visibleWidth as nativeVisibleWidth } from "@oh-my-pi/pi-natives"; -import { DEFAULT_TAB_WIDTH, visibleWidth } from "@oh-my-pi/pi-tui/utils"; +import { + DEFAULT_TAB_WIDTH, + Ellipsis, + resetHangulCompatibilityJamoWidthForTests, + setHangulCompatibilityJamoWidth, + sliceWithWidth, + truncateToWidth, + visibleWidth, +} from "@oh-my-pi/pi-tui/utils"; const ESC = "\x1b"; const ST = "\x1b\\"; const BEL = "\x07"; const TAB = DEFAULT_TAB_WIDTH; +afterEach(() => { + resetHangulCompatibilityJamoWidthForTests(); +}); + describe("visibleWidth — parity with the native width engine", () => { const corpus: [string, string][] = [ ["empty", ""], @@ -31,6 +43,8 @@ describe("visibleWidth — parity with the native width engine", () => { ["cjk", "日本語のテキスト"], ["cjk-mixed", "abc中文def"], ["hangul-syllables", "안녕하세요"], + ["compat-jamo", "ㅁㄴㅇㅂ"], + ["compat-jamo-filler", "ㅁ\u3164ㅁ"], ["styled-cjk", `${ESC}[1m漢字${ESC}[0m`], ["emoji", "👍 done"], ["emoji-zwj", "👨‍👩‍👧‍👦"], @@ -75,3 +89,68 @@ describe("visibleWidth — parity with the native width engine", () => { expect(visibleWidth(`${ESC}]66;s=3:w=4;X${ST}`)).toBe(12); // 3 * 4 }); }); + +describe("visibleWidth — runtime Hangul Compatibility Jamo profile", () => { + // `Bun.stringWidth` reports Compatibility Jamo (U+3131..U+318E) at 2 cells, + // but the real width is terminal-dependent and detected at runtime. The + // profile pushed by the probe must steer `visibleWidth` (and the native + // width engine) so the hardware cursor and truncation math stay aligned. + it("forces jamo narrow or wide independent of the OS default", () => { + setHangulCompatibilityJamoWidth(1); + expect(visibleWidth("ㅁㄴㅇㅂ")).toBe(4); + + setHangulCompatibilityJamoWidth(2); + expect(visibleWidth("ㅁㄴㅇㅂ")).toBe(8); + }); + + it("opts back into Unicode width for compatibility jamo", () => { + setHangulCompatibilityJamoWidth("unicode"); + expect(visibleWidth("ㅁ")).toBe(2); + expect(visibleWidth("\u3164")).toBe(0); + }); + + it("never widens the zero-width filler (U+3164) past the narrow correction", () => { + // The probe only measures a visible jamo (ㅁ). The invisible filler must + // not inherit a wide (2-cell) probe result — a wide terminal renders it + // at its Unicode width (0). Otherwise IME empty-syllable placeholders + // overcount the cursor/truncation math by 2 cells. + setHangulCompatibilityJamoWidth(2); + expect(visibleWidth("ㅁ")).toBe(2); + expect(visibleWidth("\u3164")).toBe(0); + expect(visibleWidth("ㅁ\u3164ㅁ")).toBe(4); + + // The narrow correction (1 cell) still applies to the filler. + setHangulCompatibilityJamoWidth(1); + expect(visibleWidth("\u3164")).toBe(1); + }); + + it("leaves composed Hangul syllables at 2 cells under any profile", () => { + setHangulCompatibilityJamoWidth(1); + expect(visibleWidth("안녕")).toBe(4); + + setHangulCompatibilityJamoWidth(2); + expect(visibleWidth("안녕")).toBe(4); + }); + + it("stays in parity with the native width engine under each profile", () => { + const input = "ㅁㄴㅇㅂㅈ\u3164"; + for (const profile of [1, 2, "unicode"] as const) { + setHangulCompatibilityJamoWidth(profile); + expect(visibleWidth(input)).toBe(nativeVisibleWidth(input, TAB)); + } + }); +}); + +describe("native text helpers — runtime Hangul Compatibility Jamo profile", () => { + it("sliceWithWidth and truncateToWidth follow the jamo width profile", () => { + const input = "ㅁ".repeat(8); + + setHangulCompatibilityJamoWidth(2); + expect(sliceWithWidth(input, 0, 16, true)).toEqual({ text: input, width: 16 }); + expect(truncateToWidth("ㅁ".repeat(20), 16, Ellipsis.Omit)).toBe(input); + + setHangulCompatibilityJamoWidth(1); + expect(sliceWithWidth(input, 0, 8, true)).toEqual({ text: input, width: 8 }); + expect(truncateToWidth("ㅁ".repeat(20), 8, Ellipsis.Omit)).toBe(input); + }); +});