aboutsummaryrefslogtreecommitdiffstats
path: root/vendor/libghostty-vt/src/terminal/kitty/dnd_command.zig
blob: b5f63d02b6aa46a66457f45adf2d55e67d346666 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
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.?);
}