diff options
Diffstat (limited to 'appearance')
| -rw-r--r-- | appearance/AppearancePanel.qml | 222 | ||||
| -rw-r--r-- | appearance/README.md | 36 | ||||
| -rw-r--r-- | appearance/Wallpapers.qml | 127 |
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: "" |
