From f168b3c098eae1db30811173ac7cc5d5ac4da3c2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Mar 2026 11:33:26 -0700 Subject: [PATCH 01/10] vt: add ghostty_terminal_get for reading terminal state Add a typed data query API to the terminal C interface, following the same OutType pattern used by the OSC command data API. The new ghostty_terminal_get function takes a GhosttyTerminalData tag and an output pointer, returning GhosttyResult. Currently exposes cols, rows, cursor x/y position, and cursor pending wrap state. The GhosttyTerminalData enum is placed with the other types in the header (before functions) per the ordering convention. --- include/ghostty/vt/terminal.h | 137 +++++++++++++++++++++- src/lib_vt.zig | 1 + src/terminal/ScreenSet.zig | 12 +- src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 211 ++++++++++++++++++++++++++++++++++ 5 files changed, 356 insertions(+), 6 deletions(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 5be6db001..88fac14ef 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -97,6 +97,119 @@ typedef struct { GhosttyTerminalScrollViewportValue value; } GhosttyTerminalScrollViewport; +/** + * Terminal screen identifier. + * + * Identifies which screen buffer is active in the terminal. + * + * @ingroup terminal + */ +typedef enum { + /** The primary (normal) screen. */ + GHOSTTY_TERMINAL_SCREEN_PRIMARY = 0, + + /** The alternate screen. */ + GHOSTTY_TERMINAL_SCREEN_ALTERNATE = 1, +} GhosttyTerminalScreen; + +/** + * Scrollbar state for the terminal viewport. + * + * Represents the scrollable area dimensions needed to render a scrollbar. + * + * @ingroup terminal + */ +typedef struct { + /** Total size of the scrollable area in rows. */ + uint64_t total; + + /** Offset into the total area that the viewport is at. */ + uint64_t offset; + + /** Length of the visible area in rows. */ + uint64_t len; +} GhosttyTerminalScrollbar; + +/** + * Terminal data types. + * + * These values specify what type of data to extract from a terminal + * using `ghostty_terminal_get`. + * + * @ingroup terminal + */ +typedef enum { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_TERMINAL_DATA_INVALID = 0, + + /** + * Terminal width in cells. + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_COLS = 1, + + /** + * Terminal height in cells. + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_ROWS = 2, + + /** + * Cursor column position (0-indexed). + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_X = 3, + + /** + * Cursor row position within the active area (0-indexed). + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_Y = 4, + + /** + * Whether the cursor has a pending wrap (next print will soft-wrap). + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_PENDING_WRAP = 5, + + /** + * The currently active screen. + * + * Output type: GhosttyTerminalScreen * + */ + GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN = 6, + + /** + * Whether the cursor is visible (DEC mode 25). + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE = 7, + + /** + * Current Kitty keyboard protocol flags. + * + * Output type: GhosttyKittyKeyFlags * (uint8_t *) + */ + GHOSTTY_TERMINAL_DATA_KITTY_KEYBOARD_FLAGS = 8, + + /** + * Scrollbar state for the terminal viewport. + * + * This may be expensive to calculate depending on where the viewport + * is (arbitrary pins are expensive). The caller should take care to only + * call this as needed and not too frequently. + * + * Output type: GhosttyTerminalScrollbar * + */ + GHOSTTY_TERMINAL_DATA_SCROLLBAR = 9, +} GhosttyTerminalData; + /** * Create a new terminal instance. * @@ -228,8 +341,28 @@ GhosttyResult ghostty_terminal_mode_get(GhosttyTerminal terminal, * @ingroup terminal */ GhosttyResult ghostty_terminal_mode_set(GhosttyTerminal terminal, - GhosttyMode mode, - bool value); + GhosttyMode mode, + bool value); + +/** + * Get data from a terminal instance. + * + * Extracts typed data from the given terminal based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid data types and output types are documented + * in the `GhosttyTerminalData` enum. + * + * @param terminal The terminal handle (may be NULL) + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * is NULL or the data type is invalid + * + * @ingroup terminal + */ +GhosttyResult ghostty_terminal_get(GhosttyTerminal terminal, + GhosttyTerminalData data, + void *out); /** @} */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index aabd78592..d6416d97a 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -191,6 +191,7 @@ comptime { @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); @export(&c.terminal_mode_get, .{ .name = "ghostty_terminal_mode_get" }); @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); + @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/ScreenSet.zig b/src/terminal/ScreenSet.zig index cbaa03f47..6fe866f70 100644 --- a/src/terminal/ScreenSet.zig +++ b/src/terminal/ScreenSet.zig @@ -9,15 +9,19 @@ const ScreenSet = @This(); const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; +const build_options = @import("terminal_options"); +const lib = @import("../lib/main.zig"); const testing = std.testing; const Allocator = std.mem.Allocator; const Screen = @import("Screen.zig"); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; + /// The possible keys for screens in the screen set. -pub const Key = enum(u1) { - primary, - alternate, -}; +pub const Key = lib.Enum(lib_target, &.{ + "primary", + "alternate", +}); /// The key value of the currently active screen. Useful for simple /// comparisons, e.g. "is this screen the primary screen". diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 72fa74345..d591dcb89 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -98,6 +98,7 @@ pub const terminal_vt_write = terminal.vt_write; pub const terminal_scroll_viewport = terminal.scroll_viewport; pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; +pub const terminal_get = terminal.get; test { _ = color; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index c3c084c52..3c771f16e 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -3,10 +3,15 @@ const testing = std.testing; const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; const ZigTerminal = @import("../Terminal.zig"); +const ScreenSet = @import("../ScreenSet.zig"); +const PageList = @import("../PageList.zig"); +const kitty = @import("../kitty/key.zig"); const modes = @import("../modes.zig"); const size = @import("../size.zig"); const Result = @import("result.zig").Result; +const log = std.log.scoped(.terminal_c); + /// C: GhosttyTerminal pub const Terminal = ?*ZigTerminal; @@ -123,6 +128,81 @@ pub fn mode_set( return .success; } +/// C: GhosttyTerminalScreen +pub const TerminalScreen = ScreenSet.Key; + +/// C: GhosttyTerminalScrollbar +pub const TerminalScrollbar = PageList.Scrollbar.C; + +/// C: GhosttyTerminalData +pub const TerminalData = enum(c_int) { + invalid = 0, + cols = 1, + rows = 2, + cursor_x = 3, + cursor_y = 4, + cursor_pending_wrap = 5, + active_screen = 6, + cursor_visible = 7, + kitty_keyboard_flags = 8, + scrollbar = 9, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: TerminalData) type { + return switch (self) { + .invalid => void, + .cols, .rows, .cursor_x, .cursor_y => size.CellCountInt, + .cursor_pending_wrap, .cursor_visible => bool, + .active_screen => TerminalScreen, + .kitty_keyboard_flags => u8, + .scrollbar => TerminalScrollbar, + }; + } +}; + +pub fn get( + terminal_: Terminal, + data: TerminalData, + out: ?*anyopaque, +) callconv(.c) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(TerminalData, @intFromEnum(data)) catch { + log.warn("terminal_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + return switch (data) { + inline else => |comptime_data| getTyped( + terminal_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + terminal_: Terminal, + comptime data: TerminalData, + out: *data.OutType(), +) Result { + const t = terminal_ orelse return .invalid_value; + switch (data) { + .invalid => return .invalid_value, + .cols => out.* = t.cols, + .rows => out.* = t.rows, + .cursor_x => out.* = t.screens.active.cursor.x, + .cursor_y => out.* = t.screens.active.cursor.y, + .cursor_pending_wrap => out.* = t.screens.active.cursor.pending_wrap, + .active_screen => out.* = t.screens.active_key, + .cursor_visible => out.* = t.modes.get(.cursor_visible), + .kitty_keyboard_flags => out.* = @as(u8, t.screens.active.kitty_keyboard.current().int()), + .scrollbar => out.* = t.screens.active.pages.scrollbar().cval(), + } + + return .success; +} + pub fn free(terminal_: Terminal) callconv(.c) void { const t = terminal_ orelse return; @@ -397,3 +477,134 @@ test "vt_write" { defer testing.allocator.free(str); try testing.expectEqualStrings("Hello", str); } + +test "get cols and rows" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var cols: size.CellCountInt = undefined; + var rows: size.CellCountInt = undefined; + try testing.expectEqual(Result.success, get(t, .cols, @ptrCast(&cols))); + try testing.expectEqual(Result.success, get(t, .rows, @ptrCast(&rows))); + try testing.expectEqual(80, cols); + try testing.expectEqual(24, rows); +} + +test "get cursor position" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + var x: size.CellCountInt = undefined; + var y: size.CellCountInt = undefined; + try testing.expectEqual(Result.success, get(t, .cursor_x, @ptrCast(&x))); + try testing.expectEqual(Result.success, get(t, .cursor_y, @ptrCast(&y))); + try testing.expectEqual(5, x); + try testing.expectEqual(0, y); +} + +test "get null" { + var cols: size.CellCountInt = undefined; + try testing.expectEqual(Result.invalid_value, get(null, .cols, @ptrCast(&cols))); +} + +test "get cursor_visible" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var visible: bool = undefined; + try testing.expectEqual(Result.success, get(t, .cursor_visible, @ptrCast(&visible))); + try testing.expect(visible); + + // DEC mode 25 controls cursor visibility + const cursor_visible_mode: modes.ModeTag.Backing = @bitCast(modes.ModeTag{ .value = 25, .ansi = false }); + try testing.expectEqual(Result.success, mode_set(t, cursor_visible_mode, false)); + try testing.expectEqual(Result.success, get(t, .cursor_visible, @ptrCast(&visible))); + try testing.expect(!visible); +} + +test "get active_screen" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var screen: TerminalScreen = undefined; + try testing.expectEqual(Result.success, get(t, .active_screen, @ptrCast(&screen))); + try testing.expectEqual(.primary, screen); +} + +test "get kitty_keyboard_flags" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var flags: u8 = undefined; + try testing.expectEqual(Result.success, get(t, .kitty_keyboard_flags, @ptrCast(&flags))); + try testing.expectEqual(0, flags); + + // Push kitty flags via VT sequence: CSI > 3 u (push disambiguate | report_events) + vt_write(t, "\x1b[>3u", 5); + + try testing.expectEqual(Result.success, get(t, .kitty_keyboard_flags, @ptrCast(&flags))); + try testing.expectEqual(3, flags); +} + +test "get invalid" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + try testing.expectEqual(Result.invalid_value, get(t, .invalid, null)); +} From 7f36e8bd43bdc52aa3398125ce8c42e5211adceb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Mar 2026 11:52:34 -0700 Subject: [PATCH 02/10] vt: add style C API Expose the terminal Style struct to the C API as GhosttyStyle, a sized struct with foreground, background, and underline colors (as tagged unions) plus boolean text decoration flags. Add ghostty_style_default() to obtain the default style and ghostty_style_is_default() to check whether a style has all default values. Wire both through c/style.zig, main.zig, and lib_vt.zig with the corresponding header in vt/style.h. --- include/ghostty/vt.h | 1 + include/ghostty/vt/style.h | 128 +++++++++++++++++++++++++++++++++++ src/lib_vt.zig | 2 + src/terminal/c/main.zig | 5 ++ src/terminal/c/style.zig | 133 +++++++++++++++++++++++++++++++++++++ 5 files changed, 269 insertions(+) create mode 100644 include/ghostty/vt/style.h create mode 100644 src/terminal/c/style.zig diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index c6ab35fa3..effafd0f0 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -98,6 +98,7 @@ extern "C" { #include #include #include +#include #include #include #include diff --git a/include/ghostty/vt/style.h b/include/ghostty/vt/style.h new file mode 100644 index 000000000..5c326ae19 --- /dev/null +++ b/include/ghostty/vt/style.h @@ -0,0 +1,128 @@ +/** + * @file style.h + * + * Terminal cell style types. + */ + +#ifndef GHOSTTY_VT_STYLE_H +#define GHOSTTY_VT_STYLE_H + +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup style Style + * + * Terminal cell style attributes. + * + * A style describes the visual attributes of a terminal cell, including + * foreground, background, and underline colors, as well as flags for + * bold, italic, underline, and other text decorations. + * + * @{ + */ + +/** + * Style color tags. + * + * These values identify the type of color in a style color. + * Use the tag to determine which field in the color value union to access. + * + * @ingroup style + */ +typedef enum { + GHOSTTY_STYLE_COLOR_NONE = 0, + GHOSTTY_STYLE_COLOR_PALETTE = 1, + GHOSTTY_STYLE_COLOR_RGB = 2, +} GhosttyStyleColorTag; + +/** + * Style color value union. + * + * Use the tag to determine which field is active. + * + * @ingroup style + */ +typedef union { + GhosttyColorPaletteIndex palette; + GhosttyColorRgb rgb; + uint32_t _padding; +} GhosttyStyleColorValue; + +/** + * Style color (tagged union). + * + * A color used in a style attribute. Can be unset (none), a palette + * index, or a direct RGB value. + * + * @ingroup style + */ +typedef struct { + GhosttyStyleColorTag tag; + GhosttyStyleColorValue value; +} GhosttyStyleColor; + +/** + * Terminal cell style. + * + * Describes the complete visual style for a terminal cell, including + * foreground, background, and underline colors, as well as text + * decoration flags. The underline field uses the same values as + * GhosttySgrUnderline. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * @ingroup style + */ +typedef struct { + size_t size; + GhosttyStyleColor fg_color; + GhosttyStyleColor bg_color; + GhosttyStyleColor underline_color; + bool bold; + bool italic; + bool faint; + bool blink; + bool inverse; + bool invisible; + bool strikethrough; + bool overline; + int underline; /**< One of GHOSTTY_SGR_UNDERLINE_* values */ +} GhosttyStyle; + +/** + * Get the default style. + * + * Initializes the style to the default values (no colors, no flags). + * + * @param style Pointer to the style to initialize + * + * @ingroup style + */ +void ghostty_style_default(GhosttyStyle* style); + +/** + * Check if a style is the default style. + * + * Returns true if all colors are unset and all flags are off. + * + * @param style Pointer to the style to check + * @return true if the style is the default style + * + * @ingroup style + */ +bool ghostty_style_is_default(const GhosttyStyle* style); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_STYLE_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index d6416d97a..bf59d646b 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -169,6 +169,8 @@ comptime { @export(&c.mode_report_encode, .{ .name = "ghostty_mode_report_encode" }); @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); @export(&c.size_report_encode, .{ .name = "ghostty_size_report_encode" }); + @export(&c.style_default, .{ .name = "ghostty_style_default" }); + @export(&c.style_is_default, .{ .name = "ghostty_style_is_default" }); @export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" }); @export(&c.sgr_new, .{ .name = "ghostty_sgr_new" }); @export(&c.sgr_free, .{ .name = "ghostty_sgr_free" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index d591dcb89..b195b4d4c 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -10,6 +10,7 @@ pub const mouse_encode = @import("mouse_encode.zig"); pub const paste = @import("paste.zig"); pub const sgr = @import("sgr.zig"); pub const size_report = @import("size_report.zig"); +pub const style = @import("style.zig"); pub const terminal = @import("terminal.zig"); // The full C API, unexported. @@ -90,6 +91,9 @@ pub const paste_is_safe = paste.is_safe; pub const size_report_encode = size_report.encode; +pub const style_default = style.default_style; +pub const style_is_default = style.style_is_default; + pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; pub const terminal_reset = terminal.reset; @@ -113,6 +117,7 @@ test { _ = paste; _ = sgr; _ = size_report; + _ = style; _ = terminal; // We want to make sure we run the tests for the C allocator interface. diff --git a/src/terminal/c/style.zig b/src/terminal/c/style.zig new file mode 100644 index 000000000..01f079ccc --- /dev/null +++ b/src/terminal/c/style.zig @@ -0,0 +1,133 @@ +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; +const style = @import("../style.zig"); +const color = @import("../color.zig"); +const sgr = @import("../sgr.zig"); + +/// C: GhosttyStyleColorTag +pub const ColorTag = enum(c_int) { + none = 0, + palette = 1, + rgb = 2, +}; + +/// C: GhosttyStyleColorValue +pub const ColorValue = extern union { + palette: u8, + rgb: color.RGB.C, + _padding: u32, +}; + +/// C: GhosttyStyleColor +pub const Color = extern struct { + tag: ColorTag, + value: ColorValue, +}; + +/// C: GhosttyStyle +pub const Style = extern struct { + size: usize = @sizeOf(Style), + fg_color: Color, + bg_color: Color, + underline_color: Color, + bold: bool, + italic: bool, + faint: bool, + blink: bool, + inverse: bool, + invisible: bool, + strikethrough: bool, + overline: bool, + underline: c_int, +}; + +fn convertColor(c: style.Style.Color) Color { + return switch (c) { + .none => .{ + .tag = .none, + .value = .{ ._padding = 0 }, + }, + .palette => |idx| .{ + .tag = .palette, + .value = .{ .palette = idx }, + }, + .rgb => |rgb| .{ + .tag = .rgb, + .value = .{ .rgb = rgb.cval() }, + }, + }; +} + +pub fn convertStyle(s: style.Style) Style { + return .{ + .fg_color = convertColor(s.fg_color), + .bg_color = convertColor(s.bg_color), + .underline_color = convertColor(s.underline_color), + .bold = s.flags.bold, + .italic = s.flags.italic, + .faint = s.flags.faint, + .blink = s.flags.blink, + .inverse = s.flags.inverse, + .invisible = s.flags.invisible, + .strikethrough = s.flags.strikethrough, + .overline = s.flags.overline, + .underline = @intFromEnum(s.flags.underline), + }; +} + +/// Returns the default style. +pub fn default_style(result: *Style) callconv(.c) void { + result.* = convertStyle(.{}); + assert(result.size == @sizeOf(Style)); +} + +/// Returns true if the style is the default style. +pub fn style_is_default(s: *const Style) callconv(.c) bool { + assert(s.size == @sizeOf(Style)); + return s.fg_color.tag == .none and + s.bg_color.tag == .none and + s.underline_color.tag == .none and + s.bold == false and + s.italic == false and + s.faint == false and + s.blink == false and + s.inverse == false and + s.invisible == false and + s.strikethrough == false and + s.overline == false and + s.underline == 0; +} + +test "default style" { + var s: Style = undefined; + default_style(&s); + try testing.expect(style_is_default(&s)); + try testing.expectEqual(ColorTag.none, s.fg_color.tag); + try testing.expectEqual(ColorTag.none, s.bg_color.tag); + try testing.expectEqual(ColorTag.none, s.underline_color.tag); + try testing.expect(!s.bold); + try testing.expect(!s.italic); + try testing.expectEqual(@as(c_int, 0), s.underline); +} + +test "convert style with colors" { + const zig_style: style.Style = .{ + .fg_color = .{ .palette = 42 }, + .bg_color = .{ .rgb = .{ .r = 255, .g = 128, .b = 64 } }, + .underline_color = .none, + .flags = .{ .bold = true, .underline = .curly }, + }; + + const c_style = convertStyle(zig_style); + try testing.expectEqual(ColorTag.palette, c_style.fg_color.tag); + try testing.expectEqual(@as(u8, 42), c_style.fg_color.value.palette); + try testing.expectEqual(ColorTag.rgb, c_style.bg_color.tag); + try testing.expectEqual(@as(u8, 255), c_style.bg_color.value.rgb.r); + try testing.expectEqual(@as(u8, 128), c_style.bg_color.value.rgb.g); + try testing.expectEqual(@as(u8, 64), c_style.bg_color.value.rgb.b); + try testing.expectEqual(ColorTag.none, c_style.underline_color.tag); + try testing.expect(c_style.bold); + try testing.expectEqual(@as(c_int, 3), c_style.underline); + try testing.expect(!style_is_default(&c_style)); +} From d62f6df1d5370b6ec7a5de80dd15718a424e727e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Mar 2026 12:04:56 -0700 Subject: [PATCH 03/10] vt: expose cursor_style via terminal_get Add cursor_style to TerminalData, returning the current SGR style of the cursor (the style applied to newly printed characters) as a GhosttyStyle. Refactor the C style conversion helpers: replace the standalone convertStyle and convertColor functions with fromStyle and fromColor initializers on the Style and Color extern structs respectively. --- include/ghostty/vt/terminal.h | 10 +++++ src/terminal/c/style.zig | 72 +++++++++++++++++------------------ src/terminal/c/terminal.zig | 4 ++ 3 files changed, 50 insertions(+), 36 deletions(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 88fac14ef..8ebd4e7dd 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -13,6 +13,7 @@ #include #include #include +#include #ifdef __cplusplus extern "C" { @@ -208,6 +209,15 @@ typedef enum { * Output type: GhosttyTerminalScrollbar * */ GHOSTTY_TERMINAL_DATA_SCROLLBAR = 9, + + /** + * The current SGR style of the cursor. + * + * This is the style that will be applied to newly printed characters. + * + * Output type: GhosttyStyle * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_STYLE = 10, } GhosttyTerminalData; /** diff --git a/src/terminal/c/style.zig b/src/terminal/c/style.zig index 01f079ccc..5538c42cc 100644 --- a/src/terminal/c/style.zig +++ b/src/terminal/c/style.zig @@ -23,6 +23,23 @@ pub const ColorValue = extern union { pub const Color = extern struct { tag: ColorTag, value: ColorValue, + + pub fn fromColor(c: style.Style.Color) Color { + return switch (c) { + .none => .{ + .tag = .none, + .value = .{ ._padding = 0 }, + }, + .palette => |idx| .{ + .tag = .palette, + .value = .{ .palette = idx }, + }, + .rgb => |rgb| .{ + .tag = .rgb, + .value = .{ .rgb = rgb.cval() }, + }, + }; + } }; /// C: GhosttyStyle @@ -40,45 +57,28 @@ pub const Style = extern struct { strikethrough: bool, overline: bool, underline: c_int, + + pub fn fromStyle(s: style.Style) Style { + return .{ + .fg_color = .fromColor(s.fg_color), + .bg_color = .fromColor(s.bg_color), + .underline_color = .fromColor(s.underline_color), + .bold = s.flags.bold, + .italic = s.flags.italic, + .faint = s.flags.faint, + .blink = s.flags.blink, + .inverse = s.flags.inverse, + .invisible = s.flags.invisible, + .strikethrough = s.flags.strikethrough, + .overline = s.flags.overline, + .underline = @intFromEnum(s.flags.underline), + }; + } }; -fn convertColor(c: style.Style.Color) Color { - return switch (c) { - .none => .{ - .tag = .none, - .value = .{ ._padding = 0 }, - }, - .palette => |idx| .{ - .tag = .palette, - .value = .{ .palette = idx }, - }, - .rgb => |rgb| .{ - .tag = .rgb, - .value = .{ .rgb = rgb.cval() }, - }, - }; -} - -pub fn convertStyle(s: style.Style) Style { - return .{ - .fg_color = convertColor(s.fg_color), - .bg_color = convertColor(s.bg_color), - .underline_color = convertColor(s.underline_color), - .bold = s.flags.bold, - .italic = s.flags.italic, - .faint = s.flags.faint, - .blink = s.flags.blink, - .inverse = s.flags.inverse, - .invisible = s.flags.invisible, - .strikethrough = s.flags.strikethrough, - .overline = s.flags.overline, - .underline = @intFromEnum(s.flags.underline), - }; -} - /// Returns the default style. pub fn default_style(result: *Style) callconv(.c) void { - result.* = convertStyle(.{}); + result.* = .fromStyle(.{}); assert(result.size == @sizeOf(Style)); } @@ -119,7 +119,7 @@ test "convert style with colors" { .flags = .{ .bold = true, .underline = .curly }, }; - const c_style = convertStyle(zig_style); + const c_style: Style = .fromStyle(zig_style); try testing.expectEqual(ColorTag.palette, c_style.fg_color.tag); try testing.expectEqual(@as(u8, 42), c_style.fg_color.value.palette); try testing.expectEqual(ColorTag.rgb, c_style.bg_color.tag); diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 3c771f16e..1d31d5ae2 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -8,6 +8,7 @@ const PageList = @import("../PageList.zig"); const kitty = @import("../kitty/key.zig"); const modes = @import("../modes.zig"); const size = @import("../size.zig"); +const style_c = @import("style.zig"); const Result = @import("result.zig").Result; const log = std.log.scoped(.terminal_c); @@ -146,6 +147,7 @@ pub const TerminalData = enum(c_int) { cursor_visible = 7, kitty_keyboard_flags = 8, scrollbar = 9, + cursor_style = 10, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: TerminalData) type { @@ -156,6 +158,7 @@ pub const TerminalData = enum(c_int) { .active_screen => TerminalScreen, .kitty_keyboard_flags => u8, .scrollbar => TerminalScrollbar, + .cursor_style => style_c.Style, }; } }; @@ -198,6 +201,7 @@ fn getTyped( .cursor_visible => out.* = t.modes.get(.cursor_visible), .kitty_keyboard_flags => out.* = @as(u8, t.screens.active.kitty_keyboard.current().int()), .scrollbar => out.* = t.screens.active.pages.scrollbar().cval(), + .cursor_style => out.* = .fromStyle(t.screens.active.cursor.style), } return .success; From d827225573b673bc5c1756f2d14971638a472d53 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Mar 2026 12:24:28 -0700 Subject: [PATCH 04/10] vt: expand padding for color union to 64-bit to allow for a pointer --- include/ghostty/vt/style.h | 2 +- src/terminal/c/style.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/include/ghostty/vt/style.h b/include/ghostty/vt/style.h index 5c326ae19..603f88d01 100644 --- a/include/ghostty/vt/style.h +++ b/include/ghostty/vt/style.h @@ -52,7 +52,7 @@ typedef enum { typedef union { GhosttyColorPaletteIndex palette; GhosttyColorRgb rgb; - uint32_t _padding; + uint64_t _padding; } GhosttyStyleColorValue; /** diff --git a/src/terminal/c/style.zig b/src/terminal/c/style.zig index 5538c42cc..99f591c10 100644 --- a/src/terminal/c/style.zig +++ b/src/terminal/c/style.zig @@ -16,7 +16,7 @@ pub const ColorTag = enum(c_int) { pub const ColorValue = extern union { palette: u8, rgb: color.RGB.C, - _padding: u32, + _padding: u64, }; /// C: GhosttyStyleColor From 5c8b9f3f434abee1e70f454ec00301010ea01edf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Mar 2026 13:10:31 -0700 Subject: [PATCH 05/10] vt: add GhosttyCell and GhosttyRow C API with data getters Add opaque GhosttyCell (uint64_t) and GhosttyRow (uint64_t) types that bitcast to the internal packed Cell and Row structs from page.zig. Each type has a corresponding data enum and getter function following the same pattern as ghostty_terminal_get. ghostty_cell_get supports extracting codepoint, content tag, wide property, has_text, has_styling, style_id, has_hyperlink, protected, and semantic_content. ghostty_row_get supports wrap, wrap_continuation, grapheme, styled, hyperlink, semantic_prompt, kitty_virtual_placeholder, and dirty. The cell and row types and functions live in a new screen.h header, separate from terminal.h, with terminal.h including screen.h for convenience. --- include/ghostty/vt.h | 1 + include/ghostty/vt/screen.h | 323 ++++++++++++++++++++++++++++++++++ include/ghostty/vt/terminal.h | 1 + src/lib_vt.zig | 2 + src/terminal/c/cell.zig | 158 +++++++++++++++++ src/terminal/c/main.zig | 8 + src/terminal/c/row.zig | 130 ++++++++++++++ 7 files changed, 623 insertions(+) create mode 100644 include/ghostty/vt/screen.h create mode 100644 src/terminal/c/cell.zig create mode 100644 src/terminal/c/row.zig diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index effafd0f0..de690577d 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -103,6 +103,7 @@ extern "C" { #include #include #include +#include #include #include diff --git a/include/ghostty/vt/screen.h b/include/ghostty/vt/screen.h new file mode 100644 index 000000000..64207ce13 --- /dev/null +++ b/include/ghostty/vt/screen.h @@ -0,0 +1,323 @@ +/** + * @file screen.h + * + * Terminal screen cell and row types. + */ + +#ifndef GHOSTTY_VT_SCREEN_H +#define GHOSTTY_VT_SCREEN_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup screen Screen + * + * 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. + * + * @{ + */ + +/** + * Opaque 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. + * + * @ingroup screen + */ +typedef uint64_t GhosttyCell; + +/** + * Opaque row value. + * + * Represents a single terminal row. The internal layout is opaque and + * must be queried via ghostty_row_get(). Obtain row values from + * terminal query APIs. + * + * @ingroup screen + */ +typedef uint64_t GhosttyRow; + +/** + * Cell content tag. + * + * Describes what kind of content a cell holds. + * + * @ingroup screen + */ +typedef enum { + /** A single codepoint (may be zero for empty). */ + GHOSTTY_CELL_CONTENT_CODEPOINT = 0, + + /** A codepoint that is part of a multi-codepoint grapheme cluster. */ + GHOSTTY_CELL_CONTENT_CODEPOINT_GRAPHEME = 1, + + /** No text; background color from palette. */ + GHOSTTY_CELL_CONTENT_BG_COLOR_PALETTE = 2, + + /** No text; background color as RGB. */ + GHOSTTY_CELL_CONTENT_BG_COLOR_RGB = 3, +} GhosttyCellContentTag; + +/** + * Cell wide property. + * + * Describes the width behavior of a cell. + * + * @ingroup screen + */ +typedef enum { + /** Not a wide character, cell width 1. */ + GHOSTTY_CELL_WIDE_NARROW = 0, + + /** Wide character, cell width 2. */ + GHOSTTY_CELL_WIDE_WIDE = 1, + + /** Spacer after wide character. Do not render. */ + GHOSTTY_CELL_WIDE_SPACER_TAIL = 2, + + /** Spacer at end of soft-wrapped line for a wide character. */ + GHOSTTY_CELL_WIDE_SPACER_HEAD = 3, +} GhosttyCellWide; + +/** + * Semantic content type of a cell. + * + * Set by semantic prompt sequences (OSC 133) to distinguish between + * command output, user input, and shell prompt text. + * + * @ingroup screen + */ +typedef enum { + /** Regular output content, such as command output. */ + GHOSTTY_CELL_SEMANTIC_OUTPUT = 0, + + /** Content that is part of user input. */ + GHOSTTY_CELL_SEMANTIC_INPUT = 1, + + /** Content that is part of a shell prompt. */ + GHOSTTY_CELL_SEMANTIC_PROMPT = 2, +} GhosttyCellSemanticContent; + +/** + * Cell data types. + * + * These values specify what type of data to extract from a cell + * using `ghostty_cell_get`. + * + * @ingroup screen + */ +typedef enum { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_CELL_DATA_INVALID = 0, + + /** + * The codepoint of the cell (0 if empty or bg-color-only). + * + * Output type: uint32_t * + */ + GHOSTTY_CELL_DATA_CODEPOINT = 1, + + /** + * The content tag describing what kind of content is in the cell. + * + * Output type: GhosttyCellContentTag * + */ + GHOSTTY_CELL_DATA_CONTENT_TAG = 2, + + /** + * The wide property of the cell. + * + * Output type: GhosttyCellWide * + */ + GHOSTTY_CELL_DATA_WIDE = 3, + + /** + * Whether the cell has text to render. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_HAS_TEXT = 4, + + /** + * Whether the cell has non-default styling. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_HAS_STYLING = 5, + + /** + * The style ID for the cell (for use with style lookups). + * + * Output type: uint16_t * + */ + GHOSTTY_CELL_DATA_STYLE_ID = 6, + + /** + * Whether the cell has a hyperlink. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_HAS_HYPERLINK = 7, + + /** + * Whether the cell is protected. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_PROTECTED = 8, + + /** + * The semantic content type of the cell (from OSC 133). + * + * Output type: GhosttyCellSemanticContent * + */ + GHOSTTY_CELL_DATA_SEMANTIC_CONTENT = 9, +} GhosttyCellData; + +/** + * Row semantic prompt state. + * + * Indicates whether any cells in a row are part of a shell prompt, + * as reported by OSC 133 sequences. + * + * @ingroup screen + */ +typedef enum { + /** No prompt cells in this row. */ + GHOSTTY_ROW_SEMANTIC_NONE = 0, + + /** Prompt cells exist and this is a primary prompt line. */ + GHOSTTY_ROW_SEMANTIC_PROMPT = 1, + + /** Prompt cells exist and this is a continuation line. */ + GHOSTTY_ROW_SEMANTIC_PROMPT_CONTINUATION = 2, +} GhosttyRowSemanticPrompt; + +/** + * Row data types. + * + * These values specify what type of data to extract from a row + * using `ghostty_row_get`. + * + * @ingroup screen + */ +typedef enum { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_ROW_DATA_INVALID = 0, + + /** + * Whether this row is soft-wrapped. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_WRAP = 1, + + /** + * Whether this row is a continuation of a soft-wrapped row. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_WRAP_CONTINUATION = 2, + + /** + * Whether any cells in this row have grapheme clusters. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_GRAPHEME = 3, + + /** + * Whether any cells in this row have styling (may have false positives). + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_STYLED = 4, + + /** + * Whether any cells in this row have hyperlinks (may have false positives). + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_HYPERLINK = 5, + + /** + * The semantic prompt state of this row. + * + * Output type: GhosttyRowSemanticPrompt * + */ + GHOSTTY_ROW_DATA_SEMANTIC_PROMPT = 6, + + /** + * Whether this row contains a Kitty virtual placeholder. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_KITTY_VIRTUAL_PLACEHOLDER = 7, + + /** + * Whether this row is dirty and requires a redraw. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_DIRTY = 8, +} GhosttyRowData; + +/** + * Get data from a cell. + * + * Extracts typed data from the given cell based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid data types and output types are documented + * in the `GhosttyCellData` enum. + * + * @param cell The cell value + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * data type is invalid + * + * @ingroup screen + */ +GhosttyResult ghostty_cell_get(GhosttyCell cell, + GhosttyCellData data, + void *out); + +/** + * Get data from a row. + * + * Extracts typed data from the given row based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid data types and output types are documented + * in the `GhosttyRowData` enum. + * + * @param row The row value + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * data type is invalid + * + * @ingroup screen + */ +GhosttyResult ghostty_row_get(GhosttyRow row, + GhosttyRowData data, + void *out); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_SCREEN_H */ diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 8ebd4e7dd..d0b14977f 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #ifdef __cplusplus diff --git a/src/lib_vt.zig b/src/lib_vt.zig index bf59d646b..5494019c4 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -171,6 +171,8 @@ comptime { @export(&c.size_report_encode, .{ .name = "ghostty_size_report_encode" }); @export(&c.style_default, .{ .name = "ghostty_style_default" }); @export(&c.style_is_default, .{ .name = "ghostty_style_is_default" }); + @export(&c.cell_get, .{ .name = "ghostty_cell_get" }); + @export(&c.row_get, .{ .name = "ghostty_row_get" }); @export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" }); @export(&c.sgr_new, .{ .name = "ghostty_sgr_new" }); @export(&c.sgr_free, .{ .name = "ghostty_sgr_free" }); diff --git a/src/terminal/c/cell.zig b/src/terminal/c/cell.zig new file mode 100644 index 000000000..493d89082 --- /dev/null +++ b/src/terminal/c/cell.zig @@ -0,0 +1,158 @@ +const std = @import("std"); +const testing = std.testing; +const page = @import("../page.zig"); +const Cell = page.Cell; +const style_c = @import("style.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyCell +pub const CCell = u64; + +/// C: GhosttyCellContentTag +pub const ContentTag = enum(c_int) { + codepoint = 0, + codepoint_grapheme = 1, + bg_color_palette = 2, + bg_color_rgb = 3, +}; + +/// C: GhosttyCellWide +pub const Wide = enum(c_int) { + narrow = 0, + wide = 1, + spacer_tail = 2, + spacer_head = 3, +}; + +/// C: GhosttyCellSemanticContent +pub const SemanticContent = enum(c_int) { + output = 0, + input = 1, + prompt = 2, +}; + +/// C: GhosttyCellData +pub const CellData = enum(c_int) { + invalid = 0, + + /// The codepoint of the cell (0 if empty or bg-color-only). + /// Output type: uint32_t * (stored as u21, zero-extended) + codepoint = 1, + + /// The content tag describing what kind of content is in the cell. + /// Output type: GhosttyCellContentTag * + content_tag = 2, + + /// The wide property of the cell. + /// Output type: GhosttyCellWide * + wide = 3, + + /// Whether the cell has text to render. + /// Output type: bool * + has_text = 4, + + /// Whether the cell has styling (non-default style). + /// Output type: bool * + has_styling = 5, + + /// The style ID for the cell (for use with style lookups). + /// Output type: uint16_t * + style_id = 6, + + /// Whether the cell has a hyperlink. + /// Output type: bool * + has_hyperlink = 7, + + /// Whether the cell is protected. + /// Output type: bool * + protected = 8, + + /// The semantic content type of the cell (from OSC 133). + /// Output type: GhosttyCellSemanticContent * + semantic_content = 9, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: CellData) type { + return switch (self) { + .invalid => void, + .codepoint => u32, + .content_tag => ContentTag, + .wide => Wide, + .has_text, .has_styling, .has_hyperlink, .protected => bool, + .style_id => u16, + .semantic_content => SemanticContent, + }; + } +}; + +pub fn get( + cell_: CCell, + data: CellData, + out: ?*anyopaque, +) callconv(.c) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(CellData, @intFromEnum(data)) catch { + return .invalid_value; + }; + } + + return switch (data) { + inline else => |comptime_data| getTyped( + cell_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + cell_: CCell, + comptime data: CellData, + out: *data.OutType(), +) Result { + const cell: Cell = @bitCast(cell_); + switch (data) { + .invalid => return .invalid_value, + .codepoint => out.* = @intCast(cell.codepoint()), + .content_tag => out.* = @enumFromInt(@intFromEnum(cell.content_tag)), + .wide => out.* = @enumFromInt(@intFromEnum(cell.wide)), + .has_text => out.* = cell.hasText(), + .has_styling => out.* = cell.hasStyling(), + .style_id => out.* = cell.style_id, + .has_hyperlink => out.* = cell.hyperlink, + .protected => out.* = cell.protected, + .semantic_content => out.* = @enumFromInt(@intFromEnum(cell.semantic_content)), + } + + return .success; +} + +test "get codepoint" { + const cell: CCell = @bitCast(Cell.init('A')); + var cp: u32 = 0; + try testing.expectEqual(Result.success, get(cell, .codepoint, @ptrCast(&cp))); + try testing.expectEqual(@as(u32, 'A'), cp); +} + +test "get has_text" { + const cell: CCell = @bitCast(Cell.init('A')); + var has: bool = false; + try testing.expectEqual(Result.success, get(cell, .has_text, @ptrCast(&has))); + try testing.expect(has); +} + +test "get empty cell" { + const cell: CCell = @bitCast(Cell.init(0)); + var has: bool = true; + try testing.expectEqual(Result.success, get(cell, .has_text, @ptrCast(&has))); + try testing.expect(!has); +} + +test "get wide" { + var zig_cell = Cell.init('A'); + zig_cell.wide = .wide; + const cell: CCell = @bitCast(zig_cell); + var w: Wide = .narrow; + try testing.expectEqual(Result.success, get(cell, .wide, @ptrCast(&w))); + try testing.expectEqual(Wide.wide, w); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index b195b4d4c..584f04f43 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -1,3 +1,4 @@ +pub const cell = @import("cell.zig"); pub const color = @import("color.zig"); pub const focus = @import("focus.zig"); pub const formatter = @import("formatter.zig"); @@ -8,6 +9,7 @@ pub const key_encode = @import("key_encode.zig"); pub const mouse_event = @import("mouse_event.zig"); pub const mouse_encode = @import("mouse_encode.zig"); pub const paste = @import("paste.zig"); +pub const row = @import("row.zig"); pub const sgr = @import("sgr.zig"); pub const size_report = @import("size_report.zig"); pub const style = @import("style.zig"); @@ -91,6 +93,10 @@ pub const paste_is_safe = paste.is_safe; pub const size_report_encode = size_report.encode; +pub const cell_get = cell.get; + +pub const row_get = row.get; + pub const style_default = style.default_style; pub const style_is_default = style.style_is_default; @@ -105,7 +111,9 @@ pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; test { + _ = cell; _ = color; + _ = row; _ = focus; _ = formatter; _ = modes; diff --git a/src/terminal/c/row.zig b/src/terminal/c/row.zig new file mode 100644 index 000000000..b67c98b3c --- /dev/null +++ b/src/terminal/c/row.zig @@ -0,0 +1,130 @@ +const std = @import("std"); +const testing = std.testing; +const page = @import("../page.zig"); +const Row = page.Row; +const Result = @import("result.zig").Result; + +/// C: GhosttyRow +pub const CRow = u64; + +/// C: GhosttyRowSemanticPrompt +pub const SemanticPrompt = enum(c_int) { + none = 0, + prompt = 1, + prompt_continuation = 2, +}; + +/// C: GhosttyRowData +pub const RowData = enum(c_int) { + invalid = 0, + + /// Whether this row is soft-wrapped. + /// Output type: bool * + wrap = 1, + + /// Whether this row is a continuation of a soft-wrapped row. + /// Output type: bool * + wrap_continuation = 2, + + /// Whether any cells in this row have grapheme clusters. + /// Output type: bool * + grapheme = 3, + + /// Whether any cells in this row have styling (may have false positives). + /// Output type: bool * + styled = 4, + + /// Whether any cells in this row have hyperlinks (may have false positives). + /// Output type: bool * + hyperlink = 5, + + /// The semantic prompt state of this row. + /// Output type: GhosttyRowSemanticPrompt * + semantic_prompt = 6, + + /// Whether this row contains a Kitty virtual placeholder. + /// Output type: bool * + kitty_virtual_placeholder = 7, + + /// Whether this row is dirty and requires a redraw. + /// Output type: bool * + dirty = 8, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: RowData) type { + return switch (self) { + .invalid => void, + .wrap, .wrap_continuation, .grapheme, .styled, .hyperlink => bool, + .kitty_virtual_placeholder, .dirty => bool, + .semantic_prompt => SemanticPrompt, + }; + } +}; + +pub fn get( + row_: CRow, + data: RowData, + out: ?*anyopaque, +) callconv(.c) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(RowData, @intFromEnum(data)) catch { + return .invalid_value; + }; + } + + return switch (data) { + inline else => |comptime_data| getTyped( + row_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + row_: CRow, + comptime data: RowData, + out: *data.OutType(), +) Result { + const row: Row = @bitCast(row_); + switch (data) { + .invalid => return .invalid_value, + .wrap => out.* = row.wrap, + .wrap_continuation => out.* = row.wrap_continuation, + .grapheme => out.* = row.grapheme, + .styled => out.* = row.styled, + .hyperlink => out.* = row.hyperlink, + .semantic_prompt => out.* = @enumFromInt(@intFromEnum(row.semantic_prompt)), + .kitty_virtual_placeholder => out.* = row.kitty_virtual_placeholder, + .dirty => out.* = row.dirty, + } + + return .success; +} + +test "get wrap" { + var zig_row: Row = @bitCast(@as(u64, 0)); + zig_row.wrap = true; + const row: CRow = @bitCast(zig_row); + var wrap: bool = false; + try testing.expectEqual(Result.success, get(row, .wrap, @ptrCast(&wrap))); + try testing.expect(wrap); +} + +test "get semantic_prompt" { + var zig_row: Row = @bitCast(@as(u64, 0)); + zig_row.semantic_prompt = .prompt; + const row: CRow = @bitCast(zig_row); + var sp: SemanticPrompt = .none; + try testing.expectEqual(Result.success, get(row, .semantic_prompt, @ptrCast(&sp))); + try testing.expectEqual(SemanticPrompt.prompt, sp); +} + +test "get dirty" { + var zig_row: Row = @bitCast(@as(u64, 0)); + zig_row.dirty = true; + const row: CRow = @bitCast(zig_row); + var dirty: bool = false; + try testing.expectEqual(Result.success, get(row, .dirty, @ptrCast(&dirty))); + try testing.expect(dirty); +} From 057f227145fcce8d92678c16591d936f54f202b8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Mar 2026 13:59:48 -0700 Subject: [PATCH 06/10] terminal: convert Point to lib.Enum/lib.TaggedUnion with C header --- include/ghostty/vt/point.h | 88 ++++++++++++++++++++++++++++++++++++++ src/terminal/point.zig | 87 +++++++++++++++++++++---------------- 2 files changed, 139 insertions(+), 36 deletions(-) create mode 100644 include/ghostty/vt/point.h diff --git a/include/ghostty/vt/point.h b/include/ghostty/vt/point.h new file mode 100644 index 000000000..f152a5c46 --- /dev/null +++ b/include/ghostty/vt/point.h @@ -0,0 +1,88 @@ +/** + * @file point.h + * + * Terminal point types for referencing locations in the terminal grid. + */ + +#ifndef GHOSTTY_VT_POINT_H +#define GHOSTTY_VT_POINT_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup point Point + * + * Types for referencing x/y positions in the terminal grid under + * different coordinate systems (active area, viewport, full screen, + * scrollback history). + * + * @{ + */ + +/** + * A coordinate in the terminal grid. + * + * @ingroup point + */ +typedef struct { + /** Column (0-indexed). */ + uint16_t x; + + /** Row (0-indexed). May exceed page size for screen/history tags. */ + uint32_t y; +} GhosttyPointCoordinate; + +/** + * Point reference tag. + * + * Determines which coordinate system a point uses. + * + * @ingroup point + */ +typedef enum { + /** Active area where the cursor can move. */ + GHOSTTY_POINT_TAG_ACTIVE = 0, + + /** Visible viewport (changes when scrolled). */ + GHOSTTY_POINT_TAG_VIEWPORT = 1, + + /** Full screen including scrollback. */ + GHOSTTY_POINT_TAG_SCREEN = 2, + + /** Scrollback history only (before active area). */ + GHOSTTY_POINT_TAG_HISTORY = 3, +} GhosttyPointTag; + +/** + * Point value union. + * + * @ingroup point + */ +typedef union { + /** Coordinate (used for all tag variants). */ + GhosttyPointCoordinate coordinate; + + /** Padding for ABI compatibility. Do not use. */ + uint64_t _padding[2]; +} GhosttyPointValue; + +/** + * Tagged union for a point in the terminal grid. + * + * @ingroup point + */ +typedef struct { + GhosttyPointTag tag; + GhosttyPointValue value; +} GhosttyPoint; + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_POINT_H */ diff --git a/src/terminal/point.zig b/src/terminal/point.zig index c5983fcbc..db834732c 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -1,52 +1,56 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const build_options = @import("terminal_options"); +const lib = @import("../lib/main.zig"); const size = @import("size.zig"); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; + /// The possible reference locations for a point. When someone says "(42, 80)" /// in the context of a terminal, that could mean multiple things: it is in the /// current visible viewport? the current active area of the screen where the /// cursor is? the entire scrollback history? etc. /// /// This tag is used to differentiate those cases. -pub const Tag = enum { - /// Top-left is part of the active area where a running program can - /// jump the cursor and make changes. The active area is the "editable" - /// part of the screen. - /// - /// The bottom-right of the active tag differs from all other tags - /// because it includes the full height (rows) of the screen, including - /// rows that may not be written yet. This is required because the active - /// area is fully "addressable" by the running program (see below) whereas - /// the other tags are used primarily for reading/modifying past-written - /// data so they can't address unwritten rows. - /// - /// Note for those less familiar with terminal functionality: there - /// are escape sequences to move the cursor to any position on - /// the screen, but it is limited to the size of the viewport and - /// the bottommost part of the screen. Terminal programs can't -- - /// with sequences at the time of writing this comment -- modify - /// anything in the scrollback, visible viewport (if it differs - /// from the active area), etc. - active, +pub const Tag = lib.Enum(lib_target, &.{ + // Top-left is part of the active area where a running program can + // jump the cursor and make changes. The active area is the "editable" + // part of the screen. + // + // The bottom-right of the active tag differs from all other tags + // because it includes the full height (rows) of the screen, including + // rows that may not be written yet. This is required because the active + // area is fully "addressable" by the running program (see below) whereas + // the other tags are used primarily for reading/modifying past-written + // data so they can't address unwritten rows. + // + // Note for those less familiar with terminal functionality: there + // are escape sequences to move the cursor to any position on + // the screen, but it is limited to the size of the viewport and + // the bottommost part of the screen. Terminal programs can't -- + // with sequences at the time of writing this comment -- modify + // anything in the scrollback, visible viewport (if it differs + // from the active area), etc. + "active", - /// Top-left is the visible viewport. This means that if the user - /// has scrolled in any direction, top-left changes. The bottom-right - /// is the last written row from the top-left. - viewport, + // Top-left is the visible viewport. This means that if the user + // has scrolled in any direction, top-left changes. The bottom-right + // is the last written row from the top-left. + "viewport", - /// Top-left is the furthest back in the scrollback history - /// supported by the screen and the bottom-right is the bottom-right - /// of the last written row. Note this last point is important: the - /// bottom right is NOT necessarily the same as "active" because - /// "active" always allows referencing the full rows tall of the - /// screen whereas "screen" only contains written rows. - screen, + // Top-left is the furthest back in the scrollback history + // supported by the screen and the bottom-right is the bottom-right + // of the last written row. Note this last point is important: the + // bottom right is NOT necessarily the same as "active" because + // "active" always allows referencing the full rows tall of the + // screen whereas "screen" only contains written rows. + "screen", - /// The top-left is the same as "screen" but the bottom-right is - /// the line just before the top of "active". This contains only - /// the scrollback history. - history, -}; + // The top-left is the same as "screen" but the bottom-right is + // the line just before the top of "active". This contains only + // the scrollback history. + "history", +}); /// An x/y point in the terminal for some definition of location (tag). pub const Point = union(Tag) { @@ -64,6 +68,17 @@ pub const Point = union(Tag) { => |v| v, }; } + + const c_union = lib.TaggedUnion( + lib_target, + @This(), + // Padding: largest variant is Coordinate (u16 + u32 = 6 bytes). + // Use [2]u64 (16 bytes) for future expansion. + [2]u64, + ); + pub const C = c_union.C; + pub const CValue = c_union.CValue; + pub const cval = c_union.cval; }; pub const Coordinate = extern struct { From 0400de28b40bc47b0fcd0f5a78a908413cb86be6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Mar 2026 14:07:12 -0700 Subject: [PATCH 07/10] vt: add ghostty_terminal_cell for point-based cell lookup Add a new C API function ghostty_terminal_cell that retrieves the opaque cell and row values at a given point in the terminal grid. The point is a tagged union supporting active, viewport, screen, and history coordinate systems. --- include/ghostty/vt/terminal.h | 36 +++++++++++++++- src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 81 +++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index d0b14977f..65a6d5389 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #ifdef __cplusplus @@ -372,8 +373,39 @@ GhosttyResult ghostty_terminal_mode_set(GhosttyTerminal terminal, * @ingroup terminal */ GhosttyResult ghostty_terminal_get(GhosttyTerminal terminal, - GhosttyTerminalData data, - void *out); + GhosttyTerminalData data, + void *out); + +/** + * Get the cell and row at a given point in the terminal. + * + * Looks up the cell at the specified point in the terminal grid. On success, + * the output parameters are set to the opaque cell and row values, which can + * be queried further with ghostty_cell_get() and ghostty_row_get(). + * + * Lookups using the `active` and `viewport` tags are fast. The `screen` + * and `history` tags may require traversing the full scrollback page list + * to resolve the y coordinate, so they can be expensive for large + * scrollback buffers. + * + * This function isn't meant to be used as the core of render loop. It + * isn't built to sustain the framerates needed for rendering large screens. + * Use the render state API for that. This API is instead meant for less + * strictly performance-sensitive use cases. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param point The point specifying which cell to look up + * @param[out] out_cell On success, set to the cell at the given point (may be NULL) + * @param[out] out_row On success, set to the row containing the cell (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * is NULL or the point is out of bounds + * + * @ingroup terminal + */ +GhosttyResult ghostty_terminal_cell(GhosttyTerminal terminal, + GhosttyPoint point, + GhosttyCell *out_cell, + GhosttyRow *out_row); /** @} */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 5494019c4..d82a3f3de 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -196,6 +196,7 @@ comptime { @export(&c.terminal_mode_get, .{ .name = "ghostty_terminal_mode_get" }); @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); + @export(&c.terminal_cell, .{ .name = "ghostty_terminal_cell" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 584f04f43..b1b2c92ef 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -109,6 +109,7 @@ pub const terminal_scroll_viewport = terminal.scroll_viewport; pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; +pub const terminal_cell = terminal.cell; test { _ = cell; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 1d31d5ae2..fe3a5e149 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -7,7 +7,10 @@ const ScreenSet = @import("../ScreenSet.zig"); const PageList = @import("../PageList.zig"); const kitty = @import("../kitty/key.zig"); const modes = @import("../modes.zig"); +const point = @import("../point.zig"); const size = @import("../size.zig"); +const cell_c = @import("cell.zig"); +const row_c = @import("row.zig"); const style_c = @import("style.zig"); const Result = @import("result.zig").Result; @@ -207,6 +210,26 @@ fn getTyped( return .success; } +pub fn cell( + terminal_: Terminal, + pt: point.Point.C, + out_cell: ?*cell_c.CCell, + out_row: ?*row_c.CRow, +) callconv(.c) Result { + const t = terminal_ orelse return .invalid_value; + const zig_pt: point.Point = switch (pt.tag) { + .active => .{ .active = pt.value.active }, + .viewport => .{ .viewport = pt.value.viewport }, + .screen => .{ .screen = pt.value.screen }, + .history => .{ .history = pt.value.history }, + }; + const result = t.screens.active.pages.getCell(zig_pt) orelse + return .invalid_value; + if (out_cell) |p| p.* = @bitCast(result.cell.*); + if (out_row) |p| p.* = @bitCast(result.row.*); + return .success; +} + pub fn free(terminal_: Terminal) callconv(.c) void { const t = terminal_ orelse return; @@ -612,3 +635,61 @@ test "get invalid" { try testing.expectEqual(Result.invalid_value, get(t, .invalid, null)); } + +test "cell" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + var out_cell: cell_c.CCell = undefined; + var out_row: row_c.CRow = undefined; + try testing.expectEqual(Result.success, cell(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 0 } }, + }, &out_cell, &out_row)); + + // Verify the cell contains 'H' + var cp: u32 = 0; + try testing.expectEqual(Result.success, cell_c.get(out_cell, .codepoint, @ptrCast(&cp))); + try testing.expectEqual(@as(u32, 'H'), cp); +} + +test "cell null terminal" { + var out_cell: cell_c.CCell = undefined; + var out_row: row_c.CRow = undefined; + try testing.expectEqual(Result.invalid_value, cell(null, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 0 } }, + }, &out_cell, &out_row)); +} + +test "cell out of bounds" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var out_cell: cell_c.CCell = undefined; + var out_row: row_c.CRow = undefined; + try testing.expectEqual(Result.invalid_value, cell(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 100, .y = 0 } }, + }, &out_cell, &out_row)); +} From df8813bf1b0a0526ee5da340b4398f85f0852c52 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Mar 2026 15:35:06 -0700 Subject: [PATCH 08/10] vt: replace ghostty_terminal_cell with GhosttyGridRef API --- include/ghostty/vt.h | 1 + include/ghostty/vt/grid_ref.h | 86 +++++++++++++++++++++++++++++++++++ include/ghostty/vt/terminal.h | 20 ++++---- src/lib_vt.zig | 4 +- src/terminal/c/grid_ref.zig | 76 +++++++++++++++++++++++++++++++ src/terminal/c/main.zig | 7 ++- src/terminal/c/terminal.zig | 43 +++++++++--------- 7 files changed, 203 insertions(+), 34 deletions(-) create mode 100644 include/ghostty/vt/grid_ref.h create mode 100644 src/terminal/c/grid_ref.zig diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index de690577d..8a53c4bd0 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -96,6 +96,7 @@ extern "C" { #include #include #include +#include #include #include #include diff --git a/include/ghostty/vt/grid_ref.h b/include/ghostty/vt/grid_ref.h new file mode 100644 index 000000000..51260e35f --- /dev/null +++ b/include/ghostty/vt/grid_ref.h @@ -0,0 +1,86 @@ +/** + * @file grid_ref.h + * + * Terminal grid reference type for referencing a resolved position in the + * terminal grid. + */ + +#ifndef GHOSTTY_VT_GRID_REF_H +#define GHOSTTY_VT_GRID_REF_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup grid_ref Grid Reference + * + * A grid reference is a resolved reference to a specific cell position in the + * terminal's internal page structure. Obtain a grid reference from + * ghostty_terminal_grid_ref(), then extract the cell or row via + * ghostty_grid_ref_cell() and ghostty_grid_ref_row(). + * + * A grid reference is only valid until the next update to the terminal + * instance. There is no guarantee that a grid reference will remain + * valid after ANY operation, even if a seemingly unrelated part of + * the grid is changed, so any information related to the grid reference + * should be read and cached immediately after obtaining the grid reference. + * + * This API is not meant to be used as the core of render loop. It isn't + * built to sustain the framerates needed for rendering large screens. + * Use the render state API for that. + * + * @{ + */ + +/** + * A resolved reference to a terminal cell position. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * @ingroup grid_ref + */ +typedef struct { + size_t size; + void *node; + uint16_t x; + uint16_t y; +} GhosttyGridRef; + +/** + * Get the cell from a grid reference. + * + * @param ref Pointer to the grid reference + * @param[out] out_cell On success, set to the cell at the ref's position (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL + * + * @ingroup grid_ref + */ +GhosttyResult ghostty_grid_ref_cell(const GhosttyGridRef *ref, + GhosttyCell *out_cell); + +/** + * Get the row from a grid reference. + * + * @param ref Pointer to the grid reference + * @param[out] out_row On success, set to the row at the ref's position (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL + * + * @ingroup grid_ref + */ +GhosttyResult ghostty_grid_ref_row(const GhosttyGridRef *ref, + GhosttyRow *out_row); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_GRID_REF_H */ diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 65a6d5389..042d3ba93 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -377,11 +378,12 @@ GhosttyResult ghostty_terminal_get(GhosttyTerminal terminal, void *out); /** - * Get the cell and row at a given point in the terminal. + * Resolve a point in the terminal grid to a grid reference. * - * Looks up the cell at the specified point in the terminal grid. On success, - * the output parameters are set to the opaque cell and row values, which can - * be queried further with ghostty_cell_get() and ghostty_row_get(). + * Resolves the given point (which can be in active, viewport, screen, + * or history coordinates) to a grid reference for that location. Use + * ghostty_grid_ref_cell() and ghostty_grid_ref_row() to extract the cell + * and row. * * Lookups using the `active` and `viewport` tags are fast. The `screen` * and `history` tags may require traversing the full scrollback page list @@ -395,17 +397,15 @@ GhosttyResult ghostty_terminal_get(GhosttyTerminal terminal, * * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) * @param point The point specifying which cell to look up - * @param[out] out_cell On success, set to the cell at the given point (may be NULL) - * @param[out] out_row On success, set to the row containing the cell (may be NULL) + * @param[out] out_ref On success, set to the grid reference at the given point (may be NULL) * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal * is NULL or the point is out of bounds * * @ingroup terminal */ -GhosttyResult ghostty_terminal_cell(GhosttyTerminal terminal, - GhosttyPoint point, - GhosttyCell *out_cell, - GhosttyRow *out_row); +GhosttyResult ghostty_terminal_grid_ref(GhosttyTerminal terminal, + GhosttyPoint point, + GhosttyGridRef *out_ref); /** @} */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index d82a3f3de..1b17eed99 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -196,7 +196,9 @@ comptime { @export(&c.terminal_mode_get, .{ .name = "ghostty_terminal_mode_get" }); @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); - @export(&c.terminal_cell, .{ .name = "ghostty_terminal_cell" }); + @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); + @export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" }); + @export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/c/grid_ref.zig b/src/terminal/c/grid_ref.zig new file mode 100644 index 000000000..592ee135f --- /dev/null +++ b/src/terminal/c/grid_ref.zig @@ -0,0 +1,76 @@ +const std = @import("std"); +const testing = std.testing; +const page = @import("../page.zig"); +const PageList = @import("../PageList.zig"); +const size = @import("../size.zig"); +const cell_c = @import("cell.zig"); +const row_c = @import("row.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyGridRef +/// +/// A sized struct that holds a reference to a position in the terminal grid. +/// The ref points to a specific cell position within the terminal's +/// internal page structure. +pub const CGridRef = extern struct { + size: usize = @sizeOf(CGridRef), + node: ?*PageList.List.Node = null, + x: size.CellCountInt = 0, + y: size.CellCountInt = 0, + + pub fn fromPin(pin: PageList.Pin) CGridRef { + return .{ + .node = pin.node, + .x = pin.x, + .y = pin.y, + }; + } + + fn toPin(self: CGridRef) ?PageList.Pin { + return .{ + .node = self.node orelse return null, + .x = self.x, + .y = self.y, + }; + } +}; + +pub fn grid_ref_cell( + ref: *const CGridRef, + out: ?*cell_c.CCell, +) callconv(.c) Result { + const p = ref.toPin() orelse return .invalid_value; + if (out) |o| o.* = @bitCast(p.rowAndCell().cell.*); + return .success; +} + +pub fn grid_ref_row( + ref: *const CGridRef, + out: ?*row_c.CRow, +) callconv(.c) Result { + const p = ref.toPin() orelse return .invalid_value; + if (out) |o| o.* = @bitCast(p.rowAndCell().row.*); + return .success; +} + +test "grid_ref_cell null node" { + const ref = CGridRef{}; + var out: cell_c.CCell = undefined; + try testing.expectEqual(Result.invalid_value, grid_ref_cell(&ref, &out)); +} + +test "grid_ref_row null node" { + const ref = CGridRef{}; + var out: row_c.CRow = undefined; + try testing.expectEqual(Result.invalid_value, grid_ref_row(&ref, &out)); +} + +test "grid_ref_cell null out" { + const ref = CGridRef{}; + try testing.expectEqual(Result.invalid_value, grid_ref_cell(&ref, null)); +} + +test "grid_ref_row null out" { + const ref = CGridRef{}; + try testing.expectEqual(Result.invalid_value, grid_ref_row(&ref, null)); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index b1b2c92ef..962e616cc 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -109,11 +109,16 @@ pub const terminal_scroll_viewport = terminal.scroll_viewport; pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; -pub const terminal_cell = terminal.cell; +pub const terminal_grid_ref = terminal.grid_ref; + +const grid_ref = @import("grid_ref.zig"); +pub const grid_ref_cell = grid_ref.grid_ref_cell; +pub const grid_ref_row = grid_ref.grid_ref_row; test { _ = cell; _ = color; + _ = grid_ref; _ = row; _ = focus; _ = formatter; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index fe3a5e149..ad560dc1a 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -11,6 +11,7 @@ const point = @import("../point.zig"); const size = @import("../size.zig"); const cell_c = @import("cell.zig"); const row_c = @import("row.zig"); +const grid_ref_c = @import("grid_ref.zig"); const style_c = @import("style.zig"); const Result = @import("result.zig").Result; @@ -210,11 +211,10 @@ fn getTyped( return .success; } -pub fn cell( +pub fn grid_ref( terminal_: Terminal, pt: point.Point.C, - out_cell: ?*cell_c.CCell, - out_row: ?*row_c.CRow, + out_ref: ?*grid_ref_c.CGridRef, ) callconv(.c) Result { const t = terminal_ orelse return .invalid_value; const zig_pt: point.Point = switch (pt.tag) { @@ -223,10 +223,9 @@ pub fn cell( .screen => .{ .screen = pt.value.screen }, .history => .{ .history = pt.value.history }, }; - const result = t.screens.active.pages.getCell(zig_pt) orelse + const p = t.screens.active.pages.pin(zig_pt) orelse return .invalid_value; - if (out_cell) |p| p.* = @bitCast(result.cell.*); - if (out_row) |p| p.* = @bitCast(result.row.*); + if (out_ref) |out| out.* = grid_ref_c.CGridRef.fromPin(p); return .success; } @@ -636,7 +635,7 @@ test "get invalid" { try testing.expectEqual(Result.invalid_value, get(t, .invalid, null)); } -test "cell" { +test "grid_ref" { var t: Terminal = null; try testing.expectEqual(Result.success, new( &lib_alloc.test_allocator, @@ -651,29 +650,30 @@ test "cell" { vt_write(t, "Hello", 5); - var out_cell: cell_c.CCell = undefined; - var out_row: row_c.CRow = undefined; - try testing.expectEqual(Result.success, cell(t, .{ + var out_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ .tag = .active, .value = .{ .active = .{ .x = 0, .y = 0 } }, - }, &out_cell, &out_row)); + }, &out_ref)); + + // Extract cell from grid ref and verify it contains 'H' + var out_cell: cell_c.CCell = undefined; + try testing.expectEqual(Result.success, grid_ref_c.grid_ref_cell(&out_ref, &out_cell)); - // Verify the cell contains 'H' var cp: u32 = 0; try testing.expectEqual(Result.success, cell_c.get(out_cell, .codepoint, @ptrCast(&cp))); try testing.expectEqual(@as(u32, 'H'), cp); } -test "cell null terminal" { - var out_cell: cell_c.CCell = undefined; - var out_row: row_c.CRow = undefined; - try testing.expectEqual(Result.invalid_value, cell(null, .{ +test "grid_ref null terminal" { + var out_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.invalid_value, grid_ref(null, .{ .tag = .active, .value = .{ .active = .{ .x = 0, .y = 0 } }, - }, &out_cell, &out_row)); + }, &out_ref)); } -test "cell out of bounds" { +test "grid_ref out of bounds" { var t: Terminal = null; try testing.expectEqual(Result.success, new( &lib_alloc.test_allocator, @@ -686,10 +686,9 @@ test "cell out of bounds" { )); defer free(t); - var out_cell: cell_c.CCell = undefined; - var out_row: row_c.CRow = undefined; - try testing.expectEqual(Result.invalid_value, cell(t, .{ + var out_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.invalid_value, grid_ref(t, .{ .tag = .active, .value = .{ .active = .{ .x = 100, .y = 0 } }, - }, &out_cell, &out_row)); + }, &out_ref)); } From 549824842dd72b2e77caf0d443a3b3951480c764 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Mar 2026 19:45:19 -0700 Subject: [PATCH 09/10] vt: add style and grapheme accessors Add ghostty_grid_ref_style and ghostty_grid_ref_graphemes to the grid ref C API, allowing callers to extract the full style and grapheme cluster directly from a grid reference without manually resolving the page internals. --- include/ghostty/vt/grid_ref.h | 41 ++++++++++++++++++ include/ghostty/vt/style.h | 10 +++++ src/lib_vt.zig | 2 + src/terminal/c/grid_ref.zig | 79 +++++++++++++++++++++++++++++++++++ src/terminal/c/main.zig | 2 + 5 files changed, 134 insertions(+) diff --git a/include/ghostty/vt/grid_ref.h b/include/ghostty/vt/grid_ref.h index 51260e35f..0b196dce5 100644 --- a/include/ghostty/vt/grid_ref.h +++ b/include/ghostty/vt/grid_ref.h @@ -12,6 +12,7 @@ #include #include #include +#include #ifdef __cplusplus extern "C" { @@ -77,6 +78,46 @@ GhosttyResult ghostty_grid_ref_cell(const GhosttyGridRef *ref, GhosttyResult ghostty_grid_ref_row(const GhosttyGridRef *ref, GhosttyRow *out_row); +/** + * Get the grapheme cluster codepoints for the cell at the grid reference's + * position. + * + * Writes the full grapheme cluster (the cell's primary codepoint followed by + * any combining codepoints) into the provided buffer. If the cell has no text, + * out_len is set to 0 and GHOSTTY_SUCCESS is returned. + * + * If the buffer is too small (or NULL), the function returns + * GHOSTTY_OUT_OF_SPACE and writes the required number of codepoints to + * out_len. The caller can then retry with a sufficiently sized buffer. + * + * @param ref Pointer to the grid reference + * @param buf Output buffer of uint32_t codepoints (may be NULL) + * @param buf_len Number of uint32_t elements in the buffer + * @param[out] out_len On success, the number of codepoints written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size in codepoints. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL, GHOSTTY_OUT_OF_SPACE if the buffer is too small + * + * @ingroup grid_ref + */ +GhosttyResult ghostty_grid_ref_graphemes(const GhosttyGridRef *ref, + uint32_t *buf, + size_t buf_len, + size_t *out_len); + +/** + * Get the style of the cell at the grid reference's position. + * + * @param ref Pointer to the grid reference + * @param[out] out_style On success, set to the cell's style (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL + * + * @ingroup grid_ref + */ +GhosttyResult ghostty_grid_ref_style(const GhosttyGridRef *ref, + GhosttyStyle *out_style); + /** @} */ #ifdef __cplusplus diff --git a/include/ghostty/vt/style.h b/include/ghostty/vt/style.h index 603f88d01..ac5cd2ad6 100644 --- a/include/ghostty/vt/style.h +++ b/include/ghostty/vt/style.h @@ -28,6 +28,16 @@ extern "C" { * @{ */ +/** + * Style identifier type. + * + * Used to look up the full style from a grid reference. + * Obtain this from a cell via GHOSTTY_CELL_DATA_STYLE_ID. + * + * @ingroup style + */ +typedef uint16_t GhosttyStyleId; + /** * Style color tags. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 1b17eed99..d6cfe49ea 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -199,6 +199,8 @@ comptime { @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" }); @export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" }); + @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); + @export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/c/grid_ref.zig b/src/terminal/c/grid_ref.zig index 592ee135f..d6afb0c45 100644 --- a/src/terminal/c/grid_ref.zig +++ b/src/terminal/c/grid_ref.zig @@ -3,8 +3,10 @@ const testing = std.testing; const page = @import("../page.zig"); const PageList = @import("../PageList.zig"); const size = @import("../size.zig"); +const stylepkg = @import("../style.zig"); const cell_c = @import("cell.zig"); const row_c = @import("row.zig"); +const style_c = @import("style.zig"); const Result = @import("result.zig").Result; /// C: GhosttyGridRef @@ -53,6 +55,58 @@ pub fn grid_ref_row( return .success; } +pub fn grid_ref_graphemes( + ref: *const CGridRef, + out_buf: ?[*]u32, + buf_len: usize, + out_len: *usize, +) callconv(.c) Result { + const p = ref.toPin() orelse return .invalid_value; + const cell = p.rowAndCell().cell; + + if (!cell.hasText()) { + out_len.* = 0; + return .success; + } + + const cp = cell.codepoint(); + const extra = if (cell.hasGrapheme()) p.grapheme(cell) else null; + const total = 1 + if (extra) |e| e.len else 0; + + if (out_buf == null or buf_len < total) { + out_len.* = total; + return .out_of_space; + } + + const buf = out_buf.?[0..buf_len]; + buf[0] = cp; + if (extra) |e| for (e, 1..) |c, i| { + buf[i] = c; + }; + + out_len.* = total; + return .success; +} + +pub fn grid_ref_style( + ref: *const CGridRef, + out: ?*style_c.Style, +) callconv(.c) Result { + const p = ref.toPin() orelse return .invalid_value; + if (out) |o| { + const cell = p.rowAndCell().cell; + if (cell.style_id == stylepkg.default_id) { + o.* = .fromStyle(.{}); + } else { + o.* = .fromStyle(p.node.data.styles.get( + p.node.data.memory, + cell.style_id, + ).*); + } + } + return .success; +} + test "grid_ref_cell null node" { const ref = CGridRef{}; var out: cell_c.CCell = undefined; @@ -74,3 +128,28 @@ test "grid_ref_row null out" { const ref = CGridRef{}; try testing.expectEqual(Result.invalid_value, grid_ref_row(&ref, null)); } + +test "grid_ref_graphemes null node" { + const ref = CGridRef{}; + var len: usize = undefined; + try testing.expectEqual(Result.invalid_value, grid_ref_graphemes(&ref, null, 0, &len)); +} + +test "grid_ref_graphemes null buf returns out_of_space" { + const ref = CGridRef{}; + var len: usize = undefined; + // With null node this returns invalid_value before checking the buffer, + // so we can only test null node here. Full buffer tests require a real page. + try testing.expectEqual(Result.invalid_value, grid_ref_graphemes(&ref, null, 0, &len)); +} + +test "grid_ref_style null node" { + const ref = CGridRef{}; + var out: style_c.Style = undefined; + try testing.expectEqual(Result.invalid_value, grid_ref_style(&ref, &out)); +} + +test "grid_ref_style null out" { + const ref = CGridRef{}; + try testing.expectEqual(Result.invalid_value, grid_ref_style(&ref, null)); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 962e616cc..8964610df 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -114,6 +114,8 @@ pub const terminal_grid_ref = terminal.grid_ref; const grid_ref = @import("grid_ref.zig"); pub const grid_ref_cell = grid_ref.grid_ref_cell; pub const grid_ref_row = grid_ref.grid_ref_row; +pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes; +pub const grid_ref_style = grid_ref.grid_ref_style; test { _ = cell; From 93c597ce6bb36179065d93a465d6ee679f7472f7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Mar 2026 19:53:52 -0700 Subject: [PATCH 10/10] example: add grid reference traversal example Add a c-vt-grid-ref example that demonstrates the terminal and grid reference APIs end-to-end. The example creates a small 10x3 terminal, writes text with mixed styles via VT sequences, then iterates over every cell in the active area using ghostty_terminal_grid_ref. For each cell it extracts the codepoint, and for each row it inspects the wrap flag and the style bold attribute. The grid_ref.h defgroup gains a @snippet reference to the new example, and vt.h gets the corresponding @example entry and @ref listing. --- example/c-vt-grid-traverse/README.md | 19 ++++++ example/c-vt-grid-traverse/build.zig | 42 ++++++++++++ example/c-vt-grid-traverse/build.zig.zon | 24 +++++++ example/c-vt-grid-traverse/src/main.c | 85 ++++++++++++++++++++++++ include/ghostty/vt.h | 6 ++ include/ghostty/vt/grid_ref.h | 4 ++ 6 files changed, 180 insertions(+) create mode 100644 example/c-vt-grid-traverse/README.md create mode 100644 example/c-vt-grid-traverse/build.zig create mode 100644 example/c-vt-grid-traverse/build.zig.zon create mode 100644 example/c-vt-grid-traverse/src/main.c diff --git a/example/c-vt-grid-traverse/README.md b/example/c-vt-grid-traverse/README.md new file mode 100644 index 000000000..f9a15851a --- /dev/null +++ b/example/c-vt-grid-traverse/README.md @@ -0,0 +1,19 @@ +# Example: `ghostty-vt` Grid Traversal + +This contains a simple example of how to use the `ghostty-vt` terminal and +grid reference APIs to create a terminal, write content into it, and then +traverse the entire grid cell-by-cell using grid refs to inspect codepoints, +row state, and styles. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-grid-traverse/build.zig b/example/c-vt-grid-traverse/build.zig new file mode 100644 index 000000000..caf174028 --- /dev/null +++ b/example/c-vt-grid-traverse/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_grid_traverse", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-grid-traverse/build.zig.zon b/example/c-vt-grid-traverse/build.zig.zon new file mode 100644 index 000000000..21b6cea18 --- /dev/null +++ b/example/c-vt-grid-traverse/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_grid_traverse, + .version = "0.0.0", + .fingerprint = 0xf694dd12db9be040, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-grid-traverse/src/main.c b/example/c-vt-grid-traverse/src/main.c new file mode 100644 index 000000000..f07169eb6 --- /dev/null +++ b/example/c-vt-grid-traverse/src/main.c @@ -0,0 +1,85 @@ +#include +#include +#include +#include + +//! [grid-ref-traverse] +int main() { + // Create a small terminal + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 10, + .rows = 3, + .max_scrollback = 0, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + // Write some content so the grid has interesting data + const char *text = "Hello!\r\n" // Row 0: H e l l o ! + "World\r\n" // Row 1: W o r l d + "\033[1mBold"; // Row 2: B o l d (bold style) + ghostty_terminal_vt_write( + terminal, (const uint8_t *)text, strlen(text)); + + // Get terminal dimensions + uint16_t cols, rows; + ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_COLS, &cols); + ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_ROWS, &rows); + + // Traverse the entire grid using grid refs + for (uint16_t row = 0; row < rows; row++) { + printf("Row %u: ", row); + for (uint16_t col = 0; col < cols; col++) { + // Resolve the point to a grid reference + GhosttyGridRef ref = GHOSTTY_INIT_SIZED(GhosttyGridRef); + GhosttyPoint pt = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = col, .y = row } }, + }; + result = ghostty_terminal_grid_ref(terminal, pt, &ref); + assert(result == GHOSTTY_SUCCESS); + + // Read the cell from the grid ref + GhosttyCell cell; + result = ghostty_grid_ref_cell(&ref, &cell); + assert(result == GHOSTTY_SUCCESS); + + // Check if the cell has text + bool has_text = false; + ghostty_cell_get(cell, GHOSTTY_CELL_DATA_HAS_TEXT, &has_text); + + if (has_text) { + uint32_t codepoint = 0; + ghostty_cell_get(cell, GHOSTTY_CELL_DATA_CODEPOINT, &codepoint); + printf("%c", (char)codepoint); + } else { + printf("."); + } + } + + // Also inspect the row for wrap state + GhosttyGridRef ref = GHOSTTY_INIT_SIZED(GhosttyGridRef); + GhosttyPoint pt = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = 0, .y = row } }, + }; + ghostty_terminal_grid_ref(terminal, pt, &ref); + + GhosttyRow grid_row; + ghostty_grid_ref_row(&ref, &grid_row); + + bool wrap = false; + ghostty_row_get(grid_row, GHOSTTY_ROW_DATA_WRAP, &wrap); + printf(" (wrap=%s", wrap ? "true" : "false"); + + // Check the style of the first cell with text + GhosttyStyle style = GHOSTTY_INIT_SIZED(GhosttyStyle); + ghostty_grid_ref_style(&ref, &style); + printf(", bold=%s)\n", style.bold ? "true" : "false"); + } + + ghostty_terminal_free(terminal); + return 0; +} +//! [grid-ref-traverse] diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 8a53c4bd0..a3d0ec57d 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -50,6 +50,7 @@ * - @ref c-vt-paste/src/main.c - Paste safety check example * - @ref c-vt-sgr/src/main.c - SGR parser example * - @ref c-vt-formatter/src/main.c - Terminal formatter example + * - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs * */ @@ -84,6 +85,11 @@ * contents as plain text. */ +/** @example c-vt-grid-traverse/src/main.c + * This example demonstrates how to traverse the entire terminal grid using + * grid refs to inspect cell codepoints, row wrap state, and cell styles. + */ + #ifndef GHOSTTY_VT_H #define GHOSTTY_VT_H diff --git a/include/ghostty/vt/grid_ref.h b/include/ghostty/vt/grid_ref.h index 0b196dce5..29ecda7b5 100644 --- a/include/ghostty/vt/grid_ref.h +++ b/include/ghostty/vt/grid_ref.h @@ -35,6 +35,10 @@ extern "C" { * built to sustain the framerates needed for rendering large screens. * Use the render state API for that. * + * ## Example + * + * @snippet c-vt-grid-traverse/src/main.c grid-ref-traverse + * * @{ */