From 10914956748109c28f2616758e7304b492619040 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 19 Aug 2026 14:31:24 +0100 Subject: [PATCH] theming: re-theme every app on wallpaper switch, crossfade wallpaper settings/theming.nix renders a full config set per wallpaper (GTK, ghostty, btop, hyprland, vesktop, zen, folder icons) from its own base16 palette; theme-apply swaps them in and reloads what can reload. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 +- common.nix | 1 + settings/desktop.nix | 42 +--- settings/quickshell.nix | 128 ++++++----- settings/stylix.nix | 144 +----------- settings/theming.nix | 470 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 560 insertions(+), 227 deletions(-) create mode 100644 settings/theming.nix diff --git a/CLAUDE.md b/CLAUDE.md index 01992da..4e2311e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ This is a NixOS flake-based configuration for multiple hosts: - `home-manager/` — Home Manager configuration (via NixOS module) - `services/` — modular service definitions, gated by hostname with `lib.mkIf` - `settings/` — shared settings (desktop, hyprland, quickshell, stylix, …) -- `walls/` — wallpapers. Each gets a dark base16 palette generated at build time; the shell picks one at runtime (bar button → `settings/quickshell.nix`), so switching re-tints the shell without a rebuild. `walls/wallpaper.png` stays the build-time stylix scheme for everything else. +- `walls/` — wallpapers. `settings/theming.nix` builds a full theme per image (base16 palette + rendered config for GTK, ghostty, btop, hyprland, vesktop, zen, folder icons); the bar's wallpaper button switches between them at runtime via `theme-apply`, no rebuild. `walls/wallpaper.png` stays stylix's build-time image. - `modules/crowdsec/` — vendored crowdsec modules from nixpkgs PR #446307; delete once that PR lands in the pinned channel - `ports.toml` — WAN → LAN port forwards consumed by `services/router.nix` diff --git a/common.nix b/common.nix index 8d5ef40..c544812 100644 --- a/common.nix +++ b/common.nix @@ -83,6 +83,7 @@ in ./settings/desktop.nix ./settings/hyprland.nix ./settings/quickshell.nix + ./settings/theming.nix ./settings/locale.nix ./settings/audio.nix ./settings/users.nix diff --git a/settings/desktop.nix b/settings/desktop.nix index c9be1e8..66622a8 100644 --- a/settings/desktop.nix +++ b/settings/desktop.nix @@ -71,55 +71,23 @@ programs.xwayland.enable = true; programs.dconf.enable = true; - home-manager.users.fred = { config, lib, pkgs, ... }: let - # Scaling all three channels by the same factor is exactly what - # Qt.lighter() does — hue and HSV saturation stay put, only value moves — - # so this lands on the same surfaces as quickshell's Theme.base01/base02. - # GTK's own shade() can't stand in: it multiplies saturation too. - tint = f: let - ch = k: toString (builtins.floor (builtins.fromJSON config.lib.stylix.colors."base00-rgb-${k}" * f)); - in "rgb(${ch "r"}, ${ch "g"}, ${ch "b"})"; - in { + home-manager.users.fred = { # Stylix's qt target only supports qtct; disable it to silence the warning. stylix.targets.qt.enable = false; - # Minimal titlebars — stylix manages the GTK theme; we layer our - # headerbar shrink on top via stylix.targets.gtk.extraCss. + # Minimal titlebars. The GTK stylesheet itself — stylix's colours plus + # our headerbar shrink and surface-tint overrides — is rendered per + # wallpaper and swapped at runtime in settings/theming.nix. gtk.enable = true; dconf.settings."org/nemo/window-state".start-with-menu-bar = false; # GTK3 ignores dconf's color-scheme=prefer-dark — it only picks # adw-gtk3's gtk-dark.css when this setting is on. Without it GTK3 apps - # (nemo) load the *light* sheet; stylix's @define-color overrides repaint + # (nemo) load the *light* sheet; the @define-color overrides repaint # most of it, but colours sass baked in as literals survive — e.g. # `messagedialog.csd.background button { color: rgba(0,0,6,0.8); }`, which # is why nemo's delete confirmation had near-black buttons on dark bg. gtk.gtk3.extraConfig.gtk-application-prefer-dark-theme = true; - - stylix.targets.gtk.extraCss = '' - /* Surfaces: one hue, three steps — the same ladder quickshell's Theme - uses (settings/quickshell.nix, commit e2c413d). 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 1.7}; - @define-color sidebar_bg_color ${tint 1.7}; - @define-color card_bg_color ${tint 1.7}; - @define-color dialog_bg_color ${tint 1.7}; - @define-color popover_bg_color ${tint 1.7}; - @define-color scrollbar_outline_color ${tint 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; - } - ''; }; }; } diff --git a/settings/quickshell.nix b/settings/quickshell.nix index a5ee460..6054f29 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -6,51 +6,18 @@ let isMacbook = config.networking.hostName == "FredOS-Macbook"; ui = import ./ui.nix; # shared corner-rounding knob - # ── Wallpaper set ────────────────────────────────────────────────────── - # Every image in walls/ gets its own dark base16 palette, generated at - # build time by stylix's own palette-generator. The shell picks one at - # RUNTIME (state file + FileView in Theme.qml), so switching wallpaper - # re-tints the whole shell without a rebuild. + # ── Wallpapers ───────────────────────────────────────────────────────── + # Built in settings/theming.nix: one entry per image in walls/, each with + # a theme directory holding that wallpaper's base16 palette and the config + # files every other app is re-themed from. The shell picks one at RUNTIME + # (state file + FileView in Theme.qml), so switching re-tints the desktop + # without a rebuild. # - # One derivation per image, not one for the set: palette generation is a - # genetic search that takes seconds, and 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 - ''; - - # The list Theme.qml carries verbatim. Naming the store paths inside a - # home-manager file keeps both image and palette alive as generation - # references — no extra gcroot needed. - wallsJson = builtins.toJSON (map (f: { - name = wallStem f; - image = "${wallImage f}"; - palette = "${wallPalette f}/palette.json"; - }) wallFiles); - - # Falls back to walls/wallpaper.png — the one stylix themes everything - # else from — 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); + # Naming the store paths inside a home-manager file is what keeps the + # images and themes alive as generation references — no extra gcroot. + wallsJson = builtins.toJSON config.fred.theming.walls; + defaultWall = config.fred.theming.defaultWall; + themeApply = config.fred.theming.apply; in { config = lib.mkIf (lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ]) { @@ -463,6 +430,7 @@ in readonly property var currentWall: walls.find(w => w.name === wallState.name) || walls[0] || null readonly property string wallImage: currentWall ? currentWall.image : "" + readonly property string themeDir: currentWall ? currentWall.theme : "" function setWall(name) { wallState.name = name; @@ -476,7 +444,7 @@ in // scheme (walls/wallpaper.png), which is also what shows if // a palette file is ever missing or malformed. property FileView palFile: FileView { - path: theme.currentWall ? theme.currentWall.palette : "" + path: theme.themeDir === "" ? "" : theme.themeDir + "/palette.json" printErrors: false // Synchronous: an async first read paints a frame in the // previous scheme on every switch. @@ -1360,6 +1328,7 @@ in readonly property string askFetch: "${askFetchScript}" readonly property string wxFetch: "${wxFetchScript}" readonly property string xdgOpen: "${pkgs.xdg-utils}/bin/xdg-open" + readonly property string themeApply: "${themeApply}" } ''; }; @@ -1380,6 +1349,21 @@ in property var mainBar: null signal notificationReceived() + // Push the active wallpaper's theme out to everything that + // isn't quickshell — GTK, ghostty, btop, vesktop, zen, + // hyprland, folder icons. Also on startup, so a login (or a + // soft reload after a rebuild) re-asserts the current choice + // rather than leaving apps on whatever was last written. + function applyTheme() { + if (Theme.themeDir !== "") + Quickshell.execDetached([Commands.themeApply, Theme.themeDir]); + } + Component.onCompleted: applyTheme() + Connections { + target: Theme + function onThemeDirChanged() { root.applyTheme(); } + } + // Bound in hyprland.nix: Super+R → launcher (bottom of the // bar window), Super+L → session menu (right edge). IpcHandler { @@ -1588,11 +1572,57 @@ in anchors { top: true; bottom: true; left: true; right: true } color: "black" - Image { + // Two layers so a switch crossfades instead of + // cutting: the new image loads into whichever layer + // is hidden, then the pair swap opacity. The loser's + // source is dropped once the fade ends — a spare + // full-screen texture is real VRAM to leave resident + // on a machine that also runs games. + Item { + id: wallLayer anchors.fill: parent - source: "file://" + Theme.wallImage - cache: false // same-URL pixmap cache survives soft reload - fillMode: Image.PreserveAspectCrop + + readonly property string wanted: "file://" + Theme.wallImage + property bool bFront: false + + Image { + id: wallA + anchors.fill: parent + cache: false // same-URL pixmap cache survives soft reload + fillMode: Image.PreserveAspectCrop + opacity: wallLayer.bFront ? 0 : 1 + Behavior on opacity { + NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } + } + } + + Image { + id: wallB + anchors.fill: parent + cache: false + fillMode: Image.PreserveAspectCrop + opacity: wallLayer.bFront ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } + } + } + + // Local files load synchronously, so by the time + // the opacities cross the new frame is decoded — + // no black flash between the two. + Component.onCompleted: wallA.source = wanted; + + onWantedChanged: { + (bFront ? wallA : wallB).source = wanted; + bFront = !bFront; + _dropBack.restart(); + } + + Timer { + id: _dropBack + interval: 500 // just past the fade + onTriggered: (wallLayer.bFront ? wallA : wallB).source = ""; + } } } } diff --git a/settings/stylix.nix b/settings/stylix.nix index 7b3f782..272b609 100644 --- a/settings/stylix.nix +++ b/settings/stylix.nix @@ -41,146 +41,10 @@ }; }; - home-manager.users.fred = { config, lib, pkgs, osConfig, ... }: let - isDesktop = lib.elem osConfig.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ]; - c = config.lib.stylix.colors; - # Pure-Nix hex parsing for color distance calculation. - 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"; - }; - - # Closest papirus-folders color to stylix's accent (base0D), chosen at - # eval time via squared RGB distance — no runtime script needed. - closestPfColor = - 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 c.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; - - # Papirus-Dark with folders recolored to the closest matching color. - # Replicates papirus-folders' change_color logic directly: for each - # colored variant (folder-{color}-*.svg), symlink the generic name - # (folder-*.svg) to it. No papirus-folders binary needed. - papirusDark = pkgs.runCommand "papirus-dark-${closestPfColor}" {} '' - # Copy full icon tree so Papirus-Dark's ../Papirus/… symlinks resolve. - tmpthemes=$(mktemp -d) - cp -r ${pkgs.papirus-icon-theme}/share/icons/. "$tmpthemes/" - chmod -R u+w "$tmpthemes" - - # Recolor folders. Papirus-Dark delegates 48x48 etc. to ../Papirus via - # symlinks, so we operate through those resolved paths in the temp tree. - for size in 22x22 24x24 32x32 48x48 64x64; do - places="$tmpthemes/Papirus-Dark/$size/places" - [ -d "$places" ] || continue - for f in \ - "$places/folder-${closestPfColor}"-*.svg \ - "$places/folder-${closestPfColor}.svg" \ - "$places/user-${closestPfColor}"-*.svg \ - "$places/user-${closestPfColor}.svg"; do - [ -f "$f" ] || continue - fname=$(basename "$f") - ln -sf "$fname" "$places/''${fname/-${closestPfColor}/}" - done - done - - # Copy Papirus-Dark with -L to dereference all symlinks, so the output - # is self-contained — no dangling ../Papirus/… pointers. - mkdir -p $out/share/icons - cp -rL "$tmpthemes/Papirus-Dark" $out/share/icons/ - ''; - - # Raised surfaces are tints of base00, matching quickshell's - # Theme.base01/base02 and the GTK overrides in settings/desktop.nix. - # Scaling all three channels is what Qt.lighter() does, so all three - # toolkits land on the same #2f3440 / #434a5b. - tint = f: let - ch = k: toString (builtins.floor (builtins.fromJSON c."base00-rgb-${k}" * f)); - in "rgb(${ch "r"}, ${ch "g"}, ${ch "b"})"; - - # Map Material You placeholders to the closest base16 slot in stylix's - # palette. 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 = 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}}" - ] - [ - "#${c.base0D}" # primary accent - "#${c.base02}" # primary container - "#${c.base0C}" # secondary container (cyan) - "#${c.base0E}" # tertiary container (purple) - "#${c.base00}" # surface (main bg) - (tint 1.7) # surface container (urlbar, panels, dialogs) - "#${c.base00}" # surface container low - (tint 2.4) # surface container high (selected tab, focused urlbar) - "#${c.base05}" # on surface (fg) - "#${c.base04}" # on surface variant (muted fg) - "#${c.base03}" # outline - "#${c.base02}" # outline variant - ]; - in lib.mkMerge [ - # Desktop-only: Zen/Vesktop CSS and GTK icon theme - (lib.mkIf isDesktop { - home.file.".zen/fraudek5.Default Profile/chrome/userChrome.css".text = - stylixize (builtins.readFile "${inputs.self}/templates/zen-userChrome.css"); - - home.file.".config/vesktop/settings/quickCss.css".text = - stylixize (builtins.readFile "${inputs.self}/templates/vesktop-quickCss.css"); - - gtk.iconTheme = { - package = papirusDark; - name = "Papirus-Dark"; - }; - }) - ]; + # Per-app theming that has to follow the RUNTIME wallpaper (GTK, ghostty, + # btop, zen, vesktop, folder icons) lives in settings/theming.nix: it + # renders one set of config files per wallpaper in walls/ and swaps them + # in without a rebuild. Everything above is build-time and stays here. }; } diff --git a/settings/theming.nix b/settings/theming.nix new file mode 100644 index 0000000..7d876b6 --- /dev/null +++ b/settings/theming.nix @@ -0,0 +1,470 @@ +# 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, 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. +# +# 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}" + ''); + + # 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})" + ]); + + # 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 ${hyprBatch s} $out/hypr.batch + 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.batch '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 + + ln -sfn "$theme" ${lib.escapeShellArg stateLink} + + install -Dm644 "$theme/gtk3.css" "$HOME/.config/gtk-3.0/gtk.css" + install -Dm644 "$theme/gtk4.css" "$HOME/.config/gtk-4.0/gtk.css" + install -Dm644 "$theme/ghostty.conf" "$HOME/.config/ghostty/theme.conf" + install -Dm644 "$theme/btop.theme" "$HOME/.config/btop/themes/runtime.theme" + install -Dm644 "$theme/vesktop.css" "$HOME/.config/vesktop/settings/quickCss.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. + ${pkgs.hyprland}/bin/hyprctl --batch "$(cat "$theme/hypr.batch")" >/dev/null 2>&1 || true + + # 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. + # + # 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 + 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; + }; + + 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; + + # 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"; + }; + + programs.ghostty.settings.config-file = "?${homeDir}/.config/ghostty/theme.conf"; + programs.ghostty.settings.keybind = [ "ctrl+shift+alt+f12=reload_config" ]; + programs.btop.settings.color_theme = "${homeDir}/.config/btop/themes/runtime.theme"; + }; + }; +}