diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 21:51:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 21:51:57 +0300 |
| commit | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch) | |
| tree | e52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff) | |
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig | 914 |
1 files changed, 691 insertions, 223 deletions
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig index bc0ffb5b..e57ad174 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig @@ -1,25 +1,61 @@ const std = @import("std"); const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; const simd = @import("../../simd/main.zig"); const lib = @import("../lib.zig"); const log = std.log.scoped(.kitty_gfx); /// The key-value pairs for the control information for a command. The -/// keys are always single characters and the values are either single -/// characters or 32-bit unsigned integers. +/// recognized keys are always single ASCII letters and the values are either +/// single characters or 32-bit unsigned integers. Unknown non-letter keys are +/// ignored, matching how unknown letter keys are ignored when building a +/// command. /// -/// For the value of this: if the value is a single printable ASCII character -/// it is the ASCII code. Otherwise, it is parsed as a 32-bit unsigned integer. -const KV = std.AutoHashMapUnmanaged(u8, u32); +/// This is deliberately a dense value table plus a presence bitmap. There are +/// only 52 possible keys(technically KIP has fever than 52 possible .. at the moment of writing), so a lookup is one machine-word bit test followed by +/// an indexed load. The values are undefined until their presence bit is set. +/// +/// Other alternatives were considered and should probably be revisited in the +/// future. Some of them were: +/// A `[52]?u32` table is 416 bytes versus 216 bytes for this layout, and +/// its larger copies erased the smaller lookup code. +/// `std.EnumMap` added optional-return lowering, +/// `std.StaticBitSet(52)` kept the same size but generated larger code +/// for the packed mask. Passing the table by pointer removed the copies +/// but did not improve end-to-end throughput. Keep this representation +/// unless a new benchmark shows an improvement in the zig compilers codegen. +/// +/// For the value itself: if it is a single printable ASCII character it is the +/// ASCII code. Otherwise, it is parsed as a 32-bit unsigned integer. +const KV = struct { + values: [52]u32 = undefined, + present: u64 = 0, + + fn index(key: u8) ?u6 { + return switch (key) { + 'a'...'z' => @intCast(key - 'a'), + 'A'...'Z' => @intCast(26 + key - 'A'), + else => null, + }; + } + + fn get(self: *const KV, key: u8) ?u32 { + const idx = index(key) orelse return null; + if (self.present & (@as(u64, 1) << idx) == 0) return null; + return self.values[idx]; + } + + fn put(self: *KV, key: u8, value: u32) void { + const idx = index(key) orelse return; + self.values[idx] = value; + self.present |= @as(u64, 1) << idx; + } +}; /// Command parser parses the Kitty graphics protocol escape sequence. pub const Parser = struct { - /// The memory used by the parser is stored in an arena because it is - /// all freed at the end of the command. - arena: ArenaAllocator, + alloc: Allocator, /// This is the list of KV pairs that we're building up. kv: KV, @@ -60,10 +96,8 @@ pub const Parser = struct { /// Initialize the parser. The allocator given will be used for both /// temporary data and long-lived values such as the final image blob. pub fn init(alloc: Allocator, max_bytes: usize) Parser { - var arena = ArenaAllocator.init(alloc); - errdefer arena.deinit(); var result: Parser = .{ - .arena = arena, + .alloc = alloc, .data = .empty, .kv = .{}, .kv_temp_len = 0, @@ -82,9 +116,7 @@ pub const Parser = struct { } pub fn deinit(self: *Parser) void { - // We don't free the hash map because its in the arena - self.data.deinit(self.arena.child_allocator); - self.arena.deinit(); + self.data.deinit(self.alloc); } /// Parse a complete command string. @@ -144,7 +176,7 @@ pub const Parser = struct { .data => { if (self.data.items.len >= self.max_bytes) return error.OutOfMemory; - try self.data.append(self.arena.child_allocator, c); + try self.data.append(self.alloc, c); }, } } @@ -160,7 +192,7 @@ pub const Parser = struct { if (self.data.items.len + rem.len > self.max_bytes) { return error.OutOfMemory; } - try self.data.appendSlice(self.arena.child_allocator, rem); + try self.data.appendSlice(self.alloc, rem); return; } @@ -205,18 +237,18 @@ pub const Parser = struct { 'p' => .{ .display = try .parse(self.kv) }, 'd' => .{ .delete = try .parse(self.kv) }, 'f' => .{ .transmit_animation_frame = try .parse(self.kv) }, - 'a' => .{ .control_animation = try .parse(self.kv) }, - 'c' => .{ .compose_animation = try .parse(self.kv) }, + 'a' => .{ .control_animation = .parse(self.kv) }, + 'c' => .{ .compose_animation = .parse(self.kv) }, else => return error.InvalidFormat, }; - // Determine our quiet value + // Determine our quiet value. The spec specifies 0 and 1 but Kitty + // allows anything greater than 1 to suppress. const quiet: Command.Quiet = if (self.kv.get('q')) |v| quiet: { break :quiet switch (v) { 0 => .no, 1 => .ok, - 2 => .failures, - else => return error.InvalidFormat, + else => .failures, }; } else .no; @@ -269,8 +301,6 @@ pub const Parser = struct { } fn finishValue(self: *Parser, next_state: State) !void { - const alloc = self.arena.allocator(); - // We can move states right away, we don't use it. self.state = next_state; @@ -278,7 +308,7 @@ pub const Parser = struct { if (self.kv_temp_len == 1) { const c = self.kv_temp[0]; if (c < '0' or c > '9') { - try self.kv.put(alloc, self.kv_current, @intCast(c)); + self.kv.put(self.kv_current, @intCast(c)); self.kv_temp_len = 0; return; } @@ -291,7 +321,7 @@ pub const Parser = struct { 'z', 'H', 'V' => @bitCast(try std.fmt.parseInt(i32, self.kv_temp[0..self.kv_temp_len], 10)), else => try std.fmt.parseInt(u32, self.kv_temp[0..self.kv_temp_len], 10), }; - try self.kv.put(alloc, self.kv_current, v); + self.kv.put(self.kv_current, v); // Clear our temp buffer self.kv_temp_len = 0; @@ -303,6 +333,10 @@ pub const Response = struct { id: u32 = 0, image_number: u32 = 0, placement_id: u32 = 0, + /// The 1-based animation frame number, echoed as "r=" for the + /// animation frame actions. This is how clients learn the frame + /// number assigned to a newly created frame. + frame: u32 = 0, message: []const u8 = "OK", pub fn encode(self: Response, writer: *std.Io.Writer) !void { @@ -326,6 +360,10 @@ pub const Response = struct { if (prior) try writer.writeByte(',') else prior = true; try writer.print("p={}", .{self.placement_id}); } + if (self.frame > 0) { + if (prior) try writer.writeByte(',') else prior = true; + try writer.print("r={}", .{self.frame}); + } try writer.writeByte(';'); try writer.writeAll(self.message); try writer.writeAll("\x1b\\"); @@ -376,6 +414,53 @@ pub const Command = struct { transmit_animation_frame: AnimationFrameLoading, control_animation: AnimationControl, compose_animation: AnimationFrameComposition, + + pub const Identifiers = struct { + image_id: u32 = 0, + image_number: u32 = 0, + placement_id: u32 = 0, + }; + + /// Returns the image and placement identifiers for any action. + pub fn identifiers(self: Control) Identifiers { + return switch (self) { + .query, .transmit => |t| .{ + .image_id = t.image_id, + .image_number = t.image_number, + .placement_id = t.placement_id, + }, + .transmit_and_display => |t| .{ + .image_id = t.transmission.image_id, + .image_number = t.transmission.image_number, + .placement_id = t.transmission.placement_id, + }, + .display => |d| .{ + .image_id = d.image_id, + .image_number = d.image_number, + .placement_id = d.placement_id, + }, + .delete => |d| .{ + .image_id = d.image_id, + .image_number = d.image_number, + .placement_id = d.placement_id, + }, + .transmit_animation_frame => |f| .{ + .image_id = f.transmission.image_id, + .image_number = f.transmission.image_number, + .placement_id = f.transmission.placement_id, + }, + .control_animation => |a| .{ + .image_id = a.image_id, + .image_number = a.image_number, + .placement_id = a.placement_id, + }, + .compose_animation => |c| .{ + .image_id = c.image_id, + .image_number = c.image_number, + .placement_id = c.placement_id, + }, + }; + } }; /// Take ownership over the data in this command. If the returned value @@ -392,6 +477,7 @@ pub const Command = struct { .query => |t| t, .transmit => |t| t, .transmit_and_display => |t| t.transmission, + .transmit_animation_frame => |f| f.transmission, else => null, }; } @@ -412,6 +498,11 @@ pub const Command = struct { pub const Transmission = struct { format: Format = .rgba, // f + /// Set when "f" carried a value we don't recognize. Kitty rejects these + /// while handling the command so the EINVAL response can carry the image + /// id, so we defer to `LoadingImage.init`. `format` is meaningless when + /// this is set. + format_unknown: bool = false, medium: Medium = .direct, // t width: u32 = 0, // s height: u32 = 0, // v @@ -422,6 +513,7 @@ pub const Transmission = struct { placement_id: u32 = 0, // p compression: Compression = .none, // o more_chunks: bool = false, // m + usage: Usage = .default, // N pub const Format = lib.Enum(lib.target, &.{ "rgb", // 24 @@ -446,6 +538,19 @@ pub const Transmission = struct { "zlib_deflate", // z }); + /// Usage hints allow for optimising resource consumption strategies. + /// + /// https://sw.kovidgoyal.net/kitty/graphics-protocol/#usage-hints + pub const Usage = packed struct(u32) { + /// Image with this usage hint is assumed to be used for only a short + /// time, so may be evicted before other images if memory pressure is + /// encountered. + transient: bool = false, + _padding: u31 = 0, + + pub const default: Usage = .{}; + }; + pub fn formatBpp(format: Format) u8 { return switch (format) { .gray => 1, @@ -461,9 +566,13 @@ pub const Transmission = struct { if (kv.get('f')) |v| { result.format = switch (v) { 24 => .rgb, - 32 => .rgba, + 0, 32 => .rgba, 100 => .png, - else => return error.InvalidFormat, + else => unknown: { + // Defer returning an error until the image is processed. + result.format_unknown = true; + break :unknown .rgba; + }, }; } @@ -529,6 +638,10 @@ pub const Transmission = struct { } } + if (kv.get('N')) |v| { + result.usage = @bitCast(v); + } + return result; } }; @@ -606,19 +719,13 @@ pub const Display = struct { } if (kv.get('C')) |v| { - result.cursor_movement = switch (v) { - 0 => .after, - 1 => .none, - else => return error.InvalidFormat, - }; + // Kitty only tests this against 1 so any other value moves the cursor. + result.cursor_movement = if (v == 1) .none else .after; } if (kv.get('U')) |v| { - result.virtual_placement = switch (v) { - 0 => false, - 1 => true, - else => return error.InvalidFormat, - }; + // Kitty stores this in a bool, so any non-zero value is virtual. + result.virtual_placement = v != 0; } if (kv.get('z')) |v| { @@ -649,23 +756,51 @@ pub const Display = struct { }; pub const AnimationFrameLoading = struct { + /// Frame data is transmitted exactly like image data, so an a=f + /// command carries a full set of transmission keys: medium (t), + /// format (f), the frame rectangle size (s/v), chunking (m), etc. + /// The image id/number here identify the (existing) image the + /// frame belongs to. + transmission: Transmission = .{}, + + /// The left/top edge (pixels) of the rectangle within the frame + /// that the transmitted data updates. x: u32 = 0, // x y: u32 = 0, // y + + /// The 1-based frame number whose pixels serve as the base canvas + /// for a newly created frame. Zero means no base frame: the + /// canvas is filled with `background` instead. create_frame: u32 = 0, // c + + /// The 1-based frame number of an existing frame to edit. Zero + /// (or any value past the next frame number) creates a new frame. edit_frame: u32 = 0, // r - gap_ms: u32 = 0, // z + + /// The gap in milliseconds before the next frame is shown. For + /// new frames, zero selects the default gap and a negative value + /// creates a gapless (never displayed) frame. For edits, zero + /// leaves the gap unchanged. + gap_ms: i32 = 0, // z + composition_mode: CompositionMode = .alpha_blend, // X background: Background = .{}, // Y + /// The canvas background color as a 32-bit RGBA value where R is + /// the most significant byte (0xRRGGBBAA), e.g. Y=4278190335 + /// (0xff0000ff) is opaque red. Bit-cast from the little-endian + /// u32 value, so the field order is reversed from the byte order. pub const Background = packed struct(u32) { - r: u8 = 0, - g: u8 = 0, - b: u8 = 0, a: u8 = 0, + b: u8 = 0, + g: u8 = 0, + r: u8 = 0, }; - fn parse(kv: KV) !AnimationFrameLoading { - var result: AnimationFrameLoading = .{}; + fn parse(kv: KV) error{InvalidFormat}!AnimationFrameLoading { + var result: AnimationFrameLoading = .{ + .transmission = try .parse(kv), + }; if (kv.get('x')) |v| { result.x = v; @@ -684,15 +819,15 @@ pub const AnimationFrameLoading = struct { } if (kv.get('z')) |v| { - result.gap_ms = v; + // 'z' is one of the keys Parser.finishValue parses as an + // i32, so the u32 in the KV table is the bit pattern of + // that i32 and this reconstructs it exactly. + result.gap_ms = @bitCast(v); } + // Tested only against 1 like Kitty. if (kv.get('X')) |v| { - result.composition_mode = switch (v) { - 0 => .alpha_blend, - 1 => .overwrite, - else => return error.InvalidFormat, - }; + result.composition_mode = if (v == 1) .overwrite else .alpha_blend; } if (kv.get('Y')) |v| { @@ -704,25 +839,56 @@ pub const AnimationFrameLoading = struct { }; pub const AnimationFrameComposition = struct { - frame: u32 = 0, // c - edit_frame: u32 = 0, // r + image_id: u32 = 0, // i + image_number: u32 = 0, // I + placement_id: u32 = 0, // p + + /// The 1-based frame number being edited (the destination). + dest_frame: u32 = 0, // c + + /// The 1-based frame number whose pixels are composed onto the + /// destination (the source). + source_frame: u32 = 0, // r + + /// The left/top edge (pixels) of the destination rectangle. x: u32 = 0, // x y: u32 = 0, // y + + /// The size of the rectangle to compose. Zero selects the full + /// image width/height. width: u32 = 0, // w height: u32 = 0, // h + + /// The left/top edge (pixels) of the source rectangle. left_edge: u32 = 0, // X top_edge: u32 = 0, // Y + composition_mode: CompositionMode = .alpha_blend, // C - fn parse(kv: KV) !AnimationFrameComposition { + // Cannot fail: every key is stored as parsed, with no range + // validation (Kitty range-checks these while handling the + // command; see the comment above the execution-deferral tests). + fn parse(kv: KV) AnimationFrameComposition { var result: AnimationFrameComposition = .{}; + if (kv.get('i')) |v| { + result.image_id = v; + } + + if (kv.get('I')) |v| { + result.image_number = v; + } + + if (kv.get('p')) |v| { + result.placement_id = v; + } + if (kv.get('c')) |v| { - result.frame = v; + result.dest_frame = v; } if (kv.get('r')) |v| { - result.edit_frame = v; + result.source_frame = v; } if (kv.get('x')) |v| { @@ -750,11 +916,8 @@ pub const AnimationFrameComposition = struct { } if (kv.get('C')) |v| { - result.composition_mode = switch (v) { - 0 => .alpha_blend, - 1 => .overwrite, - else => return error.InvalidFormat, - }; + // Kitty tests this against zero for compose so any non-zero overwrites + result.composition_mode = if (v == 0) .alpha_blend else .overwrite; } return result; @@ -762,10 +925,24 @@ pub const AnimationFrameComposition = struct { }; pub const AnimationControl = struct { + image_id: u32 = 0, // i + image_number: u32 = 0, // I + placement_id: u32 = 0, // p action: AnimationAction = .invalid, // s + + /// The 1-based frame number whose gap is set from gap_ms. frame: u32 = 0, // r - gap_ms: u32 = 0, // z + + /// The new gap for `frame` in milliseconds. Zero is ignored and a + /// negative value makes the frame gapless. + gap_ms: i32 = 0, // z + + /// The 1-based frame number to make current (client-driven + /// animation). Zero is ignored. current_frame: u32 = 0, // c + + /// The loop count: zero is ignored, 1 loops forever, and any + /// larger value plays that many minus one loops. loops: u32 = 0, // v pub const AnimationAction = enum { @@ -775,16 +952,32 @@ pub const AnimationControl = struct { run, // 3 }; - fn parse(kv: KV) !AnimationControl { + // Cannot fail: every key is stored as parsed. Out-of-range values + // (frame numbers, states) are silently ignored during execution, + // matching Kitty. + fn parse(kv: KV) AnimationControl { var result: AnimationControl = .{}; + if (kv.get('i')) |v| { + result.image_id = v; + } + + if (kv.get('I')) |v| { + result.image_number = v; + } + + if (kv.get('p')) |v| { + result.placement_id = v; + } + if (kv.get('s')) |v| { + // Kitty ignores values it doesn't know, leaving the animation + // state untouched, which is what `.invalid` means here. result.action = switch (v) { - 0 => .invalid, 1 => .stop, 2 => .run_wait, 3 => .run, - else => return error.InvalidFormat, + else => .invalid, }; } @@ -793,7 +986,10 @@ pub const AnimationControl = struct { } if (kv.get('z')) |v| { - result.gap_ms = v; + // 'z' is one of the keys Parser.finishValue parses as an + // i32, so the u32 in the KV table is the bit pattern of + // that i32 and this reconstructs it exactly. + result.gap_ms = @bitCast(v); } if (kv.get('c')) |v| { @@ -808,180 +1004,218 @@ pub const AnimationControl = struct { } }; -pub const Delete = union(enum) { - // a/A - all: bool, - - // i/I - id: struct { - delete: bool = false, // uppercase - image_id: u32 = 0, // i - placement_id: u32 = 0, // p - }, - - // n/N - newest: struct { - delete: bool = false, // uppercase - image_number: u32 = 0, // I - placement_id: u32 = 0, // p - }, - - // c/C, - intersect_cursor: bool, - - // f/F - animation_frames: bool, - - // p/P - intersect_cell: struct { - delete: bool = false, // uppercase - x: u32 = 0, // x - y: u32 = 0, // y - }, - - // q/Q - intersect_cell_z: struct { - delete: bool = false, // uppercase - x: u32 = 0, // x - y: u32 = 0, // y - z: i32 = 0, // z - }, - - // r/R - range: struct { - delete: bool = false, // uppercase - first: u32 = 0, // x - last: u32 = 0, // y - }, - - // x/X - column: struct { - delete: bool = false, // uppercase - x: u32 = 0, // x - }, - - // y/Y - row: struct { - delete: bool = false, // uppercase - y: u32 = 0, // y - }, - - // z/Z - z: struct { - delete: bool = false, // uppercase - z: i32 = 0, // z - }, +pub const Delete = struct { + image_id: u32 = 0, // i + image_number: u32 = 0, // I + placement_id: u32 = 0, // p + action: Action, fn parse(kv: KV) !Delete { - const what: u8 = what: { - const value = kv.get('d') orelse break :what 'a'; - const c = std.math.cast(u8, value) orelse return error.InvalidFormat; - break :what c; + return .{ + .image_id = kv.get('i') orelse 0, + .image_number = kv.get('I') orelse 0, + .placement_id = kv.get('p') orelse 0, + .action = try .parse(kv), }; + } - return switch (what) { - 'a', 'A' => .{ .all = what == 'A' }, + pub const Action = union(enum) { + // a/A + all: bool, - 'i', 'I' => blk: { - var result: Delete = .{ .id = .{ .delete = what == 'I' } }; - if (kv.get('i')) |v| { - result.id.image_id = v; - } - if (kv.get('p')) |v| { - result.id.placement_id = v; - } + // i/I + id: struct { + delete: bool = false, // uppercase + image_id: u32 = 0, // i + placement_id: u32 = 0, // p + }, - break :blk result; - }, + // n/N + newest: struct { + delete: bool = false, // uppercase + image_number: u32 = 0, // I + placement_id: u32 = 0, // p + }, - 'n', 'N' => blk: { - var result: Delete = .{ .newest = .{ .delete = what == 'N' } }; - if (kv.get('I')) |v| { - result.newest.image_number = v; - } - if (kv.get('p')) |v| { - result.newest.placement_id = v; - } + // c/C, + intersect_cursor: bool, - break :blk result; - }, + // f/F + animation_frames: AnimationFrames, - 'c', 'C' => .{ .intersect_cursor = what == 'C' }, + // p/P + intersect_cell: struct { + delete: bool = false, // uppercase + x: u32 = 0, // x + y: u32 = 0, // y + }, - 'f', 'F' => .{ .animation_frames = what == 'F' }, + // q/Q + intersect_cell_z: struct { + delete: bool = false, // uppercase + x: u32 = 0, // x + y: u32 = 0, // y + z: i32 = 0, // z + }, - 'p', 'P' => blk: { - var result: Delete = .{ .intersect_cell = .{ .delete = what == 'P' } }; - if (kv.get('x')) |v| { - result.intersect_cell.x = v; - } - if (kv.get('y')) |v| { - result.intersect_cell.y = v; - } + // r/R + range: struct { + delete: bool = false, // uppercase + first: u32 = 0, // x + last: u32 = 0, // y + }, - break :blk result; - }, + // x/X + column: struct { + delete: bool = false, // uppercase + x: u32 = 0, // x + }, - 'q', 'Q' => blk: { - var result: Delete = .{ .intersect_cell_z = .{ .delete = what == 'Q' } }; - if (kv.get('x')) |v| { - result.intersect_cell_z.x = v; - } - if (kv.get('y')) |v| { - result.intersect_cell_z.y = v; - } - if (kv.get('z')) |v| { - // We can bitcast here because of how we parse it earlier. - result.intersect_cell_z.z = @bitCast(v); - } + // y/Y + row: struct { + delete: bool = false, // uppercase + y: u32 = 0, // y + }, - break :blk result; - }, + // z/Z + z: struct { + delete: bool = false, // uppercase + z: i32 = 0, // z + }, - 'r', 'R' => blk: { - const x = kv.get('x') orelse return error.InvalidFormat; - const y = kv.get('y') orelse return error.InvalidFormat; - if (x > y) return error.InvalidFormat; - break :blk .{ - .range = .{ - .delete = what == 'R', - .first = x, - .last = y, - }, - }; - }, + pub const AnimationFrames = struct { + delete: bool = false, // uppercase + image_id: u32 = 0, // i + image_number: u32 = 0, // I + frame: u32 = 0, // r + }; - 'x', 'X' => blk: { - var result: Delete = .{ .column = .{ .delete = what == 'X' } }; - if (kv.get('x')) |v| { - result.column.x = v; - } + fn parse(kv: KV) error{InvalidFormat}!Action { + const what: u8 = what: { + const value = kv.get('d') orelse break :what 'a'; + const c = std.math.cast(u8, value) orelse return error.InvalidFormat; + break :what c; + }; - break :blk result; - }, + return switch (what) { + 'a', 'A' => .{ .all = what == 'A' }, - 'y', 'Y' => blk: { - var result: Delete = .{ .row = .{ .delete = what == 'Y' } }; - if (kv.get('y')) |v| { - result.row.y = v; - } + 'i', 'I' => blk: { + var result: Action = .{ .id = .{ .delete = what == 'I' } }; + if (kv.get('i')) |v| { + result.id.image_id = v; + } + if (kv.get('p')) |v| { + result.id.placement_id = v; + } - break :blk result; - }, + break :blk result; + }, - 'z', 'Z' => blk: { - var result: Delete = .{ .z = .{ .delete = what == 'Z' } }; - if (kv.get('z')) |v| { - // We can bitcast here because of how we parse it earlier. - result.z.z = @bitCast(v); - } + 'n', 'N' => blk: { + var result: Action = .{ .newest = .{ .delete = what == 'N' } }; + if (kv.get('I')) |v| { + result.newest.image_number = v; + } + if (kv.get('p')) |v| { + result.newest.placement_id = v; + } - break :blk result; - }, + break :blk result; + }, - else => return error.InvalidFormat, - }; - } + 'c', 'C' => .{ .intersect_cursor = what == 'C' }, + + 'f', 'F' => blk: { + var result: Action = .{ .animation_frames = .{ .delete = what == 'F' } }; + if (kv.get('i')) |v| { + result.animation_frames.image_id = v; + } + if (kv.get('I')) |v| { + result.animation_frames.image_number = v; + } + if (kv.get('r')) |v| { + result.animation_frames.frame = v; + } + + break :blk result; + }, + + 'p', 'P' => blk: { + var result: Action = .{ .intersect_cell = .{ .delete = what == 'P' } }; + if (kv.get('x')) |v| { + result.intersect_cell.x = v; + } + if (kv.get('y')) |v| { + result.intersect_cell.y = v; + } + + break :blk result; + }, + + 'q', 'Q' => blk: { + var result: Action = .{ .intersect_cell_z = .{ .delete = what == 'Q' } }; + if (kv.get('x')) |v| { + result.intersect_cell_z.x = v; + } + if (kv.get('y')) |v| { + result.intersect_cell_z.y = v; + } + if (kv.get('z')) |v| { + // We can bitcast here because of how we parse it earlier. + result.intersect_cell_z.z = @bitCast(v); + } + + break :blk result; + }, + + 'r', 'R' => blk: { + // Both bounds default to zero when omitted and no + // validation is performed; an inverted or zero range + // simply matches no images. + var result: Action = .{ .range = .{ .delete = what == 'R' } }; + if (kv.get('x')) |v| { + result.range.first = v; + } + if (kv.get('y')) |v| { + result.range.last = v; + } + + break :blk result; + }, + + 'x', 'X' => blk: { + var result: Action = .{ .column = .{ .delete = what == 'X' } }; + if (kv.get('x')) |v| { + result.column.x = v; + } + + break :blk result; + }, + + 'y', 'Y' => blk: { + var result: Action = .{ .row = .{ .delete = what == 'Y' } }; + if (kv.get('y')) |v| { + result.row.y = v; + } + + break :blk result; + }, + + 'z', 'Z' => blk: { + var result: Action = .{ .z = .{ .delete = what == 'Z' } }; + if (kv.get('z')) |v| { + // We can bitcast here because of how we parse it earlier. + result.z.z = @bitCast(v); + } + + break :blk result; + }, + + else => return error.InvalidFormat, + }; + } + }; }; pub const CompositionMode = enum { @@ -1005,6 +1239,26 @@ test "transmission command" { try testing.expectEqual(Transmission.Format.rgb, v.format); try testing.expectEqual(@as(u32, 10), v.width); try testing.expectEqual(@as(u32, 20), v.height); + try testing.expectEqual(false, v.usage.transient); +} + +test "transmission command with transient hint" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc, 1024 * 1024); + defer p.deinit(); + + const input = "f=24,s=10,v=20,N=1"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + const v = command.control.transmit; + try testing.expectEqual(Transmission.Format.rgb, v.format); + try testing.expectEqual(@as(u32, 10), v.width); + try testing.expectEqual(@as(u32, 20), v.height); + try testing.expectEqual(true, v.usage.transient); } test "feedSlice matches per-byte feed" { @@ -1142,7 +1396,7 @@ test "delete command" { defer command.deinit(alloc); try testing.expect(command.control == .delete); - const v = command.control.delete; + const v = command.control.delete.action; try testing.expect(v == .intersect_cell); const dv = v.intersect_cell; try testing.expect(!dv.delete); @@ -1183,6 +1437,24 @@ test "ignore unknown keys (long)" { try testing.expectEqual(@as(u32, 20), v.height); } +test "ignore unknown keys (non-letter)" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc, 1024 * 1024); + defer p.deinit(); + + const input = "f=24,s=10,v=20,!=1"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + const v = command.control.transmit; + try testing.expectEqual(Transmission.Format.rgb, v.format); + try testing.expectEqual(@as(u32, 10), v.width); + try testing.expectEqual(@as(u32, 20), v.height); +} + test "ignore very long values" { const testing = std.testing; const alloc = testing.allocator; @@ -1342,7 +1614,7 @@ test "delete range command 1" { defer command.deinit(alloc); try testing.expect(command.control == .delete); - const v = command.control.delete; + const v = command.control.delete.action; try testing.expect(v == .range); const range = v.range; try testing.expect(!range.delete); @@ -1362,7 +1634,7 @@ test "delete range command 2" { defer command.deinit(alloc); try testing.expect(command.control == .delete); - const v = command.control.delete; + const v = command.control.delete.action; try testing.expect(v == .range); const range = v.range; try testing.expect(range.delete); @@ -1371,6 +1643,9 @@ test "delete range command 2" { } test "delete range command 3" { + // An inverted range is accepted; it simply matches no images. This + // matches kitty, which does no validation and filters with + // `x <= image_id <= y`. const testing = std.testing; const alloc = testing.allocator; var p = Parser.init(alloc, 1024 * 1024); @@ -1378,10 +1653,21 @@ test "delete range command 3" { const input = "a=d,d=R,x=5,y=4"; for (input) |c| try p.feed(c); - try testing.expectError(error.InvalidFormat, p.complete(alloc)); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .delete); + const v = command.control.delete.action; + try testing.expect(v == .range); + const range = v.range; + try testing.expect(range.delete); + try testing.expectEqual(@as(u32, 5), range.first); + try testing.expectEqual(@as(u32, 4), range.last); } test "delete range command 4" { + // An omitted upper bound defaults to zero, which matches no images + // since image IDs are always non-zero. const testing = std.testing; const alloc = testing.allocator; var p = Parser.init(alloc, 1024 * 1024); @@ -1389,7 +1675,16 @@ test "delete range command 4" { const input = "a=d,d=R,x=5"; for (input) |c| try p.feed(c); - try testing.expectError(error.InvalidFormat, p.complete(alloc)); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .delete); + const v = command.control.delete.action; + try testing.expect(v == .range); + const range = v.range; + try testing.expect(range.delete); + try testing.expectEqual(@as(u32, 5), range.first); + try testing.expectEqual(@as(u32, 0), range.last); } test "delete range command 5" { @@ -1400,5 +1695,178 @@ test "delete range command 5" { const input = "a=d,d=R,y=5"; for (input) |c| try p.feed(c); - try testing.expectError(error.InvalidFormat, p.complete(alloc)); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .delete); + const v = command.control.delete.action; + try testing.expect(v == .range); + const range = v.range; + try testing.expect(range.delete); + try testing.expectEqual(@as(u32, 0), range.first); + try testing.expectEqual(@as(u32, 5), range.last); +} + +test "delete range command 6" { + // Both bounds omitted defaults to the empty range [0, 0]. + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc, 1024 * 1024); + defer p.deinit(); + + const input = "a=d,d=r"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .delete); + const v = command.control.delete.action; + try testing.expect(v == .range); + const range = v.range; + try testing.expect(!range.delete); + try testing.expectEqual(@as(u32, 0), range.first); + try testing.expectEqual(@as(u32, 0), range.last); +} + +// Kitty range-checks non-flag keys while handling the command, not while +// parsing, so a bad value never drops the command (which would make a +// response impossible even when the client gave an id). The flag keys +// "a", "d", "t" and "o" are the exception. + +test "unknown format value is deferred to execution" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=t,f=42,i=31,s=1,v=1"); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + try testing.expect(command.control.transmit.format_unknown); + try testing.expectEqual(@as(u32, 31), command.control.transmit.image_id); +} + +test "zero format value is rgba" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=t,f=0,i=31,s=1,v=1"); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + const v = command.control.transmit; + try testing.expect(!v.format_unknown); + try testing.expectEqual(Transmission.Format.rgba, v.format); +} + +test "known format values are not unknown" { + const testing = std.testing; + const alloc = testing.allocator; + + for ([_]struct { []const u8, Transmission.Format }{ + .{ "f=24", .rgb }, + .{ "f=32", .rgba }, + .{ "f=100", .png }, + }) |entry| { + const command = try Parser.parseString(alloc, entry[0]); + defer command.deinit(alloc); + + const v = command.control.transmit; + try testing.expect(!v.format_unknown); + try testing.expectEqual(entry[1], v.format); + } +} + +test "quiet value above two suppresses all responses" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=t,q=3,i=31,s=1,v=1"); + defer command.deinit(alloc); + + try testing.expectEqual(Command.Quiet.failures, command.quiet); +} + +test "cursor movement value above one moves the cursor" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=p,i=31,C=2"); + defer command.deinit(alloc); + + try testing.expect(command.control == .display); + try testing.expectEqual( + Display.CursorMovement.after, + command.control.display.cursor_movement, + ); +} + +test "virtual placement value above one is virtual" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=p,i=31,U=2"); + defer command.deinit(alloc); + + try testing.expect(command.control == .display); + try testing.expect(command.control.display.virtual_placement); +} + +test "animation frame composition value above one alpha blends" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=f,i=31,X=2"); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit_animation_frame); + try testing.expectEqual( + CompositionMode.alpha_blend, + command.control.transmit_animation_frame.composition_mode, + ); +} + +test "animation compose value above one overwrites" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=c,i=31,C=2"); + defer command.deinit(alloc); + + try testing.expect(command.control == .compose_animation); + try testing.expectEqual( + CompositionMode.overwrite, + command.control.compose_animation.composition_mode, + ); +} + +test "animation control action above three is ignored" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=a,i=31,s=4"); + defer command.deinit(alloc); + + try testing.expect(command.control == .control_animation); + try testing.expectEqual( + AnimationControl.AnimationAction.invalid, + command.control.control_animation.action, + ); +} + +test "unknown flag key values still fail parsing" { + const testing = std.testing; + const alloc = testing.allocator; + + // Kitty rejects these while parsing too. See gen/apc_parsers.py. + for ([_][]const u8{ + "a=z,i=31", // action + "a=d,d=w,i=31", // delete action + "a=t,t=q,i=31", // transmission medium + "a=t,o=q,i=31", // compression + }) |input| { + try testing.expectError( + error.InvalidFormat, + Parser.parseString(alloc, input), + ); + } } |
