DEV Community

vast cow
vast cow

Posted on

A Python Script for Automatically Switching Themes Based on the Time of Day

This Python script automatically switches your Windows appearance theme, Windows Terminal color scheme, and Visual Studio Code theme according to the time of day.

It is designed for users who want a brighter development environment during the day and a darker, more comfortable setup at night—without having to change each setting manually.

Why This Script Exists

Many developers prefer different themes depending on the time of day.

During the day, a light theme can make the screen easier to read in bright environments. At night, a dark theme can reduce eye strain and make long work sessions more comfortable.

For example:

  • Daytime: light Windows theme, bright Terminal scheme, light VS Code theme
  • Nighttime: dark Windows theme, dark Terminal scheme, dark VS Code theme

Instead of switching these settings by hand, this script automates the process based on a configurable schedule.

How It Works

The script follows a simple workflow:

  1. It reads theme settings and schedule information from config.toml.
  2. It checks the current time.
  3. It determines whether the system should be in day mode or night mode.
  4. It applies the corresponding Windows, Windows Terminal, and VS Code themes.
  5. It waits until the next scheduled switch time.
  6. It repeats the process automatically.

The result is a lightweight theme scheduler for a Windows-based development environment.

Configuration

The script expects a config.toml file in the same directory as the Python script.

The configuration file defines values such as:

  • the time when the day theme starts, for example 08:00
  • the time when the night theme starts, for example 20:00
  • the Windows Terminal color scheme for daytime
  • the Windows Terminal color scheme for nighttime
  • the VS Code theme for daytime
  • the VS Code theme for nighttime

This makes it easy to adapt the script to your own working style and preferred themes.

Running the Script

Automatic Mode

To run the script in scheduled mode, use:

python script.py
Enter fullscreen mode Exit fullscreen mode

In this mode, the script stays running in the background. It applies the correct theme immediately, then waits until the next scheduled switch time.

Manual Day Mode

To apply the day theme immediately, use:

python script.py --day
Enter fullscreen mode Exit fullscreen mode

This ignores the current time and applies the configured daytime settings.

Manual Night Mode

To apply the night theme immediately, use:

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

This also ignores the current time and applies the configured nighttime settings.

Main Features

The script provides several useful features:

  • switches the Windows system and app theme between light and dark
  • updates the Windows Terminal default color scheme
  • updates the Visual Studio Code color theme
  • notifies Windows after theme-related settings change
  • safely updates JSON configuration files only when changes are necessary
  • supports both scheduled automation and manual one-off switching
  • uses asynchronous file and system operations where appropriate

Windows Theme Switching

The Windows theme is changed by updating registry values under the current user’s personalization settings.

The script updates both:

  • AppsUseLightTheme
  • SystemUsesLightTheme

After changing these values, it broadcasts a WM_SETTINGCHANGE message so Windows and related applications can respond to the update.

Windows Terminal Integration

For Windows Terminal, the script locates the Terminal settings.json file under the user’s local application data directory.

It then updates the default profile color scheme by modifying:

profiles.defaults.colorScheme
Enter fullscreen mode Exit fullscreen mode

The script only writes the file if the desired color scheme is different from the current one. This avoids unnecessary file updates.

Visual Studio Code Integration

For Visual Studio Code, the script updates the user settings file located at:

AppData/Roaming/Code/User/settings.json
Enter fullscreen mode Exit fullscreen mode

It changes the value of:

workbench.colorTheme
Enter fullscreen mode Exit fullscreen mode

As with Windows Terminal, the file is only rewritten when the configured theme differs from the current setting.

Scheduled Switching

When run without command-line options, the script works as a scheduler.

It first applies the correct theme for the current time. Then it calculates the next switch time based on the configured day and night start times.

For example, if the configuration says:

  • day starts at 08:00
  • night starts at 20:00

then the script will use the day theme from 08:00 to 19:59, and the night theme from 20:00 until the next morning.

The script uses wait_until() from sleep_absolute to wait until the exact next switching point.

Manual Override

The script also supports manual switching through command-line arguments.

The --day option immediately applies the day theme and exits. The --night option immediately applies the night theme and exits.

These options are useful when you want to temporarily override the schedule or test your configuration.

Practical Use Cases

