diff --git a/settings/theming.nix b/settings/theming.nix index 7d876b6..9e699d5 100644 --- a/settings/theming.nix +++ b/settings/theming.nix @@ -6,10 +6,10 @@ # `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, GTK apps, vesktop, -# folder icon tint. What follows on next launch: btop, zen, spotify-player. -# Ghostty is reloaded in place when a window exists to send the keybind to, -# otherwise its (windowless) daemon is restarted. +# 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. @@ -270,21 +270,26 @@ let theme[process_end]="${base08}" ''); - # One `hyprctl --batch` argument string. Mirrors the colour keys set - # statically in settings/hyprland.nix, which stay the build-time fallback - # for the window or two before theme-apply first runs. - hyprBatch = s: pkgs.writeText "hypr.batch" (lib.concatStringsSep " ; " [ - "keyword general:col.active_border rgb(${s.base04})" - "keyword general:col.inactive_border rgb(${s.base03})" - "keyword decoration:shadow:color rgba(${s.base00}99)" - "keyword group:col.border_active rgb(${s.base04})" - "keyword group:col.border_inactive rgb(${s.base03})" - "keyword group:col.border_locked_active rgb(${s.base05})" - "keyword group:groupbar:text_color rgb(${s.base05})" - "keyword group:groupbar:col.active rgb(${s.base04})" - "keyword group:groupbar:col.inactive rgb(${s.base03})" - "keyword misc:background_color rgb(${s.base00})" - ]); + # One ` ` 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. + # + # 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}) + ''; # A featherweight icon theme that inherits Papirus-Dark and overrides only # 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 ${ghosttyConf s} $out/ghostty.conf 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 "zen.css" (stylixize s (builtins.readFile "${inputs.self}/templates/zen-userChrome.css"))} $out/zen.css ln -s ${iconTheme s} $out/icons @@ -355,7 +360,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.batch 'col.active_border rgb(' + check hypr.keywords 'col.active_border rgb(' check vesktop.css '^ --bg-4: #' ''; @@ -398,25 +403,58 @@ let cp -r "$theme"/icons/. "$icons"/ chmod -R u+w "$icons" - # Hyprland takes its colours live. - ${pkgs.hyprland}/bin/hyprctl --batch "$(cat "$theme/hypr.batch")" >/dev/null 2>&1 || true + # 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" # 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 - # any live window — the reload is process-wide, so one is enough. + # window inherits whatever the daemon parsed at startup — reopening a + # terminal is NOT enough to pick up a new theme. # - # The match has to cover the --class overrides from settings/hyprland.nix - # (dev.fred.scratchpad, dev.fred.spotify) as well as the default id: those - # windows are the same process, and mistaking them for "no ghostty open" - # would restart the daemon out from under a running shell. - cls=$(${pkgs.hyprland}/bin/hyprctl -j clients 2>/dev/null \ - | ${pkgs.jq}/bin/jq -r 'map(select(.class | test("ghostty|^dev\\.fred\\.")))[0].class // empty') - if [ -n "$cls" ]; then - ${pkgs.hyprland}/bin/hyprctl dispatch sendshortcut \ - "CTRL SHIFT ALT, F12, class:^(''${cls//./\\.})$" >/dev/null 2>&1 || true - 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 + # 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 "theme-apply: ghostty reload-config failed; open terminals keep the old theme" >&2 + 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