Compare commits
2 Commits
8f85097140
...
f658b0087e
| Author | SHA1 | Date | |
|---|---|---|---|
| f658b0087e | |||
| ac24ac7c34 |
@@ -52,11 +52,18 @@ Everything else below stays open; order is convenience, not a gate.
|
||||
summer-day; this is the on-hardware confirmation of the report.
|
||||
- [ ] **Auto-theme pair flip on hardware** — set
|
||||
`settings.autoTheme.{day,night}` to one of the new pairs (e.g.
|
||||
kiln-clay / kiln), force sunrise/sunset around the current time,
|
||||
let the timer flip once each way. Pass = the whole desktop switches
|
||||
in one generation, and nothing leaks from the previous theme —
|
||||
fonts, rounding, terminal opacity all reset (the per-theme
|
||||
appearance block), wallpaper follows.
|
||||
kiln-clay / kiln), set sunset (or sunrise) a few minutes ahead via
|
||||
Look & Feel › Auto theme (the time edit itself rebuilds — since the
|
||||
exact-time revision the timer fires AT the configured minute, no
|
||||
15-min poll), let the timer flip once each way. Pass = (1)
|
||||
`systemctl --user list-timers nomarchy-auto-theme` shows the two
|
||||
configured times as the trigger; (2) the switch lands within ~a
|
||||
minute of the configured time; (3) the whole desktop switches in
|
||||
one generation, and nothing leaks from the previous theme — fonts,
|
||||
rounding, terminal opacity all reset (the per-theme appearance
|
||||
block), wallpaper follows; (4) with autoCommit on, `git -C
|
||||
~/.nomarchy log` shows an `apply theme` commit and state.json is
|
||||
not left dirty (the 0.5.1 pathspec fix).
|
||||
- [ ] **#148 dock-intent enforcement without auto-profiles (dev box, docked)** —
|
||||
after pulling the #148 commit: **log out and back in first** — the
|
||||
watcher is `exec-once`, so `nomarchy-home` alone leaves the old one
|
||||
|
||||
@@ -70,11 +70,12 @@ State lives in `settings.autoTheme`:
|
||||
| `day` / `night` | theme slugs (see `nomarchy-state-sync list`) — the exclusive pairs are made for this: `boreal-dawn`/`boreal`, `executive-ivory`/`executive-slate`, `kiln-clay`/`kiln`, `summer-day`/`summer-night` |
|
||||
| `sunrise` / `sunset` | switch times, `"HH:MM"` (24-hour) |
|
||||
|
||||
A timer re-checks every ~15 minutes: the day theme applies around sunrise, the
|
||||
night theme around sunset — but it only rebuilds when the active theme
|
||||
actually needs to change, so most checks do nothing. Enabling rebuilds once
|
||||
(to install the timer); disabling is instant. Preview the current decision
|
||||
without switching with `nomarchy-state-sync auto --which`.
|
||||
A timer fires exactly at the configured sunrise and sunset (plus once shortly
|
||||
after login, and it catches up on a transition missed while the machine was
|
||||
off or asleep) — it only rebuilds when the active theme actually needs to
|
||||
change. The times are baked into the timer, so editing them (or enabling)
|
||||
rebuilds once; disabling is instant. Preview the current decision without
|
||||
switching with `nomarchy-state-sync auto --which`.
|
||||
|
||||
These values are deliberately kept at normal priority in the modules, so they
|
||||
stay owned by the theme system. If you *insist* on pinning one in `home.nix`
|
||||
|
||||
@@ -316,11 +316,17 @@ Design/decision records and a running log of shipped work (items marked
|
||||
drives `nomarchy-state-sync auto`, which picks day vs night by the local
|
||||
clock and applies the preset **only if it differs** (idempotent).
|
||||
`modules/home/autotheme.nix` installs a user timer
|
||||
(`OnStartupSec` + `OnCalendar=*:0/15` + `Persistent`, gated on the state
|
||||
(`OnStartupSec` + `OnCalendar` + `Persistent`, gated on the state
|
||||
flag) that ticks `auto`; Look & Feel › Auto theme toggles it and picks the
|
||||
pair/times. `cmd_apply`'s core was factored into `apply_named` so `auto`
|
||||
reuses it verbatim. Periodic (not exact sunrise/sunset timers) for
|
||||
robustness to suspend/DST; the idempotent `auto` keeps ticks cheap. Enable
|
||||
reuses it verbatim. Originally a 15-min poll (`OnCalendar=*:0/15`) for
|
||||
robustness to suspend/DST; **revised** (Bernardo 2026-07-18) to fire
|
||||
exactly at the configured sunrise/sunset — `Persistent` covers powered-off
|
||||
gaps, systemd fires elapsed calendar timers on resume, and `OnStartupSec`
|
||||
settles login, so the poll bought nothing but a ≤15-min switch lag. The
|
||||
times are now baked into `OnCalendar`, so the menu's Sunrise/Sunset edits
|
||||
rebuild via `auto --force` (day/night slugs stay live reads; editing the
|
||||
on-screen slot applies immediately since no poll catches it up). Enable
|
||||
rebuilds once (installs the timer + `auto --force`); disable is instant
|
||||
(the flag flips and `auto` self-gates). **V2:** `checks.auto-theme` moves
|
||||
the VM clock across a configured sunset/sunrise and asserts the theme flips
|
||||
|
||||
75
flake.nix
75
flake.nix
@@ -342,6 +342,48 @@
|
||||
pkgs.runCommand "nomarchy-state-bridges" { }
|
||||
"touch $out"; # all ${toString (builtins.length cases)} assertions held at eval
|
||||
|
||||
# The baked auto-theme timer (#79 revision): sunrise/sunset land in
|
||||
# the unit's OnCalendar at rebuild, and nothing in a build fails
|
||||
# when that bake goes stale or produces a calendar systemd rejects
|
||||
# — so assert it at eval, state-bridges style.
|
||||
auto-theme-timer =
|
||||
let
|
||||
homeWith = stateFile:
|
||||
(downstream.homeConfigurations.me.extendModules {
|
||||
modules = [{
|
||||
nomarchy.stateFile = nixpkgs.lib.mkForce stateFile;
|
||||
}];
|
||||
}).config;
|
||||
atState = name: at: stateFileOf name { settings.autoTheme = at; };
|
||||
on = homeWith (atState "state-autotheme-on.json" {
|
||||
enable = true; day = "summer-day"; night = "summer-night";
|
||||
sunrise = "06:30"; sunset = "21:45";
|
||||
});
|
||||
garbage = homeWith (atState "state-autotheme-garbage.json" {
|
||||
enable = true; day = "summer-day"; night = "summer-night";
|
||||
sunrise = "sometime"; sunset = "25:99";
|
||||
});
|
||||
off = homeWith absentState; # template carries no autoTheme key
|
||||
cal = c: c.systemd.user.timers.nomarchy-auto-theme.Timer.OnCalendar;
|
||||
cases = [
|
||||
{ n = "configured times are baked into OnCalendar";
|
||||
ok = cal on == [ "*-*-* 06:30:00" "*-*-* 21:45:00" ]; }
|
||||
{ n = "garbage times fall back to 07:00/20:00";
|
||||
ok = cal garbage == [ "*-*-* 07:00:00" "*-*-* 20:00:00" ]; }
|
||||
{ n = "disabled → no timer unit installed";
|
||||
ok = !(off.systemd.user.timers ? nomarchy-auto-theme); }
|
||||
];
|
||||
failed = builtins.filter (c: !c.ok) cases;
|
||||
in
|
||||
if failed != [ ] then
|
||||
throw ''
|
||||
Auto-theme timer bake broken — settings.autoTheme no longer
|
||||
reaches the unit as expected:
|
||||
${nixpkgs.lib.concatMapStringsSep "\n" (c: " ✗ ${c.n}") failed}''
|
||||
else
|
||||
pkgs.runCommand "nomarchy-auto-theme-timer" { }
|
||||
"touch $out"; # all ${toString (builtins.length cases)} assertions held at eval
|
||||
|
||||
# The bridge, end to end on a booted machine: checks.state-bridges
|
||||
# proves the option flips at eval, this proves a state key alone
|
||||
# reaches a RUNNING service. The node sets no
|
||||
@@ -2204,10 +2246,14 @@
|
||||
# `home-manager switch` is stubbed via NOMARCHY_REBUILD (that
|
||||
# generic apply path is exercised by every manual theme change);
|
||||
# this isolates the auto decision + apply + rebuild-trigger.
|
||||
# The state dir is a git repo with autoCommit on, so every set/apply
|
||||
# also exercises auto_commit against a repo that has ONLY state.json
|
||||
# — the post-#107 shape whose dead legacy pathspecs once aborted
|
||||
# every commit silently.
|
||||
auto-theme = pkgs.testers.runNixOSTest {
|
||||
name = "nomarchy-auto-theme";
|
||||
nodes.machine = { pkgs, ... }: {
|
||||
environment.systemPackages = [ pkgs.nomarchy-state-sync ];
|
||||
environment.systemPackages = [ pkgs.nomarchy-state-sync pkgs.git ];
|
||||
time.timeZone = "UTC"; # naive HH:MM == the clock we set
|
||||
};
|
||||
testScript = ''
|
||||
@@ -2220,6 +2266,14 @@
|
||||
machine.succeed(
|
||||
"echo '{\"slug\":\"summer-day\"}' > /root/.nomarchy/state.json"
|
||||
)
|
||||
# Git-tracked like a real downstream flake — no legacy
|
||||
# theme-state.json/theme.json anywhere in its history.
|
||||
machine.succeed(
|
||||
"git -C /root/.nomarchy init -q"
|
||||
" && git -C /root/.nomarchy add state.json"
|
||||
" && git -C /root/.nomarchy -c user.name=t -c user.email=t@t"
|
||||
" commit -qm init"
|
||||
)
|
||||
machine.succeed(
|
||||
"printf '#!/bin/sh\\necho switch >> /root/rebuilds\\n' > /root/rebuild"
|
||||
" && chmod +x /root/rebuild"
|
||||
@@ -2229,14 +2283,24 @@
|
||||
"NOMARCHY_DEFAULT_THEMES=${pkgs.nomarchy-default-themes} "
|
||||
"NOMARCHY_REBUILD=/root/rebuild ")
|
||||
|
||||
# Pair + schedule (setup writes only, no rebuild).
|
||||
for kv in ["settings.autoTheme.enable true",
|
||||
# Pair + schedule (setup writes only, no rebuild). autoCommit
|
||||
# first, so every following write must land as its own commit.
|
||||
for kv in ["settings.autoCommit true",
|
||||
"settings.autoTheme.enable true",
|
||||
"settings.autoTheme.day summer-day",
|
||||
"settings.autoTheme.night summer-night",
|
||||
"settings.autoTheme.sunrise 07:00",
|
||||
"settings.autoTheme.sunset 20:00"]:
|
||||
machine.succeed(env + "nomarchy-state-sync --quiet set " + kv + " --no-switch")
|
||||
|
||||
# Every `set` above auto-committed (the regression: dead legacy
|
||||
# pathspecs made `git commit` abort, skipping ALL of these).
|
||||
machine.succeed(
|
||||
"git -C /root/.nomarchy log --format=%s"
|
||||
" | grep -qF 'set settings.autoTheme.sunset'"
|
||||
)
|
||||
machine.succeed("git -C /root/.nomarchy diff --quiet HEAD -- state.json")
|
||||
|
||||
def slug():
|
||||
return machine.succeed(env + "nomarchy-state-sync get slug").strip()
|
||||
def rebuilds():
|
||||
@@ -2253,6 +2317,11 @@
|
||||
machine.succeed(env + "nomarchy-state-sync auto")
|
||||
assert slug() == "summer-night", f"post-sunset should be night, got {slug()}"
|
||||
assert rebuilds() == 1, f"sunset should trigger one rebuild, got {rebuilds()}"
|
||||
# The switch auto-committed too — nothing left dirty behind it.
|
||||
machine.succeed(
|
||||
"git -C /root/.nomarchy log --format=%s | grep -qF 'apply theme'"
|
||||
)
|
||||
machine.succeed("git -C /root/.nomarchy diff --quiet HEAD -- state.json")
|
||||
|
||||
# Another tick still after sunset → idempotent, no extra rebuild.
|
||||
machine.succeed("date -s '2026-01-02 20:06:00'")
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
# Auto time-of-day theme switch (BACKLOG #79, VISION § D). A user timer
|
||||
# periodically runs `nomarchy-state-sync auto`, which reads
|
||||
# settings.autoTheme = { enable, day, night, sunrise, sunset } from the
|
||||
# state file and applies the day or night preset for the current clock —
|
||||
# through the SAME one engine as a manual `apply` (no second pipeline).
|
||||
# runs `nomarchy-state-sync auto` at the configured sunrise and sunset,
|
||||
# which reads settings.autoTheme = { enable, day, night, sunrise, sunset }
|
||||
# from the state file and applies the day or night preset for the current
|
||||
# clock — through the SAME one engine as a manual `apply` (no second
|
||||
# pipeline).
|
||||
#
|
||||
# The command self-gates (no-op when disabled) and is idempotent (it only
|
||||
# rebuilds when the active theme actually needs to change), so the timer
|
||||
# can tick freely. Install is gated on the state flag (like nomarchy.updates
|
||||
# gates on its enable), so a machine not using the feature carries no timer;
|
||||
# enabling it from the menu (slice 3) writes the flag + rebuilds, which is
|
||||
# what brings the timer into being.
|
||||
# The trigger times are BAKED into OnCalendar at rebuild (originally this
|
||||
# was a 15-min poll; exact times won on wasted wakeups — Bernardo
|
||||
# 2026-07-18), so a time edit must rebuild: the menu's Sunrise/Sunset
|
||||
# writes run `auto --force`, whose apply is that rebuild. Missed
|
||||
# transitions are covered without polling: Persistent=true catches ones
|
||||
# that pass while powered off, systemd fires elapsed OnCalendar timers on
|
||||
# resume from suspend, and OnStartupSec settles the theme just after
|
||||
# login. The command self-gates (no-op when disabled) and is idempotent
|
||||
# (it only rebuilds when the active theme actually needs to change), so
|
||||
# every extra firing is cheap.
|
||||
#
|
||||
# Install is gated on the state flag (like nomarchy.updates gates on its
|
||||
# enable), so a machine not using the feature carries no timer; enabling
|
||||
# it from the menu (slice 3) writes the flag + rebuilds, which is what
|
||||
# brings the timer into being.
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
lib.mkIf (config.nomarchy.settings.autoTheme.enable or false) {
|
||||
let
|
||||
at = config.nomarchy.settings.autoTheme or { };
|
||||
# "HH:MM" or the fallback — mirrors _hhmm_to_min in nomarchy-state-sync,
|
||||
# so a hand-edited state can't bake an OnCalendar systemd rejects.
|
||||
hhmm = v: fallback:
|
||||
if builtins.isString v
|
||||
&& builtins.match "([01][0-9]|2[0-3]):[0-5][0-9]" v != null
|
||||
then v else fallback;
|
||||
in
|
||||
lib.mkIf (at.enable or false) {
|
||||
systemd.user.services.nomarchy-auto-theme = {
|
||||
Unit.Description = "Apply the day/night theme for the current time";
|
||||
Service = {
|
||||
@@ -25,10 +44,13 @@ lib.mkIf (config.nomarchy.settings.autoTheme.enable or false) {
|
||||
};
|
||||
|
||||
systemd.user.timers.nomarchy-auto-theme = {
|
||||
Unit.Description = "Periodic day/night theme check";
|
||||
Unit.Description = "Day/night theme switch at sunrise and sunset";
|
||||
Timer = {
|
||||
OnStartupSec = "1min"; # settle on the right theme shortly after login
|
||||
OnCalendar = "*:0/15"; # re-check every 15 min; auto no-ops if unchanged
|
||||
OnCalendar = [
|
||||
"*-*-* ${hhmm (at.sunrise or null) "07:00"}:00" # sunrise → day
|
||||
"*-*-* ${hhmm (at.sunset or null) "20:00"}:00" # sunset → night
|
||||
];
|
||||
Persistent = true; # catch a transition missed while powered off
|
||||
};
|
||||
Install.WantedBy = [ "timers.target" ];
|
||||
|
||||
@@ -962,13 +962,16 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
autotheme)
|
||||
# Look & Feel › Auto theme — automatic day/night theme switch
|
||||
# (settings.autoTheme, applied by the nomarchy-auto-theme timer from
|
||||
# autotheme.nix). The enable flag GATES the timer's install, so
|
||||
# turning it *on* must rebuild; day/night/times are read live by
|
||||
# `nomarchy-state-sync auto`, so those writes are instant
|
||||
# (--no-switch). Enabling writes the flag then `auto --force`, so a
|
||||
# single rebuild both installs the timer and applies the right theme
|
||||
# now. Disabling is instant: the flag flips and `auto` self-gates to
|
||||
# a no-op, so the lingering timer does nothing until the next rebuild.
|
||||
# autotheme.nix). The enable flag GATES the timer's install and the
|
||||
# sunrise/sunset times are BAKED into its OnCalendar, so turning it
|
||||
# *on* or editing a time must rebuild — both run `auto --force`, so
|
||||
# one rebuild re-bakes the timer AND lands on the right theme now.
|
||||
# Day/night slugs are read live by `nomarchy-state-sync auto`, so
|
||||
# those writes are instant (--no-switch); when the edited slot is the
|
||||
# one on screen, a plain `auto` applies it right away (the timer only
|
||||
# fires at the next transition — there is no poll to catch it up).
|
||||
# Disabling is instant: the flag flips and `auto` self-gates to a
|
||||
# no-op, so the lingering timer does nothing until the next rebuild.
|
||||
at_get() { nomarchy-state-sync get "settings.autoTheme.$1" 2>/dev/null; }
|
||||
en=$(at_get enable); day=$(at_get day); night=$(at_get night)
|
||||
sunrise=$(at_get sunrise); sunset=$(at_get sunset)
|
||||
@@ -997,15 +1000,20 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
nomarchy-state-sync --quiet set settings.autoTheme.enable true --no-switch
|
||||
notify-send "Auto theme" "On — applying the theme for now…"
|
||||
exec nomarchy-state-sync auto --force ;; # one rebuild: install timer + apply
|
||||
"Day theme"*)
|
||||
slug=$( { nomarchy-state-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "Day theme") || exec "$0" autotheme
|
||||
[ "$slug" != "$BACK" ] && [ -n "$slug" ] \
|
||||
&& nomarchy-state-sync --quiet set settings.autoTheme.day "$slug" --no-switch
|
||||
exec "$0" autotheme ;;
|
||||
"Night theme"*)
|
||||
slug=$( { nomarchy-state-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "Night theme") || exec "$0" autotheme
|
||||
[ "$slug" != "$BACK" ] && [ -n "$slug" ] \
|
||||
&& nomarchy-state-sync --quiet set settings.autoTheme.night "$slug" --no-switch
|
||||
"Day theme"*|"Night theme"*)
|
||||
key=day; label="Day theme"
|
||||
case "$choice" in "Night theme"*) key=night; label="Night theme" ;; esac
|
||||
slug=$( { nomarchy-state-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "$label") || exec "$0" autotheme
|
||||
if [ "$slug" != "$BACK" ] && [ -n "$slug" ]; then
|
||||
nomarchy-state-sync --quiet set "settings.autoTheme.$key" "$slug" --no-switch
|
||||
# Edited the slot that is on screen right now? Apply it — the
|
||||
# timer won't fire again until the next sunrise/sunset.
|
||||
if [ "$en" = true ] && [ "$(nomarchy-state-sync auto --which)" = "$slug" ] \
|
||||
&& [ "$(nomarchy-state-sync get slug)" != "$slug" ]; then
|
||||
notify-send "Auto theme" "$label: $slug — applying…"
|
||||
exec nomarchy-state-sync auto
|
||||
fi
|
||||
fi
|
||||
exec "$0" autotheme ;;
|
||||
"Sunrise"*|"Sunset"*)
|
||||
key=sunrise; label=Sunrise; cur="''${sunrise:-07:00}"
|
||||
@@ -1014,7 +1022,14 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||||
case "$t" in
|
||||
""|"$BACK") : ;;
|
||||
[01][0-9]:[0-5][0-9]|2[0-3]:[0-5][0-9])
|
||||
nomarchy-state-sync --quiet set "settings.autoTheme.$key" "$t" --no-switch ;;
|
||||
nomarchy-state-sync --quiet set "settings.autoTheme.$key" "$t" --no-switch
|
||||
# The time is baked into the timer's OnCalendar, so a change
|
||||
# must rebuild; `auto --force` is that rebuild AND lands on
|
||||
# the right theme for the new schedule in the same pass.
|
||||
if [ "$en" = true ]; then
|
||||
notify-send "Auto theme" "$label $t — rebuilding the schedule…"
|
||||
exec nomarchy-state-sync auto --force
|
||||
fi ;;
|
||||
*) notify-send "Auto theme" "Ignored '$t' — use HH:MM (24-hour)." ;;
|
||||
esac
|
||||
exec "$0" autotheme ;;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "nomarchy-state-sync";
|
||||
version = "0.5.0";
|
||||
version = "0.5.1";
|
||||
|
||||
src = ./.;
|
||||
|
||||
|
||||
@@ -189,8 +189,17 @@ def auto_commit(message: str) -> None:
|
||||
if not (FLAKE_DIR / ".git").exists() or shutil.which("git") is None:
|
||||
return
|
||||
git = ["git", "-C", str(FLAKE_DIR)]
|
||||
# Pathspec: preferred name plus any leftover legacy file still staged.
|
||||
paths = [STATE_NAME, *LEGACY_STATE_NAMES]
|
||||
# Pathspec: preferred name plus any legacy file git still knows — in the
|
||||
# index or in HEAD (a migration's staged `git rm` needs the pathspec to
|
||||
# ride along). Names known to neither MUST be dropped: unlike `git diff`,
|
||||
# `git commit` aborts on a pathspec that matches nothing, which silently
|
||||
# killed every auto-commit once the #107 rename removed the legacy files.
|
||||
def known(name: str) -> bool:
|
||||
return subprocess.run(git + ["ls-files", "--error-unmatch", "--", name],
|
||||
capture_output=True).returncode == 0 \
|
||||
or subprocess.run(git + ["cat-file", "-e", f"HEAD:{name}"],
|
||||
capture_output=True).returncode == 0
|
||||
paths = [STATE_NAME] + [n for n in LEGACY_STATE_NAMES if known(n)]
|
||||
# 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", "--"] + paths,
|
||||
|
||||
Reference in New Issue
Block a user