feat(wallpaper selector): add wallpaper selector menu

This commit is contained in:
sinnayuh
2025-08-15 17:33:10 +01:00
parent ce114b33ff
commit 28a6284968
5 changed files with 325 additions and 0 deletions
@@ -0,0 +1,244 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Hyprland
// Fullscreen panel similar to Overview, but shows a grid of wallpaper previews.
Scope {
id: scope
Variants {
id: variants
model: Quickshell.screens
PanelWindow {
id: root
required property var modelData
readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen)
property bool monitorIsFocused: (Hyprland.focusedMonitor?.id == monitor?.id)
screen: modelData
visible: GlobalStates.wallpaperOverviewOpen && monitorIsFocused
WlrLayershell.namespace: "quickshell:wallpaper-overview"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
color: "transparent"
anchors { top: true; bottom: true; left: true; right: true }
ColumnLayout {
id: layout
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
spacing: 8
Item { width: 1; height: 1 }
Rectangle {
id: bg
focus: true
color: Appearance.colors.colLayer0
border.width: 1
border.color: Appearance.colors.colLayer0Border
radius: Appearance.rounding.screenRounding
// Compact size for 4 thumbnails per row, 3 rows high
implicitWidth: Math.min(root.width * 0.7, 900)
implicitHeight: Math.min(root.height * 0.6, 500)
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Escape) {
GlobalStates.wallpaperOverviewOpen = false
event.accepted = true
} else if (event.key === Qt.Key_Left) {
grid.moveSelection(-1)
event.accepted = true
} else if (event.key === Qt.Key_Right) {
grid.moveSelection(1)
event.accepted = true
} else if (event.key === Qt.Key_Up) {
grid.moveSelection(-grid.columns)
event.accepted = true
} else if (event.key === Qt.Key_Down) {
grid.moveSelection(grid.columns)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
grid.activateCurrent()
event.accepted = true
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 8
GridView {
id: grid
readonly property int columns: 4 // Fixed to 4 columns
property int currentIndex: 0
readonly property int rows: Math.max(1, Math.ceil(count / columns))
Layout.preferredWidth: columns * cellWidth
Layout.alignment: Qt.AlignHCenter
Layout.fillHeight: true
cellWidth: 220
cellHeight: 140
clip: true
interactive: true
keyNavigationWraps: true
boundsBehavior: Flickable.StopAtBounds
// Performance optimization: cache more delegates for smoother scrolling
cacheBuffer: cellHeight * 2 // Cache 2 extra rows above/below visible area
ScrollBar.horizontal: ScrollBar {
policy: ScrollBar.AsNeeded
visible: false
}
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
visible: false
}
// Back to simple wallpapers array
model: Wallpapers.wallpapers
onModelChanged: currentIndex = 0
function moveSelection(delta) {
// Clear all hover states when using keyboard navigation
for (let i = 0; i < count; i++) {
const item = itemAtIndex(i)
if (item) {
item.isHovered = false
}
}
currentIndex = Math.max(0, Math.min(count - 1, currentIndex + delta))
positionViewAtIndex(currentIndex, GridView.Contain)
}
function activateCurrent() {
const path = Wallpapers.wallpapers[currentIndex]
if (!path) return
GlobalStates.wallpaperOverviewOpen = false
Wallpapers.apply(path)
}
delegate: Item {
width: grid.cellWidth
height: grid.cellHeight
property bool isHovered: false
Rectangle {
anchors.fill: parent
radius: Appearance.rounding.windowRounding
color: Appearance.colors.colLayer1
border.width: (index === grid.currentIndex || parent.isHovered) ? 3 : 0
border.color: Appearance.colors.colSecondary
}
Rectangle {
anchors.fill: parent
anchors.margins: 8
color: Appearance.colors.colLayer2
radius: Appearance.rounding.elementRounding
// Loading placeholder
Rectangle {
anchors.centerIn: parent
width: Math.min(parent.width * 0.4, 32)
height: Math.min(parent.height * 0.4, 32)
radius: Appearance.rounding.elementRounding
color: Appearance.colors.colLayer3
visible: thumbnailImage.status !== Image.Ready
// Simple loading animation
opacity: 0.3
SequentialAnimation on opacity {
running: parent.visible
loops: Animation.Infinite
NumberAnimation { to: 1.0; duration: 800; easing.type: Easing.InOutSine }
NumberAnimation { to: 0.3; duration: 800; easing.type: Easing.InOutSine }
}
}
Image {
id: thumbnailImage
anchors.fill: parent
source: `file://${modelData}`
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
smooth: true
// Much smaller sourceSize for faster loading - this is key!
// Using smaller dimensions significantly reduces decode time
sourceSize.width: Math.min(128, grid.cellWidth - 16)
sourceSize.height: Math.min(96, grid.cellHeight - 16)
// Disable mipmap for faster loading (quality vs speed tradeoff)
mipmap: false
// Smooth fade-in when ready
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: {
// Clear all other hover states and set current index
for (let i = 0; i < grid.count; i++) {
const item = grid.itemAtIndex(i)
if (item && item !== parent) {
item.isHovered = false
}
}
parent.isHovered = true
grid.currentIndex = index
}
onExited: {
parent.isHovered = false
}
onClicked: {
GlobalStates.wallpaperOverviewOpen = false
Wallpapers.apply(modelData)
}
}
}
}
}
}
}
Connections {
target: GlobalStates
function onWallpaperOverviewOpenChanged() {
if (GlobalStates.wallpaperOverviewOpen && monitorIsFocused) {
bg.forceActiveFocus();
}
}
}
}
}
GlobalShortcut {
name: "wallpaperOverviewToggle"
description: "Toggle wallpaper overview"
onPressed: { GlobalStates.wallpaperOverviewOpen = !GlobalStates.wallpaperOverviewOpen }
}
}