From d48826c0b1759f4e2c443f7c6abbd3b48b21c96e Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 20 May 2024 22:01:02 +0200 Subject: [PATCH] vaxis: add osc52 copy/paste support --- examples/cli.zig | 2 +- examples/image.zig | 2 +- examples/main.zig | 2 +- examples/nvim.zig | 2 +- examples/pathological.zig | 2 +- examples/shell.zig | 2 +- examples/table.zig | 2 +- examples/text_input.zig | 4 +- src/Loop.zig | 1 + src/Parser.zig | 149 ++++++++++++++++++++++++++++++++------ src/Tty.zig | 11 ++- src/Vaxis.zig | 30 ++++++++ src/ctlseqs.zig | 2 + src/event.zig | 5 +- 14 files changed, 180 insertions(+), 36 deletions(-) diff --git a/examples/cli.zig b/examples/cli.zig index c149acb..5ccddfa 100644 --- a/examples/cli.zig +++ b/examples/cli.zig @@ -19,7 +19,7 @@ pub fn main() !void { var loop: vaxis.Loop(Event) = .{ .vaxis = &vx }; - try loop.run(); + try loop.run(alloc); defer loop.stop(); try vx.queryTerminal(); diff --git a/examples/image.zig b/examples/image.zig index f8008e0..60ca807 100644 --- a/examples/image.zig +++ b/examples/image.zig @@ -23,7 +23,7 @@ pub fn main() !void { var loop: vaxis.Loop(Event) = .{ .vaxis = &vx }; - try loop.run(); + try loop.run(alloc); defer loop.stop(); try vx.enterAltScreen(); diff --git a/examples/main.zig b/examples/main.zig index b7537c5..92428e2 100644 --- a/examples/main.zig +++ b/examples/main.zig @@ -22,7 +22,7 @@ pub fn main() !void { // Start the read loop. This puts the terminal in raw mode and begins // reading user input - try loop.run(); + try loop.run(alloc); defer loop.stop(); // Optionally enter the alternate screen diff --git a/examples/nvim.zig b/examples/nvim.zig index 156290d..3ef0fbb 100644 --- a/examples/nvim.zig +++ b/examples/nvim.zig @@ -29,7 +29,7 @@ pub fn main() !void { var loop: vaxis.Loop(Event) = .{ .vaxis = &vx }; - try loop.run(); + try loop.run(alloc); defer loop.stop(); // Optionally enter the alternate screen diff --git a/examples/pathological.zig b/examples/pathological.zig index 1af65f1..5e1172a 100644 --- a/examples/pathological.zig +++ b/examples/pathological.zig @@ -16,7 +16,7 @@ pub fn main() !void { var loop: vaxis.Loop(Event) = .{ .vaxis = &vx }; - try loop.run(); + try loop.run(alloc); defer loop.stop(); try vx.enterAltScreen(); try vx.queryTerminal(); diff --git a/examples/shell.zig b/examples/shell.zig index fb0c5df..f6f51c9 100644 --- a/examples/shell.zig +++ b/examples/shell.zig @@ -19,7 +19,7 @@ pub fn main() !void { var loop: vaxis.Loop(Event) = .{ .vaxis = &vx }; - try loop.run(); + try loop.run(alloc); defer loop.stop(); try vx.queryTerminal(); diff --git a/examples/table.zig b/examples/table.zig index c004e1d..30a806d 100644 --- a/examples/table.zig +++ b/examples/table.zig @@ -32,7 +32,7 @@ pub fn main() !void { winsize: vaxis.Winsize, }) = .{ .vaxis = &vx }; - try loop.run(); + try loop.run(alloc); defer loop.stop(); try vx.enterAltScreen(); try vx.queryTerminal(); diff --git a/examples/text_input.zig b/examples/text_input.zig index 36fcdef..463c753 100644 --- a/examples/text_input.zig +++ b/examples/text_input.zig @@ -42,7 +42,7 @@ pub fn main() !void { // Start the read loop. This puts the terminal in raw mode and begins // reading user input - try loop.run(); + try loop.run(alloc); defer loop.stop(); // Optionally enter the alternate screen @@ -85,7 +85,7 @@ pub fn main() !void { loop.stop(); var child = std.process.Child.init(&.{"nvim"}, alloc); _ = try child.spawnAndWait(); - try loop.run(); + try loop.run(alloc); try vx.enterAltScreen(); vx.queueRefresh(); } else if (key.matches(vaxis.Key.enter, .{})) { diff --git a/src/Loop.zig b/src/Loop.zig index 5869abf..a676003 100644 --- a/src/Loop.zig +++ b/src/Loop.zig @@ -25,6 +25,7 @@ pub fn Loop(comptime T: type) type { T, self, &self.vaxis.unicode.grapheme_data, + self.vaxis.opts.system_clipboard_allocator, }); } diff --git a/src/Parser.zig b/src/Parser.zig index 10185a2..d18adb8 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -61,7 +61,7 @@ buf: [128]u8 = undefined, grapheme_data: *const grapheme.GraphemeData, -pub fn parse(self: *Parser, input: []const u8) !Result { +pub fn parse(self: *Parser, input: []const u8, paste_allocator: ?std.mem.Allocator) !Result { const n = input.len; var seq: Sequence = .{}; @@ -555,6 +555,88 @@ pub fn parse(self: *Parser, input: []const u8) !Result { else => {}, } }, + .osc => { + switch (b) { + 0x07, 0x1B => { + state = .ground; + if (b == 0x1b) { + // advance one more for the backslash + i += 1; + } + log.warn("unhandled osc: OSC {s}", .{input[start + 1 .. i + 1]}); + return .{ + .event = null, + .n = i + 1, + }; + }, + 0x30...0x39 => { + seq.param_buf[seq.param_buf_idx] = b; + seq.param_buf_idx += 1; + }, + ';' => { + if (seq.param_buf_idx == 0) { + seq.param_idx += 1; + } + if (seq.param_idx == 0) { + const p = try std.fmt.parseUnsigned(u16, seq.param_buf[0..seq.param_buf_idx], 10); + seq.param_buf_idx = 0; + seq.param_idx += 1; + switch (p) { + 52 => { + var payload: ?std.ArrayList(u8) = if (paste_allocator) |allocator| + std.ArrayList(u8).init(allocator) + else + null; + defer if (payload) |_| payload.?.deinit(); + + while (i < n) : (i += 1) { + const b_ = input[i]; + switch (b_) { + ';' => { + if (seq.param_buf_idx == 0) { + // empty param. default it to 0 and set the + // empty state + seq.params[seq.param_idx] = 0; + seq.empty_state.set(seq.param_idx); + seq.param_idx += 1; + } else { + seq.params[seq.param_idx] = @intCast(b_); + seq.param_buf_idx = 0; + seq.param_idx += 1; + } + }, + 0x07, 0x1B => { + state = .ground; + if (b == 0x1b) { + // advance one more for the backslash + i += 1; + } + if (payload) |_| { + log.debug("decoding paste: {s}", .{payload.?.items}); + const decoder = std.base64.standard.Decoder; + const text = try paste_allocator.?.alloc(u8, try decoder.calcSizeForSlice(payload.?.items)); + try decoder.decode(text, payload.?.items); + log.debug("decoded paste: {s}", .{text}); + return .{ + .event = .{ .paste = text }, + .n = i + 2, + }; + } else return .{ + .event = null, + .n = i + 2, + }; + }, + else => if (seq.param_idx == 3 and payload != null) try payload.?.append(b_), + } + } + }, + else => {}, + } + } + }, + else => {}, + } + }, else => {}, } } @@ -572,7 +654,7 @@ test "parse: single xterm keypress" { defer grapheme_data.deinit(); const input = "a"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', .text = "a", @@ -589,7 +671,7 @@ test "parse: single xterm keypress backspace" { defer grapheme_data.deinit(); const input = "\x08"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.backspace, }; @@ -605,7 +687,7 @@ test "parse: single xterm keypress with more buffer" { defer grapheme_data.deinit(); const input = "ab"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', .text = "a", @@ -623,7 +705,7 @@ test "parse: xterm escape keypress" { defer grapheme_data.deinit(); const input = "\x1b"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.escape }; const expected_event: Event = .{ .key_press = expected_key }; @@ -637,7 +719,7 @@ test "parse: xterm ctrl+a" { defer grapheme_data.deinit(); const input = "\x01"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', .mods = .{ .ctrl = true } }; const expected_event: Event = .{ .key_press = expected_key }; @@ -651,7 +733,7 @@ test "parse: xterm alt+a" { defer grapheme_data.deinit(); const input = "\x1ba"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', .mods = .{ .alt = true } }; const expected_event: Event = .{ .key_press = expected_key }; @@ -665,7 +747,7 @@ test "parse: xterm invalid ss3" { defer grapheme_data.deinit(); const input = "\x1bOZ"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); try testing.expectEqual(3, result.n); try testing.expectEqual(null, result.event); @@ -679,7 +761,7 @@ test "parse: xterm key up" { // normal version const input = "\x1bOA"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.up }; const expected_event: Event = .{ .key_press = expected_key }; @@ -691,7 +773,7 @@ test "parse: xterm key up" { // application keys version const input = "\x1b[2~"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.insert }; const expected_event: Event = .{ .key_press = expected_key }; @@ -706,7 +788,7 @@ test "parse: xterm shift+up" { defer grapheme_data.deinit(); const input = "\x1b[1;2A"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.up, .mods = .{ .shift = true } }; const expected_event: Event = .{ .key_press = expected_key }; @@ -720,7 +802,7 @@ test "parse: xterm insert" { defer grapheme_data.deinit(); const input = "\x1b[1;2A"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.up, .mods = .{ .shift = true } }; const expected_event: Event = .{ .key_press = expected_key }; @@ -734,7 +816,7 @@ test "parse: paste_start" { defer grapheme_data.deinit(); const input = "\x1b[200~"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_event: Event = .paste_start; try testing.expectEqual(6, result.n); @@ -747,20 +829,39 @@ test "parse: paste_end" { defer grapheme_data.deinit(); const input = "\x1b[201~"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_event: Event = .paste_end; try testing.expectEqual(6, result.n); try testing.expectEqual(expected_event, result.event); } +test "parse: osc52 paste" { + const alloc = testing.allocator_instance.allocator(); + const grapheme_data = try grapheme.GraphemeData.init(alloc); + defer grapheme_data.deinit(); + const input = "\x1b]52;c;b3NjNTIgcGFzdGU=\x1b\\"; + const expected_text = "osc52 paste"; + var parser: Parser = .{ .grapheme_data = &grapheme_data }; + const result = try parser.parse(input, alloc); + + try testing.expectEqual(25, result.n); + switch (result.event.?) { + .paste => |text| { + defer alloc.free(text); + try testing.expectEqualStrings(expected_text, text); + }, + else => try testing.expect(false), + } +} + test "parse: focus_in" { const alloc = testing.allocator_instance.allocator(); const grapheme_data = try grapheme.GraphemeData.init(alloc); defer grapheme_data.deinit(); const input = "\x1b[I"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_event: Event = .focus_in; try testing.expectEqual(3, result.n); @@ -773,7 +874,7 @@ test "parse: focus_out" { defer grapheme_data.deinit(); const input = "\x1b[O"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_event: Event = .focus_out; try testing.expectEqual(3, result.n); @@ -786,7 +887,7 @@ test "parse: kitty: shift+a without text reporting" { defer grapheme_data.deinit(); const input = "\x1b[97:65;2u"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', .shifted_codepoint = 'A', @@ -804,7 +905,7 @@ test "parse: kitty: alt+shift+a without text reporting" { defer grapheme_data.deinit(); const input = "\x1b[97:65;4u"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', .shifted_codepoint = 'A', @@ -822,7 +923,7 @@ test "parse: kitty: a without text reporting" { defer grapheme_data.deinit(); const input = "\x1b[97u"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', }; @@ -838,7 +939,7 @@ test "parse: kitty: release event" { defer grapheme_data.deinit(); const input = "\x1b[97;1:3u"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', }; @@ -854,7 +955,7 @@ test "parse: single codepoint" { defer grapheme_data.deinit(); const input = "🙂"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 0x1F642, .text = input, @@ -871,7 +972,7 @@ test "parse: single codepoint with more in buffer" { defer grapheme_data.deinit(); const input = "🙂a"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 0x1F642, .text = "🙂", @@ -888,7 +989,7 @@ test "parse: multiple codepoint grapheme" { defer grapheme_data.deinit(); const input = "👩‍🚀"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.multicodepoint, .text = input, @@ -905,7 +1006,7 @@ test "parse: multiple codepoint grapheme with more after" { defer grapheme_data.deinit(); const input = "👩‍🚀abc"; var parser: Parser = .{ .grapheme_data = &grapheme_data }; - const result = try parser.parse(input); + const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.multicodepoint, .text = "👩‍🚀", diff --git a/src/Tty.zig b/src/Tty.zig index a40ac23..b4375cb 100644 --- a/src/Tty.zig +++ b/src/Tty.zig @@ -89,6 +89,7 @@ pub fn run( comptime Event: type, loop: *Loop(Event), grapheme_data: *const grapheme.GraphemeData, + paste_allocator: ?std.mem.Allocator, ) !void { // get our initial winsize const winsize = try getWinsize(self.fd); @@ -146,7 +147,7 @@ pub fn run( const n = try posix.read(self.fd, &buf); var start: usize = 0; while (start < n) { - const result = try parser.parse(buf[start..n]); + const result = try parser.parse(buf[start..n], paste_allocator); start += result.n; // TODO: if we get 0 byte read, copy the remaining bytes to the // beginning of the buffer and read mmore? this should only happen @@ -201,6 +202,14 @@ pub fn run( loop.postEvent(.paste_end); } }, + .paste => |text| { + if (@hasField(Event, "paste")) { + loop.postEvent(.{ .paste = text }); + } else { + if (paste_allocator) |_| + paste_allocator.?.free(text); + } + }, .cap_kitty_keyboard => { log.info("kitty keyboard capability detected", .{}); loop.vaxis.caps.kitty_keyboard = true; diff --git a/src/Vaxis.zig b/src/Vaxis.zig index 6e2cc87..e30b231 100644 --- a/src/Vaxis.zig +++ b/src/Vaxis.zig @@ -35,6 +35,10 @@ pub const Capabilities = struct { pub const Options = struct { kitty_keyboard_flags: KittyFlags = .{}, + /// When supplied, this allocator will be used for system clipboard + /// requests. If not supplied, it won't be possible to request the system + /// clipboard + system_clipboard_allocator: ?std.mem.Allocator = null, }; tty: ?Tty, @@ -793,3 +797,29 @@ pub fn freeImage(self: Vaxis, id: u32) void { log.err("couldn't flush writer: {}", .{err}); }; } + +pub fn copyToSystemClipboard(self: Vaxis, text: []const u8, encode_allocator: std.mem.Allocator) !void { + var tty = self.tty orelse return; + const encoder = std.base64.standard.Encoder; + const size = encoder.calcSize(text.len); + const buf = try encode_allocator.alloc(u8, size); + const b64 = encoder.encode(buf, text); + defer encode_allocator.free(buf); + try std.fmt.format( + tty.buffered_writer.writer(), + ctlseqs.osc52_clipboard_copy, + .{b64}, + ); + try tty.flush(); +} + +pub fn requestSystemClipboard(self: Vaxis) !void { + if (self.opts.system_clipboard_allocator == null) return error.NoClipboardAllocator; + var tty = self.tty orelse return; + try std.fmt.format( + tty.buffered_writer.writer(), + ctlseqs.osc52_clipboard_request, + .{}, + ); + try tty.flush(); +} diff --git a/src/ctlseqs.zig b/src/ctlseqs.zig index 04b9a61..0754e35 100644 --- a/src/ctlseqs.zig +++ b/src/ctlseqs.zig @@ -105,6 +105,8 @@ pub const osc8_clear = "\x1b]8;;\x1b\\"; pub const osc9_notify = "\x1b]9;{s}\x1b\\"; pub const osc777_notify = "\x1b]777;notify;{s};{s}\x1b\\"; pub const osc22_mouse_shape = "\x1b]22;{s}\x1b\\"; +pub const osc52_clipboard_copy = "\x1b]52;c;{s}\x1b\\"; +pub const osc52_clipboard_request = "\x1b]52;c;?\x1b\\"; // Kitty graphics pub const kitty_graphics_clear = "\x1b_Ga=d\x1b\\"; diff --git a/src/event.zig b/src/event.zig index 26e9f71..b02e55e 100644 --- a/src/event.zig +++ b/src/event.zig @@ -8,8 +8,9 @@ pub const Event = union(enum) { mouse: Mouse, focus_in, focus_out, - paste_start, - paste_end, + paste_start, // bracketed paste start + paste_end, // bracketed paste end + paste: []const u8, // osc 52 paste, caller must free // these are delivered as discovered terminal capabilities cap_kitty_keyboard,