terminal: parse kitty drag and drop protocol (OSC 72)

Adds an OSC 72 parser following the kitty drag and drop protocol
specification. Parses metadata and payload into a Command.kitty_dnd_protocol
variant. Reassembly of chunked transfers and any action handling are
intentionally out of scope here; stream.zig logs the command as
unimplemented for now.

Includes a walkthrough document covering the design and each touched file.
This commit is contained in:
AJ Khullar
2026-06-15 17:41:05 +08:00
parent fdbf9ff3a3
commit 91c87e2cf3
5 changed files with 673 additions and 0 deletions

View File

@@ -156,6 +156,10 @@ pub const Command = union(Key) {
kitty_clipboard_protocol: KittyClipboardProtocol,
/// Kitty drag and drop protocol (OSC 72)
/// https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/
kitty_dnd_protocol: KittyDndProtocol,
/// OSC 3008. Hierarchical context signalling (UAPI spec).
/// https://uapi-group.org/specifications/specs/osc_context/
context_signal: parsers.context_signal.Command,
@@ -164,6 +168,8 @@ pub const Command = union(Key) {
pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC;
pub const KittyDndProtocol = parsers.kitty_dnd_protocol.OSC;
pub const Key = LibEnum(
lib.target,
// NOTE: Order matters, see LibEnum documentation.
@@ -192,6 +198,7 @@ pub const Command = union(Key) {
"conemu_comment",
"kitty_text_sizing",
"kitty_clipboard_protocol",
"kitty_dnd_protocol",
"context_signal",
},
);
@@ -344,6 +351,7 @@ pub const Parser = struct {
@"52",
@"55",
@"66",
@"72",
@"77",
@"104",
@"110",
@@ -421,6 +429,7 @@ pub const Parser = struct {
.show_desktop_notification,
.kitty_text_sizing,
.kitty_clipboard_protocol,
.kitty_dnd_protocol,
.context_signal,
=> {},
}
@@ -691,10 +700,16 @@ pub const Parser = struct {
.@"7" => switch (c) {
';' => self.captureTrailing(.fixed),
'2' => self.state = .@"72",
'7' => self.state = .@"77",
else => self.state = .invalid,
},
.@"72" => switch (c) {
';' => self.captureTrailing(.allocating),
else => self.state = .invalid,
},
.@"77" => switch (c) {
'7' => self.state = .@"777",
else => self.state = .invalid,
@@ -805,6 +820,8 @@ pub const Parser = struct {
.@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch),
.@"72" => parsers.kitty_dnd_protocol.parse(self, terminator_ch),
.@"77" => null,
.@"133" => parsers.semantic_prompt.parse(self, terminator_ch),

View File

@@ -9,6 +9,7 @@ pub const hyperlink = @import("parsers/hyperlink.zig");
pub const iterm2 = @import("parsers/iterm2.zig");
pub const kitty_clipboard_protocol = @import("parsers/kitty_clipboard_protocol.zig");
pub const kitty_color = @import("parsers/kitty_color.zig");
pub const kitty_dnd_protocol = @import("parsers/kitty_dnd_protocol.zig");
pub const kitty_text_sizing = @import("parsers/kitty_text_sizing.zig");
pub const mouse_shape = @import("parsers/mouse_shape.zig");
pub const osc9 = @import("parsers/osc9.zig");

View File

@@ -0,0 +1,192 @@
//! Kitty's drag and drop protocol (OSC 72)
//! Specification: https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/
//!
//! The OSC 72 escape has the form:
//!
//! OSC 72 ; metadata ; payload ST
//!
//! Where `metadata` is a colon separated list of `key=value` pairs and
//! `payload` is event-type specific (a space separated MIME list, base64
//! encoded binary data, or absent). The protocol is chunked at 4096 bytes
//! per payload; chunked transfers are signalled via the `m` metadata key.
//!
//! This file only parses individual OSC 72 events. Reassembly of chunked
//! transfers and event semantics (drag state machine, file I/O for the
//! remote machine subprotocols, etc.) are responsibilities of the
//! action/dispatch layer above.
const std = @import("std");
const assert = @import("../../../quirks.zig").inlineAssert;
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
/// the `readOption` method.
metadata: []const u8,
/// The raw payload. Its meaning depends on the event type (`t` key)
/// and may be base64 encoded.
payload: ?[]const u8,
/// The terminator used for the inbound OSC, recorded so that any
/// response we emit can match it.
terminator: Terminator,
pub fn readOption(self: OSC, comptime key: Option) ?key.Type() {
return key.read(self.metadata);
}
};
/// The set of values the `t` (event type) key may take. Each variant maps
/// to a single ASCII character per the spec.
pub const EventType = enum {
/// `t=a` — client begins accepting drops, payload is space-separated MIME list.
accept_drops,
/// `t=A` — client no longer wishes to accept drops.
stop_accepting_drops,
/// `t=m` — drop move event (terminal → client).
drop_move,
/// `t=M` — drop committed event (terminal → client).
drop_dropped,
/// `t=r` — request data (or response data, or end-of-drop sentinel).
request_data,
/// `t=R` — error response for a data request.
request_error,
/// `t=o` — start offering drags / drag-start gesture.
offer_drag,
/// `t=p` — pre-send data for an offered MIME type or drag image.
present_data,
/// `t=P` — change drag image or finalize start-drag.
change_drag_image,
/// `t=e` — drag offer status event (terminal → client).
drag_offer_event,
/// `t=E` — drag offer error or cancel.
drag_offer_error,
/// `t=k` — data for entries in the offered text/uri-list (drag-out).
uri_list_data,
/// `t=q` — query protocol support.
query,
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,
};
}
};
/// All metadata keys defined by the protocol. Keys are case-sensitive —
/// `x` and `X` (and `y`/`Y`, `m`/no `M` key but `M` is only a `t` value)
/// are distinct.
pub const Option = enum {
/// Event type.
t,
/// Chunking indicator: 0 or 1 (1 means more chunks follow).
m,
/// Multiplexer id, echoed in all replies for that session.
i,
/// Operation: 0 reject, 1 copy, 2 move, 3 either; also reused for
/// other meanings (e.g. opacity scaled by 1024 in drag images).
o,
/// Cell column (or generic 1-based index in request/data events).
x,
/// Cell row (or generic 1-based sub-index in request/data events).
y,
/// Pixel column (or flag, or directory handle depending on event).
X,
/// Pixel row (or directory handle, or image dimension depending on event).
Y,
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,
};
}
/// Look up an option in the metadata string. Returns null if the key
/// is absent, malformed, or its value cannot be parsed as the target
/// type. The default values from the spec are *not* substituted here;
/// callers should apply defaults via `orelse` so missing-vs-present
/// can still be distinguished where it matters.
pub fn read(comptime key: Option, metadata: []const u8) ?key.Type() {
const name = @tagName(key);
const value: []const u8 = value: {
var pos: usize = 0;
while (pos < metadata.len) {
// Skip whitespace between options.
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,
};
}
};
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;
}

View File

@@ -2058,6 +2058,7 @@ pub fn Stream(comptime H: type) type {
.conemu_run_process,
.kitty_text_sizing,
.kitty_clipboard_protocol,
.kitty_dnd_protocol,
.context_signal,
=> {
log.debug("unimplemented OSC callback: {}", .{cmd});