Desktop & Dock becomes Shell — Bar, Dock, Control Center, Tiling, Workspaces — the home for everything Quickshell draws. The settings- management cluster moves to System as Sync & Backup, Appearance's Shell tab dissolves, and 24-hour time finally lives on Date & Time, which always owned it. The bar gets what it never had: a way to survive the wallpaper. A second neutral text family (follow theme, or forced light or dark), a one-layer shadow under every glyph, and a gradient scrim for wallpapers nothing else survives — all off by default, pixel-identical until asked. Widgets earn toggles (weather, media, clipboard, calendar countdown), the vitals cluster stops leaving a dead pill behind, and Control Center's sections learn to step aside. The dock graduates from MVP: a context menu with window rows, pin, unpin, quit and new-window; scroll an icon to cycle its windows; drag to reorder on the dock itself; hover previews with one-shot captures; and "Add App to Dock" in the launcher. Three real bugs died en route — menus that slid away with the autohide, a readonly-property crash on every menu open, and a drag that drifted half a slot per icon on side docks. The pinned-apps editor in Settings becomes a drag strip. 166 contracts; the full suite is green except two live display and switcher tests that cannot run behind a locked session — re-verified on unlock. Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
285 lines
12 KiB
QML
285 lines
12 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.
|
|
// The test seam: a harness that supplies its own stills sets this false,
|
|
// and the picker then never touches the VideoWallpaper singleton at all —
|
|
// merely instantiating it would fire its startup restore inside the
|
|
// harness and play the machine's real wallpaper in a test instance.
|
|
property bool videoAware: true
|
|
|
|
readonly property var catalog: root.mode === "single" && root.videoAware
|
|
? 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 (root.videoAware && 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: root.videoAware && 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
|
|
}
|
|
}
|