terminal/kitty: drag and drop drop state machine

This commit is contained in:
Mitchell Hashimoto
2026-08-22 09:19:21 -07:00
parent 38746b8c14
commit 50f69b883c
5 changed files with 1410 additions and 281 deletions

View File

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

View File

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

View File

@@ -0,0 +1,715 @@
//! Kitty drag and drop protocol (OSC 72) state machine.
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = @import("../../quirks.zig").inlineAssert;
const osc = @import("../osc.zig");
const command = @import("dnd_command.zig");
const response = @import("dnd_response.zig");
const Metadata = command.Metadata;
const Operation = command.Operation;
const Operations = command.Operations;
const log = std.log.scoped(.kitty_dnd);
/// Maximum accumulated size of a client-sent MIME list (the accepted
/// list of a `t=m` status update). Matches kitty's MIME_LIST_SIZE_CAP.
pub const max_mime_list_bytes = 1024 * 1024;
/// Process one OSC 72 command received from the client, writing any
/// responses to the writer. Returns the state change the embedder may
/// need to act on, if any.
pub fn handleCommand(
slot: *?*State,
alloc: Allocator,
writer: *std.Io.Writer,
v: osc.Command.KittyDndProtocol,
) (Allocator.Error || std.Io.Writer.Error)!?Event {
const raw = Metadata.parse(v.metadata) orelse {
log.debug("dropping malformed OSC 72 metadata", .{});
return null;
};
// Chunk reassembly lives in the state, so before registration
// each command stands alone. The only legitimately chunked
// command before registration is t=a itself, which seeds the
// reassembly on its first chunk below.
const continuation = if (slot.*) |state| state.chunking.active else false;
const meta = if (slot.*) |state| state.chunking.apply(raw) else raw;
const payload = v.payload orelse "";
const t = meta.type orelse return null;
switch (t) {
.register => {
// x=1 declares the client's machine ID for remote drop
// support. We don't support remote drop yet, so accept and ignore.
if (meta.cell_x == 1) return null;
// Setup our state if we haven't already
const state = slot.* orelse state: {
const state = try State.create(alloc);
slot.* = state;
_ = state.chunking.apply(raw);
break :state state;
};
// Update the client ID on every registration
state.drop.client_id = meta.client_id;
return try state.register(
alloc,
payload,
continuation,
meta.more,
);
},
.unregister => {
const state = slot.* orelse return null;
state.destroy(alloc);
slot.* = null;
return .registration;
},
.status => {
const state = slot.* orelse return null;
return try state.acceptStatus(alloc, meta, payload);
},
.request => return try dataRequest(
slot.*,
alloc,
writer,
meta,
v.terminator,
),
// Drag source control. Enabling (x=1, with an optional
// machine ID payload) and disabling (x=2) offers are
// accepted and ignored since the terminal never requests a
// drag start. Offering a MIME list (x=0) for a new drag is
// refused since drag-out is not implemented.
.offer => if (meta.cell_x == 0) try refuseDragOut(
writer,
meta,
v.terminator,
),
// Drag-out data and start commands. A conforming client
// never sends these because the terminal never requests a
// drag start, but refuse them properly if one does.
.present, .start_drag => try refuseDragOut(
writer,
meta,
v.terminator,
),
// Responses to drag-out requests the terminal never makes.
.drag_event, .drag_error, .remote_data => {},
.query => try response.encode(
writer,
"t=q",
meta.client_id,
"",
.plain,
v.terminator,
),
// Only ever sent by the terminal. Ignore.
.drop, .request_error => {},
}
return null;
}
/// Handle a t=r data request or drop conclusion from the client.
/// Requests from an unregistered client (no state) get the same
/// errors kitty sends from its zeroed drop state.
fn dataRequest(
state: ?*State,
alloc: Allocator,
writer: *std.Io.Writer,
meta: Metadata,
terminator: osc.Terminator,
) (Allocator.Error || std.Io.Writer.Error)!?Event {
// Responses echo the registration's client ID, matching kitty.
const client_id = if (state) |s| s.drop.client_id else 0;
switch (command.Request.init(meta)) {
.conclude => |op| {
// The client is done with the drop: free the held data and
// report the operation it performed. Kitty hands that to
// the still-open OS drag session; ours ended at drop time
// (see dnd.zig), so the embedder decides what to do with
// it. A conclusion with no drop in progress is a no-op.
const s = state orelse return null;
const dropped = s.drop.dropped;
s.resetDrop(alloc);
return if (dropped) Event.concluded(op) else null;
},
.mime => |idx| {
const keys: response.RequestKeys = .{ .x = idx };
const items = (if (state) |s| s.drop.items else null) orelse {
try response.encodeError(
writer,
.drop,
keys,
client_id,
.ENOENT,
"no drop data available",
terminator,
);
return null;
};
if (idx < 1 or @as(usize, @intCast(idx)) > items.len) {
try response.encodeError(
writer,
.drop,
keys,
client_id,
.ENOENT,
"drop data request index out of bounds",
terminator,
);
return null;
}
var header_buf: [32]u8 = undefined;
const header = std.fmt.bufPrint(
&header_buf,
"t=r{f}",
.{keys},
) catch unreachable;
// The data chunks followed by the empty end-of-data
// message, which is how the client detects completion.
// An empty item is just the end-of-data message alone;
// clients treat a duplicate as a second completion.
const item = items[@intCast(idx - 1)];
if (item.data.len > 0) try response.encode(
writer,
header,
client_id,
item.data,
.base64,
terminator,
);
try response.encode(writer, header, client_id, "", .base64, terminator);
return null;
},
// Remote drop transfers (URI file contents and directory
// handles). We never advertise remote support (no X=1
// marker), so a conforming client never sends these.
.uri => |uri| try response.encodeError(
writer,
.drop,
.{ .x = uri.mime_idx, .y = uri.uri_idx },
client_id,
.EINVAL,
"remote drop data is not supported",
terminator,
),
.dir => |dir| try response.encodeError(
writer,
.drop,
.{ .x = dir.entry, .Y = dir.handle },
client_id,
.EINVAL,
"remote drop data is not supported",
terminator,
),
}
return null;
}
/// Refuse a drag-out command with an error, since ghostty does not
/// implement the terminal side of client-initiated drags yet.
fn refuseDragOut(
writer: *std.Io.Writer,
meta: Metadata,
terminator: osc.Terminator,
) std.Io.Writer.Error!void {
try response.encodeError(
writer,
.drag,
.{},
meta.client_id,
.EPERM,
"drag out is not supported by this terminal",
terminator,
);
}
/// A protocol state change an embedder may need to act on, returned by
/// `handleCommand` and delivered through the stream handler's
/// `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,
);
}
};

