Home-Manager Dark Mode Toggle

Posted by Ryan Himmelwright on Mon, Jan 30, 2023
Tags linux, nix, dotfiles, customization
Bodie Lighthouse, Outer Banks NC

A few months ago, I took the plunge and migrated my dotfiles system into my home-manager (nix) setup. Around the same time, I also switched to a desk in the office that is next to a window. I love all the natural light, but need to use light colored themes during different times of the day due to the brightness.

Manually switching my terminal and neovim themes every time I wanted to toggle between light and dark themes was tedious. So, I turned to my new dotfile system to see if I could figure out a more automated approach.

Implementation

Home-manager allows you to declaratively define user specific packages and dotfiles. In other words, I define everything in code (nix), and home-manager generates all the dotfiles, based on the functional nix expressions. Many applications allow you to easily set themes by declaring a variable in the configuration. For example, the following to defines settings for the terminal app, kitty:

programs.kitty = {
  enable = true;
  extraConfig = builtins.readFile ./configs/kitty.conf;
  theme = "Gruvbox Material Dark Hard";
};

The first line ’enables’ kitty, the second one pulls in my old dotfile to be used for the majority of the configuration, and the last line sets which theme to use.

With this in mind, I wondered if I could alter my home-manager configuration to conditionally toggle between ’light’ and ‘dark’ modes for my terminal apps.

Variables file

The first step took me a bit to figure out, but I eventually learned how to create a variables file that can be read from other config files:

{ config, pkgs, lib, ... }:

{
  options = with lib; with types; {
    darkmode = mkOption { type = bool; };
  };
  config = {
    darkmode = true;
  };
}

I used lib to add some typed options, that can be set and later accessed elsewhere in the nix/home-manager configuration. I added a boolean darkmode option, which can be used to define if ‘darkmode’ is enabled or not.

kitty theme set

The first application I wanted to toggle was the terminal. When I switched to home-manager for dotfiles, I started using kitty again, because of it’s configurability in home-manager.

For the darkmode toggle, I wanted to not only switch between a light and dark theme, but also to define which theme was used for each case (ex: maybe Gruvbox for dark, but Ayu for light). This turned out to be quite easy to do in the kitty home-manager config file:

{ config, ...}:

let
  kitty-theme = if config.darkmode then
    ## Dark Themes
     # "Ayu"
     # "Dracula"
     # "Gruvbox Material Dark Medium"
     "Gruvbox Material Dark Hard"
     # "Flatland"
     # "Kaolin Aurora"
     # "Kaolin Ocean"
     # "Monokai"
     # "Nord"
     # "Nova"
     # "Obsidian"
     # "PaperColor Dark"
     # "Obsidian"
     # "Solarized Dark"
     # "Spacemacs"
     else
    ## Light Themes
    # "Ayu Light"
    # "GitHub Light"
    "Gruvbox Material Light Hard"
    # "Material"
    # "Piatto Light"
    # "Solarized Light"
    # "Tomorrow"
    # "Spring"
; in

{
  programs.kitty = {
    enable = true;
    extraConfig = builtins.readFile ./configs/kitty.conf;
    theme = kitty-theme;
  };

}

First, I make sure to import the config module, which contains our config.darkmode variable. I then defined a local variable, kitty-theme using a conditional expression. Basically, if the config.darkmode is true, then kitty-theme is set to the name of my dark theme, otherwise it is set to the light theme name. To make it easy to change my preferred themes, I list some of my favorites, and un-comment my selection for each category (light/dark).

With the local variable defined, it is used when setting the programs.kitty configuration: theme = kitty-theme;. With this, home-manager builds the kitty configuration, and selects the theme based on what the darkmode variable set to in the variables.nix file!

neovim theme loading

Setting up the dark/light themes for my terminal isn’t very useful if neovim doesn’t also follow the dress code. To make this work for neovim, I had to get a little creative:

{ config, ...}:

let 
  colors-file = if config.darkmode then ./configs/nvim/dark-theme.lua else ./configs/nvim/light-theme.lua;
in
{
  imports = [
    ../variables.nix
  ];
  # Just use my neovim config
  home.file.".config/nvim/".source = ./configs/nvim/nvim-dotfiles;
  home.file.".config/nvim/".recursive = true;
  # Set color theme file to load
  home.file.".config/nvim/lua/user/colortheme.lua".source = colors-file;
}

Like the kitty setup, I import config and define a local variable (colors-file), based on the condition of config.darkmode. However, for the neovim toggle, I need to change multiple variables (ex: set vim.opt.background and some theme-specific ones too). So unlike kitty, the local variable defines a file path to a neovim configuration file, rather than just a theme name. Whichever file path is selected, home-manager copies it as a colortheme.lua file when building the neovim configuration.

