›INDEX
Last Updated:

Managing Themes on Linux

I enjoy maximizing sunlight in every room I spend time in. However, when working during the day, having my computer set to dark mode makes it much harder to see the screen clearly. For this reason, I wanted an easy way to switch between light and dark modes across my entire system.

On many operating systems, there’s a built-in way to toggle themes. Even on Linux, if you’re using a desktop environment like GNOME (common on distributions like Ubuntu), switching themes is straightforward and integrated into the settings.

But my setup is different. I use ArcoLinux with a window manager called i3. Unlike full desktop environments, i3 doesn't come with a default way to manage themes. It prioritizes giving users full control, which means configuring themes is something you need to figure out for yourself.

In this blog, I’ll walk you through the approach I've taken to manage light and dark themes on my system. If you’re using a similar setup, this guide will give you a starting point to achieve the same.

Overview

There are a lot of things that require to be managed - things like NeoVim (Editor), Brave (Browser), Alacritty (Terminal), VSCode (Editor), Tmux (Multiplexer), etc.

Unfortunately, we're going to have to configure ALL of these individually. Some of them default to using the "system theme" but that doesn't always work.

For applications that use a configuration file like Alacritty, Rofi, Tmux, etc, we can just swap out a configuration file and control the colors using this method. We're going to use symlinks to achieve this in a clean way.

Some applications need to be configure through command line operations and some need to be configured through the GUI (like VSCode).

Preview

Light Mode

Light theme just wallpaper

Light theme with applications

Wallpaper from [1].

Dark Mode

Dark theme just wallpaper

Dark theme with applications

Wallpaper from [2].

Primary Script

Overall Structure

Here's the structure of the main script that's going to do all the heavy lifting:

#!/usr/bin/env bash

THEME_FILE="$HOME/.config/suchi-configs/theme.txt"
GTK4_LIGHT="Arc-Lighter"
GTK4_DARK="Arc-Darkest"
light="light"
dark="dark"

function get_theme() {
    cat "$THEME_FILE"
}

function set_theme_file() {
    theme="$1"
    if [[ "$theme" == "$light" ]]; then
        echo "$light" > "$THEME_FILE"
    elif [[ "$theme" == "$dark" ]]; then
        echo "$dark" > "$THEME_FILE"
    else
        echo "Unable to set invalid theme $1."
        exit 1
    fi
}

# we'll flush these functions out soon
# function simple_set_file() { ... }
# function set_alacritty() { ... }
# function set_yazi() { ... }
# function set_tmux() { ... }
# function set_polybar() { ... }
# function set_dunst() { ... }
# function set_rofi() { ... }
# function set_xsettings() { ... }
# function set_gtk3() { ... }
# function set_gtk4() { ... }

function update_theme() {
    theme="$1"
    set_theme_file "$theme"
    set_alacritty "$theme"
    set_yazi "$theme"
    set_tmux "$theme"
    set_polybar "$theme"
    set_dunst "$theme"
    set_rofi "$theme"
    set_gtk3 "$theme"
    set_gtk4 "$theme"
    set_xsettings "$theme"
    $HOME/.local/scripts/setWallpaper.sh
}

case "$1" in
    "$light")
        theme="$light"
        ;;
    "$dark")
        theme="$dark"
        ;;
    "toggle")
        old_theme=$(get_theme)
        if [[ "$old_theme" == "$light" ]]; then
            theme="$dark"
        elif [[ "$old_theme" == "$dark" ]]; then
            theme="$light"
        else
            echo "Unable to set invalid theme $1."
            exit 1
        fi
        ;;
    *)
        echo "Invalid command."
        exit 1
        ;;
esac

update_theme "$theme"
i3-msg reload
echo "$theme theme has been set."

Now these are all the applications I use right now, however, what's important is the overall structure and understanding how to handle your own applications. That said, I'm going to include the detail for each application by the end of this.

Simple Set File

This is going to be the primary function I use in the script to change themes for stuff that uses configuration files:

