fix(multicursor): clipboard=unnamed, explicit "+yy #41804

Problem:
- With `clipboard=unnamed[plus]`, per-cursor yanks are joined into '"'
  on exit, but not the clipboard.
- Multicursor `"+yy` calls the clipboard provider per-cursor, behaving
  as "last wins".

Solution:
- Never cascade to/from the clipboard provider.
- Implicit clipboard (`clipboard=unnamed[plus]`) uses the cursor-local
  unnamed register, so the cascade needs no `start_batch_changes()`. On
  exit, the clipboard gets the same joined result as the `"` register.
- Operations that reference the explicit clipboard registers `"+` / `"*`
  while multicursor is active, use the cached (primary) clipboard value.
This commit is contained in:
Justin M. Keyes
2026-09-09 07:25:32 -04:00
committed by GitHub
parent efdd73c096
commit cb2e9cdfd3
5 changed files with 55 additions and 16 deletions

View File

@@ -447,8 +447,13 @@ LIMITATIONS *mcursor-limitations*
- During Insert-mode completion, live mirroring pauses if a preview could
shift or overlap the primary's completion (same-line edits before the
primary, or line insertions/deletions before it).
- Cursors on the same line can be lost or misplaced by an edit that shifts
columns (e.g. inserting text).
- Explicit clipboard registers (`"*yy`, `"+yy`) are global, not per-cursor. On
multicursor exit, you can set the clipboard to the joined yank: >
:let @+=@"
- Implicit 'clipboard' ("unnamed"/"unnamedplus") updates the clipboard on
multicursor exit.
- Same-line cursors may be lost/misplaced by an edit that shifts columns (e.g.
inserting text).
- Time-travel undo (|g-|, |g+|, |:earlier|) clears all cursors.
- Reloading/unloading a buffer (|:edit!|, 'autoread') clears its cursors
(extmarks limitation).

View File

