# Nomarchy theming engine. # # nomarchy.stateFile (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-state-sync # apply ` writes the JSON and runs `home-manager switch`, baking # everything (Hyprland, Waybar, Kitty, 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 # after output hotplug (hyprland.nix); cycled instantly with # `nomarchy-state-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-state-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 ../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-state-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 state.json is invalid: ${lib.concatMapStrings (p: " ✖ ${p}\n") problems} Fix the named field(s) in the state.json of your flake checkout (usually ~/.nomarchy/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-state-sync validate`. Re-applying any preset resets all appearance fields: `nomarchy-state-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-state-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 = ""` 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" '' ''; 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 ''; }; }