Files
Panama/config/dot/quickshell/modules/settings/WallpaperPicker.qml
T
Gabriel Brown 79b3d5cb85 Close the sweep's last blind spot, and stop shortcuts silently colliding
gapsIn and gapsOut were the only two compositor settings the write sweep had
never verified: Hyprland answers for them in CSS shorthand, "5 5 5 5", and the
sweep had no way to compare that. The preference behind each is a single int
that Hyprland expands to four sides, so a uniform reading compares exactly. A
non-uniform one is not something the preference can express, and is skipped
rather than collapsed to a number it never wrote. 63 of 63 verified live now,
none skipped.

Wallpaper thumbnails are cached. The report that five of them sat at "Loading…"
was a screenshot taken 1.1 seconds after the page opened -- decoding one of
these at tile size takes between 1.2 and 2.6 seconds and about ten start at
once, which the code already said. Measuring it did turn up something real
though: without a cache, scrolling back up pays that decode again for every
tile. The tradeoff is a wallpaper replaced in place showing a stale thumbnail
until restart, which is worth it for a directory of files that are added rather
than edited.

A chord already in use is now named rather than taken: "Super+Q is already
Terminal". Two actions on one chord means whichever Hyprland reads last wins,
which is not a thing to find out later by pressing it. Rebinding a shortcut to
the chord it already holds is correctly not a conflict.

Also: Open Appearance lands on the Windows tab now that the page has tabs,
Storage points at reclaimable container space, and a dock row shows its desktop
id only when two pinned applications share a name -- it is developer text, and
repeating it under fifteen recognisable names made the list harder to scan.

Written down because it cost the shell: QML has no default parameter values, and
`function openSettings(page: string, section: string = "")` fails the entire
configuration rather than the one function -- so the bar and dock went with it,
and 43 contracts failed at once pointing at the same line. qmllint --bare passes
that, which is why the usual check before touching the running shell did not
catch it. openSettingsSection exists as a separate function for that reason.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 09:56:02 -04:00

227 lines
9.0 KiB
QML

// The wallpaper grid.
//
// A thumbnail grid rather than a file path field: choosing a background is the
// one setting where the value IS the picture, and typing a path to something
// you cannot see is the worst possible version of it.
//
// Images are loaded asynchronously and at a fraction of their real size --
// several of the candidates here are 8-12 MB, and decoding them at full
// resolution to draw a 150px tile would cost more memory than the rest of the
// shell put together.
import QtQuick
import QtQuick.Effects
import qs.config
import qs.services
Item {
id: root
implicitHeight: grid.implicitHeight
// Sixty tiles is two screens of wallpaper on a page that also holds
// typography, window geometry and effects -- everything below it becomes
// unreachable without a long scroll past pictures. Two rows by default,
// all of them on request.
property bool expanded: false
property string mode: DesktopPreferences.get("wallpaperMode")
property string selectedOutput: Wallpaper.outputNames()[0] ?? ""
property var activeByOutput: Wallpaper.activeByOutput
property var slideshowPaths: DesktopPreferences.get("wallpaperSlideshowPaths") ?? []
property var assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({})
property var setSingleAction: function(path) { Wallpaper.setSingle(path); }
property var toggleSlideshowAction: function(path) { Wallpaper.toggleSlideshowPath(path); }
property var setAssignmentAction: function(output, path) { Wallpaper.setAssignment(output, path); }
readonly property int collapsedRows: 2
readonly property var shown: root.expanded
? Wallpaper.available
: Wallpaper.available.slice(0, root.columns * root.collapsedRows)
readonly property int hidden: Wallpaper.available.length - root.shown.length
readonly property int columns: Math.max(2, Math.floor(width / 190))
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
function isCurrent(path: string): bool {
return root.activeByOutput[root.selectedOutput] === path;
}
function selected(path: string): bool {
if (root.mode === "slideshow")
return root.slideshowPaths.includes(path);
if (root.mode === "per-monitor")
return root.assignments[root.selectedOutput] === path;
return root.isCurrent(path);
}
function activate(path: string): void {
if (root.mode === "slideshow")
root.toggleSlideshowAction(path);
else if (root.mode === "per-monitor")
root.setAssignmentAction(root.selectedOutput, path);
else
root.setSingleAction(path);
}
Grid {
id: grid
width: parent.width
columns: root.columns
spacing: 10
Repeater {
model: root.shown
Rectangle {
id: tile
required property var modelData
readonly property bool current: root.isCurrent(tile.modelData)
readonly property bool member: root.mode === "slideshow" && root.selected(tile.modelData)
width: root.cellWidth
height: Math.round(root.cellWidth * 9 / 16)
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.6)
clip: true
border.width: 0
Image {
id: thumbnail
anchors.fill: parent
source: "file://" + tile.modelData
fillMode: Image.PreserveAspectCrop
asynchronous: true
// Decode to roughly the size actually drawn. Without this a
// grid of 12MB photographs decodes at full resolution.
sourceSize.width: 400
sourceSize.height: 240
// Cached, because these are slow: the largest of these files
// takes over two seconds to decode even at this size, and
// without a cache scrolling back up pays that again for
// every tile. What is held is the scaled thumbnail, not the
// original, so the cost of keeping it is small. The tradeoff
// is that a wallpaper replaced in place under the same name
// shows its old thumbnail until the shell restarts -- worth
// it for a directory of files that are added, not edited.
cache: true
// Several of these are 8-12MB originals, so a tile can sit
// empty for a second or two. Fading in on ready makes that
// read as loading rather than as a broken image, and the
// fade runs once per tile rather than continuously.
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutQuad }
}
}
Text {
anchors.centerIn: parent
visible: thumbnail.status === Image.Loading
text: "Loading…"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
anchors.centerIn: parent
width: parent.width - 20
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
visible: thumbnail.status === Image.Error
text: "Could not read this image"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
// The prism marks the active wallpaper, the same way it marks
// the focused window and the selected sidebar entry.
Rectangle {
anchors.fill: parent
radius: parent.radius
visible: tile.current
color: "transparent"
border.width: 2
border.color: Theme.accent
}
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 8
width: 24
height: 24
radius: 12
visible: tile.member
color: Theme.alpha(Theme.bgDark, 0.82)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.16)
Text {
anchors.centerIn: parent
text: "✓"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 14
font.weight: Font.DemiBold
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 26
visible: tile.current || hover.hovered
border.width: 0
gradient: Gradient {
GradientStop { position: 0.0; color: Theme.alpha(Theme.bgDark, 0.0) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.bgDark, 0.88) }
}
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 7
text: tile.current
? "Current wallpaper"
: (tile.member ? "In slideshow" : Wallpaper.titleFor(tile.modelData))
color: tile.current ? Theme.accent : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: tile.current ? Font.DemiBold : Font.Normal
elide: Text.ElideRight
}
}
HoverHandler { id: hover }
TapHandler {
onTapped: root.activate(tile.modelData)
}
}
}
}
Text {
anchors.centerIn: parent
visible: Wallpaper.available.length === 0 && !Wallpaper.scanning
width: parent.width - 40
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: "No images found. Looked in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}