From 197c9c2e5372faeea083003bcb4dd3ac3a6da98c Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 17 Oct 2026 22:12:20 -0400 Subject: [PATCH 01/10] fix(reload): restart waybar and dunst as Hyprland's children A restarted waybar or dunst was a child of whatever ran the apply, and setsid does not move it out of that process's cgroup. From a terminal it landed in the tmux pane's scope. From a systemd service, systemd kills the cgroup when the service exits, taking the new bar with it. start_session_daemon hands the program to Hyprland's exec dispatcher, so it runs in the login session's scope as it did at login. Without a Hyprland instance answering "ok" it falls back to the setsid launch. theme opacity restarts waybar through the same path instead of sending SIGUSR2. A SIGUSR2 reload is one of the triggers of the waybar 0.15 mpris use-after-free that reload_waybar already restarts to avoid. --- lib/lib.sh | 24 ++++++++++++++++-------- tests/opacity.bats | 3 +-- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/lib/lib.sh b/lib/lib.sh index 6a315c4..75a8985 100755 --- a/lib/lib.sh +++ b/lib/lib.sh @@ -911,10 +911,8 @@ set_waybar_opacity() { @define-color waybar-bg alpha(@bg, $opacity); EOF - # Reload waybar if running - if pgrep -x waybar &>/dev/null; then - killall -SIGUSR2 waybar 2>/dev/null || true - fi + # Restarted rather than sent SIGUSR2, for the reason reload_waybar gives. + reload_waybar || true } # Get current opacity (from first available terminal) @@ -2358,8 +2356,7 @@ reload_hyprland() { reload_waybar() { if pgrep -x waybar &>/dev/null; then pkill -x waybar 2>/dev/null || true - setsid waybar >/dev/null 2>&1 /dev/null || true # Dunst auto-restarts on next notification, or start it explicitly if command -v dunst &>/dev/null; then - dunst &>/dev/null & - disown + start_session_daemon dunst fi return 0 fi return 1 } +# Starts a desktop daemon as Hyprland's child, where login started it. A child of the +# apply stays in the apply's cgroup, which systemd kills when an apply run as a service ends. +start_session_daemon() { + local program="$1" + if command -v hyprctl &>/dev/null \ + && [[ "$(hyprctl dispatch "hl.dsp.exec_cmd('$program')" 2>/dev/null)" == "ok" ]]; then + return 0 + fi + setsid "$program" >/dev/null 2>&1 Date: Sat, 17 Oct 2026 22:13:19 -0400 Subject: [PATCH 02/10] fix(reload): reload Hyprland once per apply, without printing ok apply_hyprland reloaded Hyprland itself, and reload_apps reloaded it again on the same apply. It was the only apply_* that reloaded its own app, so it now leaves the reload to reload_apps like the rest. reload_hyprland discarded only stderr, so hyprctl's "ok" landed between the per-app ticks of every apply. --- lib/lib.sh | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/lib.sh b/lib/lib.sh index 75a8985..632fc36 100755 --- a/lib/lib.sh +++ b/lib/lib.sh @@ -1812,11 +1812,6 @@ apply_hyprland() { install_themed_artifact "$lib_path/hyprland.lua" "$hypr_theme_dir" "$theme.lua" "current.lua" || return 1 - # Reload hyprland if running - if command -v hyprctl &>/dev/null; then - hyprctl reload 2>/dev/null || true - fi - return 0 } @@ -2342,7 +2337,7 @@ reload_kitty() { reload_hyprland() { if command -v hyprctl &>/dev/null; then - hyprctl reload 2>/dev/null || true + hyprctl reload >/dev/null 2>&1 || true return 0 fi return 1 From 3a4af1c619416753d78cb1c9e031d7e1deaea490 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 17 Oct 2026 22:20:10 -0400 Subject: [PATCH 03/10] fix(rank): end an apply at the next apply on the same machine calculate_usage_time merged every machine's applies into one timeline, so an apply on one machine ended the session another machine was still showing. A theme left on one machine for 103 days without a change was credited only until the next apply on any other machine. Each machine is now its own timeline. Another machine's latest apply is left out, since nothing here knows when its session ended, and only this machine's current theme runs to now. --- lib/storage.sh | 46 +++++++++++++++++++--------------------------- tests/history.bats | 12 +++++++----- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/lib/storage.sh b/lib/storage.sh index 85014e2..f2f911f 100755 --- a/lib/storage.sh +++ b/lib/storage.sh @@ -189,10 +189,12 @@ get_theme_stats() { ' } +# Seconds each theme was on screen. An apply ends at the next apply on the same machine. +# Another machine's latest apply is left out, since nothing here knows when it ended. calculate_usage_time() { local current_theme="$1" - get_history | jq -r --arg current "$current_theme" ' + get_history | jq -r --arg current "$current_theme" --arg machine "$(_storage_get_machine_id)" ' def parse_ts: if test("[+-][0-9]{2}:[0-9]{2}$") then gsub("[+-][0-9]{2}:[0-9]{2}$"; "Z") | fromdateiso8601 @@ -200,32 +202,22 @@ calculate_usage_time() { fromdateiso8601 end; - [map(select(.action == "apply")) | sort_by(.ts) | to_entries[]] as $applies | - - ($applies | map( - . as $entry | - $entry.key as $idx | - $entry.value as $apply | - - if ($idx < (($applies | length) - 1)) then - { - theme: $apply.theme, - duration: (($applies[$idx + 1].value.ts | parse_ts) - ($apply.ts | parse_ts)) - } - elif $apply.theme == $current then - { - theme: $apply.theme, - duration: ((now | floor) - ($apply.ts | parse_ts)) - } - else - null - end - ) | map(select(. != null))) as $durations | - - ($durations | group_by(.theme) | map({ - key: .[0].theme, - value: (map(.duration) | add // 0) - }) | from_entries) + [ + map(select(.action == "apply")) | group_by(.machine)[] | sort_by(.ts) + | . as $applies + | range(0; $applies | length) as $i + | $applies[$i] as $apply + | if $i < (($applies | length) - 1) then + {theme: $apply.theme, duration: (($applies[$i + 1].ts | parse_ts) - ($apply.ts | parse_ts))} + elif $apply.machine == $machine and $apply.theme == $current then + {theme: $apply.theme, duration: ((now | floor) - ($apply.ts | parse_ts))} + else + empty + end + ] + | group_by(.theme) + | map({key: .[0].theme, value: (map(.duration) | add // 0)}) + | from_entries ' } diff --git a/tests/history.bats b/tests/history.bats index 2ce2404..88ee47f 100755 --- a/tests/history.bats +++ b/tests/history.bats @@ -204,16 +204,17 @@ setup() { assert_output --partial "theme name required" } -@test "calculate_usage_time credits each apply until the next one" { - # The apply log is a timeline, not a set of independent events: a theme's hours - # are the gaps between its apply and whatever replaced it. +@test "calculate_usage_time credits each apply until the next one on the same machine" { + # A theme's hours are the gaps between its apply and whatever replaced it on that + # machine. The other machine's apply at 02:00 neither ends nord nor counts itself. add_history_record "2025-01-01T00:00:00Z" gruvbox apply add_history_record "2025-01-01T01:00:00Z" nord apply + add_history_record "2025-01-01T02:00:00Z" kanagawa apply "" arch otherbox add_history_record "2025-01-01T03:00:00Z" gruvbox apply add_history_record "2025-01-01T04:00:00Z" everforest apply - run pipeline "calculate_usage_time '' | jq -c '{gruvbox,nord}'" - assert_output '{"gruvbox":7200,"nord":7200}' + run pipeline "calculate_usage_time '' | jq -c '{gruvbox,nord,kanagawa}'" + assert_output '{"gruvbox":7200,"nord":7200,"kanagawa":null}' } @test "calculate_usage_time leaves the final apply uncounted unless it is current" { @@ -227,6 +228,7 @@ setup() { } @test "calculate_usage_time counts the current theme up to now" { + stub_command_with_output uname testbox add_history_record "2025-01-01T00:00:00Z" gruvbox apply add_history_record "2025-01-01T01:00:00Z" nord apply From b12811dcec652c2bae3295cc6c6338e25d1c7b03 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 17 Oct 2026 22:17:28 -0400 Subject: [PATCH 04/10] feat(schedule): change the theme on a timer while the user is present theme schedule on installs a systemd user timer, or a launchd agent on macOS, that runs theme schedule run every five minutes. A run reads this machine's latest apply. A scheduled apply is due again after 6 hours and a hand apply after 12, so a theme picked by hand keeps the screen for half a day. A due change waits until the user is present: logind's IdleHint and LockedHint for the display session on Linux, ten minutes without input on macOS. Every change lands while the machine is in use. Apply rows carry a trigger, schedule or manual, and a row without one was applied by hand. theme rank adds SCHEDULED TURNS: a turn is kept when the machine's next apply was also scheduled, and switched away from otherwise, with the median time before the switch. Each run records its outcome in schedule.json, which theme schedule status reads: waiting, away, applied, blocked when the user manager has no display to reach, or failed when the apply wrote no history row. A scheduled apply pushes to the gist in the foreground, because the unit ends a backgrounded push when the run exits. The launchd agent sets AbandonProcessGroup for the same reason. WSL is refused, since it has no display session to read presence from. --- CLAUDE.md | 13 ++ README.md | 20 ++- bin/theme | 153 +++++++++++++++- lib/schedule.sh | 413 ++++++++++++++++++++++++++++++++++++++++++++ lib/storage.sh | 77 ++++++--- tests/helpers.bash | 10 ++ tests/history.bats | 42 ++--- tests/schedule.bats | 58 +++++++ 8 files changed, 731 insertions(+), 55 deletions(-) create mode 100755 lib/schedule.sh create mode 100755 tests/schedule.bats diff --git a/CLAUDE.md b/CLAUDE.md index 339ce14..6dd4b5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ generated data live at: ~/.local/state/theme/history.jsonl - Unified history (synced via gist) ~/.local/state/theme/current - Current theme ID ~/.local/state/theme/sync-state.json - Sync configuration +~/.local/state/theme/schedule.json - Outcome of the last scheduled check ~/.cache/theme/backgrounds/ - Pre-rendered backgrounds per theme ~/.cache/theme/gowall/ - Per-theme gowall palettes, as JSON ``` @@ -207,6 +208,18 @@ id with hyphens replaced by underscores. - **Change-signal colors are solved, not blended by a fixed fraction.** A fixed fraction lands at a different perceived strength on every palette. `delta.sh` searches for the blend that hits a target contrast ratio against that theme's own background. +- **`theme schedule` reads only this machine's apply rows.** Each apply row carries a + `trigger`, `schedule` or `manual`, and a row without one predates the schedule and was + applied by hand. A scheduled apply is due again after 6 hours and a hand apply after 12. + A due change waits until logind (or macOS input idleness) says the user is present. + `theme rank`'s SCHEDULED TURNS reads the same field: a scheduled turn is kept when the + machine's next apply was also scheduled, and switched away from otherwise. +- **A desktop daemon the apply restarts goes through Hyprland's exec dispatcher.** A + process started by the apply stays in the apply's cgroup even after `setsid`, and + systemd kills that cgroup when the scheduled oneshot exits. So `start_session_daemon` + hands waybar and dunst to Hyprland, where login started them. On launchd the same kill + hits the job's process group, which the agent's `AbandonProcessGroup` turns off. For + the same reason a scheduled apply pushes to the gist in the foreground. - **`theme random` weights by recency, and every eligible theme stays reachable.** `compute_theme_weights` scores each candidate from days since its last apply, and `weighted_random_choice` samples that distribution. Apply count is the wrong axis: narrowing the diff --git a/README.md b/README.md index 37fcf69..af6eaa9 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,27 @@ theme unreject # Restore a rejected theme (interactive picker) ### Rankings and History ```bash -theme rank # Rankings: by likes and by hours used +theme rank # Rankings: by likes, by hours used, and by scheduled turns kept theme log # View complete history ``` +### Schedule + +Changes the theme on a timer, so the history shows which themes you switch away +from and which you leave on: + +```bash +theme schedule on # Install the timer: systemd on Linux, launchd on macOS +theme schedule status # Whether it is on, and when the next change is due +theme schedule off # Remove the timer +``` + +A theme the schedule applied keeps the screen for 6 hours, and one you applied by +hand for 12. A due change waits until you are at the machine, so it lands while +you use it. `theme rank` counts each theme's scheduled turns as kept or switched +away from. On Hyprland, telling idle from active needs a hypridle listener, which +`theme schedule --help` shows. WSL is not supported. + ### Backgrounds Each theme can drive a matching desktop background. Four modes: `plasma` draws a @@ -236,6 +253,7 @@ Data and configuration live in XDG-compliant locations: ~/.local/state/theme/ ├── history.jsonl # Usage/rating history (synced) ├── current # Current theme ID +├── schedule.json # Outcome of the last scheduled check └── sync-state.json # Sync configuration ~/.config/theme/ diff --git a/bin/theme b/bin/theme index 21c561f..c57b5d9 100755 --- a/bin/theme +++ b/bin/theme @@ -23,6 +23,7 @@ fi source "$THEME_APP_DIR/lib/lib.sh" source "$THEME_APP_DIR/lib/storage.sh" source "$THEME_APP_DIR/lib/sync.sh" +source "$THEME_APP_DIR/lib/schedule.sh" # Sourced if present rather than required, so an installation predating this # dependency still runs every command it ran before. `theme update` says how to @@ -95,6 +96,7 @@ subcommands_table() { cat <<'EOF' background Manage backgrounds (run for usage) opacity Manage terminal opacity (run for usage) + schedule Change the theme on a timer while you are active (run for usage) sync Manage GitHub Gist synchronization (run for usage) EOF } @@ -152,6 +154,7 @@ verb_usage() { case "$verb" in background) background_usage && return ;; opacity) opacity_usage && return ;; + schedule) schedule_usage && return ;; sync) sync_usage && return ;; browsers) browsers_usage && return ;; ls) verb="list" ;; @@ -457,6 +460,81 @@ EOF fi } +schedule_usage_commands() { + cat <<'EOF' + status Show whether the schedule is on and when the next change is due + on Install the timer: systemd on Linux, launchd on macOS + off Remove the timer + run Check once, and change the theme if its turn is due (the timer runs this) +EOF +} + +schedule_usage_rules() { + local idle_minutes=$((THEME_SCHEDULE_IDLE_SECONDS / 60)) + local login1="busctl call org.freedesktop.login1 /org/freedesktop/login1/session/auto org.freedesktop.login1.Session SetIdleHint" + cat </dev/null; then + print_section "Theme Schedule" "yellow" + + print_section "Commands" "blue" + schedule_usage_commands + + print_section "How it decides" "blue" + schedule_usage_rules + + print_section "Examples" "yellow" + echo " $(print_cyan "theme schedule on") # Start changing themes on the timer" + echo " $(print_cyan "theme schedule status") # When the next change is due" + + else + cat <<'EOF' +Theme Schedule +============== + +Commands +-------- +EOF + schedule_usage_commands + cat <<'EOF' + +How it decides +-------------- +EOF + schedule_usage_rules + cat <<'EOF' + +Examples +-------- + theme schedule on # Start changing themes on the timer + theme schedule status # When the next change is due +EOF + fi +} + browsers_usage() { if command -v print_section &>/dev/null; then print_section "Browser Theme Diagnostics" "yellow" @@ -749,8 +827,11 @@ show_browsers() { fi } +# Usage: apply_theme [trigger] +# trigger is "manual" unless the schedule is applying, which sets "schedule". apply_theme() { local theme_input="$1" + local trigger="${2:-manual}" if [[ -z "$theme_input" ]]; then echo "Error: Theme name required" @@ -797,20 +878,26 @@ apply_theme() { reload_apps "$applied" # Log the theme action - log_action "apply" "$canonical" + log_action "apply" "$canonical" "" "$trigger" # Log the background action if one was applied if [[ -n "$background_id" ]] && [[ "$background_id" != "none" ]]; then log_background_action "apply" "$background_id" "$canonical" fi - sync_after_action + # A scheduled run pushes in the foreground itself, because its unit ends a backgrounded push. + [[ "$trigger" == "schedule" ]] || sync_after_action echo "" echo "Theme applied." + if schedule_is_on; then + schedule_next_change_line + fi } +# Usage: apply_random [trigger] apply_random() { + local trigger="${1:-manual}" local candidates mapfile -t candidates < <(list_themes_not_rejected) @@ -846,7 +933,7 @@ apply_random() { echo "🎲 Randomly selected: $display_info" echo " (${#candidates[@]} themes, weighted toward the least recently used)" echo "" - apply_theme "$random_theme" + apply_theme "$random_theme" "$trigger" } change_theme() { @@ -1190,6 +1277,38 @@ _render_rank_section() { done <<<"$sorted" } +# Finished scheduled turns per available theme, most often kept first. +# Args: $1 rankings JSON array carrying .display, $2 name width. +_render_turns_section() { + local rankings="$1" name_width="$2" + local rows + rows=$(get_schedule_turns | jq -s -r --argjson r "$rankings" ' + ($r | map({key: .theme, value: .display}) | from_entries) as $display + | map(select($display[.theme] != null) | .display = $display[.theme]) + | sort_by(-(.kept / .turns), -.turns, .display) + | .[] + | [.display, .kept, .turns, .switched, (.median_switch_seconds | if . == null then "" else floor end)] + | @tsv') + [[ -z "$rows" ]] && return 0 + + local c_reset='\033[0m' + local c_blue='\033[0;34m' + echo "" + echo -e "${c_blue}SCHEDULED TURNS${c_reset}" + + local rank=0 display kept turns switched median + while IFS=$'\t' read -r display kept turns switched median; do + rank=$((rank + 1)) + if [[ ${#display} -gt $name_width ]]; then + display="${display:0:$((name_width - 3))}..." + fi + local after="" + [[ -n "$median" ]] && after=" after $(format_duration "$median")" + printf "%2d. %-${name_width}s %7s kept %2d switched%s\n" \ + "$rank" "$display" "$kept/$turns" "$switched" "$after" + done <<<"$rows" +} + show_rankings() { sync_before_read @@ -1231,6 +1350,7 @@ show_rankings() { _render_rank_section "BY LIKES" "$rankings" likes "$name_width" _render_rank_section "BY HOURS USED" "$rankings" hours "$name_width" + _render_turns_section "$rankings" "$name_width" echo "" } @@ -1489,8 +1609,8 @@ main() { # identically. Never fails and never prints an error; `theme update` is where # update failures surface. if declare -F bashselfupdate_notify &>/dev/null; then - case "${1:-}" in - update | --version | -v) ;; + case "${1:-} ${2:-}" in + "update "* | "--version "* | "-v "* | "schedule run") ;; *) bashselfupdate_notify theme "$THEME_APP_DIR" ;; esac fi @@ -2110,6 +2230,29 @@ main() { ;; esac ;; + schedule) + case "${2:-}" in + "" | --help | -h) + schedule_usage + ;; + status) + schedule_status + ;; + on) + schedule_on + ;; + off) + schedule_off + ;; + run) + schedule_run + ;; + *) + echo "Usage: theme schedule " >&2 + exit 1 + ;; + esac + ;; --version | -v) echo "theme $(get_version)" exit 0 diff --git a/lib/schedule.sh b/lib/schedule.sh new file mode 100755 index 0000000..7a4fe55 --- /dev/null +++ b/lib/schedule.sh @@ -0,0 +1,413 @@ +#!/usr/bin/env bash +# A timer that changes the theme while the user is present, one schedule per machine. + +set -euo pipefail + +[[ -n "${_THEME_SCHEDULE_SOURCED:-}" ]] && return 0 +_THEME_SCHEDULE_SOURCED=1 + +THEME_SCHEDULE_TURN_HOURS=6 +THEME_SCHEDULE_HOLD_HOURS=12 +THEME_SCHEDULE_CHECK_MINUTES=5 +# macOS reads input idleness itself. The hypridle listener in the help uses the same figure. +THEME_SCHEDULE_IDLE_SECONDS=600 + +THEME_SCHEDULE_UNIT="theme-schedule" +THEME_SCHEDULE_LABEL="com.datapointchris.theme-schedule" +THEME_SCHEDULE_STATE_FILE="$THEME_STATE_DIR/schedule.json" + +# The service manager that runs the timer, or nothing. WSL has no display session to read. +schedule_supervisor() { + case "$(detect_platform)" in + macos) echo "launchd" ;; + wsl) ;; + *) + if command -v systemctl &>/dev/null && command -v loginctl &>/dev/null; then + echo "systemd" + fi + ;; + esac +} + +_schedule_systemd_dir() { + echo "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" +} + +_schedule_plist() { + echo "$HOME/Library/LaunchAgents/$THEME_SCHEDULE_LABEL.plist" +} + +# On means the timer's definition is installed. +schedule_is_on() { + case "$(schedule_supervisor)" in + systemd) [[ -f "$(_schedule_systemd_dir)/$THEME_SCHEDULE_UNIT.timer" ]] ;; + launchd) [[ -f "$(_schedule_plist)" ]] ;; + *) return 1 ;; + esac +} + +# A weekday alone is ambiguous more than six days away, so those get the date. +_schedule_clock() { + jq -nr --argjson t "$1" ' + if ($t - now) > -518400 and ($t - now) < 518400 then $t | strflocaltime("%a %H:%M") + else $t | strflocaltime("%Y-%m-%d %H:%M") + end' +} + +# This machine's latest apply as {theme, trigger, at, due}. A row with no trigger was applied by hand. +schedule_last_apply() { + get_history | jq -c \ + --arg machine "$(_storage_get_machine_id)" \ + --argjson turn "$((THEME_SCHEDULE_TURN_HOURS * 3600))" \ + --argjson hold "$((THEME_SCHEDULE_HOLD_HOURS * 3600))" ' + map(select(.action == "apply" and .machine == $machine)) | last // empty + | (.trigger // "manual") as $trigger + | (.ts | sub("[+-][0-9]{2}:[0-9]{2}$"; "Z") | fromdateiso8601) as $at + | { + theme, + trigger: $trigger, + at: $at, + due: ($at + (if $trigger == "schedule" then $turn else $hold end)) + }' +} + +# Prints why the user counts as away and succeeds, or prints "active" and fails. +schedule_away_reason() { + if [[ "$(detect_platform)" == "macos" ]]; then + local idle + idle=$(ioreg -c IOHIDSystem 2>/dev/null | awk '/HIDIdleTime/ { print int($NF / 1000000000); exit }' || true) + if [[ "${idle:-0}" -ge "$THEME_SCHEDULE_IDLE_SECONDS" ]]; then + echo "no input for $(format_duration "$idle")" + return 0 + fi + else + local session hints + session=$(loginctl show-user "$(id -u)" -p Display --value 2>/dev/null || true) + if [[ -z "$session" ]]; then + echo "no display session" + return 0 + fi + hints=$(loginctl show-session "$session" -p IdleHint -p LockedHint 2>/dev/null || true) + if [[ "$hints" == *"LockedHint=yes"* ]]; then + echo "session $session is locked" + return 0 + fi + if [[ "$hints" == *"IdleHint=yes"* ]]; then + echo "session $session is idle" + return 0 + fi + fi + + echo "active" + return 1 +} + +# Prints "\t\t" at a moment in epoch seconds. +# The verdict is waiting, away (due but nobody present) or due. +schedule_decide() { + local now="$1" + local due + due=$(schedule_last_apply | jq -r '.due') + due="${due:-$now}" + + if [[ "$now" -lt "$due" ]]; then + printf 'waiting\t%s\t\n' "$due" + return 0 + fi + + local reason + if reason=$(schedule_away_reason); then + printf 'away\t%s\t%s\n' "$due" "$reason" + return 0 + fi + + printf 'due\t%s\t\n' "$due" +} + +# Replaced through a rename, so `theme schedule status` never reads half a file. +_schedule_record() { + local outcome="$1" detail="$2" + mkdir -p "$THEME_STATE_DIR" + local partial="$THEME_SCHEDULE_STATE_FILE.$$" + jq -nc \ + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg outcome "$outcome" \ + --arg detail "$detail" \ + '{ts: $ts, outcome: $outcome, detail: $detail}' >"$partial" + mv "$partial" "$THEME_SCHEDULE_STATE_FILE" +} + +# One check, which is what the timer runs. +schedule_run() { + local verdict due reason + IFS=$'\t' read -r verdict due reason < <(schedule_decide "$(date +%s)") + + case "$verdict" in + waiting) + _schedule_record waiting "next change due $(_schedule_clock "$due")" + echo "waiting: next change due $(_schedule_clock "$due")" + return 0 + ;; + away) + _schedule_record away "due since $(_schedule_clock "$due"), $reason" + echo "away: due since $(_schedule_clock "$due"), $reason" + return 0 + ;; + esac + + # Without the compositor's environment, the bar the apply restarts cannot connect and exits. + if [[ "$(detect_platform)" != "macos" ]] && [[ -z "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]]; then + local blocked="no WAYLAND_DISPLAY or DISPLAY, so a change could not reach the desktop" + _schedule_record blocked "$blocked" + echo "blocked: $blocked" >&2 + return 1 + fi + + # The draw weighs every machine's applies. + sync_pull &>/dev/null || true + + local started status=0 + started=$(date +%s) + # Backgrounded and waited on: called in a condition, the apply would run with errexit off. + apply_random schedule & + wait "$!" || status=$? + + # The history row is what the apply produced, so it decides the outcome. + local last + last=$(schedule_last_apply) + if [[ -n "$last" ]] \ + && [[ "$(jq -r '.trigger' <<<"$last")" == "schedule" ]] \ + && [[ "$(jq -r '.at' <<<"$last")" -ge "$started" ]]; then + _schedule_record applied "$(jq -r '.theme' <<<"$last")" + # In the foreground, because the unit ends any process left behind. + sync_push &>/dev/null || true + return 0 + fi + + _schedule_record failed "the apply exited $status without writing its history row" + echo "failed: the apply exited $status without writing its history row" >&2 + return 1 +} + +# The line `theme apply` prints while the schedule is on. +schedule_next_change_line() { + local last + last=$(schedule_last_apply) + [[ -z "$last" ]] && return 0 + + local due hours=$THEME_SCHEDULE_HOLD_HOURS + due=$(jq -r '.due' <<<"$last") + [[ "$(jq -r '.trigger' <<<"$last")" == "schedule" ]] && hours=$THEME_SCHEDULE_TURN_HOURS + echo "Schedule: next change after $(_schedule_clock "$due") (${hours} h, once you are active)" +} + +_schedule_write_systemd() { + local dir + dir=$(_schedule_systemd_dir) + mkdir -p "$dir" + + # The enabling shell's PATH, since the user manager's lacks where jq, yq and gh usually live. + cat >"$dir/$THEME_SCHEDULE_UNIT.service" <"$dir/$THEME_SCHEDULE_UNIT.timer" <"$plist" < + + + + Label + $THEME_SCHEDULE_LABEL + ProgramArguments + + $THEME_APP_DIR/bin/theme + schedule + run + + EnvironmentVariables + + PATH + $PATH + + StartInterval + $((THEME_SCHEDULE_CHECK_MINUTES * 60)) + AbandonProcessGroup + + StandardErrorPath + $THEME_STATE_DIR/schedule.log + + +EOF +} + +_schedule_unsupported() { + echo "Error: the schedule needs systemd and logind, or launchd on macOS" >&2 + if [[ "$(detect_platform)" == "wsl" ]]; then + echo " WSL has no display session to tell whether you are present." >&2 + fi + return 1 +} + +schedule_on() { + case "$(schedule_supervisor)" in + systemd) + _schedule_write_systemd + systemctl --user daemon-reload + systemctl --user --quiet enable "$THEME_SCHEDULE_UNIT.timer" + systemctl --user restart "$THEME_SCHEDULE_UNIT.timer" + ;; + launchd) + _schedule_write_launchd + launchctl bootout "gui/$(id -u)/$THEME_SCHEDULE_LABEL" 2>/dev/null || true + launchctl bootstrap "gui/$(id -u)" "$(_schedule_plist)" + ;; + *) + _schedule_unsupported + return 1 + ;; + esac + + echo "✓ Schedule on, checking every $THEME_SCHEDULE_CHECK_MINUTES minutes" + echo " A scheduled theme gets $THEME_SCHEDULE_TURN_HOURS h and one applied by hand $THEME_SCHEDULE_HOLD_HOURS h." + echo " A due change waits until you are active." + _schedule_warn_idle_hint +} + +schedule_off() { + case "$(schedule_supervisor)" in + systemd) + local dir + dir=$(_schedule_systemd_dir) + systemctl --user --quiet disable --now "$THEME_SCHEDULE_UNIT.timer" 2>/dev/null || true + rm -f "$dir/$THEME_SCHEDULE_UNIT.service" "$dir/$THEME_SCHEDULE_UNIT.timer" + systemctl --user daemon-reload + ;; + launchd) + launchctl bootout "gui/$(id -u)/$THEME_SCHEDULE_LABEL" 2>/dev/null || true + rm -f "$(_schedule_plist)" + ;; + *) + _schedule_unsupported + return 1 + ;; + esac + + echo "✓ Schedule off" +} + +# IdleSinceHint 0 means IdleHint has not changed since login, which is how an unset hint looks. +_schedule_warn_idle_hint() { + [[ "$(schedule_supervisor)" == "systemd" ]] || return 0 + + local session since + session=$(loginctl show-user "$(id -u)" -p Display --value 2>/dev/null || true) + [[ -n "$session" ]] || return 0 + since=$(loginctl show-session "$session" -p IdleSinceHint --value 2>/dev/null || true) + if [[ "$since" == "0" ]]; then + echo " ! IdleHint has not changed since login, so every check may read you as active." + echo " An idle daemon has to set it: see theme schedule --help" + fi +} + +_schedule_timer_state() { + case "$(schedule_supervisor)" in + systemd) + echo "timer $(systemctl --user is-active "$THEME_SCHEDULE_UNIT.timer" 2>/dev/null || true)" + ;; + launchd) + if launchctl print "gui/$(id -u)/$THEME_SCHEDULE_LABEL" &>/dev/null; then + echo "agent loaded" + else + echo "agent not loaded" + fi + ;; + esac +} + +schedule_status() { + echo "" + echo "Theme Schedule" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + + if [[ -z "$(schedule_supervisor)" ]]; then + echo "Status: not supported on $(detect_platform)" + return 0 + fi + + local on=false + schedule_is_on && on=true + if [[ "$on" == true ]]; then + echo "Status: On ✓ ($(_schedule_timer_state))" + else + echo "Status: Off" + fi + + local last now + last=$(schedule_last_apply) + now=$(date +%s) + if [[ -n "$last" ]]; then + local theme at due how="by hand" + theme=$(jq -r '.theme' <<<"$last") + at=$(jq -r '.at' <<<"$last") + due=$(jq -r '.due' <<<"$last") + [[ "$(jq -r '.trigger' <<<"$last")" == "schedule" ]] && how="by the schedule" + echo "Current: $(get_theme_display_info "$theme"), applied $how $(_schedule_clock "$at")" + if [[ "$now" -lt "$due" ]]; then + echo "Next change: after $(_schedule_clock "$due"), once you are active" + else + echo "Next change: due since $(_schedule_clock "$due"), at the first check while you are active" + fi + fi + + local reason + if reason=$(schedule_away_reason); then + echo "Presence: away ($reason)" + else + echo "Presence: active" + fi + _schedule_warn_idle_hint + + if [[ -f "$THEME_SCHEDULE_STATE_FILE" ]]; then + local checked outcome detail age + checked=$(jq -r '.ts | fromdateiso8601' "$THEME_SCHEDULE_STATE_FILE") + outcome=$(jq -r '.outcome' "$THEME_SCHEDULE_STATE_FILE") + detail=$(jq -r '.detail' "$THEME_SCHEDULE_STATE_FILE") + echo "Last check: $(_schedule_clock "$checked"), $outcome: $detail" + age=$((now - checked)) + if [[ "$on" == true ]] && [[ "$age" -gt $((THEME_SCHEDULE_CHECK_MINUTES * 60 * 3)) ]]; then + echo " ! No check for $(format_duration "$age"), so the timer is not running." + fi + elif [[ "$on" == true ]]; then + echo "Last check: none yet" + fi + + if [[ "$on" == false ]]; then + echo "" + echo "Turn it on with: theme schedule on" + fi +} diff --git a/lib/storage.sh b/lib/storage.sh index f2f911f..621d045 100755 --- a/lib/storage.sh +++ b/lib/storage.sh @@ -103,10 +103,13 @@ _storage_get_machine_id() { echo "$host" } +# Usage: log_action [theme] [message] [trigger] +# trigger says who applied a theme: "schedule" or "manual". Absent keys are omitted, never blank. log_action() { local action="$1" local theme="${2:-}" local message="${3:-}" + local trigger="${4:-}" if [[ -z "$action" ]]; then echo "Error: action required" >&2 @@ -115,34 +118,17 @@ log_action() { mkdir -p "$THEME_STATE_DIR" - local timestamp - timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ) - local platform - platform=$(detect_platform) - local machine - machine=$(_storage_get_machine_id) - - local record - if [[ -n "$message" ]]; then - record=$(jq -nc \ - --arg ts "$timestamp" \ - --arg platform "$platform" \ - --arg machine "$machine" \ - --arg theme "$theme" \ - --arg act "$action" \ - --arg msg "$message" \ - '{ts: $ts, platform: $platform, machine: $machine, theme: $theme, action: $act, message: $msg}') - else - record=$(jq -nc \ - --arg ts "$timestamp" \ - --arg platform "$platform" \ - --arg machine "$machine" \ - --arg theme "$theme" \ - --arg act "$action" \ - '{ts: $ts, platform: $platform, machine: $machine, theme: $theme, action: $act}') - fi - - echo "$record" >>"$THEME_HISTORY_FILE" + jq -nc \ + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg platform "$(detect_platform)" \ + --arg machine "$(_storage_get_machine_id)" \ + --arg theme "$theme" \ + --arg act "$action" \ + --arg msg "$message" \ + --arg trigger "$trigger" \ + '{ts: $ts, platform: $platform, machine: $machine, theme: $theme, action: $act} + + (if $msg == "" then {} else {message: $msg} end) + + (if $trigger == "" then {} else {trigger: $trigger} end)' >>"$THEME_HISTORY_FILE" } get_history() { @@ -221,6 +207,41 @@ calculate_usage_time() { ' } +# Each theme's finished scheduled turns: kept until the next scheduled change, or switched +# away from by hand, with the median seconds before the switch. +get_schedule_turns() { + get_history | jq -c ' + def parse_ts: sub("[+-][0-9]{2}:[0-9]{2}$"; "Z") | fromdateiso8601; + def median: + sort + | if length == 0 then null + elif length % 2 == 1 then .[length / 2 | floor] + else (.[length / 2 - 1] + .[length / 2]) / 2 + end; + + [ + map(select(.action == "apply")) | group_by(.machine)[] | sort_by(.ts) + | . as $applies + | range(0; ($applies | length) - 1) as $i + | select($applies[$i].trigger == "schedule") + | { + theme: $applies[$i].theme, + kept: ($applies[$i + 1].trigger == "schedule"), + seconds: (($applies[$i + 1].ts | parse_ts) - ($applies[$i].ts | parse_ts)) + } + ] + | group_by(.theme) + | map({ + theme: .[0].theme, + turns: length, + kept: (map(select(.kept)) | length), + switched: (map(select(.kept | not)) | length), + median_switch_seconds: (map(select(.kept | not) | .seconds) | median) + }) + | .[] + ' +} + get_rankings() { local current_theme current_theme=$(get_current_theme 2>/dev/null || echo "") diff --git a/tests/helpers.bash b/tests/helpers.bash index 6e0e7f7..afae7e0 100755 --- a/tests/helpers.bash +++ b/tests/helpers.bash @@ -112,6 +112,16 @@ add_history_record() { fi } +# An apply carrying the trigger the schedule reads: "schedule" or "manual". +# +# Usage: add_apply_record [machine] +add_apply_record() { + local ts="$1" theme="$2" trigger="$3" machine="${4:-testbox}" + mkdir -p "$(dirname "$THEME_HISTORY_FILE")" + printf '{"ts":"%s","platform":"arch","machine":"%s","theme":"%s","action":"apply","trigger":"%s"}\n' \ + "$ts" "$machine" "$theme" "$trigger" >>"$THEME_HISTORY_FILE" +} + # Usage: add_background_record [message] add_background_record() { local ts="$1" background="$2" theme="$3" action="$4" message="${5:-}" diff --git a/tests/history.bats b/tests/history.bats index 88ee47f..7b48f4d 100755 --- a/tests/history.bats +++ b/tests/history.bats @@ -32,18 +32,17 @@ setup() { assert_output "action,machine,platform,theme,ts" } -@test "log_action omits the message key rather than writing an empty one" { - # Two record shapes, not one with a blank field. Readers test for the key's - # presence — get_background_stats collects `.message // empty` — so an empty - # string would show up as a note with no text. +@test "log_action writes the message and trigger keys only when given" { + # Readers test for the key's presence — get_background_stats collects + # `.message // empty` — so an empty string would show up as a note with no text. log_action apply gruvbox - run jq -r 'has("message")' "$THEME_HISTORY_FILE" - assert_output "false" + run jq -r '[has("message"), has("trigger")] | join(",")' "$THEME_HISTORY_FILE" + assert_output "false,false" : >"$THEME_HISTORY_FILE" - log_action like gruvbox "nice" - run jq -r '.message' "$THEME_HISTORY_FILE" - assert_output "nice" + log_action apply gruvbox "nice" schedule + run jq -r '[.message, .trigger] | join(",")' "$THEME_HISTORY_FILE" + assert_output "nice,schedule" } @test "log_action records the platform and machine of the writing host" { @@ -63,18 +62,6 @@ setup() { assert_output "true" } -@test "log_action requires an action" { - run log_action "" - assert_failure - assert_output --partial "action required" -} - -@test "log_action creates the state directory if it is missing" { - rm -rf "$THEME_STATE_DIR" - log_action apply gruvbox - assert_equal "$(wc -l <"$THEME_HISTORY_FILE" | xargs)" "1" -} - #============================================================================== # NORMALIZATION ON READ #============================================================================== @@ -248,6 +235,19 @@ setup() { assert_output "7200" } +@test "get_schedule_turns counts a turn kept until the next scheduled change, or switched away by hand" { + # The other machine's hand apply between sora and kanagawa does not end sora's turn. + add_apply_record "2026-01-01T00:00:00Z" sora schedule + add_apply_record "2026-01-01T01:00:00Z" nord manual otherbox + add_apply_record "2026-01-01T06:00:00Z" kanagawa schedule + add_apply_record "2026-01-01T06:14:00Z" nord manual + add_apply_record "2026-01-01T18:14:00Z" sora schedule + + run pipeline "get_schedule_turns | jq -c '[.theme, .turns, .kept, .switched, .median_switch_seconds]'" + assert_output '["kanagawa",1,0,1,840] +["sora",1,1,0,null]' +} + #============================================================================== # RANKINGS #============================================================================== diff --git a/tests/schedule.bats b/tests/schedule.bats new file mode 100755 index 0000000..15f7ce9 --- /dev/null +++ b/tests/schedule.bats @@ -0,0 +1,58 @@ +#!/usr/bin/env bats +# When the schedule changes the theme: the hold each kind of apply earns, and the presence check. + +load "$HOME/.local/lib/bats-support/load.bash" +load "$HOME/.local/lib/bats-assert/load.bash" + +setup() { + load "$BATS_TEST_DIRNAME/helpers.bash" + isolate_theme_state + source_theme_libs + source "$THEME_ROOT/lib/schedule.sh" + stub_command_with_output uname testbox +} + +# A logind whose display session is 2 and carries the given hints. +stub_session() { + cat >"$BATS_TEST_TMPDIR/stubs/loginctl" < Date: Sat, 17 Oct 2026 22:44:45 -0400 Subject: [PATCH 05/10] fix(storage): give this machine the id its history rows read back as get_history passes every row's machine through normalize_machine, which strips legacy platform prefixes such as linux- and arch-. The id this machine compared against was the raw hostname, so on a host named linux-box no row ever matched. The schedule found no last apply and changed the theme at every check, the usage timeline never ran the current theme to now, and sync pushed nothing and dropped this machine's local rows on every pull. The id now goes through the same jq definition, so writes, comparisons and the gist filename agree with what reading produces. --- lib/storage.sh | 6 ++++-- tests/platform.bats | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/storage.sh b/lib/storage.sh index 621d045..ef6d163 100755 --- a/lib/storage.sh +++ b/lib/storage.sh @@ -96,11 +96,13 @@ _storage_get_machine_id() { # field, so prefixing it (the old "platform-host" format) both duplicated the # platform and split one machine across records when the platform label drifted # (arch -> archlinux) or the hostname case changed (Macmini vs macmini). The - # read-time normalize_machine shim canonicalizes existing platform-host history. + # read-time normalize_machine shim canonicalizes existing platform-host history, + # and the id goes through the same shim. A hostname shaped like a legacy label + # (linux-box) reads back as "box", so a raw id would match none of its own rows. local host host=$(uname -n 2>/dev/null | cut -d. -f1 | tr '[:upper:]' '[:lower:]') [[ -z "$host" ]] && host="unknown" - echo "$host" + jq -rn --arg host "$host" "$(_jq_normalize_history) \$host | normalize_machine" } # Usage: log_action [theme] [message] [trigger] diff --git a/tests/platform.bats b/tests/platform.bats index 90c3e29..e1aeb24 100755 --- a/tests/platform.bats +++ b/tests/platform.bats @@ -122,6 +122,14 @@ setup() { assert_output "macmini" } +@test "a host named like a platform-host label (linux-box) finds its own history rows" { + # The schedule, the usage timeline and sync each select this machine's rows by id. + stub_command_with_output uname "linux-box" + log_action apply gruvbox + run pipeline "get_history | jq --arg me \"\$(_storage_get_machine_id)\" 'map(select(.machine == \$me)) | length'" + assert_output "1" +} + @test "the machine id falls back to unknown when the hostname is empty" { # Not hypothetical: the Arch box recorded an empty hostname for a while, which # is why the history normalizer has an "unknown" branch to merge back. From 1e4c50b424049797766247934dcfe7190613ce8b Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 17 Oct 2026 22:45:51 -0400 Subject: [PATCH 06/10] fix(reload): start a restarted daemon in its own scope when Hyprland cannot take it A process the apply starts stays in the apply's cgroup even after setsid, and systemd kills that cgroup when a scheduled oneshot exits. Outside Hyprland the restarted waybar or dunst died with every scheduled change. systemd-run --user --scope moves it into a transient scope of its own, which outlives the service and still inherits the apply's environment. setsid stays for a machine with no user manager. --- CLAUDE.md | 3 ++- lib/lib.sh | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6dd4b5b..752632a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,7 +217,8 @@ id with hyphens replaced by underscores. - **A desktop daemon the apply restarts goes through Hyprland's exec dispatcher.** A process started by the apply stays in the apply's cgroup even after `setsid`, and systemd kills that cgroup when the scheduled oneshot exits. So `start_session_daemon` - hands waybar and dunst to Hyprland, where login started them. On launchd the same kill + hands waybar and dunst to Hyprland, where login started them, and elsewhere starts them + in a transient `systemd-run --user --scope`. On launchd the same kill hits the job's process group, which the agent's `AbandonProcessGroup` turns off. For the same reason a scheduled apply pushes to the gist in the foreground. - **`theme random` weights by recency, and every eligible theme stays reachable.** diff --git a/lib/lib.sh b/lib/lib.sh index 632fc36..99867d1 100755 --- a/lib/lib.sh +++ b/lib/lib.sh @@ -2369,15 +2369,19 @@ reload_dunst() { return 1 } -# Starts a desktop daemon as Hyprland's child, where login started it. A child of the -# apply stays in the apply's cgroup, which systemd kills when an apply run as a service ends. +# Starts a desktop daemon outside the apply's cgroup, which systemd kills when an apply run as a +# service ends. Hyprland's child sits where login started it. A transient scope is the fallback. start_session_daemon() { local program="$1" if command -v hyprctl &>/dev/null \ && [[ "$(hyprctl dispatch "hl.dsp.exec_cmd('$program')" 2>/dev/null)" == "ok" ]]; then return 0 fi - setsid "$program" >/dev/null 2>&1 /dev/null; then + systemd-run --user --scope --collect --quiet "$program" >/dev/null 2>&1 /dev/null 2>&1 Date: Sat, 17 Oct 2026 22:46:46 -0400 Subject: [PATCH 07/10] fix(schedule): block a scheduled run that cannot reach Hyprland The display guard checked WAYLAND_DISPLAY and DISPLAY, but every Hyprland call in the apply also needs HYPRLAND_INSTANCE_SIGNATURE. A user manager without it still wrote the history row, so the check recorded applied while the window colors, the wallpaper and the restarted bar never changed. The guard now asks hyprctl whether it reaches a running Hyprland. A blocked check names each missing variable and the systemctl --user import-environment line that supplies it, and --help says the same. --- bin/theme | 11 ++++++++--- lib/schedule.sh | 20 +++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/bin/theme b/bin/theme index c57b5d9..fb1c50b 100755 --- a/bin/theme +++ b/bin/theme @@ -490,9 +490,14 @@ schedule_usage_rules() { on-resume = $login1 b false } - On Linux the timer runs in the systemd user manager, which needs the desktop's - WAYLAND_DISPLAY or DISPLAY. theme schedule status reports a check blocked - without it. + On Linux the timer runs in the systemd user manager. It needs the desktop's + WAYLAND_DISPLAY or DISPLAY, and under Hyprland its HYPRLAND_INSTANCE_SIGNATURE. + systemctl --user show-environment lists what the manager has. If your session + does not export them to it, run this at login: + + systemctl --user import-environment WAYLAND_DISPLAY DISPLAY HYPRLAND_INSTANCE_SIGNATURE + + theme schedule status reports a check blocked without them. EOF } diff --git a/lib/schedule.sh b/lib/schedule.sh index 7a4fe55..1c4141e 100755 --- a/lib/schedule.sh +++ b/lib/schedule.sh @@ -137,6 +137,18 @@ _schedule_record() { mv "$partial" "$THEME_SCHEDULE_STATE_FILE" } +# The user manager carries only what the session imported. Without these, the restarted bar exits. +_schedule_missing_desktop_env() { + [[ "$(detect_platform)" == "macos" ]] && return 0 + local missing=() + [[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || missing+=(WAYLAND_DISPLAY DISPLAY) + if command -v hyprctl &>/dev/null && pgrep -u "$(id -u)" -x Hyprland &>/dev/null \ + && ! hyprctl version &>/dev/null; then + missing+=(HYPRLAND_INSTANCE_SIGNATURE) + fi + echo "${missing[*]}" +} + # One check, which is what the timer runs. schedule_run() { local verdict due reason @@ -155,9 +167,11 @@ schedule_run() { ;; esac - # Without the compositor's environment, the bar the apply restarts cannot connect and exits. - if [[ "$(detect_platform)" != "macos" ]] && [[ -z "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]]; then - local blocked="no WAYLAND_DISPLAY or DISPLAY, so a change could not reach the desktop" + local missing + missing=$(_schedule_missing_desktop_env) + if [[ -n "$missing" ]]; then + local blocked="no usable $missing, so a change could not reach the desktop." + blocked+=" From a terminal on the desktop, run: systemctl --user import-environment $missing" _schedule_record blocked "$blocked" echo "blocked: $blocked" >&2 return 1 From 2c29d67c1698037b2cbfe436972cc7b2d3f51bbb Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 17 Oct 2026 22:47:50 -0400 Subject: [PATCH 08/10] fix(schedule): promise a next change only while the schedule is on With the timer off, status still printed when the next change was due, though no check would ever run. The Turn it on with line already says what to do. --- lib/schedule.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/schedule.sh b/lib/schedule.sh index 1c4141e..bf26ae8 100755 --- a/lib/schedule.sh +++ b/lib/schedule.sh @@ -391,10 +391,12 @@ schedule_status() { due=$(jq -r '.due' <<<"$last") [[ "$(jq -r '.trigger' <<<"$last")" == "schedule" ]] && how="by the schedule" echo "Current: $(get_theme_display_info "$theme"), applied $how $(_schedule_clock "$at")" - if [[ "$now" -lt "$due" ]]; then - echo "Next change: after $(_schedule_clock "$due"), once you are active" - else - echo "Next change: due since $(_schedule_clock "$due"), at the first check while you are active" + if [[ "$on" == true ]]; then + if [[ "$now" -lt "$due" ]]; then + echo "Next change: after $(_schedule_clock "$due"), once you are active" + else + echo "Next change: due since $(_schedule_clock "$due"), at the first check while you are active" + fi fi fi From 5d53615ea8de72fdf753253d6ca4ff65bee35b16 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 17 Oct 2026 22:48:56 -0400 Subject: [PATCH 09/10] docs: point at the schedule's durations instead of copying them README and CLAUDE.md restated the turn and hold hours, which --help already builds from the constants. Tuning either constant would have left both sentences wrong. --- CLAUDE.md | 3 ++- README.md | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 752632a..4792e6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,7 +210,8 @@ id with hyphens replaced by underscores. target contrast ratio against that theme's own background. - **`theme schedule` reads only this machine's apply rows.** Each apply row carries a `trigger`, `schedule` or `manual`, and a row without one predates the schedule and was - applied by hand. A scheduled apply is due again after 6 hours and a hand apply after 12. + applied by hand. A scheduled apply is due again after `THEME_SCHEDULE_TURN_HOURS` and a + hand apply after the longer `THEME_SCHEDULE_HOLD_HOURS`, both in `lib/schedule.sh`. A due change waits until logind (or macOS input idleness) says the user is present. `theme rank`'s SCHEDULED TURNS reads the same field: a scheduled turn is kept when the machine's next apply was also scheduled, and switched away from otherwise. diff --git a/README.md b/README.md index af6eaa9..d4714f3 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,9 @@ theme schedule status # Whether it is on, and when the next change is du theme schedule off # Remove the timer ``` -A theme the schedule applied keeps the screen for 6 hours, and one you applied by -hand for 12. A due change waits until you are at the machine, so it lands while +A theme you applied by hand keeps the screen longer than one the schedule applied. +`theme schedule --help` gives both durations. A due change waits until you are at +the machine, so it lands while you use it. `theme rank` counts each theme's scheduled turns as kept or switched away from. On Hyprland, telling idle from active needs a hypridle listener, which `theme schedule --help` shows. WSL is not supported. From 70a102d045739b3214ed2fb0cc5968a084534e48 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 17 Oct 2026 22:49:46 -0400 Subject: [PATCH 10/10] test(history): drop the absent-keys block another test already pins "log_action writes one JSON object per call" asserts the record's exact key set, so a blank message or trigger key fails it too. What stays is the only test that log_action writes the trigger the schedule reads. --- tests/history.bats | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/history.bats b/tests/history.bats index 7b48f4d..1dbf6c6 100755 --- a/tests/history.bats +++ b/tests/history.bats @@ -32,14 +32,7 @@ setup() { assert_output "action,machine,platform,theme,ts" } -@test "log_action writes the message and trigger keys only when given" { - # Readers test for the key's presence — get_background_stats collects - # `.message // empty` — so an empty string would show up as a note with no text. - log_action apply gruvbox - run jq -r '[has("message"), has("trigger")] | join(",")' "$THEME_HISTORY_FILE" - assert_output "false,false" - - : >"$THEME_HISTORY_FILE" +@test "log_action writes the message and the trigger the schedule reads" { log_action apply gruvbox "nice" schedule run jq -r '[.message, .trigger] | join(",")' "$THEME_HISTORY_FILE" assert_output "nice,schedule"