diff --git a/src/nvim/mcursor.c b/src/nvim/mcursor.c index c66814e8ae..c02dcdb440 100644 --- a/src/nvim/mcursor.c +++ b/src/nvim/mcursor.c @@ -353,7 +353,7 @@ static void mc_execute(size_t cursoridx, size_t atomidx) // Get the tracked position: edits by other cursors (etc) may have shifted it since last update. if (ctx.mark != 0 && !mc_mark_get(curbuf, mc_ns(), ctx.mark, &ctx.pos)) { - // The extmark was deleted, thus the cursor is deleted (swept by mc_dedupe()). + // The extmark was deleted, thus the cursor is deleted (swept by mc_cleanup()). return; } @@ -457,7 +457,7 @@ static void mc_cascade(void) edits |= kv_A(g_atoms, i).type != kAMotion; } if (edits) { - mc_dedupe(); + mc_cleanup(true); if (kv_size(mc_cursors) == 0) { atoms_free(&g_atoms); return; @@ -491,7 +491,7 @@ done: atoms_free(&g_atoms); mc_sandbox_leave(&sb); end_batch_changes(); - mc_dedupe(); + mc_cleanup(true); if (handle_get_buffer(sb.bufnr) == curbuf && !curbuf->b_u_synced && curbuf->b_u_newhead != NULL) { // Store the primary's post-cascade position in the still-open undo block; redo restores it. @@ -528,8 +528,10 @@ void mc_clock_edge(bool map_edit, bool map_moved) } } -/// Prunes cursors that overlap others, so an edit does not apply N times at one position. -static void mc_dedupe(void) +/// Prunes dead cursors and ends empty sessions. Optionally dedupes. +/// +/// @param dedupe Also removes overlapping cursors at cascade boundaries. +static void mc_cleanup(bool dedupe) { const bool had_cursors = kv_size(mc_cursors) > 0; size_t n = 0; @@ -542,9 +544,9 @@ static void mc_dedupe(void) ctx_free(ctx); continue; } - // This cursor is a duplicate (to sweep) if it coincides with the primary (which always - // wins), or another cursor's mark is first at its position (first-wins tiebreak). - const bool dup = ctx->mark != 0 + // Deduplicate/merge: cursor is a duplicate if it coincides with the primary (which always + // wins), or another cursor's mark is first at its position (first wins). + const bool dup = dedupe && ctx->mark != 0 && ((curwin != NULL && buf == curbuf && equalpos(ctx->pos, curwin->w_cursor)) || mc_mark_at(buf, ctx->pos) != ctx->mark); if (dup) { @@ -1263,7 +1265,7 @@ void mc_toggle(buf_T *buf, pos_T pos, bool end_follow) uint32_t mark = mc_mark_at(buf, pos); if (mark != 0) { extmark_del_id(buf, mc_ns(), mark); - mc_dedupe(); // sweeps the mark-less cursor (and ends the session if it was the last) + mc_cleanup(false); return; } mc_add(buf, pos); @@ -1307,10 +1309,10 @@ void mc_buf_clear(buf_T *buf) void mc_buf_free(buf_T *buf) { if (mc_replaying()) { - // Can't mutate mc_cursors during cascade. Entries are swept by mc_dedupe() after the cascade. + // Can't mutate (mc_cleanup) mc_cursors during cascade. return; } - mc_dedupe(); + mc_cleanup(false); if (mc_vsel_buf == buf->handle) { // The selection extmarks died with the buffer too. mc_vsel_buf = 0; @@ -1362,7 +1364,7 @@ void mc_ns_cleared(buf_T *buf, uint32_t ns_id) uint32_t mark = 0; mc_mark_set(buf, mc_last_ns(), &mark, ctx->pos, false, true, false); } - mc_dedupe(); // Cleanup. + mc_cleanup(false); if (kv_size(mc_cursors) == 0) { // Session ended; drop the pending cascade. atoms_free(&g_atoms); diff --git a/test/functional/editor/mcursor_spec.lua b/test/functional/editor/mcursor_spec.lua index f8488ed6be..a7a902bbb6 100644 --- a/test/functional/editor/mcursor_spec.lua +++ b/test/functional/editor/mcursor_spec.lua @@ -302,6 +302,16 @@ describe('multicursor', function() api.nvim_input_mouse('left', 'press', 'C', 0, 2, 0) api.nvim_input_mouse('left', 'release', 'C', 0, 2, 0) eq(0, ncursors()) + + -- Toggling another cursor keeps the cursor under the primary. + feed('Q') + for _ = 1, 2 do + api.nvim_input_mouse('left', 'press', 'C', 0, 2, 0) + api.nvim_input_mouse('left', 'release', 'C', 0, 2, 0) + end + eq({ { 0, 0 } }, anchors()) + feed('Q') + -- CTRL-click keeps "q=" follow-mode (unlike Q). feed('ggQj') feed('q=') @@ -796,8 +806,7 @@ describe('multicursor', function() cursors({ 'a', 'b' }, 'Qj0') feed('5g') eq({ '5a', '6b' }, get_lines()) - -- The primary sitting ON a cursor (no cascade ran in between, so mc_dedupe did not): - -- the coincident pair shares one number slot, and the cursor survives. + -- Number overlapping primary/multicursor positions once, without deleting the multicursor. clear_cursors() cursors({ 'x', 'y', 'z' }, 'QjQj') feed('gg0g') @@ -855,7 +864,7 @@ describe('multicursor', function() t.matches('Invalid buffer', t.pcall_err(api.nvim_mcursor, 9999, { 1, 0 })) end) - it('deleting a cursor extmark deletes the cursor', function() + it('deleting an extmark deletes its cursor', function() cursors({ 'aaa', 'bbb', 'ccc' }) local ns = api.nvim_create_namespace('nvim.multicursor') local marks = api.nvim_buf_get_extmarks(0, ns, 0, -1, {}) @@ -875,11 +884,22 @@ describe('multicursor', function() n.expect_exit(command, 'qall!') end) - it('cursors are freed with their buffer', function() + it('cursors are disposed with their buffer', function() fn.setline(1, { 'aaa', 'bbb' }) local buf = api.nvim_get_current_buf() eq(1, api.nvim_mcursor(0, { 1, 0 })) eq(2, api.nvim_mcursor(0, { 2, 0 })) + + -- Deleting an unrelated buffer does not dedupe the cursor under the primary. #41651 + for _, has_cursor in ipairs({ false, true }) do + local scratch = api.nvim_create_buf(false, true) + if has_cursor then + eq(3, api.nvim_mcursor(scratch, { 1, 0 })) + end + api.nvim_buf_delete(scratch, { force = true }) + eq({ { 0, 0 }, { 1, 0 } }, anchors()) + end + command('new') fn.setreg('"', 'KEEP') command('bwipeout! ' .. buf) @@ -990,6 +1010,11 @@ describe('multicursor', function() eq(1, ncursors()) feed('x') -- both cursors edit again (primary still on line 2) eq({ 'aa', 'bb' }, get_lines()) + + -- Partially clearing the namespace does not "dedupe" the cursor under the primary. + feed('Q') + api.nvim_buf_clear_namespace(0, api.nvim_create_namespace('nvim.multicursor'), 0, 1) + eq({ { 1, 0 } }, anchors()) end) it(':edit! reload clears the cursors', function()