From b35f8ed16eeeb401045e139d8c658214aefd3248 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 18 Mar 2026 19:57:29 -0700 Subject: [PATCH] vt: expose render state colors in C API Add a C-facing GhosttyRenderStateColors sized struct and a ghostty_render_state_colors_get accessor so renderers can read background, foreground, cursor color state, and palette data directly from the render state. --- include/ghostty/vt.h | 1 + include/ghostty/vt/render.h | 58 +++++++++++++ src/lib/main.zig | 4 +- src/lib/struct.zig | 46 ++++++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/render.zig | 169 ++++++++++++++++++++++++++++++++++++ 7 files changed, 279 insertions(+), 1 deletion(-) diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 912d6e217..5059a0bef 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -100,6 +100,7 @@ extern "C" { #include #include +#include #include #include #include diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index 684da696f..79edfa689 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -8,6 +8,7 @@ #define GHOSTTY_VT_RENDER_H #include +#include #include #include @@ -65,6 +66,45 @@ typedef enum { GHOSTTY_RENDER_STATE_DIRTY_FULL = 2, } GhosttyRenderStateDirty; +/** + * Render-state color information. + * + * This struct uses the sized-struct ABI pattern. Initialize with + * GHOSTTY_INIT_SIZED(GhosttyRenderStateColors) before calling + * ghostty_render_state_colors_get(). + * + * Example: + * @code + * GhosttyRenderStateColors colors = GHOSTTY_INIT_SIZED(GhosttyRenderStateColors); + * GhosttyResult result = ghostty_render_state_colors_get(state, &colors); + * @endcode + * + * @ingroup render + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateColors). */ + size_t size; + + /** The default/current background color for the render state. */ + GhosttyColorRgb background; + + /** The default/current foreground color for the render state. */ + GhosttyColorRgb foreground; + + /** The cursor color when explicitly set by terminal state. */ + GhosttyColorRgb cursor; + + /** + * True when cursor contains a valid explicit cursor color value. + * If this is false, the cursor color should be ignored; it will + * contain undefined data. + * */ + bool cursor_has_value; + + /** The active 256-color palette for this render state. */ + GhosttyColorRgb palette[256]; +} GhosttyRenderStateColors; + /** * Create a new render state instance. * @@ -113,6 +153,24 @@ GhosttyResult ghostty_render_state_size_get(GhosttyRenderState state, uint16_t* out_cols, uint16_t* out_rows); +/** + * Get the current color information from a render state. + * + * This writes as many fields as fit in the caller-provided sized struct. + * `out_colors->size` must be set by the caller (typically via + * GHOSTTY_INIT_SIZED(GhosttyRenderStateColors)). + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_colors Sized output struct to receive render-state colors + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or + * `out_colors` is NULL, or if `out_colors->size` is smaller than + * `sizeof(size_t)` + * + * @ingroup render + */ +GhosttyResult ghostty_render_state_colors_get(GhosttyRenderState state, + GhosttyRenderStateColors* out_colors); + /** * Get the current dirty state of a render state. * diff --git a/src/lib/main.zig b/src/lib/main.zig index 89c6f6c47..05ebe9bd7 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 structpkg = @import("struct.zig"); const types = @import("types.zig"); const unionpkg = @import("union.zig"); @@ -7,7 +8,8 @@ pub const allocator = @import("allocator.zig"); pub const Enum = enumpkg.Enum; pub const checkGhosttyHEnum = enumpkg.checkGhosttyHEnum; pub const String = types.String; -pub const Struct = @import("struct.zig").Struct; +pub const Struct = structpkg.Struct; +pub const structSizedFieldFits = structpkg.sizedFieldFits; pub const Target = @import("target.zig").Target; pub const TaggedUnion = unionpkg.TaggedUnion; pub const cutPrefix = @import("string.zig").cutPrefix; diff --git a/src/lib/struct.zig b/src/lib/struct.zig index 134f6bebc..bbf07ece3 100644 --- a/src/lib/struct.zig +++ b/src/lib/struct.zig @@ -41,6 +41,52 @@ pub fn Struct( }; } +/// Returns true if a struct of type `T` with size `size` can set +/// field `field` (if it fits within the size). This is used for ABI +/// compatibility for structs that have an explicit size field. +pub fn sizedFieldFits( + comptime T: type, + size: usize, + comptime field: []const u8, +) bool { + const offset = @offsetOf(T, field); + const field_size = @sizeOf(@FieldType(T, field)); + return size >= offset + field_size; +} + +test "sizedFieldFits boundary checks" { + const Sized = extern struct { + size: usize, + a: u8, + b: u32, + }; + + const size_required = @offsetOf(Sized, "size") + @sizeOf(@FieldType(Sized, "size")); + const a_required = @offsetOf(Sized, "a") + @sizeOf(@FieldType(Sized, "a")); + const b_required = @offsetOf(Sized, "b") + @sizeOf(@FieldType(Sized, "b")); + + try testing.expect(sizedFieldFits(Sized, size_required, "size")); + try testing.expect(!sizedFieldFits(Sized, size_required - 1, "size")); + + try testing.expect(sizedFieldFits(Sized, a_required, "a")); + try testing.expect(!sizedFieldFits(Sized, a_required - 1, "a")); + + try testing.expect(sizedFieldFits(Sized, b_required, "b")); + try testing.expect(!sizedFieldFits(Sized, b_required - 1, "b")); +} + +test "sizedFieldFits respects alignment padding" { + const Sized = extern struct { + size: usize, + a: u8, + b: u32, + }; + + const up_to_padding = @offsetOf(Sized, "b"); + try testing.expect(sizedFieldFits(Sized, up_to_padding, "a")); + try testing.expect(!sizedFieldFits(Sized, up_to_padding, "b")); +} + test "packed struct converts to extern with full-size bools" { const Packed = packed struct { flag1: bool, diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 250635d4b..b0ed9d489 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -190,6 +190,7 @@ comptime { @export(&c.render_state_new, .{ .name = "ghostty_render_state_new" }); @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); @export(&c.render_state_size_get, .{ .name = "ghostty_render_state_size_get" }); + @export(&c.render_state_colors_get, .{ .name = "ghostty_render_state_colors_get" }); @export(&c.render_state_dirty_get, .{ .name = "ghostty_render_state_dirty_get" }); @export(&c.render_state_dirty_set, .{ .name = "ghostty_render_state_dirty_set" }); @export(&c.render_state_free, .{ .name = "ghostty_render_state_free" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index c1999ccea..a5dffe233 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -40,6 +40,7 @@ pub const render_state_new = render.new; pub const render_state_free = render.free; pub const render_state_update = render.update; pub const render_state_size_get = render.size_get; +pub const render_state_colors_get = render.colors_get; pub const render_state_dirty_get = render.dirty_get; pub const render_state_dirty_set = render.dirty_set; diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index 9104b4ac7..739d14a7f 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -1,7 +1,9 @@ const std = @import("std"); const testing = std.testing; +const lib = @import("../../lib/main.zig"); const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; +const colorpkg = @import("../color.zig"); const size = @import("../size.zig"); const terminal_c = @import("terminal.zig"); const renderpkg = @import("../render.zig"); @@ -18,6 +20,16 @@ pub const RenderState = ?*RenderStateWrapper; /// C: GhosttyRenderStateDirty pub const Dirty = renderpkg.RenderState.Dirty; +/// C: GhosttyRenderStateColors +pub const Colors = extern struct { + size: usize = @sizeOf(Colors), + background: colorpkg.RGB.C, + foreground: colorpkg.RGB.C, + cursor: colorpkg.RGB.C, + cursor_has_value: bool, + palette: [256]colorpkg.RGB.C, +}; + pub fn new( alloc_: ?*const CAllocator, result: *RenderState, @@ -65,6 +77,68 @@ pub fn size_get( return .success; } +pub fn colors_get( + state_: RenderState, + out_colors_: ?*Colors, +) callconv(.c) Result { + const state = state_ orelse return .invalid_value; + const out_colors = out_colors_ orelse return .invalid_value; + const out_size = out_colors.size; + if (out_size < @sizeOf(usize)) return .invalid_value; + + const colors = state.state.colors; + if (lib.structSizedFieldFits( + Colors, + out_size, + "background", + )) { + out_colors.background = colors.background.cval(); + } + + if (lib.structSizedFieldFits( + Colors, + out_size, + "foreground", + )) { + out_colors.foreground = colors.foreground.cval(); + } + + if (colors.cursor) |cursor| { + if (lib.structSizedFieldFits( + Colors, + out_size, + "cursor", + )) { + out_colors.cursor = cursor.cval(); + } + } + + if (lib.structSizedFieldFits( + Colors, + out_size, + "cursor_has_value", + )) { + out_colors.cursor_has_value = colors.cursor != null; + } + + if (lib.structSizedFieldFits( + Colors, + out_size, + "palette", + )) { + const palette_offset = @offsetOf(Colors, "palette"); + if (out_size > palette_offset) { + const available = out_size - palette_offset; + const max_entries = @min(colors.palette.len, available / @sizeOf(colorpkg.RGB.C)); + for (0..max_entries) |i| { + out_colors.palette[i] = colors.palette[i].cval(); + } + } + } + + return .success; +} + pub fn dirty_get( state_: RenderState, out_dirty: *Dirty, @@ -145,6 +219,24 @@ test "render: size get invalid value" { )); } +test "render: colors get invalid value" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &state, + )); + defer free(state); + + var colors: Colors = std.mem.zeroes(Colors); + colors.size = @sizeOf(Colors); + + try testing.expectEqual(Result.invalid_value, colors_get(null, &colors)); + try testing.expectEqual(Result.invalid_value, colors_get(state, null)); + + colors.size = @sizeOf(usize) - 1; + try testing.expectEqual(Result.invalid_value, colors_get(state, &colors)); +} + test "render: dirty get/set invalid value" { var state: RenderState = null; try testing.expectEqual(Result.success, new( @@ -234,3 +326,80 @@ test "render: update" { try testing.expectEqual(@as(size.CellCountInt, 80), cols); try testing.expectEqual(@as(size.CellCountInt, 24), rows); } + +test "render: colors get" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var colors: Colors = std.mem.zeroes(Colors); + colors.size = @sizeOf(Colors); + try testing.expectEqual(Result.success, colors_get(state, &colors)); + + const state_colors = &state.?.state.colors; + try testing.expectEqual(state_colors.background.cval(), colors.background); + try testing.expectEqual(state_colors.foreground.cval(), colors.foreground); + + if (state_colors.cursor) |cursor| { + try testing.expect(colors.cursor_has_value); + try testing.expectEqual(cursor.cval(), colors.cursor); + } else { + try testing.expect(!colors.cursor_has_value); + } + + for (state_colors.palette, colors.palette) |expected, actual| { + try testing.expectEqual(expected.cval(), actual); + } +} + +test "render: colors get supports truncated sized struct" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var colors: Colors = std.mem.zeroes(Colors); + const sentinel: colorpkg.RGB.C = .{ .r = 0xAA, .g = 0xBB, .b = 0xCC }; + for (&colors.palette) |*entry| entry.* = sentinel; + + colors.size = @offsetOf(Colors, "palette") + @sizeOf(colorpkg.RGB.C) * 2; + try testing.expectEqual(Result.success, colors_get(state, &colors)); + + const state_colors = &state.?.state.colors; + try testing.expectEqual(state_colors.palette[0].cval(), colors.palette[0]); + try testing.expectEqual(state_colors.palette[1].cval(), colors.palette[1]); + try testing.expectEqual(sentinel, colors.palette[2]); +}