Skip to main content
  1. Posts/

Making Neovim and Emacs Adapt to the System's Dark Mode

·1081 words·6 mins

Neovim's colorscheme kept in sync with macOS' system theme
Neovim’s colorscheme kept in sync with macOS’ system theme

In this post we'll see how to make Neovim and Emacs automatically react when the system's theme changes in Linux and macOS. The process is roughly the same, but specific instructions for each operating system will be provided when needed.

Neovim

Unfortunately for Vim users, this method only works with Neovim, as we'll be using its API to create socket files and run Lua code from an external script. Additionally, for Neovim users with a config written in Vimscript, note that you can embed Lua code in you config as shown below. Otherwise, the code should go in the init.lua file.

" vimscript
lua << EOF
  print("This is some Lua code")
EOF
" vimscript

Creating Socket Files

The following code creates a unique socket file for each running nvim session under /tmp/nvim, named using the instace's PID: /tmp/nvim/nvim[PID].sock.

function createSocket()
  pid = vim.fn.getpid()
  socket_name = '/tmp/nvim/nvim' .. pid .. '.sock'
  vim.fn.mkdir('/tmp/nvim', 'p')
  vim.fn.serverstart(socket_name)
end

Creating a Function that Updates the Theme

We'll write a Lua function that sets the appropriate colorscheme by querying the current system theme. This function will run once every time a new nvim session starts, and we'll then call it again from a Python script whenever the system theme changes, through the sockets we created.1

Linux

This code works in environments using the Freedesktop colorscheme preference implementation, such as GNOME or KDE Plasma. If you're using a standalone window manager and/or managing themes yourself, you already know what to do instead.

function updateColorscheme()
  command = "dbus-send --session --dest=org.freedesktop.portal.Desktop --print-reply /org/freedesktop/portal/desktop org.freedesktop.portal.Settings.Read string:'org.freedesktop.appearance' string:'color-scheme' | grep -o 'uint32 .' | cut -d' ' -f2"

  handle = io.popen(command)
  output = handle:read("*a")
  handle:close()
  output = string.gsub(output, "\n", "")

  if output == "1" then
    -- Set dark colorscheme
  else
    -- Set light colorscheme
  end
end

macOS

function updateColorscheme()
  exit_code = os.execute("defaults read -g AppleInterfaceStyle")
  if exit_code == 0 then
    -- Set dark colorscheme
  else
    -- Set light colorscheme
  end
end

Running the Functions on Launch

Place this Lua code after the previous functions' definitions.

vim.api.nvim_create_augroup('custom_startup', {})

vim.api.nvim_create_autocmd('VimEnter', {
  desc = 'Create a socket for every nvim process',
  group = 'custom_startup',
  once = true,
  callback = createSocket
})

vim.api.nvim_create_autocmd('UIEnter', {
  desc = 'Set the appropriate theme on startup',
  group = 'custom_startup',
  once = true,
  callback = updateColorscheme
})

Calling a Function from a Python Script

Now we can call the updateColorscheme function through the socket using a Python script with the pynvim library, which is available in most distribution's repositories. Otherwise, it can be installed directly with Python's pip.

pip install pynvim

Here's the script for changing the colorscheme.

#!/usr/bin/env python3

import glob
from pynvim import attach

nvim_sockets = (attach('socket', path=p) for p in glob.glob('/tmp/nvim/nvim*.sock'))

for nvim in nvim_sockets:
    nvim.exec_lua('updateColorscheme()')

All that remains is to automate running this script whenever the system theme changes, which we'll discuss at the end of this post.

Emacs

Making Emacs react to theme changes is very similar to the Neovim process described in the previous section: we'll create a function that queries the system theme, and we'll run it both on startup and every time it changes. The difference here is that we don't need to create sockets nor a Python script, as Emacs already gives us a way to run Elisp code with a shell command.

Creating a Function that Updates the Theme

Here's the function that gets the correct theme and applies it.2

Linux

Note: As mentioned in the Neovim section, this will work in desktop environments like GNOME and KDE Plasma. Other users will know what to do instead.

;; Set the theme variables
(setq dark-theme 'doom-tomorrow-night)
(setq light-theme 'doom-tomorrow-day)

(defun get-correct-theme ()
  (if (= (call-process-shell-command "dbus-send --session --dest=org.freedesktop.portal.Desktop --print-reply /org/freedesktop/portal/desktop org.freedesktop.portal.Settings.Read string:'org.freedesktop.appearance' string:'color-scheme' | grep -q 'uint32 1'") 0)
      dark-theme
    light-theme))

;; Apply the correct theme on startup
(setq doom-theme (get-correct-theme))

;; Function that updates the theme
(defun update-theme ()
  (load-theme (get-correct-theme)))

macOS

;; Set the theme variables
(setq dark-theme 'doom-tomorrow-night)
(setq light-theme 'doom-tomorrow-day)

(defun get-correct-theme ()
  (if (= (call-process-shell-command "defaults read -g AppleInterfaceStyle") 0)
      dark-theme
    light-theme))

;; Apply the correct theme on startup
(setq doom-theme (get-correct-theme))

;; Function that updates the theme
(defun update-theme ()
  (load-theme (get-correct-theme)))

Calling the Function from the Shell

The function can be run from outside Emacs via the following command.

emacsclient --no-wait --eval "(update-theme)"

The next section will discuss how to run this command automatically when the system theme changes.

How to Automatically Follow the System Theme

Creating a Script

The first step is to create a shell script to update both Neovim and Emacs as discussed in the previous sections. We'll put this script together with the Python script we wrote in the Neovim section inside ~/.scripts.

~/.scripts
├── neovim_emacs_theme.sh
└── update_neovim_theme.py

Here are the contents of neovim_emacs_theme.sh.

#!/bin/sh

update_theme() {
    emacsclient --no-wait --eval "(update-theme)" &
    ~/.scripts/update_neovim_theme.py &
} > /dev/null 2>&1

update_theme()

Finally, we need to make both scripts executable.

chmod +x neovim_emacs_theme.sh
chmod +x update_neovim_theme.py

Running the Script Automatically

How to run the script when the system theme changes will be different in every desktop and operating system. I will explain how to do it both in GNOME and in macOS, as that covers the two most common use cases.

On Linux (GNOME)

The Night Theme Switcher extension lets us run a script when GNOME's theme changes. Just install the extension, open its settings window and navigate to the "Commands" tab, enable "Run commands" and write our script's path in both the "Sunrise" and "Sunset" text fields (i.e. ~/.scripts/neovim_emacs_theme.sh).

Night Theme Switcher's "Commands" settings page
Night Theme Switcher’s “Commands” settings page

On macOS

macOS doesn't let us hook a script to run when the theme changes, but we can achieve the same result by using a lightweight daemon to observe these theme changes and react to them by running our script. AbyssWatcher is one such daemon I wrote in Swift for this purpose, as there were no usable existing projects with this functionality at the time of writing.

Unfortunately, the setup process is a bit longer than just installing a GNOME extension, but the same final result can be achieved. In fact, the GIF at the start of this post was recorded on macOS using AbyssWatcher! Read the project's README.org file for further instructions on setting it up.


1

For an example on how exactly to set the colorscheme, you can have a look at my dotfiles.

2

The code comes from my Doom Emacs config, but it is easy to change it for vanilla Emacs or other Emacs distributions.