mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
terminal/kitty: drag and drop drop state machine
This commit is contained in:
@@ -19,7 +19,9 @@
|
||||
//! sends, mirroring kitty's send_payload_to_child chunking.
|
||||
//! * dnd_drop.zig: the per-terminal protocol state machine, driven
|
||||
//! by client OSCs on one side and native drag events from the
|
||||
//! embedder on the other.
|
||||
//! embedder on the other. It is allocated when a client registers
|
||||
//! to accept drops and freed when it unregisters, so terminals
|
||||
//! that never see the protocol pay nothing for it.
|
||||
//!
|
||||
//! The wire behavior was validated against kitty's implementation
|
||||
//! (kitty_tests/dnd.py is the oracle), including its deviations from
|
||||
@@ -29,9 +31,9 @@
|
||||
//! omit the `;` and `m=` entirely, and registration survives a
|
||||
//! terminal reset (RIS clears only the chunk-reassembly flag).
|
||||
//!
|
||||
//! ## Divergences from kitty
|
||||
//! ## Divergences
|
||||
//!
|
||||
//! All are bounded-scope decisions, not accidents:
|
||||
//! These will be fixed in the future:
|
||||
//!
|
||||
//! * Dropped data is captured eagerly at drop time from a curated
|
||||
//! set of representations the embedder can serve (typically
|
||||
@@ -41,26 +43,27 @@
|
||||
//! x=y=Y=0) only frees the held data, and kitty's 128-entry
|
||||
//! request queue and EMFILE overflow handling are unnecessary
|
||||
//! because requests are served synchronously in order.
|
||||
//! * The MIME list a client registers with (the t=a payload) is
|
||||
//! accepted but not forwarded to the OS, so exotic pasteboard
|
||||
//! types on macOS are not offered to clients.
|
||||
//! * Every client is treated as local: machine IDs (t=a:x=1) are
|
||||
//! accepted and ignored, responses never carry the X=1 remote
|
||||
//! marker, and remote file transfer requests (t=r with y or Y
|
||||
//! keys) are answered with EINVAL. A remote client (e.g. over
|
||||
//! ssh) can still receive text drops; only file-content transfer
|
||||
//! is unavailable.
|
||||
//! * The terminal never initiates drags (drag out): enabling offers
|
||||
//! (t=o:x=1) is tracked so the state is queryable, but the
|
||||
//! terminal never sends a drag start request, so a conforming
|
||||
//! client never offers a drag. Direct offers (t=o:x=0) and drag
|
||||
//! data/start commands (t=p, t=P) are refused with EPERM.
|
||||
//! * The terminal never initiates drags (drag out): enabling and
|
||||
//! disabling offers (t=o:x=1, t=o:x=2) are accepted and ignored,
|
||||
//! and since the terminal never sends a drag start request a
|
||||
//! conforming client never offers a drag. Direct offers (t=o:x=0)
|
||||
//! and drag data/start commands (t=p, t=P) are refused with EPERM.
|
||||
//!
|
||||
//! These are on purpose forever:
|
||||
//!
|
||||
//! * Responses echo the requesting command's terminator (ST or BEL)
|
||||
//! per ghostty convention; kitty always uses ST. Terminal-
|
||||
//! initiated events always use ST.
|
||||
|
||||
const dnd_command = @import("dnd_command.zig");
|
||||
const dnd_response = @import("dnd_response.zig");
|
||||
const dnd_drop = @import("dnd_drop.zig");
|
||||
|
||||
pub const EventType = dnd_command.EventType;
|
||||
pub const Metadata = dnd_command.Metadata;
|
||||
@@ -74,7 +77,16 @@ pub const RequestKeys = dnd_response.RequestKeys;
|
||||
pub const encode = dnd_response.encode;
|
||||
pub const encodeError = dnd_response.encodeError;
|
||||
|
||||
pub const State = dnd_drop.State;
|
||||
pub const Item = dnd_drop.State.Item;
|
||||
pub const MoveEvent = dnd_drop.State.MoveEvent;
|
||||
pub const max_mime_list_bytes = dnd_drop.max_mime_list_bytes;
|
||||
pub const handleCommand = dnd_drop.handleCommand;
|
||||
pub const Event = dnd_drop.Event;
|
||||
|
||||
test {
|
||||
_ = dnd_command;
|
||||
_ = dnd_response;
|
||||
_ = dnd_drop;
|
||||
_ = @import("dnd_test.zig");
|
||||
}
|
||||
|
||||
@@ -240,7 +240,8 @@ pub const Request = union(enum) {
|
||||
}
|
||||
};
|
||||
|
||||
/// Chunk reassembly state, mirroring kitty's per-screen dnd_chunking.
|
||||
/// 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.
|
||||
|
||||
715
src/terminal/kitty/dnd_drop.zig
Normal file
715
src/terminal/kitty/dnd_drop.zig
Normal file
@@ -0,0 +1,715 @@
|
||||
//! Kitty drag and drop protocol (OSC 72) state machine.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const assert = @import("../../quirks.zig").inlineAssert;
|
||||
const osc = @import("../osc.zig");
|
||||
const command = @import("dnd_command.zig");
|
||||
const response = @import("dnd_response.zig");
|
||||
|
||||
const Metadata = command.Metadata;
|
||||
const Operation = command.Operation;
|
||||
const Operations = command.Operations;
|
||||
|
||||
const log = std.log.scoped(.kitty_dnd);
|
||||
|
||||
/// Maximum accumulated size of a client-sent MIME list (the accepted
|
||||
/// list of a `t=m` status update). Matches kitty's MIME_LIST_SIZE_CAP.
|
||||
pub const max_mime_list_bytes = 1024 * 1024;
|
||||
|
||||
/// Process one OSC 72 command received from the client, writing any
|
||||
/// responses to the writer. Returns the state change the embedder may
|
||||
/// need to act on, if any.
|
||||
pub fn handleCommand(
|
||||
slot: *?*State,
|
||||
alloc: Allocator,
|
||||
writer: *std.Io.Writer,
|
||||
v: osc.Command.KittyDndProtocol,
|
||||
) (Allocator.Error || std.Io.Writer.Error)!?Event {
|
||||
const raw = Metadata.parse(v.metadata) orelse {
|
||||
log.debug("dropping malformed OSC 72 metadata", .{});
|
||||
return null;
|
||||
};
|
||||
|
||||
// Chunk reassembly lives in the state, so before registration
|
||||
// each command stands alone. The only legitimately chunked
|
||||
// command before registration is t=a itself, which seeds the
|
||||
// reassembly on its first chunk below.
|
||||
const continuation = if (slot.*) |state| state.chunking.active else false;
|
||||
const meta = if (slot.*) |state| state.chunking.apply(raw) else raw;
|
||||
const payload = v.payload orelse "";
|
||||
const t = meta.type orelse return null;
|
||||
|
||||
switch (t) {
|
||||
.register => {
|
||||
// x=1 declares the client's machine ID for remote drop
|
||||
// support. We don't support remote drop yet, so accept and ignore.
|
||||
if (meta.cell_x == 1) return null;
|
||||
|
||||
// Setup our state if we haven't already
|
||||
const state = slot.* orelse state: {
|
||||
const state = try State.create(alloc);
|
||||
slot.* = state;
|
||||
_ = state.chunking.apply(raw);
|
||||
break :state state;
|
||||
};
|
||||
|
||||
// Update the client ID on every registration
|
||||
state.drop.client_id = meta.client_id;
|
||||
|
||||
return try state.register(
|
||||
alloc,
|
||||
payload,
|
||||
continuation,
|
||||
meta.more,
|
||||
);
|
||||
},
|
||||
|
||||
.unregister => {
|
||||
const state = slot.* orelse return null;
|
||||
state.destroy(alloc);
|
||||
slot.* = null;
|
||||
return .registration;
|
||||
},
|
||||
|
||||
.status => {
|
||||
const state = slot.* orelse return null;
|
||||
return try state.acceptStatus(alloc, meta, payload);
|
||||
},
|
||||
|
||||
.request => return try dataRequest(
|
||||
slot.*,
|
||||
alloc,
|
||||
writer,
|
||||
meta,
|
||||
v.terminator,
|
||||
),
|
||||
|
||||
// Drag source control. Enabling (x=1, with an optional
|
||||
// machine ID payload) and disabling (x=2) offers are
|
||||
// accepted and ignored since the terminal never requests a
|
||||
// drag start. Offering a MIME list (x=0) for a new drag is
|
||||
// refused since drag-out is not implemented.
|
||||
.offer => if (meta.cell_x == 0) try refuseDragOut(
|
||||
writer,
|
||||
meta,
|
||||
v.terminator,
|
||||
),
|
||||
|
||||
// Drag-out data and start commands. A conforming client
|
||||
// never sends these because the terminal never requests a
|
||||
// drag start, but refuse them properly if one does.
|
||||
.present, .start_drag => try refuseDragOut(
|
||||
writer,
|
||||
meta,
|
||||
v.terminator,
|
||||
),
|
||||
|
||||
// Responses to drag-out requests the terminal never makes.
|
||||
.drag_event, .drag_error, .remote_data => {},
|
||||
|
||||
.query => try response.encode(
|
||||
writer,
|
||||
"t=q",
|
||||
meta.client_id,
|
||||
"",
|
||||
.plain,
|
||||
v.terminator,
|
||||
),
|
||||
|
||||
// Only ever sent by the terminal. Ignore.
|
||||
.drop, .request_error => {},
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Handle a t=r data request or drop conclusion from the client.
|
||||
/// Requests from an unregistered client (no state) get the same
|
||||
/// errors kitty sends from its zeroed drop state.
|
||||
fn dataRequest(
|
||||
state: ?*State,
|
||||
alloc: Allocator,
|
||||
writer: *std.Io.Writer,
|
||||
meta: Metadata,
|
||||
terminator: osc.Terminator,
|
||||
) (Allocator.Error || std.Io.Writer.Error)!?Event {
|
||||
// Responses echo the registration's client ID, matching kitty.
|
||||
const client_id = if (state) |s| s.drop.client_id else 0;
|
||||
|
||||
switch (command.Request.init(meta)) {
|
||||
.conclude => |op| {
|
||||
// The client is done with the drop: free the held data and
|
||||
// report the operation it performed. Kitty hands that to
|
||||
// the still-open OS drag session; ours ended at drop time
|
||||
// (see dnd.zig), so the embedder decides what to do with
|
||||
// it. A conclusion with no drop in progress is a no-op.
|
||||
const s = state orelse return null;
|
||||
const dropped = s.drop.dropped;
|
||||
s.resetDrop(alloc);
|
||||
return if (dropped) Event.concluded(op) else null;
|
||||
},
|
||||
|
||||
.mime => |idx| {
|
||||
const keys: response.RequestKeys = .{ .x = idx };
|
||||
const items = (if (state) |s| s.drop.items else null) orelse {
|
||||
try response.encodeError(
|
||||
writer,
|
||||
.drop,
|
||||
keys,
|
||||
client_id,
|
||||
.ENOENT,
|
||||
"no drop data available",
|
||||
terminator,
|
||||
);
|
||||
return null;
|
||||
};
|
||||
if (idx < 1 or @as(usize, @intCast(idx)) > items.len) {
|
||||
try response.encodeError(
|
||||
writer,
|
||||
.drop,
|
||||
keys,
|
||||
client_id,
|
||||
.ENOENT,
|
||||
"drop data request index out of bounds",
|
||||
terminator,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
var header_buf: [32]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(
|
||||
&header_buf,
|
||||
"t=r{f}",
|
||||
.{keys},
|
||||
) catch unreachable;
|
||||
|
||||
// The data chunks followed by the empty end-of-data
|
||||
// message, which is how the client detects completion.
|
||||
// An empty item is just the end-of-data message alone;
|
||||
// clients treat a duplicate as a second completion.
|
||||
const item = items[@intCast(idx - 1)];
|
||||
if (item.data.len > 0) try response.encode(
|
||||
writer,
|
||||
header,
|
||||
client_id,
|
||||
item.data,
|
||||
.base64,
|
||||
terminator,
|
||||
);
|
||||
try response.encode(writer, header, client_id, "", .base64, terminator);
|
||||
return null;
|
||||
},
|
||||
|
||||
// Remote drop transfers (URI file contents and directory
|
||||
// handles). We never advertise remote support (no X=1
|
||||
// marker), so a conforming client never sends these.
|
||||
.uri => |uri| try response.encodeError(
|
||||
writer,
|
||||
.drop,
|
||||
.{ .x = uri.mime_idx, .y = uri.uri_idx },
|
||||
client_id,
|
||||
.EINVAL,
|
||||
"remote drop data is not supported",
|
||||
terminator,
|
||||
),
|
||||
.dir => |dir| try response.encodeError(
|
||||
writer,
|
||||
.drop,
|
||||
.{ .x = dir.entry, .Y = dir.handle },
|
||||
client_id,
|
||||
.EINVAL,
|
||||
"remote drop data is not supported",
|
||||
terminator,
|
||||
),
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Refuse a drag-out command with an error, since ghostty does not
|
||||
/// implement the terminal side of client-initiated drags yet.
|
||||
fn refuseDragOut(
|
||||
writer: *std.Io.Writer,
|
||||
meta: Metadata,
|
||||
terminator: osc.Terminator,
|
||||
) std.Io.Writer.Error!void {
|
||||
try response.encodeError(
|
||||
writer,
|
||||
.drag,
|
||||
.{},
|
||||
meta.client_id,
|
||||
.EPERM,
|
||||
"drag out is not supported by this terminal",
|
||||
terminator,
|
||||
);
|
||||
}
|
||||
|
||||
/// A protocol state change an embedder may need to act on, returned by
|
||||
/// `handleCommand` and delivered through the stream handler's
|
||||
/// `dnd_event` effect. This is a flat enum so it can cross a C API
|
||||
/// unchanged; any details are read back from `Terminal.kitty_dnd`.
|
||||
pub const Event = enum {
|
||||
/// The client registered (t=a), re-registered, or unregistered
|
||||
/// (t=A) to accept drops. An embedder may want to use this
|
||||
/// to setup the proper mime types to accept (e.g. on macOS)
|
||||
/// or not (unregistered).
|
||||
registration,
|
||||
|
||||
/// The client answered the drag currently over the terminal.
|
||||
/// `State.clientAccepted` has the answer. Embedders can refresh the
|
||||
/// OS drag feedback immediately rather than on the next move.
|
||||
acceptance,
|
||||
|
||||
/// The client concluded a drop, performing no operation (it
|
||||
/// canceled), a copy, or a move. The held drop data has been freed.
|
||||
concluded_none,
|
||||
concluded_copy,
|
||||
concluded_move,
|
||||
|
||||
/// The conclusion event for a performed operation.
|
||||
pub fn concluded(op: Operation) Event {
|
||||
return switch (op) {
|
||||
.none => .concluded_none,
|
||||
.copy => .concluded_copy,
|
||||
.move => .concluded_move,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// The per-terminal drop target state.
|
||||
///
|
||||
/// The primary entrypoint is `handleCommand` which takes a `*?*State`
|
||||
/// slot that it can heap allocate into when DnD activates and free when
|
||||
/// it deactivates.
|
||||
///
|
||||
/// The normal lifecycle:
|
||||
///
|
||||
/// 1. The stream handler feeds every OSC 72 command received from the
|
||||
/// client to `handleCommand`. The client registers (t=a), which
|
||||
/// allocates the state into the slot and yields a `registration`
|
||||
/// event so the embedder can register any declared MIME types
|
||||
/// with the OS. Until then `handleCommand` only answers stateless
|
||||
/// commands (queries, error responses).
|
||||
/// 2. A native drag enters or moves over the terminal. When the slot
|
||||
/// is non-null, the embedder calls `dragMove` with the pointer
|
||||
/// position, the operations the drag source allows, and the MIME
|
||||
/// types it can serve if dropped. This sends the client a t=m
|
||||
/// move event; when the slot is null the embedder should handle
|
||||
/// the drag as it would without the protocol.
|
||||
/// 3. The client answers with its acceptance (t=m:o=N), recorded by
|
||||
/// `handleCommand` which yields an `acceptance` event. The
|
||||
/// embedder reads `clientAccepted` then and on subsequent moves
|
||||
/// to give the OS drag session its feedback.
|
||||
/// 4. The drag either leaves, and the embedder calls `dragLeave` to
|
||||
/// send the t=m leave event, or drops: the embedder captures the
|
||||
/// representations it advertised and calls `dragDrop`, which
|
||||
/// copies and holds them and sends the client a t=M drop event. A
|
||||
/// new drag entering before the client concludes discards the
|
||||
/// held drop.
|
||||
/// 5. The client requests data (t=r:x=N), which `handleCommand`
|
||||
/// serves from the held copies, and then concludes the drop
|
||||
/// (t=r:o=N), which frees them and yields a `concluded_*` event
|
||||
/// naming the operation the client performed.
|
||||
/// 6. The client unregisters (t=A) and `handleCommand` frees the
|
||||
/// state, yielding a final `registration` event, or the terminal
|
||||
/// is deinitialized and calls `destroy`.
|
||||
///
|
||||
/// All calls must use the allocator the state was created with (the
|
||||
/// terminal's) and require the same synchronization as any other
|
||||
/// terminal mutation.
|
||||
pub const State = struct {
|
||||
/// Chunk reassembly for client commands. This is the only part of
|
||||
/// the state cleared by a terminal reset (RIS), matching kitty.
|
||||
chunking: command.Chunking = .{},
|
||||
|
||||
/// Drop target state for the registered client.
|
||||
drop: DropTarget = .{},
|
||||
|
||||
pub const DropTarget = struct {
|
||||
/// Multiplexer client ID from registration, echoed in every
|
||||
/// drop-side message the terminal sends.
|
||||
client_id: u32 = 0,
|
||||
|
||||
/// The MIME list the client registered with (the t=a payload),
|
||||
/// space-separated as received and accumulated across chunks.
|
||||
/// Only needed by embedders that must register types with the
|
||||
/// OS ahead of a drag; kitty frees it after doing so, we keep
|
||||
/// it so the `registration` event can be acted on from here.
|
||||
registered_mimes: std.ArrayListUnmanaged(u8) = .empty,
|
||||
|
||||
/// True while the pointer of a native drag is over the terminal.
|
||||
hovered: bool = false,
|
||||
|
||||
/// True after the native drop until the client concludes it.
|
||||
dropped: bool = false,
|
||||
|
||||
/// The client's response to the current drag, null until the
|
||||
/// client has responded. `none` means the client rejected it.
|
||||
accepted: ?Operation = null,
|
||||
|
||||
/// True while a chunked t=m acceptance is being accumulated.
|
||||
accept_in_progress: bool = false,
|
||||
|
||||
/// The client's accepted MIME list: space-separated while
|
||||
/// accumulating, converted to NUL-separated (with a trailing
|
||||
/// NUL) once complete, matching kitty's in-place conversion.
|
||||
accepted_mimes: std.ArrayListUnmanaged(u8) = .empty,
|
||||
|
||||
/// The MIME types of the current native drag, in the order
|
||||
/// that data request indices refer to.
|
||||
offered: ?Offered = null,
|
||||
|
||||
/// The data captured at drop time, parallel to `offered`.
|
||||
items: ?[]const Item = null,
|
||||
};
|
||||
|
||||
/// One dropped representation: a MIME type and its data.
|
||||
pub const Item = struct {
|
||||
mime: []const u8,
|
||||
data: []const u8,
|
||||
};
|
||||
|
||||
/// The MIME list of the current drag plus the pre-joined move-event
|
||||
/// payload ("mime1 mime2 " with a trailing space after every entry,
|
||||
/// matching kitty) so per-move encoding is allocation-free.
|
||||
const Offered = struct {
|
||||
mimes: []const []const u8,
|
||||
payload: []const u8,
|
||||
|
||||
fn init(alloc: Allocator, mimes: []const []const u8) Allocator.Error!Offered {
|
||||
const copies = try alloc.alloc([]const u8, mimes.len);
|
||||
errdefer alloc.free(copies);
|
||||
|
||||
var payload_len: usize = 0;
|
||||
for (mimes) |m| payload_len += m.len + 1;
|
||||
|
||||
const payload = try alloc.alloc(u8, payload_len);
|
||||
errdefer alloc.free(payload);
|
||||
|
||||
var offset: usize = 0;
|
||||
for (mimes, copies) |m, *copy| {
|
||||
@memcpy(payload[offset..][0..m.len], m);
|
||||
payload[offset + m.len] = ' ';
|
||||
copy.* = payload[offset..][0..m.len];
|
||||
offset += m.len + 1;
|
||||
}
|
||||
|
||||
return .{ .mimes = copies, .payload = payload };
|
||||
}
|
||||
|
||||
fn deinit(self: *const Offered, alloc: Allocator) void {
|
||||
alloc.free(self.mimes);
|
||||
alloc.free(self.payload);
|
||||
}
|
||||
|
||||
fn eql(self: *const Offered, mimes: []const []const u8) bool {
|
||||
if (self.mimes.len != mimes.len) return false;
|
||||
for (self.mimes, mimes) |a, b| {
|
||||
if (!std.mem.eql(u8, a, b)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/// The maximum number of dropped items. Embedders provide a small
|
||||
/// curated set of representations (see dnd.zig), so this is a
|
||||
/// generous bound that keeps the MIME list assembly on the stack.
|
||||
pub const max_items = 16;
|
||||
|
||||
/// Allocate a fresh state. Done by `handleCommand` on registration.
|
||||
fn create(alloc: Allocator) Allocator.Error!*State {
|
||||
const state = try alloc.create(State);
|
||||
state.* = .{};
|
||||
return state;
|
||||
}
|
||||
|
||||
/// Free the state and everything it holds.
|
||||
pub fn destroy(self: *State, alloc: Allocator) void {
|
||||
self.deinit(alloc);
|
||||
alloc.destroy(self);
|
||||
}
|
||||
|
||||
fn deinit(self: *State, alloc: Allocator) void {
|
||||
self.freeDragData(alloc);
|
||||
self.drop.accepted_mimes.deinit(alloc);
|
||||
self.drop.registered_mimes.deinit(alloc);
|
||||
}
|
||||
|
||||
/// Iterate the MIME types the client registered with, in order.
|
||||
/// Empty when the client declared none, which is the common case.
|
||||
/// The list is only needed to register exotic types with the OS,
|
||||
/// such as macOS pasteboard stuff.
|
||||
pub fn registeredMimes(self: *const State) std.mem.TokenIterator(u8, .scalar) {
|
||||
return std.mem.tokenizeScalar(
|
||||
u8,
|
||||
self.drop.registered_mimes.items,
|
||||
' ',
|
||||
);
|
||||
}
|
||||
|
||||
/// Record one chunk of a registration's MIME list.
|
||||
///
|
||||
/// `continuation` is true for every chunk but the first of a chunked
|
||||
/// registration. Returns the registration event once the list is complete.
|
||||
fn register(
|
||||
self: *State,
|
||||
alloc: Allocator,
|
||||
payload: []const u8,
|
||||
continuation: bool,
|
||||
more: bool,
|
||||
) Allocator.Error!?Event {
|
||||
const list = &self.drop.registered_mimes;
|
||||
if (!continuation) list.clearRetainingCapacity();
|
||||
|
||||
// Matching kitty, an over-cap chunk is dropped and does not
|
||||
// complete the registration.
|
||||
if (list.items.len + payload.len > max_mime_list_bytes) return null;
|
||||
try list.appendSlice(alloc, payload);
|
||||
|
||||
return if (more) null else .registration;
|
||||
}
|
||||
|
||||
/// The client's acceptance response for the drag currently over the
|
||||
/// terminal, for OS drag feedback. Null when the client hasn't
|
||||
/// responded yet (embedders should fall back to their default,
|
||||
/// typically copy) or `none` when the client rejected the drag.
|
||||
pub fn clientAccepted(self: *const State) ?Operation {
|
||||
if (self.drop.accept_in_progress) return null;
|
||||
return self.drop.accepted;
|
||||
}
|
||||
|
||||
/// Free the per-drag data (offered MIME list and held drop items).
|
||||
fn freeDragData(self: *State, alloc: Allocator) void {
|
||||
if (self.drop.offered) |*offered| {
|
||||
offered.deinit(alloc);
|
||||
self.drop.offered = null;
|
||||
}
|
||||
if (self.drop.items) |items| {
|
||||
for (items) |item| {
|
||||
alloc.free(item.mime);
|
||||
alloc.free(item.data);
|
||||
}
|
||||
alloc.free(items);
|
||||
self.drop.items = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the per-drag state while preserving the registration,
|
||||
/// mirroring kitty's reset_drop. Called when a new drag enters and
|
||||
/// when a drop concludes.
|
||||
fn resetDrop(self: *State, alloc: Allocator) void {
|
||||
self.freeDragData(alloc);
|
||||
self.drop.accepted_mimes.clearAndFree(alloc);
|
||||
self.drop.hovered = false;
|
||||
self.drop.dropped = false;
|
||||
self.drop.accepted = null;
|
||||
self.drop.accept_in_progress = false;
|
||||
}
|
||||
|
||||
/// Handle a t=m acceptance status update from the client, mirroring
|
||||
/// kitty's drop_set_status.
|
||||
fn acceptStatus(
|
||||
self: *State,
|
||||
alloc: Allocator,
|
||||
meta: Metadata,
|
||||
payload: []const u8,
|
||||
) Allocator.Error!?Event {
|
||||
const d = &self.drop;
|
||||
if (!d.accept_in_progress) {
|
||||
d.accepted_mimes.clearRetainingCapacity();
|
||||
d.accept_in_progress = true;
|
||||
d.accepted = .fromProtocol(meta.operation);
|
||||
}
|
||||
|
||||
if (payload.len > 0) {
|
||||
// Matching kitty, an over-cap list stops accumulating and
|
||||
// never finalizes, leaving the acceptance unanswered.
|
||||
if (d.accepted_mimes.items.len + payload.len > max_mime_list_bytes) return null;
|
||||
try d.accepted_mimes.appendSlice(alloc, payload);
|
||||
}
|
||||
|
||||
if (meta.more) return null;
|
||||
d.accept_in_progress = false;
|
||||
if (d.accepted_mimes.items.len > 0) {
|
||||
for (d.accepted_mimes.items) |*c| {
|
||||
if (c.* == ' ') c.* = 0;
|
||||
}
|
||||
try d.accepted_mimes.append(alloc, 0);
|
||||
}
|
||||
return .acceptance;
|
||||
}
|
||||
|
||||
/// A native drag position report from the embedder.
|
||||
pub const MoveEvent = struct {
|
||||
/// Grid cell under the pointer, zero-based from the top-left.
|
||||
cell_x: u32,
|
||||
cell_y: u32,
|
||||
|
||||
/// Pointer position in pixels relative to the top-left of the
|
||||
/// terminal's content area.
|
||||
pixel_x: i32,
|
||||
pixel_y: i32,
|
||||
|
||||
/// The operations the drag source allows.
|
||||
operations: Operations,
|
||||
};
|
||||
|
||||
/// Report a native drag moving over the terminal, sending a t=m
|
||||
/// move event to the client. `mimes` is the list of MIME types the
|
||||
/// terminal can provide for this drag, in the order data request
|
||||
/// indices will refer to.
|
||||
pub fn dragMove(
|
||||
self: *State,
|
||||
alloc: Allocator,
|
||||
writer: *std.Io.Writer,
|
||||
ev: MoveEvent,
|
||||
mimes: []const []const u8,
|
||||
) (Allocator.Error || std.Io.Writer.Error)!void {
|
||||
try self.moveEvent(
|
||||
alloc,
|
||||
writer,
|
||||
ev,
|
||||
mimes,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Report a native drop onto the terminal. The items' data is
|
||||
/// copied and held so the client's data requests can be served; it
|
||||
/// is freed when the client concludes the drop, a new drag enters,
|
||||
/// or the client unregisters.
|
||||
///
|
||||
/// Sends a t=M drop event listing the items' MIME types.
|
||||
pub fn dragDrop(
|
||||
self: *State,
|
||||
alloc: Allocator,
|
||||
writer: *std.Io.Writer,
|
||||
ev: MoveEvent,
|
||||
items: []const Item,
|
||||
) (Allocator.Error || std.Io.Writer.Error)!void {
|
||||
// Copy the items so they can be served after this call returns.
|
||||
// Items beyond the cap are dropped so the held list always
|
||||
// matches the advertised MIME list.
|
||||
const accepted_items = items[0..@min(items.len, max_items)];
|
||||
const copies = try alloc.alloc(Item, accepted_items.len);
|
||||
errdefer alloc.free(copies);
|
||||
var copied: usize = 0;
|
||||
errdefer for (copies[0..copied]) |item| {
|
||||
alloc.free(item.mime);
|
||||
alloc.free(item.data);
|
||||
};
|
||||
for (accepted_items, copies) |item, *copy| {
|
||||
const mime = try alloc.dupe(u8, item.mime);
|
||||
errdefer alloc.free(mime);
|
||||
const data = try alloc.dupe(u8, item.data);
|
||||
copy.* = .{ .mime = mime, .data = data };
|
||||
copied += 1;
|
||||
}
|
||||
|
||||
// The move handling below resets per-drag state when this drop
|
||||
// arrives without a preceding move, so the items are attached
|
||||
// after it runs. Collect the MIME list first.
|
||||
var mimes_buf: [max_items][]const u8 = undefined;
|
||||
const mimes = mimes_buf[0..copies.len];
|
||||
for (mimes, copies) |*m, item| m.* = item.mime;
|
||||
|
||||
try self.moveEvent(
|
||||
alloc,
|
||||
writer,
|
||||
ev,
|
||||
mimes,
|
||||
true,
|
||||
);
|
||||
|
||||
assert(self.drop.items == null);
|
||||
self.drop.items = copies;
|
||||
}
|
||||
|
||||
/// Report the native drag leaving the terminal, sending the t=m
|
||||
/// leave event (x=-1, y=-1).
|
||||
///
|
||||
/// Ignored after a drop: some toolkits emit a leave notification
|
||||
/// for the drop itself, and the held data must survive until the
|
||||
/// client concludes.
|
||||
pub fn dragLeave(
|
||||
self: *State,
|
||||
alloc: Allocator,
|
||||
writer: *std.Io.Writer,
|
||||
) std.Io.Writer.Error!void {
|
||||
if (self.drop.dropped) return;
|
||||
const hovered = self.drop.hovered;
|
||||
self.drop.hovered = false;
|
||||
if (self.drop.offered) |*offered| {
|
||||
offered.deinit(alloc);
|
||||
self.drop.offered = null;
|
||||
}
|
||||
|
||||
// Only a client that saw the drag enter gets the leave event,
|
||||
// matching kitty which notifies hovered windows only.
|
||||
if (!hovered) return;
|
||||
|
||||
try response.encode(
|
||||
writer,
|
||||
"t=m:x=-1:y=-1",
|
||||
self.drop.client_id,
|
||||
"",
|
||||
.plain,
|
||||
.st,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared implementation of move and drop events, mirroring kitty's
|
||||
/// drop_move_on_child.
|
||||
fn moveEvent(
|
||||
self: *State,
|
||||
alloc: Allocator,
|
||||
writer: *std.Io.Writer,
|
||||
ev: MoveEvent,
|
||||
mimes: []const []const u8,
|
||||
is_drop: bool,
|
||||
) (Allocator.Error || std.Io.Writer.Error)!void {
|
||||
if (!self.drop.hovered) {
|
||||
self.resetDrop(alloc);
|
||||
self.drop.hovered = true;
|
||||
}
|
||||
if (is_drop) {
|
||||
self.drop.dropped = true;
|
||||
self.drop.hovered = false;
|
||||
}
|
||||
|
||||
// (Re)build the offered MIME list when it changed.
|
||||
if (self.drop.offered == null or !self.drop.offered.?.eql(mimes)) {
|
||||
if (self.drop.offered) |*offered| offered.deinit(alloc);
|
||||
self.drop.offered = null;
|
||||
self.drop.offered = try Offered.init(alloc, mimes);
|
||||
}
|
||||
|
||||
var header_buf: [96]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(
|
||||
&header_buf,
|
||||
"t={c}:x={d}:y={d}:X={d}:Y={d}:o={d}",
|
||||
.{
|
||||
@as(u8, if (is_drop) 'M' else 'm'),
|
||||
ev.cell_x,
|
||||
ev.cell_y,
|
||||
ev.pixel_x,
|
||||
ev.pixel_y,
|
||||
ev.operations.protocolValue(),
|
||||
},
|
||||
) catch unreachable;
|
||||
|
||||
// The MIME list is sent with every move event, matching kitty
|
||||
// (the spec suggests only the first, but kitty always sends it
|
||||
// and clients depend on that).
|
||||
try response.encode(
|
||||
writer,
|
||||
header,
|
||||
self.drop.client_id,
|
||||
self.drop.offered.?.payload,
|
||||
.plain,
|
||||
.st,
|
||||
);
|
||||
}
|
||||
};
|
||||
626
src/terminal/kitty/dnd_test.zig
Normal file
626
src/terminal/kitty/dnd_test.zig
Normal file
@@ -0,0 +1,626 @@
|
||||
//! End-to-end tests for the OSC 72 protocol state machine, validating
|
||||
//! wire behavior against kitty's implementation (using kitty_tests/dnd.py as
|
||||
//! an oracle for the expected bytes).
|
||||
//!
|
||||
//! It isn't normal for us to have dedicated test files but in this case
|
||||
//! the dnd protocol is complicated enough that I wanted full e2e covering
|
||||
//! the full machine.
|
||||
|
||||
const std = @import("std");
|
||||
const testing = std.testing;
|
||||
|
||||
const osc = @import("../osc.zig");
|
||||
const dnd = @import("dnd.zig");
|
||||
|
||||
/// A test harness holding the lazily allocated protocol state and an
|
||||
/// output collector.
|
||||
const Harness = struct {
|
||||
state: ?*dnd.State = null,
|
||||
output: std.Io.Writer.Allocating,
|
||||
|
||||
fn init() Harness {
|
||||
return .{ .output = .init(testing.allocator) };
|
||||
}
|
||||
|
||||
fn deinit(self: *Harness) void {
|
||||
if (self.state) |state| state.destroy(testing.allocator);
|
||||
self.output.deinit();
|
||||
}
|
||||
|
||||
/// The registered state; asserts a client has registered.
|
||||
fn registered(self: *Harness) *dnd.State {
|
||||
return self.state.?;
|
||||
}
|
||||
|
||||
/// Feed one client command, as it would arrive from the OSC parser,
|
||||
/// returning the event the stream handler would pass to its effect.
|
||||
fn command(self: *Harness, metadata: []const u8, payload: ?[]const u8) !?dnd.Event {
|
||||
return try dnd.handleCommand(&self.state, testing.allocator, &self.output.writer, .{
|
||||
.metadata = metadata,
|
||||
.payload = payload,
|
||||
.terminator = .st,
|
||||
});
|
||||
}
|
||||
|
||||
/// Consume and return the collected output.
|
||||
fn consume(self: *Harness) []const u8 {
|
||||
const written = self.output.written();
|
||||
return written;
|
||||
}
|
||||
|
||||
fn clear(self: *Harness) void {
|
||||
self.output.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
fn expectOutput(self: *Harness, expected: []const u8) !void {
|
||||
try testing.expectEqualStrings(expected, self.output.written());
|
||||
self.clear();
|
||||
}
|
||||
};
|
||||
|
||||
test "dnd: query response" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
// Works without any registration, matching kitty, and allocates
|
||||
// nothing.
|
||||
_ = try h.command("t=q", null);
|
||||
try h.expectOutput("\x1b]72;t=q\x1b\\");
|
||||
try testing.expect(h.state == null);
|
||||
}
|
||||
|
||||
test "dnd: query response echoes client id" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=q:i=31", null);
|
||||
try h.expectOutput("\x1b]72;t=q:i=31\x1b\\");
|
||||
}
|
||||
|
||||
test "dnd: register and unregister" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
try testing.expect(h.state == null);
|
||||
|
||||
// Registration allocates the state and reports it, with the
|
||||
// declared MIME list readable from the state.
|
||||
try testing.expect((try h.command("t=a", "text/plain text/uri-list")).? == .registration);
|
||||
try h.expectOutput("");
|
||||
{
|
||||
var it = h.registered().registeredMimes();
|
||||
try testing.expectEqualStrings("text/plain", it.next().?);
|
||||
try testing.expectEqualStrings("text/uri-list", it.next().?);
|
||||
try testing.expect(it.next() == null);
|
||||
}
|
||||
|
||||
// Machine ID declaration is accepted and ignored.
|
||||
try testing.expect((try h.command("t=a:x=1", "1:deadbeef")) == null);
|
||||
try h.expectOutput("");
|
||||
try testing.expect(h.state != null);
|
||||
|
||||
// Re-registration replaces the list.
|
||||
try testing.expect((try h.command("t=a", "image/png")).? == .registration);
|
||||
{
|
||||
var it = h.registered().registeredMimes();
|
||||
try testing.expectEqualStrings("image/png", it.next().?);
|
||||
try testing.expect(it.next() == null);
|
||||
}
|
||||
|
||||
// Registering without a list is the common case.
|
||||
try testing.expect((try h.command("t=a", null)).? == .registration);
|
||||
{
|
||||
var it = h.registered().registeredMimes();
|
||||
try testing.expect(it.next() == null);
|
||||
}
|
||||
|
||||
// Unregistration frees it and reports the change.
|
||||
try testing.expect((try h.command("t=A", null)).? == .registration);
|
||||
try h.expectOutput("");
|
||||
try testing.expect(h.state == null);
|
||||
|
||||
// Unregistering again changes nothing.
|
||||
try testing.expect((try h.command("t=A", null)) == null);
|
||||
try h.expectOutput("");
|
||||
try testing.expect(h.state == null);
|
||||
}
|
||||
|
||||
test "dnd: no state before registration" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
// State-dependent commands from an unregistered client allocate
|
||||
// nothing; a data request gets the error kitty sends from its
|
||||
// zeroed state.
|
||||
_ = try h.command("t=m:o=1", "text/plain");
|
||||
_ = try h.command("t=r", null);
|
||||
try h.expectOutput("");
|
||||
_ = try h.command("t=r:x=1", null);
|
||||
try h.expectOutput(
|
||||
"\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\",
|
||||
);
|
||||
try testing.expect(h.state == null);
|
||||
}
|
||||
|
||||
test "dnd: move event carries position, operations, and mime list" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a", "text/plain");
|
||||
|
||||
try h.registered().dragMove(testing.allocator, &h.output.writer, .{
|
||||
.cell_x = 5,
|
||||
.cell_y = 3,
|
||||
.pixel_x = 100,
|
||||
.pixel_y = 60,
|
||||
.operations = .{ .copy = true },
|
||||
}, &.{ "text/plain", "text/uri-list" });
|
||||
|
||||
// Note the trailing space after every MIME entry, matching kitty.
|
||||
try h.expectOutput(
|
||||
"\x1b]72;t=m:x=5:y=3:X=100:Y=60:o=1:m=0;text/plain text/uri-list \x1b\\",
|
||||
);
|
||||
}
|
||||
|
||||
test "dnd: move event echoes registration client id" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a:i=7", "");
|
||||
try h.registered().dragMove(testing.allocator, &h.output.writer, .{
|
||||
.cell_x = 1,
|
||||
.cell_y = 2,
|
||||
.pixel_x = 8,
|
||||
.pixel_y = 16,
|
||||
.operations = .{ .copy = true, .move = true },
|
||||
}, &.{"text/plain"});
|
||||
try h.expectOutput("\x1b]72;t=m:x=1:y=2:X=8:Y=16:o=3:i=7:m=0;text/plain \x1b\\");
|
||||
}
|
||||
|
||||
test "dnd: re-registration updates client id in place" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a:i=7", "");
|
||||
const state = h.registered();
|
||||
_ = try h.command("t=a:i=9", "");
|
||||
// Same allocation, new client ID.
|
||||
try testing.expect(h.state.? == state);
|
||||
try testing.expectEqual(@as(u32, 9), state.drop.client_id);
|
||||
}
|
||||
|
||||
test "dnd: mime list sent on every move" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a", "");
|
||||
const ev: dnd.MoveEvent = .{
|
||||
.cell_x = 0,
|
||||
.cell_y = 0,
|
||||
.pixel_x = 0,
|
||||
.pixel_y = 0,
|
||||
.operations = .{ .copy = true },
|
||||
};
|
||||
try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"});
|
||||
h.clear();
|
||||
|
||||
// Kitty resends the list even when unchanged; clients depend on it.
|
||||
try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"});
|
||||
try h.expectOutput("\x1b]72;t=m:x=0:y=0:X=0:Y=0:o=1:m=0;text/plain \x1b\\");
|
||||
}
|
||||
|
||||
test "dnd: leave event" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a", "");
|
||||
try h.registered().dragMove(testing.allocator, &h.output.writer, .{
|
||||
.cell_x = 0,
|
||||
.cell_y = 0,
|
||||
.pixel_x = 0,
|
||||
.pixel_y = 0,
|
||||
.operations = .{ .copy = true },
|
||||
}, &.{"text/plain"});
|
||||
h.clear();
|
||||
|
||||
try h.registered().dragLeave(testing.allocator, &h.output.writer);
|
||||
try h.expectOutput("\x1b]72;t=m:x=-1:y=-1\x1b\\");
|
||||
}
|
||||
|
||||
test "dnd: client acceptance recorded" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a", "");
|
||||
try testing.expect(h.registered().clientAccepted() == null);
|
||||
|
||||
try testing.expect((try h.command("t=m:o=1", "text/plain")).? == .acceptance);
|
||||
try h.expectOutput("");
|
||||
try testing.expectEqual(dnd.Operation.copy, h.registered().clientAccepted().?);
|
||||
|
||||
// Rejection.
|
||||
try testing.expect((try h.command("t=m:o=0", "")).? == .acceptance);
|
||||
try testing.expectEqual(dnd.Operation.none, h.registered().clientAccepted().?);
|
||||
}
|
||||
|
||||
test "dnd: chunked client acceptance" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a", "");
|
||||
|
||||
// Chunked accept: continuation metadata is ignored, the acceptance
|
||||
// is pending until the final chunk.
|
||||
try testing.expect((try h.command("t=m:o=2:m=1", "text/pl")) == null);
|
||||
try testing.expect(h.registered().clientAccepted() == null);
|
||||
try testing.expect((try h.command("t=m:m=1", "ain text")) == null);
|
||||
try testing.expect((try h.command("t=m:m=0", "/html")).? == .acceptance);
|
||||
try testing.expectEqual(dnd.Operation.move, h.registered().clientAccepted().?);
|
||||
|
||||
// The accumulated list was converted to NUL-separated entries.
|
||||
try testing.expectEqualSlices(
|
||||
u8,
|
||||
"text/plain\x00text/html\x00",
|
||||
h.registered().drop.accepted_mimes.items,
|
||||
);
|
||||
}
|
||||
|
||||
test "dnd: drop and data serving round trip" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a", "text/plain text/uri-list");
|
||||
|
||||
const ev: dnd.MoveEvent = .{
|
||||
.cell_x = 4,
|
||||
.cell_y = 2,
|
||||
.pixel_x = 40,
|
||||
.pixel_y = 20,
|
||||
.operations = .{ .copy = true },
|
||||
};
|
||||
try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{
|
||||
.{ .mime = "text/uri-list", .data = "file:///tmp/a.txt\r\n" },
|
||||
.{ .mime = "text/plain", .data = "hello" },
|
||||
});
|
||||
try h.expectOutput(
|
||||
"\x1b]72;t=M:x=4:y=2:X=40:Y=20:o=1:m=0;text/uri-list text/plain \x1b\\",
|
||||
);
|
||||
|
||||
// Request the second MIME's data: base64 chunk plus the empty
|
||||
// end-of-data message.
|
||||
_ = try h.command("t=r:x=2", null);
|
||||
try h.expectOutput(
|
||||
"\x1b]72;t=r:x=2:m=0;aGVsbG8=\x1b\\" ++ "\x1b]72;t=r:x=2\x1b\\",
|
||||
);
|
||||
|
||||
// Out-of-bounds request.
|
||||
_ = try h.command("t=r:x=3", null);
|
||||
try h.expectOutput(
|
||||
"\x1b]72;t=R:x=3:m=0;ENOENT:drop data request index out of bounds\x1b\\",
|
||||
);
|
||||
|
||||
// Conclude: the performed operation is reported, held data is
|
||||
// freed, and further requests fail.
|
||||
try testing.expectEqual(dnd.Event.concluded_copy, (try h.command("t=r:o=1", null)).?);
|
||||
try h.expectOutput("");
|
||||
try testing.expect((try h.command("t=r:o=1", null)) == null);
|
||||
_ = try h.command("t=r:x=1", null);
|
||||
try h.expectOutput(
|
||||
"\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\",
|
||||
);
|
||||
}
|
||||
|
||||
test "dnd: empty item served as a single end-of-data message" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a", "");
|
||||
try h.registered().dragDrop(testing.allocator, &h.output.writer, .{
|
||||
.cell_x = 0,
|
||||
.cell_y = 0,
|
||||
.pixel_x = 0,
|
||||
.pixel_y = 0,
|
||||
.operations = .{ .copy = true },
|
||||
}, &.{.{ .mime = "text/plain", .data = "" }});
|
||||
h.clear();
|
||||
|
||||
// Kitty's oracle (test_empty_data) asserts exactly one message:
|
||||
// the empty response is itself the end-of-data signal, and a
|
||||
// duplicate would be a second completion to the client.
|
||||
_ = try h.command("t=r:x=1", null);
|
||||
try h.expectOutput("\x1b]72;t=r:x=1\x1b\\");
|
||||
}
|
||||
|
||||
test "dnd: leave without hover sends nothing" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
// Client registered but no move was ever forwarded (e.g. it
|
||||
// registered mid-drag): kitty only notifies hovered windows.
|
||||
_ = try h.command("t=a", "");
|
||||
try h.registered().dragLeave(testing.allocator, &h.output.writer);
|
||||
try h.expectOutput("");
|
||||
}
|
||||
|
||||
test "dnd: data request with no drop" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a", "");
|
||||
_ = try h.command("t=r:x=1", null);
|
||||
try h.expectOutput(
|
||||
"\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\",
|
||||
);
|
||||
}
|
||||
|
||||
test "dnd: leave after drop is ignored" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a", "");
|
||||
try h.registered().dragDrop(testing.allocator, &h.output.writer, .{
|
||||
.cell_x = 0,
|
||||
.cell_y = 0,
|
||||
.pixel_x = 0,
|
||||
.pixel_y = 0,
|
||||
.operations = .{ .copy = true },
|
||||
}, &.{.{ .mime = "text/plain", .data = "x" }});
|
||||
h.clear();
|
||||
|
||||
// Some toolkits emit a leave for the drop itself; the held data
|
||||
// must survive so the client can still fetch it.
|
||||
try h.registered().dragLeave(testing.allocator, &h.output.writer);
|
||||
try h.expectOutput("");
|
||||
|
||||
_ = try h.command("t=r:x=1", null);
|
||||
try h.expectOutput(
|
||||
"\x1b]72;t=r:x=1:m=0;eA==\x1b\\" ++ "\x1b]72;t=r:x=1\x1b\\",
|
||||
);
|
||||
}
|
||||
|
||||
test "dnd: new drag resets held drop data" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a", "");
|
||||
const ev: dnd.MoveEvent = .{
|
||||
.cell_x = 0,
|
||||
.cell_y = 0,
|
||||
.pixel_x = 0,
|
||||
.pixel_y = 0,
|
||||
.operations = .{ .copy = true },
|
||||
};
|
||||
try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{
|
||||
.{ .mime = "text/plain", .data = "old" },
|
||||
});
|
||||
h.clear();
|
||||
|
||||
// A new drag entering resets the per-drag state including the held
|
||||
// items from the unconcluded previous drop.
|
||||
try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"});
|
||||
h.clear();
|
||||
_ = try h.command("t=r:x=1", null);
|
||||
try h.expectOutput(
|
||||
"\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\",
|
||||
);
|
||||
}
|
||||
|
||||
test "dnd: remote transfer requests refused" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a", "");
|
||||
|
||||
// URI file content request.
|
||||
_ = try h.command("t=r:x=1:y=2", null);
|
||||
try h.expectOutput(
|
||||
"\x1b]72;t=R:x=1:y=2:m=0;EINVAL:remote drop data is not supported\x1b\\",
|
||||
);
|
||||
|
||||
// Directory handle request.
|
||||
_ = try h.command("t=r:Y=2:x=1", null);
|
||||
try h.expectOutput(
|
||||
"\x1b]72;t=R:x=1:Y=2:m=0;EINVAL:remote drop data is not supported\x1b\\",
|
||||
);
|
||||
}
|
||||
|
||||
test "dnd: drag out refused" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
// Enabling and disabling offers is accepted silently and allocates
|
||||
// nothing.
|
||||
_ = try h.command("t=o:x=1", null);
|
||||
_ = try h.command("t=o:x=2", null);
|
||||
try h.expectOutput("");
|
||||
try testing.expect(h.state == null);
|
||||
|
||||
// Offering a drag is refused.
|
||||
_ = try h.command("t=o:x=1", null);
|
||||
_ = try h.command("t=o:o=3", "text/plain");
|
||||
try h.expectOutput(
|
||||
"\x1b]72;t=E:m=0;EPERM:drag out is not supported by this terminal\x1b\\",
|
||||
);
|
||||
|
||||
// Starting a drag is refused, echoing the command's client id.
|
||||
_ = try h.command("t=P:x=-1:i=9", null);
|
||||
try h.expectOutput(
|
||||
"\x1b]72;t=E:i=9:m=0;EPERM:drag out is not supported by this terminal\x1b\\",
|
||||
);
|
||||
try testing.expect(h.state == null);
|
||||
}
|
||||
|
||||
test "dnd: unregister frees held drop data" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a", "");
|
||||
try h.registered().dragDrop(testing.allocator, &h.output.writer, .{
|
||||
.cell_x = 0,
|
||||
.cell_y = 0,
|
||||
.pixel_x = 0,
|
||||
.pixel_y = 0,
|
||||
.operations = .{ .copy = true },
|
||||
}, &.{.{ .mime = "text/plain", .data = "x" }});
|
||||
h.clear();
|
||||
|
||||
// The testing allocator would report the held data as leaked if
|
||||
// unregistration didn't free the whole state.
|
||||
_ = try h.command("t=A", null);
|
||||
try testing.expect(h.state == null);
|
||||
}
|
||||
|
||||
test "dnd: chunked registration reuses first chunk metadata" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
// Registration split over two chunks: the first chunk allocates
|
||||
// the state and seeds chunk reassembly, so the continuation (which
|
||||
// carries a different type) is still treated as the registration.
|
||||
try testing.expect((try h.command("t=a:i=4:m=1", "text/pla")) == null);
|
||||
try testing.expect(h.state != null);
|
||||
try testing.expect((try h.command("t=q:m=0", "in")).? == .registration);
|
||||
try h.expectOutput("");
|
||||
try testing.expectEqual(@as(u32, 4), h.registered().drop.client_id);
|
||||
{
|
||||
var it = h.registered().registeredMimes();
|
||||
try testing.expectEqualStrings("text/plain", it.next().?);
|
||||
}
|
||||
|
||||
// A query after the chunked command completes works again.
|
||||
_ = try h.command("t=q", null);
|
||||
try h.expectOutput("\x1b]72;t=q\x1b\\");
|
||||
}
|
||||
|
||||
test "dnd: malformed metadata ignored" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a:zz=1", "");
|
||||
try h.expectOutput("");
|
||||
try testing.expect(h.state == null);
|
||||
|
||||
// Command with no type is ignored, matching kitty (the spec's
|
||||
// default of t=a is not honored by the reference implementation).
|
||||
_ = try h.command("x=1", "");
|
||||
try h.expectOutput("");
|
||||
try testing.expect(h.state == null);
|
||||
}
|
||||
|
||||
test "dnd: bel terminator echoed in responses" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try dnd.handleCommand(&h.state, testing.allocator, &h.output.writer, .{
|
||||
.metadata = "t=q",
|
||||
.payload = null,
|
||||
.terminator = .bel,
|
||||
});
|
||||
try h.expectOutput("\x1b]72;t=q\x07");
|
||||
}
|
||||
|
||||
test "dnd: kitten 0.47 conversation replay" {
|
||||
// This replays a conversation recorded from the reference client
|
||||
// (`kitten dnd --drop-anywhere=copy --drop text/plain:out.txt`,
|
||||
// kitten 0.47.0) driven over a pty by a harness that sent exactly
|
||||
// the bytes this engine produces. The kitten accepted the events,
|
||||
// wrote the dropped payload to disk intact, and concluded; its
|
||||
// client bytes are frozen here as an interop regression test.
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
// Startup: register with MIME list and machine ID, then the test
|
||||
// harness reset (unregister both directions, re-register).
|
||||
_ = try h.command("t=a:m=0", "text/uri-list text/plain");
|
||||
_ = try h.command(
|
||||
"t=a:x=1:m=0",
|
||||
"1:5cff8247c477900a8727e2281fe890252f8848f87c224dd8dd7fb6303e94ddbd",
|
||||
);
|
||||
_ = try h.command("t=A", null);
|
||||
try testing.expect(h.state == null);
|
||||
_ = try h.command("t=o:x=2", null);
|
||||
_ = try h.command("t=a:m=0", "text/uri-list text/plain");
|
||||
_ = try h.command(
|
||||
"t=a:x=1:m=0",
|
||||
"1:5cff8247c477900a8727e2281fe890252f8848f87c224dd8dd7fb6303e94ddbd",
|
||||
);
|
||||
try h.expectOutput("");
|
||||
try testing.expect(h.state != null);
|
||||
|
||||
// Native drag moves over the terminal and drops.
|
||||
const ev: dnd.MoveEvent = .{
|
||||
.cell_x = 2,
|
||||
.cell_y = 1,
|
||||
.pixel_x = 20,
|
||||
.pixel_y = 18,
|
||||
.operations = .{ .copy = true },
|
||||
};
|
||||
try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"});
|
||||
try h.expectOutput("\x1b]72;t=m:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\");
|
||||
|
||||
// The kitten accepts as a copy of text/plain.
|
||||
_ = try h.command("t=m:o=1:m=0", "text/plain");
|
||||
try h.expectOutput("");
|
||||
try testing.expectEqual(dnd.Operation.copy, h.registered().clientAccepted().?);
|
||||
|
||||
try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{
|
||||
.{ .mime = "text/plain", .data = "hello from ghostty\n" },
|
||||
});
|
||||
try h.expectOutput("\x1b]72;t=M:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\");
|
||||
|
||||
// The kitten requests the data and concludes with a copy.
|
||||
_ = try h.command("t=r:x=1", null);
|
||||
try h.expectOutput(
|
||||
"\x1b]72;t=r:x=1:m=0;aGVsbG8gZnJvbSBnaG9zdHR5Cg==\x1b\\" ++
|
||||
"\x1b]72;t=r:x=1\x1b\\",
|
||||
);
|
||||
_ = try h.command("t=r:o=1", null);
|
||||
try h.expectOutput("");
|
||||
try testing.expect(h.registered().drop.items == null);
|
||||
}
|
||||
|
||||
test "dnd: large data served in chunks" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
_ = try h.command("t=a", "");
|
||||
|
||||
// 3073 bytes: one full chunk plus one byte.
|
||||
const data = [_]u8{'Z'} ** 3073;
|
||||
try h.registered().dragDrop(testing.allocator, &h.output.writer, .{
|
||||
.cell_x = 0,
|
||||
.cell_y = 0,
|
||||
.pixel_x = 0,
|
||||
.pixel_y = 0,
|
||||
.operations = .{ .copy = true },
|
||||
}, &.{.{ .mime = "application/octet-stream", .data = &data }});
|
||||
h.clear();
|
||||
|
||||
_ = try h.command("t=r:x=1", null);
|
||||
const out = h.consume();
|
||||
|
||||
// First chunk is m=1 with 4096 base64 chars, second is m=0, and
|
||||
// the final message is the bare end-of-data marker.
|
||||
try testing.expect(std.mem.startsWith(u8, out, "\x1b]72;t=r:x=1:m=1;"));
|
||||
try testing.expect(std.mem.indexOf(u8, out, "\x1b]72;t=r:x=1:m=0;") != null);
|
||||
try testing.expect(std.mem.endsWith(u8, out, "\x1b]72;t=r:x=1\x1b\\"));
|
||||
h.clear();
|
||||
}
|
||||
|
||||
test "dnd: over-cap registration list never completes" {
|
||||
var h: Harness = .init();
|
||||
defer h.deinit();
|
||||
|
||||
// Matching kitty, a chunk that would exceed the cap is dropped and
|
||||
// the registration is not reported, though the client stays
|
||||
// registered (the state exists).
|
||||
const big = try testing.allocator.alloc(u8, dnd.max_mime_list_bytes + 1);
|
||||
defer testing.allocator.free(big);
|
||||
@memset(big, 'a');
|
||||
try testing.expect((try h.command("t=a", big)) == null);
|
||||
try testing.expect(h.state != null);
|
||||
{
|
||||
var it = h.registered().registeredMimes();
|
||||
try testing.expect(it.next() == null);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
//! Kitty's drag and drop protocol (OSC 72)
|
||||
//! Specification: https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/
|
||||
//!
|
||||
//! This only captures the raw metadata and payload for the OSC. The
|
||||
//! actual protocol grammar (metadata keys, event types, chunking) is
|
||||
//! implemented in `terminal/kitty/dnd.zig` and its submodules, since the
|
||||
//! protocol requires stateful handling that doesn't belong in the
|
||||
//! stateless OSC parser.
|
||||
//!
|
||||
//! Specification: https://sw.kovidgoyal.net/kitty/dnd-protocol/
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
@@ -9,143 +16,52 @@ 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 `readOption`.
|
||||
/// The raw metadata that was received. Parse with
|
||||
/// `kitty.dnd.Metadata.parse`.
|
||||
metadata: []const u8,
|
||||
/// The raw payload. Its meaning and encoding depend on the event type (`t` key).
|
||||
|
||||
/// The raw payload. Its meaning and encoding depend on the event
|
||||
/// type (`t` metadata key). Null when the OSC had no `;` after the
|
||||
/// metadata; an empty payload is distinct from no payload.
|
||||
payload: ?[]const u8,
|
||||
|
||||
/// The terminator used for this OSC, so any response can match it.
|
||||
terminator: Terminator,
|
||||
|
||||
pub fn readOption(self: OSC, comptime key: Option) ?key.Type() {
|
||||
return key.read(self.metadata);
|
||||
/// We don't currently support encoding this to C in any way.
|
||||
pub const C = void;
|
||||
|
||||
pub fn cval(_: OSC) C {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/// 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` (multiplexer session ID).
|
||||
drop_dropped,
|
||||
/// ('r') Terminal requests data for a specific MIME type from the drag source.
|
||||
/// 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` (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.
|
||||
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 parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
|
||||
assert(parser.state == .@"72");
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
};
|
||||
const cap = if (parser.capture) |*c| c else {
|
||||
parser.state = .invalid;
|
||||
return null;
|
||||
};
|
||||
|
||||
/// 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,
|
||||
/// Chunking flag. `0` = this is the final (or only) chunk; `1` = more chunks follow.
|
||||
m,
|
||||
/// Multiplexer session ID. Echoed back in responses so a terminal multiplexer
|
||||
/// (e.g. tmux) can route data to the correct pane.
|
||||
i,
|
||||
/// Drop operation. `0` = reject, `1` = copy, `2` = move, `3` = copy or move.
|
||||
o,
|
||||
/// Cursor column in cell units (zero-based). -1 signals the drag has left the window.
|
||||
x,
|
||||
/// 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,
|
||||
/// 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,
|
||||
/// 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,
|
||||
const data = cap.trailing();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
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] };
|
||||
};
|
||||
|
||||
pub fn read(comptime key: Option, metadata: []const u8) ?key.Type() {
|
||||
const name = @tagName(key);
|
||||
parser.command = .{
|
||||
.kitty_dnd_protocol = .{
|
||||
.metadata = metadata,
|
||||
.payload = payload,
|
||||
.terminator = .init(terminator_ch),
|
||||
},
|
||||
};
|
||||
|
||||
const value: []const u8 = value: {
|
||||
var pos: usize = 0;
|
||||
while (pos < metadata.len) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
};
|
||||
return &parser.command;
|
||||
}
|
||||
|
||||
test "OSC 72: metadata only, no payload" {
|
||||
const testing = std.testing;
|
||||
@@ -192,134 +108,19 @@ test "OSC 72: metadata and non-empty payload" {
|
||||
try testing.expectEqualStrings("text/plain text/uri-list", cmd.kitty_dnd_protocol.payload.?);
|
||||
}
|
||||
|
||||
test "OSC 72: readOption .t valid event types" {
|
||||
test "OSC 72: empty metadata with payload" {
|
||||
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";
|
||||
const input = "72;;payload";
|
||||
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);
|
||||
try testing.expectEqualStrings("", cmd.kitty_dnd_protocol.metadata);
|
||||
try testing.expectEqualStrings("payload", cmd.kitty_dnd_protocol.payload.?);
|
||||
}
|
||||
|
||||
test "OSC 72: BEL terminator recorded" {
|
||||
@@ -335,29 +136,3 @@ test "OSC 72: BEL terminator recorded" {
|
||||
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");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user