aboutsummaryrefslogtreecommitdiffstats
path: root/appearance
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-06 10:27:19 +0200
committerDanilo M. <danix@danix.xyz>2026-10-06 10:27:19 +0200
commite4f4a32415b47d6d8332e82364cea669db07c00f (patch)
tree017c5b431539f1835f0fe67c0832bd814f04228b /appearance
parent595d42fbd4ee206d2b16d033e1be337f9c97eee7 (diff)
downloadquickshell-e4f4a32415b47d6d8332e82364cea669db07c00f.tar.gz
quickshell-e4f4a32415b47d6d8332e82364cea669db07c00f.zip
feat(appearance): wallpapers by folder, system ones, quality borders
The wallpaper tab showed every image under ~/Pictures/wallpapers in one grid. It now groups them by top-level folder, each behind a header that opens and closes it, and adds the distribution's /usr/share/wallpapers: its loose images as "system", its Plasma packages as "Plasma". A package carries one picture at several resolutions under contents/images/, some with a dark version under contents/images_dark/, so it shows once, the dark version where there is one and the largest of those, labelled with the package name rather than "5120x2880.png". Groups are keyed apart from their names, so a local folder called "system" does not share open state with the system group. Open state lives in the Wallpapers singleton, because the panel is destroyed on close. The list is one flat ListView of rows, a header or a row of thumbnails, so only rows in view (plus cacheBuffer) are instantiated and decode. A first version made each folder one delegate, which loaded every image of a folder as soon as it opened; the system group alone is 727. The rows live in a ListModel and a toggle inserts or removes only that folder's rows: assigning a fresh JS array instead resets the view, which loses the scroll position and re-decodes everything visible. cacheBuffer is clamped at 0 because the column's width reads negative before layout and the ListView warns on each negative value. The scan's find calls go through a small shell function rather than eval. Each thumbnail's 2px border tells how it will look on the targeted screen. `file -N -0` reads every image's size from its header after each scan, ~0.6s for 1161; the NUL keeps names containing ": " intact, and the size is the last WxH in the description because a JPEG's "density 72x72" comes first. wallp runs swaybg -m fill, which scales to cover, so the measure is the enlargement factor: <=1 green, <=1.33 yellow, else red, in the udt palette colours. A note under the mock screens, in that colour, gives the size, the factor and why it will look soft or pixelated, for the hovered image or else the staged one. The staged image keeps its accent border. Screen sizes are fixed, as the mock's already were. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'appearance')
-rw-r--r--appearance/AppearancePanel.qml222
-rw-r--r--appearance/README.md36
-rw-r--r--appearance/Wallpapers.qml127
3 files changed, 321 insertions, 64 deletions
diff --git a/appearance/AppearancePanel.qml b/appearance/AppearancePanel.qml
index 9ccd109..0547c6b 100644
--- a/appearance/AppearancePanel.qml
+++ b/appearance/AppearancePanel.qml
@@ -381,73 +381,171 @@ Scope {
}
}
- GridView {
+ // A folder is a header row followed, while open, by its
+ // thumbnails a row at a time, all in one flat ListView, so only
+ // the rows in view (and cacheBuffer around them) exist and
+ // decode: a large folder loads as it scrolls. Each row holds a
+ // group key and the index of its first file, start -1 for the
+ // header. A toggle inserts or removes just that folder's rows,
+ // so the rows around it, their decoded thumbnails and the
+ // scroll position all survive it.
+ ListView {
+ id: wallList
width: parent.width
height: parent.height - y
clip: true
- cellWidth: Math.floor(width / Math.max(1, Math.floor(width / 300)))
- cellHeight: cellWidth * 9 / 16 + 26
- // Several hundred files: only the visible thumbnails decode.
- cacheBuffer: cellHeight * 2
- model: Wallpapers.files
+ readonly property int cols: Math.max(1, Math.floor(width / 300))
+ readonly property real cell: Math.floor(width / cols)
+ readonly property real thumbHeight: cell * 9 / 16 + 26
+ // The column is laid out after creation and reads negative
+ // until then, which the buffer refuses with a warning.
+ cacheBuffer: Math.max(0, thumbHeight * 2)
+ model: ListModel { id: rows }
+
+ function group(key) {
+ return Wallpapers.groups.find(g => g.key === key) ?? { key: key, name: "", files: [] };
+ }
+ function rowsOf(g) {
+ const out = [];
+ for (let i = 0; i < g.files.length; i += cols) out.push({ key: g.key, start: i });
+ return out;
+ }
+ function rebuild() {
+ rows.clear();
+ for (const g of Wallpapers.groups) {
+ rows.append({ key: g.key, start: -1 });
+ if (Wallpapers.expanded[g.key]) rows.append(rowsOf(g));
+ }
+ }
+ function toggle(index) {
+ const g = group(rows.get(index).key);
+ const body = rowsOf(g);
+ if (Wallpapers.expanded[g.key]) rows.remove(index + 1, body.length);
+ else body.forEach((r, i) => rows.insert(index + 1 + i, r));
+ Wallpapers.toggle(g.key);
+ }
+
+ // The width settles after creation, and a new column count
+ // regroups every row, so both rebuild.
+ Component.onCompleted: rebuild()
+ onColsChanged: rebuild()
+ Connections {
+ target: Wallpapers
+ function onGroupsChanged() { wallList.rebuild(); }
+ }
delegate: Item {
- required property string modelData
- width: GridView.view.cellWidth
- height: GridView.view.cellHeight
+ id: row
+ required property int index
+ required property string key
+ required property int start
+ readonly property var group: wallList.group(key)
+ readonly property bool open: !!Wallpapers.expanded[key]
+ width: ListView.view.width
+ height: start < 0 ? 40 : wallList.thumbHeight
Rectangle {
- readonly property bool staged:
- Wallpapers.pendingH === modelData || Wallpapers.pendingV === modelData
-
- anchors.fill: parent
- anchors.margins: 6
+ visible: row.start < 0
+ anchors { fill: parent; topMargin: 6 }
radius: 8
- color: Qt.alpha(Theme.surface, thumbHover.hovered ? 0.8 : 0.3)
- border.width: staged ? 2 : 1
- border.color: staged ? Theme.accent
- : thumbHover.hovered ? Qt.alpha(Theme.accent, 0.6)
- : "transparent"
- clip: true
-
- HoverHandler {
- id: thumbHover
- // Hovering previews this image on the mock
- // screen it would actually land on.
- onHoveredChanged:
- root.hovering = hovered ? modelData
- : (root.hovering === modelData ? "" : root.hovering)
- }
+ color: Qt.alpha(Theme.surface, headerHover.hovered ? 0.8 : 0.4)
+
+ HoverHandler { id: headerHover }
MouseArea {
anchors.fill: parent
- enabled: !Wallpapers.applying
cursorShape: Qt.PointingHandCursor
- onClicked: Wallpapers.pick(modelData)
+ onClicked: wallList.toggle(row.index)
}
- Column {
- anchors.fill: parent
- anchors.margins: 6
- spacing: 4
-
- Image {
- width: parent.width
- height: parent.height - 18
- source: "file://" + modelData
- asynchronous: true
- cache: false
- fillMode: Image.PreserveAspectCrop
- // Decode at display size: full-resolution
- // wallpapers would be megabytes each in memory.
- sourceSize.width: 480
- }
+ Text {
+ id: chevron
+ anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
+ text: row.open ? "\uf077" : "\uf078"
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ }
+ Text {
+ anchors { left: chevron.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: row.group.name
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.text
+ }
+ Text {
+ anchors { right: parent.right; rightMargin: 12; verticalCenter: parent.verticalCenter }
+ text: row.group.files.length
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+ }
- Text {
- width: parent.width
- elide: Text.ElideMiddle
- text: Wallpapers.basename(modelData)
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
- color: Theme.subtext
+ Row {
+ visible: row.start >= 0
+
+ Repeater {
+ model: row.start < 0 ? [] : row.group.files.slice(row.start, row.start + wallList.cols)
+
+ delegate: Item {
+ required property string modelData
+ width: wallList.cell
+ height: wallList.thumbHeight
+
+ Rectangle {
+ readonly property bool staged:
+ Wallpapers.pendingH === modelData || Wallpapers.pendingV === modelData
+
+ anchors.fill: parent
+ anchors.margins: 6
+ radius: 8
+ color: Qt.alpha(Theme.surface, thumbHover.hovered ? 0.8 : 0.3)
+ // Staged wins the border; otherwise it
+ // says how well the image fills the
+ // screen it would go to.
+ border.width: 2
+ border.color: staged ? Theme.accent
+ : Wallpapers.qualityColor(modelData, Wallpapers.target)
+ clip: true
+
+ HoverHandler {
+ id: thumbHover
+ // Hovering previews this image on the mock
+ // screen it would actually land on.
+ onHoveredChanged:
+ root.hovering = hovered ? modelData
+ : (root.hovering === modelData ? "" : root.hovering)
+ }
+ MouseArea {
+ anchors.fill: parent
+ enabled: !Wallpapers.applying
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Wallpapers.pick(modelData)
+ }
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: 6
+ spacing: 4
+
+ Image {
+ width: parent.width
+ height: parent.height - 18
+ source: "file://" + modelData
+ asynchronous: true
+ cache: false
+ fillMode: Image.PreserveAspectCrop
+ // Decode at display size: full-resolution
+ // wallpapers would be megabytes each in memory.
+ sourceSize.width: wallList.cell
+ }
+
+ Text {
+ width: parent.width
+ elide: Text.ElideMiddle
+ text: Wallpapers.label(modelData)
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ color: Theme.subtext
+ }
+ }
+ }
}
}
}
@@ -504,13 +602,29 @@ Scope {
width: mockPane.width
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
- text: root.hovering !== "" ? Wallpapers.basename(root.hovering)
+ text: root.hovering !== "" ? Wallpapers.label(root.hovering)
: Wallpapers.dirty ? "click Apply to set"
: "click a wallpaper to stage it"
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
color: Theme.overlay
}
+ // Why the image under consideration, hovered or else the
+ // one staged for the target screen, will look worse than
+ // its thumbnail.
+ Text {
+ readonly property string subject: root.hovering !== "" ? root.hovering
+ : Wallpapers.target === "V" ? Wallpapers.pendingV : Wallpapers.pendingH
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: mockPane.width
+ visible: text !== ""
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.Wrap
+ text: subject !== "" ? Wallpapers.qualityNote(subject, Wallpapers.target) : ""
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Wallpapers.qualityColor(subject, Wallpapers.target)
+ }
+
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 8
diff --git a/appearance/README.md b/appearance/README.md
index 467dcd3..2c127a2 100644
--- a/appearance/README.md
+++ b/appearance/README.md
@@ -4,13 +4,13 @@ Wallpapers and colour scheme in one drawer. `SUPER+Return` opens it on the
Wallpaper tab; Tab switches tabs, Escape closes.
┌─[ Wallpaper ]─[ Theme ]─[ Sunset ]─[ Idle ]─[ Icons ]──┐
- │ Set on [Horizontal] [Vertical] 261 wallpapers │
- │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌──┐ ┌─────┐ │
- │ │ │ │ │ │ │ │ │ │ │ │ │ │
- │ └────┘ └────┘ └────┘ └────┘ └┬─┘ └──┬──┘ │
- │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ═╧═ ══╧══ │
- │ │ │ │ │ │ │ │ │ [Apply] [Reset] │
- │ └────┘ └────┘ └────┘ └────┘ │
+ │ Set on [Horizontal] [Vertical] 1161 wallpapers │
+ │ ▸ wallpapers 16 ┌──┐ ┌─────┐ │
+ │ ▾ SFW 30 │ │ │ │ │
+ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ └┬─┘ └──┬──┘ │
+ │ │ │ │ │ │ │ │ │ ═╧═ ══╧══ │
+ │ └────┘ └────┘ └────┘ └────┘ enlarged 1.6×: … │
+ │ ▸ system 727 [Apply] [Reset] │
└────────────────────────────────────────────────────────┘
## Running it
@@ -75,6 +75,28 @@ would look like: the staged pick where there is one, what is currently set
where there is not, and the hovered thumbnail on the targeted screen while the
pointer is over it.
+The thumbnails are grouped by folder, each a header that opens and closes
+it, closed to begin with and remembered while the shell runs. The local
+groups are the top-level folders of `~/Pictures/wallpapers`, with the images
+loose in it as a group of its own; after them come the distribution's,
+`/usr/share/wallpapers`, as `system` for its loose images and `Plasma` for
+its packages. A Plasma package holds one picture at several resolutions,
+some in a dark version too, so it shows once: dark where there is one, then
+the largest, named after the package rather than its `5120x2880.png`.
+
+The list is flat, one row of thumbnails per item, so only the rows in view
+exist and decode, and a folder of hundreds loads as it scrolls. Opening or
+closing a folder inserts or removes just its rows, which keeps the scroll
+position and the thumbnails around it. A first version made each folder one
+item, which loaded every thumbnail of a folder the moment it opened.
+
+Each thumbnail's border says how it will look on the targeted screen, from
+its real size, which `file` reads from the header after each scan (half a
+second for a thousand). `swaybg -m fill` scales to cover, so the measure is
+how far it enlarges: not at all is green, up to 1.33x yellow, past that red.
+Under the mock screens a note in the same colour says why, for the hovered
+image or else the staged one. The staged image keeps the accent border.
+
`wallp` does the actual work, including running `udt-accent`, so the accent
follows the new wallpaper exactly as it does from a terminal. This panel
replaces its qarma file dialog, not the script.
diff --git a/appearance/Wallpapers.qml b/appearance/Wallpapers.qml
index 0317ea5..9f43675 100644
--- a/appearance/Wallpapers.qml
+++ b/appearance/Wallpapers.qml
@@ -23,6 +23,10 @@ Singleton {
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"
@@ -64,20 +68,137 @@ Singleton {
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",
- `find ${JSON.stringify(root.dir)} -maxdepth 2 -type f ` +
- `\\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.webp' \\) ` +
- `-not -path '*/.*' -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2-`]
+ `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: ""