mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-10 19:29:37 +00:00
terminal: add clipboard_set effect for OSC 52 clipboard writes (#13182)
libghostty-vt was already parsing OSC 52 into the clipboard_contents action but the stream handler dropped, so there was no way to observe clipboard writes (in this case, a program using go-libghostty). This adds a clipboard_set effect following the existing bell/title_changed pattern and expose it through the C API as `GHOSTTY_TERMINAL_OPT_CLIPBOARD_SET`. The callback receives the OSC 52 kind byte and the base64 payload exactly as received; decoding and kind interpretation are left to the embedder, matching how decoding is typically deferred. Note this intentionally does not deal with clipboard read requests given the security implications. AI disclosure: Fable (via Claude Code) did the majority of the work here, I validated it on the client side and fully understand the pattern we're fitting into.
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
# Example: `ghostty-vt` Terminal Effects
|
||||
|
||||
This contains a simple example of how to register and use terminal
|
||||
effect callbacks (`write_pty`, `bell`, `title_changed`) with the
|
||||
effect callbacks (`write_pty`, `bell`, `title_changed`, and
|
||||
`clipboard_write`) with the
|
||||
`ghostty-vt` C library.
|
||||
|
||||
The clipboard effect receives one atomic write with decoded, binary-safe
|
||||
MIME representations rather than protocol-specific OSC 52 data.
|
||||
|
||||
This uses a `build.zig` and `Zig` to build the C program so that we
|
||||
can reuse a lot of our build logic and depend directly on our source
|
||||
tree, but Ghostty emits a standard C library that can be used with any
|
||||
|
||||
@@ -39,6 +39,37 @@ void on_title_changed(GhosttyTerminal terminal, void* userdata) {
|
||||
}
|
||||
//! [effects-title-changed]
|
||||
|
||||
//! [effects-clipboard-write]
|
||||
GhosttyClipboardWriteResult on_clipboard_write(
|
||||
GhosttyTerminal terminal,
|
||||
void* userdata,
|
||||
const GhosttyClipboardWrite* write) {
|
||||
(void)terminal;
|
||||
(void)userdata;
|
||||
|
||||
printf(" clipboard write (location=%d, contents=%zu)\n",
|
||||
(int)write->location, write->contents_len);
|
||||
if (write->contents_len == 0) {
|
||||
printf(" clear\n");
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < write->contents_len; i++) {
|
||||
const GhosttyClipboardContent* content = &write->contents[i];
|
||||
printf(" ");
|
||||
if (content->mime.len > 0) {
|
||||
fwrite(content->mime.ptr, 1, content->mime.len, stdout);
|
||||
}
|
||||
printf(" (%zu bytes): ", content->data.len);
|
||||
if (content->data.len > 0) {
|
||||
fwrite(content->data.ptr, 1, content->data.len, stdout);
|
||||
}
|
||||
printf("\n");
|
||||
}
|
||||
|
||||
return GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS;
|
||||
}
|
||||
//! [effects-clipboard-write]
|
||||
|
||||
//! [effects-register]
|
||||
int main() {
|
||||
// Create a terminal
|
||||
@@ -64,6 +95,8 @@ int main() {
|
||||
(const void *)on_bell);
|
||||
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED,
|
||||
(const void *)on_title_changed);
|
||||
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE,
|
||||
(const void *)on_clipboard_write);
|
||||
|
||||
// Feed VT data that triggers effects:
|
||||
|
||||
@@ -85,7 +118,14 @@ int main() {
|
||||
ghostty_terminal_vt_write(terminal, (const uint8_t*)decrqm,
|
||||
strlen(decrqm));
|
||||
|
||||
// 4. Another bell to show the counter increments
|
||||
// 4. Clipboard write (OSC 52 ; c ; <base64 data> ST)
|
||||
printf("Sending clipboard write:\n");
|
||||
const char* clipboard_seq =
|
||||
"\x1B]52;c;SGVsbG8gY2xpcGJvYXJk\x1B\\";
|
||||
ghostty_terminal_vt_write(terminal, (const uint8_t*)clipboard_seq,
|
||||
strlen(clipboard_seq));
|
||||
|
||||
// 5. Another bell to show the counter increments
|
||||
printf("Sending another BEL:\n");
|
||||
ghostty_terminal_vt_write(terminal, &bel, 1);
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ extern "C" {
|
||||
* | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) |
|
||||
* | `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 |
|
||||
*
|
||||
* ### Defining a write_pty callback
|
||||
* @snippet c-vt-effects/src/main.c effects-write-pty
|
||||
@@ -103,6 +104,9 @@ extern "C" {
|
||||
* ### Defining a title_changed callback
|
||||
* @snippet c-vt-effects/src/main.c effects-title-changed
|
||||
*
|
||||
* ### Defining a clipboard_write callback
|
||||
* @snippet c-vt-effects/src/main.c effects-clipboard-write
|
||||
*
|
||||
* ### Registering effects and processing VT data
|
||||
* @snippet c-vt-effects/src/main.c effects-register
|
||||
*
|
||||
@@ -344,6 +348,125 @@ typedef struct {
|
||||
typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal,
|
||||
void* userdata);
|
||||
|
||||
/**
|
||||
* Clipboard destination for a clipboard write.
|
||||
*
|
||||
* Protocol-specific destination identifiers are normalized to these values
|
||||
* before the clipboard write callback is invoked.
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef enum GHOSTTY_ENUM_TYPED {
|
||||
/** The standard system clipboard. */
|
||||
GHOSTTY_CLIPBOARD_LOCATION_STANDARD = 0,
|
||||
|
||||
/** The selection clipboard. */
|
||||
GHOSTTY_CLIPBOARD_LOCATION_SELECTION = 1,
|
||||
|
||||
/** The primary selection clipboard. */
|
||||
GHOSTTY_CLIPBOARD_LOCATION_PRIMARY = 2,
|
||||
GHOSTTY_CLIPBOARD_LOCATION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyClipboardLocation;
|
||||
|
||||
/**
|
||||
* One MIME representation in a clipboard write.
|
||||
*
|
||||
* Both strings are borrowed and valid only for the duration of the callback.
|
||||
* The data is binary-safe and has already been decoded from any protocol-level
|
||||
* encoding. A zero-length data string is an explicit empty representation; it
|
||||
* does not clear the clipboard.
|
||||
*
|
||||
* This struct has a frozen layout and will not gain fields in future versions.
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef struct {
|
||||
/** MIME type of the representation. */
|
||||
GhosttyString mime;
|
||||
|
||||
/** Decoded, binary-safe representation data. */
|
||||
GhosttyString data;
|
||||
} GhosttyClipboardContent;
|
||||
|
||||
/**
|
||||
* A semantic, atomic clipboard write.
|
||||
*
|
||||
* This is a sized struct. The callback must only access fields present in the
|
||||
* size reported by `size`. The request, contents array, MIME strings, and
|
||||
* data strings are all borrowed and valid only for the callback duration.
|
||||
*
|
||||
* All entries in `contents` are representations of the same logical value
|
||||
* and must be committed atomically. A `contents_len` of zero requests that
|
||||
* the destination be cleared. This is distinct from a content entry whose data
|
||||
* has zero length.
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef struct {
|
||||
/** Size of this struct in bytes. */
|
||||
size_t size;
|
||||
|
||||
/** Clipboard destination. */
|
||||
GhosttyClipboardLocation location;
|
||||
|
||||
/** Borrowed array of MIME representations. */
|
||||
const GhosttyClipboardContent* contents;
|
||||
|
||||
/** Number of entries in contents; zero means clear the destination. */
|
||||
size_t contents_len;
|
||||
} GhosttyClipboardWrite;
|
||||
|
||||
/**
|
||||
* Result of a clipboard write callback.
|
||||
*
|
||||
* Protocols without write acknowledgements, including OSC 52 and iTerm2
|
||||
* OSC 1337 Copy, ignore this result.
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef enum GHOSTTY_ENUM_TYPED {
|
||||
/** The clipboard write completed successfully. */
|
||||
GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS = 0,
|
||||
|
||||
/** The clipboard write was denied by policy or the user. */
|
||||
GHOSTTY_CLIPBOARD_WRITE_RESULT_DENIED = 1,
|
||||
|
||||
/** The destination or one or more representations are unsupported. */
|
||||
GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED = 2,
|
||||
|
||||
/** The clipboard is temporarily unavailable. */
|
||||
GHOSTTY_CLIPBOARD_WRITE_RESULT_BUSY = 3,
|
||||
|
||||
/** One or more representations contain invalid data. */
|
||||
GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA = 4,
|
||||
|
||||
/** The clipboard write failed due to an I/O error. */
|
||||
GHOSTTY_CLIPBOARD_WRITE_RESULT_IO_ERROR = 5,
|
||||
GHOSTTY_CLIPBOARD_WRITE_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyClipboardWriteResult;
|
||||
|
||||
/**
|
||||
* Callback function type for clipboard_write.
|
||||
*
|
||||
* Called synchronously for a complete logical clipboard write. Protocol
|
||||
* 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.
|
||||
*
|
||||
* @param terminal The terminal handle
|
||||
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
|
||||
* @param write Borrowed atomic clipboard write request
|
||||
* @return The result of attempting the clipboard write
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef GhosttyClipboardWriteResult (*GhosttyTerminalClipboardWriteFn)(
|
||||
GhosttyTerminal terminal,
|
||||
void* userdata,
|
||||
const GhosttyClipboardWrite* write);
|
||||
|
||||
/**
|
||||
* Callback function type for color scheme queries (CSI ? 996 n).
|
||||
*
|
||||
@@ -753,6 +876,17 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* Input type: GhosttyTerminalPwdChangedFn
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_PWD_CHANGED = 25,
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Input type: GhosttyTerminalClipboardWriteFn
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE = 26,
|
||||
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyTerminalOption;
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const grid_ref_tracked_c = @import("grid_ref_tracked.zig");
|
||||
const selection_c = @import("selection.zig");
|
||||
const style_c = @import("style.zig");
|
||||
const color = @import("../color.zig");
|
||||
const clipboard = @import("../clipboard.zig");
|
||||
const Result = @import("result.zig").Result;
|
||||
|
||||
const Handler = @import("../stream_terminal.zig").Handler;
|
||||
@@ -40,6 +41,24 @@ const TerminalWrapper = struct {
|
||||
tracked_grid_refs: std.AutoArrayHashMapUnmanaged(*grid_ref_tracked_c.TrackedGridRef, void) = .{},
|
||||
};
|
||||
|
||||
/// A single MIME representation in a clipboard write.
|
||||
///
|
||||
/// C: GhosttyClipboardContent
|
||||
pub const ClipboardContent = extern struct {
|
||||
mime: lib.String,
|
||||
data: lib.String,
|
||||
};
|
||||
|
||||
/// A protocol-neutral request to replace or clear clipboard contents.
|
||||
///
|
||||
/// C: GhosttyClipboardWrite
|
||||
pub const ClipboardWrite = extern struct {
|
||||
size: usize,
|
||||
location: clipboard.Location,
|
||||
contents: ?[*]const ClipboardContent,
|
||||
contents_len: usize,
|
||||
};
|
||||
|
||||
/// C callback state for terminal effects. Trampolines are always
|
||||
/// installed on the stream handler; they check these fields and
|
||||
/// no-op when the corresponding callback is null.
|
||||
@@ -54,6 +73,7 @@ const Effects = struct {
|
||||
title_changed: ?TitleChangedFn = null,
|
||||
pwd_changed: ?PwdChangedFn = null,
|
||||
size_cb: ?SizeFn = null,
|
||||
clipboard_write: ?ClipboardWriteFn = null,
|
||||
|
||||
/// Scratch buffer for DA1 feature codes. The device attributes
|
||||
/// trampoline converts C feature codes into this buffer and returns
|
||||
@@ -84,6 +104,10 @@ const Effects = struct {
|
||||
/// (len=0) causes the default "libghostty" to be reported.
|
||||
pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) lib.String;
|
||||
|
||||
/// C function pointer type for the clipboard_write callback. The request
|
||||
/// 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 title_changed callback.
|
||||
pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
|
||||
|
||||
@@ -138,6 +162,44 @@ const Effects = struct {
|
||||
func(@ptrCast(wrapper), wrapper.effects.userdata);
|
||||
}
|
||||
|
||||
fn clipboardWriteTrampoline(handler: *Handler, write: clipboard.Write) clipboard.WriteResult {
|
||||
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
|
||||
const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
|
||||
const func = wrapper.effects.clipboard_write orelse return .unsupported;
|
||||
|
||||
// Most protocols currently produce one representation, so keep that
|
||||
// path allocation-free while supporting arbitrary multi-MIME writes.
|
||||
var stack_contents: [4]ClipboardContent = undefined;
|
||||
const contents: []ClipboardContent = if (write.contents.len <= stack_contents.len)
|
||||
stack_contents[0..write.contents.len]
|
||||
else
|
||||
wrapper.terminal.gpa().alloc(ClipboardContent, write.contents.len) catch
|
||||
return .io_error;
|
||||
defer if (write.contents.len > stack_contents.len)
|
||||
wrapper.terminal.gpa().free(contents);
|
||||
|
||||
for (contents, write.contents) |*c_content, content| {
|
||||
c_content.* = .{
|
||||
.mime = .{
|
||||
.ptr = content.mime.ptr,
|
||||
.len = content.mime.len,
|
||||
},
|
||||
.data = .{
|
||||
.ptr = content.data.ptr,
|
||||
.len = content.data.len,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const request: ClipboardWrite = .{
|
||||
.size = @sizeOf(ClipboardWrite),
|
||||
.location = write.location,
|
||||
.contents = if (contents.len > 0) contents.ptr else null,
|
||||
.contents_len = contents.len,
|
||||
};
|
||||
return func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
|
||||
}
|
||||
|
||||
fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme {
|
||||
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
|
||||
const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
|
||||
@@ -302,6 +364,7 @@ fn new_(
|
||||
.title_changed = &Effects.titleChangedTrampoline,
|
||||
.pwd_changed = &Effects.pwdChangedTrampoline,
|
||||
.size = &Effects.sizeTrampoline,
|
||||
.clipboard_write = &Effects.clipboardWriteTrampoline,
|
||||
};
|
||||
|
||||
wrapper.* = .{
|
||||
@@ -373,6 +436,7 @@ pub const Option = enum(c_int) {
|
||||
default_cursor_blink = 23,
|
||||
glyph_protocol = 24,
|
||||
pwd_changed = 25,
|
||||
clipboard_write = 26,
|
||||
|
||||
/// Input type expected for setting the option.
|
||||
pub fn InType(comptime self: Option) type {
|
||||
@@ -387,6 +451,7 @@ pub const Option = enum(c_int) {
|
||||
.title_changed => ?Effects.TitleChangedFn,
|
||||
.pwd_changed => ?Effects.PwdChangedFn,
|
||||
.size_cb => ?Effects.SizeFn,
|
||||
.clipboard_write => ?Effects.ClipboardWriteFn,
|
||||
.title, .pwd => ?*const lib.String,
|
||||
.color_foreground, .color_background, .color_cursor => ?*const color.RGB.C,
|
||||
.color_palette => ?*const color.PaletteC,
|
||||
@@ -443,6 +508,7 @@ fn setTyped(
|
||||
.title_changed => wrapper.effects.title_changed = value,
|
||||
.pwd_changed => wrapper.effects.pwd_changed = value,
|
||||
.size_cb => wrapper.effects.size_cb = value,
|
||||
.clipboard_write => wrapper.effects.clipboard_write = value,
|
||||
.title => {
|
||||
const str = if (value) |v| v.ptr[0..v.len] else "";
|
||||
wrapper.terminal.setTitle(str) catch return .out_of_memory;
|
||||
@@ -2695,6 +2761,186 @@ test "set pwd_changed callback" {
|
||||
try testing.expectEqualStrings("file:///home/user", zigTerminal(t).?.getPwd().?);
|
||||
}
|
||||
|
||||
test "set clipboard_write callback" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.max_scrollback = 0,
|
||||
},
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
const S = struct {
|
||||
var count: usize = 0;
|
||||
var last_terminal: Terminal = null;
|
||||
var last_userdata: ?*anyopaque = null;
|
||||
var last_size: usize = 0;
|
||||
var last_location: clipboard.Location = .standard;
|
||||
var last_contents_null: bool = false;
|
||||
var last_contents_len: usize = 0;
|
||||
var last_mimes: [8][64]u8 = undefined;
|
||||
var last_mime_lens: [8]usize = @splat(0);
|
||||
var last_data: [8][64]u8 = undefined;
|
||||
var last_data_lens: [8]usize = @splat(0);
|
||||
var next_result: clipboard.WriteResult = .success;
|
||||
|
||||
fn clipboardWrite(
|
||||
terminal_: Terminal,
|
||||
ud: ?*anyopaque,
|
||||
request: *const ClipboardWrite,
|
||||
) callconv(lib.calling_conv) clipboard.WriteResult {
|
||||
count += 1;
|
||||
last_terminal = terminal_;
|
||||
last_userdata = ud;
|
||||
last_size = request.size;
|
||||
last_location = request.location;
|
||||
last_contents_null = request.contents == null;
|
||||
last_contents_len = request.contents_len;
|
||||
|
||||
if (request.contents) |ptr| {
|
||||
for (ptr[0..@min(request.contents_len, last_mimes.len)], 0..) |content, i| {
|
||||
last_mime_lens[i] = @min(content.mime.len, last_mimes[i].len);
|
||||
@memcpy(
|
||||
last_mimes[i][0..last_mime_lens[i]],
|
||||
content.mime.ptr[0..last_mime_lens[i]],
|
||||
);
|
||||
|
||||
last_data_lens[i] = @min(content.data.len, last_data[i].len);
|
||||
@memcpy(
|
||||
last_data[i][0..last_data_lens[i]],
|
||||
content.data.ptr[0..last_data_lens[i]],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return next_result;
|
||||
}
|
||||
};
|
||||
S.count = 0;
|
||||
S.last_terminal = null;
|
||||
S.last_userdata = null;
|
||||
S.next_result = .denied;
|
||||
|
||||
var sentinel: u8 = 88;
|
||||
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
|
||||
try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite)));
|
||||
|
||||
// Split OSC 52 write whose decoded payload contains an embedded NUL.
|
||||
const seq1_a = "\x1B]52;c;aGVs";
|
||||
const seq1_b = "bG8Ad29ybGQ=\x1B\\";
|
||||
vt_write(t, seq1_a, seq1_a.len);
|
||||
vt_write(t, seq1_b, seq1_b.len);
|
||||
try testing.expectEqual(@as(usize, 1), S.count);
|
||||
try testing.expectEqual(t, S.last_terminal);
|
||||
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
|
||||
try testing.expectEqual(@sizeOf(ClipboardWrite), S.last_size);
|
||||
try testing.expectEqual(clipboard.Location.standard, S.last_location);
|
||||
try testing.expect(!S.last_contents_null);
|
||||
try testing.expectEqual(@as(usize, 1), S.last_contents_len);
|
||||
try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]);
|
||||
try testing.expectEqualSlices(u8, "hello\x00world", S.last_data[0][0..S.last_data_lens[0]]);
|
||||
|
||||
// OSC 52 destinations are normalized rather than exposed as wire bytes.
|
||||
const location_cases = [_]struct {
|
||||
selector: u8,
|
||||
expected: clipboard.Location,
|
||||
}{
|
||||
.{ .selector = 's', .expected = .selection },
|
||||
.{ .selector = 'p', .expected = .primary },
|
||||
.{ .selector = 'q', .expected = .standard },
|
||||
};
|
||||
for (location_cases) |case| {
|
||||
const seq = [_]u8{ '\x1B', ']', '5', '2', ';', case.selector, ';', 'e', 'A', '=', '=', '\x1B', '\\' };
|
||||
vt_write(t, &seq, seq.len);
|
||||
try testing.expectEqual(case.expected, S.last_location);
|
||||
}
|
||||
try testing.expectEqual(@as(usize, 4), S.count);
|
||||
|
||||
// An empty content list is a clear and retains a null descriptor pointer.
|
||||
const clear = "\x1B]52;s;\x1B\\";
|
||||
vt_write(t, clear, clear.len);
|
||||
try testing.expectEqual(@as(usize, 5), S.count);
|
||||
try testing.expectEqual(clipboard.Location.selection, S.last_location);
|
||||
try testing.expect(S.last_contents_null);
|
||||
try testing.expectEqual(@as(usize, 0), S.last_contents_len);
|
||||
|
||||
// Read requests and malformed base64 must never reach the callback.
|
||||
const read = "\x1B]52;c;?\x1B\\";
|
||||
vt_write(t, read, read.len);
|
||||
const malformed = "\x1B]52;c;%%%\x1B\\";
|
||||
vt_write(t, malformed, malformed.len);
|
||||
try testing.expectEqual(@as(usize, 5), S.count);
|
||||
|
||||
// iTerm2 Copy reaches the same normalized callback.
|
||||
const iterm = "\x1B]1337;Copy=:aVRlcm0=\x1B\\";
|
||||
vt_write(t, iterm, iterm.len);
|
||||
try testing.expectEqual(@as(usize, 6), S.count);
|
||||
try testing.expectEqual(clipboard.Location.standard, S.last_location);
|
||||
try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]);
|
||||
try testing.expectEqualStrings("iTerm", S.last_data[0][0..S.last_data_lens[0]]);
|
||||
|
||||
// Every representation is converted, and callback results propagate
|
||||
// through the C trampoline for protocols that can acknowledge writes.
|
||||
const internal_contents = [_]clipboard.Content{
|
||||
.{ .mime = "text/plain", .data = "plain" },
|
||||
.{ .mime = "application/octet-stream", .data = "a\x00b" },
|
||||
.{ .mime = "text/html", .data = "<b>plain</b>" },
|
||||
.{ .mime = "text/rtf", .data = "{\\rtf1 plain}" },
|
||||
.{ .mime = "image/png", .data = "\x89PNG" },
|
||||
};
|
||||
S.next_result = .busy;
|
||||
const handler = &t.?.stream.handler;
|
||||
const write_result = handler.effects.clipboard_write.?(handler, .{
|
||||
.location = .primary,
|
||||
.contents = &internal_contents,
|
||||
});
|
||||
try testing.expectEqual(clipboard.WriteResult.busy, write_result);
|
||||
try testing.expectEqual(@as(usize, 7), S.count);
|
||||
try testing.expectEqual(@as(usize, 5), S.last_contents_len);
|
||||
try testing.expectEqualStrings(
|
||||
"application/octet-stream",
|
||||
S.last_mimes[1][0..S.last_mime_lens[1]],
|
||||
);
|
||||
try testing.expectEqualSlices(u8, "a\x00b", S.last_data[1][0..S.last_data_lens[1]]);
|
||||
try testing.expectEqualStrings("image/png", S.last_mimes[4][0..S.last_mime_lens[4]]);
|
||||
try testing.expectEqualSlices(u8, "\x89PNG", S.last_data[4][0..S.last_data_lens[4]]);
|
||||
|
||||
// Removing the callback takes effect immediately.
|
||||
try testing.expectEqual(Result.success, set(t, .clipboard_write, null));
|
||||
const after_remove = "\x1B]52;c;eA==\x1B\\";
|
||||
vt_write(t, after_remove, after_remove.len);
|
||||
try testing.expectEqual(@as(usize, 7), S.count);
|
||||
}
|
||||
|
||||
test "clipboard_write without callback is unsupported and silent" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.max_scrollback = 0,
|
||||
},
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
// OSC 52 without a callback should not crash
|
||||
const seq = "\x1B]52;c;aGVsbG8=\x1B\\";
|
||||
vt_write(t, seq, seq.len);
|
||||
|
||||
const handler = &t.?.stream.handler;
|
||||
const result = handler.effects.clipboard_write.?(handler, .{
|
||||
.location = .standard,
|
||||
.contents = &.{.{ .mime = "text/plain", .data = "hello" }},
|
||||
});
|
||||
try testing.expectEqual(clipboard.WriteResult.unsupported, result);
|
||||
}
|
||||
|
||||
test "pwd_changed without callback is silent" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
|
||||
@@ -38,6 +38,8 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: {
|
||||
@setEvalBranchQuota(10_000);
|
||||
break :structs .initComptime(.{
|
||||
.{ "GhosttyBuffer", StructInfo.init(lib.Buffer) },
|
||||
.{ "GhosttyClipboardContent", StructInfo.init(terminal.ClipboardContent) },
|
||||
.{ "GhosttyClipboardWrite", StructInfo.init(terminal.ClipboardWrite) },
|
||||
.{ "GhosttyCodepoints", StructInfo.init(Codepoints) },
|
||||
.{ "GhosttyColorPaletteMask", StructInfo.init(color_c.PaletteMask) },
|
||||
.{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) },
|
||||
@@ -212,9 +214,23 @@ test "json parses" {
|
||||
const root = parsed.value.object;
|
||||
|
||||
// Verify we have all expected structs
|
||||
try std.testing.expect(root.contains("GhosttyClipboardContent"));
|
||||
try std.testing.expect(root.contains("GhosttyClipboardWrite"));
|
||||
try std.testing.expect(root.contains("GhosttyTerminalOptions"));
|
||||
try std.testing.expect(root.contains("GhosttyFormatterTerminalOptions"));
|
||||
|
||||
const clipboard_content = root.get("GhosttyClipboardContent").?.object;
|
||||
const clipboard_content_fields = clipboard_content.get("fields").?.object;
|
||||
try std.testing.expect(clipboard_content_fields.contains("mime"));
|
||||
try std.testing.expect(clipboard_content_fields.contains("data"));
|
||||
|
||||
const clipboard_write = root.get("GhosttyClipboardWrite").?.object;
|
||||
const clipboard_write_fields = clipboard_write.get("fields").?.object;
|
||||
try std.testing.expect(clipboard_write_fields.contains("size"));
|
||||
try std.testing.expect(clipboard_write_fields.contains("location"));
|
||||
try std.testing.expect(clipboard_write_fields.contains("contents"));
|
||||
try std.testing.expect(clipboard_write_fields.contains("contents_len"));
|
||||
|
||||
// Verify GhosttyTerminalOptions fields
|
||||
const term_opts = root.get("GhosttyTerminalOptions").?.object;
|
||||
try std.testing.expect(term_opts.contains("size"));
|
||||
|
||||
36
src/terminal/clipboard.zig
Normal file
36
src/terminal/clipboard.zig
Normal file
@@ -0,0 +1,36 @@
|
||||
/// The clipboard destination for a write.
|
||||
pub const Location = enum(c_int) {
|
||||
standard = 0,
|
||||
selection = 1,
|
||||
primary = 2,
|
||||
_,
|
||||
};
|
||||
|
||||
/// A single representation of clipboard data.
|
||||
///
|
||||
/// The MIME type and data are borrowed and only valid for the duration of a
|
||||
/// clipboard write callback. Data is binary-safe.
|
||||
pub const Content = struct {
|
||||
mime: []const u8,
|
||||
data: []const u8,
|
||||
};
|
||||
|
||||
/// One atomic clipboard write.
|
||||
///
|
||||
/// Contents are borrowed and only valid for the duration of a clipboard write
|
||||
/// callback. An empty contents slice clears the destination.
|
||||
pub const Write = struct {
|
||||
location: Location,
|
||||
contents: []const Content,
|
||||
};
|
||||
|
||||
/// The result of a clipboard write.
|
||||
pub const WriteResult = enum(c_int) {
|
||||
success = 0,
|
||||
denied = 1,
|
||||
unsupported = 2,
|
||||
busy = 3,
|
||||
invalid_data = 4,
|
||||
io_error = 5,
|
||||
_,
|
||||
};
|
||||
@@ -10,6 +10,7 @@ pub const dcs = @import("dcs.zig");
|
||||
pub const osc = @import("osc.zig");
|
||||
pub const point = @import("point.zig");
|
||||
pub const color = @import("color.zig");
|
||||
pub const clipboard = @import("clipboard.zig");
|
||||
pub const device_attributes = @import("device_attributes.zig");
|
||||
pub const device_status = @import("device_status.zig");
|
||||
pub const focus = @import("focus.zig");
|
||||
|
||||
@@ -2,6 +2,7 @@ const std = @import("std");
|
||||
const build_options = @import("terminal_options");
|
||||
const testing = std.testing;
|
||||
const apc = @import("apc.zig");
|
||||
const clipboard = @import("clipboard.zig");
|
||||
const csi = @import("csi.zig");
|
||||
const device_attributes = @import("device_attributes.zig");
|
||||
const device_status = @import("device_status.zig");
|
||||
@@ -90,6 +91,20 @@ pub const Handler = struct {
|
||||
/// handler.terminal.getPwd().
|
||||
pwd_changed: ?*const fn (*Handler) void,
|
||||
|
||||
/// Called when the running program writes to a clipboard. The write
|
||||
/// has a normalized destination and one or more decoded MIME
|
||||
/// representations. All request, MIME, and data memory is borrowed
|
||||
/// and only valid for the duration of the callback.
|
||||
///
|
||||
/// 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_write: ?*const fn (*Handler, clipboard.Write) clipboard.WriteResult,
|
||||
|
||||
/// 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
|
||||
@@ -101,6 +116,7 @@ pub const Handler = struct {
|
||||
/// effects beyond that.
|
||||
pub const readonly: Effects = .{
|
||||
.bell = null,
|
||||
.clipboard_write = null,
|
||||
.color_scheme = null,
|
||||
.device_attributes = null,
|
||||
.enquiry = null,
|
||||
@@ -280,6 +296,7 @@ pub const Handler = struct {
|
||||
.window_title => self.windowTitle(value.title),
|
||||
.report_pwd => self.reportPwd(value.url),
|
||||
.xtversion => self.reportXtversion(),
|
||||
.clipboard_contents => try self.clipboardContents(value.kind, value.data),
|
||||
|
||||
// No supported DCS commands have any terminal-modifying effects,
|
||||
// but they may in the future. For now we just ignore it.
|
||||
@@ -291,7 +308,6 @@ pub const Handler = struct {
|
||||
// Have no terminal-modifying effect
|
||||
.show_desktop_notification,
|
||||
.progress_report,
|
||||
.clipboard_contents,
|
||||
.title_push,
|
||||
.title_pop,
|
||||
=> {},
|
||||
@@ -308,6 +324,44 @@ pub const Handler = struct {
|
||||
func(self);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const location: clipboard.Location = switch (kind) {
|
||||
's' => .selection,
|
||||
'p' => .primary,
|
||||
else => .standard,
|
||||
};
|
||||
|
||||
// OSC 52 uses an empty payload to clear the selected clipboard.
|
||||
if (data.len == 0) {
|
||||
_ = func(self, .{
|
||||
.location = location,
|
||||
.contents = &.{},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = std.base64.standard.Decoder;
|
||||
const decoded_len = try decoder.calcSizeForSlice(data);
|
||||
const alloc = self.terminal.gpa();
|
||||
const decoded = try alloc.alloc(u8, decoded_len);
|
||||
defer alloc.free(decoded);
|
||||
try decoder.decode(decoded, data);
|
||||
|
||||
const contents = [_]clipboard.Content{.{
|
||||
.mime = "text/plain",
|
||||
.data = decoded,
|
||||
}};
|
||||
_ = func(self, .{
|
||||
.location = location,
|
||||
.contents = &contents,
|
||||
});
|
||||
}
|
||||
|
||||
fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void {
|
||||
const func = self.effects.device_attributes orelse return;
|
||||
const attrs = func(self);
|
||||
@@ -1595,6 +1649,155 @@ test "bell effect callback" {
|
||||
}
|
||||
}
|
||||
|
||||
test "clipboard_write effect callback" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
// A null callback (the default readonly effects) silently ignores writes.
|
||||
{
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\");
|
||||
|
||||
// Terminal should still be functional after the ignored sequence
|
||||
s.nextSlice("AfterClipboard");
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("AfterClipboard", str);
|
||||
}
|
||||
|
||||
t.fullReset();
|
||||
|
||||
const S = struct {
|
||||
var count: usize = 0;
|
||||
var result: clipboard.WriteResult = .success;
|
||||
var last_location: clipboard.Location = .standard;
|
||||
var last_contents_len: usize = 0;
|
||||
var last_mime: ?[]u8 = null;
|
||||
var last_data: ?[]u8 = null;
|
||||
|
||||
fn clearCapture() void {
|
||||
if (last_mime) |value| testing.allocator.free(value);
|
||||
if (last_data) |value| testing.allocator.free(value);
|
||||
last_mime = null;
|
||||
last_data = null;
|
||||
last_contents_len = 0;
|
||||
}
|
||||
|
||||
fn clipboardWrite(_: *Handler, write: clipboard.Write) clipboard.WriteResult {
|
||||
clearCapture();
|
||||
count += 1;
|
||||
last_location = write.location;
|
||||
last_contents_len = write.contents.len;
|
||||
if (write.contents.len > 0) {
|
||||
last_mime = testing.allocator.dupe(u8, write.contents[0].mime) catch
|
||||
@panic("failed to capture clipboard MIME type");
|
||||
last_data = testing.allocator.dupe(u8, write.contents[0].data) catch
|
||||
@panic("failed to capture clipboard data");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
S.count = 0;
|
||||
S.result = .denied;
|
||||
S.clearCapture();
|
||||
defer S.clearCapture();
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.clipboard_write = &S.clipboardWrite;
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, handler);
|
||||
defer s.deinit();
|
||||
|
||||
// Selectors are normalized and payloads are decoded before the callback.
|
||||
const cases = [_]struct {
|
||||
sequence: []const u8,
|
||||
location: clipboard.Location,
|
||||
data: []const u8,
|
||||
}{
|
||||
.{ .sequence = "\x1B]52;c;aGVsbG8=\x1B\\", .location = .standard, .data = "hello" },
|
||||
.{ .sequence = "\x1B]52;s;d29ybGQ=\x07", .location = .selection, .data = "world" },
|
||||
.{ .sequence = "\x1B]52;p;cHJpbWFyeQ==\x1B\\", .location = .primary, .data = "primary" },
|
||||
.{ .sequence = "\x1B]52;0;Y3V0\x1B\\", .location = .standard, .data = "cut" },
|
||||
.{ .sequence = "\x1B]52;x;ZmFsbGJhY2s=\x1B\\", .location = .standard, .data = "fallback" },
|
||||
.{ .sequence = "\x1B]52;c;YQBi\x1B\\", .location = .standard, .data = "a\x00b" },
|
||||
};
|
||||
|
||||
for (cases, 1..) |case, expected_count| {
|
||||
s.nextSlice(case.sequence);
|
||||
try testing.expectEqual(expected_count, S.count);
|
||||
try testing.expectEqual(case.location, S.last_location);
|
||||
try testing.expectEqual(@as(usize, 1), S.last_contents_len);
|
||||
try testing.expectEqualStrings("text/plain", S.last_mime.?);
|
||||
try testing.expectEqualSlices(u8, case.data, S.last_data.?);
|
||||
}
|
||||
|
||||
// Empty data is a clear, represented by an empty contents slice.
|
||||
s.nextSlice("\x1B]52;s;\x1B\\");
|
||||
try testing.expectEqual(@as(usize, cases.len + 1), S.count);
|
||||
try testing.expectEqual(clipboard.Location.selection, S.last_location);
|
||||
try testing.expectEqual(@as(usize, 0), S.last_contents_len);
|
||||
try testing.expect(S.last_mime == null);
|
||||
try testing.expect(S.last_data == null);
|
||||
|
||||
// Reads and malformed base64 are ignored.
|
||||
s.nextSlice("\x1B]52;c;?\x1B\\");
|
||||
s.nextSlice("\x1B]52;c;***\x1B\\");
|
||||
try testing.expectEqual(@as(usize, cases.len + 1), S.count);
|
||||
|
||||
// OSC 1337 Copy shares the normalized clipboard write path.
|
||||
s.nextSlice("\x1B]1337;Copy=:aVRlcm0y\x1B\\");
|
||||
try testing.expectEqual(@as(usize, cases.len + 2), S.count);
|
||||
try testing.expectEqual(clipboard.Location.standard, S.last_location);
|
||||
try testing.expectEqualStrings("text/plain", S.last_mime.?);
|
||||
try testing.expectEqualStrings("iTerm2", S.last_data.?);
|
||||
|
||||
// Parsing across write boundaries still invokes exactly one atomic write.
|
||||
s.nextSlice("\x1B]52;p;ZnJh");
|
||||
s.nextSlice("Z21lbnRlZA==\x1B");
|
||||
s.nextSlice("\\");
|
||||
try testing.expectEqual(@as(usize, cases.len + 3), S.count);
|
||||
try testing.expectEqual(clipboard.Location.primary, S.last_location);
|
||||
try testing.expectEqualStrings("text/plain", S.last_mime.?);
|
||||
try testing.expectEqualStrings("fragmented", S.last_data.?);
|
||||
|
||||
// Callback results are intentionally ignored for protocols without a
|
||||
// write acknowledgement. The denied result above did not stop later writes.
|
||||
try testing.expectEqual(clipboard.WriteResult.denied, S.result);
|
||||
}
|
||||
|
||||
test "clipboard_write allocation failure is ignored" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
const S = struct {
|
||||
var count: usize = 0;
|
||||
|
||||
fn clipboardWrite(_: *Handler, _: clipboard.Write) clipboard.WriteResult {
|
||||
count += 1;
|
||||
return .success;
|
||||
}
|
||||
};
|
||||
S.count = 0;
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.clipboard_write = &S.clipboardWrite;
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, handler);
|
||||
defer s.deinit();
|
||||
|
||||
// Only the decoded scratch data uses the terminal allocator here. Swap in
|
||||
// an allocator that always fails, then restore it before terminal teardown.
|
||||
{
|
||||
const alloc = t.screens.active.alloc;
|
||||
t.screens.active.alloc = testing.failing_allocator;
|
||||
defer t.screens.active.alloc = alloc;
|
||||
s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\");
|
||||
}
|
||||
try testing.expectEqual(@as(usize, 0), S.count);
|
||||
}
|
||||
|
||||
test "request mode DECRQM with write_pty callback" {
|
||||
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
Reference in New Issue
Block a user