theming: stop fish repainting the palette, drive hyprland via eval

fish's stylix target sources base16-fish, which printf's OSC 4 and OSC
10/11 with build-time colours at every shell start — it painted the old
scheme back over ghostty on every reload, and again in each new window.

hyprctl keyword is legacy-parser only and refuses under the lua config
("Use eval."); emit hl.config lua and hyprctl eval it instead.

theme-apply now logs to ~/.local/state/theme-apply.log: quickshell starts
it with execDetached, which drops stdio, so stderr went nowhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-08-19 15:39:46 +01:00
parent 8e33a7ba12
commit 40f795db7b

View file

@ -270,26 +270,28 @@ let
theme[process_end]="${base08}"
'');
# One `<key> <value>` per line, applied by theme-apply with a separate
# `hyprctl keyword` each. NOT `hyprctl --batch`: a batch reports one
# combined result, so a single rejected key silently takes the rest of the
# colours with it. Per-key, a bad one names itself in the journal and the
# other nine still land.
# 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.
#
# Mirrors the colour keys set statically in settings/hyprland.nix, which
# stay the build-time fallback until theme-apply first runs.
hyprKeywords = s: pkgs.writeText "hypr.keywords" ''
general:col.active_border rgb(${s.base04})
general:col.inactive_border rgb(${s.base03})
decoration:shadow:color rgba(${s.base00}99)
group:col.border_active rgb(${s.base04})
group:col.border_inactive rgb(${s.base03})
group:col.border_locked_active rgb(${s.base05})
group:groupbar:text_color rgb(${s.base05})
group:groupbar:col.active rgb(${s.base04})
group:groupbar:col.inactive rgb(${s.base03})
misc:background_color rgb(${s.base00})
'';
# 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
@ -343,7 +345,7 @@ let
cp ${gtk4Css s} $out/gtk4.css
cp ${ghosttyConf s} $out/ghostty.conf
cp ${btopTheme s} $out/btop.theme
cp ${hyprKeywords s} $out/hypr.keywords
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
@ -360,7 +362,7 @@ let
check gtk3.css '@define-color window_bg_color'
check gtk4.css 'adw-gtk3/gtk-4.0/gtk.css'
check ghostty.conf '^background = #'
check hypr.keywords 'col.active_border rgb('
check hypr.lua 'col.active_border"]="rgb('
check vesktop.css '^ --bg-4: #'
'';
@ -385,9 +387,15 @@ let
theme="$1"
[ -d "$theme" ] || exit 0
# One line per run, so the journal answers "did it fire, and with what?"
# without any guessing: journalctl --user -u quickshell | grep theme-apply
echo "theme-apply: $theme" >&2
# 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}
@ -409,16 +417,12 @@ let
cp -r "$theme"/icons/. "$icons"/
chmod -R u+w "$icons"
# Hyprland takes its colours live, one keyword at a time. hyprctl answers
# "ok" and exits 0 even for a key it did not like, so the reply is what
# gets checked — anything else is named in quickshell's journal
# (journalctl --user -u quickshell) instead of vanishing.
while read -r key value; do
[ -n "$key" ] || continue
reply=$(${pkgs.hyprland}/bin/hyprctl keyword "$key" "$value" 2>&1)
[ "$reply" = "ok" ] \
|| echo "theme-apply: hyprctl keyword $key $value -> $reply" >&2
done < "$theme/hypr.keywords"
# 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
@ -440,7 +444,7 @@ let
# 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 "theme-apply: ghostty reload-config failed; open terminals keep the old theme" >&2
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
@ -489,6 +493,19 @@ in
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