Automatic dark mode for terminal applications

Last week, when I had to increase my screen's brightness, I figured I was using a pitch-black terminal screen. 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?'

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 or set 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.

gruvbox theme in light and dark mode

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.

  1. 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.
  2. 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
gruvbox theme in light mode

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.

gruvbox theme in 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.