Hello, How do i create an extension that changes the light and dark themes automatically based on time? all previous attempts have failed

Unfortunately, I do not possess programming skills, so i asked claude for python extension. it created this:
“”"

Auto Theme Switcher for Anki
Automatically switches between dark and light mode based on time of day.
Configurable via Tools menu or config.json.
Styled to match Anki’s native Catppuccin-based color tokens.
“”"

import json
import os
from datetime import datetime

from aqt import mw, gui_hooks
from aqt.theme import theme_manager
from aqt.qt import (
QFontDatabase,
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox,
QCheckBox, QPushButton, QFrame, QTimer, Qt
)

─── FONT LOADER ──────────────────────────────────────────────────────────────

FONT_PATH = os.path.join(os.path.dirname(file), ‘Lexend.ttf’)
FONT_URL   = ‘https://github.com/googlefonts/lexend/raw/main/fonts/lexend/ttf/Lexend-Regular.ttf’

_lexend_loaded = False

def ensure_lexend():
global _lexend_loaded
if _lexend_loaded:
return
import urllib.request
if not os.path.exists(FONT_PATH):
try:
urllib.request.urlretrieve(FONT_URL, FONT_PATH)
except Exception:
return
QFontDatabase.addApplicationFont(FONT_PATH)
_lexend_loaded = True

─── CONFIG ───────────────────────────────────────────────────────────────────

ADDON_DIR   = os.path.dirname(file)
CONFIG_PATH = os.path.join(ADDON_DIR, “config.json”)

DEFAULT_CONFIG = {
“enabled”: True,
“dark_start”: 19,
“light_start”: 7,
“check_interval_seconds”: 60
}

def load_config() → dict:
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, “r”) as f:
data = json.load(f)
return {**DEFAULT_CONFIG, **data}
except Exception:
pass
return DEFAULT_CONFIG.copy()

def save_config(cfg: dict) → None:
with open(CONFIG_PATH, “w”) as f:
json.dump(cfg, f, indent=2)

─── THEME LOGIC ──────────────────────────────────────────────────────────────

_timer = None

def should_be_dark(cfg: dict) → bool:
“”"
Returns True if the current hour falls within the dark window.

Normal case  (dark_start > light_start, e.g. light=7, dark=19):
    dark  when hour >= 19 OR hour < 7   (i.e. 19:00–06:59)
    light when 7 <= hour < 19           (i.e. 07:00–18:59)

Inverted case (dark_start < light_start, e.g. dark=7, light=19):
    dark  when 7 <= hour < 19
    light otherwise
"""
hour        = datetime.now().hour
dark_start  = cfg["dark_start"]
light_start = cfg["light_start"]

if dark_start > light_start:
    # Standard: dark at night, light during the day
    return hour >= dark_start or hour < light_start
else:
    # Inverted: dark during the day (unusual but valid)
    return dark_start <= hour < light_start

def _set_theme(night_mode: bool) → None:
“”"
Apply theme app-wide using the correct API for each Anki version.

Modern path  (Anki 2.1.50+ / 25.x): mw.pm.set_theme(BuiltinTheme)
  — writes to profile and triggers Anki's own full redraw chain.
Legacy path  (pre-2.1.50): theme_manager.set_night_mode()

apply_style() is intentionally never called directly — its signature
and availability has changed repeatedly across Anki versions.
"""
if not mw:
    return

# ── Modern path ────────────────────────────────────────────────────────
try:
    from aqt.theme import BuiltinTheme
    target = BuiltinTheme.DARK if night_mode else BuiltinTheme.LIGHT
    if mw.pm.theme() == target:
        return  # already correct, nothing to do

    mw.pm.set_theme(target)
    mw.reset()

    if hasattr(mw, "toolbar") and mw.toolbar:
        mw.toolbar.draw()

    if hasattr(mw, "web") and mw.web:
        mw.web.reload()

    return
except (ImportError, AttributeError):
    pass

