aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/kitty
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:28:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:29:28 +0300
commit4a3302d19a2baa3a33e910e0088c24e780905b90 (patch)
treedcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/terminal/kitty
parent550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff)
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/kitty')
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/color.zig21
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics.zig1
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig71
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig85
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig22
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig299
6 files changed, 459 insertions, 40 deletions
diff --git a/vendor/libghostty-vt/src/terminal/kitty/color.zig b/vendor/libghostty-vt/src/terminal/kitty/color.zig
index c1072c39..3d2e950c 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/color.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/color.zig
@@ -51,6 +51,23 @@ pub const Kind = union(enum) {
return .{ .palette = std.fmt.parseUnsigned(u8, key, 10) catch return null };
}
+ /// Returns true when a terminal has built-in state for this key.
+ ///
+ /// Unsupported special colors may still be valid Kitty protocol keys, but
+ /// libghostty-vt cannot report them because Terminal does not store them.
+ pub fn hasTerminalQueryColor(self: Kind) bool {
+ return switch (self) {
+ .palette => true,
+ .special => |special| switch (special) {
+ .foreground,
+ .background,
+ .cursor,
+ => true,
+ else => false,
+ },
+ };
+ }
+
pub fn format(
self: Kind,
writer: *std.Io.Writer,
@@ -74,4 +91,8 @@ test "OSC: kitty color protocol kind string" {
const actual = try std.fmt.bufPrint(&buf, "{f}", .{Kind{ .palette = 42 }});
try testing.expectEqualStrings("42", actual);
}
+
+ try testing.expect((Kind{ .palette = 42 }).hasTerminalQueryColor());
+ try testing.expect((Kind{ .special = .foreground }).hasTerminalQueryColor());
+ try testing.expect(!(Kind{ .special = .selection_background }).hasTerminalQueryColor());
}
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig
index 6659cd31..1f639fab 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig
+++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig
index aa35fbeb..bc0ffb5b 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig
@@ -149,6 +149,26 @@ pub const Parser = struct {
}
}
+ /// Feed a slice of bytes to the parser. This is equivalent to
+ /// calling feed for each byte in order, but once we're in the data
+ /// state the remainder of the slice is appended in bulk, avoiding
+ /// per-byte overhead for large payloads.
+ pub fn feedSlice(self: *Parser, bytes: []const u8) !void {
+ var rem = bytes;
+ while (rem.len > 0) {
+ if (self.state == .data) {
+ if (self.data.items.len + rem.len > self.max_bytes) {
+ return error.OutOfMemory;
+ }
+ try self.data.appendSlice(self.arena.child_allocator, rem);
+ return;
+ }
+
+ try self.feed(rem[0]);
+ rem = rem[1..];
+ }
+ }
+
/// Complete the parsing. This must be called after all the
/// bytes have been fed to the parser.
///
@@ -987,6 +1007,57 @@ test "transmission command" {
try testing.expectEqual(@as(u32, 20), v.height);
}
+test "feedSlice matches per-byte feed" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ const input = "f=24,s=10,v=20;aGVsbG8gd29ybGQ=";
+
+ var p1 = Parser.init(alloc, 1024 * 1024);
+ defer p1.deinit();
+ for (input) |c| try p1.feed(c);
+ const c1 = try p1.complete(alloc);
+ defer c1.deinit(alloc);
+
+ var p2 = Parser.init(alloc, 1024 * 1024);
+ defer p2.deinit();
+ try p2.feedSlice(input);
+ const c2 = try p2.complete(alloc);
+ defer c2.deinit(alloc);
+
+ try testing.expect(c1.control == .transmit);
+ try testing.expect(c2.control == .transmit);
+ try testing.expectEqualStrings(c1.data, c2.data);
+}
+
+test "feedSlice across slice boundaries" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var p = Parser.init(alloc, 1024 * 1024);
+ defer p.deinit();
+
+ try p.feedSlice("f=24,s=10");
+ try p.feedSlice(",v=20;aGVsbG8g");
+ try p.feedSlice("d29ybGQ=");
+ const command = try p.complete(alloc);
+ defer command.deinit(alloc);
+
+ try testing.expect(command.control == .transmit);
+
+ // The payload is base64-decoded on completion.
+ try testing.expectEqualStrings("hello world", command.data);
+}
+
+test "feedSlice respects max_bytes" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var p = Parser.init(alloc, 4);
+ defer p.deinit();
+
+ try p.feedSlice("f=24;ab");
+ try testing.expectError(error.OutOfMemory, p.feedSlice("cde"));
+}
+
test "transmission ignores 'm' if medium is not direct" {
const testing = std.testing;
const alloc = testing.allocator;
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig
index a6a879e5..6c72ec4e 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig
+++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig
index 8243a632..b6f44fac 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig
+++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig
index e017d5f7..ba82ea40 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig
@@ -16,6 +16,53 @@ 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.
+///
+/// Thread-safe because separate terminals may mutate their storages
+/// from different threads. On single-threaded targets this lowers to
+/// plain operations.
+var generation_counter: GenerationCounter = .{};
+
+/// 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.next();
+}
+
+/// Backing implementation for the generation counter. We use a
+/// lock-free atomic counter where we can, but not all targets support
+/// 64-bit atomic operations (e.g. 32-bit ARM Android), so we fall back
+/// to a mutex-protected counter on those. This is a cold path (only
+/// invoked on content mutations) so the mutex cost is irrelevant.
+///
+/// The pointer-width check is a conservative proxy for 64-bit atomic
+/// support: every 64-bit target supports 64-bit atomics, while 32-bit
+/// targets may not (per the compiler's atomic operand validation).
+const GenerationCounter = if (@bitSizeOf(usize) >= 64) struct {
+ value: std.atomic.Value(u64) = .init(0),
+
+ fn next(self: *@This()) u64 {
+ return self.value.fetchAdd(1, .monotonic) + 1;
+ }
+} else struct {
+ mutex: std.Thread.Mutex = .{},
+ value: u64 = 0,
+
+ fn next(self: *@This()) u64 {
+ self.mutex.lock();
+ defer self.mutex.unlock();
+ self.value += 1;
+ return self.value;
+ }
+};
+
/// 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 +74,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 +151,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 +179,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 +229,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 +275,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 +297,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 +314,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 +346,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 +440,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 +469,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 +489,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 +510,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 +548,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 +591,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 +607,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 +635,7 @@ pub const ImageStorage = struct {
try candidates.append(alloc, .{
.id = img.id,
- .time = img.transmit_time,
+ .generation = img.generation,
.used = used,
});
}
@@ -572,12 +653,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 +668,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 +681,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 +693,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 +1452,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);
+}