minimal extui fuzzy finder for neovim
1local cmdline = require("vim._extui.cmdline")
2local ext = require("vim._extui.shared")
3
4local prompt_hl_id = vim.api.nvim_get_hl_id_by_name("ArtioPrompt")
5
6--- Set the 'cmdheight' and cmdline window height. Reposition message windows.
7---
8---@param win integer Cmdline window in the current tabpage.
9---@param hide boolean Whether to hide or show the window.
10---@param height integer (Text)height of the cmdline window.
11local function win_config(win, hide, height)
12 if ext.cmdheight == 0 and vim.api.nvim_win_get_config(win).hide ~= hide then
13 vim.api.nvim_win_set_config(win, { hide = hide, height = not hide and height or nil })
14 elseif vim.api.nvim_win_get_height(win) ~= height then
15 vim.api.nvim_win_set_height(win, height)
16 end
17 if vim.o.cmdheight ~= height then
18 -- Avoid moving the cursor with 'splitkeep' = "screen", and altering the user
19 -- configured value with noautocmd.
20 vim._with({ noautocmd = true, o = { splitkeep = "screen" } }, function()
21 vim.o.cmdheight = height
22 end)
23 ext.msg.set_pos()
24 end
25end
26
27---@class artio.View
28---@field picker artio.Picker
29---@field closed boolean
30---@field opts table<'win'|'buf'|'g',table<string,any>>
31---@field win artio.View.win
32---@field preview_win integer
33local View = {}
34View.__index = View
35
36---@param picker artio.Picker
37function View:new(picker)
38 return setmetatable({
39 picker = picker,
40 closed = false,
41 opts = {},
42 win = {
43 height = 0,
44 },
45 }, View)
46end
47
48---@class artio.View.win
49---@field height integer
50
51local prompthl_id = -1
52
53local cmdbuff = "" ---@type string Stored cmdline used to calculate translation offset.
54local promptlen = 0 -- Current length of the last line in the prompt.
55local promptidx = 0
56--- Concatenate content chunks and set the text for the current row in the cmdline buffer.
57---
58---@param content CmdContent
59---@param prompt string
60function View:setprompttext(content, prompt)
61 local lines = {} ---@type string[]
62 for line in (prompt .. "\n"):gmatch("(.-)\n") do
63 lines[#lines + 1] = vim.fn.strtrans(line)
64 end
65
66 local promptstr = lines[#lines]
67 promptlen = #lines[#lines]
68
69 cmdbuff = ""
70 for _, chunk in ipairs(content) do
71 cmdbuff = cmdbuff .. chunk[2]
72 end
73 lines[#lines] = ("%s%s"):format(promptstr, vim.fn.strtrans(cmdbuff))
74 self:setlines(promptidx, promptidx + 1, lines)
75 vim.fn.prompt_setprompt(ext.bufs.cmd, promptstr)
76 vim.schedule(function()
77 local ok, result = pcall(vim.api.nvim_buf_set_mark, ext.bufs.cmd, ":", promptidx + 1, 0, {})
78 if not ok then
79 vim.notify(("Failed to set mark %d:%d\n\t%s"):format(promptidx, promptlen, result), vim.log.levels.ERROR)
80 return
81 end
82 end)
83end
84
85--- Set the cmdline buffer text and cursor position.
86---
87---@param content CmdContent
88---@param pos? integer
89---@param firstc string
90---@param prompt string
91---@param indent integer
92---@param level integer
93---@param hl_id integer
94function View:show(content, pos, firstc, prompt, indent, level, hl_id)
95 cmdline.level, cmdline.indent, cmdline.prompt = level, indent, cmdline.prompt or #prompt > 0
96 if cmdline.highlighter and cmdline.highlighter.active then
97 cmdline.highlighter.active[ext.bufs.cmd] = nil
98 end
99 if ext.msg.cmd.msg_row ~= -1 then
100 ext.msg.msg_clear()
101 end
102 ext.msg.virt.last = { {}, {}, {}, {} }
103
104 self:clear()
105
106 local cmd_text = ""
107 for _, chunk in ipairs(content) do
108 cmd_text = cmd_text .. chunk[2]
109 end
110
111 self.picker:getmatches(cmd_text)
112 self:showmatches()
113
114 self:promptpos()
115 self:setprompttext(content, ("%s%s%s"):format(firstc, prompt, (" "):rep(indent)))
116 self:updatecursor(pos)
117
118 local height = math.max(1, vim.api.nvim_win_text_height(ext.wins.cmd, {}).all)
119 height = math.min(height, self.win.height)
120 win_config(ext.wins.cmd, false, height)
121
122 prompthl_id = hl_id
123 self:drawprompt()
124 self:hlselect()
125end
126
127function View:saveview()
128 self.save = vim.fn.winsaveview()
129 self.prevwin = vim.api.nvim_get_current_win()
130end
131
132function View:restoreview()
133 vim.api.nvim_set_current_win(self.prevwin)
134 vim.fn.winrestview(self.save)
135end
136
137local ext_winhl = "Search:,CurSearch:,IncSearch:"
138
139---@param restore? boolean
140function View:setopts(restore)
141 local opts = {
142 win = {
143 eventignorewin = "all,-FileType,-InsertCharPre,-TextChangedI,-CursorMovedI",
144 winhighlight = "Normal:ArtioNormal," .. ext_winhl,
145 signcolumn = "no",
146 wrap = false,
147 },
148 buf = {
149 filetype = "artio-picker",
150 buftype = "prompt",
151 autocomplete = false,
152 },
153 g = {
154 laststatus = self.picker.win.hidestatusline and 0 or nil,
155 showmode = false,
156 showcmd = false,
157 },
158 }
159
160 for level, o in pairs(opts) do
161 self.opts[level] = self.opts[level] or {}
162 local props = {
163 scope = level == "g" and "global" or "local",
164 buf = level == "buf" and ext.bufs.cmd or nil,
165 win = level == "win" and ext.wins.cmd or nil,
166 }
167
168 for name, value in pairs(o) do
169 if restore then
170 vim.api.nvim_set_option_value(name, self.opts[level][name], props)
171 else
172 self.opts[level][name] = vim.api.nvim_get_option_value(name, props)
173 vim.api.nvim_set_option_value(name, value, props)
174 end
175 end
176 end
177end
178
179local maxlistheight = 0 -- Max height of the matches list (`self.win.height - 1`)
180
181function View:on_resized()
182 if self.picker.win.height > 1 then
183 self.win.height = self.picker.win.height
184 else
185 self.win.height = vim.o.lines * self.picker.win.height
186 end
187 self.win.height = math.max(math.ceil(self.win.height), 1)
188
189 maxlistheight = self.win.height - 1
190end
191
192function View:open()
193 if not self.picker then
194 return
195 end
196
197 ext.check_targets()
198
199 self.prev_show = cmdline.cmdline_show
200
201 vim.schedule(function()
202 vim.api.nvim_create_autocmd({ "CmdlineLeave", "ModeChanged" }, {
203 group = self.augroup,
204 once = true,
205 callback = function()
206 self:close()
207 end,
208 })
209
210 vim.api.nvim_create_autocmd("VimResized", {
211 group = self.augroup,
212 callback = function()
213 self:on_resized()
214 end,
215 })
216
217 vim.api.nvim_create_autocmd("TextChangedI", {
218 group = self.augroup,
219 buffer = ext.bufs.cmd,
220 callback = function()
221 self:update()
222 end,
223 })
224
225 vim.api.nvim_create_autocmd("CursorMovedI", {
226 group = self.augroup,
227 buffer = ext.bufs.cmd,
228 callback = function()
229 self:updatecursor()
230 end,
231 })
232 end)
233
234 self:on_resized()
235
236 cmdline.cmdline_show = function(...)
237 return self:show(...)
238 end
239
240 self:saveview()
241
242 cmdline.cmdline_show(
243 { self.picker.defaulttext and { 0, self.picker.defaulttext } or nil },
244 -1,
245 "",
246 self.picker.prompttext,
247 1,
248 0,
249 prompt_hl_id
250 )
251
252 vim._with({ noautocmd = true }, function()
253 vim.api.nvim_set_current_win(ext.wins.cmd)
254 end)
255
256 self:setopts()
257
258 vim.schedule(function()
259 self:clear()
260 self:updatecursor()
261 end)
262
263 vim._with({ noautocmd = true }, function()
264 vim.cmd.startinsert()
265 end)
266
267 vim._with({ win = ext.wins.cmd, wo = { eventignorewin = "" } }, function()
268 vim.api.nvim_exec_autocmds("WinEnter", {})
269 end)
270end
271
272function View:close()
273 if self.closed then
274 return
275 end
276 cmdline.cmdline_show = self.prev_show
277 self:closepreview()
278 vim.schedule(function()
279 pcall(vim.api.nvim_del_augroup_by_id, self.augroup)
280
281 vim.cmd.stopinsert()
282
283 -- prepare state
284 self:setopts(true)
285
286 -- reset state
287 self:clear()
288 cmdline.srow = 0
289 cmdline.erow = 0
290
291 -- restore ui
292 self:hide()
293 self:restoreview()
294 vim.cmd.redraw()
295
296 self.closed = true
297
298 self.picker:close()
299 end)
300end
301
302function View:hide()
303 vim.fn.clearmatches(ext.wins.cmd) -- Clear matchparen highlights.
304 vim.api.nvim_win_set_cursor(ext.wins.cmd, { 1, 0 })
305 vim.api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, {})
306
307 local clear = vim.schedule_wrap(function(was_prompt)
308 -- Avoid clearing prompt window when it is re-entered before the next event
309 -- loop iteration. E.g. when a non-choice confirm button is pressed.
310 if was_prompt and not cmdline.prompt then
311 pcall(function()
312 vim.api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, {})
313 vim.api.nvim_buf_set_lines(ext.bufs.dialog, 0, -1, false, {})
314 vim.api.nvim_win_set_config(ext.wins.dialog, { hide = true })
315 vim.on_key(nil, ext.msg.dialog_on_key)
316 end)
317 end
318 -- Messages emitted as a result of a typed command are treated specially:
319 -- remember if the cmdline was used this event loop iteration.
320 -- NOTE: Message event callbacks are themselves scheduled, so delay two iterations.
321 vim.schedule(function()
322 cmdline.level = -1
323 end)
324 end)
325 clear(cmdline.prompt)
326
327 cmdline.prompt, cmdline.level = false, 0
328 win_config(ext.wins.cmd, true, ext.cmdheight)
329end
330
331function View:update()
332 local text = vim.api.nvim_get_current_line()
333 text = text:sub(promptlen + 1)
334
335 cmdline.cmdline_show({ { 0, text } }, -1, "", self.picker.prompttext, cmdline.indent, cmdline.level, prompt_hl_id)
336end
337
338local curpos = { 0, 0 } -- Last drawn cursor position. absolute
339---@param pos? integer relative to prompt
340function View:updatecursor(pos)
341 self:promptpos()
342
343 if not pos or pos < 0 then
344 local cursorpos = vim.api.nvim_win_get_cursor(ext.wins.cmd)
345 pos = cursorpos[2] - promptlen
346 end
347
348 curpos[2] = math.max(curpos[2], promptlen)
349
350 if curpos[1] == promptidx + 1 and curpos[2] == promptlen + pos then
351 return
352 end
353
354 if pos < 0 then
355 -- reset to last known position
356 pos = curpos[2] - promptlen
357 end
358
359 curpos[1], curpos[2] = promptidx + 1, promptlen + pos
360
361 vim._with({ noautocmd = true }, function()
362 local ok, _ = pcall(vim.api.nvim_win_set_cursor, ext.wins.cmd, curpos)
363 if not ok then
364 vim.notify(("Failed to set cursor %d:%d"):format(curpos[1], curpos[2]), vim.log.levels.ERROR)
365 end
366 end)
367end
368
369function View:clear()
370 cmdline.srow = self.picker.opts.bottom and 0 or 1
371 cmdline.erow = 0
372 self:setlines(0, -1, {})
373end
374
375function View:promptpos()
376 promptidx = self.picker.opts.bottom and cmdline.erow or 0
377end
378
379function View:setlines(posstart, posend, lines)
380 vim._with({ noautocmd = true }, function()
381 vim.api.nvim_buf_set_lines(ext.bufs.cmd, posstart, posend, false, lines)
382 end)
383end
384
385local view_ns = vim.api.nvim_create_namespace("artio:view:ns")
386local ext_priority = {
387 prompt = 1,
388 info = 2,
389 select = 4,
390 marker = 8,
391 hl = 16,
392 icon = 32,
393 match = 64,
394}
395
396---@param line integer 0-based
397---@param col integer 0-based
398---@param opts vim.api.keyset.set_extmark
399---@return integer
400function View:mark(line, col, opts)
401 opts.hl_mode = "combine"
402 opts.invalidate = true
403
404 local ok, result
405 vim._with({ noautocmd = true }, function()
406 ok, result = pcall(vim.api.nvim_buf_set_extmark, ext.bufs.cmd, view_ns, line, col, opts)
407 end)
408 if not ok then
409 vim.notify(("Failed to add extmark %d:%d\n\t%s"):format(line, col, result), vim.log.levels.ERROR)
410 return -1
411 end
412
413 return result
414end
415
416function View:drawprompt()
417 if promptlen > 0 and prompthl_id > 0 then
418 self:mark(promptidx, 0, { hl_group = prompthl_id, end_col = promptlen, priority = ext_priority.prompt })
419 self:mark(promptidx, 0, {
420 virt_text = {
421 {
422 ("[%d] (%d/%d)"):format(self.picker.idx, #self.picker.matches, #self.picker.items),
423 "InfoText",
424 },
425 },
426 virt_text_pos = "eol_right_align",
427 priority = ext_priority.info,
428 })
429 end
430end
431
432local offset = 0
433
434function View:updateoffset()
435 self.picker:fix()
436 if self.picker.idx == 0 then
437 offset = 0
438 return
439 end
440
441 local _offset = self.picker.idx - maxlistheight
442 if _offset > offset then
443 offset = _offset
444 elseif self.picker.idx <= offset then
445 offset = self.picker.idx - 1
446 end
447
448 offset = math.min(math.max(0, offset), math.max(0, #self.picker.matches - maxlistheight))
449end
450
451local icon_pad = 2
452
453function View:showmatches()
454 local indent = vim.fn.strdisplaywidth(self.picker.opts.pointer) + 1
455 local prefix = (" "):rep(indent)
456 local icon_pad_str = (" "):rep(icon_pad)
457
458 self:updateoffset()
459
460 local lines = {} ---@type string[]
461 local hls = {}
462 local icons = {} ---@type ([string, string]|false)[]
463 local custom_hls = {} ---@type (artio.Picker.hl[]|false)[]
464 local marks = {} ---@type boolean[]
465 for i = 1 + offset, math.min(#self.picker.matches, maxlistheight + offset) do
466 local match = self.picker.matches[i]
467 local item = self.picker.items[match[1]]
468
469 local icon, icon_hl = item.icon, item.icon_hl
470 if not (icon and icon_hl) and vim.is_callable(self.picker.get_icon) then
471 icon, icon_hl = self.picker.get_icon(item)
472 item.icon, item.icon_hl = icon, icon_hl
473 end
474 icons[#icons + 1] = icon and { icon, icon_hl } or false
475 icon = icon and ("%s%s"):format(item.icon, icon_pad_str) or ""
476
477 local hl = item.hls
478 if not hl and vim.is_callable(self.picker.hl_item) then
479 hl = self.picker.hl_item(item)
480 item.hls = hl
481 end
482 custom_hls[#custom_hls + 1] = hl or false
483
484 marks[#marks + 1] = self.picker.marked[item.id] or false
485
486 lines[#lines + 1] = ("%s%s%s"):format(prefix, icon, item.text)
487 hls[#hls + 1] = match[2]
488 end
489
490 if not self.picker.opts.shrink then
491 for _ = 1, (maxlistheight - #lines) do
492 lines[#lines + 1] = ""
493 end
494 end
495 cmdline.erow = cmdline.srow + #lines
496 self:setlines(cmdline.srow, cmdline.erow, lines)
497
498 for i = 1, #lines do
499 local has_icon = icons[i] and icons[i][1] and true
500 local icon_indent = has_icon and (#icons[i][1] + icon_pad) or 0
501
502 if has_icon and icons[i][2] then
503 self:mark(cmdline.srow + i - 1, indent, {
504 end_col = indent + icon_indent,
505 hl_group = icons[i][2],
506 priority = ext_priority.icon,
507 })
508 end
509
510 local line_hls = custom_hls[i]
511 if line_hls then
512 for j = 1, #line_hls do
513 local hl = line_hls[j]
514 self:mark(cmdline.srow + i - 1, indent + icon_indent + hl[1][1], {
515 end_col = indent + icon_indent + hl[1][2],
516 hl_group = hl[2],
517 priority = ext_priority.hl,
518 })
519 end
520 end
521
522 if marks[i] then
523 self:mark(cmdline.srow + i - 1, indent - 1, {
524 virt_text = { { self.picker.opts.marker, "ArtioMarker" } },
525 virt_text_pos = "overlay",
526 priority = ext_priority.marker,
527 })
528 end
529
530 if hls[i] then
531 for j = 1, #hls[i] do
532 local col = indent + icon_indent + hls[i][j]
533 self:mark(cmdline.srow + i - 1, col, {
534 hl_group = "ArtioMatch",
535 end_col = col + 1,
536 priority = ext_priority.match,
537 })
538 end
539 end
540 end
541end
542
543function View:hlselect()
544 if self.select_ext then
545 vim._with({ noautocmd = true }, function()
546 vim.api.nvim_buf_del_extmark(ext.bufs.cmd, view_ns, self.select_ext)
547 end)
548 end
549
550 self:softupdatepreview()
551
552 self.picker:fix()
553 local idx = self.picker.idx
554 if idx == 0 then
555 return
556 end
557
558 self:updateoffset()
559 local row = math.max(0, math.min(cmdline.srow + (idx - offset), cmdline.erow) - 1)
560 if row == promptidx then
561 return
562 end
563
564 local extid = self:mark(row, 0, {
565 virt_text = { { self.picker.opts.pointer, "ArtioPointer" } },
566 virt_text_pos = "overlay",
567
568 hl_group = "ArtioSel",
569 hl_eol = true,
570 end_row = row + 1,
571 end_col = 0,
572
573 priority = ext_priority.select,
574 })
575 if extid ~= -1 then
576 self.select_ext = extid
577 end
578end
579
580function View:togglepreview()
581 if self.preview_win then
582 self:closepreview()
583 return
584 end
585
586 self:updatepreview()
587end
588
589---@return integer
590---@return fun(win: integer)?
591function View:openpreview()
592 if self.picker.idx == 0 then
593 return -1
594 end
595
596 local match = self.picker.matches[self.picker.idx]
597 local item = self.picker.items[match[1]]
598
599 if not item or not (self.picker.preview_item and vim.is_callable(self.picker.preview_item)) then
600 return -1
601 end
602
603 return self.picker.preview_item(item.v)
604end
605
606function View:updatepreview()
607 local buf, on_win = self:openpreview()
608 if buf < 0 then
609 return
610 end
611
612 if not self.preview_win then
613 local previewopts = self.picker.win.preview_opts
614 and vim.is_callable(self.picker.win.preview_opts)
615 and self.picker.win.preview_opts(self)
616 self.preview_win = vim.api.nvim_open_win(
617 buf,
618 false,
619 vim.tbl_extend("force", {
620 relative = "editor",
621 width = vim.o.columns,
622 height = self.win.height,
623 col = 0,
624 row = vim.o.lines - vim.o.cmdheight * 2 - 1 - (vim.o.winborder == "none" and 0 or 2),
625 }, previewopts or {})
626 )
627 else
628 vim.api.nvim_win_set_buf(self.preview_win, buf)
629 end
630
631 vim._with({ win = self.preview_win, noautocmd = true }, function()
632 vim.api.nvim_set_option_value("previewwindow", true, { scope = "local" })
633 vim.api.nvim_set_option_value("eventignorewin", "all,-FileType", { scope = "local" })
634 end)
635
636 if on_win and vim.is_callable(on_win) then
637 on_win(self.preview_win)
638 end
639end
640
641function View:softupdatepreview()
642 if self.picker.idx == 0 then
643 self:closepreview()
644 end
645
646 if not self.preview_win then
647 return
648 end
649
650 self:updatepreview()
651end
652
653function View:closepreview()
654 if not self.preview_win then
655 return
656 end
657
658 vim.api.nvim_win_close(self.preview_win, true)
659 self.preview_win = nil
660end
661
662return View