diff --git a/example/c-vt-kitty-graphics/src/main.c b/example/c-vt-kitty-graphics/src/main.c index d88ee1952..d0d891928 100644 --- a/example/c-vt-kitty-graphics/src/main.c +++ b/example/c-vt-kitty-graphics/src/main.c @@ -125,6 +125,17 @@ int main() { } printf("\nKitty graphics storage is available.\n"); + /* + * The storage-wide generation changes on every image/placement + * mutation. Renderers can compare it against the value from the + * previous frame: if unchanged, placement iteration and image + * staleness checks can be skipped entirely. + */ + uint64_t generation = 0; + ghostty_kitty_graphics_get(graphics, GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION, + &generation); + printf("Storage generation: %llu\n", (unsigned long long)generation); + /* Iterate placements to find the image ID. */ GhosttyKittyGraphicsPlacementIterator iter = NULL; if (ghostty_kitty_graphics_placement_iterator_new(NULL, &iter) != GHOSTTY_SUCCESS) { @@ -170,20 +181,30 @@ int main() { uint32_t width = 0, height = 0, number = 0; GhosttyKittyImageFormat format = 0; size_t data_len = 0; + uint64_t image_generation = 0; - ghostty_kitty_graphics_image_get_multi(image, 5, + /* + * The per-image generation changes on every add/replace of this + * image ID, so it detects retransmissions even when the size and + * format are unchanged. Texture caches should key staleness on it. + */ + ghostty_kitty_graphics_image_get_multi(image, 6, (GhosttyKittyGraphicsImageData[]){ GHOSTTY_KITTY_IMAGE_DATA_NUMBER, GHOSTTY_KITTY_IMAGE_DATA_WIDTH, GHOSTTY_KITTY_IMAGE_DATA_HEIGHT, GHOSTTY_KITTY_IMAGE_DATA_FORMAT, GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN, + GHOSTTY_KITTY_IMAGE_DATA_GENERATION, }, - (void*[]){ &number, &width, &height, &format, &data_len }, + (void*[]){ &number, &width, &height, &format, &data_len, + &image_generation }, NULL); - printf(" image: number=%u size=%ux%u format=%d data_len=%zu\n", - number, width, height, format, data_len); + printf(" image: number=%u size=%ux%u format=%d data_len=%zu " + "generation=%llu\n", + number, width, height, format, data_len, + (unsigned long long)image_generation); /* Compute the rendered pixel size and grid size. */ uint32_t px_w = 0, px_h = 0, cols = 0, rows = 0; diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index 9bace3a3c..dda8b836e 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -84,6 +84,28 @@ extern "C" { * - ghostty_kitty_graphics_placement_rect() — bounding rectangle as a * @ref GhosttySelection. * + * ## Change Detection + * + * Generation stamps allow renderers to cheaply detect whether Kitty + * graphics state changed between frames: + * + * - @ref GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION is a storage-wide stamp + * updated on any transmit, placement, or delete. If unchanged, the + * placement set and all image data are identical and both placement + * snapshots and per-image staleness checks can be skipped. Placement + * geometry can still change independently (scrolling moves + * placements), so ghostty_kitty_graphics_placement_render_info() + * should still be recomputed on dirty frames. + * - @ref GHOSTTY_KITTY_IMAGE_DATA_GENERATION is a per-image stamp + * changed on every add/replace of that image ID. Texture caches + * should treat a cached texture as stale when this differs from the + * cached value; dimension/length heuristics cannot detect a + * same-sized retransmission. + * + * Stamps are unique and monotonically increasing process-wide, so + * caches keyed on a generation value never alias across screens + * (main/alternate), resets, or terminals. + * * ## Lifetime and Thread Safety * * All handles borrowed from the terminal (GhosttyKittyGraphics, @@ -119,6 +141,29 @@ typedef enum GHOSTTY_ENUM_TYPED { * Output type: GhosttyKittyGraphicsPlacementIterator * */ GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR = 1, + + /** + * Generation stamp of the last content mutation to this storage: + * any image transmit/replace, placement add, or delete. Zero means + * the storage has never been mutated (and is therefore empty). + * + * If the generation is unchanged since a previous query, the set of + * placements and all image data are identical, so placement iteration + * and image staleness checks can be skipped entirely. Note that + * placement *geometry* may still have changed (scrolling and resizing + * move placements without changing the storage contents), so rendering + * geometry such as ghostty_kitty_graphics_placement_render_info() + * must still be recomputed for frames marked dirty. + * + * Stamps are unique and monotonically increasing process-wide: a + * value observed from any storage never recurs for different content, + * even across screen switches (main vs. alternate screen have + * independent storages) or terminal resets. It is therefore safe to + * key caches on this value alone. + * + * Output type: uint64_t * + */ + GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION = 2, GHOSTTY_KITTY_GRAPHICS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyGraphicsData; @@ -255,6 +300,12 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Pixel format of a Kitty graphics image. * + * Note that stored images are always fully decoded: + * GHOSTTY_KITTY_IMAGE_FORMAT_PNG is never returned by + * ghostty_kitty_graphics_image_get() because PNG payloads are decoded + * to GHOSTTY_KITTY_IMAGE_FORMAT_RGBA before storage. The PNG value + * exists only for protocol-level completeness. + * * @ingroup kitty_graphics */ typedef enum GHOSTTY_ENUM_TYPED { @@ -269,6 +320,12 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Compression of a Kitty graphics image. * + * Note that stored images are always decompressed: + * GHOSTTY_KITTY_IMAGE_COMPRESSION_ZLIB_DEFLATE payloads are inflated + * before storage, so ghostty_kitty_graphics_image_get() always reports + * GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE. Consumers never need to + * inflate image data themselves. + * * @ingroup kitty_graphics */ typedef enum GHOSTTY_ENUM_TYPED { @@ -315,14 +372,17 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_KITTY_IMAGE_DATA_HEIGHT = 4, /** - * Pixel format of the image. + * Pixel format of the image. Never GHOSTTY_KITTY_IMAGE_FORMAT_PNG; + * PNG payloads are decoded to RGBA before storage. * * Output type: GhosttyKittyImageFormat * */ GHOSTTY_KITTY_IMAGE_DATA_FORMAT = 5, /** - * Compression of the image. + * Compression of the image. Always + * GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE; compressed payloads are + * inflated before storage. * * Output type: GhosttyKittyImageCompression * */ @@ -332,17 +392,41 @@ typedef enum GHOSTTY_ENUM_TYPED { * Borrowed pointer to the raw pixel data. Valid as long as the * underlying terminal is not mutated. * + * The data is always fully decoded, uncompressed pixels in the + * format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads + * are inflated and PNG payloads are decoded to RGBA at transmission + * time, before the image is stored. Consumers can upload this + * directly to the GPU without any decode step. + * * Output type: const uint8_t ** */ GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR = 7, /** - * Length of the raw pixel data in bytes. + * Length of the raw pixel data in bytes. Always equal to + * width * height * bytes-per-pixel for the reported format. * * Output type: size_t * */ GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN = 8, + /** + * Generation stamp assigned when this image was added to (or + * replaced in) the storage. A changed generation for a given image + * ID means the pixel contents may have changed even when the + * dimensions, format, and data length are identical (e.g. a + * retransmission of the same image ID), so texture caches must key + * staleness on this value rather than on size heuristics. + * + * Stamps are unique and monotonically increasing process-wide and + * are drawn from the same sequence as + * GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored + * image, so zero can be used as an "empty" sentinel by callers. + * + * Output type: uint64_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_GENERATION = 9, + GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyGraphicsImageData; diff --git a/src/renderer/image.zig b/src/renderer/image.zig index 442b7543f..f8269354a 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -192,9 +192,9 @@ pub const State = struct { return; }; - // For transmit time we always just use the current time - // and overwrite the overlay. - const transmit_time = try std.time.Instant.now(); + // Overlays are always considered new content, so we take a + // fresh generation stamp to force replacing any existing one. + const generation = terminal.kitty.graphics.nextGeneration(); // Ensure we have space for our overlay placement. Do this before // we upload our image so we don't have to deal with cleaning @@ -207,7 +207,7 @@ pub const State = struct { try self.prepImage( alloc, .overlay, - transmit_time, + generation, pending, ); errdefer comptime unreachable; @@ -525,14 +525,14 @@ pub const State = struct { self: *State, alloc: Allocator, id: Id, - transmit_time: std.time.Instant, + generation: u64, pending: Image.Pending, ) PrepImageError!void { - // If this image exists and its transmit time is the same we assume - // it is the identical image so we don't need to send it to the GPU. + // If this image exists and its generation is the same it is the + // identical image so we don't need to send it to the GPU. const gop = try self.images.getOrPut(alloc, id); if (gop.found_existing and - gop.value_ptr.transmit_time.order(transmit_time) == .eq) + gop.value_ptr.generation == generation) { return; } @@ -570,7 +570,7 @@ pub const State = struct { if (!gop.found_existing) { gop.value_ptr.* = .{ .image = new_image, - .transmit_time = undefined, + .generation = 0, }; } else { gop.value_ptr.image.markForReplace( @@ -586,7 +586,7 @@ pub const State = struct { log.warn("error preparing image for upload err={}", .{err}); return error.ImageConversionError; }; - gop.value_ptr.transmit_time = transmit_time; + gop.value_ptr.generation = generation; } /// Prepare the provided Kitty image for upload to the GPU by copying its @@ -599,7 +599,7 @@ pub const State = struct { try self.prepImage( alloc, .{ .kitty = image.id }, - image.transmit_time, + image.generation, .{ .width = image.width, .height = image.height, @@ -688,7 +688,13 @@ pub const Id = union(enum) { /// The map used for storing images. pub const ImageMap = std.AutoHashMapUnmanaged(Id, struct { image: Image, - transmit_time: std.time.Instant, + + /// The generation of the terminal image this was created from + /// (see terminal.kitty.graphics.Image.generation). Used to detect + /// staleness: a differing generation for the same ID means the + /// contents changed and the texture must be replaced. Zero is + /// never a valid stored generation so it marks "not yet uploaded". + generation: u64, }); /// The state for a single image that is to be rendered. diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index d50b8c4c2..13a675ccc 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -52,11 +52,13 @@ else pub const Data = enum(c_int) { invalid = 0, placement_iterator = 1, + generation = 2, pub fn OutType(comptime self: Data) type { return switch (self) { .invalid => void, .placement_iterator => PlacementIterator, + .generation => u64, }; } }; @@ -129,6 +131,7 @@ fn getTyped( .layer_filter = it.layer_filter, }; }, + .generation => out.* = storage.generation, } return .success; } @@ -178,6 +181,7 @@ pub const ImageData = enum(c_int) { compression = 6, data_ptr = 7, data_len = 8, + generation = 9, pub fn OutType(comptime self: ImageData) type { return switch (self) { @@ -187,6 +191,7 @@ pub const ImageData = enum(c_int) { .compression => ImageCompression, .data_ptr => [*]const u8, .data_len => usize, + .generation => u64, }; } }; @@ -258,6 +263,7 @@ fn imageGetTyped( .compression => out.* = image.compression, .data_ptr => out.* = image.data.ptr, .data_len => out.* = image.data.len, + .generation => out.* = image.generation, } return .success; @@ -1708,3 +1714,259 @@ test "placement_get_multi null keys returns invalid_value" { var values = [_]?*anyopaque{@ptrCast(&id)}; try testing.expectEqual(Result.invalid_value, placement_get_multi(null, 1, null, &values, null)); } + +test "storage generation via get" { + 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))); + + // Fresh storage: generation zero. + var gen0: u64 = 99; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen0))); + try testing.expectEqual(0, gen0); + + // Transmit bumps the generation. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + var gen1: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen1))); + try testing.expect(gen1 > gen0); + + // Unrelated terminal writes (plain text) do not bump it. + const text = "hello world"; + terminal_c.vt_write(t, text.ptr, text.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + var gen2: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen2))); + try testing.expectEqual(gen1, gen2); + + // Placement bumps it. + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\"; + terminal_c.vt_write(t, display.ptr, display.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + var gen3: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen3))); + try testing.expect(gen3 > gen2); + + // Delete bumps it. + const del = "\x1b_Ga=d,d=A\x1b\\"; + terminal_c.vt_write(t, del.ptr, del.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + var gen4: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen4))); + try testing.expect(gen4 > gen3); +} + +test "image generation detects same-sized retransmission" { + 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 a 1x2 RGB image with id=1. + const transmit1 = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, transmit1.ptr, transmit1.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + + var gen1: u64 = 0; + var w1: u32 = 0; + var h1: u32 = 0; + var len1: usize = 0; + { + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + try testing.expectEqual(Result.success, image_get(img, .generation, @ptrCast(&gen1))); + try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w1))); + try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h1))); + try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&len1))); + try testing.expect(gen1 > 0); + } + + // Retransmit the same ID with identical dimensions but different + // pixel bytes. All size heuristics match; only the generation + // reveals the change. + const transmit2 = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA\x1b\\"; + terminal_c.vt_write(t, transmit2.ptr, transmit2.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + + { + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + var gen2: u64 = 0; + var w2: u32 = 0; + var h2: u32 = 0; + var len2: usize = 0; + try testing.expectEqual(Result.success, image_get(img, .generation, @ptrCast(&gen2))); + try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w2))); + try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h2))); + try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&len2))); + + // Size heuristics are identical... + try testing.expectEqual(w1, w2); + try testing.expectEqual(h1, h2); + try testing.expectEqual(len1, len2); + + // ...but the generation changed. + try testing.expect(gen2 > gen1); + } +} + +test "image generation via image_get_multi" { + 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); + + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var id: u32 = 0; + var generation: u64 = 0; + var written: usize = 0; + const keys = [_]ImageData{ .id, .generation }; + var values = [_]?*anyopaque{ @ptrCast(&id), @ptrCast(&generation) }; + try testing.expectEqual(Result.success, image_get_multi(img, keys.len, &keys, &values, &written)); + try testing.expectEqual(keys.len, written); + try testing.expectEqual(1, id); + try testing.expect(generation > 0); + + // The image stamp came from the same sequence as (and here, the + // same event as) the storage stamp. + var storage_gen: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&storage_gen))); + try testing.expectEqual(storage_gen, generation); +} + +test "image compression and format always report decoded data" { + 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 a zlib-compressed 1x1 RGB image (o=z). The payload is + // base64(zlib([0xFF, 0x00, 0x00])). + const compressed = comptime blk: { + // zlib stream for the 3 bytes FF 00 00 (stored block). + // 78 01: zlib header; 01: final stored block; 03 00 len; + // FC FF nlen; FF 00 00 data; adler32 = 0x03000100 (big endian). + const raw = [_]u8{ 0x78, 0x01, 0x01, 0x03, 0x00, 0xFC, 0xFF, 0xFF, 0x00, 0x00, 0x03, 0x00, 0x01, 0x00 }; + var buf: [std.base64.standard.Encoder.calcSize(raw.len)]u8 = undefined; + const enc = std.base64.standard.Encoder.encode(&buf, &raw); + break :blk enc[0..enc.len].*; + }; + const transmit = "\x1b_Ga=t,t=d,f=24,o=z,i=1,s=1,v=1;" ++ compressed ++ "\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + // Compression must report NONE: the data was inflated at + // transmission time. + var comp: ImageCompression = undefined; + try testing.expectEqual(Result.success, image_get(img, .compression, @ptrCast(&comp))); + try testing.expectEqual(.none, comp); + + // Data is the decoded pixels: width * height * bpp. + var data_len: usize = 0; + try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&data_len))); + try testing.expectEqual(3, data_len); + + var data_ptr: [*]const u8 = undefined; + try testing.expectEqual(Result.success, image_get(img, .data_ptr, @ptrCast(&data_ptr))); + try testing.expectEqualSlices(u8, &.{ 0xFF, 0x00, 0x00 }, data_ptr[0..data_len]); +} + +test "generation never recurs across resets and screen switches" { + 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; + var gen: u64 = 0; + + // Transmit on the main screen. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + const gen_main = gen; + try testing.expect(gen_main > 0); + + // Switch to the alternate screen: its storage is untouched, so its + // generation is zero (which always means "empty"). + const alt_on = "\x1b[?1049h"; + terminal_c.vt_write(t, alt_on.ptr, alt_on.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + try testing.expectEqual(0, gen); + + // A transmit on the alt screen draws from the same global sequence, + // so its stamp is strictly greater than anything seen on the main + // screen: an embedder keying a cache on the generation value alone + // can never confuse the two storages. + terminal_c.vt_write(t, transmit.ptr, transmit.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + const gen_alt = gen; + try testing.expect(gen_alt > gen_main); + + // Back to the main screen: its generation is unchanged. + const alt_off = "\x1b[?1049l"; + terminal_c.vt_write(t, alt_off.ptr, alt_off.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + try testing.expectEqual(gen_main, gen); + + // Reset zeroes the storage (empty), and the next mutation continues + // from the global sequence: past values are never reused for + // different content. + terminal_c.reset(t); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + try testing.expectEqual(0, gen); + + terminal_c.vt_write(t, transmit.ptr, transmit.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + try testing.expect(gen > gen_alt); +} diff --git a/src/terminal/kitty/graphics.zig b/src/terminal/kitty/graphics.zig index 6659cd310..1f639fab8 100644 --- a/src/terminal/kitty/graphics.zig +++ b/src/terminal/kitty/graphics.zig @@ -29,6 +29,7 @@ pub const LoadingImage = image.LoadingImage; pub const ImageStorage = storage.ImageStorage; pub const RenderPlacement = render.Placement; pub const Response = command.Response; +pub const nextGeneration = storage.nextGeneration; pub const execute = exec.execute; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index a6a879e58..6c72ec4e9 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -380,7 +380,6 @@ const EncodeableError = Image.Error || Allocator.Error; fn encodeError(r: *Response, err: EncodeableError) void { switch (err) { error.OutOfMemory => r.message = "ENOMEM: out of memory", - error.InternalError => r.message = "EINVAL: internal error", error.InvalidData => r.message = "EINVAL: invalid data", error.DecompressionFailed => r.message = "EINVAL: decompression failed", error.FilePathTooLong => r.message = "EINVAL: file path too long", @@ -573,3 +572,87 @@ test "kittygfx no response with no image ID or number load and display" { 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); +} diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index 8243a6323..b6f44fac2 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -401,12 +401,6 @@ pub const LoadingImage = struct { return error.InvalidData; } - // Set our time - self.image.transmit_time = std.time.Instant.now() catch |err| { - log.warn("failed to get time: {}", .{err}); - return error.InternalError; - }; - // Everything looks good, copy the image data over. var result = self.image; result.data = try self.data.toOwnedSlice(alloc); @@ -504,6 +498,12 @@ pub const LoadingImage = struct { }; /// Image represents a single fully loaded image. +/// +/// The image data is always fully decoded raw pixels: loading inflates +/// any zlib-compressed payload and decodes PNG into RGBA before an image +/// is completed, so `compression` is always `.none` and `format` is +/// never `.png` for a stored image, and `data.len` always equals +/// `width * height * bytes-per-pixel`. pub const Image = struct { id: u32 = 0, number: u32 = 0, @@ -512,7 +512,14 @@ pub const Image = struct { format: command.Transmission.Format = .rgb, compression: command.Transmission.Compression = .none, data: []const u8 = "", - transmit_time: std.time.Instant = undefined, + + /// Unique, monotonically increasing stamp assigned each time an + /// image is added to (or replaced in) an ImageStorage. A changed + /// generation for a given image ID means the image contents may + /// have changed, even if the dimensions and byte length are the + /// same (e.g. a retransmission of the same ID). Stamps order by + /// transmission time. Zero means "never stored". + generation: u64 = 0, /// Set this to true if this image was loaded by a command that /// doesn't specify an ID or number, since such commands should @@ -521,7 +528,6 @@ pub const Image = struct { implicit_id: bool = false, pub const Error = error{ - InternalError, InvalidData, DecompressionFailed, DimensionsRequired, diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index e017d5f79..7c1c7f83f 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -16,6 +16,26 @@ const Command = command.Command; const log = std.log.scoped(.kitty_gfx); +/// Process-global counter backing all generation stamps (see +/// ImageStorage.generation and Image.generation). This is global rather +/// than per-storage so that stamps are unique across every storage in +/// the process: two mutation events never produce the same value, even +/// across separate screens (main vs. alt), storage resets, or separate +/// terminals. This lets consumers use a generation value alone as a +/// cache key without any ambiguity. +/// +/// Atomic because separate terminals may mutate their storages from +/// different threads. On single-threaded targets (e.g. wasm without +/// atomics) this lowers to plain operations. +var generation_counter: std.atomic.Value(u64) = .init(0); + +/// Returns the next generation stamp. Stamps are unique and strictly +/// monotonically increasing process-wide, starting at 1 (0 is reserved +/// to mean "never stamped"). +pub fn nextGeneration() u64 { + return generation_counter.fetchAdd(1, .monotonic) + 1; +} + /// An image storage is associated with a terminal screen (i.e. main /// screen, alt screen) and contains all the transmitted images and /// placements. @@ -27,8 +47,32 @@ pub const ImageStorage = struct { /// purely informational for the renderer and doesn't affect the /// correctness of the program. The renderer must set this to false /// if it cares about this value. + /// + /// Note that dirty is also set by scrolling and resizing (outside + /// of this struct) because those move placement pins, even though + /// the set of images/placements itself is unchanged. See generation + /// for a signal that only tracks content mutations. + /// + /// Invariant: dirty is always set when generation changes + /// (markMutated sets both); dirty set without a generation change + /// means a geometry-only event. dirty: bool = false, + /// Generation stamp of the last content mutation to this storage: + /// any image transmit/replace, placement add, or delete of either. + /// Zero means the storage has never been mutated (and is therefore + /// empty). + /// + /// Unlike dirty, this is NOT updated by scrolling/resizing, so an + /// unchanged generation means the placement set and all image data + /// are identical; only placement geometry (pins) may have moved. + /// Values come from a process-global monotonic counter, so a value + /// observed from any storage never recurs for different content, + /// even across screen switches or storage resets. + /// + /// This field must only be written via markMutated. + generation: u64 = 0, + /// This is the next automatically assigned image ID. We start mid-way /// through the u32 range to avoid collisions with buggy programs. /// TODO: This isn't good enough, it's perfectly legal for programs @@ -80,6 +124,19 @@ pub const ImageStorage = struct { return self.total_limit != 0; } + /// Record a content mutation: marks the storage dirty and assigns a + /// fresh generation stamp. Must be called by anything that changes + /// the set of images or placements (or image contents). + /// + /// Do NOT call this for geometry-only events (scrolling, resizing, + /// screen switches); those must set only the dirty flag directly. + /// Bumping the generation for geometry changes would break the + /// contract that an unchanged generation means unchanged contents. + fn markMutated(self: *ImageStorage) void { + self.dirty = true; + self.generation = nextGeneration(); + } + /// Sets the limit in bytes for the total amount of image data that /// can be loaded. If this limit is lower, this will do an eviction /// if necessary. If the value is zero, then Kitty image protocol will @@ -95,6 +152,7 @@ pub const ImageStorage = struct { const image_limits = self.image_limits; self.deinit(alloc, s); self.* = .{ .image_limits = image_limits }; + self.markMutated(); } // If we re lowering our limit, check if we need to evict. @@ -144,7 +202,12 @@ pub const ImageStorage = struct { gop.value_ptr.* = img; self.total_bytes += img.data.len; - self.dirty = true; + // Stamp the stored image with a fresh generation. This gives + // every add/replace a unique stamp even when the same image ID + // is retransmitted with identical dimensions, so consumers + // (e.g. renderer texture caches) can detect content changes. + self.markMutated(); + gop.value_ptr.generation = self.generation; } /// Add a placement for a given image. The caller must verify in advance @@ -185,7 +248,7 @@ pub const ImageStorage = struct { const gop = try self.placements.getOrPut(alloc, key); gop.value_ptr.* = p; - self.dirty = true; + self.markMutated(); } fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void { @@ -207,7 +270,7 @@ pub const ImageStorage = struct { while (it.next()) |kv| { if (kv.value_ptr.number == image_number) { if (newest == null or - kv.value_ptr.transmit_time.order(newest.?.transmit_time) == .gt) + kv.value_ptr.generation > newest.?.generation) { newest = kv.value_ptr.*; } @@ -224,6 +287,17 @@ pub const ImageStorage = struct { t: *terminal.Terminal, cmd: command.Delete, ) void { + // Deletes only ever remove placements/images, so comparing counts + // before and after tells us whether anything actually changed. + // Only then do we mark a mutation. This matters because a + // delete-all runs on every screen clear (e.g. `ESC [ 2 J`), and + // we don't want empty clears to dirty the image state or bump + // the generation. + const placements_before = self.placements.count(); + const images_before = self.images.count(); + defer if (self.placements.count() != placements_before or + self.images.count() != images_before) self.markMutated(); + switch (cmd) { .all => |delete_images| { var it = self.placements.iterator(); @@ -245,8 +319,6 @@ pub const ImageStorage = struct { var image_it = self.images.iterator(); while (image_it.next()) |kv| self.deleteIfUnused(alloc, kv.key_ptr.*); } - - self.dirty = true; }, .id => |v| self.deleteById( @@ -341,9 +413,6 @@ pub const ImageStorage = struct { if (v.delete) self.deleteIfUnused(alloc, img.id); } } - - // Mark dirty to force redraw - self.dirty = true; }, .row => |v| row: { @@ -373,9 +442,6 @@ pub const ImageStorage = struct { if (v.delete) self.deleteIfUnused(alloc, img.id); } } - - // Mark dirty to force redraw - self.dirty = true; }, .z => |v| { @@ -396,9 +462,6 @@ pub const ImageStorage = struct { if (v.delete) self.deleteIfUnused(alloc, image_id); } } - - // Mark dirty to force redraw - self.dirty = true; }, .range => |v| range: { @@ -420,9 +483,6 @@ pub const ImageStorage = struct { if (v.delete) self.deleteIfUnused(alloc, image_id); } } - - // Mark dirty to force redraw - self.dirty = true; }, // We don't support animation frames yet so they are successfully @@ -461,9 +521,6 @@ pub const ImageStorage = struct { // If this is specified, then we also delete the image // if it is no longer in use. if (delete_unused) self.deleteIfUnused(alloc, image_id); - - // Mark dirty to force redraw - self.dirty = true; } /// Delete an image if it is unused. @@ -507,9 +564,6 @@ pub const ImageStorage = struct { if (delete_unused) self.deleteIfUnused(alloc, img.id); } } - - // Mark dirty to force redraw - self.dirty = true; } /// Evict image to make space. This will evict the oldest image, @@ -526,7 +580,7 @@ pub const ImageStorage = struct { // bit is fine compared to the megabytes we're looking to save. const Candidate = struct { id: u32, - time: std.time.Instant, + generation: u64, used: bool, }; @@ -554,7 +608,7 @@ pub const ImageStorage = struct { try candidates.append(alloc, .{ .id = img.id, - .time = img.transmit_time, + .generation = img.generation, .used = used, }); } @@ -572,12 +626,14 @@ pub const ImageStorage = struct { ) bool { _ = ctx; - // If they're usage matches, then its based on time. - if (lhs.used == rhs.used) return switch (lhs.time.order(rhs.time)) { - .lt => true, - .gt => false, - .eq => lhs.id < rhs.id, - }; + // If their usage matches, then it's based on the + // generation stamp, which orders by transmit time. + // (Stamps are unique but tie-break by ID anyway to + // stay deterministic for hand-built test images.) + if (lhs.used == rhs.used) return if (lhs.generation == rhs.generation) + lhs.id < rhs.id + else + lhs.generation < rhs.generation; // If not used, then its a better candidate return !lhs.used; @@ -585,6 +641,11 @@ pub const ImageStorage = struct { }.lessThan, ); + // Evicting anything is a content mutation. This matters for the + // setLimit path in particular, which doesn't otherwise mark it. + var any_evicted = false; + defer if (any_evicted) self.markMutated(); + // They're in order of best to evict. var evicted: usize = 0; for (candidates.items) |c| { @@ -593,6 +654,7 @@ pub const ImageStorage = struct { while (p_it.next()) |entry| { if (entry.key_ptr.image_id == c.id) { self.placements.removeByPtr(entry.key_ptr); + any_evicted = true; } } @@ -604,6 +666,7 @@ pub const ImageStorage = struct { entry.value_ptr.deinit(alloc); self.images.removeByPtr(entry.key_ptr); + any_evicted = true; if (evicted > req) return true; } @@ -1362,3 +1425,150 @@ test "storage: aspect ratio calculation when only columns or rows specified" { try testing.expectEqual(@as(u32, 100), calc_size.height); } } + +test "storage: generation stamps on image add and replace" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // Fresh storage has generation zero (never mutated). + try testing.expectEqual(@as(u64, 0), s.generation); + + try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); + const gen1 = s.generation; + try testing.expect(gen1 > 0); + + const img1 = s.imageById(1).?; + try testing.expectEqual(gen1, img1.generation); + + // A second image gets a strictly greater stamp. + try s.addImage(alloc, .{ .id = 2, .width = 1, .height = 1 }); + const gen2 = s.generation; + try testing.expect(gen2 > gen1); + try testing.expectEqual(gen2, s.imageById(2).?.generation); + + // Retransmitting the same image ID (identical dimensions) gets a + // fresh stamp: this is what makes same-sized retransmissions + // detectable by renderers. + try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); + const gen3 = s.generation; + try testing.expect(gen3 > gen2); + try testing.expectEqual(gen3, s.imageById(1).?.generation); + + // Image 2 kept its stamp. + try testing.expectEqual(gen2, s.imageById(2).?.generation); +} + +test "storage: generation bumps on placement and delete" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + const gen_add = s.generation; + + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + const gen_place = s.generation; + try testing.expect(gen_place > gen_add); + + // Reads don't change the generation. + _ = s.imageById(1); + _ = s.imageByNumber(1); + try testing.expectEqual(gen_place, s.generation); + + s.delete(alloc, &t, .{ .all = true }); + try testing.expect(s.generation > gen_place); +} + +test "storage: generation bumps when setLimit evicts or disables" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + const data = try alloc.dupe(u8, "1234"); + try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1, .data = data }); + const gen_add = s.generation; + + // Lowering the limit evicts the image and must mark a mutation. + s.dirty = false; + try s.setLimit(alloc, t.screens.active, 1); + try testing.expect(s.dirty); + try testing.expect(s.generation > gen_add); + try testing.expectEqual(@as(usize, 0), s.images.count()); + const gen_evict = s.generation; + + // Disabling (limit=0) resets the storage and must mark a mutation. + s.dirty = false; + try s.setLimit(alloc, t.screens.active, 0); + try testing.expect(s.dirty); + try testing.expect(s.generation > gen_evict); +} + +test "storage: imageByNumber returns most recently transmitted" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // Two images sharing a number: the newest transmission wins, + // regardless of insertion order or clock resolution. + try s.addImage(alloc, .{ .id = 1, .number = 7 }); + try s.addImage(alloc, .{ .id = 2, .number = 7 }); + try testing.expectEqual(@as(u32, 2), s.imageByNumber(7).?.id); + + // Retransmit the first: it becomes the newest. + try s.addImage(alloc, .{ .id = 1, .number = 7 }); + try testing.expectEqual(@as(u32, 1), s.imageByNumber(7).?.id); +} + +test "storage: nextGeneration is unique and monotonic" { + const testing = std.testing; + const a = nextGeneration(); + const b = nextGeneration(); + try testing.expect(b > a); + try testing.expect(a > 0); +} + +test "storage: no-op delete does not mark a mutation" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // A delete-all on an empty storage (this runs on every screen + // clear) must not dirty the state or bump the generation. + s.delete(alloc, &t, .{ .all = true }); + try testing.expect(!s.dirty); + try testing.expectEqual(@as(u64, 0), s.generation); + + // Same for a delete that matches nothing. + try s.addImage(alloc, .{ .id = 1 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + const gen = s.generation; + s.dirty = false; + s.delete(alloc, &t, .{ .id = .{ .image_id = 42 } }); + try testing.expect(!s.dirty); + try testing.expectEqual(gen, s.generation); + + // But a delete that removes something does mark a mutation. + s.delete(alloc, &t, .{ .id = .{ .image_id = 1 } }); + try testing.expect(s.dirty); + try testing.expect(s.generation > gen); +}