Creating a Unix Theme Engine

In this article, I will walk through creating a unified UNIX theming engine that can take predefined themes and apply them to applications system-wide.

Other Theme Engines

I am not the first person to write some sort of automatic theme software. For example, /u/Dylan112 has created a very popular application called pywal It’s a beautiful piece of software that will create themes based on wallpapers and apply them to system applications. As much as I want to love the software I think it suffers from a lack of specificity.

Most famous color schemes (i.e. gruvbox, solarized, dracula) are carefully engineered so that colors work together to create a cohesive yet functional look and feel. Something like this is simply not possible with colors generated from a wallpaper. For one, getting the right color palette from a wallpaper is nearly impossible. But even more importantly, what do we do with these colors. Maybe for one color scheme the color yellow is very subtle, and we want it to be the border color for our active window. OK, I will tell my window manager to source the color yellow from pywal. However, once I generate a new color scheme the color yellow might be very harsh. Instead I want to use blue for my border color. Now I have to go and change my window manager’s options…

You can see how this gets tedious very fast. Changing themes might require tweaking across dozens of applications and is totally unnecessary. To work around this problem I wrote a program called squash which allows users to define themes in bash scripts. These scripts can be sourced from any application and contain theme-specific information for how that application should appear.

In this article I want to explore writing this application and what I learned from it.

Creating a Theme File

Let’s start by identifying what we want our theme engine to do. We want to define a set of colors and variables that can be used by different applications. For no particular reason, I’m going to call this project squash.

A regular squash theme file will look something like this:

#!/usr/bin/env bash

# DEFINE COLORS
BG="#20292d"
FG="#c4c4b5"

BLK="#2c3539"
RED="#a66959"
GRN="#769070"
YLW="#ac8d6e"
BLU="#607a86"
MAG="#8a757e"
CYN="#60867f"
WHT="#c4c4b5"

BBLK="#343d41"
BRED="#a66959"
BGRN="#769070"
BYLW="#ac8d6e"
BBLU="#607a86"
BMAG="#8a757e"
BCYN="#60867f"
BWHT="#c4c4b5"

# ROFI OPTIONS
ACC="${YLW}"

# BAR OPTIONS
BAR_SCRIPT="tinybar"

# VIM COLORSCHEME
VIM_SCHEME='base16-default-dark'

# WALLPAPER OPTIONS
WALLPAPER_PATH="$HOME/Wallpapers/designr3.png"
WALLPAPER_STYLE="tile"

# 2BWM OPTIONS
TWOBWM_FOCUS="${CYN}"
TWOBWM_UNFOCUS="${BBLK}"
TWOBWM_FIXED="${RED}"
TWOBWM_UNKILL="${CYN}"
TWOBWM_FIXEDUNK="${MAG}"
TWOBWM_OUTR="${BG}"
TWOBWM_EMP="${BG}"

# BSPWM OPTIONS
BSPWM_NORMAL="${BBLK}"
BSPWM_FOCUSED="${CYN}"
BSPWM_URGENT="${RED}"
BSPWM_PRESEL="${GRN}"

OB_THEME="designr"

SQUASH_FONT="xft:Dina:pixelsize=12"

Wow, that seems like a lot of code! Maybe, but its just enough for us to define our basic colors for something like X11 and then define some application-specific colors. Let’s dive into each part of this time file.

First, we notice that this is actually just a bash script #!/usr/bin/env bash. This is one of squash’s defining features. Instead of some werid format like json or yaml, this is just a simple bash script that can be run by just about any shell program to get its variables. This is great since shell scripts are the “glue” that hold UNIX programs together. Anything from lemonbar to bspwm can read this format.

Next, we have our basic colors. Nothing too special here. Again this values can be used by any shell-scripting application.

Now we can start defining application-specific variables. This is what makes squash really special since we can really engineer applications to look great with any colorscheme. For example, if you are using the window manager bspwm, you can source this file and then define your active border color using BSPWM_FOCUSED. The actual value of BSPWM_FOCUSED will vary depending on what theme is currently used. But you only ever have to change themes, you never have to edit your bspwmrc when you switch themes. That is awesome.