Both colortheme.lua options set required variables, load the colorscheme, and throw an error if the theme is not found. Again, I list several themes that I have installed and un-comment the one I want:

Dark mode config (dark-theme.lua):

vim.opt.background = "dark"

-- Theme
-- local colorscheme = "ayu"
-- local colorscheme = "everforest"
local colorscheme = "gruvbox"
-- local colorscheme = "happy_hacking"
-- local colorscheme = "nord"
-- local colorscheme = "sonokai"
-- local colorscheme = "sobrio"
-- local colorscheme = "sobrio_ghost"
-- local colorscheme = "sobrio_verde"


-- Attempt to Load theme
local status_ok, _ = pcall(vim.cmd, "colorscheme " .. colorscheme)
if not status_ok then
  vim.notify("colorscheme " .. colorscheme .. " not found!")
  return
end

Light Mode config (light-theme.lua):

vim.opt.background = "light"
vim.g.ayucolor = "light"

-- Theme
-- local colorscheme = "ayu"
local colorscheme = "everforest"
-- local colorscheme = "gruvbox"
-- local colorscheme = "nord"
-- local colorscheme = "sobrio"
-- local colorscheme = "sobrio_light"
-- local colorscheme = "sobrio_verde_light"


-- Attempt to Load theme
local status_ok, _ = pcall(vim.cmd, "colorscheme " .. colorscheme)
if not status_ok then
  vim.notify("colorscheme " .. colorscheme .. " not found!")
  return
end

After setting up the separate colorscheme files, I updated my previous colorscheme.lua neovim config file:

-- Everforest specific options
vim.g.everforest_background = "hard"

-- Sonokai specific options
-- vim.g.sonokai_style = "atlantis"
-- vim.g.sonokai_style = "espresso"
vim.g.sonokai_style = "maia"

-- Try to load theme from dark/light colortheme fiUUE
local status_ok, amineeded = pcall(require, 'user.colortheme')
if not status_ok then
    local colorscheme = "gruvbox"
  vim.notify("colortheme file found. Using default (" .. colorscheme .. ") instead!")
    -- Load & Check
    local status_ok, _ = pcall(vim.cmd, "colorscheme " .. colorscheme)
    if not status_ok then
    vim.notify("colorscheme " .. colorscheme .. " not found!")
    return
    end
end

This file sets some general (non dark/light specific) variables, and then tries to load the colortheme.lua file that home-manager placed. If it cannot load it for some reason, it defaults to using gruvbox (my favorite) and notifies the user.

In summary, home-manager uses the darkmode variable to decide which colorscheme.lua file to include (dark or light) when it builds and deploys the neovim dotfiles. Then, neovim attempts to load that file for it’s colorscheme settings, falling back to a default if it encounters any issues (for example, if I’m not using home-manager on a system).

How it all works together

All of the changes made so far use dotfiles to define if an application uses a dark or light theme. Now that I use home-manager to generate my dotfiles, this means all I have to do is run home-manager, and it will apply whatever I have the darkmode variable set to:

home-manager switch

If I want to switch modes, I just change the darkmode boolean in the variables.nix file, re-run home-manager switch, and the theme changes are applied.

Script

While this system is already much simpler than manually changing all the themes, I still didn’t want to have to locate the variables.nix, edit the value, and then run home-manager every time I wanted to toggle between dark and light mode. So, I scripted it! (darkmode-toggle.sh):

VARIABLES_FILE=/home/ryan/Documents/DevOps/home-manager/variables.nix

if grep -q 'darkmode = true;' $VARIABLES_FILE; then
    echo "Currently dark-mode. Switching to light-mode."
    sed -i.bak 's/darkmode = true/darkmode = false/g' $VARIABLES_FILE
else
    echo "Currently light-mode. Switching to dark-mode."
    sed -i.bak 's/darkmode = false/darkmode = true/g' $VARIABLES_FILE
fi
echo "Running home-manager switch to apply change..."
home-manager switch
echo "Done!"

The script checks what darkmode is set to in the variables file, and switches it. It then runs home-manager switch to apply the change. There are also a few print messages to communicate what is happening.

Lastly, I added a symlink of this file to /usr/bin/darkmode-toggle, so now I just have to type darkmode-toggle (actually, dar TAB ENTER) in my terminal, and everything changes. Much better!

Wrap up/conclusion

While I have used nix/nixOS off and on for years, and switched my dotfiles over to home-manager months ago, this was really my first time playing with nix expressions. While this might not be the best approach to accomplish this, I had a ton of fun figuring it out. On top of that, this method has honestly been solid for months now.

Next Post:
Prev Post:

Converting my 34 Key layout from QMK to ZMK Using gTile