aboutsummaryrefslogtreecommitdiffstats
path: root/appearance/Wallpapers.qml
blob: 9f43675e69f55be03175324ac954bf7a20c73c9c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// 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

// 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<string> 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}`));
        }
    }
}