diff --git a/hm-modules/theme/default.nix b/hm-modules/theme/default.nix index 31448db..4f85421 100644 --- a/hm-modules/theme/default.nix +++ b/hm-modules/theme/default.nix @@ -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" ]; + }; } - ); + ]); } diff --git a/hm-modules/theme/log.sh b/hm-modules/theme/log.sh new file mode 100644 index 0000000..c96b4f4 --- /dev/null +++ b/hm-modules/theme/log.sh @@ -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; } diff --git a/hm-modules/theme/set-wallpaper.sh b/hm-modules/theme/set-wallpaper.sh new file mode 100644 index 0000000..47b4bbc --- /dev/null +++ b/hm-modules/theme/set-wallpaper.sh @@ -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 }" +[ -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 diff --git a/hm-modules/theme/theme-switch.sh b/hm-modules/theme/theme-switch.sh index 78c6f6a..d7d73ce 100644 --- a/hm-modules/theme/theme-switch.sh +++ b/hm-modules/theme/theme-switch.sh @@ -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" diff --git a/hm-modules/theme/wallpaper-pick.sh b/hm-modules/theme/wallpaper-pick.sh new file mode 100644 index 0000000..eeeb9bd --- /dev/null +++ b/hm-modules/theme/wallpaper-pick.sh @@ -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 +# base00–base05 (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 }" + +# 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: base00–base05 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 base00–05 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" diff --git a/machines/fujin/main/home.nix b/machines/fujin/main/home.nix index d40f9e3..70473a5 100644 --- a/machines/fujin/main/home.nix +++ b/machines/fujin/main/home.nix @@ -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" - ''; - }; }; };