From 70bbc970c75221c902c515efd124c47fd702e3b7 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 17 Oct 2026 10:16:40 +0200 Subject: appearance moves into the settings window The appearance shell existed because a wallpaper picker needed more room than the 600px drawer, so it ran as its own process with a full-screen overlay. The settings window now gives it that room, and its five tabs become five settings pages in the desktop process: Wallpaper, Theme, Icons & cursor, Sunset and Idle. appearance/ is gone, and with it a process, a keepalive window and a layer blur rule. The files move to desktop/modules/appearance/ unchanged in what they do. Each tab gets a small *Module.qml; the window's refresh-on-open calls the singletons' refresh(), which the panel's show() used to. The two tabs that were inline Components in AppearancePanel become ThemeTab and WallpaperTab, owning the notices the panel printed. Sunset, Idle and Icons drop their own Flickable so the page scrolls them and search can reveal a row; the wallpaper list virtualises its rows and scrolls itself, so it takes the page's new viewHeight instead. Search finds a colour scheme and an icon or cursor theme by name. The appearance IPC target moves to desktop/shell.qml with the same verbs, so udt-steps and any keybind change only the path. Git now follows the appearance pages and autostart in the sidebar. Co-Authored-By: Claude Opus 5.5 --- desktop/modules/appearance/Wallpapers.qml | 244 ++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 desktop/modules/appearance/Wallpapers.qml (limited to 'desktop/modules/appearance/Wallpapers.qml') diff --git a/desktop/modules/appearance/Wallpapers.qml b/desktop/modules/appearance/Wallpapers.qml new file mode 100644 index 0000000..7d671e1 --- /dev/null +++ b/desktop/modules/appearance/Wallpapers.qml @@ -0,0 +1,244 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import "../.." + +// The wallpaper side. Setting one is `wallp`'s job: it knows about the two +// outputs, the saved state and the accent run, and none of that is worth +// reimplementing here. +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string dir: `${home}/Pictures/wallpapers` + // The distribution's wallpapers: loose images, and Plasma packages that + // each carry one picture at several resolutions under contents/images/, + // some with a dark version under contents/images_dark/. + readonly property string sysDir: "/usr/share/wallpapers" + + // Which screen a pick applies to. wallp calls them H and V. + property string target: "H" + + // Staged picks, empty until something is chosen. Selecting does not set + // anything: both screens can be composed and applied together, which is + // also a single wallp call rather than two. + property string pendingH: "" + property string pendingV: "" + readonly property bool dirty: pendingH !== "" || pendingV !== "" + + function pick(path) { + if (target === "V") pendingV = path; + else pendingH = path; + } + + function clearPending() { pendingH = ""; pendingV = ""; } + + // What a screen should show: the staged pick if there is one, else what + // is actually set. + function shown(which) { + if (which === "V") return pendingV !== "" ? pendingV : currentV; + return pendingH !== "" ? pendingH : currentH; + } + + property list files: [] + property bool scanning: true + property bool applying: false + signal applied(bool ok, string message) + + // Sorted by most recent first: the wallpaper wanted now is usually one + // added recently. -maxdepth keeps a stray git checkout from being walked. + function refresh() { + scanning = true; + scanProc.running = false; + scanProc.running = true; + } + + Process { + id: scanProc + running: true + // img DIR "DEPTH" [ACTIONS...]: the image files under DIR. DEPTH is + // left unquoted on purpose, so "-maxdepth 2" splits into two words + // and "" vanishes. + command: ["sh", "-c", + `img() { d=$1; o=$2; shift 2; find "$d" $o -type f ` + + `\\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.webp' \\) "$@" 2>/dev/null; }; ` + + `img ${JSON.stringify(root.dir)} "-maxdepth 2" -not -path '*/.*' -printf '%T@ %p\\n' | sort -rn | cut -d' ' -f2-; ` + + `img ${JSON.stringify(root.sysDir)} "-maxdepth 1" | sort; ` + + `img ${JSON.stringify(root.sysDir)} "" -path '*/contents/images*/*'`] + stdout: StdioCollector { + onStreamFinished: { + root.files = text.trim().split("\n").filter(s => s.length); + root.scanning = false; + sizeProc.running = false; + sizeProc.command = ["sh", "-c", 'printf "%s\\0" "$@" | xargs -0 -r file -N -0', "sh"].concat(root.files); + sizeProc.running = true; + } + } + } + + // Each file's pixel size, by path, from `file`, which reads only the + // header: about half a second for a thousand. -0 ends the name with a + // NUL, since a name can hold the ": " that would otherwise end it. The + // size is the last WxH in the description, because a JPEG's comes after + // its "density 72x72". A file `file` gives no size for has no entry and + // gets no quality colour. + property var sizes: ({}) + Process { + id: sizeProc + stdout: StdioCollector { + onStreamFinished: { + const out = {}; + for (const line of text.split("\n")) { + const nul = line.indexOf("\0"); + if (nul < 0) continue; + const re = /(\d+) ?x ?(\d+)/g; + let m, last = null; + while ((m = re.exec(line.slice(nul + 1))) !== null) last = m; + if (last) out[line.slice(0, nul)] = { w: +last[1], h: +last[2] }; + } + root.sizes = out; + } + } + } + + // The screens wallp sets, as the mock draws them: DP-1 wide, DP-3 turned + // upright. + // ponytail: fixed, like the mock; read Quickshell.screens if a monitor + // changes. + readonly property var panels: ({ H: { w: 2560, h: 1080 }, V: { w: 1080, h: 1920 } }) + + // How far swaybg's `-m fill` has to enlarge an image to cover a screen, + // 0 when the size is unknown. At or under 1 it is downscaled and sharp; + // to 1.33 the softening is slight; past that it shows pixels. + function upscale(path, which) { + const s = sizes[path]; + const p = panels[which]; + return s && s.w > 0 && s.h > 0 ? Math.max(p.w / s.w, p.h / s.h) : 0; + } + function qualityColor(path, which) { + const u = upscale(path, which); + return u === 0 ? "transparent" : u <= 1 ? Theme.green : u <= 1.33 ? Theme.yellow : Theme.red; + } + // Why an image that fits will still look bad, or "" when it will not. + function qualityNote(path, which) { + const u = upscale(path, which); + if (u <= 1) return ""; + const s = sizes[path], p = panels[which]; + return `${s.w}×${s.h} is enlarged ${u.toFixed(2)}× to cover the ${p.w}×${p.h} screen: ` + + (u <= 1.33 ? "each image pixel is spread over more than one screen pixel, so it will look slightly soft." + : "each image pixel becomes a visible block, so it will look pixelated."); + } + + function basename(path) { return path.slice(path.lastIndexOf("/") + 1); } + + // The files by the subfolder they sit in, folders by name, each keeping + // the newest-first order of `files`. Images loose in `dir` itself form a + // group named after it, which sorts first. A folder with no images at + // the scanned depth has no group. The system ones follow: its loose + // images as "system", and the Plasma packages as "Plasma", one image + // each: the dark version where the package has one, else the light, and + // of those the largest by the WxH its file is named after. `key` is what + // `expanded` is indexed by, so a local folder named "system" stays apart. + readonly property var groups: { + const local = {}, system = [], plasma = {}; + for (const f of files) { + if (f.startsWith(dir + "/")) { + const rel = f.slice(dir.length + 1); + const key = rel.includes("/") ? rel.slice(0, rel.indexOf("/")) : ""; + if (!local[key]) local[key] = []; + local[key].push(f); + } else if (f.includes("/contents/images")) { + const pkg = f.slice(sysDir.length + 1).split("/")[0]; + if (!plasma[pkg] || rank(f) > rank(plasma[pkg])) plasma[pkg] = f; + } else { + system.push(f); + } + } + const byName = (a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }); + return Object.keys(local).sort(byName) + .map(k => ({ key: "local/" + k, name: k || basename(dir), files: local[k] })) + .concat([ + { key: "system", name: "system", files: system }, + { key: "plasma", name: "Plasma", files: Object.keys(plasma).sort(byName).map(k => plasma[k]) }, + ]) + .filter(g => g.files.length > 0); + } + + // Dark outranks any light, then size decides. + function rank(path) { + const m = /(\d+)x(\d+)\.\w+$/.exec(path); + return (path.includes("/contents/images_dark/") ? 1e12 : 0) + (m ? m[1] * m[2] : 0); + } + + // What a thumbnail is called: a Plasma package's file is named after its + // resolution, so it goes by the package instead. + function label(path) { + const i = path.indexOf("/contents/images"); + return i < 0 ? basename(path) : basename(path.slice(0, i)); + } + + // Which groups are open, by key. Here rather than in the panel, which is + // destroyed on close, so a folder left open is open next time. Reassigned + // whole, because bindings do not see a key change inside a var. + property var expanded: ({}) + function toggle(key) { + const e = Object.assign({}, expanded); + e[key] = !e[key]; + expanded = e; + } + + // What is on each screen now. wallp writes these on every set, so + // watching them keeps the mock honest even when set from a terminal. + property string currentH: "" + property string currentV: "" + + FileView { + path: `${root.home}/.config/wallp/wall_h` + watchChanges: true + onFileChanged: reload() + onLoaded: root.currentH = text().trim() + } + + FileView { + path: `${root.home}/.config/wallp/wall_v` + watchChanges: true + onFileChanged: reload() + onLoaded: root.currentV = text().trim() + } + + // wallp does the work, including running udt-accent, so the accent follows + // the new wallpaper exactly as it does from the command line. Both screens + // go in one call: wallp accepts H= and V= together. + function apply() { + if (applying || !dirty) return; + applying = true; + const args = []; + if (pendingH !== "") args.push(`H=${JSON.stringify(pendingH)}`); + if (pendingV !== "") args.push(`V=${JSON.stringify(pendingV)}`); + applyProc.command = ["sh", "-c", `wallp --set ${args.join(" ")} >/dev/null 2>&1`]; + applyProc.running = true; + } + + Process { + id: applyProc + stderr: StdioCollector { id: applyErr } + onExited: code => { + root.applying = false; + if (code === 0) root.clearPending(); + root.applied(code === 0, code === 0 ? "" : (applyErr.text.trim() || `wallp exited ${code}`)); + } + } +} -- cgit v1.2.3