Files
Panama/config/dot/quickshell/modules/settings/WallpaperPicker.qml
T
Gabriel Brown cb7c09d208 Give the desktop real themes, video wallpapers, and honest titlebars
Appearance now opens on Themes: light and dark side by side, each
remembering its own choice, over galleries of ten shipped themes —
Tokyo Moon and Day joined by Moon Rose, Catppuccin, Nord, Gruvbox and
Everforest in both modes. A theme is a complete palette: the catalog
lives in themes.json, Theme.qml reads every color token from the
active record, and one render pipeline carries it to kitty, tmux,
btop, GTK, Vicinae, Firefox's chrome, and the lock screen. The Theme
editor builds new ones from four wells — wheel, hex, or eyedropper —
with derived surfaces, a saturation slider, debounced fine-tune, and
effects that save with the theme. Custom edits finally keep GNOME's
accent, kitty's border, and hyprlock in sync.

Wallpapers can be video: mpvpaper per output, hardware-decoded, muted
and looped, supervised and respawned. Panama owns the pausing — games,
battery, and a bar pill for right now — because the compositor
rebuilds full-screen blur for every frame a video wallpaper draws.
The lock screen gets a still frame.

Titlebars stop lying. GNOME apps get close-only on your chosen side,
the maximize and double-click settings are gone, the Settings window
obeys the same rules, and its titlebar can be turned off entirely.
Typography becomes five labeled dropdowns instead of a wall of
samples.

Contracts updated and written throughout (165 now); per the redesign
workflow none were executed — the full sweep runs once at the end.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-23 23:39:04 -04:00

279 lines
11 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
// Videos join the grid only in single mode — slideshow and per-display
// are hyprpaper's modes and stills-only.
readonly property var catalog: root.mode === "single"
? Wallpaper.available.concat(VideoWallpaper.candidates)
: Wallpaper.available
readonly property var shown: root.expanded
? root.catalog
: root.catalog.slice(0, root.columns * root.collapsedRows)
readonly property int hidden: root.catalog.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 {
if (VideoWallpaper.active)
return VideoWallpaper.path === path;
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 video: VideoWallpaper.isVideo(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
visible: !tile.video
source: tile.video ? "" : "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 }
}
}
// A video tile draws a play badge instead of a thumbnail —
// decoding a frame per tile is exactly the cost this feature
// is designed to avoid paying casually.
Rectangle {
anchors.fill: parent
visible: tile.video
radius: parent.radius
gradient: Gradient {
GradientStop { position: 0; color: Theme.alpha(Theme.accent, 0.14) }
GradientStop { position: 1; color: Theme.alpha(Theme.bgDark, 0.9) }
}
Text {
anchors.centerIn: parent
text: "\u{F040A}"
color: Theme.alpha(Theme.fg, 0.8)
font.family: Theme.fontMono
font.pixelSize: 26
}
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 7
width: videoBadge.implicitWidth + 14
height: 20
radius: Theme.pillRadius
color: Theme.alpha(Theme.bgDark, 0.75)
Text {
id: videoBadge
anchors.centerIn: parent
text: "VIDEO"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: 9
font.weight: Font.DemiBold
font.letterSpacing: 0.5
}
}
}
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 in " + Wallpaper.searchRoots[0] + ". Change the folder above, or put some pictures there."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}