feat: Nomarchy ground-up rewrite on NixOS 26.05
Full replacement of the previous iteration, rebuilt around three ideas:
- Pure evaluation: theme-state.json lives inside the flake and is read
via the nomarchy.stateFile option — no --impure, ever.
- All-Home-Manager theming: `nomarchy-theme-sync apply` writes the JSON
and runs `home-manager switch`; every theme change is one atomic,
rollbackable generation. Wallpaper (swww) is the sole runtime piece.
- Flat, downstream-first layout: modules/{nixos,home} with one
options.nix each, exported as nixosModules/homeModules + overlay +
flake template; system (nixos-rebuild) and desktop (home-manager
switch) rebuild paths are fully split.
Ships 21 themes imported from the previous iteration (palettes,
wallpapers, btop themes, six whole-swap Waybar identities), Stylix for
the GTK/Qt/cursor long tail, a live ISO target with offline theme
switching, and docs/TESTING.md with the QEMU verification workflow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
49
pkgs/nomarchy-theme-sync/default.nix
Normal file
49
pkgs/nomarchy-theme-sync/default.nix
Normal file
@@ -0,0 +1,49 @@
|
||||
{ lib
|
||||
, stdenvNoCC
|
||||
, python3
|
||||
, makeWrapper
|
||||
, swww
|
||||
, libnotify
|
||||
, git
|
||||
# Shipped theme presets, baked into the package as a fallback so
|
||||
# `list`/`apply` work even when $NOMARCHY_PATH has no themes/ dir.
|
||||
, themesDir ? null
|
||||
}:
|
||||
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "nomarchy-theme-sync";
|
||||
version = "0.4.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
buildInputs = [ python3 ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
install -Dm755 nomarchy-theme-sync.py $out/bin/nomarchy-theme-sync
|
||||
patchShebangs $out/bin/nomarchy-theme-sync
|
||||
|
||||
${lib.optionalString (themesDir != null) ''
|
||||
mkdir -p $out/share/nomarchy
|
||||
cp -r ${themesDir} $out/share/nomarchy/themes
|
||||
''}
|
||||
|
||||
# Stdlib-only Python. home-manager is deliberately NOT wrapped in —
|
||||
# the rebuild must use the user's own home-manager from their PATH.
|
||||
wrapProgram $out/bin/nomarchy-theme-sync \
|
||||
--prefix PATH : ${lib.makeBinPath [ swww libnotify git ]} \
|
||||
${lib.optionalString (themesDir != null)
|
||||
"--set NOMARCHY_DEFAULT_THEMES $out/share/nomarchy/themes"}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Nomarchy theming: JSON state writer + Home Manager rebuild dispatcher";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "nomarchy-theme-sync";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
327
pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py
Normal file
327
pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py
Normal file
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""nomarchy-theme-sync — state writer for Nomarchy's declarative theming.
|
||||
|
||||
Single source of truth: $NOMARCHY_PATH/theme-state.json (inside the flake,
|
||||
read purely by Home Manager via the nomarchy.stateFile option).
|
||||
|
||||
Theme changes are applied by Home Manager: this tool writes the new state
|
||||
and runs `home-manager switch` (override with $NOMARCHY_REBUILD, or skip
|
||||
with --no-switch). All app theming — Hyprland, Waybar, Ghostty, btop,
|
||||
Stylix — is baked into the generation; nothing is patched at runtime.
|
||||
|
||||
The one runtime exception is the wallpaper: swww is imperative by nature,
|
||||
so `bg next` cycles instantly and `wallpaper` (re-)applies the current one
|
||||
at session start and after a switch.
|
||||
|
||||
Commands:
|
||||
list list theme presets
|
||||
apply <name|file.json> merge a preset into the state + rebuild
|
||||
set <dotted.path> <value> tweak one key (e.g. `set ui.gapsOut 16`) + rebuild
|
||||
get [dotted.path] print the current state (or one key)
|
||||
wallpaper apply the current wallpaper via swww
|
||||
bg [next|auto] cycle the theme's wallpapers (instant, no rebuild)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# ─── Paths ────────────────────────────────────────────────────────────────
|
||||
|
||||
FLAKE_DIR = Path(os.environ.get("NOMARCHY_PATH", Path.home() / ".nomarchy")).expanduser()
|
||||
STATE_FILE = FLAKE_DIR / "theme-state.json"
|
||||
|
||||
# Preset search path: the user's flake first (their custom themes win),
|
||||
# then the presets baked into this package by the Nix build.
|
||||
THEMES_DIRS = [FLAKE_DIR / "themes"]
|
||||
if os.environ.get("NOMARCHY_DEFAULT_THEMES"):
|
||||
THEMES_DIRS.append(Path(os.environ["NOMARCHY_DEFAULT_THEMES"]))
|
||||
|
||||
WALLPAPER_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
|
||||
QUIET = False
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
if not QUIET:
|
||||
print(f"nomarchy-theme-sync: {msg}")
|
||||
|
||||
|
||||
def die(msg: str) -> "None":
|
||||
print(f"nomarchy-theme-sync: error: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def notify(body: str) -> None:
|
||||
if shutil.which("notify-send"):
|
||||
subprocess.run(["notify-send", "-a", "Nomarchy", "Nomarchy", body],
|
||||
capture_output=True)
|
||||
|
||||
|
||||
# ─── State management ─────────────────────────────────────────────────────
|
||||
|
||||
def load_state(path: Path = STATE_FILE) -> dict:
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except FileNotFoundError:
|
||||
die(f"state file not found: {path} (set $NOMARCHY_PATH to your flake checkout)")
|
||||
except json.JSONDecodeError as e:
|
||||
die(f"invalid JSON in {path}: {e}")
|
||||
|
||||
|
||||
def write_state(state: dict) -> None:
|
||||
"""Atomic write: render to a temp file in the same dir, then rename."""
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=STATE_FILE.parent, prefix=".theme-state.", suffix=".json")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
f.write("\n")
|
||||
os.replace(tmp, STATE_FILE)
|
||||
except BaseException:
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
log(f"state written to {STATE_FILE}")
|
||||
|
||||
# Flakes only see git-tracked files. theme-state.json ships tracked,
|
||||
# but make sure a fresh checkout / reset can't silently hide it.
|
||||
if (FLAKE_DIR / ".git").exists() and shutil.which("git"):
|
||||
subprocess.run(
|
||||
["git", "-C", str(FLAKE_DIR), "add", "--intent-to-add", "theme-state.json"],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def deep_merge(base: dict, override: dict) -> dict:
|
||||
out = dict(base)
|
||||
for k, v in override.items():
|
||||
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
||||
out[k] = deep_merge(out[k], v)
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
# ─── Rebuild dispatch ─────────────────────────────────────────────────────
|
||||
|
||||
def run_switch() -> None:
|
||||
"""Bake the new state into a Home Manager generation."""
|
||||
override = os.environ.get("NOMARCHY_REBUILD")
|
||||
argv = shlex.split(override) if override else \
|
||||
["home-manager", "switch", "--flake", str(FLAKE_DIR)]
|
||||
|
||||
if shutil.which(argv[0]) is None:
|
||||
log(f"'{argv[0]}' not found — state written; rebuild manually to apply")
|
||||
return
|
||||
|
||||
log(f"rebuilding: {' '.join(argv)}")
|
||||
notify("Applying theme — rebuilding the desktop…")
|
||||
result = subprocess.run(argv) # stream output to the caller's terminal
|
||||
if result.returncode != 0:
|
||||
notify("Theme rebuild FAILED — see terminal / journal")
|
||||
die("rebuild failed (state file already updated; fix and re-run)")
|
||||
notify("Theme applied ✓")
|
||||
|
||||
|
||||
# ─── Theme assets (wallpapers) ────────────────────────────────────────────
|
||||
# Convention: a preset themes/<slug>.json may have a sibling directory
|
||||
# themes/<slug>/ with assets: backgrounds/ (wallpapers), btop.theme,
|
||||
# waybar.css / waybar.jsonc. All except backgrounds/ are consumed by the
|
||||
# Nix modules at eval time; wallpapers are applied here via swww.
|
||||
|
||||
def find_asset(slug: str, name: str):
|
||||
for themes_dir in THEMES_DIRS:
|
||||
candidate = themes_dir / slug / name
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def backgrounds_for(slug: str) -> list:
|
||||
bg_dir = find_asset(slug, "backgrounds")
|
||||
if bg_dir is None or not bg_dir.is_dir():
|
||||
return []
|
||||
return sorted(p for p in bg_dir.iterdir() if p.suffix.lower() in WALLPAPER_EXTS)
|
||||
|
||||
|
||||
def resolve_wallpaper(state: dict):
|
||||
"""Explicit path in the state wins; empty means 'first theme background'."""
|
||||
explicit = state.get("wallpaper", "")
|
||||
if explicit:
|
||||
path = Path(explicit).expanduser()
|
||||
if path.is_file():
|
||||
return path
|
||||
log(f"wallpaper not found, using theme default: {explicit}")
|
||||
backgrounds = backgrounds_for(state.get("slug", ""))
|
||||
return backgrounds[0] if backgrounds else None
|
||||
|
||||
|
||||
def apply_wallpaper(state: dict, wait: bool = False) -> None:
|
||||
wallpaper = resolve_wallpaper(state)
|
||||
if wallpaper is None:
|
||||
log(f"no wallpaper for theme '{state.get('slug', '?')}', skipping")
|
||||
return
|
||||
if shutil.which("swww") is None:
|
||||
log("swww not found, skipping wallpaper")
|
||||
return
|
||||
# At session start swww-daemon may still be coming up.
|
||||
for _ in range(10 if wait else 1):
|
||||
if subprocess.run(["swww", "query"], capture_output=True).returncode == 0:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
result = subprocess.run(
|
||||
["swww", "img", str(wallpaper),
|
||||
"--transition-type", "grow",
|
||||
"--transition-pos", "center",
|
||||
"--transition-duration", "1",
|
||||
"--transition-fps", "60"],
|
||||
capture_output=True,
|
||||
)
|
||||
log(f"wallpaper: {wallpaper.name}" if result.returncode == 0
|
||||
else "wallpaper: swww failed (daemon not running?)")
|
||||
|
||||
|
||||
# ─── Commands ─────────────────────────────────────────────────────────────
|
||||
|
||||
def find_preset(name: str):
|
||||
for themes_dir in THEMES_DIRS:
|
||||
candidate = themes_dir / f"{name}.json"
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def cmd_list(_args) -> None:
|
||||
names = sorted({f.stem for d in THEMES_DIRS if d.is_dir() for f in d.glob("*.json")})
|
||||
if not names:
|
||||
die(f"no theme presets found (searched: {', '.join(map(str, THEMES_DIRS))})")
|
||||
print("\n".join(names))
|
||||
|
||||
|
||||
def cmd_apply(args) -> None:
|
||||
candidate = Path(args.theme).expanduser()
|
||||
preset_path = candidate if candidate.is_file() else find_preset(args.theme)
|
||||
if preset_path is None:
|
||||
die(f"unknown theme '{args.theme}' (try `nomarchy-theme-sync list`)")
|
||||
|
||||
preset = json.loads(preset_path.read_text())
|
||||
# Merge over current state: presets define palette/name/wallpaper,
|
||||
# user tweaks (gaps, fonts) outside the preset survive.
|
||||
state = deep_merge(load_state(), preset)
|
||||
write_state(state)
|
||||
log(f"theme: {state.get('name', args.theme)}")
|
||||
if not args.no_switch:
|
||||
run_switch()
|
||||
apply_wallpaper(state)
|
||||
|
||||
|
||||
def cmd_set(args) -> None:
|
||||
state = load_state()
|
||||
try:
|
||||
value = json.loads(args.value) # numbers, bools, null, quoted strings
|
||||
except json.JSONDecodeError:
|
||||
value = args.value # bare string ("#7aa2f7", font names, paths)
|
||||
|
||||
node = state
|
||||
keys = args.path.split(".")
|
||||
for key in keys[:-1]:
|
||||
node = node.setdefault(key, {})
|
||||
if not isinstance(node, dict):
|
||||
die(f"cannot descend into non-object at '{key}' in '{args.path}'")
|
||||
node[keys[-1]] = value
|
||||
|
||||
write_state(state)
|
||||
log(f"set {args.path} = {value!r}")
|
||||
if not args.no_switch:
|
||||
run_switch()
|
||||
apply_wallpaper(state)
|
||||
|
||||
|
||||
def cmd_get(args) -> None:
|
||||
state = load_state()
|
||||
if args.path:
|
||||
node = state
|
||||
for key in args.path.split("."):
|
||||
try:
|
||||
node = node[key]
|
||||
except (KeyError, TypeError):
|
||||
die(f"no such key: {args.path}")
|
||||
print(json.dumps(node, indent=2) if isinstance(node, (dict, list)) else node)
|
||||
else:
|
||||
print(json.dumps(state, indent=2))
|
||||
|
||||
|
||||
def cmd_wallpaper(_args) -> None:
|
||||
"""Apply the current wallpaper (session start, post-switch hook)."""
|
||||
apply_wallpaper(load_state(), wait=True)
|
||||
|
||||
|
||||
def cmd_bg(args) -> None:
|
||||
"""Cycle the theme's wallpapers — instant, no rebuild needed (the
|
||||
wallpaper is runtime state for swww; nothing in Nix consumes it)."""
|
||||
state = load_state()
|
||||
if args.action == "auto":
|
||||
state["wallpaper"] = ""
|
||||
else: # next
|
||||
backgrounds = backgrounds_for(state.get("slug", ""))
|
||||
if not backgrounds:
|
||||
die(f"theme '{state.get('slug', '?')}' has no backgrounds/ directory")
|
||||
current = resolve_wallpaper(state)
|
||||
idx = (backgrounds.index(current) + 1) % len(backgrounds) if current in backgrounds else 0
|
||||
state["wallpaper"] = str(backgrounds[idx])
|
||||
write_state(state)
|
||||
apply_wallpaper(state)
|
||||
|
||||
|
||||
# ─── Entry point ──────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
global QUIET
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="nomarchy-theme-sync",
|
||||
description="Nomarchy theming — state writer + Home Manager rebuild dispatcher.",
|
||||
)
|
||||
parser.add_argument("--quiet", action="store_true", help="suppress progress output")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("list", help="list theme presets").set_defaults(func=cmd_list)
|
||||
|
||||
p = sub.add_parser("apply", help="apply a theme preset (name or JSON file) + rebuild")
|
||||
p.add_argument("theme")
|
||||
p.add_argument("--no-switch", action="store_true", help="write state only, skip the rebuild")
|
||||
p.set_defaults(func=cmd_apply)
|
||||
|
||||
p = sub.add_parser("set", help="set one key, e.g. `set ui.gapsOut 16` + rebuild")
|
||||
p.add_argument("path")
|
||||
p.add_argument("value")
|
||||
p.add_argument("--no-switch", action="store_true", help="write state only, skip the rebuild")
|
||||
p.set_defaults(func=cmd_set)
|
||||
|
||||
p = sub.add_parser("get", help="print state (or one dotted key)")
|
||||
p.add_argument("path", nargs="?")
|
||||
p.set_defaults(func=cmd_get)
|
||||
|
||||
sub.add_parser("wallpaper", help="apply the current wallpaper via swww").set_defaults(func=cmd_wallpaper)
|
||||
|
||||
p = sub.add_parser("bg", help="wallpaper control: `bg next` cycles, `bg auto` resets")
|
||||
p.add_argument("action", choices=["next", "auto"], default="next", nargs="?")
|
||||
p.set_defaults(func=cmd_bg)
|
||||
|
||||
args = parser.parse_args()
|
||||
QUIET = args.quiet
|
||||
try:
|
||||
args.func(args)
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user