nixos/shared/modules/services/theme_switcher/theme_switcher.py
2026-01-23 13:44:07 -07:00

298 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Theme Switcher Script - Time-based light/dark theme switching.
This script:
1. Determines current theme mode (light/dark) based on time
2. Updates wpaperd config to use appropriate wallpaper directory
3. Restarts wpaperd to load new config
4. Gets the selected wallpaper from wpaperd
5. Generates pywal color palette from the wallpaper
6. Applies GTK/Kvantum/Helix themes
"""
import os
import subprocess
import sys
import time
import shutil
from datetime import datetime
from pathlib import Path
from typing import Optional
import logging
import argparse
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(levelname)s: %(message)s'
)
logger = logging.getLogger(__name__)
class ThemeSwitcher:
"""Handles time-based light/dark theme switching."""
def __init__(self, wallpaper_path: str, light_time: str, dark_time: str,
backend: str, rotation_interval: str, wpaperd_mode: str,
sorting: str, apply_themes_script: str):
self.wallpaper_path = Path(wallpaper_path)
self.light_hour = int(light_time.split(':')[0])
self.dark_hour = int(dark_time.split(':')[0])
self.backend = backend
self.rotation_interval = rotation_interval
self.wpaperd_mode = wpaperd_mode
self.sorting = sorting
self.apply_themes_script = apply_themes_script
self.home = Path.home()
# Find command paths
self.systemctl = self._find_command('systemctl')
self.wpaperctl = self._find_command('wpaperctl')
self.wal = self._find_command('wal')
def _find_command(self, cmd: str) -> str:
"""Find full path to a command."""
path = shutil.which(cmd)
if path is None:
# Try common NixOS locations
common_paths = [
f'/run/current-system/sw/bin/{cmd}',
f'/etc/profiles/per-user/{os.environ.get("USER", "")}/bin/{cmd}',
f'{self.home}/.nix-profile/bin/{cmd}',
]
for p in common_paths:
if Path(p).exists():
return p
raise FileNotFoundError(f"Command not found: {cmd}")
return path
def is_wpaperd_running(self) -> bool:
"""Check if wpaperd process is running."""
try:
pgrep = shutil.which('pgrep') or 'pgrep'
result = subprocess.run(
[pgrep, '-x', 'wpaperd'],
capture_output=True,
timeout=5
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
def determine_mode(self) -> tuple[str, Path]:
"""
Determine current theme mode based on time.
Returns:
Tuple of (mode_name, wallpaper_directory)
"""
current_hour = datetime.now().hour
if self.light_hour <= current_hour < self.dark_hour:
mode = "light"
wallpaper_dir = self.wallpaper_path / "Light"
else:
mode = "dark"
wallpaper_dir = self.wallpaper_path / "Dark"
return mode, wallpaper_dir
def update_wpaperd_config(self, wallpaper_dir: Path) -> None:
"""
Update wpaperd configuration file.
Args:
wallpaper_dir: Path to wallpaper directory (Light or Dark)
"""
config_dir = self.home / ".config/wpaperd"
config_file = config_dir / "config.toml"
# Ensure config directory exists
config_dir.mkdir(parents=True, exist_ok=True)
# Remove old config file
if config_file.exists():
config_file.unlink()
# Create new config
config_content = f"""[default]
path = "{wallpaper_dir}"
duration = "{self.rotation_interval}"
mode = "{self.wpaperd_mode}"
sorting = "{self.sorting}"
"""
config_file.write_text(config_content)
logger.info(f"Updated wpaperd config: {config_file}")
def restart_wpaperd(self) -> None:
"""Restart wpaperd systemd service."""
try:
subprocess.run(
[self.systemctl, '--user', 'restart', 'wpaperd.service'],
check=True,
timeout=10
)
logger.info("Restarted wpaperd service")
# Give wpaperd time to start and load wallpaper
time.sleep(2)
except subprocess.CalledProcessError as e:
logger.error(f"Failed to restart wpaperd: {e}")
raise
except subprocess.TimeoutExpired:
logger.error("Timeout restarting wpaperd")
raise
def get_current_wallpaper(self) -> Path:
"""
Get the current wallpaper path from wpaperd.
Returns:
Path to current wallpaper file
Raises:
RuntimeError: If unable to get wallpaper path
"""
wallpaper_state_dir = self.home / ".local/state/wpaperd/wallpapers"
if not wallpaper_state_dir.exists():
raise RuntimeError(f"Wpaperd state directory not found: {wallpaper_state_dir}")
# Get first monitor directory
try:
monitor_dirs = list(wallpaper_state_dir.iterdir())
if not monitor_dirs:
raise RuntimeError("No monitor directories found in wpaperd state")
monitor_name = monitor_dirs[0].name
# Query wpaperd for current wallpaper
result = subprocess.run(
[self.wpaperctl, 'get-wallpaper', monitor_name],
capture_output=True,
text=True,
check=True,
timeout=5
)
wallpaper_path = result.stdout.strip()
if not wallpaper_path:
raise RuntimeError("wpaperctl returned empty wallpaper path")
return Path(wallpaper_path)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, IndexError) as e:
raise RuntimeError(f"Failed to get current wallpaper: {e}")
def generate_palette(self, wallpaper: Path, mode: str) -> None:
"""
Generate pywal color palette from wallpaper.
Args:
wallpaper: Path to wallpaper image
mode: Theme mode ('light' or 'dark')
"""
cmd = [self.wal, '-i', str(wallpaper), '--backend', self.backend, '-n']
if mode == 'light':
cmd.append('-l')
try:
subprocess.run(cmd, check=True, timeout=30)
logger.info(f"Generated {mode} palette from: {wallpaper}")
except subprocess.CalledProcessError as e:
logger.error(f"Failed to generate palette: {e}")
raise
except subprocess.TimeoutExpired:
logger.error("Timeout while generating palette")
raise
def apply_themes(self) -> None:
"""Apply GTK and Kvantum themes using helper script."""
try:
subprocess.run(
['python3', self.apply_themes_script],
check=True,
timeout=30
)
logger.info("Applied themes successfully")
except subprocess.CalledProcessError as e:
logger.error(f"Failed to apply themes: {e}")
raise
except subprocess.TimeoutExpired:
logger.error("Timeout while applying themes")
raise
def run(self) -> int:
"""
Execute the theme switching process.
Returns:
Exit code (0 for success, 1 for failure)
"""
try:
# Determine mode and wallpaper directory
mode, wallpaper_dir = self.determine_mode()
logger.info(f"Switching to {mode} theme with wallpapers from {wallpaper_dir}")
# Check if wpaperd is running
if not self.is_wpaperd_running():
logger.error("wpaperd is not running")
return 1
# Update wpaperd config
self.update_wpaperd_config(wallpaper_dir)
# Restart wpaperd
self.restart_wpaperd()
# Get current wallpaper
current_wallpaper = self.get_current_wallpaper()
logger.info(f"Current wallpaper: {current_wallpaper}")
# Generate palette
self.generate_palette(current_wallpaper, mode)
# Apply themes
self.apply_themes()
logger.info(f"Theme switched to {mode} mode successfully!")
return 0
except Exception as e:
logger.error(f"Theme switching failed: {e}", exc_info=True)
return 1
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description='Time-based theme switcher')
parser.add_argument('--wallpaper-path', required=True, help='Path to wallpaper directories')
parser.add_argument('--light-time', default='09:00:00', help='Light theme start time (HH:MM:SS)')
parser.add_argument('--dark-time', default='16:30:00', help='Dark theme start time (HH:MM:SS)')
parser.add_argument('--backend', default='haishoku', help='Pywal color extraction backend')
parser.add_argument('--rotation-interval', default='5m', help='Wallpaper rotation interval')
parser.add_argument('--wpaperd-mode', default='center', help='Wpaperd display mode')
parser.add_argument('--sorting', default='random', help='Wallpaper sorting method')
parser.add_argument('--apply-themes-script', required=True, help='Path to apply-themes script')
args = parser.parse_args()
switcher = ThemeSwitcher(
wallpaper_path=args.wallpaper_path,
light_time=args.light_time,
dark_time=args.dark_time,
backend=args.backend,
rotation_interval=args.rotation_interval,
wpaperd_mode=args.wpaperd_mode,
sorting=args.sorting,
apply_themes_script=args.apply_themes_script
)
return switcher.run()
if __name__ == '__main__':
sys.exit(main())