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.
pull/11664/head
Mitchell Hashimoto 2026-03-18 19:57:29 -07:00
parent b830a0ee1d
commit b35f8ed16e
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
7 changed files with 279 additions and 1 deletions

View File

@ -100,6 +100,7 @@ extern "C" {
#include <ghostty/vt/types.h>
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/color.h>
#include <ghostty/vt/focus.h>
#include <ghostty/vt/formatter.h>
#include <ghostty/vt/render.h>

View File

@ -8,6 +8,7 @@
#define GHOSTTY_VT_RENDER_H
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/color.h>
#include <ghostty/vt/terminal.h>
#include <ghostty/vt/types.h>
@ -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.
*

View File

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

View File

@ -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,

View File

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

View File

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

View File

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