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