vencord watches quickCss.css with fs.watch on the file and delivers one event per vesktop run in practice, so only the first switch applied. Its themes watcher is on the DIRECTORY, whose inode is never replaced, so it keeps firing — write the same CSS there too. Enable "fredos" once under Vencord -> Themes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
561 lines
26 KiB
Nix
561 lines
26 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, 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.
|
|
#
|
|
# 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}"
|
|
'');
|
|
|
|
# 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
|
|
'';
|
|
|
|
# ── 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 ${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.
|
|
install -Dm644 "$theme/gtk3.css" "$HOME/.config/gtk-3.0/gtk.css"
|
|
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
|
|
|
|
# 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
|
|
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;
|
|
};
|
|
|
|
# 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;
|
|
|
|
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;
|
|
|
|
# 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";
|
|
};
|
|
|
|
# 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";
|
|
};
|
|
};
|
|
}
|