Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/
Expand Down
158 changes: 153 additions & 5 deletions bin/theme
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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" ;;
Expand Down Expand Up @@ -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 <<EOF
A theme the schedule applied keeps the screen for $THEME_SCHEDULE_TURN_HOURS hours. One you applied
by hand keeps it for $THEME_SCHEDULE_HOLD_HOURS. A due change waits until you are at the machine,
so it lands while you use it. The timer checks every $THEME_SCHEDULE_CHECK_MINUTES minutes.

theme rank counts each theme's scheduled turns: kept until the next scheduled
change, or switched away from by hand.

Away on macOS means no input for $idle_minutes minutes. On Linux it means logind's
IdleHint or LockedHint is set for your display session. hypridle does not set
IdleHint by itself; this listener in hypridle.conf does:

listener {
timeout = $THEME_SCHEDULE_IDLE_SECONDS
on-timeout = $login1 b true
on-resume = $login1 b false
}

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
}

schedule_usage() {
if command -v print_section &>/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"
Expand Down Expand Up @@ -749,8 +832,11 @@ show_browsers() {
fi
}

# Usage: apply_theme <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"
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 ""
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <status|on|off|run>" >&2
exit 1
;;
esac
;;
--version | -v)
echo "theme $(get_version)"
exit 0
Expand Down
35 changes: 21 additions & 14 deletions lib/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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 &
disown
start_session_daemon waybar
return 0
fi
return 1
Expand All @@ -2370,14 +2362,29 @@ reload_dunst() {
killall dunst 2>/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 &
else
setsid "$program" >/dev/null 2>&1 </dev/null &
fi
disown
}

# Reload all applicable apps after theme apply
reload_apps() {
local platform
Expand Down
Loading
Loading