This script is useful for:

  • reducing eye strain during long development sessions
  • keeping Windows, Terminal, and VS Code visually consistent
  • improving readability based on ambient lighting
  • removing the need to manually switch themes every day
  • maintaining a more comfortable development environment

Summary

This script provides a simple but practical way to automate theme switching on Windows.

By combining Windows theme settings, Windows Terminal configuration, and Visual Studio Code settings, it keeps the entire development environment aligned with the time of day.

Once configured, it can run continuously and quietly maintain the preferred visual setup, switching between day and night themes automatically.

import argparse
import asyncio
import ctypes
import json
import logging
import os
from ctypes import wintypes
from datetime import datetime, timedelta
from pathlib import Path
import winreg

import aiofiles

try:
    import tomllib
except ModuleNotFoundError:
    import tomli as tomllib

from sleep_absolute import wait_until


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")
CONFIG: dict[str, object] = {}


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__)


def parse_hhmm(value: str) -> tuple[int, int]:
    try:
        hour_text, minute_text = value.split(":", 1)
        hour = int(hour_text)
        minute = int(minute_text)
    except ValueError as exc:
        raise ValueError(f"Invalid time format: {value!r}. Expected HH:MM.") from exc

    if not (0 <= hour <= 23):
        raise ValueError(f"Invalid hour in time: {value!r}")

    if not (0 <= minute <= 59):
        raise ValueError(f"Invalid minute in time: {value!r}")

    return hour, minute


async def load_config(path: Path = CONFIG_PATH) -> dict[str, object]:
    if not await asyncio.to_thread(path.is_file):
        raise FileNotFoundError(f"Config file was not found: {path}")

    async with aiofiles.open(path, "r", encoding="utf-8") as f:
        text = await f.read()

    config = tomllib.loads(text)

    day_start_hour, day_start_minute = parse_hhmm(
        config["schedule"]["day_start"]
    )
    night_start_hour, night_start_minute = parse_hhmm(
        config["schedule"]["night_start"]
    )

    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"],
        "day_start_hour": day_start_hour,
        "day_start_minute": day_start_minute,
        "night_start_hour": night_start_hour,
        "night_start_minute": night_start_minute,
        "day_start_minutes": day_start_hour * 60 + day_start_minute,
        "night_start_minutes": night_start_hour * 60 + night_start_minute,
    }


def _get_windows_terminal_settings_path_sync() -> 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.")


async def get_windows_terminal_settings_path() -> Path:
    return await asyncio.to_thread(_get_windows_terminal_settings_path_sync)


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_sync() -> 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


async def get_current_theme() -> int:
    return await asyncio.to_thread(_get_current_theme_sync)


def _send_setting_change_sync(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)


async def send_setting_change(param: str) -> None:
    await asyncio.to_thread(_send_setting_change_sync, param)


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


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


