From 9673a22b01317ac6493240694a2451c8287253d1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Aug 2026 20:06:51 -0700 Subject: [PATCH 1/3] libghostty: expand ABI type metadata The type metadata export only described extern struct layouts, leaving embedders to mirror enum values and tagged union relationships. Describe every public C type in a versioned manifest with target and build metadata. Keep union field renames alongside their source tagged unions so the manifest uses public C names without changing Zig value layouts. --- example/wasm-vt/index.html | 28 +- include/ghostty/vt/osc.h | 3 + include/ghostty/vt/types.h | 47 +- src/lib/union.zig | 94 ++- src/terminal/Terminal.zig | 2 +- src/terminal/c/osc.zig | 5 +- src/terminal/c/terminal.zig | 2 +- src/terminal/c/types.zig | 1066 ++++++++++++++++++++++++++--------- src/terminal/point.zig | 10 +- src/terminal/sgr.zig | 13 +- src/terminal/stream.zig | 2 +- 11 files changed, 945 insertions(+), 327 deletions(-) diff --git a/example/wasm-vt/index.html b/example/wasm-vt/index.html index 81a033737..76c9b9bbd 100644 --- a/example/wasm-vt/index.html +++ b/example/wasm-vt/index.html @@ -156,7 +156,7 @@ // Look up a field's offset and DataView setter from the type layout JSON. function fieldInfo(structName, fieldName) { - const field = typeLayout[structName].fields[fieldName]; + const field = typeLayout.types[structName].fields[fieldName]; return field; } @@ -166,9 +166,16 @@ switch (field.type) { case 'u8': case 'bool': view.setUint8(field.offset, value); break; case 'u16': view.setUint16(field.offset, value, true); break; - case 'u32': case 'enum': view.setUint32(field.offset, value, true); break; + case 'u32': view.setUint32(field.offset, value, true); break; case 'u64': view.setBigUint64(field.offset, BigInt(value), true); break; - default: throw new Error(`Unsupported field type: ${field.type}`); + default: { + const type = typeLayout.types[field.type]; + if (type?.kind === 'enum' && type.underlying === 'i32') { + view.setInt32(field.offset, value, true); + break; + } + throw new Error(`Unsupported field type: ${field.type}`); + } } } @@ -186,15 +193,14 @@ .replace(/\\\\/g, '\\'); } - // GHOSTTY_FORMATTER_FORMAT_PLAIN = 0 - const GHOSTTY_FORMATTER_FORMAT_PLAIN = 0; - // GHOSTTY_SUCCESS = 0 - const GHOSTTY_SUCCESS = 0; - function run() { const outputDiv = document.getElementById('output'); try { + const GHOSTTY_FORMATTER_FORMAT_PLAIN = + typeLayout.types.GhosttyFormatterFormat.values.PLAIN; + const GHOSTTY_SUCCESS = + typeLayout.types.GhosttyResult.values.SUCCESS; const cols = parseInt(document.getElementById('cols').value, 10); const rows = parseInt(document.getElementById('rows').value, 10); const vtText = parseEscapes(document.getElementById('vtInput').value); @@ -224,7 +230,7 @@ wasmInstance.exports.ghostty_wasm_free_u8_array(dataPtr, vtBytes.length); // Create a plain-text formatter - const FMT_OPTS_SIZE = typeLayout['GhosttyFormatterTerminalOptions'].size; + const FMT_OPTS_SIZE = typeLayout.types.GhosttyFormatterTerminalOptions.size; const fmtOptsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(FMT_OPTS_SIZE); new Uint8Array(getBuffer(), fmtOptsPtr, FMT_OPTS_SIZE).fill(0); const fmtOptsView = new DataView(getBuffer(), fmtOptsPtr, FMT_OPTS_SIZE); @@ -235,12 +241,12 @@ // Set the nested sized-struct `size` fields for extra and extra.screen const extraOffset = fieldInfo('GhosttyFormatterTerminalOptions', 'extra').offset; - const extraSize = typeLayout['GhosttyFormatterTerminalExtra'].size; + const extraSize = typeLayout.types.GhosttyFormatterTerminalExtra.size; const extraSizeField = fieldInfo('GhosttyFormatterTerminalExtra', 'size'); fmtOptsView.setUint32(extraOffset + extraSizeField.offset, extraSize, true); const screenOffset = fieldInfo('GhosttyFormatterTerminalExtra', 'screen').offset; - const screenSize = typeLayout['GhosttyFormatterScreenExtra'].size; + const screenSize = typeLayout.types.GhosttyFormatterScreenExtra.size; const screenSizeField = fieldInfo('GhosttyFormatterScreenExtra', 'size'); fmtOptsView.setUint32(extraOffset + screenOffset + screenSizeField.offset, screenSize, true); diff --git a/include/ghostty/vt/osc.h b/include/ghostty/vt/osc.h index 9409ebc73..f7626383c 100644 --- a/include/ghostty/vt/osc.h +++ b/include/ghostty/vt/osc.h @@ -63,6 +63,9 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20, GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21, GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22, + GHOSTTY_OSC_COMMAND_KITTY_CLIPBOARD_PROTOCOL = 23, + GHOSTTY_OSC_COMMAND_KITTY_DND_PROTOCOL = 24, + GHOSTTY_OSC_COMMAND_CONTEXT_SIGNAL = 25, GHOSTTY_OSC_COMMAND_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyOscCommandType; diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index c66ee89d2..bc1c6a69b 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -332,29 +332,40 @@ typedef struct { #endif /** - * Return a pointer to a null-terminated JSON string describing the - * layout of every C API struct for the current target. + * Return the versioned libghostty-vt C type manifest for the current target. * - * This is primarily useful for language bindings that can't easily - * set C struct fields and need to do so via byte offsets. For example, - * WebAssembly modules can't share struct definitions with the host. + * The manifest defines all the public types available in the linked + * build. The types contain their layouts, enum values, union fields, and more. + * + * Language bindings, such as WebAssembly hosts, should obtain offsets, + * sizes, alignments, array shapes, enum constants, and tagged-union arms from + * this manifest rather than hardcoding them. Consumers should reject unknown + * schema versions and verify the descriptors they require at initialization. * * Example (abbreviated): * @code{.json} * { - * "GhosttyMouseEncoderSize": { - * "size": 40, - * "align": 8, - * "fields": { - * "size": { "offset": 0, "size": 8, "type": "u64" }, - * "screen_width": { "offset": 8, "size": 4, "type": "u32" }, - * "screen_height": { "offset": 12, "size": 4, "type": "u32" }, - * "cell_width": { "offset": 16, "size": 4, "type": "u32" }, - * "cell_height": { "offset": 20, "size": 4, "type": "u32" }, - * "padding_top": { "offset": 24, "size": 4, "type": "u32" }, - * "padding_bottom": { "offset": 28, "size": 4, "type": "u32" }, - * "padding_right": { "offset": 32, "size": 4, "type": "u32" }, - * "padding_left": { "offset": 36, "size": 4, "type": "u32" } + * "schema": 1, + * "abi": { + * "target": "wasm32", "os": "freestanding", "environment": "none", + * "pointer_size": 4, "usize_size": 4, "endian": "little" + * }, + * "types": { + * "GhosttyRenderStateData": { + * "kind": "enum", "size": 4, "align": 4, + * "underlying": "i32", "prefix": "GHOSTTY_RENDER_STATE_DATA_", + * "values": { "INVALID": 0, "DIRTY": 3, "MAX_VALUE": 2147483647 } + * }, + * "GhosttyStyleColor": { + * "kind": "struct", "size": 16, "align": 8, + * "fields": { + * "tag": { "offset": 0, "size": 4, + * "type": "GhosttyStyleColorTag" }, + * "value": { "offset": 8, "size": 8, + * "type": "GhosttyStyleColorValue", "tag": "tag", + * "arms": { "NONE": null, "PALETTE": "palette", + * "RGB": "rgb" } } + * } * } * } * } diff --git a/src/lib/union.zig b/src/lib/union.zig index 63a34a8cb..728b13304 100644 --- a/src/lib/union.zig +++ b/src/lib/union.zig @@ -15,14 +15,18 @@ const Target = @import("target.zig").Target; /// be an enum created with the `Enum` function in this library, so that /// automatic C ABI compatibility is ensured. /// -/// The `Padding` type is a type that is always added to the C union -/// with the key `_padding`. This should be set to a type that has the size -/// and alignment needed to pad the C union to the expected size. This -/// should never change to ensure ABI compatibility. +/// `options.padding` is a type that is always added to the C union with the key +/// `_padding`. It should have the size and alignment needed to pad the C union +/// to the expected size and should never change to ensure ABI compatibility. +/// +/// Each tag has an optional field in `options.field_renames` that may rename its +/// C value field. Multiple tags may map to the same field when their C value +/// types are identical. Tags with no payload are omitted. This is used +/// for metadata and should match the C headers directly. pub fn TaggedUnion( comptime target: Target, comptime Union: type, - comptime Padding: type, + comptime options: TaggedUnionOptions(Union), ) type { return struct { comptime { @@ -30,7 +34,7 @@ pub fn TaggedUnion( .zig => {}, // For ABI compatibility, we expect that this is our union size. - .c => if (@sizeOf(CValue) != @sizeOf(Padding)) { + .c => if (@sizeOf(CValue) != @sizeOf(options.padding)) { @compileLog(@sizeOf(CValue)); @compileError("TaggedUnion CValue size does not match expected fixed size"); }, @@ -49,11 +53,25 @@ pub fn TaggedUnion( .c => extern struct { tag: Tag, value: CValue, + + /// Returns the public C value-union field name for `tag`, or + /// null when the tag has no value field. This is metadata only; + /// it does not rename fields in `CValue`. + pub fn cFieldRename(comptime tag: Tag) ?[]const u8 { + const tag_name = @tagName(tag); + const value = @field(@unionInit(Union, tag_name, undefined), tag_name); + + if (@field(options.field_renames, tag_name)) |name| return name; + if (@sizeOf(@TypeOf(value)) == 0) return null; + + return tag_name; + } }, }; /// The C ABI compatible union value type. pub const CValue = cvalue: { + @setEvalBranchQuota(10_000); switch (target) { .zig => break :cvalue extern struct {}, .c => {}, @@ -83,8 +101,8 @@ pub fn TaggedUnion( } names[tag_fields.len] = "_padding"; - types[tag_fields.len] = Padding; - attrs[tag_fields.len] = .{ .@"align" = @alignOf(Padding) }; + types[tag_fields.len] = options.padding; + attrs[tag_fields.len] = .{ .@"align" = @alignOf(options.padding) }; break :cvalue @Union(.@"extern", null, &names, &types, &attrs); }; @@ -125,6 +143,30 @@ pub fn TaggedUnion( }; } +/// Options for generating the C representation of a tagged union. +pub fn TaggedUnionOptions(comptime Union: type) type { + const Tag = @typeInfo(Union).@"union".tag_type.?; + const tag_fields = @typeInfo(Tag).@"enum".fields; + const FieldRenames: type = field_renames: { + const default_rename: ?[]const u8 = null; + + var names: [tag_fields.len][]const u8 = undefined; + var types: [tag_fields.len]type = undefined; + var attrs: [tag_fields.len]std.builtin.Type.StructField.Attributes = undefined; + + for (tag_fields, 0..) |field, i| { + names[i] = field.name; + types[i] = ?[]const u8; + attrs[i] = .{ .default_value_ptr = &default_rename }; + } + + break :field_renames @Struct(.auto, null, &names, &types, &attrs); + }; + return struct { + padding: type, + field_renames: FieldRenames = .{}, + }; +} test "TaggedUnion: matching size" { const Tag = enum(c_int) { a, b }; const U = TaggedUnion( @@ -133,7 +175,7 @@ test "TaggedUnion: matching size" { a: u32, b: u64, }, - u64, + .{ .padding = u64 }, ); try testing.expectEqual(8, @sizeOf(U.CValue)); @@ -146,7 +188,7 @@ test "TaggedUnion: padded size" { union(Tag) { a: u32, }, - u64, + .{ .padding = u64 }, ); try testing.expectEqual(8, @sizeOf(U.CValue)); @@ -157,9 +199,39 @@ test "TaggedUnion: c conversion" { const U = TaggedUnion(.c, union(Tag) { a: u32, b: u64, - }, u64); + }, .{ .padding = u64 }); const c = U.cval(.{ .a = 42 }); try testing.expectEqual(Tag.a, c.tag); try testing.expectEqual(42, c.value.a); } + +test "TaggedUnion: custom C value fields" { + const Tag = enum(c_int) { a, b, none }; + const Union = union(Tag) { + a: u32, + b: u32, + none, + }; + const U = TaggedUnion(.c, Union, .{ + .padding = u64, + .field_renames = .{ + .a = "value", + .b = "value", + }, + }); + + try testing.expect(!@hasField(U.CValue, "value")); + try testing.expect(@hasField(U.CValue, "a")); + try testing.expect(@hasField(U.CValue, "b")); + try testing.expect(@hasField(U.CValue, "none")); + try testing.expectEqualStrings("value", U.C.cFieldRename(.a).?); + try testing.expect(U.C.cFieldRename(.none) == null); + + const a = U.cval(.{ .a = 42 }); + try testing.expectEqual(Tag.a, a.tag); + try testing.expectEqual(42, a.value.a); + + const none = U.cval(.none); + try testing.expectEqual(Tag.none, none.tag); +} diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 1e974cdd5..b935b505f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2590,7 +2590,7 @@ pub const ScrollViewport = union(Tag) { @This(), // Padding: largest variant is isize (8 bytes on 64-bit). // Use [2]u64 (16 bytes) for future expansion. - [2]u64, + .{ .padding = [2]u64 }, ); pub const C = c_union.C; pub const CValue = c_union.CValue; diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index 8eca343c2..2ecc9c975 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -12,6 +12,9 @@ pub const Parser = ?*osc.Parser; /// C: GhosttyOscCommand pub const Command = ?*osc.Command; +/// C: GhosttyOscCommandType +pub const CommandType = osc.Command.Key; + pub fn new( alloc_: ?*const CAllocator, result: *Parser, @@ -44,7 +47,7 @@ pub fn end(parser_: Parser, terminator: u8) callconv(lib.calling_conv) Command { return parser_.?.end(terminator); } -pub fn commandType(command_: Command) callconv(lib.calling_conv) osc.Command.Key { +pub fn commandType(command_: Command) callconv(lib.calling_conv) CommandType { const command = command_ orelse return .invalid; return command.*; } diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 7dcca5f02..55d488158 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -182,7 +182,7 @@ pub const UnknownSequence = union(Tag) { // A future borrowed CSI payload may need parameter, separator, and // intermediate arrays. Reserve 128 bytes so that representation and // other structured sequence types can be added without an ABI break. - [16]u64, + .{ .padding = [16]u64 }, ); pub const C = c_union.C; pub const CValue = c_union.CValue; diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 681556666..88759ef1c 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -1,29 +1,49 @@ -//! Comptime-generated metadata describing the layout of all C API -//! extern structs for the current target. +//! Comptime-generated ABI metadata for the public libghostty-vt C types. //! -//! This is embedded in the binary as a const string and exposed via -//! `ghostty_type_json` so that WASM (and other FFI) consumers can -//! build structs without hardcoding byte offsets. +//! The manifest is embedded in the library and returned by +//! `ghostty_type_json`. It is intended for FFI consumers that cannot use the +//! C headers directly, most notably WebAssembly hosts. const std = @import("std"); +const builtin = @import("builtin"); +const build_options = @import("terminal_options"); const lib = @import("../lib.zig"); -const color = @import("../color.zig"); -const sgr = @import("../sgr.zig"); -const color_c = @import("color.zig"); -const mouse_event = @import("mouse_event.zig"); -const point = @import("../point.zig"); -const size_report = @import("size_report.zig"); -const terminal = @import("terminal.zig"); +const color = @import("../color.zig"); +const clipboard = @import("../clipboard.zig"); +const device_status = @import("../device_status.zig"); +const focus_pkg = @import("../focus.zig"); +const formatter_pkg = @import("../formatter.zig"); +const modes_pkg = @import("../modes.zig"); +const mouse_pkg = @import("../mouse.zig"); +const point = @import("../point.zig"); +const Selection = @import("../Selection.zig"); +const sgr = @import("../sgr.zig"); + +const input_config = @import("../../input/config.zig"); +const input_key = @import("../../input/key.zig"); +const input_mouse = @import("../../input/mouse.zig"); + +const build_info = @import("build_info.zig"); +const cell = @import("cell.zig"); +const color_c = @import("color.zig"); const formatter = @import("formatter.zig"); -const selection = @import("selection.zig"); -const selection_gesture = @import("selection_gesture.zig"); -const render = @import("render.zig"); -const style_c = @import("style.zig"); -const kitty_graphics = @import("kitty_graphics.zig"); -const mouse_encode = @import("mouse_encode.zig"); const grid_ref = @import("grid_ref.zig"); const io = @import("io.zig"); +const key_encode = @import("key_encode.zig"); +const kitty_graphics = @import("kitty_graphics.zig"); +const mouse_encode = @import("mouse_encode.zig"); +const mouse_event = @import("mouse_event.zig"); +const osc = @import("osc.zig"); +const render = @import("render.zig"); +const result = @import("result.zig"); +const row = @import("row.zig"); +const selection = @import("selection.zig"); +const selection_gesture = @import("selection_gesture.zig"); +const size_report = @import("size_report.zig"); +const snapshot = @import("snapshot.zig"); +const style = @import("style.zig"); const sys = @import("sys.zig"); +const terminal = @import("terminal.zig"); /// C: GhosttySurfacePosition pub const SurfacePosition = extern struct { @@ -37,318 +57,802 @@ pub const Codepoints = extern struct { len: usize = 0, }; -/// All C API structs and their Ghostty C names. -pub const structs: std.StaticStringMap(StructInfo) = structs: { - @setEvalBranchQuota(10_000); - break :structs .initComptime(.{ - .{ "GhosttyAllocator", StructInfo.init(lib.alloc.Allocator) }, - .{ "GhosttyAllocatorVtable", StructInfo.init(lib.alloc.VTable) }, - .{ "GhosttyBuffer", StructInfo.init(lib.Buffer) }, - .{ "GhosttyClipboardContent", StructInfo.init(terminal.ClipboardContent) }, - .{ "GhosttyClipboardWrite", StructInfo.init(terminal.ClipboardWrite) }, - .{ "GhosttyCodepoints", StructInfo.init(Codepoints) }, - .{ "GhosttyColorPaletteMask", StructInfo.init(color_c.PaletteMask) }, - .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, - .{ "GhosttyColorX11Entry", StructInfo.init(color_c.X11Entry) }, - .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, - .{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) }, - .{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) }, - .{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) }, - .{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) }, - .{ "GhosttySelection", StructInfo.init(selection.CSelection) }, - .{ "GhosttyTerminalSelectWordOptions", StructInfo.init(selection.SelectWordOptions) }, - .{ "GhosttyTerminalSelectWordBetweenOptions", StructInfo.init(selection.SelectWordBetweenOptions) }, - .{ "GhosttyTerminalSelectLineOptions", StructInfo.init(selection.SelectLineOptions) }, - .{ "GhosttyTerminalSelectionFormatOptions", StructInfo.init(selection.FormatOptions) }, - .{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) }, - .{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) }, - .{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) }, - .{ "GhosttyKittyGraphicsPlacementRenderInfo", StructInfo.init(kitty_graphics.PlacementRenderInfo) }, - .{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) }, - .{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) }, - .{ "GhosttyPoint", StructInfo.init(point.Point.C) }, - .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) }, - .{ "GhosttyReader", StructInfo.init(io.Reader) }, - .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) }, - .{ "GhosttyRenderStateCursor", StructInfo.init(render.Cursor) }, - .{ "GhosttyRenderStateRowSelection", StructInfo.init(render.RowSelection) }, - .{ "GhosttySelectionGestureBehaviors", StructInfo.init(selection_gesture.Behaviors) }, - .{ "GhosttySelectionGestureGeometry", StructInfo.init(selection_gesture.Geometry) }, - .{ "GhosttySgrAttribute", StructInfo.init(sgr.Attribute.C) }, - .{ "GhosttySgrUnknown", StructInfo.init(sgr.Attribute.Unknown.C) }, - .{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) }, - .{ "GhosttyString", StructInfo.init(lib.String) }, - .{ "GhosttySurfacePosition", StructInfo.init(SurfacePosition) }, - .{ "GhosttyStyle", StructInfo.init(style_c.Style) }, - .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) }, - .{ "GhosttySysImage", StructInfo.init(sys.Image) }, - .{ "GhosttyTerminalDesktopNotification", StructInfo.init(terminal.DesktopNotification) }, - .{ "GhosttyTerminalModeConfig", StructInfo.init(terminal.ModeConfig) }, - .{ "GhosttyTerminalProgressReport", StructInfo.init(terminal.ProgressReport) }, - .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, - .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, - .{ "GhosttyTerminalUnknownSequence", StructInfo.init(terminal.UnknownSequence.C) }, - .{ "GhosttyTerminalUnknownStringSequence", StructInfo.init(terminal.UnknownStringSequence) }, - .{ "GhosttyWriter", StructInfo.init(io.Writer) }, - }); +const TypeDecl = struct { + name: []const u8, + T: type, + kind: Kind, + prefix: []const u8 = "", + sentinel_suffix: []const u8 = "MAX_VALUE", + alias_type: []const u8 = "", + tagged_union: ?TaggedUnion = null, + union_field_names_T: ?type = null, + + const Kind = enum { + @"struct", + @"union", + @"enum", + alias, + @"opaque", + }; + + const TaggedUnion = struct { + tag_field: []const u8, + value_field: []const u8, + arm_source: ArmSource, + + const ArmSource = enum { + fields, + generated, + }; + }; + + fn initStruct(comptime name: []const u8, comptime T: type) TypeDecl { + return .{ .name = name, .T = T, .kind = .@"struct" }; + } + + fn initTaggedStruct( + comptime name: []const u8, + comptime T: type, + comptime tag_field: []const u8, + comptime value_field: []const u8, + comptime arm_source: TaggedUnion.ArmSource, + ) TypeDecl { + return .{ + .name = name, + .T = T, + .kind = .@"struct", + .tagged_union = .{ + .tag_field = tag_field, + .value_field = value_field, + .arm_source = arm_source, + }, + }; + } + + fn initUnion( + comptime name: []const u8, + comptime T: type, + comptime field_names_T: ?type, + ) TypeDecl { + return .{ + .name = name, + .T = T, + .kind = .@"union", + .union_field_names_T = field_names_T, + }; + } + + fn initEnum( + comptime name: []const u8, + comptime T: type, + comptime prefix: []const u8, + ) TypeDecl { + return .{ .name = name, .T = T, .kind = .@"enum", .prefix = prefix }; + } + + fn initEnumSentinel( + comptime name: []const u8, + comptime T: type, + comptime prefix: []const u8, + comptime sentinel_suffix: []const u8, + ) TypeDecl { + return .{ + .name = name, + .T = T, + .kind = .@"enum", + .prefix = prefix, + .sentinel_suffix = sentinel_suffix, + }; + } + + fn initAlias( + comptime name: []const u8, + comptime T: type, + comptime alias_type: []const u8, + ) TypeDecl { + return .{ .name = name, .T = T, .kind = .alias, .alias_type = alias_type }; + } + + fn initOpaque(comptime name: []const u8) TypeDecl { + return .{ .name = name, .T = *anyopaque, .kind = .@"opaque" }; + } }; -/// The comptime-generated JSON string of all structs. +/// Public C types. Names and enum prefixes are intentionally explicit because +/// Zig identifiers are not the C API contract. +const type_decls = [_]TypeDecl{ + .initStruct("GhosttyAllocator", lib.alloc.Allocator), + .initStruct("GhosttyAllocatorVtable", lib.alloc.VTable), + .initStruct("GhosttyBuffer", lib.Buffer), + .initStruct("GhosttyCellsView", cell.CellsView), + .initStruct("GhosttyClipboardContent", terminal.ClipboardContent), + .initStruct("GhosttyClipboardWrite", terminal.ClipboardWrite), + .initStruct("GhosttyCodepoints", Codepoints), + .initStruct("GhosttyColorPaletteMask", color_c.PaletteMask), + .initStruct("GhosttyColorRgb", color.RGB.C), + .initStruct("GhosttyColorX11Entry", color_c.X11Entry), + .initStruct("GhosttyDeviceAttributes", terminal.DeviceAttributes), + .initStruct("GhosttyDeviceAttributesPrimary", terminal.DeviceAttributes.Primary), + .initStruct("GhosttyDeviceAttributesSecondary", terminal.DeviceAttributes.Secondary), + .initStruct("GhosttyDeviceAttributesTertiary", terminal.DeviceAttributes.Tertiary), + .initStruct("GhosttyFormatterScreenExtra", formatter.ScreenOptions.Extra), + .initStruct("GhosttyFormatterTerminalExtra", formatter.TerminalOptions.Extra), + .initStruct("GhosttyFormatterTerminalOptions", formatter.TerminalOptions), + .initStruct("GhosttyGridRef", grid_ref.CGridRef), + .initStruct("GhosttyKittyGraphicsPlacementRenderInfo", kitty_graphics.PlacementRenderInfo), + .initStruct("GhosttyMouseEncoderSize", mouse_encode.Size), + .initStruct("GhosttyMousePosition", mouse_event.Position), + .initTaggedStruct("GhosttyPoint", point.Point.C, "tag", "value", .generated), + .initStruct("GhosttyPointCoordinate", point.Coordinate), + .initUnion("GhosttyPointValue", point.Point.CValue, point.Point.C), + .initStruct("GhosttyReader", io.Reader), + .initStruct("GhosttyRenderStateColors", render.Colors), + .initStruct("GhosttyRenderStateCursor", render.Cursor), + .initStruct("GhosttyRenderStateRowSelection", render.RowSelection), + .initStruct("GhosttySelection", selection.CSelection), + .initStruct("GhosttySelectionGestureBehaviors", selection_gesture.Behaviors), + .initStruct("GhosttySelectionGestureGeometry", selection_gesture.Geometry), + .initTaggedStruct("GhosttySgrAttribute", sgr.Attribute.C, "tag", "value", .generated), + .initStruct("GhosttySgrUnknown", sgr.Attribute.Unknown.C), + .initUnion("GhosttySgrAttributeValue", sgr.Attribute.CValue, sgr.Attribute.C), + .initStruct("GhosttySizeReportSize", size_report.Size), + .initStruct("GhosttyString", lib.String), + .initStruct("GhosttySurfacePosition", SurfacePosition), + .initStruct("GhosttyStyle", style.Style), + .initTaggedStruct("GhosttyStyleColor", style.Color, "tag", "value", .fields), + .initUnion("GhosttyStyleColorValue", style.ColorValue, null), + .initStruct("GhosttySysImage", sys.Image), + .initStruct("GhosttyTerminalDesktopNotification", terminal.DesktopNotification), + .initStruct("GhosttyTerminalModeConfig", terminal.ModeConfig), + .initStruct("GhosttyTerminalProgressReport", terminal.ProgressReport), + .initStruct("GhosttyTerminalScrollbar", terminal.TerminalScrollbar), + .initTaggedStruct("GhosttyTerminalScrollViewport", terminal.ScrollViewport, "tag", "value", .generated), + .initUnion( + "GhosttyTerminalScrollViewportValue", + terminal.ZigTerminal.ScrollViewport.CValue, + terminal.ZigTerminal.ScrollViewport.C, + ), + .initStruct("GhosttyTerminalSelectLineOptions", selection.SelectLineOptions), + .initStruct("GhosttyTerminalSelectWordBetweenOptions", selection.SelectWordBetweenOptions), + .initStruct("GhosttyTerminalSelectWordOptions", selection.SelectWordOptions), + .initStruct("GhosttyTerminalSelectionFormatOptions", selection.FormatOptions), + .initTaggedStruct("GhosttyTerminalUnknownSequence", terminal.UnknownSequence.C, "tag", "value", .generated), + .initStruct("GhosttyTerminalUnknownStringSequence", terminal.UnknownStringSequence), + .initUnion( + "GhosttyTerminalUnknownSequenceValue", + terminal.UnknownSequence.CValue, + terminal.UnknownSequence.C, + ), + .initStruct("GhosttyWriter", io.Writer), + + .initEnumSentinel("GhosttyResult", result.Result, "GHOSTTY_", "RESULT_MAX_VALUE"), + .initEnum("GhosttyBuildInfo", build_info.BuildInfo, "GHOSTTY_BUILD_INFO_"), + .initEnumSentinel("GhosttyOptimizeMode", build_info.OptimizeMode, "GHOSTTY_OPTIMIZE_", "MODE_MAX_VALUE"), + .initEnumSentinel("GhosttyCellContentTag", cell.ContentTag, "GHOSTTY_CELL_CONTENT_", "TAG_MAX_VALUE"), + .initEnum("GhosttyCellData", cell.CellData, "GHOSTTY_CELL_DATA_"), + .initEnum("GhosttyCellSemanticContent", cell.SemanticContent, "GHOSTTY_CELL_SEMANTIC_"), + .initEnum("GhosttyCellWide", cell.Wide, "GHOSTTY_CELL_WIDE_"), + .initEnum("GhosttyClipboardLocation", clipboard.Location, "GHOSTTY_CLIPBOARD_LOCATION_"), + .initEnum("GhosttyClipboardWriteResult", clipboard.WriteResult, "GHOSTTY_CLIPBOARD_WRITE_RESULT_"), + .initEnum("GhosttyColorScheme", device_status.ColorScheme, "GHOSTTY_COLOR_SCHEME_"), + .initEnum("GhosttyFocusEvent", focus_pkg.Event, "GHOSTTY_FOCUS_"), + .initEnum("GhosttyFormatterFormat", formatter_pkg.Format, "GHOSTTY_FORMATTER_FORMAT_"), + .initEnum("GhosttyKey", input_key.Key, "GHOSTTY_KEY_"), + .initEnum("GhosttyKeyAction", input_key.Action, "GHOSTTY_KEY_ACTION_"), + .initEnum("GhosttyKeyEncoderOption", key_encode.Option, "GHOSTTY_KEY_ENCODER_OPT_"), + .initEnum("GhosttyKittyGraphicsData", kitty_graphics.Data, "GHOSTTY_KITTY_GRAPHICS_DATA_"), + .initEnum("GhosttyKittyGraphicsImageData", kitty_graphics.ImageData, "GHOSTTY_KITTY_IMAGE_DATA_"), + .initEnum("GhosttyKittyGraphicsPlacementData", kitty_graphics.PlacementData, "GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_"), + .initEnum("GhosttyKittyGraphicsPlacementIteratorOption", kitty_graphics.PlacementIteratorOption, "GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_"), + .initEnum("GhosttyKittyImageCompression", kitty_graphics.ImageCompression, "GHOSTTY_KITTY_IMAGE_COMPRESSION_"), + .initEnum("GhosttyKittyImageFormat", kitty_graphics.ImageFormat, "GHOSTTY_KITTY_IMAGE_FORMAT_"), + .initEnum("GhosttyKittyPlacementLayer", kitty_graphics.PlacementLayer, "GHOSTTY_KITTY_PLACEMENT_LAYER_"), + .initEnum("GhosttyModeReportState", modes_pkg.Report.State, "GHOSTTY_MODE_REPORT_"), + .initEnum("GhosttyMouseAction", input_mouse.Action, "GHOSTTY_MOUSE_ACTION_"), + .initEnum("GhosttyMouseButton", input_mouse.Button, "GHOSTTY_MOUSE_BUTTON_"), + .initEnum("GhosttyMouseEncoderOption", mouse_encode.Option, "GHOSTTY_MOUSE_ENCODER_OPT_"), + .initEnum("GhosttyMouseFormat", mouse_pkg.Format, "GHOSTTY_MOUSE_FORMAT_"), + .initEnum("GhosttyMouseTrackingMode", mouse_pkg.Event, "GHOSTTY_MOUSE_TRACKING_"), + .initEnum("GhosttyOptionAsAlt", input_config.OptionAsAlt, "GHOSTTY_OPTION_AS_ALT_"), + .initEnum("GhosttyOscCommandData", osc.CommandData, "GHOSTTY_OSC_DATA_"), + .initEnumSentinel( + "GhosttyOscCommandType", + osc.CommandType, + "GHOSTTY_OSC_COMMAND_", + "TYPE_MAX_VALUE", + ), + .initEnum("GhosttyPointTag", point.Tag, "GHOSTTY_POINT_TAG_"), + .initEnum("GhosttyRenderStateCursorVisualStyle", render.CursorVisualStyle, "GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_"), + .initEnum("GhosttyRenderStateData", render.Data, "GHOSTTY_RENDER_STATE_DATA_"), + .initEnum("GhosttyRenderStateDirty", render.Dirty, "GHOSTTY_RENDER_STATE_DIRTY_"), + .initEnum("GhosttyRenderStateOption", render.SetOption, "GHOSTTY_RENDER_STATE_OPTION_"), + .initEnum("GhosttyRenderStateRowCellsData", render.RowCellsData, "GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_"), + .initEnum("GhosttyRenderStateRowData", render.RowData, "GHOSTTY_RENDER_STATE_ROW_DATA_"), + .initEnum("GhosttyRenderStateRowOption", render.RowOption, "GHOSTTY_RENDER_STATE_ROW_OPTION_"), + .initEnum("GhosttyRowData", row.RowData, "GHOSTTY_ROW_DATA_"), + .initEnum("GhosttyRowSemanticPrompt", row.SemanticPrompt, "GHOSTTY_ROW_SEMANTIC_"), + .initEnum("GhosttySelectionAdjust", Selection.Adjustment, "GHOSTTY_SELECTION_ADJUST_"), + .initEnum("GhosttySelectionGestureAutoscroll", selection_gesture.Autoscroll, "GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_"), + .initEnum("GhosttySelectionGestureBehavior", selection_gesture.Behavior, "GHOSTTY_SELECTION_GESTURE_BEHAVIOR_"), + .initEnum("GhosttySelectionGestureData", selection_gesture.Data, "GHOSTTY_SELECTION_GESTURE_DATA_"), + .initEnum("GhosttySelectionGestureEventOption", selection_gesture.EventOption, "GHOSTTY_SELECTION_GESTURE_EVENT_OPT_"), + .initEnum("GhosttySelectionGestureEventType", selection_gesture.EventType, "GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_"), + .initEnum("GhosttySelectionOrder", Selection.Order, "GHOSTTY_SELECTION_ORDER_"), + .initEnum("GhosttySgrAttributeTag", sgr.Attribute.Tag, "GHOSTTY_SGR_ATTR_"), + .initEnum("GhosttySgrUnderline", sgr.Attribute.Underline, "GHOSTTY_SGR_UNDERLINE_"), + .initEnumSentinel("GhosttySizeReportStyle", size_report.Style, "GHOSTTY_SIZE_REPORT_", "STYLE_MAX_VALUE"), + .initEnum("GhosttySnapshotDecoderData", snapshot.DecoderData, "GHOSTTY_SNAPSHOT_DECODER_DATA_"), + .initEnum("GhosttySnapshotDecoderOption", snapshot.DecoderOption, "GHOSTTY_SNAPSHOT_DECODER_OPT_"), + .initEnumSentinel("GhosttyStyleColorTag", style.ColorTag, "GHOSTTY_STYLE_COLOR_", "TAG_MAX_VALUE"), + .initEnum("GhosttySysLogLevel", sys.LogLevel, "GHOSTTY_SYS_LOG_LEVEL_"), + .initEnum("GhosttySysOption", sys.Option, "GHOSTTY_SYS_OPT_"), + .initEnum("GhosttyTerminalCompressionMode", terminal.CompressionMode, "GHOSTTY_TERMINAL_COMPRESSION_MODE_"), + .initEnum("GhosttyTerminalCompressionResult", terminal.CompressionResult, "GHOSTTY_TERMINAL_COMPRESSION_RESULT_"), + .initEnum("GhosttyTerminalCursorStyle", terminal.TerminalCursorStyle, "GHOSTTY_TERMINAL_CURSOR_STYLE_"), + .initEnum("GhosttyTerminalData", terminal.TerminalData, "GHOSTTY_TERMINAL_DATA_"), + .initEnum("GhosttyTerminalOption", terminal.Option, "GHOSTTY_TERMINAL_OPT_"), + .initEnum("GhosttyTerminalProgressState", terminal.ProgressState, "GHOSTTY_TERMINAL_PROGRESS_STATE_"), + .initEnum("GhosttyTerminalScreen", terminal.TerminalScreen, "GHOSTTY_TERMINAL_SCREEN_"), + .initEnum("GhosttyTerminalScrollViewportTag", terminal.ZigTerminal.ScrollViewport.Tag, "GHOSTTY_SCROLL_VIEWPORT_"), + .initEnum("GhosttyTerminalUnknownSequenceTag", terminal.UnknownSequence.Tag, "GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_"), + + .initAlias("GhosttyCell", u64, "u64"), + .initAlias("GhosttyColorPaletteIndex", u8, "u8"), + .initAlias("GhosttyKittyKeyFlags", u8, "u8"), + .initAlias("GhosttyMode", u16, "u16"), + .initAlias("GhosttyMods", u16, "u16"), + .initAlias("GhosttyRow", u64, "u64"), + .initAlias("GhosttyStyleId", u16, "u16"), + + .initOpaque("GhosttyFormatter"), + .initOpaque("GhosttyKeyEncoder"), + .initOpaque("GhosttyKeyEvent"), + .initOpaque("GhosttyKittyGraphics"), + .initOpaque("GhosttyKittyGraphicsImage"), + .initOpaque("GhosttyKittyGraphicsPlacementIterator"), + .initOpaque("GhosttyMouseEncoder"), + .initOpaque("GhosttyMouseEvent"), + .initOpaque("GhosttyOscCommand"), + .initOpaque("GhosttyOscParser"), + .initOpaque("GhosttyRenderState"), + .initOpaque("GhosttyRenderStateRowCells"), + .initOpaque("GhosttyRenderStateRowIterator"), + .initOpaque("GhosttySelectionGesture"), + .initOpaque("GhosttySelectionGestureEvent"), + .initOpaque("GhosttySgrParser"), + .initOpaque("GhosttySnapshotDecoder"), + .initOpaque("GhosttyTerminal"), + .initOpaque("GhosttyTrackedGridRef"), +}; + +comptime { + @setEvalBranchQuota(100_000); + for (type_decls, 0..) |decl, i| { + for (type_decls[0..i]) |previous| { + if (std.mem.eql(u8, decl.name, previous.name)) + @compileError("duplicate public ABI type: " ++ decl.name); + } + } +} + pub const json: [:0]const u8 = json: { - @setEvalBranchQuota(200_000); + @setEvalBranchQuota(1_000_000); var counter: std.Io.Writer.Discarding = .init(&.{}); - jsonWriteAll(&counter.writer) catch unreachable; + Json.writeAll(&counter.writer) catch unreachable; var buf: [counter.count:0]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - jsonWriteAll(&writer) catch unreachable; + Json.writeAll(&writer) catch unreachable; const final = buf; break :json final[0..writer.end :0]; }; -/// Returns a pointer to the comptime-generated JSON string describing -/// the layout of all C API extern structs, and writes its length to `len`. -/// Exported as `ghostty_type_json` for FFI consumers. pub fn get_json() callconv(lib.calling_conv) [*:0]const u8 { return json.ptr; } -/// Meta information about a struct that we expose to ease writing -/// bindings in some languages, particularly WASM where we can't -/// easily share struct definitions and need to hardcode byte offsets. -pub const StructInfo = struct { - name: []const u8, - size: usize, - @"align": usize, - fields: []const FieldInfo, +const Json = struct { + fn writeAll(writer: *std.Io.Writer) std.Io.Writer.Error!void { + var jws: std.json.Stringify = .{ .writer = writer }; + try jws.beginObject(); + try jws.objectField("schema"); + try jws.write(1); - pub const FieldInfo = struct { - name: []const u8, - offset: usize, - size: usize, - type: []const u8, - }; + try jws.objectField("abi"); + try jws.beginObject(); + try jws.objectField("target"); + try jws.write(@tagName(builtin.target.cpu.arch)); + try jws.objectField("os"); + try jws.write(@tagName(builtin.target.os.tag)); + try jws.objectField("environment"); + try jws.write(@tagName(builtin.target.abi)); + try jws.objectField("pointer_size"); + try jws.write(@sizeOf(*anyopaque)); + try jws.objectField("usize_size"); + try jws.write(@sizeOf(usize)); + try jws.objectField("endian"); + try jws.write(@tagName(builtin.target.cpu.arch.endian())); + try jws.endObject(); - pub fn init(comptime T: type) StructInfo { - comptime { - const fields = @typeInfo(T).@"struct".fields; - const field_infos: [fields.len]FieldInfo = blk: { - var infos: [fields.len]FieldInfo = undefined; - for (fields, 0..) |field, i| infos[i] = .{ - .name = field.name, - .offset = @offsetOf(T, field.name), - .size = @sizeOf(field.type), - .type = typeName(field.type), - }; - break :blk infos; - }; + try jws.objectField("library_version"); + try jws.write(build_options.version_string); + try jws.objectField("commit"); + if (build_options.version_build) |commit| try jws.write(commit) else try jws.write(null); + try jws.objectField("dirty"); + try jws.write(null); - return .{ - .name = @typeName(T), - .size = @sizeOf(T), - .@"align" = @alignOf(T), - .fields = &field_infos, - }; + try jws.objectField("types"); + try jws.beginObject(); + inline for (type_decls) |decl| { + try jws.objectField(decl.name); + try writeType(decl, &jws); } + try jws.endObject(); + try jws.endObject(); } - pub fn jsonStringify( - self: *const StructInfo, - jws: anytype, + fn writeType(comptime decl: TypeDecl, jws: *std.json.Stringify) std.Io.Writer.Error!void { + try jws.beginObject(); + try jws.objectField("kind"); + try jws.write(@tagName(decl.kind)); + + switch (decl.kind) { + .@"struct", .@"union" => { + try writeSizeAlign(decl.T, jws); + try jws.objectField("fields"); + try jws.beginObject(); + if (decl.union_field_names_T != null) { + try writeMappedUnionFields(decl, jws); + } else { + const fields = switch (decl.kind) { + .@"struct" => @typeInfo(decl.T).@"struct".fields, + .@"union" => @typeInfo(decl.T).@"union".fields, + else => unreachable, + }; + inline for (fields) |field| { + try writeField( + decl, + field.name, + field.type, + if (decl.kind == .@"union") 0 else @offsetOf(decl.T, field.name), + jws, + ); + } + } + try jws.endObject(); + }, + .@"enum" => { + try writeSizeAlign(c_int, jws); + try jws.objectField("underlying"); + try jws.write("i32"); + try jws.objectField("prefix"); + try jws.write(decl.prefix); + try jws.objectField("values"); + try jws.beginObject(); + inline for (@typeInfo(decl.T).@"enum".fields) |field| { + try writeEnumObjectField(decl.name, field.name, jws); + try jws.write(field.value); + } + try jws.objectField(decl.sentinel_suffix); + try jws.write(std.math.maxInt(c_int)); + try jws.endObject(); + }, + .alias => { + try writeSizeAlign(decl.T, jws); + try jws.objectField("type"); + try jws.write(decl.alias_type); + }, + .@"opaque" => try writeSizeAlign(*anyopaque, jws), + } + try jws.endObject(); + } + + fn writeField( + comptime decl: TypeDecl, + comptime name: []const u8, + comptime T: type, + comptime offset: usize, + jws: *std.json.Stringify, ) std.Io.Writer.Error!void { + try jws.objectField(name); try jws.beginObject(); + try jws.objectField("offset"); + try jws.write(offset); try jws.objectField("size"); - try jws.write(self.size); - try jws.objectField("align"); - try jws.write(self.@"align"); - try jws.objectField("fields"); - try jws.beginObject(); - for (self.fields) |field| { - try jws.objectField(field.name); - try jws.beginObject(); - try jws.objectField("offset"); - try jws.write(field.offset); - try jws.objectField("size"); - try jws.write(field.size); - try jws.objectField("type"); - try jws.write(field.type); - try jws.endObject(); + try jws.write(@sizeOf(T)); + try writeFieldType(decl.name, name, T, jws); + if (decl.tagged_union) |tagged| { + if (std.mem.eql(u8, name, tagged.value_field)) { + try jws.objectField("tag"); + try jws.write(tagged.tag_field); + try writeTaggedArms(decl, tagged, jws); + } } try jws.endObject(); + } + + fn writeMappedUnionFields( + comptime decl: TypeDecl, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + const FieldNames = decl.union_field_names_T.?; + const Tag = @FieldType(FieldNames, "tag"); + const tag_fields = @typeInfo(Tag).@"enum".fields; + + inline for (tag_fields, 0..) |field, i| { + const name = comptime FieldNames.cFieldRename(@field(Tag, field.name)) orelse continue; + if (comptime mappedUnionFieldIsDuplicate(decl, i, name)) continue; + try writeField(decl, name, @FieldType(decl.T, field.name), 0, jws); + } + + if (@hasField(decl.T, "_padding")) + try writeField(decl, "_padding", @FieldType(decl.T, "_padding"), 0, jws); + } + + fn mappedUnionFieldIsDuplicate( + comptime decl: TypeDecl, + comptime index: usize, + comptime name: []const u8, + ) bool { + const FieldNames = decl.union_field_names_T.?; + const Tag = @FieldType(FieldNames, "tag"); + const tag_fields = @typeInfo(Tag).@"enum".fields; + const T = @FieldType(decl.T, tag_fields[index].name); + + inline for (tag_fields[0..index]) |previous| { + const previous_name = FieldNames.cFieldRename(@field(Tag, previous.name)) orelse continue; + if (std.mem.eql(u8, previous_name, name)) { + if (@FieldType(decl.T, previous.name) != T) + @compileError("tagged union metadata maps different field types to " ++ name); + return true; + } + } + + return false; + } + + fn writeSizeAlign(comptime T: type, jws: *std.json.Stringify) std.Io.Writer.Error!void { + try jws.objectField("size"); + try jws.write(@sizeOf(T)); + try jws.objectField("align"); + try jws.write(@alignOf(T)); + } + + fn writeFieldType( + comptime owner: []const u8, + comptime field_name: []const u8, + comptime T: type, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + if (comptime std.mem.eql(u8, owner, "GhosttyCellsView") and + std.mem.eql(u8, field_name, "ptr")) + { + try writePointerTypeNamed(T, true, "GhosttyCell", jws); + return; + } + if (comptime std.mem.eql(u8, owner, "GhosttyGridRef") and + std.mem.eql(u8, field_name, "node")) + { + try writePointerTypeNamed(T, true, "opaque", jws); + return; + } + + if (comptime fieldTypeOverride(owner, field_name)) |name| { + try jws.objectField("type"); + try jws.write(name); + return; + } + + switch (@typeInfo(T)) { + .array => |info| { + try jws.objectField("type"); + try jws.write("array"); + try jws.objectField("elem"); + try jws.write(publicTypeName(info.child)); + try jws.objectField("count"); + try jws.write(info.len); + }, + .optional => |info| try writePointerType(info.child, true, jws), + .pointer => try writePointerType(T, false, jws), + else => { + try jws.objectField("type"); + try jws.write(publicTypeName(T)); + }, + } + } + + fn writePointerType( + comptime T: type, + comptime nullable: bool, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + const info = @typeInfo(T).pointer; + try jws.objectField("type"); + try jws.write("pointer"); + try jws.objectField("elem"); + try jws.write(publicTypeName(info.child)); + try jws.objectField("const"); + try jws.write(info.is_const); + if (nullable) { + try jws.objectField("nullable"); + try jws.write(true); + } + } + + fn writePointerTypeNamed( + comptime T: type, + comptime nullable: bool, + comptime elem: []const u8, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + const pointer_type = switch (@typeInfo(T)) { + .optional => |info| info.child, + .pointer => T, + else => unreachable, + }; + const info = @typeInfo(pointer_type).pointer; + try jws.objectField("type"); + try jws.write("pointer"); + try jws.objectField("elem"); + try jws.write(elem); + try jws.objectField("const"); + try jws.write(info.is_const); + if (nullable) { + try jws.objectField("nullable"); + try jws.write(true); + } + } + + fn fieldTypeOverride(comptime owner: []const u8, comptime field_name: []const u8) ?[]const u8 { + if (std.mem.eql(u8, field_name, "value")) { + if (std.mem.eql(u8, owner, "GhosttyPoint")) return "GhosttyPointValue"; + if (std.mem.eql(u8, owner, "GhosttySgrAttribute")) return "GhosttySgrAttributeValue"; + if (std.mem.eql(u8, owner, "GhosttyStyleColor")) return "GhosttyStyleColorValue"; + if (std.mem.eql(u8, owner, "GhosttyTerminalScrollViewport")) return "GhosttyTerminalScrollViewportValue"; + if (std.mem.eql(u8, owner, "GhosttyTerminalUnknownSequence")) return "GhosttyTerminalUnknownSequenceValue"; + } + if (std.mem.eql(u8, owner, "GhosttyStyleColorValue") and std.mem.eql(u8, field_name, "palette")) + return "GhosttyColorPaletteIndex"; + if (std.mem.eql(u8, owner, "GhosttySgrAttributeValue")) { + if (std.mem.eql(u8, field_name, "underline")) return "GhosttySgrUnderline"; + if (std.mem.endsWith(u8, field_name, "_8") or std.mem.endsWith(u8, field_name, "_256")) + return "GhosttyColorPaletteIndex"; + } + if (std.mem.eql(u8, owner, "GhosttyTerminalModeConfig") and std.mem.eql(u8, field_name, "mode")) + return "GhosttyMode"; + return null; + } + + fn writeTaggedArms( + comptime decl: TypeDecl, + comptime tagged: TypeDecl.TaggedUnion, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + const Tag = @FieldType(decl.T, tagged.tag_field); + try jws.objectField("arms"); + try jws.beginObject(); + inline for (@typeInfo(Tag).@"enum".fields) |field| { + try writeEnumObjectField(publicTypeName(Tag), field.name, jws); + if (comptime taggedArm(decl, field.name)) |arm| try jws.write(arm) else try jws.write(null); + } try jws.endObject(); } -}; -fn jsonWriteAll(writer: *std.Io.Writer) std.Io.Writer.Error!void { - var jws: std.json.Stringify = .{ .writer = writer }; - try jws.beginObject(); - for (structs.keys(), structs.values()) |name, *info| { - try jws.objectField(name); - try info.jsonStringify(&jws); + fn taggedArm(comptime decl: TypeDecl, comptime tag_name: []const u8) ?[]const u8 { + const tagged = decl.tagged_union.?; + const Tag = @FieldType(decl.T, tagged.tag_field); + if (tagged.arm_source == .generated) + return decl.T.cFieldRename(@field(Tag, tag_name)); + + const Value = @FieldType(decl.T, tagged.value_field); + if (!@hasField(Value, tag_name)) return null; + return if (@sizeOf(@FieldType(Value, tag_name)) == 0) null else tag_name; } - try jws.endObject(); -} -fn typeName(comptime T: type) []const u8 { - return switch (@typeInfo(T)) { - .bool => "bool", - .float => |info| switch (info.bits) { - 32 => "f32", - 64 => "f64", - else => @compileError("unsupported float size"), - }, - .int => |info| switch (info.signedness) { - .signed => switch (info.bits) { + fn publicTypeName(comptime T: type) []const u8 { + inline for (type_decls) |decl| switch (decl.kind) { + .@"struct", .@"union", .@"enum" => if (T == decl.T) return decl.name, + .alias, .@"opaque" => {}, + }; + return switch (@typeInfo(T)) { + .bool => "bool", + .float => |info| switch (info.bits) { + 32 => "f32", + 64 => "f64", + else => "opaque", + }, + .int => |info| intName(info.signedness, info.bits), + .comptime_int => "comptime_int", + .void => "void", + .@"opaque" => "opaque", + .@"fn" => "function", + .@"enum" => "enum", + .@"struct" => "struct", + .@"union" => "union", + else => "opaque", + }; + } + + fn intName(comptime signedness: std.builtin.Signedness, comptime bits: u16) []const u8 { + return switch (signedness) { + .signed => switch (bits) { 8 => "i8", 16 => "i16", 32 => "i32", 64 => "i64", - else => @compileError("unsupported signed int size"), + else => "opaque", }, - .unsigned => switch (info.bits) { + .unsigned => switch (bits) { 8 => "u8", 16 => "u16", 32 => "u32", 64 => "u64", - else => @compileError("unsupported unsigned int size"), + else => "opaque", }, - }, - .@"enum" => "enum", - .@"struct" => "struct", - .pointer => "pointer", - .array => "array", - else => "opaque", - }; -} + }; + } -test "json parses" { - const parsed = try std.json.parseFromSlice( - std.json.Value, - std.testing.allocator, - json, - .{}, - ); + fn writeUpperObjectField(comptime name: []const u8, jws: *std.json.Stringify) std.Io.Writer.Error!void { + var upper: [name.len]u8 = undefined; + for (name, 0..) |c, i| upper[i] = std.ascii.toUpper(c); + try jws.objectField(&upper); + } + + fn writeEnumObjectField( + comptime enum_name: []const u8, + comptime zig_name: []const u8, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + if (std.mem.eql(u8, enum_name, "GhosttyKey") and std.mem.startsWith(u8, zig_name, "key_")) + return writeUpperObjectField(zig_name["key_".len..], jws); + if (std.mem.eql(u8, enum_name, "GhosttySgrAttributeTag")) { + if (std.mem.eql(u8, zig_name, "256_underline_color")) return jws.objectField("UNDERLINE_COLOR_256"); + if (std.mem.eql(u8, zig_name, "8_bg")) return jws.objectField("BG_8"); + if (std.mem.eql(u8, zig_name, "8_fg")) return jws.objectField("FG_8"); + if (std.mem.eql(u8, zig_name, "8_bright_bg")) return jws.objectField("BRIGHT_BG_8"); + if (std.mem.eql(u8, zig_name, "8_bright_fg")) return jws.objectField("BRIGHT_FG_8"); + if (std.mem.eql(u8, zig_name, "256_bg")) return jws.objectField("BG_256"); + if (std.mem.eql(u8, zig_name, "256_fg")) return jws.objectField("FG_256"); + } + if (std.mem.eql(u8, enum_name, "GhosttyTerminalOption") and std.mem.eql(u8, zig_name, "size_cb")) + return jws.objectField("SIZE"); + return writeUpperObjectField(zig_name, jws); + } + + fn isBuiltinType(name: []const u8) bool { + const builtins = [_][]const u8{ + "array", "bool", "f32", "f64", "function", "i8", "i16", "i32", + "i64", "opaque", "pointer", "u8", "u16", "u32", "u64", "void", + }; + for (builtins) |builtin_name| { + if (std.mem.eql(u8, name, builtin_name)) return true; + } + return false; + } +}; + +test "manifest parses and is versioned" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); defer parsed.deinit(); - const root = parsed.value.object; + try std.testing.expectEqual(@as(i64, 1), root.get("schema").?.integer); + try std.testing.expect(root.contains("abi")); + try std.testing.expect(root.contains("library_version")); + const manifest_types = root.get("types").?.object; + try std.testing.expectEqual(type_decls.len, manifest_types.count()); + inline for (type_decls) |decl| try std.testing.expect(manifest_types.contains(decl.name)); - // Verify we have every public struct declared by the C API headers. - const expected_structs = [_][]const u8{ - "GhosttyAllocator", - "GhosttyAllocatorVtable", - "GhosttyBuffer", - "GhosttyClipboardContent", - "GhosttyClipboardWrite", - "GhosttyCodepoints", - "GhosttyColorPaletteMask", - "GhosttyColorRgb", - "GhosttyColorX11Entry", - "GhosttyDeviceAttributes", - "GhosttyDeviceAttributesPrimary", - "GhosttyDeviceAttributesSecondary", - "GhosttyDeviceAttributesTertiary", - "GhosttyFormatterScreenExtra", - "GhosttyFormatterTerminalExtra", - "GhosttyFormatterTerminalOptions", - "GhosttyGridRef", - "GhosttyKittyGraphicsPlacementRenderInfo", - "GhosttyMouseEncoderSize", - "GhosttyMousePosition", - "GhosttyPoint", - "GhosttyPointCoordinate", - "GhosttyReader", - "GhosttyRenderStateColors", - "GhosttyRenderStateCursor", - "GhosttyRenderStateRowSelection", - "GhosttySelection", - "GhosttySelectionGestureBehaviors", - "GhosttySelectionGestureGeometry", - "GhosttySgrAttribute", - "GhosttySgrUnknown", - "GhosttySizeReportSize", - "GhosttyString", - "GhosttyStyle", - "GhosttyStyleColor", - "GhosttySurfacePosition", - "GhosttySysImage", - "GhosttyTerminalDesktopNotification", - "GhosttyTerminalModeConfig", - "GhosttyTerminalProgressReport", - "GhosttyTerminalScrollbar", - "GhosttyTerminalScrollViewport", - "GhosttyTerminalSelectLineOptions", - "GhosttyTerminalSelectWordBetweenOptions", - "GhosttyTerminalSelectWordOptions", - "GhosttyTerminalSelectionFormatOptions", - "GhosttyTerminalUnknownSequence", - "GhosttyTerminalUnknownStringSequence", - "GhosttyWriter", - }; - try std.testing.expectEqual(expected_structs.len, root.count()); - for (expected_structs) |name| { - try std.testing.expect(root.contains(name)); - } - - const clipboard_content = root.get("GhosttyClipboardContent").?.object; - const clipboard_content_fields = clipboard_content.get("fields").?.object; - try std.testing.expect(clipboard_content_fields.contains("mime")); - try std.testing.expect(clipboard_content_fields.contains("data")); - - const clipboard_write = root.get("GhosttyClipboardWrite").?.object; - const clipboard_write_fields = clipboard_write.get("fields").?.object; - try std.testing.expect(clipboard_write_fields.contains("size")); - try std.testing.expect(clipboard_write_fields.contains("location")); - try std.testing.expect(clipboard_write_fields.contains("contents")); - try std.testing.expect(clipboard_write_fields.contains("contents_len")); - - const unknown_sequence = root.get("GhosttyTerminalUnknownSequence").?.object; - const unknown_sequence_fields = unknown_sequence.get("fields").?.object; - try std.testing.expect(unknown_sequence_fields.contains("tag")); - try std.testing.expect(unknown_sequence_fields.contains("value")); - - const unknown_string = root.get("GhosttyTerminalUnknownStringSequence").?.object; - const unknown_string_fields = unknown_string.get("fields").?.object; - try std.testing.expect(unknown_string_fields.contains("truncated")); - try std.testing.expect(unknown_string_fields.contains("content")); - - const render_cursor_fields = root.get("GhosttyRenderStateCursor").?.object + const cursor_fields = manifest_types.get("GhosttyRenderStateCursor").?.object .get("fields").?.object; - try std.testing.expect(render_cursor_fields.contains("size")); - try std.testing.expect(render_cursor_fields.contains("viewport_has_value")); - try std.testing.expect(render_cursor_fields.contains("viewport_x")); - try std.testing.expect(render_cursor_fields.contains("viewport_y")); - try std.testing.expect(render_cursor_fields.contains("wide_tail")); - try std.testing.expect(render_cursor_fields.contains("visible")); - try std.testing.expect(render_cursor_fields.contains("blinking")); - try std.testing.expect(render_cursor_fields.contains("password_input")); - try std.testing.expect(render_cursor_fields.contains("visual_style")); - - const reader_fields = root.get("GhosttyReader").?.object - .get("fields").?.object; - try std.testing.expect(reader_fields.contains("read")); - try std.testing.expect(reader_fields.contains("userdata")); - - const writer_fields = root.get("GhosttyWriter").?.object - .get("fields").?.object; - try std.testing.expect(writer_fields.contains("write")); - try std.testing.expect(writer_fields.contains("userdata")); - - try std.testing.expect(!root.contains("GhosttyTerminalOptions")); + try std.testing.expect(cursor_fields.contains("size")); + try std.testing.expect(cursor_fields.contains("viewport_has_value")); + try std.testing.expect(cursor_fields.contains("viewport_x")); + try std.testing.expect(cursor_fields.contains("viewport_y")); + try std.testing.expect(cursor_fields.contains("wide_tail")); + try std.testing.expect(cursor_fields.contains("visible")); + try std.testing.expect(cursor_fields.contains("blinking")); + try std.testing.expect(cursor_fields.contains("password_input")); + try std.testing.expect(cursor_fields.contains("visual_style")); } -test "struct sizes are non-zero" { - const parsed = try std.json.parseFromSlice( - std.json.Value, - std.testing.allocator, - json, - .{}, - ); +test "manifest describes enums, arrays, and tagged unions" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; - var it = parsed.value.object.iterator(); - while (it.next()) |entry| { - const struct_info = entry.value_ptr.object; - const size = struct_info.get("size").?.integer; - try std.testing.expect(size > 0); + const data = manifest_types.get("GhosttyRenderStateData").?.object; + try std.testing.expectEqualStrings("enum", data.get("kind").?.string); + try std.testing.expectEqual(@as(i64, @intFromEnum(render.Data.dirty)), data.get("values").?.object.get("DIRTY").?.integer); + + const colors = manifest_types.get("GhosttyRenderStateColors").?.object; + const palette = colors.get("fields").?.object.get("palette").?.object; + try std.testing.expectEqualStrings("array", palette.get("type").?.string); + try std.testing.expectEqualStrings("GhosttyColorRgb", palette.get("elem").?.string); + try std.testing.expectEqual(@as(i64, 256), palette.get("count").?.integer); + + const style_color = manifest_types.get("GhosttyStyleColor").?.object; + const value = style_color.get("fields").?.object.get("value").?.object; + try std.testing.expectEqualStrings("GhosttyStyleColorValue", value.get("type").?.string); + try std.testing.expectEqualStrings("tag", value.get("tag").?.string); + try std.testing.expectEqualStrings("palette", value.get("arms").?.object.get("PALETTE").?.string); + try std.testing.expect(value.get("arms").?.object.get("NONE").? == .null); +} + +test "manifest uses public enum names" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); + defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; + + const cell_content = manifest_types.get("GhosttyCellContentTag").?.object.get("values").?.object; + try std.testing.expect(cell_content.contains("TAG_MAX_VALUE")); + try std.testing.expect(!cell_content.contains("MAX_VALUE")); + + const sgr_values = manifest_types.get("GhosttySgrAttributeTag").?.object.get("values").?.object; + try std.testing.expectEqual(@as(i64, 9), sgr_values.get("UNDERLINE_COLOR_256").?.integer); + try std.testing.expectEqual(@as(i64, 23), sgr_values.get("BG_8").?.integer); + try std.testing.expect(!sgr_values.contains("256_UNDERLINE_COLOR")); + + const sgr_arms = manifest_types.get("GhosttySgrAttribute").?.object + .get("fields").?.object.get("value").?.object.get("arms").?.object; + try std.testing.expectEqualStrings("underline_color_256", sgr_arms.get("UNDERLINE_COLOR_256").?.string); + try std.testing.expectEqualStrings("bg_8", sgr_arms.get("BG_8").?.string); + + const terminal_values = manifest_types.get("GhosttyTerminalOption").?.object.get("values").?.object; + try std.testing.expectEqual(@as(i64, 6), terminal_values.get("SIZE").?.integer); + try std.testing.expect(!terminal_values.contains("SIZE_CB")); + + const osc_values = manifest_types.get("GhosttyOscCommandType").?.object.get("values").?.object; + try std.testing.expectEqual(@as(i64, 22), osc_values.get("KITTY_TEXT_SIZING").?.integer); + try std.testing.expectEqual(@as(i64, 23), osc_values.get("KITTY_CLIPBOARD_PROTOCOL").?.integer); + try std.testing.expectEqual(@as(i64, 24), osc_values.get("KITTY_DND_PROTOCOL").?.integer); + try std.testing.expectEqual(@as(i64, 25), osc_values.get("CONTEXT_SIGNAL").?.integer); + try std.testing.expect(osc_values.contains("TYPE_MAX_VALUE")); + try std.testing.expect(!osc_values.contains("MAX_VALUE")); + + const key_values = manifest_types.get("GhosttyKey").?.object.get("values").?.object; + try std.testing.expect(key_values.contains("A")); + try std.testing.expect(!key_values.contains("KEY_A")); + + const mode_values = manifest_types.get("GhosttyModeReportState").?.object.get("values").?.object; + try std.testing.expectEqual(@as(i64, 0), mode_values.get("NOT_RECOGNIZED").?.integer); + try std.testing.expectEqual(@as(i64, 4), mode_values.get("PERMANENTLY_RESET").?.integer); +} + +test "manifest named references resolve" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); + defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; + + var type_iterator = manifest_types.iterator(); + while (type_iterator.next()) |type_entry| { + const descriptor = type_entry.value_ptr.object; + const fields_value = descriptor.get("fields") orelse continue; + var field_iterator = fields_value.object.iterator(); + while (field_iterator.next()) |field_entry| { + const field = field_entry.value_ptr.object; + const field_type = field.get("type").?.string; + if (!Json.isBuiltinType(field_type)) + try std.testing.expect(manifest_types.contains(field_type)); + + if (field.get("elem")) |elem_value| { + const elem = elem_value.string; + if (!Json.isBuiltinType(elem)) + try std.testing.expect(manifest_types.contains(elem)); + } + + if (field.get("arms")) |arms_value| { + const union_descriptor = manifest_types.get(field_type).?.object; + const union_fields = union_descriptor.get("fields").?.object; + const tag_field_name = field.get("tag").?.string; + const tag_type_name = fields_value.object.get(tag_field_name).?.object.get("type").?.string; + const tag_values = manifest_types.get(tag_type_name).?.object.get("values").?.object; + var arm_iterator = arms_value.object.iterator(); + while (arm_iterator.next()) |arm_entry| { + try std.testing.expect(tag_values.contains(arm_entry.key_ptr.*)); + if (arm_entry.value_ptr.* == .null) continue; + try std.testing.expect(union_fields.contains(arm_entry.value_ptr.string)); + } + } + } } } diff --git a/src/terminal/point.zig b/src/terminal/point.zig index f6bf25c39..5937576cf 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -71,7 +71,15 @@ pub const Point = union(Tag) { @This(), // Padding: largest variant is Coordinate (u16 + u32 = 6 bytes). // Use [2]u64 (16 bytes) for future expansion. - [2]u64, + .{ + .padding = [2]u64, + .field_renames = .{ + .active = "coordinate", + .viewport = "coordinate", + .screen = "coordinate", + .history = "coordinate", + }, + }, ); pub const C = c_union.C; pub const CValue = c_union.CValue; diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index e9b9354c3..3fecd4469 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -161,7 +161,18 @@ pub const Attribute = union(Tag) { // Largest variant is Unknown.C: 2 pointers + 2 usize = 32 bytes on 64-bit. // We use [8]u64 (64 bytes) to allow room for future expansion while // maintaining ABI compatibility. - [8]u64, + .{ + .padding = [8]u64, + .field_renames = .{ + .@"256_underline_color" = "underline_color_256", + .@"8_bg" = "bg_8", + .@"8_fg" = "fg_8", + .@"8_bright_bg" = "bright_bg_8", + .@"8_bright_fg" = "bright_fg_8", + .@"256_bg" = "bg_256", + .@"256_fg" = "fg_256", + }, + }, ); pub const Value = c_union.Value; pub const C = c_union.C; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index afbfa719c..105cd1e4f 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -236,7 +236,7 @@ pub const Action = union(Key) { @This(), // TODO: Before shipping an ABI-compatible libghostty, verify this. // This was just arbitrarily chosen for now. - [16]u64, + .{ .padding = [16]u64 }, ); pub const Tag = c_union.Tag; pub const Value = c_union.Value; From c75559589e41721ab5a3de8e336d8476f4290535 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Aug 2026 20:21:59 -0700 Subject: [PATCH 2/3] libghostty: add ABI manifest schema The ABI manifest previously had no machine-readable grammar or test that the public export conformed to it. Define a Draft 2020-12 schema and add a build check that executes ghostty_type_json for native and wasm libraries before validation. Run both forms in CI and publish the schema with the generated API docs. --- .github/workflows/test.yml | 9 + Doxyfile | 3 +- build.zig | 10 + include/ghostty/vt/types.h | 3 + nix/devShell.nix | 2 + src/build/docker/lib-c-docs/Dockerfile | 1 + src/terminal/c/types-schema-verify.py | 114 ++++++++ src/terminal/c/types.schema.json | 348 +++++++++++++++++++++++++ src/terminal/c/types.zig | 3 +- 9 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 src/terminal/c/types-schema-verify.py create mode 100644 src/terminal/c/types.schema.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e45793f22..a5216072c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -701,6 +701,12 @@ jobs: -Dtarget=wasm32-freestanding \ -Doptimize=ReleaseSmall + - name: Validate WASM ABI manifest + run: | + nix develop -c zig build test-lib-vt-schema \ + -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseSmall + - name: Optimize ReleaseSmall WASM run: | nix develop -c wasm-opt -O3 \ @@ -1470,6 +1476,9 @@ jobs: - name: Test run: nix develop -c zig build test-lib-vt + - name: Validate native ABI manifest + run: nix develop -c zig build test-lib-vt-schema + test-kaitai: if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' needs: skip diff --git a/Doxyfile b/Doxyfile index 1703e6fac..77eeb3d3c 100644 --- a/Doxyfile +++ b/Doxyfile @@ -57,7 +57,8 @@ GENERATE_HTML = YES HTML_OUTPUT = zig-out/share/ghostty/doc/libghostty HTML_EXTRA_STYLESHEET = dist/doxygen/ghostty.css HTML_EXTRA_FILES = dist/doxygen/favicon.png \ - dist/doxygen/mobile-nav.js + dist/doxygen/mobile-nav.js \ + src/terminal/c/types.schema.json HTML_COLORSTYLE = DARK HTML_CODE_FOLDING = NO HTML_HEADER = dist/doxygen/header.html diff --git a/build.zig b/build.zig index 1c56cadf8..5ce7e816b 100644 --- a/build.zig +++ b/build.zig @@ -73,6 +73,10 @@ pub fn build(b: *std.Build) !void { "test-lib-vt-build", "Build libghostty-vt tests without running them (compile check)", ); + const test_lib_vt_schema_step = b.step( + "test-lib-vt-schema", + "Validate the libghostty-vt ABI type manifest", + ); const test_valgrind_step = b.step( "test-valgrind", "Run tests under valgrind", @@ -136,6 +140,12 @@ pub fn build(b: *std.Build) !void { }; libghostty_vt_shared.install(b.getInstallStep()); + const type_schema_test = b.addSystemCommand(&.{"python3"}); + type_schema_test.addFileArg(b.path("src/terminal/c/types-schema-verify.py")); + type_schema_test.addFileArg(b.path("src/terminal/c/types.schema.json")); + type_schema_test.addFileArg(libghostty_vt_shared.output); + test_lib_vt_schema_step.dependOn(&type_schema_test.step); + // libghostty-vt static lib const libghostty_vt_static = try buildpkg.GhosttyLibVt.initStatic( b, diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index bc1c6a69b..787cc0148 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -342,6 +342,9 @@ typedef struct { * this manifest rather than hardcoding them. Consumers should reject unknown * schema versions and verify the descriptors they require at initialization. * + * The formal format is defined by the + * libghostty-vt ABI manifest JSON Schema. + * * Example (abbreviated): * @code{.json} * { diff --git a/nix/devShell.nix b/nix/devShell.nix index c94892414..fba128a21 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -91,8 +91,10 @@ inherit pkgs lib stdenv; }; python = python3.withPackages (python-pkgs: [ + python-pkgs.jsonschema python-pkgs.kaitaistruct python-pkgs.ucs-detect + python-pkgs.wasmtime ]); in mkShell { diff --git a/src/build/docker/lib-c-docs/Dockerfile b/src/build/docker/lib-c-docs/Dockerfile index a3cfdcc98..b5d176160 100644 --- a/src/build/docker/lib-c-docs/Dockerfile +++ b/src/build/docker/lib-c-docs/Dockerfile @@ -14,6 +14,7 @@ WORKDIR /ghostty COPY include/ ./include/ COPY images/ ./images/ COPY dist/doxygen/ ./dist/doxygen/ +COPY src/terminal/c/types.schema.json ./src/terminal/c/types.schema.json COPY example/ ./example/ COPY Doxyfile ./ COPY DoxygenLayout.xml ./ diff --git a/src/terminal/c/types-schema-verify.py b/src/terminal/c/types-schema-verify.py new file mode 100644 index 000000000..1a8e064dc --- /dev/null +++ b/src/terminal/c/types-schema-verify.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Validate the ABI manifest exported by a native or WebAssembly library.""" + +from __future__ import annotations + +import argparse +import ctypes +import json +import sys +from pathlib import Path +from typing import Any + +try: + import jsonschema +except ImportError as error: + raise SystemExit( + "missing ABI schema verifier dependencies; run this inside " + "`nix develop`" + ) from error + + +def load_native_manifest(path: Path) -> bytes: + """Call ghostty_type_json in a native shared library.""" + library = ctypes.CDLL(str(path.resolve())) + type_json = library.ghostty_type_json + type_json.argtypes = () + type_json.restype = ctypes.c_char_p + + result = type_json() + if result is None: + raise RuntimeError("ghostty_type_json returned NULL") + return result + + +def load_wasm_manifest(path: Path) -> bytes: + """Call ghostty_type_json and read its result from WebAssembly memory.""" + try: + import wasmtime + except ImportError as error: + raise SystemExit( + "missing WebAssembly verifier dependencies; run this inside " + "`nix develop`" + ) from error + + engine = wasmtime.Engine() + module = wasmtime.Module.from_file(engine, str(path)) + store = wasmtime.Store(engine) + instance = wasmtime.Instance(store, module, []) + exports = instance.exports(store) + memory = exports["memory"] + type_json = exports["ghostty_type_json"] + + pointer = type_json(store) + data = memory.read(store, pointer, memory.data_len(store)) + terminator = data.find(b"\0") + if terminator < 0: + raise RuntimeError("ghostty_type_json result is not NUL terminated") + return bytes(data[:terminator]) + + +def load_manifest(path: Path) -> dict[str, Any]: + """Execute the public export and decode its JSON result.""" + encoded = ( + load_wasm_manifest(path) + if path.suffix.lower() == ".wasm" + else load_native_manifest(path) + ) + value = json.loads(encoded) + if not isinstance(value, dict): + raise ValueError("ghostty_type_json did not return a JSON object") + return value + + +def format_path(parts: list[object]) -> str: + """Format a jsonschema error path for command-line output.""" + return "/" + "/".join(str(part) for part in parts) + + +def validate(schema_path: Path, library_path: Path) -> None: + """Validate the schema itself and the manifest returned by the library.""" + schema = json.loads(schema_path.read_bytes()) + validator_type = jsonschema.validators.validator_for(schema) + validator_type.check_schema(schema) + + manifest = load_manifest(library_path) + errors = sorted( + validator_type(schema).iter_errors(manifest), + key=lambda error: list(error.absolute_path), + ) + if errors: + for error in errors: + print( + f"{format_path(list(error.absolute_path))}: {error.message}", + file=sys.stderr, + ) + raise SystemExit(f"ABI manifest failed {schema_path}") + + abi = manifest["abi"] + print( + f"validated {abi['target']}-{abi['os']} ABI manifest " + f"({len(manifest['types'])} types)" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("schema", type=Path) + parser.add_argument("library", type=Path) + args = parser.parse_args() + validate(args.schema, args.library) + + +if __name__ == "__main__": + main() diff --git a/src/terminal/c/types.schema.json b/src/terminal/c/types.schema.json new file mode 100644 index 000000000..7020cb172 --- /dev/null +++ b/src/terminal/c/types.schema.json @@ -0,0 +1,348 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "libghostty-vt ABI type manifest", + "description": "Schema for the JSON returned by ghostty_type_json.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "abi", "library_version", "commit", "dirty", "types"], + "properties": { + "schema": { + "const": 1 + }, + "abi": { + "$ref": "#/$defs/abi" + }, + "library_version": { + "type": "string", + "minLength": 1 + }, + "commit": { + "type": ["string", "null"], + "minLength": 1 + }, + "dirty": { + "type": ["boolean", "null"] + }, + "types": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^Ghostty[A-Za-z0-9_]+$" + }, + "additionalProperties": { + "$ref": "#/$defs/typeDescriptor" + } + } + }, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "positiveInteger": { + "type": "integer", + "minimum": 1 + }, + "typeReference": { + "type": "string", + "pattern": "^(Ghostty[A-Za-z0-9_]+|bool|f32|f64|function|i8|i16|i32|i64|opaque|u8|u16|u32|u64|void)$" + }, + "abi": { + "type": "object", + "additionalProperties": false, + "required": [ + "target", + "os", + "environment", + "pointer_size", + "usize_size", + "endian" + ], + "properties": { + "target": { + "type": "string", + "minLength": 1 + }, + "os": { + "type": "string", + "minLength": 1 + }, + "environment": { + "type": "string", + "minLength": 1 + }, + "pointer_size": { + "$ref": "#/$defs/positiveInteger" + }, + "usize_size": { + "$ref": "#/$defs/positiveInteger" + }, + "endian": { + "enum": ["little", "big"] + } + } + }, + "fieldBase": { + "type": "object", + "required": ["offset", "size", "type"], + "properties": { + "offset": { + "$ref": "#/$defs/nonNegativeInteger" + }, + "size": { + "$ref": "#/$defs/nonNegativeInteger" + } + } + }, + "plainField": { + "allOf": [ + { + "$ref": "#/$defs/fieldBase" + }, + { + "properties": { + "type": { + "$ref": "#/$defs/typeReference" + } + } + } + ], + "unevaluatedProperties": false + }, + "arrayField": { + "allOf": [ + { + "$ref": "#/$defs/fieldBase" + }, + { + "required": ["elem", "count"], + "properties": { + "type": { + "const": "array" + }, + "elem": { + "$ref": "#/$defs/typeReference" + }, + "count": { + "$ref": "#/$defs/nonNegativeInteger" + } + } + } + ], + "unevaluatedProperties": false + }, + "pointerField": { + "allOf": [ + { + "$ref": "#/$defs/fieldBase" + }, + { + "required": ["elem", "const"], + "properties": { + "type": { + "const": "pointer" + }, + "elem": { + "$ref": "#/$defs/typeReference" + }, + "const": { + "type": "boolean" + }, + "nullable": { + "const": true + } + } + } + ], + "unevaluatedProperties": false + }, + "taggedUnionField": { + "allOf": [ + { + "$ref": "#/$defs/fieldBase" + }, + { + "required": ["tag", "arms"], + "properties": { + "type": { + "$ref": "#/$defs/typeReference" + }, + "tag": { + "type": "string", + "minLength": 1 + }, + "arms": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "additionalProperties": { + "type": ["string", "null"] + } + } + } + } + ], + "unevaluatedProperties": false + }, + "field": { + "oneOf": [ + { + "$ref": "#/$defs/plainField" + }, + { + "$ref": "#/$defs/arrayField" + }, + { + "$ref": "#/$defs/pointerField" + }, + { + "$ref": "#/$defs/taggedUnionField" + } + ] + }, + "fields": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/field" + } + }, + "descriptorBase": { + "type": "object", + "required": ["kind", "size", "align"], + "properties": { + "size": { + "$ref": "#/$defs/nonNegativeInteger" + }, + "align": { + "$ref": "#/$defs/positiveInteger" + } + } + }, + "structDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "required": ["fields"], + "properties": { + "kind": { + "const": "struct" + }, + "fields": { + "$ref": "#/$defs/fields" + } + } + } + ], + "unevaluatedProperties": false + }, + "unionDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "required": ["fields"], + "properties": { + "kind": { + "const": "union" + }, + "fields": { + "$ref": "#/$defs/fields" + } + } + } + ], + "unevaluatedProperties": false + }, + "enumDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "required": ["underlying", "prefix", "values"], + "properties": { + "kind": { + "const": "enum" + }, + "underlying": { + "const": "i32" + }, + "prefix": { + "type": "string", + "minLength": 1 + }, + "values": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "additionalProperties": { + "type": "integer" + } + } + } + } + ], + "unevaluatedProperties": false + }, + "aliasDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "required": ["type"], + "properties": { + "kind": { + "const": "alias" + }, + "type": { + "$ref": "#/$defs/typeReference" + } + } + } + ], + "unevaluatedProperties": false + }, + "opaqueDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "properties": { + "kind": { + "const": "opaque" + } + } + } + ], + "unevaluatedProperties": false + }, + "typeDescriptor": { + "oneOf": [ + { + "$ref": "#/$defs/structDescriptor" + }, + { + "$ref": "#/$defs/unionDescriptor" + }, + { + "$ref": "#/$defs/enumDescriptor" + }, + { + "$ref": "#/$defs/aliasDescriptor" + }, + { + "$ref": "#/$defs/opaqueDescriptor" + } + ] + } + } +} diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 88759ef1c..2242b1d50 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -2,7 +2,8 @@ //! //! The manifest is embedded in the library and returned by //! `ghostty_type_json`. It is intended for FFI consumers that cannot use the -//! C headers directly, most notably WebAssembly hosts. +//! C headers directly, most notably WebAssembly hosts. Its format is defined +//! by `types.schema.json`. const std = @import("std"); const builtin = @import("builtin"); const build_options = @import("terminal_options"); From 0e8b7bea63ccf8b89d15a82bdaf6a9bb3fc8c43d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Aug 2026 21:04:03 -0700 Subject: [PATCH 3/3] vt: expose packed cell layout GhosttyCell was exposed as a raw integer while its manifest entry was only an alias, forcing bulk-read consumers to duplicate the internal cell bit layout.\n\nAdd reflection helpers for packed structs and tagged unions, and keep the C-facing layout metadata next to Cell itself. Extend the ABI manifest and schema with recursive bit descriptors so every content arm, including palette and RGB backgrounds, can be decoded without hardcoded masks.\n\nDocument manifest-driven cell decoding and test the metadata against Zig reflection and real cell values. --- include/ghostty/vt/render.h | 9 +- include/ghostty/vt/screen.h | 19 +- include/ghostty/vt/types.h | 7 + src/lib/main.zig | 3 + src/lib/packed.zig | 290 ++++++++++++++++++++++++++++++ src/terminal/c/types.schema.json | 155 +++++++++++++++- src/terminal/c/types.zig | 300 ++++++++++++++++++++++++++++++- src/terminal/page.zig | 46 +++++ 8 files changed, 813 insertions(+), 16 deletions(-) create mode 100644 src/lib/packed.zig diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index 728966f48..f6b5e2222 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -256,9 +256,12 @@ typedef enum GHOSTTY_ENUM_TYPED { * * This is the bulk alternative to iterating cells one at a time. * It lets callers with expensive call boundaries (e.g. WebAssembly - * embedders) read an entire row with a single call, then drill - * into the cells iterator only for cells that need managed data - * (styles, graphemes). */ + * embedders) read an entire row with a single call. + * + * Bit positions aren't protected by ABI, so callers should parse them + * out of the manifest from `ghostty_type_json`. Callers with access + * to the C header or without high FFI costs should use `ghostty_cell_get`. + */ GHOSTTY_RENDER_STATE_ROW_DATA_CELLS_RAW = 5, GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateRowData; diff --git a/include/ghostty/vt/screen.h b/include/ghostty/vt/screen.h index 0d009a781..2899989dd 100644 --- a/include/ghostty/vt/screen.h +++ b/include/ghostty/vt/screen.h @@ -21,19 +21,21 @@ extern "C" { * Terminal screen cell and row types. * * These types represent the contents of a terminal screen. A GhosttyCell - * is a single grid cell and a GhosttyRow is a single row. Both are opaque - * values whose fields are accessed via ghostty_cell_get() and - * ghostty_row_get() respectively. + * is a single grid cell and a GhosttyRow is a single row. Cell fields can + * be accessed via ghostty_cell_get() or decoded from the packed layout in + * ghostty_type_json(). Rows are opaque and accessed via ghostty_row_get(). * * @{ */ /** - * Opaque cell value. + * Packed cell value. * - * Represents a single terminal cell. The internal layout is opaque and - * must be queried via ghostty_cell_get(). Obtain cell values from - * terminal query APIs. + * Represents a single terminal cell. Portable callers can query fields via + * ghostty_cell_get(). Boundary-sensitive callers can decode the packed value + * using the GhosttyCell descriptor returned by ghostty_type_json(). The + * manifest is authoritative for the linked build; hardcoding bit positions + * is unsupported. * * @ingroup screen */ @@ -55,7 +57,8 @@ typedef uint64_t GhosttyRow; * * The memory is not owned by this struct. The pointer is only valid * for the lifetime documented by the API that produces it. Each value - * is queried via ghostty_cell_get() like any other GhosttyCell. + * can be queried via ghostty_cell_get() or decoded using the GhosttyCell + * packed descriptor returned by ghostty_type_json(). * * @ingroup screen */ diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index 787cc0148..a0621945e 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -342,6 +342,13 @@ typedef struct { * this manifest rather than hardcoding them. Consumers should reject unknown * schema versions and verify the descriptors they require at initialization. * + * Packed type descriptors define fields using `lsb` and `width`. `lsb` is + * relative to bit zero of the containing numerical value; for nested packed + * layouts it is relative to the immediate containing field. Tagged packed + * unions select an inline arm layout using the named tag field. These layouts + * describe the current linked build and are not a cross-version stability + * promise. + * * The formal format is defined by the * libghostty-vt ABI manifest JSON Schema. * diff --git a/src/lib/main.zig b/src/lib/main.zig index 45cdb71d4..573007bd9 100644 --- a/src/lib/main.zig +++ b/src/lib/main.zig @@ -1,5 +1,6 @@ const std = @import("std"); const enumpkg = @import("enum.zig"); +const packedpkg = @import("packed.zig"); const structpkg = @import("struct.zig"); const types = @import("types.zig"); const unionpkg = @import("union.zig"); @@ -8,6 +9,8 @@ pub const allocator = @import("allocator.zig"); pub const TinyIo = @import("TinyIo.zig"); pub const Buffer = types.Buffer; pub const Enum = enumpkg.Enum; +pub const Packed = packedpkg.Packed; +pub const PackedTaggedUnion = packedpkg.PackedTaggedUnion; pub const checkGhosttyHEnum = enumpkg.checkGhosttyHEnum; pub const String = types.String; pub const Struct = structpkg.Struct; diff --git a/src/lib/packed.zig b/src/lib/packed.zig new file mode 100644 index 000000000..b210242d8 --- /dev/null +++ b/src/lib/packed.zig @@ -0,0 +1,290 @@ +const std = @import("std"); +const testing = std.testing; + +/// Metadata for a field in a packed layout. +pub const FieldOptions = struct { + /// Public name for this field. The Zig field name is used by default. + name: ?[]const u8 = null, + + /// Public type name for scalar fields. The Zig type is used by default. + type_name: ?[]const u8 = null, + + /// Omit this field from public metadata. This is intended for padding. + omit: bool = false, + + /// How the field is represented in public metadata. + encoding: Encoding = .scalar, + + pub const Encoding = union(enum) { + scalar, + @"packed": type, + tagged_union: type, + }; +}; + +/// Create metadata for a packed struct. Physical layout information is always +/// reflected from `T`; options only provide public names and relationships. +pub fn Packed( + comptime T: type, + comptime options: PackedOptions(T), +) type { + const info = packedStructInfo(T); + const FieldT = std.meta.FieldEnum(T); + + return struct { + pub const Zig = T; + + // I know this is an insane amount of validation but getting + // this correct is critical to C APIs working so we go overboard. + comptime { + for (info.fields, 0..) |field, i| { + const field_options = @field(options.fields, field.name); + if (field_options.omit) { + if (field_options.name != null or + field_options.type_name != null or + field_options.encoding != .scalar) + { + @compileError("omitted packed field has other options: " ++ field.name); + } + continue; + } + + if (field_options.name) |name| { + if (name.len == 0) + @compileError("packed field name cannot be empty: " ++ field.name); + } + + switch (field_options.encoding) { + .scalar => switch (@typeInfo(field.type)) { + .bool, .int, .@"enum" => {}, + else => @compileError("packed field requires an explicit encoding: " ++ field.name), + }, + .@"packed" => |Layout| { + if (Layout.Zig != field.type) + @compileError("nested packed layout has the wrong Zig type: " ++ field.name); + if (field_options.type_name != null) + @compileError("nested packed field cannot have a scalar type name: " ++ field.name); + }, + .tagged_union => |Layout| { + if (Layout.Owner != T or Layout.Union != field.type or + Layout.union_field != @field(FieldT, field.name)) + { + @compileError("tagged union layout does not match packed field: " ++ field.name); + } + if (field_options.type_name != null) + @compileError("tagged union field cannot have a scalar type name: " ++ field.name); + }, + } + + const public_name = field_options.name orelse field.name; + for (info.fields[0..i]) |previous| { + const previous_options = @field(options.fields, previous.name); + if (previous_options.omit) continue; + const previous_name = previous_options.name orelse previous.name; + if (std.mem.eql(u8, public_name, previous_name)) + @compileError("duplicate public packed field name: " ++ public_name); + } + } + } + + pub const Backing = info.backing_integer.?; + pub const Field = std.meta.FieldEnum(T); + + pub fn fieldOptions(comptime field: Field) FieldOptions { + return @field(options.fields, @tagName(field)); + } + + pub fn fieldName(comptime field: Field) ?[]const u8 { + const field_options = fieldOptions(field); + if (field_options.omit) return null; + return field_options.name orelse @tagName(field); + } + + pub fn bitOffset(comptime field: Field) usize { + return @bitOffsetOf(T, @tagName(field)); + } + + pub fn bitWidth(comptime field: Field) usize { + return @bitSizeOf(@FieldType(T, @tagName(field))); + } + }; +} + +/// Options for a packed struct. A field is generated for every field in `T` +/// so unknown field names are rejected by normal Zig type checking. +pub fn PackedOptions(comptime T: type) type { + const fields = packedStructInfo(T).fields; + const default_options: FieldOptions = .{}; + + var names: [fields.len][]const u8 = undefined; + var types: [fields.len]type = undefined; + var attrs: [fields.len]std.builtin.Type.StructField.Attributes = undefined; + + for (fields, 0..) |field, i| { + names[i] = field.name; + types[i] = FieldOptions; + attrs[i] = .{ .default_value_ptr = &default_options }; + } + + const Fields = @Struct(.auto, null, &names, &types, &attrs); + return struct { + fields: Fields = .{}, + }; +} + +/// Describe a packed union field whose active arm is selected by another +/// field in the containing packed struct. Each tag maps to a reflected packed +/// arm layout. Multiple tags may map to the same source union field. +pub fn PackedTaggedUnion( + comptime OwnerT: type, + comptime union_field_value: std.meta.FieldEnum(OwnerT), + comptime tag_field_value: std.meta.FieldEnum(OwnerT), + comptime options: PackedTaggedUnionOptions( + @FieldType(OwnerT, @tagName(union_field_value)), + @FieldType(OwnerT, @tagName(tag_field_value)), + ), +) type { + _ = packedStructInfo(OwnerT); + const UnionT = @FieldType(OwnerT, @tagName(union_field_value)); + const union_info = @typeInfo(UnionT).@"union"; + if (union_info.layout != .@"packed") + @compileError("packed tagged union value must be a packed union"); + + const TagT = @FieldType(OwnerT, @tagName(tag_field_value)); + const tag_info = @typeInfo(TagT).@"enum"; + const ArmT = PackedTaggedUnionArm(UnionT); + + return struct { + pub const Owner = OwnerT; + pub const Union = UnionT; + pub const Tag = TagT; + pub const Arm = ArmT; + pub const union_field = union_field_value; + pub const tag_field = tag_field_value; + + comptime { + for (tag_info.fields) |tag| { + const arm_value = @field(options.arms, tag.name) orelse continue; + switch (arm_value) { + inline else => |Layout, source| { + const source_name = @tagName(source); + if (Layout.Zig != @FieldType(UnionT, source_name)) + @compileError("packed union arm layout has the wrong Zig type for tag: " ++ tag.name); + if (@bitSizeOf(Layout.Zig) > @bitSizeOf(UnionT)) + @compileError("packed union arm is wider than its union: " ++ tag.name); + }, + } + } + } + + pub fn arm(comptime tag: Tag) ?Arm { + return @field(options.arms, @tagName(tag)); + } + }; +} + +/// Options for mapping tag values to packed union arms. +pub fn PackedTaggedUnionOptions(comptime Union: type, comptime Tag: type) type { + const union_info = @typeInfo(Union).@"union"; + if (union_info.layout != .@"packed") + @compileError("packed tagged union value must be a packed union"); + const tag_fields = @typeInfo(Tag).@"enum".fields; + const Arm = PackedTaggedUnionArm(Union); + const default_arm: ?Arm = null; + + var names: [tag_fields.len][]const u8 = undefined; + var types: [tag_fields.len]type = undefined; + var attrs: [tag_fields.len]std.builtin.Type.StructField.Attributes = undefined; + + for (tag_fields, 0..) |field, i| { + names[i] = field.name; + types[i] = ?Arm; + attrs[i] = .{ .default_value_ptr = &default_arm }; + } + + const Arms = @Struct(.auto, null, &names, &types, &attrs); + return struct { + arms: Arms = .{}, + }; +} + +fn PackedTaggedUnionArm(comptime Union: type) type { + const fields = @typeInfo(Union).@"union".fields; + const Tag = std.meta.FieldEnum(Union); + + var names: [fields.len][]const u8 = undefined; + var types: [fields.len]type = undefined; + var attrs: [fields.len]std.builtin.Type.UnionField.Attributes = undefined; + + for (fields, 0..) |field, i| { + names[i] = field.name; + types[i] = type; + attrs[i] = .{ .@"align" = 1 }; + } + + return @Union(.auto, Tag, &names, &types, &attrs); +} + +fn packedStructInfo(comptime T: type) std.builtin.Type.Struct { + const info = @typeInfo(T).@"struct"; + if (info.layout != .@"packed") + @compileError("Packed requires a packed struct"); + if (info.backing_integer == null) + @compileError("Packed requires an integer-backed packed struct"); + return info; +} + +test "Packed: reflected fields and nested tagged union" { + const Tag = enum(u2) { first, second, third }; + const Value = packed union { + number: packed struct(u4) { + data: u3, + _pad: u1, + }, + flags: packed struct(u4) { + a: bool, + b: bool, + _pad: u2, + }, + }; + const Nested = packed struct(u1) { enabled: bool }; + const Subject = packed struct(u8) { + tag: Tag, + value: Value, + nested: Nested, + _padding: u1, + }; + + const Number = Packed(@FieldType(Value, "number"), .{ .fields = .{ + .data = .{ .name = "number" }, + ._pad = .{ .omit = true }, + } }); + const Flags = Packed(@FieldType(Value, "flags"), .{ .fields = .{ + ._pad = .{ .omit = true }, + } }); + const NestedLayout = Packed(Nested, .{}); + const ValueLayout = PackedTaggedUnion(Subject, .value, .tag, .{ .arms = .{ + .first = .{ .number = Number }, + .second = .{ .number = Number }, + .third = .{ .flags = Flags }, + } }); + const Layout = Packed(Subject, .{ .fields = .{ + .value = .{ .encoding = .{ .tagged_union = ValueLayout } }, + .nested = .{ .encoding = .{ .@"packed" = NestedLayout } }, + ._padding = .{ .omit = true }, + } }); + + try testing.expectEqual(@bitOffsetOf(Subject, "value"), Layout.bitOffset(.value)); + try testing.expectEqual(@bitSizeOf(Value), Layout.bitWidth(.value)); + try testing.expectEqualStrings("number", Number.fieldName(.data).?); + try testing.expect(Number.fieldName(._pad) == null); + switch (ValueLayout.arm(.first).?) { + .number => |ArmLayout| try testing.expect(ArmLayout == Number), + else => return error.TestUnexpectedResult, + } + switch (ValueLayout.arm(.second).?) { + .number => |ArmLayout| try testing.expect(ArmLayout == Number), + else => return error.TestUnexpectedResult, + } + try testing.expectEqual(@as(usize, 1), NestedLayout.bitWidth(.enabled)); +} diff --git a/src/terminal/c/types.schema.json b/src/terminal/c/types.schema.json index 7020cb172..7d784153b 100644 --- a/src/terminal/c/types.schema.json +++ b/src/terminal/c/types.schema.json @@ -45,7 +45,11 @@ }, "typeReference": { "type": "string", - "pattern": "^(Ghostty[A-Za-z0-9_]+|bool|f32|f64|function|i8|i16|i32|i64|opaque|u8|u16|u32|u64|void)$" + "pattern": "^(Ghostty[A-Za-z0-9_]+|bool|f32|f64|function|[iu][1-9][0-9]*|opaque|void)$" + }, + "integerTypeReference": { + "type": "string", + "pattern": "^[iu][1-9][0-9]*$" }, "abi": { "type": "object", @@ -208,6 +212,129 @@ "$ref": "#/$defs/field" } }, + "bitBase": { + "type": "object", + "required": ["lsb", "width"], + "properties": { + "lsb": { + "description": "Least-significant bit relative to the containing numerical value.", + "$ref": "#/$defs/nonNegativeInteger" + }, + "width": { + "description": "Physical width of this field in bits.", + "$ref": "#/$defs/positiveInteger" + } + } + }, + "scalarBit": { + "allOf": [ + { + "$ref": "#/$defs/bitBase" + }, + { + "required": ["type"], + "properties": { + "type": { + "$ref": "#/$defs/typeReference" + } + } + } + ], + "unevaluatedProperties": false + }, + "packedBit": { + "allOf": [ + { + "$ref": "#/$defs/bitBase" + }, + { + "required": ["kind", "bits"], + "properties": { + "kind": { + "const": "packed" + }, + "bits": { + "$ref": "#/$defs/bits" + } + } + } + ], + "unevaluatedProperties": false + }, + "packedArm": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "width", "bits"], + "properties": { + "kind": { + "const": "packed" + }, + "width": { + "description": "Physical width of this inline arm in bits.", + "$ref": "#/$defs/positiveInteger" + }, + "bits": { + "$ref": "#/$defs/bits" + } + } + }, + "taggedUnionBit": { + "allOf": [ + { + "$ref": "#/$defs/bitBase" + }, + { + "required": ["kind", "tag", "arms"], + "properties": { + "kind": { + "const": "union" + }, + "tag": { + "type": "string", + "minLength": 1 + }, + "arms": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/$defs/packedArm" + }, + { + "type": "null" + } + ] + } + } + } + } + ], + "unevaluatedProperties": false + }, + "bit": { + "oneOf": [ + { + "$ref": "#/$defs/scalarBit" + }, + { + "$ref": "#/$defs/packedBit" + }, + { + "$ref": "#/$defs/taggedUnionBit" + } + ] + }, + "bits": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/bit" + } + }, "descriptorBase": { "type": "object", "required": ["kind", "size", "align"], @@ -291,6 +418,29 @@ ], "unevaluatedProperties": false }, + "packedDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "required": ["underlying", "bits"], + "properties": { + "kind": { + "const": "packed" + }, + "underlying": { + "description": "Integer backing type used to interpret the packed value.", + "$ref": "#/$defs/integerTypeReference" + }, + "bits": { + "$ref": "#/$defs/bits" + } + } + } + ], + "unevaluatedProperties": false + }, "aliasDescriptor": { "allOf": [ { @@ -336,6 +486,9 @@ { "$ref": "#/$defs/enumDescriptor" }, + { + "$ref": "#/$defs/packedDescriptor" + }, { "$ref": "#/$defs/aliasDescriptor" }, diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 2242b1d50..578900a2b 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -16,6 +16,7 @@ const focus_pkg = @import("../focus.zig"); const formatter_pkg = @import("../formatter.zig"); const modes_pkg = @import("../modes.zig"); const mouse_pkg = @import("../mouse.zig"); +const page = @import("../page.zig"); const point = @import("../point.zig"); const Selection = @import("../Selection.zig"); const sgr = @import("../sgr.zig"); @@ -72,6 +73,7 @@ const TypeDecl = struct { @"struct", @"union", @"enum", + @"packed", alias, @"opaque", }; @@ -146,6 +148,10 @@ const TypeDecl = struct { }; } + fn initPacked(comptime name: []const u8, comptime T: type) TypeDecl { + return .{ .name = name, .T = T, .kind = .@"packed" }; + } + fn initAlias( comptime name: []const u8, comptime T: type, @@ -297,7 +303,7 @@ const type_decls = [_]TypeDecl{ .initEnum("GhosttyTerminalScrollViewportTag", terminal.ZigTerminal.ScrollViewport.Tag, "GHOSTTY_SCROLL_VIEWPORT_"), .initEnum("GhosttyTerminalUnknownSequenceTag", terminal.UnknownSequence.Tag, "GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_"), - .initAlias("GhosttyCell", u64, "u64"), + .initPacked("GhosttyCell", page.Cell.CLayout), .initAlias("GhosttyColorPaletteIndex", u8, "u8"), .initAlias("GhosttyKittyKeyFlags", u8, "u8"), .initAlias("GhosttyMode", u16, "u16"), @@ -438,6 +444,7 @@ const Json = struct { try jws.write(std.math.maxInt(c_int)); try jws.endObject(); }, + .@"packed" => try writePackedType(decl.T, jws), .alias => { try writeSizeAlign(decl.T, jws); try jws.objectField("type"); @@ -448,6 +455,98 @@ const Json = struct { try jws.endObject(); } + fn writePackedType( + comptime Layout: type, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + try writeSizeAlign(Layout.Zig, jws); + try jws.objectField("underlying"); + try jws.write(publicTypeName(Layout.Backing)); + try jws.objectField("bits"); + try jws.beginObject(); + try writePackedBits(Layout, jws); + try jws.endObject(); + } + + fn writePackedBits( + comptime Layout: type, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + inline for (@typeInfo(Layout.Zig).@"struct".fields) |field| { + const field_tag = @field(Layout.Field, field.name); + const name = comptime Layout.fieldName(field_tag) orelse continue; + const options = Layout.fieldOptions(field_tag); + + try jws.objectField(name); + try jws.beginObject(); + try jws.objectField("lsb"); + try jws.write(Layout.bitOffset(field_tag)); + try jws.objectField("width"); + try jws.write(Layout.bitWidth(field_tag)); + + switch (options.encoding) { + .scalar => { + try jws.objectField("type"); + try jws.write(options.type_name orelse publicTypeName(field.type)); + }, + .@"packed" => |Nested| { + try jws.objectField("kind"); + try jws.write("packed"); + try jws.objectField("bits"); + try jws.beginObject(); + try writePackedBits(Nested, jws); + try jws.endObject(); + }, + .tagged_union => |UnionLayout| try writePackedTaggedUnion(Layout, UnionLayout, jws), + } + try jws.endObject(); + } + } + + fn writePackedTaggedUnion( + comptime Layout: type, + comptime UnionLayout: type, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + try jws.objectField("kind"); + try jws.write("union"); + try jws.objectField("tag"); + try jws.write(Layout.fieldName(UnionLayout.tag_field).?); + try jws.objectField("arms"); + try jws.beginObject(); + + const tag_options = Layout.fieldOptions(UnionLayout.tag_field); + const tag_type_name = tag_options.type_name orelse publicTypeName(UnionLayout.Tag); + inline for (@typeInfo(UnionLayout.Tag).@"enum".fields) |tag| { + try writeEnumObjectField(tag_type_name, tag.name, jws); + const arm = UnionLayout.arm(@field(UnionLayout.Tag, tag.name)) orelse { + try jws.write(null); + continue; + }; + switch (arm) { + inline else => |ArmLayout| try writePackedArm(ArmLayout, jws), + } + } + + try jws.endObject(); + } + + fn writePackedArm( + comptime Layout: type, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + try jws.beginObject(); + try jws.objectField("kind"); + try jws.write("packed"); + try jws.objectField("width"); + try jws.write(@bitSizeOf(Layout.Zig)); + try jws.objectField("bits"); + try jws.beginObject(); + try writePackedBits(Layout, jws); + try jws.endObject(); + try jws.endObject(); + } + fn writeField( comptime decl: TypeDecl, comptime name: []const u8, @@ -653,7 +752,7 @@ const Json = struct { fn publicTypeName(comptime T: type) []const u8 { inline for (type_decls) |decl| switch (decl.kind) { .@"struct", .@"union", .@"enum" => if (T == decl.T) return decl.name, - .alias, .@"opaque" => {}, + .@"packed", .alias, .@"opaque" => {}, }; return switch (@typeInfo(T)) { .bool => "bool", @@ -681,14 +780,14 @@ const Json = struct { 16 => "i16", 32 => "i32", 64 => "i64", - else => "opaque", + else => std.fmt.comptimePrint("i{d}", .{bits}), }, .unsigned => switch (bits) { 8 => "u8", 16 => "u16", 32 => "u32", 64 => "u64", - else => "opaque", + else => std.fmt.comptimePrint("u{d}", .{bits}), }, }; } @@ -728,6 +827,12 @@ const Json = struct { for (builtins) |builtin_name| { if (std.mem.eql(u8, name, builtin_name)) return true; } + if (name.len > 1 and (name[0] == 'i' or name[0] == 'u') and + name[1] >= '1' and name[1] <= '9') + { + for (name[2..]) |c| if (!std.ascii.isDigit(c)) return false; + return true; + } return false; } }; @@ -779,6 +884,87 @@ test "manifest describes enums, arrays, and tagged unions" { try std.testing.expect(value.get("arms").?.object.get("NONE").? == .null); } +test "manifest describes the complete packed cell layout" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); + defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; + const descriptor = manifest_types.get("GhosttyCell").?.object; + + try std.testing.expectEqualStrings("packed", descriptor.get("kind").?.string); + try std.testing.expectEqual(@as(i64, @sizeOf(page.Cell.CLayout.Zig)), descriptor.get("size").?.integer); + try std.testing.expectEqualStrings("u64", descriptor.get("underlying").?.string); + + const bits = descriptor.get("bits").?.object; + inline for (@typeInfo(page.Cell.CLayout.Zig).@"struct".fields) |field| { + const field_tag = @field(page.Cell.CLayout.Field, field.name); + const name = comptime page.Cell.CLayout.fieldName(field_tag); + if (name) |public_name| { + const bit = bits.get(public_name).?.object; + try std.testing.expectEqual( + @as(i64, @intCast(@bitOffsetOf(page.Cell.CLayout.Zig, field.name))), + bit.get("lsb").?.integer, + ); + try std.testing.expectEqual( + @as(i64, @intCast(@bitSizeOf(field.type))), + bit.get("width").?.integer, + ); + } else { + try std.testing.expect(!bits.contains(field.name)); + } + } + + const content = bits.get("content").?.object; + try std.testing.expectEqualStrings("union", content.get("kind").?.string); + try std.testing.expectEqualStrings("content_tag", content.get("tag").?.string); + const arms = content.get("arms").?.object; + + const Content = @FieldType(page.Cell.CLayout.Zig, "content"); + const Codepoint = @FieldType(Content, "codepoint"); + const codepoint = arms.get("CODEPOINT").?.object; + const grapheme = arms.get("CODEPOINT_GRAPHEME").?.object; + try expectPackedArmField(codepoint, "codepoint", Codepoint, "data", "u21"); + try expectPackedArmField(grapheme, "codepoint", Codepoint, "data", "u21"); + try expectPackedArmField( + arms.get("BG_COLOR_PALETTE").?.object, + "index", + @FieldType(Content, "color_palette"), + "data", + "GhosttyColorPaletteIndex", + ); + + const rgb = arms.get("BG_COLOR_RGB").?.object; + const Rgb = @FieldType(Content, "color_rgb"); + inline for (@typeInfo(Rgb).@"struct".fields) |field| + try expectPackedArmField(rgb, field.name, Rgb, field.name, "u8"); +} + +test "manifest packed cell layouts decode real values" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); + defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; + const cell_bits = manifest_types.get("GhosttyCell").?.object.get("bits").?.object; + + var codepoint = page.Cell.CLayout.Zig.init('A'); + try expectDecodedCellContent(manifest_types, cell_bits, @bitCast(codepoint), "codepoint", 'A'); + + codepoint.content_tag = .codepoint_grapheme; + try expectDecodedCellContent(manifest_types, cell_bits, @bitCast(codepoint), "codepoint", 'A'); + + var palette: page.Cell.CLayout.Zig = @bitCast(@as(u64, 0)); + palette.content_tag = .bg_color_palette; + palette.content = .{ .color_palette = .{ .data = 173 } }; + try expectDecodedCellContent(manifest_types, cell_bits, @bitCast(palette), "index", 173); + + var rgb: page.Cell.CLayout.Zig = @bitCast(@as(u64, 0)); + rgb.content_tag = .bg_color_rgb; + rgb.content = .{ .color_rgb = .{ .r = 0x12, .g = 0x34, .b = 0x56 } }; + const rgb_arm = activeCellContentArm(manifest_types, cell_bits, @bitCast(rgb)); + const content = extractManifestBits(@bitCast(rgb), cell_bits.get("content").?.object); + try std.testing.expectEqual(@as(u64, 0x12), extractManifestBits(content, rgb_arm.get("bits").?.object.get("r").?.object)); + try std.testing.expectEqual(@as(u64, 0x34), extractManifestBits(content, rgb_arm.get("bits").?.object.get("g").?.object)); + try std.testing.expectEqual(@as(u64, 0x56), extractManifestBits(content, rgb_arm.get("bits").?.object.get("b").?.object)); +} + test "manifest uses public enum names" { const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); defer parsed.deinit(); @@ -827,6 +1013,13 @@ test "manifest named references resolve" { var type_iterator = manifest_types.iterator(); while (type_iterator.next()) |type_entry| { const descriptor = type_entry.value_ptr.object; + if (descriptor.get("bits")) |bits_value| { + try expectManifestBitsValid( + manifest_types, + bits_value.object, + @intCast(descriptor.get("size").?.integer * 8), + ); + } const fields_value = descriptor.get("fields") orelse continue; var field_iterator = fields_value.object.iterator(); while (field_iterator.next()) |field_entry| { @@ -857,3 +1050,102 @@ test "manifest named references resolve" { } } } + +fn expectPackedArmField( + arm: std.json.ObjectMap, + manifest_name: []const u8, + comptime T: type, + comptime zig_name: []const u8, + expected_type: []const u8, +) !void { + try std.testing.expectEqualStrings("packed", arm.get("kind").?.string); + try std.testing.expectEqual(@as(i64, @bitSizeOf(T)), arm.get("width").?.integer); + const bit = arm.get("bits").?.object.get(manifest_name).?.object; + try std.testing.expectEqual(@as(i64, @bitOffsetOf(T, zig_name)), bit.get("lsb").?.integer); + try std.testing.expectEqual(@as(i64, @bitSizeOf(@FieldType(T, zig_name))), bit.get("width").?.integer); + try std.testing.expectEqualStrings(expected_type, bit.get("type").?.string); +} + +fn extractManifestBits(value: u64, bit: std.json.ObjectMap) u64 { + const lsb: u6 = @intCast(bit.get("lsb").?.integer); + const width: u7 = @intCast(bit.get("width").?.integer); + const mask = if (width == 64) + std.math.maxInt(u64) + else + (@as(u64, 1) << @intCast(width)) - 1; + return (value >> lsb) & mask; +} + +fn activeCellContentArm( + manifest_types: std.json.ObjectMap, + cell_bits: std.json.ObjectMap, + raw: u64, +) std.json.ObjectMap { + const content_tag = cell_bits.get("content_tag").?.object; + const value = extractManifestBits(raw, content_tag); + const enum_values = manifest_types.get(content_tag.get("type").?.string).?.object + .get("values").?.object; + var iterator = enum_values.iterator(); + while (iterator.next()) |entry| { + if (entry.value_ptr.integer == value) + return cell_bits.get("content").?.object.get("arms").?.object + .get(entry.key_ptr.*).?.object; + } + unreachable; +} + +fn expectDecodedCellContent( + manifest_types: std.json.ObjectMap, + cell_bits: std.json.ObjectMap, + raw: u64, + field_name: []const u8, + expected: u64, +) !void { + const arm = activeCellContentArm(manifest_types, cell_bits, raw); + const content = extractManifestBits(raw, cell_bits.get("content").?.object); + try std.testing.expectEqual( + expected, + extractManifestBits(content, arm.get("bits").?.object.get(field_name).?.object), + ); +} + +fn expectManifestBitsValid( + manifest_types: std.json.ObjectMap, + bits: std.json.ObjectMap, + container_width: usize, +) !void { + var iterator = bits.iterator(); + while (iterator.next()) |entry| { + const bit = entry.value_ptr.object; + const lsb: usize = @intCast(bit.get("lsb").?.integer); + const width: usize = @intCast(bit.get("width").?.integer); + try std.testing.expect(lsb + width <= container_width); + + if (bit.get("type")) |type_value| { + const type_name = type_value.string; + try std.testing.expect(Json.isBuiltinType(type_name) or manifest_types.contains(type_name)); + continue; + } + + const kind = bit.get("kind").?.string; + if (std.mem.eql(u8, kind, "packed")) { + try expectManifestBitsValid(manifest_types, bit.get("bits").?.object, width); + continue; + } + + try std.testing.expectEqualStrings("union", kind); + const tag_name = bit.get("tag").?.string; + const tag = bits.get(tag_name).?.object; + const enum_values = manifest_types.get(tag.get("type").?.string).?.object + .get("values").?.object; + var arm_iterator = bit.get("arms").?.object.iterator(); + while (arm_iterator.next()) |arm_entry| { + try std.testing.expect(enum_values.contains(arm_entry.key_ptr.*)); + if (arm_entry.value_ptr.* == .null) continue; + const arm = arm_entry.value_ptr.object; + const arm_width: usize = @intCast(arm.get("width").?.integer); + try std.testing.expect(arm_width <= width); + try expectManifestBitsValid(manifest_types, arm.get("bits").?.object, arm_width); + } + } +} diff --git a/src/terminal/page.zig b/src/terminal/page.zig index c2814ae98..d19860697 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1,6 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const build_options = @import("terminal_options"); +const lib = @import("../lib/main.zig"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const assert = @import("../quirks.zig").inlineAssert; @@ -2144,6 +2145,51 @@ pub const Cell = packed struct(u64) { prompt = 2, }; + /// Metadata for the C representation. All physical bit offsets and + /// widths are reflected from Cell. + pub const CLayout = lib.Packed(Cell, .{ .fields = .{ + .content_tag = .{ .type_name = "GhosttyCellContentTag" }, + .content = .{ .encoding = .{ .tagged_union = lib.PackedTaggedUnion( + Cell, + .content, + .content_tag, + .{ .arms = .{ + .codepoint = .{ .codepoint = lib.Packed( + @FieldType(@FieldType(Cell, "content"), "codepoint"), + .{ .fields = .{ + .data = .{ .name = "codepoint" }, + ._pad = .{ .omit = true }, + } }, + ) }, + .codepoint_grapheme = .{ .codepoint = lib.Packed( + @FieldType(@FieldType(Cell, "content"), "codepoint"), + .{ .fields = .{ + .data = .{ .name = "codepoint" }, + ._pad = .{ .omit = true }, + } }, + ) }, + .bg_color_palette = .{ .color_palette = lib.Packed( + @FieldType(@FieldType(Cell, "content"), "color_palette"), + .{ .fields = .{ + .data = .{ + .name = "index", + .type_name = "GhosttyColorPaletteIndex", + }, + ._pad = .{ .omit = true }, + } }, + ) }, + .bg_color_rgb = .{ .color_rgb = lib.Packed( + @FieldType(@FieldType(Cell, "content"), "color_rgb"), + .{}, + ) }, + } }, + ) } }, + .style_id = .{ .type_name = "GhosttyStyleId" }, + .wide = .{ .type_name = "GhosttyCellWide" }, + .semantic_content = .{ .type_name = "GhosttyCellSemanticContent" }, + ._padding = .{ .omit = true }, + } }); + /// The backing integer of this packed struct. Prefer this over /// hardcoding the integer type so that code is resilient to the /// size changing.