function simple_set_file() {
    # $1 -> theme choice
    # $2 -> main file / theme file (the symbolic link)
    # $3 -> light file
    # $4 -> dark file

    theme="$1"
    main_file="$2"
    light_file="$3"
    dark_file="$4"

    if [ ! -f "$light_file" ] || [ ! -f "$dark_file" ]; then
        echo "missing either light or dark file"
        exit 1
    fi

    if [[ "$theme" == "$light" ]]; then
        ln -sf "$light_file" "$main_file"
    elif [[ "$theme" == "$dark" ]]; then
        ln -sf "$dark_file" "$main_file"
    else
        echo "Unable to set invalid theme $1 for simple set file."
        exit 1
    fi
}

This works by changing the $main_file, which is the applications config file, from being linked to either the $dark_file or the $light_file. The ln -sf creates a "symbolic link" and the -f forces so that it replaces the existing file.

Here's the example for my Alacritty config:

❯ pwd
~/.config/alacritty

❯ ls -la
.rw------- [redacted] alacritty.toml
.rw-r--r-- [redacted] dark.toml
.rw-r--r-- [redacted] light.toml
lrwxrwxrwx [redacted] theme.toml -> dark.toml

As you can see, right now I'm using the dark mode and therefore the theme.toml links to the dark.toml file. If we call the function with "light" as our theme then theme.toml will now point to light.toml.

This works well for most configurations, especially if the application auto-refreshes if the configuration is changed.

Sometimes a file doesn't realize that it was updated and therefore you can use something like:

touch --no-dereference "$config_base"

This updates the file without actually updating the file, which can lead to the application configuration being refreshed.

GTK Theme

One of the main things we'll have to change is the GTK theme as this controls a lot of the GUI based applications. I learnt about xsettingsd from the Reddit user from [5] which is used to update the theme dynamically.

First you install xsettingsd using your package manager and then create a configuration file like:

#~/.config/xsettingsd/xsettingsd.conf

Net/ThemeName "Arc-Darkest"
Net/CursorBlink 1
Net/IconThemeName "Sardi-Arc"
Gtk/CursorThemeName "Bibata-Modern-Ice"
Gtk/ApplicationPreferDarkTheme 1

But we're going to use the simple_set_file function for this too. Therefore, we maintain a light.conf and a dark.conf which are then used to create the file xsettingsd.conf as a symlink.

Then, we need to start the process like any other: xsettings &. You can add this to the list of startup applications based on however you control that. I've added it to my i3 config file.

Then, after a theme change, we need to use the following command to "reload" the config for xsettingd:

killall -HUP xsettingsd

You can read more details about xsettingsd from [7].

This was a life saver because this allows you to change the theme without having to restart all the applications. All the other methods I found require you to restart the applications.

function set_xsettings() {
    theme="$1"
    dir="$HOME/.config/xsettingsd/"
    main_file="$dir/xsettingsd.conf"
    light_file="$dir/light.conf"
    dark_file="$dir/dark.conf"

    simple_set_file "$theme" "$main_file" "$light_file" "$dark_file"

    killall -HUP xsettingsd
}

Other Considerations

I used to use these before I learnt about xsettingsd and I'm not sure if it still is necessary. I'm including it here for completeness sake:

function set_gtk3() {
    theme="$1"
    gtk3_dir="$HOME/.config/gtk-3.0"
    main_file="$gtk3_dir/settings.ini"
    light_file="$gtk3_dir/light.ini"
    dark_file="$gtk3_dir/dark.ini"

    simple_set_file "$theme" "$main_file" "$light_file" "$dark_file"
}

function set_gtk4() {
    theme="$1"

    if [[ "$theme" == "$light" ]]; then
        gsettings set org.gnome.desktop.interface gtk-theme "$GTK4_LIGHT"
    else
        gsettings set org.gnome.desktop.interface gtk-theme "$GTK4_DARK"
    fi
}

Applications

Rather than including the details manually here, I've just pasted all the contents of the script into a Gist: https://gist.github.com/SuchithSridhar/4f8128d56efdc718df4b652a7024ba67.

Most of these use the simple_set_file function to switch out the configuration file. Some require a little more handling but they all depend on the application's behaviour.

Neovim

This is one of the few applications that didn't have a clean way to dynamically switch themes by just changing the configuration file. That's probably because it's loaded the config file into memory and reloading a lua config could cause problems.

That's why I have a little "plugin" that checks that ~/.config/suchi-configs/theme.txt file and whenever that file changes, neovim reloads the theme.

