aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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: ""