diff --git a/src/terminal/kitty.zig b/src/terminal/kitty.zig index 471fb91e7..bbef8d7bb 100644 --- a/src/terminal/kitty.zig +++ b/src/terminal/kitty.zig @@ -5,6 +5,7 @@ const build_options = @import("terminal_options"); const key = @import("kitty/key.zig"); pub const clipboard = @import("kitty/clipboard.zig"); pub const color = @import("kitty/color.zig"); +pub const dnd = @import("kitty/dnd.zig"); pub const graphics = if (build_options.kitty_graphics) @import("kitty/graphics.zig") else struct {}; pub const KeyFlags = key.Flags; diff --git a/src/terminal/kitty/dnd.zig b/src/terminal/kitty/dnd.zig new file mode 100644 index 000000000..5a70b0a6f --- /dev/null +++ b/src/terminal/kitty/dnd.zig @@ -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; +} diff --git a/src/terminal/kitty/dnd_command.zig b/src/terminal/kitty/dnd_command.zig new file mode 100644 index 000000000..f056f1406 --- /dev/null +++ b/src/terminal/kitty/dnd_command.zig @@ -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.?); +}