# ── Legacy path ────────────────────────────────────────────────────────
if theme_manager.night_mode != night_mode:
    theme_manager.set_night_mode(night_mode)
    mw.reset()

    if hasattr(mw, "toolbar") and mw.toolbar:
        mw.toolbar.draw()

    if hasattr(mw, "web") and mw.web:
        mw.web.reload()

def apply_theme() → None:
cfg = load_config()
if not cfg.get(“enabled”, True):
return
_set_theme(should_be_dark(cfg))

def restart_timer(cfg: dict) → None:
global _timer
if _timer is not None:
_timer.stop()
if mw and cfg.get(“enabled”, True):
interval_ms = cfg.get(“check_interval_seconds”, 60) * 1000
_timer = QTimer(mw)
_timer.timeout.connect(apply_theme)
_timer.start(interval_ms)

─── STYLE HELPERS ────────────────────────────────────────────────────────────

def get_stylesheet() → str:
night = theme_manager.night_mode
i = 2 if night else 1

t = {
    "canvas":           ("#eff1f5", "#24273a")[i - 1],
    "canvas_elevated":  ("#e6e9ef", "#1e2030")[i - 1],
    "canvas_inset":     ("#dce0e8", "#181926")[i - 1],
    "fg":               ("#4c4f69", "#cad3f5")[i - 1],
    "fg_faint":         ("#7c7f93", "#939ab7")[i - 1],
    "fg_subtle":        ("#5c5f77", "#b8c0e0")[i - 1],
    "border":           ("#bcc0cc", "#494d64")[i - 1],
    "border_subtle":    ("#ccd0da", "#363a4f")[i - 1],
    "border_focus":     ("#dc8a78", "#f4dbd6")[i - 1],
    "btn_bg":           ("#ccd0da", "#363a4f")[i - 1],
    "btn_primary_bg":   "#2f67e1",
    "btn_primary_end":  "#2544a8",
}

return f"""
    QDialog {{ background-color: {t['canvas']}; color: {t['fg']}; }}
    QLabel {{ color: {t['fg']}; font-family: 'Lexend', sans-serif; font-size: 13px; }}
    QLabel#title {{ font-size: 16px; font-weight: 600; }}
    QLabel#subtitle {{ font-size: 11px; color: {t['fg_faint']}; }}
    QLabel#field {{ font-size: 13px; color: {t['fg_subtle']}; min-width: 170px; }}
    QLabel#preview {{ font-size: 11px; color: {t['fg_faint']}; font-style: italic;
                      background-color: {t['canvas_inset']}; border: 1px solid {t['border_subtle']};
                      border-radius: 6px; padding: 6px 10px; }}
    QSpinBox {{ background-color: {t['canvas_elevated']}; color: {t['fg']};
                border: 1px solid {t['border']}; border-radius: 5px; padding: 5px 8px; }}
    QSpinBox:focus {{ border: 1px solid {t['border_focus']}; }}
    QCheckBox {{ color: {t['fg']}; font-size: 13px; spacing: 8px; }}
    QPushButton {{ background-color: {t['btn_bg']}; color: {t['fg']};
                   border: 1px solid {t['border']}; border-radius: 5px; padding: 7px 18px; }}
    QPushButton#save {{ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
                         stop:0 {t['btn_primary_bg']}, stop:1 {t['btn_primary_end']});
                         color: #ffffff; border: none; font-weight: 600; }}
    QPushButton#cancel {{ background-color: transparent; color: {t['fg_faint']};
                           border: 1px solid {t['border_subtle']}; }}
    QFrame#divider {{ background-color: {t['border_subtle']}; max-height: 1px; }}
"""

─── SETTINGS DIALOG ──────────────────────────────────────────────────────────

class ThemeSettingsDialog(QDialog):
def init(self, parent=None):
super().init(parent)
self.cfg = load_config()
self.setWindowTitle(“Auto Theme Switcher”)
self.setFixedWidth(420)
self.setModal(True)
self._build_ui()
# Re-skin dialog if Anki’s theme changes externally while it’s open
gui_hooks.theme_did_change.append(self._on_theme_changed)

def _on_theme_changed(self) -> None:
    self.setStyleSheet(get_stylesheet())

def closeEvent(self, event):
    try:
        gui_hooks.theme_did_change.remove(self._on_theme_changed)
    except ValueError:
        pass
    super().closeEvent(event)

def _build_ui(self):
    ensure_lexend()
    self.setStyleSheet(get_stylesheet())
    root = QVBoxLayout(self)
    root.setContentsMargins(24, 24, 24, 20)

    title = QLabel("🌗 Auto Theme Switcher")
    title.setObjectName("title")
    root.addWidget(title)

    subtitle = QLabel("Switches Anki between dark and light mode on a schedule.")
    subtitle.setObjectName("subtitle")
    root.addWidget(subtitle)
    root.addSpacing(10)

    self.enabled_cb = QCheckBox("Enable automatic theme switching")
    self.enabled_cb.setChecked(self.cfg.get("enabled", True))
    self.enabled_cb.stateChanged.connect(self._update_preview)
    root.addWidget(self.enabled_cb)
    root.addSpacing(10)

    self.light_spin    = QSpinBox()
    self.dark_spin     = QSpinBox()
    self.interval_spin = QSpinBox()

    root.addLayout(self._spin_row("☀️  Light mode starts at",   self.light_spin,    0,  23, self.cfg.get("light_start", 7),                suffix=":00"))
    root.addLayout(self._spin_row("🌙  Dark mode starts at",    self.dark_spin,     0,  23, self.cfg.get("dark_start", 19),                suffix=":00"))
    root.addLayout(self._spin_row("🔁  Check every (seconds)",  self.interval_spin, 10, 3600, self.cfg.get("check_interval_seconds", 60), step=10))

    self.preview_lbl = QLabel()
    self.preview_lbl.setObjectName("preview")
    self.preview_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
    root.addWidget(self.preview_lbl)
    self._update_preview()

    btn_row = QHBoxLayout()
    btn_row.addStretch()
    cancel_btn = QPushButton("Cancel")
    cancel_btn.setObjectName("cancel")
    cancel_btn.clicked.connect(self.reject)
    save_btn = QPushButton("Save && Apply")
    save_btn.setObjectName("save")
    save_btn.clicked.connect(self._save)
    btn_row.addWidget(cancel_btn)
    btn_row.addWidget(save_btn)
    root.addLayout(btn_row)

def _spin_row(self, text, spin, min_v, max_v, val, step=1, suffix=""):
    row = QHBoxLayout()
    lbl = QLabel(text)
    spin.setRange(min_v, max_v)
    spin.setValue(val)
    spin.setSingleStep(step)
    spin.setSuffix(suffix)
    spin.valueChanged.connect(self._update_preview)
    row.addWidget(lbl)
    row.addStretch()
    row.addWidget(spin)
    return row

def _update_preview(self):
    if not self.enabled_cb.isChecked():
        self.preview_lbl.setText("Auto switching is disabled.")
        return
    now     = datetime.now().hour
    is_dark = should_be_dark({
        "dark_start":  self.dark_spin.value(),
        "light_start": self.light_spin.value()
    })
    mode = "🌙 Dark" if is_dark else "☀️ Light"
    self.preview_lbl.setText(f"Right now ({now}:xx) → {mode} mode")

def _save(self):
    light = self.light_spin.value()
    dark  = self.dark_spin.value()

    if light == dark:
        from aqt.utils import showWarning
        showWarning("Light and dark start times cannot be the same hour.")
        return

    self.cfg.update({
        "enabled":                self.enabled_cb.isChecked(),
        "dark_start":             dark,
        "light_start":            light,
        "check_interval_seconds": self.interval_spin.value()
    })
    save_config(self.cfg)
    restart_timer(self.cfg)
    apply_theme()
    self.accept()

