mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
libghostty: clipboard_read effect, enables OSC52 reads (#13965)
This adds a `clipboard_read` effect to the stream terminal handler and a matching `GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ` callback to the libghostty-vt C API so that embedders can answer OSC 52 read requests (the `?` payload). This is a _blocking_ effect: if the embedder needs to ask the user for permission, the entire VT processing pipeline is _blocked_ during the callback. This is a purposeful simplification choice compared to how Ghostty GUI works with async requests. I think its reasonable, it eliminates a TON of complexity. If the effect isn't set, then any clipboard reads are denied. This can be expanded easily to support Kitty clipboard protocol later.
This commit is contained in:
@@ -70,6 +70,46 @@ GhosttyClipboardWriteResult on_clipboard_write(
|
||||
}
|
||||
//! [effects-clipboard-write]
|
||||
|
||||
//! [effects-clipboard-read]
|
||||
void on_clipboard_read(
|
||||
GhosttyTerminal terminal,
|
||||
void* userdata,
|
||||
const GhosttyClipboardRead* read) {
|
||||
(void)terminal;
|
||||
(void)userdata;
|
||||
|
||||
// The read is synchronous: a real embedder would ask the user for
|
||||
// permission here (unless read->granted) and the VT stream waits until
|
||||
// this callback returns. The reply is sent to the program through the
|
||||
// write_pty callback.
|
||||
printf(" clipboard read (location=%d, mimes=%zu)\n",
|
||||
(int)read->location, read->mimes_len);
|
||||
for (size_t i = 0; i < read->mimes_len; i++) {
|
||||
printf(" ");
|
||||
fwrite(read->mimes[i].ptr, 1, read->mimes[i].len, stdout);
|
||||
printf("\n");
|
||||
}
|
||||
|
||||
// Reply with every requested representation we have. This example only
|
||||
// has text.
|
||||
const char* text = "Hello from the clipboard";
|
||||
GhosttyClipboardContent content = {
|
||||
.mime = {.ptr = (const uint8_t*)"text/plain", .len = 10},
|
||||
.data = {.ptr = (const uint8_t*)text, .len = strlen(text)},
|
||||
};
|
||||
GhosttyClipboardReadReply reply = {
|
||||
.size = sizeof(reply),
|
||||
.result = GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS,
|
||||
.contents = &content,
|
||||
.contents_len = 1,
|
||||
.available = NULL,
|
||||
.available_len = 0,
|
||||
.remember = false,
|
||||
};
|
||||
read->reply(read, &reply);
|
||||
}
|
||||
//! [effects-clipboard-read]
|
||||
|
||||
//! [effects-unknown-sequence]
|
||||
void on_unknown_sequence(
|
||||
GhosttyTerminal terminal,
|
||||
@@ -118,6 +158,8 @@ int main() {
|
||||
(const void *)on_title_changed);
|
||||
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE,
|
||||
(const void *)on_clipboard_write);
|
||||
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ,
|
||||
(const void *)on_clipboard_read);
|
||||
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE,
|
||||
(const void *)on_unknown_sequence);
|
||||
|
||||
@@ -154,13 +196,19 @@ int main() {
|
||||
ghostty_terminal_vt_write(terminal, (const uint8_t*)clipboard_seq,
|
||||
strlen(clipboard_seq));
|
||||
|
||||
// 5. Unsupported APC sequence
|
||||
// 5. Clipboard read (OSC 52 ; c ; ? ST)
|
||||
printf("Sending clipboard read:\n");
|
||||
const char* clipboard_read_seq = "\x1B]52;c;?\x1B\\";
|
||||
ghostty_terminal_vt_write(terminal, (const uint8_t*)clipboard_read_seq,
|
||||
strlen(clipboard_read_seq));
|
||||
|
||||
// 6. Unsupported APC sequence
|
||||
printf("Sending unknown APC:\n");
|
||||
const char* unknown_apc = "\x1B_private-command;payload\x1B\\";
|
||||
ghostty_terminal_vt_write(terminal, (const uint8_t*)unknown_apc,
|
||||
strlen(unknown_apc));
|
||||
|
||||
// 6. Another bell to show the counter increments
|
||||
// 7. Another bell to show the counter increments
|
||||
printf("Sending another BEL:\n");
|
||||
ghostty_terminal_vt_write(terminal, &bel, 1);
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ extern "C" {
|
||||
* | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) |
|
||||
* | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)|
|
||||
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 |
|
||||
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ` | `GhosttyTerminalClipboardReadFn` | Clipboard read via OSC 52 "?" |
|
||||
* | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 |
|
||||
* | `GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT` | `GhosttyTerminalProgressReportFn` | Progress report via OSC 9;4 |
|
||||
* | `GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE` | `GhosttyTerminalUnknownSequenceFn` | Unsupported sequence identifier |
|
||||
@@ -112,6 +113,9 @@ extern "C" {
|
||||
* ### Defining a clipboard_write callback
|
||||
* @snippet c-vt-effects/src/main.c effects-clipboard-write
|
||||
*
|
||||
* ### Defining a clipboard_read callback
|
||||
* @snippet c-vt-effects/src/main.c effects-clipboard-read
|
||||
*
|
||||
* ### Defining an unknown_sequence callback
|
||||
* @snippet c-vt-effects/src/main.c effects-unknown-sequence
|
||||
*
|
||||
@@ -522,8 +526,8 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* details such as OSC 52 selectors, base64 encoding, multipart chunks,
|
||||
* aliases, and terminators are normalized before this callback is invoked.
|
||||
* OSC 52 and iTerm2 OSC 1337 Copy writes therefore use the same callback
|
||||
* shape. OSC 52 clipboard read requests ("?") are always ignored and never
|
||||
* forwarded to this callback.
|
||||
* shape. OSC 52 clipboard read requests ("?") are delivered to
|
||||
* GhosttyTerminalClipboardReadFn instead.
|
||||
*
|
||||
* @param terminal The terminal handle
|
||||
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
|
||||
@@ -537,6 +541,180 @@ typedef GhosttyClipboardWriteResult (*GhosttyTerminalClipboardWriteFn)(
|
||||
void* userdata,
|
||||
const GhosttyClipboardWrite* write);
|
||||
|
||||
/**
|
||||
* Result of a clipboard read reply.
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef enum {
|
||||
/** The clipboard was read; the reply carries its contents. */
|
||||
GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS = 0,
|
||||
|
||||
/** The clipboard read was denied by policy or the user. */
|
||||
GHOSTTY_CLIPBOARD_READ_RESULT_DENIED = 1,
|
||||
|
||||
/** The embedder cannot read this clipboard. */
|
||||
GHOSTTY_CLIPBOARD_READ_RESULT_UNSUPPORTED = 2,
|
||||
|
||||
/** The clipboard is temporarily unavailable. */
|
||||
GHOSTTY_CLIPBOARD_READ_RESULT_BUSY = 3,
|
||||
|
||||
/** Reading the clipboard failed due to an I/O error. */
|
||||
GHOSTTY_CLIPBOARD_READ_RESULT_IO_ERROR = 4,
|
||||
GHOSTTY_CLIPBOARD_READ_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyClipboardReadResult;
|
||||
|
||||
/**
|
||||
* The reply to a clipboard read request.
|
||||
*
|
||||
* This is a sized struct; set `size` to `sizeof(GhosttyClipboardReadReply)`.
|
||||
* All arrays and the strings they point to are borrowed only for the
|
||||
* duration of the reply call and may be freed as soon as it returns.
|
||||
*
|
||||
* Any result other than GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS answers the
|
||||
* program with an empty clipboard; the other fields are ignored in that
|
||||
* case. On success, `contents` should carry one representation per
|
||||
* requested MIME type (GhosttyClipboardRead::mimes) that the clipboard
|
||||
* has; unrequested representations are ignored. Protocols that carry a
|
||||
* single text value (OSC 52) use the first entry with a text MIME type
|
||||
* such as "text/plain".
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef struct {
|
||||
/** Size of this struct in bytes. */
|
||||
size_t size;
|
||||
|
||||
/** Outcome of the read. */
|
||||
GhosttyClipboardReadResult result;
|
||||
|
||||
/** Borrowed array of MIME representations of the clipboard contents. */
|
||||
const GhosttyClipboardContent* contents;
|
||||
|
||||
/** Number of entries in contents. */
|
||||
size_t contents_len;
|
||||
|
||||
/**
|
||||
* Borrowed array of all MIME types available on the clipboard. Only
|
||||
* used when GhosttyClipboardRead::list is set; may be NULL otherwise.
|
||||
*/
|
||||
const GhosttyString* available;
|
||||
|
||||
/** Number of entries in available. */
|
||||
size_t available_len;
|
||||
|
||||
/**
|
||||
* Record a session grant so future requests from the same program skip
|
||||
* the permission prompt. Only honored on success when
|
||||
* GhosttyClipboardRead::can_remember is set.
|
||||
*/
|
||||
bool remember;
|
||||
} GhosttyClipboardReadReply;
|
||||
|
||||
typedef struct GhosttyClipboardRead GhosttyClipboardRead;
|
||||
|
||||
/**
|
||||
* Function type used to answer a clipboard read request. Obtained from
|
||||
* GhosttyClipboardRead::reply; see that struct for the contract.
|
||||
*
|
||||
* @param read The request being answered
|
||||
* @param reply The reply, borrowed only for the duration of this call
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef void (*GhosttyClipboardReadReplyFn)(
|
||||
const GhosttyClipboardRead* read,
|
||||
const GhosttyClipboardReadReply* reply);
|
||||
|
||||
/**
|
||||
* A synchronous request to read clipboard contents.
|
||||
*
|
||||
* This is a sized struct. The callback must only access fields present in the
|
||||
* size reported by `size`. The request is borrowed and valid only for the
|
||||
* callback duration.
|
||||
*
|
||||
* The read is answered by calling `reply` with this request and a
|
||||
* GhosttyClipboardReadReply. This must happen before the callback returns;
|
||||
* the request is invalid afterwards. Calling `reply` more than once is
|
||||
* ignored. Returning without replying answers the program with an empty
|
||||
* clipboard.
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
struct GhosttyClipboardRead {
|
||||
/** Size of this struct in bytes. */
|
||||
size_t size;
|
||||
|
||||
/** Clipboard to read. */
|
||||
GhosttyClipboardLocation location;
|
||||
|
||||
/**
|
||||
* Borrowed array of the MIME types the program wants, in order of
|
||||
* preference. Protocols that only carry text (OSC 52) request
|
||||
* "text/plain". NULL when mimes_len is zero.
|
||||
*/
|
||||
const GhosttyString* mimes;
|
||||
|
||||
/** Number of entries in mimes. */
|
||||
size_t mimes_len;
|
||||
|
||||
/**
|
||||
* True if the program also wants the list of MIME types available on the
|
||||
* clipboard, delivered through GhosttyClipboardReadReply::available.
|
||||
*/
|
||||
bool list;
|
||||
|
||||
/**
|
||||
* Name of the requesting program for permission prompts, if the protocol
|
||||
* carries one. Empty otherwise.
|
||||
*/
|
||||
GhosttyString name;
|
||||
|
||||
/**
|
||||
* True if the terminal already holds a session grant for this request
|
||||
* (kitty clipboard protocol passwords). The embedder should skip any
|
||||
* permission prompt and serve the read.
|
||||
*/
|
||||
bool granted;
|
||||
|
||||
/**
|
||||
* True if the program supplied a session password, so the embedder may
|
||||
* offer to remember the user's decision through
|
||||
* GhosttyClipboardReadReply::remember. When false, remember is ignored.
|
||||
*/
|
||||
bool can_remember;
|
||||
|
||||
/** Terminal-owned reply state. Do not access. */
|
||||
const void* ctx;
|
||||
|
||||
/** Answer the read; see the struct documentation. */
|
||||
GhosttyClipboardReadReplyFn reply;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback function type for clipboard_read.
|
||||
*
|
||||
* Called synchronously when the running program requests clipboard contents
|
||||
* via OSC 52 with a "?" payload. Answering lets the program read the user's
|
||||
* clipboard, so the embedder is expected to mediate consent. Because the
|
||||
* read is synchronous, an embedder that needs to ask the user must block
|
||||
* (for example by running a modal prompt) until it has an answer; the VT
|
||||
* stream waits until the callback returns.
|
||||
*
|
||||
* Answer by calling `read->reply(read, &reply)` before returning. See
|
||||
* GhosttyClipboardRead for the full contract.
|
||||
*
|
||||
* @param terminal The terminal handle
|
||||
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
|
||||
* @param read Borrowed clipboard read request
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef void (*GhosttyTerminalClipboardReadFn)(
|
||||
GhosttyTerminal terminal,
|
||||
void* userdata,
|
||||
const GhosttyClipboardRead* read);
|
||||
|
||||
/**
|
||||
* A request to show a desktop notification.
|
||||
*
|
||||
@@ -1067,8 +1245,8 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* Callback invoked when the running program performs a clipboard write.
|
||||
* OSC 52 and iTerm2 OSC 1337 Copy writes are normalized to an atomic set
|
||||
* of decoded MIME representations. Set to NULL to ignore clipboard writes.
|
||||
* Clipboard read requests are always ignored; see
|
||||
* GhosttyTerminalClipboardWriteFn.
|
||||
* Clipboard read requests are delivered to
|
||||
* GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ instead.
|
||||
*
|
||||
* Input type: GhosttyTerminalClipboardWriteFn
|
||||
*/
|
||||
@@ -1230,6 +1408,16 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* Input type: GhosttyString*
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_TERMINFO_NAME = 37,
|
||||
|
||||
/**
|
||||
* Callback invoked when the running program requests clipboard contents
|
||||
* via OSC 52 with a "?" payload. The read is synchronous and must be
|
||||
* answered before the callback returns. Set to NULL to ignore clipboard
|
||||
* read requests (the default).
|
||||
*
|
||||
* Input type: GhosttyTerminalClipboardReadFn
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ = 38,
|
||||
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyTerminalOption;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ pub const String = extern struct {
|
||||
.ptr = zig.ptr,
|
||||
.len = zig.len,
|
||||
},
|
||||
else => @compileError("unsupported String.init type: " ++ @typeName(@TypeOf(zig))),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ pub const apc = terminal.apc;
|
||||
pub const dcs = terminal.dcs;
|
||||
pub const osc = terminal.osc;
|
||||
pub const point = terminal.point;
|
||||
pub const clipboard = terminal.clipboard;
|
||||
pub const color = terminal.color;
|
||||
pub const device_status = terminal.device_status;
|
||||
pub const formatter = terminal.formatter;
|
||||
|
||||
@@ -138,6 +138,42 @@ pub const ClipboardWrite = extern struct {
|
||||
contents_len: usize,
|
||||
};
|
||||
|
||||
/// The reply to a clipboard read request.
|
||||
///
|
||||
/// C: GhosttyClipboardReadReply
|
||||
pub const ClipboardReadReply = extern struct {
|
||||
size: usize,
|
||||
result: clipboard.Read.Status,
|
||||
contents: ?[*]const ClipboardContent,
|
||||
contents_len: usize,
|
||||
available: ?[*]const lib.String,
|
||||
available_len: usize,
|
||||
remember: bool,
|
||||
};
|
||||
|
||||
/// A synchronous request to read clipboard contents. The embedder answers
|
||||
/// by calling `reply` with the request before the callback returns.
|
||||
///
|
||||
/// C: GhosttyClipboardRead
|
||||
pub const ClipboardRead = extern struct {
|
||||
size: usize,
|
||||
location: clipboard.Location,
|
||||
mimes: ?[*]const lib.String,
|
||||
mimes_len: usize,
|
||||
list: bool,
|
||||
name: lib.String,
|
||||
granted: bool,
|
||||
can_remember: bool,
|
||||
/// Terminal-owned reply state; opaque to the embedder.
|
||||
ctx: *const anyopaque,
|
||||
reply: ClipboardReadReplyFn,
|
||||
};
|
||||
|
||||
/// C function pointer type for replying to a clipboard read.
|
||||
///
|
||||
/// C: GhosttyClipboardReadReplyFn
|
||||
pub const ClipboardReadReplyFn = *const fn (*const ClipboardRead, *const ClipboardReadReply) callconv(lib.calling_conv) void;
|
||||
|
||||
/// A request to show a desktop notification.
|
||||
///
|
||||
/// C: GhosttyTerminalDesktopNotification
|
||||
@@ -220,6 +256,7 @@ const Effects = struct {
|
||||
progress_report: ?ProgressReportFn = null,
|
||||
size_cb: ?SizeFn = null,
|
||||
clipboard_write: ?ClipboardWriteFn = null,
|
||||
clipboard_read: ?ClipboardReadFn = null,
|
||||
unknown_sequence: ?UnknownSequenceFn = null,
|
||||
|
||||
/// Scratch buffer for DA1 feature codes. The device attributes
|
||||
@@ -255,6 +292,11 @@ const Effects = struct {
|
||||
/// and its contents are borrowed and only valid for the callback duration.
|
||||
pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, *const ClipboardWrite) callconv(lib.calling_conv) clipboard.WriteResult;
|
||||
|
||||
/// C function pointer type for the clipboard_read callback. The request
|
||||
/// is borrowed for the callback duration and must be answered through
|
||||
/// its reply function before the callback returns.
|
||||
pub const ClipboardReadFn = *const fn (Terminal, ?*anyopaque, *const ClipboardRead) callconv(lib.calling_conv) void;
|
||||
|
||||
/// C function pointer type for the desktop_notification callback. The
|
||||
/// request and its strings are borrowed for the callback duration.
|
||||
pub const DesktopNotificationFn = *const fn (Terminal, ?*anyopaque, *const DesktopNotification) callconv(lib.calling_conv) void;
|
||||
@@ -356,6 +398,100 @@ const Effects = struct {
|
||||
return func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
|
||||
}
|
||||
|
||||
/// Opaque context behind ClipboardRead.ctx for the reply trampoline.
|
||||
const ClipboardReadCtx = struct {
|
||||
read: clipboard.Read,
|
||||
wrapper: *TerminalWrapper,
|
||||
};
|
||||
|
||||
fn clipboardReadTrampoline(handler: *Handler, read: clipboard.Read) void {
|
||||
const wrapper = TerminalWrapper.fromHandler(handler);
|
||||
const func = wrapper.effects.clipboard_read orelse return;
|
||||
|
||||
// Requests carry a handful of MIME types, so keep the common case
|
||||
// allocation-free. On OOM the request goes unanswered and the
|
||||
// handler replies with an empty clipboard.
|
||||
var sfa = std.heap.stackFallback(128, wrapper.terminal.gpa());
|
||||
const alloc = sfa.get();
|
||||
const mimes = alloc.alloc(lib.String, read.mimes.len) catch {
|
||||
log.warn("out of memory converting clipboard read request", .{});
|
||||
return;
|
||||
};
|
||||
defer alloc.free(mimes);
|
||||
for (mimes, read.mimes) |*c_mime, mime| c_mime.* = .init(mime);
|
||||
|
||||
const ctx: ClipboardReadCtx = .{ .read = read, .wrapper = wrapper };
|
||||
const request: ClipboardRead = .{
|
||||
.size = @sizeOf(ClipboardRead),
|
||||
.location = read.location,
|
||||
.mimes = if (mimes.len > 0) mimes.ptr else null,
|
||||
.mimes_len = mimes.len,
|
||||
.list = read.list,
|
||||
.name = .init(read.name),
|
||||
.granted = read.granted,
|
||||
.can_remember = read.can_remember,
|
||||
.ctx = &ctx,
|
||||
.reply = &clipboardReadReplyTrampoline,
|
||||
};
|
||||
func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
|
||||
}
|
||||
|
||||
fn clipboardReadReplyTrampoline(
|
||||
request: *const ClipboardRead,
|
||||
reply: *const ClipboardReadReply,
|
||||
) callconv(lib.calling_conv) void {
|
||||
const ctx: *const ClipboardReadCtx = @ptrCast(@alignCast(request.ctx));
|
||||
const read = ctx.read;
|
||||
switch (reply.result) {
|
||||
.success => {},
|
||||
.denied => return read.reply(.denied),
|
||||
.busy => return read.reply(.busy),
|
||||
.io_error => return read.reply(.io_error),
|
||||
.unsupported, _ => return read.reply(.unsupported),
|
||||
}
|
||||
|
||||
const c_contents: []const ClipboardContent = if (reply.contents) |ptr|
|
||||
ptr[0..reply.contents_len]
|
||||
else
|
||||
&.{};
|
||||
const c_available: []const lib.String = if (reply.available) |ptr|
|
||||
ptr[0..reply.available_len]
|
||||
else
|
||||
&.{};
|
||||
|
||||
// Most replies carry one representation, so keep that path
|
||||
// allocation-free while supporting arbitrary multi-MIME replies.
|
||||
// On OOM we don't reply and the handler answers with an empty
|
||||
// clipboard.
|
||||
var sfa = std.heap.stackFallback(256, ctx.wrapper.terminal.gpa());
|
||||
const alloc = sfa.get();
|
||||
const contents = alloc.alloc(clipboard.Content, c_contents.len) catch {
|
||||
log.warn("out of memory converting clipboard read reply", .{});
|
||||
return;
|
||||
};
|
||||
defer alloc.free(contents);
|
||||
for (contents, c_contents) |*content, c_content| {
|
||||
content.* = .{
|
||||
.mime = c_content.mime.ptr[0..c_content.mime.len],
|
||||
.data = c_content.data.ptr[0..c_content.data.len],
|
||||
};
|
||||
}
|
||||
const available = alloc.alloc([]const u8, c_available.len) catch {
|
||||
log.warn("out of memory converting clipboard read reply", .{});
|
||||
return;
|
||||
};
|
||||
defer alloc.free(available);
|
||||
for (available, c_available) |*mime, c_mime| {
|
||||
mime.* = c_mime.ptr[0..c_mime.len];
|
||||
}
|
||||
|
||||
read.reply(.{ .success = .{
|
||||
.contents = contents,
|
||||
.available = available,
|
||||
.remember = reply.remember,
|
||||
} });
|
||||
}
|
||||
|
||||
fn desktopNotificationTrampoline(
|
||||
handler: *Handler,
|
||||
notification: Action.ShowDesktopNotification,
|
||||
@@ -525,6 +661,7 @@ fn wrap(
|
||||
.progress_report = &Effects.progressReportTrampoline,
|
||||
.size = &Effects.sizeTrampoline,
|
||||
.clipboard_write = &Effects.clipboardWriteTrampoline,
|
||||
.clipboard_read = null,
|
||||
};
|
||||
|
||||
wrapper.* = .{
|
||||
@@ -1021,6 +1158,7 @@ pub const Option = enum(c_int) {
|
||||
unknown_sequence = 35,
|
||||
unknown_max_bytes = 36,
|
||||
terminfo_name = 37,
|
||||
clipboard_read = 38,
|
||||
|
||||
/// Input type expected for setting the option.
|
||||
pub fn InType(comptime self: Option) type {
|
||||
@@ -1038,6 +1176,7 @@ pub const Option = enum(c_int) {
|
||||
.progress_report => ?Effects.ProgressReportFn,
|
||||
.size_cb => ?Effects.SizeFn,
|
||||
.clipboard_write => ?Effects.ClipboardWriteFn,
|
||||
.clipboard_read => ?Effects.ClipboardReadFn,
|
||||
.unknown_sequence => ?Effects.UnknownSequenceFn,
|
||||
.title, .pwd, .terminfo_name => ?*const lib.String,
|
||||
.color_foreground, .color_background, .color_cursor => ?*const color.RGB.C,
|
||||
@@ -1106,6 +1245,13 @@ fn setTyped(
|
||||
.progress_report => wrapper.effects.progress_report = value,
|
||||
.size_cb => wrapper.effects.size_cb = value,
|
||||
.clipboard_write => wrapper.effects.clipboard_write = value,
|
||||
.clipboard_read => {
|
||||
wrapper.effects.clipboard_read = value;
|
||||
wrapper.stream.handler.effects.clipboard_read = if (value != null)
|
||||
&Effects.clipboardReadTrampoline
|
||||
else
|
||||
null;
|
||||
},
|
||||
.unknown_sequence => {
|
||||
wrapper.effects.unknown_sequence = value;
|
||||
wrapper.stream.handler.unknown_sequence = if (value != null)
|
||||
@@ -4600,6 +4746,112 @@ test "clipboard_write without callback is unsupported and silent" {
|
||||
try testing.expectEqual(clipboard.WriteResult.unsupported, result);
|
||||
}
|
||||
|
||||
test "set clipboard_read callback" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
const S = struct {
|
||||
var last_data: ?[]u8 = null;
|
||||
var count: usize = 0;
|
||||
var last_size: usize = 0;
|
||||
var last_location: clipboard.Location = .standard;
|
||||
var last_mimes_len: usize = 0;
|
||||
var last_mime_is_text: bool = false;
|
||||
var last_list: bool = true;
|
||||
var last_name_len: usize = 1;
|
||||
var last_granted: bool = true;
|
||||
var last_can_remember: bool = true;
|
||||
var result: clipboard.Read.Status = .success;
|
||||
|
||||
fn deinit() void {
|
||||
if (last_data) |d| testing.allocator.free(d);
|
||||
last_data = null;
|
||||
}
|
||||
|
||||
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
|
||||
if (last_data) |d| testing.allocator.free(d);
|
||||
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
|
||||
}
|
||||
|
||||
fn clipboardRead(
|
||||
_: Terminal,
|
||||
_: ?*anyopaque,
|
||||
request: *const ClipboardRead,
|
||||
) callconv(lib.calling_conv) void {
|
||||
count += 1;
|
||||
last_size = request.size;
|
||||
last_location = request.location;
|
||||
last_mimes_len = request.mimes_len;
|
||||
last_mime_is_text = request.mimes_len > 0 and std.mem.eql(
|
||||
u8,
|
||||
request.mimes.?[0].ptr[0..request.mimes.?[0].len],
|
||||
"text/plain",
|
||||
);
|
||||
last_list = request.list;
|
||||
last_name_len = request.name.len;
|
||||
last_granted = request.granted;
|
||||
last_can_remember = request.can_remember;
|
||||
|
||||
const mime: []const u8 = "text/plain";
|
||||
const data: []const u8 = "hello";
|
||||
const contents = [_]ClipboardContent{.{
|
||||
.mime = .init(mime),
|
||||
.data = .init(data),
|
||||
}};
|
||||
request.reply(request, &.{
|
||||
.size = @sizeOf(ClipboardReadReply),
|
||||
.result = result,
|
||||
.contents = &contents,
|
||||
.contents_len = contents.len,
|
||||
.available = null,
|
||||
.available_len = 0,
|
||||
.remember = false,
|
||||
});
|
||||
}
|
||||
};
|
||||
defer S.deinit();
|
||||
|
||||
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
|
||||
|
||||
// Without a callback the handler effect is unset and reads are silent.
|
||||
try testing.expect(t.?.stream.handler.effects.clipboard_read == null);
|
||||
const read_st = "\x1B]52;c;?\x1B\\";
|
||||
vt_write(t, read_st, read_st.len);
|
||||
try testing.expect(S.last_data == null);
|
||||
|
||||
try testing.expectEqual(Result.success, set(t, .clipboard_read, @ptrCast(&S.clipboardRead)));
|
||||
try testing.expect(t.?.stream.handler.effects.clipboard_read != null);
|
||||
|
||||
const read_bel = "\x1B]52;p;?\x07";
|
||||
vt_write(t, read_bel, read_bel.len);
|
||||
try testing.expectEqual(1, S.count);
|
||||
try testing.expectEqual(@sizeOf(ClipboardRead), S.last_size);
|
||||
try testing.expectEqual(clipboard.Location.primary, S.last_location);
|
||||
try testing.expectEqual(1, S.last_mimes_len);
|
||||
try testing.expect(S.last_mime_is_text);
|
||||
try testing.expect(!S.last_list);
|
||||
try testing.expectEqual(0, S.last_name_len);
|
||||
try testing.expect(!S.last_granted);
|
||||
try testing.expect(!S.last_can_remember);
|
||||
try testing.expectEqualStrings("\x1B]52;p;aGVsbG8=\x07", S.last_data.?);
|
||||
|
||||
// Denied replies with an empty clipboard.
|
||||
S.result = .denied;
|
||||
vt_write(t, read_st, read_st.len);
|
||||
try testing.expectEqual(2, S.count);
|
||||
try testing.expectEqualStrings("\x1B]52;c;\x1B\\", S.last_data.?);
|
||||
|
||||
// Clearing the callback uninstalls the handler effect.
|
||||
try testing.expectEqual(Result.success, set(t, .clipboard_read, null));
|
||||
try testing.expect(t.?.stream.handler.effects.clipboard_read == null);
|
||||
}
|
||||
|
||||
test "pwd_changed without callback is silent" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
|
||||
@@ -174,6 +174,8 @@ const type_decls = [_]TypeDecl{
|
||||
.initStruct("GhosttyBuffer", lib.Buffer),
|
||||
.initStruct("GhosttyCellsView", cell.CellsView),
|
||||
.initStruct("GhosttyClipboardContent", terminal.ClipboardContent),
|
||||
.initStruct("GhosttyClipboardRead", terminal.ClipboardRead),
|
||||
.initStruct("GhosttyClipboardReadReply", terminal.ClipboardReadReply),
|
||||
.initStruct("GhosttyClipboardWrite", terminal.ClipboardWrite),
|
||||
.initStruct("GhosttyCodepoints", Codepoints),
|
||||
.initStruct("GhosttyColorPaletteMask", color_c.PaletteMask),
|
||||
@@ -241,6 +243,7 @@ const type_decls = [_]TypeDecl{
|
||||
.initEnum("GhosttyCellSemanticContent", cell.SemanticContent, "GHOSTTY_CELL_SEMANTIC_"),
|
||||
.initEnum("GhosttyCellWide", cell.Wide, "GHOSTTY_CELL_WIDE_"),
|
||||
.initEnum("GhosttyClipboardLocation", clipboard.Location, "GHOSTTY_CLIPBOARD_LOCATION_"),
|
||||
.initEnum("GhosttyClipboardReadResult", clipboard.Read.Status, "GHOSTTY_CLIPBOARD_READ_RESULT_"),
|
||||
.initEnum("GhosttyClipboardWriteResult", clipboard.WriteResult, "GHOSTTY_CLIPBOARD_WRITE_RESULT_"),
|
||||
.initEnum("GhosttyColorScheme", device_status.ColorScheme, "GHOSTTY_COLOR_SCHEME_"),
|
||||
.initEnum("GhosttyFocusEvent", focus_pkg.Event, "GHOSTTY_FOCUS_"),
|
||||
|
||||
@@ -52,6 +52,101 @@ pub const WriteResult = enum(c_int) {
|
||||
_,
|
||||
};
|
||||
|
||||
/// A request from the running program to read a clipboard.
|
||||
///
|
||||
/// Reads are synchronous: the effect callback must answer through `reply`
|
||||
/// before it returns, and the request (including its reply context) is
|
||||
/// invalid afterwards. An embedder that needs user consent must block until
|
||||
/// it has an answer; the VT stream waits with it.
|
||||
pub const Read = struct {
|
||||
location: Location,
|
||||
|
||||
/// The MIME types the program wants, in order of preference. The
|
||||
/// reply should carry every requested representation the clipboard
|
||||
/// has; unrequested ones are ignored. Protocols that only carry text
|
||||
/// (OSC 52) request "text/plain".
|
||||
mimes: []const []const u8,
|
||||
|
||||
/// The program also wants the list of MIME types available on the
|
||||
/// clipboard, delivered as Result.Success.available.
|
||||
list: bool,
|
||||
|
||||
/// Name of the requesting program for permission prompts, if the
|
||||
/// protocol carries one. Empty otherwise.
|
||||
name: []const u8,
|
||||
|
||||
/// True if the terminal already holds a session grant for this
|
||||
/// request (kitty clipboard protocol passwords). The embedder should
|
||||
/// skip any permission prompt and serve the read.
|
||||
granted: bool,
|
||||
|
||||
/// True if the program supplied a session password, so the embedder
|
||||
/// may offer to remember the user's decision via
|
||||
/// Result.Success.remember. When false, remember is ignored.
|
||||
can_remember: bool,
|
||||
|
||||
/// Terminal-owned reply state, only valid during the callback.
|
||||
///
|
||||
/// The result is delivered through a call rather than returned so
|
||||
/// the terminal consumes it while the embedder's memory is still
|
||||
/// alive; a returned slice would have to outlive the callback.
|
||||
reply_ctx: *anyopaque,
|
||||
reply_fn: *const fn (*anyopaque, Result) void,
|
||||
|
||||
/// Answer the read. May be called at most once; later calls are
|
||||
/// ignored. Result memory is borrowed only for the duration of this
|
||||
/// call.
|
||||
pub fn reply(self: Read, result: Result) void {
|
||||
self.reply_fn(self.reply_ctx, result);
|
||||
}
|
||||
|
||||
/// The status of a clipboard read reply.
|
||||
pub const Status = enum(c_int) {
|
||||
success = 0,
|
||||
denied = 1,
|
||||
unsupported = 2,
|
||||
busy = 3,
|
||||
io_error = 4,
|
||||
_,
|
||||
};
|
||||
|
||||
/// The reply to a clipboard read.
|
||||
pub const Result = union(enum) {
|
||||
/// The read was denied by policy or the user.
|
||||
denied,
|
||||
|
||||
/// The embedder cannot read this clipboard.
|
||||
unsupported,
|
||||
|
||||
/// The clipboard is temporarily unavailable.
|
||||
busy,
|
||||
|
||||
/// Reading the clipboard failed.
|
||||
io_error,
|
||||
|
||||
/// The read succeeded. All memory is borrowed for the duration
|
||||
/// of the reply call.
|
||||
success: Success,
|
||||
|
||||
pub const Success = struct {
|
||||
/// Representations of the clipboard contents, one per
|
||||
/// requested MIME type the clipboard has. Protocols that
|
||||
/// carry a single text value use the first entry with a
|
||||
/// text MIME type (see isTextMime).
|
||||
contents: []const Content = &.{},
|
||||
|
||||
/// All MIME types available on the clipboard. Only used when
|
||||
/// the request set `list`.
|
||||
available: []const []const u8 = &.{},
|
||||
|
||||
/// Record a session grant so future requests from the same
|
||||
/// program skip the permission prompt. Only honored when the
|
||||
/// request set `can_remember`.
|
||||
remember: bool = false,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
test isTextMime {
|
||||
const testing = std.testing;
|
||||
try testing.expect(isTextMime("text/plain"));
|
||||
|
||||
@@ -46,12 +46,13 @@ pub const Command = union(Key) {
|
||||
/// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md
|
||||
semantic_prompt: SemanticPrompt,
|
||||
|
||||
/// Set or get clipboard contents. If data is null, then the current
|
||||
/// clipboard contents are sent to the pty. If data is set, this
|
||||
/// contents is set on the clipboard.
|
||||
/// Set or get clipboard contents. If data is "?", then the current
|
||||
/// clipboard contents are sent to the pty. Otherwise, the contents
|
||||
/// are set on the clipboard.
|
||||
clipboard_contents: struct {
|
||||
kind: u8,
|
||||
data: [:0]const u8,
|
||||
terminator: Terminator = .st,
|
||||
},
|
||||
|
||||
/// OSC 7. Reports the current working directory of the shell. This is
|
||||
|
||||
@@ -6,7 +6,7 @@ const Parser = @import("../../osc.zig").Parser;
|
||||
const Command = @import("../../osc.zig").Command;
|
||||
|
||||
/// Parse OSC 52
|
||||
pub fn parse(parser: *Parser, _: ?u8) ?*Command {
|
||||
pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
|
||||
assert(parser.state == .@"52");
|
||||
const cap = if (parser.capture) |*c| c else {
|
||||
parser.state = .invalid;
|
||||
@@ -26,6 +26,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
|
||||
.clipboard_contents = .{
|
||||
.kind = 'c',
|
||||
.data = data[1 .. data.len - 1 :0],
|
||||
.terminator = .init(terminator_ch),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
@@ -41,6 +42,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
|
||||
.clipboard_contents = .{
|
||||
.kind = data[0],
|
||||
.data = data[2 .. data.len - 1 :0],
|
||||
.terminator = .init(terminator_ch),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -59,6 +61,20 @@ test "OSC 52: get/set clipboard" {
|
||||
try testing.expect(cmd == .clipboard_contents);
|
||||
try testing.expect(cmd.clipboard_contents.kind == 's');
|
||||
try testing.expectEqualStrings("?", cmd.clipboard_contents.data);
|
||||
try testing.expectEqual(.st, cmd.clipboard_contents.terminator);
|
||||
}
|
||||
|
||||
test "OSC 52: get clipboard with BEL terminator" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(null);
|
||||
|
||||
const input = "52;c;?";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end(0x07).?.*;
|
||||
try testing.expect(cmd == .clipboard_contents);
|
||||
try testing.expectEqual(.bel, cmd.clipboard_contents.terminator);
|
||||
}
|
||||
|
||||
test "OSC 52: get/set clipboard (optional parameter)" {
|
||||
|
||||
@@ -414,6 +414,7 @@ pub const Action = union(Key) {
|
||||
pub const ClipboardContents = struct {
|
||||
kind: u8,
|
||||
data: []const u8,
|
||||
terminator: osc.Terminator,
|
||||
|
||||
pub const C = extern struct {
|
||||
kind: u8,
|
||||
@@ -2500,6 +2501,7 @@ pub fn Stream(comptime H: type) type {
|
||||
self.handler.vt(.clipboard_contents, .{
|
||||
.kind = clip.kind,
|
||||
.data = clip.data,
|
||||
.terminator = clip.terminator,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -145,12 +145,23 @@ pub const Handler = struct {
|
||||
/// A write with no contents clears the destination. A content entry
|
||||
/// with empty data is a distinct empty representation.
|
||||
///
|
||||
/// Clipboard read requests (OSC 52 with a "?" payload) are never
|
||||
/// forwarded: answering one would let any program running in the
|
||||
/// terminal silently read the user's clipboard, and a VT state
|
||||
/// library has no way to mediate that with user consent.
|
||||
/// Clipboard read requests (OSC 52 with a "?" payload) are
|
||||
/// delivered to clipboard_read instead.
|
||||
clipboard_write: ?*const fn (*Handler, clipboard.Write) clipboard.WriteResult,
|
||||
|
||||
/// Called when the running program requests clipboard contents
|
||||
/// (OSC 52 with a "?" payload). Answering one lets the program
|
||||
/// read the user's clipboard, so the embedder is expected to
|
||||
/// mediate consent.
|
||||
///
|
||||
/// Reads are synchronous: the callback must answer through
|
||||
/// `read.reply` before it returns, so an embedder that needs to
|
||||
/// ask the user must block (e.g. run a modal prompt) while the
|
||||
/// stream waits. Returning without a reply, or replying denied or
|
||||
/// unsupported, answers the program with an empty clipboard so it
|
||||
/// doesn't hang. If this is null, read requests are ignored.
|
||||
clipboard_read: ?*const fn (*Handler, clipboard.Read) void,
|
||||
|
||||
/// Called in response to an XTVERSION query. Returns the version
|
||||
/// string to report (e.g. "ghostty 1.2.3"). The returned memory
|
||||
/// must be valid for the lifetime of the call. The maximum length
|
||||
@@ -162,6 +173,7 @@ pub const Handler = struct {
|
||||
/// effects beyond that.
|
||||
pub const readonly: Effects = .{
|
||||
.bell = null,
|
||||
.clipboard_read = null,
|
||||
.clipboard_write = null,
|
||||
.color_scheme = null,
|
||||
.desktop_notification = null,
|
||||
@@ -386,9 +398,10 @@ pub const Handler = struct {
|
||||
.clipboard_contents => self.clipboardContents(
|
||||
value.kind,
|
||||
value.data,
|
||||
value.terminator,
|
||||
) catch |err| {
|
||||
// Clipboard writes are external effects, not terminal state.
|
||||
log.warn("error handling clipboard write err={}", .{err});
|
||||
// Clipboard operations are external effects, not terminal state.
|
||||
log.warn("error handling clipboard operation err={}", .{err});
|
||||
},
|
||||
|
||||
.dcs_hook => try self.dcsHook(value),
|
||||
@@ -507,18 +520,26 @@ pub const Handler = struct {
|
||||
func(self, report);
|
||||
}
|
||||
|
||||
fn clipboardContents(self: *Handler, kind: u8, data: []const u8) !void {
|
||||
const func = self.effects.clipboard_write orelse return;
|
||||
|
||||
// Read requests are deliberately not forwarded; see the effect docs.
|
||||
if (data.len == 1 and data[0] == '?') return;
|
||||
|
||||
fn clipboardContents(
|
||||
self: *Handler,
|
||||
kind: u8,
|
||||
data: []const u8,
|
||||
terminator: osc.Terminator,
|
||||
) !void {
|
||||
const location: clipboard.Location = switch (kind) {
|
||||
's' => .selection,
|
||||
'p' => .primary,
|
||||
else => .standard,
|
||||
};
|
||||
|
||||
// OSC 52 uses a "?" payload to request the clipboard contents.
|
||||
if (data.len == 1 and data[0] == '?') {
|
||||
self.clipboardRead(location, terminator);
|
||||
return;
|
||||
}
|
||||
|
||||
const func = self.effects.clipboard_write orelse return;
|
||||
|
||||
// OSC 52 uses an empty payload to clear the selected clipboard.
|
||||
if (data.len == 0) {
|
||||
_ = func(self, .{
|
||||
@@ -546,6 +567,94 @@ pub const Handler = struct {
|
||||
});
|
||||
}
|
||||
|
||||
fn clipboardRead(
|
||||
self: *Handler,
|
||||
location: clipboard.Location,
|
||||
terminator: osc.Terminator,
|
||||
) void {
|
||||
const func = self.effects.clipboard_read orelse return;
|
||||
|
||||
var state: ClipboardReadState = .{
|
||||
.handler = self,
|
||||
.location = location,
|
||||
.terminator = terminator,
|
||||
};
|
||||
func(self, .{
|
||||
.location = location,
|
||||
.mimes = &.{"text/plain"},
|
||||
.list = false,
|
||||
.name = "",
|
||||
.granted = false,
|
||||
.can_remember = false,
|
||||
.reply_ctx = &state,
|
||||
.reply_fn = &ClipboardReadState.reply,
|
||||
});
|
||||
|
||||
// The program is waiting on us, so a callback that returned
|
||||
// without a (successful) reply gets an empty clipboard rather
|
||||
// than silence.
|
||||
if (!state.replied) state.respond("") catch |err| {
|
||||
log.warn("error replying to clipboard read err={}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
/// Reply state for one synchronous clipboard read. This lives on the
|
||||
/// clipboardRead stack frame, so it is only valid during the callback.
|
||||
const ClipboardReadState = struct {
|
||||
handler: *Handler,
|
||||
location: clipboard.Location,
|
||||
terminator: osc.Terminator,
|
||||
replied: bool = false,
|
||||
|
||||
fn reply(ctx: *anyopaque, result: clipboard.Read.Result) void {
|
||||
const self: *ClipboardReadState = @ptrCast(@alignCast(ctx));
|
||||
if (self.replied) {
|
||||
log.warn("clipboard read replied more than once, ignoring", .{});
|
||||
return;
|
||||
}
|
||||
|
||||
// OSC 52 carries a single text value.
|
||||
const data: []const u8 = switch (result) {
|
||||
.denied, .unsupported, .busy, .io_error => "",
|
||||
.success => |s| for (s.contents) |c| {
|
||||
if (clipboard.isTextMime(c.mime)) break c.data;
|
||||
} else "",
|
||||
};
|
||||
|
||||
self.respond(data) catch |err| {
|
||||
// Leave replied unset so clipboardRead falls back to the
|
||||
// empty reply.
|
||||
log.warn("error replying to clipboard read err={}", .{err});
|
||||
return;
|
||||
};
|
||||
self.replied = true;
|
||||
}
|
||||
|
||||
fn respond(
|
||||
self: *ClipboardReadState,
|
||||
data: []const u8,
|
||||
) error{ OutOfMemory, WriteFailed }!void {
|
||||
const handler = self.handler;
|
||||
var stack = std.heap.stackFallback(256, handler.terminal.gpa());
|
||||
const alloc = stack.get();
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer aw.deinit();
|
||||
const kind: u8 = switch (self.location) {
|
||||
.selection => 's',
|
||||
.primary => 'p',
|
||||
.standard, _ => 'c',
|
||||
};
|
||||
try aw.writer.print("\x1b]52;{c};", .{kind});
|
||||
try std.base64.standard.Encoder.encodeWriter(&aw.writer, data);
|
||||
try aw.writer.writeAll(self.terminator.string());
|
||||
|
||||
const written = try aw.toOwnedSliceSentinel(0);
|
||||
defer alloc.free(written);
|
||||
handler.writePty(written);
|
||||
}
|
||||
};
|
||||
|
||||
fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void {
|
||||
const func = self.effects.device_attributes orelse return;
|
||||
const attrs = func(self);
|
||||
@@ -2640,6 +2749,120 @@ test "clipboard_write effect callback" {
|
||||
try testing.expectEqual(clipboard.WriteResult.denied, S.result);
|
||||
}
|
||||
|
||||
test "clipboard_read effect callback" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = struct {
|
||||
var written: std.ArrayList(u8) = .empty;
|
||||
var count: usize = 0;
|
||||
var last_location: clipboard.Location = .standard;
|
||||
var last_mimes: []const []const u8 = &.{};
|
||||
var last_list: bool = true;
|
||||
var last_name: []const u8 = "unset";
|
||||
var last_granted: bool = true;
|
||||
var last_can_remember: bool = true;
|
||||
var result: ?clipboard.Read.Result = .{ .success = .{ .contents = &.{.{
|
||||
.mime = "text/plain",
|
||||
.data = "hello",
|
||||
}} } };
|
||||
var reply_twice: bool = false;
|
||||
|
||||
fn writePty(_: *Handler, data: [:0]const u8) void {
|
||||
written.appendSlice(testing.allocator, data) catch @panic("OOM");
|
||||
}
|
||||
|
||||
fn clipboardRead(_: *Handler, read: clipboard.Read) void {
|
||||
count += 1;
|
||||
last_location = read.location;
|
||||
last_mimes = read.mimes;
|
||||
last_list = read.list;
|
||||
last_name = read.name;
|
||||
last_granted = read.granted;
|
||||
last_can_remember = read.can_remember;
|
||||
if (result) |r| read.reply(r);
|
||||
if (reply_twice) read.reply(.{ .success = .{ .contents = &.{.{
|
||||
.mime = "text/plain",
|
||||
.data = "again",
|
||||
}} } });
|
||||
}
|
||||
};
|
||||
defer S.written.deinit(testing.allocator);
|
||||
|
||||
// A null callback (the default readonly effects) silently ignores reads.
|
||||
{
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
|
||||
defer s.deinit();
|
||||
|
||||
s.nextSlice("\x1B]52;c;?\x1B\\");
|
||||
try testing.expectEqual(0, S.written.items.len);
|
||||
}
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.write_pty = &S.writePty;
|
||||
handler.effects.clipboard_read = &S.clipboardRead;
|
||||
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
|
||||
defer s.deinit();
|
||||
|
||||
// Success echoes the normalized selector and request terminator.
|
||||
s.nextSlice("\x1B]52;c;?\x1B\\");
|
||||
try testing.expectEqual(1, S.count);
|
||||
try testing.expectEqual(clipboard.Location.standard, S.last_location);
|
||||
try testing.expectEqual(1, S.last_mimes.len);
|
||||
try testing.expectEqualStrings("text/plain", S.last_mimes[0]);
|
||||
try testing.expect(!S.last_list);
|
||||
try testing.expectEqualStrings("", S.last_name);
|
||||
try testing.expect(!S.last_granted);
|
||||
try testing.expect(!S.last_can_remember);
|
||||
try testing.expectEqualStrings("\x1B]52;c;aGVsbG8=\x1B\\", S.written.items);
|
||||
|
||||
S.written.clearRetainingCapacity();
|
||||
s.nextSlice("\x1B]52;p;?\x07");
|
||||
try testing.expectEqual(clipboard.Location.primary, S.last_location);
|
||||
try testing.expectEqualStrings("\x1B]52;p;aGVsbG8=\x07", S.written.items);
|
||||
|
||||
// Only the first text representation is used.
|
||||
S.written.clearRetainingCapacity();
|
||||
S.result = .{
|
||||
.success = .{
|
||||
.contents = &.{
|
||||
.{ .mime = "image/png", .data = "\x89PNG" },
|
||||
.{ .mime = "UTF8_STRING", .data = "hi" },
|
||||
},
|
||||
// OSC 52 has no session passwords, so remember is ignored.
|
||||
.remember = true,
|
||||
},
|
||||
};
|
||||
s.nextSlice("\x1B]52;s;?\x1B\\");
|
||||
try testing.expectEqual(clipboard.Location.selection, S.last_location);
|
||||
try testing.expectEqualStrings("\x1B]52;s;aGk=\x1B\\", S.written.items);
|
||||
|
||||
// Every failure, no text, and no reply all answer with an empty
|
||||
// clipboard.
|
||||
for ([_]?clipboard.Read.Result{
|
||||
.denied,
|
||||
.unsupported,
|
||||
.busy,
|
||||
.io_error,
|
||||
.{ .success = .{} },
|
||||
null,
|
||||
}) |result| {
|
||||
S.written.clearRetainingCapacity();
|
||||
S.result = result;
|
||||
s.nextSlice("\x1B]52;c;?\x1B\\");
|
||||
try testing.expectEqualStrings("\x1B]52;c;\x1B\\", S.written.items);
|
||||
}
|
||||
|
||||
// A second reply is ignored.
|
||||
S.written.clearRetainingCapacity();
|
||||
S.result = .{ .success = .{ .contents = &.{.{ .mime = "text/plain", .data = "hello" }} } };
|
||||
S.reply_twice = true;
|
||||
s.nextSlice("\x1B]52;c;?\x1B\\");
|
||||
try testing.expectEqualStrings("\x1B]52;c;aGVsbG8=\x1B\\", S.written.items);
|
||||
}
|
||||
|
||||
test "clipboard_write allocation failure is ignored" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
Reference in New Issue
Block a user