feat(multicursor): MC HAMMER #41587

Other (squashed) commits:

fix(tui): emit ui_send output atomically with the frame

Problem:
tui_ui_send() writes directly to the TTY, bypassing the output buffer.
Sequences sent via nvim_ui_send() (e.g. kitty multiple-cursors, or
visual-dot-repeat) always arrive in a separate TTY write from the frame
they were computed for. This manifests as "tearing", or e.g. in the case
of multicursor the terminal renders text with stale cursor overlays.

Solution:
- tui_ui_send(): while a frame is being assembled (pending invalid
  regions or buffered output), buffer instead of writing directly.
  - Out-of-frame sends (tty queries, clear-on-disable) still write
    immediately.
- mcursor.lua: emit the terminal-cursor update at the end of the redraw
  cycle (`on_end`, when screen positions are final) instead of
  vim.schedule().
This commit is contained in:
Justin M. Keyes
2026-09-01 11:17:22 -04:00
committed by GitHub
parent 9ebf9b1017
commit 9a29622b54
63 changed files with 5093 additions and 347 deletions

View File

@@ -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

View File

@@ -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`):

View File

@@ -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 <Esc>.
@@ -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 + `<Esc>`), 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<CR>`), `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.
==============================================================================

View File

@@ -171,7 +171,7 @@ Normal Mode: >
cursor window
---------------------------------------------------------------------------
<LeftMouse> yes end yes
<C-LeftMouse> yes end yes "CTRL-]" (2)
<C-LeftMouse> no no change no toggle multicursor
<S-LeftMouse> yes no change yes "*" (2)
<LeftDrag> yes start or extend (1) no
<LeftRelease> yes start or extend (1) no
@@ -189,7 +189,7 @@ Insert or Replace Mode: >
cursor window
---------------------------------------------------------------------------
<LeftMouse> yes (cannot be active) yes
<C-LeftMouse> yes (cannot be active) yes "CTRL-O^]" (2)
<C-LeftMouse> no (cannot be active) no no-op
<S-LeftMouse> yes (cannot be active) yes "CTRL-O*" (2)
<LeftDrag> yes start or extend (1) no like CTRL-O (1)
<LeftRelease> yes start or extend (1) no like CTRL-O (1)

View File

@@ -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*

View File

@@ -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. "<C-Leftmouse>" and
"g<LeftMouse>" work just like "CTRL-]".
This only works when the tag is a keyword. "g<LeftMouse>" works just like
"CTRL-]".
- use the ":ta {subject}" command. This also works with non-keyword
characters.

View File

@@ -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 ~
|<C-End>| <C-End> 1 same as "G"
|<C-Home>| <C-Home> 1 same as "gg"
|<C-Left>| <C-Left> 1 same as "b"
|<C-LeftMouse>| <C-LeftMouse> ":ta" to the keyword at the mouse click
|<C-LeftMouse>| <C-LeftMouse> place a multicursor at mouse-click
|<C-Right>| <C-Right> 1 same as "w"
|<C-RightMouse>| <C-RightMouse> same as "CTRL-T"
|<C-Tab>| <C-Tab> same as "g<Tab>"
@@ -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<End>| g<End> 1 same as "g$" but go to the rightmost
non-blank character instead
|g<Home>| g<Home> 1 same as "g0"
|g<LeftMouse>| g<LeftMouse> same as <C-LeftMouse>
|g<LeftMouse>| g<LeftMouse> ":tag" on the keyword at mouse-click
g<MiddleMouse> same as <C-MiddleMouse>
|g<RightMouse>| g<RightMouse> same as <C-RightMouse>
|g<Tab>| g<Tab> go to last accessed tabpage

View File

@@ -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

View File

@@ -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

View File

@@ -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|.

View File

@@ -270,6 +270,11 @@ DIAGNOSTICS
EDITOR
• Multiple cursors (|multicursor|): edit in many places at once.
• Toggle cursors with |Q|, |<C-LeftMouse>|, 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.

View File

@@ -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<CR>", …).
2. Type "1Q".
*mcursor-mouse* *<C-LeftMouse>*
<C-LeftMouse> 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:

View File

@@ -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*

View File

@@ -41,8 +41,7 @@ below.
first one is jumped to. See |tag-matchlist| for
jumping to other matching tags.
g<LeftMouse> *g<LeftMouse>*
<C-LeftMouse> *<C-LeftMouse>* *CTRL-]*
g<LeftMouse> *g<LeftMouse>* *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.

View File

@@ -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"<CR>

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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()`.

