diff --git a/src/renderer/image.zig b/src/renderer/image.zig index 8aa755e0b..bf8a606d5 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -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); +} diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index aa9d79bf7..a0333ee2f 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -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; diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index 02fb650ae..a509d23cf 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -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; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 461fc097e..e27500aab 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -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); +} diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 9cbcdf351..53382344c 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -307,10 +307,14 @@ pub const ImageStorage = struct { log.debug("addImage image={}", .{img.withoutData()}); - // Retransmitting a specific image ID replaces the old image and all - // of its placements, as required by the Kitty graphics protocol. if (gop.found_existing) { + // Retransmitting a specific image ID replaces the old image and all + // of its placements, as required by the Kitty graphics protocol. self.removePlacementsByImageId(s, img.id); + + // Relative placements parented to the removed placements go too. + _ = self.removeOrphans(s, null); + self.total_bytes -= gop.value_ptr.data.len(); gop.value_ptr.deinit(alloc); } @@ -362,13 +366,11 @@ pub const ImageStorage = struct { p, }); - // Tracked pins are marked garbage when their underlying history is - // pruned. Kitty removes placements once they scroll out of retained - // history, so reclaim those placements before growing the map for a - // new one. If allocation below fails, the sweep is still a content - // mutation and must be visible to consumers. - const removed_garbage = self.removeGarbagePlacements(s); - errdefer if (removed_garbage) self.markMutated(io); + // When we add a placement, take this opportunity to reap garbage. + // Garbage are placements that disappeared off scrollback (were + // pruned). We don't clear Kitty state in the hot path so we do + // this opportunistically here. + self.reapGarbagePlacements(io, s); // The important piece here is that the placement ID needs to // be marked internal if it is zero. This allows multiple placements @@ -405,6 +407,7 @@ pub const ImageStorage = struct { } gop.value_ptr.* = p; + // This always mutates self.markMutated(io); } @@ -467,13 +470,14 @@ pub const ImageStorage = struct { const p: *Placement = entry.value_ptr; // Virtual placements follow their placeholder cells and - // are never adjusted by scrolls, matching kitty. + // relative placements follow their parents, so neither is + // ever adjusted by scrolls, matching kitty. const pin: *PageList.Pin = switch (p.location) { .pin => |pin| pin, - .virtual => continue, + .virtual, .relative => continue, }; - // Pruned placements are reaped by removeGarbagePlacements. + // Pruned placements are reaped by reapGarbagePlacements. if (pin.garbage) continue; // Placements anchored outside the active area (scrollback) @@ -550,8 +554,7 @@ pub const ImageStorage = struct { // placement is deleted, like kitty. The image itself is // retained for future placements. if (!visible) { - p.deinit(s); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(s, entry); mutated = true; continue; } @@ -569,31 +572,47 @@ pub const ImageStorage = struct { }; } - if (mutated) self.markMutated(io); + if (mutated) { + // Placements deleted by clipping may orphan relative placements. + // Orphans have no pins so this never touches the restores. + _ = self.removeOrphans(s, null); + self.markMutated(io); + } + return result; } - /// Remove pin-backed placements whose tracked content has been pruned. - /// Virtual placements have no tracked screen location and are retained. - fn removeGarbagePlacements( + /// Reap pin-backed placements whose tracked content has been pruned + /// from history, along with any relative placements orphaned by + /// that, marking the content mutation. + /// + /// Virtual and relative placements have no tracked screen location and + /// are never pruned themselves. Kitty removes placements once they + /// scroll out of retained history. + fn reapGarbagePlacements( self: *ImageStorage, + io: std.Io, s: *terminal.Screen, - ) bool { + ) void { var removed = false; var it = self.placements.iterator(); while (it.next()) |entry| { const pin = switch (entry.value_ptr.location) { .pin => |pin| pin, - .virtual => continue, + .virtual, .relative => continue, }; if (!pin.garbage) continue; - entry.value_ptr.deinit(s); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(s, entry); removed = true; } + if (!removed) return; - return removed; + // If we removed a placement, then also remove any orphan + // children if this was a parent. + _ = self.removeOrphans(s, null); + + self.markMutated(io); } fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void { @@ -610,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); + } +} diff --git a/src/terminal/kitty/graphics_unicode.zig b/src/terminal/kitty/graphics_unicode.zig index 485502593..b62f8177c 100644 --- a/src/terminal/kitty/graphics_unicode.zig +++ b/src/terminal/kitty/graphics_unicode.zig @@ -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.