Something went wrong. Try again.
A CLI that listens for clipboard pastes via the kitty clipboard protocol and renders them as inline images or hexdumps.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! clipwatch: listen for clipboard pastes via the kitty clipboard protocol//! (OSC 5522, https://sw.kovidgoyal.net/kitty/clipboard/).//!//! With paste-events mode (CSI ? 5522 h) enabled, the terminal does not send//! pasted text to us. Instead, each user paste produces a read-response for//! the special "." MIME type: an OK packet, DATA packets whose payload is a//! base64 space-separated list of the MIME types on the clipboard, and a DONE//! packet — all carrying a one-time password in the `pw` key. We answer with//! a read request for the type we want, authorized by that password, and//! render the result: images via the kitty graphics protocol using Unicode//! placeholders, everything else as a hexdump.//!//! The whole protocol also works inside tmux (`allow-passthrough on`//! required): protocol writes are wrapped in tmux's passthrough sequence so//! they reach the outer terminal, whose replies and paste events tmux//! forwards to the active pane verbatim.
const std = @import("std");const builtin = @import("builtin");const Io = std.Io;const Allocator = std.mem.Allocator;const b64_enc = std.base64.standard.Encoder;const b64_dec = std.base64.standard.Decoder;
const default_hexdump_limit: ?usize = 20 * 16; // 20 hexdump lines
const usage = \\usage: clipwatch [options] \\ \\ --limit <n>[lines|bytes] cap hexdump output at n lines or bytes \\ (default: 20 lines; a bare number means lines) \\ --limit unlimited never trim hexdump output \\ -h, --help show this help \\;
pub fn main(init: std.process.Init) !void { const io = init.io; const gpa = init.gpa;
const limit = try parseArgs(gpa, io, init.minimal.args);
const stdin_file: Io.File = .stdin(); const stdout_file: Io.File = .stdout();
if (!(try stdin_file.isTty(io)) or !(try stdout_file.isTty(io))) std.process.fatal("clipwatch must be run interactively in a terminal that supports the kitty clipboard protocol", .{});
var in_buf: [8192]u8 = undefined; var stdin_reader: Io.File.Reader = .initStreaming(stdin_file, io, &in_buf); const in = &stdin_reader.interface;
var out_buf: [8192]u8 = undefined; var stdout_writer: Io.File.Writer = .init(stdout_file, io, &out_buf); const out = &stdout_writer.interface;
// Raw-ish mode: unbuffered, no echo, no signal keys (we handle ctrl+c // ourselves so cleanup below always runs). Newline output processing is // left on so "\n" still produces CRLF on both platforms. var term = try Terminal.init(stdin_file.handle, stdout_file.handle);
const tmux = insideTmux(gpa, init.minimal.environ);
defer term.restore(); defer { writeSeq(out, tmux, "\x1b[?5522l") catch {}; // leave paste-events mode out.flush() catch {}; }
try run(gpa, in, out, limit, tmux);}
/// Write an escape sequence to the terminal. Inside tmux it is wrapped in a/// passthrough sequence (ESC Ptmux; with the payload's ESCs doubled) so it/// reaches the outer terminal instead of being consumed by tmux.fn writeSeq(out: *Io.Writer, tmux: bool, seq: []const u8) !void { if (!tmux) return out.writeAll(seq); try out.writeAll("\x1bPtmux;"); var rest = seq; while (std.mem.indexOfScalar(u8, rest, 0x1b)) |i| { try out.writeAll(rest[0..i]); try out.writeAll("\x1b\x1b"); rest = rest[i + 1 ..]; } try out.writeAll(rest); try out.writeAll("\x1b\\");}
/// Detect whether we are running inside tmux (or screen), in which case/// graphics escape sequences must be wrapped in a passthrough sequence.fn insideTmux(gpa: Allocator, environ: std.process.Environ) bool { if (environ.getAlloc(gpa, "TMUX")) |value| { defer gpa.free(value); if (value.len > 0) return true; } else |_| {} if (environ.getAlloc(gpa, "TERM")) |term| { defer gpa.free(term); return std.mem.startsWith(u8, term, "tmux") or std.mem.startsWith(u8, term, "screen"); } else |_| {} return false;}
/// Parse the command line, returning the hexdump limit in bytes/// (null = unlimited). Exits on --help or on a bad argument.fn parseArgs(gpa: Allocator, io: Io, args: std.process.Args) !?usize { var limit = default_hexdump_limit; var it = try std.process.Args.Iterator.initAllocator(args, gpa); defer it.deinit(); _ = it.skip(); // program name while (it.next()) |arg| { if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) { var buf: [usage.len]u8 = undefined; var w: Io.File.Writer = .init(.stdout(), io, &buf); w.interface.writeAll(usage) catch {}; w.interface.flush() catch {}; std.process.exit(0); } const value = if (std.mem.eql(u8, arg, "--limit")) it.next() orelse std.process.fatal("--limit requires a value", .{}) else if (std.mem.startsWith(u8, arg, "--limit=")) arg["--limit=".len..] else std.process.fatal("unknown argument: {s}\n{s}", .{ arg, usage }); limit = parseLimit(value) catch std.process.fatal("invalid --limit value: \"{s}\" (expected <n>[lines|bytes] or \"unlimited\")", .{value}); } return limit;}
/// Parse a --limit value into a byte count (null = unlimited):/// "unlimited", or a number with an optional "lines"/"l" or "bytes"/"b"/// suffix. A bare number means lines.fn parseLimit(value: []const u8) error{InvalidLimit}!?usize { if (std.mem.eql(u8, value, "unlimited")) return null; var end: usize = 0; while (end < value.len and std.ascii.isDigit(value[end])) end += 1; const n = std.fmt.parseInt(usize, value[0..end], 10) catch return error.InvalidLimit; const suffix = value[end..]; if (suffix.len == 0 or std.mem.eql(u8, suffix, "lines") or std.mem.eql(u8, suffix, "l")) return std.math.mul(usize, n, 16) catch return error.InvalidLimit; if (std.mem.eql(u8, suffix, "bytes") or std.mem.eql(u8, suffix, "b")) return n; return error.InvalidLimit;}
fn run(gpa: Allocator, in: *Io.Reader, out: *Io.Writer, limit: ?usize, tmux: bool) !void { var scratch: std.ArrayList(u8) = .empty; defer scratch.deinit(gpa);
var disp: Display = .{ .tmux = tmux };
// Probe for protocol support: DECRQM for mode 5522, DECRQM for mode // 2004 (bracketed paste, the text-only fallback), then the cell size in // pixels (CSI 16 t) and text area size in cells (CSI 18 t) for image // rendering, followed by a universally-answered query that bounds the // wait: if its reply arrives without a positive DECRPM for either mode, // that mode is unavailable. // // Outside tmux the terminator is DA1 (CSI c). Inside tmux the mode-5522 // DECRQM and the terminator are passthrough-wrapped so the *outer* // terminal answers them (tmux would otherwise reply itself: mode not // recognized, and its own DA1). tmux forwards the outer terminal's // DECRPM reply to the active pane verbatim, but always swallows DA1 // responses, so the tmux terminator is DSR 5 (CSI 5 n -> CSI 0 n), // which it forwards. The size queries stay unwrapped — tmux answers // them with pane-local values — and so does the mode-2004 DECRQM, // because tmux implements bracketed paste per pane itself, making its // answer the authoritative one. if (tmux) { try out.writeAll("clipwatch: tmux detected — talking to the outer terminal via passthrough (needs `allow-passthrough on`; press q if this hangs)\n"); try writeSeq(out, true, "\x1b[?5522$p"); try out.writeAll("\x1b[?2004$p\x1b[16t\x1b[18t"); try writeSeq(out, true, "\x1b[5n"); } else { try out.writeAll("\x1b[?5522$p\x1b[?2004$p\x1b[16t\x1b[18t\x1b[c"); } try out.flush(); const terminator: u8 = if (tmux) 'n' else 'c'; var supported = false; var bracketed = false; handshake: while (true) { switch (try readEvent(gpa, in, &scratch)) { .csi => |body| { if (decrpmAvailable(body, "?5522;")) |ok| { supported = ok; } else if (decrpmAvailable(body, "?2004;")) |ok| { bracketed = ok; } else if (body[body.len - 1] == 't') { disp.noteSizeReport(body); } else if (body[body.len - 1] == terminator) { break :handshake; } }, .key => |k| if (isQuitKey(k)) return, else => {}, } } if (!supported) { if (!bracketed) { try out.writeAll("this terminal supports neither the kitty clipboard protocol (OSC 5522) nor bracketed paste\n"); try out.flush(); return; } return runBracketed(gpa, in, out, limit, &scratch); }
try writeSeq(out, tmux, "\x1b[?5522h"); // enable paste events try out.writeAll("clipwatch: waiting for pastes — paste into this window (e.g. ctrl+shift+v), press q to quit\n"); try out.flush();
const State = enum { idle, receiving }; var state: State = .idle; var pw: std.ArrayList(u8) = .empty; // one-time password, still base64 defer pw.deinit(gpa); var avail: std.ArrayList(u8) = .empty; // decoded space-separated MIME list defer avail.deinit(gpa); var data: std.ArrayList(u8) = .empty; // decoded clipboard data defer data.deinit(gpa); var req_is_image = false;
while (true) { const event = readEvent(gpa, in, &scratch) catch |err| switch (err) { error.EndOfStream => return, else => |e| return e, }; const packet = switch (event) { .key => |k| { if (isQuitKey(k)) return; continue; }, .osc => |body| parsePacket(body) orelse continue, else => continue, };
if (packet.pw_b64.len > 0) { pw.clearRetainingCapacity(); try pw.appendSlice(gpa, packet.pw_b64); }
switch (state) { .idle => { // Packets arriving while idle are a paste event: the MIME // type list for the "." pseudo-type. if (std.mem.eql(u8, packet.status, "OK")) { avail.clearRetainingCapacity(); } else if (std.mem.eql(u8, packet.status, "DATA")) { appendBase64(gpa, &avail, packet.payload_b64) catch continue; } else if (std.mem.eql(u8, packet.status, "DONE")) { const mime = chooseMime(avail.items) orelse { try out.writeAll("paste event, but the clipboard is empty\n"); try out.flush(); continue; }; req_is_image = std.mem.eql(u8, mime, "image/png"); try out.print("paste event: available types: {s}\n", .{std.mem.trim(u8, avail.items, " \t\r\n")}); try out.print("reading {s} …\n", .{mime}); try sendReadRequest(out, tmux, mime, pw.items); data.clearRetainingCapacity(); state = .receiving; } else { try out.print("clipboard error: {s}\n", .{packet.status}); try out.flush(); } }, .receiving => { if (std.mem.eql(u8, packet.status, "OK")) { // acknowledgement of our read request } else if (std.mem.eql(u8, packet.status, "DATA")) { appendBase64(gpa, &data, packet.payload_b64) catch continue; } else if (std.mem.eql(u8, packet.status, "DONE")) { if (data.items.len == 0) { try out.writeAll("(no data)\n"); } else if (req_is_image) { try displayImage(gpa, out, data.items, &disp); } else { try hexdump(out, data.items, limit); } try out.flush(); state = .idle; } else { try out.print("clipboard error: {s}\n", .{packet.status}); try out.flush(); state = .idle; } }, } }}
/// Parse a DECRPM report body ("?<mode>;<value>$y") for the given/// "?<mode>;" prefix. Returns null if the body is not a report for that/// mode, otherwise whether the mode is available (set, reset, or/// permanently set — but not unrecognized or permanently reset).fn decrpmAvailable(body: []const u8, comptime prefix: []const u8) ?bool { if (!std.mem.startsWith(u8, body, prefix) or !std.mem.endsWith(u8, body, "$y")) return null; const value = body[prefix.len .. body.len - "$y".len]; return value.len == 1 and switch (value[0]) { '1', '2', '3' => true, else => false, };}
/// Fallback mode when the kitty clipboard protocol is unavailable: enable/// bracketed paste (CSI ? 2004 h) and hexdump whatever text arrives between/// the CSI 200~ / CSI 201~ paste markers. Text only — no MIME types, no/// images. Bracketed paste needs no tmux passthrough: tmux implements the/// mode per pane and re-brackets the pastes it delivers.fn runBracketed(gpa: Allocator, in: *Io.Reader, out: *Io.Writer, limit: ?usize, scratch: *std.ArrayList(u8)) !void { try out.writeAll("\x1b[?2004h"); defer { out.writeAll("\x1b[?2004l") catch {}; out.flush() catch {}; } try out.writeAll("clipwatch: kitty clipboard protocol not available — falling back to bracketed paste (text only, no images)\n"); try out.writeAll("waiting for pastes — paste into this window (e.g. ctrl+shift+v), press q to quit\n"); try out.flush();
var data: std.ArrayList(u8) = .empty; defer data.deinit(gpa);
while (true) { const event = readEvent(gpa, in, scratch) catch |err| switch (err) { error.EndOfStream => return, else => |e| return e, }; switch (event) { .key => |k| if (isQuitKey(k)) return, .csi => |body| if (std.mem.eql(u8, body, "200~")) { data.clearRetainingCapacity(); collectPaste(gpa, in, &data) catch |err| switch (err) { error.EndOfStream => return, else => |e| return e, }; try hexdump(out, data.items, limit); try out.flush(); }, else => {}, } }}
/// Read the body of a bracketed paste into `data`: everything up to (but not/// including) the closing CSI 201~ marker.fn collectPaste(gpa: Allocator, in: *Io.Reader, data: *std.ArrayList(u8)) !void { const end_mark = "\x1b[201~"; while (true) { const b = try in.takeByte(); try data.append(gpa, b); if (std.mem.endsWith(u8, data.items, end_mark)) { data.items.len -= end_mark.len; return; } }}
/// Platform-specific raw-mode setup and teardown for the tty.const Terminal = if (builtin.os.tag == .windows) WindowsTerminal else PosixTerminal;
const PosixTerminal = struct { fd: std.posix.fd_t, orig: std.posix.termios,
fn init(stdin_handle: Io.File.Handle, stdout_handle: Io.File.Handle) !PosixTerminal { _ = stdout_handle; // output processing (OPOST/ONLCR) is left untouched const orig = try std.posix.tcgetattr(stdin_handle); var raw = orig; raw.lflag.ECHO = false; raw.lflag.ICANON = false; raw.lflag.ISIG = false; raw.lflag.IEXTEN = false; raw.iflag.IXON = false; raw.iflag.ICRNL = false; raw.iflag.BRKINT = false; try std.posix.tcsetattr(stdin_handle, .FLUSH, raw); return .{ .fd = stdin_handle, .orig = orig }; }
fn restore(self: *const PosixTerminal) void { std.posix.tcsetattr(self.fd, .FLUSH, self.orig) catch {}; }};
const WindowsTerminal = struct { const win32 = @import("win32").everything; const CONSOLE_MODE = win32.CONSOLE_MODE; const CP_UTF8: u32 = 65001;
/// The CONSOLE_MODE flags overlap between input and output handles, so we /// combine them at the raw bit level rather than by struct field. fn bits(mode: CONSOLE_MODE) u32 { return @bitCast(mode); }
in_handle: win32.HANDLE, out_handle: win32.HANDLE, orig_in_mode: CONSOLE_MODE, orig_out_mode: CONSOLE_MODE, orig_in_cp: u32, orig_out_cp: u32,
fn init(stdin_handle: Io.File.Handle, stdout_handle: Io.File.Handle) !WindowsTerminal { var in_mode: CONSOLE_MODE = undefined; var out_mode: CONSOLE_MODE = undefined; if (win32.GetConsoleMode(stdin_handle, &in_mode) == 0 or win32.GetConsoleMode(stdout_handle, &out_mode) == 0) return error.NotAConsole; const self: WindowsTerminal = .{ .in_handle = stdin_handle, .out_handle = stdout_handle, .orig_in_mode = in_mode, .orig_out_mode = out_mode, .orig_in_cp = win32.GetConsoleCP(), .orig_out_cp = win32.GetConsoleOutputCP(), }; // Raw input: no line buffering, no echo, no ctrl+c signal, and VT // escape sequences delivered as input bytes. VT processing on the // output side so our escape codes reach the terminal; LF keeps its // default auto-return, matching ONLCR on POSIX. const disable = bits(win32.ENABLE_PROCESSED_INPUT) | bits(win32.ENABLE_LINE_INPUT) | bits(win32.ENABLE_ECHO_INPUT); const raw_in: CONSOLE_MODE = @bitCast((bits(in_mode) & ~disable) | bits(win32.ENABLE_VIRTUAL_TERMINAL_INPUT)); const raw_out: CONSOLE_MODE = @bitCast(bits(out_mode) | bits(win32.ENABLE_PROCESSED_OUTPUT) | bits(win32.ENABLE_VIRTUAL_TERMINAL_PROCESSING)); if (win32.SetConsoleMode(stdin_handle, raw_in) == 0 or win32.SetConsoleMode(stdout_handle, raw_out) == 0) { self.restore(); return error.ConsoleSetupFailed; } _ = win32.SetConsoleCP(CP_UTF8); _ = win32.SetConsoleOutputCP(CP_UTF8); return self; }
fn restore(self: *const WindowsTerminal) void { _ = win32.SetConsoleMode(self.in_handle, self.orig_in_mode); _ = win32.SetConsoleMode(self.out_handle, self.orig_out_mode); _ = win32.SetConsoleCP(self.orig_in_cp); _ = win32.SetConsoleOutputCP(self.orig_out_cp); }};
fn isQuitKey(k: u8) bool { return k == 'q' or k == 0x03 or k == 0x04; // q, ctrl+c, ctrl+d}
const Event = union(enum) { key: u8, csi: []const u8, // parameter/intermediate bytes plus final byte osc: []const u8, // everything between "ESC ]" and the terminator skipped,};
/// Read one input token: a plain byte, or a complete escape sequence./// Slices point into `buf` and are valid until the next call.fn readEvent(gpa: Allocator, in: *Io.Reader, buf: *std.ArrayList(u8)) !Event { const b = try in.takeByte(); if (b != 0x1b) return .{ .key = b }; const kind = in.takeByte() catch return .skipped; switch (kind) { '[' => { buf.clearRetainingCapacity(); while (true) { const x = try in.takeByte(); try buf.append(gpa, x); if (x >= 0x40 and x <= 0x7e) break; } return .{ .csi = buf.items }; }, ']' => { buf.clearRetainingCapacity(); while (true) { const x = try in.takeByte(); if (x == 0x07) break; // BEL terminator if (x == 0x1b) { if (try in.takeByte() == '\\') break; // ST terminator continue; } try buf.append(gpa, x); } return .{ .osc = buf.items }; }, '_', 'P', '^', 'X' => { // APC/DCS/PM/SOS: skip until ST var prev: u8 = 0; while (true) { const x = try in.takeByte(); if (prev == 0x1b and x == '\\') break; prev = x; } return .skipped; }, else => return .skipped, }}
const Packet = struct { status: []const u8, pw_b64: []const u8, payload_b64: []const u8,};
/// Parse an OSC body of the form "5522;key=value:key=value;base64-payload".fn parsePacket(osc: []const u8) ?Packet { const prefix = "5522;"; if (!std.mem.startsWith(u8, osc, prefix)) return null; const rest = osc[prefix.len..]; var packet: Packet = .{ .status = "", .pw_b64 = "", .payload_b64 = "" }; var meta = rest; if (std.mem.indexOfScalar(u8, rest, ';')) |i| { meta = rest[0..i]; packet.payload_b64 = rest[i + 1 ..]; } var it = std.mem.splitScalar(u8, meta, ':'); while (it.next()) |kv| { const eq = std.mem.indexOfScalar(u8, kv, '=') orelse continue; const key = kv[0..eq]; const value = kv[eq + 1 ..]; if (std.mem.eql(u8, key, "status")) { packet.status = value; } else if (std.mem.eql(u8, key, "pw")) { packet.pw_b64 = value; } } return packet;}
/// Base64-decode `src` and append the result to `list`.fn appendBase64(gpa: Allocator, list: *std.ArrayList(u8), src: []const u8) !void { const n = try b64_dec.calcSizeForSlice(src); try list.ensureUnusedCapacity(gpa, n); try b64_dec.decode(list.unusedCapacitySlice()[0..n], src); list.items.len += n;}
/// Pick the MIME type to read from a decoded space-separated list:/// image/png if offered, then text/plain, then whatever comes first.fn chooseMime(avail: []const u8) ?[]const u8 { var first: ?[]const u8 = null; var text: ?[]const u8 = null; var it = std.mem.tokenizeAny(u8, avail, " \t\r\n"); while (it.next()) |mime| { if (std.mem.eql(u8, mime, "image/png")) return mime; if (text == null and std.mem.eql(u8, mime, "text/plain")) text = mime; if (first == null) first = mime; } return text orelse first;}
fn sendReadRequest(out: *Io.Writer, tmux: bool, mime: []const u8, pw_b64: []const u8) !void { var mime_buf: [512]u8 = undefined; if (b64_enc.calcSize(mime.len) > mime_buf.len) return error.MimeTooLong; const mime_b64 = b64_enc.encode(&mime_buf, mime); var name_buf: [16]u8 = undefined; const name_b64 = b64_enc.encode(&name_buf, "Paste event"); // Wrapped in tmux passthrough by hand (ESCs doubled) because the pw can // be arbitrarily long; the body between the ESCs is ESC-free. if (tmux) try out.writeAll("\x1bPtmux;\x1b"); try out.writeAll("\x1b]5522;type=read"); if (pw_b64.len > 0) try out.print(":pw={s}:name={s}", .{ pw_b64, name_b64 }); try out.print(";{s}", .{mime_b64}); try out.writeAll(if (tmux) "\x1b\x1b\\\x1b\\" else "\x1b\\"); try out.flush();}
/// Terminal geometry and image-id state for rendering images.const Display = struct { // Fallbacks are used when the terminal doesn't answer the size queries. cell_width: u32 = 10, // pixels cell_height: u32 = 20, term_cols: u32 = 80, term_rows: u32 = 24, tmux: bool = false, last_id: u8 = 0,
const Cells = struct { cols: usize, rows: usize };
/// Image ids are kept in 1…255 so the placeholder cells can carry them /// in a single SGR-256 foreground color, which tmux relays unmangled /// (unlike 24-bit color, which it may quantize). Reused round-robin; /// retransmitting an id replaces the old image. fn nextId(self: *Display) u8 { self.last_id = if (self.last_id == 255) 1 else self.last_id + 1; return self.last_id; }
/// Size the placement grid: the image at its natural cell size, scaled /// down proportionally to fit the terminal width and most of its height. fn fit(self: *const Display, width: u32, height: u32) Cells { var cols: u64 = (width + self.cell_width - 1) / self.cell_width; var rows: u64 = (height + self.cell_height - 1) / self.cell_height; const max_cols: u64 = @max(1, self.term_cols); const max_rows: u64 = @max(1, self.term_rows -| 2); if (cols > max_cols) { rows = @max(1, (rows * max_cols + cols / 2) / cols); cols = max_cols; } if (rows > max_rows) { cols = @max(1, (cols * max_rows + rows / 2) / rows); rows = max_rows; } return .{ .cols = @intCast(@min(cols, row_column_diacritics.len)), .rows = @intCast(@min(rows, row_column_diacritics.len)), }; }
/// Record a CSI … t window size report: "6;height;width t" is the cell /// size in pixels (reply to CSI 16 t), "8;rows;cols t" the text area /// size in cells (reply to CSI 18 t). fn noteSizeReport(self: *Display, body: []const u8) void { var it = std.mem.splitScalar(u8, body[0 .. body.len - 1], ';'); const kind = it.next() orelse return; const a = std.fmt.parseInt(u32, it.next() orelse return, 10) catch return; const b = std.fmt.parseInt(u32, it.next() orelse return, 10) catch return; if (a == 0 or b == 0) return; if (std.mem.eql(u8, kind, "6")) { self.cell_height = a; self.cell_width = b; } else if (std.mem.eql(u8, kind, "8")) { self.term_rows = a; self.term_cols = b; } }};
const PngSize = struct { width: u32, height: u32 };
/// Extract the pixel dimensions from a PNG file's IHDR chunk.fn pngSize(png: []const u8) ?PngSize { if (png.len < 24) return null; if (!std.mem.eql(u8, png[0..8], "\x89PNG\r\n\x1a\n")) return null; if (!std.mem.eql(u8, png[12..16], "IHDR")) return null; const width = std.mem.readInt(u32, png[16..20], .big); const height = std.mem.readInt(u32, png[20..24], .big); if (width == 0 or height == 0) return null; return .{ .width = width, .height = height };}
/// Show PNG data using the kitty graphics protocol with Unicode placeholders/// (https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders):/// the image is transmitted as a virtual placement (a=T with U=1, f=100 PNG,/// q=2 suppress responses, chunked into pieces of at most 4096 encoded/// bytes), then displayed by printing a grid of U+10EEEE placeholder cells/// that carry the image id in their foreground color and their row/column in/// combining diacritics. Because the placement itself is plain text, this/// works inside tmux; the APC transmission is wrapped in tmux's passthrough/// sequence (which requires `allow-passthrough on`).fn displayImage(gpa: Allocator, out: *Io.Writer, png: []const u8, disp: *Display) !void { const size = pngSize(png) orelse { try out.print("image/png, {d} bytes: could not parse PNG header, not displaying\n", .{png.len}); return; };
const cells = disp.fit(size.width, size.height); const id = disp.nextId();
const encoded = try gpa.alloc(u8, b64_enc.calcSize(png.len)); defer gpa.free(encoded); _ = b64_enc.encode(encoded, png);
try out.print("image/png, {d} bytes, {d}x{d} px:\n", .{ png.len, size.width, size.height });
var rest: []const u8 = encoded; var first = true; while (true) { const chunk = rest[0..@min(rest.len, 4096)]; rest = rest[chunk.len..]; const more: u1 = @intFromBool(rest.len > 0); // Inside a tmux passthrough wrapper every ESC of the payload must be // doubled, hence the extra ESCs around the APC sequence below. if (disp.tmux) try out.writeAll("\x1bPtmux;\x1b"); if (first) { try out.print("\x1b_Ga=T,U=1,q=2,f=100,i={d},c={d},r={d},m={d};", .{ id, cells.cols, cells.rows, more }); } else { try out.print("\x1b_Gm={d};", .{more}); } try out.writeAll(chunk); try out.writeAll(if (disp.tmux) "\x1b\x1b\\\x1b\\" else "\x1b\\"); if (rest.len == 0) break; first = false; }
for (0..cells.rows) |row| { try out.print("\x1b[38;5;{d}m", .{id}); for (0..cells.cols) |col| { try writeCodepoint(out, placeholder); try writeCodepoint(out, row_column_diacritics[row]); try writeCodepoint(out, row_column_diacritics[col]); } try out.writeAll("\x1b[39m\n"); }}
fn writeCodepoint(out: *Io.Writer, cp: u21) !void { var buf: [4]u8 = undefined; const n = std.unicode.utf8Encode(cp, &buf) catch unreachable; try out.writeAll(buf[0..n]);}
/// The image placeholder character.const placeholder: u21 = 0x10EEEE;
/// Combining characters encoding row/column numbers in placeholder cells,/// from kitty's gen/rowcolumn-diacritics.txt.const row_column_diacritics = [_]u21{ 0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597, 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615, 0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2, 0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A, 0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817, 0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822, 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951, 0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD, 0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1, 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5, 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1, 0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA, 0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23, 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244,};
fn hexdump(out: *Io.Writer, data: []const u8, limit: ?usize) !void { const n = @min(data.len, limit orelse data.len); try out.print("text, {d} bytes:\n", .{data.len}); var off: usize = 0; while (off < n) : (off += 16) { const line = data[off..@min(off + 16, n)]; try out.print("{x:0>8} ", .{off}); for (0..16) |i| { if (i < line.len) { try out.print("{x:0>2} ", .{line[i]}); } else { try out.writeAll(" "); } if (i == 7) try out.writeAll(" "); } try out.writeAll(" |"); for (line) |ch| try out.writeByte(if (std.ascii.isPrint(ch)) ch else '.'); try out.writeAll("|\n"); } if (data.len > n) try out.print("… {d} more bytes not shown\n", .{data.len - n});}
test "parsePacket" { const p = parsePacket("5522;type=read:status=DATA:mime=Lg==:pw=YWJj;dGV4dC9wbGFpbg==").?; try std.testing.expectEqualStrings("DATA", p.status); try std.testing.expectEqualStrings("YWJj", p.pw_b64); try std.testing.expectEqualStrings("dGV4dC9wbGFpbg==", p.payload_b64); try std.testing.expect(parsePacket("52;type=read") == null);}
test "chooseMime" { try std.testing.expectEqualStrings("image/png", chooseMime("text/plain image/png").?); try std.testing.expectEqualStrings("image/png", chooseMime("image/png\n").?); try std.testing.expectEqualStrings("text/plain", chooseMime("text/html text/plain").?); try std.testing.expectEqualStrings("text/html", chooseMime("text/html").?); try std.testing.expect(chooseMime(" ") == null);}
test "parseLimit" { try std.testing.expectEqual(@as(?usize, 320), try parseLimit("20")); try std.testing.expectEqual(@as(?usize, 320), try parseLimit("20lines")); try std.testing.expectEqual(@as(?usize, 320), try parseLimit("20l")); try std.testing.expectEqual(@as(?usize, 4096), try parseLimit("4096bytes")); try std.testing.expectEqual(@as(?usize, 4096), try parseLimit("4096b")); try std.testing.expectEqual(@as(?usize, null), try parseLimit("unlimited")); try std.testing.expectError(error.InvalidLimit, parseLimit("")); try std.testing.expectError(error.InvalidLimit, parseLimit("lines")); try std.testing.expectError(error.InvalidLimit, parseLimit("20miles"));}
test "decrpmAvailable" { try std.testing.expectEqual(@as(?bool, true), decrpmAvailable("?5522;1$y", "?5522;")); try std.testing.expectEqual(@as(?bool, true), decrpmAvailable("?2004;2$y", "?2004;")); try std.testing.expectEqual(@as(?bool, false), decrpmAvailable("?2004;0$y", "?2004;")); try std.testing.expectEqual(@as(?bool, false), decrpmAvailable("?2004;4$y", "?2004;")); try std.testing.expectEqual(@as(?bool, false), decrpmAvailable("?2004;12$y", "?2004;")); try std.testing.expectEqual(@as(?bool, null), decrpmAvailable("?2004;1$y", "?5522;")); try std.testing.expectEqual(@as(?bool, null), decrpmAvailable("?5522;1c", "?5522;"));}
test "collectPaste" { const gpa = std.testing.allocator; var data: std.ArrayList(u8) = .empty; defer data.deinit(gpa); var reader: Io.Reader = .fixed("hello \x1b world\x1b[201~q"); try collectPaste(gpa, &reader, &data); try std.testing.expectEqualStrings("hello \x1b world", data.items); try std.testing.expectEqual(@as(u8, 'q'), try reader.takeByte());
data.clearRetainingCapacity(); var empty_reader: Io.Reader = .fixed("\x1b[201~"); try collectPaste(gpa, &empty_reader, &data); try std.testing.expectEqualStrings("", data.items);
var eof_reader: Io.Reader = .fixed("no terminator"); try std.testing.expectError(error.EndOfStream, collectPaste(gpa, &eof_reader, &data));}
test "pngSize" { const png = "\x89PNG\r\n\x1a\n" ++ "\x00\x00\x00\x0d" ++ "IHDR" ++ "\x00\x00\x02\x80" ++ "\x00\x00\x01\x90"; // 640x400 const size = pngSize(png).?; try std.testing.expectEqual(@as(u32, 640), size.width); try std.testing.expectEqual(@as(u32, 400), size.height); try std.testing.expect(pngSize("not a png, but long enough to check") == null); try std.testing.expect(pngSize("\x89PNG\r\n\x1a\n") == null);}
test "Display.fit" { var disp: Display = .{}; // cell 10x20 px, terminal 80x24 cells // Natural size when it fits: 100x100 px -> 10x5 cells. try std.testing.expectEqual(Display.Cells{ .cols = 10, .rows = 5 }, disp.fit(100, 100)); // Too wide: scaled down proportionally to the 80-column terminal. try std.testing.expectEqual(Display.Cells{ .cols = 80, .rows = 10 }, disp.fit(1600, 400)); // Too tall: scaled down to term_rows - 2 = 22 rows. try std.testing.expectEqual(Display.Cells{ .cols = 11, .rows = 22 }, disp.fit(400, 1600)); // Tiny images still get at least one cell. try std.testing.expectEqual(Display.Cells{ .cols = 1, .rows = 1 }, disp.fit(1, 1));}
test "Display.noteSizeReport" { var disp: Display = .{}; disp.noteSizeReport("6;22;11t"); disp.noteSizeReport("8;50;120t"); try std.testing.expectEqual(@as(u32, 11), disp.cell_width); try std.testing.expectEqual(@as(u32, 22), disp.cell_height); try std.testing.expectEqual(@as(u32, 120), disp.term_cols); try std.testing.expectEqual(@as(u32, 50), disp.term_rows); // Zero or malformed reports leave the values untouched. disp.noteSizeReport("6;0;0t"); disp.noteSizeReport("6t"); try std.testing.expectEqual(@as(u32, 11), disp.cell_width);}
test "appendBase64" { const gpa = std.testing.allocator; var list: std.ArrayList(u8) = .empty; defer list.deinit(gpa); try appendBase64(gpa, &list, "aGVsbG8="); try appendBase64(gpa, &list, "IHdvcmxk"); try std.testing.expectEqualStrings("hello world", list.items);}