diff --git a/runtime/doc/api.txt b/runtime/doc/api.txt index 3799c77909..3f050b92f3 100644 --- a/runtime/doc/api.txt +++ b/runtime/doc/api.txt @@ -573,9 +573,9 @@ nvim_ui_term_event({event}, {value}) *nvim_ui_term_event* Emitted by the TUI client to signal when a host-terminal event occurred. Supports these events: - • "termresponse": The host-terminal sent a DA1, OSC, DCS, or APC response - sequence to Nvim. The payload is the received response. Sets - |v:termresponse| and fires |TermResponse|. + • "termresponse": The host-terminal sent an OSC, DCS, APC, or recognized + CSI (DA1, kitty multiple-cursors) response to Nvim. The payload is the + received response. Sets |v:termresponse| and fires |TermResponse|. Attributes: ~ |RPC| only @@ -1279,6 +1279,20 @@ nvim_load_context({dict}) *nvim_load_context()* Return: ~ (`any`) +nvim_mcursor({buf}, {pos}) *nvim_mcursor()* + Adds a multicursor in the given buffer. + + Attributes: ~ + Since: 0.13.0 + + Parameters: ~ + • {buf} (`integer`) Buffer handle, or 0 for current buffer + • {pos} (`[integer, integer]`) (row, col) (1,0)-indexed cursor position + (byte offset) + + Return: ~ + (`integer`) Total number of extra cursors. + nvim_open_term({buf}, {opts}) *nvim_open_term()* Open a terminal instance in a buffer diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt index c6b9bb6c95..0eff0ae69f 100644 --- a/runtime/doc/autocmd.txt +++ b/runtime/doc/autocmd.txt @@ -1323,7 +1323,8 @@ TermRequest When a |:terminal| child process emits an OSC, autocommand defined without |autocmd-nested|. *TermResponse* -TermResponse When Nvim receives a DA1, OSC, DCS, or APC response from +TermResponse When Nvim receives an OSC, DCS, APC, or recognized + CSI (DA1, kitty multiple-cursors) response from the host terminal. Sets |v:termresponse|. The |event-data| has these keys (type: `vim.event.termresponse.data`): diff --git a/runtime/doc/dev_arch.txt b/runtime/doc/dev_arch.txt index d7e79620e7..554a6f1184 100644 --- a/runtime/doc/dev_arch.txt +++ b/runtime/doc/dev_arch.txt @@ -562,6 +562,8 @@ CONCEPTS structured fields (`CmdSpec`). Every user action emits a |CmdAtom| event. - SPAN: Fragment of an Insert or Visual sequence, cascades but does not emit a CmdAtom. +- CASCADE: Replay queued atoms at the "clock edge" at every cursor. Visual + cascade "dry-runs" spans to display the selection. - COMPOSITE: An atom composed of subatoms (`CmdAtom.atoms`): a mapping/macro's commands, or a Visual sequence. - INSERT-SESSION: All changes from an insert-mode session, until . @@ -569,13 +571,13 @@ CONCEPTS - INSERT-CASCADE: Replay an insert-session span-by-span as it is typed (each span is packaged as a quasi-session: `i` + keys + ``), via nested `edit()`. Pending literal text is a PREVIEW until its span completes. -- REPLAY: Execute keys (dot-repeat, or atom). -- CASCADE: Replay queued atoms at the "clock edge" at every cursor. Visual - cascade "dry-runs" spans to display the selection. -- VOID: When a pending (Visual) atom's keys were tainted/poisoned (by: mouse, - `gv`, a scroll that drags the cursor, …), thus not replayable. - LOSSY: When capture lost part of a composite (payload with no capturing atom, incomplete insert-session): `keys` cannot replay it, `lhs` can. +- PAYLOAD: Keys a command reads interactively while executing: a search/Ex + cmdline (`d/pat`), `f{char}`, a mapping's `getchar()`. +- REPLAY: Execute keys (dot-repeat, or atom). +- VOID: When a pending (Visual) atom's keys were tainted/poisoned (by: mouse, + `gv`, a scroll that drags the cursor, …), thus not replayable. IMPLEMENTATION @@ -595,12 +597,11 @@ Capture happens at the `atom_xx()` hooks. DOT-REPEAT -The redo buffer itself is the pending change atom (`RedoBuf`); the register, -Visual flag and count are FIELDS, and the command body is bytes appended -during execution by the `redo_append_xx()` family (Vim's `AppendToRedobuff*`). -The old `["x][v][count]body` byte encoding no longer exists; `prep_redo()` -stores the prefix structurally, and consumers recompose it at the boundaries -(`redo_keys()`, `start_redo()`, `atom_from_redo()`). +The redo buffer is the pending change as a `CmdSpec` (`RedoState`): the +`["x][count]` prefix is structured fields set by `prep_redo()`, and the +command body is bytes appended during execution by the `redo_append_xx()` +family (Vim's `AppendToRedobuff*`). Consumers rebuild the keysequence at +the boundaries (`redo_keys()`, `start_redo()`, `atom_from_redo()`). THE CASCADE @@ -646,7 +647,7 @@ draw "real" cursors, it is expected to: "nvim.multicursor.cursor" positions while any exist (in-progress Visual selection). - render selections from "nvim.multicursor.visual", or simply keep their - "hl-MultiCursorVisual" grid highlights. + |hl-MCursorVisual| grid highlights. ============================================================================== diff --git a/runtime/doc/gui.txt b/runtime/doc/gui.txt index 98a80dd7e0..c8f307969d 100644 --- a/runtime/doc/gui.txt +++ b/runtime/doc/gui.txt @@ -171,7 +171,7 @@ Normal Mode: > cursor window --------------------------------------------------------------------------- yes end yes - yes end yes "CTRL-]" (2) + no no change no toggle multicursor yes no change yes "*" (2) yes start or extend (1) no yes start or extend (1) no @@ -189,7 +189,7 @@ Insert or Replace Mode: > cursor window --------------------------------------------------------------------------- yes (cannot be active) yes - yes (cannot be active) yes "CTRL-O^]" (2) + no (cannot be active) no no-op yes (cannot be active) yes "CTRL-O*" (2) yes start or extend (1) no like CTRL-O (1) yes start or extend (1) no like CTRL-O (1) diff --git a/runtime/doc/help.txt b/runtime/doc/help.txt index d022ecf556..2ac2f12bbf 100644 --- a/runtime/doc/help.txt +++ b/runtime/doc/help.txt @@ -4,7 +4,7 @@ Nvim documentation If you are new to Nvim, see |nvim-intro|. (To go to a help link, move the -cursor to it and hit CTRL-], or ctrl-click with mouse. Try it here: |bars|.) +cursor to it and hit CTRL-], or right-click with mouse. Try it here: |bars|.) ------------------------------------------------------------------------------ About Nvim *reference_toc* *Q_ct* diff --git a/runtime/doc/helphelp.txt b/runtime/doc/helphelp.txt index f6c78db845..f3ce221a02 100644 --- a/runtime/doc/helphelp.txt +++ b/runtime/doc/helphelp.txt @@ -335,8 +335,8 @@ the help text is displayed as it was intended: Jump to specific subjects by using tags. This can be done in two ways: - Use the "CTRL-]" command while standing on the name of a command or option. - This only works when the tag is a keyword. "" and - "g" work just like "CTRL-]". + This only works when the tag is a keyword. "g" works just like + "CTRL-]". - use the ":ta {subject}" command. This also works with non-keyword characters. diff --git a/runtime/doc/index.txt b/runtime/doc/index.txt index e7b4470ba8..4a7db78a52 100644 --- a/runtime/doc/index.txt +++ b/runtime/doc/index.txt @@ -402,7 +402,8 @@ Tag Char Note Normal-mode action ~ |q| q{0-9a-zA-Z"} record typed characters into named register {0-9a-zA-Z"} (uppercase to append) |q| q (while recording) stops recording -|Q| Q 2 replay last recorded register +|Q| Q place/remove a multicursor; with [count]: + place at each search match |q:| [count]q: edit : command-line in command-line window, with [count]: switch to |Ex-mode| |q/| q/ edit / command-line in command-line window @@ -430,7 +431,7 @@ Tag Char Note Normal-mode action ~ || 1 same as "G" || 1 same as "gg" || 1 same as "b" -|| ":ta" to the keyword at the mouse click +|| place a multicursor at mouse-click || 1 same as "w" || same as "CTRL-T" || same as "g" @@ -632,6 +633,7 @@ Tag Char Note Normal-mode action ~ |[star| [* 1 same as "[/" |[`| [` 1 cursor to previous lowercase mark |[/| [/ 1 cursor to N previous start of a C comment +|[C| [C 1 jump to N previous multicursor |[D| [D list all defines found in current and included files matching the word under the cursor, start searching at beginning of @@ -674,6 +676,7 @@ Tag Char Note Normal-mode action ~ |]star| ]* 1 same as "]/" |]`| ]` 1 cursor to next lowercase mark |]/| ]/ 1 cursor to N next end of a C comment +|]C| ]C 1 jump to N next multicursor |]D| ]D list all #defines found in current and included files matching the word under the cursor, start searching at cursor position @@ -706,6 +709,8 @@ Tag Char Note Normal-mode action ~ Tag Char Note Normal-mode action ~ ------------------------------------------------------------------------------ ~ +|g_CTRL-A| g CTRL-A insert an ascending number at each + multicursor ("counter") |g_CTRL-G| g CTRL-G show information about current cursor position |g_CTRL-H| g CTRL-H start Select block mode @@ -796,7 +801,7 @@ Tag Char Note Normal-mode action ~ |g| g 1 same as "g$" but go to the rightmost non-blank character instead |g| g 1 same as "g0" -|g| g same as +|g| g ":tag" on the keyword at mouse-click g same as |g| g same as |g| g go to last accessed tabpage diff --git a/runtime/doc/intro.txt b/runtime/doc/intro.txt index 68a79bce01..470fce1f00 100644 --- a/runtime/doc/intro.txt +++ b/runtime/doc/intro.txt @@ -386,7 +386,7 @@ a command that takes an argument, such as |f| or |m|, the timeout set with *CTRL-\_CTRL-G* *i_CTRL-\_CTRL-G* *c_CTRL-\_CTRL-G* *v_CTRL-\_CTRL-G* CTRL-\ CTRL-G works the same as |CTRL-\_CTRL-N| for backward compatibility. - *:exm* *:exmode* *gQ* *mode-Ex* *Ex-mode* *Ex* *EX* *E501* + *:exm* *:exmode* *mode-Ex* *Ex-mode* *Ex* *EX* *E501* :exm[ode] [count]q: Switch to Ex mode. This is like the |cmdwin| except it stays open, in Insert-mode, so can you keep diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index 400d4a7bfc..12a9115e24 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -219,7 +219,7 @@ Call |vim.lsp.enable()| to ENABLE a config: it will automatically ACTIVATE - 'filetype' matches one of the config `filetypes` - 'buftype' is empty or "help" -- `root_markers` or `root_dir()` resolved a workspace roort, or the config has +- `root_markers` or `root_dir()` resolved a workspace root, or the config has `workspace_required=false` Note: The initial call to |vim.lsp.enable()| (or |:lsp-enable|) also actively diff --git a/runtime/doc/news-0.10.txt b/runtime/doc/news-0.10.txt index 540bfe3471..df49349df4 100644 --- a/runtime/doc/news-0.10.txt +++ b/runtime/doc/news-0.10.txt @@ -217,8 +217,6 @@ The following new features were added. • Navigating the |jumplist| with CTRL+O, CTRL+I behaves more intuitively when deleting buffers, and avoids "invalid buffer" cases. #25461 • |:fclose| command. - • |v_Q-default| and |v_@-default| repeat a register for each line of a linewise - visual selection. • Clicking on a tabpage in the tabline with the middle mouse button closes it. • |:checkhealth| buffer can be opened in a split window using modifiers like |:vertical|, |:horizontal| and |:botright|. diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 0606b6fc18..3f0379a736 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -270,6 +270,11 @@ DIAGNOSTICS EDITOR +• Multiple cursors (|multicursor|): edit in many places at once. + • Toggle cursors with |Q|, ||, or |nvim_mcursor()|. + • Toggle "follow mode" with |q=|. + • Clear cursors with |CTRL-L|, restore them with |gQ|. + • [count]Q places a cursor at every search match. • |:bcd| sets a buffer-local directory. |getcwd()| and |haslocaldir()| take `bufnr` as a third parameter. • |:lcd!| |:tcd!| |:bcd!| unsets the directory of that scope. diff --git a/runtime/doc/repeat.txt b/runtime/doc/repeat.txt index 49cd4e4bd1..5c5a127277 100644 --- a/runtime/doc/repeat.txt +++ b/runtime/doc/repeat.txt @@ -40,9 +40,8 @@ selection included. See |visual-repeat|. ============================================================================== Semantic repeat *action-repeat* *cmdatom* *excalibur* -The |CmdAtom| event is published on every user action. This avoids the need -for plugins to "announce" the repeatable unit, thus plugins like vim-repeat -aren't needed. +Every user action emits a |CmdAtom| event. This avoids the need for plugins to +"announce" the repeatable unit, thus plugins like vim-repeat aren't needed. `CmdAtom.lhs` is the high-level user input collected during an action, including getchar() input. This is signficant: it reflects the semantic @@ -113,6 +112,12 @@ vim-repeat or similar. >lua end, }) vim.keymap.set('n', '.', function() + -- Multicursors: degrade to builtin "." (cascades). + local mc = vim.api.nvim_create_namespace('nvim.multicursor') + if #vim.api.nvim_buf_get_extmarks(0, mc, 0, -1, { limit = 1 }) > 0 then + vim.api.nvim_feedkeys('.', 'n', false) + return + end -- CmdAtom is deferred; schedule the replay, in case "." follows an edit. vim.schedule(function() if last then @@ -295,18 +300,6 @@ q Stops recording. {Visual}@@ register for each selected line. See |visual-repeat|, |default-mappings|. - *Q* -Q Repeat the last recorded register [count] times. - See |reg_recorded()|. - - *v_Q-default* -{Visual}Q In linewise Visual mode, repeat the last recorded - register for each selected line. - See |visual-repeat|, |default-mappings|. - - *v_Q* -{Visual}Q Place a cursor on each line of the Visual selection. - *:@* :[addr]@{0-9a-z".=*+} Execute the contents of register {0-9a-z".=*+} as an Ex command. First set cursor at line [addr] (default @@ -334,7 +327,106 @@ Q Repeat the last recorded register [count] times. ============================================================================== Multiple cursors *mcursor* *multicursor* -todo +You can place extra cursors ("multicursors") in a buffer, to repeat operations +at each cursor, as-you-type. This is like a live |macro|, but unlike macros (a +stream of keys without any context), actions (|CmdAtom|) replay "semantically". + +- Mappings and Normal-mode operators are applied at each cursor. +- Insert-mode edits appear at each cursor as you type. +- Motions (|q=|) and Visual-mode sequences replay at each cursor: "viweex" + re-executes the selection there, so the extents are per-cursor (each + cursor's own word, block, …). +- Macros (|@|) replay at each cursor. +- Registers are cursor-local. Each cursor reads/writes its own registers. + The yanks are joined (linewise) when the multicursor session ends. +- Undo is atomic: |u| reverts the aggregate edits from all cursors at once. +- CTRL-C interrupts the cascade. + +Example: place a cursor on every matching pattern: > + :g/pattern/normal! nQ +< +Example: place a cursor at each |quickfix| item, or a range: > + :cdo normal! Q + :2,4cdo normal! Q +< +Example: place a cursor at every match, using the API: >lua + for _, m in ipairs(vim.fn.matchbufline('%', [[pattern]], 1, '$')) do + vim.api.nvim_mcursor(0, { m.lnum, m.byteidx }) + end +< +Plugins can place cursors with |nvim_mcursor()| and inspect user actions via +the |CmdAtom| event. + +The cursor positions are tracked as |extmarks| in the "nvim.multicursor" +namespace. Query them with |nvim_buf_get_extmarks()|; deleting an extmark +deletes the cursor. You can also use |:marks| to peek at cursors: > + :marks nvim.multicursor +< +Multicursors are presented as |hl-MCursor| highlights, unless your (terminal) +UI supports the Kitty multiple-cursors protocol. + + +COMMANDS + *Q* +Q Toggles a multicursor at the current cursor position. + Disables follow-mode |q=|. + + With a [count]: places a cursor at every match (|gn|) + of the last search pattern. Example: + 1. Search for something ("*", "/pattern", …). + 2. Type "1Q". + + *mcursor-mouse* ** + Toggles a multicursor at the click position, without + moving the primary cursor (or changing windows). Does + NOT disable follow-mode |q=|. No-op in Insert-mode. + + *v_Q* +{Visual}Q Places a cursor on each line of the Visual selection. + Enables follow-mode |q=|. + + *q=* +q= Toggles follow-mode: motions (not jumps/scrolls) are + replayed per-cursor. Cursors arriving at the same + position (e.g. "G") are merged (deduplicated). + + Use [count] to force the mode instead of toggling: + "1q=" on, "2q=" off. + + *mcursor-clear* +CTRL-L Clears multicursors in the current buffer. + |CTRL-L-default| + + *gQ* +gQ Restores the previous multicursors. + + *g_CTRL-A* +g CTRL-A During a multicursor session, inserts an ascending + number ("counter") at each cursor, so a column of + cursors becomes 1, 2, 3, …. Use [count] to choose the + initial number. + + *]C* +]C Jump to the [count]'th next cursor. + + *[C* +[C Jump to the [count]'th previous cursor. + + +LIMITATIONS *mcursor-limitations* + +- Extra cursors on the same line can be lost or misplaced by an edit that + shifts columns (e.g. inserting text): the cursors shift each other. +- Undo restores the buffer, not (per-cursor) registers. +- Time-travel undo (|g-|, |g+|, |:earlier|) clears all cursors. +- Reloading/unloading a buffer (|:edit!|, 'autoread') clears its cursors + (extmarks limitation). +- An operator replayed at a cursor inside a closed fold applies to the whole + fold, like any operator (|fold-behavior|). +- |Q| is not allowed while recording or executing a macro. +- An 'operatorfunc' that caches its |getchar()| input instead of re-reading it + misbehaves at the extra cursors: the unconsumed input runs as a normal-mode + key. vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/runtime/doc/syntax.txt b/runtime/doc/syntax.txt index 7c43f14096..cc740bfb6c 100644 --- a/runtime/doc/syntax.txt +++ b/runtime/doc/syntax.txt @@ -5376,6 +5376,10 @@ CursorLineSign Like SignColumn when 'cursorline' is set for the cursor line. *hl-MatchParen* MatchParen Character under the cursor or just before it, if it is a paired bracket, and its match. |pi_paren.txt| + *hl-MCursor* +MCursor |multicursor| cursor. + *hl-MCursorVisual* +MCursorVisual |multicursor| Visual selection. *hl-ModeMsg* ModeMsg 'showmode' message (e.g., "-- INSERT --"). *hl-MsgArea* diff --git a/runtime/doc/tagsrch.txt b/runtime/doc/tagsrch.txt index fd9b274d71..dd9b502cf4 100644 --- a/runtime/doc/tagsrch.txt +++ b/runtime/doc/tagsrch.txt @@ -41,8 +41,7 @@ below. first one is jumped to. See |tag-matchlist| for jumping to other matching tags. -g *g* - ** *CTRL-]* +g *g* *CTRL-]* CTRL-] Jump to the definition of the keyword under the cursor. Same as ":tag {name}", where {name} is the keyword under or after cursor. diff --git a/runtime/doc/usr_05.txt b/runtime/doc/usr_05.txt index e479232c81..7bab7fbb8e 100644 --- a/runtime/doc/usr_05.txt +++ b/runtime/doc/usr_05.txt @@ -83,7 +83,7 @@ you want to remember fewer or more lines. This defines a key mapping. More about that in the next section. This defines the "Q" command to do formatting with the "gq" operator. Otherwise the -"Q" command repeats the last recorded register. +"Q" command adds a |multicursor|. > vnoremap _g y:exe "grep /" .. escape(@", '\\/') .. "/ *.c *.h" diff --git a/runtime/doc/various.txt b/runtime/doc/various.txt index 11fdb53f3e..78a8be9ef1 100644 --- a/runtime/doc/various.txt +++ b/runtime/doc/various.txt @@ -27,8 +27,8 @@ CTRL-L Clears and redraws the screen. The redraw may happen See also |nvim__redraw()|. *CTRL-L-default* By default, also clears search highlighting - |:nohlsearch| and updates diffs |:diffupdate|. - |default-mappings| + |:nohlsearch|, removes |multicursor|s, and updates + diffs |:diffupdate|. |default-mappings| *:mod* *:mode* *E359* :mod[e] Clears and redraws the screen. diff --git a/runtime/doc/vim_diff.txt b/runtime/doc/vim_diff.txt index 6372e99525..f1b92016e0 100644 --- a/runtime/doc/vim_diff.txt +++ b/runtime/doc/vim_diff.txt @@ -235,6 +235,7 @@ MAJOR COMPONENTS - Job control |job-control| - LSP framework |lsp| - Lua scripting |lua| |-l| +- Multicursor |multicursor| - Parsing engine |treesitter| - Plugin manager |vim.pack| - Providers diff --git a/runtime/doc/visual.txt b/runtime/doc/visual.txt index 3dfbeca758..ed22091494 100644 --- a/runtime/doc/visual.txt +++ b/runtime/doc/visual.txt @@ -393,9 +393,9 @@ operator: >lua end) < -Visual mode |default-mappings| "@" and "Q" repeat a register for all selected -lines if the selection is linewise. See |v_@-default| and |v_Q-default| for -details. For example, given the text: +The Visual mode |default-mappings| "@" repeats a register for all selected +lines if the selection is linewise. See |v_@-default| for details. For +example, given the text: 123(hello)321 456(world)654 diff --git a/runtime/doc/vvars.txt b/runtime/doc/vvars.txt index 3b1848f52c..ecd205aeeb 100644 --- a/runtime/doc/vvars.txt +++ b/runtime/doc/vvars.txt @@ -728,10 +728,10 @@ v:termrequest *v:termresponse* *termresponse-variable* v:termresponse - The value of the most recent OSC or DCS control sequence - received by Nvim from the terminal. This can be read in a - |TermResponse| event handler after querying the terminal using - another escape sequence. + The most recent OSC, DCS, APC, or recognized CSI (DA1, kitty + multiple-cursors) control sequence received by Nvim from the + host-terminal. Can be read in a |TermResponse| event handler + after querying the terminal. *v:testing* *testing-variable* v:testing Must be set before using `test_garbagecollect_now()`. diff --git a/runtime/lua/vim/_core/defaults.lua b/runtime/lua/vim/_core/defaults.lua index 20b21c3082..449ae975a0 100644 --- a/runtime/lua/vim/_core/defaults.lua +++ b/runtime/lua/vim/_core/defaults.lua @@ -112,9 +112,14 @@ do --- Use normal! to prevent inserting raw when using i_. #17473 --- --- See |CTRL-L-default| - vim.keymap.set('n', '', 'nohlsearchdiffupdatenormal! ', { - desc = ':help CTRL-L-default', - }) + vim.keymap.set( + 'n', + '', + 'nohlsearchdiffupdate' + .. 'call nvim_buf_clear_namespace(0, nvim_create_namespace("nvim.multicursor"), 0, -1)' + .. 'normal! ', + { desc = ':help CTRL-L-default' } + ) --- Set undo points when deleting text in insert mode. --- @@ -129,16 +134,10 @@ do --- See |&-default| vim.keymap.set('n', '&', ':&&', { desc = ':help &-default' }) - --- Use Q in Visual mode to execute a macro on each line of the selection. #21422 + --- Use @ in Visual mode to execute a macro on each line of the selection. #21422 --- This only make sense in linewise Visual mode. #28287 --- - --- Applies to @x and includes @@ too. - vim.keymap.set( - 'x', - 'Q', - "mode() ==# 'V' ? ':normal! @=reg_recorded()' : 'Q'", - { silent = true, expr = true, desc = ':help v_Q-default' } - ) + --- Includes @@ too. vim.keymap.set( 'x', '@', @@ -991,10 +990,9 @@ do end end - --- If the TUI (term_has_truecolor) was able to determine that the host - --- terminal supports truecolor, enable 'termguicolors'. Otherwise, query the - --- terminal (using both XTGETTCAP and SGR + DECRQSS). If the terminal's - --- response indicates that it does support truecolor enable 'termguicolors', + --- If the TUI (term_has_truecolor) detected that the host terminal supports truecolor, enable + --- 'termguicolors'. Otherwise, query the terminal (using both XTGETTCAP and SGR + DECRQSS). If + --- the terminal's response indicates that it does support truecolor enable 'termguicolors', --- but only if the user has not already disabled it. --- --- @param ui table The attached TTY UI (see |nvim_list_uis()|). @@ -1088,6 +1086,7 @@ do end detect_termguicolors(tty) + require('vim._core.mcursor').detect(tty) -- Kitty multicursor protocol. -- Show progress bars in supporting terminals nvim_on('Progress', vim.api.nvim_create_augroup('nvim.progress'), { @@ -1130,9 +1129,8 @@ do return end - -- 'termguicolors': enable when the attaching UI reports truecolor (or the - -- terminal query confirms it), unless the user set it. Never disabled here. - detect_termguicolors(ui) + detect_termguicolors(ui) -- 'termguicolors' + require('vim._core.mcursor').detect(ui) -- Kitty multicursor protocol. -- 'background': (re)query OSC 11. The persistent handler also reacts to -- runtime theme changes (mode 2031 -> TUI re-queries -> |TermResponse|); diff --git a/runtime/lua/vim/_core/mcursor.lua b/runtime/lua/vim/_core/mcursor.lua new file mode 100644 index 0000000000..d3f4ff4b33 --- /dev/null +++ b/runtime/lua/vim/_core/mcursor.lua @@ -0,0 +1,293 @@ +--- Multicursor: +--- - Display: kitty cursors if supported (`detect()`), else "highlight" cursors (hl-MCursor). +--- https://github.com/kovidgoyal/kitty/blob/master/docs/multiple-cursors-protocol.rst +--- - Commands: `[count]Q`, `{Visual}Q`, `gQ`, `]C`, `g CTRL-A`. +--- - Script API: `active()`. + +local M = {} + +--- Extmarks tracking multicursor positions. +local ns = vim.api.nvim_create_namespace('nvim.multicursor') +--- Selection-end cursors, during a Visual selection. +local vcur_ns = vim.api.nvim_create_namespace('nvim.multicursor.cursor') +--- Kitty cursors protocol: host terminal supports the protocol. +local tty_cursors = false +local last_seq = '' ---@type string +local pending = false + +--- Gets the operative mcursors namespace, depending on the current state. +--- @param buf integer +local function display_ns(buf) + if #vim.api.nvim_buf_get_extmarks(buf, vcur_ns, 0, -1, { limit = 1 }) > 0 then + return vcur_ns + end + return ns +end + +--- Absolute (screen) coordinates ("2:row:col", 1-indexed) of mcursors in each visible window. +--- Off-screen cursors are omitted. +--- @return string[] +local function coords() + local r = {} ---@type string[] + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + local buf = vim.api.nvim_win_get_buf(win) + -- Visible cursors only: constrain to the viewport, for performance. + local top, bot = vim.fn.line('w0', win) - 1, vim.fn.line('w$', win) - 1 + local marks = vim.api.nvim_buf_get_extmarks(buf, display_ns(buf), { top, 0 }, { bot, -1 }, {}) + local last = vim.api.nvim_buf_line_count(buf) + for _, m in ipairs(marks) do + -- Marks can be stale by the time the (scheduled) refresh runs. + if m[2] < last then + local pos = vim.fn.screenpos(win, m[2] + 1, m[3] + 1) + if pos.row > 0 and pos.col > 0 then + r[#r + 1] = ('2:%d:%d'):format(pos.row, pos.col) + end + end + end + end + return r +end + +--- Kitty cursors protocol: Sends a term sequence. Empty string ('') means clear. +local function send(seq) + if seq == last_seq then -- Skip redundant sequences. + return + end + last_seq = seq + vim.api.nvim_ui_send(seq == '' and '\027[>0;4 q' or seq) +end + +--- Kitty cursors protocol: Updates the cursors. +local function refresh() + local c = coords() + -- Clear all extra cursors ("no cursor" over the full-screen rectangle), then set shape 29 (mimic + -- primary) at each position. + send(#c == 0 and '' or ('\027[>0;4 q\027[>29;%s q'):format(table.concat(c, ';'))) +end + +--- Displays the mcursors. Invoked per-redraw while cursors exist. +--- - Kitty cursors protocol: deferred to end of redraw cycle (display_end). +--- - Else: highlight cells (the tracking extmarks only carry positions). +--- +--- NOTE: The fake Visual selections ("nvim.multicursor.visual") are self-painting extmarks. +--- TODO(justimk): could also do that for "nvim.multicursor" after #41576. +local function display_win(_, _, bufnr, topline, botline) + if tty_cursors then -- Terminal draws the cursors; emit once per redraw (on_end). + pending = true + return + end + local marks = vim.api.nvim_buf_get_extmarks( + bufnr, + display_ns(bufnr), + { topline, 0 }, + { botline + 1, 0 }, + {} + ) + local lastrow = vim.api.nvim_buf_line_count(bufnr) + for _, m in ipairs(marks) do + local row, col = m[2], m[3] + -- Marks may be stale (undo/redo). + if row < lastrow then + -- TODO(justinmk): eliminate this text-vs-virtual handling. #41576 + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, true)[1] + if col >= #line then + -- Past EOL (e.g. insert-mode "A"): overlay a virtual-space cell. #41576 + vim.api.nvim_buf_set_extmark(bufnr, ns, row, col, { + ephemeral = true, + virt_text = { { ' ', 'MCursor' } }, + virt_text_pos = 'overlay', + priority = 4097, -- Cover the selection highlight. + }) + else + vim.api.nvim_buf_set_extmark(bufnr, ns, row, col, { + ephemeral = true, + end_col = col + 1, + hl_group = 'MCursor', + priority = 4097, -- Cover the selection highlight. + }) + end + end + end +end + +--- Render cursors in one "frame" at redraw edge (on_end), when screen positions are final. +local function display_end() + if pending then + pending = false + refresh() + end +end + +--- Enables/disables multicursor display handling. +--- @param enable boolean +function M.enable(enable) + vim.api.nvim_set_decoration_provider(ns, enable and { + on_win = display_win, + on_end = display_end, + } or {}) + if not enable then + send('') -- Kitty cursors protocol: clear the UI-side cursors. + end +end + +--- ]C/[C: Jumps to the [count]'th next/previous cursor. +--- @param forward boolean +--- @param count integer? +--- @return boolean moved +function M.jump(forward, count) + local last = vim.api.nvim_buf_line_count(0) + local positions = {} ---@type vim.Pos[] + for _, m in ipairs(vim.api.nvim_buf_get_extmarks(0, ns, 0, -1, {})) do + if m[2] < last then + positions[#positions + 1] = vim.pos(0, m[2], m[3]) + end + end + local n = #positions + if n == 0 then + return false + end + table.sort(positions) + local curpos = vim.pos.cursor(0) + local steps = ((count or 1) - 1) % n + local idx ---@type integer + if forward then + local i = 1 -- First cursor after current position. (n+1: none) + while i <= n and positions[i] <= curpos do + i = i + 1 + end + idx = (i - 1 + steps) % n + 1 + else + local i = n -- Last cursor before current position. (0: none) + while i >= 1 and not (positions[i] < curpos) do + i = i - 1 + end + idx = (i - 1 - steps) % n + 1 + end + vim.cmd [[normal! m']] + vim.api.nvim_win_set_cursor(0, positions[idx]:to_cursor()) + return true +end + +--- Restores the previous multicursors. +function M.restore() + local last_ns = vim.api.nvim_create_namespace('nvim.multicursor.last') + local lastrow = vim.api.nvim_buf_line_count(0) + for _, m in ipairs(vim.api.nvim_buf_get_extmarks(0, last_ns, 0, -1, {})) do + if m[2] < lastrow then + vim.api.nvim_mcursor(0, { m[2] + 1, m[3] }) + end + end +end + +--- Places a cursor on each line of the Visual selection. Enables "follow mode" (q=). +function M.visual() + local vline = vim.fn.line('v') --[[@as integer]] + local cline = vim.fn.line('.') --[[@as integer]] + local first, last = math.min(vline, cline), math.max(vline, cline) + local vcol = vim.fn.virtcol('.', true)[1] --[[@as integer]] + vim.cmd.normal({ vim.keycode(''), bang = true }) -- End Visual mode. + --- Screen column (per-line: multibyte chars/tabs shift the byte<->screen mapping), or the + --- past-EOL insertion point on shorter lines. + --- @param lnum integer + local function bytecol(lnum) + if vim.fn.virtcol({ lnum, '$' }) <= vcol then + return vim.fn.col({ lnum, '$' }) - 1 + end + return vim.fn.virtcol2col(0, lnum, vcol) - 1 + end + vim.api.nvim_win_set_cursor(0, { first, bytecol(first) }) + for lnum = first, last do + vim.api.nvim_mcursor(0, { lnum, bytecol(lnum) }) + end + vim.cmd('norm! 1q=') -- "Follow" mode. +end + +--- "[count]Q": Places a multicursor at every match of the last search pattern. +function M.matches() + if vim.fn.getreg('/') == '' then + require('vim._core.util').echo_err('E35: No previous regular expression') + return + end + local view = vim.fn.winsaveview() + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + local pos = vim.fn.searchpos('', 'cW') + while pos[1] ~= 0 do + vim.api.nvim_mcursor(0, { pos[1], pos[2] - 1 }) + pos = vim.fn.searchpos('', 'W') + end + vim.fn.winrestview(view) +end + +--- Inserts an ascending number at each cursor (Emacs F3-counter): 1, 2, 3, …. +--- Numbers ascend in cursor order (top-to-bottom, left-to-right); the primary counts too. +--- @param start? integer First number (default 1) +--- @param step? integer Increment (default 1) +--- @param format? string %d-style format for each number (default "%d") +function M.number(start, step, format) + start = start or 1 + step = step or 1 + format = format or '%d' + local pts = { vim.pos.cursor(0) } ---@type vim.Pos[] + local lastrow = vim.api.nvim_buf_line_count(0) + for _, m in ipairs(vim.api.nvim_buf_get_extmarks(0, ns, 0, -1, {})) do + if m[2] < lastrow then + pts[#pts + 1] = vim.pos(0, m[2], m[3]) + end + end + table.sort(pts) + -- Coincident cursors (e.g. primary sitting on a cursor) share a number slot. + vim.list.unique(pts, function(p) + return ('%d:%d'):format(p.row, p.col) + end) + -- Number in position order, but INSERT bottom-up in case of text shifting. + for i = #pts, 1, -1 do + local p = pts[i] + vim.api.nvim_buf_set_text( + 0, + p.row, + p.col, + p.row, + p.col, + { format:format(start + (i - 1) * step) } + ) + end +end + +--- Kitty cursors protocol: enables terminal-driven cursor display. +--- @param enable boolean +function M.tty_cursors(enable) + if tty_cursors == enable then + return + end + tty_cursors = enable + vim.api.nvim__redraw({ valid = false, flush = false }) + if not enable then + send('') -- Clear the displayed terminal cursors. + end +end + +--- Kitty cursors protocol: Queries the host terminal for protocol support, and enables it. +--- @param ui table Id of the attached nvim_list_uis() TTY UI. +function M.detect(ui) + -- Query: "CSI > SP q" + vim.tty.request('\027[> q', { chan = ui.chan }, function(resp) + --- Response is a list of cursor shapes, e.g. "CSI > 1;2;3;29;30;40;100;101 SP q". + local shapes = resp:match('^\027%[>([%d;]*) q$') ---@type string? + if not shapes then + return -- Not a reply to our query, keep listening. + end + -- Shape 29: cursors follow the primary cursor's shape. + if vim.list_contains(vim.split(shapes, ';', { plain = true }), '29') then + M.tty_cursors(true) + end + return true + end) +end + +--- True if multicursor is active in the current buffer. +--- @return boolean +function M.active() + return #vim.api.nvim_buf_get_extmarks(0, ns, 0, -1, { limit = 1 }) > 0 +end + +return M diff --git a/runtime/lua/vim/_meta/api.gen.lua b/runtime/lua/vim/_meta/api.gen.lua index 2ccc0674e4..1b1311c498 100644 --- a/runtime/lua/vim/_meta/api.gen.lua +++ b/runtime/lua/vim/_meta/api.gen.lua @@ -119,6 +119,12 @@ function vim.api.nvim__inspect_cell(grid, row, col) end --- reach, so this function can be used to force a cache clear in a test. function vim.api.nvim__invalidate_glyph_cache() end +--- WARNING: This feature is experimental/unstable. +--- +--- Returns true if a multicursor cascade is in-progress. +--- @return boolean +function vim.api.nvim__mcursor_cascading() end + --- WARNING: This feature is experimental/unstable. --- --- Get the properties for namespace @@ -1674,6 +1680,13 @@ function vim.api.nvim_list_wins() end --- @return any function vim.api.nvim_load_context(dict) end +--- Adds a multicursor in the given buffer. +--- +--- @param buf integer Buffer handle, or 0 for current buffer +--- @param pos [integer, integer] (row, col) (1,0)-indexed cursor position (byte offset) +--- @return integer # Total number of extra cursors. +function vim.api.nvim_mcursor(buf, pos) end + --- @deprecated --- @param msg string --- @param log_level integer diff --git a/runtime/lua/vim/_meta/vvars.gen.lua b/runtime/lua/vim/_meta/vvars.gen.lua index 4f0115f77b..f404eaabe8 100644 --- a/runtime/lua/vim/_meta/vvars.gen.lua +++ b/runtime/lua/vim/_meta/vvars.gen.lua @@ -773,10 +773,10 @@ vim.v.t_string = ... --- @type string vim.v.termrequest = ... ---- The value of the most recent OSC or DCS control sequence ---- received by Nvim from the terminal. This can be read in a ---- `TermResponse` event handler after querying the terminal using ---- another escape sequence. +--- The most recent OSC, DCS, APC, or recognized CSI (DA1, kitty +--- multiple-cursors) control sequence received by Nvim from the +--- host-terminal. Can be read in a `TermResponse` event handler +--- after querying the terminal. --- @type string vim.v.termresponse = ... diff --git a/runtime/lua/vim/hl.lua b/runtime/lua/vim/hl.lua index f6df80deed..1a73a3e4a8 100644 --- a/runtime/lua/vim/hl.lua +++ b/runtime/lua/vim/hl.lua @@ -198,7 +198,9 @@ function M.hl_op(opts) local winid = api.nvim_get_current_win() local state = hl_op_state[state_key] - if state ~= nil and state.timer and not state.timer:is_closing() then + -- Multicursor cascade: accumulate per-cursor, don't cancel the previous event's highlight. + local cascading = api.nvim__mcursor_cascading() + if state ~= nil and state.timer and not state.timer:is_closing() and not cascading then state.timer:close() assert(state.clear) state.clear() diff --git a/src/nvim/api/events.c b/src/nvim/api/events.c index 81a02072b7..c9de5a8ca6 100644 --- a/src/nvim/api/events.c +++ b/src/nvim/api/events.c @@ -48,9 +48,9 @@ void nvim_error_event(uint64_t channel_id, Integer type, String msg) /// /// Supports these events: /// -/// - "termresponse": The host-terminal sent a DA1, OSC, DCS, or APC response sequence to Nvim. -/// The payload is the received response. Sets |v:termresponse| and fires -/// |TermResponse|. +/// - "termresponse": The host-terminal sent an OSC, DCS, APC, or recognized CSI (DA1, kitty +/// multiple-cursors) response to Nvim. The payload is the received response. Sets +/// |v:termresponse| and fires |TermResponse|. /// /// @param channel_id /// @param event Event name diff --git a/src/nvim/api/vim.c b/src/nvim/api/vim.c index 0dd3ee5d21..2d4e12ac53 100644 --- a/src/nvim/api/vim.c +++ b/src/nvim/api/vim.c @@ -58,6 +58,7 @@ #include "nvim/mark_defs.h" #include "nvim/math.h" #include "nvim/mbyte.h" +#include "nvim/mcursor.h" #include "nvim/memline.h" #include "nvim/memory.h" #include "nvim/memory_defs.h" @@ -1452,6 +1453,40 @@ void nvim_put(ArrayOf(String) lines, String type, Boolean after, Boolean follow, }); } +/// Adds a multicursor in the given buffer. +/// +/// @param buf Buffer handle, or 0 for current buffer +/// @param pos (row, col) (1,0)-indexed cursor position (byte offset) +/// @param[out] err Error details, if any +/// @return Total number of extra cursors. +Integer nvim_mcursor(Buffer buf, ArrayOf(Integer, 2) pos, Error *err) + FUNC_API_SINCE(15) +{ + buf_T *b = find_buffer_by_handle(buf, err); + if (b == NULL) { + return 0; + } + VALIDATE_EXP(!(pos.size != 2 || pos.items[0].type != kObjectTypeInteger + || pos.items[1].type != kObjectTypeInteger), "pos", "[row, col] array", NULL, { + return 0; + }); + + int64_t row = pos.items[0].data.integer; + int64_t col = pos.items[1].data.integer; + + VALIDATE_RANGE(!(row < 1 || row > b->b_ml.ml_line_count), "cursor line", { + return 0; + }); + VALIDATE_RANGE(!(col > MAXCOL || col < 0), "cursor column", { + return 0; + }); + + // Silently clamp to the EOL insertion point, like nvim_win_set_cursor(). + col = MIN(col, (int64_t)ml_get_buf_len(b, (linenr_T)row)); + mc_add(b, (pos_T){ .lnum = (linenr_T)row, .col = (colnr_T)col, .coladd = 0 }); + return (Integer)mc_count(); +} + /// Returns the 24-bit RGB value of a |nvim_get_color_map()| color name or /// "#rrggbb" hexadecimal string. /// @@ -2082,6 +2117,14 @@ void nvim__invalidate_glyph_cache(void) must_redraw = UPD_CLEAR; } +/// @nodoc +/// Returns true if a multicursor cascade is in-progress. +Boolean nvim__mcursor_cascading(void) + FUNC_API_SINCE(15) FUNC_API_FAST +{ + return mc_replaying(); +} + /// @nodoc Object nvim__unpack(String str, Arena *arena, Error *err) FUNC_API_FAST diff --git a/src/nvim/buffer.c b/src/nvim/buffer.c index ab20c99584..635262253a 100644 --- a/src/nvim/buffer.c +++ b/src/nvim/buffer.c @@ -77,6 +77,7 @@ #include "nvim/mark.h" #include "nvim/mark_defs.h" #include "nvim/mbyte.h" +#include "nvim/mcursor.h" #include "nvim/memfile_defs.h" #include "nvim/memline.h" #include "nvim/memline_defs.h" @@ -813,6 +814,7 @@ void buf_clear(void) { linenr_T line_count = curbuf->b_ml.ml_line_count; extmark_free_all(curbuf); // delete any extmarks + mc_buf_free(curbuf); // Multicursors died with their extmarks. while (!(curbuf->b_ml.ml_flags & ML_EMPTY)) { ml_delete(1); } @@ -924,6 +926,7 @@ bool buf_freeall(buf_T *buf, int flags) linenr_T count = buf->b_ml.ml_line_count; ml_close(buf, true); // close and delete the memline/memfile buf->b_ml.ml_line_count = 0; // no lines in buffer + mc_buf_clear(buf); // Tracked mc positions died with the text. // Ensure marks are adjusted for cleared buffer in case buffer not on disk: // if it is reloaded the buffer will be empty. @@ -1020,6 +1023,7 @@ static void free_buffer_stuff(buf_T *buf, int free_flags) } uc_clear(&buf->b_ucmds); // clear local user commands extmark_free_all(buf); // delete any extmarks + mc_buf_free(buf); // Multicursors died with their extmarks. map_clear_mode(buf, MAP_ALL_MODES, true, false); // clear local mappings map_clear_mode(buf, MAP_ALL_MODES, true, true); // clear local abbrevs XFREE_CLEAR(buf->b_start_fenc); diff --git a/src/nvim/context.c b/src/nvim/context.c index a61ed3ad9e..fbb0c3598b 100644 --- a/src/nvim/context.c +++ b/src/nvim/context.c @@ -11,6 +11,7 @@ // Related: // - vim.with() // - switch_option_context(), restore_option_context() +// - McSandbox: input-replay guard. Sibling axis to ctx_switch() and ctx_save(). #include #include diff --git a/src/nvim/context_defs.h b/src/nvim/context_defs.h index 6e34ba8ec6..c225ff09a6 100644 --- a/src/nvim/context_defs.h +++ b/src/nvim/context_defs.h @@ -22,7 +22,7 @@ typedef struct { } Context; typedef kvec_t(Context) ContextVec; -#define CONTEXT_INIT (Context) { \ +#define CONTEXT_INIT { \ .pos = { 0 }, \ .mark = 0, \ .curswant = -1, \ diff --git a/src/nvim/extmark.c b/src/nvim/extmark.c index 52c56f557b..8841a2eeab 100644 --- a/src/nvim/extmark.c +++ b/src/nvim/extmark.c @@ -38,6 +38,7 @@ #include "nvim/globals.h" #include "nvim/map_defs.h" #include "nvim/marktree.h" +#include "nvim/mcursor.h" #include "nvim/memline.h" #include "nvim/memory.h" #include "nvim/pos_defs.h" @@ -251,6 +252,9 @@ bool extmark_clear(buf_T *buf, uint32_t ns_id, int l_row, colnr_T l_col, int u_r return false; } + // Multicursor extmarks are about to die, let mc snapshot them. + mc_ns_clearing(buf, ns_id); + bool all_ns = (ns_id == 0); uint32_t *ns = NULL; if (!all_ns) { @@ -295,6 +299,9 @@ bool extmark_clear(buf_T *buf, uint32_t ns_id, int l_row, colnr_T l_col, int u_r if (marks_cleared_any) { decor_state_invalidate(buf); + // Deleting the "nvim.multicursor" namespace deletes the cursors it tracked. + // TODO(justinmk): ideally, clearing a ns could be handled in userspace, e.g. an event? + mc_ns_cleared(buf, ns_id); } return marks_cleared_any; diff --git a/src/nvim/fileio.c b/src/nvim/fileio.c index 7fe4b0996b..0b04f0302c 100644 --- a/src/nvim/fileio.c +++ b/src/nvim/fileio.c @@ -47,6 +47,7 @@ #include "nvim/macros_defs.h" #include "nvim/mbyte.h" #include "nvim/mbyte_defs.h" +#include "nvim/mcursor.h" #include "nvim/memfile.h" #include "nvim/memfile_defs.h" #include "nvim/memline.h" @@ -3168,6 +3169,8 @@ void buf_reload(buf_T *buf, int orig_mode, bool reload_options) // Set curwin/curbuf for "buf" and save some things. ctx_switch(&aco, NULL, NULL, buf, 0); + mc_buf_clear(buf); // Multicursor: file-reload invalidates the extmarks. + // Unless reload_options is set, we only want to read the text from the // file, not reset the syntax highlighting, clear marks, diff status, etc. // Force the fileformat and encoding to be the same. diff --git a/src/nvim/highlight_group.c b/src/nvim/highlight_group.c index e59fc5cda3..4f0da40391 100644 --- a/src/nvim/highlight_group.c +++ b/src/nvim/highlight_group.c @@ -183,6 +183,8 @@ static const char *highlight_init_both[] = { "default link ComplMatchIns NONE", "default link ComplHint NonText", "default link ComplHintMore MoreMsg", + "default link MCursor Cursor", + "default link MCursorVisual Visual", "default link Substitute Search", "default link StatusLineTerm StatusLine", "default link StatusLineTermNC StatusLineNC", diff --git a/src/nvim/input_cmdatom.c b/src/nvim/input_cmdatom.c index c02f6fa85a..01129076d8 100644 --- a/src/nvim/input_cmdatom.c +++ b/src/nvim/input_cmdatom.c @@ -30,6 +30,7 @@ #include "nvim/macros_defs.h" #include "nvim/mapping.h" #include "nvim/mbyte.h" +#include "nvim/mcursor.h" #include "nvim/memory.h" #include "nvim/normal.h" #include "nvim/normal_defs.h" @@ -44,32 +45,12 @@ #include "input_cmdatom.c.generated.h" -static bool mc_replaying(void) -{ - return false; -} - -static void mc_vsel_refresh(void) -{ -} - -static void mc_vsel_clear(void) -{ -} - -static bool mc_following(void) -{ - return false; -} - -static void mc_clock_edge(bool map_edit) -{ -} - CmdAtomVec g_atoms = KV_INITIAL_VALUE; /// Capture clock: ticks on any kind of capture (atom push, Visual subatom). Used to answer "was /// anything captured during this command (including its nested frames)?". static uint64_t atom_captures = 0; +/// "Cursor-global" op (undo, "g CTRL-A"), possibly in a nested frame. Command must not cascade. +static uint64_t global_ops = 0; /// Suppresses atom pushes. static bool atom_suppressed = false; /// Mapping edited the buffer, or its insert-session cascaded: cascades as one unit, incl. motions. @@ -120,7 +101,6 @@ static struct { char *cmdline; ///< The ":" payload captured at cmdline accept. NULL: none. ///< Note: search payloads ("/pat") travel on `cmdarg.searchbuf`. bool ins_cascaded; ///< Did the command's insert-session already cascade? - bool op_global; ///< Already applied to every cursor (undo, "g CTRL-A"): must not cascade. } curcmd; /// Interactively typed keys of the executing command. Collected during a composite (its `lhs` @@ -238,6 +218,7 @@ pos_T atom_origin_pos(buf_T *buf) /// @return Allocated key sequence. static char *atom_redo_keys(CmdSpec spec) { + assert(!mc_replaying()); // Captured material is never re-captured. char *keys = redo_keys(&spec).data; assert(keys != NULL); // A spec with no chars/count/reg composes to nothing. return keys; @@ -468,7 +449,7 @@ static void atom_push(bool cascade, CmdAtom *atom) atom_free(atom); return; } - atom_push_raw(cascade, atom); + atom_push_raw(cascade && mc_buf_has_cursors(curbuf), atom); } /// Stages an atom built before its command executes (do_pending_operator() prep-exempt, Visual @@ -561,21 +542,15 @@ static void atom_composite_end(void) char *lhs = atom_composite_lhs(); XFREE_CLEAR(composite.lhs); CmdAtom atom; - if (kv_size(composite.atoms) == 0) { - // The mapping's commands captured nothing (Ex/Lua commands, no-ops): but it is still a user - // action, so emit it with empty keys (identified by `lhs`). - atom = (CmdAtom){ .type = kAMapping, .keys = xstrdup(""), .lhs = lhs, .remap = remap, - .origin = composite.origin, - .changed = atom_origin_changed(composite.origin), - .moved = atom_origin_moved(composite.origin), - .undoseq = atom_origin_undoseq(composite.origin) }; - } else if (kv_size(composite.atoms) == 1) { + if (kv_size(composite.atoms) == 1) { // Single subatom. "Unwrap" it so e.g. a motion mapping reports kAMotion, not kAMapping. atom = kv_pop(composite.atoms); xfree(atom.lhs); atom.lhs = lhs; atom.remap = remap; } else { + // Zero subatoms (captured nothing (Ex/Lua, no-op); still a user action, identified by `lhs`), + // or multiple subatoms. atom = (CmdAtom){ .type = kAMapping, .keys = atoms_concat_keys(composite.atoms).data, .lhs = lhs, .remap = remap, .origin = composite.origin, .changed = atom_origin_changed(composite.origin), @@ -612,7 +587,8 @@ void atom_term_enter(void) bool atom_is_user_cmd(void) { // reg_executing is already reset for a macro's LAST command (its trailing "x" stuffs "dl"). - return ((reg_executing == 0 && !pending_end_reg_executing) || composite.macro) + return !mc_replaying() + && ((reg_executing == 0 && !pending_end_reg_executing) || composite.macro) && ex_normal_busy == 0; } @@ -646,13 +622,13 @@ static bool atom_capturable(bool consumers, bool keytyped) /// True if anything consumes atoms from `curbuf`. For performance: skip capture if no consumers. static bool atom_buf_has_consumers(void) { - return has_event(EVENT_CMDATOM); + return mc_buf_has_cursors(curbuf) || has_event(EVENT_CMDATOM); } /// XXX: Checks consumers for ANY buffer: a mapping/macro may navigate into a buffer w/ cursors... static bool atom_has_consumers(void) { - return has_event(EVENT_CMDATOM); + return mc_count() > 0 || has_event(EVENT_CMDATOM); } /// Classifies key/command `cmd` (`arg` is its argument char, for two-char commands like "g;"). @@ -742,12 +718,11 @@ unsigned atom_key_class(int cmd, int arg) /// Captures an accepted ":" or "" cmdline payload. void atom_cmdline_set(int firstc, const char *line, size_t len) { - // Not for nested cmdlines opened by a command's own execution (":normal", macros): they would - // overwrite the user command's payload, e.g. `:exe "normal! :echo 1\r"`. - if (!atom_is_user_cmd() || (firstc != ':' && firstc != K_COMMAND)) { + // Not for nested cmdlines (":norm", macros), nor a second accept (":put ." => ":put _" + // translation): the first accept is the payload. + if (!atom_is_user_cmd() || (firstc != ':' && firstc != K_COMMAND) || curcmd.cmdline != NULL) { return; } - xfree(curcmd.cmdline); curcmd.cmdline = xmemdupz(line, len); } @@ -834,11 +809,12 @@ static void atom_redo_reset(void) } } -/// Marks the running command as already applying to every cursor (see `curcmd.op_global`). -/// Called by u_doit() and mc_counter(). -void atom_op_global_set(void) +/// Flags the running command as already applying to every cursor. E.g.: undo, "g CTRL-A" +void atom_did_global_op(void) { - curcmd.op_global = true; + if (!mc_replaying()) { + global_ops++; + } } /// Declares that the current frame prepped redo. Not for nested frames (":norm"). @@ -885,7 +861,7 @@ void atom_stuff_start(const cmdarg_T *cap) /// @param peeked Resolved by a peek: the executing command did not consume the mapping's keys. void atom_map_start(const char *lhs, size_t len, bool peeked) { - if (!atom_has_consumers() + if (!atom_has_consumers() || mc_replaying() || reg_executing != 0 || ex_normal_busy != 0 || !(State & MODE_NORMAL) || Visual.active) { return; @@ -1030,6 +1006,9 @@ static bool atom_visual_end_suffix(char *suffix, const CmdSpec *spec, bool redoa /// @return True if redo was prepped. bool atom_visual_end(CmdSpec spec, bool redoable) { + if (mc_replaying()) { + return false; + } return atom_visual_end_suffix(atom_redo_keys(spec), &spec, redoable); } @@ -1176,10 +1155,10 @@ InsSession atom_ins_start(int cmd, long count, VisualIns vis, bool vblock) // The selection is consumed: already in the redo body. Also clears selection display. atom_visual_reset(); } - // bool repl = cmd == 'R' || cmd == 'V' || cmd == 'r' || cmd == 'v'; - // mc_ins_cascade_start(session.typed && count <= 1 && !repl - // && (vis == kVInsNone || (vis == kVInsKeys && !vblock)), - // session.origin.tick); + bool repl = cmd == 'R' || cmd == 'V' || cmd == 'r' || cmd == 'v'; + mc_ins_cascade_start(session.typed && count <= 1 && !repl + && (vis == kVInsNone || (vis == kVInsKeys && !vblock)), + session.origin.tick); return session; } @@ -1193,6 +1172,17 @@ void atom_ins_end(const InsSession *session, bool busy) bool user_input = session->typed // A session is user input, if user input occurred during it. #41516 || maptick != session->origin.maptick; + if (mc_replaying()) { + return; + } + if (mc_ins_commit()) { + curcmd.ins_cascaded = true; + // Not during a mapping: there the spans are subatoms of its composite. + if (has_event(EVENT_CMDATOM) && !atom_composite_active()) { + atom_ins_push(session, false); + } + return; + } if (!user_input || busy || restart_edit != 0 || !atom_buf_has_consumers() || (visual && session->vis != kVInsKeys)) { if (user_input && (busy || restart_edit != 0) && atom_composite_active()) { @@ -1201,7 +1191,7 @@ void atom_ins_end(const InsSession *session, bool busy) } return; } - atom_ins_push(session, true); + atom_ins_push(session, mc_buf_has_cursors(curbuf)); } /// Pushes the ended insert-session as one atom. Skips a session not ending in , except @@ -1231,9 +1221,10 @@ void atom_cmd_start(CmdFrame *old) old->visual = Visual; old->keytyped = KeyTyped; old->captures = atom_captures; + old->global_ops = global_ops; old->id = ++frame_id; // Sampled: "q=" toggled DURING a command must not apply to it retroactively. - old->follow = false; + old->follow = mc_following(); old->consumers = atom_buf_has_consumers(); // Diffed at command end: detects a register-write (yank). old->reg_ts = old->consumers ? reg_max_ts(true) : 0; @@ -1242,7 +1233,6 @@ void atom_cmd_start(CmdFrame *old) old->payload_end = 0; old->parent = cur_frame; cur_frame = old; - curcmd.op_global = false; atom_redo_reset(); } @@ -1285,8 +1275,8 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old) // Command from a mapping's RHS (typed keys have KeyTyped set). bool mapped = user && !old->keytyped && !synthetic; if (mapped - // NOT if cursor-global op: cascading (e.g. vim-repeat "nmap u") would undo at every cursor. - && !curcmd.op_global + // NOT if cursor-global op (cascading per-cursor would be nonsense). + && global_ops == old->global_ops // NOT if it ended in another buffer: a navigation mapping ("nnoremap l") // entering a buffer with cursors must not cascade. && ((curbuf == old->origin.buf.br_buf && atom_origin_changed(old->origin)) || ins_cascaded)) { @@ -1346,11 +1336,10 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old) bool scroll_cmd = (keycls & (kKeyScrollMove | kKeyScrollView)) != 0; bool mouse_cmd = (keycls & kKeyMouse) != 0; bool jump_cmd = (keycls & kKeyJump) != 0; - // Replayable? Register prefix ('"x') is captured as part of the command it prefixes; "@x"/"Q" - // are translations, their resolution is the atom stream. + // Replayable? Register prefix ('"x') is captured as part of the command it prefixes; "@x" is + // a translation, its resolution is the atom stream. bool replayable = (ca->cmdchar > 0 && ca->cmdchar < 0x100 - && ca->cmdchar != '"' && ca->cmdchar != '@' && ca->cmdchar != 'Q' - && !scroll_cmd) + && ca->cmdchar != '"' && ca->cmdchar != '@' && !scroll_cmd) || special_motion; bool changed = atom_origin_changed(old->origin); // Note: an operator's motion belongs to the operator (`finish_op`). diff --git a/src/nvim/input_cmdatom.h b/src/nvim/input_cmdatom.h index 4bfb077dc2..9e6a49e74c 100644 --- a/src/nvim/input_cmdatom.h +++ b/src/nvim/input_cmdatom.h @@ -20,6 +20,7 @@ struct CmdFrame { VisualState visual; ///< Visual-mode state (active/start/mode are diffed). bool keytyped; ///< KeyTyped uint64_t captures; ///< Capture counter. + uint64_t global_ops; ///< `global_ops` at entry. uint64_t id; ///< Identifies this frame (see `composite.frame`). bool follow; ///< mc_following() ("q=") bool consumers; ///< Capture is skipped if there are no consumers (for performance). diff --git a/src/nvim/input_cmdatom_defs.h b/src/nvim/input_cmdatom_defs.h index 7347adaae3..5a6bfced4e 100644 --- a/src/nvim/input_cmdatom_defs.h +++ b/src/nvim/input_cmdatom_defs.h @@ -8,10 +8,9 @@ #include "nvim/input_defs.h" // Concepts (see :help dev-cmdatom): -// - atom, composite (atom with subatoms) -// - insert-session -// - INSERTION -// - span +// - atom, span, composite (atom with subatoms) +// - insert-session, insertion +// - payload // - replay, cascade, insert-cascade typedef enum CmdAtomType { @@ -57,14 +56,14 @@ typedef struct { typedef struct CmdAtom CmdAtom; typedef kvec_t(CmdAtom) CmdAtomVec; -/// One repeatable operation. `keys` is the replay payload; `spec` is the structured form. +/// One repeatable operation. `keys` is the replay bytes; `spec` is the structured form. struct CmdAtom { CmdSpec spec; ///< Structured fields. CmdAtomVec atoms; ///< Composite (multi-command mapping, Visual sequence): its subatoms, ///< in order; their keys concatenate to `keys`. Empty for non-composite. char *keys; ///< Resolved keysequence (typeahead encoding), including `["x][count]` prefix ///< (unlike `CmdSpec.body`, the raw unprefixed form). - char *text; ///< Payload: insert-session text, or Ex/search cmdline. + char *text; ///< Insert-session text, or Ex/search cmdline payload. char *lhs; ///< Unresolved user input: mapping LHS or macro register ("@q"), or Visual op. ///< Label/hint, not replayed. NULL: untranslated, same as `keys`. CmdOrigin origin; ///< Pre-command state. diff --git a/src/nvim/insert.c b/src/nvim/insert.c index 475f154fa8..4f9d75540a 100644 --- a/src/nvim/insert.c +++ b/src/nvim/insert.c @@ -53,6 +53,7 @@ #include "nvim/marktree_defs.h" #include "nvim/mbyte.h" #include "nvim/mbyte_defs.h" +#include "nvim/mcursor.h" #include "nvim/memline.h" #include "nvim/memline_defs.h" #include "nvim/memory.h" @@ -381,6 +382,9 @@ static int insert_check(VimState *state) { InsertState *s = (InsertState *)state; + // Multicursor: insert-cascade, before entry ("A"/"o"/"cw"/…), and after every executed key. + mc_ins_cascade(); + if (!Ins.revins_legal) { Ins.revins_scol = -1; // reset on illegal motions } else { @@ -2240,6 +2244,7 @@ int stop_arrow(void) // The count is a spec field (not body bytes), so "[count]." replaces it ("3i…"). prep_redo(false, false, (CmdSpec){ .count = 1, .cmd = 'i' }); Ins.new_insert_skip = 2; + mc_ins_cascade_restart(); } else { // Cursor-move was captured (start_arrow()): the atom mc-cascade will replay it. // Only `last_insert` (the ". register, i_CTRL-A) restarts here, like Vim. @@ -2332,12 +2337,11 @@ static void stop_insert(pos_T *end_insert_pos, int esc, int nomove) // If a space was inserted for auto-formatting, remove it now. check_auto_format(true); - // If we just did an auto-indent, remove the white space from the end - // of the line, and put the cursor back. + // If we just did an auto-indent, remove the whitespace from EOL, and put the cursor back. // Do this when ESC was used or moving the cursor up/down. - // Check for the old position still being valid, just in case the text - // got changed unexpectedly. - if (!nomove && Ins.did_ai + // Check for the old position still being valid, just in case the text changed unexpectedly. + // Not for span replay during a mc-session: its synthetic ends the nested session early. + if (!nomove && Ins.did_ai && !mc_ins_replaying() && (esc || (vim_strchr(p_cpo, kCpoIndent) == NULL && curwin->w_cursor.lnum != end_insert_pos->lnum)) && end_insert_pos->lnum <= curbuf->b_ml.ml_line_count) { @@ -3361,6 +3365,7 @@ static void ins_del(void) || do_join(2, false, true, false, false) == FAIL) { vim_beep(kOptBoFlagBackspace); } else { + mc_ins_join(); curwin->w_cursor.col = temp; // Adjust orig_line_count in case more lines have been deleted than // have been added. That makes sure, that open_line() later @@ -3443,6 +3448,7 @@ static bool ins_bs(int c, int mode, int *inserted_space_p) // Delete newline! if (curwin->w_cursor.col == 0) { + mc_ins_join(); linenr_T lnum = Ins.start.lnum; if (curwin->w_cursor.lnum == lnum || Ins.revins_on) { if (u_save((linenr_T)(curwin->w_cursor.lnum - 2), diff --git a/src/nvim/insert_defs.h b/src/nvim/insert_defs.h index 2a35342b1c..b84a0fdaef 100644 --- a/src/nvim/insert_defs.h +++ b/src/nvim/insert_defs.h @@ -13,8 +13,8 @@ typedef enum { kInsJump, ///< Non-replayable jump (mouse, , …): atom terminated (). } InsArrow; -/// Insert-mode session state: the in-progress insert session, as one global "group" (Ins), so the -/// insert session can be saved/restored as a whole around nested edit() sessions. +/// Insert-mode session state: the in-progress insert session, as one global "group" (Ins), so it +/// can be saved/restored around nested edit() sessions (mc_ins_save_state). typedef struct { pos_T start; ///< Start of current INSERTION: from here to cursor is the unit that ///< undo, '[ and ". treat as one. Reanchored after cursor-move, C-G u. diff --git a/src/nvim/mcursor.c b/src/nvim/mcursor.c new file mode 100644 index 0000000000..5e8f0f1734 --- /dev/null +++ b/src/nvim/mcursor.c @@ -0,0 +1,1369 @@ +// Multicursor. Cursors are Context snapshots (mc_cursors) tracked by extmarks; user actions +// (CmdAtoms) are replayed at each cursor at the "clock edge" (the cascade). See dev_arch.txt. + +#include +#include +#include +#include +#include +#include + +#include "klib/kvec.h" +#include "nvim/api/buffer.h" +#include "nvim/api/extmark.h" +#include "nvim/api/private/defs.h" +#include "nvim/api/private/helpers.h" +#include "nvim/api/vim.h" +#include "nvim/ascii_defs.h" +#include "nvim/autocmd.h" +#include "nvim/buffer.h" +#include "nvim/buffer_defs.h" +#include "nvim/charset.h" +#include "nvim/clipboard.h" +#include "nvim/context.h" +#include "nvim/cursor.h" +#include "nvim/decoration.h" +#include "nvim/drawscreen.h" +#include "nvim/eval/typval_defs.h" +#include "nvim/ex_docmd.h" +#include "nvim/extmark.h" +#include "nvim/gettext_defs.h" +#include "nvim/globals.h" +#include "nvim/highlight_group.h" +#include "nvim/input.h" +#include "nvim/input_cmdatom.h" +#include "nvim/insert.h" +#include "nvim/keycodes.h" +#include "nvim/log.h" +#include "nvim/lua/executor.h" +#include "nvim/macros_defs.h" +#include "nvim/mark.h" +#include "nvim/marktree.h" +#include "nvim/mbyte.h" +#include "nvim/mcursor.h" +#include "nvim/memline.h" +#include "nvim/memory.h" +#include "nvim/message.h" +#include "nvim/move.h" +#include "nvim/normal.h" +#include "nvim/ops.h" +#include "nvim/option_vars.h" +#include "nvim/os/input.h" +#include "nvim/os/time.h" +#include "nvim/plines.h" +#include "nvim/pos_defs.h" +#include "nvim/register.h" +#include "nvim/register_defs.h" +#include "nvim/search.h" +#include "nvim/shada.h" +#include "nvim/state.h" +#include "nvim/state_defs.h" +#include "nvim/strings.h" +#include "nvim/types_defs.h" +#include "nvim/ui.h" +#include "nvim/undo.h" +#include "nvim/vim_defs.h" + +/// Primary-cursor state saved across a replay sandbox. +typedef struct { + save_state_T sst; ///< mode, typeahead, reg_executing, … + RedoState redo; + Context regs; ///< Registers. + handle_T bufnr; ///< buffer at enter (revalidated on leave: autocmds may wipe it) + pos_T cursor; + colnr_T curswant; + bool set_curswant; + colnr_T leftcol; + uint32_t cursor_mark; ///< extmark tracking `cursor` across replay edits + uint32_t topline_mark; ///< extmark tracking `topline` across replay edits + pos_T topline; + VisualState visual; + pos_T op_start; ///< b_op_start: change marks belong to the primary's operation + pos_T op_end; + fmark_T last_change; + visualinfo_T bvisual; + int bvisual_mode; +} McSandbox; + +/// Primary cursor's insert-session, saved across nested edit() sessions during insert-cascade. +typedef struct { + InsState ins; + varnumber_T last_changedtick; + varnumber_T last_changedtick_i; +} McInsSaved; + +#include "mcursor.c.generated.h" + +/// Insert-cascade state. +/// +/// PREVIEW/COMMIT model: the entry command ("A"/"cw") replays at each cursor on insert enter; +/// while the user types, the primary's inserted text (`region` extmark) previews as literal text +/// at each cursor; at session-end the previews are deleted and typed keys (batch) replay at each +/// cursor (the COMMIT: abbreviations, 'textwidth', … re-execute per cursor). +/// +/// XXX: Why text preview instead of LHS-replay? Two things cannot LHS-replay: +/// 1. ins-completion +/// 2. pending keys are not an append-only stream (compl/abbrevs rewrite them mid-session) +/// +/// The boundary: +/// - literal text is previewed +/// - non-literal keys (mc_ins_keys_nonliteral) have per-cursor effects, so flush (early commit). +static struct { + bool active; ///< Current insert-session is cascading. + bool first; ///< Entry-command not yet cascaded (no span pushed yet). + size_t done_len; ///< Bytes of the capture already consumed by replayed spans; tail is pending. + varnumber_T tick; ///< b:changedtick at session start: the session's atoms (spans, the whole + ///< session) diff against it for their `changed` field. + uint32_t region; ///< Extmark (pair) tracking the primary's inserted text. + kvec_t(uint32_t) regions; ///< Per-cursor extmarks (pairs, `mc_session_ns()`) tracking each + ///< cursor's preview region (anchor .. preview end). +} mc_ins_span; + +/// Editor state when the mc session started, which every cursor replays against. +static struct { + Timestamp time; ///< When the session started (nanoseconds). + Context regs; ///< Registers. Perf: per-cursor registers are "sparse", merged later. +} mc_start = { .regs = CONTEXT_INIT }; + +/// Registers that can carry per-cursor values (skips the read-only/special ones). +static const char MC_REGS[] = "abcdefghijklmnopqrstuvwxyz0123456789\"-"; + +/// Buffer holding the fake Visual selections (0: none). +static handle_T mc_vsel_buf; +/// Primary cursor's current insert span deleted a line break (BS/CTRL-U at col 0). +static bool mc_ins_joined; +/// The mcursors, in creation order. Each is a Context snapshot: an extmark-tracked position +/// plus editor state (registers) scoped to that cursor. +static ContextVec mc_cursors = KV_INITIAL_VALUE; +/// Replay is in progress: keys re-executing internally, hooks suppressed. +static bool mc_replay = false; +/// "Follow motion" mode ("q="): cascade primary-cursor motions to all mcursors. +static bool mc_follow_motion = false; + +/// Namespace for tracking multicursor positions. +static uint32_t mc_ns(void) +{ + static uint32_t ns = 0; + if (ns == 0) { + ns = (uint32_t)nvim_create_namespace(STATIC_CSTR_AS_STRING("nvim.multicursor")); + } + return ns; +} + +/// Namespace for the selection-end cursors. While they exist they are the display positions; +/// "nvim.multicursor" holds the selection anchors. +static uint32_t mc_vcur_ns(void) +{ + static uint32_t ns = 0; + if (ns == 0) { + ns = (uint32_t)nvim_create_namespace(STATIC_CSTR_AS_STRING("nvim.multicursor.cursor")); + } + return ns; +} + +/// Namespace for the fake Visual selections. +static uint32_t mc_vsel_ns(void) +{ + static uint32_t ns = 0; + if (ns == 0) { + ns = (uint32_t)nvim_create_namespace(STATIC_CSTR_AS_STRING("nvim.multicursor.visual")); + } + return ns; +} + +/// Namespace for the previous session's cursor positions, snapshotted on clear ("gQ" restores). +static uint32_t mc_last_ns(void) +{ + static uint32_t ns = 0; + if (ns == 0) { + ns = (uint32_t)nvim_create_namespace(STATIC_CSTR_AS_STRING("nvim.multicursor.last")); + } + return ns; +} + +/// Namespace for session-internal marks (live text region, transient primary-cursor tracker). +static uint32_t mc_session_ns(void) +{ + static uint32_t ns = 0; + if (ns == 0) { + ns = (uint32_t)nvim_create_namespace(STATIC_CSTR_AS_STRING("nvim.multicursor._session")); + } + return ns; +} + +/// Number of mcursors, excluding the primary cursor. 0: no multicursor session. +size_t mc_count(void) +{ + return kv_size(mc_cursors); +} + +/// Formats the multicursor 'showcmd' indicator ("=3× ") into `buf`. +/// +/// @return Length in bytes; 0 (buf="") if there are no cursors. +size_t mc_showcmd(char *buf, size_t size) +{ + if (kv_size(mc_cursors) == 0) { + buf[0] = NUL; + return 0; + } + return (size_t)snprintf(buf, size, "%s%zu× ", mc_follow_motion ? "=" : "", kv_size(mc_cursors)); +} + +/// True while a replay runs: keys executing are fed back by Nvim itself, not new user input. +bool mc_replaying(void) +{ + return mc_replay; +} + +/// Sets an extmark which tracks a position. Decor handled by mcursor.lua. TODO(justinmk): #41576 +/// +/// @param watched Decorated (ui_watched): UIs receive the position per redraw (ui-event +/// "win_extmark"), to draw the cursors themselves. +/// @param right_gravity The mark moves with its text when an insert lands exactly at its +/// position (e.g. "o" on the line above). +/// @param no_undo Transient mark, undo must not restore it (a restored right-gravity mark +/// drifts when undo re-inserts text at its position). +static void mc_point_mark_set(buf_T *buf, uint32_t ns, uint32_t *mark, pos_T pos, bool watched, + bool right_gravity, bool no_undo) +{ + DecorInline decor = DECOR_INLINE_INIT; + if (watched) { + decor.data.hl.flags = kSHUIWatched | kSHUIWatchedOverlay; + } + extmark_set(buf, ns, mark, (int)pos.lnum - 1, pos.col, (int)pos.lnum - 1, pos.col + 1, + decor, watched ? MT_FLAG_DECOR_HL : 0, right_gravity, false, no_undo, false, NULL); +} + +/// Creates or updates the extmark tracking a multicursor position. +static void mc_mark_upd(buf_T *buf, uint32_t *mark, pos_T pos) +{ + mc_point_mark_set(buf, mc_ns(), mark, pos, true, true, false); +} + +/// Gets the position tracked by `mark`, adjusted for buffer edits since the mark was set. +/// +/// @return false if the mark no longer exists. +static bool mc_mark_get(buf_T *buf, uint32_t ns, uint32_t mark, pos_T *pos) +{ + MTPair mtp = extmark_from_id(buf, ns, mark); + if (mtp.start.id == 0) { + return false; + } + pos->lnum = mtp.start.pos.row + 1; + pos->col = mtp.start.pos.col; + return true; +} + +/// Session-internal variant of mc_mark_upd(): tracks a transient position (primary cursor/topline +/// across a cascade, the preview-apply cursor). +static void mc_track_upd(buf_T *buf, uint32_t *mark, pos_T pos) +{ + mc_point_mark_set(buf, mc_session_ns(), mark, pos, false, true, true); +} + +/// Enters a replay sandbox, so keys fed (cascaded) per-cursor do not modify pending typeahead nor +/// the primary-cursor state. +/// +/// @param save_regs Registers too. Perf: skipped for pure motions. +static void mc_sandbox_enter(McSandbox *sb, bool save_regs) +{ + assert(!mc_replay); + mc_replay = true; // Capture hooks will ignore keys fed during the sandbox. + sb->bufnr = curbuf->handle; + sb->cursor = curwin->w_cursor; + sb->curswant = curwin->w_curswant; + sb->set_curswant = curwin->w_set_curswant; + sb->leftcol = curwin->w_leftcol; + sb->cursor_mark = 0; + mc_track_upd(curbuf, &sb->cursor_mark, sb->cursor); + sb->topline = (pos_T){ .lnum = curwin->w_topline, .col = 0 }; + sb->topline_mark = 0; + mc_track_upd(curbuf, &sb->topline_mark, sb->topline); + sb->visual = Visual; + // Change/Visual marks belong to the primary cursor's (already executed) operation. + // (jumplist/changelist are protected per replay, via CMOD_KEEPJUMPS.) + sb->op_start = curbuf->b_op_start; + sb->op_end = curbuf->b_op_end; + sb->last_change = curbuf->b_last_change; + sb->bvisual = curbuf->b_visual; + sb->bvisual_mode = curbuf->b_visual_mode_eval; + save_current_state(&sb->sst); // hint: see call_user_func() + save_search_patterns(); + save_redobuff(&sb->redo); + sb->regs = (Context)CONTEXT_INIT; + if (save_regs) { + ctx_save(&sb->regs, kCtxRegs); + } +} + +/// @see mc_sandbox_enter +static void mc_sandbox_leave(McSandbox *sb) +{ + if (sb->regs.regs.data != NULL) { + ctx_load(&sb->regs, kCtxRegs, 0); + } + ctx_free(&sb->regs); + restore_redobuff(&sb->redo); + restore_search_patterns(); + restore_current_state(&sb->sst); + // Visual before the cursor restore: check_cursor() below must see the restored mode (a + // blockwise 'virtualedit' selection would otherwise lose the cursor's coladd). + Visual = sb->visual; + buf_T *buf = handle_get_buffer(sb->bufnr); + bool topline_valid = false; + if (buf != NULL) { + // mc_mark_get() updates lnum/col only, an unshifted position keeps its coladd. + mc_mark_get(buf, mc_session_ns(), sb->cursor_mark, &sb->cursor); + extmark_del_id(buf, mc_session_ns(), sb->cursor_mark); + topline_valid = mc_mark_get(buf, mc_session_ns(), sb->topline_mark, &sb->topline); + extmark_del_id(buf, mc_session_ns(), sb->topline_mark); + } + if (buf == curbuf) { + curbuf->b_op_start = sb->op_start; + curbuf->b_op_end = sb->op_end; + curbuf->b_last_change = sb->last_change; + curbuf->b_visual = sb->bvisual; + curbuf->b_visual_mode_eval = sb->bvisual_mode; + curwin->w_cursor = sb->cursor; + check_cursor(curwin); + curwin->w_curswant = sb->curswant; + curwin->w_set_curswant = sb->set_curswant; + if (topline_valid) { + set_topline(curwin, MIN(sb->topline.lnum, curbuf->b_ml.ml_line_count)); + // Scroll (minimally) if an edit moved the primary cursor off-view. + update_topline(curwin); + } + curwin->w_leftcol = sb->leftcol; + } + mc_replay = false; +} + +/// Cascade step: replays an atom at `cursoridx`, then updates Context. +static void mc_execute(size_t cursoridx, size_t atomidx) +{ + Context ctx = kv_A(mc_cursors, cursoridx); + CmdAtom atom = kv_A(g_atoms, atomidx); + + if (handle_get_buffer(ctx.buf) != curbuf) { + // Cursors cascade only if their buffer is the current buffer. + return; + } + + // 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()). + return; + } + + curwin->w_cursor = ctx.pos; + // Edits by other cursors may have invalidated this cursor's position. + if (atom.type == kAInsertSpan) { + // The anchor may be one past EOL (the insertion point); edit() accepts that. + // Not check_cursor(): would clamp onto last char (a previous span may have set MODE_NORMAL). + check_pos(curbuf, &curwin->w_cursor); + } else { + check_cursor(curwin); + } + // Per-cursor `curswant`: vertical motions over short lines must not inherit the primary's column. + // Unset: derive from the position, like a new cursor. + if (ctx.curswant >= 0) { + curwin->w_curswant = ctx.curswant; + curwin->w_set_curswant = false; + } else { + curwin->w_set_curswant = true; + } + + // Perf: skip register serialization for motions, so "l" in "follow mode" is fast. + const bool swap_regs = atom.type != kAMotion; + Timestamp regs_ts = 0; + if (swap_regs) { + if (reg_max_ts(false) >= mc_start.time) { + // Registers written globally are the previous cursor's; reset to baseline. + ctx_load(&mc_start.regs, kCtxRegs, 0); + } + if (ctx.regs.data != NULL) { + ctx_load(&ctx, kCtxRegs, kCtxMergeReg); // This cursor's own writes; merge w/ baseline. + } + regs_ts = reg_max_ts(false); + } + const int save_cmod_flags = cmdmod.cmod_flags; + if (atom.type == kAInsertSpan) { + // exec_normal() clamps a past-EOL cursor via check_cursor() unless Insert-mode; a previous span + // replay may have left MODE_NORMAL, making later cursors insert one char left of their anchor. + State = MODE_INSERT; + } else { + // Navigation state belongs to the primary, replays must not touch it. Not for insert-span + // replays: their anchor needs '^, and the primary insert-session (re)sets these anyway. + cmdmod.cmod_flags |= CMOD_KEEPJUMPS; + } + + // Replay the atom using wholesome, tasty feedkeys. Usually noremap ("nix"), but `atom.remap=true` + // means we must replay LHS (re-run the mapping at cursor, e.g. vim-surround "ds'"). + nvim_feedkeys(cstr_as_string(atom.keys), cstr_as_string(atom.remap ? "ix" : "nix"), false); + cmdmod.cmod_flags = save_cmod_flags; + + // A failed command flushes remaining keys (beep_flush()), which can eat a visual atom's + // terminating /operator; end the leaked Visual mode. + if (Visual.active) { + Visual.active = false; + Visual.select = false; + } + + const bool wrote_regs = swap_regs && reg_max_ts(false) != regs_ts; + + if (cursoridx >= kv_size(mc_cursors)) { + // Cursors were removed while replaying (e.g. gQ via autocmd); already freed. + return; + } + + if (wrote_regs) { + // Re-encode this cursor's registers, as a delta vs mc_start.regs (for performance). + api_free_string(ctx.regs); + ctx.regs = shada_encode_regs(false, mc_start.time); + } + + update_curswant(); + ctx.curswant = curwin->w_curswant; + + if (atom.type == kAInsertSpan && curbuf->b_last_insert.mark.lnum > 0) { + // Anchor at the insertion point ('^ mark): this is where the primary cursor, still in Insert + // mode, displays its cursor, and where the next span continues inserting. + ctx.pos = curbuf->b_last_insert.mark; + } else { + ctx.pos = curwin->w_cursor; + } + // The extmark's decor redraws both the old and the new line (extmark_set()). + mc_mark_upd(curbuf, &ctx.mark, ctx.pos); + kv_A(mc_cursors, cursoridx) = ctx; // Update the cursor info. +} + +/// Runs the cascade: replays queued atoms (g_atoms) at every cursor, as one batch. +static void mc_cascade(void) +{ + assert(kv_size(g_atoms) >= 1); + assert(kv_size(mc_cursors) > 0); + assert(!mc_replaying()); + + // Consume a pending interrupt: CTRL-C already did its job, it should not also abort the replays. + // Note: a new CTRL-C still aborts the cascade; consume BEFORE the dedupe below. + got_int = false; + + // Merge overlapping cursors before replaying an edit. Not for pure-motion cascades ("q=" follow): + // an overlap with the primary is transient, that cursor is about to make the same move. + bool edits = false; + for (size_t i = 0; i < kv_size(g_atoms); i++) { + edits |= kv_A(g_atoms, i).type != kAMotion; + } + if (edits) { + mc_dedupe(); + if (kv_size(mc_cursors) == 0) { + atoms_free(&g_atoms); + return; + } + } + // Optimization: one clipboard-provider sync for the whole cascade. + start_batch_changes(); + McSandbox sb; + mc_sandbox_enter(&sb, edits); + + // Replay each atom at each cursor (nested ":norm! xx" queues multiple atoms per clock edge). + for (size_t ai = 0; ai < kv_size(g_atoms); ai++) { + for (size_t ci = 0; ci < kv_size(mc_cursors); ci++) { + // Replays consume `typebuf` only, so check for CTRL-C in OS/RPC input here. + line_breakcheck(); + if (got_int) { + // Interrupted (CTRL-C), abort the cascade. Keep the partial edit; a "u" will undo it. + goto done; + } + mc_execute(ci, ai); + } + } +done: + atoms_free(&g_atoms); + mc_sandbox_leave(&sb); + end_batch_changes(); + mc_dedupe(); + 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. + curbuf->b_u_newhead->uh_cursor_after = curwin->w_cursor; + } +} + +/// The clock edge: cascades the queued atoms (g_atoms) at every cursor. Called from +/// atom_cmd_end(), at the completion of a toplevel, typed command. +/// +/// @param map_edit A command fed by a mapping edited the buffer (or was insert-cascaded): the +/// whole mapping cascades as one unit, including its motions. +void mc_clock_edge(bool map_edit) +{ + if (map_edit && !atom_composite_queued() && kv_size(g_atoms) == 0 + && atom_composite_active() && mc_buf_has_cursors(curbuf)) { + // A payload mapping (vim-surround "ds'"/"S") edited the buffer via :normal/:call; invisible + // to atom capture, so nothing was queued. Fallback to LHS-replay: re-run the mapping at each + // cursor by replaying its trigger plus the keys its getchar() read (atom_lhs_replay_queue()). + atom_lhs_replay_queue(); + } + if (mc_buf_has_cursors(curbuf) && kv_size(g_atoms) > 0) { + bool has_edit = map_edit; + for (size_t i = 0; !has_edit && i < kv_size(g_atoms); i++) { + has_edit = kv_A(g_atoms, i).type != kAMotion; + } + if (has_edit || mc_follow_motion) { + mc_cascade(); + } else { + // A pure-motion mapping without "q=" follow-motion: do not cascade + // (the atoms are still emitted as one composite CmdAtom). + atoms_free(&g_atoms); + } + } +} + +/// Removes cursors that overlap another cursor, so an edit does not apply N times at one position. +static void mc_dedupe(void) +{ + const bool had_cursors = kv_size(mc_cursors) > 0; + size_t n = 0; + for (size_t i = 0; i < kv_size(mc_cursors); i++) { + Context *ctx = &kv_A(mc_cursors, i); + buf_T *buf = handle_get_buffer(ctx->buf); + if (buf == NULL + || (ctx->mark != 0 && !mc_mark_get(buf, mc_ns(), ctx->mark, &ctx->pos))) { + // Buffer was freed, or the cursor's extmark was deleted. + 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 + && ((curwin != NULL && buf == curbuf && equalpos(ctx->pos, curwin->w_cursor)) + || mc_mark_at(buf, ctx->pos) != ctx->mark); + if (dup) { + extmark_del_id(buf, mc_ns(), ctx->mark); + ctx_free(ctx); + } else { + kv_A(mc_cursors, n) = *ctx; + n++; + } + } + kv_size(mc_cursors) = n; + if (n == 0) { + // Session ended implicitly ("q=" + "G" deduped all cursors). Reset "q=". + mc_follow_motion = false; + if (had_cursors) { + ctx_free(&mc_start.regs); + mc_start.regs = (Context)CONTEXT_INIT; + mc_start.time = 0; + mc_lua_enable(false); + } + } +} + +/// Called when insert-mode backspacing deletes a linebreak. +void mc_ins_join(void) +{ + if (!mc_replaying()) { + mc_ins_joined = true; + } +} + +/// Decides if a replay may delete a linebreak: only if the primary's own span did. A replayed +/// BS/CTRL-U reaching col 0 where the primary's had more to delete must not join, it could collapse +/// other cursors' lines into one. +bool mc_ins_replay_can_join(void) +{ + return !mc_replaying() || mc_ins_joined; +} + +/// Starts an insert-cascade. Call before entering insert mode from a normal-mode command. +/// +/// @param cascade The session qualifies for insert-cascading. +/// @param tick b:changedtick at session start. +void mc_ins_cascade_start(bool cascade, varnumber_T tick) +{ + if (mc_replaying()) { + // Nested replay session: don't clobber the primary session's state. + return; + } + mc_ins_joined = false; + mc_ins_span.active = cascade && mc_buf_has_cursors(curbuf) && kv_size(g_atoms) == 0; + mc_ins_span.first = true; + mc_ins_span.done_len = 0; + mc_ins_span.tick = tick; + mc_ins_span.region = 0; + mc_ins_regions_clear(); +} + +/// True during a span replay. The replay's synthetic does not end the primary insert-session, +/// so session-end cleanup must not run. +bool mc_ins_replaying(void) +{ + return mc_replaying() && mc_ins_span.active; +} + +/// Saves the primary's insert-session state: each span replay runs a nested edit(). +static McInsSaved mc_ins_save_state(void) +{ + McInsSaved saved; + saved.ins = Ins; + // The span replay starts clean, like a new session. + Ins.did_ai = false; + Ins.ai_col = 0; + Ins.end_comment_pending = NUL; + Ins.did_si = false; + Ins.can_si = false; + Ins.can_si_back = false; + // The nested sessions advance these even with autocmds blocked; unrestored, the primary + // session's pending TextChanged(I) would be swallowed (no tick delta left). + saved.last_changedtick = curbuf->b_last_changedtick; + saved.last_changedtick_i = curbuf->b_last_changedtick_i; + return saved; +} + +static void mc_ins_restore_state(const McInsSaved *saved) +{ + Ins = saved->ins; + curbuf->b_last_changedtick = saved->last_changedtick; + curbuf->b_last_changedtick_i = saved->last_changedtick_i; +} + +/// Pushes one span and immediately cascades it. Takes ownership of `keys` and `text`. +static void mc_ins_span_push(char *keys, char *text) +{ + mc_ins_span.first = false; + // If all cursors disappear mid-session (e.g. by dedupe), emit but don't cascade. + bool cascade = mc_buf_has_cursors(curbuf); + atom_push_raw(cascade, &(CmdAtom){ + .type = kAInsertSpan, + .keys = keys, + .text = text, + .changed = buf_get_changedtick(curbuf) != mc_ins_span.tick, + }); + if (!cascade) { + return; + } + McInsSaved saved = mc_ins_save_state(); + block_autocmds(); // The span replay would fire InsertEnter/InsertLeave on every key. + mc_cascade(); + unblock_autocmds(); + mc_ins_restore_state(&saved); + mc_ins_joined = false; // The next span decides whether its replays may join. +} + +/// Deletes the per-cursor preview-region marks. +static void mc_ins_regions_clear(void) +{ + while (kv_size(mc_ins_span.regions) > 0) { + extmark_del_id(curbuf, mc_session_ns(), kv_pop(mc_ins_span.regions)); + } +} + +/// Resolves cursor's tracked position. +/// +/// @return False: the cursor is in another buffer, or its mark is gone. +static bool mc_ctx_resolve(const Context *ctx, pos_T *pos) +{ + return handle_get_buffer(ctx->buf) == curbuf && ctx->mark != 0 + && mc_mark_get(curbuf, mc_ns(), ctx->mark, pos); +} + +/// Places a paired session mark at `pos` (left-gravity anchor .. right-gravity end): text +/// inserted at `pos` lands inside the pair. +static void mc_region_mark_set(uint32_t *mark, pos_T pos) +{ + // no_undo: an undo-recorded extmark op mid-session breaks stop_arrow(). + extmark_set(curbuf, mc_session_ns(), mark, (int)pos.lnum - 1, pos.col, + (int)pos.lnum - 1, pos.col, (DecorInline)DECOR_INLINE_INIT, 0, + false, true, true, false, NULL); +} + +/// (Re)places the primary text-region mark at the cursor, and a per-cursor paired mark tracking +/// each cursor's preview region. The preview text lands between the pair, exactly like the +/// primary's `region` mark). +static void mc_ins_preview_rebase(void) +{ + mc_region_mark_set(&mc_ins_span.region, curwin->w_cursor); + mc_ins_regions_clear(); + for (size_t i = 0; i < kv_size(mc_cursors); i++) { + Context *ctx = &kv_A(mc_cursors, i); + pos_T pos; + uint32_t mark = 0; + if (mc_ctx_resolve(ctx, &pos)) { + mc_region_mark_set(&mark, pos); + kv_push(mc_ins_span.regions, mark); + } + } +} + +/// First live per-cursor preview region, or `start.id == 0` if none. A live region's buffer +/// content is the applied preview text, and start == end means no preview is applied. +static MTPair mc_ins_region_first(void) +{ + for (size_t i = 0; i < kv_size(mc_ins_span.regions); i++) { + MTPair p = extmark_from_id(curbuf, mc_session_ns(), kv_A(mc_ins_span.regions, i)); + if (p.start.id != 0) { + return p; + } + } + return (MTPair){ 0 }; +} + +/// The buffer range of paired region mark `p`. +static void mc_region_range(MTPair p, pos_T *start, pos_T *end) +{ + *start = (pos_T){ .lnum = p.start.pos.row + 1, .col = p.start.pos.col }; + *end = (pos_T){ .lnum = p.end_pos.row + 1, .col = p.end_pos.col }; +} + +/// Replaces buffer text (end-exclusive) with `text` (multiline), preserving marks and undo. +static void mc_ins_preview_replace(pos_T start, pos_T end, const String *text) +{ + Arena arena = ARENA_EMPTY; + Error err = ERROR_INIT; + char *data = text->data != NULL ? text->data : (char *)""; // Empty text (a delete) => NULL data. + size_t nlines = 1; + for (size_t i = 0; i < text->size; i++) { + nlines += data[i] == NL; + } + Array lines = arena_array(&arena, nlines); + size_t start_i = 0; + for (size_t i = 0; i <= text->size; i++) { + if (i == text->size || data[i] == NL) { + ADD_C(lines, STRING_OBJ(cbuf_as_string(data + start_i, i - start_i))); + start_i = i + 1; + } + } + nvim_buf_set_text(LUA_INTERNAL_CALL, 0, start.lnum - 1, start.col, + end.lnum - 1, end.col, lines, &arena, &err); + if (ERROR_SET(&err)) { + DLOG("preview replace failed: %s", err.msg); + api_clear_error(&err); + } + arena_mem_free(arena_finish(&arena)); +} + +/// Sets `text` as the preview at every cursor, replacing the previous one. +static void mc_ins_preview_set(const String *new) +{ + // The primary's own insert session must not notice the preview edits. + McInsSaved saved = mc_ins_save_state(); + // Track the primary cursor across the preview edits. + uint32_t primary = 0; + mc_track_upd(curbuf, &primary, curwin->w_cursor); + for (size_t i = 0; i < kv_size(mc_ins_span.regions); i++) { + MTPair p = extmark_from_id(curbuf, mc_session_ns(), kv_A(mc_ins_span.regions, i)); + if (p.start.id == 0) { + continue; // A script deleted the extmark mid-session. + } + // Cursor display mark (right-gravity) is pushed by the replacement to the new preview end. + pos_T rs, re; + mc_region_range(p, &rs, &re); + mc_ins_preview_replace(rs, re, new); + } + pos_T pos = curwin->w_cursor; + if (mc_mark_get(curbuf, mc_session_ns(), primary, &pos)) { + curwin->w_cursor = pos; + } + extmark_del_id(curbuf, mc_session_ns(), primary); + mc_ins_restore_state(&saved); +} + +/// Deletes the preview at every cursor. +static void mc_ins_preview_del(void) +{ + static const String empty = STRING_INIT; + mc_ins_preview_set(&empty); +} + +/// True if `keys` has a non-literal key (kKeyInsFlush): a literal preview cannot represent it. +static bool mc_ins_keys_nonliteral(const char *keys, size_t len) +{ + for (size_t i = 0; i < len; i++) { + int key = (uint8_t)keys[i]; + if (key == K_SPECIAL && i + 2 < len) { + key = TO_SPECIAL((uint8_t)keys[i + 1], (uint8_t)keys[i + 2]); + i += 2; + } + if ((atom_key_class(key, NUL) & kKeyInsFlush) != 0) { + return true; + } + } + return false; +} + +/// Capture restarted mid insert-session (stop_arrow(), after a non-captured cursor-move: mouse, +/// , …). The previews stay; rebase and continue the insert-cascade. +void mc_ins_cascade_restart(void) +{ + if (!mc_ins_span.active || mc_replaying() || !(State & MODE_INSERT) + || !mc_buf_has_cursors(curbuf) || kv_size(g_atoms) != 0) { + return; + } + String ins = redo_keys(NULL); + mc_ins_span.done_len = ins.size; + api_free_string(ins); + mc_ins_preview_rebase(); +} + +/// Insert-cascades the session. Called after each key in insert-mode; extends the preview or +/// flushes a span and cascades it. +void mc_ins_cascade(void) +{ + if (!mc_ins_span.active || mc_replaying() || !(State & MODE_INSERT) + || !mc_buf_has_cursors(curbuf) || kv_size(g_atoms) != 0) { + return; + } + String ins = redo_keys(NULL); + if (mc_ins_span.first) { + // Not with a pending autoindent ("o" + 'autoindent'): the entry span's replay ends in , + // which would delete the indent. + if (!Ins.did_ai && ins.data != NULL && ins.size > 0) { + // Entry replay. + StringBuilder keys = KV_INITIAL_VALUE; + kv_concat_len(keys, ins.data, ins.size); + kv_push(keys, ESC); + kv_push(keys, NUL); + mc_ins_span.done_len = ins.size; + mc_ins_span_push(keys.items, NULL); + mc_ins_preview_rebase(); + } + } else if (ins.data != NULL && ins.size < mc_ins_span.done_len) { + // Capture shrank without a restart signal, e.g. completion surgery rewrote the pending keys. + mc_ins_cascade_restart(); + } else if (ins.size > mc_ins_span.done_len + && mc_ins_keys_nonliteral(ins.data + mc_ins_span.done_len, + ins.size - mc_ins_span.done_len)) { + // Non-literal keys pending (BS, CTRL-U, ...): re-execute instead of previewing. + mc_ins_span_flush(&ins, false); + } else { + MTPair p = extmark_from_id(curbuf, mc_session_ns(), mc_ins_span.region); + if (p.start.id != 0) { + pos_T rs, re; + mc_region_range(p, &rs, &re); + String text = ml_region_text(curbuf, rs, re); + // Skip the re-apply if the previews already hold this text (first live region == primary's). + bool applied = false; + MTPair fp = mc_ins_region_first(); + if (fp.start.id != 0) { + pos_T frs, fre; + mc_region_range(fp, &frs, &fre); + String cur = ml_region_text(curbuf, frs, fre); + applied = cur.size == text.size + && (text.size == 0 || memcmp(cur.data, text.data, text.size) == 0); + api_free_string(cur); + } + if (!applied) { + mc_ins_preview_set(&text); + } + api_free_string(text); + } + } + api_free_string(ins); +} + +/// Flushes a span from the capture tail and cascades it: deletes the previews, then replays the +/// pending keys ("i" + tail) at each cursor. +/// +/// @param commit Session-end flush: the tail already ends with ; attach the "."-register text +/// and don't rebase. Else, mid-session flush: append to end the replayed +/// session, and rebase for the continuing preview. +static void mc_ins_span_flush(const String *ins, bool commit) +{ + MTPair fp = mc_ins_region_first(); + if (fp.start.id != 0 + && (fp.start.pos.row != fp.end_pos.row || fp.start.pos.col != fp.end_pos.col)) { + mc_ins_preview_del(); + } + size_t dlen = ins->size - mc_ins_span.done_len; + StringBuilder keys = KV_INITIAL_VALUE; + kv_push(keys, 'i'); + kv_concat_len(keys, ins->data + mc_ins_span.done_len, dlen); + if (!commit) { + kv_push(keys, ESC); + } + kv_push(keys, NUL); + char *text = commit && dlen > 1 ? xmemdupz(ins->data + mc_ins_span.done_len, dlen - 1) : NULL; + mc_ins_span.done_len = ins->size; + mc_ins_span_push(keys.items, text); + if (!commit) { + mc_ins_preview_rebase(); + } +} + +/// Reads `reg` (current global state), allocated, one trailing newline stripped; "" if empty. +static char *mc_reg_read(int reg) +{ + char *s = get_reg_contents(reg, 0); + if (s == NULL) { + return xstrdup(""); + } + size_t len = strlen(s); + if (len > 0 && s[len - 1] == NL) { + s[len - 1] = NUL; + } + return s; +} + +/// qsort() comparator, document-order (lnum, then col). +static int mc_ctx_pos_cmp(const void *a, const void *b) +{ + const Context *ca = *(Context *const *)a; + const Context *cb = *(Context *const *)b; + if (ca->pos.lnum != cb->pos.lnum) { + return ca->pos.lnum < cb->pos.lnum ? -1 : 1; + } + return ca->pos.col == cb->pos.col ? 0 : (ca->pos.col < cb->pos.col ? -1 : 1); +} + +/// On exit, any registers the user yanked-to are newline-concatenated (document-order) +/// and written to the primary-cursor registers. +static void mc_reg_gather(void) +{ + if (kv_size(mc_cursors) == 0 + // Exiting: windows were freed, shada was already written. + || exiting) { + return; + } + // Decide which registers were written this session, before updating (which bumps timestamps). + bool gather[sizeof(MC_REGS)] = { false }; + bool any = false; + for (int i = 0; MC_REGS[i] != NUL; i++) { + const int r = (uint8_t)MC_REGS[i]; + const int idx = r == '"' ? get_unname_register() : op_reg_index(r); + if (idx >= 0 && get_y_register(idx)->timestamp >= mc_start.time) { + gather[i] = any = true; + } + } + if (!any) { + return; + } + + // Sort primary + cursors by position: the reg join below concatenates in document-order. + Context primary = { .pos = curwin->w_cursor, .regs = shada_encode_regs(false, mc_start.time) }; + kvec_t(Context *) order = KV_INITIAL_VALUE; + kv_push(order, &primary); + for (size_t c = 0; c < kv_size(mc_cursors); c++) { + Context *ctx = &kv_A(mc_cursors, c); + if (ctx->regs.size > 0 && handle_get_buffer(ctx->buf) == curbuf) { + kv_push(order, ctx); + } + } + if (kv_size(order) == 1) { // No cursor registers to join (buffer disappeared?). + kv_destroy(order); + api_free_string(primary.regs); + return; + } + qsort(order.items, kv_size(order), sizeof(Context *), mc_ctx_pos_cmp); + Context save = CONTEXT_INIT; + ctx_save(&save, kCtxRegs); // Primary's registers, restored after the reads. + + // Join each gathered register's non-empty values. + kvec_t(char) joined[sizeof(MC_REGS)] = { { 0, 0, NULL } }; + for (size_t o = 0; o < kv_size(order); o++) { + // Exact (not merged): + ctx_load(kv_A(order, o), kCtxRegs, 0); + for (int i = 0; MC_REGS[i] != NUL; i++) { + if (!gather[i]) { + continue; + } + char *text = mc_reg_read(MC_REGS[i]); + if (*text != NUL) { + if (kv_size(joined[i]) > 0) { + kv_push(joined[i], NL); + } + kv_concat(joined[i], text); + } + xfree(text); + } + } + kv_destroy(order); + api_free_string(primary.regs); + ctx_load(&save, kCtxRegs, 0); // Restore the primary's registers. + ctx_free(&save); + + // Write each register back linewise. Skip an empty join (every value was empty). + for (int i = 0; MC_REGS[i] != NUL; i++) { + 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); + } + kv_destroy(joined[i]); + } +} + +/// Removes the fake Visual selections, clears their namespaces. +void mc_vsel_clear(void) +{ + buf_T *buf = handle_get_buffer(mc_vsel_buf); + if (buf != NULL) { + extmark_clear(buf, mc_vsel_ns(), 0, 0, MAXLNUM, MAXCOL); + extmark_clear(buf, mc_vcur_ns(), 0, 0, MAXLNUM, MAXCOL); + } + mc_vsel_buf = 0; +} + +/// Stores a fake-selection range (end-exclusive) as an extmark. +static void mc_vsel_mark(linenr_T start_lnum, colnr_T start_col, linenr_T end_lnum, colnr_T end_col) +{ + DecorInline decor = DECOR_INLINE_INIT; + decor.data.hl.hl_id = syn_check_group(S_LEN("MCursorVisual")); + uint32_t mark = 0; + extmark_set(curbuf, mc_vsel_ns(), &mark, (int)start_lnum - 1, start_col, + (int)end_lnum - 1, end_col, decor, MT_FLAG_DECOR_HL, + false, false, true, false, NULL); + mc_vsel_buf = curbuf->handle; +} + +/// Displays a fake Visual selection at each cursor, mirroring the primary cursor's selection. +void mc_vsel_refresh(void) +{ + mc_vsel_clear(); + String span = atom_visual_span(); + if (span.data == NULL || span.size == 0 || !mc_buf_has_cursors(curbuf)) { + xfree(span.data); + return; + } + + McSandbox sb; // Save the primary-cursor state (selection included), like a cascade replay. + mc_sandbox_enter(&sb, false); + block_autocmds(); + emsg_silent++; + // Dry-run motions must not touch the jumplist/changelist ("%", "(", …). + const int save_cmod_flags = cmdmod.cmod_flags; + cmdmod.cmod_flags |= CMOD_KEEPJUMPS; + + for (size_t i = 0; i < kv_size(mc_cursors); i++) { + Context *ctx = &kv_A(mc_cursors, i); + pos_T pos; + if (!mc_ctx_resolve(ctx, &pos)) { + continue; + } + curwin->w_cursor = pos; + check_cursor(curwin); + Visual.active = false; + Visual.select = false; + nvim_feedkeys(span, cstr_as_string("nix"), false); + if (!Visual.active) { + continue; + } + pos_T s = Visual.start; + pos_T e = curwin->w_cursor; + if (lt(e, s)) { + pos_T tmp = s; + s = e; + e = tmp; + } + if (Visual.mode == 'V') { + mc_vsel_mark(s.lnum, 0, e.lnum, ml_get_len(e.lnum)); + } else if (Visual.mode == Ctrl_V) { + // Blockwise: one range per line, computed by block_prep(). + colnr_T sv1, sv2, ev1, ev2; + const bool lbr_saved = reset_lbr(); + getvvcol(curwin, &s, &sv1, NULL, &sv2, 0); + getvvcol(curwin, &e, &ev1, NULL, &ev2, 0); + restore_lbr(lbr_saved); + oparg_T oa = { + .op_type = OP_NOP, + .motion_type = kMTBlockWise, + .inclusive = true, + .start = s, + .end = e, + .start_vcol = MIN(sv1, ev1), + .end_vcol = curwin->w_curswant == MAXCOL ? MAXCOL : MAX(sv2, ev2), + }; + for (linenr_T lnum = s.lnum; lnum <= e.lnum; lnum++) { + struct block_def bd; + block_prep(&oa, &bd, lnum, false); + if (bd.textlen > 0) { + mc_vsel_mark(lnum, bd.textcol, lnum, bd.textcol + bd.textlen); + } + } + } else { + // Charwise, inclusive: extend past the last selected char. + char *line = ml_get(e.lnum); + colnr_T ecol = e.col; + if (line[ecol] != NUL) { + ecol += utfc_ptr2len(line + ecol); + } else { + ecol++; + } + mc_vsel_mark(s.lnum, s.col, e.lnum, ecol); + } + // Selection-end cursor; the anchor extmark stays put (the eventual replay position). + uint32_t cmark = 0; + mc_point_mark_set(curbuf, mc_vcur_ns(), &cmark, curwin->w_cursor, true, false, true); + mc_vsel_buf = curbuf->handle; + Visual.active = false; + } + + cmdmod.cmod_flags = save_cmod_flags; + emsg_silent--; + unblock_autocmds(); + mc_sandbox_leave(&sb); + xfree(span.data); +} + +/// Time-travel undo (g-/g+, :earlier/:later) crosses cascade boundaries, where per-cursor state is +/// meaningless: delete the buffer's cursors. +void mc_undo_time(void) +{ + atom_did_global_op(); // Undo must not cascade, even via a mapping. + extmark_clear(curbuf, mc_ns(), 0, 0, MAXLNUM, MAXCOL); +} + +/// Insert-cascade "commit": run at insert-session end (atom_ins_end()). Replaces the previews with +/// a real replay. No-op if the session did not insert-cascade. +bool mc_ins_commit(void) +{ + bool ins_cascaded = mc_ins_span.active && !mc_ins_span.first; + mc_ins_span.active = false; + + if (ins_cascaded) { + // COMMIT: replace the previews with a real replay: abbrev, 'textwidth', … re-exec per cursor. + String ins = redo_keys(NULL); + if (ins.data != NULL && ins.size > mc_ins_span.done_len + // Not -terminated, e.g. CTRL-C: the previews stay. + && (uint8_t)ins.data[ins.size - 1] == ESC) { + mc_ins_span_flush(&ins, true); + } + api_free_string(ins); + } + + // Session is over, drop its marks. (region=0: none, and mc_session_ns() must not be created by + // a plain insert: namespace ids are user-observable.) + if (mc_ins_span.region != 0) { + extmark_del_id(curbuf, mc_session_ns(), mc_ins_span.region); + mc_ins_span.region = 0; + } + mc_ins_regions_clear(); + + if (!ins_cascaded) { + return false; + } + // The commit edits belong to the session (already reported in TextChangedI). Absorb their ticks, + // for TextChanged(I) parity, once per action. + curbuf->b_last_changedtick = buf_get_changedtick(curbuf); + curbuf->b_last_changedtick_i = buf_get_changedtick(curbuf); + // The mcursors were anchored at their insertion points; now that the session ended, + // shift them onto the last-inserted char, like did for the primary cursor. + for (size_t i = 0; i < kv_size(mc_cursors); i++) { + Context *ctx = &kv_A(mc_cursors, i); + if (!mc_ctx_resolve(ctx, &ctx->pos)) { + continue; + } + if (ctx->pos.col > 0) { + dec(&ctx->pos); // one char left, like (but never crossing lines) + } + mc_mark_upd(curbuf, &ctx->mark, ctx->pos); + } + return true; +} + +/// Whether `buf` has mcursors. Hot: called on every key. +bool mc_buf_has_cursors(buf_T *buf) +{ + for (size_t i = 0; i < kv_size(mc_cursors); i++) { + if (kv_A(mc_cursors, i).buf == buf->handle) { + return true; + } + } + return false; +} + +/// Whether "follow motion" mode ("q=") is enabled. +bool mc_following(void) +{ + return mc_follow_motion; +} + +/// Notifies mcursor.lua that the session started (first cursor) or ended (last cursor removed). +static void mc_lua_enable(bool enable) +{ + if (exiting) { + return; + } + typval_T tv_args[] = { + { .v_type = VAR_BOOL, .vval.v_bool = enable ? kBoolVarTrue : kBoolVarFalse }, + { .v_type = VAR_UNKNOWN }, + }; + nlua_call_typval("vim._core.mcursor", "enable", tv_args, NULL); +} + +/// The first cursor extmark at `pos` in `buf`, or 0 if none. +static uint32_t mc_mark_at(buf_T *buf, pos_T pos) +{ + MarkTreeIter itr[1] = { 0 }; + marktree_itr_get(buf->b_marktree, (int32_t)pos.lnum - 1, pos.col, itr); + MTKey k; + // Perf: bisect the marktree, instead of scanning mc_cursors (quadratic). + while ((k = marktree_itr_current(itr)).id != 0 + && k.pos.row == pos.lnum - 1 && k.pos.col == pos.col) { + if (k.ns == mc_ns() && !mt_end(k)) { + return k.id; + } + if (!marktree_itr_next(buf->b_marktree, itr)) { + break; + } + } + return 0; +} + +/// "g CTRL-A": insert an ascending number at each cursor. +void mc_counter(long count1) +{ + atom_did_global_op(); + typval_T tv_args[] = { + { .v_type = VAR_NUMBER, .vval.v_number = count1 }, + { .v_type = VAR_UNKNOWN }, + }; + nlua_call_typval("vim._core.mcursor", "number", tv_args, NULL); +} + +/// "q=": toggles "follow motion" mode; [count] forces it: "1q=" on, "2q=" off. +/// +/// @return false on an invalid count (> 2). +bool mc_follow_toggle(long count0) +{ + if (count0 == 0) { + mc_follow_motion = !mc_follow_motion; + } else if (count0 <= 2) { + mc_follow_motion = count0 == 1; + } else { + return false; + } + smsg(0, _("multicursor: follow motion %s"), mc_follow_motion ? "on" : "off"); + return true; +} + +/// Places an mcursor, or removes the cursor already at the given position. +void mc_toggle(buf_T *buf, pos_T pos, bool end_follow) +{ + if (mc_replaying()) { + // Replayed input ("Q" in a mapping's atom) must not manage cursors; see mc_add(). + return; + } + if (end_follow) { + mc_follow_motion = false; + } + 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) + return; + } + mc_add(buf, pos); +} + +/// Places an mcursor at position `pos` in `buf`. +void mc_add(buf_T *buf, pos_T pos) +{ + if (mc_replaying()) { + // Can't add cursors while the cascade iterates them. + return; + } + // Ignore duplicate cursor (e.g. repeated "[count]Q", nvim_mcursor()). + if (mc_mark_at(buf, pos) != 0) { + return; + } + if (kv_size(mc_cursors) == 0) { + // Session start: snapshot the primary's regs; hand the display to mcursor.lua. + mc_start.time = (Timestamp)os_realtime(); + ctx_save(&mc_start.regs, kCtxRegs); + mc_lua_enable(true); + } + kv_push(mc_cursors, (Context)CONTEXT_INIT); + Context *ctx = &kv_last(mc_cursors); + ctx->buf = buf->handle; + ctx->pos = pos; + mc_mark_upd(buf, &ctx->mark, pos); +} + +/// A reload/wipe invalidated the tracked positions: deletes the mcursors + "gQ" snapshot. +void mc_buf_clear(buf_T *buf) +{ + if (mc_replaying()) { + return; + } + extmark_clear(buf, mc_ns(), 0, 0, MAXLNUM, MAXCOL); + extmark_clear(buf, mc_last_ns(), 0, 0, MAXLNUM, MAXCOL); +} + +/// Called when a buffer's extmarks were freed. Deleting a cursor's extmark deletes the cursor. +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. + return; + } + mc_dedupe(); + if (mc_vsel_buf == buf->handle) { + // The selection extmarks died with the buffer too. + mc_vsel_buf = 0; + } +} + +/// Called before extmark_clear() deletes a namespace's extmarks. Refreshes the cursor-position +/// cache, so mc_ns_cleared()'s "gQ" snapshot sees positions shifted by non-cascading edits. +void mc_ns_clearing(buf_T *buf, uint32_t ns_id) +{ + if ((ns_id != mc_ns() && ns_id != 0) || mc_replaying()) { + return; + } + for (size_t i = 0; i < kv_size(mc_cursors); i++) { + Context *ctx = &kv_A(mc_cursors, i); + if (ctx->buf == buf->handle && ctx->mark != 0) { + mc_mark_get(buf, mc_ns(), ctx->mark, &ctx->pos); + } + } +} + +/// Deleting the "nvim.multicursor" namespace deletes its cursors. Saves snapshot for "gQ". +void mc_ns_cleared(buf_T *buf, uint32_t ns_id) +{ + if ((ns_id != mc_ns() && ns_id != 0) || mc_replaying() || !mc_buf_has_cursors(buf)) { + return; + } + + // End the session only if no cursors are alive. + bool others = false; + for (size_t i = 0; i < kv_size(mc_cursors) && !others; i++) { + Context *ctx = &kv_A(mc_cursors, i); + pos_T pos; + others = ctx->buf != buf->handle + || (ctx->mark != 0 && mc_mark_get(buf, mc_ns(), ctx->mark, &pos)); + } + if (!others) { + // "DWIM yank": before the multicursor session ends, join per-cursor yanks to primary. + mc_reg_gather(); + } + + // Snapshot the positions into "nvim.multicursor.last" ("gQ"). + extmark_clear(buf, mc_last_ns(), 0, 0, MAXLNUM, MAXCOL); + for (size_t i = 0; i < kv_size(mc_cursors); i++) { + Context *ctx = &kv_A(mc_cursors, i); + if (ctx->buf != buf->handle) { + continue; + } + uint32_t mark = 0; + mc_point_mark_set(buf, mc_last_ns(), &mark, ctx->pos, false, true, false); + } + mc_dedupe(); // Cleanup. + if (kv_size(mc_cursors) == 0) { + // Session ended; drop the pending cascade. + atoms_free(&g_atoms); + } +} + +#ifdef EXITFREE +/// Frees all multicursor state on exit. +void mc_free_all(void) +{ + while (kv_size(mc_cursors) > 0) { + Context ctx = kv_pop(mc_cursors); + ctx_free(&ctx); + } + kv_destroy(mc_cursors); + kv_destroy(mc_ins_span.regions); + ctx_free(&mc_start.regs); +} +#endif diff --git a/src/nvim/mcursor.h b/src/nvim/mcursor.h new file mode 100644 index 0000000000..17b479660d --- /dev/null +++ b/src/nvim/mcursor.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +#include "nvim/buffer_defs.h" // buf_T +#include "nvim/context_defs.h" // IWYU pragma: keep (used by mcursor.h.generated.h) +#include "nvim/eval/typval_defs.h" // varnumber_T +#include "nvim/input_cmdatom_defs.h" // IWYU pragma: keep (used by mcursor.h.generated.h) +#include "nvim/normal_defs.h" // IWYU pragma: keep (cmdarg_T, used by mcursor.h.generated.h) +#include "nvim/pos_defs.h" +#include "nvim/register_defs.h" // IWYU pragma: keep (yankreg_T, used by mcursor.h.generated.h) + +#include "mcursor.h.generated.h" diff --git a/src/nvim/memline.c b/src/nvim/memline.c index a0900faeab..21a0484212 100644 --- a/src/nvim/memline.c +++ b/src/nvim/memline.c @@ -1880,6 +1880,27 @@ colnr_T ml_get_buf_len(buf_T *buf, linenr_T lnum) return buf->b_ml.ml_line_textlen - 1; } +/// Gets the charwise NL-joined text of range [start, end), 1-based lnum, 0-based col, end col +/// exclusive, as an allocated String ("" if empty). Line range must be valid (start <= end); cols +/// clamp to line-length. +String ml_region_text(buf_T *buf, pos_T start, pos_T end) + FUNC_ATTR_NONNULL_ALL +{ + StringBuilder sb = KV_INITIAL_VALUE; + for (linenr_T lnum = start.lnum; lnum <= end.lnum; lnum++) { + char *line = ml_get_buf(buf, lnum); + colnr_T len = ml_get_buf_len(buf, lnum); + colnr_T from = lnum == start.lnum ? MIN(start.col, len) : 0; + colnr_T to = lnum == end.lnum ? MIN(end.col, len) : len; + kv_concat_len(sb, line + from, (size_t)(to - from)); + if (lnum < end.lnum) { + kv_push(sb, NL); + } + } + kv_push(sb, NUL); + return cbuf_as_string(sb.items, kv_size(sb) - 1); +} + /// @return codepoint at pos. pos must be either valid or have col set to MAXCOL! int gchar_pos(pos_T *pos) FUNC_ATTR_NONNULL_ARG(1) diff --git a/src/nvim/memory.c b/src/nvim/memory.c index e9dbd28210..a207fb3086 100644 --- a/src/nvim/memory.c +++ b/src/nvim/memory.c @@ -16,7 +16,6 @@ #include "nvim/buffer_defs.h" #include "nvim/buffer_updates.h" #include "nvim/channel.h" -#include "nvim/context.h" #include "nvim/decoration_provider.h" #include "nvim/drawline.h" #include "nvim/errors.h" @@ -31,6 +30,7 @@ #include "nvim/main.h" #include "nvim/map_defs.h" #include "nvim/mapping.h" +#include "nvim/mcursor.h" #include "nvim/memfile.h" #include "nvim/memory.h" #include "nvim/message.h" @@ -986,6 +986,7 @@ void free_all_mem(void) eval_clear(); api_extmark_free_all_mem(); atom_free_all(); + mc_free_all(); map_destroy(int, &buffer_handles); map_destroy(int, &window_handles); diff --git a/src/nvim/mouse.c b/src/nvim/mouse.c index 7992dc6b39..1543f17ba4 100644 --- a/src/nvim/mouse.c +++ b/src/nvim/mouse.c @@ -29,6 +29,7 @@ #include "nvim/mark_defs.h" #include "nvim/mbyte.h" #include "nvim/mbyte_defs.h" +#include "nvim/mcursor.h" #include "nvim/memline.h" #include "nvim/memory.h" #include "nvim/menu.h" @@ -227,9 +228,10 @@ static void call_click_def_func(StlClickDefinition *click_defs, int col, int whi } /// Translate window coordinates to buffer position without any side effects. -/// Returns IN_BUFFER and sets "mpos->col" to the column when in buffer text. -/// The column is one for the first column. -static int get_fpos_of_mouse(pos_T *mpos) +/// Returns IN_BUFFER and sets `mpos` (0-based column) when in buffer text. +/// +/// @param wpp If not NULL: consider any window (not just `curwin`), and return it here. +static int get_fpos_of_mouse(pos_T *mpos, win_T **wpp) { int grid = mouse_grid; int row = mouse_row; @@ -244,6 +246,9 @@ static int get_fpos_of_mouse(pos_T *mpos) if (wp == NULL) { return IN_UNKNOWN; } + if (wpp != NULL) { + *wpp = wp; + } int winrow = row; int wincol = col; @@ -276,7 +281,7 @@ static int get_fpos_of_mouse(pos_T *mpos) return IN_SEP_LINE; } - if (wp != curwin || below_buffer) { + if ((wpp == NULL && wp != curwin) || below_buffer) { return IN_UNKNOWN; } @@ -333,37 +338,7 @@ static int do_popup(int which_button, int m_pos_flag, pos_T m_pos) } /// Do the appropriate action for the current mouse click in the current mode. -/// Not used for Command-line mode. -/// -/// Normal and Visual Mode: -/// event modi- position visual change action -/// fier cursor window -/// left press - yes end yes -/// left press C yes end yes "^]" (2) -/// left press S yes end (popup: extend) yes "*" (2) -/// left drag - yes start if moved no -/// left relse - yes start if moved no -/// middle press - yes if not active no put register -/// middle press - yes if active no yank and put -/// right press - yes start or extend yes -/// right press S yes no change yes "#" (2) -/// right drag - yes extend no -/// right relse - yes extend no -/// -/// Insert or Replace Mode: -/// event modi- position visual change action -/// fier cursor window -/// left press - yes (cannot be active) yes -/// left press C yes (cannot be active) yes "CTRL-O^]" (2) -/// left press S yes (cannot be active) yes "CTRL-O*" (2) -/// left drag - yes start or extend (1) no CTRL-O (1) -/// left relse - yes start or extend (1) no CTRL-O (1) -/// middle press - no (cannot be active) no put register -/// right press - yes start or extend yes CTRL-O -/// right press S yes (cannot be active) yes "CTRL-O#" (2) -/// -/// (1) only if mouse pointer moved since press -/// (2) only if click is in same buffer +/// Not used for Command-line mode. Per-mode/button behavior: |mouse-mode-table|. /// /// @param oap operator argument, can be NULL /// @param c K_LEFTMOUSE, etc @@ -438,6 +413,9 @@ bool do_mouse(oparg_T *oap, int c, int dir, int count, bool fixindent) } stuffcharReadbuff(Ctrl_T); got_click = false; // ignore drag&release now + if ((State & MODE_INSERT) == 0) { + exec_stuffed(NULL); + } return false; } @@ -489,10 +467,15 @@ bool do_mouse(oparg_T *oap, int c, int dir, int count, bool fixindent) stuffcharReadbuff(Ctrl_G); stuffReadbuff("\"+p"); } else { + // Reg prefix must travel in the keys run by exec_stuffed. E.g. `"ay` + if (regname != 0) { + stuffcharReadbuff('"'); + stuffcharReadbuff(regname); + } stuffcharReadbuff('y'); stuffcharReadbuff(K_MIDDLEMOUSE); } - exec_stuffed(oap); + exec_stuffed(NULL); return false; } // The rest is below jump_to_mouse() @@ -587,6 +570,21 @@ bool do_mouse(oparg_T *oap, int c, int dir, int count, bool fixindent) } } + // Multicursor: CTRL-click toggles a cursor at click pos, without moving the primary cursor. + // Not in quickfix: there CTRL-click jumps to the item. No-op during insert. + if (is_click && (mod_mask & MOD_MASK_CTRL) && which_button == MOUSE_LEFT) { + pos_T pos; + win_T *wp = NULL; + if (get_fpos_of_mouse(&pos, &wp) == IN_BUFFER && !bt_quickfix(wp->w_buffer)) { + got_click = false; // ignore drag&release now + if ((State & MODE_INSERT) == 0) { + pos.coladd = 0; + mc_toggle(wp->w_buffer, pos, false); + } + return false; + } + } + int m_pos_flag = 0; pos_T m_pos = { 0 }; // When 'mousemodel' is "popup" or "popup_setpos", translate mouse events: @@ -594,7 +592,7 @@ bool do_mouse(oparg_T *oap, int c, int dir, int count, bool fixindent) // shift-left button -> right button // alt-left button -> alt-right button if (mouse_model_popup()) { - m_pos_flag = get_fpos_of_mouse(&m_pos); + m_pos_flag = get_fpos_of_mouse(&m_pos, NULL); if (!(m_pos_flag & (IN_STATUS_LINE|MOUSE_WINBAR|MOUSE_STATUSCOL)) && which_button == MOUSE_RIGHT && !(mod_mask & (MOD_MASK_SHIFT|MOD_MASK_CTRL))) { if (!is_click) { @@ -885,15 +883,16 @@ bool do_mouse(oparg_T *oap, int c, int dir, int count, bool fixindent) do_cmdline_cmd(".ll"); } got_click = false; // ignore drag&release now - } else if ((mod_mask & MOD_MASK_CTRL) - || (curbuf->b_help && (mod_mask & MOD_MASK_MULTI_CLICK) == MOD_MASK_2CLICK)) { - // Ctrl-Mouse click (or double click in a help window) jumps to the tag - // under the mouse pointer. + } else if (curbuf->b_help && (mod_mask & MOD_MASK_MULTI_CLICK) == MOD_MASK_2CLICK) { + // Double-click in a help window jumps to the clicked tag. if (State & MODE_INSERT) { stuffcharReadbuff(Ctrl_O); } stuffcharReadbuff(Ctrl_RSB); got_click = false; // ignore drag&release now + if ((State & MODE_INSERT) == 0) { + exec_stuffed(NULL); + } } else if ((mod_mask & MOD_MASK_SHIFT)) { // Shift-Mouse click searches for the next occurrence of the word under // the mouse pointer @@ -905,6 +904,9 @@ bool do_mouse(oparg_T *oap, int c, int dir, int count, bool fixindent) } else { // MOUSE_RIGHT stuffcharReadbuff('#'); } + if ((State & MODE_INSERT) == 0) { + exec_stuffed(NULL); + } } else if (in_status_line || in_sep_line) { // Do nothing if on status line or vertical separator // Handle double clicks otherwise diff --git a/src/nvim/normal.c b/src/nvim/normal.c index 00aecff3fc..b5cd75e663 100644 --- a/src/nvim/normal.c +++ b/src/nvim/normal.c @@ -68,6 +68,7 @@ #include "nvim/math.h" #include "nvim/mbyte.h" #include "nvim/mbyte_defs.h" +#include "nvim/mcursor.h" #include "nvim/memline.h" #include "nvim/memline_defs.h" #include "nvim/memory.h" @@ -266,7 +267,7 @@ static const struct nv_cmd { { 'N', nv_next, NV_MOTION, SEARCH_REV }, { 'O', nv_open, 0, 0 }, { 'P', nv_put, 0, 0 }, - { 'Q', nv_regreplay, 0, 0 }, + { 'Q', nv_Q, 0, 0 }, { 'R', nv_Replace, 0, false }, { 'S', nv_subst, NV_KEEPREG, 0 }, { 'T', nv_csearch, NV_NCH_ALW|NV_LANG|NV_MOTION, BACKWARD }, @@ -2041,13 +2042,20 @@ void display_showcmd(void) } // 'showcmdloc' is "last" or empty + // Multicursor count prefixes the pending command: "2× ciw". + char mc_buf[16]; + size_t mc_len = mc_showcmd(mc_buf, sizeof(mc_buf)); + if (ui_has(kUIMessages)) { MAXSIZE_TEMP_ARRAY(content, 1); MAXSIZE_TEMP_ARRAY(chunk, 3); - if (!showcmd_is_clear) { + char ext_buf[sizeof(mc_buf) + SHOWCMD_BUFLEN]; + STRCPY(ext_buf, mc_buf); + xstrlcpy(ext_buf + mc_len, showcmd_is_clear ? "" : showcmd_buf, sizeof(ext_buf) - mc_len); + if (*ext_buf != NUL) { // placeholder for future highlight support ADD_C(chunk, INTEGER_OBJ(0)); - ADD_C(chunk, CSTR_AS_OBJ(showcmd_buf)); + ADD_C(chunk, CSTR_AS_OBJ(ext_buf)); ADD_C(chunk, INTEGER_OBJ(0)); ADD_C(content, ARRAY_OBJ(chunk)); } @@ -2062,7 +2070,7 @@ void display_showcmd(void) int showcmd_row = Rows - 1; grid_line_start(&msg_grid_adj, showcmd_row); - int len = 0; + int len = mc_len > 0 ? grid_line_puts(sc_col, mc_buf, -1, 0) : 0; if (!showcmd_is_clear) { len += grid_line_puts(sc_col + len, showcmd_buf, -1, HL_ATTR(HLF_MSG)); } @@ -3104,22 +3112,23 @@ static void nv_zet(cmdarg_T *cap) } } -/// "Q" command. -static void nv_regreplay(cmdarg_T *cap) +/// "Q" command: Toggles a multicursor at the cursor position. +/// "[count]Q": Places a multicursor at every match of the last search pattern. +/// "{visual}Q": Places a multicursor on each selected line. +static void nv_Q(cmdarg_T *cap) { - if (checkclearop(cap->oap)) { - return; - } - - if (reg_recorded != 0) { - // The macro's commands are captured as one "@x"-labeled atom (see - // atom_macro_start()). - atom_macro_start(reg_recorded); - } - while (cap->count1-- && !got_int) { - if (do_execreg(reg_recorded, false, false, false) == false) { - clearopbeep(cap->oap); - break; + if (reg_recording != 0 || reg_executing != 0) { + // Not allowed while recording/executing a macro. |mcursor-limitations| + vim_beep(0); + } else if (!checkclearop(cap->oap)) { + if (Visual.active) { + typval_T tv_args[] = { { .v_type = VAR_UNKNOWN } }; + nlua_call_typval("vim._core.mcursor", "visual", tv_args, NULL); + } else if (cap->count0 > 0) { + typval_T tv_args[] = { { .v_type = VAR_UNKNOWN } }; + nlua_call_typval("vim._core.mcursor", "matches", tv_args, NULL); + } else { + mc_toggle(curbuf, curwin->w_cursor, true); } } } @@ -4302,6 +4311,24 @@ static void nv_brackets(cmdarg_T *cap) cap->count1) == false) { clearopbeep(cap->oap); } + } else if (cap->nchar == 'C') { + // "[C" and "]C": jump to previous/next multicursor. + if (cap->oap->op_type != OP_NOP) { + // Not an operator motion: a cascaded "d]C" would consume its own targets. + clearopbeep(cap->oap); + } else { + typval_T tv_args[] = { + { .v_type = VAR_BOOL, .vval.v_bool = cap->cmdchar == ']' ? kBoolVarTrue : kBoolVarFalse }, + { .v_type = VAR_NUMBER, .vval.v_number = cap->count1 }, + { .v_type = VAR_UNKNOWN }, + }; + typval_T rettv = TV_INITIAL_VALUE; + nlua_call_typval("vim._core.mcursor", "jump", tv_args, &rettv); + if (rettv.v_type != VAR_BOOL || rettv.vval.v_bool != kBoolVarTrue) { + clearopbeep(cap->oap); + } + tv_clear(&rettv); + } } else if (cap->nchar == 'r' || cap->nchar == 's' || cap->nchar == 'S') { // "[r", "[s", "[S", "]r", "]s" and "]S": move to next spell error. setpcmark(); @@ -4557,14 +4584,11 @@ static void nv_replace(cmdarg_T *cap) // Other characters are done below to avoid problems with things like // CTRL-V 048 (for edit() this would be R CTRL-V 0 ESC). if (had_ctrl_v != Ctrl_V && cap->nchar == '\t' && (curbuf->b_p_et || p_sta)) { - atom_stuff_start(cap); stuffnumReadbuff(cap->count1); stuffcharReadbuff('R'); stuffcharReadbuff('\t'); stuffcharReadbuff(ESC); - if (exec_stuffed(cap->oap)) { - cap->retval |= CA_COMMAND_BUSY; - } + exec_stuffed(cap); return; } @@ -4880,14 +4904,11 @@ static void nv_optrans(cmdarg_T *cap) static const char *str = "xXDCsSY&"; if (!checkclearopq(cap->oap)) { - atom_stuff_start(cap); if (cap->count0) { stuffnumReadbuff(cap->count0); } stuffReadbuff(ar[strchr(str, (char)cap->cmdchar) - str]); - if (exec_stuffed(cap->oap)) { - cap->retval |= CA_COMMAND_BUSY; - } + exec_stuffed(cap); } cap->opcount = 0; } @@ -5416,6 +5437,8 @@ static void nv_g_cmd(cmdarg_T *cap) cap->cmdchar = cap->nchar; cap->nchar = NUL; nv_addsub(cap); + } else if (cap->nchar == Ctrl_A && cap->oap->op_type == OP_NOP && mc_buf_has_cursors(curbuf)) { + mc_counter(cap->count1); } else { clearopbeep(oap); } @@ -5445,6 +5468,17 @@ static void nv_g_cmd(cmdarg_T *cap) Visual.reselect = false; break; + // "gQ": restore the previous multicursors (analogous to "gv"). + case 'Q': + if (reg_recording != 0 || reg_executing != 0) { + // Not allowed while recording/executing a macro. |mcursor-limitations| + vim_beep(0); + } else { + typval_T tv_args[] = { { .v_type = VAR_UNKNOWN } }; + nlua_call_typval("vim._core.mcursor", "restore", tv_args, NULL); + } + break; + // "gh": start Select mode. // "gH": start Select line mode. // "g^H": start Select block mode. @@ -5657,7 +5691,7 @@ static void nv_g_cmd(cmdarg_T *cap) case K_LEFTMOUSE: if (do_mouse(oap, cap->nchar, BACKWARD, cap->count1, 0)) { stuffcharReadbuff(Ctrl_RSB); - exec_stuffed(oap); + exec_stuffed(cap); } break; @@ -5777,15 +5811,12 @@ static void nv_dot(cmdarg_T *cap) // If "restart_edit" is true, the last but one command is repeated // instead of the last command (inserting text). This is used for // CTRL-O <.> in insert mode. - atom_stuff_start(cap); if (start_redo(cap->count0, restart_edit != 0 && Ins.moved == kInsNone) == false) { clearopbeep(cap->oap); return; } // Execute the redo keys here: the whole replay resolves within this "." command. - if (exec_stuffed(cap->oap)) { - cap->retval |= CA_COMMAND_BUSY; - } + exec_stuffed(cap); } /// CTRL-R: undo undo or specify register in select mode @@ -6397,6 +6428,7 @@ static void nv_object(cmdarg_T *cap) /// "q" command: Start/stop macro recording. /// "q:", "q/", "q?": cmdwin. /// "[count]q:": interactive Ex-mode. +/// "q=": Multicursor "follow". static void nv_q(cmdarg_T *cap) { if (cap->oap->op_type == OP_FORMAT) { @@ -6411,7 +6443,11 @@ static void nv_q(cmdarg_T *cap) return; } - if (cap->nchar == ':' || cap->nchar == '/' || cap->nchar == '?') { + if (cap->nchar == '=') { + if (!mc_follow_toggle(cap->count0)) { + clearopbeep(cap->oap); + } + } else if (cap->nchar == ':' || cap->nchar == '/' || cap->nchar == '?') { if (cmdwin_buf != NULL) { emsg(_(e_cmdline_window_already_open)); return; @@ -6729,30 +6765,32 @@ void normal_cmd(oparg_T *oap, bool toplevel) *oap = s.oa; } -/// Executes the pending readahead (see "Stuffing", input.c). +/// Executes pending readahead now (see "Stuffing", input.c). /// -/// During "textlock" the stuffed keys are left for the main loop instead (for CmdAtom/multicursor -/// purposes, that's fine: if stuff_empty()=false, the pending CmdAtom stays open and will collect -/// the effects later). +/// During "textlock" the stuffed keys are queued instead (main loop). For CmdAtom/multicursor +/// purposes, that's fine: if stuff_empty()=false, pending CmdAtom stays open, will collect later. /// -/// @param oap The continuing operator state (see `normal_cmd`), or NULL. -/// @return True if insert-session-resume is pending (i_CTRL-O): the caller reports -/// CA_COMMAND_BUSY, so resume happens after next command instead. -bool exec_stuffed(oparg_T *oap) +/// @param cap The cmd whose translation was stuffed ("x" => "dl"), or NULL (internal stuff). +void exec_stuffed(cmdarg_T *cap) { + if (cap != NULL) { + atom_stuff_start(cap); // Label as one atom. + } if (text_locked() || curbuf_locked()) { - return false; + return; } oparg_T oa; clear_oparg(&oa); - if (oap == NULL) { - oap = &oa; - } + oparg_T *oap = cap != NULL ? cap->oap : &oa; finish_op = false; while (!stuff_empty() && !got_int) { update_topline_cursor(); + // Continue the command's operator state. normal_cmd(oap, true); } finish_op = false; - return restart_edit != 0; + if (cap != NULL && restart_edit != 0) { + // When insert-session-resume is pending (i_CTRL-O), resume happens after next command. + cap->retval |= CA_COMMAND_BUSY; + } } diff --git a/src/nvim/ops.c b/src/nvim/ops.c index 0bfefad33b..31c9150886 100644 --- a/src/nvim/ops.c +++ b/src/nvim/ops.c @@ -54,6 +54,7 @@ #include "nvim/math.h" #include "nvim/mbyte.h" #include "nvim/mbyte_defs.h" +#include "nvim/mcursor.h" #include "nvim/memline.h" #include "nvim/memline_defs.h" #include "nvim/memory.h" diff --git a/src/nvim/option.c b/src/nvim/option.c index ecf2609464..68c59c1c6b 100644 --- a/src/nvim/option.c +++ b/src/nvim/option.c @@ -78,6 +78,7 @@ #include "nvim/macros_defs.h" #include "nvim/mapping.h" #include "nvim/mbyte.h" +#include "nvim/mcursor.h" #include "nvim/memfile.h" #include "nvim/memline.h" #include "nvim/memory.h" @@ -6836,6 +6837,11 @@ bool can_bs(int what) return false; } + // Multicursor replays may join lines only if the primary's own span did. + if (what == BS_EOL && !mc_ins_replay_can_join()) { + return false; + } + // support for number values was removed but we keep '2' since it is used in // legacy tests if (*p_bs == '2') { diff --git a/src/nvim/tui/input.c b/src/nvim/tui/input.c index 121bcb3a79..3d1926f3be 100644 --- a/src/nvim/tui/input.c +++ b/src/nvim/tui/input.c @@ -686,10 +686,35 @@ static void handle_unknown_csi(TermInput *input, const TermKeyKey *key) uint8_t initial = (cmd >> 8) & 0xFF; uint8_t command = cmd & 0xFF; - // Currently unused - (void)intermediate; - switch (command) { + case 'q': + if (initial == '>' && intermediate == ' ') { + // Kitty multiple-cursors protocol query response: + // CSI > shape;shape;… SP q + MAXSIZE_TEMP_ARRAY(args, 2); + ADD_C(args, STATIC_CSTR_AS_OBJ("termresponse")); + + StringBuilder response = KV_INITIAL_VALUE; + kv_concat(response, "\x1b[>"); + for (size_t i = 0; i < nparams; i++) { + int arg; + if (termkey_interpret_csi_param(params[i], &arg, NULL, NULL) != TERMKEY_RES_KEY) { + kv_destroy(response); + return; + } + kv_printf(response, "%d", arg); + if (i < nparams - 1) { + kv_push(response, ';'); + } + } + kv_concat(response, " q"); + ADD_C(args, STRING_OBJ(cbuf_as_string(response.items, response.size))); + + // Forward to the client (TermResponse). + rpc_send_event(ui_client_channel_id, "nvim_ui_term_event", args); + kv_destroy(response); + } + break; case 'u': switch (initial) { case '?': diff --git a/src/nvim/tui/tui.c b/src/nvim/tui/tui.c index 1c3772d4c0..a4203858f9 100644 --- a/src/nvim/tui/tui.c +++ b/src/nvim/tui/tui.c @@ -1687,10 +1687,16 @@ void tui_default_colors_set(TUIData *tui, Integer rgb_fg, Integer rgb_bg, Intege invalidate(tui, 0, tui->grid.height, 0, tui->grid.width); } -/// Writes directly to the TTY, bypassing the buffer. +/// Writes to the TTY, or buffers (to avoid "tearing") if a frame is being assembled (or output is +/// already buffered). void tui_ui_send(TUIData *tui, String content) FUNC_ATTR_NONNULL_ALL { + if (kv_size(tui->invalid_regions) || tui->bufpos > 0) { + // Append to buffer instead of writing directly. + out(tui, content.data, content.size); + return; + } uv_write_t req; uv_buf_t buf = { .base = content.data, .len = UV_BUF_LEN(content.size) }; int ret = uv_write(&req, (uv_stream_t *)&tui->output_handle, &buf, 1, NULL); diff --git a/src/nvim/undo.c b/src/nvim/undo.c index ff31fabca5..3c7148a667 100644 --- a/src/nvim/undo.c +++ b/src/nvim/undo.c @@ -113,6 +113,7 @@ #include "nvim/mark.h" #include "nvim/mark_defs.h" #include "nvim/mbyte.h" +#include "nvim/mcursor.h" #include "nvim/memline.h" #include "nvim/memline_defs.h" #include "nvim/memory.h" @@ -481,6 +482,7 @@ int u_savecommon(buf_T *buf, linenr_T top, linenr_T bot, linenr_T newbot, bool r } else { uhp->uh_cursor_vcol = -1; } + clearpos(&uhp->uh_cursor_after); // save changed and buffer empty flag for undo uhp->uh_flags = (buf->b_changed ? UH_CHANGED : 0) + @@ -1902,7 +1904,7 @@ static void u_doit(int startcount, bool quiet, bool do_buf_event) if (!undo_allowed(curbuf)) { return; } - atom_op_global_set(); // multicursor: undo/redo must not cascade (global, not per-cursor). + atom_did_global_op(); // multicursor: undo/redo must not cascade (global, not per-cursor). u_newcount = 0; u_oldcount = 0; @@ -1979,6 +1981,7 @@ void undo_time(int step, bool sec, bool file, bool absolute) text_locked_msg(); return; } + mc_undo_time(); // Time-travel crosses cascade boundaries, exit mc-session. // First make sure the current undoable change is synced. if (!curbuf->b_u_synced) { @@ -2570,6 +2573,12 @@ static void u_undoredo(bool undo, bool do_buf_event) // Make sure the cursor is on an existing line and column. check_cursor(curwin); + if (!undo && curhead->uh_cursor_after.lnum > 0) { + // Restore the post-change cursor pos, if available. + curwin->w_cursor = curhead->uh_cursor_after; + check_cursor(curwin); + } + // Remember where we are for "g-" and ":earlier 10s". curbuf->b_u_seq_cur = curhead->uh_seq; if (undo) { diff --git a/src/nvim/undo_defs.h b/src/nvim/undo_defs.h index e071880876..61640902d9 100644 --- a/src/nvim/undo_defs.h +++ b/src/nvim/undo_defs.h @@ -55,8 +55,9 @@ struct u_header { int uh_walk; ///< used by undo_time() u_entry_T *uh_entry; ///< pointer to first entry u_entry_T *uh_getbot_entry; ///< pointer to where ue_bot must be set - pos_T uh_cursor; ///< cursor position before saving - colnr_T uh_cursor_vcol; + pos_T uh_cursor; ///< Pre-change cursor pos; restored on undo. + pos_T uh_cursor_after; ///< Post-change pos; restored on redo. Not saved to undofile. + colnr_T uh_cursor_vcol; ///< Virtual column of uh_cursor ('virtualedit'), or -1. int uh_flags; ///< see below fmark_T uh_namedm[NMARKS]; ///< marks before undo/after redo extmark_undo_vec_t uh_extmark; ///< info to move extmarks diff --git a/src/nvim/vvars.lua b/src/nvim/vvars.lua index 9dac310edb..103b2b4067 100644 --- a/src/nvim/vvars.lua +++ b/src/nvim/vvars.lua @@ -886,10 +886,10 @@ M.vars = { termresponse = { type = 'string', desc = [=[ - The value of the most recent OSC or DCS control sequence - received by Nvim from the terminal. This can be read in a - |TermResponse| event handler after querying the terminal using - another escape sequence. + The most recent OSC, DCS, APC, or recognized CSI (DA1, kitty + multiple-cursors) control sequence received by Nvim from the + host-terminal. Can be read in a |TermResponse| event handler + after querying the terminal. ]=], }, testing = { diff --git a/test/functional/api/ui_spec.lua b/test/functional/api/ui_spec.lua index 6600ae89ea..c3cf7ac37f 100644 --- a/test/functional/api/ui_spec.lua +++ b/test/functional/api/ui_spec.lua @@ -133,9 +133,10 @@ describe('nvim_ui_send', function() screen:expect_unchanged() - -- The TUI client queries OSC 11 on connect, so that precedes the payload. - local bg_request = '\027]11;?\007' - eq(bg_request .. 'Hello world', table.concat(read_data)) + -- On connect, these queries precede the payload. + local mcursor_request = '\027[> q' -- kitty-multicursor (CSI > SP q) query. + local bg_request = '\027]11;?\007' -- TUI client OSC 11 query. + eq(mcursor_request .. bg_request .. 'Hello world', table.concat(read_data)) end) it('ignores ui_send event for UIs without stdout_tty', function() diff --git a/test/functional/api/vim_spec.lua b/test/functional/api/vim_spec.lua index ede230693f..3e694548e9 100644 --- a/test/functional/api/vim_spec.lua +++ b/test/functional/api/vim_spec.lua @@ -2883,6 +2883,15 @@ describe('API', function() eq({ 'a', 'b', 'c' }, eval('[g:one, g:Two, g:THREE]')) api.nvim_load_context(ctx) eq({ 1, 2, 3 }, eval('[g:one, g:Two, g:THREE]')) + + -- Context restores what it saved, irrespective of 'shada'. + command('set shada=') + command('autocmd OptionSet shada let g:optionset = 1') + api.nvim_set_var('one', 'a') + api.nvim_load_context(ctx) + eq(1, eval('g:one')) + eq('', eval('&shada')) + eq(0, eval("get(g:, 'optionset', 0)")) end) it('errors when context dict is invalid', function() diff --git a/test/functional/core/main_spec.lua b/test/functional/core/main_spec.lua index 8ef937ad06..075fc24a5d 100644 --- a/test/functional/core/main_spec.lua +++ b/test/functional/core/main_spec.lua @@ -209,6 +209,7 @@ describe('vim._core', function() 'vim._core.help', 'vim._core.log', 'vim._core.marks', + 'vim._core.mcursor', 'vim._core.options', 'vim._core.proc', 'vim._core.server', diff --git a/test/functional/editor/cmdatom_spec.lua b/test/functional/editor/cmdatom_spec.lua index 568474418e..17b9bfc726 100644 --- a/test/functional/editor/cmdatom_spec.lua +++ b/test/functional/editor/cmdatom_spec.lua @@ -124,7 +124,7 @@ describe('CmdAtom', function() it('Lua-callback mapping (e.g. "]q" default)', function() -- Same shape as the "]q" default mapping: a Lua callback with no - -- replayable keys. Still a user action: it publishes with an empty + -- replayable keys. Still a user action: it emits with an empty -- replay payload. n.exec_lua([[ vim.keymap.set('n', ']q', function() @@ -199,7 +199,7 @@ describe('CmdAtom', function() end) it('motions, search, Ex emit without an edit', function() - -- Emission is not tied to editing: every user action publishes, so + -- Emission is not tied to editing: every user action emits, so -- plugins can observe all activity. fn.setline(1, { 'alpha beta', 'gamma delta' }) feed('gg0') @@ -409,6 +409,29 @@ describe('CmdAtom', function() feed('2G&3G2&') eq({ 'red a', 'red b', 'red c', 'red d' }, get_lines()) eq({ type = 'excmd', lhs = '2&', keys = ':.,.+1s\n' }, pick(atom_last(), 'type', 'lhs', 'keys')) + + -- Reg "." put re-inserts through edit(), and a mapping's composite labels it. + api.nvim_buf_set_lines(0, 0, -1, true, { 'one' }) + feed('ggifoo') + feed('$".p') + eq('fooonefoo', fn.getline(1)) + eq( + { type = 'insert', lhs = k('1afoo'), keys = k('1afoo') }, + pick(atom_last(), 'type', 'lhs', 'keys') + ) + command('nnoremap ,p ".p') + feed(',p') + eq( + { type = 'insert', lhs = ',p', keys = k('1afoo') }, + pick(atom_last(), 'type', 'lhs', 'keys') + ) + -- Typed ":put ." emits typed cmdline payload, not the internal ":put _" translation. + feed(':put .') + eq('foo', fn.getline(2)) + eq( + { type = 'excmd', lhs = ':put .\n', keys = ':put .\n', text = 'put .' }, + pick(atom_last(), 'type', 'lhs', 'keys', 'text') + ) end) it('mapping that enters :terminal mode', function() @@ -468,11 +491,6 @@ describe('CmdAtom', function() local evs = take() eq(1, #evs) eq('@q', evs[1].lhs) - -- Same for "Q" (replay the last recorded register). - feed('Q') - evs = take() - eq(1, #evs) - eq({ lhs = '@q', keys = 'dl' }, pick(evs[1], 'lhs', 'keys')) -- Programmatic / replayed input must NOT leak any atom. fresh() @@ -1444,6 +1462,12 @@ describe('CmdAtom', function() end, }) vim.keymap.set('n', '.', function() + -- Multicursors: degrade to builtin ".", which repeats at each cursor. + local mc = vim.api.nvim_create_namespace('nvim.multicursor') + if #vim.api.nvim_buf_get_extmarks(0, mc, 0, -1, { limit = 1 }) > 0 then + vim.api.nvim_feedkeys('.', 'n', false) + return + end vim.schedule(function() if last then vim.api.nvim_feedkeys(last.keys or last.lhs, last.keys and 'n' or 'm', false) @@ -1491,6 +1515,17 @@ describe('CmdAtom', function() retry(nil, 1000, function() eq('cd', fn.getline(1)) end) + + -- While multicursor is active, the "." mapping degrades to builtin "." so it cascades. + api.nvim_buf_set_lines(0, 0, -1, true, { 'aaa', 'bbb', 'ccc' }) + feed('gg0QjQj') + feed('x') + n.poke_eventloop() + eq({ 'aa', 'bb', 'cc' }, api.nvim_buf_get_lines(0, 0, -1, true)) + feed('.') + retry(nil, 1000, function() + eq({ 'a', 'b', 'c' }, api.nvim_buf_get_lines(0, 0, -1, true)) + end) end) it('"," repeats the last motion atom', function() diff --git a/test/functional/editor/macro_spec.lua b/test/functional/editor/macro_spec.lua index b7c43e0469..b5d80e0731 100644 --- a/test/functional/editor/macro_spec.lua +++ b/test/functional/editor/macro_spec.lua @@ -74,7 +74,7 @@ describe('macros with default mappings', function() eq('lxxx', eval('@i')) end) - it('can be replayed with Q', function() + it('can be replayed with @@', function() insert [[ hello hello @@ -87,39 +87,7 @@ helloFOO hello hello]] - feed [[Q]] - expect [[ -helloFOOFOO -hello -hello]] - - feed [[G3Q]] - expect [[ -helloFOOFOO -hello -helloFOOFOOFOO]] - - feed [[ggV3jQ]] - expect [[ -helloFOOFOOFOO -helloFOO -helloFOOFOOFOOFOO]] - end) - - it('can be replayed with Q and @@', function() - insert [[ -hello -hello -hello]] - feed [[gg]] - - feed [[qqAFOOq]] - expect [[ -helloFOO -hello -hello]] - - feed [[Q]] + feed [[@q]] expect [[ helloFOOFOO hello @@ -170,39 +138,6 @@ helloFOO123 helloFOO]] end) - it('can be recorded and replayed in Visual mode', function() - insert('foo BAR BAR foo BAR foo BAR BAR BAR foo BAR BAR') - feed('0vqifofRq') - eq({ 0, 1, 7, 0 }, fn.getpos('.')) - eq({ 0, 1, 1, 0 }, fn.getpos('v')) - feed('Q') - eq({ 0, 1, 19, 0 }, fn.getpos('.')) - eq({ 0, 1, 1, 0 }, fn.getpos('v')) - feed('Q') - eq({ 0, 1, 27, 0 }, fn.getpos('.')) - eq({ 0, 1, 1, 0 }, fn.getpos('v')) - feed('@i') - eq({ 0, 1, 43, 0 }, fn.getpos('.')) - eq({ 0, 1, 1, 0 }, fn.getpos('v')) - end) - - it('can be recorded and replayed in Visual mode when ignorecase', function() - command('set ignorecase') - insert('foo BAR BAR foo BAR foo BAR BAR BAR foo BAR BAR') - feed('0vqifofRq') - eq({ 0, 1, 7, 0 }, fn.getpos('.')) - eq({ 0, 1, 1, 0 }, fn.getpos('v')) - feed('Q') - eq({ 0, 1, 19, 0 }, fn.getpos('.')) - eq({ 0, 1, 1, 0 }, fn.getpos('v')) - feed('Q') - eq({ 0, 1, 27, 0 }, fn.getpos('.')) - eq({ 0, 1, 1, 0 }, fn.getpos('v')) - feed('@i') - eq({ 0, 1, 43, 0 }, fn.getpos('.')) - eq({ 0, 1, 1, 0 }, fn.getpos('v')) - end) - it('can be replayed with @ in blockwise Visual mode', function() insert [[ hello diff --git a/test/functional/editor/mcursor_spec.lua b/test/functional/editor/mcursor_spec.lua new file mode 100644 index 0000000000..06ed5775bd --- /dev/null +++ b/test/functional/editor/mcursor_spec.lua @@ -0,0 +1,2739 @@ +-- Multicursor tests. + +local t = require('test.testutil') +local n = require('test.functional.testnvim')() +local Screen = require('test.functional.ui.screen') +local t_atom = require('test.functional.editor.atom_testutil') + +local describe, it, before_each = t.describe, t.it, t.before_each +local pending = t.pending +local clear = n.clear +local command = n.command +local feed = n.feed +local fn = n.fn +local eq = t.eq +local api = n.api +local get_lines = t_atom.get_lines +local k = t_atom.k +local atoms_start = t_atom.atoms_start +local atoms = t_atom.atoms +local atoms_tail = t_atom.atoms_tail +local atom_last = t_atom.atom_last + +--- Clears the buffer mcursors like the default CTRL-L mapping (test-harness "mapclear" removed it). +local function clear_cursors() + n.exec_lua( + [[vim.api.nvim_buf_clear_namespace(0, vim.api.nvim_create_namespace('nvim.multicursor'), 0, -1)]] + ) +end + +--- Number of multicursors. +local function ncursors() + local ns = api.nvim_create_namespace('nvim.multicursor') + return #api.nvim_buf_get_extmarks(0, ns, 0, -1, {}) +end + +--- Positions ({row, col}, 0-based, in position order) of the mcursors. +local function anchors() + local ns = api.nvim_create_namespace('nvim.multicursor') + local positions = {} + for _, m in ipairs(api.nvim_buf_get_extmarks(0, ns, 0, -1, {})) do + positions[#positions + 1] = { m[2], m[3] } + end + return positions +end + +--- Sets the buffer lines, then places cursors by "Q". +local function cursors(lines, place) + api.nvim_buf_set_lines(0, 0, -1, true, lines) + feed('gg0') + feed(place or 'QjQj') +end + +--- Asserts table-driven cascade rows. +--- - `row.place` (default: "gg0") places the primary. +--- - `row.keys` input (the "Q" placements and the operation), then compare the whole buffer. +--- - `row.after` optionally feeds a key after the operation. +--- - `row.pre` runs a :command first (such rows go last: the option persists). +local function assert_rows(rows) + for _, row in ipairs(rows) do + clear_cursors() + if row.pre then + command(row.pre) + end + api.nvim_buf_set_lines(0, 0, -1, true, row.lines) + feed(row.place or 'gg0') + feed(row.keys) + eq(row.expect, get_lines(), row.keys) + if row.after then + feed(row.after) + eq(row.after_expect, get_lines(), ('%s ; %s'):format(row.keys, row.after)) + end + end +end + +describe('multicursor', function() + before_each(function() + clear() + command('hi MCursor guifg=Black guibg=LightGrey') + end) + + describe('Q (add cursor)', function() + it('does not modify buffer', function() + cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQ') + eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) + end) + + it('creates a cursor again after clearing all', function() + cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQ') + clear_cursors() + clear_cursors() -- Repeated clear (nothing to remove) is a no-op. + eq(0, ncursors()) + eq(false, n.exec_lua("return require('vim._core.mcursor').active()")) + feed('Q') + eq(1, ncursors()) + eq(true, n.exec_lua("return require('vim._core.mcursor').active()")) + feed('gg0x') + eq({ 'aa', 'bb', 'ccc' }, get_lines()) + end) + + it('clearing mcursors also disables q= follow-mode', function() + cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQ') + feed('q=') + clear_cursors() + feed('Q') + eq(1, ncursors()) + feed('l') + eq(1, ncursors()) + -- Partial-range clear does not end the mc-session; the remaining cursors keep "q=". + clear_cursors() + cursors({ 'aaa', 'bbb', 'ccc' }) -- cursors on lines 1-2, primary on line 3 + feed('q=') + n.exec_lua( + [[vim.api.nvim_buf_clear_namespace(0, vim.api.nvim_create_namespace('nvim.multicursor'), 0, 1)]] + ) + eq(1, ncursors()) -- cursor 1 is gone, cursor 2 still exists. + feed('l') + eq({ { 1, 1 } }, anchors()) -- Still in follow-mode. + end) + + it('nvim_mcursor() at an existing cursor is a no-op (no double-apply)', function() + fn.setline(1, { 'abcdef', 'ghijkl' }) + feed('gg0') + api.nvim_mcursor(0, { 1, 0 }) + api.nvim_mcursor(0, { 1, 0 }) -- The duplicate is ignored ("Q" toggles instead)... + eq(1, ncursors()) + feed('j0x') -- ...so the edit applies once at the line-1 cursor. + eq({ 'bcdef', 'hijkl' }, get_lines()) + eq(1, ncursors()) + end) + + it('adds a cursor in the current buffer while mcursors exist in another', function() + cursors({ 'aaa', 'bbb' }, 'Q') + eq(1, ncursors()) + command('set hidden') + feed(':enew') -- Typed, so the buffer switch goes through atom capture. + cursors({ 'xxx', 'yyy' }, 'Q') + eq(1, ncursors()) -- One cursor in this buffer (other buf keeps its own). + feed('jx') -- Only the current buffer's cursors cascade. + eq({ 'xx', 'yy' }, get_lines()) + command('buffer #') + eq(1, ncursors()) + eq({ 'aaa', 'bbb' }, get_lines()) -- The other buffer was not touched. + end) + + it('entering a buffer with mcursors via a nav mapping keeps them', function() + -- A navigation mapping ("nnoremap l") ending in another buffer must not count as + -- "the mapping edited the buffer". + command('nnoremap p') + fn.setline(1, { 'aaa', 'bbb' }) + feed('gg0Q') -- Cursor in buf1, at the primary position. + eq(1, ncursors()) + command('set hidden') + command('vsplit | enew') + fn.setline(1, { 'xxx', 'yyy' }) + feed('gg0Q') -- Cursor in buf2. + eq(1, ncursors()) + feed('') -- Mapped switch to win1/buf1. + eq(1, ncursors()) -- buf1 cursor still exists + feed('') -- and back + eq(1, ncursors()) -- buf2 cursor also + end) + + it('"qQ" is recording (register Q), not a cursor', function() + -- "q" is the recording command; a stray "q" before "Q" starts recording + -- into register Q (uppercase: append) instead of adding a cursor. + feed('qQ') + eq('Q', fn.reg_recording()) + eq(0, ncursors()) + feed('q') -- stop recording: "Q" creates cursors again + eq('', fn.reg_recording()) + feed('Q') + eq(1, ncursors()) + end) + + it('gQ restores the cleared cursors (like gv)', function() + cursors({ 'aaa', 'bbb', 'ccc', 'ddd' }, 'QjQ') + clear_cursors() + eq(0, ncursors()) + feed('gQ') + eq(2, ncursors()) + feed('Gx') -- the restored cursors cascade + eq({ 'aa', 'bb', 'ccc', 'dd' }, get_lines()) + -- The snapshot is extmark-tracked: edits in between shift it. + clear_cursors() + feed('ggO') -- new line on top shifts the snapshot down + feed('gQ') + eq({ { 1, 0 }, { 2, 0 } }, anchors()) + end) + + it(':edit! clears the gQ snapshot', function() + local fname = t.tmpname() + fn.writefile({ 'aaa', 'bbb' }, fname) + command('edit ' .. fname) + feed('gg0QjQ') + clear_cursors() + command('edit!') + feed('gQ') + eq(0, ncursors()) -- nothing to restore: the snapshot died with the text + end) + + it(':g//normal! Q places a cursor at each match', function() + fn.setline(1, { 'foo a', 'bar b', 'foo c', 'baz d', 'foo e' }) + command('g/foo/normal! Q') + eq(3, ncursors()) + feed('A!') -- edit applies once per line (primary ends on the last match) + eq({ 'foo a!', 'bar b', 'foo c!', 'baz d', 'foo e!' }, get_lines()) + end) + + it('Q in operator-pending mode aborts the operator', function() + -- nv_Q: checkclearop() clears the pending operator, no cursor. + fn.setline(1, { 'one two' }) + feed('gg0') + feed('dQ') + eq({ 'one two' }, get_lines()) + eq(0, ncursors()) + feed('wx') -- "w" moves (the "d" is gone), then x deletes one char + eq({ 'one wo' }, get_lines()) + end) + + it('Q in Visual mode adds a cursor on each selected line', function() + fn.setline(1, { 'aaa', 'bbb', 'ccc' }) + feed('ggvjQ') -- selection spans lines 1-2 + eq('n', fn.mode()) -- Visual mode ended + eq({ 1, 0 }, api.nvim_win_get_cursor(0)) -- primary: first selected line + eq(2, ncursors()) -- one per selected line, including under the primary + feed('x') -- edits both lines + eq({ 'aa', 'bb', 'ccc' }, get_lines()) + -- Cursors align by screen column, not byte column: a multibyte char before the cursor + -- on one line must not shift the cursors on the other lines. + clear_cursors() + api.nvim_buf_set_lines(0, 0, -1, true, { 'é123', 'abcdef' }) + feed('gg0llvjQ') -- Visual from "2" (line 1) down; cursor ends on "c" (screen column 3) + eq({ 1, 3 }, api.nvim_win_get_cursor(0)) -- primary: on "2", not mid-"é" + feed('x') + eq({ 'é13', 'abdef' }, get_lines()) + end) + + it('Q then non-moving edit applies once (cursor merges into primary)', function() + fn.setline(1, { 'ab' }) + feed('Q') + eq(1, ncursors()) + feed('x') + eq({ 'b' }, get_lines()) + eq(0, ncursors()) -- merged at the cascade; multicursor mode ended + end) + + it('Q on an existing cursor removes it (toggle)', function() + fn.setline(1, { 'aaa', 'bbb' }) + feed('Q') + eq(1, ncursors()) + feed('Q') -- toggle off: multicursor mode ends + eq(0, ncursors()) + feed('QjQk') + eq(2, ncursors()) + feed('Q') -- removes only the cursor under the primary + eq(1, ncursors()) + feed('x') -- the remaining cursor still cascades + eq({ 'aa', 'bb' }, get_lines()) + end) + + it('Q not allowed in a macro (recording or executing)', function() + -- |mcursor-limitations|: Q beeps (no cursor) while recording or executing a macro. + fn.setline(1, { 'aaa', 'bbb', 'ccc' }) + feed('gg0') + feed('qq') + feed('Q') -- recording: not allowed (but still recorded) + feed('q') + eq(0, ncursors()) + feed('@q') -- executing the recorded "Q": not allowed either + eq(0, ncursors()) + feed('Q') -- outside a macro: works + eq(1, ncursors()) + end) + + it('1Q then Q mixes both cursor sets', function() + fn.setline(1, { 'foo bar foo' }) + feed('gg0*') -- sets the last search pattern; the cursor lands on the second "foo" + feed('1Q') + eq(2, ncursors()) -- both "foo"s, including under the primary + feed('0fb') + feed('Q') + eq(3, ncursors()) + feed('$') -- the primary edits a fourth position + feed('x') + eq({ 'oo ar o' }, get_lines()) + end) + end) + + describe('mouse', function() + it(' toggles a cursor at the click, without moving the primary', function() + command('set mousetime=0') -- repeated clicks must not count as double-clicks + fn.setline(1, { 'aaa', 'bbb', 'ccc' }) + feed('gg0') + api.nvim_input_mouse('left', 'press', 'C', 0, 2, 0) -- ctrl-click on line 3 + api.nvim_input_mouse('left', 'release', 'C', 0, 2, 0) + -- The primary did not move; a cursor was added at left-click. + eq(1, fn.line('.')) + eq({ { 2, 0 } }, anchors()) + feed('x') -- cascades to the clicked position + eq({ 'aa', 'bbb', 'cc' }, get_lines()) + -- CTRL-click on the existing cursor removes it (toggle). + api.nvim_input_mouse('left', 'press', 'C', 0, 2, 0) + api.nvim_input_mouse('left', 'release', 'C', 0, 2, 0) + eq(0, ncursors()) + -- CTRL-click keeps "q=" follow-mode (unlike Q). + feed('ggQj') + feed('q=') + api.nvim_input_mouse('left', 'press', 'C', 0, 2, 0) + api.nvim_input_mouse('left', 'release', 'C', 0, 2, 0) + feed('l') + eq({ { 0, 1 }, { 2, 1 } }, anchors()) -- Still following: the motion cascaded. + feed('q=') -- Off. + -- No-op in Insert mode. + feed('i') + api.nvim_input_mouse('left', 'press', 'C', 0, 0, 1) + api.nvim_input_mouse('left', 'release', 'C', 0, 0, 1) + eq(2, ncursors()) + eq(2, fn.line('.')) + eq('i', fn.mode()) + feed('') + end) + + it('middle-click paste applies once', function() + -- Fake clipboard provider: "*" must not touch the real system clipboard. + clear('--cmd', 'set rtp^=test/functional/fixtures') + fn.setline(1, { 'abc', 'def', 'ghi' }) + fn.setreg('*', 'NEW') -- middle-click pastes the * register + feed('gg0') + api.nvim_input_mouse('middle', 'press', '', 0, 2, 0) + api.nvim_input_mouse('middle', 'release', '', 0, 2, 0) + feed('') + -- Sanity: middle-click pastes (no cursors yet). + local _, pasted = table.concat(get_lines(), '\n'):gsub('NEW', '') + eq(1, pasted) + feed('ggQjQ2j') -- cursors on lines 1-2, primary below + api.nvim_input_mouse('middle', 'press', '', 0, 0, 0) + api.nvim_input_mouse('middle', 'release', '', 0, 0, 0) + feed('') + -- One more paste at the click position; no paste at the other cursors. + _, pasted = table.concat(get_lines(), '\n'):gsub('NEW', '') + eq(2, pasted) + end) + end) + + describe('normal-mode cascade', function() + it('CTRL-C interrupts the cascade; one "u" undoes the partial edit', function() + local nlines = 5000 + local lines = {} ---@type string[] + for i = 1, nlines do + lines[i] = 'aaa' + end + api.nvim_buf_set_lines(0, 0, -1, true, lines) + api.nvim_win_set_cursor(0, { 1, 0 }) + for i = 2, nlines do + api.nvim_mcursor(0, { i, 0 }) + end + eq(nlines - 1, ncursors()) + + -- Interrupt from in-process on_key handler. The cascade runs too fast for test-runner CTRL-C. + n.exec_lua(function() + _G.keys_seen, _G.cascading_seen = 0, false + vim.on_key(function() + _G.keys_seen = _G.keys_seen + 1 + _G.cascading_seen = _G.cascading_seen or vim.api.nvim__mcursor_cascading() + if _G.keys_seen == 1000 then + vim.fn.interrupt() + end + end) + end) + + feed('x') -- Primary edit + cascade over ~5000 cursors... + n.poke_eventloop() + eq(true, n.exec_lua('return _G.cascading_seen')) + local edited, unedited = 0, 0 + for _, l in ipairs(get_lines()) do + if l == 'aa' then + edited = edited + 1 + elseif l == 'aaa' then + unedited = unedited + 1 + end + end + eq(nlines, edited + unedited) -- no line was half-edited + eq(true, edited >= 1) -- the primary's own edit happened + eq(true, unedited >= 1) -- >=1 cursor did NOT cascade + -- The partial cascade is still one undo block. + feed('u') + eq(lines, get_lines()) + end) + + it('successive operations update cursor positions', function() + cursors({ 'AAAA', 'BBBB', 'CCCC' }, 'QjQ') + feed('jx') + eq({ 'AAA', 'BBB', 'CCC' }, get_lines()) + feed('x') + eq({ 'AA', 'BB', 'CC' }, get_lines()) + end) + + it('cursor positions follow line insertions/deletions', function() + -- "o" inserts a line at each cursor; cursors below must shift. + cursors({ 'aaa', 'bbb', 'ccc' }) + feed('oX') + eq({ 'aaa', 'X', 'bbb', 'X', 'ccc', 'X' }, get_lines()) + -- The primary cursor also shifted (2 lines were inserted above it). + eq(6, fn.line('.')) + end) + + it('CTRL-D scrolling does not affect the other cursors', function() + local screen = Screen.new(30, 10) + local l = {} + for i = 1, 50 do + l[#l + 1] = ('line%d'):format(i) + end + cursors(l) + -- Scrolling is viewport-dependent, not repeatable. Other cursors do not move, even in + -- follow-mode. + feed('') + eq({ { 0, 0 }, { 1, 0 } }, anchors()) + feed('q=') + feed('') + feed('q=') + eq({ { 0, 0 }, { 1, 0 } }, anchors()) + feed('') + -- An edit still cascades to the off-screen cursors, and the viewport stays anchored + -- (replays scroll the window; the cascade restores it). + feed('x') + eq({ 'ine1', 'ine2' }, { get_lines()[1], get_lines()[2] }) + screen:expect([[ + line5 | + line6 | + ^ine7 | + line8 | + line9 | + line10 | + line11 | + line12 | + line13 | + multicursor: ...ow motion off | + ]]) + end) + end) + + describe('. (dot-repeat)', function() + it('repeats operators, pre-cursor edits, inserts and changes at all cursors', function() + -- Operator. + cursors({ 'aaa', 'bbb', 'ccc' }) + feed('x') + eq({ 'aa', 'bb', 'cc' }, get_lines()) + feed('.') + eq({ 'a', 'b', 'c' }, get_lines()) + -- An edit made before placing cursors repeats at all of them. + clear_cursors() + api.nvim_buf_set_lines(0, 0, -1, true, { 'aaa', 'bbb', 'ccc' }) + feed('gg0') + feed('x') + eq({ 'aa', 'bbb', 'ccc' }, get_lines()) + feed('QjQj') + feed('.') + eq({ 'a', 'bb', 'cc' }, get_lines()) + -- Insert. + clear_cursors() + cursors({ 'aaa', 'bbb' }, 'Qj') + feed('iZ') + eq({ 'Zaaa', 'Zbbb' }, get_lines()) + feed('.') + eq({ 'ZZaaa', 'ZZbbb' }, get_lines()) + -- Change, with a q= move between the change and the repeat. + clear_cursors() + cursors({ 'one two', 'one two' }, 'Qj') + feed('cwX') + eq({ 'X two', 'X two' }, get_lines()) + feed('q=') + feed('w') + feed('q=') + feed('.') + eq({ 'X X', 'X X' }, get_lines()) + end) + end) + + describe('per-cursor registers', function() + it('yy/p roundtrips each cursor through its own register', function() + cursors({ 'aaa', 'bbb' }, 'Qj') + feed('yy') + feed('p') + eq({ 'aaa', 'aaa', 'bbb', 'bbb' }, get_lines()) + end) + + it('empty at cursor init restores as empty', function() + fn.setline(1, { 'one two', 'three four' }) + feed('gg0') + feed('Q') -- this cursor's snapshot: @a is EMPTY + feed('j0') + command([[let @a = 'LEAK']]) -- primary-only write (synthetic: no cascade) + feed('"ap') + -- The replay loads the other cursor's register snapshot, where @a was empty: nothing + -- pastes there (empty registers are omitted; the primary's @a must not leak through). + eq({ 'one two', 'tLEAKhree four' }, get_lines()) + end) + + it('dW + p swaps words using each cursor register', function() + cursors({ 'one two', 'three four' }, 'Qj') + feed('q=') + feed('dW') + eq({ 'two', 'four' }, get_lines()) + feed('E') + feed('p') + feed('q=') + eq({ 'twoone ', 'fourthree ' }, get_lines()) + end) + + it('clearing joins/gathers the per-cursor registers (DWIM)', function() + cursors({ 'one two', 'three four' }, 'Qj') + feed('dW') + eq({ 'two', 'four' }, get_lines()) + clear_cursors() + -- On exit, each cursor's delete is joined (document order) into '"'. + eq('one \nthree \n', fn.getreg('"')) + feed('Gop') + eq({ 'two', 'four', '', 'one ', 'three ' }, get_lines()) + + -- A cursor whose replays never wrote a register contributes nothing: it has no snapshot, + -- so the pre-session '"' cannot be joined in as if it were that cursor's delete. + fn.setreg('"', 'STALE') + cursors({ '', 'abc' }, 'Qj') + feed('x') -- writes a register at the primary only: "x" on the cursor's empty line is a no-op + clear_cursors() + eq('a', fn.getreg('"')) -- No contributions to join; primary's register is untouched. + eq('v', fn.getregtype('"')) + + -- Partial-range clear deletes cursors without ending the session; no join/gather (yet). + cursors({ 'one two', 'three four', 'five six' }) + feed('dW') + eq({ 'two', 'four', 'six' }, get_lines()) + n.exec_lua( + [[vim.api.nvim_buf_clear_namespace(0, vim.api.nvim_create_namespace('nvim.multicursor'), 0, 1)]] + ) + eq('five ', fn.getreg('"')) -- Primary's own delete, unjoined. + clear_cursors() + eq('three \nfive \n', fn.getreg('"')) -- All cursors cleared => yanks joined. + end) + + it('visual p pastes each cursor register over the selection', function() + cursors({ 'aaa X', 'bbb Y' }, 'Qj') + feed('yiw') + feed('q=') + feed('w') + feed('q=') + feed('viwp') + eq({ 'aaa aaa', 'bbb bbb' }, get_lines()) + end) + end) + + describe('operator matrix', function() + it('operators cascade at each cursor', function() + assert_rows({ + -- x deletes a char at each cursor. + { lines = { 'aaa', 'bbb', 'ccc' }, keys = 'QjQjx', expect = { 'aa', 'bb', 'cc' } }, + -- dw deletes a word at each cursor. + { + lines = { 'hello world', 'foo bar', 'one two' }, + keys = 'QjQjdw', + expect = { 'world', 'bar', 'two' }, + }, + -- 2x deletes 2 chars at each cursor. + { lines = { 'aaaa', 'bbbb', 'cccc' }, keys = 'QjQj2x', expect = { 'aa', 'bb', 'cc' } }, + -- dd deletes a line at each cursor. + { + lines = { 'a1', 'a2', 'b1', 'b2', 'c1', 'c2' }, + keys = 'Q2jQ2jdd', + expect = { 'a2', 'b2', 'c2' }, + }, + -- D deletes to EOL, C changes to EOL. + { + lines = { 'one two', 'three four' }, + place = 'gg0ll', + keys = 'QjD', + expect = { 'on', 'th' }, + }, + { + lines = { 'one two', 'three four' }, + place = 'gg0ll', + keys = 'QjCX', + expect = { 'onX', 'thX' }, + }, + -- 2cl changes 2 chars. + { lines = { 'abcd', 'efgh' }, keys = 'Qj2clXY', expect = { 'XYcd', 'XYgh' } }, + -- ~ toggles case and advances. + { lines = { 'abc', 'def' }, keys = 'Qj~~', expect = { 'ABc', 'DEf' } }, + -- cT{char} changes backwards. + { + lines = { 'x_abcY', 'w_defgZ' }, + place = 'gg$', + keys = 'Qj$cT_M', + expect = { 'x_MY', 'w_MZ' }, + }, + -- d4h deletes backwards. + { lines = { 'abcdef', 'ghijkl' }, place = 'gg$', keys = 'Qj$d4h', expect = { 'af', 'gl' } }, + -- c2aw changes counted text objects. + { + lines = { 'one two three', 'foo bar baz' }, + keys = 'Qjc2awX', + expect = { 'Xthree', 'Xbaz' }, + }, + -- ci( on empty parens inserts inside. + { + lines = { 'a()b', 'c()d' }, + place = 'gg0l', + keys = 'Qjci(X', + expect = { 'a(X)b', 'c(X)d' }, + }, + -- ci" seeks forward to the quotes. + { + lines = { 'x "aa" y', 'z "bbb" w' }, + keys = 'Qjci"NEW', + expect = { 'x "NEW" y', 'z "NEW" w' }, + }, + -- df{char} cascades with its payload char. + { lines = { 'ab,cd', 'wxy,z' }, keys = 'Qjdf,', expect = { 'cd', 'z' } }, + -- [count]r{char} replaces. + { lines = { 'abc', 'def' }, keys = 'Qj2rZ', expect = { 'ZZc', 'ZZf' } }, + -- r and gr{char}: self-terminating replace sessions (no trailing ) cascade. + { + lines = { 'abcd', 'efgh' }, + place = 'gg0l', + keys = 'Qjr', + expect = { 'a', 'cd', 'e', 'gh' }, + }, + { lines = { 'abc', 'def' }, keys = 'QjgrZ', expect = { 'Zbc', 'Zef' } }, + -- x deletes one multibyte char at each cursor. + { lines = { 'éàü', '日本語' }, keys = 'Qjx', expect = { 'àü', '本語' } }, + -- diw leaves the cursor at the deleted region (the "x" probes every cursor). + { + lines = { 'foo bar', 'baz qux', 'aaa bbb' }, + place = 'gg0l', + keys = 'QjQjdiw', + expect = { ' bar', ' qux', ' bbb' }, + after = 'x', + after_expect = { 'bar', 'qux', 'bbb' }, + }, + -- guiW lowercases and leaves the cursor at the region start. + { + lines = { 'FOO BAR', 'BAZ QUX' }, + place = 'gg0l', + keys = 'QjguiW', + expect = { 'foo BAR', 'baz QUX' }, + after = 'x', + after_expect = { 'oo BAR', 'az QUX' }, + }, + -- An operator with a search-motion payload cascades whole (the atom IS the redobuff); + -- each cursor finds its own match. + { + lines = { 'aaa find end', 'bbb find end' }, + keys = 'Qjd/find', + expect = { 'find end', 'find end' }, + }, + -- cc preserves per-line indent with 'autoindent'. + { + lines = { ' aaa', ' bbb' }, + pre = 'set autoindent', + keys = 'QjccX', + expect = { ' X', ' X' }, + }, + -- >> indents the line at each cursor. + { + lines = { 'foo', 'bar' }, + pre = 'set shiftwidth=2', + keys = 'Qj>>', + expect = { ' foo', ' bar' }, + }, + }) + end) + end) + + describe('[count]Q (search matches)', function() + it('places a cursor at each match of the last search pattern', function() + fn.setline(1, { 'foo bar foo', 'baz foo qux', 'foobar foo' }) + feed('gg0') -- on the first "foo" + feed('*') -- whole-word pattern; the cursor moves to the next match + feed('1Q') + -- 4 whole-word "foo" matches ("foobar" excluded), including under the primary. + eq(4, ncursors()) + -- The primary cursor does not move ("*" left it on the second match). + eq({ 1, 8 }, api.nvim_win_get_cursor(0)) + feed('cwXXX') + eq({ 'XXX bar XXX', 'baz XXX qux', 'foobar XXX' }, get_lines()) + -- The cursor under the primary merged at the cascade (no double-apply). + eq(3, ncursors()) + -- A "/" search likewise, also with several matches per line. + clear_cursors() + api.nvim_buf_set_lines(0, 0, -1, true, { 'ab ab ab', 'xx ab' }) + feed('gg0/ab') -- the cursor lands on the second "ab" + feed('1Q') + eq(4, ncursors()) + feed('x') + eq({ 'b b b', 'xx b' }, get_lines()) + -- Placement uses the real search engine, so it matches what "n" finds under the current + -- case options. 'ignorecase': "/foo" matches all three cases. + clear_cursors() + command('set ignorecase') + api.nvim_buf_set_lines(0, 0, -1, true, { 'Foo foo FOO' }) + feed('gg0/foo') + feed('1Q') + eq(3, ncursors()) + feed('gUiw') + eq({ 'FOO FOO FOO' }, get_lines()) + -- 'smartcase': an uppercase letter in the pattern forces case-sensitivity, so only the + -- exact-case match is a cursor (matchbufline would have matched all three). + clear_cursors() + command('set smartcase') + api.nvim_buf_set_lines(0, 0, -1, true, { 'Foo foo Foo' }) + feed('gg0/Foo') -- only the two "Foo"s, not "foo" + feed('1Q') + eq(2, ncursors()) + feed('x') + eq({ 'oo foo oo' }, get_lines()) + end) + + it('does nothing without a previous search (E35)', function() + fn.setline(1, { 'foo foo' }) + feed('1Q') + eq(0, ncursors()) + end) + end) + + describe('g CTRL-A (counter)', function() + it('inserts an ascending number at each cursor', function() + cursors({ 'a', 'b', 'c' }, 'Qj0Qj0') + feed('g') + eq({ '1a', '2b', '3c' }, get_lines()) + -- A count sets the starting number. + clear_cursors() + 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. + clear_cursors() + cursors({ 'x', 'y', 'z' }, 'QjQj') + feed('gg0g') + eq({ '1x', '2y', 'z' }, get_lines()) + eq(2, ncursors()) + end) + + it('via a mapping applies ONCE (cursor-global: no avalanche)', function() + cursors({ 'a', 'b', 'c' }) + command('nnoremap ,n g') + feed(',n') + eq({ '1a', '2b', '3c' }, get_lines()) + end) + + it('without cursors, g CTRL-A is not a command', function() + fn.setline(1, { 'x 5' }) + feed('gg0g') + eq({ 'x 5' }, get_lines()) -- beeps, no counter and no increment + feed('') -- plain CTRL-A increments as usual + eq({ 'x 6' }, get_lines()) + end) + + it('number() takes start/step/format', function() + cursors({ 'x', 'x', 'x' }, 'Qj0Qj0') + n.exec_lua([[require('vim._core.mcursor').number(10, 2, '%d) ')]]) + eq({ '10) x', '12) x', '14) x' }, get_lines()) + end) + end) + + describe('nvim_mcursor()', function() + it('adds a cursor at (row, col), which cascades', function() + fn.setline(1, { 'aaa', 'bbb', 'ccc' }) + eq(1, api.nvim_mcursor(0, { 1, 0 })) + eq(2, api.nvim_mcursor(0, { 2, 0 })) + feed('Gx') + eq({ 'aa', 'bb', 'cc' }, get_lines()) + + -- Can add mcursor to a hidden buffer. + command('set hidden') + local other = api.nvim_create_buf(true, false) + api.nvim_buf_set_lines(other, 0, -1, true, { 'xxx', 'yyy' }) + eq(3, api.nvim_mcursor(other, { 1, 0 })) + api.nvim_set_current_buf(other) + feed('Gx') + eq({ 'xx', 'yy' }, get_lines()) + end) + + it('rejects invalid positions', function() + fn.setline(1, { 'aaa' }) + t.matches('Invalid cursor line: out of range', t.pcall_err(api.nvim_mcursor, 0, { 99, 0 })) + t.matches( + "Invalid 'pos': expected %[row, col%] array", + t.pcall_err(api.nvim_mcursor, 0, { 1 }) + ) + t.matches('Invalid buffer', t.pcall_err(api.nvim_mcursor, 9999, { 1, 0 })) + end) + + it('deleting a cursor extmark deletes the 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, {}) + eq(2, #marks) + api.nvim_buf_del_extmark(0, ns, marks[1][1]) + feed('x') -- the cursor without a mark is swept; the other cascades + eq({ 'aaa', 'bb', 'cc' }, get_lines()) + eq(1, ncursors()) + end) + + it('exit with cursors active in multiple buffers', function() + cursors({ 'aaa', 'bbb' }, 'Qj') + feed('yy') -- Session-written register, exit path reaches mc_reg_gather. + command('new') + fn.setline(1, { 'ccc' }) + api.nvim_mcursor(0, { 1, 0 }) + n.expect_exit(command, 'qall!') + end) + + it('cursors are freed 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 })) + command('new') + fn.setreg('"', 'KEEP') + command('bwipeout! ' .. buf) + -- Registers NOT gathered/joined, the cleared cursors are not in curbuf. + eq('KEEP', fn.getreg('"')) + eq('v', fn.getregtype('"')) + fn.setline(1, { 'xxx', 'yyy' }) + -- The wiped buffer's cursors are gone: only the new one counts. + eq(1, api.nvim_mcursor(0, { 1, 0 })) + feed('Gx') + eq({ 'xx', 'yy' }, get_lines()) + end) + end) + + describe('buffers and windows', function() + it('cascade is per-buffer: pauses in another buffer, resumes on return', function() + -- Cursors cascade only if their buffer is the current buffer. + command('set hidden') + cursors({ 'aaa', 'bbb' }, 'Qj') + command('enew') + fn.setline(1, { 'xxx' }) + feed('gg0x') -- no cascade into the other buffer + eq({ 'xx' }, get_lines()) + eq(0, ncursors()) + command('buffer #') + eq({ 'aaa', 'bbb' }, get_lines()) + eq(1, ncursors()) -- the extmark-tracked cursor survived the round-trip + feed('2G0x') + eq({ 'aa', 'bb' }, get_lines()) + end) + + it('each buffer has its own cursor set', function() + command('set hidden') + fn.setline(1, { 'aaa', 'bbb' }) + local buf_a = api.nvim_get_current_buf() + feed('gg0Q') + feed('j') + command('new') + local buf_b = api.nvim_get_current_buf() + cursors({ 'xxx', 'yyy' }, 'Qj') + eq(1, ncursors()) + feed('x') -- cascades only in the current buffer + eq({ 'xx', 'yy' }, get_lines()) + eq({ 'aaa', 'bbb' }, api.nvim_buf_get_lines(buf_a, 0, -1, true)) + command('wincmd p') + feed('2G0x') + eq({ 'aa', 'bb' }, get_lines()) + eq({ 'xx', 'yy' }, api.nvim_buf_get_lines(buf_b, 0, -1, true)) + end) + + it('edits cascade from any window showing the buffer (:split)', function() + -- Cascade is conditional on the buffer, not the window. + cursors({ 'aaa', 'bbb' }, 'Q') + command('split') + feed('jx') -- edit from the new window + eq({ 'aa', 'bb' }, get_lines()) + end) + + it('clearing is per-buffer', function() + command('set hidden') + cursors({ 'aaa', 'bbb' }, 'Qj') + command('enew') + clear_cursors() + command('buffer #') + eq(1, ncursors()) + clear_cursors() + eq(0, ncursors()) + feed('gg0x') -- only the primary edits + eq({ 'aa', 'bbb' }, get_lines()) + end) + + it('nvim_buf_clear_namespace("nvim.multicursor") deletes the cursors', function() + cursors({ 'aaa', 'bbb' }, 'Qj') + eq(1, ncursors()) + n.exec_lua([[ + vim.api.nvim_buf_clear_namespace(0, vim.api.nvim_create_namespace('nvim.multicursor'), 0, -1) + ]]) + eq(0, ncursors()) + feed('gQ') -- the deletion snapshotted the cursors: restorable + eq(1, ncursors()) + feed('x') -- both cursors edit again (primary still on line 2) + eq({ 'aa', 'bb' }, get_lines()) + end) + + it(':edit! reload clears the cursors', function() + local fname = t.tmpname() + fn.writefile({ 'aaa', 'bbb' }, fname) + command('edit ' .. fname) + feed('gg0Q') + feed('j') + eq(1, ncursors()) + command('edit!') + eq(0, ncursors()) + feed('Q') -- A new session starts cleanly. + eq(1, ncursors()) + end) + end) + + describe('insert-mode cascade', function() + it('CTRL-U cascades before (deletion crossing the session anchor)', function() + -- Deleting typed text cascades live (the region shrinks). But CTRL-U here eats the "o" + -- autoindent, which precedes the tracked region, invisible to the preview diff. + -- Non-literal keys flush instead: the pending keys replay as a span immediately. + command('set autoindent') + cursors({ ' indented aa', ' indented cc' }, 'Q') + feed('2gg0') + feed('o') + feed('') + -- Mid-session (no yet): the cursor's line must match the + -- primary's (indent eaten at both). + eq({ ' indented aa', '', ' indented cc', '' }, get_lines()) + end) + + it('o + CTRL-U does not join lines at less-indented cursors', function() + -- 'autoindent': "o" opens an indented line at the indented cursors, an empty line at the flat + -- cursor. The replayed CTRL-U there has nothing to delete; it must not backspace through the + -- line boundary ('backspace' includes "eol") and join lines. + command('set autoindent') + cursors({ ' indented aa', 'flat bb', ' indented cc' }) + feed('oyay') + eq({ + ' indented aa', + '\tyay', + 'flat bb', + '\tyay', + ' indented cc', + '\tyay', + }, get_lines()) + end) + + it('cursor-moves cascade live at each cursor', function() + cursors({ 'alpha one', 'beta two', 'gamma three' }) + -- A cursor-move key is a cmd, not text: it flushes pending keys as a span (like CTRL-U). + feed('AabX') + -- Mid-session (no yet): the cursor-move already applied everywhere. + eq({ 'alpha oneXab', 'beta twoXab', 'gamma threeXab' }, get_lines()) + feed('') + feed('AZY') + eq({ 'Yalpha oneXabZ', 'Ybeta twoXabZ', 'Ygamma threeXabZ' }, get_lines()) + -- Word-wise cursor-move: per-cursor word boundaries, not a copied column offset. + feed('AW') + eq({ 'Yalpha WoneXabZ', 'Ybeta WtwoXabZ', 'Ygamma WthreeXabZ' }, get_lines()) + end) + + it('CTRL-G U cursor-move cascades live; one undo block', function() + cursors({ 'aa', 'bb' }, 'Qj') + feed('i12U3') + -- Mid-session: the CTRL-G U move flushed and cascaded. + eq({ '132aa', '132bb' }, get_lines()) + feed('') + -- CTRL-G U does not split undo (unlike plain ). + feed('u') + eq({ 'aa', 'bb' }, get_lines()) + end) + + it('absolute jump () splits: previews stay, the live cascade re-anchors', function() + fn.setline(1, { 'alpha', 'beta', 'gamma' }) + feed('j0Q') + feed('j0Q') + feed('gg$') + feed('ixy') + -- Mid-session: "x" stays as text; "y" (after jump) continues at the re-anchored positions. + eq({ 'yalphxa', 'xybeta', 'xygamma' }, get_lines()) + feed('') + eq({ 'yalphxa', 'xybeta', 'xygamma' }, get_lines()) + end) + + it('CTRL-C ends the session like : text and cursors survive', function() + command('set autoindent') + cursors({ ' indented aa', 'flat bb', ' indented cc' }) + feed('oyay') + -- Drain first: a CTRL-C pending in typeahead cancels the queued keys before they execute + -- (unrelated to multicursor). + n.poke_eventloop() + feed('') + -- CTRL-C ended the insert session; it must not also abort the commit replay (the primary + -- keeps its text on CTRL-C, so the cursors do too). + eq({ + ' indented aa', + '\tyay', + 'flat bb', + '\tyay', + ' indented cc', + '\tyay', + }, get_lines()) + -- The cursors survive: a subsequent edit still cascades to all three. + feed('x') + eq({ + ' indented aa', + '\tya', + 'flat bb', + '\tya', + ' indented cc', + '\tya', + }, get_lines()) + end) + + it('EOL insertion points display as virtual cells', function() + local screen = Screen.new(40, 5) + command('hi MCursor guifg=NONE guibg=Red') + cursors({ 'aa', 'bbbb', 'cc' }) + -- "A": each insertion point is past EOL (no char cell), a virtual cell overlays it. #41576 + feed('A') + screen:add_extra_attr_ids({ + [100] = { background = Screen.colors.Red }, + }) + screen:expect([[ + aa{100: } | + bbbb{100: } | + cc^ | + {1:~ }| + {5:-- INSERT --} | + ]]) + -- Ending the session moves cursors onto last char (real text); the virtual cell disappears. + feed('!') + screen:expect([[ + aa{100:!} | + bbbb{100:!} | + cc^! | + {1:~ }| + | + ]]) + end) + + it('typed text appears at cursors before leaving insert-mode', function() + local screen = Screen.new(30, 6) + cursors({ 'aaa', 'bbb', 'ccc' }) + -- Still in insert mode (no yet): the text already cascaded; each cursor displays + -- at its insertion point, like the primary. + feed('iXY') + screen:expect([[ + XY{17:a}aa | + XY{17:b}bb | + XY^ccc | + {1:~ }|*2 + {5:-- INSERT --} | + ]]) + feed('') + eq({ 'XYaaa', 'XYbbb', 'XYccc' }, get_lines()) + -- The session end shifts every cursor onto the last inserted char. + screen:expect([[ + X{17:Y}aaa | + X{17:Y}bbb | + X^Yccc | + {1:~ }|*2 + | + ]]) + -- Entering insert mode displays each cursor at its insertion point + -- right away, BEFORE any text is typed ("a": one char to the right). + feed('a') + screen:expect([[ + XY{17:a}aa | + XY{17:b}bb | + XY^ccc | + {1:~ }|*2 + {5:-- INSERT --} | + ]]) + feed('') + -- An operator entry ("ciw") live-mirrors the same way: the entry + -- replay changes each cursor's OWN word, still in insert mode. + feed('0ciwZ') + screen:expect([[ + Z{17: } | + Z{17: } | + Z^ | + {1:~ }|*2 + {5:-- INSERT --} | + ]]) + feed('') + -- A Visual-entered change ("viwc") live-mirrors too: the entry replay + -- re-executes the selection at each cursor. + feed('viwcW') + screen:expect([[ + W{17: } | + W{17: } | + W^ | + {1:~ }|*2 + {5:-- INSERT --} | + ]]) + feed('') + end) + end) + + describe('insert-mode depth', function() + it('session survives all cursors deduping away mid-session', function() + -- A cursor at the primary's position: the "A" entry replay lands it on the primary and + -- dedupe removes it mid-session; the commit must not cascade into the empty set. + fn.setline(1, { 'x' }) + feed('Q') + feed('Ahi') + n.assert_alive() + eq({ 'xhi' }, get_lines()) + end) + + it('insert sessions cascade at each cursor', function() + assert_rows({ + -- iZ: after the cursors sit ON the last inserted char (like the primary cursor). + { + lines = { 'aaa', 'bbb', 'ccc' }, + keys = 'QjQjiZ', + expect = { 'Zaaa', 'Zbbb', 'Zccc' }, + after = 'x', + after_expect = { 'aaa', 'bbb', 'ccc' }, + }, + -- A: distinct chars and line lengths, so an off-by-one anchor (e.g. a past-EOL anchor + -- clamped by a replay in MODE_NORMAL) inserts before the last char, which identical + -- chars ("!!") would mask. + { + lines = { 'a', 'bb bb', 'c cc' }, + keys = 'QjQjA!?', + expect = { 'a!?', 'bb bb!?', 'c cc!?' }, + after = 'x', + after_expect = { 'a!', 'bb bb!', 'c cc!' }, + }, + -- cw changes a word at each cursor. + { + lines = { 'aaa one', 'bbb two', 'ccc three' }, + keys = 'QjQjcwFOO', + expect = { 'FOO one', 'FOO two', 'FOO three' }, + }, + -- cascades deletion. + { lines = { 'aaa', 'bbb' }, keys = 'QjiXYZ', expect = { 'XZaaa', 'XZbbb' } }, + -- deletes before the insert start. + { + lines = { 'abc', 'def' }, + place = 'gg0l', + keys = 'QjiZ', + expect = { 'Zbc', 'Zef' }, + }, + -- at col 0 joins with the line above. + { + lines = { 'aa', 'bb', 'cc', 'dd' }, + place = 'ggj0', + keys = 'Q2ji', + expect = { 'aabb', 'ccdd' }, + }, + -- mid-insert splits the line. + { + lines = { 'aaXbb', 'ccXdd' }, + place = 'gg02l', + keys = 'QjiABCD', + expect = { 'aaAB', 'CDXbb', 'ccAB', 'CDXdd' }, + }, + -- O opens a line above. + { + lines = { 'aaa', 'bbb', 'ccc' }, + keys = 'Q2jOX', + expect = { 'X', 'aaa', 'bbb', 'X', 'ccc' }, + }, + -- a appends after the cursor char. + { lines = { 'abc', 'def' }, keys = 'QjaZ', expect = { 'aZbc', 'dZef' } }, + -- I inserts at the first non-blank. + { + lines = { ' aa', ' bb' }, + place = 'gg$', + keys = 'Qj$IX', + expect = { ' Xaa', ' Xbb' }, + }, + -- i_CTRL-W deletes a word. + { + lines = { 'zz', 'yy' }, + keys = 'Qjifoo barX', + expect = { 'foo Xzz', 'foo Xyy' }, + }, + -- i_CTRL-V inserts a unicode char; after the cursors sit ON it. + { + lines = { 'aaa', 'bbb' }, + keys = 'Qjiu00e9', + expect = { 'éaaa', 'ébbb' }, + after = 'x', + after_expect = { 'aaa', 'bbb' }, + }, + -- i at col 0 keeps the cursor at col 0. + { + lines = { 'abc', 'def' }, + keys = 'Qji', + expect = { 'abc', 'def' }, + after = 'x', + after_expect = { 'bc', 'ef' }, + }, + -- 3iZ (counted insert) cascades the whole session. + { lines = { 'aaa', 'bbb' }, keys = 'Qj3iZ', expect = { 'ZZZaaa', 'ZZZbbb' } }, + -- R replaces; R + restores the replaced chars. + { lines = { 'abcdef', 'ghijkl' }, keys = 'QjRXY', expect = { 'XYcdef', 'XYijkl' } }, + { + lines = { 'abcdef', 'ghijkl' }, + keys = 'QjRXY', + expect = { 'abcdef', 'ghijkl' }, + }, + -- Abbreviations expand identically at each cursor. + { + lines = { 'aaa', 'bbb' }, + pre = 'iabbrev teh the', + keys = 'Qjiteh ', + expect = { 'the aaa', 'the bbb' }, + }, + -- 'autoindent' applies per cursor with o. + { + lines = { ' aa', ' bb' }, + pre = 'set autoindent', + keys = 'QjoX', + expect = { ' aa', ' X', ' bb', ' X' }, + }, + }) + end) + + it('ea appends at word end at each cursor (with q=)', function() + cursors({ 'one two', 'three four' }, 'Qj') + feed('q=') + feed('ea!') + feed('q=') + eq({ 'one! two', 'three! four' }, get_lines()) + end) + + it('i_CTRL-N completion result appears at each cursor', function() + fn.setline(1, { 'wombat', 'wo', 'wo' }) + feed('2gg') + feed('Q') + feed('j') + feed('A') + eq({ 'wombat', 'wombat', 'wombat' }, get_lines()) + end) + end) + + describe('navigation state (primary-only)', function() + it('jumplist, changelist and marks follow only the primary', function() + local l = {} + for i = 1, 30 do + l[#l + 1] = ('word%d line'):format(i) + end + cursors(l) + -- Change marks and changelist: one edit at 3 cursors records the primary's position only. + local nchanges = #fn.getchangelist()[1] + feed('x') + eq({ 3, 0 }, api.nvim_buf_get_mark(0, '[')) + eq({ 3, 0 }, api.nvim_buf_get_mark(0, ']')) + eq({ 3, 0 }, api.nvim_buf_get_mark(0, '.')) + eq(nchanges + 1, #fn.getchangelist()[1]) + -- Visual marks: the primary's selection. + feed('viwy') + eq({ 3, 0 }, api.nvim_buf_get_mark(0, '<')) + eq({ 3, 3 }, api.nvim_buf_get_mark(0, '>')) + -- Jumplist: a followed jump records one entry (the primary's). + -- (Last: all cursors land on line 30 and merge.) + command('clearjumps') + feed('q=') + feed('G') + feed('q=') + eq(1, #fn.getjumplist()[1]) + end) + end) + + describe('completion', function() + -- While a completion is active, the cascade pauses: redobuff is frozen, and spans cannot replay + -- into a busy completion (edit() refuses recursive insert). The other cursors catch up when the + -- completion ends. + + --- Three empty lines under "foo*" completion candidates; cursors on lines 4-5, primary on 6. + local function ac_setup() + command('setlocal autocomplete') + cursors({ 'foo', 'foobar', 'foobarbaz', '', '', '' }, '3jQjQj') + end + + it("'autocomplete' popup: cursors catch up when the completion ends", function() + local screen = Screen.new(40, 12) + ac_setup() + feed('if') + feed('o') + screen:expect({ any = 'INSERT' }) + -- The preview updates live (even during completion); cursors intact. + eq(2, ncursors()) + feed('') + eq({ 'foo', 'foobar', 'foobarbaz', 'fo', 'fo', 'fo' }, get_lines()) + eq(2, ncursors()) + end) + + it("'autocomplete': accepting a completion applies at all cursors", function() + ac_setup() + feed('if') + feed('') -- select the first popup entry + feed('') -- accept + feed(' tail') -- and keep typing + local l = get_lines() + eq({ l[4], l[4] }, { l[5], l[6] }) + end) + + it("'autocomplete': and cancel propagate", function() + ac_setup() + feed('ifoox') + eq({ 'fox', 'fox', 'fox' }, { get_lines()[4], get_lines()[5], get_lines()[6] }) + feed('cc') + feed('f') -- cycle, then cancel back to the leader + eq({ 'f', 'f', 'f' }, { get_lines()[4], get_lines()[5], get_lines()[6] }) + end) + + it('InsertCharPre-driven complete() plugin (cmp-style)', function() + local screen = Screen.new(40, 12) + -- Typical third-party completion plugin: an InsertCharPre handler + -- that schedules complete() (textlock forbids calling it directly). + n.exec_lua([[ + vim.api.nvim_create_autocmd('InsertCharPre', { + callback = function() + vim.schedule(function() + if vim.fn.mode():find('i') and vim.fn.pumvisible() == 0 then + vim.fn.complete(1, { 'foobar', 'foolish' }) + end + end) + end, + }) + ]]) + cursors({ '', '', '' }) + feed('if') + screen:expect({ any = 'foolish' }) -- wait for the popup + feed('') -- accept the (pre-inserted) first candidate + feed('') + eq({ 'foobar', 'foobar', 'foobar' }, get_lines()) + screen:expect({ none = 'foolish' }) -- popup closed + -- with the popup still visible keeps all cursors equal too. + feed('cc') + feed('x') + screen:expect({ any = 'foolish' }) -- wait for the new popup + feed('') + local l = get_lines() + eq({ l[3], l[3] }, { l[1], l[2] }) + end) + end) + + describe('visual-mode cascade', function() + it('failed command mid-replay does not leak Visual mode into the next replay', function() + fn.setline(1, { 'alpha bravo', 'golf hotel', 'mike november' }) + feed('ggVjjQ') -- cursor on each line; enables "q=" follow-motion + feed('gg0') + -- The abandoned selection replays "vlo h " at each cursor ("q=" follow). The "h" fails + -- (col 0 after "o" swapped to the selection start), which flushes the rest of the replay, + -- eating the terminating . Visual mode must not leak into the next cursor's replay. + feed('vloh') + eq({ 'alpha bravo', 'golf hotel', 'mike november' }, get_lines()) + eq('n', api.nvim_get_mode().mode) + end) + + it('shows per-cursor visual selection', function() + local screen = Screen.new(30, 6) + cursors({ 'longword x', 'ab y', 'medium z' }) + -- Each cursor shows its own selection ("iw" = that cursor's word), previewed live. + feed('viw') + screen:expect([[ + {17:longword} x | + {17:ab} y | + {17:mediu}^m z | + {1:~ }|*2 + {5:-- VISUAL --} | + ]]) + feed('e') + screen:expect([[ + {17:longword x} | + {17:ab y} | + {17:medium }^z | + {1:~ }|*2 + {5:-- VISUAL --} | + ]]) + feed('') + screen:expect([[ + {17:l}ongword x | + {17:a}b y | + medium ^z | + {1:~ }|*2 + | + ]]) + end) + + it('shows linewise/blockwise selections', function() + local screen = Screen.new(30, 6) + cursors({ 'aaaa', 'bbbb', 'cccc', 'dddd' }, 'Q2j') + feed('Vj') -- linewise: primary lines 3-4, fake lines 1-2 + screen:expect([[ + {17:aaaa} | + {17:bbbb} | + {17:cccc} | + ^d{17:ddd} | + {1:~ }| + {5:-- VISUAL LINE --} | + ]]) + feed('') + feed('3G0l') + feed('jl') -- blockwise: primary (3,1)-(4,2), fake (1,0)-(2,1) + screen:expect([[ + {17:aa}aa | + {17:bb}bb | + c{17:cc}c | + d{17:d}^dd | + {1:~ }| + {5:-- VISUAL BLOCK --} | + ]]) + end) + + it('replays the full visual keysequence', function() + cursors({ 'one two three x', 'aa bb cc d' }, 'Qj') + -- Select word, extend twice, delete: selection re-executes at each cursor, so the extents are + -- per-cursor (not a fixed-size reselect). + atoms_start() + feed('viweex') + eq({ ' x', ' d' }, get_lines()) + -- The operator is normalized ("translated"): visual "x" == "d". + eq({ 'viweed' }, atoms_tail(1)) + end) + + it('operators cascade at each cursor', function() + assert_rows({ + -- Vjd deletes two lines. + { lines = { 'a', 'b', 'c', 'd', 'e', 'f' }, keys = 'Q4jVjd', expect = { 'c', 'd' } }, + -- r replaces the selection. + { lines = { 'one two', 'ab cd' }, keys = 'QjviwrX', expect = { 'XXX two', 'XX cd' } }, + -- Blockwise c changes the block, I inserts before it, A appends after it. + { + lines = { 'ab', 'cd', 'ef', 'gh' }, + keys = 'Q2jjcX', + expect = { 'Xb', 'Xd', 'Xf', 'Xh' }, + }, + { + lines = { 'ab', 'cd', 'ef', 'gh' }, + keys = 'Q2jjIX', + expect = { 'Xab', 'Xcd', 'Xef', 'Xgh' }, + }, + { + lines = { 'ab', 'cd', 'ef', 'gh' }, + keys = 'Q2jjA!', + expect = { 'a!b', 'c!d', 'e!f', 'g!h' }, + }, + }) + end) + + it(' discards the pending visual atom', function() + cursors({ 'abc', 'def' }, 'Qj') + feed('viw') + feed('x') -- cascades normally; no stray visual replay + eq({ 'bc', 'de' }, get_lines()) + end) + + it('cursor displays at each selection end; o swaps it', function() + local screen = Screen.new(40, 6) + command('hi MCursor guifg=NONE guibg=Red') + command('hi MCursorVisual guibg=Blue') + cursors({ 'aaa bbb ccc', 'ddd eee fff', 'ggg hhh iii' }, '4lQjQj') + feed('vl') + screen:add_extra_attr_ids({ + [100] = { background = Screen.colors.Blue1 }, + }) + -- The (red) display cursor sits at each selection end; the (blue) + -- anchor cell shows only the selection. + screen:expect([[ + aaa {100:b}{30:b}b ccc | + ddd {100:e}{30:e}e fff | + ggg {17:h}^hh iii | + {1:~ }|*2 + {5:-- VISUAL --} | + ]]) + feed('o') + screen:expect([[ + aaa {30:b}{100:b}b ccc | + ddd {30:e}{100:e}e fff | + ggg ^h{17:h}h iii | + {1:~ }|*2 + {5:-- VISUAL --} | + ]]) + feed('') + -- Back to normal mode: the cursor highlight returns to the anchors. + screen:expect([[ + aaa {30:b}bb ccc | + ddd {30:e}ee fff | + ggg ^hhh iii | + {1:~ }|*2 + | + ]]) + end) + + it('payload motions: f{char} and search', function() + cursors({ 'abcd,ef', 'wxyz,gh' }, 'Qj') + feed('vf,d') -- f-operand: replayable, cascades. + eq({ 'ef', 'gh' }, get_lines()) + clear_cursors() + cursors({ 'one two', 'one two' }, 'Qj') + feed('v/twod') -- Search payload travels in the collected keys, cascades. + eq({ 'wo', 'wo' }, get_lines()) + clear_cursors() + -- Viewport scroll (C-E) that drags the cursor (viewport edge, 'scrolloff') changes the + -- selection by a viewport-dependent amount: not replayable, edits primary only. + local lines = {} + for i = 1, 30 do + lines[i] = 'x' .. i + end + fn.setline(1, lines) + feed('10ggQgg') + feed('V') -- Drags the primary onto line 2: selection is lines 1-2. + feed('d') + eq(28, fn.line('$')) -- Primary deleted 2 lines; no cascade. + eq({ 'x3', 'x4' }, { fn.getline(1), fn.getline(2) }) + end) + end) + + describe('q= (follow motion)', function() + it('cursors follow primary-cursor motions', function() + cursors({ 'abcd', 'efgh' }, 'Q') + feed('j') -- No cascade/follow. + feed('q=') + feed('ll') -- Cascade, both cursors move 2 chars rightwards. + feed('x') + eq({ 'abd', 'efh' }, get_lines()) + -- Toggle off: "h" moves only primary, the other cursor still deletes at its unmoved position. + feed('q=') + feed('h') + feed('x') + eq({ 'ab', 'eh' }, get_lines()) + -- [count]q= forces the mode instead of toggling. + feed('1q=') -- Force "on". + feed('1q=') + feed('h') + feed('x') + eq({ 'b', 'h' }, get_lines()) + feed('2q=') -- Force "off". + feed('2q=') + feed('A!') + feed('h') + feed('x') + eq({ 'b', '!' }, get_lines()) + end) + + it('implicit exit (cursors deduped) resets follow-motion', function() + cursors({ 'aaa', 'bbb', 'ccc' }) + eq(2, ncursors()) + feed('q=') + feed('gg') -- Absolute motion: every cursor lands on the primary, all deduped. + eq(0, ncursors()) + -- Exited implicitly: "q=" resets, else the next "Q" would dedupe on "j". + feed('Q') + feed('j') + eq(1, ncursors()) + end) + + it('Q (placing a cursor) ends follow-mode', function() + cursors({ 'aaa', 'bbb', 'ccc' }, 'Qj') + feed('q=') + feed('l') -- Sanity: cascades. + eq({ { 0, 1 } }, anchors()) + feed('Q') -- Adds a cursor at the primary, and exits follow-mode. + eq(2, ncursors()) + feed('j') -- No follow: nothing converges or dedupes. + eq(2, ncursors()) + eq({ { 0, 1 }, { 1, 1 } }, anchors()) + end) + + it('jumps are not followed (CTRL-O, backtick)', function() + cursors({ 'abcd', 'efgh', 'ijkl' }, 'Q') + feed('3G') -- Jumps fill the jumplist; no cascade. + feed('2G') + feed('q=') -- Follow-mode. + feed('l') -- Sanity: motions cascade. + eq({ { 0, 1 } }, anchors()) + feed('') -- Jump. + feed('``') -- Jump. + eq({ { 0, 1 } }, anchors()) + end) + + it('cursors follow j/k, gj/gk, arrow keys, and $', function() + cursors({ 'a1', 'a2', 'a3', 'b1', 'b2', 'b3' }, 'Q') + feed('3j') -- Cursors at "a1" and "b1". + feed('q=') + feed('j') -- Both cursors move down. + feed('x') + eq({ 'a1', '2', 'a3', 'b1', '2', 'b3' }, get_lines()) + feed('k') -- Both cursors move back up. + feed('x') + eq({ '1', '2', 'a3', '1', '2', 'b3' }, get_lines()) + feed('gj') -- Display-line motions follow too. + feed('x') + eq({ '1', '', 'a3', '1', '', 'b3' }, get_lines()) + feed('gk') + feed('x') + eq({ '', '', 'a3', '', '', 'b3' }, get_lines()) + -- Arrow keys follow. + clear_cursors() + cursors({ 'abcd', 'efgh', 'ijkl', 'mnop' }, 'Q2j') -- Cursors at "abcd" and "ijkl". + feed('q=') + feed('') + feed('') + feed('x') + eq({ 'abcd', 'egh', 'ijkl', 'mop' }, get_lines()) + feed('') + feed('') + feed('x') + eq({ 'bcd', 'egh', 'jkl', 'mop' }, get_lines()) + feed('q=') + -- $ moves each cursor to its own EOL. + clear_cursors() + cursors({ 'abc', 'defgh' }, 'Qj') + feed('q=') + feed('$') + feed('x') + eq({ 'ab', 'defg' }, get_lines()) + end) + + it('cursors follow mapped motions (nnoremap j gj)', function() + command('nnoremap j gj') + cursors({ 'a1', 'a2', 'b1', 'b2' }, 'Q2j') + feed('q=') + feed('j') + feed('x') + eq({ 'a1', '2', 'b1', '2' }, get_lines()) + -- Expr-mapping results follow the same way. + command([[nnoremap j v:count == 0 ? 'gk' : 'k']]) + feed('j') + feed('x') + eq({ '1', '2', '1', '2' }, get_lines()) + -- No follow: a motion mapping does not cascade; the "x" does. + feed('q=') + feed('j') -- gk: moves only the primary. + feed('x') + eq({ '', '', '1', '2' }, get_lines()) + end) + + it('q= while macro-recording does not toggle', function() + fn.setline(1, { 'a1', 'a2', 'b1', 'b2' }) + feed('gg0') + feed('Q') -- Cursor placed before recording starts. + feed('qa') -- Start recording. + feed('2j') + feed('q=') -- q stops recording; = is a pending operator... + feed('') + feed('j') + feed('x') + -- Follow did not toggle. + 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). + feed('Q') + feed('3jhh') -- Primary at (4,2), a different column. + feed('q=') + feed('jj') -- Over the short lines: each cursor keeps its column. + feed('q=') + feed('x') + eq({ 'ABCDEF', 'xy', 'GHIJL', 'MNOPQR', 'zw', 'STVWX' }, get_lines()) + end) + end) + + describe('parity', function() + it('mapped command cascades its resolved atom', function() + command('nnoremap ,d dw') + cursors({ 'one two aa bb', 'three four cc dd' }, 'Qj') + atoms_start() + feed(',d') + eq({ 'two aa bb', 'four cc dd' }, get_lines()) + -- With mcursors: still exactly one CmdAtom event; cascade replays do not emit CmdAtoms. + eq(1, #atoms()) + end) + + it('mapping with edits and motions cascades as one unit', function() + -- Split the line at the cursor, ending at the EOL of the first half. + command('nnoremap gj ik$') + cursors({ 'aaa bbb', 'ccc ddd', 'eee fff' }, '4lQjQj') + atoms_start() + feed('gj') + eq({ 'aaa ', 'bbb', 'ccc ', 'ddd', 'eee ', 'fff' }, get_lines()) + -- The mapping is the atom: exactly one CmdAtom event. Never re-resolved. + local evs = atoms() + eq(1, #evs) + eq({ type = 'mapping', lhs = 'gj', keys = k('1iik$'), changed = true }, { + type = evs[1].type, + lhs = evs[1].lhs, + keys = evs[1].keys, + changed = evs[1].changed, + }) + -- `atoms` is non-empty iff the atom is a composite of more than one command; + local children = {} + for _, c in ipairs(evs[1].atoms) do + table.insert(children, { c.type, c.keys }) + end + eq({ + { 'insert', k('1i') }, -- spans display as "insert" (cascade-internal type) + { 'insert', k('i') }, + { 'motion', 'k' }, + { 'motion', '$' }, + }, children) + eq({ 'k', false }, { evs[1].atoms[3].cmd, evs[1].atoms[3].changed }) + -- The mapping's motions (k$) cascade too, even without "q=", because the mapping edits. + feed('x') + eq({ 'aaa', 'bbb', 'ccc', 'ddd', 'eee', 'fff' }, get_lines()) + end) + + it('operator + insert mapping cascades as one unit', function() + command('nnoremap ,x dwiFOO ') + cursors({ 'one two', 'three four' }, 'Qj') + feed(',x') + eq({ 'FOO two', 'FOO four' }, get_lines()) + end) + + it('live insert cascade emits one whole-session atom', function() + cursors({ 'aaa', 'bbb' }, 'Qj') + atoms_start() + -- Typed key-by-key (event-loop pulses between keys): cascades span-by-span, yet emits one + -- insert-session atom. Spans are cascade-internal, never CmdAtom events. + feed('i') + n.poke_eventloop() + feed('X') + n.poke_eventloop() + feed('Y') + n.poke_eventloop() + feed('') + local evs = atoms() + eq(1, #evs) + eq({ type = 'insert', text = 'XY', keys = k('1iXY') }, { + type = evs[#evs].type, + text = evs[#evs].text, + keys = evs[#evs].keys, + }) + -- No session marks outlive the session (mc_ins_commit() drops them, cascaded or not). + eq( + 0, + n.exec_lua([[ + local ns = vim.api.nvim_get_namespaces()['nvim.multicursor._session'] + return ns and #vim.api.nvim_buf_get_extmarks(0, ns, 0, -1, {}) or 0 + ]]) + ) + -- A Visual-entered session keeps its "visual" type: each nested span-replay bracket owns + -- its own InsSession, so it cannot clobber the primary session's `vis`. + clear_cursors() + cursors({ 'alpha one', 'beta two' }, 'Qj') + feed('viwcX') + eq({ 'X one', 'X two' }, get_lines()) + eq('visual', atom_last().type) + end) + + it('InsertEnter/InsertLeave/TextChanged(I) fire once per action', function() + command('let [g:ie, g:il, g:tci, g:tc] = [0, 0, 0, 0]') + command('autocmd InsertEnter * let g:ie += 1') + command('autocmd InsertLeave * let g:il += 1') + command('autocmd TextChangedI * let g:tci += 1') + command('autocmd TextChanged * let g:tc += 1') + cursors({ 'aaa', 'bbb' }, 'Qj') + -- Drain input between keys: TextChanged(I) fires only at idle. + -- The expected counts below are exactly what this sequence produces WITHOUT multicursors. + feed('i') + n.poke_eventloop() + feed('X') + n.poke_eventloop() + feed('Y') + n.poke_eventloop() + feed('') + n.poke_eventloop() + eq({ 'XYaaa', 'XYbbb' }, get_lines()) + eq(1, api.nvim_get_var('ie')) + eq(1, api.nvim_get_var('il')) + eq(2, api.nvim_get_var('tci')) -- Once per typed char, not per cursor. + eq(1, api.nvim_get_var('tc')) -- Once for the whole session. + end) + + it('InsertCharPre fires once per typed char, result applies everywhere', function() + command('let g:icp = 0') + command('autocmd InsertCharPre * let g:icp += 1 | let v:char = toupper(v:char)') + cursors({ 'aaa', 'bbb' }, 'Qj') + feed('ixy') + eq({ 'XYaaa', 'XYbbb' }, get_lines()) + eq(2, api.nvim_get_var('icp')) + end) + + it('TextYankPost fires per cursor with per-cursor contents', function() + command('let g:yanks = []') + command( + 'autocmd TextYankPost * let g:yanks += [[v:event.regcontents, luaeval("vim.api.nvim__mcursor_cascading()")]]' + ) + cursors({ 'aaa', 'bbb' }, 'Qj') + feed('yy') + -- The primary's own yank fires first and is not a replay. + eq({ { { 'bbb' }, false }, { { 'aaa' }, true } }, api.nvim_get_var('yanks')) + -- The primary's registers win, it is the effective yank. + eq('bbb\n', fn.getreg('"')) + end) + end) + + describe('yank DWIM (concat on exit)', function() + it('joins per-cursor yanks (document order) into the register on exit', function() + cursors({ 'foo x', 'bar y', 'baz z' }, 'Qj0Q') + feed('j0') -- Cursors on lines 1,2; primary on line 3 (yanked first). + feed('yiw') + -- During multicursor, the register is the primary's own yank. + eq('baz', fn.getreg('"')) + clear_cursors() -- Exit: concat, in document-order (not yank-order). + eq('foo\nbar\nbaz\n', fn.getreg('"')) + eq('V', fn.getregtype('"')) -- Linewise. + end) + + it('regular yank (no multicursor)', function() + fn.setline(1, { 'hello' }) + feed('gg0yiw') + eq('hello', fn.getreg('"')) + end) + + it('a named register concatenates, an untouched one is preserved', function() + fn.setreg('z', 'PRESET') -- 'z' is untouched during multicursor. + cursors({ 'foo', 'bar' }, 'Qj0') + feed('"ayiw') + clear_cursors() + eq('foo\nbar\n', fn.getreg('a')) -- The used register is joined. + eq('PRESET', fn.getreg('z')) -- An untouched register is not tripled. + end) + + it('last-write-wins: delete after yank yields the deletes', function() + cursors({ 'aa', 'bb' }, 'Qj0') + feed('yl') -- Yank a char into '"'. + feed('x') -- Delete a char into '"' (overwrites, per register semantics). + clear_cursors() + eq('a\nb\n', fn.getreg('"')) -- The deletes, not the yanks. + end) + end) + + describe('options', function() + it("'textwidth' auto-wrap applies at each cursor", function() + api.nvim_set_option_value('textwidth', 10, {}) + cursors({ 'aaa', 'bbb' }, 'Qj') + feed('Awww xxx yyy zzz') + local lines = get_lines() + -- The text of both cursors wrapped the same way (same number of lines each). + eq(0, #lines % 2) + local half = #lines / 2 + for i = 1, half do + local a = lines[i]:gsub('^aaa', '') + local b = lines[half + i]:gsub('^bbb', '') + eq(a, b) + end + -- The primary's line actually wrapped. + eq(true, #lines > 2) + end) + end) + + describe('undo', function() + it('redo (CTRL-R) restores the cursors to their post-edit positions', function() + cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQj$') + feed('IX ') + eq({ 'X aaa', 'X bbb', 'X ccc' }, get_lines()) + eq({ { 0, 1 }, { 1, 1 } }, anchors()) + feed('u') + eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) + eq({ { 0, 0 }, { 1, 0 } }, anchors()) + feed('') + eq({ 'X aaa', 'X bbb', 'X ccc' }, get_lines()) + -- Splice adjustment alone would drift the marks to col 2: the + -- explicit post-session positions must be restored. + eq({ { 0, 1 }, { 1, 1 } }, anchors()) + -- The primary restores to its recorded post-edit position too (the undo header's cursor is + -- the PRE-change position, it serves undo), in sync with the mcursors. + eq({ 3, 1 }, api.nvim_win_get_cursor(0)) + feed('x') + eq({ 'Xaaa', 'Xbbb', 'Xccc' }, get_lines()) + end) + + it('uu undoes two cascaded edits step by step', function() + cursors({ 'abc', 'def' }, 'Q') + feed('jx') + eq({ 'bc', 'ef' }, get_lines()) + feed('x') + eq({ 'c', 'f' }, get_lines()) + feed('u') + eq({ 'bc', 'ef' }, get_lines()) + feed('u') + eq({ 'abc', 'def' }, get_lines()) + feed('') + eq({ 'c', 'f' }, get_lines()) + -- Counted undo (2u) likewise treats each cascade as one change. + feed('2u') + eq({ 'abc', 'def' }, get_lines()) + end) + + it('bulk undo across a live insert then a cascaded delete (one step each)', function() + cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQj0') + feed('IX') -- live insert at all cursors + eq({ 'Xaaa', 'Xbbb', 'Xccc' }, get_lines()) + feed('x') -- cascaded delete at all cursors + eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) + feed('u') -- revert the delete everywhere + eq({ 'Xaaa', 'Xbbb', 'Xccc' }, get_lines()) + feed('u') -- revert the insert everywhere + eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) + eq(3, fn.line('.')) -- primary placement after undoing a live insert + end) + + it('a mapped undo/redo (vim-repeat "nmap u") does not cascade', function() + -- vim-repeat maps u/U/ to undo/redo wrappers. Such a mapping changes the buffer, but an + -- undo/redo is buffer-global, not a per-cursor edit: it must NOT cascade, or every cursor + -- would undo again, over-undoing the whole session (the reported bug). + fn.setline(1, { 'aaa', 'bbb', 'ccc' }) + feed('gg0Qj0Qj0') -- 3 cursors + feed('x') -- one cascade: aa,bb,cc + eq({ 'aa', 'bb', 'cc' }, get_lines()) + command('nnoremap u :undo') + command('nnoremap :redo') + feed('u') -- ONE undo of the cascade, not one-per-cursor (would reach the empty buffer) + eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) + feed('') -- redo mapping likewise does not cascade + eq({ 'aa', 'bb', 'cc' }, get_lines()) + -- The guard survives a nested normal_execute() after the undo, in the same mapped command. + command('nnoremap u :undo normal! l') + feed('u') + eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) + end) + + it('u/CTRL-R ping-pong toggles without drift', function() + -- Repeated undo/redo of one cascade must stabilize (no extmark drift). + cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQ') + feed('jx') + eq({ 'aa', 'bb', 'cc' }, get_lines()) + feed('u') + eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) + feed('') + eq({ 'aa', 'bb', 'cc' }, get_lines()) + feed('u') + eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) + feed('') + eq({ 'aa', 'bb', 'cc' }, get_lines()) + end) + + it('undo steps across the mc-enter boundary', function() + -- Q is placement, not an edit: the undo tree spans pre-mc and in-mc + -- edits with no extra step in between. + fn.setline(1, { 'xxx', 'yyy' }) + feed('gg0x') + eq({ 'xx', 'yyy' }, get_lines()) + feed('Q') + feed('jx') + eq({ 'x', 'yy' }, get_lines()) + feed('u') + eq({ 'xx', 'yyy' }, get_lines()) + feed('u') + eq({ 'xxx', 'yyy' }, get_lines()) + end) + + it('u after a line-inserting cascade restores every cursor position', function() + -- "o" shifts the other cursors' lines; undo must move them all back + -- (cursor extmarks restore on undo). + cursors({ 'aaa', 'bbb', 'ccc' }) + feed('oX') + eq({ 'aaa', 'X', 'bbb', 'X', 'ccc', 'X' }, get_lines()) + feed('u') + eq({ 'aaa', 'bbb', 'ccc' }, get_lines()) + feed('x') + eq({ 'aa', 'bb', 'cc' }, get_lines()) + end) + + it('one u/CTRL-R reverts a whole macro (@q) cascade', function() + -- "@x" cascades as ONE unit, so it is one undo block. + fn.setreg('q', 'iX\027') + cursors({ 'aaa', 'bbb' }, 'Qj') + feed('@q') + eq({ 'Xaaa', 'Xbbb' }, get_lines()) + feed('u') + eq({ 'aaa', 'bbb' }, get_lines()) + feed('') + eq({ 'Xaaa', 'Xbbb' }, get_lines()) + end) + + it('u undoes a visual-mode cascade at all cursors', function() + cursors({ 'abc def', 'ghi jkl' }, 'Qj') + feed('viwd') + eq({ ' def', ' jkl' }, get_lines()) + feed('u') + eq({ 'abc def', 'ghi jkl' }, get_lines()) + -- The cursors are back at their pre-edit positions. + feed('x') + eq({ 'bc def', 'hi jkl' }, get_lines()) + end) + + it('g-/g+ (time-travel) exits multicursor mode', function() + -- Out of scope forever (mcursor.md): time-travel jumps across cascade + -- boundaries, where per-cursor state is meaningless: all cursors are + -- removed instead. + cursors({ 'aaa', 'bbb' }, 'Q') + feed('jx') + eq({ 'aa', 'bb' }, get_lines()) + eq(1, ncursors()) + feed('g-') + eq(0, ncursors()) + eq({ 'aaa', 'bbb' }, get_lines()) -- one step back: the whole cascade + feed('g+') + eq(0, ncursors()) -- still out of multicursor mode + eq({ 'aa', 'bb' }, get_lines()) + feed('Q') -- a new session starts cleanly + eq(1, ncursors()) + end) + + it('u restores the buffer but NOT the registers (Vim parity)', function() + -- Undo never restores registers. + cursors({ 'foo x', 'bar y' }, 'Qj0') + feed('diw') -- each cursor deletes its word into its own register + eq({ ' x', ' y' }, get_lines()) + feed('u') + eq({ 'foo x', 'bar y' }, get_lines()) + -- The primary's register still holds its deletion... + eq('bar', fn.getreg('"')) + -- ...and the per-cursor values persist too: exiting concatenates them + -- (yank DWIM) as if the undo never happened. + clear_cursors() + eq('foo\nbar\n', fn.getreg('"')) + end) + end) + + describe('same-line cursors', function() + it('two cursors on one line edit at their own columns', function() + cursors({ 'abcdef' }, 'Q4l') + feed('x') + eq({ 'bcdf' }, get_lines()) + feed('x') + eq({ 'cd' }, get_lines()) + end) + + it('linewise op with two cursors on one line applies once', function() + pending('policy: dedup linewise ops for same-line cursors (mcursor.md)') + end) + + it('coincident cursors merge', function() + cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQ') + feed('q=') + feed('G') -- all cursors land on the last line + feed('q=') + feed('x') -- one deletion, not three + eq({ 'aaa', 'bbb', 'cc' }, get_lines()) + end) + end) + + describe('multibyte', function() + it('cursor highlight covers a double-width char', function() + local screen = Screen.new(20, 4) + cursors({ '日本語', '中文字' }, 'Qj') + screen:expect([[ + {17:日}本語 | + ^中文字 | + {1:~ }| + | + ]]) + end) + end) + + describe('terminal multiple-cursors protocol', function() + local exec_lua = n.exec_lua + + before_each(function() + -- Stub the UI channel: record emitted sequences instead of sending. + exec_lua([[ + _G.sent = {} + vim.api.nvim_ui_send = function(s) + table.insert(_G.sent, s) + end + ]]) + end) + + --- Runs detect() and replies to its support query with the given TermResponse `sequence`. + local function detect(sequence) + exec_lua(([[ + require('vim._core.mcursor').detect({ chan = 1 }) + vim.api.nvim_exec_autocmds('TermResponse', { + data = { sequence = %q, chan = 1 }, + }) + ]]):format(sequence)) + end + + it('enabled only if the terminal reply lists shape 29', function() + detect('\027[>1;2;3 q') -- no shape 29 + -- The support query was sent, but no cursor updates follow. + eq({ '\027[> q' }, exec_lua('return _G.sent')) + feed('Q') + exec_lua('vim.wait(10)') + eq({ '\027[> q' }, exec_lua('return _G.sent')) + end) + + it('displays multicursors as terminal cursors', function() + local screen = Screen.new(30, 6) + detect('\027[>1;2;3;29;30;40;100;101 q') + + cursors({ 'aaa', 'bbb', 'ccc' }, 'QjQ') + exec_lua('vim.wait(10)') -- drain the scheduled refresh + -- Clear-all, then shape 29 ("follow main cursor") at each position. + local sent = exec_lua('return _G.sent') + eq('\027[>0;4 q\027[>29;2:1:1;2:2:1 q', sent[#sent]) + + -- The cell-highlight fallback is suppressed (no {17:} on line 1). + screen:expect([[ + aaa | + ^bbb | + ccc | + {1:~ }|*2 + | + ]]) + + -- Removing all cursors clears the terminal cursors. + clear_cursors() + exec_lua('vim.wait(10)') + sent = exec_lua('return _G.sent') + eq('\027[>0;4 q', sent[#sent]) + + -- tty_cursors(false) restores the cell-highlight fallback. + feed('Q') + exec_lua("require('vim._core.mcursor').tty_cursors(false)") + screen:expect([[ + aaa | + {17:^b}bb | + ccc | + {1:~ }|*2 + | + ]]) + end) + + it('displays the cursors of every visible window (splits)', function() + local _ = Screen.new(30, 9) + detect('\027[>1;2;3;29;30;40;100;101 q') + fn.setline(1, { 'aaa', 'bbb' }) + feed('gg0Q') -- cursor in buffer 1 + command('split | enew') -- top window: a second buffer + fn.setline(1, { 'xxx', 'yyy' }) + feed('gg0Q') -- cursor in buffer 2 + exec_lua('vim.wait(10)') + -- The last sequence draws BOTH cursors: buffer 2's in the focused top + -- window, and buffer 1's in the (unfocused) bottom window. + local sent = exec_lua('return _G.sent') ---@type string[] + local seq = sent[#sent] + local win1 = fn.win_getid(fn.winnr('j')) + local pos1 = fn.screenpos(win1, 1, 1) + t.ok( + seq:find(('2:%d:%d'):format(pos1.row, pos1.col), 1, true) ~= nil, + 'buf1 cursor drawn', + seq + ) + local pos2 = fn.screenpos(0, 1, 1) + t.ok( + seq:find(('2:%d:%d'):format(pos2.row, pos2.col), 1, true) ~= nil, + 'buf2 cursor drawn', + seq + ) + end) + end) + + describe('operatorfunc (g@)', function() + it('VISUAL-mode surround ("S") cascades', function() + pending('visual ":" LHS-replay: the visual selection differs per cursor; TODO') + -- Minimal vim-surround "S": VSurround is an Ex command plus a getchar() + -- payload: `:call opfunc(visualmode(),...)` + the wrap char. + n.exec([=[ + function! VisualSurround() abort + let c = nr2char(getchar()) + let [l1, c1] = getpos("'<")[1:2] + let [l2, c2] = getpos("'>")[1:2] + call setpos('.', [0, l2, c2, 0]) + exe "normal! a" . c + call setpos('.', [0, l1, c1, 0]) + exe "normal! i" . c + endfunction + xnoremap S :call VisualSurround() + ]=]) + cursors({ 'foo one', 'bar two' }, 'Qj0') + feed('viwS"') + -- Each cursor's own word is wrapped (per-cursor extents, like "viwd"). + eq({ '"foo" one', '"bar" two' }, get_lines()) + end) + + it( + 'surround-style plugin cascades, getchar() payload included; one u/CTRL-R reverts', + function() + -- The op edits each cursor's region through :normal + register juggling (a full + -- exec_normal() per cursor). + n.exec(t_atom.minisurround_vim) + cursors({ 'alpha beta', 'gamma delta', 'epsilon zeta' }, 'Qj0Qj0') + atoms_start() + feed('ysiw"') + -- The atom is the redobuff plus the getchar()'d payload: the replayed + -- opfunc reads the same wrap char. + eq({ 'g@iw"' }, atoms_tail(1)) + eq({ '"alpha" beta', '"gamma" delta', '"epsilon" zeta' }, get_lines()) + -- The whole cascade (primary + replays) is ONE undo step: a single + -- u/CTRL-R reverts or reapplies it at every cursor. + feed('u') + eq({ 'alpha beta', 'gamma delta', 'epsilon zeta' }, get_lines()) + feed('') + eq({ '"alpha" beta', '"gamma" delta', '"epsilon" zeta' }, get_lines()) + end + ) + + it('a no-effect operator (aborted "ysa[") does not cascade; cursors survive', function() + -- vim-surround "ysa[" whose surround char is /CTRL-C: a redoable g@ whose opfunc does + -- nothing. Its "a[" textobject jumps EVERY cursor to the same "[", so a cascade would + -- collapse them (dedupe). No edit and no register write = no per-cursor effect: must not + -- cascade. + n.exec([[ + function! Noop(type) abort + call getchar() + endfunction + function! NoopSetup() abort + set operatorfunc=Noop + return 'g@' + endfunction + nnoremap ,s NoopSetup() + ]]) + fn.setline(1, { 'aaa', 'bbb', 'ccc', 'x [y] z' }) -- only line 4 has brackets + feed('gg0Qj0') -- cursor on line 1, primary on line 2 (neither has brackets) + feed(',sa[z') -- g@ + a[ jumps primary to line 4's "["; opfunc getchar()'s "z", does nothing + eq(1, ncursors()) -- the cursor survives + end) + + it('cursors placed inside the opfunc are live for the next typed cascade', function() + -- Occurrence-operator pattern (vim-mode-plus "co{motion}", issue #21334): the + -- 'operatorfunc' places a cursor at each occurrence of the word within the motion, then + -- a following typed edit cascades to all of them. Pins that nvim_mcursor() called from + -- WITHIN an opfunc yields cursors the next command cascades to (the g@ itself has no + -- effect, so it does not cascade and the placed cursors survive, like |v_Q| placement). + n.exec_lua([==[ + _G.occur_opfunc = function() + local ms = vim.fn.matchbufline('%', _G.occur_pat, vim.fn.line("'["), vim.fn.line("']")) + vim.api.nvim_win_set_cursor(0, { ms[1].lnum, ms[1].byteidx }) + for i = 2, #ms do + vim.api.nvim_mcursor(0, { ms[i].lnum, ms[i].byteidx }) + end + end + vim.keymap.set('n', 'co', function() + _G.occur_pat = ([[\<%s\>]]):format(vim.fn.expand('')) -- before g@ moves the cursor + vim.o.operatorfunc = 'v:lua.occur_opfunc' + return 'g@' + end, { expr = true }) + ]==]) + fn.setline(1, { 'text a text', 'b text c' }) + api.nvim_win_set_cursor(0, { 1, 0 }) -- on the first "text" + feed('coip') -- place a cursor at every "text" in the paragraph + eq(2, ncursors()) -- 3 matches: primary + 2 multicursors + feed('ciwWORD') -- a typed edit cascades to the opfunc-placed cursors + eq({ 'WORD a WORD', 'b WORD c' }, get_lines()) + end) + + it( + 'payload mapping (":call" + getchar, like vim-surround "ds") cascades via LHS-replay', + function() + -- A mapping whose edit is done through :normal is invisible to atom + -- capture (decide-once sees nothing). It cascades by re-running the + -- mapping (LHS + the getchar()'d target) at each cursor: LHS-replay. + n.exec(t_atom.delsurround_vim) + fn.setline(1, { 'a (one)', 'b (two)', 'c (three)' }) + feed('gg0f(Qj0f(Qj0f(') + atoms_start() + feed('ds)') -- ")" is the getchar()'d payload + eq({ 'a one', 'b two', 'c three' }, get_lines()) + -- The emitted atom carries the resolution plus the getchar()'d payload; the cascade + -- itself re-runs `lhs` (the edit is invisible, so nothing was queued for it). + local ev = atoms()[#atoms()] + eq({ lhs = 'ds)', keys = ':call DelSurround()\n)' }, { lhs = ev.lhs, keys = ev.keys }) + + -- Same for a mapping that produces NO capturable keys at all (kKeyOpaque): + -- "" (K_COMMAND) and a Lua callback (K_LUA). Both are real user + -- keystrokes, so their edit is a mapping edit and cascades by LHS-replay. + command('nnoremap normal! x') + n.exec_lua([[vim.keymap.set('n', '', function() vim.cmd('normal! x') end)]]) + for _, lhs in ipairs({ '', '' }) do + clear_cursors() + fn.setline(1, { 'aaa', 'bbb', 'ccc' }) + feed('gg0QjQj') + feed(lhs) + eq({ 'aa', 'bb', 'cc' }, get_lines()) + end + + -- Op-pending payload mapping (vim-sneak :omap) cascades by `keys`. + n.exec(t_atom.minisneak_vim) + clear_cursors() + fn.setline(1, { 'aa (x) here', 'bb (y) here', 'cc (z) here' }) + feed('gg0QjQj') + feed('dzhe') + eq({ 'here', 'here', 'here' }, get_lines()) + + -- Lua :omap textobject (starts Visual mode, |omap-info|) cascades by `keys` too. #41482 + n.exec_lua([[ + vim.keymap.set('o', 'gt', function() + vim.cmd('normal! viw') + end) + ]]) + clear_cursors() + fn.setline(1, { 'aaa xxx', 'bbb yyy', 'ccc zzz' }) + feed('gg0wQj0wQj0w') + feed('dgt') + eq({ 'aaa ', 'bbb ', 'ccc ' }, get_lines()) + end + ) + end) + + describe(']C and [C', function() + local function cur() + return { fn.line('.'), fn.col('.') - 1 } + end + + -- The default mappings require the standard startup. + before_each(function() + n.clear({ args_rm = { '--cmd' } }) + end) + + it(']C scrolls the viewport to an off-screen cursor', function() + local screen = Screen.new(30, 6) + local lines = {} ---@type string[] + for i = 1, 40 do + lines[i] = ('line %d'):format(i) + end + fn.setline(1, lines) + feed('gg0Q') + api.nvim_mcursor(0, { 30, 0 }) + feed(']C') -- jump to line 30: the viewport must follow + eq(30, fn.line('.')) + screen:expect({ any = 'ine 30' }) -- ("l" is under the painted cursor cell) + end) + + it('cycle through the cursors, wrapping', function() + cursors({ 'aaa', 'bbb', 'ccc', 'ddd' }, 'Q2jllQ') + feed('gg0j') + feed(']C') + eq({ 3, 2 }, cur()) + feed(']C') -- wraps + eq({ 1, 0 }, cur()) + feed('2]C') -- count + eq({ 1, 0 }, cur()) + feed('[C') + eq({ 3, 2 }, cur()) + feed('[C') + eq({ 1, 0 }, cur()) + end) + + it('does not move the other cursors in q= mode', function() + cursors({ 'aaa', 'bbb', 'ccc' }, 'Qj') + feed('q=') + feed(']C') + eq({ 1, 0 }, cur()) + eq({ { 0, 0 } }, anchors()) + feed('q=') + end) + + it('beeps and does not move without cursors', function() + fn.setline(1, { 'aaa' }) + feed('gg0') + eq(0, fn.assert_beeps('normal! ]C')) + eq({ 1, 0 }, cur()) + end) + end) + + describe('treesitter interaction', function() + it('markdown highlighting survives a live insert', function() + n.exec_lua([[ + -- Large, injection-heavy buffer: multi-slice ASYNC parses (the + -- crash lived in a resumed parse's external scanner). + local lines = {} + for i = 1, 800 do + vim.list_extend(lines, { + ('# Section %d'):format(i), + '', + '- item with `code` and *emphasis*', + ' - nested [link](http://x)', + '', + '```lua', + 'local x = ' .. i, + '```', + '', + }) + end + vim.api.nvim_buf_set_lines(0, 0, -1, true, lines) + vim.treesitter.start(0, 'markdown') + ]]) + feed('gg0Q2jQ2jQ4j') + feed('A') + for c in ('hello world'):gmatch('.') do + feed(c) + n.poke_eventloop() + end + feed('') + feed('u') + feed('') + n.assert_alive() + end) + end) + + describe('clipboard', function() + it("perf: provider syncs once per cascade with 'clipboard'", function() + n.exec_lua([[ + _G.copies = 0 + _G.content = {} + vim.g.clipboard = { + name = 'test', + copy = { + ['+'] = function(lines) + _G.copies = _G.copies + 1 + _G.content = lines + end, + }, + paste = { + ['+'] = function() + return _G.content + end, + }, + } + vim.o.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')) + end) + end) + + describe('UI integration', function() + it('cursor positions are pushed to UIs (win_extmark)', function() + local screen = Screen.new(30, 5) + cursors({ 'aaa', 'bbb', 'ccc' }, 'QjlQj') + local ns = api.nvim_create_namespace('nvim.multicursor') + local marks = api.nvim_buf_get_extmarks(0, ns, 0, -1, {}) + eq(2, #marks) + screen:expect({ + grid = [[ + {17:a}aa | + b{17:b}b | + c^cc | + {1:~ }| + | + ]], + extmarks = { + [2] = { + { 1000, ns, marks[1][1], 0, 0 }, + { 1000, ns, marks[2][1], 1, 1 }, + }, + }, + }) + end) + + it('showcmd area shows the cursor count ("N×")', function() + local screen = Screen.new(30, 5) + command('set showcmd') -- the test env defaults to 'noshowcmd' + cursors({ 'aaa', 'bbb', 'ccc' }, 'Q') + screen:expect({ any = '1×' }) + feed('jQ') + screen:expect({ any = '2×' }) + command('silent normal! 1q=') -- Follow mode: "=" prefix (silent to avoid the q= message). + feed('l') -- Tickle showcmd redraw. + screen:expect({ any = '=2×' }) + command('silent normal! 2q=') + feed('h') -- Tickle showcmd redraw. + clear_cursors() -- cleared with the cursors + feed('') -- the indicator refreshes on the next command + screen:expect([[ + aaa | + ^bbb | + ccc | + {1:~ }| + | + ]]) + end) + + it('showcmd area shows the cursor count ("N×") with ui2', function() + local screen = Screen.new(30, 5) + command('set showcmd') + n.exec_lua([[require('vim._core.ui2').enable({})]]) + cursors({ 'aaa', 'bbb', 'ccc' }, 'Q') + screen:expect({ any = '1×' }) + feed('jQ') + screen:expect({ any = '2×' }) + command('silent normal! 1q=') -- Follow mode: "=" prefix (silent to avoid the q= message). + feed('l') -- Tickle showcmd redraw. + screen:expect({ any = '=2×' }) + end) + end) + + describe('workflows', function() + it('split visual selection into line cursors', function() + -- {Visual}Q + fn.setline(1, { 'aaaa', 'bbbb', 'cc', 'dddd' }) + feed('gg0ll') + feed('V2j') + feed('Q') + -- Primary cursor is the top of the range. + eq({ 1, 2 }, api.nvim_win_get_cursor(0)) + -- One cursor per selected line, at primary cursor's column (on the short line: past EOL). + feed('iX') + eq({ 'aaXaa', 'bbXbb', 'ccX', 'dddd' }, get_lines()) + -- The mapping enabled follow-motion (q=). + feed('jx') + eq({ 'aaXaa', 'bbbb', 'cc', 'ddd' }, get_lines()) + end) + + it('place a cursor at a range of quickfix items: :cdo normal! Q', function() + fn.setline(1, { 'aaa', 'bbb', 'ccc', 'ddd' }) + fn.setqflist({ + { bufnr = fn.bufnr(''), lnum = 1, col = 1 }, + { bufnr = fn.bufnr(''), lnum = 2, col = 1 }, + { bufnr = fn.bufnr(''), lnum = 4, col = 1 }, + }) + command('cdo normal! Q') + eq(3, ncursors()) + feed('3G0') -- Move the primary off the last item's cursor (it would double-apply). + feed('x') + eq({ 'aa', 'bb', 'cc', 'dd' }, get_lines()) + -- A range addresses quickfix items: cursors on items 2-3 only. + clear_cursors() + command('2,3cdo normal! Q') + eq(2, ncursors()) + end) + + it('CTRL-X/CTRL-A apply at each cursor; g CTRL-A is the counter', function() + cursors({ 'x = 5', 'y = 5' }, 'Qj') + feed('') + eq({ 'x = 4', 'y = 4' }, get_lines()) + feed('') + eq({ 'x = 5', 'y = 5' }, get_lines()) + -- g_CTRL-A inserts counter at the cursor positions. + feed('g') + eq({ 'x = 15', 'y = 25' }, get_lines()) + end) + + it('o with primary cursor BETWEEN the other cursors', function() + -- The primary's own line-insert shifts the other cursors' extmarks BEFORE the replays. + fn.setline(1, { 'aaa', 'bbb', 'ccc' }) + api.nvim_mcursor(0, { 1, 0 }) + api.nvim_mcursor(0, { 3, 0 }) + api.nvim_win_set_cursor(0, { 2, 0 }) + feed('oX') + eq({ 'aaa', 'X', 'bbb', 'X', 'ccc', 'X' }, get_lines()) + end) + + it('typeahead behind the cascade-triggering key is not consumed by replays', function() + -- Batch input: "x" cascades; queued "yy" must survive the replays (save_current_state), then + -- cascade (per-cursor registers prove the yank ran at both cursors). + cursors({ 'abc', 'def' }, 'Qj') + feed('xyy') + eq({ 'bc', 'ef' }, get_lines()) + feed('p') + eq({ 'bc', 'bc', 'ef', 'ef' }, get_lines()) + end) + + it('x then p at EOL on lines of different lengths', function() + -- Jagged EOL: each cursor deletes last char into its own register, then pastes at its own + -- (shorter) EOL. + fn.setline(1, { 'abc', 'de' }) + feed('gg$Q') + feed('j$') + feed('x') + eq({ 'ab', 'd' }, get_lines()) + feed('p') + eq({ 'abc', 'de' }, get_lines()) + end) + + it('fold parity: cascade at a cursor inside a closed fold', function() + -- Operator on a closed fold applies to the whole fold (|fold-behavior|), so the replayed "x" + -- deletes the fold's lines. + fn.setline(1, { 'aaa', 'bbb', 'ccc', 'ddd' }) + feed('3G0Q') + command('2,4fold') + eq(2, fn.foldclosed(3)) + feed('gg0') + feed('x') + eq({ 'aa' }, get_lines()) + end) + + it('type=excmd CmdAtoms are emit-only, ":s" does not cascade', function() + -- cmdline payloads are captured but never cascaded: only the primary's ":s" runs. + cursors({ 'foo', 'foo' }, 'Qj') + atoms_start() + feed(':s/o/O/') + eq({ 'foo', 'fOo' }, get_lines()) + local ev = atoms()[#atoms()] + eq('excmd', ev.type) + eq('s/o/O/', ev.text) + end) + end) + + describe('@ (macro replay)', function() + it('cascades at each cursor', function() + fn.setline(1, { 'aaa', 'bbb', 'ccc' }) + feed('gg0qqxq') -- record "x"; line 1 becomes "aa" + feed('jQ') + feed('j0') + feed('@q') + eq({ 'aa', 'bb', 'cc' }, get_lines()) + -- "@@" repeats, still cascading. + feed('@@') + eq({ 'aa', 'b', 'c' }, get_lines()) + -- A macro with an insert session cascades too. + clear_cursors() + api.nvim_buf_set_lines(0, 0, -1, true, { 'aaa', 'bbb', 'ccc' }) + feed('gg0qwA!q') -- record "A!"; line 1 becomes "aaa!" + feed('jQ') + feed('j0') + feed('@w') + eq({ 'aaa!', 'bbb!', 'ccc!' }, get_lines()) + -- A count applies at each cursor. + clear_cursors() + api.nvim_buf_set_lines(0, 0, -1, true, { 'aaaa', 'bbbb', 'cccc' }) + feed('gg0') + feed('jQ') + feed('j0') + feed('2@q') -- the "x" macro, twice per cursor + eq({ 'aaaa', 'bb', 'cc' }, get_lines()) + end) + + it('from a cursorless buffer, cascades in the buffer it enters', function() + -- Capture starts before the macro runs (buffer unknown). A macro typed where there are no + -- cursors, cascades in the buffer it navigates into, like a mapping. + command('set hidden') + cursors({ 'aaa', 'bbb' }, 'jQk') -- buf1: primary on line 1, cursor on line 2. + command('vsplit | enew') -- buf2: no cursors. + fn.setreg('q', k('px')) + feed('@q') + eq({ 'aa', 'bb' }, get_lines()) + end) + end) + + describe('CmdAtom', function() + it('fires for a cascaded operation', function() + cursors({ 'aaa', 'bbb' }, 'Q') + atoms_start() + feed('jx') + -- Atom shape: see cmdatom_spec. With cursors: no extra atoms from the cascade replays. + eq({ 'j', 'dl' }, atoms_tail(2)) + -- A yank cascades (per-cursor registers) but does not edit. + feed('yy') + eq(false, atoms()[#atoms()].changed) + end) + + it('non-edit operator (zfap) cascades; fold toggles (za) do not', function() + fn.setline(1, { 'aa', 'aa', '', 'bb', 'bb', '' }) + feed('4G0Q') + feed('gg0') + atoms_start() + feed('zfap') + -- With cursors: the cascade adds no atoms. (Atom shape is covered in cmdatom_spec.) + eq({ 'zfap' }, atoms_tail(1)) + -- The fold operator cascades: each cursor folds its own paragraph. + eq({ { 1, 3 }, { 4, 6 } }, { + { fn.foldclosed(1), fn.foldclosedend(1) }, + { fn.foldclosed(4), fn.foldclosedend(4) }, + }) + feed('za') + eq({ 'za' }, atoms_tail(1)) + -- Fold toggles are view state: emitted, not cascaded. + eq({ -1, 4 }, { fn.foldclosed(1), fn.foldclosed(4) }) + end) + + it('records q= motions', function() + cursors({ 'abcd', 'efgh' }, 'Qj') + atoms_start() + feed('q=') + feed('l') + feed('l') + feed('q=') + eq({ 'q=', 'l', 'l', 'q=' }, atoms_tail(4)) + eq('motion', atoms()[#atoms() - 1].type) + end) + end) + + describe('programmatic edits', function() + it('API edits do not cascade; they shift the cursors, which then track', function() + -- Extmarks are authoritative: the cascade re-queries positions, so an interleaved API edit + -- just shifts where the replays land. + cursors({ 'aaa', 'bbb', 'ccc' }) + n.exec_lua("vim.api.nvim_buf_set_lines(0, 0, 0, true, { 'zzz' })") + eq({ 'zzz', 'aaa', 'bbb', 'ccc' }, get_lines()) + feed('x') + eq({ 'zzz', 'aa', 'bb', 'cc' }, get_lines()) + end) + + it('TextChanged autocmd editing the buffer does not re-cascade', function() + fn.setline(1, { 'top', 'aaa', 'bbb' }) + feed('2G0Q') + feed('j') + n.exec_lua([[ + _G.fired = 0 + vim.api.nvim_create_autocmd('TextChanged', { + callback = function() + _G.fired = _G.fired + 1 + if _G.fired == 1 then + vim.api.nvim_buf_set_text(0, 0, 0, 0, 0, { 'X' }) + end + end, + }) + ]]) + feed('x') + n.poke_eventloop() + eq({ 'Xtop', 'aa', 'bb' }, get_lines()) + -- Once for the whole cascade + once for the autocmd's own API edit. + -- Not once per cursor, and the autocmd edit did not cascade. + eq(2, n.exec_lua('return _G.fired')) + end) + + it('replacing the whole buffer via API does not crash the cascade', function() + cursors({ 'aaa', 'bbb', 'ccc' }) + n.exec_lua("vim.api.nvim_buf_set_lines(0, 0, -1, true, { 'fresh', 'stuff' })") + -- The cursor lines are gone: both marks collapse to the edit boundary (past the last line), + -- where they merge/dedupe into one cursor; its replay clamps onto the last line. + feed('x') + eq({ 'fresh', 'uff' }, get_lines()) + eq(0, ncursors()) + end) + + it(':normal! never cascades (programmatic input)', function() + cursors({ 'aaa', 'bbb' }, 'Qj') + command('normal! x') -- Programmatic, no cascade (primary only). + eq({ 'aaa', 'bb' }, get_lines()) + feed('x') -- User input, cascades. + eq({ 'aa', 'b' }, get_lines()) + end) + end) +end) diff --git a/test/functional/lua/ui_event_spec.lua b/test/functional/lua/ui_event_spec.lua index b0393feb8d..9dfe3033d7 100644 --- a/test/functional/lua/ui_event_spec.lua +++ b/test/functional/lua/ui_event_spec.lua @@ -452,7 +452,7 @@ describe('vim.ui_attach', function() end end) ]]) - feed('Q') + feed('@@') screen:expect({ grid = [[ | @@ -479,7 +479,7 @@ describe('vim.ui_attach', function() end end) ]]) - feed('Q') + feed('@@') screen:expect({ grid = [[ | diff --git a/test/functional/terminal/tui_spec.lua b/test/functional/terminal/tui_spec.lua index 09176ab98f..15f495f56c 100644 --- a/test/functional/terminal/tui_spec.lua +++ b/test/functional/terminal/tui_spec.lua @@ -3216,6 +3216,22 @@ describe('TUI', function() end) end) + it('TermResponse on kitty-multiple-cursors protocol query', function() + child_exec_lua([[ + _G.termresponse = nil + vim.api.nvim_create_autocmd('TermResponse', { + once = true, + callback = function(ev) + _G.termresponse = ev.data.sequence + end, + }) + ]]) + feed_data('\027[>1;2;3;29;30;40;100;101 q') + retry(nil, nil, function() + eq('\027[>1;2;3;29;30;40;100;101 q', child_exec_lua('return _G.termresponse')) + end) + end) + it('TermResponse works with vim.wait() from another autocommand #32706', function() child_exec_lua([[ _G.termresponse = nil diff --git a/test/functional/ui/messages2_spec.lua b/test/functional/ui/messages2_spec.lua index 767063f008..50c637bb81 100644 --- a/test/functional/ui/messages2_spec.lua +++ b/test/functional/ui/messages2_spec.lua @@ -459,11 +459,11 @@ describe('messages2', function() {1:~ }|*12 {19:W10: Warning: Changing a readonly file} | ]]) - feed('Qi') + feed('@@i') screen:expect([[ ^ | {1:~ }|*12 - {9:E354: Invalid register name: '^@'} | + {9:E748: No previously used register} | ]]) end) diff --git a/test/functional/ui/messages_spec.lua b/test/functional/ui/messages_spec.lua index 414ad9707b..e6e0349d26 100644 --- a/test/functional/ui/messages_spec.lua +++ b/test/functional/ui/messages_spec.lua @@ -1536,7 +1536,7 @@ stack traceback: { content = { { 'baz' } }, kind = 'echo', append = true }, }, }) - feed('Q') + feed('@@') screen:expect({ grid = [[ ^ | @@ -1544,7 +1544,7 @@ stack traceback: ]], messages = { { - content = { { "E354: Invalid register name: '^@'", 9, 'ErrorMsg' } }, + content = { { 'E748: No previously used register', 9, 'ErrorMsg' } }, history = true, kind = 'emsg', }, @@ -1558,7 +1558,7 @@ stack traceback: ]], msg_history = { prev_cmd = true, - { content = { { "E354: Invalid register name: '^@'", 9, 'ErrorMsg' } }, kind = 'emsg' }, + { content = { { 'E748: No previously used register', 9, 'ErrorMsg' } }, kind = 'emsg' }, }, }) end) @@ -1611,7 +1611,7 @@ stack traceback: end) it('can capture execute("messages"))', function() - feed('Q') + feed('@@') screen:expect({ grid = [[ ^ | @@ -1619,7 +1619,7 @@ stack traceback: ]], messages = { { - content = { { "E354: Invalid register name: '^@'", 9, 'ErrorMsg' } }, + content = { { 'E748: No previously used register', 9, 'ErrorMsg' } }, history = true, kind = 'emsg', }, @@ -1627,7 +1627,7 @@ stack traceback: }) feed(':let msg = execute("messages")') screen:expect_unchanged() - eq("E354: Invalid register name: '^@'", eval('msg'):gsub('\n', '')) + eq('E748: No previously used register', eval('msg'):gsub('\n', '')) end) it('single event for multi-expr :echo', function() @@ -3850,7 +3850,7 @@ describe('progress-message', function() eq('str-id', id7) -- internal messages are also assigned an ID (and thus advance the next progress ID) - feed('Q') + feed('@@') local id8 = api.nvim_echo( { { 'test-message 30' } }, true, diff --git a/test/functional/ui/mouse_spec.lua b/test/functional/ui/mouse_spec.lua index a1b0648c2a..0b3a6af7e1 100644 --- a/test/functional/ui/mouse_spec.lua +++ b/test/functional/ui/mouse_spec.lua @@ -54,6 +54,30 @@ describe('ui/mouse/input', function() }) end) + it('middle click in Visual selection yanks, then puts', function() + command('let g:loaded_clipboard_provider = 1') -- Avoid clipboard. + feed('<0,1>') + eq({ 2, 0 }, api.nvim_win_get_cursor(0)) + feed('viw') + eq('v', fn.mode()) + eq({ 2, 4 }, api.nvim_win_get_cursor(0)) + feed('"a') -- User typed register "a. + feed('<3,0>') + eq('mouse', fn.getreg('a')) + eq('mouse', fn.getreg('"')) + eq('tesmouseting', api.nvim_get_current_line()) + end) + + it('shift-click search and CTRL-T pop execute within the click', function() + fn.setline(1, { 'foo bar', 'x foo y' }) + feed('gg0') + feed('<0,0>') -- "*": search for the shift-clicked word. + eq({ 2, 2 }, api.nvim_win_get_cursor(0)) + eq('', api.nvim_get_vvar('errmsg')) + feed('<0,0>') -- "CTRL-T": empty tag stack raises E73. + eq('E73: Tag stack empty', api.nvim_get_vvar('errmsg')) + end) + it("in external ui works with unset 'mouse'", function() api.nvim_set_option_value('mouse', '', {}) feed('<2,1>') @@ -957,9 +981,19 @@ describe('ui/mouse/input', function() }) end) - it('ctrl + left click will search for a tag', function() - api.nvim_set_option_value('tags', './non-existent-tags-file', {}) + it('ctrl + left click places a multicursor', function() + command('hi MCursor guifg=Black guibg=LightGrey') feed('<0,0>') + -- A cursor at the click; the primary cursor did not move. + screen:expect({ + any = { '{17:t}esting', 'support and selectio^n' }, + }) + feed('q') -- remove all cursors + end) + + it('g + left click will search for a tag', function() + api.nvim_set_option_value('tags', './non-existent-tags-file', {}) + feed('g<0,0>') screen:expect({ any = { '{9:E433: No tags file}', diff --git a/test/old/testdir/test_normal.vim b/test/old/testdir/test_normal.vim index 0d5d535424..f399d5be52 100644 --- a/test/old/testdir/test_normal.vim +++ b/test/old/testdir/test_normal.vim @@ -2802,6 +2802,7 @@ func Test_normal33_g_cmd2() endfunc func Test_normal_ex_substitute() + throw 'Skipped: Nvim "gQ" restores multicursors (not Ex-mode)' " This was hanging on the substitute prompt. new call setline(1, 'a') diff --git a/test/old/testdir/test_termcodes.vim b/test/old/testdir/test_termcodes.vim index 634e174818..fde8d0baab 100644 --- a/test/old/testdir/test_termcodes.vim +++ b/test/old/testdir/test_termcodes.vim @@ -110,7 +110,7 @@ func Test_xterm_mouse_right_click_extends_visual() bwipe! endfunc -" Test that jumps to help tag and jumps back. +" Nvim: adds a multicursor. still pops the tagstack. func Test_xterm_mouse_ctrl_click() let save_mouse = &mouse let save_term = &term @@ -129,14 +129,15 @@ func Test_xterm_mouse_ctrl_click() let col = 1 call MouseCtrlLeftClick(row, col) call MouseLeftRelease(row, col) - call assert_match('usr_02.txt$', bufname('%'), msg) - call assert_equal('*usr_02.txt*', expand(''), msg) + " No tag jump: a multicursor was added at the click position. + "call assert_match('usr_02.txt$', bufname('%'), msg) + "call assert_equal('*usr_02.txt*', expand(''), msg) call MouseCtrlRightClick(row, col) call MouseRightRelease(row, col) " call assert_match('help.txt$', bufname('%'), msg) call assert_match('usr_toc.txt$', bufname('%'), msg) - call assert_equal('|usr_02.txt|', expand(''), msg) + "call assert_equal('|usr_02.txt|', expand(''), msg) helpclose endfor