diff --git a/quickshell/Modals/FileBrowser/FileBrowserSidebar.qml b/quickshell/Modals/FileBrowser/FileBrowserSidebar.qml index bc02b3e3..1cf95c7f 100644 --- a/quickshell/Modals/FileBrowser/FileBrowserSidebar.qml +++ b/quickshell/Modals/FileBrowser/FileBrowserSidebar.qml @@ -10,7 +10,7 @@ StyledRect { signal locationSelected(string path) width: 200 - color: Theme.surface + color: Theme.nestedSurface clip: true Column { diff --git a/quickshell/Modals/FileBrowser/FileBrowserSortMenu.qml b/quickshell/Modals/FileBrowser/FileBrowserSortMenu.qml index 961dc258..019e1e6f 100644 --- a/quickshell/Modals/FileBrowser/FileBrowserSortMenu.qml +++ b/quickshell/Modals/FileBrowser/FileBrowserSortMenu.qml @@ -7,13 +7,14 @@ StyledRect { property string sortBy: "name" property bool sortAscending: true + property color surfaceColor: Theme.surfaceContainer signal sortBySelected(string value) signal sortOrderSelected(bool ascending) width: 200 height: sortColumn.height + Theme.spacingM * 2 - color: Theme.surfaceContainer + color: surfaceColor radius: Theme.cornerRadius border.color: Theme.outlineMedium border.width: 1 diff --git a/quickshell/Modules/DankDash/DankDashPopout.qml b/quickshell/Modules/DankDash/DankDashPopout.qml index d98f94a7..e1dc4889 100644 --- a/quickshell/Modules/DankDash/DankDashPopout.qml +++ b/quickshell/Modules/DankDash/DankDashPopout.qml @@ -191,6 +191,10 @@ DankPopout { Keys.onPressed: function (event) { if (event.key === Qt.Key_Escape) { + if (root.currentTabIndex === 2 && wallpaperLoader.item?.handleKeyEvent && wallpaperLoader.item.handleKeyEvent(event)) { + event.accepted = true; + return; + } root.dashVisible = false; event.accepted = true; return; diff --git a/quickshell/Modules/DankDash/WallpaperTab.qml b/quickshell/Modules/DankDash/WallpaperTab.qml index 2f844ddc..be1b374b 100644 --- a/quickshell/Modules/DankDash/WallpaperTab.qml +++ b/quickshell/Modules/DankDash/WallpaperTab.qml @@ -31,9 +31,46 @@ Item { property string selectedFileName: "" property var targetScreen: null property string targetScreenName: targetScreen ? targetScreen.name : "" + // Shared with the wallpaper FileBrowser via CacheData.fileBrowserSettings["wallpaper"] + property string sortBy: "name" + property bool sortAscending: true + // Forces the page grid to rebuild when the folder model reorders in place. + property int gridRevision: 0 signal requestTabChange(int newIndex) + function refreshAfterSort() { + // Defer until FolderListModel finishes reordering. + Qt.callLater(() => { + gridRevision++; + if (visible && active) { + setInitialSelection(); + } + updateSelectedFileName(); + }); + } + + onSortByChanged: refreshAfterSort() + onSortAscendingChanged: refreshAfterSort() + + function loadSort() { + const s = CacheData.fileBrowserSettings["wallpaper"]; + if (s) { + sortBy = s.sortBy || "name"; + sortAscending = s.sortAscending !== undefined ? s.sortAscending : true; + } + } + + function persistSort() { + let settings = CacheData.fileBrowserSettings; + if (!settings["wallpaper"]) + settings["wallpaper"] = {}; + settings["wallpaper"].sortBy = sortBy; + settings["wallpaper"].sortAscending = sortAscending; + CacheData.fileBrowserSettings = settings; + CacheData.saveCache(); + } + function getCurrentWallpaper() { if (SessionData.perMonitorWallpaper && targetScreenName) { return SessionData.getMonitorWallpaper(targetScreenName); @@ -68,16 +105,62 @@ Item { } Component.onCompleted: { + loadSort(); loadWallpaperDirectory(); } + Connections { + target: CacheData + function onFileBrowserSettingsChanged() { + loadSort(); + } + } + onActiveChanged: { if (active && visible) { setInitialSelection(); } } + function goToNextCell(visibleCount) { + if (gridIndex + 1 < visibleCount) { + gridIndex++; + } else if (currentPage < totalPages - 1) { + gridIndex = 0; + currentPage++; + } else if (totalPages > 1) { + gridIndex = 0; + currentPage = 0; + } + } + + function goToPrevCell() { + if (gridIndex > 0) { + gridIndex--; + } else if (currentPage > 0) { + currentPage--; + const prevPageCount = Math.min(itemsPerPage, wallpaperFolderModel.count - currentPage * itemsPerPage); + gridIndex = prevPageCount - 1; + } else if (totalPages > 1) { + currentPage = totalPages - 1; + const lastPageCount = Math.min(itemsPerPage, wallpaperFolderModel.count - currentPage * itemsPerPage); + gridIndex = lastPageCount - 1; + } + } + + function closeOverlays() { + if (sortMenu.visible || pageJumpPopup.visible) { + sortMenu.visible = false; + pageJumpPopup.visible = false; + return true; + } + return false; + } + function handleKeyEvent(event) { + if (event.key === Qt.Key_Escape) { + return closeOverlays(); + } const columns = 4; const currentCol = gridIndex % columns; const visibleCount = Math.min(itemsPerPage, wallpaperFolderModel.count - currentPage * itemsPerPage); @@ -97,40 +180,18 @@ Item { if (event.key === Qt.Key_Right || event.key === Qt.Key_L) { if (I18n.isRtl) { - if (gridIndex > 0) { - gridIndex--; - } else if (currentPage > 0) { - currentPage--; - const prevPageCount = Math.min(itemsPerPage, wallpaperFolderModel.count - currentPage * itemsPerPage); - gridIndex = prevPageCount - 1; - } + goToPrevCell(); } else { - if (gridIndex + 1 < visibleCount) { - gridIndex++; - } else if (currentPage < totalPages - 1) { - gridIndex = 0; - currentPage++; - } + goToNextCell(visibleCount); } return true; } if (event.key === Qt.Key_Left || event.key === Qt.Key_H) { if (I18n.isRtl) { - if (gridIndex + 1 < visibleCount) { - gridIndex++; - } else if (currentPage < totalPages - 1) { - gridIndex = 0; - currentPage++; - } + goToNextCell(visibleCount); } else { - if (gridIndex > 0) { - gridIndex--; - } else if (currentPage > 0) { - currentPage--; - const prevPageCount = Math.min(itemsPerPage, wallpaperFolderModel.count - currentPage * itemsPerPage); - gridIndex = prevPageCount - 1; - } + goToPrevCell(); } return true; } @@ -141,6 +202,9 @@ Item { } else if (currentPage < totalPages - 1) { gridIndex = currentCol; currentPage++; + } else if (totalPages > 1) { + gridIndex = currentCol; + currentPage = 0; } return true; } @@ -154,19 +218,25 @@ Item { const prevPageRows = Math.ceil(prevPageCount / columns); gridIndex = (prevPageRows - 1) * columns + currentCol; gridIndex = Math.min(gridIndex, prevPageCount - 1); + } else if (totalPages > 1) { + currentPage = totalPages - 1; + const lastPageCount = Math.min(itemsPerPage, wallpaperFolderModel.count - currentPage * itemsPerPage); + const lastPageRows = Math.ceil(lastPageCount / columns); + gridIndex = (lastPageRows - 1) * columns + currentCol; + gridIndex = Math.min(gridIndex, lastPageCount - 1); } return true; } - if (event.key === Qt.Key_PageUp && currentPage > 0) { + if (event.key === Qt.Key_PageUp && totalPages > 1) { gridIndex = 0; - currentPage--; + currentPage = (currentPage - 1 + totalPages) % totalPages; return true; } - if (event.key === Qt.Key_PageDown && currentPage < totalPages - 1) { + if (event.key === Qt.Key_PageDown && totalPages > 1) { gridIndex = 0; - currentPage++; + currentPage = (currentPage + 1) % totalPages; return true; } @@ -280,6 +350,17 @@ Item { } } + function collectWallpaperPaths() { + const paths = []; + for (var i = 0; i < wallpaperFolderModel.count; i++) { + const filePath = wallpaperFolderModel.get(i, "filePath"); + if (filePath) { + paths.push(filePath.toString().replace(/^file:\/\//, '')); + } + } + return paths; + } + Connections { target: wallpaperFolderModel function onCountChanged() { @@ -288,6 +369,7 @@ Item { setInitialSelection(); } updateSelectedFileName(); + thumbnailPreloader.paths = collectWallpaperPaths(); } } function onStatusChanged() { @@ -296,10 +378,16 @@ Item { setInitialSelection(); } updateSelectedFileName(); + thumbnailPreloader.paths = collectWallpaperPaths(); } } } + WallpaperThumbnailPreloader { + id: thumbnailPreloader + cacheSize: 256 + } + FolderListModel { id: wallpaperFolderModel @@ -310,7 +398,19 @@ Item { nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif", "*.exr"] showFiles: true showDirs: false - sortField: FolderListModel.Name + sortField: { + switch (root.sortBy) { + case "size": + return FolderListModel.Size; + case "modified": + return FolderListModel.Time; + case "type": + return FolderListModel.Type; + default: + return FolderListModel.Name; + } + } + sortReversed: !root.sortAscending folder: wallpaperDir ? "file://" + wallpaperDir.split('/').map(s => encodeURIComponent(s)).join('/') : "" } @@ -339,6 +439,7 @@ Item { } Column { + id: contentColumn anchors.fill: parent spacing: 0 @@ -376,6 +477,7 @@ Item { } model: { + root.gridRevision; // re-evaluate when sort order changes in place const startIndex = currentPage * itemsPerPage; const endIndex = Math.min(startIndex + itemsPerPage, wallpaperFolderModel.count); const items = []; @@ -513,7 +615,7 @@ Item { spacing: Theme.spacingS Item { - width: (parent.width - controlsRow.width - browseButton.width - Theme.spacingS) / 2 + width: (parent.width - controlsRow.width - sortButton.width - browseButton.width - Theme.spacingS * 3) / 2 height: parent.height } @@ -527,21 +629,42 @@ Item { iconName: "skip_previous" iconSize: 20 buttonSize: 32 - enabled: currentPage > 0 + enabled: totalPages > 1 opacity: enabled ? 1.0 : 0.3 + tooltipText: I18n.tr("Previous page") + tooltipSide: "top" onClicked: { - if (currentPage > 0) { - currentPage--; + if (totalPages > 1) { + currentPage = (currentPage - 1 + totalPages) % totalPages; } } } StyledText { + id: pageIndicator anchors.verticalCenter: parent.verticalCenter text: wallpaperFolderModel.count > 0 ? (wallpaperFolderModel.count === 1 ? I18n.tr("%1 wallpaper • %2 / %3").arg(wallpaperFolderModel.count).arg(currentPage + 1).arg(totalPages) : I18n.tr("%1 wallpapers • %2 / %3").arg(wallpaperFolderModel.count).arg(currentPage + 1).arg(totalPages)) : I18n.tr("No wallpapers") font.pixelSize: 14 - color: Theme.surfaceText + color: pageIndicatorMouseArea.containsMouse && pageIndicatorMouseArea.enabled ? Theme.primary : Theme.surfaceText opacity: 0.7 + + MouseArea { + id: pageIndicatorMouseArea + anchors.fill: parent + enabled: totalPages > 1 + hoverEnabled: true + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + sortMenu.visible = false; + pageJumpPopup.visible = !pageJumpPopup.visible; + } + onEntered: if (enabled) pageJumpTooltip.show(I18n.tr("Jump to page"), pageIndicator, 0, 0, "top") + onExited: pageJumpTooltip.hide() + } + + DankTooltipV2 { + id: pageJumpTooltip + } } DankActionButton { @@ -549,16 +672,34 @@ Item { iconName: "skip_next" iconSize: 20 buttonSize: 32 - enabled: currentPage < totalPages - 1 + enabled: totalPages > 1 opacity: enabled ? 1.0 : 0.3 + tooltipText: I18n.tr("Next page") + tooltipSide: "top" onClicked: { - if (currentPage < totalPages - 1) { - currentPage++; + if (totalPages > 1) { + currentPage = (currentPage + 1) % totalPages; } } } } + DankActionButton { + id: sortButton + anchors.verticalCenter: parent.verticalCenter + iconName: "sort" + iconSize: 20 + buttonSize: 32 + opacity: 0.7 + enabled: wallpaperFolderModel.count > 0 + tooltipText: I18n.tr("Sort wallpapers") + tooltipSide: "top" + onClicked: { + pageJumpPopup.visible = false; + sortMenu.visible = !sortMenu.visible; + } + } + DankActionButton { id: browseButton anchors.verticalCenter: parent.verticalCenter @@ -566,6 +707,8 @@ Item { iconSize: 20 buttonSize: 32 opacity: 0.7 + tooltipText: I18n.tr("Choose wallpaper folder") + tooltipSide: "top" onClicked: wallpaperBrowser.open() } } @@ -583,4 +726,119 @@ Item { } } } + + function jumpToPage(value) { + const n = parseInt(value); + if (!isNaN(n)) { + currentPage = Math.max(0, Math.min(totalPages - 1, n - 1)); + } + pageJumpPopup.visible = false; + } + + // Click anywhere outside an open overlay to dismiss it. + MouseArea { + anchors.fill: parent + z: 99 + visible: sortMenu.visible || pageJumpPopup.visible + enabled: visible + onClicked: closeOverlays() + } + + BackdropBlur { + visible: sortMenu.visible + z: 100 + width: sortMenu.width + height: sortMenu.height + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: Theme.spacingM + anchors.bottomMargin: 56 + radius: Theme.cornerRadius + sourceItem: contentColumn + } + + FileBrowserSortMenu { + id: sortMenu + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: Theme.spacingM + anchors.bottomMargin: 56 + z: 101 + surfaceColor: Theme.readableSurface + sortBy: root.sortBy + sortAscending: root.sortAscending + onSortBySelected: value => { + root.sortBy = value; + root.persistSort(); + } + onSortOrderSelected: ascending => { + root.sortAscending = ascending; + root.persistSort(); + } + } + + BackdropBlur { + visible: pageJumpPopup.visible + z: 100 + width: pageJumpPopup.width + height: pageJumpPopup.height + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 56 + radius: Theme.cornerRadius + sourceItem: contentColumn + } + + StyledRect { + id: pageJumpPopup + width: 180 + height: jumpColumn.height + Theme.spacingM * 2 + color: Theme.readableSurface + radius: Theme.cornerRadius + border.color: Theme.outlineMedium + border.width: 1 + visible: false + z: 101 + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 56 + + onVisibleChanged: { + if (visible) { + pageJumpField.text = (root.currentPage + 1).toString(); + pageJumpField.forceActiveFocus(); + pageJumpField.selectAll(); + } + } + + Column { + id: jumpColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.spacingM + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Jump to page (1 - %1)").arg(root.totalPages) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceTextMedium + font.weight: Font.Medium + } + + DankTextField { + id: pageJumpField + width: parent.width + placeholderText: "1 - " + root.totalPages + maximumLength: 6 + topPadding: Theme.spacingS + bottomPadding: Theme.spacingS + validator: IntValidator { + bottom: 1 + top: root.totalPages + } + onAccepted: root.jumpToPage(text) + } + } + } } diff --git a/quickshell/Widgets/BackdropBlur.qml b/quickshell/Widgets/BackdropBlur.qml new file mode 100644 index 00000000..c4450a1f --- /dev/null +++ b/quickshell/Widgets/BackdropBlur.qml @@ -0,0 +1,50 @@ +import QtQuick +import QtQuick.Effects +import qs.Common +import qs.Services + +// Frosted-glass backdrop: blurs the region of sourceItem directly behind the item +Item { + id: root + + property Item sourceItem: null + property real radius: Theme.cornerRadius + property real blurAmount: 1.0 + property int blurMax: 96 + + readonly property bool blurActive: visible && BlurService.enabled + + ShaderEffectSource { + id: snapshot + anchors.fill: parent + sourceItem: root.sourceItem + sourceRect: { + if (!root.sourceItem) + return Qt.rect(0, 0, 0, 0); + const p = root.mapToItem(root.sourceItem, 0, 0); + return Qt.rect(p.x, p.y, root.width, root.height); + } + live: root.blurActive + hideSource: false + visible: false + } + + MultiEffect { + anchors.fill: parent + source: snapshot + visible: root.blurActive + blurEnabled: root.blurActive + blurMax: root.blurMax + blur: root.blurAmount + maskEnabled: true + maskSource: maskRect + } + + Rectangle { + id: maskRect + anchors.fill: parent + radius: root.radius + visible: false + layer.enabled: true + } +} diff --git a/quickshell/Widgets/WallpaperThumbnailPreloader.qml b/quickshell/Widgets/WallpaperThumbnailPreloader.qml new file mode 100644 index 00000000..32befdfa --- /dev/null +++ b/quickshell/Widgets/WallpaperThumbnailPreloader.qml @@ -0,0 +1,94 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common + +// Preload the CachingImage disk cache for a folder of wallpapers via ffmpegthumbnailer +// so the switcher grid renders instantly. No-op (graceful fallback) if the tool is absent. +Item { + id: root + + visible: false + + property var paths: [] + property int cacheSize: 256 + property bool autoStart: true + property int maxConcurrent: 3 + + property int _active: 0 + property var _queue: [] + property int _toolState: -1 // -1 unknown, 0 unavailable, 1 available + + onPathsChanged: if (autoStart) + preload() + + // Must match djb2Hash + cachePath in Widgets/CachingImage.qml. + function _hash(str) { + if (!str) + return ""; + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) + str.charCodeAt(i); + hash = hash & 0x7FFFFFFF; + } + return hash.toString(16).padStart(8, '0'); + } + + function _cachePathFor(path) { + const hash = _hash(path); + if (!hash) + return ""; + return `${Paths.stringify(Paths.imagecache)}/${hash}@${cacheSize}x${cacheSize}.png`; + } + + function _isAnimated(path) { + const lower = path.toLowerCase(); + return lower.endsWith(".gif") || lower.endsWith(".webp"); + } + + function preload() { + if (!paths || paths.length === 0 || _toolState === 0) + return; + if (_toolState === -1) { + Proc.runCommand("wallpaperThumbToolCheck", ["sh", "-c", "command -v ffmpegthumbnailer"], function (out, code) { + root._toolState = code === 0 ? 1 : 0; + if (root._toolState === 1) + root._start(); + }); + return; + } + _start(); + } + + function _start() { + Paths.mkdir(Paths.imagecache); + const q = []; + for (let i = 0; i < paths.length; i++) { + const p = paths[i]; + if (!p || p.startsWith("#") || _isAnimated(p)) + continue; + q.push(p); + } + _queue = q; + for (let i = 0; i < maxConcurrent; i++) + _pump(); + } + + function _pump() { + if (_queue.length === 0) + return; + const path = _queue.shift(); + const cachePath = _cachePathFor(path); + if (!cachePath) { + _pump(); + return; + } + _active++; + // One process per file: skip if already cached, otherwise generate. + const script = "test -f \"$1\" || ffmpegthumbnailer -i \"$2\" -o \"$1\" -s " + cacheSize; + Proc.runCommand(null, ["sh", "-c", script, "thumb", cachePath, path], function (out, code) { + root._active--; + root._pump(); + }, 0, 20000); + } +}