minimal extui fuzzy finder for neovim
at 3ccbd8cfef81e04868a4df1d155fed08bf870c3d 561 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 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