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 self.closed = true
251 cmdline.cmdline_show = self.prev_show
252 self:closepreview()
253 vim.schedule(function()
254 vim.cmd.stopinsert()
255 self:revertopts()
256 self:clear()
257 cmdline.srow = 0
258 cmdline.erow = 0
259 win_config(ext.wins.cmd, true, ext.cmdheight)
260 self:restoreview()
261 cmdline.cmdline_block_hide()
262 pcall(vim.api.nvim_del_augroup_by_id, self.augroup)
263 end)
264end
265
266function View:update()
267 local text = vim.api.nvim_get_current_line()
268 text = text:sub(promptlen + 1)
269
270 cmdline.cmdline_show({ { 0, text } }, nil, "", self.picker.prompttext, cmdline.indent, cmdline.level, prompt_hl_id)
271end
272
273local curpos = { 0, 0 } -- Last drawn cursor position. absolute
274---@param pos? integer relative to prompt
275function View:updatecursor(pos)
276 self:promptpos()
277
278 if not pos then
279 local cursorpos = vim.api.nvim_win_get_cursor(ext.wins.cmd)
280 pos = cursorpos[2] - promptlen
281 end
282
283 curpos[2] = math.max(curpos[2], promptlen)
284
285 if curpos[1] == promptidx + 1 and curpos[2] == promptlen + pos then
286 return
287 end
288
289 if pos < 0 then
290 -- reset to last known position
291 pos = curpos[2] - promptlen
292 end
293
294 curpos[1], curpos[2] = promptidx + 1, promptlen + pos
295
296 vim._with({ noautocmd = true }, function()
297 local ok, _ = pcall(vim.api.nvim_win_set_cursor, ext.wins.cmd, curpos)
298 if not ok then
299 vim.notify(("Failed to set cursor %d:%d"):format(curpos[1], curpos[2]), vim.log.levels.ERROR)
300 end
301 end)
302end
303
304function View:clear()
305 cmdline.srow = self.picker.opts.bottom and 0 or 1
306 cmdline.erow = 0
307 vim.api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, {})
308end
309
310function View:promptpos()
311 promptidx = self.picker.opts.bottom and cmdline.erow or 0
312end
313
314local view_ns = vim.api.nvim_create_namespace("artio:view:ns")
315---@type vim.api.keyset.set_extmark
316local ext_match_opts = {
317 hl_group = "ArtioMatch",
318 hl_mode = "combine",
319 invalidate = true,
320}
321
322---@param line integer 0-based
323---@param col integer 0-based
324---@param opts vim.api.keyset.set_extmark
325---@return integer
326function View:mark(line, col, opts)
327 local ok, result = pcall(vim.api.nvim_buf_set_extmark, ext.bufs.cmd, view_ns, line, col, opts)
328 if not ok then
329 vim.notify(("Failed to add extmark %d:%d"):format(line, col), vim.log.levels.ERROR)
330 return -1
331 end
332
333 return result
334end
335
336function View:drawprompt()
337 if promptlen > 0 and prompthl_id > 0 then
338 self:mark(promptidx, 0, { hl_group = prompthl_id, end_col = promptlen })
339 self:mark(promptidx, 0, {
340 virt_text = {
341 {
342 ("[%d] (%d/%d)"):format(self.picker.idx, #self.picker.matches, #self.picker.items),
343 "InfoText",
344 },
345 },
346 virt_text_pos = "eol_right_align",
347 hl_mode = "combine",
348 invalidate = true,
349 })
350 end
351end
352
353local offset = 0
354
355function View:updateoffset()
356 self.picker:fix()
357 if self.picker.idx == 0 then
358 offset = 0
359 return
360 end
361
362 local _offset = self.picker.idx - maxlistheight
363 if _offset > offset then
364 offset = _offset
365 elseif self.picker.idx <= offset then
366 offset = self.picker.idx - 1
367 end
368
369 offset = math.min(math.max(0, offset), math.max(0, #self.picker.matches - maxlistheight))
370end
371
372local icon_pad = 2
373
374function View:showmatches()
375 local indent = vim.fn.strdisplaywidth(self.picker.opts.pointer) + 1
376 local prefix = (" "):rep(indent)
377 local icon_pad_str = (" "):rep(icon_pad)
378
379 self:updateoffset()
380
381 local lines = {} ---@type string[]
382 local hls = {}
383 local icons = {} ---@type ([string, string]|false)[]
384 local custom_hls = {} ---@type (artio.Picker.hl[]|false)[]
385 for i = 1 + offset, math.min(#self.picker.matches, maxlistheight + offset) do
386 local match = self.picker.matches[i]
387 local item = self.picker.items[match[1]]
388
389 local icon, icon_hl = item.icon, item.icon_hl
390 if not (icon and icon_hl) and vim.is_callable(self.picker.get_icon) then
391 icon, icon_hl = self.picker.get_icon(item)
392 item.icon, item.icon_hl = icon, icon_hl
393 end
394 icons[#icons + 1] = icon and { icon, icon_hl } or false
395 icon = icon and ("%s%s"):format(item.icon, icon_pad_str) or ""
396
397 local hl = item.hls
398 if not hl and vim.is_callable(self.picker.hl_item) then
399 hl = self.picker.hl_item(item)
400 item.hls = hl
401 end
402 custom_hls[#custom_hls + 1] = hl or false
403
404 lines[#lines + 1] = ("%s%s%s"):format(prefix, icon, item.text)
405 hls[#hls + 1] = match[2]
406 end
407
408 if not self.picker.opts.shrink then
409 for _ = 1, (maxlistheight - #lines) do
410 lines[#lines + 1] = ""
411 end
412 end
413 cmdline.erow = cmdline.srow + #lines
414 vim.api.nvim_buf_set_lines(ext.bufs.cmd, cmdline.srow, cmdline.erow, false, lines)
415
416 for i = 1, #lines do
417 local has_icon = icons[i] and icons[i][1] and true
418 local icon_indent = has_icon and (#icons[i][1] + icon_pad) or 0
419
420 if has_icon and icons[i][2] then
421 self:mark(
422 cmdline.srow + i - 1,
423 indent,
424 vim.tbl_extend("force", ext_match_opts, {
425 end_col = indent + icon_indent,
426 hl_group = icons[i][2],
427 })
428 )
429 end
430
431 local line_hls = custom_hls[i]
432 if line_hls then
433 for j = 1, #line_hls do
434 local hl = line_hls[j]
435 self:mark(
436 cmdline.srow + i - 1,
437 indent + hl[1][1],
438 vim.tbl_extend("force", ext_match_opts, {
439 end_col = indent + hl[1][2],
440 hl_group = hl[2],
441 })
442 )
443 end
444 end
445
446 if hls[i] then
447 for j = 1, #hls[i] do
448 local col = indent + icon_indent + hls[i][j]
449 self:mark(cmdline.srow + i - 1, col, vim.tbl_extend("force", ext_match_opts, { end_col = col + 1 }))
450 end
451 end
452 end
453end
454
455function View:hlselect()
456 if self.select_ext then
457 vim.api.nvim_buf_del_extmark(ext.bufs.cmd, view_ns, self.select_ext)
458 end
459
460 self:softupdatepreview()
461
462 self.picker:fix()
463 local idx = self.picker.idx
464 if idx == 0 then
465 return
466 end
467
468 self:updateoffset()
469 local row = math.max(0, math.min(cmdline.srow + (idx - offset), cmdline.erow) - 1)
470 if row == promptidx then
471 return
472 end
473
474 do
475 local ok, result = self:mark(row, 0, {
476 virt_text = { { self.picker.opts.pointer, "ArtioPointer" } },
477 hl_mode = "combine",
478 virt_text_pos = "overlay",
479 line_hl_group = "ArtioSel",
480 invalidate = true,
481 })
482 if ok then
483 self.select_ext = result
484 end
485 end
486end
487
488function View:togglepreview()
489 if self.preview_win then
490 self:closepreview()
491 return
492 end
493
494 self:updatepreview()
495end
496
497---@return integer
498---@return fun(win: integer)?
499function View:openpreview()
500 if self.picker.idx == 0 then
501 return -1
502 end
503
504 local match = self.picker.matches[self.picker.idx]
505 local item = self.picker.items[match[1]]
506
507 if not item or not (self.picker.preview_item and vim.is_callable(self.picker.preview_item)) then
508 return -1
509 end
510
511 return self.picker.preview_item(item.v)
512end
513
514function View:updatepreview()
515 local buf, on_win = self:openpreview()
516 if buf < 0 then
517 return
518 end
519
520 if not self.preview_win then
521 self.preview_win = vim.api.nvim_open_win(
522 buf,
523 false,
524 vim.tbl_extend("force", self.picker.win.preview_opts(self), {
525 relative = "editor",
526 style = "minimal",
527 })
528 )
529 else
530 vim.api.nvim_win_set_buf(self.preview_win, buf)
531 end
532
533 vim.api.nvim_set_option_value("previewwindow", true, { scope = "local", win = self.preview_win })
534
535 if on_win and vim.is_callable(on_win) then
536 on_win(self.preview_win)
537 end
538end
539
540function View:softupdatepreview()
541 if self.picker.idx == 0 then
542 self:closepreview()
543 end
544
545 if not self.preview_win then
546 return
547 end
548
549 self:updatepreview()
550end
551
552function View:closepreview()
553 if not self.preview_win then
554 return
555 end
556
557 vim.api.nvim_win_close(self.preview_win, true)
558 self.preview_win = nil
559end
560
561return View