From fc5a7277297f7098d1d53e4ad972d51a8fc4da4c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 3 Jul 2026 21:10:37 -0700 Subject: [PATCH] lib-vt: add unicode codepoint width API Embedders that render text outside the terminal grid need to predict how many cells a codepoint will occupy once it is written to the terminal. The immediate motivation is IME preedit overlay rendering: measuring preedit text with font APIs (e.g. CoreText advances) can disagree with the terminal's unicode table on ambiguous-width CJK and emoji, causing the overlay to visibly jump when the composed text commits and reflows through the real grid layout. This exposes the exact width table the terminal print path already uses, so overlays are column-accurate by construction. From C: uint8_t w = ghostty_unicode_codepoint_width(0x4E00); // 2 And from the Zig module: const vt = @import("ghostty-vt"); const w = vt.unicode.codepointWidth(0x4E00); // 2 The function is total over its input: 0 for zero-width codepoints (controls, combining marks, default-ignorables, surrogates), 2 for wide codepoints (East Asian Wide/Fullwidth, regional indicators, clamped at 2), and 1 for everything else, including invalid values beyond U+10FFFF. Perf: uses the LUT lookup we use for the main core terminal Binary size: the width table was already linked into libghostty-vt via the print path, so this adds only the exported wrapper. --- include/ghostty/vt.h | 2 + include/ghostty/vt/unicode.h | 73 ++++++++++++++++++++++++++++++++++++ src/lib_vt.zig | 9 +++++ src/terminal/c/main.zig | 4 ++ src/terminal/c/unicode.zig | 29 ++++++++++++++ src/unicode/main.zig | 48 ++++++++++++++++++++++++ 6 files changed, 165 insertions(+) create mode 100644 include/ghostty/vt/unicode.h create mode 100644 src/terminal/c/unicode.zig diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 94a850334..30df72a3a 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -34,6 +34,7 @@ * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences * - @ref paste "Paste Utilities" - Validate paste data safety + * - @ref unicode "Unicode Utilities" - Codepoint properties for text layout * - @ref build_info "Build Info" - Query compile-time build configuration * - @ref allocator "Memory Management" - Memory management and custom allocators * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions @@ -145,6 +146,7 @@ extern "C" { #include #include #include +#include #include #ifdef __cplusplus diff --git a/include/ghostty/vt/unicode.h b/include/ghostty/vt/unicode.h new file mode 100644 index 000000000..9f1f114a9 --- /dev/null +++ b/include/ghostty/vt/unicode.h @@ -0,0 +1,73 @@ +/** + * @file unicode.h + * + * Unicode utilities - codepoint properties matching the terminal's + * text layout semantics. + */ + +#ifndef GHOSTTY_VT_UNICODE_H +#define GHOSTTY_VT_UNICODE_H + +/** @defgroup unicode Unicode Utilities + * + * Unicode codepoint properties matching the terminal's text layout + * semantics. + * + * ## Basic Usage + * + * Use ghostty_unicode_codepoint_width() to determine how many terminal + * grid cells a codepoint occupies, using the exact same width table the + * terminal itself uses when laying out printed text. This is useful for + * predicting column layout of text that has not yet been written to the + * terminal, such as IME preedit (composition) overlays. + * + * @{ + */ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Returns the terminal display width of a Unicode codepoint in + * terminal grid cells: 0, 1, or 2. + * + * This is the same width table the terminal itself uses when laying + * out printed text, so callers can predict column layout (e.g. IME + * preedit overlays) that exactly matches what the terminal will do + * when the text is actually written to it. + * + * Semantics: + * - Returns 0 for zero-width codepoints: C0/C1 control characters, + * nonspacing and enclosing combining marks, default-ignorable + * codepoints (ZWJ, ZWNJ, variation selectors, etc.), and + * surrogate codepoints. + * - Returns 2 for wide codepoints: East Asian Wide/Fullwidth + * (including emoji with default emoji presentation) and regional + * indicators. Width is clamped to 2 (e.g. the three-em dash). + * - Returns 1 for everything else, including invalid codepoints + * beyond U+10FFFF (this function is total; it never fails). + * + * This operates on a single codepoint only and therefore cannot + * account for grapheme-cluster-level width rules (VS16 emoji + * presentation, combining sequences, etc.). Callers wanting + * cluster-accurate widths must segment text into grapheme clusters + * themselves and combine per-codepoint widths. + * + * This function is pure, allocates nothing, and is thread-safe. + * + * @param cp The Unicode codepoint to measure + * @return Display width in cells: 0, 1, or 2 + */ +GHOSTTY_API uint8_t ghostty_unicode_codepoint_width(uint32_t cp); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_UNICODE_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 97dbbba80..ad78190e4 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -133,6 +133,13 @@ pub const input = struct { pub const encodeMouse = mouse_encode.encode; }; +/// Unicode utilities that match the terminal's text layout semantics. +pub const unicode = struct { + const unicode_pkg = @import("unicode/main.zig"); + + pub const codepointWidth = unicode_pkg.codepointWidth; +}; + comptime { // If we're building the C library (vs. the Zig module) then // we want to reference the C API so that it gets exported. @@ -187,6 +194,7 @@ comptime { @export(&c.mode_report_encode, .{ .name = "ghostty_mode_report_encode" }); @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); @export(&c.paste_encode, .{ .name = "ghostty_paste_encode" }); + @export(&c.unicode_codepoint_width, .{ .name = "ghostty_unicode_codepoint_width" }); @export(&c.size_report_encode, .{ .name = "ghostty_size_report_encode" }); @export(&c.style_default, .{ .name = "ghostty_style_default" }); @export(&c.style_is_default, .{ .name = "ghostty_style_is_default" }); @@ -341,6 +349,7 @@ test { _ = terminal; _ = @import("lib/main.zig"); @import("std").testing.refAllDecls(input); + @import("std").testing.refAllDecls(unicode); if (comptime terminal.options.c_abi) { _ = terminal.c_api; } diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 648bdbe51..1344351af 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -43,6 +43,7 @@ pub const size_report = @import("size_report.zig"); pub const style = @import("style.zig"); pub const sys = @import("sys.zig"); pub const terminal = @import("terminal.zig"); +pub const unicode = @import("unicode.zig"); // The full C API, unexported. pub const build_info = buildpkg.get; @@ -198,6 +199,8 @@ pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; pub const type_json = types.get_json; +pub const unicode_codepoint_width = unicode.codepoint_width; + pub const grid_ref_cell = grid_ref.grid_ref_cell; pub const grid_ref_row = grid_ref.grid_ref_row; pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes; @@ -236,6 +239,7 @@ test { _ = sys; _ = terminal; _ = types; + _ = unicode; // We want to make sure we run the tests for the C allocator interface. _ = @import("../../lib/allocator.zig"); diff --git a/src/terminal/c/unicode.zig b/src/terminal/c/unicode.zig new file mode 100644 index 000000000..716ff1e23 --- /dev/null +++ b/src/terminal/c/unicode.zig @@ -0,0 +1,29 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const unicode_pkg = @import("../../unicode/main.zig"); + +pub fn codepoint_width(cp: u32) callconv(lib.calling_conv) u8 { + if (cp > 0x10FFFF) return 1; + return unicode_pkg.codepointWidth(@intCast(cp)); +} + +test "codepoint_width narrow" { + const testing = std.testing; + try testing.expectEqual(1, codepoint_width('a')); +} + +test "codepoint_width wide" { + const testing = std.testing; + try testing.expectEqual(2, codepoint_width(0x4E00)); +} + +test "codepoint_width zero" { + const testing = std.testing; + try testing.expectEqual(0, codepoint_width(0x0301)); +} + +test "codepoint_width out of range" { + const testing = std.testing; + try testing.expectEqual(1, codepoint_width(0x110000)); + try testing.expectEqual(1, codepoint_width(std.math.maxInt(u32))); +} diff --git a/src/unicode/main.zig b/src/unicode/main.zig index 11ecbd903..1e8466285 100644 --- a/src/unicode/main.zig +++ b/src/unicode/main.zig @@ -5,6 +5,54 @@ pub const table = @import("props_table.zig").table; pub const Properties = @import("props.zig").Properties; pub const graphemeBreak = grapheme.graphemeBreak; +/// Returns the terminal display width of a codepoint in terminal +/// grid cells: 0, 1, or 2. +/// +/// This is the same width table the terminal uses when laying out +/// printed text: 0 for zero-width codepoints (controls, combining +/// marks, default-ignorables, surrogates), 2 for wide codepoints +/// (East Asian Wide/Fullwidth, regional indicators, clamped at 2), +/// and 1 otherwise. +/// +/// This operates on a single codepoint and cannot account for +/// grapheme-cluster-level width rules (VS16, combining sequences); +/// callers needing cluster-accurate widths must segment into +/// grapheme clusters and combine per-codepoint widths. +pub fn codepointWidth(cp: u21) u2 { + return table.get(cp).width; +} + +test "codepointWidth" { + const testing = @import("std").testing; + + // Narrow (width 1) + try testing.expectEqual(1, codepointWidth('a')); + try testing.expectEqual(1, codepointWidth(' ')); + try testing.expectEqual(1, codepointWidth(0x10FFFF)); // max codepoint + + // C0/C1 control characters (width 0) + try testing.expectEqual(0, codepointWidth(0x00)); // NUL + try testing.expectEqual(0, codepointWidth(0x07)); // BEL + try testing.expectEqual(0, codepointWidth(0x1B)); // ESC + try testing.expectEqual(0, codepointWidth(0x7F)); // DEL + try testing.expectEqual(0, codepointWidth(0x80)); // C1 PAD + + // Zero-width codepoints + try testing.expectEqual(0, codepointWidth(0x0301)); // combining acute + try testing.expectEqual(0, codepointWidth(0x200B)); // zero width space + try testing.expectEqual(0, codepointWidth(0x200D)); // ZWJ + try testing.expectEqual(0, codepointWidth(0xFE0F)); // VS16 + try testing.expectEqual(0, codepointWidth(0xD800)); // surrogate + + // Wide (width 2) + try testing.expectEqual(2, codepointWidth(0x4E00)); // CJK ideograph + try testing.expectEqual(2, codepointWidth(0xFF21)); // fullwidth A + try testing.expectEqual(2, codepointWidth(0xAC00)); // Hangul syllable + try testing.expectEqual(2, codepointWidth(0x1F600)); // emoji + try testing.expectEqual(2, codepointWidth(0x1F1E6)); // regional indicator + try testing.expectEqual(2, codepointWidth(0x2E3B)); // three-em dash (clamped) +} + test { @import("std").testing.refAllDecls(@This()); }