diff --git a/CLAUDE.md b/CLAUDE.md index 339ce14..4792e6c 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,20 @@ 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 `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. +- **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, 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.** `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..d4714f3 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,28 @@ 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 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. + ### Backgrounds Each theme can drive a matching desktop background. Four modes: `plasma` draws a @@ -236,6 +254,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..fb1c50b 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,86 @@ 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 +832,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 +883,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 +938,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 +1282,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 +1355,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 +1614,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 +2235,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/lib.sh b/lib/lib.sh index 6a315c4..99867d1 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) @@ -1814,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 } @@ -2344,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 @@ -2358,8 +2351,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 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 + if systemctl --user show-environment &>/dev/null; then + systemd-run --user --scope --collect --quiet "$program" >/dev/null 2>&1 /dev/null 2>&1 /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" +} + +# 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 + 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 + + 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 + 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 [[ "$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 + + 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 85014e2..ef6d163 100755 --- a/lib/storage.sh +++ b/lib/storage.sh @@ -96,17 +96,22 @@ _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] +# 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 +120,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() { @@ -189,10 +177,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 +190,57 @@ calculate_usage_time() { fromdateiso8601 end; - [map(select(.action == "apply")) | sort_by(.ts) | to_entries[]] as $applies | + [ + 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 + ' +} - ($applies | map( - . as $entry | - $entry.key as $idx | - $entry.value as $apply | +# 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; - 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)) + [ + 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)) } - else - null - end - ) | map(select(. != null))) as $durations | - - ($durations | group_by(.theme) | map({ - key: .[0].theme, - value: (map(.duration) | add // 0) - }) | from_entries) + ] + | 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) + }) + | .[] ' } 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 2ce2404..1dbf6c6 100755 --- a/tests/history.bats +++ b/tests/history.bats @@ -32,18 +32,10 @@ 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. - log_action apply gruvbox - run jq -r 'has("message")' "$THEME_HISTORY_FILE" - assert_output "false" - - : >"$THEME_HISTORY_FILE" - log_action like gruvbox "nice" - run jq -r '.message' "$THEME_HISTORY_FILE" - assert_output "nice" +@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" } @test "log_action records the platform and machine of the writing host" { @@ -63,18 +55,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 #============================================================================== @@ -204,16 +184,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 +208,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 @@ -246,6 +228,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/opacity.bats b/tests/opacity.bats index eb60672..d8ac591 100755 --- a/tests/opacity.bats +++ b/tests/opacity.bats @@ -17,8 +17,7 @@ setup() { source_theme_libs stub_command tmux 1 # "no server running", so nothing is sourced into one - stub_command pgrep 1 # waybar not running - stub_command killall 0 # never reached while pgrep says no + stub_command pgrep 1 # waybar not running, so it is never restarted stub_command cmd.exe 1 # not WSL, so the Windows Terminal write is skipped } 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. 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" <