Desktop & Dock becomes Shell — Bar, Dock, Control Center, Tiling, Workspaces — the home for everything Quickshell draws. The settings- management cluster moves to System as Sync & Backup, Appearance's Shell tab dissolves, and 24-hour time finally lives on Date & Time, which always owned it. The bar gets what it never had: a way to survive the wallpaper. A second neutral text family (follow theme, or forced light or dark), a one-layer shadow under every glyph, and a gradient scrim for wallpapers nothing else survives — all off by default, pixel-identical until asked. Widgets earn toggles (weather, media, clipboard, calendar countdown), the vitals cluster stops leaving a dead pill behind, and Control Center's sections learn to step aside. The dock graduates from MVP: a context menu with window rows, pin, unpin, quit and new-window; scroll an icon to cycle its windows; drag to reorder on the dock itself; hover previews with one-shot captures; and "Add App to Dock" in the launcher. Three real bugs died en route — menus that slid away with the autohide, a readonly-property crash on every menu open, and a drag that drifted half a slot per icon on side docks. The pinned-apps editor in Settings becomes a drag strip. 166 contracts; the full suite is green except two live display and switcher tests that cannot run behind a locked session — re-verified on unlock. Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
230 lines
12 KiB
Bash
Executable File
230 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# Video wallpapers.
|
|
#
|
|
# A looping video behind every window is the single most expensive thing a
|
|
# desktop can draw, and none of the cost is visible: Hyprland rebuilds the
|
|
# full-screen blur chain on every wallpaper frame while any blur-enabled layer
|
|
# exists, and an occluded wallpaper keeps compositing unless solitary mode
|
|
# holds -- which one toast breaks. So Panama owns the pause policy rather than
|
|
# trusting the compositor with it, and this pins that policy in place.
|
|
#
|
|
# What must hold:
|
|
#
|
|
# 1. The video pauses for a game, on battery when asked to, and on demand
|
|
# from the bar. Each reason is independent; the video plays only when
|
|
# none of them holds.
|
|
# 2. mpvpaper is told to decode on the GPU, stay muted, loop, and open an
|
|
# IPC socket. A silent software-decode fallback would burn a core
|
|
# forever with nothing on screen to explain it.
|
|
# 3. hyprpaper's service is stopped while a video plays and started again
|
|
# afterwards. Both claim the background layer and stacking within a layer
|
|
# is creation order -- a race with no winner worth having.
|
|
# 4. The picker cannot be flooded, and cannot be handed a file mpvpaper will
|
|
# not play.
|
|
# 5. The lock screen gets a still frame, because hyprlock cannot play motion.
|
|
#
|
|
# Static only: starting a real mpvpaper would take over the live desktop's
|
|
# background.
|
|
|
|
set -euo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
shell_dir="$repo_dir/config/dot/quickshell"
|
|
service="$shell_dir/services/VideoWallpaper.qml"
|
|
wallpaper="$shell_dir/services/Wallpaper.qml"
|
|
helper="$shell_dir/scripts/panama-video-wallpaper"
|
|
indicator="$shell_dir/modules/bar/WallpaperIndicator.qml"
|
|
bar="$shell_dir/modules/bar/Bar.qml"
|
|
picker="$shell_dir/modules/settings/WallpaperPicker.qml"
|
|
appearance="$shell_dir/modules/settings/AppearancePage.qml"
|
|
schema="$shell_dir/config/PreferenceSchema.qml"
|
|
lock="$shell_dir/scripts/panama-lock"
|
|
packages="$repo_dir/setup/packages/hyprland-packages"
|
|
|
|
fail() {
|
|
printf 'video wallpaper contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
for file in "$service" "$wallpaper" "$helper" "$indicator" "$bar" "$picker" \
|
|
"$appearance" "$schema" "$lock" "$packages"; do
|
|
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
|
|
done
|
|
[[ -x "$helper" ]] || fail 'panama-video-wallpaper is not executable'
|
|
bash -n "$helper" || fail 'panama-video-wallpaper does not parse'
|
|
|
|
# ── 1. Three independent pause reasons ──────────────────────────────────────
|
|
python3 - "$service" <<'PY' || fail 'the pause policy drifted'
|
|
import re
|
|
import sys
|
|
|
|
text = open(sys.argv[1], encoding="utf-8").read()
|
|
stripped = re.sub(r"//.*", "", text)
|
|
|
|
reasons = {
|
|
"gamePaused": "FocusModes.gameRunning",
|
|
"batteryPaused": 'DesktopPreferences.get("videoWallpaperPauseOnBattery")',
|
|
}
|
|
for prop, source in reasons.items():
|
|
block = re.search(r'property bool ' + prop + r':(.*?)\n\s*(readonly )?property', stripped, re.S)
|
|
if not block or source not in block.group(1):
|
|
raise SystemExit(f"{prop} no longer follows {source}")
|
|
|
|
battery = re.search(r'property bool batteryPaused:(.*?)\n\s*(readonly )?property', stripped, re.S)
|
|
for needle in ("Battery.available", "!Battery.acOnline"):
|
|
if needle not in battery.group(1):
|
|
raise SystemExit(f"the battery pause reason no longer checks {needle}")
|
|
|
|
if not re.search(r'property bool manuallyPaused: false', stripped):
|
|
raise SystemExit("the bar pill's manual pause is gone")
|
|
|
|
combined = re.search(r'property bool paused:(.*?)\n', stripped)
|
|
if not combined:
|
|
raise SystemExit("there is no combined paused state")
|
|
for reason in ("root.manuallyPaused", "root.gamePaused", "root.batteryPaused"):
|
|
if reason not in combined.group(1):
|
|
raise SystemExit(f"the combined paused state ignores {reason}")
|
|
|
|
# The pause reaches mpv over its JSON IPC socket rather than by killing and
|
|
# respawning the player, which would restart the video from the first frame.
|
|
if 'JSON.stringify({ "command": ["set_property", "pause", root.paused] })' not in stripped:
|
|
raise SystemExit("pausing no longer goes over mpv's JSON IPC")
|
|
if 'onPausedChanged: pauseSync.restart()' not in stripped:
|
|
raise SystemExit("a change of pause state does not push to the player")
|
|
PY
|
|
|
|
# ── 2. What mpvpaper is actually told ───────────────────────────────────────
|
|
for option in 'hwdec=vaapi' 'no-audio' 'loop-file=inf' 'input-ipc-server='; do
|
|
rg -Fq "$option" "$service" || fail "the mpvpaper invocation is missing $option"
|
|
done
|
|
rg -Fq '"mpvpaper", "-o",' "$service" \
|
|
|| fail 'the mpvpaper invocation is no longer a fixed argv'
|
|
# -f is banned outright: it forks mpvpaper into the background, which reads to
|
|
# the supervisor as an instant death — the respawn loop it caused flashed the
|
|
# desktop black once a second until the flag was removed.
|
|
if rg -Fq '"mpvpaper", "-f"' "$service"; then
|
|
fail 'mpvpaper is launched with -f again; the supervisor needs it foreground'
|
|
fi
|
|
# Restore is the service's own, reactive and once per session — a one-shot
|
|
# timer raced the async preference load and the availability probe at cold
|
|
# start and silently lost.
|
|
for needle in 'function tryRestore' 'restoreConsumed' 'videoWallpaperPath'; do
|
|
rg -Fq "$needle" "$service" || fail "startup restore lost its $needle"
|
|
done
|
|
rg -Fq 'command -v mpvpaper' "$service" \
|
|
|| fail 'the shell never checks whether mpvpaper is installed'
|
|
rg -Fq 'mpvpaper' "$packages" \
|
|
|| fail 'mpvpaper is not in the package list, so a fresh machine has no player'
|
|
|
|
# A dead player while a video is meant to be active is a crash -- mpvpaper has
|
|
# a known hotplug segfault -- so the whole set respawns rather than being
|
|
# reasoned about per output.
|
|
rg -Fq 'playerRespawn.restart();' "$service" \
|
|
|| fail 'a crashed player is not respawned'
|
|
rg -Fq 'onOutputSignatureChanged: if (root.active) playerRespawn.restart()' "$service" \
|
|
|| fail 'a display hotplug does not respawn the players'
|
|
|
|
# ── 3. The hyprpaper handover ───────────────────────────────────────────────
|
|
rg -Fq '["systemctl", "--user", "stop", "hyprpaper.service"]' "$service" \
|
|
|| fail 'starting a video does not stop hyprpaper, so the two race for the layer'
|
|
rg -Fq '["systemctl", "--user", "start", "hyprpaper.service"]' "$service" \
|
|
|| fail 'stopping a video does not bring hyprpaper back'
|
|
rg -Fq 'Wallpaper.refreshActive()' "$service" \
|
|
|| fail 'the still wallpaper is not reapplied once hyprpaper returns'
|
|
|
|
# Wallpaper.qml routes a video away from the hyprpaper transaction, which would
|
|
# only fail validation, and gives hyprpaper a beat to come back on the way out.
|
|
for needle in 'VideoWallpaper.isVideo(path)' 'VideoWallpaper.isVideo(root.configured)' \
|
|
'pendingStillPolicy' 'VideoWallpaper.start(' 'VideoWallpaper.stop()'; do
|
|
rg -Fq "$needle" "$wallpaper" || fail "Wallpaper.qml lost its video routing: $needle"
|
|
done
|
|
|
|
# ── 4. Discovery is bounded and typed ───────────────────────────────────────
|
|
rg -Fq 'NR <= 60' "$helper" \
|
|
|| fail 'the video scan is unbounded, so a dumping-ground folder floods the picker'
|
|
rg -Fq -- '-maxdepth 2' "$helper" \
|
|
|| fail 'the video scan is no longer bounded in depth'
|
|
rg -Fq "\\( -iname '*.mp4' -o -iname '*.mkv' -o -iname '*.webm' \\)" "$helper" \
|
|
|| fail 'the video scan accepts extensions mpvpaper may not play'
|
|
rg -Fq '/\.(mp4|mkv|webm)$/i' "$service" \
|
|
|| fail 'the shell and the helper disagree about what counts as a video'
|
|
rg -Fq 'VideoWallpaper.isVideo(' "$picker" \
|
|
|| fail 'the picker does not distinguish video tiles from image tiles'
|
|
|
|
# ── 5. The lock screen gets a still ─────────────────────────────────────────
|
|
rg -Fq 'video-wallpaper-frame.png' "$service" \
|
|
|| fail 'no still frame is cached for the lock screen'
|
|
rg -Fq 'video-wallpaper-frame.png' "$lock" \
|
|
|| fail 'the lock screen does not use the cached still frame'
|
|
rg -Fq 'ffmpeg -hide_banner' "$helper" \
|
|
|| fail 'the frame grab no longer runs ffmpeg quietly'
|
|
rg -Fq '\.(mp4|mkv|webm)$' "$lock" \
|
|
|| fail 'panama-lock does not recognise a video wallpaper'
|
|
|
|
# ── The bar pill ────────────────────────────────────────────────────────────
|
|
rg -Fq 'visible: VideoWallpaper.active' "$indicator" \
|
|
|| fail 'the bar pill is not conditional on a video actually playing'
|
|
rg -Fq 'onActivated: VideoWallpaper.togglePause()' "$indicator" \
|
|
|| fail 'clicking the bar pill does not pause the video'
|
|
rg -Fq 'Accessible.name: VideoWallpaper.paused' "$indicator" \
|
|
|| fail 'the bar pill has no spoken name, so it is a glyph and nothing else'
|
|
rg -Fq 'Paused for game' "$indicator" \
|
|
|| fail 'the pill does not say why the video paused itself'
|
|
python3 - "$bar" <<'PY' || fail 'the wallpaper pill is not in the bar'
|
|
import re
|
|
import sys
|
|
|
|
text = open(sys.argv[1], encoding="utf-8").read()
|
|
if 'WallpaperIndicator {' not in text:
|
|
raise SystemExit("Bar.qml never instantiates WallpaperIndicator")
|
|
# It belongs in the right-hand status row, beside the other conditional pills.
|
|
row = re.search(r'// ── Right ─+\n\s*Row \{(.*?)\n \}', text, re.S)
|
|
if not row or 'WallpaperIndicator {' not in row.group(1):
|
|
raise SystemExit("WallpaperIndicator is not in the bar's right-hand row")
|
|
PY
|
|
if rg -n 'NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|loops:[[:space:]]*Animation\.Infinite' \
|
|
"$indicator"; then
|
|
fail 'the wallpaper pill animates continuously beside a video that already costs frames'
|
|
fi
|
|
|
|
# ── Schema and settings surface ─────────────────────────────────────────────
|
|
python3 - "$schema" <<'PY' || fail 'the video wallpaper schema entries are missing or malformed'
|
|
import re
|
|
import sys
|
|
|
|
text = open(sys.argv[1], encoding="utf-8").read()
|
|
expected = {
|
|
"videoWallpaperDir": ("string", '"Videos/Wallpapers"', "wallpaper"),
|
|
"videoWallpaperPauseOnBattery": ("bool", "true", "wallpaper"),
|
|
}
|
|
for key, (kind, default, group) in expected.items():
|
|
block = re.search(r"\{\s*\n\s*key:\s*\"" + key + r"\".*?\n\s{8}\}", text, re.S)
|
|
if not block:
|
|
raise SystemExit(f"missing {key}")
|
|
body = block.group(0)
|
|
if not re.search(rf'type:\s*"{kind}"', body):
|
|
raise SystemExit(f"{key} has wrong type")
|
|
if not re.search(rf'def:\s*{re.escape(default)}', body):
|
|
raise SystemExit(f"{key} has wrong default")
|
|
if not re.search(rf'group:\s*"{group}"', body):
|
|
raise SystemExit(f"{key} has wrong group")
|
|
if 'internal: true' in body:
|
|
raise SystemExit(f"{key} is marked internal, so nobody can find it")
|
|
PY
|
|
|
|
for needle in 'title: "Video playback"' 'setting: "videoWallpaperDir"' \
|
|
'setting: "videoWallpaperPauseOnBattery"' 'VideoWallpaper.rescan()'; do
|
|
rg -Fq "$needle" "$appearance" || fail "Appearance is missing $needle"
|
|
done
|
|
# Honest about what it cannot do, rather than silently showing a black lock
|
|
# screen when the wallpaper is a video.
|
|
rg -Fq 'Still frame' "$appearance" \
|
|
|| fail 'the settings page does not say the lock screen uses a still frame'
|
|
|
|
# ── The doctor knows about it ───────────────────────────────────────────────
|
|
rg -Fq 'input.video-wallpaper' "$shell_dir/scripts/panama-doctor" \
|
|
|| fail 'panama-doctor has no video wallpaper check'
|
|
|
|
printf 'video wallpaper contract: PASS\n'
|