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