diff --git a/src/terminal/kitty/dnd.zig b/src/terminal/kitty/dnd.zig index 5a70b0a6f..c60415c41 100644 --- a/src/terminal/kitty/dnd.zig +++ b/src/terminal/kitty/dnd.zig @@ -60,6 +60,7 @@ //! initiated events always use ST. const dnd_command = @import("dnd_command.zig"); +const dnd_response = @import("dnd_response.zig"); pub const EventType = dnd_command.EventType; pub const Metadata = dnd_command.Metadata; @@ -68,6 +69,12 @@ pub const Operations = dnd_command.Operations; pub const Request = dnd_command.Request; pub const Chunking = dnd_command.Chunking; +pub const Errno = dnd_response.Errno; +pub const RequestKeys = dnd_response.RequestKeys; +pub const encode = dnd_response.encode; +pub const encodeError = dnd_response.encodeError; + test { _ = dnd_command; + _ = dnd_response; } diff --git a/src/terminal/kitty/dnd_response.zig b/src/terminal/kitty/dnd_response.zig new file mode 100644 index 000000000..1f7584f12 --- /dev/null +++ b/src/terminal/kitty/dnd_response.zig @@ -0,0 +1,248 @@ +const std = @import("std"); +const Terminator = @import("../osc.zig").Terminator; + +/// The maximum raw bytes per base64-encoded chunk, chosen by kitty so a +/// chunk is exactly 4096 base64 characters (the protocol's chunk limit). +pub const max_chunk_raw = 3072; + +/// The maximum bytes per plain-text (non-base64) chunk. +pub const max_chunk_plain = 4096; + +/// Error names used in protocol error payloads. The wire encoding is +/// the tag name itself. This matches kitty's get_errno_name vocabulary, +/// which extends the spec's list with EISDIR, ENOSPC, and OK. +pub const Errno = enum { + OK, + EPERM, + ENOENT, + EIO, + EINVAL, + EMFILE, + ENOMEM, + EFBIG, + EISDIR, + ENOSPC, + EUNKNOWN, +}; + +/// The payload encoding for a message. +pub const Encoding = enum { + /// Payload bytes are sent as-is (MIME lists, error strings). + plain, + + /// Payload bytes are base64-encoded (all binary data). + base64, +}; + +/// The `x`/`y`/`Y` keys of the data request currently being answered, +/// echoed in responses and errors so the client can match them up. Only +/// non-zero keys are written, matching kitty's drop_append_request_keys. +pub const RequestKeys = struct { + x: i32 = 0, + y: i32 = 0, + Y: i32 = 0, + + pub fn format(self: RequestKeys, writer: *std.Io.Writer) !void { + if (self.x != 0) try writer.print(":x={d}", .{self.x}); + if (self.y != 0) try writer.print(":y={d}", .{self.y}); + if (self.Y != 0) try writer.print(":Y={d}", .{self.Y}); + } +}; + +/// Encode a complete protocol message: one bare OSC when `data` is +/// empty, otherwise one complete OSC per chunk of `data`, each +/// repeating the header. The final chunk carries `m=0`, earlier +/// chunks `m=1`. +/// +/// `header` is the metadata without the OSC introducer, e.g. "t=q" or +/// "t=m:x=5:y=3". The client ID is appended as `:i=N` when non-zero. +pub fn encode( + writer: *std.Io.Writer, + header: []const u8, + client_id: u32, + data: []const u8, + encoding: Encoding, + terminator: Terminator, +) std.Io.Writer.Error!void { + // The client ID is part of the repeated header. + var id_buf: [16]u8 = undefined; + const id: []const u8 = if (client_id != 0) std.fmt.bufPrint( + &id_buf, + ":i={d}", + .{client_id}, + ) catch unreachable else ""; + + if (data.len == 0) { + try writer.print("\x1b]72;{s}{s}{s}", .{ + header, + id, + terminator.string(), + }); + return; + } + + const limit: usize = switch (encoding) { + .base64 => max_chunk_raw, + .plain => max_chunk_plain, + }; + + var offset: usize = 0; + while (offset < data.len) { + const chunk_len = @min(data.len - offset, limit); + const chunk = data[offset .. offset + chunk_len]; + offset += chunk_len; + const last: u8 = if (offset >= data.len) '0' else '1'; + + try writer.print("\x1b]72;{s}{s}:m={c};", .{ header, id, last }); + switch (encoding) { + .plain => try writer.writeAll(chunk), + .base64 => { + var b64_buf: [std.base64.standard.Encoder.calcSize(max_chunk_raw)]u8 = undefined; + try writer.writeAll(std.base64.standard.Encoder.encode( + &b64_buf, + chunk, + )); + }, + } + try writer.writeAll(terminator.string()); + } +} + +/// Encode an error response. `kind` selects the header: t=R for drop +/// data request errors, t=E for drag offer errors. The payload is +/// "NAME" or "NAME:description", sent plain (not base64). +pub fn encodeError( + writer: *std.Io.Writer, + kind: enum { drop, drag }, + keys: RequestKeys, + client_id: u32, + errno: Errno, + desc: []const u8, + terminator: Terminator, +) std.Io.Writer.Error!void { + var header_buf: [64]u8 = undefined; + const header = std.fmt.bufPrint(&header_buf, "t={c}{f}", .{ + @as(u8, switch (kind) { + .drop => 'R', + .drag => 'E', + }), + keys, + }) catch unreachable; + + // Description strings are short static messages; size the buffer + // for the longest error name plus a generous description. + var payload_buf: [256]u8 = undefined; + const payload = if (desc.len > 0) std.fmt.bufPrint( + &payload_buf, + "{t}:{s}", + .{ errno, desc }, + ) catch unreachable else @tagName(errno); + + try encode(writer, header, client_id, payload, .plain, terminator); +} + +test "encode: bare message" { + const testing = std.testing; + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, "t=q", 0, "", .plain, .st); + try testing.expectEqualStrings("\x1b]72;t=q\x1b\\", writer.buffered()); +} + +test "encode: bare message with client id" { + const testing = std.testing; + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, "t=q", 7, "", .plain, .st); + try testing.expectEqualStrings("\x1b]72;t=q:i=7\x1b\\", writer.buffered()); +} + +test "encode: plain payload single chunk" { + const testing = std.testing; + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, "t=m:x=1:y=2", 0, "text/plain ", .plain, .st); + try testing.expectEqualStrings( + "\x1b]72;t=m:x=1:y=2:m=0;text/plain \x1b\\", + writer.buffered(), + ); +} + +test "encode: base64 payload chunking" { + const testing = std.testing; + const alloc = testing.allocator; + + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + + // Exactly one byte more than a chunk to force two chunks. + const data = [_]u8{'A'} ** (max_chunk_raw + 1); + try encode(&aw.writer, "t=r:x=1", 3, &data, .base64, .st); + + const out = aw.written(); + + // First chunk: full header, m=1, 4096 base64 chars. + const prefix = "\x1b]72;t=r:x=1:i=3:m=1;"; + try testing.expect(std.mem.startsWith(u8, out, prefix)); + const first_end = std.mem.indexOf(u8, out, "\x1b\\").?; + try testing.expectEqual(@as(usize, prefix.len + 4096), first_end); + + // Second chunk: m=0 with the single remaining byte. + const rest = out[first_end + 2 ..]; + try testing.expect(std.mem.startsWith(u8, rest, "\x1b]72;t=r:x=1:i=3:m=0;")); + + // Decodes back to the original data. + var decoded: std.ArrayList(u8) = .empty; + defer decoded.deinit(alloc); + var it = std.mem.splitSequence(u8, out, "\x1b\\"); + while (it.next()) |osc| { + if (osc.len == 0) continue; + const payload_start = std.mem.indexOfScalar(u8, osc, ';').?; + const payload = osc[std.mem.indexOfScalarPos(u8, osc, payload_start + 1, ';').? + 1 ..]; + const n = try std.base64.standard.Decoder.calcSizeForSlice(payload); + const start = decoded.items.len; + try decoded.resize(alloc, start + n); + try std.base64.standard.Decoder.decode(decoded.items[start..], payload); + } + try testing.expectEqualSlices(u8, &data, decoded.items); +} + +test "encodeError: with and without description" { + const testing = std.testing; + var buf: [256]u8 = undefined; + + { + var writer: std.Io.Writer = .fixed(&buf); + try encodeError(&writer, .drop, .{ .x = 2 }, 0, .ENOENT, "drop data request index out of bounds", .st); + try testing.expectEqualStrings( + "\x1b]72;t=R:x=2:m=0;ENOENT:drop data request index out of bounds\x1b\\", + writer.buffered(), + ); + } + { + var writer: std.Io.Writer = .fixed(&buf); + try encodeError(&writer, .drag, .{}, 5, .EPERM, "", .st); + try testing.expectEqualStrings( + "\x1b]72;t=E:i=5:m=0;EPERM\x1b\\", + writer.buffered(), + ); + } +} + +test "RequestKeys: only non-zero keys written" { + const testing = std.testing; + var buf: [64]u8 = undefined; + + { + const s = try std.fmt.bufPrint(&buf, "{f}", .{RequestKeys{}}); + try testing.expectEqualStrings("", s); + } + { + const s = try std.fmt.bufPrint(&buf, "{f}", .{RequestKeys{ .x = 1, .y = 2, .Y = 3 }}); + try testing.expectEqualStrings(":x=1:y=2:Y=3", s); + } + { + const s = try std.fmt.bufPrint(&buf, "{f}", .{RequestKeys{ .Y = 4 }}); + try testing.expectEqualStrings(":Y=4", s); + } +}