Automatically Changing Theme in the Terminal

I was always a proponent of using a dark theme for anything terminal-related. Be it Neovim or a terminal emulator. It is an exception, though. I have everything else configured to follow the system theme which changes based on the time of the day.

Some time ago, I was sitting in a bright waiting room and working in the terminal. The text was hardly legible. Increasing MacBook's brightness to its maximum didn't help.

"I wonder if changing to a light theme would make things more legible?"—I thought to myself.

I caved in. I switched to a light color scheme in Neovim. It sure was better and much easier on the eyes!

It was just a beginning, though. All I did was manually changing a setting in Neovim. Ideally, I wouldn't even have to think about it. Additionally, there are a couple more things that I want to line up as changing the color scheme only in Neovim created this eyesore:

A WezTerm terminal emulator with a default dark color scheme. Inside the WezTerm window, tmux with a dark status line runs. Inside tmux, Neovim with a light color scheme runs.

The off-color parts are WezTerm and tmux. Moreover, I have a themed fish shell (not pictured) and its colors should also change.

Obstacles

First thing I did was checking if WezTerm can detect the current system theme. It sure does! Knowing, that I can detect in in WezTerm I started to look for a way to pass this information down to tmux, fish, and Neovim. That's when the problems started.

My first thought was using an environment variable. After quite a while of experimenting I ran into an issue—tmux takes a snapshot of variables when it starts its server. I learned about update-environment option. However, I couldn't make it work. Additionally, per its documentation it would only work when creating a new tmux session or attaching an existing tmux session. That's not ideal, because the most common scenario is that I have laptop closed and want an automatic change as soon as I open it.

I then realized that there is yet another obstacle. update-environment might work for updating sessions and passing the information down to fish and Neovim. What about updating tmux itself? tmux.conf is evaluated only once—at start-up. I experimented with reloading tmux, but to no avail.

So... How did I get it going?

The Theme

Before I get to the solution, I want to mention which theme I use. For over a year and a half I have been using tokyonight.nvim. What I like about this theme is the fact that it has a lot of extras—themes for other programs. This allows me to have everything in sync: from Neovim, through shell, to terminal emulator. To top it off, tokyonight.nvim supports a wide range of Neovim plugins.

Tokyonight has 4 different flavors, including a light variant. That's the light theme I tried and I mostly liked it. "Mostly", because I found it too washed-out. I found slightly brightening the background to fix this for me.

Neovim with themed with tokyonight color scheme.
The default background color (#e1e2e7)
Neovim with themed with tokyonight color scheme and background color changed to #f1f2f7.
Background color set to #f1f2f7

I have removed this background color override from code snippets in the next section for clarity. If you would like to see the exact code that I used, then see the commit where I introduced these changes.

The Automation

WezTerm

Making WezTerm automatically switch its theme was the least cumbersome part. WezTerm's documentation has a recipe for detecting system's appearance. Additionally, WezTerm automatically reloads its config on change. Finally, I stumbled upon a comment on GitHub that showed a solution using a file in a /tmp directory for syncing the theme.

That's how I came up with a following Lua module.

local wezterm = require("wezterm")
local M = {}

---@type Mode|nil
local force = nil

---@enum (key) Mode
local theme = {
	light = "tokyonight_day",
	dark = "tokyonight_storm",
}

---@return Mode
local detect = function()
	if force ~= nil then
		return force
	elseif wezterm.gui and wezterm.gui.get_appearance():find("Light") then
		return "light"
	end
	return "dark"
end

M.set = function(config)
	local mode = detect()
	config.color_scheme = theme[mode]

	local ok, _, stderr = wezterm.run_child_process({
		"sh",
		"-c",
		'echo "' .. mode .. '" > /tmp/tymek-theme',
	})
	if not ok then
		error(stderr, 0)
	end
end

return M

With the above module in place the only change to ~/.config/wezterm/wezterm.lua was:

-config.color_scheme = "tokyonight_storm"
+require("theme").set(config)

tmux

To solve the tmux issues, I dug into its man page. That's how I learned about tmux hooks. Namely, a client-focus-in hook. Judging by its name, it sounds like a perfect solution!

It did take some wrestling to get the hook working. The syntax for defining hooks is:

set-hook hook-name command

Looking at what commands are available in tmux I found out about source. However, things weren't that simple... I ran into config parsing problems problems, because command has to be a single string. Additionally, for some reason I couldn't make tmux source to work.

Luckily, I managed to wrap the tmux source call in a run-shell command. It wasn't that straightforward either. The run-shell command also expects a single string.

*Cue in me wrestling with quoting inside my tmux.conf*

After some time I emerged victorious! I managed to register a client-focus-in hook that used run-shell which ran tmux source. I ended up refactoring this solution to create a separate update-theme-tmux script:

#!/bin/sh
update() {
  VARIANT="$(cat /tmp/tymek-theme)"
  FORMAT="${HOME}/.local/share/nvim/lazy/tokyonight.nvim/extras/tmux/%s.tmux"

  if [ "$VARIANT" = "light" ]; then
    THEME="tokyonight_day"
  else
    THEME="tokyonight_storm"
  fi

  tmux source $(printf $FORMAT $THEME)
}

update

With the script in $PATH all it took was adding the following two lines to tmux.conf:

run-shell "update-theme-tmux"
set-hook -g client-focus-in 'run-shell "update-theme-tmux"'

The first line is responsible for initialization on tmux server startup. The second line updates the theme on focus.

fish

Definitely, fish has the biggest room for improvements. It doesn't update automatically. Similarly to tmux, I created an update-theme-fish that is run on fish startup. Additionally, I have created a mapping to call this script and the colors.

#!/usr/bin/env fish --no-config
function update
  set -f VARIANT "$(cat /tmp/tymek-theme)"
  set -f FORMAT "$HOME/.local/share/nvim/lazy/tokyonight.nvim/extras/fish/%s.fish"

  if [ "$VARIANT" = "light" ]
    set -f THEME "tokyonight_day"
  else
    set -f THEME "tokyonight_storm"
  end

  source $(printf $FORMAT $THEME)
end

update

Notice the --no-config! Otherwise, including it in config.fish will result in an infinite loop.

I have explored my options a little bit. However, fish doesn't seem to have any hooks (which I find understandable). Technically tmux could send a command to run the fish script via a tmux hook. What if Neovim is running, though? I thought that this is too little gain to work on this at the moment.

Neovim

Once I figured out how to make tmux work, I knew what to look for in :h Events. Surely, Neovim has a FocusGained event!

Similarly to WezTerm, I created a separate Lua module for the theme-updating logic:

local M = {}

---@enum (key) Mode
local theme = {
	light = "tokyonight-day",
	dark = "tokyonight-storm",
}

---@param callback fun(mode: Mode)
local detect = function(callback)
	vim.system(
		{
			"cat",
			"/tmp/tymek-theme",
		},
		{ text = true },
		---@param obj vim.SystemCompleted
		vim.schedule_wrap(function(obj)
			callback(vim.trim(obj.stdout))
		end)
	)
end

M.update = function()
	detect(function(mode)
		vim.api.nvim_cmd({
			cmd = "colorscheme",
			args = { theme[mode] },
		}, {})

		require("nvim-highlight-colors").turnOn()
	end)
end

return M

With the module in place, I replaced :colorscheme calls with the update function and defined an autocmd:

vim.api.nvim_create_autocmd("FocusGained", {
	callback = require("tymek.theme").update,
})

The Result

A video showing a theme variant being forced. The theme change propagates throughout WezTerm, tmux, and Neovim.