diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/terminal/kitty | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/kitty')
13 files changed, 5745 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/terminal/kitty/color.zig b/vendor/libghostty-vt/src/terminal/kitty/color.zig new file mode 100644 index 00000000..c1072c39 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/color.zig @@ -0,0 +1,77 @@ +const std = @import("std"); +const terminal = @import("../main.zig"); +const RGB = terminal.color.RGB; +const Terminator = terminal.osc.Terminator; + +pub const OSC = struct { + pub const Request = union(enum) { + query: Kind, + set: struct { key: Kind, color: RGB }, + reset: Kind, + }; + + /// list of requests + list: std.ArrayList(Request), + + /// We must reply with the same string terminator (ST) as used in the + /// request. + terminator: Terminator = .st, + + pub fn deinit(self: *OSC, alloc: std.mem.Allocator) void { + self.list.deinit(alloc); + } + + /// We don't currently support encoding this to C in any way. + pub const C = void; + + pub fn cval(_: OSC) C { + return {}; + } +}; + +pub const Special = enum { + foreground, + background, + selection_foreground, + selection_background, + cursor, + cursor_text, + visual_bell, + second_transparent_background, +}; + +pub const Kind = union(enum) { + pub const max: usize = std.math.maxInt(u8) + @typeInfo(Special).@"enum".fields.len; + + palette: u8, + special: Special, + + pub fn parse(key: []const u8) ?Kind { + if (std.meta.stringToEnum(Special, key)) |s| return .{ .special = s }; + return .{ .palette = std.fmt.parseUnsigned(u8, key, 10) catch return null }; + } + + pub fn format( + self: Kind, + writer: *std.Io.Writer, + ) !void { + switch (self) { + .palette => |p| try writer.print("{d}", .{p}), + .special => |s| try writer.print("{s}", .{@tagName(s)}), + } + } +}; + +test "OSC: kitty color protocol kind string" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + { + const actual = try std.fmt.bufPrint(&buf, "{f}", .{Kind{ .special = .foreground }}); + try testing.expectEqualStrings("foreground", actual); + } + { + const actual = try std.fmt.bufPrint(&buf, "{f}", .{Kind{ .palette = 42 }}); + try testing.expectEqualStrings("42", actual); + } +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig new file mode 100644 index 00000000..c710f81a --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig @@ -0,0 +1,36 @@ +//! 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. + +const render = @import("graphics_render.zig"); +const command = @import("graphics_command.zig"); +const exec = @import("graphics_exec.zig"); +const image = @import("graphics_image.zig"); +const storage = @import("graphics_storage.zig"); +pub const unicode = @import("graphics_unicode.zig"); +pub const Command = command.Command; +pub const CommandParser = command.Parser; +pub const Image = image.Image; +pub const ImageStorage = storage.ImageStorage; +pub const RenderPlacement = render.Placement; +pub const Response = command.Response; + +pub const execute = exec.execute; + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig new file mode 100644 index 00000000..dfce56e3 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig @@ -0,0 +1,1325 @@ +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const simd = @import("../../simd/main.zig"); + +const log = std.log.scoped(.kitty_gfx); + +/// The key-value pairs for the control information for a command. The +/// keys are always single characters and the values are either single +/// characters or 32-bit unsigned integers. +/// +/// 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 Parser = struct { + /// The memory used by the parser is stored in an arena because it is + /// all freed at the end of the command. + arena: ArenaAllocator, + + /// 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), plus one character for the sign bit on signed ints. + kv_temp: [11]u8, + kv_temp_len: u4, + kv_current: u8, // Current kv key + + /// This is the list we use to collect the bytes from the data payload. + /// The Kitty Graphics protocol specification seems to imply that the + /// payload content of a single command should never exceed 4096 bytes, + /// but Kitty itself supports larger payloads, so we use an ArrayList + /// here instead of a fixed buffer so that we can too. + data: std.ArrayList(u8), + + /// Internal state for parsing. + state: State, + + 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, + + /// Collecting the data payload 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) Parser { + var arena = ArenaAllocator.init(alloc); + errdefer arena.deinit(); + var result: Parser = .{ + .arena = arena, + .data = .empty, + .kv = .{}, + .kv_temp_len = 0, + .kv_current = 0, + .state = .control_key, + + .kv_temp = undefined, + }; + if (std.valgrind.runningOnValgrind() > 0) { + // Initialize our undefined fields so Valgrind can catch it. + // https://github.com/ziglang/zig/issues/19148 + result.kv_temp = undefined; + } + return result; + } + + pub fn deinit(self: *Parser) void { + // We don't free the hash map because its in the arena + self.data.deinit(self.arena.child_allocator); + self.arena.deinit(); + } + + /// Parse a complete command string. + pub fn parseString(alloc: Allocator, data: []const u8) !Command { + var parser = init(alloc); + defer parser.deinit(); + for (data) |c| try parser.feed(c); + return try parser.complete(alloc); + } + + /// 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: *Parser, 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; + }, + + // This can be encountered if we have a sequence with no + // control data, only payload data (i.e. "\x1b_G;<data>"). + // + // Kitty treats this as valid so we do as well. + ';' => self.state = .data, + + 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(self.arena.child_allocator, c), + } + } + + /// 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: *Parser, alloc: Allocator) !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 .parse(self.kv) }, + 't' => .{ .transmit = try .parse(self.kv) }, + 'T' => .{ .transmit_and_display = .{ + .transmission = try .parse(self.kv), + .display = try .parse(self.kv), + } }, + 'p' => .{ .display = try .parse(self.kv) }, + 'd' => .{ .delete = try .parse(self.kv) }, + 'f' => .{ .transmit_animation_frame = try .parse(self.kv) }, + 'a' => .{ .control_animation = try .parse(self.kv) }, + 'c' => .{ .compose_animation = try .parse(self.kv) }, + 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 = try self.decodeData(alloc), + }; + } + + /// Decodes the payload data from base64 and returns it as a slice. + /// This function will destroy the contents of self.data, it should + /// only be used once we are done collecting payload bytes. + fn decodeData(self: *Parser, alloc: Allocator) ![]const u8 { + if (self.data.items.len == 0) { + return ""; + } + + const max_len = simd.base64.maxLen(self.data.items); + assert(max_len <= self.data.items.len); + + // This is kinda cursed, but we can decode the base64 on top of + // itself, since it's guaranteed that the encoded size is larger, + // and any bytes in areas that are written to will have already + // been used (assuming scalar decoding). + const decoded = simd.base64.decode( + self.data.items, + self.data.items[0..max_len], + ) catch |err| { + log.warn("failed to decode base64 payload data: {}", .{err}); + return error.InvalidData; + }; + assert(decoded.len <= max_len); + + // Remove the extra bytes. + self.data.items.len = decoded.len; + + return try self.data.toOwnedSlice(alloc); + } + + fn accumulateValue(self: *Parser, 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: *Parser, next_state: State) !void { + const alloc = self.arena.allocator(); + + // We can move states right away, we don't use it. + self.state = next_state; + + // 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; + } + } + + // Handle integer fields, parsing signed fields accordingly. We still + // store the fields as u32 as they can be bitcast back later during + // building of the higher-level command tree. + const v: u32 = switch (self.kv_current) { + 'z', 'H', 'V' => @bitCast(try std.fmt.parseInt(i32, self.kv_temp[0..self.kv_temp_len], 10)), + else => try std.fmt.parseInt(u32, self.kv_temp[0..self.kv_temp_len], 10), + }; + try self.kv.put(alloc, self.kv_current, v); + + // 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: *std.Io.Writer) !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; + + // Used to cheaply keep track if we need to add a comma before + // the next key-value pair. + var prior: bool = false; + + try writer.writeAll("\x1b_G"); + if (self.id > 0) { + prior = true; + try writer.print("i={}", .{self.id}); + } + if (self.image_number > 0) { + if (prior) try writer.writeByte(',') else prior = true; + try writer.print("I={}", .{self.image_number}); + } + if (self.placement_id > 0) { + if (prior) try writer.writeByte(',') else prior = true; + 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"); + } + + /// Empty response + pub fn empty(self: Response) bool { + return self.id == 0 and self.image_number == 0; + } +}; + +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 = .rgba, // 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. + gray_alpha, + gray, + + pub fn bpp(self: Format) u8 { + return switch (self) { + .gray => 1, + .gray_alpha => 2, + .rgb => 3, + .rgba => 4, + .png => unreachable, // Must be validated before + }; + } + }; + + 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 the transmission medium is a local-only medium, ignore the "m" + // key. The Kitty graphics protocol specification does not explicitly + // call out this behavior (although the "m" key is only mentioned in + // connection with remote clients) but that's how it's implemented in + // Kitty and at least one client (mpv) relies on this behavior when + // using the shared memory transmission medium. + // + // https://sw.kovidgoyal.net/kitty/graphics-protocol/#the-transmission-medium + // https://github.com/kovidgoyal/kitty/blob/ccc3bee9af794f332b4e9adcd714a649f639c397/kitty/graphics.c#L547-L592 + if (result.medium == .direct) { + 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 + parent_id: u32 = 0, // P + parent_placement_id: u32 = 0, // Q + horizontal_offset: i32 = 0, // H + vertical_offset: i32 = 0, // V + 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); + } + + if (kv.get('P')) |v| { + result.parent_id = v; + } + + if (kv.get('Q')) |v| { + result.parent_placement_id = v; + } + + if (kv.get('H')) |v| { + // We can bitcast here because of how we parse it earlier. + result.horizontal_offset = @bitCast(v); + } + + if (kv.get('V')) |v| { + // We can bitcast here because of how we parse it earlier. + result.vertical_offset = @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 + }, + + // r/R + range: struct { + delete: bool = false, // uppercase + first: u32 = 0, // x + last: u32 = 0, // y + }, + + // x/X + column: struct { + delete: bool = false, // uppercase + x: u32 = 0, // x + }, + + // y/Y + row: struct { + delete: bool = false, // uppercase + y: u32 = 0, // y + }, + + // z/Z + z: struct { + delete: bool = false, // uppercase + z: i32 = 0, // z + }, + + 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; + }, + + 'r', 'R' => blk: { + const x = kv.get('x') orelse return error.InvalidFormat; + const y = kv.get('y') orelse return error.InvalidFormat; + if (x > y) return error.InvalidFormat; + break :blk .{ + .range = .{ + .delete = what == 'R', + .first = x, + .last = y, + }, + }; + }, + + '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 = Parser.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(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + const v = command.control.transmit; + try testing.expectEqual(Transmission.Format.rgb, v.format); + try testing.expectEqual(@as(u32, 10), v.width); + try testing.expectEqual(@as(u32, 20), v.height); +} + +test "transmission ignores 'm' if medium is not direct" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=t,t=t,m=1"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + const v = command.control.transmit; + try testing.expectEqual(Transmission.Medium.temporary_file, v.medium); + try testing.expect(!v.more_chunks); +} + +test "transmission respects 'm' if medium is direct" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=t,t=d,m=1"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + const v = command.control.transmit; + try testing.expectEqual(Transmission.Medium.direct, v.medium); + try testing.expect(v.more_chunks); +} + +test "query command" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "i=31,s=1,v=1,a=q,t=d,f=24;QUFBQQ"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + 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 = Parser.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(alloc); + 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 = Parser.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(alloc); + 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 "no control data" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = ";QUFBQQ"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + try testing.expectEqualStrings("AAAA", command.data); +} + +test "ignore unknown keys (long)" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.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(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + const v = command.control.transmit; + try testing.expectEqual(Transmission.Format.rgb, v.format); + try testing.expectEqual(@as(u32, 10), v.width); + try testing.expectEqual(@as(u32, 20), v.height); +} + +test "ignore very long values" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.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(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + const v = command.control.transmit; + try testing.expectEqual(Transmission.Format.rgb, v.format); + try testing.expectEqual(@as(u32, 10), v.width); + try testing.expectEqual(@as(u32, 0), v.height); +} + +test "ensure very large negative values don't get skipped" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=p,i=1,z=-2000000000"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .display); + const v = command.control.display; + try testing.expectEqual(1, v.image_id); + try testing.expectEqual(-2000000000, v.z); +} + +test "ensure proper overflow error for u32" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=p,i=10000000000"; + for (input) |c| try p.feed(c); + try testing.expectError(error.Overflow, p.complete(alloc)); +} + +test "ensure proper overflow error for i32" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=p,i=1,z=-9999999999"; + for (input) |c| try p.feed(c); + try testing.expectError(error.Overflow, p.complete(alloc)); +} + +test "all i32 values" { + const testing = std.testing; + const alloc = testing.allocator; + + { + // 'z' (usually z-axis values) + var p = Parser.init(alloc); + defer p.deinit(); + const input = "a=p,i=1,z=-1"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .display); + const v = command.control.display; + try testing.expectEqual(1, v.image_id); + try testing.expectEqual(-1, v.z); + } + + { + // 'H' (relative placement, horizontal offset) + var p = Parser.init(alloc); + defer p.deinit(); + const input = "a=p,i=1,H=-1"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .display); + const v = command.control.display; + try testing.expectEqual(1, v.image_id); + try testing.expectEqual(-1, v.horizontal_offset); + } + + { + // 'V' (relative placement, vertical offset) + var p = Parser.init(alloc); + defer p.deinit(); + const input = "a=p,i=1,V=-1"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .display); + const v = command.control.display; + try testing.expectEqual(1, v.image_id); + try testing.expectEqual(-1, v.vertical_offset); + } +} + +test "response: encode nothing without ID or image number" { + const testing = std.testing; + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + var r: Response = .{}; + try r.encode(&writer); + try testing.expectEqualStrings("", writer.buffered()); +} + +test "response: encode with only image id" { + const testing = std.testing; + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + var r: Response = .{ .id = 4 }; + try r.encode(&writer); + try testing.expectEqualStrings("\x1b_Gi=4;OK\x1b\\", writer.buffered()); +} + +test "response: encode with only image number" { + const testing = std.testing; + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + var r: Response = .{ .image_number = 4 }; + try r.encode(&writer); + try testing.expectEqualStrings("\x1b_GI=4;OK\x1b\\", writer.buffered()); +} + +test "response: encode with image ID and number" { + const testing = std.testing; + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + var r: Response = .{ .id = 12, .image_number = 4 }; + try r.encode(&writer); + try testing.expectEqualStrings("\x1b_Gi=12,I=4;OK\x1b\\", writer.buffered()); +} + +test "delete range command 1" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=d,d=r,x=3,y=4"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .delete); + const v = command.control.delete; + try testing.expect(v == .range); + const range = v.range; + try testing.expect(!range.delete); + try testing.expectEqual(@as(u32, 3), range.first); + try testing.expectEqual(@as(u32, 4), range.last); +} + +test "delete range command 2" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=d,d=R,x=5,y=11"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .delete); + const v = command.control.delete; + try testing.expect(v == .range); + const range = v.range; + try testing.expect(range.delete); + try testing.expectEqual(@as(u32, 5), range.first); + try testing.expectEqual(@as(u32, 11), range.last); +} + +test "delete range command 3" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=d,d=R,x=5,y=4"; + for (input) |c| try p.feed(c); + try testing.expectError(error.InvalidFormat, p.complete(alloc)); +} + +test "delete range command 4" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=d,d=R,x=5"; + for (input) |c| try p.feed(c); + try testing.expectError(error.InvalidFormat, p.complete(alloc)); +} + +test "delete range command 5" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=d,d=R,y=5"; + for (input) |c| try p.feed(c); + try testing.expectError(error.InvalidFormat, p.complete(alloc)); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig new file mode 100644 index 00000000..5b3ab915 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig @@ -0,0 +1,570 @@ +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; + +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: *const 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.screens.active.kitty_images.enabled()) { + log.debug("kitty graphics requested but disabled", .{}); + return null; + } + + log.debug("executing kitty graphics command: quiet={} control={}", .{ + cmd.quiet, + cmd.control, + }); + + // The quiet settings used to control the response. We have to make this + // a var because in certain special cases (namely chunked transmissions) + // this can change. + var quiet = cmd.quiet; + + const resp_: ?Response = switch (cmd.control) { + .query => query(alloc, cmd), + .display => display(alloc, terminal, cmd), + .delete => delete(alloc, terminal, cmd), + + .transmit, .transmit_and_display => resp: { + // If we're transmitting, then our `q` setting value is complicated. + // The `q` setting inherits the value from the starting command + // unless `q` is set >= 1 on this command. If it is, then we save + // that as the new `q` setting. + const storage = &terminal.screens.active.kitty_images; + if (storage.loading) |loading| switch (cmd.quiet) { + // q=0 we use whatever the start command value is + .no => quiet = loading.quiet, + + // q>=1 we use the new value, but we should already be set to it + inline .ok, .failures => |tag| { + assert(quiet == tag); + loading.quiet = tag; + }, + }; + + break :resp transmit(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 (quiet) { + .no => if (resp.empty()) null else 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: *const 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: *const 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; + result = display(alloc, terminal, &.{ + .control = .{ .display = d_copy }, + .quiet = cmd.quiet, + }); + } + + // If there are more chunks expected we do not respond. + if (load.more) return .{}; + + // If the loaded image was assigned its ID automatically, not based + // on a number or explicitly specified ID, then we don't respond. + if (load.image.implicit_id) return .{}; + + // After the image is added, set the ID in case it changed. + // The resulting image number and placement ID never change. + result.id = load.image.id; + + 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.screens.active.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 = "ENOENT: image not found"; + return result; + }; + + // Make sure our response has the image id in case we looked up by number + result.id = img.id; + + // Location where the placement will go. + const location: ImageStorage.Placement.Location = location: { + // Virtual placements are not tracked + if (d.virtual_placement) { + if (d.parent_id > 0) { + result.message = "EINVAL: virtual placement cannot refer to a parent"; + return result; + } + + break :location .{ .virtual = {} }; + } + + // Track a new pin for our cursor. The cursor is always tracked but we + // don't want this one to move with the cursor. + const pin = terminal.screens.active.pages.trackPin( + terminal.screens.active.cursor.page_pin.*, + ) catch |err| { + log.warn("failed to create pin for Kitty graphics err={}", .{err}); + result.message = "EINVAL: failed to prepare terminal state"; + return result; + }; + break :location .{ .pin = pin }; + }; + + // Add the placement + const p: ImageStorage.Placement = .{ + .location = location, + .x_offset = d.x_offset, + .y_offset = d.y_offset, + .source_x = d.x, + .source_y = d.y, + .source_width = d.width, + .source_height = d.height, + .columns = d.columns, + .rows = d.rows, + .z = d.z, + }; + storage.addPlacement( + alloc, + img.id, + result.placement_id, + p, + ) catch |err| { + p.deinit(terminal.screens.active); + encodeError(&result, err); + return result; + }; + + // Apply cursor movement setting. This only applies to pin placements. + switch (p.location) { + .virtual => {}, + .pin => |pin| switch (d.cursor_movement) { + .none => {}, + .after => { + // We use terminal.index to properly handle scroll regions. + const size = p.gridSize(img, terminal); + for (0..size.rows) |_| terminal.index() catch |err| { + log.warn("failed to move cursor: {}", .{err}); + break; + }; + + terminal.setCursorPos( + terminal.screens.active.cursor.y, + pin.x + size.cols + 1, + ); + }, + }, + } + + return result; +} + +/// Display a previously transmitted image. +fn delete( + alloc: Allocator, + terminal: *Terminal, + cmd: *const Command, +) Response { + const storage = &terminal.screens.active.kitty_images; + storage.delete(alloc, terminal, cmd.control.delete); + + // Delete never responds on success + return .{}; +} + +fn loadAndAddImage( + alloc: Allocator, + terminal: *Terminal, + cmd: *const Command, +) !struct { + image: Image, + more: bool = false, + display: ?command.Display = null, +} { + const t = cmd.transmission().?; + const storage = &terminal.screens.active.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 .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 the image also has no number then its auto-ID is "implicit". + // See the doc comment on the Image.implicit_id field for more detail. + if (loading.image.number == 0) loading.image.implicit_id = true; + } + + // If 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.TemporaryFileNotNamedCorrectly => r.message = "EINVAL: temporary file not named correctly", + 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", + } +} + +test "kittygfx more chunks with q=1" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Initial chunk has q=1 + { + const cmd = try command.Parser.parseString( + alloc, + "a=T,f=24,t=d,i=1,s=1,v=2,c=10,r=1,m=1,q=1;////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd); + try testing.expect(resp == null); + } + + // Subsequent chunk has no q but should respect initial + { + const cmd = try command.Parser.parseString( + alloc, + "m=0;////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd); + try testing.expect(resp == null); + } +} + +test "kittygfx more chunks with q=0" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Initial chunk has q=0 + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,t=d,s=1,v=2,c=10,r=1,m=1,i=1,q=0;////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd); + try testing.expect(resp == null); + } + + // Subsequent chunk has no q so should respond OK + { + const cmd = try command.Parser.parseString( + alloc, + "m=0;////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } +} + +test "kittygfx more chunks with chunk increasing q" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Initial chunk has q=0 + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,t=d,s=1,v=2,c=10,r=1,m=1,i=1,q=0;////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd); + try testing.expect(resp == null); + } + + // Subsequent chunk sets q=1 so should not respond + { + const cmd = try command.Parser.parseString( + alloc, + "m=0,q=1;////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd); + try testing.expect(resp == null); + } +} + +test "kittygfx default format is rgba" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + const cmd = try command.Parser.parseString( + alloc, + "a=t,t=d,i=1,s=1,v=2,c=10,r=1;///////////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + + const storage = &t.screens.active.kitty_images; + const img = storage.imageById(1).?; + try testing.expectEqual(command.Transmission.Format.rgba, img.format); +} + +test "kittygfx test valid u32 (expect invalid image ID)" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + const cmd = try command.Parser.parseString( + alloc, + "a=p,i=4294967295", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqual(resp.message, "ENOENT: image not found"); +} + +test "kittygfx test valid i32 (expect invalid image ID)" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + const cmd = try command.Parser.parseString( + alloc, + "a=p,i=1,z=-2147483648", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqual(resp.message, "ENOENT: image not found"); +} + +test "kittygfx no response with no image ID or number" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,t=d,s=1,v=2,c=10,r=1,i=0,I=0;////////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd); + try testing.expect(resp == null); + } +} + +test "kittygfx no response with no image ID or number load and display" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + { + const cmd = try command.Parser.parseString( + alloc, + "a=T,f=24,t=d,s=1,v=2,c=10,r=1,i=0,I=0;////////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd); + try testing.expect(resp == null); + } +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig new file mode 100644 index 00000000..d2877cfc --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig @@ -0,0 +1,835 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const posix = std.posix; + +const fastmem = @import("../../fastmem.zig"); +const command = @import("graphics_command.zig"); +const PageList = @import("../PageList.zig"); +const wuffs = @import("wuffs"); + +const temp_dir = struct { + const TempDir = @import("../../os/TempDir.zig"); + const allocTmpDir = @import("../../os/file.zig").allocTmpDir; + const freeTmpDir = @import("../../os/file.zig").freeTmpDir; +}; + +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, + + /// Quiet is the quiet settings for the initial load command. This is + /// used if q isn't set on subsequent chunks. + quiet: command.Command.Quiet, + + /// 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: *const 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(), + .quiet = cmd.quiet, + }; + + // Special case for the direct medium, we just add the chunk directly. + if (t.medium == .direct) { + try result.addData(alloc, cmd.data); + return result; + } + + // Otherwise, the payload data is guaranteed to be a path. + + if (comptime builtin.os.tag != .windows) { + if (std.mem.indexOfScalar(u8, cmd.data, 0) != null) { + // posix.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 = switch (t.medium) { + .direct => unreachable, // handled above + .file, .temporary_file => posix.realpath(cmd.data, &abs_buf) catch |err| { + log.warn("failed to get absolute path: {}", .{err}); + return error.InvalidData; + }, + .shared_memory => cmd.data, + }; + + // 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 { + // windows is currently unsupported, does it support shm? + if (comptime builtin.target.os.tag == .windows) { + return error.UnsupportedMedium; + } + + // libc is required for shm_open + if (comptime !builtin.link_libc) { + return error.UnsupportedMedium; + } + + // Since we're only supporting posix then max_path_bytes should + // be enough to stack allocate the path. + var buf: [std.fs.max_path_bytes]u8 = undefined; + const pathz = std.fmt.bufPrintZ(&buf, "{s}", .{path}) catch return error.InvalidData; + + const fd = std.c.shm_open(pathz, @as(c_int, @bitCast(std.c.O{ .ACCMODE = .RDONLY })), 0); + switch (std.posix.errno(fd)) { + .SUCCESS => {}, + else => |err| { + log.warn("unable to open shared memory {s}: {}", .{ path, err }); + return error.InvalidData; + }, + } + defer _ = std.c.close(fd); + defer _ = std.c.shm_unlink(pathz); + + // The size from stat on may be larger than our expected size because + // shared memory has to be a multiple of the page size. + const stat_size: usize = stat: { + const stat = std.posix.fstat(fd) catch |err| { + log.warn("unable to fstat shared memory {s}: {}", .{ path, err }); + return error.InvalidData; + }; + if (stat.size <= 0) return error.InvalidData; + break :stat @intCast(stat.size); + }; + + const expected_size: usize = switch (self.image.format) { + // Png we decode the full data size because later decoding will + // get the proper dimensions and assert validity. + .png => stat_size, + + // For these formats we have a size we must have. + .gray, .gray_alpha, .rgb, .rgba => |f| size: { + const bpp = f.bpp(); + break :size self.image.width * self.image.height * bpp; + }, + }; + + // Our stat size must be at least the expected size otherwise + // the shared memory data is invalid. + if (stat_size < expected_size) { + log.warn( + "shared memory size too small expected={} actual={}", + .{ expected_size, stat_size }, + ); + return error.InvalidData; + } + + const map = std.posix.mmap( + null, + stat_size, // mmap always uses the stat size + std.c.PROT.READ, + std.c.MAP{ .TYPE = .SHARED }, + fd, + 0, + ) catch |err| { + log.warn("unable to mmap shared memory {s}: {}", .{ path, err }); + return error.InvalidData; + }; + defer std.posix.munmap(map); + + // Our end size always uses the expected size so we cut off the + // padding for mmap alignment. + const start: usize = @intCast(t.offset); + const end: usize = if (t.size > 0) @min( + @as(usize, @intCast(t.offset)) + @as(usize, @intCast(t.size)), + expected_size, + ) else expected_size; + + assert(self.data.items.len == 0); + try self.data.appendSlice(alloc, map[start..end]); + } + + /// 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; + if (std.mem.indexOf(u8, path, "tty-graphics-protocol") == null) { + return error.TemporaryFileNotNamedCorrectly; + } + } + defer if (medium == .temporary_file) { + posix.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: [4096]u8 = undefined; + var buf_reader = file.reader(&buf); + const reader = &buf_reader.interface; + + // Read the file + var managed: std.ArrayList(u8) = .empty; + errdefer managed.deinit(alloc); + const size: usize = if (t.size > 0) @min(t.size, max_size) else max_size; + reader.appendRemaining(alloc, &managed, .limited(size)) catch { + log.warn("failed to read temporary file: {?}", .{buf_reader.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 (temp_dir.allocTmpDir(std.heap.page_allocator)) |dir| { + defer temp_dir.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 (posix.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 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; + + // If our data would get too big, return an error + if (self.data.items.len + data.len > max_size) { + log.warn("image data too large max_size={}", .{max_size}); + return error.InvalidData; + } + + // Ensure we have enough room to add the data + // to the end of the ArrayList before doing so. + try self.data.ensureUnusedCapacity(alloc, data.len); + + const start_i = self.data.items.len; + self.data.items.len = start_i + data.len; + fastmem.copy(u8, self.data.items[start_i..], data); + } + + /// 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 = img.format.bpp(); + 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 buf: [std.compress.flate.max_window_len]u8 = undefined; + var reader: std.Io.Reader = .fixed(self.data.items); + var stream: std.compress.flate.Decompress = .init(&reader, .zlib, &buf); + + // Write it to an array list + var list: std.ArrayList(u8) = .empty; + errdefer list.deinit(alloc); + stream.reader.appendRemaining(alloc, &list, .limited(max_size)) catch { + log.warn("failed to read decompressed data: {?}", .{stream.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); + + const result = wuffs.png.decode( + alloc, + self.data.items, + ) catch |err| switch (err) { + error.WuffsError => return error.InvalidData, + error.OutOfMemory => return error.OutOfMemory, + error.Overflow => return error.InvalidData, + }; + defer alloc.free(result.data); + + if (result.data.len > max_size) { + log.warn("png image too large size={} max_size={}", .{ result.data.len, max_size }); + return error.InvalidData; + } + + // Replace our data + self.data.deinit(alloc); + self.data = .{}; + try self.data.ensureUnusedCapacity(alloc, result.data.len); + try self.data.appendSlice(alloc, result.data[0..result.data.len]); + + // Store updated image dimensions + self.image.width = result.width; + self.image.height = result.height; + self.image.format = .rgba; + } +}; + +/// 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, + + /// Set this to true if this image was loaded by a command that + /// doesn't specify an ID or number, since such commands should + /// not be responded to, even though we do currently give them + /// IDs in the public range (which is bad!). + implicit_id: bool = false, + + pub const Error = error{ + InternalError, + InvalidData, + DecompressionFailed, + DimensionsRequired, + DimensionsTooLarge, + FilePathTooLong, + TemporaryFileNotInTempDir, + TemporaryFileNotNamedCorrectly, + 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: PageList.Pin, + bottom_right: PageList.Pin, +}; + +// 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; + + // <ESC>_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA<ESC>\ + 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-raw.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-raw.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-raw.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-raw.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: temporary file without correct path" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "image.data", + .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 alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd)); + + // Temporary file should still be there + try tmp_dir.dir.access(path, .{}); +} + +test "image load: rgb, not compressed, temporary file" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "tty-graphics-protocol-image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("tty-graphics-protocol-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 alloc.dupe(u8, 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 temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "image.data", + .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 alloc.dupe(u8, 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 temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "tty-graphics-protocol-image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .png, + .medium = .file, + .compression = .none, + .width = 0, + .height = 0, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, 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 == .rgba); + try tmp_dir.dir.access(path, .{}); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_render.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_render.zig new file mode 100644 index 00000000..946b537a --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_render.zig @@ -0,0 +1,27 @@ +const std = @import("std"); +const testing = std.testing; +const terminal = @import("../main.zig"); + +/// A render placement is a way to position a Kitty graphics image onto +/// the screen. It is broken down into the fields that make it easier to +/// position the image using a renderer. +pub const Placement = struct { + /// The top-left corner of the image in grid coordinates. + top_left: terminal.Pin, + + /// The offset in pixels from the top-left corner of the grid cell. + offset_x: u32 = 0, + offset_y: u32 = 0, + + /// The source rectangle of the image to render. This doesn't have to + /// match the size the destination size and the renderer is expected + /// to scale the image to fit the destination size. + source_x: u32 = 0, + source_y: u32 = 0, + source_width: u32 = 0, + source_height: u32 = 0, + + /// The final width/height of the image in pixels. + dest_width: u32 = 0, + dest_height: u32 = 0, +}; diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig new file mode 100644 index 00000000..8ff68e3f --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig @@ -0,0 +1,1359 @@ +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +const terminal = @import("../main.zig"); +const point = @import("../point.zig"); +const size = @import("../size.zig"); +const command = @import("graphics_command.zig"); +const PageList = @import("../PageList.zig"); +const Screen = @import("../Screen.zig"); +const LoadingImage = @import("graphics_image.zig").LoadingImage; +const Image = @import("graphics_image.zig").Image; +const Rect = @import("graphics_image.zig").Rect; +const Command = command.Command; + +const log = std.log.scoped(.kitty_gfx); + +/// An image storage is associated with a terminal screen (i.e. main +/// screen, alt screen) and contains all the transmitted images and +/// placements. +pub const ImageStorage = struct { + const ImageMap = std.AutoHashMapUnmanaged(u32, Image); + const PlacementMap = std.AutoHashMapUnmanaged(PlacementKey, Placement); + + /// Dirty is set to true if placements or images change. This is + /// purely informational for the renderer and doesn't affect the + /// correctness of the program. The renderer must set this to false + /// if it cares about this value. + dirty: bool = false, + + /// This is the next automatically assigned image ID. We start mid-way + /// through the u32 range to avoid collisions with buggy programs. + /// TODO: This isn't good enough, it's perfectly legal for programs + /// to use IDs in the latter half of the range and collisions + /// are not gracefully handled. + next_image_id: u32 = 2147483647, + + /// This is the next automatically assigned placement ID. This is never + /// user-facing so we can start at 0. This is 32-bits because we use + /// the same space for external placement IDs. We can start at zero + /// because any number is valid. + next_internal_placement_id: u32 = 0, + + /// The set of images that are currently known. + images: ImageMap = .{}, + + /// The set of placements for loaded images. + placements: PlacementMap = .{}, + + /// Non-null if there is an in-progress loading image. + loading: ?*LoadingImage = null, + + /// The total bytes of image data that have been loaded and the limit. + /// If the limit is reached, the oldest images will be evicted to make + /// space. Unused images take priority. + total_bytes: usize = 0, + total_limit: usize = 320 * 1000 * 1000, // 320MB + + pub fn deinit( + self: *ImageStorage, + alloc: Allocator, + s: *terminal.Screen, + ) void { + if (self.loading) |loading| loading.destroy(alloc); + + var it = self.images.iterator(); + while (it.next()) |kv| kv.value_ptr.deinit(alloc); + self.images.deinit(alloc); + + self.clearPlacements(s); + self.placements.deinit(alloc); + } + + /// Kitty image protocol is enabled if we have a non-zero limit. + pub fn enabled(self: *const ImageStorage) bool { + return self.total_limit != 0; + } + + /// Sets the limit in bytes for the total amount of image data that + /// can be loaded. If this limit is lower, this will do an eviction + /// if necessary. If the value is zero, then Kitty image protocol will + /// be disabled. + pub fn setLimit( + self: *ImageStorage, + alloc: Allocator, + s: *terminal.Screen, + limit: usize, + ) !void { + // Special case disabling by quickly deleting all + if (limit == 0) { + self.deinit(alloc, s); + self.* = .{}; + } + + // If we re lowering our limit, check if we need to evict. + if (limit < self.total_bytes) { + const req_bytes = self.total_bytes - limit; + log.info("evicting images to lower limit, evicting={}", .{req_bytes}); + if (!try self.evictImage(alloc, req_bytes)) { + log.warn("failed to evict enough images for required bytes", .{}); + } + } + + self.total_limit = limit; + } + + /// Add an already-loaded image to the storage. This will automatically + /// free any existing image with the same ID. + pub fn addImage(self: *ImageStorage, alloc: Allocator, img: Image) Allocator.Error!void { + // If the image itself is over the limit, then error immediately + if (img.data.len > self.total_limit) return error.OutOfMemory; + + // If this would put us over the limit, then evict. + const total_bytes = self.total_bytes + img.data.len; + if (total_bytes > self.total_limit) { + const req_bytes = total_bytes - self.total_limit; + log.info("evicting images to make space for {} bytes", .{req_bytes}); + if (!try self.evictImage(alloc, req_bytes)) { + log.warn("failed to evict enough images for required bytes", .{}); + return error.OutOfMemory; + } + } + + // Do the gop op first so if it fails we don't get a partial state + const gop = try self.images.getOrPut(alloc, img.id); + + log.debug("addImage image={}", .{img: { + var copy = img; + copy.data = ""; + break :img copy; + }}); + + // Write our new image + if (gop.found_existing) { + self.total_bytes -= gop.value_ptr.data.len; + gop.value_ptr.deinit(alloc); + } + + gop.value_ptr.* = img; + self.total_bytes += img.data.len; + + self.dirty = true; + } + + /// Add a placement for a given image. The caller must verify in advance + /// the image exists to prevent memory corruption. + pub fn addPlacement( + self: *ImageStorage, + alloc: Allocator, + image_id: u32, + placement_id: u32, + p: Placement, + ) !void { + assert(self.images.get(image_id) != null); + log.debug("placement image_id={} placement_id={} placement={}\n", .{ + image_id, + placement_id, + p, + }); + + // The important piece here is that the placement ID needs to + // be marked internal if it is zero. This allows multiple placements + // to be added for the same image. If it is non-zero, then it is + // an external placement ID and we can only have one placement + // per (image id, placement id) pair. + const key: PlacementKey = .{ + .image_id = image_id, + .placement_id = if (placement_id == 0) .{ + .tag = .internal, + .id = id: { + defer self.next_internal_placement_id +%= 1; + break :id self.next_internal_placement_id; + }, + } else .{ + .tag = .external, + .id = placement_id, + }, + }; + + const gop = try self.placements.getOrPut(alloc, key); + gop.value_ptr.* = p; + + self.dirty = true; + } + + fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void { + var it = self.placements.iterator(); + while (it.next()) |entry| entry.value_ptr.deinit(s); + self.placements.clearRetainingCapacity(); + } + + /// Get an image by its ID. If the image doesn't exist, null is returned. + pub fn imageById(self: *const ImageStorage, image_id: u32) ?Image { + return self.images.get(image_id); + } + + /// Get an image by its number. If the image doesn't exist, return null. + pub fn imageByNumber(self: *const ImageStorage, image_number: u32) ?Image { + var newest: ?Image = null; + + var it = self.images.iterator(); + while (it.next()) |kv| { + if (kv.value_ptr.number == image_number) { + if (newest == null or + kv.value_ptr.transmit_time.order(newest.?.transmit_time) == .gt) + { + newest = kv.value_ptr.*; + } + } + } + + return newest; + } + + /// Delete placements, images. + pub fn delete( + self: *ImageStorage, + alloc: Allocator, + t: *terminal.Terminal, + cmd: command.Delete, + ) void { + switch (cmd) { + .all => |delete_images| { + var it = self.placements.iterator(); + while (it.next()) |entry| { + // Skip virtual placements + switch (entry.value_ptr.location) { + .pin => {}, + .virtual => continue, + } + + // Deinit the placement and remove it + const image_id = entry.key_ptr.image_id; + entry.value_ptr.deinit(t.screens.active); + self.placements.removeByPtr(entry.key_ptr); + if (delete_images) self.deleteIfUnused(alloc, image_id); + } + + if (delete_images) { + var image_it = self.images.iterator(); + while (image_it.next()) |kv| self.deleteIfUnused(alloc, kv.key_ptr.*); + } + + self.dirty = true; + }, + + .id => |v| self.deleteById( + alloc, + t.screens.active, + v.image_id, + v.placement_id, + v.delete, + ), + + .newest => |v| newest: { + const img = self.imageByNumber(v.image_number) orelse break :newest; + self.deleteById( + alloc, + t.screens.active, + img.id, + v.placement_id, + v.delete, + ); + }, + + .intersect_cursor => |delete_images| { + self.deleteIntersecting( + alloc, + t, + .{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, + } }, + delete_images, + {}, + null, + ); + }, + + .intersect_cell => |v| intersect_cell: { + if (v.x <= 0 or v.y <= 0) { + log.warn("delete intersect cell coords must be at least 1", .{}); + break :intersect_cell; + } + + self.deleteIntersecting( + alloc, + t, + .{ .active = .{ + .x = std.math.cast(size.CellCountInt, v.x - 1) orelse break :intersect_cell, + .y = std.math.cast(size.CellCountInt, v.y - 1) orelse break :intersect_cell, + } }, + v.delete, + {}, + null, + ); + }, + + .intersect_cell_z => |v| intersect_cell_z: { + if (v.x <= 0 or v.y <= 0) { + log.warn("delete intersect cell coords must be at least 1", .{}); + break :intersect_cell_z; + } + + self.deleteIntersecting( + alloc, + t, + .{ .active = .{ + .x = std.math.cast(size.CellCountInt, v.x - 1) orelse break :intersect_cell_z, + .y = std.math.cast(size.CellCountInt, v.y - 1) orelse break :intersect_cell_z, + } }, + v.delete, + v.z, + struct { + fn filter(ctx: i32, p: Placement) bool { + return p.z == ctx; + } + }.filter, + ); + }, + + .column => |v| column: { + if (v.x <= 0) { + log.warn("delete column must be greater than zero", .{}); + break :column; + } + + const x = v.x - 1; + var it = self.placements.iterator(); + while (it.next()) |entry| { + const img = self.imageById(entry.key_ptr.image_id) orelse continue; + const rect = entry.value_ptr.rect(img, t) orelse continue; + if (rect.top_left.x <= x and rect.bottom_right.x >= x) { + entry.value_ptr.deinit(t.screens.active); + self.placements.removeByPtr(entry.key_ptr); + if (v.delete) self.deleteIfUnused(alloc, img.id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + + .row => |v| row: { + if (v.y <= 0) { + log.warn("delete row must be greater than zero", .{}); + break :row; + } + + // v.y is in active coords so we want to convert it to a pin + // so we can compare by page offsets. + const target_pin = t.screens.active.pages.pin(.{ .active = .{ + .y = std.math.cast(size.CellCountInt, v.y - 1) orelse break :row, + } }) orelse break :row; + + var it = self.placements.iterator(); + while (it.next()) |entry| { + const img = self.imageById(entry.key_ptr.image_id) orelse continue; + const rect = entry.value_ptr.rect(img, t) orelse continue; + + // We need to copy our pin to ensure we are at least at + // the top-left x. + var target_pin_copy = target_pin; + target_pin_copy.x = rect.top_left.x; + if (target_pin_copy.isBetween(rect.top_left, rect.bottom_right)) { + entry.value_ptr.deinit(t.screens.active); + self.placements.removeByPtr(entry.key_ptr); + if (v.delete) self.deleteIfUnused(alloc, img.id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + + .z => |v| { + var it = self.placements.iterator(); + while (it.next()) |entry| { + switch (entry.value_ptr.location) { + .pin => {}, + + // Virtual placeholders cannot delete by z according + // to the spec. + .virtual => continue, + } + + if (entry.value_ptr.z == v.z) { + const image_id = entry.key_ptr.image_id; + entry.value_ptr.deinit(t.screens.active); + self.placements.removeByPtr(entry.key_ptr); + if (v.delete) self.deleteIfUnused(alloc, image_id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + + .range => |v| range: { + if (v.first <= 0 or v.last <= 0) { + log.warn("delete range values must be greater than zero", .{}); + break :range; + } + if (v.first > v.last) { + log.warn("delete range 'x' ({}) must be less than or equal to 'y' ({})", .{ v.first, v.last }); + break :range; + } + + var it = self.placements.iterator(); + while (it.next()) |entry| { + if (entry.key_ptr.image_id >= v.first or entry.key_ptr.image_id <= v.last) { + const image_id = entry.key_ptr.image_id; + entry.value_ptr.deinit(t.screens.active); + self.placements.removeByPtr(entry.key_ptr); + if (v.delete) self.deleteIfUnused(alloc, image_id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + + // We don't support animation frames yet so they are successfully + // deleted! + .animation_frames => {}, + } + } + + fn deleteById( + self: *ImageStorage, + alloc: Allocator, + s: *terminal.Screen, + image_id: u32, + placement_id: u32, + delete_unused: bool, + ) void { + // If no placement, we delete all placements with the ID + if (placement_id == 0) { + var it = self.placements.iterator(); + while (it.next()) |entry| { + if (entry.key_ptr.image_id == image_id) { + entry.value_ptr.deinit(s); + self.placements.removeByPtr(entry.key_ptr); + } + } + } else { + if (self.placements.getEntry(.{ + .image_id = image_id, + .placement_id = .{ .tag = .external, .id = placement_id }, + })) |entry| { + entry.value_ptr.deinit(s); + self.placements.removeByPtr(entry.key_ptr); + } + } + + // If this is specified, then we also delete the image + // if it is no longer in use. + if (delete_unused) self.deleteIfUnused(alloc, image_id); + + // Mark dirty to force redraw + self.dirty = true; + } + + /// Delete an image if it is unused. + fn deleteIfUnused(self: *ImageStorage, alloc: Allocator, image_id: u32) void { + var it = self.placements.iterator(); + while (it.next()) |kv| { + if (kv.key_ptr.image_id == image_id) { + return; + } + } + + // If we get here, we can delete the image. + if (self.images.getEntry(image_id)) |entry| { + self.total_bytes -= entry.value_ptr.data.len; + entry.value_ptr.deinit(alloc); + self.images.removeByPtr(entry.key_ptr); + } + } + + /// Deletes all placements intersecting a screen point. + fn deleteIntersecting( + self: *ImageStorage, + alloc: Allocator, + t: *terminal.Terminal, + p: point.Point, + delete_unused: bool, + filter_ctx: anytype, + comptime filter: ?fn (@TypeOf(filter_ctx), Placement) bool, + ) void { + // Convert our target point to a pin for comparison. + const target_pin = t.screens.active.pages.pin(p) orelse return; + + var it = self.placements.iterator(); + while (it.next()) |entry| { + const img = self.imageById(entry.key_ptr.image_id) orelse continue; + const rect = entry.value_ptr.rect(img, t) orelse continue; + if (target_pin.isBetween(rect.top_left, rect.bottom_right)) { + if (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue; + entry.value_ptr.deinit(t.screens.active); + self.placements.removeByPtr(entry.key_ptr); + if (delete_unused) self.deleteIfUnused(alloc, img.id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + } + + /// Evict image to make space. This will evict the oldest image, + /// prioritizing unused images first, as recommended by the published + /// Kitty spec. + /// + /// This will evict as many images as necessary to make space for + /// req bytes. + fn evictImage(self: *ImageStorage, alloc: Allocator, req: usize) !bool { + assert(req <= self.total_limit); + + // Ironically we allocate to evict. We should probably redesign the + // data structures to avoid this but for now allocating a little + // bit is fine compared to the megabytes we're looking to save. + const Candidate = struct { + id: u32, + time: std.time.Instant, + used: bool, + }; + + var candidates: std.ArrayList(Candidate) = .empty; + defer candidates.deinit(alloc); + + var it = self.images.iterator(); + while (it.next()) |kv| { + const img = kv.value_ptr; + + // This is a huge waste. See comment above about redesigning + // our data structures to avoid this. Eviction should be very + // rare though and we never have that many images/placements + // so hopefully this will last a long time. + const used = used: { + var p_it = self.placements.iterator(); + while (p_it.next()) |p_kv| { + if (p_kv.key_ptr.image_id == img.id) { + break :used true; + } + } + + break :used false; + }; + + try candidates.append(alloc, .{ + .id = img.id, + .time = img.transmit_time, + .used = used, + }); + } + + // Sort + std.mem.sortUnstable( + Candidate, + candidates.items, + {}, + struct { + fn lessThan( + ctx: void, + lhs: Candidate, + rhs: Candidate, + ) bool { + _ = ctx; + + // If they're usage matches, then its based on time. + if (lhs.used == rhs.used) return switch (lhs.time.order(rhs.time)) { + .lt => true, + .gt => false, + .eq => lhs.id < rhs.id, + }; + + // If not used, then its a better candidate + return !lhs.used; + } + }.lessThan, + ); + + // They're in order of best to evict. + var evicted: usize = 0; + for (candidates.items) |c| { + // Delete all the placements for this image and the image. + var p_it = self.placements.iterator(); + while (p_it.next()) |entry| { + if (entry.key_ptr.image_id == c.id) { + self.placements.removeByPtr(entry.key_ptr); + } + } + + if (self.images.getEntry(c.id)) |entry| { + log.info("evicting image id={} bytes={}", .{ c.id, entry.value_ptr.data.len }); + + evicted += entry.value_ptr.data.len; + self.total_bytes -= entry.value_ptr.data.len; + + entry.value_ptr.deinit(alloc); + self.images.removeByPtr(entry.key_ptr); + + if (evicted > req) return true; + } + } + + return false; + } + + /// Every placement is uniquely identified by the image ID and the + /// placement ID. If an image ID isn't specified it is assumed to be 0. + /// Likewise, if a placement ID isn't specified it is assumed to be 0. + pub const PlacementKey = struct { + image_id: u32, + placement_id: packed struct { + tag: enum(u1) { internal, external }, + id: u32, + }, + }; + + pub const Placement = struct { + /// The location where this placement should be drawn. + location: Location, + + /// Offset of the x/y from the top-left of the cell. + x_offset: u32 = 0, + y_offset: u32 = 0, + + /// Source rectangle for the image to pull from + source_x: u32 = 0, + source_y: u32 = 0, + source_width: u32 = 0, + source_height: u32 = 0, + + /// The columns/rows this image occupies. + columns: u32 = 0, + rows: u32 = 0, + + /// The z-index for this placement. + z: i32 = 0, + + pub const Location = union(enum) { + /// Exactly placed on a screen pin. + pin: *PageList.Pin, + + /// Virtual placement (U=1) for unicode placeholders. + virtual: void, + }; + + pub fn deinit( + self: *const Placement, + s: *terminal.Screen, + ) void { + switch (self.location) { + .pin => |p| s.pages.untrackPin(p), + .virtual => {}, + } + } + + /// Calculates the size of this placement's image in pixels, + /// taking in to account the specified rows and columns. + pub fn calculatedSize( + self: Placement, + image: Image, + t: *const terminal.Terminal, + ) struct { + width: u32, + height: u32, + } { + // Height / width of the image in px. + const width = if (self.source_width > 0) self.source_width else image.width; + const height = if (self.source_height > 0) self.source_height else image.height; + + // If we don't have any specified cols or rows then the placement + // should be the native size of the image, and doesn't need to be + // re-scaled. + if (self.columns == 0 and self.rows == 0) return .{ + .width = width, + .height = height, + }; + + // We calculate the size of a cell so that we can multiply + // it by the specified cols/rows to get the correct px size. + // + // We assume that the width is divided evenly by the column + // count and the height by the row count, because it should be. + const cell_width: u32 = t.width_px / t.cols; + const cell_height: u32 = t.height_px / t.rows; + + const width_f64: f64 = @floatFromInt(width); + const height_f64: f64 = @floatFromInt(height); + + // If we have a specified cols AND rows then we calculate + // the width and height from them directly, we don't need + // to adjust for aspect ratio. + if (self.columns > 0 and self.rows > 0) { + const calc_width = cell_width * self.columns; + const calc_height = cell_height * self.rows; + + return .{ + .width = calc_width, + .height = calc_height, + }; + } + + // Either the columns or the rows were specified, but not both, + // so we need to calculate the other one based on the aspect ratio. + + // If only the columns were specified, we determine + // the height of the image based on the aspect ratio. + if (self.columns > 0) { + const aspect = height_f64 / width_f64; + const calc_width: u32 = cell_width * self.columns; + const calc_height: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(calc_width)) * aspect, + )); + + return .{ + .width = calc_width, + .height = calc_height, + }; + } + + // Otherwise, only the rows were specified, so we + // determine the width based on the aspect ratio. + { + const aspect = width_f64 / height_f64; + const calc_height: u32 = cell_height * self.rows; + const calc_width: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(calc_height)) * aspect, + )); + + return .{ + .width = calc_width, + .height = calc_height, + }; + } + } + + /// Returns the size in grid cells that this placement takes up. + pub fn gridSize( + self: Placement, + image: Image, + t: *const terminal.Terminal, + ) struct { + cols: u32, + rows: u32, + } { + // If we have a specified columns and rows then this is trivial. + if (self.columns > 0 and self.rows > 0) return .{ + .cols = self.columns, + .rows = self.rows, + }; + + // Otherwise we calculate the pixel size, divide by + // cell size, and round up to the nearest integer. + const calc_size = self.calculatedSize(image, t); + return .{ + .cols = std.math.divCeil( + u32, + calc_size.width + self.x_offset, + t.width_px / t.cols, + ) catch 0, + .rows = std.math.divCeil( + u32, + calc_size.height + self.y_offset, + t.height_px / t.rows, + ) catch 0, + }; + // NOTE: Above `divCeil`s can only fail if the cell size is 0, + // in such a case it seems safe to return 0 for this. + } + + /// Returns a selection of the entire rectangle this placement + /// occupies within the screen. This can return null if the placement + /// doesn't have an associated rect (i.e. a virtual placement). + pub fn rect( + self: Placement, + image: Image, + t: *const terminal.Terminal, + ) ?Rect { + const grid_size = self.gridSize(image, t); + const pin = switch (self.location) { + .pin => |p| p, + .virtual => return null, + }; + + var br = switch (pin.downOverflow(grid_size.rows - 1)) { + .offset => |v| v, + .overflow => |v| v.end, + }; + br.x = @min( + // We need to sub one here because the x value is + // one width already. So if the image is width "1" + // then we add zero to X because X itself is width 1. + pin.x + (grid_size.cols - 1), + t.cols - 1, + ); + + return .{ + .top_left = pin.*, + .bottom_right = br, + }; + } + }; +}; + +// Our pin for the placement +fn trackPin( + t: *terminal.Terminal, + pt: point.Coordinate, +) !*PageList.Pin { + return try t.screens.active.pages.trackPin(t.screens.active.pages.pin(.{ + .active = pt, + }).?); +} + +test "storage: add placement with zero placement id" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(alloc, 1, 0, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + try s.addPlacement(alloc, 1, 0, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + + try testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .internal, .id = 0 }, + }) != null); + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .internal, .id = 1 }, + }) != null); +} + +test "storage: delete all placements and images" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .all = true }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 0), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete all placements and images preserves limit" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + s.total_limit = 5000; + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .all = true }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 0), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(@as(usize, 5000), s.total_limit); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete all placements" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .all = false }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete all placements by image id" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .id = .{ .image_id = 2 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete all placements by image id and unused images" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .id = .{ .delete = true, .image_id = 2 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete placement by specific id" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .id = .{ + .delete = true, + .image_id = 1, + .placement_id = 2, + } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked + 2, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete intersecting cursor" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + + t.screens.active.cursorAbsolute(12, 12); + + s.dirty = false; + s.delete(alloc, &t, .{ .intersect_cursor = false }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 2 }, + }) != null); +} + +test "storage: delete intersecting cursor plus unused" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + + t.screens.active.cursorAbsolute(12, 12); + + s.dirty = false; + s.delete(alloc, &t, .{ .intersect_cursor = true }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 2 }, + }) != null); +} + +test "storage: delete intersecting cursor hits multiple" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + + t.screens.active.cursorAbsolute(26, 26); + + s.dirty = false; + s.delete(alloc, &t, .{ .intersect_cursor = true }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete by column" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .column = .{ + .delete = false, + .x = 60, + } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); +} + +test "storage: delete by column 1x1" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 0 }) } }); + try s.addPlacement(alloc, 1, 3, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 2, .y = 0 }) } }); + + s.delete(alloc, &t, .{ .column = .{ + .delete = false, + .x = 2, + } }); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expectEqual(@as(usize, 1), s.images.count()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 3 }, + }) != null); +} + +test "storage: delete by row" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); + try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); + + s.dirty = false; + s.delete(alloc, &t, .{ .row = .{ + .delete = false, + .y = 60, + } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); +} + +test "storage: delete by row 1x1" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 0 }) } }); + try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 1 }) } }); + try s.addPlacement(alloc, 1, 3, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 2 }) } }); + + s.delete(alloc, &t, .{ .row = .{ + .delete = false, + .y = 2, + } }); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expectEqual(@as(usize, 1), s.images.count()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 3 }, + }) != null); +} + +test "storage: delete images by range 1" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + s.dirty = false; + s.delete(alloc, &t, .{ .range = .{ .delete = false, .first = 1, .last = 2 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete images by range 2" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + s.dirty = false; + s.delete(alloc, &t, .{ .range = .{ .delete = true, .first = 1, .last = 2 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete images by range 3" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + s.dirty = false; + s.delete(alloc, &t, .{ .range = .{ .delete = false, .first = 1, .last = 1 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: delete images by range 4" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + try s.addImage(alloc, .{ .id = 2 }); + try s.addImage(alloc, .{ .id = 3 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(alloc, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + s.dirty = false; + s.delete(alloc, &t, .{ .range = .{ .delete = true, .first = 1, .last = 1 } }); + try testing.expect(s.dirty); + try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: aspect ratio calculation when only columns or rows specified" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); + defer t.deinit(alloc); + t.width_px = 1000; // 10 px per col + t.height_px = 2000; // 20 px per row + + // Case 1: Only columns specified + { + const image = Image{ .id = 1, .width = 16, .height = 9 }; + var placement = ImageStorage.Placement{ + .location = .{ .virtual = {} }, + .columns = 10, + .rows = 0, + }; + + // Image is 16x9, set to a width of 10 columns, at 10px per column + // that's 100px width. 100px * (9 / 16) = 56.25, which should round + // to a height of 56px. + + const calc_size = placement.calculatedSize(image, &t); + try testing.expectEqual(@as(u32, 100), calc_size.width); + try testing.expectEqual(@as(u32, 56), calc_size.height); + } + + // Case 2: Only rows specified + { + const image = Image{ .id = 2, .width = 16, .height = 9 }; + var placement = ImageStorage.Placement{ + .location = .{ .virtual = {} }, + .columns = 0, + .rows = 5, + }; + + // Image is 16x9, set to a height of 5 rows, at 20px per row that's + // 100px height. 100px * (16 / 9) = 177.77..., which should round to + // a width of 178px. + + const calc_size = placement.calculatedSize(image, &t); + try testing.expectEqual(@as(u32, 178), calc_size.width); + try testing.expectEqual(@as(u32, 100), calc_size.height); + } +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_unicode.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_unicode.zig new file mode 100644 index 00000000..a223797b --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_unicode.zig @@ -0,0 +1,1347 @@ +//! This file contains various logic and data for working with the +//! Kitty graphics protocol unicode placeholder, virtual placement feature. + +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; +const testing = std.testing; +const terminal = @import("../main.zig"); +const kitty_gfx = terminal.kitty.graphics; +const Image = kitty_gfx.Image; +const ImageStorage = kitty_gfx.ImageStorage; +const RenderPlacement = kitty_gfx.RenderPlacement; + +const log = std.log.scoped(.kitty_gfx); + +/// Codepoint for the unicode placeholder character. +pub const placeholder: u21 = 0x10EEEE; + +/// Returns an iterator that iterates over all of the virtual placements +/// in the given pin. If `limit` is provided, the iterator will stop +/// when it reaches that pin (inclusive). If `limit` is not provided, +/// the iterator will continue until the end of the page list. +pub fn placementIterator( + pin: terminal.Pin, + limit: ?terminal.Pin, +) PlacementIterator { + var row_it = pin.rowIterator(.right_down, limit); + const row = row_it.next(); + return .{ .row_it = row_it, .row = row }; +} + +/// Iterator over unicode virtual placements. +pub const PlacementIterator = struct { + row_it: terminal.PageList.RowIterator, + row: ?terminal.Pin, + + pub fn next(self: *PlacementIterator) ?Placement { + while (self.row) |*row| { + // This row flag is set on rows that have the virtual placeholder + if (!row.rowAndCell().row.kitty_virtual_placeholder) { + self.row = self.row_it.next(); + continue; + } + + // Our current run. A run is always only a single row. This + // assumption is built-in to our logic so if we want to change + // this later we have to redo the logic; tests should cover; + var run: ?IncompletePlacement = null; + + // Iterate over our remaining cells and find one with a placeholder. + const cells = row.cells(.right); + for (cells, row.x..) |*cell, x| { + // "row" now points to the top-left pin of the placement. + // We need this temporary state to build our incomplete + // placement. + assert(@intFromPtr(row) == @intFromPtr(&self.row)); + row.x = @intCast(x); + + // If this cell doesn't have the placeholder, then we + // complete the run if we have it otherwise we just move + // on and keep searching. + if (cell.codepoint() != placeholder) { + if (run) |prev| return prev.complete(); + continue; + } + + // If we don't have a previous run, then we save this + // incomplete one, start a run, and move on. + const curr = IncompletePlacement.init(row, cell); + if (run) |*prev| { + // If we can't append, then we complete the previous + // run and return it. + if (!prev.append(&curr)) { + // Note: self.row is already updated due to the + // row pointer above. It points back at this same + // cell so we can continue the new placements from + // here. + return prev.complete(); + } + + // append is mutating so if we reached this point + // then prev has been updated. + } else { + // For appending, we need to set our initial values. + var prev = curr; + if (prev.row == null) prev.row = 0; + if (prev.col == null) prev.col = 0; + run = prev; + } + } + + // We move to the next row no matter what + self.row = self.row_it.next(); + + // If we have a run, we complete it here. + if (run) |prev| return prev.complete(); + } + + return null; + } +}; + +/// A virtual placement in the terminal. This can represent more than +/// one cell if the cells combine to be a run. +pub const Placement = struct { + /// The top-left pin of the placement. This can be used to get the + /// screen x/y. + pin: terminal.Pin, + + /// The image ID and placement ID for this virtual placement. The + /// image ID is encoded in the fg color (plus optional a 8-bit high + /// value in the 3rd diacritic). The placement ID is encoded in the + /// underline color (optionally). + image_id: u32, + placement_id: u32, + + /// Starting row/col index for the image itself. This is the "fragment" + /// of the image we want to show in this placement. This is 0-indexed. + col: u32, + row: u32, + + /// The width/height in cells of this placement. + width: u32, + height: u32, + + pub const Error = error{ + PlacementGridOutOfBounds, + PlacementMissingPlacement, + }; + + /// Take this virtual placement and convert it to a render placement. + pub fn renderPlacement( + self: *const Placement, + storage: *const ImageStorage, + img: *const Image, + cell_width: u32, + cell_height: u32, + ) Error!RenderPlacement { + // In this function, there is a variable naming convention to try + // to make it slightly less confusing. The prefix will tell you what + // coordinate/size space a variable lives in: + // - img_* is for the original image + // - p_* is for the final placement + // - vp_* is for the virtual placement + + // Determine the grid size that this virtual placement fits into. + const p_grid = try self.grid(storage, img, cell_width, cell_height); + + // From here on out we switch to floating point math. These are + // constants that we'll reference repeatedly. + const img_width_f64: f64 = @floatFromInt(img.width); + const img_height_f64: f64 = @floatFromInt(img.height); + + // Next we have to fit the source image into the grid size while preserving + // aspect ratio. We will center the image horizontally/vertically if + // necessary. + const p_scale: struct { + /// The offsets are pixels from the top-left of the placement-sized + /// image in order to center the image as necessary. + x_offset: f64 = 0, + y_offset: f64 = 0, + + /// The multipliers to apply to the width/height of the original + /// image size in order to reach the placement size. + x_scale: f64 = 0, + y_scale: f64 = 0, + } = scale: { + const p_rows_px: f64 = @floatFromInt(p_grid.rows * cell_height); + const p_cols_px: f64 = @floatFromInt(p_grid.columns * cell_width); + if (img_width_f64 * p_rows_px > img_height_f64 * p_cols_px) { + // Image is wider than the grid, fit width and center height + const x_scale = p_cols_px / @max(img_width_f64, 1); + const y_scale = x_scale; + const y_offset = (p_rows_px - img_height_f64 * y_scale) / 2; + break :scale .{ + .x_scale = x_scale, + .y_scale = y_scale, + .y_offset = y_offset, + }; + } else { + // Image is taller than the grid, fit height and center width + const y_scale = p_rows_px / @max(img_height_f64, 1); + const x_scale = y_scale; + const x_offset = (p_cols_px - img_width_f64 * x_scale) / 2; + break :scale .{ + .x_scale = x_scale, + .y_scale = y_scale, + .x_offset = x_offset, + }; + } + }; + + // Scale our original image according to the aspect ratio + // and padding calculated for p_scale. + const img_scaled: struct { + x_offset: f64, + y_offset: f64, + width: f64, + height: f64, + } = scale: { + const x_offset: f64 = p_scale.x_offset / p_scale.x_scale; + const y_offset: f64 = p_scale.y_offset / p_scale.y_scale; + const width: f64 = img_width_f64 + (x_offset * 2); + const height: f64 = img_height_f64 + (y_offset * 2); + break :scale .{ + .x_offset = x_offset, + .y_offset = y_offset, + .width = width, + .height = height, + }; + }; + + // Calculate the source rectangle for the scaled image. These + // coordinates are in the scaled image space. + var img_scale_source: struct { + x: f64, + y: f64, + width: f64, + height: f64, + } = source: { + // Float-converted values we already have + const vp_width: f64 = @floatFromInt(self.width); + const vp_height: f64 = @floatFromInt(self.height); + const vp_col: f64 = @floatFromInt(self.col); + const vp_row: f64 = @floatFromInt(self.row); + const p_grid_cols: f64 = @floatFromInt(p_grid.columns); + const p_grid_rows: f64 = @floatFromInt(p_grid.rows); + + // Calculate the scaled source rectangle for the image, undoing + // the aspect ratio scaling as necessary. + const width: f64 = img_scaled.width * (vp_width / p_grid_cols); + const height: f64 = img_scaled.height * (vp_height / p_grid_rows); + const x: f64 = img_scaled.width * (vp_col / p_grid_cols); + const y: f64 = img_scaled.height * (vp_row / p_grid_rows); + + break :source .{ + .width = width, + .height = height, + .x = x, + .y = y, + }; + }; + + // The destination rectangle. The x/y is specified by offsets from + // the top-left since that's how our RenderPlacement works. + const p_dest: struct { + x_offset: f64, + y_offset: f64, + width: f64, + height: f64, + } = dest: { + var x_offset: f64 = 0; + var y_offset: f64 = 0; + var width: f64 = @floatFromInt(self.width * cell_width); + var height: f64 = @floatFromInt(self.height * cell_height); + + if (img_scale_source.y < img_scaled.y_offset) { + // If our source rect y is within the offset area, we need to + // adjust our source rect and destination since the source texture + // doesn't actually have the offset area blank. + const offset: f64 = img_scaled.y_offset - img_scale_source.y; + img_scale_source.height -= offset; + y_offset = offset; + height -= offset * p_scale.y_scale; + img_scale_source.y = 0; + + // If our height is greater than our original height, + // bring it back down. This addresses the case where the top + // and bottom offsets are both used. + if (img_scale_source.height > img_height_f64) { + img_scale_source.height = img_height_f64; + height = img_height_f64 * p_scale.y_scale; + } + } else if (img_scale_source.y + img_scale_source.height > + img_scaled.height - img_scaled.y_offset) + { + // if our y is in our bottom offset area, we need to shorten the + // source to fit in the cell. + img_scale_source.y -= img_scaled.y_offset; + img_scale_source.height = img_scaled.height - img_scaled.y_offset - img_scale_source.y; + img_scale_source.height -= img_scaled.y_offset; + height = img_scale_source.height * p_scale.y_scale; + } else { + img_scale_source.y -= img_scaled.y_offset; + } + + if (img_scale_source.x < img_scaled.x_offset) { + // If our source rect x is within the offset area, we need to + // adjust our source rect and destination since the source texture + // doesn't actually have the offset area blank. + const offset: f64 = img_scaled.x_offset - img_scale_source.x; + img_scale_source.width -= offset; + x_offset = offset; + width -= offset * p_scale.x_scale; + img_scale_source.x = 0; + + // If our width is greater than our original width, + // bring it back down. This addresses the case where the left + // and right offsets are both used. + if (img_scale_source.width > img_width_f64) { + img_scale_source.width = img_width_f64; + width = img_width_f64 * p_scale.x_scale; + } + } else if (img_scale_source.x + img_scale_source.width > + img_scaled.width - img_scaled.x_offset) + { + // if our x is in our right offset area, we need to shorten the + // source to fit in the cell. + img_scale_source.x -= img_scaled.x_offset; + img_scale_source.width = img_scaled.width - img_scaled.x_offset - img_scale_source.x; + img_scale_source.width -= img_scaled.x_offset; + width = img_scale_source.width * p_scale.x_scale; + } else { + img_scale_source.x -= img_scaled.x_offset; + } + + // If our modified source width/height is less than zero then + // we render nothing because it means we're rendering outside + // of the visible image. + if (img_scale_source.width <= 0 or img_scale_source.height <= 0) { + return .{ .top_left = self.pin }; + } + + break :dest .{ + .x_offset = x_offset * p_scale.x_scale, + .y_offset = y_offset * p_scale.y_scale, + .width = width, + .height = height, + }; + }; + // log.warn("img_width={} img_height={}\np_grid={}\np_scale={}\nimg_scaled={}\nimg_scale_source={}\np_dest={}\n", .{ + // img_width_f64, + // img_height_f64, + // p_grid, + // p_scale, + // img_scaled, + // img_scale_source, + // p_dest, + // }); + + return .{ + .top_left = self.pin, + .offset_x = @intFromFloat(@round(p_dest.x_offset)), + .offset_y = @intFromFloat(@round(p_dest.y_offset)), + .source_x = @intFromFloat(@round(img_scale_source.x)), + .source_y = @intFromFloat(@round(img_scale_source.y)), + .source_width = @intFromFloat(@round(img_scale_source.width)), + .source_height = @intFromFloat(@round(img_scale_source.height)), + .dest_width = @intFromFloat(@round(p_dest.width)), + .dest_height = @intFromFloat(@round(p_dest.height)), + }; + } + + // Calculate the grid size for the placement. For virtual placements, + // we use the requested row/cols. If either isn't specified, we choose + // the best size based on the image size to fit the entire image in its + // original size. + // + // This part of the code does NOT do preserve any aspect ratios. Its + // dumbly fitting the image into the grid size -- possibly user specified. + fn grid( + self: *const Placement, + storage: *const ImageStorage, + image: *const Image, + cell_width: u32, + cell_height: u32, + ) !struct { + rows: u32, + columns: u32, + } { + // Get the placement. If an ID is specified we look for the exact one. + // If no ID, then we find the first virtual placement for this image. + const placement = if (self.placement_id > 0) storage.placements.get(.{ + .image_id = self.image_id, + .placement_id = .{ .tag = .external, .id = self.placement_id }, + }) orelse { + return Error.PlacementMissingPlacement; + } else placement: { + var it = storage.placements.iterator(); + while (it.next()) |entry| { + if (entry.key_ptr.image_id == self.image_id and + entry.value_ptr.location == .virtual) + { + break :placement entry.value_ptr.*; + } + } + + return Error.PlacementMissingPlacement; + }; + + // Use requested rows/columns if specified + // For unspecified rows/columns, calculate based on the image size. + var rows = placement.rows; + var columns = placement.columns; + if (rows == 0) rows = (image.height + cell_height - 1) / cell_height; + if (columns == 0) columns = (image.width + cell_width - 1) / cell_width; + return .{ + .rows = std.math.cast(terminal.size.CellCountInt, rows) orelse + return Error.PlacementGridOutOfBounds, + .columns = std.math.cast(terminal.size.CellCountInt, columns) orelse + return Error.PlacementGridOutOfBounds, + }; + } +}; + +/// IncompletePlacement is the placement information present in a single +/// cell. It is "incomplete" because the specification allows for missing +/// diacritics and so on that continue from previous valid placements. +const IncompletePlacement = struct { + /// The pin of the cell that created this incomplete placement. + pin: terminal.Pin, + + /// Lower 24 bits of the image ID. This is specified in the fg color + /// and is always required. + image_id_low: u24, + + /// Higher 8 bits of the image ID specified using the 3rd diacritic. + /// This is optional. + image_id_high: ?u8 = null, + + /// Placement ID is optionally specified in the underline color. + placement_id: ?u24 = null, + + /// The row/col index for the image. These are 0-indexed. These + /// are specified using diacritics. The row is first and the col + /// is second. Both are optional. If not specified, they can continue + /// a previous placement under certain conditions. + row: ?u32 = null, + col: ?u32 = null, + + /// The run width so far in cells. + width: u32 = 1, + + /// Parse the incomplete placement information from a row and cell. + /// + /// The cell could be derived from the row but in our usage we already + /// have the cell and we don't want to waste cycles recomputing it. + pub fn init( + row: *const terminal.Pin, + cell: *const terminal.Cell, + ) IncompletePlacement { + assert(cell.codepoint() == placeholder); + const style = row.style(cell); + + var result: IncompletePlacement = .{ + .pin = row.*, + .image_id_low = colorToId(style.fg_color), + .placement_id = placement_id: { + const id = colorToId(style.underline_color); + break :placement_id if (id != 0) id else null; + }, + }; + + // Try to decode all our diacritics. Any invalid diacritics are + // treated as if they don't exist. This isn't explicitly specified + // at the time of writing this but it appears to be how Kitty behaves. + const cps: []const u21 = row.grapheme(cell) orelse &.{}; + if (cps.len > 0) { + result.row = getIndex(cps[0]) orelse value: { + log.warn("virtual placement with invalid row diacritic cp={X}", .{cps[0]}); + break :value null; + }; + + if (cps.len > 1) { + result.col = getIndex(cps[1]) orelse value: { + log.warn("virtual placement with invalid col diacritic cp={X}", .{cps[1]}); + break :value null; + }; + + if (cps.len > 2) { + const high_ = getIndex(cps[2]) orelse value: { + log.warn("virtual placement with invalid high diacritic cp={X}", .{cps[2]}); + break :value null; + }; + + if (high_) |high| { + result.image_id_high = std.math.cast( + u8, + high, + ) orelse value: { + log.warn("virtual placement with invalid high diacritic cp={X} value={}", .{ + cps[2], + high, + }); + break :value null; + }; + } + + // Any additional diacritics are ignored. + } + } + } + + return result; + } + + /// Append this incomplete placement to an existing placement to + /// create a run. This returns true if the placements are compatible + /// and were combined. If this returns false, the other placement is + /// unchanged. + pub fn append(self: *IncompletePlacement, other: *const IncompletePlacement) bool { + if (!self.canAppend(other)) return false; + self.width += 1; + return true; + } + + fn canAppend(self: *const IncompletePlacement, other: *const IncompletePlacement) bool { + // Converted from Kitty's logic, don't @ me. + return self.image_id_low == other.image_id_low and + self.placement_id == other.placement_id and + (other.row == null or other.row == self.row) and + (other.col == null or other.col == self.col.? + self.width) and + (other.image_id_high == null or other.image_id_high == self.image_id_high); + } + + /// Complete the incomplete placement to create a full placement. + /// This creates a new placement that isn't continuous with any previous + /// placements. + /// + /// The pin is the pin of the cell that created this incomplete placement. + pub fn complete(self: *const IncompletePlacement) Placement { + return .{ + .pin = self.pin, + .image_id = image_id: { + const low: u32 = @intCast(self.image_id_low); + const high: u32 = @intCast(self.image_id_high orelse 0); + break :image_id low | (high << 24); + }, + + .placement_id = self.placement_id orelse 0, + .col = self.col orelse 0, + .row = self.row orelse 0, + .width = self.width, + .height = 1, + }; + } + + /// Convert a style color to a Kitty image protocol ID. This works by + /// taking the 24 most significant bits of the color, which lets it work + /// for both palette and rgb-based colors. + fn colorToId(c: terminal.Style.Color) u24 { + return switch (c) { + .none => 0, + .palette => |v| @intCast(v), + .rgb => |rgb| rgb: { + const r: u24 = @intCast(rgb.r); + const g: u24 = @intCast(rgb.g); + const b: u24 = @intCast(rgb.b); + break :rgb (r << 16) | (g << 8) | b; + }, + }; + } +}; + +/// Get the row/col index for a diacritic codepoint. These are 0-indexed. +fn getIndex(cp: u21) ?u32 { + const idx = std.sort.binarySearch(u21, diacritics, cp, (struct { + fn compare(context: u21, item: u21) std.math.Order { + return std.math.order(context, item); + } + }).compare) orelse return null; + return @intCast(idx); +} + +/// These are the diacritics used with the Kitty graphics protocol +/// Unicode placement feature to specify the row/column for placement. +/// The index into the array determines the value. +/// +/// This is derived from: +/// https://sw.kovidgoyal.net/kitty/_downloads/f0a0de9ec8d9ff4456206db8e0814937/rowcolumn-diacritics.txt +const diacritics: []const u21 = &.{ + 0x0305, + 0x030D, + 0x030E, + 0x0310, + 0x0312, + 0x033D, + 0x033E, + 0x033F, + 0x0346, + 0x034A, + 0x034B, + 0x034C, + 0x0350, + 0x0351, + 0x0352, + 0x0357, + 0x035B, + 0x0363, + 0x0364, + 0x0365, + 0x0366, + 0x0367, + 0x0368, + 0x0369, + 0x036A, + 0x036B, + 0x036C, + 0x036D, + 0x036E, + 0x036F, + 0x0483, + 0x0484, + 0x0485, + 0x0486, + 0x0487, + 0x0592, + 0x0593, + 0x0594, + 0x0595, + 0x0597, + 0x0598, + 0x0599, + 0x059C, + 0x059D, + 0x059E, + 0x059F, + 0x05A0, + 0x05A1, + 0x05A8, + 0x05A9, + 0x05AB, + 0x05AC, + 0x05AF, + 0x05C4, + 0x0610, + 0x0611, + 0x0612, + 0x0613, + 0x0614, + 0x0615, + 0x0616, + 0x0617, + 0x0657, + 0x0658, + 0x0659, + 0x065A, + 0x065B, + 0x065D, + 0x065E, + 0x06D6, + 0x06D7, + 0x06D8, + 0x06D9, + 0x06DA, + 0x06DB, + 0x06DC, + 0x06DF, + 0x06E0, + 0x06E1, + 0x06E2, + 0x06E4, + 0x06E7, + 0x06E8, + 0x06EB, + 0x06EC, + 0x0730, + 0x0732, + 0x0733, + 0x0735, + 0x0736, + 0x073A, + 0x073D, + 0x073F, + 0x0740, + 0x0741, + 0x0743, + 0x0745, + 0x0747, + 0x0749, + 0x074A, + 0x07EB, + 0x07EC, + 0x07ED, + 0x07EE, + 0x07EF, + 0x07F0, + 0x07F1, + 0x07F3, + 0x0816, + 0x0817, + 0x0818, + 0x0819, + 0x081B, + 0x081C, + 0x081D, + 0x081E, + 0x081F, + 0x0820, + 0x0821, + 0x0822, + 0x0823, + 0x0825, + 0x0826, + 0x0827, + 0x0829, + 0x082A, + 0x082B, + 0x082C, + 0x082D, + 0x0951, + 0x0953, + 0x0954, + 0x0F82, + 0x0F83, + 0x0F86, + 0x0F87, + 0x135D, + 0x135E, + 0x135F, + 0x17DD, + 0x193A, + 0x1A17, + 0x1A75, + 0x1A76, + 0x1A77, + 0x1A78, + 0x1A79, + 0x1A7A, + 0x1A7B, + 0x1A7C, + 0x1B6B, + 0x1B6D, + 0x1B6E, + 0x1B6F, + 0x1B70, + 0x1B71, + 0x1B72, + 0x1B73, + 0x1CD0, + 0x1CD1, + 0x1CD2, + 0x1CDA, + 0x1CDB, + 0x1CE0, + 0x1DC0, + 0x1DC1, + 0x1DC3, + 0x1DC4, + 0x1DC5, + 0x1DC6, + 0x1DC7, + 0x1DC8, + 0x1DC9, + 0x1DCB, + 0x1DCC, + 0x1DD1, + 0x1DD2, + 0x1DD3, + 0x1DD4, + 0x1DD5, + 0x1DD6, + 0x1DD7, + 0x1DD8, + 0x1DD9, + 0x1DDA, + 0x1DDB, + 0x1DDC, + 0x1DDD, + 0x1DDE, + 0x1DDF, + 0x1DE0, + 0x1DE1, + 0x1DE2, + 0x1DE3, + 0x1DE4, + 0x1DE5, + 0x1DE6, + 0x1DFE, + 0x20D0, + 0x20D1, + 0x20D4, + 0x20D5, + 0x20D6, + 0x20D7, + 0x20DB, + 0x20DC, + 0x20E1, + 0x20E7, + 0x20E9, + 0x20F0, + 0x2CEF, + 0x2CF0, + 0x2CF1, + 0x2DE0, + 0x2DE1, + 0x2DE2, + 0x2DE3, + 0x2DE4, + 0x2DE5, + 0x2DE6, + 0x2DE7, + 0x2DE8, + 0x2DE9, + 0x2DEA, + 0x2DEB, + 0x2DEC, + 0x2DED, + 0x2DEE, + 0x2DEF, + 0x2DF0, + 0x2DF1, + 0x2DF2, + 0x2DF3, + 0x2DF4, + 0x2DF5, + 0x2DF6, + 0x2DF7, + 0x2DF8, + 0x2DF9, + 0x2DFA, + 0x2DFB, + 0x2DFC, + 0x2DFD, + 0x2DFE, + 0x2DFF, + 0xA66F, + 0xA67C, + 0xA67D, + 0xA6F0, + 0xA6F1, + 0xA8E0, + 0xA8E1, + 0xA8E2, + 0xA8E3, + 0xA8E4, + 0xA8E5, + 0xA8E6, + 0xA8E7, + 0xA8E8, + 0xA8E9, + 0xA8EA, + 0xA8EB, + 0xA8EC, + 0xA8ED, + 0xA8EE, + 0xA8EF, + 0xA8F0, + 0xA8F1, + 0xAAB0, + 0xAAB2, + 0xAAB3, + 0xAAB7, + 0xAAB8, + 0xAABE, + 0xAABF, + 0xAAC1, + 0xFE20, + 0xFE21, + 0xFE22, + 0xFE23, + 0xFE24, + 0xFE25, + 0xFE26, + 0x10A0F, + 0x10A38, + 0x1D185, + 0x1D186, + 0x1D187, + 0x1D188, + 0x1D189, + 0x1D1AA, + 0x1D1AB, + 0x1D1AC, + 0x1D1AD, + 0x1D242, + 0x1D243, + 0x1D244, +}; + +test "unicode diacritic sorted" { + // diacritics must be sorted since we use a binary search. + try testing.expect(std.sort.isSorted(u21, diacritics, {}, (struct { + fn lessThan(context: void, lhs: u21, rhs: u21) bool { + _ = context; + return lhs < rhs; + } + }).lessThan)); +} + +test "unicode diacritic" { + // Some spot checks based on Kitty behavior + try testing.expectEqual(30, getIndex(0x483).?); + try testing.expectEqual(294, getIndex(0x1d242).?); +} + +test "unicode placement: none" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + t.modes.set(.grapheme_cluster, true); + + // Single cell + try t.printString("hello\nworld\n1\n2"); + + // No placements + const pin = t.screens.active.pages.getTopLeft(.viewport); + var it = placementIterator(pin, null); + try testing.expect(it.next() == null); +} + +test "unicode placement: single row/col" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + t.modes.set(.grapheme_cluster, true); + + // Single cell + try t.printString("\u{10EEEE}\u{0305}\u{0305}"); + + // Get our top left pin + const pin = t.screens.active.pages.getTopLeft(.viewport); + + // Should have exactly one placement + var it = placementIterator(pin, null); + { + const p = it.next().?; + try testing.expectEqual(0, p.image_id); + try testing.expectEqual(0, p.placement_id); + try testing.expectEqual(0, p.row); + try testing.expectEqual(0, p.col); + } + try testing.expect(it.next() == null); +} + +test "unicode placement: continuation break" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 5, .cols = 10 }); + defer t.deinit(alloc); + t.modes.set(.grapheme_cluster, true); + + // Two runs because it jumps cols + try t.printString("\u{10EEEE}\u{0305}\u{0305}"); + try t.printString("\u{10EEEE}\u{0305}\u{030E}"); + + // Get our top left pin + const pin = t.screens.active.pages.getTopLeft(.viewport); + + // Should have exactly one placement + var it = placementIterator(pin, null); + { + const p = it.next().?; + try testing.expectEqual(0, p.image_id); + try testing.expectEqual(0, p.placement_id); + try testing.expectEqual(0, p.row); + try testing.expectEqual(0, p.col); + try testing.expectEqual(1, p.width); + } + { + const p = it.next().?; + try testing.expectEqual(0, p.image_id); + try testing.expectEqual(0, p.placement_id); + try testing.expectEqual(0, p.row); + try testing.expectEqual(2, p.col); + try testing.expectEqual(1, p.width); + } + try testing.expect(it.next() == null); +} + +test "unicode placement: continuation with diacritics set" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 5, .cols = 10 }); + defer t.deinit(alloc); + t.modes.set(.grapheme_cluster, true); + + // Three cells. They'll continue even though they're explicit + try t.printString("\u{10EEEE}\u{0305}\u{0305}"); + try t.printString("\u{10EEEE}\u{0305}\u{030D}"); + try t.printString("\u{10EEEE}\u{0305}\u{030E}"); + + // Get our top left pin + const pin = t.screens.active.pages.getTopLeft(.viewport); + + // Should have exactly one placement + var it = placementIterator(pin, null); + { + const p = it.next().?; + try testing.expectEqual(0, p.image_id); + try testing.expectEqual(0, p.placement_id); + try testing.expectEqual(0, p.row); + try testing.expectEqual(0, p.col); + try testing.expectEqual(3, p.width); + } + try testing.expect(it.next() == null); +} + +test "unicode placement: continuation with no col" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 5, .cols = 10 }); + defer t.deinit(alloc); + t.modes.set(.grapheme_cluster, true); + + // Three cells. They'll continue even though they're explicit + try t.printString("\u{10EEEE}\u{0305}\u{0305}"); + try t.printString("\u{10EEEE}\u{0305}"); + try t.printString("\u{10EEEE}\u{0305}"); + + // Get our top left pin + const pin = t.screens.active.pages.getTopLeft(.viewport); + + // Should have exactly one placement + var it = placementIterator(pin, null); + { + const p = it.next().?; + try testing.expectEqual(0, p.image_id); + try testing.expectEqual(0, p.placement_id); + try testing.expectEqual(0, p.row); + try testing.expectEqual(0, p.col); + try testing.expectEqual(3, p.width); + } + try testing.expect(it.next() == null); +} + +test "unicode placement: continuation with no diacritics" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 5, .cols = 10 }); + defer t.deinit(alloc); + t.modes.set(.grapheme_cluster, true); + + // Three cells. They'll continue even though they're explicit + try t.printString("\u{10EEEE}"); + try t.printString("\u{10EEEE}"); + try t.printString("\u{10EEEE}"); + + // Get our top left pin + const pin = t.screens.active.pages.getTopLeft(.viewport); + + // Should have exactly one placement + var it = placementIterator(pin, null); + { + const p = it.next().?; + try testing.expectEqual(0, p.image_id); + try testing.expectEqual(0, p.placement_id); + try testing.expectEqual(0, p.row); + try testing.expectEqual(0, p.col); + try testing.expectEqual(3, p.width); + } + try testing.expect(it.next() == null); +} + +test "unicode placement: run ending" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 5, .cols = 10 }); + defer t.deinit(alloc); + t.modes.set(.grapheme_cluster, true); + + // Three cells. They'll continue even though they're explicit + try t.printString("\u{10EEEE}\u{0305}\u{0305}"); + try t.printString("\u{10EEEE}\u{0305}\u{030D}"); + try t.printString("ABC"); + + // Get our top left pin + const pin = t.screens.active.pages.getTopLeft(.viewport); + + // Should have exactly one placement + var it = placementIterator(pin, null); + { + const p = it.next().?; + try testing.expectEqual(0, p.image_id); + try testing.expectEqual(0, p.placement_id); + try testing.expectEqual(0, p.row); + try testing.expectEqual(0, p.col); + try testing.expectEqual(2, p.width); + } + try testing.expect(it.next() == null); +} + +test "unicode placement: run starting in the middle" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 5, .cols = 10 }); + defer t.deinit(alloc); + t.modes.set(.grapheme_cluster, true); + + // Three cells. They'll continue even though they're explicit + try t.printString("ABC"); + try t.printString("\u{10EEEE}\u{0305}\u{0305}"); + try t.printString("\u{10EEEE}\u{0305}\u{030D}"); + + // Get our top left pin + const pin = t.screens.active.pages.getTopLeft(.viewport); + + // Should have exactly one placement + var it = placementIterator(pin, null); + { + const p = it.next().?; + try testing.expectEqual(0, p.image_id); + try testing.expectEqual(0, p.placement_id); + try testing.expectEqual(0, p.row); + try testing.expectEqual(0, p.col); + try testing.expectEqual(2, p.width); + } + try testing.expect(it.next() == null); +} + +test "unicode placement: specifying image id as palette" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + t.modes.set(.grapheme_cluster, true); + + // Single cell + try t.setAttribute(.{ .@"256_fg" = 42 }); + try t.printString("\u{10EEEE}\u{0305}\u{0305}"); + + // Get our top left pin + const pin = t.screens.active.pages.getTopLeft(.viewport); + + // Should have exactly one placement + var it = placementIterator(pin, null); + { + const p = it.next().?; + try testing.expectEqual(42, p.image_id); + try testing.expectEqual(0, p.placement_id); + try testing.expectEqual(0, p.row); + try testing.expectEqual(0, p.col); + } + try testing.expect(it.next() == null); +} + +test "unicode placement: specifying image id with high bits" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + t.modes.set(.grapheme_cluster, true); + + // Single cell + try t.setAttribute(.{ .@"256_fg" = 42 }); + try t.printString("\u{10EEEE}\u{0305}\u{0305}\u{030E}"); + + // Get our top left pin + const pin = t.screens.active.pages.getTopLeft(.viewport); + + // Should have exactly one placement + var it = placementIterator(pin, null); + { + const p = it.next().?; + try testing.expectEqual(33554474, p.image_id); + try testing.expectEqual(0, p.placement_id); + try testing.expectEqual(0, p.row); + try testing.expectEqual(0, p.col); + } + try testing.expect(it.next() == null); +} + +test "unicode placement: specifying placement id as palette" { + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + t.modes.set(.grapheme_cluster, true); + + // Single cell + try t.setAttribute(.{ .@"256_fg" = 42 }); + try t.setAttribute(.{ .@"256_underline_color" = 21 }); + try t.printString("\u{10EEEE}\u{0305}\u{0305}"); + + // Get our top left pin + const pin = t.screens.active.pages.getTopLeft(.viewport); + + // Should have exactly one placement + var it = placementIterator(pin, null); + { + const p = it.next().?; + try testing.expectEqual(42, p.image_id); + try testing.expectEqual(21, p.placement_id); + try testing.expectEqual(0, p.row); + try testing.expectEqual(0, p.col); + } + try testing.expect(it.next() == null); +} + +// Fish: +// printf "\033_Gf=100,i=1,t=f,q=2;$(printf dog.png | base64)\033\\" +// printf "\e[38;5;1m\U10EEEE\U0305\U0305\U10EEEE\U0305\U030D\U10EEEE\U0305\U030E\U10EEEE\U0305\U0310\e[39m\n" +// printf "\e[38;5;1m\U10EEEE\U030D\U0305\U10EEEE\U030D\U030D\U10EEEE\U030D\U030E\U10EEEE\U030D\U0310\e[39m\n" +// printf "\033_Ga=p,i=1,U=1,q=2,c=4,r=2\033\\" +test "unicode render placement: dog 4x2" { + const alloc = testing.allocator; + const cell_width = 36; + const cell_height = 80; + + var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); + defer t.deinit(alloc); + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + const image: Image = .{ .id = 1, .width = 500, .height = 306 }; + try s.addImage(alloc, image); + try s.addPlacement(alloc, 1, 0, .{ + .location = .{ .virtual = {} }, + .columns = 4, + .rows = 2, + }); + + // Row 1 + { + const p: Placement = .{ + .pin = t.screens.active.cursor.page_pin.*, + .image_id = 1, + .placement_id = 0, + .col = 0, + .row = 0, + .width = 4, + .height = 1, + }; + const rp = try p.renderPlacement(&s, &image, cell_width, cell_height); + try testing.expectEqual(0, rp.offset_x); + try testing.expectEqual(36, rp.offset_y); + try testing.expectEqual(0, rp.source_x); + try testing.expectEqual(0, rp.source_y); + try testing.expectEqual(500, rp.source_width); + try testing.expectEqual(153, rp.source_height); + try testing.expectEqual(144, rp.dest_width); + try testing.expectEqual(44, rp.dest_height); + } + // Row 2 + { + const p: Placement = .{ + .pin = t.screens.active.cursor.page_pin.*, + .image_id = 1, + .placement_id = 0, + .col = 0, + .row = 1, + .width = 4, + .height = 1, + }; + const rp = try p.renderPlacement(&s, &image, cell_width, cell_height); + try testing.expectEqual(0, rp.offset_x); + try testing.expectEqual(0, rp.offset_y); + try testing.expectEqual(0, rp.source_x); + try testing.expectEqual(153, rp.source_y); + try testing.expectEqual(500, rp.source_width); + try testing.expectEqual(153, rp.source_height); + try testing.expectEqual(144, rp.dest_width); + try testing.expectEqual(44, rp.dest_height); + } +} + +// Fish: +// printf "\033_Gf=100,i=1,t=f,q=2;$(printf dog.png | base64)\033\\" +// printf "\e[38;5;1m\U10EEEE\U0305\U0305\U10EEEE\U0305\U030D\U10EEEE\U0305\U030E\U10EEEE\U0305\U0310\e[39m\n" +// printf "\e[38;5;1m\U10EEEE\U030D\U0305\U10EEEE\U030D\U030D\U10EEEE\U030D\U030E\U10EEEE\U030D\U0310\e[39m\n" +// printf "\033_Ga=p,i=1,U=1,q=2,c=2,r=2\033\\" +test "unicode render placement: dog 2x2 with blank cells" { + const alloc = testing.allocator; + const cell_width = 36; + const cell_height = 80; + + var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); + defer t.deinit(alloc); + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + const image: Image = .{ .id = 1, .width = 500, .height = 306 }; + try s.addImage(alloc, image); + try s.addPlacement(alloc, 1, 0, .{ + .location = .{ .virtual = {} }, + .columns = 2, + .rows = 2, + }); + + // Row 1 + { + const p: Placement = .{ + .pin = t.screens.active.cursor.page_pin.*, + .image_id = 1, + .placement_id = 0, + .col = 0, + .row = 0, + .width = 4, + .height = 1, + }; + const rp = try p.renderPlacement(&s, &image, cell_width, cell_height); + try testing.expectEqual(0, rp.offset_x); + try testing.expectEqual(58, rp.offset_y); + try testing.expectEqual(0, rp.source_x); + try testing.expectEqual(0, rp.source_y); + try testing.expectEqual(500, rp.source_width); + try testing.expectEqual(153, rp.source_height); + try testing.expectEqual(72, rp.dest_width); + try testing.expectEqual(22, rp.dest_height); + } + // Row 2 + { + const p: Placement = .{ + .pin = t.screens.active.cursor.page_pin.*, + .image_id = 1, + .placement_id = 0, + .col = 0, + .row = 1, + .width = 4, + .height = 1, + }; + const rp = try p.renderPlacement(&s, &image, cell_width, cell_height); + try testing.expectEqual(0, rp.offset_x); + try testing.expectEqual(0, rp.offset_y); + try testing.expectEqual(0, rp.source_x); + try testing.expectEqual(153, rp.source_y); + try testing.expectEqual(500, rp.source_width); + try testing.expectEqual(153, rp.source_height); + try testing.expectEqual(72, rp.dest_width); + try testing.expectEqual(22, rp.dest_height); + } +} + +// Fish: +// printf "\033_Gf=100,i=1,t=f,q=2;$(printf dog.png | base64)\033\\" +// printf "\e[38;5;1m\U10EEEE\U0305\U0305\U10EEEE\U0305\U030D\U10EEEE\U0305\U030E\U10EEEE\U0305\U0310\e[39m\n" +// printf "\033_Ga=p,i=1,U=1,q=2,c=1,r=1\033\\" +test "unicode render placement: dog 1x1" { + const alloc = testing.allocator; + const cell_width = 36; + const cell_height = 80; + + var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); + defer t.deinit(alloc); + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + const image: Image = .{ .id = 1, .width = 500, .height = 306 }; + try s.addImage(alloc, image); + try s.addPlacement(alloc, 1, 0, .{ + .location = .{ .virtual = {} }, + .columns = 1, + .rows = 1, + }); + + // Row 1 + { + const p: Placement = .{ + .pin = t.screens.active.cursor.page_pin.*, + .image_id = 1, + .placement_id = 0, + .col = 0, + .row = 0, + .width = 4, + .height = 1, + }; + const rp = try p.renderPlacement(&s, &image, cell_width, cell_height); + try testing.expectEqual(0, rp.offset_x); + try testing.expectEqual(29, rp.offset_y); + try testing.expectEqual(0, rp.source_x); + try testing.expectEqual(0, rp.source_y); + try testing.expectEqual(500, rp.source_width); + try testing.expectEqual(306, rp.source_height); + try testing.expectEqual(36, rp.dest_width); + try testing.expectEqual(22, rp.dest_height); + } +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/key.zig b/vendor/libghostty-vt/src/terminal/kitty/key.zig new file mode 100644 index 00000000..8594c4c3 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/key.zig @@ -0,0 +1,169 @@ +//! 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 FlagStack = struct { + const len = 8; + + flags: [len]Flags = @splat(.disabled), + idx: u3 = 0, + + /// Return the current stack value + pub fn current(self: FlagStack) Flags { + return self.flags[self.idx]; + } + + /// Perform the "set" operation as described in the spec for + /// the CSI = u sequence. + pub fn set( + self: *FlagStack, + mode: SetMode, + v: Flags, + ) 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: *FlagStack, flags: Flags) 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: *FlagStack, 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 = @splat(.disabled); + return; + } + + for (0..n) |_| { + self.flags[self.idx] = .disabled; + self.idx -%= 1; + } + } + + // Make sure we the overflow works as expected + test { + const testing = std.testing; + var stack: FlagStack = .{}; + 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 Flags = packed struct(u5) { + disambiguate: bool = false, + report_events: bool = false, + report_alternates: bool = false, + report_all: bool = false, + report_associated: bool = false, + + /// Kitty keyboard protocol disabled (all flags off). + pub const disabled: Flags = .{ + .disambiguate = false, + .report_events = false, + .report_alternates = false, + .report_all = false, + .report_associated = false, + }; + + /// Sets all modes on. + pub const @"true": Flags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }; + + pub fn int(self: Flags) 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), + (Flags{ .disambiguate = true }).int(), + ); + try testing.expectEqual( + @as(u5, 0b10), + (Flags{ .report_events = true }).int(), + ); + } +}; + +/// The possible modes for setting the key flags. +pub const SetMode = enum { set, @"or", not }; + +test "FlagStack: push pop" { + const testing = std.testing; + var stack: FlagStack = .{}; + stack.push(.{ .disambiguate = true }); + try testing.expectEqual( + Flags{ .disambiguate = true }, + stack.current(), + ); + + stack.pop(1); + try testing.expectEqual(Flags{}, stack.current()); +} + +test "FlagStack: pop big number" { + const testing = std.testing; + var stack: FlagStack = .{}; + stack.pop(100); + try testing.expectEqual(Flags{}, stack.current()); +} + +test "FlagStack: set" { + const testing = std.testing; + var stack: FlagStack = .{}; + stack.set(.set, .{ .disambiguate = true }); + try testing.expectEqual( + Flags{ .disambiguate = true }, + stack.current(), + ); + + stack.set(.@"or", .{ .report_events = true }); + try testing.expectEqual( + Flags{ + .disambiguate = true, + .report_events = true, + }, + stack.current(), + ); + + stack.set(.not, .{ .report_events = true }); + try testing.expectEqual( + Flags{ .disambiguate = true }, + stack.current(), + ); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/testdata/dog.png b/vendor/libghostty-vt/src/terminal/kitty/testdata/dog.png Binary files differnew file mode 100644 index 00000000..a5749c23 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/testdata/dog.png diff --git a/vendor/libghostty-vt/src/terminal/kitty/testdata/image-png-none-50x76-2147483647-raw.data b/vendor/libghostty-vt/src/terminal/kitty/testdata/image-png-none-50x76-2147483647-raw.data Binary files differnew file mode 100644 index 00000000..032cb07c --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/testdata/image-png-none-50x76-2147483647-raw.data diff --git a/vendor/libghostty-vt/src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647-raw.data b/vendor/libghostty-vt/src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647-raw.data Binary files differnew file mode 100644 index 00000000..2c070cb2 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647-raw.data diff --git a/vendor/libghostty-vt/src/terminal/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data b/vendor/libghostty-vt/src/terminal/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data Binary files differnew file mode 100644 index 00000000..ea816083 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data |
