1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
|
--- 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: `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')
--- Previous session's cursors, snapshotted on clear ("gQ"). Mark id 1 is the primary cursor.
local last_ns = vim.api.nvim_create_namespace('nvim.multicursor.last')
--- Kitty cursors protocol: host terminal supports the protocol.
local tty_cursors = false
local last_seq = '' ---@type string
local pending = false
--- Max CSI params in one terminal seq (kitty: 256). #41969
local max_csi_params = 256
--- 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.
--- @param seq string
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: Builds the escape sequence for extra cursors at screen coords `c`.
---@param c string[] Coords in "2:row:col" format.
local function cursor_seq(c)
local seq = '\027[>0;4 q' -- Clear.
local hl = vim.api.nvim_get_hl(0, { name = 'MCursor', link = false })
if hl.fg then
seq = seq
.. ('\027[>30;2:%d:%d:%d q'):format(
bit.rshift(bit.band(hl.fg, 0xFF0000), 16),
bit.rshift(bit.band(hl.fg, 0xFF00), 8),
bit.band(hl.fg, 0xFF)
)
end
if hl.bg then
seq = seq
.. ('\027[>40;2:%d:%d:%d q'):format(
bit.rshift(bit.band(hl.bg, 0xFF0000), 16),
bit.rshift(bit.band(hl.bg, 0xFF00), 8),
bit.band(hl.bg, 0xFF)
)
end
-- Shape 29 at each position, in batches of ~(max_csi_params/3).
local per_seq = math.floor((max_csi_params - 1) / 3) -- Shape takes 1 param, each coord takes 3.
for i = 1, #c, per_seq do
seq = seq .. ('\027[>29;%s q'):format(table.concat(c, ';', i, math.min(i + per_seq - 1, #c)))
end
return seq
end
--- Kitty cursors protocol: Updates the cursors.
local function refresh()
local c = coords()
send(#c == 0 and '' or cursor_seq(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.
--- @param bufnr integer
--- @param topline integer
--- @param botline integer
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.
--- Without a session, jumps to the previous session's cursors ("gQ").
--- @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[]
local jump_ns = M.active() and ns or last_ns
local prev ---@type vim.Pos?
for _, m in ipairs(vim.api.nvim_buf_get_extmarks(0, jump_ns, 0, -1, {})) do
local pos = vim.pos(0, m[2], m[3])
-- Skip duplicates (sorted): the "last" primary (mark 1) may share a position with a cursor.
if m[2] < last and pos ~= prev then
positions[#positions + 1] = pos
end
prev = pos
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, assert(positions[idx]):to_cursor())
return true
end
--- Restores the previous multicursors, and the primary-cursor position.
function M.restore()
if M.active() then
vim.api.nvim_echo({ { 'gQ: multicursor session is active' } }, true, {})
return
end
local lastrow = vim.api.nvim_buf_line_count(0)
local primary ---@type [integer, integer]?
for _, m in ipairs(vim.api.nvim_buf_get_extmarks(0, last_ns, 0, -1, {})) do
if m[2] < lastrow and m[1] == 1 then -- Extmark 1 = primary cursor.
primary = { m[2] + 1, m[3] }
elseif m[2] < lastrow then
vim.api.nvim_buf_set_extmark(0, ns, m[2], m[3], {})
end
end
if primary then
vim.api.nvim_win_set_cursor(0, primary)
end
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
|