def _set_theme_registry_sync(light: bool) -> None:
    value = 1 if light else 0
    logger.debug("Setting Windows theme registry values 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)


async def set_theme(light: bool) -> None:
    await asyncio.to_thread(_set_theme_registry_sync, light)
    await notify_theme_changed()
    logger.debug("Windows theme updated")


def desired_theme_for_now(now: datetime | None = None) -> bool:
    now = now or datetime.now()
    minutes = now.hour * 60 + now.minute

    is_light = (
        int(CONFIG["day_start_minutes"])
        <= minutes
        < int(CONFIG["night_start_minutes"])
    )

    logger.debug(
        "Evaluated desired Windows theme at %s -> %s",
        now.isoformat(),
        "light" if is_light else "dark",
    )
    return is_light


def desired_terminal_color_scheme_for_now(now: datetime | None = None) -> str:
    now = now or datetime.now()

    scheme = (
        str(CONFIG["day_terminal_color_scheme"])
        if desired_theme_for_now(now)
        else str(CONFIG["night_terminal_color_scheme"])
    )

    logger.debug(
        "Evaluated desired Terminal color scheme at %s -> %s",
        now.isoformat(),
        scheme,
    )
    return scheme


def desired_vscode_theme_for_now(now: datetime | None = None) -> str:
    now = now or datetime.now()

    theme = (
        str(CONFIG["day_vscode_theme"])
        if desired_theme_for_now(now)
        else str(CONFIG["night_vscode_theme"])
    )

    logger.debug(
        "Evaluated desired VS Code theme at %s -> %s",
        now.isoformat(),
        theme,
    )
    return theme


async def read_json_file(path: Path) -> dict:
    try:
        async with aiofiles.open(path, "r", encoding="utf-8") as f:
            text = await f.read()
    except FileNotFoundError:
        return {}

    if not text.strip():
        return {}

    return json.loads(text)


async 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
    try:
        async with aiofiles.open(path, "r", encoding="utf-8") as f:
            old_text = await f.read()
    except FileNotFoundError:
        pass

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

    await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
    async with aiofiles.open(path, "w", encoding="utf-8", newline="\n") as f:
        await f.write(new_text)

    logger.debug("%s updated: %s", description, path)
    return True


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

    settings = await read_json_file(settings_path)

    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 await write_json_if_changed(
        settings_path,
        settings,
        "Windows Terminal settings.json",
    )


async def set_vscode_color_theme(theme_name: str) -> bool:
    settings_path = get_vscode_settings_path()
    settings = await read_json_file(settings_path)

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

    if current_theme == theme_name:
        return False

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


async def apply_explicit_theme(
    *,
    light: bool,
    terminal_color_scheme: str,
    vscode_theme: str,
) -> bool:
    current_theme_task = asyncio.create_task(get_current_theme())
    terminal_task = asyncio.create_task(
        set_windows_terminal_color_scheme(terminal_color_scheme)
    )
    vscode_task = asyncio.create_task(set_vscode_color_theme(vscode_theme))

    current_is_light = bool(await current_theme_task)

    theme_task: asyncio.Task[None] | None = None
    if current_is_light != light:
        theme_task = asyncio.create_task(set_theme(light))

    terminal_changed = await terminal_task
    vscode_changed = await vscode_task

    if theme_task is not None:
        await theme_task

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

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

    return light


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


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


async def apply_theme_for_now() -> bool:
    now = datetime.now()

    should_use_light_theme = desired_theme_for_now(now)
    desired_terminal_scheme = desired_terminal_color_scheme_for_now(now)
    desired_vscode_theme = desired_vscode_theme_for_now(now)

    current_theme_task = asyncio.create_task(get_current_theme())
    terminal_task = asyncio.create_task(
        set_windows_terminal_color_scheme(desired_terminal_scheme)
    )
    vscode_task = asyncio.create_task(set_vscode_color_theme(desired_vscode_theme))

    current_is_light = bool(await current_theme_task)

    theme_task: asyncio.Task[None] | None = None
    if current_is_light != should_use_light_theme:
        theme_task = asyncio.create_task(set_theme(should_use_light_theme))

    terminal_changed = await terminal_task
    vscode_changed = await vscode_task

    if theme_task is not None:
        await theme_task

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

    return should_use_light_theme


def next_switch_datetime(now: datetime | None = None) -> datetime:
    now = now or datetime.now()

    today_day_start = now.replace(
        hour=int(CONFIG["day_start_hour"]),
        minute=int(CONFIG["day_start_minute"]),
        second=0,
        microsecond=0,
    )

    today_night_start = now.replace(
        hour=int(CONFIG["night_start_hour"]),
        minute=int(CONFIG["night_start_minute"]),
        second=0,
        microsecond=0,
    )

    if now < today_day_start:
        next_dt = today_day_start
    elif now < today_night_start:
        next_dt = today_night_start
    else:
        next_dt = today_day_start + timedelta(days=1)

    logger.debug("Next scheduled switch time: %s", next_dt.isoformat())
    return next_dt


async def scheduler() -> None:
    await apply_theme_for_now()

    while True:
        next_dt = next_switch_datetime()
        await wait_until(next_dt)
        await apply_theme_for_now()


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

    mode_group = parser.add_mutually_exclusive_group()
    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()


async def async_main() -> None:
    global CONFIG
    CONFIG = await load_config()

    args = parse_args()

    if args.day:
        await apply_day_theme()
        return

    if args.night:
        await apply_night_theme()
        return

    await scheduler()


def main() -> None:
    asyncio.run(async_main())


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Top comments (0)