─── MENU & STARTUP ───────────────────────────────────────────────────────────

def open_settings():
ThemeSettingsDialog(mw).exec()

def toggle_theme():
“”“Immediately flip the theme, bypassing the schedule.”“”
_set_theme(not theme_manager.night_mode)

def on_main_window_init():
if mw:
a = mw.form.menuTools.addAction(“🌗 Auto Theme Switcher…”)
a.triggered.connect(open_settings)
t = mw.form.menuTools.addAction(“🔀 Toggle Theme Now”)
t.triggered.connect(toggle_theme)
cfg = load_config()
apply_theme()
restart_timer(cfg)

gui_hooks.main_window_did_init.append(on_main_window_init)

This one worked perfectly till after some time it automatically switched again to dark mode with some elements being/appearing as if they are in white mode.

even at morning when its supposed to be in light mode, it wasnt fully application wide. only the main UI was white. not the menu bars which were still dark.

My Operating system doesnt change light automatically so i cant choose the “System” theme in anki preferences. so i was seeking an extension.

Thank you

1 Like

This looks like a lot of code. I recommend prompting Claude to get rid of all custom style manipulation and just call theme_manager.set_night_mode().

3 Likes

Same old problem. it still changes to dark with some elements still appearing as if white after some time. i will now disable this onigiri ui and try again here is some images to aid. the updated code by claude is also attached below

Notice that the menu bar and the Deck, add, browse, sync buttons are dark, while main UI is white. and after some time, it changes to this below.

Below is the normal dark mode for reference

"""
Auto Theme Switcher for Anki
Automatically switches between day and night mode based on the current time.
Checks every minute and switches at configurable sunrise/sunset times.
"""

from datetime import time as dtime
import datetime

from aqt import mw
from aqt.qt import QTimer
from aqt.theme import theme_manager

# ── Configuration ─────────────────────────────────────────────────────────────
# Edit these times to control when night mode kicks in and out.
NIGHT_START = dtime(20, 0)   # 8:00 PM  → night mode ON
NIGHT_END   = dtime(6, 30)   # 6:30 AM  → night mode OFF

# How often (in milliseconds) to re-check the time.
CHECK_INTERVAL_MS = 60_000   # every 60 seconds
# ──────────────────────────────────────────────────────────────────────────────


def _should_be_night() -> bool:
    """Return True if the current local time falls in the night window."""
    now = datetime.datetime.now().time()
    if NIGHT_START <= NIGHT_END:
        # Night window doesn't cross midnight (e.g. 22:00 – 06:00 is cross-midnight,
        # but 08:00 – 20:00 would be a daytime-only window — unusual but supported).
        return NIGHT_START <= now < NIGHT_END
    else:
        # Window crosses midnight: night if now >= NIGHT_START OR now < NIGHT_END
        return now >= NIGHT_START or now < NIGHT_END


def _apply_theme() -> None:
    """Switch to night or day mode if the current setting doesn't match."""
    want_night = _should_be_night()
    # theme_manager.night_mode is True when night mode is currently active.
    if theme_manager.night_mode != want_night:
        theme_manager.set_night_mode(want_night)
        # Refresh all open windows so colours update immediately.
        if mw:
            mw.reset()


def _start_timer() -> None:
    """Apply theme immediately, then start the periodic check."""
    _apply_theme()

    timer = QTimer(mw)
    timer.timeout.connect(_apply_theme)
    timer.start(CHECK_INTERVAL_MS)


# Run after Anki's main window is fully ready.
if mw:
    mw.progress.timer(500, _start_timer, False)

Thank you.

You should always make sure to test your add-on with no other add-on involved, especially ones that do heavy UI modifications such as Onigiri.

Well, the test still came out negative with onigiri disabled, It still defaults to dark mode after some time with some elements in white. I will try other modifications or tweaks.