diff --git a/build.zig b/build.zig index ca79c1e..ff7fc11 100644 --- a/build.zig +++ b/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); } diff --git a/examples/clipboard_demo.zig b/examples/clipboard_demo.zig new file mode 100644 index 0000000..e2bb7d6 --- /dev/null +++ b/examples/clipboard_demo.zig @@ -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; + } + } +} diff --git a/src/clipboard.zig b/src/clipboard.zig new file mode 100644 index 0000000..2d21c97 --- /dev/null +++ b/src/clipboard.zig @@ -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;;\x07 +/// or with ST terminator: +/// \x1b]52;;\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); +} diff --git a/src/root.zig b/src/root.zig index 8a39efe..152f6d0 100644 --- a/src/root.zig +++ b/src/root.zig @@ -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 // ============================================================================