Some checks failed
Check / eval (push) Has been cancelled
EasyEffects 8 publishes SNI IconName com.github.wwmm.easyeffects, which Papirus renders as its full-colour blue equaliser — the one splash of colour in an otherwise monochrome Waybar tray (nm-applet, udiskie & co resolve to Papirus symbolics). Fix at the shared icon-theme layer so it holds for ALL themes at once: wrap the resolved icon set in a thin child theme, Nomarchy-icons, that Inherits it and ships a palette-coloured monochrome easyeffects glyph (the app's own -symbolic shape, filled with the theme's colour — the same hue as the bar's glyphs, so it tracks every theme/mode on switch). The child becomes the session icon theme; a scalable override out-resolves the parent's fixed-size icon at every requested size, while every other icon still falls through to the parent unchanged. Since iconTheme feeds both stylix (GTK) and rofi, wrapping it once here covers generated and whole-swap themes with no per-theme files. Adding a future offender is one more SVG in scalable/apps. Mechanism validated by GTK IconTheme lookup (override wins at 16/22/24/ 32px; udiskie/nm/folder icons still resolve from Papirus). Icon pkg builds; fill=#D3DAE0, Inherits=Papirus-Dark under Boreal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
293 lines
14 KiB
Nix
293 lines
14 KiB
Nix
# Nomarchy theming engine.
|
|
#
|
|
# nomarchy.stateFile (theme-state.json, inside the consuming flake) is
|
|
# ingested at evaluation time — pure, because flake files are store
|
|
# paths — and exposed to every other module as `config.nomarchy.theme`.
|
|
#
|
|
# Theme changes are fully Home Manager managed: `nomarchy-theme-sync
|
|
# apply <theme>` writes the JSON and runs `home-manager switch`, baking
|
|
# everything (Hyprland, Waybar, Ghostty, btop, Stylix) into one
|
|
# read-only generation. No runtime patching, no partial states; theme
|
|
# history is generation history.
|
|
#
|
|
# The one runtime exception is the wallpaper (swww is imperative by
|
|
# nature): applied at session start, after every switch (hook below),
|
|
# and cycled instantly with `nomarchy-theme-sync bg next`.
|
|
{ config, lib, pkgs, ... }:
|
|
|
|
let
|
|
cfg = config.nomarchy;
|
|
|
|
# Fail-closed load: missing / empty / non-object get a short pointer at
|
|
# the template + `nomarchy-theme-sync validate`, not a raw readFile stack
|
|
# from deep inside a consumer (nightlight, hyprland, …). Field-level
|
|
# checks below still run on the merged result.
|
|
themeState = import ../theme-state-read.nix { inherit lib; } cfg.stateFile;
|
|
|
|
# Defaults guarantee evaluation succeeds on a sparse or older state
|
|
# file (e.g. one written before a schema field was added). The shipped
|
|
# Boreal preset provides the color/ansi fallbacks; the path is
|
|
# relative to this module, so it resolves inside Nomarchy's own flake
|
|
# source even when consumed downstream.
|
|
preset = builtins.fromJSON (builtins.readFile ../../themes/boreal.json);
|
|
|
|
defaults = preset // {
|
|
fonts = {
|
|
mono = "JetBrainsMono Nerd Font";
|
|
ui = "Inter";
|
|
size = 11;
|
|
};
|
|
ui = {
|
|
gapsIn = 5;
|
|
gapsOut = 12;
|
|
borderSize = 2;
|
|
rounding = 10;
|
|
iconSize = 36; # rofi launcher + menu icon size (px) for the generated theme
|
|
activeOpacity = 1.0;
|
|
inactiveOpacity = 0.95;
|
|
terminalOpacity = 0.96;
|
|
blur = true;
|
|
shadow = true;
|
|
};
|
|
# Icon theme name; "" means "pick by mode" (resolved below). A preset
|
|
# or the state file can name any theme in the shipped icon package.
|
|
icons = "";
|
|
# Window border colors. Values are palette keys (resolved against
|
|
# `colors` below) or literal #rrggbb. Solid, not a gradient — every
|
|
# legacy identity theme used a solid border (accent for most, the text
|
|
# tone for kanagawa/summer-*). Each preset declares its own so a theme
|
|
# switch always replaces it (deep_merge would otherwise leave it stuck).
|
|
border = { active = "accent"; inactive = "overlay"; };
|
|
|
|
# Non-appearance feature settings the menu/watchers write into this same
|
|
# in-flake state. nomarchy.nightlight: `installed` (sticky — gates the unit,
|
|
# so the first enable rebuilds) and `on` (instant runtime on/off).
|
|
# settings.keyboard.devices: per-device layouts the new-keyboard watcher
|
|
# remembers (device-name -> XKB layout), git-tracked instead of
|
|
# ~/.local/state; they graduate into nomarchy.keyboard.devices on the next
|
|
# rebuild. settings.monitors: per-output resolutions the Display menu
|
|
# remembers (output-name -> "WxH@R"), overlaid onto nomarchy.monitors by
|
|
# name in hyprland.nix — the monitor twin of the keyboard graduation.
|
|
# settings.displayProfile / displayProfileAuto: active named layout +
|
|
# auto-switch on plug events (hyprland.nix / Display menu).
|
|
# settings.firstBootShown: first-session welcome toast gate (#81) —
|
|
# written true after the toast fires (nomarchy-first-boot).
|
|
# Defaulted so a sparse/older state file still evaluates; nomarchy.settings
|
|
# exposes them.
|
|
settings = {
|
|
nightlight = { installed = false; on = true; };
|
|
keyboard.devices = { };
|
|
monitors = { };
|
|
# Automatic timezone detection (nomarchy.system.autoTimezone): a system
|
|
# service, but the flag lives here so both sides read one source — the
|
|
# home side gates the Waybar-refresh watcher (timezone.nix) on it.
|
|
autoTimezone = false;
|
|
displayProfile = "";
|
|
displayProfileAuto = false;
|
|
firstBootShown = false;
|
|
};
|
|
};
|
|
|
|
parsed = lib.recursiveUpdate defaults themeState;
|
|
|
|
# ── Friendly eval-time validation ───────────────────────────────────
|
|
# The same schema nomarchy-theme-sync enforces before every write. A
|
|
# HAND-edited state file (the one path that bypasses the tool) must
|
|
# fail with the field, the problem, and the fix — not a Nix stack
|
|
# trace deep in some consumer. Checks run on `parsed` (after the
|
|
# defaults), so missing fields are fine; only wrong values throw.
|
|
isHex = v: builtins.isString v && builtins.match "#[0-9a-fA-F]{6}" v != null;
|
|
isNum = v: builtins.isInt v || builtins.isFloat v;
|
|
colorRoles = [ "base" "mantle" "surface" "overlay" "text" "subtext"
|
|
"muted" "accent" "accentAlt" "good" "warn" "bad" ];
|
|
got = v: "got: ${builtins.toJSON v}";
|
|
problems = lib.concatLists [
|
|
(map (k: "colors.${k} must be \"#RRGGBB\" (${got (parsed.colors.${k} or null)})")
|
|
(builtins.filter (k: !isHex (parsed.colors.${k} or null)) colorRoles))
|
|
(lib.optional (!(parsed.mode == "dark" || parsed.mode == "light"))
|
|
"mode must be \"dark\" or \"light\" (${got parsed.mode})")
|
|
(map (k: "ui.${k} must be a non-negative whole number (${got (parsed.ui.${k} or null)})")
|
|
(builtins.filter
|
|
(k: let v = parsed.ui.${k} or null; in !(builtins.isInt v && v >= 0))
|
|
[ "gapsIn" "gapsOut" "borderSize" "rounding" "iconSize" ]))
|
|
(map (k: "ui.${k} must be a number between 0 and 1 (${got (parsed.ui.${k} or null)})")
|
|
(builtins.filter
|
|
(k: let v = parsed.ui.${k} or null; in !(isNum v && v >= 0 && v <= 1))
|
|
[ "activeOpacity" "inactiveOpacity" "terminalOpacity" ]))
|
|
(map (k: "ui.${k} must be true or false (${got (parsed.ui.${k} or null)})")
|
|
(builtins.filter (k: !builtins.isBool (parsed.ui.${k} or null))
|
|
[ "blur" "shadow" ]))
|
|
(map (k: "fonts.${k} must be a font-family string (${got (parsed.fonts.${k} or null)})")
|
|
(builtins.filter (k: !builtins.isString (parsed.fonts.${k} or null))
|
|
[ "mono" "ui" ]))
|
|
(lib.optional (!(isNum (parsed.fonts.size or null) && parsed.fonts.size > 0))
|
|
"fonts.size must be a positive number (${got (parsed.fonts.size or null)})")
|
|
(lib.optional (!(builtins.isList parsed.ansi
|
|
&& builtins.length parsed.ansi == 16
|
|
&& lib.all isHex parsed.ansi))
|
|
"ansi must be a list of exactly 16 \"#RRGGBB\" strings")
|
|
(map (k: "border.${k} must be a palette role or \"#RRGGBB\" (${got (parsed.border.${k} or null)})")
|
|
(builtins.filter
|
|
(k: let v = parsed.border.${k} or null;
|
|
in !(builtins.isString v && (builtins.elem v colorRoles || isHex v)))
|
|
[ "active" "inactive" ]))
|
|
];
|
|
checked =
|
|
if problems == [ ] then parsed
|
|
else throw ''
|
|
|
|
Nomarchy: your theme-state.json is invalid:
|
|
${lib.concatMapStrings (p: " ✖ ${p}\n") problems}
|
|
Fix the named field(s) in the theme-state.json of your flake
|
|
checkout (usually ~/.nomarchy/theme-state.json — the store copy at
|
|
${toString cfg.stateFile} is a snapshot of it). The tool prints
|
|
the same report with per-field fixes: `nomarchy-theme-sync
|
|
validate`. Re-applying any preset resets all appearance fields:
|
|
`nomarchy-theme-sync apply boreal`.'';
|
|
|
|
# A border value is a palette key (look it up in colors) unless it's
|
|
# already a literal hex. Unknown roles are rejected by the field checks
|
|
# above; resolve only runs on a validated state.
|
|
resolveColor = v:
|
|
if lib.hasPrefix "#" v then v
|
|
else parsed.colors.${v} or (throw ''
|
|
|
|
Nomarchy: border role "${v}" is not in the palette (colors.*).
|
|
Use one of: ${lib.concatStringsSep ", " colorRoles}
|
|
or a literal "#RRGGBB". Validate with: nomarchy-theme-sync validate'');
|
|
border = {
|
|
active = resolveColor checked.border.active;
|
|
inactive = resolveColor checked.border.inactive;
|
|
};
|
|
|
|
# Resolve the icon theme once and expose it on nomarchy.theme so both
|
|
# the GTK side (stylix.nix) and rofi (rofi.nix) agree. The shipped
|
|
# package (papirus-icon-theme) carries the Dark/Light pair.
|
|
iconTheme =
|
|
if parsed.icons != "" then parsed.icons
|
|
else if parsed.mode == "light" then "Papirus-Light"
|
|
else "Papirus-Dark";
|
|
|
|
# Icon-pack resolution — opt-in, no default bloat. Papirus is the shipped
|
|
# default and the ONLY icon pack in the closure unless a theme's `icons`
|
|
# names a set from another pack; then that pack (and only it) is
|
|
# union-joined alongside papirus. Because the default themes name
|
|
# `Papirus-*`, the resolved package below is byte-identical to
|
|
# papirus-icon-theme (no symlinkJoin, no added MB). To offer another set:
|
|
# add one row to `iconPacks` and set `icons = "<Name>"` in a theme JSON
|
|
# (see templates/downstream/home.nix). Referencing `pkgs.*` here is
|
|
# eval-only — a pack enters the closure solely when it is the matched one.
|
|
papirusPkg = pkgs.papirus-icon-theme;
|
|
iconPacks = [
|
|
# pkg + the theme-name prefixes it provides (packs ship many named
|
|
# variants under one prefix, e.g. Tela / Tela-dark / Tela-blue).
|
|
{ pkg = papirusPkg; prefixes = [ "Papirus" "breeze" ]; }
|
|
{ pkg = pkgs.tela-icon-theme; prefixes = [ "Tela" ]; }
|
|
{ pkg = pkgs.qogir-icon-theme; prefixes = [ "Qogir" ]; }
|
|
{ pkg = pkgs.reversal-icon-theme; prefixes = [ "Reversal" ]; }
|
|
{ pkg = pkgs.numix-icon-theme-circle; prefixes = [ "Numix" ]; }
|
|
];
|
|
# The pack whose prefix matches the resolved name (null = unknown name →
|
|
# GTK falls back gracefully; papirus is still present).
|
|
matchedPack =
|
|
let m = lib.findFirst
|
|
(p: lib.any (pre: lib.hasPrefix pre iconTheme) p.prefixes) null iconPacks;
|
|
in if m == null then null else m.pkg;
|
|
iconThemePackage =
|
|
if matchedPack == null || matchedPack == papirusPkg
|
|
then papirusPkg # single pack → identical store path, zero closure delta
|
|
else pkgs.symlinkJoin {
|
|
name = "nomarchy-icon-themes";
|
|
paths = [ papirusPkg matchedPack ];
|
|
};
|
|
|
|
# ── Tray-icon overrides (BACKLOG #89) ────────────────────────────────
|
|
# Some SNI apps publish an *app* IconName that the icon set renders in
|
|
# full colour (EasyEffects 8 → `com.github.wwmm.easyeffects`, Papirus'
|
|
# blue equaliser), so the icon clashes with Waybar's monochrome tray.
|
|
# We wrap the resolved set in a thin child theme that `Inherits` it and
|
|
# ships palette-coloured monochrome overrides for the offenders, then
|
|
# make that child the session icon theme: every *other* icon still
|
|
# resolves from the parent unchanged (verified — udiskie, nm-applet and
|
|
# file-manager icons all fall through). Theme-following: the fill is the
|
|
# palette `text` colour — the same hue as the bar's own glyphs — so it
|
|
# is regenerated on every switch and works under any theme/mode. A
|
|
# scalable override out-resolves the parent's fixed-size icon at every
|
|
# requested size. Adding another offender = one more SVG in scalable/apps.
|
|
overrideIconTheme = "Nomarchy-icons";
|
|
easyeffectsGlyph = pkgs.writeText "com.github.wwmm.easyeffects.svg" ''
|
|
<?xml version="1.0"?>
|
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 16 16">
|
|
<g style="fill:${checked.colors.text};fill-opacity:1;stroke:none">
|
|
<rect x="7.5" y="2" width="1" height="12" ry=".5"/>
|
|
<rect x="12" y="4" width="1" height="8" ry=".5"/>
|
|
<rect x="3" y="4" width="1" height="8" ry=".5"/>
|
|
<circle cx="8" cy="5" r="1.5"/>
|
|
<circle cx="12.5" cy="9" r="1.5"/>
|
|
<circle cx="3.5" cy="7.5" r="1.5"/>
|
|
</g>
|
|
</svg>
|
|
'';
|
|
overrideIndex = pkgs.writeText "index.theme" ''
|
|
[Icon Theme]
|
|
Name=${overrideIconTheme}
|
|
Comment=Nomarchy tray-icon overrides
|
|
Inherits=${iconTheme},hicolor
|
|
Directories=scalable/apps
|
|
|
|
[scalable/apps]
|
|
Context=Applications
|
|
Size=48
|
|
MinSize=8
|
|
MaxSize=512
|
|
Type=Scalable
|
|
'';
|
|
overrideIconPkg = pkgs.runCommand "nomarchy-tray-icons" { } ''
|
|
apps="$out/share/icons/${overrideIconTheme}/scalable/apps"
|
|
mkdir -p "$apps"
|
|
cp ${overrideIndex} "$out/share/icons/${overrideIconTheme}/index.theme"
|
|
cp ${easyeffectsGlyph} "$apps/com.github.wwmm.easyeffects.svg"
|
|
'';
|
|
# Child + parent joined so the session profile carries both; the child's
|
|
# index Inherits the parent by name, so lookups start in the child and
|
|
# fall through. This becomes the exposed iconTheme/-Package below.
|
|
sessionIconThemePackage = pkgs.symlinkJoin {
|
|
name = "nomarchy-session-icons";
|
|
paths = [ overrideIconPkg iconThemePackage ];
|
|
};
|
|
in
|
|
{
|
|
config = {
|
|
# Expose the override child as the session icon theme (it Inherits the
|
|
# resolved parent, so downstream consumers — stylix GTK, rofi — behave
|
|
# identically save for the tray overrides). `iconTheme` here is the
|
|
# child name; the parent name lives on in its index's Inherits.
|
|
nomarchy.theme = checked // {
|
|
border = border;
|
|
iconTheme = overrideIconTheme;
|
|
iconThemePackage = sessionIconThemePackage;
|
|
};
|
|
|
|
# Feature toggles the menu writes (settings.nightlight.enable, …), exposed
|
|
# alongside the appearance state. Feature modules mkDefault-read from here
|
|
# so a menu toggle lands in the flake instead of in ~/.local/state.
|
|
nomarchy.settings = checked.settings;
|
|
|
|
nomarchy.lib = {
|
|
# "#7aa2f7" -> "rgb(7aa2f7)" (Hyprland color syntax)
|
|
rgb = c: "rgb(${lib.removePrefix "#" c})";
|
|
# "#16161e" -> "rgba(16161eaa)" (Hyprland color + hex alpha)
|
|
rgba = c: alpha: "rgba(${lib.removePrefix "#" c}${alpha})";
|
|
};
|
|
|
|
home.packages = [ cfg.package ];
|
|
|
|
# After a switch the new theme's wallpaper should show without
|
|
# logging out. Best-effort: outside a graphical session swww isn't
|
|
# running and this is a no-op.
|
|
home.activation.nomarchyWallpaper = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
|
run ${lib.getExe cfg.package} --quiet wallpaper || true
|
|
'';
|
|
};
|
|
}
|