News from the Machineroom


Writing a Neovim Theme in Lua

Published:

Recently I got interested in neovim, since it can be extended and configured using Lua script. While vim can be extended with major scripting languages like Python and Ruby since basically forever, these are pretty heavy runtimes. Also, for configuration vimL is still the way to go, which I find quite off-putting.

Since version 0.5 neovim supports using Lua for configuration, which sparked my interest and thus I began creating a new configuration from scratch, but this is a story for another post. In this one, I want to document my experience of creating a theme using Lua script.

For some time now I use a custom theme in Sublime Text which I called DeepSea, consisting mainly of blue with some yellow accents. Since I currently spend a considerable amount of time in CLion due to my work, I have reproduced it inside that, too. Both of these versions are unpublished as of now, although that hopefully changes in the future. In vim I previously used the lucius and Apprentice themes, but for neovim I was in the mood to try something new: to reproduce my deepsea theme in neovim. Because I cannot stand vimL and was curious about using Lua with neovim, I decided to make the theme with Lua. A quick search on the web lead me to some examples, which I used as a rough guidance:

Since rose-pine is the simplest of the bunch, it ended up being my main inspiration, but I still found plenty of stuff to remove and simplify. I like simple things, so my new theme does contain neither colour variants nor other options. This helps to keep things simple, which is also a big bonus when learning things.

To test the theme locally, we need to make the theme available to the runtime path. Of course I want to publish it, so it should live under version control in its own directory. Therefore I create the source directory in my usual development directory tree, i.e. $HOME/devel/plugins/neovim/deepsea. Then I create a package directory in the local hierarchy and symlinked my source directory to it:

$ mkdir -p .local/share/nvim/site/pack/local/start
$ ln -s $HOME/devel/plugins/neovim/deepsea .local/share/nvim/site/pack/local/start/deepsea

I am not a huge fan of the freedesktop.org directory hierarchies in general, but in this case it works out quite nicely. Shared configuration lives version-controlled in $HOME/.config/nvim and local, temporary stuff lives in $HOME/.local/share/nvim/site.

With that out of the way, we can take a look at the plugin itself. Vim plugins are structured into multiple directories, each containing code for a specific purpose:

Neovim adds a new directory, lua, which contains lua modules accessible by the neovim runtime.

For a colour scheme like we are building, we obviously need the colours directory, but as it turns out it is just an entrypoint to the Lua code. In all of the colour schemes I looked at, it only contained a small vimL script, that immediately called the lua command to execute some Lua code. In fact, neovim supports Lua scripts directly, so lets use it directly!

It always follows the same theme:

  1. clear the loaded package
  2. require the package
  3. execute some code from the package

Since we do not provide configuration options for the deepsea colour scheme, a small colors/deepsea.lua is completely sufficient:

package.loaded['deepsea'] = nil
require('deepsea').colorscheme()

All it does is calling the colorscheme() function from the deepsea module, so that is what we will look at next. All of our reference themes have a subdirectory with modules for the Lua code:

Each of them contains even more files, for example a colors.lua file containing the colour definitions. It kind of makes sense for them, since they all have a lot of options and therefore a lot of code, but I like to keep things small and simple. So we will just put everything we need into a single module, lua/deepsea.lua. This way we will not even need a subdirectory in the lua directory. The file is just a regular Lua module, which provides the colorscheme() function invoked by the colour scheme vimL script we saw earlier:

local M = {}

function M.colorscheme()
	-- code here
end

return M

A vim colour scheme is basically a sequence of :highlight commands put into a file that is executed as a vimL script. This will obviously not work directly with Lua code, so we need to invoke the command ourselves. Our reference themes all use the vim.cmd() function to execute the highlight command, so we will do the same. And since we want to use true colours, we use the guibg and guifg keys.

Using true colour with the neovim theme needs the termguicolors option set. And if neovim is used inside of tmux(1) running in an xterm(1), tmux needs to override the xterm termcap entry to enable true colour support, which is done with this line in $HOME/.tmux.conf:

set-option -as terminal-overrides ",xterm-256color*:Tc"

The highlight command takes five arguments:

  1. the highlight group
  2. the effect (bold, underline)
  3. the foreground colour guifg
  4. the background colour guibg
  5. the effect colour guisp

This structure lends itself perfectly to organize the theme into a tabular form, mapping each highlight group to a set of attributes. This can neatly be expressed in Lua as a table containing more tables:

local theme = {
	CursorLine = { bg = '#123456' },
	CursorLineNR = { bg = '#123456', fg = '#FF0000' },
	LineNR = { bg = '#011220', fg = '#FFCC00' },
	Normal = { bg = '#002233', fg = '#AAAAAA' },
}

Each entry into the inner table corresponds to a configuration key, mapped to a highlight argument. We implement this in a function which we conveniently call highlight(), just like the vim command we wrap:

local function highlight(group, style)
	local effect = style.effect and 'gui=' .. style.effect or 'gui=NONE'
	local fg = style.fg and 'guifg=' .. style.fg or 'guifg=NONE'
	local bg = style.bg and 'guibg=' .. style.bg or 'guibg=NONE'
	local sp = style.sp and 'guisp=' .. style.sp or ''

	vim.cmd(string.format('highlight %s %s %s %s %s', group, effect, fg, bg, sp))
end

Each entry of the style table is mapped to a argument string, with four possible argument types:

When we add more and more styles for ever more highlight groups, we soon discover that we need to repeat a lot of colour values. Keeping everything consistent gets combersome quite quickly and makes changes rather painful. What we want is a colour palette, which we also express as a lua table. Each colour is given a name, so if we want to change a colour, we edit its value once and all highlight groups using the name will be changed automatically:

-- colour palette
local p = {
	darkblue = '#002233',
	darkerblue = '#011220',
	lightgrey = '#AAAAAA',
	mediumblue = '#123456',
	red = '#FF0000',
	yellow = '#FFCC00'
}
-- theme
local t = {
	CursorLine = { bg = p.mediumblue },
	CursorLineNR = { bg = p.mediumblue, fg = p.red },
	LineNR = { bg = p.darkerblue, fg = p.yellow },
	Normal = { bg = p.darkblue, fg = p.red },
}

To avoid a lot of typing, I chose to name my colour palette table p. Yes, I use single-letter variables. That’s how I roll.

Now we can iterate through our theme table and call the highlight() function with our styles.

But since another colour scheme might be active when our theme is applied, we check the colors_name global vim variable. If it is set, we call the :highlight clear vim command to remove all previously set highlights, so that we start with a blank slate. And of course we need to set the name of our colour scheme. This gives us everything to fill out the content of the colorscheme() function sketched out above:

function M.colorscheme()
	if vim.g.colors_name then
		vim.cmd('highlight clear')
	end
	vim.g.colors_name = 'deepsea'
	for group, style in pairs(t) do
		highlight(group, style)
	end
end

That’s it! Our first neovim colour scheme, all written in Lua script.

The complete code for the theme is available from the Codeberg repository, but it is actively worked on and therefore may be subject to change.