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:
parent
7c6515765e
commit
3ec75f6264
4 changed files with 498 additions and 0 deletions
19
build.zig
19
build.zig
|
|
@ -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
192
examples/clipboard_demo.zig
Normal 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
282
src/clipboard.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue