From 685126ab475271825ee14200e3bf470bdcd51d35 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sun, 21 Jun 2026 20:24:58 +0100 Subject: [PATCH] feat(nightlight): menu-driven, in-flake on/off (no ~/.local/state) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Night light is off by default and configured entirely through the menu, writing into the downstream flake instead of ~/.local/state — so the choice is git-tracked and reproducible across a clone+rebuild. Two flags under a new settings.* section of the state file (exposed as the nomarchy.settings option): - settings.nightlight.installed — sticky, gates the hyprsunset unit; nomarchy.nightlight.enable mkDefault-reads it. First enable from the menu rebuilds to create the unit (the one accepted rebuild). - settings.nightlight.on — runtime on/off, toggled instantly (theme-sync set --no-switch + systemctl), no rebuild. An ExecCondition reads the live flag at session start so an off survives reboot; splitting it from the sticky flag means a later unrelated rebuild never undoes an instant-off (no decay). Drops the ~/.local/state/nomarchy/nightlight-off marker and the ConditionPathExists hack. theme-sync set now skips the wallpaper re-apply for non-wallpaper keys, and its rebuild notifications are generic ("changes" not "theme"). First cut of the broader principle — any user-settable config gets a menu writer that lands it in the flake; no state outside the checkout. Docs (README §4, ROADMAP, template) updated to reflect it. Eval- and round-trip-verified; on-hardware check pending. Co-Authored-By: Claude Opus 4.8 --- README.md | 19 +++- docs/ROADMAP.md | 45 ++++---- modules/home/nightlight.nix | 101 ++++++++++-------- modules/home/options.nix | 11 ++ modules/home/rofi.nix | 6 +- modules/home/theme.nix | 11 ++ modules/home/waybar.nix | 8 +- .../nomarchy-theme-sync.py | 12 ++- templates/downstream/home.nix | 3 +- theme-state.json | 3 +- 10 files changed, 144 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 062993f..7d6a072 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ two tables below are split along exactly that line. | `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.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; toggle it on/off from the menu (System › Night light) or the Waybar moon/sun indicator | +| `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.waybar.enable` | `true` | Nomarchy's Waybar | @@ -292,6 +292,23 @@ imperative; nothing in Nix consumes the path): applied at session start and after every switch via a tiny activation hook, cycled instantly with `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. + ### Per-theme app assets (`themes//`) Recoloring covers 95% of theming; the rest is one optional assets directory diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0c027af..4bd1192 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -463,25 +463,34 @@ how to override it. Items marked ✓ are shipped. drives the HM `services.hyprsunset` with two time-based profiles (`sunrise` → identity, `sunset` → `temperature`), so hyprsunset handles the schedule and the on-login state. Active-profile-at-session-start was - confirmed on hardware (2026-06-18). ✓ **Menu + Waybar toggle:** - `nomarchy-nightlight` (in `nightlight.nix`, on PATH) starts/stops the - hyprsunset *service* — `systemctl --user is-active` is the single source of - truth, the screen restores to true colours when stopped, and it forces the - filter off for colour-sensitive work and back on without touching config. A - `nomarchy-menu nightlight` entry (System submenu, self-gated on the unit - existing) and a self-gating Waybar `custom/nightlight` indicator (moon = on, - sun = off; in the generated bar and both summer whole-swaps) drive it. - ✓ **Persistent off:** a manual `off` now drops a marker - (`~/.local/state/nomarchy/nightlight-off`) and the unit refuses to (re)start - while it exists (`Unit.ConditionPathExists=!%h/…`), so an off survives - logout/reboot until toggled back on; `on`/toggle-on remove the marker first - (a failed condition would otherwise no-op the start). Without the marker the - service stays `WantedBy=graphical-session.target` and resumes its schedule at - login as before — the session-scoped behaviour is now the *default*, with - persistence opt-in via the toggle. Remaining (optional): geo (lat/long) auto + confirmed on hardware (2026-06-18). ✓ **Menu + Waybar toggle (opt-in, + instant, in-flake state):** two git-tracked keys in the state file, both + 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** (next target: the per-device keyboard-layout memory + below, still in `~/.local/state`; then 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 (off persisting across reboot, and that stopping hyprsunset - cleanly restores the gamma). + on-machine check (first enable rebuilds + comes on; later toggles instant; off + persists across reboot via ExecCondition; stopping hyprsunset restores gamma). - **Keyboard layouts (per-device + switching):** - ✓ **Per-device declarative layout:** `nomarchy.keyboard.devices` (`{ "" = { layout; variant; }; }`) generates Hyprland diff --git a/modules/home/nightlight.nix b/modules/home/nightlight.nix index c757503..05d7d07 100644 --- a/modules/home/nightlight.nix +++ b/modules/home/nightlight.nix @@ -1,60 +1,74 @@ # Night light — a scheduled blue-light filter via hyprsunset (Hyprland's own -# gamma/temperature tool). Warm at night, identity (no shift) by day; -# hyprsunset's time-based `profile` entries handle the schedule and pick the -# right state on session start. Opt-in via nomarchy.nightlight.enable. +# gamma/temperature tool). Warm at night, identity (no shift) by day; the +# schedule (temperature/sunrise/sunset) is tuned via nomarchy.nightlight.* in +# home.nix and baked into the unit's time-based `profile`. # -# The hyprsunset HM service module is provided by home-manager; this only -# configures it. Override anything with plain services.hyprsunset.* options. -# -# nomarchy-nightlight is the menu/Waybar toggle: it starts/stops the hyprsunset -# *service* (so `systemctl --user is-active` is the single source of truth and -# the screen restores to true colours when stopped), letting you force the -# filter off for colour-sensitive work and back on without touching the config. +# Off by default and opt-in. Two git-tracked flags in the state file, both +# menu-written (no ~/.local/state): +# 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 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; - # Toggle/status helper, on PATH so the menu and both the generated and - # whole-swap Waybars can drive it by name. Self-gates: when the unit doesn't - # exist (night-light not enabled) `status` prints nothing, so the Waybar - # module hides itself — same pattern as the power-profile indicator. nomarchy-nightlight = pkgs.writeShellScriptBin "nomarchy-nightlight" '' unit=hyprsunset.service - # Persistent-off marker — mirrors the unit's ConditionPathExists below. - # Present => night-light stays off across logout/reboot until toggled on. - # Path is literal (not XDG_STATE_HOME) so it matches the unit's %h check. - marker="$HOME/.local/state/nomarchy/nightlight-off" - mark_off() { mkdir -p "$(dirname "$marker")"; : > "$marker"; } - mark_on() { rm -f "$marker"; } - if ! systemctl --user cat "$unit" >/dev/null 2>&1; then - [ "''${1:-}" = status ] && exit 0 - notify-send "Night light" "Not enabled — set nomarchy.nightlight.enable." - exit 0 - fi + # 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) - if systemctl --user is-active --quiet "$unit"; then - printf '{"text":"󰖔","tooltip":"Night light on — warm on schedule (click to disable)","class":"on"}\n' - else - printf '{"text":"󰖙","tooltip":"Night light off (click to enable)","class":"off"}\n' - fi ;; - on) mark_on; systemctl --user start "$unit" ;; - off) mark_off; systemctl --user stop "$unit" ;; + # 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 - mark_off; systemctl --user stop "$unit" - notify-send "Night light off" "Screen back to true colours (stays off until re-enabled)." + write_on false; stop # on -> off (instant) + elif installed; then + write_on true; start # installed, off -> on (instant) else - mark_on; systemctl --user start "$unit" - notify-send "Night light on" "Warm filter follows your schedule." + install_feature; start # first enable (rebuilds) fi ;; - *) echo "usage: nomarchy-nightlight [toggle|status|on|off]" >&2; exit 64 ;; + *) echo "usage: nomarchy-nightlight [toggle|status|on|off|should-start]" >&2; exit 64 ;; esac ''; in { config = { + # 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; settings.profile = [ @@ -65,15 +79,12 @@ in ]; }; - # Persistent off: a manual `off` drops ~/.local/state/nomarchy/nightlight-off, - # and this Condition makes the unit refuse to (re)start while it exists — so - # an off survives logout/reboot. `nomarchy-nightlight on` removes it first - # (a failed condition would otherwise no-op the start). %h = $HOME. - systemd.user.services.hyprsunset.Unit.ConditionPathExists = - lib.mkIf cfg.enable "!%h/.local/state/nomarchy/nightlight-off"; + # 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"; - # Always on PATH (it self-gates at runtime), so a static whole-swap Waybar - # can exec it even when night-light is off without the call failing. home.packages = [ nomarchy-nightlight ]; }; } diff --git a/modules/home/options.nix b/modules/home/options.nix index 79f3542..29fb275 100644 --- a/modules/home/options.nix +++ b/modules/home/options.nix @@ -255,6 +255,17 @@ in 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 { type = lib.types.attrs; readOnly = true; diff --git a/modules/home/rofi.nix b/modules/home/rofi.nix index 86f004e..48fcc49 100644 --- a/modules/home/rofi.nix +++ b/modules/home/rofi.nix @@ -370,8 +370,10 @@ ${themeRows} && row "Audio" audio-volume-high command -v system-config-printer >/dev/null 2>&1 && row "Printers" printer row "Do Not Disturb" notification-disabled - systemctl --user cat hyprsunset.service >/dev/null 2>&1 \ - && row "Night light" weather-clear-night + 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 command -v nomarchy-snapshots >/dev/null 2>&1 && row "Snapshots" timeshift if [ -e "''${bats[0]}" ] && command -v powerprofilesctl >/dev/null 2>&1; then row "Power profile" preferences-system-power diff --git a/modules/home/theme.nix b/modules/home/theme.nix index 6ff3a33..f234ddc 100644 --- a/modules/home/theme.nix +++ b/modules/home/theme.nix @@ -54,6 +54,12 @@ let # tone for kanagawa/summer-*). Each preset declares its own so a theme # switch always replaces it (deep_merge would otherwise leave it stuck). border = { active = "accent"; inactive = "overlay"; }; + + # Non-appearance feature settings the menu writes into this same in-flake + # state. nomarchy.nightlight: `installed` (sticky — gates the unit, so the + # first enable rebuilds) and `on` (instant runtime on/off). Defaulted so a + # sparse/older state file still evaluates; nomarchy.settings exposes them. + settings.nightlight = { installed = false; on = true; }; }; parsed = lib.recursiveUpdate defaults themeState; @@ -78,6 +84,11 @@ in config = { nomarchy.theme = parsed // { 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 = parsed.settings; + nomarchy.lib = { # "#7aa2f7" -> "rgb(7aa2f7)" (Hyprland color syntax) rgb = c: "rgb(${lib.removePrefix "#" c})"; diff --git a/modules/home/waybar.nix b/modules/home/waybar.nix index e8b4ebe..c84d3de 100644 --- a/modules/home/waybar.nix +++ b/modules/home/waybar.nix @@ -131,9 +131,11 @@ let on-click = "nomarchy-powerprofile-cycle"; }; - # Night-light (hyprsunset) toggle + state. Self-gates: hidden unless - # nomarchy.nightlight is enabled (the status helper prints nothing then). - # Click starts/stops the schedule; moon = on, sun = off. + # 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"; diff --git a/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py b/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py index 7292870..60b940c 100644 --- a/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py +++ b/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py @@ -165,12 +165,12 @@ def run_switch() -> None: # Persistent (timeout 0): stays up for the whole rebuild — replaced # in place by the success/failure notification below — so a multi- # 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 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)") - 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 to re-read its freshly rebuilt # config/style. SIGUSR2 = reload; harmless no-op if waybar isn't running. @@ -294,7 +294,11 @@ def cmd_set(args) -> None: check_fonts(state) if not args.no_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: diff --git a/templates/downstream/home.nix b/templates/downstream/home.nix index 49e788a..6b6a792 100644 --- a/templates/downstream/home.nix +++ b/templates/downstream/home.nix @@ -16,7 +16,8 @@ # ── Opt-in features — uncomment and tweak to enable ───────────────── # 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 # sunset = "20:00"; # sunrise = "07:00"; diff --git a/theme-state.json b/theme-state.json index 04b0e7b..e6a5bdb 100644 --- a/theme-state.json +++ b/theme-state.json @@ -55,5 +55,6 @@ "terminalOpacity": 0.96, "blur": true, "shadow": true - } + }, + "settings": {} }