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
27local cmdbuff = "" ---@type string Stored cmdline used to calculate translation offset.
28local promptlen = 0 -- Current length of the last line in the prompt.
29local promptwidth = 0 -- Current width of the prompt in the cmdline buffer.
30local promptidx = 0
31--- Concatenate content chunks and set the text for the current row in the cmdline buffer.
32---
33---@param content CmdContent
34---@param prompt string
35local function set_text(content, prompt)
36 local lines = {} ---@type string[]
37 for line in (prompt .. "\n"):gmatch("(.-)\n") do
38 lines[#lines + 1] = vim.fn.strtrans(line)
39 end
40
41 promptlen = #lines[#lines]
42 promptwidth = vim.fn.strdisplaywidth(lines[#lines])
43
44 cmdbuff = ""
45 for _, chunk in ipairs(content) do
46 cmdbuff = cmdbuff .. chunk[2]
47 end
48 lines[#lines] = ("%s%s"):format(lines[#lines], vim.fn.strtrans(cmdbuff))
49 vim.api.nvim_buf_set_lines(ext.bufs.cmd, promptidx, promptidx + 1, false, lines)
50end
51
52---@class artio.View
53---@field picker artio.Picker
54---@field closed boolean
55---@field win artio.View.win
56---@field preview_win integer
57local View = {}
58View.__index = View
59
60---@param picker artio.Picker
61function View:new(picker)
62 return setmetatable({
63 picker = picker,
64 closed = false,
65 win = {
66 height = 0,
67 },
68 }, View)
69end
70
71---@class artio.View.win
72---@field height integer
73
74local prompthl_id = -1
75
76--- Set the cmdline buffer text and cursor position.
77---
78---@param content CmdContent
79---@param pos? integer
80---@param firstc string
81---@param prompt string
82---@param indent integer
83---@param level integer
84---@param hl_id integer
85function View:show(content, pos, firstc, prompt, indent, level, hl_id)
86 cmdline.level, cmdline.indent, cmdline.prompt = level, indent, cmdline.prompt or #prompt > 0
87 if cmdline.highlighter and cmdline.highlighter.active then
88 cmdline.highlighter.active[ext.bufs.cmd] = nil
89 end
90 if ext.msg.cmd.msg_row ~= -1 then
91 ext.msg.msg_clear()
92 end
93 ext.msg.virt.last = { {}, {}, {}, {} }
94
95 self:clear()
96
97 local cmd_text = ""
98 for _, chunk in ipairs(content) do
99 cmd_text = cmd_text .. chunk[2]
100 end
101
102 self.picker:getmatches(cmd_text)
103 self:showmatches()
104
105 self:promptpos()
106 set_text(content, ("%s%s%s"):format(firstc, prompt, (" "):rep(indent)))
107 self:updatecursor(pos)
108
109 local height = math.max(1, vim.api.nvim_win_text_height(ext.wins.cmd, {}).all)
110 height = math.min(height, self.win.height)
111 win_config(ext.wins.cmd, false, height)
112
113 prompthl_id = hl_id
114 self:drawprompt()
115 self:hlselect()
116end
117
118function View:saveview()
119 self.save = vim.fn.winsaveview()
120 self.prevwin = vim.api.nvim_get_current_win()
121end
122
123function View:restoreview()
124 vim.api.nvim_set_current_win(self.prevwin)
125 vim.fn.winrestview(self.save)
126end
127
128local ext_winhl = "Search:MsgArea,CurSearch:MsgArea,IncSearch:MsgArea"
129
130function View:setopts()
131 local opts = {
132 eventignorewin = "all,-FileType,-TextChangedI,-CursorMovedI",
133 winhighlight = "Normal:ArtioNormal," .. ext_winhl,
134 laststatus = self.picker.win.hidestatusline and 0 or nil,
135 filetype = "artio-picker",
136 autocomplete = false,
137 }
138
139 self.opts = {}
140
141 for name, value in pairs(opts) do
142 self.opts[name] = vim.api.nvim_get_option_value(name, { scope = "local" })
143 vim.api.nvim_set_option_value(name, value, { scope = "local" })
144 end
145end
146
147function View:revertopts()
148 for name, value in pairs(self.opts) do
149 vim.api.nvim_set_option_value(name, value, { scope = "local" })
150 end
151end
152
153local maxlistheight = 0 -- Max height of the matches list (`self.win.height - 1`)
154
155function View:on_resized()
156 if self.picker.win.height > 1 then
157 self.win.height = self.picker.win.height
158 else
159 self.win.height = vim.o.lines * self.picker.win.height
160 end
161 self.win.height = math.max(math.ceil(self.win.height), 1)
162
163 maxlistheight = self.win.height - 1
164end
165
166function View:open()
167 if not self.picker then
168 return
169 end
170
171 ext.check_targets()
172
173 self.prev_show = cmdline.cmdline_show
174
175 self.augroup = vim.api.nvim_create_augroup("artio:view", {})
176
177 vim.schedule(function()
178 vim.api.nvim_create_autocmd({ "CmdlineLeave", "ModeChanged" }, {
179 group = self.augroup,
180 once = true,
181 callback = function()
182 self:close()
183 end,
184 })
185
186 vim.api.nvim_create_autocmd("VimResized", {
187 group = self.augroup,
188 callback = function()
189 self:on_resized()
190 end,
191 })
192
193 vim.api.nvim_create_autocmd("TextChangedI", {
194 group = self.augroup,
195 callback = function()
196 self:update()
197 end,
198 })
199
200 vim.api.nvim_create_autocmd("CursorMovedI", {
201 group = self.augroup,
202 callback = function()
203 self:updatecursor()
204 end,
205 })
206 end)
207
208 self:on_resized()
209
210 cmdline.cmdline_show = function(...)
211 return self:show(...)
212 end
213
214 self:saveview()
215
216 cmdline.cmdline_show(
217 { self.picker.defaulttext and { 0, self.picker.defaulttext } or nil },
218 nil,
219 "",
220 self.picker.prompttext,
221 1,
222 0,
223 prompt_hl_id
224 )
225
226 vim._with({ noautocmd = true }, function()
227 vim.api.nvim_set_current_win(ext.wins.cmd)
228 end)
229
230 self:setopts()
231
232 vim._with({ noautocmd = true }, function()
233 vim.cmd.startinsert()
234 end)
235
236 vim.schedule(function()
237 self:clear()
238 self:updatecursor()
239 end)
240
241 vim._with({ win = ext.wins.cmd, wo = { eventignorewin = "" } }, function()
242 vim.api.nvim_exec_autocmds("WinEnter", {})
243 end)
244end
245
246function View:close()
247 if self.closed then
248 return
249 end
250 cmdline.cmdline_show = self.prev_show
251 self:closepreview()
252 vim.schedule(function()
253 vim.cmd.stopinsert()
254 self:revertopts()
255 self:clear()
256 cmdline.srow = 0
257 cmdline.erow = 0
258 win_config(ext.wins.cmd, true, ext.cmdheight)
259 self:restoreview()
260 vim.cmd.redraw()
261 cmdline.cmdline_block_hide()
262 pcall(vim.api.nvim_del_augroup_by_id, self.augroup)
263 self.closed = true
264 end)
265end
266
267function View:update()
268 local text = vim.api.nvim_get_current_line()
269 text = text:sub(promptlen + 1)
270
271 cmdline.cmdline_show({ { 0, text } }, nil, "", self.picker.prompttext, cmdline.indent, cmdline.level, prompt_hl_id)
272end
273
274local curpos = { 0, 0 } -- Last drawn cursor position. absolute
275---@param pos? integer relative to prompt
276function View:updatecursor(pos)
277 self:promptpos()
278
279 if not pos then
280 local cursorpos = vim.api.nvim_win_get_cursor(ext.wins.cmd)
281 pos = cursorpos[2] - promptlen
282 end
283
284 curpos[2] = math.max(curpos[2], promptlen)
285
286 if curpos[1] == promptidx + 1 and curpos[2] == promptlen + pos then
287 return
288 end
289
290 if pos < 0 then
291 -- reset to last known position
292 pos = curpos[2] - promptlen
293 end
294
295 curpos[1], curpos[2] = promptidx + 1, promptlen + pos
296
297 vim._with({ noautocmd = true }, function()
298 local ok, _ = pcall(vim.api.nvim_win_set_cursor, ext.wins.cmd, curpos)
299 if not ok then
300 vim.notify(("Failed to set cursor %d:%d"):format(curpos[1], curpos[2]), vim.log.levels.ERROR)
301 end
302 end)
303end
304
305function View:clear()
306 cmdline.srow = self.picker.opts.bottom and 0 or 1
307 cmdline.erow = 0
308 vim.api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, {})
309end
310
311function View:promptpos()
312 promptidx = self.picker.opts.bottom and cmdline.erow or 0
313end
314
315local view_ns = vim.api.nvim_create_namespace("artio:view:ns")
316---@type vim.api.keyset.set_extmark
317local ext_match_opts = {
318 hl_group = "ArtioMatch",
319 hl_mode = "combine",
320 invalidate = true,
321}
322
323---@param line integer 0-based
324---@param col integer 0-based
325---@param opts vim.api.keyset.set_extmark
326---@return integer
327function View:mark(line, col, opts)
328 local ok, result = pcall(vim.api.nvim_buf_set_extmark, ext.bufs.cmd, view_ns, line, col, opts)
329 if not ok then
330 vim.notify(("Failed to add extmark %d:%d"):format(line, col), vim.log.levels.ERROR)
331 return -1
332 end
333
334 return result
335end
336
337function View:drawprompt()
338 if promptlen > 0 and prompthl_id > 0 then
339 self:mark(promptidx, 0, { hl_group = prompthl_id, end_col = promptlen })
340 self:mark(promptidx, 0, {
341 virt_text = {
342 {
343 ("[%d] (%d/%d)"):format(self.picker.idx, #self.picker.matches, #self.picker.items),
344 "InfoText",
345 },
346 },
347 virt_text_pos = "eol_right_align",
348 hl_mode = "combine",
349 invalidate = true,
350 })
351 end
352end
353
354local offset = 0
355
356function View:updateoffset()
357 self.picker:fix()
358 if self.picker.idx == 0 then
359 offset = 0
360 return
361 end
362
363 local _offset = self.picker.idx - maxlistheight
364 if _offset > offset then
365 offset = _offset
366 elseif self.picker.idx <= offset then
367 offset = self.picker.idx - 1
368 end
369
370 offset = math.min(math.max(0, offset), math.max(0, #self.picker.matches - maxlistheight))
371end
372
373local icon_pad = 2
374
375function View:showmatches()
376 local indent = vim.fn.strdisplaywidth(self.picker.opts.pointer) + 1
377 local prefix = (" "):rep(indent)
378 local icon_pad_str = (" "):rep(icon_pad)
379
380 self:updateoffset()
381
382 local lines = {} ---@type string[]
383 local hls = {}
384 local icons = {} ---@type ([string, string]|false)[]
385 local custom_hls = {} ---@type (artio.Picker.hl[]|false)[]
386 for i = 1 + offset, math.min(#self.picker.matches, maxlistheight + offset) do
387 local match = self.picker.matches[i]
388 local item = self.picker.items[match[1]]
389
390 local icon, icon_hl = item.icon, item.icon_hl
391 if not (icon and icon_hl) and vim.is_callable(self.picker.get_icon) then
392 icon, icon_hl = self.picker.get_icon(item)
393 item.icon, item.icon_hl = icon, icon_hl
394 end
395 icons[#icons + 1] = icon and { icon, icon_hl } or false
396 icon = icon and ("%s%s"):format(item.icon, icon_pad_str) or ""
397
398 local hl = item.hls
399 if not hl and vim.is_callable(self.picker.hl_item) then
400 hl = self.picker.hl_item(item)
401 item.hls = hl
402 end
403 custom_hls[#custom_hls + 1] = hl or false
404
405 lines[#lines + 1] = ("%s%s%s"):format(prefix, icon, item.text)
406 hls[#hls + 1] = match[2]
407 end
408
409 if not self.picker.opts.shrink then
410 for _ = 1, (maxlistheight - #lines) do
411 lines[#lines + 1] = ""
412 end
413 end
414 cmdline.erow = cmdline.srow + #lines
415 vim.api.nvim_buf_set_lines(ext.bufs.cmd, cmdline.srow, cmdline.erow, false, lines)
416
417 for i = 1, #lines do
418 local has_icon = icons[i] and icons[i][1] and true
419 local icon_indent = has_icon and (#icons[i][1] + icon_pad) or 0
420
421 if has_icon and icons[i][2] then
422 self:mark(
423 cmdline.srow + i - 1,
424 indent,
425 vim.tbl_extend("force", ext_match_opts, {
426 end_col = indent + icon_indent,
427 hl_group = icons[i][2],
428 })
429 )
430 end
431
432 local line_hls = custom_hls[i]
433 if line_hls then
434 for j = 1, #line_hls do
435 local hl = line_hls[j]
436 self:mark(
437 cmdline.srow + i - 1,
438 indent + icon_indent + hl[1][1],
439 vim.tbl_extend("force", ext_match_opts, {
440 end_col = indent + hl[1][2],
441 hl_group = hl[2],
442 })
443 )
444 end
445 end
446
447 if hls[i] then
448 for j = 1, #hls[i] do
449 local col = indent + icon_indent + hls[i][j]
450 self:mark(cmdline.srow + i - 1, col, vim.tbl_extend("force", ext_match_opts, { end_col = col + 1 }))
451 end
452 end
453 end
454end
455
456function View:hlselect()
457 if self.select_ext then
458 vim.api.nvim_buf_del_extmark(ext.bufs.cmd, view_ns, self.select_ext)
459 end
460
461 self:softupdatepreview()
462
463 self.picker:fix()
464 local idx = self.picker.idx
465 if idx == 0 then
466 return
467 end
468
469 self:updateoffset()
470 local row = math.max(0, math.min(cmdline.srow + (idx - offset), cmdline.erow) - 1)
471 if row == promptidx then
472 return
473 end
474
475 do
476 local extid = self:mark(row, 0, {
477 virt_text = { { self.picker.opts.pointer, "ArtioPointer" } },
478 hl_mode = "combine",
479 virt_text_pos = "overlay",
480 line_hl_group = "ArtioSel",
481 invalidate = true,
482 })
483 if extid ~= -1 then
484 self.select_ext = extid
485 end
486 end
487end
488
489function View:togglepreview()
490 if self.preview_win then
491 self:closepreview()
492 return
493 end
494
495 self:updatepreview()
496end
497
498---@return integer
499---@return fun(win: integer)?
500function View:openpreview()
501 if self.picker.idx == 0 then
502 return -1
503 end
504
505 local match = self.picker.matches[self.picker.idx]
506 local item = self.picker.items[match[1]]
507
508 if not item or not (self.picker.preview_item and vim.is_callable(self.picker.preview_item)) then
509 return -1
510 end
511
512 return self.picker.preview_item(item.v)
513end
514
515function View:updatepreview()
516 local buf, on_win = self:openpreview()
517 if buf < 0 then
518 return
519 end
520
521 if not self.preview_win then
522 self.preview_win = vim.api.nvim_open_win(
523 buf,
524 false,
525 vim.tbl_extend("force", self.picker.win.preview_opts(self), {
526 relative = "editor",
527 style = "minimal",
528 })
529 )
530 else
531 vim.api.nvim_win_set_buf(self.preview_win, buf)
532 end
533
534 vim.api.nvim_set_option_value("previewwindow", true, { scope = "local", win = self.preview_win })
535
536 if on_win and vim.is_callable(on_win) then
537 on_win(self.preview_win)
538 end
539end
540
541function View:softupdatepreview()
542 if self.picker.idx == 0 then
543 self:closepreview()
544 end
545
546 if not self.preview_win then
547 return
548 end
549
550 self:updatepreview()
551end
552
553function View:closepreview()
554 if not self.preview_win then
555 return
556 end
557
558 vim.api.nvim_win_close(self.preview_win, true)
559 self.preview_win = nil
560end
561
562return View