From 28a6284968ddb28c30372eb86f8dabd6f7e04b5d Mon Sep 17 00:00:00 2001 From: sinnayuh Date: Fri, 15 Aug 2025 17:33:10 +0100 Subject: [PATCH] feat(wallpaper selector): add wallpaper selector menu --- .config/hypr/hyprland/keybinds.conf | 1 + .config/quickshell/ii/GlobalStates.qml | 1 + .../wallpaperOverview/WallpaperOverview.qml | 244 ++++++++++++++++++ .config/quickshell/ii/services/Wallpapers.qml | 76 ++++++ .config/quickshell/ii/shell.qml | 3 + 5 files changed, 325 insertions(+) create mode 100644 .config/quickshell/ii/modules/wallpaperOverview/WallpaperOverview.qml create mode 100644 .config/quickshell/ii/services/Wallpapers.qml diff --git a/.config/hypr/hyprland/keybinds.conf b/.config/hypr/hyprland/keybinds.conf index c7e953d94..9c9529a69 100644 --- a/.config/hypr/hyprland/keybinds.conf +++ b/.config/hypr/hyprland/keybinds.conf @@ -25,6 +25,7 @@ bindit = ,Super_R, global, quickshell:workspaceNumber # [hidden] bindd = Super, V, Clipboard history >> clipboard, global, quickshell:overviewClipboardToggle # Clipboard history >> clipboard bindd = Super, Period, Emoji >> clipboard, global, quickshell:overviewEmojiToggle # Emoji >> clipboard bindd = Super, Tab, Toggle overview, global, quickshell:overviewToggle # [hidden] Toggle overview/launcher (alt) +bindd = Super, Comma, Toggle wallpaper overview, global, quickshell:wallpaperOverviewToggle # Wallpaper overview bindd = Super, A, Toggle left sidebar, global, quickshell:sidebarLeftToggle # Toggle left sidebar bind = Super+Alt, A, global, quickshell:sidebarLeftToggleDetach # [hidden] bind = Super, B, global, quickshell:sidebarLeftToggle # [hidden] diff --git a/.config/quickshell/ii/GlobalStates.qml b/.config/quickshell/ii/GlobalStates.qml index 3f7468f4c..57ce3b4cc 100644 --- a/.config/quickshell/ii/GlobalStates.qml +++ b/.config/quickshell/ii/GlobalStates.qml @@ -17,6 +17,7 @@ Singleton { property bool osdVolumeOpen: false property bool oskOpen: false property bool overviewOpen: false + property bool wallpaperOverviewOpen: false property bool screenLocked: false property bool screenLockContainsCharacters: false property bool screenUnlockFailed: false diff --git a/.config/quickshell/ii/modules/wallpaperOverview/WallpaperOverview.qml b/.config/quickshell/ii/modules/wallpaperOverview/WallpaperOverview.qml new file mode 100644 index 000000000..233ae9534 --- /dev/null +++ b/.config/quickshell/ii/modules/wallpaperOverview/WallpaperOverview.qml @@ -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 } + } +} + + diff --git a/.config/quickshell/ii/services/Wallpapers.qml b/.config/quickshell/ii/services/Wallpapers.qml new file mode 100644 index 000000000..d2db55141 --- /dev/null +++ b/.config/quickshell/ii/services/Wallpapers.qml @@ -0,0 +1,76 @@ +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import Qt.labs.folderlistmodel +import Quickshell +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * Provides a list of wallpapers and an "apply" action that calls the existing + * switchwall.sh script. Uses QML's built-in image scaling for thumbnails. + */ +Singleton { + id: root + + // Directory to search for wallpapers (new location) + // Resolves to: ~/Pictures/wallpapers + property list searchDirs: [ FileUtils.trimFileProtocol(`${Directories.pictures}/wallpapers`) ] + + // Supported image extensions. Videos are intentionally excluded for now + // to keep the overview lightweight. + readonly property list extensions: [ + "jpg", "jpeg", "png", "webp", "avif", "bmp" + ] + + // Resulting list of absolute file paths (without file:// prefix) + property list wallpapers: [] + + // Public API (FolderListModel driven) + function reload() { + files.folder = `file://${root.searchDirs[0]}` + } + onSearchDirsChanged: reload() + + function apply(path) { + if (!path || path.length === 0) return + applyProc.command = [ + "bash", "-c", + `${StringUtils.shellSingleQuoteEscape(Directories.wallpaperSwitchScriptPath)} ` + + `--image ${StringUtils.shellSingleQuoteEscape(path)}` + ] + applyProc.running = true + } + + // Folder model + FolderListModel { + id: files + nameFilters: extensions.map(ext => `*.${ext}`) + showDirs: false + showDotAndDotDot: false + showOnlyReadable: true + sortField: FolderListModel.Time + sortReversed: true + onCountChanged: { + console.log(`[Wallpapers] FolderListModel count=${files.count} folder=${files.folder}`) + root.wallpapers = [] + for (let i = 0; i < files.count; i++) { + const path = files.get(i, "filePath") || FileUtils.trimFileProtocol(files.get(i, "fileURL")) + if (path && path.length) root.wallpapers.push(path) + } + } + } + + // Expose the model for direct binding when needed + property alias filesModel: files + + // Internal: applying a wallpaper + Process { + id: applyProc + } + + Component.onCompleted: reload() +} + + diff --git a/.config/quickshell/ii/shell.qml b/.config/quickshell/ii/shell.qml index 8186d1a56..45532738c 100644 --- a/.config/quickshell/ii/shell.qml +++ b/.config/quickshell/ii/shell.qml @@ -18,6 +18,7 @@ import "./modules/notificationPopup/" import "./modules/onScreenDisplay/" import "./modules/onScreenKeyboard/" import "./modules/overview/" +import "./modules/wallpaperOverview/" import "./modules/screenCorners/" import "./modules/session/" import "./modules/sidebarLeft/" @@ -43,6 +44,7 @@ ShellRoot { property bool enableOnScreenDisplayVolume: true property bool enableOnScreenKeyboard: true property bool enableOverview: true + property bool enableWallpaperOverview: true property bool enableReloadPopup: true property bool enableScreenCorners: true property bool enableSession: true @@ -69,6 +71,7 @@ ShellRoot { LazyLoader { active: enableOnScreenDisplayVolume; component: OnScreenDisplayVolume {} } LazyLoader { active: enableOnScreenKeyboard; component: OnScreenKeyboard {} } LazyLoader { active: enableOverview; component: Overview {} } + LazyLoader { active: enableWallpaperOverview; component: WallpaperOverview {} } LazyLoader { active: enableReloadPopup; component: ReloadPopup {} } LazyLoader { active: enableScreenCorners; component: ScreenCorners {} } LazyLoader { active: enableSession; component: Session {} }