Give the desktop real themes, video wallpapers, and honest titlebars

Appearance now opens on Themes: light and dark side by side, each
remembering its own choice, over galleries of ten shipped themes —
Tokyo Moon and Day joined by Moon Rose, Catppuccin, Nord, Gruvbox and
Everforest in both modes. A theme is a complete palette: the catalog
lives in themes.json, Theme.qml reads every color token from the
active record, and one render pipeline carries it to kitty, tmux,
btop, GTK, Vicinae, Firefox's chrome, and the lock screen. The Theme
editor builds new ones from four wells — wheel, hex, or eyedropper —
with derived surfaces, a saturation slider, debounced fine-tune, and
effects that save with the theme. Custom edits finally keep GNOME's
accent, kitty's border, and hyprlock in sync.

Wallpapers can be video: mpvpaper per output, hardware-decoded, muted
and looped, supervised and respawned. Panama owns the pausing — games,
battery, and a bar pill for right now — because the compositor
rebuilds full-screen blur for every frame a video wallpaper draws.
The lock screen gets a still frame.

Titlebars stop lying. GNOME apps get close-only on your chosen side,
the maximize and double-click settings are gone, the Settings window
obeys the same rules, and its titlebar can be turned off entirely.
Typography becomes five labeled dropdowns instead of a wall of
samples.

