diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig | 1359 |
1 files changed, 1359 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig new file mode 100644 index 00000000..8ff68e3f --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig @@ -0,0 +1,1359 @@ +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +const terminal = @import("../main.zig"); +const point = @import("../point.zig"); +const size = @import("../size.zig"); +const command = @import("graphics_command.zig"); +const PageList = @import("../PageList.zig"); +const Screen = @import("../Screen.zig"); +const LoadingImage = @import("graphics_image.zig").LoadingImage; +const Image = @import("graphics_image.zig").Image; +const Rect = @import("graphics_image.zig").Rect; +const Command = command.Command; + +const log = std.log.scoped(.kitty_gfx); + +/// An image storage is associated with a terminal screen (i.e. main +/// screen, alt screen) and contains all the transmitted images and +/// placements. +pub const ImageStorage = struct { + const ImageMap = std.AutoHashMapUnmanaged(u32, Image); + const PlacementMap = std.AutoHashMapUnmanaged(PlacementKey, Placement); + + /// Dirty is set to true if placements or images change. This is + /// purely informational for the renderer and doesn't affect the + /// correctness of the program. The renderer must set this to false + /// if it cares about this value. + dirty: bool = false, + + /// This is the next automatically assigned image ID. We start mid-way + /// through the u32 range to avoid collisions with buggy programs. + /// TODO: This isn't good enough, it's perfectly legal for programs + /// to use IDs in the latter half of the range and collisions + /// are not gracefully handled. + next_image_id: u32 = 2147483647, + + /// This is the next automatically assigned placement ID. This is never + /// user-facing so we can start at 0. This is 32-bits because we use + /// the same space for external placement IDs. We can start at zero + /// because any number is valid. + next_internal_placement_id: u32 = 0, + + /// The set of images that are currently known. + images: ImageMap = .{}, + + /// The set of placements for loaded images. + placements: PlacementMap = .{}, + + /// Non-null if there is an in-progress loading image. + loading: ?*LoadingImage = null, + + /// The total bytes of image data that have been loaded and the limit. + /// If the limit is reached, the oldest images will be evicted to make + /// space. Unused images take priority. + total_bytes: usize = 0, + total_limit: usize = 320 * 1000 * 1000, // 320MB + + pub fn deinit( + self: *ImageStorage, + alloc: Allocator, + s: *terminal.Screen, + ) void { + if (self.loading) |loading| loading.destroy(alloc); + + var it = self.images.iterator(); + while (it.next()) |kv| kv.value_ptr.deinit(alloc); + self.images.deinit(alloc); + + self.clearPlacements(s); + self.placements.deinit(alloc); + } + + /// Kitty image protocol is enabled if we have a non-zero limit. + pub fn enabled(self: *const ImageStorage) bool { + return self.total_limit != 0; + } + + /// Sets the limit in bytes for the total amount of image data that + /// can be loaded. If this limit is lower, this will do an eviction + /// if necessary. If the value is zero, then Kitty image protocol will + /// be disabled. + pub fn setLimit( + self: *ImageStorage, + alloc: Allocator, + s: *terminal.Screen, + limit: usize, + ) !void { + // Special case disabling by quickly deleting all + if (limit == 0) { + self.deinit(alloc, s); + self.* = .{}; + } + + // If we re lowering our limit, check if we need to evict. + if (limit < self.total_bytes) { + const req_bytes = self.total_bytes - limit; + log.info("evicting images to lower limit, evicting={}", .{req_bytes}); + if (!try self.evictImage(alloc, req_bytes)) { + log.warn("failed to evict enough images for required bytes", .{}); + } + } + + self.total_limit = limit; + } + + /// Add an already-loaded image to the storage. This will automatically + /// free any existing image with the same ID. + pub fn addImage(self: *ImageStorage, alloc: Allocator, img: Image) Allocator.Error!void { + // If the image itself is over the limit, then error immediately + if (img.data.len > self.total_limit) return error.OutOfMemory; + + // If this would put us over the limit, then evict. + const total_bytes = self.total_bytes + img.data.len; + if (total_bytes > self.total_limit) { + const req_bytes = total_bytes - self.total_limit; + log.info("evicting images to make space for {} bytes", .{req_bytes}); + if (!try self.evictImage(alloc, req_bytes)) { + log.warn("failed to evict enough images for required bytes", .{}); + return error.OutOfMemory; + } + } + + // Do the gop op first so if it fails we don't get a partial state + const gop = try self.images.getOrPut(alloc, img.id); + + log.debug("addImage image={}", .{img: { + var copy = img; + copy.data = ""; + break :img copy; + }}); + + // Write our new image + if (gop.found_existing) { + self.total_bytes -= gop.value_ptr.data.len; + gop.value_ptr.deinit(alloc); + } + + gop.value_ptr.* = img; + self.total_bytes += img.data.len; + + self.dirty = true; + } + + /// Add a placement for a given image. The caller must verify in advance + /// the image exists to prevent memory corruption. + pub fn addPlacement( + self: *ImageStorage, + alloc: Allocator, + image_id: u32, + placement_id: u32, + p: Placement, + ) !void { + assert(self.images.get(image_id) != null); + log.debug("placement image_id={} placement_id={} placement={}\n", .{ + image_id, + placement_id, + p, + }); + + // The important piece here is that the placement ID needs to + // be marked internal if it is zero. This allows multiple placements + // to be added for the same image. If it is non-zero, then it is + // an external placement ID and we can only have one placement + // per (image id, placement id) pair. + const key: PlacementKey = .{ + .image_id = image_id, + .placement_id = if (placement_id == 0) .{ + .tag = .internal, + .id = id: { + defer self.next_internal_placement_id +%= 1; + break :id self.next_internal_placement_id; + }, + } else .{ + .tag = .external, + .id = placement_id, + }, + }; + + const gop = try self.placements.getOrPut(alloc, key); + gop.value_ptr.* = p; + + self.dirty = true; + } + + fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void { + var it = self.placements.iterator(); + while (it.next()) |entry| entry.value_ptr.deinit(s); + self.placements.clearRetainingCapacity(); + } + + /// Get an image by its ID. If the image doesn't exist, null is returned. + pub fn imageById(self: *const ImageStorage, image_id: u32) ?Image { + return self.images.get(image_id); + } + + /// Get an image by its number. If the image doesn't exist, return null. + pub fn imageByNumber(self: *const ImageStorage, image_number: u32) ?Image { + var newest: ?Image = null; + + var it = self.images.iterator(); + while (it.next()) |kv| { + if (kv.value_ptr.number == image_number) { + if (newest == null or + kv.value_ptr.transmit_time.order(newest.?.transmit_time) == .gt) + { + newest = kv.value_ptr.*; + } + } + } + + return newest; + } + + /// Delete placements, images. + pub fn delete( + self: *ImageStorage, + alloc: Allocator, + t: *terminal.Terminal, + cmd: command.Delete, + ) void { + switch (cmd) { + .all => |delete_images| { + var it = self.placements.iterator(); + while (it.next()) |entry| { + // Skip virtual placements + switch (entry.value_ptr.location) { + .pin => {}, + .virtual => continue, + } + + // Deinit the placement and remove it + const image_id = entry.key_ptr.image_id; + entry.value_ptr.deinit(t.screens.active); + self.placements.removeByPtr(entry.key_ptr); + if (delete_images) self.deleteIfUnused(alloc, image_id); + } + + if (delete_images) { + var image_it = self.images.iterator(); + while (image_it.next()) |kv| self.deleteIfUnused(alloc, kv.key_ptr.*); + } + + self.dirty = true; + }, + + .id => |v| self.deleteById( + alloc, + t.screens.active, + v.image_id, + v.placement_id, + v.delete, + ), + + .newest => |v| newest: { + const img = self.imageByNumber(v.image_number) orelse break :newest; + self.deleteById( + alloc, + t.screens.active, + img.id, + v.placement_id, + v.delete, + ); + }, + + .intersect_cursor => |delete_images| { + self.deleteIntersecting( + alloc, + t, + .{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, + } }, + delete_images, + {}, + null, + ); + }, + + .intersect_cell => |v| intersect_cell: { + if (v.x <= 0 or v.y <= 0) { + log.warn("delete intersect cell coords must be at least 1", .{}); + break :intersect_cell; + } + + self.deleteIntersecting( + alloc, + t, + .{ .active = .{ + .x = std.math.cast(size.CellCountInt, v.x - 1) orelse break :intersect_cell, + .y = std.math.cast(size.CellCountInt, v.y - 1) orelse break :intersect_cell, + } }, + v.delete, + {}, + null, + ); + }, + + .intersect_cell_z => |v| intersect_cell_z: { + if (v.x <= 0 or v.y <= 0) { + log.warn("delete intersect cell coords must be at least 1", .{}); + break :intersect_cell_z; + } + + self.deleteIntersecting( + alloc, + t, + .{ .active = .{ + .x = std.math.cast(size.CellCountInt, v.x - 1) orelse break :intersect_cell_z, + .y = std.math.cast(size.CellCountInt, v.y - 1) orelse break :intersect_cell_z, + } }, + v.delete, + v.z, + struct { + fn filter(ctx: i32, p: Placement) bool { + return p.z == ctx; + } + }.filter, + ); + }, + + .column => |v| column: { + if (v.x <= 0) { + log.warn("delete column must be greater than zero", .{}); + break :column; + } + + const x = v.x - 1; + var it = self.placements.iterator(); + while (it.next()) |entry| { + 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.placements.removeByPtr(entry.key_ptr); + if (v.delete) self.deleteIfUnused(alloc, img.id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + + .row => |v| row: { + if (v.y <= 0) { + log.warn("delete row must be greater than zero", .{}); + break :row; + } + + // v.y is in active coords so we want to convert it to a pin + // so we can compare by page offsets. + const target_pin = t.screens.active.pages.pin(.{ .active = .{ + .y = std.math.cast(size.CellCountInt, v.y - 1) orelse break :row, + } }) orelse break :row; + + var it = self.placements.iterator(); + while (it.next()) |entry| { + const img = self.imageById(entry.key_ptr.image_id) orelse continue; + const rect = entry.value_ptr.rect(img, t) orelse continue; + + // We need to copy our pin to ensure we are at least at + // the top-left x. + 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.placements.removeByPtr(entry.key_ptr); + if (v.delete) self.deleteIfUnused(alloc, img.id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + + .z => |v| { + var it = self.placements.iterator(); + while (it.next()) |entry| { + switch (entry.value_ptr.location) { + .pin => {}, + + // Virtual placeholders cannot delete by z according + // to the spec. + .virtual => continue, + } + + if (entry.value_ptr.z == v.z) { + const image_id = entry.key_ptr.image_id; + entry.value_ptr.deinit(t.screens.active); + self.placements.removeByPtr(entry.key_ptr); + if (v.delete) self.deleteIfUnused(alloc, image_id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + + .range => |v| range: { + if (v.first <= 0 or v.last <= 0) { + log.warn("delete range values must be greater than zero", .{}); + break :range; + } + if (v.first > v.last) { + log.warn("delete range 'x' ({}) must be less than or equal to 'y' ({})", .{ v.first, v.last }); + break :range; + } + + var it = self.placements.iterator(); + while (it.next()) |entry| { + if (entry.key_ptr.image_id >= v.first or entry.key_ptr.image_id <= v.last) { + const image_id = entry.key_ptr.image_id; + entry.value_ptr.deinit(t.screens.active); + self.placements.removeByPtr(entry.key_ptr); + if (v.delete) self.deleteIfUnused(alloc, image_id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + + // We don't support animation frames yet so they are successfully + // deleted! + .animation_frames => {}, + } + } + + fn deleteById( + self: *ImageStorage, + alloc: Allocator, + s: *terminal.Screen, + image_id: u32, + placement_id: u32, + delete_unused: bool, + ) void { + // If no placement, we delete all placements with the ID + if (placement_id == 0) { + var it = self.placements.iterator(); + while (it.next()) |entry| { + if (entry.key_ptr.image_id == image_id) { + entry.value_ptr.deinit(s); + self.placements.removeByPtr(entry.key_ptr); + } + } + } else { + if (self.placements.getEntry(.{ + .image_id = image_id, + .placement_id = .{ .tag = .external, .id = placement_id }, + })) |entry| { + entry.value_ptr.deinit(s); + self.placements.removeByPtr(entry.key_ptr); + } + } + + // If this is specified, then we also delete the image + // if it is no longer in use. + if (delete_unused) self.deleteIfUnused(alloc, image_id); + + // Mark dirty to force redraw + self.dirty = true; + } + + /// Delete an image if it is unused. + fn deleteIfUnused(self: *ImageStorage, alloc: Allocator, image_id: u32) void { + var it = self.placements.iterator(); + while (it.next()) |kv| { + if (kv.key_ptr.image_id == image_id) { + return; + } + } + + // If we get here, we can delete the image. + if (self.images.getEntry(image_id)) |entry| { + self.total_bytes -= entry.value_ptr.data.len; + entry.value_ptr.deinit(alloc); + self.images.removeByPtr(entry.key_ptr); + } + } + + /// Deletes all placements intersecting a screen point. + fn deleteIntersecting( + self: *ImageStorage, + alloc: Allocator, + t: *terminal.Terminal, + p: point.Point, + delete_unused: bool, + filter_ctx: anytype, + comptime filter: ?fn (@TypeOf(filter_ctx), Placement) bool, + ) void { + // Convert our target point to a pin for comparison. + const target_pin = t.screens.active.pages.pin(p) orelse return; + + var it = self.placements.iterator(); + while (it.next()) |entry| { + const img = self.imageById(entry.key_ptr.image_id) orelse continue; + const rect = entry.value_ptr.rect(img, t) orelse continue; + if (target_pin.isBetween(rect.top_left, rect.bottom_right)) { + if (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue; + entry.value_ptr.deinit(t.screens.active); + self.placements.removeByPtr(entry.key_ptr); + if (delete_unused) self.deleteIfUnused(alloc, img.id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + } + + /// Evict image to make space. This will evict the oldest image, + /// prioritizing unused images first, as recommended by the published + /// Kitty spec. + /// + /// This will evict as many images as necessary to make space for + /// req bytes. + fn evictImage(self: *ImageStorage, alloc: Allocator, req: usize) !bool { + assert(req <= self.total_limit); + + // Ironically we allocate to evict. We should probably redesign the + // data structures to avoid this but for now allocating a little + // bit is fine compared to the megabytes we're looking to save. + const Candidate = struct { + id: u32, + time: std.time.Instant, + used: bool, + }; + + var candidates: std.ArrayList(Candidate) = .empty; + defer candidates.deinit(alloc); + + var it = self.images.iterator(); + while (it.next()) |kv| { + const img = kv.value_ptr; + + // This is a huge waste. See comment above about redesigning + // our data structures to avoid this. Eviction should be very + // rare though and we never have that many images/placements + // so hopefully this will last a long time. + const used = used: { + var p_it = self.placements.iterator(); + while (p_it.next()) |p_kv| { + if (p_kv.key_ptr.image_id == img.id) { + break :used true; + } + } + + break :used false; + }; + + try candidates.append(alloc, .{ + .id = img.id, + .time = img.transmit_time, + .used = used, + }); + } + + // Sort + std.mem.sortUnstable( + Candidate, + candidates.items, + {}, + struct { + fn lessThan( + ctx: void, + lhs: Candidate, + rhs: Candidate, + ) bool { + _ = ctx; + + // If they're usage matches, then its based on time. + if (lhs.used == rhs.used) return switch (lhs.time.order(rhs.time)) { + .lt => true, + .gt => false, + .eq => lhs.id < rhs.id, + }; + + // If not used, then its a better candidate + return !lhs.used; + } + }.lessThan, + ); + + // They're in order of best to evict. + var evicted: usize = 0; + for (candidates.items) |c| { + // Delete all the placements for this image and the image. + var p_it = self.placements.iterator(); + while (p_it.next()) |entry| { + if (entry.key_ptr.image_id == c.id) { + self.placements.removeByPtr(entry.key_ptr); + } + } + + if (self.images.getEntry(c.id)) |entry| { + log.info("evicting image id={} bytes={}", .{ c.id, entry.value_ptr.data.len }); + + evicted += entry.value_ptr.data.len; + self.total_bytes -= entry.value_ptr.data.len; + + entry.value_ptr.deinit(alloc); + self.images.removeByPtr(entry.key_ptr); + + if (evicted > req) return true; + } + } + + return false; + } + + /// Every placement is uniquely identified by the image ID and the + /// placement ID. If an image ID isn't specified it is assumed to be 0. + /// 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, + }, + }; + + pub const Placement = struct { + /// The location where this placement should be drawn. + location: Location, + + /// Offset of the x/y from the top-left of the cell. + x_offset: u32 = 0, + y_offset: u32 = 0, + + /// Source rectangle for the image to pull from + source_x: u32 = 0, + source_y: u32 = 0, + source_width: u32 = 0, + source_height: u32 = 0, + + /// The columns/rows this image occupies. + columns: u32 = 0, + rows: u32 = 0, + + /// The z-index for this placement. + z: i32 = 0, + + pub const Location = union(enum) { + /// Exactly placed on a screen pin. + pin: *PageList.Pin, + + /// Virtual placement (U=1) for unicode placeholders. + virtual: void, + }; + + pub fn deinit( + self: *const Placement, + s: *terminal.Screen, + ) void { + switch (self.location) { + .pin => |p| s.pages.untrackPin(p), + .virtual => {}, + } + } + + /// Calculates the size of this placement's image in pixels, + /// taking in to account the specified rows and columns. + pub fn calculatedSize( + self: Placement, + image: Image, + t: *const terminal.Terminal, + ) struct { + width: u32, + height: u32, + } { + // Height / width of the image in px. + const width = if (self.source_width > 0) self.source_width else image.width; + const height = if (self.source_height > 0) self.source_height else image.height; + + // If we don't have any specified cols or rows then the placement + // should be the native size of the image, and doesn't need to be + // re-scaled. + if (self.columns == 0 and self.rows == 0) return .{ + .width = width, + .height = height, + }; + + // We calculate the size of a cell so that we can multiply + // it by the specified cols/rows to get the correct px size. + // + // We assume that the width is divided evenly by the column + // count and the height by the row count, because it should be. + const cell_width: u32 = t.width_px / t.cols; + const cell_height: u32 = t.height_px / t.rows; + + const width_f64: f64 = @floatFromInt(width); + const height_f64: f64 = @floatFromInt(height); + + // If we have a specified cols AND rows then we calculate + // the width and height from them directly, we don't need + // to adjust for aspect ratio. + if (self.columns > 0 and self.rows > 0) { + const calc_width = cell_width * self.columns; + const calc_height = cell_height * self.rows; + + return .{ + .width = calc_width, + .height = calc_height, + }; + } + + // Either the columns or the rows were specified, but not both, + // so we need to calculate the other one based on the aspect ratio. + + // If only the columns were specified, we determine + // the height of the image based on the aspect ratio. + if (self.columns > 0) { + const aspect = height_f64 / width_f64; + const calc_width: u32 = cell_width * self.columns; + const calc_height: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(calc_width)) * aspect, + )); + + return .{ + .width = calc_width, + .height = calc_height, + }; + } + + // Otherwise, only the rows were specified, so we + // determine the width based on the aspect ratio. + { + const aspect = width_f64 / height_f64; + const calc_height: u32 = cell_height * self.rows; + const calc_width: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(calc_height)) * aspect, + )); + + return .{ + .width = calc_width, + .height = calc_height, + }; + } + } + + /// Returns the size in grid cells that this placement takes up. + pub fn gridSize( + self: Placement, + image: Image, + t: *const terminal.Terminal, + ) struct { + cols: u32, + rows: u32, + } { + // If we have a specified columns and rows then this is trivial. + if (self.columns > 0 and self.rows > 0) return .{ + .cols = self.columns, + .rows = self.rows, + }; + + // Otherwise we calculate the pixel size, divide by + // cell size, and round up to the nearest integer. + const calc_size = self.calculatedSize(image, t); + return .{ + .cols = std.math.divCeil( + u32, + calc_size.width + self.x_offset, + t.width_px / t.cols, + ) catch 0, + .rows = std.math.divCeil( + u32, + calc_size.height + self.y_offset, + t.height_px / t.rows, + ) catch 0, + }; + // NOTE: Above `divCeil`s can only fail if the cell size is 0, + // in such a case it seems safe to return 0 for this. + } + + /// Returns a selection of the entire rectangle this placement + /// occupies within the screen. This can return null if the placement + /// doesn't have an associated rect (i.e. a virtual placement). + pub fn rect( + self: Placement, + image: Image, + t: *const terminal.Terminal, + ) ?Rect { + const grid_size = self.gridSize(image, t); + const pin = switch (self.location) { + .pin => |p| p, + .virtual => return null, + }; + + var br = switch (pin.downOverflow(grid_size.rows - 1)) { + .offset => |v| v, + .overflow => |v| v.end, + }; + br.x = @min( + // We need to sub one here because the x value is + // one width already. So if the image is width "1" + // then we add zero to X because X itself is width 1. + pin.x + (grid_size.cols - 1), + t.cols - 1, + ); + + return .{ + .top_left = pin.*, + .bottom_right = br, + }; + } + }; +}; + +// Our pin for the placement +fn trackPin( + t: *terminal.Terminal, + pt: point.Coordinate, +) !*PageList.Pin { + return try t.screens.active.pages.trackPin(t.screens.active.pages.pin(.{ + .active = pt, + }).?); +} + +test "storage: add placement with zero placement id" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(alloc, 1, 0, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + try s.addPlacement(alloc, 1, 0, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + + try testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .internal, .id = 0 }, + }) != null); + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .internal, .id = 1 }, + }) != null); +} + +test "storage: delete all placements and images" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .all = true }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 0), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete all placements and images preserves limit" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + s.total_limit = 5000; + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .all = true }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 0), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(@as(usize, 5000), s.total_limit); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete all placements" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .all = false }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete all placements by image id" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .id = .{ .image_id = 2 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete all placements by image id and unused images" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .id = .{ .delete = true, .image_id = 2 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete placement by specific id" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .id = .{ + .delete = true, + .image_id = 1, + .placement_id = 2, + } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked + 2, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete intersecting cursor" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + + t.screens.active.cursorAbsolute(12, 12); + + s.dirty = false; + s.delete(alloc, &t, .{ .intersect_cursor = false }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 2 }, + }) != null); +} + +test "storage: delete intersecting cursor plus unused" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + + t.screens.active.cursorAbsolute(12, 12); + + s.dirty = false; + s.delete(alloc, &t, .{ .intersect_cursor = true }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 2 }, + }) != null); +} + +test "storage: delete intersecting cursor hits multiple" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + + t.screens.active.cursorAbsolute(26, 26); + + s.dirty = false; + s.delete(alloc, &t, .{ .intersect_cursor = true }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete by column" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .column = .{ + .delete = false, + .x = 60, + } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); +} + +test "storage: delete by column 1x1" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 0 }) } }); + try s.addPlacement(alloc, 1, 3, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 2, .y = 0 }) } }); + + s.delete(alloc, &t, .{ .column = .{ + .delete = false, + .x = 2, + } }); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expectEqual(@as(usize, 1), s.images.count()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 3 }, + }) != null); +} + +test "storage: delete by row" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .row = .{ + .delete = false, + .y = 60, + } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); +} + +test "storage: delete by row 1x1" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 0 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 1 }) } }); + try s.addPlacement(alloc, 1, 3, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 2 }) } }); + + s.delete(alloc, &t, .{ .row = .{ + .delete = false, + .y = 2, + } }); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expectEqual(@as(usize, 1), s.images.count()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 3 }, + }) != null); +} + +test "storage: delete images by range 1" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + s.dirty = false; + s.delete(alloc, &t, .{ .range = .{ .delete = false, .first = 1, .last = 2 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete images by range 2" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + s.dirty = false; + s.delete(alloc, &t, .{ .range = .{ .delete = true, .first = 1, .last = 2 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete images by range 3" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + s.dirty = false; + s.delete(alloc, &t, .{ .range = .{ .delete = false, .first = 1, .last = 1 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete images by range 4" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + s.dirty = false; + s.delete(alloc, &t, .{ .range = .{ .delete = true, .first = 1, .last = 1 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: aspect ratio calculation when only columns or rows specified" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); + defer t.deinit(alloc); + t.width_px = 1000; // 10 px per col + t.height_px = 2000; // 20 px per row + + // Case 1: Only columns specified + { + const image = Image{ .id = 1, .width = 16, .height = 9 }; + var placement = ImageStorage.Placement{ + .location = .{ .virtual = {} }, + .columns = 10, + .rows = 0, + }; + + // Image is 16x9, set to a width of 10 columns, at 10px per column + // that's 100px width. 100px * (9 / 16) = 56.25, which should round + // to a height of 56px. + + const calc_size = placement.calculatedSize(image, &t); + try testing.expectEqual(@as(u32, 100), calc_size.width); + try testing.expectEqual(@as(u32, 56), calc_size.height); + } + + // Case 2: Only rows specified + { + const image = Image{ .id = 2, .width = 16, .height = 9 }; + var placement = ImageStorage.Placement{ + .location = .{ .virtual = {} }, + .columns = 0, + .rows = 5, + }; + + // Image is 16x9, set to a height of 5 rows, at 20px per row that's + // 100px height. 100px * (16 / 9) = 177.77..., which should round to + // a width of 178px. + + const calc_size = placement.calculatedSize(image, &t); + try testing.expectEqual(@as(u32, 178), calc_size.width); + try testing.expectEqual(@as(u32, 100), calc_size.height); + } +} |
