terminal: support pending image payloads for kitty graphics

Represent Kitty image data as a complete/pending tagged union.
Kitty images can now be completed _later_ if we have all their other
metadata up front.

This will be used by the snapshot API to transmit lightweight
information up front so that renderers of the snapshot can show
placeholders and accept mutating pty data, while the real image data
streams in later.
This commit is contained in:
Mitchell Hashimoto
2026-08-03 13:09:26 -07:00
parent 7d748097a0
commit 6760c6482b
5 changed files with 478 additions and 45 deletions

View File

@@ -277,7 +277,13 @@ pub const State = struct {
while (it.next()) |kv| {
switch (kv.key_ptr.*) {
// We're only looking at Kitty images
.kitty => |id| if (storage.imageById(id) == null) {
.kitty => |id| if (storage.imageById(id)) |image| {
// A pending source must not keep an older texture for
// the same ID renderable while its placements wait.
if (image.data.isPending()) {
kv.value_ptr.image.markForUnload();
}
} else {
kv.value_ptr.image.markForUnload();
},
@@ -408,6 +414,10 @@ pub const State = struct {
image: *const terminal.kitty.graphics.Image,
p: *const terminal.kitty.graphics.ImageStorage.Placement,
) PrepImageError!void {
// Keep the native placement but do not create a renderer placement or
// texture until the decoded bytes arrive.
if (image.data.isPending()) return;
// Get the rect for the placement. If this placement doesn't have
// a rect then its virtual or something so skip it.
const rect = p.rect(image.*, t) orelse return;
@@ -478,6 +488,7 @@ pub const State = struct {
);
return;
};
if (image.data.isPending()) return;
const rp = p.renderPlacement(
storage,
@@ -597,6 +608,7 @@ pub const State = struct {
alloc: Allocator,
image: *const terminal.kitty.graphics.Image,
) PrepImageError!void {
const data = image.data.bytes() orelse unreachable;
try self.prepImage(
alloc,
.{ .kitty = image.id },
@@ -615,7 +627,7 @@ pub const State = struct {
// constCasts are always gross but this one is safe is because
// the data is only read from here and copied into its own
// buffer.
.data = @constCast(image.data.ptr),
.data = @constCast(data.ptr),
},
);
}
@@ -963,3 +975,62 @@ pub const Image = union(enum) {
};
}
};
test "kitty renderer ignores pending payloads and retains native placements" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 });
defer t.deinit(alloc);
t.width_px = 30;
t.height_px = 30;
var state: State = .empty;
defer state.deinit(alloc);
const storage = &t.screens.active.kitty_images;
const pending = try storage.addPendingImage(io, alloc, .{
.id = 1,
.width = 1,
.height = 1,
.format = .rgba,
.data = .{ .pending = 4 },
});
const pin = try t.screens.active.pages.trackPin(
t.screens.active.cursor.page_pin.*,
);
try storage.addPlacement(io, alloc, 1, 1, .{
.location = .{ .pin = pin },
.columns = 1,
.rows = 1,
});
state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
try testing.expectEqual(@as(usize, 1), storage.placements.count());
try testing.expectEqual(@as(usize, 0), state.kitty_placements.items.len);
try testing.expect(state.images.get(.{ .kitty = 1 }) == null);
const pixels = try alloc.dupe(u8, "rgba");
try testing.expect(pending.complete(storage, io, pixels));
state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
try testing.expectEqual(@as(usize, 1), state.kitty_placements.items.len);
try testing.expectEqual(
pending.generation,
state.images.get(.{ .kitty = 1 }).?.generation,
);
// A newer pending replacement clears the renderer placement and marks
// the copied texture data for unload without deleting the native pin.
_ = try storage.addPendingImage(io, alloc, .{
.id = 1,
.width = 1,
.height = 1,
.format = .rgba,
.data = .{ .pending = 4 },
});
state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
try testing.expectEqual(@as(usize, 1), storage.placements.count());
try testing.expectEqual(@as(usize, 0), state.kitty_placements.items.len);
try testing.expect(state.images.get(.{ .kitty = 1 }).?.image.isUnloading());
}