diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (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.zig | 570 |
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); + } +} |
