I've decided to go outside more, touching grass and all that. You obviously want to bring your laptop when doing so, which surfaced the problem of not being able to read text on my screen when using a dark theme.

Adaptive theme switching demonstration in Sway

Phones, some proprietary operating systems and even some Linux desktop environments have the concept of "adaptive themes", which means basically switching between a light and a dark theme dynamically, without the need to restart applications.

Linux at day, linux at night

The dark/light preference used to be indicated in different ways on Linux. GTK-based applications wrote to ~/.config/gtk-3.0/settings.ini, Qt did something else and others tried to create their own standards.

The cool kids now have agreed on using the XDG desktop portal D-Bus API. It exposes the system's preferred color scheme through the org.freedesktop.appearance namespace with the color-scheme key.

It can be queried with dbus-send:

dbus-send --session --print-reply --dest=org.freedesktop.portal.Desktop \
  /org/freedesktop/portal/desktop \
  org.freedesktop.portal.Settings.ReadOne \
  string:'org.freedesktop.appearance' \
  string:'color-scheme'

method return time=1765310080.289645 sender=:1.12 -> destination=:1.466 serial=948 reply_serial=2
   variant       uint32 1

The number at the end shows the preference

  • 0: No preference
  • 1: Prefer dark appearance
  • 2: Prefer light appearance

Some applications natively listen to that these days (e.g. firefox). Others need custom configuration for it.

Neovim

First, the editor. I wrote a listener in lua, which can be used in the Neovim config. Save this file as theme-toggle.lua, then it can be required and used like this:

require('theme-toggle').setup({
    on_dark = function()
        vim.opt.background = "dark"
        print("Theme switched to dark")
    end,
    on_light = function()
        vim.opt.background = "light"
        print("Theme switched to light")
    end,
})

I made it a configurable function for what to be run on toggle, so it can be extended to not only change vim.opt.background but also switch theme settings for the statusline (e.g. lua-line) and more.

Terminal

Some terminals, e.g. Ghostty already support configuring two themes, a dark and a light one, which get switched automatically.

My terminal emulator, Rio terminal was missing that feature on Linux so I added it in this pull request, which should be included in the next release.

Sway

For sway (and other applications) I created a SystemD user service in NixOS in a module that allows passing it script to toggle things.

{
  config,
  lib,
  pkgs,
  ...
}:
with lib;
let

  themeWatcherScript = pkgs.writeShellScript "theme-watcher" ''
    update_theme() {
      local scheme=$(${pkgs.dconf}/bin/dconf read /org/gnome/desktop/interface/color-scheme 2>/dev/null)

      local theme="prefer-dark"
      if [[ "$scheme" == "'prefer-light'" ]]; then
        theme="prefer-light"
      fi

      ${concatStringsSep "\n" (
        map (script: ''
          ${script} "$theme"
        '') cfg.scripts
      )}
    }

    # Set initial theme
    update_theme

    # Monitor dbus for changes to color-scheme
    ${pkgs.dconf}/bin/dconf watch /org/gnome/desktop/interface/color-scheme | while read -r line; do
      update_theme
    done
  '';
in
{
  options.pinpox.services.theme-switcher = {
    enable = mkEnableOption "theme switcher service";

    scripts = mkOption {
      type = types.listOf types.str;
      default = [ ];
      description = ''
        List of scripts to call when theme changes. Each script will be called
        with one argument: either "prefer-light" or "prefer-dark".
      '';
    };
  };

  config = mkIf cfg.enable {

    systemd.user.services.theme-watcher = {
      Unit = {
        Description = "Theme watcher - updates applications on dbus theme change";
        PartOf = [ "graphical-session.target" ];
        After = [ "graphical-session.target" ];
      };

      Service = {
        Type = "simple";
        ExecStart = "${themeWatcherScript}";
        Restart = "on-failure";
        RestartSec = 3;
      };

      Install = {
        WantedBy = [ "graphical-session.target" ];
      };
    };
};

The pinpox.services.theme-switcher.scripts option is a list of strings that can be set from all other parts of the configuration (e.g. sway) that want to be switched. It expects script that can be called with exactly one argument: "prefer-light" or "prefer-dark" and calls all of them when the preferred theme changes.

For sway I used:

pinpox.services.theme-switcher =
  let
    swayThemeSwitcher = pkgs.writeShellScript "sway-theme-switcher" ''
      theme="$1"

      if [[ "$theme" == "prefer-light" ]]; then
        ${pkgs.sway}/bin/swaymsg "${lightColors}"
      else
        ${pkgs.sway}/bin/swaymsg "${darkColors}"
      fi
    '';
  in
  {
    scripts = [ "${swayThemeSwitcher}" ];
  };

Switcher Script

The following script can be used to toggle between light and dark themes easily. I have created a keybind for it in sway.

#!/usr/bin/env bash

# Read current setting
current=$(dconf read /org/gnome/desktop/interface/color-scheme 2>/dev/null)

if [[ $current == "'prefer-dark'" ]] || [[ $current == "" ]]; then
  dconf write /org/gnome/desktop/interface/color-scheme "'prefer-light'"
  echo "Switched to light theme"
else
  dconf write /org/gnome/desktop/interface/color-scheme "'prefer-dark'"
  echo "Switched to dark theme"
fi