Add wallpaper switch

This commit is contained in:
Alexander
2026-07-03 19:14:45 +02:00
parent 6f2598ef52
commit 873bec97fc
6 changed files with 306 additions and 30 deletions
+97 -11
View File
@@ -30,11 +30,53 @@ let
availableVariants = concatStringsSep " " (attrNames themeVariants);
# `variant:schemeFile` pairs so the wallpaper picker can read each variant's
# base16 palette at runtime.
variantSchemes = concatStringsSep " "
(mapAttrsToList (variant: v: "${variant}:${v.scheme}") themeVariants);
# variant:doomtheme pairs consumed by the theme-switch script's
# best-effort Doom Emacs hook (see ./theme-switch.sh).
doomThemeMap = concatStringsSep " "
(mapAttrsToList (variant: theme: "${variant}:${theme}") cfg.doomThemes);
# Stylix is only declared on hosts that import its Home Manager module
# (e.g. `fujin`). Servers (susano, izanagi, amaterasu, ...) don't, so
# `stylix` isn't declared there. We probe option *declarations* (not
# config values) to avoid an evaluation cycle.
hasStylix = options ? stylix;
# --- Wallpaper handling (runtime-cloned repo + palette matching) ----------
wallpapersEnabled = cfg.wallpaperRepo != null;
wallpapersDir = "${config.home.homeDirectory}/Wallpapers";
# hyprpaper's config points here; set-wallpaper rebinds this symlink, so we
# never have to write hyprpaper config ourselves.
wallpaperSymlink = "${config.xdg.stateHome}/theme/wallpaper-current";
wallpaper-pick = pkgs.writeShellScriptBin "wallpaper-pick" ''
SCRIPT_TAG="wallpaper-pick"
${builtins.readFile ./log.sh}
wallpapersDir="${wallpapersDir}"
wallpaperRepo="${cfg.wallpaperRepo or ""}"
variantSchemes="${variantSchemes}"
MAGICK="${getExe pkgs.imagemagick}"
GIT="${getExe pkgs.git}"
${builtins.readFile ./wallpaper-pick.sh}
'';
wallpaper-set = pkgs.writeShellScriptBin "wallpaper-set" ''
SCRIPT_TAG="wallpaper-set"
${builtins.readFile ./log.sh}
wallpaperSymlink="${wallpaperSymlink}"
${builtins.readFile ./set-wallpaper.sh}
'';
# Applied once at login (after hyprpaper) to seed a base-variant wallpaper.
wallpaper-init = pkgs.writeShellScript "wallpaper-init" ''
wp="$(${getExe wallpaper-pick} ${baseVariant} 2>/dev/null)" || exit 0
[ -n "$wp" ] && ${getExe wallpaper-set} "$wp" || true
'';
# Userspace theme switcher: activates a pre-built Home Manager
# specialisation generation. No sudo, no OS rebuild — just runs the
# specialisation's activation script in the base HM generation.
@@ -42,17 +84,16 @@ let
# The script logic lives in ./theme-switch.sh (plain shell, no Nix
# escaping); we only inject the config-derived values it needs.
theme-switch = pkgs.writeShellScriptBin "theme-switch" ''
SCRIPT_TAG="theme-switch"
${builtins.readFile ./log.sh}
baseVariant="${baseVariant}"
availableVariants="${availableVariants}"
doomThemes="${doomThemeMap}"
wallpapersEnabled="${if wallpapersEnabled then "1" else "0"}"
wallpaperPickBin="${if wallpapersEnabled then getExe wallpaper-pick else ""}"
wallpaperSetBin="${if wallpapersEnabled then getExe wallpaper-set else ""}"
${builtins.readFile ./theme-switch.sh}
'';
# Stylix is only declared on hosts that import its Home Manager module
# (e.g. `fujin`). Servers (susano, izanagi, amaterasu, ...) don't, so
# `stylix` isn't declared there. We probe option *declarations* (not
# config values) to avoid an evaluation cycle.
hasStylix = options ? stylix;
in {
options.dov.dynamic-theme = {
enable = mkEnableOption "dynamic theme switching";
@@ -95,24 +136,43 @@ in {
map the variants to `catppuccin-mocha` / `catppuccin-latte`.
'';
};
wallpaperRepo = mkOption {
type = types.nullOr types.str;
default = null;
example = "https://example.org/me/wallpapers.git";
description = ''
Git URL of a wallpaper repository. When set, theme-switch also picks
a random wallpaper whose palette matches the current variant and
applies it via hyprpaper. The repo is cloned to
`~/Wallpapers` on first use (a one-time, runtime cost it is NOT
fetched at build time, so `nix build` stays fast).
'';
};
};
config = mkIf cfg.enable (
config = mkIf cfg.enable (mkMerge [
{
home.packages = [ theme-switch ];
home.packages = [ theme-switch ]
++ optionals wallpapersEnabled [ wallpaper-pick wallpaper-set ];
}
# Only reference the `stylix` option where it is actually declared.
# `mkIf false` is NOT enough: the module system still rejects a
# definition (even a disabled one) for an option that does not exist,
# which breaks every host that doesn't import stylix. `optionalAttrs`
# removes the key structurally, so non-stylix hosts evaluate cleanly.
// optionalAttrs hasStylix {
# (keyed on `options ?`, i.e. *declarations* — never a config value) is
# the recursion-free way to gate structure on stylix presence.
(optionalAttrs hasStylix {
stylix = {
enable = true;
autoEnable = true;
# Base scheme/polarity; overridable per specialisation below.
base16Scheme = mkDefault themeVariants.${baseVariant}.scheme;
polarity = mkDefault themeVariants.${baseVariant}.polarity;
# When we manage hyprpaper's wallpaper ourselves, disable stylix's
# hyprpaper target so the two don't fight over the wallpaper path.
targets.hyprpaper.enable = mkForce (!wallpapersEnabled);
};
# Generate Home Manager specialisations for every non-base variant.
@@ -126,6 +186,32 @@ in {
};
};
}) specialisationVariants;
})
# Wallpaper handling. These options always exist structurally (HM ships
# services.hyprpaper and a freeform systemd.user.services), so we gate
# the *values* with mkIf/optionals rather than shaping structure — that
# avoids the config-depends-on-config recursion that optionalAttrs on a
# config value would cause.
{
services.hyprpaper.enable = wallpapersEnabled;
services.hyprpaper.settings = mkIf wallpapersEnabled {
splash = false;
wallpaper = [{ monitor = ""; path = wallpaperSymlink; }];
};
systemd.user.services.wallpaper-init = mkIf wallpapersEnabled {
Unit = {
Description = "Pick and apply a wallpaper matching the base theme";
After = [ "hyprpaper.service" ];
PartOf = [ "graphical-session.target" ];
};
Service = {
Type = "oneshot";
ExecStart = "${wallpaper-init}";
};
Install.WantedBy = [ "graphical-session.target" ];
};
}
);
]);
}
+24
View File
@@ -0,0 +1,24 @@
# shellcheck shell=bash
# Colorful, TTY-aware logging helpers: log_info / log_warn / log_error.
#
# Everything is written to *stderr*, so a script's stdout stays a clean data
# channel (e.g. wallpaper-pick prints the chosen image path on stdout while
# its progress goes to stderr). Set SCRIPT_TAG to prefix every line with the
# originating script, e.g. "[theme-switch] selecting wallpaper...".
#
# Colors are auto-disabled when stderr isn't a terminal or NO_COLOR is set.
__theme_log_init() {
if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then
__C_RESET=$'\033[0m'
__C_INFO=$'\033[1;34m' # blue
__C_WARN=$'\033[1;33m' # yellow
__C_ERR=$'\033[1;31m' # red
else
__C_RESET=""; __C_INFO=""; __C_WARN=""; __C_ERR=""
fi
}
__theme_log_init
log_info() { printf '%s%s%s%s\n' "${__C_INFO}" "${SCRIPT_TAG:+[$SCRIPT_TAG] }" "$*" "${__C_RESET}" >&2; }
log_warn() { printf '%s%s%s%s\n' "${__C_WARN}" "${SCRIPT_TAG:+[$SCRIPT_TAG] }" "warning: $*" "${__C_RESET}" >&2; }
log_error() { printf '%s%s%s%s\n' "${__C_ERR}" "${SCRIPT_TAG:+[$SCRIPT_TAG] }" "error: $*" "${__C_RESET}" >&2; }
+27
View File
@@ -0,0 +1,27 @@
# shellcheck shell=bash
# set-wallpaper — bind the runtime wallpaper symlink to $1 and restart hyprpaper
# so it picks up the new image. hyprpaper's config (managed by Home Manager)
# points at the symlink, so we never write hyprpaper config ourselves.
#
# Env (injected by the Nix wrapper):
# wallpaperSymlink path to the symlink hyprpaper's `wallpaper.path` reads
# Arg: image path
: "${wallpaperSymlink:?set-wallpaper: wallpaperSymlink not set}"
set -euo pipefail
target="${1:?usage: set-wallpaper <image-path>}"
[ -f "$target" ] || { log_error "not a file: $target"; exit 1; }
mkdir -p "$(dirname "$wallpaperSymlink")"
ln -sfn "$target" "$wallpaperSymlink"
# Apply live by (re)starting the user hyprpaper service. `systemctl restart`
# starts the unit even if it is inactive — important because a freshly-added
# hyprpaper service won't be running until the first switch. Tolerate the
# service being absent (e.g. on a host without hyprpaper).
if command -v systemctl >/dev/null 2>&1 && systemctl --user restart hyprpaper 2>/dev/null; then
log_info "applied $(basename "$target") (hyprpaper started)"
else
log_warn "could not start hyprpaper; wallpaper symlink set but not displayed"
fi
+23 -11
View File
@@ -57,7 +57,7 @@ do
done
if [ -z "$gen" ]; then
echo "error: could not resolve Home Manager base generation" >&2
log_error "could not resolve Home Manager base generation"
exit 1
fi
@@ -72,18 +72,16 @@ case "$variant" in
esac
if [ ! -x "$activate" ]; then
echo "error: variant '$variant' not found at $activate" >&2
echo "Available: ${availableVariants}"
log_error "variant '$variant' not found at $activate"
log_info "available: ${availableVariants}"
exit 1
fi
echo "Switching theme to $variant..."
log_info "switching theme to ${variant}"
# Best-effort: live-switch the running Doom Emacs daemon too. No config
# files are touched; if the daemon isn't running or emacsclient isn't
# installed (e.g. on a server), this is silently skipped. A theme symbol
# that isn't installed in Doom makes emacsclient return non-zero, which we
# also treat as skip.
# installed (e.g. on a server), this is silently skipped.
doom_theme=""
if [ -n "$doomThemes" ]; then
for pair in $doomThemes; do
@@ -92,12 +90,26 @@ if [ -n "$doomThemes" ]; then
esac
done
fi
if [ -n "$doom_theme" ] && command -v emacsclient >/dev/null 2>&1; then
if emacsclient --eval "(progn (mapc (function disable-theme) custom-enabled-themes) (load-theme (quote ${doom_theme}) t))" >/dev/null 2>&1; then
echo "Doom: ${doom_theme}"
if [ -n "$doom_theme" ]; then
if command -v emacsclient >/dev/null 2>&1 \
&& emacsclient --eval "(progn (mapc (function disable-theme) custom-enabled-themes) (load-theme (quote ${doom_theme}) t))" >/dev/null 2>&1; then
log_info "doom: ${doom_theme}"
else
echo "note: Doom theme switch skipped (daemon not running or '${doom_theme}' not installed)" >&2
log_warn "doom theme not applied (daemon not running or '${doom_theme}' not installed)"
fi
fi
# Pick a palette-matching wallpaper and apply it. This BLOCKS — on the very
# first run wallpaper-pick clones the wallpaper repo and builds the palette
# index, which can take a few minutes; progress is logged on stderr by the
# pick/set scripts. Failures fall back to leaving the wallpaper unchanged.
if [ "${wallpapersEnabled:-0}" = "1" ] && [ -n "${wallpaperPickBin:-}" ] && [ -n "${wallpaperSetBin:-}" ]; then
if wp="$("$wallpaperPickBin" "$variant")" && [ -n "$wp" ]; then
"$wallpaperSetBin" "$wp" || log_warn "wallpaper apply failed"
else
log_warn "wallpaper selection failed; leaving wallpaper unchanged"
fi
fi
log_info "activating Home Manager specialisation"
exec "$activate"
+131
View File
@@ -0,0 +1,131 @@
# shellcheck shell=bash
# wallpaper-pick — print a random wallpaper from $wallpapersDir whose palette
# best matches the given theme variant. Per-image palettes are extracted once
# (ImageMagick) and cached, keyed by the (immutable) source path so a rebuild
# that changes the wallpaper set reindexes automatically.
#
# Matching uses several key colors on both sides: the variant's base16
# base00base05 (the background→foreground neutral range) are each matched to
# their nearest wallpaper dominant color; the sum of those nearest distances
# is the score. A random image is picked from the lowest-score tier.
#
# Env (injected by the Nix wrapper):
# wallpapersDir directory of wallpaper images (~/Wallpapers, a git clone)
# wallpaperRepo git URL cloned into wallpapersDir on first use
# variantSchemes space-separated `variant:schemeFile` pairs (base16 YAML)
# MAGICK path to the ImageMagick `magick` binary
# GIT path to the `git` binary
# Arg: variant name (e.g. gruvbox-dark)
: "${wallpapersDir:?wallpaper-pick: wallpapersDir not set}"
: "${wallpaperRepo:?wallpaper-pick: wallpaperRepo not set}"
: "${variantSchemes:?wallpaper-pick: variantSchemes not set}"
: "${MAGICK:?wallpaper-pick: MAGICK not set}"
: "${GIT:?wallpaper-pick: GIT not set}"
set -euo pipefail
variant="${1:?usage: wallpaper-pick <variant>}"
# Ensure the wallpaper repo is cloned locally. The clone is a one-time cost
# paid on first use (not at build time), keeping `nix build` fast.
if [ ! -d "$wallpapersDir/.git" ]; then
log_info "cloning wallpaper repo (one-time, may take a few minutes)..."
mkdir -p "$(dirname "$wallpapersDir")"
"$GIT" clone --depth=1 --single-branch "$wallpaperRepo" "$wallpapersDir" >&2
log_info "clone ready"
fi
cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/wallpaper-pick"
palettes="$cache_dir/palettes.tsv"
stamp_file="$cache_dir/source"
# Cache key: the clone's current commit, so a `git pull` reindexes. Falls back
# to the resolved path if git fails for some reason.
src_stamp="$("$GIT" -C "$wallpapersDir" rev-parse HEAD 2>/dev/null || readlink -f "$wallpapersDir")"
rebuild_cache() {
img_count="$(find "$wallpapersDir" -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.webp' \) -printf . 2>/dev/null | wc -c)"
log_info "indexing ${img_count} wallpapers (one-time, building palette cache)..."
mkdir -p "$cache_dir"
tmp="$palettes.tmp"
: > "$tmp"
jobs="$(nproc 2>/dev/null || echo 4)"
# For each image: top dominant colors (by pixel count) as space-separated #hex.
find "$wallpapersDir" -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.webp' \) -print0 \
| xargs -0 -P "$jobs" -n1 sh -c '
MAGICK="$0" img="$1"
"$MAGICK" "$img" -resize 128x128^ -gravity center -extent 128x128 +dither -colors 8 -format "%c" histogram:info: 2>/dev/null \
| grep -E "^[[:space:]]*[0-9]+:" | sort -rn | grep -oE "#[0-9A-Fa-f]{6}" | head -6 | paste -sd" " - \
| { read -r hex; [ -n "$hex" ] && printf "%s\t%s\n" "$img" "$hex"; }
' "$MAGICK" >> "$tmp" || true
mv "$tmp" "$palettes"
printf '%s\n' "$src_stamp" > "$stamp_file"
}
if [ ! -f "$palettes" ] || [ ! -f "$stamp_file" ] || [ "$(cat "$stamp_file")" != "$src_stamp" ]; then
rebuild_cache
fi
# Resolve the base16 scheme file for the requested variant.
scheme=""
for pair in $variantSchemes; do
case "$pair" in
"${variant}":*) scheme="${pair#*:}"; break ;;
esac
done
[ -n "$scheme" ] || { log_error "no scheme mapped for variant '$variant'"; exit 2; }
# Theme key colors: base00base05 hex (one per line, with leading #).
theme_colors="$(grep -E '^[[:space:]]*base0[0-5]:' "$scheme" | grep -oE '#[0-9A-Fa-f]{6}')"
[ -n "$theme_colors" ] || { log_error "could not parse base0005 from $scheme"; exit 2; }
# hex (no #) -> decimal rgb on stdout
hex2dec() { printf '%d %d %d\n' "$((16#${1:0:2}))" "$((16#${1:2:2}))" "$((16#${1:4:2}))"; }
# Theme colors as parallel arrays.
t_r=(); t_g=(); t_b=()
while read -r h; do
read -r r g b < <(hex2dec "${h#\#}")
t_r+=("$r"); t_g+=("$g"); t_b+=("$b")
done <<< "$theme_colors"
# Score every indexed wallpaper: sum over theme colors of nearest wallpaper color.
scores="$cache_dir/scores.tmp"
: > "$scores"
while IFS=$'\t' read -r path whexes; do
w_r=(); w_g=(); w_b=()
for wh in $whexes; do
read -r wr wg wb < <(hex2dec "${wh#\#}")
w_r+=("$wr"); w_g+=("$wg"); w_b+=("$wb")
done
if [ "${#w_r[@]}" -eq 0 ]; then continue; fi
total=0
for i in "${!t_r[@]}"; do
mind=1000000000
for j in "${!w_r[@]}"; do
dr=$(( t_r[i] - w_r[j] )); dg=$(( t_g[i] - w_g[j] )); db=$(( t_b[i] - w_b[j] ))
cd=$(( dr*dr + dg*dg + db*db ))
if [ "$cd" -lt "$mind" ]; then mind=$cd; fi
done
total=$(( total + mind ))
done
printf '%s\t%s\n' "$total" "$path"
done < "$palettes" | sort -n -k1 > "$scores"
[ -s "$scores" ] || { log_error "no wallpapers indexed"; exit 3; }
# Matched tier: within 1.4x of the best score (fall back to closest 20).
best="$(head -n1 "$scores" | cut -f1)"
threshold=$(( best * 14 / 10 ))
matched="$(awk -F'\t' -v t="$threshold" '$1 <= t {print $2}' "$scores")"
if [ "$(printf '%s\n' "$matched" | grep -c .)" -lt 5 ]; then
matched="$(head -n20 "$scores" | cut -f2)"
fi
# Print the chosen PATH on stdout (data channel for callers) and log the
# human-readable choice on stderr.
chosen="$(printf '%s\n' "$matched" | shuf -n1)"
log_info "picked $(basename "$chosen") for ${variant}"
printf '%s\n' "$chosen"
+4 -8
View File
@@ -70,7 +70,10 @@
notification.mako.enable = true;
dynamic-theme.enable = true;
dynamic-theme = {
enable = true;
wallpaperRepo = "https://gitea.susano-homelab.duckdns.org/fujin/gruvbox-wallpapers.git";
};
};
programs = {
@@ -88,13 +91,6 @@
directory = ["/home/${username}/nixos-dotfiles" "/home/${username}/.cache/nix"];
};
};
hooks = {
commit-msg = pkgs.writeScript "commit-msg" ''
#!${pkgs.bash}/bin/bash
# Remove Co-Authored-by lines from commit messages (Claude likes to add these)
${pkgs.gnused}/bin/sed -i '/^Co-[Aa]uthored-[Bb]y:/d' "$1"
'';
};
};
};