Making Neovim's color scheme follow system light/dark preference on GNOME
GNOME has a feature where there’s a system-level setting for whether user prefers light or dark theme. This got added relatively recently (in GNOME 42). Its intended use is so that apps can automatically adjust to match the user’s preferences without hacks. Hacks like “read the current GTK theme and try to figure out whether it’s light or dark based on some heuristic”.
Today I’ve managed to put together one annoying missing piece of personal infra.
I’ve had gnome-terminal
sorta-following the setting for a while, and tonight
I’ve made Neovim also do that. To celebrate, I wanted to share how
this works.
All scripts copied here are in my ducktape repo in
dotfiles/local/bin
, where you can copy fork and improve
to your heart’s content. The Neovim part was added in MR #81.
Those are the dependencies:
pip install pynvim absl-py dbus-python
Shared setup
Night Theme Switcher
Install the Night Theme Switcher GNOME extension. This extension lets you attach scripts to when the theme is changed.
Light/dark scripts
Create a pair of scripts, set_light_theme
and set_dark_theme
, put them
wherever. Mine are currently in ~/.local/bin
.
Point Night Theme Switcher to run those when the theme is changed.
Neovim
In my particular case, I like Solarized colors, which I have
everywhere I can (VSCode, Neovim, gnome-terminal
, even this site - as of now).
I use the vim-colors-solarized
plugin which adds
both light and dark variants, toggled by set background=light
or dark
.
init.vim
Open ~/.config/nvim/init.vim
and add this hunk somewhere near the top.
It’ll read the current setting from gsettings
and update Vim’s background to
match.
" Set color theme to light/dark based on current system preferences.
" Done early to prefer flashing of the wrong theme before this runs.
" Will later be picked up when setting up Solarized colors.
" Called on theme switches by set_light_theme, set_dark_theme scripts.
function! UpdateThemeFromGnome()
if !executable('gsettings')
return
endif
let color_scheme = system('gsettings get org.gnome.desktop.interface color-scheme')
" remove newline character from color_scheme
let color_scheme = substitute(color_scheme, "\n", "", "")
" Remove quote marks
let color_scheme = substitute(color_scheme, "'", "", "g")
if color_scheme == 'prefer-dark'
set background=dark
else
" With disabled night mode, value seems to be to 'default' on my system.
set background=light
endif
endfunction
call UpdateThemeFromGnome()
update_nvim_theme_from_gnome
Create this script somewhere and chmod +x
it.
I named it update_nvim_theme_from_gnome
.
It’ll use pynvim
to connect to running Neovim instances and run the function
we made above to update the background.
#!/usr/bin/python
# Updates the theme on all running Neovim instances.
import glob
import os
from pynvim import attach
# TODO: should probably only try to do this to *my* neovim instances
for dir in glob.glob('/tmp/nvim*'):
= os.path.join(dir, '0')
socket = attach("socket", path=socket)
nvim "call UpdateThemeFromGnome()") nvim.command(
Update set_light_theme
and set_dark_theme
to call it. This will make it so
that when you switch theme, it’ll not just affect new Neovim instances, but also
all currently running ones.
There’s a TODO in there. Exercise for the reader I guess - I don’t particularly
care because I rarely run Neovim as root
, but I expect this would crash
and burn if there were Neovim running as any user other than you. Cause it would
probably not let you write into that socket.
GNOME Terminal
I have another script for GNOME Terminal doing something similar.
It assumes that you have a light and dark profile set up. Open GNOME Terminal preferences and note down the names of the profiles you wanna use in light/dark configurations
switch_gnome_terminal_profile
Let’s call our script switch_gnome_terminal_profile
:
#!/usr/bin/python
# Works on gnome-terminal 3.44.0 as of 2022-09-03.
# Requirements: absl-py, dbus-python
from typing import List
import json
import re
import subprocess
import ast
import dbus
from xml.etree import ElementTree
from absl import app, flags, logging
= flags.DEFINE_string('profile', None,
_PROFILE 'Name or UUID of profile to set everywhere')
def gsettings_get_profile_uuid_list() -> List[str]:
= subprocess.check_output(
out "gsettings", "get", "org.gnome.Terminal.ProfilesList",
["list"]).decode('utf-8')
return ast.literal_eval(out)
def gsettings_get_default_profile_uuid() -> str:
= subprocess.check_output(
out "gsettings", "get", "org.gnome.Terminal.ProfilesList",
["default"]).decode('utf-8')
return ast.literal_eval(out)
def gsettings_set_default_profile_uuid(uuid: str) -> None:
= subprocess.check_output([
out "gsettings", "set", "org.gnome.Terminal.ProfilesList", "default",
f"'{uuid}'"
'utf-8')
]).decode(assert out == ''
def dconf_get_profile_visible_name(uuid: str) -> str:
# As of 2022-09-03 (gnome-terminal 3.44.0), somehow the visible-name only
# seems to propagate correctly into dconf, not into gsettings...
# but the list of profiles (ProfileList) is up in gsettings.
# dconf list /org/gnome/terminal/legacy/profiles:/ returns a lot of profiles
# which I've deleted a long time back.
= subprocess.check_output([
name "dconf", "read",
f"/org/gnome/terminal/legacy/profiles:/:{uuid}/visible-name"
'utf-8').strip()
]).decode(return ast.literal_eval(name)
def dbus_update_profile_on_all_windows(uuid: str) -> None:
= dbus.SessionBus()
bus
= bus.get_object('org.gnome.Terminal', '/org/gnome/Terminal/window')
obj = dbus.Interface(obj, 'org.freedesktop.DBus.Introspectable')
iface
= ElementTree.fromstring(iface.Introspect())
tree = [child.attrib['name'] for child in tree if child.tag == 'node']
windows "requesting new uuid: %s", uuid)
logging.info(
def _get_window_profile_uuid(window_actions_iface):
# gnome-terminal source code pointer:
# https://gitlab.gnome.org/GNOME/gnome-terminal/-/blob/f85f2a381e5ba9904d00236e46fc72ae31253ff0/src/terminal-window.cc#L402
# D-Feet (https://wiki.gnome.org/action/show/Apps/DFeet) is useful for
# manual poking.
= window_actions_iface.Describe('profile')
description = description[2][0]
profile_uuid return profile_uuid
for window in windows:
= f'/org/gnome/Terminal/window/{window}'
window_path # TODO: if there's other windows open - like Gnome Terminal preferences,
# About dialog etc. - this will also catch those windows and fail
# because they do not have the 'profile' action.
= bus.get_object('org.gnome.Terminal', window_path)
obj "talking to: %s", obj)
logging.info(= dbus.Interface(obj, 'org.gtk.Actions')
window_actions_iface "current uuid: %s",
logging.info(
_get_window_profile_uuid(window_actions_iface))#res = window_actions_iface.Activate('about', [], [])
= window_actions_iface.SetState(
res # https://wiki.gnome.org/Projects/GLib/GApplication/DBusAPI#Overview-2
# https://gitlab.gnome.org/GNOME/gnome-terminal/-/blob/f85f2a381e5ba9904d00236e46fc72ae31253ff0/src/terminal-window.cc#L2132
'profile',
# Requested new state
# https://gitlab.gnome.org/GNOME/gnome-terminal/-/blob/f85f2a381e5ba9904d00236e46fc72ae31253ff0/src/terminal-window.cc#L1319
uuid,# "Platform data" - `a{sv}`
[])"window_actions_iface.SetState result: %s", res)
logging.info(= _get_window_profile_uuid(window_actions_iface)
uuid_after "new uuid: %s", uuid_after)
logging.info(assert new_uuid == uuid
# TODO: this only includes currently active tabs, not background tabs :/
def main(_):
= set(gsettings_get_profile_uuid_list())
profile_uuids = {}
uuid_by_name for uuid in profile_uuids:
= dconf_get_profile_visible_name(uuid)
name = uuid
uuid_by_name[name]
if _PROFILE.value in profile_uuids:
= _PROFILE.value
uuid elif _PROFILE.value in uuid_by_name:
= uuid_by_name[_PROFILE.value]
uuid else:
raise Exception("No such profile (by UUID or name)")
gsettings_set_default_profile_uuid(uuid)
dbus_update_profile_on_all_windows(uuid)
if __name__ == '__main__':
flags.mark_flag_as_required(_PROFILE.name) app.run(main)
This script expects a profile name or UUID in --profile
, and when called,
it’ll update GNOME Terminal’s settings to have that profile be the default.
That will make any new terminal windows/tabs use that profile.
Then it’ll talk to GNOME Terminal over dbus and update the profile
of each window. Unfortunately, this only updates the theme on windows that
are currently active - i.e., not on background tabs. I’ve not yet figured out
how to fix this - I’ve looked into gnome-terminal
’s source
code when I originally wrote the script, and I even
faintly remember reporting this as an issue. Basically that the dbus interface
should be a bit extended. If you know how to fix this, let me know.
Figuring this out took a while. D-Feet has been useful for it.
Generally, it’s very questionable and broke for me at least once (because of
something having to do with which particular knobs are in gconf
vs dconf
vs gsettings
). Works for me on gnome-terminal 3.44.0. Caveat emptor.
As with the other scripts in here, it’s currently in my ducktape repo and if I update it later, it’ll be reflected there.
Putting it together
Just make your set_light_theme
and set_dark_theme
scripts call the
appropriate scripts for gnome-terminal
and Neovim. Here’s how they look for
me:
set_dark_theme
#!/bin/bash
switch_gnome_terminal_profile --profile='Solarized Dark'
update_nvim_theme_from_gnome
set_light_theme
#!/bin/bash
switch_gnome_terminal_profile --profile='Solarized Light'
update_nvim_theme_from_gnome
Why is one on PATH
and not the other? Tech debt in my personal infra.
Deployment step of built artifacts isn’t separated and my old scripts repo isn’t
yet merged into my maximally glorious Ducktape monorepo. Sue me :P
Still, over time, I’ve made it a project to make the duct tape holding together my computer have a better CI setup than many commercial software projects :P
Short update
Oh also I’m now in San Francisco and at OpenAI, working on reinforcement learning. Long time, much news. Also Copilot is a thing and has surprised me very strongly by how good and useful it is. Sometime I’ll be writing some sorta summary of last year or two, but today is not the day and this is not that blogpost.
EDIT (2023-01-16): How I got to OpenAI is that blogpost.
Cheers, have a nice long weekend if you’re in the US.