mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
terminal/kitty: drag and drop command decoding
This commit is contained in:
@@ -5,6 +5,7 @@ const build_options = @import("terminal_options");
|
||||
const key = @import("kitty/key.zig");
|
||||
pub const clipboard = @import("kitty/clipboard.zig");
|
||||
pub const color = @import("kitty/color.zig");
|
||||
pub const dnd = @import("kitty/dnd.zig");
|
||||
pub const graphics = if (build_options.kitty_graphics) @import("kitty/graphics.zig") else struct {};
|
||||
|
||||
pub const KeyFlags = key.Flags;
|
||||
|
||||
73
src/terminal/kitty/dnd.zig
Normal file
73
src/terminal/kitty/dnd.zig
Normal file
@@ -0,0 +1,73 @@
|
||||
//! Kitty drag and drop protocol (OSC 72).
|
||||
//!
|
||||
//! Specification: https://sw.kovidgoyal.net/kitty/dnd-protocol/
|
||||
//! Reference implementation: kitty/dnd.c and kitty/screen.c in
|
||||
//! https://github.com/kovidgoyal/kitty (introduced in kitty 0.47).
|
||||
//!
|
||||
//! The protocol lets a program running in the terminal participate in
|
||||
//! native OS drag and drop. A client registers to accept drops (t=a);
|
||||
//! the terminal then forwards native drag movement (t=m) and drops
|
||||
//! (t=M) to it and serves the dropped data on request (t=r), instead
|
||||
//! of the traditional behavior of pasting dropped paths or text.
|
||||
//!
|
||||
//! The implementation is split into:
|
||||
//!
|
||||
//! * dnd_command.zig: metadata grammar and typed command decoding,
|
||||
//! including chunk reassembly. The grammar mirrors kitty's
|
||||
//! generated parser exactly.
|
||||
//! * dnd_response.zig: wire encoding for everything the terminal
|
||||
//! sends, mirroring kitty's send_payload_to_child chunking.
|
||||
//! * dnd_drop.zig: the per-terminal protocol state machine, driven
|
||||
//! by client OSCs on one side and native drag events from the
|
||||
//! embedder on the other.
|
||||
//!
|
||||
//! The wire behavior was validated against kitty's implementation
|
||||
//! (kitty_tests/dnd.py is the oracle), including its deviations from
|
||||
//! the published spec: the MIME list payload is sent on every move
|
||||
//! event with a trailing space after each entry, a missing `t` key
|
||||
//! ignores the command rather than defaulting to `a`, empty payloads
|
||||
//! omit the `;` and `m=` entirely, and registration survives a
|
||||
//! terminal reset (RIS clears only the chunk-reassembly flag).
|
||||
//!
|
||||
//! ## Divergences from kitty
|
||||
//!
|
||||
//! All are bounded-scope decisions, not accidents:
|
||||
//!
|
||||
//! * Dropped data is captured eagerly at drop time from a curated
|
||||
//! set of representations the embedder can serve (typically
|
||||
//! text/uri-list and text/plain), rather than fetched from the OS
|
||||
//! on demand. Consequently the native drag session concludes at
|
||||
//! drop time and the client's concluding operation (t=r with
|
||||
//! x=y=Y=0) only frees the held data, and kitty's 128-entry
|
||||
//! request queue and EMFILE overflow handling are unnecessary
|
||||
//! because requests are served synchronously in order.
|
||||
//! * The MIME list a client registers with (the t=a payload) is
|
||||
//! accepted but not forwarded to the OS, so exotic pasteboard
|
||||
//! types on macOS are not offered to clients.
|
||||
//! * Every client is treated as local: machine IDs (t=a:x=1) are
|
||||
//! accepted and ignored, responses never carry the X=1 remote
|
||||
//! marker, and remote file transfer requests (t=r with y or Y
|
||||
//! keys) are answered with EINVAL. A remote client (e.g. over
|
||||
//! ssh) can still receive text drops; only file-content transfer
|
||||
//! is unavailable.
|
||||
//! * The terminal never initiates drags (drag out): enabling offers
|
||||
//! (t=o:x=1) is tracked so the state is queryable, but the
|
||||
//! terminal never sends a drag start request, so a conforming
|
||||
//! client never offers a drag. Direct offers (t=o:x=0) and drag
|
||||
//! data/start commands (t=p, t=P) are refused with EPERM.
|
||||
//! * Responses echo the requesting command's terminator (ST or BEL)
|
||||
//! per ghostty convention; kitty always uses ST. Terminal-
|
||||
//! initiated events always use ST.
|
||||
|
||||
const dnd_command = @import("dnd_command.zig");
|
||||
|
||||
pub const EventType = dnd_command.EventType;
|
||||
pub const Metadata = dnd_command.Metadata;
|
||||
pub const Operation = dnd_command.Operation;
|
||||
pub const Operations = dnd_command.Operations;
|
||||
pub const Request = dnd_command.Request;
|
||||
pub const Chunking = dnd_command.Chunking;
|
||||
|
||||
test {
|
||||
_ = dnd_command;
|
||||
}
|
||||
435
src/terminal/kitty/dnd_command.zig
Normal file
435
src/terminal/kitty/dnd_command.zig
Normal file
@@ -0,0 +1,435 @@
|
||||
const std = @import("std");
|
||||
|
||||
/// Decoded OSC 72 metadata.
|
||||
pub const Metadata = struct {
|
||||
/// Event type (`t`). Null when the metadata had no `t` key; such
|
||||
/// commands parse successfully but are ignored, matching kitty.
|
||||
type: ?EventType = null,
|
||||
|
||||
/// Chunking flag (`m`): true when more chunks follow.
|
||||
more: bool = false,
|
||||
|
||||
/// Multiplexer client ID (`i`), echoed in every response so a
|
||||
/// terminal multiplexer can route responses to the correct client.
|
||||
client_id: u32 = 0,
|
||||
|
||||
/// Operation (`o`): meaning depends on the event type, commonly
|
||||
/// 0=none/reject, 1=copy, 2=move, 3=copy or move.
|
||||
operation: u32 = 0,
|
||||
|
||||
/// `x`, `y`, `X`, `Y` keys.
|
||||
cell_x: i32 = 0,
|
||||
cell_y: i32 = 0,
|
||||
pixel_x: i32 = 0,
|
||||
pixel_y: i32 = 0,
|
||||
|
||||
/// Parse raw OSC 72 metadata (the part before the first `;`).
|
||||
/// Returns null when malformed; callers should ignore the command,
|
||||
/// matching kitty which rejects the entire command on any error.
|
||||
pub fn parse(raw: []const u8) ?Metadata {
|
||||
var result: Metadata = .{};
|
||||
var pos: usize = 0;
|
||||
// The continue expression consumes the ':' separating a field
|
||||
// from the next; the body advances past the field itself.
|
||||
while (pos < raw.len) : (pos += 1) {
|
||||
// Single-character key.
|
||||
const key = raw[pos];
|
||||
pos += 1;
|
||||
switch (key) {
|
||||
't', 'm', 'i', 'o', 'x', 'y', 'X', 'Y' => {},
|
||||
else => return null,
|
||||
}
|
||||
|
||||
// '=' separator.
|
||||
if (pos >= raw.len) return null;
|
||||
if (raw[pos] != '=') return null;
|
||||
pos += 1;
|
||||
|
||||
// Value.
|
||||
if (pos >= raw.len) return null;
|
||||
switch (key) {
|
||||
't' => {
|
||||
result.type = std.enums.fromInt(
|
||||
EventType,
|
||||
raw[pos],
|
||||
) orelse return null;
|
||||
pos += 1;
|
||||
},
|
||||
|
||||
'm', 'i', 'o' => {
|
||||
const v = parseUnsigned(raw, &pos) orelse return null;
|
||||
switch (key) {
|
||||
'm' => result.more = v != 0,
|
||||
'i' => result.client_id = v,
|
||||
'o' => result.operation = v,
|
||||
else => unreachable,
|
||||
}
|
||||
},
|
||||
|
||||
'x', 'y', 'X', 'Y' => {
|
||||
const negative = raw[pos] == '-';
|
||||
if (negative) pos += 1;
|
||||
const unsigned = parseUnsigned(raw, &pos) orelse return null;
|
||||
// Matches kitty's cast of the u32 magnitude to i32,
|
||||
// which wraps rather than erroring on overflow.
|
||||
const magnitude: i32 = @bitCast(unsigned);
|
||||
const v = if (negative) 0 -% magnitude else magnitude;
|
||||
switch (key) {
|
||||
'x' => result.cell_x = v,
|
||||
'y' => result.cell_y = v,
|
||||
'X' => result.pixel_x = v,
|
||||
'Y' => result.pixel_y = v,
|
||||
else => unreachable,
|
||||
}
|
||||
},
|
||||
|
||||
else => unreachable,
|
||||
}
|
||||
|
||||
// Values are separated by ':'.
|
||||
if (pos >= raw.len) break;
|
||||
if (raw[pos] != ':') return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Parse an unsigned decimal value at `pos`, advancing it. At most
|
||||
/// 10 digits and at most maxInt(u32), matching kitty. Returns null
|
||||
/// when there are no digits or the value is too large.
|
||||
fn parseUnsigned(raw: []const u8, pos: *usize) ?u32 {
|
||||
const start = pos.*;
|
||||
var acc: u64 = 0;
|
||||
var i = start;
|
||||
while (i < raw.len and i < start + 10) : (i += 1) {
|
||||
const d = raw[i] -% '0';
|
||||
if (d > 9) break;
|
||||
acc = acc * 10 + d;
|
||||
}
|
||||
if (i == start) return null;
|
||||
pos.* = i;
|
||||
return std.math.cast(u32, acc) orelse null;
|
||||
}
|
||||
};
|
||||
|
||||
/// The event type, i.e. values for the `t` metadata key. A single OSC 72
|
||||
/// code is used for both directions of the protocol, so most types have
|
||||
/// one meaning when received by the terminal from a client and another
|
||||
/// when sent by the terminal to a client.
|
||||
///
|
||||
/// The `drop` and `request_response` types are only ever sent by the
|
||||
/// terminal; kitty parses but ignores them when received and we do the
|
||||
/// same.
|
||||
pub const EventType = enum(u8) {
|
||||
/// 'a': (recv) client registers to accept drops. With x=1 the payload
|
||||
/// is the client's machine ID for remote drop support instead.
|
||||
register = 'a',
|
||||
|
||||
/// 'A': (recv) client unregisters from accepting drops.
|
||||
unregister = 'A',
|
||||
|
||||
/// 'm': (recv) client reports acceptance status for the drag currently
|
||||
/// over the terminal: `o` is the chosen operation and the payload is
|
||||
/// the accepted MIME list. (send) pointer moved over the terminal
|
||||
/// during a drag, or with x=-1,y=-1 the drag left the window.
|
||||
status = 'm',
|
||||
|
||||
/// 'M': (send only) items were dropped onto the terminal.
|
||||
drop = 'M',
|
||||
|
||||
/// 'r': (recv) client requests drop data, or with x=y=Y=0 concludes
|
||||
/// the drop with `o` as the performed operation. (send) drop data
|
||||
/// response chunks.
|
||||
request = 'r',
|
||||
|
||||
/// 'R': (send only) error response to a data request.
|
||||
request_error = 'R',
|
||||
|
||||
/// 'o': (recv) drag source control: x=1 enables offering drags (payload
|
||||
/// optionally the client machine ID), x=2 disables, x=0 offers a MIME
|
||||
/// list for a new drag. (send) request that the client start a drag at
|
||||
/// the given position.
|
||||
offer = 'o',
|
||||
|
||||
/// 'p': (recv) pre-sent data for an offered drag: x>=0 is a 0-based
|
||||
/// MIME index, x<0 attaches drag image -x.
|
||||
present = 'p',
|
||||
|
||||
/// 'P': (recv) x=-1 starts the offered drag, x>=0 changes the drag
|
||||
/// image mid-drag.
|
||||
start_drag = 'P',
|
||||
|
||||
/// 'e': (recv) drag data for MIME index `y` of an in-progress drag.
|
||||
/// (send) drag status events (accepted, dropped, finished, ...).
|
||||
drag_event = 'e',
|
||||
|
||||
/// 'E': (recv) client aborts the whole drag (y=-1) or reports an error
|
||||
/// for MIME index `y`. (send) drag start response (OK or error).
|
||||
drag_error = 'E',
|
||||
|
||||
/// 'k': (recv) remote file data for a drag. (send) request for remote
|
||||
/// file data.
|
||||
remote_data = 'k',
|
||||
|
||||
/// 'q': (recv) query protocol support. (send) the query response.
|
||||
query = 'q',
|
||||
};
|
||||
|
||||
/// A drop operation. Values match the protocol's `o` key.
|
||||
pub const Operation = enum(u2) {
|
||||
none = 0,
|
||||
copy = 1,
|
||||
move = 2,
|
||||
|
||||
/// Convert a protocol `o` value the way kitty does: anything other
|
||||
/// than copy or move means none.
|
||||
pub fn fromProtocol(v: u32) Operation {
|
||||
return switch (v) {
|
||||
1 => .copy,
|
||||
2 => .move,
|
||||
else => .none,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// The set of operations allowed by a drag source, sent as a bitmask in
|
||||
/// the `o` key of move and drop events.
|
||||
pub const Operations = packed struct(u2) {
|
||||
copy: bool = false,
|
||||
move: bool = false,
|
||||
|
||||
pub fn protocolValue(self: Operations) u2 {
|
||||
return @bitCast(self);
|
||||
}
|
||||
};
|
||||
|
||||
/// A decoded `t=r` data request. The request form is disambiguated by
|
||||
/// which keys are non-zero, mirroring kitty's drop_process_queue.
|
||||
pub const Request = union(enum) {
|
||||
/// x=y=Y=0: the drop is concluded with the given operation.
|
||||
conclude: Operation,
|
||||
|
||||
/// Y=0, y=0, x!=0: request data for the 1-based MIME index x.
|
||||
mime: i32,
|
||||
|
||||
/// Y=0, y!=0: request the contents of the y'th (1-based) file in the
|
||||
/// text/uri-list MIME at 1-based index x. Remote drops only.
|
||||
uri: struct {
|
||||
mime_idx: i32,
|
||||
uri_idx: i32,
|
||||
},
|
||||
|
||||
/// Y!=0: request entry x (1-based) of directory handle Y, or close
|
||||
/// the handle when x=0. Remote drops only.
|
||||
dir: struct {
|
||||
handle: i32,
|
||||
entry: i32,
|
||||
},
|
||||
|
||||
pub fn init(meta: Metadata) Request {
|
||||
if (meta.pixel_y != 0) return .{ .dir = .{
|
||||
.handle = meta.pixel_y,
|
||||
.entry = meta.cell_x,
|
||||
} };
|
||||
if (meta.cell_y != 0) return .{ .uri = .{
|
||||
.mime_idx = meta.cell_x,
|
||||
.uri_idx = meta.cell_y,
|
||||
} };
|
||||
if (meta.cell_x != 0) return .{ .mime = meta.cell_x };
|
||||
return .{ .conclude = .fromProtocol(meta.operation) };
|
||||
}
|
||||
};
|
||||
|
||||
/// Chunk reassembly state, mirroring kitty's per-screen dnd_chunking.
|
||||
/// While a chunked command is in progress, the metadata of the first
|
||||
/// chunk is reused for all subsequent chunks; only the `more` flag is
|
||||
/// taken from each continuation.
|
||||
pub const Chunking = struct {
|
||||
active: bool = false,
|
||||
metadata: Metadata = .{},
|
||||
|
||||
/// Returns the effective metadata for a received chunk and updates
|
||||
/// the reassembly state.
|
||||
pub fn apply(self: *Chunking, meta: Metadata) Metadata {
|
||||
if (self.active) {
|
||||
var copy = self.metadata;
|
||||
copy.more = meta.more;
|
||||
self.active = meta.more;
|
||||
return copy;
|
||||
}
|
||||
|
||||
if (meta.more) {
|
||||
self.active = true;
|
||||
self.metadata = meta;
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
};
|
||||
|
||||
test "Metadata: empty" {
|
||||
const testing = std.testing;
|
||||
const meta = Metadata.parse("").?;
|
||||
try testing.expect(meta.type == null);
|
||||
try testing.expect(!meta.more);
|
||||
try testing.expectEqual(@as(u32, 0), meta.client_id);
|
||||
}
|
||||
|
||||
test "Metadata: all keys" {
|
||||
const testing = std.testing;
|
||||
const meta = Metadata.parse("t=m:m=1:i=3:o=2:x=10:y=5:X=320:Y=200").?;
|
||||
try testing.expectEqual(EventType.status, meta.type.?);
|
||||
try testing.expect(meta.more);
|
||||
try testing.expectEqual(@as(u32, 3), meta.client_id);
|
||||
try testing.expectEqual(@as(u32, 2), meta.operation);
|
||||
try testing.expectEqual(@as(i32, 10), meta.cell_x);
|
||||
try testing.expectEqual(@as(i32, 5), meta.cell_y);
|
||||
try testing.expectEqual(@as(i32, 320), meta.pixel_x);
|
||||
try testing.expectEqual(@as(i32, 200), meta.pixel_y);
|
||||
}
|
||||
|
||||
test "Metadata: all event types" {
|
||||
const testing = std.testing;
|
||||
const cases = .{
|
||||
.{ "t=a", EventType.register },
|
||||
.{ "t=A", EventType.unregister },
|
||||
.{ "t=m", EventType.status },
|
||||
.{ "t=M", EventType.drop },
|
||||
.{ "t=r", EventType.request },
|
||||
.{ "t=R", EventType.request_error },
|
||||
.{ "t=o", EventType.offer },
|
||||
.{ "t=p", EventType.present },
|
||||
.{ "t=P", EventType.start_drag },
|
||||
.{ "t=e", EventType.drag_event },
|
||||
.{ "t=E", EventType.drag_error },
|
||||
.{ "t=k", EventType.remote_data },
|
||||
.{ "t=q", EventType.query },
|
||||
};
|
||||
inline for (cases) |case| {
|
||||
try testing.expectEqual(case[1], Metadata.parse(case[0]).?.type.?);
|
||||
}
|
||||
}
|
||||
|
||||
test "Metadata: case-sensitive coordinate keys" {
|
||||
const testing = std.testing;
|
||||
const meta = Metadata.parse("x=10:Y=200").?;
|
||||
try testing.expectEqual(@as(i32, 10), meta.cell_x);
|
||||
try testing.expectEqual(@as(i32, 0), meta.cell_y);
|
||||
try testing.expectEqual(@as(i32, 0), meta.pixel_x);
|
||||
try testing.expectEqual(@as(i32, 200), meta.pixel_y);
|
||||
}
|
||||
|
||||
test "Metadata: negative coordinates" {
|
||||
const testing = std.testing;
|
||||
const meta = Metadata.parse("t=m:x=-1:y=-1").?;
|
||||
try testing.expectEqual(@as(i32, -1), meta.cell_x);
|
||||
try testing.expectEqual(@as(i32, -1), meta.cell_y);
|
||||
}
|
||||
|
||||
test "Metadata: malformed inputs rejected" {
|
||||
const testing = std.testing;
|
||||
// Unknown event type.
|
||||
try testing.expect(Metadata.parse("t=z") == null);
|
||||
// Unknown key.
|
||||
try testing.expect(Metadata.parse("z=1") == null);
|
||||
// Missing '=' mid-stream.
|
||||
try testing.expect(Metadata.parse("x10") == null);
|
||||
// No digits.
|
||||
try testing.expect(Metadata.parse("x=notanumber") == null);
|
||||
try testing.expect(Metadata.parse("x=-") == null);
|
||||
// Too large.
|
||||
try testing.expect(Metadata.parse("i=4294967296") == null);
|
||||
try testing.expect(Metadata.parse("i=99999999999") == null);
|
||||
// Garbage after value.
|
||||
try testing.expect(Metadata.parse("x=1z") == null);
|
||||
// No whitespace tolerance, matching kitty.
|
||||
try testing.expect(Metadata.parse("t=a: x=1") == null);
|
||||
try testing.expect(Metadata.parse("t = a") == null);
|
||||
// Truncated mid-construct, matching kitty's final-state check.
|
||||
// Nothing state-changing may be parsed out of these: e.g. "t=r:x="
|
||||
// must not be treated as a drop conclusion.
|
||||
try testing.expect(Metadata.parse("t") == null);
|
||||
try testing.expect(Metadata.parse("t=") == null);
|
||||
try testing.expect(Metadata.parse("x=") == null);
|
||||
try testing.expect(Metadata.parse("t=r:x=") == null);
|
||||
try testing.expect(Metadata.parse("x=1:t=") == null);
|
||||
}
|
||||
|
||||
test "Metadata: trailing separator accepted" {
|
||||
const testing = std.testing;
|
||||
|
||||
// Kitty's state machine accepts the metadata ending right after a
|
||||
// value separator.
|
||||
const meta = Metadata.parse("t=a:").?;
|
||||
try testing.expectEqual(EventType.register, meta.type.?);
|
||||
}
|
||||
|
||||
test "Metadata: u32 boundary accepted" {
|
||||
const testing = std.testing;
|
||||
const meta = Metadata.parse("i=4294967295").?;
|
||||
try testing.expectEqual(@as(u32, std.math.maxInt(u32)), meta.client_id);
|
||||
}
|
||||
|
||||
test "Request: classification" {
|
||||
const testing = std.testing;
|
||||
|
||||
// Conclude.
|
||||
{
|
||||
const r: Request = .init(Metadata.parse("t=r:o=2").?);
|
||||
try testing.expectEqual(Operation.move, r.conclude);
|
||||
}
|
||||
// Conclude with unknown operation is none (canceled).
|
||||
{
|
||||
const r: Request = .init(Metadata.parse("t=r:o=9").?);
|
||||
try testing.expectEqual(Operation.none, r.conclude);
|
||||
}
|
||||
// MIME data request.
|
||||
{
|
||||
const r: Request = .init(Metadata.parse("t=r:x=2").?);
|
||||
try testing.expectEqual(@as(i32, 2), r.mime);
|
||||
}
|
||||
// URI file request.
|
||||
{
|
||||
const r: Request = .init(Metadata.parse("t=r:x=1:y=3").?);
|
||||
try testing.expectEqual(@as(i32, 1), r.uri.mime_idx);
|
||||
try testing.expectEqual(@as(i32, 3), r.uri.uri_idx);
|
||||
}
|
||||
// Directory handle request.
|
||||
{
|
||||
const r: Request = .init(Metadata.parse("t=r:Y=2:x=1").?);
|
||||
try testing.expectEqual(@as(i32, 2), r.dir.handle);
|
||||
try testing.expectEqual(@as(i32, 1), r.dir.entry);
|
||||
}
|
||||
}
|
||||
|
||||
test "Chunking: reassembly reuses first chunk metadata" {
|
||||
const testing = std.testing;
|
||||
var chunking: Chunking = .{};
|
||||
|
||||
// First chunk starts reassembly.
|
||||
const first = chunking.apply(Metadata.parse("t=m:o=1:m=1").?);
|
||||
try testing.expect(chunking.active);
|
||||
try testing.expectEqual(EventType.status, first.type.?);
|
||||
try testing.expect(first.more);
|
||||
|
||||
// Continuation metadata is ignored except for `more`.
|
||||
const second = chunking.apply(Metadata.parse("t=q:o=2:m=1").?);
|
||||
try testing.expect(chunking.active);
|
||||
try testing.expectEqual(EventType.status, second.type.?);
|
||||
try testing.expectEqual(@as(u32, 1), second.operation);
|
||||
try testing.expect(second.more);
|
||||
|
||||
// Final chunk ends reassembly.
|
||||
const last = chunking.apply(Metadata.parse("t=q:m=0").?);
|
||||
try testing.expect(!chunking.active);
|
||||
try testing.expectEqual(EventType.status, last.type.?);
|
||||
try testing.expect(!last.more);
|
||||
}
|
||||
|
||||
test "Chunking: unchunked commands pass through" {
|
||||
const testing = std.testing;
|
||||
var chunking: Chunking = .{};
|
||||
const meta = chunking.apply(Metadata.parse("t=q").?);
|
||||
try testing.expect(!chunking.active);
|
||||
try testing.expectEqual(EventType.query, meta.type.?);
|
||||
}
|
||||
Reference in New Issue
Block a user