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

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


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


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)

    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,
    }


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 desired_theme_for_now(now: datetime | None = None) -> bool:
    now = now or datetime.now()
    minutes = now.hour * 60 + now.minute

    is_light = (
        CONFIG["day_start_minutes"]
        <= minutes
        < 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 = (
        CONFIG["day_terminal_color_scheme"]
        if desired_theme_for_now(now)
        else 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 = (
        CONFIG["day_vscode_theme"]
        if desired_theme_for_now(now)
        else CONFIG["night_vscode_theme"]
    )

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


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,
) -> bool:
    """
    時刻判定を使わず、指定されたテーマを即時適用する。
    CLI の --day / --night から使う。
    """
    current_is_light = bool(get_current_theme())

    if current_is_light != light:
        set_theme(light)

    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",
        "day/light" if light else "night/dark",
        terminal_color_scheme,
        vscode_theme,
    )

    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"],
    )


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"],
    )


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

    should_use_light_theme = desired_theme_for_now(now)
    current_is_light = bool(get_current_theme())

    if current_is_light != should_use_light_theme:
        set_theme(should_use_light_theme)

    desired_terminal_scheme = desired_terminal_color_scheme_for_now(now)
    terminal_changed = set_windows_terminal_color_scheme(desired_terminal_scheme)

    desired_vscode_theme = desired_vscode_theme_for_now(now)
    vscode_changed = set_vscode_color_theme(desired_vscode_theme)

    if terminal_changed or vscode_changed:
        try:
            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=CONFIG["day_start_hour"],
        minute=CONFIG["day_start_minute"],
        second=0,
        microsecond=0,
    )

    today_night_start = now.replace(
        hour=CONFIG["night_start_hour"],
        minute=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:
    apply_theme_for_now()

    while True:
        next_dt = next_switch_datetime()
        await wait_until(next_dt)
        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()


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

    if args.day:
        apply_day_theme()
        return

    if args.night:
        apply_night_theme()
        return

    asyncio.run(scheduler())


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

Top comments (0)