From d502580e3fe95e6a35e0bb355a8a2e8f069d92eb Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 11 Nov 2026 16:31:06 +0100 Subject: [PATCH] Dark wallpaper rotator: new style every 3 h Six text-free styles (gradient, flow/Voronoi, habit heatmap, shutdown clock, Earth day/night, plasma) replace the white i3 root. A 15-minute systemd user timer picks a new random style per monitor every 3 hours, unlike its previous one and the other monitor's; live styles refresh in between. Also syncs i3/config with the live config and prunes the oldest evidence artifact for the 60-file cap. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWX51FMD58yKUC1eYWf --- .../wallpaper-rotator-2026-11-11.json | 14 ++ .../evidence/js-anki-deck-2026-10-04.json | 38 ----- .../wallpaper-rotator-2026-11-11.json | 39 +++++ linux_configuration/i3/config | 48 +++++- .../i3/install_wallpaper_rotator.sh | 49 ++++++ .../i3/wallpaper-rotator.service | 14 ++ .../i3/wallpaper-rotator.timer | 9 + python_pkg/wallpaper_rotator/__init__.py | 7 + python_pkg/wallpaper_rotator/__main__.py | 11 ++ python_pkg/wallpaper_rotator/_context.py | 36 ++++ python_pkg/wallpaper_rotator/_paint.py | 59 +++++++ python_pkg/wallpaper_rotator/_palette.py | 41 +++++ python_pkg/wallpaper_rotator/_rotation.py | 118 +++++++++++++ python_pkg/wallpaper_rotator/_style_clock.py | 95 +++++++++++ python_pkg/wallpaper_rotator/_style_earth.py | 64 +++++++ .../wallpaper_rotator/_style_gradient.py | 32 ++++ .../wallpaper_rotator/_style_heatmap.py | 117 +++++++++++++ python_pkg/wallpaper_rotator/_style_plasma.py | 40 +++++ .../wallpaper_rotator/_style_procedural.py | 109 ++++++++++++ python_pkg/wallpaper_rotator/_tick.py | 88 ++++++++++ python_pkg/wallpaper_rotator/styles.py | 46 ++++++ .../wallpaper_rotator/tests/__init__.py | 1 + .../wallpaper_rotator/tests/test_context.py | 21 +++ .../wallpaper_rotator/tests/test_main.py | 33 ++++ .../wallpaper_rotator/tests/test_paint.py | 66 ++++++++ .../tests/test_procedural.py | 39 +++++ .../wallpaper_rotator/tests/test_rotation.py | 155 +++++++++++++++++ .../tests/test_style_data.py | 125 ++++++++++++++ .../wallpaper_rotator/tests/test_styles.py | 50 ++++++ .../wallpaper_rotator/tests/test_tick.py | 156 ++++++++++++++++++ 30 files changed, 1681 insertions(+), 39 deletions(-) create mode 100644 docs/superpowers/contracts/wallpaper-rotator-2026-11-11.json delete mode 100644 docs/superpowers/evidence/js-anki-deck-2026-10-04.json create mode 100644 docs/superpowers/evidence/wallpaper-rotator-2026-11-11.json create mode 100755 linux_configuration/i3/install_wallpaper_rotator.sh create mode 100644 linux_configuration/i3/wallpaper-rotator.service create mode 100644 linux_configuration/i3/wallpaper-rotator.timer create mode 100644 python_pkg/wallpaper_rotator/__init__.py create mode 100644 python_pkg/wallpaper_rotator/__main__.py create mode 100644 python_pkg/wallpaper_rotator/_context.py create mode 100644 python_pkg/wallpaper_rotator/_paint.py create mode 100644 python_pkg/wallpaper_rotator/_palette.py create mode 100644 python_pkg/wallpaper_rotator/_rotation.py create mode 100644 python_pkg/wallpaper_rotator/_style_clock.py create mode 100644 python_pkg/wallpaper_rotator/_style_earth.py create mode 100644 python_pkg/wallpaper_rotator/_style_gradient.py create mode 100644 python_pkg/wallpaper_rotator/_style_heatmap.py create mode 100644 python_pkg/wallpaper_rotator/_style_plasma.py create mode 100644 python_pkg/wallpaper_rotator/_style_procedural.py create mode 100644 python_pkg/wallpaper_rotator/_tick.py create mode 100644 python_pkg/wallpaper_rotator/styles.py create mode 100644 python_pkg/wallpaper_rotator/tests/__init__.py create mode 100644 python_pkg/wallpaper_rotator/tests/test_context.py create mode 100644 python_pkg/wallpaper_rotator/tests/test_main.py create mode 100644 python_pkg/wallpaper_rotator/tests/test_paint.py create mode 100644 python_pkg/wallpaper_rotator/tests/test_procedural.py create mode 100644 python_pkg/wallpaper_rotator/tests/test_rotation.py create mode 100644 python_pkg/wallpaper_rotator/tests/test_style_data.py create mode 100644 python_pkg/wallpaper_rotator/tests/test_styles.py create mode 100644 python_pkg/wallpaper_rotator/tests/test_tick.py diff --git a/docs/superpowers/contracts/wallpaper-rotator-2026-11-11.json b/docs/superpowers/contracts/wallpaper-rotator-2026-11-11.json new file mode 100644 index 00000000..d9aebceb --- /dev/null +++ b/docs/superpowers/contracts/wallpaper-rotator-2026-11-11.json @@ -0,0 +1,14 @@ +{ + "title": "Desktop background: six dark styles, new one every 3 hours", + "objective": "Replace the white i3 root window with a dark, text-free background that changes every 3 hours to a random style different from the previous one on that monitor and from the other monitor's.", + "acceptance_criteria": [ + "Six styles (gradient, procedural, heatmap, clock, earth, plasma) render the exact output size with mean brightness below 80/255", + "plan() rotates after 3 h to styles unlike each monitor's previous and unlike each other; inside 3 h static styles stay, live styles redraw, a lost root pixmap repaints", + "install_wallpaper_rotator.sh leaves wallpaper-rotator.timer enabled and active and paints once" + ], + "out_of_scope": [ + "Animated backgrounds", + "Any text on the background" + ], + "verifier": "python3 -m pytest python_pkg/wallpaper_rotator/tests --cov=python_pkg.wallpaper_rotator --cov-branch --cov-fail-under=100; pre-commit run --files " +} diff --git a/docs/superpowers/evidence/js-anki-deck-2026-10-04.json b/docs/superpowers/evidence/js-anki-deck-2026-10-04.json deleted file mode 100644 index 47b0d861..00000000 --- a/docs/superpowers/evidence/js-anki-deck-2026-10-04.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "intent": "Generate a verified Anki .apkg deck so JS/TS Array, String, Map/Set/Object/Math and sort/destructuring idioms can be memorized for LeetCode.", - "scope": [ - "New package python_pkg/js_anki (models, verify, deck, cli, runner.js, 3 JSON data files, tests)", - "genanki added to meta/requirements-test.txt and the pylint hook additional_dependencies", - "Non-goals: no type-in cards, no add-ons, .apkg is a gitignored build artifact" - ], - "changes": [ - "98 entries -> 199 flip/self-grade cards (task, behaviour, argument-order) in JS::Array, JS::String and JS::Extras subdecks plus ::Rare tiers", - "Every example is executed in Node during the build and the build exits 1 if any result differs from the stated one", - "Deterministic model/deck ids and note GUIDs so re-importing updates cards instead of duplicating" - ], - "verification": [ - { - "command": "PYTHONPATH=. python3 -m python_pkg.js_anki build", - "result": "pass", - "evidence": "verified 98 examples; wrote 199 cards" - }, - { - "command": "pytest python_pkg/js_anki --cov=python_pkg.js_anki --cov-branch", - "result": "pass", - "evidence": "24 passed, 100% line and branch coverage" - }, - { - "command": "manual: anki js_methods.apkg", - "result": "pass", - "evidence": "User imported into desktop Anki and confirmed the cards look ok" - } - ], - "risks": [ - "Node must be on PATH to build the deck", - "LeetCode's Node version may lack ES2023 methods (toSorted, findLast) on older runtimes" - ], - "rollback": [ - "git revert the commit; delete python_pkg/js_anki/build/", - "Remove the imported JS deck from Anki" - ] -} diff --git a/docs/superpowers/evidence/wallpaper-rotator-2026-11-11.json b/docs/superpowers/evidence/wallpaper-rotator-2026-11-11.json new file mode 100644 index 00000000..12881e5f --- /dev/null +++ b/docs/superpowers/evidence/wallpaper-rotator-2026-11-11.json @@ -0,0 +1,39 @@ +{ + "intent": "The empty i3 desktop shows a dark, text-free background instead of blinding white, changing every 3 hours to a different random style per monitor.", + "scope": [ + "python_pkg/wallpaper_rotator (new package, tests)", + "linux_configuration/i3: config exec line, wallpaper-rotator.service/.timer, install_wallpaper_rotator.sh", + "linux_configuration/i3/config synced from the live ~/.config/i3/config (adds the guard exec lines the repo lacked)", + "Non-goal: animation, text, per-reveal rotation (dropped at the user's request)" + ], + "changes": [ + "Six numpy/PIL styles in the Dracula palette: gradient+grain, flow field/Voronoi, habit heatmap from guard ledgers, shutdown clock from /etc/shutdown-schedule.conf, Earth day/night from NASA Blue/Black Marble, plasma still", + "_rotation.plan() decides rotate/refresh/repaint from a JSON state; _tick composes per-output images into one root image and sets it with feh", + "systemd user oneshot service + 15-minute timer; i3 exec paints at login" + ], + "verification": [ + { + "command": "python3 -m pytest python_pkg/wallpaper_rotator/tests --cov=python_pkg.wallpaper_rotator --cov-branch --cov-fail-under=100", + "result": "pass", + "evidence": "53 passed, TOTAL 434 stmts 44 branches, 100.00% branch coverage" + }, + { + "command": "~/data/claude-scratch/wallpaper/e2e_timer.sh (private Xvfb :77 + nested i3, run 3x)", + "result": "pass", + "evidence": "tick1 painted; tick2 unchanged (static) or painted (live style); tick3 after 3 h painted a different style; PASS x3" + }, + { + "command": "install_wallpaper_rotator.sh on the live session", + "result": "pass", + "evidence": "timer active, next 16:15; state DP-0 gradient, HDMI-0 clock; user confirmed ok on screen" + } + ], + "risks": [ + "NASA asset URLs move again: the earth style is then skipped (available() false), others unaffected", + "Render CPU every 15 min while a live style is shown (~1-3 s at Nice 19)" + ], + "rollback": [ + "systemctl --user disable --now wallpaper-rotator.timer; git revert the commit", + "Check that the root reverts to its plain colour after the next login" + ] +} diff --git a/linux_configuration/i3/config b/linux_configuration/i3/config index 129d8496..865b7408 100644 --- a/linux_configuration/i3/config +++ b/linux_configuration/i3/config @@ -23,6 +23,37 @@ font pango:System San Francisco Display, FontAwesome 8 # https://wiki.archlinux.org/index.php/XDG_Autostart exec --no-startup-id dex --autostart --environment i3 +# OpenTabletDriver daemon (Huion HS64 -> DP-0 mapping). Started here because i3 +# does not reliably activate graphical-session.target, which the packaged +# systemd user service depends on. This guarantees the tablet works on login. +exec --no-startup-id systemctl --user start opentabletdriver.service + +# Workout screen locker. Same reason as the line above: the unit is +# WantedBy=graphical-session.target, and if i3 does not activate that target +# the login run never happens -- which on 2026-08-30 left the machine +# unenforced from a 14:06 reboot until the next timer tick. The run is +# idempotent (its early-exit ladder handles "already logged", "weekly minimum +# met", "window still active"), so starting it here as well costs nothing. +exec --no-startup-id systemctl --user start workout-locker.service + +# LeetCode gate. Same reason again: WantedBy=graphical-session.target is not a +# guarantee under i3, and on 2026-09-13 the timer's boot-time catch-up died +# before X existed and burned the day's slot. The run is idempotent (a settled +# day exits "already unlocked" with no window) and waits for X itself. +exec --no-startup-id systemctl --user start leetcode-guard.service + +# Book gate (book-guard). Same reason as the two above: WantedBy=graphical- +# session.target is not a guarantee under i3. The run is idempotent (on pace, +# a free day, an escape or the morning-session carrot all exit without a +# window), so starting it here as well costs nothing. +exec --no-startup-id systemctl --user start book-guard.service + +# Wallpaper rotator (python_pkg/wallpaper_rotator): a dark, text-free +# background, new every 3 hours via a 15-minute systemd timer. This start +# paints right at login instead of at the next tick. Installed by +# linux_configuration/i3/install_wallpaper_rotator.sh. +exec --no-startup-id systemctl --user start wallpaper-rotator.service + # The combination of xss-lock, nm-applet and pactl is a popular choice, so # they are included here as an example. Modify as you see fit. @@ -34,6 +65,11 @@ exec --no-startup-id xss-lock --transfer-sleep-lock -- i3lock --nofork # and nm-applet is a desktop environment-independent system tray GUI for it. exec --no-startup-id nm-applet +# Workout screen-locker status now lives in the i3blocks bar +# ([screen_locker], clicking it opens http://127.0.0.1:8770). The GTK tray icon +# it replaced had been commented out here since 2026-08-21, which is part of +# why a miscounted week went unnoticed for two of them. + # Keep screen awake and unlocked; also treat controller input as activity exec --no-startup-id bash /home/kuhy/src/testsAndMisc/linux_configuration/utils/turn_off_auto_idle_screen_shutdown.sh --watch-controller @@ -217,4 +253,14 @@ bar { } } -bindsym $mod+d exec "dmenu_run -nf '#F8F8F2' -nb '#282A36' -sb '#6272A4' -sf '#F8F8F2' -fn 'monospace-10' -p 'dmenu%'" +bindsym $mod+d exec "PATH=$HOME/.local/bin:$PATH dmenu_run -nf '#F8F8F2' -nb '#282A36' -sb '#6272A4' -sf '#F8F8F2' -fn 'monospace-10' -p 'dmenu%'" + + + +# leetcode-guard status tray. Left-click TOGGLES the status window +# (open/close); right-click gives refresh and quit. +exec --no-startup-id /usr/bin/python3 /home/kuhy/.config/i3/scripts/leetcode_guard_tray.py + +# control-panel fleet tray. Left-click opens the dashboard; +# right-click lists every service and its own dashboards. +exec --no-startup-id /usr/bin/python3 /home/kuhy/.config/i3/scripts/control_panel_tray.py diff --git a/linux_configuration/i3/install_wallpaper_rotator.sh b/linux_configuration/i3/install_wallpaper_rotator.sh new file mode 100755 index 00000000..adc10cbd --- /dev/null +++ b/linux_configuration/i3/install_wallpaper_rotator.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Install the wallpaper rotator: Earth images, the systemd user service and +# its 15-minute timer, then run one tick. Rerunnable: images are kept, units +# are re-linked. +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly UNIT="wallpaper-rotator" +readonly EARTH_DIR="${HOME}/.cache/wallpaper_rotator/earth" +readonly NASA="https://assets.science.nasa.gov/content/dam/science/esd/eo/images" +# NASA Earth Observatory, public domain: Blue Marble Next Generation +# (December, topography + bathymetry) and Black Marble 2012 night lights. +readonly DAY_URL="${NASA}/bmng/bmng-topography-bathymetry/december/world.topo.bathy.200412.3x5400x2700.jpg" +readonly NIGHT_URL="${NASA}/imagerecords/79000/79765/dnb_land_ocean_ice.2012.3600x1800.jpg" + +ensure_packages() { + local missing=() + command -v feh >/dev/null || missing+=(feh) + /usr/bin/python3 -c 'import numpy' 2>/dev/null || missing+=(python-numpy) + /usr/bin/python3 -c 'import PIL' 2>/dev/null || missing+=(python-pillow) + if ((${#missing[@]})); then + sudo pacman -S --needed --noconfirm "${missing[@]}" + fi +} + +fetch() { + local url="$1" dest="$2" + [[ -s "$dest" ]] && return 0 + curl -fsSL --retry 3 -o "${dest}.part" "$url" + mv "${dest}.part" "$dest" +} + +main() { + ensure_packages + mkdir -p "$EARTH_DIR" "${HOME}/.config/systemd/user" + fetch "$DAY_URL" "${EARTH_DIR}/day.jpg" + fetch "$NIGHT_URL" "${EARTH_DIR}/night.jpg" + local kind + for kind in service timer; do + ln -sf "${SCRIPT_DIR}/${UNIT}.${kind}" "${HOME}/.config/systemd/user/${UNIT}.${kind}" + done + systemctl --user daemon-reload + systemctl --user enable --now "${UNIT}.timer" + systemctl --user start "${UNIT}.service" + echo "wallpaper-rotator timer: $(systemctl --user is-active "${UNIT}.timer")" +} + +main "$@" diff --git a/linux_configuration/i3/wallpaper-rotator.service b/linux_configuration/i3/wallpaper-rotator.service new file mode 100644 index 00000000..93279e90 --- /dev/null +++ b/linux_configuration/i3/wallpaper-rotator.service @@ -0,0 +1,14 @@ +[Unit] +Description=Rotate or refresh the desktop background (one tick) + +[Service] +Type=oneshot +WorkingDirectory=%h/src/testsAndMisc +Environment=DISPLAY=:0 +Environment=XAUTHORITY=%h/.Xauthority +# i3-msg finds the live socket via the X root property; a stale I3SOCK +# inherited from an older session would point at a dead socket. +UnsetEnvironment=I3SOCK +ExecStart=/usr/bin/python3 -m python_pkg.wallpaper_rotator +Nice=19 +IOSchedulingClass=idle diff --git a/linux_configuration/i3/wallpaper-rotator.timer b/linux_configuration/i3/wallpaper-rotator.timer new file mode 100644 index 00000000..b6480847 --- /dev/null +++ b/linux_configuration/i3/wallpaper-rotator.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Desktop background tick every 15 minutes (new style every 3 hours) + +[Timer] +OnCalendar=*:0/15 +AccuracySec=1min + +[Install] +WantedBy=timers.target diff --git a/python_pkg/wallpaper_rotator/__init__.py b/python_pkg/wallpaper_rotator/__init__.py new file mode 100644 index 00000000..26590b9c --- /dev/null +++ b/python_pkg/wallpaper_rotator/__init__.py @@ -0,0 +1,7 @@ +"""Rotate the i3 desktop background among six dark, text-free styles. + +A systemd timer ticks every 15 minutes. Every three hours each monitor gets a +random style different from its previous one and from the other monitor's; +between rotations the data-driven styles (shutdown clock, habit heatmap, +Earth day/night) redraw so they stay current. +""" diff --git a/python_pkg/wallpaper_rotator/__main__.py b/python_pkg/wallpaper_rotator/__main__.py new file mode 100644 index 00000000..dff19148 --- /dev/null +++ b/python_pkg/wallpaper_rotator/__main__.py @@ -0,0 +1,11 @@ +"""Entry point for the systemd timer: ``python3 -m python_pkg.wallpaper_rotator``.""" + +from __future__ import annotations + +import logging + +from python_pkg.wallpaper_rotator._tick import tick + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(message)s") + logging.getLogger(__name__).info("painted" if tick() else "unchanged") diff --git a/python_pkg/wallpaper_rotator/_context.py b/python_pkg/wallpaper_rotator/_context.py new file mode 100644 index 00000000..5d63e355 --- /dev/null +++ b/python_pkg/wallpaper_rotator/_context.py @@ -0,0 +1,36 @@ +"""Inputs a style may read, injectable for tests.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +DEFAULT_CACHE = Path.home() / ".cache" / "wallpaper_rotator" + + +@dataclass(frozen=True) +class RenderContext: + """Where the data-driven styles read from, and what time it is. + + Attributes: + now: Current local time (timezone-aware). + data_home: ``~/.local/share``, holding the guards' ledgers. + earth_dir: Holds ``day.jpg`` and ``night.jpg`` (see install.sh). + shutdown_conf: screen-locker's schedule file. + """ + + now: datetime + data_home: Path + earth_dir: Path + shutdown_conf: Path + + @classmethod + def live(cls) -> RenderContext: + """Build the context for the running desktop.""" + return cls( + now=datetime.now().astimezone(), + data_home=Path.home() / ".local" / "share", + earth_dir=DEFAULT_CACHE / "earth", + shutdown_conf=Path("/etc/shutdown-schedule.conf"), + ) diff --git a/python_pkg/wallpaper_rotator/_paint.py b/python_pkg/wallpaper_rotator/_paint.py new file mode 100644 index 00000000..7589714d --- /dev/null +++ b/python_pkg/wallpaper_rotator/_paint.py @@ -0,0 +1,59 @@ +"""Compose per-monitor images into one root image and hand it to feh.""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING, cast + +from PIL import Image + +from python_pkg.wallpaper_rotator import _palette + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from pathlib import Path + +Rect = tuple[int, int, int, int] + + +def layout(outputs: Sequence[Mapping[str, object]]) -> dict[str, Rect]: + """Map each active output from ``i3-msg -t get_outputs`` to (x, y, w, h).""" + rects: dict[str, Rect] = {} + for output in outputs: + if not output.get("active"): + continue + rect = cast("Mapping[str, int]", output["rect"]) + rects[str(output["name"])] = ( + rect["x"], + rect["y"], + rect["width"], + rect["height"], + ) + return rects + + +def compose( + rects: Mapping[str, Rect], images: Mapping[str, Image.Image] +) -> Image.Image: + """Paste each output's image at its position on one root-sized canvas.""" + width = max((x + w for x, _, w, _ in rects.values()), default=1) + height = max((y + h for _, y, _, h in rects.values()), default=1) + root = Image.new("RGB", (width, height), _palette.DEEP) + for name, (x, y, _, _) in rects.items(): + if name in images: + root.paste(images[name], (x, y)) + return root + + +def apply(root: Image.Image, target: Path) -> None: + """Write the root image and set it as the X background with feh. + + One image spanning the whole root avoids relying on feh's Xinerama + screen order matching i3's output names. + """ + target.parent.mkdir(parents=True, exist_ok=True) + root.save(target, compress_level=1) + subprocess.run( + ["/usr/bin/feh", "--no-fehbg", "--no-xinerama", "--bg-tile", str(target)], + check=True, + ) diff --git a/python_pkg/wallpaper_rotator/_palette.py b/python_pkg/wallpaper_rotator/_palette.py new file mode 100644 index 00000000..d9adf0d0 --- /dev/null +++ b/python_pkg/wallpaper_rotator/_palette.py @@ -0,0 +1,41 @@ +"""Dracula-family colours shared by every style (matches the i3bar).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from numpy.typing import NDArray + +Rgb = tuple[int, int, int] + +DEEP: Rgb = (0x1A, 0x1B, 0x24) +BG: Rgb = (0x28, 0x2A, 0x36) +LINE: Rgb = (0x44, 0x47, 0x5A) +COMMENT: Rgb = (0x62, 0x72, 0xA4) +CYAN: Rgb = (0x8B, 0xE9, 0xFD) +GREEN: Rgb = (0x50, 0xFA, 0x7B) +ORANGE: Rgb = (0xFF, 0xB8, 0x6C) +PINK: Rgb = (0xFF, 0x79, 0xC6) +PURPLE: Rgb = (0xBD, 0x93, 0xF9) +RED: Rgb = (0xFF, 0x55, 0x55) +ACCENTS: tuple[Rgb, ...] = (CYAN, GREEN, ORANGE, PINK, PURPLE, RED) + + +def mix(a: Rgb, b: Rgb, t: float) -> Rgb: + """Linear blend from ``a`` (t=0) to ``b`` (t=1).""" + return ( + round(a[0] + (b[0] - a[0]) * t), + round(a[1] + (b[1] - a[1]) * t), + round(a[2] + (b[2] - a[2]) * t), + ) + + +def ramp(t: NDArray[np.float64], stops: tuple[Rgb, ...]) -> NDArray[np.uint8]: + """Map values in [0, 1] through evenly spaced colour stops to RGB.""" + xs = np.linspace(0.0, 1.0, len(stops)) + clipped = np.clip(t, 0.0, 1.0) + channels = [np.interp(clipped, xs, [stop[c] for stop in stops]) for c in range(3)] + return np.stack(channels, axis=-1).round().astype(np.uint8) diff --git a/python_pkg/wallpaper_rotator/_rotation.py b/python_pkg/wallpaper_rotator/_rotation.py new file mode 100644 index 00000000..6bd70541 --- /dev/null +++ b/python_pkg/wallpaper_rotator/_rotation.py @@ -0,0 +1,118 @@ +"""Decide, on each timer tick, which style each monitor shows and whether to paint. + +A new style comes every three hours, never the one that monitor just had and +never the one the other monitor shows. Between rotations the data-driven +styles redraw so their content stays current, and everything repaints when +the root pixmap is gone (new X session, or something else set a background). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from datetime import datetime, timedelta +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from pathlib import Path + import random + +ROTATE_EVERY = timedelta(hours=3) +# Styles whose picture depends on the time or on ledgers, not just the seed. +LIVE_STYLES = frozenset({"clock", "earth", "heatmap"}) + + +@dataclass(frozen=True) +class Pick: + """One monitor's style and the seed that makes its picture reproducible.""" + + style: str + seed: int + + +@dataclass(frozen=True) +class State: + """What is on screen, since when, and which root pixmap carries it.""" + + rotated_at: datetime + picks: dict[str, Pick] = field(default_factory=dict) + root_id: str | None = None + + def save(self, path: Path) -> None: + """Write the state as JSON, creating the parent directory.""" + path.parent.mkdir(parents=True, exist_ok=True) + data = { + "rotated_at": self.rotated_at.isoformat(), + "picks": {o: [p.style, p.seed] for o, p in self.picks.items()}, + "root_id": self.root_id, + } + path.write_text(json.dumps(data), encoding="utf-8") + + @classmethod + def load(cls, path: Path) -> State | None: + """Read a saved state; ``None`` when missing or unreadable.""" + try: + data = json.loads(path.read_text(encoding="utf-8")) + picks = {o: Pick(str(s), int(n)) for o, (s, n) in data["picks"].items()} + return cls( + datetime.fromisoformat(data["rotated_at"]), picks, data["root_id"] + ) + except OSError, ValueError, KeyError, TypeError, AttributeError: + return None + + +def choose( + previous: Mapping[str, str], + outputs: Sequence[str], + names: Sequence[str], + rng: random.Random, +) -> dict[str, str]: + """Pick a style per output, unlike its previous one and unlike each other.""" + picked: dict[str, str] = {} + for output in outputs: + taken = {previous.get(output), *picked.values()} + options = [n for n in names if n not in taken] or list(names) + picked[output] = rng.choice(options) + return picked + + +def plan( + state: State | None, + outputs: Sequence[str], + now: datetime, + names: Sequence[str], + rng: random.Random, + *, + root_id: str | None, +) -> tuple[State, bool]: + """Return the state to show and whether the root needs painting. + + Args: + state: The saved state, or ``None`` on the first run. + outputs: Active output names. + now: Current time. + names: Styles available right now. + rng: Randomness for styles and seeds. + root_id: The root pixmap id now on the X server, if any. + """ + due = state is None or now - state.rotated_at >= ROTATE_EVERY + if state is None or due: + previous = {o: p.style for o, p in state.picks.items()} if state else {} + styles = choose(previous, outputs, names, rng) + picks = {o: Pick(s, rng.getrandbits(32)) for o, s in styles.items()} + return State(now, picks, state.root_id if state else None), True + missing = [o for o in outputs if o not in state.picks] + if missing: + others = {o: p.style for o, p in state.picks.items()} + styles = choose( + {}, missing, [n for n in names if n not in others.values()] or names, rng + ) + picks = { + **state.picks, + **{o: Pick(s, rng.getrandbits(32)) for o, s in styles.items()}, + } + return replace(state, picks=picks), True + lost = root_id is None or root_id != state.root_id + live = any(state.picks[o].style in LIVE_STYLES for o in outputs) + return state, lost or live diff --git a/python_pkg/wallpaper_rotator/_style_clock.py b/python_pkg/wallpaper_rotator/_style_clock.py new file mode 100644 index 00000000..f06e56ef --- /dev/null +++ b/python_pkg/wallpaper_rotator/_style_clock.py @@ -0,0 +1,95 @@ +"""Style 4: the time left until tonight's shutdown, as colour and an arc.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from PIL import Image, ImageDraw + +from python_pkg.wallpaper_rotator import _palette + +if TYPE_CHECKING: + from pathlib import Path + + from python_pkg.wallpaper_rotator._context import RenderContext + +_DEFAULT_SHUTDOWN = 23 * 60 +_DEFAULT_MORNING = 5 * 60 +_LAST_MON_WED = 2 # Monday..Wednesday use the early shutdown +_WARN_MINUTES = 120 # colour moves blue -> red over the last two hours +_CALM = _palette.mix(_palette.DEEP, _palette.COMMENT, 0.45) +_HALF = 0.5 +_ALARM = (0x5A, 0x16, 0x22) + + +def _read_conf(conf: Path) -> dict[str, int]: + values: dict[str, int] = {} + try: + text = conf.read_text(encoding="utf-8") + except OSError: + return values + for line in text.splitlines(): + key, sep, value = line.partition("=") + if sep and value.strip().isdigit(): + values[key.strip()] = int(value.strip()) + return values + + +def schedule(conf: Path, weekday: int) -> tuple[int, int]: + """Return (morning end, shutdown) in minutes after midnight for a weekday.""" + values = _read_conf(conf) + key = "MON_WED_MINUTES" if weekday <= _LAST_MON_WED else "THU_SUN_MINUTES" + return ( + values.get("MORNING_END_MINUTES", _DEFAULT_MORNING), + values.get(key, _DEFAULT_SHUTDOWN), + ) + + +def _glow(tone: _palette.Rgb, size: tuple[int, int]) -> Image.Image: + """A radial wash from ``tone`` at the centre to DEEP at the corners.""" + width, height = size + small_w, small_h = max(width // 4, 2), max(height // 4, 2) + ys, xs = np.indices((small_h, small_w), dtype=np.float64) + radial = np.hypot(xs / small_w - 0.5, ys / small_h - 0.5) / 0.71 + glow = Image.fromarray(_palette.ramp(radial, (tone, _palette.DEEP)), "RGB") + return glow.resize(size, Image.Resampling.BICUBIC) + + +def _draw_arcs( + image: Image.Image, tone: _palette.Rgb, urgency: float, share: float +) -> None: + """Draw the dim full ring and, over it, the arc of day still left.""" + width, height = image.size + radius = min(width, height) * 0.32 + box = ( + width / 2 - radius, + height / 2 - radius, + width / 2 + radius, + height / 2 + radius, + ) + line = max(round(min(width, height) / 180), 1) + draw = ImageDraw.Draw(image) + draw.arc( + box, 0, 360, fill=_palette.mix(_palette.DEEP, _palette.LINE, 0.9), width=line + ) + if share > 0: + bright = _palette.mix( + tone, _palette.CYAN if urgency < _HALF else _palette.RED, 0.5 + ) + draw.arc(box, -90, -90 + 360 * share, fill=bright, width=line) + + +def render( + ctx: RenderContext, size: tuple[int, int], _rng: np.random.Generator +) -> Image.Image: + """Tint the screen by urgency; a thin arc shows the share of day left.""" + morning, shutdown = schedule(ctx.shutdown_conf, ctx.now.weekday()) + minute = ctx.now.hour * 60 + ctx.now.minute + left = max(shutdown - minute, 0) + urgency = 1.0 - min(left, _WARN_MINUTES) / _WARN_MINUTES + tone = _palette.mix(_CALM, _ALARM, urgency) + image = _glow(tone, size) + share = min(left / max(shutdown - morning, 1), 1.0) + _draw_arcs(image, tone, urgency, share) + return image diff --git a/python_pkg/wallpaper_rotator/_style_earth.py b/python_pkg/wallpaper_rotator/_style_earth.py new file mode 100644 index 00000000..af40a5a7 --- /dev/null +++ b/python_pkg/wallpaper_rotator/_style_earth.py @@ -0,0 +1,64 @@ +"""Style 5: the Earth now, lit side from Blue Marble, dark side from Black Marble. + +Images: NASA Earth Observatory, public domain; fetched by install.sh. +""" + +from __future__ import annotations + +from datetime import UTC +from typing import TYPE_CHECKING + +import numpy as np +from PIL import Image + +from python_pkg.wallpaper_rotator import _palette + +if TYPE_CHECKING: + from python_pkg.wallpaper_rotator._context import RenderContext + +_DAY = "day.jpg" +_NIGHT = "night.jpg" +_TWILIGHT = 0.12 # width of the soft terminator, in cos(zenith) +_DAY_DIM = 0.45 # keep the lit side well below "blinding" + + +def available(ctx: RenderContext) -> bool: + """Both source images are on disk.""" + return (ctx.earth_dir / _DAY).is_file() and (ctx.earth_dir / _NIGHT).is_file() + + +def sun_position(ctx: RenderContext) -> tuple[float, float]: + """Approximate subsolar (latitude, longitude) in radians for ``ctx.now``.""" + utc = ctx.now.astimezone(UTC) + day_of_year = utc.timetuple().tm_yday + declination = np.radians(-23.44) * np.cos(2 * np.pi * (day_of_year + 10) / 365) + hours = utc.hour + utc.minute / 60 + return float(declination), float(np.radians((12.0 - hours) * 15.0)) + + +def _load(ctx: RenderContext, name: str, size: tuple[int, int]) -> np.ndarray: + with Image.open(ctx.earth_dir / name) as raw: + rgb = raw.convert("RGB").resize(size, Image.Resampling.LANCZOS) + return np.asarray(rgb, dtype=np.float64) + + +def render( + ctx: RenderContext, size: tuple[int, int], _rng: np.random.Generator +) -> Image.Image: + """Blend day and night maps along the real terminator, letterboxed.""" + width, height = size + map_w = width + map_h = min(width // 2, height) + lat = np.linspace(np.pi / 2, -np.pi / 2, map_h)[:, None] + lon = np.linspace(-np.pi, np.pi, map_w)[None, :] + sun_lat, sun_lon = sun_position(ctx) + cos_zenith = np.sin(lat) * np.sin(sun_lat) + np.cos(lat) * np.cos(sun_lat) * np.cos( + lon - sun_lon + ) + lit = np.clip(cos_zenith / _TWILIGHT + 0.5, 0.0, 1.0)[..., None] + day = _load(ctx, _DAY, (map_w, map_h)) * _DAY_DIM + night = _load(ctx, _NIGHT, (map_w, map_h)) + earth = (day * lit + night * (1.0 - lit)).round().astype(np.uint8) + image = Image.new("RGB", size, _palette.DEEP) + image.paste(Image.fromarray(earth, "RGB"), (0, (height - map_h) // 2)) + return image diff --git a/python_pkg/wallpaper_rotator/_style_gradient.py b/python_pkg/wallpaper_rotator/_style_gradient.py new file mode 100644 index 00000000..64ce70f4 --- /dev/null +++ b/python_pkg/wallpaper_rotator/_style_gradient.py @@ -0,0 +1,32 @@ +"""Style 1: a dark diagonal gradient with film grain.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from PIL import Image + +from python_pkg.wallpaper_rotator import _palette + +if TYPE_CHECKING: + from python_pkg.wallpaper_rotator._context import RenderContext + +_GRAIN = 3 + + +def render( + _ctx: RenderContext, size: tuple[int, int], rng: np.random.Generator +) -> Image.Image: + """Blend BG into DEEP along a random diagonal, then add faint noise.""" + width, height = size + angle = rng.uniform(0.0, 2.0 * np.pi) + # The ramp is smooth: compute it at a quarter size, then upscale. + small_w, small_h = max(width // 4, 2), max(height // 4, 2) + ys, xs = np.indices((small_h, small_w), dtype=np.float64) + proj = xs / small_w * np.cos(angle) + ys / small_h * np.sin(angle) + t = (proj - proj.min()) / max(float(np.ptp(proj)), 1e-9) + small = Image.fromarray(_palette.ramp(t, (_palette.BG, _palette.DEEP)), "RGB") + base = np.asarray(small.resize(size, Image.Resampling.BICUBIC), dtype=np.int16) + grain = rng.integers(-_GRAIN, _GRAIN + 1, size=(height, width, 1), dtype=np.int16) + return Image.fromarray(np.clip(base + grain, 0, 255).astype(np.uint8), "RGB") diff --git a/python_pkg/wallpaper_rotator/_style_heatmap.py b/python_pkg/wallpaper_rotator/_style_heatmap.py new file mode 100644 index 00000000..eb24ba7e --- /dev/null +++ b/python_pkg/wallpaper_rotator/_style_heatmap.py @@ -0,0 +1,117 @@ +"""Style 3: a year of habits as four stacked day grids, colour only.""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass +from datetime import date, timedelta +import json +from typing import TYPE_CHECKING, cast + +from PIL import Image, ImageDraw + +from python_pkg.wallpaper_rotator import _palette + +if TYPE_CHECKING: + from pathlib import Path + + import numpy as np + + from python_pkg.wallpaper_rotator._context import RenderContext + +# Ledger directory under ~/.local/share -> the colour of its grid. +HABITS: tuple[tuple[str, _palette.Rgb], ...] = ( + ("workout_locker", _palette.GREEN), + ("leetcode_guard", _palette.ORANGE), + ("anki_guard", _palette.CYAN), + ("book_guard", _palette.PURPLE), +) +WEEKS = 52 +_EMPTY = _palette.mix(_palette.BG, _palette.LINE, 0.5) +_FULL_AT = 3 # credits in a day that light a cell fully + + +def credit_days(ledger: Path) -> Counter[date]: + """Count the non-zero ``credit`` rows per day in one guard ledger. + + A missing or unreadable ledger yields no days: the grid stays empty + rather than the background failing. + """ + try: + data = json.loads(ledger.read_text(encoding="utf-8")) + except OSError, ValueError: + return Counter() + rows = data.get("entries", []) if isinstance(data, dict) else [] + days: Counter[date] = Counter() + for row in cast("list[object]", rows): + if not isinstance(row, dict) or row.get("kind") != "credit": + continue + if row.get("amount", 1) == 0: + continue + try: + days[date.fromisoformat(str(row.get("day")))] += 1 + except ValueError: + continue + return days + + +@dataclass(frozen=True) +class _Grid: + """Where the day cells sit: first day, left edge, cell pitch and size.""" + + today: date + start: date + left: float + pitch: float + cell: float + + +def _cell_colour(count: int, colour: _palette.Rgb) -> _palette.Rgb: + if count <= 0: + return _EMPTY + return _palette.mix(_EMPTY, colour, 0.35 + 0.45 * min(count, _FULL_AT) / _FULL_AT) + + +def _draw_band( + draw: ImageDraw.ImageDraw, + grid: _Grid, + band_top: float, + days: Counter[date], + colour: _palette.Rgb, +) -> None: + """Fill one habit's 7-row grid, oldest day at the left.""" + for offset in range((grid.today - grid.start).days + 1): + day = grid.start + timedelta(days=offset) + x = grid.left + (offset // 7) * grid.pitch + y = band_top + day.weekday() * grid.pitch + draw.rectangle( + (x, y, x + grid.cell, y + grid.cell), + fill=_cell_colour(days[day], colour), + ) + + +def render( + ctx: RenderContext, size: tuple[int, int], _rng: np.random.Generator +) -> Image.Image: + """Draw one 7-row x 53-column grid per habit, today at the bottom right.""" + width, height = size + image = Image.new("RGB", size, _palette.DEEP) + draw = ImageDraw.Draw(image) + today = ctx.now.date() + columns = WEEKS + 1 + band_gap = 2 # cells of space between habits + rows = 7 * len(HABITS) + band_gap * (len(HABITS) - 1) + pitch = min(width * 0.86 / columns, height * 0.8 / rows) + grid = _Grid( + today=today, + start=today - timedelta(days=today.weekday() + 7 * WEEKS), + left=(width - pitch * columns) / 2, + pitch=pitch, + cell=max(pitch * 0.82, 1.0), + ) + top = (height - pitch * rows) / 2 + for band, (folder, colour) in enumerate(HABITS): + days = credit_days(ctx.data_home / folder / "ledger.json") + band_top = top + band * (7 + band_gap) * pitch + _draw_band(draw, grid, band_top, days, colour) + return image diff --git a/python_pkg/wallpaper_rotator/_style_plasma.py b/python_pkg/wallpaper_rotator/_style_plasma.py new file mode 100644 index 00000000..9861d9b6 --- /dev/null +++ b/python_pkg/wallpaper_rotator/_style_plasma.py @@ -0,0 +1,40 @@ +"""Style 6: a frozen frame of a slow plasma shader, in dark palette tones.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from PIL import Image + +from python_pkg.wallpaper_rotator import _palette + +if TYPE_CHECKING: + from python_pkg.wallpaper_rotator._context import RenderContext + +# Smooth field: render at a quarter and upscale, a 16x cheaper pass. +_DOWNSCALE = 4 +_WAVES = 4 + + +def render( + _ctx: RenderContext, size: tuple[int, int], rng: np.random.Generator +) -> Image.Image: + """Sum random sine waves, map through a dark ramp with one dim accent.""" + width, height = size + small_w, small_h = max(width // _DOWNSCALE, 2), max(height // _DOWNSCALE, 2) + ys, xs = np.indices((small_h, small_w), dtype=np.float64) + xs /= small_w + ys /= small_h + field = np.zeros_like(xs) + for _ in range(_WAVES): + fx, fy = rng.uniform(1.0, 6.0, size=2) + phase = rng.uniform(0.0, 2.0 * np.pi) + field += np.sin(xs * fx * 2 * np.pi + ys * fy * 2 * np.pi + phase) + cx, cy = rng.uniform(0.2, 0.8, size=2) + field += np.sin(np.hypot(xs - cx, ys - cy) * rng.uniform(6.0, 14.0)) + t = (field - field.min()) / max(float(np.ptp(field)), 1e-9) + accent = _palette.mix(_palette.BG, _palette.ACCENTS[rng.integers(6)], 0.35) + stops = (_palette.DEEP, _palette.BG, accent, _palette.BG, _palette.DEEP) + small = Image.fromarray(_palette.ramp(t, stops), "RGB") + return small.resize(size, Image.Resampling.BICUBIC) diff --git a/python_pkg/wallpaper_rotator/_style_procedural.py b/python_pkg/wallpaper_rotator/_style_procedural.py new file mode 100644 index 00000000..5df143e2 --- /dev/null +++ b/python_pkg/wallpaper_rotator/_style_procedural.py @@ -0,0 +1,109 @@ +"""Style 2: fresh procedural art each showing (flow field or Voronoi).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from PIL import Image, ImageDraw + +from python_pkg.wallpaper_rotator import _palette + +if TYPE_CHECKING: + from python_pkg.wallpaper_rotator._context import RenderContext + +_STEPS = 90 +_SITES = 36 +_TINTED_SHARE = 0.2 # fraction of Voronoi cells given an accent tint +_FLOW_SHARE = 0.5 # chance of the flow field over Voronoi + + +def _angle_field(xs: np.ndarray, ys: np.ndarray, waves: np.ndarray) -> np.ndarray: + """Cheap smooth noise: a sum of sines in each particle's position.""" + total = np.zeros_like(xs) + for fx, fy, phase in waves: + total += np.sin(xs * fx + ys * fy + phase) + return total * np.pi + + +def _trace( + start: tuple[np.ndarray, np.ndarray], + size: tuple[int, int], + waves: np.ndarray, +) -> np.ndarray: + """Step every particle along the angle field; return all positions.""" + xs, ys = start + width, height = size + step = max(width, height) / 600 + trails = np.empty((_STEPS, len(xs), 2)) + for i in range(_STEPS): + trails[i, :, 0], trails[i, :, 1] = xs, ys + angle = _angle_field(xs / width * 4, ys / height * 4, waves) + xs = xs + np.cos(angle) * step + ys = ys + np.sin(angle) * step + return trails + + +def _flow(size: tuple[int, int], rng: np.random.Generator) -> Image.Image: + """Trace particles through a smooth angle field as translucent strokes.""" + width, height = size + image = Image.new("RGB", size, _palette.DEEP) + draw = ImageDraw.Draw(image, "RGBA") + count = max(width * height // 2000, 50) + xs = rng.uniform(0, width, count) + ys = rng.uniform(0, height, count) + waves = rng.uniform([0.5, 0.5, 0.0], [3.0, 3.0, 6.3], size=(3, 3)) + trails = _trace((xs, ys), size, waves) + colours = [_palette.ACCENTS[k] for k in rng.choice(6, size=2, replace=False)] + line_width = max(width // 1500, 1) + for p in range(count): + r, g, b = colours[p % 2] + points = [(float(x), float(y)) for x, y in trails[:, p]] + draw.line(points, fill=(r, g, b, 40), width=line_width) + return image + + +def _nearest_sites( + grid: tuple[np.ndarray, np.ndarray], sites: np.ndarray +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Per pixel: distance to the nearest and second-nearest site, and owner.""" + xs, ys = grid + best = np.full(xs.shape, np.inf) + second = np.full(xs.shape, np.inf) + owner = np.zeros(xs.shape, dtype=np.int64) + for k, (sx, sy) in enumerate(sites): + dist = np.hypot(xs - sx, ys - sy) + closer = dist < best + second = np.where(closer, best, np.minimum(second, dist)) + owner = np.where(closer, k, owner) + best = np.where(closer, dist, best) + return best, second, owner + + +def _voronoi(size: tuple[int, int], rng: np.random.Generator) -> Image.Image: + """Shade Voronoi cells darker towards their edges, a few cells tinted.""" + width, height = size + half_w, half_h = max(width // 2, 1), max(height // 2, 1) + ys, xs = np.indices((half_h, half_w), dtype=np.float64) + sites = rng.uniform([0, 0], [half_w, half_h], size=(_SITES, 2)) + best, second, owner = _nearest_sites((xs, ys), sites) + edge = np.clip((second - best) / (0.05 * max(half_w, half_h)), 0.0, 1.0) + tints = [_palette.mix(_palette.BG, c, 0.25) for c in _palette.ACCENTS] + cell_colour = np.array( + [ + tints[rng.integers(6)] if rng.random() < _TINTED_SHARE else _palette.BG + for _ in sites + ], + dtype=np.float64, + ) + deep = np.array(_palette.DEEP, dtype=np.float64) + rgb = deep + (cell_colour[owner] - deep) * edge[..., None] + small = Image.fromarray(rgb.round().astype(np.uint8), "RGB") + return small.resize(size, Image.Resampling.BILINEAR) + + +def render( + _ctx: RenderContext, size: tuple[int, int], rng: np.random.Generator +) -> Image.Image: + """Pick one of the two generators and run it with a fresh seed.""" + return _flow(size, rng) if rng.random() < _FLOW_SHARE else _voronoi(size, rng) diff --git a/python_pkg/wallpaper_rotator/_tick.py b/python_pkg/wallpaper_rotator/_tick.py new file mode 100644 index 00000000..9f4e1eff --- /dev/null +++ b/python_pkg/wallpaper_rotator/_tick.py @@ -0,0 +1,88 @@ +"""One timer tick: rotate or refresh the background, then remember it.""" + +from __future__ import annotations + +from dataclasses import replace +import json +import os +from pathlib import Path +import random +import re +import subprocess +from typing import cast + +import numpy as np + +from python_pkg.wallpaper_rotator import _paint +from python_pkg.wallpaper_rotator._context import DEFAULT_CACHE, RenderContext +from python_pkg.wallpaper_rotator._rotation import State, plan +from python_pkg.wallpaper_rotator.styles import STYLES + +ROOT_IMAGE = DEFAULT_CACHE / "root.png" +STATE_FILE = DEFAULT_CACHE / "state.json" +# X writes its server pid to /.X-lock. +_X_LOCK_DIR = Path("/") / "tmp" +_PIXMAP = re.compile(r"#\s*(0x[0-9a-f]+)") + + +def i3_outputs() -> list[dict[str, object]]: + """Return ``i3-msg -t get_outputs`` as parsed JSON.""" + out = subprocess.run( + ["/usr/bin/i3-msg", "-t", "get_outputs"], + check=True, + capture_output=True, + text=True, + ).stdout + return cast("list[dict[str, object]]", json.loads(out)) + + +def root_identity(display: str | None = None) -> str | None: + """Identify the root pixmap now shown: X server pid plus pixmap id. + + The pid (from the X lock file) changes with every X session, so a fresh + login never matches a pixmap id saved in an earlier one. ``None`` means + no background is set at all. + """ + display = display or os.environ.get("DISPLAY", ":0") + number = display.lstrip(":").split(".")[0] + try: + server = (_X_LOCK_DIR / f".X{number}-lock").read_text(encoding="utf-8").strip() + except OSError: + server = "?" + prop = subprocess.run( + ["/usr/bin/xprop", "-root", "_XROOTPMAP_ID"], + check=False, + capture_output=True, + text=True, + ).stdout + found = _PIXMAP.search(prop) + return f"{server}:{found.group(1)}" if found else None + + +def tick(rng: random.Random | None = None) -> bool: + """Run one tick; return whether the background was repainted.""" + rects = _paint.layout(i3_outputs()) + ctx = RenderContext.live() + names = [name for name, style in STYLES.items() if style.available(ctx)] + state, paint = plan( + State.load(STATE_FILE), + list(rects), + ctx.now, + names, + rng or random.SystemRandom(), + root_id=root_identity(), + ) + if paint: + images = { + output: STYLES[pick.style].render( + ctx, + (rects[output][2], rects[output][3]), + np.random.default_rng(pick.seed), + ) + for output, pick in state.picks.items() + if output in rects + } + _paint.apply(_paint.compose(rects, images), ROOT_IMAGE) + state = replace(state, root_id=root_identity()) + state.save(STATE_FILE) + return paint diff --git a/python_pkg/wallpaper_rotator/styles.py b/python_pkg/wallpaper_rotator/styles.py new file mode 100644 index 00000000..80c909d8 --- /dev/null +++ b/python_pkg/wallpaper_rotator/styles.py @@ -0,0 +1,46 @@ +"""The six background styles, keyed by name.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from python_pkg.wallpaper_rotator import _style_clock as _clock +from python_pkg.wallpaper_rotator import _style_earth as _earth +from python_pkg.wallpaper_rotator import _style_gradient as _gradient +from python_pkg.wallpaper_rotator import _style_heatmap as _heatmap +from python_pkg.wallpaper_rotator import _style_plasma as _plasma +from python_pkg.wallpaper_rotator import _style_procedural as _procedural + +if TYPE_CHECKING: + import numpy as np + from PIL import Image + + from python_pkg.wallpaper_rotator._context import RenderContext + +RenderFn = Callable[ + ["RenderContext", tuple[int, int], "np.random.Generator"], "Image.Image" +] + + +def _always(_ctx: RenderContext) -> bool: + return True + + +@dataclass(frozen=True) +class Style: + """A renderer plus a check that its inputs exist.""" + + render: RenderFn + available: Callable[[RenderContext], bool] = _always + + +STYLES: dict[str, Style] = { + "gradient": Style(_gradient.render), + "procedural": Style(_procedural.render), + "heatmap": Style(_heatmap.render), + "clock": Style(_clock.render), + "earth": Style(_earth.render, _earth.available), + "plasma": Style(_plasma.render), +} diff --git a/python_pkg/wallpaper_rotator/tests/__init__.py b/python_pkg/wallpaper_rotator/tests/__init__.py new file mode 100644 index 00000000..da0ce3f2 --- /dev/null +++ b/python_pkg/wallpaper_rotator/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for wallpaper_rotator.""" diff --git a/python_pkg/wallpaper_rotator/tests/test_context.py b/python_pkg/wallpaper_rotator/tests/test_context.py new file mode 100644 index 00000000..c85fa4f3 --- /dev/null +++ b/python_pkg/wallpaper_rotator/tests/test_context.py @@ -0,0 +1,21 @@ +"""Tests for the render context built for the running desktop.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from python_pkg.wallpaper_rotator import _context + + +def test_live_context_points_at_the_users_dirs( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path)) + monkeypatch.setattr(_context, "DEFAULT_CACHE", tmp_path / ".cache" / "w") + ctx = _context.RenderContext.live() + assert ctx.data_home == tmp_path / ".local" / "share" + assert ctx.earth_dir == tmp_path / ".cache" / "w" / "earth" + assert ctx.shutdown_conf == Path("/etc/shutdown-schedule.conf") + assert ctx.now.tzinfo is not None diff --git a/python_pkg/wallpaper_rotator/tests/test_main.py b/python_pkg/wallpaper_rotator/tests/test_main.py new file mode 100644 index 00000000..881ea3b1 --- /dev/null +++ b/python_pkg/wallpaper_rotator/tests/test_main.py @@ -0,0 +1,33 @@ +"""Tests for the ``python3 -m python_pkg.wallpaper_rotator`` entry point.""" + +from __future__ import annotations + +import logging +import runpy + +import pytest + +from python_pkg.wallpaper_rotator import _tick + + +def _run_main( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + outcome: object, +) -> list[str]: + monkeypatch.setattr(_tick, "tick", lambda: outcome) + with caplog.at_level(logging.INFO): + runpy.run_module("python_pkg.wallpaper_rotator", run_name="__main__") + return caplog.messages + + +def test_main_reports_a_repaint( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + assert "painted" in _run_main(monkeypatch, caplog, outcome=1) + + +def test_main_reports_an_unchanged_background( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + assert "unchanged" in _run_main(monkeypatch, caplog, outcome=0) diff --git a/python_pkg/wallpaper_rotator/tests/test_paint.py b/python_pkg/wallpaper_rotator/tests/test_paint.py new file mode 100644 index 00000000..460ea0f3 --- /dev/null +++ b/python_pkg/wallpaper_rotator/tests/test_paint.py @@ -0,0 +1,66 @@ +"""Tests for composing the root image and handing it to feh.""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING + +from PIL import Image +import pytest + +from python_pkg.wallpaper_rotator import _paint, _palette + +if TYPE_CHECKING: + from pathlib import Path + + +def test_layout_keeps_only_active_outputs() -> None: + outputs = [ + { + "name": "DP-0", + "active": True, + "rect": {"x": 0, "y": 5, "width": 4, "height": 3}, + }, + { + "name": "HDMI-0", + "active": False, + "rect": {"x": 9, "y": 9, "width": 9, "height": 9}, + }, + ] + assert _paint.layout(outputs) == {"DP-0": (0, 5, 4, 3)} + + +def test_compose_places_each_image_and_skips_missing_ones() -> None: + rects = {"A": (0, 0, 2, 2), "B": (2, 0, 2, 2), "C": (0, 2, 4, 1)} + images = { + "A": Image.new("RGB", (2, 2), (255, 0, 0)), + "B": Image.new("RGB", (2, 2), (0, 255, 0)), + } + root = _paint.compose(rects, images) + assert root.size == (4, 3) + assert root.getpixel((0, 0)) == (255, 0, 0) + assert root.getpixel((3, 1)) == (0, 255, 0) + assert root.getpixel((0, 2)) == _palette.DEEP + + +def test_compose_without_outputs_is_one_pixel() -> None: + assert _paint.compose({}, {}).size == (1, 1) + + +def test_apply_saves_image_and_runs_feh( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + calls: list[list[str]] = [] + + def fake_run(cmd: list[str], **kwargs: object) -> None: + calls.append(cmd) + assert kwargs == {"check": True} + + monkeypatch.setattr(subprocess, "run", fake_run) + target = tmp_path / "cache" / "root.png" + _paint.apply(Image.new("RGB", (3, 2)), target) + with Image.open(target) as saved: + assert saved.size == (3, 2) + assert calls == [ + ["/usr/bin/feh", "--no-fehbg", "--no-xinerama", "--bg-tile", str(target)] + ] diff --git a/python_pkg/wallpaper_rotator/tests/test_procedural.py b/python_pkg/wallpaper_rotator/tests/test_procedural.py new file mode 100644 index 00000000..2ebda1da --- /dev/null +++ b/python_pkg/wallpaper_rotator/tests/test_procedural.py @@ -0,0 +1,39 @@ +"""Both procedural generators are reachable and render the requested size.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path + +import numpy as np +import pytest + +from python_pkg.wallpaper_rotator import _style_procedural as _procedural +from python_pkg.wallpaper_rotator._context import RenderContext + +_SIZE = (320, 180) + + +def _seed_choosing(generator: str) -> int: + """First seed whose first draw makes ``render`` pick the wanted generator.""" + return next( + seed + for seed in range(100) + if (np.random.default_rng(seed).random() < _procedural._FLOW_SHARE) + == (generator == "flow") + ) + + +@pytest.mark.parametrize("generator", ["flow", "voronoi"]) +def test_render_reaches_each_generator(generator: str) -> None: + ctx = RenderContext( + now=datetime(2026, 11, 11, tzinfo=UTC), + data_home=Path(), + earth_dir=Path(), + shutdown_conf=Path(), + ) + rng = np.random.default_rng(_seed_choosing(generator)) + expected = getattr(_procedural, f"_{generator}")( + _SIZE, np.random.default_rng(_seed_choosing(generator)) + ) + assert _procedural.render(ctx, _SIZE, rng).size == expected.size == _SIZE diff --git a/python_pkg/wallpaper_rotator/tests/test_rotation.py b/python_pkg/wallpaper_rotator/tests/test_rotation.py new file mode 100644 index 00000000..3398070e --- /dev/null +++ b/python_pkg/wallpaper_rotator/tests/test_rotation.py @@ -0,0 +1,155 @@ +"""Tests for the 3-hourly style rotation and its state.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +import random +from typing import TYPE_CHECKING + +from python_pkg.wallpaper_rotator._rotation import Pick, State, choose, plan + +if TYPE_CHECKING: + from pathlib import Path + +_NAMES = ["clock", "earth", "gradient", "heatmap", "plasma", "procedural"] +_T0 = datetime(2026, 11, 11, 12, 0, tzinfo=UTC) +_OUTS = ["DP-0", "HDMI-0"] + + +def test_choose_differs_from_previous_and_between_outputs() -> None: + rng = random.SystemRandom() + previous = {"DP-0": "plasma", "HDMI-0": "earth"} + for _ in range(200): + picked = choose(previous, _OUTS, _NAMES, rng) + assert picked["DP-0"] != previous["DP-0"] + assert picked["HDMI-0"] != previous["HDMI-0"] + assert picked["DP-0"] != picked["HDMI-0"] + previous = picked + + +def test_first_run_rotates_and_paints() -> None: + state, paint = plan(None, _OUTS, _T0, _NAMES, random.SystemRandom(), root_id="0x1") + assert paint + assert set(state.picks) == set(_OUTS) + assert state.rotated_at == _T0 + + +def test_static_styles_untouched_before_three_hours() -> None: + old = State(_T0, {o: Pick("plasma", 5) for o in _OUTS}, root_id="0x1") + state, paint = plan( + old, + _OUTS, + _T0 + timedelta(hours=2), + _NAMES, + random.SystemRandom(), + root_id="0x1", + ) + assert not paint + assert state == old + + +def test_data_styles_redraw_with_same_style_before_three_hours() -> None: + old = State( + _T0, {"DP-0": Pick("clock", 5), "HDMI-0": Pick("plasma", 6)}, root_id="0x1" + ) + state, paint = plan( + old, + _OUTS, + _T0 + timedelta(minutes=15), + _NAMES, + random.SystemRandom(), + root_id="0x1", + ) + assert paint + assert state.picks == old.picks + + +def test_lost_root_pixmap_repaints_same_styles() -> None: + old = State(_T0, {o: Pick("plasma", 5) for o in _OUTS}, root_id="0x1") + state, paint = plan( + old, + _OUTS, + _T0 + timedelta(hours=1), + _NAMES, + random.SystemRandom(), + root_id=None, + ) + assert paint + assert state.picks == old.picks + + +def test_three_hours_rotates_to_new_styles() -> None: + old = State( + _T0, {"DP-0": Pick("plasma", 5), "HDMI-0": Pick("earth", 6)}, root_id="0x1" + ) + later = _T0 + timedelta(hours=3) + state, paint = plan(old, _OUTS, later, _NAMES, random.SystemRandom(), root_id="0x1") + assert paint + assert state.rotated_at == later + assert state.picks["DP-0"].style != "plasma" + assert state.picks["HDMI-0"].style != "earth" + + +def test_state_round_trips_through_json(tmp_path: Path) -> None: + old = State(_T0, {"DP-0": Pick("clock", 5)}, root_id="0x1") + path = tmp_path / "state.json" + old.save(path) + assert State.load(path) == old + assert State.load(tmp_path / "missing.json") is None + + +def test_state_load_rejects_unreadable_content(tmp_path: Path) -> None: + path = tmp_path / "state.json" + bad_date = '{"rotated_at": "x", "picks": {}, "root_id": null}' + for text in ("not json", "{}", '{"picks": {"a": [1]}}', bad_date): + path.write_text(text, encoding="utf-8") + assert State.load(path) is None + + +def test_new_output_gets_a_style_unlike_the_others() -> None: + old = State(_T0, {"DP-0": Pick("plasma", 5)}, root_id="0x1") + state, paint = plan( + old, + _OUTS, + _T0 + timedelta(hours=1), + _NAMES, + random.SystemRandom(), + root_id="0x1", + ) + assert paint + assert state.picks["DP-0"] == old.picks["DP-0"] + assert state.picks["HDMI-0"].style != "plasma" + + +def test_new_output_falls_back_to_all_names_when_none_unused() -> None: + old = State(_T0, {"DP-0": Pick("plasma", 5)}, root_id="0x1") + state, paint = plan( + old, + _OUTS, + _T0 + timedelta(hours=1), + ["plasma"], + random.SystemRandom(), + root_id="0x1", + ) + assert paint + assert state.picks["HDMI-0"].style == "plasma" + + +def test_rotation_with_stale_state_missing_an_output() -> None: + old = State(_T0, {"DP-0": Pick("plasma", 5)}, root_id="0x1") + state, paint = plan( + old, + _OUTS, + _T0 + timedelta(hours=4), + _NAMES, + random.SystemRandom(), + root_id="0x1", + ) + assert paint + assert set(state.picks) == set(_OUTS) + assert state.picks["DP-0"].style != "plasma" + + +def test_choose_reuses_names_when_every_option_is_taken() -> None: + picked = choose({"DP-0": "plasma"}, ["DP-0"], ["plasma"], random.SystemRandom()) + assert picked == {"DP-0": "plasma"} diff --git a/python_pkg/wallpaper_rotator/tests/test_style_data.py b/python_pkg/wallpaper_rotator/tests/test_style_data.py new file mode 100644 index 00000000..ab8ff413 --- /dev/null +++ b/python_pkg/wallpaper_rotator/tests/test_style_data.py @@ -0,0 +1,125 @@ +"""Branches of the data-driven styles: clock conf, habit ledgers, Earth maps.""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +import json +from typing import TYPE_CHECKING + +import numpy as np +from PIL import Image + +from python_pkg.wallpaper_rotator import _style_clock as _clock +from python_pkg.wallpaper_rotator import _style_earth as _earth +from python_pkg.wallpaper_rotator import _style_heatmap as _heatmap +from python_pkg.wallpaper_rotator._context import RenderContext + +if TYPE_CHECKING: + from pathlib import Path + + +def _ctx(tmp_path: Path, now: datetime, conf: Path | None = None) -> RenderContext: + return RenderContext( + now=now, + data_home=tmp_path / "share", + earth_dir=tmp_path / "earth", + shutdown_conf=conf or tmp_path / "missing.conf", + ) + + +def test_schedule_defaults_when_conf_is_missing(tmp_path: Path) -> None: + assert _clock.schedule(tmp_path / "nope.conf", 0) == (300, 1380) + + +def test_schedule_reads_numbers_and_skips_junk_lines(tmp_path: Path) -> None: + conf = tmp_path / "s.conf" + conf.write_text( + "no equals sign\n" + "MON_WED_MINUTES=1200\n" + "THU_SUN_MINUTES = 1300 \n" + "MORNING_END_MINUTES=abc\n", + encoding="utf-8", + ) + assert _clock.schedule(conf, 2) == (300, 1200) + assert _clock.schedule(conf, 3) == (300, 1300) + + +def test_clock_after_shutdown_draws_only_the_dim_ring(tmp_path: Path) -> None: + late = _ctx(tmp_path, datetime(2026, 11, 11, 23, 30, tzinfo=UTC)) + image = _clock.render(late, (200, 120), np.random.default_rng(0)) + assert image.size == (200, 120) + + +def test_clock_tone_is_calmer_hours_before_shutdown(tmp_path: Path) -> None: + early = _ctx(tmp_path, datetime(2026, 11, 11, 8, 0, tzinfo=UTC)) + late = _ctx(tmp_path, datetime(2026, 11, 11, 22, 30, tzinfo=UTC)) + rng = np.random.default_rng(0) + corner_early = _clock.render(early, (200, 120), rng).getpixel((100, 5)) + corner_late = _clock.render(late, (200, 120), rng).getpixel((100, 5)) + assert corner_early != corner_late + + +def _ledger(tmp_path: Path, text: str) -> Path: + path = tmp_path / "ledger.json" + path.write_text(text, encoding="utf-8") + return path + + +def test_credit_days_counts_real_credits_per_day(tmp_path: Path) -> None: + entries = [ + {"kind": "credit", "amount": 5, "day": "2026-11-10"}, + {"kind": "credit", "day": "2026-11-10"}, + {"kind": "credit", "amount": 1, "day": "2026-11-09"}, + ] + days = _heatmap.credit_days(_ledger(tmp_path, json.dumps({"entries": entries}))) + assert days == {date(2026, 11, 10): 2, date(2026, 11, 9): 1} + + +def test_credit_days_ignores_unusable_rows(tmp_path: Path) -> None: + entries = [ + "not a dict", + {"kind": "debit", "amount": 3, "day": "2026-11-10"}, + {"kind": "credit", "amount": 0, "day": "2026-11-10"}, + {"kind": "credit", "amount": 1, "day": "yesterday"}, + {"kind": "credit", "amount": 1}, + ] + days = _heatmap.credit_days(_ledger(tmp_path, json.dumps({"entries": entries}))) + assert not days + + +def test_credit_days_survives_a_broken_ledger(tmp_path: Path) -> None: + assert not _heatmap.credit_days(tmp_path / "missing.json") + assert not _heatmap.credit_days(_ledger(tmp_path, "{not json")) + assert not _heatmap.credit_days(_ledger(tmp_path, "[1, 2]")) + assert not _heatmap.credit_days(_ledger(tmp_path, "{}")) + + +def test_heatmap_lights_cells_for_credit_days(tmp_path: Path) -> None: + now = datetime(2026, 11, 11, 12, 0, tzinfo=UTC) + ctx = _ctx(tmp_path, now) + folder = ctx.data_home / "workout_locker" + folder.mkdir(parents=True) + rows = [{"kind": "credit", "amount": 1, "day": "2026-11-11"}] * 3 + (folder / "ledger.json").write_text(json.dumps({"entries": rows}), encoding="utf-8") + lit = _heatmap.render(ctx, (800, 480), np.random.default_rng(0)) + blank = _heatmap.render( + _ctx(tmp_path / "x", now), (800, 480), np.random.default_rng(0) + ) + assert lit.tobytes() != blank.tobytes() + + +def test_earth_available_needs_both_images(tmp_path: Path) -> None: + ctx = _ctx(tmp_path, datetime(2026, 11, 11, tzinfo=UTC)) + assert not _earth.available(ctx) + ctx.earth_dir.mkdir() + Image.new("RGB", (4, 2)).save(ctx.earth_dir / "day.jpg") + assert not _earth.available(ctx) + Image.new("RGB", (4, 2)).save(ctx.earth_dir / "night.jpg") + assert _earth.available(ctx) + + +def test_sun_is_over_the_greenwich_meridian_at_noon_utc(tmp_path: Path) -> None: + ctx = _ctx(tmp_path, datetime(2026, 3, 20, 12, 0, tzinfo=UTC)) + lat, lon = _earth.sun_position(ctx) + assert lon == 0.0 + assert abs(lat) < np.radians(24) diff --git a/python_pkg/wallpaper_rotator/tests/test_styles.py b/python_pkg/wallpaper_rotator/tests/test_styles.py new file mode 100644 index 00000000..bd72962d --- /dev/null +++ b/python_pkg/wallpaper_rotator/tests/test_styles.py @@ -0,0 +1,50 @@ +"""Every style renders the exact output size and stays dark.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +import numpy as np +from PIL import Image +import pytest + +from python_pkg.wallpaper_rotator._context import RenderContext +from python_pkg.wallpaper_rotator.styles import STYLES + +if TYPE_CHECKING: + from pathlib import Path + +# Mean brightness ceiling (0-255): the whole point is "not blinding". +_DARK_MEAN = 80 + + +@pytest.fixture +def ctx(tmp_path: Path) -> RenderContext: + earth = tmp_path / "earth" + earth.mkdir() + Image.new("RGB", (64, 32), (40, 90, 160)).save(earth / "day.jpg") + Image.new("RGB", (64, 32), (5, 5, 10)).save(earth / "night.jpg") + conf = tmp_path / "shutdown.conf" + conf.write_text("MON_WED_MINUTES=1380\nTHU_SUN_MINUTES=1380\n") + return RenderContext( + now=datetime(2026, 11, 11, 21, 30, tzinfo=UTC), + data_home=tmp_path / "share", + earth_dir=earth, + shutdown_conf=conf, + ) + + +def test_all_six_styles_registered() -> None: + assert len(STYLES) == 6 + + +@pytest.mark.parametrize("name", sorted(STYLES)) +@pytest.mark.parametrize("size", [(384, 216), (256, 144)]) +def test_style_is_exact_size_and_dark( + name: str, size: tuple[int, int], ctx: RenderContext +) -> None: + image = STYLES[name].render(ctx, size, np.random.default_rng(3)) + assert image.size == size + assert image.mode == "RGB" + assert float(np.asarray(image).mean()) < _DARK_MEAN diff --git a/python_pkg/wallpaper_rotator/tests/test_tick.py b/python_pkg/wallpaper_rotator/tests/test_tick.py new file mode 100644 index 00000000..4c7d7696 --- /dev/null +++ b/python_pkg/wallpaper_rotator/tests/test_tick.py @@ -0,0 +1,156 @@ +"""Tests for one timer tick, with i3-msg, xprop and feh replaced by fakes.""" + +from __future__ import annotations + +from datetime import UTC, datetime +import json +import random +import subprocess +from typing import TYPE_CHECKING + +import pytest + +from python_pkg.wallpaper_rotator import _tick +from python_pkg.wallpaper_rotator._context import RenderContext +from python_pkg.wallpaper_rotator._rotation import Pick, State + +if TYPE_CHECKING: + from pathlib import Path + + from PIL import Image + +_NOW = datetime(2026, 11, 11, 12, 0, tzinfo=UTC) +_OUTPUTS = [ + { + "name": "DP-0", + "active": True, + "rect": {"x": 0, "y": 0, "width": 64, "height": 36}, + }, + { + "name": "off", + "active": False, + "rect": {"x": 0, "y": 0, "width": 1, "height": 1}, + }, +] + + +def _completed(stdout: str) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=[], returncode=0, stdout=stdout) + + +@pytest.fixture +def ctx(tmp_path: Path) -> RenderContext: + return RenderContext( + now=_NOW, + data_home=tmp_path / "share", + earth_dir=tmp_path / "no-earth", + shutdown_conf=tmp_path / "missing.conf", + ) + + +@pytest.fixture +def world( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ctx: RenderContext +) -> list[Image.Image]: + """Fake every outside effect; return the list of root images "painted".""" + painted: list[Image.Image] = [] + monkeypatch.setattr(_tick, "STATE_FILE", tmp_path / "state.json") + monkeypatch.setattr(_tick, "ROOT_IMAGE", tmp_path / "root.png") + monkeypatch.setattr(_tick, "i3_outputs", lambda: _OUTPUTS) + monkeypatch.setattr(_tick, "root_identity", lambda display=None: "7:0x1") + monkeypatch.setattr(RenderContext, "live", classmethod(lambda _cls: ctx)) + monkeypatch.setattr( + _tick._paint, "apply", lambda root, _target: painted.append(root) + ) + return painted + + +def test_i3_outputs_parses_i3_msg_json(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[list[str]] = [] + + def fake_run(cmd: list[str], **_kw: object) -> subprocess.CompletedProcess[str]: + calls.append(cmd) + return _completed(json.dumps(_OUTPUTS)) + + monkeypatch.setattr(subprocess, "run", fake_run) + assert _tick.i3_outputs() == _OUTPUTS + assert calls == [["/usr/bin/i3-msg", "-t", "get_outputs"]] + + +def test_root_identity_joins_server_pid_and_pixmap( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + (tmp_path / ".X1-lock").write_text(" 4242\n", encoding="utf-8") + monkeypatch.setattr(_tick, "_X_LOCK_DIR", tmp_path) + monkeypatch.setattr( + subprocess, + "run", + lambda *_a, **_kw: _completed("_XROOTPMAP_ID(PIXMAP) # 0x4a00003\n"), + ) + assert _tick.root_identity(":1.0") == "4242:0x4a00003" + + +def test_root_identity_reads_display_from_environment( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + (tmp_path / ".X5-lock").write_text("9\n", encoding="utf-8") + monkeypatch.setattr(_tick, "_X_LOCK_DIR", tmp_path) + monkeypatch.setenv("DISPLAY", ":5") + monkeypatch.setattr(subprocess, "run", lambda *_a, **_kw: _completed("# 0xab")) + assert _tick.root_identity() == "9:0xab" + + +def test_root_identity_without_lock_file_or_pixmap( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(_tick, "_X_LOCK_DIR", tmp_path) + monkeypatch.setattr( + subprocess, + "run", + lambda *_a, **_kw: _completed("_XROOTPMAP_ID: not found.\n"), + ) + assert _tick.root_identity(":0") is None + + +def test_root_identity_marks_unreadable_lock_with_question_mark( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(_tick, "_X_LOCK_DIR", tmp_path) + monkeypatch.setattr(subprocess, "run", lambda *_a, **_kw: _completed("# 0x1")) + assert _tick.root_identity(":0") == "?:0x1" + + +def test_first_tick_paints_and_saves_state(world: list[Image.Image]) -> None: + assert _tick.tick(random.SystemRandom()) is True + assert len(world) == 1 + assert world[0].size == (64, 36) + state = State.load(_tick.STATE_FILE) + assert state is not None + assert set(state.picks) == {"DP-0"} + assert state.root_id == "7:0x1" + + +def test_tick_defaults_to_a_fresh_rng(world: list[Image.Image]) -> None: + assert _tick.tick() is True + assert len(world) == 1 + + +def test_idle_tick_leaves_the_background_alone(world: list[Image.Image]) -> None: + State(_NOW, {"DP-0": Pick("plasma", 1)}, root_id="7:0x1").save(_tick.STATE_FILE) + assert _tick.tick(random.SystemRandom()) is False + assert not world + + +def test_live_style_redraws_and_skips_unplugged_outputs( + world: list[Image.Image], +) -> None: + State( + _NOW, + {"DP-0": Pick("clock", 1), "gone": Pick("clock", 2)}, + root_id="7:0x1", + ).save(_tick.STATE_FILE) + assert _tick.tick(random.SystemRandom()) is True + assert len(world) == 1 + state = State.load(_tick.STATE_FILE) + assert state is not None + assert state.picks["DP-0"].style == "clock"