Compare commits
131 Commits
v1
...
b1cf10ff06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1cf10ff06 | ||
| 60c6f14c08 | |||
| a8391c381d | |||
| 28a28e05d3 | |||
| 239c3c4551 | |||
| 92b3c1a1e3 | |||
| 8961fd6936 | |||
| 1928cd94f6 | |||
| 21c0c58ea2 | |||
| 926be22fd4 | |||
| cd5d3b51e2 | |||
| 6c61b51d55 | |||
| 28cbaf6f5e | |||
| d078ba2a82 | |||
| ce89fddab6 | |||
| cfecb612a6 | |||
| be5888d354 | |||
| f848e7390f | |||
| 907d3123ea | |||
| ddf9b186e4 | |||
|
|
175b877f95 | ||
|
|
7b599c5786 | ||
|
|
f924d92f1b | ||
|
|
f2c815ddbd | ||
|
|
16275947ec | ||
| 7f10a12ec8 | |||
| 825b7e25a4 | |||
| 801ffa4e24 | |||
| ccd896c8dc | |||
| 51e319d357 | |||
| 7569b678db | |||
| 28e21af206 | |||
| eecc214ca9 | |||
| 93521c8617 | |||
| 3f15f6451f | |||
|
|
8fded63b10 | ||
|
|
edd0bd38ce | ||
|
|
096440a7d7 | ||
|
|
7d5f091c29 | ||
|
|
90a5104f94 | ||
|
|
39cfe0fb12 | ||
|
|
01ee847490 | ||
|
|
808a3febdd | ||
|
|
4ad564bd80 | ||
|
|
3e49481d46 | ||
|
|
8d54eecd67 | ||
|
|
4e3acbe89c | ||
|
|
8f2e047f4a | ||
|
|
8874a22a37 | ||
|
|
310614bdd2 | ||
|
|
fb75c3dedf | ||
|
|
44d5516191 | ||
|
|
47c8b6f997 | ||
|
|
abc953ea84 | ||
|
|
d1d6a09d9d | ||
|
|
a360bc87ca | ||
|
|
1921839e0e | ||
|
|
9df18261f9 | ||
|
|
e02b4d8200 | ||
|
|
5eadf0cff6 | ||
|
|
26e393b65d | ||
|
|
fb78c814cc | ||
|
|
821032e81c | ||
|
|
b7b51e9354 | ||
|
|
0fe46221ea | ||
|
|
bfb80cb60d | ||
|
|
352c681f48 | ||
|
|
d2ac131b75 | ||
|
|
2a23e82169 | ||
|
|
1b8eccbdca | ||
|
|
3f5e414341 | ||
|
|
774bdad6e4 | ||
|
|
e05e3647e6 | ||
|
|
472d7502b4 | ||
|
|
b0b8a9a09b | ||
|
|
56f1cc3fa9 | ||
|
|
9cd6f5e30c | ||
|
|
a83edb0d36 | ||
|
|
fd6e5f60e9 | ||
|
|
a643391d3d | ||
|
|
63136a8cb1 | ||
|
|
0d80ab272f | ||
|
|
397dd5991a | ||
|
|
274ffc25e1 | ||
|
|
0e6c678835 | ||
|
|
18b854563b | ||
|
|
f70838c5b5 | ||
|
|
1e4427f6af | ||
|
|
a47aa3aff5 | ||
|
|
baab2d3b88 | ||
|
|
5ea4f0c9ac | ||
|
|
938753273d | ||
|
|
46af2f0632 | ||
|
|
bc4e8e1410 | ||
|
|
4c2ad38656 | ||
|
|
6d70bba8e6 | ||
|
|
9726ba3b2f | ||
|
|
4024da791f | ||
|
|
aac678335c | ||
|
|
7d6d74fd7f | ||
|
|
cdd1897b14 | ||
|
|
9976ea06f5 | ||
|
|
cdfe92a089 | ||
|
|
431af618cc | ||
|
|
47526ae6e2 | ||
|
|
86802f244e | ||
|
|
f3325385c1 | ||
|
|
aed41793f8 | ||
|
|
685126ab47 | ||
|
|
c8d0b09044 | ||
|
|
c2f90c7d0a | ||
|
|
5747dc9839 | ||
|
|
d1344712b8 | ||
|
|
37204f5f45 | ||
| 97b5944dc1 | |||
| 0c483f9512 | |||
| dfb57c2e34 | |||
| 995810927d | |||
| e1cf190dd2 | |||
|
|
70334e68bb | ||
|
|
6a4af69b0f | ||
|
|
d9466d6555 | ||
|
|
0e42763aea | ||
|
|
a6d6860054 | ||
|
|
5c43a93285 | ||
|
|
09c308b93c | ||
|
|
bdf20f2d8e | ||
|
|
c57d26864e | ||
|
|
019fdfc8bb | ||
|
|
c2281dbc61 | ||
|
|
b4fe52261b |
90
.gitea/workflows/bump.yml
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Nomarchy scheduled lock bump — the automated half of "rock-stable
|
||||||
|
# without rotting": once a week, update flake.lock (inputs track pinned
|
||||||
|
# release branches, so this never jumps a NixOS release — that's a
|
||||||
|
# deliberate hand-edited v2, see agent/GOALS.md), gate it, and land it
|
||||||
|
# on main only on green. `v1` promotion stays human, always.
|
||||||
|
#
|
||||||
|
# Fast lane: workflow_dispatch. For a security fix upstream, run this
|
||||||
|
# workflow manually from the Actions tab instead of waiting for Monday.
|
||||||
|
#
|
||||||
|
# Gate scope: `nix flake check --no-build` — the same eval tier as
|
||||||
|
# check.yml (this runner has no KVM; see item 20 for the VM-suite
|
||||||
|
# upgrade path). The py_compile/bash -n steps are skipped on purpose:
|
||||||
|
# they don't read flake.lock, so a lock bump cannot break them. The
|
||||||
|
# bump commit's own push then triggers check.yml as a second net.
|
||||||
|
# A green bump therefore guards evaluation, not VM behaviour — the
|
||||||
|
# checks.* suite still runs locally / at promotion time.
|
||||||
|
#
|
||||||
|
# Failure mode: a red gate fails this run visibly and pushes nothing;
|
||||||
|
# next schedule retries. A push race (someone landed on main mid-run)
|
||||||
|
# also just fails the final push — rerun or wait a week.
|
||||||
|
#
|
||||||
|
# Container recipe (nixbld users, sandbox=false, pinned Nix, plain-shell
|
||||||
|
# install) inherited from check.yml — the gotchas are documented there.
|
||||||
|
|
||||||
|
name: Lock bump
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '17 5 * * 1' # Mondays 05:17 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
env:
|
||||||
|
NIX_CONFIG: |
|
||||||
|
experimental-features = nix-command flakes
|
||||||
|
sandbox = false
|
||||||
|
NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
run: |
|
||||||
|
NIX_VERSION=2.31.5
|
||||||
|
groupadd -r nixbld 2>/dev/null || true
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
useradd -r -g nixbld -G nixbld -d /var/empty \
|
||||||
|
-s /usr/sbin/nologin -c "Nix build user $i" "nixbld$i" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
curl -L "https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" | sh -s -- --no-daemon
|
||||||
|
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Update flake.lock
|
||||||
|
# Inputs track release branches (nixos-26.05, release-26.05), so
|
||||||
|
# the update stays within them by construction. The human-readable
|
||||||
|
# change list goes into the commit body below.
|
||||||
|
run: |
|
||||||
|
nix flake update 2>&1 | tee /tmp/bump-log
|
||||||
|
if git diff --quiet flake.lock; then
|
||||||
|
echo "Lock already up to date — nothing to do."
|
||||||
|
echo "changed=0" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "changed=1" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
id: update
|
||||||
|
|
||||||
|
- name: Gate — nix flake check (eval only)
|
||||||
|
if: steps.update.outputs.changed == '1'
|
||||||
|
run: nix flake check --no-build
|
||||||
|
|
||||||
|
- name: Commit + push on green
|
||||||
|
if: steps.update.outputs.changed == '1'
|
||||||
|
# The checkout step persists the Actions token, so this push
|
||||||
|
# authenticates as the workflow; its push triggers check.yml as
|
||||||
|
# the second net. Only flake.lock is committed — pathspec-limited,
|
||||||
|
# nothing else can ride along.
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "chore(lock): scheduled upstream bump"
|
||||||
|
echo
|
||||||
|
grep -E '^(• | )' /tmp/bump-log || true
|
||||||
|
echo
|
||||||
|
echo "Gate: nix flake check --no-build (eval tier — see bump.yml header)."
|
||||||
|
} > /tmp/bump-msg
|
||||||
|
git -c user.name="nomarchy-bump" -c user.email="actions@git.bemagri.xyz" \
|
||||||
|
commit --only flake.lock -F /tmp/bump-msg
|
||||||
|
git push origin HEAD:main
|
||||||
101
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Nomarchy CI — the always-on net under direct-to-main pushes.
|
||||||
|
#
|
||||||
|
# Scope (deliberate): the EVAL tier only. The runner behind this Gitea
|
||||||
|
# instance is act_runner + docker containers (the legacy repo's check.yml
|
||||||
|
# ran 57 times on it) — no systemd, no /dev/kvm — so the checks.* VM
|
||||||
|
# tests and full toplevel builds can't run here. `nix flake check
|
||||||
|
# --no-build` still catches most breakage (type errors, missing options,
|
||||||
|
# bad merges, template/mkFlake drift — see docs/TESTING.md §1); the VM
|
||||||
|
# suite stays a local/promotion gate until a KVM-capable NixOS runner
|
||||||
|
# exists (see the commented vm-checks job at the bottom).
|
||||||
|
#
|
||||||
|
# Inherited-from-legacy gotchas (learned over 57 runs, kept verbatim):
|
||||||
|
# - Single-user Nix (--no-daemon): no systemd in the container. The
|
||||||
|
# installer runs as root and honours build-users-group=nixbld from
|
||||||
|
# its bundled nix.conf, aborting unless the group exists AND has
|
||||||
|
# members — create nixbld + users first.
|
||||||
|
# - sandbox=false: Stylix/base16.nix do import-from-derivation; the
|
||||||
|
# single-user Nix in this container can't set up the build sandbox
|
||||||
|
# (no user namespaces), which otherwise surfaces as
|
||||||
|
# "path '…-source' is not valid".
|
||||||
|
# - Pin the Nix version: 2.34's lazy-trees git cache doesn't
|
||||||
|
# materialise flake-input `-source` paths into the store, breaking
|
||||||
|
# the same IFD reads. 2.31.5 matches what wrote flake.lock.
|
||||||
|
# - Plain-shell Nix install, not a JS action: act_runner's bundled act
|
||||||
|
# tops out at node20; a `run:` step has no node-runtime coupling.
|
||||||
|
|
||||||
|
name: Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, v1]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
eval:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
NIX_CONFIG: |
|
||||||
|
experimental-features = nix-command flakes
|
||||||
|
sandbox = false
|
||||||
|
NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
run: |
|
||||||
|
NIX_VERSION=2.31.5
|
||||||
|
groupadd -r nixbld 2>/dev/null || true
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
useradd -r -g nixbld -G nixbld -d /var/empty \
|
||||||
|
-s /usr/sbin/nologin -c "Nix build user $i" "nixbld$i" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
curl -L "https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" | sh -s -- --no-daemon
|
||||||
|
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: nix flake check (eval only)
|
||||||
|
# Full module-system evaluation of every output — both nixos
|
||||||
|
# configs, the home config, the checks.* derivations (instantiated,
|
||||||
|
# not run) and the downstream template through lib.mkFlake.
|
||||||
|
run: nix flake check --no-build
|
||||||
|
|
||||||
|
- name: Python syntax (nomarchy-theme-sync)
|
||||||
|
# Via nix shell so the step doesn't depend on the runner image
|
||||||
|
# preinstalling python3.
|
||||||
|
run: |
|
||||||
|
nix shell nixpkgs#python3 --command \
|
||||||
|
python3 -m py_compile pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py
|
||||||
|
|
||||||
|
- name: Shell syntax (tracked scripts)
|
||||||
|
# The distro's user-facing scripts are generated by Nix (their
|
||||||
|
# syntax is exercised by the eval + local builds); this covers the
|
||||||
|
# hand-written .sh files: installer helpers and maintainer tools.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
fail=0
|
||||||
|
while IFS= read -r script; do
|
||||||
|
head -1 "$script" | grep -qE '^#!.*\b(bash|sh)\b' || continue
|
||||||
|
if ! bash -n "$script"; then
|
||||||
|
echo "::error file=$script::bash syntax error"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done < <(git ls-files '*.sh')
|
||||||
|
exit "$fail"
|
||||||
|
|
||||||
|
# ── vm-checks (DISABLED until a KVM runner exists) ────────────────────
|
||||||
|
# The real prize: running the checks.* VM suite + a toplevel build in
|
||||||
|
# CI. Needs a runner on a NixOS (or at least nix + /dev/kvm) host —
|
||||||
|
# register one with a dedicated label, then uncomment and set runs-on
|
||||||
|
# to that label. Do NOT enable this against a label that doesn't
|
||||||
|
# exist: Gitea queues such jobs forever instead of failing.
|
||||||
|
#
|
||||||
|
# vm-checks:
|
||||||
|
# runs-on: nix-kvm
|
||||||
|
# timeout-minutes: 120
|
||||||
|
# steps:
|
||||||
|
# - uses: actions/checkout@v4
|
||||||
|
# - run: nix flake check # builds + runs the VM tests
|
||||||
|
# - run: nix build .#nixosConfigurations.nomarchy.config.system.build.toplevel --no-link
|
||||||
|
# - run: nix build .#homeConfigurations.nomarchy.activationPackage --no-link
|
||||||
1
.gitignore
vendored
@@ -11,3 +11,4 @@ __pycache__/
|
|||||||
|
|
||||||
# Claude Code machine-local settings (permissions etc.)
|
# Claude Code machine-local settings (permissions etc.)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
.claude/skills/
|
||||||
|
|||||||
42
CLAUDE.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Nomarchy — agent entry point
|
||||||
|
|
||||||
|
Nomarchy is a NixOS-based distro: rock-stable, fully reproducible, themed
|
||||||
|
from one JSON, configured through a menu that writes into the user's own
|
||||||
|
flake checkout. Read the README for the architecture.
|
||||||
|
|
||||||
|
## If you're here to work autonomously (the loop)
|
||||||
|
Follow **`agent/LOOP.md`** — it defines one iteration: orient
|
||||||
|
(GOALS/BACKLOG/JOURNAL/MEMORY), take the top actionable BACKLOG item,
|
||||||
|
verify up the V0–V3 ladder, commit+push on `main`, record. All loop state
|
||||||
|
is git-tracked in `agent/`.
|
||||||
|
|
||||||
|
## Rules that apply to every session, loop or not
|
||||||
|
- **Honesty rule** (docs/TESTING.md): for visual/interactive changes,
|
||||||
|
evaluation is not rendering — state exactly what you verified and at
|
||||||
|
which tier. Cheap check first, always: `nix flake check --no-build`.
|
||||||
|
- **Conventions** (`agent/CONVENTIONS.md`): in-flake state, menu
|
||||||
|
placement, Waybar parity with the summer whole-swaps, toggle-vs-package
|
||||||
|
discipline, commented template examples for opt-ins.
|
||||||
|
- **Git:** direct commits on `main`, pushed; `v1` is the human-only
|
||||||
|
release pointer — never touch it. Never run `nix flake update` unless
|
||||||
|
the task is a lock bump. No formatter — match the aligned
|
||||||
|
hand-formatting.
|
||||||
|
- Machine-specifics live in `hosts/`, the distro in `modules/`, data in
|
||||||
|
`themes/`, code in `pkgs/`, maintainer tools in `tools/`.
|
||||||
|
|
||||||
|
## Delegation
|
||||||
|
|
||||||
|
The higher your tier, the more you delegate. Push the work down, keep your own context for judgment. Brief every child: the context, the why, what done looks like. It starts blank and inherits nothing.
|
||||||
|
|
||||||
|
| Model | Best for | Delegate? | Effort |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Haiku | bulk mechanical | never | low |
|
||||||
|
| Sonnet | scoped research | when it helps | medium |
|
||||||
|
| Opus 4.8 | multi-step reasoning | on clear benefit | xhigh |
|
||||||
|
| Fable 5 | judgment, taste | by default | medium |
|
||||||
|
|
||||||
|
Fable goes xhigh only for the hardest calls. Skip high.
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
The parent doesn't have to be the top model. An Opus parent spawns a Fable child for the one hard call. The child answers and returns. Work above your tier? Return it, don't burn tokens on it.
|
||||||
98
README.md
@@ -76,7 +76,10 @@ Flat on purpose. Two module trees, one options file each, no hidden layers.
|
|||||||
├── templates/downstream/ # `nix flake init -t` starter for users
|
├── templates/downstream/ # `nix flake init -t` starter for users
|
||||||
├── docs/TESTING.md # how to verify changes (incl. AI-agent rules)
|
├── docs/TESTING.md # how to verify changes (incl. AI-agent rules)
|
||||||
├── docs/OVERRIDES.md # how downstream users override defaults
|
├── docs/OVERRIDES.md # how downstream users override defaults
|
||||||
├── docs/ROADMAP.md # forward-looking plans + shipped-fixes log
|
├── docs/RECOVERY.md # runbook: broken theme/desktop/boot → undo it
|
||||||
|
├── docs/ROADMAP.md # design/decision records + shipped-fixes log
|
||||||
|
├── agent/ # autonomous-agent loop: protocol (LOOP.md),
|
||||||
|
│ # prioritized BACKLOG, journal, memory
|
||||||
└── tools/ # maintainer-only
|
└── tools/ # maintainer-only
|
||||||
├── import-palettes.py # converts old-distro themes → JSON + assets
|
├── import-palettes.py # converts old-distro themes → JSON + assets
|
||||||
├── test-live-iso.sh # build the ISO + boot it in QEMU
|
├── test-live-iso.sh # build the ISO + boot it in QEMU
|
||||||
@@ -128,6 +131,12 @@ mkdir my-machine && cd my-machine
|
|||||||
nix flake init -t "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=v1"
|
nix flake init -t "git+https://git.bemagri.xyz/bernardo/nomarchy.git?ref=v1"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The template's own README then walks the first-run steps: a real
|
||||||
|
`hardware-configuration.nix`, `git init` (flakes only see tracked
|
||||||
|
files), the two first rebuilds, and landing the checkout at
|
||||||
|
`~/.nomarchy` so the theme CLI finds it. (The installer does all of
|
||||||
|
this for you.)
|
||||||
|
|
||||||
You own two files day-to-day: `system.nix` and `home.nix` (plus
|
You own two files day-to-day: `system.nix` and `home.nix` (plus
|
||||||
`theme-state.json`, written by the CLI). Your `flake.nix` is set up once —
|
`theme-state.json`, written by the CLI). Your `flake.nix` is set up once —
|
||||||
later by the installer — and never hand-edited; it's a single call:
|
later by the installer — and never hand-edited; it's a single call:
|
||||||
@@ -162,6 +171,8 @@ Day-to-day you'll use the shipped shortcuts instead:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
sys-update # nix flake update + system rebuild (BTRFS snapshot first when available)
|
sys-update # nix flake update + system rebuild (BTRFS snapshot first when available)
|
||||||
|
sys-rebuild # system rebuild against the CURRENT lock (config changes only, no update)
|
||||||
|
# …both end with a package-level diff of what the rebuild changed (nvd)
|
||||||
home-update # home-manager switch (no flake update, no sudo)
|
home-update # home-manager switch (no flake update, no sudo)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -182,6 +193,13 @@ behaviour (input/misc/monitor/chrome) is `mkDefault` so a plain `home.nix`
|
|||||||
assignment wins, and bind/exec-once lists concatenate. Full guide with
|
assignment wins, and bind/exec-once lists concatenate. Full guide with
|
||||||
examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
|
examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
|
||||||
|
|
||||||
|
**Where each option goes.** `nomarchy.system.*`, `nomarchy.hardware.*`, and
|
||||||
|
`nomarchy.services.*` are NixOS options — set them in `system.nix`. Everything
|
||||||
|
else under `nomarchy.*` is a Home Manager option — set it in `home.nix`. The
|
||||||
|
two tables below are split along exactly that line.
|
||||||
|
|
||||||
|
**`home.nix`** (Home Manager — the desktop):
|
||||||
|
|
||||||
| Option | Default | Purpose |
|
| Option | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `nomarchy.stateFile` | — (required) | Path to your theme-state.json |
|
| `nomarchy.stateFile` | — (required) | Path to your theme-state.json |
|
||||||
@@ -190,11 +208,13 @@ examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
|
|||||||
| `nomarchy.keyboard.variant` | `""` | XKB variant for the session |
|
| `nomarchy.keyboard.variant` | `""` | XKB variant for the session |
|
||||||
| `nomarchy.keyboard.devices` | `{}` | Per-device layout overrides (Hyprland `device` blocks keyed by `hyprctl devices` name) — e.g. an external keyboard with its own layout/variant |
|
| `nomarchy.keyboard.devices` | `{}` | Per-device layout overrides (Hyprland `device` blocks keyed by `hyprctl devices` name) — e.g. an external keyboard with its own layout/variant |
|
||||||
| `nomarchy.keyboard.layouts` | `[]` | Extra candidate layouts; when set, a watcher prompts (rofi) for a layout on a newly-connected keyboard and remembers it per-device |
|
| `nomarchy.keyboard.layouts` | `[]` | Extra candidate layouts; when set, a watcher prompts (rofi) for a layout on a newly-connected keyboard and remembers it per-device |
|
||||||
| `nomarchy.nightlight.enable` | `false` | Scheduled blue-light filter (hyprsunset) — warm at night (`.temperature`, default 4000K) between `.sunset`/`.sunrise`, no shift by day |
|
| `nomarchy.nightlight.enable` | `false` | Scheduled blue-light filter (hyprsunset) — warm at night (`.temperature`, default 4000K) between `.sunset`/`.sunrise`, no shift by day; off by default — enable it from the menu (System › Night light; the first enable rebuilds), then toggle on/off instantly from the menu or the Waybar moon indicator (writes `settings.nightlight.on` in your flake, no rebuild) and it survives reboot, so it stays reproducible |
|
||||||
|
| `nomarchy.updates.enable` | `false` | Passive update awareness: a background check (`.interval`, default daily) that flags when flake inputs (nixpkgs, the Nomarchy input, …) are behind upstream — and, with Flatpak on, when apps have updates (`.flatpak`) — via a Waybar indicator + notification. Never changes anything; click the indicator to run the upgrade flow |
|
||||||
| `nomarchy.hyprland.enable` | `true` | Nomarchy's Hyprland config |
|
| `nomarchy.hyprland.enable` | `true` | Nomarchy's Hyprland config |
|
||||||
| `nomarchy.waybar.enable` | `true` | Nomarchy's Waybar |
|
| `nomarchy.waybar.enable` | `true` | Nomarchy's Waybar |
|
||||||
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
|
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
|
||||||
| `nomarchy.swaync.enable` | `true` | swaync notifications, themed |
|
| `nomarchy.swaync.enable` | `true` | swaync notifications, themed |
|
||||||
|
| `nomarchy.batteryNotify.enable` | `true` | Low-battery toasts at the bar's thresholds — 25% low, 10% critical (stays up until dismissed); silent no-op on machines without a battery |
|
||||||
| `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 30) |
|
| `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 30) |
|
||||||
| `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins |
|
| `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins |
|
||||||
| `nomarchy.osd.enable` | `true` | swayosd on-screen display for volume/brightness/mute |
|
| `nomarchy.osd.enable` | `true` | swayosd on-screen display for volume/brightness/mute |
|
||||||
@@ -205,20 +225,41 @@ examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
|
|||||||
| `nomarchy.fastfetch.enable` | `true` | fastfetch fronted by the themed Nomarchy logo |
|
| `nomarchy.fastfetch.enable` | `true` | fastfetch fronted by the themed Nomarchy logo |
|
||||||
| `nomarchy.keys.enable` | `true` | gpg-agent fronting SSH + pinentry-qt |
|
| `nomarchy.keys.enable` | `true` | gpg-agent fronting SSH + pinentry-qt |
|
||||||
| `nomarchy.displays.enable` | `true` | nwg-displays interactive monitor arranger (helper for `nomarchy.monitors`) |
|
| `nomarchy.displays.enable` | `true` | nwg-displays interactive monitor arranger (helper for `nomarchy.monitors`) |
|
||||||
|
| `nomarchy.viewers.enable` | `true` | Document/image viewers: zathura (Stylix-themed PDF) + imv |
|
||||||
|
| `nomarchy.mime.enable` | `true` | Default "open with" associations (PDF/image/video/text/browser/directory); entries for apps you removed are skipped, so it degrades with the suite |
|
||||||
| `nomarchy.monitors` | `[]` | Declarative per-output layout → Hyprland `monitor` rules (applied on hotplug); `,preferred,auto,1` wildcard kept as fallback |
|
| `nomarchy.monitors` | `[]` | Declarative per-output layout → Hyprland `monitor` rules (applied on hotplug); `,preferred,auto,1` wildcard kept as fallback |
|
||||||
|
| `nomarchy.displayProfiles` | `{}` | Named layouts for the same outputs (docked/undocked/…), switched from System › Display › Profiles: instant via hyprctl, persisted in-flake (`settings.displayProfile`), baked over `nomarchy.monitors` at the next rebuild. The menu's Auto-switch row (`settings.displayProfileAuto`) makes plugging/unplugging outputs apply the matching profile automatically. A profile can also pin workspaces to outputs (`workspaces = { "1" = "DP-3"; }`) — moved instantly on switch, baked as Hyprland `workspace` rules |
|
||||||
|
| `nomarchy.launchOrFocus` | `[]` | Launch-or-focus binds: `SUPER+<key>` focuses the app's window (case-insensitive class match) or launches it; entries land in the SUPER+? cheatsheet, and a bind whose app was removed notifies instead of failing silently |
|
||||||
| `nomarchy.themesDir` | Nomarchy's `themes/` | Where per-theme app overrides are probed |
|
| `nomarchy.themesDir` | Nomarchy's `themes/` | Where per-theme app overrides are probed |
|
||||||
|
| `nomarchy.package` | overlay's `nomarchy-theme-sync` | The theme/state tool package, overridable if you fork it |
|
||||||
|
|
||||||
|
**`system.nix`** (NixOS — the machine):
|
||||||
|
|
||||||
|
| Option | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
| `nomarchy.system.plymouth.enable` | `true` | Branded boot splash, background from the theme JSON (recolors on system rebuilds) |
|
| `nomarchy.system.plymouth.enable` | `true` | Branded boot splash, background from the theme JSON (recolors on system rebuilds) |
|
||||||
|
| `nomarchy.system.stateFile` | `null` | theme-state.json for the system-side consumers (the Plymouth tint); `lib.mkFlake` wires it for you — set it only when composing the modules by hand |
|
||||||
| `nomarchy.system.fileManager.enable` | `true` | Thunar GUI + gvfs/tumbler/udisks2 (the "open folder" handler) |
|
| `nomarchy.system.fileManager.enable` | `true` | Thunar GUI + gvfs/tumbler/udisks2 (the "open folder" handler) |
|
||||||
| `nomarchy.system.greeter.enable` | `true` | greetd/tuigreet |
|
| `nomarchy.system.greeter.enable` | `true` | greetd/tuigreet |
|
||||||
| `nomarchy.system.greeter.autoLogin` | `null` | Auto-login this user into Hyprland (installer sets it on LUKS machines) |
|
| `nomarchy.system.greeter.autoLogin` | `null` | Auto-login this user into Hyprland (installer sets it on LUKS machines) |
|
||||||
| `nomarchy.system.audio.enable` | `true` | Pipewire stack |
|
| `nomarchy.system.audio.enable` | `true` | Pipewire stack |
|
||||||
| `nomarchy.system.bluetooth.enable` | `true` | Bluetooth + blueman |
|
| `nomarchy.system.bluetooth.enable` | `true` | Bluetooth + blueman |
|
||||||
|
| `nomarchy.system.autoTimezone.enable` | `false` | Automatic timezone (geoclue + automatic-timezoned) — the clock follows your location; toggle from System › Auto timezone (a menu enable rebuilds: it has to unset the static `time.timeZone`) |
|
||||||
| `nomarchy.system.snapper.enable` | `false` | Hourly/daily BTRFS timeline snapshots + `nixos-rebuild-snap` (installer enables it; no-op unless root is BTRFS) |
|
| `nomarchy.system.snapper.enable` | `false` | Hourly/daily BTRFS timeline snapshots + `nixos-rebuild-snap` (installer enables it; no-op unless root is BTRFS) |
|
||||||
| `nomarchy.system.power.enable` | `true` | Active power management (see below) |
|
| `nomarchy.system.power.enable` | `true` | Active power management (see below) |
|
||||||
| `nomarchy.system.power.backend` | `"ppd"` | `"ppd"` (power-profiles-daemon + menu/Waybar switcher) or `"tlp"` (deeper battery tuning, no switcher) — mutually exclusive |
|
| `nomarchy.system.power.backend` | `"ppd"` | `"ppd"` (power-profiles-daemon + menu/Waybar switcher) or `"tlp"` (deeper battery tuning, no switcher) — mutually exclusive |
|
||||||
| `nomarchy.system.power.laptop` | `false` | Marks a laptop, gating battery-only features; the installer sets it when a battery is present |
|
| `nomarchy.system.power.laptop` | `false` | Marks a laptop, gating battery-only features; the installer sets it when a battery is present |
|
||||||
| `nomarchy.system.power.thermal.enable` | `false` | thermald (Intel-only); the installer enables it on a GenuineIntel CPU |
|
| `nomarchy.system.power.thermal.enable` | `false` | thermald (Intel-only); the installer enables it on a GenuineIntel CPU |
|
||||||
| `nomarchy.system.power.batteryChargeLimit` | `null` | Stop charging at this % (e.g. `80`) where the hardware supports it; needs `power.laptop` |
|
| `nomarchy.system.power.batteryChargeLimit` | `null` | Stop charging at this % (e.g. `80`) where the hardware supports it; needs `power.laptop` |
|
||||||
|
| `nomarchy.hardware.intel.enable` | `false` | Intel enablement above nixos-hardware (GuC/HuC firmware via `i915.enable_guc=3` — `.guc` toggles just that; the installer unsets it on `xe`-driver GPUs); the installer sets it on an Intel CPU/GPU |
|
||||||
|
| `nomarchy.hardware.intel.computeRuntime` | `false` | Opt-in: Intel GPU compute — OpenCL/Level-Zero (`intel-compute-runtime`) + oneVPL (`vpl-gpu-rt`) |
|
||||||
|
| `nomarchy.hardware.amd.enable` | `false` | AMD enablement above nixos-hardware (amd-pstate EPP + radeonsi VA-API, each toggleable via `.pstate` / `.vaapi`); installer-set on an AMD CPU/GPU |
|
||||||
|
| `nomarchy.hardware.amd.rocm.enable` | `false` | Opt-in: ROCm HIP/OpenCL GPU compute (multi-GB); pair with `.gfxOverride` (e.g. `"11.0.0"`) for an unlisted iGPU |
|
||||||
|
| `nomarchy.hardware.fingerprint.enable` | `false` | fprintd for a detected fingerprint reader (installer-set); enroll with `fprintd-enroll` |
|
||||||
|
| `nomarchy.hardware.fingerprint.pam` | `false` | Opt-in: use the fingerprint for login + sudo (PAM) |
|
||||||
|
| `nomarchy.hardware.npu.enable` | `false` | Opt-in/experimental: load the on-die NPU driver (`amdxdna`/`intel_vpu`); userspace runtime is BYO |
|
||||||
|
| `nomarchy.hardware.latestKernel` | `false` | Opt-in: ship `linuxPackages_latest` instead of the default kernel — for very new hardware whose drivers landed recently |
|
||||||
|
| `nomarchy.hardware.camera.hideIrSensor` | `false` | Hide a dual-sensor webcam's IR node from PipeWire so apps only ever see the colour camera (the "second, dark Integrated Camera"); installer-set on a paired RGB+IR webcam. `/dev/video*` stays open, so Howdy-style face unlock still works; `.irMatch` overrides the IR-name regex |
|
||||||
| `nomarchy.services.tailscale.enable` | `false` | Opt-in: Tailscale mesh VPN (then `sudo tailscale up`) |
|
| `nomarchy.services.tailscale.enable` | `false` | Opt-in: Tailscale mesh VPN (then `sudo tailscale up`) |
|
||||||
| `nomarchy.services.syncthing.enable` | `false` | Opt-in: Syncthing file sync as the login user (GUI at `127.0.0.1:8384`) |
|
| `nomarchy.services.syncthing.enable` | `false` | Opt-in: Syncthing file sync as the login user (GUI at `127.0.0.1:8384`) |
|
||||||
| `nomarchy.services.podman.enable` | `false` | Opt-in: rootless Podman (`docker` aliased to it) |
|
| `nomarchy.services.podman.enable` | `false` | Opt-in: rootless Podman (`docker` aliased to it) |
|
||||||
@@ -235,14 +276,19 @@ examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
|
|||||||
| `nomarchy.services.ollama.enable` | `false` | Opt-in: Ollama local LLM runtime on `127.0.0.1:11434` (CPU; GPU via `services.ollama.acceleration`) |
|
| `nomarchy.services.ollama.enable` | `false` | Opt-in: Ollama local LLM runtime on `127.0.0.1:11434` (CPU; GPU via `services.ollama.acceleration`) |
|
||||||
| `nomarchy.services.printing.enable` | `false` | Opt-in: CUPS + Avahi/mDNS network printer discovery |
|
| `nomarchy.services.printing.enable` | `false` | Opt-in: CUPS + Avahi/mDNS network printer discovery |
|
||||||
| `nomarchy.services.openrgb.enable` | `false` | Opt-in: OpenRGB daemon for RGB peripheral/motherboard lighting |
|
| `nomarchy.services.openrgb.enable` | `false` | Opt-in: OpenRGB daemon for RGB peripheral/motherboard lighting |
|
||||||
| `nomarchy.services.restic.enable` | `false` | Opt-in: scheduled daily restic backup (set `.repository` + `.passwordFile`; 7/4/6 retention; list/restore via the `restic-nomarchy` wrapper) |
|
| `nomarchy.services.restic.enable` | `false` | Opt-in: scheduled daily restic backup (set `.repository` + `.passwordFile`; backs up `.paths`, default `/home`; 7/4/6 retention; list/restore via the `restic-nomarchy` wrapper) |
|
||||||
|
|
||||||
Beyond the `nomarchy.*` surface, the system layer turns on the usual
|
Beyond the `nomarchy.*` surface, the system layer turns on the usual
|
||||||
desktop services with `lib.mkDefault` (override natively). One worth
|
desktop services with `lib.mkDefault` (override natively). Two worth
|
||||||
calling out: **`services.fwupd.enable`** is on by default for firmware
|
calling out: **`services.fwupd.enable`** is on by default for firmware
|
||||||
updates via LVFS — it only refreshes metadata, never flashes on its own,
|
updates via LVFS — it only refreshes metadata, never flashes on its own,
|
||||||
so run `fwupdmgr update` to apply. Disable with `services.fwupd.enable =
|
so run `fwupdmgr update` to apply; disable with `services.fwupd.enable =
|
||||||
false;` on machines without real firmware (VMs/headless).
|
false;` on machines without real firmware (VMs/headless). And
|
||||||
|
**`services.earlyoom`** is on by default so running out of memory kills
|
||||||
|
the offending process (with a desktop notification) instead of freezing
|
||||||
|
the desktop — process-level on purpose, since a Hyprland session is one
|
||||||
|
cgroup and systemd-oomd would kill all of it (oomd is disabled
|
||||||
|
accordingly). Opt out with `services.earlyoom.enable = false;`.
|
||||||
|
|
||||||
## 4. How theming works
|
## 4. How theming works
|
||||||
|
|
||||||
@@ -271,6 +317,31 @@ imperative; nothing in Nix consumes the path): applied at session start and
|
|||||||
after every switch via a tiny activation hook, cycled instantly with
|
after every switch via a tiny activation hook, cycled instantly with
|
||||||
`bg next`.
|
`bg next`.
|
||||||
|
|
||||||
|
### Config the menu writes (not just themes)
|
||||||
|
|
||||||
|
The in-flake-state model isn't only for appearance. **Feature toggles you flip
|
||||||
|
from the menu are written into a `settings.*` section of the *same* state file**
|
||||||
|
— git-tracked, reproducible, never stashed in `~/.local/state`. The menu is just
|
||||||
|
an ergonomic writer for your flake, so version-controlling your downstream
|
||||||
|
reproduces the machine, settings and all. Where a toggle can take effect without
|
||||||
|
a rebuild it does: the menu writes the key (`--no-switch`) and flips the running
|
||||||
|
service, which reads the *live* flake state at session start, so the choice is
|
||||||
|
both instant and survives reboot.
|
||||||
|
|
||||||
|
**Night light** is the first to use this — enable it from the menu (System ›
|
||||||
|
Night light; the first enable rebuilds to install hyprsunset), then on/off is
|
||||||
|
instant. Internally it's two keys: `settings.nightlight.installed` (sticky —
|
||||||
|
gates the unit, the first enable rebuilds) and `settings.nightlight.on` (the
|
||||||
|
instant on/off). Expect more `nomarchy.*` toggles to migrate to this pattern.
|
||||||
|
|
||||||
|
**Auto-commit (opt-in):** System › Auto-commit makes every `apply`/`set`
|
||||||
|
mutation also `git commit` theme-state.json in your flake — *only* that
|
||||||
|
file, so unrelated dirty work is never swept up — turning your settings
|
||||||
|
history into `git log`. Off by default; the toggle is instant (nothing in
|
||||||
|
Nix consumes the flag), the off-write is itself committed so history stays
|
||||||
|
consistent, and wallpaper cycling (`bg next`) is deliberately excluded —
|
||||||
|
the current wallpaper rides along with the next real commit.
|
||||||
|
|
||||||
### Per-theme app assets (`themes/<slug>/`)
|
### Per-theme app assets (`themes/<slug>/`)
|
||||||
|
|
||||||
Recoloring covers 95% of theming; the rest is one optional assets directory
|
Recoloring covers 95% of theming; the rest is one optional assets directory
|
||||||
@@ -299,9 +370,15 @@ nomarchy-theme-sync bg next # cycle wallpapers — instant, no rebuil
|
|||||||
nomarchy-theme-sync bg auto # back to the theme's default wallpaper
|
nomarchy-theme-sync bg auto # back to the theme's default wallpaper
|
||||||
nomarchy-theme-sync get colors.accent
|
nomarchy-theme-sync get colors.accent
|
||||||
sys-update # update inputs + rebuild the system (snapshots first)
|
sys-update # update inputs + rebuild the system (snapshots first)
|
||||||
|
sys-rebuild # rebuild the system, current lock (no update)
|
||||||
home-update # rebuild just the desktop layer
|
home-update # rebuild just the desktop layer
|
||||||
|
nomarchy-doctor # read-only health sheet (also: menu › System › Doctor)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Something broke anyway? Every rebuild is a generation and (on BTRFS)
|
||||||
|
every hour is a snapshot — the undo story, from a bad theme to a
|
||||||
|
machine that won't boot, is **[docs/RECOVERY.md](docs/RECOVERY.md)**.
|
||||||
|
|
||||||
Keybinds: `SUPER+Return` terminal · `SUPER+D` launcher · `SUPER+T` theme
|
Keybinds: `SUPER+Return` terminal · `SUPER+D` launcher · `SUPER+T` theme
|
||||||
picker · `SUPER+SHIFT+T` next wallpaper · `SUPER+X` power menu ·
|
picker · `SUPER+SHIFT+T` next wallpaper · `SUPER+X` power menu ·
|
||||||
`SUPER+E` file manager (yazi) · `SUPER+N` notifications · `SUPER+CTRL+V`
|
`SUPER+E` file manager (yazi) · `SUPER+N` notifications · `SUPER+CTRL+V`
|
||||||
@@ -357,6 +434,9 @@ reload # exec zsh (reload the shell)
|
|||||||
|
|
||||||
## Roadmap & known issues
|
## Roadmap & known issues
|
||||||
|
|
||||||
See **[docs/ROADMAP.md](docs/ROADMAP.md)** — forward-looking plans plus the
|
The prioritized queue of what's next lives in
|
||||||
log of shipped fixes. Kept out of the README so this stays a focused entry
|
**[agent/BACKLOG.md](agent/BACKLOG.md)**; the detailed design/decision
|
||||||
point.
|
records and the log of shipped fixes stay in
|
||||||
|
**[docs/ROADMAP.md](docs/ROADMAP.md)**. Development runs on an
|
||||||
|
agent-driven loop — see **[agent/LOOP.md](agent/LOOP.md)**. Kept out of
|
||||||
|
the README so this stays a focused entry point.
|
||||||
|
|||||||
339
agent/BACKLOG.md
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
# Backlog — the prioritized task queue
|
||||||
|
|
||||||
|
The forward-looking half of the old docs/ROADMAP.md, reworked as a queue
|
||||||
|
agents can execute. Detailed design notes and decision records stay in
|
||||||
|
`docs/ROADMAP.md` (referenced as "ROADMAP § <item>"); this file is *what's
|
||||||
|
next, in what order*.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Agents take the topmost actionable item (see LOOP.md). Finished items
|
||||||
|
are **deleted** here — the journal + git log are the record; durable
|
||||||
|
design notes get a ✓-entry in docs/ROADMAP.md if worth keeping.
|
||||||
|
- Item numbers are **stable IDs** — never renumbered or reused. A gap in
|
||||||
|
the sequence means shipped (or dropped) work; new items take the next
|
||||||
|
free number regardless of tier.
|
||||||
|
- Tags: `[blocked:hw]` needs real hardware (see HARDWARE-QUEUE.md) ·
|
||||||
|
`[human]` needs Bernardo · `[stuck]` two failed attempts, needs help ·
|
||||||
|
`[big]` must be split before starting.
|
||||||
|
- Agents may append to **PROPOSED** and **Decisions** freely; only
|
||||||
|
Bernardo moves items *out* of PROPOSED into the tiers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NOW
|
||||||
|
|
||||||
|
*(empty — NEXT's top item is the queue head)*
|
||||||
|
|
||||||
|
## NEXT
|
||||||
|
|
||||||
|
### 36. Battery / power quick menu `[human]`
|
||||||
|
(Raised by Bernardo, 2026-07-07 — item 6.) Slice (a) — the charge-threshold
|
||||||
|
toggle in the rofi System submenu — shipped 2026-07-07 (iteration #64;
|
||||||
|
`nomarchy-menu batterylimit`, self-gated on the sysfs node, and MOVED out
|
||||||
|
of the gum control-center). Remaining:
|
||||||
|
- (b) `[human]` **Battery-icon click → a dedicated power menu** offering
|
||||||
|
charge threshold + power profile (and switching backend to TLP when
|
||||||
|
relevant). Bernardo will provide a mockup ("when it's time") — hold
|
||||||
|
design until then. Pairs with the instant-effect follow-up in PROPOSED.
|
||||||
|
|
||||||
|
### 29. Laptop docking / external-monitor UX `[human]` (needs hardware)
|
||||||
|
(Raised by Bernardo, 2026-07-07.) The shipped display application is
|
||||||
|
weak and unintuitive, and Hyprland's model for workspaces across
|
||||||
|
multiple monitors is unclear to users. Make docking a laptop to
|
||||||
|
external monitors first-class:
|
||||||
|
- **Unplug → restore the panel:** when the external monitor is removed
|
||||||
|
and the laptop's own panel is off (was the only-active output while
|
||||||
|
docked), turn the internal panel back on *immediately* — never leave
|
||||||
|
the user staring at a dead lid.
|
||||||
|
- **Plug → extend by default:** a newly connected external monitor
|
||||||
|
should *extend* the laptop panel (not mirror), out of the box.
|
||||||
|
- **HDMI audio:** an easy way to route audio to the monitor when a
|
||||||
|
display with audio (HDMI/DP) is connected — a menu toggle / quick
|
||||||
|
action, not manual pavucontrol digging.
|
||||||
|
- **Workspaces + multi-monitor:** document (cheatsheet + menu) and,
|
||||||
|
where sensible, sane defaults for how Hyprland binds workspaces to
|
||||||
|
outputs so multi-monitor behavior is predictable.
|
||||||
|
Likely a `hyprland`/`kanshi`-style profile layer plus a menu surface;
|
||||||
|
split into slices before starting. `[blocked:hw]` for the real
|
||||||
|
dock/undock + HDMI-audio verification (see HARDWARE-QUEUE.md).
|
||||||
|
|
||||||
|
### 28. Theme UI review — make every surface stunning `[big]`
|
||||||
|
(Raised by Bernardo, 2026-07-05 — placed at the head of NEXT; reorder
|
||||||
|
freely.) A thorough visual pass over the whole themed UI — Waybar (the
|
||||||
|
generated bar AND the summer whole-swaps), rofi menus, swaync,
|
||||||
|
hyprlock, the cheatsheet, ghostty/btop, GTK/Qt, boot splash, greeter —
|
||||||
|
with the bar set at *stunning*, not merely consistent (pillar 4).
|
||||||
|
Grounded in design theory, not taste alone:
|
||||||
|
- **Color:** palette-role relationships that demonstrably work
|
||||||
|
together — harmony models (analogous/complementary accents),
|
||||||
|
perceptual lightness ordering of base→surface→overlay→text (check in
|
||||||
|
OKLCH, not raw hex), accent/warn/bad distinguishable for color-blind
|
||||||
|
users; extend tools/check-theme-contrast.py to encode the adopted
|
||||||
|
rules (it currently guards legibility pairs only).
|
||||||
|
- **Typography & rhythm:** a consistent type scale across bar/menus/
|
||||||
|
OSD, spacing on the ui.gapsIn/gapsOut grid, aligned paddings (the
|
||||||
|
bar's per-module padding vs rofi rows vs swaync toasts).
|
||||||
|
- **Hierarchy:** what reads first — bar module ordering/weights, menu
|
||||||
|
title vs rows, toast title vs body; icon weights consistent with the
|
||||||
|
adjacent text.
|
||||||
|
Bernardo has offered image-expert models for designed graphics
|
||||||
|
(wallpapers, logos, glyphs, preview/branding art): an agent slice that
|
||||||
|
needs artwork must STOP and ask ("say the word") instead of shipping
|
||||||
|
programmer-art.
|
||||||
|
Slices: (a) ✓ the audit (2026-07-05, iteration #31 — findings below);
|
||||||
|
(b) palette-level fixes + the extended contrast/harmony checker —
|
||||||
|
P1s ✓ shipped 2026-07-05 (iteration #32): summer-day + flexoki-light
|
||||||
|
subtext/surface/overlay/muted retuned (flexoki to its canonical
|
||||||
|
upstream tones), miasma + kanagawa surfaces raised (kanagawa to
|
||||||
|
sumiInk4); `subtext/base ≥ 3` now GATED in check-theme-contrast.py
|
||||||
|
(all 21 pass). P2s ✓ shipped 2026-07-05 (iteration #33): 18 hex
|
||||||
|
retunes across 9 themes (OKLCH hue-preserving minimal raises;
|
||||||
|
canonical where upstream defines one — gruvbox bg4, nord #616e88,
|
||||||
|
latte overlay1, flexoki 600-series statuses for light mode) and the
|
||||||
|
four floors GATED: muted ≥ 2, text/surface ≥ 4.5, accentAlt ≥ 3,
|
||||||
|
warn ≥ 2.5 (21×7 pairings green; audit contrast findings 20 → 1 —
|
||||||
|
miasma's earthy `bad` stays as identity, good/bad floors stay
|
||||||
|
audit-only so no exemption list was needed). Remaining (b): nothing —
|
||||||
|
further palette work graduates from slices (c)/(d) findings;
|
||||||
|
(c) per-surface polish slices — capture harness ✓ shipped 2026-07-05
|
||||||
|
(iteration #34): `tools/theme-shot.nix` boots the themed desktop
|
||||||
|
headlessly at 1080p and screenshots desktop + open root menu (proven
|
||||||
|
on tokyo-night); per-surface punch items from the first full-res
|
||||||
|
review:
|
||||||
|
- ✓ **rofi keybind hints** (shipped iteration #37): dimmed pango
|
||||||
|
hints on root/Tools/System rows, generated from keybinds.nix via
|
||||||
|
the cheatsheet's prettyKeys (single source, can't drift) —
|
||||||
|
capture-verified on tokyo-night.
|
||||||
|
- **Icon weight mix `[human]`:** full-color Papirus icons in the
|
||||||
|
menus vs all-monochrome bar glyphs — pick: symbolic/monochrome
|
||||||
|
menu icons for one system, or color as the menu's identity.
|
||||||
|
A/B captured on lumon (iteration #46, side-by-side:
|
||||||
|
https://claude.ai/code/artifact/1ccd10fe-4b6f-454a-888d-e7fdf2362b03):
|
||||||
|
B (symbolic) matches the bar's weight but Papirus symbolic
|
||||||
|
coverage has holes (preferences-desktop-theme-symbolic missing —
|
||||||
|
Look & Feel lost its icon) and rofi tints symbolics a fixed dim
|
||||||
|
gray, not the theme text color — going B needs an icon audit +
|
||||||
|
recolor slice. A costs nothing. Awaiting the call; A stays until
|
||||||
|
then.
|
||||||
|
- ✓ **Bar right-cluster rhythm** (shipped iteration #36): group
|
||||||
|
margins before pulseaudio/powerprofile/battery — heads that
|
||||||
|
self-hide degrade to the uniform spacing.
|
||||||
|
- ✓ **Semantic dim** (shipped iteration #36): workspaces + dnd +
|
||||||
|
muted-volume now use `@muted` (floor-guaranteed by 28b) instead
|
||||||
|
of `alpha(@text, 0.5)` tints; the CSS comment now documents the
|
||||||
|
new invariant.
|
||||||
|
- ✓ **compositor backdrop** (shipped iteration #35): Hyprland
|
||||||
|
`misc:background_color` now the theme's base — no more black flash
|
||||||
|
behind a missing/slow wallpaper (glaring on light themes in the
|
||||||
|
summer-day capture).
|
||||||
|
- summer-day whole-swap capture reviewed (iteration #35): the
|
||||||
|
authored slate menu + island-pill bar survive the 28b retunes and
|
||||||
|
read as one coherent identity — keep. ✓ placeholder-dim nit fixed
|
||||||
|
(iteration #38: both summer rasi, bg0 at 60% alpha).
|
||||||
|
- latte capture reviewed (iteration #38): the generated menu is
|
||||||
|
fully coherent on light themes (zebra rows, retuned surface, blue
|
||||||
|
selected, dimmed hints) — the audit's "dark terminal on latte"
|
||||||
|
preview concern was a stale preview, not a defect. Backdrop
|
||||||
|
probe: misc:background_color IS applied (hyprctl getoption =
|
||||||
|
0xFFEFF1F5); captures show dark because awww's empty-state layer
|
||||||
|
paints over it in the wallpaper-less VM — on hardware the fix
|
||||||
|
covers its intended window (the pre-awww startup gap).
|
||||||
|
- ✓ Harness gap fixed (iteration #44): guest `~/.nomarchy` seeded
|
||||||
|
via tmpfiles BEFORE greetd starts the session, so the session's
|
||||||
|
own exec-once wallpaper paint runs (a post-boot testScript seed
|
||||||
|
was too late for exec-once, and a manual re-run fails without
|
||||||
|
WAYLAND_DISPLAY — awww's client socket is display-named).
|
||||||
|
Wallpaper-complete summer-night capture reviewed: lakeside night
|
||||||
|
scene + cream inverted bar + menu-over-scene all cohere; the
|
||||||
|
placeholder dim (#2d353b99) renders. Note: summer-night's cream
|
||||||
|
bar/menu on dark backdrop is the authored inverted design (fg as
|
||||||
|
surface), not a rendering bug.
|
||||||
|
- ✓ Identity-theme capture + stub purge (iteration #45): the lumon
|
||||||
|
capture exposed that catppuccin/lumon/nord/retro-82 shipped stub
|
||||||
|
waybar.css files (2–14 lines, from the f211ef0 rewrite import) —
|
||||||
|
and a theme waybar.css whole-swaps the ENTIRE generated
|
||||||
|
stylesheet, so those four ran an unstyled default bar. Stubs
|
||||||
|
deleted; re-capture confirms lumon falls back to the generated
|
||||||
|
styled bar (workspace pill, padded right cluster). Their full
|
||||||
|
~110-line rofi.rasi designs kept. Real whole-swaps remain only
|
||||||
|
summer-day/night (+ nord/retro-82 lost nothing: the stubs had no
|
||||||
|
design).
|
||||||
|
(d) the whole-swap themes brought to the same bar (module parity
|
||||||
|
verified textually 2026-07-05: whole-swaps carry every functional
|
||||||
|
module; omitting cpu/memory is identity). Visual work is
|
||||||
|
V3-bound by nature — screenshots are the review artifact, hardware
|
||||||
|
confirms.
|
||||||
|
|
||||||
|
**Audit findings (slice a — `tools/audit-theme-design.py` over all 21
|
||||||
|
palettes + visual pass over previews; identity-aware curation):**
|
||||||
|
- ~~P1 · invisible-secondary-text palettes~~ ✓ fixed iteration #32
|
||||||
|
(summer-day subtext #6e828a, surface #efe7d0; flexoki-light to
|
||||||
|
canonical Flexoki: surface #E6E4D9, overlay #B7B5AC, subtext
|
||||||
|
#6F6E69, muted #878580). Gate added.
|
||||||
|
- ~~P1 · inverted bg stacks~~ ✓ fixed iteration #32 (miasma surface
|
||||||
|
#2e2e2e; kanagawa surface #2a2a37 = upstream sumiInk4). Note
|
||||||
|
learned: in this palette model `overlay` is a MID-TONE
|
||||||
|
(borders/dim, catppuccin semantics), not a third bg tier — the
|
||||||
|
dark-theme overlay==muted pattern is intentional.
|
||||||
|
- **P2 · muted floor:** muted-on-base < 2.0 on gruvbox (1.27),
|
||||||
|
everforest (1.55), lumon (1.68), nord (1.69), white (1.82), latte
|
||||||
|
(1.91), retro-82 (1.93) — dimmed workspaces/inactive items barely
|
||||||
|
render (visible on the lumon preview). Adopt ≥ 2.0 and retune.
|
||||||
|
- **P2 · warn barely reads:** warn-on-base ≈ 2.0–2.3 on rose-pine,
|
||||||
|
summer-day, latte, flexoki-light — this is the bar's 25%-battery
|
||||||
|
color. Floor 2.5 (or see the color-only rule below).
|
||||||
|
- **P2 · text-on-surface < 4.5:** ristretto 3.88, latte 4.39 (menus
|
||||||
|
and chips draw text on surface).
|
||||||
|
- **P3 · accentAlt-on-base < 3:** latte 2.34, summer-day 2.83.
|
||||||
|
- **Design rule (systemic, beats palette surgery): status is never
|
||||||
|
color-only.** good/warn collapse under protanopia on ~half the
|
||||||
|
themes and good/bad under deuteranopia (59 pair-findings) — that's
|
||||||
|
inherent to red/green/yellow, not fixable per-palette. Battery and
|
||||||
|
recording already carry glyph changes; sweep the remaining
|
||||||
|
color-only indicators (updates count, notification dot, VPN shield
|
||||||
|
states) for shape/glyph redundancy.
|
||||||
|
- **Identity exemptions, made explicit:** vantablack + white
|
||||||
|
(deliberately hueless statuses), lumon (all-blue), hackerman
|
||||||
|
(matrix green statuses), matte-black/retro-82 (off-family hues) are
|
||||||
|
identity, not defects — slice (b)'s checker gets a per-theme
|
||||||
|
exemption list instead of silently lowered floors.
|
||||||
|
- **ANSI / greeter notes:** latte, rose-pine and white ship
|
||||||
|
`ansi[0]` LIGHTER than `ansi[15]` — decide the light-theme ANSI
|
||||||
|
convention (terminals conventionally keep black dark) before the
|
||||||
|
greeter/tty theming ships to them. tokyo-night/summer-night
|
||||||
|
`ansi[7]-on-ansi[0]` ≈ 2.8–3.0 = the greeter's muted text; fine as
|
||||||
|
muted, watch on hardware.
|
||||||
|
- **Visual pass (480×270 previews — too small for type/spacing; the
|
||||||
|
deep per-surface pass needs 1080p captures):** the generated bar's
|
||||||
|
right cluster runs ~9 modules with uniform spacing — no grouping
|
||||||
|
rhythm (consider micro-gaps between tray/net · stats · toggles ·
|
||||||
|
battery/notify groups); latte's preview shows a DARK terminal on
|
||||||
|
the light desktop — verify ghostty consumes the latte palette (or
|
||||||
|
the preview is stale); summer-day's island-pill whole-swap bar and
|
||||||
|
lumon's globe identity are strong — preserve through any polish.
|
||||||
|
|
||||||
|
### 14. Automated lock bumps — confirm the first real run
|
||||||
|
Slices b+c shipped 2026-07-05 (`.gitea/workflows/bump.yml`): weekly
|
||||||
|
schedule (Mon 05:17 UTC) + `workflow_dispatch` as the security
|
||||||
|
fast-lane; `nix flake update` → eval gate → pathspec-limited lock
|
||||||
|
commit pushed to `main` on green (that push triggers check.yml as the
|
||||||
|
second net). Update/commit/push logic simulated locally end-to-end in
|
||||||
|
a scratch clone, incl. a real update; today's bumped lock evals green.
|
||||||
|
Remaining (not confirmable from a session): the first scheduled or
|
||||||
|
dispatched run must land — watch that the runner picks up the cron and
|
||||||
|
that the Actions token can push. Then delete this item. Item 20's KVM
|
||||||
|
runner later upgrades the gate from eval-only to the VM suite.
|
||||||
|
|
||||||
|
### 20. KVM runner → VM suite in CI `[human]`
|
||||||
|
The remaining stretch of the CI item — checks-on-push is live and
|
||||||
|
**green** (run #58; runner = gitea/act_runner docker, eval tier).
|
||||||
|
Register a second runner on a host with `/dev/kvm` + nix (host-mode
|
||||||
|
label `nix-kvm`), then an agent uncomments the workflow's `vm-checks`
|
||||||
|
job: the `checks.*` VM suite + real toplevel/HM builds on every push
|
||||||
|
that later upgrades item 14's bump gate from eval-only to the full suite.
|
||||||
|
|
||||||
|
### 33. rofi page-overflow hint (scrollbar)
|
||||||
|
(Raised by Bernardo, 2026-07-07 — item 3.) When a list/grid overflows
|
||||||
|
onto another page, nothing signals there's more below. Add a scrollbar
|
||||||
|
(rofi's `scrollbar` element) or a "more ↓" hint, themed to the palette,
|
||||||
|
on the launcher grid and the long text menus. Check rofi 2.0.0's
|
||||||
|
scrollbar support/theming.
|
||||||
|
_Scoped 2026-07-07:_ rofi only draws the scrollbar when the list actually
|
||||||
|
overflows (`lines < rows`), so enabling it self-hides on short menus —
|
||||||
|
the right primitive. But it's a **visual, ~10-file slice**: the generated
|
||||||
|
palette theme (`rofi.nix`, `scrollbar = false`) **plus every authored
|
||||||
|
`.rasi`** (boreal/nord/lumon/kanagawa/retro-82/neon-glass/executive-slate/
|
||||||
|
summer-day/summer-night) — all deliberately set `scrollbar { width:0 }`
|
||||||
|
today, each needs a per-theme handle width/colour. Payoff is V3-only
|
||||||
|
(rendering), so it wants a themed session, not a headless edit.
|
||||||
|
|
||||||
|
### 41. Floating-window audit — remaining (needs hardware class checks)
|
||||||
|
(Raised by Bernardo, 2026-07-07 — item 11.) Broaden the `windowrule`
|
||||||
|
float set beyond the conservative first cut.
|
||||||
|
_Conservative cut shipped 2026-07-07 (iteration #63, on Bernardo's "take a
|
||||||
|
conservative approach"):_ float + center the mixer (item 35), **blueman**
|
||||||
|
(manager/adapters) and **system-config-printer** — the only shipped,
|
||||||
|
unambiguous config dialogs whose classes are confident. **Remaining
|
||||||
|
candidates, deliberately deferred because their window class can't be
|
||||||
|
verified headlessly** (a guessed class = dead rule): the polkit auth
|
||||||
|
prompt (hyprpolkitagent — no discoverable class in the package), GTK
|
||||||
|
file-chooser portals (`xdg-desktop-portal-gtk`), and any pinentry dialog.
|
||||||
|
Next slice: on hardware, run `hyprctl clients` while each is open, read
|
||||||
|
the real `class`, then append rules. Also revisit whether blueman/s-c-p
|
||||||
|
actually float once seen (regex tolerance for the `.…-wrapped` form).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## LATER
|
||||||
|
|
||||||
|
- **Wallpapers artifact split** (ROADMAP § Faster switches — decided,
|
||||||
|
deferred): pinned `Nomarchy-wallpapers` input so a state write stops
|
||||||
|
re-copying 86 MB. Follow-on: pre-built theme variants if switches are
|
||||||
|
still slow after.
|
||||||
|
- **Installer round 2** (ROADMAP § Installer): multi-disk BTRFS RAID,
|
||||||
|
impermanence, BIOS/legacy boot.
|
||||||
|
- **Boot-from-snapshot**: a systemd-boot equivalent of grub-btrfs.
|
||||||
|
- **Night-light geo mode**: lat/long auto sunset/sunrise (means wlsunset).
|
||||||
|
- **Per-theme icon overrides** / more icon packs (ROADMAP § Icon themes).
|
||||||
|
- **MIPI/IPU software-ISP camera** support (no-UVC machines).
|
||||||
|
- **VPN exit-node richer display** (country/city) (optional).
|
||||||
|
- **NixOS release bump → v2** `[human]`: deliberate, hand-edited, never
|
||||||
|
automated; the previous attempt was discarded (2026-06-22) over a
|
||||||
|
Hyprland OOM blocker — see MEMORY.md before retrying (NOW#3 should
|
||||||
|
also soften that blocker class).
|
||||||
|
|
||||||
|
## PROPOSED (agent suggestions — await human triage)
|
||||||
|
|
||||||
|
*Agents: append here with a one-paragraph pitch (what/why/cost). Do not
|
||||||
|
implement. Bernardo moves accepted items into a tier.*
|
||||||
|
|
||||||
|
- **Battery charge-limit — make the toggle instant** `[blocked:hw]`
|
||||||
|
(follow-up to the preset toggle shipped 2026-07-07, iteration #55).
|
||||||
|
The control-center now has a proper toggle (Off / 80% / 90% / 60% /
|
||||||
|
Custom…) writing `settings.power.batteryChargeLimit` — but that's a
|
||||||
|
baked NixOS option, so it only lands on the next `sys-rebuild`. To make
|
||||||
|
it instant (no rebuild): add a udev rule making
|
||||||
|
`/sys/class/power_supply/BAT*/charge_control_end_threshold`
|
||||||
|
group-writable (`MODE`/`GROUP`) so the menu can `echo N >` it live
|
||||||
|
alongside persisting state (the sysfs oneshot in power.nix still owns
|
||||||
|
boot + AC-replug re-apply), and/or surface a rofi-menu quick row or a
|
||||||
|
Waybar action. `[blocked:hw]`: the sysfs write + no-rebuild effect can
|
||||||
|
only be confirmed on a laptop exposing the threshold. Decide first
|
||||||
|
whether the small privilege grant (a local user can set the charge cap)
|
||||||
|
is acceptable.
|
||||||
|
|
||||||
|
- **Portal/Flatpak camera picker still lists the internal IR sensor**
|
||||||
|
(ROADMAP § Webcam follow-up). The shipped IR-hide is a *WirePlumber
|
||||||
|
v4l2* rule, but Flatpak/portal apps consume cameras via the
|
||||||
|
**libcamera** path, where both sensors remain visible — a Flatpak
|
||||||
|
Zoom user can still pick the black IR "camera". Options, roughly
|
||||||
|
ascending cost: (a) do nothing — document it (portal camera support
|
||||||
|
is still rare in practice); (b) a WirePlumber *libcamera* monitor
|
||||||
|
rule disabling GREY-only nodes — needs verifying that libcamera
|
||||||
|
monitor rules can match early enough (the v4l2 investigation found
|
||||||
|
only `device.api` binds pre-rule, which is why surgical scoping
|
||||||
|
failed before — same wall likely applies); (c) a libcamera
|
||||||
|
configuration/udev quirk hiding the IR sensor at the libcamera layer
|
||||||
|
itself. Cost: (b)/(c) need a T14s-style RGB+IR machine to verify →
|
||||||
|
pairs with a hardware-queue session. Recommend (a) now, (b)
|
||||||
|
investigated when the T14s is next available.
|
||||||
|
|
||||||
|
## Decisions `[human]`
|
||||||
|
|
||||||
|
Open calls only Bernardo can make; agents add options/evidence but never
|
||||||
|
decide.
|
||||||
|
|
||||||
|
- **Formatter adoption:** repo deliberately has none; `nixfmt-rfc-style`
|
||||||
|
would flatten the aligned hand-formatting of ~33 files. Adopt or
|
||||||
|
declare never?
|
||||||
|
- **Docs site vs Markdown-in-repo** (from the docs-review item).
|
||||||
|
|
||||||
|
- **zram swap:** faster under pressure and pairs with NOW#3, but it
|
||||||
|
interacts with the hibernation-swapfile story (resume device/priority
|
||||||
|
ordering) — adopt, adopt-with-hibernation-guard, or skip?
|
||||||
|
- **Default browser:** the template comments Firefox out. The shipped
|
||||||
|
mime defaults (item 8, done) point `text/html`/http(s) at
|
||||||
|
`firefox.desktop` as *inert* entries — they activate the moment
|
||||||
|
Firefox is installed and are skipped otherwise, so the remaining call
|
||||||
|
is only: ship a browser active in the suite, or stay
|
||||||
|
browserless-by-default?
|
||||||
70
agent/CONVENTIONS.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Conventions — how Nomarchy code is written
|
||||||
|
|
||||||
|
The standing rules an agent must follow while coding. GOALS.md says what
|
||||||
|
we're building; this says how. Details/rationale live in docs/ROADMAP.md's
|
||||||
|
decision records and the README.
|
||||||
|
|
||||||
|
## Repo layout (the rule of thumb)
|
||||||
|
`modules/` is the distro (reusable, no machine specifics) · `hosts/` is a
|
||||||
|
machine · `themes/` is data · `pkgs/` is code · `tools/` is maintainer-only
|
||||||
|
· `agent/` is loop state. If a new file doesn't obviously belong to one of
|
||||||
|
those, it probably shouldn't exist.
|
||||||
|
|
||||||
|
## Nix style
|
||||||
|
- **No formatter.** Files use deliberate aligned hand-formatting — match
|
||||||
|
the surrounding style exactly; never reflow a file you're only touching
|
||||||
|
a line of.
|
||||||
|
- Distro defaults use `lib.mkDefault` so a plain downstream assignment
|
||||||
|
wins; bind/exec lists concatenate. Behaviour options overridable,
|
||||||
|
appearance flows from the state JSON.
|
||||||
|
- Options live in the existing surfaces: `nomarchy.system.*` /
|
||||||
|
`nomarchy.hardware.*` / `nomarchy.services.*` (NixOS, `system.nix`),
|
||||||
|
everything else `nomarchy.*` (HM, `home.nix`). Update the README tables
|
||||||
|
when the surface changes.
|
||||||
|
|
||||||
|
## Feature design
|
||||||
|
- **In-flake state:** any user-settable config gets a menu writer that
|
||||||
|
lands it in `theme-state.json` (`settings.*`), git-tracked. No
|
||||||
|
`~/.local/state`, no side files. Instant-effect where possible
|
||||||
|
(`--no-switch` + flip the service; the service reads the *live* working
|
||||||
|
tree at start — the night-light `ExecCondition` pattern). Rebuild-baked
|
||||||
|
values graduate via `mkDefault` reads of the settings key.
|
||||||
|
- **Toggle vs package:** a `nomarchy.*` toggle only when there's real
|
||||||
|
config behind it (units, groups, udev, firewall). A bare package goes
|
||||||
|
in the downstream template's `home.packages` — opt-out is deleting the
|
||||||
|
line.
|
||||||
|
- **Opt-in features** ship a commented example in
|
||||||
|
`templates/downstream/home.nix` or `system.nix`.
|
||||||
|
- **Menu:** new entries go in the right submenu (Tools › / System ›; root
|
||||||
|
stays six entries), end lists with the shared `↩ Back`, self-gate on
|
||||||
|
the feature's availability, and add the direct
|
||||||
|
`SUPER+CTRL+<mnemonic>` bind in `keybinds.nix` (single source — it
|
||||||
|
feeds both Hyprland and the SUPER+? cheatsheet).
|
||||||
|
- **Waybar:** new indicators self-gate (hidden when irrelevant), use
|
||||||
|
named `writeShellScriptBin`s on PATH (so static configs can exec them
|
||||||
|
by bare name), and are added to **both** the generated `waybar.nix`
|
||||||
|
config **and** every `waybar.jsonc` whole-swap — summer-day, summer-night
|
||||||
|
and executive-slate (the parity rule).
|
||||||
|
- **Theming:** every new visual surface consumes the palette from the
|
||||||
|
state JSON. There is no second renderer to keep in sync — add the key
|
||||||
|
to the JSON, consume it in the module.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- docs/TESTING.md is canonical; LOOP.md's ladder (V0–V3) sets the
|
||||||
|
required tier. Cheap first: `nix flake check --no-build`, `bash -n`,
|
||||||
|
`py_compile`.
|
||||||
|
- Prefer a permanent `checks.*` runNixOSTest over a one-off manual poke;
|
||||||
|
reusable recipes (headless Hyprland with software GL, QMP screenshots,
|
||||||
|
udev-event fakes) are indexed in MEMORY.md and demonstrated by the
|
||||||
|
existing checks (`distro-id`, `hardware-toggles`,
|
||||||
|
`battery-charge-limit`).
|
||||||
|
- The honesty rule: report exactly what you verified and at which tier.
|
||||||
|
|
||||||
|
## Git
|
||||||
|
- `main` is development (direct commits, pushed); `v1` is the release
|
||||||
|
pointer — **human-only, fast-forward-only, never touched by agents**.
|
||||||
|
- Commit style: `feat|fix|test|docs|chore(scope): summary`, body with
|
||||||
|
what/why + verification tier + what remains. Bookkeeping (`agent/`
|
||||||
|
updates) rides in the same commit as the change.
|
||||||
|
- `flake.lock` moves only when the task *is* a lock bump, and only within
|
||||||
|
the pinned release branches.
|
||||||
58
agent/GOALS.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Goals — what "done" looks like for Nomarchy
|
||||||
|
|
||||||
|
The north star every loop iteration serves. When two options conflict,
|
||||||
|
the earlier pillar wins.
|
||||||
|
|
||||||
|
## The four pillars (in priority order)
|
||||||
|
|
||||||
|
1. **Rock-stable.** A workstation you never fight. Everything is a NixOS/HM
|
||||||
|
generation — atomic, rollbackable, never partial. `nix flake check` is
|
||||||
|
green at every commit on `main`. Regressions are caught by the VM-test
|
||||||
|
suite (`checks.*`), not by users. `v1` only ever advances after human
|
||||||
|
on-hardware QA.
|
||||||
|
2. **Reproducible, with zero hidden state.** The downstream flake checkout
|
||||||
|
*is* the machine. All user-settable config is menu-writable into the
|
||||||
|
git-tracked state file (`theme-state.json` `settings.*`) — never
|
||||||
|
`~/.local/state`, never `~/.config` side files. Re-cloning your flake
|
||||||
|
reproduces the machine, settings and all.
|
||||||
|
3. **Effortless to configure.** The user never has to learn Nix. Every
|
||||||
|
common knob is reachable from `nomarchy-menu` (SUPER+M); the menu is an
|
||||||
|
ergonomic writer for the flake. Where a toggle can take effect without a
|
||||||
|
rebuild, it must (`--no-switch` + flip the running service).
|
||||||
|
4. **Beautiful.** One JSON themes the entire desktop coherently — Hyprland,
|
||||||
|
Waybar, Ghostty, btop, rofi, GTK/Qt, boot splash, greeter. Every new
|
||||||
|
surface follows the palette. Informative, self-gating Waybar modules
|
||||||
|
(they hide when irrelevant). No unthemed corner survives contact with
|
||||||
|
the theme switcher.
|
||||||
|
|
||||||
|
## Quality bars (non-negotiable)
|
||||||
|
|
||||||
|
- **The honesty rule** (docs/TESTING.md): for anything visual, "the Nix
|
||||||
|
evaluates" is not "it renders". Verify at the highest tier you can reach
|
||||||
|
(see LOOP.md's verification ladder) and *state the tier you reached*.
|
||||||
|
Never claim a check you didn't run.
|
||||||
|
- **Parity rule:** any module added to the generated Waybar config must
|
||||||
|
also be added to the summer-day/night `waybar.jsonc` whole-swaps.
|
||||||
|
- **Menu placement:** new menu entries go in the right category submenu
|
||||||
|
(Tools › / System ›), with a direct `SUPER+CTRL+<mnemonic>` bind and
|
||||||
|
self-gating where applicable. The root picker stays six entries.
|
||||||
|
- **Opt-in features** ship a commented example in
|
||||||
|
`templates/downstream/{home,system}.nix`.
|
||||||
|
- **Option surface discipline:** a toggle exists only when there is real
|
||||||
|
config behind it. Bare package installs go in the template's
|
||||||
|
`home.packages` (opt-out = delete the line), never a `nomarchy.apps.*`.
|
||||||
|
|
||||||
|
## Non-goals (do not drift into these)
|
||||||
|
|
||||||
|
- **No binary cache.** Compile-from-source is a deliberate values call;
|
||||||
|
automation targets the config/lock channel, not artifact distribution.
|
||||||
|
- **No second theming pipeline.** The dispatcher owns menu structure; the
|
||||||
|
renderer (rofi) stays swappable. No GTK4 launcher.
|
||||||
|
- **No nixpkgs major bump on `main`.** Lock updates stay within the pinned
|
||||||
|
release branch; a release jump is a deliberate `v2`, hand-edited by the
|
||||||
|
maintainer (the last attempt was discarded over a Hyprland OOM — see
|
||||||
|
agent/MEMORY.md).
|
||||||
|
- **No repo-wide reformat.** The `.nix` files use deliberate aligned
|
||||||
|
hand-formatting; adopting a formatter is an open maintainer decision
|
||||||
|
(BACKLOG.md § Decisions), not a cleanup.
|
||||||
|
- **No multi-DE.** Hyprland is the desktop.
|
||||||
306
agent/HARDWARE-QUEUE.md
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
# Hardware queue — V3 checks only a human can run
|
||||||
|
|
||||||
|
Everything shipped at V1/V2 whose final verification needs real hardware.
|
||||||
|
Agents **append** (newest at the bottom of a section) with exact steps;
|
||||||
|
Bernardo runs them and either checks off (`[x]` + date + verdict) or files
|
||||||
|
the failure as a NOW bug in BACKLOG.md. Machines: the **AMD dev box**
|
||||||
|
(Ryzen AI laptop: AMD + fingerprint + NPU), the **Latitude 5410** (Intel
|
||||||
|
QA machine), the **T14s** (webcam case).
|
||||||
|
|
||||||
|
## Any machine (dev box is fine)
|
||||||
|
- [ ] **Battery charge limit re-apply on unplug** — `sudo nixos-rebuild
|
||||||
|
switch` with `power.batteryChargeLimit = 80`, physically unplug/replug
|
||||||
|
AC, confirm `charge_control_end_threshold` re-reads 80. (udev trigger
|
||||||
|
already VM-verified.)
|
||||||
|
- [ ] **SSH_AUTH_SOCK for GUI clients** — after relogin, launch a GUI git
|
||||||
|
client (or `rofi`-launched terminal-less app) and confirm it reaches
|
||||||
|
gpg-agent's SSH socket without an interactive shell parent.
|
||||||
|
- [x] **Night-light full cycle** — first menu enable rebuilds + hyprsunset
|
||||||
|
starts; later toggles are instant (no rebuild); an *off* survives
|
||||||
|
reboot (ExecCondition); stopping hyprsunset restores gamma.
|
||||||
|
— 2026-07-04 Bernardo: PASS enable/disable on hardware
|
||||||
|
(off-survives-reboot + gamma-restore not explicitly checked).
|
||||||
|
- [x] **Auto-timezone** — enable from the menu; confirm geoclue finds the
|
||||||
|
zone, `/etc/localtime` updates, and the Waybar clock refreshes
|
||||||
|
(SIGUSR2 watcher) — also after a manual `timedatectl set-timezone`.
|
||||||
|
— 2026-07-04 Bernardo: PASS — enabled from the menu, `timedatectl`
|
||||||
|
shows Europe/London (the manual set-timezone re-trigger wasn't
|
||||||
|
exercised).
|
||||||
|
- [ ] **Keyboard layout cycle bind** — with a comma layout (e.g.
|
||||||
|
`nomarchy.keyboard.layout = "us,de"`), SUPER+SHIFT+K cycles the
|
||||||
|
focused keyboard's layout, the Waybar `` indicator follows, and
|
||||||
|
the row shows in the SUPER+? cheatsheet. — 2026-07-04 attempt:
|
||||||
|
no comma layout was configured, so the bind wasn't rendered (the
|
||||||
|
gate working as designed, not a failure) — retest after setting a
|
||||||
|
comma layout + rebuild + relogin. The SUPER+? no-op found en
|
||||||
|
route IS a real bug → BACKLOG item 26 (fix shipped — see the
|
||||||
|
dedicated SUPER+? entry below).
|
||||||
|
- [ ] **SUPER+? opens the cheatsheet** (item 32 re-fix — supersedes the
|
||||||
|
item 26 attempt) — the bind is now `$mod SHIFT, slash` (the BASE
|
||||||
|
keysym: Hyprland 0.55 resolves the sym with Shift consumed, so the
|
||||||
|
old `question` keysym never matched while Shift was down — same
|
||||||
|
pattern as the working `$mod SHIFT, 1` workspace binds). After
|
||||||
|
`home-update` + relogin (or `hyprctl reload`), SUPER+? must open the
|
||||||
|
keybindings cheatsheet, whose row still reads `SUPER + ?` (not
|
||||||
|
`SUPER + SHIFT + ?`).
|
||||||
|
- [ ] **swaync readable on summer-day** (item 25 fix) — on summer-day
|
||||||
|
after `home-update`: `notify-send "title" "body text"` shows
|
||||||
|
readable body text; open the control centre (SUPER+N), hover a
|
||||||
|
notification row and check the Clear button — all text legible
|
||||||
|
(body/buttons now @text on tinted chips, not subtext/surface).
|
||||||
|
- [ ] **Bar + rofi legible on flexoki-light** (item 27 fix) — switch to
|
||||||
|
flexoki-light: every Waybar module readable (window title, tray
|
||||||
|
row, dimmed inactive workspaces / muted volume visible-but-dim),
|
||||||
|
rofi inputbar/alternate rows show text. Spot-check one dark theme
|
||||||
|
(e.g. tokyo-night) for no visual regression in the same spots.
|
||||||
|
- [ ] **Stub-bar themes got the generated bar** (item 28, 90a5104) —
|
||||||
|
switch to catppuccin, lumon, nord and retro-82: each now shows
|
||||||
|
the generated styled bar in its own palette (workspace pill,
|
||||||
|
padded right cluster) instead of the old raw default Waybar.
|
||||||
|
Their rofi menus should look unchanged.
|
||||||
|
- [ ] **Back-audit spot-check** (item 24) — the keybinds cheatsheet
|
||||||
|
(SUPER+? / root menu › Keybindings) now ends in ↩ Back and
|
||||||
|
returns to the root picker; spot a couple of submenus (Display,
|
||||||
|
VPN › Tailscale) still Back correctly after the audit pass.
|
||||||
|
- [ ] **Network menu has no blank rows** (item 22 fix, needs Wi-Fi) —
|
||||||
|
after `home-update`, open Tools › Network: no empty separator
|
||||||
|
rows between the ethernet/wifi/VPN sections (compact = True).
|
||||||
|
KNOWN RESIDUAL: if exactly one nameless wifi row remains showing
|
||||||
|
only security+bars, that's a hidden-SSID AP — config can't filter
|
||||||
|
it; report it and the follow-up is a small source patch skipping
|
||||||
|
empty-name APs in create_ap_list.
|
||||||
|
- [ ] **Keyboard hotplug picker (re-verify after in-flake graduation)** —
|
||||||
|
plug an external keyboard post-login, pick a layout in rofi, confirm
|
||||||
|
it applies per-device only, persists in `settings.keyboard.devices`,
|
||||||
|
and graduates into a `device{}` block on the next rebuild.
|
||||||
|
- [x] **Snapshots restore/rollback** — ⚠ PRECONDITION: update to main ≥
|
||||||
|
a47aa3a and RELOGIN first (the polkit agent starts with the session).
|
||||||
|
Bernardo's 2026-07-04 Latitude findings 5/6 ("btrfs-assistant still
|
||||||
|
crashes", "menu snapshots shows nothing") match the PRE-fix behavior
|
||||||
|
exactly: no agent → pkexec fails silently (= menu does nothing), and
|
||||||
|
a direct unprivileged run crashes (= the libbtrfsutil bug). If either
|
||||||
|
still reproduces ON THE FIXED BUILD after relogin, reopen BACKLOG
|
||||||
|
item 4 as [stuck]. Then: `nomarchy-menu snapshot` now opens
|
||||||
|
the **btrfs-assistant GUI**: a *themed* polkit prompt must appear
|
||||||
|
(hyprpolkitagent — first on-hardware outing) and the GUI must open
|
||||||
|
as root. Also exercise the fzf fallback in a terminal
|
||||||
|
(`sudo nomarchy-snapshots`): browse/diff, restore a file
|
||||||
|
(`undochange`), and a root-config rollback behind the typed-`yes`
|
||||||
|
gate. Bonus check: any other pkexec flow now prompts instead of
|
||||||
|
silently failing. — 2026-07-04 Bernardo: PASS on the fixed build —
|
||||||
|
polkit prompt appears, GUI opens as root, fzf fallback works.
|
||||||
|
Restore/rollback not exercised → residual item below.
|
||||||
|
- [ ] **Snapshots restore + rollback exercise** (residual from the item
|
||||||
|
above — the GUI/polkit half passed 2026-07-04): in
|
||||||
|
`sudo nomarchy-snapshots`, restore a single file (`undochange`)
|
||||||
|
and walk a root-config rollback up to (or through) the
|
||||||
|
typed-`yes` gate.
|
||||||
|
- [ ] **Caffeine toggle (item 13 slice)** — click in the bar → turns
|
||||||
|
(warm tint on the generated bar) and hypridle must NOT lock /
|
||||||
|
blank while it's on (wait past the 5-min lock); click again
|
||||||
|
releases. Also present on both summer bars now.
|
||||||
|
- [ ] **Screen recording (item 12)** — Tools › Capture: Record region
|
||||||
|
(slurp) and Record screen produce a playable .mp4 in
|
||||||
|
~/Videos/Recordings; the red ⏺ REC appears in the bar instantly
|
||||||
|
and CLICKING IT stops + notifies the path; the + audio variants
|
||||||
|
carry sound. While recording, the Capture menu offers only
|
||||||
|
"■ Stop recording". On the AMD box wl-screenrec (VAAPI) should
|
||||||
|
do the work; if it dies at start the wf-recorder fallback kicks
|
||||||
|
in silently — check the notification says which. Summer bars
|
||||||
|
have the same ⏺ (parity).
|
||||||
|
- [ ] **Doctor (item 10)** — menu › System › Doctor opens the sheet in
|
||||||
|
a terminal; `nomarchy-doctor` over SSH shows the same minus user
|
||||||
|
units. On a healthy machine everything is ✔/– (dev-box run
|
||||||
|
2026-07-04 correctly flagged a genuinely failed user unit).
|
||||||
|
- [ ] **Rollback menu (item 9b)** — after a couple of theme changes,
|
||||||
|
menu › System › Rollback: recent desktop generations listed
|
||||||
|
(newest marked current); picking an older one opens a terminal,
|
||||||
|
activates it, and the theme visibly reverts; picking the newer one
|
||||||
|
again rolls forward. The two System rows: Snapshots opens the
|
||||||
|
snapshot flow, "boot an older generation (how)" fires an
|
||||||
|
instruction notification.
|
||||||
|
- [ ] **Open-a-file smoke (viewers + mime defaults, item 8)** — after
|
||||||
|
`home-update`: from yazi/Thunar, open a PDF (→ zathura, themed to
|
||||||
|
the palette), an image (→ imv, NOT GIMP), a video (→ mpv);
|
||||||
|
`xdg-open .` on a directory → Thunar. With no browser installed,
|
||||||
|
clicking a link must still fall through to *something sane* (the
|
||||||
|
firefox.desktop entries are inert); if you've uncommented a
|
||||||
|
browser, links go there.
|
||||||
|
- [ ] **Update awareness** — with `nomarchy.updates.enable`, let the timer
|
||||||
|
fire (or start the unit): indicator appears only when inputs are
|
||||||
|
behind, notification only on count growth, click opens the upgrade
|
||||||
|
flow.
|
||||||
|
- [ ] **VPN menu live paths** — import a real WireGuard `.conf` and an
|
||||||
|
`.ovpn` via System → VPN, toggle up/down (● / ○ state), and the
|
||||||
|
Tailscale block: up/down + exit-node without sudo (operator grant).
|
||||||
|
- [ ] **Printer menu** — with `nomarchy.services.printing`, System →
|
||||||
|
Printers opens system-config-printer; add a printer, test page.
|
||||||
|
- [ ] **GRUB UEFI ISO theme render** — boot the ISO on UEFI hardware:
|
||||||
|
composed splash background, palette menu in the lower third, accent
|
||||||
|
timeout bar.
|
||||||
|
- [ ] **Visual theme pass** — live ISO: all six identity themes (bars) +
|
||||||
|
the four authored rofi `.rasi` (nord/retro-82/lumon/kanagawa) look
|
||||||
|
right, not just parse.
|
||||||
|
- [x] **Auto-commit on a real machine** — System › Auto-commit toggles on
|
||||||
|
(row shows state, notification fires); a theme apply from SUPER+T then
|
||||||
|
shows a `nomarchy: apply theme …` commit in `~/.nomarchy` (`git log`);
|
||||||
|
unrelated dirty files in the checkout stay uncommitted; toggle off is
|
||||||
|
itself the last commit. Also: the "Auto timezone (on/off)" row label
|
||||||
|
now reflects the real state (the `= true` comparison fix).
|
||||||
|
— 2026-07-04 Bernardo: PASS — theme changes committed and the
|
||||||
|
toggle self-committed.
|
||||||
|
- [ ] **Display profiles, slice a (item 15)** — declare two
|
||||||
|
`nomarchy.displayProfiles` (e.g. docked disables eDP-1 + arranges
|
||||||
|
the externals; undocked re-enables it), `home-update`, then:
|
||||||
|
System › Display › Profiles lists them (● marks active); applying
|
||||||
|
is instant (no rebuild) and survives a relogin (the baked
|
||||||
|
overlay); "Base layout" restores; a menu resolution pick made
|
||||||
|
earlier must NOT re-enable a profile-disabled panel after the
|
||||||
|
next rebuild.
|
||||||
|
- [ ] **Display profiles workspace pins, slice c (item 15)** — give the
|
||||||
|
docked profile `workspaces = { "1" = "<ext>"; "9" = "eDP-1"; }`:
|
||||||
|
applying the profile moves open workspaces 1/9 to those outputs
|
||||||
|
immediately, new visits land there too, and the pins survive a
|
||||||
|
relogin (baked `workspace` rules). Switching to a profile without
|
||||||
|
pins leaves workspaces where they are (stale session pins are
|
||||||
|
expected until reload/rebuild — noted in the applier).
|
||||||
|
- [ ] **Display profiles auto-switch, slice b (item 15)** — same setup,
|
||||||
|
then menu › Profiles › Auto-switch on: plugging the dock/monitor
|
||||||
|
applies the matching profile within ~3s (toast names it);
|
||||||
|
unplugging switches back; Auto-switch off stops that; with two
|
||||||
|
profiles naming the same outputs, no flapping (ties do nothing).
|
||||||
|
- [ ] **P2 retunes eyeball (item 28b)** — spot-check on top of the P1
|
||||||
|
entry below: gruvbox/nord/lumon/everforest/retro-82/white dimmed
|
||||||
|
text (inactive workspaces, muted volume) now visible-but-dim;
|
||||||
|
latte + rose-pine(dawn) + summer-day warn/battery tint reads on
|
||||||
|
the light bases; flexoki-light statuses are the deeper canonical
|
||||||
|
600s; latte selected-row/alt-accent pink darker. Anything that
|
||||||
|
lost its "dim" feel → reopen 28b.
|
||||||
|
- [ ] **Retuned palettes eyeball (item 28b P1)** — switch through
|
||||||
|
summer-day, flexoki-light, kanagawa, miasma: chips/menus/toasts
|
||||||
|
now draw on a *raised* surface (summer-day's was slate-on-cream,
|
||||||
|
kanagawa's near-black, miasma's pure black), secondary text
|
||||||
|
(tooltips, fastfetch labels) is visible on summer-day +
|
||||||
|
flexoki-light, kanagawa floats are lighter than the bg (upstream
|
||||||
|
sumiInk4). Anything that reads worse than before → reopen 28b.
|
||||||
|
- [ ] **Clock zone tooltip (LATER item)** — hover the bar clock: the
|
||||||
|
tooltip's first line shows the zone ("BST (UTC+0100)") above the
|
||||||
|
calendar, on the generated AND summer bars; with auto-timezone
|
||||||
|
on, a zone change updates it (the SIGUSR2 reload).
|
||||||
|
- [ ] **Doctor bar tripwire (LATER item)** — with everything healthy
|
||||||
|
the bar shows nothing; `systemctl --user start doomed`-style
|
||||||
|
induced failure → within ~5 min a red appears (tooltip lists
|
||||||
|
the ✖ lines), clicking opens the sheet in a terminal;
|
||||||
|
reset-failed → it disappears on the next poll. Also visible on
|
||||||
|
both summer bars.
|
||||||
|
- [ ] **OCR region (LATER item)** — Tools › Capture › "OCR region →
|
||||||
|
clipboard": select a region with visible text → toast reports
|
||||||
|
the word count and `wl-paste` yields the text; Esc in slurp
|
||||||
|
cancels silently; a text-free region toasts "No text recognized"
|
||||||
|
without clobbering the clipboard.
|
||||||
|
- [ ] **Look & Feel submenu (item 19)** — root menu: "Look & Feel"
|
||||||
|
(replacing Theme, root still six rows) → Theme grid opens, Next
|
||||||
|
wallpaper cycles instantly, Night light toggles (and is GONE
|
||||||
|
from System). SUPER+T / SUPER+SHIFT+T direct binds unchanged.
|
||||||
|
- [ ] **Launch-or-focus (item 17)** — uncomment the template's
|
||||||
|
`nomarchy.launchOrFocus` firefox example (with firefox
|
||||||
|
installed), `home-update` + reload: SUPER+B launches firefox
|
||||||
|
when closed, FOCUSES the existing window when open (also from
|
||||||
|
another workspace); with firefox removed the bind fires a
|
||||||
|
"not installed" toast; the row shows under SUPER+?.
|
||||||
|
- [ ] **Themed greeter + console (item 16)** — after `sys-rebuild` +
|
||||||
|
logout: tuigreet renders in theme colors (dark container, accent
|
||||||
|
border, themed prompt/time) and a raw tty (CTRL+ALT+F2) shows the
|
||||||
|
theme's ANSI palette; on a LUKS machine the passphrase prompt
|
||||||
|
follows too. Then switch theme + `sys-rebuild` + logout → the
|
||||||
|
greeter follows the new palette. (tuigreet can't run under the
|
||||||
|
VM harness — see MEMORY.md — so rendering is hardware-tier.)
|
||||||
|
- [ ] **Color picker (item 13, final slice)** — SUPER+CTRL+P (or Tools ›
|
||||||
|
Color picker): hyprpicker's zoom loupe appears; click a pixel →
|
||||||
|
toast shows the hex and `wl-paste` yields it; Esc cancels with no
|
||||||
|
toast. Row present in the SUPER+? cheatsheet.
|
||||||
|
- [ ] **Low-battery toasts (item 13 slice)** — on battery, drain past
|
||||||
|
25%: one "Battery low" toast; past 10%: a critical "Battery
|
||||||
|
critical" toast that stays up until dismissed (swaync); plug in,
|
||||||
|
drain again → it re-notifies; no repeat toasts while it just keeps
|
||||||
|
draining. (Crossing logic VM-verified — this checks the real
|
||||||
|
swaync rendering in a session.)
|
||||||
|
|
||||||
|
- [ ] **Battery charge-limit toggle** (iteration #55) — control-center
|
||||||
|
(menu › System › Control Center › System Toggles › Battery Limit):
|
||||||
|
the preset picker (Off / 80% / 90% / 60% / Custom…) writes the value
|
||||||
|
(`nomarchy-theme-sync get settings.power.batteryChargeLimit` reflects
|
||||||
|
the pick); after a `sys-rebuild` the sysfs
|
||||||
|
`charge_control_end_threshold` reads it. (UX-only over the already
|
||||||
|
VM-verified writer — a session sanity pass, not a deep check.)
|
||||||
|
- [ ] **Waybar hides under fullscreen video** (item 30) — after
|
||||||
|
`home-update` + relogin, put a browser video (YouTube) into
|
||||||
|
fullscreen (F): the bar is now *covered* by the video, not drawn
|
||||||
|
on top; exit fullscreen → the bar returns. Normal tiling
|
||||||
|
unchanged (the bar still reserves its space). Accepted trade-off
|
||||||
|
of `layer: bottom`: a floating window dragged over the top strip
|
||||||
|
can now overlap the bar — confirm that's the only regression.
|
||||||
|
- [ ] **Battery limit in rofi System** (iteration #64, item 36a) — after
|
||||||
|
`home-update`: menu › System shows a "Battery limit" row (this laptop
|
||||||
|
exposes the threshold node); picking a preset (80/90/60/Off/Custom)
|
||||||
|
writes it (`nomarchy-theme-sync get settings.power.batteryChargeLimit`
|
||||||
|
reflects the pick) and after a `sys-rebuild` the sysfs
|
||||||
|
`charge_control_end_threshold` matches. The gum control-center no
|
||||||
|
longer lists Battery Limit (moved, not duplicated).
|
||||||
|
- [ ] **Config dialogs float** (iteration #63, item 41 cut) — after
|
||||||
|
`home-update` + relogin: open Bluetooth (blueman-manager) and, if
|
||||||
|
printing is on, System ▸ Printers (system-config-printer) — each
|
||||||
|
opens floating + centered, not tiled. If either still tiles, run
|
||||||
|
`hyprctl clients` while it's open, read the real `class`, and fix the
|
||||||
|
regex. While there, capture the polkit prompt's and a GTK file
|
||||||
|
dialog's class for the next 41 slice.
|
||||||
|
- [ ] **Right-click volume → floating mixer** (iteration #62, item 35) —
|
||||||
|
after `home-update` + relogin: right-click the Waybar volume module
|
||||||
|
opens pwvucontrol as a floating, centered window (not tiled);
|
||||||
|
left-click still mutes. Verify across a whole-swap theme too
|
||||||
|
(summer/boreal/executive-slate) since the jsoncs got the same wiring.
|
||||||
|
- [ ] **Window focus on arrows** (iteration #61) — after `home-update` +
|
||||||
|
relogin: SUPER+←/→/↑/↓ move focus between tiled windows; SUPER+H/J/K/L
|
||||||
|
no longer move focus; SUPER+? cheatsheet shows the arrow glyphs.
|
||||||
|
- [ ] **Audio opens in Amberol** (iteration #60, item 37) — after
|
||||||
|
`home-update`: double-click an mp3/flac/ogg (or `xdg-open song.mp3`)
|
||||||
|
→ it opens in Amberol, not mpv; video files still open in mpv.
|
||||||
|
(`xdg-mime query default audio/mpeg` → io.bassi.Amberol.desktop.)
|
||||||
|
- [ ] **Capture-to-file keybinds** (iteration #59, item 38) — after
|
||||||
|
`home-update` + relogin: SHIFT+Print prompts a region select then
|
||||||
|
saves a PNG under ~/Pictures/Screenshots (toast shows the path);
|
||||||
|
CTRL+Print saves the whole screen the same way; bare Print still
|
||||||
|
copies a region to the clipboard. Both new rows appear in SUPER+?.
|
||||||
|
- [ ] **rofi menu polish** (iteration #58, item 39 + #56/#57 34/40) —
|
||||||
|
after `home-update`: (a) menu › System › Power profile shows a
|
||||||
|
colored icon per profile (performance/balanced/power-saver), not
|
||||||
|
just text — icons *render* (not blank); (b) every submenu's ↩ Back
|
||||||
|
shows a single arrow, no double; (c) on this single-monitor box,
|
||||||
|
System › Display → pick a resolution → Back returns to System
|
||||||
|
(not back into the same resolution list).
|
||||||
|
|
||||||
|
## AMD dev box only
|
||||||
|
- [ ] **AMD runtime bits** — VA-API (`vainfo` → radeonsi), amd-pstate EPP
|
||||||
|
active and PPD switching governors; opt-ins: ROCm (`rocminfo`, a GPU
|
||||||
|
PyTorch/Ollama smoke) and the XDNA NPU driver loading.
|
||||||
|
- [ ] **Fingerprint** — `fprintd-enroll` + (opt-in PAM) login/sudo.
|
||||||
|
|
||||||
|
## Latitude 5410 only
|
||||||
|
- [x] **Waybar theme-switch resilience** (finding #1 re-test, needs main ≥
|
||||||
|
the supervisor commit + relogin) — switch themes repeatedly (incl.
|
||||||
|
summer-day/night whole-swaps): the bar restarts cleanly each time;
|
||||||
|
if anything kills it, it's back within ~a second. `pgrep -f
|
||||||
|
nomarchy-waybar` shows the supervisor. — 2026-07-04 Bernardo:
|
||||||
|
PASS — theme switches clean; pkill respawn so fast the bar never
|
||||||
|
visibly disappears.
|
||||||
|
- [x] **Media keys + gestures** (from dccceb4) — volume/brightness keys
|
||||||
|
drive the OSD; touchpad gestures work. — 2026-07-04 Bernardo:
|
||||||
|
PASS, volume/brightness keys and touchpad gestures all working.
|
||||||
|
- [ ] **v1 QA batch on-hardware pass** (583708d batch was QEMU-verified) —
|
||||||
|
general smoke before the next `main → v1` promotion.
|
||||||
|
|
||||||
|
## T14s only
|
||||||
|
- [ ] **Webcam IR-hide end-to-end on Nomarchy** — installer detects the
|
||||||
|
RGB+IR pair, bakes `hardware.camera.hideIrSensor`; `wpctl status`
|
||||||
|
shows one colour source; an app picker lists one camera; Howdy-style
|
||||||
|
direct `/dev/video2` reads still work.
|
||||||
1228
agent/JOURNAL.md
Normal file
147
agent/LOOP.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# The loop — autonomous iteration protocol
|
||||||
|
|
||||||
|
How an AI agent works on Nomarchy unattended. One **iteration** = pick one
|
||||||
|
task, do it, verify it, commit it, record it. The protocol is
|
||||||
|
runner-agnostic; the same iteration works under any of:
|
||||||
|
|
||||||
|
- **Interactive `/loop`** in a Claude Code session in this repo — the agent
|
||||||
|
self-paces iterations until stopped.
|
||||||
|
- **Headless** (`claude -p`, cron/systemd-timer) — one invocation runs one
|
||||||
|
iteration (or a small fixed number) and exits.
|
||||||
|
- **A fresh manual session** — a human says "do a loop iteration"; the
|
||||||
|
files below carry all the state, so any session can pick up where the
|
||||||
|
last left off.
|
||||||
|
|
||||||
|
All loop state lives in this directory, git-tracked. There is no state
|
||||||
|
outside the checkout (the distro's own philosophy, applied to its agents).
|
||||||
|
|
||||||
|
## The files
|
||||||
|
|
||||||
|
| File | Role | Who writes it |
|
||||||
|
|---|---|---|
|
||||||
|
| `GOALS.md` | North star + quality bars + non-goals | Human (agents propose edits) |
|
||||||
|
| `BACKLOG.md` | Prioritized task queue (NOW/NEXT/LATER/PROPOSED/DECISIONS) | Both — see its header rules |
|
||||||
|
| `JOURNAL.md` | Append-only iteration log | Agents |
|
||||||
|
| `MEMORY.md` | Curated durable lessons/gotchas | Agents (curated, not append-only) |
|
||||||
|
| `HARDWARE-QUEUE.md` | Pending on-hardware checks only Bernardo can run | Agents append, human checks off |
|
||||||
|
| `CONVENTIONS.md` | Repo/design conventions to follow while coding | Human (agents propose edits) |
|
||||||
|
|
||||||
|
## Model & token economy
|
||||||
|
|
||||||
|
Spend expensive tokens on judgment, not mechanics.
|
||||||
|
|
||||||
|
- **Plan and reason on the strong model.** Orientation, task selection,
|
||||||
|
design, debugging, Nix eval semantics, verification judgment, and
|
||||||
|
anything that would land in a commit unreviewed stay with the
|
||||||
|
top-tier model running the loop (Fable 5).
|
||||||
|
- **Delegate mechanical subtasks to cheaper models.** When a subtask is
|
||||||
|
fully specified and needs no design judgment — grep/audit sweeps,
|
||||||
|
README-option-table reconciliation, a repeated edit applied across
|
||||||
|
files, summarizing long logs or check output — hand it to a subagent
|
||||||
|
with a `model` override: `haiku` for search/summarize/audit, `sonnet`
|
||||||
|
for routine well-specified edits. The strong model writes the spec,
|
||||||
|
reviews the result, and owns the commit. Only delegate when writing
|
||||||
|
the spec is cheaper than doing the work — a spawned agent starts cold
|
||||||
|
and must re-derive context.
|
||||||
|
- **Read narrowly.** Step 0's list is the whole orientation read (the
|
||||||
|
*last 3–5 entries* of the journal, never the full file). Read large
|
||||||
|
files by section, don't re-read what's already in context, and tail
|
||||||
|
build/check logs instead of dumping them.
|
||||||
|
- **Write tersely.** Journal entries follow the template and no more;
|
||||||
|
commit bodies state what/why/tier, not a narrative.
|
||||||
|
- **Headless runners** may run whole low-stakes iterations (QA sweeps,
|
||||||
|
docs-drift passes) on a cheaper `--model`; iterations touching
|
||||||
|
`modules/` or `pkgs/` behavior keep the strong model.
|
||||||
|
|
||||||
|
## One iteration, step by step
|
||||||
|
|
||||||
|
### 0. Orient
|
||||||
|
1. Read `GOALS.md`, `CONVENTIONS.md`, `MEMORY.md`, the **last 3–5 entries**
|
||||||
|
of `JOURNAL.md`, and `BACKLOG.md`.
|
||||||
|
2. `git pull --ff-only` (skip silently if offline). Confirm you are on
|
||||||
|
`main` with a clean tree. **A dirty tree you didn't create → stop and
|
||||||
|
report; never stash or discard someone else's work.**
|
||||||
|
3. Sanity baseline: if the last journal entry reports a red
|
||||||
|
`nix flake check`, or you have any reason to suspect breakage, run
|
||||||
|
`nix flake check --no-build` first. **A red baseline preempts the
|
||||||
|
backlog — fixing it *is* this iteration's task.**
|
||||||
|
|
||||||
|
### 1. Pick exactly one task
|
||||||
|
- Take the **topmost actionable** item: NOW before NEXT; never LATER
|
||||||
|
unless NOW and NEXT are empty or all blocked.
|
||||||
|
- *Actionable* means: not `[blocked:hw]` (those wait in
|
||||||
|
`HARDWARE-QUEUE.md`), not `[human]` (decisions), and small enough to
|
||||||
|
finish + verify in one iteration. If the top item is too big, **split
|
||||||
|
it in BACKLOG.md** (that edit is part of the iteration) and take the
|
||||||
|
first slice.
|
||||||
|
- Never implement anything from **PROPOSED** — those await human triage.
|
||||||
|
- If nothing is actionable, do a **QA sweep** instead: run the full check
|
||||||
|
suite, hunt drift (README option tables vs the live `nomarchy.*`
|
||||||
|
surface, template drift, dead code), deepen a VM test, or research and
|
||||||
|
write up a PROPOSED item. An iteration that only improves the backlog
|
||||||
|
is a valid iteration. If even that yields nothing, journal it and stop
|
||||||
|
— do not manufacture churn.
|
||||||
|
|
||||||
|
### 2. Work
|
||||||
|
- Keep the diff focused on the task. Unrelated fixes you trip over become
|
||||||
|
PROPOSED/NOW entries, not scope creep.
|
||||||
|
- Mechanical, fully-specified sub-steps go to a cheaper model
|
||||||
|
(see *Model & token economy* above); design and review stay here.
|
||||||
|
- Follow `CONVENTIONS.md`. Match the surrounding hand-formatting; never
|
||||||
|
run a formatter.
|
||||||
|
- New gotcha discovered the hard way → one line in `MEMORY.md` now, while
|
||||||
|
it's fresh.
|
||||||
|
|
||||||
|
### 3. Verify — the ladder
|
||||||
|
Climb as high as the change warrants and your environment allows; **record
|
||||||
|
the tier reached** in the commit body and journal entry.
|
||||||
|
|
||||||
|
| Tier | What | When required |
|
||||||
|
|---|---|---|
|
||||||
|
| **V0** | `nix flake check --no-build` (+ `bash -n` / `py_compile` for scripts) | Every change, no exceptions |
|
||||||
|
| **V1** | Build the touched output: `system.build.toplevel`, the HM generation, the ISO, or the package | Anything beyond docs/comments |
|
||||||
|
| **V2** | VM: a `checks.*` runNixOSTest (add one if the change is guardable), or boot `tools/test-live-iso.sh` / `tools/test-install.sh` | Behavioral changes — services, boot, installer, session |
|
||||||
|
| **V3** | Real hardware | Cannot be done by the agent → append to `HARDWARE-QUEUE.md` with exact test steps |
|
||||||
|
|
||||||
|
The honesty rule governs: a visual/interactive change verified only to V1
|
||||||
|
is **not done** — it ships as "V1-verified, V2/V3 pending" with the pending
|
||||||
|
check queued. Prefer *adding a permanent `checks.*` test* over a one-off
|
||||||
|
manual VM poke when the behavior is testable headlessly (see MEMORY.md for
|
||||||
|
the reusable recipes).
|
||||||
|
|
||||||
|
### 4. Commit + push
|
||||||
|
- Style: match the log — `feat(scope): …`, `fix(scope): …`,
|
||||||
|
`test(scope): …`, `docs(scope): …`. Body explains what/why + the
|
||||||
|
verification tier reached and what remains.
|
||||||
|
- Include the `agent/` bookkeeping updates (backlog/journal/memory/queue)
|
||||||
|
**in the same commit** as the change they describe.
|
||||||
|
- Commit directly on `main` and `git push` (Bernardo's standing workflow).
|
||||||
|
- **Never:** force-push; touch the `v1` branch or any branch/tag you
|
||||||
|
didn't create; commit secrets or binaries; run `nix flake update`
|
||||||
|
unless the task is explicitly a lock bump; delete themes, wallpapers,
|
||||||
|
or user-facing assets without the backlog saying so.
|
||||||
|
|
||||||
|
### 5. Record
|
||||||
|
1. Mark the task in `BACKLOG.md` (move to its ✓ line or delete, per that
|
||||||
|
file's rules).
|
||||||
|
2. Append a `JOURNAL.md` entry (template in that file).
|
||||||
|
3. Queue any V3 checks in `HARDWARE-QUEUE.md`.
|
||||||
|
|
||||||
|
### 6. Pace (self-paced runners only)
|
||||||
|
Under `/loop`, continue to the next iteration while tasks remain
|
||||||
|
actionable and checks stay green. Stop the loop when: nothing is
|
||||||
|
actionable, the same task has failed twice (journal the failure analysis
|
||||||
|
and mark the item `[stuck]`), or a `[human]` decision blocks everything
|
||||||
|
remaining.
|
||||||
|
|
||||||
|
## Stop-and-escalate conditions (any runner)
|
||||||
|
|
||||||
|
Write a journal entry + a BACKLOG note, then stop, when:
|
||||||
|
- A fix would require touching `v1`, force-pushing, or a nixpkgs release
|
||||||
|
jump.
|
||||||
|
- The working tree contains uncommitted work you didn't create.
|
||||||
|
- A task turns out to need a design decision Bernardo hasn't made → move
|
||||||
|
it to **Decisions** in BACKLOG.md with the options laid out.
|
||||||
|
- Two consecutive iterations failed on the same task (`[stuck]`).
|
||||||
|
- Anything would delete or rewrite user data, git history, or the state
|
||||||
|
file schema in a non-backward-compatible way.
|
||||||
102
agent/MEMORY.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Memory — durable lessons, learned the hard way
|
||||||
|
|
||||||
|
Curated, not append-only: one line per fact, newest at the top of its
|
||||||
|
section; delete entries that stop being true. Details usually live in a
|
||||||
|
docs/ROADMAP.md decision record — pointer given as (§ item). Add a fact
|
||||||
|
here the moment a debugging session teaches you something a future
|
||||||
|
iteration would otherwise rediscover.
|
||||||
|
|
||||||
|
## Testing & VM recipes
|
||||||
|
- **tuigreet dies silently under runNixOSTest** (even bare, no theme
|
||||||
|
flag: greetd sits as "(greetd)" with no child, nothing in the
|
||||||
|
journal — its stderr goes to the VT) — nixpkgs' own greetd test uses
|
||||||
|
agreety instead. Greeter *rendering* is interactive-ISO/hardware
|
||||||
|
tier; don't burn another session on a checks.greeter VM test.
|
||||||
|
- In VM tests `pgrep -f PATTERN` can match the test backdoor's own
|
||||||
|
`bash -c` wrapper (the pattern is in its cmdline) — use `pgrep -x`
|
||||||
|
or a `[t]uigreet`-style bracket pattern.
|
||||||
|
- A checks.* fixture CANNOT be a writeText/toFile state file read at
|
||||||
|
eval time ("path … is not valid" — flake check's eval store won't
|
||||||
|
realise it): extract the logic into a pure importable file and
|
||||||
|
unit-test THAT (monitor-rules.nix / checks.display-profiles is the
|
||||||
|
pattern).
|
||||||
|
- CI (`.gitea/workflows/check.yml`) is **eval-tier only**: the act_runner
|
||||||
|
is a docker container (no systemd, no /dev/kvm). Container gotchas are
|
||||||
|
documented in the workflow header (single-user Nix + nixbld users,
|
||||||
|
`sandbox=false` for Stylix IFD, Nix pinned 2.31.5 vs lazy-trees, no JS
|
||||||
|
actions past node20) — learned over the legacy repo's 57 runs; read
|
||||||
|
them before touching the workflow.
|
||||||
|
- The Gitea instance is **1.25.4** — `on: schedule` workflows are
|
||||||
|
supported; bump.yml assumes the Actions token can push to `main`
|
||||||
|
(standard Gitea behaviour, but unconfirmed until the first run lands).
|
||||||
|
- The git server is **Gitea** (gitea/act_runner via docker-compose), NOT
|
||||||
|
Forgejo — workflows are read from `.gitea/workflows/` (or `.github/`),
|
||||||
|
never `.forgejo/workflows/` (a whole push cycle was lost to that).
|
||||||
|
- Reusable headless VM harness: `checks.*` via runNixOSTest — existing
|
||||||
|
examples to crib from: `distro-id` (boots + `switch-to-configuration
|
||||||
|
dry-activate`), `hardware-toggles` (kernel cmdline/PAM assertions),
|
||||||
|
`battery-charge-limit` (fake Mains adapter via `test_power`, real udev
|
||||||
|
uevent, `InvocationID` change proves the restart).
|
||||||
|
- Themed-desktop screenshots work headlessly: software-GL Hyprland
|
||||||
|
(`LIBGL_ALWAYS_SOFTWARE` on virtio-gpu) + `machine.screenshot()` QMP
|
||||||
|
dump — prototyped 2026-06-19, kept as the fallback for theme previews
|
||||||
|
(§ Visual theme picker).
|
||||||
|
- Hyprland/Ghostty need guest GL (`virtio-vga-gl`, `gl=on`) in
|
||||||
|
interactive QEMU or the session won't start; black screen ≈ missing GL
|
||||||
|
(docs/TESTING.md § gotchas).
|
||||||
|
- No KVM = slow, not broken; don't read slowness as failure.
|
||||||
|
|
||||||
|
## Known-broken / watchlist
|
||||||
|
- **btrfs-assistant "segfault" was unprivileged-only** (re-diagnosed
|
||||||
|
2026-07-04): libbtrfsutil's unprivileged subvolume iteration crashes on
|
||||||
|
btrfs-progs 6.17.1 (upstream-fixed after); **as root it works**, and the
|
||||||
|
pkexec launcher runs it as root. The real distro bug was **no polkit
|
||||||
|
agent in the session** (every pkexec failed silently) — hyprpolkitagent
|
||||||
|
now ships (hyprland.nix exec-once). `checks.snapshot-gui` guards the
|
||||||
|
root path. Lesson: before "app X is broken", check WHO it runs as — and
|
||||||
|
whether polkit prompts can render at all (§ Snapshot browse/restore).
|
||||||
|
- **NixOS release bump is a trap:** the discarded attempt
|
||||||
|
(branch deleted 2026-06-22) hit a Hyprland OOM blocker; a redo is a
|
||||||
|
deliberate `v2`, never part of routine lock bumps.
|
||||||
|
- `theme-state.json` is git-tracked inside an 86 MB flake tree, so every
|
||||||
|
state write re-copies the source before eval — the wallpapers-artifact
|
||||||
|
split (BACKLOG LATER) is the decided fix (§ Faster switches).
|
||||||
|
|
||||||
|
## Gotchas (cost a debugging session once)
|
||||||
|
- Waybar `layer: top` renders above **even real-fullscreen windows** — the
|
||||||
|
bar draws over a fullscreen video. `layer: bottom` lets the fullscreen
|
||||||
|
surface cover it while the exclusive zone still reserves the bar's space
|
||||||
|
in normal tiling (trade-off: floating windows can now overlap the bar
|
||||||
|
strip). Set in both waybar.nix and every whole-swap jsonc (item 30).
|
||||||
|
- Hyprland binds match the exact modmask: a shifted keysym (`question`)
|
||||||
|
needs SHIFT in `mods` or the bind never fires — the keypress falls
|
||||||
|
through to the focused window (§ item 26; caught on hardware, invisible
|
||||||
|
to eval-tier tests).
|
||||||
|
- Never kill a Wayland session-lock client (hyprlock): its crash
|
||||||
|
failsafe drops to a tty instead of unlocking (§ Hibernate
|
||||||
|
double-unlock).
|
||||||
|
- rofi `element-icon size` is one value = a square cell; `WxH` silently
|
||||||
|
collapses and non-square icons letterbox — pre-crop images square at
|
||||||
|
build (§ Visual theme picker).
|
||||||
|
- WirePlumber 0.5 monitor rules can only early-match `device.api`;
|
||||||
|
`device.product.name` etc. bind *after* the rule runs — surgical
|
||||||
|
libcamera scoping is impossible (§ Webcam).
|
||||||
|
- `hyprctl switchxkblayout` is a *global* layout flip; per-device isolation
|
||||||
|
needs `device[<name>]:kb_layout` keywords (§ Keyboard layouts).
|
||||||
|
- Waybar's clock captures the timezone at construction — a zone change
|
||||||
|
needs SIGUSR2 (watcher in `timezone.nix`) (§ Automatic timezone).
|
||||||
|
- Waybar `persistent_workspaces` (underscore) is dead syntax silently
|
||||||
|
ignored; the hyphen form is honoured and renders phantom workspaces
|
||||||
|
(§ Waybar shows non-existent workspaces).
|
||||||
|
- GTK4/libadwaita/Qt6 read light/dark from the portal's
|
||||||
|
`org.freedesktop.appearance color-scheme` (dconf), not Stylix polarity
|
||||||
|
(§ GTK/Qt ignore the theme's mode).
|
||||||
|
- Update order matters downstream: `sys-update` (lock) before
|
||||||
|
`home-update`, or desktop changes are silently skipped against the old
|
||||||
|
lock (README § 3).
|
||||||
|
- grub `loadfont`s every `.pf2` in a theme dir — reuse a bundled DejaVu
|
||||||
|
rather than shipping fonts (§ Distro branding).
|
||||||
|
- The Claude Code harness drops machine-local files into the repo
|
||||||
|
(`.claude/skills/…`) mid-session — never `git add -A` blindly; check
|
||||||
|
`git status --short` for strangers first (they're gitignored now, but
|
||||||
|
the next surprise won't be) (§ loop hygiene).
|
||||||
@@ -39,7 +39,7 @@ in your `home.nix` wins — no `mkForce` needed:
|
|||||||
input.follow_mouse = 0; # was 1
|
input.follow_mouse = 0; # was 1
|
||||||
input.touchpad.natural_scroll = false;
|
input.touchpad.natural_scroll = false;
|
||||||
misc.disable_splash_rendering = false;
|
misc.disable_splash_rendering = false;
|
||||||
monitor = [ "DP-1,2560x1440@144,0x0,1" ]; # replaces the default rule
|
monitor = [ "DP-1,2560x1440@144,0x0,1" ]; # raw rule — replaces the default
|
||||||
animations.enabled = false;
|
animations.enabled = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,6 +50,11 @@ in your `home.nix` wins — no `mkForce` needed:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For monitor layout, prefer the friendlier **`nomarchy.monitors`** (a list of
|
||||||
|
per-output submodules — resolution/position/scale/rotation — turned into
|
||||||
|
Hyprland rules and applied on hotplug; run `nwg-displays` to find the values
|
||||||
|
interactively). Assigning `settings.monitor` directly, as above, replaces it.
|
||||||
|
|
||||||
### Adding vs. overriding lists
|
### Adding vs. overriding lists
|
||||||
|
|
||||||
`bind`, `bindel`, `bindl`, `bindm` and `exec-once` are lists kept at normal
|
`bind`, `bindel`, `bindl`, `bindm` and `exec-once` are lists kept at normal
|
||||||
@@ -140,6 +145,7 @@ value is theme-owned at normal priority — either change it via the CLI (§1) o
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Change gaps / colors / rounding / fonts | `nomarchy-theme-sync set …` or `apply` |
|
| Change gaps / colors / rounding / fonts | `nomarchy-theme-sync set …` or `apply` |
|
||||||
| Change input / misc / monitor / animations / terminal chrome | plain assignment in `home.nix` |
|
| Change input / misc / monitor / animations / terminal chrome | plain assignment in `home.nix` |
|
||||||
|
| Arrange monitors declaratively | `nomarchy.monitors` (values via `nwg-displays`) |
|
||||||
| Add keybinds / autostarts | add to the `bind` / `exec-once` list (concatenates) |
|
| Add keybinds / autostarts | add to the `bind` / `exec-once` list (concatenates) |
|
||||||
| Replace all keybinds | `bind = lib.mkForce [ … ]` |
|
| Replace all keybinds | `bind = lib.mkForce [ … ]` |
|
||||||
| Hardcode an appearance value against the theme | `lib.mkForce` in `home.nix` |
|
| Hardcode an appearance value against the theme | `lib.mkForce` in `home.nix` |
|
||||||
|
|||||||
92
docs/RECOVERY.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Recovery runbook — when something breaks
|
||||||
|
|
||||||
|
Ordered from "the desktop looks wrong" to "the machine won't boot".
|
||||||
|
Everything here already ships on an installed machine — you don't need a
|
||||||
|
live USB until the last resort. The theme is always the same: **nothing
|
||||||
|
in Nomarchy is destroyed by a bad change** — every rebuild is a NixOS /
|
||||||
|
Home Manager generation you can step back to, and (on BTRFS installs)
|
||||||
|
snapper keeps file-level history on top.
|
||||||
|
|
||||||
|
If the graphical session is unusable, a text console is one keystroke
|
||||||
|
away: **Ctrl+Alt+F2** gives a TTY login (the session itself runs on
|
||||||
|
tty1); log in with your normal user.
|
||||||
|
|
||||||
|
## 1. A theme or desktop change broke the session
|
||||||
|
|
||||||
|
Theme applies and `home-update` are Home Manager switches — one
|
||||||
|
generation each, so the previous desktop is still on disk:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
home-manager generations # newest first, one per theme/HM change
|
||||||
|
/nix/store/…-home-manager-generation/activate # run the one you want
|
||||||
|
```
|
||||||
|
|
||||||
|
The same picker lives in the menu: **System › Rollback** lists the
|
||||||
|
recent desktop generations — pick one and it activates.
|
||||||
|
|
||||||
|
Or simply apply a theme you know is good: `nomarchy-theme-sync apply
|
||||||
|
tokyo-night`. If a switch failed halfway, the state file is written
|
||||||
|
*before* the rebuild — fix the cause and re-run
|
||||||
|
`home-manager switch --flake ~/.nomarchy` (or `home-update`).
|
||||||
|
|
||||||
|
Your flake checkout is a git repo, and with auto-commit enabled every
|
||||||
|
apply is a commit: `git -C ~/.nomarchy log` to see what changed,
|
||||||
|
`git revert` the culprit, then `home-update`.
|
||||||
|
|
||||||
|
## 2. The desktop won't start at all
|
||||||
|
|
||||||
|
Greeter loops, black screen after the password, session exits straight
|
||||||
|
back to tuigreet — from the Ctrl+Alt+F2 TTY:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
journalctl -b -u greetd # did the session command launch?
|
||||||
|
journalctl --user -b # Hyprland + the user services
|
||||||
|
```
|
||||||
|
|
||||||
|
- Rolling back the *desktop* half is §1 (works from the TTY).
|
||||||
|
- If greetd/tuigreet itself is broken, that's system-side → §3.
|
||||||
|
- In a **VM**, a black screen is almost always missing guest OpenGL,
|
||||||
|
not your config — see docs/TESTING.md §5.
|
||||||
|
- First boot after an install came up *unthemed*: read
|
||||||
|
`/var/log/nomarchy-hm-preactivate.log` on the installed system.
|
||||||
|
|
||||||
|
## 3. A system change broke it — boot an older generation
|
||||||
|
|
||||||
|
Reboot and pick an older **NixOS generation** in the systemd-boot menu
|
||||||
|
(hold a key during firmware handoff if the menu doesn't linger; the
|
||||||
|
last 10 generations are kept). That boots yesterday's system unchanged.
|
||||||
|
|
||||||
|
Booting an old generation is temporary — the default entry is still the
|
||||||
|
broken one. Make the fix stick from the working boot: revert the change
|
||||||
|
in `~/.nomarchy` (`git -C ~/.nomarchy revert …` or edit `system.nix`
|
||||||
|
back), then `sys-rebuild`.
|
||||||
|
|
||||||
|
## 4. Files went missing or wrong — snapshots (BTRFS installs)
|
||||||
|
|
||||||
|
With `nomarchy.system.snapper.enable` (the installer's default on
|
||||||
|
BTRFS), the root filesystem has hourly/daily history, and
|
||||||
|
`nixos-rebuild-snap` leaves a snapshot right before a rebuild:
|
||||||
|
|
||||||
|
- **GUI:** menu › System › Snapshots (btrfs-assistant; expects a polkit
|
||||||
|
password prompt).
|
||||||
|
- **Terminal/SSH:** `sudo nomarchy-snapshots` — browse a snapshot's
|
||||||
|
diff, **restore changed files** (snapper `undochange`), or **roll the
|
||||||
|
whole root back** to a snapshot and reboot. Both destructive actions
|
||||||
|
sit behind a typed-`yes` confirmation.
|
||||||
|
|
||||||
|
Snapshots are the undo for *data on disk*; the Nix config model is
|
||||||
|
undone by generations (§1/§3) — use each for its half.
|
||||||
|
|
||||||
|
## 5. Last resort — from the outside
|
||||||
|
|
||||||
|
Boot the Nomarchy ISO (any NixOS ISO works), then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo mount /dev/<root> /mnt # + /mnt/boot; LUKS: cryptsetup open first
|
||||||
|
sudo nixos-enter --root /mnt # chroot with nix available
|
||||||
|
```
|
||||||
|
|
||||||
|
From there you have the full toolbox: `nixos-rebuild boot --flake
|
||||||
|
/home/<you>/.nomarchy#default` after fixing the flake, or snapper from
|
||||||
|
§4. If you get this far with something Nomarchy shipped broken, please
|
||||||
|
file it.
|
||||||
606
docs/ROADMAP.md
@@ -5,6 +5,12 @@ Forward-looking plans, plus a running log of shipped fixes (the
|
|||||||
stays a focused entry point — what Nomarchy is, how to install it, and
|
stays a focused entry point — what Nomarchy is, how to install it, and
|
||||||
how to override it. Items marked ✓ are shipped.
|
how to override it. Items marked ✓ are shipped.
|
||||||
|
|
||||||
|
> **The live, prioritized queue now lives in [`agent/BACKLOG.md`](../agent/BACKLOG.md)**
|
||||||
|
> (part of the autonomous-agent loop, `agent/LOOP.md`). This file remains
|
||||||
|
> the detailed design/decision record and the shipped log — backlog items
|
||||||
|
> reference it as "ROADMAP § <item>". When a backlog item ships, its
|
||||||
|
> lasting design notes get a ✓ entry here.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
- **Menu system** (apps launcher + theme switching + system actions), built
|
- **Menu system** (apps launcher + theme switching + system actions), built
|
||||||
on rofi 2.0 (native Wayland on 26.05) — its `.rasi` theme is baked from
|
on rofi 2.0 (native Wayland on 26.05) — its `.rasi` theme is baked from
|
||||||
@@ -51,6 +57,11 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
one-off — any new feature that earns a menu entry must be placed in the
|
one-off — any new feature that earns a menu entry must be placed in the
|
||||||
right submenu (don't let the root creep back to a flat list), with its
|
right submenu (don't let the root creep back to a flat list), with its
|
||||||
direct `SUPER+CTRL+<mnemonic>` bind and self-gating as applicable.
|
direct `SUPER+CTRL+<mnemonic>` bind and self-gating as applicable.
|
||||||
|
- ✓ **Back everywhere:** every list menu now ends with a `↩ Back` entry that
|
||||||
|
returns one level up (power/theme/power-profile/clipboard/files/capture,
|
||||||
|
not just Tools/System), so you never have to Esc out and reopen. A `back`
|
||||||
|
helper + a shared `BACK` label keep it uniform; matched exactly so it can't
|
||||||
|
collide with clipboard/filename content. Esc still quits instantly.
|
||||||
- ✓ **Menu modules from rofi plugins:** the old `calc` flow committed the
|
- ✓ **Menu modules from rofi plugins:** the old `calc` flow committed the
|
||||||
expression blind (result only in the *next* menu's `-mesg`) and `qalc -t`
|
expression blind (result only in the *next* menu's `-mesg`) and `qalc -t`
|
||||||
misparsed common phrasings (`15% of 200` → `rem(15, 1 B)`, the natural-
|
misparsed common phrasings (`15% of 200` → `rem(15, 1 B)`, the natural-
|
||||||
@@ -67,25 +78,136 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
launcher, and yazi (SUPER+E) already covers real browsing.
|
launcher, and yazi (SUPER+E) already covers real browsing.
|
||||||
- **More menu modules from rofi tools:** the script-based counterparts
|
- **More menu modules from rofi tools:** the script-based counterparts
|
||||||
(run via `rofi -dmenu`, like the hand-rolled modules), each a deliberate
|
(run via `rofi -dmenu`, like the hand-rolled modules), each a deliberate
|
||||||
replacement of an existing flow: **rofi-network-manager** (a keyboard
|
replacement of an existing flow.
|
||||||
wifi/VPN picker vs today's `nmtui`-in-terminal `network`), **rofi-rbw /
|
- ✓ **Network** (`networkmanager_dmenu`): a native rofi wifi/VPN picker
|
||||||
rofi-pass** (a secrets module — Bitwarden via rbw, or `pass` — pairs with
|
replacing the old `nmtui`-in-terminal `network` flow. Configured (xdg
|
||||||
`keys.nix`), and **rofi-pulse-select** (an audio sink/source switcher).
|
`networkmanager-dmenu/config.ini`) to drive rofi with `rofi_highlight`
|
||||||
Decide per-module whether it earns replacing the current path.
|
for connected/available rows; editing a connection drops to nmtui in the
|
||||||
|
terminal. Inherits the generated theme like every other module.
|
||||||
|
- ✓ **Audio** (`rofi-pulse-select`): a PipeWire/pulse sink/source switcher
|
||||||
|
under System → Audio (Output/Input), self-gated on the pulse socket.
|
||||||
|
- Deferred: **rofi-rbw / rofi-pass** secrets module — no Bitwarden/`pass`
|
||||||
|
setup here today (secrets are gpg/ssh + gnome-keyring, see `keys.nix`),
|
||||||
|
so it'd mean adopting a new secret manager. Revisit if that changes.
|
||||||
|
- Also: menu search is now **case-insensitive fuzzy** (`matching = fuzzy`,
|
||||||
|
`sorting-method = fzf`) across every module + the launcher.
|
||||||
|
- ✓ **Printer setup menu module:** a `nomarchy-menu printers` entry in the
|
||||||
|
**System** submenu opens **system-config-printer** (the CUPS admin GUI —
|
||||||
|
discovery, drivers/PPDs, options, test page), mirroring the bluetooth/blueman
|
||||||
|
pattern: the printing service ships the package (`environment.systemPackages`)
|
||||||
|
and the menu execs it, self-gated on the `system-config-printer` binary so
|
||||||
|
the entry appears only when `nomarchy.services.printing` is on. No direct
|
||||||
|
`SUPER+CTRL` bind — printer setup is a rare one-off, not a frequent utility
|
||||||
|
(left out deliberately; easy to add). Chose the GUI over a rofi-native
|
||||||
|
`lpadmin` flow (driver/PPD picking is impractical in rofi) and the CUPS web
|
||||||
|
UI (an unthemed browser page). Validated: the generated menu script's
|
||||||
|
`bash -n` build check passes, and an eval confirms printing-on puts
|
||||||
|
system-config-printer in `systemPackages`. Pending an on-machine check.
|
||||||
|
- ✓ **VPN setup & management menu:** a dedicated **System → VPN** flow
|
||||||
|
(`nomarchy-vpn`, `modules/home/rofi.nix`) that goes past the Network module
|
||||||
|
(`networkmanager_dmenu` only connects/disconnects *existing* VPNs) to a guided
|
||||||
|
setup + management surface across the three common kinds:
|
||||||
|
- **WireGuard:** import a `.conf` into NetworkManager (`nmcli connection import
|
||||||
|
type wireguard file …` — NM handles wg tunnels natively, no plugin) and
|
||||||
|
toggle it up/down.
|
||||||
|
- **OpenVPN:** import an `.ovpn` (`nmcli connection import type openvpn file …`);
|
||||||
|
the `networkmanager-openvpn` plugin ships system-side
|
||||||
|
(`networking.networkmanager.plugins`, mkDefault) so the openvpn type is
|
||||||
|
available — import type is chosen by file extension.
|
||||||
|
- **Tailscale:** status (read-only) + `up`/`down` + **exit-node** selection. It
|
||||||
|
lives outside NetworkManager, so the menu drives the `tailscale` CLI
|
||||||
|
directly, **self-gated** on the CLI being present (= `nomarchy.services.tailscale`,
|
||||||
|
which makes the login user the **operator** via `extraSetFlags`). So
|
||||||
|
up/down/exit-node run **inline without sudo**, falling back to a sudo terminal
|
||||||
|
only if the operator grant is absent; the first interactive login uses a
|
||||||
|
terminal (the auth URL is visible).
|
||||||
|
Shape: a `nomarchy-menu vpn` rofi submenu under **System** — NM
|
||||||
|
VPN/WireGuard connections shown ● active / ○ inactive and toggled on select
|
||||||
|
(networkmanager-group users need no sudo), Import via the Files/`fd` picker
|
||||||
|
(`*.conf`/`*.ovpn`), the Tailscale block when present — ending in `↩ Back`. A
|
||||||
|
self-gating Waybar **`custom/vpn`** shield (`nomarchy-vpn-status`: shown only
|
||||||
|
while a NM tunnel or Tailscale is up; `@good` tone; click opens the submenu),
|
||||||
|
wired into the generated bar **and the summer whole-swaps**. Secrets stay in
|
||||||
|
the connection manager's own store (NetworkManager / Tailscale) — no new secret
|
||||||
|
manager (cf. the deferred rofi-rbw/pass note above). **Decided: import-first**
|
||||||
|
— a from-scratch WireGuard keypair/peer editor is too much rofi surface, so
|
||||||
|
creation is deferred to `nm-connection-editor`. Eval + build green; **pending
|
||||||
|
an on-machine check** (the nmcli import/up-down + Tailscale paths need a live
|
||||||
|
session with real configs). Remaining (optional): richer exit-node display
|
||||||
|
(country/city).
|
||||||
- **Theme parity with legacy:** summer-day/night now carry their legacy
|
- **Theme parity with legacy:** summer-day/night now carry their legacy
|
||||||
bar layouts as `waybar.jsonc` whole-swaps (adapted: dead legacy script
|
bar layouts as `waybar.jsonc` whole-swaps (adapted: dead legacy script
|
||||||
modules dropped, Nerd-Fonts-v2 codepoints remapped to FontAwesome/v3,
|
modules dropped, Nerd-Fonts-v2 codepoints remapped to FontAwesome/v3,
|
||||||
logo button opens nomarchy-menu); the other four identity themes are
|
logo button opens nomarchy-menu); the other four identity themes are
|
||||||
palette recolors and already match. Remaining: a visual pass over all
|
palette recolors and already match. Remaining: a visual pass over all
|
||||||
six on the live ISO
|
six on the live ISO
|
||||||
- **Per-theme rofi identity:** the `themes/<slug>/rofi.rasi` whole-swap
|
- ✓ **Per-theme rofi identity:** the `themes/<slug>/rofi.rasi` whole-swap
|
||||||
ships, and summer-day/night carry their legacy designs (inverted window,
|
ships, and all six identity themes now carry a designed `.rasi`. summer-day/
|
||||||
green inputbar, yellow bottom-border). Remaining: author `.rasi`
|
night keep their legacy ports (inverted window, green inputbar, yellow
|
||||||
identities for the other four ported themes if/when they want one (the
|
bottom-border); the other four were authored from each theme's character
|
||||||
generated palette theme is the default and looks fine)
|
(no legacy layout to port — their waybar whole-swaps are palette-only):
|
||||||
- **Faster switches:** move `backgrounds/` out of the flake source (the 86 MB
|
**nord** a soft rounded "frost panel" (frost border, brighter-frost
|
||||||
re-copy on every state write is the main eval tax), then pre-built theme
|
selection, aurora-purple prompt); **retro-82** a sharp CRT terminal (square
|
||||||
variants if still needed
|
corners, amber-on-navy, a teal scanline underline, mono); **lumon** a
|
||||||
|
clinical cyan "bezel" (thick cyan frame, a *framed*-not-filled readout
|
||||||
|
inputbar, mono); **kanagawa** ink-and-paper (warm washi-paper frame, a
|
||||||
|
deeper ink-well inputbar, crystal-blue wave reserved for the selection).
|
||||||
|
Each is self-contained (it replaces the generated theme) and keeps the
|
||||||
|
element structure the theme-grid picker's per-invocation `-theme-str`
|
||||||
|
layers onto; all four parse clean under `rofi -dump-theme`. The generated
|
||||||
|
palette theme stays the default for the other 15 presets. Remaining: a
|
||||||
|
visual pass over the four on hardware (the parse check confirms syntax, not
|
||||||
|
aesthetics).
|
||||||
|
- ✓ **Visual theme picker (preview thumbnails):** `nomarchy-menu theme` is now
|
||||||
|
a rofi **icon grid of real desktop previews** instead of a plain-text list —
|
||||||
|
each theme a screenshot of its themed desktop (waybar + floating terminal),
|
||||||
|
pretty name beneath, **grouped dark-first then light** in one scrollable grid
|
||||||
|
(the previews make the mode obvious, so no light/dark submenu split), the
|
||||||
|
active theme marked `✓`, ending in `↩ Back`. The grid, the Name→slug map and
|
||||||
|
the active mark are all **generated at eval time** from the preset JSONs in
|
||||||
|
`rofi.nix` (`builtins.readDir` + `fromJSON`); "active" is just `t.slug`, since
|
||||||
|
every switch rebuilds the menu. Grid layout via a per-invocation `-theme-str`
|
||||||
|
(`listview { columns: 3; flow: horizontal; }` so Down scrolls row-by-row, +
|
||||||
|
vertical `element` cards, name centred below). **Sizing gotcha (learned the
|
||||||
|
hard way):** rofi's `element-icon` `size` is a **single value → a square
|
||||||
|
cell** (a two-value `WxH` is silently collapsed), and the icon is *contained*
|
||||||
|
in that square. So a 16:9 preview letterboxes (theme-coloured bands top/
|
||||||
|
bottom), and a cell can't be shorter-than-square without the bands returning.
|
||||||
|
The fix: a build-time imagemagick step **centre-crops each preview to a
|
||||||
|
square** so it fills the cell edge-to-edge; the window width is derived so a
|
||||||
|
column is exactly the icon side (no slack margins). One knob, `themeGridIconW`
|
||||||
|
(240px), drives both icon and window. **Workflow:** Bernardo captures previews
|
||||||
|
on real hardware and commits them as `themes/<slug>/preview.png`, **already
|
||||||
|
downscaled to 480×270** (~2.4 MB total for all 21, vs ~28 MB full-res) — the
|
||||||
|
source stays 16:9 and untouched; only the *displayed* thumb is squared at
|
||||||
|
build, so the crop is reversible. Graceful fallback when a theme has no
|
||||||
|
`preview.png`: a plain-name row, so it degrades cleanly.
|
||||||
|
(A headless VM-render route — `runNixOSTest` + software-GL Hyprland
|
||||||
|
(`LIBGL_ALWAYS_SOFTWARE` on virtio-gpu) + `machine.screenshot()` QMP
|
||||||
|
framebuffer dump — was prototyped 2026-06-19 and **works** (themed waybar
|
||||||
|
rendered + captured); real-hardware capture was chosen for fidelity +
|
||||||
|
simplicity, with the VM route as a documented fallback if hand-capturing all
|
||||||
|
themes gets tedious.)
|
||||||
|
- **Faster switches:** move `backgrounds/` out of the flake source. Diagnosis
|
||||||
|
(confirmed): `themes/` is 86 MB and `backgrounds/` is **all** of it — the
|
||||||
|
palette JSONs + per-theme overrides are only ~208 KB, and the wallpapers are
|
||||||
|
**never read at Nix eval** (only the Python `swww` path uses them). But
|
||||||
|
`theme-state.json` is git-tracked, so every `apply` rewrites it → the flake
|
||||||
|
tree changes → Nix re-copies the whole 86 MB source before `home-manager
|
||||||
|
switch` can evaluate. You pay an 86 MB copy to change a 1 KB file.
|
||||||
|
**Decided approach (deferred — don't want the extra moving part yet):**
|
||||||
|
Option 1, a **separate pinned wallpapers artifact** — a `Nomarchy-wallpapers`
|
||||||
|
repo or release tarball, pulled once via a flake input / `fetchurl` (pinned
|
||||||
|
by hash → content-addressed, never re-copied on a state write), with
|
||||||
|
`nomarchy-theme-sync` reading wallpapers from that stable store path (the
|
||||||
|
`NOMARCHY_DEFAULT_THEMES` env hook already anticipates external theme
|
||||||
|
assets). Keeps eval **pure**; the live ISO still bakes them in (fetched at
|
||||||
|
build). A state write then re-copies only ~208 KB. Rejected alternative:
|
||||||
|
moving `theme-state.json` out to `~/.config` + `--impure` eval (one repo, no
|
||||||
|
second artifact, but trades away the in-tree-pinned-state reproducibility).
|
||||||
|
Follow-on if `home-manager switch` itself is still the bottleneck after the
|
||||||
|
copy is gone: **pre-built theme variants** (build each theme's generation
|
||||||
|
ahead of time so a switch just activates a cached one).
|
||||||
- Greeter (tuigreet/SDDM) theming from the same JSON (Plymouth ships since
|
- Greeter (tuigreet/SDDM) theming from the same JSON (Plymouth ships since
|
||||||
v1: `nomarchy.system.plymouth.*`, background tinted from the state file)
|
v1: `nomarchy.system.plymouth.*`, background tinted from the state file)
|
||||||
- Installer round 2: multi-disk BTRFS RAID, impermanence, BIOS/legacy
|
- Installer round 2: multi-disk BTRFS RAID, impermanence, BIOS/legacy
|
||||||
@@ -99,10 +221,28 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
✓ `isoImage.splashImage` — the vendored vector logo
|
✓ `isoImage.splashImage` — the vendored vector logo
|
||||||
(`modules/nixos/branding/logo.svg`, from legacy) recolored to the palette
|
(`modules/nixos/branding/logo.svg`, from legacy) recolored to the palette
|
||||||
accent on the theme base, built at ISO-build time (`hosts/live.nix`).
|
accent on the theme base, built at ISO-build time (`hosts/live.nix`).
|
||||||
Remaining: `isoImage.grubTheme` so UEFI boot matches the isolinux splash
|
✓ **`isoImage.grubTheme` (UEFI boot matches BIOS):** `hosts/live.nix` now
|
||||||
(needs a full grub theme dir), and the `distroId` question (it changes
|
builds a `nomarchyGrubTheme` dir whose background is the *same* composed
|
||||||
`DEFAULT_HOSTNAME` and upstream `isNixos` checks — needs a test pass;
|
splash image as the isolinux splash (accent logo on base), with a
|
||||||
nixos-* CLI names stay regardless)
|
palette-coloured boot menu in the lower third (clear of the centred logo)
|
||||||
|
and an accent timeout bar. Derived from `nixos-grub2-theme` only to reuse
|
||||||
|
its bundled DejaVu `.pf2` (grub `loadfont`s every `.pf2` in the dir); the
|
||||||
|
stock NixOS `logo.png` is dropped since ours is in the background. Built +
|
||||||
|
structure-verified (theme.txt palette colours, 1920×1080 background, font
|
||||||
|
present). Remaining: a UEFI ISO-boot render check on hardware (the file
|
||||||
|
wiring is confirmed; the visual is not CI-testable, same as the splash).
|
||||||
|
✓ **`distroId = "nomarchy"`:** os-release is now honest — `ID=nomarchy`,
|
||||||
|
`ID_LIKE=nixos` (the standard derivative-distro lineage marker, cf.
|
||||||
|
Ubuntu→debian), `DEFAULT_HOSTNAME=nomarchy`, lsb `DISTRIB_ID`/`CPE_NAME`
|
||||||
|
follow. **Verified safe:** `switch-to-configuration` builds its "is this
|
||||||
|
NixOS?" guard from the *configured* distroId (and `/etc/NIXOS` remains as
|
||||||
|
the fallback), so rebuilds keep working — a new `checks.distro-id`
|
||||||
|
VM-test boots such a system and runs `switch-to-configuration dry-activate`
|
||||||
|
green; nixos-* CLI tools are package names, untouched. The one side effect
|
||||||
|
(isNixos→false blanks the upstream nixos.org URLs) is handled by
|
||||||
|
`extraOSReleaseArgs` restoring `HOME_URL`/`DOCUMENTATION_URL`/`SUPPORT_URL`/
|
||||||
|
`BUG_REPORT_URL` to the project. os-release output eval-verified from the
|
||||||
|
real downstream config.
|
||||||
- ✓ **fastfetch branding:** `modules/home/fastfetch.nix`
|
- ✓ **fastfetch branding:** `modules/home/fastfetch.nix`
|
||||||
(`nomarchy.fastfetch.enable`) — the vendored vector logo, recolored to
|
(`nomarchy.fastfetch.enable`) — the vendored vector logo, recolored to
|
||||||
the palette accent and rendered to compact block-art via chafa at build
|
the palette accent and rendered to compact block-art via chafa at build
|
||||||
@@ -176,8 +316,17 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
comes from the agent's zsh integration; cache TTLs (30 min / 2 h) govern
|
comes from the agent's zsh integration; cache TTLs (30 min / 2 h) govern
|
||||||
re-prompting. gnome-keyring stays the Secret Service (modern versions run
|
re-prompting. gnome-keyring stays the Secret Service (modern versions run
|
||||||
no SSH agent, so no socket contention); screen lock doesn't flush the
|
no SSH agent, so no socket contention); screen lock doesn't flush the
|
||||||
cache. Remaining (optional): a session-level `SSH_AUTH_SOCK` export so GUI
|
cache. ✓ **session-level `SSH_AUTH_SOCK`:** besides the zsh integration,
|
||||||
clients launched outside a shell also see the agent.
|
a `home.sessionVariables` export now covers GUI clients launched outside a
|
||||||
|
shell (rofi launcher, autostarted apps) that never inherit the interactive
|
||||||
|
shell's copy — resolved with `gpgconf --list-dirs agent-ssh-socket` at
|
||||||
|
session-init (the same lookup the shell integration uses, so the two can't
|
||||||
|
drift), reaching GUI apps via the login shell that starts Hyprland the same
|
||||||
|
way `NIXOS_OZONE_WL` does. Verified by building the home generation and
|
||||||
|
inspecting the rendered `hm-session-vars.sh` — it carries
|
||||||
|
`export SSH_AUTH_SOCK="$(…/gpgconf --list-dirs agent-ssh-socket)"` with the
|
||||||
|
command substitution intact (unescaped, resolved at session-init). Pending
|
||||||
|
an on-machine check that a GUI git/ssh client picks up the agent.
|
||||||
- **Sanitize & organize the repo:** a housekeeping pass for consistency
|
- **Sanitize & organize the repo:** a housekeeping pass for consistency
|
||||||
and clarity.
|
and clarity.
|
||||||
- ✓ pruned now-redundant config: the installer no longer writes
|
- ✓ pruned now-redundant config: the installer no longer writes
|
||||||
@@ -204,18 +353,15 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
hand-formatting, so `nixfmt-rfc-style --check` flags ~33 files. Adopting a
|
hand-formatting, so `nixfmt-rfc-style --check` flags ~33 files. Adopting a
|
||||||
formatter would be a one-time repo-wide reformat that flattens that
|
formatter would be a one-time repo-wide reformat that flattens that
|
||||||
alignment — a maintainer call, not a silent cleanup, so left untouched.
|
alignment — a maintainer call, not a silent cleanup, so left untouched.
|
||||||
- **Full docs review & restructure:** a dedicated pass over all the prose,
|
- ✓ **Full docs review & restructure** (completed 2026-07-04 in four
|
||||||
not just the code-adjacent cleanup the repo-sanitize item covers. The
|
slices): the roadmap/backlog split (this file + agent/BACKLOG.md);
|
||||||
README has grown to ~540 lines with a ~200-line roadmap inline — the
|
option tables reconciled with the live surface and guarded permanently
|
||||||
biggest single move is likely **splitting the roadmap out** (e.g.
|
by `checks.option-docs`; drift pass over OVERRIDES/TESTING/template
|
||||||
`ROADMAP.md` / `docs/`) so the README stays a focused "what it is / how
|
README (claims held; two gaps fixed); the install/first-run story now
|
||||||
to install / how to override" entry point. Also: reconcile every option
|
hands off README §3 → template README explicitly; and a new
|
||||||
table against the live `nomarchy.*` surface (snapper and others are
|
**docs/RECOVERY.md** runbook (generations → journals → older boot
|
||||||
missing); a pass over `docs/OVERRIDES.md` + `docs/TESTING.md` and the
|
generation → snapper → nixos-enter). Still open, deliberately: the
|
||||||
`templates/downstream/README.md` for drift; check the install/first-run
|
docs-site-vs-Markdown call (BACKLOG § Decisions).
|
||||||
story reads cleanly end to end; and decide whether anything wants a real
|
|
||||||
docs site vs. staying Markdown-in-repo. Pairs with the first-boot welcome
|
|
||||||
and control-center items (shared "how do I…" surface).
|
|
||||||
- **Laptop power / battery management:** a real power story behind a
|
- **Laptop power / battery management:** a real power story behind a
|
||||||
`nomarchy.system.power.*` surface (`modules/nixos/power.nix`), replacing the
|
`nomarchy.system.power.*` surface (`modules/nixos/power.nix`), replacing the
|
||||||
old "only `services.upower` for Waybar reporting" baseline:
|
old "only `services.upower` for Waybar reporting" baseline:
|
||||||
@@ -231,7 +377,21 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
turns it on for a `GenuineIntel` CPU.
|
turns it on for a `GenuineIntel` CPU.
|
||||||
- ✓ **longevity:** `power.batteryChargeLimit` (e.g. 80) — a backend-
|
- ✓ **longevity:** `power.batteryChargeLimit` (e.g. 80) — a backend-
|
||||||
independent sysfs oneshot writes `charge_control_end_threshold`. Off by
|
independent sysfs oneshot writes `charge_control_end_threshold`. Off by
|
||||||
default; the installer scaffolds it commented-out on laptops.
|
default; the installer scaffolds it commented-out on laptops. ✓ the
|
||||||
|
threshold is now **re-applied on AC state changes** (a `services.udev`
|
||||||
|
rule on `SUBSYSTEM=="power_supply", ATTR{type}=="Mains"` restarts the
|
||||||
|
oneshot via `systemctl --no-block`), closing the firmware-resets-on-
|
||||||
|
unplug gap — the match is by adapter *type*, not kernel name
|
||||||
|
(AC/AC0/ADP1/ACAD vary), and `restart` (not `try-restart`) re-applies
|
||||||
|
even if the boot run was inactive. Eval-verified both ways (rule present
|
||||||
|
with charge limit on / absent off), and **VM-verified the trigger**
|
||||||
|
(`checks.battery-charge-limit`: the `test_power` module fakes a Mains
|
||||||
|
adapter, toggling `ac_online` emits a real `power_supply` uevent, and the
|
||||||
|
udev rule restarts the oneshot — confirmed by a changed `InvocationID`).
|
||||||
|
The sysfs *write* itself needs a real `charge_control_end_threshold`, so
|
||||||
|
a final on-hardware check (a `sudo nixos-rebuild` + a physical unplug)
|
||||||
|
remains — the dev box has both an `AC` Mains adapter and a `BAT0`
|
||||||
|
`charge_control_end_threshold`, so it can exercise it.
|
||||||
- ✓ **installer:** writes `power.laptop = true` (battery probe) and
|
- ✓ **installer:** writes `power.laptop = true` (battery probe) and
|
||||||
`power.thermal.enable` (Intel) into the generated `system.nix`.
|
`power.thermal.enable` (Intel) into the generated `system.nix`.
|
||||||
- ✓ **idle cohesion:** `modules/home/idle.nix` now suspends only on
|
- ✓ **idle cohesion:** `modules/home/idle.nix` now suspends only on
|
||||||
@@ -241,8 +401,6 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
logind's lid handling is left at its defaults (suspend on lid close,
|
logind's lid handling is left at its defaults (suspend on lid close,
|
||||||
ignore when docked) — an explicit "I'm done" that coheres with the
|
ignore when docked) — an explicit "I'm done" that coheres with the
|
||||||
idle behaviour.
|
idle behaviour.
|
||||||
- Remaining: a boot-only→event-driven charge-limit re-apply (udev) if a
|
|
||||||
firmware resets the threshold on unplug.
|
|
||||||
- ✓ **Waybar parity:** the `custom/powerprofile` indicator now shows in the
|
- ✓ **Waybar parity:** the `custom/powerprofile` indicator now shows in the
|
||||||
summer-day/night whole-swap themes too. `powerProfileStatus`/`Cycle` are
|
summer-day/night whole-swap themes too. `powerProfileStatus`/`Cycle` are
|
||||||
named `writeShellScriptBin`s on PATH (`waybar.nix` home.packages), so the
|
named `writeShellScriptBin`s on PATH (`waybar.nix` home.packages), so the
|
||||||
@@ -263,6 +421,31 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
Complements nixos-hardware (model quirks) rather than replacing it; keep
|
Complements nixos-hardware (model quirks) rather than replacing it; keep
|
||||||
the detection in the installer's `hardware-db` so an installed machine
|
the detection in the installer's `hardware-db` so an installed machine
|
||||||
bakes the right defaults, all overridable through `nomarchy.hardware.*`.
|
bakes the right defaults, all overridable through `nomarchy.hardware.*`.
|
||||||
|
- ✓ **Mechanism + broad seed shipped** (`modules/nixos/hardware.nix`): a
|
||||||
|
vendor-keyed `nomarchy.hardware.*` surface (`intel`, `amd`, `fingerprint`,
|
||||||
|
`npu`), `hardware-db.sh` extended to detect Intel/AMD, a fingerprint reader
|
||||||
|
(libfprint USB vendor IDs) and an NPU (Intel VPU / AMD XDNA PCI IDs), and
|
||||||
|
the installer bakes the matching toggles into `system.nix` — safe defaults
|
||||||
|
active (GuC/HuC, amd-pstate, AMD VA-API env, fprintd), heavy/experimental
|
||||||
|
opt-ins commented (Intel compute-runtime, ROCm, fingerprint PAM, NPU
|
||||||
|
driver). Audited against the nixos-hardware commons so it only fills the
|
||||||
|
gap above them — microcode / the media-driver VA-API stack / weekly fstrim
|
||||||
|
already come from `common-cpu-*` / `common-gpu-*` / `common-pc-ssd`.
|
||||||
|
Exercised live on the dev machine's detection (an AMD Ryzen-AI laptop: AMD
|
||||||
|
+ fingerprint + NPU all detected) and a toggles-on build (`amd_pstate=active`
|
||||||
|
+ `i915.enable_guc=3` in kernel-params, `fprintd.service` present).
|
||||||
|
**Remaining: on-hardware verification of the runtime bits (AMD/NPU/Intel
|
||||||
|
GPU-compute) — none are testable in CI or on the Intel Latitudes for the
|
||||||
|
AMD paths.** SSD TRIM is intentionally left to `common-pc-ssd`.
|
||||||
|
- ✓ **Hardening (driver-gen + kernel awareness):** `intel.guc` emits the
|
||||||
|
*i915* param, so the installer turns it off when the GPU is on the newer
|
||||||
|
`xe` driver (Lunar Lake / Battlemage / Panther Lake — GuC is default-on
|
||||||
|
there); the NPU detector matches the PCI *accelerator class* (not just a
|
||||||
|
device-ID list) so new gens are caught without a code change; and a
|
||||||
|
`nomarchy.hardware.latestKernel` escape hatch (+ a build-time warning when
|
||||||
|
`npu.enable` predates the shipped kernel) covers very-new hardware whose
|
||||||
|
drivers only just landed. A config-assertion VM test guards the wiring
|
||||||
|
(`checks.hardware-toggles`: kernel cmdline + fprintd + PAM, booted in a VM).
|
||||||
Worked example — ThinkPad T14s (Ryzen 7 PRO 7840U + Radeon 780M):
|
Worked example — ThinkPad T14s (Ryzen 7 PRO 7840U + Radeon 780M):
|
||||||
- **GPU compute — ROCm:** the 780M is an RDNA3 iGPU (gfx1103) ROCm doesn't
|
- **GPU compute — ROCm:** the 780M is an RDNA3 iGPU (gfx1103) ROCm doesn't
|
||||||
officially list, so it needs `HSA_OVERRIDE_GFX_VERSION=11.0.0`. Multi-GB
|
officially list, so it needs `HSA_OVERRIDE_GFX_VERSION=11.0.0`. Multi-GB
|
||||||
@@ -287,6 +470,91 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
distro-wide regardless).
|
distro-wide regardless).
|
||||||
Sub-items here can graduate into their own roadmap entries as they're
|
Sub-items here can graduate into their own roadmap entries as they're
|
||||||
scoped; the unifying work is the detection + `nomarchy.hardware.*` surface.
|
scoped; the unifying work is the detection + `nomarchy.hardware.*` surface.
|
||||||
|
- **Webcam support & tuning:** improve out-of-the-box webcam behaviour.
|
||||||
|
Motivating case — on the ThinkPad T14s AMD Gen 4 the camera shows a dark,
|
||||||
|
low-quality image. **Diagnosed on hardware (2026-06-26)**, and it's *not* what
|
||||||
|
the first cut of this item guessed (UVC default-controls tuning vs a MIPI/
|
||||||
|
libcamera gap):
|
||||||
|
- The camera is a **USB UVC** module (Chicony `04f2:b7c0`, `uvcvideo`) and is
|
||||||
|
**dual-sensor** — `video0` Color (MJPG, up to 2592×1944 / 1080p) + `video2`
|
||||||
|
**IR** (8-bit `GREY` only, the face-unlock sensor). The AMD IPU `[1022:1502]`
|
||||||
|
is present on PCI but **unused** (the camera enumerates over USB, not the
|
||||||
|
MIPI/ISP path), so the libcamera-software-ISP branch is moot on this machine.
|
||||||
|
- The **raw color capture is fine**: at factory defaults with auto-exposure,
|
||||||
|
`/dev/video0` measures luma ≈130/255 *from the first frame* (no AE ramp, not
|
||||||
|
dark); forcing manual exposure made it *worse*. So there is **no v4l2 control
|
||||||
|
default to bake** — the speculated `v4l2-ctl`-tuning / udev-oneshot fix is the
|
||||||
|
wrong tree.
|
||||||
|
- **Real cause is the consumption path.** PipeWire/WirePlumber exposes the
|
||||||
|
device through **both** backends at once — two `[v4l2]` sources (node 144 =
|
||||||
|
`/dev/video0` Color, node 146 = `/dev/video2` **IR**) **plus** two
|
||||||
|
`[libcamera]` nodes (Color + IR) — and the IR sensor is presented as an
|
||||||
|
**indistinguishable** "Integrated Camera". So an app's camera picker shows up
|
||||||
|
to *four* identical entries, and choosing the IR one yields a black/dark
|
||||||
|
monochrome frame; the libcamera path can also negotiate the low-res `YUYV`
|
||||||
|
640×480 mode ("bad quality"). The default source is the color node, so apps
|
||||||
|
that don't let you choose are fine — the breakage is selecting (or an app
|
||||||
|
auto-selecting) the wrong node.
|
||||||
|
- **Fix — validated live on hardware (2026-06-26).** Two WirePlumber 0.5
|
||||||
|
drop-ins collapse the four entries to one clean color camera, confirmed via
|
||||||
|
`wpctl status` (before: 2 v4l2 + 2 libcamera incl. both IR nodes → after:
|
||||||
|
**1** v4l2 source = `/dev/video0` color, **0** libcamera):
|
||||||
|
1. **Hide the IR node** — `monitor.v4l2.rules` matching the IR sensor →
|
||||||
|
`node.disabled = true`. (Tested by card name `~.*Integrated I`; the
|
||||||
|
**shipping** match should key on the more robust, vendor-neutral heuristic
|
||||||
|
of a **`GREY`-only / no-color-format** node, since other vendors' IR cards
|
||||||
|
are named differently.)
|
||||||
|
2. **Drop the duplicate backend** — `wireplumber.profiles.main.monitor.libcamera
|
||||||
|
= disabled`, since a plain UVC cam is fully covered by v4l2 (also kills the
|
||||||
|
libcamera low-res-`YUYV` path).
|
||||||
|
**Drawbacks / design constraints for the module:**
|
||||||
|
- Rule 2 (libcamera off) is only safe **when a UVC camera exists** — on a
|
||||||
|
MIPI/IPU-only machine (no UVC fallback) it would kill the camera entirely,
|
||||||
|
so it **must be conditional on detection**, not blanket. Rule 1 (IR-hide)
|
||||||
|
is broadly safe. Exactly the `nomarchy.hardware.*`-gated targeting the
|
||||||
|
parent item calls for.
|
||||||
|
- **Face-unlock is *not* broken:** `node.disabled` only hides the PipeWire
|
||||||
|
node; the kernel `/dev/video2` stays openable, so Howdy (which reads the IR
|
||||||
|
device directly, bypassing PipeWire) still works.
|
||||||
|
A true MIPI/IPU software-ISP camera with no UVC fallback stays a separate
|
||||||
|
future item.
|
||||||
|
- **Shipped (2026-06-27):** `nomarchy.hardware.camera.hideIrSensor` (+ an
|
||||||
|
overridable `irMatch` regex) in `modules/nixos/hardware.nix` emits rule 1 via
|
||||||
|
`services.pipewire.wireplumber.extraConfig`; the installer's `hardware-db.sh`
|
||||||
|
auto-detects a paired RGB+IR webcam (from `/sys/class/video4linux/*/name`)
|
||||||
|
and bakes the toggle into `system.nix`, with a commented example in the
|
||||||
|
downstream template. **Decision: v4l2 IR-hide only — libcamera is left
|
||||||
|
untouched** so an external camera you plug in is never affected. Surgical
|
||||||
|
internal-only libcamera scoping proved impossible: the distinguishing device
|
||||||
|
props (`api.libcamera.location`, `device.product.name`) bind *after* the
|
||||||
|
monitor rule runs, and the only early-matchable prop (`device.api`) is
|
||||||
|
all-or-nothing — so a broad libcamera-off was the only option and was rejected
|
||||||
|
as the blunt instrument it is (external USB cams are UVC and keep working via
|
||||||
|
v4l2 regardless). Verified: installer detection fires on the T14s; the
|
||||||
|
generated drop-in's serialized content is valid WP-0.5 config; and the exact
|
||||||
|
shipped `irMatch` was re-confirmed live (1 V4L2 source = the colour
|
||||||
|
`/dev/video0`, libcamera untouched, IR node disabled in the WirePlumber log).
|
||||||
|
Remaining: an on-Nomarchy end-to-end check; optional `v4l-utils` +
|
||||||
|
`cameractrls` for the rare genuine-tuning case; and a follow-up for
|
||||||
|
portal/Flatpak apps that consume the libcamera path (where the internal IR is
|
||||||
|
still listed, since libcamera stays on).
|
||||||
|
- ✓ **Memory-pressure protection (earlyoom, default-on):**
|
||||||
|
`modules/nixos/oom.nix` — running out of memory kills the offending
|
||||||
|
process instead of freezing the desktop. **earlyoom over systemd-oomd,
|
||||||
|
deliberately:** oomd kills whole cgroups, and a Hyprland session is ONE
|
||||||
|
scope (no per-app systemd scopes here, unlike GNOME) — under pressure it
|
||||||
|
would take out the entire desktop; earlyoom kills the single largest
|
||||||
|
process before the thrash point. nixpkgs default-enables oomd *inert*
|
||||||
|
(no slices monitored) — disabled outright so there's one owner. Session
|
||||||
|
plumbing is `--avoid`-listed (Hyprland/hyprlock/greetd/waybar/pipewire/
|
||||||
|
wireplumber/Xwayland/nix-daemon/systemd — unanchored, since NixOS
|
||||||
|
wrappers rename comm to `.foo-wrapped`); no `--prefer` tuning (largest-
|
||||||
|
RSS selection already finds the hog); kills raise a desktop notification
|
||||||
|
(systembus-notify). All `mkDefault` — opt out with
|
||||||
|
`services.earlyoom.enable = false`. **VM-verified**
|
||||||
|
(`checks.oom-protection`): a chunked allocator peaked at ~686 MB in a
|
||||||
|
1 GB VM, earlyoom SIGTERM'd it in 0.1 s, a bystander unit survived, and
|
||||||
|
oomd is asserted off.
|
||||||
- **Opt-in services & integrations:** the counterpart to the opt-*out*
|
- **Opt-in services & integrations:** the counterpart to the opt-*out*
|
||||||
application suite above — heavier or more personal integrations shipped
|
application suite above — heavier or more personal integrations shipped
|
||||||
**off by default**, each a `nomarchy.services.<name>.enable` toggle a
|
**off by default**, each a `nomarchy.services.<name>.enable` toggle a
|
||||||
@@ -344,19 +612,74 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
(no kanshi — it fights Hyprland's own output management). `nwg-displays`
|
(no kanshi — it fights Hyprland's own output management). `nwg-displays`
|
||||||
ships behind `nomarchy.displays.enable` as an interactive arranger (a
|
ships behind `nomarchy.displays.enable` as an interactive arranger (a
|
||||||
helper to find values; the declarative config stays the source of truth —
|
helper to find values; the declarative config stays the source of truth —
|
||||||
its output file isn't sourced). Remaining: true docked/undocked **profile
|
its output file isn't sourced). The System ▸ **Display** menu picks an
|
||||||
switching** of the *same* outputs (Hyprland's per-output rules cover the
|
output's resolution from its advertised modes: applied live via `hyprctl
|
||||||
common "external connects → arrange" case, not multi-layout toggles), and
|
keyword monitor` (current position + scale kept) and persisted to the
|
||||||
optionally workspace-to-monitor binding.
|
in-flake state (`settings.monitors.<name>`, no rebuild), overlaid onto
|
||||||
|
`nomarchy.monitors` by name so the next rebuild bakes it in — the monitor
|
||||||
|
twin of the keyboard-layout graduation. Remaining: true docked/undocked
|
||||||
|
**profile switching** of the *same* outputs (Hyprland's per-output rules
|
||||||
|
cover the common "external connects → arrange" case, not multi-layout
|
||||||
|
toggles), and optionally workspace-to-monitor binding.
|
||||||
- ✓ **Night light / blue-light filter:** `nomarchy.nightlight` (opt-in) —
|
- ✓ **Night light / blue-light filter:** `nomarchy.nightlight` (opt-in) —
|
||||||
a scheduled colour-temperature shift via `hyprsunset` (Hyprland-native),
|
a scheduled colour-temperature shift via `hyprsunset` (Hyprland-native),
|
||||||
warm at night, identity (no shift) by day. `modules/home/nightlight.nix`
|
warm at night, identity (no shift) by day. `modules/home/nightlight.nix`
|
||||||
drives the HM `services.hyprsunset` with two time-based profiles
|
drives the HM `services.hyprsunset` with two time-based profiles
|
||||||
(`sunrise` → identity, `sunset` → `temperature`), so hyprsunset handles the
|
(`sunrise` → identity, `sunset` → `temperature`), so hyprsunset handles the
|
||||||
schedule and the on-login state. Needs an on-hardware check that hyprsunset
|
schedule and the on-login state. Active-profile-at-session-start was
|
||||||
applies the active profile at session start. Remaining (optional): a menu +
|
confirmed on hardware (2026-06-18). ✓ **Menu + Waybar toggle (opt-in,
|
||||||
Waybar toggle to force it on/off, and geo (lat/long) auto sunset/sunrise
|
instant, in-flake state):** two git-tracked keys in the state file, both
|
||||||
(would mean wlsunset, which schedules by location).
|
menu-written (exposed via the new `nomarchy.settings` option) —
|
||||||
|
`settings.nightlight.installed` (**sticky**: gates the hyprsunset unit;
|
||||||
|
`nomarchy.nightlight.enable` `mkDefault`-reads it) and
|
||||||
|
`settings.nightlight.on` (runtime on/off). **Off by default.** The *first*
|
||||||
|
enable from the menu writes `installed` and rebuilds to create the unit (the
|
||||||
|
one accepted rebuild); **every toggle after is instant** —
|
||||||
|
`nomarchy-theme-sync set settings.nightlight.on … --no-switch` (atomic +
|
||||||
|
`git add -N`, no rebuild) plus a `systemctl` start/stop. Splitting the sticky
|
||||||
|
flag from the on/off is what avoids a **decay** bug: an instant-off writes
|
||||||
|
only `on`, so an unrelated later rebuild (e.g. `sys-update`) never drops the
|
||||||
|
unit and "on" stays instant. Persistence across logout/reboot comes from an
|
||||||
|
`ExecCondition` on the unit (`nomarchy-nightlight should-start`) that reads the
|
||||||
|
**live** working-tree on/off at start time — *not* the eval-frozen store copy
|
||||||
|
— so an off survives a reboot with no rebuild; a later rebuild bakes the same
|
||||||
|
value. No more `~/.local/state` marker, no marker `ConditionPathExists`. The
|
||||||
|
`nomarchy-menu` System-submenu entry is always shown (current on/off) so the
|
||||||
|
feature can be enabled from the menu; the self-gating Waybar
|
||||||
|
`custom/nightlight` indicator shows the moon while running and hides otherwise.
|
||||||
|
This is the first cut of a broader principle — **any user-settable config
|
||||||
|
gets a menu writer that lands it in the downstream flake; no state lives
|
||||||
|
outside the checkout** (Phase 1 night-light + Phase 2 the per-device
|
||||||
|
keyboard-layout memory below are done — both now in the git-tracked state
|
||||||
|
file; next target: opt-in auto-commit of the flake on each mutation, owned
|
||||||
|
files only). Remaining (optional): geo (lat/long) auto
|
||||||
|
sunset/sunrise (would mean wlsunset, which schedules by location). Pending an
|
||||||
|
on-machine check (first enable rebuilds + comes on; later toggles instant; off
|
||||||
|
persists across reboot via ExecCondition; stopping hyprsunset restores gamma).
|
||||||
|
- ✓ **Automatic timezone (location-following clock):** `nomarchy.system.autoTimezone`
|
||||||
|
(opt-in, off by default) — geoclue + `services.automatic-timezoned` drive
|
||||||
|
`/etc/localtime` from your location, so travelling to another zone updates the
|
||||||
|
Waybar clock on its own. **Menu-driven, in-flake state** (the night-light /
|
||||||
|
keyboard philosophy): the flag is `settings.autoTimezone` in theme-state.json,
|
||||||
|
git-tracked; the System-menu entry (`nomarchy-menu autotimezone` →
|
||||||
|
`nomarchy-autotimezone`) writes it and rebuilds. **Not instant like
|
||||||
|
night-light** — it's a *system* service (not a user unit you can start/stop),
|
||||||
|
so the toggle drives a `sudo nixos-rebuild` (bakes the service + the
|
||||||
|
`time.timeZone` override) plus a `home-manager switch` (the Waybar-refresh
|
||||||
|
watcher), both off the one flag. **time.timeZone handling:** a runtime zone
|
||||||
|
needs `/etc/localtime` writable; automatic-timezoned sets `time.timeZone = null`
|
||||||
|
itself, but the installer's static value would collide (a hard eval error), so
|
||||||
|
the module forces it null (`mkForce`) over the installer's value when enabled,
|
||||||
|
and reverts to it when disabled. **Live clock refresh:** Waybar's clock module
|
||||||
|
captures the zone at construction, so `modules/home/timezone.nix` runs a tiny
|
||||||
|
user service that watches timedate1's change signal and reloads Waybar
|
||||||
|
(SIGUSR2) on a real zone change (also catches a manual `timedatectl
|
||||||
|
set-timezone`). Eval-verified both ways (on: geoclue+daemon on, tz forced null
|
||||||
|
over a static installer value, watcher present; off: static tz intact, no
|
||||||
|
service/watcher). **Pending an on-hardware check** — geoclue detection and the
|
||||||
|
SIGUSR2 clock refresh aren't testable in CI (if SIGUSR2 proves insufficient,
|
||||||
|
fall back to restarting waybar). Remaining (optional): a Waybar tooltip line
|
||||||
|
showing the detected zone.
|
||||||
- **Keyboard layouts (per-device + switching):**
|
- **Keyboard layouts (per-device + switching):**
|
||||||
- ✓ **Per-device declarative layout:** `nomarchy.keyboard.devices`
|
- ✓ **Per-device declarative layout:** `nomarchy.keyboard.devices`
|
||||||
(`{ "<hyprctl-device-name>" = { layout; variant; }; }`) generates Hyprland
|
(`{ "<hyprctl-device-name>" = { layout; variant; }; }`) generates Hyprland
|
||||||
@@ -372,20 +695,46 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
watch` daemon runs (exec-once): it polls `hyprctl devices`, and when a
|
watch` daemon runs (exec-once): it polls `hyprctl devices`, and when a
|
||||||
keyboard connects *after* login that isn't in `keyboard.devices` and
|
keyboard connects *after* login that isn't in `keyboard.devices` and
|
||||||
hasn't been chosen before, it pops a rofi layout picker, applies the
|
hasn't been chosen before, it pops a rofi layout picker, applies the
|
||||||
choice with `hyprctl switchxkblayout` (an index into the candidate set,
|
choice as a **per-device** `hyprctl keyword device[<name>]:kb_layout` (the
|
||||||
which `input.kb_layout` carries), and remembers it per-device in
|
runtime twin of the declarative `device{}` blocks — so it isolates that
|
||||||
`~/.local/state/nomarchy/keyboard-layouts` — re-applied silently on later
|
one keyboard and never touches the built-in board), and remembers it. The
|
||||||
reconnects. The boot-time set (incl. the built-in keyboard) is never
|
boot-time set (incl. the built-in keyboard) is never prompted. The
|
||||||
prompted. The runtime-remember complement to the declarative
|
runtime-remember complement to the declarative `keyboard.devices`.
|
||||||
`keyboard.devices`; a stateful runtime piece by design. **Pending an
|
**Picker verified on hardware (2026-06-18)**; the first cut applied the
|
||||||
on-hardware test** (hotplug isn't verifiable in CI). Remaining (bonus):
|
choice with `switchxkblayout` (a global layout-index flip) and merged the
|
||||||
offer to write a choice into `keyboard.devices` so it graduates to the
|
candidate pool into `input.kb_layout`, so a selection leaked onto the
|
||||||
reproducible config.
|
laptop keyboard and stuck after unplug — fixed by the per-device keyword +
|
||||||
- Remaining: a multi-layout cycle bind (`hyprctl switchxkblayout` / xkb
|
keeping the pool out of the session layout.
|
||||||
`grp:` options) for switching layouts on one keyboard; add the
|
- ✓ **In-flake state + graduation (2026-06-23):** the remembered picks moved
|
||||||
`hyprland/language` module to the summer whole-swap themes for parity
|
out of `~/.local/state/nomarchy/keyboard-layouts` into the git-tracked
|
||||||
(the gating doesn't translate to their static JSON). Keep the system
|
state file (`settings.keyboard.devices`, a device-name → layout map) —
|
||||||
(console/initrd) and session layouts in sync (the LUKS-keymap work).
|
Phase 2 of the in-flake-state principle, the same path night-light took.
|
||||||
|
The watcher reads the **live** working tree (a pick is honoured at once)
|
||||||
|
and writes instantly with `--no-switch` (no rebuild), merging the whole map
|
||||||
|
back at the dot-free parent path so a device name containing a dot can't
|
||||||
|
corrupt the dotted set-path. Each remembered device **graduates** into
|
||||||
|
`nomarchy.keyboard.devices` on the next rebuild (a generated Hyprland
|
||||||
|
`device{}` block, `mkDefault` so a hand-written entry still wins), after
|
||||||
|
which the watcher sees it as declared and steps back. Eval-verified
|
||||||
|
(graduation + precedence) and the writer round-trips; **still needs the
|
||||||
|
on-hardware hotplug re-verify** (the picker path isn't testable in CI).
|
||||||
|
- ✓ **Multi-layout cycle bind + summer parity (2026-07-04):**
|
||||||
|
`SUPER+SHIFT+K` → `hyprctl switchxkblayout current next`, rendered
|
||||||
|
only when the session layout has a comma (same gate as the Waybar
|
||||||
|
language indicator) — data lives in `keybinds.nix` as a separate
|
||||||
|
`multiLayoutBinds` list, so hyprland.nix and the cheatsheet consume
|
||||||
|
one source and gate identically (verified both ways: absent on a
|
||||||
|
single layout; bind + cheatsheet row render under `us,de` via
|
||||||
|
extendModules). `current` targets the focused keyboard, so a
|
||||||
|
per-device-overridden board (one layout) is a no-op, never a leak.
|
||||||
|
`hyprland/language` (` {short}`) added to both summer
|
||||||
|
`waybar.jsonc` whole-swaps + `#language` in their CSS — static JSON
|
||||||
|
can't eval-gate, so on summer themes the module shows even with one
|
||||||
|
layout (a deliberate parity-over-minimalism call; it's small).
|
||||||
|
Noticed en route: summer-night carries an `idle_inhibitor` the
|
||||||
|
generated bar lacks — folded into the idle-inhibit backlog item.
|
||||||
|
- Remaining: keep the system (console/initrd) and session layouts in
|
||||||
|
sync (the LUKS-keymap work).
|
||||||
- ✓ **Do-Not-Disturb:** swaync DND toggle wired into the menu
|
- ✓ **Do-Not-Disturb:** swaync DND toggle wired into the menu
|
||||||
(`nomarchy-menu dnd`, in the picker, SUPER+CTRL+D) and a Waybar bell
|
(`nomarchy-menu dnd`, in the picker, SUPER+CTRL+D) and a Waybar bell
|
||||||
indicator (`custom/notification` via `swaync-client -swb`: shows the
|
indicator (`custom/notification` via `swaync-client -swb`: shows the
|
||||||
@@ -404,21 +753,92 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
change). **The backend is VM-verified** (a full BTRFS+LUKS install: both
|
change). **The backend is VM-verified** (a full BTRFS+LUKS install: both
|
||||||
`root`+`home` configs, a `/home` timeline snapshot taken, `/home/.snapshots`
|
`root`+`home` configs, a `/home` timeline snapshot taken, `/home/.snapshots`
|
||||||
a real subvolume, the oneshot `Result=success`).
|
a real subvolume, the oneshot `Result=success`).
|
||||||
- ⚠ **Known bug — btrfs-assistant 2.2 segfaults on launch** in nixpkgs
|
- ✓ **Resolved — the "btrfs-assistant 2.2 segfault" was unprivileged-only**
|
||||||
26.05: it crashes inside `libbtrfsutil.so.1.4.0` (a library ABI mismatch,
|
(re-diagnosed 2026-07-04): gdb puts the crash in `btrfs_util_subvolume_
|
||||||
confirmed in the VM regardless of GL/Qt platform — so it's not a VM/GL
|
iterator_next()` — libbtrfsutil's *unprivileged* subvolume-iteration path
|
||||||
artifact and will crash on hardware too). The menu wiring is correct and
|
in btrfs-progs 6.17.1 (upstream-fixed after 6.17.1, kdave/btrfs-progs
|
||||||
the app is installed, but the GUI doesn't open. The snapshot *backend*
|
`886571653` "re-enable tree search v2 ioctl"; symbol versions were checked
|
||||||
(the actual snapshots) is unaffected. Fix path: a nixpkgs override aligning
|
and match, so not an ABI/link issue). **As root it runs fine** — VM-proven
|
||||||
its libbtrfsutil, or wait for an upstream bump; meanwhile the menu entry
|
(root `--version` exits 0, unprivileged exits 139) — and the pkexec
|
||||||
is a no-op when the binary crashes. Fallback if it lingers: a rofi-based
|
launcher runs it as root, so the GUI was never actually broken *when
|
||||||
snapshot menu (no btrfs-assistant dependency).
|
launched right*. What WAS missing distro-wide: **no polkit authentication
|
||||||
- Remaining (optional): a keyboard-driven rofi browse for quick glances;
|
agent**, so every pkexec prompt in the session failed silently — that's
|
||||||
boot-from-snapshot needs a systemd-boot equivalent of grub-btrfs.
|
the root cause of "the GUI doesn't open". Fixes: `hyprpolkitagent`
|
||||||
- **Update awareness:** updates are manual today (`sys-update`/`home-update`);
|
(Hyprland's Qt agent, Stylix-themed) now ships via exec-once in
|
||||||
add a Waybar indicator / notification when flake inputs are stale or a new
|
`hyprland.nix`; the menu prefers `btrfs-assistant-launcher` again with
|
||||||
nixpkgs rev is available (optionally with a pending-change count). Augments,
|
`nomarchy-snapshots` as fallback; `checks.snapshot-gui` guards the root
|
||||||
never replaces, the explicit rebuild flow.
|
path on a real btrfs volume (and the GUI event loop offscreen) against
|
||||||
|
lock-bump regressions. No btrfs-progs patch: our flows are all root-side,
|
||||||
|
so the unprivileged fix rides in with a future lock bump. Original
|
||||||
|
diagnosis kept below for the record. ✓ **Fallback shipped:**
|
||||||
|
the menu now launches `nomarchy-snapshots` (system-side, gated on
|
||||||
|
`nomarchy.system.snapper`) instead — a keyboard-driven snapper
|
||||||
|
browser/restore with no btrfs-assistant dependency. snapper is root-only
|
||||||
|
here (no `ALLOW_USERS`) and rofi can't run as root under Wayland, so it
|
||||||
|
runs in a terminal via `sudo` (one password prompt) and uses `fzf` to pick:
|
||||||
|
browse/diff (read-only), restore files (`undochange`), or roll back (root
|
||||||
|
config only) — each behind a typed-`yes` confirmation, which is *safer*
|
||||||
|
than the fat-fingerable one-click rofi rollback this entry originally
|
||||||
|
avoided. btrfs-assistant stays installed for when nixpkgs fixes it.
|
||||||
|
**Pending an on-machine check of the restore/rollback paths.**
|
||||||
|
- Remaining (optional): boot-from-snapshot needs a systemd-boot equivalent
|
||||||
|
of grub-btrfs.
|
||||||
|
- ✓ **Update awareness:** `nomarchy.updates.enable` (opt-in, `modules/home/
|
||||||
|
updates.nix`) — a `nomarchy-updates` checker on a systemd user timer
|
||||||
|
(`.interval`, default daily) compares each **direct** branch-tracking flake
|
||||||
|
input (nixpkgs, the Nomarchy input, home-manager, stylix … via `git
|
||||||
|
ls-remote` vs the locked rev; offline → skipped, no false alarm) and, when
|
||||||
|
the `flatpak` CLI is present (`.flatpak`), counts Flatpak updates. A
|
||||||
|
self-gating Waybar `custom/updates` indicator (hidden until something's
|
||||||
|
available; accent ` N`, signal-refreshed) + a notification that fires only
|
||||||
|
when the count *grows* (so a daily timer never nags). Click → the upgrade
|
||||||
|
flow in a terminal (`sys-update` / `flatpak update`, each confirmed). Purely
|
||||||
|
passive — it never changes anything, augmenting the explicit rebuild flow.
|
||||||
|
Indicator is in the generated bar and both summer whole-swaps. **Pending an
|
||||||
|
on-machine check** (the ls-remote/notify/timer path needs a live session).
|
||||||
|
- **Automated upstream lock bumps (maintainer CI):** the upstream twin of the
|
||||||
|
user-side checker above — automate advancing Nomarchy's *own* `flake.lock`,
|
||||||
|
which is the entire delivery channel (a downstream takes a single input,
|
||||||
|
`?ref=v1`, so nixpkgs/home-manager/stylix/nixos-hardware reach it
|
||||||
|
*transitively pinned* through that lock; users can't bump them independently).
|
||||||
|
**Tiered by what CI can't do:** the hardware-QA gate (the Latitude 5410 + the
|
||||||
|
AMD dev box — AMD/NPU/Intel-GPU-compute runtime bits aren't testable in CI or
|
||||||
|
on the Intel Latitudes for the AMD paths) can't be automated, so a bot drives
|
||||||
|
`main` and a human still promotes `main → v1`. A scheduled job (Forgejo
|
||||||
|
Actions, or Renovate's Nix manager opening a reviewable lock-diff PR) runs
|
||||||
|
`nix flake update` → `nix flake check` + the existing `runNixOSTest` suite +
|
||||||
|
builds `system.build.toplevel`, and on green lands on `main` — which nobody
|
||||||
|
tracks, so a bad bump costs a debug session, not a user outage; `v1` stays
|
||||||
|
the manual on-hardware gate (it only ever fast-forwards, never force-pushed,
|
||||||
|
or a user's locked rev can vanish from history). **Cadence weekly**
|
||||||
|
(release-26.05 doesn't move fast; nightly is just churn), and `nix flake
|
||||||
|
update` stays *within* the pinned release branches — no surprise major bump
|
||||||
|
(a 26.11 jump is a deliberate `v2`, hand-edited in flake.nix). Side benefit:
|
||||||
|
shrinks the security-patch latency the single-input model otherwise imposes
|
||||||
|
(users are gated on the maintainer for nixpkgs CVEs), since a fix is usually
|
||||||
|
already eval/build/VM-tested and one hardware promotion away from `v1` —
|
||||||
|
worth fast-laning lock-only/security bumps ahead of feature batches. The repo
|
||||||
|
has **no CI today** (manual `nix flake update` only). Explicitly *not* a
|
||||||
|
binary cache: compile-from-source is a deliberate values call (Gentoo-style),
|
||||||
|
so this automates the *config/lock* channel, not artifact distribution.
|
||||||
|
- ✓ **Opt-in auto-commit of state mutations (in-flake state, Phase 4):**
|
||||||
|
`settings.autoCommit` (menu: **System › Auto-commit**, self-gated on the
|
||||||
|
flake being a git repo) makes every `apply`/`set` also `git commit`
|
||||||
|
theme-state.json in the downstream flake. Design decisions: the flag is
|
||||||
|
**live-read by the tool** on each write — nothing in Nix consumes it, so
|
||||||
|
the toggle is instant, no rebuild, no option-surface addition; the commit
|
||||||
|
is **pathspec-limited** (`git commit -- theme-state.json`) so unrelated
|
||||||
|
dirty files are never swept up; it fires when the flag is on before *or*
|
||||||
|
after the write, so the disable-toggle itself lands in history instead of
|
||||||
|
staying forever-dirty; a same-value `set` no-ops (diff against HEAD); a
|
||||||
|
missing git identity falls back to `Nomarchy <nomarchy@localhost>`;
|
||||||
|
`bg next` is deliberately excluded (runtime wallpaper churn — the path
|
||||||
|
rides along with the next real commit). Verified: V1 (HM generation
|
||||||
|
builds, generated menu `bash -n` green) + a 7-assertion sandbox-repo
|
||||||
|
round-trip incl. the apply path; on-machine check queued. Rider fix:
|
||||||
|
`get` now prints booleans JSON-style (`true`, not Python's `True`) —
|
||||||
|
which also un-sticks the System menu's "Auto timezone (on/off)" label,
|
||||||
|
whose `= true` comparison could never match before.
|
||||||
- **"nomarchy" control center:** a single TUI/GUI front-end over the common
|
- **"nomarchy" control center:** a single TUI/GUI front-end over the common
|
||||||
toggles — theme, power profile, opt-in services, display, DND — built on
|
toggles — theme, power profile, opt-in services, display, DND — built on
|
||||||
the same `nomarchy-theme-sync` / `nomarchy.*` surface the menu already
|
the same `nomarchy-theme-sync` / `nomarchy.*` surface the menu already
|
||||||
@@ -426,6 +846,50 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
your theme / essentials" flow (ties into the branding work).
|
your theme / essentials" flow (ties into the branding work).
|
||||||
|
|
||||||
## Known issues & follow-ups
|
## Known issues & follow-ups
|
||||||
|
- ✓ **README option tables drift from the live surface** (docs review
|
||||||
|
slice (a), 2026-07-04): a fresh reconcile found 9 undocumented options
|
||||||
|
(autoTimezone, camera.hideIrSensor/irMatch, intel.guc, amd.pstate/
|
||||||
|
vaapi, package, system.stateFile, restic.paths) — rows/mentions added,
|
||||||
|
and the diff is now permanent: `checks.option-docs` walks the option
|
||||||
|
names out of the four option-declaring modules at eval time (their
|
||||||
|
lazy `config` halves never evaluate, so dummy args suffice) and
|
||||||
|
requires each a table row — or a backticked `.leaf` mention for
|
||||||
|
non-enable sub-knobs — plus flags table rows whose option no longer
|
||||||
|
exists (tools/check-option-docs.py).
|
||||||
|
- ✓ **swaync text invisible on summer-day** (Latitude QA, 2026-07-04):
|
||||||
|
the themed CSS paired @subtext on @base, but palette roles are not
|
||||||
|
uniform across themes — summer-day/flexoki-light use subtext as
|
||||||
|
text-*on*-surface (== base) and surface as a dark chip (== text), so
|
||||||
|
body text and widget buttons self-colored. Fix: swaync uses only
|
||||||
|
pairings safe in every palette — @text on @base for text (survey:
|
||||||
|
worst ratio 5.18 across 21 themes) and alpha(@text, 0.1) tints for
|
||||||
|
chips/hovers — guarded permanently by `checks.theme-contrast`
|
||||||
|
(tools/check-theme-contrast.py, WCAG ratios over themes/*.json).
|
||||||
|
The same audit found generated waybar/rofi CSS shared the bug class,
|
||||||
|
exposed on flexoki-light (no whole-swap) — fixed in the follow-on:
|
||||||
|
waybar dim/secondary shades are alpha(@text) tints, rofi @dim/@surface
|
||||||
|
are fg-derived #RRGGBBAA tints, and the contrast check's pairings now
|
||||||
|
cover all three generated surfaces. Status glyph accents
|
||||||
|
(good/warn/bad on base, 2.0–2.7 in some palettes) were left as-is —
|
||||||
|
raising them is palette design, a human call.
|
||||||
|
- ✓ **Waybar crash on theme switch left the session bar-less** (Latitude
|
||||||
|
hardware QA, 2026-07-04): exec-once has no supervisor, so any crash
|
||||||
|
orphaned the session until relogin — and the switch path itself was
|
||||||
|
crash-prone: `reload_style_on_change` (inotify on style.css) and
|
||||||
|
theme-sync's SIGUSR2 both fired while the HM symlinks flipped, a
|
||||||
|
double-reload race in waybar's in-place reload. Fix: (1) the bar runs
|
||||||
|
under a `nomarchy-waybar` supervisor (waybar.nix) — ANY exit respawns
|
||||||
|
it, with a crash-loop guard (5 fast exits → critical notification,
|
||||||
|
stop); (2) theme-sync now prefers a clean `pkill -x waybar` when it
|
||||||
|
sees the supervisor (a restart with fresh config+style — no reload
|
||||||
|
code path at all), keeping SIGUSR2 only as the fallback for
|
||||||
|
unsupervised/custom bars; `reload_style_on_change` stays for manual
|
||||||
|
`home-update` restyles. VM-verified on the headless software-GL
|
||||||
|
desktop: SIGKILL → new pid; clean kill → respawn; SIGUSR2 → alive.
|
||||||
|
- ✓ **`sys-rebuild`** (Latitude QA request): the no-update twin of
|
||||||
|
`sys-update` — snapshot-first system rebuild against the *current*
|
||||||
|
lock, for config-only changes; `home-update` was already lock-free.
|
||||||
|
README §3/§5 + the motd list all three.
|
||||||
- ✓ **Yazi TOML parse error on startup:** yazi 26.x made fetchers'
|
- ✓ **Yazi TOML parse error on startup:** yazi 26.x made fetchers'
|
||||||
`group` field required, so `[[plugin.prepend_fetchers]]` failed to parse
|
`group` field required, so `[[plugin.prepend_fetchers]]` failed to parse
|
||||||
and yazi fell back to presets. Fixed by adding `group = "git"` to both
|
and yazi fell back to presets. Fixed by adding `group = "git"` to both
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ than claiming success. "All Nix files parse" is not "the bar renders."
|
|||||||
nix flake check --no-build # full module-system evaluation, no builds
|
nix flake check --no-build # full module-system evaluation, no builds
|
||||||
nix-instantiate --parse <file> # syntax-only, works even on macOS
|
nix-instantiate --parse <file> # syntax-only, works even on macOS
|
||||||
python3 -m py_compile pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py
|
python3 -m py_compile pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py
|
||||||
|
bash -n <script>.sh # shell syntax (tools/, installer bits)
|
||||||
```
|
```
|
||||||
|
|
||||||
`nix flake check` needs a Linux machine (or a Linux builder) since all
|
`nix flake check` needs a Linux machine (or a Linux builder) since all
|
||||||
@@ -22,6 +23,17 @@ bad merges — most breakage stops here. It also evaluates the downstream
|
|||||||
template through `lib.mkFlake` (including a real nixos-hardware profile),
|
template through `lib.mkFlake` (including a real nixos-hardware profile),
|
||||||
so template/wrapper drift fails fast too.
|
so template/wrapper drift fails fast too.
|
||||||
|
|
||||||
|
## 1b. CI (automatic on push)
|
||||||
|
|
||||||
|
Every push to `main`/`v1` runs `.gitea/workflows/check.yml`: the §1
|
||||||
|
cheap checks (flake eval, Python + shell syntax) on the Gitea instance.
|
||||||
|
That's the **eval tier only** — the runner is a docker container without
|
||||||
|
KVM, so the `checks.*` VM suite and real builds stay local (this file)
|
||||||
|
until a KVM-capable runner is registered; the workflow carries a
|
||||||
|
commented `vm-checks` job ready for that day. A green CI run is *not* "it
|
||||||
|
renders" (the honesty rule below still applies) — it means "nobody broke
|
||||||
|
evaluation".
|
||||||
|
|
||||||
## 2. Build and boot the live ISO
|
## 2. Build and boot the live ISO
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
24
flake.lock
generated
@@ -145,11 +145,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1781063120,
|
"lastModified": 1783221248,
|
||||||
"narHash": "sha256-1UIF/mDJluwJQjmmcZ2j1L2+mjYsefe82QCLj0TYSOg=",
|
"narHash": "sha256-ESQnuNHEDChsB4IxoLRhscVahqkDWkTb+qdIz8euYt4=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "baa46aeb6d02e0ba13de67cd35e3d57aedfacf01",
|
"rev": "af2beae5f0fae0a4310cc0e6aef2572f56090353",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -166,11 +166,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1781020964,
|
"lastModified": 1783370751,
|
||||||
"narHash": "sha256-fS7xTi2j2iso5Hj7RNZLv/acDlCT+fgMVkVk40A7Uco=",
|
"narHash": "sha256-E+3MIMvKuo9k+K+qLQ9YXzsBzkgHuyVLnsEbN2DFfuc=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixos-hardware",
|
"repo": "nixos-hardware",
|
||||||
"rev": "32c2cd9e46286c4eced3dc6b613c659126bf3cca",
|
"rev": "662bd6e312d2c8b212e32cb377abaee190749320",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -182,11 +182,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780902259,
|
"lastModified": 1783148766,
|
||||||
"narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=",
|
"narHash": "sha256-uslt2pqShTIXDdAHRHv2QkYLsVdY8Oqwz0EA48/RSM8=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca",
|
"rev": "a50de1b7d8a586adc18d2395c19de7d6058e6030",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -249,11 +249,11 @@
|
|||||||
"tinted-zed": "tinted-zed"
|
"tinted-zed": "tinted-zed"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780702455,
|
"lastModified": 1783359251,
|
||||||
"narHash": "sha256-+srjPGNy67nKytYwdlepycL51IG6S34sS4MKRZXK8G0=",
|
"narHash": "sha256-HUiCnEVlJ4n+qJlZojiz/zv+P0cqM5zsg1dxpz2J7Mg=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "stylix",
|
"repo": "stylix",
|
||||||
"rev": "54fa19702f4f2c7f6a981a92850678933588af9a",
|
"rev": "e602ad042f00409f33c8ad2829cd8d59ba345c7e",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
415
flake.nix
@@ -83,6 +83,9 @@
|
|||||||
# works on machines that don't check out this repo.
|
# works on machines that don't check out this repo.
|
||||||
themesDir = ./themes;
|
themesDir = ./themes;
|
||||||
};
|
};
|
||||||
|
nomarchy-doctor = final.callPackage ./pkgs/nomarchy-doctor { };
|
||||||
|
nomarchy-control-center = final.callPackage ./pkgs/nomarchy-control-center { };
|
||||||
|
nomarchy-battery-notify = final.callPackage ./pkgs/nomarchy-battery-notify { };
|
||||||
};
|
};
|
||||||
|
|
||||||
nixosModules.nomarchy = {
|
nixosModules.nomarchy = {
|
||||||
@@ -134,6 +137,418 @@
|
|||||||
downstream.nixosConfigurations.default.config.system.build.toplevel;
|
downstream.nixosConfigurations.default.config.system.build.toplevel;
|
||||||
downstream-template-home =
|
downstream-template-home =
|
||||||
downstream.homeConfigurations.me.activationPackage;
|
downstream.homeConfigurations.me.activationPackage;
|
||||||
|
|
||||||
|
# Every hex-on-hex text pairing the generated swaync CSS uses
|
||||||
|
# must contrast in EVERY palette — summer-day's body text was
|
||||||
|
# invisible on hardware (item 25: subtext==base there). Cheap
|
||||||
|
# (no VM); extend the script's PAIRINGS as more generated
|
||||||
|
# surfaces move to palette-safe roles (item 27: waybar/rofi).
|
||||||
|
theme-contrast = pkgs.runCommand "nomarchy-theme-contrast"
|
||||||
|
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||||||
|
''
|
||||||
|
python3 ${./tools/check-theme-contrast.py} ${./themes}
|
||||||
|
touch $out
|
||||||
|
'';
|
||||||
|
|
||||||
|
# The README option tables must track the live `nomarchy.*`
|
||||||
|
# surface. The surface is walked at eval time from the four
|
||||||
|
# option-declaring modules (their lazy `config` halves stay
|
||||||
|
# unevaluated), so the live side of the diff can't go stale;
|
||||||
|
# the script wants a table row per option (inline `.leaf`
|
||||||
|
# mentions ok for non-enable sub-knobs) and flags stale rows.
|
||||||
|
option-docs =
|
||||||
|
let
|
||||||
|
lib = nixpkgs.lib;
|
||||||
|
names = prefix: opts: lib.concatLists (lib.mapAttrsToList
|
||||||
|
(n: v:
|
||||||
|
if lib.isOption v
|
||||||
|
then lib.optional (!(v.readOnly or false)) "${prefix}${n}"
|
||||||
|
else names "${prefix}${n}." v)
|
||||||
|
opts);
|
||||||
|
surface = lib.concatLists [
|
||||||
|
(names "nomarchy."
|
||||||
|
((import ./modules/home/options.nix { inherit lib pkgs; config = { }; }).options.nomarchy))
|
||||||
|
(names "nomarchy.system."
|
||||||
|
((import ./modules/nixos/options.nix { inherit lib; config = { }; }).options.nomarchy.system))
|
||||||
|
(names "nomarchy.hardware."
|
||||||
|
((import ./modules/nixos/hardware.nix { inherit lib pkgs; config = { }; }).options.nomarchy.hardware))
|
||||||
|
(names "nomarchy.services."
|
||||||
|
((import ./modules/nixos/services.nix { inherit lib pkgs; config = { }; }).options.nomarchy.services))
|
||||||
|
];
|
||||||
|
in
|
||||||
|
pkgs.runCommand "nomarchy-option-docs"
|
||||||
|
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||||||
|
''
|
||||||
|
python3 ${./tools/check-option-docs.py} \
|
||||||
|
${pkgs.writeText "option-surface.txt"
|
||||||
|
(lib.concatStringsSep "\n" surface + "\n")} \
|
||||||
|
${./README.md}
|
||||||
|
touch $out
|
||||||
|
'';
|
||||||
|
|
||||||
|
# The state-file friendly-errors contract (item 11): validate
|
||||||
|
# reports broken fixtures by field with a fix, good state
|
||||||
|
# passes, and an invalid `set` is refused BEFORE the write —
|
||||||
|
# the on-disk file must be byte-identical after the attempt.
|
||||||
|
theme-sync-validate = pkgs.runCommand "nomarchy-theme-sync-validate"
|
||||||
|
{ nativeBuildInputs = [ pkgs.python3 pkgs.jq ]; }
|
||||||
|
''
|
||||||
|
set -eu
|
||||||
|
tool=${./pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py}
|
||||||
|
export NOMARCHY_DEFAULT_THEMES=${./themes}
|
||||||
|
|
||||||
|
mkdir good && cp ${./templates/downstream/theme-state.json} good/theme-state.json
|
||||||
|
chmod +w good/theme-state.json
|
||||||
|
NOMARCHY_PATH=$PWD/good python3 "$tool" validate
|
||||||
|
|
||||||
|
mkdir syntax && printf '{ "slug": "x", }' > syntax/theme-state.json
|
||||||
|
if NOMARCHY_PATH=$PWD/syntax python3 "$tool" validate 2>err; then
|
||||||
|
echo "FAIL: syntax fixture accepted"; exit 1; fi
|
||||||
|
grep -q "syntax error at line" err
|
||||||
|
|
||||||
|
mkdir badhex && jq '.colors.accent = "7aa2f7"' \
|
||||||
|
good/theme-state.json > badhex/theme-state.json
|
||||||
|
if NOMARCHY_PATH=$PWD/badhex python3 "$tool" validate 2>err; then
|
||||||
|
echo "FAIL: bad hex accepted"; exit 1; fi
|
||||||
|
grep -q "colors.accent" err && grep -q "fix:" err
|
||||||
|
|
||||||
|
mkdir badtype && jq '.ui.gapsOut = "12"' \
|
||||||
|
good/theme-state.json > badtype/theme-state.json
|
||||||
|
if NOMARCHY_PATH=$PWD/badtype python3 "$tool" validate 2>err; then
|
||||||
|
echo "FAIL: bad type accepted"; exit 1; fi
|
||||||
|
grep -q "ui.gapsOut" err
|
||||||
|
|
||||||
|
mkdir unknown && jq '.extraKey = 1' \
|
||||||
|
good/theme-state.json > unknown/theme-state.json
|
||||||
|
NOMARCHY_PATH=$PWD/unknown python3 "$tool" validate \
|
||||||
|
| grep -q "unknown top-level key"
|
||||||
|
|
||||||
|
cp good/theme-state.json before.json
|
||||||
|
if NOMARCHY_PATH=$PWD/good python3 "$tool" --quiet \
|
||||||
|
set ui.gapsOut '"12"' --no-switch 2>err; then
|
||||||
|
echo "FAIL: invalid set was written"; exit 1; fi
|
||||||
|
grep -q "refusing to write" err
|
||||||
|
cmp good/theme-state.json before.json
|
||||||
|
|
||||||
|
touch $out
|
||||||
|
'';
|
||||||
|
|
||||||
|
# nomarchy-doctor's contract: an induced failed unit flips the
|
||||||
|
# sheet to ✖/exit-1 and names the unit; with the failure
|
||||||
|
# cleared it reports healthy/exit-0. Minimal node (just the
|
||||||
|
# package) — the disk/flake/snapper checks self-skip in a VM.
|
||||||
|
doctor = pkgs.testers.runNixOSTest {
|
||||||
|
name = "nomarchy-doctor";
|
||||||
|
nodes.machine = { ... }: {
|
||||||
|
environment.systemPackages = [ pkgs.nomarchy-doctor ];
|
||||||
|
systemd.services.doomed = {
|
||||||
|
description = "deliberately failing unit (doctor test)";
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
ExecStart = "${pkgs.coreutils}/bin/false";
|
||||||
|
};
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
testScript = ''
|
||||||
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
machine.wait_until_succeeds("systemctl is-failed doomed.service")
|
||||||
|
out = machine.fail("nomarchy-doctor 2>&1")
|
||||||
|
assert "doomed.service" in out, f"doctor did not name the failed unit:\n{out}"
|
||||||
|
assert "journalctl" in out, f"doctor did not print a fix:\n{out}"
|
||||||
|
machine.succeed("systemctl reset-failed")
|
||||||
|
machine.succeed("nomarchy-doctor")
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Config-level VM assertions for the nomarchy.hardware.* toggles —
|
||||||
|
# what they SET (kernel cmdline, fprintd, PAM). Real hardware
|
||||||
|
# behaviour (firmware/driver/device) needs bare metal and is out of
|
||||||
|
# scope here. Imports only hardware.nix, so the VM stays minimal.
|
||||||
|
hardware-toggles = pkgs.testers.runNixOSTest {
|
||||||
|
name = "nomarchy-hardware-toggles";
|
||||||
|
nodes.machine = { ... }: {
|
||||||
|
imports = [ ./modules/nixos/hardware.nix ];
|
||||||
|
nomarchy.hardware = {
|
||||||
|
intel.enable = true;
|
||||||
|
amd.enable = true;
|
||||||
|
fingerprint = { enable = true; pam = true; };
|
||||||
|
npu.enable = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
testScript = ''
|
||||||
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
cmdline = machine.succeed("cat /proc/cmdline")
|
||||||
|
assert "amd_pstate=active" in cmdline, cmdline
|
||||||
|
assert "i915.enable_guc=3" in cmdline, cmdline
|
||||||
|
machine.succeed("systemctl cat fprintd.service")
|
||||||
|
machine.succeed("grep -q pam_fprintd /etc/pam.d/sudo")
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Runtime VM check for the battery-charge-limit re-apply: the udev
|
||||||
|
# rule must restart the oneshot on an AC state change (firmware can
|
||||||
|
# clear the threshold on unplug). The `test_power` module fakes a
|
||||||
|
# Mains adapter we can toggle to emit a real power_supply uevent —
|
||||||
|
# so this exercises the trigger, not just the config wiring. (The
|
||||||
|
# sysfs *write* needs a real charge_control_end_threshold and stays
|
||||||
|
# an on-hardware check.) Imports options.nix (where the power
|
||||||
|
# options live) + power.nix, so the VM stays minimal.
|
||||||
|
battery-charge-limit = pkgs.testers.runNixOSTest {
|
||||||
|
name = "nomarchy-battery-charge-limit";
|
||||||
|
nodes.machine = { ... }: {
|
||||||
|
imports = [ ./modules/nixos/options.nix ./modules/nixos/power.nix ];
|
||||||
|
boot.kernelModules = [ "test_power" ]; # fake Mains + battery
|
||||||
|
nomarchy.system.power = {
|
||||||
|
enable = true;
|
||||||
|
laptop = true;
|
||||||
|
batteryChargeLimit = 80;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
testScript = ''
|
||||||
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
|
||||||
|
# The oneshot exists and the fake Mains adapter is present.
|
||||||
|
machine.succeed("systemctl cat nomarchy-battery-charge-limit.service")
|
||||||
|
machine.succeed("udevadm settle")
|
||||||
|
machine.succeed("grep -lx Mains /sys/class/power_supply/*/type")
|
||||||
|
|
||||||
|
# Let any add-event restart settle, then sample the invocation.
|
||||||
|
machine.succeed("sleep 2")
|
||||||
|
before = machine.succeed(
|
||||||
|
"systemctl show -p InvocationID --value "
|
||||||
|
"nomarchy-battery-charge-limit.service"
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
# Simulate an AC unplug → 'change' uevent on the Mains device.
|
||||||
|
machine.succeed("echo off > /sys/module/test_power/parameters/ac_online")
|
||||||
|
machine.succeed("udevadm settle")
|
||||||
|
|
||||||
|
# The udev rule must have restarted the oneshot (new InvocationID).
|
||||||
|
machine.wait_until_succeeds(
|
||||||
|
'test "$(systemctl show -p InvocationID --value '
|
||||||
|
'nomarchy-battery-charge-limit.service)" != "' + before + '"',
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Unit test of the monitor-rule overlay (modules/home/
|
||||||
|
# monitor-rules.nix — pure, imported directly, no HM eval): the
|
||||||
|
# active profile must replace base entries whole by name, leave
|
||||||
|
# unnamed base outputs alone, and the disable-guard must stop a
|
||||||
|
# stale menu resolution pick (settings.monitors) resurrecting a
|
||||||
|
# panel the profile disables. Junk in settings.displayProfile
|
||||||
|
# degrades to base config, never an eval error.
|
||||||
|
display-profiles =
|
||||||
|
let
|
||||||
|
inherit (nixpkgs) lib;
|
||||||
|
monitorLib = import ./modules/home/monitor-rules.nix { inherit lib; };
|
||||||
|
entry = a: monitorLib.defaults // a;
|
||||||
|
fixture = {
|
||||||
|
base = [
|
||||||
|
(entry { name = "eDP-1"; position = "0x0"; })
|
||||||
|
(entry { name = "HDMI-A-1"; position = "auto-right"; })
|
||||||
|
];
|
||||||
|
profiles.docked = [
|
||||||
|
(entry { name = "eDP-1"; resolution = "disable"; })
|
||||||
|
(entry { name = "DP-3"; position = "0x0"; })
|
||||||
|
];
|
||||||
|
resOverrides."eDP-1" = "1920x1080@60"; # stale pick — must stay dead
|
||||||
|
};
|
||||||
|
rulesFor = active: map monitorLib.rule
|
||||||
|
(monitorLib.resolve (fixture // { inherit active; }));
|
||||||
|
docked = rulesFor "docked";
|
||||||
|
junk = rulesFor 42; # hand-edited state (validator only warns)
|
||||||
|
expect = cond: msg: lib.assertMsg cond
|
||||||
|
"display-profiles: ${msg} (rendered: ${builtins.toJSON docked})";
|
||||||
|
ok =
|
||||||
|
expect (lib.elem "eDP-1, disable" docked)
|
||||||
|
"the profile did not disable eDP-1"
|
||||||
|
&& expect (! lib.any (lib.hasInfix "1920x1080") docked)
|
||||||
|
"a stale resolution pick resurrected the disabled panel"
|
||||||
|
&& expect (lib.elem "DP-3, preferred, 0x0, 1" docked)
|
||||||
|
"the profile-only output's rule is missing"
|
||||||
|
&& expect (lib.elem "HDMI-A-1, preferred, auto-right, 1" docked)
|
||||||
|
"an unnamed base output lost its rule"
|
||||||
|
&& expect (lib.elem "eDP-1, 1920x1080@60, 0x0, 1" junk)
|
||||||
|
"junk active profile must degrade to base config (with the pick applied)"
|
||||||
|
&& expect (monitorLib.workspaceRule "1" "DP-3" == "1, monitor:DP-3")
|
||||||
|
"workspace pin did not render as a Hyprland workspace rule";
|
||||||
|
in
|
||||||
|
assert ok; pkgs.runCommand "nomarchy-display-profiles" { } "touch $out";
|
||||||
|
|
||||||
|
# The low-battery watcher's crossing logic: one toast per downward
|
||||||
|
# crossing (normal at 25%, critical at 10%), re-armed by charging.
|
||||||
|
# test_power fakes a battery whose capacity/status we script;
|
||||||
|
# notify-send is shimmed to a log via PATH (the package resolves
|
||||||
|
# it from PATH for exactly this reason). What stays on-hardware:
|
||||||
|
# the real toast rendering through swaync in a session.
|
||||||
|
battery-notify = pkgs.testers.runNixOSTest {
|
||||||
|
name = "nomarchy-battery-notify";
|
||||||
|
nodes.machine = { ... }: {
|
||||||
|
boot.kernelModules = [ "test_power" ]; # fake battery + Mains
|
||||||
|
environment.systemPackages = [ pkgs.nomarchy-battery-notify ];
|
||||||
|
};
|
||||||
|
testScript = ''
|
||||||
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
|
||||||
|
# notify-send shim: log the calls instead of needing a session bus.
|
||||||
|
machine.succeed(
|
||||||
|
"mkdir -p /shim && printf '#!/bin/sh\necho \"$*\" >> /tmp/notifications\n'"
|
||||||
|
" > /shim/notify-send && chmod +x /shim/notify-send"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Self-gate: with the fake battery gone, the watcher exits 0 at once.
|
||||||
|
machine.succeed("modprobe -r test_power")
|
||||||
|
machine.succeed("timeout 5 nomarchy-battery-notify 1")
|
||||||
|
machine.succeed("modprobe test_power")
|
||||||
|
|
||||||
|
# Healthy level while discharging: silence.
|
||||||
|
machine.succeed("echo discharging > /sys/module/test_power/parameters/battery_status")
|
||||||
|
machine.succeed("echo 80 > /sys/module/test_power/parameters/battery_capacity")
|
||||||
|
machine.succeed(
|
||||||
|
"systemd-run --unit=batwatch --setenv=PATH=/shim:/run/current-system/sw/bin"
|
||||||
|
" /run/current-system/sw/bin/nomarchy-battery-notify 1"
|
||||||
|
)
|
||||||
|
machine.sleep(3)
|
||||||
|
machine.fail("test -s /tmp/notifications")
|
||||||
|
|
||||||
|
# Crossing 25% fires the low toast exactly once, not per poll.
|
||||||
|
machine.succeed("echo 20 > /sys/module/test_power/parameters/battery_capacity")
|
||||||
|
machine.wait_until_succeeds("grep -q 'Battery low' /tmp/notifications")
|
||||||
|
machine.succeed("echo 15 > /sys/module/test_power/parameters/battery_capacity")
|
||||||
|
machine.sleep(3)
|
||||||
|
assert machine.succeed("grep -c 'Battery low' /tmp/notifications").strip() == "1"
|
||||||
|
|
||||||
|
# Crossing 10% escalates to a critical-urgency toast, once.
|
||||||
|
machine.succeed("echo 5 > /sys/module/test_power/parameters/battery_capacity")
|
||||||
|
machine.wait_until_succeeds("grep -q 'Battery critical' /tmp/notifications")
|
||||||
|
assert "critical" in machine.succeed("grep 'Battery critical' /tmp/notifications")
|
||||||
|
machine.sleep(3)
|
||||||
|
assert machine.succeed("grep -c 'Battery critical' /tmp/notifications").strip() == "1"
|
||||||
|
|
||||||
|
# Back on the charger → re-armed: the next drain notifies again.
|
||||||
|
machine.succeed("echo charging > /sys/module/test_power/parameters/battery_status")
|
||||||
|
machine.succeed("echo 80 > /sys/module/test_power/parameters/battery_capacity")
|
||||||
|
machine.sleep(3)
|
||||||
|
machine.succeed("echo discharging > /sys/module/test_power/parameters/battery_status")
|
||||||
|
machine.succeed("echo 20 > /sys/module/test_power/parameters/battery_capacity")
|
||||||
|
machine.wait_until_succeeds(
|
||||||
|
"test \"$(grep -c 'Battery low' /tmp/notifications)\" = 2"
|
||||||
|
)
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Memory-pressure protection (oom.nix): earlyoom must kill a
|
||||||
|
# runaway allocator BEFORE the kernel thrash point — and must not
|
||||||
|
# take anything else with it. A bystander unit survives the kill,
|
||||||
|
# proving the process-level granularity that motivated earlyoom
|
||||||
|
# over cgroup-level systemd-oomd (which would kill the whole
|
||||||
|
# Hyprland session scope). Imports only oom.nix so the VM stays
|
||||||
|
# minimal.
|
||||||
|
oom-protection = pkgs.testers.runNixOSTest {
|
||||||
|
name = "nomarchy-oom-protection";
|
||||||
|
nodes.machine = { ... }: {
|
||||||
|
imports = [ ./modules/nixos/oom.nix ];
|
||||||
|
environment.systemPackages = [ pkgs.python3 ];
|
||||||
|
virtualisation.memorySize = 1024;
|
||||||
|
};
|
||||||
|
testScript = ''
|
||||||
|
machine.wait_for_unit("earlyoom.service")
|
||||||
|
|
||||||
|
# One owner: oomd (nixpkgs default-on, inert) is disabled.
|
||||||
|
machine.fail("systemctl is-active --quiet systemd-oomd.service")
|
||||||
|
# The session avoid-list made it into the daemon invocation.
|
||||||
|
machine.succeed("systemctl cat earlyoom.service | grep -q -- --avoid")
|
||||||
|
|
||||||
|
# A bystander that must survive (stand-in for the session).
|
||||||
|
machine.succeed("systemd-run --unit=bystander sleep infinity")
|
||||||
|
|
||||||
|
# The hog: allocate in 20 MB chunks with a short sleep, so
|
||||||
|
# earlyoom's poll wins the race against the kernel OOM killer.
|
||||||
|
machine.succeed(
|
||||||
|
"systemd-run --unit=hog python3 -c 'import time; "
|
||||||
|
"exec(\"a=[]\\nwhile True:\\n a.append(bytearray(20*1024*1024)); time.sleep(0.05)\")'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# earlyoom (not the kernel) pulled the trigger…
|
||||||
|
machine.wait_until_succeeds(
|
||||||
|
"journalctl -u earlyoom.service | grep -Eiq 'sending SIG(TERM|KILL)'",
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
# …the hog is gone…
|
||||||
|
machine.wait_until_succeeds(
|
||||||
|
'test "$(systemctl show -p ActiveState --value hog.service)" != "active"',
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
# …and the bystander is untouched.
|
||||||
|
machine.succeed("systemctl is-active --quiet bystander.service")
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Snapshot GUI canary: btrfs-assistant crashes in libbtrfsutil's
|
||||||
|
# UNPRIVILEGED subvolume iteration (btrfs-progs 6.17.1, fixed
|
||||||
|
# upstream after 6.17.1) but runs fine as root — which is how the
|
||||||
|
# menu launches it (pkexec launcher). Guard the root path on a
|
||||||
|
# real btrfs volume so a lock bump that breaks it fails here, not
|
||||||
|
# on a user's machine. The GUI event loop is exercised offscreen
|
||||||
|
# (survives a 5s timeout = no startup crash past the Btrfs scan).
|
||||||
|
snapshot-gui = pkgs.testers.runNixOSTest {
|
||||||
|
name = "nomarchy-snapshot-gui";
|
||||||
|
nodes.machine = { pkgs, ... }: {
|
||||||
|
environment.systemPackages = [ pkgs.btrfs-assistant pkgs.btrfs-progs ];
|
||||||
|
virtualisation.emptyDiskImages = [ 512 ];
|
||||||
|
};
|
||||||
|
testScript = ''
|
||||||
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
machine.succeed(
|
||||||
|
"mkfs.btrfs /dev/vdb && mkdir -p /mnt && mount /dev/vdb /mnt "
|
||||||
|
"&& btrfs subvolume create /mnt/sub1"
|
||||||
|
)
|
||||||
|
# Root (the launcher's effective context): must work.
|
||||||
|
machine.succeed("QT_QPA_PLATFORM=offscreen btrfs-assistant-bin --version")
|
||||||
|
# The GUI proper survives startup (timeout kill = alive → 124).
|
||||||
|
machine.succeed(
|
||||||
|
"set +e; timeout 5 env QT_QPA_PLATFORM=offscreen btrfs-assistant-bin; "
|
||||||
|
"[ $? -eq 124 ]"
|
||||||
|
)
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# The distroId question (roadmap "Distro branding"): set
|
||||||
|
# distroId = "nomarchy" so /etc/os-release is honest (ID=nomarchy,
|
||||||
|
# ID_LIKE=nixos). The risk is switch-to-configuration's "is this
|
||||||
|
# NixOS?" guard — but it builds the check from the *configured*
|
||||||
|
# distroId (and /etc/NIXOS still exists), so a rebuild must still
|
||||||
|
# work. This boots such a system and runs `switch-to-configuration
|
||||||
|
# dry-activate` to prove the guard passes. Inline (not the full
|
||||||
|
# module) so the VM stays minimal — the real wiring in default.nix
|
||||||
|
# is covered by the downstream-template-system build.
|
||||||
|
distro-id = pkgs.testers.runNixOSTest {
|
||||||
|
name = "nomarchy-distro-id";
|
||||||
|
nodes.machine = { ... }: {
|
||||||
|
system.nixos.distroName = "Nomarchy";
|
||||||
|
system.nixos.distroId = "nomarchy";
|
||||||
|
# Test base omits switch-to-configuration by default; we need it
|
||||||
|
# to exercise the "is this NixOS?" guard.
|
||||||
|
system.switch.enable = true;
|
||||||
|
};
|
||||||
|
testScript = { nodes, ... }: ''
|
||||||
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
|
||||||
|
# os-release reflects the rebrand, with the nixos lineage marker.
|
||||||
|
machine.succeed("grep -q '^ID=nomarchy$' /etc/os-release")
|
||||||
|
machine.succeed("grep -q '^ID_LIKE=nixos$' /etc/os-release")
|
||||||
|
machine.succeed("grep -q '^NAME=Nomarchy$' /etc/os-release")
|
||||||
|
|
||||||
|
# The rebuild path must still recognise this as a NixOS install
|
||||||
|
# (the guard is built from the configured distroId, not "nixos").
|
||||||
|
machine.succeed(
|
||||||
|
"${nodes.machine.system.build.toplevel}/bin/switch-to-configuration dry-activate"
|
||||||
|
)
|
||||||
|
'';
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# ─── Reference host ────────────────────────────────────────────
|
# ─── Reference host ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,26 +4,85 @@
|
|||||||
{ lib, pkgs, username, nomarchySrc, ... }:
|
{ lib, pkgs, username, nomarchySrc, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
# ISO boot splash: the Nomarchy monogram recolored to the palette accent,
|
# ISO boot branding: the Nomarchy monogram recolored to the palette accent,
|
||||||
# centred on the theme base. Built from the vendored vector logo and the
|
# centred on the theme base. Built from the vendored vector logo and the
|
||||||
# live theme-state.json (tokyo-night by default). Shows on the isolinux
|
# live theme-state.json (tokyo-night by default). The same composed image
|
||||||
# (BIOS) boot menu; UEFI/GRUB still uses the stock theme (see roadmap).
|
# backs both the isolinux (BIOS) splash and the GRUB (UEFI) theme below, so
|
||||||
|
# the two boot paths match.
|
||||||
state = builtins.fromJSON (builtins.readFile ../theme-state.json);
|
state = builtins.fromJSON (builtins.readFile ../theme-state.json);
|
||||||
isoColor = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback);
|
isoColor = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback);
|
||||||
|
accent = isoColor "accent" "7aa2f7";
|
||||||
|
base = isoColor "base" "1a1b26";
|
||||||
|
subtext = isoColor "subtext" "787c99";
|
||||||
|
|
||||||
isoSplash = pkgs.runCommand "nomarchy-iso-splash.png"
|
isoSplash = pkgs.runCommand "nomarchy-iso-splash.png"
|
||||||
{ nativeBuildInputs = [ pkgs.imagemagick pkgs.librsvg ]; } ''
|
{ nativeBuildInputs = [ pkgs.imagemagick pkgs.librsvg ]; } ''
|
||||||
rsvg-convert -h 320 ${../modules/nixos/branding/logo.svg} > logo.png
|
rsvg-convert -h 320 ${../modules/nixos/branding/logo.svg} > logo.png
|
||||||
magick logo.png -fill "#${isoColor "accent" "7aa2f7"}" -colorize 100 logo-c.png
|
magick logo.png -fill "#${accent}" -colorize 100 logo-c.png
|
||||||
magick -size 1920x1080 xc:"#${isoColor "base" "1a1b26"}" \
|
magick -size 1920x1080 xc:"#${base}" \
|
||||||
logo-c.png -gravity center -composite $out
|
logo-c.png -gravity center -composite $out
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# GRUB (UEFI) theme matched to the BIOS splash: the same accent-logo-on-base
|
||||||
|
# image as the background, plus a palette-coloured boot menu in the lower
|
||||||
|
# third (clear of the centred logo). Derived from nixos-grub2-theme only to
|
||||||
|
# reuse its bundled DejaVu .pf2 font — we overwrite the background and
|
||||||
|
# theme.txt and drop the stock NixOS logo (ours is in the background). grub
|
||||||
|
# loads every .pf2 in the dir, so "DejaVu Regular" resolves.
|
||||||
|
grubThemeTxt = pkgs.writeText "nomarchy-grub-theme.txt" ''
|
||||||
|
title-text: ""
|
||||||
|
desktop-image: "background.png"
|
||||||
|
desktop-color: "#${base}"
|
||||||
|
|
||||||
|
message-font: "DejaVu Regular"
|
||||||
|
message-color: "#${subtext}"
|
||||||
|
terminal-font: "DejaVu Regular"
|
||||||
|
|
||||||
|
+ boot_menu {
|
||||||
|
left = 50%-300
|
||||||
|
width = 600
|
||||||
|
top = 64%
|
||||||
|
height = 26%
|
||||||
|
item_font = "DejaVu Regular"
|
||||||
|
item_color = "#${subtext}"
|
||||||
|
item_height = 36
|
||||||
|
item_spacing = 6
|
||||||
|
selected_item_font = "DejaVu Regular"
|
||||||
|
selected_item_color = "#${accent}"
|
||||||
|
scrollbar = false
|
||||||
|
}
|
||||||
|
|
||||||
|
+ progress_bar {
|
||||||
|
id = "__timeout__"
|
||||||
|
left = 50%-300
|
||||||
|
top = 92%
|
||||||
|
width = 600
|
||||||
|
height = 16
|
||||||
|
show_text = true
|
||||||
|
text = "@TIMEOUT_NOTIFICATION_MIDDLE@"
|
||||||
|
font = "DejaVu Regular"
|
||||||
|
text_color = "#${subtext}"
|
||||||
|
border_color = "#${accent}"
|
||||||
|
bg_color = "#${base}"
|
||||||
|
fg_color = "#${accent}"
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
|
||||||
|
nomarchyGrubTheme = pkgs.runCommand "nomarchy-grub-theme" { } ''
|
||||||
|
cp -r ${pkgs.nixos-grub2-theme} $out
|
||||||
|
chmod -R u+w $out
|
||||||
|
cp ${isoSplash} $out/background.png
|
||||||
|
cp ${grubThemeTxt} $out/theme.txt
|
||||||
|
rm -f $out/logo.png
|
||||||
|
'';
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
networking.hostName = "nomarchy-live";
|
networking.hostName = "nomarchy-live";
|
||||||
|
|
||||||
isoImage.volumeID = lib.mkForce "NOMARCHY_LIVE";
|
isoImage.volumeID = lib.mkForce "NOMARCHY_LIVE";
|
||||||
isoImage.edition = lib.mkForce "live";
|
isoImage.edition = lib.mkForce "live";
|
||||||
isoImage.splashImage = isoSplash;
|
isoImage.splashImage = isoSplash; # isolinux / BIOS
|
||||||
|
isoImage.grubTheme = nomarchyGrubTheme; # GRUB / UEFI
|
||||||
|
|
||||||
# The minimal-CD profile slims the image for a CONSOLE installer; this
|
# The minimal-CD profile slims the image for a CONSOLE installer; this
|
||||||
# ISO is the desktop, so re-enable what it strips. Above all
|
# ISO is the desktop, so re-enable what it strips. Above all
|
||||||
|
|||||||
29
modules/home/battery-notify.nix
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Low-battery notifications (nomarchy.batteryNotify) — the bar colors the
|
||||||
|
# battery @warn/@bad at 25/10% (waybar.nix battery.states) but nothing
|
||||||
|
# *notified*; this watcher fires a toast at those same thresholds. Session-
|
||||||
|
# side and self-gating on a battery being present (the powerprofile-script
|
||||||
|
# pattern — no system→home coupling), so it's a silent no-op on desktops.
|
||||||
|
# swaync shows critical toasts until dismissed (timeout-critical = 0), so
|
||||||
|
# the 10% one can't slip by unseen. Logic lives in pkgs/nomarchy-battery-
|
||||||
|
# notify (overlay), where checks.battery-notify exercises it.
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
{
|
||||||
|
config = lib.mkIf config.nomarchy.batteryNotify.enable {
|
||||||
|
systemd.user.services.nomarchy-battery-notify = {
|
||||||
|
Unit = {
|
||||||
|
Description = "Low-battery notifications (25/10%, the bar's thresholds)";
|
||||||
|
PartOf = [ "graphical-session.target" ];
|
||||||
|
After = [ "graphical-session.target" ];
|
||||||
|
};
|
||||||
|
Service = {
|
||||||
|
# The script resolves notify-send from PATH (that's what the VM
|
||||||
|
# check shims); here libnotify provides the real one.
|
||||||
|
Environment = "PATH=${lib.makeBinPath [ pkgs.libnotify ]}";
|
||||||
|
ExecStart = "${pkgs.nomarchy-battery-notify}/bin/nomarchy-battery-notify";
|
||||||
|
Restart = "on-failure";
|
||||||
|
};
|
||||||
|
Install.WantedBy = [ "graphical-session.target" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Nomarchy — Home Manager entry point.
|
# Nomarchy — Home Manager entry point.
|
||||||
# Consume this via homeModules.nomarchy (flake.nix), which also pulls in
|
# Consume this via homeModules.nomarchy (flake.nix), which also pulls in
|
||||||
# the stylix home module that stylix.nix configures.
|
# the stylix home module that stylix.nix configures.
|
||||||
{ config, pkgs, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
@@ -18,9 +18,15 @@
|
|||||||
./yazi.nix # flagship TUI file manager, themed + plugins
|
./yazi.nix # flagship TUI file manager, themed + plugins
|
||||||
./osd.nix # swayosd volume/brightness OSD, themed
|
./osd.nix # swayosd volume/brightness OSD, themed
|
||||||
./nightlight.nix # scheduled blue-light filter (hyprsunset), opt-in
|
./nightlight.nix # scheduled blue-light filter (hyprsunset), opt-in
|
||||||
|
./timezone.nix # keep the Waybar clock in step with auto-timezone changes
|
||||||
|
./updates.nix # passive update-awareness indicator + notification, opt-in
|
||||||
./shell.nix # zsh + starship + bat/eza/zoxide, themed
|
./shell.nix # zsh + starship + bat/eza/zoxide, themed
|
||||||
./keys.nix # gpg-agent (fronts SSH) + pinentry-qt
|
./keys.nix # gpg-agent (fronts SSH) + pinentry-qt
|
||||||
./fastfetch.nix # system info with the themed Nomarchy logo
|
./fastfetch.nix # system info with the themed Nomarchy logo
|
||||||
|
./viewers.nix # zathura (Stylix-themed) + imv — PDF/image viewing
|
||||||
|
./mime.nix # default applications (mimeapps.list), degrades with the suite
|
||||||
|
./recording.nix # nomarchy-record: screen recording behind Capture + the bar ⏺
|
||||||
|
./battery-notify.nix # low-battery toasts at the bar's 25/10% thresholds
|
||||||
];
|
];
|
||||||
|
|
||||||
# Clipboard history (wl-paste watcher); browsed via the SUPER+CTRL+V
|
# Clipboard history (wl-paste watcher); browsed via the SUPER+CTRL+V
|
||||||
@@ -32,6 +38,16 @@
|
|||||||
services.network-manager-applet.enable = true;
|
services.network-manager-applet.enable = true;
|
||||||
xsession.preferStatusNotifierItems = true;
|
xsession.preferStatusNotifierItems = true;
|
||||||
|
|
||||||
|
# Standard XDG user directories (Downloads, Documents, Pictures, Music,
|
||||||
|
# Videos, Desktop, Public, Templates): written to user-dirs.dirs so file
|
||||||
|
# pickers/browsers resolve them, and created on activation so a fresh
|
||||||
|
# install lands with them present (not just on first app use). mkDefault
|
||||||
|
# so a downstream home.nix can flip it off or remap individual paths.
|
||||||
|
xdg.userDirs = {
|
||||||
|
enable = lib.mkDefault true;
|
||||||
|
createDirectories = lib.mkDefault true;
|
||||||
|
};
|
||||||
|
|
||||||
home.stateVersion = "26.05";
|
home.stateVersion = "26.05";
|
||||||
|
|
||||||
home.packages = with pkgs; [
|
home.packages = with pkgs; [
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ let
|
|||||||
c = t.colors;
|
c = t.colors;
|
||||||
inherit (config.nomarchy.lib) rgb rgba;
|
inherit (config.nomarchy.lib) rgb rgba;
|
||||||
|
|
||||||
|
# nomarchy-theme-sync — the in-flake state writer the keyboard watcher uses
|
||||||
|
# to remember per-device layouts (same path night-light's toggle takes).
|
||||||
|
sync = lib.getExe config.nomarchy.package;
|
||||||
|
|
||||||
# swayosd's `--input-volume mute-toggle` draws the OSD but never flips the
|
# swayosd's `--input-volume mute-toggle` draws the OSD but never flips the
|
||||||
# source mute (verified on hardware: wpctl toggles the state, swayosd's
|
# source mute (verified on hardware: wpctl toggles the state, swayosd's
|
||||||
# input-mute path is a no-op — unlike its working output-mute path). Do the
|
# input-mute path is a no-op — unlike its working output-mute path). Do the
|
||||||
@@ -27,6 +31,34 @@ let
|
|||||||
${pkgs.swayosd}/bin/swayosd-client --custom-icon "$icon" --custom-progress "$2"
|
${pkgs.swayosd}/bin/swayosd-client --custom-icon "$icon" --custom-progress "$2"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# Launch-or-focus (nomarchy.launchOrFocus, item 17): focus the app's
|
||||||
|
# window if one exists (case-insensitive class match), launch it
|
||||||
|
# otherwise. Self-gates: an uninstalled command notifies instead of
|
||||||
|
# failing silently, so a bind survives the app being removed from the
|
||||||
|
# suite. rofi.nix renders the same entries into the SUPER+? cheatsheet.
|
||||||
|
focusOrLaunch = pkgs.writeShellScriptBin "nomarchy-focus-or-launch" ''
|
||||||
|
class="''${1:-}"; shift || true
|
||||||
|
[ -n "$class" ] && [ $# -gt 0 ] || {
|
||||||
|
echo "usage: nomarchy-focus-or-launch <class> <command...>" >&2; exit 64
|
||||||
|
}
|
||||||
|
if hyprctl clients -j 2>/dev/null \
|
||||||
|
| ${pkgs.jq}/bin/jq -e --arg c "$class" \
|
||||||
|
'any(.[]; (.class | ascii_downcase) == ($c | ascii_downcase))' >/dev/null; then
|
||||||
|
exec hyprctl dispatch focuswindow "class:(?i)^$class\$"
|
||||||
|
fi
|
||||||
|
command -v "$1" >/dev/null 2>&1 \
|
||||||
|
|| { notify-send "Launch" "$1 is not installed."; exit 0; }
|
||||||
|
exec "$@"
|
||||||
|
'';
|
||||||
|
lofEntries = map
|
||||||
|
(e: e // {
|
||||||
|
command = if e.command == "" then lib.toLower e.class else e.command;
|
||||||
|
})
|
||||||
|
config.nomarchy.launchOrFocus;
|
||||||
|
lofBinds = map
|
||||||
|
(e: "${e.mods}, ${e.key}, exec, ${focusOrLaunch}/bin/nomarchy-focus-or-launch ${e.class} ${e.command}")
|
||||||
|
lofEntries;
|
||||||
|
|
||||||
# SUPER+1..9 / SUPER+SHIFT+1..9 workspace binds, generated.
|
# SUPER+1..9 / SUPER+SHIFT+1..9 workspace binds, generated.
|
||||||
workspaceBinds = builtins.concatLists (builtins.genList
|
workspaceBinds = builtins.concatLists (builtins.genList
|
||||||
(i:
|
(i:
|
||||||
@@ -42,23 +74,151 @@ let
|
|||||||
keybinds = import ./keybinds.nix;
|
keybinds = import ./keybinds.nix;
|
||||||
mkBind = b: "${b.mods}, ${b.key}, ${b.action}";
|
mkBind = b: "${b.mods}, ${b.key}, ${b.action}";
|
||||||
|
|
||||||
# A nomarchy.monitors entry -> a Hyprland `monitor` rule. Unset optional
|
# Monitor-rule composition (rendering + the display-profile/resolution
|
||||||
# fields are omitted; `resolution = "disable"` collapses to the short form.
|
# overlays) lives in ./monitor-rules.nix — pure, so
|
||||||
monitorRule = m:
|
# checks.display-profiles unit-tests the overlay semantics directly.
|
||||||
if m.resolution == "disable" then "${m.name}, disable"
|
# The layering story (profiles replace whole-by-name, menu resolution
|
||||||
else lib.concatStringsSep ", " (
|
# picks apply field-level, disable-guard) is documented there.
|
||||||
[ m.name m.resolution (toString m.position) (toString m.scale) ]
|
monitorLib = import ./monitor-rules.nix { inherit lib; };
|
||||||
++ lib.optionals (m.transform != null) [ "transform" (toString m.transform) ]
|
monitorRule = monitorLib.rule;
|
||||||
++ lib.optionals (m.mirror != null) [ "mirror" m.mirror ]
|
|
||||||
++ lib.optionals (m.bitdepth != null) [ "bitdepth" (toString m.bitdepth) ]
|
|
||||||
++ lib.optionals (m.vrr != null) [ "vrr" (toString m.vrr) ]
|
|
||||||
++ lib.optional (m.extra != "") m.extra
|
|
||||||
);
|
|
||||||
|
|
||||||
# Keyboard layout candidates: the primary nomarchy.keyboard.layout (split on
|
# The night-light pattern: nomarchy-display-profile applies rules live
|
||||||
# commas) plus any nomarchy.keyboard.layouts. Drives input.kb_layout so all
|
# via hyprctl and writes settings.displayProfile with --no-switch;
|
||||||
# of them are switchable, and feeds the watcher's picker.
|
# resolve bakes the same choice here at the next rebuild.
|
||||||
kbLayouts = lib.unique (
|
# Profiles normalized to { monitors; workspaces; } — the option also
|
||||||
|
# accepts the original bare-list-of-monitors shape.
|
||||||
|
profiles = lib.mapAttrs
|
||||||
|
(_: p: if builtins.isList p then { monitors = p; workspaces = { }; } else p)
|
||||||
|
config.nomarchy.displayProfiles;
|
||||||
|
resolvedMonitors = monitorLib.resolve {
|
||||||
|
base = config.nomarchy.monitors;
|
||||||
|
profiles = lib.mapAttrs (_: p: p.monitors) profiles;
|
||||||
|
active = config.nomarchy.settings.displayProfile or "";
|
||||||
|
resOverrides = config.nomarchy.settings.monitors;
|
||||||
|
};
|
||||||
|
|
||||||
|
# The active profile's workspace→output pins, baked as `workspace`
|
||||||
|
# rules below (same rebuild path as the monitor overlay; the applier
|
||||||
|
# covers the instant half). Same junk-state guard as resolve.
|
||||||
|
activeProfile =
|
||||||
|
let a = config.nomarchy.settings.displayProfile or "";
|
||||||
|
in profiles.${if builtins.isString a then a else ""} or { workspaces = { }; };
|
||||||
|
profileWorkspaceRules =
|
||||||
|
lib.mapAttrsToList monitorLib.workspaceRule activeProfile.workspaces;
|
||||||
|
|
||||||
|
# Profile applier — on PATH only when profiles are declared (the menu
|
||||||
|
# row self-gates on it). apply: the profile's rules live via hyprctl +
|
||||||
|
# the in-flake state write; base: clear the state, then hyprctl reload
|
||||||
|
# re-applies the generated config's rules.
|
||||||
|
displayProfileTool = pkgs.writeShellScriptBin "nomarchy-display-profile" ''
|
||||||
|
case "''${1:-}" in
|
||||||
|
list)
|
||||||
|
${lib.concatMapStringsSep "\n" (n: " echo ${lib.escapeShellArg n}") (lib.attrNames profiles)} ;;
|
||||||
|
active)
|
||||||
|
cur=$(${sync} get settings.displayProfile 2>/dev/null) || cur=
|
||||||
|
echo "''${cur:-none}" ;;
|
||||||
|
apply)
|
||||||
|
case "''${2:-}" in
|
||||||
|
${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||||
|
(name: p:
|
||||||
|
" ${lib.escapeShellArg name})\n"
|
||||||
|
+ lib.concatMapStringsSep "\n"
|
||||||
|
(m: " hyprctl keyword monitor ${lib.escapeShellArg (monitorRule m)} >/dev/null 2>&1")
|
||||||
|
p.monitors
|
||||||
|
# Workspace pins: the keyword sets the session rule, the dispatch
|
||||||
|
# moves an already-open workspace over. Pins from a previously
|
||||||
|
# applied profile linger until reload/rebuild (hyprctl can only
|
||||||
|
# add) — harmless, a rule naming an absent output is inert.
|
||||||
|
+ lib.concatStrings (lib.mapAttrsToList
|
||||||
|
(ws: out:
|
||||||
|
"\n hyprctl keyword workspace ${lib.escapeShellArg (monitorLib.workspaceRule ws out)} >/dev/null 2>&1"
|
||||||
|
+ "\n hyprctl dispatch moveworkspacetomonitor ${lib.escapeShellArg ws} ${lib.escapeShellArg out} >/dev/null 2>&1")
|
||||||
|
p.workspaces)
|
||||||
|
+ " ;;")
|
||||||
|
profiles)}
|
||||||
|
*) echo "unknown profile '$2' — try: nomarchy-display-profile list" >&2; exit 64 ;;
|
||||||
|
esac
|
||||||
|
${sync} --quiet set settings.displayProfile "$2" --no-switch
|
||||||
|
notify-send "Display profile" "Applied: $2" ;;
|
||||||
|
base)
|
||||||
|
${sync} --quiet set settings.displayProfile "" --no-switch
|
||||||
|
hyprctl reload >/dev/null 2>&1
|
||||||
|
notify-send "Display profile" "Base layout restored." ;;
|
||||||
|
match)
|
||||||
|
# Which profile fits these connected outputs? (args = output names,
|
||||||
|
# e.g. `match $(hyprctl monitors all -j | jq -r '.[].name')`.)
|
||||||
|
# An exact set match wins; else the largest profile whose named
|
||||||
|
# outputs are all connected; any tie prints nothing (exit 1) — the
|
||||||
|
# auto-switch watcher must never flap between ambiguous layouts.
|
||||||
|
shift
|
||||||
|
[ "$#" -gt 0 ] || exit 1
|
||||||
|
con=" $* " ncon=$#
|
||||||
|
exact= exactdup= best= bestn=0 dup=
|
||||||
|
while IFS=: read -r p names; do
|
||||||
|
[ -n "$p" ] || continue
|
||||||
|
n=0 ok=1
|
||||||
|
for o in $names; do
|
||||||
|
case "$con" in *" $o "*) n=$((n+1)) ;; *) ok=; break ;; esac
|
||||||
|
done
|
||||||
|
[ -n "$ok" ] || continue
|
||||||
|
if [ "$n" -eq "$ncon" ]; then
|
||||||
|
if [ -n "$exact" ]; then exactdup=1; else exact=$p; fi
|
||||||
|
fi
|
||||||
|
if [ "$n" -gt "$bestn" ]; then best=$p bestn=$n dup=
|
||||||
|
elif [ "$n" -eq "$bestn" ] && [ "$n" -gt 0 ]; then dup=1
|
||||||
|
fi
|
||||||
|
done < <(printf '%s\n' ${lib.concatMapStringsSep " " lib.escapeShellArg
|
||||||
|
(lib.mapAttrsToList
|
||||||
|
(name: p: "${name}:${lib.concatMapStringsSep " " (m: m.name) p.monitors}")
|
||||||
|
profiles)})
|
||||||
|
if [ -n "$exact" ] && [ -z "$exactdup" ]; then echo "$exact"; exit 0; fi
|
||||||
|
if [ -n "$best" ] && [ -z "$dup" ]; then echo "$best"; exit 0; fi
|
||||||
|
exit 1 ;;
|
||||||
|
*)
|
||||||
|
echo "usage: nomarchy-display-profile [list|active|apply <name>|base|match <output>...]" >&2
|
||||||
|
exit 64 ;;
|
||||||
|
esac
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Hotplug auto-switch (opt-in via the menu's Auto-switch row →
|
||||||
|
# settings.displayProfileAuto, read LIVE on every output change so the
|
||||||
|
# toggle is instant, no rebuild). Polls like the keyboard watcher —
|
||||||
|
# exec-once, not a systemd unit (graphical-session-bound units raced
|
||||||
|
# Hyprland's IPC on relogin; see the waybar note below). Only reacts to
|
||||||
|
# CHANGES after session start: the baked config already encodes the
|
||||||
|
# last choice, and login must not fight a deliberate manual pick.
|
||||||
|
# `match` picks deterministically (exact set, else unambiguous largest
|
||||||
|
# subset); applying via the tool persists the concrete profile, so the
|
||||||
|
# next rebuild bakes what auto chose.
|
||||||
|
displayProfileWatch = pkgs.writeShellScriptBin "nomarchy-display-profile-watch" ''
|
||||||
|
set -u
|
||||||
|
auto_on() {
|
||||||
|
v=$(${sync} get settings.displayProfileAuto 2>/dev/null) || v=false
|
||||||
|
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
|
||||||
|
}
|
||||||
|
outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; }
|
||||||
|
prev=$(outputs)
|
||||||
|
while :; do
|
||||||
|
sleep 3
|
||||||
|
cur=$(outputs)
|
||||||
|
[ "$cur" = "$prev" ] && continue
|
||||||
|
prev=$cur
|
||||||
|
[ -n "$cur" ] || continue
|
||||||
|
auto_on || continue
|
||||||
|
target=$(nomarchy-display-profile match $cur) || continue
|
||||||
|
[ "$target" = "$(nomarchy-display-profile active)" ] && continue
|
||||||
|
nomarchy-display-profile apply "$target"
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Candidate layouts offered by the new-keyboard picker: the session
|
||||||
|
# layout(s) (nomarchy.keyboard.layout, comma-split for a multi-layout
|
||||||
|
# session) plus the extra nomarchy.keyboard.layouts pool. The pool is
|
||||||
|
# deliberately NOT merged into input.kb_layout — those candidates are for
|
||||||
|
# *external* keyboards and get applied per-device by the watcher's apply().
|
||||||
|
# Loading them onto the session keyboard is what let a global switch flip
|
||||||
|
# the built-in board to the wrong layout.
|
||||||
|
pickerLayouts = lib.unique (
|
||||||
(lib.splitString "," config.nomarchy.keyboard.layout)
|
(lib.splitString "," config.nomarchy.keyboard.layout)
|
||||||
++ config.nomarchy.keyboard.layouts
|
++ config.nomarchy.keyboard.layouts
|
||||||
);
|
);
|
||||||
@@ -68,21 +228,41 @@ let
|
|||||||
# keyboard.devices, not already remembered), ask for a layout and persist it
|
# keyboard.devices, not already remembered), ask for a layout and persist it
|
||||||
# per-device, re-applying silently on later reconnects. Stateful runtime
|
# per-device, re-applying silently on later reconnects. Stateful runtime
|
||||||
# piece — the complement to the declarative keyboard.devices. Reliable
|
# piece — the complement to the declarative keyboard.devices. Reliable
|
||||||
# primitives only: poll hyprctl devices, apply via switchxkblayout (an index
|
# primitives only: poll hyprctl devices, apply with a per-device
|
||||||
# into kbLayouts). NOTE: needs an on-hardware test (hotplug isn't verifiable
|
# `device[<name>]:kb_layout` keyword — the runtime twin of the declarative
|
||||||
# in CI).
|
# device blocks, so it isolates that one keyboard and never disturbs the
|
||||||
|
# built-in board (switchxkblayout flipped the *shared* layout, which leaked
|
||||||
|
# onto the laptop and stuck after unplug).
|
||||||
|
#
|
||||||
|
# The remembered choices live in the git-tracked in-flake state
|
||||||
|
# (settings.keyboard.devices), NOT ~/.local/state — read live from the
|
||||||
|
# working tree (so a pick this session is honoured at once) and written
|
||||||
|
# instantly with `--no-switch` (no rebuild). A later rebuild graduates them
|
||||||
|
# into nomarchy.keyboard.devices (generated device{} blocks), after which the
|
||||||
|
# watcher treats them as declared and steps back. NOTE: needs an on-hardware
|
||||||
|
# test (hotplug isn't verifiable in CI).
|
||||||
keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" ''
|
keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" ''
|
||||||
set -u
|
set -u
|
||||||
layouts="${lib.concatStringsSep " " kbLayouts}"
|
layouts="${lib.concatStringsSep " " pickerLayouts}"
|
||||||
declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}"
|
declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}"
|
||||||
state="''${XDG_STATE_HOME:-$HOME/.local/state}/nomarchy/keyboard-layouts"
|
sync=${sync}
|
||||||
mkdir -p "$(dirname "$state")"; [ -f "$state" ] || : > "$state"
|
|
||||||
|
|
||||||
layout_index() { i=0; for l in $layouts; do [ "$l" = "$1" ] && { printf %s "$i"; return; }; i=$((i + 1)); done; printf %s -1; }
|
apply() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
|
||||||
apply() { idx=$(layout_index "$2"); [ "$idx" -ge 0 ] && hyprctl switchxkblayout "$1" "$idx" >/dev/null 2>&1; }
|
|
||||||
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
|
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
|
||||||
is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
|
is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
|
||||||
saved_for() { while IFS='=' read -r k v; do [ "$k" = "$1" ] && { printf '%s' "$v"; return; }; done < "$state"; }
|
|
||||||
|
# The remembered map (device-name -> layout) from the LIVE in-flake state;
|
||||||
|
# an absent key (sparse state) reads as an empty object.
|
||||||
|
saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; }
|
||||||
|
saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; }
|
||||||
|
|
||||||
|
# Persist a pick INSTANTLY: merge it into the map and write the whole object
|
||||||
|
# back at the dot-free parent path, so a device name containing a dot can't
|
||||||
|
# corrupt the dotted set-path. --no-switch = write only, no rebuild.
|
||||||
|
remember() {
|
||||||
|
map=$(saved_map | jq -c --arg k "$1" --arg v "$2" '. + {($k): $v}') || return
|
||||||
|
"$sync" --quiet set settings.keyboard.devices "$map" --no-switch
|
||||||
|
}
|
||||||
|
|
||||||
# Startup: re-apply remembered layouts, never prompt — so the built-in
|
# Startup: re-apply remembered layouts, never prompt — so the built-in
|
||||||
# keyboard just stays on the session default.
|
# keyboard just stays on the session default.
|
||||||
@@ -106,7 +286,7 @@ let
|
|||||||
apply "$kb" "$s"
|
apply "$kb" "$s"
|
||||||
else
|
else
|
||||||
choice=$(printf '%s\n' $layouts | rofi -dmenu -p "Layout · $kb")
|
choice=$(printf '%s\n' $layouts | rofi -dmenu -p "Layout · $kb")
|
||||||
[ -n "$choice" ] && { printf '%s=%s\n' "$kb" "$choice" >> "$state"; apply "$kb" "$choice"; }
|
[ -n "$choice" ] && { remember "$kb" "$choice"; apply "$kb" "$choice"; }
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
prev="$cur"
|
prev="$cur"
|
||||||
@@ -135,7 +315,11 @@ in
|
|||||||
# and Hyprland applies them on hotplug. mkDefault so a downstream
|
# and Hyprland applies them on hotplug. mkDefault so a downstream
|
||||||
# `monitor = [...]` replaces the lot (the live ISO uses mkForce too).
|
# `monitor = [...]` replaces the lot (the live ISO uses mkForce too).
|
||||||
monitor = lib.mkDefault
|
monitor = lib.mkDefault
|
||||||
([ ",preferred,auto,1" ] ++ map monitorRule config.nomarchy.monitors);
|
([ ",preferred,auto,1" ] ++ map monitorRule resolvedMonitors);
|
||||||
|
|
||||||
|
# The active display profile's workspace→output pins (a list, so
|
||||||
|
# downstream `workspace = [...]` rules concatenate).
|
||||||
|
workspace = profileWorkspaceRules;
|
||||||
|
|
||||||
# exec-once is a list at normal priority, so downstream additions
|
# exec-once is a list at normal priority, so downstream additions
|
||||||
# CONCATENATE (your autostart runs alongside ours) rather than
|
# CONCATENATE (your autostart runs alongside ours) rather than
|
||||||
@@ -146,7 +330,21 @@ in
|
|||||||
# Paint the wallpaper as soon as the session is up (waits for
|
# Paint the wallpaper as soon as the session is up (waits for
|
||||||
# the daemon internally).
|
# the daemon internally).
|
||||||
"nomarchy-theme-sync wallpaper"
|
"nomarchy-theme-sync wallpaper"
|
||||||
] ++ lib.optional kbAutoSwitch "${keyboardWatch}/bin/nomarchy-keyboard-watch";
|
# Polkit authentication agent — without one, EVERY pkexec/polkit
|
||||||
|
# prompt in the session fails silently (btrfs-assistant-launcher
|
||||||
|
# was the discovery case). hyprpolkitagent is Hyprland's own Qt
|
||||||
|
# agent, so the prompt is Stylix-themed like every other Qt
|
||||||
|
# surface. Its binary lives in libexec (not bin), hence the path.
|
||||||
|
"${pkgs.hyprpolkitagent}/libexec/hyprpolkitagent"
|
||||||
|
# Waybar is launched here rather than as a systemd user service: bound
|
||||||
|
# to graphical-session.target the unit raced Hyprland's IPC on relogin
|
||||||
|
# and landed in `failed`; exec-once only fires once the compositor is
|
||||||
|
# up. Via the nomarchy-waybar supervisor (waybar.nix): a crash — or
|
||||||
|
# the clean pkill a theme switch now does — respawns the bar instead
|
||||||
|
# of orphaning the session bar-less.
|
||||||
|
] ++ lib.optional config.nomarchy.waybar.enable "nomarchy-waybar"
|
||||||
|
++ lib.optional kbAutoSwitch "${keyboardWatch}/bin/nomarchy-keyboard-watch"
|
||||||
|
++ lib.optional (profiles != { }) "${displayProfileWatch}/bin/nomarchy-display-profile-watch";
|
||||||
|
|
||||||
# ── Theme-driven look ──────────────────────────────────────────
|
# ── Theme-driven look ──────────────────────────────────────────
|
||||||
# These flow from theme-state.json and stay at NORMAL priority:
|
# These flow from theme-state.json and stay at NORMAL priority:
|
||||||
@@ -201,8 +399,10 @@ in
|
|||||||
|
|
||||||
input = {
|
input = {
|
||||||
# kb_layout/variant come from nomarchy.keyboard.* — set that
|
# kb_layout/variant come from nomarchy.keyboard.* — set that
|
||||||
# option (the installer writes it; it also drives tty/LUKS).
|
# option (the installer writes it; it also drives tty/LUKS). Only the
|
||||||
kb_layout = lib.concatStringsSep "," kbLayouts;
|
# session layout(s) land here; the keyboard.layouts pool is applied
|
||||||
|
# per-device to external boards, never onto the session keyboard.
|
||||||
|
kb_layout = config.nomarchy.keyboard.layout;
|
||||||
kb_variant = config.nomarchy.keyboard.variant;
|
kb_variant = config.nomarchy.keyboard.variant;
|
||||||
follow_mouse = lib.mkDefault 1;
|
follow_mouse = lib.mkDefault 1;
|
||||||
touchpad.natural_scroll = lib.mkDefault true;
|
touchpad.natural_scroll = lib.mkDefault true;
|
||||||
@@ -231,13 +431,46 @@ in
|
|||||||
disable_hyprland_logo = lib.mkDefault true;
|
disable_hyprland_logo = lib.mkDefault true;
|
||||||
disable_splash_rendering = lib.mkDefault true;
|
disable_splash_rendering = lib.mkDefault true;
|
||||||
force_default_wallpaper = lib.mkDefault 0;
|
force_default_wallpaper = lib.mkDefault 0;
|
||||||
|
# The backdrop when no wallpaper is painted (awww still starting,
|
||||||
|
# a slow first paint, or a wallpaper-less setup): the theme's base
|
||||||
|
# instead of compositor black — on light themes the black flash
|
||||||
|
# reads as a glitch (item 28c, seen in the capture harness).
|
||||||
|
background_color = rgb c.base;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Window rules — float + center small config/utility dialogs that
|
||||||
|
# tile awkwardly. Normal-priority list like `bind`/`exec-once` below,
|
||||||
|
# so a downstream `windowrule = [...]` concatenates rather than
|
||||||
|
# replaces. Class regexes use `(?i)` and tolerate the XWayland
|
||||||
|
# `.…-wrapped` binary-name form.
|
||||||
|
#
|
||||||
|
# Conservative set on purpose (item 41): only windows the distro
|
||||||
|
# actually ships, each an unambiguous dialog. Deliberately NOT
|
||||||
|
# floated yet — needs on-hardware class verification: the polkit auth
|
||||||
|
# prompt (hyprpolkitagent exposes no discoverable class) and GTK
|
||||||
|
# file-chooser portals. A dead rule is harmless, but guessing a class
|
||||||
|
# is worse than leaving it for a verified pass.
|
||||||
|
windowrule = [
|
||||||
|
# Audio mixer (item 35): right-click the Waybar volume module.
|
||||||
|
"float, class:^(com\\.saivert\\.pwvucontrol|org\\.pulseaudio\\.pavucontrol|pavucontrol)$"
|
||||||
|
"center, class:^(com\\.saivert\\.pwvucontrol|org\\.pulseaudio\\.pavucontrol|pavucontrol)$"
|
||||||
|
|
||||||
|
# Bluetooth manager (blueman) + CUPS printer admin.
|
||||||
|
"float, class:(?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$"
|
||||||
|
"center, class:(?i)^(\\.?blueman-(manager|adapters)(-wrapped)?|\\.?system-config-printer(-wrapped)?)$"
|
||||||
|
];
|
||||||
|
|
||||||
# Rendered from ./keybinds.nix (the cheatsheet reads the same list),
|
# Rendered from ./keybinds.nix (the cheatsheet reads the same list),
|
||||||
# plus the generated per-workspace binds. Like exec-once above this
|
# plus the generated per-workspace binds. Like exec-once above this
|
||||||
# stays a normal-priority list, so a downstream `bind = [...]`
|
# stays a normal-priority list, so a downstream `bind = [...]`
|
||||||
# concatenates rather than replaces.
|
# concatenates rather than replaces. The layout-cycle bind only
|
||||||
bind = map mkBind keybinds.binds ++ workspaceBinds;
|
# exists when there is something to cycle (same comma condition
|
||||||
|
# the Waybar language indicator gates on).
|
||||||
|
bind = map mkBind (keybinds.binds
|
||||||
|
++ lib.optionals (lib.hasInfix "," config.nomarchy.keyboard.layout)
|
||||||
|
keybinds.multiLayoutBinds)
|
||||||
|
++ workspaceBinds
|
||||||
|
++ lofBinds;
|
||||||
|
|
||||||
# Media keys via swayosd-client: it performs the action AND shows
|
# Media keys via swayosd-client: it performs the action AND shows
|
||||||
# the on-screen display (the nomarchy.osd module). e = repeat,
|
# the on-screen display (the nomarchy.osd module). e = repeat,
|
||||||
@@ -266,11 +499,28 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Runtime-remembered per-device layouts (settings.keyboard.devices, written
|
||||||
|
# by the watcher into the in-flake state) graduate into the declarative
|
||||||
|
# keyboard.devices: each becomes a generated Hyprland device{} block —
|
||||||
|
# reproducible and applied natively on hotplug — after which the watcher sees
|
||||||
|
# it as declared and steps back. mkDefault so a hand-written
|
||||||
|
# keyboard.devices.<name> in home.nix still wins for the same keyboard.
|
||||||
|
nomarchy.keyboard.devices = lib.mapAttrs
|
||||||
|
(_name: layout: { layout = lib.mkDefault layout; })
|
||||||
|
config.nomarchy.settings.keyboard.devices;
|
||||||
|
|
||||||
# nwg-displays: interactive monitor arranger (applies live via hyprctl and
|
# nwg-displays: interactive monitor arranger (applies live via hyprctl and
|
||||||
# writes a config file). A helper to find values for nomarchy.monitors —
|
# writes a config file). A helper to find values for nomarchy.monitors —
|
||||||
# the declarative config stays the source of truth (we don't source its
|
# the declarative config stays the source of truth (we don't source its
|
||||||
# output). Gated on its toggle; pointless without the Hyprland session.
|
# output). Gated on its toggle; pointless without the Hyprland session.
|
||||||
home.packages = lib.optionals
|
home.packages =
|
||||||
(config.nomarchy.hyprland.enable && config.nomarchy.displays.enable)
|
lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable)
|
||||||
[ pkgs.nwg-displays ];
|
[ pkgs.nwg-displays ]
|
||||||
|
# The new-keyboard watcher on PATH (when enabled) so it's discoverable and
|
||||||
|
# runnable by name for debugging — Hyprland still exec-once's it by store path.
|
||||||
|
++ lib.optional (config.nomarchy.hyprland.enable && kbAutoSwitch) keyboardWatch
|
||||||
|
# The display-profile switcher (the menu's Profiles row gates on it)
|
||||||
|
# + its hotplug watcher, on PATH for debugging like the keyboard one.
|
||||||
|
++ lib.optionals (config.nomarchy.hyprland.enable && profiles != { })
|
||||||
|
[ displayProfileTool displayProfileWatch ];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
{
|
{
|
||||||
binds = [
|
binds = [
|
||||||
{ mods = "$mod"; key = "Return"; action = "exec, $terminal"; desc = "Open terminal"; }
|
{ mods = "$mod"; key = "Return"; action = "exec, $terminal"; desc = "Open terminal"; }
|
||||||
{ mods = "$mod"; key = "Space"; action = "exec, rofi -show drun"; desc = "Quick launch (apps)"; }
|
{ mods = "$mod"; key = "Space"; action = "exec, rofi -show drun -theme launcher"; desc = "Quick launch (apps)"; }
|
||||||
{ mods = "$mod"; key = "D"; action = "exec, rofi -show drun"; desc = "App launcher"; }
|
{ mods = "$mod"; key = "D"; action = "exec, rofi -show drun -theme launcher"; desc = "App launcher"; }
|
||||||
{ mods = "$mod"; key = "M"; action = "exec, nomarchy-menu"; desc = "Main menu"; }
|
{ mods = "$mod"; key = "M"; action = "exec, nomarchy-menu"; desc = "Main menu"; }
|
||||||
{ mods = "$mod"; key = "E"; action = "exec, $terminal -e yazi"; desc = "File manager (yazi)"; }
|
{ mods = "$mod"; key = "E"; action = "exec, $terminal -e yazi"; desc = "File manager (yazi)"; }
|
||||||
{ mods = "$mod"; key = "Q"; action = "killactive"; desc = "Close window"; }
|
{ mods = "$mod"; key = "Q"; action = "killactive"; desc = "Close window"; }
|
||||||
@@ -33,8 +33,13 @@
|
|||||||
{ mods = "$mod"; key = "X"; action = "exec, nomarchy-menu power"; desc = "Power menu"; }
|
{ mods = "$mod"; key = "X"; action = "exec, nomarchy-menu power"; desc = "Power menu"; }
|
||||||
|
|
||||||
{ mods = "$mod"; key = "N"; action = "exec, swaync-client -t"; desc = "Notification centre"; }
|
{ mods = "$mod"; key = "N"; action = "exec, swaync-client -t"; desc = "Notification centre"; }
|
||||||
# SUPER+? (the "question" keysym already implies Shift on most layouts).
|
# SUPER+? — ? is Shift+/. SHIFT stays in the modmask (you hold it), but
|
||||||
{ mods = "$mod"; key = "question"; action = "exec, nomarchy-menu keybinds"; desc = "Keybindings cheatsheet"; }
|
# the keysym must be the BASE key `slash`, not `question`: Hyprland
|
||||||
|
# resolves the sym with Shift consumed, so `question` never matches while
|
||||||
|
# Shift is down — same as the `$mod SHIFT, 1` workspace binds. (item 26
|
||||||
|
# fixed the modmask but kept the shifted keysym → still dead; item 32.)
|
||||||
|
# The cheatsheet still renders this row as SUPER + ? (rofi.nix).
|
||||||
|
{ mods = "$mod SHIFT"; key = "slash"; action = "exec, nomarchy-menu keybinds"; desc = "Keybindings cheatsheet"; }
|
||||||
|
|
||||||
# Menu functions — SUPER+CTRL+<mnemonic> jumps straight to a
|
# Menu functions — SUPER+CTRL+<mnemonic> jumps straight to a
|
||||||
# nomarchy-menu module (all also reachable from the SUPER+M picker).
|
# nomarchy-menu module (all also reachable from the SUPER+M picker).
|
||||||
@@ -46,17 +51,32 @@
|
|||||||
{ mods = "$mod CTRL"; key = "N"; action = "exec, nomarchy-menu network"; desc = "Network (nmtui)"; }
|
{ mods = "$mod CTRL"; key = "N"; action = "exec, nomarchy-menu network"; desc = "Network (nmtui)"; }
|
||||||
{ mods = "$mod CTRL"; key = "B"; action = "exec, nomarchy-menu bluetooth"; desc = "Bluetooth"; }
|
{ mods = "$mod CTRL"; key = "B"; action = "exec, nomarchy-menu bluetooth"; desc = "Bluetooth"; }
|
||||||
{ mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; }
|
{ mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; }
|
||||||
|
{ mods = "$mod CTRL"; key = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker (→ clipboard)"; }
|
||||||
{ mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; }
|
{ mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; }
|
||||||
{ mods = "$mod CTRL"; key = "D"; action = "exec, nomarchy-menu dnd"; desc = "Do Not Disturb toggle"; }
|
{ mods = "$mod CTRL"; key = "D"; action = "exec, nomarchy-menu dnd"; desc = "Do Not Disturb toggle"; }
|
||||||
|
|
||||||
# Focus
|
# Focus — SUPER + arrow keys.
|
||||||
{ mods = "$mod"; key = "H"; action = "movefocus, l"; desc = "Focus left"; }
|
{ mods = "$mod"; key = "left"; action = "movefocus, l"; desc = "Focus left"; }
|
||||||
{ mods = "$mod"; key = "L"; action = "movefocus, r"; desc = "Focus right"; }
|
{ mods = "$mod"; key = "right"; action = "movefocus, r"; desc = "Focus right"; }
|
||||||
{ mods = "$mod"; key = "K"; action = "movefocus, u"; desc = "Focus up"; }
|
{ mods = "$mod"; key = "up"; action = "movefocus, u"; desc = "Focus up"; }
|
||||||
{ mods = "$mod"; key = "J"; action = "movefocus, d"; desc = "Focus down"; }
|
{ mods = "$mod"; key = "down"; action = "movefocus, d"; desc = "Focus down"; }
|
||||||
|
|
||||||
# Screenshot region to clipboard (the menu's Capture module has more).
|
# Screenshots (the menu's Capture module has the rest: OCR, recording).
|
||||||
|
# Bare Print → region to clipboard; the two → file binds save a
|
||||||
|
# timestamped PNG under ~/Pictures/Screenshots and toast the path, the
|
||||||
|
# same plumbing the Capture menu's "→ file" rows use.
|
||||||
{ mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy"; desc = "Screenshot region → clipboard"; }
|
{ mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy"; desc = "Screenshot region → clipboard"; }
|
||||||
|
{ mods = "SHIFT"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim -g \"$(slurp)\" \"$f\" && notify-send \"Screenshot saved\" \"$f\""; desc = "Screenshot region → file"; }
|
||||||
|
{ mods = "CTRL"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim \"$f\" && notify-send \"Screenshot saved\" \"$f\""; desc = "Screenshot screen → file"; }
|
||||||
|
];
|
||||||
|
|
||||||
|
# Rendered only when the session has >1 layout (a comma in
|
||||||
|
# nomarchy.keyboard.layout) — hyprland.nix and rofi.nix both gate on
|
||||||
|
# that same condition, so the bind and its cheatsheet row stay in
|
||||||
|
# step. `current` targets the focused keyboard, so a board with its
|
||||||
|
# own per-device layout (a single one) is a no-op, never a leak.
|
||||||
|
multiLayoutBinds = [
|
||||||
|
{ mods = "$mod SHIFT"; key = "K"; action = "exec, hyprctl switchxkblayout current next"; desc = "Cycle keyboard layout"; }
|
||||||
];
|
];
|
||||||
|
|
||||||
extra = [
|
extra = [
|
||||||
@@ -64,5 +84,6 @@
|
|||||||
{ keys = "SUPER + SHIFT + 1-9"; desc = "Move window to workspace 1-9"; }
|
{ keys = "SUPER + SHIFT + 1-9"; desc = "Move window to workspace 1-9"; }
|
||||||
{ keys = "SUPER + drag"; desc = "Move (LMB) / resize (RMB) window"; }
|
{ keys = "SUPER + drag"; desc = "Move (LMB) / resize (RMB) window"; }
|
||||||
{ keys = "Volume / Brightness"; desc = "Hardware keys, shown via the OSD"; }
|
{ keys = "Volume / Brightness"; desc = "Hardware keys, shown via the OSD"; }
|
||||||
|
{ keys = "Bar: click"; desc = "Caffeine — hold the screen awake (idle inhibitor)"; }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
# GNOME session to lean on) and themed by Stylix's Qt config, so the
|
# GNOME session to lean on) and themed by Stylix's Qt config, so the
|
||||||
# passphrase dialog tracks the palette.
|
# passphrase dialog tracks the palette.
|
||||||
#
|
#
|
||||||
# SSH_AUTH_SOCK is exported by the agent's shell integration (zsh, on via
|
# SSH_AUTH_SOCK reaches both terminal and GUI clients: the agent's zsh
|
||||||
# home.shell.enableZshIntegration in shell.nix). Terminal git/ssh is the
|
# integration sets it in interactive shells (home.shell.enableZshIntegration
|
||||||
# supported path; a GUI client launched outside a shell won't inherit the
|
# in shell.nix), and a session-level home.sessionVariables export (below)
|
||||||
# socket — revisit with a session-level export if that's ever wanted.
|
# covers GUI clients launched outside a shell — e.g. from the rofi launcher —
|
||||||
|
# which never inherit the interactive shell's copy. Both resolve the same
|
||||||
|
# socket via gpgconf, so they can't drift.
|
||||||
#
|
#
|
||||||
# gnome-keyring (system side) stays the Secret Service for application
|
# gnome-keyring (system side) stays the Secret Service for application
|
||||||
# secrets; modern gnome-keyring no longer runs an SSH agent, so there is no
|
# secrets; modern gnome-keyring no longer runs an SSH agent, so there is no
|
||||||
@@ -38,5 +40,16 @@ in
|
|||||||
defaultCacheTtlSsh = 1800;
|
defaultCacheTtlSsh = 1800;
|
||||||
maxCacheTtlSsh = 7200;
|
maxCacheTtlSsh = 7200;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Session-level SSH_AUTH_SOCK so GUI clients launched outside a shell
|
||||||
|
# (rofi launcher, autostarted apps) reach the agent — the shell
|
||||||
|
# integration only covers interactive shells. Resolved with gpgconf at
|
||||||
|
# session-init time (the same lookup the shell integration uses), so it
|
||||||
|
# tracks the agent's real socket rather than a hardcoded path; valid
|
||||||
|
# before first use since the socket is systemd-activated. Reaches GUI
|
||||||
|
# apps the way NIXOS_OZONE_WL does — sourced into the login shell that
|
||||||
|
# starts Hyprland, so every spawned client inherits it.
|
||||||
|
home.sessionVariables.SSH_AUTH_SOCK =
|
||||||
|
"$(${pkgs.gnupg}/bin/gpgconf --list-dirs agent-ssh-socket)";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
62
modules/home/mime.nix
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Default applications (xdg mimeapps.list) — without this, "open a
|
||||||
|
# PDF/photo" falls to whatever GTK guesses first (GIMP for images).
|
||||||
|
# Every association is mkDefault AND degrades gracefully by design: an
|
||||||
|
# entry whose .desktop file isn't installed is skipped by GIO/xdg-open,
|
||||||
|
# which then falls through to whatever else claims the type — so
|
||||||
|
# deleting an app from the template suite (or never uncommenting the
|
||||||
|
# browser) leaves no broken "open" behaviour, just the old guessing.
|
||||||
|
{ config, lib, ... }:
|
||||||
|
|
||||||
|
lib.mkIf config.nomarchy.mime.enable {
|
||||||
|
xdg.mimeApps = {
|
||||||
|
enable = lib.mkDefault true;
|
||||||
|
defaultApplications = lib.mapAttrs (_: v: lib.mkDefault v) {
|
||||||
|
"application/pdf" = "org.pwmt.zathura.desktop";
|
||||||
|
|
||||||
|
"image/png" = "imv.desktop";
|
||||||
|
"image/jpeg" = "imv.desktop";
|
||||||
|
"image/gif" = "imv.desktop";
|
||||||
|
"image/webp" = "imv.desktop";
|
||||||
|
"image/avif" = "imv.desktop";
|
||||||
|
"image/bmp" = "imv.desktop";
|
||||||
|
"image/tiff" = "imv.desktop";
|
||||||
|
"image/svg+xml" = "imv.desktop";
|
||||||
|
|
||||||
|
# Video → mpv (the template's media player).
|
||||||
|
"video/mp4" = "mpv.desktop";
|
||||||
|
"video/webm" = "mpv.desktop";
|
||||||
|
"video/x-matroska" = "mpv.desktop";
|
||||||
|
"video/quicktime" = "mpv.desktop";
|
||||||
|
|
||||||
|
# Audio → Amberol (the template's GTK4 music player). Amberol
|
||||||
|
# registers these types itself, so this only sets the preference over
|
||||||
|
# mpv, which also claims them. Both x- and canonical names because
|
||||||
|
# files report either. Degrades to mpv/whatever if Amberol is dropped.
|
||||||
|
"audio/mpeg" = "io.bassi.Amberol.desktop";
|
||||||
|
"audio/flac" = "io.bassi.Amberol.desktop";
|
||||||
|
"audio/x-flac" = "io.bassi.Amberol.desktop";
|
||||||
|
"audio/ogg" = "io.bassi.Amberol.desktop";
|
||||||
|
"audio/x-vorbis+ogg" = "io.bassi.Amberol.desktop";
|
||||||
|
"audio/opus" = "io.bassi.Amberol.desktop";
|
||||||
|
"audio/wav" = "io.bassi.Amberol.desktop";
|
||||||
|
"audio/x-wav" = "io.bassi.Amberol.desktop";
|
||||||
|
"audio/mp4" = "io.bassi.Amberol.desktop";
|
||||||
|
"audio/x-m4a" = "io.bassi.Amberol.desktop";
|
||||||
|
"audio/aac" = "io.bassi.Amberol.desktop";
|
||||||
|
|
||||||
|
# The template's active editor; degrades if you drop vscode.
|
||||||
|
"text/plain" = "code.desktop";
|
||||||
|
|
||||||
|
# The system-side Thunar (nomarchy.system.fileManager).
|
||||||
|
"inode/directory" = "thunar.desktop";
|
||||||
|
|
||||||
|
# The template ships no browser by default (open Decision) — these
|
||||||
|
# are inert until one is installed; firefox is the template's first
|
||||||
|
# suggestion. A different browser registers its own handler and
|
||||||
|
# wins once these entries stay dead.
|
||||||
|
"text/html" = "firefox.desktop";
|
||||||
|
"x-scheme-handler/http" = "firefox.desktop";
|
||||||
|
"x-scheme-handler/https" = "firefox.desktop";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
72
modules/home/monitor-rules.nix
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Pure monitor-rule composition, split out of hyprland.nix so
|
||||||
|
# checks.display-profiles can exercise the overlay semantics directly —
|
||||||
|
# no Home Manager evaluation, no state-file fixture.
|
||||||
|
{ lib }:
|
||||||
|
|
||||||
|
rec {
|
||||||
|
# Skeleton for an entry built outside the option type (mirrors the
|
||||||
|
# monitorType defaults in options.nix).
|
||||||
|
defaults = {
|
||||||
|
resolution = "preferred"; position = "auto"; scale = 1;
|
||||||
|
transform = null; mirror = null; bitdepth = null; vrr = null; extra = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
# An entry -> a Hyprland `monitor` rule. Unset optional fields are
|
||||||
|
# omitted; `resolution = "disable"` collapses to the short form.
|
||||||
|
rule = m:
|
||||||
|
if m.resolution == "disable" then "${m.name}, disable"
|
||||||
|
else lib.concatStringsSep ", " (
|
||||||
|
[ m.name m.resolution (toString m.position) (toString m.scale) ]
|
||||||
|
++ lib.optionals (m.transform != null) [ "transform" (toString m.transform) ]
|
||||||
|
++ lib.optionals (m.mirror != null) [ "mirror" m.mirror ]
|
||||||
|
++ lib.optionals (m.bitdepth != null) [ "bitdepth" (toString m.bitdepth) ]
|
||||||
|
++ lib.optionals (m.vrr != null) [ "vrr" (toString m.vrr) ]
|
||||||
|
++ lib.optional (m.extra != "") m.extra
|
||||||
|
);
|
||||||
|
|
||||||
|
# A profile's workspace pinning ({ "1" = "DP-3"; }) -> a Hyprland
|
||||||
|
# `workspace` rule. Only the active profile's pins are rendered
|
||||||
|
# (hyprland.nix); a workspace rule naming an absent output is inert.
|
||||||
|
workspaceRule = ws: out: "${ws}, monitor:${out}";
|
||||||
|
|
||||||
|
# The full overlay, three layers:
|
||||||
|
#
|
||||||
|
# 1. base (nomarchy.monitors) with the ACTIVE display profile's entries
|
||||||
|
# (settings.displayProfile naming a nomarchy.displayProfiles key)
|
||||||
|
# replacing base entries WHOLE, by name — a profile entry is a
|
||||||
|
# complete statement about that output. Outputs a profile doesn't
|
||||||
|
# name keep their base rules; a cleared/unknown/non-string value
|
||||||
|
# ("" after `nomarchy-display-profile base`, or hand-edited junk —
|
||||||
|
# the validator only warns) means base config only.
|
||||||
|
#
|
||||||
|
# 2. Menu-remembered per-output resolutions (settings.monitors:
|
||||||
|
# output-name -> "WxH@R", written instantly by the Display menu)
|
||||||
|
# overlaid field-level by name: a declared output keeps its
|
||||||
|
# position/scale/etc and only its resolution changes; an output
|
||||||
|
# covered solely by the `,preferred,auto,1` wildcard (e.g. the
|
||||||
|
# laptop's built-in panel) becomes a new rule with default
|
||||||
|
# position/scale. The menu pick wins over a hand-set resolution
|
||||||
|
# (it's the explicit live action) — EXCEPT onto disabled entries: a
|
||||||
|
# stale pick, made while the output was enabled, must not resurrect
|
||||||
|
# a panel the active profile (or the base config) disables.
|
||||||
|
#
|
||||||
|
# 3. Anything still uncovered falls to the wildcard (hyprland.nix
|
||||||
|
# prepends it).
|
||||||
|
resolve = { base, profiles, active, resOverrides }:
|
||||||
|
let
|
||||||
|
activeName = if builtins.isString active then active else "";
|
||||||
|
profileEntries = profiles.${activeName} or [ ];
|
||||||
|
profileNames = map (m: m.name) profileEntries;
|
||||||
|
baseMonitors =
|
||||||
|
lib.filter (m: ! lib.elem m.name profileNames) base
|
||||||
|
++ profileEntries;
|
||||||
|
declaredNames = map (m: m.name) baseMonitors;
|
||||||
|
in
|
||||||
|
map (m: if resOverrides ? ${m.name} && m.resolution != "disable"
|
||||||
|
then m // { resolution = resOverrides.${m.name}; }
|
||||||
|
else m)
|
||||||
|
baseMonitors
|
||||||
|
++ lib.mapAttrsToList
|
||||||
|
(name: res: defaults // { inherit name; resolution = res; })
|
||||||
|
(lib.filterAttrs (name: _: ! lib.elem name declaredNames) resOverrides);
|
||||||
|
}
|
||||||
@@ -1,18 +1,75 @@
|
|||||||
# Night light — a scheduled blue-light filter via hyprsunset (Hyprland's own
|
# Night light — a scheduled blue-light filter via hyprsunset (Hyprland's own
|
||||||
# gamma/temperature tool). Warm at night, identity (no shift) by day;
|
# gamma/temperature tool). Warm at night, identity (no shift) by day; the
|
||||||
# hyprsunset's time-based `profile` entries handle the schedule and pick the
|
# schedule (temperature/sunrise/sunset) is tuned via nomarchy.nightlight.* in
|
||||||
# right state on session start. Opt-in via nomarchy.nightlight.enable.
|
# home.nix and baked into the unit's time-based `profile`.
|
||||||
#
|
#
|
||||||
# The hyprsunset HM service module is provided by home-manager; this only
|
# Off by default and opt-in. Two git-tracked flags in the state file, both
|
||||||
# configures it. Override anything with plain services.hyprsunset.* options.
|
# menu-written (no ~/.local/state):
|
||||||
{ config, lib, ... }:
|
# settings.nightlight.installed — does the hyprsunset unit exist? Sticky; the
|
||||||
|
# option mkDefault-reads it, so the FIRST enable from the menu rebuilds (to
|
||||||
|
# create the unit) and an instant-off is never undone by a later rebuild.
|
||||||
|
# settings.nightlight.on — runtime on/off. Toggled INSTANTLY (write + systemctl,
|
||||||
|
# no rebuild); read by the unit's ExecCondition (should-start) at session
|
||||||
|
# start so the choice survives logout/reboot via the *live* state, not the
|
||||||
|
# eval-frozen store copy. A later rebuild bakes the same value (no divergence).
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
cfg = config.nomarchy.nightlight;
|
cfg = config.nomarchy.nightlight;
|
||||||
|
s = config.nomarchy.settings.nightlight;
|
||||||
|
sync = lib.getExe config.nomarchy.package;
|
||||||
|
# Runtime-on default for when the `on` key hasn't been written yet (e.g. right
|
||||||
|
# after the first enable). Baked at eval; only used when the live key is absent.
|
||||||
|
onDefault = lib.boolToString s.on;
|
||||||
|
|
||||||
|
nomarchy-nightlight = pkgs.writeShellScriptBin "nomarchy-nightlight" ''
|
||||||
|
unit=hyprsunset.service
|
||||||
|
# Instant runtime on/off: write the in-flake state WITHOUT a rebuild.
|
||||||
|
write_on() { ${sync} --quiet set settings.nightlight.on "$1" --no-switch; }
|
||||||
|
# First enable: mark the feature installed and REBUILD to create the unit
|
||||||
|
# (the one rebuild we accept; every toggle after is instant).
|
||||||
|
install_feature() { ${sync} --quiet set settings.nightlight.installed true; }
|
||||||
|
# Read the LIVE working-tree on/off (~/.nomarchy via $NOMARCHY_PATH), not the
|
||||||
|
# store copy baked into this generation; fall back to the eval-time default
|
||||||
|
# when absent. Normalise Python's True/False bool rendering.
|
||||||
|
is_on() {
|
||||||
|
v=$(${sync} get settings.nightlight.on 2>/dev/null) || v=${onDefault}
|
||||||
|
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
|
||||||
|
}
|
||||||
|
installed() { systemctl --user cat "$unit" >/dev/null 2>&1; }
|
||||||
|
start() { systemctl --user start "$unit" 2>/dev/null || true; }
|
||||||
|
stop() { systemctl --user stop "$unit" 2>/dev/null || true; }
|
||||||
|
case "''${1:-toggle}" in
|
||||||
|
should-start) is_on ;; # ExecCondition gate (login/reboot)
|
||||||
|
status)
|
||||||
|
# Waybar (polls every 3s): moon while running; print nothing otherwise so
|
||||||
|
# the module self-hides — enable / re-enable from the System menu.
|
||||||
|
systemctl --user is-active --quiet "$unit" \
|
||||||
|
&& printf '{"text":"","tooltip":"Night light on — warm on schedule (click to disable)","class":"on"}\n'
|
||||||
|
exit 0 ;;
|
||||||
|
on)
|
||||||
|
if installed; then write_on true; start; else install_feature; start; fi ;;
|
||||||
|
off) write_on false; stop ;;
|
||||||
|
toggle)
|
||||||
|
if systemctl --user is-active --quiet "$unit"; then
|
||||||
|
write_on false; stop # on -> off (instant)
|
||||||
|
elif installed; then
|
||||||
|
write_on true; start # installed, off -> on (instant)
|
||||||
|
else
|
||||||
|
install_feature; start # first enable (rebuilds)
|
||||||
|
fi ;;
|
||||||
|
*) echo "usage: nomarchy-nightlight [toggle|status|on|off|should-start]" >&2; exit 64 ;;
|
||||||
|
esac
|
||||||
|
'';
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
config = lib.mkIf cfg.enable {
|
config = {
|
||||||
services.hyprsunset = {
|
# Unit presence tracks the sticky `installed` flag the menu writes (first
|
||||||
|
# enable rebuilds). mkDefault so a hand-set nomarchy.nightlight.enable in
|
||||||
|
# home.nix also works as a declarative opt-in.
|
||||||
|
nomarchy.nightlight.enable = lib.mkDefault s.installed;
|
||||||
|
|
||||||
|
services.hyprsunset = lib.mkIf cfg.enable {
|
||||||
enable = true;
|
enable = true;
|
||||||
settings.profile = [
|
settings.profile = [
|
||||||
# Daytime: identity = no colour change.
|
# Daytime: identity = no colour change.
|
||||||
@@ -21,5 +78,13 @@ in
|
|||||||
{ time = cfg.sunset; temperature = cfg.temperature; }
|
{ time = cfg.sunset; temperature = cfg.temperature; }
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Gate the unit on the LIVE on/off state at start time (login/reboot), not
|
||||||
|
# at eval time — so a menu toggle (written without a rebuild) is honoured on
|
||||||
|
# the next session. A failed condition skips the unit (inactive, not failed).
|
||||||
|
systemd.user.services.hyprsunset.Service.ExecCondition =
|
||||||
|
lib.mkIf cfg.enable "${nomarchy-nightlight}/bin/nomarchy-nightlight should-start";
|
||||||
|
|
||||||
|
home.packages = [ nomarchy-nightlight ];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# User-level `nomarchy.*` options — the full surface downstream users
|
# User-level `nomarchy.*` options — the full surface downstream users
|
||||||
# configure in their home.nix. Kept small on purpose.
|
# configure in their home.nix. Kept small on purpose.
|
||||||
{ lib, pkgs, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
# One output's layout — turned into a Hyprland `monitor` rule in
|
# One output's layout — turned into a Hyprland `monitor` rule in
|
||||||
@@ -55,6 +55,33 @@ let
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# One display profile: a monitor layout plus optional workspace→output
|
||||||
|
# pinning. A bare list of monitor entries still works (the original
|
||||||
|
# shape) — hyprland.nix normalizes it to { monitors = […]; }.
|
||||||
|
# (either, not coercedTo: coercedTo refuses list-of-submodule sources.)
|
||||||
|
displayProfileType = lib.types.either
|
||||||
|
(lib.types.listOf monitorType)
|
||||||
|
(lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
monitors = lib.mkOption {
|
||||||
|
type = lib.types.listOf monitorType;
|
||||||
|
default = [ ];
|
||||||
|
description = "nomarchy.monitors-style entries; each replaces the base entry for the same output whole.";
|
||||||
|
};
|
||||||
|
workspaces = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf lib.types.str;
|
||||||
|
default = { };
|
||||||
|
example = { "1" = "DP-3"; "9" = "eDP-1"; };
|
||||||
|
description = ''
|
||||||
|
Workspace → output pinning while this profile is active
|
||||||
|
(Hyprland `workspace = <ws>, monitor:<output>` rules).
|
||||||
|
Applied instantly on profile switch (existing workspaces are
|
||||||
|
moved over) and baked at the next rebuild.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.nomarchy = {
|
options.nomarchy = {
|
||||||
@@ -73,13 +100,13 @@ in
|
|||||||
# ── Preferences ────────────────────────────────────────────────
|
# ── Preferences ────────────────────────────────────────────────
|
||||||
terminal = lib.mkOption {
|
terminal = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "ghostty";
|
default = config.nomarchy.settings.terminal or "ghostty";
|
||||||
description = "Terminal emulator command, used by keybinds and $TERMINAL.";
|
description = "Terminal emulator command, used by keybinds and $TERMINAL.";
|
||||||
};
|
};
|
||||||
|
|
||||||
keyboard.layout = lib.mkOption {
|
keyboard.layout = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "us";
|
default = config.nomarchy.settings.keyboard.layout or "us";
|
||||||
example = "de";
|
example = "de";
|
||||||
description = ''
|
description = ''
|
||||||
XKB layout for the Hyprland session. The console (and the LUKS
|
XKB layout for the Hyprland session. The console (and the LUKS
|
||||||
@@ -105,10 +132,14 @@ in
|
|||||||
picker. When non-empty, a small watcher runs in the session: when a
|
picker. When non-empty, a small watcher runs in the session: when a
|
||||||
keyboard connects that isn't covered by nomarchy.keyboard.devices and
|
keyboard connects that isn't covered by nomarchy.keyboard.devices and
|
||||||
hasn't been chosen before, it pops a rofi picker (these layouts plus
|
hasn't been chosen before, it pops a rofi picker (these layouts plus
|
||||||
the primary nomarchy.keyboard.layout), applies the choice, and
|
the primary nomarchy.keyboard.layout), applies the choice to that
|
||||||
remembers it per-device (~/.local/state) — re-applying automatically
|
keyboard only (a per-device kb_layout, so the built-in board is left
|
||||||
on later reconnects. The runtime-remember complement to the
|
alone), and remembers it in the git-tracked in-flake state
|
||||||
declarative keyboard.devices; a stateful runtime piece by design.
|
(settings.keyboard.devices, not ~/.local/state) — re-applied
|
||||||
|
automatically on later reconnects and across reboots. Each remembered
|
||||||
|
choice graduates into nomarchy.keyboard.devices on the next rebuild
|
||||||
|
(a generated device block). The runtime-remember complement to the
|
||||||
|
declarative keyboard.devices.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,7 +166,9 @@ in
|
|||||||
nomarchy.keyboard.layout for that keyboard only — e.g. an external
|
nomarchy.keyboard.layout for that keyboard only — e.g. an external
|
||||||
keyboard that's physically a different layout than the laptop's
|
keyboard that's physically a different layout than the laptop's
|
||||||
built-in one. Hyprland applies it automatically whenever that
|
built-in one. Hyprland applies it automatically whenever that
|
||||||
keyboard is connected.
|
keyboard is connected. The interactive watcher
|
||||||
|
(nomarchy.keyboard.layouts) also writes its remembered picks here on
|
||||||
|
the next rebuild, so a runtime choice graduates into reproducible config.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,6 +217,32 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
updates = {
|
||||||
|
enable = lib.mkEnableOption ''
|
||||||
|
passive update awareness: a background check (systemd user timer) that
|
||||||
|
compares the flake's locked inputs (nixpkgs, the Nomarchy input, …)
|
||||||
|
against upstream and — when Flatpak is enabled — counts Flatpak
|
||||||
|
updates, surfacing a Waybar indicator + a notification when something
|
||||||
|
is available. It never changes anything; you still run sys-update /
|
||||||
|
home-update / flatpak update yourself'' // { default = config.nomarchy.settings.updates.enable or false; };
|
||||||
|
|
||||||
|
interval = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "daily";
|
||||||
|
example = "6h";
|
||||||
|
description = "How often to check, as a systemd OnCalendar expression.";
|
||||||
|
};
|
||||||
|
|
||||||
|
flatpak = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = ''
|
||||||
|
Also count available Flatpak updates when the `flatpak` CLI is
|
||||||
|
present (i.e. nomarchy.services.flatpak is on). No effect otherwise.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
monitors = lib.mkOption {
|
monitors = lib.mkOption {
|
||||||
type = lib.types.listOf monitorType;
|
type = lib.types.listOf monitorType;
|
||||||
default = [ ];
|
default = [ ];
|
||||||
@@ -205,11 +264,88 @@ in
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
launchOrFocus = lib.mkOption {
|
||||||
|
type = lib.types.listOf (lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
key = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
example = "B";
|
||||||
|
description = "The key (with `mods`) that focuses-or-launches the app.";
|
||||||
|
};
|
||||||
|
mods = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "$mod";
|
||||||
|
description = "Modifier string, Hyprland syntax (\"$mod\", \"$mod SHIFT\").";
|
||||||
|
};
|
||||||
|
class = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
example = "firefox";
|
||||||
|
description = "Window class to focus (case-insensitive; see `hyprctl clients`).";
|
||||||
|
};
|
||||||
|
command = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Command to launch when no window matches (defaults to the class).";
|
||||||
|
};
|
||||||
|
desc = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Cheatsheet label (defaults to \"Focus or launch <command>\").";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
default = [ ];
|
||||||
|
example = lib.literalExpression ''
|
||||||
|
[
|
||||||
|
{ key = "B"; class = "firefox"; }
|
||||||
|
{ key = "O"; class = "obsidian"; }
|
||||||
|
]
|
||||||
|
'';
|
||||||
|
description = ''
|
||||||
|
Launch-or-focus binds: the key focuses the app's existing window
|
||||||
|
(case-insensitive class match) or launches it if none is open. Each
|
||||||
|
entry generates a Hyprland bind AND a SUPER+? cheatsheet row. The
|
||||||
|
launcher self-gates: a bind whose command isn't installed notifies
|
||||||
|
instead of failing silently.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
displayProfiles = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf displayProfileType;
|
||||||
|
default = { };
|
||||||
|
example = lib.literalExpression ''
|
||||||
|
{
|
||||||
|
docked = {
|
||||||
|
monitors = [
|
||||||
|
{ name = "eDP-1"; resolution = "disable"; }
|
||||||
|
{ name = "DP-3"; position = "0x0"; }
|
||||||
|
{ name = "DP-4"; position = "auto-right"; }
|
||||||
|
];
|
||||||
|
workspaces = { "1" = "DP-3"; "9" = "DP-4"; };
|
||||||
|
};
|
||||||
|
undocked = [ { name = "eDP-1"; position = "0x0"; } ];
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
description = ''
|
||||||
|
Named display layouts for the same outputs (docked, undocked, …):
|
||||||
|
a list of nomarchy.monitors-style entries, or an attrset with
|
||||||
|
`monitors` plus optional `workspaces` (workspace → output pinning
|
||||||
|
while the profile is active). Switch from the menu (System ›
|
||||||
|
Display › Profiles) or `nomarchy-display-profile apply <name>`:
|
||||||
|
the profile's rules apply instantly via hyprctl and the choice
|
||||||
|
persists in the in-flake state (settings.displayProfile), so the
|
||||||
|
next rebuild bakes the active profile's entries over
|
||||||
|
nomarchy.monitors by name. Outputs a profile doesn't name keep
|
||||||
|
their base rules.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
# ── Component toggles ──────────────────────────────────────────
|
# ── Component toggles ──────────────────────────────────────────
|
||||||
hyprland.enable = lib.mkEnableOption "Nomarchy's Hyprland configuration" // { default = true; };
|
hyprland.enable = lib.mkEnableOption "Nomarchy's Hyprland configuration" // { default = true; };
|
||||||
waybar.enable = lib.mkEnableOption "Nomarchy's Waybar configuration" // { default = true; };
|
waybar.enable = lib.mkEnableOption "Nomarchy's Waybar configuration" // { default = true; };
|
||||||
rofi.enable = lib.mkEnableOption "Nomarchy's themed rofi launcher + the nomarchy-menu dispatcher" // { default = true; };
|
rofi.enable = lib.mkEnableOption "Nomarchy's themed rofi launcher + the nomarchy-menu dispatcher" // { default = true; };
|
||||||
swaync.enable = lib.mkEnableOption "swaync notifications, themed from the state file" // { default = true; };
|
swaync.enable = lib.mkEnableOption "swaync notifications, themed from the state file" // { default = true; };
|
||||||
|
batteryNotify.enable = lib.mkEnableOption "low-battery notifications at the bar's thresholds (25% low, 10% critical — that one stays up until dismissed); self-gating, a silent no-op on machines without a battery" // { default = true; };
|
||||||
idle.enable = lib.mkEnableOption "hyprlock + hypridle (idle lock, display off, suspend)" // { default = true; };
|
idle.enable = lib.mkEnableOption "hyprlock + hypridle (idle lock, display off, suspend)" // { default = true; };
|
||||||
yazi.enable = lib.mkEnableOption "the yazi TUI file manager, themed with a curated plugin set" // { default = true; };
|
yazi.enable = lib.mkEnableOption "the yazi TUI file manager, themed with a curated plugin set" // { default = true; };
|
||||||
osd.enable = lib.mkEnableOption "swayosd on-screen display for volume/brightness/mute" // { default = true; };
|
osd.enable = lib.mkEnableOption "swayosd on-screen display for volume/brightness/mute" // { default = true; };
|
||||||
@@ -220,6 +356,8 @@ in
|
|||||||
btop.enable = lib.mkEnableOption "btop with the per-theme nomarchy theme" // { default = true; };
|
btop.enable = lib.mkEnableOption "btop with the per-theme nomarchy theme" // { default = true; };
|
||||||
stylix.enable = lib.mkEnableOption "Stylix theming for the long tail of apps (GTK, Qt, cursors)" // { default = true; };
|
stylix.enable = lib.mkEnableOption "Stylix theming for the long tail of apps (GTK, Qt, cursors)" // { default = true; };
|
||||||
displays.enable = lib.mkEnableOption "the nwg-displays interactive monitor arranger (a helper to find nomarchy.monitors values; the declarative config stays the source of truth)" // { default = true; };
|
displays.enable = lib.mkEnableOption "the nwg-displays interactive monitor arranger (a helper to find nomarchy.monitors values; the declarative config stays the source of truth)" // { default = true; };
|
||||||
|
viewers.enable = lib.mkEnableOption "the document/image viewers (zathura, Stylix-themed, + imv)" // { default = true; };
|
||||||
|
mime.enable = lib.mkEnableOption "default file associations (xdg mimeapps.list: PDF/image/video/text/browser/directory); entries for absent apps are skipped, so it degrades with the package suite" // { default = true; };
|
||||||
|
|
||||||
# ── Computed (read-only) ───────────────────────────────────────
|
# ── Computed (read-only) ───────────────────────────────────────
|
||||||
theme = lib.mkOption {
|
theme = lib.mkOption {
|
||||||
@@ -228,6 +366,17 @@ in
|
|||||||
description = "The parsed theme state (stateFile merged over defaults).";
|
description = "The parsed theme state (stateFile merged over defaults).";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
settings = lib.mkOption {
|
||||||
|
type = lib.types.attrs;
|
||||||
|
readOnly = true;
|
||||||
|
description = ''
|
||||||
|
Parsed feature settings — the `settings` section of the state file,
|
||||||
|
what the menu/Waybar toggles write (e.g. settings.nightlight.enable).
|
||||||
|
Feature options mkDefault-read from here, so a menu toggle lands in the
|
||||||
|
in-flake state (git-tracked, reproducible) rather than ~/.local/state.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
lib = lib.mkOption {
|
lib = lib.mkOption {
|
||||||
type = lib.types.attrs;
|
type = lib.types.attrs;
|
||||||
readOnly = true;
|
readOnly = true;
|
||||||
|
|||||||
99
modules/home/recording.nix
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Screen recording (menu › Tools › Capture). One helper owns the whole
|
||||||
|
# lifecycle: `start` launches wl-screenrec (VAAPI hardware encode) and
|
||||||
|
# falls back to wf-recorder (software x264) if it dies on the spot —
|
||||||
|
# e.g. no usable VAAPI device; `status` feeds the self-gating Waybar
|
||||||
|
# indicator (waybar.nix, signal 8), whose click is the ONE stop surface
|
||||||
|
# (`stop` SIGINTs the recorder so it finalizes the file cleanly). State
|
||||||
|
# is a runtime pidfile — nothing persists across sessions.
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
lib.mkIf config.nomarchy.rofi.enable {
|
||||||
|
home.packages = [
|
||||||
|
pkgs.wl-screenrec
|
||||||
|
pkgs.wf-recorder
|
||||||
|
|
||||||
|
(pkgs.writeShellScriptBin "nomarchy-record" ''
|
||||||
|
run="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||||
|
pidfile="$run/nomarchy-record.pid"
|
||||||
|
filefile="$run/nomarchy-record.file"
|
||||||
|
|
||||||
|
alive() { [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; }
|
||||||
|
poke_bar() { pkill -RTMIN+8 waybar 2>/dev/null || true; }
|
||||||
|
|
||||||
|
case "''${1:-}" in
|
||||||
|
start)
|
||||||
|
if alive; then
|
||||||
|
notify-send "Screen recording" "Already recording — stop it via the ⏺ in the bar."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
target="''${2:-screen}"; audio="''${3:-}"
|
||||||
|
geo=""
|
||||||
|
if [ "$target" = region ]; then
|
||||||
|
geo=$(slurp) || exit 0 # Esc in slurp = cancel, silently
|
||||||
|
fi
|
||||||
|
dir="$HOME/Videos/Recordings"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
file="$dir/$(date +%Y%m%d-%H%M%S).mp4"
|
||||||
|
|
||||||
|
launch() { # launch <recorder> -> 0 if it survived startup
|
||||||
|
case "$1" in
|
||||||
|
wl-screenrec)
|
||||||
|
setsid wl-screenrec ''${geo:+-g "$geo"} \
|
||||||
|
''${audio:+--audio} -f "$file" >/dev/null 2>&1 & ;;
|
||||||
|
wf-recorder)
|
||||||
|
setsid wf-recorder ''${geo:+-g "$geo"} \
|
||||||
|
''${audio:+-a} -f "$file" >/dev/null 2>&1 & ;;
|
||||||
|
esac
|
||||||
|
echo $! > "$pidfile"
|
||||||
|
sleep 0.7
|
||||||
|
alive
|
||||||
|
}
|
||||||
|
|
||||||
|
started=""
|
||||||
|
if command -v wl-screenrec >/dev/null 2>&1 && launch wl-screenrec; then
|
||||||
|
started=wl-screenrec
|
||||||
|
elif command -v wf-recorder >/dev/null 2>&1 && launch wf-recorder; then
|
||||||
|
started=wf-recorder # software fallback (no VAAPI device)
|
||||||
|
fi
|
||||||
|
if [ -z "$started" ]; then
|
||||||
|
rm -f "$pidfile"
|
||||||
|
notify-send "Screen recording" "Recorder failed to start (no wl-screenrec/wf-recorder able to run)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s' "$file" > "$filefile"
|
||||||
|
poke_bar
|
||||||
|
notify-send "Screen recording" "Recording ($started)''${audio:+ with audio} — click the ⏺ in the bar to stop." ;;
|
||||||
|
|
||||||
|
stop)
|
||||||
|
if ! alive; then
|
||||||
|
rm -f "$pidfile" "$filefile"; poke_bar
|
||||||
|
notify-send "Screen recording" "No recording is running."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
pid=$(cat "$pidfile")
|
||||||
|
kill -INT "$pid" 2>/dev/null # graceful: both recorders finalize on INT
|
||||||
|
for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
|
||||||
|
kill -0 "$pid" 2>/dev/null && kill "$pid" 2>/dev/null
|
||||||
|
file=$(cat "$filefile" 2>/dev/null || echo "?")
|
||||||
|
rm -f "$pidfile" "$filefile"
|
||||||
|
poke_bar
|
||||||
|
notify-send "Screen recording saved" "$file" ;;
|
||||||
|
|
||||||
|
active)
|
||||||
|
alive ;;
|
||||||
|
|
||||||
|
status)
|
||||||
|
# Waybar JSON while recording; nothing => module hidden.
|
||||||
|
if alive; then
|
||||||
|
file=$(cat "$filefile" 2>/dev/null || echo "")
|
||||||
|
printf '{"text":"⏺ REC","class":"recording","tooltip":"Recording to %s — click to stop"}\n' "''${file##*/}"
|
||||||
|
fi
|
||||||
|
exit 0 ;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "usage: nomarchy-record start [region|screen] [audio] | stop | active | status" >&2
|
||||||
|
exit 2 ;;
|
||||||
|
esac
|
||||||
|
'')
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -19,6 +19,102 @@ let
|
|||||||
rasiOverride = cfg.themesDir + "/${t.slug}/rofi.rasi";
|
rasiOverride = cfg.themesDir + "/${t.slug}/rofi.rasi";
|
||||||
hasRasiOverride = builtins.pathExists rasiOverride;
|
hasRasiOverride = builtins.pathExists rasiOverride;
|
||||||
|
|
||||||
|
# App-launcher theme (SUPER+Space/D → `rofi -show drun -theme launcher`).
|
||||||
|
# Kept separate from the base theme so a theme can give the launcher its
|
||||||
|
# own layout (e.g. Boreal's icon grid) WITHOUT that layout leaking into
|
||||||
|
# the text menus, which share the base theme and need a list. The default
|
||||||
|
# just re-imports the active base theme (custom.rasi for generated themes,
|
||||||
|
# rofi.rasi for whole-swaps — see the dataFile below), so every other
|
||||||
|
# theme's launcher is byte-for-byte its list.
|
||||||
|
launcherOverride = cfg.themesDir + "/${t.slug}/launcher.rasi";
|
||||||
|
hasLauncherOverride = builtins.pathExists launcherOverride;
|
||||||
|
|
||||||
|
# ── Visual theme picker ──────────────────────────────────────────────
|
||||||
|
# Every preset (themes/<slug>.json) is read at eval time to build a grid
|
||||||
|
# of real desktop previews. Grouped dark-first then light — the previews
|
||||||
|
# make the mode obvious at a glance, so no light/dark submenu split — and
|
||||||
|
# the active theme is marked ✓. "Active" is just t.slug: every switch
|
||||||
|
# rebuilds this script, so the baked-in mark always tracks the live theme.
|
||||||
|
themeFiles = lib.filterAttrs
|
||||||
|
(n: ty: ty == "regular" && lib.hasSuffix ".json" n)
|
||||||
|
(builtins.readDir cfg.themesDir);
|
||||||
|
themeData = lib.mapAttrsToList (fname: _:
|
||||||
|
let
|
||||||
|
j = builtins.fromJSON (builtins.readFile (cfg.themesDir + "/${fname}"));
|
||||||
|
slug = j.slug or (lib.removeSuffix ".json" fname);
|
||||||
|
in {
|
||||||
|
inherit slug;
|
||||||
|
name = j.name or slug;
|
||||||
|
mode = j.mode or "dark";
|
||||||
|
hasPreview = builtins.pathExists (cfg.themesDir + "/${slug}/preview.png");
|
||||||
|
}) themeFiles;
|
||||||
|
|
||||||
|
byName = lib.sort (a: b: a.name < b.name);
|
||||||
|
orderedThemes =
|
||||||
|
byName (lib.filter (th: th.mode != "light") themeData) # dark group
|
||||||
|
++ byName (lib.filter (th: th.mode == "light") themeData); # then light
|
||||||
|
|
||||||
|
# rofi's element-icon cells are SQUARE, so a 16:9 preview would letterbox
|
||||||
|
# (theme-coloured bands top/bottom). Build-time crop each committed 480×270
|
||||||
|
# preview to a centred square so it fills the cell edge-to-edge. The source
|
||||||
|
# preview.png stays 480×270 (untouched) — only the displayed thumb is square.
|
||||||
|
themeThumbs = pkgs.runCommand "nomarchy-theme-thumbs"
|
||||||
|
{ nativeBuildInputs = [ pkgs.imagemagick ]; } ''
|
||||||
|
mkdir -p $out
|
||||||
|
${lib.concatMapStringsSep "\n"
|
||||||
|
(th: lib.optionalString th.hasPreview ''
|
||||||
|
magick ${cfg.themesDir + "/${th.slug}/preview.png"} \
|
||||||
|
-resize 360x360^ -gravity center -extent 360x360 -strip $out/${th.slug}.png
|
||||||
|
'')
|
||||||
|
orderedThemes}
|
||||||
|
'';
|
||||||
|
|
||||||
|
# One grid row per theme (square thumb + name, ✓ on the active one), and a
|
||||||
|
# Name→slug map so the picked label resolves back to the preset that
|
||||||
|
# nomarchy-theme-sync applies (pretty names ≠ slugs, hence the map). Themes
|
||||||
|
# with no preview degrade to a plain-name row.
|
||||||
|
themeRows = lib.concatMapStringsSep "\n" (th:
|
||||||
|
let label = th.name + lib.optionalString (th.slug == t.slug) " ✓";
|
||||||
|
in if th.hasPreview
|
||||||
|
then "row ${lib.escapeShellArg label} ${themeThumbs}/${th.slug}.png"
|
||||||
|
else "printf '%s\\n' ${lib.escapeShellArg label}")
|
||||||
|
orderedThemes;
|
||||||
|
themeSlugMap = lib.concatMapStringsSep "\n" (th:
|
||||||
|
" [${lib.escapeShellArg th.name}]=${lib.escapeShellArg th.slug}")
|
||||||
|
orderedThemes;
|
||||||
|
|
||||||
|
# Per-invocation grid layout (cards: a 16:9 preview above a centered name),
|
||||||
|
# overriding the single-column list theme just for this menu.
|
||||||
|
# · The icon box is sized 16:9 (rofi 2.0 takes a two-value `size`) to
|
||||||
|
# match the 480×270 previews exactly — they fill it with no letterbox.
|
||||||
|
# · The window is sized to the *content* (3 × icon + paddings), not a
|
||||||
|
# percentage, so a column is no wider than its card — otherwise the
|
||||||
|
# selection highlight balloons out around the image.
|
||||||
|
# · flow: horizontal lays cards out row-by-row (rofi's default Vertical
|
||||||
|
# fills column-by-column, so Down at a column's foot jumped to the top
|
||||||
|
# of the next column instead of scrolling the page).
|
||||||
|
# Grid dial — the previews are as big as the icon px allows; screen *height*
|
||||||
|
# caps it, so showing 2 rows (not 3) lets each card grow a lot. Scrolling
|
||||||
|
# reaches the rest. Tune themeGridIconW for size; the window resizes to fit.
|
||||||
|
# rofi's element-icon `size` is a SINGLE value (the manual only documents
|
||||||
|
# one) — a two-value "WxH" is silently collapsed, which is why the preview
|
||||||
|
# used to render tiny inside a too-wide column. So: size = the card *width*,
|
||||||
|
# rofi fits the 16:9 image tight to it (no square letterbox), and the window
|
||||||
|
# is sized so a column is exactly that width — the preview fills the cell.
|
||||||
|
themeGridCols = 3;
|
||||||
|
themeGridLines = 3;
|
||||||
|
themeGridIconW = 240; # square preview card side (px) — the size knob
|
||||||
|
themeGridPad = 0; # margin around the preview inside its cell (px)
|
||||||
|
themeGridGap = 8; # gap between cards (px)
|
||||||
|
themeGridThemeStr = lib.escapeShellArg (lib.concatStringsSep " " [
|
||||||
|
# window width = cards + their padding + inter-card gaps + chrome (~20px)
|
||||||
|
"window { width: ${toString (themeGridCols * (themeGridIconW + 2 * themeGridPad) + (themeGridCols - 1) * themeGridGap + 20)}px; }"
|
||||||
|
"listview { columns: ${toString themeGridCols}; lines: ${toString themeGridLines}; spacing: ${toString themeGridGap}px; flow: horizontal; }"
|
||||||
|
"element { orientation: vertical; padding: ${toString themeGridPad}px; spacing: 2px; }"
|
||||||
|
"element-icon { size: ${toString themeGridIconW}px; }"
|
||||||
|
"element-text { horizontal-align: 0.5; }"
|
||||||
|
]);
|
||||||
|
|
||||||
# Keybindings cheatsheet (SUPER+? → the `keybinds` menu module). Built
|
# Keybindings cheatsheet (SUPER+? → the `keybinds` menu module). Built
|
||||||
# from the SAME ./keybinds.nix that hyprland.nix binds, so it can never
|
# from the SAME ./keybinds.nix that hyprland.nix binds, so it can never
|
||||||
# drift from the live shortcuts. "$mod" reads as SUPER; rows are padded
|
# drift from the live shortcuts. "$mod" reads as SUPER; rows are padded
|
||||||
@@ -30,14 +126,39 @@ let
|
|||||||
prettyKeys = b:
|
prettyKeys = b:
|
||||||
let m = lib.replaceStrings [ "$mod" ] [ "SUPER" ] b.mods;
|
let m = lib.replaceStrings [ "$mod" ] [ "SUPER" ] b.mods;
|
||||||
prefix = if m == "" then "" else lib.concatStringsSep " + " (lib.splitString " " m) + " + ";
|
prefix = if m == "" then "" else lib.concatStringsSep " + " (lib.splitString " " m) + " + ";
|
||||||
key = lib.replaceStrings [ "question" "slash" ] [ "?" "/" ] b.key;
|
key = lib.replaceStrings
|
||||||
in prefix + key;
|
[ "question" "slash" "left" "right" "up" "down" ]
|
||||||
|
[ "?" "/" "←" "→" "↑" "↓" ] b.key;
|
||||||
|
# The bind is `SHIFT + slash` (base keysym; keybinds.nix), which
|
||||||
|
# renders "SHIFT + /". `?` IS Shift+/, so collapse that back to the
|
||||||
|
# single documented glyph — the cheatsheet reads "SUPER + ?".
|
||||||
|
in lib.replaceStrings [ "SHIFT + /" ] [ "?" ] (prefix + key);
|
||||||
cheatRows =
|
cheatRows =
|
||||||
map (b: padRight 22 (prettyKeys b) + b.desc) keybinds.binds
|
map (b: padRight 22 (prettyKeys b) + b.desc) (keybinds.binds
|
||||||
|
++ lib.optionals (lib.hasInfix "," cfg.keyboard.layout)
|
||||||
|
keybinds.multiLayoutBinds)
|
||||||
|
# Launch-or-focus binds (nomarchy.launchOrFocus, generated in
|
||||||
|
# hyprland.nix) — same renderer, so they land in the cheatsheet too.
|
||||||
|
++ map (e: padRight 22 (prettyKeys e)
|
||||||
|
+ (if e.desc != "" then e.desc
|
||||||
|
else "Focus or launch ${if e.command == "" then lib.toLower e.class else e.command}"))
|
||||||
|
cfg.launchOrFocus
|
||||||
++ map (e: padRight 22 e.keys + e.desc) keybinds.extra;
|
++ map (e: padRight 22 e.keys + e.desc) keybinds.extra;
|
||||||
cheatsheetFile = pkgs.writeText "nomarchy-keybinds.txt"
|
cheatsheetFile = pkgs.writeText "nomarchy-keybinds.txt"
|
||||||
(lib.concatStringsSep "\n" cheatRows + "\n");
|
(lib.concatStringsSep "\n" cheatRows + "\n");
|
||||||
|
|
||||||
|
# Dimmed keybind hints on menu rows (item 28c) — the same keybinds.nix
|
||||||
|
# + prettyKeys the cheatsheet uses, so they can never drift from the
|
||||||
|
# live binds. Pango spans; the menus that show them pass -markup-rows.
|
||||||
|
# Empty (no hint) when nothing binds the action.
|
||||||
|
hintForAction = action:
|
||||||
|
let m = lib.filter (b: b.action == action) keybinds.binds;
|
||||||
|
in lib.optionalString (m != [ ])
|
||||||
|
" <span size='small' alpha='55%'>${prettyKeys (lib.head m)}</span>";
|
||||||
|
menuHint = module: hintForAction "exec, nomarchy-menu ${module}";
|
||||||
|
# Apps opens rofi drun directly, not a nomarchy-menu module.
|
||||||
|
appsHint = " <span size='small' alpha='55%'>SUPER + D</span>";
|
||||||
|
|
||||||
nomarchy-menu = pkgs.writeShellScriptBin "nomarchy-menu" ''
|
nomarchy-menu = pkgs.writeShellScriptBin "nomarchy-menu" ''
|
||||||
# Nomarchy menu dispatcher — thin presentation layer over
|
# Nomarchy menu dispatcher — thin presentation layer over
|
||||||
# `rofi -dmenu`; actions delegate to nomarchy-theme-sync, systemctl,
|
# `rofi -dmenu`; actions delegate to nomarchy-theme-sync, systemctl,
|
||||||
@@ -64,6 +185,20 @@ let
|
|||||||
# rofi has show-icons (on globally; passed explicitly on the icon menus).
|
# rofi has show-icons (on globally; passed explicitly on the icon menus).
|
||||||
row() { printf '%s\0icon\x1f%s\n' "$1" "$2"; }
|
row() { printf '%s\0icon\x1f%s\n' "$1" "$2"; }
|
||||||
|
|
||||||
|
# Back-audit exceptions (item 24): external modi/tools (calc, emoji,
|
||||||
|
# networkmanager_dmenu, rofi-pulse-select) can't take an injected row —
|
||||||
|
# Esc is their back path; free-text prompts (web, ask) have no list to
|
||||||
|
# append to — Esc cancels. Everything hand-rolled gets the row below.
|
||||||
|
# General menu convention: every list menu ends with a "↩ Back" entry that
|
||||||
|
# returns one level up (so you never have to Esc out and reopen). The `↩`
|
||||||
|
# glyph in the label IS the arrow — `back` must not also attach a
|
||||||
|
# go-previous icon, or icon menus render two arrows (item 34). So `back`
|
||||||
|
# is now just the plain-label emit, identical to what pick-lists append —
|
||||||
|
# one arrow everywhere. Always matched EXACTLY ("↩ Back") so it can't
|
||||||
|
# collide with clipboard or filename content.
|
||||||
|
BACK="↩ Back"
|
||||||
|
back() { printf '%s\n' "$BACK"; }
|
||||||
|
|
||||||
case "''${1:-}" in
|
case "''${1:-}" in
|
||||||
power)
|
power)
|
||||||
choice=$( {
|
choice=$( {
|
||||||
@@ -73,8 +208,10 @@ let
|
|||||||
row "Hibernate" system-hibernate
|
row "Hibernate" system-hibernate
|
||||||
row "Reboot" system-reboot
|
row "Reboot" system-reboot
|
||||||
row "Shutdown" system-shutdown
|
row "Shutdown" system-shutdown
|
||||||
|
back
|
||||||
} | rofi -dmenu -show-icons -p Power) || exit 0
|
} | rofi -dmenu -show-icons -p Power) || exit 0
|
||||||
case "$choice" in
|
case "$choice" in
|
||||||
|
"$BACK") exec "$0" ;;
|
||||||
*Lock) loginctl lock-session ;;
|
*Lock) loginctl lock-session ;;
|
||||||
*Logout) hyprctl dispatch exit ;;
|
*Logout) hyprctl dispatch exit ;;
|
||||||
*Suspend) systemctl suspend ;;
|
*Suspend) systemctl suspend ;;
|
||||||
@@ -92,17 +229,44 @@ let
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
cur=$(powerprofilesctl get 2>/dev/null)
|
cur=$(powerprofilesctl get 2>/dev/null)
|
||||||
choice=$(powerprofilesctl list 2>/dev/null \
|
# Each profile gets its themed icon from Papirus' colored
|
||||||
| sed -nE 's/^[* ] ([a-z-]+):$/\1/p' \
|
# battery-profile-* family (the -symbolic variants are #444 grey and
|
||||||
| rofi -dmenu -p "profile (now: $cur)") || exit 0
|
# vanish on dark themes). The selected row's returned text is still
|
||||||
|
# the bare profile name, so `powerprofilesctl set` is unaffected.
|
||||||
|
choice=$( {
|
||||||
|
powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p' \
|
||||||
|
| while IFS= read -r p; do
|
||||||
|
case "$p" in
|
||||||
|
performance) row "$p" battery-profile-performance ;;
|
||||||
|
balanced) row "$p" battery-profile-balanced ;;
|
||||||
|
power-saver) row "$p" battery-profile-powersave ;;
|
||||||
|
*) row "$p" preferences-system-power ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
back
|
||||||
|
} | rofi -dmenu -show-icons -p "profile (now: $cur)") || exit 0
|
||||||
|
[ "$choice" = "$BACK" ] && exec "$0" system
|
||||||
[ -n "$choice" ] && powerprofilesctl set "$choice" ;;
|
[ -n "$choice" ] && powerprofilesctl set "$choice" ;;
|
||||||
|
|
||||||
theme)
|
theme)
|
||||||
choice=$(nomarchy-theme-sync list | rofi -dmenu -p theme) || exit 0
|
# Visual picker: a grid of real desktop previews (rows + Name→slug
|
||||||
[ -n "$choice" ] && exec nomarchy-theme-sync apply "$choice" ;;
|
# map generated from the presets at build time). Pick a card → apply
|
||||||
|
# that slug. The grid layout is a per-invocation -theme-str override.
|
||||||
|
declare -A THEME_SLUG=(
|
||||||
|
${themeSlugMap}
|
||||||
|
)
|
||||||
|
choice=$( {
|
||||||
|
${themeRows}
|
||||||
|
back
|
||||||
|
} | rofi -dmenu -show-icons -p Theme -theme-str ${themeGridThemeStr}) || exit 0
|
||||||
|
[ "$choice" = "$BACK" ] && exec "$0"
|
||||||
|
choice="''${choice% ✓}" # drop the active marker if present
|
||||||
|
slug="''${THEME_SLUG[$choice]:-}"
|
||||||
|
[ -n "$slug" ] && exec nomarchy-theme-sync apply "$slug" ;;
|
||||||
|
|
||||||
clipboard)
|
clipboard)
|
||||||
sel=$(cliphist list | rofi -dmenu -p clip) || exit 0
|
sel=$( { cliphist list; printf '%s\n' "$BACK"; } | rofi -dmenu -p clip) || exit 0
|
||||||
|
[ "$sel" = "$BACK" ] && exec "$0" tools
|
||||||
printf '%s' "$sel" | cliphist decode | wl-copy ;;
|
printf '%s' "$sel" | cliphist decode | wl-copy ;;
|
||||||
|
|
||||||
calc)
|
calc)
|
||||||
@@ -114,10 +278,10 @@ let
|
|||||||
-calc-command "echo -n '{result}' | wl-copy" ;;
|
-calc-command "echo -n '{result}' | wl-copy" ;;
|
||||||
|
|
||||||
files)
|
files)
|
||||||
sel=$(fd . "$HOME" --type f --hidden --exclude .git --exclude .cache 2>/dev/null \
|
sel=$( { fd . "$HOME" --type f --hidden --exclude .git --exclude .cache 2>/dev/null \
|
||||||
| head -n 50000 \
|
| head -n 50000 | sed "s|^$HOME/||"; printf '%s\n' "$BACK"; } \
|
||||||
| sed "s|^$HOME/||" \
|
|
||||||
| rofi -dmenu -p file) || exit 0
|
| rofi -dmenu -p file) || exit 0
|
||||||
|
[ "$sel" = "$BACK" ] && exec "$0" tools
|
||||||
[ -n "$sel" ] && exec xdg-open "$HOME/$sel" ;;
|
[ -n "$sel" ] && exec xdg-open "$HOME/$sel" ;;
|
||||||
|
|
||||||
emoji)
|
emoji)
|
||||||
@@ -132,34 +296,229 @@ let
|
|||||||
exec xdg-open "https://www.google.com/search?q=$(urlencode "$q")" ;;
|
exec xdg-open "https://www.google.com/search?q=$(urlencode "$q")" ;;
|
||||||
|
|
||||||
network)
|
network)
|
||||||
# nmtui in a terminal (NetworkManager is the system network stack).
|
# Native rofi wifi/VPN picker (networkmanager_dmenu) over the system
|
||||||
exec ${cfg.terminal} -e nmtui ;;
|
# NetworkManager — replaces the old nmtui-in-a-terminal flow. Reads
|
||||||
|
# its config from xdg.configFile below (told to drive rofi).
|
||||||
|
# External tool: no injected ↩ Back row possible — Esc closes.
|
||||||
|
exec networkmanager_dmenu ;;
|
||||||
|
|
||||||
|
vpn)
|
||||||
|
# VPN setup + management (nomarchy-vpn): NM VPN/WireGuard connect +
|
||||||
|
# config import + Tailscale. Distinct from `network` (the wifi picker).
|
||||||
|
exec nomarchy-vpn ;;
|
||||||
|
|
||||||
bluetooth)
|
bluetooth)
|
||||||
# blueman-manager GUI (services.blueman.enable, system-side).
|
# blueman-manager GUI (services.blueman.enable, system-side).
|
||||||
exec blueman-manager ;;
|
exec blueman-manager ;;
|
||||||
|
|
||||||
|
printers)
|
||||||
|
# system-config-printer GUI (the CUPS admin app), installed by
|
||||||
|
# nomarchy.services.printing. Self-gated in the System menu; guard
|
||||||
|
# here too in case it's invoked directly.
|
||||||
|
command -v system-config-printer >/dev/null 2>&1 \
|
||||||
|
&& exec system-config-printer
|
||||||
|
notify-send "Printers" "Not available (nomarchy.services.printing off?)."; exit 0 ;;
|
||||||
|
|
||||||
|
audio)
|
||||||
|
# PipeWire (pulse) sink/source switcher via rofi-pulse-select. Self-
|
||||||
|
# gated in the System menu on the pulse socket; guarded here too.
|
||||||
|
if [ ! -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ]; then
|
||||||
|
notify-send "Audio" "No PipeWire/PulseAudio socket (nomarchy.audio off?)."; exit 0
|
||||||
|
fi
|
||||||
|
choice=$( {
|
||||||
|
row "Output device" audio-volume-high
|
||||||
|
row "Input device" audio-input-microphone
|
||||||
|
back
|
||||||
|
} | rofi -dmenu -show-icons -p Audio) || exit 0
|
||||||
|
case "$choice" in
|
||||||
|
"$BACK") exec "$0" system ;;
|
||||||
|
*Output*) exec rofi-pulse-select sink ;;
|
||||||
|
*Input*) exec rofi-pulse-select source ;;
|
||||||
|
esac ;;
|
||||||
|
|
||||||
|
display)
|
||||||
|
# Per-output resolution picker. Applies the chosen mode INSTANTLY via
|
||||||
|
# `hyprctl keyword monitor` — keeping the output's current position and
|
||||||
|
# scale, so only the resolution changes — and PERSISTS it to the in-flake
|
||||||
|
# state (settings.monitors.<name>, --no-switch = no rebuild), so the
|
||||||
|
# choice survives logout/reboot. A later rebuild bakes the same
|
||||||
|
# resolution into the generated monitor rule (hyprland.nix overlays
|
||||||
|
# settings.monitors onto nomarchy.monitors by name). Self-gated to the
|
||||||
|
# Hyprland session; guard here too in case it's invoked directly.
|
||||||
|
command -v hyprctl >/dev/null 2>&1 \
|
||||||
|
|| { notify-send "Display" "Hyprland is not running."; exit 0; }
|
||||||
|
mons=$(hyprctl monitors -j)
|
||||||
|
|
||||||
|
# Choose the output (skip the chooser when only one is connected and
|
||||||
|
# there are no profiles — the Profiles row needs the chooser to live in).
|
||||||
|
# Where the mode picker's Back returns to: the output chooser when it
|
||||||
|
# was shown (a real previous level), else straight to System — on a
|
||||||
|
# single-monitor laptop the chooser is skipped, so returning to
|
||||||
|
# `display` would just re-open this same mode picker (item 40).
|
||||||
|
modeBack=system
|
||||||
|
profs=; command -v nomarchy-display-profile >/dev/null 2>&1 && profs=1
|
||||||
|
if [ -n "$profs" ] || [ "$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)" -gt 1 ]; then
|
||||||
|
name=$( {
|
||||||
|
[ -n "$profs" ] && printf 'Profiles ›\n'
|
||||||
|
printf '%s' "$mons" | jq -r '.[] | "\(.name) · \(.width)x\(.height)@\(.refreshRate|round)Hz"'
|
||||||
|
printf '%s\n' "$BACK"
|
||||||
|
} | rofi -dmenu -p Display ) || exit 0
|
||||||
|
[ "$name" = "$BACK" ] && exec "$0" system
|
||||||
|
[ "$name" = "Profiles ›" ] && exec "$0" display-profile
|
||||||
|
name=''${name%% ·*} # strip the " · WxH@R" hint
|
||||||
|
modeBack=display
|
||||||
|
else
|
||||||
|
name=$(printf '%s' "$mons" | jq -r '.[0].name')
|
||||||
|
fi
|
||||||
|
[ -n "$name" ] || exit 0
|
||||||
|
|
||||||
|
# Pick a mode: a few Hyprland resolution tokens, then the output's
|
||||||
|
# advertised modes (rounded refresh rate, deduped, highest first). The
|
||||||
|
# prompt shows the current mode.
|
||||||
|
cur=$(printf '%s' "$mons" | jq -r --arg n "$name" \
|
||||||
|
'.[]|select(.name==$n)|"\(.width)x\(.height)@\(.refreshRate|round)"')
|
||||||
|
mode=$( {
|
||||||
|
printf 'preferred\nhighres\nhighrr\n'
|
||||||
|
printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.availableModes[]' \
|
||||||
|
| sed 's/Hz$//' | awk -F@ '{ printf "%s@%d\n", $1, $2 + 0.5 }' | sort -rV -u
|
||||||
|
printf '%s\n' "$BACK"
|
||||||
|
} | rofi -dmenu -p "$name (now $cur)" ) || exit 0
|
||||||
|
[ "$mode" = "$BACK" ] && exec "$0" "$modeBack"
|
||||||
|
[ -n "$mode" ] || exit 0
|
||||||
|
|
||||||
|
# Apply live (keep the output's current position + scale), then persist.
|
||||||
|
pos=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|"\(.x)x\(.y)"')
|
||||||
|
scale=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.scale')
|
||||||
|
if hyprctl keyword monitor "$name,$mode,$pos,$scale" >/dev/null 2>&1; then
|
||||||
|
nomarchy-theme-sync --quiet set "settings.monitors.$name" "$mode" --no-switch
|
||||||
|
notify-send "Display" "$name → $mode"
|
||||||
|
else
|
||||||
|
notify-send "Display" "Could not set $name to $mode."
|
||||||
|
fi ;;
|
||||||
|
|
||||||
capture)
|
capture)
|
||||||
|
# Screenshots + screen recording. Recording rows self-swap: while
|
||||||
|
# one runs, the only offer is Stop (same surface as the bar's ⏺).
|
||||||
choice=$( {
|
choice=$( {
|
||||||
row "Region → clipboard" applets-screenshooter
|
row "Region → clipboard" applets-screenshooter
|
||||||
row "Region → file" applets-screenshooter
|
row "Region → file" applets-screenshooter
|
||||||
row "Full screen → clipboard" camera-photo
|
row "Full screen → clipboard" camera-photo
|
||||||
row "Full screen → file" camera-photo
|
row "Full screen → file" camera-photo
|
||||||
|
command -v tesseract >/dev/null 2>&1 && row "OCR region → clipboard" scanner
|
||||||
|
if command -v nomarchy-record >/dev/null 2>&1; then
|
||||||
|
if nomarchy-record active; then
|
||||||
|
row "■ Stop recording" media-playback-stop
|
||||||
|
else
|
||||||
|
row "Record region" media-record
|
||||||
|
row "Record screen" media-record
|
||||||
|
row "Record region + audio" audio-input-microphone
|
||||||
|
row "Record screen + audio" audio-input-microphone
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
back
|
||||||
} | rofi -dmenu -show-icons -p Capture) || exit 0
|
} | rofi -dmenu -show-icons -p Capture) || exit 0
|
||||||
dir="$HOME/Pictures/Screenshots"
|
dir="$HOME/Pictures/Screenshots"
|
||||||
file="$dir/$(date +%Y%m%d-%H%M%S).png"
|
file="$dir/$(date +%Y%m%d-%H%M%S).png"
|
||||||
case "$choice" in
|
case "$choice" in
|
||||||
|
"$BACK") exec "$0" tools ;;
|
||||||
*"Region → clipboard"*) grim -g "$(slurp)" - | wl-copy ;;
|
*"Region → clipboard"*) grim -g "$(slurp)" - | wl-copy ;;
|
||||||
*"Region → file"*) mkdir -p "$dir"; grim -g "$(slurp)" "$file" \
|
*"Region → file"*) mkdir -p "$dir"; grim -g "$(slurp)" "$file" \
|
||||||
&& notify-send "Screenshot saved" "$file" ;;
|
&& notify-send "Screenshot saved" "$file" ;;
|
||||||
|
*"OCR region"*)
|
||||||
|
# Select → screenshot → tesseract → clipboard. Esc in slurp
|
||||||
|
# cancels silently (the recording.nix pattern); recognizing
|
||||||
|
# nothing toasts instead of copying an empty clipboard.
|
||||||
|
geo=$(slurp) || exit 0
|
||||||
|
txt=$(grim -g "$geo" - | tesseract stdin stdout 2>/dev/null)
|
||||||
|
[ -n "''${txt//[[:space:]]/}" ] \
|
||||||
|
|| { notify-send "OCR" "No text recognized in that region."; exit 0; }
|
||||||
|
printf '%s' "$txt" | wl-copy
|
||||||
|
notify-send "OCR" "Copied $(printf '%s' "$txt" | wc -w) word(s) to the clipboard." ;;
|
||||||
*"Full screen → clipboard"*) grim - | wl-copy ;;
|
*"Full screen → clipboard"*) grim - | wl-copy ;;
|
||||||
*"Full screen → file"*) mkdir -p "$dir"; grim "$file" \
|
*"Full screen → file"*) mkdir -p "$dir"; grim "$file" \
|
||||||
&& notify-send "Screenshot saved" "$file" ;;
|
&& notify-send "Screenshot saved" "$file" ;;
|
||||||
|
*"Stop recording"*) exec nomarchy-record stop ;;
|
||||||
|
*"Record region + audio"*) exec nomarchy-record start region audio ;;
|
||||||
|
*"Record screen + audio"*) exec nomarchy-record start screen audio ;;
|
||||||
|
*"Record region"*) exec nomarchy-record start region ;;
|
||||||
|
*"Record screen"*) exec nomarchy-record start screen ;;
|
||||||
esac ;;
|
esac ;;
|
||||||
|
|
||||||
|
lookfeel)
|
||||||
|
# Look & Feel — appearance grouped (item 19): the theme picker,
|
||||||
|
# wallpaper cycling, night light. The root stays six rows; the
|
||||||
|
# SUPER+T / SUPER+SHIFT+T direct binds still hit the leaves.
|
||||||
|
choice=$( {
|
||||||
|
row "Theme${menuHint "theme"}" preferences-desktop-theme
|
||||||
|
row "Next wallpaper${hintForAction "exec, nomarchy-theme-sync bg next"}" preferences-desktop-wallpaper
|
||||||
|
if systemctl --user is-active --quiet hyprsunset.service 2>/dev/null
|
||||||
|
then row "Night light (on)" weather-clear-night
|
||||||
|
else row "Night light (off)" weather-clear-night
|
||||||
|
fi
|
||||||
|
back
|
||||||
|
} | rofi -dmenu -show-icons -markup-rows -p "Look & Feel") || exit 0
|
||||||
|
case "$choice" in
|
||||||
|
"$BACK") exec "$0" ;;
|
||||||
|
*Theme*) exec "$0" theme ;;
|
||||||
|
*wallpaper*) exec nomarchy-theme-sync bg next ;;
|
||||||
|
*"Night light"*) exec "$0" nightlight ;;
|
||||||
|
esac ;;
|
||||||
|
|
||||||
|
display-profile)
|
||||||
|
# Named multi-output layouts (nomarchy.displayProfiles). Applying is
|
||||||
|
# instant (hyprctl per entry, via nomarchy-display-profile) and
|
||||||
|
# persisted in-flake (settings.displayProfile) so the next rebuild
|
||||||
|
# bakes it. "Base layout" clears the state and hyprctl-reloads.
|
||||||
|
command -v nomarchy-display-profile >/dev/null 2>&1 \
|
||||||
|
|| { notify-send "Display profiles" "None declared (nomarchy.displayProfiles)."; exit 0; }
|
||||||
|
active=$(nomarchy-display-profile active)
|
||||||
|
choice=$( {
|
||||||
|
nomarchy-display-profile list | while IFS= read -r p; do
|
||||||
|
if [ "$p" = "$active" ]; then printf '● %s\n' "$p"; else printf '○ %s\n' "$p"; fi
|
||||||
|
done
|
||||||
|
printf 'Base layout\n'
|
||||||
|
if [ "$(nomarchy-theme-sync get settings.displayProfileAuto 2>/dev/null)" = true ]
|
||||||
|
then printf 'Auto-switch (on)\n'
|
||||||
|
else printf 'Auto-switch (off)\n'
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$BACK"
|
||||||
|
} | rofi -dmenu -p Profiles ) || exit 0
|
||||||
|
case "$choice" in
|
||||||
|
"$BACK") exec "$0" display ;;
|
||||||
|
"Base layout") exec nomarchy-display-profile base ;;
|
||||||
|
*"Auto-switch"*)
|
||||||
|
# Instant in-flake flag; the watcher reads it live on the next
|
||||||
|
# output change — no rebuild, no unit juggling.
|
||||||
|
if [ "$(nomarchy-theme-sync get settings.displayProfileAuto 2>/dev/null)" = true ]; then
|
||||||
|
nomarchy-theme-sync --quiet set settings.displayProfileAuto false --no-switch
|
||||||
|
notify-send "Display profiles" "Auto-switch off — profiles change only from this menu."
|
||||||
|
else
|
||||||
|
nomarchy-theme-sync --quiet set settings.displayProfileAuto true --no-switch
|
||||||
|
notify-send "Display profiles" "Auto-switch on — plugging/unplugging outputs picks the matching profile."
|
||||||
|
fi
|
||||||
|
exec "$0" display-profile ;;
|
||||||
|
*) exec nomarchy-display-profile apply "''${choice#[●○] }" ;;
|
||||||
|
esac ;;
|
||||||
|
|
||||||
|
colorpicker)
|
||||||
|
# Pick a pixel's color (hyprpicker's zoom loupe) → clipboard, hex
|
||||||
|
# in the confirmation toast. Esc/cancel exits silently. Self-gated
|
||||||
|
# in the Tools row; guarded here too for direct invocation.
|
||||||
|
command -v hyprpicker >/dev/null 2>&1 \
|
||||||
|
|| { notify-send "Color picker" "hyprpicker is not installed."; exit 0; }
|
||||||
|
color=$(hyprpicker) || exit 0
|
||||||
|
[ -n "$color" ] || exit 0
|
||||||
|
printf '%s' "$color" | wl-copy
|
||||||
|
notify-send "Color picker" "$color copied to the clipboard." ;;
|
||||||
|
|
||||||
keybinds)
|
keybinds)
|
||||||
# Read-only cheatsheet, generated from keybinds.nix at build time.
|
# Read-only cheatsheet, generated from keybinds.nix at build time.
|
||||||
rofi -dmenu -i -p keys < ${cheatsheetFile} >/dev/null || exit 0 ;;
|
# Enter on a row is a deliberate no-op (nothing to launch); ↩ Back
|
||||||
|
# returns to the root picker, which is also where it's launched.
|
||||||
|
sel=$( { cat ${cheatsheetFile}; printf '%s\n' "$BACK"; } \
|
||||||
|
| rofi -dmenu -i -p keys ) || exit 0
|
||||||
|
[ "$sel" = "$BACK" ] && exec "$0"
|
||||||
|
exit 0 ;;
|
||||||
|
|
||||||
ask)
|
ask)
|
||||||
# Free-text question → claude CLI in a terminal (OAuth, no API key).
|
# Free-text question → claude CLI in a terminal (OAuth, no API key).
|
||||||
@@ -178,36 +537,128 @@ let
|
|||||||
&& notify-send "Do Not Disturb off" "Notifications resumed."
|
&& notify-send "Do Not Disturb off" "Notifications resumed."
|
||||||
exit 0 ;;
|
exit 0 ;;
|
||||||
|
|
||||||
|
nightlight)
|
||||||
|
# Force the scheduled blue-light filter on/off for the session by
|
||||||
|
# starting/stopping hyprsunset (nomarchy-nightlight, from nightlight.nix).
|
||||||
|
# Self-gated in the menu; guard here too in case it's invoked directly.
|
||||||
|
command -v nomarchy-nightlight >/dev/null 2>&1 \
|
||||||
|
&& exec nomarchy-nightlight toggle
|
||||||
|
notify-send "Night light" "Not available (nomarchy.nightlight off?)."; exit 0 ;;
|
||||||
|
|
||||||
|
autotimezone)
|
||||||
|
# Toggle automatic timezone detection (geoclue + automatic-timezoned).
|
||||||
|
# A SYSTEM service, so flipping it writes the in-flake flag and runs a
|
||||||
|
# system rebuild (sudo) + a home switch — in a terminal, like Snapshots.
|
||||||
|
command -v nomarchy-autotimezone >/dev/null 2>&1 \
|
||||||
|
|| { notify-send "Auto timezone" "Unavailable on this machine."; exit 0; }
|
||||||
|
exec ${cfg.terminal} -e nomarchy-autotimezone toggle ;;
|
||||||
|
|
||||||
|
autocommit)
|
||||||
|
# Toggle opt-in auto-commit: every menu/theme mutation also commits
|
||||||
|
# theme-state.json (that file only) in the downstream flake, so
|
||||||
|
# settings history is `git log`. Nothing in Nix consumes the flag —
|
||||||
|
# the tool reads the live state on each write — so the toggle is
|
||||||
|
# instant, no rebuild. The off-write commits too (the tool fires
|
||||||
|
# when the flag was on before OR after), keeping history consistent.
|
||||||
|
if [ "$(nomarchy-theme-sync get settings.autoCommit 2>/dev/null)" = true ]; then
|
||||||
|
nomarchy-theme-sync --quiet set settings.autoCommit false --no-switch
|
||||||
|
notify-send "Auto-commit" "Off — menu changes stay uncommitted in your flake."
|
||||||
|
else
|
||||||
|
nomarchy-theme-sync --quiet set settings.autoCommit true --no-switch
|
||||||
|
notify-send "Auto-commit" "On — each change commits theme-state.json in your flake."
|
||||||
|
fi
|
||||||
|
exit 0 ;;
|
||||||
|
|
||||||
snapshot)
|
snapshot)
|
||||||
# btrfs-assistant: snapshot browse / diff / restore / rollback over
|
# Snapshot browse/restore — the btrfs-assistant GUI via its pkexec
|
||||||
# snapper, elevating via polkit. Shipped system-side with
|
# launcher. The "2.2 segfaults on 26.05" diagnosis was HALF right:
|
||||||
# nomarchy.system.snapper; self-gate so the entry no-ops elsewhere.
|
# only UNPRIVILEGED runs crash (libbtrfsutil unprivileged subvolume
|
||||||
command -v btrfs-assistant >/dev/null 2>&1 \
|
# iteration, btrfs-progs 6.17.1; fixed upstream after) — as root it
|
||||||
|| { notify-send "Snapshots" "btrfs-assistant isn't installed (BTRFS snapshots off?)."; exit 0; }
|
# runs fine, and the launcher runs it as root. The prompt needs the
|
||||||
exec btrfs-assistant ;;
|
# session polkit agent (hyprpolkitagent, exec-once in hyprland.nix).
|
||||||
|
# Fallback: nomarchy-snapshots, the keyboard-driven fzf browser in a
|
||||||
|
# terminal (kept installed — also nice over SSH).
|
||||||
|
# Self-gated to nomarchy.system.snapper, so it no-ops elsewhere.
|
||||||
|
command -v btrfs-assistant-launcher >/dev/null 2>&1 \
|
||||||
|
&& exec btrfs-assistant-launcher
|
||||||
|
command -v nomarchy-snapshots >/dev/null 2>&1 \
|
||||||
|
|| { notify-send "Snapshots" "Snapshot tools unavailable (nomarchy.system.snapper off?)."; exit 0; }
|
||||||
|
exec ${cfg.terminal} -e sudo nomarchy-snapshots ;;
|
||||||
|
|
||||||
|
doctor)
|
||||||
|
# Read-only health sheet in a terminal; exit code doesn't matter
|
||||||
|
# here — the sheet itself is the product.
|
||||||
|
exec ${cfg.terminal} -e sh -c "nomarchy-doctor
|
||||||
|
printf '\nEnter to close.'; read -r _" ;;
|
||||||
|
|
||||||
|
controlcenter)
|
||||||
|
# Nomarchy TUI Control Center.
|
||||||
|
exec ${cfg.terminal} -e nomarchy-control-center ;;
|
||||||
|
|
||||||
|
rollback)
|
||||||
|
# Undo, one menu away (informative + rock-stable). Desktop =
|
||||||
|
# Home Manager generations: theme/config history is already one
|
||||||
|
# generation per change, and running an older generation's
|
||||||
|
# activate script is the supported HM rollback — reversible by
|
||||||
|
# activating a newer one again, so no typed-yes gate here.
|
||||||
|
# System-level undo deliberately LINKS OUT instead of putting
|
||||||
|
# destructive flows in rofi: boot-menu generations and snapper
|
||||||
|
# rollback have their own homes and gates (docs/RECOVERY.md,
|
||||||
|
# nomarchy-snapshots).
|
||||||
|
gens=$(home-manager generations 2>/dev/null | head -10)
|
||||||
|
choice=$( {
|
||||||
|
if [ -n "$gens" ]; then
|
||||||
|
printf '%s\n' "$gens" | awk '{
|
||||||
|
printf "Desktop gen %s — %s %s%s\n", $5, $1, $2, (NR==1 ? " (current)" : "")
|
||||||
|
}' | while IFS= read -r line; do row "$line" document-open-recent; done
|
||||||
|
fi
|
||||||
|
command -v nomarchy-snapshots >/dev/null 2>&1 \
|
||||||
|
&& row "System files → Snapshots" timeshift
|
||||||
|
row "System config → boot an older generation (how)" help-about
|
||||||
|
back
|
||||||
|
} | rofi -dmenu -show-icons -p Rollback) || exit 0
|
||||||
|
case "$choice" in
|
||||||
|
"$BACK") exec "$0" system ;;
|
||||||
|
"Desktop gen "*)
|
||||||
|
num=''${choice#Desktop gen }; num=''${num%% *}
|
||||||
|
path=$(printf '%s\n' "$gens" | awk -v n="$num" '$5 == n { print $7 }')
|
||||||
|
[ -x "$path/activate" ] \
|
||||||
|
|| { notify-send "Rollback" "Generation $num not found on disk."; exit 1; }
|
||||||
|
exec ${cfg.terminal} -e sh -c "
|
||||||
|
echo \"Activating Home Manager generation $num…\"; echo
|
||||||
|
\"$path/activate\"
|
||||||
|
echo; echo \"Desktop rolled back to generation $num.\"
|
||||||
|
echo \"(Roll forward the same way — newer generations stay listed.)\"
|
||||||
|
printf 'Enter to close.'; read -r _" ;;
|
||||||
|
*Snapshots*) exec "$0" snapshot ;;
|
||||||
|
*"older generation"*)
|
||||||
|
notify-send "System rollback" "Reboot and pick an older NixOS generation in the boot menu (the last 10 are kept). Make it stick: revert the change in ~/.nomarchy, then sys-rebuild. Details: docs/RECOVERY.md §3." ;;
|
||||||
|
esac ;;
|
||||||
|
|
||||||
tools)
|
tools)
|
||||||
# Tools submenu — utilities you invoke. Each leaf is also reachable
|
# Tools submenu — utilities you invoke. Each leaf is also reachable
|
||||||
# directly (SUPER+CTRL+<mnemonic>); this just keeps the root short.
|
# directly (SUPER+CTRL+<mnemonic>); this just keeps the root short.
|
||||||
choice=$( {
|
choice=$( {
|
||||||
row "Calculator" accessories-calculator
|
row "Calculator${menuHint "calc"}" accessories-calculator
|
||||||
row "Clipboard" edit-paste
|
row "Clipboard${menuHint "clipboard"}" edit-paste
|
||||||
row "Emoji" face-smile-big
|
row "Emoji${menuHint "emoji"}" face-smile-big
|
||||||
row "Files" system-file-manager
|
row "Files${menuHint "files"}" system-file-manager
|
||||||
row "Web search" system-search
|
row "Web search${menuHint "web"}" system-search
|
||||||
row "Capture" applets-screenshooter
|
row "Capture${menuHint "capture"}" applets-screenshooter
|
||||||
row "Ask Claude" internet-chat
|
command -v hyprpicker >/dev/null 2>&1 && row "Color picker${menuHint "colorpicker"}" preferences-desktop-color
|
||||||
row "Back" go-previous
|
row "Ask Claude${menuHint "ask"}" internet-chat
|
||||||
} | rofi -dmenu -show-icons -p Tools) || exit 0
|
back
|
||||||
|
} | rofi -dmenu -show-icons -markup-rows -p Tools) || exit 0
|
||||||
case "$choice" in
|
case "$choice" in
|
||||||
|
"$BACK") exec "$0" ;;
|
||||||
*Calc*) exec "$0" calc ;;
|
*Calc*) exec "$0" calc ;;
|
||||||
*Clipboard*) exec "$0" clipboard ;;
|
*Clipboard*) exec "$0" clipboard ;;
|
||||||
*Emoji*) exec "$0" emoji ;;
|
*Emoji*) exec "$0" emoji ;;
|
||||||
*Files*) exec "$0" files ;;
|
*Files*) exec "$0" files ;;
|
||||||
*Web*) exec "$0" web ;;
|
*Web*) exec "$0" web ;;
|
||||||
*Capture*) exec "$0" capture ;;
|
*Capture*) exec "$0" capture ;;
|
||||||
|
*Color*) exec "$0" colorpicker ;;
|
||||||
*Ask*) exec "$0" ask ;;
|
*Ask*) exec "$0" ask ;;
|
||||||
*Back*) exec "$0" ;;
|
|
||||||
esac ;;
|
esac ;;
|
||||||
|
|
||||||
system)
|
system)
|
||||||
@@ -216,22 +667,97 @@ let
|
|||||||
# the submenu shrinks to match the hardware, like the Waybar modules.
|
# the submenu shrinks to match the hardware, like the Waybar modules.
|
||||||
bats=(/sys/class/power_supply/BAT*)
|
bats=(/sys/class/power_supply/BAT*)
|
||||||
choice=$( {
|
choice=$( {
|
||||||
row "Network" network-wireless
|
row "Network${menuHint "network"}" network-wireless
|
||||||
row "Bluetooth" bluetooth
|
row "VPN" network-vpn
|
||||||
row "Do Not Disturb" notification-disabled
|
row "Bluetooth${menuHint "bluetooth"}" bluetooth
|
||||||
command -v btrfs-assistant >/dev/null 2>&1 && row "Snapshots" timeshift
|
[ -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ] \
|
||||||
|
&& row "Audio" audio-volume-high
|
||||||
|
row "Display" video-display
|
||||||
|
command -v system-config-printer >/dev/null 2>&1 && row "Printers" printer
|
||||||
|
row "Do Not Disturb${menuHint "dnd"}" notification-disabled
|
||||||
|
if [ "$(nomarchy-theme-sync get settings.autoTimezone 2>/dev/null)" = true ]
|
||||||
|
then row "Auto timezone (on)" preferences-system-time
|
||||||
|
else row "Auto timezone (off)" preferences-system-time
|
||||||
|
fi
|
||||||
|
if [ -e "''${NOMARCHY_PATH:-$HOME/.nomarchy}/.git" ]; then
|
||||||
|
if [ "$(nomarchy-theme-sync get settings.autoCommit 2>/dev/null)" = true ]
|
||||||
|
then row "Auto-commit (on)" git
|
||||||
|
else row "Auto-commit (off)" git
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
command -v nomarchy-snapshots >/dev/null 2>&1 && row "Snapshots" timeshift
|
||||||
|
row "Rollback" edit-undo
|
||||||
|
command -v nomarchy-doctor >/dev/null 2>&1 && row "Doctor" utilities-system-monitor
|
||||||
|
command -v nomarchy-control-center >/dev/null 2>&1 && row "Control Center" preferences-desktop
|
||||||
if [ -e "''${bats[0]}" ] && command -v powerprofilesctl >/dev/null 2>&1; then
|
if [ -e "''${bats[0]}" ] && command -v powerprofilesctl >/dev/null 2>&1; then
|
||||||
row "Power profile" preferences-system-power
|
row "Power profile" preferences-system-power
|
||||||
fi
|
fi
|
||||||
row "Back" go-previous
|
# Self-gated on the charge-threshold sysfs node (laptops that
|
||||||
} | rofi -dmenu -show-icons -p System) || exit 0
|
# expose it), like Power profile above.
|
||||||
|
[ -e "''${bats[0]}/charge_control_end_threshold" ] \
|
||||||
|
&& row "Battery limit" battery-080
|
||||||
|
back
|
||||||
|
} | rofi -dmenu -show-icons -markup-rows -p System) || exit 0
|
||||||
case "$choice" in
|
case "$choice" in
|
||||||
|
"$BACK") exec "$0" ;;
|
||||||
*Network*) exec "$0" network ;;
|
*Network*) exec "$0" network ;;
|
||||||
|
*VPN*) exec "$0" vpn ;;
|
||||||
*Bluetooth*) exec "$0" bluetooth ;;
|
*Bluetooth*) exec "$0" bluetooth ;;
|
||||||
|
*Audio*) exec "$0" audio ;;
|
||||||
|
*Display*) exec "$0" display ;;
|
||||||
|
*Printers*) exec "$0" printers ;;
|
||||||
*"Do Not Disturb"*) exec "$0" dnd ;;
|
*"Do Not Disturb"*) exec "$0" dnd ;;
|
||||||
|
*"Auto timezone"*) exec "$0" autotimezone ;;
|
||||||
|
*"Auto-commit"*) exec "$0" autocommit ;;
|
||||||
*Snapshots*) exec "$0" snapshot ;;
|
*Snapshots*) exec "$0" snapshot ;;
|
||||||
|
*Rollback*) exec "$0" rollback ;;
|
||||||
|
*Doctor*) exec "$0" doctor ;;
|
||||||
|
*"Control Center"*) exec "$0" controlcenter ;;
|
||||||
*"Power profile"*) exec "$0" power-profile ;;
|
*"Power profile"*) exec "$0" power-profile ;;
|
||||||
*Back*) exec "$0" ;;
|
*"Battery limit"*) exec "$0" batterylimit ;;
|
||||||
|
esac ;;
|
||||||
|
|
||||||
|
batterylimit)
|
||||||
|
# Battery charge-limit preset picker — the rofi-System counterpart of
|
||||||
|
# the control-center's toggle (Bernardo: he wanted this in the menu,
|
||||||
|
# not buried in the gum TUI). Writes the baked NixOS option
|
||||||
|
# settings.power.batteryChargeLimit via theme-sync (--no-switch), so
|
||||||
|
# it lands on the next sys-rebuild — the sysfs oneshot in power.nix
|
||||||
|
# owns applying it (boot + AC-replug). Self-gated in System; guard
|
||||||
|
# here too in case it's invoked directly.
|
||||||
|
bats=(/sys/class/power_supply/BAT*)
|
||||||
|
[ -e "''${bats[0]}/charge_control_end_threshold" ] \
|
||||||
|
|| { notify-send "Battery limit" "No charge-threshold control on this machine."; exit 0; }
|
||||||
|
cur=$(nomarchy-theme-sync get settings.power.batteryChargeLimit 2>/dev/null)
|
||||||
|
sel=$( {
|
||||||
|
row "80% (recommended)" battery-080
|
||||||
|
row "90%" battery-090
|
||||||
|
row "60%" battery-060
|
||||||
|
row "Off — charge to 100%" battery-100
|
||||||
|
row "Custom…" battery
|
||||||
|
back
|
||||||
|
} | rofi -dmenu -show-icons -p "Battery limit (now: ''${cur:-default})") || exit 0
|
||||||
|
set_limit() {
|
||||||
|
nomarchy-theme-sync --quiet set settings.power.batteryChargeLimit "$1" --no-switch
|
||||||
|
notify-send "Battery limit" "$2 — applies on the next rebuild."
|
||||||
|
}
|
||||||
|
case "$sel" in
|
||||||
|
"$BACK") exec "$0" system ;;
|
||||||
|
"80%"*) set_limit 80 "Set to 80%" ;;
|
||||||
|
"90%"*) set_limit 90 "Set to 90%" ;;
|
||||||
|
"60%"*) set_limit 60 "Set to 60%" ;;
|
||||||
|
"Off"*) set_limit null "Off — charges to 100%" ;;
|
||||||
|
"Custom"*)
|
||||||
|
limit=$(rofi -dmenu -p "Charge limit % (50–100)" < /dev/null) || exit 0
|
||||||
|
[ -n "$limit" ] || exit 0
|
||||||
|
case "$limit" in
|
||||||
|
*[!0-9]*) notify-send "Battery limit" "Not a whole number: $limit"; exit 0 ;;
|
||||||
|
esac
|
||||||
|
if [ "$limit" -ge 50 ] && [ "$limit" -le 100 ]; then
|
||||||
|
set_limit "$limit" "Set to $limit%"
|
||||||
|
else
|
||||||
|
notify-send "Battery limit" "Out of range (50–100): $limit"
|
||||||
|
fi ;;
|
||||||
esac ;;
|
esac ;;
|
||||||
|
|
||||||
"")
|
"")
|
||||||
@@ -239,16 +765,16 @@ let
|
|||||||
# short and scannable. Apps/Theme/Power/Keybindings are frequent enough
|
# short and scannable. Apps/Theme/Power/Keybindings are frequent enough
|
||||||
# to keep at the top; everything else lives under Tools or System.
|
# to keep at the top; everything else lives under Tools or System.
|
||||||
choice=$( {
|
choice=$( {
|
||||||
row "Apps" applications-all
|
row "Apps${appsHint}" applications-all
|
||||||
row "Theme" preferences-desktop-theme
|
row "Look & Feel" preferences-desktop-theme
|
||||||
row "Tools" applications-utilities
|
row "Tools" applications-utilities
|
||||||
row "System" preferences-system
|
row "System" preferences-system
|
||||||
row "Power" system-shutdown
|
row "Power${menuHint "power"}" system-shutdown
|
||||||
row "Keybindings" preferences-desktop-keyboard
|
row "Keybindings${menuHint "keybinds"}" preferences-desktop-keyboard
|
||||||
} | rofi -dmenu -show-icons -p Menu) || exit 0
|
} | rofi -dmenu -show-icons -markup-rows -p Menu) || exit 0
|
||||||
case "$choice" in
|
case "$choice" in
|
||||||
*Apps*) exec rofi -show drun ;;
|
*Apps*) exec rofi -show drun -theme launcher ;;
|
||||||
*Theme*) exec "$0" theme ;;
|
*Look*) exec "$0" lookfeel ;;
|
||||||
*Tools*) exec "$0" tools ;;
|
*Tools*) exec "$0" tools ;;
|
||||||
*System*) exec "$0" system ;;
|
*System*) exec "$0" system ;;
|
||||||
*Power*) exec "$0" power ;;
|
*Power*) exec "$0" power ;;
|
||||||
@@ -256,20 +782,164 @@ let
|
|||||||
esac ;;
|
esac ;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
echo "usage: nomarchy-menu [tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|capture|keybinds|ask|dnd|snapshot]" >&2
|
echo "usage: nomarchy-menu [lookfeel|tools|system|power|power-profile|batterylimit|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|display-profile|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autocommit|vpn|snapshot]" >&2
|
||||||
exit 64 ;;
|
exit 64 ;;
|
||||||
esac
|
esac
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# VPN setup + management — a dedicated System submenu (and the custom/vpn
|
||||||
|
# Waybar indicator's click target). Goes past networkmanager_dmenu (which
|
||||||
|
# only connects existing VPNs) to importing configs and driving Tailscale:
|
||||||
|
# · NetworkManager VPN/WireGuard connections, ● active / ○ inactive, toggled
|
||||||
|
# up/down (networkmanager-group users need no sudo).
|
||||||
|
# · Import a WireGuard .conf or OpenVPN .ovpn (nmcli import; OpenVPN needs the
|
||||||
|
# networkmanager-openvpn plugin, shipped system-side).
|
||||||
|
# · Tailscale (self-gated on the CLI being present = nomarchy.services.tailscale,
|
||||||
|
# which makes the login user the operator): status is read-only; up/down/
|
||||||
|
# exit-node run inline without sudo thanks to the operator grant, falling
|
||||||
|
# back to a sudo terminal if it's absent. First login goes to a terminal.
|
||||||
|
# Secrets stay in NetworkManager's / Tailscale's own store — no new manager.
|
||||||
|
nomarchy-vpn = pkgs.writeShellScriptBin "nomarchy-vpn" ''
|
||||||
|
set -u
|
||||||
|
BACK="↩ Back"
|
||||||
|
|
||||||
|
conn_rows() {
|
||||||
|
nmcli -t -f NAME,TYPE,STATE connection show 2>/dev/null \
|
||||||
|
| awk -F: '$2=="vpn"||$2=="wireguard"{ printf "%s %s\n", ($3=="activated"?"●":"○"), $1 }'
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle_conn() {
|
||||||
|
mark=''${1%% *}; name=''${1#* }
|
||||||
|
if [ "$mark" = "●" ]; then
|
||||||
|
nmcli connection down "$name" >/dev/null 2>&1 \
|
||||||
|
&& notify-send "VPN" "Disconnected $name" || notify-send "VPN" "Could not disconnect $name"
|
||||||
|
else
|
||||||
|
nmcli connection up "$name" >/dev/null 2>&1 \
|
||||||
|
&& notify-send "VPN" "Connected $name" || notify-send "VPN" "Could not connect $name (auth or bad config?)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
import_config() {
|
||||||
|
file=$( { fd -e conf -e ovpn . "$HOME" --type f --hidden \
|
||||||
|
--exclude .git --exclude .cache 2>/dev/null \
|
||||||
|
| sed "s|^$HOME/||"; printf '%s\n' "$BACK"; } \
|
||||||
|
| rofi -dmenu -p "Import VPN config" ) || return
|
||||||
|
{ [ -z "$file" ] || [ "$file" = "$BACK" ]; } && return
|
||||||
|
case "$file" in *.ovpn) type=openvpn ;; *) type=wireguard ;; esac
|
||||||
|
if nmcli connection import type "$type" file "$HOME/$file" >/dev/null 2>&1; then
|
||||||
|
notify-send "VPN" "Imported $type config: $(basename "$file")"
|
||||||
|
else
|
||||||
|
notify-send "VPN" "Import failed ($type plugin missing, or bad file?)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ts_state() { tailscale status --json 2>/dev/null | jq -r '.BackendState // "Unknown"'; }
|
||||||
|
# Run a privileged tailscale subcommand. nomarchy.services.tailscale makes
|
||||||
|
# the login user the operator, so this runs inline (no sudo); if the operator
|
||||||
|
# grant is absent (downstream dropped it) it falls back to a sudo terminal.
|
||||||
|
ts_priv() {
|
||||||
|
if tailscale "$@" >/dev/null 2>&1; then notify-send "Tailscale" "tailscale $*"
|
||||||
|
else ${cfg.terminal} -e sudo tailscale "$@"; fi
|
||||||
|
}
|
||||||
|
ts_exit_node() {
|
||||||
|
node=$( { printf 'none\n'; tailscale exit-node list 2>/dev/null | awk 'NR>1 && $2 {print $2}'; \
|
||||||
|
printf '%s\n' "$BACK"; } | rofi -dmenu -p "Exit node" ) || return
|
||||||
|
{ [ -z "$node" ] || [ "$node" = "$BACK" ]; } && return
|
||||||
|
[ "$node" = none ] && node=""
|
||||||
|
ts_priv set --exit-node="$node"
|
||||||
|
}
|
||||||
|
tailscale_menu() {
|
||||||
|
while :; do
|
||||||
|
st=$(ts_state)
|
||||||
|
choice=$( {
|
||||||
|
if [ "$st" = Running ]; then printf 'Disconnect\n'; else printf 'Connect\n'; fi
|
||||||
|
printf 'Exit node…\n'
|
||||||
|
printf '%s\n' "$BACK"
|
||||||
|
} | rofi -dmenu -p "Tailscale ($st)" ) || return
|
||||||
|
case "$choice" in
|
||||||
|
"$BACK"|"") return ;;
|
||||||
|
# Authed-but-down → bring it up inline; otherwise it needs an
|
||||||
|
# interactive login, so show it in a terminal (URL visible).
|
||||||
|
Connect) if [ "$st" = Stopped ]; then ts_priv up; else ${cfg.terminal} -e sudo tailscale up; fi ;;
|
||||||
|
Disconnect) ts_priv down ;;
|
||||||
|
"Exit node…") ts_exit_node ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
have_ts=""
|
||||||
|
command -v tailscale >/dev/null 2>&1 && have_ts=1
|
||||||
|
choice=$( {
|
||||||
|
conn_rows
|
||||||
|
printf 'Import config…\n'
|
||||||
|
[ -n "$have_ts" ] && printf 'Tailscale ›\n'
|
||||||
|
printf '%s\n' "$BACK"
|
||||||
|
} | rofi -dmenu -p VPN ) || exit 0
|
||||||
|
case "$choice" in
|
||||||
|
"$BACK"|"") exec nomarchy-menu system ;;
|
||||||
|
"Import config…") import_config ;;
|
||||||
|
"Tailscale ›") tailscale_menu ;;
|
||||||
|
"● "*|"○ "*) toggle_conn "$choice" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
'';
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
config = lib.mkIf cfg.rofi.enable {
|
config = lib.mkIf cfg.rofi.enable {
|
||||||
home.packages = [
|
home.packages = [
|
||||||
nomarchy-menu
|
nomarchy-menu
|
||||||
pkgs.fd # files module (fuzzy search over $HOME)
|
nomarchy-vpn # VPN submenu (NM connect/import + Tailscale)
|
||||||
pkgs.xdg-utils # xdg-open for the files + web modules
|
pkgs.fd # files module (fuzzy search over $HOME)
|
||||||
pkgs.nodejs # npx, for the Ask Claude module (claude-code from npm)
|
pkgs.xdg-utils # xdg-open for the files + web modules
|
||||||
|
pkgs.nodejs # npx, for the Ask Claude module (claude-code from npm)
|
||||||
|
pkgs.networkmanager_dmenu # network module: rofi wifi/VPN picker
|
||||||
|
pkgs.rofi-pulse-select # audio module: sink/source switcher
|
||||||
|
# Capture › OCR region: English only — the unscoped wrapper drags
|
||||||
|
# in every language's traineddata, a real cost on a compile-from-
|
||||||
|
# source distro. More languages: override in your home.nix.
|
||||||
|
(pkgs.tesseract.override { enableLanguages = [ "eng" ]; })
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# networkmanager_dmenu drives rofi (not bare dmenu) and uses rofi's
|
||||||
|
# active/urgent row styling for the connected/available networks, so it
|
||||||
|
# inherits the generated theme like every other menu module. Editing a
|
||||||
|
# connection drops to nmtui in the configured terminal. force: the tool
|
||||||
|
# writes a default config on first run, so an unmanaged file may already
|
||||||
|
# sit at this path — we fully own it, so overwrite rather than abort.
|
||||||
|
# compact: with False the script inserts EMPTY rows as section
|
||||||
|
# separators (eth/wifi/vpn) — blank entries in a themed picker read as
|
||||||
|
# breakage (item 22). True drops them and uses the unpadded row format,
|
||||||
|
# which also sits better in rofi's proportional font than the
|
||||||
|
# space-aligned columns. (A hidden-SSID AP still renders one nameless
|
||||||
|
# row — sec+bars only; not filterable via config, dedup keeps one.)
|
||||||
|
xdg.configFile."networkmanager-dmenu/config.ini" = {
|
||||||
|
force = true;
|
||||||
|
text = ''
|
||||||
|
[dmenu]
|
||||||
|
dmenu_command = rofi
|
||||||
|
rofi_highlight = True
|
||||||
|
compact = True
|
||||||
|
wifi_chars = ▂▄▆█
|
||||||
|
|
||||||
|
[editor]
|
||||||
|
terminal = ${cfg.terminal}
|
||||||
|
gui_if_available = False
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# The launcher theme lives beside HM's active base theme, which it
|
||||||
|
# @imports so the default launcher is byte-for-byte that theme's list.
|
||||||
|
# HM names that base by its SOURCE: a generated (attrset) theme lands as
|
||||||
|
# custom.rasi, but a whole-swap theme (a path — themes/<slug>/rofi.rasi)
|
||||||
|
# keeps its basename, rofi.rasi. Import must match, or `-theme launcher`
|
||||||
|
# dies with "custom.rasi not found" (rofi resolves the missing import
|
||||||
|
# against $HOME) on every whole-swap theme.
|
||||||
|
xdg.dataFile."rofi/themes/launcher.rasi".text =
|
||||||
|
if hasLauncherOverride
|
||||||
|
then builtins.readFile launcherOverride
|
||||||
|
else ''@import "${if hasRasiOverride then "rofi.rasi" else "custom.rasi"}"'';
|
||||||
|
|
||||||
programs.rofi = {
|
programs.rofi = {
|
||||||
enable = true;
|
enable = true;
|
||||||
terminal = cfg.terminal;
|
terminal = cfg.terminal;
|
||||||
@@ -292,6 +962,21 @@ in
|
|||||||
# reserving `lines` (8) — so a 6-entry menu doesn't leave empty space,
|
# reserving `lines` (8) — so a 6-entry menu doesn't leave empty space,
|
||||||
# while the app launcher still fills + scrolls past `lines`.
|
# while the app launcher still fills + scrolls past `lines`.
|
||||||
fixed-num-lines = false;
|
fixed-num-lines = false;
|
||||||
|
# Search: case-insensitive fuzzy across every menu + the launcher, so
|
||||||
|
# "system" finds "System" and "fzr" finds "Firefox". `normal` sorting
|
||||||
|
# ranks by match distance so the closest match lands on top (the
|
||||||
|
# per-keystroke modules — calc/emoji — opt out per-invocation with
|
||||||
|
# -no-sort).
|
||||||
|
#
|
||||||
|
# NOT `fzf`: rofi 2.0.0's fzf sorter mis-ranks with fuzzy matching —
|
||||||
|
# typing "steam" left ghostty selected on top instead of Steam
|
||||||
|
# (confirmed on hardware: `-sorting-method normal` and `-matching
|
||||||
|
# normal` both fixed it, `-no-sort` did not). Keep fuzzy matching for
|
||||||
|
# forgiveness; use the normal sorter to rank it (item 31).
|
||||||
|
matching = "fuzzy";
|
||||||
|
case-sensitive = false;
|
||||||
|
sort = true;
|
||||||
|
sorting-method = "normal";
|
||||||
};
|
};
|
||||||
|
|
||||||
# Whole-swap themes bring their own rofi.rasi; otherwise the theme
|
# Whole-swap themes bring their own rofi.rasi; otherwise the theme
|
||||||
@@ -302,8 +987,13 @@ in
|
|||||||
"*" = {
|
"*" = {
|
||||||
bg = mkLiteral c.base;
|
bg = mkLiteral c.base;
|
||||||
fg = mkLiteral c.text;
|
fg = mkLiteral c.text;
|
||||||
dim = mkLiteral c.subtext;
|
# @dim/@surface are fg-derived #RRGGBBAA tints, NOT the
|
||||||
surface = mkLiteral c.surface;
|
# palette's subtext/surface roles — "on-surface" palettes
|
||||||
|
# (flexoki-light: subtext==base, surface==text) made the
|
||||||
|
# inputbar, textbox and alternate rows invisible (item 27;
|
||||||
|
# guarded by tools/check-theme-contrast.py).
|
||||||
|
dim = mkLiteral (c.text + "80");
|
||||||
|
surface = mkLiteral (c.text + "1a");
|
||||||
accent = mkLiteral c.accent;
|
accent = mkLiteral c.accent;
|
||||||
|
|
||||||
background-color = mkLiteral "transparent";
|
background-color = mkLiteral "transparent";
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ in
|
|||||||
targets = {
|
targets = {
|
||||||
gtk.enable = true;
|
gtk.enable = true;
|
||||||
qt.enable = true;
|
qt.enable = true;
|
||||||
|
# No-op unless programs.zathura is on (viewers.nix enables it).
|
||||||
|
zathura.enable = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
cursor = {
|
cursor = {
|
||||||
|
|||||||
@@ -29,11 +29,14 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
style = ''
|
style = ''
|
||||||
/* Palette baked from theme-state.json */
|
/* Palette baked from theme-state.json. Only roles guaranteed to
|
||||||
|
contrast @base in EVERY palette are used: subtext/surface mean
|
||||||
|
"on-surface" in some light themes (summer-day: subtext==base,
|
||||||
|
surface==text — body text was invisible on hardware, item 25).
|
||||||
|
Chips/hovers are alpha(@text) tints — contrast by construction.
|
||||||
|
tools/check-theme-contrast.py guards the hex-on-hex pairings. */
|
||||||
@define-color base ${c.base};
|
@define-color base ${c.base};
|
||||||
@define-color surface ${c.surface};
|
|
||||||
@define-color text ${c.text};
|
@define-color text ${c.text};
|
||||||
@define-color subtext ${c.subtext};
|
|
||||||
@define-color accent ${c.accent};
|
@define-color accent ${c.accent};
|
||||||
@define-color bad ${c.bad};
|
@define-color bad ${c.bad};
|
||||||
|
|
||||||
@@ -50,7 +53,7 @@ in
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-content .body {
|
.notification-content .body {
|
||||||
color: @subtext;
|
color: @text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification.critical {
|
.notification.critical {
|
||||||
@@ -66,7 +69,7 @@ in
|
|||||||
|
|
||||||
.control-center .notification-row:focus,
|
.control-center .notification-row:focus,
|
||||||
.control-center .notification-row:hover {
|
.control-center .notification-row:hover {
|
||||||
background: alpha(@surface, 0.6);
|
background: alpha(@text, 0.1);
|
||||||
border-radius: ${r}px;
|
border-radius: ${r}px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +79,7 @@ in
|
|||||||
}
|
}
|
||||||
|
|
||||||
.widget-title > button {
|
.widget-title > button {
|
||||||
background: @surface;
|
background: alpha(@text, 0.1);
|
||||||
color: @text;
|
color: @text;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: ${r}px;
|
border-radius: ${r}px;
|
||||||
|
|||||||
@@ -54,10 +54,86 @@ let
|
|||||||
# tone for kanagawa/summer-*). Each preset declares its own so a theme
|
# tone for kanagawa/summer-*). Each preset declares its own so a theme
|
||||||
# switch always replaces it (deep_merge would otherwise leave it stuck).
|
# switch always replaces it (deep_merge would otherwise leave it stuck).
|
||||||
border = { active = "accent"; inactive = "overlay"; };
|
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.
|
||||||
|
# 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;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
parsed = lib.recursiveUpdate defaults themeState;
|
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 tokyo-night`.'';
|
||||||
|
|
||||||
# A border value is a palette key (look it up in colors) unless it's
|
# A border value is a palette key (look it up in colors) unless it's
|
||||||
# already a literal hex; unknown keys fall through to the raw string.
|
# already a literal hex; unknown keys fall through to the raw string.
|
||||||
resolveColor = v: if lib.hasPrefix "#" v then v else parsed.colors.${v} or v;
|
resolveColor = v: if lib.hasPrefix "#" v then v else parsed.colors.${v} or v;
|
||||||
@@ -76,7 +152,12 @@ let
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
config = {
|
config = {
|
||||||
nomarchy.theme = parsed // { inherit iconTheme border; };
|
nomarchy.theme = checked // { inherit iconTheme border; };
|
||||||
|
|
||||||
|
# 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 = {
|
nomarchy.lib = {
|
||||||
# "#7aa2f7" -> "rgb(7aa2f7)" (Hyprland color syntax)
|
# "#7aa2f7" -> "rgb(7aa2f7)" (Hyprland color syntax)
|
||||||
|
|||||||
44
modules/home/timezone.nix
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Auto-timezone, home side: keep the Waybar clock in step with the system
|
||||||
|
# timezone. Waybar's clock module captures the zone once at construction, so a
|
||||||
|
# runtime timezone change (automatic-timezoned, nomarchy.system.autoTimezone)
|
||||||
|
# would NOT show until a relogin. A tiny watcher subscribes to timedate1's
|
||||||
|
# change signal and reloads Waybar (SIGUSR2 = the same reload theme-sync uses),
|
||||||
|
# so the clock follows your location live. Also catches a manual
|
||||||
|
# `timedatectl set-timezone`.
|
||||||
|
#
|
||||||
|
# Gated on the same in-flake flag the system side reads (settings.autoTimezone,
|
||||||
|
# exposed via nomarchy.settings) — so it only runs when the feature is on. The
|
||||||
|
# menu toggle (nomarchy-autotimezone) rebuilds both sides off that one flag.
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.nomarchy;
|
||||||
|
enabled = cfg.waybar.enable && (cfg.settings.autoTimezone or false);
|
||||||
|
|
||||||
|
tzWatch = pkgs.writeShellScript "nomarchy-tz-watch" ''
|
||||||
|
last=$(${pkgs.systemd}/bin/timedatectl show -p Timezone --value 2>/dev/null || true)
|
||||||
|
${pkgs.dbus}/bin/dbus-monitor --system \
|
||||||
|
"type='signal',interface='org.freedesktop.DBus.Properties',path='/org/freedesktop/timedate1',member='PropertiesChanged'" \
|
||||||
|
2>/dev/null |
|
||||||
|
while read -r _; do
|
||||||
|
cur=$(${pkgs.systemd}/bin/timedatectl show -p Timezone --value 2>/dev/null || true)
|
||||||
|
[ "$cur" = "$last" ] && continue
|
||||||
|
last=$cur
|
||||||
|
${pkgs.procps}/bin/pkill -SIGUSR2 -x waybar 2>/dev/null || true
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
{
|
||||||
|
systemd.user.services.nomarchy-tz-watch = lib.mkIf enabled {
|
||||||
|
Unit = {
|
||||||
|
Description = "Reload Waybar on timezone change (auto-timezone)";
|
||||||
|
PartOf = [ "graphical-session.target" ];
|
||||||
|
After = [ "graphical-session.target" ];
|
||||||
|
};
|
||||||
|
Service = {
|
||||||
|
ExecStart = "${tzWatch}";
|
||||||
|
Restart = "on-failure";
|
||||||
|
};
|
||||||
|
Install.WantedBy = [ "graphical-session.target" ];
|
||||||
|
};
|
||||||
|
}
|
||||||
141
modules/home/updates.nix
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Update awareness (opt-in, nomarchy.updates.enable) — a passive background
|
||||||
|
# check that surfaces a Waybar indicator + a notification when updates are
|
||||||
|
# available, without ever changing anything (you still run sys-update /
|
||||||
|
# home-update / flatpak update). It counts:
|
||||||
|
# • flake inputs whose locked rev is behind upstream (nixpkgs, the Nomarchy
|
||||||
|
# input, home-manager …) — via `git ls-remote` on each branch-tracking
|
||||||
|
# github/git input in flake.lock; offline → skipped, never a false alarm.
|
||||||
|
# • Flatpak updates, when the `flatpak` CLI is present (services.flatpak on).
|
||||||
|
#
|
||||||
|
# nomarchy-updates is always on PATH and self-gates (status prints nothing
|
||||||
|
# until the timer has found something), so the Waybar module — generated and
|
||||||
|
# whole-swap — can exec it by name even when the feature is off.
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.nomarchy.updates;
|
||||||
|
|
||||||
|
nomarchy-updates = pkgs.writeShellScriptBin "nomarchy-updates" ''
|
||||||
|
set -u
|
||||||
|
# System + user profiles, so flatpak / sys-update resolve from a timer-run
|
||||||
|
# service too (build-time tools below use absolute store paths regardless).
|
||||||
|
export PATH="$PATH:/run/current-system/sw/bin:/etc/profiles/per-user/$USER/bin"
|
||||||
|
GIT=${pkgs.git}/bin/git
|
||||||
|
JQ=${pkgs.jq}/bin/jq
|
||||||
|
cache="''${XDG_CACHE_HOME:-$HOME/.cache}/nomarchy"
|
||||||
|
state="$cache/updates.json"
|
||||||
|
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||||||
|
mkdir -p "$cache"
|
||||||
|
|
||||||
|
count_nix() {
|
||||||
|
local lock="$flake/flake.lock" n=0 name kind owner repo url ref locked giturl up
|
||||||
|
[ -f "$lock" ] || { echo 0; return; }
|
||||||
|
while IFS=$'\t' read -r name kind owner repo url ref locked; do
|
||||||
|
[ -n "$locked" ] || continue
|
||||||
|
case "$kind" in
|
||||||
|
github) giturl="https://github.com/$owner/$repo" ;;
|
||||||
|
git) giturl="$url" ;;
|
||||||
|
*) continue ;;
|
||||||
|
esac
|
||||||
|
up=$("$GIT" ls-remote "$giturl" "$ref" 2>/dev/null | ${pkgs.gawk}/bin/awk 'NR==1{print $1}')
|
||||||
|
[ -n "$up" ] || continue # offline / unknown → skip (no false alarm)
|
||||||
|
[ "$up" != "$locked" ] && n=$((n + 1))
|
||||||
|
done < <(
|
||||||
|
# Only the flake's DIRECT inputs (root.inputs) — not the transitive
|
||||||
|
# closure — so a deep dependency bump doesn't nag as an "update".
|
||||||
|
"$JQ" -r '
|
||||||
|
.nodes as $nodes
|
||||||
|
| ($nodes.root.inputs | [ .[] | if type == "array" then .[0] else . end ]) as $direct
|
||||||
|
| $nodes | to_entries[]
|
||||||
|
| .key as $k | .value as $v
|
||||||
|
| select($direct | index($k))
|
||||||
|
| select(($v.original.type? == "github") or ($v.original.type? == "git"))
|
||||||
|
| select(($v.original.rev? // "") == "") # branch-tracking only
|
||||||
|
| [ $k, $v.original.type,
|
||||||
|
($v.original.owner? // ""), ($v.original.repo? // ""),
|
||||||
|
($v.original.url? // ""), ($v.original.ref? // "HEAD"),
|
||||||
|
($v.locked.rev? // "") ] | @tsv
|
||||||
|
' "$lock"
|
||||||
|
)
|
||||||
|
echo "$n"
|
||||||
|
}
|
||||||
|
|
||||||
|
count_flatpak() {
|
||||||
|
${lib.optionalString cfg.flatpak ''
|
||||||
|
if command -v flatpak >/dev/null 2>&1; then
|
||||||
|
flatpak remote-ls --updates --columns=application 2>/dev/null | ${pkgs.gnugrep}/bin/grep -c . || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
''}
|
||||||
|
echo 0
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh_bar() { ${pkgs.procps}/bin/pkill -RTMIN+9 -x waybar 2>/dev/null || true; }
|
||||||
|
|
||||||
|
case "''${1:-status}" in
|
||||||
|
check)
|
||||||
|
nix=$(count_nix); fp=$(count_flatpak); total=$((nix + fp))
|
||||||
|
prev=$("$JQ" -r '.total // 0' "$state" 2>/dev/null || echo 0)
|
||||||
|
printf '{"nix":%d,"flatpak":%d,"total":%d,"ts":%d}\n' \
|
||||||
|
"$nix" "$fp" "$total" "$(${pkgs.coreutils}/bin/date +%s)" > "$state"
|
||||||
|
# Notify only when NEW updates appear, so a daily timer doesn't nag.
|
||||||
|
if [ "$total" -gt 0 ] && [ "$total" -gt "$prev" ]; then
|
||||||
|
msg="$nix flake input(s)"
|
||||||
|
[ "$fp" -gt 0 ] && msg="$msg · $fp Flatpak(s)"
|
||||||
|
${pkgs.libnotify}/bin/notify-send -a Nomarchy "Updates available" \
|
||||||
|
"$msg — click the bar icon, or run sys-update."
|
||||||
|
fi
|
||||||
|
refresh_bar ;;
|
||||||
|
status)
|
||||||
|
total=$("$JQ" -r '.total // 0' "$state" 2>/dev/null || echo 0)
|
||||||
|
[ "$total" -gt 0 ] 2>/dev/null || exit 0 # up to date / unchecked → hide
|
||||||
|
nix=$("$JQ" -r '.nix // 0' "$state"); fp=$("$JQ" -r '.flatpak // 0' "$state")
|
||||||
|
tip="Updates available"
|
||||||
|
[ "$nix" -gt 0 ] && tip="$tip\n• $nix flake input(s) — sys-update"
|
||||||
|
[ "$fp" -gt 0 ] && tip="$tip\n• $fp Flatpak(s) — flatpak update"
|
||||||
|
printf '{"text":" %d","tooltip":"%s","class":"available"}\n' "$total" "$tip" ;;
|
||||||
|
upgrade)
|
||||||
|
echo "Checking…"; "$0" check
|
||||||
|
nix=$("$JQ" -r '.nix // 0' "$state" 2>/dev/null || echo 0)
|
||||||
|
fp=$("$JQ" -r '.flatpak // 0' "$state" 2>/dev/null || echo 0)
|
||||||
|
echo "Pending: $nix flake input(s), $fp Flatpak(s)."
|
||||||
|
if [ "$nix" -gt 0 ] && command -v sys-update >/dev/null 2>&1; then
|
||||||
|
read -rp "Run sys-update (flake update + system rebuild)? [y/N] " a
|
||||||
|
[ "$a" = y ] && sys-update
|
||||||
|
fi
|
||||||
|
if [ "$fp" -gt 0 ] && command -v flatpak >/dev/null 2>&1; then
|
||||||
|
read -rp "Run flatpak update? [y/N] " a
|
||||||
|
[ "$a" = y ] && flatpak update
|
||||||
|
fi
|
||||||
|
"$0" check
|
||||||
|
echo "Done — press enter."; read -r _ || true ;;
|
||||||
|
*) echo "usage: nomarchy-updates [check|status|upgrade]" >&2; exit 64 ;;
|
||||||
|
esac
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
{
|
||||||
|
config = lib.mkMerge [
|
||||||
|
# Always on PATH so the Waybar module (incl. the static whole-swap themes)
|
||||||
|
# can exec it; it self-gates at runtime.
|
||||||
|
{ home.packages = [ nomarchy-updates ]; }
|
||||||
|
|
||||||
|
(lib.mkIf cfg.enable {
|
||||||
|
systemd.user.services.nomarchy-updates = {
|
||||||
|
Unit.Description = "Check for Nomarchy / nixpkgs / Flatpak updates";
|
||||||
|
Service = {
|
||||||
|
Type = "oneshot";
|
||||||
|
ExecStart = "${nomarchy-updates}/bin/nomarchy-updates check";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
systemd.user.timers.nomarchy-updates = {
|
||||||
|
Unit.Description = "Periodic update-awareness check";
|
||||||
|
Timer = {
|
||||||
|
OnStartupSec = "2min";
|
||||||
|
OnCalendar = cfg.interval;
|
||||||
|
Persistent = true;
|
||||||
|
};
|
||||||
|
Install.WantedBy = [ "timers.target" ];
|
||||||
|
};
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
15
modules/home/viewers.nix
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Document & image viewers — the "open a PDF / open a photo" half of a
|
||||||
|
# complete workstation. zathura goes through its HM module (not a bare
|
||||||
|
# package) because Stylix themes it via programs.zathura.options — the
|
||||||
|
# reason it's a component toggle here rather than a template package
|
||||||
|
# line. imv rides along: wayland-native, and an image viewer is a
|
||||||
|
# borderless dark surface — nothing to theme. The heavier editors
|
||||||
|
# (GIMP, Inkscape) stay template packages; mime.nix points the default
|
||||||
|
# associations at these viewers.
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
lib.mkIf config.nomarchy.viewers.enable {
|
||||||
|
programs.zathura.enable = lib.mkDefault true;
|
||||||
|
|
||||||
|
home.packages = [ pkgs.imv ];
|
||||||
|
}
|
||||||
@@ -28,6 +28,37 @@ let
|
|||||||
# Named writeShellScriptBins (put on PATH via home.packages below) rather
|
# Named writeShellScriptBins (put on PATH via home.packages below) rather
|
||||||
# than bare writeShellScript store paths, so the whole-swap themes' static
|
# than bare writeShellScript store paths, so the whole-swap themes' static
|
||||||
# waybar.jsonc can exec them by name too — same as the swaync bell.
|
# waybar.jsonc can exec them by name too — same as the swaync bell.
|
||||||
|
# Waybar supervisor — exec-once has no restart, so a crashed bar used to
|
||||||
|
# leave the session bar-less until relogin (seen on hardware: a theme
|
||||||
|
# switch crashed waybar mid-reload). Respawns on ANY exit — a plain
|
||||||
|
# `pkill -x waybar` is now a clean restart, which nomarchy-theme-sync
|
||||||
|
# uses instead of the crash-prone in-place SIGUSR2 reload when it sees
|
||||||
|
# this supervisor running. Crash-loop guard: 5 exits within 10s of
|
||||||
|
# their start → give up with a critical notification instead of
|
||||||
|
# spinning. Stop the bar for real: pkill -f nomarchy-waybar (TERM is
|
||||||
|
# trapped to take the child down too).
|
||||||
|
waybarSupervisor = pkgs.writeShellScriptBin "nomarchy-waybar" ''
|
||||||
|
child=
|
||||||
|
trap '[ -n "$child" ] && kill "$child" 2>/dev/null; exit 0' TERM INT
|
||||||
|
fails=0
|
||||||
|
while :; do
|
||||||
|
start=$(date +%s)
|
||||||
|
waybar & child=$!
|
||||||
|
wait "$child"; code=$?
|
||||||
|
if [ $(( $(date +%s) - start )) -lt 10 ]; then
|
||||||
|
fails=$((fails + 1))
|
||||||
|
if [ "$fails" -ge 5 ]; then
|
||||||
|
notify-send -u critical "Waybar" \
|
||||||
|
"Crashing on start (exit $code) — check ~/.config/waybar. Giving up." 2>/dev/null
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fails=0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
|
||||||
powerProfileStatus = pkgs.writeShellScriptBin "nomarchy-powerprofile-status" ''
|
powerProfileStatus = pkgs.writeShellScriptBin "nomarchy-powerprofile-status" ''
|
||||||
case "$(ls /sys/class/power_supply/ 2>/dev/null)" in *BAT*) ;; *) exit 0 ;; esac
|
case "$(ls /sys/class/power_supply/ 2>/dev/null)" in *BAT*) ;; *) exit 0 ;; esac
|
||||||
command -v powerprofilesctl >/dev/null 2>&1 || exit 0
|
command -v powerprofilesctl >/dev/null 2>&1 || exit 0
|
||||||
@@ -51,6 +82,31 @@ let
|
|||||||
[ -n "$next" ] && powerprofilesctl set "$next"
|
[ -n "$next" ] && powerprofilesctl set "$next"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# VPN indicator — shows a shield when a NetworkManager VPN/WireGuard
|
||||||
|
# connection is active OR Tailscale is up; prints nothing otherwise so the
|
||||||
|
# module self-hides (like nightlight/updates). Click opens the VPN submenu.
|
||||||
|
vpnStatus = pkgs.writeShellScriptBin "nomarchy-vpn-status" ''
|
||||||
|
active=$(nmcli -t -f TYPE connection show --active 2>/dev/null | grep -Exm1 'vpn|wireguard')
|
||||||
|
ts=""
|
||||||
|
if command -v tailscale >/dev/null 2>&1; then
|
||||||
|
[ "$(tailscale status --json 2>/dev/null | jq -r '.BackendState // empty')" = Running ] && ts=1
|
||||||
|
fi
|
||||||
|
[ -n "$active" ] || [ -n "$ts" ] || exit 0
|
||||||
|
printf '{"text":"","tooltip":"VPN active (click to manage)","class":"on"}\n'
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Health warning — self-gates: prints nothing while nomarchy-doctor
|
||||||
|
# exits 0, so the module only appears when the sheet has a ✖ (the
|
||||||
|
# same self-hide discipline as vpn/nightlight/updates). The tooltip
|
||||||
|
# carries the first failing lines; click opens the full sheet.
|
||||||
|
doctorStatus = pkgs.writeShellScriptBin "nomarchy-doctor-status" ''
|
||||||
|
command -v nomarchy-doctor >/dev/null 2>&1 || exit 0
|
||||||
|
out=$(nomarchy-doctor 2>/dev/null) && exit 0
|
||||||
|
tip=$(printf '%s\n' "$out" | grep '✖' | head -5 \
|
||||||
|
| ${pkgs.jq}/bin/jq -Rs 'rtrimstr("\n") + "\n(click for the full sheet)"')
|
||||||
|
printf '{"text":"","tooltip":%s,"class":"bad"}\n' "$tip"
|
||||||
|
'';
|
||||||
|
|
||||||
# Per-theme override probe.
|
# Per-theme override probe.
|
||||||
assetDir = config.nomarchy.themesDir + "/${t.slug}";
|
assetDir = config.nomarchy.themesDir + "/${t.slug}";
|
||||||
styleOverride = assetDir + "/waybar.css";
|
styleOverride = assetDir + "/waybar.css";
|
||||||
@@ -63,7 +119,12 @@ let
|
|||||||
(lib.mapAttrsToList (name: value: "@define-color ${name} ${value};") t.colors);
|
(lib.mapAttrsToList (name: value: "@define-color ${name} ${value};") t.colors);
|
||||||
|
|
||||||
generatedSettings = {
|
generatedSettings = {
|
||||||
layer = "top";
|
# `bottom`, not `top`: on Hyprland the `top` layer renders above every
|
||||||
|
# window — so a real fullscreen surface (a browser video gone
|
||||||
|
# fullscreen) sits *under* the bar. On `bottom` the fullscreen window
|
||||||
|
# covers the bar, while the exclusive zone still reserves its space in
|
||||||
|
# normal tiling. Keep in sync with the whole-swap jsoncs (parity rule).
|
||||||
|
layer = "bottom";
|
||||||
position = "top";
|
position = "top";
|
||||||
height = 34;
|
height = 34;
|
||||||
margin-top = t.ui.gapsOut;
|
margin-top = t.ui.gapsOut;
|
||||||
@@ -77,9 +138,9 @@ let
|
|||||||
|
|
||||||
modules-left = [ "hyprland/workspaces" "hyprland/window" ];
|
modules-left = [ "hyprland/workspaces" "hyprland/window" ];
|
||||||
modules-center = [ "clock" ];
|
modules-center = [ "clock" ];
|
||||||
modules-right = [ "tray" "pulseaudio" "network" "cpu" "memory" "custom/powerprofile" ]
|
modules-right = [ "custom/recording" "idle_inhibitor" "tray" "custom/vpn" "pulseaudio" "custom/powerprofile" "custom/nightlight" ]
|
||||||
++ lib.optional showLanguage "hyprland/language"
|
++ lib.optional showLanguage "hyprland/language"
|
||||||
++ [ "battery" "custom/notification" ];
|
++ [ "battery" "custom/doctor" "custom/updates" "custom/notification" ];
|
||||||
|
|
||||||
"hyprland/workspaces" = {
|
"hyprland/workspaces" = {
|
||||||
format = "{icon}";
|
format = "{icon}";
|
||||||
@@ -94,40 +155,49 @@ let
|
|||||||
clock = {
|
clock = {
|
||||||
format = "{:%H:%M}";
|
format = "{:%H:%M}";
|
||||||
format-alt = "{:%A %d %B %Y}";
|
format-alt = "{:%A %d %B %Y}";
|
||||||
tooltip-format = "<tt><small>{calendar}</small></tt>";
|
# Zone line first (LATER nicety): under auto-timezone the tz-watch
|
||||||
|
# SIGUSR2 reload keeps it showing the currently *detected* zone.
|
||||||
|
tooltip-format = "{:%Z (UTC%z)}\n<tt><small>{calendar}</small></tt>";
|
||||||
};
|
};
|
||||||
|
|
||||||
# Active keyboard layout (per focused device) — only placed in
|
# Active keyboard layout (per focused device) — only placed in
|
||||||
# modules-right when showLanguage (see above).
|
# modules-right when showLanguage (see above).
|
||||||
"hyprland/language" = {
|
"hyprland/language" = {
|
||||||
format = " {short}";
|
format = "<span size='${toString (t.fonts.size + 2)}pt'></span> {short}";
|
||||||
tooltip = false;
|
tooltip = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
pulseaudio = {
|
pulseaudio = {
|
||||||
format = "{icon} {volume}%";
|
format = "<span size='${toString (t.fonts.size + 2)}pt'>{icon}</span> {volume}%";
|
||||||
format-muted = "";
|
format-muted = "";
|
||||||
format-icons.default = [ "" "" "" ];
|
format-icons.default = [ "" "" "" ];
|
||||||
on-click = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle";
|
on-click = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle";
|
||||||
|
# Right-click → the full mixer (per-app volumes) in a floating window
|
||||||
|
# (item 35). pwvucontrol ships in the template suite; the Hyprland
|
||||||
|
# windowrule floats it. Keep in sync with the whole-swap jsoncs.
|
||||||
|
on-click-right = "pwvucontrol";
|
||||||
};
|
};
|
||||||
|
|
||||||
network = {
|
# Idle inhibitor (caffeine): click → to hold the screen awake —
|
||||||
format-wifi = " {essid}";
|
# blocks hypridle's lock / display-off / suspend during video or a
|
||||||
format-ethernet = "";
|
# presentation. Waybar holds a Wayland idle-inhibit while activated;
|
||||||
format-disconnected = "";
|
# the state resets with the bar (deliberate — caffeine shouldn't
|
||||||
tooltip-format = "{ipaddr} via {gwaddr}";
|
# survive a relogin). Summer-night's whole-swap bar had this first
|
||||||
# nm-applet sits in the tray for the GUI path; this is the
|
# (reverse parity gap, 2026-07-04).
|
||||||
# keyboard-friendly one.
|
idle_inhibitor = {
|
||||||
on-click = "${config.nomarchy.terminal} -e nmtui";
|
format = "{icon}";
|
||||||
|
format-icons = { activated = ""; deactivated = ""; };
|
||||||
|
tooltip-format-activated = "Screen held awake — click to release";
|
||||||
|
tooltip-format-deactivated = "Keep the screen awake (caffeine)";
|
||||||
};
|
};
|
||||||
|
|
||||||
cpu.format = " {usage}%";
|
# No network module: nm-applet lives in the tray (the GUI path), so the
|
||||||
memory.format = " {percentage}%";
|
# bar's wifi/ethernet indicator would just duplicate it.
|
||||||
|
|
||||||
battery = {
|
battery = {
|
||||||
states = { warning = 25; critical = 10; };
|
states = { warning = 25; critical = 10; };
|
||||||
format = "{icon} {capacity}%";
|
format = "<span size='${toString (t.fonts.size + 2)}pt'>{icon}</span> {capacity}%";
|
||||||
format-charging = " {capacity}%";
|
format-charging = "<span size='${toString (t.fonts.size + 2)}pt'></span> {capacity}%";
|
||||||
format-icons = [ "" "" "" "" "" ];
|
format-icons = [ "" "" "" "" "" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,6 +208,61 @@ let
|
|||||||
on-click = "nomarchy-powerprofile-cycle";
|
on-click = "nomarchy-powerprofile-cycle";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# VPN shield — self-hides unless a NM VPN/WireGuard tunnel or Tailscale is
|
||||||
|
# up. Click opens the VPN submenu (nomarchy-vpn). 5s poll like powerprofile.
|
||||||
|
"custom/vpn" = {
|
||||||
|
exec = "nomarchy-vpn-status";
|
||||||
|
return-type = "json";
|
||||||
|
interval = 5;
|
||||||
|
on-click = "nomarchy-vpn";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Health check is real work (systemctl/disk/git sweeps), so a long
|
||||||
|
# interval — this is a tripwire, not a monitor.
|
||||||
|
"custom/doctor" = {
|
||||||
|
exec = "nomarchy-doctor-status";
|
||||||
|
return-type = "json";
|
||||||
|
interval = 300;
|
||||||
|
on-click = "nomarchy-menu doctor";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Screen recording indicator. Self-gates: visible only while
|
||||||
|
# nomarchy-record runs (status prints nothing otherwise), and the
|
||||||
|
# click IS the stop surface — the menu only starts recordings.
|
||||||
|
# signal 8: the recorder pokes the bar on start/stop so the ⏺
|
||||||
|
# appears/vanishes instantly (the interval is just a safety net).
|
||||||
|
"custom/recording" = {
|
||||||
|
exec = "nomarchy-record status";
|
||||||
|
return-type = "json";
|
||||||
|
interval = 10;
|
||||||
|
signal = 8;
|
||||||
|
on-click = "nomarchy-record stop";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Night-light (hyprsunset) indicator. Self-gates: the moon shows only while
|
||||||
|
# the schedule runs; otherwise the status helper prints nothing => hidden
|
||||||
|
# (enable / re-enable from the System menu). Click toggles instantly — writes
|
||||||
|
# the in-flake on/off (settings.nightlight.on, no rebuild) and flips the unit
|
||||||
|
# with systemctl, so the choice lands in the flake and survives reboot.
|
||||||
|
"custom/nightlight" = {
|
||||||
|
exec = "nomarchy-nightlight status";
|
||||||
|
return-type = "json";
|
||||||
|
interval = 3;
|
||||||
|
on-click = "nomarchy-nightlight toggle";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Update awareness. Self-gates: hidden unless nomarchy.updates is enabled
|
||||||
|
# AND the periodic check found something (the helper prints nothing then).
|
||||||
|
# signal 9 lets the checker refresh it instantly; click opens the upgrade
|
||||||
|
# flow in a terminal.
|
||||||
|
"custom/updates" = {
|
||||||
|
exec = "nomarchy-updates status";
|
||||||
|
return-type = "json";
|
||||||
|
interval = 1800;
|
||||||
|
signal = 9;
|
||||||
|
on-click = "${config.nomarchy.terminal} -e nomarchy-updates upgrade";
|
||||||
|
};
|
||||||
|
|
||||||
# swaync notification bell + Do-Not-Disturb state. `-swb` streams JSON
|
# swaync notification bell + Do-Not-Disturb state. `-swb` streams JSON
|
||||||
# (text/tooltip/class) on every change, so it tracks count and DND with
|
# (text/tooltip/class) on every change, so it tracks count and DND with
|
||||||
# no polling. Left-click toggles the panel; right-click toggles DND.
|
# no polling. Left-click toggles the panel; right-click toggles DND.
|
||||||
@@ -183,6 +308,11 @@ let
|
|||||||
border-radius: ${toString t.ui.rounding}px;
|
border-radius: ${toString t.ui.rounding}px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dim states use the palette's @muted role: since item 28b it is
|
||||||
|
floor-guaranteed legible on @base in every theme (muted/base >=
|
||||||
|
2.0, gated by tools/check-theme-contrast.py) — the palettes that
|
||||||
|
once made it vanish (gruvbox muted≈base, item 27) were retuned.
|
||||||
|
Secondary-but-not-dim stays alpha(@text, 0.85). */
|
||||||
#workspaces button {
|
#workspaces button {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
color: @muted;
|
color: @muted;
|
||||||
@@ -200,7 +330,7 @@ let
|
|||||||
}
|
}
|
||||||
|
|
||||||
#window {
|
#window {
|
||||||
color: @subtext;
|
color: alpha(@text, 0.85);
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +339,47 @@ let
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
#tray, #pulseaudio, #network, #cpu, #memory, #custom-powerprofile, #language, #battery, #custom-notification {
|
#tray, #pulseaudio, #custom-powerprofile, #custom-nightlight, #custom-updates, #custom-vpn, #custom-recording, #idle_inhibitor, #language, #battery, #custom-doctor, #custom-notification {
|
||||||
color: @subtext;
|
color: alpha(@text, 0.85);
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Group rhythm (item 28c): a wider breath before each functional
|
||||||
|
group of the right cluster — media/stats · toggles · status —
|
||||||
|
on top of the uniform module spacing. A group head can self-hide
|
||||||
|
(e.g. no battery on desktops); grouping then degrades to the
|
||||||
|
uniform spacing, never breaks. */
|
||||||
|
#pulseaudio, #custom-powerprofile, #battery { margin-left: 14px; }
|
||||||
|
|
||||||
|
/* Caffeine engaged → warm tone (the screen is being held awake). */
|
||||||
|
#idle_inhibitor.activated { color: @warn; }
|
||||||
|
|
||||||
|
/* Recording in progress → the alert red; the ⏺ is also the stop button. */
|
||||||
|
#custom-recording.recording { color: @bad; }
|
||||||
|
|
||||||
|
/* VPN active → accent green, reading as "connected / protected". */
|
||||||
|
#custom-vpn.on { color: @good; }
|
||||||
|
|
||||||
|
/* Night-light active → warm tone, matching the filter it represents. */
|
||||||
|
#custom-nightlight.on { color: @warn; }
|
||||||
|
|
||||||
|
/* Updates pending → accent, to draw the eye. */
|
||||||
|
#custom-updates.available { color: @accent; }
|
||||||
|
|
||||||
|
/* Doctor tripwire — only rendered when something is ✖, so it is
|
||||||
|
always the alert color. */
|
||||||
|
#custom-doctor { color: @bad; }
|
||||||
|
|
||||||
|
/* Icon-only status modules carry no text, so they don't get the
|
||||||
|
(size+2)pt Pango icon span the icon+text modules use — bump their
|
||||||
|
font-size to match, or these glyphs read smaller than the volume /
|
||||||
|
battery / language icons beside them. */
|
||||||
|
#custom-recording, #custom-updates, #custom-vpn, #custom-nightlight, #custom-doctor, #custom-notification { font-size: ${toString (t.fonts.size + 2)}pt; }
|
||||||
|
|
||||||
|
/* The speedometer + caffeine glyphs render small in their em box —
|
||||||
|
size them up a touch more so they read at a glance. */
|
||||||
|
#custom-powerprofile, #idle_inhibitor { font-size: ${toString (t.fonts.size + 3)}pt; }
|
||||||
|
|
||||||
/* notifications waiting → accent; Do-Not-Disturb → muted bell-off */
|
/* notifications waiting → accent; Do-Not-Disturb → muted bell-off */
|
||||||
#custom-notification.notification { color: @accent; }
|
#custom-notification.notification { color: @accent; }
|
||||||
#custom-notification.dnd-none,
|
#custom-notification.dnd-none,
|
||||||
@@ -228,7 +394,14 @@ in
|
|||||||
{
|
{
|
||||||
programs.waybar = lib.mkIf config.nomarchy.waybar.enable {
|
programs.waybar = lib.mkIf config.nomarchy.waybar.enable {
|
||||||
enable = true;
|
enable = true;
|
||||||
systemd.enable = true; # started/stopped with graphical-session.target
|
# Launched from Hyprland's exec-once (hyprland.nix), NOT a systemd user
|
||||||
|
# service. Bound to graphical-session.target the unit raced Hyprland's IPC
|
||||||
|
# on a warm relogin — it started before the socket was up, exited, landed
|
||||||
|
# in `failed`, and was never retried, so the bar vanished. exec-once only
|
||||||
|
# fires once Hyprland is up, dodging the race; theme switches reload the
|
||||||
|
# running bar via SIGUSR2 (nomarchy-theme-sync). No uwsm here to manage the
|
||||||
|
# session target, so we don't depend on its lifecycle.
|
||||||
|
systemd.enable = false;
|
||||||
|
|
||||||
# mkDefault so downstream can replace the whole bar config/style with
|
# mkDefault so downstream can replace the whole bar config/style with
|
||||||
# a plain home.nix assignment. For per-theme identity, prefer the
|
# a plain home.nix assignment. For per-theme identity, prefer the
|
||||||
@@ -249,7 +422,8 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
# The power-profile helpers on PATH, so both the generated bar and the
|
# The power-profile helpers on PATH, so both the generated bar and the
|
||||||
# whole-swap themes' static waybar.jsonc can exec them by name.
|
# whole-swap themes' static waybar.jsonc can exec them by name — plus
|
||||||
|
# the supervisor hyprland.nix exec-onces.
|
||||||
home.packages = lib.optionals config.nomarchy.waybar.enable
|
home.packages = lib.optionals config.nomarchy.waybar.enable
|
||||||
[ powerProfileStatus powerProfileCycle ];
|
[ waybarSupervisor powerProfileStatus powerProfileCycle vpnStatus doctorStatus ];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,28 @@ let
|
|||||||
'';
|
'';
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
imports = [ ./options.nix ./plymouth.nix ./file-manager.nix ./power.nix ./services.nix ];
|
imports = [ ./options.nix ./plymouth.nix ./greeter.nix ./file-manager.nix ./power.nix ./services.nix ./hardware.nix ./timezone.nix ./oom.nix ];
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
# The safe half of distro branding: distroName flows into
|
# Distro branding. distroName flows into /etc/os-release PRETTY_NAME,
|
||||||
# /etc/os-release PRETTY_NAME, systemd-boot entry titles and the
|
# systemd-boot entry titles and the ISO boot-menu label; distroId is the
|
||||||
# ISO boot-menu label. distroId stays "nixos" on purpose — it feeds
|
# machine-readable ID (DEFAULT_HOSTNAME, lsb DISTRIB_ID, CPE name).
|
||||||
# DEFAULT_HOSTNAME and upstream isNixos checks (see roadmap).
|
# distroId = "nomarchy" makes os-release honest — ID=nomarchy with
|
||||||
|
# ID_LIKE=nixos, the standard derivative-distro lineage marker (cf.
|
||||||
|
# Ubuntu→debian) — and is safe: switch-to-configuration builds its
|
||||||
|
# "is this NixOS?" guard from the *configured* distroId (and /etc/NIXOS
|
||||||
|
# still exists as the fallback), so rebuilds keep working; the nixos-*
|
||||||
|
# CLI tools are package names, untouched. The one side effect is that
|
||||||
|
# isNixos goes false and blanks the upstream nixos.org URLs, so we
|
||||||
|
# restore them pointing at the project instead.
|
||||||
system.nixos.distroName = lib.mkDefault "Nomarchy";
|
system.nixos.distroName = lib.mkDefault "Nomarchy";
|
||||||
|
system.nixos.distroId = lib.mkDefault "nomarchy";
|
||||||
|
system.nixos.extraOSReleaseArgs = lib.mkDefault {
|
||||||
|
HOME_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
|
||||||
|
DOCUMENTATION_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
|
||||||
|
SUPPORT_URL = "https://git.bemagri.xyz/bernardo/Nomarchy";
|
||||||
|
BUG_REPORT_URL = "https://git.bemagri.xyz/bernardo/Nomarchy/issues";
|
||||||
|
};
|
||||||
|
|
||||||
# MOTD on TTY/SSH login (the desktop auto-logs into Hyprland, so this
|
# MOTD on TTY/SSH login (the desktop auto-logs into Hyprland, so this
|
||||||
# is mostly seen over SSH or on a bare console). Branded, and doubles
|
# is mostly seen over SSH or on a bare console). Branded, and doubles
|
||||||
@@ -39,6 +53,7 @@ in
|
|||||||
${distroName} — a NixOS desktop, themed from one JSON.
|
${distroName} — a NixOS desktop, themed from one JSON.
|
||||||
|
|
||||||
sys-update update inputs + rebuild the system
|
sys-update update inputs + rebuild the system
|
||||||
|
sys-rebuild rebuild the system, no input update
|
||||||
home-update apply home/theme changes (no sudo)
|
home-update apply home/theme changes (no sudo)
|
||||||
nomarchy-theme-sync apply <theme> switch the whole palette
|
nomarchy-theme-sync apply <theme> switch the whole palette
|
||||||
SUPER+? keybindings cheatsheet
|
SUPER+? keybindings cheatsheet
|
||||||
@@ -69,6 +84,11 @@ in
|
|||||||
console.earlySetup = lib.mkDefault true;
|
console.earlySetup = lib.mkDefault true;
|
||||||
boot.initrd.systemd.enable = lib.mkDefault true;
|
boot.initrd.systemd.enable = lib.mkDefault true;
|
||||||
|
|
||||||
|
# Nomarchy roots are BTRFS, not ZFS, so adopt the 26.11 default early and
|
||||||
|
# silence the eval warning the old `true` default emits. mkDefault, so a
|
||||||
|
# genuine ZFS-root downstream can still force it back on.
|
||||||
|
boot.zfs.forceImportRoot = lib.mkDefault false;
|
||||||
|
|
||||||
# ── Wayland session: Hyprland ────────────────────────────────────
|
# ── Wayland session: Hyprland ────────────────────────────────────
|
||||||
# Installs the binary, registers the session, wires up
|
# Installs the binary, registers the session, wires up
|
||||||
# xdg-desktop-portal-hyprland. Configuration is Home Manager's job.
|
# xdg-desktop-portal-hyprland. Configuration is Home Manager's job.
|
||||||
@@ -79,22 +99,8 @@ in
|
|||||||
extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; # file pickers, etc.
|
extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; # file pickers, etc.
|
||||||
};
|
};
|
||||||
|
|
||||||
services.greetd = lib.mkIf cfg.greeter.enable {
|
# The greetd/tuigreet login screen lives in ./greeter.nix — themed
|
||||||
enable = lib.mkDefault true;
|
# from the state JSON (console.colors + --theme) at system rebuild.
|
||||||
settings = {
|
|
||||||
default_session = {
|
|
||||||
# start-hyprland is Hyprland 0.55's watchdog launcher; running
|
|
||||||
# the bare binary makes every session print a warning.
|
|
||||||
command = lib.mkDefault "${pkgs.tuigreet}/bin/tuigreet --time --remember --greeting 'Welcome to ${distroName}' --cmd start-hyprland";
|
|
||||||
user = "greeter";
|
|
||||||
};
|
|
||||||
# Boot straight into the session once; logout → normal greeter.
|
|
||||||
initial_session = lib.mkIf (cfg.greeter.autoLogin != null) {
|
|
||||||
command = "start-hyprland";
|
|
||||||
user = cfg.greeter.autoLogin;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# ── Audio: Pipewire ──────────────────────────────────────────────
|
# ── Audio: Pipewire ──────────────────────────────────────────────
|
||||||
security.rtkit.enable = lib.mkDefault cfg.audio.enable;
|
security.rtkit.enable = lib.mkDefault cfg.audio.enable;
|
||||||
@@ -122,6 +128,11 @@ in
|
|||||||
services.fwupd.enable = lib.mkDefault true;
|
services.fwupd.enable = lib.mkDefault true;
|
||||||
|
|
||||||
networking.networkmanager.enable = lib.mkDefault true;
|
networking.networkmanager.enable = lib.mkDefault true;
|
||||||
|
# OpenVPN support for the VPN menu's import/connect flow (nomarchy-vpn).
|
||||||
|
# WireGuard needs no plugin (NetworkManager imports wg .conf natively);
|
||||||
|
# this adds the openvpn type so `nmcli connection import type openvpn` works.
|
||||||
|
# mkDefault so a downstream can drop it to slim the closure.
|
||||||
|
networking.networkmanager.plugins = lib.mkDefault [ pkgs.networkmanager-openvpn ];
|
||||||
|
|
||||||
# No double-unlock on hibernate. Locking the session before sleep is
|
# No double-unlock on hibernate. Locking the session before sleep is
|
||||||
# right for suspend (resumes from RAM, no other gate), but an encrypted
|
# right for suspend (resumes from RAM, no other gate), but an encrypted
|
||||||
@@ -249,6 +260,10 @@ in
|
|||||||
# (every weight × variant) — too heavy for the ISO and closures.
|
# (every weight × variant) — too heavy for the ISO and closures.
|
||||||
nerd-fonts.mononoki
|
nerd-fonts.mononoki
|
||||||
nerd-fonts.inconsolata
|
nerd-fonts.inconsolata
|
||||||
|
# GeistMono (57 MB) — the Boreal theme's mono face; modern and
|
||||||
|
# geometric, unlike the ten defaults. Cheap enough for the ISO
|
||||||
|
# (Iosevka above was rejected at 1.1 GB; this is a compact family).
|
||||||
|
nerd-fonts.geist-mono
|
||||||
inter
|
inter
|
||||||
noto-fonts
|
noto-fonts
|
||||||
noto-fonts-color-emoji
|
noto-fonts-color-emoji
|
||||||
@@ -268,6 +283,8 @@ in
|
|||||||
# ── Essential packages ───────────────────────────────────────────
|
# ── Essential packages ───────────────────────────────────────────
|
||||||
environment.systemPackages = with pkgs; [
|
environment.systemPackages = with pkgs; [
|
||||||
nomarchy-theme-sync # provided by overlays.default
|
nomarchy-theme-sync # provided by overlays.default
|
||||||
|
nomarchy-doctor # read-only health check (System › Doctor)
|
||||||
|
nomarchy-control-center # TUI control center
|
||||||
|
|
||||||
# Friendly wrappers for the two rebuild paths (README §3). Run as
|
# Friendly wrappers for the two rebuild paths (README §3). Run as
|
||||||
# your user: `nix flake update` must NOT run as root (libgit2
|
# your user: `nix flake update` must NOT run as root (libgit2
|
||||||
@@ -282,11 +299,48 @@ in
|
|||||||
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||||||
echo "sys-update: updating flake inputs in $flake"
|
echo "sys-update: updating flake inputs in $flake"
|
||||||
nix flake update --flake "$flake"
|
nix flake update --flake "$flake"
|
||||||
|
before=$(readlink -f /run/current-system)
|
||||||
if command -v nixos-rebuild-snap >/dev/null 2>&1; then
|
if command -v nixos-rebuild-snap >/dev/null 2>&1; then
|
||||||
sudo nixos-rebuild-snap "$@" # BTRFS snapshot first
|
sudo nixos-rebuild-snap "$@" # BTRFS snapshot first
|
||||||
else
|
else
|
||||||
sudo nixos-rebuild switch --flake "$flake#default" "$@"
|
sudo nixos-rebuild switch --flake "$flake#default" "$@"
|
||||||
fi
|
fi
|
||||||
|
# What did that update actually change? Package-level diff of the
|
||||||
|
# old vs new generation (the informative half of "informative +
|
||||||
|
# rock-stable"); never fails the run.
|
||||||
|
after=$(readlink -f /run/current-system)
|
||||||
|
if [ "$before" = "$after" ]; then
|
||||||
|
echo "sys-update: no changes — the system is identical."
|
||||||
|
else
|
||||||
|
echo "sys-update: what changed:"
|
||||||
|
${pkgs.nvd}/bin/nvd diff "$before" "$after" || true
|
||||||
|
fi
|
||||||
|
'')
|
||||||
|
# The no-update twin (hardware-QA request): rebuild the system
|
||||||
|
# against the CURRENT lock — config changes only, no `nix flake
|
||||||
|
# update` — mirroring how home-update never touches the lock.
|
||||||
|
# Same snapshot-first path when available.
|
||||||
|
(pkgs.writeShellScriptBin "sys-rebuild" ''
|
||||||
|
set -e
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
echo "sys-rebuild: run as your normal user (it sudos the rebuild itself)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||||||
|
before=$(readlink -f /run/current-system)
|
||||||
|
if command -v nixos-rebuild-snap >/dev/null 2>&1; then
|
||||||
|
sudo nixos-rebuild-snap "$@" # BTRFS snapshot first
|
||||||
|
else
|
||||||
|
sudo nixos-rebuild switch --flake "$flake#default" "$@"
|
||||||
|
fi
|
||||||
|
# Same what-changed diff as sys-update (the twins stay twins).
|
||||||
|
after=$(readlink -f /run/current-system)
|
||||||
|
if [ "$before" = "$after" ]; then
|
||||||
|
echo "sys-rebuild: no changes — the system is identical."
|
||||||
|
else
|
||||||
|
echo "sys-rebuild: what changed:"
|
||||||
|
${pkgs.nvd}/bin/nvd diff "$before" "$after" || true
|
||||||
|
fi
|
||||||
'')
|
'')
|
||||||
(pkgs.writeShellScriptBin "home-update" ''
|
(pkgs.writeShellScriptBin "home-update" ''
|
||||||
set -e
|
set -e
|
||||||
@@ -304,6 +358,7 @@ in
|
|||||||
wl-clipboard
|
wl-clipboard
|
||||||
grim
|
grim
|
||||||
slurp
|
slurp
|
||||||
|
hyprpicker
|
||||||
] ++ lib.optional (cfg.snapper.enable && (config.fileSystems."/".fsType or "") == "btrfs")
|
] ++ lib.optional (cfg.snapper.enable && (config.fileSystems."/".fsType or "") == "btrfs")
|
||||||
# Snapshot, then rebuild — rollback material for system changes
|
# Snapshot, then rebuild — rollback material for system changes
|
||||||
# (theme changes don't need it; HM generations already roll back).
|
# (theme changes don't need it; HM generations already roll back).
|
||||||
@@ -320,8 +375,73 @@ in
|
|||||||
nixos-rebuild switch --flake /etc/nixos#default "$@"
|
nixos-rebuild switch --flake /etc/nixos#default "$@"
|
||||||
'')
|
'')
|
||||||
# The desktop snapshot manager (browse / diff / restore / rollback over
|
# The desktop snapshot manager (browse / diff / restore / rollback over
|
||||||
# snapper, elevating via polkit) — what `nomarchy-menu snapshot` launches.
|
# snapper, elevating via polkit) — the primary `nomarchy-menu snapshot`
|
||||||
++ lib.optional cfg.snapper.enable pkgs.btrfs-assistant;
|
# target. The "2.2 segfault" is unprivileged-only (libbtrfsutil
|
||||||
|
# unprivileged subvolume iteration, btrfs-progs 6.17.1, fixed upstream
|
||||||
|
# after); the pkexec launcher runs it as root, where it works —
|
||||||
|
# VM-proven, guarded by checks.snapshot-gui.
|
||||||
|
++ lib.optional cfg.snapper.enable pkgs.btrfs-assistant
|
||||||
|
# Keyboard-driven snapper browser/restore — the menu's fallback when the
|
||||||
|
# GUI is absent, and handy over SSH. Runs as root (snapper is root-only
|
||||||
|
# here; the menu opens it in a terminal via sudo, one password prompt),
|
||||||
|
# fzf to pick, with browse/diff (read-only) and typed-`yes` confirmation
|
||||||
|
# before any write.
|
||||||
|
++ lib.optional cfg.snapper.enable (pkgs.writeShellApplication {
|
||||||
|
name = "nomarchy-snapshots";
|
||||||
|
runtimeInputs = with pkgs; [ snapper fzf gawk gnugrep less coreutils systemd ];
|
||||||
|
text = ''
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "nomarchy-snapshots must run as root (snapper needs it) — use sudo." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mapfile -t configs < <(snapper list-configs | awk 'NR>2 {print $1}' | grep .)
|
||||||
|
if [ "''${#configs[@]}" -eq 0 ]; then
|
||||||
|
echo "No snapper configs found." >&2; exit 1
|
||||||
|
elif [ "''${#configs[@]}" -eq 1 ]; then
|
||||||
|
config="''${configs[0]}"
|
||||||
|
else
|
||||||
|
config=$(printf '%s\n' "''${configs[@]}" | fzf --prompt="snapper config> ") || exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
snap=$(snapper -c "$config" list \
|
||||||
|
| fzf --header-lines=2 --prompt="[$config] pick a snapshot (Esc quits)> ") || exit 0
|
||||||
|
num=$(awk '{print $1}' <<<"$snap")
|
||||||
|
case "$num" in ""|*[!0-9]*) continue ;; esac
|
||||||
|
|
||||||
|
action=$(printf '%s\n' \
|
||||||
|
"Browse changes since #$num (read-only)" \
|
||||||
|
"Restore changed files to #$num (undochange)" \
|
||||||
|
"Roll the system back to #$num (reboot)" \
|
||||||
|
"↩ Back to the snapshot list" \
|
||||||
|
| fzf --prompt="snapshot #$num> ") || exit 0
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
Browse*)
|
||||||
|
snapper -c "$config" status "$num..0" | less -R || true ;;
|
||||||
|
Restore*)
|
||||||
|
read -rp "Revert files in '$config' to snapshot #$num? Type yes to confirm: " ans || continue
|
||||||
|
if [ "$ans" = yes ]; then
|
||||||
|
snapper -c "$config" undochange "$num..0"
|
||||||
|
echo "Files restored. Press enter."; read -r _ || true
|
||||||
|
fi ;;
|
||||||
|
Roll*)
|
||||||
|
if [ "$config" != root ]; then
|
||||||
|
echo "Rollback applies to the 'root' config only; use Restore for '$config'. Press enter."
|
||||||
|
read -r _ || true
|
||||||
|
else
|
||||||
|
read -rp "Roll the SYSTEM back to #$num and REBOOT now? Type yes to confirm: " ans || continue
|
||||||
|
if [ "$ans" = yes ]; then
|
||||||
|
snapper -c root rollback "$num"
|
||||||
|
echo "Rolled back — rebooting…"; systemctl reboot
|
||||||
|
fi
|
||||||
|
fi ;;
|
||||||
|
*) continue ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
});
|
||||||
|
|
||||||
# Don't let boot entries fill the ESP over the years.
|
# Don't let boot entries fill the ESP over the years.
|
||||||
boot.loader.systemd-boot.configurationLimit = lib.mkDefault 10;
|
boot.loader.systemd-boot.configurationLimit = lib.mkDefault 10;
|
||||||
|
|||||||
70
modules/nixos/greeter.nix
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Greeter — greetd/tuigreet, themed from the same theme-state.json that
|
||||||
|
# drives the desktop (nomarchy.system.stateFile; the Plymouth model:
|
||||||
|
# baked at SYSTEM rebuild, so it follows the theme as of the last
|
||||||
|
# sys-update, not the last instant apply).
|
||||||
|
#
|
||||||
|
# tuigreet draws on the virtual console with the 16 ANSI slots, so the
|
||||||
|
# theming is two-part:
|
||||||
|
# 1. console.colors — the VT palette becomes the theme's ansi[] hexes
|
||||||
|
# (which also themes raw ttys and the LUKS passphrase prompt: the
|
||||||
|
# same JSON reaches every pre-session surface).
|
||||||
|
# 2. --theme — tuigreet components on NAMED slots (its parser is
|
||||||
|
# ratatui Color::from_str; names map to the standard indexes, e.g.
|
||||||
|
# blue=4, gray=7, white=15, so the palette above hands them the
|
||||||
|
# theme's colors). ANSI "black" stays dark even in light themes —
|
||||||
|
# the greeter reads terminal-dark there, the same convention every
|
||||||
|
# terminal applies to ANSI colors.
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.nomarchy.system;
|
||||||
|
distroName = config.system.nixos.distroName;
|
||||||
|
|
||||||
|
state =
|
||||||
|
if cfg.stateFile != null
|
||||||
|
then builtins.fromJSON (builtins.readFile cfg.stateFile)
|
||||||
|
else { };
|
||||||
|
# A sparse/hand-rolled state without a proper ansi block just skips the
|
||||||
|
# theming (stock tuigreet grey) — never an eval error.
|
||||||
|
ansi = state.ansi or [ ];
|
||||||
|
themed = builtins.isList ansi && builtins.length ansi == 16;
|
||||||
|
|
||||||
|
tuigreetTheme = lib.concatStringsSep ";" [
|
||||||
|
"container=black" # ansi[0] — the theme's terminal background
|
||||||
|
"border=blue" # ansi[4] — the accent family in every shipped palette
|
||||||
|
"title=cyan"
|
||||||
|
"greet=cyan"
|
||||||
|
"prompt=green"
|
||||||
|
"input=white" # ansi[15] — bright foreground
|
||||||
|
"action=blue"
|
||||||
|
"button=yellow"
|
||||||
|
"time=cyan"
|
||||||
|
"text=gray" # ansi[7] — muted foreground
|
||||||
|
];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
config = {
|
||||||
|
# VT palette from the theme (RRGGBB, no #; lands as vt.default_* kernel
|
||||||
|
# params). mkDefault so a downstream console.colors wins.
|
||||||
|
console.colors = lib.mkIf themed (lib.mkDefault (map (lib.removePrefix "#") ansi));
|
||||||
|
|
||||||
|
services.greetd = lib.mkIf cfg.greeter.enable {
|
||||||
|
enable = lib.mkDefault true;
|
||||||
|
settings = {
|
||||||
|
default_session = {
|
||||||
|
# start-hyprland is Hyprland 0.55's watchdog launcher; running
|
||||||
|
# the bare binary makes every session print a warning.
|
||||||
|
command = lib.mkDefault ("${pkgs.tuigreet}/bin/tuigreet --time --remember --greeting 'Welcome to ${distroName}'"
|
||||||
|
+ lib.optionalString themed " --theme '${tuigreetTheme}'"
|
||||||
|
+ " --cmd start-hyprland");
|
||||||
|
user = "greeter";
|
||||||
|
};
|
||||||
|
# Boot straight into the session once; logout → normal greeter.
|
||||||
|
initial_session = lib.mkIf (cfg.greeter.autoLogin != null) {
|
||||||
|
command = "start-hyprland";
|
||||||
|
user = cfg.greeter.autoLogin;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
242
modules/nixos/hardware.nix
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# Hardware enablement beyond nixos-hardware.
|
||||||
|
#
|
||||||
|
# The nixos-hardware "common-*" profiles the installer selects cover the
|
||||||
|
# BASICS (microcode, the Intel/AMD VA-API media stack, weekly fstrim), and
|
||||||
|
# power.nix adds thermald + power-profiles-daemon. This module fills the GAP
|
||||||
|
# above those: broadly-beneficial bits that default ON when the installer
|
||||||
|
# detects the vendor (opt-OUT), and heavier/experimental bits behind opt-IN
|
||||||
|
# toggles. The installer's hardware-db.sh probes what's present and writes the
|
||||||
|
# matching nomarchy.hardware.* into the generated system.nix.
|
||||||
|
#
|
||||||
|
# Audited against the commons so we don't double-set: we add GuC/HuC, the
|
||||||
|
# amd-pstate governor, the AMD VA-API env, GPU-compute runtimes, fprintd, and
|
||||||
|
# the NPU driver — none of which the commons turn on.
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.nomarchy.hardware;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.nomarchy.hardware = {
|
||||||
|
intel = {
|
||||||
|
enable = lib.mkEnableOption ''
|
||||||
|
Intel CPU/GPU enablement (the installer turns this on when it detects
|
||||||
|
an Intel CPU or GPU). Complements nixos-hardware's common-gpu-intel'';
|
||||||
|
|
||||||
|
guc = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = cfg.intel.enable;
|
||||||
|
defaultText = lib.literalExpression "config.nomarchy.hardware.intel.enable";
|
||||||
|
description = ''
|
||||||
|
Load the GPU's GuC/HuC firmware via the i915 param
|
||||||
|
(i915.enable_guc=3) — better power management and HuC-accelerated
|
||||||
|
media. On by default with intel.enable. NOTE: this is the *i915*
|
||||||
|
driver's param; the newer `xe` driver (Lunar Lake / Battlemage /
|
||||||
|
Panther Lake and other recent Xe GPUs) enables GuC by default and
|
||||||
|
ignores it, so the installer turns this off on xe-driver hardware.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
computeRuntime = lib.mkEnableOption ''
|
||||||
|
Intel GPU compute: the OpenCL / Level Zero (intel-compute-runtime) and
|
||||||
|
oneVPL (vpl-gpu-rt) runtimes for GPU compute and transcode. Opt-in (a
|
||||||
|
few hundred MB) — the Intel counterpart to AMD ROCm'';
|
||||||
|
};
|
||||||
|
|
||||||
|
amd = {
|
||||||
|
enable = lib.mkEnableOption ''
|
||||||
|
AMD CPU/GPU enablement (installer-set on an AMD CPU or GPU).
|
||||||
|
Complements nixos-hardware's common-cpu-amd / common-gpu-amd'';
|
||||||
|
|
||||||
|
pstate = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = cfg.amd.enable;
|
||||||
|
defaultText = lib.literalExpression "config.nomarchy.hardware.amd.enable";
|
||||||
|
description = ''
|
||||||
|
Use the amd-pstate EPP driver (amd_pstate=active) — the modern Zen
|
||||||
|
power/perf governor that power-profiles-daemon drives per profile.
|
||||||
|
On by default with amd.enable (broadly beneficial on Zen 2+).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
vaapi = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = cfg.amd.enable;
|
||||||
|
defaultText = lib.literalExpression "config.nomarchy.hardware.amd.enable";
|
||||||
|
description = ''
|
||||||
|
Point VA-API at mesa's radeonsi (LIBVA_DRIVER_NAME=radeonsi) for
|
||||||
|
hardware video decode/encode. On by default with amd.enable.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
rocm = {
|
||||||
|
enable = lib.mkEnableOption ''
|
||||||
|
AMD ROCm: the HIP / OpenCL GPU-compute stack (multi-GB closure).
|
||||||
|
Opt-in — unlocks GPU PyTorch / Ollama on Radeon'';
|
||||||
|
|
||||||
|
gfxOverride = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
example = "11.0.0";
|
||||||
|
description = ''
|
||||||
|
HSA_OVERRIDE_GFX_VERSION for GPUs ROCm doesn't officially list
|
||||||
|
(e.g. an RDNA3 780M iGPU, gfx1103, needs "11.0.0"). Empty = no
|
||||||
|
override.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
fingerprint = {
|
||||||
|
enable = lib.mkEnableOption ''
|
||||||
|
a fingerprint reader via fprintd (the installer turns this on when it
|
||||||
|
detects a known reader). Enroll with `fprintd-enroll`'';
|
||||||
|
|
||||||
|
pam = lib.mkEnableOption ''
|
||||||
|
using the fingerprint for login and sudo (PAM). Opt-in — password-only
|
||||||
|
stays the default for the cautious; enroll a finger first'';
|
||||||
|
};
|
||||||
|
|
||||||
|
npu.enable = lib.mkEnableOption ''
|
||||||
|
the on-die NPU (AI accelerator) kernel driver — amdxdna on AMD (Ryzen
|
||||||
|
AI), intel_vpu on Intel (Core Ultra and newer). Opt-in and experimental:
|
||||||
|
this loads the in-kernel driver only; the userspace runtime (AMD XRT /
|
||||||
|
oneAPI Level Zero NPU) is yours to add. Needs a recent kernel (see
|
||||||
|
latestKernel)'';
|
||||||
|
|
||||||
|
latestKernel = lib.mkEnableOption ''
|
||||||
|
the latest mainline kernel (pkgs.linuxPackages_latest) instead of the
|
||||||
|
distro default — for very new hardware whose drivers (a fresh NPU, the
|
||||||
|
`xe` GPU driver, new-platform enablement) only landed recently. Off by
|
||||||
|
default; the default kernel already carries amd-pstate and amdxdna (6.14+)'';
|
||||||
|
|
||||||
|
camera = {
|
||||||
|
hideIrSensor = lib.mkEnableOption ''
|
||||||
|
hiding a dual-sensor webcam's IR (face-unlock) node from PipeWire so
|
||||||
|
apps only ever see the colour camera. Such modules (common on recent
|
||||||
|
ThinkPads) expose the IR sensor as a SECOND, identically-named
|
||||||
|
"Integrated Camera"; selecting it gives a dark, 8-bit-greyscale image —
|
||||||
|
the classic "my webcam is dark" symptom. The installer turns this on
|
||||||
|
when it detects a paired RGB+IR webcam. Only the PipeWire node is
|
||||||
|
disabled — the kernel /dev/video* device stays open, so face-unlock
|
||||||
|
(Howdy) still works. Acts on the V4L2 path only; the libcamera monitor
|
||||||
|
is left untouched, so an external camera you plug in is never affected.
|
||||||
|
See irMatch'';
|
||||||
|
|
||||||
|
irMatch = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "~.*(Integrated I|IR Camera|Infrared).*";
|
||||||
|
description = ''
|
||||||
|
WirePlumber regex (matched against a V4L2 node's api.v4l2.cap.card)
|
||||||
|
selecting the IR sensor to hide. The default catches the common
|
||||||
|
dual-sensor naming ("… Integrated I", "IR Camera", "Infrared"); set it
|
||||||
|
to your camera's IR card name if it differs — find it with
|
||||||
|
`v4l2-ctl --list-devices` or `wpctl inspect`. Only consulted when
|
||||||
|
camera.hideIrSensor is on.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkMerge [
|
||||||
|
# ── Intel ──────────────────────────────────────────────────────────
|
||||||
|
(lib.mkIf cfg.intel.guc {
|
||||||
|
boot.kernelParams = [ "i915.enable_guc=3" ];
|
||||||
|
})
|
||||||
|
(lib.mkIf cfg.intel.computeRuntime {
|
||||||
|
hardware.graphics.extraPackages = with pkgs; [ intel-compute-runtime vpl-gpu-rt ];
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── AMD ────────────────────────────────────────────────────────────
|
||||||
|
(lib.mkIf cfg.amd.pstate {
|
||||||
|
boot.kernelParams = [ "amd_pstate=active" ];
|
||||||
|
})
|
||||||
|
(lib.mkIf cfg.amd.vaapi {
|
||||||
|
# radeonsi itself comes from mesa (via common-gpu-amd); this just steers
|
||||||
|
# libva at it. mkDefault so a hand-set value or another module wins.
|
||||||
|
environment.sessionVariables.LIBVA_DRIVER_NAME = lib.mkDefault "radeonsi";
|
||||||
|
hardware.graphics.extraPackages = [ pkgs.libva ];
|
||||||
|
})
|
||||||
|
(lib.mkIf cfg.amd.rocm.enable {
|
||||||
|
hardware.graphics.extraPackages = [ pkgs.rocmPackages.clr pkgs.rocmPackages.clr.icd ];
|
||||||
|
environment.sessionVariables = lib.optionalAttrs (cfg.amd.rocm.gfxOverride != "") {
|
||||||
|
HSA_OVERRIDE_GFX_VERSION = cfg.amd.rocm.gfxOverride;
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Fingerprint ────────────────────────────────────────────────────
|
||||||
|
(lib.mkIf cfg.fingerprint.enable {
|
||||||
|
services.fprintd.enable = true;
|
||||||
|
})
|
||||||
|
(lib.mkIf (cfg.fingerprint.enable && cfg.fingerprint.pam) {
|
||||||
|
security.pam.services.login.fprintAuth = true;
|
||||||
|
security.pam.services.sudo.fprintAuth = true;
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Newest kernel for very-new hardware (opt-in escape hatch) ──────
|
||||||
|
(lib.mkIf cfg.latestKernel {
|
||||||
|
boot.kernelPackages = lib.mkDefault pkgs.linuxPackages_latest;
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── NPU (in-kernel driver only; userspace runtime is BYO) ──────────
|
||||||
|
# The driver has to actually be in the running kernel — warn (don't fail)
|
||||||
|
# when it predates the shipped one, pointing at latestKernel.
|
||||||
|
(lib.mkIf (cfg.npu.enable && cfg.amd.enable) {
|
||||||
|
boot.kernelModules = [ "amdxdna" ];
|
||||||
|
warnings = lib.optional
|
||||||
|
(!lib.versionAtLeast config.boot.kernelPackages.kernel.version "6.14")
|
||||||
|
''
|
||||||
|
nomarchy.hardware.npu: the amdxdna driver needs kernel >= 6.14, but
|
||||||
|
this config ships ${config.boot.kernelPackages.kernel.version}. Set
|
||||||
|
nomarchy.hardware.latestKernel = true.'';
|
||||||
|
})
|
||||||
|
(lib.mkIf (cfg.npu.enable && cfg.intel.enable) {
|
||||||
|
boot.kernelModules = [ "intel_vpu" ];
|
||||||
|
warnings = lib.optional
|
||||||
|
(!lib.versionAtLeast config.boot.kernelPackages.kernel.version "6.11")
|
||||||
|
''
|
||||||
|
nomarchy.hardware.npu: the intel_vpu driver (especially for newer
|
||||||
|
NPUs) wants a recent kernel, but this config ships
|
||||||
|
${config.boot.kernelPackages.kernel.version}. Consider
|
||||||
|
nomarchy.hardware.latestKernel = true.'';
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Webcam: hide a dual-sensor module's IR node ────────────────────
|
||||||
|
# A built-in RGB+IR webcam exposes its IR (face-unlock) sensor as a second,
|
||||||
|
# identically-named camera; an app that picks it gets a dark greyscale
|
||||||
|
# image. Disable that node on the V4L2 PipeWire path so only the colour
|
||||||
|
# camera is offered. Matched by card name (irMatch). libcamera is left
|
||||||
|
# alone on purpose — an external camera may rely on it, and surgical
|
||||||
|
# internal-only libcamera scoping isn't possible (the distinguishing
|
||||||
|
# device props bind after the monitor rule runs). The kernel /dev/video*
|
||||||
|
# stays open, so Howdy face-unlock still reads the IR sensor directly.
|
||||||
|
(lib.mkIf (cfg.camera.hideIrSensor && config.services.pipewire.wireplumber.enable) {
|
||||||
|
services.pipewire.wireplumber.extraConfig."90-nomarchy-hide-ir-camera" = {
|
||||||
|
"monitor.v4l2.rules" = [
|
||||||
|
{
|
||||||
|
matches = [ { "api.v4l2.cap.card" = cfg.camera.irMatch; } ];
|
||||||
|
actions."update-props"."node.disabled" = true;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Sanity ─────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = cfg.amd.rocm.enable -> cfg.amd.enable;
|
||||||
|
message = "nomarchy.hardware.amd.rocm.enable needs nomarchy.hardware.amd.enable.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = cfg.intel.computeRuntime -> cfg.intel.enable;
|
||||||
|
message = "nomarchy.hardware.intel.computeRuntime needs nomarchy.hardware.intel.enable.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = cfg.npu.enable -> (cfg.amd.enable || cfg.intel.enable);
|
||||||
|
message = "nomarchy.hardware.npu.enable needs a detected Intel or AMD platform.";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
42
modules/nixos/oom.nix
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Memory-pressure protection: keep the desktop alive when RAM runs out.
|
||||||
|
#
|
||||||
|
# A workstation that compiles from source WILL exhaust memory eventually —
|
||||||
|
# a big `nix build`, a runaway eval, a browser tab. The kernel's own OOM
|
||||||
|
# killer acts only after the system has thrashed itself unresponsive
|
||||||
|
# (often minutes of frozen desktop) and then picks by badness score,
|
||||||
|
# which can land on the compositor.
|
||||||
|
#
|
||||||
|
# earlyoom over systemd-oomd — a deliberate choice: oomd kills whole
|
||||||
|
# cgroups, and a Hyprland session runs as ONE scope (nothing spawns
|
||||||
|
# per-app systemd scopes here, unlike GNOME), so under pressure oomd
|
||||||
|
# would take out the entire desktop to save it. earlyoom kills a single
|
||||||
|
# process (highest oom_score ≈ the hog) BEFORE the thrash point — "kill
|
||||||
|
# the build step, keep the session". nixpkgs default-enables oomd in an
|
||||||
|
# inert state (no slices monitored); it's disabled outright below so
|
||||||
|
# there is exactly one owner of the OOM story.
|
||||||
|
{ lib, ... }:
|
||||||
|
|
||||||
|
{
|
||||||
|
services.earlyoom = {
|
||||||
|
enable = lib.mkDefault true;
|
||||||
|
|
||||||
|
# Desktop toast when something is killed (relayed via
|
||||||
|
# systembus-notify), so a vanished build/tab is explained rather
|
||||||
|
# than mysterious.
|
||||||
|
enableNotifications = lib.mkDefault true;
|
||||||
|
|
||||||
|
# Never pick the session plumbing: losing the compositor or the lock
|
||||||
|
# screen IS the outage this module exists to prevent (and killing a
|
||||||
|
# Wayland session-lock client trips its go-to-a-tty failsafe). No
|
||||||
|
# --prefer tuning: highest-memory selection already targets the hog.
|
||||||
|
# Matched unanchored — NixOS wrappers rename comm to ".foo-wrapped".
|
||||||
|
extraArgs = lib.mkDefault [
|
||||||
|
"--avoid"
|
||||||
|
"(Hyprland|hyprlock|greetd|waybar|pipewire|wireplumber|Xwayland|nix-daemon|systemd)"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# One owner (see header): oomd ships default-on but inert; make the
|
||||||
|
# earlyoom choice explicit and total.
|
||||||
|
systemd.oomd.enable = lib.mkDefault false;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# Deliberately small: only things a downstream user plausibly disagrees
|
# Deliberately small: only things a downstream user plausibly disagrees
|
||||||
# with get a toggle. Everything else in the system module is set with
|
# with get a toggle. Everything else in the system module is set with
|
||||||
# lib.mkDefault, so plain NixOS options override it natively.
|
# lib.mkDefault, so plain NixOS options override it natively.
|
||||||
{ lib, ... }:
|
{ config, lib, ... }:
|
||||||
|
|
||||||
{
|
{
|
||||||
options.nomarchy.system = {
|
options.nomarchy.system = {
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
greeter.autoLogin = lib.mkOption {
|
greeter.autoLogin = lib.mkOption {
|
||||||
type = lib.types.nullOr lib.types.str;
|
type = lib.types.nullOr lib.types.str;
|
||||||
default = null;
|
default = config.nomarchy.settings.greeter.autoLogin or null;
|
||||||
example = "ada";
|
example = "ada";
|
||||||
description = ''
|
description = ''
|
||||||
Log this user straight into Hyprland on boot (greetd
|
Log this user straight into Hyprland on boot (greetd
|
||||||
@@ -44,7 +44,17 @@
|
|||||||
option'' // { default = true; };
|
option'' // { default = true; };
|
||||||
|
|
||||||
audio.enable = lib.mkEnableOption "the Pipewire audio stack" // { default = true; };
|
audio.enable = lib.mkEnableOption "the Pipewire audio stack" // { default = true; };
|
||||||
bluetooth.enable = lib.mkEnableOption "Bluetooth support with blueman" // { default = true; };
|
bluetooth.enable = lib.mkEnableOption "Bluetooth support with blueman" // { default = config.nomarchy.settings.bluetooth.enable or true; };
|
||||||
|
|
||||||
|
autoTimezone.enable = lib.mkEnableOption ''
|
||||||
|
automatic timezone detection (geoclue + automatic-timezoned): the
|
||||||
|
system timezone — and so the Waybar clock — follows your location, so
|
||||||
|
travelling to another zone updates the time on its own. Off by default
|
||||||
|
(it's a location service and needs the network); toggle it from the
|
||||||
|
System menu, which lands the choice in the in-flake state file. Enabling
|
||||||
|
it unsets the static time.timeZone for you (a runtime timezone needs
|
||||||
|
/etc/localtime writable), so the menu toggle drives a system rebuild''
|
||||||
|
// { default = false; };
|
||||||
|
|
||||||
snapper.enable = lib.mkEnableOption ''
|
snapper.enable = lib.mkEnableOption ''
|
||||||
hourly/daily BTRFS timeline snapshots of / via snapper, plus the
|
hourly/daily BTRFS timeline snapshots of / via snapper, plus the
|
||||||
@@ -92,15 +102,16 @@
|
|||||||
|
|
||||||
batteryChargeLimit = lib.mkOption {
|
batteryChargeLimit = lib.mkOption {
|
||||||
type = lib.types.nullOr (lib.types.ints.between 50 100);
|
type = lib.types.nullOr (lib.types.ints.between 50 100);
|
||||||
default = null;
|
default = config.nomarchy.settings.power.batteryChargeLimit or null;
|
||||||
example = 80;
|
example = 80;
|
||||||
description = ''
|
description = ''
|
||||||
Stop charging at this percentage to extend battery lifespan,
|
Stop charging at this percentage to extend battery lifespan,
|
||||||
where the hardware exposes a charge threshold
|
where the hardware exposes a charge threshold
|
||||||
(/sys/class/power_supply/BAT*/charge_control_end_threshold).
|
(/sys/class/power_supply/BAT*/charge_control_end_threshold).
|
||||||
null leaves charging at the firmware default. Backend-independent
|
null leaves charging at the firmware default. Backend-independent
|
||||||
(a small systemd unit writes the sysfs knob at boot), so it
|
(a small systemd unit writes the sysfs knob, re-applied on AC
|
||||||
works under PPD too; needs nomarchy.system.power.laptop.
|
state changes), so it works under PPD too; needs
|
||||||
|
nomarchy.system.power.laptop.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ in
|
|||||||
|
|
||||||
# Battery charge limit via sysfs. PPD can't cap charge at all, and
|
# Battery charge limit via sysfs. PPD can't cap charge at all, and
|
||||||
# TLP's own knob only applies under TLP — so a tiny oneshot writes
|
# TLP's own knob only applies under TLP — so a tiny oneshot writes
|
||||||
# the threshold directly, independent of the backend. Boot-time only:
|
# the threshold directly, independent of the backend. Re-applied on
|
||||||
# some firmwares reset the threshold on unplug; revisit with a udev
|
# AC state changes by the udev rule below (some firmwares reset the
|
||||||
# hook if that bites. `-` paths that don't exist are skipped, so this
|
# threshold when the charger is unplugged). `[ -w ]` skips paths that
|
||||||
# is a clean no-op on hardware without the control.
|
# don't exist, so this is a clean no-op on hardware without the control.
|
||||||
systemd.services.nomarchy-battery-charge-limit = lib.mkIf chargeLimit {
|
systemd.services.nomarchy-battery-charge-limit = lib.mkIf chargeLimit {
|
||||||
description = "Cap battery charging at ${toString cfg.batteryChargeLimit}%";
|
description = "Cap battery charging at ${toString cfg.batteryChargeLimit}%";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
@@ -56,5 +56,16 @@ in
|
|||||||
exit 0
|
exit 0
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Re-apply the threshold whenever the mains adapter changes state: the
|
||||||
|
# boot oneshot above runs once, but some firmwares clear the limit when
|
||||||
|
# the charger is unplugged, so it must be re-asserted on the event.
|
||||||
|
# Matched by ATTR{type}=="Mains" (vendor-neutral — the kernel name
|
||||||
|
# AC/AC0/ADP1/ACAD varies); --no-block so the RUN+= returns at once
|
||||||
|
# (systemd kills long-running udev workers); restart (not try-restart)
|
||||||
|
# so it re-applies even if the boot run was inactive.
|
||||||
|
services.udev.extraRules = lib.mkIf chargeLimit ''
|
||||||
|
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${config.systemd.package}/bin/systemctl --no-block restart nomarchy-battery-charge-limit.service"
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ in
|
|||||||
{
|
{
|
||||||
options.nomarchy.services = {
|
options.nomarchy.services = {
|
||||||
tailscale.enable = lib.mkEnableOption ''
|
tailscale.enable = lib.mkEnableOption ''
|
||||||
Tailscale, the mesh VPN — ships the daemon; authenticate once with
|
Tailscale, the mesh VPN — ships the daemon and makes the login user its
|
||||||
`sudo tailscale up`'';
|
operator, so `tailscale up/down/set` and the System → VPN menu work
|
||||||
|
without sudo. Authenticate once (the menu's Connect, or `tailscale up`)'';
|
||||||
|
|
||||||
syncthing.enable = lib.mkEnableOption ''
|
syncthing.enable = lib.mkEnableOption ''
|
||||||
Syncthing continuous file sync, running as the login user — add
|
Syncthing continuous file sync, running as the login user — add
|
||||||
@@ -75,7 +76,8 @@ in
|
|||||||
|
|
||||||
printing.enable = lib.mkEnableOption ''
|
printing.enable = lib.mkEnableOption ''
|
||||||
CUPS printing with Avahi/mDNS, so network printers are auto-discovered
|
CUPS printing with Avahi/mDNS, so network printers are auto-discovered
|
||||||
(add vendor drivers via `services.printing.drivers`)'';
|
(add vendor drivers via `services.printing.drivers`); the menu's
|
||||||
|
System ▸ Printers entry opens the system-config-printer GUI'' // { default = config.nomarchy.settings.printing.enable or false; };
|
||||||
|
|
||||||
openrgb.enable = lib.mkEnableOption ''
|
openrgb.enable = lib.mkEnableOption ''
|
||||||
the OpenRGB daemon and GUI for controlling RGB lighting on peripherals
|
the OpenRGB daemon and GUI for controlling RGB lighting on peripherals
|
||||||
@@ -124,6 +126,12 @@ in
|
|||||||
config = lib.mkMerge [
|
config = lib.mkMerge [
|
||||||
(lib.mkIf cfg.tailscale.enable {
|
(lib.mkIf cfg.tailscale.enable {
|
||||||
services.tailscale.enable = true;
|
services.tailscale.enable = true;
|
||||||
|
# Let the login user drive tailscale (up/down/set — and so the VPN menu's
|
||||||
|
# Tailscale controls) without sudo. It's already in wheel, so the operator
|
||||||
|
# grant is no real new privilege, just skips the password prompt. The
|
||||||
|
# module's tailscaled-set unit applies this after the daemon starts.
|
||||||
|
# mkDefault so a downstream can drop or replace it (e.g. extraSetFlags = []).
|
||||||
|
services.tailscale.extraSetFlags = lib.mkDefault [ "--operator=${args.username}" ];
|
||||||
})
|
})
|
||||||
|
|
||||||
(lib.mkIf cfg.syncthing.enable {
|
(lib.mkIf cfg.syncthing.enable {
|
||||||
@@ -246,6 +254,9 @@ in
|
|||||||
nssmdns4 = true;
|
nssmdns4 = true;
|
||||||
openFirewall = true;
|
openFirewall = true;
|
||||||
};
|
};
|
||||||
|
# The CUPS admin GUI — the menu's System ▸ Printers entry execs it
|
||||||
|
# (self-gated on this binary), so it ships with the printing service.
|
||||||
|
environment.systemPackages = [ pkgs.system-config-printer ];
|
||||||
})
|
})
|
||||||
|
|
||||||
(lib.mkIf cfg.openrgb.enable {
|
(lib.mkIf cfg.openrgb.enable {
|
||||||
|
|||||||
87
modules/nixos/timezone.nix
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Automatic timezone detection (opt-in) — the system timezone, and so the
|
||||||
|
# Waybar clock, follows your location, so travelling to another zone updates
|
||||||
|
# the time on its own. Geoclue feeds `automatic-timezoned`, which drives
|
||||||
|
# /etc/localtime at runtime.
|
||||||
|
#
|
||||||
|
# In-flake state, menu-driven (the keyboard/night-light philosophy): the on/off
|
||||||
|
# flag lives in the same theme-state.json under `settings.autoTimezone`
|
||||||
|
# (git-tracked, reproducible), written by the System-menu toggle
|
||||||
|
# (nomarchy-autotimezone). Because this is a SYSTEM service — not a user unit it
|
||||||
|
# can start/stop instantly like night-light — the toggle drives a system rebuild
|
||||||
|
# (plus a home switch for the Waybar-refresh watcher in timezone.nix home-side).
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.nomarchy.system;
|
||||||
|
|
||||||
|
# Read the same state file the rest of the system side uses (Plymouth too),
|
||||||
|
# wired by lib.mkFlake. The flag defaults off when the file is absent/sparse.
|
||||||
|
state =
|
||||||
|
if cfg.stateFile != null
|
||||||
|
then builtins.fromJSON (builtins.readFile cfg.stateFile)
|
||||||
|
else { };
|
||||||
|
stateEnabled = (state.settings or { }).autoTimezone or false;
|
||||||
|
|
||||||
|
sync = lib.getExe pkgs.nomarchy-theme-sync;
|
||||||
|
|
||||||
|
# Menu/CLI toggle. Runs as the normal user (it owns the flake checkout +
|
||||||
|
# writes the state); sudos only the system switch, like sys-update. Writes
|
||||||
|
# the in-flake flag, then rebuilds: the system rebuild bakes the service +
|
||||||
|
# the time.timeZone override, the home switch installs/removes the Waybar
|
||||||
|
# refresh watcher — both read the same flag we just wrote.
|
||||||
|
nomarchy-autotimezone = pkgs.writeShellScriptBin "nomarchy-autotimezone" ''
|
||||||
|
set -e
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
echo "nomarchy-autotimezone: run as your normal user (it sudos the rebuild itself)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||||||
|
|
||||||
|
cur=$(${sync} get settings.autoTimezone 2>/dev/null) || cur=false
|
||||||
|
case "''${1:-toggle}" in
|
||||||
|
on) new=true ;;
|
||||||
|
off) new=false ;;
|
||||||
|
toggle) case "$cur" in true|True) new=false ;; *) new=true ;; esac ;;
|
||||||
|
status) echo "$cur"; exit 0 ;;
|
||||||
|
*) echo "usage: nomarchy-autotimezone [toggle|on|off|status]" >&2; exit 64 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
${sync} --quiet set settings.autoTimezone "$new" --no-switch
|
||||||
|
|
||||||
|
if [ "$new" = true ]; then
|
||||||
|
notify-send "Auto timezone" "Enabling — rebuilding the system…" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
notify-send "Auto timezone" "Disabling — rebuilding the system…" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo nixos-rebuild switch --flake "$flake#default"
|
||||||
|
home-manager switch --flake "$flake"
|
||||||
|
|
||||||
|
if [ "$new" = true ]; then
|
||||||
|
notify-send "Auto timezone on" "The clock now follows your location." 2>/dev/null || true
|
||||||
|
else
|
||||||
|
notify-send "Auto timezone off" "Back to the fixed timezone in system.nix." 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
{
|
||||||
|
config = lib.mkMerge [
|
||||||
|
{
|
||||||
|
# Shipped unconditionally so the menu can enable the feature even while
|
||||||
|
# it's off. Track the in-flake flag; mkDefault so a hand-set
|
||||||
|
# nomarchy.system.autoTimezone.enable in system.nix still wins.
|
||||||
|
environment.systemPackages = [ nomarchy-autotimezone ];
|
||||||
|
nomarchy.system.autoTimezone.enable = lib.mkDefault stateEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
(lib.mkIf cfg.autoTimezone.enable {
|
||||||
|
services.geoclue2.enable = true;
|
||||||
|
services.automatic-timezoned.enable = true;
|
||||||
|
# A runtime timezone needs /etc/localtime writable. automatic-timezoned
|
||||||
|
# sets time.timeZone = null itself, but the installer writes a static
|
||||||
|
# value at normal priority, which would collide (a hard eval error) —
|
||||||
|
# mkForce null overrides both and resolves cleanly.
|
||||||
|
time.timeZone = lib.mkForce null;
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
12
pkgs/nomarchy-battery-notify/default.nix
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Low-battery notification watcher (nomarchy.batteryNotify). A package
|
||||||
|
# (not an inline script) so the VM check can exercise the crossing logic
|
||||||
|
# on a minimal node against a test_power fake battery — same reasoning
|
||||||
|
# as nomarchy-doctor. libnotify is deliberately NOT a runtimeInput: the
|
||||||
|
# user unit puts it on PATH; the check shims notify-send instead.
|
||||||
|
{ writeShellApplication, coreutils }:
|
||||||
|
|
||||||
|
writeShellApplication {
|
||||||
|
name = "nomarchy-battery-notify";
|
||||||
|
runtimeInputs = [ coreutils ];
|
||||||
|
text = builtins.readFile ./nomarchy-battery-notify.sh;
|
||||||
|
}
|
||||||
58
pkgs/nomarchy-battery-notify/nomarchy-battery-notify.sh
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Low-battery notifications — the session-side complement to the bar's
|
||||||
|
# battery colors: Waybar paints the module @warn at 25% and @bad at 10%
|
||||||
|
# (waybar.nix battery.states) but nothing *notified*. This watcher polls
|
||||||
|
# the same sysfs state the bar reads and fires exactly one notification
|
||||||
|
# per downward crossing — normal at 25%, critical at 10% (swaync keeps
|
||||||
|
# critical toasts up until dismissed) — re-arming once the charger lands.
|
||||||
|
#
|
||||||
|
# notify-send is resolved from PATH on purpose (not a runtimeInput): the
|
||||||
|
# unit (battery-notify.nix) provides libnotify; the VM check shims it to
|
||||||
|
# capture the calls.
|
||||||
|
#
|
||||||
|
# usage: nomarchy-battery-notify [poll-interval-seconds]
|
||||||
|
|
||||||
|
interval="${1:-30}"
|
||||||
|
warn=25
|
||||||
|
crit=10
|
||||||
|
|
||||||
|
# System batteries only: type Battery, and not scope=Device (how
|
||||||
|
# peripheral batteries — mice, headsets — report). Name-agnostic on
|
||||||
|
# purpose: BAT0, dual-battery BAT0+BAT1, CMB0, test_battery all match.
|
||||||
|
batteries() {
|
||||||
|
local d
|
||||||
|
for d in /sys/class/power_supply/*/; do
|
||||||
|
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||||||
|
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||||||
|
printf '%s\n' "$d"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Self-gate: no battery, not a laptop — a clean no-op on desktops.
|
||||||
|
[ -n "$(batteries)" ] || exit 0
|
||||||
|
|
||||||
|
state=ok # ok → warn → crit, one notification per downward crossing
|
||||||
|
while :; do
|
||||||
|
sum=0 n=0 discharging=
|
||||||
|
while IFS= read -r d; do
|
||||||
|
cap=$(cat "$d/capacity" 2>/dev/null) || continue
|
||||||
|
sum=$((sum + cap)); n=$((n + 1))
|
||||||
|
[ "$(cat "$d/status" 2>/dev/null)" = Discharging ] && discharging=1
|
||||||
|
done < <(batteries)
|
||||||
|
|
||||||
|
if [ "$n" -gt 0 ] && [ -n "$discharging" ]; then
|
||||||
|
cap=$((sum / n))
|
||||||
|
if [ "$cap" -le "$crit" ] && [ "$state" != crit ]; then
|
||||||
|
notify-send -u critical -a Nomarchy "Battery critical: ${cap}%" \
|
||||||
|
"Plug in now."
|
||||||
|
state=crit
|
||||||
|
elif [ "$cap" -le "$warn" ] && [ "$state" = ok ]; then
|
||||||
|
notify-send -u normal -a Nomarchy "Battery low: ${cap}%" \
|
||||||
|
"Consider plugging in."
|
||||||
|
state=warn
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# On the charger (or nothing readable): re-arm for the next drain.
|
||||||
|
state=ok
|
||||||
|
fi
|
||||||
|
sleep "$interval"
|
||||||
|
done
|
||||||
7
pkgs/nomarchy-control-center/default.nix
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{ writeShellApplication, coreutils, gawk, jq, gum }:
|
||||||
|
|
||||||
|
writeShellApplication {
|
||||||
|
name = "nomarchy-control-center";
|
||||||
|
runtimeInputs = [ coreutils gawk jq gum ];
|
||||||
|
text = builtins.readFile ./nomarchy-control-center.sh;
|
||||||
|
}
|
||||||
227
pkgs/nomarchy-control-center/nomarchy-control-center.sh
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Nomarchy Control Center — TUI frontend over nomarchy-theme-sync and tools
|
||||||
|
# Uses charmbracelet/gum for rendering.
|
||||||
|
|
||||||
|
trap 'exit 0' SIGINT
|
||||||
|
|
||||||
|
function get_state() {
|
||||||
|
nomarchy-theme-sync get "$1" 2>/dev/null || echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function set_state() {
|
||||||
|
nomarchy-theme-sync --quiet set "$1" "$2" --no-switch
|
||||||
|
}
|
||||||
|
|
||||||
|
function first_boot() {
|
||||||
|
gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "Welcome to Nomarchy!"
|
||||||
|
|
||||||
|
echo "Pick a Theme Preset:"
|
||||||
|
themes=$(nomarchy-theme-sync list)
|
||||||
|
chosen_theme=$(printf "%s" "$themes" | gum choose)
|
||||||
|
if [ -n "$chosen_theme" ]; then
|
||||||
|
echo "Applying theme $chosen_theme..."
|
||||||
|
nomarchy-theme-sync apply "$chosen_theme"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if gum confirm "Enable auto-commit for settings?"; then
|
||||||
|
set_state "settings.autoCommit" "true"
|
||||||
|
else
|
||||||
|
set_state "settings.autoCommit" "false"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v nomarchy-autotimezone >/dev/null 2>&1; then
|
||||||
|
if gum confirm "Enable automatic timezone detection?"; then
|
||||||
|
set_state "settings.autoTimezone" "true"
|
||||||
|
else
|
||||||
|
set_state "settings.autoTimezone" "false"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
gum style --foreground 212 "First boot configuration complete!"
|
||||||
|
echo "You can always change these later in the Control Center."
|
||||||
|
read -r -n 1 -s -p "Press any key to exit..."
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
function main_menu() {
|
||||||
|
while true; do
|
||||||
|
choice=$(gum choose "Appearance" "System Toggles" "Health (Doctor)" "Rollback" "Exit")
|
||||||
|
case "$choice" in
|
||||||
|
"Appearance") appearance_menu ;;
|
||||||
|
"System Toggles") toggles_menu ;;
|
||||||
|
"Health (Doctor)")
|
||||||
|
if command -v nomarchy-doctor >/dev/null 2>&1; then
|
||||||
|
nomarchy-doctor || true
|
||||||
|
else
|
||||||
|
echo "nomarchy-doctor not found."
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
read -r -n 1 -s -p "Press any key to return..."
|
||||||
|
echo
|
||||||
|
;;
|
||||||
|
"Rollback")
|
||||||
|
gens=$(home-manager generations 2>/dev/null | head -10)
|
||||||
|
if [ -z "$gens" ]; then
|
||||||
|
echo "No Home Manager generations found."
|
||||||
|
else
|
||||||
|
printf "%s\n" "$gens" | awk '{ printf "Desktop gen %s — %s %s%s\n", $5, $1, $2, (NR==1 ? " (current)" : "") }' > /tmp/nomarchy-gens
|
||||||
|
sel=$(gum choose < /tmp/nomarchy-gens || true)
|
||||||
|
if [ -n "$sel" ]; then
|
||||||
|
num=${sel#Desktop gen }
|
||||||
|
num=${num%% *}
|
||||||
|
path=$(printf "%s\n" "$gens" | awk -v n="$num" '$5 == n { print $7 }')
|
||||||
|
if [ -x "$path/activate" ]; then
|
||||||
|
echo "Activating Home Manager generation $num..."
|
||||||
|
"$path/activate"
|
||||||
|
echo "Desktop rolled back to generation $num."
|
||||||
|
else
|
||||||
|
echo "Generation $num not found."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
read -r -n 1 -s -p "Press any key to return..."
|
||||||
|
echo
|
||||||
|
;;
|
||||||
|
"Exit"|*) break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
function appearance_menu() {
|
||||||
|
while true; do
|
||||||
|
choice=$(gum choose "Pick Theme" "Toggle Blur" "Set Gaps" "Back")
|
||||||
|
case "$choice" in
|
||||||
|
"Pick Theme")
|
||||||
|
themes=$(nomarchy-theme-sync list)
|
||||||
|
chosen=$(printf "%s" "$themes" | gum choose)
|
||||||
|
if [ -n "$chosen" ]; then
|
||||||
|
nomarchy-theme-sync apply "$chosen"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"Toggle Blur")
|
||||||
|
blur=$(get_state "ui.blur")
|
||||||
|
if [ "$blur" = "true" ]; then
|
||||||
|
set_state "ui.blur" "false"
|
||||||
|
else
|
||||||
|
set_state "ui.blur" "true"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"Set Gaps")
|
||||||
|
gaps=$(gum input --prompt "Enter gaps in pixels: " --placeholder "$(get_state ui.gapsOut)")
|
||||||
|
if [ -n "$gaps" ]; then
|
||||||
|
set_state "ui.gapsOut" "$gaps"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"Back"|*) break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggles_menu() {
|
||||||
|
while true; do
|
||||||
|
choice=$(gum choose "Auto-commit" "Auto-timezone" "Night Light" "Updates" "Bluetooth" "Printing" "Terminal" "Keyboard Layout" "Auto-Login" "Back")
|
||||||
|
case "$choice" in
|
||||||
|
"Auto-commit")
|
||||||
|
cur=$(get_state "settings.autoCommit")
|
||||||
|
if [ "$cur" = "true" ]; then
|
||||||
|
set_state "settings.autoCommit" "false"
|
||||||
|
echo "Auto-commit disabled."
|
||||||
|
else
|
||||||
|
set_state "settings.autoCommit" "true"
|
||||||
|
echo "Auto-commit enabled."
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
;;
|
||||||
|
"Auto-timezone")
|
||||||
|
cur=$(get_state "settings.autoTimezone")
|
||||||
|
if [ "$cur" = "true" ]; then
|
||||||
|
set_state "settings.autoTimezone" "false"
|
||||||
|
echo "Auto-timezone disabled."
|
||||||
|
else
|
||||||
|
set_state "settings.autoTimezone" "true"
|
||||||
|
echo "Auto-timezone enabled."
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
;;
|
||||||
|
"Night Light")
|
||||||
|
if command -v nomarchy-nightlight >/dev/null 2>&1; then
|
||||||
|
nomarchy-nightlight toggle
|
||||||
|
sleep 1
|
||||||
|
else
|
||||||
|
echo "nomarchy-nightlight not found."
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"Updates")
|
||||||
|
cur=$(get_state "settings.updates.enable")
|
||||||
|
if [ "$cur" = "true" ]; then
|
||||||
|
set_state "settings.updates.enable" "false"
|
||||||
|
echo "Updates checker disabled (requires rebuild)."
|
||||||
|
else
|
||||||
|
set_state "settings.updates.enable" "true"
|
||||||
|
echo "Updates checker enabled (requires rebuild)."
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
;;
|
||||||
|
"Bluetooth")
|
||||||
|
cur=$(get_state "settings.bluetooth.enable")
|
||||||
|
if [ "$cur" = "true" ]; then
|
||||||
|
set_state "settings.bluetooth.enable" "false"
|
||||||
|
echo "Bluetooth disabled (requires rebuild)."
|
||||||
|
else
|
||||||
|
set_state "settings.bluetooth.enable" "true"
|
||||||
|
echo "Bluetooth enabled (requires rebuild)."
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
;;
|
||||||
|
"Printing")
|
||||||
|
cur=$(get_state "settings.printing.enable")
|
||||||
|
if [ "$cur" = "true" ]; then
|
||||||
|
set_state "settings.printing.enable" "false"
|
||||||
|
echo "Printing services disabled (requires rebuild)."
|
||||||
|
else
|
||||||
|
set_state "settings.printing.enable" "true"
|
||||||
|
echo "Printing services enabled (requires rebuild)."
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
;;
|
||||||
|
"Terminal")
|
||||||
|
term=$(gum choose "ghostty" "kitty" "alacritty" "wezterm" "foot")
|
||||||
|
if [ -n "$term" ]; then
|
||||||
|
set_state "settings.terminal" "\"$term\""
|
||||||
|
echo "Terminal set to $term (requires rebuild)."
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
;;
|
||||||
|
"Keyboard Layout")
|
||||||
|
layout=$(gum input --prompt "Enter keyboard layout (e.g. us, de, es): " --placeholder "$(get_state settings.keyboard.layout | tr -d '"')")
|
||||||
|
if [ -n "$layout" ]; then
|
||||||
|
set_state "settings.keyboard.layout" "\"$layout\""
|
||||||
|
echo "Keyboard layout set to $layout (requires rebuild)."
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
;;
|
||||||
|
"Auto-Login")
|
||||||
|
user=$(gum input --prompt "Enter auto-login username (or empty to disable): " --placeholder "$(get_state settings.greeter.autoLogin | tr -d '"')")
|
||||||
|
if [ -n "$user" ]; then
|
||||||
|
set_state "settings.greeter.autoLogin" "\"$user\""
|
||||||
|
echo "Auto-login set to $user (requires rebuild)."
|
||||||
|
else
|
||||||
|
set_state "settings.greeter.autoLogin" "null"
|
||||||
|
echo "Auto-login disabled (requires rebuild)."
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
;;
|
||||||
|
"Back"|*) break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--first-boot" ]; then
|
||||||
|
first_boot
|
||||||
|
else
|
||||||
|
main_menu
|
||||||
|
fi
|
||||||
10
pkgs/nomarchy-doctor/default.nix
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# One-shot read-only health check (System › Doctor / `nomarchy-doctor`).
|
||||||
|
# A package (not an inline script) so the VM check can exercise it on a
|
||||||
|
# minimal node without pulling in the whole distro module.
|
||||||
|
{ writeShellApplication, coreutils, gawk, git, gnugrep, jq, systemd }:
|
||||||
|
|
||||||
|
writeShellApplication {
|
||||||
|
name = "nomarchy-doctor";
|
||||||
|
runtimeInputs = [ coreutils gawk git gnugrep jq systemd ];
|
||||||
|
text = builtins.readFile ./nomarchy-doctor.sh;
|
||||||
|
}
|
||||||
138
pkgs/nomarchy-doctor/nomarchy-doctor.sh
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# nomarchy-doctor — one-shot, READ-ONLY health check: the things that
|
||||||
|
# actually break user machines, as a pass/fail sheet where every
|
||||||
|
# failure prints the one command that fixes it. It never changes
|
||||||
|
# anything itself. Exit 0 = no failures (warnings allowed), 1 = at
|
||||||
|
# least one ✖.
|
||||||
|
|
||||||
|
flake="${NOMARCHY_PATH:-$HOME/.nomarchy}"
|
||||||
|
fails=0
|
||||||
|
warns=0
|
||||||
|
|
||||||
|
grn=$'\033[32m'; red=$'\033[31m'; ylw=$'\033[33m'; dim=$'\033[2m'; rst=$'\033[0m'
|
||||||
|
ok() { printf ' %b✔%b %s\n' "$grn" "$rst" "$1"; }
|
||||||
|
bad() { printf ' %b✖%b %s\n' "$red" "$rst" "$1"
|
||||||
|
printf ' %bfix: %s%b\n' "$dim" "$2" "$rst"
|
||||||
|
fails=$((fails + 1)); }
|
||||||
|
warn() { printf ' %b●%b %s\n' "$ylw" "$rst" "$1"
|
||||||
|
if [ $# -gt 1 ]; then printf ' %b%s%b\n' "$dim" "$2" "$rst"; fi
|
||||||
|
warns=$((warns + 1)); }
|
||||||
|
skip() { printf ' %b– %s%b\n' "$dim" "$1" "$rst"; }
|
||||||
|
|
||||||
|
printf 'nomarchy-doctor — %s\n\n' "$(date '+%Y-%m-%d %H:%M')"
|
||||||
|
|
||||||
|
# ── systemd units ────────────────────────────────────────────────────
|
||||||
|
mapfile -t sysfailed < <(systemctl --failed --no-legend --plain 2>/dev/null | awk '{print $1}')
|
||||||
|
if [ "${#sysfailed[@]}" -eq 0 ]; then
|
||||||
|
ok "no failed system units"
|
||||||
|
else
|
||||||
|
bad "failed system unit(s): ${sysfailed[*]}" \
|
||||||
|
"journalctl -b -u <unit> (read why; sys-rebuild after fixing)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A user bus only exists inside a session (not over bare SSH/root).
|
||||||
|
if [ -n "$(systemctl --user is-system-running 2>/dev/null || true)" ]; then
|
||||||
|
mapfile -t usrfailed < <(systemctl --user --failed --no-legend --plain 2>/dev/null | awk '{print $1}')
|
||||||
|
if [ "${#usrfailed[@]}" -eq 0 ]; then
|
||||||
|
ok "no failed user units"
|
||||||
|
else
|
||||||
|
bad "failed user unit(s): ${usrfailed[*]}" \
|
||||||
|
"journalctl --user -b -u <unit>"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "user units (no user session bus here)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── disk space ───────────────────────────────────────────────────────
|
||||||
|
# Only real on-disk filesystems; skips the tmpfs/9p/overlay mounts of
|
||||||
|
# live systems and test VMs (where usage numbers mean nothing).
|
||||||
|
seen=""
|
||||||
|
for mp in / /boot /nix; do
|
||||||
|
[ -d "$mp" ] || continue
|
||||||
|
line=$(df -PT "$mp" 2>/dev/null | awk 'NR==2 {gsub("%","",$6); print $1, $2, $6, $5}')
|
||||||
|
[ -n "$line" ] || continue
|
||||||
|
read -r dev fstype use availkb <<<"$line"
|
||||||
|
case "$fstype" in
|
||||||
|
ext2|ext3|ext4|btrfs|xfs|vfat|f2fs|zfs) ;;
|
||||||
|
*) continue ;;
|
||||||
|
esac
|
||||||
|
case " $seen " in *" $dev "*) continue ;; esac
|
||||||
|
seen="$seen $dev"
|
||||||
|
avail=$(numfmt --to=iec $((availkb * 1024)))
|
||||||
|
if [ "$use" -ge 90 ]; then
|
||||||
|
bad "$mp is ${use}% full (${avail} free)" \
|
||||||
|
"sudo nix-collect-garbage --delete-older-than 14d (then sys-rebuild to prune old boot entries)"
|
||||||
|
else
|
||||||
|
ok "$mp has space (${use}% used, ${avail} free)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── the flake + state file ───────────────────────────────────────────
|
||||||
|
if [ -f "$flake/theme-state.json" ]; then
|
||||||
|
if jq empty "$flake/theme-state.json" 2>/dev/null; then
|
||||||
|
ok "theme-state.json parses"
|
||||||
|
else
|
||||||
|
bad "theme-state.json is not valid JSON (rebuilds will fail)" \
|
||||||
|
"nomarchy-theme-sync validate (names the spot; fix it, or re-apply a theme)"
|
||||||
|
fi
|
||||||
|
if git -C "$flake" ls-files --error-unmatch theme-state.json >/dev/null 2>&1; then
|
||||||
|
ok "theme-state.json is git-tracked"
|
||||||
|
else
|
||||||
|
bad "theme-state.json is NOT git-tracked (flake evaluation can't see it)" \
|
||||||
|
"git -C $flake add theme-state.json"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "theme-state.json (no flake checkout at $flake)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "$flake/.git" ]; then
|
||||||
|
dirty=$(git -C "$flake" status --porcelain 2>/dev/null | wc -l)
|
||||||
|
if [ "$dirty" -gt 0 ]; then
|
||||||
|
warn "$dirty uncommitted change(s) in $flake (normal — theme writes land there)" \
|
||||||
|
"commit when happy: git -C $flake add -A && git -C $flake commit -m settings"
|
||||||
|
else
|
||||||
|
ok "flake checkout is clean"
|
||||||
|
fi
|
||||||
|
behind=$(git -C "$flake" rev-list --count 'HEAD..@{u}' 2>/dev/null || echo 0)
|
||||||
|
if [ "$behind" -gt 0 ]; then
|
||||||
|
warn "flake is $behind commit(s) behind its upstream" "git -C $flake pull, then sys-rebuild + home-update"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── generation age ───────────────────────────────────────────────────
|
||||||
|
# The profile SYMLINK's own mtime is the generation's creation time
|
||||||
|
# (store paths themselves are all epoch-1).
|
||||||
|
link=$(readlink /nix/var/nix/profiles/system 2>/dev/null || true)
|
||||||
|
if [ -n "$link" ] && [ -e "/nix/var/nix/profiles/$link" ]; then
|
||||||
|
ts=$(stat -c %Y "/nix/var/nix/profiles/$link" 2>/dev/null || echo 0)
|
||||||
|
days=$(( ($(date +%s) - ts) / 86400 ))
|
||||||
|
if [ "$days" -gt 30 ]; then
|
||||||
|
warn "last system rebuild was $days days ago" \
|
||||||
|
"sys-update when convenient (security fixes arrive with input bumps)"
|
||||||
|
else
|
||||||
|
ok "system generation is $days day(s) old"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "system generation age (no system profile)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── snapper timeline (when enabled) ──────────────────────────────────
|
||||||
|
if [ -n "$(systemctl list-unit-files snapper-timeline.timer --no-legend --plain 2>/dev/null)" ]; then
|
||||||
|
if systemctl is-active --quiet snapper-timeline.timer; then
|
||||||
|
ok "snapper timeline snapshots are running"
|
||||||
|
else
|
||||||
|
bad "snapper is enabled but the timeline timer is not active" \
|
||||||
|
"systemctl status snapper-timeline.timer (then journalctl -u snapper-timeline.service)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "snapper (not enabled on this machine)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── verdict ──────────────────────────────────────────────────────────
|
||||||
|
echo
|
||||||
|
if [ "$fails" -eq 0 ]; then
|
||||||
|
printf '%b✔ healthy%b — %d warning(s)\n' "$grn" "$rst" "$warns"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
printf '%b✖ %d problem(s)%b, %d warning(s) — each ✖ above shows its fix\n' "$red" "$fails" "$rst" "$warns"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -112,6 +112,7 @@ HARDWARE_DB=(
|
|||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
nomarchy_detect_hw() {
|
nomarchy_detect_hw() {
|
||||||
local sys_vendor product_name cpu_vendor
|
local sys_vendor product_name cpu_vendor
|
||||||
|
local nvidia=0 amdgpu=0 intelgpu=0
|
||||||
sys_vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || echo "")
|
sys_vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || echo "")
|
||||||
product_name=$(cat /sys/class/dmi/id/product_name 2>/dev/null || echo "")
|
product_name=$(cat /sys/class/dmi/id/product_name 2>/dev/null || echo "")
|
||||||
cpu_vendor=$(lscpu 2>/dev/null | awk -F: '/Vendor ID/{gsub(/ /,"",$2); print $2; exit}')
|
cpu_vendor=$(lscpu 2>/dev/null | awk -F: '/Vendor ID/{gsub(/ /,"",$2); print $2; exit}')
|
||||||
@@ -126,7 +127,7 @@ nomarchy_detect_hw() {
|
|||||||
|
|
||||||
# GPU (lspci may list several; report all)
|
# GPU (lspci may list several; report all)
|
||||||
if command -v lspci >/dev/null 2>&1; then
|
if command -v lspci >/dev/null 2>&1; then
|
||||||
local gpu_line nvidia=0 amdgpu=0 intelgpu=0
|
local gpu_line
|
||||||
while IFS= read -r gpu_line; do
|
while IFS= read -r gpu_line; do
|
||||||
case "$gpu_line" in
|
case "$gpu_line" in
|
||||||
*"[10de:"*|*"NVIDIA"*) nvidia=1 ;;
|
*"[10de:"*|*"NVIDIA"*) nvidia=1 ;;
|
||||||
@@ -140,6 +141,92 @@ nomarchy_detect_hw() {
|
|||||||
(( intelgpu )) && { echo "MODULE common-gpu-intel"; echo "DETAIL gpu: Intel"; detected=1; }
|
(( intelgpu )) && { echo "MODULE common-gpu-intel"; echo "DETAIL gpu: Intel"; detected=1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── nomarchy.hardware.* enablement — the gap ABOVE the nixos-hardware
|
||||||
|
# commons (GuC/HuC, amd-pstate, the AMD VA-API env, GPU-compute runtimes,
|
||||||
|
# fprintd, the NPU driver). Emitted as NOMARCHY lines the installer turns
|
||||||
|
# into nomarchy.hardware.* in system.nix; safe bits active, heavy opt-ins
|
||||||
|
# commented.
|
||||||
|
if [[ "$cpu_vendor" == "GenuineIntel" || $intelgpu -eq 1 ]]; then
|
||||||
|
echo "NOMARCHY hardware.intel.enable=true"
|
||||||
|
echo "DETAIL nomarchy.hardware.intel: GuC/HuC on; GPU-compute runtime opt-in"
|
||||||
|
# i915.enable_guc applies to the i915 driver only. The newer `xe` driver
|
||||||
|
# (Lunar Lake / Battlemage / Panther Lake, recent Xe GPUs) enables GuC by
|
||||||
|
# default and ignores the param — so turn the toggle off when xe is bound.
|
||||||
|
if lspci -k 2>/dev/null | grep -qiE 'driver in use: xe\b'; then
|
||||||
|
echo "NOMARCHY hardware.intel.guc=false"
|
||||||
|
echo "DETAIL Intel GPU on the xe driver → GuC default-on (i915 param skipped)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "$cpu_vendor" == "AuthenticAMD" || $amdgpu -eq 1 ]]; then
|
||||||
|
echo "NOMARCHY hardware.amd.enable=true"
|
||||||
|
echo "DETAIL nomarchy.hardware.amd: amd-pstate + VA-API on; ROCm opt-in"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fingerprint reader — libfprint's common USB vendor IDs (Goodix,
|
||||||
|
# Synaptics/Validity, Elan, EgisTec, Upek, AuthenTec, FocalTech, NB).
|
||||||
|
local have_fp=0
|
||||||
|
if command -v lsusb >/dev/null 2>&1; then
|
||||||
|
local vid
|
||||||
|
for vid in 27c6 06cb 138a 04f3 1c7a 147e 08ff 2808 1fae; do
|
||||||
|
lsusb 2>/dev/null | grep -qiE "ID ${vid}:" && { have_fp=1; break; }
|
||||||
|
done
|
||||||
|
else
|
||||||
|
local f
|
||||||
|
for f in /sys/bus/usb/devices/*/idVendor; do
|
||||||
|
[[ -e "$f" ]] || continue
|
||||||
|
case "$(cat "$f" 2>/dev/null)" in
|
||||||
|
27c6|06cb|138a|04f3|1c7a|147e|08ff|2808|1fae) have_fp=1; break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [[ $have_fp -eq 1 ]]; then
|
||||||
|
echo "NOMARCHY hardware.fingerprint.enable=true"
|
||||||
|
echo "DETAIL fingerprint reader detected → fprintd (PAM login/sudo opt-in)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Dual-sensor webcam (RGB + IR face-unlock). Such modules expose the IR
|
||||||
|
# sensor as a SECOND, identically-named "Integrated Camera"; an app that
|
||||||
|
# picks it shows a dark, 8-bit-greyscale image. When an IR-companion node
|
||||||
|
# sits alongside a normal camera node, enable hiding the IR one from
|
||||||
|
# PipeWire (the colour camera stays; the kernel /dev/video* is untouched, so
|
||||||
|
# Howdy can still read the IR sensor directly). Read from /sys — no
|
||||||
|
# v4l2-ctl needed in the installer env.
|
||||||
|
local cam_name have_ir_cam=0 have_color_cam=0 vf
|
||||||
|
local ir_re='Integrated I|IR Camera|Infrared'
|
||||||
|
for vf in /sys/class/video4linux/video*/name; do
|
||||||
|
[[ -e "$vf" ]] || continue
|
||||||
|
cam_name=$(cat "$vf" 2>/dev/null)
|
||||||
|
shopt -s nocasematch
|
||||||
|
if [[ "$cam_name" =~ $ir_re ]]; then
|
||||||
|
have_ir_cam=1
|
||||||
|
elif [[ "$cam_name" =~ (Camera|Webcam) ]]; then
|
||||||
|
have_color_cam=1
|
||||||
|
fi
|
||||||
|
shopt -u nocasematch
|
||||||
|
done
|
||||||
|
if [[ $have_ir_cam -eq 1 && $have_color_cam -eq 1 ]]; then
|
||||||
|
echo "NOMARCHY hardware.camera.hideIrSensor=true"
|
||||||
|
echo "DETAIL dual-sensor webcam (RGB+IR) → hide the IR node so apps get the colour camera"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# NPU (detect-only → a commented, experimental opt-in). Match the PCI
|
||||||
|
# "Processing accelerators" class [1200] (or accelerator keywords) and
|
||||||
|
# attribute by vendor — future-proof vs a per-device-ID list, so new gens
|
||||||
|
# (e.g. Panther Lake) are caught without a code change. Known IDs kept for
|
||||||
|
# reference: Intel VPU 8086:{7d1d,643e,ad1d,b03e}, AMD XDNA 1022:1502.
|
||||||
|
if command -v lspci >/dev/null 2>&1; then
|
||||||
|
local npu_line
|
||||||
|
npu_line=$(lspci -nn 2>/dev/null \
|
||||||
|
| grep -iE '\[1200\]|processing accelerat|neural|\[8086:(7d1d|643e|ad1d|b03e)\]|\[1022:1502\]' \
|
||||||
|
| head -1)
|
||||||
|
case "$npu_line" in
|
||||||
|
*"[8086:"*|*Intel*)
|
||||||
|
echo "NOMARCHY-NPU intel"; echo "DETAIL Intel NPU detected (opt-in, experimental)" ;;
|
||||||
|
*"[1022:"*|*"Advanced Micro Devices"*|*AMD*)
|
||||||
|
echo "NOMARCHY-NPU amd"; echo "DETAIL AMD XDNA NPU detected (opt-in, experimental)" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
# Chassis (glob test, not compgen — nixpkgs' non-interactive bash
|
# Chassis (glob test, not compgen — nixpkgs' non-interactive bash
|
||||||
# is built without the completion builtins)
|
# is built without the completion builtins)
|
||||||
local bats=(/sys/class/power_supply/BAT*)
|
local bats=(/sys/class/power_supply/BAT*)
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ if [[ $EUID -ne 0 ]]; then
|
|||||||
exec sudo --preserve-env "$0" "$@"
|
exec sudo --preserve-env "$0" "$@"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# `sudo --preserve-env` (needed to carry the NOMARCHY_* vars) also drags in
|
||||||
|
# the live session user's HOME=/home/nomarchy. Root-run `nix` calls below
|
||||||
|
# would then scribble an eval cache + .nix-defexpr into /home/nomarchy —
|
||||||
|
# and the in-chroot one lands on the TARGET disk as a stray, orphaned
|
||||||
|
# /home/nomarchy (no such user on the installed system). Pin root's own
|
||||||
|
# HOME so every root nix invocation stays in /root; the user activation
|
||||||
|
# sets HOME=/home/$USERNAME explicitly and is unaffected.
|
||||||
|
export HOME=/root
|
||||||
|
|
||||||
header "Nomarchy installer" "NixOS, themed and ready to go."
|
header "Nomarchy installer" "NixOS, themed and ready to go."
|
||||||
|
|
||||||
[[ -d /sys/firmware/efi ]] \
|
[[ -d /sys/firmware/efi ]] \
|
||||||
@@ -210,6 +219,8 @@ section "Hardware detection"
|
|||||||
source "$SHARE/hardware-db.sh"
|
source "$SHARE/hardware-db.sh"
|
||||||
|
|
||||||
HW_PROFILES=()
|
HW_PROFILES=()
|
||||||
|
HW_NOMARCHY=() # NOMARCHY hardware.* assignments from detection
|
||||||
|
NPU_VENDOR="" # "intel" | "amd" if an NPU was detected (commented opt-in)
|
||||||
hw_mode="${NOMARCHY_HW:-auto}"
|
hw_mode="${NOMARCHY_HW:-auto}"
|
||||||
if [[ "$hw_mode" == "none" ]]; then
|
if [[ "$hw_mode" == "none" ]]; then
|
||||||
info "Hardware profiles skipped."
|
info "Hardware profiles skipped."
|
||||||
@@ -220,8 +231,10 @@ else
|
|||||||
if [[ -n "$detection" ]]; then
|
if [[ -n "$detection" ]]; then
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
case "$line" in
|
case "$line" in
|
||||||
MODULE\ *) HW_PROFILES+=("${line#MODULE }") ;;
|
MODULE\ *) HW_PROFILES+=("${line#MODULE }") ;;
|
||||||
DETAIL\ *) info "→ ${line#DETAIL }" ;;
|
NOMARCHY-NPU\ *) NPU_VENDOR="${line#NOMARCHY-NPU }" ;;
|
||||||
|
NOMARCHY\ *) HW_NOMARCHY+=("${line#NOMARCHY }") ;;
|
||||||
|
DETAIL\ *) info "→ ${line#DETAIL }" ;;
|
||||||
esac
|
esac
|
||||||
done <<< "$detection"
|
done <<< "$detection"
|
||||||
fi
|
fi
|
||||||
@@ -428,6 +441,62 @@ NIX
|
|||||||
)
|
)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Hardware enablement (nomarchy.hardware.*): what hardware-db.sh detected
|
||||||
|
# above the nixos-hardware commons. Safe defaults active; the heavier or
|
||||||
|
# experimental opt-ins written commented for the user to flip on.
|
||||||
|
HARDWARE_CONFIG=""
|
||||||
|
if [[ ${#HW_NOMARCHY[@]} -gt 0 || -n "$NPU_VENDOR" ]]; then
|
||||||
|
has_intel=0; has_amd=0; has_fp=0; intel_guc_off=0; has_camera_ir=0
|
||||||
|
if [[ ${#HW_NOMARCHY[@]} -gt 0 ]]; then
|
||||||
|
for nm in "${HW_NOMARCHY[@]}"; do
|
||||||
|
case "$nm" in
|
||||||
|
hardware.intel.enable=true) has_intel=1 ;;
|
||||||
|
hardware.intel.guc=false) intel_guc_off=1 ;;
|
||||||
|
hardware.amd.enable=true) has_amd=1 ;;
|
||||||
|
hardware.fingerprint.enable=true) has_fp=1 ;;
|
||||||
|
hardware.camera.hideIrSensor=true) has_camera_ir=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
hw_lines=" # Hardware enablement (auto-detected). Safe defaults are active;
|
||||||
|
# the heavier opt-ins are commented — uncomment to turn them on."
|
||||||
|
if [[ $has_intel -eq 1 ]]; then
|
||||||
|
hw_lines+="
|
||||||
|
nomarchy.hardware.intel.enable = true; # GuC/HuC firmware (i915.enable_guc=3)"
|
||||||
|
if [[ $intel_guc_off -eq 1 ]]; then
|
||||||
|
hw_lines+="
|
||||||
|
nomarchy.hardware.intel.guc = false; # GPU on the xe driver → GuC is default-on"
|
||||||
|
fi
|
||||||
|
hw_lines+="
|
||||||
|
# nomarchy.hardware.intel.computeRuntime = true; # OpenCL/oneVPL GPU compute (opt-in)"
|
||||||
|
fi
|
||||||
|
if [[ $has_amd -eq 1 ]]; then
|
||||||
|
hw_lines+="
|
||||||
|
nomarchy.hardware.amd.enable = true; # amd-pstate EPP + radeonsi VA-API
|
||||||
|
# nomarchy.hardware.amd.rocm.enable = true; # ROCm GPU compute (multi-GB, opt-in)
|
||||||
|
# nomarchy.hardware.amd.rocm.gfxOverride = \"\"; # e.g. \"11.0.0\" for an unlisted iGPU"
|
||||||
|
fi
|
||||||
|
if [[ $has_fp -eq 1 ]]; then
|
||||||
|
hw_lines+="
|
||||||
|
nomarchy.hardware.fingerprint.enable = true; # fprintd (enroll: fprintd-enroll)
|
||||||
|
# nomarchy.hardware.fingerprint.pam = true; # use it for login + sudo (opt-in)"
|
||||||
|
fi
|
||||||
|
if [[ $has_camera_ir -eq 1 ]]; then
|
||||||
|
hw_lines+="
|
||||||
|
nomarchy.hardware.camera.hideIrSensor = true; # dual-sensor webcam: hide the IR node (color cam only)"
|
||||||
|
fi
|
||||||
|
if [[ -n "$NPU_VENDOR" ]]; then
|
||||||
|
hw_lines+="
|
||||||
|
# nomarchy.hardware.npu.enable = true; # $NPU_VENDOR NPU driver (experimental; userspace runtime BYO)
|
||||||
|
# nomarchy.hardware.latestKernel = true; # newest kernel if the NPU driver isn't in the shipped one"
|
||||||
|
fi
|
||||||
|
HARDWARE_CONFIG=$(cat <<NIX
|
||||||
|
|
||||||
|
$hw_lines
|
||||||
|
NIX
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
# initialHashedPassword is safe to template: mkpasswd's alphabet is
|
# initialHashedPassword is safe to template: mkpasswd's alphabet is
|
||||||
# [a-zA-Z0-9./$] — no Nix string metacharacters.
|
# [a-zA-Z0-9./$] — no Nix string metacharacters.
|
||||||
cat > "$FLAKE_DIR/system.nix" <<EOF
|
cat > "$FLAKE_DIR/system.nix" <<EOF
|
||||||
@@ -459,7 +528,7 @@ cat > "$FLAKE_DIR/system.nix" <<EOF
|
|||||||
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
|
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
|
||||||
initialHashedPassword = "$HASHED_PASSWORD";
|
initialHashedPassword = "$HASHED_PASSWORD";
|
||||||
};
|
};
|
||||||
$AUTOLOGIN_CONFIG$POWER_CONFIG$RESUME_CONFIG
|
$AUTOLOGIN_CONFIG$POWER_CONFIG$HARDWARE_CONFIG$RESUME_CONFIG
|
||||||
# Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap.
|
# Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap.
|
||||||
nomarchy.system.snapper.enable = true;
|
nomarchy.system.snapper.enable = true;
|
||||||
|
|
||||||
@@ -573,6 +642,8 @@ cat > /mnt/root/nomarchy-hm-activate.sh <<EOF
|
|||||||
set -ex
|
set -ex
|
||||||
exec > /var/log/nomarchy-hm-preactivate.log 2>&1
|
exec > /var/log/nomarchy-hm-preactivate.log 2>&1
|
||||||
export PATH=/run/current-system/sw/bin:\$PATH
|
export PATH=/run/current-system/sw/bin:\$PATH
|
||||||
|
# Keep root's nix state in /root, not a stray /home/nomarchy on the target.
|
||||||
|
export HOME=/root
|
||||||
# Normally pre-built in the live env and copied over; the in-chroot
|
# Normally pre-built in the live env and copied over; the in-chroot
|
||||||
# build (default substituters — the live-side build already proved the
|
# build (default substituters — the live-side build already proved the
|
||||||
# no-network case) is a last-resort fallback.
|
# no-network case) is a last-resort fallback.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Commands:
|
|||||||
apply <name|file.json> merge a preset into the state + rebuild
|
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
|
set <dotted.path> <value> tweak one key (e.g. `set ui.gapsOut 16`) + rebuild
|
||||||
get [dotted.path] print the current state (or one key)
|
get [dotted.path] print the current state (or one key)
|
||||||
|
validate check the state file against the schema (read-only)
|
||||||
wallpaper apply the current wallpaper via swww
|
wallpaper apply the current wallpaper via swww
|
||||||
bg [next|auto] cycle the theme's wallpapers (instant, no rebuild)
|
bg [next|auto] cycle the theme's wallpapers (instant, no rebuild)
|
||||||
"""
|
"""
|
||||||
@@ -25,6 +26,7 @@ Commands:
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -90,11 +92,23 @@ def load_state(path: Path = STATE_FILE) -> dict:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
die(f"state file not found: {path} (set $NOMARCHY_PATH to your flake checkout)")
|
die(f"state file not found: {path} (set $NOMARCHY_PATH to your flake checkout)")
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
die(f"invalid JSON in {path}: {e}")
|
die(f"theme-state.json has a JSON syntax error at line {e.lineno}, "
|
||||||
|
f"column {e.colno}: {e.msg}\n"
|
||||||
|
f" file: {path}\n"
|
||||||
|
f" fix: correct the syntax by hand (a trailing comma is the "
|
||||||
|
f"usual culprit) — nothing was changed")
|
||||||
|
|
||||||
|
|
||||||
def write_state(state: dict) -> None:
|
def write_state(state: dict) -> None:
|
||||||
"""Atomic write: render to a temp file in the same dir, then rename."""
|
"""Atomic write: render to a temp file in the same dir, then rename.
|
||||||
|
Validates first — an invalid state never reaches disk (the old file
|
||||||
|
stays untouched), so a bad `set` can't brick the next rebuild."""
|
||||||
|
errors, warnings = validate_state(state)
|
||||||
|
for w in warnings:
|
||||||
|
log(f"warning: {w}")
|
||||||
|
if errors:
|
||||||
|
die("refusing to write an invalid state (nothing changed):\n ✖ "
|
||||||
|
+ "\n ✖ ".join(errors))
|
||||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fd, tmp = tempfile.mkstemp(dir=STATE_FILE.parent, prefix=".theme-state.", suffix=".json")
|
fd, tmp = tempfile.mkstemp(dir=STATE_FILE.parent, prefix=".theme-state.", suffix=".json")
|
||||||
try:
|
try:
|
||||||
@@ -116,6 +130,39 @@ def write_state(state: dict) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def auto_commit_enabled(state: dict) -> bool:
|
||||||
|
return bool((state.get("settings") or {}).get("autoCommit"))
|
||||||
|
|
||||||
|
|
||||||
|
def auto_commit(message: str) -> None:
|
||||||
|
"""Opt-in (settings.autoCommit): commit theme-state.json — and nothing
|
||||||
|
else — after a mutation, so settings history is `git log`. The pathspec
|
||||||
|
keeps unrelated dirty files out of the commit; a missing git identity
|
||||||
|
falls back to a Nomarchy one so a fresh machine never errors. Callers
|
||||||
|
fire this when the flag is on before OR after the write, so the
|
||||||
|
disable-toggle itself lands in history instead of staying dirty.
|
||||||
|
`bg` is deliberately excluded (runtime wallpaper churn); the wallpaper
|
||||||
|
path rides along with the next apply/set commit."""
|
||||||
|
if not (FLAKE_DIR / ".git").exists() or shutil.which("git") is None:
|
||||||
|
return
|
||||||
|
git = ["git", "-C", str(FLAKE_DIR)]
|
||||||
|
# No-op when the file already matches HEAD (a `set` to the same value).
|
||||||
|
# On a repo with no commits yet this diff errors — then just commit.
|
||||||
|
if subprocess.run(git + ["diff", "--quiet", "HEAD", "--", "theme-state.json"],
|
||||||
|
capture_output=True).returncode == 0:
|
||||||
|
return
|
||||||
|
if not subprocess.run(git + ["config", "user.email"],
|
||||||
|
capture_output=True, text=True).stdout.strip():
|
||||||
|
git += ["-c", "user.name=Nomarchy", "-c", "user.email=nomarchy@localhost"]
|
||||||
|
result = subprocess.run(
|
||||||
|
git + ["commit", "--quiet", "-m", message, "--", "theme-state.json"],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
log(f"auto-committed: {message}")
|
||||||
|
else:
|
||||||
|
log(f"auto-commit skipped: {(result.stderr or result.stdout).strip()}")
|
||||||
|
|
||||||
|
|
||||||
def deep_merge(base: dict, override: dict) -> dict:
|
def deep_merge(base: dict, override: dict) -> dict:
|
||||||
out = dict(base)
|
out = dict(base)
|
||||||
for k, v in override.items():
|
for k, v in override.items():
|
||||||
@@ -126,6 +173,152 @@ def deep_merge(base: dict, override: dict) -> dict:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Schema validation ─────────────────────────────────────────────────────
|
||||||
|
# The friendly-errors contract (with theme.nix, which enforces the same
|
||||||
|
# rules at eval time): a hand-edited state file fails HERE, before it is
|
||||||
|
# written, with the field, the problem, and the fix — never as a Nix
|
||||||
|
# stack trace at rebuild. Hard errors cover the appearance schema the
|
||||||
|
# modules consume; settings.* (menu-writer territory) and unknown keys
|
||||||
|
# only warn, so custom keys and newer/older schemas keep working.
|
||||||
|
|
||||||
|
HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||||
|
COLOR_ROLES = ("base", "mantle", "surface", "overlay", "text", "subtext",
|
||||||
|
"muted", "accent", "accentAlt", "good", "warn", "bad")
|
||||||
|
TOP_KEYS = {"version", "name", "slug", "mode", "wallpaper", "colors", "ansi",
|
||||||
|
"fonts", "ui", "icons", "border", "settings"}
|
||||||
|
UI_UINTS = ("gapsIn", "gapsOut", "borderSize", "rounding", "iconSize")
|
||||||
|
UI_OPACITIES = ("activeOpacity", "inactiveOpacity", "terminalOpacity")
|
||||||
|
UI_BOOLS = ("blur", "shadow")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_hex(v) -> bool:
|
||||||
|
return isinstance(v, str) and bool(HEX_RE.match(v))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_num(v) -> bool:
|
||||||
|
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_state(state) -> tuple:
|
||||||
|
"""Return (errors, warnings) — friendly strings, one problem each."""
|
||||||
|
errors, warnings = [], []
|
||||||
|
|
||||||
|
def err(field, problem, fix):
|
||||||
|
errors.append(f"{field}: {problem}\n fix: {fix}")
|
||||||
|
|
||||||
|
if not isinstance(state, dict):
|
||||||
|
err("top level", "must be a JSON object",
|
||||||
|
"re-apply a preset: nomarchy-theme-sync apply tokyo-night")
|
||||||
|
return errors, warnings
|
||||||
|
|
||||||
|
for k in state:
|
||||||
|
if k not in TOP_KEYS:
|
||||||
|
warnings.append(f"unknown top-level key '{k}' — a typo? (it is ignored)")
|
||||||
|
|
||||||
|
for field, want in (("name", "the theme's display name"),
|
||||||
|
("slug", "the theme's directory name"),
|
||||||
|
("wallpaper", "a filename in the theme's backgrounds/ (or \"\")"),
|
||||||
|
("icons", "an icon theme name (or \"\" for pick-by-mode)")):
|
||||||
|
v = state.get(field)
|
||||||
|
if v is not None and not isinstance(v, str):
|
||||||
|
err(field, f"must be a string ({want}), got {v!r}", 'quote it, e.g. "tokyo-night"')
|
||||||
|
|
||||||
|
mode = state.get("mode")
|
||||||
|
if mode is not None and mode not in ("dark", "light"):
|
||||||
|
err("mode", f'must be "dark" or "light", got {mode!r}', 'set it to "dark" or "light"')
|
||||||
|
|
||||||
|
colors = state.get("colors", {})
|
||||||
|
if not isinstance(colors, dict):
|
||||||
|
err("colors", "must be an object of palette roles",
|
||||||
|
"re-apply a preset to restore the palette")
|
||||||
|
else:
|
||||||
|
for k, v in colors.items():
|
||||||
|
if k not in COLOR_ROLES:
|
||||||
|
warnings.append(f"colors.{k}: not a Nomarchy palette role (it is ignored)")
|
||||||
|
elif not _is_hex(v):
|
||||||
|
err(f"colors.{k}", f'must be "#RRGGBB", got {v!r}',
|
||||||
|
'use a 6-digit hex color like "#7aa2f7"')
|
||||||
|
|
||||||
|
ansi = state.get("ansi")
|
||||||
|
if ansi is not None and not (isinstance(ansi, list) and len(ansi) == 16
|
||||||
|
and all(_is_hex(c) for c in ansi)):
|
||||||
|
err("ansi", 'must be a list of exactly 16 "#RRGGBB" strings',
|
||||||
|
"re-apply a preset to restore the terminal palette")
|
||||||
|
|
||||||
|
fonts = state.get("fonts", {})
|
||||||
|
if isinstance(fonts, dict):
|
||||||
|
for k in ("mono", "ui"):
|
||||||
|
v = fonts.get(k)
|
||||||
|
if v is not None and not isinstance(v, str):
|
||||||
|
err(f"fonts.{k}", f"must be a font family name (string), got {v!r}",
|
||||||
|
'quote it, e.g. "JetBrainsMono Nerd Font"')
|
||||||
|
size = fonts.get("size")
|
||||||
|
if size is not None and not (_is_num(size) and size > 0):
|
||||||
|
err("fonts.size", f"must be a positive number (points), got {size!r}",
|
||||||
|
"use a plain number like 11 (no quotes)")
|
||||||
|
else:
|
||||||
|
err("fonts", "must be an object", 're-add: {"mono": "...", "ui": "...", "size": 11}')
|
||||||
|
|
||||||
|
ui = state.get("ui", {})
|
||||||
|
if isinstance(ui, dict):
|
||||||
|
for k in UI_UINTS:
|
||||||
|
v = ui.get(k)
|
||||||
|
if v is not None and not (isinstance(v, int) and not isinstance(v, bool) and v >= 0):
|
||||||
|
err(f"ui.{k}", f"must be a non-negative whole number (pixels), got {v!r}",
|
||||||
|
"use a plain number like 12 (no quotes)")
|
||||||
|
for k in UI_OPACITIES:
|
||||||
|
v = ui.get(k)
|
||||||
|
if v is not None and not (_is_num(v) and 0 <= v <= 1):
|
||||||
|
err(f"ui.{k}", f"must be a number between 0 and 1, got {v!r}",
|
||||||
|
"use e.g. 0.95 (no quotes)")
|
||||||
|
for k in UI_BOOLS:
|
||||||
|
v = ui.get(k)
|
||||||
|
if v is not None and not isinstance(v, bool):
|
||||||
|
err(f"ui.{k}", f"must be true or false, got {v!r}",
|
||||||
|
"use true or false (no quotes)")
|
||||||
|
else:
|
||||||
|
err("ui", "must be an object", "re-apply a preset to restore the UI block")
|
||||||
|
|
||||||
|
border = state.get("border")
|
||||||
|
if border is not None:
|
||||||
|
if not isinstance(border, dict):
|
||||||
|
err("border", "must be an object", 're-add: {"active": "accent", "inactive": "overlay"}')
|
||||||
|
else:
|
||||||
|
for k in ("active", "inactive"):
|
||||||
|
v = border.get(k)
|
||||||
|
if v is not None and not (isinstance(v, str)
|
||||||
|
and (v in COLOR_ROLES or _is_hex(v))):
|
||||||
|
err(f"border.{k}",
|
||||||
|
f'must be a palette role ({", ".join(COLOR_ROLES)}) or "#RRGGBB", got {v!r}',
|
||||||
|
'use e.g. "accent" or "#7aa2f7"')
|
||||||
|
|
||||||
|
settings = state.get("settings")
|
||||||
|
if settings is not None and not isinstance(settings, dict):
|
||||||
|
err("settings", "must be an object (the menu writes feature flags here)",
|
||||||
|
're-add: "settings": {}')
|
||||||
|
|
||||||
|
slug = state.get("slug")
|
||||||
|
if isinstance(slug, str) and slug and find_preset(slug) is None:
|
||||||
|
warnings.append(f"slug '{slug}' matches no shipped/custom preset — "
|
||||||
|
f"fine for a hand-rolled theme, but per-theme assets won't resolve")
|
||||||
|
|
||||||
|
return errors, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_validate(_args) -> None:
|
||||||
|
state = load_state() # dies with the friendly syntax error on bad JSON
|
||||||
|
errors, warnings = validate_state(state)
|
||||||
|
for w in warnings:
|
||||||
|
print(f" ● {w}")
|
||||||
|
if errors:
|
||||||
|
print(f"nomarchy-theme-sync: {STATE_FILE} has "
|
||||||
|
f"{len(errors)} problem(s):", file=sys.stderr)
|
||||||
|
for e in errors:
|
||||||
|
print(f" ✖ {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
log(f"{STATE_FILE} is valid ({len(warnings)} warning(s))")
|
||||||
|
|
||||||
|
|
||||||
# ─── Font verification ────────────────────────────────────────────────────
|
# ─── Font verification ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def check_fonts(state: dict) -> None:
|
def check_fonts(state: dict) -> None:
|
||||||
@@ -165,12 +358,28 @@ def run_switch() -> None:
|
|||||||
# Persistent (timeout 0): stays up for the whole rebuild — replaced
|
# Persistent (timeout 0): stays up for the whole rebuild — replaced
|
||||||
# in place by the success/failure notification below — so a multi-
|
# in place by the success/failure notification below — so a multi-
|
||||||
# minute switch never looks like it silently failed.
|
# minute switch never looks like it silently failed.
|
||||||
notify("Applying theme — rebuilding the desktop…", persistent=True)
|
notify("Applying changes — rebuilding the desktop…", persistent=True)
|
||||||
result = subprocess.run(argv) # stream output to the caller's terminal
|
result = subprocess.run(argv) # stream output to the caller's terminal
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
notify("Theme rebuild FAILED — see terminal / journal", urgency="critical")
|
notify("Rebuild FAILED — see terminal / journal", urgency="critical")
|
||||||
die("rebuild failed (state file already updated; fix and re-run)")
|
die("rebuild failed (state file already updated; fix and re-run)")
|
||||||
notify("Theme applied ✓")
|
notify("Changes applied ✓")
|
||||||
|
# Waybar runs from Hyprland's exec-once (not a systemd unit HM would
|
||||||
|
# restart on switch), so nudge the running bar onto the freshly rebuilt
|
||||||
|
# config/style. Under the nomarchy-waybar supervisor a plain kill IS a
|
||||||
|
# clean restart with the new files — waybar's in-place SIGUSR2 reload
|
||||||
|
# is what crashed the bar on hardware (double-reload race with
|
||||||
|
# reload_style_on_change while the symlinks flip), so prefer the
|
||||||
|
# restart whenever the supervisor is there to catch it; fall back to
|
||||||
|
# SIGUSR2 for unsupervised/custom bars. No-ops if nothing is running.
|
||||||
|
if shutil.which("pkill"):
|
||||||
|
supervised = subprocess.run(
|
||||||
|
["pgrep", "-f", "nomarchy-waybar"], capture_output=True
|
||||||
|
).returncode == 0
|
||||||
|
if supervised:
|
||||||
|
subprocess.run(["pkill", "-x", "waybar"], check=False)
|
||||||
|
else:
|
||||||
|
subprocess.run(["pkill", "--signal", "SIGUSR2", "-x", "waybar"], check=False)
|
||||||
|
|
||||||
|
|
||||||
# ─── Theme assets (wallpapers) ────────────────────────────────────────────
|
# ─── Theme assets (wallpapers) ────────────────────────────────────────────
|
||||||
@@ -257,10 +466,17 @@ def cmd_apply(args) -> None:
|
|||||||
die(f"unknown theme '{args.theme}' (try `nomarchy-theme-sync list`)")
|
die(f"unknown theme '{args.theme}' (try `nomarchy-theme-sync list`)")
|
||||||
|
|
||||||
preset = json.loads(preset_path.read_text())
|
preset = json.loads(preset_path.read_text())
|
||||||
# Merge over current state: presets define palette/name/wallpaper,
|
# Merge over current state. Presets carry a full appearance block —
|
||||||
# user tweaks (gaps, fonts) outside the preset survive.
|
# palette, border, fonts and ui — so a switch always replaces the last
|
||||||
state = deep_merge(load_state(), preset)
|
# theme's look (a theme can ship a bespoke font/opacity/geometry, e.g.
|
||||||
|
# Boreal, without it leaking into the next). settings.* (feature state,
|
||||||
|
# keyboard/monitor memory) live outside any preset and survive. A
|
||||||
|
# per-key `set ui.<k>`/`fonts.<k>` tweak holds until the next apply.
|
||||||
|
old = load_state()
|
||||||
|
state = deep_merge(old, preset)
|
||||||
write_state(state)
|
write_state(state)
|
||||||
|
if auto_commit_enabled(old) or auto_commit_enabled(state):
|
||||||
|
auto_commit(f"nomarchy: apply theme {state.get('name', args.theme)}")
|
||||||
log(f"theme: {state.get('name', args.theme)}")
|
log(f"theme: {state.get('name', args.theme)}")
|
||||||
check_fonts(state)
|
check_fonts(state)
|
||||||
if not args.no_switch:
|
if not args.no_switch:
|
||||||
@@ -275,6 +491,7 @@ def cmd_set(args) -> None:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
value = args.value # bare string ("#7aa2f7", font names, paths)
|
value = args.value # bare string ("#7aa2f7", font names, paths)
|
||||||
|
|
||||||
|
was_on = auto_commit_enabled(state)
|
||||||
node = state
|
node = state
|
||||||
keys = args.path.split(".")
|
keys = args.path.split(".")
|
||||||
for key in keys[:-1]:
|
for key in keys[:-1]:
|
||||||
@@ -284,12 +501,18 @@ def cmd_set(args) -> None:
|
|||||||
node[keys[-1]] = value
|
node[keys[-1]] = value
|
||||||
|
|
||||||
write_state(state)
|
write_state(state)
|
||||||
|
if was_on or auto_commit_enabled(state):
|
||||||
|
auto_commit(f"nomarchy: set {args.path} = {json.dumps(value)}")
|
||||||
log(f"set {args.path} = {value!r}")
|
log(f"set {args.path} = {value!r}")
|
||||||
if args.path.startswith("fonts."):
|
if args.path.startswith("fonts."):
|
||||||
check_fonts(state)
|
check_fonts(state)
|
||||||
if not args.no_switch:
|
if not args.no_switch:
|
||||||
run_switch()
|
run_switch()
|
||||||
apply_wallpaper(state)
|
# Only wallpaper/theme keys change what swww shows; skip the re-apply
|
||||||
|
# (and its transition) for unrelated keys like ui.* or settings.* so a
|
||||||
|
# gaps tweak or a feature toggle doesn't flash the background.
|
||||||
|
if args.path.split(".")[0] in ("wallpaper", "slug"):
|
||||||
|
apply_wallpaper(state)
|
||||||
|
|
||||||
|
|
||||||
def cmd_get(args) -> None:
|
def cmd_get(args) -> None:
|
||||||
@@ -301,7 +524,10 @@ def cmd_get(args) -> None:
|
|||||||
node = node[key]
|
node = node[key]
|
||||||
except (KeyError, TypeError):
|
except (KeyError, TypeError):
|
||||||
die(f"no such key: {args.path}")
|
die(f"no such key: {args.path}")
|
||||||
print(json.dumps(node, indent=2) if isinstance(node, (dict, list)) else node)
|
# Booleans print JSON-style (true/false, not Python's True/False) so
|
||||||
|
# shell consumers can compare against the same literal they `set`.
|
||||||
|
print(json.dumps(node, indent=2)
|
||||||
|
if isinstance(node, (dict, list, bool)) else node)
|
||||||
else:
|
else:
|
||||||
print(json.dumps(state, indent=2))
|
print(json.dumps(state, indent=2))
|
||||||
|
|
||||||
@@ -357,6 +583,8 @@ def main() -> None:
|
|||||||
p.add_argument("path", nargs="?")
|
p.add_argument("path", nargs="?")
|
||||||
p.set_defaults(func=cmd_get)
|
p.set_defaults(func=cmd_get)
|
||||||
|
|
||||||
|
sub.add_parser("validate", help="check theme-state.json against the schema (read-only)").set_defaults(func=cmd_validate)
|
||||||
|
|
||||||
sub.add_parser("wallpaper", help="apply the current wallpaper via swww").set_defaults(func=cmd_wallpaper)
|
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 = sub.add_parser("bg", help="wallpaper control: `bg next` cycles, `bg auto` resets")
|
||||||
|
|||||||
@@ -16,23 +16,51 @@
|
|||||||
|
|
||||||
# ── Opt-in features — uncomment and tweak to enable ─────────────────
|
# ── Opt-in features — uncomment and tweak to enable ─────────────────
|
||||||
# nomarchy.nightlight = { # scheduled blue-light filter (hyprsunset)
|
# nomarchy.nightlight = { # scheduled blue-light filter (hyprsunset)
|
||||||
# enable = true;
|
# enable = true; # declarative opt-in (or just enable it from
|
||||||
|
# # the menu: System › Night light)
|
||||||
# temperature = 4000; # night warmth in K — lower is warmer
|
# temperature = 4000; # night warmth in K — lower is warmer
|
||||||
# sunset = "20:00";
|
# sunset = "20:00";
|
||||||
# sunrise = "07:00";
|
# sunrise = "07:00";
|
||||||
# };
|
# };
|
||||||
#
|
#
|
||||||
|
# nomarchy.updates = { # passive update-awareness indicator + notification
|
||||||
|
# enable = true;
|
||||||
|
# interval = "daily"; # how often to check (systemd OnCalendar)
|
||||||
|
# flatpak = true; # also count Flatpak updates when flatpak is on
|
||||||
|
# };
|
||||||
|
#
|
||||||
# nomarchy.monitors = [ # declarative per-output layout, hotplug-applied
|
# nomarchy.monitors = [ # declarative per-output layout, hotplug-applied
|
||||||
# { name = "eDP-1"; position = "0x0"; }
|
# { name = "eDP-1"; position = "0x0"; }
|
||||||
# { name = "HDMI-A-1"; position = "auto-right"; }
|
# { name = "HDMI-A-1"; position = "auto-right"; }
|
||||||
# ]; # names from `hyprctl devices`
|
# ]; # names from `hyprctl devices`
|
||||||
#
|
#
|
||||||
|
# nomarchy.launchOrFocus = [ # SUPER+<key> focuses the app's window,
|
||||||
|
# { key = "B"; class = "firefox"; } # or launches it if none is open;
|
||||||
|
# { key = "O"; class = "obsidian"; }# rows appear in the SUPER+? cheatsheet
|
||||||
|
# ]; # (class from `hyprctl clients`)
|
||||||
|
#
|
||||||
|
# nomarchy.displayProfiles = { # named layouts for the SAME outputs —
|
||||||
|
# docked = { # switch: System › Display › Profiles
|
||||||
|
# monitors = [
|
||||||
|
# { name = "eDP-1"; resolution = "disable"; }
|
||||||
|
# { name = "DP-3"; position = "0x0"; }
|
||||||
|
# { name = "DP-4"; position = "auto-right"; }
|
||||||
|
# ];
|
||||||
|
# workspaces = { "1" = "DP-3"; "9" = "DP-4"; }; # optional ws→output pins
|
||||||
|
# };
|
||||||
|
# undocked = [ { name = "eDP-1"; position = "0x0"; } ]; # bare list = monitors only
|
||||||
|
# }; # applied instantly + remembered in the
|
||||||
|
# # flake state (settings.displayProfile);
|
||||||
|
# # the menu's Auto-switch row makes dock/
|
||||||
|
# # undock pick the matching profile
|
||||||
|
#
|
||||||
# nomarchy.keyboard.devices = { # a specific keyboard's own layout
|
# nomarchy.keyboard.devices = { # a specific keyboard's own layout
|
||||||
# "keychron-keychron-k2" = { layout = "de"; };
|
# "keychron-keychron-k2" = { layout = "de"; };
|
||||||
# };
|
# };
|
||||||
#
|
#
|
||||||
# nomarchy.keyboard.layouts = [ "de" "fr" ]; # rofi-prompt for a layout when a
|
# nomarchy.keyboard.layouts = [ "de" "fr" ]; # rofi-prompt for a layout when a new
|
||||||
# # new keyboard connects + remember it
|
# # keyboard connects + remember it in the
|
||||||
|
# # flake state (graduates to .devices above)
|
||||||
|
|
||||||
# ── Application suite ───────────────────────────────────────────────
|
# ── Application suite ───────────────────────────────────────────────
|
||||||
# A starter complete-workstation set, installed for your user — yours to
|
# A starter complete-workstation set, installed for your user — yours to
|
||||||
@@ -53,6 +81,7 @@
|
|||||||
inkscape # vector graphics
|
inkscape # vector graphics
|
||||||
mpv # media player
|
mpv # media player
|
||||||
amberol # music player (local library; streaming → spotify below)
|
amberol # music player (local library; streaming → spotify below)
|
||||||
|
pwvucontrol # PipeWire volume mixer (right-click the Waybar volume)
|
||||||
|
|
||||||
# ── More apps — uncomment to add ─────────────────────────────────
|
# ── More apps — uncomment to add ─────────────────────────────────
|
||||||
# Web browsers
|
# Web browsers
|
||||||
@@ -74,7 +103,7 @@
|
|||||||
|
|
||||||
# Office & documents
|
# Office & documents
|
||||||
# onlyoffice-desktopeditors # stronger MS-Office fidelity
|
# onlyoffice-desktopeditors # stronger MS-Office fidelity
|
||||||
# kdePackages.okular # PDF viewer
|
# kdePackages.okular # heavier PDF viewer (zathura ships by default)
|
||||||
# xournalpp # PDF annotation
|
# xournalpp # PDF annotation
|
||||||
# pdfarranger
|
# pdfarranger
|
||||||
# calibre # ebook library
|
# calibre # ebook library
|
||||||
@@ -105,6 +134,11 @@
|
|||||||
# kdePackages.kdenlive # video editor (pulls in KDE libs)
|
# kdePackages.kdenlive # video editor (pulls in KDE libs)
|
||||||
# handbrake # transcoder
|
# handbrake # transcoder
|
||||||
|
|
||||||
|
# Webcam tuning (rarely needed — the distro's IR-hide covers the
|
||||||
|
# common dual-sensor problem; these are for genuine tuning cases)
|
||||||
|
# cameractrls # GUI for exposure/focus/zoom, saves per-camera presets
|
||||||
|
# v4l-utils # v4l2-ctl CLI: list formats, poke controls directly
|
||||||
|
|
||||||
# Media
|
# Media
|
||||||
# vlc
|
# vlc
|
||||||
# spotify # (unfree)
|
# spotify # (unfree)
|
||||||
|
|||||||
@@ -31,7 +31,27 @@
|
|||||||
# thermal.enable = true; # thermald (Intel CPUs)
|
# thermal.enable = true; # thermald (Intel CPUs)
|
||||||
# };
|
# };
|
||||||
#
|
#
|
||||||
# nomarchy.services.tailscale.enable = true; # mesh VPN — then `sudo tailscale up`
|
# nomarchy.system.autoTimezone.enable = true; # clock follows your location
|
||||||
|
# # (geoclue); travelling updates the
|
||||||
|
# # time on its own. Unsets time.timeZone
|
||||||
|
# # above. Easier toggled from System menu.
|
||||||
|
#
|
||||||
|
# nomarchy.hardware = { # enablement above nixos-hardware (installer-detected)
|
||||||
|
# intel.enable = true; # GuC/HuC firmware; installer sets this on Intel
|
||||||
|
# intel.computeRuntime = true; # OpenCL/oneVPL GPU compute (opt-in)
|
||||||
|
# amd.enable = true; # amd-pstate + radeonsi VA-API; installer-set on AMD
|
||||||
|
# amd.rocm.enable = true; # ROCm GPU compute (multi-GB, opt-in)
|
||||||
|
# amd.rocm.gfxOverride = "11.0.0"; # HSA override for an unlisted iGPU
|
||||||
|
# fingerprint.enable = true; # fprintd; installer-set when a reader is detected
|
||||||
|
# fingerprint.pam = true; # use the fingerprint for login + sudo
|
||||||
|
# npu.enable = true; # on-die NPU driver (experimental; userspace runtime BYO)
|
||||||
|
# latestKernel = true; # newest kernel for very-new hardware (drivers not yet in the default)
|
||||||
|
# camera.hideIrSensor = true; # dual-sensor webcam: hide the IR node so apps get the color cam
|
||||||
|
# # (installer-set when a paired RGB+IR webcam is detected)
|
||||||
|
# };
|
||||||
|
#
|
||||||
|
# nomarchy.services.tailscale.enable = true; # mesh VPN — connect from System › VPN
|
||||||
|
# # (login user is operator, no sudo)
|
||||||
# nomarchy.services.syncthing.enable = true; # file sync — GUI at http://127.0.0.1:8384
|
# nomarchy.services.syncthing.enable = true; # file sync — GUI at http://127.0.0.1:8384
|
||||||
# nomarchy.services.podman.enable = true; # rootless containers (docker → podman)
|
# nomarchy.services.podman.enable = true; # rootless containers (docker → podman)
|
||||||
# nomarchy.services.flatpak.enable = true; # Flatpak + the Flathub remote
|
# nomarchy.services.flatpak.enable = true; # Flatpak + the Flathub remote
|
||||||
|
|||||||
@@ -55,5 +55,6 @@
|
|||||||
"terminalOpacity": 0.96,
|
"terminalOpacity": 0.96,
|
||||||
"blur": true,
|
"blur": true,
|
||||||
"shadow": true
|
"shadow": true
|
||||||
}
|
},
|
||||||
|
"settings": {}
|
||||||
}
|
}
|
||||||
|
|||||||
60
themes/boreal.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"name": "Boreal",
|
||||||
|
"slug": "boreal",
|
||||||
|
"mode": "dark",
|
||||||
|
"wallpaper": "",
|
||||||
|
"colors": {
|
||||||
|
"base": "#21272F",
|
||||||
|
"mantle": "#1A1F26",
|
||||||
|
"surface": "#303A46",
|
||||||
|
"overlay": "#404D5C",
|
||||||
|
"text": "#D3DAE0",
|
||||||
|
"subtext": "#97A3B2",
|
||||||
|
"muted": "#5E6A78",
|
||||||
|
"accent": "#B79BE8",
|
||||||
|
"accentAlt": "#86C7C0",
|
||||||
|
"good": "#A3CF88",
|
||||||
|
"warn": "#E6C384",
|
||||||
|
"bad": "#E2818A"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"active": "accent",
|
||||||
|
"inactive": "overlay"
|
||||||
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "GeistMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 6,
|
||||||
|
"gapsOut": 14,
|
||||||
|
"borderSize": 3,
|
||||||
|
"rounding": 12,
|
||||||
|
"iconSize": 44,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.93,
|
||||||
|
"terminalOpacity": 0.90,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
|
"ansi": [
|
||||||
|
"#2E3742",
|
||||||
|
"#E2818A",
|
||||||
|
"#A3CF88",
|
||||||
|
"#E6C384",
|
||||||
|
"#86A9E0",
|
||||||
|
"#B79BE8",
|
||||||
|
"#86C7C0",
|
||||||
|
"#D3DAE0",
|
||||||
|
"#5E6A78",
|
||||||
|
"#EC98A0",
|
||||||
|
"#B4DB9C",
|
||||||
|
"#EFCF98",
|
||||||
|
"#9DBBEC",
|
||||||
|
"#C6AEEF",
|
||||||
|
"#99D6CF",
|
||||||
|
"#E8EDF2"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
themes/boreal/backgrounds/aurora-boreal.png
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
42
themes/boreal/launcher.rasi
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Boreal — app-launcher override (themes/<slug>/launcher.rasi).
|
||||||
|
* Installed to ~/.local/share/rofi/themes/launcher.rasi and used only by
|
||||||
|
* `rofi -show drun` (the SUPER+Space / SUPER+D launcher). It inherits the
|
||||||
|
* frosted aurora panel from the sibling rofi.rasi (this whole-swap theme,
|
||||||
|
* which HM deploys under its source basename — rofi.rasi — not custom.rasi),
|
||||||
|
* then reshapes the list into a 4×3 grid of large application icons.
|
||||||
|
* Text menus keep the list — they don't use this theme.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import "rofi.rasi"
|
||||||
|
|
||||||
|
window { width: 780px; }
|
||||||
|
|
||||||
|
listview {
|
||||||
|
columns: 4;
|
||||||
|
lines: 3;
|
||||||
|
fixed-columns: true;
|
||||||
|
spacing: 10px;
|
||||||
|
padding: 6px 0px 0px 0px;
|
||||||
|
/* Fill row-by-row so Down at a column's foot pages to the next screen
|
||||||
|
instead of jumping to the top of the next column (rofi's default
|
||||||
|
Vertical flow). Mirrors the theme-picker grid in rofi.nix. */
|
||||||
|
flow: horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
element {
|
||||||
|
orientation: vertical;
|
||||||
|
padding: 16px 8px;
|
||||||
|
spacing: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
element-icon {
|
||||||
|
size: 56px;
|
||||||
|
horizontal-align: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
element-text {
|
||||||
|
horizontal-align: 0.5;
|
||||||
|
vertical-align: 0.5;
|
||||||
|
}
|
||||||
116
themes/boreal/rofi.rasi
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Boreal — rofi whole-swap (themes/<slug>/rofi.rasi).
|
||||||
|
* The base theme: a frosted aurora panel used by every menu (and, as a
|
||||||
|
* list, the fallback launcher). The app launcher reshapes this into a
|
||||||
|
* grid via themes/boreal/launcher.rasi, which @imports this file. The
|
||||||
|
* `configuration {}` block is omitted on purpose — it comes from
|
||||||
|
* modules/home/rofi.nix. Element structure mirrors the generated theme
|
||||||
|
* so the theme-grid picker's per-invocation -theme-str still lays out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {
|
||||||
|
base: #21272F;
|
||||||
|
glassBg: #21272FE6;
|
||||||
|
surface: #303A46;
|
||||||
|
overlay: #404D5C;
|
||||||
|
text: #D3DAE0;
|
||||||
|
subtext: #97A3B2;
|
||||||
|
muted: #5E6A78;
|
||||||
|
accent: #B79BE8;
|
||||||
|
accentAlt: #86C7C0;
|
||||||
|
accentSoft: #B79BE83B;
|
||||||
|
frostEdge: #86C7C05C;
|
||||||
|
|
||||||
|
font: "GeistMono Nerd Font 11";
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: @text;
|
||||||
|
}
|
||||||
|
|
||||||
|
window {
|
||||||
|
background-color: @glassBg;
|
||||||
|
border: 1px;
|
||||||
|
border-color: @frostEdge;
|
||||||
|
border-radius: 14px;
|
||||||
|
width: 620px;
|
||||||
|
location: center;
|
||||||
|
anchor: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainbox {
|
||||||
|
background-color: transparent;
|
||||||
|
children: [ inputbar, message, listview ];
|
||||||
|
spacing: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputbar {
|
||||||
|
children: [ prompt, entry ];
|
||||||
|
background-color: @surface;
|
||||||
|
text-color: @text;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
spacing: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt { text-color: @accent; }
|
||||||
|
|
||||||
|
entry {
|
||||||
|
text-color: @text;
|
||||||
|
cursor: text;
|
||||||
|
placeholder: "Search…";
|
||||||
|
placeholder-color: @muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
listview {
|
||||||
|
background-color: transparent;
|
||||||
|
columns: 1;
|
||||||
|
lines: 8;
|
||||||
|
dynamic: true;
|
||||||
|
scrollbar: false;
|
||||||
|
spacing: 6px;
|
||||||
|
padding: 4px 0px 0px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
element {
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: @text;
|
||||||
|
orientation: horizontal;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
spacing: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
element normal.normal,
|
||||||
|
element alternate.normal {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected row lifts on a soft aurora-violet wash. */
|
||||||
|
element selected.normal {
|
||||||
|
background-color: @accentSoft;
|
||||||
|
text-color: @text;
|
||||||
|
}
|
||||||
|
|
||||||
|
element-icon {
|
||||||
|
background-color: transparent;
|
||||||
|
size: 30px;
|
||||||
|
cursor: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
element-text {
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: inherit;
|
||||||
|
highlight: bold;
|
||||||
|
vertical-align: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message { padding: 0px; }
|
||||||
|
|
||||||
|
textbox {
|
||||||
|
background-color: @surface;
|
||||||
|
text-color: @text;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollbar { width: 0px; }
|
||||||
166
themes/boreal/waybar.css
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/*
|
||||||
|
* Boreal — Waybar whole-swap (themes/<slug>/waybar.css).
|
||||||
|
*
|
||||||
|
* A theme waybar.css replaces the ENTIRE generated stylesheet and is read
|
||||||
|
* raw (no palette is prepended), so this file MUST define its own colors.
|
||||||
|
* Identity: a floating "aurora frost" bar — three frosted-glass pills
|
||||||
|
* (launcher+clock · workspaces · status) over the wallpaper's blur. The
|
||||||
|
* whole right side is ONE continuous pill: the system tray shares its
|
||||||
|
* glass with the status modules, no separators, no boxes. Companion:
|
||||||
|
* waybar.jsonc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@define-color base #21272F;
|
||||||
|
@define-color mantle #1A1F26;
|
||||||
|
@define-color surface #303A46;
|
||||||
|
@define-color overlay #404D5C;
|
||||||
|
@define-color text #D3DAE0;
|
||||||
|
@define-color subtext #97A3B2;
|
||||||
|
@define-color muted #5E6A78;
|
||||||
|
@define-color accent #B79BE8;
|
||||||
|
@define-color accentAlt #86C7C0;
|
||||||
|
@define-color good #A3CF88;
|
||||||
|
@define-color warn #E6C384;
|
||||||
|
@define-color bad #E2818A;
|
||||||
|
|
||||||
|
* {
|
||||||
|
font-family: "GeistMono Nerd Font", "Symbols Nerd Font";
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The bar itself is invisible — the pills float over the wallpaper. */
|
||||||
|
window#waybar {
|
||||||
|
background: transparent;
|
||||||
|
color: @text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The three frosted-glass pills ────────────────────────────────────
|
||||||
|
Global blur turns the translucent @base into frosted glass; a faint
|
||||||
|
frost-cyan edge lifts each pill off the wallpaper. */
|
||||||
|
.modules-left,
|
||||||
|
.modules-center,
|
||||||
|
.modules-right {
|
||||||
|
background: alpha(@base, 0.72);
|
||||||
|
border: 1px solid alpha(@accentAlt, 0.14);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0 6px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every module sits ON its pill: transparent, so nothing boxes the tray
|
||||||
|
off from the status icons — the seamless-cluster requirement. */
|
||||||
|
#custom-nomarchy,
|
||||||
|
#clock,
|
||||||
|
#custom-recording,
|
||||||
|
#custom-updates,
|
||||||
|
#custom-doctor,
|
||||||
|
#idle_inhibitor,
|
||||||
|
#custom-nightlight,
|
||||||
|
#language,
|
||||||
|
#custom-vpn,
|
||||||
|
#pulseaudio,
|
||||||
|
#battery,
|
||||||
|
#custom-powerprofile,
|
||||||
|
#tray,
|
||||||
|
#custom-notification,
|
||||||
|
#custom-powermenu {
|
||||||
|
background: transparent;
|
||||||
|
color: alpha(@text, 0.9);
|
||||||
|
padding: 0 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Launcher — the aurora-violet Nomarchy mark opens the pill ────────
|
||||||
|
The mark lives at U+F000 in the dedicated "Nomarchy" font; pin the
|
||||||
|
family or the Nerd Font's glass glyph at the same codepoint wins. */
|
||||||
|
#custom-nomarchy {
|
||||||
|
color: @accent;
|
||||||
|
font-family: Nomarchy;
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 0 12px 0 14px;
|
||||||
|
}
|
||||||
|
#custom-nomarchy:hover { color: @accentAlt; }
|
||||||
|
|
||||||
|
/* ── Clock — mono digits align; date recedes to frost-grey ───────────*/
|
||||||
|
#clock {
|
||||||
|
color: @text;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
#clock.date { color: @subtext; font-weight: 500; }
|
||||||
|
|
||||||
|
/* ── Workspaces — dots that bloom to an aurora pill when active ───────*/
|
||||||
|
#workspaces { padding: 0 2px; }
|
||||||
|
|
||||||
|
#workspaces button {
|
||||||
|
color: @muted;
|
||||||
|
padding: 0 9px;
|
||||||
|
margin: 3px 1px;
|
||||||
|
border-radius: 9px;
|
||||||
|
transition: color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
#workspaces button:hover {
|
||||||
|
color: @text;
|
||||||
|
background: alpha(@overlay, 0.5);
|
||||||
|
}
|
||||||
|
#workspaces button.active {
|
||||||
|
color: @base;
|
||||||
|
background: @accent;
|
||||||
|
}
|
||||||
|
#workspaces button.urgent {
|
||||||
|
color: @base;
|
||||||
|
background: @bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status semantics (neutral until a state fires) ──────────────────*/
|
||||||
|
#custom-recording.recording { color: @bad; }
|
||||||
|
#custom-updates.available { color: @accent; }
|
||||||
|
#custom-doctor { color: @bad; }
|
||||||
|
#idle_inhibitor.activated { color: @warn; }
|
||||||
|
#custom-nightlight.on { color: @warn; }
|
||||||
|
#custom-vpn.on { color: @good; }
|
||||||
|
#pulseaudio.muted { color: @muted; }
|
||||||
|
|
||||||
|
#battery.charging { color: @good; }
|
||||||
|
#battery.warning:not(.charging) { color: @warn; }
|
||||||
|
#battery.critical:not(.charging) { color: @bad; }
|
||||||
|
|
||||||
|
/* Icon-only status modules carry no text, so they don't get the 13pt
|
||||||
|
Pango icon span the icon+text modules (volume/battery/language) use —
|
||||||
|
bump their font-size to match, or these glyphs read noticeably smaller
|
||||||
|
than their neighbours. */
|
||||||
|
#custom-recording,
|
||||||
|
#custom-updates,
|
||||||
|
#custom-doctor,
|
||||||
|
#custom-nightlight,
|
||||||
|
#custom-vpn,
|
||||||
|
#custom-notification { font-size: 17px; }
|
||||||
|
/* Speedometer + caffeine cup render small in their em box — a touch more. */
|
||||||
|
#custom-powerprofile,
|
||||||
|
#idle_inhibitor { font-size: 18px; }
|
||||||
|
|
||||||
|
#custom-notification.notification { color: @accent; }
|
||||||
|
#custom-notification.dnd-none,
|
||||||
|
#custom-notification.dnd-notification { color: @muted; }
|
||||||
|
|
||||||
|
/* Tray shares the pill's glass — same transparent background, its own
|
||||||
|
modest padding so nm-applet & friends breathe with the status icons. */
|
||||||
|
#tray { padding: 0 6px; }
|
||||||
|
#tray > .passive { -gtk-icon-effect: dim; }
|
||||||
|
#tray > .needs-attention { -gtk-icon-effect: highlight; }
|
||||||
|
|
||||||
|
/* Power button closes the pill on the right; warms to alert on hover. */
|
||||||
|
#custom-powermenu { color: @subtext; padding: 0 12px 0 8px; }
|
||||||
|
#custom-powermenu:hover { color: @bad; }
|
||||||
|
|
||||||
|
/* ── Tooltips — frosted, frost-cyan edge ─────────────────────────────*/
|
||||||
|
tooltip {
|
||||||
|
background: alpha(@base, 0.96);
|
||||||
|
border: 1px solid alpha(@accentAlt, 0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
tooltip label { color: @text; }
|
||||||
176
themes/boreal/waybar.jsonc
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
{
|
||||||
|
"margin-top": 8,
|
||||||
|
"margin-left": 14,
|
||||||
|
"margin-right": 14,
|
||||||
|
"margin-bottom": 0,
|
||||||
|
"height": 38,
|
||||||
|
"layer": "bottom",
|
||||||
|
"position": "top",
|
||||||
|
"spacing": 4,
|
||||||
|
"reload_style_on_change": true,
|
||||||
|
"modules-left": [
|
||||||
|
"custom/nomarchy",
|
||||||
|
"clock",
|
||||||
|
"clock#date"
|
||||||
|
],
|
||||||
|
"modules-center": [
|
||||||
|
"hyprland/workspaces"
|
||||||
|
],
|
||||||
|
"modules-right": [
|
||||||
|
"custom/recording",
|
||||||
|
"custom/updates",
|
||||||
|
"custom/doctor",
|
||||||
|
"idle_inhibitor",
|
||||||
|
"custom/nightlight",
|
||||||
|
"hyprland/language",
|
||||||
|
"custom/vpn",
|
||||||
|
"pulseaudio",
|
||||||
|
"battery",
|
||||||
|
"custom/powerprofile",
|
||||||
|
"tray",
|
||||||
|
"custom/notification",
|
||||||
|
"custom/powermenu"
|
||||||
|
],
|
||||||
|
"custom/nomarchy": {
|
||||||
|
"interval": "once",
|
||||||
|
"format": "",
|
||||||
|
"on-click": "nomarchy-menu",
|
||||||
|
"tooltip-format": "Nomarchy menu"
|
||||||
|
},
|
||||||
|
"clock": {
|
||||||
|
"format": "{:%H:%M}",
|
||||||
|
"tooltip": true,
|
||||||
|
"tooltip-format": "{:%Z (UTC%z)}\n<tt><small>{calendar}</small></tt>"
|
||||||
|
},
|
||||||
|
"clock#date": {
|
||||||
|
"format": "{:%a %d %b}",
|
||||||
|
"tooltip": false
|
||||||
|
},
|
||||||
|
"hyprland/workspaces": {
|
||||||
|
"disable-scroll": true,
|
||||||
|
"all-outputs": true,
|
||||||
|
"on-click": "activate",
|
||||||
|
"on-scroll-up": "hyprctl dispatch workspace r+1",
|
||||||
|
"on-scroll-down": "hyprctl dispatch workspace r-1"
|
||||||
|
},
|
||||||
|
"custom/recording": {
|
||||||
|
"return-type": "json",
|
||||||
|
"interval": 10,
|
||||||
|
"signal": 8,
|
||||||
|
"exec": "nomarchy-record status",
|
||||||
|
"on-click": "nomarchy-record stop"
|
||||||
|
},
|
||||||
|
"custom/updates": {
|
||||||
|
"return-type": "json",
|
||||||
|
"interval": 1800,
|
||||||
|
"signal": 9,
|
||||||
|
"exec": "nomarchy-updates status",
|
||||||
|
"on-click": "sh -c '$TERMINAL -e nomarchy-updates upgrade'"
|
||||||
|
},
|
||||||
|
"custom/doctor": {
|
||||||
|
"return-type": "json",
|
||||||
|
"interval": 300,
|
||||||
|
"exec": "nomarchy-doctor-status",
|
||||||
|
"on-click": "nomarchy-menu doctor"
|
||||||
|
},
|
||||||
|
"idle_inhibitor": {
|
||||||
|
"format": "{icon}",
|
||||||
|
"format-icons": {
|
||||||
|
"activated": " ",
|
||||||
|
"deactivated": " "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"custom/nightlight": {
|
||||||
|
"return-type": "json",
|
||||||
|
"interval": 3,
|
||||||
|
"exec": "nomarchy-nightlight status",
|
||||||
|
"on-click": "nomarchy-nightlight toggle"
|
||||||
|
},
|
||||||
|
"hyprland/language": {
|
||||||
|
"format": "<span size='13pt'></span> {short}",
|
||||||
|
"tooltip": false
|
||||||
|
},
|
||||||
|
"custom/vpn": {
|
||||||
|
"return-type": "json",
|
||||||
|
"interval": 5,
|
||||||
|
"exec": "nomarchy-vpn-status",
|
||||||
|
"on-click": "nomarchy-vpn"
|
||||||
|
},
|
||||||
|
"pulseaudio": {
|
||||||
|
"scroll-step": 5,
|
||||||
|
"format": "<span size='13pt'>{icon}</span> {volume}%",
|
||||||
|
"format-bluetooth": "<span size='13pt'>{icon}</span> {volume}%",
|
||||||
|
"format-bluetooth-muted": " {icon}",
|
||||||
|
"format-muted": "<span size='13pt'></span> muted",
|
||||||
|
"format-icons": {
|
||||||
|
"headphone": "",
|
||||||
|
"hands-free": "",
|
||||||
|
"headset": "",
|
||||||
|
"phone": "",
|
||||||
|
"portable": "",
|
||||||
|
"car": "",
|
||||||
|
"default": [
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"on-click": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle",
|
||||||
|
"on-click-right": "pwvucontrol",
|
||||||
|
"tooltip-format": "{desc} | {volume}%"
|
||||||
|
},
|
||||||
|
"battery": {
|
||||||
|
"interval": 30,
|
||||||
|
"states": {
|
||||||
|
"warning": 25,
|
||||||
|
"critical": 10
|
||||||
|
},
|
||||||
|
"format": "<span size='13pt'>{icon}</span> {capacity}%",
|
||||||
|
"format-charging": "<span size='13pt'></span> {capacity}%",
|
||||||
|
"format-plugged": "<span size='13pt'></span> {capacity}%",
|
||||||
|
"format-icons": [
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"on-click": "nomarchy-menu power",
|
||||||
|
"tooltip-format": "Battery status"
|
||||||
|
},
|
||||||
|
"custom/powerprofile": {
|
||||||
|
"return-type": "json",
|
||||||
|
"interval": 5,
|
||||||
|
"exec": "nomarchy-powerprofile-status",
|
||||||
|
"on-click": "nomarchy-powerprofile-cycle"
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"icon-size": 15,
|
||||||
|
"spacing": 10
|
||||||
|
},
|
||||||
|
"custom/notification": {
|
||||||
|
"format": "{icon}",
|
||||||
|
"return-type": "json",
|
||||||
|
"exec": "swaync-client -swb",
|
||||||
|
"exec-if": "which swaync-client",
|
||||||
|
"escape": true,
|
||||||
|
"tooltip": true,
|
||||||
|
"on-click": "swaync-client -t -sw",
|
||||||
|
"on-click-right": "swaync-client -d -sw",
|
||||||
|
"format-icons": {
|
||||||
|
"none": "",
|
||||||
|
"notification": "",
|
||||||
|
"dnd-none": "",
|
||||||
|
"dnd-notification": "",
|
||||||
|
"inhibited-none": "",
|
||||||
|
"inhibited-notification": "",
|
||||||
|
"dnd-inhibited-none": "",
|
||||||
|
"dnd-inhibited-notification": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"custom/powermenu": {
|
||||||
|
"format": "",
|
||||||
|
"on-click": "nomarchy-menu power",
|
||||||
|
"tooltip": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,21 +7,38 @@
|
|||||||
"colors": {
|
"colors": {
|
||||||
"base": "#eff1f5",
|
"base": "#eff1f5",
|
||||||
"mantle": "#cbcdd0",
|
"mantle": "#cbcdd0",
|
||||||
"surface": "#bcc0cc",
|
"surface": "#bfc4d0",
|
||||||
"overlay": "#acb0be",
|
"overlay": "#acb0be",
|
||||||
"text": "#4c4f69",
|
"text": "#4c4f69",
|
||||||
"subtext": "#5c5f77",
|
"subtext": "#5c5f77",
|
||||||
"muted": "#acb0be",
|
"muted": "#8c8fa1",
|
||||||
"accent": "#1e66f5",
|
"accent": "#1e66f5",
|
||||||
"accentAlt": "#ea76cb",
|
"accentAlt": "#d260b5",
|
||||||
"good": "#40a02b",
|
"good": "#40a02b",
|
||||||
"warn": "#df8e1d",
|
"warn": "#d6860a",
|
||||||
"bad": "#d20f39"
|
"bad": "#d20f39"
|
||||||
},
|
},
|
||||||
"border": {
|
"border": {
|
||||||
"active": "accent",
|
"active": "accent",
|
||||||
"inactive": "overlay"
|
"inactive": "overlay"
|
||||||
},
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "JetBrainsMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 5,
|
||||||
|
"gapsOut": 12,
|
||||||
|
"borderSize": 2,
|
||||||
|
"rounding": 10,
|
||||||
|
"iconSize": 36,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.96,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
"ansi": [
|
"ansi": [
|
||||||
"#bcc0cc",
|
"#bcc0cc",
|
||||||
"#d20f39",
|
"#d20f39",
|
||||||
|
|||||||
BIN
themes/catppuccin-latte/preview.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
@@ -22,6 +22,23 @@
|
|||||||
"active": "accent",
|
"active": "accent",
|
||||||
"inactive": "overlay"
|
"inactive": "overlay"
|
||||||
},
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "JetBrainsMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 5,
|
||||||
|
"gapsOut": 12,
|
||||||
|
"borderSize": 2,
|
||||||
|
"rounding": 10,
|
||||||
|
"iconSize": 36,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.96,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
"ansi": [
|
"ansi": [
|
||||||
"#45475a",
|
"#45475a",
|
||||||
"#f38ba8",
|
"#f38ba8",
|
||||||
|
|||||||
BIN
themes/catppuccin/preview.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
@@ -1,2 +0,0 @@
|
|||||||
@define-color foreground #cdd6f4;
|
|
||||||
@define-color background #181824;
|
|
||||||
@@ -22,6 +22,23 @@
|
|||||||
"active": "accent",
|
"active": "accent",
|
||||||
"inactive": "overlay"
|
"inactive": "overlay"
|
||||||
},
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "JetBrainsMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 5,
|
||||||
|
"gapsOut": 12,
|
||||||
|
"borderSize": 2,
|
||||||
|
"rounding": 10,
|
||||||
|
"iconSize": 36,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.96,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
"ansi": [
|
"ansi": [
|
||||||
"#060B1E",
|
"#060B1E",
|
||||||
"#ED5B5A",
|
"#ED5B5A",
|
||||||
|
|||||||
BIN
themes/ethereal/preview.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
@@ -11,7 +11,7 @@
|
|||||||
"overlay": "#475258",
|
"overlay": "#475258",
|
||||||
"text": "#d3c6aa",
|
"text": "#d3c6aa",
|
||||||
"subtext": "#d3c6aa",
|
"subtext": "#d3c6aa",
|
||||||
"muted": "#475258",
|
"muted": "#59646a",
|
||||||
"accent": "#7fbbb3",
|
"accent": "#7fbbb3",
|
||||||
"accentAlt": "#d699b6",
|
"accentAlt": "#d699b6",
|
||||||
"good": "#a7c080",
|
"good": "#a7c080",
|
||||||
@@ -22,6 +22,23 @@
|
|||||||
"active": "accent",
|
"active": "accent",
|
||||||
"inactive": "overlay"
|
"inactive": "overlay"
|
||||||
},
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "JetBrainsMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 5,
|
||||||
|
"gapsOut": 12,
|
||||||
|
"borderSize": 2,
|
||||||
|
"rounding": 10,
|
||||||
|
"iconSize": 36,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.96,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
"ansi": [
|
"ansi": [
|
||||||
"#475258",
|
"#475258",
|
||||||
"#e67e80",
|
"#e67e80",
|
||||||
|
|||||||
BIN
themes/everforest/preview.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
60
themes/executive-slate.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"name": "Executive Slate",
|
||||||
|
"slug": "executive-slate",
|
||||||
|
"mode": "dark",
|
||||||
|
"wallpaper": "",
|
||||||
|
"colors": {
|
||||||
|
"base": "#191C24",
|
||||||
|
"mantle": "#111319",
|
||||||
|
"surface": "#2A3040",
|
||||||
|
"overlay": "#3E4759",
|
||||||
|
"text": "#D7DDEA",
|
||||||
|
"subtext": "#9AA5BB",
|
||||||
|
"muted": "#5C6578",
|
||||||
|
"accent": "#5A8AE6",
|
||||||
|
"accentAlt": "#86A9F2",
|
||||||
|
"good": "#6FAE72",
|
||||||
|
"warn": "#DBA85A",
|
||||||
|
"bad": "#D06B71"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"active": "accent",
|
||||||
|
"inactive": "overlay"
|
||||||
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "JetBrainsMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 5,
|
||||||
|
"gapsOut": 12,
|
||||||
|
"borderSize": 2,
|
||||||
|
"rounding": 10,
|
||||||
|
"iconSize": 36,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.96,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
|
"ansi": [
|
||||||
|
"#2A3040",
|
||||||
|
"#D06B71",
|
||||||
|
"#6FAE72",
|
||||||
|
"#DBA85A",
|
||||||
|
"#5A8AE6",
|
||||||
|
"#8C79D6",
|
||||||
|
"#55A5AE",
|
||||||
|
"#D7DDEA",
|
||||||
|
"#5C6578",
|
||||||
|
"#DE7C82",
|
||||||
|
"#82BE85",
|
||||||
|
"#E7BB6E",
|
||||||
|
"#86A9F2",
|
||||||
|
"#A594E8",
|
||||||
|
"#6FBEC6",
|
||||||
|
"#F0F3F9"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
themes/executive-slate/backgrounds/wallpaper.png
Normal file
|
After Width: | Height: | Size: 661 KiB |
119
themes/executive-slate/rofi.rasi
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Executive Slate — rofi whole-swap (themes/<slug>/rofi.rasi).
|
||||||
|
* Identity: a flat, sharp-cornered "control panel". No zebra, no glow —
|
||||||
|
* a solid slate window, a recessed surface inputbar, and one accent edge
|
||||||
|
* marking the selected row. The `configuration {}` block is omitted on
|
||||||
|
* purpose — modi/terminal/show-icons come from modules/home/rofi.nix.
|
||||||
|
* Element structure mirrors the generated theme so the theme-grid
|
||||||
|
* picker's per-invocation -theme-str (vertical cards) still lays out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {
|
||||||
|
base: #191C24;
|
||||||
|
surface: #2A3040;
|
||||||
|
overlay: #3E4759;
|
||||||
|
text: #D7DDEA;
|
||||||
|
subtext: #9AA5BB;
|
||||||
|
muted: #5C6578;
|
||||||
|
accent: #5A8AE6;
|
||||||
|
accentAlt: #86A9F2;
|
||||||
|
|
||||||
|
font: "Inter 13";
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: @text;
|
||||||
|
}
|
||||||
|
|
||||||
|
window {
|
||||||
|
background-color: @base;
|
||||||
|
border: 1px;
|
||||||
|
border-color: @overlay;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 40%;
|
||||||
|
location: center;
|
||||||
|
anchor: center;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainbox {
|
||||||
|
background-color: transparent;
|
||||||
|
children: [ inputbar, message, listview ];
|
||||||
|
spacing: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputbar {
|
||||||
|
children: [ prompt, entry ];
|
||||||
|
background-color: @surface;
|
||||||
|
text-color: @text;
|
||||||
|
border: 1px;
|
||||||
|
border-color: @overlay;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
spacing: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt { text-color: @accent; }
|
||||||
|
|
||||||
|
entry {
|
||||||
|
text-color: @text;
|
||||||
|
cursor: text;
|
||||||
|
placeholder: "Type to filter…";
|
||||||
|
placeholder-color: @muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
listview {
|
||||||
|
background-color: transparent;
|
||||||
|
columns: 1;
|
||||||
|
lines: 8;
|
||||||
|
dynamic: true;
|
||||||
|
scrollbar: false;
|
||||||
|
spacing: 4px;
|
||||||
|
padding: 4px 0px 0px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
element {
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: @text;
|
||||||
|
orientation: horizontal;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
spacing: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
element normal.normal,
|
||||||
|
element alternate.normal {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The one highlight: a solid surface row with an accent edge. */
|
||||||
|
element selected.normal {
|
||||||
|
background-color: @surface;
|
||||||
|
text-color: @text;
|
||||||
|
border: 0px 0px 0px 3px;
|
||||||
|
border-color: @accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
element-icon {
|
||||||
|
background-color: transparent;
|
||||||
|
size: 36px;
|
||||||
|
cursor: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
element-text {
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: inherit;
|
||||||
|
highlight: bold;
|
||||||
|
vertical-align: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message { padding: 0px; }
|
||||||
|
|
||||||
|
textbox {
|
||||||
|
background-color: @surface;
|
||||||
|
text-color: @text;
|
||||||
|
border: 1px;
|
||||||
|
border-color: @overlay;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollbar { width: 0px; }
|
||||||
180
themes/executive-slate/waybar.css
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/*
|
||||||
|
* Executive Slate — Waybar whole-swap (themes/<slug>/waybar.css).
|
||||||
|
*
|
||||||
|
* A theme waybar.css replaces the ENTIRE generated stylesheet and is read
|
||||||
|
* raw (no palette is prepended), so this file MUST define its own colors.
|
||||||
|
* Identity: a solid, non-transparent, edge-to-edge status strip — no
|
||||||
|
* floating island, sharp corners, tight modules, and a single accent
|
||||||
|
* underline marking the active workspace. Companion: waybar.jsonc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@define-color base #191C24;
|
||||||
|
@define-color mantle #111319;
|
||||||
|
@define-color surface #2A3040;
|
||||||
|
@define-color overlay #3E4759;
|
||||||
|
@define-color text #D7DDEA;
|
||||||
|
@define-color subtext #9AA5BB;
|
||||||
|
@define-color muted #5C6578;
|
||||||
|
@define-color accent #5A8AE6;
|
||||||
|
@define-color accentAlt #86A9F2;
|
||||||
|
@define-color good #6FAE72;
|
||||||
|
@define-color warn #DBA85A;
|
||||||
|
@define-color bad #D06B71;
|
||||||
|
|
||||||
|
* {
|
||||||
|
font-family: "Inter", "Symbols Nerd Font", "JetBrainsMono Nerd Font", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Solid bar, crisp edge against the content below. */
|
||||||
|
window#waybar {
|
||||||
|
background: @base;
|
||||||
|
border-bottom: 1px solid @overlay;
|
||||||
|
color: @text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modules-left,
|
||||||
|
.modules-center,
|
||||||
|
.modules-right {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uniform module rhythm; groups get a wider breath below. */
|
||||||
|
#custom-nomarchy,
|
||||||
|
#clock,
|
||||||
|
#custom-recording,
|
||||||
|
#idle_inhibitor,
|
||||||
|
#custom-nightlight,
|
||||||
|
#language,
|
||||||
|
#custom-updates,
|
||||||
|
#pulseaudio,
|
||||||
|
#custom-powerprofile,
|
||||||
|
#battery,
|
||||||
|
#custom-vpn,
|
||||||
|
#custom-doctor,
|
||||||
|
#custom-notification,
|
||||||
|
#tray,
|
||||||
|
#custom-powermenu {
|
||||||
|
color: @text;
|
||||||
|
padding: 0 11px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group heads — a wider gap before each functional cluster of the
|
||||||
|
right side (media/toggles · stats · status). Self-hiding heads
|
||||||
|
(e.g. no battery on a desktop) degrade to the uniform spacing. */
|
||||||
|
#pulseaudio,
|
||||||
|
#battery,
|
||||||
|
#custom-vpn { margin-left: 6px; }
|
||||||
|
|
||||||
|
/* ── Menu launcher — the branded Nomarchy mark ───────────────────────
|
||||||
|
The mark lives at U+F000 in the dedicated "Nomarchy" font; pin the
|
||||||
|
family or the Nerd Font's glass glyph at the same codepoint wins. */
|
||||||
|
#custom-nomarchy {
|
||||||
|
color: @accent;
|
||||||
|
font-family: Nomarchy;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
#custom-nomarchy:hover {
|
||||||
|
background: @surface;
|
||||||
|
color: @accentAlt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Workspaces — flat cells, active carries an accent underline ──── */
|
||||||
|
#workspaces { padding: 0; margin: 0; }
|
||||||
|
|
||||||
|
#workspaces button {
|
||||||
|
color: @subtext;
|
||||||
|
padding: 0 11px;
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: color 0.15s ease-in-out, background 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#workspaces button:hover {
|
||||||
|
background: @surface;
|
||||||
|
color: @text;
|
||||||
|
border-bottom: 2px solid @overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
#workspaces button.active {
|
||||||
|
color: @text;
|
||||||
|
background: @surface;
|
||||||
|
border-bottom: 2px solid @accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#workspaces button.urgent {
|
||||||
|
color: @bad;
|
||||||
|
border-bottom: 2px solid @bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Clock — time (center) and date (left) share the #clock id ───── */
|
||||||
|
#clock {
|
||||||
|
color: @text;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status cluster: neutral by default, semantic on state ───────── */
|
||||||
|
#custom-recording.recording { color: @bad; }
|
||||||
|
|
||||||
|
#idle_inhibitor.activated { color: @warn; }
|
||||||
|
|
||||||
|
#custom-nightlight.on { color: @warn; }
|
||||||
|
|
||||||
|
#custom-updates.available { color: @accent; }
|
||||||
|
|
||||||
|
/* Icon-only status modules carry no text, so they don't get the 13pt
|
||||||
|
Pango icon span the icon+text modules (volume/battery/language) use —
|
||||||
|
bump their font-size to match, or these glyphs read smaller than their
|
||||||
|
neighbours. */
|
||||||
|
#custom-recording,
|
||||||
|
#custom-nightlight,
|
||||||
|
#custom-updates,
|
||||||
|
#custom-vpn,
|
||||||
|
#custom-doctor,
|
||||||
|
#custom-notification { font-size: 17px; }
|
||||||
|
/* Speedometer + caffeine cup render small in their em box — a touch more. */
|
||||||
|
#custom-powerprofile,
|
||||||
|
#idle_inhibitor { font-size: 18px; }
|
||||||
|
|
||||||
|
#pulseaudio.muted { color: @muted; }
|
||||||
|
|
||||||
|
#battery.charging { color: @good; }
|
||||||
|
#battery.warning:not(.charging) { color: @warn; }
|
||||||
|
#battery.critical:not(.charging) {
|
||||||
|
color: @bad;
|
||||||
|
border-bottom: 2px solid @bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
#custom-vpn.on { color: @good; }
|
||||||
|
|
||||||
|
/* Doctor is only rendered when something is ✖, so always the alert. */
|
||||||
|
#custom-doctor { color: @bad; }
|
||||||
|
|
||||||
|
#custom-notification.notification { color: @accent; }
|
||||||
|
#custom-notification.dnd-none,
|
||||||
|
#custom-notification.dnd-notification { color: @muted; }
|
||||||
|
|
||||||
|
#tray { color: @text; }
|
||||||
|
|
||||||
|
#custom-powermenu { color: @subtext; }
|
||||||
|
#custom-powermenu:hover {
|
||||||
|
background: @surface;
|
||||||
|
color: @bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tooltips ─────────────────────────────────────────────────────── */
|
||||||
|
tooltip {
|
||||||
|
background: @surface;
|
||||||
|
border: 1px solid @overlay;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
tooltip label {
|
||||||
|
color: @text;
|
||||||
|
}
|
||||||
175
themes/executive-slate/waybar.jsonc
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
{
|
||||||
|
"margin-top": 0,
|
||||||
|
"margin-left": 0,
|
||||||
|
"margin-bottom": 0,
|
||||||
|
"margin-right": 0,
|
||||||
|
"height": 34,
|
||||||
|
"layer": "bottom",
|
||||||
|
"position": "top",
|
||||||
|
"spacing": 0,
|
||||||
|
"reload_style_on_change": true,
|
||||||
|
"modules-left": [
|
||||||
|
"custom/nomarchy",
|
||||||
|
"hyprland/workspaces",
|
||||||
|
"clock#date"
|
||||||
|
],
|
||||||
|
"modules-center": [
|
||||||
|
"clock"
|
||||||
|
],
|
||||||
|
"modules-right": [
|
||||||
|
"custom/recording",
|
||||||
|
"idle_inhibitor",
|
||||||
|
"custom/nightlight",
|
||||||
|
"hyprland/language",
|
||||||
|
"custom/updates",
|
||||||
|
"pulseaudio",
|
||||||
|
"custom/powerprofile",
|
||||||
|
"battery",
|
||||||
|
"custom/vpn",
|
||||||
|
"custom/doctor",
|
||||||
|
"custom/notification",
|
||||||
|
"tray",
|
||||||
|
"custom/powermenu"
|
||||||
|
],
|
||||||
|
"custom/nomarchy": {
|
||||||
|
"interval": "once",
|
||||||
|
"format": "",
|
||||||
|
"on-click": "nomarchy-menu",
|
||||||
|
"tooltip-format": "Nomarchy menu"
|
||||||
|
},
|
||||||
|
"hyprland/workspaces": {
|
||||||
|
"disable-scroll": true,
|
||||||
|
"all-outputs": true,
|
||||||
|
"on-click": "activate",
|
||||||
|
"on-scroll-up": "hyprctl dispatch workspace r+1",
|
||||||
|
"on-scroll-down": "hyprctl dispatch workspace r-1"
|
||||||
|
},
|
||||||
|
"clock": {
|
||||||
|
"format": "{:%H:%M}",
|
||||||
|
"tooltip": true,
|
||||||
|
"tooltip-format": "{:%Z (UTC%z)}\n<tt><small>{calendar}</small></tt>"
|
||||||
|
},
|
||||||
|
"clock#date": {
|
||||||
|
"format": "{:%a %d %b}",
|
||||||
|
"tooltip": false
|
||||||
|
},
|
||||||
|
"custom/recording": {
|
||||||
|
"return-type": "json",
|
||||||
|
"interval": 10,
|
||||||
|
"signal": 8,
|
||||||
|
"exec": "nomarchy-record status",
|
||||||
|
"on-click": "nomarchy-record stop"
|
||||||
|
},
|
||||||
|
"idle_inhibitor": {
|
||||||
|
"format": "{icon}",
|
||||||
|
"format-icons": {
|
||||||
|
"activated": " ",
|
||||||
|
"deactivated": " "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"custom/nightlight": {
|
||||||
|
"return-type": "json",
|
||||||
|
"interval": 3,
|
||||||
|
"exec": "nomarchy-nightlight status",
|
||||||
|
"on-click": "nomarchy-nightlight toggle"
|
||||||
|
},
|
||||||
|
"hyprland/language": {
|
||||||
|
"format": "<span size='13pt'></span> {short}",
|
||||||
|
"tooltip": false
|
||||||
|
},
|
||||||
|
"custom/updates": {
|
||||||
|
"return-type": "json",
|
||||||
|
"interval": 1800,
|
||||||
|
"signal": 9,
|
||||||
|
"exec": "nomarchy-updates status",
|
||||||
|
"on-click": "sh -c '$TERMINAL -e nomarchy-updates upgrade'"
|
||||||
|
},
|
||||||
|
"pulseaudio": {
|
||||||
|
"scroll-step": 5,
|
||||||
|
"format": "<span size='13pt'>{icon}</span> {volume}%",
|
||||||
|
"format-bluetooth": "<span size='13pt'>{icon}</span> {volume}%",
|
||||||
|
"format-bluetooth-muted": " {icon}",
|
||||||
|
"format-muted": "<span size='13pt'></span> muted",
|
||||||
|
"format-icons": {
|
||||||
|
"headphone": "",
|
||||||
|
"hands-free": "",
|
||||||
|
"headset": "",
|
||||||
|
"phone": "",
|
||||||
|
"portable": "",
|
||||||
|
"car": "",
|
||||||
|
"default": [
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"on-click": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle",
|
||||||
|
"on-click-right": "pwvucontrol",
|
||||||
|
"tooltip-format": "{desc} | {volume}%"
|
||||||
|
},
|
||||||
|
"custom/powerprofile": {
|
||||||
|
"return-type": "json",
|
||||||
|
"interval": 5,
|
||||||
|
"exec": "nomarchy-powerprofile-status",
|
||||||
|
"on-click": "nomarchy-powerprofile-cycle"
|
||||||
|
},
|
||||||
|
"battery": {
|
||||||
|
"interval": 30,
|
||||||
|
"states": {
|
||||||
|
"warning": 25,
|
||||||
|
"critical": 10
|
||||||
|
},
|
||||||
|
"format": "<span size='13pt'>{icon}</span> {capacity}%",
|
||||||
|
"format-charging": "<span size='13pt'></span> {capacity}%",
|
||||||
|
"format-plugged": "<span size='13pt'></span> {capacity}%",
|
||||||
|
"format-icons": [
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"on-click": "nomarchy-menu power",
|
||||||
|
"tooltip-format": "Battery status"
|
||||||
|
},
|
||||||
|
"custom/vpn": {
|
||||||
|
"return-type": "json",
|
||||||
|
"interval": 5,
|
||||||
|
"exec": "nomarchy-vpn-status",
|
||||||
|
"on-click": "nomarchy-vpn"
|
||||||
|
},
|
||||||
|
"custom/doctor": {
|
||||||
|
"return-type": "json",
|
||||||
|
"interval": 300,
|
||||||
|
"exec": "nomarchy-doctor-status",
|
||||||
|
"on-click": "nomarchy-menu doctor"
|
||||||
|
},
|
||||||
|
"custom/notification": {
|
||||||
|
"format": "{icon}",
|
||||||
|
"return-type": "json",
|
||||||
|
"exec": "swaync-client -swb",
|
||||||
|
"exec-if": "which swaync-client",
|
||||||
|
"escape": true,
|
||||||
|
"tooltip": true,
|
||||||
|
"on-click": "swaync-client -t -sw",
|
||||||
|
"on-click-right": "swaync-client -d -sw",
|
||||||
|
"format-icons": {
|
||||||
|
"none": "",
|
||||||
|
"notification": "",
|
||||||
|
"dnd-none": "",
|
||||||
|
"dnd-notification": "",
|
||||||
|
"inhibited-none": "",
|
||||||
|
"inhibited-notification": "",
|
||||||
|
"dnd-inhibited-none": "",
|
||||||
|
"dnd-inhibited-notification": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"spacing": 8
|
||||||
|
},
|
||||||
|
"custom/powermenu": {
|
||||||
|
"format": "",
|
||||||
|
"on-click": "nomarchy-menu power",
|
||||||
|
"tooltip": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,21 +7,38 @@
|
|||||||
"colors": {
|
"colors": {
|
||||||
"base": "#FFFCF0",
|
"base": "#FFFCF0",
|
||||||
"mantle": "#d9d6cc",
|
"mantle": "#d9d6cc",
|
||||||
"surface": "#100F0F",
|
"surface": "#E6E4D9",
|
||||||
"overlay": "#100F0F",
|
"overlay": "#B7B5AC",
|
||||||
"text": "#100F0F",
|
"text": "#100F0F",
|
||||||
"subtext": "#FFFCF0",
|
"subtext": "#6F6E69",
|
||||||
"muted": "#100F0F",
|
"muted": "#878580",
|
||||||
"accent": "#205EA6",
|
"accent": "#205EA6",
|
||||||
"accentAlt": "#CE5D97",
|
"accentAlt": "#A02F6F",
|
||||||
"good": "#879A39",
|
"good": "#66800B",
|
||||||
"warn": "#D0A215",
|
"warn": "#AD8301",
|
||||||
"bad": "#D14D41"
|
"bad": "#AF3029"
|
||||||
},
|
},
|
||||||
"border": {
|
"border": {
|
||||||
"active": "accent",
|
"active": "accent",
|
||||||
"inactive": "overlay"
|
"inactive": "overlay"
|
||||||
},
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "JetBrainsMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 5,
|
||||||
|
"gapsOut": 12,
|
||||||
|
"borderSize": 2,
|
||||||
|
"rounding": 10,
|
||||||
|
"iconSize": 36,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.96,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
"ansi": [
|
"ansi": [
|
||||||
"#100F0F",
|
"#100F0F",
|
||||||
"#D14D41",
|
"#D14D41",
|
||||||
|
|||||||
BIN
themes/flexoki-light/preview.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
@@ -11,7 +11,7 @@
|
|||||||
"overlay": "#3c3836",
|
"overlay": "#3c3836",
|
||||||
"text": "#d4be98",
|
"text": "#d4be98",
|
||||||
"subtext": "#d4be98",
|
"subtext": "#d4be98",
|
||||||
"muted": "#3c3836",
|
"muted": "#7c6f64",
|
||||||
"accent": "#7daea3",
|
"accent": "#7daea3",
|
||||||
"accentAlt": "#d3869b",
|
"accentAlt": "#d3869b",
|
||||||
"good": "#a9b665",
|
"good": "#a9b665",
|
||||||
@@ -22,6 +22,23 @@
|
|||||||
"active": "accent",
|
"active": "accent",
|
||||||
"inactive": "overlay"
|
"inactive": "overlay"
|
||||||
},
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "JetBrainsMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 5,
|
||||||
|
"gapsOut": 12,
|
||||||
|
"borderSize": 2,
|
||||||
|
"rounding": 10,
|
||||||
|
"iconSize": 36,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.96,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
"ansi": [
|
"ansi": [
|
||||||
"#3c3836",
|
"#3c3836",
|
||||||
"#ea6962",
|
"#ea6962",
|
||||||
|
|||||||
BIN
themes/gruvbox/preview.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
@@ -22,6 +22,23 @@
|
|||||||
"active": "accent",
|
"active": "accent",
|
||||||
"inactive": "overlay"
|
"inactive": "overlay"
|
||||||
},
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "JetBrainsMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 5,
|
||||||
|
"gapsOut": 12,
|
||||||
|
"borderSize": 2,
|
||||||
|
"rounding": 10,
|
||||||
|
"iconSize": 36,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.96,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
"ansi": [
|
"ansi": [
|
||||||
"#0B0C16",
|
"#0B0C16",
|
||||||
"#50f872",
|
"#50f872",
|
||||||
|
|||||||
BIN
themes/hackerman/preview.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
@@ -7,7 +7,7 @@
|
|||||||
"colors": {
|
"colors": {
|
||||||
"base": "#1f1f28",
|
"base": "#1f1f28",
|
||||||
"mantle": "#1a1a22",
|
"mantle": "#1a1a22",
|
||||||
"surface": "#090618",
|
"surface": "#2a2a37",
|
||||||
"overlay": "#727169",
|
"overlay": "#727169",
|
||||||
"text": "#dcd7ba",
|
"text": "#dcd7ba",
|
||||||
"subtext": "#c8c093",
|
"subtext": "#c8c093",
|
||||||
@@ -22,6 +22,23 @@
|
|||||||
"active": "text",
|
"active": "text",
|
||||||
"inactive": "overlay"
|
"inactive": "overlay"
|
||||||
},
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "JetBrainsMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 5,
|
||||||
|
"gapsOut": 12,
|
||||||
|
"borderSize": 2,
|
||||||
|
"rounding": 10,
|
||||||
|
"iconSize": 36,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.96,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
"ansi": [
|
"ansi": [
|
||||||
"#090618",
|
"#090618",
|
||||||
"#c34043",
|
"#c34043",
|
||||||
|
|||||||
BIN
themes/kanagawa/preview.png
Normal file
|
After Width: | Height: | Size: 229 KiB |
107
themes/kanagawa/rofi.rasi
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Nomarchy Kanagawa — rofi whole-swap (themes/<slug>/rofi.rasi).
|
||||||
|
* Ink-and-paper identity: a sumi-ink canvas inside a warm washi-paper frame
|
||||||
|
* (the same text-toned border the theme uses on windows — the only warm
|
||||||
|
* frame of the four), the inputbar a deeper ink well, and the crystal-blue
|
||||||
|
* wave reserved for the selected row. Soft rounding, an unhurried feel.
|
||||||
|
* The `configuration {}` block is omitted on purpose — modi/terminal/
|
||||||
|
* show-icons come from modules/home/rofi.nix.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {
|
||||||
|
sumi: #1f1f28; /* sumi ink — the canvas */
|
||||||
|
well: #090618; /* deeper ink — inputbar + zebra */
|
||||||
|
paper: #dcd7ba; /* washi paper — text + frame */
|
||||||
|
parch: #c8c093; /* parchment — the prompt */
|
||||||
|
grey: #727169;
|
||||||
|
wave: #7e9cd8; /* crystal-blue wave — the selection */
|
||||||
|
|
||||||
|
font: "JetBrainsMono Nerd Font 14";
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: @paper;
|
||||||
|
}
|
||||||
|
|
||||||
|
window {
|
||||||
|
background-color: @sumi;
|
||||||
|
border: 2px;
|
||||||
|
border-color: @paper; /* the warm paper frame (kanagawa's border tone) */
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 40%;
|
||||||
|
location: center;
|
||||||
|
anchor: center;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainbox {
|
||||||
|
background-color: transparent;
|
||||||
|
children: [ inputbar, message, listview ];
|
||||||
|
spacing: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputbar {
|
||||||
|
children: [ prompt, entry ];
|
||||||
|
background-color: @well; /* the ink well */
|
||||||
|
text-color: @paper;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
spacing: 8px;
|
||||||
|
margin: 0px 0px 8px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt { text-color: @parch; }
|
||||||
|
|
||||||
|
entry {
|
||||||
|
text-color: @paper;
|
||||||
|
cursor: text;
|
||||||
|
placeholder: "search…";
|
||||||
|
placeholder-color: @grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
listview {
|
||||||
|
background-color: transparent;
|
||||||
|
columns: 1;
|
||||||
|
lines: 8;
|
||||||
|
dynamic: true;
|
||||||
|
scrollbar: false;
|
||||||
|
spacing: 4px;
|
||||||
|
padding: 8px 0px 0px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
element {
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: @paper;
|
||||||
|
orientation: horizontal;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
spacing: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
element alternate.normal {
|
||||||
|
background-color: @well;
|
||||||
|
}
|
||||||
|
element selected.normal {
|
||||||
|
background-color: @wave;
|
||||||
|
text-color: @sumi;
|
||||||
|
}
|
||||||
|
|
||||||
|
element-icon {
|
||||||
|
background-color: transparent;
|
||||||
|
size: 36px;
|
||||||
|
cursor: inherit;
|
||||||
|
}
|
||||||
|
element-text {
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: inherit;
|
||||||
|
highlight: bold;
|
||||||
|
vertical-align: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message { padding: 8px 0px 0px 0px; }
|
||||||
|
textbox {
|
||||||
|
background-color: @well;
|
||||||
|
text-color: @paper;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollbar { width: 0px; }
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"overlay": "#304860",
|
"overlay": "#304860",
|
||||||
"text": "#d6e2ee",
|
"text": "#d6e2ee",
|
||||||
"subtext": "#d6e2ee",
|
"subtext": "#d6e2ee",
|
||||||
"muted": "#304860",
|
"muted": "#3c556e",
|
||||||
"accent": "#f2fcff",
|
"accent": "#f2fcff",
|
||||||
"accentAlt": "#8bc9eb",
|
"accentAlt": "#8bc9eb",
|
||||||
"good": "#5e95bc",
|
"good": "#5e95bc",
|
||||||
@@ -22,6 +22,23 @@
|
|||||||
"active": "accent",
|
"active": "accent",
|
||||||
"inactive": "overlay"
|
"inactive": "overlay"
|
||||||
},
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "JetBrainsMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 5,
|
||||||
|
"gapsOut": 12,
|
||||||
|
"borderSize": 2,
|
||||||
|
"rounding": 10,
|
||||||
|
"iconSize": 36,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.96,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
"ansi": [
|
"ansi": [
|
||||||
"#1b2d40",
|
"#1b2d40",
|
||||||
"#4d86b0",
|
"#4d86b0",
|
||||||
|
|||||||
BIN
themes/lumon/preview.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
110
themes/lumon/rofi.rasi
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Nomarchy Lumon — rofi whole-swap (themes/<slug>/rofi.rasi).
|
||||||
|
* Clinical-terminal identity (the MDR readout): a thick cyan bezel around a
|
||||||
|
* cold teal screen, the inputbar FRAMED rather than filled (a data field,
|
||||||
|
* not a button), near-white headings, cyan selection. Faintly softened
|
||||||
|
* corners keep it boxy without going razor-sharp like Retro 82.
|
||||||
|
* The `configuration {}` block is omitted on purpose — modi/terminal/
|
||||||
|
* show-icons come from modules/home/rofi.nix.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {
|
||||||
|
base: #16242d;
|
||||||
|
surface: #1b2d40;
|
||||||
|
overlay: #304860;
|
||||||
|
text: #d6e2ee;
|
||||||
|
ice: #f2fcff; /* near-white — the prompt */
|
||||||
|
cyan: #8bc9eb; /* the Lumon glow — bezel + select */
|
||||||
|
|
||||||
|
font: "JetBrainsMono Nerd Font 13";
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: @text;
|
||||||
|
}
|
||||||
|
|
||||||
|
window {
|
||||||
|
background-color: @base;
|
||||||
|
border: 3px; /* a screen bezel */
|
||||||
|
border-color: @cyan;
|
||||||
|
border-radius: 4px; /* boxy, faintly softened */
|
||||||
|
width: 40%;
|
||||||
|
location: center;
|
||||||
|
anchor: center;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainbox {
|
||||||
|
background-color: transparent;
|
||||||
|
children: [ inputbar, message, listview ];
|
||||||
|
spacing: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Framed, not filled — a readout field rather than a button. */
|
||||||
|
inputbar {
|
||||||
|
children: [ prompt, entry ];
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: @text;
|
||||||
|
border: 1px;
|
||||||
|
border-color: @overlay;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
spacing: 8px;
|
||||||
|
margin: 0px 0px 8px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt { text-color: @ice; }
|
||||||
|
|
||||||
|
entry {
|
||||||
|
text-color: @text;
|
||||||
|
cursor: text;
|
||||||
|
placeholder: "refine…";
|
||||||
|
placeholder-color: @overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
listview {
|
||||||
|
background-color: transparent;
|
||||||
|
columns: 1;
|
||||||
|
lines: 8;
|
||||||
|
dynamic: true;
|
||||||
|
scrollbar: false;
|
||||||
|
spacing: 4px;
|
||||||
|
padding: 8px 0px 0px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
element {
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: @text;
|
||||||
|
orientation: horizontal;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
spacing: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
element alternate.normal {
|
||||||
|
background-color: @surface;
|
||||||
|
}
|
||||||
|
element selected.normal {
|
||||||
|
background-color: @cyan;
|
||||||
|
text-color: @base;
|
||||||
|
}
|
||||||
|
|
||||||
|
element-icon {
|
||||||
|
background-color: transparent;
|
||||||
|
size: 36px;
|
||||||
|
cursor: inherit;
|
||||||
|
}
|
||||||
|
element-text {
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: inherit;
|
||||||
|
highlight: bold;
|
||||||
|
vertical-align: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message { padding: 8px 0px 0px 0px; }
|
||||||
|
textbox {
|
||||||
|
background-color: @surface;
|
||||||
|
text-color: @text;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollbar { width: 0px; }
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
@define-color foreground #d6e2ee;
|
|
||||||
@define-color background #213442;
|
|
||||||
@@ -22,6 +22,23 @@
|
|||||||
"active": "accent",
|
"active": "accent",
|
||||||
"inactive": "overlay"
|
"inactive": "overlay"
|
||||||
},
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "JetBrainsMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 5,
|
||||||
|
"gapsOut": 12,
|
||||||
|
"borderSize": 2,
|
||||||
|
"rounding": 10,
|
||||||
|
"iconSize": 36,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.96,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
"ansi": [
|
"ansi": [
|
||||||
"#333333",
|
"#333333",
|
||||||
"#D35F5F",
|
"#D35F5F",
|
||||||
|
|||||||
BIN
themes/matte-black/preview.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
@@ -7,7 +7,7 @@
|
|||||||
"colors": {
|
"colors": {
|
||||||
"base": "#222222",
|
"base": "#222222",
|
||||||
"mantle": "#1d1d1d",
|
"mantle": "#1d1d1d",
|
||||||
"surface": "#000000",
|
"surface": "#2e2e2e",
|
||||||
"overlay": "#666666",
|
"overlay": "#666666",
|
||||||
"text": "#c2c2b0",
|
"text": "#c2c2b0",
|
||||||
"subtext": "#d7c483",
|
"subtext": "#d7c483",
|
||||||
@@ -22,6 +22,23 @@
|
|||||||
"active": "accent",
|
"active": "accent",
|
||||||
"inactive": "overlay"
|
"inactive": "overlay"
|
||||||
},
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "JetBrainsMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 5,
|
||||||
|
"gapsOut": 12,
|
||||||
|
"borderSize": 2,
|
||||||
|
"rounding": 10,
|
||||||
|
"iconSize": 36,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.96,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
"ansi": [
|
"ansi": [
|
||||||
"#000000",
|
"#000000",
|
||||||
"#685742",
|
"#685742",
|
||||||
|
|||||||
BIN
themes/miasma/preview.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
60
themes/neon-glass.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"name": "Neon Glass",
|
||||||
|
"slug": "neon-glass",
|
||||||
|
"mode": "dark",
|
||||||
|
"wallpaper": "",
|
||||||
|
"colors": {
|
||||||
|
"base": "#0B0E14",
|
||||||
|
"mantle": "#07090D",
|
||||||
|
"surface": "#151A22",
|
||||||
|
"overlay": "#1E2530",
|
||||||
|
"text": "#E0E6F0",
|
||||||
|
"subtext": "#8F9BB3",
|
||||||
|
"muted": "#4B566B",
|
||||||
|
"accent": "#00F0FF",
|
||||||
|
"accentAlt": "#B026FF",
|
||||||
|
"good": "#00FFA3",
|
||||||
|
"warn": "#FFD000",
|
||||||
|
"bad": "#FF003C"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"active": "accent",
|
||||||
|
"inactive": "overlay"
|
||||||
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "JetBrainsMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 5,
|
||||||
|
"gapsOut": 12,
|
||||||
|
"borderSize": 2,
|
||||||
|
"rounding": 10,
|
||||||
|
"iconSize": 36,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.96,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
|
"ansi": [
|
||||||
|
"#151A22",
|
||||||
|
"#FF003C",
|
||||||
|
"#00FFA3",
|
||||||
|
"#FFD000",
|
||||||
|
"#00F0FF",
|
||||||
|
"#B026FF",
|
||||||
|
"#00E5FF",
|
||||||
|
"#E0E6F0",
|
||||||
|
"#4B566B",
|
||||||
|
"#FF3366",
|
||||||
|
"#33FFB8",
|
||||||
|
"#FFE033",
|
||||||
|
"#33F3FF",
|
||||||
|
"#C44DFF",
|
||||||
|
"#33EBFF",
|
||||||
|
"#FFFFFF"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
themes/neon-glass/backgrounds/wallpaper.png
Normal file
|
After Width: | Height: | Size: 702 KiB |
85
themes/neon-glass/rofi.rasi
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
* Neon Glass Rofi Theme
|
||||||
|
* Glassmorphic, transparent, blurred background with glowing accents.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {
|
||||||
|
/* Using standard theme palette via env variables injected by home-manager,
|
||||||
|
or fallback if testing independently. The whole-swap still gets the
|
||||||
|
colors injected into the RASI by the module! */
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: var(text, #E0E6F0);
|
||||||
|
font: "Inter 13";
|
||||||
|
}
|
||||||
|
|
||||||
|
window {
|
||||||
|
width: 600px;
|
||||||
|
/* transparent base with slight tint */
|
||||||
|
background-color: rgba(11, 14, 20, 0.45);
|
||||||
|
border: 1px;
|
||||||
|
border-color: rgba(0, 240, 255, 0.3);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
/* Blur would be applied via hyprland layerrule for rofi */
|
||||||
|
}
|
||||||
|
|
||||||
|
mainbox {
|
||||||
|
spacing: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputbar {
|
||||||
|
background-color: rgba(21, 26, 34, 0.5);
|
||||||
|
border: 1px;
|
||||||
|
border-color: rgba(0, 240, 255, 0.5);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
spacing: 12px;
|
||||||
|
children: [ prompt, entry ];
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt {
|
||||||
|
text-color: var(accent, #00F0FF);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry {
|
||||||
|
placeholder: "Search...";
|
||||||
|
placeholder-color: var(subtext, #8F9BB3);
|
||||||
|
}
|
||||||
|
|
||||||
|
listview {
|
||||||
|
lines: 7;
|
||||||
|
spacing: 8px;
|
||||||
|
scrollbar: false;
|
||||||
|
border: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
element {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
spacing: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
element normal.normal, element alternate.normal {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
element selected.normal {
|
||||||
|
background-color: rgba(0, 240, 255, 0.15);
|
||||||
|
border: 1px;
|
||||||
|
border-color: rgba(0, 240, 255, 0.4);
|
||||||
|
text-color: var(accent, #00F0FF);
|
||||||
|
}
|
||||||
|
|
||||||
|
element-icon {
|
||||||
|
size: 24px;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
element-text {
|
||||||
|
background-color: transparent;
|
||||||
|
vertical-align: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
element-text selected {
|
||||||
|
text-color: var(accent, #00F0FF);
|
||||||
|
}
|
||||||
108
themes/neon-glass/waybar.css
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/* Neon Glass Waybar — Segmented floating pills with glassmorphism */
|
||||||
|
|
||||||
|
* {
|
||||||
|
font-family: "Inter", "Symbols Nerd Font", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
window#waybar {
|
||||||
|
/* The main bar background is transparent so the wallpaper shows through */
|
||||||
|
background: transparent;
|
||||||
|
color: @text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The pill containers: left, center, right.
|
||||||
|
* Glassmorphism: semi-transparent base + blur (blur requires hyprland layerrule if waybar doesn't natively blur, but alpha base helps).
|
||||||
|
*/
|
||||||
|
.modules-left,
|
||||||
|
.modules-center,
|
||||||
|
.modules-right {
|
||||||
|
background: alpha(@base, 0.45);
|
||||||
|
border: 1px solid alpha(@accent, 0.2);
|
||||||
|
border-radius: 20px;
|
||||||
|
margin: 4px 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add a glowing top border effect on hover or generally to accent the glass */
|
||||||
|
.modules-left {
|
||||||
|
border-top: 1px solid alpha(@accent, 0.6);
|
||||||
|
}
|
||||||
|
.modules-center {
|
||||||
|
border-top: 1px solid alpha(@text, 0.3);
|
||||||
|
}
|
||||||
|
.modules-right {
|
||||||
|
border-top: 1px solid alpha(@accentAlt, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove default padding from individual modules so they fit snug in the pill */
|
||||||
|
#workspaces button,
|
||||||
|
#window,
|
||||||
|
#clock,
|
||||||
|
#tray,
|
||||||
|
#pulseaudio,
|
||||||
|
#battery,
|
||||||
|
#custom-notification,
|
||||||
|
#custom-vpn,
|
||||||
|
#custom-powerprofile,
|
||||||
|
#custom-updates,
|
||||||
|
#custom-doctor {
|
||||||
|
padding: 0 8px;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workspaces */
|
||||||
|
#workspaces button {
|
||||||
|
color: @subtext;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#workspaces button:hover {
|
||||||
|
background: alpha(@surface, 0.5);
|
||||||
|
color: @text;
|
||||||
|
}
|
||||||
|
|
||||||
|
#workspaces button.active {
|
||||||
|
color: @accent;
|
||||||
|
background: alpha(@accent, 0.15);
|
||||||
|
box-shadow: 0 0 8px alpha(@accent, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#workspaces button.urgent {
|
||||||
|
color: @bad;
|
||||||
|
background: alpha(@bad, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Indicators */
|
||||||
|
#battery.charging { color: @good; }
|
||||||
|
#battery.warning:not(.charging) { color: @warn; }
|
||||||
|
#battery.critical:not(.charging) {
|
||||||
|
color: @bad;
|
||||||
|
animation: blink 1s steps(2, start) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pulseaudio.muted { color: @muted; }
|
||||||
|
|
||||||
|
#custom-updates { color: @accentAlt; }
|
||||||
|
#custom-doctor { color: @bad; }
|
||||||
|
#custom-vpn { color: @accent; }
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
to { color: @text; }
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip {
|
||||||
|
background: alpha(@base, 0.85);
|
||||||
|
border: 1px solid alpha(@accent, 0.4);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
tooltip label {
|
||||||
|
color: @text;
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"overlay": "#4c566a",
|
"overlay": "#4c566a",
|
||||||
"text": "#d8dee9",
|
"text": "#d8dee9",
|
||||||
"subtext": "#e5e9f0",
|
"subtext": "#e5e9f0",
|
||||||
"muted": "#4c566a",
|
"muted": "#616e88",
|
||||||
"accent": "#81a1c1",
|
"accent": "#81a1c1",
|
||||||
"accentAlt": "#b48ead",
|
"accentAlt": "#b48ead",
|
||||||
"good": "#a3be8c",
|
"good": "#a3be8c",
|
||||||
@@ -22,6 +22,23 @@
|
|||||||
"active": "accent",
|
"active": "accent",
|
||||||
"inactive": "overlay"
|
"inactive": "overlay"
|
||||||
},
|
},
|
||||||
|
"fonts": {
|
||||||
|
"mono": "JetBrainsMono Nerd Font",
|
||||||
|
"ui": "Inter",
|
||||||
|
"size": 11
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"gapsIn": 5,
|
||||||
|
"gapsOut": 12,
|
||||||
|
"borderSize": 2,
|
||||||
|
"rounding": 10,
|
||||||
|
"iconSize": 36,
|
||||||
|
"activeOpacity": 1.0,
|
||||||
|
"inactiveOpacity": 0.95,
|
||||||
|
"terminalOpacity": 0.96,
|
||||||
|
"blur": true,
|
||||||
|
"shadow": true
|
||||||
|
},
|
||||||
"ansi": [
|
"ansi": [
|
||||||
"#3b4252",
|
"#3b4252",
|
||||||
"#bf616a",
|
"#bf616a",
|
||||||
|
|||||||
BIN
themes/nord/preview.png
Normal file
|
After Width: | Height: | Size: 187 KiB |