How to make UI for neovim plugins in Lua

Tech
blogpost

In the last article, we saw the basics of creating plugins in Lua using floating windows. Now it's time for a more traditional approach. Let's create a simple plugin that will show us last opened files in handy side navigation. As we focus on learning the interface, we will use vim native oldfiles list for this purpose. It will look something like this:

If you didn't read previous article, I highly recommend you to do so, because this article expands on the ideas from the last one and is full of new things in comparison.

Plugin window

Ok, so we should start by writing a function that will create our first window, where the oldfiles list will be displayed. But first, we will declare three variables in the main scope of our script: buf and win that will contain our navigation window and buffer references and start_win that will remember the position where we opened our navigation. We will be using these often across our plugin functions.

-- It's our main starting function. For now we will only creating navigation window here.
local function oldfiles()
  create_win()
end

local function create_win()
  -- We save handle to window from which we open the navigation
  start_win = vim.api.nvim_get_current_win()

  vim.api.nvim_command('botright vnew') -- We open a new vertical window at the far right
  win = vim.api.nvim_get_current_win() -- We save our navigation window handle...
  buf = vim.api.nvim_get_current_buf() -- ...and it's buffer handle.

  -- We should name our buffer. All buffers in vim must have unique names.
  -- The easiest solution will be adding buffer handle to it
  -- because it is already unique and it's just a number.
  vim.api.nvim_buf_set_name(buf, 'Oldfiles #' .. buf)

  -- Now we set some options for our buffer.
  -- nofile prevent mark buffer as modified so we never get warnings about not saved changes.
  -- Also some plugins treat nofile buffers different.
  -- For example coc.nvim don't triggers aoutcompletation for these.
  vim.api.nvim_buf_set_option(buf, 'buftype', 'nofile')
  -- We do not need swapfile for this buffer.
  vim.api.nvim_buf_set_option(buf, 'swapfile', false)
  -- And we would rather prefer that this buffer will be destroyed when hide.
  vim.api.nvim_buf_set_option(buf, 'bufhidden', 'wipe')
  -- It's not necessary but it is good practice to set custom filetype.
  -- This allows users to create their own autocommand or colorschemes on filetype.
  -- and prevent collisions with other plugins.
  vim.api.nvim_buf_set_option(buf, 'filetype', 'nvim-oldfile')

  -- For better UX we will turn off line wrap and turn on current line highlight.
  vim.api.nvim_win_set_option(win, 'wrap', false)
  vim.api.nvim_win_set_option(win, 'cursorline', true)

  set_mappings() -- At end we will set mappings for our navigation.
end

Drawing function

Okay, so we have a window, now we need something to display in it. We will use vim oldfiles special variable, which stores paths to previously opened files. We will take as many items from it, as we can display without scrolling, but of course, you can take as many as you want in your script. We will call this function redraw because it can be used to refresh navigation content. File paths might be long, so we will try to make them relative to the working directory.

