From cd5f9eef0a1ac100b022acdfb565d64579c03225 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 20 Aug 2026 14:51:00 -0700 Subject: [PATCH] terminal/kitty: add relative placement storage --- src/renderer/image.zig | 4 + src/terminal/c/kitty_graphics.zig | 4 +- src/terminal/kitty/graphics_exec.zig | 10 +- src/terminal/kitty/graphics_storage.zig | 529 +++++++++++++++++++++--- 4 files changed, 489 insertions(+), 58 deletions(-) diff --git a/src/renderer/image.zig b/src/renderer/image.zig index 8aa755e0b..98882ec66 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -319,6 +319,10 @@ pub const State = struct { // placement itself. continue; }, + + // Nothing creates relative placements yet; rendering + // support comes with the protocol wiring. + .relative => continue, } // Get the image for the placement diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index 02fb650ae..3ee0f7b73 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -602,7 +602,7 @@ fn computeViewportPos( // screen position — they are rendered inline by the text layout. const pin = switch (p.location) { .pin => |pin| pin, - .virtual => return .{ .col = 0, .row = 0, .visible = false }, + .virtual, .relative => return .{ .col = 0, .row = 0, .visible = false }, }; if (pin.garbage) return .{ .col = 0, .row = 0, .visible = false }; @@ -1632,7 +1632,7 @@ test "placement_render_info returns all fields" { const entry = iter.?.entry.?; const pin = switch (entry.value_ptr.location) { .pin => |pin| pin, - .virtual => unreachable, + .virtual, .relative => unreachable, }; pin.garbage = true; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 461fc097e..4e3e4f4e4 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -304,9 +304,11 @@ fn display( return result; }; - // Apply cursor movement setting. This only applies to pin placements. + // Apply cursor movement setting. This only applies to pin placements: + // relative placements never move the cursor regardless of C=, just + // like kitty. switch (p.location) { - .virtual => {}, + .virtual, .relative => {}, .pin => |pin| switch (d.cursor_movement) { .none => {}, .after => { @@ -1320,11 +1322,11 @@ test "kittygfx placement moves cursor past a tall image" { }).?; const first_pin = switch (first.location) { .pin => |pin| pin, - .virtual => unreachable, + .virtual, .relative => unreachable, }; const second_pin = switch (second.location) { .pin => |pin| pin, - .virtual => unreachable, + .virtual, .relative => unreachable, }; const first_y = t.screens.active.pages.pointFromPin( .screen, diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 545ffce7f..53382344c 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -307,10 +307,14 @@ pub const ImageStorage = struct { log.debug("addImage image={}", .{img.withoutData()}); - // Retransmitting a specific image ID replaces the old image and all - // of its placements, as required by the Kitty graphics protocol. if (gop.found_existing) { + // Retransmitting a specific image ID replaces the old image and all + // of its placements, as required by the Kitty graphics protocol. self.removePlacementsByImageId(s, img.id); + + // Relative placements parented to the removed placements go too. + _ = self.removeOrphans(s, null); + self.total_bytes -= gop.value_ptr.data.len(); gop.value_ptr.deinit(alloc); } @@ -362,13 +366,11 @@ pub const ImageStorage = struct { p, }); - // Tracked pins are marked garbage when their underlying history is - // pruned. Kitty removes placements once they scroll out of retained - // history, so reclaim those placements before growing the map for a - // new one. If allocation below fails, the sweep is still a content - // mutation and must be visible to consumers. - const removed_garbage = self.removeGarbagePlacements(s); - errdefer if (removed_garbage) self.markMutated(io); + // When we add a placement, take this opportunity to reap garbage. + // Garbage are placements that disappeared off scrollback (were + // pruned). We don't clear Kitty state in the hot path so we do + // this opportunistically here. + self.reapGarbagePlacements(io, s); // The important piece here is that the placement ID needs to // be marked internal if it is zero. This allows multiple placements @@ -405,6 +407,7 @@ pub const ImageStorage = struct { } gop.value_ptr.* = p; + // This always mutates self.markMutated(io); } @@ -467,13 +470,14 @@ pub const ImageStorage = struct { const p: *Placement = entry.value_ptr; // Virtual placements follow their placeholder cells and - // are never adjusted by scrolls, matching kitty. + // relative placements follow their parents, so neither is + // ever adjusted by scrolls, matching kitty. const pin: *PageList.Pin = switch (p.location) { .pin => |pin| pin, - .virtual => continue, + .virtual, .relative => continue, }; - // Pruned placements are reaped by removeGarbagePlacements. + // Pruned placements are reaped by reapGarbagePlacements. if (pin.garbage) continue; // Placements anchored outside the active area (scrollback) @@ -550,8 +554,7 @@ pub const ImageStorage = struct { // placement is deleted, like kitty. The image itself is // retained for future placements. if (!visible) { - p.deinit(s); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(s, entry); mutated = true; continue; } @@ -569,31 +572,47 @@ pub const ImageStorage = struct { }; } - if (mutated) self.markMutated(io); + if (mutated) { + // Placements deleted by clipping may orphan relative placements. + // Orphans have no pins so this never touches the restores. + _ = self.removeOrphans(s, null); + self.markMutated(io); + } + return result; } - /// Remove pin-backed placements whose tracked content has been pruned. - /// Virtual placements have no tracked screen location and are retained. - fn removeGarbagePlacements( + /// Reap pin-backed placements whose tracked content has been pruned + /// from history, along with any relative placements orphaned by + /// that, marking the content mutation. + /// + /// Virtual and relative placements have no tracked screen location and + /// are never pruned themselves. Kitty removes placements once they + /// scroll out of retained history. + fn reapGarbagePlacements( self: *ImageStorage, + io: std.Io, s: *terminal.Screen, - ) bool { + ) void { var removed = false; var it = self.placements.iterator(); while (it.next()) |entry| { const pin = switch (entry.value_ptr.location) { .pin => |pin| pin, - .virtual => continue, + .virtual, .relative => continue, }; if (!pin.garbage) continue; - entry.value_ptr.deinit(s); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(s, entry); removed = true; } + if (!removed) return; - return removed; + // If we removed a placement, then also remove any orphan + // children if this was a parent. + _ = self.removeOrphans(s, null); + + self.markMutated(io); } fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void { @@ -610,23 +629,250 @@ pub const ImageStorage = struct { var it = self.placements.iterator(); while (it.next()) |entry| { if (entry.key_ptr.image_id != image_id) continue; - entry.value_ptr.deinit(s); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(s, entry); } } - fn removePlacementByPtr(self: *ImageStorage, key: *PlacementKey) void { - const img = self.images.getPtr(key.image_id).?; + /// Remove a placement by its map entry, releasing any tracked pin + /// and keeping the image's placement count in sync. The entry must + /// point into the placements map (via iteration or getEntry). + fn removePlacement( + self: *ImageStorage, + s: *terminal.Screen, + entry: PlacementMap.Entry, + ) void { + entry.value_ptr.deinit(s); + const img = self.images.getPtr(entry.key_ptr.image_id).?; assert(img.metadata.placement_count > 0); img.metadata.placement_count -= 1; - self.placements.removeByPtr(key); + self.placements.removeByPtr(entry.key_ptr); + } + + /// Remove relative placements whose parent placement no longer + /// exists, keeping a relative placement's lifetime tied to its + /// parent chain. Chains can be several links deep, so this loops + /// until a pass removes nothing to take out entire orphaned + /// subtrees. Returns true if anything was removed. + /// + /// If `delete_unused` is non-null, an image left without placements + /// by the reap is freed using it. This matches uppercase delete + /// semantics; every other removal path retains image data and + /// passes null. + /// + /// This must be called, outside of any placements iteration, after + /// every operation that removes placements. Kitty gets the same + /// effect lazily by dropping unresolvable placements while drawing; + /// we do it eagerly because our renderer never mutates terminal + /// state. + fn removeOrphans( + self: *ImageStorage, + s: *terminal.Screen, + delete_unused: ?Allocator, + ) bool { + var removed_any = false; + var removed = true; + while (removed) { + removed = false; + var it = self.placements.iterator(); + while (it.next()) |entry| { + const rel = switch (entry.value_ptr.location) { + .relative => |rel| rel, + .pin, .virtual => continue, + }; + if (self.placements.contains(rel.parent)) continue; + + // Parent is gone, remove this placement. + const image_id = entry.key_ptr.image_id; + self.removePlacement(s, entry); + removed = true; + removed_any = true; + + if (delete_unused) |alloc| self.deleteIfUnused( + alloc, + image_id, + ); + } + } + + return removed_any; + } + + /// Maximum number of parent links in a relative placement chain, + /// matching the minimum specified in the spec and the actual + /// limit defined by Kitty at the time of authoring. + pub const parent_chain_limit = 8; + + pub const ParentError = error{ + /// The parent image (P=) does not exist. + ParentImageNotFound, + /// The parent image exists but the requested placement (Q=, or + /// any placement when Q is omitted) does not. + ParentPlacementNotFound, + /// The placement refers to itself as its own parent. + SelfParent, + /// The parent chain loops back to the placement being created. + Cycle, + /// The parent chain exceeds parent_chain_limit links. + TooDeep, + /// An ancestor in the chain no longer exists. This should not + /// happen since removeOrphans keeps chains intact, but we + /// check anyway rather than trusting the invariant. + AncestorNotFound, + }; + + /// Resolve and validate the parent reference (P=/Q=) of a relative + /// placement, returning the concrete key of the parent placement. + /// `child` is the key of the placement being created when it is + /// addressable (an explicit placement ID); null for placements that + /// will receive a fresh internal ID, which nothing can refer to yet. + /// + /// Checks: the parent must exist, the placement must not + /// parent itself, and the resulting ancestor chain must be acyclic + /// and within parent_chain_limit. + pub fn resolveParent( + self: *ImageStorage, + io: std.Io, + s: *terminal.Screen, + child: ?PlacementKey, + parent_image_id: u32, + parent_placement_id: u32, + ) ParentError!PlacementKey { + // Reap placements whose content has been pruned from history + // before resolving: a pruned parent must be reported as missing, + // not accepted and then immediately reaped by addPlacement's own + // sweep, which would store an orphan. + self.reapGarbagePlacements(io, s); + + // If the parent doesn't exist we're already failed. + if (!self.images.contains(parent_image_id)) { + return error.ParentImageNotFound; + } + + // Find the parent + const parent: PlacementKey = parent: { + // An explicit parent placement ID (Q=) selects exactly that + // placement. + if (parent_placement_id > 0) { + const key: PlacementKey = .{ + .image_id = parent_image_id, + .placement_id = .{ + .tag = .external, + .id = parent_placement_id, + }, + }; + if (!self.placements.contains(key)) { + return error.ParentPlacementNotFound; + } + break :parent key; + } + + // No Q: pick a placement of the parent image. Kitty picks + // the oldest surviving placement; our placement map doesn't + // track creation order so we pick by PlacementId.preferredOver + // instead. This is unspecified so any behavior here is fine. + // In practice the parent image has a single placement, and + // clients use Q when it doesn't. + const best: PlacementId = best: { + var best: ?PlacementId = null; + var it = self.placements.keyIterator(); + while (it.next()) |key| { + if (key.image_id != parent_image_id) continue; + const id = key.placement_id; + const b = best orelse { + best = id; + continue; + }; + if (id.preferredOver(b)) best = id; + } + break :best best orelse return error.ParentPlacementNotFound; + }; + + break :parent .{ + .image_id = parent_image_id, + .placement_id = best, + }; + }; + + // A placement cannot be its own parent. This must be checked + // before the chain walk so it reports EINVAL, not ECYCLE. + if (child) |c| if (parent.eql(c)) return error.SelfParent; + + // Walk the would-be ancestor chain. `depth` counts parent links, + // starting at one for the link we are about to create. + var depth: usize = 1; + var key = parent; + while (true) { + if (child) |c| if (key.eql(c)) return error.Cycle; + const p = self.placements.get(key) orelse return error.AncestorNotFound; + const rel = switch (p.location) { + .relative => |rel| rel, + // A pin or virtual placement is the chain root. + .pin, .virtual => break, + }; + if (depth >= parent_chain_limit) return error.TooDeep; + depth += 1; + key = rel.parent; + } + + return parent; + } + + /// The result of resolving a relative placement's parent chain: + /// the chain's root placement (always pin or virtual, never + /// relative) and the total cell offset from the root's origin. + pub const ResolvedChain = struct { + root_key: PlacementKey, + root: Placement, + horizontal_offset: i32, + vertical_offset: i32, + }; + + /// Resolve a relative placement's parent chain to its root, + /// accumulating the cell offsets (H=/V=) of every link on the way. + /// The placement's position is the root's origin plus the accumulated + /// offsets. + /// + /// Returns null when the chain is broken or too deep. Neither can + /// normally happen for stored placements (resolveParent validates + /// chains and removeOrphans removes broken ones), with one kitty + /// quirk: replacing an ancestor placement can deepen the chains + /// below it beyond parent_chain_limit after the fact. + pub fn resolveChain( + self: *const ImageStorage, + rel: Placement.Relative, + ) ?ResolvedChain { + var horizontal = rel.horizontal_offset; + var vertical = rel.vertical_offset; + var key = rel.parent; + var depth: usize = 1; + while (true) { + const p = self.placements.get(key) orelse return null; + switch (p.location) { + .relative => |parent_rel| { + if (depth >= parent_chain_limit) return null; + depth += 1; + horizontal +|= parent_rel.horizontal_offset; + vertical +|= parent_rel.vertical_offset; + key = parent_rel.parent; + }, + + .pin, .virtual => return .{ + .root_key = key, + .root = p, + .horizontal_offset = horizontal, + .vertical_offset = vertical, + }, + } + } } /// Returns the placement that a unicode placeholder cell referencing /// this image/placement ID pair targets, or null if there is none. A /// zero placement ID targets one of the image's virtual placements, /// chosen by PlacementId.preferredOver so the choice is stable (an - /// image rarely has more than one). + /// image rarely has more than one). This is the shared lookup used + /// both for sizing placeholder runs and for positioning relative + /// placements whose chain roots at a virtual placement. pub fn placeholderTarget( self: *const ImageStorage, image_id: u32, @@ -694,8 +940,11 @@ pub const ImageStorage = struct { const placements_before = self.placements.count(); const images_before = self.images.count(); - // Delete unused placements and images + // Delete unused placements and images. Orphaned relative + // placements must be reaped before the image sweep so that + // their images are reclaimable too. self.deleteVisiblePlacements(alloc, t, true); + _ = self.removeOrphans(t.screens.active, null); var image_it = self.images.iterator(); while (image_it.next()) |entry| { self.deleteIfUnused(alloc, entry.key_ptr.*); @@ -817,8 +1066,7 @@ pub const ImageStorage = struct { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t) orelse continue; if (rect.top_left.x <= x and rect.bottom_right.x >= x) { - entry.value_ptr.deinit(t.screens.active); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(t.screens.active, entry); if (v.delete) self.deleteIfUnused(alloc, img.id); } } @@ -846,8 +1094,7 @@ pub const ImageStorage = struct { var target_pin_copy = target_pin; target_pin_copy.x = rect.top_left.x; if (target_pin_copy.isBetween(rect.top_left, rect.bottom_right)) { - entry.value_ptr.deinit(t.screens.active); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(t.screens.active, entry); if (v.delete) self.deleteIfUnused(alloc, img.id); } } @@ -857,7 +1104,9 @@ pub const ImageStorage = struct { var it = self.placements.iterator(); while (it.next()) |entry| { switch (entry.value_ptr.location) { - .pin => {}, + // Relative placements carry their own z value and + // are matched by z deletes just like kitty does. + .pin, .relative => {}, // Virtual placeholders cannot delete by z according // to the spec. @@ -866,8 +1115,7 @@ pub const ImageStorage = struct { if (entry.value_ptr.z == v.z) { const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(t.screens.active); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(t.screens.active, entry); if (v.delete) self.deleteIfUnused(alloc, image_id); } } @@ -885,8 +1133,7 @@ pub const ImageStorage = struct { if (entry.key_ptr.image_id < v.first or entry.key_ptr.image_id > v.last) continue; - entry.value_ptr.deinit(t.screens.active); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(t.screens.active, entry); } // Uppercase deletion also frees matching images that are now @@ -905,6 +1152,21 @@ pub const ImageStorage = struct { // deleted! .animation_frames => {}, } + + // Deleting placements orphans any relative placements parented + // to them (transitively). Their lifetime is tied to the parent + // so they are removed as well, and an uppercase delete also + // frees any image the cascade leaves without placements (the + // per-branch deleteIfUnused calls above ran while the orphans + // still counted as placements). + const delete_unused: bool = switch (cmd) { + .all, .intersect_cursor, .animation_frames => |v| v, + inline else => |v| v.delete, + }; + _ = self.removeOrphans( + t.screens.active, + if (delete_unused) alloc else null, + ); } /// Delete only non-virtual placements that intersect the active screen. @@ -921,7 +1183,10 @@ pub const ImageStorage = struct { while (it.next()) |entry| { const pin = switch (entry.value_ptr.location) { .pin => |pin| pin, - .virtual => continue, + // Virtual placements are never selected by visible + // deletes per the protocol. Relative placements are + // removed with their parents instead (removeOrphans). + .virtual, .relative => continue, }; if (pin.garbage) continue; @@ -938,8 +1203,7 @@ pub const ImageStorage = struct { } const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(t.screens.active); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(t.screens.active, entry); if (delete_unused) self.deleteIfUnused(alloc, image_id); } } @@ -965,8 +1229,7 @@ pub const ImageStorage = struct { .id = placement_id, }, })) |entry| { - entry.value_ptr.deinit(s); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(s, entry); matched = true; } @@ -1005,8 +1268,7 @@ pub const ImageStorage = struct { const rect = entry.value_ptr.rect(img, t) orelse continue; if (rect.contains(target_pin)) { if (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue; - entry.value_ptr.deinit(t.screens.active); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(t.screens.active, entry); if (delete_unused) self.deleteIfUnused(alloc, img.id); } } @@ -1071,8 +1333,13 @@ pub const ImageStorage = struct { // Evicting anything is a content mutation. This matters for the // setLimit path in particular, which doesn't otherwise mark it. + // Evicted placements can also orphan relative placements of + // other images, which must be reaped along with them. const images_before = self.images.count(); - defer if (self.images.count() != images_before) self.markMutated(io); + defer if (self.images.count() != images_before) { + _ = self.removeOrphans(s, null); + self.markMutated(io); + }; var evicted: usize = 0; while (evicted < req) { @@ -1092,8 +1359,7 @@ pub const ImageStorage = struct { var p_it = self.placements.iterator(); while (p_it.next()) |entry| { if (entry.key_ptr.image_id == c.id) { - entry.value_ptr.deinit(s); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(s, entry); } } @@ -1146,6 +1412,10 @@ pub const ImageStorage = struct { pub const PlacementKey = struct { image_id: u32, placement_id: PlacementId, + + pub fn eql(self: PlacementKey, other: PlacementKey) bool { + return std.meta.eql(self, other); + } }; /// Internal placement IDs are assigned by us for placements created @@ -1191,7 +1461,27 @@ pub const ImageStorage = struct { pin: *PageList.Pin, /// Virtual placement (U=1) for unicode placeholders. - virtual: void, + virtual, + + /// Placed relative to a parent placement (P=/Q=). The + /// placement has no screen position of its own; it is + /// positioned at render time by resolving the parent chain + /// to its root (see resolveChain) and therefore follows the + /// parent through scrolls automatically. Its lifetime is + /// tied to the parent: removing any ancestor removes it + /// too (see removeOrphans). + relative: Relative, + }; + + pub const Relative = struct { + /// The parent placement. Resolved to a concrete key when + /// the placement is created, so the parent is guaranteed + /// to exist at that point. + parent: PlacementKey, + + /// Cell offsets from the parent's origin (H=/V=). + horizontal_offset: i32 = 0, + vertical_offset: i32 = 0, }; pub fn deinit( @@ -1200,7 +1490,7 @@ pub const ImageStorage = struct { ) void { switch (self.location) { .pin => |p| s.pages.untrackPin(p), - .virtual => {}, + .virtual, .relative => {}, } } @@ -1459,6 +1749,11 @@ pub const ImageStorage = struct { /// Returns a selection of the entire rectangle this placement /// occupies within the screen. This can return null for a virtual /// placement or when unavailable pixel geometry makes it empty. + /// + /// Relative placements also return null: they have no screen + /// position of their own, so geometric queries (delete by + /// intersection, row, column, etc.) never match them directly. + /// They are instead removed when their parent chain is removed. pub fn rect( self: Placement, image: Image, @@ -1467,7 +1762,7 @@ pub const ImageStorage = struct { const grid_size = self.gridSize(image, t); const pin = switch (self.location) { .pin => |p| p, - .virtual => return null, + .virtual, .relative => return null, }; if (pin.garbage) return null; @@ -3623,6 +3918,136 @@ test "storage: scroll margins multi-line scroll up with scrollback" { try testing.expectEqual(@as(usize, 0), storage.placements.count()); } +test "storage: resolveChain accumulates offsets to the pin root" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ + .location = .{ .relative = .{ + .parent = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }, + .horizontal_offset = 2, + .vertical_offset = 1, + } }, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 3, .{ + .location = .{ .relative = .{ + .parent = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 2 }, + }, + .horizontal_offset = -1, + .vertical_offset = 4, + } }, + }); + + const grandchild = s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 3 }, + }).?; + const chain = s.resolveChain(grandchild.location.relative).?; + try testing.expect(chain.root_key.eql(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + })); + try testing.expect(chain.root.location == .pin); + try testing.expectEqual(@as(i32, 1), chain.horizontal_offset); + try testing.expectEqual(@as(i32, 5), chain.vertical_offset); +} + +test "storage: resolveChain finds virtual roots" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .virtual = {} }, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ + .location = .{ .relative = .{ + .parent = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }, + .horizontal_offset = 1, + } }, + }); + + const child = s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 2 }, + }).?; + const chain = s.resolveChain(child.location.relative).?; + try testing.expect(chain.root.location == .virtual); + try testing.expect(chain.root_key.eql(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + })); + try testing.expectEqual(@as(i32, 1), chain.horizontal_offset); + try testing.expectEqual(@as(i32, 0), chain.vertical_offset); +} + +test "storage: eviction removes orphaned relative placements" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + s.total_limit = 8; + + // Image 1 holds most of the byte budget and has a pin placement. + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 2, + .height = 1, + .data = .{ .complete = try alloc.dupe(u8, &.{ 0, 0, 0, 0, 0, 0 }) }, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }, + }); + + // Image 2's placement is relative to image 1's placement. + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ + .location = .{ .relative = .{ .parent = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + } } }, + }); + + // Adding image 3 exceeds the limit and evicts image 1 (oldest), + // removing its placement, which orphans image 2's placement. + try s.addImage(io, alloc, t.screens.active, .{ + .id = 3, + .width = 2, + .height = 1, + .data = .{ .complete = try alloc.dupe(u8, &.{ 0, 0, 0, 0, 0, 0 }) }, + }); + + try testing.expect(s.imageById(1) == null); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expect(s.imageById(2) != null); +} + test "storage: placeholderTarget lookup" { const testing = std.testing; const alloc = testing.allocator;