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
279 lines
11 KiB
QML
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
|
|
}
|
|
}
|