terminal/kitty: drag and drop response encoding

This commit is contained in:
Mitchell Hashimoto
2026-08-21 14:58:15 -07:00
parent 7c845e8af5
commit 38746b8c14
2 changed files with 255 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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);
}
}