Github theme-loader.nvim is the plugin created and the README has instructions on how to setup and use the plugin.

GUI Based Theme Control

I'm not going to elaborate on this because it really depends on the individual applications but usually they have an option to "follow system theme". Example:

Example of Brave auto theme

Another useful and important tool is the "Dark Reader" extension for brave and other browsers. This styles pages to be dark mode if they don't already have a dark mode [8].

Dark Reader extension on browsers

Similarly with VSCode:

VSCode Option for auto theme detection

Set Preference for System

There's a couple of settings you can set to indicate to applications that you prefer a dark theme. Here are a few settings:

# just as a command for gtk-4
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark'

# xsettingsd config to manage gtk (.config/xsettingsd/settings.conf)
Gtk/ApplicationPreferDarkTheme 1

# gtk3 file config (.config/gtk-3.0/settings.ini)
gtk-application-prefer-dark-theme=1

This makes applications list to the system theme preference.

Auto Control of Theme

I have my theme toggler bound to a hotkey so that I can change it easily. However, I've since discovered better methods to auto change the theme.

Fixed Time Based Change

Initially, I just had a time based way to decide the theme on startup. It only ran once after startup at which point it decided the theme based on the current time.

DARK_MODE_TIME_START="18"
DARK_MODE_TIME_END="6"

function set_theme_based_on_time() {
    current_hour=$(date +"%H")

    if [ "$current_hour" -ge "$DARK_MODE_TIME_START" ] || [ "$current_hour" -lt "$DARK_MODE_TIME_END" ]; then
        $HOME/.local/scripts/set-theme.sh dark
    else
        $HOME/.local/scripts/set-theme.sh light
    fi
}

However, now I only default back to this if I don't have an active internet connection.

Auto Change Based On Sunrise And Sunset

With the help of ChatGPT, I wrote a simple script using the "Sunrise-Sunset" API [6], which decides the theme based on the sunrise and sunset times for that day.

This script runs every 15-mins so that it can automatically change the theme whenever it gets dark.

#!/usr/bin/env python3

import os
import subprocess
from datetime import datetime, timezone

import requests

# Expected to be added as a cronjob:
# */15 * * * * /..../theme-auto-swticher.py

# City (Latitude, Longitude)
LAT = 9999
LON = 9999

CONFIG_PATH = os.path.expanduser("~/.config/suchi-configs/theme.txt")
CACHE_PATH = os.path.expanduser("~/.cache/suchi-cache/sunrise-sunset.txt")
SET_THEME_SCRIPT = os.path.expanduser("~/.local/scripts/set-theme.sh")

API_URL = "https://api.sunrise-sunset.org/json"


def get_current_theme():
    if os.path.exists(CONFIG_PATH):
        with open(CONFIG_PATH, "r") as file:
            return file.read().strip()
    return None


def fetch_and_cache_sun_times():
    params = {"lat": LAT, "lng": LON, "formatted": 0}
    print("Accessing Sunrise-Sunset API")
    response = requests.get(API_URL, params=params)
    response.raise_for_status()
    data = response.json()
    sunrise_utc = datetime.fromisoformat(data["results"]["sunrise"]).replace(
        tzinfo=timezone.utc
    )
    sunset_utc = datetime.fromisoformat(data["results"]["sunset"]).replace(
        tzinfo=timezone.utc
    )

    # Convert to local time
    sunrise_local = sunrise_utc.astimezone().replace(tzinfo=None)
    sunset_local = sunset_utc.astimezone().replace(tzinfo=None)

    # Write cache with labeled fields and header
    os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
    with open(CACHE_PATH, "w") as cache_file:
        cache_file.write(
            "# Cached sunrise and sunset times generated "
            "by theme-auto-switcher.py\n"
            f"Date: {datetime.now().date()}\n"
            f"Sunrise: {sunrise_local.isoformat()}\n"
            f"Sunset: {sunset_local.isoformat()}\n"
        )

    return sunrise_local, sunset_local


def read_cached_sun_times():
    if not os.path.exists(CACHE_PATH):
        return None, None

    with open(CACHE_PATH, "r") as cache_file:
        lines = cache_file.readlines()
        if len(lines) < 4:
            return None, None

        # Skip the header and parse labeled fields
        cache_date = lines[1].split("Date:")[1].strip()
        sunrise = datetime.fromisoformat(lines[2].split("Sunrise:")[1].strip())
        sunset = datetime.fromisoformat(lines[3].split("Sunset:")[1].strip())

        if cache_date == str(datetime.now().date()):
            return sunrise, sunset

    return None, None


