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

307 lines
10 KiB
Python
Executable File

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