mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-02 05:39:05 +00:00
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:
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
192
src/terminal/osc/parsers/kitty_dnd_protocol.zig
Normal file
192
src/terminal/osc/parsers/kitty_dnd_protocol.zig
Normal 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;
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
Reference in New Issue
Block a user