local function redraw()
  -- First we allow introduce new changes to buffer. We will block that at end.
  vim.api.nvim_buf_set_option(buf, 'modifiable', true)

  local items_count =  vim.api.nvim_win_get_height(win) - 1 -- get the window height
  local list = {}

  -- If you using nightly build you can get oldfiles like this
  local oldfiles = vim.v.oldfiles
  -- In stable version works only that
  local oldfiles = vim.api.nvim_get_vvar('oldfiles')

  -- Now we populate our list with X last items form oldfiles
  for i = #oldfiles, #oldfiles - items_count, -1 do

    -- We use build-in vim function fnamemodify to make path relative
    -- In nightly we can call vim function like that
    local path = vim.fn.fnamemodify(oldfiles[i], ':.')
    -- and this is stable version:
    local path = vim.api.nvim_call_function('fnamemodify', {oldfiles[i], ':.'})

    -- We iterate form end to start, so we should insert items
    -- at the end of results list to preserve order
    table.insert(list, #list + 1, path)
  end

  -- We apply results to buffer
  vim.api.nvim_buf_set_lines(buf, 0, -1, false, list)
  -- And turn off editing
  vim.api.nvim_buf_set_option(buf, 'modifiable', false)
end

We can now update our main function. We will also add some code that prevents opening multiple navigation windows. For this purpose, we can use nvim_win_is_valid which checks if our plugin window already exists.

local function oldfiles()
  if win and vim.api.nvim_win_is_valid(win) then
    vim.api.nvim_set_current_win(win)
  else
    create_win()
  end

  redraw()
end

Openings files

We can now look at our oldfiles, but it would be much handier if we can also open them. We will allow users to open files in 5 different ways! In a new tab, in horizontal or vertical splits, in the current window and in preview mode, which will keep the focus on navigation.

Let's start by opening files in the current window. We should prepare for two scenarios:
1. Opening a file in the window from which the user opens navigation.
2. Closing the starting window, when we will create a new one for opening file.

local function open()
  -- We get path from line which user push enter on
  local path = vim.api.nvim_get_current_line()

  -- if the starting window exists
  if vim.api.nvim_win_is_valid(start_win) then
    -- we move to it
    vim.api.nvim_set_current_win(start_win)
    -- and edit chosen file
    vim.api.nvim_command('edit ' .. path)
  else
    -- if there is no starting window we create new from lest side
    vim.api.nvim_command('leftabove vsplit ' .. path)
    -- and set it as our new starting window
    start_win = vim.api.nvim_get_current_win()
  end
end

-- After opening desired file user no longer need our navigation
-- so we should create function to closing it.
local function close()
  if win and vim.api.nvim_win_is_valid(win) then
    vim.api.nvim_win_close(win, true)
  end
end

-- Ok. Now we are ready to making two first opening functions

local function open_and_close()
  open() -- We open new file
  close() -- and close navigation
end

local function preview()
  open() -- WE open new file
  -- but in preview instead of closing navigation
  -- we focus back to it
  vim.api.nvim_set_current_win(win)
end
-- To making splits we need only one function
local function split(axis)
  local path = vim.api.nvim_get_current_line()

  -- We still need to handle two scenarios
  if vim.api.nvim_win_is_valid(start_win) then
    vim.api.nvim_set_current_win(start_win)
    -- We pass v in axis argument if we want vertical split
    -- or nothing/empty string otherwise.
    vim.api.nvim_command(axis ..'split ' .. path)
  else
    -- if there is no starting window we make new on left
    vim.api.nvim_command('leftabove ' .. axis..'split ' .. path)
    -- but in this case we do not need to set new starting window
    -- because splits always close navigation 
  end

  close()
end

And in the end the simplest opening in new tab.

local function open_in_tab()
  local path = vim.api.nvim_get_current_line()

  vim.api.nvim_command('tabnew ' .. path)
  close()
end

For everything to work, we need to add the key mappings, export all public functions, and add a command to trigger our navigation.

local function set_mappings()
  local mappings = {
    q = 'close()',
    ['<cr>'] = 'open_and_close()',
    v = 'split("v")',
    s = 'split("")',
    p = 'preview()',
    t = 'open_in_tab()'
  }

  for k,v in pairs(mappings) do
    -- let's assume that our script is in lua/nvim-oldfile.lua file.
    vim.api.nvim_buf_set_keymap(buf, 'n', k, ':lua require"nvim-oldfile".'..v..'<cr>', {
        nowait = true, noremap = true, silent = true
      })
  end
end

-- at file end
return {
  oldfiles = oldfiles,
  close = close,
  open_and_close = open_and_close,
  preview = preview,
  open_in_tab = open_in_tab,
  split = split
}
command! Oldfiles lua require'nvim-oldfile'.oldfiles()

And that's it! Have fun and make grate things!

The whole plugin

local buf, win, start_win

local function open()
  local path = vim.api.nvim_get_current_line()

  if vim.api.nvim_win_is_valid(start_win) then
    vim.api.nvim_set_current_win(start_win)
    vim.api.nvim_command('edit ' .. path)
  else
    vim.api.nvim_command('leftabove vsplit ' .. path)
    start_win = vim.api.nvim_get_current_win()
  end
end

local function close()
  if win and vim.api.nvim_win_is_valid(win) then
    vim.api.nvim_win_close(win, true)
  end
end

local function open_and_close()
  open()
  close()
end

local function preview()
  open()
  vim.api.nvim_set_current_win(win)
end

local function split(axis)
  local path = vim.api.nvim_get_current_line()

  if vim.api.nvim_win_is_valid(start_win) then
    vim.api.nvim_set_current_win(start_win)
    vim.api.nvim_command(axis ..'split ' .. path)
  else
    vim.api.nvim_command('leftabove ' .. axis..'split ' .. path)
  end

  close()
end

local function open_in_tab()
  local path = vim.api.nvim_get_current_line()

  vim.api.nvim_command('tabnew ' .. path)
  close()
end


local function redraw()
  vim.api.nvim_buf_set_option(buf, 'modifiable', true)
  local items_count =  vim.api.nvim_win_get_height(win) - 1
  local list = {}
  local oldfiles = vim.api.nvim_get_vvar('oldfiles')

  for i = #oldfiles, #oldfiles - items_count, -1 do
    pcall(function()
      local path = vim.api.nvim_call_function('fnamemodify', {oldfiles[i], ':.'})
      table.insert(list, #list + 1, path)
    end)
  end

  vim.api.nvim_buf_set_lines(buf, 0, -1, false, list)
  vim.api.nvim_buf_set_option(buf, 'modifiable', false)
end

local function set_mappings()
  local mappings = {
    q = 'close()',
    ['<cr>'] = 'open_and_close()',
    v = 'split("v")',
    s = 'split("")',
    p = 'preview()',
    t = 'open_in_tab()'
  }

  for k,v in pairs(mappings) do
    vim.api.nvim_buf_set_keymap(buf, 'n', k, ':lua require"nvim-oldfile".'..v..'<cr>', {
        nowait = true, noremap = true, silent = true
      })
  end
end

local function create_win()
  start_win = vim.api.nvim_get_current_win()

  vim.api.nvim_command('botright vnew')
  win = vim.api.nvim_get_current_win()
  buf = vim.api.nvim_get_current_buf()

  vim.api.nvim_buf_set_name(0, 'Oldfiles #' .. buf)

  vim.api.nvim_buf_set_option(0, 'buftype', 'nofile')
  vim.api.nvim_buf_set_option(0, 'swapfile', false)
  vim.api.nvim_buf_set_option(0, 'filetype', 'nvim-oldfile')
  vim.api.nvim_buf_set_option(0, 'bufhidden', 'wipe')

  vim.api.nvim_command('setlocal nowrap')
  vim.api.nvim_command('setlocal cursorline')

  set_mappings()
end

local function oldfiles()
  if win and vim.api.nvim_win_is_valid(win) then
    vim.api.nvim_set_current_win(win)
  else
    create_win()
  end

  redraw()
end

return {
  oldfiles = oldfiles,
  close = close,
  open_and_close = open_and_close,
  preview = preview,
  open_in_tab = open_in_tab,
  split = split
}

Read more on our blog

Check out the knowledge base collected and distilled by experienced
professionals.
bloglist_item
Tech

In Ruby on Rails, view objects are an essential part of the Model-View-Controller (MVC) architecture. They play a crucial role in separating the presentation logic from the business logic of your a...

bloglist_item
Tech

Recently I got assigned to an old project and while luckily it had instructions on how to set it up locally in the Read.me the number of steps was damn too high. So instead of wasting half a da...

bloglist_item
Tech

Today I had the opportunity to use https://docs.ruby-lang.org/en/2.4.0/syntax/refinements_rdoc.html for the first time in my almost 8 years of Ruby programming.
So in general it works in t...

Powstańców Warszawy 5
15-129 Białystok

+48 668 842 999
CONTACT US