terminal/kitty: add relative placement storage

This commit is contained in:
Mitchell Hashimoto
2026-08-20 14:51:00 -07:00
parent a32a100d4b
commit cd5f9eef0a
4 changed files with 489 additions and 58 deletions

View File

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

View File

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

View File

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

View File

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