Some checks failed
Check / eval (push) Has been cancelled
Ghostty's OpenGL 4.3 floor broke the Acer (HD 4000 / 4.2). Dual-terminal support (default Ghostty + install-time Kitty fallback) was more complexity than it was worth. - Remove ghostty.nix / nomarchy.ghostty.enable - Kitty always installed and themed from theme-state - Default nomarchy.terminal = kitty; SUPER+Return, doctor, calendar, what-changed use kitty --class=com.nomarchy.* - Drop install-time glxinfo probe and mesa-demos on the installer - Docs/REQUIREMENTS no longer list OpenGL 4.3 as a terminal floor Verified: V0 flake check; option-docs; installer-safety; template HM has kitty, no ghostty; menu/calendar emit classed kitty launches.
294 lines
14 KiB
Nix
294 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, 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-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
|
|
'';
|
|
};
|
|
}
|