From 373462ba433bb5b9f15f7bf119def6c02418d186 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 14:15:01 -0800 Subject: [PATCH] terminal2: starting to port kitty graphics --- src/terminal2/PageList.zig | 4 + src/terminal2/kitty.zig | 3 + src/terminal2/kitty/graphics.zig | 22 + src/terminal2/kitty/graphics_command.zig | 984 ++++++++++++++++++ src/terminal2/kitty/graphics_exec.zig | 344 ++++++ src/terminal2/kitty/graphics_image.zig | 776 ++++++++++++++ src/terminal2/kitty/graphics_storage.zig | 919 ++++++++++++++++ src/terminal2/kitty/key.zig | 151 +++ .../image-png-none-50x76-2147483647-raw.data | Bin 0 -> 86 bytes .../image-rgb-none-20x15-2147483647.data | 1 + ...ge-rgb-zlib_deflate-128x96-2147483647.data | 1 + src/terminal2/main.zig | 1 + 12 files changed, 3206 insertions(+) create mode 100644 src/terminal2/kitty/graphics.zig create mode 100644 src/terminal2/kitty/graphics_command.zig create mode 100644 src/terminal2/kitty/graphics_exec.zig create mode 100644 src/terminal2/kitty/graphics_image.zig create mode 100644 src/terminal2/kitty/graphics_storage.zig create mode 100644 src/terminal2/kitty/key.zig create mode 100644 src/terminal2/kitty/testdata/image-png-none-50x76-2147483647-raw.data create mode 100644 src/terminal2/kitty/testdata/image-rgb-none-20x15-2147483647.data create mode 100644 src/terminal2/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647.data diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index 4b78b1514..02bcd82da 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -1405,6 +1405,10 @@ pub fn untrackPin(self: *PageList, p: *Pin) void { } } +pub fn countTrackedPins(self: *const PageList) usize { + return self.tracked_pins.count(); +} + /// Returns the viewport for the given pin, prefering to pin to /// "active" if the pin is within the active area. fn pinIsActive(self: *const PageList, p: Pin) bool { diff --git a/src/terminal2/kitty.zig b/src/terminal2/kitty.zig index 6b86a3280..e2341a3dc 100644 --- a/src/terminal2/kitty.zig +++ b/src/terminal2/kitty.zig @@ -6,4 +6,7 @@ pub usingnamespace @import("../terminal/kitty/key.zig"); test { @import("std").testing.refAllDecls(@This()); + + _ = @import("kitty/graphics.zig"); + _ = @import("kitty/key.zig"); } diff --git a/src/terminal2/kitty/graphics.zig b/src/terminal2/kitty/graphics.zig new file mode 100644 index 000000000..cfc45adbc --- /dev/null +++ b/src/terminal2/kitty/graphics.zig @@ -0,0 +1,22 @@ +//! Kitty graphics protocol support. +//! +//! Documentation: +//! https://sw.kovidgoyal.net/kitty/graphics-protocol +//! +//! Unimplemented features that are still todo: +//! - shared memory transmit +//! - virtual placement w/ unicode +//! - animation +//! +//! Performance: +//! The performance of this particular subsystem of Ghostty is not great. +//! We can avoid a lot more allocations, we can replace some C code (which +//! implicitly allocates) with native Zig, we can improve the data structures +//! to avoid repeated lookups, etc. I tried to avoid pessimization but my +//! aim to ship a v1 of this implementation came at some cost. I learned a lot +//! though and I think we can go back through and fix this up. + +pub usingnamespace @import("graphics_command.zig"); +pub usingnamespace @import("graphics_exec.zig"); +pub usingnamespace @import("graphics_image.zig"); +pub usingnamespace @import("graphics_storage.zig"); diff --git a/src/terminal2/kitty/graphics_command.zig b/src/terminal2/kitty/graphics_command.zig new file mode 100644 index 000000000..ca7a4d674 --- /dev/null +++ b/src/terminal2/kitty/graphics_command.zig @@ -0,0 +1,984 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +/// 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. +/// +/// 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); + +/// Command parser parses the Kitty graphics protocol escape sequence. +pub const CommandParser = 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, + + /// This is the list of KV pairs that we're building up. + kv: KV = .{}, + + /// This is used as a buffer to store the key/value of a KV pair. + /// The value of a KV pair is at most a 32-bit integer which at most + /// is 10 characters (4294967295). + kv_temp: [10]u8 = undefined, + kv_temp_len: u4 = 0, + kv_current: u8 = 0, // Current kv key + + /// This is the list of bytes that contains both KV data and final + /// data. You shouldn't access this directly. + data: std.ArrayList(u8), + + /// Internal state for parsing. + state: State = .control_key, + + const State = enum { + /// Parsing k/v pairs. The "ignore" variants are in that state + /// but ignore any data because we know they're invalid. + control_key, + control_key_ignore, + control_value, + control_value_ignore, + + /// We're parsing the data blob. + data, + }; + + /// 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) CommandParser { + var arena = ArenaAllocator.init(alloc); + errdefer arena.deinit(); + return .{ + .arena = arena, + .data = std.ArrayList(u8).init(alloc), + }; + } + + pub fn deinit(self: *CommandParser) void { + // We don't free the hash map because its in the arena + self.arena.deinit(); + self.data.deinit(); + } + + /// Feed a single byte to the parser. + /// + /// The first byte to start parsing should be the byte immediately following + /// the "G" in the APC sequence, i.e. "\x1b_G123" the first byte should + /// be "1". + pub fn feed(self: *CommandParser, c: u8) !void { + switch (self.state) { + .control_key => switch (c) { + // '=' means the key is complete and we're moving to the value. + '=' => if (self.kv_temp_len != 1) { + // All control keys are a single character right now so + // if we're not a single character just ignore follow-up + // data. + self.state = .control_value_ignore; + self.kv_temp_len = 0; + } else { + self.kv_current = self.kv_temp[0]; + self.kv_temp_len = 0; + self.state = .control_value; + }, + + else => try self.accumulateValue(c, .control_key_ignore), + }, + + .control_key_ignore => switch (c) { + '=' => self.state = .control_value_ignore, + else => {}, + }, + + .control_value => switch (c) { + ',' => try self.finishValue(.control_key), // move to next key + ';' => try self.finishValue(.data), // move to data + else => try self.accumulateValue(c, .control_value_ignore), + }, + + .control_value_ignore => switch (c) { + ',' => self.state = .control_key_ignore, + ';' => self.state = .data, + else => {}, + }, + + .data => try self.data.append(c), + } + + // We always add to our data list because this is our stable + // array of bytes that we'll reference everywhere else. + } + + /// Complete the parsing. This must be called after all the + /// bytes have been fed to the parser. + /// + /// The allocator given will be used for the long-lived data + /// of the final command. + pub fn complete(self: *CommandParser) !Command { + switch (self.state) { + // We can't ever end in the control key state and be valid. + // This means the command looked something like "a=1,b" + .control_key, .control_key_ignore => return error.InvalidFormat, + + // Some commands (i.e. placements) end without extra data so + // we end in the value state. i.e. "a=1,b=2" + .control_value => try self.finishValue(.data), + .control_value_ignore => {}, + + // Most commands end in data, i.e. "a=1,b=2;1234" + .data => {}, + } + + // Determine our action, which is always a single character. + const action: u8 = action: { + const value = self.kv.get('a') orelse break :action 't'; + const c = std.math.cast(u8, value) orelse return error.InvalidFormat; + break :action c; + }; + const control: Command.Control = switch (action) { + 'q' => .{ .query = try Transmission.parse(self.kv) }, + 't' => .{ .transmit = try Transmission.parse(self.kv) }, + 'T' => .{ .transmit_and_display = .{ + .transmission = try Transmission.parse(self.kv), + .display = try Display.parse(self.kv), + } }, + 'p' => .{ .display = try Display.parse(self.kv) }, + 'd' => .{ .delete = try Delete.parse(self.kv) }, + 'f' => .{ .transmit_animation_frame = try AnimationFrameLoading.parse(self.kv) }, + 'a' => .{ .control_animation = try AnimationControl.parse(self.kv) }, + 'c' => .{ .compose_animation = try AnimationFrameComposition.parse(self.kv) }, + else => return error.InvalidFormat, + }; + + // Determine our quiet value + 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 .no; + + return .{ + .control = control, + .quiet = quiet, + .data = if (self.data.items.len == 0) "" else data: { + break :data try self.data.toOwnedSlice(); + }, + }; + } + + fn accumulateValue(self: *CommandParser, c: u8, overflow_state: State) !void { + const idx = self.kv_temp_len; + self.kv_temp_len += 1; + if (self.kv_temp_len > self.kv_temp.len) { + self.state = overflow_state; + self.kv_temp_len = 0; + return; + } + self.kv_temp[idx] = c; + } + + fn finishValue(self: *CommandParser, next_state: State) !void { + const alloc = self.arena.allocator(); + + // We can move states right away, we don't use it. + self.state = next_state; + + // Check for ASCII chars first + 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_temp_len = 0; + return; + } + } + + // Only "z" is currently signed. This is a bit of a kloodge; if more + // fields become signed we can rethink this but for now we parse + // "z" as i32 then bitcast it to u32 then bitcast it back later. + if (self.kv_current == 'z') { + const v = try std.fmt.parseInt(i32, self.kv_temp[0..self.kv_temp_len], 10); + try self.kv.put(alloc, self.kv_current, @bitCast(v)); + } else { + const v = try std.fmt.parseInt(u32, self.kv_temp[0..self.kv_temp_len], 10); + try self.kv.put(alloc, self.kv_current, v); + } + + // Clear our temp buffer + self.kv_temp_len = 0; + } +}; + +/// Represents a possible response to a command. +pub const Response = struct { + id: u32 = 0, + image_number: u32 = 0, + placement_id: u32 = 0, + message: []const u8 = "OK", + + pub fn encode(self: Response, writer: anytype) !void { + // We only encode a result if we have either an id or an image number. + if (self.id == 0 and self.image_number == 0) return; + + try writer.writeAll("\x1b_G"); + if (self.id > 0) { + try writer.print("i={}", .{self.id}); + } + if (self.image_number > 0) { + if (self.id > 0) try writer.writeByte(','); + try writer.print("I={}", .{self.image_number}); + } + if (self.placement_id > 0) { + try writer.print(",p={}", .{self.placement_id}); + } + try writer.writeByte(';'); + try writer.writeAll(self.message); + try writer.writeAll("\x1b\\"); + } + + /// Returns true if this response is not an error. + pub fn ok(self: Response) bool { + return std.mem.eql(u8, self.message, "OK"); + } +}; + +pub const Command = struct { + control: Control, + quiet: Quiet = .no, + data: []const u8 = "", + + pub const Action = enum { + query, // q + transmit, // t + transmit_and_display, // T + display, // p + delete, // d + transmit_animation_frame, // f + control_animation, // a + compose_animation, // c + }; + + pub const Quiet = enum { + no, // 0 + ok, // 1 + failures, // 2 + }; + + pub const Control = union(Action) { + query: Transmission, + transmit: Transmission, + transmit_and_display: struct { + transmission: Transmission, + display: Display, + }, + display: Display, + delete: Delete, + transmit_animation_frame: AnimationFrameLoading, + control_animation: AnimationControl, + compose_animation: AnimationFrameComposition, + }; + + /// Take ownership over the data in this command. If the returned value + /// has a length of zero, then the data was empty and need not be freed. + pub fn toOwnedData(self: *Command) []const u8 { + const result = self.data; + self.data = ""; + return result; + } + + /// Returns the transmission data if it has any. + pub fn transmission(self: Command) ?Transmission { + return switch (self.control) { + .query => |t| t, + .transmit => |t| t, + .transmit_and_display => |t| t.transmission, + else => null, + }; + } + + /// Returns the display data if it has any. + pub fn display(self: Command) ?Display { + return switch (self.control) { + .display => |d| d, + .transmit_and_display => |t| t.display, + else => null, + }; + } + + pub fn deinit(self: Command, alloc: Allocator) void { + if (self.data.len > 0) alloc.free(self.data); + } +}; + +pub const Transmission = struct { + format: Format = .rgb, // f + medium: Medium = .direct, // t + width: u32 = 0, // s + height: u32 = 0, // v + size: u32 = 0, // S + offset: u32 = 0, // O + image_id: u32 = 0, // i + image_number: u32 = 0, // I + placement_id: u32 = 0, // p + compression: Compression = .none, // o + more_chunks: bool = false, // m + + pub const Format = enum { + rgb, // 24 + rgba, // 32 + png, // 100 + + // The following are not supported directly via the protocol + // but they are formats that a png may decode to that we + // support. + grey_alpha, + }; + + pub const Medium = enum { + direct, // d + file, // f + temporary_file, // t + shared_memory, // s + }; + + pub const Compression = enum { + none, + zlib_deflate, // z + }; + + fn parse(kv: KV) !Transmission { + var result: Transmission = .{}; + if (kv.get('f')) |v| { + result.format = switch (v) { + 24 => .rgb, + 32 => .rgba, + 100 => .png, + else => return error.InvalidFormat, + }; + } + + if (kv.get('t')) |v| { + const c = std.math.cast(u8, v) orelse return error.InvalidFormat; + result.medium = switch (c) { + 'd' => .direct, + 'f' => .file, + 't' => .temporary_file, + 's' => .shared_memory, + else => return error.InvalidFormat, + }; + } + + if (kv.get('s')) |v| { + result.width = v; + } + + if (kv.get('v')) |v| { + result.height = v; + } + + if (kv.get('S')) |v| { + result.size = v; + } + + if (kv.get('O')) |v| { + result.offset = v; + } + + 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('o')) |v| { + const c = std.math.cast(u8, v) orelse return error.InvalidFormat; + result.compression = switch (c) { + 'z' => .zlib_deflate, + else => return error.InvalidFormat, + }; + } + + if (kv.get('m')) |v| { + result.more_chunks = v > 0; + } + + return result; + } +}; + +pub const Display = struct { + image_id: u32 = 0, // i + image_number: u32 = 0, // I + placement_id: u32 = 0, // p + x: u32 = 0, // x + y: u32 = 0, // y + width: u32 = 0, // w + height: u32 = 0, // h + x_offset: u32 = 0, // X + y_offset: u32 = 0, // Y + columns: u32 = 0, // c + rows: u32 = 0, // r + cursor_movement: CursorMovement = .after, // C + virtual_placement: bool = false, // U + z: i32 = 0, // z + + pub const CursorMovement = enum { + after, // 0 + none, // 1 + }; + + fn parse(kv: KV) !Display { + var result: Display = .{}; + + 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('x')) |v| { + result.x = v; + } + + if (kv.get('y')) |v| { + result.y = v; + } + + if (kv.get('w')) |v| { + result.width = v; + } + + if (kv.get('h')) |v| { + result.height = v; + } + + if (kv.get('X')) |v| { + result.x_offset = v; + } + + if (kv.get('Y')) |v| { + result.y_offset = v; + } + + if (kv.get('c')) |v| { + result.columns = v; + } + + if (kv.get('r')) |v| { + result.rows = v; + } + + if (kv.get('C')) |v| { + result.cursor_movement = switch (v) { + 0 => .after, + 1 => .none, + else => return error.InvalidFormat, + }; + } + + if (kv.get('U')) |v| { + result.virtual_placement = switch (v) { + 0 => false, + 1 => true, + else => return error.InvalidFormat, + }; + } + + if (kv.get('z')) |v| { + // We can bitcast here because of how we parse it earlier. + result.z = @bitCast(v); + } + + return result; + } +}; + +pub const AnimationFrameLoading = struct { + x: u32 = 0, // x + y: u32 = 0, // y + create_frame: u32 = 0, // c + edit_frame: u32 = 0, // r + gap_ms: u32 = 0, // z + composition_mode: CompositionMode = .alpha_blend, // X + background: Background = .{}, // Y + + pub const Background = packed struct(u32) { + r: u8 = 0, + g: u8 = 0, + b: u8 = 0, + a: u8 = 0, + }; + + fn parse(kv: KV) !AnimationFrameLoading { + var result: AnimationFrameLoading = .{}; + + if (kv.get('x')) |v| { + result.x = v; + } + + if (kv.get('y')) |v| { + result.y = v; + } + + if (kv.get('c')) |v| { + result.create_frame = v; + } + + if (kv.get('r')) |v| { + result.edit_frame = v; + } + + if (kv.get('z')) |v| { + result.gap_ms = v; + } + + if (kv.get('X')) |v| { + result.composition_mode = switch (v) { + 0 => .alpha_blend, + 1 => .overwrite, + else => return error.InvalidFormat, + }; + } + + if (kv.get('Y')) |v| { + result.background = @bitCast(v); + } + + return result; + } +}; + +pub const AnimationFrameComposition = struct { + frame: u32 = 0, // c + edit_frame: u32 = 0, // r + x: u32 = 0, // x + y: u32 = 0, // y + width: u32 = 0, // w + height: u32 = 0, // h + left_edge: u32 = 0, // X + top_edge: u32 = 0, // Y + composition_mode: CompositionMode = .alpha_blend, // C + + fn parse(kv: KV) !AnimationFrameComposition { + var result: AnimationFrameComposition = .{}; + + if (kv.get('c')) |v| { + result.frame = v; + } + + if (kv.get('r')) |v| { + result.edit_frame = v; + } + + if (kv.get('x')) |v| { + result.x = v; + } + + if (kv.get('y')) |v| { + result.y = v; + } + + if (kv.get('w')) |v| { + result.width = v; + } + + if (kv.get('h')) |v| { + result.height = v; + } + + if (kv.get('X')) |v| { + result.left_edge = v; + } + + if (kv.get('Y')) |v| { + result.top_edge = v; + } + + if (kv.get('C')) |v| { + result.composition_mode = switch (v) { + 0 => .alpha_blend, + 1 => .overwrite, + else => return error.InvalidFormat, + }; + } + + return result; + } +}; + +pub const AnimationControl = struct { + action: AnimationAction = .invalid, // s + frame: u32 = 0, // r + gap_ms: u32 = 0, // z + current_frame: u32 = 0, // c + loops: u32 = 0, // v + + pub const AnimationAction = enum { + invalid, // 0 + stop, // 1 + run_wait, // 2 + run, // 3 + }; + + fn parse(kv: KV) !AnimationControl { + var result: AnimationControl = .{}; + + if (kv.get('s')) |v| { + result.action = switch (v) { + 0 => .invalid, + 1 => .stop, + 2 => .run_wait, + 3 => .run, + else => return error.InvalidFormat, + }; + } + + if (kv.get('r')) |v| { + result.frame = v; + } + + if (kv.get('z')) |v| { + result.gap_ms = v; + } + + if (kv.get('c')) |v| { + result.current_frame = v; + } + + if (kv.get('v')) |v| { + result.loops = v; + } + + return result; + } +}; + +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 + }, + + // 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 + }, + + 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 switch (what) { + 'a', 'A' => .{ .all = what == 'A' }, + + '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; + } + + break :blk result; + }, + + '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; + } + + break :blk result; + }, + + 'c', 'C' => .{ .intersect_cursor = what == 'C' }, + + 'f', 'F' => .{ .animation_frames = what == 'F' }, + + '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; + } + + break :blk result; + }, + + '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); + } + + break :blk result; + }, + + 'x', 'X' => blk: { + var result: Delete = .{ .column = .{ .delete = what == 'X' } }; + if (kv.get('x')) |v| { + result.column.x = v; + } + + break :blk result; + }, + + 'y', 'Y' => blk: { + var result: Delete = .{ .row = .{ .delete = what == 'Y' } }; + if (kv.get('y')) |v| { + result.row.y = v; + } + + 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); + } + + break :blk result; + }, + + else => return error.InvalidFormat, + }; + } +}; + +pub const CompositionMode = enum { + alpha_blend, // 0 + overwrite, // 1 +}; + +test "transmission command" { + const testing = std.testing; + const alloc = testing.allocator; + var p = CommandParser.init(alloc); + defer p.deinit(); + + const input = "f=24,s=10,v=20"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + 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 "query command" { + const testing = std.testing; + const alloc = testing.allocator; + var p = CommandParser.init(alloc); + defer p.deinit(); + + const input = "i=31,s=1,v=1,a=q,t=d,f=24;AAAA"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .query); + const v = command.control.query; + try testing.expectEqual(Transmission.Medium.direct, v.medium); + try testing.expectEqual(@as(u32, 1), v.width); + try testing.expectEqual(@as(u32, 1), v.height); + try testing.expectEqual(@as(u32, 31), v.image_id); + try testing.expectEqualStrings("AAAA", command.data); +} + +test "display command" { + const testing = std.testing; + const alloc = testing.allocator; + var p = CommandParser.init(alloc); + defer p.deinit(); + + const input = "a=p,U=1,i=31,c=80,r=120"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .display); + const v = command.control.display; + try testing.expectEqual(@as(u32, 80), v.columns); + try testing.expectEqual(@as(u32, 120), v.rows); + try testing.expectEqual(@as(u32, 31), v.image_id); +} + +test "delete command" { + const testing = std.testing; + const alloc = testing.allocator; + var p = CommandParser.init(alloc); + defer p.deinit(); + + const input = "a=d,d=p,x=3,y=4"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .delete); + const v = command.control.delete; + try testing.expect(v == .intersect_cell); + const dv = v.intersect_cell; + try testing.expect(!dv.delete); + try testing.expectEqual(@as(u32, 3), dv.x); + try testing.expectEqual(@as(u32, 4), dv.y); +} + +test "ignore unknown keys (long)" { + const testing = std.testing; + const alloc = testing.allocator; + var p = CommandParser.init(alloc); + defer p.deinit(); + + const input = "f=24,s=10,v=20,hello=world"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + 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; + var p = CommandParser.init(alloc); + defer p.deinit(); + + const input = "f=24,s=10,v=2000000000000000000000000000000000000000"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + 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, 0), v.height); +} + +test "response: encode nothing without ID or image number" { + const testing = std.testing; + var buf: [1024]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + + var r: Response = .{}; + try r.encode(fbs.writer()); + try testing.expectEqualStrings("", fbs.getWritten()); +} + +test "response: encode with only image id" { + const testing = std.testing; + var buf: [1024]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + + var r: Response = .{ .id = 4 }; + try r.encode(fbs.writer()); + try testing.expectEqualStrings("\x1b_Gi=4;OK\x1b\\", fbs.getWritten()); +} + +test "response: encode with only image number" { + const testing = std.testing; + var buf: [1024]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + + var r: Response = .{ .image_number = 4 }; + try r.encode(fbs.writer()); + try testing.expectEqualStrings("\x1b_GI=4;OK\x1b\\", fbs.getWritten()); +} + +test "response: encode with image ID and number" { + const testing = std.testing; + var buf: [1024]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + + var r: Response = .{ .id = 12, .image_number = 4 }; + try r.encode(fbs.writer()); + try testing.expectEqualStrings("\x1b_Gi=12,I=4;OK\x1b\\", fbs.getWritten()); +} diff --git a/src/terminal2/kitty/graphics_exec.zig b/src/terminal2/kitty/graphics_exec.zig new file mode 100644 index 000000000..b4047c1d5 --- /dev/null +++ b/src/terminal2/kitty/graphics_exec.zig @@ -0,0 +1,344 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const renderer = @import("../../renderer.zig"); +const point = @import("../point.zig"); +const Terminal = @import("../Terminal.zig"); +const command = @import("graphics_command.zig"); +const image = @import("graphics_image.zig"); +const Command = command.Command; +const Response = command.Response; +const LoadingImage = image.LoadingImage; +const Image = image.Image; +const ImageStorage = @import("graphics_storage.zig").ImageStorage; + +const log = std.log.scoped(.kitty_gfx); + +/// Execute a Kitty graphics command against the given terminal. This +/// will never fail, but the response may indicate an error and the +/// terminal state may not be updated to reflect the command. This will +/// never put the terminal in an unrecoverable state, however. +/// +/// The allocator must be the same allocator that was used to build +/// the command. +pub fn execute( + alloc: Allocator, + terminal: *Terminal, + cmd: *Command, +) ?Response { + // If storage is disabled then we disable the full protocol. This means + // we don't even respond to queries so the terminal completely acts as + // if this feature is not supported. + if (!terminal.screen.kitty_images.enabled()) { + log.debug("kitty graphics requested but disabled", .{}); + return null; + } + + log.debug("executing kitty graphics command: quiet={} control={}", .{ + cmd.quiet, + cmd.control, + }); + + const resp_: ?Response = switch (cmd.control) { + .query => query(alloc, cmd), + .transmit, .transmit_and_display => transmit(alloc, terminal, cmd), + .display => display(alloc, terminal, cmd), + .delete => delete(alloc, terminal, cmd), + + .transmit_animation_frame, + .control_animation, + .compose_animation, + => .{ .message = "ERROR: unimplemented action" }, + }; + + // Handle the quiet settings + if (resp_) |resp| { + if (!resp.ok()) { + log.warn("erroneous kitty graphics response: {s}", .{resp.message}); + } + + return switch (cmd.quiet) { + .no => resp, + .ok => if (resp.ok()) null else resp, + .failures => null, + }; + } + + 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(alloc: Allocator, cmd: *Command) Response { + const t = cmd.control.query; + + // Query requires image ID. We can't actually send a response without + // an image ID either but we return an error and this will be logged + // downstream. + if (t.image_id == 0) { + return .{ .message = "EINVAL: image ID required" }; + } + + // Build a partial response to start + var result: Response = .{ + .id = t.image_id, + .image_number = t.image_number, + .placement_id = t.placement_id, + }; + + // Attempt to load the image. If we cannot, then set an appropriate error. + var loading = LoadingImage.init(alloc, cmd) catch |err| { + encodeError(&result, err); + return result; + }; + loading.deinit(alloc); + + return result; +} + +/// Transmit image data. +/// +/// This loads the image, validates it, and puts it into the terminal +/// screen storage. It does not display the image. +fn transmit( + alloc: Allocator, + terminal: *Terminal, + cmd: *Command, +) Response { + const t = cmd.transmission().?; + var result: Response = .{ + .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| { + encodeError(&result, err); + return result; + }; + errdefer load.image.deinit(alloc); + + // If we're also displaying, then do that now. This function does + // both transmit and transmit and display. The display might also be + // deferred if it is multi-chunk. + if (load.display) |d| { + assert(!load.more); + var d_copy = d; + d_copy.image_id = load.image.id; + return display(alloc, terminal, &.{ + .control = .{ .display = d_copy }, + .quiet = cmd.quiet, + }); + } + + // If there are more chunks expected we do not respond. + if (load.more) return .{}; + + // After the image is added, set the ID in case it changed + result.id = load.image.id; + + // If the original request had an image number, then we respond. + // Otherwise, we don't respond. + if (load.image.number == 0) return .{}; + + return result; +} + +/// Display a previously transmitted image. +fn display( + alloc: Allocator, + terminal: *Terminal, + cmd: *const Command, +) Response { + const d = cmd.display().?; + + // Display requires image ID or number. + if (d.image_id == 0 and d.image_number == 0) { + return .{ .message = "EINVAL: image ID or number required" }; + } + + // Build up our response + var result: Response = .{ + .id = d.image_id, + .image_number = d.image_number, + .placement_id = d.placement_id, + }; + + // Verify the requested image exists if we have an ID + const storage = &terminal.screen.kitty_images; + const img_: ?Image = if (d.image_id != 0) + storage.imageById(d.image_id) + else + storage.imageByNumber(d.image_number); + const img = img_ orelse { + result.message = "EINVAL: image not found"; + return result; + }; + + // Make sure our response has the image id in case we looked up by number + result.id = img.id; + + // Determine the screen point for the placement. + const placement_point = (point.Viewport{ + .x = terminal.screen.cursor.x, + .y = terminal.screen.cursor.y, + }).toScreen(&terminal.screen); + + // Add the placement + const p: ImageStorage.Placement = .{ + .point = placement_point, + .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, + }; + storage.addPlacement( + alloc, + img.id, + result.placement_id, + p, + ) catch |err| { + encodeError(&result, err); + return result; + }; + + // Cursor needs to move after placement + switch (d.cursor_movement) { + .none => {}, + .after => { + const rect = p.rect(img, terminal); + + // We can do better by doing this with pure internal screen state + // but this handles scroll regions. + const height = rect.bottom_right.y - rect.top_left.y; + for (0..height) |_| terminal.index() catch |err| { + log.warn("failed to move cursor: {}", .{err}); + break; + }; + + terminal.setCursorPos( + terminal.screen.cursor.y, + rect.bottom_right.x + 1, + ); + }, + } + + // Display does not result in a response on success + return .{}; +} + +/// Display a previously transmitted image. +fn delete( + alloc: Allocator, + terminal: *Terminal, + cmd: *Command, +) Response { + const storage = &terminal.screen.kitty_images; + storage.delete(alloc, terminal, cmd.control.delete); + + // Delete never responds on success + return .{}; +} + +fn loadAndAddImage( + alloc: Allocator, + terminal: *Terminal, + cmd: *Command, +) !struct { + image: Image, + more: bool = false, + display: ?command.Display = null, +} { + const t = cmd.transmission().?; + const storage = &terminal.screen.kitty_images; + + // Determine our image. This also handles chunking and early exit. + var loading: LoadingImage = if (storage.loading) |loading| loading: { + // Note: we do NOT want to call "cmd.toOwnedData" here because + // we're _copying_ the data. We want the command data to be freed. + try loading.addData(alloc, cmd.data); + + // If we have more then we're done + if (t.more_chunks) return .{ .image = loading.image, .more = true }; + + // We have no more chunks. We're going to be completing the + // image so we want to destroy the pointer to the loading + // image and copy it out. + defer { + alloc.destroy(loading); + storage.loading = null; + } + + break :loading loading.*; + } else try LoadingImage.init(alloc, cmd); + + // 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 + // after we've copied the image out. + errdefer loading.deinit(alloc); + + // 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 this is chunked, this is the beginning of a new chunked transmission. + // (We checked for an in-progress chunk above.) + if (t.more_chunks) { + // We allocate the pointer on the heap because its rare and we + // don't want to always pay the memory cost to keep it around. + const loading_ptr = try alloc.create(LoadingImage); + errdefer alloc.destroy(loading_ptr); + loading_ptr.* = loading; + storage.loading = loading_ptr; + return .{ .image = loading.image, .more = true }; + } + + // Dump the image data before it is decompressed + // loading.debugDump() catch unreachable; + + // Validate and store our image + var img = try loading.complete(alloc); + errdefer img.deinit(alloc); + try storage.addImage(alloc, img); + + // Get our display settings + const display_ = loading.display; + + // Ensure we deinit the loading state because we're done. The image + // won't be deinit because of "complete" above. + loading.deinit(alloc); + + return .{ .image = img, .display = display_ }; +} + +const EncodeableError = Image.Error || Allocator.Error; + +/// Encode an error code into a message for a response. +fn encodeError(r: *Response, err: EncodeableError) void { + switch (err) { + error.OutOfMemory => r.message = "ENOMEM: out of memory", + error.InternalError => r.message = "EINVAL: internal error", + error.InvalidData => r.message = "EINVAL: invalid data", + error.DecompressionFailed => r.message = "EINVAL: decompression failed", + error.FilePathTooLong => r.message = "EINVAL: file path too long", + error.TemporaryFileNotInTempDir => r.message = "EINVAL: temporary file not in temp dir", + error.UnsupportedFormat => r.message = "EINVAL: unsupported format", + error.UnsupportedMedium => r.message = "EINVAL: unsupported medium", + error.UnsupportedDepth => r.message = "EINVAL: unsupported pixel depth", + error.DimensionsRequired => r.message = "EINVAL: dimensions required", + error.DimensionsTooLarge => r.message = "EINVAL: dimensions too large", + } +} diff --git a/src/terminal2/kitty/graphics_image.zig b/src/terminal2/kitty/graphics_image.zig new file mode 100644 index 000000000..d84ea91d6 --- /dev/null +++ b/src/terminal2/kitty/graphics_image.zig @@ -0,0 +1,776 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +const command = @import("graphics_command.zig"); +const point = @import("../point.zig"); +const internal_os = @import("../../os/main.zig"); +const stb = @import("../../stb/main.zig"); + +const log = std.log.scoped(.kitty_gfx); + +/// Maximum width or height of an image. Taken directly from Kitty. +const max_dimension = 10000; + +/// Maximum size in bytes, taken from Kitty. +const max_size = 400 * 1024 * 1024; // 400MB + +/// An image that is still being loaded. The image should be initialized +/// using init on the first chunk and then addData for each subsequent +/// chunk. Once all chunks have been added, complete should be called +/// to finalize the image. +pub const LoadingImage = struct { + /// The in-progress image. The first chunk must have all the metadata + /// so this comes from that initially. + image: Image, + + /// The data that is being built up. + data: std.ArrayListUnmanaged(u8) = .{}, + + /// This is non-null when a transmit and display command is given + /// so that we display the image after it is fully loaded. + display: ?command.Display = null, + + /// Initialize a chunked immage from the first image transmission. + /// If this is a multi-chunk image, this should only be the FIRST + /// chunk. + pub fn init(alloc: Allocator, cmd: *command.Command) !LoadingImage { + // Build our initial image from the properties sent via the control. + // These can be overwritten by the data loading process. For example, + // PNG loading sets the width/height from the data. + const t = cmd.transmission().?; + var result: LoadingImage = .{ + .image = .{ + .id = t.image_id, + .number = t.image_number, + .width = t.width, + .height = t.height, + .compression = t.compression, + .format = t.format, + }, + + .display = cmd.display(), + }; + + // Special case for the direct medium, we just add it directly + // which will handle copying the data, base64 decoding, etc. + if (t.medium == .direct) { + try result.addData(alloc, cmd.data); + return result; + } + + // For every other medium, we'll need to at least base64 decode + // the data to make it useful so let's do that. Also, all the data + // has to be path data so we can put it in a stack-allocated buffer. + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const Base64Decoder = std.base64.standard.Decoder; + const size = Base64Decoder.calcSizeForSlice(cmd.data) catch |err| { + log.warn("failed to calculate base64 size for file path: {}", .{err}); + return error.InvalidData; + }; + if (size > buf.len) return error.FilePathTooLong; + Base64Decoder.decode(&buf, cmd.data) catch |err| { + log.warn("failed to decode base64 data: {}", .{err}); + return error.InvalidData; + }; + + if (comptime builtin.os.tag != .windows) { + if (std.mem.indexOfScalar(u8, buf[0..size], 0) != null) { + // std.os.realpath *asserts* that the path does not have + // internal nulls instead of erroring. + log.warn("failed to get absolute path: BadPathName", .{}); + return error.InvalidData; + } + } + + var abs_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const path = std.os.realpath(buf[0..size], &abs_buf) catch |err| { + log.warn("failed to get absolute path: {}", .{err}); + return error.InvalidData; + }; + + // Depending on the medium, load the data from the path. + switch (t.medium) { + .direct => unreachable, // handled above + .file => try result.readFile(.file, alloc, t, path), + .temporary_file => try result.readFile(.temporary_file, alloc, t, path), + .shared_memory => try result.readSharedMemory(alloc, t, path), + } + + return result; + } + + /// Reads the data from a shared memory segment. + fn readSharedMemory( + self: *LoadingImage, + alloc: Allocator, + t: command.Transmission, + path: []const u8, + ) !void { + // We require libc for this for shm_open + if (comptime !builtin.link_libc) return error.UnsupportedMedium; + + // Todo: support shared memory + _ = self; + _ = alloc; + _ = t; + _ = path; + return error.UnsupportedMedium; + } + + /// Reads the data from a temporary file and returns it. This allocates + /// and does not free any of the data, so the caller must free it. + /// + /// This will also delete the temporary file if it is in a safe location. + fn readFile( + self: *LoadingImage, + comptime medium: command.Transmission.Medium, + alloc: Allocator, + t: command.Transmission, + path: []const u8, + ) !void { + switch (medium) { + .file, .temporary_file => {}, + else => @compileError("readFile only supports file and temporary_file"), + } + + // Verify file seems "safe". This is logic copied directly from Kitty, + // mostly. This is really rough but it will catch obvious bad actors. + if (std.mem.startsWith(u8, path, "/proc/") or + std.mem.startsWith(u8, path, "/sys/") or + (std.mem.startsWith(u8, path, "/dev/") and + !std.mem.startsWith(u8, path, "/dev/shm/"))) + { + return error.InvalidData; + } + + // Temporary file logic + if (medium == .temporary_file) { + if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir; + } + defer if (medium == .temporary_file) { + std.os.unlink(path) catch |err| { + log.warn("failed to delete temporary file: {}", .{err}); + }; + }; + + var file = std.fs.cwd().openFile(path, .{}) catch |err| { + log.warn("failed to open temporary file: {}", .{err}); + return error.InvalidData; + }; + defer file.close(); + + // File must be a regular file + if (file.stat()) |stat| { + if (stat.kind != .file) { + log.warn("file is not a regular file kind={}", .{stat.kind}); + return error.InvalidData; + } + } else |err| { + log.warn("failed to stat file: {}", .{err}); + return error.InvalidData; + } + + if (t.offset > 0) { + file.seekTo(@intCast(t.offset)) catch |err| { + log.warn("failed to seek to offset {}: {}", .{ t.offset, err }); + return error.InvalidData; + }; + } + + var buf_reader = std.io.bufferedReader(file.reader()); + const reader = buf_reader.reader(); + + // Read the file + var managed = std.ArrayList(u8).init(alloc); + errdefer managed.deinit(); + const size: usize = if (t.size > 0) @min(t.size, max_size) else max_size; + reader.readAllArrayList(&managed, size) catch |err| { + log.warn("failed to read temporary file: {}", .{err}); + return error.InvalidData; + }; + + // Set our data + assert(self.data.items.len == 0); + self.data = .{ .items = managed.items, .capacity = managed.capacity }; + } + + /// Returns true if path appears to be in a temporary directory. + /// Copies logic from Kitty. + fn isPathInTempDir(path: []const u8) bool { + if (std.mem.startsWith(u8, path, "/tmp")) return true; + if (std.mem.startsWith(u8, path, "/dev/shm")) return true; + if (internal_os.allocTmpDir(std.heap.page_allocator)) |dir| { + defer internal_os.freeTmpDir(std.heap.page_allocator, dir); + if (std.mem.startsWith(u8, path, dir)) return true; + + // The temporary dir is sometimes a symlink. On macOS for + // example /tmp is /private/var/... + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + if (std.os.realpath(dir, &buf)) |real_dir| { + if (std.mem.startsWith(u8, path, real_dir)) return true; + } else |_| {} + } + + return false; + } + + pub fn deinit(self: *LoadingImage, alloc: Allocator) void { + self.image.deinit(alloc); + self.data.deinit(alloc); + } + + pub fn destroy(self: *LoadingImage, alloc: Allocator) void { + self.deinit(alloc); + alloc.destroy(self); + } + + /// Adds a chunk of base64-encoded data to the image. Use this if the + /// image is coming in chunks (the "m" parameter in the protocol). + pub fn addData(self: *LoadingImage, alloc: Allocator, data: []const u8) !void { + // If no data, skip + if (data.len == 0) return; + + // Grow our array list by size capacity if it needs it + const Base64Decoder = std.base64.standard.Decoder; + const size = Base64Decoder.calcSizeForSlice(data) catch |err| { + log.warn("failed to calculate size for base64 data: {}", .{err}); + return error.InvalidData; + }; + + // If our data would get too big, return an error + if (self.data.items.len + size > max_size) { + log.warn("image data too large max_size={}", .{max_size}); + return error.InvalidData; + } + + try self.data.ensureUnusedCapacity(alloc, size); + + // We decode directly into the arraylist + const start_i = self.data.items.len; + self.data.items.len = start_i + size; + const buf = self.data.items[start_i..]; + Base64Decoder.decode(buf, data) catch |err| switch (err) { + // We have to ignore invalid padding because lots of encoders + // add the wrong padding. Since we validate image data later + // (PNG decode or simple dimensions check), we can ignore this. + error.InvalidPadding => {}, + + else => { + log.warn("failed to decode base64 data: {}", .{err}); + return error.InvalidData; + }, + }; + } + + /// Complete the chunked image, returning a completed image. + pub fn complete(self: *LoadingImage, alloc: Allocator) !Image { + const img = &self.image; + + // Decompress the data if it is compressed. + try self.decompress(alloc); + + // Decode the png if we have to + if (img.format == .png) try self.decodePng(alloc); + + // Validate our dimensions. + if (img.width == 0 or img.height == 0) return error.DimensionsRequired; + if (img.width > max_dimension or img.height > max_dimension) return error.DimensionsTooLarge; + + // Data length must be what we expect + const bpp: u32 = switch (img.format) { + .grey_alpha => 2, + .rgb => 3, + .rgba => 4, + .png => unreachable, // png should be decoded by here + }; + const expected_len = img.width * img.height * bpp; + const actual_len = self.data.items.len; + if (actual_len != expected_len) { + std.log.warn( + "unexpected length image id={} width={} height={} bpp={} expected_len={} actual_len={}", + .{ img.id, img.width, img.height, bpp, expected_len, actual_len }, + ); + return error.InvalidData; + } + + // Set our time + self.image.transmit_time = std.time.Instant.now() catch |err| { + log.warn("failed to get time: {}", .{err}); + return error.InternalError; + }; + + // Everything looks good, copy the image data over. + var result = self.image; + result.data = try self.data.toOwnedSlice(alloc); + errdefer result.deinit(alloc); + self.image = .{}; + return result; + } + + /// Debug function to write the data to a file. This is useful for + /// capturing some test data for unit tests. + pub fn debugDump(self: LoadingImage) !void { + if (comptime builtin.mode != .Debug) @compileError("debugDump in non-debug"); + + var buf: [1024]u8 = undefined; + const filename = try std.fmt.bufPrint( + &buf, + "image-{s}-{s}-{d}x{d}-{}.data", + .{ + @tagName(self.image.format), + @tagName(self.image.compression), + self.image.width, + self.image.height, + self.image.id, + }, + ); + const cwd = std.fs.cwd(); + const f = try cwd.createFile(filename, .{}); + defer f.close(); + + const writer = f.writer(); + try writer.writeAll(self.data.items); + } + + /// Decompress the data in-place. + fn decompress(self: *LoadingImage, alloc: Allocator) !void { + return switch (self.image.compression) { + .none => {}, + .zlib_deflate => self.decompressZlib(alloc), + }; + } + + fn decompressZlib(self: *LoadingImage, alloc: Allocator) !void { + // Open our zlib stream + var fbs = std.io.fixedBufferStream(self.data.items); + var stream = std.compress.zlib.decompressor(fbs.reader()); + + // Write it to an array list + var list = std.ArrayList(u8).init(alloc); + errdefer list.deinit(); + stream.reader().readAllArrayList(&list, max_size) catch |err| { + log.warn("failed to read decompressed data: {}", .{err}); + return error.DecompressionFailed; + }; + + // Empty our current data list, take ownership over managed array list + self.data.deinit(alloc); + self.data = .{ .items = list.items, .capacity = list.capacity }; + + // Make sure we note that our image is no longer compressed + self.image.compression = .none; + } + + /// Decode the data as PNG. This will also updated the image dimensions. + fn decodePng(self: *LoadingImage, alloc: Allocator) !void { + assert(self.image.format == .png); + + // Decode PNG + var width: c_int = 0; + var height: c_int = 0; + var bpp: c_int = 0; + const data = stb.stbi_load_from_memory( + self.data.items.ptr, + @intCast(self.data.items.len), + &width, + &height, + &bpp, + 0, + ) orelse return error.InvalidData; + defer stb.stbi_image_free(data); + const len: usize = @intCast(width * height * bpp); + if (len > max_size) { + log.warn("png image too large size={} max_size={}", .{ len, max_size }); + return error.InvalidData; + } + + // Validate our bpp + if (bpp < 2 or bpp > 4) { + log.warn("png with unsupported bpp={}", .{bpp}); + return error.UnsupportedDepth; + } + + // Replace our data + self.data.deinit(alloc); + self.data = .{}; + try self.data.ensureUnusedCapacity(alloc, len); + try self.data.appendSlice(alloc, data[0..len]); + + // Store updated image dimensions + self.image.width = @intCast(width); + self.image.height = @intCast(height); + self.image.format = switch (bpp) { + 2 => .grey_alpha, + 3 => .rgb, + 4 => .rgba, + else => unreachable, // validated above + }; + } +}; + +/// Image represents a single fully loaded image. +pub const Image = struct { + id: u32 = 0, + number: u32 = 0, + width: u32 = 0, + height: u32 = 0, + format: command.Transmission.Format = .rgb, + compression: command.Transmission.Compression = .none, + data: []const u8 = "", + transmit_time: std.time.Instant = undefined, + + pub const Error = error{ + InternalError, + InvalidData, + DecompressionFailed, + DimensionsRequired, + DimensionsTooLarge, + FilePathTooLong, + TemporaryFileNotInTempDir, + UnsupportedFormat, + UnsupportedMedium, + UnsupportedDepth, + }; + + pub fn deinit(self: *Image, alloc: Allocator) void { + if (self.data.len > 0) alloc.free(self.data); + } + + /// Mostly for logging + pub fn withoutData(self: *const Image) Image { + var copy = self.*; + copy.data = ""; + return copy; + } +}; + +/// The rect taken up by some image placement, in grid cells. This will +/// be rounded up to the nearest grid cell since we can't place images +/// in partial grid cells. +pub const Rect = struct { + top_left: point.ScreenPoint = .{}, + bottom_right: point.ScreenPoint = .{}, + + /// True if the rect contains a given screen point. + pub fn contains(self: Rect, p: point.ScreenPoint) bool { + return p.y >= self.top_left.y and + p.y <= self.bottom_right.y and + p.x >= self.top_left.x and + p.x <= self.bottom_right.x; + } +}; + +/// Easy base64 encoding function. +fn testB64(alloc: Allocator, data: []const u8) ![]const u8 { + const B64Encoder = std.base64.standard.Encoder; + const b64 = try alloc.alloc(u8, B64Encoder.calcSize(data.len)); + errdefer alloc.free(b64); + return B64Encoder.encode(b64, data); +} + +/// Easy base64 decoding function. +fn testB64Decode(alloc: Allocator, data: []const u8) ![]const u8 { + const B64Decoder = std.base64.standard.Decoder; + const result = try alloc.alloc(u8, try B64Decoder.calcSizeForSlice(data)); + errdefer alloc.free(result); + try B64Decoder.decode(result, data); + return result; +} + +// This specifically tests we ALLOW invalid RGB data because Kitty +// documents that this should work. +test "image load with invalid RGB data" { + const testing = std.testing; + const alloc = testing.allocator; + + // _Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\ + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .width = 1, + .height = 1, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, "AAAA"), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); +} + +test "image load with image too wide" { + const testing = std.testing; + const alloc = testing.allocator; + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .width = max_dimension + 1, + .height = 1, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, "AAAA"), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); +} + +test "image load with image too tall" { + const testing = std.testing; + const alloc = testing.allocator; + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .height = max_dimension + 1, + .width = 1, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, "AAAA"), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); +} + +test "image load: rgb, zlib compressed, direct" { + const testing = std.testing; + const alloc = testing.allocator; + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .direct, + .compression = .zlib_deflate, + .height = 96, + .width = 128, + .image_id = 31, + } }, + .data = try alloc.dupe( + u8, + @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"), + ), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + var img = try loading.complete(alloc); + defer img.deinit(alloc); + + // should be decompressed + try testing.expect(img.compression == .none); +} + +test "image load: rgb, not compressed, direct" { + const testing = std.testing; + const alloc = testing.allocator; + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .direct, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe( + u8, + @embedFile("testdata/image-rgb-none-20x15-2147483647.data"), + ), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + var img = try loading.complete(alloc); + defer img.deinit(alloc); + + // should be decompressed + try testing.expect(img.compression == .none); +} + +test "image load: rgb, zlib compressed, direct, chunked" { + const testing = std.testing; + const alloc = testing.allocator; + + const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"); + + // Setup our initial chunk + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .direct, + .compression = .zlib_deflate, + .height = 96, + .width = 128, + .image_id = 31, + .more_chunks = true, + } }, + .data = try alloc.dupe(u8, data[0..1024]), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + + // Read our remaining chunks + var fbs = std.io.fixedBufferStream(data[1024..]); + var buf: [1024]u8 = undefined; + while (fbs.reader().readAll(&buf)) |size| { + try loading.addData(alloc, buf[0..size]); + if (size < buf.len) break; + } else |err| return err; + + // Complete + var img = try loading.complete(alloc); + defer img.deinit(alloc); + try testing.expect(img.compression == .none); +} + +test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk" { + const testing = std.testing; + const alloc = testing.allocator; + + const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"); + + // Setup our initial chunk + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .direct, + .compression = .zlib_deflate, + .height = 96, + .width = 128, + .image_id = 31, + .more_chunks = true, + } }, + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + + // Read our remaining chunks + var fbs = std.io.fixedBufferStream(data); + var buf: [1024]u8 = undefined; + while (fbs.reader().readAll(&buf)) |size| { + try loading.addData(alloc, buf[0..size]); + if (size < buf.len) break; + } else |err| return err; + + // Complete + var img = try loading.complete(alloc); + defer img.deinit(alloc); + try testing.expect(img.compression == .none); +} + +test "image load: rgb, not compressed, temporary file" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try internal_os.TempDir.init(); + defer tmp_dir.deinit(); + const data = try testB64Decode( + alloc, + @embedFile("testdata/image-rgb-none-20x15-2147483647.data"), + ); + defer alloc.free(data); + try tmp_dir.dir.writeFile("image.data", data); + + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const path = try tmp_dir.dir.realpath("image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .temporary_file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try testB64(alloc, path), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + var img = try loading.complete(alloc); + defer img.deinit(alloc); + try testing.expect(img.compression == .none); + + // Temporary file should be gone + try testing.expectError(error.FileNotFound, tmp_dir.dir.access(path, .{})); +} + +test "image load: rgb, not compressed, regular file" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try internal_os.TempDir.init(); + defer tmp_dir.deinit(); + const data = try testB64Decode( + alloc, + @embedFile("testdata/image-rgb-none-20x15-2147483647.data"), + ); + defer alloc.free(data); + try tmp_dir.dir.writeFile("image.data", data); + + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const path = try tmp_dir.dir.realpath("image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try testB64(alloc, path), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + var img = try loading.complete(alloc); + defer img.deinit(alloc); + try testing.expect(img.compression == .none); + try tmp_dir.dir.access(path, .{}); +} + +test "image load: png, not compressed, regular file" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try internal_os.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); + try tmp_dir.dir.writeFile("image.data", data); + + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const path = try tmp_dir.dir.realpath("image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .png, + .medium = .file, + .compression = .none, + .width = 0, + .height = 0, + .image_id = 31, + } }, + .data = try testB64(alloc, path), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + var img = try loading.complete(alloc); + defer img.deinit(alloc); + try testing.expect(img.compression == .none); + try testing.expect(img.format == .rgb); + try tmp_dir.dir.access(path, .{}); +} diff --git a/src/terminal2/kitty/graphics_storage.zig b/src/terminal2/kitty/graphics_storage.zig new file mode 100644 index 000000000..230c1edc3 --- /dev/null +++ b/src/terminal2/kitty/graphics_storage.zig @@ -0,0 +1,919 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +const terminal = @import("../main.zig"); +const point = @import("../point.zig"); +const command = @import("graphics_command.zig"); +const PageList = @import("../PageList.zig"); +const Screen = @import("../Screen.zig"); +const LoadingImage = @import("graphics_image.zig").LoadingImage; +const Image = @import("graphics_image.zig").Image; +const Rect = @import("graphics_image.zig").Rect; +const Command = command.Command; + +const log = std.log.scoped(.kitty_gfx); + +/// An image storage is associated with a terminal screen (i.e. main +/// screen, alt screen) and contains all the transmitted images and +/// placements. +pub const ImageStorage = struct { + const ImageMap = std.AutoHashMapUnmanaged(u32, Image); + const PlacementMap = std.AutoHashMapUnmanaged(PlacementKey, Placement); + + /// Dirty is set to true if placements or images change. This is + /// purely informational for the renderer and doesn't affect the + /// correctness of the program. The renderer must set this to false + /// if it cares about this value. + dirty: bool = false, + + /// This is the next automatically assigned image ID. We start mid-way + /// through the u32 range to avoid collisions with buggy programs. + next_image_id: u32 = 2147483647, + + /// This is the next automatically assigned placement ID. This is never + /// user-facing so we can start at 0. This is 32-bits because we use + /// the same space for external placement IDs. We can start at zero + /// because any number is valid. + next_internal_placement_id: u32 = 0, + + /// The set of images that are currently known. + images: ImageMap = .{}, + + /// The set of placements for loaded images. + placements: PlacementMap = .{}, + + /// Non-null if there is an in-progress loading image. + loading: ?*LoadingImage = null, + + /// The total bytes of image data that have been loaded and the limit. + /// If the limit is reached, the oldest images will be evicted to make + /// space. Unused images take priority. + total_bytes: usize = 0, + total_limit: usize = 320 * 1000 * 1000, // 320MB + + pub fn deinit( + self: *ImageStorage, + alloc: Allocator, + t: *terminal.Terminal, + ) void { + if (self.loading) |loading| loading.destroy(alloc); + + var it = self.images.iterator(); + while (it.next()) |kv| kv.value_ptr.deinit(alloc); + self.images.deinit(alloc); + + self.clearPlacements(t); + self.placements.deinit(alloc); + } + + /// Kitty image protocol is enabled if we have a non-zero limit. + pub fn enabled(self: *const ImageStorage) bool { + return self.total_limit != 0; + } + + /// Sets the limit in bytes for the total amount of image data that + /// can be loaded. If this limit is lower, this will do an eviction + /// if necessary. If the value is zero, then Kitty image protocol will + /// be disabled. + pub fn setLimit(self: *ImageStorage, alloc: Allocator, limit: usize) !void { + // Special case disabling by quickly deleting all + if (limit == 0) { + self.deinit(alloc); + self.* = .{}; + } + + // If we re lowering our limit, check if we need to evict. + if (limit < self.total_bytes) { + const req_bytes = self.total_bytes - limit; + log.info("evicting images to lower limit, evicting={}", .{req_bytes}); + if (!try self.evictImage(alloc, req_bytes)) { + log.warn("failed to evict enough images for required bytes", .{}); + } + } + + self.total_limit = limit; + } + + /// Add an already-loaded image to the storage. This will automatically + /// free any existing image with the same ID. + pub fn addImage(self: *ImageStorage, alloc: Allocator, img: Image) Allocator.Error!void { + // If the image itself is over the limit, then error immediately + if (img.data.len > self.total_limit) return error.OutOfMemory; + + // If this would put us over the limit, then evict. + const total_bytes = self.total_bytes + img.data.len; + if (total_bytes > self.total_limit) { + const req_bytes = total_bytes - self.total_limit; + log.info("evicting images to make space for {} bytes", .{req_bytes}); + if (!try self.evictImage(alloc, req_bytes)) { + log.warn("failed to evict enough images for required bytes", .{}); + return error.OutOfMemory; + } + } + + // Do the gop op first so if it fails we don't get a partial state + const gop = try self.images.getOrPut(alloc, img.id); + + log.debug("addImage image={}", .{img: { + var copy = img; + copy.data = ""; + break :img copy; + }}); + + // Write our new image + if (gop.found_existing) { + self.total_bytes -= gop.value_ptr.data.len; + gop.value_ptr.deinit(alloc); + } + + gop.value_ptr.* = img; + self.total_bytes += img.data.len; + + self.dirty = true; + } + + /// Add a placement for a given image. The caller must verify in advance + /// the image exists to prevent memory corruption. + pub fn addPlacement( + self: *ImageStorage, + alloc: Allocator, + image_id: u32, + placement_id: u32, + p: Placement, + ) !void { + assert(self.images.get(image_id) != null); + log.debug("placement image_id={} placement_id={} placement={}\n", .{ + image_id, + placement_id, + p, + }); + + // The important piece here is that the placement ID needs to + // be marked internal if it is zero. This allows multiple placements + // to be added for the same image. If it is non-zero, then it is + // an external placement ID and we can only have one placement + // per (image id, placement id) pair. + const key: PlacementKey = .{ + .image_id = image_id, + .placement_id = if (placement_id == 0) .{ + .tag = .internal, + .id = id: { + defer self.next_internal_placement_id +%= 1; + break :id self.next_internal_placement_id; + }, + } else .{ + .tag = .external, + .id = placement_id, + }, + }; + + const gop = try self.placements.getOrPut(alloc, key); + gop.value_ptr.* = p; + + self.dirty = true; + } + + fn clearPlacements(self: *ImageStorage, t: *terminal.Terminal) void { + var it = self.placements.iterator(); + while (it.next()) |entry| entry.value_ptr.deinit(t); + self.placements.clearRetainingCapacity(); + } + + /// Get an image by its ID. If the image doesn't exist, null is returned. + pub fn imageById(self: *const ImageStorage, image_id: u32) ?Image { + return self.images.get(image_id); + } + + /// Get an image by its number. If the image doesn't exist, return null. + pub fn imageByNumber(self: *const ImageStorage, image_number: u32) ?Image { + var newest: ?Image = null; + + var it = self.images.iterator(); + while (it.next()) |kv| { + if (kv.value_ptr.number == image_number) { + if (newest == null or + kv.value_ptr.transmit_time.order(newest.?.transmit_time) == .gt) + { + newest = kv.value_ptr.*; + } + } + } + + return newest; + } + + /// Delete placements, images. + pub fn delete( + self: *ImageStorage, + alloc: Allocator, + t: *terminal.Terminal, + cmd: command.Delete, + ) void { + switch (cmd) { + .all => |delete_images| if (delete_images) { + // We just reset our entire state. + self.deinit(alloc, t); + self.* = .{ + .dirty = true, + .total_limit = self.total_limit, + }; + } else { + // Delete all our placements + self.clearPlacements(t); + self.placements.deinit(alloc); + self.placements = .{}; + self.dirty = true; + }, + + .id => |v| self.deleteById( + alloc, + t, + v.image_id, + v.placement_id, + v.delete, + ), + + .newest => |v| newest: { + if (true) @panic("TODO"); + const img = self.imageByNumber(v.image_number) orelse break :newest; + self.deleteById(alloc, img.id, v.placement_id, v.delete); + }, + + .intersect_cursor => |delete_images| { + if (true) @panic("TODO"); + const target = (point.Viewport{ + .x = t.screen.cursor.x, + .y = t.screen.cursor.y, + }).toScreen(&t.screen); + self.deleteIntersecting(alloc, t, target, delete_images, {}, null); + }, + + .intersect_cell => |v| { + if (true) @panic("TODO"); + const target = (point.Viewport{ .x = v.x, .y = v.y }).toScreen(&t.screen); + self.deleteIntersecting(alloc, t, target, v.delete, {}, null); + }, + + .intersect_cell_z => |v| { + if (true) @panic("TODO"); + const target = (point.Viewport{ .x = v.x, .y = v.y }).toScreen(&t.screen); + self.deleteIntersecting(alloc, t, target, v.delete, v.z, struct { + fn filter(ctx: i32, p: Placement) bool { + return p.z == ctx; + } + }.filter); + }, + + .column => |v| { + if (true) @panic("TODO"); + var it = self.placements.iterator(); + while (it.next()) |entry| { + const img = self.imageById(entry.key_ptr.image_id) orelse continue; + const rect = entry.value_ptr.rect(img, t); + if (rect.top_left.x <= v.x and rect.bottom_right.x >= v.x) { + self.placements.removeByPtr(entry.key_ptr); + if (v.delete) self.deleteIfUnused(alloc, img.id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + + .row => |v| { + if (true) @panic("TODO"); + // Get the screenpoint y + const y = (point.Viewport{ .x = 0, .y = v.y }).toScreen(&t.screen).y; + + var it = self.placements.iterator(); + while (it.next()) |entry| { + const img = self.imageById(entry.key_ptr.image_id) orelse continue; + const rect = entry.value_ptr.rect(img, t); + if (rect.top_left.y <= y and rect.bottom_right.y >= y) { + self.placements.removeByPtr(entry.key_ptr); + if (v.delete) self.deleteIfUnused(alloc, img.id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + + .z => |v| { + if (true) @panic("TODO"); + var it = self.placements.iterator(); + while (it.next()) |entry| { + if (entry.value_ptr.z == v.z) { + const image_id = entry.key_ptr.image_id; + self.placements.removeByPtr(entry.key_ptr); + if (v.delete) self.deleteIfUnused(alloc, image_id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + + // We don't support animation frames yet so they are successfully + // deleted! + .animation_frames => {}, + } + } + + fn deleteById( + self: *ImageStorage, + alloc: Allocator, + t: *terminal.Terminal, + image_id: u32, + placement_id: u32, + delete_unused: bool, + ) void { + // If no placement, we delete all placements with the ID + if (placement_id == 0) { + var it = self.placements.iterator(); + while (it.next()) |entry| { + if (entry.key_ptr.image_id == image_id) { + entry.value_ptr.deinit(t); + self.placements.removeByPtr(entry.key_ptr); + } + } + } else { + if (self.placements.getEntry(.{ + .image_id = image_id, + .placement_id = .{ .tag = .external, .id = placement_id }, + })) |entry| { + entry.value_ptr.deinit(t); + self.placements.removeByPtr(entry.key_ptr); + } + } + + // If this is specified, then we also delete the image + // if it is no longer in use. + if (delete_unused) self.deleteIfUnused(alloc, image_id); + + // Mark dirty to force redraw + self.dirty = true; + } + + /// Delete an image if it is unused. + fn deleteIfUnused(self: *ImageStorage, alloc: Allocator, image_id: u32) void { + var it = self.placements.iterator(); + while (it.next()) |kv| { + if (kv.key_ptr.image_id == image_id) { + return; + } + } + + // If we get here, we can delete the image. + if (self.images.getEntry(image_id)) |entry| { + self.total_bytes -= entry.value_ptr.data.len; + entry.value_ptr.deinit(alloc); + self.images.removeByPtr(entry.key_ptr); + } + } + + /// Deletes all placements intersecting a screen point. + fn deleteIntersecting( + self: *ImageStorage, + alloc: Allocator, + t: *const terminal.Terminal, + p: point.ScreenPoint, + delete_unused: bool, + filter_ctx: anytype, + comptime filter: ?fn (@TypeOf(filter_ctx), Placement) bool, + ) void { + var it = self.placements.iterator(); + while (it.next()) |entry| { + const img = self.imageById(entry.key_ptr.image_id) orelse continue; + const rect = entry.value_ptr.rect(img, t); + if (rect.contains(p)) { + if (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue; + self.placements.removeByPtr(entry.key_ptr); + if (delete_unused) self.deleteIfUnused(alloc, img.id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + } + + /// Evict image to make space. This will evict the oldest image, + /// prioritizing unused images first, as recommended by the published + /// Kitty spec. + /// + /// This will evict as many images as necessary to make space for + /// req bytes. + fn evictImage(self: *ImageStorage, alloc: Allocator, req: usize) !bool { + assert(req <= self.total_limit); + + // Ironically we allocate to evict. We should probably redesign the + // data structures to avoid this but for now allocating a little + // bit is fine compared to the megabytes we're looking to save. + const Candidate = struct { + id: u32, + time: std.time.Instant, + used: bool, + }; + + var candidates = std.ArrayList(Candidate).init(alloc); + defer candidates.deinit(); + + var it = self.images.iterator(); + while (it.next()) |kv| { + const img = kv.value_ptr; + + // This is a huge waste. See comment above about redesigning + // our data structures to avoid this. Eviction should be very + // rare though and we never have that many images/placements + // so hopefully this will last a long time. + const used = used: { + var p_it = self.placements.iterator(); + while (p_it.next()) |p_kv| { + if (p_kv.key_ptr.image_id == img.id) { + break :used true; + } + } + + break :used false; + }; + + try candidates.append(.{ + .id = img.id, + .time = img.transmit_time, + .used = used, + }); + } + + // Sort + std.mem.sortUnstable( + Candidate, + candidates.items, + {}, + struct { + fn lessThan( + ctx: void, + lhs: Candidate, + rhs: Candidate, + ) bool { + _ = ctx; + + // If they're usage matches, then its based on time. + if (lhs.used == rhs.used) return switch (lhs.time.order(rhs.time)) { + .lt => true, + .gt => false, + .eq => lhs.id < rhs.id, + }; + + // If not used, then its a better candidate + return !lhs.used; + } + }.lessThan, + ); + + // They're in order of best to evict. + var evicted: usize = 0; + for (candidates.items) |c| { + // Delete all the placements for this image and the image. + var p_it = self.placements.iterator(); + while (p_it.next()) |entry| { + if (entry.key_ptr.image_id == c.id) { + self.placements.removeByPtr(entry.key_ptr); + } + } + + if (self.images.getEntry(c.id)) |entry| { + log.info("evicting image id={} bytes={}", .{ c.id, entry.value_ptr.data.len }); + + evicted += entry.value_ptr.data.len; + self.total_bytes -= entry.value_ptr.data.len; + + entry.value_ptr.deinit(alloc); + self.images.removeByPtr(entry.key_ptr); + + if (evicted > req) return true; + } + } + + return false; + } + + /// Every placement is uniquely identified by the image ID and the + /// placement ID. If an image ID isn't specified it is assumed to be 0. + /// Likewise, if a placement ID isn't specified it is assumed to be 0. + pub const PlacementKey = struct { + image_id: u32, + placement_id: packed struct { + tag: enum(u1) { internal, external }, + id: u32, + }, + }; + + pub const Placement = struct { + /// The tracked pin for this placement. + pin: *PageList.Pin, + + /// Offset of the x/y from the top-left of the cell. + x_offset: u32 = 0, + y_offset: u32 = 0, + + /// Source rectangle for the image to pull from + source_x: u32 = 0, + source_y: u32 = 0, + source_width: u32 = 0, + source_height: u32 = 0, + + /// The columns/rows this image occupies. + columns: u32 = 0, + rows: u32 = 0, + + /// The z-index for this placement. + z: i32 = 0, + + pub fn deinit( + self: *const Placement, + t: *terminal.Terminal, + ) void { + t.screen.pages.untrackPin(self.pin); + } + + /// Returns a selection of the entire rectangle this placement + /// occupies within the screen. + pub fn rect( + self: Placement, + image: Image, + t: *const terminal.Terminal, + ) Rect { + // If we have columns/rows specified we can simplify this whole thing. + if (self.columns > 0 and self.rows > 0) { + return .{ + .top_left = self.point, + .bottom_right = .{ + .x = @min(self.point.x + self.columns, t.cols - 1), + .y = self.point.y + self.rows, + }, + }; + } + + // Calculate our cell size. + const terminal_width_f64: f64 = @floatFromInt(t.width_px); + const terminal_height_f64: f64 = @floatFromInt(t.height_px); + const grid_columns_f64: f64 = @floatFromInt(t.cols); + const grid_rows_f64: f64 = @floatFromInt(t.rows); + const cell_width_f64 = terminal_width_f64 / grid_columns_f64; + const cell_height_f64 = terminal_height_f64 / grid_rows_f64; + + // Our image width + const width_px = if (self.source_width > 0) self.source_width else image.width; + const height_px = if (self.source_height > 0) self.source_height else image.height; + + // Calculate our image size in grid cells + const width_f64: f64 = @floatFromInt(width_px); + const height_f64: f64 = @floatFromInt(height_px); + const width_cells: u32 = @intFromFloat(@ceil(width_f64 / cell_width_f64)); + const height_cells: u32 = @intFromFloat(@ceil(height_f64 / cell_height_f64)); + + return .{ + .top_left = self.point, + .bottom_right = .{ + .x = @min(self.point.x + width_cells, t.cols - 1), + .y = self.point.y + height_cells, + }, + }; + } + }; +}; + +// Our pin for the placement +fn trackPin( + t: *terminal.Terminal, + pt: point.Point.Coordinate, +) !*PageList.Pin { + return try t.screen.pages.trackPin(t.screen.pages.pin(.{ + .active = pt, + }).?); +} + +test "storage: add placement with zero placement id" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, 100, 100); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, &t); + try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(alloc, 1, 0, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); + try s.addPlacement(alloc, 1, 0, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); + + try testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .internal, .id = 0 }, + }) != null); + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .internal, .id = 1 }, + }) != null); +} + +test "storage: delete all placements and images" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, 3, 3); + defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, &t); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + + s.dirty = false; + s.delete(alloc, &t, .{ .all = true }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 0), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); +} + +test "storage: delete all placements and images preserves limit" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, 3, 3); + defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, &t); + s.total_limit = 5000; + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + + s.dirty = false; + s.delete(alloc, &t, .{ .all = true }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 0), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(@as(usize, 5000), s.total_limit); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); +} + +test "storage: delete all placements" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, 3, 3); + defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, &t); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + + s.dirty = false; + s.delete(alloc, &t, .{ .all = false }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); +} + +test "storage: delete all placements by image id" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, 3, 3); + defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, &t); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + + s.dirty = false; + s.delete(alloc, &t, .{ .id = .{ .image_id = 2 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); +} + +test "storage: delete all placements by image id and unused images" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, 3, 3); + defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, &t); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + + s.dirty = false; + s.delete(alloc, &t, .{ .id = .{ .delete = true, .image_id = 2 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); +} + +test "storage: delete placement by specific id" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, 3, 3); + defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, &t); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + + s.dirty = false; + s.delete(alloc, &t, .{ .id = .{ + .delete = true, + .image_id = 1, + .placement_id = 2, + } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked + 2, t.screen.pages.countTrackedPins()); +} + +// test "storage: delete intersecting cursor" { +// const testing = std.testing; +// const alloc = testing.allocator; +// var t = try terminal.Terminal.init(alloc, 100, 100); +// defer t.deinit(alloc); +// t.width_px = 100; +// t.height_px = 100; +// +// var s: ImageStorage = .{}; +// defer s.deinit(alloc); +// try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); +// try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); +// try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); +// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); +// +// t.screen.cursor.x = 12; +// t.screen.cursor.y = 12; +// +// s.dirty = false; +// s.delete(alloc, &t, .{ .intersect_cursor = false }); +// try testing.expect(s.dirty); +// try testing.expectEqual(@as(usize, 1), s.placements.count()); +// try testing.expectEqual(@as(usize, 2), s.images.count()); +// +// // verify the placement is what we expect +// try testing.expect(s.placements.get(.{ +// .image_id = 1, +// .placement_id = .{ .tag = .external, .id = 2 }, +// }) != null); +// } +// +// test "storage: delete intersecting cursor plus unused" { +// const testing = std.testing; +// const alloc = testing.allocator; +// var t = try terminal.Terminal.init(alloc, 100, 100); +// defer t.deinit(alloc); +// t.width_px = 100; +// t.height_px = 100; +// +// var s: ImageStorage = .{}; +// defer s.deinit(alloc); +// try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); +// try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); +// try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); +// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); +// +// t.screen.cursor.x = 12; +// t.screen.cursor.y = 12; +// +// s.dirty = false; +// s.delete(alloc, &t, .{ .intersect_cursor = true }); +// try testing.expect(s.dirty); +// try testing.expectEqual(@as(usize, 1), s.placements.count()); +// try testing.expectEqual(@as(usize, 2), s.images.count()); +// +// // verify the placement is what we expect +// try testing.expect(s.placements.get(.{ +// .image_id = 1, +// .placement_id = .{ .tag = .external, .id = 2 }, +// }) != null); +// } +// +// test "storage: delete intersecting cursor hits multiple" { +// const testing = std.testing; +// const alloc = testing.allocator; +// var t = try terminal.Terminal.init(alloc, 100, 100); +// defer t.deinit(alloc); +// t.width_px = 100; +// t.height_px = 100; +// +// var s: ImageStorage = .{}; +// defer s.deinit(alloc); +// try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); +// try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); +// try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); +// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); +// +// t.screen.cursor.x = 26; +// t.screen.cursor.y = 26; +// +// s.dirty = false; +// s.delete(alloc, &t, .{ .intersect_cursor = true }); +// try testing.expect(s.dirty); +// try testing.expectEqual(@as(usize, 0), s.placements.count()); +// try testing.expectEqual(@as(usize, 1), s.images.count()); +// } +// +// test "storage: delete by column" { +// const testing = std.testing; +// const alloc = testing.allocator; +// var t = try terminal.Terminal.init(alloc, 100, 100); +// defer t.deinit(alloc); +// t.width_px = 100; +// t.height_px = 100; +// +// var s: ImageStorage = .{}; +// defer s.deinit(alloc); +// try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); +// try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); +// try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); +// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); +// +// s.dirty = false; +// s.delete(alloc, &t, .{ .column = .{ +// .delete = false, +// .x = 60, +// } }); +// try testing.expect(s.dirty); +// try testing.expectEqual(@as(usize, 1), s.placements.count()); +// try testing.expectEqual(@as(usize, 2), s.images.count()); +// +// // verify the placement is what we expect +// try testing.expect(s.placements.get(.{ +// .image_id = 1, +// .placement_id = .{ .tag = .external, .id = 1 }, +// }) != null); +// } +// +// test "storage: delete by row" { +// const testing = std.testing; +// const alloc = testing.allocator; +// var t = try terminal.Terminal.init(alloc, 100, 100); +// defer t.deinit(alloc); +// t.width_px = 100; +// t.height_px = 100; +// +// var s: ImageStorage = .{}; +// defer s.deinit(alloc); +// try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); +// try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); +// try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); +// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); +// +// s.dirty = false; +// s.delete(alloc, &t, .{ .row = .{ +// .delete = false, +// .y = 60, +// } }); +// try testing.expect(s.dirty); +// try testing.expectEqual(@as(usize, 1), s.placements.count()); +// try testing.expectEqual(@as(usize, 2), s.images.count()); +// +// // verify the placement is what we expect +// try testing.expect(s.placements.get(.{ +// .image_id = 1, +// .placement_id = .{ .tag = .external, .id = 1 }, +// }) != null); +// } diff --git a/src/terminal2/kitty/key.zig b/src/terminal2/kitty/key.zig new file mode 100644 index 000000000..938bf65b5 --- /dev/null +++ b/src/terminal2/kitty/key.zig @@ -0,0 +1,151 @@ +//! Kitty keyboard protocol support. + +const std = @import("std"); + +/// Stack for the key flags. This implements the push/pop behavior +/// of the CSI > u and CSI < u sequences. We implement the stack as +/// fixed size to avoid heap allocation. +pub const KeyFlagStack = struct { + const len = 8; + + flags: [len]KeyFlags = .{.{}} ** len, + idx: u3 = 0, + + /// Return the current stack value + pub fn current(self: KeyFlagStack) KeyFlags { + return self.flags[self.idx]; + } + + /// Perform the "set" operation as described in the spec for + /// the CSI = u sequence. + pub fn set( + self: *KeyFlagStack, + mode: KeySetMode, + v: KeyFlags, + ) void { + switch (mode) { + .set => self.flags[self.idx] = v, + .@"or" => self.flags[self.idx] = @bitCast( + self.flags[self.idx].int() | v.int(), + ), + .not => self.flags[self.idx] = @bitCast( + self.flags[self.idx].int() & ~v.int(), + ), + } + } + + /// Push a new set of flags onto the stack. If the stack is full + /// then the oldest entry is evicted. + pub fn push(self: *KeyFlagStack, flags: KeyFlags) void { + // Overflow and wrap around if we're full, which evicts + // the oldest entry. + self.idx +%= 1; + self.flags[self.idx] = flags; + } + + /// Pop `n` entries from the stack. This will just wrap around + /// if `n` is greater than the amount in the stack. + pub fn pop(self: *KeyFlagStack, n: usize) void { + // If n is more than our length then we just reset the stack. + // This also avoids a DoS vector where a malicious client + // could send a huge number of pop commands to waste cpu. + if (n >= self.flags.len) { + self.idx = 0; + self.flags = .{.{}} ** len; + return; + } + + for (0..n) |_| { + self.flags[self.idx] = .{}; + self.idx -%= 1; + } + } + + // Make sure we the overflow works as expected + test { + const testing = std.testing; + var stack: KeyFlagStack = .{}; + stack.idx = stack.flags.len - 1; + stack.idx +%= 1; + try testing.expect(stack.idx == 0); + + stack.idx = 0; + stack.idx -%= 1; + try testing.expect(stack.idx == stack.flags.len - 1); + } +}; + +/// The possible flags for the Kitty keyboard protocol. +pub const KeyFlags = packed struct(u5) { + disambiguate: bool = false, + report_events: bool = false, + report_alternates: bool = false, + report_all: bool = false, + report_associated: bool = false, + + pub fn int(self: KeyFlags) u5 { + return @bitCast(self); + } + + // Its easy to get packed struct ordering wrong so this test checks. + test { + const testing = std.testing; + + try testing.expectEqual( + @as(u5, 0b1), + (KeyFlags{ .disambiguate = true }).int(), + ); + try testing.expectEqual( + @as(u5, 0b10), + (KeyFlags{ .report_events = true }).int(), + ); + } +}; + +/// The possible modes for setting the key flags. +pub const KeySetMode = enum { set, @"or", not }; + +test "KeyFlagStack: push pop" { + const testing = std.testing; + var stack: KeyFlagStack = .{}; + stack.push(.{ .disambiguate = true }); + try testing.expectEqual( + KeyFlags{ .disambiguate = true }, + stack.current(), + ); + + stack.pop(1); + try testing.expectEqual(KeyFlags{}, stack.current()); +} + +test "KeyFlagStack: pop big number" { + const testing = std.testing; + var stack: KeyFlagStack = .{}; + stack.pop(100); + try testing.expectEqual(KeyFlags{}, stack.current()); +} + +test "KeyFlagStack: set" { + const testing = std.testing; + var stack: KeyFlagStack = .{}; + stack.set(.set, .{ .disambiguate = true }); + try testing.expectEqual( + KeyFlags{ .disambiguate = true }, + stack.current(), + ); + + stack.set(.@"or", .{ .report_events = true }); + try testing.expectEqual( + KeyFlags{ + .disambiguate = true, + .report_events = true, + }, + stack.current(), + ); + + stack.set(.not, .{ .report_events = true }); + try testing.expectEqual( + KeyFlags{ .disambiguate = true }, + stack.current(), + ); +} diff --git a/src/terminal2/kitty/testdata/image-png-none-50x76-2147483647-raw.data b/src/terminal2/kitty/testdata/image-png-none-50x76-2147483647-raw.data new file mode 100644 index 0000000000000000000000000000000000000000..032cb07c722cfd7ee5dd701e3a7407ddcaafc565 GIT binary patch literal 86 zcmeAS@N?(olHy`uVBq!ia0y~yU@&4}VDMpNW?*0ty;