Massively rework wallpaper script

Now runs automatically every 15 minutes, always updates to new backgrounds, and tries to reduce the blue light level at night
This commit is contained in:
Michael Bradley 2025-06-09 00:07:31 -04:00
parent c1c8a83ec5
commit 8bee11d5fb
Signed by: MichaelBradley
SSH key fingerprint: SHA256:BKO2eI2LPsCbQS3n3i5SdwZTAIV3F1lHezR07qP+Ob0
10 changed files with 262 additions and 65 deletions

View file

@ -19,13 +19,15 @@ discord = { source = "config/electron-flags.conf", target = ".config/discord-fla
1password = { source = "autostart/1password.desktop", target = ".config/autostart/1password.desktop", direct = true }
megasync = { source = "autostart/megasync.desktop", target = ".config/autostart/megasync.desktop", direct = true }
# Also needs a one-time `systemctl --user enable --now eww-daemon.service eww-bars.service ip-geolocation.service sunset.timer swww-daemon.service'
# Also needs a one-time `systemctl --user enable --now eww-daemon.service eww-bars.service ip-geolocation.service sunset.timer swww-daemon.service wallpaper.timer'
eww-daemon = { source = "systemd/eww-daemon.service", target = ".config/systemd/user/eww-daemon.service", direct = true }
eww-bars = { source = "systemd/eww-bars.service", target = ".config/systemd/user/eww-bars.service" }
ip-geolocation = { source = "systemd/ip-geolocation.service", target = ".config/systemd/user/ip-geolocation.service", direct = true }
sunset-service = { source = "systemd/sunset.service", target = ".config/systemd/user/sunset.service", direct = true }
sunset-timer = { source = "systemd/sunset.timer", target = ".config/systemd/user/sunset.timer", direct = true }
swww-daemon = { source = "systemd/swww-daemon.service", target = ".config/systemd/user/swww-daemon.service", direct = true }
wallpaper-service = { source = "systemd/wallpaper.service", target = ".config/systemd/user/wallpaper.service", direct = true }
wallpaper-timer = { source = "systemd/wallpaper.timer", target = ".config/systemd/user/wallpaper.timer", direct = true }
btop = { source = "btop", target = ".config/btop", direct = true }
dunst = { source = "dunst", target = ".config/dunst", direct = true }

View file

@ -120,7 +120,7 @@
;(defwidget colour_selector []
;(clicker :text "" :command "uwsm-app -- hyprpicker -a"))
(defwidget wallpaper []
(clicker :text "󰸉" :command "~/scripts/swww_change.py"))
(clicker :text "󰸉" :command "~/.config/eww/scripts/wallpaper-shell"))
(defwidget sunset []
(clicker :text "" :command "python -OO ~/scripts/sunset.py"))

4
eww/scripts/wallpaper-shell Executable file
View file

@ -0,0 +1,4 @@
#!/bin/env sh
# EWW doesn't seem to like listening to Python scripts directly, but this wrapper seems to work fine
python -OO ~/scripts/wallpaper.py

View file

@ -1,45 +0,0 @@
"""Basic functions for interacting with swww"""
from json import loads
from pathlib import Path
from random import sample
from shlex import split
from socket import gethostname
from subprocess import run
IS_DESKTOP = gethostname() == "chonk"
MONITORS = ("DP-1", "DP-2") if IS_DESKTOP else ("eDP-1",)
ANGLE_TRANSITION_ARGS = [
"--transition-type", "wipe",
"--transition-angle", "30",
"--transition-step", "45",
"--transition-fps", "165" if IS_DESKTOP else "120",
]
IMMEDIATE_TRANSITION_ARGS = [
"--transition-step", "255",
]
def _run_cmd(arg0: str, *args: str) -> str:
return run([arg0, *args] if args else split(arg0), capture_output=True).stdout.decode("utf-8")
def monitors() -> list[str]:
return [monitor["name"] for monitor in loads(_run_cmd("hyprctl monitors -j"))]
def run_swww(monitor: str, image: Path, extra_args: list[str] | None = None) -> None:
run(["swww", "img", "-o", monitor, image, *(extra_args or [])])
def get_wallpapers() -> list[Path]:
return list(Path("~/Pictures/wallpapers/").expanduser().glob("*"))
def sample_wallpapers(displays: list[str] | None = None) -> "zip[tuple[str, Path]]":
if displays is None:
displays = monitors()
return zip(displays, sample(get_wallpapers(), len(displays)))

