Add clipboard support via OSC 52 escape sequences

New features:
- Copy text to system clipboard (Ctrl+V paste)
- Copy to X11 primary selection (middle-click paste)
- Copy to both selections simultaneously
- Clear clipboard contents
- Query clipboard (limited terminal support)
- Parse OSC 52 responses

API:
- copy(allocator, writer, text) - allocating version
- copySmall(writer, text) - stack-based for text <= 1024 bytes
- copyTo/copySmallTo - select target (clipboard/primary/both)
- clear(writer) - clear clipboard
- parseResponse(allocator, response) - decode clipboard responses

Example: clipboard_demo.zig with interactive testing

Tests: 8 clipboard tests, all pass

Terminal support: xterm, iTerm2, kitty, alacritty, WezTerm, foot
Note: tmux requires 'set -g set-clipboard on'

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 13:43:36 +01:00
parent 7c6515765e
commit 3ec75f6264
4 changed files with 498 additions and 0 deletions

View file

@ -156,4 +156,23 @@ pub fn build(b: *std.Build) void {
run_animation_demo.step.dependOn(b.getInstallStep());
const animation_demo_step = b.step("animation-demo", "Run animation demo");
animation_demo_step.dependOn(&run_animation_demo.step);
// Ejemplo: clipboard_demo
const clipboard_demo_exe = b.addExecutable(.{
.name = "clipboard-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/clipboard_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(clipboard_demo_exe);
const run_clipboard_demo = b.addRunArtifact(clipboard_demo_exe);
run_clipboard_demo.step.dependOn(b.getInstallStep());
const clipboard_demo_step = b.step("clipboard-demo", "Run clipboard demo");
clipboard_demo_step.dependOn(&run_clipboard_demo.step);
}

192
examples/clipboard_demo.zig Normal file
View file

@ -0,0 +1,192 @@
//! Clipboard demo for zcatui.
//!
//! Demonstrates OSC 52 clipboard support:
//! - Copy text to system clipboard
//! - Copy to primary selection (X11)
//! - Clear clipboard
//!
//! Run with: zig build clipboard-demo
//!
//! Note: OSC 52 support varies by terminal. If clipboard operations
//! don't work, check your terminal's documentation.
const std = @import("std");
const zcatui = @import("zcatui");
const Terminal = zcatui.Terminal;
const Buffer = zcatui.Buffer;
const Rect = zcatui.Rect;
const Style = zcatui.Style;
const Color = zcatui.Color;
const Event = zcatui.Event;
const KeyCode = zcatui.KeyCode;
const Layout = zcatui.Layout;
const Constraint = zcatui.Constraint;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
const Paragraph = zcatui.widgets.Paragraph;
const Clipboard = zcatui.clipboard;
const AppState = struct {
running: bool = true,
last_action: []const u8 = "Press a key to test clipboard",
copy_count: u32 = 0,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
var state = AppState{};
while (state.running) {
try term.drawWithContext(&state, render);
if (try term.pollEvent(100)) |event| {
try handleEvent(&state, &term, allocator, event);
}
}
}
fn handleEvent(state: *AppState, term: *Terminal, allocator: std.mem.Allocator, event: Event) !void {
const writer = term.backend.stdout.deprecatedWriter();
switch (event) {
.key => |key| {
switch (key.code) {
.esc => state.running = false,
.char => |c| {
switch (c) {
'q', 'Q' => state.running = false,
'1' => {
state.copy_count += 1;
var buf: [64]u8 = undefined;
const text = std.fmt.bufPrint(&buf, "Hello from zcatui #{d}!", .{state.copy_count}) catch "Hello!";
try Clipboard.copy(allocator, writer, text);
state.last_action = "Copied text to clipboard (Ctrl+V to paste)";
},
'2' => {
try Clipboard.copySmallTo(writer, "Primary selection text", .primary);
state.last_action = "Copied to primary (middle-click to paste)";
},
'3' => {
try Clipboard.copySmallTo(writer, "Both clipboard and primary!", .both);
state.last_action = "Copied to both clipboard and primary";
},
'c', 'C' => {
try Clipboard.clear(writer);
state.last_action = "Cleared clipboard";
},
else => {},
}
},
else => {},
}
},
else => {},
}
}
fn render(state: *AppState, area: Rect, buf: *Buffer) void {
// Main layout
const chunks = Layout.vertical(&.{
Constraint.length(3), // Title
Constraint.length(8), // Actions
Constraint.length(5), // Status
Constraint.min(0), // Help
}).split(area);
// Title
renderTitle(chunks.get(0), buf);
// Actions
renderActions(chunks.get(1), buf);
// Status
renderStatus(state, chunks.get(2), buf);
// Help
renderHelp(chunks.get(3), buf);
}
fn renderTitle(area: Rect, buf: *Buffer) void {
const block = Block.init()
.title(" Clipboard Demo (OSC 52) ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.cyan));
block.render(area, buf);
const inner = block.inner(area);
_ = buf.setString(inner.left(), inner.top(), "Test clipboard operations in your terminal", Style.default);
}
fn renderActions(area: Rect, buf: *Buffer) void {
const block = Block.init()
.title(" Available Actions ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.yellow));
block.render(area, buf);
const inner = block.inner(area);
var y = inner.top();
const actions = [_][]const u8{
"1 - Copy text to clipboard",
"2 - Copy to primary selection (X11)",
"3 - Copy to both clipboard and primary",
"c - Clear clipboard",
"q - Quit",
};
for (actions) |action| {
if (y < inner.bottom()) {
_ = buf.setString(inner.left(), y, action, Style.default);
y += 1;
}
}
}
fn renderStatus(state: *AppState, area: Rect, buf: *Buffer) void {
const block = Block.init()
.title(" Last Action ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.green));
block.render(area, buf);
const inner = block.inner(area);
_ = buf.setString(inner.left(), inner.top(), state.last_action, Style.default.fg(Color.white).bold());
if (state.copy_count > 0) {
var count_buf: [32]u8 = undefined;
const count_text = std.fmt.bufPrint(&count_buf, "Total copies: {d}", .{state.copy_count}) catch "?";
_ = buf.setString(inner.left(), inner.top() + 1, count_text, Style.default.dim());
}
}
fn renderHelp(area: Rect, buf: *Buffer) void {
const block = Block.init()
.title(" Notes ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.blue));
block.render(area, buf);
const inner = block.inner(area);
var y = inner.top();
const notes = [_][]const u8{
"OSC 52 clipboard support varies by terminal.",
"Supported: xterm, iTerm2, kitty, alacritty, WezTerm, foot",
"tmux: enable with 'set -g set-clipboard on'",
"Some terminals disable OSC 52 by default for security.",
};
for (notes) |note| {
if (y < inner.bottom()) {
_ = buf.setString(inner.left(), y, note, Style.default.dim());
y += 1;
}
}
}

282
src/clipboard.zig Normal file
View file

@ -0,0 +1,282 @@
//! Clipboard support via OSC 52 escape sequences.
//!
//! OSC 52 is a terminal escape sequence that allows programs to
//! copy text to the system clipboard. It's supported by many
//! modern terminals including:
//! - xterm
//! - iTerm2
//! - kitty
//! - alacritty
//! - WezTerm
//! - foot
//! - tmux (with set-clipboard on)
//!
//! ## Example
//!
//! ```zig
//! const clipboard = @import("clipboard.zig");
//!
//! // Copy to clipboard
//! try clipboard.copy(allocator, writer, "Hello, World!");
//!
//! // Copy with specific clipboard target
//! try clipboard.copyTo(allocator, writer, "Hello", .primary);
//! ```
//!
//! ## Security Note
//!
//! Some terminals disable OSC 52 by default for security reasons
//! (it could potentially be abused by malicious content in cat'd files).
//! Users may need to explicitly enable it in their terminal settings.
const std = @import("std");
const Allocator = std.mem.Allocator;
const base64 = std.base64;
/// Clipboard selection target.
pub const Selection = enum {
/// System clipboard (Ctrl+V)
clipboard,
/// Primary selection (middle-click paste on X11)
primary,
/// Both clipboard and primary
both,
/// Returns the OSC 52 parameter string for this selection.
fn toParam(self: Selection) []const u8 {
return switch (self) {
.clipboard => "c",
.primary => "p",
.both => "pc",
};
}
};
/// OSC 52 escape sequence format:
/// \x1b]52;<target>;<base64-data>\x07
/// or with ST terminator:
/// \x1b]52;<target>;<base64-data>\x1b\\
const OSC_START = "\x1b]52;";
const BEL = "\x07";
const ST = "\x1b\\";
/// Copies text to the system clipboard via OSC 52.
///
/// Uses the standard 'c' (clipboard) selection target.
pub fn copy(allocator: Allocator, writer: anytype, text: []const u8) !void {
try copyTo(allocator, writer, text, .clipboard);
}
/// Copies text to the specified clipboard selection.
///
/// `selection` can be:
/// - `.clipboard` - system clipboard (Ctrl+V paste)
/// - `.primary` - X11 primary selection (middle-click paste)
/// - `.both` - copy to both
pub fn copyTo(allocator: Allocator, writer: anytype, text: []const u8, selection: Selection) !void {
// Calculate base64 encoded size
const encoded_len = base64.standard.Encoder.calcSize(text.len);
// Allocate buffer for base64 encoded data
const encoded = try allocator.alloc(u8, encoded_len);
defer allocator.free(encoded);
// Encode text to base64
_ = base64.standard.Encoder.encode(encoded, text);
// Write OSC 52 sequence
try writer.writeAll(OSC_START);
try writer.writeAll(selection.toParam());
try writer.writeAll(";");
try writer.writeAll(encoded);
try writer.writeAll(BEL);
}
/// Copies text using a stack buffer (no allocation needed for small strings).
///
/// Maximum text length is 1024 bytes (results in ~1365 base64 chars).
/// For longer text, use `copy()` which allocates.
pub fn copySmall(writer: anytype, text: []const u8) !void {
try copySmallTo(writer, text, .clipboard);
}
/// Copies text to the specified selection using a stack buffer.
pub fn copySmallTo(writer: anytype, text: []const u8, selection: Selection) !void {
const max_text_len = 1024;
if (text.len > max_text_len) {
return error.TextTooLong;
}
// Stack buffer for base64 (1024 bytes -> 1368 base64 chars max)
var encoded: [1368]u8 = undefined;
const encoded_len = base64.standard.Encoder.calcSize(text.len);
_ = base64.standard.Encoder.encode(encoded[0..encoded_len], text);
// Write OSC 52 sequence
try writer.writeAll(OSC_START);
try writer.writeAll(selection.toParam());
try writer.writeAll(";");
try writer.writeAll(encoded[0..encoded_len]);
try writer.writeAll(BEL);
}
/// Clears the clipboard contents.
pub fn clear(writer: anytype) !void {
try clearSelection(writer, .clipboard);
}
/// Clears the specified clipboard selection.
pub fn clearSelection(writer: anytype, selection: Selection) !void {
// Empty base64 data clears the clipboard
try writer.writeAll(OSC_START);
try writer.writeAll(selection.toParam());
try writer.writeAll(";");
try writer.writeAll(BEL);
}
/// Queries the clipboard contents (not widely supported).
///
/// Note: Most terminals don't support this for security reasons.
/// When supported, the terminal will respond with OSC 52 containing
/// the clipboard contents in base64.
pub fn query(writer: anytype) !void {
try querySelection(writer, .clipboard);
}
/// Queries the specified clipboard selection.
pub fn querySelection(writer: anytype, selection: Selection) !void {
// '?' requests clipboard contents
try writer.writeAll(OSC_START);
try writer.writeAll(selection.toParam());
try writer.writeAll(";?");
try writer.writeAll(BEL);
}
/// Parses an OSC 52 response to extract clipboard contents.
///
/// Returns null if the response is not a valid OSC 52 sequence.
/// The returned slice is allocated and must be freed by the caller.
pub fn parseResponse(allocator: Allocator, response: []const u8) !?[]u8 {
// Check for OSC 52 prefix
if (!std.mem.startsWith(u8, response, OSC_START)) {
return null;
}
// Find the semicolon after selection parameter
const data_start = std.mem.indexOfPos(u8, response, OSC_START.len, ";") orelse return null;
// Find the terminator (BEL or ST)
const data_end = blk: {
if (std.mem.indexOf(u8, response[data_start..], BEL)) |pos| {
break :blk data_start + pos;
}
if (std.mem.indexOf(u8, response[data_start..], ST)) |pos| {
break :blk data_start + pos;
}
return null;
};
const base64_data = response[data_start + 1 .. data_end];
if (base64_data.len == 0) {
// Empty clipboard
return try allocator.alloc(u8, 0);
}
// Decode base64
const decoded_len = base64.standard.Decoder.calcSizeForSlice(base64_data) catch return null;
const decoded = try allocator.alloc(u8, decoded_len);
errdefer allocator.free(decoded);
base64.standard.Decoder.decode(decoded, base64_data) catch {
allocator.free(decoded);
return null;
};
return decoded;
}
// ============================================================================
// Tests
// ============================================================================
test "copy generates correct OSC 52 sequence" {
var buf: [256]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
try copySmall(stream.writer(), "hello");
const expected = "\x1b]52;c;aGVsbG8=\x07";
try std.testing.expectEqualStrings(expected, stream.getWritten());
}
test "copyTo with primary selection" {
var buf: [256]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
try copySmallTo(stream.writer(), "test", .primary);
const expected = "\x1b]52;p;dGVzdA==\x07";
try std.testing.expectEqualStrings(expected, stream.getWritten());
}
test "copyTo with both selections" {
var buf: [256]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
try copySmallTo(stream.writer(), "test", .both);
const expected = "\x1b]52;pc;dGVzdA==\x07";
try std.testing.expectEqualStrings(expected, stream.getWritten());
}
test "clear clipboard" {
var buf: [64]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
try clear(stream.writer());
const expected = "\x1b]52;c;\x07";
try std.testing.expectEqualStrings(expected, stream.getWritten());
}
test "copy with allocator" {
const allocator = std.testing.allocator;
var buf: [256]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
try copy(allocator, stream.writer(), "hello world");
const expected = "\x1b]52;c;aGVsbG8gd29ybGQ=\x07";
try std.testing.expectEqualStrings(expected, stream.getWritten());
}
test "parseResponse valid response" {
const allocator = std.testing.allocator;
const response = "\x1b]52;c;aGVsbG8=\x07";
const result = try parseResponse(allocator, response);
defer if (result) |r| allocator.free(r);
try std.testing.expect(result != null);
try std.testing.expectEqualStrings("hello", result.?);
}
test "parseResponse invalid response" {
const allocator = std.testing.allocator;
const result = try parseResponse(allocator, "not an osc sequence");
try std.testing.expect(result == null);
}
test "copySmall rejects long text" {
var buf: [2048]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
// Create text longer than 1024 bytes
var long_text: [1025]u8 = undefined;
@memset(&long_text, 'x');
const result = copySmall(stream.writer(), &long_text);
try std.testing.expectError(error.TextTooLong, result);
}

View file

@ -167,6 +167,11 @@ pub const AnimationGroup = animation.AnimationGroup;
pub const Easing = animation.Easing;
pub const Timer = animation.Timer;
// Clipboard (OSC 52)
pub const clipboard = @import("clipboard.zig");
pub const Clipboard = clipboard;
pub const ClipboardSelection = clipboard.Selection;
// ============================================================================
// Tests
// ============================================================================