feat: init dynamic wallpaper ($theme gen)
Some checks are pending
quality / changes (push) Waiting to run
quality / Flake Check (push) Blocked by required conditions
quality / Nix Format Check (push) Blocked by required conditions
quality / Deploy netty (push) Blocked by required conditions

This commit is contained in:
Harivansh Rathi 2026-04-08 15:10:57 -04:00
parent 38c096dbc1
commit af19b1e78b
6 changed files with 489 additions and 11 deletions

View file

@ -76,6 +76,15 @@ let
runtimeInputs = with pkgs; [ nix ];
};
wallpaper-gen = mkScript {
name = "wallpaper-gen";
file = ./wallpaper-gen.sh;
runtimeInputs = with pkgs; [ uv ];
replacements = {
"@WALLPAPER_GEN_PY@" = "${./wallpaper-gen.py}";
};
};
theme = mkScript {
name = "theme";
file = ./theme.sh;
@ -110,8 +119,12 @@ let
"@LAZYGIT_DARWIN_FILE@" = "${config.home.homeDirectory}/Library/Application Support/lazygit/config.yml";
"@LAZYGIT_DARWIN_DARK_FILE@" = "${config.home.homeDirectory}/Library/Application Support/lazygit/config-dark.yml";
"@LAZYGIT_DARWIN_LIGHT_FILE@" = "${config.home.homeDirectory}/Library/Application Support/lazygit/config-light.yml";
"@WALLPAPER_DARK_FILE@" = "${theme.wallpapers.dark}";
"@WALLPAPER_LIGHT_FILE@" = "${theme.wallpapers.light}";
"@WALLPAPER_DIR@" = theme.wallpapers.dir;
"@WALLPAPER_DARK_FILE@" = theme.wallpapers.dark;
"@WALLPAPER_LIGHT_FILE@" = theme.wallpapers.light;
"@WALLPAPER_CURRENT_FILE@" = theme.wallpapers.current;
"@WALLPAPER_STATIC_DARK@" = "${theme.wallpapers.staticDark}";
"@WALLPAPER_STATIC_LIGHT@" = "${theme.wallpapers.staticLight}";
};
};
};

View file

@ -1,5 +1,5 @@
usage() {
echo "usage: theme <dark|light|toggle|current>"
echo "usage: theme <dark|light|toggle|gen>"
}
read_mode() {
@ -14,6 +14,21 @@ read_mode() {
echo "@DEFAULT_MODE@"
}
set_wallpaper() {
if [[ "$(uname -s)" == "Darwin" ]] && command -v osascript >/dev/null 2>&1; then
if [[ -f "@WALLPAPER_CURRENT_FILE@" ]]; then
wp_resolved=$(readlink -f "@WALLPAPER_CURRENT_FILE@" 2>/dev/null || echo "@WALLPAPER_CURRENT_FILE@")
# macOS caches wallpaper data by file path - copy to a unique temp path
# so macOS is forced to read the new image data
wp_dir=$(dirname "$wp_resolved")
wp_tmp="${wp_dir}/.wallpaper-active-$$.jpg"
rm -f "${wp_dir}"/.wallpaper-active-*.jpg 2>/dev/null || true
cp "$wp_resolved" "$wp_tmp"
osascript -e "tell application \"System Events\" to tell every desktop to set picture to \"${wp_tmp}\"" >/dev/null 2>&1 || true
fi
fi
}
link_mode_assets() {
local mode="$1"
local fzf_target
@ -44,13 +59,17 @@ link_mode_assets() {
;;
esac
mkdir -p "@STATE_DIR@" "@FZF_DIR@" "@GHOSTTY_DIR@" "@TMUX_DIR@" "@LAZYGIT_DIR@"
mkdir -p "@STATE_DIR@" "@FZF_DIR@" "@GHOSTTY_DIR@" "@TMUX_DIR@" "@LAZYGIT_DIR@" "@WALLPAPER_DIR@"
printf '%s\n' "$mode" > "@STATE_FILE@"
ln -sfn "$fzf_target" "@FZF_CURRENT_FILE@"
ln -sfn "$ghostty_target" "@GHOSTTY_CURRENT_FILE@"
ln -sfn "$tmux_target" "@TMUX_CURRENT_FILE@"
ln -sfn "$lazygit_target" "@LAZYGIT_CURRENT_FILE@"
if [[ -f "$wallpaper" ]]; then
ln -sfn "$wallpaper" "@WALLPAPER_CURRENT_FILE@"
fi
if command -v tmux >/dev/null 2>&1 && tmux start-server >/dev/null 2>&1; then
tmux source-file "@TMUX_CONFIG@" >/dev/null 2>&1 || true
fi
@ -65,7 +84,7 @@ link_mode_assets() {
osascript -e "tell application \"System Events\" to tell appearance preferences to set dark mode to ${apple_dark_mode}" >/dev/null 2>&1 || true
osascript -e "tell application \"System Events\" to tell every desktop to set picture to \"${wallpaper}\"" >/dev/null 2>&1 || true
set_wallpaper
osascript <<'EOF' >/dev/null 2>&1 || true
tell application "System Events"
@ -91,7 +110,7 @@ EOF
)
}
mode="${1:-current}"
mode="${1:-}"
case "$mode" in
dark|light)
@ -103,8 +122,10 @@ case "$mode" in
mode="dark"
fi
;;
current)
read_mode
gen)
wallpaper-gen
set_wallpaper
printf 'generated new wallpaper\n'
exit 0
;;
*)

