lib-vt: add unicode codepoint width API (#13178)

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.
This commit is contained in:
Mitchell Hashimoto
2026-07-03 21:28:42 -07:00
committed by GitHub
6 changed files with 165 additions and 0 deletions

View File

@@ -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 <ghostty/vt/screen.h>
#include <ghostty/vt/selection.h>
#include <ghostty/vt/size_report.h>
#include <ghostty/vt/unicode.h>
#include <ghostty/vt/wasm.h>
#ifdef __cplusplus

View File

@@ -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 <stdint.h>
#include <ghostty/vt/types.h>
#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 */

View File

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

View File

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

View File

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

View File

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