Skip to content

Add omarchy theme schedule for day/night switching#5779

Open
scottjones wants to merge 10 commits into
basecamp:devfrom
scottjones:theme-schedule
Open

Add omarchy theme schedule for day/night switching#5779
scottjones wants to merge 10 commits into
basecamp:devfrom
scottjones:theme-schedule

Conversation

@scottjones
Copy link
Copy Markdown

Automatic day/night theme switching

Adds omarchy theme schedule {enable,disable,status} for automatic theme swaps at sunrise and sunset. Opt-in; off by default.

Up-front honesty: this started as exploration after someone suggested it on X — I didn't have a personal need for it myself, and the original suggester turned out to not be an omarchy user. I built it because the design problem was interesting (event-driven, offline location, "learning" model that needs no settings UI), and validated it against my own day for ~24 hours. Putting it forward in case automatic day/night switching is a common-enough request that you'd consider merging. Equally happy to close it out if it's not a direction you want.

Design

Two slot files at ~/.config/omarchy/current/:

theme.day    # slug of the day theme
theme.night  # slug of the night theme

The timer being enabled is the source of truth for "scheduling is on" — no separate flag file (matches omarchy-battery-monitor's pattern).

Sunrise/sunset is computed from the system timezone — no network call, no IP geolocation. omarchy-theme-schedule-location reads /usr/share/zoneinfo/zone1970.tab and parses the ISO 6709 coordinate for $(readlink /etc/localtime). Users with strong opinions can override with ~/.config/omarchy/theme-schedule.location containing lat,long.

A small Python NOAA solar-position helper (omarchy-theme-schedule-suncalc, stdlib only) does the math.

No settings UI — the existing theme menu drives everything. When the schedule is enabled, the theme-set hook records every manual change into the active phase's slot. So users keep using omarchy theme set … as they always have; whatever they wear during the day becomes the day theme, same for night. Even at enable time, the system uses your current theme as one half of the pair and only asks about the other.

Four triggers, all event-driven, all idempotent:

Trigger When Latency
omarchy-theme-schedule.timer OnCalendar drop-in Exact sunrise/sunset ±30 s
omarchy-theme-schedule-sleepwatch.service (gdbus on logind PrepareForSleep) Resume from suspend, after Hyprland is responsive ~2-3 s post-resume
omarchy-theme-schedule-tzwatch.path /etc/localtime changes <1 s
omarchy-hook theme-set (config/omarchy/hooks/theme-set.d/record-schedule-slot) Manual omarchy theme set synchronous

Sleepwatch also fires its apply at service startup, which handles the multi-day-shutdown case where all OnCalendar entries in the drop-in are in the past.

apply is fully idempotent: compute phase, read slot, swap only if current ≠ target. Safe to invoke from any trigger.

Non-obvious design choices flagged for review

  1. No Persistent=true on the timer. First implementation crashed Hyprland on resume — Persistent=true caught up the missed sunrise event within 1 s of resume, ran omarchy-theme-set (which restarts waybar/swaybg/swayosd/terminal/btop/etc.), and the cascade during session stabilization tore down UWSM. Replaced with the sleepwatch service which waits for hyprctl version to respond before calling apply.

  2. No hourly polling. Earlier draft kept OnCalendar=hourly as a fallback. Removed in favor of fully event-driven design (sun events + sleepwatch + tzwatch + hook). Trade-off: a multi-day shutdown could leave the drop-in fully stale, but sleepwatch's startup-apply rebuilds it before any timer fires.

  3. omarchy-theme-set is called with OMARCHY_THEME_SCHEDULE_TICK=1 when invoked by apply, so the hook (which would otherwise record the new theme into the active slot) knows to skip. Same env var is honored by the hook script.

  4. No GUI / no settings file for the schedule itself. The flow is enable → optional disable → status. Settings are inferred from current theme + slots.

Open questions worth surfacing

  • Number of phases: day/night only, or N (morning/day/evening/night)?
  • Sunrise/sunset offsets ("switch 30 min after sunset"): v1 or v2?
  • Location accuracy: is timezone-derived enough by default, or should we reach for IP geolocation / geoclue?
  • Lock screen handoff: on resume the lock screen renders with the pre-suspend theme until unlock (omarchy-theme-set doesn't restart hyprlock). Accepted limitation; ~1-3 s of stale theme on a transient screen.

Files

bin/omarchy-theme-schedule-{enable,disable,status}        # user-facing
bin/omarchy-theme-schedule-{apply,record-slot,sleepwatch} # hidden helpers
bin/omarchy-theme-schedule-{location,suncalc}             # hidden helpers
config/omarchy/hooks/theme-set.d/record-schedule-slot     # installed by enable
config/systemd/user/omarchy-theme-schedule.service        # oneshot
config/systemd/user/omarchy-theme-schedule.timer          # OnCalendar drop-in
config/systemd/user/omarchy-theme-schedule-tzwatch.path
config/systemd/user/omarchy-theme-schedule-sleepwatch.service
test/install-theme-schedule.sh                            # local dev installer

Auto-routed by the existing omarchy dispatcher — appears as omarchy theme schedule {enable,disable,status} under the existing theme group. Internal helpers are marked omarchy:hidden=true so they don't pollute help output.

Validation

Real-world tested across:

  • ✅ Live sunset firing (timer at exact OnCalendar, active session)
  • ✅ Live sunrise after overnight suspend (sleepwatch resume path)
  • ✅ Resume from suspend with mismatch (manual TICK=1 + lid close → swap on auth)
  • ✅ Multi-day shutdown simulation (back-dated drop-in → sleepwatch regenerates on startup)
  • ✅ Hook learning (manual theme change rewrites active slot)
  • ✅ disable / re-enable cycle (slots preserved, all 3 units rejoin cleanly)
  • ✅ Dispatcher routing (omarchy theme schedule …)
  • ✅ DST transitions (spring-forward + fall-back, both directions)
  • ✅ Year boundary
  • ✅ Concurrent invocations (5× parallel apply)
  • ✅ Uninstalled theme (apply continues + notifies; schedule doesn't silently die)
  • systemd-analyze verify clean

Also cross-machine validated on a separate v3.8 / non-Mac install:

  • ✅ Clean install with no omarchy-hook shim needed (v3.8's hook already supports .d/)
  • ✅ First-run enable (caught a real bug here — the timer's OnCalendar lives in a drop-in written by apply, so enable had to be reordered to run apply before systemctl --user enable --now on the timer)
  • ✅ Hook learning end-to-end through the unmodified v3.8 omarchy-hook
  • ✅ Helper-failure resilience (stubbed omarchy-theme-schedule-suncalc to return empty output; record-slot logged a breadcrumb to stderr and exited 0 instead of crashing the hook chain)

Note on test/install-theme-schedule.sh

That's a local-development helper I used to test against an older installed omarchy (symlinks new bin/ scripts into the live install, copies units into ~/.config/systemd/user/). Happy to drop it from the PR if reviewers prefer; leaving it as a possible reference for someone wanting to iterate on the feature without a full omarchy update cycle.

scottjones and others added 10 commits May 10, 2026 20:59
Swaps themes at sunrise and sunset using sun times computed
from the system timezone. Slots are learned from manual theme
changes via the theme-set hook, so there's no separate config
UI — the existing theme menu drives everything.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Caused a Hyprland session crash on resume from suspend: the timer
fired its missed-event catchup within 1 second of resume, ran
omarchy-theme-set, and the cascade of component restarts during
session stabilization brought down UWSM.

Hourly fallback OnCalendar still corrects within an hour, which
is good enough for theme drift recovery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Listens to logind's PrepareForSleep D-Bus signal via gdbus monitor.
On resume, polls hyprctl until responsive (typically <3s, capped at 10s)
then runs apply — so omarchy-theme-set's component restarts don't race
with session stabilization. Hourly OnCalendar fallback removed since
the schedule is now fully event-driven (sun events, resume, tz change,
manual theme change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After a shutdown longer than the drop-in's 48-hour window, all
its OnCalendar entries are in the past and the timer parks in
'elapsed' state with no future fires queued. Two changes fix that:

- sleepwatch now runs apply once at its own startup (with the
  same Hyprland-readiness wait it uses on resume), so the drop-in
  is regenerated against current dates whenever the session starts.

- apply now try-restarts the timer after rewriting the drop-in,
  to pull it out of 'elapsed' state when daemon-reload alone
  wouldn't recompute next-fire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the script exited silently when stdin wasn't a tty (set
-euo pipefail + read on EOF). Now it explains how to invoke with an
argument instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DST: previously the 'tomorrow' offset was epoch + 86400, which
skips a calendar day across a spring-forward boundary (Sat 23:30
EST + 24 h of seconds = Mon 00:30 EDT). Now uses calendar-day
arithmetic on the date string.

Uninstalled theme: when a configured slot referenced a theme that
had been removed, omarchy-theme-set failed and set -e killed apply
before the drop-in regeneration ran — schedule silently died and
the same broken theme would be retried on every subsequent firing.
Now the failure is caught; the schedule keeps running with stale
slot, the user gets a stderr message and a notify-send.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves record-schedule-slot from default/omarchy/hooks/ (which was a
new tree we'd created and nothing reads from) to config/omarchy/hooks/
where existing sample hooks live. Instead of bundling the file into
every user's hook dir on upgrade, enable now copies it on opt-in and
disable removes it — so users who never enable scheduling never see
the hook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously every apply firing during a broken-theme state sent a
fresh -u critical notification — they stack in mako since critical
notifications don't auto-dismiss. Now gated by a flag in
$XDG_RUNTIME_DIR: at most one notification per broken state. The
flag is cleared on the next successful apply so a recurrence after
fix re-notifies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
set -euo pipefail + an empty array from mapfile = unbound-variable
crash when ${suntimes[0]} is accessed — which surfaces in the
caller as a generic "Hook failed: …record-schedule-slot" from
omarchy-hook on every theme change. Two fixes:

- record-slot now runs without -e and exits 0 with a stderr
  breadcrumb when location or suncalc isn't reachable / returns
  nothing. Slot learning is best-effort; don't take down the hook
  chain over a transient compute failure.

- apply explicitly checks suncalc output length before indexing
  the array, both at the top and inside the drop-in regen loop.
  apply still exits 1 on top-level failure (it can't do its job
  without sun times), but no longer with a cryptic "unbound
  variable" message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The base timer unit ships with no OnCalendar — entries live in a
drop-in written by apply. On a fresh install the drop-in doesn't
exist yet, so 'systemctl --user enable --now omarchy-theme-schedule.timer'
fails with 'bad unit file setting' and set -euo pipefail then exits
the enable script before tzwatch.path, sleepwatch.service, and apply
run. User ends up with hook + slots written but services half-installed
and has to re-run enable.

Reorder: run apply first (writes drop-in, applies theme if needed),
then enable+start all three units. apply's own try-restart of the
timer no-ops while the timer is still inactive, so the reorder is
safe.

Caught by v3.8 / non-Mac test that didn't have a leftover drop-in
to mask the bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 12, 2026 12:47
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in, event-driven day/night theme scheduler to Omarchy (omarchy theme schedule {enable,disable,status}), backed by systemd user units and small helper scripts (timezone-derived location + NOAA sunrise/sunset math) and a theme-set hook to “learn” day/night slots from manual theme changes.

Changes:

  • Introduces new omarchy-theme-schedule-* commands (enable/disable/status + apply/location/suncalc/record-slot/sleepwatch helpers).
  • Adds systemd user units to trigger applies at sunrise/sunset, on resume, and on timezone changes.
  • Adds a theme-set hook to persist the current theme into ~/.config/omarchy/current/theme.day / theme.night.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 6 out of 14 changed files in this pull request and generated no comments.

Show a summary per file
File Description
bin/omarchy-theme-schedule-enable Interactive/CLI flow to set day/night slots and enable units.
bin/omarchy-theme-schedule-disable Disables units and removes the learning hook while preserving slots.
bin/omarchy-theme-schedule-status Reports scheduler status, slots, computed phase, and next fire time.
bin/omarchy-theme-schedule-apply Idempotently applies the correct phase theme and regenerates timer OnCalendar drop-in.
bin/omarchy-theme-schedule-record-slot Learns the active phase slot from manual theme changes.
bin/omarchy-theme-schedule-sleepwatch Watches logind resume events and runs apply after compositor readiness.
bin/omarchy-theme-schedule-location Resolves lat/long from timezone (or user override).
bin/omarchy-theme-schedule-suncalc Stdlib-only Python NOAA sunrise/sunset calculator.
config/systemd/user/omarchy-theme-schedule.service Oneshot unit that runs apply.
config/systemd/user/omarchy-theme-schedule.timer Timer unit whose OnCalendar is provided via a generated drop-in.
config/systemd/user/omarchy-theme-schedule-tzwatch.path Path unit to re-run apply when /etc/localtime changes.
config/systemd/user/omarchy-theme-schedule-sleepwatch.service Long-running resume watcher service.
config/omarchy/hooks/theme-set.d/record-schedule-slot Hook invoked by omarchy-theme-set to trigger slot learning.
test/install-theme-schedule.sh Local development installer/uninstaller for testing against an existing install.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@mckamyk
Copy link
Copy Markdown

mckamyk commented May 12, 2026

+1 :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants