diff --git a/example/c-vt-effects/README.md b/example/c-vt-effects/README.md index 5f5a22b14..724edb2ee 100644 --- a/example/c-vt-effects/README.md +++ b/example/c-vt-effects/README.md @@ -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 diff --git a/example/c-vt-effects/src/main.c b/example/c-vt-effects/src/main.c index 1e3a3d645..cefee2ada 100644 --- a/example/c-vt-effects/src/main.c +++ b/example/c-vt-effects/src/main.c @@ -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 ; 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); diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 0cd87feb5..a68b24f92 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -82,7 +82,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_SET` | `GhosttyTerminalClipboardSetFn` | Clipboard write via OSC 52 | + * | `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 @@ -93,6 +93,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 * @@ -286,32 +289,123 @@ typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal, void* userdata); /** - * Callback function type for clipboard_set. + * Clipboard destination for a clipboard write. * - * Called when the running program sets the clipboard via OSC 52. The - * kind byte identifies the target selection ('c' clipboard, 'p' primary, - * 's' selection, '0'-'7' cut buffers); most terminals treat everything - * as the standard clipboard. The data is the base64-encoded payload - * exactly as received; decoding is left to the embedder. The data is - * only valid for the duration of the call. - * - * OSC 52 clipboard *read* requests ("?") are never forwarded to this - * callback: answering one would let any program running in the terminal - * silently read the user's clipboard. - * - * @param terminal The terminal handle - * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA - * @param kind The OSC 52 selection kind byte - * @param data Pointer to the base64-encoded payload bytes - * @param len Length of the payload in bytes + * Protocol-specific destination identifiers are normalized to these values + * before the clipboard write callback is invoked. * * @ingroup terminal */ -typedef void (*GhosttyTerminalClipboardSetFn)(GhosttyTerminal terminal, - void* userdata, - uint8_t kind, - const uint8_t* data, - size_t len); +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). @@ -724,13 +818,15 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_TERMINAL_OPT_PWD_CHANGED = 25, /** - * Callback invoked when the running program sets the clipboard via - * OSC 52. Set to NULL to ignore clipboard writes. Read requests are - * always ignored; see GhosttyTerminalClipboardSetFn. + * 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: GhosttyTerminalClipboardSetFn + * Input type: GhosttyTerminalClipboardWriteFn */ - GHOSTTY_TERMINAL_OPT_CLIPBOARD_SET = 26, + GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE = 26, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 81fe67f15..6047a7057 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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,7 +73,7 @@ const Effects = struct { title_changed: ?TitleChangedFn = null, pwd_changed: ?PwdChangedFn = null, size_cb: ?SizeFn = null, - clipboard_set: ?ClipboardSetFn = null, + clipboard_write: ?ClipboardWriteFn = null, /// Scratch buffer for DA1 feature codes. The device attributes /// trampoline converts C feature codes into this buffer and returns @@ -85,10 +104,9 @@ 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_set callback. The kind - /// byte identifies the OSC 52 target selection and the data is the - /// base64-encoded payload exactly as received. - pub const ClipboardSetFn = *const fn (Terminal, ?*anyopaque, u8, [*]const u8, usize) callconv(lib.calling_conv) void; + /// 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; @@ -144,11 +162,42 @@ const Effects = struct { func(@ptrCast(wrapper), wrapper.effects.userdata); } - fn clipboardSetTrampoline(handler: *Handler, kind: u8, data: []const u8) void { + 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_set orelse return; - func(@ptrCast(wrapper), wrapper.effects.userdata, kind, data.ptr, data.len); + 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 { @@ -309,7 +358,7 @@ fn new_( .title_changed = &Effects.titleChangedTrampoline, .pwd_changed = &Effects.pwdChangedTrampoline, .size = &Effects.sizeTrampoline, - .clipboard_set = &Effects.clipboardSetTrampoline, + .clipboard_write = &Effects.clipboardWriteTrampoline, }; wrapper.* = .{ @@ -357,7 +406,7 @@ pub const Option = enum(c_int) { default_cursor_blink = 23, glyph_protocol = 24, pwd_changed = 25, - clipboard_set = 26, + clipboard_write = 26, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -372,7 +421,7 @@ pub const Option = enum(c_int) { .title_changed => ?Effects.TitleChangedFn, .pwd_changed => ?Effects.PwdChangedFn, .size_cb => ?Effects.SizeFn, - .clipboard_set => ?Effects.ClipboardSetFn, + .clipboard_write => ?Effects.ClipboardWriteFn, .title, .pwd => ?*const lib.String, .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, .color_palette => ?*const color.PaletteC, @@ -429,7 +478,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_set => wrapper.effects.clipboard_set = 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; @@ -2438,7 +2487,7 @@ test "set pwd_changed callback" { try testing.expectEqualStrings("file:///home/user", zigTerminal(t).?.getPwd().?); } -test "set clipboard_set callback" { +test "set clipboard_write callback" { var t: Terminal = null; try testing.expectEqual(Result.success, new( &lib.alloc.test_allocator, @@ -2453,47 +2502,147 @@ test "set clipboard_set callback" { const S = struct { var count: usize = 0; + var last_terminal: Terminal = null; var last_userdata: ?*anyopaque = null; - var last_kind: u8 = 0; - var last_data: [64]u8 = undefined; - var last_len: usize = 0; + 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 clipboardSet( - _: Terminal, + fn clipboardWrite( + terminal_: Terminal, ud: ?*anyopaque, - kind: u8, - data: [*]const u8, - len: usize, - ) callconv(lib.calling_conv) void { + request: *const ClipboardWrite, + ) callconv(lib.calling_conv) clipboard.WriteResult { count += 1; + last_terminal = terminal_; last_userdata = ud; - last_kind = kind; - last_len = @min(len, last_data.len); - @memcpy(last_data[0..last_len], data[0..last_len]); + 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_set, @ptrCast(&S.clipboardSet))); + try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite))); - // OSC 52 ; c ; base64("hello") ST — clipboard write - const seq1 = "\x1B]52;c;aGVsbG8=\x1B\\"; - vt_write(t, seq1, seq1.len); + // 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(@as(u8, 'c'), S.last_kind); - try testing.expectEqualStrings("aGVsbG8=", S.last_data[0..S.last_len]); + 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 read requests ("?") must never reach the callback. - const seq2 = "\x1B]52;c;?\x1B\\"; - vt_write(t, seq2, seq2.len); - try testing.expectEqual(@as(usize, 1), S.count); + // 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 = "plain" }, + .{ .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_set without callback is silent" { +test "clipboard_write without callback is unsupported and silent" { var t: Terminal = null; try testing.expectEqual(Result.success, new( &lib.alloc.test_allocator, @@ -2509,6 +2658,13 @@ test "clipboard_set without callback is silent" { // 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" { diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index de043a038..48d7f4fdd 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -37,6 +37,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) }, .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, @@ -209,9 +211,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")); diff --git a/src/terminal/clipboard.zig b/src/terminal/clipboard.zig new file mode 100644 index 000000000..554ca984b --- /dev/null +++ b/src/terminal/clipboard.zig @@ -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, + _, +}; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 53491a009..99699be0d 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -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"); diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 021fac854..e6c55fa29 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.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"); @@ -88,18 +89,19 @@ pub const Handler = struct { /// handler.terminal.getPwd(). pwd_changed: ?*const fn (*Handler) void, - /// Called when the running program sets the clipboard via OSC 52. - /// The kind byte identifies the target selection ('c' clipboard, - /// 'p' primary, 's' selection, '0'-'7' cut buffers) and data is the - /// base64-encoded payload exactly as received; decoding and kind - /// interpretation are left to the embedder. The data is only valid - /// during the lifetime of the call. + /// 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_set: ?*const fn (*Handler, u8, []const u8) void, + 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 @@ -112,7 +114,7 @@ pub const Handler = struct { /// effects beyond that. pub const readonly: Effects = .{ .bell = null, - .clipboard_set = null, + .clipboard_write = null, .color_scheme = null, .device_attributes = null, .enquiry = null, @@ -290,7 +292,7 @@ pub const Handler = struct { .window_title => self.windowTitle(value.title), .report_pwd => self.reportPwd(value.url), .xtversion => self.reportXtversion(), - .clipboard_contents => self.clipboardContents(value.kind, value.data), + .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. @@ -318,17 +320,42 @@ pub const Handler = struct { func(self); } - fn clipboardContents(self: *Handler, kind: u8, data: []const u8) void { - const func = self.effects.clipboard_set orelse return; + 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 - // clipboard_set effect docs. Empty payloads carry nothing to set - // (some emitters use them to clear the clipboard), so they are - // ignored as well rather than inventing clear semantics here. - if (data.len == 0) return; + // Read requests are deliberately not forwarded; see the effect docs. if (data.len == 1 and data[0] == '?') return; - func(self, kind, data); + 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 { @@ -1444,11 +1471,11 @@ test "bell effect callback" { } } -test "clipboard_set effect callback" { +test "clipboard_write effect callback" { var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); - // Test OSC 52 with null callback (default readonly effects) doesn't crash + // A null callback (the default readonly effects) silently ignores writes. { var s: Stream = .initAlloc(testing.allocator, .init(&t)); defer s.deinit(); @@ -1464,45 +1491,133 @@ test "clipboard_set effect callback" { t.fullReset(); - // Test OSC 52 with a callback - { - const S = struct { - var count: usize = 0; - var last_kind: u8 = 0; - var last_data: ?[:0]const u8 = null; - fn clipboardSet(_: *Handler, kind: u8, data: []const u8) void { - count += 1; - last_kind = kind; - if (last_data) |old| testing.allocator.free(old); - last_data = testing.allocator.dupeZ(u8, data) catch null; + 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"); } - }; - S.count = 0; - defer if (S.last_data) |data| testing.allocator.free(data); + return result; + } + }; + S.count = 0; + S.result = .denied; + S.clearCapture(); + defer S.clearCapture(); - var handler: Handler = .init(&t); - handler.effects.clipboard_set = &S.clipboardSet; + var handler: Handler = .init(&t); + handler.effects.clipboard_write = &S.clipboardWrite; - var s: Stream = .initAlloc(testing.allocator, handler); - defer s.deinit(); + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); - // A write is forwarded with its kind and undecoded base64 payload. - s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\"); - try testing.expectEqual(@as(usize, 1), S.count); - try testing.expectEqual(@as(u8, 'c'), S.last_kind); - try testing.expectEqualStrings("aGVsbG8=", S.last_data.?); + // 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" }, + }; - // BEL termination and a non-default kind work too. - s.nextSlice("\x1B]52;p;d29ybGQ=\x07"); - try testing.expectEqual(@as(usize, 2), S.count); - try testing.expectEqual(@as(u8, 'p'), S.last_kind); - try testing.expectEqualStrings("d29ybGQ=", S.last_data.?); - - // Read requests and empty payloads are never forwarded. - s.nextSlice("\x1B]52;c;?\x1B\\"); - s.nextSlice("\x1B]52;c;\x1B\\"); - try testing.expectEqual(@as(usize, 2), S.count); + 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" {