terminal/kitty: create relative placements from put commands

This commit is contained in:
Mitchell Hashimoto
2026-08-20 15:01:43 -07:00
parent f5b3efe452
commit 33ca9e5ddc

View File

@@ -232,6 +232,13 @@ fn display(
.placement_id = d.placement_id,
};
// A virtual placement (U=1) cannot also be a relative placement.
// Kitty checks this before even looking up the image.
if (d.virtual_placement and d.parent_id > 0) {
result.message = "EINVAL: virtual placement cannot refer to a parent";
return result;
}
// Verify the requested image exists if we have an ID
const storage = &terminal.screens.active.kitty_images;
const img_: ?Image = if (d.image_id != 0)
@@ -249,25 +256,57 @@ fn display(
// Location where the placement will go.
const location: ImageStorage.Placement.Location = location: {
// Virtual placements are not tracked
if (d.virtual_placement) {
if (d.parent_id > 0) {
result.message = "EINVAL: virtual placement cannot refer to a parent";
return result;
}
if (d.virtual_placement) break :location .virtual;
break :location .{ .virtual = {} };
// No parent reference (P=): the placement is pinned to the
// cursor. The cursor is always tracked but we don't want
// this pin to move with the cursor.
if (d.parent_id == 0) {
const pin = terminal.screens.active.pages.trackPin(
terminal.screens.active.cursor.page_pin.*,
) catch |err| {
log.warn("failed to create pin for Kitty graphics err={}", .{err});
result.message = "EINVAL: failed to prepare terminal state";
return result;
};
break :location .{ .pin = pin };
}
// Track a new pin for our cursor. The cursor is always tracked but we
// don't want this one to move with the cursor.
const pin = terminal.screens.active.pages.trackPin(
terminal.screens.active.cursor.page_pin.*,
// A parent reference makes this a relative placement: it is
// positioned relative to the parent placement instead of the
// cursor.
// The key of the placement being created, when it is
// addressable (an explicit placement ID). Needed for
// self-parent and cycle detection.
const child: ?ImageStorage.PlacementKey = if (d.placement_id > 0) .{
.image_id = img.id,
.placement_id = .{ .tag = .external, .id = d.placement_id },
} else null;
const parent = storage.resolveParent(
io,
terminal.screens.active,
child,
d.parent_id,
d.parent_placement_id,
) catch |err| {
log.warn("failed to create pin for Kitty graphics err={}", .{err});
result.message = "EINVAL: failed to prepare terminal state";
result.message = switch (err) {
error.ParentImageNotFound => "ENOPARENT: parent image not found",
error.ParentPlacementNotFound => "ENOPARENT: parent placement not found",
error.SelfParent => "EINVAL: placement cannot be its own parent",
error.Cycle => "ECYCLE: parent chain creates a cycle",
error.TooDeep => "ETOODEEP: parent chain too deep",
error.AncestorNotFound => "ENOENT: parent chain ancestor not found",
};
return result;
};
break :location .{ .pin = pin };
break :location .{ .relative = .{
.parent = parent,
.horizontal_offset = d.horizontal_offset,
.vertical_offset = d.vertical_offset,
} };
};
// Add the placement
@@ -1610,3 +1649,494 @@ test "kittygfx implicit id assignment does not replace client image" {
try testing.expect(implicit.metadata.implicit_id);
try testing.expectEqualSlices(u8, &.{ 0, 0, 255 }, implicit.data.bytes().?);
}
test "kittygfx relative placement with missing parent image" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const storage = &t.screens.active.kitty_images;
{
const cmd = try command.Parser.parseString(
alloc,
"a=t,f=24,s=1,v=1,i=1;AAAA",
);
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
// The parent image does not exist: the placement must be rejected
// with ENOPARENT, nothing may be created, and the cursor must not
// move (a relative placement never moves the cursor, and a failed
// one certainly must not).
{
const cmd = try command.Parser.parseString(
alloc,
"a=p,i=1,p=1,P=42,Q=1,H=2,V=2",
);
defer cmd.deinit(alloc);
const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqualStrings(
"ENOPARENT: parent image not found",
resp.message,
);
}
try testing.expectEqual(@as(usize, 0), storage.placements.count());
try testing.expectEqual(0, t.screens.active.cursor.x);
try testing.expectEqual(0, t.screens.active.cursor.y);
}
test "kittygfx relative placement with missing parent placement" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const storage = &t.screens.active.kitty_images;
for ([_][]const u8{
"a=t,f=24,s=1,v=1,i=1;AAAA",
"a=t,f=24,s=1,v=1,i=2;AAAA",
}) |input| {
const cmd = try command.Parser.parseString(alloc, input);
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
// The parent image exists but has no placements at all.
{
const cmd = try command.Parser.parseString(alloc, "a=p,i=1,P=2");
defer cmd.deinit(alloc);
const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqualStrings(
"ENOPARENT: parent placement not found",
resp.message,
);
}
// The parent image has a placement, but not the requested one.
{
const cmd = try command.Parser.parseString(alloc, "a=p,i=2,p=1,C=1");
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
{
const cmd = try command.Parser.parseString(alloc, "a=p,i=1,P=2,Q=9");
defer cmd.deinit(alloc);
const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqualStrings(
"ENOPARENT: parent placement not found",
resp.message,
);
}
try testing.expectEqual(@as(usize, 1), storage.placements.count());
}
test "kittygfx relative placement cannot parent itself" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
for ([_][]const u8{
"a=t,f=24,s=1,v=1,i=1;AAAA",
"a=p,i=1,p=1,C=1",
}) |input| {
const cmd = try command.Parser.parseString(alloc, input);
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
// Explicitly via Q, and implicitly when the Q=0 fallback selects
// the placement being replaced.
for ([_][]const u8{
"a=p,i=1,p=1,P=1,Q=1",
"a=p,i=1,p=1,P=1",
}) |input| {
const cmd = try command.Parser.parseString(alloc, input);
defer cmd.deinit(alloc);
const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqualStrings(
"EINVAL: placement cannot be its own parent",
resp.message,
);
}
// The original placement must be untouched.
const storage = &t.screens.active.kitty_images;
const p = storage.placements.get(.{
.image_id = 1,
.placement_id = .{ .tag = .external, .id = 1 },
}).?;
try testing.expect(p.location == .pin);
}
test "kittygfx relative placement cycle" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
for ([_][]const u8{
"a=t,f=24,s=1,v=1,i=1;AAAA",
"a=p,i=1,p=1,C=1",
"a=p,i=1,p=2,P=1,Q=1",
}) |input| {
const cmd = try command.Parser.parseString(alloc, input);
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
// Replacing placement 1 with a parent of placement 2 would create
// the cycle 1 -> 2 -> 1.
{
const cmd = try command.Parser.parseString(alloc, "a=p,i=1,p=1,P=1,Q=2");
defer cmd.deinit(alloc);
const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqualStrings(
"ECYCLE: parent chain creates a cycle",
resp.message,
);
}
// The original placement must be untouched, keeping the stored
// chains acyclic.
const storage = &t.screens.active.kitty_images;
const p = storage.placements.get(.{
.image_id = 1,
.placement_id = .{ .tag = .external, .id = 1 },
}).?;
try testing.expect(p.location == .pin);
}
test "kittygfx relative placement chain depth limit" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
for ([_][]const u8{
"a=t,f=24,s=1,v=1,i=1;AAAA",
"a=p,i=1,p=1,C=1",
}) |input| {
const cmd = try command.Parser.parseString(alloc, input);
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
// Chains of up to parent_chain_limit (8) links must work: these
// placements form the chain 9 -> 8 -> ... -> 2 -> 1.
for (2..10) |id| {
var buf: [64]u8 = undefined;
const cmd = try command.Parser.parseString(
alloc,
try std.fmt.bufPrint(&buf, "a=p,i=1,p={},P=1,Q={}", .{ id, id - 1 }),
);
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
// One more link exceeds the limit.
{
const cmd = try command.Parser.parseString(alloc, "a=p,i=1,p=10,P=1,Q=9");
defer cmd.deinit(alloc);
const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqualStrings(
"ETOODEEP: parent chain too deep",
resp.message,
);
}
const storage = &t.screens.active.kitty_images;
try testing.expectEqual(@as(usize, 9), storage.placements.count());
}
test "kittygfx relative placement does not move cursor" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const storage = &t.screens.active.kitty_images;
// C is left at its default (move after) on the relative placement
// but it must never move the cursor.
for ([_][]const u8{
"a=t,f=24,s=1,v=1,i=1;AAAA",
"a=p,i=1,p=1,C=1",
"a=p,i=1,p=2,P=1,Q=1,H=3,V=2,c=2,r=2",
}) |input| {
const cmd = try command.Parser.parseString(alloc, input);
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
try testing.expectEqual(0, t.screens.active.cursor.x);
try testing.expectEqual(0, t.screens.active.cursor.y);
// The stored placement carries the parent link and offsets.
const p = storage.placements.get(.{
.image_id = 1,
.placement_id = .{ .tag = .external, .id = 2 },
}).?;
const rel = p.location.relative;
try testing.expect(rel.parent.eql(.{
.image_id = 1,
.placement_id = .{ .tag = .external, .id = 1 },
}));
try testing.expectEqual(@as(i32, 3), rel.horizontal_offset);
try testing.expectEqual(@as(i32, 2), rel.vertical_offset);
}
test "kittygfx virtual placement with parent rejected before image lookup" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
// Kitty rejects U=1 + P= before checking that the image exists,
// so this must not be ENOENT.
const cmd = try command.Parser.parseString(alloc, "a=p,i=42,U=1,P=1");
defer cmd.deinit(alloc);
const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqualStrings(
"EINVAL: virtual placement cannot refer to a parent",
resp.message,
);
}
test "kittygfx deleting a parent deletes relative placements transitively" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const storage = &t.screens.active.kitty_images;
// Root (pin) <- child <- grandchild
for ([_][]const u8{
"a=t,f=24,s=1,v=1,i=1;AAAA",
"a=p,i=1,p=1,C=1",
"a=p,i=1,p=2,P=1,Q=1",
"a=p,i=1,p=3,P=1,Q=2",
}) |input| {
const cmd = try command.Parser.parseString(alloc, input);
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
try testing.expectEqual(@as(usize, 3), storage.placements.count());
// Deleting the middle placement takes the grandchild with it but
// leaves the root.
{
const cmd = try command.Parser.parseString(alloc, "a=d,d=i,i=1,p=2");
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd) == null);
}
try testing.expectEqual(@as(usize, 1), storage.placements.count());
try testing.expect(storage.placements.contains(.{
.image_id = 1,
.placement_id = .{ .tag = .external, .id = 1 },
}));
// Deleting the root removes the remaining placement, and the
// image itself is retained (lowercase delete).
{
const cmd = try command.Parser.parseString(alloc, "a=d,d=i,i=1,p=1");
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd) == null);
}
try testing.expectEqual(@as(usize, 0), storage.placements.count());
try testing.expect(storage.imageById(1) != null);
}
test "kittygfx retransmitting parent image deletes relative placements" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const storage = &t.screens.active.kitty_images;
// Image 2's placement is relative to image 1's placement.
for ([_][]const u8{
"a=t,f=24,s=1,v=1,i=1;AAAA",
"a=t,f=24,s=1,v=1,i=2;AAAA",
"a=p,i=1,p=1,C=1",
"a=p,i=2,p=1,P=1,Q=1",
}) |input| {
const cmd = try command.Parser.parseString(alloc, input);
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
try testing.expectEqual(@as(usize, 2), storage.placements.count());
// Retransmitting image 1 removes its placements, which orphans
// and removes image 2's placement too. Image 2 is left without
// any placement so it is freed as well: retransmission deletes
// with uppercase semantics, and kitty also removes an image whose
// last placement died from a broken parent chain.
{
const cmd = try command.Parser.parseString(
alloc,
"a=t,f=24,s=1,v=1,i=1;AAAA",
);
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
try testing.expectEqual(@as(usize, 0), storage.placements.count());
try testing.expect(storage.imageById(2) == null);
}
test "kittygfx relative placement parent fallback picks lowest external id" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const storage = &t.screens.active.kitty_images;
for ([_][]const u8{
"a=t,f=24,s=1,v=1,i=1;AAAA",
"a=t,f=24,s=1,v=1,i=2;AAAA",
"a=p,i=1,p=7,C=1",
"a=p,i=1,p=3,C=1",
"a=p,i=2,P=1",
}) |input| {
const cmd = try command.Parser.parseString(alloc, input);
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
var it = storage.placements.iterator();
const rel = while (it.next()) |entry| {
if (entry.key_ptr.image_id == 2) break entry.value_ptr.location.relative;
} else return error.PlacementNotFound;
try testing.expect(rel.parent.eql(.{
.image_id = 1,
.placement_id = .{ .tag = .external, .id = 3 },
}));
}
test "kittygfx placements created after a full reset are retained" {
// Regression test: Screen.reset reuses the cursor's tracked pin
// but PageList.reset marked it garbage. Placements copy the cursor
// pin, so every placement created after a reset was born garbage
// and silently swept by the next placement command.
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try Terminal.init(io, alloc, .{ .rows = 24, .cols = 80 });
defer t.deinit(alloc);
const storage = &t.screens.active.kitty_images;
t.fullReset();
for ([_][]const u8{
"a=t,f=24,s=1,v=1,i=1;AAAA",
"a=p,i=1,p=1,C=1",
"a=p,i=1,p=2,C=1",
"a=p,i=1,p=3,P=1,Q=2",
}) |input| {
const cmd = try command.Parser.parseString(alloc, input);
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
try testing.expectEqual(@as(usize, 3), storage.placements.count());
}
test "kittygfx relative placement with pruned parent is ENOPARENT" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const storage = &t.screens.active.kitty_images;
for ([_][]const u8{
"a=t,f=24,s=1,v=1,i=1;AAAA",
"a=p,i=1,p=1,C=1",
}) |input| {
const cmd = try command.Parser.parseString(alloc, input);
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
// The parent's tracked content is pruned from history but the
// placement hasn't been swept yet. The put must reap it and answer
// ENOPARENT rather than storing an orphan against it.
const parent = storage.placements.getPtr(.{
.image_id = 1,
.placement_id = .{ .tag = .external, .id = 1 },
}).?;
parent.location.pin.garbage = true;
{
const cmd = try command.Parser.parseString(alloc, "a=p,i=1,p=2,P=1,Q=1");
defer cmd.deinit(alloc);
const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqualStrings(
"ENOPARENT: parent placement not found",
resp.message,
);
}
try testing.expectEqual(@as(usize, 0), storage.placements.count());
}
test "kittygfx uppercase delete frees image of cascaded placements" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const storage = &t.screens.active.kitty_images;
for ([_][]const u8{
"a=t,f=24,s=1,v=1,i=1;AAAA",
"a=p,i=1,p=1,C=1",
"a=p,i=1,p=2,P=1,Q=1",
}) |input| {
const cmd = try command.Parser.parseString(alloc, input);
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
}
// The uppercase delete only matches placement 1. The cascade
// removes placement 2, leaving the image without placements, so
// the image must be freed too.
{
const cmd = try command.Parser.parseString(alloc, "a=d,d=I,i=1,p=1");
defer cmd.deinit(alloc);
try testing.expect(execute(io, alloc, &t, &cmd) == null);
}
try testing.expectEqual(@as(usize, 0), storage.placements.count());
try testing.expect(storage.imageById(1) == null);
}