2026-08-19 14:31:24 +01:00
|
|
|
# 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.
|
|
|
|
|
#
|
2026-08-19 15:05:59 +01:00
|
|
|
# What follows a switch immediately: quickshell, hyprland, vesktop, folder
|
|
|
|
|
# icons, and ghostty (reloaded over its D-Bus action). GTK apps only re-read
|
|
|
|
|
# their stylesheet at startup, so nemo etc. change on next launch; so do btop,
|
|
|
|
|
# zen and spotify-player.
|
2026-08-19 14:31:24 +01:00
|
|
|
#
|
|
|
|
|
# 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}"
|
|
|
|
|
'');
|
|
|
|
|
|
2026-08-19 15:39:46 +01:00
|
|
|
# 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.
|
2026-08-19 15:05:59 +01:00
|
|
|
#
|
2026-08-19 15:39:46 +01:00
|
|
|
# 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.
|
|
|
|
|
hyprLua = s: pkgs.writeText "hypr.lua" ("hl.config({" + lib.concatStringsSep "," [
|
|
|
|
|
''general={["col.active_border"]="rgb(${s.base04})",["col.inactive_border"]="rgb(${s.base03})"}''
|
|
|
|
|
''decoration={shadow={color="rgba(${s.base00}99)"}}''
|
|
|
|
|
(''group={["col.border_active"]="rgb(${s.base04})",''
|
|
|
|
|
+ ''["col.border_inactive"]="rgb(${s.base03})",''
|
|
|
|
|
+ ''["col.border_locked_active"]="rgb(${s.base05})",''
|
|
|
|
|
+ ''groupbar={text_color="rgb(${s.base05})",''
|
|
|
|
|
+ ''["col.active"]="rgb(${s.base04})",["col.inactive"]="rgb(${s.base03})"}}'')
|
|
|
|
|
''misc={background_color="rgb(${s.base00})"}''
|
|
|
|
|
] + "})");
|
2026-08-19 14:31:24 +01:00
|
|
|
|
|
|
|
|
# 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
|
2026-08-19 15:39:46 +01:00
|
|
|
cp ${hyprLua s} $out/hypr.lua
|
2026-08-19 14:31:24 +01:00
|
|
|
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 = #'
|
2026-08-19 15:39:46 +01:00
|
|
|
check hypr.lua 'col.active_border"]="rgb('
|
2026-08-19 14:31:24 +01:00
|
|
|
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
|
|
|
|
|
|
2026-08-19 15:39:46 +01:00
|
|
|
# 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"
|
2026-08-19 15:23:10 +01:00
|
|
|
|
2026-08-19 14:31:24 +01:00
|
|
|
ln -sfn "$theme" ${lib.escapeShellArg stateLink}
|
|
|
|
|
|
2026-08-19 15:23:10 +01:00
|
|
|
# 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.
|
2026-08-19 14:31:24 +01:00
|
|
|
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/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"
|
|
|
|
|
|
2026-08-19 15:39:46 +01:00
|
|
|
# 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"
|
2026-08-19 14:31:24 +01:00
|
|
|
|
|
|
|
|
# Ghostty holds its config in one process (gtk-single-instance), so a new
|
2026-08-19 15:05:59 +01:00
|
|
|
# window inherits whatever the daemon parsed at startup — reopening a
|
|
|
|
|
# terminal is NOT enough to pick up a new theme.
|
2026-08-19 14:31:24 +01:00
|
|
|
#
|
2026-08-19 15:05:59 +01:00
|
|
|
# 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 overrides from settings/hyprland.nix (dev.fred.scratchpad,
|
|
|
|
|
# dev.fred.spotify) — those windows are 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
|
2026-08-19 15:39:46 +01:00
|
|
|
echo "ghostty reload-config failed; open terminals keep the old theme"
|
2026-08-19 15:05:59 +01:00
|
|
|
else
|
|
|
|
|
${pkgs.systemd}/bin/systemctl --user restart ghostty-daemon.service 2>/dev/null || true
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# GTK reads ~/.config/gtk-{3,4}.0/gtk.css once at startup and does not
|
|
|
|
|
# watch it, so running apps only re-read on a theme-name change. Toggling
|
|
|
|
|
# the gsettings key away and back is the one nudge that reaches them, and
|
|
|
|
|
# only lands if something is bridging gsettings to GTK (the portal). Nemo
|
|
|
|
|
# picks the new colours up on next launch regardless.
|
|
|
|
|
gsettings=${pkgs.glib.bin}/bin/gsettings
|
|
|
|
|
iface=org.gnome.desktop.interface
|
|
|
|
|
if cur=$($gsettings get $iface gtk-theme 2>/dev/null); then
|
|
|
|
|
cur=''${cur//\'/}
|
|
|
|
|
# The detour has to be a DIFFERENT name or gsettings writes the same
|
|
|
|
|
# value, emits no change signal, and nothing reloads.
|
|
|
|
|
away=Adwaita
|
|
|
|
|
[ "$cur" = "$away" ] && away=Adwaita-dark
|
|
|
|
|
$gsettings set $iface gtk-theme "$away" 2>/dev/null || true
|
|
|
|
|
$gsettings set $iface gtk-theme "$cur" 2>/dev/null || true
|
2026-08-19 14:31:24 +01:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-19 15:49:47 +01:00
|
|
|
# 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.
|
|
|
|
|
stylix.targets.fish.enable = false;
|
|
|
|
|
|
2026-08-19 14:31:24 +01:00
|
|
|
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;
|
|
|
|
|
|
2026-08-19 15:39:46 +01:00
|
|
|
# The one that made ghostty look unthemeable. stylix's fish target
|
|
|
|
|
# sources base16-fish, which is a base16-SHELL script: at every shell
|
|
|
|
|
# start it printf's OSC 4 (palette 0-15) and OSC 10/11 (fg/bg) with the
|
|
|
|
|
# BUILD-TIME colours, overwriting whatever ghostty just loaded. So a
|
|
|
|
|
# wallpaper switch reloaded ghostty correctly and fish immediately
|
|
|
|
|
# painted the old scheme back over it — and opening a new window ran
|
|
|
|
|
# fish again, which is why reopening never helped either. Only
|
|
|
|
|
# selection-background, which base16-fish does not touch, got through.
|
|
|
|
|
#
|
|
|
|
|
# Off, fish leaves the palette alone and inherits the terminal's, so
|
|
|
|
|
# every ANSI-coloured program in a ghostty window follows a switch live.
|
|
|
|
|
stylix.targets.fish.enable = false;
|
|
|
|
|
|
2026-08-19 14:31:24 +01:00
|
|
|
# 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";
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-19 15:23:10 +01:00
|
|
|
# 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.
|
2026-08-19 14:31:24 +01:00
|
|
|
programs.ghostty.settings.keybind = [ "ctrl+shift+alt+f12=reload_config" ];
|
2026-08-19 15:23:10 +01:00
|
|
|
programs.btop.settings.color_theme = "${stateLink}/btop.theme";
|
2026-08-19 14:31:24 +01:00
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
}
|