@@ -7,6 +7,7 @@
#include "nvim/clipboard.h"
#include "nvim/eval.h"
#include "nvim/eval/typval.h"
#include "nvim/mcursor.h"
#include "nvim/option_vars.h"
#include "nvim/register.h"
@@ -26,8 +27,8 @@ static bool clipboard_didwarn = false;
/// @param quiet Suppress error messages
/// @param writing if we're setting the contents of the clipboard
///
/// @returns the yankreg that should be written into, or `NULL`
/// if the register isn't a clipboard or provider isn't available.
/// @return The yankreg that should be written-to/read-from, or `NULL` if the register isn't
/// a clipboard or provider isn't available.
yankreg_T *adjust_clipboard_name(int *name, bool quiet, bool writing)
{
#define MSG_NO_CLIP "clipboard: No provider. " \
@@ -84,6 +85,7 @@ end:
bool get_clipboard(int name, yankreg_T **target, bool quiet)
{
const bool implicit = name == NUL; // clipboard=unnamed[plus]
// show message on error
bool errmsg = true;
@@ -91,6 +93,13 @@ bool get_clipboard(int name, yankreg_T **target, bool quiet)
if (reg == NULL) {
return false;
}
if (mc_replaying()) { // Multicursor cascade: don't read the provider.
if (implicit) {
return false; // clipboard=unnamed[plus] behaves as (cursor-local) unnamed register.
}
*target = reg; // Explicit "+/"* gets the primary cursor's (cached) clipboard value.
return true;
}
free_register(reg);
list_T *const args = tv_list_alloc(1);
@@ -202,7 +211,8 @@ err:
void set_clipboard(int name, yankreg_T *reg)
{
if (!adjust_clipboard_name(&name, false, true)) {
if (mc_replaying() // Multicursor cascade never writes the clipboard.
|| !adjust_clipboard_name(&name, false, true)) {
return;
}

View File

@@ -462,8 +462,6 @@ static void mc_cascade(void)
return;
}
}
// Optimization: one clipboard-provider sync for the whole cascade.
start_batch_changes();
McSandbox sb;
mc_sandbox_enter(&sb, edits);
@@ -489,7 +487,6 @@ static void mc_cascade(void)
done:
atoms_free(&g_atoms);
mc_sandbox_leave(&sb);
end_batch_changes();
mc_cleanup(true);
if (handle_get_buffer(sb.bufnr) == curbuf && !curbuf->b_u_synced
&& curbuf->b_u_newhead != NULL) {
@@ -1033,6 +1030,10 @@ static void mc_reg_gather(void)
if (gather[i] && kv_size(joined[i]) > 0) {
write_reg_contents_ex(MC_REGS[i], joined[i].items, (ssize_t)kv_size(joined[i]), false,
kMTLineWise, 0);
if (MC_REGS[i] == '"') {
// If implicit clipboard is enabled (clipboard=unnamed[plus]): write the joined result.
set_clipboard(NUL, get_y_previous());
}
}
kv_destroy(joined[i]);
}

View File

@@ -71,6 +71,9 @@ yankreg_T *get_y_register(int reg)
return &y_regs[reg];
}
/// What `p` pastes: the last-written register (x: -, yy: 0, "+yy: +, :let @"=…: 0).
///
/// Exception: with clipboard=unnamed/unnamedplus, `p` tries the provider first.
yankreg_T *get_y_previous(void)
FUNC_ATTR_PURE
{

View File

@@ -2891,9 +2891,9 @@ describe('multicursor', function()
eq({ '', '' }, get_lines())
end)
it("perf: provider syncs once per cascade with 'clipboard'", function()
it('clipboard: implicit clipboard=unnamed[plus], explicit "+', function()
n.exec_lua([[
_G.copies = 0
_G.copies, _G.pastes = 0, 0
_G.content = {}
vim.g.clipboard = {
name = 'test',
@@ -2905,20 +2905,40 @@ describe('multicursor', function()
},
paste = {
['+'] = function()
_G.pastes = _G.pastes + 1
return _G.content
end,
},
}
vim.o.clipboard = 'unnamedplus'
]])
local function provider()
return n.exec_lua('return { _G.copies, _G.pastes, _G.content }')
end
-- Implicit clipboard (clipboard=unnamed[plus]) is ignored during cascade.
-- On multicursor exit, the unnamed (") join is written to the clipboard.
command('set clipboard=unnamedplus')
cursors({ 'aa bb', 'cc dd', 'ee ff' })
local base = n.exec_lua('return _G.copies')
feed('dw')
eq({ 'bb', 'dd', 'ff' }, get_lines())
-- One provider sync for the primary's own delete, ONE for the whole
-- cascade (not one per cursor), and the primary's registers win.
eq(base + 2, n.exec_lua('return _G.copies'))
eq({ 'ee ' }, n.exec_lua('return _G.content'))
-- Clipboard provider was updated only by the primary's "dw". Not cascaded.
eq({ 1, 0, { 'ee ' } }, provider())
feed('p') -- Pastes unnamed reg (not clipboard) per-cursor.
eq({ 'baa b', 'dcc d', 'fee f' }, get_lines())
eq({ 1, 1, { 'ee ' } }, provider())
clear_cursors() -- Exit: the joined yank is written to the clipboard.
eq({ 2, 1, { 'aa ', 'cc ', 'ee ', '' } }, provider())
-- Explicit clipboard "+".
command('set clipboard=')
cursors({ 'aa', 'bb' }, 'Qj0')
feed('"+yy') -- Explicit "+: writes the primary's yank to the clipboard.
eq({ 3, 1, { 'bb', '' } }, provider())
feed('"+p') -- Explicit "+: pastes the primary's clipboard at every cursor.
eq({ 'aa', 'bb', 'bb', 'bb' }, get_lines())
eq({ 3, 2, { 'bb', '' } }, provider())
clear_cursors()
eq({ 3, 2, { 'bb', '' } }, provider())
end)
end)