mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
terminal/kitty: drag and drop response encoding
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
248
src/terminal/kitty/dnd_response.zig
Normal file
248
src/terminal/kitty/dnd_response.zig
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user