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:
- It reads theme settings and schedule information from
config.toml. - It checks the current time.
- It determines whether the system should be in day mode or night mode.
- It applies the corresponding Windows, Windows Terminal, and VS Code themes.
- It waits until the next scheduled switch time.
- 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
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
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
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:
AppsUseLightThemeSystemUsesLightTheme
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
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
It changes the value of:
workbench.colorTheme
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()
Top comments (0)