aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig
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/graphics_exec.zig
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig570
1 files changed, 570 insertions, 0 deletions
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);
+ }
+}