aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/appearance/Wallpapers.qml
blob: 73c1f25d878f4ed31eeb65839101eec3bece32d8 (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
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
// 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
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<string> files: []
    property bool scanning: true
    property bool applying: false
    signal applied(bool ok, string message)
    // Sent before wallp runs, while current still names the old picture, so
    // WallpaperSpread can cover the swap with it.
    signal swapping(string which, string from, string to)
    // Every run's end, Apply's or the engine's. applied is Apply's alone: it
    // reports to the save bar and closes the window.
    signal finished(bool ok)
    // A successful Apply, with what it set ("" for a screen it left).
    signal manualApplied(string h, string v)

    // 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.");
    }

    // Real-ESRGAN through stable-diffusion.cpp's sd-cli, for an image too
    // small for its screen. The file is replaced in place, so wallp's state
    // and the engine's lists, which name it by path, still find it. sd-cli
    // writes to a dot-named sibling first, which the scan skips, and only a
    // successful run is moved over the original, so a failure leaves it
    // untouched. The name keeps its extension because sd-cli writes the
    // format the extension names. Only files under `dir`: the system ones
    // are not ours to rewrite. Success is no [E] line as well as exit 0: a
    // model that fails to load logs "[E] new_upscaler_ctx failed", saves
    // the input unscaled and exits 0 (measured).
    //
    // Which Real-ESRGAN model is decided first, as its own step, so the page
    // can show the choice before anything is rewritten. Imajev-4B makes it,
    // a small Jev-style vision decision model, run once through llama-mtmd-cli on a
    // 512 px thumbnail: the answer is held to one token, A or B, by a
    // grammar, so greedy decoding returns the option with the highest logit,
    // which is the decision. About 1.7 s. Anime goes to the anime model,
    // which keeps line art clean; everything else, and any failure of the
    // decider, goes to the general one, safe on any image. Measured on 22
    // wallpapers it got every anime and photo right and called 4 of 6
    // painted 3D renders anime, which then come out smoother than painted;
    // the 2B, and a three-way prompt adding "painted or render", did worse.
    // ponytail: argmax only, so Imajev's calibration temperature is not
    // needed. One 4x pass, which covers both screens unless the image is
    // under 640 px wide or 480 px tall; --upscale-repeats if that matters.
    readonly property string upscalers: "/data/LLM-models/SD/upscalers"
    readonly property string decider: "/data/LLM-models/SD/imajev/imajev-4b"
    property string upscaling: ""
    property string upscaleError: ""
    function canUpscale(path, which) {
        return path.startsWith(dir + "/") && upscale(path, which) > 1;
    }
    function sibling(path) { return path.slice(0, path.lastIndexOf("/") + 1) + "." + basename(path); }

    // The decision for one image: decidedFor is the path it was made for, so
    // staging another image leaves it standing for the first and unread.
    property string deciding: ""
    property string decidedFor: ""
    property string decidedKind: ""   // "anime" or "photo"
    function decide(path) {
        if (deciding !== "" || upscaling !== "" || path === "") return;
        deciding = path;
        upscaleError = "";
        decideProc.command = ["sh", "-c",
            'magick "$1" -resize 512x512 "$2" 2>/dev/null; '
            + 'llama-mtmd-cli -m "$3-Q4_K_M.gguf" --mmproj "$3-mmproj-f16.gguf" --image "$2" '
            + '-ngl 99 --temp 0 -n 1 --grammar "$5" -p "$4" 2>/dev/null; rm -f "$2"',
            "sh", path, sibling(path) + ".jpg", decider,
            "Is this image A) anime, cartoon or illustration art, or B) a photograph or realistic render? Answer A or B.",
            'root ::= "A" | "B"'];
        decideProc.running = true;
    }
    Process {
        id: decideProc
        stdout: StdioCollector { id: decideOut }
        onExited: {
            root.decidedKind = decideOut.text.trim().endsWith("A") ? "anime" : "photo";
            root.decidedFor = root.deciding;
            root.deciding = "";
        }
    }

    function upscaleFile(path) {
        if (upscaling !== "" || path === "" || path !== decidedFor) return;
        upscaling = path;
        upscaleError = "";
        upscaleProc.command = ["sh", "-c",
            'log=$(sd-cli -M upscale --upscale-model "$1" -i "$2" -o "$3" 2>&1); s=$?; '
            + 'err=$(printf "%s\\n" "$log" | grep -F "[E]"); '
            + 'if [ $s -eq 0 ] && [ -z "$err" ]; then mv -f "$3" "$2"; '
            + 'else rm -f "$3"; echo "${err:-sd-cli exited $s}" >&2; exit 1; fi',
            "sh", `${upscalers}/RealESRGAN_x4plus${decidedKind === "anime" ? "_anime_6B" : ""}.pth`,
            path, sibling(path)];
        upscaleProc.running = true;
    }
    Process {
        id: upscaleProc
        stderr: StdioCollector { id: upscaleErr }
        onExited: code => {
            if (code !== 0) root.upscaleError = `upscaling ${root.basename(root.upscaling)} failed: ${upscaleErr.text.trim()}`;
            root.decidedFor = "";
            root.upscaling = "";
            // The new size clears the quality note and the red border.
            root.refresh();
        }
    }

    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 (dirty) run(pendingH, pendingV, "manual");
    }

    // What started the run in flight: "manual" (Apply), "tick" (the engine's
    // timer) or "home" (the engine switched off). A tick is the light run,
    // UDT_ROTATION, which udt-accent reads; the others are full.
    property string kind: ""

    // false when nothing was started: a run in flight, or nothing to set.
    function run(h, v, kind) {
        if (applying || (h === "" && v === "")) return false;
        applying = true;
        root.kind = kind;
        let covers = 0;
        if (h !== "" && currentH !== "" && h !== currentH) { covers++; swapping("H", currentH, h); }
        if (v !== "" && currentV !== "" && v !== currentV) { covers++; swapping("V", currentV, v); }
        const args = [];
        if (h !== "") args.push(`H=${JSON.stringify(h)}`);
        if (v !== "") args.push(`V=${JSON.stringify(v)}`);
        const env = kind === "tick" ? "UDT_ROTATION=1 " : "";
        applyProc.command = ["sh", "-c", `${env}wallp --set ${args.join(" ")} >/dev/null 2>&1`];
        uncovered = covers;
        if (covers === 0) applyProc.running = true;
        else coverWait.restart();
        return true;
    }

    // Covers still to come up before wallp may swap swaybg under them. The
    // cover decodes its pictures asynchronously, and starting wallp with
    // swapping showed the new picture through the still empty cover for a
    // moment, then the old one over it, then the burn. WallpaperSpread
    // calls covered() once it is drawing; a cover that never comes (a
    // screen missing, a picture that fails to load) is waited out.
    property int uncovered: 0
    function covered() {
        if (uncovered === 0) return;
        if (--uncovered === 0) {
            coverWait.stop();
            applyProc.running = true;
        }
    }
    Timer {
        id: coverWait
        interval: 1500
        onTriggered: if (root.uncovered > 0) {
            root.uncovered = 0;
            applyProc.running = true;
        }
    }

    Process {
        id: applyProc
        stderr: StdioCollector { id: applyErr }
        onExited: code => {
            const ok = code === 0;
            root.applying = false;
            if (root.kind === "manual") {
                if (ok) {
                    root.manualApplied(root.pendingH, root.pendingV);
                    root.clearPending();
                }
                root.applied(ok, ok ? "" : (applyErr.text.trim() || `wallp exited ${code}`));
            } else if (!ok) {
                console.warn(`Wallpapers: the engine's ${root.kind} set failed, wallp exited ${code}`);
            }
            root.finished(ok);
        }
    }
}