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