216
scripts/lib/wallpaper.py Normal file
View file

@ -0,0 +1,216 @@
"""Script to update the wallpaper"""
from abc import ABC, abstractmethod
from json import dumps, loads
from math import ceil, floor, isnan, nan
from os import environ
from pathlib import Path
from random import sample
from re import Pattern, compile, match
from subprocess import run
from time import sleep
from typing import cast, override
from .evening import on_steam, sunup_amount
# Python sets aren't insertion-ordered, but dict keys are
type OrderedSet[T] = dict[T, None]
class Background(ABC):
_colour: Pattern[str] = compile(r"^[a-fA-F0-9]{6}$")
@abstractmethod
def blue_light(self) -> float: ...
@abstractmethod
@override
def __eq__(self, value: object, /) -> bool: ...
@abstractmethod
@override
def __hash__(self) -> int: ...
@abstractmethod
@override
def __str__(self) -> str: ...
@classmethod
def parse(cls, arg: str) -> "Background":
if match(cls._colour, arg):
return Colour(int(arg, 16))
else:
return Wallpaper(Path(arg))
class Wallpaper(Background):
_cache: Path = Path(environ["XDG_CACHE_HOME"]) / "wallpaper_blue_light.json"
_wallpapers: Path = Path("~/Pictures/wallpapers/").expanduser()
def __init__(self, image: Path) -> None:
self._path: Path = image
self._blue_light: float = nan
def _calculate_blue_light(self) -> float:
from PIL import Image
data = cast(list[int], Image.open(self._path).getdata(2))
return sum(data) / len(data)
@classmethod
def _read_cache(cls) -> dict[str, float]:
if cls._cache.exists():
if (text := cls._cache.read_text().strip()):
if isinstance((obj := loads(text)), dict):
return cast(dict[str, float], obj)
return {}
@classmethod
def _write_cache(cls, cache: dict[str, float]) -> None:
stringified = dumps(cache)
if len(stringified) != cls._cache.write_text(stringified):
raise Exception("Did not fully write blue light cache to file")
@classmethod
def available(cls) -> "OrderedSet[Background]":
cache = cls._read_cache()
modified = False
wallpapers = {str(path): Wallpaper(path) for path in cls._wallpapers.iterdir()}
for wallpaper in wallpapers.values():
cached = cache.get(str(wallpaper._path))
if cached is None or isnan(cached):
print("New file:", wallpaper._path)
cached = wallpaper._calculate_blue_light()
modified = True
cache[str(wallpaper._path)] = cached
wallpaper._blue_light = cached
if modified:
cache = {path: score for (path, score) in sorted(cache.items(), key=lambda pair: pair[1])}
cls._write_cache(cache)
return {wallpapers[path]: None for path in cache.keys()}
@override
def blue_light(self) -> float:
return self._blue_light
@override
def __eq__(self, value: object, /) -> bool:
if isinstance(value, Wallpaper):
return self._path == value._path
return False
@override
def __hash__(self) -> int:
return hash(self._path)
@override
def __str__(self) -> str:
return str(self._path)
@override
def __repr__(self) -> str:
return f"Wallpaper({repr(self._path)})"
class Colour(Background):
def __init__(self, value: int) -> None:
self._value: int = value
@override
def blue_light(self) -> float:
return self._value & 0xFF
@override
def __eq__(self, value: object, /) -> bool:
if isinstance(value, Colour):
return self._value == value._value
return False
@override
def __hash__(self) -> int:
return hash(self._value)
@override
def __str__(self) -> str:
return hex(self._value)
@override
def __repr__(self) -> str:
return f"Colour({hex(self._value)})"
def _convert_bytes(output: bytes) -> str:
return output.decode("utf-8").strip()
def _swww_cmd(*args: str) -> str:
output = run(["swww", *args], capture_output=True)
if output.stderr:
raise Exception(f"swww returned: '{_convert_bytes(output.stderr)}'")
return _convert_bytes(output.stdout)
class Monitor:
_query: Pattern[str] = compile(r"^([^:]+): (\d+)x(\d+), scale: ([\d.]+), currently displaying: (?:image|color): (.+)$")
_transition_args: list[str] = [
"--transition-type=wipe",
"--transition-angle=30",
"--transition-step=45",
"--transition-fps=120",
]
def __init__(self, line: str) -> None:
if not (values := match(self._query, line)):
raise Exception(f"Failed to extract information from: '{line}'")
self._name: str = values[1]
self._resolution: tuple[int, int] = (int(values[2]), int(values[3]))
self._scale: float = float(values[4])
self._background: Background = Background.parse(values[5])
def is_colour(self) -> bool:
return isinstance(self._background, Colour)
def set_wallpaper(self, background: Background) -> None:
self._background = background
_ = _swww_cmd("img", *self._transition_args, f"--outputs={self._name}", str(self._background))
def get_background(self) -> Background:
return self._background
@classmethod
def current(cls) -> "list[Monitor]":
return [Monitor(line) for line in _swww_cmd("query").splitlines()]
@override
def __eq__(self, value: object, /) -> bool:
if isinstance(value, Monitor):
return self._background == value._background
return False
@override
def __repr__(self) -> str:
return f"Monitor('{self._name}: {self._resolution[0]}x{self._resolution[1]}, scale: {self._scale}, currently displaying: {"color" if self.is_colour() else "image"}: {self._background}')"
@override
def __str__(self) -> str:
return f"{self._name}: {self._background}"
def select_wallpapers(available: list[Background]) -> list[Background]:
radius = len(available) / 3
centre = sunup_amount() * len(available)
return available[max(0, floor(centre - radius)): min(len(available) + 1, ceil(centre + radius))]
def main(check_steam: bool = False) -> None:
if check_steam and on_steam():
return
monitors = Monitor.current()
wallpapers = Wallpaper.available()
for monitor in monitors:
wallpapers.pop(monitor.get_background(), None)
for monitor, wallpaper in zip(monitors, sample(select_wallpapers(list(wallpapers.keys())), len(monitors))):
monitor.set_wallpaper(wallpaper)
sleep(1.25)

