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:
Krzysztof Rudnicki 2026-11-11 16:31:06 +01:00
parent c409ebb3eb
commit d502580e3f
30 changed files with 1681 additions and 39 deletions

View 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>"
}

View File

@ -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"
]
}

View 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"
]
}

View File

@ -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

View 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 "$@"

View 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

View 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

View 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.
"""

View 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")

View 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"),
)

View 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,
)

View 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)

View 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

View 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

View 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

View 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")

View 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

View 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)

View 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)

View 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

View 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),
}

View File

@ -0,0 +1 @@
"""Tests for wallpaper_rotator."""

View 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

View 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)

View 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)]
]

View 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

View 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"}

View 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)

View 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

View 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"