mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 21:34:05 +01:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWX51FMD58yKUC1eYWf
This commit is contained in:
parent
c409ebb3eb
commit
d502580e3f
14
docs/superpowers/contracts/wallpaper-rotator-2026-11-11.json
Normal file
14
docs/superpowers/contracts/wallpaper-rotator-2026-11-11.json
Normal file
@ -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 <changed-files>"
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
39
docs/superpowers/evidence/wallpaper-rotator-2026-11-11.json
Normal file
39
docs/superpowers/evidence/wallpaper-rotator-2026-11-11.json
Normal file
@ -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"
|
||||
]
|
||||
}
|
||||
@ -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
|
||||
|
||||
49
linux_configuration/i3/install_wallpaper_rotator.sh
Executable file
49
linux_configuration/i3/install_wallpaper_rotator.sh
Executable file
@ -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 "$@"
|
||||
14
linux_configuration/i3/wallpaper-rotator.service
Normal file
14
linux_configuration/i3/wallpaper-rotator.service
Normal file
@ -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
|
||||
9
linux_configuration/i3/wallpaper-rotator.timer
Normal file
9
linux_configuration/i3/wallpaper-rotator.timer
Normal file
@ -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
|
||||
7
python_pkg/wallpaper_rotator/__init__.py
Normal file
7
python_pkg/wallpaper_rotator/__init__.py
Normal file
@ -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.
|
||||
"""
|
||||
11
python_pkg/wallpaper_rotator/__main__.py
Normal file
11
python_pkg/wallpaper_rotator/__main__.py
Normal file
@ -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")
|
||||
36
python_pkg/wallpaper_rotator/_context.py
Normal file
36
python_pkg/wallpaper_rotator/_context.py
Normal file
@ -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"),
|
||||
)
|
||||
59
python_pkg/wallpaper_rotator/_paint.py
Normal file
59
python_pkg/wallpaper_rotator/_paint.py
Normal file
@ -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,
|
||||
)
|
||||
41
python_pkg/wallpaper_rotator/_palette.py
Normal file
41
python_pkg/wallpaper_rotator/_palette.py
Normal file
@ -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)
|
||||
118
python_pkg/wallpaper_rotator/_rotation.py
Normal file
118
python_pkg/wallpaper_rotator/_rotation.py
Normal file
@ -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
|
||||
95
python_pkg/wallpaper_rotator/_style_clock.py
Normal file
95
python_pkg/wallpaper_rotator/_style_clock.py
Normal file
@ -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
|
||||
64
python_pkg/wallpaper_rotator/_style_earth.py
Normal file
64
python_pkg/wallpaper_rotator/_style_earth.py
Normal file
@ -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
|
||||
32
python_pkg/wallpaper_rotator/_style_gradient.py
Normal file
32
python_pkg/wallpaper_rotator/_style_gradient.py
Normal file
@ -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")
|
||||
117
python_pkg/wallpaper_rotator/_style_heatmap.py
Normal file
117
python_pkg/wallpaper_rotator/_style_heatmap.py
Normal file
@ -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
|
||||
40
python_pkg/wallpaper_rotator/_style_plasma.py
Normal file
40
python_pkg/wallpaper_rotator/_style_plasma.py
Normal file
@ -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)
|
||||
109
python_pkg/wallpaper_rotator/_style_procedural.py
Normal file
109
python_pkg/wallpaper_rotator/_style_procedural.py
Normal file
@ -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)
|
||||
88
python_pkg/wallpaper_rotator/_tick.py
Normal file
88
python_pkg/wallpaper_rotator/_tick.py
Normal file
@ -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 <tmp>/.X<display>-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
|
||||
46
python_pkg/wallpaper_rotator/styles.py
Normal file
46
python_pkg/wallpaper_rotator/styles.py
Normal file
@ -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),
|
||||
}
|
||||
1
python_pkg/wallpaper_rotator/tests/__init__.py
Normal file
1
python_pkg/wallpaper_rotator/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for wallpaper_rotator."""
|
||||
21
python_pkg/wallpaper_rotator/tests/test_context.py
Normal file
21
python_pkg/wallpaper_rotator/tests/test_context.py
Normal file
@ -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
|
||||
33
python_pkg/wallpaper_rotator/tests/test_main.py
Normal file
33
python_pkg/wallpaper_rotator/tests/test_main.py
Normal file
@ -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)
|
||||
66
python_pkg/wallpaper_rotator/tests/test_paint.py
Normal file
66
python_pkg/wallpaper_rotator/tests/test_paint.py
Normal file
@ -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)]
|
||||
]
|
||||
39
python_pkg/wallpaper_rotator/tests/test_procedural.py
Normal file
39
python_pkg/wallpaper_rotator/tests/test_procedural.py
Normal file
@ -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
|
||||
155
python_pkg/wallpaper_rotator/tests/test_rotation.py
Normal file
155
python_pkg/wallpaper_rotator/tests/test_rotation.py
Normal file
@ -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"}
|
||||
125
python_pkg/wallpaper_rotator/tests/test_style_data.py
Normal file
125
python_pkg/wallpaper_rotator/tests/test_style_data.py
Normal file
@ -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)
|
||||
50
python_pkg/wallpaper_rotator/tests/test_styles.py
Normal file
50
python_pkg/wallpaper_rotator/tests/test_styles.py
Normal file
@ -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
|
||||
156
python_pkg/wallpaper_rotator/tests/test_tick.py
Normal file
156
python_pkg/wallpaper_rotator/tests/test_tick.py
Normal file
@ -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"
|
||||
Loading…
Reference in New Issue
Block a user