mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-15 02:12:01 +00:00
Merge branch 'ghostty-org:main' into main
This commit is contained in:
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -98,6 +98,7 @@ deblasis
|
||||
dervedro
|
||||
devsunb
|
||||
diaaeddin
|
||||
-didouougha slingshotting LLM output
|
||||
diego-moment
|
||||
diegoarmstrong
|
||||
dkinzler
|
||||
|
||||
@@ -112,7 +112,6 @@ private class TerminalGlassView: NSView, ObservableObject {
|
||||
}
|
||||
|
||||
class GlassViewModel: ObservableObject {
|
||||
@Published var isActive: Bool = false
|
||||
@Published var backgroundColor: Color = .clear
|
||||
@Published var backgroundOpacity: Double = 0
|
||||
@Published var cornerRadius: CGFloat = 0
|
||||
|
||||
@@ -41,7 +41,6 @@ pub fn build(b: *std.Build) !void {
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.link_libc = true,
|
||||
});
|
||||
|
||||
const unit_tests = b.addTest(.{
|
||||
@@ -49,6 +48,9 @@ pub fn build(b: *std.Build) !void {
|
||||
.root_module = module,
|
||||
});
|
||||
|
||||
// Windows always has a libc available.
|
||||
const windows = target.result.os.tag == .windows;
|
||||
|
||||
translate: {
|
||||
const translate_c = b.lazyImport(@This(), "translate_c") orelse break :translate;
|
||||
const translate_c_dep = b.lazyDependency("translate_c", .{}) orelse break :translate;
|
||||
@@ -56,13 +58,16 @@ pub fn build(b: *std.Build) !void {
|
||||
.c_source_file = b.addWriteFiles().add("wuffs_c.h", &wuffs_c_source),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.libc_file = if (target.result.os.tag.isDarwin()) libc_file: {
|
||||
switch (try @import("apple_sdk").pathsForTarget(b, target.result)) {
|
||||
inline else => |paths| break :libc_file paths.libc,
|
||||
}
|
||||
} else null,
|
||||
.link_libc = windows,
|
||||
});
|
||||
|
||||
// Wuffs only needs stdlib.h and string.h from libc, and only for
|
||||
// a handful of declarations. We provide minimal versions of these
|
||||
// headers so that wuffs can be translated and compiled without
|
||||
// libc, notably for freestanding targets (wasm32) but this also
|
||||
// avoids requiring an Apple SDK for translate-c on macOS.
|
||||
if (!windows) wuffs_c.addIncludePath(b.path("include"));
|
||||
|
||||
var flags: std.ArrayList([]const u8) = .empty;
|
||||
defer flags.deinit(b.allocator);
|
||||
try flags.append(b.allocator, "-DWUFFS_IMPLEMENTATION");
|
||||
|
||||
@@ -22,12 +22,11 @@
|
||||
.hash = "N-V-__8AADYiAAB_80AWnH1AxXC0tql9thT-R-DYO1gBqTLc",
|
||||
.lazy = true,
|
||||
},
|
||||
|
||||
.apple_sdk = .{ .path = "../apple-sdk" },
|
||||
},
|
||||
.paths = .{
|
||||
"build.zig",
|
||||
"build.zig.zon",
|
||||
"include",
|
||||
"src",
|
||||
},
|
||||
}
|
||||
|
||||
13
pkg/wuffs/include/stdlib.h
Normal file
13
pkg/wuffs/include/stdlib.h
Normal file
@@ -0,0 +1,13 @@
|
||||
// Minimal stdlib.h so that wuffs can be translated and compiled without
|
||||
// requiring libc headers. See string.h in this directory for details.
|
||||
#ifndef GHOSTTY_WUFFS_STDLIB_H
|
||||
#define GHOSTTY_WUFFS_STDLIB_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
void *malloc(size_t size);
|
||||
void *calloc(size_t count, size_t size);
|
||||
void *realloc(void *ptr, size_t size);
|
||||
void free(void *ptr);
|
||||
|
||||
#endif
|
||||
22
pkg/wuffs/include/string.h
Normal file
22
pkg/wuffs/include/string.h
Normal file
@@ -0,0 +1,22 @@
|
||||
// Minimal string.h so that wuffs can be translated and compiled without
|
||||
// requiring libc headers (e.g. for wasm32-freestanding targets, or to
|
||||
// avoid needing an Apple SDK for translate-c on macOS).
|
||||
//
|
||||
// Wuffs only calls the mem* family, whose symbols are provided by Zig's
|
||||
// compiler-rt on targets without libc and by libc everywhere else. The
|
||||
// declarations here match the standard C ABI, so this header is safe to
|
||||
// use on every target, including ones that do link libc.
|
||||
#ifndef GHOSTTY_WUFFS_STRING_H
|
||||
#define GHOSTTY_WUFFS_STRING_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
void *memcpy(void *dst, const void *src, size_t n);
|
||||
void *memmove(void *dst, const void *src, size_t n);
|
||||
void *memset(void *b, int c, size_t n);
|
||||
int memcmp(const void *s1, const void *s2, size_t n);
|
||||
size_t strlen(const char *s);
|
||||
int strcmp(const char *s1, const char *s2);
|
||||
int strncmp(const char *s1, const char *s2, size_t n);
|
||||
|
||||
#endif
|
||||
@@ -16,6 +16,38 @@ pub const ImageData = struct {
|
||||
data: []u8,
|
||||
};
|
||||
|
||||
// Wuffs' generated `wuffs_foo__bar__alloc()` convenience functions are the
|
||||
// only code that references libc's calloc/free. We never call them
|
||||
// and linker garbage collection strips them, but that isn't guaranteed.
|
||||
// When libc isn't linked there would be nothing to provide calloc/free if
|
||||
// they survive, so export stubs to satisfy the link. Weak so that any real
|
||||
// definition wins. Hidden keeps them out of the export table where the
|
||||
// format honors it (e.g. wasm).
|
||||
comptime {
|
||||
if (!builtin.link_libc) {
|
||||
@export(&callocStub, .{
|
||||
.name = "calloc",
|
||||
.linkage = .weak,
|
||||
.visibility = .hidden,
|
||||
});
|
||||
@export(&freeStub, .{
|
||||
.name = "free",
|
||||
.linkage = .weak,
|
||||
.visibility = .hidden,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn callocStub(count: usize, size: usize) callconv(.c) ?*anyopaque {
|
||||
_ = count;
|
||||
_ = size;
|
||||
return null;
|
||||
}
|
||||
|
||||
fn freeStub(ptr: ?*anyopaque) callconv(.c) void {
|
||||
_ = ptr;
|
||||
}
|
||||
|
||||
test {
|
||||
refAllDeclsRecursive(@This());
|
||||
}
|
||||
|
||||
@@ -16,11 +16,15 @@ pub fn gToRgba(alloc: Allocator, src: []const u8) Error![]u8 {
|
||||
}
|
||||
|
||||
pub fn gaToRgba(alloc: Allocator, src: []const u8) Error![]u8 {
|
||||
// Wuffs doesn't support YA_PREMUL as a swizzle source. The nonpremul
|
||||
// pair produces the same bytes (r=g=b=y, a=a, no alpha math), which
|
||||
// is what we want: alpha semantics are preserved as-is, matching the
|
||||
// other conversions here.
|
||||
return swizzle(
|
||||
alloc,
|
||||
src,
|
||||
c.WUFFS_BASE__PIXEL_FORMAT__YA_PREMUL,
|
||||
c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL,
|
||||
c.WUFFS_BASE__PIXEL_FORMAT__YA_NONPREMUL,
|
||||
c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +55,16 @@ pub fn bgraToRgba(alloc: Allocator, src: []const u8) Error![]u8 {
|
||||
);
|
||||
}
|
||||
|
||||
test "gaToRgba" {
|
||||
const rgba = try gaToRgba(std.testing.allocator, &.{ 7, 100, 8, 200 });
|
||||
defer std.testing.allocator.free(rgba);
|
||||
|
||||
try std.testing.expectEqualSlices(u8, &.{
|
||||
7, 7, 7, 100,
|
||||
8, 8, 8, 200,
|
||||
}, rgba);
|
||||
}
|
||||
|
||||
fn swizzle(
|
||||
alloc: Allocator,
|
||||
src: []const u8,
|
||||
|
||||
@@ -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