How I made my Neovim statusline in Lua
Posted on Sunday, 29 November 2020 Suggest An EditTable of Content
Introduction
Hello there! So, I’ve been playing around with the latest Neovim feature and that is it can now use Lua for its config. Quite a while ago I wrote this post where I explain how I made my statusline. Now, it’s time to update that post using Lua.
Prerequisite
- Neovim 0.5 (we need this version for lua support)
- gitsigns.nvim (optional)
- nerdfont (optional)
- True colours capable terminal (optional)
- Patience
- Googling skills in case something doesn’t work correctly
Creating The Statusline
Writing out first Lua file
We will write our file inside ~/.config/nvim/Lua/
so it will get picked up by Neovim. Let’s call it statusline.lua
inside that path.
Our first function
Let’s make our first function, but before that, let’s prepare a global variable for our statusline to expose it to v:lua
. You can call it whatever you want, usually it’s called M
, but I’ll call it Statusline
. Our current file will look something like this.
Statusline = {}
This first function is going to be a helper function that will return true of false based on our winwidth
. We’ll use this to decide different indicators depending on our winwidth
.
Statusline.is_truncated = function(_, width)
local current_window = vim.fn.winnr()
local current_width = vim.fn.winwidth(current_window)
return current_width < width
end
What this function does is it gets the window number value using vim.fn.winnr()
and we use it as an argument for vim.fn.winwidth
which will return the width of our current window. This function will return true
or false
depending on the argument that we passed to this function.
Highlight groups
To make our statusline look prettier, we’ll get some colours going on so it won’t look bland. Let’s define the highlight groups as a table for easier usage.
Statusline.colors = {
active = '%#StatusLine#',
inactive = '%#StatuslineNC#',
mode = '%#Mode#',
mode_alt = '%#ModeAlt#',
git = '%#Git#',
git_alt = '%#GitAlt#',
filetype = '%#Filetype#',
filetype_alt = '%#FiletypeAlt#',
line_col = '%#LineCol#',
line_col_alt = '%#LineColAlt#',
}
For now, ignore the names, we’ll talk about that later on in this post. This part doesn’t define the colours, it only defines the group name. Let’s define their colours.
local hl = function(group, options)
local bg = options.bg == nil and '' or 'guibg=' .. options.bg
local fg = options.fg == nil and '' or 'guifg=' .. options.fg
local gui = options.gui == nil and '' or 'gui=' .. options.gui
vim.cmd(string.format(
'hi %s %s %s %s',
group, bg, fg, gui
))
end
local color_hl = {
{'StatusLine', { fg = '#3C3836', bg = '#EBDBB2' }},
{'StatusLineNc', { fg = '#3C3836', bg = '#928374' }},
{'Mode', { bg = '#928374', fg = '#1D2021', gui="bold" }},
{'LineCol', { bg = '#928374', fg = '#1D2021', gui="bold" }},
{'Git', { bg = '#504945', fg = '#EBDBB2' }},
{'Filetype', { bg = '#504945', fg = '#EBDBB2' }},
{'Filename', { bg = '#504945', fg = '#EBDBB2' }},
{'ModeAlt', { bg = '#504945', fg = '#928374' }},
{'GitAlt', { bg = '#3C3836', fg = '#504945' }},
{'LineColAlt', { bg = '#504945', fg = '#928374' }},
{'FiletypeAlt', { bg = '#3C3836', fg = '#504945' }},
}
for _, highlight in pairs(highlights) do
set_hl(highlight[1], highlight[2])
end
You can define this using vimscript but I prefer doing it in Lua because why not ツ It’s more fun for me and I don’t like vimscript since I use vim for the first time, so this is nicer.
I personally put this bit on another file where I override my colourscheme, but putting it on this file works fine as well.
Separators
Since I use nerdfont, I have fancy symbols that I can use. I use these symbols as a separator.
Statusline.separators = {
arrow = { '', '' },
rounded = { '', '' },
}
I use the arrow separator, either one is fine. It will look empty here because my website doesn’t use nerdfont :p
Mode indicator
The first indicator of our statusline would be the Mode indicator.
Statusline.get_current_mode = function()
local modes = {
['n'] = {'Normal', 'N'};
['no'] = {'N·Pending', 'N' ;
['v'] = {'Visual', 'V' };
['V'] = {'V·Line', 'V' };
[''] = {'V·Block', 'V'};
['s'] = {'Select', 'S'};
['S'] = {'S·Line', 'S'};
[''] = {'S·Block', 'S'};
['i'] = {'Insert', 'S'};
['R'] = {'Replace', 'R'};
['Rv'] = {'V·Replace', 'V'};
['c'] = {'Command', 'C'};
['cv'] = {'Vim Ex ', 'V'};
['ce'] = {'Ex ', 'E'};
['r'] = {'Prompt ', 'P'};
['rm'] = {'More ', 'M'};
['r?'] = {'Confirm ', 'C'};
['!'] = {'Shell ', 'S'};
['t'] = {'Terminal ', 'T'};
}
local current_mode = vim.fn.mode()
if self:is_truncated(80) then
return string.format(' %s ', modes[current_mode][2]):upper()
else
return string.format(' %s ', modes[current_mode][1]):upper()
end
end
You probably notice that V·Block
and S·Block
look empty but it’s not. It’s a raw character of C-v
and C-s
. If you go to vim and press C-v
in insert mode twice, it will insert something like ^V
. It’s not the same with ^V
, I thought they were the same but they’re not.
What that code does is it creates a key-value pair table with String as a key and a table as a value. We use the table’s key to match what vim.fn.mode()
returns.
Depending on our winwidth
, it will return different output. For example, if our winwidth
isn’t wide enough, it will return N
instead of Normal
. If you want to change when will it change then adjust the argument that we passed into is_truncated
function. I use 80
because that’s what works for me.
Git status
I use gitsigns.nvim to show the git hunk status on signcolumn
. It also provides some details like how many lines have been changed, added, or removed. It also provides the branch name. So, I’d like to integrate this functionality into my statusline. Let’s make our second function
Statusline.get_git_status = function()
-- use fallback because it doesn't set this variable on initial `BufEnter`
local signs = vim.b.gitsigns_status_dict or {head = '', added = 0, changed = 0, removed = 0}
if self:is_truncated(90) then
if signs.head ~= '' then
return string.format(' %s ', signs.head or '')
else
return ''
end
else
if signs.head ~= '' then
return string.format(
' +%s ~%s -%s | %s ',
signs.added, signs.changed, signs.removed, signs.head
)
else
return ''
end
end
end
What we did is get the git hunk status from gitsigns.nvim and put it on a variable. I use fallback here because gitsigns.nvim doesn’t set that variable fast enough on initial BufEnter
so I always get a nil
error.
The next bit is we check if the branch name exists or not (basically checking if we’re in a git repo or not), if it exists then we’d return a formatted status that will look something like this.
If our screen isn’t wide enough, it will remove the git hunk summary and just display the branch name.
I use nerdfont for the git branch icon and the separator.
Filename indicator
Next one is the filename indicator. I’d like to be able to see the filename without having to press C-g
every time I want to check the filename and its path.
Statusline.get_filename = function(self)
if self:is_truncated(140) then
return " %<%f "
else
return " %<%F "
end
end
Depending on our winwidth
, it will display an absolute path, relative path to our CWD, or current filename.
The %<
is to tell the statusline to truncate this indicator if it’s too long or doesn’t have enough space instead of truncating other indicator.
Filetype indicator
I want to see the filetype of the current buffer, so I’d like to include this on my statusline as well.
Statusline.get_filetype = function()
local filetype = vim.bo.filetype
if filetype == '' then
return ''
else
return string.format(' %s ', filetype):lower()
end
end
It gets a value from vim.bo.filetype
which will return a filetype and we’ll make it lowercase using lower()
method. If the current buffer doesn’t have a filetype, it will return nothing.
Line indicator
Even though I have number
and relativenumber
turned on, I’d like to have this on my statusline as well.
Statusline.get_line_col = function(self)
if self:is_truncated(60) then
return ' %l:%c '
else
return ' Ln %l, Col %c '
end
end
It will display something like Ln 12, Col 2
which means we’re at Line 12 and Column 2. This also depends on our windwidth
, if it’s not wide enough then it will display something like 12:2
.
LSP diagnostic
I use the built-in LSP client and it has diagnostic capability. We can get the count summary using vim.lsp.diagnostic.get_count(bufnr, severity)
. Let’s integrate it into our statusline.
Statusline.get_lsp_diagnostic = function(self)
local result = {}
local levels = {
errors = 'Error',
warnings = 'Warning',
info = 'Information',
hints = 'Hint'
}
for k, level in pairs(levels) do
result[k] = vim.lsp.diagnostic.get_count(0, level)
end
if self:is_truncated(120) then
return ''
else
return string.format(
"| :%s :%s :%s :%s ",
result['errors'] or 0, result['warnings'] or 0,
result['info'] or 0, result['hints'] or 0
)
end
end
I got this section from this repo with some modification. It will be hidden when our winwidth
is less than 120
. I don’t personally use this because I use a small monitor so it rarely fit :p
Different Statusline
We don’t want our active and inactive statusline to be the same. It would be better if they have a distinct separation, at least in my opinion.
Active Statusline
We’ve made the components/indicators, time to combine them all.
Statusline.set_active = function(self)
local mode = self.colors.mode .. self:get_current_mode()
local mode_alt = self.colors.mode_alt .. self.separators.arrow[1]
local git = self.colors.git .. self:get_git_status()
local git_alt = self.colors.git_alt .. self.separators.arrow[1]
local filename = self.colors.inactive .. self:get_filename()
local filetype_alt = self.colors.filetype_alt .. self.separators.arrow[2]
local filetype = self.colors.filetype .. self:get_filetype()
local line_col = self.colors.line_col .. self:get_line_col()
local line_col_alt = self.colors.line_col_alt .. self.separators.arrow[2]
local diagnostic = self.colors.line_col .. self:get_lsp_diagnostic()
return table.concat({
self.colors.active,
mode, mode_alt, git, git_alt,
"%=", filename, "%=",
filetype_alt, filetype, line_col_alt, line_col, diagnostic
})
end
Remember the highlight groups that we defined earlier? We concatenate them with our indicator. For example, the result will look something like %#Mode#Normal
.
The %=
acts like a separator. If we put one, it will place the next indicators to the right, since I want my filename indicator to be in the middle, I put 2 of them around my filename indicator. It will basically center it. You can freely swap them around of course.
Inactive Statusline
Like I said previously, I’d like to have a distinct separation between my active and inactive statusline. There’s nothing much going on here, it only shows absolute file path on the center and dimmed background.
Statusline.set_inactive = function(self)
return self.colors.inactive .. '%= %F %='
end
Dynamic statusline
We’ve made our Active and Inactive statusline, let’s make them change dynamically.
Statusline.active = function() return Statusline:set_active() end
Statusline.inactive = function() return Statusline:set_inactive() end
vim.api.nvim_exec([[
augroup Statusline
au!
au WinEnter,BufEnter * setlocal statusline=%!v:lua.Statusline.active()
au WinLeave,BufLeave * setlocal statusline=%!v:lua.Statusline.inactive()
augroup END
]], true)
This auto command runs every time you enter or leave a buffer and set the corresponding statusline. It needs to be done using vimscript because it doesn’t have lua version yet. It’s currently a work in progress at the time of writing this post.
We made a new function because the colon :
in lua is a method, but it’s also reserved in vimscript. So we need a function that doesn’t need that in lua.
Result
Let’s see the whole file now.
Statusline = {}
Statusline.is_truncated = function(_, width)
local current_window = vim.fn.winnr()
local current_width = vim.fn.winwidth(current_window)
return current_width < width
end
Statusline.colors = {
active = '%#StatusLine#',
inactive = '%#StatuslineNC#',
mode = '%#Mode#',
mode_alt = '%#ModeAlt#',
git = '%#Git#',
git_alt = '%#GitAlt#',
filetype = '%#Filetype#',
filetype_alt = '%#FiletypeAlt#',
line_col = '%#LineCol#',
line_col_alt = '%#LineColAlt#',
}
local set_hl = function(group, options)
local bg = options.bg == nil and '' or 'guibg=' .. options.bg
local fg = options.fg == nil and '' or 'guifg=' .. options.fg
local gui = options.gui == nil and '' or 'gui=' .. options.gui
vim.cmd(string.format(
'hi %s %s %s %s',
group, bg, fg, gui
))
end
local color_hl = {
{'Active', { bg = 'blue', fg = '#EBDBB2' }},
{'Inactive', { bg = '#3C3836', fg = '#928374' }},
{'Mode', { bg = '#928374', fg = '#1D2021', gui="bold" }},
{'LineCol', { bg = '#928374', fg = '#1D2021', gui="bold" }},
{'Git', { bg = '#504945', fg = '#EBDBB2' }},
{'Filetype', { bg = '#504945', fg = '#EBDBB2' }},
{'Filename', { bg = '#504945', fg = '#EBDBB2' }},
{'ModeAlt', { bg = '#504945', fg = '#928374' }},
{'GitAlt', { bg = '#3C3836', fg = '#504945' }},
{'LineColAlt', { bg = '#504945', fg = '#928374' }},
{'FiletypeAlt', { bg = '#3C3836', fg = '#504945' }},
}
for _, highlight in pairs(highlights) do
set_hl(highlight[1], highlight[2])
end
Statusline.separators = {
arrow = { '', '' },
rounded = { '', '' },
}
Statusline.get_current_mode = function()
local modes = {
['n'] = {'Normal', 'N'};
['no'] = {'N·Pending', 'N' ;
['v'] = {'Visual', 'V' };
['V'] = {'V·Line', 'V' };
[''] = {'V·Block', 'V'};
['s'] = {'Select', 'S'};
['S'] = {'S·Line', 'S'};
[''] = {'S·Block', 'S'};
['i'] = {'Insert', 'S'};
['R'] = {'Replace', 'R'};
['Rv'] = {'V·Replace', 'V'};
['c'] = {'Command', 'C'};
['cv'] = {'Vim Ex ', 'V'};
['ce'] = {'Ex ', 'E'};
['r'] = {'Prompt ', 'P'};
['rm'] = {'More ', 'M'};
['r?'] = {'Confirm ', 'C'};
['!'] = {'Shell ', 'S'};
['t'] = {'Terminal ', 'T'};
}
local current_mode = vim.fn.mode()
if self:is_truncated(80) then
return string.format(' %s ', modes[current_mode][2]):upper()
else
return string.format(' %s ', modes[current_mode][1]):upper()
end
end
Statusline.get_git_status = function()
-- use fallback because it doesn't set this variable on initial `BufEnter`
local signs = vim.b.gitsigns_status_dict or {head = '', added = 0, changed = 0, removed = 0}
if self:is_truncated(90) then
if signs.head ~= '' then
return string.format(' %s ', signs.head or '')
else
return ''
end
else
if signs.head ~= '' then
return string.format(
' +%s ~%s -%s | %s ',
signs.added, signs.changed, signs.removed, signs.head
)
else
return ''
end
end
end
Statusline.get_filename = function(self)
if self:is_truncated(140) then
return " %f "
else
return " %F "
end
end
Statusline.get_filetype = function()
local filetype = vim.bo.filetype
if filetype == '' then
return ''
else
return string.format(' %s ', filetype):lower()
end
end
Statusline.get_line_col = function(self)
if self:is_truncated(60) then
return ' %l:%c '
else
return ' Ln %l, Col %c '
end
end
Statusline.get_lsp_diagnostic = function(self)
local result = {}
local levels = {
errors = 'Error',
warnings = 'Warning',
info = 'Information',
hints = 'Hint'
}
for k, level in pairs(levels) do
result[k] = vim.lsp.diagnostic.get_count(0, level)
end
if self:is_truncated(120) then
return ''
else
return string.format(
"| :%s :%s :%s :%s ",
result['errors'] or 0, result['warnings'] or 0,
result['info'] or 0, result['hints'] or 0
)
end
end
Statusline.set_active = function(self)
local mode = self.colors.mode .. self:get_current_mode()
local mode_alt = self.colors.mode_alt .. self.separators.arrow[1]
local git = self.colors.git .. self:get_git_status()
local git_alt = self.colors.git_alt .. self.separators.arrow[1]
local filename = self.colors.inactive .. self:get_filename()
local filetype_alt = self.colors.filetype_alt .. self.separators.arrow[2]
local filetype = self.colors.filetype .. self:get_filetype()
local line_col = self.colors.line_col .. self:get_line_col()
local line_col_alt = self.colors.line_col_alt .. self.separators.arrow[2]
local diagnostic = self.colors.line_col .. self:get_lsp_diagnostic()
return table.concat({
self.colors.active,
mode, mode_alt, git, git_alt,
"%=", filename, "%=",
filetype_alt, filetype, line_col_alt, line_col, diagnostic
})
end
Statusline.set_inactive = function(self)
return self.colors.inactive .. '%= %F %='
end
Statusline.active = function() return Statusline:set_active() end
Statusline.inactive = function() return Statusline:set_inactive() end
vim.api.nvim_exec([[
augroup Statusline
au!
au WinEnter,BufEnter * setlocal statusline=%!v:lua.Statusline.active()
au WinLeave,BufLeave * setlocal statusline=%!v:lua.Statusline.inactive()
augroup END
]], true)
And here’s the result.
Here’s a preview video for a better demonstration. As you can see on the video, they change their appearance based on the screen width.
That’s the active statusline, I don’t think I need to put a screenshot for the inactive one because nothing interesting going on there :p.
Here’s my statusline file for a reference. There are also some great statusline plugins written in lua if you want to get started quickly such as express_line.nvim, galaxyline.nvim, and neoline.vim.
Closing Note
I really like how it turned out, Lua support on Neovim is probably the best update I’ve ever experienced. Kudos to all of Neovim contributors! If you have a better suggestion for this post then feel free to click on the suggest an edit
link below the title, it will bring you to the original file of this post. Anyway, thanks for reading, and have a great day! ツ