From a65e11cc928f1ecf4d4a1d640d28a37c06e3d20f Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 19 Jul 2026 23:28:50 -0500 Subject: [PATCH] kitty images: add support for transient usage hints Kitty 0.48 added support for usage hints in the image protocol, specifically for marking images as "transient", meaning that they should be prioritized for eviction if there is memory pressure. https://sw.kovidgoyal.net/kitty/graphics-protocol/#image-usage-hints Also changed the eviction algorithm to use an allocated array for organizing the images to be evicted rather than using an ArrayList to minimize the number of allocations made (no real memory savings though). --- src/terminal/kitty/graphics_command.zig | 38 ++++++++++ src/terminal/kitty/graphics_image.zig | 2 + src/terminal/kitty/graphics_storage.zig | 96 ++++++++++++++++++++----- 3 files changed, 117 insertions(+), 19 deletions(-) diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index bc0ffb5ba..ce791072b 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -422,6 +422,7 @@ pub const Transmission = struct { placement_id: u32 = 0, // p compression: Compression = .none, // o more_chunks: bool = false, // m + usage: Usage = .default, // N pub const Format = lib.Enum(lib.target, &.{ "rgb", // 24 @@ -446,6 +447,19 @@ pub const Transmission = struct { "zlib_deflate", // z }); + /// Usage hints allow for optimising resource consumption strategies. + /// + /// https://sw.kovidgoyal.net/kitty/graphics-protocol/#usage-hints + pub const Usage = packed struct(u32) { + /// Image with this usage hint is assumed to be used for only a short + /// time, so may be evicted before other images if memory pressure is + /// encountered. + transient: bool = false, + _padding: u31 = 0, + + pub const default: Usage = .{}; + }; + pub fn formatBpp(format: Format) u8 { return switch (format) { .gray => 1, @@ -529,6 +543,10 @@ pub const Transmission = struct { } } + if (kv.get('N')) |v| { + result.usage = @bitCast(v); + } + return result; } }; @@ -1005,6 +1023,26 @@ test "transmission command" { try testing.expectEqual(Transmission.Format.rgb, v.format); try testing.expectEqual(@as(u32, 10), v.width); try testing.expectEqual(@as(u32, 20), v.height); + try testing.expectEqual(false, v.usage.transient); +} + +test "transmission command with transient hint" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc, 1024 * 1024); + defer p.deinit(); + + const input = "f=24,s=10,v=20,N=1"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + const v = command.control.transmit; + try testing.expectEqual(Transmission.Format.rgb, v.format); + try testing.expectEqual(@as(u32, 10), v.width); + try testing.expectEqual(@as(u32, 20), v.height); + try testing.expectEqual(true, v.usage.transient); } test "feedSlice matches per-byte feed" { diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index b6f44fac2..b66389630 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -88,6 +88,7 @@ pub const LoadingImage = struct { .height = t.height, .compression = t.compression, .format = t.format, + .usage = t.usage, }, .display = cmd.display(), @@ -512,6 +513,7 @@ pub const Image = struct { format: command.Transmission.Format = .rgb, compression: command.Transmission.Compression = .none, data: []const u8 = "", + usage: command.Transmission.Usage = .default, /// Unique, monotonically increasing stamp assigned each time an /// image is added to (or replaced in) an ImageStorage. A changed diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index ba82ea402..6da0f5744 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -594,8 +594,8 @@ pub const ImageStorage = struct { } /// Evict image to make space. This will evict the oldest image, - /// prioritizing unused images first, as recommended by the published - /// Kitty spec. + /// prioritizing transient images and unused images as recommended by the + /// published Kitty spec. /// /// This will evict as many images as necessary to make space for /// req bytes. @@ -608,14 +608,20 @@ pub const ImageStorage = struct { const Candidate = struct { id: u32, generation: u64, - used: bool, + // Map images into four distinct blocks: + // 0: transient, unused + // 1: not transient, unused + // 2: transient, used + // 3: not transient, used + block: u2, }; - var candidates: std.ArrayList(Candidate) = .empty; - defer candidates.deinit(alloc); + const candidates = try alloc.alloc(Candidate, self.images.count()); + defer alloc.free(candidates); var it = self.images.iterator(); - while (it.next()) |kv| { + var i: usize = 0; + while (it.next()) |kv| : (i += 1) { const img = kv.value_ptr; // This is a huge waste. See comment above about redesigning @@ -633,17 +639,24 @@ pub const ImageStorage = struct { break :used false; }; - try candidates.append(alloc, .{ + const transient = img.usage.transient; + + candidates[i] = .{ .id = img.id, .generation = img.generation, - .used = used, - }); + // Map images into four distinct blocks: + // 0: transient, unused + // 1: not transient, unused + // 2: transient, used + // 3: not transient, used + .block = (if (transient) @as(u2, 0) else @as(u2, 1)) + (if (used) @as(u2, 2) else @as(u2, 0)), + }; } // Sort std.mem.sortUnstable( Candidate, - candidates.items, + candidates, {}, struct { fn lessThan( @@ -653,17 +666,20 @@ pub const ImageStorage = struct { ) bool { _ = ctx; - // 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) + // If images mapped into different blocks, prioritize lower + // numbered blocks. + if (lhs.block < rhs.block) return true; + if (lhs.block > rhs.block) return false; + + // If images mapped to the same block, compare generations. + return if (lhs.generation == rhs.generation) + // If the generation is the same, use the ID to + // prioritize evicting "earlier" images. lhs.id < rhs.id else + // If the generation is different, prioritize evicting + // images from earlied generations. lhs.generation < rhs.generation; - - // If not used, then its a better candidate - return !lhs.used; } }.lessThan, ); @@ -675,7 +691,7 @@ pub const ImageStorage = struct { // They're in order of best to evict. var evicted: usize = 0; - for (candidates.items) |c| { + for (candidates) |c| { // Delete all the placements for this image and the image. var p_it = self.placements.iterator(); while (p_it.next()) |entry| { @@ -1599,3 +1615,45 @@ test "storage: no-op delete does not mark a mutation" { try testing.expect(s.dirty); try testing.expect(s.generation > gen); } + +test "storage: evict unused transient image" { + 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 = .{ .total_limit = 192 }; + defer s.deinit(alloc, t.screens.active); + + try s.addImage(alloc, .{ + .id = 1, + .data = try alloc.dupe(u8, "*" ** 64), + .usage = .{ .transient = false }, + }); + try s.addImage(alloc, .{ + .id = 2, + .data = try alloc.dupe(u8, "*" ** 64), + .usage = .{ .transient = true }, + }); + try s.addImage(alloc, .{ + .id = 3, + .data = try alloc.dupe(u8, "*" ** 64), + .usage = .{ .transient = true }, + }); + try s.addPlacement( + alloc, + 2, + 1, + .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }, + ); + + const gen = s.generation; + const result = try s.evictImage(alloc, 32); + try testing.expect(s.dirty); + try testing.expect(s.generation > gen); + try testing.expectEqual(true, result); + try testing.expectEqual(2, s.images.count()); + try testing.expect(s.images.contains(1)); + try testing.expect(s.images.contains(2)); + try testing.expect(!s.images.contains(3)); +}