theming: reload ghostty over D-Bus, apply hypr keywords one by one

hyprctl --batch reported one combined result, so a rejected key took the
rest with it and errors were swallowed; each keyword now reports to the
journal. Ghostty's reload_config keybind never arrived via sendshortcut —
use its org.gtk.Actions reload-config action instead, which needs no
window. Add a gsettings theme toggle to nudge running GTK apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-08-19 15:05:59 +01:00
parent c85bb4545f
commit 43f6a886ac

View file

@ -6,10 +6,10 @@
# `theme-apply` drops that wallpaper's rendered config into place and nudges # `theme-apply` drops that wallpaper's rendered config into place and nudges
# every app that can reload without restarting. # every app that can reload without restarting.
# #
# What follows a switch immediately: quickshell, hyprland, GTK apps, vesktop, # What follows a switch immediately: quickshell, hyprland, vesktop, folder
# folder icon tint. What follows on next launch: btop, zen, spotify-player. # icons, and ghostty (reloaded over its D-Bus action). GTK apps only re-read
# Ghostty is reloaded in place when a window exists to send the keybind to, # their stylesheet at startup, so nemo etc. change on next launch; so do btop,
# otherwise its (windowless) daemon is restarted. # zen and spotify-player.
# #
# stylix stays the source of truth for build-time defaults and for every # stylix stays the source of truth for build-time defaults and for every
# target not listed here — walls/wallpaper.png remains its image. # target not listed here — walls/wallpaper.png remains its image.
@ -270,21 +270,26 @@ let
theme[process_end]="${base08}" theme[process_end]="${base08}"
''); '');
# One `hyprctl --batch` argument string. Mirrors the colour keys set # One `<key> <value>` per line, applied by theme-apply with a separate
# statically in settings/hyprland.nix, which stay the build-time fallback # `hyprctl keyword` each. NOT `hyprctl --batch`: a batch reports one
# for the window or two before theme-apply first runs. # combined result, so a single rejected key silently takes the rest of the
hyprBatch = s: pkgs.writeText "hypr.batch" (lib.concatStringsSep " ; " [ # colours with it. Per-key, a bad one names itself in the journal and the
"keyword general:col.active_border rgb(${s.base04})" # other nine still land.
"keyword general:col.inactive_border rgb(${s.base03})" #
"keyword decoration:shadow:color rgba(${s.base00}99)" # Mirrors the colour keys set statically in settings/hyprland.nix, which
"keyword group:col.border_active rgb(${s.base04})" # stay the build-time fallback until theme-apply first runs.
"keyword group:col.border_inactive rgb(${s.base03})" hyprKeywords = s: pkgs.writeText "hypr.keywords" ''
"keyword group:col.border_locked_active rgb(${s.base05})" general:col.active_border rgb(${s.base04})
"keyword group:groupbar:text_color rgb(${s.base05})" general:col.inactive_border rgb(${s.base03})
"keyword group:groupbar:col.active rgb(${s.base04})" decoration:shadow:color rgba(${s.base00}99)
"keyword group:groupbar:col.inactive rgb(${s.base03})" group:col.border_active rgb(${s.base04})
"keyword misc:background_color rgb(${s.base00})" 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})
'';
# A featherweight icon theme that inherits Papirus-Dark and overrides only # A featherweight icon theme that inherits Papirus-Dark and overrides only
# the folder faces, rather than a recoloured copy of the whole 200MB tree # the folder faces, rather than a recoloured copy of the whole 200MB tree
@ -338,7 +343,7 @@ let
cp ${gtk4Css s} $out/gtk4.css cp ${gtk4Css s} $out/gtk4.css
cp ${ghosttyConf s} $out/ghostty.conf cp ${ghosttyConf s} $out/ghostty.conf
cp ${btopTheme s} $out/btop.theme cp ${btopTheme s} $out/btop.theme
cp ${hyprBatch s} $out/hypr.batch cp ${hyprKeywords s} $out/hypr.keywords
cp ${pkgs.writeText "vesktop.css" (stylixize s (builtins.readFile "${inputs.self}/templates/vesktop-quickCss.css"))} $out/vesktop.css 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 cp ${pkgs.writeText "zen.css" (stylixize s (builtins.readFile "${inputs.self}/templates/zen-userChrome.css"))} $out/zen.css
ln -s ${iconTheme s} $out/icons ln -s ${iconTheme s} $out/icons
@ -355,7 +360,7 @@ let
check gtk3.css '@define-color window_bg_color' check gtk3.css '@define-color window_bg_color'
check gtk4.css 'adw-gtk3/gtk-4.0/gtk.css' check gtk4.css 'adw-gtk3/gtk-4.0/gtk.css'
check ghostty.conf '^background = #' check ghostty.conf '^background = #'
check hypr.batch 'col.active_border rgb(' check hypr.keywords 'col.active_border rgb('
check vesktop.css '^ --bg-4: #' check vesktop.css '^ --bg-4: #'
''; '';
@ -398,26 +403,59 @@ let
cp -r "$theme"/icons/. "$icons"/ cp -r "$theme"/icons/. "$icons"/
chmod -R u+w "$icons" chmod -R u+w "$icons"
# Hyprland takes its colours live. # Hyprland takes its colours live, one keyword at a time. hyprctl answers
${pkgs.hyprland}/bin/hyprctl --batch "$(cat "$theme/hypr.batch")" >/dev/null 2>&1 || true # "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"
# Ghostty holds its config in one process (gtk-single-instance), so a new # Ghostty holds its config in one process (gtk-single-instance), so a new
# window would inherit the old theme. Poke the reload_config keybind at # window inherits whatever the daemon parsed at startup — reopening a
# any live window — the reload is process-wide, so one is enough. # terminal is NOT enough to pick up a new theme.
# #
# The match has to cover the --class overrides from settings/hyprland.nix # It is a GTK application, so it publishes its actions on the session bus;
# (dev.fred.scratchpad, dev.fred.spotify) as well as the default id: those # `reload-config` is the same action the reload_config keybind fires. Going
# windows are the same process, and mistaking them for "no ghostty open" # through D-Bus works with no window open, needs no window focused, and
# would restart the daemon out from under a running shell. # doesn't depend on synthesising a keypress into the right surface.
cls=$(${pkgs.hyprland}/bin/hyprctl -j clients 2>/dev/null \ if ! ${pkgs.glib.bin}/bin/gdbus call --session \
| ${pkgs.jq}/bin/jq -r 'map(select(.class | test("ghostty|^dev\\.fred\\.")))[0].class // empty') --dest com.mitchellh.ghostty \
if [ -n "$cls" ]; then --object-path /com/mitchellh/ghostty \
${pkgs.hyprland}/bin/hyprctl dispatch sendshortcut \ --method org.gtk.Actions.Activate \
"CTRL SHIFT ALT, F12, class:^(''${cls//./\\.})$" >/dev/null 2>&1 || true 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 "theme-apply: ghostty reload-config failed; open terminals keep the old theme" >&2
else else
# No windows to lose: the daemon is the only thing holding stale config.
${pkgs.systemd}/bin/systemctl --user restart ghostty-daemon.service 2>/dev/null || true ${pkgs.systemd}/bin/systemctl --user restart ghostty-daemon.service 2>/dev/null || true
fi 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 in
{ {