From 50f69b883cc75061441deadcbcbee9ecf6fc81b7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Aug 2026 09:19:21 -0700 Subject: [PATCH] terminal/kitty: drag and drop drop state machine --- src/terminal/kitty/dnd.zig | 34 +- src/terminal/kitty/dnd_command.zig | 3 +- src/terminal/kitty/dnd_drop.zig | 715 ++++++++++++++++++ src/terminal/kitty/dnd_test.zig | 626 +++++++++++++++ .../osc/parsers/kitty_dnd_protocol.zig | 313 ++------ 5 files changed, 1410 insertions(+), 281 deletions(-) create mode 100644 src/terminal/kitty/dnd_drop.zig create mode 100644 src/terminal/kitty/dnd_test.zig diff --git a/src/terminal/kitty/dnd.zig b/src/terminal/kitty/dnd.zig index c60415c41..42afcb749 100644 --- a/src/terminal/kitty/dnd.zig +++ b/src/terminal/kitty/dnd.zig @@ -19,7 +19,9 @@ //! 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. +//! embedder on the other. It is allocated when a client registers +//! to accept drops and freed when it unregisters, so terminals +//! that never see the protocol pay nothing for it. //! //! The wire behavior was validated against kitty's implementation //! (kitty_tests/dnd.py is the oracle), including its deviations from @@ -29,9 +31,9 @@ //! omit the `;` and `m=` entirely, and registration survives a //! terminal reset (RIS clears only the chunk-reassembly flag). //! -//! ## Divergences from kitty +//! ## Divergences //! -//! All are bounded-scope decisions, not accidents: +//! 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 @@ -41,26 +43,27 @@ //! 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. +//! * 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; @@ -74,7 +77,16 @@ pub const RequestKeys = dnd_response.RequestKeys; pub const encode = dnd_response.encode; pub const encodeError = dnd_response.encodeError; +pub const State = dnd_drop.State; +pub const Item = dnd_drop.State.Item; +pub const MoveEvent = dnd_drop.State.MoveEvent; +pub const max_mime_list_bytes = dnd_drop.max_mime_list_bytes; +pub const handleCommand = dnd_drop.handleCommand; +pub const Event = dnd_drop.Event; + test { _ = dnd_command; _ = dnd_response; + _ = dnd_drop; + _ = @import("dnd_test.zig"); } diff --git a/src/terminal/kitty/dnd_command.zig b/src/terminal/kitty/dnd_command.zig index f056f1406..b5f63d02b 100644 --- a/src/terminal/kitty/dnd_command.zig +++ b/src/terminal/kitty/dnd_command.zig @@ -240,7 +240,8 @@ pub const Request = union(enum) { } }; -/// Chunk reassembly state, mirroring kitty's per-screen dnd_chunking. +/// 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. diff --git a/src/terminal/kitty/dnd_drop.zig b/src/terminal/kitty/dnd_drop.zig new file mode 100644 index 000000000..f9486f9e9 --- /dev/null +++ b/src/terminal/kitty/dnd_drop.zig @@ -0,0 +1,715 @@ +//! Kitty drag and drop protocol (OSC 72) state machine. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const assert = @import("../../quirks.zig").inlineAssert; +const osc = @import("../osc.zig"); +const command = @import("dnd_command.zig"); +const response = @import("dnd_response.zig"); + +const Metadata = command.Metadata; +const Operation = command.Operation; +const Operations = command.Operations; + +const log = std.log.scoped(.kitty_dnd); + +/// Maximum accumulated size of a client-sent MIME list (the accepted +/// list of a `t=m` status update). Matches kitty's MIME_LIST_SIZE_CAP. +pub const max_mime_list_bytes = 1024 * 1024; + +/// Process one OSC 72 command received from the client, writing any +/// responses to the writer. Returns the state change the embedder may +/// need to act on, if any. +pub fn handleCommand( + slot: *?*State, + alloc: Allocator, + writer: *std.Io.Writer, + v: osc.Command.KittyDndProtocol, +) (Allocator.Error || std.Io.Writer.Error)!?Event { + const raw = Metadata.parse(v.metadata) orelse { + log.debug("dropping malformed OSC 72 metadata", .{}); + return null; + }; + + // Chunk reassembly lives in the state, so before registration + // each command stands alone. The only legitimately chunked + // command before registration is t=a itself, which seeds the + // reassembly on its first chunk below. + const continuation = if (slot.*) |state| state.chunking.active else false; + const meta = if (slot.*) |state| state.chunking.apply(raw) else raw; + const payload = v.payload orelse ""; + const t = meta.type orelse return null; + + switch (t) { + .register => { + // x=1 declares the client's machine ID for remote drop + // support. We don't support remote drop yet, so accept and ignore. + if (meta.cell_x == 1) return null; + + // Setup our state if we haven't already + const state = slot.* orelse state: { + const state = try State.create(alloc); + slot.* = state; + _ = state.chunking.apply(raw); + break :state state; + }; + + // Update the client ID on every registration + state.drop.client_id = meta.client_id; + + return try state.register( + alloc, + payload, + continuation, + meta.more, + ); + }, + + .unregister => { + const state = slot.* orelse return null; + state.destroy(alloc); + slot.* = null; + return .registration; + }, + + .status => { + const state = slot.* orelse return null; + return try state.acceptStatus(alloc, meta, payload); + }, + + .request => return try dataRequest( + slot.*, + alloc, + writer, + meta, + v.terminator, + ), + + // Drag source control. Enabling (x=1, with an optional + // machine ID payload) and disabling (x=2) offers are + // accepted and ignored since the terminal never requests a + // drag start. Offering a MIME list (x=0) for a new drag is + // refused since drag-out is not implemented. + .offer => if (meta.cell_x == 0) try refuseDragOut( + writer, + meta, + v.terminator, + ), + + // Drag-out data and start commands. A conforming client + // never sends these because the terminal never requests a + // drag start, but refuse them properly if one does. + .present, .start_drag => try refuseDragOut( + writer, + meta, + v.terminator, + ), + + // Responses to drag-out requests the terminal never makes. + .drag_event, .drag_error, .remote_data => {}, + + .query => try response.encode( + writer, + "t=q", + meta.client_id, + "", + .plain, + v.terminator, + ), + + // Only ever sent by the terminal. Ignore. + .drop, .request_error => {}, + } + + return null; +} + +/// Handle a t=r data request or drop conclusion from the client. +/// Requests from an unregistered client (no state) get the same +/// errors kitty sends from its zeroed drop state. +fn dataRequest( + state: ?*State, + alloc: Allocator, + writer: *std.Io.Writer, + meta: Metadata, + terminator: osc.Terminator, +) (Allocator.Error || std.Io.Writer.Error)!?Event { + // Responses echo the registration's client ID, matching kitty. + const client_id = if (state) |s| s.drop.client_id else 0; + + switch (command.Request.init(meta)) { + .conclude => |op| { + // The client is done with the drop: free the held data and + // report the operation it performed. Kitty hands that to + // the still-open OS drag session; ours ended at drop time + // (see dnd.zig), so the embedder decides what to do with + // it. A conclusion with no drop in progress is a no-op. + const s = state orelse return null; + const dropped = s.drop.dropped; + s.resetDrop(alloc); + return if (dropped) Event.concluded(op) else null; + }, + + .mime => |idx| { + const keys: response.RequestKeys = .{ .x = idx }; + const items = (if (state) |s| s.drop.items else null) orelse { + try response.encodeError( + writer, + .drop, + keys, + client_id, + .ENOENT, + "no drop data available", + terminator, + ); + return null; + }; + if (idx < 1 or @as(usize, @intCast(idx)) > items.len) { + try response.encodeError( + writer, + .drop, + keys, + client_id, + .ENOENT, + "drop data request index out of bounds", + terminator, + ); + return null; + } + + var header_buf: [32]u8 = undefined; + const header = std.fmt.bufPrint( + &header_buf, + "t=r{f}", + .{keys}, + ) catch unreachable; + + // The data chunks followed by the empty end-of-data + // message, which is how the client detects completion. + // An empty item is just the end-of-data message alone; + // clients treat a duplicate as a second completion. + const item = items[@intCast(idx - 1)]; + if (item.data.len > 0) try response.encode( + writer, + header, + client_id, + item.data, + .base64, + terminator, + ); + try response.encode(writer, header, client_id, "", .base64, terminator); + return null; + }, + + // Remote drop transfers (URI file contents and directory + // handles). We never advertise remote support (no X=1 + // marker), so a conforming client never sends these. + .uri => |uri| try response.encodeError( + writer, + .drop, + .{ .x = uri.mime_idx, .y = uri.uri_idx }, + client_id, + .EINVAL, + "remote drop data is not supported", + terminator, + ), + .dir => |dir| try response.encodeError( + writer, + .drop, + .{ .x = dir.entry, .Y = dir.handle }, + client_id, + .EINVAL, + "remote drop data is not supported", + terminator, + ), + } + + return null; +} + +/// Refuse a drag-out command with an error, since ghostty does not +/// implement the terminal side of client-initiated drags yet. +fn refuseDragOut( + writer: *std.Io.Writer, + meta: Metadata, + terminator: osc.Terminator, +) std.Io.Writer.Error!void { + try response.encodeError( + writer, + .drag, + .{}, + meta.client_id, + .EPERM, + "drag out is not supported by this terminal", + terminator, + ); +} + +/// A protocol state change an embedder may need to act on, returned by +/// `handleCommand` and delivered through the stream handler's +/// `dnd_event` effect. This is a flat enum so it can cross a C API +/// unchanged; any details are read back from `Terminal.kitty_dnd`. +pub const Event = enum { + /// The client registered (t=a), re-registered, or unregistered + /// (t=A) to accept drops. An embedder may want to use this + /// to setup the proper mime types to accept (e.g. on macOS) + /// or not (unregistered). + registration, + + /// The client answered the drag currently over the terminal. + /// `State.clientAccepted` has the answer. Embedders can refresh the + /// OS drag feedback immediately rather than on the next move. + acceptance, + + /// The client concluded a drop, performing no operation (it + /// canceled), a copy, or a move. The held drop data has been freed. + concluded_none, + concluded_copy, + concluded_move, + + /// The conclusion event for a performed operation. + pub fn concluded(op: Operation) Event { + return switch (op) { + .none => .concluded_none, + .copy => .concluded_copy, + .move => .concluded_move, + }; + } +}; + +/// The per-terminal drop target state. +/// +/// The primary entrypoint is `handleCommand` which takes a `*?*State` +/// slot that it can heap allocate into when DnD activates and free when +/// it deactivates. +/// +/// The normal lifecycle: +/// +/// 1. The stream handler feeds every OSC 72 command received from the +/// client to `handleCommand`. The client registers (t=a), which +/// allocates the state into the slot and yields a `registration` +/// event so the embedder can register any declared MIME types +/// with the OS. Until then `handleCommand` only answers stateless +/// commands (queries, error responses). +/// 2. A native drag enters or moves over the terminal. When the slot +/// is non-null, the embedder calls `dragMove` with the pointer +/// position, the operations the drag source allows, and the MIME +/// types it can serve if dropped. This sends the client a t=m +/// move event; when the slot is null the embedder should handle +/// the drag as it would without the protocol. +/// 3. The client answers with its acceptance (t=m:o=N), recorded by +/// `handleCommand` which yields an `acceptance` event. The +/// embedder reads `clientAccepted` then and on subsequent moves +/// to give the OS drag session its feedback. +/// 4. The drag either leaves, and the embedder calls `dragLeave` to +/// send the t=m leave event, or drops: the embedder captures the +/// representations it advertised and calls `dragDrop`, which +/// copies and holds them and sends the client a t=M drop event. A +/// new drag entering before the client concludes discards the +/// held drop. +/// 5. The client requests data (t=r:x=N), which `handleCommand` +/// serves from the held copies, and then concludes the drop +/// (t=r:o=N), which frees them and yields a `concluded_*` event +/// naming the operation the client performed. +/// 6. The client unregisters (t=A) and `handleCommand` frees the +/// state, yielding a final `registration` event, or the terminal +/// is deinitialized and calls `destroy`. +/// +/// All calls must use the allocator the state was created with (the +/// terminal's) and require the same synchronization as any other +/// terminal mutation. +pub const State = struct { + /// Chunk reassembly for client commands. This is the only part of + /// the state cleared by a terminal reset (RIS), matching kitty. + chunking: command.Chunking = .{}, + + /// Drop target state for the registered client. + drop: DropTarget = .{}, + + pub const DropTarget = struct { + /// Multiplexer client ID from registration, echoed in every + /// drop-side message the terminal sends. + client_id: u32 = 0, + + /// The MIME list the client registered with (the t=a payload), + /// space-separated as received and accumulated across chunks. + /// Only needed by embedders that must register types with the + /// OS ahead of a drag; kitty frees it after doing so, we keep + /// it so the `registration` event can be acted on from here. + registered_mimes: std.ArrayListUnmanaged(u8) = .empty, + + /// True while the pointer of a native drag is over the terminal. + hovered: bool = false, + + /// True after the native drop until the client concludes it. + dropped: bool = false, + + /// The client's response to the current drag, null until the + /// client has responded. `none` means the client rejected it. + accepted: ?Operation = null, + + /// True while a chunked t=m acceptance is being accumulated. + accept_in_progress: bool = false, + + /// The client's accepted MIME list: space-separated while + /// accumulating, converted to NUL-separated (with a trailing + /// NUL) once complete, matching kitty's in-place conversion. + accepted_mimes: std.ArrayListUnmanaged(u8) = .empty, + + /// The MIME types of the current native drag, in the order + /// that data request indices refer to. + offered: ?Offered = null, + + /// The data captured at drop time, parallel to `offered`. + items: ?[]const Item = null, + }; + + /// One dropped representation: a MIME type and its data. + pub const Item = struct { + mime: []const u8, + data: []const u8, + }; + + /// The MIME list of the current drag plus the pre-joined move-event + /// payload ("mime1 mime2 " with a trailing space after every entry, + /// matching kitty) so per-move encoding is allocation-free. + const Offered = struct { + mimes: []const []const u8, + payload: []const u8, + + fn init(alloc: Allocator, mimes: []const []const u8) Allocator.Error!Offered { + const copies = try alloc.alloc([]const u8, mimes.len); + errdefer alloc.free(copies); + + var payload_len: usize = 0; + for (mimes) |m| payload_len += m.len + 1; + + const payload = try alloc.alloc(u8, payload_len); + errdefer alloc.free(payload); + + var offset: usize = 0; + for (mimes, copies) |m, *copy| { + @memcpy(payload[offset..][0..m.len], m); + payload[offset + m.len] = ' '; + copy.* = payload[offset..][0..m.len]; + offset += m.len + 1; + } + + return .{ .mimes = copies, .payload = payload }; + } + + fn deinit(self: *const Offered, alloc: Allocator) void { + alloc.free(self.mimes); + alloc.free(self.payload); + } + + fn eql(self: *const Offered, mimes: []const []const u8) bool { + if (self.mimes.len != mimes.len) return false; + for (self.mimes, mimes) |a, b| { + if (!std.mem.eql(u8, a, b)) return false; + } + return true; + } + }; + + /// The maximum number of dropped items. Embedders provide a small + /// curated set of representations (see dnd.zig), so this is a + /// generous bound that keeps the MIME list assembly on the stack. + pub const max_items = 16; + + /// Allocate a fresh state. Done by `handleCommand` on registration. + fn create(alloc: Allocator) Allocator.Error!*State { + const state = try alloc.create(State); + state.* = .{}; + return state; + } + + /// Free the state and everything it holds. + pub fn destroy(self: *State, alloc: Allocator) void { + self.deinit(alloc); + alloc.destroy(self); + } + + fn deinit(self: *State, alloc: Allocator) void { + self.freeDragData(alloc); + self.drop.accepted_mimes.deinit(alloc); + self.drop.registered_mimes.deinit(alloc); + } + + /// Iterate the MIME types the client registered with, in order. + /// Empty when the client declared none, which is the common case. + /// The list is only needed to register exotic types with the OS, + /// such as macOS pasteboard stuff. + pub fn registeredMimes(self: *const State) std.mem.TokenIterator(u8, .scalar) { + return std.mem.tokenizeScalar( + u8, + self.drop.registered_mimes.items, + ' ', + ); + } + + /// Record one chunk of a registration's MIME list. + /// + /// `continuation` is true for every chunk but the first of a chunked + /// registration. Returns the registration event once the list is complete. + fn register( + self: *State, + alloc: Allocator, + payload: []const u8, + continuation: bool, + more: bool, + ) Allocator.Error!?Event { + const list = &self.drop.registered_mimes; + if (!continuation) list.clearRetainingCapacity(); + + // Matching kitty, an over-cap chunk is dropped and does not + // complete the registration. + if (list.items.len + payload.len > max_mime_list_bytes) return null; + try list.appendSlice(alloc, payload); + + return if (more) null else .registration; + } + + /// The client's acceptance response for the drag currently over the + /// terminal, for OS drag feedback. Null when the client hasn't + /// responded yet (embedders should fall back to their default, + /// typically copy) or `none` when the client rejected the drag. + pub fn clientAccepted(self: *const State) ?Operation { + if (self.drop.accept_in_progress) return null; + return self.drop.accepted; + } + + /// Free the per-drag data (offered MIME list and held drop items). + fn freeDragData(self: *State, alloc: Allocator) void { + if (self.drop.offered) |*offered| { + offered.deinit(alloc); + self.drop.offered = null; + } + if (self.drop.items) |items| { + for (items) |item| { + alloc.free(item.mime); + alloc.free(item.data); + } + alloc.free(items); + self.drop.items = null; + } + } + + /// Clear the per-drag state while preserving the registration, + /// mirroring kitty's reset_drop. Called when a new drag enters and + /// when a drop concludes. + fn resetDrop(self: *State, alloc: Allocator) void { + self.freeDragData(alloc); + self.drop.accepted_mimes.clearAndFree(alloc); + self.drop.hovered = false; + self.drop.dropped = false; + self.drop.accepted = null; + self.drop.accept_in_progress = false; + } + + /// Handle a t=m acceptance status update from the client, mirroring + /// kitty's drop_set_status. + fn acceptStatus( + self: *State, + alloc: Allocator, + meta: Metadata, + payload: []const u8, + ) Allocator.Error!?Event { + const d = &self.drop; + if (!d.accept_in_progress) { + d.accepted_mimes.clearRetainingCapacity(); + d.accept_in_progress = true; + d.accepted = .fromProtocol(meta.operation); + } + + if (payload.len > 0) { + // Matching kitty, an over-cap list stops accumulating and + // never finalizes, leaving the acceptance unanswered. + if (d.accepted_mimes.items.len + payload.len > max_mime_list_bytes) return null; + try d.accepted_mimes.appendSlice(alloc, payload); + } + + if (meta.more) return null; + d.accept_in_progress = false; + if (d.accepted_mimes.items.len > 0) { + for (d.accepted_mimes.items) |*c| { + if (c.* == ' ') c.* = 0; + } + try d.accepted_mimes.append(alloc, 0); + } + return .acceptance; + } + + /// A native drag position report from the embedder. + pub const MoveEvent = struct { + /// Grid cell under the pointer, zero-based from the top-left. + cell_x: u32, + cell_y: u32, + + /// Pointer position in pixels relative to the top-left of the + /// terminal's content area. + pixel_x: i32, + pixel_y: i32, + + /// The operations the drag source allows. + operations: Operations, + }; + + /// Report a native drag moving over the terminal, sending a t=m + /// move event to the client. `mimes` is the list of MIME types the + /// terminal can provide for this drag, in the order data request + /// indices will refer to. + pub fn dragMove( + self: *State, + alloc: Allocator, + writer: *std.Io.Writer, + ev: MoveEvent, + mimes: []const []const u8, + ) (Allocator.Error || std.Io.Writer.Error)!void { + try self.moveEvent( + alloc, + writer, + ev, + mimes, + false, + ); + } + + /// Report a native drop onto the terminal. The items' data is + /// copied and held so the client's data requests can be served; it + /// is freed when the client concludes the drop, a new drag enters, + /// or the client unregisters. + /// + /// Sends a t=M drop event listing the items' MIME types. + pub fn dragDrop( + self: *State, + alloc: Allocator, + writer: *std.Io.Writer, + ev: MoveEvent, + items: []const Item, + ) (Allocator.Error || std.Io.Writer.Error)!void { + // Copy the items so they can be served after this call returns. + // Items beyond the cap are dropped so the held list always + // matches the advertised MIME list. + const accepted_items = items[0..@min(items.len, max_items)]; + const copies = try alloc.alloc(Item, accepted_items.len); + errdefer alloc.free(copies); + var copied: usize = 0; + errdefer for (copies[0..copied]) |item| { + alloc.free(item.mime); + alloc.free(item.data); + }; + for (accepted_items, copies) |item, *copy| { + const mime = try alloc.dupe(u8, item.mime); + errdefer alloc.free(mime); + const data = try alloc.dupe(u8, item.data); + copy.* = .{ .mime = mime, .data = data }; + copied += 1; + } + + // The move handling below resets per-drag state when this drop + // arrives without a preceding move, so the items are attached + // after it runs. Collect the MIME list first. + var mimes_buf: [max_items][]const u8 = undefined; + const mimes = mimes_buf[0..copies.len]; + for (mimes, copies) |*m, item| m.* = item.mime; + + try self.moveEvent( + alloc, + writer, + ev, + mimes, + true, + ); + + assert(self.drop.items == null); + self.drop.items = copies; + } + + /// Report the native drag leaving the terminal, sending the t=m + /// leave event (x=-1, y=-1). + /// + /// Ignored after a drop: some toolkits emit a leave notification + /// for the drop itself, and the held data must survive until the + /// client concludes. + pub fn dragLeave( + self: *State, + alloc: Allocator, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + if (self.drop.dropped) return; + const hovered = self.drop.hovered; + self.drop.hovered = false; + if (self.drop.offered) |*offered| { + offered.deinit(alloc); + self.drop.offered = null; + } + + // Only a client that saw the drag enter gets the leave event, + // matching kitty which notifies hovered windows only. + if (!hovered) return; + + try response.encode( + writer, + "t=m:x=-1:y=-1", + self.drop.client_id, + "", + .plain, + .st, + ); + } + + /// Shared implementation of move and drop events, mirroring kitty's + /// drop_move_on_child. + fn moveEvent( + self: *State, + alloc: Allocator, + writer: *std.Io.Writer, + ev: MoveEvent, + mimes: []const []const u8, + is_drop: bool, + ) (Allocator.Error || std.Io.Writer.Error)!void { + if (!self.drop.hovered) { + self.resetDrop(alloc); + self.drop.hovered = true; + } + if (is_drop) { + self.drop.dropped = true; + self.drop.hovered = false; + } + + // (Re)build the offered MIME list when it changed. + if (self.drop.offered == null or !self.drop.offered.?.eql(mimes)) { + if (self.drop.offered) |*offered| offered.deinit(alloc); + self.drop.offered = null; + self.drop.offered = try Offered.init(alloc, mimes); + } + + var header_buf: [96]u8 = undefined; + const header = std.fmt.bufPrint( + &header_buf, + "t={c}:x={d}:y={d}:X={d}:Y={d}:o={d}", + .{ + @as(u8, if (is_drop) 'M' else 'm'), + ev.cell_x, + ev.cell_y, + ev.pixel_x, + ev.pixel_y, + ev.operations.protocolValue(), + }, + ) catch unreachable; + + // The MIME list is sent with every move event, matching kitty + // (the spec suggests only the first, but kitty always sends it + // and clients depend on that). + try response.encode( + writer, + header, + self.drop.client_id, + self.drop.offered.?.payload, + .plain, + .st, + ); + } +}; diff --git a/src/terminal/kitty/dnd_test.zig b/src/terminal/kitty/dnd_test.zig new file mode 100644 index 000000000..c78ce00f6 --- /dev/null +++ b/src/terminal/kitty/dnd_test.zig @@ -0,0 +1,626 @@ +//! End-to-end tests for the OSC 72 protocol state machine, validating +//! wire behavior against kitty's implementation (using kitty_tests/dnd.py as +//! an oracle for the expected bytes). +//! +//! It isn't normal for us to have dedicated test files but in this case +//! the dnd protocol is complicated enough that I wanted full e2e covering +//! the full machine. + +const std = @import("std"); +const testing = std.testing; + +const osc = @import("../osc.zig"); +const dnd = @import("dnd.zig"); + +/// A test harness holding the lazily allocated protocol state and an +/// output collector. +const Harness = struct { + state: ?*dnd.State = null, + output: std.Io.Writer.Allocating, + + fn init() Harness { + return .{ .output = .init(testing.allocator) }; + } + + fn deinit(self: *Harness) void { + if (self.state) |state| state.destroy(testing.allocator); + self.output.deinit(); + } + + /// The registered state; asserts a client has registered. + fn registered(self: *Harness) *dnd.State { + return self.state.?; + } + + /// Feed one client command, as it would arrive from the OSC parser, + /// returning the event the stream handler would pass to its effect. + fn command(self: *Harness, metadata: []const u8, payload: ?[]const u8) !?dnd.Event { + return try dnd.handleCommand(&self.state, testing.allocator, &self.output.writer, .{ + .metadata = metadata, + .payload = payload, + .terminator = .st, + }); + } + + /// Consume and return the collected output. + fn consume(self: *Harness) []const u8 { + const written = self.output.written(); + return written; + } + + fn clear(self: *Harness) void { + self.output.clearRetainingCapacity(); + } + + fn expectOutput(self: *Harness, expected: []const u8) !void { + try testing.expectEqualStrings(expected, self.output.written()); + self.clear(); + } +}; + +test "dnd: query response" { + var h: Harness = .init(); + defer h.deinit(); + + // Works without any registration, matching kitty, and allocates + // nothing. + _ = try h.command("t=q", null); + try h.expectOutput("\x1b]72;t=q\x1b\\"); + try testing.expect(h.state == null); +} + +test "dnd: query response echoes client id" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=q:i=31", null); + try h.expectOutput("\x1b]72;t=q:i=31\x1b\\"); +} + +test "dnd: register and unregister" { + var h: Harness = .init(); + defer h.deinit(); + + try testing.expect(h.state == null); + + // Registration allocates the state and reports it, with the + // declared MIME list readable from the state. + try testing.expect((try h.command("t=a", "text/plain text/uri-list")).? == .registration); + try h.expectOutput(""); + { + var it = h.registered().registeredMimes(); + try testing.expectEqualStrings("text/plain", it.next().?); + try testing.expectEqualStrings("text/uri-list", it.next().?); + try testing.expect(it.next() == null); + } + + // Machine ID declaration is accepted and ignored. + try testing.expect((try h.command("t=a:x=1", "1:deadbeef")) == null); + try h.expectOutput(""); + try testing.expect(h.state != null); + + // Re-registration replaces the list. + try testing.expect((try h.command("t=a", "image/png")).? == .registration); + { + var it = h.registered().registeredMimes(); + try testing.expectEqualStrings("image/png", it.next().?); + try testing.expect(it.next() == null); + } + + // Registering without a list is the common case. + try testing.expect((try h.command("t=a", null)).? == .registration); + { + var it = h.registered().registeredMimes(); + try testing.expect(it.next() == null); + } + + // Unregistration frees it and reports the change. + try testing.expect((try h.command("t=A", null)).? == .registration); + try h.expectOutput(""); + try testing.expect(h.state == null); + + // Unregistering again changes nothing. + try testing.expect((try h.command("t=A", null)) == null); + try h.expectOutput(""); + try testing.expect(h.state == null); +} + +test "dnd: no state before registration" { + var h: Harness = .init(); + defer h.deinit(); + + // State-dependent commands from an unregistered client allocate + // nothing; a data request gets the error kitty sends from its + // zeroed state. + _ = try h.command("t=m:o=1", "text/plain"); + _ = try h.command("t=r", null); + try h.expectOutput(""); + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\", + ); + try testing.expect(h.state == null); +} + +test "dnd: move event carries position, operations, and mime list" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", "text/plain"); + + try h.registered().dragMove(testing.allocator, &h.output.writer, .{ + .cell_x = 5, + .cell_y = 3, + .pixel_x = 100, + .pixel_y = 60, + .operations = .{ .copy = true }, + }, &.{ "text/plain", "text/uri-list" }); + + // Note the trailing space after every MIME entry, matching kitty. + try h.expectOutput( + "\x1b]72;t=m:x=5:y=3:X=100:Y=60:o=1:m=0;text/plain text/uri-list \x1b\\", + ); +} + +test "dnd: move event echoes registration client id" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a:i=7", ""); + try h.registered().dragMove(testing.allocator, &h.output.writer, .{ + .cell_x = 1, + .cell_y = 2, + .pixel_x = 8, + .pixel_y = 16, + .operations = .{ .copy = true, .move = true }, + }, &.{"text/plain"}); + try h.expectOutput("\x1b]72;t=m:x=1:y=2:X=8:Y=16:o=3:i=7:m=0;text/plain \x1b\\"); +} + +test "dnd: re-registration updates client id in place" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a:i=7", ""); + const state = h.registered(); + _ = try h.command("t=a:i=9", ""); + // Same allocation, new client ID. + try testing.expect(h.state.? == state); + try testing.expectEqual(@as(u32, 9), state.drop.client_id); +} + +test "dnd: mime list sent on every move" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + const ev: dnd.MoveEvent = .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }; + try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"}); + h.clear(); + + // Kitty resends the list even when unchanged; clients depend on it. + try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"}); + try h.expectOutput("\x1b]72;t=m:x=0:y=0:X=0:Y=0:o=1:m=0;text/plain \x1b\\"); +} + +test "dnd: leave event" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try h.registered().dragMove(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{"text/plain"}); + h.clear(); + + try h.registered().dragLeave(testing.allocator, &h.output.writer); + try h.expectOutput("\x1b]72;t=m:x=-1:y=-1\x1b\\"); +} + +test "dnd: client acceptance recorded" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try testing.expect(h.registered().clientAccepted() == null); + + try testing.expect((try h.command("t=m:o=1", "text/plain")).? == .acceptance); + try h.expectOutput(""); + try testing.expectEqual(dnd.Operation.copy, h.registered().clientAccepted().?); + + // Rejection. + try testing.expect((try h.command("t=m:o=0", "")).? == .acceptance); + try testing.expectEqual(dnd.Operation.none, h.registered().clientAccepted().?); +} + +test "dnd: chunked client acceptance" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + + // Chunked accept: continuation metadata is ignored, the acceptance + // is pending until the final chunk. + try testing.expect((try h.command("t=m:o=2:m=1", "text/pl")) == null); + try testing.expect(h.registered().clientAccepted() == null); + try testing.expect((try h.command("t=m:m=1", "ain text")) == null); + try testing.expect((try h.command("t=m:m=0", "/html")).? == .acceptance); + try testing.expectEqual(dnd.Operation.move, h.registered().clientAccepted().?); + + // The accumulated list was converted to NUL-separated entries. + try testing.expectEqualSlices( + u8, + "text/plain\x00text/html\x00", + h.registered().drop.accepted_mimes.items, + ); +} + +test "dnd: drop and data serving round trip" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", "text/plain text/uri-list"); + + const ev: dnd.MoveEvent = .{ + .cell_x = 4, + .cell_y = 2, + .pixel_x = 40, + .pixel_y = 20, + .operations = .{ .copy = true }, + }; + try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{ + .{ .mime = "text/uri-list", .data = "file:///tmp/a.txt\r\n" }, + .{ .mime = "text/plain", .data = "hello" }, + }); + try h.expectOutput( + "\x1b]72;t=M:x=4:y=2:X=40:Y=20:o=1:m=0;text/uri-list text/plain \x1b\\", + ); + + // Request the second MIME's data: base64 chunk plus the empty + // end-of-data message. + _ = try h.command("t=r:x=2", null); + try h.expectOutput( + "\x1b]72;t=r:x=2:m=0;aGVsbG8=\x1b\\" ++ "\x1b]72;t=r:x=2\x1b\\", + ); + + // Out-of-bounds request. + _ = try h.command("t=r:x=3", null); + try h.expectOutput( + "\x1b]72;t=R:x=3:m=0;ENOENT:drop data request index out of bounds\x1b\\", + ); + + // Conclude: the performed operation is reported, held data is + // freed, and further requests fail. + try testing.expectEqual(dnd.Event.concluded_copy, (try h.command("t=r:o=1", null)).?); + try h.expectOutput(""); + try testing.expect((try h.command("t=r:o=1", null)) == null); + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\", + ); +} + +test "dnd: empty item served as a single end-of-data message" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try h.registered().dragDrop(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{.{ .mime = "text/plain", .data = "" }}); + h.clear(); + + // Kitty's oracle (test_empty_data) asserts exactly one message: + // the empty response is itself the end-of-data signal, and a + // duplicate would be a second completion to the client. + _ = try h.command("t=r:x=1", null); + try h.expectOutput("\x1b]72;t=r:x=1\x1b\\"); +} + +test "dnd: leave without hover sends nothing" { + var h: Harness = .init(); + defer h.deinit(); + + // Client registered but no move was ever forwarded (e.g. it + // registered mid-drag): kitty only notifies hovered windows. + _ = try h.command("t=a", ""); + try h.registered().dragLeave(testing.allocator, &h.output.writer); + try h.expectOutput(""); +} + +test "dnd: data request with no drop" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\", + ); +} + +test "dnd: leave after drop is ignored" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try h.registered().dragDrop(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{.{ .mime = "text/plain", .data = "x" }}); + h.clear(); + + // Some toolkits emit a leave for the drop itself; the held data + // must survive so the client can still fetch it. + try h.registered().dragLeave(testing.allocator, &h.output.writer); + try h.expectOutput(""); + + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=r:x=1:m=0;eA==\x1b\\" ++ "\x1b]72;t=r:x=1\x1b\\", + ); +} + +test "dnd: new drag resets held drop data" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + const ev: dnd.MoveEvent = .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }; + try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{ + .{ .mime = "text/plain", .data = "old" }, + }); + h.clear(); + + // A new drag entering resets the per-drag state including the held + // items from the unconcluded previous drop. + try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"}); + h.clear(); + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\", + ); +} + +test "dnd: remote transfer requests refused" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + + // URI file content request. + _ = try h.command("t=r:x=1:y=2", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:y=2:m=0;EINVAL:remote drop data is not supported\x1b\\", + ); + + // Directory handle request. + _ = try h.command("t=r:Y=2:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:Y=2:m=0;EINVAL:remote drop data is not supported\x1b\\", + ); +} + +test "dnd: drag out refused" { + var h: Harness = .init(); + defer h.deinit(); + + // Enabling and disabling offers is accepted silently and allocates + // nothing. + _ = try h.command("t=o:x=1", null); + _ = try h.command("t=o:x=2", null); + try h.expectOutput(""); + try testing.expect(h.state == null); + + // Offering a drag is refused. + _ = try h.command("t=o:x=1", null); + _ = try h.command("t=o:o=3", "text/plain"); + try h.expectOutput( + "\x1b]72;t=E:m=0;EPERM:drag out is not supported by this terminal\x1b\\", + ); + + // Starting a drag is refused, echoing the command's client id. + _ = try h.command("t=P:x=-1:i=9", null); + try h.expectOutput( + "\x1b]72;t=E:i=9:m=0;EPERM:drag out is not supported by this terminal\x1b\\", + ); + try testing.expect(h.state == null); +} + +test "dnd: unregister frees held drop data" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try h.registered().dragDrop(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{.{ .mime = "text/plain", .data = "x" }}); + h.clear(); + + // The testing allocator would report the held data as leaked if + // unregistration didn't free the whole state. + _ = try h.command("t=A", null); + try testing.expect(h.state == null); +} + +test "dnd: chunked registration reuses first chunk metadata" { + var h: Harness = .init(); + defer h.deinit(); + + // Registration split over two chunks: the first chunk allocates + // the state and seeds chunk reassembly, so the continuation (which + // carries a different type) is still treated as the registration. + try testing.expect((try h.command("t=a:i=4:m=1", "text/pla")) == null); + try testing.expect(h.state != null); + try testing.expect((try h.command("t=q:m=0", "in")).? == .registration); + try h.expectOutput(""); + try testing.expectEqual(@as(u32, 4), h.registered().drop.client_id); + { + var it = h.registered().registeredMimes(); + try testing.expectEqualStrings("text/plain", it.next().?); + } + + // A query after the chunked command completes works again. + _ = try h.command("t=q", null); + try h.expectOutput("\x1b]72;t=q\x1b\\"); +} + +test "dnd: malformed metadata ignored" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a:zz=1", ""); + try h.expectOutput(""); + try testing.expect(h.state == null); + + // Command with no type is ignored, matching kitty (the spec's + // default of t=a is not honored by the reference implementation). + _ = try h.command("x=1", ""); + try h.expectOutput(""); + try testing.expect(h.state == null); +} + +test "dnd: bel terminator echoed in responses" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try dnd.handleCommand(&h.state, testing.allocator, &h.output.writer, .{ + .metadata = "t=q", + .payload = null, + .terminator = .bel, + }); + try h.expectOutput("\x1b]72;t=q\x07"); +} + +test "dnd: kitten 0.47 conversation replay" { + // This replays a conversation recorded from the reference client + // (`kitten dnd --drop-anywhere=copy --drop text/plain:out.txt`, + // kitten 0.47.0) driven over a pty by a harness that sent exactly + // the bytes this engine produces. The kitten accepted the events, + // wrote the dropped payload to disk intact, and concluded; its + // client bytes are frozen here as an interop regression test. + var h: Harness = .init(); + defer h.deinit(); + + // Startup: register with MIME list and machine ID, then the test + // harness reset (unregister both directions, re-register). + _ = try h.command("t=a:m=0", "text/uri-list text/plain"); + _ = try h.command( + "t=a:x=1:m=0", + "1:5cff8247c477900a8727e2281fe890252f8848f87c224dd8dd7fb6303e94ddbd", + ); + _ = try h.command("t=A", null); + try testing.expect(h.state == null); + _ = try h.command("t=o:x=2", null); + _ = try h.command("t=a:m=0", "text/uri-list text/plain"); + _ = try h.command( + "t=a:x=1:m=0", + "1:5cff8247c477900a8727e2281fe890252f8848f87c224dd8dd7fb6303e94ddbd", + ); + try h.expectOutput(""); + try testing.expect(h.state != null); + + // Native drag moves over the terminal and drops. + const ev: dnd.MoveEvent = .{ + .cell_x = 2, + .cell_y = 1, + .pixel_x = 20, + .pixel_y = 18, + .operations = .{ .copy = true }, + }; + try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"}); + try h.expectOutput("\x1b]72;t=m:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\"); + + // The kitten accepts as a copy of text/plain. + _ = try h.command("t=m:o=1:m=0", "text/plain"); + try h.expectOutput(""); + try testing.expectEqual(dnd.Operation.copy, h.registered().clientAccepted().?); + + try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{ + .{ .mime = "text/plain", .data = "hello from ghostty\n" }, + }); + try h.expectOutput("\x1b]72;t=M:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\"); + + // The kitten requests the data and concludes with a copy. + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=r:x=1:m=0;aGVsbG8gZnJvbSBnaG9zdHR5Cg==\x1b\\" ++ + "\x1b]72;t=r:x=1\x1b\\", + ); + _ = try h.command("t=r:o=1", null); + try h.expectOutput(""); + try testing.expect(h.registered().drop.items == null); +} + +test "dnd: large data served in chunks" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + + // 3073 bytes: one full chunk plus one byte. + const data = [_]u8{'Z'} ** 3073; + try h.registered().dragDrop(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{.{ .mime = "application/octet-stream", .data = &data }}); + h.clear(); + + _ = try h.command("t=r:x=1", null); + const out = h.consume(); + + // First chunk is m=1 with 4096 base64 chars, second is m=0, and + // the final message is the bare end-of-data marker. + try testing.expect(std.mem.startsWith(u8, out, "\x1b]72;t=r:x=1:m=1;")); + try testing.expect(std.mem.indexOf(u8, out, "\x1b]72;t=r:x=1:m=0;") != null); + try testing.expect(std.mem.endsWith(u8, out, "\x1b]72;t=r:x=1\x1b\\")); + h.clear(); +} + +test "dnd: over-cap registration list never completes" { + var h: Harness = .init(); + defer h.deinit(); + + // Matching kitty, a chunk that would exceed the cap is dropped and + // the registration is not reported, though the client stays + // registered (the state exists). + const big = try testing.allocator.alloc(u8, dnd.max_mime_list_bytes + 1); + defer testing.allocator.free(big); + @memset(big, 'a'); + try testing.expect((try h.command("t=a", big)) == null); + try testing.expect(h.state != null); + { + var it = h.registered().registeredMimes(); + try testing.expect(it.next() == null); + } +} diff --git a/src/terminal/osc/parsers/kitty_dnd_protocol.zig b/src/terminal/osc/parsers/kitty_dnd_protocol.zig index 35d239066..839e39618 100644 --- a/src/terminal/osc/parsers/kitty_dnd_protocol.zig +++ b/src/terminal/osc/parsers/kitty_dnd_protocol.zig @@ -1,5 +1,12 @@ //! Kitty's drag and drop protocol (OSC 72) -//! Specification: https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/ +//! +//! This only captures the raw metadata and payload for the OSC. The +//! actual protocol grammar (metadata keys, event types, chunking) is +//! implemented in `terminal/kitty/dnd.zig` and its submodules, since the +//! protocol requires stateful handling that doesn't belong in the +//! stateless OSC parser. +//! +//! Specification: https://sw.kovidgoyal.net/kitty/dnd-protocol/ const std = @import("std"); @@ -9,143 +16,52 @@ const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; const Terminator = @import("../../osc.zig").Terminator; -const log = std.log.scoped(.kitty_dnd_protocol); - pub const OSC = struct { - /// The raw metadata that was received. Parse individual values with `readOption`. + /// The raw metadata that was received. Parse with + /// `kitty.dnd.Metadata.parse`. metadata: []const u8, - /// The raw payload. Its meaning and encoding depend on the event type (`t` key). + + /// The raw payload. Its meaning and encoding depend on the event + /// type (`t` metadata key). Null when the OSC had no `;` after the + /// metadata; an empty payload is distinct from no payload. payload: ?[]const u8, + /// The terminator used for this OSC, so any response can match it. terminator: Terminator, - pub fn readOption(self: OSC, comptime key: Option) ?key.Type() { - return key.read(self.metadata); + /// We don't currently support encoding this to C in any way. + pub const C = void; + + pub fn cval(_: OSC) C { + return {}; } }; -/// Values for the `t` (event type) metadata key. -pub const EventType = enum { - /// ('a') Terminal registers itself as willing to accept drops. - accept_drops, - /// ('A') Terminal unregisters itself; drops should no longer be forwarded. - stop_accepting_drops, - /// ('m') Pointer is moving over the terminal while a drag is in progress. - /// Carries `x`/`y` cursor position; -1 signals the drag left the window. - drop_move, - /// ('M') Items were dropped onto the terminal. - /// Carries `x`/`y` drop position and `i` (multiplexer session ID). - drop_dropped, - /// ('r') Terminal requests data for a specific MIME type from the drag source. - /// Carries `i` (multiplexer session ID) and `y` (1-based MIME type index). - request_data, - /// ('R') Error response to a `request_data` event. - request_error, - /// ('o') Terminal offers data for an outgoing drag (drag-out from terminal). - offer_drag, - /// ('p') Drag source presents the actual payload for a previously requested MIME type. - /// Carries `i` (multiplexer session ID), `o` (operation), and `m` (chunking flag). - present_data, - /// ('P') Replace the current drag image with a new one. - /// Payload is the image data; `X`/`Y` carry image dimensions in pixels. - change_drag_image, - /// ('e') Notification of an event on an outgoing drag offer (e.g., accepted or rejected). - drag_offer_event, - /// ('E') Error on an outgoing drag offer. - drag_offer_error, - /// ('k') URI list data delivered as part of a drag or clipboard transfer. - uri_list_data, - /// ('q') Query terminal capabilities related to the drag-and-drop protocol. - query, +pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { + assert(parser.state == .@"72"); - pub fn init(str: []const u8) ?EventType { - if (str.len != 1) return null; - return switch (str[0]) { - 'a' => .accept_drops, - 'A' => .stop_accepting_drops, - 'm' => .drop_move, - 'M' => .drop_dropped, - 'r' => .request_data, - 'R' => .request_error, - 'o' => .offer_drag, - 'p' => .present_data, - 'P' => .change_drag_image, - 'e' => .drag_offer_event, - 'E' => .drag_offer_error, - 'k' => .uri_list_data, - 'q' => .query, - else => null, - }; - } -}; + const cap = if (parser.capture) |*c| c else { + parser.state = .invalid; + return null; + }; -/// Metadata keys defined by the protocol. Keys are case-sensitive: `x` and `X` are distinct. -pub const Option = enum { - /// Event type. Maps to `EventType`; present in every OSC 72 sequence. - t, - /// Chunking flag. `0` = this is the final (or only) chunk; `1` = more chunks follow. - m, - /// Multiplexer session ID. Echoed back in responses so a terminal multiplexer - /// (e.g. tmux) can route data to the correct pane. - i, - /// Drop operation. `0` = reject, `1` = copy, `2` = move, `3` = copy or move. - o, - /// Cursor column in cell units (zero-based). -1 signals the drag has left the window. - x, - /// Cursor row in cell units (zero-based). Also used as a 1-based MIME type index - /// in some events (e.g. `request_data`). -1 signals the drag has left the window. - y, - /// Pixel offset from the left edge of the cell; also used as image width - /// (with `change_drag_image`) or as a symlink/directory marker. - X, - /// Pixel offset from the top edge of the cell; also used as image height - /// (with `change_drag_image`) or as a parent directory handle. - Y, + const data = cap.trailing(); - pub fn Type(comptime key: Option) type { - return switch (key) { - .t => EventType, - // The spec uses 32-bit signed or unsigned; we standardize on - // i32 because the location keys legitimately take -1 (drag - // leaves the window) and other keys never exceed i32 range. - .m, .i, .o, .x, .y, .X, .Y => i32, - }; - } + const metadata: []const u8, const payload: ?[]const u8 = result: { + const sep = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null }; + break :result .{ data[0..sep], data[sep + 1 .. data.len] }; + }; - pub fn read(comptime key: Option, metadata: []const u8) ?key.Type() { - const name = @tagName(key); + parser.command = .{ + .kitty_dnd_protocol = .{ + .metadata = metadata, + .payload = payload, + .terminator = .init(terminator_ch), + }, + }; - const value: []const u8 = value: { - var pos: usize = 0; - while (pos < metadata.len) { - while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; - if (pos >= metadata.len) return null; - - // Case-sensitive match: x and X must not be confused. - if (!std.mem.startsWith(u8, metadata[pos..], name)) { - pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null; - pos += 1; - continue; - } - pos += name.len; - - while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; - if (pos >= metadata.len) return null; - if (metadata[pos] != '=') return null; - - const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len; - const start = pos + 1; - break :value std.mem.trim(u8, metadata[start..end], &std.ascii.whitespace); - } - return null; - }; - - return switch (key) { - .t => .init(value), - .m, .i, .o, .x, .y, .X, .Y => std.fmt.parseInt(i32, value, 10) catch null, - }; - } -}; + return &parser.command; +} test "OSC 72: metadata only, no payload" { const testing = std.testing; @@ -192,134 +108,19 @@ test "OSC 72: metadata and non-empty payload" { try testing.expectEqualStrings("text/plain text/uri-list", cmd.kitty_dnd_protocol.payload.?); } -test "OSC 72: readOption .t valid event types" { +test "OSC 72: empty metadata with payload" { const testing = std.testing; var p: Parser = .init(testing.allocator); defer p.deinit(); - const cases = .{ - .{ "72;t=a", EventType.accept_drops }, - .{ "72;t=A", EventType.stop_accepting_drops }, - .{ "72;t=m", EventType.drop_move }, - .{ "72;t=M", EventType.drop_dropped }, - .{ "72;t=r", EventType.request_data }, - .{ "72;t=R", EventType.request_error }, - .{ "72;t=o", EventType.offer_drag }, - .{ "72;t=p", EventType.present_data }, - .{ "72;t=P", EventType.change_drag_image }, - .{ "72;t=e", EventType.drag_offer_event }, - .{ "72;t=E", EventType.drag_offer_error }, - .{ "72;t=k", EventType.uri_list_data }, - .{ "72;t=q", EventType.query }, - }; - - inline for (cases) |case| { - p.deinit(); - p = .init(testing.allocator); - for (case[0]) |ch| p.next(ch); - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expectEqual(case[1], cmd.kitty_dnd_protocol.readOption(.t).?); - } -} - -test "OSC 72: readOption .t unknown value returns null" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;t=z"; + const input = "72;;payload"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.t) == null); -} - -test "OSC 72: readOption integer keys" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;t=m:i=3:x=10:y=5:X=320:Y=200:o=1:m=0"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expectEqual(@as(i32, 3), cmd.kitty_dnd_protocol.readOption(.i).?); - try testing.expectEqual(@as(i32, 10), cmd.kitty_dnd_protocol.readOption(.x).?); - try testing.expectEqual(@as(i32, 5), cmd.kitty_dnd_protocol.readOption(.y).?); - try testing.expectEqual(@as(i32, 320), cmd.kitty_dnd_protocol.readOption(.X).?); - try testing.expectEqual(@as(i32, 200), cmd.kitty_dnd_protocol.readOption(.Y).?); - try testing.expectEqual(@as(i32, 1), cmd.kitty_dnd_protocol.readOption(.o).?); - try testing.expectEqual(@as(i32, 0), cmd.kitty_dnd_protocol.readOption(.m).?); -} - -test "OSC 72: readOption negative sentinel (-1 for drag leave)" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;t=m:x=-1:y=-1"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expectEqual(@as(i32, -1), cmd.kitty_dnd_protocol.readOption(.x).?); - try testing.expectEqual(@as(i32, -1), cmd.kitty_dnd_protocol.readOption(.y).?); -} - -test "OSC 72: readOption case-sensitive key matching" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - // x=10 must not be returned when asking for .X - const input = "72;x=10:Y=200"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expectEqual(@as(i32, 10), cmd.kitty_dnd_protocol.readOption(.x).?); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.X) == null); - try testing.expectEqual(@as(i32, 200), cmd.kitty_dnd_protocol.readOption(.Y).?); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.y) == null); -} - -test "OSC 72: readOption absent key returns null" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;t=a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.i) == null); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.x) == null); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.X) == null); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.m) == null); -} - -test "OSC 72: readOption malformed integer returns null" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;x=notanumber"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.x) == null); + try testing.expectEqualStrings("", cmd.kitty_dnd_protocol.metadata); + try testing.expectEqualStrings("payload", cmd.kitty_dnd_protocol.payload.?); } test "OSC 72: BEL terminator recorded" { @@ -335,29 +136,3 @@ test "OSC 72: BEL terminator recorded" { try testing.expect(cmd == .kitty_dnd_protocol); try testing.expect(cmd.kitty_dnd_protocol.terminator == .bel); } - -pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { - assert(parser.state == .@"72"); - - const cap = if (parser.capture) |*c| c else { - parser.state = .invalid; - return null; - }; - - const data = cap.trailing(); - - const metadata: []const u8, const payload: ?[]const u8 = result: { - const sep = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null }; - break :result .{ data[0..sep], data[sep + 1 .. data.len] }; - }; - - parser.command = .{ - .kitty_dnd_protocol = .{ - .metadata = metadata, - .payload = payload, - .terminator = .init(terminator_ch), - }, - }; - - return &parser.command; -}