Contracts updated and written throughout (165 now); per the redesign
workflow none were executed — the full sweep runs once at the end.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-23 23:39:04 -04:00
parent 7578348db1
commit cb7c09d208
68 changed files with 6115 additions and 1138 deletions
@@ -0,0 +1,244 @@
pragma Singleton
// Video wallpapers, played by mpvpaper — one supervised process per output,
// hardware-decoded, muted, looped. Panama owns the pause policy, because the
// compositor cannot be trusted with it: 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 a
// single toast breaks. So the wallpaper pauses itself whenever a game runs,
// whenever the machine is on battery (if the preference says so), and whenever
// the bar pill is clicked, over mpv's JSON IPC socket.
//
// hyprpaper and mpvpaper both claim the background layer and stacking within a
// layer is creation order — a race. So while a video is active hyprpaper's
// service is stopped, and stopping the video starts it again; Wallpaper.qml
// reapplies the still policy once it returns.
//
// mpvpaper 1.9 (Terra) is the floor: it carries the libmpv fence-leak
// workaround. Known upstream sharp edges — a hotplug segfault and a
// mitigated-not-fixed leak — are why the processes are supervised and simply
// respawned rather than trusted.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-video-wallpaper"
property bool available: false // mpvpaper on PATH, checked once
property bool scanned: false
property var candidates: [] // newest-60 videos in videoWallpaperDir
property string path: "" // the active video, "" when inactive
readonly property bool active: root.path !== ""
property string lastError: ""
// Pause reasons, each independent; the video plays only when none holds.
property bool manuallyPaused: false
readonly property bool gamePaused: FocusModes.gameRunning
readonly property bool batteryPaused: DesktopPreferences.get("videoWallpaperPauseOnBattery") === true
&& Battery.available && !Battery.acOnline
readonly property bool paused: root.manuallyPaused || root.gamePaused || root.batteryPaused
readonly property string frameDir: Quickshell.env("XDG_STATE_HOME") !== ""
? Quickshell.env("XDG_STATE_HOME") + "/panama"
: Quickshell.env("HOME") + "/.local/state/panama"
// The lock screen cannot play video; panama-lock uses this still instead.
readonly property string framePath: root.frameDir + "/video-wallpaper-frame.png"
readonly property var outputs: Quickshell.screens.map(screen => screen.name)
function videoDir(): string {
const stored = String(DesktopPreferences.get("videoWallpaperDir") || "Videos/Wallpapers");
if (stored.startsWith("/"))
return stored;
if (stored.startsWith("~/"))
return Quickshell.env("HOME") + stored.slice(1);
return Quickshell.env("HOME") + "/" + stored;
}
function isVideo(candidate: string): bool {
return /\.(mp4|mkv|webm)$/i.test(String(candidate));
}
function socketFor(output: string): string {
return Quickshell.env("XDG_RUNTIME_DIR") + "/panama-wallpaper-" + output + ".sock";
}
// ── Discovery ───────────────────────────────────────────────────────────
function rescan(): void {
if (!scanProc.running) {
scanProc.command = [root.helperPath, "scan", root.videoDir()];
scanProc.running = true;
}
}
Process {
id: scanProc
stdout: StdioCollector {
onStreamFinished: {
root.candidates = this.text.split("\n").filter(line => line !== "");
root.scanned = true;
}
}
}
Process {
id: probeProc
command: ["sh", "-c", "command -v mpvpaper >/dev/null && echo yes || echo no"]
running: true
stdout: StdioCollector {
onStreamFinished: root.available = this.text.trim() === "yes"
}
}
// ── Playback lifecycle ──────────────────────────────────────────────────
// One mpvpaper per output. `-p -a FULL` is belt-and-braces auto-pause;
// the real pausing happens over the IPC socket below. `hwdec=vaapi` is
// named explicitly so a silent software-decode fallback cannot hide.
function mpvpaperCommand(output: string): var {
return ["mpvpaper", "-f", "-o",
"hwdec=vaapi profile=fast no-audio loop-file=inf panscan=1.0 "
+ "input-ipc-server=" + root.socketFor(output),
"-p", "-a", "FULL",
output, root.path];
}
function start(video: string): bool {
if (!root.available) {
root.lastError = "mpvpaper is not installed — run panama update to pick it up.";
return false;
}
if (!root.isVideo(video))
return false;
root.lastError = "";
root.path = video;
root.manuallyPaused = false;
hyprpaperControl.command = ["systemctl", "--user", "stop", "hyprpaper.service"];
hyprpaperControl.running = true;
frameProc.command = ["sh", "-c",
"mkdir -p '" + root.frameDir + "' && exec '" + root.helperPath
+ "' frame \"$1\" \"$2\"", "frame", video, root.framePath];
frameProc.running = true;
playerRespawn.restart();
return true;
}
function stop(): void {
if (!root.active)
return;
root.path = "";
root.manuallyPaused = false;
playerRespawn.stop();
for (const player of root.players)
player.running = false;
hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"];
hyprpaperControl.running = true;
Qt.callLater(() => Wallpaper.refreshActive());
}
// Players are created per current output set each (re)spawn, so hotplug
// lands on the same path as a crash: kill what runs, spawn fresh.
property var players: []
Timer {
id: playerRespawn
interval: 400
onTriggered: root.spawnPlayers()
}
function spawnPlayers(): void {
for (const player of root.players)
player.running = false;
const spawned = [];
for (const output of root.outputs) {
const player = playerComponent.createObject(root, { output: output });
player.command = root.mpvpaperCommand(output);
player.running = true;
spawned.push(player);
}
root.players = spawned;
pauseSync.restart();
}
Component {
id: playerComponent
Process {
id: player
property string output: ""
onExited: {
// A dead player while a video is meant to be active is a
// crash (mpvpaper has a known hotplug segfault): respawn the
// whole set after a beat rather than reasoning per-output.
if (root.active && !playerRespawn.running)
playerRespawn.restart();
}
}
}
onOutputsChanged: if (root.active) playerRespawn.restart()
Process {
id: hyprpaperControl
}
Process {
id: frameProc
}
// ── Pause, over mpv's JSON IPC ──────────────────────────────────────────
onPausedChanged: pauseSync.restart()
// The sockets appear a moment after mpvpaper starts; a short retry beats
// ordering ceremony. Each sync writes the current desired state to every
// output's socket and disconnects.
Timer {
id: pauseSync
interval: 600
onTriggered: root.pushPauseState()
}
function pushPauseState(): void {
if (!root.active)
return;
for (const output of root.outputs) {
const socket = pauseSocketComponent.createObject(root, {
socketPath: root.socketFor(output),
payload: JSON.stringify({ command: ["set_property", "pause", root.paused] }) + "\n"
});
socket.connected = true;
}
}
Component {
id: pauseSocketComponent
Socket {
id: pauseSocket
property string payload: ""
path: socketPath
property string socketPath: ""
onConnectionStateChanged: {
if (connected) {
write(payload);
flush();
Qt.callLater(() => { pauseSocket.connected = false; pauseSocket.destroy(); });
}
}
onError: pauseSocket.destroy()
}
}
function togglePause(): void {
root.manuallyPaused = !root.manuallyPaused;
}
Component.onCompleted: root.rescan()
}