Motion-Wallpaper-Omarchy/motion-wallpaper-toggle
Gavin Nugent ee9d894ad9 TUI: float from Walker, follow Omarchy theme, center the layout
Launcher
  - .desktop now wraps the toggle in `xdg-terminal-exec --app-id=TUI.float`
    so Hyprland's existing floating-window rule treats it like btop / impala
    instead of opening a tiled fullscreen terminal.

Theme integration
  - Read ~/.config/omarchy/current/theme/colors.toml at startup; map
    accent/color1/color2/color8 → COLOR_ACCENT/ERROR/OK/MUTED. Catppuccin
    fallback if the file is missing.
  - Push the theme into gum's own widget chrome (cursor, selected row,
    headers, prompts, confirm) via GUM_* env vars so the menu cursor and
    highlight follow the active theme instead of gum's pink defaults.

Centered layout
  - Compute panel width / margin / top-pad from `tput cols` + `tput lines`.
  - Status panel rendered with --margin to center horizontally; title
    centered inside the box.
  - GUM_CHOOSE_CURSOR padded with PANEL_INDENT so menu rows line up with
    the panel's left edge.
  - prompt_header indents the per-prompt label and appends a centered,
    muted nav hint; gum's flush-left help footer suppressed via
    GUM_CHOOSE_SHOW_HELP=false (also filter / file).
  - center_screen() clears + pads before each gum prompt so the UI sits
    vertically centered on every navigation step.

Stopped-state menu
  - Replaced the "panel flashes then drops into file picker" flow with a
    gum-choose menu first: Start with <last video> / Pick a video and
    start / Turn autostart ON|OFF / Cancel. Status panel stays visible.

$HOME-confined browser
  - Replaced gum file (alt-screen, can navigate above $HOME via its
    built-in up key) with a gum-choose-driven browser. The "Up one
    folder" entry is omitted when at $HOME, so escape is structurally
    impossible. Lists folders first, then video files; remembers
    LAST_DIR; bounces back to $HOME on empty leaves and bails if even
    $HOME has no entries (instead of looping).

Centralized strings
  - All ~50 user-facing labels live in a single TXT_* block at the top
    of the script, grouped by purpose (header / menu / confirm /
    success / error / notification). Format strings use printf %s.

Robustness
  - Clamp PANEL_WIDTH ≤ TERM_COLS for narrow floating windows.
  - Empty-dir warning rendered with the same horizontal margin as
    everything else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:45:08 +01:00

898 lines
31 KiB
Bash

