mirror of
https://github.com/neovim/neovim.git
synced 2026-09-04 05:10:36 +00:00
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:
@@ -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|);
|
||||
|
||||
293
runtime/lua/vim/_core/mcursor.lua
Normal file
293
runtime/lua/vim/_core/mcursor.lua
Normal 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
|
||||
13
runtime/lua/vim/_meta/api.gen.lua
generated
13
runtime/lua/vim/_meta/api.gen.lua
generated
@@ -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
|
||||
|
||||
8
runtime/lua/vim/_meta/vvars.gen.lua
generated
8
runtime/lua/vim/_meta/vvars.gen.lua
generated
@@ -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 = ...
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user