One of goals which neovim devs set for themselves, was making lua the first-class scripting language alternative to viml. Since version 0.4 its' interpreter along with 'stdlib' have been already built into the editor. Lua is quite simple to learn, very fast and widely used in gamedev community. In my opinion it also has much lower learning curve than viml, which may encourage new people to begin their journey with extending neovim capabilities or just making simple scripts for one time purposes. So how about we try it out? Let's write simple plugin to display the files we have recently worked on. How should we name it? Maybe... "What have I done?!". It will look something like that:
Plugin directories structure
Our plugin should have at least two directories: plugin
where we put its main file and lua
with whole codebase. Of course if we really want it, we can put everything in one file, but please, let's not be this guy (or gal). So plugin/whid.vim
and lua/whid.lua
will be fine. We can start from:
" in plugin/whid.vim
if exists('g:loaded_whid') | finish | endif " prevent loading file twice
let s:save_cpo = &cpo " save user coptions
set cpo&vim " reset them to defaults
" command to run our plugin
command! Whid lua require'whid'.whid()
let &cpo = s:save_cpo " and restore after
unlet s:save_cpo
let g:loaded_whid = 1
let s:save_cpo = &cpo
is a common practice preventing custom coptions
(sequence of single character flags) to interfere with the plugin. For our own purposes, the lack of this line would probably not hurt, but it is considered as good practice (at least according to the vim help files). There is also command! Whid lua require'whid'.whid()
which requires plugin's lua module and calls its main function.
Floating window
Okey let's start with something fun. We should create a place where we can display things. Thankfully neovim (now vim too) has neat feature called floating windows. It's a window that is displayed over top of other windows, like in OS.
-- in lua/whid.lua
local api = vim.api
local buf, win
local function open_window()
buf = api.nvim_create_buf(false, true) -- create new emtpy buffer
api.nvim_buf_set_option(buf, 'bufhidden', 'wipe')
-- get dimensions
local width = api.nvim_get_option("columns")
local height = api.nvim_get_option("lines")
-- calculate our floating window size
local win_height = math.ceil(height * 0.8 - 4)
local win_width = math.ceil(width * 0.8)
-- and its starting position
local row = math.ceil((height - win_height) / 2 - 1)
local col = math.ceil((width - win_width) / 2)
-- set some options
local opts = {
style = "minimal",
relative = "editor",
width = win_width,
height = win_height,
row = row,
col = col
}
-- and finally create it with buffer attached
win = api.nvim_open_win(buf, true, opts)
end
On top of the file we define win
and buf
variables in the highest scope, which will be often referenced by the other function. Empty, at this moment, the buffer will be the place where we put our results. It was created as not listed buffer (first argument) and "scratch-buffer" (second argument; see :h scratch-buffer
). Also we set it to be deleted when hidden bufhidden = wipe
.
With nvim_open_win(buf, true, opts)
we create new window with previously created buffer attached to it. Second argument makes the new window focused. width
and height
are pretty self explanatory. row
and col
are starting position of our window calculated from the upper left corner of editor relative = "editor"
. style = "minimal"
is handy option that configures appearance of window and here we disable many unwanted options, like line numbers or highlighting of spelling errors.
So now we have floating window, but we can make it look even better. Neovim currently doesn't support widgets like border, so we should create one by ourselves. It's quite simple. We need another floating window, slightly bigger than the first one and placed under it.
local border_opts = {
style = "minimal",
relative = "editor",
width = win_width + 2,
height = win_height + 2,
row = row - 1,
col = col - 1
}
We will fill it with "box-drawing" characters.
local border_buf = api.nvim_create_buf(false, true)
local border_lines = { '╔' .. string.rep('═', win_width) .. '╗' }
local middle_line = '║' .. string.rep(' ', win_width) .. '║'
for i=1, win_height do
table.insert(border_lines, middle_line)
end
table.insert(border_lines, '╚' .. string.rep('═', win_width) .. '╝')
api.nvim_buf_set_lines(border_buf, 0, -1, false, border_lines)
-- set bufer's (border_buf) lines from first line (0) to last (-1)
-- ignoring out-of-bounds error (false) with lines (border_lines)
Of course we must open windows in proper order. And one more thing. Both windows should always close together. It will be quite odd if after closing first the border is still there. Currently viml autocomand is the best solution for this.
local border_win = api.nvim_open_win(border_buf, true, border_opts)
win = api.nvim_open_win(buf, true, opts)
api.nvim_command('au BufWipeout <buffer> exe "silent bwipeout! "'..border_buf)
Get some data
Our plugin is designed to show latest files that we have worked on. We will use simple git command to do that. Something like:
git diff-tree --no-commit-id --name-only -r HEAD
Let's create function, that will put some data in our pretty window. We will call it frequently, so let's name it update_view
.
local function update_view()
-- we will use vim systemlist function which run shell
-- command and return result as list
local result = vim.fn.systemlist('git diff-tree --no-commit-id --name-only -r HEAD')
-- with small indentation results will look better
for k,v in pairs(result) do
result[k] = ' '..result[k]
end
api.nvim_buf_set_lines(buf, 0, -1, false, result)
end
Hmm... it isn't very handy if we can look only for current files. Because we will directly call this function to update our view, we should accept param with information on whether we want show older or newer state.
local position = 0
local function update_view(direction)
position = position + direction
if position < 0 then position = 0 end -- HEAD~0 is the newest state
local result = vim.fn.systemlist('git diff-tree --no-commit-id --name-only -r HEAD~'..position)
-- ... rest of the code
end
Do you know what is still missing here? Our plugin's title. Centered! This function will help us put some text in the middle.
local function center(str)
local width = api.nvim_win_get_width(0)
local shift = math.floor(width / 2) - math.floor(string.len(str) / 2)
return string.rep(' ', shift) .. str
end
api.nvim_buf_set_lines(buf, 0, -1, false, {
center('What have i done?'),
center('HEAD~'..position),
''
})
end
Some highlights will be nice. There are a few options we can chose: defining custom syntax file (you can match pattern based on line number) or use virtual text annotation instead of normal text (it is possible, but in that case our code would be more complicated) or... we can use position based highlighting nvim_buf_add_highlight
. But first we must declare our highlights. We will link to existing default highlights group instead of setting color by ourselves. This way it will match user colorsheme.
" in plugin/whid.vim after set cpo&vim
hi def link WhidHeader Number
hi def link WhidSubHeader Identifier
Now let's add highlighting
api.nvim_buf_add_highlight(buf, -1, 'WhidHeader', 0, 0, -1)
api.nvim_buf_add_highlight(buf, -1, 'WhidSubHeader', 1, 0, -1)
We add highlights to buffer buf
as ungrouped highlight (second argument -1
). We can pass the namespace id here, which give us possibility to clear all highlights in group at once, but in our case we don't need that. Next is line number and last two params are start and end (byte-indexed) column range.
Whole function will look like this:
local function update_view(direction)
-- Is nice to prevent user from editing interface, so
-- we should enabled it before updating view and disabled after it.
api.nvim_buf_set_option(buf, 'modifiable', true)
position = position + direction
if position < 0 then position = 0 end
local result = vim.fn.systemlist('git diff-tree --no-commit-id --name-only -r HEAD~'..position)
for k,v in pairs(result) do
result[k] = ' '..result[k]
end
api.nvim_buf_set_lines(buf, 0, -1, false, {
center('What have i done?'),
center('HEAD~'..position),
''
})
api.nvim_buf_set_lines(buf, 3, -1, false, result)
api.nvim_buf_add_highlight(buf, -1, 'WhidHeader', 0, 0, -1)
api.nvim_buf_add_highlight(buf, -1, 'whidSubHeader', 1, 0, -1)
api.nvim_buf_set_option(buf, 'modifiable', false)
end
User input
Now we should make our plugin interactive. Nothing too complicated, only simple features like changing currently previewed state or selecting and opening files. Our plugin will receive user input via mappings. Pressing a key will trigger certain action. Let's look how mappings are defined in lua api.
api.nvim_buf_set_keymap(buf, 'n', 'x', ':echo "wow!"<cr>', { nowait = true, noremap = true, silent = true })
First argument, as usual, is the buffer. These mappings will be scoped to it. Next is mode short-name. We define all ours mappings in normal mode n
. Then is a "left" keys combination (I choose x
as example) mapped to "right" keys combination (we tell neovim to enter command-line mode, typing some viml and push enter <cr>
). At last there are some options. We want neovim to trigger mapping as soon as it matches a pattern, so we set nowait
flag, we prevent it from triggering our mapping via others mappings noremap
and don't show typing to user silent
. It is quite a long line, so we use array to save some writing.
local function set_mappings()
local mappings = {
['['] = 'update_view(-1)',
[']'] = 'update_view(1)',
['<cr>'] = 'open_file()',
h = 'update_view(-1)',
l = 'update_view(1)',
q = 'close_window()',
k = 'move_cursor()'
}
for k,v in pairs(mappings) do
api.nvim_buf_set_keymap(buf, 'n', k, ':lua require"whid".'..v..'<cr>', {
nowait = true, noremap = true, silent = true
})
end
end
We can also disable not used keys (or not, whichever you like).
local other_chars = {
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'i', 'n', 'o', 'p', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'
}
for k,v in ipairs(other_chars) do
api.nvim_buf_set_keymap(buf, 'n', v, '', { nowait = true, noremap = true, silent = true })
api.nvim_buf_set_keymap(buf, 'n', v:upper(), '', { nowait = true, noremap = true, silent = true })
api.nvim_buf_set_keymap(buf, 'n', '<c-'..v..'>', '', { nowait = true, noremap = true, silent = true })
end
Public function
Okey, but there are some new functions mentioned in the mapping. Let's look at them.
local function close_window()
api.nvim_win_close(win, true)
end
-- Our file list start at line 4, so we can prevent reaching above it
-- from bottm the end of the buffer will limit movment
local function move_cursor()
local new_pos = math.max(4, api.nvim_win_get_cursor(win)[1] - 1)
api.nvim_win_set_cursor(win, {new_pos, 0})
end
-- Open file under cursor
local function open_file()
local str = api.nvim_get_current_line()
close_window()
api.nvim_command('edit '..str)
end
Our file list is quite simple. We can just get line under the cursor and tell neovim to edit it. Of course we can built more sophisticated mechanism. We can get line number (or even column) and then, based on it, trigger specific action. It will allow to separate view form logic. But for our purposes it is enough.
However non of these functions can be called if we don't export them first. At the bottom of the file we will return associative array with publicly available functions.
return {
whid = whid,
update_view = update_view,
open_file = open_file,
move_cursor = move_cursor,
close_window = close_window
}
And of course the main function!
local function whid()
position = 0 -- if you want to preserve last displayed state just omit this line
open_window()
set_mappings()
update_view(0)
api.nvim_win_set_cursor(win, {4, 0}) -- set cursor on first list entry
end
The whole plugin...
... with small improvments (you can find them by looking for the comments).
" plugin/whid.vim
if exists('g:loaded_whid') | finish | endif
let s:save_cpo = &cpo
set cpo&vim
hi def link WhidHeader Number
hi def link WhidSubHeader Identifier
command! Whid lua require'whid'.whid()
let &cpo = s:save_cpo
unlet s:save_cpo
let g:loaded_whid = 1
-- lua/whid.lua
local api = vim.api
local buf, win
local position = 0
local function center(str)
local width = api.nvim_win_get_width(0)
local shift = math.floor(width / 2) - math.floor(string.len(str) / 2)
return string.rep(' ', shift) .. str
end
local function open_window()
buf = api.nvim_create_buf(false, true)
local border_buf = api.nvim_create_buf(false, true)
api.nvim_buf_set_option(buf, 'bufhidden', 'wipe')
api.nvim_buf_set_option(buf, 'filetype', 'whid')
local width = api.nvim_get_option("columns")
local height = api.nvim_get_option("lines")
local win_height = math.ceil(height * 0.8 - 4)
local win_width = math.ceil(width * 0.8)
local row = math.ceil((height - win_height) / 2 - 1)
local col = math.ceil((width - win_width) / 2)
local border_opts = {
style = "minimal",
relative = "editor",
width = win_width + 2,
height = win_height + 2,
row = row - 1,
col = col - 1
}
local opts = {
style = "minimal",
relative = "editor",
width = win_width,
height = win_height,
row = row,
col = col
}
local border_lines = { '╔' .. string.rep('═', win_width) .. '╗' }
local middle_line = '║' .. string.rep(' ', win_width) .. '║'
for i=1, win_height do
table.insert(border_lines, middle_line)
end
table.insert(border_lines, '╚' .. string.rep('═', win_width) .. '╝')
api.nvim_buf_set_lines(border_buf, 0, -1, false, border_lines)
local border_win = api.nvim_open_win(border_buf, true, border_opts)
win = api.nvim_open_win(buf, true, opts)
api.nvim_command('au BufWipeout <buffer> exe "silent bwipeout! "'..border_buf)
api.nvim_win_set_option(win, 'cursorline', true) -- it highlight line with the cursor on it
-- we can add title already here, because first line will never change
api.nvim_buf_set_lines(buf, 0, -1, false, { center('What have i done?'), '', ''})
api.nvim_buf_add_highlight(buf, -1, 'WhidHeader', 0, 0, -1)
end
local function update_view(direction)
api.nvim_buf_set_option(buf, 'modifiable', true)
position = position + direction
if position < 0 then position = 0 end
local result = vim.fn.systemlist('git diff-tree --no-commit-id --name-only -r HEAD~'..position)
if #result == 0 then table.insert(result, '') end -- add an empty line to preserve layout if there is no results
for k,v in pairs(result) do
result[k] = ' '..result[k]
end
api.nvim_buf_set_lines(buf, 1, 2, false, {center('HEAD~'..position)})
api.nvim_buf_set_lines(buf, 3, -1, false, result)
api.nvim_buf_add_highlight(buf, -1, 'whidSubHeader', 1, 0, -1)
api.nvim_buf_set_option(buf, 'modifiable', false)
end
local function close_window()
api.nvim_win_close(win, true)
end
local function open_file()
local str = api.nvim_get_current_line()
close_window()
api.nvim_command('edit '..str)
end
local function move_cursor()
local new_pos = math.max(4, api.nvim_win_get_cursor(win)[1] - 1)
api.nvim_win_set_cursor(win, {new_pos, 0})
end
local function set_mappings()
local mappings = {
['['] = 'update_view(-1)',
[']'] = 'update_view(1)',
['<cr>'] = 'open_file()',
h = 'update_view(-1)',
l = 'update_view(1)',
q = 'close_window()',
k = 'move_cursor()'
}
for k,v in pairs(mappings) do
api.nvim_buf_set_keymap(buf, 'n', k, ':lua require"whid".'..v..'<cr>', {
nowait = true, noremap = true, silent = true
})
end
local other_chars = {
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'i', 'n', 'o', 'p', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'
}
for k,v in ipairs(other_chars) do
api.nvim_buf_set_keymap(buf, 'n', v, '', { nowait = true, noremap = true, silent = true })
api.nvim_buf_set_keymap(buf, 'n', v:upper(), '', { nowait = true, noremap = true, silent = true })
api.nvim_buf_set_keymap(buf, 'n', '<c-'..v..'>', '', { nowait = true, noremap = true, silent = true })
end
end
local function whid()
position = 0
open_window()
set_mappings()
update_view(0)
api.nvim_win_set_cursor(win, {4, 0})
end
return {
whid = whid,
update_view = update_view,
open_file = open_file,
move_cursor = move_cursor,
close_window = close_window
}
Now you should have a basic knowledge, enough to write simple TUI for your lua neovim scripts. Have fun!
The code can be found also here https://github.com/rafcamlet/nvim-whid