diff --git a/include/ghostty/vt/kitty_graphics.h b/include/ghostty/vt/kitty_graphics.h index dda8b836e..22f6ac864 100644 --- a/include/ghostty/vt/kitty_graphics.h +++ b/include/ghostty/vt/kitty_graphics.h @@ -390,7 +390,8 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Borrowed pointer to the raw pixel data. Valid as long as the - * underlying terminal is not mutated. + * underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when + * the image metadata is resident but its pixel payload is pending. * * The data is always fully decoded, uncompressed pixels in the * format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads @@ -404,7 +405,9 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Length of the raw pixel data in bytes. Always equal to - * width * height * bytes-per-pixel for the reported format. + * width * height * bytes-per-pixel for the reported format. For a + * pending image, this is the expected length reserved against the + * storage limit even though DATA_PTR is not available yet. * * Output type: size_t * */ @@ -421,7 +424,10 @@ typedef enum GHOSTTY_ENUM_TYPED { * 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. + * image, so zero can be used as an "empty" sentinel by callers. Pending + * payload completion preserves this value to retain image age; consumers + * detect that completion through GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION + * and retry DATA_PTR. * * Output type: uint64_t * */ diff --git a/src/renderer/image.zig b/src/renderer/image.zig index dc5e3e228..3d5416209 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -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()); +} diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index 310b79cff..e47e6894f 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -261,8 +261,8 @@ fn imageGetTyped( .height => out.* = image.height, .format => out.* = image.format, .compression => out.* = image.compression, - .data_ptr => out.* = image.data.ptr, - .data_len => out.* = image.data.len, + .data_ptr => out.* = (image.data.bytes() orelse return .no_value).ptr, + .data_len => out.* = image.data.len(), .generation => out.* = image.generation, } @@ -990,6 +990,57 @@ test "image_get_handle and image_get with transmitted image" { try testing.expect(data_len > 0); } +test "image_get exposes pending metadata without a data pointer" { + 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, + 80, + 24, + )); + defer terminal_c.free(t); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + const alloc = lib.alloc.default(&lib.alloc.test_allocator); + const pending = try graphics.addPendingImage(testing.io, alloc, .{ + .id = 42, + .number = 7, + .width = 1, + .height = 4, + .format = .rgb, + .data = .{ .pending = 12 }, + }); + + const img = image_get_handle(graphics, 42); + try testing.expect(img != null); + + var number: u32 = 0; + try testing.expectEqual(Result.success, image_get(img, .number, @ptrCast(&number))); + try testing.expectEqual(@as(u32, 7), number); + + var data_len: usize = 0; + try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&data_len))); + try testing.expectEqual(@as(usize, 12), data_len); + + var data_ptr: [*]const u8 = undefined; + try testing.expectEqual(Result.no_value, image_get(img, .data_ptr, @ptrCast(&data_ptr))); + + const pixels = try alloc.dupe(u8, "*" ** 12); + try testing.expect(pending.complete(graphics, testing.io, pixels)); + const completed_img = image_get_handle(graphics, 42); + try testing.expect(completed_img != null); + try testing.expectEqual(Result.success, image_get(completed_img, .data_ptr, @ptrCast(&data_ptr))); + try testing.expectEqualSlices(u8, pixels, data_ptr[0..data_len]); +} + test "placement_rect with transmit and display" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index ef584c09a..bfb8e6b44 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -433,7 +433,7 @@ pub const LoadingImage = struct { // Everything looks good, copy the image data over. var result = self.image; - result.data = try self.data.toOwnedSlice(alloc); + result.data = .{ .complete = try self.data.toOwnedSlice(alloc) }; errdefer result.deinit(alloc); self.image = .{}; return result; @@ -527,13 +527,13 @@ pub const LoadingImage = struct { } }; -/// Image represents a single fully loaded image. +/// Image represents a single image whose metadata is fully known. /// -/// 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`. +/// Complete 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. Pending image data reserves the exact decoded byte +/// length that will be attached later. pub const Image = struct { id: u32 = 0, number: u32 = 0, @@ -541,7 +541,7 @@ pub const Image = struct { height: u32 = 0, format: command.Transmission.Format = .rgb, compression: command.Transmission.Compression = .none, - data: []const u8 = "", + data: Data = .{ .complete = "" }, usage: command.Transmission.Usage = .default, /// Unique, monotonically increasing stamp assigned each time an @@ -571,14 +571,49 @@ pub const Image = struct { UnsupportedDepth, }; + pub const Data = union(enum) { + /// Owned, decoded image bytes. The empty default is not allocated. + complete: []const u8, + + /// Expected decoded byte length for a payload that has not arrived. + pending: usize, + + /// Bytes reserved against the storage limit. + pub fn len(self: Data) usize { + return switch (self) { + .complete => |data| data.len, + .pending => |expected_len| expected_len, + }; + } + + /// Returns decoded bytes when the payload is complete. + pub fn bytes(self: Data) ?[]const u8 { + return switch (self) { + .complete => |data| data, + .pending => null, + }; + } + + pub fn isPending(self: Data) bool { + return self == .pending; + } + + pub fn deinit(self: *Data, alloc: Allocator) void { + switch (self.*) { + .complete => |data| if (data.len > 0) alloc.free(data), + .pending => {}, + } + } + }; + pub fn deinit(self: *Image, alloc: Allocator) void { - if (self.data.len > 0) alloc.free(self.data); + self.data.deinit(alloc); } /// Mostly for logging pub fn withoutData(self: *const Image) Image { var copy = self.*; - copy.data = ""; + if (copy.data == .complete) copy.data = .{ .complete = "" }; return copy; } }; diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 2a9ad7f56..d59de54d4 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -132,6 +132,38 @@ pub const ImageStorage = struct { total_bytes: usize = 0, total_limit: usize = 320 * 1000 * 1000, // 320MB + /// Identifies one exact pending image transmission. The generation is + /// assigned by this storage when the pending image is inserted, so a + /// later replacement of the same ID cannot consume stale payload bytes. + pub const PendingImage = struct { + id: u32, + generation: u64, + + /// Attach owned decoded bytes if this exact pending transmission is + /// still resident. On true, storage owns `data`; on false, the caller + /// retains ownership. Completion preserves the image generation (and + /// therefore eviction age) while marking storage content as mutated. + pub fn complete( + self: PendingImage, + storage: *ImageStorage, + io: std.Io, + data: []const u8, + ) bool { + const img = storage.images.getPtr(self.id) orelse return false; + if (img.generation != self.generation) return false; + + const expected_len = switch (img.data) { + .complete => return false, + .pending => |len| len, + }; + if (data.len != expected_len) return false; + + img.data = .{ .complete = data }; + storage.markMutated(io); + return true; + } + }; + pub fn deinit( self: *ImageStorage, alloc: Allocator, @@ -196,40 +228,54 @@ pub const ImageStorage = struct { self.total_limit = limit; } - /// Add an already-loaded image to the storage. This will automatically - /// free any existing image with the same ID. + /// Add an image to the storage. This will automatically free any existing + /// image with the same ID. Prefer addPendingImage for pending data so the + /// caller receives a completion token. pub fn addImage(self: *ImageStorage, io: std.Io, alloc: Allocator, img: Image) Allocator.Error!void { + const new_len = img.data.len(); + // If the image itself is over the limit, then error immediately - if (img.data.len > self.total_limit) return error.OutOfMemory; + if (new_len > self.total_limit) return error.OutOfMemory; + + // Credit an existing image's reservation before calculating the + // replacement size. In particular, a completed live transmission + // replacing pending snapshot metadata must be able to reuse the + // reservation without evicting its own ID and placements. + const old_len = if (self.images.get(img.id)) |old| + old.data.len() + else + 0; + assert(old_len <= self.total_bytes); + + // Reserve map capacity before evicting anything so allocation failure + // cannot leave storage partially evicted. Existing keys need no room. + if (!self.images.contains(img.id)) { + try self.images.ensureUnusedCapacity(alloc, 1); + } // If this would put us over the limit, then evict. - const total_bytes = self.total_bytes + img.data.len; + const total_bytes = self.total_bytes - old_len + new_len; if (total_bytes > self.total_limit) { const req_bytes = total_bytes - self.total_limit; log.info("evicting images to make space for {} bytes", .{req_bytes}); - if (!try self.evictImage(io, alloc, req_bytes)) { + if (!try self.evictImageExcept(io, alloc, req_bytes, img.id)) { log.warn("failed to evict enough images for required bytes", .{}); return error.OutOfMemory; } } - // Do the gop op first so if it fails we don't get a partial state - const gop = try self.images.getOrPut(alloc, img.id); + const gop = self.images.getOrPutAssumeCapacity(img.id); - log.debug("addImage image={}", .{img: { - var copy = img; - copy.data = ""; - break :img copy; - }}); + log.debug("addImage image={}", .{img.withoutData()}); // Write our new image if (gop.found_existing) { - self.total_bytes -= gop.value_ptr.data.len; + self.total_bytes -= gop.value_ptr.data.len(); gop.value_ptr.deinit(alloc); } gop.value_ptr.* = img; - self.total_bytes += img.data.len; + self.total_bytes += new_len; // Stamp the stored image with a fresh generation. This gives // every add/replace a unique stamp even when the same image ID @@ -239,6 +285,20 @@ pub const ImageStorage = struct { gop.value_ptr.generation = self.generation; } + /// Add an image whose decoded payload bytes have not arrived yet. + /// Returns the token required to complete this exact transmission. + pub fn addPendingImage( + self: *ImageStorage, + io: std.Io, + alloc: Allocator, + img: Image, + ) Allocator.Error!PendingImage { + assert(img.data == .pending); + try self.addImage(io, alloc, img); + const stored = self.images.get(img.id).?; + return .{ .id = stored.id, .generation = stored.generation }; + } + /// Add a placement for a given image. The caller must verify in advance /// the image exists to prevent memory corruption. pub fn addPlacement( @@ -565,7 +625,7 @@ pub const ImageStorage = struct { // If we get here, we can delete the image. if (self.images.getEntry(image_id)) |entry| { - self.total_bytes -= entry.value_ptr.data.len; + self.total_bytes -= entry.value_ptr.data.len(); entry.value_ptr.deinit(alloc); self.images.removeByPtr(entry.key_ptr); } @@ -604,6 +664,18 @@ pub const ImageStorage = struct { /// This will evict as many images as necessary to make space for /// req bytes. fn evictImage(self: *ImageStorage, io: std.Io, alloc: Allocator, req: usize) !bool { + return self.evictImageExcept(io, alloc, req, null); + } + + /// Evict images while preserving `exclude_id`, used when replacing an + /// existing image whose old reservation has already been credited. + fn evictImageExcept( + self: *ImageStorage, + io: std.Io, + alloc: Allocator, + req: usize, + exclude_id: ?u32, + ) !bool { assert(req <= self.total_limit); // Ironically we allocate to evict. We should probably redesign the @@ -624,9 +696,10 @@ pub const ImageStorage = struct { defer alloc.free(candidates); var it = self.images.iterator(); - var i: usize = 0; - while (it.next()) |kv| : (i += 1) { + var candidate_count: usize = 0; + while (it.next()) |kv| { const img = kv.value_ptr; + if (exclude_id != null and img.id == exclude_id.?) continue; // This is a huge waste. See comment above about redesigning // our data structures to avoid this. Eviction should be very @@ -645,7 +718,7 @@ pub const ImageStorage = struct { const transient = img.usage.transient; - candidates[i] = .{ + candidates[candidate_count] = .{ .id = img.id, .generation = img.generation, // Map images into four distinct blocks: @@ -655,12 +728,15 @@ pub const ImageStorage = struct { // 3: not transient, used .block = (if (transient) @as(u2, 0) else @as(u2, 1)) + (if (used) @as(u2, 2) else @as(u2, 0)), }; + candidate_count += 1; } + const candidate_slice = candidates[0..candidate_count]; + // Sort std.mem.sortUnstable( Candidate, - candidates, + candidate_slice, {}, struct { fn lessThan( @@ -695,7 +771,7 @@ pub const ImageStorage = struct { // They're in order of best to evict. var evicted: usize = 0; - for (candidates) |c| { + for (candidate_slice) |c| { // Delete all the placements for this image and the image. var p_it = self.placements.iterator(); while (p_it.next()) |entry| { @@ -706,16 +782,17 @@ pub const ImageStorage = struct { } if (self.images.getEntry(c.id)) |entry| { - log.info("evicting image id={} bytes={}", .{ c.id, entry.value_ptr.data.len }); + const image_len = entry.value_ptr.data.len(); + log.info("evicting image id={} bytes={}", .{ c.id, image_len }); - evicted += entry.value_ptr.data.len; - self.total_bytes -= entry.value_ptr.data.len; + evicted += image_len; + self.total_bytes -= image_len; entry.value_ptr.deinit(alloc); self.images.removeByPtr(entry.key_ptr); any_evicted = true; - if (evicted > req) return true; + if (evicted >= req) return true; } } @@ -1566,7 +1643,12 @@ test "storage: generation bumps when setLimit evicts or disables" { defer s.deinit(alloc, t.screens.active); const data = try alloc.dupe(u8, "1234"); - try s.addImage(io, alloc, .{ .id = 1, .width = 1, .height = 1, .data = data }); + try s.addImage(io, alloc, .{ + .id = 1, + .width = 1, + .height = 1, + .data = .{ .complete = data }, + }); const gen_add = s.generation; // Lowering the limit evicts the image and must mark a mutation. @@ -1656,17 +1738,17 @@ test "storage: evict unused transient image" { try s.addImage(io, alloc, .{ .id = 1, - .data = try alloc.dupe(u8, "*" ** 64), + .data = .{ .complete = try alloc.dupe(u8, "*" ** 64) }, .usage = .{ .transient = false }, }); try s.addImage(io, alloc, .{ .id = 2, - .data = try alloc.dupe(u8, "*" ** 64), + .data = .{ .complete = try alloc.dupe(u8, "*" ** 64) }, .usage = .{ .transient = true }, }); try s.addImage(io, alloc, .{ .id = 3, - .data = try alloc.dupe(u8, "*" ** 64), + .data = .{ .complete = try alloc.dupe(u8, "*" ** 64) }, .usage = .{ .transient = true }, }); try s.addPlacement( @@ -1687,3 +1769,191 @@ test "storage: evict unused transient image" { try testing.expect(s.images.contains(2)); try testing.expect(!s.images.contains(3)); } + +test "storage: pending image completes once and preserves age" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{ .total_limit = 16 }; + defer s.deinit(alloc, t.screens.active); + + const pending = try s.addPendingImage(io, alloc, .{ + .id = 1, + .number = 7, + .width = 2, + .height = 2, + .format = .rgba, + .data = .{ .pending = 16 }, + }); + try testing.expectEqual(@as(usize, 16), s.total_bytes); + try testing.expectEqual(@as(u32, 1), s.imageByNumber(7).?.id); + try testing.expect(s.imageById(1).?.data.isPending()); + + try s.addPlacement(io, alloc, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }, + }); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + + const wrong = try alloc.dupe(u8, "short"); + const wrong_completed = pending.complete(&s, io, wrong); + try testing.expect(!wrong_completed); + defer alloc.free(wrong); + + const storage_generation = s.generation; + s.dirty = false; + const pixels = try alloc.dupe(u8, "*" ** 16); + try testing.expect(pending.complete(&s, io, pixels)); + try testing.expect(s.dirty); + try testing.expect(s.generation > storage_generation); + try testing.expectEqual(pending.generation, s.imageById(1).?.generation); + try testing.expectEqual(@as(usize, 16), s.total_bytes); + try testing.expectEqualSlices(u8, pixels, s.imageById(1).?.data.bytes().?); + + const duplicate = try alloc.dupe(u8, "!" ** 16); + const duplicate_completed = pending.complete(&s, io, duplicate); + try testing.expect(!duplicate_completed); + defer alloc.free(duplicate); +} + +test "storage: stale pending completion loses to delete replacement and eviction" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{ .total_limit = 8 }; + defer s.deinit(alloc, t.screens.active); + + const deleted = try s.addPendingImage(io, alloc, .{ + .id = 1, + .data = .{ .pending = 4 }, + }); + s.delete(io, alloc, &t, .{ .id = .{ .delete = true, .image_id = 1 } }); + const deleted_data = try alloc.dupe(u8, "gone"); + const deleted_completed = deleted.complete(&s, io, deleted_data); + try testing.expect(!deleted_completed); + defer alloc.free(deleted_data); + + const replaced = try s.addPendingImage(io, alloc, .{ + .id = 2, + .data = .{ .pending = 4 }, + }); + try s.addImage(io, alloc, .{ + .id = 2, + .data = .{ .complete = try alloc.dupe(u8, "live") }, + }); + const replaced_data = try alloc.dupe(u8, "late"); + const replaced_completed = replaced.complete(&s, io, replaced_data); + try testing.expect(!replaced_completed); + defer alloc.free(replaced_data); + try testing.expectEqualStrings("live", s.imageById(2).?.data.bytes().?); + + const evicted = try s.addPendingImage(io, alloc, .{ + .id = 3, + .data = .{ .pending = 4 }, + }); + try s.addImage(io, alloc, .{ + .id = 4, + .data = .{ .complete = try alloc.dupe(u8, "12345678") }, + }); + try testing.expect(s.imageById(3) == null); + const evicted_data = try alloc.dupe(u8, "late"); + const evicted_completed = evicted.complete(&s, io, evicted_data); + try testing.expect(!evicted_completed); + defer alloc.free(evicted_data); +} + +test "storage: replacement reuses pending reservation and preserves placements" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{ .total_limit = 12 }; + defer s.deinit(alloc, t.screens.active); + + const pending = try s.addPendingImage(io, alloc, .{ + .id = 1, + .width = 2, + .height = 1, + .format = .rgba, + .data = .{ .pending = 8 }, + }); + try s.addPlacement(io, alloc, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }, + }); + try s.addImage(io, alloc, .{ + .id = 2, + .data = .{ .complete = try alloc.dupe(u8, "keep") }, + }); + + try s.addImage(io, alloc, .{ + .id = 1, + .width = 2, + .height = 1, + .format = .rgba, + .data = .{ .complete = try alloc.dupe(u8, "12345678") }, + }); + try testing.expect(s.images.contains(1)); + try testing.expect(s.images.contains(2)); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 12), s.total_bytes); + + const stale = try alloc.dupe(u8, "snapshot"); + const stale_completed = pending.complete(&s, io, stale); + try testing.expect(!stale_completed); + defer alloc.free(stale); + + // Growing the replacement requires eviction, but the replacement ID and + // its placement are excluded. The other image supplies the needed bytes. + try s.addImage(io, alloc, .{ + .id = 1, + .data = .{ .complete = try alloc.dupe(u8, "1234567890") }, + }); + try testing.expect(s.images.contains(1)); + try testing.expect(!s.images.contains(2)); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 10), s.total_bytes); +} + +test "storage: pending images share exact eviction ordering" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{ .total_limit = 192 }; + defer s.deinit(alloc, t.screens.active); + + _ = try s.addPendingImage(io, alloc, .{ + .id = 1, + .data = .{ .pending = 64 }, + .usage = .{ .transient = false }, + }); + _ = try s.addPendingImage(io, alloc, .{ + .id = 2, + .data = .{ .pending = 64 }, + .usage = .{ .transient = true }, + }); + try s.addImage(io, alloc, .{ + .id = 3, + .data = .{ .complete = try alloc.dupe(u8, "*" ** 64) }, + .usage = .{ .transient = true }, + }); + try s.addPlacement(io, alloc, 2, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }, + }); + + // ID 3 is transient and unused, so one exact-size eviction is enough. + try testing.expect(try s.evictImage(io, alloc, 64)); + try testing.expect(s.images.contains(1)); + try testing.expect(s.images.contains(2)); + try testing.expect(!s.images.contains(3)); + try testing.expectEqual(@as(usize, 128), s.total_bytes); +}