408
scripts/wallpaper-gen.py Normal file
View file

@ -0,0 +1,408 @@
# /// script
# dependencies = ["pillow"]
# ///
"""Generate topographic contour wallpapers from real-world elevation data."""
from __future__ import annotations
import json
import math
import os
import random
import subprocess
import sys
import urllib.request
from datetime import datetime
from io import BytesIO
from typing import Iterator
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont
Coord = tuple[float, float]
Candidate = tuple[float, float, str]
Rgb = tuple[int, int, int]
HOME: str = os.environ["HOME"]
DIR: str = os.path.join(HOME, "Pictures", "Screensavers")
STATE: str = os.path.join(
os.environ.get("XDG_STATE_HOME", os.path.join(HOME, ".local", "state")),
"theme",
"current",
)
LOG_DIR: str = os.path.join(
os.environ.get("XDG_STATE_HOME", os.path.join(HOME, ".local", "state")),
"wallpaper",
)
LOG_FILE: str = os.path.join(LOG_DIR, "gen.log")
HISTORY_FILE: str = os.path.join(LOG_DIR, "history.json")
CACHE: str = os.path.join(
os.environ.get("XDG_CACHE_HOME", os.path.join(HOME, ".cache")),
"wallpaper",
)
os.makedirs(DIR, exist_ok=True)
os.makedirs(LOG_DIR, exist_ok=True)
os.makedirs(CACHE, exist_ok=True)
TILE_URL: str = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
TILE_SIZE: int = 256
ZOOM: int = 11
CONTOUR_LEVELS: int = 20
# cozybox palette - matches lib/theme.nix
THEMES: dict[str, dict[str, Rgb]] = {
"dark": {
"bg": (0x18, 0x18, 0x18),
"line": (0x2d, 0x2d, 0x2d),
"label": (0xFF, 0xFF, 0xFF),
},
"light": {
"bg": (0xE7, 0xE7, 0xE7),
"line": (0xCE, 0xCE, 0xCE),
"label": (0x00, 0x00, 0x00),
},
}
# curated mountain/terrain locations with interesting topography
LOCATIONS: list[Coord] = [
(46.56, 7.69), # Swiss Alps - Bernese Oberland
(36.10, -112.09), # Grand Canyon
(27.99, 86.93), # Everest region
(61.22, 6.78), # Norwegian fjords
(-50.94, -73.15), # Patagonia
(36.25, 137.60), # Japanese Alps
(39.11, -106.45), # Colorado Rockies
(46.42, 11.84), # Dolomites
(44.07, 6.87), # French Alps
(-43.59, 170.14), # New Zealand Alps
(64.07, -16.20), # Iceland highlands
(28.60, 83.82), # Annapurna
(42.50, 44.50), # Caucasus
(47.07, 12.69), # Austrian Alps
(45.83, 6.86), # Mont Blanc
]
MIN_RELIEF: int = 400
MAX_RETRIES: int = 20
SEA_LEVEL: int = 32768
MIN_LAND_FRACTION: float = 0.1
PREVIEW_W: int = 384
PREVIEW_H: int = 240
GRID_COLS: int = 6
GRID_ROWS: int = 4
MIN_CONTOUR_COVERAGE: float = 0.15
MIN_OCCUPIED_CELLS: int = 12
MAX_CELL_SHARE: float = 0.15
MAX_CACHED_CANDIDATES: int = 24
HISTORY_SIZE: int = 10 # remember this many recent locations to avoid repeats
RUN_ID: str = f"{datetime.now().strftime('%Y%m%dT%H%M%S')}-pid{os.getpid()}"
def log(message: str) -> None:
line = f"{datetime.now().astimezone().isoformat(timespec='seconds')} [{RUN_ID}] {message}"
print(line, file=sys.stderr, flush=True)
with open(LOG_FILE, "a", encoding="utf-8") as f:
print(line, file=f)
def load_history() -> list[Coord]:
try:
with open(HISTORY_FILE, encoding="utf-8") as f:
entries = json.load(f)
return [(e[0], e[1]) for e in entries if isinstance(e, list) and len(e) == 2]
except (FileNotFoundError, json.JSONDecodeError, KeyError):
return []
def save_history(lat: float, lon: float) -> None:
history = load_history()
history.append((round(lat, 2), round(lon, 2)))
history = history[-HISTORY_SIZE:]
with open(HISTORY_FILE, "w", encoding="utf-8") as f:
json.dump(history, f)
def random_location() -> Coord:
return random.uniform(-60, 70), random.uniform(-180, 180)
def cached_locations(tiles_x: int, tiles_y: int) -> list[Coord]:
suffix = f"_z{ZOOM}_{tiles_x}x{tiles_y}.png"
locations: list[Coord] = []
for name in os.listdir(CACHE):
if not name.startswith("terrain_") or not name.endswith(suffix):
continue
coords = name[len("terrain_") : -len(suffix)]
try:
lat, lon = coords.split("_", 1)
locations.append((float(lat), float(lon)))
except ValueError:
continue
random.shuffle(locations)
return locations[:MAX_CACHED_CANDIDATES]
def candidate_locations(tiles_x: int, tiles_y: int) -> Iterator[Candidate]:
seen: set[Coord] = set()
recent = {(round(lat, 2), round(lon, 2)) for lat, lon in load_history()}
cached: list[Coord] = cached_locations(tiles_x, tiles_y)
fallback: list[Coord] = list(LOCATIONS)
random.shuffle(fallback)
log(f"candidate_pools cached={len(cached)} curated={len(fallback)} random={MAX_RETRIES} recent_skip={len(recent)}")
for lat, lon in cached:
key = (round(lat, 2), round(lon, 2))
if key in seen or key in recent:
continue
seen.add(key)
yield lat, lon, "cached"
for lat, lon in fallback:
key = (round(lat, 2), round(lon, 2))
if key in seen or key in recent:
continue
seen.add(key)
yield lat, lon, "curated"
for _ in range(MAX_RETRIES):
lat, lon = random_location()
key = (round(lat, 2), round(lon, 2))
if key in seen or key in recent:
continue
seen.add(key)
yield lat, lon, "random"
def reverse_geocode(lat: float, lon: float) -> str | None:
try:
url = f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lon}&format=json&zoom=6&accept-language=en"
req = urllib.request.Request(url, headers={"User-Agent": "wallpaper-gen/1.0"})
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read())
addr = data.get("address", {})
name = (
addr.get("region")
or addr.get("state_district")
or addr.get("state")
or addr.get("county")
or addr.get("country")
)
return name
except Exception:
return None
def get_resolution() -> tuple[int, int]:
"""Get primary display resolution on macOS."""
try:
out = subprocess.check_output(
["system_profiler", "SPDisplaysDataType", "-json"],
timeout=5,
).decode()
displays = json.loads(out)
for gpu in displays.get("SPDisplaysDataType", []):
for disp in gpu.get("spdisplays_ndrvs", []):
res = disp.get("_spdisplays_resolution", "")
if " x " in res:
parts = res.split(" x ")
w = int(parts[0].strip())
h = int(parts[1].split()[0].strip())
return w, h
except Exception:
pass
return 3024, 1964 # MacBook Pro 14" default
def lat_lon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
n = 2**zoom
x = int(n * ((lon + 180) / 360))
lat_rad = math.radians(lat)
y = int(n * (1 - (math.log(math.tan(lat_rad) + 1 / math.cos(lat_rad)) / math.pi)) / 2)
return x, y
def fetch_tile(z: int, x: int, y: int) -> Image.Image:
url = TILE_URL.format(z=z, x=x, y=y)
req = urllib.request.Request(url, headers={"User-Agent": "wallpaper-gen/1.0"})
with urllib.request.urlopen(req, timeout=15) as resp:
return Image.open(BytesIO(resp.read()))
def fetch_terrain(
lat: float, lon: float, tiles_x: int, tiles_y: int, zoom: int
) -> tuple[Image.Image, bool]:
tag = f"{lat:.2f}_{lon:.2f}_z{zoom}_{tiles_x}x{tiles_y}"
cache_file = os.path.join(CACHE, f"terrain_{tag}.png")
if os.path.exists(cache_file):
return Image.open(cache_file), True
cx, cy = lat_lon_to_tile(lat, lon, zoom)
sx, sy = cx - tiles_x // 2, cy - tiles_y // 2
full = Image.new("RGB", (tiles_x * TILE_SIZE, tiles_y * TILE_SIZE))
for ty in range(tiles_y):
for tx in range(tiles_x):
tile = fetch_tile(zoom, sx + tx, sy + ty)
full.paste(tile, (tx * TILE_SIZE, ty * TILE_SIZE))
full.save(cache_file)
return full, False
def decode_terrarium(img: Image.Image) -> tuple[Image.Image, int, float]:
raw_bytes = img.tobytes()
px_count = len(raw_bytes) // 3
raw: list[int] = [0] * px_count
land: int = 0
for i in range(px_count):
off = i * 3
raw[i] = (raw_bytes[off] << 8) | raw_bytes[off + 1]
if raw[i] > SEA_LEVEL:
land += 1
lo, hi = min(raw), max(raw)
rng = hi - lo or 1
norm = bytearray((e - lo) * 255 // rng for e in raw)
return Image.frombytes("L", img.size, bytes(norm)), hi - lo, land / px_count
def build_contour_mask(elevation: Image.Image, W: int, H: int) -> Image.Image:
S: int = 2
iW, iH = W * S, H * S
terrain = elevation.resize((iW, iH), Image.BICUBIC)
terrain = terrain.filter(ImageFilter.GaussianBlur(radius=15 * S))
step = max(1, 256 // CONTOUR_LEVELS)
terrain = terrain.point(lambda p: (p // step) * step)
eroded = terrain.filter(ImageFilter.MinFilter(3))
edges = ImageChops.subtract(terrain, eroded)
edges = edges.point(lambda p: 255 if p > 0 else 0)
edges = edges.filter(ImageFilter.MaxFilter(3))
edges = edges.filter(ImageFilter.GaussianBlur(radius=1.5 * S))
return edges.point(lambda p: 255 if p > 80 else 0)
def contour_stats(elevation: Image.Image) -> tuple[float, int, float]:
mask = build_contour_mask(elevation, PREVIEW_W, PREVIEW_H).resize(
(PREVIEW_W, PREVIEW_H), Image.NEAREST
)
mask_bytes = mask.tobytes()
total: int = len(mask_bytes)
filled: int = 0
cell_counts: list[int] = [0] * (GRID_COLS * GRID_ROWS)
for y in range(PREVIEW_H):
row_off = y * PREVIEW_W
cy = y * GRID_ROWS // PREVIEW_H
for x in range(PREVIEW_W):
if mask_bytes[row_off + x]:
filled += 1
cx = x * GRID_COLS // PREVIEW_W
cell_counts[cy * GRID_COLS + cx] += 1
coverage = filled / total
occupied = sum(1 for count in cell_counts if count)
largest_share = max(cell_counts) / filled if filled else 1
return coverage, occupied, largest_share
def candidate_summary(
elevation: Image.Image, relief: int, land_fraction: float
) -> tuple[bool, str]:
if relief < MIN_RELIEF or land_fraction < MIN_LAND_FRACTION:
return False, (
f"relief={relief} land_fraction={land_fraction:.3f} "
f"thresholds=({MIN_RELIEF},{MIN_LAND_FRACTION:.3f})"
)
coverage, occupied, largest_share = contour_stats(elevation)
ok = (
coverage >= MIN_CONTOUR_COVERAGE
and occupied >= MIN_OCCUPIED_CELLS
and largest_share <= MAX_CELL_SHARE
)
return ok, (
f"relief={relief} land_fraction={land_fraction:.3f} "
f"coverage={coverage:.3f} occupied={occupied} largest_cell_share={largest_share:.3f} "
f"thresholds=({MIN_CONTOUR_COVERAGE:.3f},{MIN_OCCUPIED_CELLS},{MAX_CELL_SHARE:.3f})"
)
def render_contours(
elevation: Image.Image, W: int, H: int, bg: Rgb, line_color: Rgb
) -> Image.Image:
S: int = 2
iW, iH = W * S, H * S
edges = build_contour_mask(elevation, W, H)
bg_img = Image.new("RGB", (iW, iH), bg)
fg_img = Image.new("RGB", (iW, iH), line_color)
img = Image.composite(fg_img, bg_img, edges)
return img.resize((W, H), Image.LANCZOS)
def find_font() -> str | None:
candidates = [
os.path.join(HOME, "Library/Fonts/BerkeleyMono-Regular.otf"),
os.path.join(HOME, "Library/Fonts/BerkeleyMono-Regular.ttf"),
os.path.join(HOME, ".local/share/fonts/berkeley-mono/BerkeleyMono-Regular.ttf"),
"/Library/Fonts/BerkeleyMono-Regular.otf",
]
for path in candidates:
if os.path.exists(path):
return path
return None
def gen() -> None:
W, H = get_resolution()
tiles_x = math.ceil(W / TILE_SIZE)
tiles_y = math.ceil(H / TILE_SIZE)
theme = "dark"
try:
with open(STATE) as f:
t = f.read().strip()
if t in THEMES:
theme = t
except FileNotFoundError:
pass
log(f"start theme={theme} resolution={W}x{H} tiles={tiles_x}x{tiles_y}")
for index, (lat, lon, source) in enumerate(candidate_locations(tiles_x, tiles_y), start=1):
log(f"candidate[{index}] source={source} lat={lat:.2f} lon={lon:.2f} begin")
try:
terrain_img, cache_hit = fetch_terrain(lat, lon, tiles_x, tiles_y, ZOOM)
except Exception as err:
log(f"candidate[{index}] source={source} lat={lat:.2f} lon={lon:.2f} fetch_error={err!r}")
continue
elevation, relief, land_fraction = decode_terrarium(terrain_img)
ok, summary = candidate_summary(elevation, relief, land_fraction)
status = "accept" if ok else "reject"
cache = "hit" if cache_hit else "miss"
log(f"candidate[{index}] source={source} lat={lat:.2f} lon={lon:.2f} cache={cache} {status} {summary}")
if ok:
break
else:
log("finish status=no_candidate")
return
elevation = elevation.crop((0, 0, min(elevation.width, W), min(elevation.height, H)))
save_history(lat, lon)
place = reverse_geocode(lat, lon)
log(f"selected lat={lat:.2f} lon={lon:.2f} place={place or '<none>'}")
coords = f"{lat:.2f}, {lon:.2f}"
label = f"{place} ({coords})" if place else coords
font_path = find_font()
for theme_name, colors in THEMES.items():
img = render_contours(elevation, W, H, colors["bg"], colors["line"])
draw = ImageDraw.Draw(img)
if font_path:
font = ImageFont.truetype(font_path, 14)
else:
font = ImageFont.load_default()
draw.text((24, H - 30), label, fill=colors["label"], font=font)
out_path = os.path.join(DIR, f"wallpaper-{theme_name}.jpg")
img.save(out_path, quality=95)
log(f"wrote theme={theme_name} path={out_path}")
link = os.path.join(DIR, "wallpaper.jpg")
if os.path.lexists(link):
os.unlink(link)
target = os.path.join(DIR, f"wallpaper-{theme}.jpg")
os.symlink(target, link)
log(f"finish status=ok target={target} link={link}")
if __name__ == "__main__":
gen()

1
scripts/wallpaper-gen.sh Normal file
View file

@ -0,0 +1 @@
uv run "@WALLPAPER_GEN_PY@"