def get_sun_times():
    sunrise, sunset = read_cached_sun_times()
    if sunrise and sunset:
        return sunrise, sunset
    return fetch_and_cache_sun_times()


def is_daytime():
    sunrise, sunset = get_sun_times()
    now = datetime.now()
    return sunrise <= now <= sunset


def main():
    expected_theme = "light" if is_daytime() else "dark"
    current_theme = get_current_theme()

    if current_theme != expected_theme:
        subprocess.run([SET_THEME_SCRIPT, expected_theme])
        print(f"Theme changed to {expected_theme}.")
    else:
        print(f"Theme is already {current_theme}. No change needed.")


if __name__ == "__main__":
    main()

Auto Change After Wake

When you schedule the theme-auto-switcher.py using cron, it only runs at every 15th minute of the hour. However, I want to see the right theme when I first open my laptop after I've put it to sleep for a while. It doesn't make sense to wait up to 15 mins for the theme to change.

Therefore, I've added a little script/service that runs after waking up from sleep that checks if the theme needs to be changed. I used Daniel Garajau's method from [4] to write the systemd service.

We need to run the change theme script as a regular user and not the root user since it needs access to all the user level controls.

I recommend reading the blog [4] but here's the files I've adapted:

Usually, systemd doesn't call user level suspend handling services so we install this so that user level services can run on suspend.target.

There's however, a clean way to proxy out these hooks from the system to the user instance. To do that, you will need to create a system unit that will trigger a user target (directly taken from [4]):

; /etc/systemd/system/suspend@.service

; Enable it by running sudo systemctl daemon-reload && sudo systemctl enable suspend@$(whoami)

[Unit]
Description=Call user's suspend target after system suspend
After=suspend.target

[Service]
Type=oneshot
ExecStart=/usr/bin/systemctl --user --machine=%i@ start --wait suspend.target

[Install]
WantedBy=suspend.target

Here's the command to enable it:

sudo systemctl daemon-reload && sudo systemctl enable suspend@$(whoami)

We need a service that handles the suspend action:

; ~/.config/systemd/user/suspend.target

[Unit]
Description=User-level suspend target
StopWhenUnneeded=yes

Then, we need to actually write the service that runs the script:

; ~/.config/systemd/user/wakeup-script.service

[Unit]
Description=Run script after waking from sleep
After=suspend.target

[Service]
Type=oneshot
ExecStart=/home/user/.local/scripts/wakeup-script.sh

[Install]
WantedBy=suspend.target

After this, we want to enable the system service for the user:

systemctl --user daemon-reload
systemctl --user enable wakeup-script.service

The wakeup-script.sh is just a simple bash script that exists. I didn't directly call the theme script because I wanted the option to do other things as well:

# wakeup-script.sh

#!/usr/bin/env bash

# Run after waking up from sleep.

# Maximum wait time for network connection (in seconds)
MAX_WAIT=240  # 4 minutes
CHECK_INTERVAL=1  # Check every second

# Function to check internet connectivity
check_internet() {
    ping -q -c 1 -W 1 8.8.8.8 > /dev/null 2>&1
    return $?
}

# Wait for internet connection
SECONDS_WAITED=0
while ! check_internet; do
    sleep $CHECK_INTERVAL
    SECONDS_WAITED=$((SECONDS_WAITED + CHECK_INTERVAL))

    # Exit if maximum wait time is exceeded
    if [[ $SECONDS_WAITED -ge $MAX_WAIT ]]; then
        echo "Internet connection not restored after $MAX_WAIT seconds. Exiting."
        exit 1
    fi
done

# Internet is connected; run the theme switcher script
echo "Internet connected after $SECONDS_WAITED seconds. Running theme auto-switcher."
/home/user/.local/scripts/theme-auto-swticher.py

References

I'm going to include as many references as I can but this has been a long term project and I might not have all the original sources for everything.

Enjoy the notes on this website? Consider supporting me in this adventure in you preferred way: Support me.