From 5209695703db4096923c203f235d78aec0cbdec8 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Thu, 3 Sep 2026 15:20:30 -0400 Subject: [PATCH] fix(multicursor): abandoned Visual selection does not move cursors #41670 Problem: "v{motion}" moves the primary cursor to the selection end, but the other cursors snap back to their anchors unless follow-mode ("q=") is on. The selection preview showed them at the selection end. Solution: Replay the abandoned selection at every cursor, not only in follow-mode. An abandoned typed selection now always emits a "visual" CmdAtom. --- src/nvim/input_cmdatom.c | 6 ++--- test/functional/editor/cmdatom_spec.lua | 8 ++++-- test/functional/editor/mcursor_spec.lua | 33 +++++++++++-------------- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/nvim/input_cmdatom.c b/src/nvim/input_cmdatom.c index 0b2af19e1d..b4df950c8d 100644 --- a/src/nvim/input_cmdatom.c +++ b/src/nvim/input_cmdatom.c @@ -1310,9 +1310,9 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old) vatom.state |= kVatomVoid; } } else if (old->visual.active) { - if (user && old->follow && atom_visual_replayable() && kv_size(vatom.atoms) > 0) { - // Follow-motion ("q="): a selection abandoned without an operator (, "v" toggle) still - // moved the primary cursor to the selection end: replay it at every cursor. + if (user && atom_visual_replayable() && kv_size(vatom.atoms) > 0) { + // Cursor moved to selection-end after ESC/"v"-toggle. Even though this is technically + // a "motion", it's more intuitive to always replay it? #41631 char suffix[2] = { ESC, NUL }; atom_visual_end_suffix(xstrdup(suffix), NULL, false); } else { diff --git a/test/functional/editor/cmdatom_spec.lua b/test/functional/editor/cmdatom_spec.lua index 17b9bfc726..6df6f19a70 100644 --- a/test/functional/editor/cmdatom_spec.lua +++ b/test/functional/editor/cmdatom_spec.lua @@ -849,11 +849,15 @@ describe('CmdAtom', function() feed(',D') eq(before, #atoms()) atom('w', 'dw', ',Dw') -- The supplied motion finishes the operation - -- Same for a mapping that opens Visual mode: "" abandons the selection and closes it. + -- Same for a mapping that opens Visual mode: ESC closes the selection (cursor stays on the + -- selection end, where it moved during the Visual sequence). feed(',V') eq(before + 1, #atoms()) feed('') - eq({ type = 'mapping', lhs = k(',V') }, pick(atom_last(), 'type', 'lhs', 'keys')) + eq( + { type = 'visual', lhs = k(',V'), keys = k('v') }, + pick(atom_last(), 'type', 'lhs', 'keys') + ) -- A mapping whose trailing prefix is completed by TYPED keys ("," + "w") -- ends where its own keys stop: each `lhs` owns only what it produced. command('set notimeout') diff --git a/test/functional/editor/mcursor_spec.lua b/test/functional/editor/mcursor_spec.lua index a6fbe4d700..2338ca3a25 100644 --- a/test/functional/editor/mcursor_spec.lua +++ b/test/functional/editor/mcursor_spec.lua @@ -1533,10 +1533,11 @@ describe('multicursor', function() {1:~ }|*2 {5:-- VISUAL --} | ]]) + -- : cursors move to the selection-end, like the primary. feed('') screen:expect([[ - {17:l}ongword x | - {17:a}b y | + longword {17:x} | + ab {17:y} | medium ^z | {1:~ }|*2 | @@ -1557,7 +1558,8 @@ describe('multicursor', function() {5:-- VISUAL LINE --} | ]]) feed('') - feed('3G0l') + clear_cursors() + cursors({ 'aaaa', 'bbbb', 'cccc', 'dddd' }, 'Q2jl') feed('jl') -- blockwise: primary (3,1)-(4,2), fake (1,0)-(2,1) screen:expect([[ {17:aa}aa | @@ -1605,11 +1607,19 @@ describe('multicursor', function() }) end) - it(' discards the pending visual atom', function() + it('ESC moves cursors to selection-end', function() cursors({ 'abc', 'def' }, 'Qj') feed('viw') feed('x') -- cascades normally; no stray visual replay - eq({ 'bc', 'de' }, get_lines()) + eq({ 'ab', 'de' }, get_lines()) + -- Without follow-mode a plain motion ("0") stays primary-only. + -- But the selection itself moves every cursor to its selection-end. + clear_cursors() + cursors({ 'aaa bbb ccc', 'ddd eee fff', 'ggg hhh iii' }, '4lQjQj') + feed('viwx') + eq({ 'aaa bb ccc', 'ddd ee fff', 'ggg hh iii' }, get_lines()) + feed('0viwex') + eq({ 'aaa bb cc', 'ddd ee ff', 'ggg h iii' }, get_lines()) end) it('cursor displays at each selection end; o swaps it', function() @@ -1807,19 +1817,6 @@ describe('multicursor', function() eq({ '1', 'a2', 'b1', '2' }, get_lines()) end) - it('abandoned visual selection moves cursors to their selection ends', function() - cursors({ 'aaa bbb ccc', 'ddd eee fff', 'ggg hhh iii' }, '4lQjQj') - feed('q=') - feed('viw') -- Selection end: the last char of each cursor's word. - feed('x') - eq({ 'aaa bb ccc', 'ddd ee fff', 'ggg hh iii' }, get_lines()) - -- No follow: discards, other cursors stay; "x" cascades. - feed('q=') - feed('0viw') - feed('x') - eq({ 'aaa bbccc', 'ddd eefff', 'gg hh iii' }, get_lines()) - end) - it('per-cursor curswant is kept over short lines', function() fn.setline(1, { 'ABCDEF', 'xy', 'GHIJKL', 'MNOPQR', 'zw', 'STUVWX' }) feed('gg04l') -- Cursor at (1,4).