From 91c87e2cf324e113dbf4d6ff37f630210626092c Mon Sep 17 00:00:00 2001 From: AJ Khullar Date: Mon, 15 Jun 2026 17:41:05 +0800 Subject: [PATCH 1/4] 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. --- kitty-dnd-parser-walkthrough.md | 462 ++++++++++++++++++ src/terminal/osc.zig | 17 + src/terminal/osc/parsers.zig | 1 + .../osc/parsers/kitty_dnd_protocol.zig | 192 ++++++++ src/terminal/stream.zig | 1 + 5 files changed, 673 insertions(+) create mode 100644 kitty-dnd-parser-walkthrough.md create mode 100644 src/terminal/osc/parsers/kitty_dnd_protocol.zig diff --git a/kitty-dnd-parser-walkthrough.md b/kitty-dnd-parser-walkthrough.md new file mode 100644 index 000000000..1d9ba5a9d --- /dev/null +++ b/kitty-dnd-parser-walkthrough.md @@ -0,0 +1,462 @@ +# Kitty DnD Parser — Walkthrough + +A line-by-line walkthrough of the OSC 72 (kitty drag and drop protocol) +parser added to ghostty. + +## Big picture: how ghostty parses OSC + +Before any of these changes, ghostty's OSC handling looked like this: + +1. **`src/terminal/Parser.zig`** is the VT escape sequence parser. When it + sees `ESC ]` (OSC start), it begins feeding characters to an OSC + sub-parser. +2. **`src/terminal/osc.zig`** holds that sub-parser. It's a character-level + state machine that walks the digits of the OSC number (so `5522` + advances through states `@"5"` → `@"55"` → `@"552"` → `@"5522"`), then + captures the trailing data after the first `;`. +3. When the OSC terminator (`ST` or `BEL`) arrives, `Parser.end()` + dispatches to one of the helpers in **`src/terminal/osc/parsers/`** based + on the final state. Those helpers turn the raw captured bytes into a + typed `Command` variant. +4. **`src/terminal/stream.zig`** receives the final `Command` and decides + what handler to call (set window title, write to clipboard, etc.). + Unimplemented commands just get a debug log. + +The job here was to plug OSC 72 into this pipeline. The user-visible +result: when a TUI app sends `OSC 72 ; t=a:i=5 ; text/plain text/uri-list +ST`, ghostty parses it and produces a `Command.kitty_dnd_protocol` value +with `metadata="t=a:i=5"` and `payload="text/plain text/uri-list"`. The +actual action — actually accepting drops — is **not** wired yet. + +Four files were touched. Each is walked through below. + +--- + +## File 1: `src/terminal/osc/parsers/kitty_dnd_protocol.zig` (new, ~155 lines) + +This is the bulk of the work. It's modeled directly on the existing +`kitty_clipboard_protocol.zig`. + +### Imports + +```zig +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; +``` + +- `std`: Zig stdlib. +- `inlineAssert`: ghostty's own assert helper from `src/quirks.zig` + (probably wraps `std.debug.assert` with some compile-time behavior + tuning). Used to assert the parser state matches what's expected. +- `Parser`, `Command`, `Terminator`: types from the parent `osc.zig`. This + is a leaf parser, so these aren't defined here — we hand back data the + parent uses. + +### `pub const OSC = struct` (the output value) + +```zig +pub const OSC = struct { + metadata: []const u8, + payload: ?[]const u8, + terminator: Terminator, + pub fn readOption(self: OSC, comptime key: Option) ?key.Type() { + return key.read(self.metadata); + } +}; +``` + +This is what gets stored in the `Command` union when an OSC 72 is parsed. +Three fields: + +- **`metadata`**: the raw bytes between the first and second `;`. For + `OSC 72;t=m:x=5:y=3;text/plain ST`, this is `"t=m:x=5:y=3"`. Not + pre-parsed — kept as a slice into the capture buffer. +- **`payload`**: an *optional* slice of everything after the second `;`. + Optional because some OSCs have no payload at all (`OSC 72;t=A ST` — + stop accepting drops, no payload needed). +- **`terminator`**: was it terminated by `ST` (`ESC \`) or `BEL` (`0x07`)? + Recorded so when we respond, we match what the client used. This is a + convention across all of ghostty's OSC parsers. + +`readOption` is a thin wrapper around `Option.read`. It's syntactic sugar +so callers write `osc.readOption(.t)` instead of `Option.read(.t, +osc.metadata)`. The `comptime key: Option` parameter means the key is +known at compile time — that lets the return type vary per key (look at +`key.Type()`), giving you `EventType` for `.t` and `i32` for the rest. + +### `pub const EventType = enum` + +```zig +pub const EventType = enum { + accept_drops, // t=a + stop_accepting_drops, // t=A + drop_move, // t=m + drop_dropped, // t=M + request_data, // t=r + request_error, // t=R + offer_drag, // t=o + present_data, // t=p + change_drag_image, // t=P + drag_offer_event, // t=e + drag_offer_error, // t=E + uri_list_data, // t=k + query, // t=q + pub fn init(str: []const u8) ?EventType { ... } +}; +``` + +The typed representation of the `t` metadata key — every event type the +protocol defines. There are 13 of them, mapped from single ASCII +characters (case-sensitive: `m` and `M` are different events). + +`init` takes the string value of `t` (e.g. `"a"` or `"M"`), checks it's +exactly one char long, and switches on that char. Returns `null` if the +value is unknown or wrong length — null means "this key is not parseable +as EventType." + +### `pub const Option = enum` + +```zig +pub const Option = enum { + t, // event type + m, // chunking indicator (0 or 1) + i, // multiplexer id + o, // operation (0 reject, 1 copy, 2 move, 3 either; also reused for opacity etc.) + x, // cell column / 1-based index + y, // cell row / 1-based subindex + X, // pixel x / flag / handle + Y, // pixel y / handle / image height + ... +}; +``` + +The set of all metadata keys the protocol uses. **Case-sensitive**: `x` +and `X` are distinct keys with different meanings, which is one of the +protocol's subtle gotchas. Zig enum tag names are case-sensitive +identifiers, so this works naturally. + +#### `pub fn Type(comptime key: Option) type` + +```zig +pub fn Type(comptime key: Option) type { + return switch (key) { + .t => EventType, + .m, .i, .o, .x, .y, .X, .Y => i32, + }; +} +``` + +A compile-time function that returns a *type*. It says: "if you ask for +the value of key `.t`, you'll get back an `EventType`; for any other key, +you'll get back an `i32`." + +Why `i32`? The spec says "32-bit signed or unsigned integers". Signed was +chosen because some location keys legitimately take `-1` (e.g. +`x=-1, y=-1` means "the drag has left the window"). Using `i32` everywhere +avoids needing two types. + +#### `pub fn read(comptime key: Option, metadata: []const u8) ?key.Type()` + +The workhorse. Walks the `key=value:key=value:...` metadata string looking +for a specific key, returns the parsed value or null. + +Step by step: + +```zig +const name = @tagName(key); // "t", "x", "X", etc. +``` + +`@tagName` is a Zig builtin that returns the string form of an enum tag at +compile time. For `.X` it returns `"X"`. + +```zig +const value: []const u8 = value: { + var pos: usize = 0; + while (pos < metadata.len) { +``` + +A labeled block (`value: { ... }`) is used so we can `break :value ` +from inside the loop. Cursor `pos` tracks where we are in the metadata. + +```zig + while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; + if (pos >= metadata.len) return null; +``` + +Skip any whitespace at the start of an option. The spec doesn't explicitly +require this but the clipboard parser does it and it's harmless. + +```zig + if (!std.mem.startsWith(u8, metadata[pos..], name)) { + pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null; + pos += 1; + continue; + } +``` + +Try to match our key name at the current position. **Critical for this +protocol:** `std.mem.startsWith` is case-sensitive, so `"x"` will not +match `"X="`. If we don't match, jump past the next `:` to the start of +the next option. If there's no next `:`, bail (key not present). + +```zig + 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; +``` + +The key matched. Skip past it, skip whitespace, expect `=`. If not, this +isn't actually a `key=value` pair — bail. + +```zig + 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); +``` + +The value runs from just after `=` to either the next `:` or end of +metadata. Trim whitespace and `break :value` with the slice. This slice +is still backed by the parser's capture buffer — no allocation. + +```zig +return switch (key) { + .t => .init(value), + .m, .i, .o, .x, .y, .X, .Y => std.fmt.parseInt(i32, value, 10) catch null, +}; +``` + +Once we have the value string, parse it according to the key's type. For +`.t`, hand to `EventType.init`. For integers, `std.fmt.parseInt` does the +work and returns null on garbage. + +### `pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command` + +```zig +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; +} +``` + +This is what `osc.zig` calls when it sees the OSC has finished and the +state machine is in `.@"72"`. + +- `assert(parser.state == .@"72")`: sanity check — we should only ever be + called for an OSC 72. +- Pull the capture buffer (the bytes between the OSC number and the + terminator). +- Split on the first `;` — everything before is metadata, everything + after is payload. If there's no `;`, the entire thing is metadata and + payload is null. +- Stuff the result into the parser's `command` union, marking it as our + variant. +- Return a pointer back to the union. Caller (the stream) reads it. + +The destructuring syntax `const a: T1, const b: T2 = ...` is Zig's +tuple-style multiple assignment. + +--- + +## File 2: `src/terminal/osc/parsers.zig` (1 line added) + +```zig +pub const kitty_dnd_protocol = @import("parsers/kitty_dnd_protocol.zig"); +``` + +This module is just an index — it re-exports all the parser submodules so +`osc.zig` can write `parsers.kitty_dnd_protocol.parse(...)`. + +--- + +## File 3: `src/terminal/osc.zig` (small edits) + +### Add to the `Command` union (around line ~157) + +```zig +kitty_clipboard_protocol: KittyClipboardProtocol, + +/// Kitty drag and drop protocol (OSC 72) +/// https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/ +kitty_dnd_protocol: KittyDndProtocol, +``` + +`Command` is a tagged union — one variant per OSC type. A new variant is +added. Its payload type is `KittyDndProtocol`, declared right below. + +### Type alias + +```zig +pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC; + +pub const KittyDndProtocol = parsers.kitty_dnd_protocol.OSC; +``` + +So the union field type has a friendly name. +`parsers.kitty_dnd_protocol.OSC` is the struct from file 1. + +### Add to the `Key` enum list + +```zig +"kitty_clipboard_protocol", +"kitty_dnd_protocol", +"context_signal", +``` + +`Key` is generated by ghostty's `LibEnum` helper, which produces an enum +from a string list (deterministic ordering for ABI stability across the +C/Zig boundary). Order matters per the comment in the file. Adding the +tag here keeps the union and the key enum in sync. + +### Add to `reset()` switch + +```zig +.kitty_text_sizing, +.kitty_clipboard_protocol, +.kitty_dnd_protocol, +.context_signal, +=> {}, +``` + +`reset()` deinits any allocated memory a command variant owns. Most +variants (including this one) own no allocations — their slices point +into the parser's capture buffer, which the parser itself manages. So +this lands in the `=> {}` (do-nothing) arm. The switch must be exhaustive +across all `Key` tags, so it has to land somewhere — and "do nothing" is +correct. + +### State machine — add `@"72"` and extend `@"7"` + +In the `State` enum: + +```zig +@"66", +@"72", +@"77", +``` + +In the `next()` function, extending the existing `@"7"` handler: + +```zig +.@"7" => switch (c) { + ';' => self.captureTrailing(.fixed), // OSC 7 alone = report_pwd + '2' => self.state = .@"72", // NEW: OSC 72 + '7' => self.state = .@"77", // OSC 777 bridge + else => self.state = .invalid, +}, + +.@"72" => switch (c) { + ';' => self.captureTrailing(.allocating), + else => self.state = .invalid, +}, +``` + +What this is doing: + +- In state `@"7"` after seeing the `7` digit. If the next char is `2`, + transition to `@"72"`. (Previously `@"7"` only accepted `;` for OSC 7 + and `7` for the OSC 77 bridge.) +- In state `@"72"`, the only valid next char is `;`, which kicks off + **capturing** the trailing data. +- `captureTrailing(.allocating)` chooses **allocating mode** for the + capture. The default fixed buffer is 2048 bytes, but the protocol + allows payloads up to 4096 bytes per chunk (after base64), plus + metadata. Allocating mode grows as needed up to whatever the allocator + gives. If no allocator is configured, it falls back gracefully to the + fixed buffer. + +### Dispatch in `end()` + +```zig +.@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch), + +.@"72" => parsers.kitty_dnd_protocol.parse(self, terminator_ch), + +.@"77" => null, +``` + +When the parser sees the OSC terminator, `end()` looks at the final state +and hands off to the right helper. For state `@"72"`, that's the `parse` +function we wrote in file 1. + +--- + +## File 4: `src/terminal/stream.zig` (added to unimplemented list) + +```zig +.kitty_text_sizing, +.kitty_clipboard_protocol, +.kitty_dnd_protocol, +.context_signal, +=> { + log.debug("unimplemented OSC callback: {}", .{cmd}); +}, +``` + +`stream.zig` is downstream of the parser — when a fully-parsed `Command` +arrives, it dispatches to a real handler (set the title, do the clipboard +op, etc.). For this protocol, there is no handler yet (intentional — that's +the next step). So it lands in the "unimplemented" arm, which just logs. + +**The compiler enforces exhaustive switches**, so adding a new union +variant without adding it to this switch would have been a build error. +It had to go somewhere; this is the most honest place. + +--- + +## Design decisions worth understanding + +1. **Thin parser, lazy field reads.** Kept raw `metadata` and `payload` + slices and provided a `readOption` accessor. Alternative: eagerly parse + every key into a struct at parse time. The lazy approach matches + `kitty_clipboard_protocol`, lets callers pay parse cost only for keys + they care about, and is dead simple. Downside: every `readOption` call + scans the metadata string. Tradeoff is fine because metadata is tiny + (~30 chars typical). + +2. **No chunking reassembly.** The protocol says payloads >4096 bytes get + chunked across multiple OSC 72 messages. The parser deliberately does + **not** reassemble these — each chunk surfaces as its own `Command`. + Why: chunking is stateful across multiple escape codes, which is the + *action layer's* concern (which buffer to append into, what to do when + out of order, how to handle errors mid-stream). Putting that in the + OSC parser would conflate two responsibilities. + +3. **No semantic validation.** The structure is parsed but it isn't + validated that e.g. a `t=m` event actually has a sensible `x/y`, or + that a `t=q` query has the keys it should. The spec lets us be lax, + and the action layer is better-positioned to validate against + context. + +4. **`i32` for all integer keys.** The spec says "32-bit signed or + unsigned." Signed was chosen because `-1` is a real sentinel value + (drag leave, drag cancel). Unsigned would force casting everywhere. + +5. **No reset of capture in `parse`.** The parser's main loop handles + capture lifecycle — `reset()` (in `osc.zig`) cleans up the capture + between OSCs. The parse function just consumes `cap.trailing()` and + returns. diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 36cfd7f82..9304f633c 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -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), diff --git a/src/terminal/osc/parsers.zig b/src/terminal/osc/parsers.zig index a56b38e88..8cb91fb94 100644 --- a/src/terminal/osc/parsers.zig +++ b/src/terminal/osc/parsers.zig @@ -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"); diff --git a/src/terminal/osc/parsers/kitty_dnd_protocol.zig b/src/terminal/osc/parsers/kitty_dnd_protocol.zig new file mode 100644 index 000000000..1d99dc7b7 --- /dev/null +++ b/src/terminal/osc/parsers/kitty_dnd_protocol.zig @@ -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; +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 9771334f9..8453b3453 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -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}); From dd6c09bcf8b3e60d49883c55e042332a0bea4074 Mon Sep 17 00:00:00 2001 From: AJ Khullar Date: Tue, 16 Jun 2026 19:28:50 +0800 Subject: [PATCH 2/4] terminal: clean up OSC 72 parser comments and add tests Trims over-commented fields and enum variants to match the repo baseline (kitty_clipboard_protocol style). Adds 11 tests covering metadata/payload parsing, all EventType values, integer keys, negative sentinels, case-sensitive key matching, and BEL terminator recording. Removes the development walkthrough document. --- kitty-dnd-parser-walkthrough.md | 462 ------------------ src/terminal/osc.zig | 1 - .../osc/parsers/kitty_dnd_protocol.zig | 247 ++++++++-- 3 files changed, 194 insertions(+), 516 deletions(-) delete mode 100644 kitty-dnd-parser-walkthrough.md diff --git a/kitty-dnd-parser-walkthrough.md b/kitty-dnd-parser-walkthrough.md deleted file mode 100644 index 1d9ba5a9d..000000000 --- a/kitty-dnd-parser-walkthrough.md +++ /dev/null @@ -1,462 +0,0 @@ -# Kitty DnD Parser — Walkthrough - -A line-by-line walkthrough of the OSC 72 (kitty drag and drop protocol) -parser added to ghostty. - -## Big picture: how ghostty parses OSC - -Before any of these changes, ghostty's OSC handling looked like this: - -1. **`src/terminal/Parser.zig`** is the VT escape sequence parser. When it - sees `ESC ]` (OSC start), it begins feeding characters to an OSC - sub-parser. -2. **`src/terminal/osc.zig`** holds that sub-parser. It's a character-level - state machine that walks the digits of the OSC number (so `5522` - advances through states `@"5"` → `@"55"` → `@"552"` → `@"5522"`), then - captures the trailing data after the first `;`. -3. When the OSC terminator (`ST` or `BEL`) arrives, `Parser.end()` - dispatches to one of the helpers in **`src/terminal/osc/parsers/`** based - on the final state. Those helpers turn the raw captured bytes into a - typed `Command` variant. -4. **`src/terminal/stream.zig`** receives the final `Command` and decides - what handler to call (set window title, write to clipboard, etc.). - Unimplemented commands just get a debug log. - -The job here was to plug OSC 72 into this pipeline. The user-visible -result: when a TUI app sends `OSC 72 ; t=a:i=5 ; text/plain text/uri-list -ST`, ghostty parses it and produces a `Command.kitty_dnd_protocol` value -with `metadata="t=a:i=5"` and `payload="text/plain text/uri-list"`. The -actual action — actually accepting drops — is **not** wired yet. - -Four files were touched. Each is walked through below. - ---- - -## File 1: `src/terminal/osc/parsers/kitty_dnd_protocol.zig` (new, ~155 lines) - -This is the bulk of the work. It's modeled directly on the existing -`kitty_clipboard_protocol.zig`. - -### Imports - -```zig -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; -``` - -- `std`: Zig stdlib. -- `inlineAssert`: ghostty's own assert helper from `src/quirks.zig` - (probably wraps `std.debug.assert` with some compile-time behavior - tuning). Used to assert the parser state matches what's expected. -- `Parser`, `Command`, `Terminator`: types from the parent `osc.zig`. This - is a leaf parser, so these aren't defined here — we hand back data the - parent uses. - -### `pub const OSC = struct` (the output value) - -```zig -pub const OSC = struct { - metadata: []const u8, - payload: ?[]const u8, - terminator: Terminator, - pub fn readOption(self: OSC, comptime key: Option) ?key.Type() { - return key.read(self.metadata); - } -}; -``` - -This is what gets stored in the `Command` union when an OSC 72 is parsed. -Three fields: - -- **`metadata`**: the raw bytes between the first and second `;`. For - `OSC 72;t=m:x=5:y=3;text/plain ST`, this is `"t=m:x=5:y=3"`. Not - pre-parsed — kept as a slice into the capture buffer. -- **`payload`**: an *optional* slice of everything after the second `;`. - Optional because some OSCs have no payload at all (`OSC 72;t=A ST` — - stop accepting drops, no payload needed). -- **`terminator`**: was it terminated by `ST` (`ESC \`) or `BEL` (`0x07`)? - Recorded so when we respond, we match what the client used. This is a - convention across all of ghostty's OSC parsers. - -`readOption` is a thin wrapper around `Option.read`. It's syntactic sugar -so callers write `osc.readOption(.t)` instead of `Option.read(.t, -osc.metadata)`. The `comptime key: Option` parameter means the key is -known at compile time — that lets the return type vary per key (look at -`key.Type()`), giving you `EventType` for `.t` and `i32` for the rest. - -### `pub const EventType = enum` - -```zig -pub const EventType = enum { - accept_drops, // t=a - stop_accepting_drops, // t=A - drop_move, // t=m - drop_dropped, // t=M - request_data, // t=r - request_error, // t=R - offer_drag, // t=o - present_data, // t=p - change_drag_image, // t=P - drag_offer_event, // t=e - drag_offer_error, // t=E - uri_list_data, // t=k - query, // t=q - pub fn init(str: []const u8) ?EventType { ... } -}; -``` - -The typed representation of the `t` metadata key — every event type the -protocol defines. There are 13 of them, mapped from single ASCII -characters (case-sensitive: `m` and `M` are different events). - -`init` takes the string value of `t` (e.g. `"a"` or `"M"`), checks it's -exactly one char long, and switches on that char. Returns `null` if the -value is unknown or wrong length — null means "this key is not parseable -as EventType." - -### `pub const Option = enum` - -```zig -pub const Option = enum { - t, // event type - m, // chunking indicator (0 or 1) - i, // multiplexer id - o, // operation (0 reject, 1 copy, 2 move, 3 either; also reused for opacity etc.) - x, // cell column / 1-based index - y, // cell row / 1-based subindex - X, // pixel x / flag / handle - Y, // pixel y / handle / image height - ... -}; -``` - -The set of all metadata keys the protocol uses. **Case-sensitive**: `x` -and `X` are distinct keys with different meanings, which is one of the -protocol's subtle gotchas. Zig enum tag names are case-sensitive -identifiers, so this works naturally. - -#### `pub fn Type(comptime key: Option) type` - -```zig -pub fn Type(comptime key: Option) type { - return switch (key) { - .t => EventType, - .m, .i, .o, .x, .y, .X, .Y => i32, - }; -} -``` - -A compile-time function that returns a *type*. It says: "if you ask for -the value of key `.t`, you'll get back an `EventType`; for any other key, -you'll get back an `i32`." - -Why `i32`? The spec says "32-bit signed or unsigned integers". Signed was -chosen because some location keys legitimately take `-1` (e.g. -`x=-1, y=-1` means "the drag has left the window"). Using `i32` everywhere -avoids needing two types. - -#### `pub fn read(comptime key: Option, metadata: []const u8) ?key.Type()` - -The workhorse. Walks the `key=value:key=value:...` metadata string looking -for a specific key, returns the parsed value or null. - -Step by step: - -```zig -const name = @tagName(key); // "t", "x", "X", etc. -``` - -`@tagName` is a Zig builtin that returns the string form of an enum tag at -compile time. For `.X` it returns `"X"`. - -```zig -const value: []const u8 = value: { - var pos: usize = 0; - while (pos < metadata.len) { -``` - -A labeled block (`value: { ... }`) is used so we can `break :value ` -from inside the loop. Cursor `pos` tracks where we are in the metadata. - -```zig - while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; - if (pos >= metadata.len) return null; -``` - -Skip any whitespace at the start of an option. The spec doesn't explicitly -require this but the clipboard parser does it and it's harmless. - -```zig - if (!std.mem.startsWith(u8, metadata[pos..], name)) { - pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null; - pos += 1; - continue; - } -``` - -Try to match our key name at the current position. **Critical for this -protocol:** `std.mem.startsWith` is case-sensitive, so `"x"` will not -match `"X="`. If we don't match, jump past the next `:` to the start of -the next option. If there's no next `:`, bail (key not present). - -```zig - 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; -``` - -The key matched. Skip past it, skip whitespace, expect `=`. If not, this -isn't actually a `key=value` pair — bail. - -```zig - 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); -``` - -The value runs from just after `=` to either the next `:` or end of -metadata. Trim whitespace and `break :value` with the slice. This slice -is still backed by the parser's capture buffer — no allocation. - -```zig -return switch (key) { - .t => .init(value), - .m, .i, .o, .x, .y, .X, .Y => std.fmt.parseInt(i32, value, 10) catch null, -}; -``` - -Once we have the value string, parse it according to the key's type. For -`.t`, hand to `EventType.init`. For integers, `std.fmt.parseInt` does the -work and returns null on garbage. - -### `pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command` - -```zig -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; -} -``` - -This is what `osc.zig` calls when it sees the OSC has finished and the -state machine is in `.@"72"`. - -- `assert(parser.state == .@"72")`: sanity check — we should only ever be - called for an OSC 72. -- Pull the capture buffer (the bytes between the OSC number and the - terminator). -- Split on the first `;` — everything before is metadata, everything - after is payload. If there's no `;`, the entire thing is metadata and - payload is null. -- Stuff the result into the parser's `command` union, marking it as our - variant. -- Return a pointer back to the union. Caller (the stream) reads it. - -The destructuring syntax `const a: T1, const b: T2 = ...` is Zig's -tuple-style multiple assignment. - ---- - -## File 2: `src/terminal/osc/parsers.zig` (1 line added) - -```zig -pub const kitty_dnd_protocol = @import("parsers/kitty_dnd_protocol.zig"); -``` - -This module is just an index — it re-exports all the parser submodules so -`osc.zig` can write `parsers.kitty_dnd_protocol.parse(...)`. - ---- - -## File 3: `src/terminal/osc.zig` (small edits) - -### Add to the `Command` union (around line ~157) - -```zig -kitty_clipboard_protocol: KittyClipboardProtocol, - -/// Kitty drag and drop protocol (OSC 72) -/// https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/ -kitty_dnd_protocol: KittyDndProtocol, -``` - -`Command` is a tagged union — one variant per OSC type. A new variant is -added. Its payload type is `KittyDndProtocol`, declared right below. - -### Type alias - -```zig -pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC; - -pub const KittyDndProtocol = parsers.kitty_dnd_protocol.OSC; -``` - -So the union field type has a friendly name. -`parsers.kitty_dnd_protocol.OSC` is the struct from file 1. - -### Add to the `Key` enum list - -```zig -"kitty_clipboard_protocol", -"kitty_dnd_protocol", -"context_signal", -``` - -`Key` is generated by ghostty's `LibEnum` helper, which produces an enum -from a string list (deterministic ordering for ABI stability across the -C/Zig boundary). Order matters per the comment in the file. Adding the -tag here keeps the union and the key enum in sync. - -### Add to `reset()` switch - -```zig -.kitty_text_sizing, -.kitty_clipboard_protocol, -.kitty_dnd_protocol, -.context_signal, -=> {}, -``` - -`reset()` deinits any allocated memory a command variant owns. Most -variants (including this one) own no allocations — their slices point -into the parser's capture buffer, which the parser itself manages. So -this lands in the `=> {}` (do-nothing) arm. The switch must be exhaustive -across all `Key` tags, so it has to land somewhere — and "do nothing" is -correct. - -### State machine — add `@"72"` and extend `@"7"` - -In the `State` enum: - -```zig -@"66", -@"72", -@"77", -``` - -In the `next()` function, extending the existing `@"7"` handler: - -```zig -.@"7" => switch (c) { - ';' => self.captureTrailing(.fixed), // OSC 7 alone = report_pwd - '2' => self.state = .@"72", // NEW: OSC 72 - '7' => self.state = .@"77", // OSC 777 bridge - else => self.state = .invalid, -}, - -.@"72" => switch (c) { - ';' => self.captureTrailing(.allocating), - else => self.state = .invalid, -}, -``` - -What this is doing: - -- In state `@"7"` after seeing the `7` digit. If the next char is `2`, - transition to `@"72"`. (Previously `@"7"` only accepted `;` for OSC 7 - and `7` for the OSC 77 bridge.) -- In state `@"72"`, the only valid next char is `;`, which kicks off - **capturing** the trailing data. -- `captureTrailing(.allocating)` chooses **allocating mode** for the - capture. The default fixed buffer is 2048 bytes, but the protocol - allows payloads up to 4096 bytes per chunk (after base64), plus - metadata. Allocating mode grows as needed up to whatever the allocator - gives. If no allocator is configured, it falls back gracefully to the - fixed buffer. - -### Dispatch in `end()` - -```zig -.@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch), - -.@"72" => parsers.kitty_dnd_protocol.parse(self, terminator_ch), - -.@"77" => null, -``` - -When the parser sees the OSC terminator, `end()` looks at the final state -and hands off to the right helper. For state `@"72"`, that's the `parse` -function we wrote in file 1. - ---- - -## File 4: `src/terminal/stream.zig` (added to unimplemented list) - -```zig -.kitty_text_sizing, -.kitty_clipboard_protocol, -.kitty_dnd_protocol, -.context_signal, -=> { - log.debug("unimplemented OSC callback: {}", .{cmd}); -}, -``` - -`stream.zig` is downstream of the parser — when a fully-parsed `Command` -arrives, it dispatches to a real handler (set the title, do the clipboard -op, etc.). For this protocol, there is no handler yet (intentional — that's -the next step). So it lands in the "unimplemented" arm, which just logs. - -**The compiler enforces exhaustive switches**, so adding a new union -variant without adding it to this switch would have been a build error. -It had to go somewhere; this is the most honest place. - ---- - -## Design decisions worth understanding - -1. **Thin parser, lazy field reads.** Kept raw `metadata` and `payload` - slices and provided a `readOption` accessor. Alternative: eagerly parse - every key into a struct at parse time. The lazy approach matches - `kitty_clipboard_protocol`, lets callers pay parse cost only for keys - they care about, and is dead simple. Downside: every `readOption` call - scans the metadata string. Tradeoff is fine because metadata is tiny - (~30 chars typical). - -2. **No chunking reassembly.** The protocol says payloads >4096 bytes get - chunked across multiple OSC 72 messages. The parser deliberately does - **not** reassemble these — each chunk surfaces as its own `Command`. - Why: chunking is stateful across multiple escape codes, which is the - *action layer's* concern (which buffer to append into, what to do when - out of order, how to handle errors mid-stream). Putting that in the - OSC parser would conflate two responsibilities. - -3. **No semantic validation.** The structure is parsed but it isn't - validated that e.g. a `t=m` event actually has a sensible `x/y`, or - that a `t=q` query has the keys it should. The spec lets us be lax, - and the action layer is better-positioned to validate against - context. - -4. **`i32` for all integer keys.** The spec says "32-bit signed or - unsigned." Signed was chosen because `-1` is a real sentinel value - (drag leave, drag cancel). Unsigned would force casting everywhere. - -5. **No reset of capture in `parse`.** The parser's main loop handles - capture lifecycle — `reset()` (in `osc.zig`) cleans up the capture - between OSCs. The parse function just consumes `cap.trailing()` and - returns. diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 9304f633c..84bf81099 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -157,7 +157,6 @@ 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). diff --git a/src/terminal/osc/parsers/kitty_dnd_protocol.zig b/src/terminal/osc/parsers/kitty_dnd_protocol.zig index 1d99dc7b7..22f01f2ba 100644 --- a/src/terminal/osc/parsers/kitty_dnd_protocol.zig +++ b/src/terminal/osc/parsers/kitty_dnd_protocol.zig @@ -1,19 +1,5 @@ //! 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"); @@ -26,14 +12,11 @@ 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. + /// The raw metadata that was received. Parse individual values with `readOption`. metadata: []const u8, - /// The raw payload. Its meaning depends on the event type (`t` key) - /// and may be base64 encoded. + /// The raw payload. Its meaning and encoding depend on the event type (`t` key). payload: ?[]const u8, - /// The terminator used for the inbound OSC, recorded so that any - /// response we emit can match it. + /// The terminator used for this OSC, so any response can match it. terminator: Terminator, pub fn readOption(self: OSC, comptime key: Option) ?key.Type() { @@ -41,34 +24,20 @@ pub const OSC = struct { } }; -/// The set of values the `t` (event type) key may take. Each variant maps -/// to a single ASCII character per the spec. +/// Values for the `t` (event type) metadata key. 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 { @@ -92,26 +61,15 @@ pub const EventType = enum { } }; -/// 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. +/// Metadata keys defined by the protocol. Keys are case-sensitive: `x` and `X` 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 { @@ -124,18 +82,12 @@ pub const Option = enum { }; } - /// 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; @@ -165,6 +117,195 @@ pub const Option = enum { } }; +test "OSC 72: metadata only, no payload" { + 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.expectEqualStrings("t=a", cmd.kitty_dnd_protocol.metadata); + try testing.expect(cmd.kitty_dnd_protocol.payload == null); +} + +test "OSC 72: metadata and empty payload" { + 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.expectEqualStrings("t=a", cmd.kitty_dnd_protocol.metadata); + try testing.expectEqualStrings("", cmd.kitty_dnd_protocol.payload.?); +} + +test "OSC 72: metadata and non-empty payload" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "72;t=a:i=5;text/plain text/uri-list"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_dnd_protocol); + try testing.expectEqualStrings("t=a:i=5", cmd.kitty_dnd_protocol.metadata); + try testing.expectEqualStrings("text/plain text/uri-list", cmd.kitty_dnd_protocol.payload.?); +} + +test "OSC 72: readOption .t valid event types" { + 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"; + 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); +} + +test "OSC 72: BEL terminator recorded" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "72;t=q"; + for (input) |ch| p.next(ch); + + const cmd = p.end(0x07).?.*; + 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"); From a6132c18bf4245b9ecead8095da8e637d4e2f0b8 Mon Sep 17 00:00:00 2001 From: AJ Khullar Date: Wed, 17 Jun 2026 18:16:30 +0800 Subject: [PATCH 3/4] terminal: add doc comments to OSC 72 EventType and Option enums --- .../osc/parsers/kitty_dnd_protocol.zig | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/terminal/osc/parsers/kitty_dnd_protocol.zig b/src/terminal/osc/parsers/kitty_dnd_protocol.zig index 22f01f2ba..6533815bb 100644 --- a/src/terminal/osc/parsers/kitty_dnd_protocol.zig +++ b/src/terminal/osc/parsers/kitty_dnd_protocol.zig @@ -26,18 +26,36 @@ pub const OSC = struct { /// 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` (drop ID). drop_dropped, + /// ('r') Terminal requests data for a specific MIME type from the drag source. + /// Carries `i` (drop ID) and `m` (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` (drop ID), `m` (MIME type index), and `o` (chunk offset). 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 init(str: []const u8) ?EventType { @@ -63,13 +81,24 @@ pub const EventType = enum { /// 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, + /// MIME type index. Identifies which MIME type (from the offered list) is being + /// requested or transferred. Zero-based integer. m, + /// Drop ID. An integer that uniquely identifies a drag-and-drop operation for its + /// lifetime. The same ID is used across the request/present exchange for one drop. i, + /// Chunk offset. Used when payload data is split across multiple `present_data` + /// messages; indicates which chunk this message carries (zero-based). o, + /// Cursor column (zero-based cell units). -1 signals the drag has left the window. x, + /// Cursor row (zero-based cell units). -1 signals the drag has left the window. y, + /// Drag image width in pixels (used with `change_drag_image`). X, + /// Drag image height in pixels (used with `change_drag_image`). Y, pub fn Type(comptime key: Option) type { From c488ccda667afbad5e6bfc4815d3aa7abbde5fc8 Mon Sep 17 00:00:00 2001 From: AJ Khullar Date: Wed, 17 Jun 2026 18:23:24 +0800 Subject: [PATCH 4/4] terminal: correct OSC 72 doc comments for m, i, o, y, X, Y keys --- .../osc/parsers/kitty_dnd_protocol.zig | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/terminal/osc/parsers/kitty_dnd_protocol.zig b/src/terminal/osc/parsers/kitty_dnd_protocol.zig index 6533815bb..35d239066 100644 --- a/src/terminal/osc/parsers/kitty_dnd_protocol.zig +++ b/src/terminal/osc/parsers/kitty_dnd_protocol.zig @@ -34,17 +34,17 @@ pub const EventType = enum { /// 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` (drop ID). + /// 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` (drop ID) and `m` (MIME type index). + /// 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` (drop ID), `m` (MIME type index), and `o` (chunk offset). + /// 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. @@ -83,22 +83,23 @@ pub const EventType = enum { pub const Option = enum { /// Event type. Maps to `EventType`; present in every OSC 72 sequence. t, - /// MIME type index. Identifies which MIME type (from the offered list) is being - /// requested or transferred. Zero-based integer. + /// Chunking flag. `0` = this is the final (or only) chunk; `1` = more chunks follow. m, - /// Drop ID. An integer that uniquely identifies a drag-and-drop operation for its - /// lifetime. The same ID is used across the request/present exchange for one drop. + /// Multiplexer session ID. Echoed back in responses so a terminal multiplexer + /// (e.g. tmux) can route data to the correct pane. i, - /// Chunk offset. Used when payload data is split across multiple `present_data` - /// messages; indicates which chunk this message carries (zero-based). + /// Drop operation. `0` = reject, `1` = copy, `2` = move, `3` = copy or move. o, - /// Cursor column (zero-based cell units). -1 signals the drag has left the window. + /// Cursor column in cell units (zero-based). -1 signals the drag has left the window. x, - /// Cursor row (zero-based cell units). -1 signals the drag has left the window. + /// 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, - /// Drag image width in pixels (used with `change_drag_image`). + /// 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, - /// Drag image height in pixels (used with `change_drag_image`). + /// 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, pub fn Type(comptime key: Option) type {