base03/base04/base05 were three independent slots off the wallpaper, not one ink at three strengths. On wallhaven-k8dqr6 the raw ladder is red, blue and cream; on 5y39p9 base03 is a saturated gold against pale cream. That is why muted labels read as a second accent and why the shell frame never quite matched the window border beside it. base04 and base03 are now base05 mixed toward base00. The two factors and the mix live in settings/ui.nix and are used by all three consumers: Theme.qml, theming.nix's hypr.lua (applied at runtime) and hyprland.nix's build-time fallback. Same trick base01/base02 already used for surfaces. Verified on 5y39p9: hypr.lua now emits aca888 / 727761 from base05 f1e2b6 against base00 0a1f1c, matching what inkStep computes in the shell.
691 lines
33 KiB
Nix
691 lines
33 KiB
Nix
# 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, ghostty (D-Bus
|
|
# reload-config), GTK3 apps such as nemo (each wallpaper installs
|
|
# its own named GTK theme, switched over gsettings) and the folder icon tint.
|
|
# On next launch only: GTK4/libadwaita apps, btop, zen.
|
|
#
|
|
# 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
|
|
ui = import ./ui.nix; # shared ink ladder (inkMuted / inkFaint / mixHex)
|
|
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}"
|
|
'');
|
|
|
|
# Lua, fed to `hyprctl eval`. NOT `hyprctl keyword`, which answers
|
|
# "keyword can't work with non-legacy parsers. Use eval."
|
|
# under the Lua config this repo uses (settings/hyprland.nix sets
|
|
# configType = "lua"). eval runs in the live VM with isDynamicParse() true,
|
|
# so hl.config schedules the refresh that actually repaints the borders.
|
|
#
|
|
# Keys are dot-joined here, matching how the Lua config manager flattens
|
|
# nested tables — the same shape settings/hyprland.nix already declares,
|
|
# which stays the build-time fallback until theme-apply first runs.
|
|
#
|
|
# One line, not pretty-printed: hyprctl joins its argv into the request
|
|
# string, so keeping the payload free of newlines keeps it intact.
|
|
# Borders take the DERIVED ink, not s.base04/s.base03 — quickshell's
|
|
# Theme.qml runs the same two mixes, and the shell frame sits directly
|
|
# against these borders. Reading the raw slots here is what left the two
|
|
# outlines a visible shade apart.
|
|
hyprLua = s: let
|
|
ink4 = ui.mixHex s.base05 s.base00 ui.inkMuted;
|
|
ink3 = ui.mixHex s.base05 s.base00 ui.inkFaint;
|
|
in pkgs.writeText "hypr.lua" ("hl.config({" + lib.concatStringsSep "," [
|
|
''general={["col.active_border"]="rgb(${ink4})",["col.inactive_border"]="rgb(${ink3})"}''
|
|
''decoration={shadow={color="rgba(${s.base00}99)"}}''
|
|
(''group={["col.border_active"]="rgb(${ink4})",''
|
|
+ ''["col.border_inactive"]="rgb(${ink3})",''
|
|
+ ''["col.border_locked_active"]="rgb(${s.base05})",''
|
|
+ ''groupbar={text_color="rgb(${s.base05})",''
|
|
+ ''["col.active"]="rgb(${ink4})",["col.inactive"]="rgb(${ink3})"}}'')
|
|
''misc={background_color="rgb(${s.base00})"}''
|
|
] + "})");
|
|
|
|
# ── App icons ──────────────────────────────────────────────────────────
|
|
# YAMIS draws the app icons in the launcher. Picked over the far bigger
|
|
# monochrome sets because coverage beats library size: it is the only one
|
|
# still shipping commits, so it has the apps actually installed here
|
|
# (zen, ghostty, vesktop, bazaar, faugus, protonplus) where the 10k-icon
|
|
# archives — Suru++ Asprómauros, dead since 2019 — do not.
|
|
#
|
|
# Not in nixpkgs. Upstream is a bare icon tree with the theme at its root,
|
|
# so the "build" is a copy into the directory name the spec resolves by.
|
|
#
|
|
# FollowsColorScheme=true is a KDE key: only Plasma rewrites the embedded
|
|
# <style id="current-color-scheme"> block. Anywhere else the icons render
|
|
# at that block's literal fallback, #d8dee9, and never track the wallpaper.
|
|
#
|
|
# Rewriting that string on disk would mean a copy of all 6101 files per
|
|
# wallpaper — 75MB across the set, and a 15MB rm -rf + cp inside
|
|
# theme-apply on every switch, which is the one path that has to stay
|
|
# quick. quickshell tints these at draw time instead (ColorOverlay against
|
|
# base05, the same trick the tray uses), so the launcher follows the
|
|
# palette live and nothing is copied. monoIcons below hands it the prefix
|
|
# that identifies a YAMIS icon.
|
|
#
|
|
# Consequence: GTK apps still see #d8dee9. That is fine on these dark
|
|
# themes, and it is the only consumer that reads these files directly.
|
|
yamisIcons = pkgs.runCommand "yamis-icon-theme" { } ''
|
|
mkdir -p $out/share/icons
|
|
cp -r ${pkgs.fetchgit {
|
|
url = "https://bitbucket.org/dirn-typo/yet-another-monochrome-icon-set.git";
|
|
rev = "4294d5d51f896e54de3f01c12ed35a46d9451258";
|
|
hash = "sha256-oVztgqlGB8qkOlJU7HCWXT8BBZLAHHajdws6FIe33XA=";
|
|
}} $out/share/icons/yet-another-monochrome-icon-set
|
|
chmod -R u+w $out/share/icons
|
|
'';
|
|
|
|
# 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=YAMIS app icons with ${color} Papirus folders"
|
|
# YAMIS first, so every app it covers wins; its own index.theme falls
|
|
# through to Papirus-Dark for the rest. Papirus is repeated here
|
|
# anyway — an upstream edit to YAMIS's Inherits must not be able to
|
|
# break the fallback that the folder overrides below depend on.
|
|
echo "Inherits=yet-another-monochrome-icon-set,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
|
|
'';
|
|
|
|
# A real, installed GTK3 theme per wallpaper — the only way a RUNNING GTK3
|
|
# app ever changes colour.
|
|
#
|
|
# ~/.config/gtk-3.0/gtk.css cannot do it. gtksettings.c loads that file
|
|
# behind `if (G_UNLIKELY (!css_provider))` on a static, so it is parsed once
|
|
# per process and never re-read; and it sits at PRIORITY_USER, above the
|
|
# theme, so leaving a stale copy there would also outrank whatever the theme
|
|
# supplies. The named-theme provider, by contrast, is reloaded whenever
|
|
# gtk-theme-name changes — that signal reaches apps here through
|
|
# xdg-desktop-portal-gtk.
|
|
#
|
|
# Hence one theme NAME per wallpaper rather than a single "Fred" that gets
|
|
# rewritten: gtk_css_provider_get_named memoises providers in a static hash
|
|
# table keyed by name, so reusing the name would hand back the CSS parsed
|
|
# the first time. Distinct names also make switching back to an earlier
|
|
# wallpaper a cache hit with the right colours.
|
|
gtkThemeName = f: "Fred-${wallSlug f}";
|
|
gtkTheme = f: s: pkgs.runCommand "gtk-theme-${wallSlug f}" { } ''
|
|
mkdir -p $out/gtk-3.0
|
|
# GTK resolves the theme's relative url(assets/...) against the theme dir,
|
|
# not the imported file, so the artwork has to be reachable from here.
|
|
ln -s ${pkgs.adw-gtk3}/share/themes/adw-gtk3/gtk-3.0/assets $out/gtk-3.0/assets
|
|
|
|
# Both variants: gtk-application-prefer-dark-theme is on, so GTK asks for
|
|
# gtk-dark.css, and _gtk_css_provider_load_named falls back to gtk.css
|
|
# only if the variant is missing entirely.
|
|
for v in gtk.css gtk-dark.css; do
|
|
{
|
|
echo "@import url(\"file://${pkgs.adw-gtk3}/share/themes/adw-gtk3/gtk-3.0/$v\");"
|
|
cat ${gtkCss s}
|
|
} > $out/gtk-3.0/$v
|
|
done
|
|
|
|
cat > $out/index.theme <<'EOF'
|
|
[Desktop Entry]
|
|
Type=X-GNOME-Metatheme
|
|
Name=${gtkThemeName f}
|
|
Comment=adw-gtk3 tinted to ${wallStem f}
|
|
Encoding=UTF-8
|
|
|
|
[X-GNOME-Metatheme]
|
|
GtkTheme=${gtkThemeName f}
|
|
EOF
|
|
|
|
grep -q '@import url' $out/gtk-3.0/gtk-dark.css
|
|
grep -q '@define-color window_bg_color' $out/gtk-3.0/gtk-dark.css
|
|
'';
|
|
|
|
# ── 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
|
|
echo -n "${gtkThemeName f}" > $out/gtk-theme-name
|
|
cp ${gtk4Css s} $out/gtk4.css
|
|
cp ${ghosttyConf s} $out/ghostty.conf
|
|
cp ${btopTheme s} $out/btop.theme
|
|
cp ${hyprLua s} $out/hypr.lua
|
|
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.lua '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
|
|
|
|
# Everything from here lands in one log, truncated per run. NOT the
|
|
# journal: quickshell starts this with execDetached, which drops the
|
|
# child's stdio, so anything written to stderr goes nowhere. This file is
|
|
# the only record of what a switch actually did.
|
|
# cat ~/.local/state/theme-apply.log
|
|
log="$HOME/.local/state/theme-apply.log"
|
|
mkdir -p "$(dirname "$log")"
|
|
exec >"$log" 2>&1
|
|
echo "theme-apply $(date -Is): $theme"
|
|
|
|
ln -sfn "$theme" ${lib.escapeShellArg stateLink}
|
|
|
|
# Only the files an app insists on finding at a fixed path get copied.
|
|
# Ghostty and btop are pointed straight at the state symlink instead
|
|
# (see the home-manager block below) — one less write to go wrong, and no
|
|
# way for a stale copy to survive a switch.
|
|
# NOTE: no ~/.config/gtk-3.0/gtk.css. GTK3's colours come from the
|
|
# installed Fred-<wallpaper> theme instead (see gtkTheme above); a copy
|
|
# here would sit at PRIORITY_USER, outranking the theme, and GTK3 parses
|
|
# it once per process — so it would pin every GTK3 app to whichever
|
|
# wallpaper was live when the app started.
|
|
#
|
|
# GTK4 still needs it: libadwaita ignores gtk-theme-name, so user CSS is
|
|
# the only way in, and those apps do change only on restart.
|
|
install -Dm644 "$theme/gtk4.css" "$HOME/.config/gtk-4.0/gtk.css"
|
|
# quickCss, and ONLY quickCss. This file is a variable override layer for
|
|
# the Midnight theme — it restates :root custom properties — so it has to
|
|
# be applied after Midnight to win, and vencord injects quickCss after
|
|
# themes. Shipping the same CSS as a theme instead puts it in the same
|
|
# cascade slot as Midnight, where load order decides and we lose.
|
|
# Written IN PLACE (cat >), not install: vencord's fs.watch is inotify on
|
|
# the inode, and install unlinks+recreates — new inode, watch left on the
|
|
# dead one, so only the first switch per vesktop run ever landed. Keeping
|
|
# the inode keeps the watch, and vencord hot-swaps quickCss with no reload.
|
|
mkdir -p "$HOME/.config/vesktop/settings"
|
|
cat "$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. `hyprctl eval`, not `hyprctl keyword`:
|
|
# keyword is a legacy-parser command and refuses outright under the Lua
|
|
# config. eval answers "ok", or a string naming every key it rejected —
|
|
# hl.config carries on past a bad key, so the rest still land.
|
|
reply=$(${pkgs.hyprland}/bin/hyprctl eval "$(cat "$theme/hypr.lua")" 2>&1)
|
|
[ "$reply" = "ok" ] || echo "hyprctl eval -> $reply"
|
|
|
|
# Ghostty holds its config in one process (gtk-single-instance), so a new
|
|
# window inherits whatever the daemon parsed at startup — reopening a
|
|
# terminal is NOT enough to pick up a new theme.
|
|
#
|
|
# It is a GTK application, so it publishes its actions on the session bus;
|
|
# `reload-config` is the same action the reload_config keybind fires. Going
|
|
# through D-Bus works with no window open, needs no window focused, and
|
|
# doesn't depend on synthesising a keypress into the right surface.
|
|
if ! ${pkgs.glib.bin}/bin/gdbus call --session \
|
|
--dest com.mitchellh.ghostty \
|
|
--object-path /com/mitchellh/ghostty \
|
|
--method org.gtk.Actions.Activate \
|
|
reload-config '[]' '{}' >/dev/null 2>&1; then
|
|
# No instance on the bus, or the action is gone. Restarting the daemon
|
|
# fixes it but takes every open terminal with it, so that is only on the
|
|
# table when there is nothing to lose. The class test has to cover the
|
|
# --class override from settings/hyprland.nix (dev.fred.scratchpad) —
|
|
# that window is the same process.
|
|
if ${pkgs.hyprland}/bin/hyprctl -j clients 2>/dev/null \
|
|
| ${pkgs.jq}/bin/jq -e 'any(.[].class; test("ghostty|^dev\\.fred\\."))' >/dev/null 2>&1; then
|
|
echo "ghostty reload-config failed; open terminals keep the old theme"
|
|
else
|
|
${pkgs.systemd}/bin/systemctl --user restart ghostty-daemon.service 2>/dev/null || true
|
|
fi
|
|
fi
|
|
|
|
# No vesktop reload needed: the in-place quickCss write above keeps
|
|
# vencord's file watch alive, so the CSS hot-swaps in the running window.
|
|
# (The old install-based write replaced the inode, killed the watch after
|
|
# one event, and needed a Ctrl+R sendshortcut here — which dropped calls.)
|
|
|
|
# Point GTK3 at this wallpaper's theme. Every wallpaper has its own theme
|
|
# name, so this is always a real change: gsettings emits, the portal
|
|
# forwards it, and running GTK3 apps reload the named theme — no restart,
|
|
# no away-and-back dance.
|
|
gtktheme=$(cat "$theme/gtk-theme-name")
|
|
${pkgs.glib.bin}/bin/gsettings set org.gnome.desktop.interface gtk-theme "$gtktheme" \
|
|
|| echo "gsettings set gtk-theme $gtktheme failed"
|
|
'';
|
|
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; };
|
|
# The installed YAMIS tree. quickshell needs the prefix to tell a
|
|
# monochrome icon from a Papirus fallback at draw time — see the tint
|
|
# note on yamisIcons above.
|
|
monoIcons = lib.mkOption { internal = true; type = lib.types.str; };
|
|
};
|
|
|
|
config = lib.mkMerge [{
|
|
# EVERY host, servers included. stylix ships fish as BOTH a NixOS and a
|
|
# home-manager target, and this is the NixOS half —
|
|
# programs.fish.interactiveShellInit in /etc/fish. Turning off only the
|
|
# home-manager one left this still sourcing base16-fish, which printf's
|
|
# OSC 4 / OSC 10 / OSC 11 at every shell start and repaints the build-time
|
|
# palette over whatever ghostty just reloaded. Both halves have to go or
|
|
# the terminal never changes colour.
|
|
#
|
|
# Why this is not desktop-only: OSC escapes repaint the terminal that
|
|
# DISPLAYS the shell, not the machine running it. A login shell on the
|
|
# server sends them straight back down the SSH pipe, so sshing into
|
|
# Mediaserver from a themed ghostty repainted the local window in the
|
|
# server's build-time wallpaper colours. Off, the server touches nothing
|
|
# and every TUI over SSH just uses the client's 16 ANSI colours — which
|
|
# theme-apply already keeps in sync with the wallpaper. No theme state
|
|
# has to cross the wire at all.
|
|
stylix.targets.fish.enable = false;
|
|
home-manager.users.fred.stylix.targets.fish.enable = false;
|
|
|
|
# Same story for btop, one app instead of the whole palette: stylix would
|
|
# write a hex theme baked from the host's own wallpaper. On a desktop
|
|
# theme-apply owns that file (color_theme points at the state symlink
|
|
# below); on the server there is no wallpaper worth following, so leaving
|
|
# color_theme unset lands on btop's built-in Default, which draws in the
|
|
# terminal's 16 ANSI colours and therefore in the client's theme.
|
|
home-manager.users.fred.stylix.targets.btop.enable = false;
|
|
}
|
|
(lib.mkIf isDesktop {
|
|
fred.theming = {
|
|
inherit walls defaultWall;
|
|
apply = themeApply;
|
|
monoIcons = "${yamisIcons}/share/icons/yet-another-monochrome-icon-set";
|
|
};
|
|
|
|
# Papirus-Fred inherits this by name, so it has to be on the icon search
|
|
# path for the lookup to resolve — installing the theme is what makes the
|
|
# Inherits= line above mean anything.
|
|
environment.systemPackages = [ yamisIcons ];
|
|
|
|
home-manager.users.fred = { config, lib, pkgs, osConfig, ... }: {
|
|
# These 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. (btop's is off for every host above.)
|
|
stylix.targets.gtk.enable = false;
|
|
stylix.targets.ghostty.enable = false;
|
|
|
|
# (fish's two targets are killed for every host above — base16-fish is
|
|
# what made ghostty look unthemeable, locally and over SSH alike.)
|
|
|
|
# Same reasoning, one layer up: stylix's yazi target writes a theme in
|
|
# hex, which would pin yazi to the build-time scheme. home-manager/
|
|
# fred.nix gives it an ANSI-named theme instead, so it rides ghostty's
|
|
# palette like every other TUI.
|
|
stylix.targets.yazi.enable = false;
|
|
|
|
# Every wallpaper's GTK3 theme, installed up front. theme-apply only
|
|
# flips gtk-theme between them, so they all have to exist on disk —
|
|
# GTK silently falls back to Adwaita for a name it cannot find.
|
|
home.file = lib.listToAttrs (map (f: lib.nameValuePair
|
|
".local/share/themes/${gtkThemeName f}"
|
|
{ source = gtkTheme f (schemeOf f); })
|
|
wallFiles);
|
|
|
|
# Everything stylix's gtk target did apart from writing gtk.css. The
|
|
# name here is only the pre-first-switch fallback and what lands in
|
|
# settings.ini; the live value comes from gsettings via the portal.
|
|
gtk.theme = { package = pkgs.adw-gtk3; name = gtkThemeName (lib.head wallFiles); };
|
|
# 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";
|
|
};
|
|
|
|
# Both read straight through the state symlink, so a switch is nothing
|
|
# but the `ln -sfn` in theme-apply — no copy to fail, nothing stale.
|
|
#
|
|
# The leading `?` marks the include optional: before the first
|
|
# theme-apply of a fresh account the link does not exist yet, and
|
|
# without it ghostty treats that as a config error. Includes are also
|
|
# applied AFTER the file that names them, so these colours win over
|
|
# anything home-manager writes above.
|
|
programs.ghostty.settings.config-file = "?${stateLink}/ghostty.conf";
|
|
# Manual escape hatch; the switcher itself reloads over D-Bus.
|
|
programs.ghostty.settings.keybind = [ "ctrl+shift+alt+f12=reload_config" ];
|
|
programs.btop.settings.color_theme = "${stateLink}/btop.theme";
|
|
};
|
|
})];
|
|
}
|