#!/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())