// Copyright (C) 2026 Danilo M. // // 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. import Quickshell import QtQuick import "../.." // Wallpapers by folder, staged per screen and set together with Apply, beside // a mock of the two screens above them. Sized by its host: the thumbnail list scrolls // itself, so it needs a height rather than the page's scroll. Column { id: tab spacing: 16 // What the mock screens show. Hovering a thumbnail previews it on the // targeted screen; the other keeps whatever is currently set. property string hovering: "" property string notice: "" // Close the window: after a wallpaper is set, so the desk shows, and when // wallhaven-dl opens. signal dismiss Connections { target: Wallpapers function onApplied(ok, message) { tab.notice = ok ? "" : `wallpaper failed: ${message}`; if (ok) tab.dismiss(); } } // The two screens as they sit on the desk: DP-3 rotated upright on // the left, DP-1 wide on the right, centred against each other the // way Hyprland has them. Hovering a thumbnail fills the screen it // would be set on; the other keeps what is on it now. Beside them, // what the image under consideration will look like, and the actions. Item { id: mockPane width: parent.width height: screens.height // Half the width for the two panels side by side plus the gap, // capped so the upright DP-3 stays under 200px and the list keeps // the room; the bezels and stands are drawn outside that. readonly property real scale: Math.min(width * 0.5 / (1080 + 2560 + 160), 200 / 1920) Row { id: screens spacing: Math.round(160 * mockPane.scale) MockScreen { anchors.verticalCenter: parent.verticalCenter label: "DP-3" targeted: Wallpapers.target === "V" source: (targeted && tab.hovering !== "") ? tab.hovering : Wallpapers.shown("V") // DP-3 has transform=1, so it stands upright. panelWidth: Math.round(1080 * mockPane.scale) panelHeight: Math.round(1920 * mockPane.scale) } MockScreen { anchors.verticalCenter: parent.verticalCenter label: "DP-1" targeted: Wallpapers.target === "H" source: (targeted && tab.hovering !== "") ? tab.hovering : Wallpapers.shown("H") panelWidth: Math.round(2560 * mockPane.scale) panelHeight: Math.round(1080 * mockPane.scale) } } Column { anchors { left: screens.right; leftMargin: 24; right: parent.right; verticalCenter: parent.verticalCenter } spacing: 10 Text { width: parent.width wrapMode: Text.Wrap text: tab.hovering !== "" ? Wallpapers.label(tab.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: tab.hovering !== "" ? tab.hovering : Wallpapers.target === "V" ? Wallpapers.pendingV : Wallpapers.pendingH width: parent.width visible: text !== "" wrapMode: Text.Wrap text: subject !== "" ? Wallpapers.qualityNote(subject, Wallpapers.target) : "" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Wallpapers.qualityColor(subject, Wallpapers.target) } // A Flow, not a Row: a Row overflows its width and clipped the // last button in a narrow window (AGENTS.md). Flow { width: parent.width spacing: 8 Tab { text: Wallpapers.applying ? "applying…" : "Apply" selected: Wallpapers.dirty && !Wallpapers.applying enabled: Wallpapers.dirty && !Wallpapers.applying onClicked: Wallpapers.apply() } Tab { text: "Reset" enabled: Wallpapers.dirty && !Wallpapers.applying onClicked: Wallpapers.clearPending() } // wallhaven-dl searches wallhaven.cc and finds larger // copies of low-res wallpapers. Launched through its // desktop entry, which needs no ~/bin on quickshell's PATH // and keeps the app's own taskbar icon. The window closes // so the app is not opened underneath it. Tab { text: "Open wallhaven-dl" onClicked: { Quickshell.execDetached(["gtk-launch", "wallhaven-dl"]); tab.dismiss(); } } } } } Column { width: parent.width height: parent.height - mockPane.height - parent.spacing spacing: 12 Text { visible: tab.notice !== "" width: parent.width wrapMode: Text.Wrap text: tab.notice font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.red } Row { spacing: 8 Text { anchors.verticalCenter: parent.verticalCenter text: "Set on" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.subtext } Tab { text: "Horizontal" selected: Wallpapers.target === "H" onClicked: Wallpapers.target = "H" } Tab { text: "Vertical" selected: Wallpapers.target === "V" onClicked: Wallpapers.target = "V" } Text { anchors.verticalCenter: parent.verticalCenter text: Wallpapers.scanning ? "scanning…" : Wallpapers.applying ? "applying…" : Wallpapers.files.length + " wallpapers" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } color: Theme.overlay } } // 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 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 { 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 { visible: row.start < 0 anchors { fill: parent; topMargin: 6 } radius: 8 color: Qt.alpha(Theme.surface, headerHover.hovered ? 0.8 : 0.4) HoverHandler { id: headerHover } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: wallList.toggle(row.index) } 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 } } 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: tab.hovering = hovered ? modelData : (tab.hovering === modelData ? "" : tab.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 } } } } } } } } } }