Files
ghostty/src/terminal/kitty/graphics_exec.zig
Mitchell Hashimoto bdc0b6c19c kitty/gfx: add generation stamps, delete transmit time
Add a generation counter to the kitty graphics image storage. Every
content mutation (image transmit/replace, placement add, delete)
assigns the storage a fresh stamp, and every image is stamped when
it is added or replaced. 

This solves two problems: 

First, a retransmission of the same image ID with identical dimensions 
was previously undetectable by anything comparing width, height, format, 
and data length; the per-image stamp changes on every add/replace, so caches 
keyed on it always see the change. Second, the dirty flag was the only 
storage-wide signal, and it is also set by scrolling and resizing, which move 
placements without changing contents. The generation is only bumped by content
mutations, so an unchanged value means the placement set and all
image data are identical and consumers can skip re-reading them,
recomputing only placement geometry.

The generation replaces Image.transmit_time entirely: newest-image
lookup by number, eviction ordering, and the renderer's texture
staleness checks all key on it now. A monotonic counter strictly
orders transmissions where Instant-based times could collide within
clock resolution (the renderer previously assumed equal timestamps
meant identical images), and this removes a syscall and an error
path per transmission.

Delete commands now only mark a mutation (dirty flag and
generation) when they actually remove something. A delete-all runs
on every screen clear, so previously every ESC [ 2 J dirtied the
image state even with no images stored. Eviction via setLimit also
now marks the state dirty, which it previously did not.

Both generations are exposed through libghostty-vt as
GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION and
GHOSTTY_KITTY_IMAGE_DATA_GENERATION (uint64_t), and the headers now
document that stored image data is always post-inflate/post-decode:
COMPRESSION always reports NONE, FORMAT is never PNG, and DATA_PTR
is raw pixels ready for GPU upload.

    uint64_t gen = 0;
    ghostty_kitty_graphics_get(
        graphics, GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION, &gen);
    if (gen == last_gen) return; // nothing changed, skip re-reads
2026-07-03 10:55:10 -07:00

659 lines
21 KiB
Zig

const std = @import("std");
const assert = @import("../../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const Terminal = @import("../Terminal.zig");
const command = @import("graphics_command.zig");
const image = @import("graphics_image.zig");
const Command = command.Command;
const Response = command.Response;
const LoadingImage = image.LoadingImage;
const Image = image.Image;
const ImageStorage = @import("graphics_storage.zig").ImageStorage;
const log = std.log.scoped(.kitty_gfx);
/// Execute a Kitty graphics command against the given terminal. This
/// will never fail, but the response may indicate an error and the
/// terminal state may not be updated to reflect the command. This will
/// never put the terminal in an unrecoverable state, however.
///
/// The allocator must be the same allocator that was used to build
/// the command.
pub fn execute(
alloc: Allocator,
terminal: *Terminal,
cmd: *const Command,
) ?Response {
// If storage is disabled then we disable the full protocol. This means
// we don't even respond to queries so the terminal completely acts as
// if this feature is not supported.
if (!terminal.screens.active.kitty_images.enabled()) {
log.debug("kitty graphics requested but disabled", .{});
return null;
}
log.debug("executing kitty graphics command: quiet={} control={}", .{
cmd.quiet,
cmd.control,
});
// The quiet settings used to control the response. We have to make this
// a var because in certain special cases (namely chunked transmissions)
// this can change.
var quiet = cmd.quiet;
const resp_: ?Response = switch (cmd.control) {
.query => query(alloc, terminal, cmd),
.display => display(alloc, terminal, cmd),
.delete => delete(alloc, terminal, cmd),
.transmit, .transmit_and_display => resp: {
// If we're transmitting, then our `q` setting value is complicated.
// The `q` setting inherits the value from the starting command
// unless `q` is set >= 1 on this command. If it is, then we save
// that as the new `q` setting.
const storage = &terminal.screens.active.kitty_images;
if (storage.loading) |loading| switch (cmd.quiet) {
// q=0 we use whatever the start command value is
.no => quiet = loading.quiet,
// q>=1 we use the new value, but we should already be set to it
inline .ok, .failures => |tag| {
assert(quiet == tag);
loading.quiet = tag;
},
};
break :resp transmit(alloc, terminal, cmd);
},
.transmit_animation_frame,
.control_animation,
.compose_animation,
=> .{ .message = "ERROR: unimplemented action" },
};
// Handle the quiet settings
if (resp_) |resp| {
if (!resp.ok()) {
log.warn("erroneous kitty graphics response: {s}", .{resp.message});
}
return switch (quiet) {
.no => if (resp.empty()) null else resp,
.ok => if (resp.ok()) null else resp,
.failures => null,
};
}
return null;
}
/// Execute a "query" command.
///
/// This command is used to attempt to load an image and respond with
/// success/error but does not persist any of the command to the terminal
/// state.
fn query(
alloc: Allocator,
terminal: *const Terminal,
cmd: *const Command,
) Response {
const t = cmd.control.query;
// Query requires image ID. We can't actually send a response without
// an image ID either but we return an error and this will be logged
// downstream.
if (t.image_id == 0) {
return .{ .message = "EINVAL: image ID required" };
}
// Build a partial response to start
var result: Response = .{
.id = t.image_id,
.image_number = t.image_number,
.placement_id = t.placement_id,
};
// Attempt to load the image. If we cannot, then set an appropriate error.
const storage = &terminal.screens.active.kitty_images;
var loading = LoadingImage.init(alloc, cmd, storage.image_limits) catch |err| {
encodeError(&result, err);
return result;
};
loading.deinit(alloc);
return result;
}
/// Transmit image data.
///
/// This loads the image, validates it, and puts it into the terminal
/// screen storage. It does not display the image.
fn transmit(
alloc: Allocator,
terminal: *Terminal,
cmd: *const Command,
) Response {
const t = cmd.transmission().?;
var result: Response = .{
.id = t.image_id,
.image_number = t.image_number,
.placement_id = t.placement_id,
};
if (t.image_id > 0 and t.image_number > 0) {
return .{ .message = "EINVAL: image ID and number are mutually exclusive" };
}
const load = loadAndAddImage(alloc, terminal, cmd) catch |err| {
encodeError(&result, err);
return result;
};
errdefer load.image.deinit(alloc);
// If we're also displaying, then do that now. This function does
// both transmit and transmit and display. The display might also be
// deferred if it is multi-chunk.
if (load.display) |d| {
assert(!load.more);
var d_copy = d;
d_copy.image_id = load.image.id;
result = display(alloc, terminal, &.{
.control = .{ .display = d_copy },
.quiet = cmd.quiet,
});
}
// If there are more chunks expected we do not respond.
if (load.more) return .{};
// If the loaded image was assigned its ID automatically, not based
// on a number or explicitly specified ID, then we don't respond.
if (load.image.implicit_id) return .{};
// After the image is added, set the ID in case it changed.
// The resulting image number and placement ID never change.
result.id = load.image.id;
return result;
}
/// Display a previously transmitted image.
fn display(
alloc: Allocator,
terminal: *Terminal,
cmd: *const Command,
) Response {
const d = cmd.display().?;
// Display requires image ID or number.
if (d.image_id == 0 and d.image_number == 0) {
return .{ .message = "EINVAL: image ID or number required" };
}
// Build up our response
var result: Response = .{
.id = d.image_id,
.image_number = d.image_number,
.placement_id = d.placement_id,
};
// Verify the requested image exists if we have an ID
const storage = &terminal.screens.active.kitty_images;
const img_: ?Image = if (d.image_id != 0)
storage.imageById(d.image_id)
else
storage.imageByNumber(d.image_number);
const img = img_ orelse {
result.message = "ENOENT: image not found";
return result;
};
// Make sure our response has the image id in case we looked up by number
result.id = img.id;
// Location where the placement will go.
const location: ImageStorage.Placement.Location = location: {
// Virtual placements are not tracked
if (d.virtual_placement) {
if (d.parent_id > 0) {
result.message = "EINVAL: virtual placement cannot refer to a parent";
return result;
}
break :location .{ .virtual = {} };
}
// Track a new pin for our cursor. The cursor is always tracked but we
// don't want this one to move with the cursor.
const pin = terminal.screens.active.pages.trackPin(
terminal.screens.active.cursor.page_pin.*,
) catch |err| {
log.warn("failed to create pin for Kitty graphics err={}", .{err});
result.message = "EINVAL: failed to prepare terminal state";
return result;
};
break :location .{ .pin = pin };
};
// Add the placement
const p: ImageStorage.Placement = .{
.location = location,
.x_offset = d.x_offset,
.y_offset = d.y_offset,
.source_x = d.x,
.source_y = d.y,
.source_width = d.width,
.source_height = d.height,
.columns = d.columns,
.rows = d.rows,
.z = d.z,
};
storage.addPlacement(
alloc,
img.id,
result.placement_id,
p,
) catch |err| {
p.deinit(terminal.screens.active);
encodeError(&result, err);
return result;
};
// Apply cursor movement setting. This only applies to pin placements.
switch (p.location) {
.virtual => {},
.pin => |pin| switch (d.cursor_movement) {
.none => {},
.after => {
// We use terminal.index to properly handle scroll regions.
const size = p.gridSize(img, terminal);
for (0..size.rows) |_| terminal.index() catch |err| {
log.warn("failed to move cursor: {}", .{err});
break;
};
terminal.setCursorPos(
terminal.screens.active.cursor.y,
pin.x + size.cols + 1,
);
},
},
}
return result;
}
/// Display a previously transmitted image.
fn delete(
alloc: Allocator,
terminal: *Terminal,
cmd: *const Command,
) Response {
const storage = &terminal.screens.active.kitty_images;
storage.delete(alloc, terminal, cmd.control.delete);
// Delete never responds on success
return .{};
}
fn loadAndAddImage(
alloc: Allocator,
terminal: *Terminal,
cmd: *const Command,
) !struct {
image: Image,
more: bool = false,
display: ?command.Display = null,
} {
const t = cmd.transmission().?;
const storage = &terminal.screens.active.kitty_images;
// Determine our image. This also handles chunking and early exit.
var loading: LoadingImage = if (storage.loading) |loading| loading: {
// Note: we do NOT want to call "cmd.toOwnedData" here because
// we're _copying_ the data. We want the command data to be freed.
try loading.addData(alloc, cmd.data);
// If we have more then we're done
if (t.more_chunks) return .{ .image = loading.image, .more = true };
// We have no more chunks. We're going to be completing the
// image so we want to destroy the pointer to the loading
// image and copy it out.
defer {
alloc.destroy(loading);
storage.loading = null;
}
break :loading loading.*;
} else try .init(alloc, cmd, storage.image_limits);
// We only want to deinit on error. If we're chunking, then we don't
// want to deinit at all. If we're not chunking, then we'll deinit
// after we've copied the image out.
errdefer loading.deinit(alloc);
// If the image has no ID, we assign one
if (loading.image.id == 0) {
loading.image.id = storage.next_image_id;
storage.next_image_id +%= 1;
// If the image also has no number then its auto-ID is "implicit".
// See the doc comment on the Image.implicit_id field for more detail.
if (loading.image.number == 0) loading.image.implicit_id = true;
}
// If this is chunked, this is the beginning of a new chunked transmission.
// (We checked for an in-progress chunk above.)
if (t.more_chunks) {
// We allocate the pointer on the heap because its rare and we
// don't want to always pay the memory cost to keep it around.
const loading_ptr = try alloc.create(LoadingImage);
errdefer alloc.destroy(loading_ptr);
loading_ptr.* = loading;
storage.loading = loading_ptr;
return .{ .image = loading.image, .more = true };
}
// Dump the image data before it is decompressed
// loading.debugDump() catch unreachable;
// Validate and store our image
var img = try loading.complete(alloc);
errdefer img.deinit(alloc);
try storage.addImage(alloc, img);
// Get our display settings
const display_ = loading.display;
// Ensure we deinit the loading state because we're done. The image
// won't be deinit because of "complete" above.
loading.deinit(alloc);
return .{ .image = img, .display = display_ };
}
const EncodeableError = Image.Error || Allocator.Error;
/// Encode an error code into a message for a response.
fn encodeError(r: *Response, err: EncodeableError) void {
switch (err) {
error.OutOfMemory => r.message = "ENOMEM: out of memory",
error.InvalidData => r.message = "EINVAL: invalid data",
error.DecompressionFailed => r.message = "EINVAL: decompression failed",
error.FilePathTooLong => r.message = "EINVAL: file path too long",
error.TemporaryFileNotInTempDir => r.message = "EINVAL: temporary file not in temp dir",
error.TemporaryFileNotNamedCorrectly => r.message = "EINVAL: temporary file not named correctly",
error.UnsupportedFormat => r.message = "EINVAL: unsupported format",
error.UnsupportedMedium => r.message = "EINVAL: unsupported medium",
error.UnsupportedDepth => r.message = "EINVAL: unsupported pixel depth",
error.DimensionsRequired => r.message = "EINVAL: dimensions required",
error.DimensionsTooLarge => r.message = "EINVAL: dimensions too large",
}
}
test "kittygfx more chunks with q=1" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
// Initial chunk has q=1
{
const cmd = try command.Parser.parseString(
alloc,
"a=T,f=24,t=d,i=1,s=1,v=2,c=10,r=1,m=1,q=1;////",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd);
try testing.expect(resp == null);
}
// Subsequent chunk has no q but should respect initial
{
const cmd = try command.Parser.parseString(
alloc,
"m=0;////",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd);
try testing.expect(resp == null);
}
}
test "kittygfx more chunks with q=0" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
// Initial chunk has q=0
{
const cmd = try command.Parser.parseString(
alloc,
"a=t,f=24,t=d,s=1,v=2,c=10,r=1,m=1,i=1,q=0;////",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd);
try testing.expect(resp == null);
}
// Subsequent chunk has no q so should respond OK
{
const cmd = try command.Parser.parseString(
alloc,
"m=0;////",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd).?;
try testing.expect(resp.ok());
}
}
test "kittygfx more chunks with chunk increasing q" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
// Initial chunk has q=0
{
const cmd = try command.Parser.parseString(
alloc,
"a=t,f=24,t=d,s=1,v=2,c=10,r=1,m=1,i=1,q=0;////",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd);
try testing.expect(resp == null);
}
// Subsequent chunk sets q=1 so should not respond
{
const cmd = try command.Parser.parseString(
alloc,
"m=0,q=1;////",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd);
try testing.expect(resp == null);
}
}
test "kittygfx default format is rgba" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const cmd = try command.Parser.parseString(
alloc,
"a=t,t=d,i=1,s=1,v=2,c=10,r=1;///////////",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd).?;
try testing.expect(resp.ok());
const storage = &t.screens.active.kitty_images;
const img = storage.imageById(1).?;
try testing.expectEqual(command.Transmission.Format.rgba, img.format);
}
test "kittygfx test valid u32 (expect invalid image ID)" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const cmd = try command.Parser.parseString(
alloc,
"a=p,i=4294967295",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqual(resp.message, "ENOENT: image not found");
}
test "kittygfx test valid i32 (expect invalid image ID)" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const cmd = try command.Parser.parseString(
alloc,
"a=p,i=1,z=-2147483648",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqual(resp.message, "ENOENT: image not found");
}
test "kittygfx no response with no image ID or number" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
{
const cmd = try command.Parser.parseString(
alloc,
"a=t,f=24,t=d,s=1,v=2,c=10,r=1,i=0,I=0;////////",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd);
try testing.expect(resp == null);
}
}
test "kittygfx no response with no image ID or number load and display" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
{
const cmd = try command.Parser.parseString(
alloc,
"a=T,f=24,t=d,s=1,v=2,c=10,r=1,i=0,I=0;////////",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd);
try testing.expect(resp == null);
}
}
test "kittygfx retransmit same id gets fresh image generation" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const storage = &t.screens.active.kitty_images;
// Transmit a 1x2 RGB image with id=1.
{
const cmd = try command.Parser.parseString(
alloc,
"a=t,t=d,f=24,i=1,s=1,v=2;////////",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd).?;
try testing.expect(resp.ok());
}
const gen1 = storage.imageById(1).?.generation;
try testing.expect(gen1 > 0);
try testing.expectEqual(gen1, storage.generation);
// Retransmit the same id with identical dimensions/length. The
// (width, height, format, len) tuple is identical, so only the
// generation can reveal that the contents were replaced.
{
const cmd = try command.Parser.parseString(
alloc,
"a=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd).?;
try testing.expect(resp.ok());
}
const gen2 = storage.imageById(1).?.generation;
try testing.expect(gen2 > gen1);
try testing.expectEqual(gen2, storage.generation);
}
test "kittygfx delete then retransmit same id gets fresh generation" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const storage = &t.screens.active.kitty_images;
// Transmit and display, then delete everything (including image
// data), then retransmit the same ID.
{
const cmd = try command.Parser.parseString(
alloc,
"a=T,t=d,f=24,i=1,s=1,v=2,c=1,r=1;////////",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd).?;
try testing.expect(resp.ok());
}
const gen1 = storage.imageById(1).?.generation;
{
const cmd = try command.Parser.parseString(alloc, "a=d,d=A");
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd);
try testing.expect(resp == null);
}
try testing.expect(storage.imageById(1) == null);
const gen_delete = storage.generation;
try testing.expect(gen_delete > gen1);
{
const cmd = try command.Parser.parseString(
alloc,
"a=t,t=d,f=24,i=1,s=1,v=2;////////",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd).?;
try testing.expect(resp.ok());
}
const gen2 = storage.imageById(1).?.generation;
try testing.expect(gen2 > gen1);
try testing.expect(gen2 > gen_delete);
}