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:
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.
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.
-
It stores the current theme variant—either "light" or
"dark"—to
/tmp/tymek-theme
- It has a variant-to-theme mapping
-
It has a
force
variable both for testing and manually overriding the variant if needed -
I made it stand-alone (saved to
~/.config/wezterm/theme.lua
) to not litter WezTerm's main config
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,
})