View File

@@ -112,9 +112,14 @@ do
--- Use normal! <C-L> to prevent inserting raw <C-L> when using i_<C-O>. #17473
---
--- See |CTRL-L-default|
vim.keymap.set('n', '<C-L>', '<Cmd>nohlsearch<Bar>diffupdate<Bar>normal! <C-L><CR>', {
desc = ':help CTRL-L-default',
})
vim.keymap.set(
'n',
'<C-L>',
'<Cmd>nohlsearch<Bar>diffupdate'
.. '<Bar>call nvim_buf_clear_namespace(0, nvim_create_namespace("nvim.multicursor"), 0, -1)'
.. '<Bar>normal! <C-L><CR>',
{ 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', '&', ':&&<CR>', { 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! @<C-R>=reg_recorded()<CR><CR>' : '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<string,any> 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|);

View File

@@ -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('<Esc>'), 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

View File

@@ -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

View File

@@ -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 = ...

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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 <assert.h>
#include <stdbool.h>

View File

@@ -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, \

View File

@@ -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;

View File

@@ -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.

View File

@@ -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",

View File

@@ -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<CR>") 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 "<Cmd>" 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 <Esc>, 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 <M-l> <C-w>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`).

View File

@@ -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).

View File

@@ -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.

View File

@@ -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 <Esc> 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),

View File

@@ -13,8 +13,8 @@ typedef enum {
kInsJump, ///< Non-replayable jump (mouse, <PageUp>, …): atom terminated (<Esc>).
} 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.

1369
src/nvim/mcursor.c Normal file

File diff suppressed because it is too large Load Diff

14
src/nvim/mcursor.h Normal file
View File

@@ -0,0 +1,14 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#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"

View File

@@ -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)

View File

@@ -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);

View File

@@ -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<MiddleMouse>`
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

View File

@@ -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;
}
}

View File

@@ -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"

View File

@@ -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') {

View File

@@ -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 '?':

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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()

View File

@@ -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()

View File

@@ -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',

View File

@@ -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<Esc>')
feed('$".p')
eq('fooonefoo', fn.getline(1))
eq(
{ type = 'insert', lhs = k('1afoo<Esc>'), keys = k('1afoo<Esc>') },
pick(atom_last(), 'type', 'lhs', 'keys')
)
command('nnoremap ,p ".p')
feed(',p')
eq(
{ type = 'insert', lhs = ',p', keys = k('1afoo<Esc>') },
pick(atom_last(), 'type', 'lhs', 'keys')
)
-- Typed ":put ." emits typed cmdline payload, not the internal ":put _" translation.
feed(':put .<CR>')
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()

View File

@@ -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 [[qqAFOO<esc>q]]
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

File diff suppressed because it is too large Load Diff

View File

@@ -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 = [[
|

View File

@@ -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

View File

@@ -459,11 +459,11 @@ describe('messages2', function()
{1:~ }|*12
{19:W10: Warning: Changing a readonly file} |
]])
feed('<Esc>Qi')
feed('<Esc>@@i')
screen:expect([[
^ |
{1:~ }|*12
{9:E354: Invalid register name: '^@'} |
{9:E748: No previously used register} |
]])
end)

View File

@@ -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")<CR>')
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,

View File

@@ -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('<LeftMouse><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('<MiddleMouse><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('<S-LeftMouse><0,0>') -- "*": search for the shift-clicked word.
eq({ 2, 2 }, api.nvim_win_get_cursor(0))
eq('', api.nvim_get_vvar('errmsg'))
feed('<C-RightMouse><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('<LeftMouse><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('<C-LeftMouse><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<BS>') -- 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<LeftMouse><0,0>')
screen:expect({
any = {
'{9:E433: No tags file}',

View File

@@ -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')

View File

@@ -110,7 +110,7 @@ func Test_xterm_mouse_right_click_extends_visual()
bwipe!
endfunc
" Test that <C-LeftMouse> jumps to help tag and <C-RightMouse> jumps back.
" Nvim: <C-LeftMouse> adds a multicursor. <C-RightMouse> 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('<cWORD>'), 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('<cWORD>'), 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('<cWORD>'), msg)
"call assert_equal('|usr_02.txt|', expand('<cWORD>'), msg)
helpclose
endfor