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).
This commit is contained in:
Jeffrey C. Ollie
2026-07-19 23:28:50 -05:00
parent 77c65cb5fe
commit a65e11cc92
3 changed files with 117 additions and 19 deletions

View File

@@ -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" {

View File

@@ -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

View File

@@ -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));
}