aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/kitty
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-04 19:22:04 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 01:23:06 +0300
commit41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch)
tree3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/terminal/kitty
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/kitty')
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/color.zig77
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics.zig36
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig1325
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig570
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig835
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_render.zig27
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig1359
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_unicode.zig1347
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/key.zig169
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/testdata/dog.pngbin0 -> 237387 bytes
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/testdata/image-png-none-50x76-2147483647-raw.databin0 -> 86 bytes
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647-raw.databin0 -> 900 bytes
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.databin0 -> 30455 bytes
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
new file mode 100644
index 00000000..a5749c23
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/kitty/testdata/dog.png
Binary files differ
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
new file mode 100644
index 00000000..032cb07c
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/kitty/testdata/image-png-none-50x76-2147483647-raw.data
Binary files differ
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
new file mode 100644
index 00000000..2c070cb2
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647-raw.data
Binary files differ
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
new file mode 100644
index 00000000..ea816083
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data
Binary files differ