minimal extui fuzzy finder for neovim
at 8763f8aac5d2aa435f5dccb3c56bac81557df31e 662 lines 17 kB view raw
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