View file

@ -1,9 +0,0 @@
#!/bin/env python
"""Selects new backgrounds for the monitors"""
from lib.swww import ANGLE_TRANSITION_ARGS, run_swww, sample_wallpapers
for monitor, wallpaper in sample_wallpapers():
run_swww(monitor, wallpaper, ANGLE_TRANSITION_ARGS)

View file

@ -1,9 +0,0 @@
#!/bin/env python
"""Sets a new background for the monitors immediately"""
from lib.swww import IMMEDIATE_TRANSITION_ARGS, run_swww, sample_wallpapers
for monitor, wallpaper in sample_wallpapers():
run_swww(monitor, wallpaper, IMMEDIATE_TRANSITION_ARGS)

20
scripts/wallpaper.py Executable file
View file

@ -0,0 +1,20 @@
#!/bin/env python
from argparse import ArgumentParser, Namespace
from typing import cast
from lib.wallpaper import main
class Arguments(Namespace):
check_steam: bool = False
def call_from_args() -> None:
parser = ArgumentParser(description="Adjusts the wallpaper based on the time of day")
_ = parser.add_argument("-s", "--check-steam", action="store_true", help="Don't adjust the wallpaper if Steam is active")
args = cast(Arguments, parser.parse_args())
main(args.check_steam)
if __name__ == "__main__":
call_from_args()

View file

@ -0,0 +1,6 @@
[Unit]
Description=Sets the wallpaper based on the time of day
[Service]
Type=oneshot
ExecStart=/bin/env python -OO /home/mbradley/scripts/wallpaper.py --check-steam

12
systemd/wallpaper.timer Normal file
View file

@ -0,0 +1,12 @@
[Unit]
Description=Changes the wallpaper over the course of the day to ease eye strain
After=graphical-session.target
After=swww-daemon.service
[Timer]
OnCalendar=*:0/15
Persistent=true
AccuracySec=5s
[Install]
WantedBy=timers.target