Skip to content

Efficient targetted menu built for fast buffer navigation

License

Notifications You must be signed in to change notification settings

leath-dub/snipe.nvim

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 

Repository files navigation

Snipe.nvim

Efficient targetted menu built for fast buffer navigation

recording

20241028_17h05m16s_grim Also useful as a general menu (see vim.ui.select wrapper).

Description

Snipe.nvim is selection menu that can accept any list of items and present a user interface with quick minimal character navigation hints to select exactly what you want. It is not flashy it is just fast !

Motivation

If you ever find yourself in a tangle of buffers scrabbling to find your way back to where you came from, this plugin can help ! Maybe you use harpoon, this is great for project files, but what if you want a fast fallback for when you're in someone else's project. Maybe you use telescope, but that can feel inconsistent and visually distracting. This is why I made this, because I wanted a Vimium-like way of hopping around a large amount of buffers

Usage

For lazy.nvim:

{
  "leath-dub/snipe.nvim",
  keys = {
    {"gb", function () require("snipe").open_buffer_menu() end, desc = "Open Snipe buffer menu"}
  },
  opts = {}
}

For packadd (builtin package manager), clone the repo into $HOME/.config/nvim/pack/snipe/opt/snipe.nvim and add this to your configuration:

vim.cmd.packadd "snipe.nvim"
local snipe = require("snipe")
snipe.setup()
vim.keymap.set("n", "gb", snipe.open_buffer_menu)

Options

You can pass in a table of options to the setup function, here are the default options:

