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
510 lines
20 KiB
QML
510 lines
20 KiB
QML
pragma Singleton
|
|
|
|
// Verified wallpaper policy application. hyprpaper 0.8 applies one output per
|
|
// IPC call, so Panama queues every connected output and persists a policy only
|
|
// after listactive confirms the complete map.
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
import "WallpaperPolicy.js" as WallpaperPolicy
|
|
import qs.config
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
property var available: []
|
|
property var activeByOutput: ({})
|
|
property string lastError: ""
|
|
property bool scanning: false
|
|
property var transaction: null
|
|
property bool startupRestoreEnabled: true
|
|
property var outputOverride: null
|
|
property var candidateOverride: null
|
|
property string slideshowPath: ""
|
|
property int slideshowIndex: -1
|
|
property var shuffleBag: []
|
|
property int slideshowIntervalOverrideMs: 0
|
|
property bool pendingHotplugReapply: false
|
|
|
|
readonly property string configured: DesktopPreferences.get("wallpaperPath")
|
|
readonly property string shippedPath: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
|
|
readonly property string active: {
|
|
const outputs = root.outputNames();
|
|
if (outputs.length > 0 && root.activeByOutput[outputs[0]])
|
|
return root.activeByOutput[outputs[0]];
|
|
const paths = Object.values(root.activeByOutput);
|
|
return paths.length > 0 ? paths[0] : "";
|
|
}
|
|
readonly property bool busy: root.transaction !== null
|
|
|| applyProcess.running || verifyProcess.running
|
|
readonly property var slideshowCollection: WallpaperPolicy.validCollection(
|
|
DesktopPreferences.get("wallpaperSlideshowPaths") ?? [], root.candidates([]))
|
|
readonly property bool slideshowTimerRunning: slideshowTimer.running
|
|
readonly property string outputSignature: root.outputNames().slice().sort().join("|")
|
|
|
|
property var outputNames: function() {
|
|
const outputs = Array.isArray(root.outputOverride)
|
|
? root.outputOverride.slice()
|
|
: Quickshell.screens.map(screen => screen.name).filter(name => !!name);
|
|
return root.primaryFirstOutputs(outputs);
|
|
}
|
|
|
|
// One folder, chosen in Settings, rather than four swept blindly. A leading
|
|
// slash means an absolute path -- a drive that is not under home -- and
|
|
// anything else is read relative to it, which is how the capture folders
|
|
// work too.
|
|
readonly property var searchRoots: {
|
|
const configured = String(DesktopPreferences.get("wallpaperDir") ?? "").trim();
|
|
const folder = configured === "" ? "Pictures/Wallpapers" : configured;
|
|
const home = Quickshell.env("HOME");
|
|
if (folder.startsWith("/"))
|
|
return [folder];
|
|
if (folder.startsWith("~/"))
|
|
return [home + folder.slice(1)];
|
|
return [`${home}/${folder}`];
|
|
}
|
|
|
|
Process {
|
|
id: scan
|
|
command: [Quickshell.shellDir + "/scripts/panama-wallpaper-scan"]
|
|
.concat(root.searchRoots)
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
root.available = this.text.split("\n")
|
|
.map(line => line.trim()).filter(line => line.length > 0);
|
|
root.scanning = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: activeQuery
|
|
command: ["hyprctl", "hyprpaper", "listactive"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const parsed = root.parseActive(this.text);
|
|
if (parsed !== null)
|
|
root.activeByOutput = parsed;
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: applyProcess
|
|
onExited: (exitCode, exitStatus) => {
|
|
if (root.transaction === null)
|
|
return;
|
|
if (exitCode !== 0) {
|
|
root.lastError = "Hyprpaper did not apply that background.";
|
|
root.transaction = null;
|
|
root.schedulePendingHotplug();
|
|
return;
|
|
}
|
|
root.drainTransaction();
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: verifyProcess
|
|
property string outputText: ""
|
|
onStarted: outputText = ""
|
|
stdout: StdioCollector {
|
|
onStreamFinished: verifyProcess.outputText = this.text
|
|
}
|
|
onExited: (exitCode, exitStatus) => root.finishVerification(exitCode)
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
root.rescan();
|
|
root.refreshActive();
|
|
restore.restart();
|
|
}
|
|
|
|
Timer {
|
|
id: restore
|
|
interval: 1500
|
|
onTriggered: {
|
|
if (!root.startupRestoreEnabled)
|
|
return;
|
|
// A stored video wallpaper restores through mpvpaper; handing its
|
|
// path to the hyprpaper transaction would only fail validation.
|
|
if (DesktopPreferences.get("wallpaperMode") === "single"
|
|
&& VideoWallpaper.isVideo(root.configured)) {
|
|
VideoWallpaper.start(root.configured);
|
|
return;
|
|
}
|
|
root.applyCurrentPolicy(false);
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: slideshowTimer
|
|
interval: root.slideshowIntervalOverrideMs > 0
|
|
? root.slideshowIntervalOverrideMs
|
|
: DesktopPreferences.get("wallpaperIntervalMinutes") * 60000
|
|
repeat: true
|
|
running: DesktopPreferences.get("wallpaperMode") === "slideshow"
|
|
&& root.slideshowCollection.length >= 2 && !root.busy
|
|
onTriggered: root.advanceSlideshow()
|
|
}
|
|
|
|
Timer {
|
|
id: outputSettle
|
|
interval: 350
|
|
onTriggered: {
|
|
if (root.busy) {
|
|
root.pendingHotplugReapply = true;
|
|
return;
|
|
}
|
|
root.applyCurrentPolicy(false);
|
|
}
|
|
}
|
|
|
|
onOutputSignatureChanged: {
|
|
if (Object.keys(root.activeByOutput).length > 0)
|
|
outputSettle.restart();
|
|
}
|
|
|
|
function rescan(): void {
|
|
if (scan.running)
|
|
return;
|
|
root.scanning = true;
|
|
scan.running = true;
|
|
}
|
|
|
|
function refreshActive(): void {
|
|
if (!activeQuery.running && !root.busy)
|
|
activeQuery.running = true;
|
|
}
|
|
|
|
// The saved primary role anchors Panama's per-monitor choices. Keep the
|
|
// compositor's remaining order stable so a reconnect does not reshuffle
|
|
// the rest of the picker unnecessarily.
|
|
function primaryFirstOutputs(outputs: var): var {
|
|
const stored = DesktopPreferences.get("displays");
|
|
const layouts = stored && typeof stored === "object" ? stored : {};
|
|
const primary = outputs.find(output => layouts[output]?.primary === true);
|
|
return primary === undefined
|
|
? outputs
|
|
: [primary].concat(outputs.filter(output => output !== primary));
|
|
}
|
|
|
|
function candidates(extra: var): var {
|
|
const result = [];
|
|
const discovered = Array.isArray(root.candidateOverride)
|
|
? root.candidateOverride : root.available;
|
|
for (const path of discovered.concat([root.shippedPath, root.configured]).concat(extra || [])) {
|
|
if (typeof path === "string" && /^\/[^,\n]+$/.test(path) && !result.includes(path))
|
|
result.push(path);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function currentPolicy(): var {
|
|
return {
|
|
mode: DesktopPreferences.get("wallpaperMode") ?? "single",
|
|
// A stored video path cannot serve as a hyprpaper still; policies
|
|
// built while one is stored fall back to the shipped image.
|
|
globalPath: root.configured === "" || VideoWallpaper.isVideo(root.configured)
|
|
? root.shippedPath : root.configured,
|
|
collection: DesktopPreferences.get("wallpaperSlideshowPaths") ?? [],
|
|
intervalMinutes: DesktopPreferences.get("wallpaperIntervalMinutes") ?? 30,
|
|
shuffle: DesktopPreferences.get("wallpaperShuffle") !== false,
|
|
assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({}),
|
|
slideshowPath: root.slideshowPath !== "" ? root.slideshowPath : root.active
|
|
};
|
|
}
|
|
|
|
function normalizePolicy(policy: var): var {
|
|
if (!policy || typeof policy !== "object")
|
|
return null;
|
|
const mode = ["single", "slideshow", "per-monitor"].includes(policy.mode)
|
|
? policy.mode : "single";
|
|
const rawGlobal = policy.globalPath === "" ? root.shippedPath : policy.globalPath;
|
|
const candidatePaths = root.candidates([rawGlobal]
|
|
.concat(policy.collection || [])
|
|
.concat(Object.values(policy.assignments || {}))
|
|
.concat([policy.slideshowPath || ""]));
|
|
if (!WallpaperPolicy.validPath(rawGlobal, candidatePaths))
|
|
return null;
|
|
return {
|
|
mode,
|
|
globalPath: rawGlobal,
|
|
storedPath: rawGlobal === root.shippedPath ? "" : rawGlobal,
|
|
collection: WallpaperPolicy.validCollection(policy.collection || [], candidatePaths),
|
|
intervalMinutes: Math.max(5, Math.min(1440, Number(policy.intervalMinutes) || 30)),
|
|
shuffle: policy.shuffle !== false,
|
|
assignments: WallpaperPolicy.validAssignments(policy.assignments || {}, candidatePaths),
|
|
slideshowPath: WallpaperPolicy.validPath(policy.slideshowPath, candidatePaths)
|
|
? policy.slideshowPath : rawGlobal,
|
|
nextShuffleBag: Array.isArray(policy.nextShuffleBag)
|
|
? policy.nextShuffleBag.slice() : root.shuffleBag.slice(),
|
|
nextSlideshowIndex: Number.isInteger(policy.nextSlideshowIndex)
|
|
? policy.nextSlideshowIndex : root.slideshowIndex,
|
|
candidates: candidatePaths
|
|
};
|
|
}
|
|
|
|
function applyPolicy(policy: var, persist: bool, automatic: bool): bool {
|
|
if (root.busy)
|
|
return false;
|
|
const normalized = root.normalizePolicy(policy);
|
|
if (normalized === null) {
|
|
root.lastError = "That wallpaper policy is not valid.";
|
|
return false;
|
|
}
|
|
const outputs = root.outputNames();
|
|
if (outputs.length === 0) {
|
|
root.lastError = "No display to set a wallpaper on.";
|
|
return false;
|
|
}
|
|
const expected = WallpaperPolicy.effectiveMap(
|
|
normalized.mode, normalized.globalPath, normalized.slideshowPath,
|
|
normalized.assignments, outputs, normalized.candidates);
|
|
if (Object.keys(expected).length !== outputs.length
|
|
|| Object.values(expected).some(path => path === "")) {
|
|
root.lastError = "That wallpaper policy is not valid.";
|
|
return false;
|
|
}
|
|
root.lastError = "";
|
|
root.transaction = {
|
|
expected,
|
|
remaining: outputs.slice(),
|
|
policy: normalized,
|
|
persist: persist === true,
|
|
automatic: automatic === true
|
|
};
|
|
root.drainTransaction();
|
|
return true;
|
|
}
|
|
|
|
function drainTransaction(): void {
|
|
if (root.transaction === null || applyProcess.running || verifyProcess.running)
|
|
return;
|
|
if (root.transaction.remaining.length === 0) {
|
|
verifyProcess.exec(["hyprctl", "hyprpaper", "listactive"]);
|
|
return;
|
|
}
|
|
const output = root.transaction.remaining[0];
|
|
root.transaction.remaining = root.transaction.remaining.slice(1);
|
|
applyProcess.exec([
|
|
"hyprctl", "hyprpaper", "wallpaper",
|
|
`${output},${root.transaction.expected[output]}`
|
|
]);
|
|
}
|
|
|
|
function parseActive(text: string): var {
|
|
const result = {};
|
|
const lines = String(text).split("\n").map(line => line.trim()).filter(line => line !== "");
|
|
for (const line of lines) {
|
|
const match = line.match(/^([A-Za-z0-9_.-]+): (\/[^,\n]+)$/);
|
|
if (!match || result[match[1]] !== undefined)
|
|
return null;
|
|
result[match[1]] = match[2];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function finishVerification(exitCode: int): void {
|
|
if (root.transaction === null)
|
|
return;
|
|
const observed = exitCode === 0 ? root.parseActive(verifyProcess.outputText) : null;
|
|
const expected = root.transaction.expected;
|
|
const matches = observed !== null
|
|
&& Object.keys(observed).length === Object.keys(expected).length
|
|
&& Object.keys(expected).every(output => observed[output] === expected[output]);
|
|
if (!matches) {
|
|
root.lastError = "Hyprpaper did not confirm that background.";
|
|
root.transaction = null;
|
|
root.schedulePendingHotplug();
|
|
return;
|
|
}
|
|
|
|
const completed = root.transaction;
|
|
root.activeByOutput = observed;
|
|
root.transaction = null;
|
|
root.lastError = "";
|
|
if (completed.automatic) {
|
|
root.slideshowPath = completed.policy.slideshowPath;
|
|
root.shuffleBag = completed.policy.nextShuffleBag;
|
|
root.slideshowIndex = completed.policy.nextSlideshowIndex;
|
|
}
|
|
if (completed.persist)
|
|
root.persistPolicy(completed.policy);
|
|
root.schedulePendingHotplug();
|
|
}
|
|
|
|
function persistPolicy(policy: var): void {
|
|
DesktopPreferences.set("wallpaperMode", policy.mode);
|
|
DesktopPreferences.set("wallpaperPath", policy.storedPath);
|
|
DesktopPreferences.set("wallpaperSlideshowPaths", policy.collection);
|
|
DesktopPreferences.set("wallpaperIntervalMinutes", policy.intervalMinutes);
|
|
DesktopPreferences.set("wallpaperShuffle", policy.shuffle);
|
|
DesktopPreferences.set("wallpaperPerMonitor", policy.assignments);
|
|
}
|
|
|
|
function applyCurrentPolicy(persist: bool): bool {
|
|
// While mpvpaper holds the background, hyprpaper has nothing to apply
|
|
// — hotplug is VideoWallpaper's problem and it respawns per output.
|
|
if (VideoWallpaper.active)
|
|
return true;
|
|
return root.applyPolicy(root.currentPolicy(), persist === true, false);
|
|
}
|
|
|
|
function schedulePendingHotplug(): void {
|
|
if (!root.pendingHotplugReapply)
|
|
return;
|
|
root.pendingHotplugReapply = false;
|
|
outputSettle.restart();
|
|
}
|
|
|
|
function advanceSlideshow(): bool {
|
|
if (root.busy)
|
|
return false;
|
|
const collection = root.slideshowCollection;
|
|
if (collection.length < 2)
|
|
return false;
|
|
|
|
const current = root.slideshowPath !== ""
|
|
? root.slideshowPath
|
|
: (root.active !== "" ? root.active : collection[0]);
|
|
const policy = root.currentPolicy();
|
|
policy.mode = "slideshow";
|
|
policy.collection = collection;
|
|
|
|
if (DesktopPreferences.get("wallpaperShuffle") !== false) {
|
|
const next = WallpaperPolicy.shuffledNext(
|
|
collection, root.shuffleBag, current, Math.random);
|
|
policy.slideshowPath = next.path;
|
|
policy.nextShuffleBag = next.bag;
|
|
policy.nextSlideshowIndex = collection.indexOf(next.path);
|
|
} else {
|
|
policy.slideshowPath = WallpaperPolicy.orderedNext(collection, current);
|
|
policy.nextShuffleBag = [];
|
|
policy.nextSlideshowIndex = collection.indexOf(policy.slideshowPath);
|
|
}
|
|
return root.applyPolicy(policy, false, true);
|
|
}
|
|
|
|
function setSingle(path: string): bool {
|
|
// A video routes to mpvpaper instead of hyprpaper; it still rides
|
|
// wallpaperPath so restore, backup, and the picker's "current" ring
|
|
// treat both kinds the same.
|
|
if (VideoWallpaper.isVideo(path)) {
|
|
if (!VideoWallpaper.start(path)) {
|
|
root.lastError = VideoWallpaper.lastError;
|
|
return false;
|
|
}
|
|
root.lastError = "";
|
|
DesktopPreferences.set("wallpaperMode", "single");
|
|
DesktopPreferences.set("wallpaperPath", path);
|
|
return true;
|
|
}
|
|
const effectivePath = path === "" ? root.shippedPath : path;
|
|
const allowed = root.candidates([]);
|
|
if (!WallpaperPolicy.validPath(effectivePath, allowed)) {
|
|
root.lastError = "That file path cannot be used as a wallpaper.";
|
|
return false;
|
|
}
|
|
const policy = root.currentPolicy();
|
|
policy.mode = "single";
|
|
policy.globalPath = effectivePath;
|
|
policy.slideshowPath = effectivePath;
|
|
// Returning from a video: stop mpvpaper first, then give hyprpaper's
|
|
// service a beat to come back before the transaction talks to it.
|
|
if (VideoWallpaper.active) {
|
|
VideoWallpaper.stop();
|
|
root.pendingStillPolicy = policy;
|
|
stillAfterVideo.restart();
|
|
return true;
|
|
}
|
|
return root.applyPolicy(policy, true, false);
|
|
}
|
|
|
|
property var pendingStillPolicy: null
|
|
|
|
Timer {
|
|
id: stillAfterVideo
|
|
interval: 900
|
|
onTriggered: {
|
|
if (root.pendingStillPolicy) {
|
|
root.applyPolicy(root.pendingStillPolicy, true, false);
|
|
root.pendingStillPolicy = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function setMode(mode: string): bool {
|
|
if (!["single", "slideshow", "per-monitor"].includes(mode))
|
|
return false;
|
|
const policy = root.currentPolicy();
|
|
policy.mode = mode;
|
|
// Slideshow and per-display are hyprpaper's modes; a stored video path
|
|
// cannot serve as their global still, so fall back to the shipped one.
|
|
if (VideoWallpaper.isVideo(policy.globalPath)) {
|
|
policy.globalPath = root.shippedPath;
|
|
policy.slideshowPath = root.shippedPath;
|
|
}
|
|
if (mode === "slideshow") {
|
|
const collection = WallpaperPolicy.validCollection(
|
|
policy.collection, root.candidates([]));
|
|
if (!collection.includes(policy.slideshowPath))
|
|
policy.slideshowPath = collection[0] || policy.globalPath;
|
|
}
|
|
if (VideoWallpaper.active) {
|
|
VideoWallpaper.stop();
|
|
root.pendingStillPolicy = policy;
|
|
stillAfterVideo.restart();
|
|
return true;
|
|
}
|
|
return root.applyPolicy(policy, true, false);
|
|
}
|
|
|
|
function setAssignment(output: string, path: string): bool {
|
|
if (!root.outputNames().includes(output)
|
|
|| !WallpaperPolicy.validPath(path, root.candidates([])))
|
|
return false;
|
|
const policy = root.currentPolicy();
|
|
policy.mode = "per-monitor";
|
|
policy.assignments = Object.assign({}, policy.assignments);
|
|
policy.assignments[output] = path;
|
|
return root.applyPolicy(policy, true, false);
|
|
}
|
|
|
|
function toggleSlideshowPath(path: string): bool {
|
|
if (!WallpaperPolicy.validPath(path, root.candidates([])))
|
|
return false;
|
|
const policy = root.currentPolicy();
|
|
const collection = WallpaperPolicy.validCollection(policy.collection, root.candidates([]));
|
|
policy.collection = collection.includes(path)
|
|
? collection.filter(candidate => candidate !== path)
|
|
: collection.concat([path]);
|
|
policy.mode = "slideshow";
|
|
if (!policy.collection.includes(policy.slideshowPath))
|
|
policy.slideshowPath = policy.collection[0] || policy.globalPath;
|
|
return root.applyPolicy(policy, true, false);
|
|
}
|
|
|
|
function setIntervalMinutes(minutes: int): bool {
|
|
const value = PreferenceSchema.coerce("wallpaperIntervalMinutes", minutes);
|
|
return value !== undefined
|
|
&& DesktopPreferences.set("wallpaperIntervalMinutes", value);
|
|
}
|
|
|
|
function setShuffle(enabled: bool): bool {
|
|
return DesktopPreferences.set("wallpaperShuffle", enabled === true);
|
|
}
|
|
|
|
// Compatibility boundary used by backup/reset and the existing picker.
|
|
function set(path: string): bool {
|
|
return root.setSingle(path);
|
|
}
|
|
|
|
function titleFor(path: string): string {
|
|
const file = String(path).split("/").pop();
|
|
return file.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " ");
|
|
}
|
|
}
|