barrettruth.com/src/content/git/auto-theme.nvim.mdx

173 lines
8.9 KiB
Text

---
title: "auto-theme.nvim"
slug: "auto-theme.nvim"
date: "28/11/2025"
---
# the problem
I toggle between light and dark mode at at around 17:00 every day. Resetting my environment and theme across all my applications was a pain. I built auto-theme.nvim to update my neovim theme automatically.
## the solution
I spawn a OS-specific watcher to listen, in a non-polling manner, for theme updates:
- linux: I integrate with D-Bus, parsing and filtering messages for settings changes to the system appearance.
- macOS: I integrate with the Apple toolkit AppKit to do the same thing--wait for a theme changed notification to be pushed out to the running app object and print accordingly.
Stdout is continually parsed from the job and the appropriate theme is set.
# the interesting problem
OK, cool. Now, how can I implement automatic theme-switching for _all_ programs I use?
> Note: I use my color scheme [midnight.nvim](/git/midnight.nvim.html)
I most commonly use the following applications:
1. [neovim](https://neovim.io/)
2. [ghostty](https://ghostty.org/)
3. [sioyek](https://sioyek.info/)
4. [ungoogled-chromium](https://github.com/ungoogled-software/ungoogled-chromium)
5. [swaywm](https://swaywm.org/)
6. [rofi](https://github.com/davatorium/rofi)
7. [tmux](https://github.com/tmux/tmux/wiki)
8. [fzf](https://github.com/junegunn/fzf)
9. [ripgrep](https://github.com/BurntSushi/ripgrep)
10. [zsh](https://www.zsh.org/)
All of the code can be found in my [dotfiles](https://github.com/barrettruth/dots). The implementations are scattered and I provide no guarantee that files will not be moved
## success criteria
I run or trigger _one command_--every application updates automatically.
The feasability of this depends on the underlying support software has for dynamic reloading of configuration. In many cases this is not possible.
## the solution
As of November 28, 2025, I've created [this script](https://github.com/barrettruth/dots/blob/main/scripts/theme) which is bound by a [karabiner](https://karabiner-elements.pqrs.org/) and [keyd](https://github.com/rvaiya/keyd) binding for macOS and linux, respectively, for quick access.
### successes
1. neovim: [auto-theme.nvim](https://github.com/barrettruth/auto-theme.nvim)
2. ghostty: Ghostty supports [light and dark themes based on the system appearance](https://github.com/tmux/tmux/wiki)--easy.
3. sioyek: Any changes to user configuration are automatically reloaded--my script updates the program's settings file `prefs_user.config` in-place
4. ungoogled-chromium: I folded and used the default system theme which automatically reads and updates according to the system environment
5. swaywm (linux): sway reads from a symlink'ed theme file updated by the scripts; `swaymsg reload` triggers an instant window-manager-wide reload
6. rofi: the config file, `config.rasi`, derives its theme from a symlink'ed file updated and reloaded by the script
7. tmux: similarly to rofi, the config `tmux.conf` reads from symlink'ed theme files that are automatically reloaded with `source-file`. I also refresh the UI with `refresh-client`:
```sh
ln -sf ~/.config/tmux/themes/$theme.tmux ~/.config/tmux/themes/theme.tmux
tmux source-file ~/.config/tmux/themes/$theme.tmux
tmux refresh-client -S
```
### failures
Unfortunately, the following programs I've found nearly impossible to dynamically reload:
8. fzf: Overwriting fzf's themes, from the interactive shell `fzf` binary to `fzf-{cd,file}-widget` to integration with [fzf-lua](https://github.com/ibhagwan/fzf-lua/), I found this potentially doable but just _way too complex_. Feel free to investigate yourself--I'm going with the default theme.
9. ripgrep: I use the default theme. The ripgrep global configuration file does not support environment variables, exterminating the option to provide a `${THEME}`-based path in the global configuration file.
10. zsh: it's impossible to update `$THEME` across all existing shells (simply a limit of posix). However, all affected _programs_ will read the proper `$THEME`--I'm fine compromising here.
### upd: fzf, ripgrep, fzf-lua, and shell improvements <span class="date">30/11/2025</span>
After some _extreme_ amounts of finagling I'm now able to automatically update fzf and ripgrep themes both in the shell (after re-rendering the prompt\*) and in new fzf-lua instances. I consider this a 99% win.
I do it with the following strategy for:
a) cli programs
Since, according to \#10 above, it is impossible to update all running shells, one way I can come to auto-updating program themes is in between terminal prompts. To get around this, I added a zsh precmd hook to my `zshrc` for both programs which checks if a symlink has been updated:
- fzf: If `~/.config/fzf/themes/theme` is updated, re-export `$FZF_DEFAULT_OPTS` to include the new colors:
```zsh
_fzf_theme_precmd() {
local theme_file=~/.config/fzf/themes/theme
local theme_target=$(readlink "$theme_file" 2>/dev/null) || return
typeset -g _FZF_THEME_TARGET
test "$theme_target" = "$_FZF_THEME_TARGET" && return
_FZF_THEME_TARGET="$theme_target"
test -r "$theme_file" && export FZF_DEFAULT_OPTS="$(<"$theme_file") $FZF_OPTS"
}
add-zsh-hook precmd _fzf_theme_precmd
```
- ripgrep: If `~/.config/rg/themes/theme` is updated, re-build the `$RIPGREP_CONFIG_PATH` file as a concatentation of the _new_ theme and a separete _base_ configuration file and re-export the environment variable.
Any and all shells, after re-rendering their terminal prompts, will see proper colors for all fzf and ripgrep commands.
b) neovim
This is a bit trickier. How can a running neovim process automatically update its internal colors used by fzf-lua? There are two aspects of this problem:
1. Finding and interacting with all existing Neovim instances: RPCs with remote neovim features provide the ability to remotely probe neovim instances.
I use an RPC to trigger the update (see \#2 below). Of course, this requires automatically configuring a socket for each neovim instance to listen on. I use the process id, unique to the neovim instance--any 1:1 mapping from neovim instance to socket identifier will do:
```lua
local socket_path = ('/tmp/nvim-%d.sock'):format(vim.fn.getpid())
vim.fn.serverstart(socket_path)
```
Then send a a command like so (see `:h remote`):
```sh
nvim --server "$socket" --remote-send "<c-o><cmd>lua require('fzf_reload').reload()<cr>" 2>/dev/null || true
```
Neovim instances can be found by just listing `/tmp/nvim-*.sock`.
2. Re-configuring fzf-lua: fzf-lua does not support "dynamic" reconfiguration but you can re-initialize the plugin with `require('fzf-lua').setup(opts)`.
- fzf: I expose a function for RPC calls `fzf_theme.reload_colors()` which re-initializes the fzf environment. Special care must be taken to store and pass down the _initial_ fzf-lua configuration and update it with the new environments colors.
- ripgrep: automatically re-reads from `$RIPGREP_CONFIG_PATH`, a symlink updated by my theme script
I confess that this solution is not perfect. For example, existing pickers cannot have their theme dynamically re-loaded ([I'm looking into this](https://github.com/ibhagwan/fzf-lua/discussions/2448)) as `FzFLua resume` cannot build theme context. I'm close enough (for now! >:)).
### upd: neovim, tmux improvements <span class="date">2/23/2025</span>
Apparently, neovim [already supports auto-switching the `vim.o.background`](https://github.com/neovim/neovim/commit/d460928263d0ff53283f301dfcb85f5b6e17d2ac) as of November, 26, 2024. So, technically, auto-theme.nvim is defunct. Welp, who cares... it was fun to make! I use the following config to auto-toggle the theme when the aforementioned option is set:
```lua
vim.api.nvim_create_autocmd({ 'OptionSet' }, {
pattern = 'background',
callback = function()
vim.cmd.colorscheme(
vim.o.background == 'dark' and 'midnight' or 'daylight'
)
end,
group = vim.api.nvim_create_augroup(
'Midnight',
{ clear = true }
),
})
```
tmux 3.6 also supports this too as of 3.6--see [here](https://github.com/tmux/tmux/issues/4699). I updated my tmux.conf, conveniently removing my `~/.config/tmux/themes/*` symlink hackiness:
```mux
if -F '#{==:#{client_theme},dark}' {
# set dark mode
}
if -F '#{==:#{client_theme},light}' {
# set light mode
}
```
## upd: improve tmux theme updating <span class="date">14/12/2025</span>
The above config does not always update the tmux theme in tandem with the system theme. For example, on system startup, the tmux theme is not set. I could only get around this by manually reloading the configuration _inside_ of tmux.
It is simpler (and more correct) to set the theme as a function of when tmux itself detects changes to the system theme. This is possible by leveraging the exposed hooks `client-{light,dark}-theme` as follows:
```tmux
set-hook -g client-light-theme 'source $XDG_CONFIG_HOME/tmux/themes/daylight.conf'
set-hook -g client-dark-theme 'source $XDG_CONFIG_HOME/tmux/themes/midnight.conf'
```
where the configuration files house the individual theme logic.