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:
parent
42adab61df
commit
1091495674
6 changed files with 560 additions and 227 deletions
|
|
@ -14,7 +14,7 @@ This is a NixOS flake-based configuration for multiple hosts:
|
|||
- `home-manager/` — Home Manager configuration (via NixOS module)
|
||||
- `services/` — modular service definitions, gated by hostname with `lib.mkIf`
|
||||
- `settings/` — shared settings (desktop, hyprland, quickshell, stylix, …)
|
||||
- `walls/` — wallpapers. Each gets a dark base16 palette generated at build time; the shell picks one at runtime (bar button → `settings/quickshell.nix`), so switching re-tints the shell without a rebuild. `walls/wallpaper.png` stays the build-time stylix scheme for everything else.
|
||||
- `walls/` — wallpapers. `settings/theming.nix` builds a full theme per image (base16 palette + rendered config for GTK, ghostty, btop, hyprland, vesktop, zen, folder icons); the bar's wallpaper button switches between them at runtime via `theme-apply`, no rebuild. `walls/wallpaper.png` stays stylix's build-time image.
|
||||
- `modules/crowdsec/` — vendored crowdsec modules from nixpkgs PR #446307; delete once that PR lands in the pinned channel
|
||||
- `ports.toml` — WAN → LAN port forwards consumed by `services/router.nix`
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ in
|
|||
./settings/desktop.nix
|
||||
./settings/hyprland.nix
|
||||
./settings/quickshell.nix
|
||||
./settings/theming.nix
|
||||
./settings/locale.nix
|
||||
./settings/audio.nix
|
||||
./settings/users.nix
|
||||
|
|
|
|||
|
|
@ -71,55 +71,23 @@
|
|||
programs.xwayland.enable = true;
|
||||
programs.dconf.enable = true;
|
||||
|
||||
home-manager.users.fred = { config, lib, pkgs, ... }: let
|
||||
# Scaling all three channels by the same factor is exactly what
|
||||
# Qt.lighter() does — hue and HSV saturation stay put, only value moves —
|
||||
# so this lands on the same surfaces as quickshell's Theme.base01/base02.
|
||||
# GTK's own shade() can't stand in: it multiplies saturation too.
|
||||
tint = f: let
|
||||
ch = k: toString (builtins.floor (builtins.fromJSON config.lib.stylix.colors."base00-rgb-${k}" * f));
|
||||
in "rgb(${ch "r"}, ${ch "g"}, ${ch "b"})";
|
||||
in {
|
||||
home-manager.users.fred = {
|
||||
# Stylix's qt target only supports qtct; disable it to silence the warning.
|
||||
stylix.targets.qt.enable = false;
|
||||
# Minimal titlebars — stylix manages the GTK theme; we layer our
|
||||
# headerbar shrink on top via stylix.targets.gtk.extraCss.
|
||||
# Minimal titlebars. The GTK stylesheet itself — stylix's colours plus
|
||||
# our headerbar shrink and surface-tint overrides — is rendered per
|
||||
# wallpaper and swapped at runtime in settings/theming.nix.
|
||||
gtk.enable = true;
|
||||
|
||||
dconf.settings."org/nemo/window-state".start-with-menu-bar = false;
|
||||
|
||||
# GTK3 ignores dconf's color-scheme=prefer-dark — it only picks
|
||||
# adw-gtk3's gtk-dark.css when this setting is on. Without it GTK3 apps
|
||||
# (nemo) load the *light* sheet; stylix's @define-color overrides repaint
|
||||
# (nemo) load the *light* sheet; the @define-color overrides repaint
|
||||
# most of it, but colours sass baked in as literals survive — e.g.
|
||||
# `messagedialog.csd.background button { color: rgba(0,0,6,0.8); }`, which
|
||||
# is why nemo's delete confirmation had near-black buttons on dark bg.
|
||||
gtk.gtk3.extraConfig.gtk-application-prefer-dark-theme = true;
|
||||
|
||||
stylix.targets.gtk.extraCss = ''
|
||||
/* Surfaces: one hue, three steps — the same ladder quickshell's Theme
|
||||
uses (settings/quickshell.nix, commit e2c413d). Stylix paints every
|
||||
raised surface with base01/base02, which come straight off the
|
||||
wallpaper and routinely land off-hue: base01 is #3f3fab purple over a
|
||||
slate #1c1f26 desktop, which is why nemo's delete dialog was blue. */
|
||||
@define-color headerbar_bg_color ${tint 1.7};
|
||||
@define-color sidebar_bg_color ${tint 1.7};
|
||||
@define-color card_bg_color ${tint 1.7};
|
||||
@define-color dialog_bg_color ${tint 1.7};
|
||||
@define-color popover_bg_color ${tint 1.7};
|
||||
@define-color scrollbar_outline_color ${tint 2.4};
|
||||
|
||||
headerbar { min-height: 0; padding: 0; margin: 0; }
|
||||
headerbar .title { font-size: 0; }
|
||||
window:backdrop {
|
||||
background-color: @window_bg_color;
|
||||
color: @window_fg_color;
|
||||
}
|
||||
window:backdrop headerbar {
|
||||
background-color: @headerbar_bg_color;
|
||||
color: @headerbar_fg_color;
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,146 +41,10 @@
|
|||
};
|
||||
};
|
||||
|
||||
home-manager.users.fred = { config, lib, pkgs, osConfig, ... }: let
|
||||
isDesktop = lib.elem osConfig.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ];
|
||||
c = config.lib.stylix.colors;
|
||||
|
||||
# Pure-Nix hex parsing for color distance calculation.
|
||||
hexDigit = ch: {
|
||||
"0"=0;"1"=1;"2"=2;"3"=3;"4"=4;"5"=5;"6"=6;"7"=7;
|
||||
"8"=8;"9"=9;"a"=10;"b"=11;"c"=12;"d"=13;"e"=14;"f"=15;
|
||||
}.${ch};
|
||||
hexByte = s: hexDigit (builtins.substring 0 1 s) * 16
|
||||
+ hexDigit (builtins.substring 1 1 s);
|
||||
hexToRgb = hex: let h = lib.toLower hex; in {
|
||||
r = hexByte (builtins.substring 0 2 h);
|
||||
g = hexByte (builtins.substring 2 2 h);
|
||||
b = hexByte (builtins.substring 4 2 h);
|
||||
};
|
||||
|
||||
# papirus-folders palette → the front-face fill of each
|
||||
# folder-<color>.svg, read out of the icons themselves.
|
||||
#
|
||||
# NOT the Material Design values the names imply. Papirus paints the
|
||||
# front panel a good deal lighter than its own colour name: nordic's
|
||||
# face is 81a1c1, while 5e81ac is only the small back flap behind it.
|
||||
# Matching a teal accent against the nominal values is what made these
|
||||
# folders come out blue — the real darkcyan is 17x closer than nordic,
|
||||
# the nominal one wasn't.
|
||||
#
|
||||
# Every key here is a variant that actually ships (there is no
|
||||
# "palegreen"; a name with no folder-<name>-*.svg files silently
|
||||
# recolours nothing).
|
||||
pfPalette = {
|
||||
adwaita="93c0ea"; black="4f4f4f"; blue="5294e2"; bluegrey="607d8b";
|
||||
breeze="57b8ec"; brown="ae8e6c"; carmine="a30002"; cyan="00bcd4";
|
||||
darkcyan="45abb7"; deeporange="eb6637"; green="87b158"; grey="8e8e8e";
|
||||
indigo="5c6bc0"; magenta="ca71df"; nordic="81a1c1"; orange="ee923a";
|
||||
palebrown="d1bfae"; paleorange="eeca8f"; pink="f06292"; red="e25252";
|
||||
teal="16a085"; violet="7e57c2"; white="e4e4e4"; yaru="676767";
|
||||
yellow="f9bd30";
|
||||
};
|
||||
|
||||
# Closest papirus-folders color to stylix's accent (base0D), chosen at
|
||||
# eval time via squared RGB distance — no runtime script needed.
|
||||
closestPfColor =
|
||||
let
|
||||
sq = x: x * x;
|
||||
dist = a: b: sq (a.r - b.r) + sq (a.g - b.g) + sq (a.b - b.b);
|
||||
accent = hexToRgb c.base0D;
|
||||
ranked = map (n: { name = n; d = dist accent (hexToRgb pfPalette.${n}); })
|
||||
(builtins.attrNames pfPalette);
|
||||
in (builtins.foldl' (best: x: if x.d < best.d then x else best)
|
||||
(builtins.head ranked)
|
||||
(builtins.tail ranked)).name;
|
||||
|
||||
# Papirus-Dark with folders recolored to the closest matching color.
|
||||
# Replicates papirus-folders' change_color logic directly: for each
|
||||
# colored variant (folder-{color}-*.svg), symlink the generic name
|
||||
# (folder-*.svg) to it. No papirus-folders binary needed.
|
||||
papirusDark = pkgs.runCommand "papirus-dark-${closestPfColor}" {} ''
|
||||
# Copy full icon tree so Papirus-Dark's ../Papirus/… symlinks resolve.
|
||||
tmpthemes=$(mktemp -d)
|
||||
cp -r ${pkgs.papirus-icon-theme}/share/icons/. "$tmpthemes/"
|
||||
chmod -R u+w "$tmpthemes"
|
||||
|
||||
# Recolor folders. Papirus-Dark delegates 48x48 etc. to ../Papirus via
|
||||
# symlinks, so we operate through those resolved paths in the temp tree.
|
||||
for size in 22x22 24x24 32x32 48x48 64x64; do
|
||||
places="$tmpthemes/Papirus-Dark/$size/places"
|
||||
[ -d "$places" ] || continue
|
||||
for f in \
|
||||
"$places/folder-${closestPfColor}"-*.svg \
|
||||
"$places/folder-${closestPfColor}.svg" \
|
||||
"$places/user-${closestPfColor}"-*.svg \
|
||||
"$places/user-${closestPfColor}.svg"; do
|
||||
[ -f "$f" ] || continue
|
||||
fname=$(basename "$f")
|
||||
ln -sf "$fname" "$places/''${fname/-${closestPfColor}/}"
|
||||
done
|
||||
done
|
||||
|
||||
# Copy Papirus-Dark with -L to dereference all symlinks, so the output
|
||||
# is self-contained — no dangling ../Papirus/… pointers.
|
||||
mkdir -p $out/share/icons
|
||||
cp -rL "$tmpthemes/Papirus-Dark" $out/share/icons/
|
||||
'';
|
||||
|
||||
# Raised surfaces are tints of base00, matching quickshell's
|
||||
# Theme.base01/base02 and the GTK overrides in settings/desktop.nix.
|
||||
# Scaling all three channels is what Qt.lighter() does, so all three
|
||||
# toolkits land on the same #2f3440 / #434a5b.
|
||||
tint = f: let
|
||||
ch = k: toString (builtins.floor (builtins.fromJSON c."base00-rgb-${k}" * f));
|
||||
in "rgb(${ch "r"}, ${ch "g"}, ${ch "b"})";
|
||||
|
||||
# Map Material You placeholders to the closest base16 slot in stylix's
|
||||
# palette. The mapping is approximate (Material You has more semantic
|
||||
# colours than base16), but covers the placeholders used in our Zen
|
||||
# userChrome and Vesktop quickCss templates.
|
||||
stylixize = builtins.replaceStrings
|
||||
[
|
||||
"{{colors.primary.default.hex}}"
|
||||
"{{colors.primary_container.default.hex}}"
|
||||
"{{colors.secondary_container.default.hex}}"
|
||||
"{{colors.tertiary_container.default.hex}}"
|
||||
"{{colors.surface.default.hex}}"
|
||||
"{{colors.surface_container.default.hex}}"
|
||||
"{{colors.surface_container_low.default.hex}}"
|
||||
"{{colors.surface_container_high.default.hex}}"
|
||||
"{{colors.on_surface.default.hex}}"
|
||||
"{{colors.on_surface_variant.default.hex}}"
|
||||
"{{colors.outline.default.hex}}"
|
||||
"{{colors.outline_variant.default.hex}}"
|
||||
]
|
||||
[
|
||||
"#${c.base0D}" # primary accent
|
||||
"#${c.base02}" # primary container
|
||||
"#${c.base0C}" # secondary container (cyan)
|
||||
"#${c.base0E}" # tertiary container (purple)
|
||||
"#${c.base00}" # surface (main bg)
|
||||
(tint 1.7) # surface container (urlbar, panels, dialogs)
|
||||
"#${c.base00}" # surface container low
|
||||
(tint 2.4) # surface container high (selected tab, focused urlbar)
|
||||
"#${c.base05}" # on surface (fg)
|
||||
"#${c.base04}" # on surface variant (muted fg)
|
||||
"#${c.base03}" # outline
|
||||
"#${c.base02}" # outline variant
|
||||
];
|
||||
in lib.mkMerge [
|
||||
# Desktop-only: Zen/Vesktop CSS and GTK icon theme
|
||||
(lib.mkIf isDesktop {
|
||||
home.file.".zen/fraudek5.Default Profile/chrome/userChrome.css".text =
|
||||
stylixize (builtins.readFile "${inputs.self}/templates/zen-userChrome.css");
|
||||
|
||||
home.file.".config/vesktop/settings/quickCss.css".text =
|
||||
stylixize (builtins.readFile "${inputs.self}/templates/vesktop-quickCss.css");
|
||||
|
||||
gtk.iconTheme = {
|
||||
package = papirusDark;
|
||||
name = "Papirus-Dark";
|
||||
};
|
||||
})
|
||||
];
|
||||
# Per-app theming that has to follow the RUNTIME wallpaper (GTK, ghostty,
|
||||
# btop, zen, vesktop, folder icons) lives in settings/theming.nix: it
|
||||
# renders one set of config files per wallpaper in walls/ and swaps them
|
||||
# in without a rebuild. Everything above is build-time and stays here.
|
||||
};
|
||||
}
|
||||
|
|
|
|||
470
settings/theming.nix
Normal file
470
settings/theming.nix
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
# settings/theming.nix — runtime theme switching.
|
||||
#
|
||||
# stylix themes the system from ONE image at build time. This module builds a
|
||||
# complete theme for EVERY image in walls/ instead, and swaps between them at
|
||||
# runtime: the wallpaper picker in the quickshell bar writes a state file, and
|
||||
# `theme-apply` drops that wallpaper's rendered config into place and nudges
|
||||
# every app that can reload without restarting.
|
||||
#
|
||||
# What follows a switch immediately: quickshell, hyprland, GTK apps, vesktop,
|
||||
# folder icon tint. What follows on next launch: btop, zen, spotify-player.
|
||||
# Ghostty is reloaded in place when a window exists to send the keybind to,
|
||||
# otherwise its (windowless) daemon is restarted.
|
||||
#
|
||||
# stylix stays the source of truth for build-time defaults and for every
|
||||
# target not listed here — walls/wallpaper.png remains its image.
|
||||
{ config, pkgs, lib, inputs, ... }:
|
||||
let
|
||||
isDesktop = lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ];
|
||||
homeDir = "/home/fred";
|
||||
stateLink = "${homeDir}/.local/state/quickshell-theme";
|
||||
zenChrome = ".zen/fraudek5.Default Profile/chrome";
|
||||
|
||||
# ── Wallpapers ─────────────────────────────────────────────────────────
|
||||
# One palette derivation per image, not one for the set: generation is a
|
||||
# genetic search that takes seconds, so 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
|
||||
'';
|
||||
|
||||
# Import from derivation, the same way stylix reads its own generated
|
||||
# palette. Each wallpaper's palette is built during evaluation so its
|
||||
# colours can be rendered into config files here rather than at runtime.
|
||||
#
|
||||
# The `.override {}` is not decoration: bare mkSchemeAttrs output is not
|
||||
# callable as a template renderer, and calling it anyway returns a plain
|
||||
# attrset that stringifies to the template's path. stylix does the same
|
||||
# thing to build lib.stylix.colors.
|
||||
schemeOf = f: (config.stylix.base16.mkSchemeAttrs
|
||||
(builtins.fromJSON (builtins.readFile "${wallPalette f}/palette.json"))).override { };
|
||||
|
||||
# ── Shared colour helpers ──────────────────────────────────────────────
|
||||
# Scaling all three channels by one factor is exactly what Qt.lighter()
|
||||
# does — hue and HSV saturation stay put, only value moves — so GTK lands
|
||||
# on the same surfaces as quickshell's Theme.base01/base02. GTK's own
|
||||
# shade() can't stand in: it multiplies saturation too.
|
||||
tint = s: f: let
|
||||
ch = k: toString (builtins.floor (builtins.fromJSON s."base00-rgb-${k}" * f));
|
||||
in "rgb(${ch "r"}, ${ch "g"}, ${ch "b"})";
|
||||
|
||||
hexDigit = ch: {
|
||||
"0"=0;"1"=1;"2"=2;"3"=3;"4"=4;"5"=5;"6"=6;"7"=7;
|
||||
"8"=8;"9"=9;"a"=10;"b"=11;"c"=12;"d"=13;"e"=14;"f"=15;
|
||||
}.${ch};
|
||||
hexByte = s: hexDigit (builtins.substring 0 1 s) * 16
|
||||
+ hexDigit (builtins.substring 1 1 s);
|
||||
hexToRgb = hex: let h = lib.toLower hex; in {
|
||||
r = hexByte (builtins.substring 0 2 h);
|
||||
g = hexByte (builtins.substring 2 2 h);
|
||||
b = hexByte (builtins.substring 4 2 h);
|
||||
};
|
||||
|
||||
# papirus-folders palette → the front-face fill of each folder-<color>.svg,
|
||||
# read out of the icons themselves.
|
||||
#
|
||||
# NOT the Material Design values the names imply. Papirus paints the front
|
||||
# panel a good deal lighter than its own colour name: nordic's face is
|
||||
# 81a1c1, while 5e81ac is only the small back flap behind it. Matching a
|
||||
# teal accent against the nominal values is what made these folders come
|
||||
# out blue — the real darkcyan is 17x closer than nordic, the nominal one
|
||||
# wasn't.
|
||||
#
|
||||
# Every key here is a variant that actually ships (there is no "palegreen";
|
||||
# a name with no folder-<name>-*.svg files silently recolours nothing).
|
||||
pfPalette = {
|
||||
adwaita="93c0ea"; black="4f4f4f"; blue="5294e2"; bluegrey="607d8b";
|
||||
breeze="57b8ec"; brown="ae8e6c"; carmine="a30002"; cyan="00bcd4";
|
||||
darkcyan="45abb7"; deeporange="eb6637"; green="87b158"; grey="8e8e8e";
|
||||
indigo="5c6bc0"; magenta="ca71df"; nordic="81a1c1"; orange="ee923a";
|
||||
palebrown="d1bfae"; paleorange="eeca8f"; pink="f06292"; red="e25252";
|
||||
teal="16a085"; violet="7e57c2"; white="e4e4e4"; yaru="676767";
|
||||
yellow="f9bd30";
|
||||
};
|
||||
|
||||
pfColorOf = s:
|
||||
let
|
||||
sq = x: x * x;
|
||||
dist = a: b: sq (a.r - b.r) + sq (a.g - b.g) + sq (a.b - b.b);
|
||||
accent = hexToRgb s.base0D;
|
||||
ranked = map (n: { name = n; d = dist accent (hexToRgb pfPalette.${n}); })
|
||||
(builtins.attrNames pfPalette);
|
||||
in (builtins.foldl' (best: x: if x.d < best.d then x else best)
|
||||
(builtins.head ranked)
|
||||
(builtins.tail ranked)).name;
|
||||
|
||||
# Map Material You placeholders to the closest base16 slot. The mapping is
|
||||
# approximate (Material You has more semantic colours than base16), but
|
||||
# covers the placeholders used in our Zen userChrome and Vesktop quickCss
|
||||
# templates.
|
||||
stylixize = s: builtins.replaceStrings
|
||||
[
|
||||
"{{colors.primary.default.hex}}"
|
||||
"{{colors.primary_container.default.hex}}"
|
||||
"{{colors.secondary_container.default.hex}}"
|
||||
"{{colors.tertiary_container.default.hex}}"
|
||||
"{{colors.surface.default.hex}}"
|
||||
"{{colors.surface_container.default.hex}}"
|
||||
"{{colors.surface_container_low.default.hex}}"
|
||||
"{{colors.surface_container_high.default.hex}}"
|
||||
"{{colors.on_surface.default.hex}}"
|
||||
"{{colors.on_surface_variant.default.hex}}"
|
||||
"{{colors.outline.default.hex}}"
|
||||
"{{colors.outline_variant.default.hex}}"
|
||||
]
|
||||
[
|
||||
"#${s.base0D}" # primary accent
|
||||
"#${s.base02}" # primary container
|
||||
"#${s.base0C}" # secondary container (cyan)
|
||||
"#${s.base0E}" # tertiary container (purple)
|
||||
"#${s.base00}" # surface (main bg)
|
||||
(tint s 1.7) # surface container (urlbar, panels, dialogs)
|
||||
"#${s.base00}" # surface container low
|
||||
(tint s 2.4) # surface container high (selected tab, focused urlbar)
|
||||
"#${s.base05}" # on surface (fg)
|
||||
"#${s.base04}" # on surface variant (muted fg)
|
||||
"#${s.base03}" # outline
|
||||
"#${s.base02}" # outline variant
|
||||
];
|
||||
|
||||
# ── Per-app renderers ──────────────────────────────────────────────────
|
||||
# GTK reuses stylix's own mustache template, so every @define-color stylix
|
||||
# would have written is still written — our overrides are appended after
|
||||
# it and therefore win.
|
||||
#
|
||||
# `template` is the mustache SOURCE, not a path to it: base16.nix only
|
||||
# treats the argument as a file when it is a path or a derivation, and
|
||||
# anything else — including the store-path string `builtins.path` hands
|
||||
# back — is written out verbatim as the template. That failure is silent
|
||||
# (you get a "stylesheet" containing one store path), which is what the
|
||||
# check in themeFor below exists to catch.
|
||||
gtkCssTemplate =
|
||||
builtins.readFile "${inputs.stylix}/modules/gtk/gtk.css.mustache";
|
||||
gtkCss = s: pkgs.concatText "gtk3.css" [
|
||||
(s { template = gtkCssTemplate; extension = ".css"; })
|
||||
(pkgs.writeText "gtk-extra.css" ''
|
||||
/* Surfaces: one hue, three steps — the same ladder quickshell's Theme
|
||||
uses. Stylix paints every raised surface with base01/base02, which
|
||||
come straight off the wallpaper and routinely land off-hue: base01
|
||||
is #3f3fab purple over a slate #1c1f26 desktop, which is why nemo's
|
||||
delete dialog was blue. */
|
||||
@define-color headerbar_bg_color ${tint s 1.7};
|
||||
@define-color sidebar_bg_color ${tint s 1.7};
|
||||
@define-color card_bg_color ${tint s 1.7};
|
||||
@define-color dialog_bg_color ${tint s 1.7};
|
||||
@define-color popover_bg_color ${tint s 1.7};
|
||||
@define-color scrollbar_outline_color ${tint s 2.4};
|
||||
|
||||
headerbar { min-height: 0; padding: 0; margin: 0; }
|
||||
headerbar .title { font-size: 0; }
|
||||
window:backdrop {
|
||||
background-color: @window_bg_color;
|
||||
color: @window_fg_color;
|
||||
}
|
||||
window:backdrop headerbar {
|
||||
background-color: @headerbar_bg_color;
|
||||
color: @headerbar_fg_color;
|
||||
}
|
||||
'')
|
||||
];
|
||||
|
||||
# GTK4 reads gtk-theme-name and then ignores it, so the stylesheet has to be
|
||||
# pulled in by hand — this is what home-manager's own gtk4.theme option
|
||||
# writes, and taking over the file means taking this with it. It has to lead
|
||||
# the file: everything after an @import overrides it, which is exactly the
|
||||
# order the colours want.
|
||||
gtk4Css = s: pkgs.concatText "gtk4.css" [
|
||||
(pkgs.writeText "adw-gtk3-import.css" ''
|
||||
@import url("file://${pkgs.adw-gtk3}/share/themes/adw-gtk3/gtk-4.0/gtk.css");
|
||||
'')
|
||||
(gtkCss s)
|
||||
];
|
||||
|
||||
# Ghostty theme files are plain config syntax, so this is included from the
|
||||
# main config with `config-file` rather than installed as a named theme.
|
||||
ghosttyConf = s: pkgs.writeText "ghostty-theme.conf" (with s.withHashtag; ''
|
||||
background = ${base00}
|
||||
foreground = ${base05}
|
||||
cursor-color = ${base05}
|
||||
selection-background = ${base02}
|
||||
selection-foreground = ${base05}
|
||||
palette = 0=${base00}
|
||||
palette = 1=${base08}
|
||||
palette = 2=${base0B}
|
||||
palette = 3=${base0A}
|
||||
palette = 4=${base0D}
|
||||
palette = 5=${base0E}
|
||||
palette = 6=${base0C}
|
||||
palette = 7=${base05}
|
||||
palette = 8=${base03}
|
||||
palette = 9=${base08}
|
||||
palette = 10=${base0B}
|
||||
palette = 11=${base0A}
|
||||
palette = 12=${base0D}
|
||||
palette = 13=${base0E}
|
||||
palette = 14=${base0C}
|
||||
palette = 15=${base07}
|
||||
'');
|
||||
|
||||
btopTheme = s: pkgs.writeText "btop.theme" (with s.withHashtag; ''
|
||||
#Generated by settings/theming.nix
|
||||
theme[main_bg]="${base00}"
|
||||
theme[main_fg]="${base05}"
|
||||
theme[title]="${base05}"
|
||||
theme[hi_fg]="${base0D}"
|
||||
theme[selected_bg]="${base03}"
|
||||
theme[selected_fg]="${base0D}"
|
||||
theme[inactive_fg]="${base04}"
|
||||
theme[graph_text]="${base06}"
|
||||
theme[meter_bg]="${base03}"
|
||||
theme[proc_misc]="${base06}"
|
||||
theme[cpu_box]="${base0E}"
|
||||
theme[mem_box]="${base0B}"
|
||||
theme[net_box]="${base0C}"
|
||||
theme[proc_box]="${base0D}"
|
||||
theme[div_line]="${base01}"
|
||||
theme[temp_start]="${base0B}"
|
||||
theme[temp_mid]="${base0A}"
|
||||
theme[temp_end]="${base08}"
|
||||
theme[cpu_start]="${base0B}"
|
||||
theme[cpu_mid]="${base0A}"
|
||||
theme[cpu_end]="${base08}"
|
||||
theme[free_start]="${base0A}"
|
||||
theme[free_mid]="${base0B}"
|
||||
theme[free_end]="${base0B}"
|
||||
theme[cached_start]="${base0C}"
|
||||
theme[cached_mid]="${base0C}"
|
||||
theme[cached_end]="${base0A}"
|
||||
theme[available_start]="${base08}"
|
||||
theme[available_mid]="${base0A}"
|
||||
theme[available_end]="${base0B}"
|
||||
theme[used_start]="${base0A}"
|
||||
theme[used_mid]="${base09}"
|
||||
theme[used_end]="${base08}"
|
||||
theme[download_start]="${base0B}"
|
||||
theme[download_mid]="${base0A}"
|
||||
theme[download_end]="${base08}"
|
||||
theme[upload_start]="${base0B}"
|
||||
theme[upload_mid]="${base0A}"
|
||||
theme[upload_end]="${base08}"
|
||||
theme[process_start]="${base0B}"
|
||||
theme[process_mid]="${base0A}"
|
||||
theme[process_end]="${base08}"
|
||||
'');
|
||||
|
||||
# One `hyprctl --batch` argument string. Mirrors the colour keys set
|
||||
# statically in settings/hyprland.nix, which stay the build-time fallback
|
||||
# for the window or two before theme-apply first runs.
|
||||
hyprBatch = s: pkgs.writeText "hypr.batch" (lib.concatStringsSep " ; " [
|
||||
"keyword general:col.active_border rgb(${s.base04})"
|
||||
"keyword general:col.inactive_border rgb(${s.base03})"
|
||||
"keyword decoration:shadow:color rgba(${s.base00}99)"
|
||||
"keyword group:col.border_active rgb(${s.base04})"
|
||||
"keyword group:col.border_inactive rgb(${s.base03})"
|
||||
"keyword group:col.border_locked_active rgb(${s.base05})"
|
||||
"keyword group:groupbar:text_color rgb(${s.base05})"
|
||||
"keyword group:groupbar:col.active rgb(${s.base04})"
|
||||
"keyword group:groupbar:col.inactive rgb(${s.base03})"
|
||||
"keyword misc:background_color rgb(${s.base00})"
|
||||
]);
|
||||
|
||||
# A featherweight icon theme that inherits Papirus-Dark and overrides only
|
||||
# the folder faces, rather than a recoloured copy of the whole 200MB tree
|
||||
# per wallpaper. Papirus-Dark delegates its size dirs to ../Papirus by
|
||||
# symlink, so the sources are resolved through the real files.
|
||||
iconTheme = s: let color = pfColorOf s; in
|
||||
pkgs.runCommand "papirus-fred-${color}" { } ''
|
||||
sizes="22x22 24x24 32x32 48x48 64x64"
|
||||
mkdir -p $out
|
||||
{
|
||||
echo "[Icon Theme]"
|
||||
echo "Name=Papirus-Fred"
|
||||
echo "Comment=Papirus-Dark with ${color} folders"
|
||||
echo "Inherits=Papirus-Dark,Papirus,hicolor"
|
||||
printf 'Directories='
|
||||
first=1
|
||||
for size in $sizes; do
|
||||
[ $first -eq 1 ] || printf ','
|
||||
printf '%s/places' "$size"
|
||||
first=0
|
||||
done
|
||||
echo
|
||||
for size in $sizes; do
|
||||
echo
|
||||
echo "[$size/places]"
|
||||
echo "Size=''${size%%x*}"
|
||||
echo "Context=Places"
|
||||
echo "Type=Fixed"
|
||||
done
|
||||
} > $out/index.theme
|
||||
|
||||
for size in $sizes; do
|
||||
src=${pkgs.papirus-icon-theme}/share/icons/Papirus-Dark/$size/places
|
||||
[ -d "$src" ] || continue
|
||||
mkdir -p $out/$size/places
|
||||
for f in "$src"/folder-${color}*.svg "$src"/user-${color}*.svg; do
|
||||
[ -f "$f" ] || continue
|
||||
fname=$(basename "$f")
|
||||
# folder-darkcyan-cloud.svg -> folder-cloud.svg; the generic name is
|
||||
# what nemo actually asks for.
|
||||
ln -sf "$(readlink -f "$f")" "$out/$size/places/''${fname/-${color}/}"
|
||||
done
|
||||
done
|
||||
'';
|
||||
|
||||
# ── One theme directory per wallpaper ──────────────────────────────────
|
||||
themeFor = f: let s = schemeOf f; in pkgs.runCommand "theme-${wallSlug f}" { } ''
|
||||
mkdir -p $out
|
||||
cp ${wallPalette f}/palette.json $out/palette.json
|
||||
cp ${gtkCss s} $out/gtk3.css
|
||||
cp ${gtk4Css s} $out/gtk4.css
|
||||
cp ${ghosttyConf s} $out/ghostty.conf
|
||||
cp ${btopTheme s} $out/btop.theme
|
||||
cp ${hyprBatch s} $out/hypr.batch
|
||||
cp ${pkgs.writeText "vesktop.css" (stylixize s (builtins.readFile "${inputs.self}/templates/vesktop-quickCss.css"))} $out/vesktop.css
|
||||
cp ${pkgs.writeText "zen.css" (stylixize s (builtins.readFile "${inputs.self}/templates/zen-userChrome.css"))} $out/zen.css
|
||||
ln -s ${iconTheme s} $out/icons
|
||||
|
||||
# Cheap smoke test: every one of these is generated by a different
|
||||
# mechanism, and a silent miss (a template that didn't render, a scheme
|
||||
# attr that came back empty) is invisible until the desktop is ugly.
|
||||
check() {
|
||||
grep -q "$2" "$out/$1" || {
|
||||
echo "theming: $out/$1 is missing /$2/ — renderer broke" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
check gtk3.css '@define-color window_bg_color'
|
||||
check gtk4.css 'adw-gtk3/gtk-4.0/gtk.css'
|
||||
check ghostty.conf '^background = #'
|
||||
check hypr.batch 'col.active_border rgb('
|
||||
check vesktop.css '^ --bg-4: #'
|
||||
'';
|
||||
|
||||
walls = map (f: {
|
||||
name = wallStem f;
|
||||
image = "${wallImage f}";
|
||||
theme = "${themeFor f}";
|
||||
}) wallFiles;
|
||||
|
||||
# Falls back to walls/wallpaper.png — the one stylix themes the build with
|
||||
# — 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);
|
||||
|
||||
# ── The switcher ───────────────────────────────────────────────────────
|
||||
# Copies rather than symlinks into ~/.config: GTK and vesktop reload by
|
||||
# watching those files, and a symlink whose target changed underneath
|
||||
# doesn't fire a file monitor.
|
||||
themeApply = pkgs.writeShellScript "theme-apply" ''
|
||||
set -u
|
||||
theme="$1"
|
||||
[ -d "$theme" ] || exit 0
|
||||
|
||||
ln -sfn "$theme" ${lib.escapeShellArg stateLink}
|
||||
|
||||
install -Dm644 "$theme/gtk3.css" "$HOME/.config/gtk-3.0/gtk.css"
|
||||
install -Dm644 "$theme/gtk4.css" "$HOME/.config/gtk-4.0/gtk.css"
|
||||
install -Dm644 "$theme/ghostty.conf" "$HOME/.config/ghostty/theme.conf"
|
||||
install -Dm644 "$theme/btop.theme" "$HOME/.config/btop/themes/runtime.theme"
|
||||
install -Dm644 "$theme/vesktop.css" "$HOME/.config/vesktop/settings/quickCss.css"
|
||||
install -Dm644 "$theme/zen.css" "$HOME/${zenChrome}/userChrome.css"
|
||||
|
||||
# Icon theme: replace the directory contents (cheap — the tree is
|
||||
# symlinks into the papirus store path) so GTK's icon-theme monitor sees
|
||||
# a change and rescans.
|
||||
icons="$HOME/.local/share/icons/Papirus-Fred"
|
||||
rm -rf "$icons"
|
||||
mkdir -p "$icons"
|
||||
cp -r "$theme"/icons/. "$icons"/
|
||||
chmod -R u+w "$icons"
|
||||
|
||||
# Hyprland takes its colours live.
|
||||
${pkgs.hyprland}/bin/hyprctl --batch "$(cat "$theme/hypr.batch")" >/dev/null 2>&1 || true
|
||||
|
||||
# Ghostty holds its config in one process (gtk-single-instance), so a new
|
||||
# window would inherit the old theme. Poke the reload_config keybind at
|
||||
# any live window — the reload is process-wide, so one is enough.
|
||||
#
|
||||
# The match has to cover the --class overrides from settings/hyprland.nix
|
||||
# (dev.fred.scratchpad, dev.fred.spotify) as well as the default id: those
|
||||
# windows are the same process, and mistaking them for "no ghostty open"
|
||||
# would restart the daemon out from under a running shell.
|
||||
cls=$(${pkgs.hyprland}/bin/hyprctl -j clients 2>/dev/null \
|
||||
| ${pkgs.jq}/bin/jq -r 'map(select(.class | test("ghostty|^dev\\.fred\\.")))[0].class // empty')
|
||||
if [ -n "$cls" ]; then
|
||||
${pkgs.hyprland}/bin/hyprctl dispatch sendshortcut \
|
||||
"CTRL SHIFT ALT, F12, class:^(''${cls//./\\.})$" >/dev/null 2>&1 || true
|
||||
else
|
||||
# No windows to lose: the daemon is the only thing holding stale config.
|
||||
${pkgs.systemd}/bin/systemctl --user restart ghostty-daemon.service 2>/dev/null || true
|
||||
fi
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.fred.theming = {
|
||||
walls = lib.mkOption {
|
||||
internal = true;
|
||||
type = lib.types.listOf (lib.types.attrsOf lib.types.str);
|
||||
description = "Every wallpaper in walls/, with its image and theme directory.";
|
||||
};
|
||||
defaultWall = lib.mkOption { internal = true; type = lib.types.str; };
|
||||
apply = lib.mkOption { internal = true; type = lib.types.path; };
|
||||
};
|
||||
|
||||
config = lib.mkIf isDesktop {
|
||||
fred.theming = {
|
||||
inherit walls defaultWall;
|
||||
apply = themeApply;
|
||||
};
|
||||
|
||||
home-manager.users.fred = { config, lib, pkgs, osConfig, ... }: {
|
||||
# These four targets write build-time files that theme-apply replaces
|
||||
# at runtime; leaving them on means stylix and the switcher fight over
|
||||
# the same paths on every rebuild.
|
||||
stylix.targets.gtk.enable = false;
|
||||
stylix.targets.ghostty.enable = false;
|
||||
stylix.targets.btop.enable = false;
|
||||
|
||||
# Everything stylix's gtk target did apart from writing gtk.css.
|
||||
gtk.theme = { package = pkgs.adw-gtk3; name = "adw-gtk3"; };
|
||||
# Explicitly null, not merely unset: home-manager defaults gtk4.theme to
|
||||
# gtk.theme and then writes gtk-4.0/gtk.css to @import the theme, which
|
||||
# is a file theme-apply owns. gtk4Css reproduces that import itself.
|
||||
gtk.gtk4.theme = null;
|
||||
gtk.font = {
|
||||
inherit (osConfig.stylix.fonts.sansSerif) package name;
|
||||
size = osConfig.stylix.fonts.sizes.applications;
|
||||
};
|
||||
# Written into ~/.local/share/icons by theme-apply; the package is here
|
||||
# so Papirus-Dark — which Papirus-Fred inherits — is installed.
|
||||
gtk.iconTheme = {
|
||||
package = pkgs.papirus-icon-theme;
|
||||
name = "Papirus-Fred";
|
||||
};
|
||||
|
||||
programs.ghostty.settings.config-file = "?${homeDir}/.config/ghostty/theme.conf";
|
||||
programs.ghostty.settings.keybind = [ "ctrl+shift+alt+f12=reload_config" ];
|
||||
programs.btop.settings.color_theme = "${homeDir}/.config/btop/themes/runtime.theme";
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue