Skip to content
vast-cow's blog
Go back

Switch Windows, Windows Terminal, and VS Code Themes All at Once

Edit page

The following Python script is a CLI tool that explicitly switches Windows between light and dark themes while also synchronizing Windows Terminal, VS Code, and desktop background colors. It is intended for Windows environments.

What This Script Does

Run the script with either --day or --night.

There is no automatic time-based switching. It is designed to apply either the daytime or nighttime configuration explicitly and manually.


Usage

1. Required File

You must place the following configuration file in the same folder as the script:

config.toml

The script defines the path as:

CONFIG_PATH = Path(__file__).with_name("config.toml")

For example, if the script is located at:

C:\Users\you\theme_switcher.py

then the configuration file must be:

C:\Users\you\config.toml

2. Example config.toml

The script expects the following TOML structure:

[terminal]
day_color_scheme = "One Half Light"
night_color_scheme = "One Half Dark"

[vscode]
day_theme = "Default Light Modern"
night_theme = "Default Dark Modern"

The values must match actual theme names available in your Windows Terminal and VS Code installations.


3. Apply the Day Theme

python theme_switcher.py --day

This applies:

Windows: Light theme
Wallpaper: White
Windows Terminal: terminal.day_color_scheme from config.toml
VS Code: vscode.day_theme from config.toml

4. Apply the Night Theme

python theme_switcher.py --night

This applies:

Windows: Dark theme
Wallpaper: Black
Windows Terminal: terminal.night_color_scheme from config.toml
VS Code: vscode.night_theme from config.toml

Main Features

1. Windows Light/Dark Theme Switching

set_theme(light: bool) updates the Windows Registry.

Target registry key:

HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize

The script modifies these values:

AppsUseLightTheme
SystemUsesLightTheme

Meaning:

ValueMeaning
1Light theme
0Dark theme

After updating the registry, it broadcasts WM_SETTINGCHANGE to notify Windows of the theme change.


2. Set the Wallpaper to a Solid Color

set_solid_wallpaper(r, g, b) uses the Windows COM API to change the desktop background to a solid color.

The script uses the following fixed values:

DAY_WALLPAPER_RGB = (255, 255, 255)
NIGHT_WALLPAPER_RGB = (0, 0, 0)

Therefore:

ModeWallpaper Color
--dayWhite
--nightBlack

Instead of using an image file, the script calls IDesktopWallpaper::SetBackgroundColor and IDesktopWallpaper::Enable(False) to disable wallpaper images and apply a solid background color.


3. Change the Windows Terminal Color Scheme

set_windows_terminal_color_scheme(color_scheme) edits Windows Terminal’s settings.json.

The script searches for the configuration file under:

%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_*\LocalState\settings.json

It updates the following JSON field:

{
    "profiles": {
        "defaults": {
            "colorScheme": "..."
        }
    }
}

For example, when using --night, the following value from config.toml is applied:

[terminal]
night_color_scheme = "One Half Dark"

4. Change the VS Code Theme

set_vscode_color_theme(theme_name) modifies VS Code’s user settings.

Target file:

%USERPROFILE%\AppData\Roaming\Code\User\settings.json

It updates:

{
    "workbench.colorTheme": "Default Dark Modern"
}

If settings.json does not exist, it will be created automatically.


5. Avoid Unnecessary File Writes

write_json_if_changed() compares the newly generated JSON with the existing file contents.

If there is no difference, the file is not rewritten:

if old_text == new_text:
    return False

This avoids unnecessary updates when the desired configuration is already applied.


Execution Flow

When --day Is Specified

main()
  └─ apply_day_theme()
      └─ apply_explicit_theme(
             light=True,
             terminal_color_scheme=CONFIG["day_terminal_color_scheme"],
             vscode_theme=CONFIG["day_vscode_theme"],
             wallpaper_rgb=(255, 255, 255)
         )

When --night Is Specified

main()
  └─ apply_night_theme()
      └─ apply_explicit_theme(
             light=False,
             terminal_color_scheme=CONFIG["night_terminal_color_scheme"],
             vscode_theme=CONFIG["night_vscode_theme"],
             wallpaper_rgb=(0, 0, 0)
         )

