From af19b1e78bbef0b81f1c41dc73932f2df117b509 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Wed, 8 Apr 2026 15:10:57 -0400 Subject: [PATCH] feat: init dynamic wallpaper ($theme gen) --- home/scripts.nix | 33 +++- lib/theme.nix | 8 +- scripts/default.nix | 17 +- scripts/theme.sh | 33 +++- scripts/wallpaper-gen.py | 408 +++++++++++++++++++++++++++++++++++++++ scripts/wallpaper-gen.sh | 1 + 6 files changed, 489 insertions(+), 11 deletions(-) create mode 100644 scripts/wallpaper-gen.py create mode 100644 scripts/wallpaper-gen.sh diff --git a/home/scripts.nix b/home/scripts.nix index 670db99..6f206c7 100644 --- a/home/scripts.nix +++ b/home/scripts.nix @@ -14,7 +14,12 @@ in ++ lib.optionals hostConfig.isDarwin (builtins.attrValues customScripts.darwinPackages); home.activation.initializeThemeState = lib.hm.dag.entryAfter [ "writeBoundary" ] '' - mkdir -p "${customScripts.theme.paths.stateDir}" "${customScripts.theme.paths.fzfDir}" "${customScripts.theme.paths.ghosttyDir}" "${customScripts.theme.paths.tmuxDir}" + mkdir -p "${customScripts.theme.paths.stateDir}" \ + "${customScripts.theme.paths.fzfDir}" \ + "${customScripts.theme.paths.ghosttyDir}" \ + "${customScripts.theme.paths.tmuxDir}" \ + "${customScripts.theme.paths.lazygitDir}" \ + "${customScripts.theme.wallpapers.dir}" if [[ -f "${customScripts.theme.paths.stateFile}" ]]; then mode=$(tr -d '[:space:]' < "${customScripts.theme.paths.stateFile}") @@ -28,17 +33,43 @@ in fzf_target="${customScripts.theme.paths.fzfDir}/cozybox-light" ghostty_target="${customScripts.theme.paths.ghosttyDir}/cozybox-light" tmux_target="${customScripts.tmuxConfigs.light}" + lazygit_target="${customScripts.theme.paths.lazygitDir}/config-light.yml" ;; *) printf '%s\n' "${customScripts.theme.defaultMode}" > "${customScripts.theme.paths.stateFile}" fzf_target="${customScripts.theme.paths.fzfDir}/cozybox-dark" ghostty_target="${customScripts.theme.paths.ghosttyDir}/cozybox-dark" tmux_target="${customScripts.tmuxConfigs.dark}" + lazygit_target="${customScripts.theme.paths.lazygitDir}/config-dark.yml" ;; esac ln -sfn "$fzf_target" "${customScripts.theme.paths.fzfCurrentFile}" ln -sfn "$ghostty_target" "${customScripts.theme.paths.ghosttyCurrentFile}" ln -sfn "$tmux_target" "${customScripts.theme.paths.tmuxCurrentFile}" + ln -sfn "$lazygit_target" "${customScripts.theme.paths.lazygitCurrentFile}" + ${lib.optionalString hostConfig.isDarwin '' + lg_darwin="${config.home.homeDirectory}/Library/Application Support/lazygit" + mkdir -p "$lg_darwin" + case "$mode" in + light) ln -sfn "$lg_darwin/config-light.yml" "$lg_darwin/config.yml" ;; + *) ln -sfn "$lg_darwin/config-dark.yml" "$lg_darwin/config.yml" ;; + esac + ''} + + # seed wallpapers from static assets if no generated ones exist yet + if [[ ! -f "${customScripts.theme.wallpapers.dark}" ]]; then + cp "${customScripts.theme.wallpapers.staticDark}" "${customScripts.theme.wallpapers.dark}" + fi + if [[ ! -f "${customScripts.theme.wallpapers.light}" ]]; then + cp "${customScripts.theme.wallpapers.staticLight}" "${customScripts.theme.wallpapers.light}" + fi + + # ensure wallpaper symlink points to active mode + case "$mode" in + light) wp_target="${customScripts.theme.wallpapers.light}" ;; + *) wp_target="${customScripts.theme.wallpapers.dark}" ;; + esac + ln -sfn "$wp_target" "${customScripts.theme.wallpapers.current}" ''; } diff --git a/lib/theme.nix b/lib/theme.nix index e869c49..11bda45 100644 --- a/lib/theme.nix +++ b/lib/theme.nix @@ -14,8 +14,12 @@ let gray = "#928374"; }; wallpapers = { - dark = ../assets/wallpapers/topography-dark.jpg; - light = ../assets/wallpapers/topography-light.jpg; + dir = "${config.home.homeDirectory}/Pictures/Screensavers"; + dark = "${config.home.homeDirectory}/Pictures/Screensavers/wallpaper-dark.jpg"; + light = "${config.home.homeDirectory}/Pictures/Screensavers/wallpaper-light.jpg"; + current = "${config.home.homeDirectory}/Pictures/Screensavers/wallpaper.jpg"; + staticDark = ../assets/wallpapers/topography-dark.jpg; + staticLight = ../assets/wallpapers/topography-light.jpg; }; paths = { stateDir = "${config.xdg.stateHome}/theme"; diff --git a/scripts/default.nix b/scripts/default.nix index 7362df5..c55b454 100644 --- a/scripts/default.nix +++ b/scripts/default.nix @@ -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}"; }; }; }; diff --git a/scripts/theme.sh b/scripts/theme.sh index 7a60ab3..2123546 100644 --- a/scripts/theme.sh +++ b/scripts/theme.sh @@ -1,5 +1,5 @@ usage() { - echo "usage: theme " + echo "usage: theme " } 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 ;; *) diff --git a/scripts/wallpaper-gen.py b/scripts/wallpaper-gen.py new file mode 100644 index 0000000..92fdab3 --- /dev/null +++ b/scripts/wallpaper-gen.py @@ -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 ''}") + + 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() diff --git a/scripts/wallpaper-gen.sh b/scripts/wallpaper-gen.sh new file mode 100644 index 0000000..df9165a --- /dev/null +++ b/scripts/wallpaper-gen.sh @@ -0,0 +1 @@ +uv run "@WALLPAPER_GEN_PY@"