mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 08:01:45 +00:00
kitty graphics: support relative placements (#13939)
This adds full support for relative placements: https://sw.kovidgoyal.net/kitty/graphics-protocol/#relative-placements (`R=` and `Q=`). The high-level description is that this allows images to be placed relative to other images. For more details, the spec explains it better than I can, or the demo video below. The implementation here was pretty straightforward. It mainly revolved around adding parent resolution logic to puts (and validation), and proper unparented placement reaping in the right points, and then the various tests around that. The renderer logic itself was also straigthforward, I had to add some offset resolution to the core graphics storage but we can reuse that. The main complexity -- as always -- (MY OWN EMDASHES) is Unicode placeholders. But... also not too hard. After this, the only feature we don't support from the protocol is animation. **AI usage:** Test writing, validation against spec/reference, judging, and some "fill in the function/block". I setup the overall shape of the implementation. ## Demo Ghostty on the left, Kitty on the right https://github.com/user-attachments/assets/dc2ec3ac-480a-4587-94e4-06a842f9b3f4
This commit is contained in:
@@ -299,6 +299,18 @@ pub const State = struct {
|
||||
const top_y = t.screens.active.pages.pointFromPin(.screen, top).?.screen.y;
|
||||
const bot_y = t.screens.active.pages.pointFromPin(.screen, bot).?.screen.y;
|
||||
|
||||
// Relative placements whose parent chain roots at a virtual
|
||||
// placement can only be positioned once the placeholder cells
|
||||
// have been scanned below, so they are collected here first.
|
||||
var pending_relative: std.ArrayListUnmanaged(struct {
|
||||
image_id: u32,
|
||||
p: terminal.kitty.graphics.ImageStorage.Placement,
|
||||
root_key: terminal.kitty.graphics.ImageStorage.PlacementKey,
|
||||
horizontal_offset: i32,
|
||||
vertical_offset: i32,
|
||||
}) = .empty;
|
||||
defer pending_relative.deinit(alloc);
|
||||
|
||||
// Go through the placements and ensure the image is
|
||||
// on the GPU or else is ready to be sent to the GPU.
|
||||
var it = storage.placements.iterator();
|
||||
@@ -306,8 +318,9 @@ pub const State = struct {
|
||||
const p = kv.value_ptr;
|
||||
|
||||
// Special logic based on location
|
||||
switch (p.location) {
|
||||
.pin => {},
|
||||
const origin: Origin = switch (p.location) {
|
||||
.pin => |pin| .{ .pin = pin },
|
||||
|
||||
.virtual => {
|
||||
// We need to mark virtual placements on our renderer so that
|
||||
// we know to rebuild in more scenarios since cell changes can
|
||||
@@ -319,7 +332,46 @@ pub const State = struct {
|
||||
// placement itself.
|
||||
continue;
|
||||
},
|
||||
}
|
||||
|
||||
.relative => |rel| origin: {
|
||||
// An unresolvable chain is never drawn. This only
|
||||
// happens transiently (storage reaps orphans) or for
|
||||
// chains re-parented too deep, which kitty doesn't
|
||||
// draw either.
|
||||
const chain = storage.resolveChain(rel) orelse continue;
|
||||
switch (chain.root.location) {
|
||||
// Rooted at a pin: anchored at the root's pin,
|
||||
// offset by the accumulated chain offsets.
|
||||
.pin => |root_pin| break :origin .{
|
||||
.pin = root_pin,
|
||||
.horizontal_offset = chain.horizontal_offset,
|
||||
.vertical_offset = chain.vertical_offset,
|
||||
},
|
||||
|
||||
// Rooted at a virtual placement: positioned from
|
||||
// the root's placeholder cells, which we only
|
||||
// know after the placeholder scan below. The
|
||||
// placeholders also move with cell changes so we
|
||||
// must rebuild every frame, like virtuals.
|
||||
.virtual => {
|
||||
self.kitty_virtual = true;
|
||||
pending_relative.append(alloc, .{
|
||||
.image_id = kv.key_ptr.image_id,
|
||||
.p = p.*,
|
||||
.root_key = chain.root_key,
|
||||
.horizontal_offset = chain.horizontal_offset,
|
||||
.vertical_offset = chain.vertical_offset,
|
||||
}) catch |err| {
|
||||
log.warn("error deferring relative placement err={}", .{err});
|
||||
};
|
||||
continue;
|
||||
},
|
||||
|
||||
// resolveChain roots are never relative.
|
||||
.relative => unreachable,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Get the image for the placement
|
||||
const image = storage.imageById(kv.key_ptr.image_id) orelse {
|
||||
@@ -337,6 +389,7 @@ pub const State = struct {
|
||||
bot_y,
|
||||
&image,
|
||||
p,
|
||||
origin,
|
||||
) catch |err| {
|
||||
// For errors we log and continue. We try to place
|
||||
// other placements even if one fails.
|
||||
@@ -346,6 +399,17 @@ pub const State = struct {
|
||||
|
||||
// If we have virtual placements then we need to scan for placeholders.
|
||||
if (self.kitty_virtual) {
|
||||
// The minimum placeholder cell seen per virtual placement,
|
||||
// in viewport coordinates. This is the origin for relative
|
||||
// placements rooted at a virtual placement: kitty positions
|
||||
// those at the min-x/min-y of the parent's placeholder
|
||||
// cells. Only tracked when such placements exist.
|
||||
var virtual_origins: std.AutoHashMapUnmanaged(
|
||||
terminal.kitty.graphics.ImageStorage.PlacementKey,
|
||||
struct { x: u32, y: u32 },
|
||||
) = .empty;
|
||||
defer virtual_origins.deinit(alloc);
|
||||
|
||||
var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot);
|
||||
while (v_it.next()) |virtual_p| {
|
||||
self.prepKittyVirtualPlacement(
|
||||
@@ -358,6 +422,69 @@ pub const State = struct {
|
||||
// other placements even if one fails.
|
||||
log.warn("error preparing kitty placement err={}", .{err});
|
||||
};
|
||||
|
||||
// We need to track the origins of all the placeholders
|
||||
// when we have relative cells so that we can calculate
|
||||
// the proper offsets later.
|
||||
if (pending_relative.items.len > 0) fold: {
|
||||
// Find the target for this virtual placeholder.
|
||||
const target = storage.placeholderTarget(
|
||||
virtual_p.image_id,
|
||||
virtual_p.placement_id,
|
||||
) orelse break :fold;
|
||||
|
||||
// Get the actual viewport position for it.
|
||||
const vp = t.screens.active.pages.pointFromPin(
|
||||
.viewport,
|
||||
virtual_p.pin,
|
||||
) orelse break :fold;
|
||||
|
||||
// Add the origin for this target
|
||||
const gop = virtual_origins.getOrPut(
|
||||
alloc,
|
||||
target.key,
|
||||
) catch |err| {
|
||||
log.warn("error tracking virtual origin err={}", .{err});
|
||||
break :fold;
|
||||
};
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = .{ .x = vp.viewport.x, .y = vp.viewport.y };
|
||||
} else {
|
||||
gop.value_ptr.x = @min(gop.value_ptr.x, vp.viewport.x);
|
||||
gop.value_ptr.y = @min(gop.value_ptr.y, vp.viewport.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Position the relative placements rooted at virtual
|
||||
// placements now that the placeholder cells are known. A
|
||||
// root with no placeholders on screen leaves its relative
|
||||
// placements undrawn, matching kitty.
|
||||
for (pending_relative.items) |pr| {
|
||||
const origin = virtual_origins.get(pr.root_key) orelse continue;
|
||||
const image = storage.imageById(pr.image_id) orelse continue;
|
||||
if (image.data.isPending()) continue;
|
||||
|
||||
const grid = pr.p.gridSize(image, t);
|
||||
if (grid.cols == 0 or grid.rows == 0) continue;
|
||||
|
||||
// Viewport-relative signed position; cull placements
|
||||
// entirely outside the viewport.
|
||||
const x: i64 = @as(i64, origin.x) + pr.horizontal_offset;
|
||||
const y: i64 = @as(i64, origin.y) + pr.vertical_offset;
|
||||
if (y >= t.rows or y + grid.rows - 1 < 0) continue;
|
||||
if (x >= t.cols or x + grid.cols - 1 < 0) continue;
|
||||
|
||||
self.appendKittyPlacement(
|
||||
alloc,
|
||||
t,
|
||||
&image,
|
||||
&pr.p,
|
||||
std.math.cast(i32, x) orelse continue,
|
||||
std.math.cast(i32, y) orelse continue,
|
||||
) catch |err| {
|
||||
log.warn("error preparing kitty placement err={}", .{err});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,8 +530,19 @@ pub const State = struct {
|
||||
ImageConversionError,
|
||||
};
|
||||
|
||||
/// Get the viewport-relative position for this
|
||||
/// placement and add it to the placements list.
|
||||
/// Where a placement is anchored on screen: a pin, plus cell
|
||||
/// offsets from it for relative placements. The pin is the
|
||||
/// placement's own pin for pin placements; for relative placements
|
||||
/// it is the root of the parent chain and the offsets are the
|
||||
/// accumulated chain offsets in cells.
|
||||
const Origin = struct {
|
||||
pin: *const terminal.Pin,
|
||||
horizontal_offset: i32 = 0,
|
||||
vertical_offset: i32 = 0,
|
||||
};
|
||||
|
||||
/// Get the viewport-relative position for this placement and add it
|
||||
/// to the placements list.
|
||||
fn prepKittyPlacement(
|
||||
self: *State,
|
||||
alloc: Allocator,
|
||||
@@ -413,23 +551,58 @@ pub const State = struct {
|
||||
bot_y: u32,
|
||||
image: *const terminal.kitty.graphics.Image,
|
||||
p: *const terminal.kitty.graphics.ImageStorage.Placement,
|
||||
origin: Origin,
|
||||
) 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;
|
||||
// An origin whose tracked content was pruned has no position.
|
||||
if (origin.pin.garbage) return;
|
||||
|
||||
// The size of the placement in grid cells. A zero size can
|
||||
// occur when pixel geometry is unavailable; nothing to place.
|
||||
const grid = p.gridSize(image.*, t);
|
||||
if (grid.cols == 0 or grid.rows == 0) return;
|
||||
|
||||
// This is expensive but necessary.
|
||||
const img_top_y = t.screens.active.pages.pointFromPin(.screen, rect.top_left).?.screen.y;
|
||||
const img_bot_y = t.screens.active.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y;
|
||||
const origin_y = t.screens.active.pages.pointFromPin(
|
||||
.screen,
|
||||
origin.pin.*,
|
||||
).?.screen.y;
|
||||
|
||||
// If the selection isn't within our viewport then skip it.
|
||||
if (img_top_y > bot_y) return;
|
||||
if (img_bot_y < top_y) return;
|
||||
// The placement's edges in screen coordinates. Chain offsets
|
||||
// are signed: a relative placement can hang above or to the
|
||||
// left of its origin, so this math must be signed and widened.
|
||||
const img_top_y: i64 = @as(i64, origin_y) + origin.vertical_offset;
|
||||
const img_bot_y: i64 = img_top_y + grid.rows - 1;
|
||||
const img_left_x: i64 = @as(i64, origin.pin.x) + origin.horizontal_offset;
|
||||
const img_right_x: i64 = img_left_x + grid.cols - 1;
|
||||
|
||||
// If the placement isn't within our viewport then skip it.
|
||||
if (img_top_y > bot_y or img_bot_y < top_y) return;
|
||||
if (img_left_x >= t.cols or img_right_x < 0) return;
|
||||
|
||||
// Viewport-relative position. Offsets so extreme that the
|
||||
// position is unrepresentable have no renderable pixels.
|
||||
const y_pos = std.math.cast(i32, img_top_y - top_y) orelse return;
|
||||
const x_pos = std.math.cast(i32, img_left_x) orelse return;
|
||||
|
||||
try self.appendKittyPlacement(alloc, t, image, p, x_pos, y_pos);
|
||||
}
|
||||
|
||||
/// Compute the sizes for a native kitty placement positioned at the
|
||||
/// given viewport cell position, prepare its image for the GPU, and
|
||||
/// append it to the placements list.
|
||||
fn appendKittyPlacement(
|
||||
self: *State,
|
||||
alloc: Allocator,
|
||||
t: *const terminal.Terminal,
|
||||
image: *const terminal.kitty.graphics.Image,
|
||||
p: *const terminal.kitty.graphics.ImageStorage.Placement,
|
||||
x: i32,
|
||||
y: i32,
|
||||
) PrepImageError!void {
|
||||
// We need to prep this image for upload if it isn't in the
|
||||
// cache OR it is in the cache but the transmit time doesn't
|
||||
// match meaning this image is different.
|
||||
@@ -442,15 +615,12 @@ pub const State = struct {
|
||||
|
||||
const source = p.sourceRect(image.*);
|
||||
|
||||
// Get the viewport-relative Y position of the placement.
|
||||
const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y));
|
||||
|
||||
// Accumulate the placement
|
||||
if (dest_size.width > 0 and dest_size.height > 0) {
|
||||
try self.kitty_placements.append(alloc, .{
|
||||
.image_id = .{ .kitty = image.id },
|
||||
.x = @intCast(rect.top_left.x),
|
||||
.y = y_pos,
|
||||
.x = x,
|
||||
.y = y,
|
||||
.z = p.z,
|
||||
.width = dest_size.width,
|
||||
.height = dest_size.height,
|
||||
@@ -1071,3 +1241,201 @@ test "kitty renderer uses the intersected source rectangle" {
|
||||
try testing.expectEqual(@as(u32, 1), placement.source_width);
|
||||
try testing.expectEqual(@as(u32, 2), placement.source_height);
|
||||
}
|
||||
|
||||
test "kitty renderer positions relative placements from the parent pin" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
const io = testing.io;
|
||||
|
||||
var t = try terminal.Terminal.init(io, alloc, .{ .rows = 10, .cols = 10 });
|
||||
defer t.deinit(alloc);
|
||||
t.width_px = 100;
|
||||
t.height_px = 100;
|
||||
|
||||
var state: State = .empty;
|
||||
defer state.deinit(alloc);
|
||||
|
||||
const storage = &t.screens.active.kitty_images;
|
||||
const pixels = try alloc.alloc(u8, 3);
|
||||
@memset(pixels, 0);
|
||||
try storage.addImage(io, alloc, t.screens.active, .{
|
||||
.id = 1,
|
||||
.width = 1,
|
||||
.height = 1,
|
||||
.format = .rgb,
|
||||
.data = .{ .complete = pixels },
|
||||
});
|
||||
|
||||
// Parent at (2, 1).
|
||||
const pin = try t.screens.active.pages.trackPin(
|
||||
t.screens.active.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?,
|
||||
);
|
||||
try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{
|
||||
.location = .{ .pin = pin },
|
||||
.columns = 1,
|
||||
.rows = 1,
|
||||
});
|
||||
|
||||
// Child offset (3, 2) cells from the parent.
|
||||
try storage.addPlacement(io, alloc, t.screens.active, 1, 2, .{
|
||||
.location = .{ .relative = .{
|
||||
.parent = .{
|
||||
.image_id = 1,
|
||||
.placement_id = .{ .tag = .external, .id = 1 },
|
||||
},
|
||||
.horizontal_offset = 3,
|
||||
.vertical_offset = 2,
|
||||
} },
|
||||
.columns = 1,
|
||||
.rows = 1,
|
||||
.z = 1,
|
||||
});
|
||||
|
||||
state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
|
||||
try testing.expectEqual(@as(usize, 2), state.kitty_placements.items.len);
|
||||
|
||||
// Sorted by z: parent (z=0) first, child (z=1) second.
|
||||
const parent = state.kitty_placements.items[0];
|
||||
try testing.expectEqual(@as(i32, 2), parent.x);
|
||||
try testing.expectEqual(@as(i32, 1), parent.y);
|
||||
const child = state.kitty_placements.items[1];
|
||||
try testing.expectEqual(@as(i32, 5), child.x);
|
||||
try testing.expectEqual(@as(i32, 3), child.y);
|
||||
|
||||
// Pin-rooted relative placements don't force per-frame rebuilds.
|
||||
try testing.expect(!state.kitty_virtual);
|
||||
}
|
||||
|
||||
test "kitty renderer relative placement with negative offsets" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
const io = testing.io;
|
||||
|
||||
var t = try terminal.Terminal.init(io, alloc, .{ .rows = 10, .cols = 10 });
|
||||
defer t.deinit(alloc);
|
||||
t.width_px = 100;
|
||||
t.height_px = 100;
|
||||
|
||||
var state: State = .empty;
|
||||
defer state.deinit(alloc);
|
||||
|
||||
const storage = &t.screens.active.kitty_images;
|
||||
const pixels = try alloc.alloc(u8, 3);
|
||||
@memset(pixels, 0);
|
||||
try storage.addImage(io, alloc, t.screens.active, .{
|
||||
.id = 1,
|
||||
.width = 1,
|
||||
.height = 1,
|
||||
.format = .rgb,
|
||||
.data = .{ .complete = pixels },
|
||||
});
|
||||
|
||||
const pin = try t.screens.active.pages.trackPin(
|
||||
t.screens.active.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?,
|
||||
);
|
||||
try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{
|
||||
.location = .{ .pin = pin },
|
||||
.columns = 1,
|
||||
.rows = 1,
|
||||
});
|
||||
|
||||
// Hangs up and to the left of the viewport but still has visible
|
||||
// cells at (0, 0), so it must be kept with a negative position.
|
||||
try storage.addPlacement(io, alloc, t.screens.active, 1, 2, .{
|
||||
.location = .{ .relative = .{
|
||||
.parent = .{
|
||||
.image_id = 1,
|
||||
.placement_id = .{ .tag = .external, .id = 1 },
|
||||
},
|
||||
.horizontal_offset = -3,
|
||||
.vertical_offset = -3,
|
||||
} },
|
||||
.columns = 2,
|
||||
.rows = 2,
|
||||
.z = 1,
|
||||
});
|
||||
|
||||
// Entirely off the left edge of the screen: culled.
|
||||
try storage.addPlacement(io, alloc, t.screens.active, 1, 3, .{
|
||||
.location = .{ .relative = .{
|
||||
.parent = .{
|
||||
.image_id = 1,
|
||||
.placement_id = .{ .tag = .external, .id = 1 },
|
||||
},
|
||||
.horizontal_offset = -9,
|
||||
} },
|
||||
.columns = 2,
|
||||
.rows = 2,
|
||||
.z = 2,
|
||||
});
|
||||
|
||||
state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
|
||||
try testing.expectEqual(@as(usize, 2), state.kitty_placements.items.len);
|
||||
const child = state.kitty_placements.items[1];
|
||||
try testing.expectEqual(@as(i32, -1), child.x);
|
||||
try testing.expectEqual(@as(i32, -1), child.y);
|
||||
}
|
||||
|
||||
test "kitty renderer positions relative placements from virtual parent placeholders" {
|
||||
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);
|
||||
t.width_px = 50;
|
||||
t.height_px = 50;
|
||||
t.modes.set(.grapheme_cluster, true);
|
||||
|
||||
var state: State = .empty;
|
||||
defer state.deinit(alloc);
|
||||
|
||||
const storage = &t.screens.active.kitty_images;
|
||||
const pixels = try alloc.alloc(u8, 10 * 10 * 3);
|
||||
@memset(pixels, 0);
|
||||
try storage.addImage(io, alloc, t.screens.active, .{
|
||||
.id = 1,
|
||||
.width = 10,
|
||||
.height = 10,
|
||||
.format = .rgb,
|
||||
.data = .{ .complete = pixels },
|
||||
});
|
||||
try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{
|
||||
.location = .{ .virtual = {} },
|
||||
});
|
||||
|
||||
// Placeholder cells for the virtual placement at (1, 1) and
|
||||
// (3, 2): the minimum cell (1, 1) is the anchor for relative
|
||||
// placements rooted at the virtual placement.
|
||||
try t.setAttribute(.{ .@"256_fg" = 1 });
|
||||
try t.setAttribute(.{ .@"256_underline_color" = 1 });
|
||||
t.screens.active.cursorAbsolute(1, 1);
|
||||
try t.printString("\u{10EEEE}\u{0305}\u{0305}");
|
||||
t.screens.active.cursorAbsolute(3, 2);
|
||||
try t.printString("\u{10EEEE}\u{0305}\u{0305}");
|
||||
|
||||
// Child of the virtual placement, offset (1, 2) cells.
|
||||
try storage.addPlacement(io, alloc, t.screens.active, 1, 2, .{
|
||||
.location = .{ .relative = .{
|
||||
.parent = .{
|
||||
.image_id = 1,
|
||||
.placement_id = .{ .tag = .external, .id = 1 },
|
||||
},
|
||||
.horizontal_offset = 1,
|
||||
.vertical_offset = 2,
|
||||
} },
|
||||
.columns = 1,
|
||||
.rows = 1,
|
||||
.z = 5,
|
||||
});
|
||||
|
||||
state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
|
||||
try testing.expect(state.kitty_virtual);
|
||||
|
||||
// Two placeholder runs (z=-1) plus the child (z=5), sorted by z.
|
||||
try testing.expectEqual(@as(usize, 3), state.kitty_placements.items.len);
|
||||
const child = state.kitty_placements.items[2];
|
||||
try testing.expectEqual(@as(i32, 5), child.z);
|
||||
try testing.expectEqual(@as(i32, 2), child.x);
|
||||
try testing.expectEqual(@as(i32, 3), child.y);
|
||||
}
|
||||
|
||||
@@ -397,11 +397,16 @@ pub fn reset(self: *Screen) void {
|
||||
self.pages.reset();
|
||||
|
||||
// The above reset preserves tracked pins so we can still use
|
||||
// our cursor pin, which should be at the top-left already.
|
||||
// our cursor pin, which should be at the top-left already. The
|
||||
// reset marks every tracked pin as garbage, but we keep using
|
||||
// this one at its new valid position, so clear the flag: copies
|
||||
// of the cursor pin (e.g. for Kitty image placements) must not
|
||||
// be born garbage.
|
||||
const cursor_pin: *PageList.Pin = self.cursor.page_pin;
|
||||
assert(cursor_pin.node == self.pages.pages.first.?);
|
||||
assert(cursor_pin.x == 0);
|
||||
assert(cursor_pin.y == 0);
|
||||
cursor_pin.garbage = false;
|
||||
const cursor_rac = cursor_pin.rowAndCell();
|
||||
self.cursor.deinit(self.alloc);
|
||||
self.cursor = .{
|
||||
@@ -3806,6 +3811,23 @@ test "Screen forwards optional scrollback limits" {
|
||||
try testing.expect(!s.no_scrollback);
|
||||
}
|
||||
|
||||
test "Screen reset cursor pin is not garbage" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
const io = testing.io;
|
||||
|
||||
var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 24, .max_scrollback_bytes = 1000 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("hello, world");
|
||||
|
||||
// The page reset marks every tracked pin garbage but the screen
|
||||
// keeps using the cursor pin, so it must come back clean: anything
|
||||
// that copies it (e.g. Kitty image placements) would otherwise be
|
||||
// born garbage and reaped.
|
||||
s.reset();
|
||||
try testing.expect(!s.cursor.page_pin.garbage);
|
||||
}
|
||||
|
||||
test "Screen read and write" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@@ -10,6 +10,7 @@ const grid_ref = @import("grid_ref.zig");
|
||||
const selection_c = @import("selection.zig");
|
||||
const terminal_c = @import("terminal.zig");
|
||||
const Terminal = @import("../Terminal.zig");
|
||||
const PageList = @import("../PageList.zig");
|
||||
const Result = @import("result.zig").Result;
|
||||
|
||||
/// C: GhosttyKittyGraphics
|
||||
@@ -600,10 +601,35 @@ fn computeViewportPos(
|
||||
) struct { col: i32, row: i32, visible: bool } {
|
||||
// Virtual placements use unicode placeholders and don't have a
|
||||
// screen position — they are rendered inline by the text layout.
|
||||
const pin = switch (p.location) {
|
||||
.pin => |pin| pin,
|
||||
// Relative placements are anchored at the root of their parent
|
||||
// chain, offset by the accumulated chain offsets. A chain rooted
|
||||
// at a virtual placement has no resolvable position here: its
|
||||
// origin is the parent's placeholder cells, which only a renderer
|
||||
// scanning the screen can locate.
|
||||
const origin: struct {
|
||||
pin: *const PageList.Pin,
|
||||
col_offset: i32 = 0,
|
||||
row_offset: i32 = 0,
|
||||
} = switch (p.location) {
|
||||
.pin => |pin| .{ .pin = pin },
|
||||
.virtual => return .{ .col = 0, .row = 0, .visible = false },
|
||||
.relative => |rel| origin: {
|
||||
const storage = &t.screens.active.kitty_images;
|
||||
const chain = storage.resolveChain(rel) orelse
|
||||
return .{ .col = 0, .row = 0, .visible = false };
|
||||
switch (chain.root.location) {
|
||||
.pin => |root_pin| break :origin .{
|
||||
.pin = root_pin,
|
||||
.col_offset = chain.horizontal_offset,
|
||||
.row_offset = chain.vertical_offset,
|
||||
},
|
||||
.virtual => return .{ .col = 0, .row = 0, .visible = false },
|
||||
.relative => unreachable, // resolveChain roots are never relative
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const pin = origin.pin;
|
||||
if (pin.garbage) return .{ .col = 0, .row = 0, .visible = false };
|
||||
|
||||
// Convert both the placement's pin and the viewport's top-left
|
||||
@@ -618,17 +644,23 @@ fn computeViewportPos(
|
||||
|
||||
// Subtracting viewport origin from the pin gives us viewport-
|
||||
// relative coordinates. The row can be negative when the
|
||||
// placement has partially scrolled above the viewport.
|
||||
const vp_row: i32 = @as(i32, @intCast(pin_screen.screen.y)) -
|
||||
@as(i32, @intCast(vp_screen.screen.y));
|
||||
const vp_col: i32 = @intCast(pin_screen.screen.x);
|
||||
// placement has partially scrolled above the viewport, and both
|
||||
// can be negative for relative placements with negative offsets.
|
||||
const vp_row: i32 = (@as(i32, @intCast(pin_screen.screen.y)) -
|
||||
@as(i32, @intCast(vp_screen.screen.y))) +| origin.row_offset;
|
||||
const vp_col: i32 = @as(i32, @intCast(pin_screen.screen.x)) +|
|
||||
origin.col_offset;
|
||||
|
||||
// A placement is invisible if its bottom edge (row + height)
|
||||
// is above the viewport, or its top edge is at or below the
|
||||
// viewport's last row.
|
||||
// A placement is invisible if its bottom edge (row + height) is
|
||||
// above the viewport, or its top edge is at or below the viewport's
|
||||
// last row. The same applies horizontally: a pin's column is always
|
||||
// in bounds, but a relative placement's offsets can push it fully
|
||||
// off either side.
|
||||
const grid_size = p.gridSize(image.*, t);
|
||||
const bottom_row = @as(i64, vp_row) + @as(i64, grid_size.rows);
|
||||
const visible = bottom_row > 0 and vp_row < @as(i32, t.rows);
|
||||
const right_col = @as(i64, vp_col) + @as(i64, grid_size.cols);
|
||||
const visible = bottom_row > 0 and vp_row < @as(i32, t.rows) and
|
||||
right_col > 0 and vp_col < @as(i32, t.cols);
|
||||
|
||||
return .{ .col = vp_col, .row = vp_row, .visible = visible };
|
||||
}
|
||||
@@ -1632,7 +1664,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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -304,9 +343,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 +1361,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,
|
||||
@@ -1608,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);
|
||||
}
|
||||
|
||||
@@ -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,16 +629,280 @@ 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). 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,
|
||||
placement_id: u32,
|
||||
) ?struct { key: PlacementKey, placement: Placement } {
|
||||
if (placement_id > 0) {
|
||||
const key: PlacementKey = .{
|
||||
.image_id = image_id,
|
||||
.placement_id = .{ .tag = .external, .id = placement_id },
|
||||
};
|
||||
const p = self.placements.get(key) orelse return null;
|
||||
return .{ .key = key, .placement = p };
|
||||
}
|
||||
|
||||
var best: ?PlacementKey = null;
|
||||
var it = self.placements.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (entry.key_ptr.image_id != image_id) continue;
|
||||
if (entry.value_ptr.location != .virtual) continue;
|
||||
const b = best orelse {
|
||||
best = entry.key_ptr.*;
|
||||
continue;
|
||||
};
|
||||
if (entry.key_ptr.placement_id.preferredOver(b.placement_id)) {
|
||||
best = entry.key_ptr.*;
|
||||
}
|
||||
}
|
||||
|
||||
const key = best orelse return null;
|
||||
return .{ .key = key, .placement = self.placements.get(key).? };
|
||||
}
|
||||
|
||||
/// Get an image by its ID. If the image doesn't exist, null is returned.
|
||||
@@ -657,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.*);
|
||||
@@ -780,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);
|
||||
}
|
||||
}
|
||||
@@ -809,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);
|
||||
}
|
||||
}
|
||||
@@ -820,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.
|
||||
@@ -829,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);
|
||||
}
|
||||
}
|
||||
@@ -848,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
|
||||
@@ -868,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.
|
||||
@@ -884,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;
|
||||
|
||||
@@ -901,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);
|
||||
}
|
||||
}
|
||||
@@ -928,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;
|
||||
}
|
||||
|
||||
@@ -968,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);
|
||||
}
|
||||
}
|
||||
@@ -1034,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) {
|
||||
@@ -1055,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1108,10 +1411,28 @@ pub const ImageStorage = struct {
|
||||
/// Likewise, if a placement ID isn't specified it is assumed to be 0.
|
||||
pub const PlacementKey = struct {
|
||||
image_id: u32,
|
||||
placement_id: packed struct {
|
||||
tag: enum(u1) { internal, external },
|
||||
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
|
||||
/// without an explicit ID (p=0); external IDs are client-specified.
|
||||
/// The two are separate namespaces, hence the tag.
|
||||
pub const PlacementId = packed struct {
|
||||
tag: enum(u1) { internal, external },
|
||||
id: u32,
|
||||
|
||||
/// Deterministic preference order used when an operation must
|
||||
/// pick a single placement out of several (kitty uses creation
|
||||
/// order, which our map doesn't track): external IDs win over
|
||||
/// internal ones, and lower IDs win within a tag.
|
||||
pub fn preferredOver(self: PlacementId, other: PlacementId) bool {
|
||||
if (self.tag != other.tag) return self.tag == .external;
|
||||
return self.id < other.id;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Placement = struct {
|
||||
@@ -1140,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(
|
||||
@@ -1149,7 +1490,7 @@ pub const ImageStorage = struct {
|
||||
) void {
|
||||
switch (self.location) {
|
||||
.pin => |p| s.pages.untrackPin(p),
|
||||
.virtual => {},
|
||||
.virtual, .relative => {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1408,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,
|
||||
@@ -1416,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;
|
||||
|
||||
@@ -3571,3 +3917,185 @@ test "storage: scroll margins multi-line scroll up with scrollback" {
|
||||
try t.scrollUp(2);
|
||||
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;
|
||||
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, 5, .{
|
||||
.location = .{ .virtual = {} },
|
||||
});
|
||||
|
||||
// Exact external ID match.
|
||||
{
|
||||
const target = s.placeholderTarget(1, 5).?;
|
||||
const expected: ImageStorage.PlacementKey = .{
|
||||
.image_id = 1,
|
||||
.placement_id = .{ .tag = .external, .id = 5 },
|
||||
};
|
||||
try testing.expectEqual(expected, target.key);
|
||||
try testing.expect(target.placement.location == .virtual);
|
||||
}
|
||||
|
||||
// Zero placement ID falls back to the image's virtual placement.
|
||||
{
|
||||
const target = s.placeholderTarget(1, 0).?;
|
||||
const expected: ImageStorage.PlacementKey = .{
|
||||
.image_id = 1,
|
||||
.placement_id = .{ .tag = .external, .id = 5 },
|
||||
};
|
||||
try testing.expectEqual(expected, target.key);
|
||||
}
|
||||
|
||||
try testing.expect(s.placeholderTarget(1, 9) == null);
|
||||
try testing.expect(s.placeholderTarget(2, 0) == null);
|
||||
|
||||
// With multiple virtual placements, the zero-ID fallback picks
|
||||
// deterministically by PlacementId.preferredOver: lowest external.
|
||||
try s.addPlacement(io, alloc, t.screens.active, 1, 3, .{
|
||||
.location = .{ .virtual = {} },
|
||||
});
|
||||
{
|
||||
const expected: ImageStorage.PlacementKey = .{
|
||||
.image_id = 1,
|
||||
.placement_id = .{ .tag = .external, .id = 3 },
|
||||
};
|
||||
try testing.expectEqual(expected, s.placeholderTarget(1, 0).?.key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,29 +363,17 @@ pub const Placement = struct {
|
||||
image: *const Image,
|
||||
cell_width: u32,
|
||||
cell_height: u32,
|
||||
) !struct {
|
||||
) Error!struct {
|
||||
rows: u32,
|
||||
columns: u32,
|
||||
} {
|
||||
// Get the placement. If an ID is specified we look for the exact one.
|
||||
// If no ID, then we find the first virtual placement for this image.
|
||||
const placement = if (self.placement_id > 0) storage.placements.get(.{
|
||||
.image_id = self.image_id,
|
||||
.placement_id = .{ .tag = .external, .id = self.placement_id },
|
||||
}) orelse {
|
||||
return Error.PlacementMissingPlacement;
|
||||
} else placement: {
|
||||
var it = storage.placements.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (entry.key_ptr.image_id == self.image_id and
|
||||
entry.value_ptr.location == .virtual)
|
||||
{
|
||||
break :placement entry.value_ptr.*;
|
||||
}
|
||||
}
|
||||
|
||||
return Error.PlacementMissingPlacement;
|
||||
};
|
||||
const target = storage.placeholderTarget(
|
||||
self.image_id,
|
||||
self.placement_id,
|
||||
) orelse return Error.PlacementMissingPlacement;
|
||||
const placement = target.placement;
|
||||
|
||||
// Use requested rows/columns if specified
|
||||
// For unspecified rows/columns, calculate based on the image size.
|
||||
|
||||
Reference in New Issue
Block a user