How I made my Neovim statusline in Lua

Suggest An Edit

Table 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.

gitstatus

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.

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! ツ