View File

@@ -0,0 +1,626 @@
//! End-to-end tests for the OSC 72 protocol state machine, validating
//! wire behavior against kitty's implementation (using kitty_tests/dnd.py as
//! an oracle for the expected bytes).
//!
//! It isn't normal for us to have dedicated test files but in this case
//! the dnd protocol is complicated enough that I wanted full e2e covering
//! the full machine.
const std = @import("std");
const testing = std.testing;
const osc = @import("../osc.zig");
const dnd = @import("dnd.zig");
/// A test harness holding the lazily allocated protocol state and an
/// output collector.
const Harness = struct {
state: ?*dnd.State = null,
output: std.Io.Writer.Allocating,
fn init() Harness {
return .{ .output = .init(testing.allocator) };
}
fn deinit(self: *Harness) void {
if (self.state) |state| state.destroy(testing.allocator);
self.output.deinit();
}
/// The registered state; asserts a client has registered.
fn registered(self: *Harness) *dnd.State {
return self.state.?;
}
/// Feed one client command, as it would arrive from the OSC parser,
/// returning the event the stream handler would pass to its effect.
fn command(self: *Harness, metadata: []const u8, payload: ?[]const u8) !?dnd.Event {
return try dnd.handleCommand(&self.state, testing.allocator, &self.output.writer, .{
.metadata = metadata,
.payload = payload,
.terminator = .st,
});
}
/// Consume and return the collected output.
fn consume(self: *Harness) []const u8 {
const written = self.output.written();
return written;
}
fn clear(self: *Harness) void {
self.output.clearRetainingCapacity();
}
fn expectOutput(self: *Harness, expected: []const u8) !void {
try testing.expectEqualStrings(expected, self.output.written());
self.clear();
}
};
test "dnd: query response" {
var h: Harness = .init();
defer h.deinit();
// Works without any registration, matching kitty, and allocates
// nothing.
_ = try h.command("t=q", null);
try h.expectOutput("\x1b]72;t=q\x1b\\");
try testing.expect(h.state == null);
}
test "dnd: query response echoes client id" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=q:i=31", null);
try h.expectOutput("\x1b]72;t=q:i=31\x1b\\");
}
test "dnd: register and unregister" {
var h: Harness = .init();
defer h.deinit();
try testing.expect(h.state == null);
// Registration allocates the state and reports it, with the
// declared MIME list readable from the state.
try testing.expect((try h.command("t=a", "text/plain text/uri-list")).? == .registration);
try h.expectOutput("");
{
var it = h.registered().registeredMimes();
try testing.expectEqualStrings("text/plain", it.next().?);
try testing.expectEqualStrings("text/uri-list", it.next().?);
try testing.expect(it.next() == null);
}
// Machine ID declaration is accepted and ignored.
try testing.expect((try h.command("t=a:x=1", "1:deadbeef")) == null);
try h.expectOutput("");
try testing.expect(h.state != null);
// Re-registration replaces the list.
try testing.expect((try h.command("t=a", "image/png")).? == .registration);
{
var it = h.registered().registeredMimes();
try testing.expectEqualStrings("image/png", it.next().?);
try testing.expect(it.next() == null);
}
// Registering without a list is the common case.
try testing.expect((try h.command("t=a", null)).? == .registration);
{
var it = h.registered().registeredMimes();
try testing.expect(it.next() == null);
}
// Unregistration frees it and reports the change.
try testing.expect((try h.command("t=A", null)).? == .registration);
try h.expectOutput("");
try testing.expect(h.state == null);
// Unregistering again changes nothing.
try testing.expect((try h.command("t=A", null)) == null);
try h.expectOutput("");
try testing.expect(h.state == null);
}
test "dnd: no state before registration" {
var h: Harness = .init();
defer h.deinit();
// State-dependent commands from an unregistered client allocate
// nothing; a data request gets the error kitty sends from its
// zeroed state.
_ = try h.command("t=m:o=1", "text/plain");
_ = try h.command("t=r", null);
try h.expectOutput("");
_ = try h.command("t=r:x=1", null);
try h.expectOutput(
"\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\",
);
try testing.expect(h.state == null);
}
test "dnd: move event carries position, operations, and mime list" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a", "text/plain");
try h.registered().dragMove(testing.allocator, &h.output.writer, .{
.cell_x = 5,
.cell_y = 3,
.pixel_x = 100,
.pixel_y = 60,
.operations = .{ .copy = true },
}, &.{ "text/plain", "text/uri-list" });
// Note the trailing space after every MIME entry, matching kitty.
try h.expectOutput(
"\x1b]72;t=m:x=5:y=3:X=100:Y=60:o=1:m=0;text/plain text/uri-list \x1b\\",
);
}
test "dnd: move event echoes registration client id" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a:i=7", "");
try h.registered().dragMove(testing.allocator, &h.output.writer, .{
.cell_x = 1,
.cell_y = 2,
.pixel_x = 8,
.pixel_y = 16,
.operations = .{ .copy = true, .move = true },
}, &.{"text/plain"});
try h.expectOutput("\x1b]72;t=m:x=1:y=2:X=8:Y=16:o=3:i=7:m=0;text/plain \x1b\\");
}
test "dnd: re-registration updates client id in place" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a:i=7", "");
const state = h.registered();
_ = try h.command("t=a:i=9", "");
// Same allocation, new client ID.
try testing.expect(h.state.? == state);
try testing.expectEqual(@as(u32, 9), state.drop.client_id);
}
test "dnd: mime list sent on every move" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a", "");
const ev: dnd.MoveEvent = .{
.cell_x = 0,
.cell_y = 0,
.pixel_x = 0,
.pixel_y = 0,
.operations = .{ .copy = true },
};
try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"});
h.clear();
// Kitty resends the list even when unchanged; clients depend on it.
try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"});
try h.expectOutput("\x1b]72;t=m:x=0:y=0:X=0:Y=0:o=1:m=0;text/plain \x1b\\");
}
test "dnd: leave event" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a", "");
try h.registered().dragMove(testing.allocator, &h.output.writer, .{
.cell_x = 0,
.cell_y = 0,
.pixel_x = 0,
.pixel_y = 0,
.operations = .{ .copy = true },
}, &.{"text/plain"});
h.clear();
try h.registered().dragLeave(testing.allocator, &h.output.writer);
try h.expectOutput("\x1b]72;t=m:x=-1:y=-1\x1b\\");
}
test "dnd: client acceptance recorded" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a", "");
try testing.expect(h.registered().clientAccepted() == null);
try testing.expect((try h.command("t=m:o=1", "text/plain")).? == .acceptance);
try h.expectOutput("");
try testing.expectEqual(dnd.Operation.copy, h.registered().clientAccepted().?);
// Rejection.
try testing.expect((try h.command("t=m:o=0", "")).? == .acceptance);
try testing.expectEqual(dnd.Operation.none, h.registered().clientAccepted().?);
}
test "dnd: chunked client acceptance" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a", "");
// Chunked accept: continuation metadata is ignored, the acceptance
// is pending until the final chunk.
try testing.expect((try h.command("t=m:o=2:m=1", "text/pl")) == null);
try testing.expect(h.registered().clientAccepted() == null);
try testing.expect((try h.command("t=m:m=1", "ain text")) == null);
try testing.expect((try h.command("t=m:m=0", "/html")).? == .acceptance);
try testing.expectEqual(dnd.Operation.move, h.registered().clientAccepted().?);
// The accumulated list was converted to NUL-separated entries.
try testing.expectEqualSlices(
u8,
"text/plain\x00text/html\x00",
h.registered().drop.accepted_mimes.items,
);
}
test "dnd: drop and data serving round trip" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a", "text/plain text/uri-list");
const ev: dnd.MoveEvent = .{
.cell_x = 4,
.cell_y = 2,
.pixel_x = 40,
.pixel_y = 20,
.operations = .{ .copy = true },
};
try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{
.{ .mime = "text/uri-list", .data = "file:///tmp/a.txt\r\n" },
.{ .mime = "text/plain", .data = "hello" },
});
try h.expectOutput(
"\x1b]72;t=M:x=4:y=2:X=40:Y=20:o=1:m=0;text/uri-list text/plain \x1b\\",
);
// Request the second MIME's data: base64 chunk plus the empty
// end-of-data message.
_ = try h.command("t=r:x=2", null);
try h.expectOutput(
"\x1b]72;t=r:x=2:m=0;aGVsbG8=\x1b\\" ++ "\x1b]72;t=r:x=2\x1b\\",
);
// Out-of-bounds request.
_ = try h.command("t=r:x=3", null);
try h.expectOutput(
"\x1b]72;t=R:x=3:m=0;ENOENT:drop data request index out of bounds\x1b\\",
);
// Conclude: the performed operation is reported, held data is
// freed, and further requests fail.
try testing.expectEqual(dnd.Event.concluded_copy, (try h.command("t=r:o=1", null)).?);
try h.expectOutput("");
try testing.expect((try h.command("t=r:o=1", null)) == null);
_ = try h.command("t=r:x=1", null);
try h.expectOutput(
"\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\",
);
}
test "dnd: empty item served as a single end-of-data message" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a", "");
try h.registered().dragDrop(testing.allocator, &h.output.writer, .{
.cell_x = 0,
.cell_y = 0,
.pixel_x = 0,
.pixel_y = 0,
.operations = .{ .copy = true },
}, &.{.{ .mime = "text/plain", .data = "" }});
h.clear();
// Kitty's oracle (test_empty_data) asserts exactly one message:
// the empty response is itself the end-of-data signal, and a
// duplicate would be a second completion to the client.
_ = try h.command("t=r:x=1", null);
try h.expectOutput("\x1b]72;t=r:x=1\x1b\\");
}
test "dnd: leave without hover sends nothing" {
var h: Harness = .init();
defer h.deinit();
// Client registered but no move was ever forwarded (e.g. it
// registered mid-drag): kitty only notifies hovered windows.
_ = try h.command("t=a", "");
try h.registered().dragLeave(testing.allocator, &h.output.writer);
try h.expectOutput("");
}
test "dnd: data request with no drop" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a", "");
_ = try h.command("t=r:x=1", null);
try h.expectOutput(
"\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\",
);
}
test "dnd: leave after drop is ignored" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a", "");
try h.registered().dragDrop(testing.allocator, &h.output.writer, .{
.cell_x = 0,
.cell_y = 0,
.pixel_x = 0,
.pixel_y = 0,
.operations = .{ .copy = true },
}, &.{.{ .mime = "text/plain", .data = "x" }});
h.clear();
// Some toolkits emit a leave for the drop itself; the held data
// must survive so the client can still fetch it.
try h.registered().dragLeave(testing.allocator, &h.output.writer);
try h.expectOutput("");
_ = try h.command("t=r:x=1", null);
try h.expectOutput(
"\x1b]72;t=r:x=1:m=0;eA==\x1b\\" ++ "\x1b]72;t=r:x=1\x1b\\",
);
}
test "dnd: new drag resets held drop data" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a", "");
const ev: dnd.MoveEvent = .{
.cell_x = 0,
.cell_y = 0,
.pixel_x = 0,
.pixel_y = 0,
.operations = .{ .copy = true },
};
try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{
.{ .mime = "text/plain", .data = "old" },
});
h.clear();
// A new drag entering resets the per-drag state including the held
// items from the unconcluded previous drop.
try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"});
h.clear();
_ = try h.command("t=r:x=1", null);
try h.expectOutput(
"\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\",
);
}
test "dnd: remote transfer requests refused" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a", "");
// URI file content request.
_ = try h.command("t=r:x=1:y=2", null);
try h.expectOutput(
"\x1b]72;t=R:x=1:y=2:m=0;EINVAL:remote drop data is not supported\x1b\\",
);
// Directory handle request.
_ = try h.command("t=r:Y=2:x=1", null);
try h.expectOutput(
"\x1b]72;t=R:x=1:Y=2:m=0;EINVAL:remote drop data is not supported\x1b\\",
);
}
test "dnd: drag out refused" {
var h: Harness = .init();
defer h.deinit();
// Enabling and disabling offers is accepted silently and allocates
// nothing.
_ = try h.command("t=o:x=1", null);
_ = try h.command("t=o:x=2", null);
try h.expectOutput("");
try testing.expect(h.state == null);
// Offering a drag is refused.
_ = try h.command("t=o:x=1", null);
_ = try h.command("t=o:o=3", "text/plain");
try h.expectOutput(
"\x1b]72;t=E:m=0;EPERM:drag out is not supported by this terminal\x1b\\",
);
// Starting a drag is refused, echoing the command's client id.
_ = try h.command("t=P:x=-1:i=9", null);
try h.expectOutput(
"\x1b]72;t=E:i=9:m=0;EPERM:drag out is not supported by this terminal\x1b\\",
);
try testing.expect(h.state == null);
}
test "dnd: unregister frees held drop data" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a", "");
try h.registered().dragDrop(testing.allocator, &h.output.writer, .{
.cell_x = 0,
.cell_y = 0,
.pixel_x = 0,
.pixel_y = 0,
.operations = .{ .copy = true },
}, &.{.{ .mime = "text/plain", .data = "x" }});
h.clear();
// The testing allocator would report the held data as leaked if
// unregistration didn't free the whole state.
_ = try h.command("t=A", null);
try testing.expect(h.state == null);
}
test "dnd: chunked registration reuses first chunk metadata" {
var h: Harness = .init();
defer h.deinit();
// Registration split over two chunks: the first chunk allocates
// the state and seeds chunk reassembly, so the continuation (which
// carries a different type) is still treated as the registration.
try testing.expect((try h.command("t=a:i=4:m=1", "text/pla")) == null);
try testing.expect(h.state != null);
try testing.expect((try h.command("t=q:m=0", "in")).? == .registration);
try h.expectOutput("");
try testing.expectEqual(@as(u32, 4), h.registered().drop.client_id);
{
var it = h.registered().registeredMimes();
try testing.expectEqualStrings("text/plain", it.next().?);
}
// A query after the chunked command completes works again.
_ = try h.command("t=q", null);
try h.expectOutput("\x1b]72;t=q\x1b\\");
}
test "dnd: malformed metadata ignored" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a:zz=1", "");
try h.expectOutput("");
try testing.expect(h.state == null);
// Command with no type is ignored, matching kitty (the spec's
// default of t=a is not honored by the reference implementation).
_ = try h.command("x=1", "");
try h.expectOutput("");
try testing.expect(h.state == null);
}
test "dnd: bel terminator echoed in responses" {
var h: Harness = .init();
defer h.deinit();
_ = try dnd.handleCommand(&h.state, testing.allocator, &h.output.writer, .{
.metadata = "t=q",
.payload = null,
.terminator = .bel,
});
try h.expectOutput("\x1b]72;t=q\x07");
}
test "dnd: kitten 0.47 conversation replay" {
// This replays a conversation recorded from the reference client
// (`kitten dnd --drop-anywhere=copy --drop text/plain:out.txt`,
// kitten 0.47.0) driven over a pty by a harness that sent exactly
// the bytes this engine produces. The kitten accepted the events,
// wrote the dropped payload to disk intact, and concluded; its
// client bytes are frozen here as an interop regression test.
var h: Harness = .init();
defer h.deinit();
// Startup: register with MIME list and machine ID, then the test
// harness reset (unregister both directions, re-register).
_ = try h.command("t=a:m=0", "text/uri-list text/plain");
_ = try h.command(
"t=a:x=1:m=0",
"1:5cff8247c477900a8727e2281fe890252f8848f87c224dd8dd7fb6303e94ddbd",
);
_ = try h.command("t=A", null);
try testing.expect(h.state == null);
_ = try h.command("t=o:x=2", null);
_ = try h.command("t=a:m=0", "text/uri-list text/plain");
_ = try h.command(
"t=a:x=1:m=0",
"1:5cff8247c477900a8727e2281fe890252f8848f87c224dd8dd7fb6303e94ddbd",
);
try h.expectOutput("");
try testing.expect(h.state != null);
// Native drag moves over the terminal and drops.
const ev: dnd.MoveEvent = .{
.cell_x = 2,
.cell_y = 1,
.pixel_x = 20,
.pixel_y = 18,
.operations = .{ .copy = true },
};
try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"});
try h.expectOutput("\x1b]72;t=m:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\");
// The kitten accepts as a copy of text/plain.
_ = try h.command("t=m:o=1:m=0", "text/plain");
try h.expectOutput("");
try testing.expectEqual(dnd.Operation.copy, h.registered().clientAccepted().?);
try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{
.{ .mime = "text/plain", .data = "hello from ghostty\n" },
});
try h.expectOutput("\x1b]72;t=M:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\");
// The kitten requests the data and concludes with a copy.
_ = try h.command("t=r:x=1", null);
try h.expectOutput(
"\x1b]72;t=r:x=1:m=0;aGVsbG8gZnJvbSBnaG9zdHR5Cg==\x1b\\" ++
"\x1b]72;t=r:x=1\x1b\\",
);
_ = try h.command("t=r:o=1", null);
try h.expectOutput("");
try testing.expect(h.registered().drop.items == null);
}
test "dnd: large data served in chunks" {
var h: Harness = .init();
defer h.deinit();
_ = try h.command("t=a", "");
// 3073 bytes: one full chunk plus one byte.
const data = [_]u8{'Z'} ** 3073;
try h.registered().dragDrop(testing.allocator, &h.output.writer, .{
.cell_x = 0,
.cell_y = 0,
.pixel_x = 0,
.pixel_y = 0,
.operations = .{ .copy = true },
}, &.{.{ .mime = "application/octet-stream", .data = &data }});
h.clear();
_ = try h.command("t=r:x=1", null);
const out = h.consume();
// First chunk is m=1 with 4096 base64 chars, second is m=0, and
// the final message is the bare end-of-data marker.
try testing.expect(std.mem.startsWith(u8, out, "\x1b]72;t=r:x=1:m=1;"));
try testing.expect(std.mem.indexOf(u8, out, "\x1b]72;t=r:x=1:m=0;") != null);
try testing.expect(std.mem.endsWith(u8, out, "\x1b]72;t=r:x=1\x1b\\"));
h.clear();
}
test "dnd: over-cap registration list never completes" {
var h: Harness = .init();
defer h.deinit();
// Matching kitty, a chunk that would exceed the cap is dropped and
// the registration is not reported, though the client stays
// registered (the state exists).
const big = try testing.allocator.alloc(u8, dnd.max_mime_list_bytes + 1);
defer testing.allocator.free(big);
@memset(big, 'a');
try testing.expect((try h.command("t=a", big)) == null);
try testing.expect(h.state != null);
{
var it = h.registered().registeredMimes();
try testing.expect(it.next() == null);
}
}

View File

@@ -1,5 +1,12 @@
//! Kitty's drag and drop protocol (OSC 72)
//! Specification: https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/
//!
//! This only captures the raw metadata and payload for the OSC. The
//! actual protocol grammar (metadata keys, event types, chunking) is
//! implemented in `terminal/kitty/dnd.zig` and its submodules, since the
//! protocol requires stateful handling that doesn't belong in the
//! stateless OSC parser.
//!
//! Specification: https://sw.kovidgoyal.net/kitty/dnd-protocol/
const std = @import("std");
@@ -9,143 +16,52 @@ const Parser = @import("../../osc.zig").Parser;
const Command = @import("../../osc.zig").Command;
const Terminator = @import("../../osc.zig").Terminator;
const log = std.log.scoped(.kitty_dnd_protocol);
pub const OSC = struct {
/// The raw metadata that was received. Parse individual values with `readOption`.
/// The raw metadata that was received. Parse with
/// `kitty.dnd.Metadata.parse`.
metadata: []const u8,
/// The raw payload. Its meaning and encoding depend on the event type (`t` key).
/// The raw payload. Its meaning and encoding depend on the event
/// type (`t` metadata key). Null when the OSC had no `;` after the
/// metadata; an empty payload is distinct from no payload.
payload: ?[]const u8,
/// The terminator used for this OSC, so any response can match it.
terminator: Terminator,
pub fn readOption(self: OSC, comptime key: Option) ?key.Type() {
return key.read(self.metadata);
/// We don't currently support encoding this to C in any way.
pub const C = void;
pub fn cval(_: OSC) C {
return {};
}
};
/// Values for the `t` (event type) metadata key.
pub const EventType = enum {
/// ('a') Terminal registers itself as willing to accept drops.
accept_drops,
/// ('A') Terminal unregisters itself; drops should no longer be forwarded.
stop_accepting_drops,
/// ('m') Pointer is moving over the terminal while a drag is in progress.
/// Carries `x`/`y` cursor position; -1 signals the drag left the window.
drop_move,
/// ('M') Items were dropped onto the terminal.
/// Carries `x`/`y` drop position and `i` (multiplexer session ID).
drop_dropped,
/// ('r') Terminal requests data for a specific MIME type from the drag source.
/// Carries `i` (multiplexer session ID) and `y` (1-based MIME type index).
request_data,
/// ('R') Error response to a `request_data` event.
request_error,
/// ('o') Terminal offers data for an outgoing drag (drag-out from terminal).
offer_drag,
/// ('p') Drag source presents the actual payload for a previously requested MIME type.
/// Carries `i` (multiplexer session ID), `o` (operation), and `m` (chunking flag).
present_data,
/// ('P') Replace the current drag image with a new one.
/// Payload is the image data; `X`/`Y` carry image dimensions in pixels.
change_drag_image,
/// ('e') Notification of an event on an outgoing drag offer (e.g., accepted or rejected).
drag_offer_event,
/// ('E') Error on an outgoing drag offer.
drag_offer_error,
/// ('k') URI list data delivered as part of a drag or clipboard transfer.
uri_list_data,
/// ('q') Query terminal capabilities related to the drag-and-drop protocol.
query,
pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
assert(parser.state == .@"72");
pub fn init(str: []const u8) ?EventType {
if (str.len != 1) return null;
return switch (str[0]) {
'a' => .accept_drops,
'A' => .stop_accepting_drops,
'm' => .drop_move,
'M' => .drop_dropped,
'r' => .request_data,
'R' => .request_error,
'o' => .offer_drag,
'p' => .present_data,
'P' => .change_drag_image,
'e' => .drag_offer_event,
'E' => .drag_offer_error,
'k' => .uri_list_data,
'q' => .query,
else => null,
};
}
};
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
/// Metadata keys defined by the protocol. Keys are case-sensitive: `x` and `X` are distinct.
pub const Option = enum {
/// Event type. Maps to `EventType`; present in every OSC 72 sequence.
t,
/// Chunking flag. `0` = this is the final (or only) chunk; `1` = more chunks follow.
m,
/// Multiplexer session ID. Echoed back in responses so a terminal multiplexer
/// (e.g. tmux) can route data to the correct pane.
i,
/// Drop operation. `0` = reject, `1` = copy, `2` = move, `3` = copy or move.
o,
/// Cursor column in cell units (zero-based). -1 signals the drag has left the window.
x,
/// Cursor row in cell units (zero-based). Also used as a 1-based MIME type index
/// in some events (e.g. `request_data`). -1 signals the drag has left the window.
y,
/// Pixel offset from the left edge of the cell; also used as image width
/// (with `change_drag_image`) or as a symlink/directory marker.
X,
/// Pixel offset from the top edge of the cell; also used as image height
/// (with `change_drag_image`) or as a parent directory handle.
Y,
const data = cap.trailing();
pub fn Type(comptime key: Option) type {
return switch (key) {
.t => EventType,
// The spec uses 32-bit signed or unsigned; we standardize on
// i32 because the location keys legitimately take -1 (drag
// leaves the window) and other keys never exceed i32 range.
.m, .i, .o, .x, .y, .X, .Y => i32,
};
}
const metadata: []const u8, const payload: ?[]const u8 = result: {
const sep = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null };
break :result .{ data[0..sep], data[sep + 1 .. data.len] };
};
pub fn read(comptime key: Option, metadata: []const u8) ?key.Type() {
const name = @tagName(key);
parser.command = .{
.kitty_dnd_protocol = .{
.metadata = metadata,
.payload = payload,
.terminator = .init(terminator_ch),
},
};
const value: []const u8 = value: {
var pos: usize = 0;
while (pos < metadata.len) {
while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1;
if (pos >= metadata.len) return null;
// Case-sensitive match: x and X must not be confused.
if (!std.mem.startsWith(u8, metadata[pos..], name)) {
pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null;
pos += 1;
continue;
}
pos += name.len;
while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1;
if (pos >= metadata.len) return null;
if (metadata[pos] != '=') return null;
const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len;
const start = pos + 1;
break :value std.mem.trim(u8, metadata[start..end], &std.ascii.whitespace);
}
return null;
};
return switch (key) {
.t => .init(value),
.m, .i, .o, .x, .y, .X, .Y => std.fmt.parseInt(i32, value, 10) catch null,
};
}
};
return &parser.command;
}
test "OSC 72: metadata only, no payload" {
const testing = std.testing;
@@ -192,134 +108,19 @@ test "OSC 72: metadata and non-empty payload" {
try testing.expectEqualStrings("text/plain text/uri-list", cmd.kitty_dnd_protocol.payload.?);
}
test "OSC 72: readOption .t valid event types" {
test "OSC 72: empty metadata with payload" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const cases = .{
.{ "72;t=a", EventType.accept_drops },
.{ "72;t=A", EventType.stop_accepting_drops },
.{ "72;t=m", EventType.drop_move },
.{ "72;t=M", EventType.drop_dropped },
.{ "72;t=r", EventType.request_data },
.{ "72;t=R", EventType.request_error },
.{ "72;t=o", EventType.offer_drag },
.{ "72;t=p", EventType.present_data },
.{ "72;t=P", EventType.change_drag_image },
.{ "72;t=e", EventType.drag_offer_event },
.{ "72;t=E", EventType.drag_offer_error },
.{ "72;t=k", EventType.uri_list_data },
.{ "72;t=q", EventType.query },
};
inline for (cases) |case| {
p.deinit();
p = .init(testing.allocator);
for (case[0]) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_dnd_protocol);
try testing.expectEqual(case[1], cmd.kitty_dnd_protocol.readOption(.t).?);
}
}
test "OSC 72: readOption .t unknown value returns null" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "72;t=z";
const input = "72;;payload";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_dnd_protocol);
try testing.expect(cmd.kitty_dnd_protocol.readOption(.t) == null);
}
test "OSC 72: readOption integer keys" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "72;t=m:i=3:x=10:y=5:X=320:Y=200:o=1:m=0";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_dnd_protocol);
try testing.expectEqual(@as(i32, 3), cmd.kitty_dnd_protocol.readOption(.i).?);
try testing.expectEqual(@as(i32, 10), cmd.kitty_dnd_protocol.readOption(.x).?);
try testing.expectEqual(@as(i32, 5), cmd.kitty_dnd_protocol.readOption(.y).?);
try testing.expectEqual(@as(i32, 320), cmd.kitty_dnd_protocol.readOption(.X).?);
try testing.expectEqual(@as(i32, 200), cmd.kitty_dnd_protocol.readOption(.Y).?);
try testing.expectEqual(@as(i32, 1), cmd.kitty_dnd_protocol.readOption(.o).?);
try testing.expectEqual(@as(i32, 0), cmd.kitty_dnd_protocol.readOption(.m).?);
}
test "OSC 72: readOption negative sentinel (-1 for drag leave)" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "72;t=m:x=-1:y=-1";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_dnd_protocol);
try testing.expectEqual(@as(i32, -1), cmd.kitty_dnd_protocol.readOption(.x).?);
try testing.expectEqual(@as(i32, -1), cmd.kitty_dnd_protocol.readOption(.y).?);
}
test "OSC 72: readOption case-sensitive key matching" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
// x=10 must not be returned when asking for .X
const input = "72;x=10:Y=200";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_dnd_protocol);
try testing.expectEqual(@as(i32, 10), cmd.kitty_dnd_protocol.readOption(.x).?);
try testing.expect(cmd.kitty_dnd_protocol.readOption(.X) == null);
try testing.expectEqual(@as(i32, 200), cmd.kitty_dnd_protocol.readOption(.Y).?);
try testing.expect(cmd.kitty_dnd_protocol.readOption(.y) == null);
}
test "OSC 72: readOption absent key returns null" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "72;t=a";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_dnd_protocol);
try testing.expect(cmd.kitty_dnd_protocol.readOption(.i) == null);
try testing.expect(cmd.kitty_dnd_protocol.readOption(.x) == null);
try testing.expect(cmd.kitty_dnd_protocol.readOption(.X) == null);
try testing.expect(cmd.kitty_dnd_protocol.readOption(.m) == null);
}
test "OSC 72: readOption malformed integer returns null" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "72;x=notanumber";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_dnd_protocol);
try testing.expect(cmd.kitty_dnd_protocol.readOption(.x) == null);
try testing.expectEqualStrings("", cmd.kitty_dnd_protocol.metadata);
try testing.expectEqualStrings("payload", cmd.kitty_dnd_protocol.payload.?);
}
test "OSC 72: BEL terminator recorded" {
@@ -335,29 +136,3 @@ test "OSC 72: BEL terminator recorded" {
try testing.expect(cmd == .kitty_dnd_protocol);
try testing.expect(cmd.kitty_dnd_protocol.terminator == .bel);
}
pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
assert(parser.state == .@"72");
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
const data = cap.trailing();
const metadata: []const u8, const payload: ?[]const u8 = result: {
const sep = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null };
break :result .{ data[0..sep], data[sep + 1 .. data.len] };
};
parser.command = .{
.kitty_dnd_protocol = .{
.metadata = metadata,
.payload = payload,
.terminator = .init(terminator_ch),
},
};
return &parser.command;
}