#!/usr/bin/env bash
# ==============================================================================
# Motion Wallpaper — gum-powered TUI for mpvpaper on Omarchy / Hyprland.
#
# Actions:
# toggle (default) Interactive TUI. If running, offers Stop / Change.
# start Non-interactive start from saved state (for systemd).
# stop Stop mpvpaper and restore the normal wallpaper.
# change Pick a new video while already running.
# status Print current state (TUI header via gum).
#
# Files:
# ~/.config/motion-wallpaper/state last video + target monitor
# ~/Videos/Wallpapers/ optional quick-pick library
# ~/.cache/motion-wallpaper.log runtime log
# ==============================================================================
set -euo pipefail
APP_NAME="Motion Wallpaper"
STATE_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/motion-wallpaper"
STATE_FILE="$STATE_DIR/state"
LOG_DIR="${XDG_CACHE_HOME:-$HOME/.cache}"
LOG_FILE="$LOG_DIR/motion-wallpaper.log"
LIBRARY_DIR="$HOME/Videos/Wallpapers"
# mpv options forwarded via mpvpaper -o. An input-ipc-server socket is opened
# so our companion watcher can send pause/resume commands when Hyprland
# reports a fullscreen window (mpvpaper's own -p is unreliable on 0.54.x).
RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
MPV_IPC_SOCK="$RUNTIME_DIR/motion-wallpaper-mpv.sock"
MPV_OPTS="--loop --no-audio --mute=yes --vo=gpu --profile=high-quality --input-ipc-server=$MPV_IPC_SOCK"
# ===== theme colors ===========================================================
# Pull TUI accents from the active Omarchy theme so the interface follows the
# system color scheme (the file is a symlink that swaps when the user runs
# omarchy-theme-set). Defaults below are Catppuccin Mocha and only kick in if
# colors.toml is missing or unreadable.
THEME_COLORS_FILE="$HOME/.config/omarchy/current/theme/colors.toml"
load_theme_colors() {
COLOR_ACCENT="#cba6f7"
COLOR_ERROR="#f38ba8"
COLOR_OK="#a6e3a1"
COLOR_MUTED="#6c7086"
[ -r "$THEME_COLORS_FILE" ] || return 0
local key val
while IFS='=' read -r key val; do
key="${key// /}"
val="${val// /}"; val="${val//\"/}"
case "$key" in
accent) COLOR_ACCENT="$val" ;; # primary accent
color1) COLOR_ERROR="$val" ;; # ANSI red → errors
color2) COLOR_OK="$val" ;; # ANSI green → success
color8) COLOR_MUTED="$val" ;; # ANSI bright black → muted lines
esac
done < "$THEME_COLORS_FILE"
}
load_theme_colors
# ===== layout =================================================================
# Center the status panel horizontally in the floating window. gum style's
# --margin shifts the whole box right; we compute the side margin from the
# real terminal width (tput cols) so the panel sits centered regardless of
# how the user has sized the floating window.
TERM_COLS="$(tput cols 2>/dev/null || echo "${COLUMNS:-80}")"
TERM_ROWS="$(tput lines 2>/dev/null || echo "${LINES:-24}")"
PANEL_WIDTH=52
# Clamp panel width on narrow terminals so the box can never exceed the
# window — gum style would otherwise wrap the border into the next column.
[ "$PANEL_WIDTH" -gt "$TERM_COLS" ] && PANEL_WIDTH="$TERM_COLS"
PANEL_MARGIN=$(( (TERM_COLS - PANEL_WIDTH) / 2 ))
[ "$PANEL_MARGIN" -lt 0 ] && PANEL_MARGIN=0
# Reusable indent string for rows gum doesn't know how to position itself
# (menu items, header labels, success/error lines).
PANEL_INDENT="$(printf '%*s' "$PANEL_MARGIN" '')"
# Vertical centering: estimate the total rows the centered block occupies
# (panel ≈ 9, gap 1, label 1, hint 1, ~5 menu rows = ~17). The top pad is
# half the slack between the terminal height and that estimate, clamped to
# ≥0 so tiny floating windows still show everything.
EST_CONTENT_ROWS=17
TOP_PAD_ROWS=$(( (TERM_ROWS - EST_CONTENT_ROWS) / 2 ))
[ "$TOP_PAD_ROWS" -lt 0 ] && TOP_PAD_ROWS=0
# Clear and push the cursor down before each interactive gum prompt so the
# rendered UI sits vertically centered. Without this, gum draws at the
# current cursor position (top of window on first call, then below previous
# output as the user navigates) and the panel looks lost in dead space.
center_screen() {
printf '\033[2J\033[H'
local i
for ((i = 0; i < TOP_PAD_ROWS; i++)); do printf '\n'; done
}
# Push the loaded theme into gum's own widget chrome (cursor, selected item,
# headers, prompts, confirm buttons). Without this, gum keeps its built-in
# pink/cyan defaults regardless of $COLOR_* values, so the menu cursor and
# highlighted row never follow the system theme.
export GUM_CHOOSE_CURSOR_FOREGROUND="$COLOR_ACCENT"
export GUM_CHOOSE_SELECTED_FOREGROUND="$COLOR_ACCENT"
export GUM_CHOOSE_HEADER_FOREGROUND="$COLOR_ACCENT"
export GUM_CONFIRM_PROMPT_FOREGROUND="$COLOR_ACCENT"
export GUM_CONFIRM_SELECTED_BACKGROUND="$COLOR_ACCENT"
export GUM_CONFIRM_SELECTED_FOREGROUND="$COLOR_MUTED"
export GUM_INPUT_PROMPT_FOREGROUND="$COLOR_ACCENT"
export GUM_INPUT_CURSOR_FOREGROUND="$COLOR_ACCENT"
export GUM_FILE_HEADER_FOREGROUND="$COLOR_ACCENT"
export GUM_FILTER_INDICATOR_FOREGROUND="$COLOR_ACCENT"
export GUM_FILTER_HEADER_FOREGROUND="$COLOR_ACCENT"
export GUM_SPIN_SPINNER_FOREGROUND="$COLOR_ACCENT"
# Pad gum's cursor with the panel's left margin so the menu items render at
# the same column as the centered panel above them. Inactive rows are padded
# automatically to match the cursor's printable width.
export GUM_CHOOSE_CURSOR="${PANEL_INDENT}> "
# Hide gum's built-in help footer — it renders flush-left and can't be
# indented. We draw our own centered hint as part of prompt_header instead.
export GUM_CHOOSE_SHOW_HELP=false
export GUM_FILTER_SHOW_HELP=false
export GUM_FILE_SHOW_HELP=false
# ===== user-facing strings ====================================================
# All copy lives here so the wording can be retuned without hunting through
# the action handlers. Format strings use printf-style %s; menu items must
# match verbatim because case branches compare against them.
# Header panel labels + values
TXT_TITLE="$APP_NAME"
TXT_LBL_STATUS="status: "
TXT_LBL_TARGET="target: "
TXT_LBL_VIDEO="video: "
TXT_LBL_AUTO="autostart:"
TXT_VAL_RUNNING="running"
TXT_VAL_STOPPED="stopped"
TXT_VAL_NONE="(none)"
TXT_VAL_AUTO_ON="enabled"
TXT_VAL_AUTO_OFF="disabled"
# Menu / picker headers
TXT_HDR_MAIN="What would you like to do?"
TXT_HDR_MONITOR="Select a monitor"
TXT_HDR_LIBRARY_FMT="Choose a video from %s"
TXT_HDR_BROWSE_FMT="Browse: %s"
# Menu items (used in case branches → keep verbatim)
TXT_BTN_STOP="Stop motion wallpaper"
TXT_BTN_CHANGE="Change video"
TXT_BTN_PICK="Pick a video and start"
TXT_BTN_AUTOSTART_ON="Turn autostart ON"
TXT_BTN_AUTOSTART_OFF="Turn autostart OFF"
TXT_BTN_CANCEL="Cancel"
TXT_BTN_START_FMT="Start with %s"
TXT_BTN_ALL_MONITORS="All monitors"
TXT_BTN_UP="── Up one folder ──"
TXT_BTN_BROWSE="── Browse filesystem… ──"
# Confirms
TXT_CONFIRM_DISABLE_AUTOSTART="Autostart is still enabled — also disable it so the wallpaper doesn't resume after reboot?"
TXT_CONFIRM_STOP_RUNNING="Motion wallpaper is still running — stop it now too?"
TXT_CONFIRM_OFFER_AUTOSTART="Start motion wallpaper automatically after login / reboot?"
# Success messages
TXT_OK_STOPPED="Stopped. Normal wallpaper restored."
TXT_OK_SWAPPED_FMT="Swapped to %s."
TXT_OK_STARTED_FMT="Started %s on %s."
TXT_OK_AUTOSTART_ON="Autostart enabled."
TXT_OK_AUTOSTART_ON_REBOOT="Autostart enabled — wallpaper will resume after reboot."
TXT_OK_AUTOSTART_ON_LATER="Autostart enabled — wallpaper will resume after reboot (once started)."
TXT_OK_AUTOSTART_OFF="Autostart disabled."
# Errors
TXT_ERR_NO_HYPRCTL="hyprctl not found. Are you in Hyprland?"
TXT_ERR_NO_JQ="jq is not installed."
TXT_ERR_NO_HIS="HYPRLAND_INSTANCE_SIGNATURE is not set — the launcher didn't inherit the Hyprland session. Try running motion-wallpaper-toggle from a regular terminal."
TXT_ERR_HYPRCTL_FMT="Could not read monitors from hyprctl. See %s."
TXT_ERR_NO_UNIT="motion-wallpaper.service is not installed. Re-run wallpaper.sh."
TXT_ERR_AUTOSTART_FAIL="Failed to enable autostart (systemctl error)."
TXT_ERR_FILE_FMT="File not found: %s"
TXT_ERR_NOT_RUNNING="Motion wallpaper is not running."
TXT_ERR_MPVP_FMT="mpvpaper failed to start. See %s for details."
TXT_ERR_EMPTY_DIR="No subfolders or videos in this folder."
# Notifications
TXT_NOTIFY_STARTED_FMT="Motion wallpaper started on %s."
TXT_NOTIFY_STOPPED="Motion wallpaper stopped."
TXT_NOTIFY_UPDATED="Motion wallpaper updated."
# Misc
TXT_PRESS_ENTER="Press enter to close…"
TXT_HINT_NAV="↑/↓ navigate · enter select · esc cancel"
# Backwards-compat alias used by pick_video's "browse" sentinel match.
BROWSE_SENTINEL="$TXT_BTN_BROWSE"
mkdir -p "$STATE_DIR" "$LOG_DIR"
# ===== helpers ================================================================
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE"
}
require_gum() {
if ! command -v gum >/dev/null 2>&1; then
echo "ERROR: gum is not installed. Run: sudo pacman -S gum" >&2
exit 1
fi
}
require_tty() {
if [ ! -t 0 ] || [ ! -t 1 ]; then
cat >&2 <<MSG
$APP_NAME: interactive mode requires a terminal.
Run from a terminal, or use a non-interactive action:
$0 start | stop | status
MSG
exit 1
fi
}
tui_err() {
gum style --margin="0 $PANEL_MARGIN" --foreground="$COLOR_ERROR" --bold "ERROR: $1"
log "ERROR: $1"
# Hold the terminal open so launcher-spawned windows don't flash away.
if [ -t 0 ]; then
gum input --placeholder="$TXT_PRESS_ENTER" >/dev/null 2>&1 || true
fi
}
tui_ok() {
gum style --margin="0 $PANEL_MARGIN" --foreground="$COLOR_OK" "$1"
log "$1"
}
notify() {
# Fire-and-forget system notification so the user sees results after the
# TUI terminal closes.
if command -v notify-send >/dev/null 2>&1; then
notify-send "$APP_NAME" "$1" || true
fi
log "$1"
}
load_state() {
LAST_VIDEO=""
LAST_TARGET=""
LAST_DIR=""
[ -f "$STATE_FILE" ] || return 0
# Parse KEY="VALUE" lines directly instead of `source`, so a maliciously
# crafted video path (e.g. containing `";rm -rf …`) can't execute.
local key val
while IFS='=' read -r key val; do
val="${val#\"}"
val="${val%\"}"
case "$key" in
LAST_VIDEO) LAST_VIDEO="$val" ;;
LAST_TARGET) LAST_TARGET="$val" ;;
LAST_DIR) LAST_DIR="$val" ;;
esac
done < "$STATE_FILE"
}
save_state() {
# $1 video, $2 target, $3 (optional) last dir — defaults to dirname of $1
# so the filesystem browser re-opens where the user last landed.
local dir="${3:-$(dirname "$1")}"
umask 077
# Atomic write: avoids leaving a truncated file if the process dies mid-cat.
local tmp="$STATE_FILE.tmp"
cat > "$tmp" <<STATE
LAST_VIDEO="$1"
LAST_TARGET="$2"
LAST_DIR="$dir"
STATE
mv -f "$tmp" "$STATE_FILE"
}
is_running() {
pgrep -x mpvpaper >/dev/null 2>&1
}
# ===== autostart (systemd user unit) ==========================================
autostart_installed() {
systemctl --user list-unit-files motion-wallpaper.service >/dev/null 2>&1
}
autostart_enabled() {
systemctl --user is-enabled motion-wallpaper.service >/dev/null 2>&1
}
autostart_enable() {
if ! autostart_installed; then
tui_err "$TXT_ERR_NO_UNIT"
return 1
fi
if ! systemctl --user enable motion-wallpaper.service >/dev/null 2>&1; then
tui_err "$TXT_ERR_AUTOSTART_FAIL"
return 1
fi
log "autostart enabled"
return 0
}
autostart_disable() {
systemctl --user disable motion-wallpaper.service >/dev/null 2>&1 || true
log "autostart disabled"
}
# Returns the status panel as a string. Used both as a stand-alone display
# (action_status / show_header) and as the --header of gum prompts so the
# status stays visible while the user interacts in a small floating window.
header_text() {
load_state
local status_line target_line video_line autostart_line
if is_running; then
status_line="$TXT_LBL_STATUS $(gum style --foreground="$COLOR_OK" "$TXT_VAL_RUNNING")"
else
status_line="$TXT_LBL_STATUS $(gum style --foreground="$COLOR_MUTED" "$TXT_VAL_STOPPED")"
fi
target_line="$TXT_LBL_TARGET ${LAST_TARGET:-$(gum style --foreground="$COLOR_MUTED" --italic "$TXT_VAL_NONE")}"
video_line="$TXT_LBL_VIDEO ${LAST_VIDEO:-$(gum style --foreground="$COLOR_MUTED" --italic "$TXT_VAL_NONE")}"
if autostart_enabled; then
autostart_line="$TXT_LBL_AUTO $(gum style --foreground="$COLOR_OK" "$TXT_VAL_AUTO_ON")"
else
autostart_line="$TXT_LBL_AUTO $(gum style --foreground="$COLOR_MUTED" "$TXT_VAL_AUTO_OFF")"
fi
# Title centered, data lines left-aligned with consistent label indent —
# the box itself is shifted right by PANEL_MARGIN so it sits centered in
# the floating window.
local title_block
title_block="$(gum style --align=center --width=$((PANEL_WIDTH - 4)) \
--bold --foreground="$COLOR_ACCENT" "$TXT_TITLE")"
gum style --border=rounded --border-foreground="$COLOR_ACCENT" \
--padding="0 2" --width="$PANEL_WIDTH" \
--margin="0 $PANEL_MARGIN" \
"$title_block" \
"" \
"$status_line" \
"$target_line" \
"$video_line" \
"$autostart_line"
}
show_header() {
header_text
}
# Build a multi-line --header value combining the persistent status panel
# (TUI_HEADER, set once per interactive session) with a per-prompt label
# and a centered, muted navigation hint that replaces gum's flush-left
# default footer (which can't be indented). All three lines line up with
# the panel's left edge via PANEL_INDENT.
prompt_header() {
local label="$1"
local hint
hint="$(gum style --foreground="$COLOR_MUTED" --italic "$TXT_HINT_NAV")"
if [ -n "${TUI_HEADER:-}" ]; then
printf '%s\n\n%s%s\n%s%s' \
"$TUI_HEADER" \
"$PANEL_INDENT" "$label" \
"$PANEL_INDENT" "$hint"
else
printf '%s%s\n%s%s' \
"$PANEL_INDENT" "$label" \
"$PANEL_INDENT" "$hint"
fi
}
# ===== selection ==============================================================
ensure_hyprland_env() {
# The TUI can be launched from contexts whose HYPRLAND_INSTANCE_SIGNATURE
# isn't usable. Three failure modes, all silent:
# 1) HIS unset (fresh login shell, cron, ssh).
# 2) HIS set but points to a dead session — the `hypr/<sig>/` directory
# and its socket files can linger on disk after the Hyprland process
# exits, so a dir-exists check is not enough.
# 3) Walker's env retained an old HIS across a Hyprland restart.
# `hyprctl instances` is the source of truth: it only reports live
# instances. Always cross-check against it and rewrite HIS if ours isn't
# in the list.
local his="${HYPRLAND_INSTANCE_SIGNATURE:-}"
local live
live="$(hyprctl instances 2>/dev/null | awk '/^instance /{sub(/:$/,"",$2); print $2}')"
if [ -z "$live" ]; then
# No live Hyprland — nothing we can do, let the caller surface an error.
return 0
fi
if [ -n "$his" ] && printf '%s\n' "$live" | grep -qxF "$his"; then
return 0
fi
local sig
sig="$(printf '%s\n' "$live" | head -n 1)"
export HYPRLAND_INSTANCE_SIGNATURE="$sig"
if [ -n "$his" ]; then
log "replaced stale HYPRLAND_INSTANCE_SIGNATURE ($his$sig)"
else
log "recovered HYPRLAND_INSTANCE_SIGNATURE=$sig from hyprctl instances"
fi
}
get_monitors() {
# hyprctl's failure modes are noisy: when HYPRLAND_INSTANCE_SIGNATURE isn't
# set (can happen when the TUI is spawned from a launcher whose env doesn't
# inherit the Hyprland session), hyprctl prints a plain-text error to stdout
# and exits 0 — which then makes jq complain loudly. Validate the JSON and
# fall back to parsing the human-readable form.
ensure_hyprland_env
local mon_json mon_text
mon_json="$(hyprctl monitors -j 2>/dev/null || true)"
if [ -n "$mon_json" ] && printf '%s' "$mon_json" | jq -e . >/dev/null 2>&1; then
printf '%s' "$mon_json" | jq -r '.[].name'
return 0
fi
mon_text="$(hyprctl monitors 2>/dev/null || true)"
if [ -n "$mon_text" ]; then
printf '%s\n' "$mon_text" \
| awk '/^Monitor /{print $2; ok=1} END{exit !ok}' \
&& return 0
fi
log "get_monitors: hyprctl produced no usable output (HIS=${HYPRLAND_INSTANCE_SIGNATURE:-unset})"
return 1
}
pick_target() {
command -v hyprctl >/dev/null || { tui_err "$TXT_ERR_NO_HYPRCTL"; return 1; }
command -v jq >/dev/null || { tui_err "$TXT_ERR_NO_JQ"; return 1; }
local monitors
if ! monitors="$(get_monitors)" || [ -z "$monitors" ]; then
if [ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]; then
tui_err "$TXT_ERR_NO_HIS"
else
tui_err "$(printf "$TXT_ERR_HYPRCTL_FMT" "$LOG_FILE")"
fi
return 1
fi
local count
count="$(printf '%s\n' "$monitors" | wc -l)"
if [ "$count" -eq 1 ]; then
printf '%s' "$monitors"
return 0
fi
local selected
center_screen
selected=$( { echo "$TXT_BTN_ALL_MONITORS"; printf '%s\n' "$monitors"; } \
| gum choose --header="$(prompt_header "$TXT_HDR_MONITOR")") || return 1
if [ "$selected" = "$TXT_BTN_ALL_MONITORS" ]; then
printf '%s' '*'
else
printf '%s' "$selected"
fi
}
browse_filesystem() {
# Custom browser built on gum choose so we can hard-confine navigation to
# $HOME — gum file exposes a built-in "up" key we can't intercept, which
# let users wander above $HOME by mistake. By owning the entry list, the
# "Up one folder" item simply isn't offered at $HOME, so escape is impossible.
# Side benefit: gum choose stays in the main screen (no alt-screen takeover),
# so the status panel remains visible the whole way through.
load_state
local cur="${LAST_DIR:-}"
if [ -z "$cur" ] && [ -n "${LAST_VIDEO:-}" ]; then
cur="$(dirname "$LAST_VIDEO")"
fi
case "$cur" in
"$HOME"|"$HOME"/*) : ;;
*) cur="$HOME" ;;
esac
[ -d "$cur" ] || cur="$HOME"
local entries name rel label choice
while true; do
entries=()
[ "$cur" != "$HOME" ] && entries+=("$TXT_BTN_UP")
# Directories first (skip hidden), then video files (skip hidden).
while IFS= read -r -d '' name; do
entries+=("$(basename "$name")/")
done < <(find "$cur" -mindepth 1 -maxdepth 1 -type d ! -name '.*' \
-print0 2>/dev/null | sort -z)
while IFS= read -r -d '' name; do
entries+=("$(basename "$name")")
done < <(find "$cur" -mindepth 1 -maxdepth 1 -type f ! -name '.*' \
\( -iname '*.mp4' -o -iname '*.mkv' -o -iname '*.webm' \
-o -iname '*.mov' -o -iname '*.avi' \) \
-print0 2>/dev/null | sort -z)
if [ ${#entries[@]} -eq 0 ]; then
gum style --margin="0 $PANEL_MARGIN" --foreground="$COLOR_ERROR" "$TXT_ERR_EMPTY_DIR"
# Bounce back to $HOME instead of getting stuck on an empty leaf.
# If $HOME itself is empty we'd loop forever, so bail with an error.
if [ "$cur" = "$HOME" ]; then
return 1
fi
cur="$HOME"
continue
fi
rel="${cur#"$HOME"}"; [ -z "$rel" ] && rel="/"
label="$(printf "$TXT_HDR_BROWSE_FMT" "~$rel")"
center_screen
choice=$(printf '%s\n' "${entries[@]}" \
| gum choose --height=20 --header="$(prompt_header "$label")") || return 1
case "$choice" in
"$TXT_BTN_UP") cur="$(dirname "$cur")" ;;
*/) cur="$cur/${choice%/}" ;;
*) printf '%s' "$cur/$choice"; return 0 ;;
esac
done
}
pick_video() {
local library=() basenames=() v
if [ -d "$LIBRARY_DIR" ]; then
while IFS= read -r -d '' f; do
library+=("$f")
done < <(
find "$LIBRARY_DIR" -maxdepth 1 -type f \
\( -iname '*.mp4' -o -iname '*.mkv' -o -iname '*.webm' -o -iname '*.mov' -o -iname '*.avi' \) \
-print0 2>/dev/null | sort -z
)
fi
if [ ${#library[@]} -eq 0 ]; then
browse_filesystem || return 1
return 0
fi
for v in "${library[@]}"; do
basenames+=("$(basename "$v")")
done
local choice
center_screen
choice=$( { printf '%s\n' "${basenames[@]}"; echo "$TXT_BTN_BROWSE"; } \
| gum choose --header="$(prompt_header "$(printf "$TXT_HDR_LIBRARY_FMT" "$LIBRARY_DIR")")") || return 1
if [ "$choice" = "$TXT_BTN_BROWSE" ]; then
browse_filesystem
else
printf '%s' "$LIBRARY_DIR/$choice"
fi
}
# ===== mpvpaper control =======================================================
kill_static_wallpapers() {
pkill -x hyprpaper 2>/dev/null || true
pkill -x swaybg 2>/dev/null || true
}
kill_watcher() {
pkill -f motion-wallpaper-watcher 2>/dev/null || true
pkill -f motion-wallpaper-theme-watcher 2>/dev/null || true
}
# Locate a sibling script — first in PATH, then in this script's dir. Used
# for both watchers because launcher-spawned PATH is minimal.
locate_sibling() {
local name="$1" path
path="$(command -v "$name" 2>/dev/null || true)"
if [ -z "$path" ]; then
local self_dir
self_dir="$(dirname "$(readlink -f "$0")")"
if [ -x "$self_dir/$name" ]; then
path="$self_dir/$name"
fi
fi
printf '%s' "$path"
}
start_watcher() {
local watcher theme_watcher
watcher="$(locate_sibling motion-wallpaper-watcher)"
theme_watcher="$(locate_sibling motion-wallpaper-theme-watcher)"
kill_watcher
if [ -n "$watcher" ]; then
setsid "$watcher" < /dev/null >> "$LOG_FILE" 2>&1 &
disown 2>/dev/null || true
log "auto-pause watcher spawned via $watcher"
else
log "auto-pause watcher binary not found — auto-pause disabled"
fi
if [ -n "$theme_watcher" ]; then
setsid "$theme_watcher" < /dev/null >> "$LOG_FILE" 2>&1 &
disown 2>/dev/null || true
log "theme watcher spawned via $theme_watcher"
else
log "theme watcher binary not found — theme-change auto-stop disabled"
fi
}
restore_static_wallpaper() {
local omarchy_bg="$HOME/.config/omarchy/current/background"
if [ -e "$omarchy_bg" ]; then
# Stop may be called twice in quick succession when the TUI stops the
# wallpaper and systemd's ExecStop fires right after. Skip if someone
# else already put swaybg back so we don't kill-and-respawn (causes a
# visible flicker).
if pgrep -x swaybg >/dev/null 2>&1 && ! pgrep -x mpvpaper >/dev/null 2>&1; then
log "swaybg already running, skipping restore"
return 0
fi
pkill -x hyprpaper 2>/dev/null || true
pkill -x swaybg 2>/dev/null || true
if command -v uwsm-app >/dev/null 2>&1; then
setsid uwsm-app -- swaybg -i "$omarchy_bg" -m fill >/dev/null 2>&1 &
else
setsid swaybg -i "$omarchy_bg" -m fill >/dev/null 2>&1 &
fi
disown 2>/dev/null || true
log "restored swaybg -> $omarchy_bg"
elif command -v hyprpaper >/dev/null 2>&1; then
hyprctl dispatch exec hyprpaper >/dev/null 2>&1 || true
log "restored hyprpaper"
else
log "no known static wallpaper daemon to restore"
fi
}
start_mpvpaper_fg() {
local target="$1" video="$2"
kill_static_wallpapers
pkill -x mpvpaper 2>/dev/null || true
sleep 0.3
log "systemd start target=$target video=$video"
start_watcher
# shellcheck disable=SC2086 # intentional word-splitting on MPV_OPTS
exec mpvpaper -o "$MPV_OPTS" "$target" "$video"
}
start_mpvpaper_bg() {
local target="$1" video="$2"
kill_static_wallpapers
pkill -x mpvpaper 2>/dev/null || true
sleep 0.3
log "start target=$target video=$video"
# setsid detaches from the controlling terminal so mpvpaper survives the
# TUI terminal closing; uwsm-app parents it to the user systemd scope
# (matches how Omarchy autostarts swaybg). Without both, mpvpaper was
# getting SIGHUP'd when the Walker-spawned terminal exited.
# shellcheck disable=SC2086 # intentional word-splitting on MPV_OPTS
if command -v uwsm-app >/dev/null 2>&1; then
setsid uwsm-app -- mpvpaper -o "$MPV_OPTS" "$target" "$video" \
< /dev/null >> "$LOG_FILE" 2>&1 &
else
# shellcheck disable=SC2086
setsid mpvpaper -o "$MPV_OPTS" "$target" "$video" \
< /dev/null >> "$LOG_FILE" 2>&1 &
fi
disown 2>/dev/null || true
start_watcher
sleep 0.8
if ! is_running; then
tui_err "$(printf "$TXT_ERR_MPVP_FMT" "$LOG_FILE")"
return 1
fi
}
stop_mpvpaper() {
# Serialize stops so the TUI-initiated path and systemd's ExecStop (which
# fires when the TUI kills mpvpaper) can't both be mid-restore at once.
# `flock -n` → second caller skips cleanly if the first still holds it.
(
flock -n 9 || { log "stop already in progress, skipping"; exit 0; }
kill_watcher
pkill -x mpvpaper 2>/dev/null || true
# mpv cleans up its IPC socket on exit; a hard kill can leave it dangling.
rm -f "$MPV_IPC_SOCK" 2>/dev/null || true
restore_static_wallpaper
log "stopped"
) 9>"$STATE_DIR/.stop.lock"
}
# ===== actions ================================================================
action_toggle() {
require_gum
require_tty
# Render the status panel once and reuse it as the --header of every gum
# prompt below, so it stays visible while the user makes choices in a
# small floating window (instead of flashing past on first paint).
TUI_HEADER="$(header_text)"
if is_running; then
local autostart_label
if autostart_enabled; then
autostart_label="$TXT_BTN_AUTOSTART_OFF"
else
autostart_label="$TXT_BTN_AUTOSTART_ON"
fi
local choice
center_screen
choice=$(gum choose --header="$(prompt_header "$TXT_HDR_MAIN")" \
"$TXT_BTN_STOP" "$TXT_BTN_CHANGE" "$autostart_label" "$TXT_BTN_CANCEL") || exit 0
case "$choice" in
"$TXT_BTN_STOP")
stop_mpvpaper
tui_ok "$TXT_OK_STOPPED"
notify "$TXT_NOTIFY_STOPPED"
# If autostart is on, a plain stop will let the wallpaper return on
# next reboot. Offer to turn autostart off so "stop" means "stop".
if autostart_enabled; then
if gum confirm "$TXT_CONFIRM_DISABLE_AUTOSTART"; then
autostart_disable
tui_ok "$TXT_OK_AUTOSTART_OFF"
fi
fi
;;
"$TXT_BTN_CHANGE")
load_state
local video
video=$(pick_video) || exit 0
[ -z "$video" ] && exit 0
[ -f "$video" ] || { tui_err "$(printf "$TXT_ERR_FILE_FMT" "$video")"; exit 1; }
save_state "$video" "$LAST_TARGET"
start_mpvpaper_bg "$LAST_TARGET" "$video" || exit 1
tui_ok "$(printf "$TXT_OK_SWAPPED_FMT" "$(basename "$video")")"
notify "$TXT_NOTIFY_UPDATED"
;;
"$TXT_BTN_AUTOSTART_ON")
autostart_enable && tui_ok "$TXT_OK_AUTOSTART_ON_REBOOT"
;;
"$TXT_BTN_AUTOSTART_OFF")
autostart_disable
tui_ok "$TXT_OK_AUTOSTART_OFF"
# Motion wallpaper is still running at this point. Offer to also stop
# it now so the label "turn off" matches visible behaviour.
if is_running; then
if gum confirm "$TXT_CONFIRM_STOP_RUNNING"; then
stop_mpvpaper
tui_ok "$TXT_OK_STOPPED"
notify "$TXT_NOTIFY_STOPPED"
fi
fi
;;
"$TXT_BTN_CANCEL") exit 0 ;;
esac
return 0
fi
# Stopped state: show a gum-choose menu first instead of dumping the user
# straight into the (alt-screen) file picker. Keeps the status panel
# visible and lets them reuse a saved video, toggle autostart, or back out
# without committing to a file pick.
load_state
local options=()
local last_label=""
if [ -n "${LAST_VIDEO:-}" ] && [ -f "${LAST_VIDEO:-}" ]; then
last_label="$(printf "$TXT_BTN_START_FMT" "$(basename "$LAST_VIDEO")")"
options+=("$last_label")
fi
options+=("$TXT_BTN_PICK")
if autostart_installed; then
if autostart_enabled; then
options+=("$TXT_BTN_AUTOSTART_OFF")
else
options+=("$TXT_BTN_AUTOSTART_ON")
fi
fi
options+=("$TXT_BTN_CANCEL")
local choice
center_screen
choice=$(gum choose --header="$(prompt_header "$TXT_HDR_MAIN")" "${options[@]}") || exit 0
local target video
case "$choice" in
"$last_label")
[ -n "$last_label" ] || exit 0
target=$(pick_target) || exit 0
[ -z "$target" ] && exit 0
save_state "$LAST_VIDEO" "$target"
start_mpvpaper_bg "$target" "$LAST_VIDEO" || exit 1
tui_ok "$(printf "$TXT_OK_STARTED_FMT" "$(basename "$LAST_VIDEO")" "$target")"
notify "$(printf "$TXT_NOTIFY_STARTED_FMT" "$target")"
;;
"$TXT_BTN_PICK")
target=$(pick_target) || exit 0
[ -z "$target" ] && exit 0
video=$(pick_video) || exit 0
[ -z "$video" ] && exit 0
[ -f "$video" ] || { tui_err "$(printf "$TXT_ERR_FILE_FMT" "$video")"; exit 1; }
save_state "$video" "$target"
start_mpvpaper_bg "$target" "$video" || exit 1
tui_ok "$(printf "$TXT_OK_STARTED_FMT" "$(basename "$video")" "$target")"
notify "$(printf "$TXT_NOTIFY_STARTED_FMT" "$target")"
;;
"$TXT_BTN_AUTOSTART_ON")
autostart_enable && tui_ok "$TXT_OK_AUTOSTART_ON_LATER"
return 0
;;
"$TXT_BTN_AUTOSTART_OFF")
autostart_disable
tui_ok "$TXT_OK_AUTOSTART_OFF"
return 0
;;
"$TXT_BTN_CANCEL"|*) exit 0 ;;
esac
# Offer autostart on first fresh start (skipped silently if already on or
# if the systemd unit isn't installed).
if autostart_installed && ! autostart_enabled; then
if gum confirm "$TXT_CONFIRM_OFFER_AUTOSTART"; then
autostart_enable && tui_ok "$TXT_OK_AUTOSTART_ON"
fi
fi
}
action_change() {
require_gum
require_tty
if ! is_running; then
tui_err "$TXT_ERR_NOT_RUNNING"
exit 1
fi
load_state
TUI_HEADER="$(header_text)"
local video
video=$(pick_video) || exit 0
[ -z "$video" ] && exit 0
[ -f "$video" ] || { tui_err "$(printf "$TXT_ERR_FILE_FMT" "$video")"; exit 1; }
save_state "$video" "$LAST_TARGET"
start_mpvpaper_bg "$LAST_TARGET" "$video" || exit 1
tui_ok "$(printf "$TXT_OK_SWAPPED_FMT" "$(basename "$video")")"
notify "$TXT_NOTIFY_UPDATED"
}
action_start() {
load_state
if [ -z "$LAST_VIDEO" ] || [ -z "$LAST_TARGET" ]; then
log "autostart: no saved state, exiting cleanly"
exit 0
fi
if [ ! -f "$LAST_VIDEO" ]; then
log "autostart: saved video missing ($LAST_VIDEO)"
exit 1
fi
start_mpvpaper_fg "$LAST_TARGET" "$LAST_VIDEO"
}
action_stop() {
stop_mpvpaper
}
action_status() {
if command -v gum >/dev/null 2>&1 && [ -t 1 ]; then
show_header
else
load_state
if is_running; then echo "status: running"; else echo "status: stopped"; fi
if [ -n "${LAST_TARGET:-}" ]; then echo "target: $LAST_TARGET"; fi
if [ -n "${LAST_VIDEO:-}" ]; then echo "video: $LAST_VIDEO"; fi
fi
}
# ===== main ===================================================================
case "${1:-toggle}" in
toggle) action_toggle ;;
start) action_start ;;
stop) action_stop ;;
change) action_change ;;
status) action_status ;;
-h|--help)
cat <<USAGE
$APP_NAME
Usage: ${0##*/} [toggle|start|stop|change|status]
toggle Interactive gum TUI (default). If running, offers Stop / Change.
start Start from saved state without prompting (for systemd).
stop Stop mpvpaper and restore the normal wallpaper.
change Pick a new video while already running.
status Print current state.
USAGE
;;
*)
echo "Unknown action: $1" >&2
echo "Try: ${0##*/} --help" >&2
exit 1
;;
esac