Making the Themes System Wide

We have defined our first theme, designr, but now we need to get it out to applications. I’m taking some inspiration from pywal here, but we can create files of various formats in $HOME/.cache/squash so that applications have a constant location to source files from.

Not all applications are going to want shell scripts, so we should convert our squash file into as many formats as possible. For the applications I use most, I figured I needed to write a .css file, a file for .Xresources, and a plain text file for other users to interact with.

The code for this isn’t very interesting, so I won’t post it here. If you are interested in reading it, you can check out the write_XXXX functions in squash.

Applying the New Theme to Active Terminals

Probably 90% of my workflow is done within terminals. If I switch themes mid-session, the last thing I want to do is close and reopen all my terminals to get my new theme to take effect. Let’s write a pair of functions that will reload all active terminals with our new colors.

First, we can load our theme colors into an array so they are easier to iterate over:

COLOR_ARRAY=($BLK $RED $GRN $YLW $BLU $MAG $CYN $WHT \
             $BBLK $BRED $BGRN $BYLW $BBLU $BMAG $BCYN $BWHT)

Now, let’s generate a set of escape sequences that something like urxvt can use to update its internal state.

update_terminals() {
    local sequence=""

    for i in {0..15}; do 
        sequence+="\033]4;${i};${COLOR_ARRAY[${i}]}\007"
    done

    for i in 10 12 13; do 
        sequence+="\033]${i};${FG}\007"
    done

    for i in 11 14 708; do 
        sequence+="\033]${i};${BG}\007"
    done
    
    if [[ $SQUASH_FONT ]]; then
        sequence+="\033]50;${SQUASH_FONT}\007"
        sequence+="\033]711;${SQUASH_FONT}\007"
    fi


    reload_terminals "${sequence}"
}

All that’s left is to send this sequence to all active terminals.

reload_terminals() {
    local seq="${1}"

    for term in /dev/pts/[0-9]*; do
        printf "%b" "${seq}" > "${term}"
    done
}

Dynamically Reloading Neovim

For just about all software development I use neovim. Just like my terminals, I don’t want to close and reopen neovim whenever I change themes. Sadly this is not possible with vanilla neovim. However, you can use neovim-remote to simulate a client-server model. This means you can send commands to neovim without having that window or instance focused. I’ll go ahead and assume we have neovim-remote installed.

The first thing we need to do is make sure that anytime we open neovim we open it on a socket so we can talk to it remotely. I added the following to my .zshrc:

nvim() {
    local fn="$(mktemp -u "/tmp/nvimsocket-XXXXXXX")"
    NVIM_LISTEN_ADDRESS=$fn /usr/bin/nvim $@
}

This next part is VERY hacky. I’m going to use sed to change the value of my colorscheme in my $MYVIMRC. I can’t think of another way to make this change safer sadly.

update_nvim() {
    if [[ -z ${VIM_SCHEME} ]]; then
        printf "Error: VIM_SCHEME not set: not changing vim colorscheme\n"
    else
        sed -i -e "s/colorscheme .*/colorscheme $VIM_SCHEME/g" $VIM_CONFIG
        reload_nvim
    fi
}

This changes the colorscheme variable in neovim to the value that we defined in our squash file. This is actually very convenient, however, since this means that each squash theme can use a different vim colorscheme. Cool!

All we have to do now is tell all instances of neovim to source $MYVIMRC.

reload_nvim() {
    if [[ ! $(command -v nvr) ]]; then
        printf "Error:: neovim-remote not found\n"
        printf "Please install nvr to reload neovim\n"
    else
        inst=($(nvr --serverlist | grep nvim | sort | uniq))

        for nvim_inst in "${inst[@]}"; do 
            nvr --servername "${nvim_inst}" --remote-send '<Esc>:so ${HOME}/.config/nvim/init.vim<CR>' &
        done
    fi
}

And we’re all done with neovim. Upon switching themes we will change the active colorscheme and reload all open instances.