terminal/kitty: drag and drop command decoding

This commit is contained in:
Mitchell Hashimoto
2026-08-21 14:51:23 -07:00
parent 3b9c4e0ddb
commit 7c845e8af5
3 changed files with 509 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ const build_options = @import("terminal_options");
const key = @import("kitty/key.zig");
pub const clipboard = @import("kitty/clipboard.zig");
pub const color = @import("kitty/color.zig");
pub const dnd = @import("kitty/dnd.zig");
pub const graphics = if (build_options.kitty_graphics) @import("kitty/graphics.zig") else struct {};
pub const KeyFlags = key.Flags;

View File

@@ -0,0 +1,73 @@
//! Kitty drag and drop protocol (OSC 72).
//!
//! Specification: https://sw.kovidgoyal.net/kitty/dnd-protocol/
//! Reference implementation: kitty/dnd.c and kitty/screen.c in
//! https://github.com/kovidgoyal/kitty (introduced in kitty 0.47).
//!
//! 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.
//!
//! The implementation is split into:
//!
//! * dnd_command.zig: metadata grammar and typed command decoding,
//! including chunk reassembly. The grammar mirrors kitty's
//! generated parser exactly.
//! * dnd_response.zig: wire encoding for everything the terminal
//! sends, mirroring kitty's send_payload_to_child chunking.
//! * dnd_drop.zig: the per-terminal protocol state machine, driven
//! by client OSCs on one side and native drag events from the
//! embedder on the other.
//!
//! The wire behavior was validated against kitty's implementation
//! (kitty_tests/dnd.py is the oracle), including its deviations from
//! the published spec: the MIME list payload is sent on every move
//! event with a trailing space after each entry, a missing `t` key
//! ignores the command rather than defaulting to `a`, empty payloads
//! omit the `;` and `m=` entirely, and registration survives a
//! terminal reset (RIS clears only the chunk-reassembly flag).
//!
//! ## Divergences from kitty
//!
//! All are bounded-scope decisions, not accidents:
//!
//! * 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.
//! * The MIME list a client registers with (the t=a payload) is
//! accepted but not forwarded to the OS, so exotic pasteboard
//! types on macOS are not offered to clients.
//! * 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 offers
//! (t=o:x=1) is tracked so the state is queryable, but the
//! terminal never sends a drag start request, so 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.
//! * 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");
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;
test {
_ = dnd_command;
}

View File

@@ -0,0 +1,435 @@
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, mirroring kitty's per-screen dnd_chunking.
/// 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.?);
}