# 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 and helium (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, spotify-player. # # 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-.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--*.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. 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})"}'' ] + "})"); # 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 ''; # 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- 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" # Vesktop gets the same CSS twice, on purpose. # # quickCss is what applies with no setup, but vencord watches it with # fs.watch on the FILE, and in practice that watcher delivers exactly one # event per vesktop run here — every switch after the first is ignored # until vesktop restarts. (The writes themselves are fine: a standalone # fs.watch sees every one of these installs.) # # The themes directory is vencord's other watcher and it watches the # DIRECTORY, whose inode nothing ever replaces, so it keeps firing. Enable # "fredos" once under Vencord → Themes and switches apply live. Identical # declarations from both sources land on the same result. install -Dm644 "$theme/vesktop.css" "$HOME/.config/vesktop/settings/quickCss.css" install -Dm644 "$theme/vesktop.css" "$HOME/.config/vesktop/themes/fredos.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 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 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 # 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; }; }; config = lib.mkIf isDesktop { fred.theming = { inherit walls defaultWall; apply = themeApply; }; # 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; # Same story one layer up, for helium: stylix's chromium target is not a # theme but a MANAGED POLICY — {"BrowserThemeColor":"#"} written to # /etc/{chromium,brave,opt/chrome}/policies/managed/extra.json, pinned to # the build-time wallpaper. helium reads /etc/chromium/policies, so it came # up wearing one frozen colour, labelled "controlled by your administrator", # with the whole appearance section greyed out — which also locks the GTK # theme mode and the frame toggle that apps/helium.nix sets. Off, so those # prefs are ours and the colours follow the live wallpaper theme. stylix.targets.chromium.enable = false; 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; # 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; # 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"; }; }; }