aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/appearance/WallpaperTab.qml
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/appearance/WallpaperTab.qml')
-rw-r--r--desktop/modules/appearance/WallpaperTab.qml357
1 files changed, 357 insertions, 0 deletions
diff --git a/desktop/modules/appearance/WallpaperTab.qml b/desktop/modules/appearance/WallpaperTab.qml
new file mode 100644
index 0000000..72d4dfe
--- /dev/null
+++ b/desktop/modules/appearance/WallpaperTab.qml
@@ -0,0 +1,357 @@
+// 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.
+
+import Quickshell
+import QtQuick
+import "../.."
+
+// Wallpapers by folder, staged per screen and set together with Apply, beside
+// a mock of the two screens. Sized by its host: the thumbnail list scrolls
+// itself, so it needs a height rather than the page's scroll.
+Row {
+ id: tab
+ spacing: 20
+
+ // 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();
+ }
+ }
+
+ Column {
+ width: parent.width - mockPane.width - parent.spacing
+ height: parent.height
+ 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
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // 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.
+ Item {
+ id: mockPane
+ // A third of the panel rather than a fixed width, so the
+ // preview keeps its share when the panel is resized.
+ width: Math.round(parent.width * 0.33)
+ height: parent.height
+
+ // Divisor is the two panels side by side plus the gap; the
+ // bezels and stands are drawn outside that, hence the margin.
+ readonly property real scale: (width * 0.92) / (1080 + 2560 + 160)
+
+ Column {
+ anchors.centerIn: parent
+ spacing: 14
+
+ Row {
+ 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)
+ }
+ }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: mockPane.width
+ horizontalAlignment: Text.AlignHCenter
+ 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
+ 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)
+ }
+
+ // 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 panel closes
+ // so the app window is not opened under the overlay.
+ Tab {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Open wallhaven-dl"
+ onClicked: {
+ Quickshell.execDetached(["gtk-launch", "wallhaven-dl"]);
+ tab.dismiss();
+ }
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ 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()
+ }
+ }
+ }
+ }
+}