#!/usr/bin/env python3 """ Wallpaper Rotation Script - Handles continuous wallpaper rotation with palette regeneration. This script: 1. Checks if wpaperd is running 2. Determines current theme mode (light/dark) for correct pywal flags 3. Pauses wpaperd's auto-rotation 4. Advances to next wallpaper 5. Gets the new wallpaper path 6. Regenerates pywal palette 7. Applies themes 8. Resumes wpaperd's auto-rotation """ import os import subprocess import sys import time import shutil from datetime import datetime from pathlib import Path import logging import argparse # Setup logging logging.basicConfig( level=logging.INFO, format='%(levelname)s: %(message)s' ) logger = logging.getLogger(__name__) class WallpaperRotator: """Handles wallpaper rotation with theme regeneration.""" def __init__(self, light_time: str, dark_time: str, backend: str, apply_themes_script: str): self.light_hour = int(light_time.split(':')[0]) self.dark_hour = int(dark_time.split(':')[0]) self.backend = backend self.apply_themes_script = apply_themes_script self.home = Path.home() # Find command paths 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, list[str]]: """ Determine current theme mode and pywal flags based on time. Returns: Tuple of (mode_name, pywal_flags_list) """ current_hour = datetime.now().hour if self.light_hour <= current_hour < self.dark_hour: mode = "light" pywal_flags = ['-l', '-n'] else: mode = "dark" pywal_flags = ['-n'] return mode, pywal_flags def pause_wpaperd(self) -> None: """Pause wpaperd's automatic wallpaper rotation.""" try: subprocess.run( [self.wpaperctl, 'pause-wallpaper'], check=True, timeout=5, capture_output=True ) logger.debug("Paused wpaperd rotation") except subprocess.CalledProcessError as e: logger.error(f"Failed to pause wpaperd: {e}") raise except subprocess.TimeoutExpired: logger.error("Timeout while pausing wpaperd") raise except FileNotFoundError: logger.error("wpaperctl command not found") raise def resume_wpaperd(self) -> None: """Resume wpaperd's automatic wallpaper rotation.""" try: subprocess.run( [self.wpaperctl, 'resume-wallpaper'], check=True, timeout=5, capture_output=True ) logger.debug("Resumed wpaperd rotation") except subprocess.CalledProcessError as e: logger.warning(f"Failed to resume wpaperd: {e}") except subprocess.TimeoutExpired: logger.warning("Timeout while resuming wpaperd") except FileNotFoundError: logger.warning("wpaperctl command not found") def advance_wallpaper(self) -> None: """Advance to next wallpaper using wpaperctl.""" try: subprocess.run( [self.wpaperctl, 'next-wallpaper'], check=True, timeout=5, capture_output=True ) logger.info("Advanced to next wallpaper") # Give wpaperd time to complete transition time.sleep(1) except subprocess.CalledProcessError as e: logger.error(f"Failed to advance wallpaper: {e}") raise except subprocess.TimeoutExpired: logger.error("Timeout while advancing wallpaper") raise except FileNotFoundError: logger.error("wpaperctl command not found") 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, pywal_flags: list[str]) -> None: """ Generate pywal color palette from wallpaper. Args: wallpaper: Path to wallpaper image pywal_flags: List of additional pywal flags """ cmd = [self.wal, '-i', str(wallpaper), '--backend', self.backend] + pywal_flags try: subprocess.run(cmd, check=True, timeout=30, capture_output=True) logger.info(f"Generated palette from: {wallpaper.name}") except subprocess.CalledProcessError as e: logger.error(f"Failed to generate palette: {e}") raise except subprocess.TimeoutExpired: logger.error("Timeout while generating palette") raise except FileNotFoundError: logger.error("wal command not found") 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, capture_output=True ) logger.debug("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 except FileNotFoundError as e: logger.error(f"Theme application script not found: {e}") raise def run(self) -> int: """ Execute the wallpaper rotation and theme regeneration process. Returns: Exit code (0 for success, 1 for failure) """ # Check if wpaperd is running if not self.is_wpaperd_running(): logger.info("wpaperd is not running, skipping rotation") return 0 try: # Determine mode mode, pywal_flags = self.determine_mode() logger.info(f"Rotating wallpaper and regenerating {mode} palette...") # Pause wpaperd self.pause_wpaperd() try: # Advance to next wallpaper self.advance_wallpaper() # Get new wallpaper path current_wallpaper = self.get_current_wallpaper() logger.info(f"New wallpaper: {current_wallpaper.name}") # Generate palette self.generate_palette(current_wallpaper, pywal_flags) # Apply themes self.apply_themes() logger.info("Wallpaper rotated and palette regenerated successfully!") return 0 finally: # Always resume wpaperd, even if something fails self.resume_wpaperd() except Exception as e: logger.error(f"Wallpaper rotation failed: {e}", exc_info=True) return 1 def main(): """Main entry point.""" parser = argparse.ArgumentParser(description='Wallpaper rotation with theme regeneration') 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('--apply-themes-script', required=True, help='Path to apply-themes script') args = parser.parse_args() rotator = WallpaperRotator( light_time=args.light_time, dark_time=args.dark_time, backend=args.backend, apply_themes_script=args.apply_themes_script ) return rotator.run() if __name__ == '__main__': sys.exit(main())