libghostty: add kitty graphics placement iterator API

Add a C API for iterating over Kitty graphics placements via the
new GhosttyKittyGraphics opaque handle. The API follows the same
pattern as the render state row iterator: allocate an iterator with
ghostty_kitty_graphics_placement_iterator_new, populate it from a
graphics handle via ghostty_kitty_graphics_get with the
PLACEMENT_ITERATOR data kind, advance with
ghostty_kitty_graphics_placement_next, and query per-placement
fields with ghostty_kitty_graphics_placement_get.
This commit is contained in:
Mitchell Hashimoto
2026-04-06 09:06:06 -07:00
parent e89b2c88f3
commit 9033f6f8ce
5 changed files with 576 additions and 2 deletions

View File

@@ -7,6 +7,11 @@
#ifndef GHOSTTY_VT_KITTY_GRAPHICS_H
#define GHOSTTY_VT_KITTY_GRAPHICS_H
#include <stdbool.h>
#include <stdint.h>
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/types.h>
#ifdef __cplusplus
extern "C" {
#endif
@@ -14,7 +19,7 @@ extern "C" {
/** @defgroup kitty_graphics Kitty Graphics
*
* Opaque handle to the Kitty graphics image storage associated with a
* terminal screen.
* terminal screen, and an iterator for inspecting placements.
*
* @{
*/
@@ -31,6 +36,204 @@ extern "C" {
*/
typedef struct GhosttyKittyGraphicsImpl* GhosttyKittyGraphics;
/**
* Opaque handle to a Kitty graphics placement iterator.
*
* @ingroup kitty_graphics
*/
typedef struct GhosttyKittyGraphicsPlacementIteratorImpl* GhosttyKittyGraphicsPlacementIterator;
/**
* Queryable data kinds for ghostty_kitty_graphics_get().
*
* @ingroup kitty_graphics
*/
typedef enum {
/** Invalid / sentinel value. */
GHOSTTY_KITTY_GRAPHICS_DATA_INVALID = 0,
/**
* Populate a pre-allocated placement iterator with placement data from
* the storage. Iterator data is only valid as long as the underlying
* terminal is not mutated.
*
* Output type: GhosttyKittyGraphicsPlacementIterator *
*/
GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR = 1,
} GhosttyKittyGraphicsData;
/**
* Queryable data kinds for ghostty_kitty_graphics_placement_get().
*
* @ingroup kitty_graphics
*/
typedef enum {
/** Invalid / sentinel value. */
GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_INVALID = 0,
/**
* The image ID this placement belongs to.
*
* Output type: uint32_t *
*/
GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID = 1,
/**
* The placement ID.
*
* Output type: uint32_t *
*/
GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID = 2,
/**
* Whether this is a virtual placement (unicode placeholder).
*
* Output type: bool *
*/
GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_VIRTUAL = 3,
/**
* Pixel offset from the left edge of the cell.
*
* Output type: uint32_t *
*/
GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET = 4,
/**
* Pixel offset from the top edge of the cell.
*
* Output type: uint32_t *
*/
GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Y_OFFSET = 5,
/**
* Source rectangle x origin in pixels.
*
* Output type: uint32_t *
*/
GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_X = 6,
/**
* Source rectangle y origin in pixels.
*
* Output type: uint32_t *
*/
GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_Y = 7,
/**
* Source rectangle width in pixels (0 = full image width).
*
* Output type: uint32_t *
*/
GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_WIDTH = 8,
/**
* Source rectangle height in pixels (0 = full image height).
*
* Output type: uint32_t *
*/
GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_HEIGHT = 9,
/**
* Number of columns this placement occupies.
*
* Output type: uint32_t *
*/
GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_COLUMNS = 10,
/**
* Number of rows this placement occupies.
*
* Output type: uint32_t *
*/
GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_ROWS = 11,
/**
* Z-index for this placement.
*
* Output type: int32_t *
*/
GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z = 12,
} GhosttyKittyGraphicsPlacementData;
/**
* Get data from a kitty graphics storage instance.
*
* The output pointer must be of the appropriate type for the requested
* data kind.
*
* Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.
*
* @param graphics The kitty graphics handle
* @param data The type of data to extract
* @param[out] out Pointer to store the extracted data
* @return GHOSTTY_SUCCESS on success
*
* @ingroup kitty_graphics
*/
GHOSTTY_API GhosttyResult ghostty_kitty_graphics_get(
GhosttyKittyGraphics graphics,
GhosttyKittyGraphicsData data,
void* out);
/**
* Create a new placement iterator instance.
*
* All fields except the allocator are left undefined until populated
* via ghostty_kitty_graphics_get() with
* GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR.
*
* @param allocator Pointer to allocator, or NULL to use the default allocator
* @param[out] out_iterator On success, receives the created iterator handle
* @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation
* failure
*
* @ingroup kitty_graphics
*/
GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_iterator_new(
const GhosttyAllocator* allocator,
GhosttyKittyGraphicsPlacementIterator* out_iterator);
/**
* Free a placement iterator.
*
* @param iterator The iterator handle to free (may be NULL)
*
* @ingroup kitty_graphics
*/
GHOSTTY_API void ghostty_kitty_graphics_placement_iterator_free(
GhosttyKittyGraphicsPlacementIterator iterator);
/**
* Advance the placement iterator to the next placement.
*
* @param iterator The iterator handle (may be NULL)
* @return true if advanced to the next placement, false if at the end
*
* @ingroup kitty_graphics
*/
GHOSTTY_API bool ghostty_kitty_graphics_placement_next(
GhosttyKittyGraphicsPlacementIterator iterator);
/**
* Get data from the current placement in a placement iterator.
*
* Call ghostty_kitty_graphics_placement_next() at least once before
* calling this function.
*
* @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param data The data kind to query
* @param[out] out Pointer to receive the queried value
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the
* iterator is NULL or not positioned on a placement
*
* @ingroup kitty_graphics
*/
GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_get(
GhosttyKittyGraphicsPlacementIterator iterator,
GhosttyKittyGraphicsPlacementData data,
void* out);
/** @} */
#ifdef __cplusplus

View File

@@ -233,6 +233,11 @@ comptime {
@export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" });
@export(&c.terminal_get, .{ .name = "ghostty_terminal_get" });
@export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" });
@export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" });
@export(&c.kitty_graphics_placement_iterator_new, .{ .name = "ghostty_kitty_graphics_placement_iterator_new" });
@export(&c.kitty_graphics_placement_iterator_free, .{ .name = "ghostty_kitty_graphics_placement_iterator_free" });
@export(&c.kitty_graphics_placement_next, .{ .name = "ghostty_kitty_graphics_placement_next" });
@export(&c.kitty_graphics_placement_get, .{ .name = "ghostty_kitty_graphics_placement_get" });
@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" });

View File

@@ -5,7 +5,12 @@
via `lib.TaggedUnion`.
- Any functions must be updated all the way through from here to
`src/terminal/c/main.zig` to `src/lib_vt.zig` and the headers
in `include/ghostty/vt.h`.
in `include/ghostty/vt.h`. Specifically:
1. Define the function in `src/terminal/c/<module>.zig`.
2. Re-export it via a `pub const` in `src/terminal/c/main.zig`.
3. Add an `@export` call in `src/lib_vt.zig` with the
`ghostty_` prefixed symbol name.
4. Declare it in the corresponding header under `include/ghostty/vt/`.
- In `include/ghostty/vt.h`, always sort the header contents by:
(1) macros, (2) forward declarations, (3) types, (4) functions

View File

@@ -1,8 +1,364 @@
const std = @import("std");
const build_options = @import("terminal_options");
const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
const kitty_gfx = @import("../kitty/graphics_storage.zig");
const Result = @import("result.zig").Result;
/// C: GhosttyKittyGraphics
pub const KittyGraphics = if (build_options.kitty_graphics)
*kitty_gfx.ImageStorage
else
*anyopaque;
/// C: GhosttyKittyGraphicsPlacementIterator
pub const PlacementIterator = ?*PlacementIteratorWrapper;
const PlacementMap = std.AutoHashMapUnmanaged(
kitty_gfx.ImageStorage.PlacementKey,
kitty_gfx.ImageStorage.Placement,
);
const PlacementIteratorWrapper = struct {
alloc: std.mem.Allocator,
inner: PlacementMap.Iterator = undefined,
entry: ?PlacementMap.Entry = null,
};
/// C: GhosttyKittyGraphicsData
pub const Data = enum(c_int) {
invalid = 0,
placement_iterator = 1,
pub fn OutType(comptime self: Data) type {
return switch (self) {
.invalid => void,
.placement_iterator => PlacementIterator,
};
}
};
/// C: GhosttyKittyGraphicsPlacementData
pub const PlacementData = enum(c_int) {
invalid = 0,
image_id = 1,
placement_id = 2,
is_virtual = 3,
x_offset = 4,
y_offset = 5,
source_x = 6,
source_y = 7,
source_width = 8,
source_height = 9,
columns = 10,
rows = 11,
z = 12,
pub fn OutType(comptime self: PlacementData) type {
return switch (self) {
.invalid => void,
.image_id, .placement_id => u32,
.is_virtual => bool,
.x_offset,
.y_offset,
.source_x,
.source_y,
.source_width,
.source_height,
.columns,
.rows,
=> u32,
.z => i32,
};
}
};
pub fn get(
graphics_: KittyGraphics,
data: Data,
out: ?*anyopaque,
) callconv(lib.calling_conv) Result {
if (comptime !build_options.kitty_graphics) return .no_value;
return switch (data) {
.invalid => .invalid_value,
inline else => |comptime_data| getTyped(
graphics_,
comptime_data,
@ptrCast(@alignCast(out)),
),
};
}
fn getTyped(
graphics_: KittyGraphics,
comptime data: Data,
out: *data.OutType(),
) Result {
const storage = graphics_;
switch (data) {
.invalid => return .invalid_value,
.placement_iterator => {
const it = out.* orelse return .invalid_value;
it.* = .{
.alloc = it.alloc,
.inner = storage.placements.iterator(),
};
},
}
return .success;
}
pub fn placement_iterator_new(
alloc_: ?*const CAllocator,
out: *PlacementIterator,
) callconv(lib.calling_conv) Result {
const alloc = lib.alloc.default(alloc_);
const ptr = alloc.create(PlacementIteratorWrapper) catch {
out.* = null;
return .out_of_memory;
};
ptr.* = .{ .alloc = alloc };
out.* = ptr;
return .success;
}
pub fn placement_iterator_free(iter_: PlacementIterator) callconv(lib.calling_conv) void {
const iter = iter_ orelse return;
iter.alloc.destroy(iter);
}
pub fn placement_iterator_next(iter_: PlacementIterator) callconv(lib.calling_conv) bool {
if (comptime !build_options.kitty_graphics) return false;
const iter = iter_ orelse return false;
iter.entry = iter.inner.next() orelse return false;
return true;
}
pub fn placement_get(
iter_: PlacementIterator,
data: PlacementData,
out: ?*anyopaque,
) callconv(lib.calling_conv) Result {
if (comptime !build_options.kitty_graphics) return .no_value;
return switch (data) {
.invalid => .invalid_value,
inline else => |comptime_data| placementGetTyped(
iter_,
comptime_data,
@ptrCast(@alignCast(out)),
),
};
}
fn placementGetTyped(
iter_: PlacementIterator,
comptime data: PlacementData,
out: *data.OutType(),
) Result {
const iter = iter_ orelse return .invalid_value;
const entry = iter.entry orelse return .invalid_value;
switch (data) {
.invalid => return .invalid_value,
.image_id => out.* = entry.key_ptr.image_id,
.placement_id => out.* = entry.key_ptr.placement_id.id,
.is_virtual => out.* = entry.value_ptr.location == .virtual,
.x_offset => out.* = entry.value_ptr.x_offset,
.y_offset => out.* = entry.value_ptr.y_offset,
.source_x => out.* = entry.value_ptr.source_x,
.source_y => out.* = entry.value_ptr.source_y,
.source_width => out.* = entry.value_ptr.source_width,
.source_height => out.* = entry.value_ptr.source_height,
.columns => out.* = entry.value_ptr.columns,
.rows => out.* = entry.value_ptr.rows,
.z => out.* = entry.value_ptr.z,
}
return .success;
}
const testing = std.testing;
const terminal_c = @import("terminal.zig");
test "placement_iterator new/free" {
var iter: PlacementIterator = null;
try testing.expectEqual(Result.success, placement_iterator_new(
&lib.alloc.test_allocator,
&iter,
));
try testing.expect(iter != null);
placement_iterator_free(iter);
}
test "placement_iterator free null" {
placement_iterator_free(null);
}
test "placement_iterator next on empty storage" {
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator, &t,
.{ .cols = 80, .rows = 24, .max_scrollback = 0 },
));
defer terminal_c.free(t);
var graphics: KittyGraphics = undefined;
try testing.expectEqual(Result.success, terminal_c.get(
t,
.kitty_graphics,
@ptrCast(&graphics),
));
var iter: PlacementIterator = null;
try testing.expectEqual(Result.success, placement_iterator_new(
&lib.alloc.test_allocator,
&iter,
));
defer placement_iterator_free(iter);
try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
try testing.expect(!placement_iterator_next(iter));
}
test "placement_iterator get before next returns invalid" {
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator, &t,
.{ .cols = 80, .rows = 24, .max_scrollback = 0 },
));
defer terminal_c.free(t);
var graphics: KittyGraphics = undefined;
try testing.expectEqual(Result.success, terminal_c.get(
t,
.kitty_graphics,
@ptrCast(&graphics),
));
var iter: PlacementIterator = null;
try testing.expectEqual(Result.success, placement_iterator_new(
&lib.alloc.test_allocator,
&iter,
));
defer placement_iterator_free(iter);
try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
var image_id: u32 = undefined;
try testing.expectEqual(Result.invalid_value, placement_get(iter, .image_id, @ptrCast(&image_id)));
}
test "placement_iterator with transmit and display" {
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator, &t,
.{ .cols = 80, .rows = 24, .max_scrollback = 0 },
));
defer terminal_c.free(t);
// Transmit and display a 1x2 RGB image (image_id=1, placement_id=1).
// a=T (transmit+display), t=d (direct), f=24 (RGB), i=1, p=1
// s=1,v=2 (1x2 pixels), c=10,r=1 (10 cols, 1 row)
// //////// = 8 base64 chars = 6 bytes = 1*2*3 RGB bytes
const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\";
terminal_c.vt_write(t, cmd.ptr, cmd.len);
var graphics: KittyGraphics = undefined;
try testing.expectEqual(Result.success, terminal_c.get(
t,
.kitty_graphics,
@ptrCast(&graphics),
));
var iter: PlacementIterator = null;
try testing.expectEqual(Result.success, placement_iterator_new(
&lib.alloc.test_allocator,
&iter,
));
defer placement_iterator_free(iter);
try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
// Should have exactly one placement.
try testing.expect(placement_iterator_next(iter));
var image_id: u32 = undefined;
try testing.expectEqual(Result.success, placement_get(iter, .image_id, @ptrCast(&image_id)));
try testing.expectEqual(1, image_id);
var placement_id: u32 = undefined;
try testing.expectEqual(Result.success, placement_get(iter, .placement_id, @ptrCast(&placement_id)));
try testing.expectEqual(1, placement_id);
var is_virtual: bool = undefined;
try testing.expectEqual(Result.success, placement_get(iter, .is_virtual, @ptrCast(&is_virtual)));
try testing.expect(!is_virtual);
// No more placements.
try testing.expect(!placement_iterator_next(iter));
}
test "placement_iterator with multiple placements" {
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
var t: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator, &t,
.{ .cols = 80, .rows = 24, .max_scrollback = 0 },
));
defer terminal_c.free(t);
// Transmit image 1 then display it twice with different placement IDs.
const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
const display1 = "\x1b_Ga=p,i=1,p=1,c=10,r=1;\x1b\\";
const display2 = "\x1b_Ga=p,i=1,p=2,c=5,r=1;\x1b\\";
terminal_c.vt_write(t, transmit.ptr, transmit.len);
terminal_c.vt_write(t, display1.ptr, display1.len);
terminal_c.vt_write(t, display2.ptr, display2.len);
var graphics: KittyGraphics = undefined;
try testing.expectEqual(Result.success, terminal_c.get(
t,
.kitty_graphics,
@ptrCast(&graphics),
));
var iter: PlacementIterator = null;
try testing.expectEqual(Result.success, placement_iterator_new(
&lib.alloc.test_allocator,
&iter,
));
defer placement_iterator_free(iter);
try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter)));
// Count placements and collect image IDs.
var count: usize = 0;
var seen_p1 = false;
var seen_p2 = false;
while (placement_iterator_next(iter)) {
count += 1;
var image_id: u32 = undefined;
try testing.expectEqual(Result.success, placement_get(iter, .image_id, @ptrCast(&image_id)));
try testing.expectEqual(1, image_id);
var placement_id: u32 = undefined;
try testing.expectEqual(Result.success, placement_get(iter, .placement_id, @ptrCast(&placement_id)));
if (placement_id == 1) seen_p1 = true;
if (placement_id == 2) seen_p2 = true;
}
try testing.expectEqual(2, count);
try testing.expect(seen_p1);
try testing.expect(seen_p2);
}

View File

@@ -9,6 +9,11 @@ pub const focus = @import("focus.zig");
pub const formatter = @import("formatter.zig");
pub const grid_ref = @import("grid_ref.zig");
pub const kitty_graphics = @import("kitty_graphics.zig");
pub const kitty_graphics_get = kitty_graphics.get;
pub const kitty_graphics_placement_iterator_new = kitty_graphics.placement_iterator_new;
pub const kitty_graphics_placement_iterator_free = kitty_graphics.placement_iterator_free;
pub const kitty_graphics_placement_next = kitty_graphics.placement_iterator_next;
pub const kitty_graphics_placement_get = kitty_graphics.placement_get;
pub const types = @import("types.zig");
pub const modes = @import("modes.zig");
pub const osc = @import("osc.zig");