diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td
index 110fb10ac..3d70361e8 100644
--- a/.github/VOUCHED.td
+++ b/.github/VOUCHED.td
@@ -156,6 +156,7 @@ hulet
i999rri
icodesign
illiakrauchanka
+j-c-m
j0hnm4r5
jacobsandlund
jake-stewart
@@ -299,6 +300,7 @@ slsrepo
steven-tk
sunshine-syz
svmhdvn
+-tangivis
tasselx
tbrundige
tdgroot
diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h
index 89ae241d6..b23303605 100644
--- a/include/ghostty/vt/terminal.h
+++ b/include/ghostty/vt/terminal.h
@@ -95,8 +95,8 @@ extern "C" {
* | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS query (CSI 14/16/18 t) or mode 2048 enable |
* | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) |
* | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)|
- * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 |
- * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ` | `GhosttyTerminalClipboardReadFn` | Clipboard read via OSC 52 "?" |
+ * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 / OSC 5522 |
+ * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ` | `GhosttyTerminalClipboardReadFn` | Clipboard read via OSC 52 "?" / OSC 5522 |
* | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 |
* | `GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT` | `GhosttyTerminalProgressReportFn` | Progress report via OSC 9;4 |
* | `GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE` | `GhosttyTerminalUnknownSequenceFn` | Unsupported sequence identifier |
@@ -494,7 +494,10 @@ typedef struct {
* Result of a clipboard write callback.
*
* Protocols without write acknowledgements, including OSC 52 and iTerm2
- * OSC 1337 Copy, ignore this result.
+ * OSC 1337 Copy, ignore this result. The Kitty clipboard protocol
+ * (OSC 5522) acknowledges writes: each result maps to the corresponding
+ * protocol status (DONE, EPERM, ENOSYS, EBUSY, EINVAL, EIO) and is
+ * reported back to the running program through the write_pty callback.
*
* @ingroup terminal
*/
@@ -525,9 +528,18 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Called synchronously for a complete logical clipboard write. Protocol
* details such as OSC 52 selectors, base64 encoding, multipart chunks,
* aliases, and terminators are normalized before this callback is invoked.
- * OSC 52 and iTerm2 OSC 1337 Copy writes therefore use the same callback
- * shape. OSC 52 clipboard read requests ("?") are delivered to
- * GhosttyTerminalClipboardReadFn instead.
+ * OSC 52, iTerm2 OSC 1337 Copy, and Kitty clipboard (OSC 5522) writes
+ * therefore use the same callback shape.
+ *
+ * Every invocation is one complete write: the contents replace whatever
+ * the destination previously held, so there is never a partial update to
+ * detect or a reset to perform. A Kitty clipboard write transaction
+ * results in exactly one invocation, at commit, carrying all of the
+ * transaction's MIME representations together; its protocol response is
+ * generated automatically from the returned result.
+ *
+ * Clipboard read requests (OSC 52 "?" and OSC 5522 reads) are delivered
+ * to GhosttyTerminalClipboardReadFn instead.
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
@@ -572,8 +584,9 @@ typedef enum {
* duration of the reply call and may be freed as soon as it returns.
*
* Any result other than GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS answers the
- * program with an empty clipboard; the other fields are ignored in that
- * case. On success, `contents` should carry one representation per
+ * program with an empty clipboard (OSC 52) or the matching protocol status
+ * (OSC 5522: EPERM, ENOSYS, EBUSY, EIO); the other fields are ignored in
+ * that case. On success, `contents` should carry one representation per
* requested MIME type (GhosttyClipboardRead::mimes) that the clipboard
* has; unrequested representations are ignored. Protocols that carry a
* single text value (OSC 52) use the first entry with a text MIME type
@@ -637,7 +650,7 @@ typedef void (*GhosttyClipboardReadReplyFn)(
* GhosttyClipboardReadReply. This must happen before the callback returns;
* the request is invalid afterwards. Calling `reply` more than once is
* ignored. Returning without replying answers the program with an empty
- * clipboard.
+ * clipboard (OSC 52) or EPERM (OSC 5522).
*
* @ingroup terminal
*/
@@ -695,15 +708,22 @@ struct GhosttyClipboardRead {
* Callback function type for clipboard_read.
*
* Called synchronously when the running program requests clipboard contents
- * via OSC 52 with a "?" payload. Answering lets the program read the user's
- * clipboard, so the embedder is expected to mediate consent. Because the
- * read is synchronous, an embedder that needs to ask the user must block
- * (for example by running a modal prompt) until it has an answer; the VT
- * stream waits until the callback returns.
+ * via OSC 52 with a "?" payload or a Kitty clipboard (OSC 5522) read.
+ * Answering lets the program read the user's clipboard, so the embedder is
+ * expected to mediate consent. Because the read is synchronous, an embedder
+ * that needs to ask the user must block (for example by running a modal
+ * prompt) until it has an answer; the VT stream waits until the callback
+ * returns.
*
* Answer by calling `read->reply(read, &reply)` before returning. See
* GhosttyClipboardRead for the full contract.
*
+ * OSC 5522 requests carry the program's MIME list, name, and password grant
+ * state; a reply that sets `remember` records a session grant so later
+ * requests with the same password arrive with `granted` set. Kitty itself
+ * serves a request for only the targets listing (`list` with no `mimes`)
+ * without prompting.
+ *
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
* @param read Borrowed clipboard read request
@@ -1243,9 +1263,10 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* Callback invoked when the running program performs a clipboard write.
- * OSC 52 and iTerm2 OSC 1337 Copy writes are normalized to an atomic set
- * of decoded MIME representations. Set to NULL to ignore clipboard writes.
- * Clipboard read requests are delivered to
+ * OSC 52, iTerm2 OSC 1337 Copy, and Kitty clipboard (OSC 5522) writes
+ * are normalized to an atomic set of decoded MIME representations. Set
+ * to NULL to ignore clipboard writes (Kitty clipboard writes are then
+ * refused with ENOSYS). Clipboard read requests are delivered to
* GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ instead.
*
* Input type: GhosttyTerminalClipboardWriteFn
@@ -1411,9 +1432,10 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* Callback invoked when the running program requests clipboard contents
- * via OSC 52 with a "?" payload. The read is synchronous and must be
- * answered before the callback returns. Set to NULL to ignore clipboard
- * read requests (the default).
+ * via OSC 52 with a "?" payload or a Kitty clipboard (OSC 5522) read. The
+ * read is synchronous and must be answered before the callback returns.
+ * Set to NULL (the default) to ignore OSC 52 read requests and refuse
+ * OSC 5522 reads with EPERM.
*
* Input type: GhosttyTerminalClipboardReadFn
*/
diff --git a/src/lib_vt.zig b/src/lib_vt.zig
index 1cc5c9ed3..414f6995b 100644
--- a/src/lib_vt.zig
+++ b/src/lib_vt.zig
@@ -50,10 +50,10 @@ pub const sys = terminal.sys;
pub const TinyIo = @import("lib/TinyIo.zig");
pub const apc = terminal.apc;
+pub const clipboard = terminal.clipboard;
pub const dcs = terminal.dcs;
pub const osc = terminal.osc;
pub const point = terminal.point;
-pub const clipboard = terminal.clipboard;
pub const color = terminal.color;
pub const device_status = terminal.device_status;
pub const formatter = terminal.formatter;
diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig
index b7176bba2..561728f5f 100644
--- a/src/terminal/Terminal.zig
+++ b/src/terminal/Terminal.zig
@@ -91,6 +91,12 @@ mouse_shape: mouse.Shape = .text,
/// Per-session Glyph Protocol registrations.
glyph_glossary: glyph.Glossary = .empty,
+/// Kitty drag and drop protocol (OSC 72) state. Allocated when a client
+/// registers to accept drops (t=a) and freed when it unregisters (t=A),
+/// so a terminal that never runs a drag and drop aware program pays
+/// nothing for it. Non-null means a client currently accepts drops.
+kitty_dnd: ?*kitty.dnd.State = null,
+
/// These are just a packed set of flags we may set on the terminal.
flags: packed struct {
// This supports a Kitty extension where programs using semantic
@@ -353,6 +359,7 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void {
self.pwd.deinit(alloc);
self.title.deinit(alloc);
self.glyph_glossary.deinit(alloc);
+ if (self.kitty_dnd) |dnd| dnd.destroy(alloc);
self.* = undefined;
}
@@ -4907,6 +4914,9 @@ pub fn fullReset(self: *Terminal) void {
self.pwd.clearRetainingCapacity();
self.title.clearRetainingCapacity();
self.glyph_glossary.clearAndFree(self.gpa());
+ // A reset only interrupts an in-progress chunked OSC 72 command;
+ // drag and drop registration survives, matching kitty.
+ if (self.kitty_dnd) |dnd| dnd.chunking = .{};
self.status_display = .main;
self.scrolling_region = .{
.top = 0,
diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig
index 947504582..6c35d6e06 100644
--- a/src/terminal/c/terminal.zig
+++ b/src/terminal/c/terminal.zig
@@ -240,8 +240,12 @@ pub const ModeConfig = extern struct {
/// C callback state for terminal effects. Most trampolines are always
/// installed on the stream handler; they check these fields and no-op when
-/// the corresponding callback is null. The unknown-sequence trampoline is
-/// installed dynamically to preserve its null fast path.
+/// the corresponding callback is null. The unknown-sequence and
+/// clipboard trampolines are installed dynamically to preserve their
+/// null fast paths (for clipboard_write, a null Zig-level effect makes
+/// Kitty clipboard writes fail up front instead of spooling a
+/// transaction that can never commit; for clipboard_read it keeps
+/// reads denied).
const Effects = struct {
userdata: ?*anyopaque = null,
write_pty: ?WritePtyFn = null,
@@ -653,6 +657,7 @@ fn wrap(
.bell = &Effects.bellTrampoline,
.color_scheme = &Effects.colorSchemeTrampoline,
.desktop_notification = &Effects.desktopNotificationTrampoline,
+ .drag_and_drop = null,
.device_attributes = &Effects.deviceAttributesTrampoline,
.enquiry = &Effects.enquiryTrampoline,
.xtversion = &Effects.xtversionTrampoline,
@@ -660,7 +665,9 @@ fn wrap(
.pwd_changed = &Effects.pwdChangedTrampoline,
.progress_report = &Effects.progressReportTrampoline,
.size = &Effects.sizeTrampoline,
- .clipboard_write = &Effects.clipboardWriteTrampoline,
+
+ // Installed dynamically when the callback is set; see Effects.
+ .clipboard_write = null,
.clipboard_read = null,
};
@@ -1244,7 +1251,13 @@ fn setTyped(
.pwd_changed => wrapper.effects.pwd_changed = value,
.progress_report => wrapper.effects.progress_report = value,
.size_cb => wrapper.effects.size_cb = value,
- .clipboard_write => wrapper.effects.clipboard_write = value,
+ .clipboard_write => {
+ wrapper.effects.clipboard_write = value;
+ wrapper.stream.handler.effects.clipboard_write = if (value != null)
+ &Effects.clipboardWriteTrampoline
+ else
+ null;
+ },
.clipboard_read => {
wrapper.effects.clipboard_read = value;
wrapper.stream.handler.effects.clipboard_read = if (value != null)
@@ -4717,8 +4730,10 @@ test "set clipboard_write callback" {
try testing.expectEqualStrings("image/png", S.last_mimes[4][0..S.last_mime_lens[4]]);
try testing.expectEqualSlices(u8, "\x89PNG", S.last_data[4][0..S.last_data_lens[4]]);
- // Removing the callback takes effect immediately.
+ // Removing the callback takes effect immediately and uninstalls
+ // the trampoline.
try testing.expectEqual(Result.success, set(t, .clipboard_write, null));
+ try testing.expect(t.?.stream.handler.effects.clipboard_write == null);
const after_remove = "\x1B]52;c;eA==\x1B\\";
vt_write(t, after_remove, after_remove.len);
try testing.expectEqual(@as(usize, 7), S.count);
@@ -4738,12 +4753,183 @@ test "clipboard_write without callback is unsupported and silent" {
const seq = "\x1B]52;c;aGVsbG8=\x1B\\";
vt_write(t, seq, seq.len);
- const handler = &t.?.stream.handler;
- const result = handler.effects.clipboard_write.?(handler, .{
- .location = .standard,
- .contents = &.{.{ .mime = "text/plain", .data = "hello" }},
- });
- try testing.expectEqual(clipboard.WriteResult.unsupported, result);
+ // No trampoline is installed until a callback is set, so the
+ // stream skips clipboard work (and never spools a Kitty clipboard
+ // transaction it can't deliver).
+ try testing.expect(t.?.stream.handler.effects.clipboard_write == null);
+}
+
+test "kitty clipboard write via C effects" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ 80,
+ 24,
+ ));
+ defer free(t);
+
+ const S = struct {
+ var responses: [512]u8 = undefined;
+ var responses_len: usize = 0;
+ var write_count: usize = 0;
+ var last_location: clipboard.Location = .standard;
+ var last_contents_len: usize = 0;
+ var last_mimes: [4][64]u8 = undefined;
+ var last_mime_lens: [4]usize = @splat(0);
+ var last_data: [4][64]u8 = undefined;
+ var last_data_lens: [4]usize = @splat(0);
+
+ fn writePty(
+ _: Terminal,
+ _: ?*anyopaque,
+ ptr: [*]const u8,
+ len: usize,
+ ) callconv(lib.calling_conv) void {
+ @memcpy(responses[responses_len..][0..len], ptr[0..len]);
+ responses_len += len;
+ }
+
+ fn clipboardWrite(
+ _: Terminal,
+ _: ?*anyopaque,
+ request: *const ClipboardWrite,
+ ) callconv(lib.calling_conv) clipboard.WriteResult {
+ write_count += 1;
+ last_location = request.location;
+ last_contents_len = request.contents_len;
+ if (request.contents) |ptr| {
+ for (ptr[0..@min(request.contents_len, last_mimes.len)], 0..) |content, i| {
+ last_mime_lens[i] = @min(content.mime.len, last_mimes[i].len);
+ @memcpy(
+ last_mimes[i][0..last_mime_lens[i]],
+ content.mime.ptr[0..last_mime_lens[i]],
+ );
+ last_data_lens[i] = @min(content.data.len, last_data[i].len);
+ @memcpy(
+ last_data[i][0..last_data_lens[i]],
+ content.data.ptr[0..last_data_lens[i]],
+ );
+ }
+ }
+ return .success;
+ }
+ };
+ S.responses_len = 0;
+ S.write_count = 0;
+ S.last_mime_lens = @splat(0);
+ S.last_data_lens = @splat(0);
+
+ try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
+ try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite)));
+
+ // A full OSC 5522 write transaction: begin, chunked data for two
+ // representations, commit. Only the commit invokes the callback,
+ // and its result maps to the DONE response.
+ const seqs = [_][]const u8{
+ "\x1B]5522;type=write:id=c1\x1B\\",
+ "\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\", // "Ghost"
+ "\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;dHk=\x1B\\", // "ty"
+ "\x1B]5522;type=wdata:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\", // "hi"
+ "\x1B]5522;type=wdata\x1B\\",
+ };
+ for (seqs) |seq| vt_write(t, seq.ptr, seq.len);
+
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+ try testing.expectEqual(clipboard.Location.standard, S.last_location);
+ try testing.expectEqual(@as(usize, 2), S.last_contents_len);
+ try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]);
+ try testing.expectEqualStrings("Ghostty", S.last_data[0][0..S.last_data_lens[0]]);
+ try testing.expectEqualStrings("text/html", S.last_mimes[1][0..S.last_mime_lens[1]]);
+ try testing.expectEqualStrings("hi", S.last_data[1][0..S.last_data_lens[1]]);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=DONE:id=c1\x1B\\",
+ S.responses[0..S.responses_len],
+ );
+
+ // Without a read callback reads are denied.
+ S.responses_len = 0;
+ const read = "\x1B]5522;type=read:id=r1;dGV4dC9wbGFpbg==\x1B\\";
+ vt_write(t, read, read.len);
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=EPERM:id=r1\x1B\\",
+ S.responses[0..S.responses_len],
+ );
+
+ // With a read callback the request is served through it.
+ const R = struct {
+ var count: usize = 0;
+ var last_mimes_len: usize = 0;
+ var last_mime_is_text: bool = false;
+ var last_list: bool = true;
+ var last_name_len: usize = 0;
+ var last_granted: bool = true;
+ var last_can_remember: bool = true;
+
+ fn clipboardRead(
+ _: Terminal,
+ _: ?*anyopaque,
+ request: *const ClipboardRead,
+ ) callconv(lib.calling_conv) void {
+ count += 1;
+ last_mimes_len = request.mimes_len;
+ last_mime_is_text = request.mimes_len > 0 and std.mem.eql(
+ u8,
+ request.mimes.?[0].ptr[0..request.mimes.?[0].len],
+ "text/plain",
+ );
+ last_list = request.list;
+ last_name_len = request.name.len;
+ last_granted = request.granted;
+ last_can_remember = request.can_remember;
+
+ const mime: []const u8 = "text/plain";
+ const data: []const u8 = "hello";
+ const contents = [_]ClipboardContent{.{
+ .mime = .init(mime),
+ .data = .init(data),
+ }};
+ request.reply(request, &.{
+ .size = @sizeOf(ClipboardReadReply),
+ .result = .success,
+ .contents = &contents,
+ .contents_len = contents.len,
+ .available = null,
+ .available_len = 0,
+ .remember = false,
+ });
+ }
+ };
+ try testing.expectEqual(Result.success, set(t, .clipboard_read, @ptrCast(&R.clipboardRead)));
+ S.responses_len = 0;
+ // name="app" without a password: forwarded for prompts, not
+ // rememberable.
+ const read2 = "\x1B]5522;type=read:id=r2:name=YXBw;dGV4dC9wbGFpbg==\x1B\\";
+ vt_write(t, read2, read2.len);
+ try testing.expectEqual(@as(usize, 1), R.count);
+ try testing.expectEqual(@as(usize, 1), R.last_mimes_len);
+ try testing.expect(R.last_mime_is_text);
+ try testing.expect(!R.last_list);
+ try testing.expectEqual(@as(usize, 3), R.last_name_len);
+ try testing.expect(!R.last_granted);
+ try testing.expect(!R.last_can_remember);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=OK:id=r2\x1B\\" ++
+ "\x1B]5522;type=read:status=DATA:id=r2:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1B\\" ++
+ "\x1B]5522;type=read:status=DONE:id=r2\x1B\\",
+ S.responses[0..S.responses_len],
+ );
+
+ // Without a clipboard callback the transaction fails up front.
+ try testing.expectEqual(Result.success, set(t, .clipboard_write, null));
+ S.responses_len = 0;
+ const begin = "\x1B]5522;type=write:id=c2\x1B\\";
+ vt_write(t, begin, begin.len);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=ENOSYS:id=c2\x1B\\",
+ S.responses[0..S.responses_len],
+ );
}
test "set clipboard_read callback" {
diff --git a/src/terminal/kitty.zig b/src/terminal/kitty.zig
index 471fb91e7..bbef8d7bb 100644
--- a/src/terminal/kitty.zig
+++ b/src/terminal/kitty.zig
@@ -5,6 +5,7 @@ const build_options = @import("terminal_options");
const key = @import("kitty/key.zig");
pub const clipboard = @import("kitty/clipboard.zig");
pub const color = @import("kitty/color.zig");
+pub const dnd = @import("kitty/dnd.zig");
pub const graphics = if (build_options.kitty_graphics) @import("kitty/graphics.zig") else struct {};
pub const KeyFlags = key.Flags;
diff --git a/src/terminal/kitty/clipboard_command.zig b/src/terminal/kitty/clipboard_command.zig
index 5f7115d83..40718840a 100644
--- a/src/terminal/kitty/clipboard_command.zig
+++ b/src/terminal/kitty/clipboard_command.zig
@@ -28,8 +28,8 @@ pub const max_pw_len = 128;
/// types are tiny; anything longer drops the packet.
pub const max_mime_len = 256;
-/// Maximum decoded name length we bother validating. Longer names are
-/// treated as present without validation; only their presence matters.
+/// Maximum decoded name length. Kitty has no limit but names are shown
+/// in permission prompts so anything longer drops the packet.
pub const max_name_len = 256;
/// The decoded, validated metadata of one OSC 5522 sequence.
@@ -62,9 +62,10 @@ pub const Metadata = struct {
/// treat the request as though it had no password."
pw: []const u8 = "",
- /// True if a non-empty (valid) name was given. We don't retain the
- /// name contents; it exists to opt into password grants.
- has_name: bool = false,
+ /// Decoded human friendly name of the requesting program, shown in
+ /// permission prompts. Empty means absent. Its presence opts into
+ /// password grants.
+ name: []const u8 = "",
/// Parse the metadata field. The raw value is expected to be exactly
/// the metadata (prefix and payload and separators stripped out).
@@ -124,21 +125,13 @@ pub const Metadata = struct {
error.Invalid => return null,
};
} else if (std.mem.eql(u8, key, "name")) {
- // We only need to know whether a (non-empty) name was
- // given; the contents are decoded for validation only.
- result.has_name = has_name: {
- const name = decodeValue(
- alloc,
- value,
- max_name_len,
- ) catch |err| switch (err) {
- error.OutOfMemory => return error.OutOfMemory,
- // Over-long names are accepted as present but
- // not validated further.
- error.Overflow => break :has_name true,
- error.Invalid => return null,
- };
- break :has_name name.len > 0;
+ result.name = decodeValue(
+ alloc,
+ value,
+ max_name_len,
+ ) catch |err| switch (err) {
+ error.OutOfMemory => return error.OutOfMemory,
+ error.Overflow, error.Invalid => return null,
};
}
// Unknown keys are ignored.
@@ -351,7 +344,21 @@ test "metadata: pw and name" {
// pw="secret", name="app"
const meta = (try Metadata.parse(arena.allocator(), "type=read:pw=c2VjcmV0:name=YXBw")).?;
try testing.expectEqualStrings("secret", meta.pw);
- try testing.expect(meta.has_name);
+ try testing.expectEqualStrings("app", meta.name);
+}
+
+test "metadata: over-long name dropped" {
+ const testing = std.testing;
+ var arena: std.heap.ArenaAllocator = .init(testing.allocator);
+ defer arena.deinit();
+ const Encoder = std.base64.standard.Encoder;
+ const long = "n" ** (max_name_len + 1);
+ var buf: [Encoder.calcSize(long.len)]u8 = undefined;
+ const raw = try std.mem.concat(arena.allocator(), u8, &.{
+ "type=read:name=",
+ Encoder.encode(&buf, long),
+ });
+ try testing.expect((try Metadata.parse(arena.allocator(), raw)) == null);
}
test "metadata: empty name" {
@@ -359,7 +366,7 @@ test "metadata: empty name" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const meta = (try Metadata.parse(arena.allocator(), "type=read:pw=c2VjcmV0:name=")).?;
- try testing.expect(!meta.has_name);
+ try testing.expectEqual(@as(usize, 0), meta.name.len);
}
test "payload: mime iterator" {
diff --git a/src/terminal/kitty/clipboard_write.zig b/src/terminal/kitty/clipboard_write.zig
index c4e33a4f3..fbb631446 100644
--- a/src/terminal/kitty/clipboard_write.zig
+++ b/src/terminal/kitty/clipboard_write.zig
@@ -36,7 +36,7 @@ pub const WriteState = struct {
loc: clipboard.Location,
id: []const u8,
pw: []const u8,
- has_name: bool,
+ name: []const u8,
spool: std.ArrayListUnmanaged(u8) = .empty,
entries: std.ArrayListUnmanaged(Entry) = .empty,
aliases: std.ArrayListUnmanaged(Alias) = .empty,
@@ -68,12 +68,13 @@ pub const WriteState = struct {
errdefer arena.deinit();
const id = try arena.allocator().dupe(u8, meta.id);
const pw = try arena.allocator().dupe(u8, meta.pw);
+ const name = try arena.allocator().dupe(u8, meta.name);
return .{
.arena = arena,
.loc = meta.loc,
.id = id,
.pw = pw,
- .has_name = meta.has_name,
+ .name = name,
};
}
@@ -220,7 +221,7 @@ pub const WriteState = struct {
loc: clipboard.Location,
id: []const u8,
pw: []const u8,
- has_name: bool,
+ name: []const u8,
truncated: bool,
contents: []const Content,
@@ -289,7 +290,7 @@ pub const WriteState = struct {
.loc = self.loc,
.id = self.id,
.pw = self.pw,
- .has_name = self.has_name,
+ .name = self.name,
.truncated = self.truncated,
.contents = try contents.toOwnedSlice(alloc),
};
diff --git a/src/terminal/kitty/dnd.zig b/src/terminal/kitty/dnd.zig
new file mode 100644
index 000000000..3142a4158
--- /dev/null
+++ b/src/terminal/kitty/dnd.zig
@@ -0,0 +1,69 @@
+//! Kitty drag and drop protocol (OSC 72).
+//!
+//! Specification: https://sw.kovidgoyal.net/kitty/dnd-protocol/
+//!
+//! The protocol lets a program running in the terminal participate in
+//! native OS drag and drop. A client registers to accept drops (t=a);
+//! the terminal then forwards native drag movement (t=m) and drops
+//! (t=M) to it and serves the dropped data on request (t=r), instead
+//! of the traditional behavior of pasting dropped paths or text.
+//!
+//! ## Divergences
+//!
+//! These will be fixed in the future:
+//!
+//! * Dropped data is captured eagerly at drop time from a curated
+//! set of representations the embedder can serve (typically
+//! text/uri-list and text/plain), rather than fetched from the OS
+//! on demand. Consequently the native drag session concludes at
+//! drop time and the client's concluding operation (t=r with
+//! x=y=Y=0) only frees the held data, and kitty's 128-entry
+//! request queue and EMFILE overflow handling are unnecessary
+//! because requests are served synchronously in order.
+//! * Every client is treated as local: machine IDs (t=a:x=1) are
+//! accepted and ignored, responses never carry the X=1 remote
+//! marker, and remote file transfer requests (t=r with y or Y
+//! keys) are answered with EINVAL. A remote client (e.g. over
+//! ssh) can still receive text drops; only file-content transfer
+//! is unavailable.
+//! * The terminal never initiates drags (drag out): enabling and
+//! disabling offers (t=o:x=1, t=o:x=2) are accepted and ignored,
+//! and since the terminal never sends a drag start request a
+//! conforming client never offers a drag. Direct offers (t=o:x=0)
+//! and drag data/start commands (t=p, t=P) are refused with EPERM.
+//!
+//! These are on purpose forever:
+//!
+//! * Responses echo the requesting command's terminator (ST or BEL)
+//! per ghostty convention; kitty always uses ST. Terminal-
+//! initiated events always use ST.
+
+const dnd_command = @import("dnd_command.zig");
+const dnd_response = @import("dnd_response.zig");
+const dnd_drop = @import("dnd_drop.zig");
+
+pub const EventType = dnd_command.EventType;
+pub const Metadata = dnd_command.Metadata;
+pub const Operation = dnd_command.Operation;
+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;
+
+pub const State = dnd_drop.State;
+pub const Item = dnd_drop.State.Item;
+pub const MoveEvent = dnd_drop.State.MoveEvent;
+pub const max_mime_list_bytes = dnd_drop.max_mime_list_bytes;
+pub const handleCommand = dnd_drop.handleCommand;
+pub const Event = dnd_drop.Event;
+
+test {
+ _ = dnd_command;
+ _ = dnd_response;
+ _ = dnd_drop;
+ _ = @import("dnd_test.zig");
+}
diff --git a/src/terminal/kitty/dnd_command.zig b/src/terminal/kitty/dnd_command.zig
new file mode 100644
index 000000000..b5f63d02b
--- /dev/null
+++ b/src/terminal/kitty/dnd_command.zig
@@ -0,0 +1,436 @@
+const std = @import("std");
+
+/// Decoded OSC 72 metadata.
+pub const Metadata = struct {
+ /// Event type (`t`). Null when the metadata had no `t` key; such
+ /// commands parse successfully but are ignored, matching kitty.
+ type: ?EventType = null,
+
+ /// Chunking flag (`m`): true when more chunks follow.
+ more: bool = false,
+
+ /// Multiplexer client ID (`i`), echoed in every response so a
+ /// terminal multiplexer can route responses to the correct client.
+ client_id: u32 = 0,
+
+ /// Operation (`o`): meaning depends on the event type, commonly
+ /// 0=none/reject, 1=copy, 2=move, 3=copy or move.
+ operation: u32 = 0,
+
+ /// `x`, `y`, `X`, `Y` keys.
+ cell_x: i32 = 0,
+ cell_y: i32 = 0,
+ pixel_x: i32 = 0,
+ pixel_y: i32 = 0,
+
+ /// Parse raw OSC 72 metadata (the part before the first `;`).
+ /// Returns null when malformed; callers should ignore the command,
+ /// matching kitty which rejects the entire command on any error.
+ pub fn parse(raw: []const u8) ?Metadata {
+ var result: Metadata = .{};
+ var pos: usize = 0;
+ // The continue expression consumes the ':' separating a field
+ // from the next; the body advances past the field itself.
+ while (pos < raw.len) : (pos += 1) {
+ // Single-character key.
+ const key = raw[pos];
+ pos += 1;
+ switch (key) {
+ 't', 'm', 'i', 'o', 'x', 'y', 'X', 'Y' => {},
+ else => return null,
+ }
+
+ // '=' separator.
+ if (pos >= raw.len) return null;
+ if (raw[pos] != '=') return null;
+ pos += 1;
+
+ // Value.
+ if (pos >= raw.len) return null;
+ switch (key) {
+ 't' => {
+ result.type = std.enums.fromInt(
+ EventType,
+ raw[pos],
+ ) orelse return null;
+ pos += 1;
+ },
+
+ 'm', 'i', 'o' => {
+ const v = parseUnsigned(raw, &pos) orelse return null;
+ switch (key) {
+ 'm' => result.more = v != 0,
+ 'i' => result.client_id = v,
+ 'o' => result.operation = v,
+ else => unreachable,
+ }
+ },
+
+ 'x', 'y', 'X', 'Y' => {
+ const negative = raw[pos] == '-';
+ if (negative) pos += 1;
+ const unsigned = parseUnsigned(raw, &pos) orelse return null;
+ // Matches kitty's cast of the u32 magnitude to i32,
+ // which wraps rather than erroring on overflow.
+ const magnitude: i32 = @bitCast(unsigned);
+ const v = if (negative) 0 -% magnitude else magnitude;
+ switch (key) {
+ 'x' => result.cell_x = v,
+ 'y' => result.cell_y = v,
+ 'X' => result.pixel_x = v,
+ 'Y' => result.pixel_y = v,
+ else => unreachable,
+ }
+ },
+
+ else => unreachable,
+ }
+
+ // Values are separated by ':'.
+ if (pos >= raw.len) break;
+ if (raw[pos] != ':') return null;
+ }
+
+ return result;
+ }
+
+ /// Parse an unsigned decimal value at `pos`, advancing it. At most
+ /// 10 digits and at most maxInt(u32), matching kitty. Returns null
+ /// when there are no digits or the value is too large.
+ fn parseUnsigned(raw: []const u8, pos: *usize) ?u32 {
+ const start = pos.*;
+ var acc: u64 = 0;
+ var i = start;
+ while (i < raw.len and i < start + 10) : (i += 1) {
+ const d = raw[i] -% '0';
+ if (d > 9) break;
+ acc = acc * 10 + d;
+ }
+ if (i == start) return null;
+ pos.* = i;
+ return std.math.cast(u32, acc) orelse null;
+ }
+};
+
+/// The event type, i.e. values for the `t` metadata key. A single OSC 72
+/// code is used for both directions of the protocol, so most types have
+/// one meaning when received by the terminal from a client and another
+/// when sent by the terminal to a client.
+///
+/// The `drop` and `request_response` types are only ever sent by the
+/// terminal; kitty parses but ignores them when received and we do the
+/// same.
+pub const EventType = enum(u8) {
+ /// 'a': (recv) client registers to accept drops. With x=1 the payload
+ /// is the client's machine ID for remote drop support instead.
+ register = 'a',
+
+ /// 'A': (recv) client unregisters from accepting drops.
+ unregister = 'A',
+
+ /// 'm': (recv) client reports acceptance status for the drag currently
+ /// over the terminal: `o` is the chosen operation and the payload is
+ /// the accepted MIME list. (send) pointer moved over the terminal
+ /// during a drag, or with x=-1,y=-1 the drag left the window.
+ status = 'm',
+
+ /// 'M': (send only) items were dropped onto the terminal.
+ drop = 'M',
+
+ /// 'r': (recv) client requests drop data, or with x=y=Y=0 concludes
+ /// the drop with `o` as the performed operation. (send) drop data
+ /// response chunks.
+ request = 'r',
+
+ /// 'R': (send only) error response to a data request.
+ request_error = 'R',
+
+ /// 'o': (recv) drag source control: x=1 enables offering drags (payload
+ /// optionally the client machine ID), x=2 disables, x=0 offers a MIME
+ /// list for a new drag. (send) request that the client start a drag at
+ /// the given position.
+ offer = 'o',
+
+ /// 'p': (recv) pre-sent data for an offered drag: x>=0 is a 0-based
+ /// MIME index, x<0 attaches drag image -x.
+ present = 'p',
+
+ /// 'P': (recv) x=-1 starts the offered drag, x>=0 changes the drag
+ /// image mid-drag.
+ start_drag = 'P',
+
+ /// 'e': (recv) drag data for MIME index `y` of an in-progress drag.
+ /// (send) drag status events (accepted, dropped, finished, ...).
+ drag_event = 'e',
+
+ /// 'E': (recv) client aborts the whole drag (y=-1) or reports an error
+ /// for MIME index `y`. (send) drag start response (OK or error).
+ drag_error = 'E',
+
+ /// 'k': (recv) remote file data for a drag. (send) request for remote
+ /// file data.
+ remote_data = 'k',
+
+ /// 'q': (recv) query protocol support. (send) the query response.
+ query = 'q',
+};
+
+/// A drop operation. Values match the protocol's `o` key.
+pub const Operation = enum(u2) {
+ none = 0,
+ copy = 1,
+ move = 2,
+
+ /// Convert a protocol `o` value the way kitty does: anything other
+ /// than copy or move means none.
+ pub fn fromProtocol(v: u32) Operation {
+ return switch (v) {
+ 1 => .copy,
+ 2 => .move,
+ else => .none,
+ };
+ }
+};
+
+/// The set of operations allowed by a drag source, sent as a bitmask in
+/// the `o` key of move and drop events.
+pub const Operations = packed struct(u2) {
+ copy: bool = false,
+ move: bool = false,
+
+ pub fn protocolValue(self: Operations) u2 {
+ return @bitCast(self);
+ }
+};
+
+/// A decoded `t=r` data request. The request form is disambiguated by
+/// which keys are non-zero, mirroring kitty's drop_process_queue.
+pub const Request = union(enum) {
+ /// x=y=Y=0: the drop is concluded with the given operation.
+ conclude: Operation,
+
+ /// Y=0, y=0, x!=0: request data for the 1-based MIME index x.
+ mime: i32,
+
+ /// Y=0, y!=0: request the contents of the y'th (1-based) file in the
+ /// text/uri-list MIME at 1-based index x. Remote drops only.
+ uri: struct {
+ mime_idx: i32,
+ uri_idx: i32,
+ },
+
+ /// Y!=0: request entry x (1-based) of directory handle Y, or close
+ /// the handle when x=0. Remote drops only.
+ dir: struct {
+ handle: i32,
+ entry: i32,
+ },
+
+ pub fn init(meta: Metadata) Request {
+ if (meta.pixel_y != 0) return .{ .dir = .{
+ .handle = meta.pixel_y,
+ .entry = meta.cell_x,
+ } };
+ if (meta.cell_y != 0) return .{ .uri = .{
+ .mime_idx = meta.cell_x,
+ .uri_idx = meta.cell_y,
+ } };
+ if (meta.cell_x != 0) return .{ .mime = meta.cell_x };
+ return .{ .conclude = .fromProtocol(meta.operation) };
+ }
+};
+
+/// Chunk reassembly state.
+///
+/// While a chunked command is in progress, the metadata of the first
+/// chunk is reused for all subsequent chunks; only the `more` flag is
+/// taken from each continuation.
+pub const Chunking = struct {
+ active: bool = false,
+ metadata: Metadata = .{},
+
+ /// Returns the effective metadata for a received chunk and updates
+ /// the reassembly state.
+ pub fn apply(self: *Chunking, meta: Metadata) Metadata {
+ if (self.active) {
+ var copy = self.metadata;
+ copy.more = meta.more;
+ self.active = meta.more;
+ return copy;
+ }
+
+ if (meta.more) {
+ self.active = true;
+ self.metadata = meta;
+ }
+
+ return meta;
+ }
+};
+
+test "Metadata: empty" {
+ const testing = std.testing;
+ const meta = Metadata.parse("").?;
+ try testing.expect(meta.type == null);
+ try testing.expect(!meta.more);
+ try testing.expectEqual(@as(u32, 0), meta.client_id);
+}
+
+test "Metadata: all keys" {
+ const testing = std.testing;
+ const meta = Metadata.parse("t=m:m=1:i=3:o=2:x=10:y=5:X=320:Y=200").?;
+ try testing.expectEqual(EventType.status, meta.type.?);
+ try testing.expect(meta.more);
+ try testing.expectEqual(@as(u32, 3), meta.client_id);
+ try testing.expectEqual(@as(u32, 2), meta.operation);
+ try testing.expectEqual(@as(i32, 10), meta.cell_x);
+ try testing.expectEqual(@as(i32, 5), meta.cell_y);
+ try testing.expectEqual(@as(i32, 320), meta.pixel_x);
+ try testing.expectEqual(@as(i32, 200), meta.pixel_y);
+}
+
+test "Metadata: all event types" {
+ const testing = std.testing;
+ const cases = .{
+ .{ "t=a", EventType.register },
+ .{ "t=A", EventType.unregister },
+ .{ "t=m", EventType.status },
+ .{ "t=M", EventType.drop },
+ .{ "t=r", EventType.request },
+ .{ "t=R", EventType.request_error },
+ .{ "t=o", EventType.offer },
+ .{ "t=p", EventType.present },
+ .{ "t=P", EventType.start_drag },
+ .{ "t=e", EventType.drag_event },
+ .{ "t=E", EventType.drag_error },
+ .{ "t=k", EventType.remote_data },
+ .{ "t=q", EventType.query },
+ };
+ inline for (cases) |case| {
+ try testing.expectEqual(case[1], Metadata.parse(case[0]).?.type.?);
+ }
+}
+
+test "Metadata: case-sensitive coordinate keys" {
+ const testing = std.testing;
+ const meta = Metadata.parse("x=10:Y=200").?;
+ try testing.expectEqual(@as(i32, 10), meta.cell_x);
+ try testing.expectEqual(@as(i32, 0), meta.cell_y);
+ try testing.expectEqual(@as(i32, 0), meta.pixel_x);
+ try testing.expectEqual(@as(i32, 200), meta.pixel_y);
+}
+
+test "Metadata: negative coordinates" {
+ const testing = std.testing;
+ const meta = Metadata.parse("t=m:x=-1:y=-1").?;
+ try testing.expectEqual(@as(i32, -1), meta.cell_x);
+ try testing.expectEqual(@as(i32, -1), meta.cell_y);
+}
+
+test "Metadata: malformed inputs rejected" {
+ const testing = std.testing;
+ // Unknown event type.
+ try testing.expect(Metadata.parse("t=z") == null);
+ // Unknown key.
+ try testing.expect(Metadata.parse("z=1") == null);
+ // Missing '=' mid-stream.
+ try testing.expect(Metadata.parse("x10") == null);
+ // No digits.
+ try testing.expect(Metadata.parse("x=notanumber") == null);
+ try testing.expect(Metadata.parse("x=-") == null);
+ // Too large.
+ try testing.expect(Metadata.parse("i=4294967296") == null);
+ try testing.expect(Metadata.parse("i=99999999999") == null);
+ // Garbage after value.
+ try testing.expect(Metadata.parse("x=1z") == null);
+ // No whitespace tolerance, matching kitty.
+ try testing.expect(Metadata.parse("t=a: x=1") == null);
+ try testing.expect(Metadata.parse("t = a") == null);
+ // Truncated mid-construct, matching kitty's final-state check.
+ // Nothing state-changing may be parsed out of these: e.g. "t=r:x="
+ // must not be treated as a drop conclusion.
+ try testing.expect(Metadata.parse("t") == null);
+ try testing.expect(Metadata.parse("t=") == null);
+ try testing.expect(Metadata.parse("x=") == null);
+ try testing.expect(Metadata.parse("t=r:x=") == null);
+ try testing.expect(Metadata.parse("x=1:t=") == null);
+}
+
+test "Metadata: trailing separator accepted" {
+ const testing = std.testing;
+
+ // Kitty's state machine accepts the metadata ending right after a
+ // value separator.
+ const meta = Metadata.parse("t=a:").?;
+ try testing.expectEqual(EventType.register, meta.type.?);
+}
+
+test "Metadata: u32 boundary accepted" {
+ const testing = std.testing;
+ const meta = Metadata.parse("i=4294967295").?;
+ try testing.expectEqual(@as(u32, std.math.maxInt(u32)), meta.client_id);
+}
+
+test "Request: classification" {
+ const testing = std.testing;
+
+ // Conclude.
+ {
+ const r: Request = .init(Metadata.parse("t=r:o=2").?);
+ try testing.expectEqual(Operation.move, r.conclude);
+ }
+ // Conclude with unknown operation is none (canceled).
+ {
+ const r: Request = .init(Metadata.parse("t=r:o=9").?);
+ try testing.expectEqual(Operation.none, r.conclude);
+ }
+ // MIME data request.
+ {
+ const r: Request = .init(Metadata.parse("t=r:x=2").?);
+ try testing.expectEqual(@as(i32, 2), r.mime);
+ }
+ // URI file request.
+ {
+ const r: Request = .init(Metadata.parse("t=r:x=1:y=3").?);
+ try testing.expectEqual(@as(i32, 1), r.uri.mime_idx);
+ try testing.expectEqual(@as(i32, 3), r.uri.uri_idx);
+ }
+ // Directory handle request.
+ {
+ const r: Request = .init(Metadata.parse("t=r:Y=2:x=1").?);
+ try testing.expectEqual(@as(i32, 2), r.dir.handle);
+ try testing.expectEqual(@as(i32, 1), r.dir.entry);
+ }
+}
+
+test "Chunking: reassembly reuses first chunk metadata" {
+ const testing = std.testing;
+ var chunking: Chunking = .{};
+
+ // First chunk starts reassembly.
+ const first = chunking.apply(Metadata.parse("t=m:o=1:m=1").?);
+ try testing.expect(chunking.active);
+ try testing.expectEqual(EventType.status, first.type.?);
+ try testing.expect(first.more);
+
+ // Continuation metadata is ignored except for `more`.
+ const second = chunking.apply(Metadata.parse("t=q:o=2:m=1").?);
+ try testing.expect(chunking.active);
+ try testing.expectEqual(EventType.status, second.type.?);
+ try testing.expectEqual(@as(u32, 1), second.operation);
+ try testing.expect(second.more);
+
+ // Final chunk ends reassembly.
+ const last = chunking.apply(Metadata.parse("t=q:m=0").?);
+ try testing.expect(!chunking.active);
+ try testing.expectEqual(EventType.status, last.type.?);
+ try testing.expect(!last.more);
+}
+
+test "Chunking: unchunked commands pass through" {
+ const testing = std.testing;
+ var chunking: Chunking = .{};
+ const meta = chunking.apply(Metadata.parse("t=q").?);
+ try testing.expect(!chunking.active);
+ try testing.expectEqual(EventType.query, meta.type.?);
+}
diff --git a/src/terminal/kitty/dnd_drop.zig b/src/terminal/kitty/dnd_drop.zig
new file mode 100644
index 000000000..e3c7ee5ce
--- /dev/null
+++ b/src/terminal/kitty/dnd_drop.zig
@@ -0,0 +1,715 @@
+//! Kitty drag and drop protocol (OSC 72) state machine.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+const assert = @import("../../quirks.zig").inlineAssert;
+const osc = @import("../osc.zig");
+const command = @import("dnd_command.zig");
+const response = @import("dnd_response.zig");
+
+const Metadata = command.Metadata;
+const Operation = command.Operation;
+const Operations = command.Operations;
+
+const log = std.log.scoped(.kitty_dnd);
+
+/// Maximum accumulated size of a client-sent MIME list (the accepted
+/// list of a `t=m` status update). Matches kitty's MIME_LIST_SIZE_CAP.
+pub const max_mime_list_bytes = 1024 * 1024;
+
+/// Process one OSC 72 command received from the client, writing any
+/// responses to the writer. Returns the state change the embedder may
+/// need to act on, if any.
+pub fn handleCommand(
+ slot: *?*State,
+ alloc: Allocator,
+ writer: *std.Io.Writer,
+ v: osc.Command.KittyDndProtocol,
+) (Allocator.Error || std.Io.Writer.Error)!?Event {
+ const raw = Metadata.parse(v.metadata) orelse {
+ log.debug("dropping malformed OSC 72 metadata", .{});
+ return null;
+ };
+
+ // Chunk reassembly lives in the state, so before registration
+ // each command stands alone. The only legitimately chunked
+ // command before registration is t=a itself, which seeds the
+ // reassembly on its first chunk below.
+ const continuation = if (slot.*) |state| state.chunking.active else false;
+ const meta = if (slot.*) |state| state.chunking.apply(raw) else raw;
+ const payload = v.payload orelse "";
+ const t = meta.type orelse return null;
+
+ switch (t) {
+ .register => {
+ // x=1 declares the client's machine ID for remote drop
+ // support. We don't support remote drop yet, so accept and ignore.
+ if (meta.cell_x == 1) return null;
+
+ // Setup our state if we haven't already
+ const state = slot.* orelse state: {
+ const state = try State.create(alloc);
+ slot.* = state;
+ _ = state.chunking.apply(raw);
+ break :state state;
+ };
+
+ // Update the client ID on every registration
+ state.drop.client_id = meta.client_id;
+
+ return try state.register(
+ alloc,
+ payload,
+ continuation,
+ meta.more,
+ );
+ },
+
+ .unregister => {
+ const state = slot.* orelse return null;
+ state.destroy(alloc);
+ slot.* = null;
+ return .registration;
+ },
+
+ .status => {
+ const state = slot.* orelse return null;
+ return try state.acceptStatus(alloc, meta, payload);
+ },
+
+ .request => return try dataRequest(
+ slot.*,
+ alloc,
+ writer,
+ meta,
+ v.terminator,
+ ),
+
+ // Drag source control. Enabling (x=1, with an optional
+ // machine ID payload) and disabling (x=2) offers are
+ // accepted and ignored since the terminal never requests a
+ // drag start. Offering a MIME list (x=0) for a new drag is
+ // refused since drag-out is not implemented.
+ .offer => if (meta.cell_x == 0) try refuseDragOut(
+ writer,
+ meta,
+ v.terminator,
+ ),
+
+ // Drag-out data and start commands. A conforming client
+ // never sends these because the terminal never requests a
+ // drag start, but refuse them properly if one does.
+ .present, .start_drag => try refuseDragOut(
+ writer,
+ meta,
+ v.terminator,
+ ),
+
+ // Responses to drag-out requests the terminal never makes.
+ .drag_event, .drag_error, .remote_data => {},
+
+ .query => try response.encode(
+ writer,
+ "t=q",
+ meta.client_id,
+ "",
+ .plain,
+ v.terminator,
+ ),
+
+ // Only ever sent by the terminal. Ignore.
+ .drop, .request_error => {},
+ }
+
+ return null;
+}
+
+/// Handle a t=r data request or drop conclusion from the client.
+/// Requests from an unregistered client (no state) get the same
+/// errors kitty sends from its zeroed drop state.
+fn dataRequest(
+ state: ?*State,
+ alloc: Allocator,
+ writer: *std.Io.Writer,
+ meta: Metadata,
+ terminator: osc.Terminator,
+) (Allocator.Error || std.Io.Writer.Error)!?Event {
+ // Responses echo the registration's client ID, matching kitty.
+ const client_id = if (state) |s| s.drop.client_id else 0;
+
+ switch (command.Request.init(meta)) {
+ .conclude => |op| {
+ // The client is done with the drop: free the held data and
+ // report the operation it performed. Kitty hands that to
+ // the still-open OS drag session; ours ended at drop time
+ // (see dnd.zig), so the embedder decides what to do with
+ // it. A conclusion with no drop in progress is a no-op.
+ const s = state orelse return null;
+ const dropped = s.drop.dropped;
+ s.resetDrop(alloc);
+ return if (dropped) Event.concluded(op) else null;
+ },
+
+ .mime => |idx| {
+ const keys: response.RequestKeys = .{ .x = idx };
+ const items = (if (state) |s| s.drop.items else null) orelse {
+ try response.encodeError(
+ writer,
+ .drop,
+ keys,
+ client_id,
+ .ENOENT,
+ "no drop data available",
+ terminator,
+ );
+ return null;
+ };
+ if (idx < 1 or @as(usize, @intCast(idx)) > items.len) {
+ try response.encodeError(
+ writer,
+ .drop,
+ keys,
+ client_id,
+ .ENOENT,
+ "drop data request index out of bounds",
+ terminator,
+ );
+ return null;
+ }
+
+ var header_buf: [32]u8 = undefined;
+ const header = std.fmt.bufPrint(
+ &header_buf,
+ "t=r{f}",
+ .{keys},
+ ) catch unreachable;
+
+ // The data chunks followed by the empty end-of-data
+ // message, which is how the client detects completion.
+ // An empty item is just the end-of-data message alone;
+ // clients treat a duplicate as a second completion.
+ const item = items[@intCast(idx - 1)];
+ if (item.data.len > 0) try response.encode(
+ writer,
+ header,
+ client_id,
+ item.data,
+ .base64,
+ terminator,
+ );
+ try response.encode(writer, header, client_id, "", .base64, terminator);
+ return null;
+ },
+
+ // Remote drop transfers (URI file contents and directory
+ // handles). We never advertise remote support (no X=1
+ // marker), so a conforming client never sends these.
+ .uri => |uri| try response.encodeError(
+ writer,
+ .drop,
+ .{ .x = uri.mime_idx, .y = uri.uri_idx },
+ client_id,
+ .EINVAL,
+ "remote drop data is not supported",
+ terminator,
+ ),
+ .dir => |dir| try response.encodeError(
+ writer,
+ .drop,
+ .{ .x = dir.entry, .Y = dir.handle },
+ client_id,
+ .EINVAL,
+ "remote drop data is not supported",
+ terminator,
+ ),
+ }
+
+ return null;
+}
+
+/// Refuse a drag-out command with an error, since ghostty does not
+/// implement the terminal side of client-initiated drags yet.
+fn refuseDragOut(
+ writer: *std.Io.Writer,
+ meta: Metadata,
+ terminator: osc.Terminator,
+) std.Io.Writer.Error!void {
+ try response.encodeError(
+ writer,
+ .drag,
+ .{},
+ meta.client_id,
+ .EPERM,
+ "drag out is not supported by this terminal",
+ terminator,
+ );
+}
+
+/// A protocol state change an embedder may need to act on, returned by
+/// `handleCommand` and delivered through the stream handler's
+/// `drag_and_drop` effect. This is a flat enum so it can cross a C API
+/// unchanged; any details are read back from `Terminal.kitty_dnd`.
+pub const Event = enum {
+ /// The client registered (t=a), re-registered, or unregistered
+ /// (t=A) to accept drops. An embedder may want to use this
+ /// to setup the proper mime types to accept (e.g. on macOS)
+ /// or not (unregistered).
+ registration,
+
+ /// The client answered the drag currently over the terminal.
+ /// `State.clientAccepted` has the answer. Embedders can refresh the
+ /// OS drag feedback immediately rather than on the next move.
+ acceptance,
+
+ /// The client concluded a drop, performing no operation (it
+ /// canceled), a copy, or a move. The held drop data has been freed.
+ concluded_none,
+ concluded_copy,
+ concluded_move,
+
+ /// The conclusion event for a performed operation.
+ pub fn concluded(op: Operation) Event {
+ return switch (op) {
+ .none => .concluded_none,
+ .copy => .concluded_copy,
+ .move => .concluded_move,
+ };
+ }
+};
+
+/// The per-terminal drop target state.
+///
+/// The primary entrypoint is `handleCommand` which takes a `*?*State`
+/// slot that it can heap allocate into when DnD activates and free when
+/// it deactivates.
+///
+/// The normal lifecycle:
+///
+/// 1. The stream handler feeds every OSC 72 command received from the
+/// client to `handleCommand`. The client registers (t=a), which
+/// allocates the state into the slot and yields a `registration`
+/// event so the embedder can register any declared MIME types
+/// with the OS. Until then `handleCommand` only answers stateless
+/// commands (queries, error responses).
+/// 2. A native drag enters or moves over the terminal. When the slot
+/// is non-null, the embedder calls `dragMove` with the pointer
+/// position, the operations the drag source allows, and the MIME
+/// types it can serve if dropped. This sends the client a t=m
+/// move event; when the slot is null the embedder should handle
+/// the drag as it would without the protocol.
+/// 3. The client answers with its acceptance (t=m:o=N), recorded by
+/// `handleCommand` which yields an `acceptance` event. The
+/// embedder reads `clientAccepted` then and on subsequent moves
+/// to give the OS drag session its feedback.
+/// 4. The drag either leaves, and the embedder calls `dragLeave` to
+/// send the t=m leave event, or drops: the embedder captures the
+/// representations it advertised and calls `dragDrop`, which
+/// copies and holds them and sends the client a t=M drop event. A
+/// new drag entering before the client concludes discards the
+/// held drop.
+/// 5. The client requests data (t=r:x=N), which `handleCommand`
+/// serves from the held copies, and then concludes the drop
+/// (t=r:o=N), which frees them and yields a `concluded_*` event
+/// naming the operation the client performed.
+/// 6. The client unregisters (t=A) and `handleCommand` frees the
+/// state, yielding a final `registration` event, or the terminal
+/// is deinitialized and calls `destroy`.
+///
+/// All calls must use the allocator the state was created with (the
+/// terminal's) and require the same synchronization as any other
+/// terminal mutation.
+pub const State = struct {
+ /// Chunk reassembly for client commands. This is the only part of
+ /// the state cleared by a terminal reset (RIS), matching kitty.
+ chunking: command.Chunking = .{},
+
+ /// Drop target state for the registered client.
+ drop: DropTarget = .{},
+
+ pub const DropTarget = struct {
+ /// Multiplexer client ID from registration, echoed in every
+ /// drop-side message the terminal sends.
+ client_id: u32 = 0,
+
+ /// The MIME list the client registered with (the t=a payload),
+ /// space-separated as received and accumulated across chunks.
+ /// Only needed by embedders that must register types with the
+ /// OS ahead of a drag; kitty frees it after doing so, we keep
+ /// it so the `registration` event can be acted on from here.
+ registered_mimes: std.ArrayListUnmanaged(u8) = .empty,
+
+ /// True while the pointer of a native drag is over the terminal.
+ hovered: bool = false,
+
+ /// True after the native drop until the client concludes it.
+ dropped: bool = false,
+
+ /// The client's response to the current drag, null until the
+ /// client has responded. `none` means the client rejected it.
+ accepted: ?Operation = null,
+
+ /// True while a chunked t=m acceptance is being accumulated.
+ accept_in_progress: bool = false,
+
+ /// The client's accepted MIME list: space-separated while
+ /// accumulating, converted to NUL-separated (with a trailing
+ /// NUL) once complete, matching kitty's in-place conversion.
+ accepted_mimes: std.ArrayListUnmanaged(u8) = .empty,
+
+ /// The MIME types of the current native drag, in the order
+ /// that data request indices refer to.
+ offered: ?Offered = null,
+
+ /// The data captured at drop time, parallel to `offered`.
+ items: ?[]const Item = null,
+ };
+
+ /// One dropped representation: a MIME type and its data.
+ pub const Item = struct {
+ mime: []const u8,
+ data: []const u8,
+ };
+
+ /// The MIME list of the current drag plus the pre-joined move-event
+ /// payload ("mime1 mime2 " with a trailing space after every entry,
+ /// matching kitty) so per-move encoding is allocation-free.
+ const Offered = struct {
+ mimes: []const []const u8,
+ payload: []const u8,
+
+ fn init(alloc: Allocator, mimes: []const []const u8) Allocator.Error!Offered {
+ const copies = try alloc.alloc([]const u8, mimes.len);
+ errdefer alloc.free(copies);
+
+ var payload_len: usize = 0;
+ for (mimes) |m| payload_len += m.len + 1;
+
+ const payload = try alloc.alloc(u8, payload_len);
+ errdefer alloc.free(payload);
+
+ var offset: usize = 0;
+ for (mimes, copies) |m, *copy| {
+ @memcpy(payload[offset..][0..m.len], m);
+ payload[offset + m.len] = ' ';
+ copy.* = payload[offset..][0..m.len];
+ offset += m.len + 1;
+ }
+
+ return .{ .mimes = copies, .payload = payload };
+ }
+
+ fn deinit(self: *const Offered, alloc: Allocator) void {
+ alloc.free(self.mimes);
+ alloc.free(self.payload);
+ }
+
+ fn eql(self: *const Offered, mimes: []const []const u8) bool {
+ if (self.mimes.len != mimes.len) return false;
+ for (self.mimes, mimes) |a, b| {
+ if (!std.mem.eql(u8, a, b)) return false;
+ }
+ return true;
+ }
+ };
+
+ /// The maximum number of dropped items. Embedders provide a small
+ /// curated set of representations (see dnd.zig), so this is a
+ /// generous bound that keeps the MIME list assembly on the stack.
+ pub const max_items = 16;
+
+ /// Allocate a fresh state. Done by `handleCommand` on registration.
+ fn create(alloc: Allocator) Allocator.Error!*State {
+ const state = try alloc.create(State);
+ state.* = .{};
+ return state;
+ }
+
+ /// Free the state and everything it holds.
+ pub fn destroy(self: *State, alloc: Allocator) void {
+ self.deinit(alloc);
+ alloc.destroy(self);
+ }
+
+ fn deinit(self: *State, alloc: Allocator) void {
+ self.freeDragData(alloc);
+ self.drop.accepted_mimes.deinit(alloc);
+ self.drop.registered_mimes.deinit(alloc);
+ }
+
+ /// Iterate the MIME types the client registered with, in order.
+ /// Empty when the client declared none, which is the common case.
+ /// The list is only needed to register exotic types with the OS,
+ /// such as macOS pasteboard stuff.
+ pub fn registeredMimes(self: *const State) std.mem.TokenIterator(u8, .scalar) {
+ return std.mem.tokenizeScalar(
+ u8,
+ self.drop.registered_mimes.items,
+ ' ',
+ );
+ }
+
+ /// Record one chunk of a registration's MIME list.
+ ///
+ /// `continuation` is true for every chunk but the first of a chunked
+ /// registration. Returns the registration event once the list is complete.
+ fn register(
+ self: *State,
+ alloc: Allocator,
+ payload: []const u8,
+ continuation: bool,
+ more: bool,
+ ) Allocator.Error!?Event {
+ const list = &self.drop.registered_mimes;
+ if (!continuation) list.clearRetainingCapacity();
+
+ // Matching kitty, an over-cap chunk is dropped and does not
+ // complete the registration.
+ if (list.items.len + payload.len > max_mime_list_bytes) return null;
+ try list.appendSlice(alloc, payload);
+
+ return if (more) null else .registration;
+ }
+
+ /// The client's acceptance response for the drag currently over the
+ /// terminal, for OS drag feedback. Null when the client hasn't
+ /// responded yet (embedders should fall back to their default,
+ /// typically copy) or `none` when the client rejected the drag.
+ pub fn clientAccepted(self: *const State) ?Operation {
+ if (self.drop.accept_in_progress) return null;
+ return self.drop.accepted;
+ }
+
+ /// Free the per-drag data (offered MIME list and held drop items).
+ fn freeDragData(self: *State, alloc: Allocator) void {
+ if (self.drop.offered) |*offered| {
+ offered.deinit(alloc);
+ self.drop.offered = null;
+ }
+ if (self.drop.items) |items| {
+ for (items) |item| {
+ alloc.free(item.mime);
+ alloc.free(item.data);
+ }
+ alloc.free(items);
+ self.drop.items = null;
+ }
+ }
+
+ /// Clear the per-drag state while preserving the registration,
+ /// mirroring kitty's reset_drop. Called when a new drag enters and
+ /// when a drop concludes.
+ fn resetDrop(self: *State, alloc: Allocator) void {
+ self.freeDragData(alloc);
+ self.drop.accepted_mimes.clearAndFree(alloc);
+ self.drop.hovered = false;
+ self.drop.dropped = false;
+ self.drop.accepted = null;
+ self.drop.accept_in_progress = false;
+ }
+
+ /// Handle a t=m acceptance status update from the client, mirroring
+ /// kitty's drop_set_status.
+ fn acceptStatus(
+ self: *State,
+ alloc: Allocator,
+ meta: Metadata,
+ payload: []const u8,
+ ) Allocator.Error!?Event {
+ const d = &self.drop;
+ if (!d.accept_in_progress) {
+ d.accepted_mimes.clearRetainingCapacity();
+ d.accept_in_progress = true;
+ d.accepted = .fromProtocol(meta.operation);
+ }
+
+ if (payload.len > 0) {
+ // Matching kitty, an over-cap list stops accumulating and
+ // never finalizes, leaving the acceptance unanswered.
+ if (d.accepted_mimes.items.len + payload.len > max_mime_list_bytes) return null;
+ try d.accepted_mimes.appendSlice(alloc, payload);
+ }
+
+ if (meta.more) return null;
+ d.accept_in_progress = false;
+ if (d.accepted_mimes.items.len > 0) {
+ for (d.accepted_mimes.items) |*c| {
+ if (c.* == ' ') c.* = 0;
+ }
+ try d.accepted_mimes.append(alloc, 0);
+ }
+ return .acceptance;
+ }
+
+ /// A native drag position report from the embedder.
+ pub const MoveEvent = struct {
+ /// Grid cell under the pointer, zero-based from the top-left.
+ cell_x: u32,
+ cell_y: u32,
+
+ /// Pointer position in pixels relative to the top-left of the
+ /// terminal's content area.
+ pixel_x: i32,
+ pixel_y: i32,
+
+ /// The operations the drag source allows.
+ operations: Operations,
+ };
+
+ /// Report a native drag moving over the terminal, sending a t=m
+ /// move event to the client. `mimes` is the list of MIME types the
+ /// terminal can provide for this drag, in the order data request
+ /// indices will refer to.
+ pub fn dragMove(
+ self: *State,
+ alloc: Allocator,
+ writer: *std.Io.Writer,
+ ev: MoveEvent,
+ mimes: []const []const u8,
+ ) (Allocator.Error || std.Io.Writer.Error)!void {
+ try self.moveEvent(
+ alloc,
+ writer,
+ ev,
+ mimes,
+ false,
+ );
+ }
+
+ /// Report a native drop onto the terminal. The items' data is
+ /// copied and held so the client's data requests can be served; it
+ /// is freed when the client concludes the drop, a new drag enters,
+ /// or the client unregisters.
+ ///
+ /// Sends a t=M drop event listing the items' MIME types.
+ pub fn dragDrop(
+ self: *State,
+ alloc: Allocator,
+ writer: *std.Io.Writer,
+ ev: MoveEvent,
+ items: []const Item,
+ ) (Allocator.Error || std.Io.Writer.Error)!void {
+ // Copy the items so they can be served after this call returns.
+ // Items beyond the cap are dropped so the held list always
+ // matches the advertised MIME list.
+ const accepted_items = items[0..@min(items.len, max_items)];
+ const copies = try alloc.alloc(Item, accepted_items.len);
+ errdefer alloc.free(copies);
+ var copied: usize = 0;
+ errdefer for (copies[0..copied]) |item| {
+ alloc.free(item.mime);
+ alloc.free(item.data);
+ };
+ for (accepted_items, copies) |item, *copy| {
+ const mime = try alloc.dupe(u8, item.mime);
+ errdefer alloc.free(mime);
+ const data = try alloc.dupe(u8, item.data);
+ copy.* = .{ .mime = mime, .data = data };
+ copied += 1;
+ }
+
+ // The move handling below resets per-drag state when this drop
+ // arrives without a preceding move, so the items are attached
+ // after it runs. Collect the MIME list first.
+ var mimes_buf: [max_items][]const u8 = undefined;
+ const mimes = mimes_buf[0..copies.len];
+ for (mimes, copies) |*m, item| m.* = item.mime;
+
+ try self.moveEvent(
+ alloc,
+ writer,
+ ev,
+ mimes,
+ true,
+ );
+
+ assert(self.drop.items == null);
+ self.drop.items = copies;
+ }
+
+ /// Report the native drag leaving the terminal, sending the t=m
+ /// leave event (x=-1, y=-1).
+ ///
+ /// Ignored after a drop: some toolkits emit a leave notification
+ /// for the drop itself, and the held data must survive until the
+ /// client concludes.
+ pub fn dragLeave(
+ self: *State,
+ alloc: Allocator,
+ writer: *std.Io.Writer,
+ ) std.Io.Writer.Error!void {
+ if (self.drop.dropped) return;
+ const hovered = self.drop.hovered;
+ self.drop.hovered = false;
+ if (self.drop.offered) |*offered| {
+ offered.deinit(alloc);
+ self.drop.offered = null;
+ }
+
+ // Only a client that saw the drag enter gets the leave event,
+ // matching kitty which notifies hovered windows only.
+ if (!hovered) return;
+
+ try response.encode(
+ writer,
+ "t=m:x=-1:y=-1",
+ self.drop.client_id,
+ "",
+ .plain,
+ .st,
+ );
+ }
+
+ /// Shared implementation of move and drop events, mirroring kitty's
+ /// drop_move_on_child.
+ fn moveEvent(
+ self: *State,
+ alloc: Allocator,
+ writer: *std.Io.Writer,
+ ev: MoveEvent,
+ mimes: []const []const u8,
+ is_drop: bool,
+ ) (Allocator.Error || std.Io.Writer.Error)!void {
+ if (!self.drop.hovered) {
+ self.resetDrop(alloc);
+ self.drop.hovered = true;
+ }
+ if (is_drop) {
+ self.drop.dropped = true;
+ self.drop.hovered = false;
+ }
+
+ // (Re)build the offered MIME list when it changed.
+ if (self.drop.offered == null or !self.drop.offered.?.eql(mimes)) {
+ if (self.drop.offered) |*offered| offered.deinit(alloc);
+ self.drop.offered = null;
+ self.drop.offered = try Offered.init(alloc, mimes);
+ }
+
+ var header_buf: [96]u8 = undefined;
+ const header = std.fmt.bufPrint(
+ &header_buf,
+ "t={c}:x={d}:y={d}:X={d}:Y={d}:o={d}",
+ .{
+ @as(u8, if (is_drop) 'M' else 'm'),
+ ev.cell_x,
+ ev.cell_y,
+ ev.pixel_x,
+ ev.pixel_y,
+ ev.operations.protocolValue(),
+ },
+ ) catch unreachable;
+
+ // The MIME list is sent with every move event, matching kitty
+ // (the spec suggests only the first, but kitty always sends it
+ // and clients depend on that).
+ try response.encode(
+ writer,
+ header,
+ self.drop.client_id,
+ self.drop.offered.?.payload,
+ .plain,
+ .st,
+ );
+ }
+};
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);
+ }
+}
diff --git a/src/terminal/kitty/dnd_test.zig b/src/terminal/kitty/dnd_test.zig
new file mode 100644
index 000000000..c78ce00f6
--- /dev/null
+++ b/src/terminal/kitty/dnd_test.zig
@@ -0,0 +1,626 @@
+//! End-to-end tests for the OSC 72 protocol state machine, validating
+//! wire behavior against kitty's implementation (using kitty_tests/dnd.py as
+//! an oracle for the expected bytes).
+//!
+//! It isn't normal for us to have dedicated test files but in this case
+//! the dnd protocol is complicated enough that I wanted full e2e covering
+//! the full machine.
+
+const std = @import("std");
+const testing = std.testing;
+
+const osc = @import("../osc.zig");
+const dnd = @import("dnd.zig");
+
+/// A test harness holding the lazily allocated protocol state and an
+/// output collector.
+const Harness = struct {
+ state: ?*dnd.State = null,
+ output: std.Io.Writer.Allocating,
+
+ fn init() Harness {
+ return .{ .output = .init(testing.allocator) };
+ }
+
+ fn deinit(self: *Harness) void {
+ if (self.state) |state| state.destroy(testing.allocator);
+ self.output.deinit();
+ }
+
+ /// The registered state; asserts a client has registered.
+ fn registered(self: *Harness) *dnd.State {
+ return self.state.?;
+ }
+
+ /// Feed one client command, as it would arrive from the OSC parser,
+ /// returning the event the stream handler would pass to its effect.
+ fn command(self: *Harness, metadata: []const u8, payload: ?[]const u8) !?dnd.Event {
+ return try dnd.handleCommand(&self.state, testing.allocator, &self.output.writer, .{
+ .metadata = metadata,
+ .payload = payload,
+ .terminator = .st,
+ });
+ }
+
+ /// Consume and return the collected output.
+ fn consume(self: *Harness) []const u8 {
+ const written = self.output.written();
+ return written;
+ }
+
+ fn clear(self: *Harness) void {
+ self.output.clearRetainingCapacity();
+ }
+
+ fn expectOutput(self: *Harness, expected: []const u8) !void {
+ try testing.expectEqualStrings(expected, self.output.written());
+ self.clear();
+ }
+};
+
+test "dnd: query response" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ // Works without any registration, matching kitty, and allocates
+ // nothing.
+ _ = try h.command("t=q", null);
+ try h.expectOutput("\x1b]72;t=q\x1b\\");
+ try testing.expect(h.state == null);
+}
+
+test "dnd: query response echoes client id" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=q:i=31", null);
+ try h.expectOutput("\x1b]72;t=q:i=31\x1b\\");
+}
+
+test "dnd: register and unregister" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ try testing.expect(h.state == null);
+
+ // Registration allocates the state and reports it, with the
+ // declared MIME list readable from the state.
+ try testing.expect((try h.command("t=a", "text/plain text/uri-list")).? == .registration);
+ try h.expectOutput("");
+ {
+ var it = h.registered().registeredMimes();
+ try testing.expectEqualStrings("text/plain", it.next().?);
+ try testing.expectEqualStrings("text/uri-list", it.next().?);
+ try testing.expect(it.next() == null);
+ }
+
+ // Machine ID declaration is accepted and ignored.
+ try testing.expect((try h.command("t=a:x=1", "1:deadbeef")) == null);
+ try h.expectOutput("");
+ try testing.expect(h.state != null);
+
+ // Re-registration replaces the list.
+ try testing.expect((try h.command("t=a", "image/png")).? == .registration);
+ {
+ var it = h.registered().registeredMimes();
+ try testing.expectEqualStrings("image/png", it.next().?);
+ try testing.expect(it.next() == null);
+ }
+
+ // Registering without a list is the common case.
+ try testing.expect((try h.command("t=a", null)).? == .registration);
+ {
+ var it = h.registered().registeredMimes();
+ try testing.expect(it.next() == null);
+ }
+
+ // Unregistration frees it and reports the change.
+ try testing.expect((try h.command("t=A", null)).? == .registration);
+ try h.expectOutput("");
+ try testing.expect(h.state == null);
+
+ // Unregistering again changes nothing.
+ try testing.expect((try h.command("t=A", null)) == null);
+ try h.expectOutput("");
+ try testing.expect(h.state == null);
+}
+
+test "dnd: no state before registration" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ // State-dependent commands from an unregistered client allocate
+ // nothing; a data request gets the error kitty sends from its
+ // zeroed state.
+ _ = try h.command("t=m:o=1", "text/plain");
+ _ = try h.command("t=r", null);
+ try h.expectOutput("");
+ _ = try h.command("t=r:x=1", null);
+ try h.expectOutput(
+ "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\",
+ );
+ try testing.expect(h.state == null);
+}
+
+test "dnd: move event carries position, operations, and mime list" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a", "text/plain");
+
+ try h.registered().dragMove(testing.allocator, &h.output.writer, .{
+ .cell_x = 5,
+ .cell_y = 3,
+ .pixel_x = 100,
+ .pixel_y = 60,
+ .operations = .{ .copy = true },
+ }, &.{ "text/plain", "text/uri-list" });
+
+ // Note the trailing space after every MIME entry, matching kitty.
+ try h.expectOutput(
+ "\x1b]72;t=m:x=5:y=3:X=100:Y=60:o=1:m=0;text/plain text/uri-list \x1b\\",
+ );
+}
+
+test "dnd: move event echoes registration client id" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a:i=7", "");
+ try h.registered().dragMove(testing.allocator, &h.output.writer, .{
+ .cell_x = 1,
+ .cell_y = 2,
+ .pixel_x = 8,
+ .pixel_y = 16,
+ .operations = .{ .copy = true, .move = true },
+ }, &.{"text/plain"});
+ try h.expectOutput("\x1b]72;t=m:x=1:y=2:X=8:Y=16:o=3:i=7:m=0;text/plain \x1b\\");
+}
+
+test "dnd: re-registration updates client id in place" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a:i=7", "");
+ const state = h.registered();
+ _ = try h.command("t=a:i=9", "");
+ // Same allocation, new client ID.
+ try testing.expect(h.state.? == state);
+ try testing.expectEqual(@as(u32, 9), state.drop.client_id);
+}
+
+test "dnd: mime list sent on every move" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a", "");
+ const ev: dnd.MoveEvent = .{
+ .cell_x = 0,
+ .cell_y = 0,
+ .pixel_x = 0,
+ .pixel_y = 0,
+ .operations = .{ .copy = true },
+ };
+ try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"});
+ h.clear();
+
+ // Kitty resends the list even when unchanged; clients depend on it.
+ try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"});
+ try h.expectOutput("\x1b]72;t=m:x=0:y=0:X=0:Y=0:o=1:m=0;text/plain \x1b\\");
+}
+
+test "dnd: leave event" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a", "");
+ try h.registered().dragMove(testing.allocator, &h.output.writer, .{
+ .cell_x = 0,
+ .cell_y = 0,
+ .pixel_x = 0,
+ .pixel_y = 0,
+ .operations = .{ .copy = true },
+ }, &.{"text/plain"});
+ h.clear();
+
+ try h.registered().dragLeave(testing.allocator, &h.output.writer);
+ try h.expectOutput("\x1b]72;t=m:x=-1:y=-1\x1b\\");
+}
+
+test "dnd: client acceptance recorded" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a", "");
+ try testing.expect(h.registered().clientAccepted() == null);
+
+ try testing.expect((try h.command("t=m:o=1", "text/plain")).? == .acceptance);
+ try h.expectOutput("");
+ try testing.expectEqual(dnd.Operation.copy, h.registered().clientAccepted().?);
+
+ // Rejection.
+ try testing.expect((try h.command("t=m:o=0", "")).? == .acceptance);
+ try testing.expectEqual(dnd.Operation.none, h.registered().clientAccepted().?);
+}
+
+test "dnd: chunked client acceptance" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a", "");
+
+ // Chunked accept: continuation metadata is ignored, the acceptance
+ // is pending until the final chunk.
+ try testing.expect((try h.command("t=m:o=2:m=1", "text/pl")) == null);
+ try testing.expect(h.registered().clientAccepted() == null);
+ try testing.expect((try h.command("t=m:m=1", "ain text")) == null);
+ try testing.expect((try h.command("t=m:m=0", "/html")).? == .acceptance);
+ try testing.expectEqual(dnd.Operation.move, h.registered().clientAccepted().?);
+
+ // The accumulated list was converted to NUL-separated entries.
+ try testing.expectEqualSlices(
+ u8,
+ "text/plain\x00text/html\x00",
+ h.registered().drop.accepted_mimes.items,
+ );
+}
+
+test "dnd: drop and data serving round trip" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a", "text/plain text/uri-list");
+
+ const ev: dnd.MoveEvent = .{
+ .cell_x = 4,
+ .cell_y = 2,
+ .pixel_x = 40,
+ .pixel_y = 20,
+ .operations = .{ .copy = true },
+ };
+ try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{
+ .{ .mime = "text/uri-list", .data = "file:///tmp/a.txt\r\n" },
+ .{ .mime = "text/plain", .data = "hello" },
+ });
+ try h.expectOutput(
+ "\x1b]72;t=M:x=4:y=2:X=40:Y=20:o=1:m=0;text/uri-list text/plain \x1b\\",
+ );
+
+ // Request the second MIME's data: base64 chunk plus the empty
+ // end-of-data message.
+ _ = try h.command("t=r:x=2", null);
+ try h.expectOutput(
+ "\x1b]72;t=r:x=2:m=0;aGVsbG8=\x1b\\" ++ "\x1b]72;t=r:x=2\x1b\\",
+ );
+
+ // Out-of-bounds request.
+ _ = try h.command("t=r:x=3", null);
+ try h.expectOutput(
+ "\x1b]72;t=R:x=3:m=0;ENOENT:drop data request index out of bounds\x1b\\",
+ );
+
+ // Conclude: the performed operation is reported, held data is
+ // freed, and further requests fail.
+ try testing.expectEqual(dnd.Event.concluded_copy, (try h.command("t=r:o=1", null)).?);
+ try h.expectOutput("");
+ try testing.expect((try h.command("t=r:o=1", null)) == null);
+ _ = try h.command("t=r:x=1", null);
+ try h.expectOutput(
+ "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\",
+ );
+}
+
+test "dnd: empty item served as a single end-of-data message" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a", "");
+ try h.registered().dragDrop(testing.allocator, &h.output.writer, .{
+ .cell_x = 0,
+ .cell_y = 0,
+ .pixel_x = 0,
+ .pixel_y = 0,
+ .operations = .{ .copy = true },
+ }, &.{.{ .mime = "text/plain", .data = "" }});
+ h.clear();
+
+ // Kitty's oracle (test_empty_data) asserts exactly one message:
+ // the empty response is itself the end-of-data signal, and a
+ // duplicate would be a second completion to the client.
+ _ = try h.command("t=r:x=1", null);
+ try h.expectOutput("\x1b]72;t=r:x=1\x1b\\");
+}
+
+test "dnd: leave without hover sends nothing" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ // Client registered but no move was ever forwarded (e.g. it
+ // registered mid-drag): kitty only notifies hovered windows.
+ _ = try h.command("t=a", "");
+ try h.registered().dragLeave(testing.allocator, &h.output.writer);
+ try h.expectOutput("");
+}
+
+test "dnd: data request with no drop" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a", "");
+ _ = try h.command("t=r:x=1", null);
+ try h.expectOutput(
+ "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\",
+ );
+}
+
+test "dnd: leave after drop is ignored" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a", "");
+ try h.registered().dragDrop(testing.allocator, &h.output.writer, .{
+ .cell_x = 0,
+ .cell_y = 0,
+ .pixel_x = 0,
+ .pixel_y = 0,
+ .operations = .{ .copy = true },
+ }, &.{.{ .mime = "text/plain", .data = "x" }});
+ h.clear();
+
+ // Some toolkits emit a leave for the drop itself; the held data
+ // must survive so the client can still fetch it.
+ try h.registered().dragLeave(testing.allocator, &h.output.writer);
+ try h.expectOutput("");
+
+ _ = try h.command("t=r:x=1", null);
+ try h.expectOutput(
+ "\x1b]72;t=r:x=1:m=0;eA==\x1b\\" ++ "\x1b]72;t=r:x=1\x1b\\",
+ );
+}
+
+test "dnd: new drag resets held drop data" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a", "");
+ const ev: dnd.MoveEvent = .{
+ .cell_x = 0,
+ .cell_y = 0,
+ .pixel_x = 0,
+ .pixel_y = 0,
+ .operations = .{ .copy = true },
+ };
+ try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{
+ .{ .mime = "text/plain", .data = "old" },
+ });
+ h.clear();
+
+ // A new drag entering resets the per-drag state including the held
+ // items from the unconcluded previous drop.
+ try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"});
+ h.clear();
+ _ = try h.command("t=r:x=1", null);
+ try h.expectOutput(
+ "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\",
+ );
+}
+
+test "dnd: remote transfer requests refused" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a", "");
+
+ // URI file content request.
+ _ = try h.command("t=r:x=1:y=2", null);
+ try h.expectOutput(
+ "\x1b]72;t=R:x=1:y=2:m=0;EINVAL:remote drop data is not supported\x1b\\",
+ );
+
+ // Directory handle request.
+ _ = try h.command("t=r:Y=2:x=1", null);
+ try h.expectOutput(
+ "\x1b]72;t=R:x=1:Y=2:m=0;EINVAL:remote drop data is not supported\x1b\\",
+ );
+}
+
+test "dnd: drag out refused" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ // Enabling and disabling offers is accepted silently and allocates
+ // nothing.
+ _ = try h.command("t=o:x=1", null);
+ _ = try h.command("t=o:x=2", null);
+ try h.expectOutput("");
+ try testing.expect(h.state == null);
+
+ // Offering a drag is refused.
+ _ = try h.command("t=o:x=1", null);
+ _ = try h.command("t=o:o=3", "text/plain");
+ try h.expectOutput(
+ "\x1b]72;t=E:m=0;EPERM:drag out is not supported by this terminal\x1b\\",
+ );
+
+ // Starting a drag is refused, echoing the command's client id.
+ _ = try h.command("t=P:x=-1:i=9", null);
+ try h.expectOutput(
+ "\x1b]72;t=E:i=9:m=0;EPERM:drag out is not supported by this terminal\x1b\\",
+ );
+ try testing.expect(h.state == null);
+}
+
+test "dnd: unregister frees held drop data" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a", "");
+ try h.registered().dragDrop(testing.allocator, &h.output.writer, .{
+ .cell_x = 0,
+ .cell_y = 0,
+ .pixel_x = 0,
+ .pixel_y = 0,
+ .operations = .{ .copy = true },
+ }, &.{.{ .mime = "text/plain", .data = "x" }});
+ h.clear();
+
+ // The testing allocator would report the held data as leaked if
+ // unregistration didn't free the whole state.
+ _ = try h.command("t=A", null);
+ try testing.expect(h.state == null);
+}
+
+test "dnd: chunked registration reuses first chunk metadata" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ // Registration split over two chunks: the first chunk allocates
+ // the state and seeds chunk reassembly, so the continuation (which
+ // carries a different type) is still treated as the registration.
+ try testing.expect((try h.command("t=a:i=4:m=1", "text/pla")) == null);
+ try testing.expect(h.state != null);
+ try testing.expect((try h.command("t=q:m=0", "in")).? == .registration);
+ try h.expectOutput("");
+ try testing.expectEqual(@as(u32, 4), h.registered().drop.client_id);
+ {
+ var it = h.registered().registeredMimes();
+ try testing.expectEqualStrings("text/plain", it.next().?);
+ }
+
+ // A query after the chunked command completes works again.
+ _ = try h.command("t=q", null);
+ try h.expectOutput("\x1b]72;t=q\x1b\\");
+}
+
+test "dnd: malformed metadata ignored" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a:zz=1", "");
+ try h.expectOutput("");
+ try testing.expect(h.state == null);
+
+ // Command with no type is ignored, matching kitty (the spec's
+ // default of t=a is not honored by the reference implementation).
+ _ = try h.command("x=1", "");
+ try h.expectOutput("");
+ try testing.expect(h.state == null);
+}
+
+test "dnd: bel terminator echoed in responses" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try dnd.handleCommand(&h.state, testing.allocator, &h.output.writer, .{
+ .metadata = "t=q",
+ .payload = null,
+ .terminator = .bel,
+ });
+ try h.expectOutput("\x1b]72;t=q\x07");
+}
+
+test "dnd: kitten 0.47 conversation replay" {
+ // This replays a conversation recorded from the reference client
+ // (`kitten dnd --drop-anywhere=copy --drop text/plain:out.txt`,
+ // kitten 0.47.0) driven over a pty by a harness that sent exactly
+ // the bytes this engine produces. The kitten accepted the events,
+ // wrote the dropped payload to disk intact, and concluded; its
+ // client bytes are frozen here as an interop regression test.
+ var h: Harness = .init();
+ defer h.deinit();
+
+ // Startup: register with MIME list and machine ID, then the test
+ // harness reset (unregister both directions, re-register).
+ _ = try h.command("t=a:m=0", "text/uri-list text/plain");
+ _ = try h.command(
+ "t=a:x=1:m=0",
+ "1:5cff8247c477900a8727e2281fe890252f8848f87c224dd8dd7fb6303e94ddbd",
+ );
+ _ = try h.command("t=A", null);
+ try testing.expect(h.state == null);
+ _ = try h.command("t=o:x=2", null);
+ _ = try h.command("t=a:m=0", "text/uri-list text/plain");
+ _ = try h.command(
+ "t=a:x=1:m=0",
+ "1:5cff8247c477900a8727e2281fe890252f8848f87c224dd8dd7fb6303e94ddbd",
+ );
+ try h.expectOutput("");
+ try testing.expect(h.state != null);
+
+ // Native drag moves over the terminal and drops.
+ const ev: dnd.MoveEvent = .{
+ .cell_x = 2,
+ .cell_y = 1,
+ .pixel_x = 20,
+ .pixel_y = 18,
+ .operations = .{ .copy = true },
+ };
+ try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"});
+ try h.expectOutput("\x1b]72;t=m:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\");
+
+ // The kitten accepts as a copy of text/plain.
+ _ = try h.command("t=m:o=1:m=0", "text/plain");
+ try h.expectOutput("");
+ try testing.expectEqual(dnd.Operation.copy, h.registered().clientAccepted().?);
+
+ try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{
+ .{ .mime = "text/plain", .data = "hello from ghostty\n" },
+ });
+ try h.expectOutput("\x1b]72;t=M:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\");
+
+ // The kitten requests the data and concludes with a copy.
+ _ = try h.command("t=r:x=1", null);
+ try h.expectOutput(
+ "\x1b]72;t=r:x=1:m=0;aGVsbG8gZnJvbSBnaG9zdHR5Cg==\x1b\\" ++
+ "\x1b]72;t=r:x=1\x1b\\",
+ );
+ _ = try h.command("t=r:o=1", null);
+ try h.expectOutput("");
+ try testing.expect(h.registered().drop.items == null);
+}
+
+test "dnd: large data served in chunks" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ _ = try h.command("t=a", "");
+
+ // 3073 bytes: one full chunk plus one byte.
+ const data = [_]u8{'Z'} ** 3073;
+ try h.registered().dragDrop(testing.allocator, &h.output.writer, .{
+ .cell_x = 0,
+ .cell_y = 0,
+ .pixel_x = 0,
+ .pixel_y = 0,
+ .operations = .{ .copy = true },
+ }, &.{.{ .mime = "application/octet-stream", .data = &data }});
+ h.clear();
+
+ _ = try h.command("t=r:x=1", null);
+ const out = h.consume();
+
+ // First chunk is m=1 with 4096 base64 chars, second is m=0, and
+ // the final message is the bare end-of-data marker.
+ try testing.expect(std.mem.startsWith(u8, out, "\x1b]72;t=r:x=1:m=1;"));
+ try testing.expect(std.mem.indexOf(u8, out, "\x1b]72;t=r:x=1:m=0;") != null);
+ try testing.expect(std.mem.endsWith(u8, out, "\x1b]72;t=r:x=1\x1b\\"));
+ h.clear();
+}
+
+test "dnd: over-cap registration list never completes" {
+ var h: Harness = .init();
+ defer h.deinit();
+
+ // Matching kitty, a chunk that would exceed the cap is dropped and
+ // the registration is not reported, though the client stays
+ // registered (the state exists).
+ const big = try testing.allocator.alloc(u8, dnd.max_mime_list_bytes + 1);
+ defer testing.allocator.free(big);
+ @memset(big, 'a');
+ try testing.expect((try h.command("t=a", big)) == null);
+ try testing.expect(h.state != null);
+ {
+ var it = h.registered().registeredMimes();
+ try testing.expect(it.next() == null);
+ }
+}
diff --git a/src/terminal/osc/parsers/kitty_dnd_protocol.zig b/src/terminal/osc/parsers/kitty_dnd_protocol.zig
index 35d239066..839e39618 100644
--- a/src/terminal/osc/parsers/kitty_dnd_protocol.zig
+++ b/src/terminal/osc/parsers/kitty_dnd_protocol.zig
@@ -1,5 +1,12 @@
//! Kitty's drag and drop protocol (OSC 72)
-//! Specification: https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/
+//!
+//! This only captures the raw metadata and payload for the OSC. The
+//! actual protocol grammar (metadata keys, event types, chunking) is
+//! implemented in `terminal/kitty/dnd.zig` and its submodules, since the
+//! protocol requires stateful handling that doesn't belong in the
+//! stateless OSC parser.
+//!
+//! Specification: https://sw.kovidgoyal.net/kitty/dnd-protocol/
const std = @import("std");
@@ -9,143 +16,52 @@ const Parser = @import("../../osc.zig").Parser;
const Command = @import("../../osc.zig").Command;
const Terminator = @import("../../osc.zig").Terminator;
-const log = std.log.scoped(.kitty_dnd_protocol);
-
pub const OSC = struct {
- /// The raw metadata that was received. Parse individual values with `readOption`.
+ /// The raw metadata that was received. Parse with
+ /// `kitty.dnd.Metadata.parse`.
metadata: []const u8,
- /// The raw payload. Its meaning and encoding depend on the event type (`t` key).
+
+ /// The raw payload. Its meaning and encoding depend on the event
+ /// type (`t` metadata key). Null when the OSC had no `;` after the
+ /// metadata; an empty payload is distinct from no payload.
payload: ?[]const u8,
+
/// The terminator used for this OSC, so any response can match it.
terminator: Terminator,
- pub fn readOption(self: OSC, comptime key: Option) ?key.Type() {
- return key.read(self.metadata);
+ /// We don't currently support encoding this to C in any way.
+ pub const C = void;
+
+ pub fn cval(_: OSC) C {
+ return {};
}
};
-/// Values for the `t` (event type) metadata key.
-pub const EventType = enum {
- /// ('a') Terminal registers itself as willing to accept drops.
- accept_drops,
- /// ('A') Terminal unregisters itself; drops should no longer be forwarded.
- stop_accepting_drops,
- /// ('m') Pointer is moving over the terminal while a drag is in progress.
- /// Carries `x`/`y` cursor position; -1 signals the drag left the window.
- drop_move,
- /// ('M') Items were dropped onto the terminal.
- /// Carries `x`/`y` drop position and `i` (multiplexer session ID).
- drop_dropped,
- /// ('r') Terminal requests data for a specific MIME type from the drag source.
- /// Carries `i` (multiplexer session ID) and `y` (1-based MIME type index).
- request_data,
- /// ('R') Error response to a `request_data` event.
- request_error,
- /// ('o') Terminal offers data for an outgoing drag (drag-out from terminal).
- offer_drag,
- /// ('p') Drag source presents the actual payload for a previously requested MIME type.
- /// Carries `i` (multiplexer session ID), `o` (operation), and `m` (chunking flag).
- present_data,
- /// ('P') Replace the current drag image with a new one.
- /// Payload is the image data; `X`/`Y` carry image dimensions in pixels.
- change_drag_image,
- /// ('e') Notification of an event on an outgoing drag offer (e.g., accepted or rejected).
- drag_offer_event,
- /// ('E') Error on an outgoing drag offer.
- drag_offer_error,
- /// ('k') URI list data delivered as part of a drag or clipboard transfer.
- uri_list_data,
- /// ('q') Query terminal capabilities related to the drag-and-drop protocol.
- query,
+pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
+ assert(parser.state == .@"72");
- pub fn init(str: []const u8) ?EventType {
- if (str.len != 1) return null;
- return switch (str[0]) {
- 'a' => .accept_drops,
- 'A' => .stop_accepting_drops,
- 'm' => .drop_move,
- 'M' => .drop_dropped,
- 'r' => .request_data,
- 'R' => .request_error,
- 'o' => .offer_drag,
- 'p' => .present_data,
- 'P' => .change_drag_image,
- 'e' => .drag_offer_event,
- 'E' => .drag_offer_error,
- 'k' => .uri_list_data,
- 'q' => .query,
- else => null,
- };
- }
-};
+ const cap = if (parser.capture) |*c| c else {
+ parser.state = .invalid;
+ return null;
+ };
-/// Metadata keys defined by the protocol. Keys are case-sensitive: `x` and `X` are distinct.
-pub const Option = enum {
- /// Event type. Maps to `EventType`; present in every OSC 72 sequence.
- t,
- /// Chunking flag. `0` = this is the final (or only) chunk; `1` = more chunks follow.
- m,
- /// Multiplexer session ID. Echoed back in responses so a terminal multiplexer
- /// (e.g. tmux) can route data to the correct pane.
- i,
- /// Drop operation. `0` = reject, `1` = copy, `2` = move, `3` = copy or move.
- o,
- /// Cursor column in cell units (zero-based). -1 signals the drag has left the window.
- x,
- /// Cursor row in cell units (zero-based). Also used as a 1-based MIME type index
- /// in some events (e.g. `request_data`). -1 signals the drag has left the window.
- y,
- /// Pixel offset from the left edge of the cell; also used as image width
- /// (with `change_drag_image`) or as a symlink/directory marker.
- X,
- /// Pixel offset from the top edge of the cell; also used as image height
- /// (with `change_drag_image`) or as a parent directory handle.
- Y,
+ const data = cap.trailing();
- pub fn Type(comptime key: Option) type {
- return switch (key) {
- .t => EventType,
- // The spec uses 32-bit signed or unsigned; we standardize on
- // i32 because the location keys legitimately take -1 (drag
- // leaves the window) and other keys never exceed i32 range.
- .m, .i, .o, .x, .y, .X, .Y => i32,
- };
- }
+ const metadata: []const u8, const payload: ?[]const u8 = result: {
+ const sep = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null };
+ break :result .{ data[0..sep], data[sep + 1 .. data.len] };
+ };
- pub fn read(comptime key: Option, metadata: []const u8) ?key.Type() {
- const name = @tagName(key);
+ parser.command = .{
+ .kitty_dnd_protocol = .{
+ .metadata = metadata,
+ .payload = payload,
+ .terminator = .init(terminator_ch),
+ },
+ };
- const value: []const u8 = value: {
- var pos: usize = 0;
- while (pos < metadata.len) {
- while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1;
- if (pos >= metadata.len) return null;
-
- // Case-sensitive match: x and X must not be confused.
- if (!std.mem.startsWith(u8, metadata[pos..], name)) {
- pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null;
- pos += 1;
- continue;
- }
- pos += name.len;
-
- while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1;
- if (pos >= metadata.len) return null;
- if (metadata[pos] != '=') return null;
-
- const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len;
- const start = pos + 1;
- break :value std.mem.trim(u8, metadata[start..end], &std.ascii.whitespace);
- }
- return null;
- };
-
- return switch (key) {
- .t => .init(value),
- .m, .i, .o, .x, .y, .X, .Y => std.fmt.parseInt(i32, value, 10) catch null,
- };
- }
-};
+ return &parser.command;
+}
test "OSC 72: metadata only, no payload" {
const testing = std.testing;
@@ -192,134 +108,19 @@ test "OSC 72: metadata and non-empty payload" {
try testing.expectEqualStrings("text/plain text/uri-list", cmd.kitty_dnd_protocol.payload.?);
}
-test "OSC 72: readOption .t valid event types" {
+test "OSC 72: empty metadata with payload" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
- const cases = .{
- .{ "72;t=a", EventType.accept_drops },
- .{ "72;t=A", EventType.stop_accepting_drops },
- .{ "72;t=m", EventType.drop_move },
- .{ "72;t=M", EventType.drop_dropped },
- .{ "72;t=r", EventType.request_data },
- .{ "72;t=R", EventType.request_error },
- .{ "72;t=o", EventType.offer_drag },
- .{ "72;t=p", EventType.present_data },
- .{ "72;t=P", EventType.change_drag_image },
- .{ "72;t=e", EventType.drag_offer_event },
- .{ "72;t=E", EventType.drag_offer_error },
- .{ "72;t=k", EventType.uri_list_data },
- .{ "72;t=q", EventType.query },
- };
-
- inline for (cases) |case| {
- p.deinit();
- p = .init(testing.allocator);
- for (case[0]) |ch| p.next(ch);
- const cmd = p.end('\x1b').?.*;
- try testing.expect(cmd == .kitty_dnd_protocol);
- try testing.expectEqual(case[1], cmd.kitty_dnd_protocol.readOption(.t).?);
- }
-}
-
-test "OSC 72: readOption .t unknown value returns null" {
- const testing = std.testing;
-
- var p: Parser = .init(testing.allocator);
- defer p.deinit();
-
- const input = "72;t=z";
+ const input = "72;;payload";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_dnd_protocol);
- try testing.expect(cmd.kitty_dnd_protocol.readOption(.t) == null);
-}
-
-test "OSC 72: readOption integer keys" {
- const testing = std.testing;
-
- var p: Parser = .init(testing.allocator);
- defer p.deinit();
-
- const input = "72;t=m:i=3:x=10:y=5:X=320:Y=200:o=1:m=0";
- for (input) |ch| p.next(ch);
-
- const cmd = p.end('\x1b').?.*;
- try testing.expect(cmd == .kitty_dnd_protocol);
- try testing.expectEqual(@as(i32, 3), cmd.kitty_dnd_protocol.readOption(.i).?);
- try testing.expectEqual(@as(i32, 10), cmd.kitty_dnd_protocol.readOption(.x).?);
- try testing.expectEqual(@as(i32, 5), cmd.kitty_dnd_protocol.readOption(.y).?);
- try testing.expectEqual(@as(i32, 320), cmd.kitty_dnd_protocol.readOption(.X).?);
- try testing.expectEqual(@as(i32, 200), cmd.kitty_dnd_protocol.readOption(.Y).?);
- try testing.expectEqual(@as(i32, 1), cmd.kitty_dnd_protocol.readOption(.o).?);
- try testing.expectEqual(@as(i32, 0), cmd.kitty_dnd_protocol.readOption(.m).?);
-}
-
-test "OSC 72: readOption negative sentinel (-1 for drag leave)" {
- const testing = std.testing;
-
- var p: Parser = .init(testing.allocator);
- defer p.deinit();
-
- const input = "72;t=m:x=-1:y=-1";
- for (input) |ch| p.next(ch);
-
- const cmd = p.end('\x1b').?.*;
- try testing.expect(cmd == .kitty_dnd_protocol);
- try testing.expectEqual(@as(i32, -1), cmd.kitty_dnd_protocol.readOption(.x).?);
- try testing.expectEqual(@as(i32, -1), cmd.kitty_dnd_protocol.readOption(.y).?);
-}
-
-test "OSC 72: readOption case-sensitive key matching" {
- const testing = std.testing;
-
- var p: Parser = .init(testing.allocator);
- defer p.deinit();
-
- // x=10 must not be returned when asking for .X
- const input = "72;x=10:Y=200";
- for (input) |ch| p.next(ch);
-
- const cmd = p.end('\x1b').?.*;
- try testing.expect(cmd == .kitty_dnd_protocol);
- try testing.expectEqual(@as(i32, 10), cmd.kitty_dnd_protocol.readOption(.x).?);
- try testing.expect(cmd.kitty_dnd_protocol.readOption(.X) == null);
- try testing.expectEqual(@as(i32, 200), cmd.kitty_dnd_protocol.readOption(.Y).?);
- try testing.expect(cmd.kitty_dnd_protocol.readOption(.y) == null);
-}
-
-test "OSC 72: readOption absent key returns null" {
- const testing = std.testing;
-
- var p: Parser = .init(testing.allocator);
- defer p.deinit();
-
- const input = "72;t=a";
- for (input) |ch| p.next(ch);
-
- const cmd = p.end('\x1b').?.*;
- try testing.expect(cmd == .kitty_dnd_protocol);
- try testing.expect(cmd.kitty_dnd_protocol.readOption(.i) == null);
- try testing.expect(cmd.kitty_dnd_protocol.readOption(.x) == null);
- try testing.expect(cmd.kitty_dnd_protocol.readOption(.X) == null);
- try testing.expect(cmd.kitty_dnd_protocol.readOption(.m) == null);
-}
-
-test "OSC 72: readOption malformed integer returns null" {
- const testing = std.testing;
-
- var p: Parser = .init(testing.allocator);
- defer p.deinit();
-
- const input = "72;x=notanumber";
- for (input) |ch| p.next(ch);
-
- const cmd = p.end('\x1b').?.*;
- try testing.expect(cmd == .kitty_dnd_protocol);
- try testing.expect(cmd.kitty_dnd_protocol.readOption(.x) == null);
+ try testing.expectEqualStrings("", cmd.kitty_dnd_protocol.metadata);
+ try testing.expectEqualStrings("payload", cmd.kitty_dnd_protocol.payload.?);
}
test "OSC 72: BEL terminator recorded" {
@@ -335,29 +136,3 @@ test "OSC 72: BEL terminator recorded" {
try testing.expect(cmd == .kitty_dnd_protocol);
try testing.expect(cmd.kitty_dnd_protocol.terminator == .bel);
}
-
-pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
- assert(parser.state == .@"72");
-
- const cap = if (parser.capture) |*c| c else {
- parser.state = .invalid;
- return null;
- };
-
- const data = cap.trailing();
-
- const metadata: []const u8, const payload: ?[]const u8 = result: {
- const sep = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null };
- break :result .{ data[0..sep], data[sep + 1 .. data.len] };
- };
-
- parser.command = .{
- .kitty_dnd_protocol = .{
- .metadata = metadata,
- .payload = payload,
- .terminator = .init(terminator_ch),
- },
- };
-
- return &parser.command;
-}
diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig
index 5556ba808..cd62a316a 100644
--- a/src/terminal/stream.zig
+++ b/src/terminal/stream.zig
@@ -129,6 +129,7 @@ pub const Action = union(Key) {
color_operation: ColorOperation,
semantic_prompt: SemanticPrompt,
kitty_clipboard: KittyClipboard,
+ kitty_dnd: KittyDnd,
pub const Key = lib.Enum(
lib.target,
@@ -229,6 +230,7 @@ pub const Action = union(Key) {
"color_operation",
"semantic_prompt",
"kitty_clipboard",
+ "kitty_dnd",
},
);
@@ -449,6 +451,8 @@ pub const Action = union(Key) {
pub const SemanticPrompt = osc.Command.SemanticPrompt;
pub const KittyClipboard = osc.Command.KittyClipboardProtocol;
+
+ pub const KittyDnd = osc.Command.KittyDndProtocol;
};
/// Returns a type that can process a stream of tty control characters.
@@ -2561,6 +2565,10 @@ pub fn Stream(comptime H: type) type {
self.handler.vt(.kitty_clipboard, v);
},
+ .kitty_dnd_protocol => |v| {
+ self.handler.vt(.kitty_dnd, v);
+ },
+
.conemu_sleep,
.conemu_show_message_box,
.conemu_change_tab_title,
@@ -2571,7 +2579,6 @@ pub fn Stream(comptime H: type) type {
.conemu_output_environment_variable,
.conemu_run_process,
.kitty_text_sizing,
- .kitty_dnd_protocol,
.kitty_desktop_notification,
.context_signal,
=> {
diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig
index 10f239831..bcf8690bc 100644
--- a/src/terminal/stream_terminal.zig
+++ b/src/terminal/stream_terminal.zig
@@ -1,4 +1,5 @@
const std = @import("std");
+const Allocator = std.mem.Allocator;
const build_options = @import("terminal_options");
const testing = std.testing;
const apc = @import("apc.zig");
@@ -14,7 +15,9 @@ const color = @import("color.zig");
const modes = @import("modes.zig");
const osc = @import("osc.zig");
const osc_color = @import("osc/parsers/color.zig");
+const kitty_clipboard = @import("kitty/clipboard.zig");
const kitty_color = @import("kitty/color.zig");
+const kitty_dnd = @import("kitty/dnd.zig");
const size_report = @import("size_report.zig");
const simd = @import("../simd/main.zig");
const terminfo = @import("../terminfo/main.zig");
@@ -71,6 +74,17 @@ pub const Handler = struct {
/// The DCS command handler maintains state for DCS queries.
dcs_handler: dcs.Handler = .{},
+ /// The in-flight Kitty clipboard protocol (OSC 5522) write
+ /// transaction, if any. Null means no transaction is active.
+ /// Heap-allocated since transactions are rare and short-lived.
+ kitty_clipboard_write: ?*kitty_clipboard.WriteState = null,
+
+ /// Kitty clipboard protocol (OSC 5522) session password grants,
+ /// recorded when a clipboard_read reply asks to remember the user's
+ /// decision. Later requests carrying a granted password are forwarded
+ /// with `granted` set so the embedder can skip its prompt.
+ kitty_clipboard_grants: kitty_clipboard.Grants = .{},
+
/// Called for sequence identifiers not supported by this library.
/// Currently, only APC is reported. Content is borrowed and only valid
/// for the duration of the callback. Set `apc_handler.unknown_max_bytes`
@@ -103,6 +117,15 @@ pub const Handler = struct {
/// valid for the duration of the callback.
desktop_notification: ?*const fn (*Handler, Action.ShowDesktopNotification) void,
+ /// Called when drag and drop protocol state changes in a way the
+ /// embedder may need to act on: the running program registering
+ /// or unregistering to accept drops, answering a drag, or
+ /// concluding a drop. The event says what changed; the details
+ /// are read from `handler.terminal.kitty_dnd` (Kitty's OSC 72 is
+ /// the only drag and drop protocol today). Native drag events
+ /// flow the other way, by calling `kitty.dnd.State` directly.
+ drag_and_drop: ?*const fn (*Handler, kitty_dnd.Event) void,
+
/// Called in response to a color scheme DSR query (CSI ? 996 n).
/// Returns the current color scheme. Return null to silently
/// ignore the query.
@@ -145,21 +168,38 @@ pub const Handler = struct {
/// A write with no contents clears the destination. A content entry
/// with empty data is a distinct empty representation.
///
- /// Clipboard read requests (OSC 52 with a "?" payload) are
- /// delivered to clipboard_read instead.
+ /// OSC 52, OSC 1337 Copy, and Kitty clipboard (OSC 5522) writes all
+ /// share this callback. Every call is one complete write whose
+ /// contents replace whatever the destination previously held; there
+ /// is never a partial update. A Kitty clipboard write transaction
+ /// results in exactly one call, at commit, carrying all of the
+ /// transaction's representations, and the returned result is
+ /// reported back to the running program as the commit status (see
+ /// kittyClipboard).
+ ///
+ /// Clipboard read requests (OSC 52 with a "?" payload and OSC 5522
+ /// reads) are delivered to clipboard_read instead.
clipboard_write: ?*const fn (*Handler, clipboard.Write) clipboard.WriteResult,
/// Called when the running program requests clipboard contents
- /// (OSC 52 with a "?" payload). Answering one lets the program
- /// read the user's clipboard, so the embedder is expected to
- /// mediate consent.
+ /// (OSC 52 with a "?" payload, or a Kitty clipboard (OSC 5522)
+ /// read). Answering one lets the program read the user's
+ /// clipboard, so the embedder is expected to mediate consent.
///
/// Reads are synchronous: the callback must answer through
/// `read.reply` before it returns, so an embedder that needs to
/// ask the user must block (e.g. run a modal prompt) while the
- /// stream waits. Returning without a reply, or replying denied or
- /// unsupported, answers the program with an empty clipboard so it
- /// doesn't hang. If this is null, read requests are ignored.
+ /// stream waits. Returning without a reply, or replying with any
+ /// failure, answers the program with an empty clipboard (OSC 52)
+ /// or the matching protocol status (OSC 5522) so it doesn't hang.
+ /// If this is null, OSC 52 reads are ignored and OSC 5522 reads
+ /// are refused with EPERM.
+ ///
+ /// OSC 5522 requests carry the program's MIME list, name, and
+ /// password grant state; a reply that sets `remember` records a
+ /// session grant so later requests with the same password arrive
+ /// with `granted` set. Kitty itself serves a request for only the
+ /// targets listing (`list` with no `mimes`) without prompting.
clipboard_read: ?*const fn (*Handler, clipboard.Read) void,
/// Called in response to an XTVERSION query. Returns the version
@@ -178,6 +218,7 @@ pub const Handler = struct {
.color_scheme = null,
.desktop_notification = null,
.device_attributes = null,
+ .drag_and_drop = null,
.enquiry = null,
.progress_report = null,
.size = null,
@@ -207,6 +248,8 @@ pub const Handler = struct {
}
pub fn deinit(self: *Handler) void {
+ self.kittyClipboardAbort();
+ self.kitty_clipboard_grants.deinit(self.terminal.gpa());
self.apc_handler.deinit();
self.dcs_handler.deinit();
}
@@ -374,6 +417,11 @@ pub const Handler = struct {
.kitty_color_report => self.kittyColorOperation(value) catch |err| {
log.warn("error reporting Kitty colors err={}", .{err});
},
+ .kitty_clipboard => self.kittyClipboard(value) catch |err| {
+ // Clipboard operations are external effects, not terminal
+ // state; a failed transaction was already answered.
+ log.warn("error handling kitty clipboard err={}", .{err});
+ },
// APC
.apc_start => self.apc_handler.start(),
@@ -403,6 +451,12 @@ pub const Handler = struct {
// Clipboard operations are external effects, not terminal state.
log.warn("error handling clipboard operation err={}", .{err});
},
+ .kitty_dnd => self.kittyDnd(value) catch |err| {
+ // Drag and drop is a self-contained subsystem: an OOM
+ // updating its state or a failure writing a response
+ // degrades it without corrupting terminal state, so we log.
+ log.warn("error handling kitty dnd err={}", .{err});
+ },
.dcs_hook => try self.dcsHook(value),
.dcs_put => try self.dcsPut(value),
@@ -411,8 +465,6 @@ pub const Handler = struct {
// Have no terminal-modifying effect
.title_push,
.title_pop,
- // Unimplemented; the sequence is consumed and ignored.
- .kitty_clipboard,
=> {},
}
}
@@ -655,6 +707,431 @@ pub const Handler = struct {
}
};
+ /// Handle one Kitty clipboard protocol (OSC 5522) packet.
+ fn kittyClipboard(
+ self: *Handler,
+ v: Action.Value(.kitty_clipboard),
+ ) error{OutOfMemory}!void {
+ // Decode and validate the metadata.
+ var arena: std.heap.ArenaAllocator = .init(self.terminal.gpa());
+ defer arena.deinit();
+ const meta = (try kitty_clipboard.Metadata.parse(
+ arena.allocator(),
+ v.metadata,
+ )) orelse return;
+
+ const payload = v.payload orelse "";
+ switch (meta.op) {
+ .read => try self.kittyClipboardRead(&meta, payload, v.terminator),
+ .write => try self.kittyClipboardWriteBegin(&meta, v.terminator),
+ .wdata => try self.kittyClipboardData(&meta, payload, v.terminator),
+ .walias => try self.kittyClipboardAlias(&meta, payload, v.terminator),
+ }
+ }
+
+ fn kittyClipboardRead(
+ self: *Handler,
+ meta: *const kitty_clipboard.Metadata,
+ payload: []const u8,
+ terminator: osc.Terminator,
+ ) error{OutOfMemory}!void {
+ // The payload is the requested MIME list. Kitty drops a read
+ // request with an undecodable payload without any response.
+ const alloc = self.terminal.gpa();
+ const decoded = kitty_clipboard.Payload.init(
+ alloc,
+ payload,
+ ) catch |err| switch (err) {
+ error.OutOfMemory => return error.OutOfMemory,
+ error.Invalid => return,
+ };
+ defer decoded.deinit(alloc);
+
+ // Without a clipboard_read effect nothing can serve the read.
+ // EPERM is the protocol's denial so clients degrade gracefully.
+ const func = self.effects.clipboard_read orelse {
+ self.kittyClipboardRespond(&.{
+ .op = .read,
+ .status = .EPERM,
+ .id = meta.id,
+ .terminator = terminator,
+ });
+ return;
+ };
+
+ // The targets type ('.') asks for the listing of available
+ // types rather than data. Requested types beyond the cap are
+ // dropped and simply never served, which is how the protocol
+ // reports an unavailable type anyway.
+ var mimes_buf: [kitty_clipboard.max_read_mimes][]const u8 = undefined;
+ const mimes, const list = mimes: {
+ var targets = false;
+ var len: usize = 0;
+ var it = decoded.mimeIterator();
+ while (it.next()) |mime| {
+ if (std.mem.eql(u8, mime, kitty_clipboard.targets_mime)) {
+ targets = true;
+ continue;
+ }
+ if (len == mimes_buf.len) continue;
+ mimes_buf[len] = mime;
+ len += 1;
+ }
+ break :mimes .{ mimes_buf[0..len], targets };
+ };
+
+ // Per the spec a password without a name is no password. A
+ // stored grant for it lets the embedder skip its prompt.
+ const pw: []const u8 = if (meta.name.len > 0) meta.pw else "";
+ const granted = self.kitty_clipboard_grants.use(alloc, pw, .read);
+
+ var state: KittyClipboardReadState = .{
+ .handler = self,
+ .primary = meta.loc == .primary,
+ .id = meta.id,
+ .pw = pw,
+ .mimes = mimes,
+ .list = list,
+ .terminator = terminator,
+ };
+ func(self, .{
+ .location = meta.loc,
+ .mimes = mimes,
+ .list = list,
+ .name = meta.name,
+ .granted = granted,
+ .can_remember = pw.len > 0,
+ .reply_ctx = &state,
+ .reply_fn = &KittyClipboardReadState.reply,
+ });
+
+ // The program is waiting on us, so a callback that returned
+ // without a reply is answered as a denial rather than silence.
+ if (!state.replied) state.respondStatus(.EPERM);
+ }
+
+ /// Reply state for one synchronous Kitty clipboard read. This lives
+ /// on the kittyClipboardRead stack frame, so it is only valid during
+ /// the callback.
+ const KittyClipboardReadState = struct {
+ handler: *Handler,
+ primary: bool,
+ id: []const u8,
+
+ /// The effective password, empty when the request had none.
+ pw: []const u8,
+
+ /// The requested types; only these are served from a reply.
+ mimes: []const []const u8,
+ list: bool,
+ terminator: osc.Terminator,
+ replied: bool = false,
+
+ fn reply(ctx: *anyopaque, result: clipboard.Read.Result) void {
+ const self: *KittyClipboardReadState = @ptrCast(@alignCast(ctx));
+ if (self.replied) {
+ log.warn("clipboard read replied more than once, ignoring", .{});
+ return;
+ }
+ self.replied = true;
+
+ const success = switch (result) {
+ .denied => return self.respondStatus(.EPERM),
+ .unsupported => return self.respondStatus(.ENOSYS),
+ .busy => return self.respondStatus(.EBUSY),
+ .io_error => return self.respondStatus(.EIO),
+ .success => |s| s,
+ };
+
+ // Remembering is only offered when the request carried a
+ // usable password.
+ if (success.remember and self.pw.len > 0) {
+ self.handler.kitty_clipboard_grants.grant(
+ self.handler.terminal.gpa(),
+ self.pw,
+ .read,
+ false,
+ ) catch |err| {
+ log.warn("error recording clipboard grant err={}", .{err});
+ };
+ }
+
+ self.respondSuccess(&success) catch |err| {
+ log.warn("error replying to clipboard read err={}", .{err});
+ self.respondStatus(.EIO);
+ };
+ }
+
+ /// Answer with a single status packet.
+ fn respondStatus(
+ self: *const KittyClipboardReadState,
+ status: kitty_clipboard.Status,
+ ) void {
+ self.handler.kittyClipboardRespond(&.{
+ .op = .read,
+ .status = status,
+ .id = self.id,
+ .terminator = self.terminator,
+ });
+ }
+
+ /// Answer with the full success sequence (OK, listing, DATA
+ /// chunks, DONE), serving only the requested representations
+ /// in request order.
+ fn respondSuccess(
+ self: *const KittyClipboardReadState,
+ success: *const clipboard.Read.Result.Success,
+ ) error{ OutOfMemory, WriteFailed }!void {
+ const handler = self.handler;
+ if (handler.effects.write_pty == null) return;
+
+ var served_buf: [kitty_clipboard.max_read_mimes]clipboard.Content = undefined;
+ var served_len: usize = 0;
+ for (self.mimes) |mime| {
+ for (success.contents) |content| {
+ if (!std.mem.eql(u8, content.mime, mime)) continue;
+ served_buf[served_len] = content;
+ served_len += 1;
+ break;
+ }
+ }
+
+ // Status packets fit on the stack; DATA packets carry the
+ // clipboard contents and fall back to the heap.
+ var stack = std.heap.stackFallback(1024, handler.terminal.gpa());
+ const alloc = stack.get();
+ var aw: std.Io.Writer.Allocating = .init(alloc);
+ defer aw.deinit();
+ try (kitty_clipboard.ReadSuccess{
+ .primary = self.primary,
+ .id = self.id,
+ .list = self.list,
+ .available = success.available,
+ .contents = served_buf[0..served_len],
+ .terminator = self.terminator,
+ }).encode(&aw.writer);
+
+ const written = try aw.toOwnedSliceSentinel(0);
+ defer alloc.free(written);
+ handler.writePty(written);
+ }
+ };
+
+ fn kittyClipboardWriteBegin(
+ self: *Handler,
+ meta: *const kitty_clipboard.Metadata,
+ terminator: osc.Terminator,
+ ) error{OutOfMemory}!void {
+ // A new write silently replaces any in-flight transaction.
+ self.kittyClipboardAbort();
+
+ // Without a clipboard_write effect a commit can never succeed,
+ // so fail the transaction up front instead of spooling data
+ // we'd only throw away. Later wdata packets are ignored.
+ if (self.effects.clipboard_write == null) {
+ self.kittyClipboardRespond(&.{
+ .op = .write,
+ .status = .ENOSYS,
+ .id = meta.id,
+ .terminator = terminator,
+ });
+ return;
+ }
+
+ // Setup our write state
+ const alloc = self.terminal.gpa();
+ const state = try alloc.create(kitty_clipboard.WriteState);
+ errdefer alloc.destroy(state);
+ state.* = try .init(alloc, meta);
+ self.kitty_clipboard_write = state;
+ }
+
+ fn kittyClipboardData(
+ self: *Handler,
+ meta: *const kitty_clipboard.Metadata,
+ payload: []const u8,
+ terminator: osc.Terminator,
+ ) error{OutOfMemory}!void {
+ // Data without a transaction is silently ignored.
+ const state = self.kitty_clipboard_write orelse return;
+
+ // A wdata packet without a MIME type commits the transaction.
+ if (meta.mime.len == 0) return self.kittyClipboardCommit(
+ state,
+ terminator,
+ );
+
+ state.data(
+ self.terminal.gpa(),
+ meta,
+ payload,
+ ) catch |err| switch (err) {
+ // Failing to spool matches kitty's EIO for a failed buffer
+ // write.
+ error.OutOfMemory => {
+ self.kittyClipboardFinish(
+ state,
+ .EIO,
+ terminator,
+ );
+ return error.OutOfMemory;
+ },
+ };
+ }
+
+ fn kittyClipboardAlias(
+ self: *Handler,
+ meta: *const kitty_clipboard.Metadata,
+ payload: []const u8,
+ terminator: osc.Terminator,
+ ) error{OutOfMemory}!void {
+ // Aliases without a transaction or without a target MIME type
+ // are silently ignored.
+ const state = self.kitty_clipboard_write orelse return;
+ if (meta.mime.len == 0) return;
+
+ state.alias(
+ self.terminal.gpa(),
+ meta,
+ payload,
+ ) catch |err| switch (err) {
+ error.OutOfMemory => {
+ self.kittyClipboardFinish(
+ state,
+ .EIO,
+ terminator,
+ );
+ return error.OutOfMemory;
+ },
+
+ // An undecodable alias payload aborts the transaction.
+ error.Invalid => self.kittyClipboardFinish(
+ state,
+ .EINVAL,
+ terminator,
+ ),
+ };
+ }
+
+ fn kittyClipboardCommit(
+ self: *Handler,
+ state: *kitty_clipboard.WriteState,
+ terminator: osc.Terminator,
+ ) error{OutOfMemory}!void {
+ const alloc = self.terminal.gpa();
+ const committed = state.commit(alloc) catch |err| switch (err) {
+ error.OutOfMemory => {
+ self.kittyClipboardFinish(state, .EIO, terminator);
+ return error.OutOfMemory;
+ },
+ };
+ defer committed.deinit(alloc);
+
+ // The effect result maps 1:1 onto the protocol's commit
+ // statuses. The effect can't be null here (checked when the
+ // transaction began) but if an embedder cleared it
+ // mid-transaction that's ENOSYS.
+ const result: clipboard.WriteResult = if (self.effects.clipboard_write) |func|
+ func(self, .{
+ .location = committed.loc,
+ .contents = committed.contents,
+ })
+ else
+ .unsupported;
+
+ self.kittyClipboardFinish(state, switch (result) {
+ .success => .DONE,
+ .denied => .EPERM,
+ .unsupported => .ENOSYS,
+ .busy => .EBUSY,
+ .invalid_data => .EINVAL,
+ .io_error, _ => .EIO,
+ }, terminator);
+ }
+
+ /// Answer a write transaction with its final status and drop it.
+ /// The id echoed is the one from the transaction's opening write
+ /// packet, matching kitty.
+ fn kittyClipboardFinish(
+ self: *Handler,
+ state: *const kitty_clipboard.WriteState,
+ status: kitty_clipboard.Status,
+ terminator: osc.Terminator,
+ ) void {
+ self.kittyClipboardRespond(&.{
+ .op = .write,
+ .status = status,
+ .id = state.id,
+ .terminator = terminator,
+ });
+ self.kittyClipboardAbort();
+ }
+
+ /// Drop any in-flight write transaction without responding.
+ fn kittyClipboardAbort(self: *Handler) void {
+ if (self.kitty_clipboard_write) |state| {
+ const alloc = self.terminal.gpa();
+ state.deinit(alloc);
+ alloc.destroy(state);
+ self.kitty_clipboard_write = null;
+ }
+ }
+
+ /// Encode and write a single response packet. Unlike kitty, which
+ /// always terminates responses with ST, we echo the terminator of
+ /// the request being answered, matching our other OSC responses.
+ fn kittyClipboardRespond(
+ self: *Handler,
+ response: *const kitty_clipboard.Response,
+ ) void {
+ if (self.effects.write_pty == null) return;
+
+ // Our responses carry at most a status and the echoed id so
+ // they virtually always fit on the stack.
+ var stack = std.heap.stackFallback(1024, self.terminal.gpa());
+ const alloc = stack.get();
+ var aw: std.Io.Writer.Allocating = .init(alloc);
+ defer aw.deinit();
+ response.encode(&aw.writer) catch return;
+ const resp = aw.toOwnedSliceSentinel(0) catch return;
+ defer alloc.free(resp);
+ self.writePty(resp);
+ }
+
+ /// Handle an OSC 72 drag and drop command.
+ fn kittyDnd(
+ self: *Handler,
+ v: Action.KittyDnd,
+ ) (Allocator.Error || std.Io.Writer.Error)!void {
+ // Responses are usually small (queries, errors) but data
+ // serving can produce many chunks, so fall back to the heap.
+ var stack = std.heap.stackFallback(512, self.terminal.gpa());
+ const response_alloc = stack.get();
+ var aw: std.Io.Writer.Allocating = .init(response_alloc);
+ defer aw.deinit();
+
+ // The state is allocated on registration and owned by the
+ // terminal, so it uses the terminal's allocator, not the
+ // response's.
+ const event = try kitty_dnd.handleCommand(
+ &self.terminal.kitty_dnd,
+ self.terminal.gpa(),
+ &aw.writer,
+ v,
+ );
+
+ if (aw.written().len > 0) {
+ const written = aw.toOwnedSliceSentinel(0) catch return;
+ defer response_alloc.free(written);
+ self.writePty(written);
+ }
+
+ if (event) |ev| {
+ const func = self.effects.drag_and_drop orelse return;
+ func(self, ev);
+ }
+ }
+
fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void {
const func = self.effects.device_attributes orelse return;
const attrs = func(self);
@@ -2895,6 +3372,663 @@ test "clipboard_write allocation failure is ignored" {
try testing.expect(!s.handler.semantic_failure);
}
+/// Shared capture state for the Kitty clipboard (OSC 5522) tests below:
+/// records every pty response and the most recent clipboard write.
+const KittyClipboardCapture = struct {
+ var responses: [1024]u8 = undefined;
+ var responses_len: usize = 0;
+ var write_count: usize = 0;
+ var result: clipboard.WriteResult = .success;
+ var last_location: clipboard.Location = .standard;
+ var last_contents_len: usize = 0;
+ var last_mimes: [8][64]u8 = undefined;
+ var last_mime_lens: [8]usize = @splat(0);
+ var last_data: [8][256]u8 = undefined;
+ var last_data_lens: [8]usize = @splat(0);
+
+ // Read capture. A null read_result returns without replying.
+ var read_count: usize = 0;
+ var read_result: ?clipboard.Read.Result = null;
+ var read_reply_twice: bool = false;
+ var last_read_location: clipboard.Location = .standard;
+ var last_read_mimes: [8][64]u8 = undefined;
+ var last_read_mime_lens: [8]usize = @splat(0);
+ var last_read_mimes_len: usize = 0;
+ var last_read_list: bool = false;
+ var last_read_name: [64]u8 = undefined;
+ var last_read_name_len: usize = 0;
+ var last_read_granted: bool = false;
+ var last_read_can_remember: bool = false;
+
+ fn reset() void {
+ responses_len = 0;
+ write_count = 0;
+ result = .success;
+ last_location = .standard;
+ last_contents_len = 0;
+ last_mime_lens = @splat(0);
+ last_data_lens = @splat(0);
+ read_count = 0;
+ read_result = null;
+ read_reply_twice = false;
+ last_read_location = .standard;
+ last_read_mime_lens = @splat(0);
+ last_read_mimes_len = 0;
+ last_read_list = false;
+ last_read_name_len = 0;
+ last_read_granted = false;
+ last_read_can_remember = false;
+ }
+
+ fn writePty(_: *Handler, data: [:0]const u8) void {
+ @memcpy(responses[responses_len..][0..data.len], data);
+ responses_len += data.len;
+ }
+
+ fn clipboardWrite(_: *Handler, write: clipboard.Write) clipboard.WriteResult {
+ write_count += 1;
+ last_location = write.location;
+ last_contents_len = write.contents.len;
+ for (write.contents[0..@min(write.contents.len, last_mimes.len)], 0..) |content, i| {
+ last_mime_lens[i] = content.mime.len;
+ @memcpy(last_mimes[i][0..content.mime.len], content.mime);
+ last_data_lens[i] = content.data.len;
+ @memcpy(last_data[i][0..content.data.len], content.data);
+ }
+ return result;
+ }
+
+ fn clipboardRead(_: *Handler, read: clipboard.Read) void {
+ read_count += 1;
+ last_read_location = read.location;
+ last_read_mimes_len = read.mimes.len;
+ for (read.mimes[0..@min(read.mimes.len, last_read_mimes.len)], 0..) |mime, i| {
+ last_read_mime_lens[i] = mime.len;
+ @memcpy(last_read_mimes[i][0..mime.len], mime);
+ }
+ last_read_list = read.list;
+ last_read_name_len = read.name.len;
+ @memcpy(last_read_name[0..read.name.len], read.name);
+ last_read_granted = read.granted;
+ last_read_can_remember = read.can_remember;
+ if (read_result) |r| read.reply(r);
+ if (read_reply_twice) read.reply(.denied);
+ }
+
+ fn responseSlice() []const u8 {
+ return responses[0..responses_len];
+ }
+
+ fn readMimeAt(i: usize) []const u8 {
+ return last_read_mimes[i][0..last_read_mime_lens[i]];
+ }
+
+ fn readName() []const u8 {
+ return last_read_name[0..last_read_name_len];
+ }
+
+ fn mimeAt(i: usize) []const u8 {
+ return last_mimes[i][0..last_mime_lens[i]];
+ }
+
+ fn dataAt(i: usize) []const u8 {
+ return last_data[i][0..last_data_lens[i]];
+ }
+};
+
+test "kitty clipboard write transaction round trip" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // Begin a write, stream two MIME types (one chunked), alias the
+ // plain text, and commit. Only the commit produces a response.
+ s.nextSlice("\x1B]5522;type=write:id=42\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); // "Ghost"
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;dHk=\x1B\\"); // "ty"
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\"); // "hi"
+ // Alias "TEXT UTF8_STRING" -> text/plain.
+ s.nextSlice("\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;VEVYVCBVVEY4X1NUUklORw==\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.write_count);
+ try testing.expectEqual(@as(usize, 0), S.responses_len);
+
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+ try testing.expectEqual(clipboard.Location.standard, S.last_location);
+ try testing.expectEqual(@as(usize, 4), S.last_contents_len);
+ try testing.expectEqualStrings("text/plain", S.mimeAt(0));
+ try testing.expectEqualStrings("Ghostty", S.dataAt(0));
+ try testing.expectEqualStrings("text/html", S.mimeAt(1));
+ try testing.expectEqualStrings("hi", S.dataAt(1));
+ try testing.expectEqualStrings("TEXT", S.mimeAt(2));
+ try testing.expectEqualStrings("Ghostty", S.dataAt(2));
+ try testing.expectEqualStrings("UTF8_STRING", S.mimeAt(3));
+ try testing.expectEqualStrings("Ghostty", S.dataAt(3));
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=DONE:id=42\x1B\\",
+ S.responseSlice(),
+ );
+
+ // A commit with no transaction in flight is silently ignored.
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+}
+
+test "kitty clipboard write result maps to response status" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ const cases = [_]struct {
+ result: clipboard.WriteResult,
+ response: []const u8,
+ }{
+ .{ .result = .success, .response = "\x1B]5522;type=write:status=DONE\x1B\\" },
+ .{ .result = .denied, .response = "\x1B]5522;type=write:status=EPERM\x1B\\" },
+ .{ .result = .unsupported, .response = "\x1B]5522;type=write:status=ENOSYS\x1B\\" },
+ .{ .result = .busy, .response = "\x1B]5522;type=write:status=EBUSY\x1B\\" },
+ .{ .result = .invalid_data, .response = "\x1B]5522;type=write:status=EINVAL\x1B\\" },
+ .{ .result = .io_error, .response = "\x1B]5522;type=write:status=EIO\x1B\\" },
+ };
+
+ for (cases) |case| {
+ S.reset();
+ S.result = case.result;
+
+ // An immediately-committed write with no data is a clear.
+ s.nextSlice("\x1B]5522;type=write\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+ try testing.expectEqual(@as(usize, 0), S.last_contents_len);
+ try testing.expectEqualStrings(case.response, S.responseSlice());
+ }
+
+ // The response echoes the request terminator, unlike kitty which
+ // always uses ST.
+ S.reset();
+ s.nextSlice("\x1B]5522;type=write:loc=primary\x07");
+ s.nextSlice("\x1B]5522;type=wdata\x07");
+ try testing.expectEqual(clipboard.Location.primary, S.last_location);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=DONE\x07",
+ S.responseSlice(),
+ );
+}
+
+test "kitty clipboard write without clipboard effect responds ENOSYS" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // The transaction fails as soon as it begins; the rest of it is
+ // ignored without further responses.
+ s.nextSlice("\x1B]5522;type=write:id=x\x1B\\");
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=ENOSYS:id=x\x1B\\",
+ S.responseSlice(),
+ );
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=ENOSYS:id=x\x1B\\",
+ S.responseSlice(),
+ );
+}
+
+test "kitty clipboard read without effect is denied with EPERM" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // The denial never includes loc (only OK responses do) and echoes
+ // the sanitized id.
+ s.nextSlice("\x1B]5522;type=read:loc=primary:id=*4 2*;dGV4dC9wbGFpbg==\x1B\\");
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=EPERM:id=42\x1B\\",
+ S.responseSlice(),
+ );
+
+ // A missing payload is an empty MIME list, still answered.
+ S.reset();
+ s.nextSlice("\x1B]5522;type=read\x07");
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=EPERM\x07",
+ S.responseSlice(),
+ );
+
+ // An undecodable payload is dropped without a response.
+ S.reset();
+ s.nextSlice("\x1B]5522;type=read;!!!\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.responses_len);
+}
+
+test "kitty clipboard read round trip" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_read = &S.clipboardRead;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ S.read_result = .{
+ .success = .{
+ .contents = &.{
+ // Unrequested representations are never served, and the
+ // served ones follow request order, not reply order.
+ .{ .mime = "image/png", .data = "\x89PNG" },
+ .{ .mime = "text/html", .data = "hi" },
+ .{ .mime = "text/plain", .data = "Ghostty" },
+ },
+ .available = &.{ "text/plain", "text/html" },
+ },
+ };
+
+ // Request the targets listing plus two types from the primary
+ // selection: ". text/plain text/html".
+ s.nextSlice("\x1B]5522;type=read:loc=primary:id=r1;LiB0ZXh0L3BsYWluIHRleHQvaHRtbA==\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.read_count);
+ try testing.expectEqual(clipboard.Location.primary, S.last_read_location);
+ try testing.expectEqual(@as(usize, 2), S.last_read_mimes_len);
+ try testing.expectEqualStrings("text/plain", S.readMimeAt(0));
+ try testing.expectEqualStrings("text/html", S.readMimeAt(1));
+ try testing.expect(S.last_read_list);
+ try testing.expectEqualStrings("", S.readName());
+ try testing.expect(!S.last_read_granted);
+ try testing.expect(!S.last_read_can_remember);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=OK:loc=primary:id=r1\x1B\\" ++
+ "\x1B]5522;type=read:status=DATA:id=r1:mime=Lg==;dGV4dC9wbGFpbiB0ZXh0L2h0bWwK\x1B\\" ++
+ "\x1B]5522;type=read:status=DATA:id=r1:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x1B\\" ++
+ "\x1B]5522;type=read:status=DATA:id=r1:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\" ++
+ "\x1B]5522;type=read:status=DONE:id=r1\x1B\\",
+ S.responseSlice(),
+ );
+
+ // Without the listing request `available` is ignored. The response
+ // echoes the request terminator.
+ S.responses_len = 0;
+ s.nextSlice("\x1B]5522;type=read:id=r2;dGV4dC9wbGFpbg==\x07");
+ try testing.expectEqual(clipboard.Location.standard, S.last_read_location);
+ try testing.expectEqual(@as(usize, 1), S.last_read_mimes_len);
+ try testing.expect(!S.last_read_list);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=OK:id=r2\x07" ++
+ "\x1B]5522;type=read:status=DATA:id=r2:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x07" ++
+ "\x1B]5522;type=read:status=DONE:id=r2\x07",
+ S.responseSlice(),
+ );
+
+ // A listing-only request carries no types.
+ S.responses_len = 0;
+ s.nextSlice("\x1B]5522;type=read;Lg==\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.last_read_mimes_len);
+ try testing.expect(S.last_read_list);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=OK\x1B\\" ++
+ "\x1B]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbiB0ZXh0L2h0bWwK\x1B\\" ++
+ "\x1B]5522;type=read:status=DONE\x1B\\",
+ S.responseSlice(),
+ );
+}
+
+test "kitty clipboard read result maps to response status" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_read = &S.clipboardRead;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ const cases = [_]struct {
+ result: ?clipboard.Read.Result,
+ response: []const u8,
+ }{
+ .{ .result = .denied, .response = "\x1B]5522;type=read:status=EPERM:id=x\x1B\\" },
+ .{ .result = .unsupported, .response = "\x1B]5522;type=read:status=ENOSYS:id=x\x1B\\" },
+ .{ .result = .busy, .response = "\x1B]5522;type=read:status=EBUSY:id=x\x1B\\" },
+ .{ .result = .io_error, .response = "\x1B]5522;type=read:status=EIO:id=x\x1B\\" },
+ // No reply at all is a denial rather than silence.
+ .{ .result = null, .response = "\x1B]5522;type=read:status=EPERM:id=x\x1B\\" },
+ // A success with nothing to serve is still OK then DONE.
+ .{ .result = .{ .success = .{} }, .response = "\x1B]5522;type=read:status=OK:id=x\x1B\\" ++
+ "\x1B]5522;type=read:status=DONE:id=x\x1B\\" },
+ };
+
+ for (cases) |case| {
+ S.reset();
+ S.read_result = case.result;
+ s.nextSlice("\x1B]5522;type=read:id=x;dGV4dC9wbGFpbg==\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.read_count);
+ try testing.expectEqualStrings(case.response, S.responseSlice());
+ }
+
+ // A second reply is ignored.
+ S.reset();
+ S.read_result = .{ .success = .{ .contents = &.{.{ .mime = "text/plain", .data = "hello" }} } };
+ S.read_reply_twice = true;
+ s.nextSlice("\x1B]5522;type=read;dGV4dC9wbGFpbg==\x1B\\");
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=OK\x1B\\" ++
+ "\x1B]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1B\\" ++
+ "\x1B]5522;type=read:status=DONE\x1B\\",
+ S.responseSlice(),
+ );
+}
+
+test "kitty clipboard read caps requested types" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_read = &S.clipboardRead;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // "a/0 a/1 a/2 a/3 a/4 a/5 .": extras are dropped but the listing
+ // request after them still counts.
+ S.read_result = .{ .success = .{} };
+ s.nextSlice("\x1B]5522;type=read;YS8wIGEvMSBhLzIgYS8zIGEvNCBhLzUgLg==\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.read_count);
+ try testing.expectEqual(kitty_clipboard.max_read_mimes, S.last_read_mimes_len);
+ try testing.expectEqualStrings("a/0", S.readMimeAt(0));
+ try testing.expectEqualStrings("a/3", S.readMimeAt(kitty_clipboard.max_read_mimes - 1));
+ try testing.expect(S.last_read_list);
+}
+
+test "kitty clipboard read password grants" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_read = &S.clipboardRead;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // pw="secret", name="app": the first request isn't granted but the
+ // reply may ask to remember it.
+ S.read_result = .{ .success = .{ .remember = true } };
+ s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.read_count);
+ try testing.expectEqualStrings("app", S.readName());
+ try testing.expect(!S.last_read_granted);
+ try testing.expect(S.last_read_can_remember);
+
+ // The same password is now granted; a different one is not.
+ S.read_result = .{ .success = .{} };
+ s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\");
+ try testing.expect(S.last_read_granted);
+ s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw\x1B\\");
+ try testing.expect(!S.last_read_granted);
+ try testing.expect(S.last_read_can_remember);
+
+ // A password without a name doesn't count: it is neither granted
+ // nor rememberable, even if the reply asks.
+ S.read_result = .{ .success = .{ .remember = true } };
+ s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0\x1B\\");
+ try testing.expectEqualStrings("", S.readName());
+ try testing.expect(!S.last_read_granted);
+ try testing.expect(!S.last_read_can_remember);
+ s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=\x1B\\");
+ try testing.expect(!S.last_read_can_remember);
+ S.read_result = .{ .success = .{} };
+ s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw\x1B\\");
+ try testing.expect(!S.last_read_granted);
+
+ // A grant is advisory: the request is still forwarded and the
+ // embedder may deny it.
+ S.responses_len = 0;
+ S.read_result = .denied;
+ s.nextSlice("\x1B]5522;type=read:id=d:pw=c2VjcmV0:name=YXBw\x1B\\");
+ try testing.expect(S.last_read_granted);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=EPERM:id=d\x1B\\",
+ S.responseSlice(),
+ );
+
+ // Grants are freed with the stream (the testing allocator catches
+ // the leak otherwise).
+}
+
+test "kitty clipboard malformed packets are silently dropped" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // Missing type, unknown type, bare metadata record, invalid mime
+ // base64, and orphaned transaction packets all drop silently.
+ s.nextSlice("\x1B]5522;loc=primary\x1B\\");
+ s.nextSlice("\x1B]5522;type=bobr\x1B\\");
+ s.nextSlice("\x1B]5522;type=read:bare\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=!!!;R2hvc3Q=\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
+ s.nextSlice("\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;VEVYVA==\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.write_count);
+ try testing.expectEqual(@as(usize, 0), S.responses_len);
+ try testing.expect(!s.handler.semantic_failure);
+
+ // The terminal is still functional afterwards.
+ s.nextSlice("ok");
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("ok", str);
+}
+
+test "kitty clipboard new write replaces in-flight transaction" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ s.nextSlice("\x1B]5522;type=write:id=old\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;b2xk\x1B\\"); // "old"
+ s.nextSlice("\x1B]5522;type=write:id=new\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;bmV3\x1B\\"); // "new"
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+ try testing.expectEqual(@as(usize, 1), S.last_contents_len);
+ try testing.expectEqualStrings("new", S.dataAt(0));
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=DONE:id=new\x1B\\",
+ S.responseSlice(),
+ );
+}
+
+test "kitty clipboard invalid walias payload aborts with EINVAL" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ s.nextSlice("\x1B]5522;type=write:id=w\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
+ s.nextSlice("\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;!!!\x1B\\");
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=EINVAL:id=w\x1B\\",
+ S.responseSlice(),
+ );
+ try testing.expect(!s.handler.semantic_failure);
+
+ // The transaction is gone: a commit does nothing further.
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.write_count);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=EINVAL:id=w\x1B\\",
+ S.responseSlice(),
+ );
+}
+
+test "kitty clipboard invalid wdata chunk is skipped" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ s.nextSlice("\x1B]5522;type=write\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;SGVsbG8=\x1B\\"); // "Hello"
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;!!!bad!!!\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;V29ybGQ=\x1B\\"); // "World"
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+ try testing.expectEqualStrings("HelloWorld", S.dataAt(0));
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=DONE\x1B\\",
+ S.responseSlice(),
+ );
+}
+
+test "kitty clipboard in-flight transaction is freed on deinit" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // Never committed: stream deinit must free the transaction (the
+ // testing allocator catches the leak otherwise).
+ s.nextSlice("\x1B]5522;type=write\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.write_count);
+}
+
+test "kitty clipboard allocation failure is ignored" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // Only transaction state uses the terminal allocator here. Swap in
+ // an allocator that always fails, then restore it before teardown.
+ {
+ const alloc = t.screens.active.alloc;
+ t.screens.active.alloc = testing.failing_allocator;
+ defer t.screens.active.alloc = alloc;
+ s.nextSlice("\x1B]5522;type=write\x1B\\");
+ }
+
+ // Clipboard writes are external effects, best-effort like OSC 52;
+ // the failed transaction never started and is not a semantic
+ // failure.
+ try testing.expect(!s.handler.semantic_failure);
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.write_count);
+ try testing.expectEqual(@as(usize, 0), S.responses_len);
+}
+
+test "kitty clipboard without write_pty still commits writes" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ s.nextSlice("\x1B]5522;type=write\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+ try testing.expectEqualStrings("Ghost", S.dataAt(0));
+
+ // Reads are dropped without a way to respond.
+ s.nextSlice("\x1B]5522;type=read\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.responses_len);
+}
+
test "request mode DECRQM with write_pty callback" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
@@ -4059,3 +5193,204 @@ test "continuation reconstructs standard stream without duplicate effects" {
restored_terminal.screens.active.cursor.style_id,
);
}
+
+test "kitty dnd: query response" {
+ const S = struct {
+ var pty: std.ArrayListUnmanaged(u8) = .empty;
+ fn writePty(_: *Handler, data: [:0]const u8) void {
+ pty.appendSlice(testing.allocator, data) catch unreachable;
+ }
+ };
+ S.pty = .empty;
+ defer S.pty.deinit(testing.allocator);
+
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ s.nextSlice("\x1B]72;t=q:i=3\x1B\\");
+ try testing.expectEqualStrings("\x1b]72;t=q:i=3\x1b\\", S.pty.items);
+}
+
+test "kitty dnd: register, drop, and serve data" {
+ const S = struct {
+ var pty: std.ArrayListUnmanaged(u8) = .empty;
+ fn writePty(_: *Handler, data: [:0]const u8) void {
+ pty.appendSlice(testing.allocator, data) catch unreachable;
+ }
+ };
+ S.pty = .empty;
+ defer S.pty.deinit(testing.allocator);
+
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // Client registers to accept drops.
+ s.nextSlice("\x1B]72;t=a;text/plain text/uri-list\x1B\\");
+ try testing.expectEqualStrings("", S.pty.items);
+ try testing.expect(t.kitty_dnd != null);
+
+ // A native drop arrives; the embedder feeds it to the terminal
+ // state and delivers the produced event bytes itself.
+ {
+ var aw: std.Io.Writer.Allocating = .init(testing.allocator);
+ defer aw.deinit();
+ try t.kitty_dnd.?.dragDrop(testing.allocator, &aw.writer, .{
+ .cell_x = 2,
+ .cell_y = 1,
+ .pixel_x = 20,
+ .pixel_y = 18,
+ .operations = .{ .copy = true },
+ }, &.{
+ .{ .mime = "text/plain", .data = "hello" },
+ });
+ try testing.expectEqualStrings(
+ "\x1b]72;t=M:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\",
+ aw.written(),
+ );
+ }
+
+ // The client requests the data and concludes.
+ s.nextSlice("\x1B]72;t=r:x=1\x1B\\");
+ try testing.expectEqualStrings(
+ "\x1b]72;t=r:x=1:m=0;aGVsbG8=\x1b\\" ++ "\x1b]72;t=r:x=1\x1b\\",
+ S.pty.items,
+ );
+ S.pty.clearRetainingCapacity();
+
+ s.nextSlice("\x1B]72;t=r\x1B\\");
+ try testing.expectEqualStrings("", S.pty.items);
+ try testing.expect(t.kitty_dnd.?.drop.items == null);
+}
+
+test "kitty dnd: state updates work without write_pty effect" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) });
+ defer s.deinit();
+
+ // Queries produce no output (nowhere to write) but registration
+ // state still updates.
+ s.nextSlice("\x1B]72;t=q\x1B\\");
+ s.nextSlice("\x1B]72;t=a\x1B\\");
+ try testing.expect(t.kitty_dnd != null);
+
+ // The terminal remains functional.
+ s.nextSlice("ok");
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("ok", str);
+}
+
+test "kitty dnd: registration survives terminal reset" {
+ const S = struct {
+ var pty: std.ArrayListUnmanaged(u8) = .empty;
+ fn writePty(_: *Handler, data: [:0]const u8) void {
+ pty.appendSlice(testing.allocator, data) catch unreachable;
+ }
+ };
+ S.pty = .empty;
+ defer S.pty.deinit(testing.allocator);
+
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // Start a chunked command, then reset mid-chunk.
+ s.nextSlice("\x1B]72;t=a:i=5\x1B\\");
+ s.nextSlice("\x1B]72;t=m:o=1:m=1;text/pl\x1B\\");
+ s.nextSlice("\x1Bc");
+
+ // Registration survives (matching kitty), chunking was interrupted
+ // so a new command is not treated as a continuation.
+ try testing.expect(t.kitty_dnd != null);
+ try testing.expect(!t.kitty_dnd.?.chunking.active);
+ s.nextSlice("\x1B]72;t=q\x1B\\");
+ try testing.expectEqualStrings("\x1b]72;t=q\x1b\\", S.pty.items);
+}
+
+test "kitty dnd: effect reports registration, acceptance, and conclusion" {
+ const S = struct {
+ var events: std.ArrayListUnmanaged(kitty_dnd.Event) = .empty;
+ var mimes: std.ArrayListUnmanaged(u8) = .empty;
+
+ fn clear() void {
+ events.deinit(testing.allocator);
+ events = .empty;
+ mimes.deinit(testing.allocator);
+ mimes = .empty;
+ }
+
+ fn dragAndDrop(handler: *Handler, ev: kitty_dnd.Event) void {
+ events.append(testing.allocator, ev) catch unreachable;
+ // Registration details are read from the terminal state.
+ if (ev == .registration) {
+ mimes.clearRetainingCapacity();
+ const state = handler.terminal.kitty_dnd orelse return;
+ var it = state.registeredMimes();
+ while (it.next()) |m| {
+ mimes.appendSlice(testing.allocator, m) catch unreachable;
+ mimes.append(testing.allocator, ',') catch unreachable;
+ }
+ }
+ }
+ };
+ S.clear();
+ defer S.clear();
+
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ var handler: Handler = .init(&t);
+ handler.effects.drag_and_drop = &S.dragAndDrop;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // Registration with a MIME list, read back from the state.
+ s.nextSlice("\x1B]72;t=a;image/png text/plain\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.events.items.len);
+ try testing.expect(S.events.items[0] == .registration);
+ try testing.expectEqualStrings("image/png,text/plain,", S.mimes.items);
+
+ // A native drag and the client's answer.
+ {
+ var aw: std.Io.Writer.Allocating = .init(testing.allocator);
+ defer aw.deinit();
+ try t.kitty_dnd.?.dragDrop(testing.allocator, &aw.writer, .{
+ .cell_x = 0,
+ .cell_y = 0,
+ .pixel_x = 0,
+ .pixel_y = 0,
+ .operations = .{ .copy = true },
+ }, &.{.{ .mime = "text/plain", .data = "x" }});
+ }
+ s.nextSlice("\x1B]72;t=m:o=2;text/plain\x1B\\");
+ try testing.expectEqual(@as(usize, 2), S.events.items.len);
+ try testing.expect(S.events.items[1] == .acceptance);
+
+ // Conclusion carries the performed operation.
+ s.nextSlice("\x1B]72;t=r:o=2\x1B\\");
+ try testing.expectEqual(@as(usize, 3), S.events.items.len);
+ try testing.expectEqual(kitty_dnd.Event.concluded_move, S.events.items[2]);
+
+ // Unregistration reports with the state gone.
+ s.nextSlice("\x1B]72;t=A\x1B\\");
+ try testing.expectEqual(@as(usize, 4), S.events.items.len);
+ try testing.expect(S.events.items[3] == .registration);
+ try testing.expect(t.kitty_dnd == null);
+ try testing.expectEqualStrings("", S.mimes.items);
+}
diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig
index 192e51644..1dc5268f7 100644
--- a/src/termio/stream_handler.zig
+++ b/src/termio/stream_handler.zig
@@ -357,6 +357,7 @@ pub const StreamHandler = struct {
.title_push,
.title_pop,
.kitty_clipboard,
+ .kitty_dnd,
=> {},
}
}