theming: re-theme every app on wallpaper switch, crossfade wallpaper

settings/theming.nix renders a full config set per wallpaper (GTK, ghostty,
btop, hyprland, vesktop, zen, folder icons) from its own base16 palette;
theme-apply swaps them in and reloads what can reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-08-19 14:31:24 +01:00
parent 42adab61df
commit 1091495674
6 changed files with 560 additions and 227 deletions

View file

@ -6,51 +6,18 @@ let
isMacbook = config.networking.hostName == "FredOS-Macbook";
ui = import ./ui.nix; # shared corner-rounding knob
# ── Wallpaper set ──────────────────────────────────────────────────────
# Every image in walls/ gets its own dark base16 palette, generated at
# build time by stylix's own palette-generator. The shell picks one at
# RUNTIME (state file + FileView in Theme.qml), so switching wallpaper
# re-tints the whole shell without a rebuild.
# ── Wallpapers ─────────────────────────────────────────────────────────
# Built in settings/theming.nix: one entry per image in walls/, each with
# a theme directory holding that wallpaper's base16 palette and the config
# files every other app is re-themed from. The shell picks one at RUNTIME
# (state file + FileView in Theme.qml), so switching re-tints the desktop
# without a rebuild.
#
# One derivation per image, not one for the set: palette generation is a
# genetic search that takes seconds, and per-image derivations mean adding
# a wallpaper only regenerates the new one. builtins.path hashes the image
# content alone, so unrelated repo edits don't invalidate any of them.
paletteGenerator =
inputs.stylix.packages.${pkgs.stdenv.hostPlatform.system}.palette-generator;
wallFiles = builtins.attrNames (lib.filterAttrs
(n: t: t == "regular"
&& lib.any (e: lib.hasSuffix e (lib.toLower n)) [ ".png" ".jpg" ".jpeg" ])
(builtins.readDir (inputs.self + "/walls")));
# Store names allow a narrow charset, filenames don't. Keep wallpaper
# names plain — a space survives, anything more exotic may not.
wallStem = f: lib.head (lib.splitString "." f);
wallSlug = f: lib.replaceStrings [ " " ] [ "-" ] (wallStem f);
wallImage = f: builtins.path {
name = "wall-" + lib.replaceStrings [ " " ] [ "-" ] f;
path = inputs.self + "/walls/${f}";
};
wallPalette = f: pkgs.runCommand "palette-${wallSlug f}" { } ''
mkdir -p $out
${paletteGenerator}/bin/palette-generator dark ${wallImage f} $out/palette.json
'';
# The list Theme.qml carries verbatim. Naming the store paths inside a
# home-manager file keeps both image and palette alive as generation
# references — no extra gcroot needed.
wallsJson = builtins.toJSON (map (f: {
name = wallStem f;
image = "${wallImage f}";
palette = "${wallPalette f}/palette.json";
}) wallFiles);
# Falls back to walls/wallpaper.png — the one stylix themes everything
# else from — so a missing state file lands on the build-time scheme.
defaultWall = if lib.elem "wallpaper" (map wallStem wallFiles)
then "wallpaper"
else wallStem (lib.head wallFiles);
# Naming the store paths inside a home-manager file is what keeps the
# images and themes alive as generation references — no extra gcroot.
wallsJson = builtins.toJSON config.fred.theming.walls;
defaultWall = config.fred.theming.defaultWall;
themeApply = config.fred.theming.apply;
in
{
config = lib.mkIf (lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ]) {
@ -463,6 +430,7 @@ in
readonly property var currentWall:
walls.find(w => w.name === wallState.name) || walls[0] || null
readonly property string wallImage: currentWall ? currentWall.image : ""
readonly property string themeDir: currentWall ? currentWall.theme : ""
function setWall(name) {
wallState.name = name;
@ -476,7 +444,7 @@ in
// scheme (walls/wallpaper.png), which is also what shows if
// a palette file is ever missing or malformed.
property FileView palFile: FileView {
path: theme.currentWall ? theme.currentWall.palette : ""
path: theme.themeDir === "" ? "" : theme.themeDir + "/palette.json"
printErrors: false
// Synchronous: an async first read paints a frame in the
// previous scheme on every switch.
@ -1360,6 +1328,7 @@ in
readonly property string askFetch: "${askFetchScript}"
readonly property string wxFetch: "${wxFetchScript}"
readonly property string xdgOpen: "${pkgs.xdg-utils}/bin/xdg-open"
readonly property string themeApply: "${themeApply}"
}
'';
};
@ -1380,6 +1349,21 @@ in
property var mainBar: null
signal notificationReceived()
// Push the active wallpaper's theme out to everything that
// isn't quickshell GTK, ghostty, btop, vesktop, zen,
// hyprland, folder icons. Also on startup, so a login (or a
// soft reload after a rebuild) re-asserts the current choice
// rather than leaving apps on whatever was last written.
function applyTheme() {
if (Theme.themeDir !== "")
Quickshell.execDetached([Commands.themeApply, Theme.themeDir]);
}
Component.onCompleted: applyTheme()
Connections {
target: Theme
function onThemeDirChanged() { root.applyTheme(); }
}
// Bound in hyprland.nix: Super+R launcher (bottom of the
// bar window), Super+L session menu (right edge).
IpcHandler {
@ -1588,11 +1572,57 @@ in
anchors { top: true; bottom: true; left: true; right: true }
color: "black"
Image {
// Two layers so a switch crossfades instead of
// cutting: the new image loads into whichever layer
// is hidden, then the pair swap opacity. The loser's
// source is dropped once the fade ends a spare
// full-screen texture is real VRAM to leave resident
// on a machine that also runs games.
Item {
id: wallLayer
anchors.fill: parent
source: "file://" + Theme.wallImage
cache: false // same-URL pixmap cache survives soft reload
fillMode: Image.PreserveAspectCrop
readonly property string wanted: "file://" + Theme.wallImage
property bool bFront: false
Image {
id: wallA
anchors.fill: parent
cache: false // same-URL pixmap cache survives soft reload
fillMode: Image.PreserveAspectCrop
opacity: wallLayer.bFront ? 0 : 1
Behavior on opacity {
NumberAnimation { duration: 450; easing.type: Easing.InOutQuad }
}
}
Image {
id: wallB
anchors.fill: parent
cache: false
fillMode: Image.PreserveAspectCrop
opacity: wallLayer.bFront ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: 450; easing.type: Easing.InOutQuad }
}
}
// Local files load synchronously, so by the time
// the opacities cross the new frame is decoded
// no black flash between the two.
Component.onCompleted: wallA.source = wanted;
onWantedChanged: {
(bFront ? wallA : wallB).source = wanted;
bFront = !bFront;
_dropBack.restart();
}
Timer {
id: _dropBack
interval: 500 // just past the fade
onTriggered: (wallLayer.bFront ? wallA : wallB).source = "";
}
}
}
}