From 3cf01e84453c73e196cd3900d1b30757f4358e84 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 28 May 2026 09:29:57 -0700 Subject: [PATCH 1/2] libghostty: add utf-8 grapheme cell getter to C API Add a render-state row-cells getter that encodes the current cell's full grapheme cluster directly as UTF-8 into a caller-provided GhosttyBuffer. The getter writes the base codepoint first, followed by any extra grapheme codepoints, and follows the existing buffer-writer convention where len is bytes written on success or required capacity on GHOSTTY_OUT_OF_SPACE. Previously C consumers could query grapheme codepoints, but bindings that needed UTF-8 text had to reconstruct and encode the cluster themselves. That duplicated terminal internals in downstream bindings and made users pay for awkward cross-language struct handling. By owning the UTF-8/grapheme behavior in libghostty, bindings can use one stable C API and optionally wrap it with small binding-local helpers. --- include/ghostty/vt/render.h | 13 +++++ include/ghostty/vt/types.h | 17 +++++++ src/lib/main.zig | 1 + src/lib/types.zig | 6 +++ src/terminal/c/render.zig | 99 +++++++++++++++++++++++++++++++++++++ src/terminal/c/types.zig | 1 + src/terminal/lib.zig | 1 + 7 files changed, 138 insertions(+) diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index acf44076a..c5b1d0d4f 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -614,6 +614,19 @@ typedef enum GHOSTTY_ENUM_TYPED { * GhosttyCell for renderers that only need to know whether fetching the * full style is necessary. */ GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING = 8, + + /** + * Encode the current cell's full grapheme cluster as UTF-8 into a + * caller-provided buffer (GhosttyBuffer). + * + * The base codepoint is encoded first, followed by any extra grapheme + * codepoints. Returns GHOSTTY_SUCCESS with len=0 when the cell has no text. + * + * If ptr is NULL or cap is too small for a non-empty cell, returns + * GHOSTTY_OUT_OF_SPACE without writing any bytes and sets len to the required + * buffer size in bytes. + */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8 = 9, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateRowCellsData; diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index 1bec223d6..214d28229 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -227,6 +227,23 @@ typedef struct { size_t len; } GhosttyString; +/** + * A caller-provided byte buffer. + * + * APIs that write to this type use `len` for the number of bytes written on + * GHOSTTY_SUCCESS and the required byte capacity on GHOSTTY_OUT_OF_SPACE. + */ +typedef struct { + /** Destination buffer for bytes. May be NULL when cap is 0 to query required size. */ + uint8_t* ptr; + + /** Capacity of ptr in bytes. */ + size_t cap; + + /** Bytes written on success, or required byte capacity on GHOSTTY_OUT_OF_SPACE. */ + size_t len; +} GhosttyBuffer; + /** * A surface-space position in pixels. * diff --git a/src/lib/main.zig b/src/lib/main.zig index 05ebe9bd7..b22b3d987 100644 --- a/src/lib/main.zig +++ b/src/lib/main.zig @@ -5,6 +5,7 @@ const types = @import("types.zig"); const unionpkg = @import("union.zig"); pub const allocator = @import("allocator.zig"); +pub const Buffer = types.Buffer; pub const Enum = enumpkg.Enum; pub const checkGhosttyHEnum = enumpkg.checkGhosttyHEnum; pub const String = types.String; diff --git a/src/lib/types.zig b/src/lib/types.zig index 758540d12..655fdc220 100644 --- a/src/lib/types.zig +++ b/src/lib/types.zig @@ -11,3 +11,9 @@ pub const String = extern struct { }; } }; + +pub const Buffer = extern struct { + ptr: ?[*]u8 = null, + cap: usize = 0, + len: usize = 0, +}; diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index bf74bd545..0f3b2e781 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -467,6 +467,7 @@ pub const RowCellsData = enum(c_int) { fg_color = 6, selected = 7, has_styling = 8, + graphemes_utf8 = 9, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: RowCellsData) type { @@ -478,6 +479,7 @@ pub const RowCellsData = enum(c_int) { .graphemes_buf => u32, .bg_color, .fg_color => colorpkg.RGB.C, .selected, .has_styling => bool, + .graphemes_utf8 => lib.Buffer, }; } }; @@ -493,6 +495,7 @@ pub fn row_cells_get( return .invalid_value; }; } + if (out == null) return .invalid_value; return switch (data) { .invalid => .invalid_value, @@ -573,11 +576,44 @@ fn rowCellsGetTyped( else false, .has_styling => out.* = cell.hasStyling(), + .graphemes_utf8 => return rowCellsGetGraphemesUtf8(cell, if (cell.hasGrapheme()) cells.graphemes[x] else &.{}, out), } return .success; } +fn rowCellsGetGraphemesUtf8( + cell: page.Cell, + extra: []const u21, + out: *lib.Buffer, +) Result { + out.len = 0; + + if (!cell.hasText()) return .success; + + var needed = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch + return .invalid_value; + for (extra) |cp| { + needed += std.unicode.utf8CodepointSequenceLength(cp) catch + return .invalid_value; + } + out.len = needed; + + if (out.ptr == null or out.cap < needed) return .out_of_space; + + const buf = out.ptr.?[0..out.cap]; + var i: usize = 0; + i += std.unicode.utf8Encode(cell.codepoint(), buf[i..]) catch + return .invalid_value; + for (extra) |cp| { + i += std.unicode.utf8Encode(cp, buf[i..]) catch + return .invalid_value; + } + + out.len = i; + return .success; +} + /// C: GhosttyRenderStateRowData pub const RowData = enum(c_int) { invalid = 0, @@ -1256,6 +1292,69 @@ test "render: row cells get has_styling" { try testing.expect(has_styling); } +test "render: row cells get graphemes utf8" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 10, .rows = 3, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + const input = "e\u{301}"; + terminal_c.vt_write(terminal, input, input.len); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + + try testing.expectEqual(Result.success, row_cells_select(cells, 0)); + + var text: lib.Buffer = .{}; + try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); + try testing.expectEqual(@as(usize, input.len), text.len); + + var small = [_]u8{ 'x', 'x' }; + text = .{ .ptr = &small, .cap = small.len }; + try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); + try testing.expectEqual(@as(usize, input.len), text.len); + try testing.expectEqualSlices(u8, &.{ 'x', 'x' }, &small); + + var buf: [8]u8 = undefined; + text = .{ .ptr = &buf, .cap = buf.len }; + try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); + try testing.expectEqual(input.len, text.len); + try testing.expectEqualStrings(input, buf[0..text.len]); + + try testing.expectEqual(Result.success, row_cells_select(cells, 1)); + text = .{ .ptr = &buf, .cap = buf.len }; + try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); + try testing.expectEqual(@as(usize, 0), text.len); +} + test "render: row iterator next" { var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index a44dd1ff5..de043a038 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -36,6 +36,7 @@ pub const Codepoints = extern struct { pub const structs: std.StaticStringMap(StructInfo) = structs: { @setEvalBranchQuota(10_000); break :structs .initComptime(.{ + .{ "GhosttyBuffer", StructInfo.init(lib.Buffer) }, .{ "GhosttyCodepoints", StructInfo.init(Codepoints) }, .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, diff --git a/src/terminal/lib.zig b/src/terminal/lib.zig index 3cd657b4e..921d831b7 100644 --- a/src/terminal/lib.zig +++ b/src/terminal/lib.zig @@ -14,6 +14,7 @@ pub const calling_conv: std.builtin.CallingConvention = .c; /// Forwarded decls from lib that are used. pub const alloc = lib.allocator; +pub const Buffer = lib.Buffer; pub const Enum = lib.Enum; pub const TaggedUnion = lib.TaggedUnion; pub const Struct = lib.Struct; From 519a612bebf25887973bab4ae22bba85f48a5e6b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 28 May 2026 13:00:14 -0700 Subject: [PATCH 2/2] libghostty: fix wasm build for selection gesture --- src/terminal/SelectionGesture.zig | 22 +++++++++++++++++++--- src/terminal/c/selection_gesture.zig | 8 +++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index 22ba468b9..f0e11f63e 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -67,6 +67,7 @@ const SelectionGesture = @This(); const std = @import("std"); +const builtin = @import("builtin"); const assert = std.debug.assert; const testing = std.testing; const Allocator = std.mem.Allocator; @@ -79,6 +80,16 @@ const Selection = @import("Selection.zig"); const Terminal = @import("Terminal.zig"); const point = @import("point.zig"); +const freestanding_wasm = builtin.target.cpu.arch == .wasm32 and + builtin.target.os.tag == .freestanding; + +/// Monotonic timestamp type for click-repeat detection. +/// +/// Freestanding wasm cannot reference std.time.Instant because Zig's stdlib +/// Instant type depends on POSIX timespec for that target, so represent the C +/// API nanosecond timestamp directly as a u64 there. +pub const Time = if (freestanding_wasm) u64 else std.time.Instant; + /// The tracked pin of the initial left click along with the screen /// that the pin is part of. left_click_pin: ?*Pin, @@ -89,7 +100,7 @@ left_click_screen_generation: usize, /// The left click time was the last time the left click was done, if the /// caller could provide one. If this is null then we only support single clicks. left_click_count: u3, -left_click_time: ?std.time.Instant, +left_click_time: ?Time, /// The selection behavior chosen for the active left-click gesture. left_click_behavior: Behavior, @@ -220,7 +231,7 @@ pub const Press = struct { /// The time when the press event occurred. Use a monotonic timer. /// This can be null if you're on a system that doesn't support /// time for some reason. In that case, we only support single clicks. - time: ?std.time.Instant, + time: ?Time, /// The cell where the click was. /// @@ -619,7 +630,7 @@ fn pressRepeat( const time = p.time orelse return error.PressRequiresReset; { const prev_time = self.left_click_time orelse return error.PressRequiresReset; - const since = time.since(prev_time); + const since = timeSince(time, prev_time); if (since > p.repeat_interval) return error.PressRequiresReset; } @@ -653,6 +664,11 @@ fn pressRepeat( self.left_click_behavior = p.behaviors[self.left_click_count - 1]; } +fn timeSince(time: Time, prev_time: Time) u64 { + if (comptime freestanding_wasm) return time -| prev_time; + return time.since(prev_time); +} + fn pressSelection( self: *const SelectionGesture, screen: *Screen, diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index 3562447d9..cc56da3c0 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -738,7 +738,13 @@ fn clearWordBoundaryCodepoints(event: *EventWrapper, target: *[]const u21) void target.* = &selection_codepoints.default_word_boundaries; } -fn instantFromNs(ns: u64) std.time.Instant { +fn instantFromNs(ns: u64) SelectionGesture.Time { + if (comptime builtin.target.cpu.arch == .wasm32 and + builtin.target.os.tag == .freestanding) + { + return ns; + } + return switch (builtin.os.tag) { .windows, .uefi, .wasi => .{ .timestamp = ns }, else => .{ .timestamp = .{