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); + }); +});