picker.lua

Branches > main > dee5107 > files / nvim / lua / picker.lua

local api = vim.api
local fn = vim.fn

local ns = api.nvim_create_namespace("dev.grahp.picker")

local function float(row, height)
  local buf = api.nvim_create_buf(false, true)
  vim.bo[buf].bufhidden = "wipe"
  local win = api.nvim_open_win(buf, false, {
    relative = "editor",
    row = row,
    col = 0,
    width = vim.o.columns,
    height = height,
    style = "minimal",
    border = "none"
  })
  return win, buf
end

--- Fullscreen fuzzy picker, prompt at the bottom and matches going up from it
--- on_choice gets the picked string, or nil if cancelled
local function pick(items, on_choice)
  local orig_win = api.nvim_get_current_win()
  local orig_cursor = api.nvim_win_get_cursor(orig_win)
  local h = vim.o.lines - 1
  local list_win, list_buf = float(0, h)
  local prompt_win, prompt_buf = float(h, 1)
  vim.wo[list_win].cursorline = true
  api.nvim_set_current_win(prompt_win)

  local p = {items = items, query = "", matches = {}}
  local done = false

  local function query() return api.nvim_buf_get_lines(prompt_buf, 0, 1, false)[1] end

  local function refilter()
    p.query = query()
    local res = p.query == "" and {vim.list_slice(p.items, 1, h), {}}
                or fn.matchfuzzypos(p.items, p.query, {limit = h})
    p.matches = res[1]
    local lines = {}
    for row = 1, h do lines[row] = p.matches[h - row + 1] or "" end
    api.nvim_buf_set_lines(list_buf, 0, -1, false, lines)
    api.nvim_buf_clear_namespace(list_buf, ns, 0, -1)
    for i, positions in ipairs(res[2]) do
      for _, pos in ipairs(positions) do
        local col = vim.str_byteindex(p.matches[i], "utf-32", pos, false)
        api.nvim_buf_set_extmark(list_buf, ns, h - i, col, {end_col = col + 1, hl_group = "PickerMatch"})
      end
    end
    api.nvim_win_set_cursor(list_win, {h, 0})
  end

  local function close(choice)
    if done then return end
    done = true
    vim.cmd("stopinsert")
    pcall(api.nvim_win_close, prompt_win, true)
    pcall(api.nvim_win_close, list_win, true)
    vim.schedule(function()
      --- Leaving insert mode nudges the cursor left, code actions care about where it is
      pcall(api.nvim_win_set_cursor, orig_win, orig_cursor)
      on_choice(choice)
    end)
  end

  local function accept()
    --- TextChangedI waits for typeahead, so typing fast then <CR> can be stale
    if query() ~= p.query then refilter() end
    close(p.matches[h - api.nvim_win_get_cursor(list_win)[1] + 1])
  end

  local function move(d)
    return function()
      local row = api.nvim_win_get_cursor(list_win)[1] + d
      if row <= h and row > h - #p.matches then api.nvim_win_set_cursor(list_win, {row, 0}) end
    end
  end

  local keys = {["<CR>"] = accept,
                ["<Esc>"] = function() close() end,
                ["<C-c>"] = function() close() end,
                ["<Down>"] = move(1), ["<C-j>"] = move(1), ["<C-n>"] = move(1), ["<Tab>"] = move(1),
                ["<Up>"] = move(-1), ["<C-k>"] = move(-1), ["<C-p>"] = move(-1), ["<S-Tab>"] = move(-1)}
  for lhs, f in pairs(keys) do
    vim.keymap.set({"i", "n"}, lhs, f, {buffer = prompt_buf, nowait = true})
  end

  api.nvim_create_autocmd({"TextChangedI", "TextChanged"}, {buffer = prompt_buf, callback = refilter})
  api.nvim_create_autocmd("WinLeave", {buffer = prompt_buf, callback = function() close() end})

  function p.set_items(new)
    if not done then p.items = new; refilter() end
  end

  refilter()
  vim.cmd("startinsert")
  return p
end

local function lines_of(cmd, cb)
  vim.system(cmd, {text = true}, vim.schedule_wrap(function(res)
    cb(res.code == 0 and vim.split(res.stdout, "\n", {trimempty = true}) or nil)
  end))
end

--- Opens instantly, the file list fills in once git (or rg outside a repo) is done
local function files()
  local p = pick({}, function(file)
    if file then vim.cmd("edit " .. fn.fnameescape(file)) end
  end)
  lines_of({"git", "ls-files", "--cached", "--others", "--exclude-standard"}, function(found)
    if found then
      p.set_items(found)
    elseif fn.executable("rg") == 1 then
      lines_of({"rg", "--files", "--hidden", "--glob", "!.git"}, function(rg) p.set_items(rg or {}) end)
    end
  end)
end

--- Drop in for vim.ui.select (code actions and such)
local function select(items, opts, on_choice)
  local format = (opts or {}).format_item or tostring
  local texts = vim.tbl_map(function(item) return (format(item):gsub("\n", " ")) end, items)
  pick(texts, function(text)
    local idx = text and fn.index(texts, text) + 1
    on_choice(idx and items[idx], idx)
  end)
end

return {files = files, select = select}