libghostty: add render state structured cursor read (#13851)

A normal renderer would have to call `ghostty_render_state_get` _eight
times_ to reconstruct the cursor. In languages where FFI is expensive
(Go, wasm, etc.), this showed up in profiles of every frame.

Add a sized cursor snapshot and expose it. Also expose the existing
color snapshot through ghostty_render_state_get and remove the older
dedicated color getter.

Found during my normal Go/wasm adventures.
This commit is contained in:
Mitchell Hashimoto
2026-08-15 21:08:27 -07:00
committed by GitHub
6 changed files with 324 additions and 65 deletions

View File

@@ -97,7 +97,8 @@ int main(void) {
// state. These are needed to resolve palette-indexed cell colors.
GhosttyRenderStateColors colors =
GHOSTTY_INIT_SIZED(GhosttyRenderStateColors);
result = ghostty_render_state_colors_get(render_state, &colors);
result = ghostty_render_state_get(
render_state, GHOSTTY_RENDER_STATE_DATA_COLORS, &colors);
assert(result == GHOSTTY_SUCCESS);
printf("Background: #%02x%02x%02x\n",
@@ -107,31 +108,16 @@ int main(void) {
//! [render-colors]
//! [render-cursor]
// Read cursor position and visual style from the render state.
bool cursor_visible = false;
ghostty_render_state_get(
render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VISIBLE,
&cursor_visible);
bool cursor_in_viewport = false;
ghostty_render_state_get(
render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE,
&cursor_in_viewport);
if (cursor_visible && cursor_in_viewport) {
uint16_t cx, cy;
ghostty_render_state_get(
render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_X, &cx);
ghostty_render_state_get(
render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y, &cy);
GhosttyRenderStateCursorVisualStyle style;
ghostty_render_state_get(
render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VISUAL_STYLE,
&style);
// Read all cursor state in one call.
GhosttyRenderStateCursor cursor =
GHOSTTY_INIT_SIZED(GhosttyRenderStateCursor);
result = ghostty_render_state_get(
render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR, &cursor);
assert(result == GHOSTTY_SUCCESS);
if (cursor.visible && cursor.viewport_has_value) {
const char* style_name = "unknown";
switch (style) {
switch (cursor.visual_style) {
case GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BAR:
style_name = "bar";
break;
@@ -145,7 +131,8 @@ int main(void) {
style_name = "hollow";
break;
}
printf("Cursor at (%u, %u), style: %s\n", cx, cy, style_name);
printf("Cursor at (%u, %u), style: %s\n",
cursor.viewport_x, cursor.viewport_y, style_name);
}
//! [render-cursor]

View File

@@ -199,6 +199,14 @@ typedef enum GHOSTTY_ENUM_TYPED {
/** Whether the cursor is on the tail of a wide character (bool).
* Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */
GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_WIDE_TAIL = 17,
/** All cursor state in one sized struct (GhosttyRenderStateCursor).
* Initialize the output with GHOSTTY_INIT_SIZED before querying. */
GHOSTTY_RENDER_STATE_DATA_CURSOR = 18,
/** All render-state colors in one sized struct (GhosttyRenderStateColors).
* Initialize the output with GHOSTTY_INIT_SIZED before querying. */
GHOSTTY_RENDER_STATE_DATA_COLORS = 19,
GHOSTTY_RENDER_STATE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyRenderStateData;
@@ -286,17 +294,59 @@ typedef struct {
uint16_t end_x;
} GhosttyRenderStateRowSelection;
/**
* Render-state cursor information.
*
* This struct uses the sized-struct ABI pattern. Initialize with
* GHOSTTY_INIT_SIZED(GhosttyRenderStateCursor) before querying
* GHOSTTY_RENDER_STATE_DATA_CURSOR.
*
* When viewport_has_value is false, viewport_x, viewport_y, and wide_tail
* contain undefined data and must not be read.
*
* @ingroup render
*/
typedef struct {
/** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateCursor). */
size_t size;
/** Whether the cursor is visible within the viewport. */
bool viewport_has_value;
/** Cursor viewport x position in cells. */
uint16_t viewport_x;
/** Cursor viewport y position in cells. */
uint16_t viewport_y;
/** Whether the cursor is on the tail of a wide character. */
bool wide_tail;
/** Whether the cursor is visible based on terminal modes. */
bool visible;
/** Whether the cursor should blink based on terminal modes. */
bool blinking;
/** Whether the cursor is at a password input field. */
bool password_input;
/** The visual style of the cursor. */
GhosttyRenderStateCursorVisualStyle visual_style;
} GhosttyRenderStateCursor;
/**
* 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().
* GHOSTTY_INIT_SIZED(GhosttyRenderStateColors) before querying
* GHOSTTY_RENDER_STATE_DATA_COLORS.
*
* Example:
* @code
* GhosttyRenderStateColors colors = GHOSTTY_INIT_SIZED(GhosttyRenderStateColors);
* GhosttyResult result = ghostty_render_state_colors_get(state, &colors);
* GhosttyResult result = ghostty_render_state_get(
* state, GHOSTTY_RENDER_STATE_DATA_COLORS, &colors);
* @endcode
*
* @ingroup render
@@ -430,8 +480,9 @@ GHOSTTY_API GhosttyResult ghostty_render_state_end_update(GhosttyRenderState sta
* @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param data The data kind to query
* @param[out] out Pointer to receive the queried value
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` is
* NULL or `data` is not a recognized enum value
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or
* `out` is NULL, `data` is not a recognized enum value, or a sized
* output struct is smaller than `sizeof(size_t)`
*
* @ingroup render
*/
@@ -486,24 +537,6 @@ GHOSTTY_API GhosttyResult ghostty_render_state_set(GhosttyRenderState state,
GhosttyRenderStateOption option,
const void* value);
/**
* 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
*/
GHOSTTY_API GhosttyResult ghostty_render_state_colors_get(GhosttyRenderState state,
GhosttyRenderStateColors* out_colors);
/**
* Create a new row iterator instance.
*

View File

@@ -286,7 +286,6 @@ comptime {
@export(&c.render_state_get, .{ .name = "ghostty_render_state_get" });
@export(&c.render_state_get_multi, .{ .name = "ghostty_render_state_get_multi" });
@export(&c.render_state_set, .{ .name = "ghostty_render_state_set" });
@export(&c.render_state_colors_get, .{ .name = "ghostty_render_state_colors_get" });
@export(&c.render_state_row_iterator_new, .{ .name = "ghostty_render_state_row_iterator_new" });
@export(&c.render_state_row_iterator_next, .{ .name = "ghostty_render_state_row_iterator_next" });
@export(&c.render_state_row_get, .{ .name = "ghostty_render_state_row_get" });

View File

@@ -90,7 +90,6 @@ pub const render_state_end_update = render.end_update;
pub const render_state_get = render.get;
pub const render_state_get_multi = render.get_multi;
pub const render_state_set = render.set;
pub const render_state_colors_get = render.colors_get;
pub const render_state_row_iterator_new = render.row_iterator_new;
pub const render_state_row_iterator_next = render.row_iterator_next;
pub const render_state_row_get = render.row_get;

View File

@@ -100,6 +100,19 @@ pub const CursorVisualStyle = enum(c_int) {
}
};
/// C: GhosttyRenderStateCursor
pub const Cursor = extern struct {
size: usize = @sizeOf(Cursor),
viewport_has_value: bool,
viewport_x: u16,
viewport_y: u16,
wide_tail: bool,
visible: bool,
blinking: bool,
password_input: bool,
visual_style: CursorVisualStyle,
};
/// C: GhosttyRenderStateData
pub const Data = enum(c_int) {
invalid = 0,
@@ -120,6 +133,8 @@ pub const Data = enum(c_int) {
cursor_viewport_x = 15,
cursor_viewport_y = 16,
cursor_viewport_wide_tail = 17,
cursor = 18,
colors = 19,
/// Output type expected for querying the data of the given kind.
pub fn OutType(comptime self: Data) type {
@@ -135,6 +150,8 @@ pub const Data = enum(c_int) {
.cursor_visible, .cursor_blinking, .cursor_password_input => bool,
.cursor_viewport_has_value, .cursor_viewport_wide_tail => bool,
.cursor_viewport_x, .cursor_viewport_y => size.CellCountInt,
.cursor => Cursor,
.colors => Colors,
};
}
};
@@ -270,12 +287,13 @@ inline fn getDispatch(
};
}
const out_ptr = out orelse return .invalid_value;
return switch (data) {
.invalid => .invalid_value,
inline else => |comptime_data| getTyped(
state,
comptime_data,
@ptrCast(@alignCast(out)),
@ptrCast(@alignCast(out_ptr)),
),
};
}
@@ -328,6 +346,8 @@ fn getTyped(
const vp = state.state.cursor.viewport orelse return .invalid_value;
out.* = vp.wide_tail;
},
.cursor => return writeCursor(state, out),
.colors => return writeColors(state, out),
}
return .success;
@@ -367,12 +387,87 @@ fn setTyped(
return .success;
}
pub fn colors_get(
state_: RenderState,
out_colors_: ?*Colors,
) callconv(lib.calling_conv) Result {
const state = state_ orelse return .invalid_value;
const out_colors = out_colors_ orelse return .invalid_value;
fn writeCursor(
state: *RenderStateWrapper,
out_cursor: *Cursor,
) Result {
const out_size = out_cursor.size;
if (out_size < @sizeOf(usize)) return .invalid_value;
const cursor = &state.state.cursor;
if (lib.structSizedFieldFits(
Cursor,
out_size,
"viewport_has_value",
)) {
out_cursor.viewport_has_value = cursor.viewport != null;
}
if (cursor.viewport) |viewport| {
if (lib.structSizedFieldFits(
Cursor,
out_size,
"viewport_x",
)) {
out_cursor.viewport_x = viewport.x;
}
if (lib.structSizedFieldFits(
Cursor,
out_size,
"viewport_y",
)) {
out_cursor.viewport_y = viewport.y;
}
if (lib.structSizedFieldFits(
Cursor,
out_size,
"wide_tail",
)) {
out_cursor.wide_tail = viewport.wide_tail;
}
}
if (lib.structSizedFieldFits(
Cursor,
out_size,
"visible",
)) {
out_cursor.visible = cursor.visible;
}
if (lib.structSizedFieldFits(
Cursor,
out_size,
"blinking",
)) {
out_cursor.blinking = cursor.blinking;
}
if (lib.structSizedFieldFits(
Cursor,
out_size,
"password_input",
)) {
out_cursor.password_input = cursor.password_input;
}
if (lib.structSizedFieldFits(
Cursor,
out_size,
"visual_style",
)) {
out_cursor.visual_style = CursorVisualStyle.fromCursorStyle(cursor.visual_style);
}
return .success;
}
fn writeColors(
state: *RenderStateWrapper,
out_colors: *Colors,
) Result {
const out_size = out_colors.size;
if (out_size < @sizeOf(usize)) return .invalid_value;
@@ -997,6 +1092,14 @@ test "render: begin/end update" {
test "render: get invalid value" {
var cols: size.CellCountInt = 0;
try testing.expectEqual(Result.invalid_value, get(null, .cols, @ptrCast(&cols)));
var state: RenderState = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&state,
));
defer free(state);
try testing.expectEqual(Result.invalid_value, get(state, .cols, null));
}
test "render: get invalid data" {
@@ -1010,7 +1113,7 @@ test "render: get invalid data" {
try testing.expectEqual(Result.invalid_value, get(state, .invalid, null));
}
test "render: colors get invalid value" {
test "render: aggregate get invalid value" {
var state: RenderState = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
@@ -1021,11 +1124,20 @@ test "render: colors get invalid value" {
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));
try testing.expectEqual(Result.invalid_value, get(null, .colors, &colors));
try testing.expectEqual(Result.invalid_value, get(state, .colors, null));
colors.size = @sizeOf(usize) - 1;
try testing.expectEqual(Result.invalid_value, colors_get(state, &colors));
try testing.expectEqual(Result.invalid_value, get(state, .colors, &colors));
var cursor: Cursor = std.mem.zeroes(Cursor);
cursor.size = @sizeOf(Cursor);
try testing.expectEqual(Result.invalid_value, get(null, .cursor, &cursor));
try testing.expectEqual(Result.invalid_value, get(state, .cursor, null));
cursor.size = @sizeOf(usize) - 1;
try testing.expectEqual(Result.invalid_value, get(state, .cursor, &cursor));
}
test "render: get/set dirty invalid value" {
@@ -1738,7 +1850,7 @@ test "render: update" {
try testing.expectEqual(@as(size.CellCountInt, 24), rows_val);
}
test "render: colors get" {
test "render: colors data get" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
@@ -1759,7 +1871,7 @@ test "render: colors get" {
var colors: Colors = std.mem.zeroes(Colors);
colors.size = @sizeOf(Colors);
try testing.expectEqual(Result.success, colors_get(state, &colors));
try testing.expectEqual(Result.success, get(state, .colors, &colors));
const state_colors = &state.?.state.colors;
try testing.expectEqual(state_colors.background.cval(), colors.background);
@@ -1777,6 +1889,121 @@ test "render: colors get" {
}
}
test "render: cursor data get matches scalar getters" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
80,
24,
));
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 cursor: Cursor = std.mem.zeroes(Cursor);
cursor.size = @sizeOf(Cursor);
try testing.expectEqual(Result.success, get(state, .cursor, &cursor));
var viewport_has_value: bool = undefined;
var viewport_x: u16 = undefined;
var viewport_y: u16 = undefined;
var wide_tail: bool = undefined;
var visible: bool = undefined;
var blinking: bool = undefined;
var password_input: bool = undefined;
var visual_style: CursorVisualStyle = undefined;
try testing.expectEqual(Result.success, get(state, .cursor_viewport_has_value, &viewport_has_value));
try testing.expectEqual(Result.success, get(state, .cursor_viewport_x, &viewport_x));
try testing.expectEqual(Result.success, get(state, .cursor_viewport_y, &viewport_y));
try testing.expectEqual(Result.success, get(state, .cursor_viewport_wide_tail, &wide_tail));
try testing.expectEqual(Result.success, get(state, .cursor_visible, &visible));
try testing.expectEqual(Result.success, get(state, .cursor_blinking, &blinking));
try testing.expectEqual(Result.success, get(state, .cursor_password_input, &password_input));
try testing.expectEqual(Result.success, get(state, .cursor_visual_style, &visual_style));
try testing.expectEqual(viewport_has_value, cursor.viewport_has_value);
try testing.expectEqual(viewport_x, cursor.viewport_x);
try testing.expectEqual(viewport_y, cursor.viewport_y);
try testing.expectEqual(wide_tail, cursor.wide_tail);
try testing.expectEqual(visible, cursor.visible);
try testing.expectEqual(blinking, cursor.blinking);
try testing.expectEqual(password_input, cursor.password_input);
try testing.expectEqual(visual_style, cursor.visual_style);
}
test "render: cursor data get without viewport" {
var state: RenderState = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&state,
));
defer free(state);
state.?.state.cursor.viewport = null;
state.?.state.cursor.visible = true;
state.?.state.cursor.blinking = true;
state.?.state.cursor.password_input = true;
state.?.state.cursor.visual_style = .underline;
var cursor: Cursor = std.mem.zeroes(Cursor);
cursor.size = @sizeOf(Cursor);
cursor.viewport_x = 0xAAAA;
cursor.viewport_y = 0xBBBB;
cursor.wide_tail = true;
try testing.expectEqual(Result.success, get(state, .cursor, &cursor));
try testing.expect(!cursor.viewport_has_value);
try testing.expectEqual(@as(u16, 0xAAAA), cursor.viewport_x);
try testing.expectEqual(@as(u16, 0xBBBB), cursor.viewport_y);
try testing.expect(cursor.wide_tail);
try testing.expect(cursor.visible);
try testing.expect(cursor.blinking);
try testing.expect(cursor.password_input);
try testing.expectEqual(CursorVisualStyle.underline, cursor.visual_style);
}
test "render: cursor data get supports truncated sized struct" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
80,
24,
));
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));
const expected = state.?.state.cursor;
const viewport = expected.viewport.?;
var cursor: Cursor = std.mem.zeroes(Cursor);
cursor.size = @offsetOf(Cursor, "viewport_y") + @sizeOf(u16);
cursor.wide_tail = !viewport.wide_tail;
cursor.visible = !expected.visible;
try testing.expectEqual(Result.success, get(state, .cursor, &cursor));
try testing.expect(cursor.viewport_has_value);
try testing.expectEqual(viewport.x, cursor.viewport_x);
try testing.expectEqual(viewport.y, cursor.viewport_y);
try testing.expectEqual(!viewport.wide_tail, cursor.wide_tail);
try testing.expectEqual(!expected.visible, cursor.visible);
}
test "render: row cells bg_color no background" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
@@ -2020,7 +2247,7 @@ test "render: row cells fg_color from style" {
try testing.expectEqual(@as(u8, 30), fg.b);
}
test "render: colors get supports truncated sized struct" {
test "render: colors data get supports truncated sized struct" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
@@ -2044,7 +2271,7 @@ test "render: colors get supports truncated sized struct" {
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));
try testing.expectEqual(Result.success, get(state, .colors, &colors));
const state_colors = &state.?.state.colors;
try testing.expectEqual(state_colors.palette[0].cval(), colors.palette[0]);

View File

@@ -70,6 +70,7 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: {
.{ "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) },
@@ -257,6 +258,7 @@ test "json parses" {
"GhosttyPointCoordinate",
"GhosttyReader",
"GhosttyRenderStateColors",
"GhosttyRenderStateCursor",
"GhosttyRenderStateRowSelection",
"GhosttySelection",
"GhosttySelectionGestureBehaviors",
@@ -309,6 +311,18 @@ test "json parses" {
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
.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"));