From 3ad977f01d97e84b576e6965c5c9e4f75c10cb35 Mon Sep 17 00:00:00 2001 From: Gregory Anders <8965202+gpanders@users.noreply.github.com> Date: Tue, 11 Jun 2024 13:18:06 -0500 Subject: [PATCH] feat(terminal): add support for copying with OSC 52 in embedded terminal (#29117) When libvterm receives the OSC 52 escape sequence it ignores it because Nvim does not set any selection callbacks. Install selection callbacks that forward to the clipboard provider, so that setting the clipboard with OSC 52 in the embedded terminal writes to the system clipboard using the configured clipboard provider. --- runtime/doc/news.txt | 3 +- runtime/doc/nvim_terminal_emulator.txt | 17 ++++- src/nvim/base64.c | 1 + src/nvim/terminal.c | 69 ++++++++++++++++++++- test/functional/terminal/clipboard_spec.lua | 65 +++++++++++++++++++ 5 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 test/functional/terminal/clipboard_spec.lua diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 2ff6b0302c..455b38b5fa 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -138,7 +138,8 @@ STARTUP TERMINAL -• TODO +• The |terminal| now understands the OSC 52 escape sequence to write to the + system clipboard (copy). Querying with OSC 52 (paste) is not supported. TREESITTER diff --git a/runtime/doc/nvim_terminal_emulator.txt b/runtime/doc/nvim_terminal_emulator.txt index a6ebc7e958..81bcd89146 100644 --- a/runtime/doc/nvim_terminal_emulator.txt +++ b/runtime/doc/nvim_terminal_emulator.txt @@ -164,7 +164,22 @@ directory indicated in the request. >lua To try it out, select the above code and source it with `:'<,'>lua`, then run this command in a :terminal buffer: > - printf "\033]7;file://./foo/bar\033\\" + printf "\033]7;file://./foo/bar\033\\" + +OSC 52: write to system clipboard *terminal-osc52* + +Applications in the :terminal buffer can write to the system clipboard by +emitting an OSC 52 sequence. Example: > + + printf '\033]52;;%s\033\\' "$(echo -n 'Hello world' | base64)" + +Nvim uses the configured |clipboard| provider to write to the system +clipboard. Reading from the system clipboard with OSC 52 is not supported, as +this would allow any arbitrary program in the :terminal to read the user's +clipboard. + +OSC 52 sequences sent from the :terminal buffer do not emit a |TermRequest| +event. The event is handled directly by Nvim and is not forwarded to plugins. ============================================================================== Status Variables *terminal-status* diff --git a/src/nvim/base64.c b/src/nvim/base64.c index 39e4ec4872..99d3c5a33e 100644 --- a/src/nvim/base64.c +++ b/src/nvim/base64.c @@ -142,6 +142,7 @@ char *base64_encode(const char *src, size_t src_len) /// @param [out] out_lenp Returns the length of the decoded string /// @return Decoded string char *base64_decode(const char *src, size_t src_len, size_t *out_lenp) + FUNC_ATTR_NONNULL_ALL { assert(src != NULL); assert(out_lenp != NULL); diff --git a/src/nvim/terminal.c b/src/nvim/terminal.c index 027ff79696..818f8abbb5 100644 --- a/src/nvim/terminal.c +++ b/src/nvim/terminal.c @@ -112,6 +112,9 @@ typedef struct { // libvterm. Improves performance when receiving large bursts of data. #define REFRESH_DELAY 10 +#define TEXTBUF_SIZE 0x1fff +#define SELECTIONBUF_SIZE 0x0400 + static TimeWatcher refresh_timer; static bool refresh_pending = false; @@ -127,7 +130,7 @@ struct terminal { // buffer used to: // - convert VTermScreen cell arrays into utf8 strings // - receive data from libvterm as a result of key presses. - char textbuf[0x1fff]; + char textbuf[TEXTBUF_SIZE]; ScrollbackLine **sb_buffer; // Scrollback storage. size_t sb_current; // Lines stored in sb_buffer. @@ -166,6 +169,9 @@ struct terminal { // When there is a pending TermRequest autocommand, block and store input. StringBuilder *pending_send; + char *selection_buffer; /// libvterm selection buffer + StringBuilder selection; /// Growable array containing full selection data + size_t refcount; // reference count }; @@ -179,6 +185,12 @@ static VTermScreenCallbacks vterm_screen_callbacks = { .sb_popline = term_sb_pop, }; +static VTermSelectionCallbacks vterm_selection_callbacks = { + .set = term_selection_set, + // For security reasons we don't support querying the system clipboard from the embedded terminal + .query = NULL, +}; + static Set(ptr_t) invalidated_terminals = SET_INIT; static void emit_termrequest(void **argv) @@ -315,6 +327,11 @@ void terminal_open(Terminal **termpp, buf_T *buf, TerminalOptions opts) vterm_screen_set_damage_merge(term->vts, VTERM_DAMAGE_SCROLL); vterm_screen_reset(term->vts, 1); vterm_output_set_callback(term->vt, term_output_callback, term); + + term->selection_buffer = xcalloc(SELECTIONBUF_SIZE, 1); + vterm_state_set_selection_callbacks(state, &vterm_selection_callbacks, term, + term->selection_buffer, SELECTIONBUF_SIZE); + // force a initial refresh of the screen to ensure the buffer will always // have as many lines as screen rows when refresh_scrollback is called term->invalid_start = 0; @@ -769,6 +786,8 @@ void terminal_destroy(Terminal **termpp) } xfree(term->sb_buffer); xfree(term->title); + xfree(term->selection_buffer); + kv_destroy(term->selection); vterm_free(term->vt); xfree(term); *termpp = NULL; // coverity[dead-store] @@ -1198,6 +1217,54 @@ static int term_sb_pop(int cols, VTermScreenCell *cells, void *data) return 1; } +static void term_clipboard_set(void **argv) +{ + VTermSelectionMask mask = (VTermSelectionMask)(long)argv[0]; + char *data = argv[1]; + + char regname; + switch (mask) { + case VTERM_SELECTION_CLIPBOARD: + regname = '+'; + break; + case VTERM_SELECTION_PRIMARY: + regname = '*'; + break; + default: + regname = '+'; + break; + } + + list_T *lines = tv_list_alloc(1); + tv_list_append_allocated_string(lines, data); + + list_T *args = tv_list_alloc(3); + tv_list_append_list(args, lines); + + const char regtype = 'v'; + tv_list_append_string(args, ®type, 1); + + tv_list_append_string(args, ®name, 1); + eval_call_provider("clipboard", "set", args, true); +} + +static int term_selection_set(VTermSelectionMask mask, VTermStringFragment frag, void *user) +{ + Terminal *term = user; + if (frag.initial) { + kv_size(term->selection) = 0; + } + + kv_concat_len(term->selection, frag.str, frag.len); + + if (frag.final) { + char *data = xmemdupz(term->selection.items, kv_size(term->selection)); + multiqueue_put(main_loop.events, term_clipboard_set, (void *)mask, data); + } + + return 1; +} + // }}} // input handling {{{ diff --git a/test/functional/terminal/clipboard_spec.lua b/test/functional/terminal/clipboard_spec.lua new file mode 100644 index 0000000000..4a1a0e29fd --- /dev/null +++ b/test/functional/terminal/clipboard_spec.lua @@ -0,0 +1,65 @@ +local t = require('test.testutil') +local n = require('test.functional.testnvim')() + +local eq = t.eq +local retry = t.retry + +local clear = n.clear +local fn = n.fn +local testprg = n.testprg +local exec_lua = n.exec_lua +local eval = n.eval + +describe(':terminal', function() + before_each(function() + clear() + + exec_lua([[ + local function clipboard(reg, type) + if type == 'copy' then + return function(lines) + local data = table.concat(lines, '\n') + vim.g.clipboard_data = data + end + end + + if type == 'paste' then + return function() + error() + end + end + + error('invalid type: ' .. type) + end + + vim.g.clipboard = { + name = 'Test', + copy = { + ['+'] = clipboard('+', 'copy'), + ['*'] = clipboard('*', 'copy'), + }, + paste = { + ['+'] = clipboard('+', 'paste'), + ['*'] = clipboard('*', 'paste'), + }, + } + ]]) + end) + + it('can write to the system clipboard', function() + eq('Test', eval('g:clipboard.name')) + + local text = 'Hello, world! This is some\nexample text\nthat spans multiple\nlines' + local encoded = exec_lua('return vim.base64.encode(...)', text) + + local function osc52(arg) + return string.format('\027]52;;%s\027\\', arg) + end + + fn.termopen({ testprg('shell-test'), '-t', osc52(encoded) }) + + retry(nil, 1000, function() + eq(text, exec_lua([[ return vim.g.clipboard_data ]])) + end) + end) +end)