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 b67e4c6de..26ee0c0b8 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -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; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 439b2e001..1c3b9f0c5 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,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 = "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_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( diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 6e9cf806e..f160f3852 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -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")); 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 c5b27fa9b..af277f976 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 c635e91d9..91cfb21cf 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"); @@ -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);