Merge branch 'ghostty-org:main' into main

This commit is contained in:
Mohammad AlShami
2026-08-23 12:16:01 +03:00
committed by Mohammad H. AlShami
17 changed files with 3779 additions and 338 deletions

2
.github/VOUCHED.td vendored
View File

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

View File

@@ -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
*/

View File

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

View File

@@ -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,

View File

@@ -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\\", // "<b>hi</b>"
"\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("<b>hi</b>", 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" {

View File

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

View File

@@ -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" {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
=> {

File diff suppressed because it is too large Load Diff

View File

@@ -357,6 +357,7 @@ pub const StreamHandler = struct {
.title_push,
.title_pop,
.kitty_clipboard,
.kitty_dnd,
=> {},
}
}