Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,17 @@ var program = try zz.Program(Model).initWithOptions(gpa.allocator(), .{
.cursor = false, // Show cursor
.bracketed_paste = true, // Enable bracketed paste mode
.kitty_keyboard = false, // Enable Kitty keyboard protocol
.osc52 = .{ // OSC 52 clipboard defaults
.enabled = true,
.query_enabled = true, // Allow OSC 52 clipboard reads (query)
.target = .clipboard, // .primary, .secondary, .select, .cut_buffer, .raw
.terminator = .bel, // .bel or .st
.passthrough = .auto, // .auto, .none, .tmux, .dcs
.max_bytes = null, // Optional write payload limit
.query_timeout_ms = 180,
.max_read_bytes = null, // Optional decoded read limit
.strict_query_target = false,
},
.unicode_width_strategy = null, // null=auto, .legacy_wcwidth, .unicode
.suspend_enabled = true, // Enable Ctrl+Z suspend/resume
.title = "My App", // Window title
Expand Down Expand Up @@ -714,6 +725,58 @@ pub const Msg = union(enum) {
};
```

### OSC 52 Clipboard (Copy + Query)

Copy text/bytes to the system clipboard from your app:

```zig
// Uses Program option defaults (.osc52)
_ = try ctx.setClipboard("Copied from ZigZag");
```

Query clipboard bytes back from the terminal:

```zig
if (try ctx.getClipboard(ctx.allocator)) |clip| {
// clip is decoded bytes from OSC 52 response
}
```

Per-call overrides for edge cases:

```zig
_ = try ctx.setClipboardWithOptions("Primary selection", .{
.target = .primary,
.terminator = .st,
.passthrough = .tmux,
.max_bytes = 64 * 1024,
});

if (try ctx.getClipboardWithOptions(ctx.allocator, .{
.target = .clipboard,
.timeout_ms = 250,
.passthrough = .auto,
.strict_target = true,
})) |clip| {
_ = clip;
}
```

Advanced/extension example (non-standard selector string):

```zig
_ = try ctx.setClipboardWithOptions("Custom selector", .{
.target = .{ .raw = "c" },
});
```

Notes:
- Returns `false` when disabled (`.osc52.enabled = false`), blocked by guardrails (TTY/size), or unavailable in current output mode.
- Query returns `null` when disabled/blocked/timed out/no response/invalid payload.
- `.passthrough = .auto` detects tmux/screen-like environments and wraps OSC 52 in DCS passthrough when needed.
- Terminals differ in security policy and maximum accepted sequence length. Use `.max_bytes` to enforce an app-side ceiling if desired.
- The `run-clipboard_osc52` example also handles `Msg.paste` (bracketed paste input) to demonstrate inbound paste events.

### Suspend/Resume

Ctrl+Z support is enabled by default. Handle resume events by adding a `resumed` field:
Expand Down Expand Up @@ -930,6 +993,7 @@ zig build run-dashboard
zig build run-showcase # Multi-tab demo of all features
zig build run-focus_form # Focus management with Tab cycling
zig build run-tabs # TabGroup multi-screen routing
zig build run-clipboard_osc52 # OSC 52 clipboard output demo
```

## Building
Expand Down
1 change: 1 addition & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub fn build(b: *std.Build) void {
"modal",
"tooltip",
"tabs",
"clipboard_osc52",
};

for (examples) |example_name| {
Expand Down
207 changes: 207 additions & 0 deletions examples/clipboard_osc52.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
//! ZigZag OSC 52 Clipboard Example
//! Demonstrates clipboard writes and reads with configurable OSC 52 options.

const std = @import("std");
const zz = @import("zigzag");

const Model = struct {
status: []const u8,
status_buf: [320]u8,
terminator: zz.OscTerminator,
passthrough: zz.Osc52Passthrough,

pub const Msg = union(enum) {
key: zz.KeyEvent,
paste: []const u8,
};

pub fn init(self: *Model, _: *zz.Context) zz.Cmd(Msg) {
self.* = .{
.status = "Ready. Press 'c' to copy to clipboard.",
.status_buf = undefined,
.terminator = .bel,
.passthrough = .auto,
};
return .none;
}

pub fn update(self: *Model, msg: Msg, ctx: *zz.Context) zz.Cmd(Msg) {
switch (msg) {
.key => |k| switch (k.key) {
.escape => return .quit,
.char => |c| switch (c) {
'q' => return .quit,
't' => {
self.terminator = switch (self.terminator) {
.bel => .st,
.st => .bel,
};
self.setStatus("Terminator set to {s}", .{self.terminatorName()});
},
'p' => {
self.passthrough = switch (self.passthrough) {
.auto => .none,
.none => .tmux,
.tmux => .dcs,
.dcs => .auto,
};
self.setStatus("Passthrough set to {s}", .{self.passthroughName()});
},
'c' => self.copyWith(ctx, "Copied via default target (clipboard)", null),
'1' => self.copyWith(ctx, "Copied to target=c (clipboard)", .clipboard),
'2' => self.copyWith(ctx, "Copied to target=p (primary)", .primary),
'3' => self.copyWith(ctx, "Copied to target=q (secondary)", .secondary),
'4' => self.copyWith(ctx, "Copied to target=s (select)", .select),
'g' => self.readClipboard(ctx),
else => {},
},
else => {},
},
.paste => |text| {
self.setStatusFromBytes("Pasted", text);
},
}
return .none;
}

fn copyWith(self: *Model, ctx: *zz.Context, text: []const u8, target: ?zz.Osc52Target) void {
const result = if (target) |t|
ctx.setClipboardWithOptions(text, .{
.target = t,
.terminator = self.terminator,
.passthrough = self.passthrough,
}) catch false
else
ctx.setClipboardWithOptions(text, .{
.terminator = self.terminator,
.passthrough = self.passthrough,
}) catch false;

if (result) {
self.setStatus("Sent: \"{s}\"", .{text});
} else {
self.setStatus("Clipboard write rejected (disabled / guardrail / terminal policy)", .{});
}
}

fn setStatus(self: *Model, comptime fmt: []const u8, args: anytype) void {
self.status = std.fmt.bufPrint(&self.status_buf, fmt, args) catch "status error";
}

fn readClipboard(self: *Model, ctx: *zz.Context) void {
const result = ctx.getClipboardWithOptions(ctx.allocator, .{
.terminator = self.terminator,
.passthrough = self.passthrough,
}) catch null;

if (result) |bytes| {
self.setStatusFromBytes("Read", bytes);
} else {
self.setStatus("Clipboard read unavailable (blocked / timeout / terminal policy)", .{});
}
}

fn setStatusFromBytes(self: *Model, label: []const u8, bytes: []const u8) void {
var out: [220]u8 = undefined;
var prefix_buf: [32]u8 = undefined;
const prefix = std.fmt.bufPrint(&prefix_buf, "{s}: \"", .{label}) catch "Data: \"";
const suffix = "\"";
if (out.len <= prefix.len + suffix.len + 4) {
self.status = "Read clipboard";
return;
}

var pos: usize = 0;
@memcpy(out[pos .. pos + prefix.len], prefix);
pos += prefix.len;

const max_inner = out.len - prefix.len - suffix.len;
var shown: usize = 0;
while (shown < bytes.len and shown < max_inner) : (shown += 1) {
const b = bytes[shown];
out[pos] = if (b >= 32 and b <= 126) b else '.';
pos += 1;
}
if (shown < bytes.len and pos + 3 <= out.len - suffix.len) {
out[pos] = '.';
out[pos + 1] = '.';
out[pos + 2] = '.';
pos += 3;
}
@memcpy(out[pos .. pos + suffix.len], suffix);
pos += suffix.len;

self.status = std.fmt.bufPrint(&self.status_buf, "{s}", .{out[0..pos]}) catch "read status error";
}

fn terminatorName(self: *const Model) []const u8 {
return switch (self.terminator) {
.bel => "BEL",
.st => "ST",
};
}

fn passthroughName(self: *const Model) []const u8 {
return switch (self.passthrough) {
.auto => "auto",
.none => "none",
.tmux => "tmux",
.dcs => "dcs",
};
}

pub fn view(self: *const Model, ctx: *const zz.Context) []const u8 {
var title_style = zz.Style{};
title_style = title_style.bold(true);
title_style = title_style.fg(zz.Color.cyan());
title_style = title_style.inline_style(true);

var info_style = zz.Style{};
info_style = info_style.fg(zz.Color.gray(16));
info_style = info_style.inline_style(true);

var hint_style = zz.Style{};
hint_style = hint_style.fg(zz.Color.gray(12));
hint_style = hint_style.inline_style(true);

const title = title_style.render(ctx.allocator, "OSC 52 Clipboard Demo") catch "OSC 52 Clipboard Demo";
const mode_line = std.fmt.allocPrint(ctx.allocator, "terminator={s} passthrough={s}", .{
self.terminatorName(),
self.passthroughName(),
}) catch "";
const mode = info_style.render(ctx.allocator, mode_line) catch mode_line;
const status = info_style.render(ctx.allocator, self.status) catch self.status;
const hints = hint_style.render(ctx.allocator, "c copy(default) g read(query) paste shortcut sends Msg.paste 1/2/3/4 target(c/p/q/s) t terminator p passthrough q quit") catch "";

const content = std.fmt.allocPrint(ctx.allocator, "{s}\n\n{s}\n{s}\n\n{s}", .{
title,
mode,
status,
hints,
}) catch "render error";

return zz.place.place(ctx.allocator, ctx.width, ctx.height, .center, .middle, content) catch content;
}
};

pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();

var program = try zz.Program(Model).initWithOptions(gpa.allocator(), .{
.title = "ZigZag OSC 52 Clipboard",
.osc52 = .{
.enabled = true,
.query_enabled = true,
.target = .clipboard,
.terminator = .bel,
.passthrough = .auto,
.max_bytes = 256 * 1024,
.query_timeout_ms = 250,
.max_read_bytes = 256 * 1024,
},
});
defer program.deinit();

try program.run();
}
38 changes: 38 additions & 0 deletions src/core/context.zig
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,41 @@ pub const Context = struct {
return false;
}

/// Copy bytes to the system clipboard via OSC 52 using terminal defaults.
/// Returns false when clipboard output is unavailable or disabled.
pub fn setClipboard(self: *Context, bytes: []const u8) !bool {
if (self._terminal) |term| {
return term.setClipboard(bytes);
}
return false;
}

/// Copy bytes to the system clipboard via OSC 52 with per-call overrides.
pub fn setClipboardWithOptions(self: *Context, bytes: []const u8, options: terminal_mod.Osc52WriteOptions) !bool {
if (self._terminal) |term| {
return term.setClipboardWithOptions(bytes, options);
}
return false;
}

/// Query clipboard bytes via OSC 52 using terminal defaults.
/// Returns null when unavailable, blocked, or timed out.
pub fn getClipboard(self: *Context, allocator: std.mem.Allocator) !?[]u8 {
if (self._terminal) |term| {
return term.getClipboard(allocator);
}
return null;
}

/// Query clipboard bytes via OSC 52 with per-call overrides.
/// Returns null when unavailable, blocked, or timed out.
pub fn getClipboardWithOptions(self: *Context, allocator: std.mem.Allocator, options: terminal_mod.Osc52ReadOptions) !?[]u8 {
if (self._terminal) |term| {
return term.getClipboardWithOptions(allocator, options);
}
return null;
}

/// Draw a PNG image file via Kitty graphics protocol (`t=f`).
/// Returns false when unsupported or path is empty.
pub fn drawKittyImageFromFile(self: *Context, path: []const u8, options: Terminal.KittyImageFileOptions) !bool {
Expand Down Expand Up @@ -309,6 +344,9 @@ pub const Options = struct {
/// Enable Kitty keyboard protocol
kitty_keyboard: bool = false,

/// OSC 52 clipboard configuration
osc52: terminal_mod.Osc52Config = .{},

/// Force Unicode width strategy (`null` = auto-detect)
unicode_width_strategy: ?unicode_mod.WidthStrategy = null,

Expand Down
1 change: 1 addition & 0 deletions src/core/program.zig
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ pub fn Program(comptime Model: type) type {
.input = self.options.input,
.output = self.options.output,
.kitty_keyboard = self.options.kitty_keyboard,
.osc52 = self.options.osc52,
});

// Set title if provided
Expand Down
6 changes: 6 additions & 0 deletions src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,12 @@ pub const CacheImage = command.CacheImage;
pub const PlaceCachedImage = command.PlaceCachedImage;
pub const DeleteImage = command.DeleteImage;
pub const ImageCapabilities = terminal.ImageCapabilities;
pub const Osc52Target = terminal.Osc52Target;
pub const Osc52Passthrough = terminal.Osc52Passthrough;
pub const Osc52Config = terminal.Osc52Config;
pub const Osc52WriteOptions = terminal.Osc52WriteOptions;
pub const Osc52ReadOptions = terminal.Osc52ReadOptions;
pub const OscTerminator = terminal.ansi.OscTerminator;

// Color utilities
pub const ColorProfile = color.ColorProfile;
Expand Down
Loading