Automatic dark mode for terminal applications
I love dark mode. It makes reading text comfortable for me. Because I'm working remotely for a company with a large timezone difference, most of the time, this also means I'm working during the evenings. Initially, I was manually changing my light and dark modes in macOS. Apple later released an "Auto" mode, which would switch to dark and light based on your location's time.
There is one caveat, though.
It only works for GUI applications.
If you're like me, using shell applications, such as Tmux, Vim, etc., it won't work for you. I've started using a terminal when I was 17 years old. Since then, I never asked myself, "why does the terminal have a dark background?". I just took it for granted.
Last week, when I had to increase my screen's brightness, I've figured out that I was using a pitch-black terminal screen and all my applications (Vim, Alacritty, etc.) had dark backgrounds. So, I asked myself, "what if I use a light theme during the day and switch back to a darker theme later in the evening?".
There are already light color schemes for Vim, Alacritty, and most of the popular applications.
- In Vim, if your color scheme supports both a light and dark mode, you switch between by using the command:
set background=dark
orset background=light
. - In Alacritty, you can define multiple color schemes and switch between them easily in the config file
alacritty.yaml
. Alacritty doesn't have an API, though, but there are ways to emit an event. I'll explain in a bit. - Tmux doesn't use many colors usually unless you add a status bar or change the pane borders. Just like Vim or Alacritty, you can define the status bar colors in the config file, which is
tmux.conf
I had a rough plan on how I wanted to tackle this issue:
- Pick up a popular color scheme with both dark and light modes and pleasant to the eyes.
- Implement the light and dark modes for each application separately.
- Find a way to change the modes programmatically. I.e., I should be able to change Vim's color mode from outside Vim.
- Create a script called
change_background
that would change all applications modes from light-to-dark or dark-to-light - Run a daemon process that would listen to macOS "Appearance changed" events and call the
change_background
script.
Let me go over this list one by one and explain how things have evolved. I will share code snippets throughout the blog post, but my setup is open source and can be found in my GitHub dotfiles repo.
TL;DR; here is a demo of the final work:
Color theme
I'm a huge fan of the Molokai color theme. I even forked it and modified it for my liking. Some issues with the Molokai theme are 1. It's not maintained anymore 2. It doesn't have a light theme.
I had to find a new color theme that is well maintained and has excellent light and dark colors.
I checked many themes over the weekend with multiple dark mode options (solarized, gruvbox, papercolor, ayu, etc.). Eventually, I decided on gruvbox (for now at least). This color theme is community maintainedand is decent-looking.
One main issue I had with gruvbox was the pastel colors, which decreases the contrast quite a bit. Luckily, it has a dark_contrast
and light_contrast
options to increase the contrast. I set both to hard
, which is more pleasant to read.
Vim
Now that we have a color scheme, we can easily change it inside our vimrc
. There are plenty of blog posts that explain how to switch between light and dark mode in Vim. But for me, I had several more criteria:
- It needs to be automatic.
- It needs to be fast.
All the solutions I've seen so far didn't meet these criteria.
- Automatic switch between the light and dark mode usually doesn't exist. The function that checks whether to enable or disable a particular mode is only sourced when starting a new Vim session. If you have multiple Vim sessions open in various windows, they won't change. You have two close and re-open all sessions.
- One way to solve this is to set a background job via
start_timer
. You can pass a callback, which will be called every nth second. That way, the function can check whether it's time to switch the mode. The issue with this is, it's affecting the performance in the long term, and you can feel it when you're using Vim.
So, how do we solve it? We could make sure to receive events from within Vim and then automatically make the changes. This way, the event will automatically update any open Vim session. It'll also be very fast because there will be no job running a background while using the editor (more on this later).
In Vim, we can use the autocmd
setting. It's a setting where you can listen to specific events and then trigger a function call. There are many events, such as BufEnter
(after entering a buffer) or FocusLost
(Vim lost input focus). One particular event that is useful for us is SigUSR1
:
SigUSR1 After the SIGUSR1 signal has been detected.
Could be used if other ways of notifying Vim
are not feasible. E.g. to check for the
result of a build that takes a long time, or
when a motion sensor is triggered.
{only on Unix}
The SIGUSR1
signal can be sent to an application via the kill
command. Usually, people use it to kill processes, but as some of you already know, the kill
command is also used to signal a process. That means, if we find the PID
of a running Vim process and send a SIGUSR1
signal, Vim can capture and trigger function for us. This is how we're going to detect the processes and send the signal (macOS):
for pid in (pgrep vim)
kill -SIGUSR1 $pid
end
pgrep
is a tool that finds the pid of a process by its name. Because we might have multiple Vim sessions running in our Terminal, we'll be iterating over it in a for loop. Finally, we send the SIGUSR1
signal to each process.
How does Vim catch this signal?
First, we create the command that changes the Vim theme. And then we also add an autocmd
for the SigUSR1
event (somehow Vim decided to call the event SigUSR1
instead of SIGUSR1
) :
" ChangeBackground changes the background mode based on macOS's `Appearance`
" setting. We also refresh the statusline colors to reflect the new mode.
function! ChangeBackground()
if system("defaults read -g AppleInterfaceStyle") =~ '^Dark'
set background=dark " for the dark version of the theme
else
set background=light " for the light version of the theme
endif
colorscheme gruvbox
try
execute "AirlineRefresh"
catch
endtry
endfunction
" initialize the colorscheme for the first run
call ChangeBackground()
" change the color scheme if we receive a SigUSR1
autocmd SigUSR1 * call ChangeBackground()
This script was my first solution. However, I later discovered, until you focus on each Vim buffer, the callback is never called. It only changes the background if I'm using Vim or switch to any of my Vim instances.
This solution won't work for us. We need to send a command to Vim, but Vim doesn't have a proper API (or an RPC interface) we could use. It comes with a feature called clientserver
, but it's not a widely used feature and only works with specific OS's. (nit: looks like NeoVim solved this already).
Luckily, I'm using tmux
already and had an idea on how to fix it. Tmux has a command called send-keys
where you can send keystrokes to a particular pane. What if we could find all Vim instances and safely call the ChangeBackground
function?
That's what we did finally. Here is the tmux
script (written in Fish) that finds all the Vim instances and then calls the ChangeBackground()
function:
set -l tmux_wins (/usr/local/bin/tmux list-windows -t main)
for wix in (/usr/local/bin/tmux list-windows -t main -F 'main:#{window_index}')
for pix in (/usr/local/bin/tmux list-panes -F 'main:#{window_index}.#{pane_index}' -t $wix)
set -l is_vim "ps -o state= -o comm= -t '#{pane_tty}' | grep -iqE '^[^TXZ ]+ +(\\S+\\/)?g?(view|n?vim?x?)(diff)?\$'"
/usr/local/bin/tmux if-shell -t "$pix" "$is_vim" "send-keys -t $pix escape ENTER"
/usr/local/bin/tmux if-shell -t "$pix" "$is_vim" "send-keys -t $pix ':call ChangeBackground()' ENTER"
end
end
Tmux
For tmux, I used the https://github.com/edkolev/tmuxline.vim plugin. Once sourced, this plugin automatically changes your Tmux status bar based on your Vim color scheme. It has support for pretty much all color schemes. Once you configure it in your vimrc, it automatically changes the tmux
status bar colors when you open Vim. It also supports dark mode.
There is one problem, if you don't have any open vim
instance and change the background to dark, it means that tmux
's background will never change. To overcome this problem, we're going to use the command :TmuxlineSnapshot [file]
. The command saves the file into a set of tmux directives, which you can put into tmux.conf
or source separately. I called this twice, for both the light and dark mode of my Vim theme:
:set background=dark
:TmuxlineSnapshot ~/tmux-dark.conf
:set background=light
:TmuxlineSnapshost ~/tmux-light.conf
After that, I can easily enable the tmux configuration by calling the tmux source-file
command:
tmux source-file ~/.tmux/tmux-light.conf
Alacritty
Alacritty has support for defining multiple themes. So, you can define two themes, one being dark and another one to be light. However, the problem with Alacritty is, it doesn't have an option or a command to change the theme on-the-fly. But as we did so far, there is also a hacky solution for this.
First, we can emulate "listen to events" for Alacritty by enabling the option to reload the config if it detects a change automatically. The option is:
live_config_reload: true
Once it's set, we can now make changes to our configuration (which also contains the color schemes). The next step is to let alacritty know that we want to source a second configuration file (which will only have our color schemes):
import:
- "/Users/fatih/.config/alacritty/color.yml"
Finally, here is the color scheme I'm using (it's long, you can see the full file here, so I'll show an excerpt):
schemes:
gruvbox_light: &gruvbox_light
primary:
background: '0xf9f5d7' # hard contrast
gruvbox_dark: &gruvbox_dark
primary:
background: '0x1d2021' # hard contrast
colors: *gruvbox_dark
The colors
field works like a switch. It's a YAML reference, and the name should match one of the words inside the schemes.
Here is the trick, we're going to change the colors
field with a script. And because live reload is enabled, it'll automatically detect the change. Here is the script I wrote (using sed
) to achieve this:
function alacritty-theme --argument theme
if ! test -f ~/.config/alacritty/color.yml
echo "file ~/.config/alacritty/color.yml doesn't exist"
return
end
# sed doesn't like symlinks, get the absolute path
set -l config_path (realpath ~/.config/alacritty/color.yml)
sed -i "" -e "s#^colors: \*.*#colors: *$theme#g" $config_path
echo "switched to $theme."
end
With this script, I can now easily switch my theme with the following command:
alacritty-theme gruvbox_dark
Putting everything together
Now that we know how to change the mode of all the terminal applications (and the Terminal itself), we can easily put them into a single script. I'm calling this script change-background.fish, and here is how we put it all together:
function change_background --argument mode_setting
# change background to the given mode. If mode is missing,
# we try to deduct it from the system settings.
set -l mode "light" # default value
if test -z $mode_setting
set -l val (defaults read -g AppleInterfaceStyle) >/dev/null
if test $status -eq 0
set mode "dark"
end
else
switch $mode_setting
case light
osascript -l JavaScript -e "Application('System Events').appearancePreferences.darkMode = false" >/dev/null
set mode "light"
case dark
osascript -l JavaScript -e "Application('System Events').appearancePreferences.darkMode = true" >/dev/null
set mode "dark"
end
end
# change vim
set -l tmux_wins (/usr/local/bin/tmux list-windows -t main)
for wix in (/usr/local/bin/tmux list-windows -t main -F 'main:#{window_index}')
for pix in (/usr/local/bin/tmux list-panes -F 'main:#{window_index}.#{pane_index}' -t $wix)
set -l is_vim "ps -o state= -o comm= -t '#{pane_tty}' | grep -iqE '^[^TXZ ]+ +(\\S+\\/)?g?(view|n?vim?x?)(diff)?\$'"
/usr/local/bin/tmux if-shell -t "$pix" "$is_vim" "send-keys -t $pix escape ENTER"
/usr/local/bin/tmux if-shell -t "$pix" "$is_vim" "send-keys -t $pix ':call ChangeBackground()' ENTER"
end
end
# change tmux
switch $mode
case dark
tmux source-file ~/.tmux/tmux-dark.conf
case light
tmux source-file ~/.tmux/tmux-light.conf
end
# change alacritty
switch $mode
case dark
alacritty-theme gruvbox_dark
case light
alacritty-theme gruvbox_light
end
end
I can use this script in two modes. If I don't pass any arguments, it'll read the value from the macOS preferences and change all the themes accordingly. Assuming it's during noon if I run the following command in my Terminal:
change-background
It will change all my terminal themes to light
mode (because we assumed it's noon). Another way of using this script is to pass an argument explicitly:
change-background dark
This command will change the background mode and the themes of my Terminal to dark mode. Because it also changes the global OS setting, all the GUI applications also will switch to dark mode.
Auto dark mode for your Terminal
Now that we have a single switch to change the Terminal from dark to light (or vice versa), how can we automatically switch it? One idea I had is to write a script that would poll the global OS setting (via: defaults read -g AppleInterfaceStyle
). If it were dark, we would call change_background dark
otherwise change_background light
.
The problem with that is, it wouldn't be instantaneously, and it would periodically have to run the command. I wanted it to be automatic and listen to an event that macOS would fire.
After searching for it a bit, I talked to my friend Bouke due to his low-level system experience. He immediately showed me a Swift script that would check for events. But then he decided to make it even simpler and released a Swift script that one can easily compile and run in the background via launchctl
. The repo is called dark-mode-notify.
All you have to do is to compile the app that listens to dark-mode events:
swiftc dark-mode-notify.swift -o /usr/local/bin/dark-mode-notify
And then use this new binary in a launchctl
script with our change-background
function we created:
?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>io.arslan.dark-mode-notify</string>
<key>KeepAlive</key>
<true/>
<key>StandardErrorPath</key>
<string>/Users/fatih/.dark-mode-notify-stderr.log</string>
<key>StandardOutPath</key>
<string>/Users/fatih/.dark-mode-notify-stdout.log</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/dark-mode-notify</string>
<string>/usr/local/bin/fish</string>
<string>-c</string>
<string>change_background</string>
</array>
</dict>
</plist>
As you see here, we're passing the arguments fish -c change_background
to the dark-mode-notify
app. Launchctl runs in the background, and then the rest is now handled via dark-mode-notify
. Whenever we change the background, dark-mode-notify
will receive an event and then call our fish -c change_background
arguments, which changes our Terminal themes.
And finally, here is again a demo of how it looks like when the appearance changes in macOS:
Verdict
As you see, I can't say it was an easy task. Most of the scripts we wrote are hacky and rely on certain things (tmux sending actual keystrokes to Vim, alacritty reading the configuration file and making changes, etc.). These could be improved if I would switch my editor (Vim → NeoVim) or Terminal (Alacritty → Kitty) because these applications have a proper API, and we could programmatically change the backgrounds. But for now, I'm happy with what we have.
Also, I think macOS terminal applications could listen to the dark-mode notify events and automatically switch the theme, just like how GUI applications change currently. But I don't have hope this will happen soon, so this is the best I can achieve for now.
No spam, no sharing to third party. Only you and me.