aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-10 21:51:57 +0300
committerGitHub <noreply@github.com>2026-09-10 21:51:57 +0300
commit425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch)
treee52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig
parent90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff)
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig3113
1 files changed, 3029 insertions, 84 deletions
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig
index 6c72ec4e..b1b968b2 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig
@@ -5,6 +5,8 @@ const Allocator = std.mem.Allocator;
const Terminal = @import("../Terminal.zig");
const command = @import("graphics_command.zig");
const image = @import("graphics_image.zig");
+const animation = @import("graphics_animation.zig");
+const pixel = @import("graphics_pixel.zig");
const Command = command.Command;
const Response = command.Response;
const LoadingImage = image.LoadingImage;
@@ -21,6 +23,7 @@ const log = std.log.scoped(.kitty_gfx);
/// The allocator must be the same allocator that was used to build
/// the command.
pub fn execute(
+ io: std.Io,
alloc: Allocator,
terminal: *Terminal,
cmd: *const Command,
@@ -43,12 +46,32 @@ pub fn execute(
// this can change.
var quiet = cmd.quiet;
+ // The protocol makes i and I mutually exclusive for every action, so this
+ // must happen before dispatch and before an action can mutate storage.
+ // https://sw.kovidgoyal.net/kitty/graphics-protocol/#requesting-image-ids-from-the-terminal
+ const identifiers = cmd.control.identifiers();
+ if (identifiers.image_id > 0 and identifiers.image_number > 0) {
+ const resp: Response = .{
+ .id = identifiers.image_id,
+ .image_number = identifiers.image_number,
+ .placement_id = identifiers.placement_id,
+ .message = "EINVAL: image ID and number are mutually exclusive",
+ };
+ log.warn("erroneous kitty graphics response: {s}", .{resp.message});
+
+ return switch (quiet) {
+ .no => resp,
+ .ok => resp,
+ .failures => null,
+ };
+ }
+
const resp_: ?Response = switch (cmd.control) {
- .query => query(alloc, terminal, cmd),
- .display => display(alloc, terminal, cmd),
- .delete => delete(alloc, terminal, cmd),
+ .query => query(io, alloc, terminal, cmd),
+ .display => display(io, alloc, terminal, cmd),
+ .delete => delete(io, alloc, terminal, cmd),
- .transmit, .transmit_and_display => resp: {
+ .transmit, .transmit_and_display, .transmit_animation_frame => resp: {
// If we're transmitting, then our `q` setting value is complicated.
// The `q` setting inherits the value from the starting command
// unless `q` is set >= 1 on this command. If it is, then we save
@@ -65,13 +88,35 @@ pub fn execute(
},
};
- break :resp transmit(alloc, terminal, cmd);
+ break :resp switch (cmd.control) {
+ .transmit_animation_frame => transmitAnimationFrame(
+ io,
+ alloc,
+ terminal,
+ cmd,
+ ),
+ else => transmit(
+ io,
+ alloc,
+ terminal,
+ cmd,
+ ),
+ };
},
- .transmit_animation_frame,
- .control_animation,
- .compose_animation,
- => .{ .message = "ERROR: unimplemented action" },
+ .control_animation => controlAnimation(
+ io,
+ alloc,
+ terminal,
+ cmd,
+ ),
+
+ .compose_animation => composeAnimation(
+ io,
+ alloc,
+ terminal,
+ cmd,
+ ),
};
// Handle the quiet settings
@@ -89,12 +134,14 @@ pub fn execute(
return null;
}
+
/// Execute a "query" command.
///
/// This command is used to attempt to load an image and respond with
/// success/error but does not persist any of the command to the terminal
/// state.
fn query(
+ io: std.Io,
alloc: Allocator,
terminal: *const Terminal,
cmd: *const Command,
@@ -115,13 +162,21 @@ fn query(
.placement_id = t.placement_id,
};
- // Attempt to load the image. If we cannot, then set an appropriate error.
+ // A query must attempt a complete load, then discard the result without
+ // changing image storage.
+ // https://sw.kovidgoyal.net/kitty/graphics-protocol/#querying-support-and-available-transmission-mediums
const storage = &terminal.screens.active.kitty_images;
- var loading = LoadingImage.init(alloc, cmd, storage.image_limits) catch |err| {
+ var loading = LoadingImage.init(io, alloc, cmd, storage.image_limits) catch |err| {
encodeError(&result, err);
return result;
};
- loading.deinit(alloc);
+ defer loading.deinit(alloc);
+
+ var img = loading.complete(alloc) catch |err| {
+ encodeError(&result, err);
+ return result;
+ };
+ img.deinit(alloc);
return result;
}
@@ -131,21 +186,35 @@ fn query(
/// This loads the image, validates it, and puts it into the terminal
/// screen storage. It does not display the image.
fn transmit(
+ io: std.Io,
alloc: Allocator,
terminal: *Terminal,
cmd: *const Command,
) Response {
const t = cmd.transmission().?;
- var result: Response = .{
+ const storage = &terminal.screens.active.kitty_images;
+
+ var result: Response = if (storage.loading) |loading| loading: {
+ // Any transmit-like command received while a load is in progress
+ // is a continuation chunk of that load, and the load completes
+ // according to how it started. If an animation frame load (a=f)
+ // is in progress, this chunk belongs to it even when the client
+ // didn't repeat a=f on the chunk.
+ if (loading.frame != null) return transmitAnimationFrame(
+ io,
+ alloc,
+ terminal,
+ cmd,
+ );
+
+ break :loading loading.response;
+ } else .{
.id = t.image_id,
.image_number = t.image_number,
.placement_id = t.placement_id,
};
- if (t.image_id > 0 and t.image_number > 0) {
- return .{ .message = "EINVAL: image ID and number are mutually exclusive" };
- }
- const load = loadAndAddImage(alloc, terminal, cmd) catch |err| {
+ const load = loadAndAddImage(io, alloc, terminal, cmd) catch |err| {
encodeError(&result, err);
return result;
};
@@ -158,7 +227,7 @@ fn transmit(
assert(!load.more);
var d_copy = d;
d_copy.image_id = load.image.id;
- result = display(alloc, terminal, &.{
+ result = display(io, alloc, terminal, &.{
.control = .{ .display = d_copy },
.quiet = cmd.quiet,
});
@@ -169,7 +238,7 @@ fn transmit(
// If the loaded image was assigned its ID automatically, not based
// on a number or explicitly specified ID, then we don't respond.
- if (load.image.implicit_id) return .{};
+ if (load.image.metadata.implicit_id) return .{};
// After the image is added, set the ID in case it changed.
// The resulting image number and placement ID never change.
@@ -180,6 +249,7 @@ fn transmit(
/// Display a previously transmitted image.
fn display(
+ io: std.Io,
alloc: Allocator,
terminal: *Terminal,
cmd: *const Command,
@@ -198,6 +268,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)
@@ -215,42 +292,84 @@ 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";
+ if (d.virtual_placement) 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 .{ .virtual = {} };
+ };
+ 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
- const p: ImageStorage.Placement = .{
- .location = location,
- .x_offset = d.x_offset,
- .y_offset = d.y_offset,
- .source_x = d.x,
- .source_y = d.y,
- .source_width = d.width,
- .source_height = d.height,
- .columns = d.columns,
- .rows = d.rows,
- .z = d.z,
+ const p: ImageStorage.Placement = placement: {
+ var p: ImageStorage.Placement = .{
+ .location = location,
+ .x_offset = d.x_offset,
+ .y_offset = d.y_offset,
+ .source_x = d.x,
+ .source_y = d.y,
+ .source_width = d.width,
+ .source_height = d.height,
+ .columns = d.columns,
+ .rows = d.rows,
+ .z = d.z,
+ };
+
+ const cell_offset = p.cellOffset(terminal);
+ if (terminal.width_px / terminal.cols > 0) p.x_offset = cell_offset.x;
+ if (terminal.height_px / terminal.rows > 0) p.y_offset = cell_offset.y;
+
+ break :placement p;
};
storage.addPlacement(
+ io,
alloc,
+ terminal.screens.active,
img.id,
result.placement_id,
p,
@@ -260,22 +379,54 @@ 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 => {
// We use terminal.index to properly handle scroll regions.
+ const screen = terminal.screens.active;
const size = p.gridSize(img, terminal);
- for (0..size.rows) |_| terminal.index() catch |err| {
+ const target_x = @as(usize, pin.x) +| @as(usize, size.cols);
+ const wraps = target_x >= @as(usize, terminal.cols);
+ const requested_rows =
+ (@as(usize, size.rows) -| 1) +| @intFromBool(wraps);
+
+ // The requested row count comes from the application and can
+ // be as large as u32. Calling terminal.index once for every
+ // row could therefore leave the terminal unresponsive for a
+ // very long time.
+ //
+ // First allow enough calls to reach the bottom of the scroll
+ // region. Each call after that scrolls the region by one row,
+ // so limit those extra calls to one screen. This follows
+ // Kitty's behavior while keeping the amount of work bounded.
+ const region = terminal.scrolling_region;
+ const rows_before_scroll: usize = if (screen.cursor.y >= region.top and
+ screen.cursor.y <= region.bottom and
+ screen.cursor.x >= region.left and
+ screen.cursor.x <= region.right)
+ @as(usize, region.bottom - screen.cursor.y)
+ else
+ 0;
+ const rows_to_move: usize = @min(
+ requested_rows,
+ rows_before_scroll +| @as(usize, terminal.rows),
+ );
+ for (0..rows_to_move) |_| terminal.index() catch |err| {
log.warn("failed to move cursor: {}", .{err});
break;
};
- terminal.setCursorPos(
- terminal.screens.active.cursor.y,
- pin.x + size.cols + 1,
+ // Kitty wraps once when the movement reaches the right edge;
+ // otherwise it leaves the cursor immediately to the right of
+ // the placement.
+ screen.cursor.pending_wrap = false;
+ screen.cursorHorizontalAbsolute(
+ if (wraps) 0 else @intCast(target_x),
);
},
},
@@ -284,20 +435,556 @@ fn display(
return result;
}
+/// Transmit animation frame data (a=f).
+///
+/// Frame data is loaded exactly like image data, including chunking
+/// and every transmission medium, and once complete it is composed
+/// into a frame of an existing image's animation.
+fn transmitAnimationFrame(
+ io: std.Io,
+ alloc: Allocator,
+ terminal: *Terminal,
+ cmd: *const Command,
+) Response {
+ const storage = &terminal.screens.active.kitty_images;
+
+ // A chunk arriving while a load is in progress continues that load.
+ if (storage.loading) |loading| {
+ // If this is the first frame, then we treat it like a normal image
+ // because the first frame is just the image.
+ if (loading.frame == null) return transmit(
+ io,
+ alloc,
+ terminal,
+ cmd,
+ );
+
+ // Not the first frame, so continue loading similar to transmit
+ // but this is for a subsequent frame.
+ var result = loading.response;
+ loading.addData(alloc, cmd.data) catch |err| {
+ encodeError(&result, err);
+ return result;
+ };
+
+ // If more chunks are expected we don't respond yet.
+ const t = cmd.transmission().?;
+ if (t.more_chunks) return .{};
+
+ // Final chunk: copy the loading state out and free the
+ // pointer, mirroring loadAndAddImage.
+ var loading_copy = loading.*;
+ alloc.destroy(loading);
+ storage.loading = null;
+ defer loading_copy.deinit(alloc);
+ return completeAnimationFrame(
+ io,
+ alloc,
+ terminal,
+ &loading_copy,
+ );
+ }
+
+ // We are starting our load, either the first frame or a subsequent frame.
+ const f = cmd.control.transmit_animation_frame;
+ const t = f.transmission;
+ var result: Response = .{
+ .id = t.image_id,
+ .image_number = t.image_number,
+ .placement_id = t.placement_id,
+ // Errors echo the client's frame number; on success this is
+ // replaced with the resolved (possibly newly assigned) one.
+ .frame = f.edit_frame,
+ };
+
+ // A frame can only be added to an existing image.
+ if (t.image_id == 0 and t.image_number == 0) {
+ result.message = "EINVAL: image ID or number required";
+ return result;
+ }
+ const img = storage.imagePtrByIdOrNumber(
+ t.image_id,
+ t.image_number,
+ ) orelse {
+ result.message = "ENOENT: image not found";
+ return result;
+ };
+ result.id = img.id;
+
+ var loading = LoadingImage.init(
+ io,
+ alloc,
+ cmd,
+ storage.image_limits,
+ ) catch |err| {
+ encodeError(&result, err);
+ return result;
+ };
+ loading.frame = .{
+ .cmd = f,
+ .image_generation = img.generation,
+ };
+ loading.response.id = img.id;
+
+ // Chunked: store the loading state and wait for the rest. The
+ // frame parameters above are saved with it; continuation chunks
+ // contribute only payload bytes.
+ if (t.more_chunks) {
+ const loading_ptr = alloc.create(LoadingImage) catch |err| {
+ loading.deinit(alloc);
+ encodeError(&result, err);
+ return result;
+ };
+ loading_ptr.* = loading;
+ storage.loading = loading_ptr;
+ return .{};
+ }
+
+ defer loading.deinit(alloc);
+ return completeAnimationFrame(
+ io,
+ alloc,
+ terminal,
+ &loading,
+ );
+}
+
+/// Complete a fully loaded animation frame: validate it against its
+/// image and compose it into the image's animation.
+fn completeAnimationFrame(
+ io: std.Io,
+ alloc: Allocator,
+ terminal: *Terminal,
+ loading: *LoadingImage,
+) Response {
+ const storage = &terminal.screens.active.kitty_images;
+ var result = loading.response;
+ const f = loading.frame.?.cmd;
+
+ // Re-resolve the image: it may have been deleted, evicted, or
+ // replaced while the frame data was being transmitted. The saved
+ // generation pins the exact image the load started against.
+ var img = storage.imagePtrByIdOrNumber(
+ loading.image.id,
+ loading.image.number,
+ ) orelse {
+ result.message = "ENOENT: image not found";
+ return result;
+ };
+ if (img.generation != loading.frame.?.image_generation) {
+ result.message = "ENOENT: image not found";
+ return result;
+ }
+ if (img.data.bytes() == null) {
+ result.message = "EINVAL: image data incomplete";
+ return result;
+ }
+
+ // Finish decoding the frame data: decompression, PNG decoding,
+ // and length validation all match image loading.
+ var frame_img = loading.complete(alloc) catch |err| {
+ encodeError(&result, err);
+ return result;
+ };
+ defer frame_img.deinit(alloc);
+
+ // The frame rectangle may not exceed the image's size. The x/y
+ // offsets are deliberately not validated: composition clips,
+ // matching Kitty.
+ if (frame_img.width > img.width or frame_img.height > img.height) {
+ result.message = "EINVAL: frame dimensions exceed image";
+ return result;
+ }
+
+ // All composition happens in RGBA; convert both sides as needed.
+ if (frame_img.format != .rgba) {
+ const rgba = pixel.rgbaFromFormat(
+ alloc,
+ frame_img.format,
+ frame_img.data.bytes().?,
+ ) catch |err| {
+ encodeError(&result, err);
+ return result;
+ };
+ frame_img.data.deinit(alloc);
+ frame_img.data = .{ .complete = rgba };
+ frame_img.format = .rgba;
+ }
+ storage.convertImageToRgba(io, alloc, img) catch |err| {
+ encodeError(&result, err);
+ return result;
+ };
+
+ const anim = ensureAnimation(alloc, img) catch |err| {
+ encodeError(&result, err);
+ return result;
+ };
+
+ // Resolve the frame number: r in 1..count edits that existing
+ // frame, anything else (including omitted) creates a new frame
+ // appended at count+1. The resolved number is echoed in the
+ // response so clients can learn assigned frame numbers.
+ const count: u32 = anim.frameCount();
+ const number: u32 = number: {
+ const r = f.edit_frame;
+ if (r == 0 or r > count + 1) break :number count + 1;
+ break :number r;
+ };
+ result.frame = number;
+
+ const src = frame_img.data.bytes().?;
+ if (number == count + 1) {
+ // Creating a new frame. The gap defaults to 40ms when omitted
+ // and a negative gap creates a gapless (never shown) frame.
+ const gap: u32 = if (f.gap_ms > 0)
+ @intCast(f.gap_ms)
+ else if (f.gap_ms < 0)
+ 0
+ else
+ animation.default_gap_ms;
+
+ // The base canvas frame must exist before we reserve space.
+ if (f.create_frame > 0 and img.frameData(f.create_frame) == null) {
+ result.message = "EINVAL: base frame not found";
+ return result;
+ }
+
+ // Reserve room for the new frame, evicting other images if
+ // needed. Eviction can remove images (never this one), so we
+ // re-resolve our pointer afterwards to be safe.
+ const frame_len: usize = @as(usize, img.width) * img.height * 4;
+ const image_id = img.id;
+ storage.reserveAnimationBytes(
+ io,
+ alloc,
+ terminal.screens.active,
+ image_id,
+ frame_len,
+ ) catch {
+ result.message = "ENOSPC: animation frame storage full";
+ return result;
+ };
+ img = storage.imagePtrByIdOrNumber(image_id, 0).?;
+
+ const canvas = alloc.alloc(u8, frame_len) catch {
+ storage.releaseAnimationBytes(frame_len);
+ result.message = "ENOMEM: out of memory";
+ return result;
+ };
+ if (f.create_frame > 0) {
+ @memcpy(canvas, img.frameData(f.create_frame).?);
+ } else {
+ pixel.fillBackground(canvas, f.background);
+ }
+ pixel.composeRect(
+ canvas,
+ img.width,
+ img.height,
+ src,
+ frame_img.width,
+ frame_img.height,
+ f.x,
+ f.y,
+ f.composition_mode,
+ );
+
+ anim.frames.append(alloc, .{
+ .data = canvas,
+ .gap_ms = gap,
+ }) catch {
+ alloc.free(canvas);
+ storage.releaseAnimationBytes(frame_len);
+ result.message = "ENOMEM: out of memory";
+ return result;
+ };
+
+ // A new frame never changes the displayed frame (playback
+ // reaches it later), but the storage content changed.
+ storage.markMutated(io);
+ } else {
+ // Editing an existing frame. A nonzero gap also updates the
+ // frame's gap; the 40ms default doesn't apply to edits.
+ //
+ // Unlike frame creation there is no byte reservation here:
+ // frames are always stored at full image size, so the edit
+ // composes into the existing buffer in place and storage
+ // usage cannot change. Kitty likewise exempts frame edits
+ // from its quota check.
+ if (f.gap_ms != 0) anim.setGapAt(
+ number - 1,
+ if (f.gap_ms > 0) @intCast(f.gap_ms) else 0,
+ );
+
+ // The frame data is owned by this storage, so the const cast
+ // is safe (same reasoning as the renderer's image uploads).
+ const dst = @constCast(img.frameData(number).?);
+ pixel.composeRect(
+ dst,
+ img.width,
+ img.height,
+ src,
+ frame_img.width,
+ frame_img.height,
+ f.x,
+ f.y,
+ f.composition_mode,
+ );
+
+ if (number - 1 == anim.current_index) {
+ // The displayed pixels changed; restart the frame's gap
+ // timer like Kitty's re-upload does.
+ anim.frame_shown_at_ms = null;
+ storage.markImageContentChanged(io, img);
+ } else {
+ storage.markMutated(io);
+ }
+ }
+
+ return result;
+}
+
+/// Control animation playback (a=a).
+///
+/// Successful commands never respond. The only possible responses are
+/// for a missing image or identifier. Invalid key values are silently
+/// ignored, matching Kitty.
+fn controlAnimation(
+ io: std.Io,
+ alloc: Allocator,
+ terminal: *Terminal,
+ cmd: *const Command,
+) Response {
+ const a = cmd.control.control_animation;
+ const storage = &terminal.screens.active.kitty_images;
+
+ var result: Response = .{
+ .id = a.image_id,
+ .image_number = a.image_number,
+ .placement_id = a.placement_id,
+ };
+ if (a.image_id == 0 and a.image_number == 0) {
+ result.message = "EINVAL: image ID or number required";
+ return result;
+ }
+ const img = storage.imagePtrByIdOrNumber(
+ a.image_id,
+ a.image_number,
+ ) orelse {
+ result.message = "ENOENT: image not found";
+ return result;
+ };
+
+ const anim = ensureAnimation(alloc, img) catch |err| {
+ encodeError(&result, err);
+ return result;
+ };
+
+ // The keys below are applied independently and in the same order as Kitty.
+
+ // Set a frame's gap (r= together with z=). This is the only way
+ // to give the root frame a gap, since it is created gapless.
+ if (a.frame != 0 and a.frame <= anim.frameCount() and a.gap_ms != 0) {
+ anim.setGapAt(
+ a.frame - 1,
+ if (a.gap_ms > 0) @intCast(a.gap_ms) else 0,
+ );
+ storage.markMutated(io);
+ }
+
+ // Set the current frame (c=), the client-driven animation
+ // primitive.
+ if (a.current_frame != 0 and a.current_frame <= anim.frameCount() and
+ a.current_frame - 1 != anim.current_index)
+ {
+ anim.current_index = a.current_frame - 1;
+ anim.frame_shown_at_ms = null;
+ storage.markImageContentChanged(io, img);
+ }
+
+ // Set the playback state (s=). Any state change resets the loop
+ // counter; leaving the stopped state restarts the gap timer.
+ if (a.action != .invalid) {
+ const old = anim.state;
+ anim.state = switch (a.action) {
+ .invalid => unreachable,
+ .stop => .stopped,
+ .run_wait => .loading,
+ .run => .running,
+ };
+ if (old == .stopped and anim.state != .stopped) {
+ anim.frame_shown_at_ms = null;
+ }
+ anim.current_loop = 0;
+ storage.markMutated(io);
+ }
+
+ // Set the loop count (v=), stored off by one per the protocol:
+ // v=1 loops forever, v=n plays n-1 loops.
+ if (a.loops != 0) {
+ anim.max_loops = a.loops - 1;
+ storage.markMutated(io);
+ }
+
+ // Successful animation control commands never respond.
+ return .{};
+}
+
+/// Compose a rectangle of pixels from one animation frame onto
+/// another (a=c). Frame 1 (the root frame) always exists, so this also works
+/// on images without any animation state.
+fn composeAnimation(
+ io: std.Io,
+ alloc: Allocator,
+ terminal: *Terminal,
+ cmd: *const Command,
+) Response {
+ const c = cmd.control.compose_animation;
+ const storage = &terminal.screens.active.kitty_images;
+
+ var result: Response = .{
+ .id = c.image_id,
+ .image_number = c.image_number,
+ .placement_id = c.placement_id,
+ };
+ if (c.image_id == 0 and c.image_number == 0) {
+ result.message = "EINVAL: image ID or number required";
+ return result;
+ }
+ const img = storage.imagePtrByIdOrNumber(
+ c.image_id,
+ c.image_number,
+ ) orelse {
+ result.message = "ENOENT: image not found";
+ return result;
+ };
+ result.id = img.id;
+ if (img.data.bytes() == null) {
+ result.message = "EINVAL: image data incomplete";
+ return result;
+ }
+
+ // Both frames must exist. Note that r is the source frame and c
+ // the destination; the spec's reference table describes these
+ // backwards (see AnimationFrameComposition).
+ if (img.frameData(c.source_frame) == null) {
+ result.message = "ENOENT: source frame not found";
+ return result;
+ }
+ if (img.frameData(c.dest_frame) == null) {
+ result.message = "ENOENT: destination frame not found";
+ return result;
+ }
+
+ // Rectangle validation is done in u64 so untrusted 32-bit values
+ // can't overflow. Out-of-bounds rectangles are errors here,
+ // unlike a=f which clips.
+ const width: u64 = if (c.width > 0) c.width else img.width;
+ const height: u64 = if (c.height > 0) c.height else img.height;
+ if (@as(u64, c.x) + width > img.width or
+ @as(u64, c.y) + height > img.height)
+ {
+ result.message = "EINVAL: destination rectangle out of bounds";
+ return result;
+ }
+ if (@as(u64, c.left_edge) + width > img.width or
+ @as(u64, c.top_edge) + height > img.height)
+ {
+ result.message = "EINVAL: source rectangle out of bounds";
+ return result;
+ }
+
+ // Composing a frame onto itself requires non-overlapping
+ // rectangles.
+ if (c.source_frame == c.dest_frame) {
+ const x_overlaps = @max(c.left_edge, c.x) < @min(c.left_edge, c.x) + width;
+ const y_overlaps = @max(c.top_edge, c.y) < @min(c.top_edge, c.y) + height;
+ if (x_overlaps and y_overlaps) {
+ result.message = "EINVAL: source and destination rectangles overlap";
+ return result;
+ }
+ }
+
+ // All composition happens in RGBA.
+ storage.convertImageToRgba(
+ io,
+ alloc,
+ img,
+ ) catch |err| {
+ encodeError(&result, err);
+ return result;
+ };
+
+ // The frame data is owned by this storage, so the const cast is
+ // safe. The rectangles were validated disjoint above, so in-place
+ // composition within one frame is well-defined.
+ const src = img.frameData(c.source_frame).?;
+ const dst = @constCast(img.frameData(c.dest_frame).?);
+ pixel.composeCanvasRect(
+ dst,
+ src,
+ img.width,
+ @intCast(width),
+ @intCast(height),
+ c.left_edge,
+ c.top_edge,
+ c.x,
+ c.y,
+ c.composition_mode,
+ );
+
+ // If the destination is the displayed frame then the on-screen
+ // content changed.
+ const current: u32 = if (img.animation) |anim| anim.current_index else 0;
+ if (c.dest_frame - 1 == current) {
+ storage.markImageContentChanged(io, img);
+ } else {
+ storage.markMutated(io);
+ }
+
+ return result;
+}
+
+/// Get or lazily create the animation state for an image.
+fn ensureAnimation(
+ alloc: Allocator,
+ img: *Image,
+) Allocator.Error!*animation.Animation {
+ if (img.animation) |anim| return anim;
+ const anim = try alloc.create(animation.Animation);
+ anim.* = .{};
+ img.animation = anim;
+ return anim;
+}
+
/// Display a previously transmitted image.
fn delete(
+ io: std.Io,
alloc: Allocator,
terminal: *Terminal,
cmd: *const Command,
) Response {
const storage = &terminal.screens.active.kitty_images;
- storage.delete(alloc, terminal, cmd.control.delete);
+
+ // Every delete command aborts an incomplete chunked upload.
+ if (storage.loading) |loading| {
+ loading.destroy(alloc);
+ storage.loading = null;
+ }
+
+ // Then perform the actual deletion request, too.
+ storage.delete(
+ io,
+ alloc,
+ terminal,
+ cmd.control.delete.action,
+ );
// Delete never responds on success
return .{};
}
fn loadAndAddImage(
+ io: std.Io,
alloc: Allocator,
terminal: *Terminal,
cmd: *const Command,
@@ -327,7 +1014,18 @@ fn loadAndAddImage(
}
break :loading loading.*;
- } else try .init(alloc, cmd, storage.image_limits);
+ } else loading: {
+ // Reusing a specific image ID deletes the old image and all of its
+ // placements when the new transmission begins, not when it completes.
+ if (t.image_id > 0) {
+ storage.delete(io, alloc, terminal, .{ .id = .{
+ .image_id = t.image_id,
+ .delete = true,
+ } });
+ }
+
+ break :loading try .init(io, alloc, cmd, storage.image_limits);
+ };
// We only want to deinit on error. If we're chunking, then we don't
// want to deinit at all. If we're not chunking, then we'll deinit
@@ -336,12 +1034,12 @@ fn loadAndAddImage(
// If the image has no ID, we assign one
if (loading.image.id == 0) {
- loading.image.id = storage.next_image_id;
- storage.next_image_id +%= 1;
-
- // If the image also has no number then its auto-ID is "implicit".
- // See the doc comment on the Image.implicit_id field for more detail.
- if (loading.image.number == 0) loading.image.implicit_id = true;
+ if (loading.image.number > 0) {
+ loading.image.id = storage.nextImageId(.explicit);
+ } else {
+ loading.image.id = storage.nextImageId(.implicit);
+ loading.image.metadata.implicit_id = true;
+ }
}
// If this is chunked, this is the beginning of a new chunked transmission.
@@ -362,7 +1060,7 @@ fn loadAndAddImage(
// Validate and store our image
var img = try loading.complete(alloc);
errdefer img.deinit(alloc);
- try storage.addImage(alloc, img);
+ try storage.addImage(io, alloc, terminal.screens.active, img);
// Get our display settings
const display_ = loading.display;
@@ -380,6 +1078,7 @@ const EncodeableError = Image.Error || Allocator.Error;
fn encodeError(r: *Response, err: EncodeableError) void {
switch (err) {
error.OutOfMemory => r.message = "ENOMEM: out of memory",
+ error.InsufficientData => r.message = "ENODATA: insufficient data",
error.InvalidData => r.message = "EINVAL: invalid data",
error.DecompressionFailed => r.message = "EINVAL: decompression failed",
error.FilePathTooLong => r.message = "EINVAL: file path too long",
@@ -393,11 +1092,262 @@ fn encodeError(r: *Response, err: EncodeableError) void {
}
}
+test "kittygfx query validates image data" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const io = testing.io;
+
+ var terminal = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
+ defer terminal.deinit(alloc);
+
+ var cmd: Command = .{
+ .control = .{ .query = .{
+ .format = .rgb,
+ .width = 1,
+ .height = 1,
+ .image_id = 31,
+ } },
+ // A 1x1 RGB image requires three bytes.
+ .data = try alloc.dupe(u8, &.{ 0, 0 }),
+ };
+ defer cmd.deinit(alloc);
+
+ const resp = execute(io, alloc, &terminal, &cmd).?;
+ try testing.expect(!resp.ok());
+ try testing.expectEqual(@as(u32, 31), resp.id);
+ try testing.expectEqualStrings("EINVAL: invalid data", resp.message);
+ try testing.expectEqual(
+ @as(usize, 0),
+ terminal.screens.active.kitty_images.images.count(),
+ );
+}
+
+test "kittygfx valid query does not replace or store image" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const io = testing.io;
+
+ var terminal = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
+ defer terminal.deinit(alloc);
+ const storage = &terminal.screens.active.kitty_images;
+
+ // Store a red pixel under the same ID used by the query.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=1,i=31;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &terminal, &cmd).?.ok());
+ }
+
+ // Successfully validate a black pixel without replacing the red one.
+ var cmd: Command = .{
+ .control = .{ .query = .{
+ .format = .rgb,
+ .width = 1,
+ .height = 1,
+ .image_id = 31,
+ } },
+ .data = try alloc.dupe(u8, &.{ 0, 0, 0 }),
+ };
+ defer cmd.deinit(alloc);
+
+ const resp = execute(io, alloc, &terminal, &cmd).?;
+ try testing.expect(resp.ok());
+ try testing.expectEqual(@as(usize, 1), storage.images.count());
+ try testing.expectEqualSlices(
+ u8,
+ &.{ 255, 0, 0 },
+ storage.imageById(31).?.data.bytes().?,
+ );
+}
+
+test "kittygfx image id and number are mutually exclusive for every action" {
+ 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 inputs = [_][]const u8{
+ "a=q,f=24,s=1,v=1,i=1,I=2,p=3;AAAA",
+ "a=t,f=24,s=1,v=1,i=1,I=2,p=3;AAAA",
+ "a=T,f=24,s=1,v=1,i=1,I=2,p=3;AAAA",
+ "a=p,i=1,I=2,p=3",
+ "a=d,d=a,i=1,I=2,p=3",
+ "a=f,i=1,I=2,p=3;AAAA",
+ "a=a,i=1,I=2,p=3",
+ "a=c,i=1,I=2,p=3",
+ };
+
+ for (inputs) |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.expectEqual(@as(u32, 1), resp.id);
+ try testing.expectEqual(@as(u32, 2), resp.image_number);
+ try testing.expectEqual(@as(u32, 3), resp.placement_id);
+ try testing.expectEqualStrings(
+ "EINVAL: image ID and number are mutually exclusive",
+ resp.message,
+ );
+
+ var buf: [128]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try resp.encode(&writer);
+ try testing.expectEqualStrings(
+ "\x1b_Gi=1,I=2,p=3;EINVAL: image ID and number are mutually exclusive\x1b\\",
+ writer.buffered(),
+ );
+ }
+
+ try testing.expectEqual(@as(usize, 0), t.screens.active.kitty_images.images.count());
+ try testing.expectEqual(@as(usize, 0), t.screens.active.kitty_images.placements.count());
+}
+
+test "kittygfx conflicting identifiers are rejected before mutation" {
+ 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;
+
+ // Store an image so a buggy put would succeed and mutate placement state.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=1,i=1;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // Directly constructed commands must receive the same validation as
+ // commands produced by the parser.
+ {
+ const cmd: Command = .{ .control = .{ .display = .{
+ .image_id = 1,
+ .image_number = 2,
+ .placement_id = 7,
+ .cursor_movement = .none,
+ } } };
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(!resp.ok());
+ try testing.expectEqual(@as(u32, 1), resp.id);
+ try testing.expectEqual(@as(u32, 2), resp.image_number);
+ try testing.expectEqual(@as(u32, 7), resp.placement_id);
+ try testing.expectEqual(@as(usize, 0), storage.placements.count());
+ }
+
+ // Add a real placement so a buggy delete would remove it.
+ {
+ const cmd = try command.Parser.parseString(alloc, "a=p,i=1,p=7,C=1");
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ try testing.expectEqual(@as(usize, 1), storage.placements.count());
+ }
+
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=d,d=i,i=1,I=2,p=7",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(!resp.ok());
+ try testing.expectEqual(@as(usize, 1), storage.placements.count());
+ }
+
+ // q=2 suppresses the required error response but not the validation.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=d,d=i,i=1,I=2,p=7,q=2",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ try testing.expectEqual(@as(usize, 1), storage.placements.count());
+ }
+}
+
+test "kittygfx chunked success response uses initial identifiers" {
+ 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 cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=2,I=93,p=7,m=1;AAAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+
+ {
+ const cmd = try command.Parser.parseString(alloc, "m=0;AAAA");
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+
+ try testing.expect(resp.ok());
+ try testing.expectEqual(@as(u32, 1), resp.id);
+ try testing.expectEqual(@as(u32, 93), resp.image_number);
+ try testing.expectEqual(@as(u32, 7), resp.placement_id);
+
+ var buf: [128]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try resp.encode(&writer);
+ try testing.expectEqualStrings(
+ "\x1b_Gi=1,I=93,p=7;OK\x1b\\",
+ writer.buffered(),
+ );
+ }
+}
+
+test "kittygfx chunked error response uses initial identifiers" {
+ 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 cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=1,i=41,p=7,m=1;AA==",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+
+ {
+ const cmd = try command.Parser.parseString(alloc, "m=0;AA==");
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+
+ try testing.expect(!resp.ok());
+ try testing.expectEqual(@as(u32, 41), resp.id);
+ try testing.expectEqual(@as(u32, 7), resp.placement_id);
+ try testing.expectEqualStrings("EINVAL: invalid data", resp.message);
+ }
+}
+
test "kittygfx more chunks with q=1" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
+ var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
// Initial chunk has q=1
@@ -407,7 +1357,7 @@ test "kittygfx more chunks with q=1" {
"a=T,f=24,t=d,i=1,s=1,v=2,c=10,r=1,m=1,q=1;////",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd);
+ const resp = execute(io, alloc, &t, &cmd);
try testing.expect(resp == null);
}
@@ -418,7 +1368,7 @@ test "kittygfx more chunks with q=1" {
"m=0;////",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd);
+ const resp = execute(io, alloc, &t, &cmd);
try testing.expect(resp == null);
}
}
@@ -426,8 +1376,9 @@ test "kittygfx more chunks with q=1" {
test "kittygfx more chunks with q=0" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
+ var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
// Initial chunk has q=0
@@ -437,7 +1388,7 @@ test "kittygfx more chunks with q=0" {
"a=t,f=24,t=d,s=1,v=2,c=10,r=1,m=1,i=1,q=0;////",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd);
+ const resp = execute(io, alloc, &t, &cmd);
try testing.expect(resp == null);
}
@@ -448,7 +1399,7 @@ test "kittygfx more chunks with q=0" {
"m=0;////",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd).?;
+ const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(resp.ok());
}
}
@@ -456,8 +1407,9 @@ test "kittygfx more chunks with q=0" {
test "kittygfx more chunks with chunk increasing q" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
+ var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
// Initial chunk has q=0
@@ -467,7 +1419,7 @@ test "kittygfx more chunks with chunk increasing q" {
"a=t,f=24,t=d,s=1,v=2,c=10,r=1,m=1,i=1,q=0;////",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd);
+ const resp = execute(io, alloc, &t, &cmd);
try testing.expect(resp == null);
}
@@ -478,16 +1430,93 @@ test "kittygfx more chunks with chunk increasing q" {
"m=0,q=1;////",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd);
+ const resp = execute(io, alloc, &t, &cmd);
try testing.expect(resp == null);
}
}
+test "kittygfx delete aborts chunked image load" {
+ 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;
+
+ // Begin a two-chunk RGB image, then interrupt it with a delete.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=2,i=1,m=1;AAAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+ try testing.expect(storage.loading != null);
+
+ {
+ const cmd = try command.Parser.parseString(alloc, "a=d");
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+ try testing.expect(storage.loading == null);
+
+ // A fresh chunked upload must start from empty state after the delete.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=2,i=1,m=1;AAAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+ {
+ const cmd = try command.Parser.parseString(alloc, "m=0;AAAA");
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ try testing.expectEqual(@as(usize, 6), storage.imageById(1).?.data.len());
+}
+
+test "kittygfx uppercase id delete preserves image when placement does not match" {
+ 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;
+
+ // Store an unplaced 1x1 RGB image.
+ {
+ 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());
+ }
+
+ // Uppercase deletion may free data only if the named placement matched.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=d,d=I,i=1,p=7",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+
+ try testing.expect(storage.imageById(1) != null);
+}
+
test "kittygfx default format is rgba" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
+ var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const cmd = try command.Parser.parseString(
@@ -495,7 +1524,7 @@ test "kittygfx default format is rgba" {
"a=t,t=d,i=1,s=1,v=2,c=10,r=1;///////////",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd).?;
+ const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(resp.ok());
const storage = &t.screens.active.kitty_images;
@@ -506,8 +1535,9 @@ test "kittygfx default format is rgba" {
test "kittygfx test valid u32 (expect invalid image ID)" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
+ var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const cmd = try command.Parser.parseString(
@@ -515,7 +1545,7 @@ test "kittygfx test valid u32 (expect invalid image ID)" {
"a=p,i=4294967295",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd).?;
+ const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqual(resp.message, "ENOENT: image not found");
}
@@ -523,8 +1553,9 @@ test "kittygfx test valid u32 (expect invalid image ID)" {
test "kittygfx test valid i32 (expect invalid image ID)" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
+ var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const cmd = try command.Parser.parseString(
@@ -532,7 +1563,7 @@ test "kittygfx test valid i32 (expect invalid image ID)" {
"a=p,i=1,z=-2147483648",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd).?;
+ const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqual(resp.message, "ENOENT: image not found");
}
@@ -540,8 +1571,9 @@ test "kittygfx test valid i32 (expect invalid image ID)" {
test "kittygfx no response with no image ID or number" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
+ var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
{
@@ -550,7 +1582,7 @@ test "kittygfx no response with no image ID or number" {
"a=t,f=24,t=d,s=1,v=2,c=10,r=1,i=0,I=0;////////",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd);
+ const resp = execute(io, alloc, &t, &cmd);
try testing.expect(resp == null);
}
}
@@ -558,8 +1590,9 @@ test "kittygfx no response with no image ID or number" {
test "kittygfx no response with no image ID or number load and display" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
+ var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
{
@@ -568,16 +1601,17 @@ test "kittygfx no response with no image ID or number load and display" {
"a=T,f=24,t=d,s=1,v=2,c=10,r=1,i=0,I=0;////////",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd);
+ const resp = execute(io, alloc, &t, &cmd);
try testing.expect(resp == null);
}
}
test "kittygfx retransmit same id gets fresh image generation" {
const testing = std.testing;
+ const io = testing.io;
const alloc = testing.allocator;
- var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
+ var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const storage = &t.screens.active.kitty_images;
@@ -588,7 +1622,7 @@ test "kittygfx retransmit same id gets fresh image generation" {
"a=t,t=d,f=24,i=1,s=1,v=2;////////",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd).?;
+ const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(resp.ok());
}
const gen1 = storage.imageById(1).?.generation;
@@ -604,7 +1638,7 @@ test "kittygfx retransmit same id gets fresh image generation" {
"a=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd).?;
+ const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(resp.ok());
}
const gen2 = storage.imageById(1).?.generation;
@@ -612,11 +1646,134 @@ test "kittygfx retransmit same id gets fresh image generation" {
try testing.expectEqual(gen2, storage.generation);
}
+test "kittygfx retransmit same id removes existing placements" {
+ const testing = std.testing;
+ const io = testing.io;
+ const alloc = testing.allocator;
+
+ var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
+ defer t.deinit(alloc);
+ const storage = &t.screens.active.kitty_images;
+ const tracked = t.screens.active.pages.countTrackedPins();
+
+ // Transmit and display an image, then add anonymous and named placements.
+ // Multiple anonymous a=p placements for one image are explicitly valid.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=T,t=d,f=24,i=1,s=1,v=2,C=1;////////",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ }
+ {
+ const cmd = try command.Parser.parseString(alloc, "a=p,i=1,C=1");
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ }
+ {
+ const cmd = try command.Parser.parseString(alloc, "a=p,i=1,p=7,C=1");
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ }
+ try testing.expectEqual(@as(usize, 3), storage.placements.count());
+ try testing.expectEqual(
+ tracked + 3,
+ t.screens.active.pages.countTrackedPins(),
+ );
+
+ // Retransmitting replaces the image and must delete every old placement.
+ // Plain a=t creates no replacement placement of its own.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ }
+ try testing.expectEqual(@as(usize, 0), storage.placements.count());
+ try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins());
+
+ // a=T creates one new placement after deleting the previous image and
+ // placements, so repeated redraws remain bounded at one placement.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=T,t=d,f=24,i=1,s=1,v=2,C=1;////////",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ }
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=T,t=d,f=24,i=1,s=1,v=2,C=1;AAAAAAAA",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ }
+ try testing.expectEqual(@as(usize, 1), storage.placements.count());
+ try testing.expectEqual(
+ tracked + 1,
+ t.screens.active.pages.countTrackedPins(),
+ );
+}
+
+test "kittygfx retransmit same id removes image on first chunk" {
+ const testing = std.testing;
+ const io = testing.io;
+ const alloc = testing.allocator;
+
+ var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
+ defer t.deinit(alloc);
+ const storage = &t.screens.active.kitty_images;
+ const tracked = t.screens.active.pages.countTrackedPins();
+
+ // Store and display the old image.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=T,t=d,f=24,i=1,s=1,v=2,C=1;////////",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ try testing.expect(storage.imageById(1) != null);
+ try testing.expectEqual(@as(usize, 1), storage.placements.count());
+ try testing.expectEqual(
+ tracked + 1,
+ t.screens.active.pages.countTrackedPins(),
+ );
+
+ // Starting a replacement for the same explicit ID removes the old image
+ // and placements before the replacement's final chunk arrives.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,t=d,f=24,i=1,s=1,v=2,m=1;AAAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+ try testing.expect(storage.loading != null);
+ try testing.expect(storage.imageById(1) == null);
+ try testing.expectEqual(@as(usize, 0), storage.placements.count());
+ try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins());
+}
+
test "kittygfx delete then retransmit same id gets fresh generation" {
const testing = std.testing;
+ const io = testing.io;
const alloc = testing.allocator;
- var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
+ var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const storage = &t.screens.active.kitty_images;
@@ -628,7 +1785,7 @@ test "kittygfx delete then retransmit same id gets fresh generation" {
"a=T,t=d,f=24,i=1,s=1,v=2,c=1,r=1;////////",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd).?;
+ const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(resp.ok());
}
const gen1 = storage.imageById(1).?.generation;
@@ -636,7 +1793,7 @@ test "kittygfx delete then retransmit same id gets fresh generation" {
{
const cmd = try command.Parser.parseString(alloc, "a=d,d=A");
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd);
+ const resp = execute(io, alloc, &t, &cmd);
try testing.expect(resp == null);
}
try testing.expect(storage.imageById(1) == null);
@@ -649,10 +1806,1798 @@ test "kittygfx delete then retransmit same id gets fresh generation" {
"a=t,t=d,f=24,i=1,s=1,v=2;////////",
);
defer cmd.deinit(alloc);
- const resp = execute(alloc, &t, &cmd).?;
+ const resp = execute(io, alloc, &t, &cmd).?;
try testing.expect(resp.ok());
}
const gen2 = storage.imageById(1).?.generation;
try testing.expect(gen2 > gen1);
try testing.expect(gen2 > gen_delete);
}
+
+test "kittygfx display clamps cell offsets" {
+ 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);
+ t.width_px = 50; // 10 px per col
+ t.height_px = 100; // 20 px per row
+
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=T,t=d,f=24,i=1,s=1,v=1,c=2,r=1,X=99,Y=99,C=1;AAAA",
+ );
+ defer cmd.deinit(alloc);
+
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+
+ const storage = &t.screens.active.kitty_images;
+ var it = storage.placements.iterator();
+ const placement = it.next().?.value_ptr;
+ try testing.expectEqual(@as(u32, 9), placement.x_offset);
+ try testing.expectEqual(@as(u32, 19), placement.y_offset);
+
+ const actual = placement.pixelSize(storage.imageById(1).?, &t);
+ try testing.expectEqual(@as(u32, 11), actual.width);
+ try testing.expectEqual(@as(u32, 1), actual.height);
+}
+
+test "kittygfx placement bounds cursor movement for untrusted dimensions" {
+ 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 cmd = try command.Parser.parseString(
+ alloc,
+ "a=T,t=d,f=24,i=1,s=1,v=1,c=4294967295,r=4294967295;////",
+ );
+ defer cmd.deinit(alloc);
+
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ try testing.expectEqual(@as(usize, 1), t.screens.active.kitty_images.placements.count());
+ // Reaching the bottom takes four rows, then scrolling is capped to one
+ // screen (five rows), matching Kitty's bounded screen-scroll behavior.
+ try testing.expectEqual(@as(usize, 10), t.screens.active.pages.scrollbar().total);
+}
+
+test "kittygfx placement moves cursor past a tall 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);
+
+ // Load a one-pixel RGB image, then place it over the full width and eight
+ // rows. Eight rows are taller than the screen but still within Kitty's
+ // scroll budget.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,t=d,f=24,i=1,s=1,v=1;////",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ }
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=p,i=1,p=1,c=5,r=8",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ }
+
+ // A subsequent placement begins immediately after the first one instead
+ // of overlapping it at the old one-screen movement cap.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=p,i=1,p=2,C=1",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ }
+
+ const storage = &t.screens.active.kitty_images;
+ const first = storage.placements.get(.{
+ .image_id = 1,
+ .placement_id = .{ .tag = .external, .id = 1 },
+ }).?;
+ const second = storage.placements.get(.{
+ .image_id = 1,
+ .placement_id = .{ .tag = .external, .id = 2 },
+ }).?;
+ const first_pin = switch (first.location) {
+ .pin => |pin| pin,
+ .virtual, .relative => unreachable,
+ };
+ const second_pin = switch (second.location) {
+ .pin => |pin| pin,
+ .virtual, .relative => unreachable,
+ };
+ const first_y = t.screens.active.pages.pointFromPin(
+ .screen,
+ first_pin.*,
+ ).?.screen.y;
+ const second_y = t.screens.active.pages.pointFromPin(
+ .screen,
+ second_pin.*,
+ ).?.screen.y;
+ try testing.expectEqual(first_y + 8, second_y);
+}
+
+test "kittygfx unknown format responds with EINVAL" {
+ 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 cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=42,t=d,i=31,s=1,v=1;AAAA",
+ );
+ defer cmd.deinit(alloc);
+
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(!resp.ok());
+ try testing.expectEqual(@as(u32, 31), resp.id);
+ try testing.expectEqualStrings("EINVAL: unsupported format", resp.message);
+ try testing.expectEqual(
+ @as(usize, 0),
+ t.screens.active.kitty_images.images.count(),
+ );
+}
+
+test "kittygfx unknown format on query responds with EINVAL" {
+ 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 cmd = try command.Parser.parseString(
+ alloc,
+ "a=q,f=42,t=d,i=31,s=1,v=1;AAAA",
+ );
+ defer cmd.deinit(alloc);
+
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(!resp.ok());
+ try testing.expectEqual(@as(u32, 31), resp.id);
+ try testing.expectEqualStrings("EINVAL: unsupported format", resp.message);
+}
+
+test "kittygfx zero format is rgba" {
+ 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 treats f=0 as an absent f, i.e. RGBA.
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=0,t=d,i=1,s=1,v=2,c=10,r=1;///////////",
+ );
+ defer cmd.deinit(alloc);
+
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+
+ const img = t.screens.active.kitty_images.imageById(1).?;
+ try testing.expectEqual(command.Transmission.Format.rgba, img.format);
+}
+
+test "kittygfx unknown format with q=3 has no response" {
+ 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);
+
+ // q above 2 is out of range in the spec but Kitty suppresses
+ // everything for anything above 1, so we must still parse it.
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=42,t=d,i=31,q=3,s=1,v=1;AAAA",
+ );
+ defer cmd.deinit(alloc);
+
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+}
+
+test "kittygfx out of range display keys are tolerated" {
+ 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 cmd = try command.Parser.parseString(
+ alloc,
+ "a=T,f=24,t=d,i=31,s=1,v=1,C=2,U=2;AAAA",
+ );
+ defer cmd.deinit(alloc);
+
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ try testing.expectEqual(@as(u32, 31), resp.id);
+ }
+
+ // U=2 must behave like U=1, so the placement is virtual.
+ const storage = &t.screens.active.kitty_images;
+ var it = storage.placements.iterator();
+ const entry = it.next().?;
+ try testing.expect(entry.value_ptr.location == .virtual);
+}
+
+test "kittygfx number-based transmission assigns smallest free 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;
+
+ // Empty storage: the first free ID is 1, as in Kitty.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=1,I=42;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ try testing.expectEqual(@as(u32, 1), resp.id);
+ try testing.expectEqual(@as(u32, 42), resp.image_number);
+ }
+
+ // Occupy ID 2 explicitly; the next number gets 3.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=1,i=2;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=1,I=43;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ try testing.expectEqual(@as(u32, 3), resp.id);
+ }
+
+ // Deleting ID 1 opens a gap that the next number fills.
+ {
+ const cmd = try command.Parser.parseString(alloc, "a=d,d=I,i=1");
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=1,I=44;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ try testing.expectEqual(@as(u32, 1), resp.id);
+ }
+
+ try testing.expectEqual(@as(usize, 3), storage.images.count());
+ try testing.expectEqual(@as(u32, 44), storage.imageById(1).?.number);
+ try testing.expectEqual(@as(u32, 0), storage.imageById(2).?.number);
+ try testing.expectEqual(@as(u32, 43), storage.imageById(3).?.number);
+}
+
+test "kittygfx number-based id assignment does not replace client 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;
+
+ // A client stores and places a red pixel on the ID the old wrapping
+ // counter would assign first.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=1,i=2147483647;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ {
+ const cmd = try command.Parser.parseString(alloc, "a=p,i=2147483647,C=1");
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // A number-based transmission must not collide with it.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=1,I=42;AAD/",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ try testing.expectEqual(@as(u32, 1), resp.id);
+ }
+
+ // The client's image and placement are untouched.
+ try testing.expectEqual(@as(usize, 2), storage.images.count());
+ try testing.expectEqual(@as(usize, 1), storage.placements.count());
+ try testing.expectEqualSlices(
+ u8,
+ &.{ 255, 0, 0 },
+ storage.imageById(2147483647).?.data.bytes().?,
+ );
+}
+
+test "kittygfx implicit id assignment does not replace client 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;
+
+ // A client stores and places a red pixel on the ID the implicit
+ // counter assigns first.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=1,i=2147483647;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ {
+ const cmd = try command.Parser.parseString(alloc, "a=p,i=2147483647,C=1");
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // Transmit without an ID or number: no response, and the counter
+ // skips over the in-use ID instead of replacing that image.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=1;AAD/",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+
+ try testing.expectEqual(@as(usize, 2), storage.images.count());
+ try testing.expectEqual(@as(usize, 1), storage.placements.count());
+ try testing.expectEqualSlices(
+ u8,
+ &.{ 255, 0, 0 },
+ storage.imageById(2147483647).?.data.bytes().?,
+ );
+ const implicit = storage.imageById(2147483648).?;
+ 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);
+}
+
+// Animation tests frequently start from a 1x1 RGB red image with
+// id=1 ("/wAA" is FF0000). Composition converts images to RGBA, so
+// after the first frame command the base data is FF0000FF.
+
+test "kittygfx animation: new frame with default gap responds with frame number" {
+ 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;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // Transmit a 1x1 RGB blue frame ("AAD/" is 0000FF).
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=24,s=1,v=1;AAD/",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ try testing.expectEqual(@as(u32, 1), resp.id);
+ try testing.expectEqual(@as(u32, 2), resp.frame);
+
+ var buf: [128]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try resp.encode(&writer);
+ try testing.expectEqualStrings("\x1b_Gi=1,r=2;OK\x1b\\", writer.buffered());
+
+ const img = storage.imagePtrByIdOrNumber(1, 0).?;
+ try testing.expectEqual(command.Transmission.Format.rgba, img.format);
+ try testing.expectEqualSlices(u8, &.{ 255, 0, 0, 255 }, img.data.bytes().?);
+
+ const anim = img.animation.?;
+ try testing.expectEqual(@as(u32, 2), anim.frameCount());
+ try testing.expectEqual(@as(u32, 0), anim.root_gap_ms);
+ try testing.expectEqual(@as(u32, 40), anim.frames.items[0].gap_ms);
+ try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, anim.frames.items[0].data);
+
+ // The displayed frame is still the root: renderData is the base.
+ try testing.expectEqualSlices(u8, &.{ 255, 0, 0, 255 }, img.renderData().bytes().?);
+
+ // Frame bytes count against the storage total.
+ try testing.expectEqual(@as(usize, 8), storage.total_bytes);
+}
+
+test "kittygfx animation: frame gap normalization on create" {
+ 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;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ for ([_][]const u8{
+ "a=f,i=1,f=24,s=1,v=1,z=100;AAD/",
+ "a=f,i=1,f=24,s=1,v=1,z=-5;AAD/",
+ }) |input| {
+ const cmd = try command.Parser.parseString(alloc, input);
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?;
+ try testing.expectEqual(@as(u32, 100), anim.frames.items[0].gap_ms);
+ try testing.expectEqual(@as(u32, 0), anim.frames.items[1].gap_ms);
+}
+
+test "kittygfx animation: background fill and offset composition" {
+ 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;
+
+ // 2x1 RGB white image ("////////" is FFFFFF FFFFFF).
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=2,v=1,i=1;////////",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // New frame: 1x1 RGB white transmitted at x=1 over an opaque red
+ // background (Y=4278190335 is 0xff0000ff, R in the MSB).
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=24,s=1,v=1,x=1,Y=4278190335;////",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?;
+ try testing.expectEqualSlices(
+ u8,
+ &.{ 255, 0, 0, 255, 255, 255, 255, 255 },
+ anim.frames.items[0].data,
+ );
+}
+
+test "kittygfx animation: create from base frame with overwrite" {
+ 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;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // Frame 2 based on the root (c=1) with a semi-transparent blue
+ // pixel ("AAD/gA==" is 0000FF80) in overwrite mode: the source
+ // replaces the canvas including alpha.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=32,s=1,v=1,c=1,X=1;AAD/gA==",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ }
+
+ const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?;
+ try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 128 }, anim.frames.items[0].data);
+}
+
+test "kittygfx animation: alpha blend composes over base frame" {
+ 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;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // Blend a fully opaque blue pixel over the root: alpha blending
+ // an opaque source is equivalent to a copy.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=32,s=1,v=1,c=1;AAD//w==",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // Blend a fully transparent pixel over the root: the canvas is
+ // unchanged (still the red base).
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=32,s=1,v=1,c=1;AAD/AA==",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?;
+ try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, anim.frames.items[0].data);
+ try testing.expectEqualSlices(u8, &.{ 255, 0, 0, 255 }, anim.frames.items[1].data);
+}
+
+test "kittygfx animation: edit root frame bumps generation" {
+ 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;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ const gen1 = storage.imagePtrByIdOrNumber(1, 0).?.generation;
+
+ // r=1 edits the root frame in place. The root is the displayed
+ // frame so the image generation must change.
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=24,s=1,v=1,r=1;AAD/",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ try testing.expectEqual(@as(u32, 1), resp.frame);
+
+ const img = storage.imagePtrByIdOrNumber(1, 0).?;
+ try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, img.data.bytes().?);
+ try testing.expect(img.generation > gen1);
+}
+
+test "kittygfx animation: edit frame gap without data change" {
+ 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;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // Create frame 2, then edit it with z=-1: gap becomes gapless.
+ // Editing with z=0 leaves the gap unchanged (no 40ms default).
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=24,s=1,v=1,z=77;AAD/",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=24,s=1,v=1,r=2;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?;
+ try testing.expectEqual(@as(u32, 77), anim.frames.items[0].gap_ms);
+
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=24,s=1,v=1,r=2,z=-1;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ try testing.expectEqual(@as(u32, 0), anim.frames.items[0].gap_ms);
+}
+
+test "kittygfx animation: frame errors" {
+ 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 cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=1,i=1;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ const cases = [_]struct {
+ input: []const u8,
+ message: []const u8,
+ }{
+ // Unknown image.
+ .{
+ .input = "a=f,i=9,f=24,s=1,v=1;AAD/",
+ .message = "ENOENT: image not found",
+ },
+ // Frame bigger than the image.
+ .{
+ .input = "a=f,i=1,f=24,s=2,v=1;////////",
+ .message = "EINVAL: frame dimensions exceed image",
+ },
+ // Nonexistent base frame.
+ .{
+ .input = "a=f,i=1,f=24,s=1,v=1,c=5;AAD/",
+ .message = "EINVAL: base frame not found",
+ },
+ // Insufficient data for the declared rectangle.
+ .{
+ .input = "a=f,i=1,f=24,s=1,v=1;AA==",
+ .message = "ENODATA: insufficient data",
+ },
+ };
+ for (cases) |case| {
+ const cmd = try command.Parser.parseString(alloc, case.input);
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(!resp.ok());
+ try testing.expectEqualStrings(case.message, resp.message);
+ }
+
+ // None of the failures may have created a frame.
+ const storage = &t.screens.active.kitty_images;
+ const img = storage.imagePtrByIdOrNumber(1, 0).?;
+ if (img.animation) |anim| {
+ try testing.expectEqual(@as(usize, 0), anim.frames.items.len);
+ }
+}
+
+test "kittygfx animation: excess frame data is truncated" {
+ 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;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // Six bytes for a 1x1 RGB frame: Kitty tolerates the excess.
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=24,s=1,v=1;AAD/////",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+
+ const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?;
+ try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, anim.frames.items[0].data);
+}
+
+test "kittygfx animation: chunked frame transmission" {
+ 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;
+
+ // 2x1 RGB white image.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=2,v=1,i=1;////////",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // Transmit a 2x1 frame in two chunks of one pixel each. The
+ // chunks repeat a=f per the protocol. No response until the
+ // final chunk.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=24,s=2,v=1,z=60,m=1;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+ try testing.expect(storage.loading != null);
+ {
+ const cmd = try command.Parser.parseString(alloc, "a=f,m=0;AAD/");
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ try testing.expectEqual(@as(u32, 1), resp.id);
+ try testing.expectEqual(@as(u32, 2), resp.frame);
+ }
+ try testing.expect(storage.loading == null);
+
+ const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?;
+ try testing.expectEqual(@as(u32, 60), anim.frames.items[0].gap_ms);
+ try testing.expectEqualSlices(
+ u8,
+ &.{ 255, 0, 0, 255, 0, 0, 255, 255 },
+ anim.frames.items[0].data,
+ );
+}
+
+test "kittygfx animation: chunked frame continuation without a=f" {
+ 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=2,v=1,i=1;////////",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // The spec requires chunks to repeat a=f, but a bare continuation
+ // (which parses as a transmit action) must still continue the
+ // frame load rather than turn into an image transmission.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=24,s=2,v=1,m=1;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+ {
+ const cmd = try command.Parser.parseString(alloc, "m=0;AAD/");
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ try testing.expectEqual(@as(u32, 2), resp.frame);
+ }
+
+ const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?;
+ try testing.expectEqual(@as(usize, 1), anim.frames.items.len);
+}
+
+test "kittygfx animation: control command sets gap current state and loops" {
+ 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;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=24,s=1,v=1;AAD/",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ const img = storage.imagePtrByIdOrNumber(1, 0).?;
+ const gen1 = img.generation;
+
+ // One command can set several things: the root frame's gap
+ // (r=1,z=50), the current frame (c=2), the state (s=3, running),
+ // and the loop count (v=3, meaning two loops). Successful a=a
+ // commands never respond.
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=a,i=1,r=1,z=50,c=2,s=3,v=3",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+
+ const anim = img.animation.?;
+ try testing.expectEqual(@as(u32, 50), anim.root_gap_ms);
+ try testing.expectEqual(@as(u32, 1), anim.current_index);
+ try testing.expectEqual(animation.Animation.State.running, anim.state);
+ try testing.expectEqual(@as(u32, 2), anim.max_loops);
+
+ // Changing the current frame changes the displayed content.
+ try testing.expect(img.generation > gen1);
+ try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, img.renderData().bytes().?);
+}
+
+test "kittygfx animation: control command ignores invalid values" {
+ 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;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // Out-of-range frame numbers and s values are silently ignored.
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=a,i=1,r=9,z=50,c=9,s=7",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+
+ const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?;
+ try testing.expectEqual(@as(u32, 0), anim.root_gap_ms);
+ try testing.expectEqual(@as(u32, 0), anim.current_index);
+ try testing.expectEqual(animation.Animation.State.stopped, anim.state);
+}
+
+test "kittygfx animation: control command missing image responds ENOENT" {
+ 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 cmd = try command.Parser.parseString(alloc, "a=a,i=9,s=3");
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(!resp.ok());
+ try testing.expectEqualStrings("ENOENT: image not found", resp.message);
+}
+
+test "kittygfx animation: compose frames" {
+ 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;
+
+ // 2x1 RGB image: red then blue ("/wAAAAD/" is FF0000 0000FF).
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=2,v=1,i=1;/wAAAAD/",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ // Frame 2 starts as a transparent canvas with a white pixel at
+ // x=0 ("////" is FFFFFF).
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=24,s=1,v=1;////",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // Compose the root's pixel at (1,0) onto frame 2 at (0,0):
+ // r=1 is the source frame, c=2 the destination, X/Y the source
+ // offsets, x/y the destination offsets.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=c,i=1,r=1,c=2,w=1,h=1,X=1,x=0",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ try testing.expectEqual(@as(u32, 0), resp.frame);
+ }
+
+ const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?;
+ try testing.expectEqualSlices(
+ u8,
+ &.{ 0, 0, 255, 255, 0, 0, 0, 0 },
+ anim.frames.items[0].data,
+ );
+}
+
+test "kittygfx animation: compose current frame bumps generation" {
+ 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;
+
+ // 2x1 RGB white image; the root frame is the displayed frame.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=2,v=1,i=1;////////",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ const img = storage.imagePtrByIdOrNumber(1, 0).?;
+ const gen1 = img.generation;
+
+ // a=c works on images without animation state: both frames are
+ // the root. Copy pixel 0 onto pixel 1.
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=c,i=1,r=1,c=1,w=1,h=1,x=1",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ try testing.expect(img.generation > gen1);
+}
+
+test "kittygfx animation: compose errors" {
+ 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 cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=1,i=1;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ const cases = [_]struct {
+ input: []const u8,
+ message: []const u8,
+ }{
+ .{
+ .input = "a=c,i=9,r=1,c=1",
+ .message = "ENOENT: image not found",
+ },
+ .{
+ .input = "a=c,i=1,r=2,c=1",
+ .message = "ENOENT: source frame not found",
+ },
+ .{
+ .input = "a=c,i=1,r=1,c=2",
+ .message = "ENOENT: destination frame not found",
+ },
+ // 1x1 image: any offset pushes the rect out of bounds.
+ .{
+ .input = "a=c,i=1,r=1,c=1,x=1",
+ .message = "EINVAL: destination rectangle out of bounds",
+ },
+ .{
+ .input = "a=c,i=1,r=1,c=1,X=1",
+ .message = "EINVAL: source rectangle out of bounds",
+ },
+ // Same frame, same (whole-image) rect: overlap.
+ .{
+ .input = "a=c,i=1,r=1,c=1",
+ .message = "EINVAL: source and destination rectangles overlap",
+ },
+ };
+ for (cases) |case| {
+ const cmd = try command.Parser.parseString(alloc, case.input);
+ defer cmd.deinit(alloc);
+ const resp = execute(io, alloc, &t, &cmd).?;
+ try testing.expect(!resp.ok());
+ try testing.expectEqualStrings(case.message, resp.message);
+ }
+}
+
+test "kittygfx animation: delete frame promotes root and fixes current" {
+ 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;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ // Frames 2 and 3: blue and white.
+ for ([_][]const u8{
+ "a=f,i=1,f=24,s=1,v=1,z=10;AAD/",
+ "a=f,i=1,f=24,s=1,v=1,z=20;////",
+ }) |input| {
+ const cmd = try command.Parser.parseString(alloc, input);
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ const total_before = storage.total_bytes;
+
+ // Delete the root frame (r omitted selects it): frame 2 becomes
+ // the new root, including its gap. Deletes never respond.
+ {
+ const cmd = try command.Parser.parseString(alloc, "a=d,d=f,i=1");
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+ const img = storage.imagePtrByIdOrNumber(1, 0).?;
+ const anim = img.animation.?;
+ try testing.expectEqual(@as(u32, 2), anim.frameCount());
+ try testing.expectEqual(@as(u32, 10), anim.root_gap_ms);
+ try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, img.data.bytes().?);
+ try testing.expectEqual(total_before - 4, storage.total_bytes);
+
+ // Delete a frame number past the end: clamps to the last frame.
+ {
+ const cmd = try command.Parser.parseString(alloc, "a=d,d=f,i=1,r=9");
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+ try testing.expectEqual(@as(u32, 1), anim.frameCount());
+ try testing.expectEqual(total_before - 8, storage.total_bytes);
+
+ // Down to one frame: d=f is now a no-op and the image survives.
+ {
+ const cmd = try command.Parser.parseString(alloc, "a=d,d=f,i=1");
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+ try testing.expect(storage.imageById(1) != null);
+}
+
+test "kittygfx animation: uppercase frame delete removes non-animated 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;
+
+ // Transmit and place: unlike d=I, an uppercase frame delete on a
+ // non-animated image removes it even though it is placed.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=T,f=24,s=1,v=1,i=1,C=1;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ try testing.expectEqual(@as(usize, 1), storage.placements.count());
+
+ {
+ const cmd = try command.Parser.parseString(alloc, "a=d,d=F,i=1");
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+ try testing.expect(storage.imageById(1) == null);
+ try testing.expectEqual(@as(usize, 0), storage.placements.count());
+ try testing.expectEqual(@as(usize, 0), storage.total_bytes);
+}
+
+test "kittygfx animation: deleting current frame refreshes display" {
+ 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;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ for ([_][]const u8{
+ "a=f,i=1,f=24,s=1,v=1;AAD/",
+ "a=f,i=1,f=24,s=1,v=1;////",
+ "a=a,i=1,c=3",
+ }) |input| {
+ const cmd = try command.Parser.parseString(alloc, input);
+ defer cmd.deinit(alloc);
+ _ = execute(io, alloc, &t, &cmd);
+ }
+
+ const img = storage.imagePtrByIdOrNumber(1, 0).?;
+ const anim = img.animation.?;
+ try testing.expectEqual(@as(u32, 2), anim.current_index);
+ const gen1 = img.generation;
+
+ // Deleting the last frame (the current one) clamps the index and
+ // refreshes the display.
+ {
+ const cmd = try command.Parser.parseString(alloc, "a=d,d=f,i=1,r=3");
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+ }
+ try testing.expectEqual(@as(u32, 1), anim.current_index);
+ try testing.expect(img.generation > gen1);
+ try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, img.renderData().bytes().?);
+}
+
+test "kittygfx animation: retransmitting base image resets animation" {
+ 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;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=24,s=1,v=1;AAD/",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ try testing.expectEqual(@as(usize, 8), storage.total_bytes);
+
+ // Retransmit the base image: the animation is dropped and the
+ // frame bytes are credited back.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,f=24,s=1,v=1,i=1;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ const img = storage.imagePtrByIdOrNumber(1, 0).?;
+ try testing.expect(img.animation == null);
+ try testing.expectEqual(@as(usize, 3), storage.total_bytes);
+}
+
+test "kittygfx animation: control negative gap makes frame gapless" {
+ 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;/wAA",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=f,i=1,f=24,s=1,v=1;AAD/",
+ );
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd).?.ok());
+ }
+
+ const cmd = try command.Parser.parseString(alloc, "a=a,i=1,r=2,z=-1");
+ defer cmd.deinit(alloc);
+ try testing.expect(execute(io, alloc, &t, &cmd) == null);
+
+ const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?;
+ try testing.expectEqual(@as(u32, 0), anim.frames.items[0].gap_ms);
+}