diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 21:51:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 21:51:57 +0300 |
| commit | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch) | |
| tree | e52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/terminal/kitty/dnd_command.zig | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff) | |
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/kitty/dnd_command.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/kitty/dnd_command.zig | 436 |
1 files changed, 436 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/terminal/kitty/dnd_command.zig b/vendor/libghostty-vt/src/terminal/kitty/dnd_command.zig new file mode 100644 index 00000000..b5f63d02 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/dnd_command.zig @@ -0,0 +1,436 @@ +const std = @import("std"); + +/// Decoded OSC 72 metadata. +pub const Metadata = struct { + /// Event type (`t`). Null when the metadata had no `t` key; such + /// commands parse successfully but are ignored, matching kitty. + type: ?EventType = null, + + /// Chunking flag (`m`): true when more chunks follow. + more: bool = false, + + /// Multiplexer client ID (`i`), echoed in every response so a + /// terminal multiplexer can route responses to the correct client. + client_id: u32 = 0, + + /// Operation (`o`): meaning depends on the event type, commonly + /// 0=none/reject, 1=copy, 2=move, 3=copy or move. + operation: u32 = 0, + + /// `x`, `y`, `X`, `Y` keys. + cell_x: i32 = 0, + cell_y: i32 = 0, + pixel_x: i32 = 0, + pixel_y: i32 = 0, + + /// Parse raw OSC 72 metadata (the part before the first `;`). + /// Returns null when malformed; callers should ignore the command, + /// matching kitty which rejects the entire command on any error. + pub fn parse(raw: []const u8) ?Metadata { + var result: Metadata = .{}; + var pos: usize = 0; + // The continue expression consumes the ':' separating a field + // from the next; the body advances past the field itself. + while (pos < raw.len) : (pos += 1) { + // Single-character key. + const key = raw[pos]; + pos += 1; + switch (key) { + 't', 'm', 'i', 'o', 'x', 'y', 'X', 'Y' => {}, + else => return null, + } + + // '=' separator. + if (pos >= raw.len) return null; + if (raw[pos] != '=') return null; + pos += 1; + + // Value. + if (pos >= raw.len) return null; + switch (key) { + 't' => { + result.type = std.enums.fromInt( + EventType, + raw[pos], + ) orelse return null; + pos += 1; + }, + + 'm', 'i', 'o' => { + const v = parseUnsigned(raw, &pos) orelse return null; + switch (key) { + 'm' => result.more = v != 0, + 'i' => result.client_id = v, + 'o' => result.operation = v, + else => unreachable, + } + }, + + 'x', 'y', 'X', 'Y' => { + const negative = raw[pos] == '-'; + if (negative) pos += 1; + const unsigned = parseUnsigned(raw, &pos) orelse return null; + // Matches kitty's cast of the u32 magnitude to i32, + // which wraps rather than erroring on overflow. + const magnitude: i32 = @bitCast(unsigned); + const v = if (negative) 0 -% magnitude else magnitude; + switch (key) { + 'x' => result.cell_x = v, + 'y' => result.cell_y = v, + 'X' => result.pixel_x = v, + 'Y' => result.pixel_y = v, + else => unreachable, + } + }, + + else => unreachable, + } + + // Values are separated by ':'. + if (pos >= raw.len) break; + if (raw[pos] != ':') return null; + } + + return result; + } + + /// Parse an unsigned decimal value at `pos`, advancing it. At most + /// 10 digits and at most maxInt(u32), matching kitty. Returns null + /// when there are no digits or the value is too large. + fn parseUnsigned(raw: []const u8, pos: *usize) ?u32 { + const start = pos.*; + var acc: u64 = 0; + var i = start; + while (i < raw.len and i < start + 10) : (i += 1) { + const d = raw[i] -% '0'; + if (d > 9) break; + acc = acc * 10 + d; + } + if (i == start) return null; + pos.* = i; + return std.math.cast(u32, acc) orelse null; + } +}; + +/// The event type, i.e. values for the `t` metadata key. A single OSC 72 +/// code is used for both directions of the protocol, so most types have +/// one meaning when received by the terminal from a client and another +/// when sent by the terminal to a client. +/// +/// The `drop` and `request_response` types are only ever sent by the +/// terminal; kitty parses but ignores them when received and we do the +/// same. +pub const EventType = enum(u8) { + /// 'a': (recv) client registers to accept drops. With x=1 the payload + /// is the client's machine ID for remote drop support instead. + register = 'a', + + /// 'A': (recv) client unregisters from accepting drops. + unregister = 'A', + + /// 'm': (recv) client reports acceptance status for the drag currently + /// over the terminal: `o` is the chosen operation and the payload is + /// the accepted MIME list. (send) pointer moved over the terminal + /// during a drag, or with x=-1,y=-1 the drag left the window. + status = 'm', + + /// 'M': (send only) items were dropped onto the terminal. + drop = 'M', + + /// 'r': (recv) client requests drop data, or with x=y=Y=0 concludes + /// the drop with `o` as the performed operation. (send) drop data + /// response chunks. + request = 'r', + + /// 'R': (send only) error response to a data request. + request_error = 'R', + + /// 'o': (recv) drag source control: x=1 enables offering drags (payload + /// optionally the client machine ID), x=2 disables, x=0 offers a MIME + /// list for a new drag. (send) request that the client start a drag at + /// the given position. + offer = 'o', + + /// 'p': (recv) pre-sent data for an offered drag: x>=0 is a 0-based + /// MIME index, x<0 attaches drag image -x. + present = 'p', + + /// 'P': (recv) x=-1 starts the offered drag, x>=0 changes the drag + /// image mid-drag. + start_drag = 'P', + + /// 'e': (recv) drag data for MIME index `y` of an in-progress drag. + /// (send) drag status events (accepted, dropped, finished, ...). + drag_event = 'e', + + /// 'E': (recv) client aborts the whole drag (y=-1) or reports an error + /// for MIME index `y`. (send) drag start response (OK or error). + drag_error = 'E', + + /// 'k': (recv) remote file data for a drag. (send) request for remote + /// file data. + remote_data = 'k', + + /// 'q': (recv) query protocol support. (send) the query response. + query = 'q', +}; + +/// A drop operation. Values match the protocol's `o` key. +pub const Operation = enum(u2) { + none = 0, + copy = 1, + move = 2, + + /// Convert a protocol `o` value the way kitty does: anything other + /// than copy or move means none. + pub fn fromProtocol(v: u32) Operation { + return switch (v) { + 1 => .copy, + 2 => .move, + else => .none, + }; + } +}; + +/// The set of operations allowed by a drag source, sent as a bitmask in +/// the `o` key of move and drop events. +pub const Operations = packed struct(u2) { + copy: bool = false, + move: bool = false, + + pub fn protocolValue(self: Operations) u2 { + return @bitCast(self); + } +}; + +/// A decoded `t=r` data request. The request form is disambiguated by +/// which keys are non-zero, mirroring kitty's drop_process_queue. +pub const Request = union(enum) { + /// x=y=Y=0: the drop is concluded with the given operation. + conclude: Operation, + + /// Y=0, y=0, x!=0: request data for the 1-based MIME index x. + mime: i32, + + /// Y=0, y!=0: request the contents of the y'th (1-based) file in the + /// text/uri-list MIME at 1-based index x. Remote drops only. + uri: struct { + mime_idx: i32, + uri_idx: i32, + }, + + /// Y!=0: request entry x (1-based) of directory handle Y, or close + /// the handle when x=0. Remote drops only. + dir: struct { + handle: i32, + entry: i32, + }, + + pub fn init(meta: Metadata) Request { + if (meta.pixel_y != 0) return .{ .dir = .{ + .handle = meta.pixel_y, + .entry = meta.cell_x, + } }; + if (meta.cell_y != 0) return .{ .uri = .{ + .mime_idx = meta.cell_x, + .uri_idx = meta.cell_y, + } }; + if (meta.cell_x != 0) return .{ .mime = meta.cell_x }; + return .{ .conclude = .fromProtocol(meta.operation) }; + } +}; + +/// Chunk reassembly state. +/// +/// While a chunked command is in progress, the metadata of the first +/// chunk is reused for all subsequent chunks; only the `more` flag is +/// taken from each continuation. +pub const Chunking = struct { + active: bool = false, + metadata: Metadata = .{}, + + /// Returns the effective metadata for a received chunk and updates + /// the reassembly state. + pub fn apply(self: *Chunking, meta: Metadata) Metadata { + if (self.active) { + var copy = self.metadata; + copy.more = meta.more; + self.active = meta.more; + return copy; + } + + if (meta.more) { + self.active = true; + self.metadata = meta; + } + + return meta; + } +}; + +test "Metadata: empty" { + const testing = std.testing; + const meta = Metadata.parse("").?; + try testing.expect(meta.type == null); + try testing.expect(!meta.more); + try testing.expectEqual(@as(u32, 0), meta.client_id); +} + +test "Metadata: all keys" { + const testing = std.testing; + const meta = Metadata.parse("t=m:m=1:i=3:o=2:x=10:y=5:X=320:Y=200").?; + try testing.expectEqual(EventType.status, meta.type.?); + try testing.expect(meta.more); + try testing.expectEqual(@as(u32, 3), meta.client_id); + try testing.expectEqual(@as(u32, 2), meta.operation); + try testing.expectEqual(@as(i32, 10), meta.cell_x); + try testing.expectEqual(@as(i32, 5), meta.cell_y); + try testing.expectEqual(@as(i32, 320), meta.pixel_x); + try testing.expectEqual(@as(i32, 200), meta.pixel_y); +} + +test "Metadata: all event types" { + const testing = std.testing; + const cases = .{ + .{ "t=a", EventType.register }, + .{ "t=A", EventType.unregister }, + .{ "t=m", EventType.status }, + .{ "t=M", EventType.drop }, + .{ "t=r", EventType.request }, + .{ "t=R", EventType.request_error }, + .{ "t=o", EventType.offer }, + .{ "t=p", EventType.present }, + .{ "t=P", EventType.start_drag }, + .{ "t=e", EventType.drag_event }, + .{ "t=E", EventType.drag_error }, + .{ "t=k", EventType.remote_data }, + .{ "t=q", EventType.query }, + }; + inline for (cases) |case| { + try testing.expectEqual(case[1], Metadata.parse(case[0]).?.type.?); + } +} + +test "Metadata: case-sensitive coordinate keys" { + const testing = std.testing; + const meta = Metadata.parse("x=10:Y=200").?; + try testing.expectEqual(@as(i32, 10), meta.cell_x); + try testing.expectEqual(@as(i32, 0), meta.cell_y); + try testing.expectEqual(@as(i32, 0), meta.pixel_x); + try testing.expectEqual(@as(i32, 200), meta.pixel_y); +} + +test "Metadata: negative coordinates" { + const testing = std.testing; + const meta = Metadata.parse("t=m:x=-1:y=-1").?; + try testing.expectEqual(@as(i32, -1), meta.cell_x); + try testing.expectEqual(@as(i32, -1), meta.cell_y); +} + +test "Metadata: malformed inputs rejected" { + const testing = std.testing; + // Unknown event type. + try testing.expect(Metadata.parse("t=z") == null); + // Unknown key. + try testing.expect(Metadata.parse("z=1") == null); + // Missing '=' mid-stream. + try testing.expect(Metadata.parse("x10") == null); + // No digits. + try testing.expect(Metadata.parse("x=notanumber") == null); + try testing.expect(Metadata.parse("x=-") == null); + // Too large. + try testing.expect(Metadata.parse("i=4294967296") == null); + try testing.expect(Metadata.parse("i=99999999999") == null); + // Garbage after value. + try testing.expect(Metadata.parse("x=1z") == null); + // No whitespace tolerance, matching kitty. + try testing.expect(Metadata.parse("t=a: x=1") == null); + try testing.expect(Metadata.parse("t = a") == null); + // Truncated mid-construct, matching kitty's final-state check. + // Nothing state-changing may be parsed out of these: e.g. "t=r:x=" + // must not be treated as a drop conclusion. + try testing.expect(Metadata.parse("t") == null); + try testing.expect(Metadata.parse("t=") == null); + try testing.expect(Metadata.parse("x=") == null); + try testing.expect(Metadata.parse("t=r:x=") == null); + try testing.expect(Metadata.parse("x=1:t=") == null); +} + +test "Metadata: trailing separator accepted" { + const testing = std.testing; + + // Kitty's state machine accepts the metadata ending right after a + // value separator. + const meta = Metadata.parse("t=a:").?; + try testing.expectEqual(EventType.register, meta.type.?); +} + +test "Metadata: u32 boundary accepted" { + const testing = std.testing; + const meta = Metadata.parse("i=4294967295").?; + try testing.expectEqual(@as(u32, std.math.maxInt(u32)), meta.client_id); +} + +test "Request: classification" { + const testing = std.testing; + + // Conclude. + { + const r: Request = .init(Metadata.parse("t=r:o=2").?); + try testing.expectEqual(Operation.move, r.conclude); + } + // Conclude with unknown operation is none (canceled). + { + const r: Request = .init(Metadata.parse("t=r:o=9").?); + try testing.expectEqual(Operation.none, r.conclude); + } + // MIME data request. + { + const r: Request = .init(Metadata.parse("t=r:x=2").?); + try testing.expectEqual(@as(i32, 2), r.mime); + } + // URI file request. + { + const r: Request = .init(Metadata.parse("t=r:x=1:y=3").?); + try testing.expectEqual(@as(i32, 1), r.uri.mime_idx); + try testing.expectEqual(@as(i32, 3), r.uri.uri_idx); + } + // Directory handle request. + { + const r: Request = .init(Metadata.parse("t=r:Y=2:x=1").?); + try testing.expectEqual(@as(i32, 2), r.dir.handle); + try testing.expectEqual(@as(i32, 1), r.dir.entry); + } +} + +test "Chunking: reassembly reuses first chunk metadata" { + const testing = std.testing; + var chunking: Chunking = .{}; + + // First chunk starts reassembly. + const first = chunking.apply(Metadata.parse("t=m:o=1:m=1").?); + try testing.expect(chunking.active); + try testing.expectEqual(EventType.status, first.type.?); + try testing.expect(first.more); + + // Continuation metadata is ignored except for `more`. + const second = chunking.apply(Metadata.parse("t=q:o=2:m=1").?); + try testing.expect(chunking.active); + try testing.expectEqual(EventType.status, second.type.?); + try testing.expectEqual(@as(u32, 1), second.operation); + try testing.expect(second.more); + + // Final chunk ends reassembly. + const last = chunking.apply(Metadata.parse("t=q:m=0").?); + try testing.expect(!chunking.active); + try testing.expectEqual(EventType.status, last.type.?); + try testing.expect(!last.more); +} + +test "Chunking: unchunked commands pass through" { + const testing = std.testing; + var chunking: Chunking = .{}; + const meta = chunking.apply(Metadata.parse("t=q").?); + try testing.expect(!chunking.active); + try testing.expectEqual(EventType.query, meta.type.?); +} |
