minimal extui fuzzy finder for neovim
at cc48ad2e120904e1ff6bafae3751eab9cf1cbabb 562 lines 14 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 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