libghostty: expose Kitty image configs, decode png callback from C API (#12144)

This exposes the APIs necessary to enable Kitty image protocol parsing
and state from the C API.

* You can now set the PNG decoder via the `ghostty_sys_set` API.
* You can set Kitty image configs via `ghostty_terminal_set` API.
* An example showing this working has been added.
* **You cannot yet query Kitty images for metadata or rendering.** I'm
going to follow that up in a separate PR.
This commit is contained in:
Mitchell Hashimoto
2026-04-06 08:46:21 -07:00
committed by GitHub
11 changed files with 634 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
# Example: `ghostty-vt` Kitty Graphics Protocol
This contains a simple example of how to use the system interface
(`ghostty_sys_set`) to install a PNG decoder callback, then send
a Kitty Graphics Protocol image via `ghostty_terminal_vt_write`.
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
```

View File

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

View File

@@ -0,0 +1,24 @@
.{
.name = .c_vt_kitty_graphics,
.version = "0.0.0",
.fingerprint = 0x432d40ecc8f15589,
.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",
},
}

View File

@@ -0,0 +1,125 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <ghostty/vt.h>
//! [kitty-graphics-decode-png]
/**
* Minimal PNG decoder callback for the sys interface.
*
* A real implementation would use a PNG library (libpng, stb_image, etc.)
* to decode the PNG data. This example uses a hardcoded 1x1 red pixel
* since we know exactly what image we're sending.
*
* WARNING: This is only an example for providing a callback, it DOES NOT
* actually decode the PNG it is passed. It hardcodes a response.
*/
bool decode_png(void* userdata,
const GhosttyAllocator* allocator,
const uint8_t* data,
size_t data_len,
GhosttySysImage* out) {
int* count = (int*)userdata;
(*count)++;
printf(" decode_png called (size=%zu, call #%d)\n", data_len, *count);
/* Allocate RGBA pixel data through the provided allocator. */
const size_t pixel_len = 4; /* 1x1 RGBA */
uint8_t* pixels = ghostty_alloc(allocator, pixel_len);
if (!pixels) return false;
/* Fill with red (R=255, G=0, B=0, A=255). */
pixels[0] = 255;
pixels[1] = 0;
pixels[2] = 0;
pixels[3] = 255;
out->width = 1;
out->height = 1;
out->data = pixels;
out->data_len = pixel_len;
return true;
}
//! [kitty-graphics-decode-png]
//! [kitty-graphics-write-pty]
/**
* write_pty callback to capture terminal responses.
*
* The Kitty graphics protocol sends an APC response back to the pty
* when an image is loaded (unless suppressed with q=2).
*/
void on_write_pty(GhosttyTerminal terminal,
void* userdata,
const uint8_t* data,
size_t len) {
(void)terminal;
(void)userdata;
printf(" response (%zu bytes): ", len);
fwrite(data, 1, len, stdout);
printf("\n");
}
//! [kitty-graphics-write-pty]
//! [kitty-graphics-main]
int main() {
/* Install the PNG decoder via the sys interface. */
int decode_count = 0;
ghostty_sys_set(GHOSTTY_SYS_OPT_USERDATA, &decode_count);
ghostty_sys_set(GHOSTTY_SYS_OPT_DECODE_PNG, (const void*)decode_png);
/* Create a terminal with Kitty graphics enabled. */
GhosttyTerminal terminal = NULL;
GhosttyTerminalOptions opts = {
.cols = 80,
.rows = 24,
.max_scrollback = 0,
};
if (ghostty_terminal_new(NULL, &terminal, opts) != GHOSTTY_SUCCESS) {
fprintf(stderr, "Failed to create terminal\n");
return 1;
}
/* Set a storage limit to enable Kitty graphics. */
uint64_t storage_limit = 64 * 1024 * 1024; /* 64 MiB */
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT,
&storage_limit);
/* Install write_pty to see the protocol response. */
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_WRITE_PTY,
(const void*)on_write_pty);
/*
* Send a Kitty graphics command with an inline 1x1 PNG image.
*
* The escape sequence is:
* ESC _G a=T,f=100,q=1; <base64 PNG data> ESC \
*
* Where:
* a=T — transmit and display
* f=100 — PNG format
* q=1 — request a response (q=0 would suppress it)
*/
printf("Sending Kitty graphics PNG image:\n");
const char* kitty_cmd =
"\x1b_Ga=T,f=100,q=1;"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA"
"DUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="
"\x1b\\";
ghostty_terminal_vt_write(terminal, (const uint8_t*)kitty_cmd,
strlen(kitty_cmd));
printf("PNG decode calls: %d\n", decode_count);
/* Clean up. */
ghostty_terminal_free(terminal);
/* Clear the sys callbacks. */
ghostty_sys_set(GHOSTTY_SYS_OPT_DECODE_PNG, NULL);
ghostty_sys_set(GHOSTTY_SYS_OPT_USERDATA, NULL);
return 0;
}
//! [kitty-graphics-main]

View File

@@ -98,6 +98,11 @@
* grid refs to inspect cell codepoints, row wrap state, and cell styles.
*/
/** @example c-vt-kitty-graphics/src/main.c
* This example demonstrates how to use the system interface to install a
* PNG decoder callback and send a Kitty Graphics Protocol image.
*/
#ifndef GHOSTTY_VT_H
#define GHOSTTY_VT_H
@@ -118,6 +123,7 @@ extern "C" {
#include <ghostty/vt/osc.h>
#include <ghostty/vt/sgr.h>
#include <ghostty/vt/style.h>
#include <ghostty/vt/sys.h>
#include <ghostty/vt/key.h>
#include <ghostty/vt/modes.h>
#include <ghostty/vt/mouse.h>

133
include/ghostty/vt/sys.h Normal file
View File

@@ -0,0 +1,133 @@
/**
* @file sys.h
*
* System interface - runtime-swappable implementations for external dependencies.
*/
#ifndef GHOSTTY_VT_SYS_H
#define GHOSTTY_VT_SYS_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <ghostty/vt/types.h>
#include <ghostty/vt/allocator.h>
/** @defgroup sys System Interface
*
* Runtime-swappable function pointers for operations that depend on
* external implementations (e.g. image decoding).
*
* These are process-global settings that must be configured at startup
* before any terminal functionality that depends on them is used.
* Setting these enables various optional features of the terminal. For
* example, setting a PNG decoder enables PNG image support in the Kitty
* Graphics Protocol.
*
* Use ghostty_sys_set() with a `GhosttySysOption` to install or clear
* an implementation. Passing NULL as the value clears the implementation
* and disables the corresponding feature.
*
* ## Example
*
* ### Defining a PNG decode callback
* @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-decode-png
*
* ### Installing the callback and sending a PNG image
* @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-main
*
* @{
*/
#ifdef __cplusplus
extern "C" {
#endif
/**
* Result of decoding an image.
*
* The `data` buffer must be allocated through the allocator provided to
* the decode callback. The library takes ownership and will free it
* with the same allocator.
*/
typedef struct {
/** Image width in pixels. */
uint32_t width;
/** Image height in pixels. */
uint32_t height;
/** Pointer to the decoded RGBA pixel data. */
uint8_t* data;
/** Length of the pixel data in bytes. */
size_t data_len;
} GhosttySysImage;
/**
* Callback type for PNG decoding.
*
* Decodes raw PNG data into RGBA pixels. The output pixel data must be
* allocated through the provided allocator. The library takes ownership
* of the buffer and will free it with the same allocator.
*
* @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA
* @param allocator The allocator to use for the output pixel buffer
* @param data Pointer to the raw PNG data
* @param data_len Length of the raw PNG data in bytes
* @param[out] out On success, filled with the decoded image
* @return true on success, false on failure
*/
typedef bool (*GhosttySysDecodePngFn)(
void* userdata,
const GhosttyAllocator* allocator,
const uint8_t* data,
size_t data_len,
GhosttySysImage* out);
/**
* System option identifiers for ghostty_sys_set().
*/
typedef enum {
/**
* Set the userdata pointer passed to all sys callbacks.
*
* Input type: void* (or NULL)
*/
GHOSTTY_SYS_OPT_USERDATA = 0,
/**
* Set the PNG decode function.
*
* When set, the terminal can accept PNG images via the Kitty
* Graphics Protocol. When cleared (NULL value), PNG decoding is
* unsupported and PNG image data will be rejected.
*
* Input type: GhosttySysDecodePngFn (function pointer, or NULL)
*/
GHOSTTY_SYS_OPT_DECODE_PNG = 1,
} GhosttySysOption;
/**
* Set a system-level option.
*
* Configures a process-global implementation function. These should be
* set once at startup before using any terminal functionality that
* depends on them.
*
* @param option The option to set
* @param value Pointer to the value (type depends on the option),
* or NULL to clear it
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the
* option is not recognized
*/
GHOSTTY_API GhosttyResult ghostty_sys_set(GhosttySysOption option,
const void* value);
#ifdef __cplusplus
}
#endif
/** @} */
#endif /* GHOSTTY_VT_SYS_H */

View File

@@ -534,6 +534,49 @@ typedef enum {
* Input type: GhosttyColorRgb[256]*
*/
GHOSTTY_TERMINAL_OPT_COLOR_PALETTE = 14,
/**
* Set the Kitty image storage limit in bytes.
*
* Applied to all initialized screens (primary and alternate).
* A value of zero disables the Kitty graphics protocol entirely,
* deleting all stored images and placements. A NULL value pointer
* is equivalent to zero (disables). Has no effect when Kitty graphics
* are disabled at build time.
*
* Input type: uint64_t*
*/
GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT = 15,
/**
* Enable or disable Kitty image loading via the file medium.
*
* A NULL value pointer is a no-op. Has no effect when Kitty graphics
* are disabled at build time.
*
* Input type: bool*
*/
GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_FILE = 16,
/**
* Enable or disable Kitty image loading via the temporary file medium.
*
* A NULL value pointer is a no-op. Has no effect when Kitty graphics
* are disabled at build time.
*
* Input type: bool*
*/
GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE = 17,
/**
* Enable or disable Kitty image loading via the shared memory medium.
*
* A NULL value pointer is a no-op. Has no effect when Kitty graphics
* are disabled at build time.
*
* Input type: bool*
*/
GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_SHARED_MEM = 18,
} GhosttyTerminalOption;
/**
@@ -756,6 +799,46 @@ typedef enum {
* Output type: GhosttyColorRgb[256] *
*/
GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT = 25,
/**
* The Kitty image storage limit in bytes for the active screen.
*
* A value of zero means the Kitty graphics protocol is disabled.
* Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.
*
* Output type: uint64_t *
*/
GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_STORAGE_LIMIT = 26,
/**
* Whether the file medium is enabled for Kitty image loading on the
* active screen.
*
* Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.
*
* Output type: bool *
*/
GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_FILE = 27,
/**
* Whether the temporary file medium is enabled for Kitty image loading
* on the active screen.
*
* Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.
*
* Output type: bool *
*/
GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_TEMP_FILE = 28,
/**
* Whether the shared memory medium is enabled for Kitty image loading
* on the active screen.
*
* Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.
*
* Output type: bool *
*/
GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_SHARED_MEM = 29,
} GhosttyTerminalData;
/**

View File

@@ -189,6 +189,7 @@ 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.sys_set, .{ .name = "ghostty_sys_set" });
@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" });

View File

@@ -22,6 +22,7 @@ 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");
pub const sys = @import("sys.zig");
pub const terminal = @import("terminal.zig");
// The full C API, unexported.
@@ -132,6 +133,8 @@ pub const row_get = row.get;
pub const style_default = style.default_style;
pub const style_is_default = style.style_is_default;
pub const sys_set = sys.set;
pub const terminal_new = terminal.new;
pub const terminal_free = terminal.free;
pub const terminal_reset = terminal.reset;
@@ -173,6 +176,7 @@ test {
_ = sgr;
_ = size_report;
_ = style;
_ = sys;
_ = terminal;
_ = types;

137
src/terminal/c/sys.zig Normal file
View File

@@ -0,0 +1,137 @@
const std = @import("std");
const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
const terminal_sys = @import("../sys.zig");
const Result = @import("result.zig").Result;
/// C: GhosttySysImage
pub const Image = extern struct {
width: u32,
height: u32,
data: ?[*]u8,
data_len: usize,
};
/// C: GhosttySysDecodePngFn
pub const DecodePngFn = *const fn (
?*anyopaque,
*const CAllocator,
[*]const u8,
usize,
*Image,
) callconv(lib.calling_conv) bool;
/// C: GhosttySysOption
pub const Option = enum(c_int) {
userdata = 0,
decode_png = 1,
pub fn InType(comptime self: Option) type {
return switch (self) {
.userdata => ?*const anyopaque,
.decode_png => ?DecodePngFn,
};
}
};
/// Global state for the sys interface so we can call through to the C
/// callbacks from Zig.
const Global = struct {
userdata: ?*anyopaque = null,
decode_png: ?DecodePngFn = null,
};
/// Global state for the C sys interface.
var global: Global = .{};
/// Zig-compatible wrapper that calls through to the stored C callback.
/// The C callback allocates the pixel data through the provided allocator,
/// so we can take ownership directly.
fn decodePngWrapper(
alloc: std.mem.Allocator,
data: []const u8,
) terminal_sys.DecodeError!terminal_sys.Image {
const func = global.decode_png orelse return error.InvalidData;
const c_alloc = CAllocator.fromZig(&alloc);
var out: Image = undefined;
if (!func(global.userdata, &c_alloc, data.ptr, data.len, &out)) return error.InvalidData;
const result_data = out.data orelse return error.InvalidData;
return .{
.width = out.width,
.height = out.height,
.data = result_data[0..out.data_len],
};
}
pub fn set(
option: Option,
value: ?*const anyopaque,
) callconv(lib.calling_conv) Result {
if (comptime std.debug.runtime_safety) {
_ = std.meta.intToEnum(Option, @intFromEnum(option)) catch {
return .invalid_value;
};
}
return switch (option) {
inline else => |comptime_option| setTyped(
comptime_option,
@ptrCast(@alignCast(value)),
),
};
}
fn setTyped(
comptime option: Option,
value: option.InType(),
) Result {
switch (option) {
.userdata => global.userdata = @constCast(value),
.decode_png => {
global.decode_png = value;
terminal_sys.decode_png = if (value != null) &decodePngWrapper else null;
},
}
return .success;
}
test "set decode_png with null clears" {
// Start from a known state.
global.decode_png = null;
terminal_sys.decode_png = null;
try std.testing.expectEqual(Result.success, set(.decode_png, null));
try std.testing.expect(terminal_sys.decode_png == null);
}
test "set decode_png installs wrapper" {
const S = struct {
fn decode(_: ?*anyopaque, _: *const CAllocator, _: [*]const u8, _: usize, out: *Image) callconv(lib.calling_conv) bool {
out.* = .{ .width = 1, .height = 1, .data = null, .data_len = 0 };
return true;
}
};
try std.testing.expectEqual(Result.success, set(
.decode_png,
@ptrCast(&S.decode),
));
try std.testing.expect(terminal_sys.decode_png != null);
// Clear it again.
try std.testing.expectEqual(Result.success, set(.decode_png, null));
try std.testing.expect(terminal_sys.decode_png == null);
}
test "set userdata" {
var data: u32 = 42;
try std.testing.expectEqual(Result.success, set(.userdata, @ptrCast(&data)));
try std.testing.expect(global.userdata == @as(?*anyopaque, @ptrCast(&data)));
// Clear it.
try std.testing.expectEqual(Result.success, set(.userdata, null));
try std.testing.expect(global.userdata == null);
}

View File

@@ -1,5 +1,6 @@
const std = @import("std");
const testing = std.testing;
const build_options = @import("terminal_options");
const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
const ZigTerminal = @import("../Terminal.zig");
@@ -304,6 +305,10 @@ pub const Option = enum(c_int) {
color_background = 12,
color_cursor = 13,
color_palette = 14,
kitty_image_storage_limit = 15,
kitty_image_medium_file = 16,
kitty_image_medium_temp_file = 17,
kitty_image_medium_shared_mem = 18,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
@@ -320,6 +325,11 @@ pub const Option = enum(c_int) {
.title, .pwd => ?*const lib.String,
.color_foreground, .color_background, .color_cursor => ?*const color.RGB.C,
.color_palette => ?*const color.PaletteC,
.kitty_image_storage_limit => ?*const u64,
.kitty_image_medium_file,
.kitty_image_medium_temp_file,
.kitty_image_medium_shared_mem,
=> ?*const bool,
};
}
};
@@ -388,6 +398,32 @@ fn setTyped(
);
wrapper.terminal.flags.dirty.palette = true;
},
.kitty_image_storage_limit => {
if (comptime !build_options.kitty_graphics) return .success;
const limit: usize = if (value) |v| @intCast(v.*) else 0;
var it = wrapper.terminal.screens.all.iterator();
while (it.next()) |entry| {
const screen = entry.value.*;
screen.kitty_images.setLimit(screen.alloc, screen, limit) catch return .out_of_memory;
}
},
.kitty_image_medium_file,
.kitty_image_medium_temp_file,
.kitty_image_medium_shared_mem,
=> {
if (comptime !build_options.kitty_graphics) return .success;
const val = (value orelse return .success).*;
var it = wrapper.terminal.screens.all.iterator();
while (it.next()) |entry| {
const screen = entry.value.*;
switch (option) {
.kitty_image_medium_file => screen.kitty_images.image_limits.file = val,
.kitty_image_medium_temp_file => screen.kitty_images.image_limits.temporary_file = val,
.kitty_image_medium_shared_mem => screen.kitty_images.image_limits.shared_memory = val,
else => unreachable,
}
}
},
}
return .success;
}
@@ -513,6 +549,10 @@ pub const TerminalData = enum(c_int) {
color_background_default = 23,
color_cursor_default = 24,
color_palette_default = 25,
kitty_image_storage_limit = 26,
kitty_image_medium_file = 27,
kitty_image_medium_temp_file = 28,
kitty_image_medium_shared_mem = 29,
/// Output type expected for querying the data of the given kind.
pub fn OutType(comptime self: TerminalData) type {
@@ -535,6 +575,11 @@ pub const TerminalData = enum(c_int) {
.color_cursor_default,
=> color.RGB.C,
.color_palette, .color_palette_default => color.PaletteC,
.kitty_image_storage_limit => u64,
.kitty_image_medium_file,
.kitty_image_medium_temp_file,
.kitty_image_medium_shared_mem,
=> bool,
};
}
};
@@ -603,6 +648,22 @@ fn getTyped(
.color_cursor_default => out.* = (t.colors.cursor.default orelse return .no_value).cval(),
.color_palette => out.* = color.paletteCval(&t.colors.palette.current),
.color_palette_default => out.* = color.paletteCval(&t.colors.palette.original),
.kitty_image_storage_limit => {
if (comptime !build_options.kitty_graphics) return .no_value;
out.* = @intCast(t.screens.active.kitty_images.total_limit);
},
.kitty_image_medium_file => {
if (comptime !build_options.kitty_graphics) return .no_value;
out.* = t.screens.active.kitty_images.image_limits.file;
},
.kitty_image_medium_temp_file => {
if (comptime !build_options.kitty_graphics) return .no_value;
out.* = t.screens.active.kitty_images.image_limits.temporary_file;
},
.kitty_image_medium_shared_mem => {
if (comptime !build_options.kitty_graphics) return .no_value;
out.* = t.screens.active.kitty_images.image_limits.shared_memory;
},
}
return .success;