diff --git a/bombadil.toml b/bombadil.toml index 48a80a6..02537e9 100644 --- a/bombadil.toml +++ b/bombadil.toml @@ -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 } diff --git a/eww/eww.yuck b/eww/eww.yuck index dc58e80..539a155 100644 --- a/eww/eww.yuck +++ b/eww/eww.yuck @@ -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")) diff --git a/eww/scripts/wallpaper-shell b/eww/scripts/wallpaper-shell new file mode 100755 index 0000000..daa799a --- /dev/null +++ b/eww/scripts/wallpaper-shell @@ -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 diff --git a/scripts/lib/swww.py b/scripts/lib/swww.py deleted file mode 100644 index 394ba01..0000000 --- a/scripts/lib/swww.py +++ /dev/null @@ -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))) - diff --git a/scripts/lib/wallpaper.py b/scripts/lib/wallpaper.py new file mode 100644 index 0000000..4d633ba --- /dev/null +++ b/scripts/lib/wallpaper.py @@ -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) diff --git a/scripts/swww_change.py b/scripts/swww_change.py deleted file mode 100755 index 7483f5e..0000000 --- a/scripts/swww_change.py +++ /dev/null @@ -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) - diff --git a/scripts/swww_set.py b/scripts/swww_set.py deleted file mode 100755 index 299c1f6..0000000 --- a/scripts/swww_set.py +++ /dev/null @@ -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) - diff --git a/scripts/wallpaper.py b/scripts/wallpaper.py new file mode 100755 index 0000000..4c20b21 --- /dev/null +++ b/scripts/wallpaper.py @@ -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() diff --git a/systemd/wallpaper.service b/systemd/wallpaper.service new file mode 100644 index 0000000..ec66bfb --- /dev/null +++ b/systemd/wallpaper.service @@ -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 diff --git a/systemd/wallpaper.timer b/systemd/wallpaper.timer new file mode 100644 index 0000000..14081a9 --- /dev/null +++ b/systemd/wallpaper.timer @@ -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