DEV Community

vast cow
vast cow

Posted on

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

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.

  • --day

    • Switch Windows to the light theme
    • Set the desktop background color to white
    • Change the Windows Terminal color scheme to the daytime scheme
    • Change the VS Code theme to the daytime theme
  • --night

    • Switch Windows to the dark theme
    • Set the desktop background color to black
    • Change the Windows Terminal color scheme to the nighttime scheme
    • Change the VS Code theme to the nighttime theme

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
Enter fullscreen mode Exit fullscreen mode

The script defines the path as:

CONFIG_PATH = Path(__file__).with_name("config.toml")
Enter fullscreen mode Exit fullscreen mode

For example, if the script is located at:

C:\Users\you\theme_switcher.py
Enter fullscreen mode Exit fullscreen mode

then the configuration file must be:

C:\Users\you\config.toml
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This applies:

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

4. Apply the Night Theme

python theme_switcher.py --night
Enter fullscreen mode Exit fullscreen mode

This applies:

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

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
Enter fullscreen mode Exit fullscreen mode

The script modifies these values:

AppsUseLightTheme
SystemUsesLightTheme
Enter fullscreen mode Exit fullscreen mode

Meaning:

Value Meaning
1 Light theme
0 Dark 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)
Enter fullscreen mode Exit fullscreen mode

Therefore:

Mode Wallpaper Color
--day White
--night Black

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
Enter fullscreen mode Exit fullscreen mode

It updates the following JSON field:

{
    "profiles": {
        "defaults": {
            "colorScheme": "..."
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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

[terminal]
night_color_scheme = "One Half Dark"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It updates:

{
    "workbench.colorTheme": "Default Dark Modern"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
         )
Enter fullscreen mode Exit fullscreen mode

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)
         )
Enter fullscreen mode Exit fullscreen mode

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

  • Windows
  • Python 3.11 or newer (standard library only)
  • Python 3.10 or older requires tomli

The script handles this as follows:

try:
    import tomllib
except ModuleNotFoundError:
    import tomli as tomllib
Enter fullscreen mode Exit fullscreen mode

Python 3.11+ includes tomllib by default.

For Python 3.10 and earlier:

pip install tomli
Enter fullscreen mode Exit fullscreen mode

Notes and Caveats

1. Windows Only

The script depends on Windows-specific APIs:

import winreg
ctypes.WinDLL("ole32")
ctypes.WinDLL("user32")
Enter fullscreen mode Exit fullscreen mode

It will not run on macOS or Linux.


2. Windows Terminal Must Be Installed

The script searches for a package directory matching:

Microsoft.WindowsTerminal_*
Enter fullscreen mode Exit fullscreen mode

If Windows Terminal is not installed, it raises:

FileNotFoundError: Windows Terminal settings.json was not found.
Enter fullscreen mode Exit fullscreen mode

3. Missing config.toml Causes Startup Failure

The configuration file is loaded during module initialization:

CONFIG = load_config()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

As a consequence:

  • JSONC comments are not preserved
  • The file may be reformatted
  • Comments may be removed
  • Key ordering may change

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 Theme Light Dark
Wallpaper Solid white Solid black
Windows Terminal Day color scheme Night color scheme
VS Code Day theme Night theme

Typical usage:

python theme_switcher.py --day
python theme_switcher.py --night
Enter fullscreen mode Exit fullscreen mode

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 settings_path.exists():
        with settings_path.open("r", encoding="utf-8") as f:
            settings = json.load(f)
    else:
        settings = {}

    current_theme = settings.get("workbench.colorTheme")

    if current_theme == theme_name:
        return False

    settings["workbench.colorTheme"] = theme_name
    return write_json_if_changed(settings_path, settings, "VS Code settings.json")


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()
Enter fullscreen mode Exit fullscreen mode

Top comments (0)