terminal: make clipboard writes protocol neutral

#13182

Replace the OSC 52-specific kind and encoded payload callback with an
atomic clipboard write containing a normalized destination and decoded
MIME representations. This keeps protocol details out of embedders and
lets iTerm2 Copy use the same semantic path.

Represent clears with an empty content list, preserve binary payloads,
and return a generic result for protocols that acknowledge writes. Add
the C ABI descriptors, layout metadata, and effects example so future
multipart protocols can reuse the callback without another API break.
This commit is contained in:
Mitchell Hashimoto
2026-07-10 09:17:26 -07:00
parent 0a410f18e5
commit 634ef71986
8 changed files with 582 additions and 118 deletions

View File

@@ -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 = "<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_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" {

View File

@@ -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"));

View 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,
_,
};

View File

@@ -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");

View File

@@ -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" {