Inside apply_explicit_theme(), the following operations occur in order:

  1. Check the current Windows theme
  2. Change the Windows theme if necessary
  3. Update the wallpaper color
  4. Change the Windows Terminal color scheme
  5. Change the VS Code theme
  6. Broadcast an environment-change notification if Terminal or VS Code settings were modified

Requirements and Dependencies

Required

The script handles this as follows:

try:
    import tomllib
except ModuleNotFoundError:
    import tomli as tomllib

Python 3.11+ includes tomllib by default.

For Python 3.10 and earlier:

pip install tomli

Notes and Caveats

1. Windows Only

The script depends on Windows-specific APIs:

import winreg
ctypes.WinDLL("ole32")
ctypes.WinDLL("user32")

It will not run on macOS or Linux.


2. Windows Terminal Must Be Installed

The script searches for a package directory matching:

Microsoft.WindowsTerminal_*

If Windows Terminal is not installed, it raises:

FileNotFoundError: Windows Terminal settings.json was not found.

3. Missing config.toml Causes Startup Failure

The configuration file is loaded during module initialization:

CONFIG = load_config()

As a result, if config.toml is missing, the script may fail before any command-line processing occurs, including before most execution paths beyond basic interpreter startup.


4. JSON Comments May Be Lost

Both Windows Terminal and VS Code settings files are read and written using:

json.load()
json.dumps()

As a consequence:

VS Code’s settings.json commonly contains comments because it supports JSONC, so users relying on comments should be aware of this limitation.


Summary

This script provides a Windows theme-switching utility that synchronizes four settings at once:

Target--day--night
Windows ThemeLightDark
WallpaperSolid whiteSolid black
Windows TerminalDay color schemeNight color scheme
VS CodeDay themeNight theme

Typical usage:

python theme_switcher.py --day
python theme_switcher.py --night

All theme-specific values are managed through config.toml located in the same directory as the script.

import argparse
import ctypes
import json
import logging
import os
import uuid
from ctypes import wintypes
from pathlib import Path
import winreg

try:
    import tomllib
except ModuleNotFoundError:
    import tomli as tomllib

PERSONALIZE_KEY = "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"
APPS_KEY = "AppsUseLightTheme"
SYSTEM_KEY = "SystemUsesLightTheme"

HWND_BROADCAST = 0xFFFF
WM_SETTINGCHANGE = 0x001A
SMTO_ABORTIFHUNG = 0x0002

CONFIG_PATH = Path(__file__).with_name("config.toml")

CLSID_DESKTOP_WALLPAPER = "C2CF3110-460E-4FC1-B9D0-8A1C0C9CC4BD"
IID_IDESKTOP_WALLPAPER = "B92B56A9-8B55-4E14-9A89-0199BBB6F93B"

COINIT_APARTMENTTHREADED = 0x2
CLSCTX_ALL = 0x17
S_OK = 0
S_FALSE = 1
RPC_E_CHANGED_MODE = 0x80010106

DAY_WALLPAPER_RGB = (255, 255, 255)
NIGHT_WALLPAPER_RGB = (0, 0, 0)


if hasattr(wintypes, "ULONG_PTR"):
    ULONG_PTR = wintypes.ULONG_PTR
else:
    ULONG_PTR = ctypes.c_size_t


logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)


class GUID(ctypes.Structure):
    _fields_ = [
        ("Data1", wintypes.DWORD),
        ("Data2", wintypes.WORD),
        ("Data3", wintypes.WORD),
        ("Data4", ctypes.c_ubyte * 8),
    ]

    @classmethod
    def from_string(cls, value: str) -> "GUID":
        return cls.from_buffer_copy(uuid.UUID(value).bytes_le)


def check_hresult(hr: int) -> None:
    if ctypes.c_long(hr).value < 0:
        raise OSError(f"HRESULT 0x{hr & 0xFFFFFFFF:08X}")


def rgb_to_colorref(r: int, g: int, b: int) -> int:
    """Return Windows COLORREF value. COLORREF is 0x00BBGGRR."""
    return (r & 0xFF) | ((g & 0xFF) << 8) | ((b & 0xFF) << 16)


def set_solid_wallpaper(r: int, g: int, b: int) -> None:
    """
    Set the Windows desktop background to a solid color without using an image file.

    Specify the background color via IDesktopWallpaper::SetBackgroundColor,
    and disable wallpaper image display via IDesktopWallpaper::Enable(False).
    """
    logger.debug("Setting solid desktop wallpaper color to RGB(%d, %d, %d)", r, g, b)

    ole32 = ctypes.WinDLL("ole32")

    ole32.CoInitializeEx.argtypes = [ctypes.c_void_p, wintypes.DWORD]
    ole32.CoInitializeEx.restype = ctypes.c_long

    ole32.CoUninitialize.argtypes = []
    ole32.CoUninitialize.restype = None

    ole32.CoCreateInstance.argtypes = [
        ctypes.POINTER(GUID),
        ctypes.c_void_p,
        wintypes.DWORD,
        ctypes.POINTER(GUID),
        ctypes.POINTER(ctypes.c_void_p),
    ]
    ole32.CoCreateInstance.restype = ctypes.c_long

    co_initialized_here = False
    hr = ole32.CoInitializeEx(None, COINIT_APARTMENTTHREADED)
    hr_unsigned = hr & 0xFFFFFFFF

    if hr in (S_OK, S_FALSE):
        co_initialized_here = True
    elif hr_unsigned == RPC_E_CHANGED_MODE:
        logger.debug("COM was already initialized with a different threading model")
    else:
        check_hresult(hr)

    p_wallpaper = ctypes.c_void_p()

    try:
        clsid = GUID.from_string(CLSID_DESKTOP_WALLPAPER)
        iid = GUID.from_string(IID_IDESKTOP_WALLPAPER)

        hr = ole32.CoCreateInstance(
            ctypes.byref(clsid),
            None,
            CLSCTX_ALL,
            ctypes.byref(iid),
            ctypes.byref(p_wallpaper),
        )
        check_hresult(hr)

        vtable = ctypes.cast(
            p_wallpaper,
            ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p)),
        ).contents

        release = ctypes.WINFUNCTYPE(
            wintypes.ULONG,
            ctypes.c_void_p,
        )(vtable[2])

        set_background_color = ctypes.WINFUNCTYPE(
            ctypes.c_long,
            ctypes.c_void_p,
            wintypes.DWORD,
        )(vtable[8])

        enable = ctypes.WINFUNCTYPE(
            ctypes.c_long,
            ctypes.c_void_p,
            wintypes.BOOL,
        )(vtable[18])

        hr = set_background_color(p_wallpaper, rgb_to_colorref(r, g, b))
        check_hresult(hr)

        hr = enable(p_wallpaper, False)
        check_hresult(hr)

        logger.debug("Solid desktop wallpaper color applied")

    finally:
        if p_wallpaper:
            release(p_wallpaper)

        if co_initialized_here:
            ole32.CoUninitialize()


def set_day_wallpaper() -> None:
    set_solid_wallpaper(*DAY_WALLPAPER_RGB)


def set_night_wallpaper() -> None:
    set_solid_wallpaper(*NIGHT_WALLPAPER_RGB)


def load_config(path: Path = CONFIG_PATH) -> dict:
    if not path.is_file():
        raise FileNotFoundError(f"Config file was not found: {path}")

    with path.open("rb") as f:
        config = tomllib.load(f)

    return {
        "day_terminal_color_scheme": config["terminal"]["day_color_scheme"],
        "night_terminal_color_scheme": config["terminal"]["night_color_scheme"],
        "day_vscode_theme": config["vscode"]["day_theme"],
        "night_vscode_theme": config["vscode"]["night_theme"],
    }


CONFIG = load_config()


def get_windows_terminal_settings_path() -> Path:
    packages_dir = Path(os.environ["LOCALAPPDATA"]) / "Packages"
    logger.debug("Searching for Windows Terminal settings.json under: %s", packages_dir)

    for package_dir in packages_dir.glob("Microsoft.WindowsTerminal_*"):
        settings_path = package_dir / "LocalState" / "settings.json"
        logger.debug("Checking candidate path: %s", settings_path)
        if settings_path.is_file():
            logger.debug("Found Windows Terminal settings.json: %s", settings_path)
            return settings_path

    raise FileNotFoundError("Windows Terminal settings.json was not found.")


def get_vscode_settings_path() -> Path:
    path = Path.home() / "AppData" / "Roaming" / "Code" / "User" / "settings.json"
    logger.debug("Using VS Code settings.json path: %s", path)
    return path


def get_current_theme() -> int:
    try:
        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, PERSONALIZE_KEY) as key:
            value, regtype = winreg.QueryValueEx(key, APPS_KEY)
            if regtype == winreg.REG_DWORD:
                logger.debug("Current Windows app theme registry value: %s", value)
                return int(value)
    except FileNotFoundError:
        logger.debug("Theme registry key/value not found. Falling back to light theme.")

    return 1


def send_setting_change(param: str) -> None:
    logger.debug("Broadcasting WM_SETTINGCHANGE with param=%r", param)

    user32 = ctypes.WinDLL("user32", use_last_error=True)
    send_message_timeout = user32.SendMessageTimeoutW
    send_message_timeout.argtypes = [
        wintypes.HWND,
        wintypes.UINT,
        wintypes.WPARAM,
        wintypes.LPCWSTR,
        wintypes.UINT,
        wintypes.UINT,
        ctypes.POINTER(ULONG_PTR),
    ]
    send_message_timeout.restype = wintypes.LPARAM

    result = ULONG_PTR()
    ret = send_message_timeout(
        HWND_BROADCAST,
        WM_SETTINGCHANGE,
        0,
        param,
        SMTO_ABORTIFHUNG,
        5000,
        ctypes.byref(result),
    )

    if ret == 0:
        raise ctypes.WinError(ctypes.get_last_error())

    logger.debug("WM_SETTINGCHANGE broadcast completed successfully for param=%r", param)


def notify_theme_changed() -> None:
    logger.debug("Notifying system that theme has changed")
    send_setting_change("ImmersiveColorSet")


def notify_environment_changed() -> None:
    logger.debug("Notifying system that environment/settings may have changed")
    send_setting_change("Environment")


def set_theme(light: bool) -> None:
    value = 1 if light else 0
    logger.debug("Setting Windows theme to: %s", "light" if light else "dark")

    with winreg.CreateKey(winreg.HKEY_CURRENT_USER, PERSONALIZE_KEY) as key:
        winreg.SetValueEx(key, APPS_KEY, 0, winreg.REG_DWORD, value)
        winreg.SetValueEx(key, SYSTEM_KEY, 0, winreg.REG_DWORD, value)

    notify_theme_changed()
    logger.debug("Windows theme updated")


def write_json_if_changed(path: Path, data: dict, description: str) -> bool:
    new_text = json.dumps(data, ensure_ascii=False, indent=4) + "\n"

    old_text = None
    if path.exists():
        old_text = path.read_text(encoding="utf-8")

    if old_text == new_text:
        logger.debug("%s already matches desired content; no update needed", description)
        return False

    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(new_text, encoding="utf-8", newline="\n")
    logger.debug("%s updated: %s", description, path)
    return True


def set_windows_terminal_color_scheme(color_scheme: str) -> bool:
    settings_path = get_windows_terminal_settings_path()
    logger.debug("Loading Windows Terminal settings from: %s", settings_path)

    with settings_path.open("r", encoding="utf-8") as f:
        settings = json.load(f)

    profiles = settings.setdefault("profiles", {})
    defaults = profiles.setdefault("defaults", {})

    current_scheme = defaults.get("colorScheme")
    logger.debug("Current Terminal default colorScheme: %r", current_scheme)
    logger.debug("Desired Terminal default colorScheme: %r", color_scheme)

    if current_scheme == color_scheme:
        return False

    defaults["colorScheme"] = color_scheme
    return write_json_if_changed(settings_path, settings, "Windows Terminal settings.json")


def set_vscode_color_theme(theme_name: str) -> bool:
    settings_path = get_vscode_settings_path()

    if not settings_path.is_file():
        raise FileNotFoundError(f"VS Code settings.json was not found: {settings_path}")

    text = settings_path.read_text(encoding="utf-8")

    key = '"workbench.colorTheme"'
    key_pos = text.find(key)

    if key_pos == -1:
        raise ValueError(
            f'{key} was not found in VS Code settings.json: {settings_path}'
        )

    colon_pos = text.find(":", key_pos + len(key))
    if colon_pos == -1:
        raise ValueError(f"Malformed {key} setting in: {settings_path}")

    value_start = colon_pos + 1

    while value_start < len(text) and text[value_start].isspace():
        value_start += 1

    if value_start >= len(text) or text[value_start] != '"':
        raise ValueError(f"{key} does not have a quoted string value")

    value_end = value_start + 1

    while value_end < len(text):
        if text[value_end] == '"' and text[value_end - 1] != "\\":
            break
        value_end += 1

    if value_end >= len(text):
        raise ValueError(f"Unterminated string value for {key}")

    current_theme = text[value_start + 1 : value_end]

    logger.debug("Current VS Code color theme: %r", current_theme)
    logger.debug("Desired VS Code color theme: %r", theme_name)

    if current_theme == theme_name:
        return False

    escaped_theme_name = (
        theme_name
        .replace("\\", "\\\\")
        .replace('"', '\\"')
    )

    new_text = (
        text[: value_start + 1]
        + escaped_theme_name
        + text[value_end:]
    )

    settings_path.write_text(new_text, encoding="utf-8", newline="\n")
    logger.debug("VS Code settings.json updated: %s", settings_path)
    return True


def apply_explicit_theme(
    *,
    light: bool,
    terminal_color_scheme: str,
    vscode_theme: str,
    wallpaper_rgb: tuple[int, int, int],
) -> bool:
    """
    Apply the specified theme immediately without using time-based determination.

    Used by the CLI options --day / --night.
    """
    current_is_light = bool(get_current_theme())

    if current_is_light != light:
        set_theme(light)

    set_solid_wallpaper(*wallpaper_rgb)

    terminal_changed = set_windows_terminal_color_scheme(terminal_color_scheme)
    vscode_changed = set_vscode_color_theme(vscode_theme)

    if terminal_changed or vscode_changed:
        try:
            notify_environment_changed()
        except OSError:
            logger.exception("Failed to broadcast environment change notification")

    logger.info(
        "Applied explicit %s theme: terminal=%r, vscode=%r, wallpaper_rgb=%r",
        "day/light" if light else "night/dark",
        terminal_color_scheme,
        vscode_theme,
        wallpaper_rgb,
    )

    return light


def apply_day_theme() -> bool:
    return apply_explicit_theme(
        light=True,
        terminal_color_scheme=CONFIG["day_terminal_color_scheme"],
        vscode_theme=CONFIG["day_vscode_theme"],
        wallpaper_rgb=DAY_WALLPAPER_RGB,
    )


def apply_night_theme() -> bool:
    return apply_explicit_theme(
        light=False,
        terminal_color_scheme=CONFIG["night_terminal_color_scheme"],
        vscode_theme=CONFIG["night_vscode_theme"],
        wallpaper_rgb=NIGHT_WALLPAPER_RGB,
    )


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Switch Windows, Windows Terminal, and VS Code themes explicitly."
    )

    mode_group = parser.add_mutually_exclusive_group(required=True)
    mode_group.add_argument(
        "--day",
        action="store_true",
        help="Apply day/light settings immediately and exit. Ignores current time.",
    )
    mode_group.add_argument(
        "--night",
        action="store_true",
        help="Apply night/dark settings immediately and exit. Ignores current time.",
    )

    return parser.parse_args()


def main() -> None:
    args = parse_args()

    if args.day:
        apply_day_theme()
        return

    if args.night:
        apply_night_theme()
        return


if __name__ == "__main__":
    main()

Edit page
Share this post:

Comments


Previous Post
How to Change the Hostname in WSL
Next Post
Managing Diffs When Letting AI Agents Work on Massive Repositories