{
  ui = {
    max_height = -1, -- -1 means dynamic height
    -- Where to place the ui window
    -- Can be any of "topleft", "bottomleft", "topright", "bottomright", "center", "cursor" (sets under the current cursor pos)
    position = "topleft",
    -- Override options passed to `nvim_open_win`
    -- Be careful with this as snipe will not validate
    -- anything you override here. See `:h nvim_open_win`
    -- for config options
    open_win_override = {
      -- title = "My Window Title",
      border = "single", -- use "rounded" for rounded border
    },

    -- Preselect the currently open buffer
    preselect_current = false,

    -- Set a function to preselect the currently open buffer
    -- E.g, `preselect = require("snipe").preselect_by_classifier("#")` to
    -- preselect alternate buffer (see :h ls and look at the "Indicators")
    preselect = nil, -- function (bs: Buffer[] [see lua/snipe/buffer.lua]) -> int (index)

    -- Changes how the items are aligned: e.g. "<tag> foo    " vs "<tag>    foo"
    -- Can be "left", "right" or "file-first"
    -- NOTE: "file-first" buts the file name first and then the directory name
    text_align = "left",
  },
  hints = {
    -- Charaters to use for hints (NOTE: make sure they don't collide with the navigation keymaps)
    dictionary = "sadflewcmpghio",
  },
  navigate = {
    -- When the list is too long it is split into pages
    -- `[next|prev]_page` options allow you to navigate
    -- this list
    next_page = "J",
    prev_page = "K",

    -- You can also just use normal navigation to go to the item you want
    -- this option just sets the keybind for selecting the item under the
    -- cursor
    under_cursor = "<cr>",

    -- In case you changed your mind, provide a keybind that lets you
    -- cancel the snipe and close the window.
    ---@type string|string[]
    cancel_snipe = "<esc>",

    -- Close the buffer under the cursor
    -- Remove "j" and "k" from your dictionary to navigate easier to delete
    -- NOTE: Make sure you don't use the character below on your dictionary
    close_buffer = "D",

    -- Open buffer in vertical split
    open_vsplit = "V",

    -- Open buffer in split, based on `vim.opt.splitbelow`
    open_split = "H",

    -- Change tag manually
    change_tag = "C",
  },
  -- The default sort used for the buffers
  -- Can be any of "last", (sort buffers by last accessed) "default" (sort buffers by its number)
  sort = "default"
}

More Details

Projects using snipe.nvim

  • snipe-lsp - navigate LSP symbols using a snipe menu
  • snipe-spell - use snipe as ui menu to builtin vim spell checking
  • snipe-marks - navigate marks using snipe

Use snipe as a vim.ui.select wrapper

Snipe nvim can act as your vim.ui.select menu, which is what is used for "code actions" in LSP among other things. You can set this up like so:

local snipe = require("snipe")
snipe.ui_select_menu = require("snipe.menu"):new { position = "center" }
snipe.ui_select_menu:add_new_buffer_callback(function (m)
  vim.keymap.set("n", "<esc>", function ()
    m:close()
  end, { nowait = true, buffer = m.buf })
end)
vim.ui.select = snipe.ui_select;

This makes vim.ui.select menus open in the center, with <esc> to cancel.

Development

The older API, as I am sure contributors are aware, was shite! The new API is based on creating a Menu which is just a state object mostly just maintaining a buffer, a window and what page you are on. There is no longer a concept of generator/producer functions, each call to open on the window just accepts a list of items to show. All of the old global config was implemented much easier using this api. A minimal example of a menu is the following:

local Menu = require("snipe.menu")
local menu = Menu:new {
  -- Per-menu configuration (does not affect global configuration)
  position = "center"
}

-- The items to snipe is just an array
-- Be careful how you reference the array in closures though
-- if you have the items table created inside a closure
-- as uncommented when setting the open keymap a few lines down,
-- this means that the items array will change every trigger and
-- can be an outdated capture in sub-closures.
local items = { "foo", "bar", "baz" }

vim.keymap.set("n", "gb", function()
  -- local items = { ... }

  -- This method allows you to add `n' callbacks to be
  -- triggered whenever a new buffer is created.
  -- A new buffer is only ever created if it is somehow
  -- externally removed or at normal startup. The reason
  -- For this system is so that you can update any buffer local
  -- keymaps and alike to work for the new buffer.
  menu:add_new_buffer_callback(function(m)
    -- `m` is a reference to the menu, prefer referencing it via this (i.e. not through your menu variable) !

    -- Keymaps like "open in split" etc can be put in here
    print("I dont want any other keymaps X( !")
  end)

  menu:open(items, function (m, i)
    -- Prefer accessing items on the menu itself (m.items not items) !
    print("You selected: " .. m.items[i])
    print("You are hovering over: " .. m.items[m:hovered()])
    -- Close the menu
    m:close()
    -- You can also call `reopen` for things like navigating
    -- between pages when the window can stay open and just
    -- needs to be updated.
  end, function (item)
    -- Format function means you don't just have to pass a list of strings
    -- you get to format each item as you choose.
    return item
  end, 10 -- the item to preselect, if it is out of bounds of the currently shown page
          -- it is ignored
  )
end)

Examples

File browser

local uv = vim.uv or vim.loop
local menu
local items

local prev = uv.cwd()
local curr = prev

local function new_dir(dir_name)
  prev = curr

  local cwd = uv.cwd()

  if uv.fs_realpath(dir_name) == cwd then
    dir_name = cwd
  end

  local dir = uv.fs_opendir(dir_name)
  if dir == nil then
    return
  end

  items = {}
  while true do
    local ent = dir:readdir()
    if not ent then
      break
    end

    if dir_name ~= uv.cwd() then
      ent[1].name = dir_name .. "/" .. ent[1].name
    end

    table.insert(items, ent[1])
  end
  dir:closedir()

  curr = dir_name
end

local function set_keymaps(m)
  local nav_next = function()
    m:goto_next_page()
    m:reopen()
  end

  local nav_prev = function()
    m:goto_prev_page()
    m:reopen()
  end

  vim.keymap.set("n", "J", nav_next, { nowait = true, buffer = m.buf })
  vim.keymap.set("n", "K", nav_prev, { nowait = true, buffer = m.buf })
  vim.keymap.set("n", "<esc>", function()
    m:close()
  end, { nowait = true, buffer = m.buf })
  vim.keymap.set("n", "-", function()
    new_dir(prev)
    m.items = items
    m:reopen()
  end)
  vim.keymap.set("n", "..", function()
    local cwd = uv.fs_realpath(uv.cwd())
    local cur = uv.fs_realpath(vim.fs.dirname(m.items[1].name))
    local dir = uv.fs_realpath(vim.fs.dirname(cur))

    if uv.fs_realpath(dir) == cwd then
      new_dir(cwd)
      m.items = items
      m:reopen()
      return
    end

    local tot = 0
    local matched = 0
    local fail = false
    local unmatched = ""

    local cwd_it = cwd:gmatch("[^/]+")
    local dir_it = dir:gmatch("[^/]+")
    local dual_it = function()
      return cwd_it(), dir_it()
    end

    for cwd_d, dir_d in dual_it do
      if cwd_d == dir_d and not fail then
        matched = matched + 1
      else
        if not fail then
          unmatched = unmatched .. (dir_d or "")
        else
          unmatched = unmatched .. "/" .. (dir_d or "")
        end
        fail = true
      end
      tot = tot + 1
    end

    if tot == matched then
      unmatched = dir:gsub(cwd, "")
    end

    unmatched = unmatched:gsub("^/", "")
    unmatched = unmatched:gsub("/$", "")
    unmatched = unmatched == "/" and "" or unmatched

    -- how many ".." is determined by tot - matched
    local backs = tot - matched
    local pfx = ("../"):rep(backs):gsub("/$", "")

    new_dir(pfx .. unmatched)
    m.items = items
    m:reopen()
  end, { nowait = true, buffer = m.buf })
end

local function open_file_manager()
  if menu == nil then
    menu = require("snipe.menu"):new { position = "bottomleft" }
    menu:add_new_buffer_callback(set_keymaps)
  end

  new_dir(uv.cwd())
  menu:open(items, function(m, i)
    if m.items[i].type == "directory" then
      new_dir(m.items[i].name)
      m.items = items
      m:reopen()
    else
      m:close()
      vim.cmd.edit(m.items[i].name)
    end
  end, function (item)
  if item.type == "directory" then
    return item.name .. "/"
  end
  return item.name end)
end

vim.keymap.set("n", "cd", open_file_manager)

Modal Buffer menu

The following code has a single menu that has different actions on the selected item depending on what keybind you open it with (<leader>o or <leader>d):

local menu = require("snipe.menu"):new()
local items

-- Other default mappings can be set here too
local function set_keymaps(m)
  vim.keymap.set("n", "<esc>", function()
    m:close()
  end, { nowait = true, buffer = m.buf })
end
menu:add_new_buffer_callback(set_keymaps)

vim.keymap.set("n", "<leader>o", function()
  items = require("snipe.buffer").get_buffers()
  menu.config.open_win_override.title = "Snipe [Open]"
  menu:open(items, function(m, i)
    m:close()
    vim.api.nvim_set_current_buf(m.items[i].id)
  end, function (item) return item.name end)
end)

vim.keymap.set("n", "<leader>d", function()
  items = require("snipe.buffer").get_buffers()
  menu.config.open_win_override.title = "Snipe [Delete]"
  menu:open(items, function(m, i)
    local bufnr = m.items[i].id
    -- I have to hack switch back to main window, otherwise currently background focused
    -- window cannot be deleted when focused on a floating window
    local current_tabpage = vim.api.nvim_get_current_tabpage()
    local root_win = vim.api.nvim_tabpage_list_wins(current_tabpage)[1]
    vim.api.nvim_set_current_win(root_win)
    vim.api.nvim_buf_delete(bufnr, { force = true })
    vim.api.nvim_set_current_win(m.win)
    table.remove(m.items, i)
    m:reopen()
  end, function (item) return item.name end)
end)

About

Efficient targetted menu built for fast buffer navigation

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages